Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Nextgen Playground #62

Draft
wants to merge 71 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
71 commits
Select commit Hold shift + click to select a range
7f86e68
some basic implementation/tests passing
AlecRosenbaum Aug 3, 2021
186f93a
field def
AlecRosenbaum Aug 3, 2021
c7085f7
field def
AlecRosenbaum Aug 3, 2021
4110b13
support accepts when parsing
AlecRosenbaum Aug 3, 2021
cbffc30
support accepts when parsing
AlecRosenbaum Aug 3, 2021
0c169b9
support serialize_to
AlecRosenbaum Aug 3, 2021
704ac91
support custom serialization functions
AlecRosenbaum Aug 3, 2021
473e4fb
support autodefined annotated fields
AlecRosenbaum Aug 3, 2021
6a1d8a3
test and fix required fields
AlecRosenbaum Aug 3, 2021
4842cdd
refactor and test nullability, fix annotation autodef
AlecRosenbaum Aug 3, 2021
4bbf62c
add simple string field
AlecRosenbaum Aug 3, 2021
a693bc1
add optional context support
AlecRosenbaum Aug 4, 2021
72ce654
add optional annotation support, add complex test, refactor nullability
AlecRosenbaum Aug 5, 2021
40f7cf9
add intra-schema inter-field dependency support
AlecRosenbaum Aug 5, 2021
1fafbc0
support value-optional fields, add field dependency test
AlecRosenbaum Aug 5, 2021
e4110f1
format with black
AlecRosenbaum Aug 5, 2021
48d011a
(minor) simplify UpdateLead schema in test
AlecRosenbaum Aug 5, 2021
e46766e
test and fix fields that depend on errored fields
AlecRosenbaum Aug 5, 2021
49d43f7
split new stuff up into better modules
AlecRosenbaum Aug 6, 2021
d9c58ad
minor cleanup, move stuff around a bit to not be so nested
AlecRosenbaum Aug 6, 2021
12f26ad
format tests, add docstrings and minor changes to field + schema
AlecRosenbaum Aug 6, 2021
fa2648f
refactor nullabilityto support different omitted/explit null behavior
AlecRosenbaum Aug 6, 2021
bc875aa
listfield implementation
AlecRosenbaum Aug 9, 2021
7865e2f
support + test chaining list field validation
AlecRosenbaum Aug 9, 2021
b132fd5
add tests around context on list fields
AlecRosenbaum Aug 9, 2021
bfcf1a6
add nested schema field support
AlecRosenbaum Aug 9, 2021
8d5a5cf
add enumfield support
AlecRosenbaum Aug 9, 2021
1f24117
add autodef support for basic list fields
AlecRosenbaum Aug 9, 2021
8ed3920
include parent dependencies, refactor field to not always require bei…
AlecRosenbaum Aug 9, 2021
230cc67
refactor field to avoid the needing simple_field, remove simple_field
AlecRosenbaum Aug 9, 2021
dac11c7
allow field functions to include the 'self' param
AlecRosenbaum Aug 10, 2021
86237d5
allow field parents to be Field instances instead of just callables
AlecRosenbaum Aug 10, 2021
2ce77a0
add regex field
AlecRosenbaum Aug 10, 2021
0293265
add datetime field
AlecRosenbaum Aug 10, 2021
ec2ff2d
add boolfield
AlecRosenbaum Aug 10, 2021
4f56530
port/implement url field
AlecRosenbaum Aug 10, 2021
4365905
minor linting fixes
AlecRosenbaum Aug 10, 2021
03cd0a8
resolve another linter issue
AlecRosenbaum Aug 10, 2021
37ca0b2
mypy mostly passing
AlecRosenbaum Aug 11, 2021
70908e2
remove unused imports
AlecRosenbaum Aug 11, 2021
4d3cb60
Refactor schema to be inheritance-based
AlecRosenbaum Aug 13, 2021
ee2be60
drop old schema decorator, fix formatting
AlecRosenbaum Aug 13, 2021
57ddd45
fix field type definitions
AlecRosenbaum Aug 13, 2021
8492203
WIP, still trying to get the typing right
AlecRosenbaum Aug 27, 2021
4a3e6a1
fix annotations
AlecRosenbaum Nov 18, 2021
3821373
remove unused import
AlecRosenbaum Nov 18, 2021
8ce54d7
refactor schema into schema definition + declarative schema
AlecRosenbaum Nov 18, 2021
9761020
fix readme usage of clean
AlecRosenbaum Nov 18, 2021
21f54b7
support using old-style fields on new-style schemas
AlecRosenbaum Nov 25, 2021
8dd80bf
don't change black settings for the whole project
AlecRosenbaum Nov 25, 2021
c0008db
black with new settings
AlecRosenbaum Nov 25, 2021
c2646ed
fix True comparison
AlecRosenbaum Nov 25, 2021
671d3c6
add requirements for Protocol support on 3.6 + 3.7
AlecRosenbaum Nov 25, 2021
d642898
move generic to typing extensions import too
AlecRosenbaum Nov 25, 2021
dfe5c79
try using slots fo rmake value work on older py3 versions
AlecRosenbaum Nov 25, 2021
c57ecf6
actually, don't. Just drop 3.6, which is EOL in a few weeks anyway
AlecRosenbaum Nov 25, 2021
27f726f
fix get_origin / get_args imports on python 3.7
AlecRosenbaum Nov 25, 2021
178a9e1
format files
AlecRosenbaum Nov 25, 2021
6f8f05c
add 3.10 testing
AlecRosenbaum Nov 25, 2021
08de536
add basic support for parsing schema definitions from attrs classes
AlecRosenbaum Nov 26, 2021
2ea9c16
support schema class from an attrs class
AlecRosenbaum Nov 26, 2021
be1f06e
flake8 and black cleanup
AlecRosenbaum Nov 26, 2021
da6accd
don't overlap with declarative schema class
AlecRosenbaum Nov 26, 2021
4651378
Attrins AttrsSchema tances instead of classessub
AlecRosenbaum Nov 26, 2021
43018e0
add typing, fix bugs on attrs ext
AlecRosenbaum Nov 26, 2021
68f2f56
implement emailfield, add test cases for basic example from the readme
AlecRosenbaum Dec 7, 2021
e99ef54
update README with tweak from test
AlecRosenbaum Dec 7, 2021
8e4854b
add explicit nullability example test case, tweak serialization and f…
AlecRosenbaum Dec 7, 2021
f123e6d
fix linting errors
AlecRosenbaum Dec 7, 2021
d9f0926
remove unused import
AlecRosenbaum Dec 7, 2021
41668d7
bump the version, export chausie from top-level init
AlecRosenbaum Jan 7, 2022
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .circleci/config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,10 @@ workflows:
matrix:
parameters:
python:
- "3.6"
- "3.7"
- "3.8"
- "3.9"
- "3.10"
- black

