From 851b60f3e08ae37b6548cc42e0e5407934f0a0d9 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Wed, 15 Sep 2021 11:52:55 +0100 Subject: [PATCH] Version 0.3.0 (#98) Co-authored-by: Amin Alaee --- docs/examples.md | 64 +-- docs/fields.md | 29 +- docs/forms.md | 52 +- docs/index.md | 38 +- docs/json_schema.md | 29 +- docs/references.md | 94 ++-- docs/schemas.md | 113 ++-- docs/tokenized_errors.md | 14 +- examples/api/app.py | 27 +- examples/form/app.py | 49 +- mkdocs.yml | 3 + scripts/check | 2 +- setup.cfg | 5 - tests/test_base.py | 22 +- tests/test_definitions.py | 169 +++--- tests/test_fields.py | 16 +- tests/test_forms.py | 42 +- tests/test_json_schema.py | 43 +- tests/test_schemas.py | 544 ++++++++++--------- tests/tokenize/test_validate_json.py | 13 +- tests/tokenize/test_validate_yaml.py | 13 +- typesystem/__init__.py | 6 +- typesystem/composites.py | 22 +- typesystem/fields.py | 68 +-- typesystem/formats.py | 2 +- typesystem/forms.py | 50 +- typesystem/json_schema.py | 61 ++- typesystem/schemas.py | 295 ++++------ typesystem/tokenize/positional_validation.py | 2 +- typesystem/tokenize/tokenize_json.py | 2 +- typesystem/tokenize/tokenize_yaml.py | 6 +- 31 files changed, 944 insertions(+), 951 deletions(-) diff --git a/docs/examples.md b/docs/examples.md index d5af7e9..88df142 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -15,18 +15,21 @@ uvicorn **app.py** ```python +import typesystem +import uvicorn from starlette.applications import Starlette from starlette.responses import JSONResponse from starlette.routing import Route -import typesystem -import uvicorn users = [] -class User(typesystem.Schema): - username = typesystem.String(max_length=100) - is_admin = typesystem.Boolean(default=False) +user_schema = typesystem.Schema( + fields={ + "username": typesystem.String(max_length=100), + "is_admin": typesystem.Boolean(default=False), + } +) async def list_users(request): @@ -39,7 +42,7 @@ async def add_user(request): if errors: return JSONResponse(dict(errors), status_code=400) users.append(user) - return JSONResponse(dict(user)) + return JSONResponse(user) app = Starlette(debug=True, routes=[ @@ -73,13 +76,13 @@ uvicorn **app.py** ```python +import typesystem +import uvicorn from starlette.applications import Starlette from starlette.responses import RedirectResponse -from starlette.routing import Route, Mount +from starlette.routing import Mount, Route from starlette.staticfiles import StaticFiles from starlette.templating import Jinja2Templates -import typesystem -import uvicorn forms = typesystem.Jinja2Forms(package="bootstrap4") templates = Jinja2Templates(directory="templates") @@ -87,42 +90,41 @@ statics = StaticFiles(directory="statics", packages=["bootstrap4"]) bookings = [] -class BookingSchema(typesystem.Schema): - start_date = typesystem.Date(title="Start date") - end_date = typesystem.Date(title="End date") - room = typesystem.Choice( - title="Room type", - choices=[ - ("double", "Double room"), - ("twin", "Twin room"), - ("single", "Single room"), - ], - ) - include_breakfast = typesystem.Boolean(title="Include breakfast", default=False) - - def __str__(self): - breakfast = ( - "(with breakfast)" if self.include_breakfast else "(without breakfast)" - ) - return f"Booking for {self.room} from {self.start_date} to {self.end_date}" +booking_schema = typesystem.Schema( + fields={ + "start_date": typesystem.Date(title="Start date"), + "end_date": typesystem.Date(title="End date"), + "room": typesystem.Choice( + title="Room type", + choices=[ + ("double", "Double room"), + ("twin", "Twin room"), + ("single", "Single room"), + ], + ), + "include_breakfast": typesystem.Boolean( + title="Include breakfast", default=False + ), + } +) async def homepage(request): - form = forms.Form(BookingSchema) + form = forms.create_form(booking_schema) context = {"request": request, "form": form, "bookings": bookings} return templates.TemplateResponse("index.html", context) async def make_booking(request): data = await request.form() - booking, errors = BookingSchema.validate_or_error(data) + booking, errors = booking_schema.validate_or_error(data) if errors: - form = forms.Form(BookingSchema, values=data, errors=errors) + form = forms.create_form(booking_schema) context = {"request": request, "form": form, "bookings": bookings} return templates.TemplateResponse("index.html", context) bookings.append(booking) - return RedirectResponse(request.url_for("homepage")) + return RedirectResponse(request.url_for("homepage"), status_code=303) app = Starlette( diff --git a/docs/fields.md b/docs/fields.md index 4cfcd08..f534d53 100644 --- a/docs/fields.md +++ b/docs/fields.md @@ -1,10 +1,20 @@ -Fields are usually declared as attributes on schema classes: +Fields are passed as a dictionary to the Schema classes: ```python -class Organisation(typesystem.Schema): - name = typesystem.String(title="Name", max_length=100) - date_created = typesystem.Date(title="Date created", default=datetime.date.today) - owner = typesystem.Reference(to=User, allow_null=True) +import typesystem + +user_schema = typesystem.Schema(fields={"name": typesystem.String()}) + +definitions = typesystem.Definitions() +definitions["User"] = user_schema + +organization_schema = typesystem.Schema( + fields={ + "name": typesystem.String(title="Name", max_length=100), + "date_created": typesystem.Date(title="Date created", default=datetime.date.today), + "owner": typesystem.Reference(to="User", allow_null=True, definitions=definitions), + } +) ``` Fields are always *required* in inputs, unless a *default* value is set. @@ -20,6 +30,7 @@ All fields support the following arguments. * `description` - A string describing the input. **Default: `None`** * `default` - A value to be used if no input is provided for this field. May be a callable, such as `datetime.datetime.now`. **Default: `NO_DEFAULT`** * `allow_null` - A boolean determining if `None` values are valid. **Default: `False`** +* `read_only` - A boolean determining if field should be considered as read-only, this is usually used in form rendering. **Default: `False`** ## Using fields directly @@ -60,6 +71,7 @@ For example: `username = typesystem.String(max_length=100)` * `min_length` - A minimum number of characters that valid input stings may contain. **Default: `None`** * `pattern` - A regular expression that must match. This can be either a string or a compiled regular expression. E.g. `pattern="^[A-Za-z]+$"` **Default: `None`** * `format` - A string used to indicate a semantic type, such as `"email"`, `"url"`, or `"color"`. **Default: `None`** +* `coerce_types` - A boolean determining if type casting should be done, E.g. changing `None` to `""` if `allow_blank`. **Default: `True`** ### Text @@ -181,7 +193,7 @@ extra_metadata = typesystem.Object(properties=typesystem.String(max_length=100)) Schema classes implement their validation behaviour by generating an `Object` field, and automatically determining the `properties` and `required` attributes. -You'll typically want to use `typesystem.Reference(to=SomeSchema)` rather than +You'll typically want to use `typesystem.Reference(to="SomeSchema")` rather than using the `Object` field directly, but it can be useful if you have a more complex data structure that you need to validate. @@ -201,12 +213,13 @@ Used to reference a nested schema. For example: ```python -owner = typesystem.Reference(to=User, allow_null=True) +owner = typesystem.Reference(to="User", allow_null=True, definitions=definitions) ``` **Arguments**: -* `to` - A schema class or field instance. **Required** +* `to` - Name of schema defined in definitions. **Required** +* `definitions` - `Definitions` instance. **Required** ## Other data types diff --git a/docs/forms.md b/docs/forms.md index f88d5c4..a899983 100644 --- a/docs/forms.md +++ b/docs/forms.md @@ -8,17 +8,21 @@ import typesystem forms = typesystem.Jinja2Forms(package="typesystem") # Use the default templates. -class BookingSchema(typesystem.Schema): - start_date = typesystem.Date(title="Start date") - end_date = typesystem.Date(title="End date") - room = typesystem.Choice(title="Room type", choices=[ - ('double', 'Double room'), - ('twin', 'Twin room'), - ('single', 'Single room') - ]) - include_breakfast = typesystem.Boolean(title="Include breakfast", default=False) - -form = forms.Form(BookingSchema) +booking_schema = typesystem.Schema( + fields={ + "start_date": typesystem.Date(title="Start date"), + "end_date": typesystem.Date(title="End date"), + "room": typesystem.Choice(title="Room type", choices=[ + ('double', 'Double room'), + ('twin', 'Twin room'), + ('single', 'Single room') + ]), + "include_breakfast": typesystem.Boolean(title="Include breakfast", default=False), + + } +) + +form = forms.create_form(booking_schema) print(form) ``` @@ -34,26 +38,26 @@ Notice that only the fields in the form are rendered. The surrounding `
`, ```html - + - + - + - + - + - @@ -63,10 +67,10 @@ Notice that only the fields in the form are rendered. The surrounding ``, - + - + ``` @@ -92,15 +96,7 @@ We can include values in a form like so: ```python initial_values = {'room': 'double', 'include_breakfast': True} -form = forms.Form(BookingSchema, values=initial_values) -``` - -We can also include validation errors: - -```python -booking, errors = BookingSchema.validate_or_error(data) -if errors: - form = forms.Form(BookingSchema, values=data, errors=errors) +form = forms.create_form(booking_schema, values=initial_values) ``` ## Customizing field rendering diff --git a/docs/index.md b/docs/index.md index 5fc2741..78ef8e9 100644 --- a/docs/index.md +++ b/docs/index.md @@ -50,30 +50,30 @@ $ pip3 install typesystem[pyyaml] ```python import typesystem -class Artist(typesystem.Schema): - name = typesystem.String(max_length=100) - -class Album(typesystem.Schema): - title = typesystem.String(max_length=100) - release_date = typesystem.Date() - artist = typesystem.Reference(Artist) - -album = Album.validate({ +artist_schema = typesystem.Schema( + fields={ + "name": typesystem.String(max_length=100) + } +) + +definitions = typesystem.Definitions() +definitions["Artist"] = artist_schema + +album_schema = typesystem.Schema( + fields={ + "title": typesystem.String(max_length=100), + "release_date": typesystem.Date(), + "artist": typesystem.Reference("Artist", definitions=definitions) + } +) + +album = album_schema.validate({ "title": "Double Negative", "release_date": "2018-09-14", "artist": {"name": "Low"} }) print(album) -# Album(title='Double Negative', release_date=datetime.date(2018, 9, 14), artist=Artist(name='Low')) - -print(album.release_date) -# datetime.date(2018, 9, 14) - -print(album['release_date']) -# '2018-09-14' - -print(dict(album)) # {'title': 'Double Negative', 'release_date': '2018-09-14', 'artist': {'name': 'Low'}} ``` @@ -82,7 +82,7 @@ print(dict(album)) There are plenty of other great validation libraries for Python out there, including [Marshmallow](https://github.com/marshmallow-code/marshmallow), [Schematics](https://github.com/schematics/schematics), -[Voluptuous](https://github.com/alecthomas/voluptuous), and many others. +[Voluptuous](https://github.com/alecthomas/voluptuous), [Pydantic](https://github.com/samuelcolvin/pydantic/) and many others. TypeSystem exists because I want a data validation library that offers first-class support for: diff --git a/docs/json_schema.md b/docs/json_schema.md index 25c8578..d3f713f 100644 --- a/docs/json_schema.md +++ b/docs/json_schema.md @@ -1,4 +1,4 @@ -TypeSystem can convert Schema classes or Field instances to/from JSON Schema. +TypeSystem can convert Schema or Field instances to/from JSON Schema. !!! note TypeSystem only supports `$ref` pointers that use the standard "definitions" @@ -15,20 +15,21 @@ Let's define a schema, and dump it out into a JSON schema document: import json import typesystem -class BookingSchema(typesystem.Schema): - start_date = typesystem.Date(title="Start date") - end_date = typesystem.Date(title="End date") - room = typesystem.Choice( - title="Room type", - choices=[ - ("double", "Double room"), - ("twin", "Twin room"), - ("single", "Single room"), - ], - ) - include_breakfast = typesystem.Boolean(title="Include breakfast", default=False) +booking_schema = typesystem.Schema( + fields={ + "start_date": typesystem.Date(title="Start date"), + "end_date": typesystem.Date(title="End date"), + "room": typesystem.Choice(title="Room type", choices=[ + ('double', 'Double room'), + ('twin', 'Twin room'), + ('single', 'Single room') + ]), + "include_breakfast": typesystem.Boolean(title="Include breakfast", default=False), -schema = typesystem.to_json_schema(BookingSchema) + } +) + +schema = typesystem.to_json_schema(booking_schema) print(json.dumps(schema, indent=4)) ``` diff --git a/docs/references.md b/docs/references.md index f74230a..4f204de 100644 --- a/docs/references.md +++ b/docs/references.md @@ -5,56 +5,25 @@ The simplest way to use a reference, is with a schema class as a the target. ```python import typesystem -class Artist(typesystem.Schema): - name = typesystem.String(max_length=100) - -class Album(typesystem.Schema): - title = typesystem.String(max_length=100) - release_date = typesystem.Date() - artist = typesystem.Reference(to=Artist) -``` - -Using a schema class directly might not always be possible. If you need to -to support back-references or cyclical references, you can use a string-literal -reference and provide a `SchemaDefinitions` instance, which is a dictionary-like -object providing an index of reference lookups. - -```python -import typesystem - -definitions = typesystem.SchemaDefinitions() - -class Artist(typesystem.Schema): - name = typesystem.String(max_length=100) - -class Person(typesystem.Schema): - name = typesystem.String(max_length=100) - release_date = typesystem.Date() - artist = typesystem.Reference(to='Artist', definitions=definitions) - -definitions['Artist'] = Artist -definitions['Person'] = Person +artist_schema = typesystem.Schema( + fields={ + "name": typesystem.String(max_length=100) + } +) + +definitions = typesystem.Definitions() +definitions["Artist"] = artist_schema + +album_schema = typesystem.Schema( + fields={ + "title": typesystem.String(max_length=100), + "release_date": typesystem.Date(), + "artist": typesystem.Reference(to="Artist", definitions=definitions), + } +) ``` -A shorthand for including a schema class in the definitions index, and for -setting the `definitions` on any Reference fields, is to declare schema -classes with the `definitions` keyword argument, like so: - -```python -import typesystem - -definitions = typesystem.SchemaDefinitions() - -class Artist(typesystem.Schema, definitions=definitions): - name = typesystem.String(max_length=100) - -class Person(typesystem.Schema, definitions=definitions): - name = typesystem.String(max_length=100) - release_date = typesystem.Date() - artist = typesystem.Reference(to='Artist') -``` - -Registering schema classes against a `SchemaDefinitions` instance is particularly +Registering schema instances against a `Definitions` instance is particularly useful if you're using JSON schema to document the input and output types of a Web API, since you can easily dump all the type definitions: @@ -62,15 +31,24 @@ a Web API, since you can easily dump all the type definitions: import json import typesystem -definitions = typesystem.SchemaDefinitions() +definitions = typesystem.Definitions() -class Artist(typesystem.Schema, definitions=definitions): - name = typesystem.String(max_length=100) +artist_schema = typesystem.Schema( + fields={ + "name": typesystem.String(max_length=100) + } +) -class Person(typesystem.Schema, definitions=definitions): - name = typesystem.String(max_length=100) - release_date = typesystem.Date() - artist = typesystem.Reference(to='Artist') +album_schema = typesystem.Schema( + fields={ + "title": typesystem.String(max_length=100), + "release_date": typesystem.Date(), + "artist": typesystem.Reference(to="Artist", definitions=definitions), + } +) + +definitions["Artist"] = artist_schema +definitions["Album"] = album_schema document = typesystem.to_json_schema(definitions) print(json.dumps(document, indent=4)) @@ -89,10 +67,10 @@ print(json.dumps(document, indent=4)) # "name" # ] # }, -# "Person": { +# "Album": { # "type": "object", # "properties": { -# "name": { +# "title": { # "type": "string", # "minLength": 1, # "maxLength": 100 @@ -107,7 +85,7 @@ print(json.dumps(document, indent=4)) # } # }, # "required": [ -# "name", +# "title", # "release_date", # "artist" # ] diff --git a/docs/schemas.md b/docs/schemas.md index 5ccb4f5..e398602 100644 --- a/docs/schemas.md +++ b/docs/schemas.md @@ -1,15 +1,24 @@ -Let's start by defining some schema classes. +Let's start by defining some schemas. ```python import typesystem -class Artist(typesystem.Schema): - name = typesystem.String(max_length=100) +artist_schema = typesystem.Schema( + fields={ + "name": typesystem.String(max_length=100) + } +) -class Album(typesystem.Schema): - title = typesystem.String(max_length=100) - release_date = typesystem.Date() - artist = typesystem.Reference(Artist) +definitions = typesystem.Definitions() +definitions["Artist"] = artist_schema + +album_schema = typesystem.Schema( + fields={ + "title": typesystem.String(max_length=100), + "release_date": typesystem.Date(), + "artist": typesystem.Reference("Artist", definitions=definitions) + } +) ``` We've got some incoming user data that we'd like to validate against our schema. @@ -25,10 +34,10 @@ data = { We can validate the data against a Schema by using `.validate(data)`. ```python -album = Album.validate(data) +album = album_schema.validate(data) ``` -If validation succeeds, this will return an `Album` instance. +If validation succeeds, this will return an `dict`. If validation fails, a `ValidationError` will be raised. @@ -36,7 +45,7 @@ Alternatively we can use `.validate_or_error(data)`, which will return a two-tuple of `(value, error)`. Either one of `value` or `error` will be `None`. ```python -album, error = Album.validate_or_error(data) +album, error = album_schema.validate_or_error(data) if error: ... else: @@ -53,7 +62,7 @@ invalid_data = { 'release_date': '2018.09.14', 'artist': {'name': 'x' * 1000} } -album, error = Album.validate_or_error(invalid_data) +album, error = album_schema.validate_or_error(invalid_data) print(dict(error)) # {'release_date': 'Must be a valid date format.', 'artist': {'name': 'Must have no more than 100 characters.'}} @@ -65,7 +74,7 @@ If you want more precise information about exactly what error messages exist, you can access each individual message with `error.messages()`: ```python -album, error = Album.validate_or_error(invalid_data) +album, error = album_schema.validate_or_error(invalid_data) for message in error.messages(): print(f'{message.index!r}, {message.code!r}, {message.text!r})') @@ -75,7 +84,7 @@ for message in error.messages(): ## Working with schema instances -Schema instances are returned by calls to `.validate()`. +Python dictionaries are returned by calls to `.validate()`. ```python data = { @@ -83,71 +92,38 @@ data = { 'release_date': '2018-09-14', 'artist': {'name': 'Low'} } -album = Album.validate(data) -print(album) -# Album(title='Double Negative', release_date=datetime.date(2018, 9, 14), artist=Artist(name='Low')) -``` - -Attributes on schemas return native python data types. - -```python -print(type(album.release_date)) -# -``` +album = album_schema.validate(data) -Schema instances present a dict-like interface, allowing them to be easily serialized. - -```python -print(dict(album)) +print(album) # {'title': 'Double Negative', 'release_date': '2018-09-14', 'artist': {'name': 'Low'}} ``` -Index lookup on schema instances returns serialized datatypes. +You can also `serialize` data using the schema instance: ```python -print(type(album['release_date'])) -# +artist = artist_schema.serialize({'name': 'Low'}) +album = album_schema.serialize({'title': 'Double Negative', 'artist': artist}) ``` -You can also instantiate schema instances directly. +If `serialize` directly, validation is not done and data returned may be sparsely populated. +Any unused attributes without a default will not be returned. ```python -artist = Artist(name='Low') -album = Album(title='Double Negative', release_date='2018-09-14', artist=artist) -``` - -When instantiating with keyword arguments, each keyword argument will be validated. - -If instantiated directly, schema instances may be sparsely populated. Any unused -attributes without a default will not be set on the instance. +artist = artist_schema.serialize({'name': 'Low'}) +album = album_schema.serialize({'title': 'Double Negative', 'artist': artist}) -```python -artist = Artist(name='Low') -album = Album(title='Double Negative', artist=artist) print(album) -# Album(title='Double Negative', artist=Artist(name='Low')) [sparse] -album.release_date -# AttributeError: 'Album' object has no attribute 'release_date' -print(dict(album)) -{'title': 'Double Negative', 'artist': {'name': 'Low'}} +# {'title': 'Double Negative', 'artist': {'name': 'Low'}} [sparse] + +album['release_date'] +# KetError: 'release_date' ``` -Sparsely populated instances can be useful for cases of loading data from database, +Sparsely serialized data can be useful for cases of loading data from database, when you do not need to retrieve all the fields, or for cases of loading nested data where no database join has been made, and only the primary key of the relationship is known. -You can also instantiate a schema from an object instance or dictionary. - -```python -new_album = Album(album) -``` - -Note that data validation is not applied when instantiating a schema instance -directly from an instance or dictionary. This should be used when creating -instances against a data source that is already known to be validated, such as -when loading existing instances from a database. - ## Using strict validation By default, additional properties in the incoming user data is ignored. @@ -162,19 +138,10 @@ data = { ``` After validating against the schema, the `num_tracks` property is not present -on the `album` instance. +in the returned data. ```python -album = Album.validate(data) -album.num_tracks -# AttributeError: 'Album' object has no attribute 'num_tracks' -``` - -If you use strict validation, additional properties becomes an error instead. - -```python -album, error = Album.validate_or_error(data, strict=True) - -print(dict(error)) -# {'num_tracks': 'Invalid property name.'} +album = album_schema.validate(data) +album['num_tracks] +# KeyError: 'num_tracks' ``` diff --git a/docs/tokenized_errors.md b/docs/tokenized_errors.md index f535e52..9c6d5dd 100644 --- a/docs/tokenized_errors.md +++ b/docs/tokenized_errors.md @@ -4,9 +4,13 @@ indicators showing exactly where the error occurred in the raw textual content. ```python import typesystem -class Config(typesystem.Schema): - num_worker_processes = typesystem.Integer() - enable_auto_reload = typesystem.Boolean() + +config_schema = typesystem.Schema( + fields={ + "num_worker_processes": typesystem.Integer(), + "enable_auto_reload": typesystem.Boolean(), + } +) text = '''{ "num_worker_processes": "x", @@ -14,7 +18,7 @@ text = '''{ }''' try: - typesystem.validate_json(text, validator=Config) + typesystem.validate_json(text, validator=config_schema) except (typesystem.ValidationError, typesystem.ParseError) as exc: for message in exc.messages(): line_no = message.start_position.line_no @@ -28,4 +32,4 @@ The two functions for parsing content and providing positional error messages ar * `validate_json(text_or_bytes, validator)` * `validate_yaml(text_or_bytes, validator)` -In both cases `validator` may either be a `Schema` class, or a `Field` instance. +In both cases `validator` may either be a `Schema` or a `Field` instance. diff --git a/examples/api/app.py b/examples/api/app.py index 5503268..e3071a8 100644 --- a/examples/api/app.py +++ b/examples/api/app.py @@ -1,15 +1,18 @@ +import typesystem +import uvicorn from starlette.applications import Starlette from starlette.responses import JSONResponse from starlette.routing import Route -import typesystem -import uvicorn users = [] -class User(typesystem.Schema): - username = typesystem.String(max_length=100) - is_admin = typesystem.Boolean(default=False) +user_schema = typesystem.Schema( + fields={ + "username": typesystem.String(max_length=100), + "is_admin": typesystem.Boolean(default=False), + } +) async def list_users(request): @@ -18,17 +21,19 @@ async def list_users(request): async def add_user(request): data = await request.json() - user, errors = User.validate_or_error(data) + user, errors = user_schema.validate_or_error(data) if errors: return JSONResponse(dict(errors), status_code=400) users.append(user) - return JSONResponse(dict(user)) + return JSONResponse(user) -app = Starlette(routes=[ - Route('/', list_users, methods=["GET"]), - Route('/', add_user, methods=["POST"]), -]) +app = Starlette( + routes=[ + Route("/", list_users, methods=["GET"]), + Route("/", add_user, methods=["POST"]), + ] +) if __name__ == "__main__": diff --git a/examples/form/app.py b/examples/form/app.py index 9513b8e..08668db 100644 --- a/examples/form/app.py +++ b/examples/form/app.py @@ -1,10 +1,10 @@ +import typesystem +import uvicorn from starlette.applications import Starlette from starlette.responses import RedirectResponse -from starlette.routing import Route, Mount +from starlette.routing import Mount, Route from starlette.staticfiles import StaticFiles from starlette.templating import Jinja2Templates -import typesystem -import uvicorn forms = typesystem.Jinja2Forms(package="bootstrap4") templates = Jinja2Templates(directory="templates") @@ -12,42 +12,41 @@ bookings = [] -class BookingSchema(typesystem.Schema): - start_date = typesystem.Date(title="Start date") - end_date = typesystem.Date(title="End date") - room = typesystem.Choice( - title="Room type", - choices=[ - ("double", "Double room"), - ("twin", "Twin room"), - ("single", "Single room"), - ], - ) - include_breakfast = typesystem.Boolean(title="Include breakfast", default=False) - - def __str__(self): - breakfast = ( - "(with breakfast)" if self.include_breakfast else "(without breakfast)" - ) - return f"Booking for {self.room} from {self.start_date} to {self.end_date}" +booking_schema = typesystem.Schema( + fields={ + "start_date": typesystem.Date(title="Start date"), + "end_date": typesystem.Date(title="End date"), + "room": typesystem.Choice( + title="Room type", + choices=[ + ("double", "Double room"), + ("twin", "Twin room"), + ("single", "Single room"), + ], + ), + "include_breakfast": typesystem.Boolean( + title="Include breakfast", default=False + ), + } +) async def homepage(request): - form = forms.Form(BookingSchema) + form = forms.create_form(booking_schema) context = {"request": request, "form": form, "bookings": bookings} return templates.TemplateResponse("index.html", context) async def make_booking(request): data = await request.form() - booking, errors = BookingSchema.validate_or_error(data) + booking, errors = booking_schema.validate_or_error(data) if errors: - form = forms.Form(BookingSchema, values=data, errors=errors) + form = forms.create_form(booking_schema) context = {"request": request, "form": form, "bookings": bookings} return templates.TemplateResponse("index.html", context) bookings.append(booking) - return RedirectResponse(request.url_for("homepage")) + return RedirectResponse(request.url_for("homepage"), status_code=303) app = Starlette( diff --git a/mkdocs.yml b/mkdocs.yml index eec542a..3d03266 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -20,5 +20,8 @@ nav: markdown_extensions: - admonition + - pymdownx.highlight + - pymdownx.superfences + - codehilite - markdown.extensions.codehilite: guess_lang: false diff --git a/scripts/check b/scripts/check index 0bb9d98..ce775a2 100755 --- a/scripts/check +++ b/scripts/check @@ -13,4 +13,4 @@ export SOURCE_FILES="typesystem tests" ${PREFIX}isort --check --diff --project=typesystem $SOURCE_FILES ${PREFIX}black --check --diff $SOURCE_FILES ${PREFIX}flake8 $SOURCE_FILES -${PREFIX}mypy +${PREFIX}mypy typesystem diff --git a/setup.cfg b/setup.cfg index f76a37b..1bb4298 100644 --- a/setup.cfg +++ b/setup.cfg @@ -5,11 +5,6 @@ max-line-length = 88 [mypy] disallow_untyped_defs = True ignore_missing_imports = True -files = - typesystem/*.py, - typesystem/tokenize/positional_validation.py, - typesystem/tokenize/tokenize_json.py, - typesystem/tokenize/tokens.py [tool:isort] profile = black diff --git a/tests/test_base.py b/tests/test_base.py index b88f40d..c2d812a 100644 --- a/tests/test_base.py +++ b/tests/test_base.py @@ -1,16 +1,18 @@ import typesystem - -class Example(typesystem.Schema): - a = typesystem.String(max_length=10) - b = typesystem.Integer(maximum=5) +example = typesystem.Schema( + fields={ + "a": typesystem.String(max_length=10), + "b": typesystem.Integer(maximum=5), + } +) def test_validation_result_repr(): - result = Example.validate_or_error({"a": "a", "b": 1}) - assert repr(result) == "ValidationResult(value=Example(a='a', b=1))" + result = example.validate_or_error({"a": "a", "b": 1}) + assert repr(result) == "ValidationResult(value={'a': 'a', 'b': 1})" - result = Example.validate_or_error({"a": "a"}) + result = example.validate_or_error({"a": "a"}) assert ( repr(result) == "ValidationResult(error=ValidationError(" "[Message(text='This field is required.', code='required', index=['b'])]))" @@ -18,7 +20,7 @@ def test_validation_result_repr(): def test_validation_error_repr(): - result = Example.validate_or_error({"a": "a"}) + result = example.validate_or_error({"a": "a"}) assert ( repr(result.error) == "ValidationError([Message" "(text='This field is required.', code='required', index=['b'])])" @@ -32,7 +34,7 @@ def test_validation_error_repr(): def test_validation_error_str(): - result = Example.validate_or_error({"a": "a"}) + result = example.validate_or_error({"a": "a"}) assert str(result.error) == "{'b': 'This field is required.'}" result = typesystem.String(max_length=10).validate_or_error("a" * 100) @@ -40,7 +42,7 @@ def test_validation_error_str(): def test_validation_message_repr(): - result = Example.validate_or_error({"a": "a"}) + result = example.validate_or_error({"a": "a"}) message = result.error.messages()[0] assert ( repr(message) diff --git a/tests/test_definitions.py b/tests/test_definitions.py index a79e01c..0e06745 100644 --- a/tests/test_definitions.py +++ b/tests/test_definitions.py @@ -4,122 +4,135 @@ def test_reference(): - definitions = typesystem.SchemaDefinitions() + definitions = typesystem.Definitions() - class Album(typesystem.Schema, definitions=definitions): - title = typesystem.String(max_length=100) - release_date = typesystem.Date() - artist = typesystem.Reference("Artist") - - class Artist(typesystem.Schema, definitions=definitions): - name = typesystem.String(max_length=100) - - album = Album.validate( - { - "title": "Double Negative", - "release_date": "2018-09-14", - "artist": {"name": "Low"}, + album = typesystem.Schema( + fields={ + "title": typesystem.String(max_length=100), + "release_date": typesystem.Date(), + "artist": typesystem.Reference("Artist", definitions=definitions), } ) - assert album == Album( - title="Double Negative", - release_date=datetime.date(2018, 9, 14), - artist=Artist(name="Low"), - ) - - # Identical class names in alternate definitions should not clash. - definitions = typesystem.SchemaDefinitions() - class Album(typesystem.Schema, definitions=definitions): - renamed_title = typesystem.String(max_length=100) - renamed_release_date = typesystem.Date() - renamed_artist = typesystem.Reference("Artist") + artist = typesystem.Schema(fields={"name": typesystem.String(max_length=100)}) - class Artist(typesystem.Schema, definitions=definitions): - renamed_name = typesystem.String(max_length=100) + definitions["Artist"] = artist - album = Album.validate( + value = album.validate( { - "renamed_title": "Double Negative", - "renamed_release_date": "2018-09-14", - "renamed_artist": {"renamed_name": "Low"}, + "title": "Double Negative", + "release_date": "2018-09-14", + "artist": {"name": "Low"}, } ) - assert album == Album( - renamed_title="Double Negative", - renamed_release_date=datetime.date(2018, 9, 14), - renamed_artist=Artist(renamed_name="Low"), - ) + assert value == { + "title": "Double Negative", + "release_date": datetime.date(2018, 9, 14), + "artist": {"name": "Low"}, + } def test_definitions_as_mapping(): """ Ensure that definitions support a mapping interface. """ - definitions = typesystem.SchemaDefinitions() + artist = typesystem.Schema(fields={"name": typesystem.String(max_length=100)}) - class Album(typesystem.Schema, definitions=definitions): - title = typesystem.String(max_length=100) - release_date = typesystem.Date() - artist = typesystem.Reference("Artist") + definitions = typesystem.Definitions() + definitions["Artist"] = artist + + album = typesystem.Schema( + fields={ + "title": typesystem.String(max_length=100), + "release_date": typesystem.Date(), + "artist": typesystem.Reference(to="Artist", definitions=definitions), + } + ) - class Artist(typesystem.Schema, definitions=definitions): - name = typesystem.String(max_length=100) + definitions["Album"] = album - assert definitions["Album"] == Album - assert definitions["Artist"] == Artist - assert dict(definitions) == {"Album": Album, "Artist": Artist} + assert definitions["Album"] == album + assert definitions["Artist"] == artist + assert dict(definitions) == {"Album": album, "Artist": artist} assert len(definitions) == 2 del definitions["Artist"] def test_string_references(): - definitions = typesystem.SchemaDefinitions() + definitions = typesystem.Definitions() - class ExampleA(typesystem.Schema, definitions=definitions): - field_on_a = typesystem.Integer() - example_b = typesystem.Reference("ExampleB") + example_b = typesystem.Schema( + fields={ + "field_on_b": typesystem.Integer(), + } + ) - class ExampleB(typesystem.Schema, definitions=definitions): - field_on_b = typesystem.Integer() + definitions["ExampleB"] = example_b + + example_a = typesystem.Schema( + fields={ + "field_on_a": typesystem.Integer(), + "example_b": typesystem.Reference(to="ExampleB", definitions=definitions), + } + ) - value = ExampleA.validate({"field_on_a": "123", "example_b": {"field_on_b": "456"}}) - assert value == ExampleA(field_on_a=123, example_b=ExampleB(field_on_b=456)) + value = example_a.validate( + {"field_on_a": "123", "example_b": {"field_on_b": "456"}} + ) + assert value == {"field_on_a": 123, "example_b": {"field_on_b": 456}} - class ExampleC(typesystem.Schema, definitions=definitions): - field_on_c = typesystem.Integer() - example_d = typesystem.Array(items=typesystem.Reference("ExampleD")) + example_d = typesystem.Schema(fields={"field_on_d": typesystem.Integer()}) - class ExampleD(typesystem.Schema, definitions=definitions): - field_on_d = typesystem.Integer() + definitions["ExampleD"] = example_d - value = ExampleC.validate( + example_c = typesystem.Schema( + fields={ + "field_on_c": typesystem.Integer(), + "example_d": typesystem.Array( + items=typesystem.Reference(to="ExampleD", definitions=definitions) + ), + } + ) + + value = example_c.validate( {"field_on_c": "123", "example_d": [{"field_on_d": "456"}]} ) - assert value == ExampleC(field_on_c=123, example_d=[ExampleD(field_on_d=456)]) + assert value == {"field_on_c": 123, "example_d": [{"field_on_d": 456}]} + + example_f = typesystem.Schema(fields={"field_on_f": typesystem.Integer()}) - class ExampleE(typesystem.Schema, definitions=definitions): - field_on_e = typesystem.Integer() - example_f = typesystem.Array(items=[typesystem.Reference("ExampleF")]) + definitions["ExampleF"] = example_f - class ExampleF(typesystem.Schema, definitions=definitions): - field_on_f = typesystem.Integer() + example_e = typesystem.Schema( + fields={ + "field_on_e": typesystem.Integer(), + "example_f": typesystem.Array( + items=[typesystem.Reference(to="ExampleF", definitions=definitions)] + ), + } + ) - value = ExampleE.validate( + value = example_e.validate( {"field_on_e": "123", "example_f": [{"field_on_f": "456"}]} ) - assert value == ExampleE(field_on_e=123, example_f=[ExampleF(field_on_f=456)]) + assert value == {"field_on_e": 123, "example_f": [{"field_on_f": 456}]} - class ExampleG(typesystem.Schema, definitions=definitions): - field_on_g = typesystem.Integer() - example_h = typesystem.Object( - properties={"h": typesystem.Reference("ExampleH")} - ) + example_h = typesystem.Schema(fields={"field_on_h": typesystem.Integer()}) - class ExampleH(typesystem.Schema, definitions=definitions): - field_on_h = typesystem.Integer() + definitions["ExampleH"] = example_h + + example_g = typesystem.Schema( + fields={ + "field_on_g": typesystem.Integer(), + "example_h": typesystem.Object( + properties={ + "h": typesystem.Reference(to="ExampleH", definitions=definitions) + } + ), + } + ) - value = ExampleG.validate( + value = example_g.validate( {"field_on_g": "123", "example_h": {"h": {"field_on_h": "456"}}} ) - assert value == ExampleG(field_on_g=123, example_h={"h": ExampleH(field_on_h=456)}) + assert value == {"field_on_g": 123, "example_h": {"h": {"field_on_h": 456}}} diff --git a/tests/test_fields.py b/tests/test_fields.py index df877df..d391eda 100644 --- a/tests/test_fields.py +++ b/tests/test_fields.py @@ -154,8 +154,8 @@ def test_integer(): value, error = validator.validate_or_error(float("nan")) assert error == ValidationError(text="Must be an integer.", code="integer") - validator = Integer() - value, error = validator.validate_or_error("123", strict=True) + validator = Integer(coerce_types=False) + value, error = validator.validate_or_error("123") assert error == ValidationError(text="Must be a number.", code="type") validator = Integer(allow_null=True) @@ -168,8 +168,8 @@ def test_integer(): assert value is None assert error is None - validator = Integer(allow_null=True) - value, error = validator.validate_or_error("", strict=True) + validator = Integer(allow_null=True, coerce_types=False) + value, error = validator.validate_or_error("") assert error == ValidationError(text="Must be a number.", code="type") validator = Integer(maximum=10) @@ -244,8 +244,8 @@ def test_float(): value, error = validator.validate_or_error(float("nan")) assert error == ValidationError(text="Must be finite.", code="finite") - validator = Float() - value, error = validator.validate_or_error("123", strict=True) + validator = Float(coerce_types=False) + value, error = validator.validate_or_error("123") assert error == ValidationError(text="Must be a number.", code="type") validator = Float(allow_null=True) @@ -363,8 +363,8 @@ def test_boolean(): assert value is None assert error is None - validator = Boolean() - value, error = validator.validate_or_error("True", strict=True) + validator = Boolean(coerce_types=False) + value, error = validator.validate_or_error("True") assert error == ValidationError(text="Must be a boolean.", code="type") diff --git a/tests/test_forms.py b/tests/test_forms.py index 44ddfba..33fa264 100644 --- a/tests/test_forms.py +++ b/tests/test_forms.py @@ -1,22 +1,28 @@ import jinja2 import markupsafe +import pytest import typesystem - -class Contact(typesystem.Schema): - a = typesystem.Boolean() - b = typesystem.String(max_length=10) - c = typesystem.Text() - d = typesystem.Choice(choices=[("abc", "Abc"), ("def", "Def"), ("ghi", "Ghi")]) - password = typesystem.Password() +contact = typesystem.Schema( + fields={ + "a": typesystem.Boolean(), + "b": typesystem.String(max_length=10), + "c": typesystem.Text(), + "d": typesystem.Choice( + choices=[("abc", "Abc"), ("def", "Def"), ("ghi", "Ghi")] + ), + "extra": typesystem.Boolean(default=True, read_only=True), + "password": typesystem.Password(), + } +) forms = typesystem.Jinja2Forms(package="typesystem") def test_form_rendering(): - form = forms.Form(Contact) + form = forms.create_form(contact) html = str(form) @@ -28,13 +34,29 @@ def test_form_rendering(): def test_password_rendering(): - form = forms.Form(Contact, values={"password": "secret"}) + form = forms.create_form(contact, values={"password": "secret"}) html = str(form) assert "secret" not in html +def test_form_validation(): + password_schema = typesystem.Schema( + {"password": typesystem.String(format="password")} + ) + + form = forms.create_form(password_schema) + + with pytest.raises(AssertionError): + form.is_valid + + form.validate(data={"password": "secret"}) + + assert form.is_valid is True + assert form.validated_data == {"password": "secret"} + + def test_form_html(): - form = forms.Form(Contact) + form = forms.create_form(contact) markup = form.__html__() diff --git a/tests/test_json_schema.py b/tests/test_json_schema.py index 0a55b7e..e8c7848 100644 --- a/tests/test_json_schema.py +++ b/tests/test_json_schema.py @@ -78,7 +78,7 @@ def load_test_cases(): @pytest.mark.parametrize("schema,data,is_valid,description", test_cases) def test_json_schema(schema, data, is_valid, description): validator = from_json_schema(schema) - value, error = validator.validate_or_error(data, strict=True) + value, error = validator.validate_or_error(data) if is_valid: assert error is None, description else: @@ -104,36 +104,37 @@ def test_to_from_json_schema(schema, data, is_valid, description): """ validator = from_json_schema(schema) - value_before_convert, error_before_convert = validator.validate_or_error( - data, strict=True - ) + value_before_convert, error_before_convert = validator.validate_or_error(data) schema_after = to_json_schema(validator) validator = from_json_schema(schema_after) - value_after_convert, error_after_convert = validator.validate_or_error( - data, strict=True - ) + value_after_convert, error_after_convert = validator.validate_or_error(data) assert error_before_convert == error_after_convert assert value_before_convert == value_after_convert def test_schema_to_json_schema(): - class BookingSchema(typesystem.Schema): - start_date = typesystem.Date(title="Start date") - end_date = typesystem.Date(title="End date") - room = typesystem.Choice( - title="Room type", - choices=[ - ("double", "Double room"), - ("twin", "Twin room"), - ("single", "Single room"), - ], - ) - include_breakfast = typesystem.Boolean(title="Include breakfast", default=False) - - schema = to_json_schema(BookingSchema) + booking_schema = typesystem.Schema( + { + "start_date": typesystem.Date(title="Start date"), + "end_date": typesystem.Date(title="End date"), + "room": typesystem.Choice( + title="Room type", + choices=[ + ("double", "Double room"), + ("twin", "Twin room"), + ("single", "Single room"), + ], + ), + "include_breakfast": typesystem.Boolean( + title="Include breakfast", default=False + ), + } + ) + + schema = to_json_schema(booking_schema) assert schema == { "type": "object", diff --git a/tests/test_schemas.py b/tests/test_schemas.py index be0c5bb..da1d7d9 100644 --- a/tests/test_schemas.py +++ b/tests/test_schemas.py @@ -1,325 +1,361 @@ import datetime -import decimal -import uuid - -import pytest import typesystem import typesystem.formats -class Person(typesystem.Schema): - name = typesystem.String(max_length=100, allow_blank=False) - age = typesystem.Integer() - - -class Product(typesystem.Schema): - name = typesystem.String(max_length=100, allow_blank=False) - rating = typesystem.Integer(default=None) - - -def test_required(): - class Example(typesystem.Schema): - field = typesystem.Integer() - - value, error = Example.validate_or_error({}) - assert dict(error) == {"field": "This field is required."} - - class Example(typesystem.Schema): - field = typesystem.Integer(allow_null=True) - - value, error = Example.validate_or_error({}) - assert dict(value) == {"field": None} - - class Example(typesystem.Schema): - field = typesystem.Integer(default=0) - - value, error = Example.validate_or_error({}) - assert dict(value) == {"field": 0} - - class Example(typesystem.Schema): - field = typesystem.Integer(allow_null=True, default=0) - - value, error = Example.validate_or_error({}) - assert dict(value) == {"field": 0} - - class Example(typesystem.Schema): - field = typesystem.String() - - value, error = Example.validate_or_error({}) - assert dict(error) == {"field": "This field is required."} - - class Example(typesystem.Schema): - field = typesystem.String(allow_blank=True) - - value, error = Example.validate_or_error({}) - assert dict(value) == {"field": ""} - - class Example(typesystem.Schema): - field = typesystem.String(allow_null=True, allow_blank=True) - - value, error = Example.validate_or_error({}) - assert dict(value) == {"field": None} - +def test_schema(): + validator = typesystem.Schema(fields={}) + value, error = validator.validate_or_error({}) + assert value == {} -def test_schema_validation(): - value, error = Person.validate_or_error({"name": "Tom", "age": "123"}) - assert not error - assert value == Person(name="Tom", age=123) + data = validator.serialize(None) + assert data is None - value, error = Person.validate_or_error({"name": "Tom", "age": "123"}) - assert not error - assert value == Person(name="Tom", age=123) + validator = typesystem.Schema(fields={"title": typesystem.String()}) + data = validator.serialize({}) + assert data == {} - value, error = Person.validate_or_error({"name": "Tom", "age": "abc"}) - assert dict(error) == {"age": "Must be a number."} - - value, error = Person.validate_or_error({"name": "Tom"}) - assert dict(error) == {"age": "This field is required."} + validator = typesystem.Schema(fields={}) + value, error = validator.validate_or_error(None) + assert dict(error) == {"": "May not be null."} + validator = typesystem.Schema(fields={}) + value, error = validator.validate_or_error(123) + assert dict(error) == {"": "Must be an object."} -def test_schema_eq(): - tom = Person(name="Tom", age=123) - lucy = Person(name="Lucy", age=123) - assert tom != lucy + validator = typesystem.Schema(fields={}) + value, error = validator.validate_or_error({1: 123}) + assert dict(error) == {1: "All object keys must be strings."} - tom = Person(name="Tom", age=123) - tshirt = Product(name="T-Shirt") - assert tom != tshirt + validator = typesystem.Schema(fields={}, allow_null=True) + value, error = validator.validate_or_error(None) + assert value is None + assert error is None + validator = typesystem.Schema(fields={"example": typesystem.Integer()}) + value, error = validator.validate_or_error({"example": "123"}) + assert value == {"example": 123} -def test_schema_repr(): - tom = Person(name="Tom", age=123) - assert repr(tom) == "Person(name='Tom', age=123)" + validator = typesystem.Schema(fields={"example": typesystem.Integer()}) + value, error = validator.validate_or_error({"example": "abc"}) + assert dict(error) == {"example": "Must be a number."} - tom = Person(name="Tom") - assert repr(tom) == "Person(name='Tom') [sparse]" + validator = typesystem.Schema(fields={"example": typesystem.Integer(default=0)}) + value, error = validator.validate_or_error({"example": "123"}) + assert value == {"example": 123} + validator = typesystem.Schema(fields={"example": typesystem.Integer(default=0)}) + value, error = validator.validate_or_error({}) + assert value == {"example": 0} -def test_schema_instantiation(): - tshirt = Product(name="T-Shirt") - assert tshirt.name == "T-Shirt" - assert tshirt.rating is None + validator = typesystem.Schema(fields={"example": typesystem.Integer()}) + value, error = validator.validate_or_error({"example": "abc"}) + assert dict(error) == {"example": "Must be a number."} - empty = Product() - assert not hasattr(empty, "name") - - with pytest.raises(TypeError): - Product(name="T-Shirt", other="Invalid") - - with pytest.raises(TypeError): - Product(name="x" * 1000) - - tshirt = Product(name="T-Shirt") - assert Product(tshirt) == tshirt - - -def test_schema_subclass(): - class DetailedProduct(Product): - info = typesystem.Text() + validator = typesystem.Schema( + fields={"example": typesystem.Integer(read_only=True)} + ) + value, error = validator.validate_or_error({"example": "123"}) + assert value == {} - assert set(DetailedProduct.fields.keys()) == {"name", "rating", "info"} +person = typesystem.Schema( + fields={ + "name": typesystem.String(max_length=100, allow_blank=False), + "age": typesystem.Integer(), + } +) -def test_schema_serialization(): - tshirt = Product(name="T-Shirt") - data = dict(tshirt) +product = typesystem.Schema( + fields={ + "name": typesystem.String(max_length=100, allow_blank=False), + "rating": typesystem.Integer(default=None), + } +) - assert data == {"name": "T-Shirt", "rating": None} +def test_required(): + example = typesystem.Schema(fields={"field": typesystem.Integer()}) -def test_schema_null_items_array_serialization(): - class Product(typesystem.Schema): - names = typesystem.Array() + value, error = example.validate_or_error({}) + assert dict(error) == {"field": "This field is required."} - tshirt = Product(names=[1, "2", {"nested": 3}]) + example = typesystem.Schema(fields={"field": typesystem.Integer(allow_null=True)}) - data = dict(tshirt) + value, error = example.validate_or_error({}) + assert dict(value) == {"field": None} - assert data == {"names": [1, "2", {"nested": 3}]} + example = typesystem.Schema(fields={"field": typesystem.Integer(default=0)}) + value, error = example.validate_or_error({}) + assert dict(value) == {"field": 0} -def test_schema_string_array_serialization(): - class Product(typesystem.Schema): - names = typesystem.Array(typesystem.String()) + example = typesystem.Schema( + fields={"field": typesystem.Integer(allow_null=True, default=0)} + ) - tshirt = Product(names=["T-Shirt"]) + value, error = example.validate_or_error({}) + assert dict(value) == {"field": 0} - data = dict(tshirt) + example = typesystem.Schema(fields={"field": typesystem.String()}) - assert data == {"names": ["T-Shirt"]} + value, error = example.validate_or_error({}) + assert dict(error) == {"field": "This field is required."} + example = typesystem.Schema(fields={"field": typesystem.String(allow_blank=True)}) -def test_schema_dates_array_serialization(): - class BlogPost(typesystem.Schema): - text = typesystem.String() - modified = typesystem.Array(typesystem.Date()) + value, error = example.validate_or_error({}) + assert dict(value) == {"field": ""} - post = BlogPost(text="Hi", modified=[datetime.date.today()]) + example = typesystem.Schema( + fields={"field": typesystem.String(allow_null=True, allow_blank=True)} + ) - data = dict(post) + value, error = example.validate_or_error({}) + assert dict(value) == {"field": None} - assert data["text"] == "Hi" - assert data["modified"] == [datetime.date.today().isoformat()] +def test_schema_validation(): + value, error = person.validate_or_error({"name": "Tom", "age": "123"}) + assert not error + assert value == {"name": "Tom", "age": 123} -def test_schema_positional_array_serialization(): - class NumberName(typesystem.Schema): - pair = typesystem.Array([typesystem.Integer(), typesystem.String()]) + value, error = person.validate_or_error({"name": "Tom", "age": "123"}) + assert not error + assert value == {"name": "Tom", "age": 123} - name = NumberName(pair=[1, "one"]) + value, error = person.validate_or_error({"name": "Tom", "age": "abc"}) + assert dict(error) == {"age": "Must be a number."} - data = dict(name) + value, error = person.validate_or_error({"name": "Tom"}) + assert dict(error) == {"age": "This field is required."} - assert data == {"pair": [1, "one"]} +def test_schema_array_serialization(): + category = typesystem.Schema(fields={"title": typesystem.String()}) -def test_schema_len(): - tshirt = Product(name="T-Shirt") + definitions = typesystem.Definitions() + definitions["Category"] = category - count = len(tshirt) + blog_post = typesystem.Schema( + fields={ + "categories": typesystem.Array( + items=[typesystem.Reference(to="Category", definitions=definitions)], + allow_null=True, + ), + "text": typesystem.String(), + } + ) - assert count == 2 + item = {"text": "Hi", "categories": [{"title": "Tech"}]} + data = blog_post.serialize(item) + assert data["categories"] == [{"title": "Tech"}] + item = {"text": "Hi", "categories": None} + data = blog_post.serialize(item) + assert data["categories"] is None -def test_schema_getattr(): - tshirt = Product(name="T-Shirt") - assert tshirt["name"] == "T-Shirt" + blog_post = typesystem.Schema( + fields={ + "categories": typesystem.Array(allow_null=True), + "text": typesystem.String(), + } + ) + item = {"text": "Hi", "categories": [{"title": 123}]} + data = blog_post.serialize(item) + assert data["categories"] == [{"title": 123}] -def test_schema_missing_getattr(): - tshirt = Product(name="T-Shirt") + blog_post = typesystem.Schema( + fields={ + "categories": typesystem.Array(items=typesystem.Integer(), allow_null=True), + "text": typesystem.String(), + } + ) - with pytest.raises(KeyError): - assert tshirt["missing"] + item = {"text": "Hi", "categories": [{"title": 123}]} + data = blog_post.serialize(item) + assert data["categories"] == [{"title": 123}] def test_schema_date_serialization(): - class BlogPost(typesystem.Schema): - text = typesystem.String() - created = typesystem.Date() - modified = typesystem.Date(allow_null=True) - - post = BlogPost(text="Hi", created=datetime.date.today()) + blog_post = typesystem.Schema( + fields={ + "text": typesystem.String(), + "created": typesystem.Date(), + "modified": typesystem.Date(allow_null=True), + } + ) - data = dict(post) + today = datetime.date.today() + item = {"text": "Hi", "created": today, "modified": None} + data = blog_post.serialize(item) assert data["text"] == "Hi" - assert data["created"] == datetime.date.today().isoformat() + assert data["created"] == today.isoformat() assert data["modified"] is None def test_schema_time_serialization(): - class MealSchedule(typesystem.Schema): - guest_id = typesystem.Integer() - breakfast_at = typesystem.Time() - dinner_at = typesystem.Time(allow_null=True) + meal_schedule = typesystem.Schema( + fields={ + "guest_id": typesystem.Integer(), + "breakfast_at": typesystem.Time(), + "dinner_at": typesystem.Time(allow_null=True), + } + ) guest_id = 123 breakfast_at = datetime.time(hour=10, minute=30) - schedule = MealSchedule(guest_id=guest_id, breakfast_at=breakfast_at) + item = {"guest_id": guest_id, "breakfast_at": breakfast_at, "dinner_at": None} + data = meal_schedule.serialize(item) - assert typesystem.formats.TIME_REGEX.match(schedule["breakfast_at"]) - assert schedule["guest_id"] == guest_id - assert schedule["breakfast_at"] == breakfast_at.isoformat() - assert schedule["dinner_at"] is None + assert typesystem.formats.TIME_REGEX.match(data["breakfast_at"]) + assert data["guest_id"] == guest_id + assert data["breakfast_at"] == breakfast_at.isoformat() + assert data["dinner_at"] is None def test_schema_datetime_serialization(): - class Guest(typesystem.Schema): - id = typesystem.Integer() - name = typesystem.String() - check_in = typesystem.DateTime() - check_out = typesystem.DateTime(allow_null=True) + guest = typesystem.Schema( + fields={ + "id": typesystem.Integer(), + "name": typesystem.String(), + "check_in": typesystem.DateTime(), + "check_out": typesystem.DateTime(allow_null=True), + } + ) guest_id = 123 guest_name = "Bob" check_in = datetime.datetime.now(tz=datetime.timezone.utc) - guest = Guest(id=guest_id, name=guest_name, check_in=check_in) + item = {"id": guest_id, "name": guest_name, "check_in": check_in, "check_out": None} + data = guest.serialize(item) - assert typesystem.formats.DATETIME_REGEX.match(guest["check_in"]) - assert guest["id"] == guest_id - assert guest["name"] == guest_name - assert guest["check_in"] == check_in.isoformat()[:-6] + "Z" - assert guest["check_out"] is None + assert typesystem.formats.DATETIME_REGEX.match(data["check_in"]) + assert data["id"] == guest_id + assert data["name"] == guest_name + assert data["check_in"] == check_in.isoformat()[:-6] + "Z" + assert data["check_out"] is None def test_schema_decimal_serialization(): - class InventoryItem(typesystem.Schema): - name = typesystem.String() - price = typesystem.Decimal(precision="0.01", allow_null=True) + inventory = typesystem.Schema( + fields={ + "name": typesystem.String(), + "price": typesystem.Decimal(precision="0.01", allow_null=True), + } + ) - item = InventoryItem(name="Example", price=123.45) + item = {"name": "example", "price": 123.45} + data = inventory.serialize(item) - assert item.price == decimal.Decimal("123.45") - assert item["price"] == 123.45 + assert data["name"] == "example" + assert data["price"] == 123.45 - item = InventoryItem(name="test") - assert dict(item) == {"name": "test", "price": None} - item = InventoryItem(name="test", price=0) - assert dict(item) == {"name": "test", "price": 0} + item = {"name": "example", "price": None} + assert inventory.serialize(item) == {"name": "example", "price": None} + + item = {"name": "example", "price": 0} + assert inventory.serialize(item) == {"name": "example", "price": 0} def test_schema_uuid_serialization(): - class User(typesystem.Schema): - id = typesystem.String(format="uuid") - username = typesystem.String() + user = typesystem.Schema( + fields={ + "id": typesystem.String(format="uuid"), + "username": typesystem.String(), + } + ) - item = User(id="b769df4a-18ec-480f-89ef-8ea961a82269", username="tom") + item = {"id": "b769df4a-18ec-480f-89ef-8ea961a82269", "username": "tom"} + data = user.serialize(item) - assert item.id == uuid.UUID("b769df4a-18ec-480f-89ef-8ea961a82269") - assert item["id"] == "b769df4a-18ec-480f-89ef-8ea961a82269" + assert data["id"] == "b769df4a-18ec-480f-89ef-8ea961a82269" + assert data["username"] == "tom" + + +def test_schema_reference_serialization(): + author = typesystem.Schema(fields={"username": typesystem.String()}) + + definitions = typesystem.Definitions() + definitions["Author"] = author + + blog_post = typesystem.Schema( + fields={ + "author": typesystem.Reference(to="Author", definitions=definitions), + "text": typesystem.String(), + } + ) + + item = {"text": "sample", "author": {"username": "tom"}} + data = blog_post.serialize(item) + assert data["author"] == {"username": "tom"} + + item = {"text": "sample", "author": None} + data = blog_post.serialize(item) + assert data["author"] is None def test_schema_with_callable_default(): - class Example(typesystem.Schema): - created = typesystem.Date(default=datetime.date.today) + schema = typesystem.Schema( + fields={"created": typesystem.Date(default=datetime.date.today)} + ) - value, error = Example.validate_or_error({}) - assert value.created == datetime.date.today() + value, _ = schema.validate_or_error({}) + assert value["created"] == datetime.date.today() def test_nested_schema(): - class Artist(typesystem.Schema): - name = typesystem.String(max_length=100) + definitions = typesystem.Definitions() + + artist = typesystem.Schema(fields={"name": typesystem.String(max_length=100)}) - class Album(typesystem.Schema): - title = typesystem.String(max_length=100) - release_year = typesystem.Integer() - artist = typesystem.Reference(Artist) + definitions["Artist"] = artist - value = Album.validate( + album = typesystem.Schema( + fields={ + "title": typesystem.String(max_length=100), + "release_year": typesystem.Integer(), + "artist": typesystem.Reference("Artist", definitions=definitions), + } + ) + + value = album.validate( {"title": "Double Negative", "release_year": "2018", "artist": {"name": "Low"}} ) - assert dict(value) == { + assert value == { "title": "Double Negative", "release_year": 2018, "artist": {"name": "Low"}, } - assert value == Album( - title="Double Negative", release_year=2018, artist=Artist(name="Low") - ) - value, error = Album.validate_or_error( + value, error = album.validate_or_error( {"title": "Double Negative", "release_year": "2018", "artist": None} ) assert dict(error) == {"artist": "May not be null."} - value, error = Album.validate_or_error( + value, error = album.validate_or_error( {"title": "Double Negative", "release_year": "2018", "artist": "Low"} ) assert dict(error) == {"artist": "Must be an object."} - class Album(typesystem.Schema): - title = typesystem.String(max_length=100) - release_year = typesystem.Integer() - artist = typesystem.Reference(Artist, allow_null=True) + album = typesystem.Schema( + fields={ + "title": typesystem.String(max_length=100), + "release_year": typesystem.Integer(), + "artist": typesystem.Reference( + "artist", definitions=definitions, allow_null=True + ), + } + ) - value = Album.validate( + value = album.validate( {"title": "Double Negative", "release_year": "2018", "artist": None} ) - assert dict(value) == { + assert value == { "title": "Double Negative", "release_year": 2018, "artist": None, @@ -327,49 +363,59 @@ class Album(typesystem.Schema): def test_nested_schema_array(): - class Artist(typesystem.Schema): - name = typesystem.String(max_length=100) - - class Album(typesystem.Schema): - title = typesystem.String(max_length=100) - release_year = typesystem.Integer() - artists = typesystem.Array(items=typesystem.Reference(Artist)) + artist = typesystem.Schema(fields={"name": typesystem.String(max_length=100)}) + + definitions = typesystem.Definitions() + definitions["Artist"] = artist + + album = typesystem.Schema( + fields={ + "title": typesystem.String(max_length=100), + "release_year": typesystem.Integer(), + "artists": typesystem.Array( + items=typesystem.Reference(to="Artist", definitions=definitions) + ), + } + ) - value = Album.validate( + value = album.validate( { "title": "Double Negative", "release_year": "2018", "artists": [{"name": "Low"}], } ) - assert dict(value) == { + assert value == { "title": "Double Negative", "release_year": 2018, "artists": [{"name": "Low"}], } - assert value == Album( - title="Double Negative", release_year=2018, artists=[Artist(name="Low")] - ) - value, error = Album.validate_or_error( + value, error = album.validate_or_error( {"title": "Double Negative", "release_year": "2018", "artists": None} ) assert dict(error) == {"artists": "May not be null."} - value, error = Album.validate_or_error( + value, error = album.validate_or_error( {"title": "Double Negative", "release_year": "2018", "artists": "Low"} ) assert dict(error) == {"artists": "Must be an array."} - class Album(typesystem.Schema): - title = typesystem.String(max_length=100) - release_year = typesystem.Integer() - artists = typesystem.Array(items=typesystem.Reference(Artist), allow_null=True) + album = typesystem.Schema( + fields={ + "title": typesystem.String(max_length=100), + "release_year": typesystem.Integer(), + "artists": typesystem.Array( + items=typesystem.Reference(to="Artist", definitions=definitions), + allow_null=True, + ), + } + ) - value = Album.validate( + value = album.validate( {"title": "Double Negative", "release_year": "2018", "artists": None} ) - assert dict(value) == { + assert value == { "title": "Double Negative", "release_year": 2018, "artists": None, @@ -377,15 +423,20 @@ class Album(typesystem.Schema): def test_nested_schema_to_json_schema(): - class Artist(typesystem.Schema): - name = typesystem.String(max_length=100) + artist = typesystem.Schema(fields={"name": typesystem.String(max_length=100)}) - class Album(typesystem.Schema): - title = typesystem.String(max_length=100) - release_date = typesystem.Date() - artist = typesystem.Reference(Artist) + definitions = typesystem.Definitions() + definitions["Artist"] = artist - schema = typesystem.to_json_schema(Album) + album = typesystem.Schema( + fields={ + "title": typesystem.String(max_length=100), + "release_date": typesystem.Date(), + "artist": typesystem.Reference(to="Artist", definitions=definitions), + } + ) + + schema = typesystem.to_json_schema(album) assert schema == { "type": "object", @@ -408,15 +459,20 @@ class Album(typesystem.Schema): def test_definitions_to_json_schema(): - definitions = typesystem.SchemaDefinitions() + definitions = typesystem.Definitions() - class Artist(typesystem.Schema, definitions=definitions): - name = typesystem.String(max_length=100) + artist = typesystem.Schema(fields={"name": typesystem.String(max_length=100)}) + + album = typesystem.Schema( + fields={ + "title": typesystem.String(max_length=100), + "release_date": typesystem.Date(), + "artist": typesystem.Reference(to="Artist", definitions=definitions), + } + ) - class Album(typesystem.Schema, definitions=definitions): - title = typesystem.String(max_length=100) - release_date = typesystem.Date() - artist = typesystem.Reference("Artist") + definitions["Artist"] = artist + definitions["Album"] = album schema = typesystem.to_json_schema(definitions) diff --git a/tests/tokenize/test_validate_json.py b/tests/tokenize/test_validate_json.py index 3180a4b..23ac668 100644 --- a/tests/tokenize/test_validate_json.py +++ b/tests/tokenize/test_validate_json.py @@ -34,13 +34,16 @@ def test_validate_json(): "end_position=Position(line_no=3, column_no=14, char_index=31))" ) - class Validator(Schema): - a = Integer() - b = Integer() + validator = Schema( + fields={ + "a": Integer(), + "b": Integer(), + } + ) text = '{\n "a": "123",\n "b": "abc"}' with pytest.raises(ValidationError) as exc_info: - validate_json(text, validator=Validator) + validate_json(text, validator=validator) exc = exc_info.value assert exc.messages() == [ Message( @@ -54,7 +57,7 @@ class Validator(Schema): text = '{"a": "123"}' with pytest.raises(ValidationError) as exc_info: - validate_json(text, validator=Validator) + validate_json(text, validator=validator) exc = exc_info.value assert exc.messages() == [ Message( diff --git a/tests/tokenize/test_validate_yaml.py b/tests/tokenize/test_validate_yaml.py index 00f6dc0..40a0684 100644 --- a/tests/tokenize/test_validate_yaml.py +++ b/tests/tokenize/test_validate_yaml.py @@ -27,14 +27,17 @@ def test_validate_yaml(): ) ] - class Validator(Schema): - a = Integer() - b = Integer() + validator = Schema( + fields={ + "a": Integer(), + "b": Integer(), + } + ) text = "a: 123\nb: abc\n" with pytest.raises(ValidationError) as exc_info: - validate_yaml(text, validator=Validator) + validate_yaml(text, validator=validator) exc = exc_info.value assert exc.messages() == [ @@ -49,7 +52,7 @@ class Validator(Schema): text = "a: 123" with pytest.raises(ValidationError) as exc_info: - validate_yaml(text, validator=Validator) + validate_yaml(text, validator=validator) exc = exc_info.value assert exc.messages() == [ Message( diff --git a/typesystem/__init__.py b/typesystem/__init__.py index 85d8a24..165a090 100644 --- a/typesystem/__init__.py +++ b/typesystem/__init__.py @@ -21,12 +21,12 @@ ) from typesystem.forms import Jinja2Forms from typesystem.json_schema import from_json_schema, to_json_schema -from typesystem.schemas import Reference, Schema, SchemaDefinitions +from typesystem.schemas import Definitions, Reference, Schema from typesystem.tokenize.positional_validation import validate_with_positions from typesystem.tokenize.tokenize_json import tokenize_json, validate_json from typesystem.tokenize.tokenize_yaml import tokenize_yaml, validate_yaml -__version__ = "0.2.5" +__version__ = "0.3.0" __all__ = [ "Array", "Any", @@ -50,7 +50,7 @@ "UUID", # Schemas "Schema", - "SchemaDefinitions", + "Definitions", # Exceptions "ParseError", "ValidationError", diff --git a/typesystem/composites.py b/typesystem/composites.py index 8f663dd..0c1edb8 100644 --- a/typesystem/composites.py +++ b/typesystem/composites.py @@ -16,7 +16,7 @@ def __init__(self, **kwargs: typing.Any) -> None: assert "allow_null" not in kwargs super().__init__(**kwargs) - def validate(self, value: typing.Any, strict: bool = False) -> typing.Any: + def validate(self, value: typing.Any) -> typing.Any: raise self.validation_error("never") @@ -38,11 +38,11 @@ def __init__(self, one_of: typing.List[Field], **kwargs: typing.Any) -> None: super().__init__(**kwargs) self.one_of = one_of - def validate(self, value: typing.Any, strict: bool = False) -> typing.Any: + def validate(self, value: typing.Any) -> typing.Any: candidate = None match_count = 0 for child in self.one_of: - validated, error = child.validate_or_error(value, strict=strict) + validated, error = child.validate_or_error(value) if error is None: match_count += 1 candidate = validated @@ -67,9 +67,9 @@ def __init__(self, all_of: typing.List[Field], **kwargs: typing.Any) -> None: super().__init__(**kwargs) self.all_of = all_of - def validate(self, value: typing.Any, strict: bool = False) -> typing.Any: + def validate(self, value: typing.Any) -> typing.Any: for child in self.all_of: - child.validate(value, strict=strict) + child.validate(value) return value @@ -87,8 +87,8 @@ def __init__(self, negated: Field, **kwargs: typing.Any) -> None: super().__init__(**kwargs) self.negated = negated - def validate(self, value: typing.Any, strict: bool = False) -> typing.Any: - _, error = self.negated.validate_or_error(value, strict=strict) + def validate(self, value: typing.Any) -> typing.Any: + _, error = self.negated.validate_or_error(value) if error: return value raise self.validation_error("negated") @@ -114,9 +114,9 @@ def __init__( self.then_clause = Any() if then_clause is None else then_clause self.else_clause = Any() if else_clause is None else else_clause - def validate(self, value: typing.Any, strict: bool = False) -> typing.Any: - _, error = self.if_clause.validate_or_error(value, strict=strict) + def validate(self, value: typing.Any) -> typing.Any: + _, error = self.if_clause.validate_or_error(value) if error is None: - return self.then_clause.validate(value, strict=strict) + return self.then_clause.validate(value) else: - return self.else_clause.validate(value, strict=strict) + return self.else_clause.validate(value) diff --git a/typesystem/fields.py b/typesystem/fields.py index 1303634..4837d6b 100644 --- a/typesystem/fields.py +++ b/typesystem/fields.py @@ -19,7 +19,6 @@ class Field: errors: typing.Dict[str, str] = {} - _creation_counter = 0 def __init__( self, @@ -28,6 +27,7 @@ def __init__( description: str = "", default: typing.Any = NO_DEFAULT, allow_null: bool = False, + read_only: bool = False, ): assert isinstance(title, str) assert isinstance(description, str) @@ -41,20 +41,14 @@ def __init__( self.title = title self.description = description self.allow_null = allow_null + self.read_only = read_only - # We need this global counter to determine what order fields have - # been declared in when used with `Schema`. - self._creation_counter = Field._creation_counter - Field._creation_counter += 1 - - def validate(self, value: typing.Any, *, strict: bool = False) -> typing.Any: + def validate(self, value: typing.Any) -> typing.Any: raise NotImplementedError() # pragma: no cover - def validate_or_error( - self, value: typing.Any, *, strict: bool = False - ) -> ValidationResult: + def validate_or_error(self, value: typing.Any) -> ValidationResult: try: - value = self.validate(value, strict=strict) + value = self.validate(value) except ValidationError as error: return ValidationResult(value=None, error=error) return ValidationResult(value=value, error=None) @@ -112,6 +106,7 @@ def __init__( min_length: int = None, pattern: typing.Union[str, typing.Pattern] = None, format: str = None, + coerce_types: bool = True, **kwargs: typing.Any, ) -> None: super().__init__(**kwargs) @@ -129,6 +124,7 @@ def __init__( self.max_length = max_length self.min_length = min_length self.format = format + self.coerce_types = coerce_types if pattern is None: self.pattern = None @@ -140,10 +136,10 @@ def __init__( self.pattern = pattern.pattern self.pattern_regex = pattern - def validate(self, value: typing.Any, *, strict: bool = False) -> typing.Any: + def validate(self, value: typing.Any) -> typing.Any: if value is None and self.allow_null: return None - elif value is None and self.allow_blank and not strict: + elif value is None and self.allow_blank and self.coerce_types: # Leniently cast nulls to empty strings if allow_blank. return "" elif value is None: @@ -161,7 +157,7 @@ def validate(self, value: typing.Any, *, strict: bool = False) -> typing.Any: value = value.strip() if not self.allow_blank and not value: - if self.allow_null and not strict: + if self.allow_null and self.coerce_types: # Leniently cast empty strings (after trimming) to null if allow_null. return None raise self.validation_error("blank") @@ -212,6 +208,7 @@ def __init__( exclusive_maximum: typing.Union[int, float, decimal.Decimal] = None, precision: str = None, multiple_of: typing.Union[int, float, decimal.Decimal] = None, + coerce_types: bool = True, **kwargs: typing.Any, ): super().__init__(**kwargs) @@ -234,11 +231,12 @@ def __init__( self.exclusive_maximum = exclusive_maximum self.multiple_of = multiple_of self.precision = precision + self.coerce_types = coerce_types - def validate(self, value: typing.Any, *, strict: bool = False) -> typing.Any: + def validate(self, value: typing.Any) -> typing.Any: if value is None and self.allow_null: return None - elif value == "" and self.allow_null and not strict: + elif value == "" and self.allow_null and self.coerce_types: return None elif value is None: raise self.validation_error("null") @@ -250,7 +248,7 @@ def validate(self, value: typing.Any, *, strict: bool = False) -> typing.Any: and not value.is_integer() ): raise self.validation_error("integer") - elif not isinstance(value, (int, float)) and strict: + elif not isinstance(value, (int, float)) and not self.coerce_types: raise self.validation_error("type") try: @@ -328,7 +326,11 @@ class Boolean(Field): } coerce_null_values = {"", "null", "none"} - def validate(self, value: typing.Any, *, strict: bool = False) -> typing.Any: + def __init__(self, *, coerce_types: bool = True, **kwargs: typing.Any) -> None: + super().__init__(**kwargs) + self.coerce_types = coerce_types + + def validate(self, value: typing.Any) -> typing.Any: if value is None and self.allow_null: return None @@ -336,7 +338,7 @@ def validate(self, value: typing.Any, *, strict: bool = False) -> typing.Any: raise self.validation_error("null") elif not isinstance(value, bool): - if strict: + if not self.coerce_types: raise self.validation_error("type") if isinstance(value, str): @@ -364,6 +366,7 @@ def __init__( self, *, choices: typing.Sequence[typing.Union[str, typing.Tuple[str, str]]] = None, + coerce_types: bool = True, **kwargs: typing.Any, ) -> None: super().__init__(**kwargs) @@ -371,16 +374,17 @@ def __init__( (choice if isinstance(choice, (tuple, list)) else (choice, choice)) for choice in choices or [] ] + self.coerce_types = coerce_types assert all(len(choice) == 2 for choice in self.choices) - def validate(self, value: typing.Any, *, strict: bool = False) -> typing.Any: + def validate(self, value: typing.Any) -> typing.Any: if value is None and self.allow_null: return None elif value is None: raise self.validation_error("null") elif value not in Uniqueness([key for key, value in self.choices]): if value == "": - if self.allow_null and not strict: + if self.allow_null and self.coerce_types: return None raise self.validation_error("required") raise self.validation_error("choice") @@ -443,7 +447,7 @@ def __init__( self.max_properties = max_properties self.required = required - def validate(self, value: typing.Any, *, strict: bool = False) -> typing.Any: + def validate(self, value: typing.Any) -> typing.Any: if value is None and self.allow_null: return None elif value is None: @@ -492,7 +496,7 @@ def validate(self, value: typing.Any, *, strict: bool = False) -> typing.Any: validated[key] = child_schema.get_default_value() continue item = value[key] - child_value, error = child_schema.validate_or_error(item, strict=strict) + child_value, error = child_schema.validate_or_error(item) if not error: validated[key] = child_value else: @@ -504,9 +508,7 @@ def validate(self, value: typing.Any, *, strict: bool = False) -> typing.Any: for pattern, child_schema in self.pattern_properties.items(): if isinstance(key, str) and re.search(pattern, key): item = value[key] - child_value, error = child_schema.validate_or_error( - item, strict=strict - ) + child_value, error = child_schema.validate_or_error(item) if not error: validated[key] = child_value else: @@ -535,7 +537,7 @@ def validate(self, value: typing.Any, *, strict: bool = False) -> typing.Any: child_schema = self.additional_properties for key in remaining: item = value[key] - child_value, error = child_schema.validate_or_error(item, strict=strict) + child_value, error = child_schema.validate_or_error(item) if not error: validated[key] = child_value else: @@ -599,7 +601,7 @@ def __init__( self.max_items = max_items self.unique_items = unique_items - def validate(self, value: typing.Any, *, strict: bool = False) -> typing.Any: + def validate(self, value: typing.Any) -> typing.Any: if value is None and self.allow_null: return None elif value is None: @@ -639,7 +641,7 @@ def validate(self, value: typing.Any, *, strict: bool = False) -> typing.Any: if validator is None: validated.append(item) else: - item, error = validator.validate_or_error(item, strict=strict) + item, error = validator.validate_or_error(item) if error: error_messages += error.messages(add_prefix=pos) else: @@ -704,7 +706,7 @@ def __init__(self, any_of: typing.List[Field], **kwargs: typing.Any): if any([child.allow_null for child in any_of]): self.allow_null = True - def validate(self, value: typing.Any, strict: bool = False) -> typing.Any: + def validate(self, value: typing.Any) -> typing.Any: if value is None and self.allow_null: return None elif value is None: @@ -712,7 +714,7 @@ def validate(self, value: typing.Any, strict: bool = False) -> typing.Any: candidate_errors = [] for child in self.any_of: - validated, error = child.validate_or_error(value, strict=strict) + validated, error = child.validate_or_error(value) if error is None: return validated else: @@ -738,7 +740,7 @@ class Any(Field): Always matches. """ - def validate(self, value: typing.Any, strict: bool = False) -> typing.Any: + def validate(self, value: typing.Any) -> typing.Any: return value @@ -754,7 +756,7 @@ def __init__(self, const: typing.Any, **kwargs: typing.Any): super().__init__(**kwargs) self.const = const - def validate(self, value: typing.Any, strict: bool = False) -> typing.Any: + def validate(self, value: typing.Any) -> typing.Any: if value != self.const: if self.const is None: raise self.validation_error("only_null") diff --git a/typesystem/formats.py b/typesystem/formats.py index d3dfb9d..9f56ca3 100644 --- a/typesystem/formats.py +++ b/typesystem/formats.py @@ -118,7 +118,7 @@ def validate(self, value: typing.Any) -> datetime.datetime: raise self.validation_error("format") groups = match.groupdict() - if groups["microsecond"]: + if groups["microsecond"] is not None: groups["microsecond"] = groups["microsecond"].ljust(6, "0") tzinfo_str = groups.pop("tzinfo") diff --git a/typesystem/forms.py b/typesystem/forms.py index 0beca8f..cf4e0ec 100644 --- a/typesystem/forms.py +++ b/typesystem/forms.py @@ -6,7 +6,6 @@ import typing -from typesystem.base import ValidationError from typesystem.fields import Boolean, Choice, Field, Object, String from typesystem.schemas import Schema @@ -35,20 +34,40 @@ def __init__( self, *, env: "jinja2.Environment", - schema: typing.Type[Schema], - values: dict = None, - errors: ValidationError = None, + schema: Schema, + values: typing.Dict[str, typing.Any] = None, ) -> None: self.env = env self.schema = schema - self.values = values - self.errors = errors + self.values = self.schema.serialize(values) + self.errors: typing.Optional[typing.Dict[str, typing.Any]] = None + self._validate_called = False + + def validate(self, data: dict = None) -> None: + assert not self._validate_called, "validate() has already been called." + self.data = data + self.values, self.errors = self.schema.validate_or_error(data) + self._validate_called = True + + @property + def is_valid(self) -> bool: + assert self._validate_called, "validate() has not been called." + return self.errors is None + + @property + def validated_data(self) -> typing.Any: + return self.values def render_fields(self) -> str: + values = self.data if self.errors else self.values + errors = self.errors + html = "" for field_name, field in self.schema.fields.items(): - value = None if self.values is None else self.values.get(field_name) - error = None if self.errors is None else self.errors.get(field_name) + if field.read_only: + continue + value = None if values is None else values.get(field_name) + error = None if errors is None else errors.get(field_name) html += self.render_field( field_name=field_name, field=field, value=value, error=error ) @@ -62,8 +81,7 @@ def render_field( value: typing.Any = None, error: str = None, ) -> str: - field_id_prefix = "form-" + self.schema.__name__.lower() + "-" - field_id = field_id_prefix + field_name.replace("_", "-") + field_id = field_name.replace("_", "-") label = field.title or field_name allow_empty = field.allow_null or getattr(field, "allow_blank", False) required = not field.has_default() and not allow_empty @@ -136,11 +154,7 @@ def load_template_env( ) return jinja2.Environment(loader=loader, autoescape=True) - def Form( - self, - schema: typing.Type[Schema], - *, - values: dict = None, - errors: ValidationError = None, - ) -> Form: # type: ignore - return Form(env=self.env, schema=schema, values=values, errors=errors) + def create_form( + self, schema: Schema, values: typing.Dict[str, typing.Any] = None + ) -> Form: + return Form(env=self.env, schema=schema, values=values) diff --git a/typesystem/json_schema.py b/typesystem/json_schema.py index 3b71f0f..6d7a9fe 100644 --- a/typesystem/json_schema.py +++ b/typesystem/json_schema.py @@ -21,7 +21,7 @@ String, Union, ) -from typesystem.schemas import Reference, Schema, SchemaDefinitions +from typesystem.schemas import Definitions, Reference, Schema TYPE_CONSTRAINTS = { "additionalItems", @@ -51,7 +51,7 @@ } -definitions = SchemaDefinitions() +definitions = Definitions() JSONSchema = ( Object( @@ -108,13 +108,13 @@ def from_json_schema( - data: typing.Union[bool, dict], definitions: SchemaDefinitions = None + data: typing.Union[bool, dict], definitions: Definitions = None ) -> Field: if isinstance(data, bool): return {True: Any(), False: NeverMatch()}[data] if definitions is None: - definitions = SchemaDefinitions() + definitions = Definitions() for key, value in data.get("definitions", {}).items(): ref = f"#/definitions/{key}" definitions[ref] = from_json_schema(value, definitions=definitions) @@ -147,7 +147,7 @@ def from_json_schema( return Any() -def type_from_json_schema(data: dict, definitions: SchemaDefinitions) -> Field: +def type_from_json_schema(data: dict, definitions: Definitions) -> Field: """ Build a typed field or union of typed fields from a JSON schema object. """ @@ -197,7 +197,7 @@ def get_valid_types(data: dict) -> typing.Tuple[typing.Set[str], bool]: def from_json_schema_type( - data: dict, type_string: str, allow_null: bool, definitions: SchemaDefinitions + data: dict, type_string: str, allow_null: bool, definitions: Definitions ) -> Field: """ Build a typed field from a JSON schema object. @@ -212,6 +212,7 @@ def from_json_schema_type( "exclusive_maximum": data.get("exclusiveMaximum", None), "multiple_of": data.get("multipleOf", None), "default": data.get("default", NO_DEFAULT), + "coerce_types": False, } return Float(**kwargs) @@ -224,6 +225,7 @@ def from_json_schema_type( "exclusive_maximum": data.get("exclusiveMaximum", None), "multiple_of": data.get("multipleOf", None), "default": data.get("default", NO_DEFAULT), + "coerce_types": False, } return Integer(**kwargs) @@ -237,11 +239,16 @@ def from_json_schema_type( "format": data.get("format"), "pattern": data.get("pattern", None), "default": data.get("default", NO_DEFAULT), + "coerce_types": False, } return String(**kwargs) elif type_string == "boolean": - kwargs = {"allow_null": allow_null, "default": data.get("default", NO_DEFAULT)} + kwargs = { + "allow_null": allow_null, + "default": data.get("default", NO_DEFAULT), + "coerce_types": False, + } return Boolean(**kwargs) elif type_string == "array": @@ -329,49 +336,49 @@ def from_json_schema_type( assert False, f"Invalid argument type_string={type_string!r}" # pragma: no cover -def ref_from_json_schema(data: dict, definitions: SchemaDefinitions) -> Field: +def ref_from_json_schema(data: dict, definitions: Definitions) -> Field: reference_string = data["$ref"] assert reference_string.startswith("#/"), "Unsupported $ref style in document." return Reference(to=reference_string, definitions=definitions) -def enum_from_json_schema(data: dict, definitions: SchemaDefinitions) -> Field: +def enum_from_json_schema(data: dict, definitions: Definitions) -> Field: choices = [(item, item) for item in data["enum"]] kwargs = {"choices": choices, "default": data.get("default", NO_DEFAULT)} return Choice(**kwargs) -def const_from_json_schema(data: dict, definitions: SchemaDefinitions) -> Field: +def const_from_json_schema(data: dict, definitions: Definitions) -> Field: const = data["const"] kwargs = {"const": const, "default": data.get("default", NO_DEFAULT)} return Const(**kwargs) -def all_of_from_json_schema(data: dict, definitions: SchemaDefinitions) -> Field: +def all_of_from_json_schema(data: dict, definitions: Definitions) -> Field: all_of = [from_json_schema(item, definitions=definitions) for item in data["allOf"]] kwargs = {"all_of": all_of, "default": data.get("default", NO_DEFAULT)} return AllOf(**kwargs) -def any_of_from_json_schema(data: dict, definitions: SchemaDefinitions) -> Field: +def any_of_from_json_schema(data: dict, definitions: Definitions) -> Field: any_of = [from_json_schema(item, definitions=definitions) for item in data["anyOf"]] kwargs = {"any_of": any_of, "default": data.get("default", NO_DEFAULT)} return Union(**kwargs) -def one_of_from_json_schema(data: dict, definitions: SchemaDefinitions) -> Field: +def one_of_from_json_schema(data: dict, definitions: Definitions) -> Field: one_of = [from_json_schema(item, definitions=definitions) for item in data["oneOf"]] kwargs = {"one_of": one_of, "default": data.get("default", NO_DEFAULT)} return OneOf(**kwargs) -def not_from_json_schema(data: dict, definitions: SchemaDefinitions) -> Field: +def not_from_json_schema(data: dict, definitions: Definitions) -> Field: negated = from_json_schema(data["not"], definitions=definitions) kwargs = {"negated": negated, "default": data.get("default", NO_DEFAULT)} return Not(**kwargs) -def if_then_else_from_json_schema(data: dict, definitions: SchemaDefinitions) -> Field: +def if_then_else_from_json_schema(data: dict, definitions: Definitions) -> Field: if_clause = from_json_schema(data["if"], definitions=definitions) then_clause = ( from_json_schema(data["then"], definitions=definitions) @@ -393,7 +400,7 @@ def if_then_else_from_json_schema(data: dict, definitions: SchemaDefinitions) -> def to_json_schema( - arg: typing.Union[Field, typing.Type[Schema]], _definitions: dict = None + arg: typing.Union[Field, Definitions], _definitions: dict = None ) -> typing.Union[bool, dict]: if isinstance(arg, Any): @@ -401,24 +408,21 @@ def to_json_schema( elif isinstance(arg, NeverMatch): return False + field: typing.Optional[Field] data: dict = {} is_root = _definitions is None definitions = {} if _definitions is None else _definitions if isinstance(arg, Field): field = arg - elif isinstance(arg, SchemaDefinitions): + elif isinstance(arg, Definitions): field = None for key, value in arg.items(): definitions[key] = to_json_schema(value, _definitions=definitions) - else: - field = arg.make_validator() if isinstance(field, Reference): - data["$ref"] = f"#/definitions/{field.target_string}" - definitions[field.target_string] = to_json_schema( - field.target, _definitions=definitions - ) + data["$ref"] = f"#/definitions/{field.to}" + definitions[field.to] = to_json_schema(field.target, _definitions=definitions) elif isinstance(field, String): data["type"] = ["string", "null"] if field.allow_null else "string" @@ -513,6 +517,17 @@ def to_json_schema( if field.required: data["required"] = field.required + elif isinstance(field, Schema): + data["type"] = ["object", "null"] if field.allow_null else "object" + data.update(get_standard_properties(field)) + if field.fields: + data["properties"] = { + key: to_json_schema(value, _definitions=definitions) + for key, value in field.fields.items() + } + if field.required: + data["required"] = field.required + elif isinstance(field, Choice): data["enum"] = [key for key, value in field.choices] data.update(get_standard_properties(field)) diff --git a/typesystem/schemas.py b/typesystem/schemas.py index a103a53..96b00e3 100644 --- a/typesystem/schemas.py +++ b/typesystem/schemas.py @@ -1,12 +1,96 @@ import typing -from abc import ABCMeta -from collections.abc import Mapping, MutableMapping +from collections.abc import MutableMapping -from typesystem.base import ValidationError, ValidationResult -from typesystem.fields import Array, Field, Object +from typesystem.base import ValidationError +from typesystem.fields import Field, Message -class SchemaDefinitions(MutableMapping): +class Schema(Field): + errors = { + "type": "Must be an object.", + "null": "May not be null.", + "invalid_key": "All object keys must be strings.", + "required": "This field is required.", + } + + def __init__( + self, + fields: typing.Dict[str, Field], + **kwargs: typing.Any, + ) -> None: + super().__init__(**kwargs) + self.fields = fields + self.required = [ + key + for key, field in fields.items() + if not (field.read_only or field.has_default()) + ] + + def validate(self, value: typing.Any) -> typing.Any: + if value is None and self.allow_null: + return None + elif value is None: + raise self.validation_error("null") + elif not isinstance(value, (dict, typing.Mapping)): + raise self.validation_error("type") + + validated = {} + error_messages = [] + + # Ensure all property keys are strings. + for key in value.keys(): + if not isinstance(key, str): + text = self.get_error_text("invalid_key") + message = Message(text=text, code="invalid_key", index=[key]) + error_messages.append(message) + + # Required properties + for key in self.required: + if key not in value: + text = self.get_error_text("required") + message = Message(text=text, code="required", index=[key]) + error_messages.append(message) + + # Properties + for key, child_schema in self.fields.items(): + if child_schema.read_only: + continue + + if key not in value: + if child_schema.has_default(): + validated[key] = child_schema.get_default_value() + continue + item = value[key] + child_value, error = child_schema.validate_or_error(item) + if not error: + validated[key] = child_value + else: + error_messages += error.messages(add_prefix=key) + + if error_messages: + raise ValidationError(messages=error_messages) + + return validated + + def serialize( + self, obj: typing.Any + ) -> typing.Optional[typing.Dict[str, typing.Any]]: + if obj is None: + return None + + is_mapping = isinstance(obj, dict) + + ret = {} + for key, field in self.fields.items(): + try: + value = obj[key] if is_mapping else getattr(obj, key) + except (KeyError, AttributeError): + continue + ret[key] = field.serialize(value) + return ret + + +class Definitions(MutableMapping): def __init__(self, *args: typing.Any, **kwargs: typing.Any) -> None: self._definitions = dict(*args, **kwargs) # type: dict @@ -29,217 +113,30 @@ def __delitem__(self, key: typing.Any) -> None: del self._definitions[key] -def set_definitions(field: Field, definitions: SchemaDefinitions) -> None: - """ - Recursively set the definitions that string-referenced `Reference` fields - should use. - """ - if isinstance(field, Reference) and field.definitions is None: - field.definitions = definitions - elif isinstance(field, Array): - if field.items is not None: - if isinstance(field.items, (tuple, list)): - for child in field.items: - set_definitions(child, definitions) - else: - set_definitions(field.items, definitions) - elif isinstance(field, Object): - for child in field.properties.values(): - set_definitions(child, definitions) - - -class SchemaMetaclass(ABCMeta): - def __new__( - cls: type, - name: str, - bases: typing.Sequence[type], - attrs: dict, - definitions: SchemaDefinitions = None, - ) -> "SchemaMetaclass": - fields: typing.Dict[str, Field] = {} - - for key, value in list(attrs.items()): - if isinstance(value, Field): - attrs.pop(key) - fields[key] = value - - # If this class is subclassing other Schema classes, add their fields. - for base in reversed(bases): - base_fields = getattr(base, "fields", {}) - for key, value in base_fields.items(): - if isinstance(value, Field) and key not in fields: - fields[key] = value - - # Add the definitions to any `Reference` fields that we're including. - if definitions is not None: - for field in fields.values(): - set_definitions(field, definitions) - - # Sort fields by their actual position in the source code, - # using `Field._creation_counter` - attrs["fields"] = dict( - sorted(fields.items(), key=lambda item: item[1]._creation_counter) - ) - - new_type = super(SchemaMetaclass, cls).__new__( # type: ignore - cls, name, bases, attrs - ) - if definitions is not None: - definitions[name] = new_type - return new_type - - -class Schema(Mapping, metaclass=SchemaMetaclass): - fields: typing.Dict[str, Field] = {} - - def __init__(self, *args: typing.Any, **kwargs: typing.Any) -> None: - if args: - assert len(args) == 1 - assert not kwargs - item = args[0] - if isinstance(item, dict): - for key in self.fields.keys(): - if key in item: - setattr(self, key, item[key]) - else: - for key in self.fields.keys(): - if hasattr(item, key): - setattr(self, key, getattr(item, key)) - return - - for key, schema in self.fields.items(): - if key in kwargs: - value = kwargs.pop(key) - value, error = schema.validate_or_error(value) - if error: - class_name = self.__class__.__name__ - error_text = " ".join( - [message.text for message in error.messages()] - ) - message = ( - f"Invalid argument {key!r} for {class_name}(). {error_text}" - ) - raise TypeError(message) - setattr(self, key, value) - elif schema.has_default(): - setattr(self, key, schema.get_default_value()) - - if kwargs: - key = list(kwargs.keys())[0] - class_name = self.__class__.__name__ - message = f"{key!r} is an invalid keyword argument for {class_name}()." - raise TypeError(message) - - @classmethod - def make_validator(cls: typing.Type["Schema"], *, strict: bool = False) -> Field: - required = [key for key, value in cls.fields.items() if not value.has_default()] - return Object( - properties=cls.fields, - required=required, - additional_properties=False if strict else None, - ) - - @classmethod - def validate( - cls: typing.Type["Schema"], value: typing.Any, *, strict: bool = False - ) -> "Schema": - validator = cls.make_validator(strict=strict) - value = validator.validate(value, strict=strict) - return cls(value) - - @classmethod - def validate_or_error( - cls: typing.Type["Schema"], value: typing.Any, *, strict: bool = False - ) -> ValidationResult: - try: - value = cls.validate(value, strict=strict) - except ValidationError as error: - return ValidationResult(value=None, error=error) - return ValidationResult(value=value, error=None) - - @property - def is_sparse(self) -> bool: - # A schema is sparsely populated if it does not include attributes - # for all its fields. - return bool([key for key in self.fields.keys() if not hasattr(self, key)]) - - def __eq__(self, other: typing.Any) -> bool: - if not isinstance(other, self.__class__): - return False - - for key in self.fields.keys(): - if getattr(self, key) != getattr(other, key): - return False - return True - - def __getitem__(self, key: typing.Any) -> typing.Any: - try: - field = self.fields[key] - value = getattr(self, key) - except (KeyError, AttributeError): - raise KeyError(key) from None - else: - return field.serialize(value) - - def __iter__(self) -> typing.Iterator[str]: - for key in self.fields: - if hasattr(self, key): - yield key - - def __len__(self) -> int: - return len([key for key in self.fields if hasattr(self, key)]) - - def __repr__(self) -> str: - class_name = self.__class__.__name__ - arguments = { - key: getattr(self, key) for key in self.fields.keys() if hasattr(self, key) - } - argument_str = ", ".join( - [f"{key}={value!r}" for key, value in arguments.items()] - ) - sparse_indicator = " [sparse]" if self.is_sparse else "" - return f"{class_name}({argument_str}){sparse_indicator}" - - class Reference(Field): errors = {"null": "May not be null."} def __init__( self, - to: typing.Union[str, typing.Type[Schema]], - definitions: typing.Mapping = None, + to: str, + definitions: Definitions, **kwargs: typing.Any, ) -> None: super().__init__(**kwargs) self.to = to self.definitions = definitions - if isinstance(to, str): - self._target_string = to - else: - assert issubclass(to, Schema) - self._target = to @property - def target_string(self) -> str: - if not hasattr(self, "_target_string"): - self._target_string = self._target.__name__ - return self._target_string + def target(self) -> typing.Any: + return self.definitions[self.to] - @property - def target(self) -> typing.Union[Field, typing.Type[Schema]]: - if not hasattr(self, "_target"): - assert ( - self.definitions is not None - ), "String reference missing 'definitions'." - self._target = self.definitions[self.to] - return self._target - - def validate(self, value: typing.Any, *, strict: bool = False) -> typing.Any: + def validate(self, value: typing.Any) -> typing.Any: if value is None and self.allow_null: return None elif value is None: raise self.validation_error("null") - return self.target.validate(value, strict=strict) + + return self.target.validate(value) def serialize(self, obj: typing.Any) -> typing.Any: if obj is None: diff --git a/typesystem/tokenize/positional_validation.py b/typesystem/tokenize/positional_validation.py index b4580d6..4611006 100644 --- a/typesystem/tokenize/positional_validation.py +++ b/typesystem/tokenize/positional_validation.py @@ -7,7 +7,7 @@ def validate_with_positions( - *, token: Token, validator: typing.Union[Field, typing.Type[Schema]] + *, token: Token, validator: typing.Union[Field, Schema] ) -> typing.Any: try: return validator.validate(token.value) diff --git a/typesystem/tokenize/tokenize_json.py b/typesystem/tokenize/tokenize_json.py index 82142bf..b04217e 100644 --- a/typesystem/tokenize/tokenize_json.py +++ b/typesystem/tokenize/tokenize_json.py @@ -182,7 +182,7 @@ def tokenize_json(content: typing.Union[str, bytes]) -> Token: def validate_json( content: typing.Union[str, bytes], - validator: typing.Union[Field, typing.Type[Schema]], + validator: typing.Union[Field, Schema], ) -> typing.Any: """ Parse and validate a JSON string, returning positionally marked error diff --git a/typesystem/tokenize/tokenize_yaml.py b/typesystem/tokenize/tokenize_yaml.py index 47bf48f..48c3542 100644 --- a/typesystem/tokenize/tokenize_yaml.py +++ b/typesystem/tokenize/tokenize_yaml.py @@ -53,7 +53,7 @@ def construct_sequence(loader: "yaml.Loader", node: "yaml.Node") -> ListToken: def construct_scalar(loader: "yaml.Loader", node: "yaml.Node") -> ScalarToken: start = node.start_mark.index end = node.end_mark.index - value = loader.construct_scalar(node) + value = loader.construct_scalar(node) # type: ignore return ScalarToken(value, start, end - 1, content=str_content) def construct_int(loader: "yaml.Loader", node: "yaml.Node") -> ScalarToken: @@ -104,6 +104,8 @@ def construct_null(loader: "yaml.Loader", node: "yaml.Node") -> ScalarToken: return yaml.load(str_content, CustomSafeLoader) except (yaml.scanner.ScannerError, yaml.parser.ParserError) as exc: # type: ignore # Handle cases that result in a YAML parse error. + assert exc.problem is not None + assert exc.problem_mark is not None text = exc.problem + "." position = _get_position(str_content, index=exc.problem_mark.index) raise ParseError(text=text, code="parse_error", position=position) @@ -111,7 +113,7 @@ def construct_null(loader: "yaml.Loader", node: "yaml.Node") -> ScalarToken: def validate_yaml( content: typing.Union[str, bytes], - validator: typing.Union[Field, typing.Type[Schema]], + validator: typing.Union[Field, Schema], ) -> typing.Any: """ Parse and validate a YAML string, returning positionally marked error