jobs:
Expand Down
4 changes: 3 additions & 1 deletion cleancat/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
URL,
ValidationError,
)
from . import chausie

__all__ = [
'Bool',
Expand All @@ -42,6 +43,7 @@
'TrimmedString',
'URL',
'ValidationError',
'chausie',
]

__version__ = '1.0.0'
__version__ = '1.1.0'
165 changes: 165 additions & 0 deletions cleancat/chausie/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
CleanChausie
========

Data validation and transformation library for Python. Successor to CleanCat.

Key features:
* Operate on/with type-checked objects that have good IDE/autocomplete support
* Annotation-based declarations for simple fields
* Composable/reusable fields and field validation logic
* Support (but not require) passing around a context (to avoid global state)
- Context pattern is compatible with explicit sqlalchemy-based session management. i.e. pass in a session when validating
* Cleanly support intra-schema field dependencies (i.e. one field can depend on the validated value of another)
* Explicit nullability/omission parameters
* Errors returned for multiple fields at a time, with field attribution

## CleanChausie By Example

### Basic example in Flask

This is a direct port of the example from the OG cleancat README.

This shows:
* Annotation-based declarations for simple fields.
* Type-checked objects (successful validation results in initialized instances of the schema)

```python
from typing import List
from cleancat.chausie.field import (
field, emailfield, listfield, urlfield, ValidationError,
)
from cleancat.chausie.schema import Schema
from flask import app, request, jsonify

class JobApplication(Schema):
first_name: str
last_name: str
email: str = field(emailfield())
urls: List[str] = field(listfield(urlfield(default_scheme='http://')))

@app.route('/job_application', methods=['POST'])
def test_view():
result = JobApplication.clean(request.json)
if isinstance(result, ValidationError):
return jsonify({'errors': [{'msg': e.msg, 'field': e.field} for e in result.errors] }), 400

# Now "result" has the validated data, in the form of a `JobApplication` instance.
assert isinstance(result, JobApplication)
name = f'{result.first_name} {result.last_name}'
```

### Explicit Nullability

TODO revisit omission defaults so that they match the annotation

```python
from typing import Optional, Union
from cleancat.chausie.consts import OMITTED
from cleancat.chausie.field import field, strfield, Optional as CCOptional, Required
from cleancat.chausie.schema import Schema

class NullabilityExample(Schema):
# auto defined based on annotations
nonnull_required: str
nullable_omittable: Optional[str]

# manually specified
nonnull_omittable: Union[str, OMITTED] = field(strfield, nullability=CCOptional(allow_none=False))
nullable_required: Optional[str] = field(strfield, nullability=Required(allow_none=True))
```

### Composable/Reusable Fields

```python
from typing import Union
from cleancat.chausie.field import field, Field, strfield, intfield, Error
from cleancat.chausie.schema import Schema

@field(parents=(strfield,))
def trimmed_string(value: str) -> str:
return value.strip()

def max_val(max_value: int) -> Field:
@field()
def _max_val(value: int) -> Union[int, Error]:
if value > max_value:
return Error(msg=f'value is above allowed max of {max_value}')
return value
return _max_val

def min_val(min_value: int) -> Field:
@field()
def _min_val(value: int) -> Union[int, Error]:
if value < min_value:
return Error(msg=f'value is below allowed min of {min_value}')
return value
return _min_val

def constrained_int(min: int, max: int) -> Field:
return field(parents=(intfield, min_val(min), max_val(max)))()

class ReusableFieldsExampleSchema(Schema):
first_name: str = trimmed_string
age: int = field(parents=(intfield, min_val(0)))()
score: int = constrained_int(min=0, max=100)
```

### Context Support

```python
import attrs
from cleancat.chausie.field import field, strfield
from cleancat.chausie.schema import Schema

class MyModel: # some ORM model
id: str
created_by: 'User'

@attrs.frozen
class Context:
authenticated_user: 'User' # the User making a request
session: 'Session' # active ORM Session

class ContextExampleSchema(Schema):
@field(parents=(strfield,), accepts=('id',))
def obj(self, value: str, context: Context) -> MyModel:
return (
context.session
.query(MyModel)
.filter(MyModel.created_by == context.authenticated_user.id)
.filter(MyModel.id == value)
)

with atomic() as session:
result = ContextExampleSchema.clean(
data={'id': 'mymodel_primarykey'},
context=Context(authenticated_user=EXAMPLE_USER, session=session)
)
assert isinstance(result, ContextExampleSchema)
assert isinstance(result.obj, MyModel)
```


### Intra-schema Field dependencies

```python
from cleancat.chausie.field import field
from cleancat.chausie.schema import Schema

class DependencyExampleSchema(Schema):
a: str
b: str

@field()
def a_and_b(self, a: str, b: str) -> str:
return f'{a}::{b}'


result = DependencyExampleSchema.clean(
data={'a': 'a', 'b': 'b'},
)
assert isinstance(result, DependencyExampleSchema)
assert result.a_and_b == 'a::b'
```

### Per-Field Errors
Empty file added cleancat/chausie/__init__.py
Empty file.
24 changes: 24 additions & 0 deletions cleancat/chausie/consts.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
class OMITTED:
"""used as singleton for omitted values in validation"""

def __repr__(self) -> str:
return "omitted"

def __str__(self) -> str:
return "omitted"


omitted = OMITTED()


class EMPTY:
"""used as singleton for omitted options/kwargs"""

def __repr__(self) -> str:
return "empty"

def __str__(self) -> str:
return "empty"


empty = EMPTY()
Empty file.
87 changes: 87 additions & 0 deletions cleancat/chausie/ext/attrs.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
from typing import Any, Dict, Generic, Type, TypeVar, Union
import attr
from cleancat.chausie.consts import empty
from cleancat.chausie.field import (
Error,
Field,
Optional,
Required,
ValidationError,
)
from cleancat.chausie.schema import field_def_from_annotation

from cleancat.chausie.schema_definition import (
SchemaDefinition,
clean as clean_schema,
)


def convert_attrib_to_field(attrib: attr.Attribute) -> Field:
"""Convert attr Attribute to cleanchausie Field."""
if attrib.type:
field = field_def_from_annotation(attrib.type)
assert field
else:
field = Field(
validators=(),
accepts=(),
nullability=Required(),
depends_on=(),
serialize_to=None,
serialize_func=lambda v: v,
)

if attrib.default:
nullability = Optional(omitted_value=attrib.default)
field = attr.evolve(field, nullability=nullability)

if attrib.validator:

def _validate(value):
try:
# no ability to validate against other values on the
# instance (since no instance exists yet), but should
# support simple validation cases.
attrib.validator(None, attrib, value)
return value
except Exception as e:
return Error(msg=str(e))

new_validators = (field.validators or ()) + (_validate,)
field = attr.evolve(field, validators=new_validators)

return field


def schema_def_from_attrs_class(attrs_class: Type) -> SchemaDefinition:
return SchemaDefinition(
fields={
attr_field.name: convert_attrib_to_field(attr_field)
for attr_field in attr.fields(attrs_class)
}
)


T = TypeVar('T')


@attr.frozen
class AttrsSchema(Generic[T]):
attrs_class: Type[T]
schema_definition: SchemaDefinition

def clean(
self, data: Dict, context: Any = empty
) -> Union[T, ValidationError]:
result = clean_schema(self.schema_definition, data, context)
if isinstance(result, ValidationError):
return result
else:
return self.attrs_class(**result)


def schema_for_attrs_class(attrs_class: Type[T]) -> AttrsSchema[T]:
schema_definition = schema_def_from_attrs_class(attrs_class=attrs_class)
return AttrsSchema(
attrs_class=attrs_class, schema_definition=schema_definition
)
Loading