├── docs ├── _static │ └── .gitkeep ├── authors.rst ├── history.rst ├── readme.rst ├── contributing.rst ├── modules.rst ├── spelling_wordlist.txt ├── installation.rst ├── index.rst ├── implementation_notes.rst ├── jsonmodels.rst ├── Makefile ├── make.bat ├── usage.rst └── conf.py ├── MANIFEST.in ├── jsonmodels ├── __init__.py ├── errors.py ├── collections.py ├── parsers.py ├── utilities.py ├── models.py ├── validators.py ├── builders.py └── fields.py ├── setup.cfg ├── tox.ini ├── .coveragerc ├── tests ├── fixtures │ ├── schema5.json │ ├── schema_enum.json │ ├── schema_pattern.json │ ├── schema_pattern_flag.json │ ├── schema_list_item_simple.json │ ├── schema6.json │ ├── schema1.json │ ├── schema4.json │ ├── schema_max.json │ ├── schema_min.json │ ├── schema_length_min.json │ ├── schema_length_max.json │ ├── schema_length.json │ ├── schema_max_exclusive.json │ ├── schema_min_exclusive.json │ ├── schema_circular.json │ ├── schema_with_defaults.json │ ├── schema_with_list.json │ ├── schema_length_list.json │ ├── schema2.json │ ├── schema_circular2.json │ └── schema3.json ├── __init__.py ├── utilities.py ├── test_memory_usage.py ├── test_circular_references.py ├── test_project.py ├── test_nullable.py ├── test_fields.py ├── test_lazy_loading.py ├── test_name.py ├── test_utilities.py ├── test_struct.py ├── test_datetime_fields.py ├── test_data_initialization.py ├── test_validation.py ├── test_schema.py └── test_jsonmodels.py ├── requirements.txt ├── tasks.py ├── .github └── workflows │ ├── lint.yml │ ├── release.yml │ └── test.yml ├── .gitignore ├── .pre-commit-config.yaml ├── AUTHORS.rst ├── Makefile ├── LICENSE ├── CODE_OF_CONDUCT.md ├── setup.py ├── CONTRIBUTING.rst ├── HISTORY.rst └── README.rst /docs/_static/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/authors.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../AUTHORS.rst -------------------------------------------------------------------------------- /docs/history.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../HISTORY.rst -------------------------------------------------------------------------------- /docs/readme.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../README.rst -------------------------------------------------------------------------------- /docs/contributing.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../CONTRIBUTING.rst -------------------------------------------------------------------------------- /docs/modules.rst: -------------------------------------------------------------------------------- 1 | jsonmodels 2 | ========== 3 | 4 | .. toctree:: 5 | :maxdepth: 4 6 | 7 | jsonmodels 8 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include AUTHORS.rst 2 | include CONTRIBUTING.rst 3 | include HISTORY.rst 4 | include LICENSE 5 | include README.rst -------------------------------------------------------------------------------- /jsonmodels/__init__.py: -------------------------------------------------------------------------------- 1 | __author__ = "Szczepan Cieślik" 2 | __email__ = "szczepan.cieslik@gmail.com" 3 | __version__ = "2.7.0" 4 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [flake8] 2 | exclude = ./docs/conf.py 3 | max-complexity = 8 4 | max_line_length = 88 5 | 6 | [isort] 7 | profile=black 8 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py38, py39, py310, py311, py312 3 | 4 | [testenv] 5 | commands = python setup.py test 6 | deps = 7 | -r{toxinidir}/requirements.txt 8 | -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | branch = True 3 | source = jsonmodels/* 4 | omit = 5 | setup.py 6 | tasks.py 7 | jsonmodels/__init__.py 8 | [report] 9 | show_missing = true -------------------------------------------------------------------------------- /jsonmodels/errors.py: -------------------------------------------------------------------------------- 1 | class ValidationError(RuntimeError): 2 | pass 3 | 4 | 5 | class FieldNotFound(RuntimeError): 6 | pass 7 | 8 | 9 | class FieldNotSupported(ValueError): 10 | pass 11 | -------------------------------------------------------------------------------- /tests/fixtures/schema5.json: -------------------------------------------------------------------------------- 1 | { 2 | "additionalProperties": false, 3 | "properties": { 4 | "has_childen": { 5 | "type": "boolean" 6 | } 7 | }, 8 | "type": "object" 9 | } 10 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | def _have_flake8(): 2 | try: 3 | import flake8 # noqa: F401 4 | 5 | return True 6 | except ImportError: 7 | return False 8 | 9 | 10 | LINT = _have_flake8() 11 | CHECK_SPELLING = False 12 | -------------------------------------------------------------------------------- /docs/spelling_wordlist.txt: -------------------------------------------------------------------------------- 1 | bool 2 | cieślik 3 | datetime 4 | explicite 5 | django 6 | docstring 7 | docstrings 8 | indices 9 | jsonmodels 10 | kwargs 11 | metaclass 12 | namedtuple 13 | repo 14 | schemas 15 | szczepan 16 | regex 17 | validator 18 | validators 19 | -------------------------------------------------------------------------------- /tests/fixtures/schema_enum.json: -------------------------------------------------------------------------------- 1 | { 2 | "additionalProperties": false, 3 | "properties": { 4 | "handness": { 5 | "type": "string", 6 | "enum": ["left", "right"] 7 | } 8 | }, 9 | "type": "object" 10 | } 11 | -------------------------------------------------------------------------------- /tests/fixtures/schema_pattern.json: -------------------------------------------------------------------------------- 1 | { 2 | "additionalProperties": false, 3 | "properties": { 4 | "name": { 5 | "type": "string", 6 | "pattern": "/^some pattern$/" 7 | } 8 | }, 9 | "type": "object" 10 | } 11 | -------------------------------------------------------------------------------- /tests/fixtures/schema_pattern_flag.json: -------------------------------------------------------------------------------- 1 | { 2 | "additionalProperties": false, 3 | "properties": { 4 | "name": { 5 | "type": "string", 6 | "pattern": "/^some pattern$/i" 7 | } 8 | }, 9 | "type": "object" 10 | } 11 | -------------------------------------------------------------------------------- /docs/installation.rst: -------------------------------------------------------------------------------- 1 | ============ 2 | Installation 3 | ============ 4 | 5 | At the command line:: 6 | 7 | $ easy_install jsonmodels 8 | 9 | Or, if you have `virtualenvwrapper` installed:: 10 | 11 | $ mkvirtualenv jsonmodels 12 | $ pip install jsonmodels 13 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | -e . 2 | Jinja2 3 | MarkupSafe 4 | Pygments 5 | Sphinx 6 | coverage 7 | docutils 8 | flake8 9 | invoke 10 | importlib-metadata==4.13.0 11 | mccabe 12 | pep8 13 | py 14 | pyflakes 15 | pytest 16 | pytest-cov 17 | sphinxcontrib-spelling 18 | tox 19 | virtualenv 20 | wheel 21 | -------------------------------------------------------------------------------- /tests/fixtures/schema_list_item_simple.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "object", 3 | "additionalProperties": false, 4 | "properties": { 5 | "lucky_numbers": { 6 | "type": "array", 7 | "items": { 8 | "type": "number" 9 | } 10 | } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /tests/fixtures/schema6.json: -------------------------------------------------------------------------------- 1 | { 2 | "additionalProperties": false, 3 | "properties": { 4 | "name": { 5 | "type": "string", 6 | "some": "unproper value" 7 | }, 8 | "surname": { 9 | "type": "string" 10 | } 11 | }, 12 | "type": "object" 13 | } 14 | -------------------------------------------------------------------------------- /tests/fixtures/schema1.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "object", 3 | "properties": { 4 | "name": {"type": "string"}, 5 | "surname": {"type": "string"}, 6 | "age": {"type": "number"}, 7 | "extra": {"type": "object"} 8 | }, 9 | "required": ["name", "surname"], 10 | "additionalProperties": false 11 | } 12 | -------------------------------------------------------------------------------- /tasks.py: -------------------------------------------------------------------------------- 1 | """Tasks for invoke.""" 2 | 3 | from invoke import run, task 4 | 5 | 6 | @task 7 | def test(): 8 | run("./setup.py test --quick") 9 | 10 | 11 | @task 12 | def fulltest(): 13 | run("./setup.py test") 14 | 15 | 16 | @task 17 | def coverage(): 18 | run("./setup.py test", hide="stdout") 19 | run("coverage html") 20 | -------------------------------------------------------------------------------- /tests/fixtures/schema4.json: -------------------------------------------------------------------------------- 1 | { 2 | "additionalProperties": false, 3 | "properties": { 4 | "date": { 5 | "type": "string" 6 | }, 7 | "end": { 8 | "type": "string" 9 | }, 10 | "time": { 11 | "type": "string" 12 | } 13 | }, 14 | "type": "object" 15 | } 16 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: Lint 2 | 3 | on: [push, pull_request, workflow_dispatch] 4 | 5 | jobs: 6 | lint: 7 | runs-on: ubuntu-latest 8 | 9 | steps: 10 | - uses: actions/checkout@v3 11 | - uses: actions/setup-python@v4 12 | with: 13 | python-version: "3.x" 14 | - uses: pre-commit/action@v2.0.3 15 | -------------------------------------------------------------------------------- /tests/fixtures/schema_max.json: -------------------------------------------------------------------------------- 1 | { 2 | "additionalProperties": false, 3 | "properties": { 4 | "age": { 5 | "type": "number", 6 | "maximum": 18 7 | }, 8 | "name": { 9 | "type": "string" 10 | }, 11 | "surname": { 12 | "type": "string" 13 | } 14 | }, 15 | "type": "object" 16 | } 17 | -------------------------------------------------------------------------------- /tests/fixtures/schema_min.json: -------------------------------------------------------------------------------- 1 | { 2 | "additionalProperties": false, 3 | "properties": { 4 | "age": { 5 | "type": "number", 6 | "minimum": 18 7 | }, 8 | "name": { 9 | "type": "string" 10 | }, 11 | "surname": { 12 | "type": "string" 13 | } 14 | }, 15 | "type": "object" 16 | } 17 | -------------------------------------------------------------------------------- /tests/fixtures/schema_length_min.json: -------------------------------------------------------------------------------- 1 | { 2 | "additionalProperties": false, 3 | "properties": { 4 | "age": { 5 | "type": "number" 6 | }, 7 | "name": { 8 | "type": "string", 9 | "minLength": 5 10 | }, 11 | "surname": { 12 | "type": "string" 13 | } 14 | }, 15 | "type": "object" 16 | } 17 | -------------------------------------------------------------------------------- /tests/fixtures/schema_length_max.json: -------------------------------------------------------------------------------- 1 | { 2 | "additionalProperties": false, 3 | "properties": { 4 | "age": { 5 | "type": "number" 6 | }, 7 | "name": { 8 | "type": "string", 9 | "maxLength": 20 10 | }, 11 | "surname": { 12 | "type": "string" 13 | } 14 | }, 15 | "type": "object" 16 | } 17 | -------------------------------------------------------------------------------- /tests/utilities.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | 4 | FIXTURES_DIR = os.path.join(os.path.dirname(os.path.realpath(__file__)), "fixtures") 5 | 6 | 7 | def get_fixture(filepath): 8 | """Get fixture content. 9 | 10 | :param string filepath: Path to file. 11 | 12 | """ 13 | with open(os.path.join(FIXTURES_DIR, filepath)) as fixture: 14 | return json.loads(fixture.read()) 15 | -------------------------------------------------------------------------------- /tests/fixtures/schema_length.json: -------------------------------------------------------------------------------- 1 | { 2 | "additionalProperties": false, 3 | "properties": { 4 | "age": { 5 | "type": "number" 6 | }, 7 | "name": { 8 | "type": "string", 9 | "minLength": 5, 10 | "maxLength": 20 11 | }, 12 | "surname": { 13 | "type": "string" 14 | } 15 | }, 16 | "type": "object" 17 | } 18 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | Welcome to JSON models' documentation! 2 | ====================================== 3 | 4 | Contents: 5 | 6 | .. toctree:: 7 | :maxdepth: 2 8 | 9 | readme 10 | installation 11 | usage 12 | implementation_notes 13 | modules 14 | contributing 15 | authors 16 | history 17 | 18 | Indices and tables 19 | ================== 20 | 21 | * :ref:`genindex` 22 | * :ref:`modindex` 23 | * :ref:`search` 24 | -------------------------------------------------------------------------------- /tests/fixtures/schema_max_exclusive.json: -------------------------------------------------------------------------------- 1 | { 2 | "additionalProperties": false, 3 | "properties": { 4 | "age": { 5 | "type": "number", 6 | "maximum": 18, 7 | "exclusiveMaximum": true 8 | }, 9 | "name": { 10 | "type": "string" 11 | }, 12 | "surname": { 13 | "type": "string" 14 | } 15 | }, 16 | "type": "object" 17 | } 18 | -------------------------------------------------------------------------------- /tests/fixtures/schema_min_exclusive.json: -------------------------------------------------------------------------------- 1 | { 2 | "additionalProperties": false, 3 | "properties": { 4 | "age": { 5 | "type": "number", 6 | "minimum": 18, 7 | "exclusiveMinimum": true 8 | }, 9 | "name": { 10 | "type": "string" 11 | }, 12 | "surname": { 13 | "type": "string" 14 | } 15 | }, 16 | "type": "object" 17 | } 18 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.py[cod] 2 | 3 | # C extensions 4 | *.so 5 | 6 | # Packages 7 | *.egg 8 | *.egg-info 9 | dist 10 | build 11 | eggs 12 | parts 13 | bin 14 | var 15 | sdist 16 | develop-eggs 17 | .installed.cfg 18 | lib 19 | lib64 20 | 21 | # Installer logs 22 | pip-log.txt 23 | 24 | # Unit test / coverage reports 25 | .cache 26 | .coverage 27 | .tox 28 | .cache/ 29 | nosetests.xml 30 | htmlcov 31 | 32 | # Translations 33 | *.mo 34 | 35 | # Mr Developer 36 | .mr.developer.cfg 37 | .project 38 | .pydevproject 39 | 40 | # Complexity 41 | output/*.html 42 | output/*/index.html 43 | 44 | # Sphinx 45 | docs/_build 46 | -------------------------------------------------------------------------------- /jsonmodels/collections.py: -------------------------------------------------------------------------------- 1 | class ModelCollection(list): 2 | 3 | """`ModelCollection` is list which validates stored values. 4 | 5 | Validation is made with use of field passed to `__init__` at each point, 6 | when new value is assigned. 7 | 8 | """ 9 | 10 | def __init__(self, field): 11 | self.field = field 12 | 13 | def append(self, value): 14 | self.field.validate_single_value(value) 15 | super().append(value) 16 | 17 | def __setitem__(self, key, value): 18 | self.field.validate_single_value(value) 19 | super().__setitem__(key, value) 20 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/psf/black 3 | rev: 23.11.0 4 | hooks: 5 | - id: black 6 | args: ["--target-version", "py37"] 7 | 8 | - repo: https://github.com/PyCQA/isort 9 | rev: 5.12.0 10 | hooks: 11 | - id: isort 12 | 13 | - repo: https://github.com/pre-commit/pygrep-hooks 14 | rev: v1.10.0 15 | hooks: 16 | - id: python-check-blanket-noqa 17 | 18 | - repo: https://github.com/pre-commit/pre-commit-hooks 19 | rev: v4.5.0 20 | hooks: 21 | - id: check-merge-conflict 22 | - id: check-yaml 23 | 24 | ci: 25 | autoupdate_schedule: quarterly 26 | -------------------------------------------------------------------------------- /docs/implementation_notes.rst: -------------------------------------------------------------------------------- 1 | ==================== 2 | Implementation notes 3 | ==================== 4 | 5 | Below you can read some implementation specific quirks you should know/remember 6 | about when you are using `jsonmodels` (especially on production 7 | servers/applications). 8 | 9 | PyPy 10 | ---- 11 | 12 | PyPy is supported, although there is one problem with garbage collecting: 13 | **PyPy's weakref implementation is not stable, so garbage collecting may not 14 | work, which may cause memory leak** (values for nonexistent objects may still 15 | be preserved, since descriptors are for fields implementation). 16 | 17 | All others features are fully supported. 18 | -------------------------------------------------------------------------------- /AUTHORS.rst: -------------------------------------------------------------------------------- 1 | ======= 2 | Credits 3 | ======= 4 | 5 | Development Lead 6 | ---------------- 7 | 8 | * Szczepan Cieślik 9 | 10 | Contributors 11 | ------------ 12 | 13 | (In alphabetical order) 14 | 15 | * Avraham Shukron 16 | * Chris Targett 17 | * Daniel Schiavini 18 | * Dima Kuznetsov 19 | * Hugo van Kemenade 20 | * Jannis Leidel 21 | * Johannes Garimort 22 | * Omer Anson 23 | * Pavel Lipchak 24 | * Roberto Fernandez Diaz 25 | * Vorren 26 | -------------------------------------------------------------------------------- /tests/test_memory_usage.py: -------------------------------------------------------------------------------- 1 | import platform 2 | 3 | from pytest import mark 4 | 5 | from jsonmodels.fields import StringField 6 | from jsonmodels.models import Base 7 | 8 | 9 | class User(Base): 10 | name = StringField() 11 | 12 | 13 | @mark.skipif( 14 | platform.python_implementation() == "PyPy", 15 | reason="PyPy's weakref implementation is not stable.", 16 | ) 17 | def test_garbage_collecting(): 18 | first = len(User.name.memory) 19 | instance = User(name="Bob") 20 | second = len(User.name.memory) 21 | User(name="Frank") 22 | third = len(User.name.memory) 23 | del instance 24 | four = len(User.name.memory) 25 | 26 | assert first < second 27 | assert second == third 28 | assert third > four 29 | assert first == four 30 | -------------------------------------------------------------------------------- /tests/fixtures/schema_circular.json: -------------------------------------------------------------------------------- 1 | { 2 | "additionalProperties": false, 3 | "definitions": { 4 | "tests_test_circular_references_primary": { 5 | "additionalProperties": false, 6 | "properties": { 7 | "name": { 8 | "type": "string" 9 | }, 10 | "secondary": "#/definitions/tests_test_circular_references_secondary" 11 | }, 12 | "type": "object" 13 | }, 14 | "tests_test_circular_references_secondary": { 15 | "additionalProperties": false, 16 | "properties": { 17 | "data": { 18 | "type": "number" 19 | }, 20 | "first": "#/definitions/tests_test_circular_references_primary" 21 | }, 22 | "type": "object" 23 | } 24 | }, 25 | "properties": { 26 | "name": { 27 | "type": "string" 28 | }, 29 | "secondary": "#/definitions/tests_test_circular_references_secondary" 30 | }, 31 | "type": "object" 32 | } 33 | -------------------------------------------------------------------------------- /tests/fixtures/schema_with_defaults.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "object", 3 | "additionalProperties": false, 4 | "properties": { 5 | "age": { 6 | "type": "number", 7 | "default": 18 8 | }, 9 | "name": { 10 | "type": "string", 11 | "default": "John Doe" 12 | }, 13 | "nicknames": { 14 | "type": "array", 15 | "default": [ 16 | "yo", 17 | "dawg" 18 | ], 19 | "items": { 20 | "type": "string" 21 | } 22 | }, 23 | "pet": { 24 | "type": "object", 25 | "additionalProperties": false, 26 | "properties": { 27 | "kind": { 28 | "type": "string", 29 | "default": "Dog" 30 | } 31 | }, 32 | "default": { 33 | "kind": "Cat" 34 | } 35 | }, 36 | "profession": { 37 | "type": "string", 38 | "default": null 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /tests/test_circular_references.py: -------------------------------------------------------------------------------- 1 | from jsonmodels import fields, models 2 | from jsonmodels.utilities import compare_schemas 3 | 4 | from .utilities import get_fixture 5 | 6 | 7 | class Primary(models.Base): 8 | name = fields.StringField() 9 | secondary = fields.EmbeddedField("Secondary") 10 | 11 | 12 | class Secondary(models.Base): 13 | data = fields.IntField() 14 | first = fields.EmbeddedField("Primary") 15 | 16 | 17 | def test_generate_circular_schema(): 18 | schema = Primary.to_json_schema() 19 | 20 | pattern = get_fixture("schema_circular.json") 21 | assert compare_schemas(pattern, schema) is True 22 | 23 | 24 | class File(models.Base): 25 | name = fields.StringField() 26 | size = fields.FloatField() 27 | 28 | 29 | class Directory(models.Base): 30 | name = fields.StringField() 31 | children = fields.ListField(["Directory", File]) 32 | 33 | 34 | class Filesystem(models.Base): 35 | name = fields.StringField() 36 | children = fields.ListField([Directory, File]) 37 | 38 | 39 | def test_generate_circular_schema2(): 40 | schema = Filesystem.to_json_schema() 41 | 42 | pattern = get_fixture("schema_circular2.json") 43 | assert compare_schemas(pattern, schema) is True 44 | -------------------------------------------------------------------------------- /tests/fixtures/schema_with_list.json: -------------------------------------------------------------------------------- 1 | { 2 | "additionalProperties": false, 3 | "properties": { 4 | "names": { 5 | "items": { 6 | "oneOf": [ 7 | { 8 | "type": "string" 9 | }, 10 | { 11 | "type": "number" 12 | }, 13 | { 14 | "type": "number" 15 | }, 16 | { 17 | "type": "boolean" 18 | }, 19 | { 20 | "additionalProperties": false, 21 | "properties": { 22 | "date": { 23 | "type": "string" 24 | }, 25 | "end": { 26 | "type": "string" 27 | }, 28 | "time": { 29 | "type": "string" 30 | } 31 | }, 32 | "type": "object" 33 | } 34 | ] 35 | }, 36 | "type": "array" 37 | } 38 | }, 39 | "type": "object" 40 | } 41 | -------------------------------------------------------------------------------- /tests/fixtures/schema_length_list.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "object", 3 | "additionalProperties": false, 4 | "properties": { 5 | "min_max_len": { 6 | "type": "array", 7 | "items": { 8 | "type": "string" 9 | }, 10 | "minItems": 2, 11 | "maxItems": 4 12 | }, 13 | "min_len": { 14 | "type": "array", 15 | "items": { 16 | "type": "string" 17 | }, 18 | "minItems": 2 19 | }, 20 | "max_len": { 21 | "type": "array", 22 | "items": { 23 | "type": "string" 24 | }, 25 | "minItems": 4 26 | }, 27 | "item_validator_int": { 28 | "type": "array", 29 | "items": { 30 | "type": "number", 31 | "minimum": 10, 32 | "maximum": 20 33 | } 34 | }, 35 | "item_validator_str": { 36 | "type": "array", 37 | "items": { 38 | "type": "string", 39 | "minLength": 10, 40 | "maxLength": 20, 41 | "pattern": "/\\w+/" 42 | }, 43 | "minItems": 1, 44 | "maxItems": 2 45 | }, 46 | "surname": { 47 | "type": "string" 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: clean-pyc clean-build docs clean 2 | 3 | help: 4 | @echo "clean-build - remove build artifacts" 5 | @echo "clean-pyc - remove Python file artifacts" 6 | @echo "lint - check style with flake8" 7 | @echo "test - run tests quickly with the default Python" 8 | @echo "test-all - run tests on every Python version with tox" 9 | @echo "coverage - check code coverage quickly with the default Python" 10 | @echo "docs - generate Sphinx HTML documentation, including API docs" 11 | @echo "release - package and upload a release" 12 | @echo "sdist - package" 13 | 14 | clean: clean-build clean-pyc 15 | rm -fr htmlcov/ 16 | 17 | clean-build: 18 | rm -fr build/ 19 | rm -fr dist/ 20 | rm -fr *.egg-info 21 | 22 | clean-pyc: 23 | find . -name '*.pyc' -exec rm -f {} + 24 | find . -name '*.pyo' -exec rm -f {} + 25 | find . -name '*~' -exec rm -f {} + 26 | 27 | lint: 28 | flake8 jsonmodels tests 29 | 30 | test: 31 | python setup.py test 32 | 33 | test-all: 34 | tox 35 | 36 | coverage: 37 | python setup.py test 38 | coverage html 39 | open htmlcov/index.html 40 | 41 | docs: 42 | rm -f docs/jsonmodels.rst 43 | rm -f docs/modules.rst 44 | sphinx-apidoc -o docs/ jsonmodels 45 | $(MAKE) -C docs clean 46 | $(MAKE) -C docs html 47 | open docs/_build/html/index.html 48 | 49 | release: clean 50 | python setup.py sdist upload 51 | python setup.py bdist_wheel upload 52 | 53 | dist: clean 54 | python setup.py sdist 55 | python setup.py bdist_wheel 56 | ls -l dist 57 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2014, Szczepan Cieślik 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 5 | 6 | * Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 7 | 8 | * Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. 9 | 10 | * Neither the name of JSON models nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. 11 | 12 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -------------------------------------------------------------------------------- /tests/test_project.py: -------------------------------------------------------------------------------- 1 | import os 2 | import re 3 | import subprocess 4 | 5 | import pytest 6 | from invoke import run 7 | 8 | import tests 9 | 10 | root_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) 11 | source_dir = os.path.join(root_dir, "jsonmodels") 12 | tests_dir = os.path.join(root_dir, "tests") 13 | 14 | 15 | @pytest.mark.skipif(not tests.LINT, reason="No lint tests.") 16 | def test_pep8_and_complexity(): 17 | result = [] 18 | for filename in _collect_static([source_dir, tests_dir]): 19 | result.append(subprocess.call(["flake8", filename])) 20 | 21 | if any(result): 22 | raise RuntimeError("Tests for PEP8 compliance and complexity have failed!") 23 | 24 | 25 | @pytest.mark.skipif( 26 | not tests.LINT or not tests.CHECK_SPELLING, reason="No spelling check." 27 | ) 28 | def test_docs(): 29 | run("sphinx-build -b spelling -d docs/_build/doctress " "docs docs/build/spelling") 30 | 31 | 32 | def _collect_static(dirs): 33 | matches = [] 34 | for dir_ in dirs: 35 | _collect_recursively(dir_, matches) 36 | return matches 37 | 38 | 39 | def _collect_recursively(directory, result): 40 | for name in os.listdir(directory): 41 | if not re.search("^\\.", name): 42 | fullpath = os.path.join(directory, name) 43 | if re.search("\\.py$", name): 44 | result.append(fullpath) 45 | elif os.path.isdir(fullpath): 46 | _collect_recursively(fullpath, result) 47 | -------------------------------------------------------------------------------- /tests/test_nullable.py: -------------------------------------------------------------------------------- 1 | from jsonmodels.fields import DictField, EmbeddedField, ListField, StringField 2 | from jsonmodels.models import Base 3 | 4 | 5 | class Nullable(Base): 6 | field = StringField(nullable=True) 7 | 8 | 9 | class NullableDict(Base): 10 | field = DictField(nullable=True) 11 | 12 | 13 | class NullableListField(Base): 14 | field = ListField([str], nullable=True) 15 | 16 | 17 | class NullableEmbedded(Base): 18 | field = EmbeddedField(Nullable, nullable=True) 19 | 20 | 21 | def test_nullable_simple_field(): 22 | result = Nullable.to_json_schema() 23 | 24 | assert result["properties"]["field"]["type"] == ["string", "null"] 25 | 26 | 27 | def test_nullable_dict_field(): 28 | result = NullableDict.to_json_schema() 29 | 30 | assert result["properties"]["field"]["type"] == ["object", "null"] 31 | 32 | 33 | def test_nullable_list_field(): 34 | result = NullableListField.to_json_schema() 35 | items = result["properties"]["field"]["items"] 36 | assert items.get("oneOf") 37 | assert items["oneOf"] == [{"type": "string"}, {"type": "null"}] 38 | 39 | 40 | def test_nullable_embedded_field(): 41 | result = NullableEmbedded.to_json_schema() 42 | 43 | expected = [ 44 | { 45 | "type": "object", 46 | "additionalProperties": False, 47 | "properties": {"field": {"type": ["string", "null"]}}, 48 | }, 49 | {"type": "null"}, 50 | ] 51 | assert result["properties"]["field"]["oneOf"] == expected 52 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - "*" 7 | 8 | jobs: 9 | build: 10 | if: github.repository == 'jazzband/jsonmodels' 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - uses: actions/checkout@v3 15 | with: 16 | fetch-depth: 0 17 | 18 | - name: Set up Python 19 | uses: actions/setup-python@v4 20 | with: 21 | python-version: "3.10" 22 | 23 | - name: Get pip cache dir 24 | id: pip-cache 25 | run: | 26 | echo "::set-output name=dir::$(pip cache dir)" 27 | 28 | - name: Cache 29 | uses: actions/cache@v3 30 | with: 31 | path: ${{ steps.pip-cache.outputs.dir }} 32 | key: release-${{ hashFiles('**/setup.py') }} 33 | restore-keys: | 34 | release- 35 | 36 | - name: Install dependencies 37 | run: | 38 | python -m pip install -U pip 39 | python -m pip install -U setuptools twine wheel 40 | 41 | - name: Build package 42 | run: | 43 | python setup.py --version 44 | python setup.py sdist --format=gztar bdist_wheel 45 | twine check dist/* 46 | 47 | - name: Upload packages to Jazzband 48 | if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags') 49 | uses: pypa/gh-action-pypi-publish@master 50 | with: 51 | user: jazzband 52 | password: ${{ secrets.JAZZBAND_RELEASE_KEY }} 53 | repository_url: https://jazzband.co/projects/jsonmodels/upload 54 | -------------------------------------------------------------------------------- /tests/fixtures/schema2.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "object", 3 | "properties": { 4 | "age": {"type": "number"}, 5 | "name": {"type": "string"}, 6 | "surname": {"type": "string"}, 7 | "car": { 8 | "type": "object", 9 | "properties": { 10 | "brand": {"type": "string"}, 11 | "registration": {"type": "string"}, 12 | "extra": {"type": "object"} 13 | }, 14 | "required": ["brand", "registration","extra"], 15 | "additionalProperties": false 16 | }, 17 | "kids": { 18 | "items": { 19 | "type": "object", 20 | "properties": { 21 | "age": {"type": "number"}, 22 | "name": {"type": "string"}, 23 | "surname": {"type": "string"}, 24 | "toys": { 25 | "type": "array", 26 | "items": { 27 | "additionalProperties": false, 28 | "properties": { 29 | "name": {"type": "string"} 30 | }, 31 | "required": ["name"], 32 | "type": "object" 33 | } 34 | } 35 | }, 36 | "required": ["surname", "name"], 37 | "additionalProperties": false 38 | }, 39 | "type": "array" 40 | } 41 | }, 42 | "required": ["surname", "name"], 43 | "additionalProperties": false 44 | } 45 | -------------------------------------------------------------------------------- /tests/fixtures/schema_circular2.json: -------------------------------------------------------------------------------- 1 | { 2 | "additionalProperties": false, 3 | "definitions": { 4 | "tests_test_circular_references_directory": { 5 | "additionalProperties": false, 6 | "properties": { 7 | "children": { 8 | "items": { 9 | "oneOf": [ 10 | "#/definitions/tests_test_circular_references_directory", 11 | "#/definitions/tests_test_circular_references_file" 12 | ] 13 | }, 14 | "type": "array" 15 | }, 16 | "name": { 17 | "type": "string" 18 | } 19 | }, 20 | "type": "object" 21 | }, 22 | "tests_test_circular_references_file": { 23 | "additionalProperties": false, 24 | "properties": { 25 | "name": { 26 | "type": "string" 27 | }, 28 | "size": { 29 | "type": "float" 30 | } 31 | }, 32 | "type": "object" 33 | } 34 | }, 35 | "properties": { 36 | "children": { 37 | "items": { 38 | "oneOf": [ 39 | "#/definitions/tests_test_circular_references_directory", 40 | "#/definitions/tests_test_circular_references_file" 41 | ] 42 | }, 43 | "type": "array" 44 | }, 45 | "name": { 46 | "type": "string" 47 | } 48 | }, 49 | "type": "object" 50 | } 51 | -------------------------------------------------------------------------------- /tests/test_fields.py: -------------------------------------------------------------------------------- 1 | from jsonmodels import fields, models, validators 2 | 3 | 4 | def test_bool_field(): 5 | field = fields.BoolField() 6 | 7 | class Person(models.Base): 8 | is_programmer = field 9 | 10 | person = Person() 11 | assert person.is_programmer is None 12 | 13 | person.is_programmer = True 14 | assert person.is_programmer is True 15 | 16 | person.is_programmer = False 17 | assert person.is_programmer is False 18 | 19 | assert field.parse_value(True) is True 20 | assert field.parse_value("something") is True 21 | assert field.parse_value(object()) is True 22 | 23 | assert field.parse_value(None) is None 24 | 25 | assert field.parse_value(False) is False 26 | assert field.parse_value(0) is False 27 | assert field.parse_value("") is False 28 | assert field.parse_value([]) is False 29 | 30 | 31 | def test_dict_field(): 32 | field = fields.DictField() 33 | default_field = fields.DictField( 34 | default={"extra_default": "Hello", "deep_extra": {"spanish": "Hola"}}, 35 | validators=[validators.Length(2)], 36 | ) 37 | 38 | class Person(models.Base): 39 | extra = field 40 | extra_required = fields.DictField(required=True) 41 | extra_default = default_field 42 | extra_nullable = fields.DictField(nullable=True) 43 | 44 | person = Person(extra_required={"required": True}) 45 | assert person.extra is None 46 | assert person.extra_required == {"required": True} 47 | assert person.extra_default == { 48 | "extra_default": "Hello", 49 | "deep_extra": {"spanish": "Hola"}, 50 | } 51 | 52 | person.extra = {"extra": True} 53 | assert person.extra == {"extra": True} 54 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | test: 7 | runs-on: ubuntu-latest 8 | strategy: 9 | max-parallel: 7 10 | matrix: 11 | python-version: ["pypy-3.9", "pypy-3.10", "3.8", "3.9", "3.10", "3.11", "3.12"] 12 | 13 | steps: 14 | - uses: actions/checkout@v3 15 | 16 | - name: Set up Python ${{ matrix.python-version }} 17 | uses: actions/setup-python@v4 18 | with: 19 | python-version: ${{ matrix.python-version }} 20 | 21 | - name: Get pip cache dir 22 | id: pip-cache 23 | run: | 24 | echo "::set-output name=dir::$(pip cache dir)" 25 | 26 | - name: Cache 27 | uses: actions/cache@v3 28 | with: 29 | path: ${{ steps.pip-cache.outputs.dir }} 30 | key: ${{ matrix.os }}-${{ matrix.python-version }}-v1-${{ hashFiles('**/requirements.txt') }} 31 | restore-keys: | 32 | ${{ matrix.os }}-${{ matrix.python-version }}-v1- 33 | 34 | - name: Install enchant 35 | run: | 36 | sudo apt-get -qq update 37 | sudo apt-get -y install enchant-2 38 | 39 | - name: Install dependencies 40 | run: | 41 | python -m pip install --upgrade pip 42 | python -m pip install -r requirements.txt 43 | 44 | - name: Tests 45 | run: | 46 | pytest 47 | 48 | - name: Upload coverage 49 | uses: codecov/codecov-action@v2 50 | with: 51 | name: Python ${{ matrix.python-version }} 52 | 53 | success: 54 | needs: test 55 | runs-on: ubuntu-latest 56 | name: test successful 57 | steps: 58 | - name: Success 59 | run: echo Test successful 60 | -------------------------------------------------------------------------------- /docs/jsonmodels.rst: -------------------------------------------------------------------------------- 1 | jsonmodels package 2 | ================== 3 | 4 | Submodules 5 | ---------- 6 | 7 | jsonmodels\.builders module 8 | --------------------------- 9 | 10 | .. automodule:: jsonmodels.builders 11 | :members: 12 | :undoc-members: 13 | :show-inheritance: 14 | 15 | jsonmodels\.collections module 16 | ------------------------------ 17 | 18 | .. automodule:: jsonmodels.collections 19 | :members: 20 | :undoc-members: 21 | :show-inheritance: 22 | 23 | jsonmodels\.errors module 24 | ------------------------- 25 | 26 | .. automodule:: jsonmodels.errors 27 | :members: 28 | :undoc-members: 29 | :show-inheritance: 30 | 31 | jsonmodels\.fields module 32 | ------------------------- 33 | 34 | .. automodule:: jsonmodels.fields 35 | :members: 36 | :undoc-members: 37 | :show-inheritance: 38 | 39 | jsonmodels\.models module 40 | ------------------------- 41 | 42 | .. automodule:: jsonmodels.models 43 | :members: 44 | :undoc-members: 45 | :show-inheritance: 46 | 47 | jsonmodels\.parsers module 48 | -------------------------- 49 | 50 | .. automodule:: jsonmodels.parsers 51 | :members: 52 | :undoc-members: 53 | :show-inheritance: 54 | 55 | jsonmodels\.utilities module 56 | ---------------------------- 57 | 58 | .. automodule:: jsonmodels.utilities 59 | :members: 60 | :undoc-members: 61 | :show-inheritance: 62 | 63 | jsonmodels\.validators module 64 | ----------------------------- 65 | 66 | .. automodule:: jsonmodels.validators 67 | :members: 68 | :undoc-members: 69 | :show-inheritance: 70 | 71 | 72 | Module contents 73 | --------------- 74 | 75 | .. automodule:: jsonmodels 76 | :members: 77 | :undoc-members: 78 | :show-inheritance: 79 | -------------------------------------------------------------------------------- /tests/test_lazy_loading.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from jsonmodels import errors, fields, models 4 | 5 | 6 | class Primary(models.Base): 7 | name = fields.StringField() 8 | secondary = fields.EmbeddedField("Secondary") 9 | 10 | 11 | class Third(models.Base): 12 | name = fields.StringField() 13 | secondary = fields.EmbeddedField("tests.test_lazy_loading.Secondary") 14 | 15 | 16 | class Fourth(models.Base): 17 | name = fields.StringField() 18 | secondary = fields.EmbeddedField(".Secondary") 19 | 20 | 21 | class Fifth(models.Base): 22 | name = fields.StringField() 23 | secondary = fields.EmbeddedField("..test_lazy_loading.Secondary") 24 | 25 | 26 | class Sixth(models.Base): 27 | name = fields.StringField() 28 | secondary = fields.EmbeddedField("...tests.test_lazy_loading.Secondary") 29 | 30 | 31 | class Seventh(models.Base): 32 | name = fields.StringField() 33 | secondary = fields.EmbeddedField("....tests.test_lazy_loading.Secondary") 34 | 35 | 36 | class Eighth(models.Base): 37 | name = fields.StringField() 38 | secondary = fields.EmbeddedField(".SomeWrongEntity") 39 | 40 | 41 | class Secondary(models.Base): 42 | data = fields.IntField() 43 | 44 | 45 | @pytest.mark.parametrize( 46 | ["model"], 47 | [ 48 | (Primary,), 49 | (Third,), 50 | (Fourth,), 51 | (Fifth,), 52 | (Sixth,), 53 | ], 54 | ) 55 | def test_embedded_model(model): 56 | entity = model() 57 | assert entity.secondary is None 58 | entity.name = "chuck" 59 | entity.secondary = Secondary() 60 | entity.secondary.data = 42 61 | 62 | with pytest.raises(errors.ValidationError): 63 | entity.secondary = "something different" 64 | 65 | entity.secondary = None 66 | 67 | 68 | def test_relative_too_much(): 69 | with pytest.raises(ValueError): 70 | Seventh() 71 | 72 | 73 | def test_wrong_entity(): 74 | with pytest.raises(ValueError): 75 | Eighth() 76 | 77 | 78 | class File(models.Base): 79 | name = fields.StringField() 80 | size = fields.FloatField() 81 | 82 | 83 | class Directory(models.Base): 84 | name = fields.StringField() 85 | children = fields.ListField(["Directory", File]) 86 | 87 | 88 | def test_list_field(): 89 | directory = Directory() 90 | some_file = File() 91 | directory.children.append(some_file) 92 | sub_dir = Directory() 93 | directory.children.append(sub_dir) 94 | with pytest.raises(errors.ValidationError): 95 | directory.children.append("some string") 96 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Code of Conduct 2 | 3 | As contributors and maintainers of the Jazzband projects, and in the interest of 4 | fostering an open and welcoming community, we pledge to respect all people who 5 | contribute through reporting issues, posting feature requests, updating documentation, 6 | submitting pull requests or patches, and other activities. 7 | 8 | We are committed to making participation in the Jazzband a harassment-free experience 9 | for everyone, regardless of the level of experience, gender, gender identity and 10 | expression, sexual orientation, disability, personal appearance, body size, race, 11 | ethnicity, age, religion, or nationality. 12 | 13 | Examples of unacceptable behavior by participants include: 14 | 15 | - The use of sexualized language or imagery 16 | - Personal attacks 17 | - Trolling or insulting/derogatory comments 18 | - Public or private harassment 19 | - Publishing other's private information, such as physical or electronic addresses, 20 | without explicit permission 21 | - Other unethical or unprofessional conduct 22 | 23 | The Jazzband roadies have the right and responsibility to remove, edit, or reject 24 | comments, commits, code, wiki edits, issues, and other contributions that are not 25 | aligned to this Code of Conduct, or to ban temporarily or permanently any contributor 26 | for other behaviors that they deem inappropriate, threatening, offensive, or harmful. 27 | 28 | By adopting this Code of Conduct, the roadies commit themselves to fairly and 29 | consistently applying these principles to every aspect of managing the jazzband 30 | projects. Roadies who do not follow or enforce the Code of Conduct may be permanently 31 | removed from the Jazzband roadies. 32 | 33 | This code of conduct applies both within project spaces and in public spaces when an 34 | individual is representing the project or its community. 35 | 36 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by 37 | contacting the roadies at `roadies@jazzband.co`. All complaints will be reviewed and 38 | investigated and will result in a response that is deemed necessary and appropriate to 39 | the circumstances. Roadies are obligated to maintain confidentiality with regard to the 40 | reporter of an incident. 41 | 42 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 43 | 1.3.0, available at [https://contributor-covenant.org/version/1/3/0/][version] 44 | 45 | [homepage]: https://contributor-covenant.org 46 | [version]: https://contributor-covenant.org/version/1/3/0/ 47 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import sys 4 | 5 | from setuptools import setup 6 | from setuptools.command.test import test as TestCommand 7 | 8 | from jsonmodels import __author__, __email__, __version__ 9 | 10 | PROJECT_NAME = "jsonmodels" 11 | 12 | if sys.argv[-1] == "publish": 13 | os.system("python setup.py sdist upload") 14 | sys.exit() 15 | 16 | 17 | class PyTest(TestCommand): 18 | user_options = [("pytest-args=", "a", "Arguments to pass to py.test")] 19 | 20 | def initialize_options(self): 21 | TestCommand.initialize_options(self) 22 | self.pytest_args = ["--cov", PROJECT_NAME] 23 | 24 | def finalize_options(self): 25 | TestCommand.finalize_options(self) 26 | self.test_args = [] 27 | self.test_suite = True 28 | 29 | def run_tests(self): 30 | import pytest 31 | 32 | errno = pytest.main(self.pytest_args) 33 | sys.exit(errno) 34 | 35 | 36 | # Hacking tests. 37 | try: 38 | import tests 39 | except ImportError: 40 | pass 41 | else: 42 | if "test" in sys.argv and "--no-lint" in sys.argv: 43 | tests.LINT = False 44 | del sys.argv[sys.argv.index("--no-lint")] 45 | 46 | if "test" in sys.argv and "--spelling" in sys.argv: 47 | tests.CHECK_SPELLING = True 48 | del sys.argv[sys.argv.index("--spelling")] 49 | 50 | readme = open("README.rst").read() 51 | history = open("HISTORY.rst").read().replace(".. :changelog:", "") 52 | 53 | setup( 54 | name=PROJECT_NAME, 55 | version=__version__, 56 | description="Models to make easier to deal with structures that" 57 | " are converted to, or read from JSON.", 58 | long_description=readme + "\n\n" + history, 59 | author=__author__, 60 | author_email=__email__, 61 | url="https://github.com/jazzband/jsonmodels", 62 | packages=[ 63 | PROJECT_NAME, 64 | ], 65 | package_dir={PROJECT_NAME: PROJECT_NAME}, 66 | include_package_data=True, 67 | install_requires=[ 68 | "python-dateutil", 69 | ], 70 | license="BSD", 71 | zip_safe=False, 72 | keywords=PROJECT_NAME, 73 | python_requires=">=3.8", 74 | classifiers=[ 75 | "Intended Audience :: Developers", 76 | "License :: OSI Approved :: BSD License", 77 | "Natural Language :: English", 78 | "Programming Language :: Python :: 3", 79 | "Programming Language :: Python :: 3.8", 80 | "Programming Language :: Python :: 3.9", 81 | "Programming Language :: Python :: 3.10", 82 | "Programming Language :: Python :: 3.11", 83 | "Programming Language :: Python :: 3.12", 84 | "Programming Language :: Python :: 3 :: Only", 85 | ], 86 | cmdclass={ 87 | "test": PyTest, 88 | }, 89 | ) 90 | -------------------------------------------------------------------------------- /tests/fixtures/schema3.json: -------------------------------------------------------------------------------- 1 | { 2 | "additionalProperties": false, 3 | "properties": { 4 | "age": { 5 | "type": "number" 6 | }, 7 | "car": { 8 | "oneOf": [ 9 | { 10 | "additionalProperties": false, 11 | "properties": { 12 | "brand": { 13 | "type": "string" 14 | }, 15 | "capacity": { 16 | "type": "float" 17 | } 18 | }, 19 | "type": "object" 20 | }, 21 | { 22 | "additionalProperties": false, 23 | "properties": { 24 | "brand": { 25 | "type": "string" 26 | }, 27 | "velocity": { 28 | "type": "float" 29 | } 30 | }, 31 | "type": "object" 32 | } 33 | ] 34 | }, 35 | "computer": { 36 | "items": { 37 | "oneOf": [ 38 | { 39 | "additionalProperties": false, 40 | "properties": { 41 | "name": { 42 | "type": "string" 43 | }, 44 | "ports": { 45 | "type": "string" 46 | } 47 | }, 48 | "type": "object" 49 | }, 50 | { 51 | "additionalProperties": false, 52 | "properties": { 53 | "battery_voltage": { 54 | "type": "float" 55 | }, 56 | "name": { 57 | "type": "string" 58 | } 59 | }, 60 | "type": "object" 61 | }, 62 | { 63 | "additionalProperties": false, 64 | "properties": { 65 | "name": { 66 | "type": "string" 67 | }, 68 | "os": { 69 | "type": "string" 70 | } 71 | }, 72 | "type": "object" 73 | } 74 | ] 75 | }, 76 | "type": "array" 77 | }, 78 | "name": { 79 | "type": "string" 80 | }, 81 | "surname": { 82 | "type": "string" 83 | } 84 | }, 85 | "required": [ 86 | "surname", 87 | "name" 88 | ], 89 | "type": "object" 90 | } 91 | -------------------------------------------------------------------------------- /tests/test_name.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from jsonmodels import fields, models 4 | 5 | 6 | def test_fields_can_have_different_names(): 7 | class Human(models.Base): 8 | name = fields.StringField() 9 | surname = fields.StringField(name="second-name") 10 | 11 | chuck = Human(name="Chuck", surname="Testa") 12 | assert chuck.to_struct() == {"name": "Chuck", "second-name": "Testa"} 13 | 14 | 15 | def test_only_subset_of_names_is_accepted(): 16 | with pytest.raises(ValueError): 17 | fields.StringField(name="totally wrong name") 18 | with pytest.raises(ValueError): 19 | fields.StringField(name="wrong!") 20 | with pytest.raises(ValueError): 21 | fields.StringField(name="~wrong") 22 | 23 | 24 | def test_load_data_from_structure_names(): 25 | class Human(models.Base): 26 | name = fields.StringField() 27 | surname = fields.StringField(name="second-name") 28 | 29 | data = {"name": "Chuck", "second-name": "Testa"} 30 | chuck = Human(**data) 31 | assert chuck.name == "Chuck" 32 | assert chuck.surname == "Testa" 33 | 34 | data = {"name": "Chuck", "surname": "Testa"} 35 | chuck = Human(**data) 36 | assert chuck.name == "Chuck" 37 | assert chuck.surname == "Testa" 38 | 39 | 40 | def test_names_duplicates_are_invalid(): 41 | with pytest.raises(ValueError): 42 | 43 | class Human(models.Base): 44 | name = fields.StringField(name="surname") 45 | surname = fields.StringField() 46 | 47 | class OtherHuman(models.Base): 48 | name = fields.StringField(name="surname") 49 | surname = fields.StringField(name="name") 50 | 51 | 52 | def test_data_assignation(): 53 | """If names collide - value may be assigned twice. 54 | 55 | If we pass value for `brand` - the value whould be assigned only to 56 | `description` field (as this is its structure name). In case you pass 57 | two values - for description AND brand - only structure name is taken into 58 | account. 59 | 60 | This is done so models can be 'smart' - to use structure name by default, 61 | but take attribute names as fallback, so it won't mess with more convinient 62 | workflow. 63 | """ 64 | 65 | class Product(models.Base): 66 | brand = fields.StringField(name="description") 67 | description = fields.StringField(name="product_description") 68 | 69 | product = Product(description="foo") 70 | assert product.brand == "foo" 71 | assert product.description is None 72 | 73 | 74 | def test_nested_data(): 75 | class Pet(models.Base): 76 | name = fields.StringField(required=True, name="pets_name") 77 | age = fields.IntField() 78 | 79 | class Human(models.Base): 80 | name = fields.StringField() 81 | pet = fields.EmbeddedField(Pet, name="owned-pet") 82 | 83 | data = {"name": "John", "owned-pet": {"pets_name": "Pinky", "age": 2}} 84 | human = Human(**data) 85 | assert human.pet.name == "Pinky" 86 | 87 | 88 | def test_cross_names(): 89 | class Foo(models.Base): 90 | one = fields.IntField(name="two") 91 | two = fields.IntField(name="one") 92 | 93 | foo = Foo(one=1, two=2) 94 | assert foo.one == 2 95 | assert foo.two == 1 96 | -------------------------------------------------------------------------------- /jsonmodels/parsers.py: -------------------------------------------------------------------------------- 1 | """Parsers to change model structure into different ones.""" 2 | import inspect 3 | 4 | from . import builders, errors, fields 5 | 6 | 7 | def to_struct(model): 8 | """Cast instance of model to python structure. 9 | 10 | :param model: Model to be casted. 11 | :rtype: ``dict`` 12 | 13 | """ 14 | model.validate() 15 | 16 | resp = {} 17 | for _, name, field in model.iterate_with_name(): 18 | value = field.__get__(model) 19 | if value is None: 20 | continue 21 | 22 | value = field.to_struct(value) 23 | resp[name] = value 24 | return resp 25 | 26 | 27 | def to_json_schema(cls): 28 | """Generate JSON schema for given class. 29 | 30 | :param cls: Class to be casted. 31 | :rtype: ``dict`` 32 | 33 | """ 34 | builder = build_json_schema(cls) 35 | return builder.build() 36 | 37 | 38 | def build_json_schema(value, parent_builder=None): 39 | from .models import Base 40 | 41 | cls = value if inspect.isclass(value) else value.__class__ 42 | if issubclass(cls, Base): 43 | return build_json_schema_object(cls, parent_builder) 44 | else: 45 | return build_json_schema_primitive(cls, parent_builder) 46 | 47 | 48 | def build_json_schema_object(cls, parent_builder=None): 49 | builder = builders.ObjectBuilder(cls, parent_builder) 50 | if builder.count_type(builder.type) > 1: 51 | return builder 52 | for _, name, field in cls.iterate_with_name(): 53 | if isinstance(field, fields.EmbeddedField): 54 | builder.add_field(name, field, _parse_embedded(field, builder)) 55 | elif isinstance(field, fields.ListField): 56 | builder.add_field(name, field, _parse_list(field, builder)) 57 | else: 58 | builder.add_field(name, field, _create_primitive_field_schema(field)) 59 | return builder 60 | 61 | 62 | def _parse_list(field, parent_builder): 63 | builder = builders.ListBuilder( 64 | parent_builder, field.nullable, default=field._default 65 | ) 66 | for type in field.items_types: 67 | builder.add_type_schema(build_json_schema(type, builder)) 68 | return builder.build() 69 | 70 | 71 | def _parse_embedded(field, parent_builder): 72 | builder = builders.EmbeddedBuilder( 73 | parent_builder, field.nullable, default=field._default 74 | ) 75 | for type in field.types: 76 | builder.add_type_schema(build_json_schema(type, builder)) 77 | return builder.build() 78 | 79 | 80 | def build_json_schema_primitive(cls, parent_builder): 81 | builder = builders.PrimitiveBuilder(cls, parent_builder) 82 | return builder 83 | 84 | 85 | def _create_primitive_field_schema(field): 86 | if isinstance(field, fields.StringField): 87 | obj_type = "string" 88 | elif isinstance(field, fields.IntField): 89 | obj_type = "number" 90 | elif isinstance(field, fields.FloatField): 91 | obj_type = "float" 92 | elif isinstance(field, fields.BoolField): 93 | obj_type = "boolean" 94 | elif isinstance(field, fields.DictField): 95 | obj_type = "object" 96 | else: 97 | raise errors.FieldNotSupported( 98 | f"Field {type(field).__class__.__name__} is not supported!" 99 | ) 100 | 101 | if field.nullable: 102 | obj_type = [obj_type, "null"] 103 | 104 | schema = {"type": obj_type} 105 | 106 | if field.has_default: 107 | schema["default"] = field._default 108 | 109 | return schema 110 | -------------------------------------------------------------------------------- /CONTRIBUTING.rst: -------------------------------------------------------------------------------- 1 | ============ 2 | Contributing 3 | ============ 4 | 5 | .. image:: https://jazzband.co/static/img/jazzband.svg 6 | :target: https://jazzband.co/ 7 | :alt: Jazzband 8 | 9 | This is a `Jazzband `_ project. By contributing you agree to abide 10 | by the `Contributor Code of Conduct `_ and follow the 11 | `guidelines `_. 12 | 13 | Contributions are welcome, and they are greatly appreciated! Every 14 | little bit helps, and credit will always be given. 15 | 16 | You can contribute in many ways: 17 | 18 | Types of Contributions 19 | ---------------------- 20 | 21 | Report Bugs 22 | ~~~~~~~~~~~ 23 | 24 | Report bugs at https://github.com/jazzband/jsonmodels/issues. 25 | 26 | If you are reporting a bug, please include: 27 | 28 | * Your operating system name and version. 29 | * Any details about your local setup that might be helpful in troubleshooting. 30 | * Detailed steps to reproduce the bug. 31 | 32 | Fix Bugs 33 | ~~~~~~~~ 34 | 35 | Look through the GitHub issues for bugs. Anything tagged with "bug" 36 | is open to whoever wants to implement it. 37 | 38 | Implement Features 39 | ~~~~~~~~~~~~~~~~~~ 40 | 41 | Look through the GitHub issues for features. Anything tagged with "feature" 42 | is open to whoever wants to implement it. 43 | 44 | Write Documentation 45 | ~~~~~~~~~~~~~~~~~~~ 46 | 47 | JSON models could always use more documentation, whether as part of the 48 | official JSON models docs, in docstrings, or even on the web in blog posts, 49 | articles, and such. 50 | 51 | Submit Feedback 52 | ~~~~~~~~~~~~~~~ 53 | 54 | The best way to send feedback is to file an issue at https://github.com/jazzband/jsonmodels/issues. 55 | 56 | If you are proposing a feature: 57 | 58 | * Explain in detail how it would work. 59 | * Keep the scope as narrow as possible, to make it easier to implement. 60 | * Remember that this is a volunteer-driven project, and that contributions 61 | are welcome :) 62 | 63 | Get Started! 64 | ------------ 65 | 66 | Ready to contribute? Here's how to set up `jsonmodels` for local development. 67 | 68 | 1. Fork the `jsonmodels` repo on GitHub. 69 | 2. Clone your fork locally:: 70 | 71 | $ git clone git@github.com:your_name_here/jsonmodels.git 72 | 73 | 3. Install your local copy into a virtualenv. Assuming you have 74 | `virtualenvwrapper` installed, this is how you set up your fork for local 75 | development:: 76 | 77 | $ mkvirtualenv jsonmodels 78 | $ cd jsonmodels/ 79 | $ pip install -r requirements.txt 80 | 81 | 4. Create a branch for local development:: 82 | 83 | $ git checkout -b name-of-your-bugfix-or-feature 84 | 85 | Now you can make your changes locally. 86 | 87 | 5. When you're done making changes, check that your changes pass the tests, including testing other Python versions with tox:: 88 | 89 | $ pytest 90 | $ tox 91 | 92 | To get tox, just pip install them into your virtualenv. 93 | 94 | 6. Commit your changes and push your branch to GitHub:: 95 | 96 | $ git add . 97 | $ git commit -m "Your detailed description of your changes." 98 | $ git push origin name-of-your-bugfix-or-feature 99 | 100 | 7. Submit a pull request through the GitHub website. 101 | 102 | Pull Request Guidelines 103 | ----------------------- 104 | 105 | Before you submit a pull request, check that it meets these guidelines: 106 | 107 | 1. The pull request should include tests. 108 | 2. If the pull request adds functionality, the docs should be updated. Put 109 | your new functionality into a function with a docstring, and add the 110 | feature to the list in README.rst. 111 | 3. The pull request should work for Python 3.8+, and for 112 | PyPy. Check https://github.com/jazzband/jsonmodels/actions and make 113 | sure that the tests pass for all supported Python versions. 114 | 115 | Tips 116 | ---- 117 | 118 | To run a subset of tests:: 119 | 120 | $ pytest -k test_jsonmodels 121 | -------------------------------------------------------------------------------- /jsonmodels/utilities.py: -------------------------------------------------------------------------------- 1 | import re 2 | import sre_constants 3 | from collections import namedtuple 4 | 5 | SCALAR_TYPES = tuple(list((str,)) + [int, float, bool]) 6 | 7 | ECMA_TO_PYTHON_FLAGS = { 8 | "i": re.I, 9 | "m": re.M, 10 | } 11 | 12 | PYTHON_TO_ECMA_FLAGS = {value: key for key, value in ECMA_TO_PYTHON_FLAGS.items()} 13 | 14 | PythonRegex = namedtuple("PythonRegex", ["regex", "flags"]) 15 | 16 | 17 | def _normalize_string_type(value): 18 | if isinstance(value, str): 19 | return str(value) 20 | else: 21 | return value 22 | 23 | 24 | def _compare_dicts(one, two): 25 | if len(one) != len(two): 26 | return False 27 | 28 | for key, value in one.items(): 29 | if key not in one or key not in two: 30 | return False 31 | 32 | if not compare_schemas(one[key], two[key]): 33 | return False 34 | return True 35 | 36 | 37 | def _compare_lists(one, two): 38 | if len(one) != len(two): 39 | return False 40 | 41 | they_match = False 42 | for first_item in one: 43 | for second_item in two: 44 | if they_match: 45 | continue 46 | they_match = compare_schemas(first_item, second_item) 47 | return they_match 48 | 49 | 50 | def _assert_same_types(one, two): 51 | if not isinstance(one, type(two)) or not isinstance(two, type(one)): 52 | raise RuntimeError( 53 | f'Types mismatch! "{type(one).__name__}" and "{type(two).__name__}".' 54 | ) 55 | 56 | 57 | def compare_schemas(one, two): 58 | """Compare two structures that represents JSON schemas. 59 | 60 | For comparison you can't use normal comparison, because in JSON schema 61 | lists DO NOT keep order (and Python lists do), so this must be taken into 62 | account during comparison. 63 | 64 | Note this wont check all configurations, only first one that seems to 65 | match, which can lead to wrong results. 66 | 67 | :param one: First schema to compare. 68 | :param two: Second schema to compare. 69 | :rtype: `bool` 70 | 71 | """ 72 | one = _normalize_string_type(one) 73 | two = _normalize_string_type(two) 74 | 75 | _assert_same_types(one, two) 76 | 77 | if isinstance(one, list): 78 | return _compare_lists(one, two) 79 | elif isinstance(one, dict): 80 | return _compare_dicts(one, two) 81 | elif isinstance(one, SCALAR_TYPES): 82 | return one == two 83 | elif one is None: 84 | return one is two 85 | else: 86 | raise RuntimeError(f'Not allowed type "{type(one).__name__}"') 87 | 88 | 89 | def is_ecma_regex(regex): 90 | """Check if given regex is of type ECMA 262 or not. 91 | 92 | :rtype: bool 93 | 94 | """ 95 | if re.match(r"/[^/]+/[gimuy]*", regex): 96 | return True 97 | 98 | try: 99 | re.compile(regex) 100 | except sre_constants.error as err: 101 | raise ValueError( 102 | f"Given regex {regex} isn't ECMA regex nor Python regex: {err}." 103 | ) 104 | return False 105 | 106 | 107 | def convert_ecma_regex_to_python(value): 108 | """Convert ECMA 262 regex to Python tuple with regex and flags. 109 | 110 | If given value is already Python regex it will be returned unchanged. 111 | 112 | :param string value: ECMA regex. 113 | :return: 2-tuple with `regex` and `flags` 114 | :rtype: namedtuple 115 | 116 | """ 117 | if not is_ecma_regex(value): 118 | return PythonRegex(value, []) 119 | 120 | parts = value.split("/") 121 | flags = parts.pop() 122 | 123 | try: 124 | result_flags = [ECMA_TO_PYTHON_FLAGS[f] for f in flags] 125 | except KeyError: 126 | raise ValueError(f'Wrong flags "{flags}".') 127 | 128 | return PythonRegex("/".join(parts[1:]), result_flags) 129 | 130 | 131 | def convert_python_regex_to_ecma(value, flags=[]): 132 | """Convert Python regex to ECMA 262 regex. 133 | 134 | If given value is already ECMA regex it will be returned unchanged. 135 | 136 | :param string value: Python regex. 137 | :param list flags: List of flags (allowed flags: `re.I`, `re.M`) 138 | :return: ECMA 262 regex 139 | :rtype: str 140 | 141 | """ 142 | if is_ecma_regex(value): 143 | return value 144 | 145 | result_flags = [PYTHON_TO_ECMA_FLAGS[f] for f in flags] 146 | result_flags = "".join(result_flags) 147 | 148 | return f"/{value}/{result_flags}" 149 | -------------------------------------------------------------------------------- /HISTORY.rst: -------------------------------------------------------------------------------- 1 | .. :changelog: 2 | 3 | History 4 | ------- 5 | 6 | 2.7.0 (2023-12-17) 7 | ++++++++++++++++++ 8 | 9 | * Added Python 3.12, PyPy 3.9 and 3.10 support. 10 | * Removed Python 3.7 and PyPy 3.8 support. 11 | 12 | 2.6.0 (2022-10-14) 13 | ++++++++++++++++++ 14 | 15 | * Removed Python 3.6 support. 16 | * Added support for Python 3.11. 17 | 18 | 2.5.1 (2022-06-16) 19 | ++++++++++++++++++ 20 | 21 | * Specified PyPy version to PyPy 3.8. 22 | * Added support for Python 3.10. 23 | 24 | 2.5.0 (2021-07-26) 25 | ++++++++++++++++++ 26 | 27 | * Improvied error messages for field validation errors. 28 | * Allowed to validate non model list items. 29 | * Added DictField. 30 | 31 | 2.4.1 (2021-02-19) 32 | ++++++++++++++++++ 33 | 34 | * Added Python 3.8 and 3.9 support. 35 | * Removed Python 2.7, 3.3 and 3.5 support. 36 | 37 | 2.4 (2018-12-01) 38 | ++++++++++++++++ 39 | 40 | * Fixed length validator. 41 | * Added Python 3.7 support. 42 | 43 | 2.3 (2018-02-04) 44 | ++++++++++++++++ 45 | 46 | * Added name mapping for fields. 47 | * Added value parsing to IntField. 48 | * Fixed bug with ECMA regex flags recognition. 49 | 50 | 2.2 (2017-08-21) 51 | ++++++++++++++++ 52 | 53 | * Fixed time fields, when value is not required. 54 | * Dropped support for python 2.6 55 | * Added support for python 3.6 56 | * Added nullable param for fields. 57 | * Improved model representation. 58 | 59 | 2.1.5 (2017-02-01) 60 | ++++++++++++++++++ 61 | 62 | * Fixed DateTimefield error when value is None. 63 | * Fixed comparing models without required values. 64 | 65 | 2.1.4 (2017-01-24) 66 | ++++++++++++++++++ 67 | 68 | * Allow to compare models based on their type and fields (rather than their 69 | reference). 70 | 71 | 2.1.3 (2017-01-16) 72 | ++++++++++++++++++ 73 | 74 | * Fixed generated schema. 75 | * Improved JSON serialization. 76 | 77 | 2.1.2 (2016-01-06) 78 | ++++++++++++++++++ 79 | 80 | * Fixed memory leak. 81 | 82 | 2.1.1 (2015-11-15) 83 | ++++++++++++++++++ 84 | 85 | * Added support for Python 2.6, 3.2 and 3.5. 86 | 87 | 2.1 (2015-11-02) 88 | ++++++++++++++++ 89 | 90 | * Added lazy loading of types. 91 | * Added schema generation for circular models. 92 | * Improved readability of validation error. 93 | * Fixed structure generation for list field. 94 | 95 | 2.0.1 (2014-11-15) 96 | ++++++++++++++++++ 97 | 98 | * Fixed schema generation for primitives. 99 | 100 | 2.0 (2014-11-14) 101 | ++++++++++++++++ 102 | 103 | * Fields now are descriptors. 104 | * Empty required fields are still validated only during explicite validations. 105 | 106 | Backward compatibility breaks 107 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 108 | 109 | * Renamed _types to types in fields. 110 | * Renamed _items_types to items_types in ListField. 111 | * Removed data transformers. 112 | * Renamed module `error` to `errors`. 113 | * Removed explicit validation - validation occurs at assign time. 114 | * Renamed `get_value_replacement` to `get_default_value`. 115 | * Renamed modules `utils` to `utilities`. 116 | 117 | 1.4 (2014-07-22) 118 | ++++++++++++++++ 119 | 120 | * Allowed validators to modify generated schema. 121 | * Added validator for maximum value. 122 | * Added utilities to convert regular expressions between Python and ECMA 123 | formats. 124 | * Added validator for regex. 125 | * Added validator for minimum value. 126 | * By default "validators" property of field is an empty list. 127 | 128 | 1.3.1 (2014-07-13) 129 | ++++++++++++++++++ 130 | 131 | * Fixed generation of schema for BoolField. 132 | 133 | 1.3 (2014-07-13) 134 | ++++++++++++++++ 135 | 136 | * Added new fields (BoolField, TimeField, DateField and DateTimeField). 137 | * ListField is always not required. 138 | * Schema can be now generated from class itself (not from an instance). 139 | 140 | 1.2 (2014-06-18) 141 | ++++++++++++++++ 142 | 143 | * Fixed values population, when value is not dictionary. 144 | * Added custom validators. 145 | * Added tool for schema comparison. 146 | 147 | 1.1.1 (2014-06-07) 148 | ++++++++++++++++++ 149 | 150 | * Added possibility to populate already initialized data to EmbeddedField. 151 | * Added `compare_schemas` utility. 152 | 153 | 1.1 (2014-05-19) 154 | ++++++++++++++++ 155 | 156 | * Added docs. 157 | * Added json schema generation. 158 | * Added tests for PEP8 and complexity. 159 | * Moved to Python 3.4. 160 | * Added PEP257 compatibility. 161 | * Added help text to fields. 162 | 163 | 1.0.5 (2014-04-14) 164 | ++++++++++++++++++ 165 | 166 | * Added data transformers. 167 | 168 | 1.0.4 (2014-04-13) 169 | ++++++++++++++++++ 170 | 171 | * List field now supports simple types. 172 | 173 | 1.0.3 (2014-04-10) 174 | ++++++++++++++++++ 175 | 176 | * Fixed compatibility with Python 3. 177 | * Fixed `str` and `repr` methods. 178 | 179 | 1.0.2 (2014-04-03) 180 | ++++++++++++++++++ 181 | 182 | * Added deep data initialization. 183 | 184 | 1.0.1 (2014-04-03) 185 | ++++++++++++++++++ 186 | 187 | * Added `populate` method. 188 | 189 | 1.0 (2014-04-02) 190 | ++++++++++++++++ 191 | 192 | * First stable release on PyPI. 193 | 194 | 0.1.0 (2014-03-17) 195 | ++++++++++++++++++ 196 | 197 | * First release on PyPI. 198 | -------------------------------------------------------------------------------- /jsonmodels/models.py: -------------------------------------------------------------------------------- 1 | from . import errors, parsers 2 | from .errors import ValidationError 3 | from .fields import BaseField 4 | 5 | 6 | class JsonmodelMeta(type): 7 | def __new__(cls, name, bases, attributes): 8 | cls.validate_fields(attributes) 9 | return super(cls, cls).__new__(cls, name, bases, attributes) 10 | 11 | @staticmethod 12 | def validate_fields(attributes): 13 | fields = { 14 | key: value 15 | for key, value in attributes.items() 16 | if isinstance(value, BaseField) 17 | } 18 | taken_names = set() 19 | for name, field in fields.items(): 20 | structue_name = field.structue_name(name) 21 | if structue_name in taken_names: 22 | raise ValueError("Name taken", structue_name, name) 23 | taken_names.add(structue_name) 24 | 25 | 26 | class Base(metaclass=JsonmodelMeta): 27 | 28 | """Base class for all models.""" 29 | 30 | def __init__(self, **kwargs): 31 | self._cache_key = _CacheKey() 32 | self.populate(**kwargs) 33 | 34 | def populate(self, **values): 35 | """Populate values to fields. Skip non-existing.""" 36 | values = values.copy() 37 | fields = list(self.iterate_with_name()) 38 | for _, structure_name, field in fields: 39 | if structure_name in values: 40 | self.set_field(field, structure_name, values.pop(structure_name)) 41 | for name, _, field in fields: 42 | if name in values: 43 | self.set_field(field, name, values.pop(name)) 44 | 45 | def get_field(self, field_name): 46 | """Get field associated with given attribute.""" 47 | for attr_name, field in self: 48 | if field_name == attr_name: 49 | return field 50 | 51 | raise errors.FieldNotFound("Field not found", field_name) 52 | 53 | def set_field(self, field, field_name, value): 54 | """Sets the value of a field.""" 55 | try: 56 | field.__set__(self, value) 57 | except ValidationError as error: 58 | raise ValidationError(f"Error for field '{field_name}': {error}.") 59 | 60 | def __iter__(self): 61 | """Iterate through fields and values.""" 62 | yield from self.iterate_over_fields() 63 | 64 | def validate(self): 65 | """Explicitly validate all the fields.""" 66 | for name, field in self: 67 | try: 68 | field.validate_for_object(self) 69 | except ValidationError as error: 70 | raise ValidationError( 71 | f"Error for field '{name}'.", 72 | error, 73 | ) 74 | 75 | @classmethod 76 | def iterate_over_fields(cls): 77 | """Iterate through fields as `(attribute_name, field_instance)`.""" 78 | for attr in dir(cls): 79 | clsattr = getattr(cls, attr) 80 | if isinstance(clsattr, BaseField): 81 | yield attr, clsattr 82 | 83 | @classmethod 84 | def iterate_with_name(cls): 85 | """Iterate over fields, but also give `structure_name`. 86 | 87 | Format is `(attribute_name, structue_name, field_instance)`. 88 | Structure name is name under which value is seen in structure and 89 | schema (in primitives) and only there. 90 | """ 91 | for attr_name, field in cls.iterate_over_fields(): 92 | structure_name = field.structue_name(attr_name) 93 | yield attr_name, structure_name, field 94 | 95 | def to_struct(self): 96 | """Cast model to Python structure.""" 97 | return parsers.to_struct(self) 98 | 99 | @classmethod 100 | def to_json_schema(cls): 101 | """Generate JSON schema for model.""" 102 | return parsers.to_json_schema(cls) 103 | 104 | def __repr__(self): 105 | attrs = {} 106 | for name, _ in self: 107 | attr = getattr(self, name) 108 | if attr is not None: 109 | attrs[name] = repr(attr) 110 | 111 | return "{class_name}({fields})".format( 112 | class_name=self.__class__.__name__, 113 | fields=", ".join("{0[0]}={0[1]}".format(x) for x in sorted(attrs.items())), 114 | ) 115 | 116 | def __str__(self): 117 | return f"{self.__class__.__name__} object" 118 | 119 | def __setattr__(self, name, value): 120 | try: 121 | return super().__setattr__(name, value) 122 | except ValidationError as error: 123 | raise ValidationError(f"Error for field '{name}'.", error) 124 | 125 | def __eq__(self, other): 126 | if type(other) is not type(self): 127 | return False 128 | 129 | for name, _ in self.iterate_over_fields(): 130 | try: 131 | our = getattr(self, name) 132 | except errors.ValidationError: 133 | our = None 134 | 135 | try: 136 | their = getattr(other, name) 137 | except errors.ValidationError: 138 | their = None 139 | 140 | if our != their: 141 | return False 142 | 143 | return True 144 | 145 | def __ne__(self, other): 146 | return not (self == other) 147 | 148 | 149 | class _CacheKey: 150 | """Object to identify model in memory.""" 151 | -------------------------------------------------------------------------------- /tests/test_utilities.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | import pytest 4 | 5 | from jsonmodels import utilities 6 | 7 | 8 | def test_allowed_types(): 9 | """Only lists and dicts are allowed.""" 10 | utilities.compare_schemas(["one"], ["one"]) 11 | utilities.compare_schemas({"one": "two"}, {"one": "two"}) 12 | 13 | with pytest.raises(RuntimeError): 14 | utilities.compare_schemas(("tuple",), ("tuple",)) 15 | 16 | with pytest.raises(RuntimeError): 17 | utilities.compare_schemas({"this_is": "dict"}, ["list"]) 18 | 19 | assert utilities.compare_schemas("string", "string") 20 | assert utilities.compare_schemas(42, 42) 21 | assert utilities.compare_schemas(23.0, 23.0) 22 | assert utilities.compare_schemas(True, True) 23 | 24 | assert not utilities.compare_schemas("string", "other string") 25 | assert not utilities.compare_schemas(42, 1) 26 | assert not utilities.compare_schemas(23.0, 24.0) 27 | assert not utilities.compare_schemas(True, False) 28 | 29 | 30 | def test_basic_comparison(): 31 | assert utilities.compare_schemas({"one": "value"}, {"one": "value"}) 32 | assert utilities.compare_schemas(["one", "two"], ["one", "two"]) 33 | assert utilities.compare_schemas(["one", "two"], ["two", "one"]) 34 | 35 | 36 | def test_comparison_with_different_amount_of_items(): 37 | assert not utilities.compare_schemas( 38 | {"one": 1, "two": 2}, {"one": 1, "two": 2, "three": 3} 39 | ) 40 | assert not utilities.compare_schemas(["one", "two"], ["one", "two", "three"]) 41 | 42 | 43 | def test_comparison_of_list_with_items_with_different_keys(): 44 | assert utilities.compare_schemas([{"one": 1}, {"two": 2}], [{"two": 2}, {"one": 1}]) 45 | 46 | 47 | def test_failed_comparison_of_two_dicts(): 48 | assert not utilities.compare_schemas( 49 | {"one": 1, "two": 2}, 50 | {"one": 1, "two": 3}, 51 | ) 52 | 53 | 54 | def test_is_ecma_regex(): 55 | assert utilities.is_ecma_regex("some regex") is False 56 | assert utilities.is_ecma_regex("^some regex$") is False 57 | assert utilities.is_ecma_regex("/^some regex$/") is True 58 | assert utilities.is_ecma_regex("/^some regex$/gim") is True 59 | assert utilities.is_ecma_regex("/^some regex$/miug") is True 60 | 61 | with pytest.raises(ValueError): 62 | utilities.is_ecma_regex("[wrong regex") 63 | with pytest.raises(ValueError): 64 | utilities.is_ecma_regex("wrong regex[]") 65 | with pytest.raises(ValueError): 66 | utilities.is_ecma_regex("wrong regex(gim") 67 | with pytest.raises(ValueError): 68 | utilities.is_ecma_regex("wrong regex)asdf") 69 | 70 | assert utilities.is_ecma_regex(r"/^some regex\/gim") is True 71 | 72 | assert utilities.is_ecma_regex("/^some regex\\\\/miug") is True 73 | assert utilities.is_ecma_regex("/^some regex\\\\/gim") is True 74 | assert utilities.is_ecma_regex("/\\\\/") is True 75 | 76 | assert utilities.is_ecma_regex("some /regex/asdf") is False 77 | assert utilities.is_ecma_regex("^some regex$//") is False 78 | 79 | 80 | def test_convert_ecma_regex_to_python(): 81 | assert ("some", []) == utilities.convert_ecma_regex_to_python("/some/") 82 | assert ("some/pattern", []) == utilities.convert_ecma_regex_to_python( 83 | "/some/pattern/" 84 | ) 85 | assert (r"^some \d+ pattern$", []) == utilities.convert_ecma_regex_to_python( 86 | r"/^some \d+ pattern$/" 87 | ) 88 | 89 | regex, flags = utilities.convert_ecma_regex_to_python(r"/^regex \d/i") 90 | assert r"^regex \d" == regex 91 | assert {re.I} == set(flags) 92 | 93 | result = utilities.convert_ecma_regex_to_python(r"/^regex \d/m") 94 | assert r"^regex \d" == result.regex 95 | assert {re.M} == set(result.flags) 96 | 97 | result = utilities.convert_ecma_regex_to_python(r"/^regex \d/mi") 98 | assert r"^regex \d" == result.regex 99 | assert {re.M, re.I} == set(result.flags) 100 | 101 | with pytest.raises(ValueError): 102 | utilities.convert_ecma_regex_to_python("/regex/wrong") 103 | 104 | assert ("python regex", []) == utilities.convert_ecma_regex_to_python( 105 | "python regex" 106 | ) 107 | 108 | assert (r"^another \d python regex$", []) == utilities.convert_ecma_regex_to_python( 109 | r"^another \d python regex$" 110 | ) 111 | 112 | result = utilities.convert_ecma_regex_to_python("python regex") 113 | assert "python regex" == result.regex 114 | assert [] == result.flags 115 | 116 | 117 | def test_convert_python_regex_to_ecma(): 118 | assert "/^some regex$/" == utilities.convert_python_regex_to_ecma(r"^some regex$") 119 | 120 | assert "/^some regex$/" == utilities.convert_python_regex_to_ecma( 121 | r"^some regex$", [] 122 | ) 123 | 124 | assert r"/pattern \d+/i" == utilities.convert_python_regex_to_ecma( 125 | r"pattern \d+", [re.I] 126 | ) 127 | 128 | assert r"/pattern \d+/m" == utilities.convert_python_regex_to_ecma( 129 | r"pattern \d+", [re.M] 130 | ) 131 | 132 | assert r"/pattern \d+/im" == utilities.convert_python_regex_to_ecma( 133 | r"pattern \d+", [re.I, re.M] 134 | ) 135 | 136 | assert "/ecma pattern$/" == utilities.convert_python_regex_to_ecma( 137 | "/ecma pattern$/" 138 | ) 139 | 140 | assert "/ecma pattern$/im" == utilities.convert_python_regex_to_ecma( 141 | "/ecma pattern$/im" 142 | ) 143 | 144 | assert "/ecma pattern$/wrong" == utilities.convert_python_regex_to_ecma( 145 | "/ecma pattern$/wrong" 146 | ) 147 | 148 | assert "/ecma pattern$/m" == utilities.convert_python_regex_to_ecma( 149 | "/ecma pattern$/m", [re.M] 150 | ) 151 | 152 | 153 | def test_converters(): 154 | assert r"/^ecma \d regex$/im" == utilities.convert_python_regex_to_ecma( 155 | *utilities.convert_ecma_regex_to_python(r"/^ecma \d regex$/im") 156 | ) 157 | 158 | result = utilities.convert_ecma_regex_to_python( 159 | utilities.convert_python_regex_to_ecma(r"^some \w python regex$", [re.I]) 160 | ) 161 | 162 | assert r"^some \w python regex$" == result.regex 163 | assert [re.I] == result.flags 164 | -------------------------------------------------------------------------------- /tests/test_struct.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | import pytest 4 | 5 | from jsonmodels import errors, fields, models 6 | 7 | 8 | class _DateField(fields.BaseField): 9 | _types = (datetime,) 10 | 11 | 12 | def test_to_struct_basic(): 13 | class Person(models.Base): 14 | name = fields.StringField(required=True) 15 | surname = fields.StringField(required=True) 16 | age = fields.IntField() 17 | cash = fields.FloatField() 18 | extra = fields.DictField() 19 | 20 | alan = Person() 21 | with pytest.raises(errors.ValidationError): 22 | alan.to_struct() 23 | 24 | alan.name = "Alan" 25 | alan.surname = "Wake" 26 | assert {"name": "Alan", "surname": "Wake"} == alan.to_struct() 27 | 28 | alan.age = 24 29 | alan.cash = 2445.45 30 | alan.extra = {"extra_value": 1} 31 | 32 | pattern = { 33 | "name": "Alan", 34 | "surname": "Wake", 35 | "age": 24, 36 | "cash": 2445.45, 37 | "extra": {"extra_value": 1}, 38 | } 39 | 40 | assert pattern == alan.to_struct() 41 | 42 | 43 | def test_to_struct_nested_1(): 44 | class Car(models.Base): 45 | brand = fields.StringField() 46 | extra = fields.DictField() 47 | 48 | class ParkingPlace(models.Base): 49 | location = fields.StringField() 50 | car = fields.EmbeddedField(Car) 51 | 52 | place = ParkingPlace() 53 | place.location = "never never land" 54 | 55 | pattern = { 56 | "location": "never never land", 57 | } 58 | assert pattern == place.to_struct() 59 | 60 | place.car = Car() 61 | pattern["car"] = {} 62 | assert pattern == place.to_struct() 63 | 64 | place.car.brand = "Fiat" 65 | place.car.extra = {"extra": 1} 66 | pattern["car"]["brand"] = "Fiat" 67 | pattern["car"]["extra"] = {"extra": 1} 68 | assert pattern == place.to_struct() 69 | 70 | 71 | def test_to_struct_nested_2(): 72 | class Viper(models.Base): 73 | serial = fields.StringField() 74 | 75 | class Lamborghini(models.Base): 76 | serial = fields.StringField() 77 | 78 | class Parking(models.Base): 79 | location = fields.StringField() 80 | cars = fields.ListField([Viper, Lamborghini]) 81 | 82 | parking = Parking() 83 | pattern = {"cars": []} 84 | assert pattern == parking.to_struct() 85 | 86 | parking.location = "somewhere" 87 | pattern["location"] = "somewhere" 88 | assert pattern == parking.to_struct() 89 | 90 | viper = Viper() 91 | viper.serial = "12345" 92 | parking.cars.append(viper) 93 | pattern["cars"].append({"serial": "12345"}) 94 | assert pattern == parking.to_struct() 95 | 96 | parking.cars.append(Viper()) 97 | pattern["cars"].append({}) 98 | assert pattern == parking.to_struct() 99 | 100 | lamborghini = Lamborghini() 101 | lamborghini.serial = "54321" 102 | parking.cars.append(lamborghini) 103 | pattern["cars"].append({"serial": "54321"}) 104 | assert pattern == parking.to_struct() 105 | 106 | 107 | def test_to_struct_with_non_models_types(): 108 | class Person(models.Base): 109 | names = fields.ListField(str) 110 | surname = fields.StringField() 111 | 112 | person = Person() 113 | pattern = {"names": []} 114 | 115 | assert pattern == person.to_struct() 116 | 117 | person.surname = "Norris" 118 | pattern["surname"] = "Norris" 119 | assert pattern == person.to_struct() 120 | 121 | person.names.append("Chuck") 122 | pattern["names"].append("Chuck") 123 | assert pattern == person.to_struct() 124 | 125 | person.names.append("Testa") 126 | pattern["names"].append("Testa") 127 | pattern == person.to_struct() 128 | 129 | 130 | def test_to_struct_with_multi_non_models_types(): 131 | class Person(models.Base): 132 | name = fields.StringField() 133 | mix = fields.ListField((str, float)) 134 | 135 | person = Person() 136 | pattern = {"mix": []} 137 | assert pattern == person.to_struct() 138 | 139 | person.mix.append("something") 140 | pattern["mix"].append("something") 141 | assert pattern == person.to_struct() 142 | 143 | person.mix.append(42.0) 144 | pattern["mix"].append(42.0) 145 | assert pattern == person.to_struct() 146 | 147 | person.mix.append("different") 148 | pattern["mix"].append("different") 149 | assert pattern == person.to_struct() 150 | 151 | 152 | def test_list_to_struct(): 153 | class Cat(models.Base): 154 | name = fields.StringField(required=True) 155 | breed = fields.StringField() 156 | 157 | class Dog(models.Base): 158 | name = fields.StringField(required=True) 159 | age = fields.IntField() 160 | 161 | class Person(models.Base): 162 | name = fields.StringField(required=True) 163 | surname = fields.StringField(required=True) 164 | pets = fields.ListField(items_types=[Cat, Dog]) 165 | 166 | cat = Cat(name="Garfield") 167 | dog = Dog(name="Dogmeat", age=9) 168 | 169 | person = Person(name="Johny", surname="Bravo", pets=[cat, dog]) 170 | pattern = { 171 | "surname": "Bravo", 172 | "name": "Johny", 173 | "pets": [{"name": "Garfield"}, {"age": 9, "name": "Dogmeat"}], 174 | } 175 | assert pattern == person.to_struct() 176 | 177 | 178 | def test_to_struct_time(): 179 | class Clock(models.Base): 180 | time = fields.TimeField() 181 | 182 | clock = Clock() 183 | clock.time = "12:03:34" 184 | 185 | pattern = {"time": "12:03:34"} 186 | assert pattern == clock.to_struct() 187 | 188 | 189 | def test_to_struct_date(): 190 | class Event(models.Base): 191 | start = fields.DateField() 192 | 193 | event = Event() 194 | event.start = "2014-04-21" 195 | 196 | pattern = {"start": "2014-04-21"} 197 | assert pattern == event.to_struct() 198 | 199 | 200 | def test_to_struct_datetime(): 201 | class Event(models.Base): 202 | start = fields.DateTimeField() 203 | 204 | event = Event() 205 | event.start = "2013-05-06 12:03:34" 206 | 207 | pattern = {"start": "2013-05-06T12:03:34"} 208 | assert pattern == event.to_struct() 209 | -------------------------------------------------------------------------------- /tests/test_datetime_fields.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | 3 | import pytest 4 | from dateutil.tz import tzoffset 5 | 6 | from jsonmodels import fields, models 7 | 8 | 9 | class _TestCet(datetime.tzinfo): 10 | def tzname(self): 11 | return "CET" 12 | 13 | def utcoffset(self, dt): 14 | return datetime.timedelta(hours=2) 15 | 16 | def dst(self, dt): 17 | return datetime.timedelta(0) 18 | 19 | 20 | class _TestUtc(datetime.tzinfo): 21 | def tzname(self): 22 | return "UTC" 23 | 24 | def utcoffset(self, dt): 25 | return datetime.timedelta(0) 26 | 27 | def dst(self, dt): 28 | return datetime.timedelta(0) 29 | 30 | 31 | def test_time_field(): 32 | class Event(models.Base): 33 | time = fields.TimeField() 34 | 35 | event = Event() 36 | 37 | event.time = "12:03:34" 38 | assert isinstance(event.time, datetime.time) 39 | event.time = datetime.time() 40 | 41 | 42 | def test_time_field_not_required(): 43 | class Event(models.Base): 44 | time = fields.TimeField(required=False) 45 | 46 | event = Event() 47 | 48 | event.time = None 49 | assert event.time is None 50 | 51 | 52 | def test_time_field_to_struct(): 53 | field = fields.TimeField() 54 | 55 | assert field.str_format is None 56 | 57 | tt = datetime.time() 58 | assert "00:00:00" == field.to_struct(tt) 59 | 60 | tt = datetime.time(12, 34, 56) 61 | assert "12:34:56" == field.to_struct(tt) 62 | 63 | 64 | def test_base_field_to_struct(): 65 | field = fields.BaseField() 66 | assert field.to_struct(True) is True 67 | assert field.to_struct(False) is False 68 | assert field.to_struct("chuck") == "chuck" 69 | assert field.to_struct(1) == 1 70 | 71 | 72 | def test_time_field_to_struct_with_format(): 73 | field = fields.TimeField(str_format="%H:%M") 74 | 75 | assert "%H:%M" == field.str_format 76 | 77 | tt = datetime.time() 78 | assert "00:00" == field.to_struct(tt) 79 | 80 | tt = datetime.time(12, 34, 56) 81 | assert "12:34" == field.to_struct(tt) 82 | 83 | 84 | def test_time_field_to_struct_with_tz(): 85 | field = fields.TimeField() 86 | 87 | tt = datetime.time(tzinfo=_TestCet()) 88 | assert "00:00:00+02:00" == field.to_struct(tt) 89 | 90 | tt = datetime.time(12, 34, 56, tzinfo=_TestCet()) 91 | assert "12:34:56+02:00" == field.to_struct(tt) 92 | 93 | tt = datetime.time(tzinfo=_TestUtc()) 94 | assert "00:00:00+00:00" == field.to_struct(tt) 95 | 96 | tt = datetime.time(12, 34, 56, tzinfo=_TestUtc()) 97 | assert "12:34:56+00:00" == field.to_struct(tt) 98 | 99 | 100 | def test_time_field_format_has_precedence(): 101 | field = fields.TimeField(str_format="%H:%M") 102 | 103 | tt = datetime.time(12, 34, 56, tzinfo=_TestCet()) 104 | assert "12:34" == field.to_struct(tt) 105 | 106 | 107 | def test_time_field_parse_value(): 108 | field = fields.TimeField() 109 | 110 | assert datetime.time() == field.parse_value("00:00:00") 111 | assert datetime.time(2, 34, 45, tzinfo=tzoffset(None, 7200)) == field.parse_value( 112 | "2:34:45+02:00" 113 | ) 114 | 115 | with pytest.raises(ValueError): 116 | field.parse_value("not a time") 117 | 118 | 119 | def test_date_field(): 120 | class Event(models.Base): 121 | date = fields.DateField() 122 | 123 | event = Event() 124 | 125 | event.date = "2014-04-21" 126 | assert isinstance(event.date, datetime.date) 127 | event.date = datetime.date(2014, 4, 21) 128 | 129 | 130 | def test_date_field_not_required(): 131 | class Event(models.Base): 132 | date = fields.DateField(required=False) 133 | 134 | event = Event() 135 | 136 | event.date = None 137 | assert event.date is None 138 | 139 | 140 | def test_date_field_to_struct(): 141 | field = fields.DateField() 142 | 143 | assert field.str_format is None 144 | 145 | tt = datetime.date(2000, 1, 1) 146 | assert "2000-01-01" == field.to_struct(tt) 147 | 148 | tt = datetime.date(2491, 5, 6) 149 | assert "2491-05-06" == field.to_struct(tt) 150 | 151 | 152 | def test_date_field_to_struct_with_format(): 153 | field = fields.DateField(str_format="%Y/%m/%d") 154 | 155 | assert "%Y/%m/%d" == field.str_format 156 | 157 | tt = datetime.date(2244, 5, 7) 158 | assert "2244/05/07" == field.to_struct(tt) 159 | 160 | 161 | def test_date_field_parse_value(): 162 | field = fields.DateField() 163 | 164 | assert datetime.date(2012, 12, 21) == field.parse_value("2012-12-21") 165 | assert datetime.date(2014, 4, 21) == field.parse_value("2014-04-21") 166 | 167 | with pytest.raises(ValueError): 168 | field.parse_value("not a date") 169 | 170 | 171 | def test_datetime_field(): 172 | class Event(models.Base): 173 | date = fields.DateTimeField() 174 | 175 | event = Event() 176 | event.date = "2013-05-06 12:03:34" 177 | 178 | assert isinstance(event.date, datetime.datetime) 179 | event.date = datetime.datetime.now() 180 | 181 | 182 | def test_datetime_field_not_required(): 183 | class Event(models.Base): 184 | date = fields.DateTimeField() 185 | 186 | event = Event() 187 | event.date = None 188 | assert event.date is None 189 | 190 | 191 | def test_datetime_field_to_struct(): 192 | field = fields.DateTimeField() 193 | 194 | assert field.str_format is None 195 | 196 | tt = datetime.datetime(2014, 5, 7, 12, 45, 56) 197 | assert "2014-05-07T12:45:56" == field.to_struct(tt) 198 | 199 | 200 | def test_datetime_field_to_struct_with_format(): 201 | field = fields.TimeField(str_format="%H:%M %Y/%m") 202 | 203 | assert "%H:%M %Y/%m" == field.str_format 204 | 205 | tt = datetime.datetime(2014, 5, 7, 12, 45, 56) 206 | assert "12:45 2014/05" == field.to_struct(tt) 207 | 208 | 209 | def test_datetime_field_to_struct_with_tz(): 210 | field = fields.DateTimeField() 211 | 212 | tt = datetime.datetime(2014, 5, 7, 12, 45, 56, tzinfo=_TestCet()) 213 | assert "2014-05-07T12:45:56+02:00" == field.to_struct(tt) 214 | 215 | tt = datetime.datetime(2014, 5, 7, 12, 45, 56, tzinfo=_TestUtc()) 216 | assert "2014-05-07T12:45:56+00:00" == field.to_struct(tt) 217 | 218 | 219 | def test_datetime_field_format_has_precedence(): 220 | field = fields.DateTimeField(str_format="%H:%M %Y/%m") 221 | 222 | tt = datetime.datetime(2014, 5, 7, 12, 45, 56, tzinfo=_TestCet()) 223 | assert "12:45 2014/05" == field.to_struct(tt) 224 | 225 | 226 | def test_datetime_field_parse_value(): 227 | field = fields.DateTimeField() 228 | 229 | assert datetime.datetime(2014, 4, 21, 12, 45, 56) == field.parse_value( 230 | "2014-04-21T12:45:56" 231 | ) 232 | assert datetime.datetime( 233 | 2014, 4, 21, 12, 45, 56, tzinfo=tzoffset(None, 7200) 234 | ) == field.parse_value("2014-04-21T12:45:56+02:00") 235 | 236 | with pytest.raises(ValueError): 237 | field.parse_value("not a datetime") 238 | 239 | 240 | def test_datetime_field_is_none(): 241 | """If field nullable, dateutil raises error""" 242 | 243 | datetime_field = fields.DateTimeField() 244 | 245 | assert datetime_field.parse_value(None) is None 246 | -------------------------------------------------------------------------------- /jsonmodels/validators.py: -------------------------------------------------------------------------------- 1 | """Predefined validators.""" 2 | import re 3 | from functools import reduce 4 | 5 | from . import utilities 6 | from .errors import ValidationError 7 | 8 | 9 | class Min: 10 | 11 | """Validator for minimum value.""" 12 | 13 | def __init__(self, minimum_value, exclusive=False): 14 | """Init. 15 | 16 | :param minimum_value: Minimum value for validator. 17 | :param bool exclusive: If `True`, then validated value must be strongly 18 | lower than given threshold. 19 | 20 | """ 21 | self.minimum_value = minimum_value 22 | self.exclusive = exclusive 23 | 24 | def validate(self, value): 25 | """Validate value.""" 26 | if self.exclusive: 27 | if value <= self.minimum_value: 28 | tpl = "'{value}' is lower or equal than minimum ('{min}')." 29 | raise ValidationError(tpl.format(value=value, min=self.minimum_value)) 30 | else: 31 | if value < self.minimum_value: 32 | raise ValidationError( 33 | f"'{value}' is lower than minimum ('{self.minimum_value}')." 34 | ) 35 | 36 | def modify_schema(self, field_schema): 37 | """Modify field schema.""" 38 | field_schema["minimum"] = self.minimum_value 39 | if self.exclusive: 40 | field_schema["exclusiveMinimum"] = True 41 | 42 | 43 | class Max: 44 | 45 | """Validator for maximum value.""" 46 | 47 | def __init__(self, maximum_value, exclusive=False): 48 | """Init. 49 | 50 | :param maximum_value: Maximum value for validator. 51 | :param bool exclusive: If `True`, then validated value must be strongly 52 | bigger than given threshold. 53 | 54 | """ 55 | self.maximum_value = maximum_value 56 | self.exclusive = exclusive 57 | 58 | def validate(self, value): 59 | """Validate value.""" 60 | if self.exclusive: 61 | if value >= self.maximum_value: 62 | tpl = "'{val}' is bigger or equal than maximum ('{max}')." 63 | raise ValidationError(tpl.format(val=value, max=self.maximum_value)) 64 | else: 65 | if value > self.maximum_value: 66 | raise ValidationError( 67 | f"'{value}' is bigger than maximum ('{self.maximum_value}')." 68 | ) 69 | 70 | def modify_schema(self, field_schema): 71 | """Modify field schema.""" 72 | field_schema["maximum"] = self.maximum_value 73 | if self.exclusive: 74 | field_schema["exclusiveMaximum"] = True 75 | 76 | 77 | class Regex: 78 | 79 | """Validator for regular expressions.""" 80 | 81 | FLAGS = { 82 | "ignorecase": re.I, 83 | "multiline": re.M, 84 | } 85 | 86 | def __init__(self, pattern, **flags): 87 | """Init. 88 | 89 | Note, that if given pattern is ECMA regex, given flags will be 90 | **completely ignored** and taken from given regex. 91 | 92 | 93 | :param string pattern: Pattern of regex. 94 | :param bool flags: Flags used for the regex matching. 95 | Allowed flag names are in the `FLAGS` attribute. The flag value 96 | does not matter as long as it evaluates to True. 97 | Flags with False values will be ignored. 98 | Invalid flags will be ignored. 99 | 100 | """ 101 | if utilities.is_ecma_regex(pattern): 102 | result = utilities.convert_ecma_regex_to_python(pattern) 103 | self.pattern, self.flags = result 104 | else: 105 | self.pattern = pattern 106 | self.flags = [ 107 | self.FLAGS[key] 108 | for key, value in flags.items() 109 | if key in self.FLAGS and value 110 | ] 111 | 112 | def validate(self, value): 113 | """Validate value.""" 114 | flags = self._calculate_flags() 115 | 116 | try: 117 | result = re.search(self.pattern, value, flags) 118 | except TypeError as te: 119 | raise ValidationError(*te.args) 120 | 121 | if not result: 122 | raise ValidationError( 123 | f'Value "{value}" did not match pattern "{self.pattern}".' 124 | ) 125 | 126 | def _calculate_flags(self): 127 | return reduce(lambda x, y: x | y, self.flags, 0) 128 | 129 | def modify_schema(self, field_schema): 130 | """Modify field schema.""" 131 | field_schema["pattern"] = utilities.convert_python_regex_to_ecma( 132 | self.pattern, self.flags 133 | ) 134 | 135 | 136 | class Length: 137 | 138 | """Validator for length.""" 139 | 140 | def __init__(self, minimum_value=None, maximum_value=None): 141 | """Init. 142 | 143 | Note that if no `minimum_value` neither `maximum_value` will be 144 | specified, `ValueError` will be raised. 145 | 146 | :param int minimum_value: Minimum value (optional). 147 | :param int maximum_value: Maximum value (optional). 148 | 149 | """ 150 | if minimum_value is None and maximum_value is None: 151 | raise ValueError( 152 | "Either 'minimum_value' or 'maximum_value' must be specified." 153 | ) 154 | 155 | self.minimum_value = minimum_value 156 | self.maximum_value = maximum_value 157 | 158 | def validate(self, value): 159 | """Validate value.""" 160 | len_ = len(value) 161 | 162 | if self.minimum_value is not None and len_ < self.minimum_value: 163 | tpl = "Value '{val}' length is lower than allowed minimum '{min}'." 164 | raise ValidationError(tpl.format(val=value, min=self.minimum_value)) 165 | 166 | if self.maximum_value is not None and len_ > self.maximum_value: 167 | raise ValidationError( 168 | "Value '{val}' length is bigger than " 169 | "allowed maximum '{max}'.".format( 170 | val=value, 171 | max=self.maximum_value, 172 | ) 173 | ) 174 | 175 | def modify_schema(self, field_schema): 176 | """Modify field schema.""" 177 | is_array = field_schema.get("type") == "array" 178 | 179 | if self.minimum_value: 180 | key = "minItems" if is_array else "minLength" 181 | field_schema[key] = self.minimum_value 182 | 183 | if self.maximum_value: 184 | key = "maxItems" if is_array else "maxLength" 185 | field_schema[key] = self.maximum_value 186 | 187 | 188 | class Enum: 189 | 190 | """Validator for enums.""" 191 | 192 | def __init__(self, *choices): 193 | """Init. 194 | 195 | :param [] choices: Valid choices for the field. 196 | """ 197 | 198 | self.choices = list(choices) 199 | 200 | def validate(self, value): 201 | if value not in self.choices: 202 | tpl = "Value '{val}' is not a valid choice." 203 | raise ValidationError(tpl.format(val=value)) 204 | 205 | def modify_schema(self, field_schema): 206 | field_schema["enum"] = self.choices 207 | -------------------------------------------------------------------------------- /jsonmodels/builders.py: -------------------------------------------------------------------------------- 1 | """Builders to generate in memory representation of model and fields tree.""" 2 | 3 | 4 | from collections import defaultdict 5 | 6 | from . import errors 7 | from .fields import NotSet 8 | 9 | 10 | class Builder: 11 | def __init__(self, parent=None, nullable=False, default=NotSet): 12 | self.parent = parent 13 | self.types_builders = {} 14 | self.types_count = defaultdict(int) 15 | self.definitions = set() 16 | self.nullable = nullable 17 | self.default = default 18 | 19 | @property 20 | def has_default(self): 21 | return self.default is not NotSet 22 | 23 | def register_type(self, type, builder): 24 | if self.parent: 25 | return self.parent.register_type(type, builder) 26 | 27 | self.types_count[type] += 1 28 | if type not in self.types_builders: 29 | self.types_builders[type] = builder 30 | 31 | def get_builder(self, type): 32 | if self.parent: 33 | return self.parent.get_builder(type) 34 | 35 | return self.types_builders[type] 36 | 37 | def count_type(self, type): 38 | if self.parent: 39 | return self.parent.count_type(type) 40 | 41 | return self.types_count[type] 42 | 43 | @staticmethod 44 | def maybe_build(value): 45 | return value.build() if isinstance(value, Builder) else value 46 | 47 | def add_definition(self, builder): 48 | if self.parent: 49 | return self.parent.add_definition(builder) 50 | 51 | self.definitions.add(builder) 52 | 53 | 54 | class ObjectBuilder(Builder): 55 | def __init__(self, model_type, *args, **kwargs): 56 | super().__init__(*args, **kwargs) 57 | self.properties = {} 58 | self.required = [] 59 | self.type = model_type 60 | 61 | self.register_type(self.type, self) 62 | 63 | def add_field(self, name, field, schema): 64 | _apply_validators_modifications(schema, field) 65 | self.properties[name] = schema 66 | if field.required: 67 | self.required.append(name) 68 | 69 | def build(self): 70 | builder = self.get_builder(self.type) 71 | if self.is_definition and not self.is_root: 72 | self.add_definition(builder) 73 | [self.maybe_build(value) for _, value in self.properties.items()] 74 | return f"#/definitions/{self.type_name}" 75 | else: 76 | return builder.build_definition(nullable=self.nullable) 77 | 78 | @property 79 | def type_name(self): 80 | module_name = f"{self.type.__module__}.{self.type.__name__}" 81 | return module_name.replace(".", "_").lower() 82 | 83 | def build_definition(self, add_defintitions=True, nullable=False): 84 | properties = { 85 | name: self.maybe_build(value) for name, value in self.properties.items() 86 | } 87 | schema = { 88 | "type": "object", 89 | "additionalProperties": False, 90 | "properties": properties, 91 | } 92 | if self.required: 93 | schema["required"] = self.required 94 | if self.definitions and add_defintitions: 95 | schema["definitions"] = { 96 | builder.type_name: builder.build_definition(False, False) 97 | for builder in self.definitions 98 | } 99 | return schema 100 | 101 | @property 102 | def is_definition(self): 103 | if self.count_type(self.type) > 1: 104 | return True 105 | elif self.parent: 106 | return self.parent.is_definition 107 | else: 108 | return False 109 | 110 | @property 111 | def is_root(self): 112 | return not bool(self.parent) 113 | 114 | 115 | def _apply_validators_modifications(field_schema, field): 116 | for validator in field.validators: 117 | try: 118 | validator.modify_schema(field_schema) 119 | except AttributeError: 120 | pass 121 | 122 | if "items" in field_schema: 123 | for validator in field.item_validators: 124 | try: 125 | validator.modify_schema(field_schema["items"]) 126 | except AttributeError: # Case when validator is simple function. 127 | pass 128 | 129 | 130 | class PrimitiveBuilder(Builder): 131 | def __init__(self, type, *args, **kwargs): 132 | super().__init__(*args, **kwargs) 133 | self.type = type 134 | 135 | def build(self): 136 | schema = {} 137 | if issubclass(self.type, str): 138 | obj_type = "string" 139 | elif issubclass(self.type, bool): 140 | obj_type = "boolean" 141 | elif issubclass(self.type, int): 142 | obj_type = "number" 143 | elif issubclass(self.type, float): 144 | obj_type = "number" 145 | elif issubclass(self.type, dict): 146 | obj_type = "object" 147 | else: 148 | raise errors.FieldNotSupported("Can't specify value schema!", self.type) 149 | 150 | if self.nullable: 151 | obj_type = [obj_type, "null"] 152 | schema["type"] = obj_type 153 | 154 | if self.has_default: 155 | schema["default"] = self.default 156 | 157 | return schema 158 | 159 | 160 | class ListBuilder(Builder): 161 | def __init__(self, *args, **kwargs): 162 | super().__init__(*args, **kwargs) 163 | self.schemas = [] 164 | 165 | def add_type_schema(self, schema): 166 | self.schemas.append(schema) 167 | 168 | def build(self): 169 | schema = {"type": "array"} 170 | if self.nullable: 171 | self.add_type_schema({"type": "null"}) 172 | 173 | if self.has_default: 174 | schema["default"] = [self.to_struct(i) for i in self.default] 175 | 176 | schemas = [self.maybe_build(s) for s in self.schemas] 177 | if len(schemas) == 1: 178 | items = schemas[0] 179 | else: 180 | items = {"oneOf": schemas} 181 | 182 | schema["items"] = items 183 | return schema 184 | 185 | @property 186 | def is_definition(self): 187 | return self.parent.is_definition 188 | 189 | @staticmethod 190 | def to_struct(item): 191 | return item 192 | 193 | 194 | class EmbeddedBuilder(Builder): 195 | def __init__(self, *args, **kwargs): 196 | super().__init__(*args, **kwargs) 197 | self.schemas = [] 198 | 199 | def add_type_schema(self, schema): 200 | self.schemas.append(schema) 201 | 202 | def build(self): 203 | if self.nullable: 204 | self.add_type_schema({"type": "null"}) 205 | 206 | schemas = [self.maybe_build(schema) for schema in self.schemas] 207 | if len(schemas) == 1: 208 | schema = schemas[0] 209 | else: 210 | schema = {"oneOf": schemas} 211 | 212 | if self.has_default: 213 | # The default value of EmbeddedField is expected to be an instance 214 | # of a subclass of models.Base, thus have `to_struct` 215 | schema["default"] = self.default.to_struct() 216 | 217 | return schema 218 | 219 | @property 220 | def is_definition(self): 221 | return self.parent.is_definition 222 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | PAPER = 8 | BUILDDIR = _build 9 | 10 | # User-friendly check for sphinx-build 11 | ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1) 12 | $(error The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don't have Sphinx installed, grab it from http://sphinx-doc.org/) 13 | endif 14 | 15 | # Internal variables. 16 | PAPEROPT_a4 = -D latex_paper_size=a4 17 | PAPEROPT_letter = -D latex_paper_size=letter 18 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 19 | # the i18n builder cannot share the environment and doctrees with the others 20 | I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 21 | 22 | .PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest gettext 23 | 24 | help: 25 | @echo "Please use \`make ' where is one of" 26 | @echo " html to make standalone HTML files" 27 | @echo " dirhtml to make HTML files named index.html in directories" 28 | @echo " singlehtml to make a single large HTML file" 29 | @echo " pickle to make pickle files" 30 | @echo " json to make JSON files" 31 | @echo " htmlhelp to make HTML files and a HTML help project" 32 | @echo " qthelp to make HTML files and a qthelp project" 33 | @echo " devhelp to make HTML files and a Devhelp project" 34 | @echo " epub to make an epub" 35 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" 36 | @echo " latexpdf to make LaTeX files and run them through pdflatex" 37 | @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx" 38 | @echo " text to make text files" 39 | @echo " man to make manual pages" 40 | @echo " texinfo to make Texinfo files" 41 | @echo " info to make Texinfo files and run them through makeinfo" 42 | @echo " gettext to make PO message catalogs" 43 | @echo " changes to make an overview of all changed/added/deprecated items" 44 | @echo " xml to make Docutils-native XML files" 45 | @echo " pseudoxml to make pseudoxml-XML files for display purposes" 46 | @echo " linkcheck to check all external links for integrity" 47 | @echo " doctest to run all doctests embedded in the documentation (if enabled)" 48 | 49 | clean: 50 | rm -rf $(BUILDDIR)/* 51 | 52 | html: 53 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html 54 | @echo 55 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." 56 | 57 | dirhtml: 58 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml 59 | @echo 60 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." 61 | 62 | singlehtml: 63 | $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml 64 | @echo 65 | @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." 66 | 67 | pickle: 68 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle 69 | @echo 70 | @echo "Build finished; now you can process the pickle files." 71 | 72 | json: 73 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json 74 | @echo 75 | @echo "Build finished; now you can process the JSON files." 76 | 77 | htmlhelp: 78 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp 79 | @echo 80 | @echo "Build finished; now you can run HTML Help Workshop with the" \ 81 | ".hhp project file in $(BUILDDIR)/htmlhelp." 82 | 83 | qthelp: 84 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp 85 | @echo 86 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \ 87 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:" 88 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/complexity.qhcp" 89 | @echo "To view the help file:" 90 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/complexity.qhc" 91 | 92 | devhelp: 93 | $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp 94 | @echo 95 | @echo "Build finished." 96 | @echo "To view the help file:" 97 | @echo "# mkdir -p $$HOME/.local/share/devhelp/complexity" 98 | @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/complexity" 99 | @echo "# devhelp" 100 | 101 | epub: 102 | $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub 103 | @echo 104 | @echo "Build finished. The epub file is in $(BUILDDIR)/epub." 105 | 106 | latex: 107 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 108 | @echo 109 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." 110 | @echo "Run \`make' in that directory to run these through (pdf)latex" \ 111 | "(use \`make latexpdf' here to do that automatically)." 112 | 113 | latexpdf: 114 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 115 | @echo "Running LaTeX files through pdflatex..." 116 | $(MAKE) -C $(BUILDDIR)/latex all-pdf 117 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 118 | 119 | latexpdfja: 120 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 121 | @echo "Running LaTeX files through platex and dvipdfmx..." 122 | $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja 123 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 124 | 125 | text: 126 | $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text 127 | @echo 128 | @echo "Build finished. The text files are in $(BUILDDIR)/text." 129 | 130 | man: 131 | $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man 132 | @echo 133 | @echo "Build finished. The manual pages are in $(BUILDDIR)/man." 134 | 135 | texinfo: 136 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 137 | @echo 138 | @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." 139 | @echo "Run \`make' in that directory to run these through makeinfo" \ 140 | "(use \`make info' here to do that automatically)." 141 | 142 | info: 143 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 144 | @echo "Running Texinfo files through makeinfo..." 145 | make -C $(BUILDDIR)/texinfo info 146 | @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." 147 | 148 | gettext: 149 | $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale 150 | @echo 151 | @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." 152 | 153 | changes: 154 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes 155 | @echo 156 | @echo "The overview file is in $(BUILDDIR)/changes." 157 | 158 | linkcheck: 159 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck 160 | @echo 161 | @echo "Link check complete; look for any errors in the above output " \ 162 | "or in $(BUILDDIR)/linkcheck/output.txt." 163 | 164 | doctest: 165 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest 166 | @echo "Testing of doctests in the sources finished, look at the " \ 167 | "results in $(BUILDDIR)/doctest/output.txt." 168 | 169 | xml: 170 | $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml 171 | @echo 172 | @echo "Build finished. The XML files are in $(BUILDDIR)/xml." 173 | 174 | pseudoxml: 175 | $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml 176 | @echo 177 | @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml." -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | REM Command file for Sphinx documentation 4 | 5 | if "%SPHINXBUILD%" == "" ( 6 | set SPHINXBUILD=sphinx-build 7 | ) 8 | set BUILDDIR=_build 9 | set ALLSPHINXOPTS=-d %BUILDDIR%/doctrees %SPHINXOPTS% . 10 | set I18NSPHINXOPTS=%SPHINXOPTS% . 11 | if NOT "%PAPER%" == "" ( 12 | set ALLSPHINXOPTS=-D latex_paper_size=%PAPER% %ALLSPHINXOPTS% 13 | set I18NSPHINXOPTS=-D latex_paper_size=%PAPER% %I18NSPHINXOPTS% 14 | ) 15 | 16 | if "%1" == "" goto help 17 | 18 | if "%1" == "help" ( 19 | :help 20 | echo.Please use `make ^` where ^ is one of 21 | echo. html to make standalone HTML files 22 | echo. dirhtml to make HTML files named index.html in directories 23 | echo. singlehtml to make a single large HTML file 24 | echo. pickle to make pickle files 25 | echo. json to make JSON files 26 | echo. htmlhelp to make HTML files and a HTML help project 27 | echo. qthelp to make HTML files and a qthelp project 28 | echo. devhelp to make HTML files and a Devhelp project 29 | echo. epub to make an epub 30 | echo. latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter 31 | echo. text to make text files 32 | echo. man to make manual pages 33 | echo. texinfo to make Texinfo files 34 | echo. gettext to make PO message catalogs 35 | echo. changes to make an overview over all changed/added/deprecated items 36 | echo. xml to make Docutils-native XML files 37 | echo. pseudoxml to make pseudoxml-XML files for display purposes 38 | echo. linkcheck to check all external links for integrity 39 | echo. doctest to run all doctests embedded in the documentation if enabled 40 | goto end 41 | ) 42 | 43 | if "%1" == "clean" ( 44 | for /d %%i in (%BUILDDIR%\*) do rmdir /q /s %%i 45 | del /q /s %BUILDDIR%\* 46 | goto end 47 | ) 48 | 49 | 50 | %SPHINXBUILD% 2> nul 51 | if errorlevel 9009 ( 52 | echo. 53 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 54 | echo.installed, then set the SPHINXBUILD environment variable to point 55 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 56 | echo.may add the Sphinx directory to PATH. 57 | echo. 58 | echo.If you don't have Sphinx installed, grab it from 59 | echo.http://sphinx-doc.org/ 60 | exit /b 1 61 | ) 62 | 63 | if "%1" == "html" ( 64 | %SPHINXBUILD% -b html %ALLSPHINXOPTS% %BUILDDIR%/html 65 | if errorlevel 1 exit /b 1 66 | echo. 67 | echo.Build finished. The HTML pages are in %BUILDDIR%/html. 68 | goto end 69 | ) 70 | 71 | if "%1" == "dirhtml" ( 72 | %SPHINXBUILD% -b dirhtml %ALLSPHINXOPTS% %BUILDDIR%/dirhtml 73 | if errorlevel 1 exit /b 1 74 | echo. 75 | echo.Build finished. The HTML pages are in %BUILDDIR%/dirhtml. 76 | goto end 77 | ) 78 | 79 | if "%1" == "singlehtml" ( 80 | %SPHINXBUILD% -b singlehtml %ALLSPHINXOPTS% %BUILDDIR%/singlehtml 81 | if errorlevel 1 exit /b 1 82 | echo. 83 | echo.Build finished. The HTML pages are in %BUILDDIR%/singlehtml. 84 | goto end 85 | ) 86 | 87 | if "%1" == "pickle" ( 88 | %SPHINXBUILD% -b pickle %ALLSPHINXOPTS% %BUILDDIR%/pickle 89 | if errorlevel 1 exit /b 1 90 | echo. 91 | echo.Build finished; now you can process the pickle files. 92 | goto end 93 | ) 94 | 95 | if "%1" == "json" ( 96 | %SPHINXBUILD% -b json %ALLSPHINXOPTS% %BUILDDIR%/json 97 | if errorlevel 1 exit /b 1 98 | echo. 99 | echo.Build finished; now you can process the JSON files. 100 | goto end 101 | ) 102 | 103 | if "%1" == "htmlhelp" ( 104 | %SPHINXBUILD% -b htmlhelp %ALLSPHINXOPTS% %BUILDDIR%/htmlhelp 105 | if errorlevel 1 exit /b 1 106 | echo. 107 | echo.Build finished; now you can run HTML Help Workshop with the ^ 108 | .hhp project file in %BUILDDIR%/htmlhelp. 109 | goto end 110 | ) 111 | 112 | if "%1" == "qthelp" ( 113 | %SPHINXBUILD% -b qthelp %ALLSPHINXOPTS% %BUILDDIR%/qthelp 114 | if errorlevel 1 exit /b 1 115 | echo. 116 | echo.Build finished; now you can run "qcollectiongenerator" with the ^ 117 | .qhcp project file in %BUILDDIR%/qthelp, like this: 118 | echo.^> qcollectiongenerator %BUILDDIR%\qthelp\complexity.qhcp 119 | echo.To view the help file: 120 | echo.^> assistant -collectionFile %BUILDDIR%\qthelp\complexity.ghc 121 | goto end 122 | ) 123 | 124 | if "%1" == "devhelp" ( 125 | %SPHINXBUILD% -b devhelp %ALLSPHINXOPTS% %BUILDDIR%/devhelp 126 | if errorlevel 1 exit /b 1 127 | echo. 128 | echo.Build finished. 129 | goto end 130 | ) 131 | 132 | if "%1" == "epub" ( 133 | %SPHINXBUILD% -b epub %ALLSPHINXOPTS% %BUILDDIR%/epub 134 | if errorlevel 1 exit /b 1 135 | echo. 136 | echo.Build finished. The epub file is in %BUILDDIR%/epub. 137 | goto end 138 | ) 139 | 140 | if "%1" == "latex" ( 141 | %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex 142 | if errorlevel 1 exit /b 1 143 | echo. 144 | echo.Build finished; the LaTeX files are in %BUILDDIR%/latex. 145 | goto end 146 | ) 147 | 148 | if "%1" == "latexpdf" ( 149 | %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex 150 | cd %BUILDDIR%/latex 151 | make all-pdf 152 | cd %BUILDDIR%/.. 153 | echo. 154 | echo.Build finished; the PDF files are in %BUILDDIR%/latex. 155 | goto end 156 | ) 157 | 158 | if "%1" == "latexpdfja" ( 159 | %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex 160 | cd %BUILDDIR%/latex 161 | make all-pdf-ja 162 | cd %BUILDDIR%/.. 163 | echo. 164 | echo.Build finished; the PDF files are in %BUILDDIR%/latex. 165 | goto end 166 | ) 167 | 168 | if "%1" == "text" ( 169 | %SPHINXBUILD% -b text %ALLSPHINXOPTS% %BUILDDIR%/text 170 | if errorlevel 1 exit /b 1 171 | echo. 172 | echo.Build finished. The text files are in %BUILDDIR%/text. 173 | goto end 174 | ) 175 | 176 | if "%1" == "man" ( 177 | %SPHINXBUILD% -b man %ALLSPHINXOPTS% %BUILDDIR%/man 178 | if errorlevel 1 exit /b 1 179 | echo. 180 | echo.Build finished. The manual pages are in %BUILDDIR%/man. 181 | goto end 182 | ) 183 | 184 | if "%1" == "texinfo" ( 185 | %SPHINXBUILD% -b texinfo %ALLSPHINXOPTS% %BUILDDIR%/texinfo 186 | if errorlevel 1 exit /b 1 187 | echo. 188 | echo.Build finished. The Texinfo files are in %BUILDDIR%/texinfo. 189 | goto end 190 | ) 191 | 192 | if "%1" == "gettext" ( 193 | %SPHINXBUILD% -b gettext %I18NSPHINXOPTS% %BUILDDIR%/locale 194 | if errorlevel 1 exit /b 1 195 | echo. 196 | echo.Build finished. The message catalogs are in %BUILDDIR%/locale. 197 | goto end 198 | ) 199 | 200 | if "%1" == "changes" ( 201 | %SPHINXBUILD% -b changes %ALLSPHINXOPTS% %BUILDDIR%/changes 202 | if errorlevel 1 exit /b 1 203 | echo. 204 | echo.The overview file is in %BUILDDIR%/changes. 205 | goto end 206 | ) 207 | 208 | if "%1" == "linkcheck" ( 209 | %SPHINXBUILD% -b linkcheck %ALLSPHINXOPTS% %BUILDDIR%/linkcheck 210 | if errorlevel 1 exit /b 1 211 | echo. 212 | echo.Link check complete; look for any errors in the above output ^ 213 | or in %BUILDDIR%/linkcheck/output.txt. 214 | goto end 215 | ) 216 | 217 | if "%1" == "doctest" ( 218 | %SPHINXBUILD% -b doctest %ALLSPHINXOPTS% %BUILDDIR%/doctest 219 | if errorlevel 1 exit /b 1 220 | echo. 221 | echo.Testing of doctests in the sources finished, look at the ^ 222 | results in %BUILDDIR%/doctest/output.txt. 223 | goto end 224 | ) 225 | 226 | if "%1" == "xml" ( 227 | %SPHINXBUILD% -b xml %ALLSPHINXOPTS% %BUILDDIR%/xml 228 | if errorlevel 1 exit /b 1 229 | echo. 230 | echo.Build finished. The XML files are in %BUILDDIR%/xml. 231 | goto end 232 | ) 233 | 234 | if "%1" == "pseudoxml" ( 235 | %SPHINXBUILD% -b pseudoxml %ALLSPHINXOPTS% %BUILDDIR%/pseudoxml 236 | if errorlevel 1 exit /b 1 237 | echo. 238 | echo.Build finished. The pseudo-XML files are in %BUILDDIR%/pseudoxml. 239 | goto end 240 | ) 241 | 242 | :end -------------------------------------------------------------------------------- /docs/usage.rst: -------------------------------------------------------------------------------- 1 | ===== 2 | Usage 3 | ===== 4 | 5 | To use JSON models in a project: 6 | 7 | .. code-block:: python 8 | 9 | import jsonmodels 10 | 11 | Creating models 12 | --------------- 13 | 14 | To create models you need to create class that inherits from 15 | :class:`jsonmodels.models.Base` (and *NOT* :class:`jsonmodels.models.PreBase` 16 | to which although refers links in documentation) and have class attributes 17 | which values inherits from :class:`jsonmodels.fields.BaseField` (so all other 18 | fields classes from :mod:`jsonmodels.fields`). 19 | 20 | .. code-block:: python 21 | 22 | class Cat(models.Base): 23 | 24 | name = fields.StringField(required=True) 25 | breed = fields.StringField() 26 | extra = fields.DictField() 27 | 28 | 29 | class Dog(models.Base): 30 | 31 | name = fields.StringField(required=True) 32 | age = fields.IntField() 33 | 34 | 35 | class Car(models.Base): 36 | 37 | registration_number = fields.StringField(required=True) 38 | engine_capacity = fields.FloatField() 39 | color = fields.StringField() 40 | 41 | 42 | class Person(models.Base): 43 | 44 | name = fields.StringField(required=True) 45 | surname = fields.StringField(required=True) 46 | car = fields.EmbeddedField(Car) 47 | pets = fields.ListField([Cat, Dog]) 48 | 49 | Usage 50 | ----- 51 | 52 | After that you can use it as normal object. You can pass kwargs in constructor 53 | or :meth:`jsonmodels.models.PreBase.populate` method. 54 | 55 | .. code-block:: python 56 | 57 | >>> person = Person(name='Chuck') 58 | >>> person.name 59 | 'Chuck' 60 | >>> person.surname 61 | None 62 | >>> person.populate(surname='Norris') 63 | >>> person.surname 64 | 'Norris' 65 | >>> person.name 66 | 'Chuck' 67 | 68 | Validation 69 | ---------- 70 | 71 | You can specify which fields are *required*, if required value is absent during 72 | :meth:`jsonmodels.models.PreBase.validate` the 73 | :class:`jsonmodels.error.ValidationError` will be raised. 74 | 75 | .. code-block:: python 76 | 77 | >>> bugs = Person(name='Bugs', surname='Bunny') 78 | >>> bugs.validate() 79 | 80 | >>> dafty = Person() 81 | >>> dafty.validate() 82 | *** ValidationError: Field is required! 83 | 84 | Note that required fields are not raising error if no value was assigned 85 | during initialization, but first try of accessing will raise it. 86 | 87 | .. code-block:: python 88 | 89 | >>> dafty = Person() 90 | >>> dafty.name 91 | *** ValidationError: Field is required! 92 | 93 | Also validation is made every time new value is assigned, so trying assign 94 | `int` to `StringField` will also raise an error: 95 | 96 | .. code-block:: python 97 | 98 | >>> dafty.name = 3 99 | *** ValidationError: ('Value is wrong, expected type "basestring"', 3) 100 | 101 | During casting model to JSON or JSONSchema explicite validation is always 102 | called. 103 | 104 | Validators 105 | ~~~~~~~~~~ 106 | 107 | Validators can be passed through `validators` keyword, as a single validator, 108 | or list of validators (so, as you may be expecting, you can't pass object that 109 | extends `List`). 110 | 111 | You can try to use validators shipped with this library. To get more details 112 | see :mod:`jsonmodels.validators`. Shipped validators affect generated schema 113 | out of the box, to use full potential JSON schema gives you. 114 | 115 | Custom validators 116 | ~~~~~~~~~~~~~~~~~ 117 | 118 | You can always specify your own validators. Custom validator can be object with 119 | `validate` method (which takes precedence) or function (or callable object). 120 | 121 | Each validator **must** raise exception to indicate validation 122 | didn't pass. Returning values like `False` won't have any effect. 123 | 124 | .. code-block:: python 125 | 126 | >>> class RangeValidator(object): 127 | ... 128 | ... def __init__(self, min, max): 129 | ... # Some logic here. 130 | ... 131 | ... def validate(self, value): 132 | ... # Some logic here. 133 | 134 | >>> def some_validator(value): 135 | ... # Some logic here. 136 | 137 | >>> class Person(models.Base): 138 | ... 139 | ... name = fields.StringField(required=True, validators=some_validator) 140 | ... surname = fields.StringField(required=True) 141 | ... age = fields.IntField( 142 | ... Car, validators=[some_validator, RangeValidator(0, 100)]) 143 | 144 | If your validator have method `modify_schema` you can use it to affect 145 | generated schema in any way. Given argument is schema for single field. For 146 | example: 147 | 148 | .. code-block:: python 149 | 150 | >>> class Length(object): 151 | ... 152 | ... def validate(self, value): 153 | ... # Some logic here. 154 | ... 155 | ... def modify_schema(self, field_schema): 156 | ... if self.minimum_value: 157 | ... field_schema['minLength'] = self.minimum_value 158 | ... 159 | ... if self.maximum_value: 160 | ... field_schema['maxLength'] = self.maximum_value 161 | 162 | Default values 163 | -------------- 164 | 165 | You can specify default value for each of field (and this default value will be 166 | shown in generated schema). Currently only scalars are accepted and model 167 | instances for `EmbeddedField`, like in example below: 168 | 169 | .. code-block:: python 170 | 171 | class Pet(models.Base): 172 | kind = fields.StringField(default="Dog") 173 | 174 | class Person(models.Base): 175 | name = fields.StringField(default="John Doe") 176 | age = fields.IntField(default=18) 177 | pet = fields.EmbeddedField(Pet, default=Pet(kind="Cat")) 178 | profession = fields.StringField(default=None) 179 | 180 | With this schema generated look like this: 181 | 182 | .. code-block:: json 183 | 184 | { 185 | "type": "object", 186 | "additionalProperties": false, 187 | "properties": { 188 | "age": { 189 | "type": "number", 190 | "default": 18 191 | }, 192 | "name": { 193 | "type": "string", 194 | "default": "John Doe" 195 | }, 196 | "pet": { 197 | "type": "object", 198 | "additionalProperties": false, 199 | "properties": { 200 | "kind": { 201 | "type": "string", 202 | "default": "Dog" 203 | } 204 | }, 205 | "default": { 206 | "kind": "Cat" 207 | } 208 | }, 209 | "profession": { 210 | "type": "string", 211 | "default": null 212 | } 213 | } 214 | } 215 | 216 | Casting to Python struct (and JSON) 217 | ----------------------------------- 218 | 219 | Instance of model can be easy casted to Python struct (and thanks to that, 220 | later to JSON). See :meth:`jsonmodels.models.PreBase.to_struct`. 221 | 222 | .. code-block:: python 223 | 224 | >>> cat = Cat(name='Garfield') 225 | >>> dog = Dog(name='Dogmeat', age=9) 226 | >>> car = Car(registration_number='ASDF 777', color='red') 227 | >>> person = Person(name='Johny', surname='Bravo', pets=[cat, dog]) 228 | >>> person.car = car 229 | >>> person.to_struct() 230 | # (...) 231 | 232 | Having Python struct it is easy to cast it to JSON. 233 | 234 | .. code-block:: python 235 | 236 | >>> import json 237 | >>> person_json = json.dumps(person.to_struct()) 238 | 239 | Creating JSON schema for your model 240 | ----------------------------------- 241 | 242 | JSON schema, although it is far more friendly than XML schema still have 243 | something in common with its old friend: people don't like to write it and 244 | (probably) they shouldn't do it or even read it. Thanks to `jsonmodels` it 245 | is possible to you to operate just on models. 246 | 247 | .. code-block:: python 248 | 249 | >>> person = Person() 250 | >>> schema = person.to_json_schema() 251 | 252 | And thats it! You can serve then this schema through your API or use it for 253 | validation incoming data. 254 | 255 | Different names in structure and objects 256 | ---------------------------------------- 257 | 258 | In case you want (or you must) use different names in generated/consumed data 259 | and its schema you can use `name=` param for your fields: 260 | 261 | .. code-block:: python 262 | 263 | class Human(models.Base): 264 | 265 | name = fields.StringField() 266 | surname = fields.StringField(name='second-name') 267 | 268 | The `name` value will be usable as `surname` in all places where you are using 269 | **objects** and will be seen as `second-name` in all structures - so in dict 270 | representation and jsonschema. 271 | 272 | .. code-block:: python 273 | 274 | >>> john = Human(name='John', surname='Doe') 275 | >>> john.surname 276 | 'Doe' 277 | >>> john.to_struct() 278 | {'name': 'John', 'second-name': 'Doe'} 279 | 280 | Remember that your models must not have conflicting names in a way that it 281 | cannot be resolved by model. You can use cross references though, like this: 282 | 283 | .. code-block:: python 284 | 285 | class Foo(models.Base): 286 | 287 | one = fields.IntField(name='two') 288 | two = fields.IntField(name='one') 289 | 290 | But remember that **structure name has priority** so with `Foo` model above you 291 | could run into wrong assumptions: 292 | 293 | .. code-block:: python 294 | 295 | >>> foo = Foo(one=1, two=2) 296 | >>> foo.one 297 | 2 # Not 1, like expected 298 | >>> foo.two 299 | 1 # Not 2, like expected 300 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """Configuration for documentation.""" 3 | # 4 | # complexity documentation build configuration file, created by 5 | # sphinx-quickstart on Tue Jul 9 22:26:36 2013. 6 | # 7 | # This file is execfile()d with the current directory set to its containing dir. 8 | # 9 | # Note that not all possible configuration values are present in this 10 | # autogenerated file. 11 | # 12 | # All configuration values have a default; values that are commented out 13 | # serve to show the default. 14 | 15 | import os 16 | import sys 17 | 18 | # If extensions (or modules to document with autodoc) are in another directory, 19 | # add these directories to sys.path here. If the directory is relative to the 20 | # documentation root, use os.path.abspath to make it absolute, like shown here. 21 | # sys.path.insert(0, os.path.abspath('.')) 22 | 23 | # Get the project root dir, which is the parent dir of this 24 | cwd = os.getcwd() 25 | project_root = os.path.dirname(cwd) 26 | 27 | # Insert the project root dir as the first element in the PYTHONPATH. 28 | # This lets us ensure that the source package is imported, and that its 29 | # version is used. 30 | sys.path.insert(0, project_root) 31 | 32 | import jsonmodels 33 | 34 | # -- General configuration ----------------------------------------------------- 35 | 36 | # If your documentation needs a minimal Sphinx version, state it here. 37 | # needs_sphinx = '1.0' 38 | 39 | # Add any Sphinx extension module names here, as strings. They can be extensions 40 | # coming with Sphinx (named 'sphinx.ext.*') or your custom ones. 41 | extensions = [ 42 | "sphinx.ext.autodoc", 43 | "sphinx.ext.viewcode", 44 | ] 45 | 46 | try: 47 | import sphinxcontrib.spelling 48 | 49 | extensions.append("sphinxcontrib.spelling") 50 | except ImportError: 51 | pass 52 | 53 | # Add any paths that contain templates here, relative to this directory. 54 | templates_path = ["_templates"] 55 | 56 | # The suffix of source filenames. 57 | source_suffix = ".rst" 58 | 59 | # The encoding of source files. 60 | # source_encoding = 'utf-8-sig' 61 | 62 | # The master toctree document. 63 | master_doc = "index" 64 | 65 | # General information about the project. 66 | project = "JSON models" 67 | copyright = "2014, Szczepan Cieślik" 68 | 69 | # The version info for the project you're documenting, acts as replacement for 70 | # |version| and |release|, also used in various other places throughout the 71 | # built documents. 72 | # 73 | # The short X.Y version. 74 | version = jsonmodels.__version__ 75 | # The full version, including alpha/beta/rc tags. 76 | release = jsonmodels.__version__ 77 | 78 | # The language for content autogenerated by Sphinx. Refer to documentation 79 | # for a list of supported languages. 80 | # language = None 81 | 82 | # There are two options for replacing |today|: either, you set today to some 83 | # non-false value, then it is used: 84 | # today = '' 85 | # Else, today_fmt is used as the format for a strftime call. 86 | # today_fmt = '%B %d, %Y' 87 | 88 | # List of patterns, relative to source directory, that match files and 89 | # directories to ignore when looking for source files. 90 | exclude_patterns = ["_build"] 91 | 92 | # The reST default role (used for this markup: `text`) to use for all documents. 93 | # default_role = None 94 | 95 | # If true, '()' will be appended to :func: etc. cross-reference text. 96 | # add_function_parentheses = True 97 | 98 | # If true, the current module name will be prepended to all description 99 | # unit titles (such as .. function::). 100 | # add_module_names = True 101 | 102 | # If true, sectionauthor and moduleauthor directives will be shown in the 103 | # output. They are ignored by default. 104 | # show_authors = False 105 | 106 | # The name of the Pygments (syntax highlighting) style to use. 107 | pygments_style = "sphinx" 108 | 109 | # A list of ignored prefixes for module index sorting. 110 | # modindex_common_prefix = [] 111 | 112 | # If true, keep warnings as "system message" paragraphs in the built documents. 113 | # keep_warnings = False 114 | 115 | 116 | # -- Options for HTML output --------------------------------------------------- 117 | 118 | # The theme to use for HTML and HTML Help pages. See the documentation for 119 | # a list of builtin themes. 120 | html_theme = "default" 121 | 122 | # Theme options are theme-specific and customize the look and feel of a theme 123 | # further. For a list of options available for each theme, see the 124 | # documentation. 125 | # html_theme_options = {} 126 | 127 | # Add any paths that contain custom themes here, relative to this directory. 128 | # html_theme_path = [] 129 | 130 | # The name for this set of Sphinx documents. If None, it defaults to 131 | # " v documentation". 132 | # html_title = None 133 | 134 | # A shorter title for the navigation bar. Default is the same as html_title. 135 | # html_short_title = None 136 | 137 | # The name of an image file (relative to this directory) to place at the top 138 | # of the sidebar. 139 | # html_logo = None 140 | 141 | # The name of an image file (within the static path) to use as favicon of the 142 | # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 143 | # pixels large. 144 | # html_favicon = None 145 | 146 | # Add any paths that contain custom static files (such as style sheets) here, 147 | # relative to this directory. They are copied after the builtin static files, 148 | # so a file named "default.css" will overwrite the builtin "default.css". 149 | html_static_path = ["_static"] 150 | 151 | # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, 152 | # using the given strftime format. 153 | # html_last_updated_fmt = '%b %d, %Y' 154 | 155 | # If true, SmartyPants will be used to convert quotes and dashes to 156 | # typographically correct entities. 157 | # html_use_smartypants = True 158 | 159 | # Custom sidebar templates, maps document names to template names. 160 | # html_sidebars = {} 161 | 162 | # Additional templates that should be rendered to pages, maps page names to 163 | # template names. 164 | # html_additional_pages = {} 165 | 166 | # If false, no module index is generated. 167 | # html_domain_indices = True 168 | 169 | # If false, no index is generated. 170 | # html_use_index = True 171 | 172 | # If true, the index is split into individual pages for each letter. 173 | # html_split_index = False 174 | 175 | # If true, links to the reST sources are added to the pages. 176 | # html_show_sourcelink = True 177 | 178 | # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. 179 | # html_show_sphinx = True 180 | 181 | # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. 182 | # html_show_copyright = True 183 | 184 | # If true, an OpenSearch description file will be output, and all pages will 185 | # contain a tag referring to it. The value of this option must be the 186 | # base URL from which the finished HTML is served. 187 | # html_use_opensearch = '' 188 | 189 | # This is the file name suffix for HTML files (e.g. ".xhtml"). 190 | # html_file_suffix = None 191 | 192 | # Output file base name for HTML help builder. 193 | htmlhelp_basename = "jsonmodelsdoc" 194 | 195 | 196 | # -- Options for LaTeX output -------------------------------------------------- 197 | 198 | latex_elements = { 199 | # The paper size ('letterpaper' or 'a4paper'). 200 | #'papersize': 'letterpaper', 201 | # The font size ('10pt', '11pt' or '12pt'). 202 | #'pointsize': '10pt', 203 | # Additional stuff for the LaTeX preamble. 204 | #'preamble': '', 205 | } 206 | 207 | # Grouping the document tree into LaTeX files. List of tuples 208 | # (source start file, target name, title, author, documentclass [howto/manual]). 209 | latex_documents = [ 210 | ( 211 | "index", 212 | "jsonmodels.tex", 213 | "JSON models Documentation", 214 | "Szczepan Cieślik", 215 | "manual", 216 | ), 217 | ] 218 | 219 | # The name of an image file (relative to this directory) to place at the top of 220 | # the title page. 221 | # latex_logo = None 222 | 223 | # For "manual" documents, if this is true, then toplevel headings are parts, 224 | # not chapters. 225 | # latex_use_parts = False 226 | 227 | # If true, show page references after internal links. 228 | # latex_show_pagerefs = False 229 | 230 | # If true, show URL addresses after external links. 231 | # latex_show_urls = False 232 | 233 | # Documents to append as an appendix to all manuals. 234 | # latex_appendices = [] 235 | 236 | # If false, no module index is generated. 237 | # latex_domain_indices = True 238 | 239 | 240 | # -- Options for manual page output -------------------------------------------- 241 | 242 | # One entry per manual page. List of tuples 243 | # (source start file, name, description, authors, manual section). 244 | man_pages = [ 245 | ("index", "jsonmodels", "JSON models Documentation", ["Szczepan Cieślik"], 1) 246 | ] 247 | 248 | # If true, show URL addresses after external links. 249 | # man_show_urls = False 250 | 251 | 252 | # -- Options for Texinfo output ------------------------------------------------ 253 | 254 | # Grouping the document tree into Texinfo files. List of tuples 255 | # (source start file, target name, title, author, 256 | # dir menu entry, description, category) 257 | texinfo_documents = [ 258 | ( 259 | "index", 260 | "jsonmodels", 261 | "JSON models Documentation", 262 | "Szczepan Cieślik", 263 | "jsonmodels", 264 | "One line description of project.", 265 | "Miscellaneous", 266 | ), 267 | ] 268 | 269 | # Documents to append as an appendix to all manuals. 270 | # texinfo_appendices = [] 271 | 272 | # If false, no module index is generated. 273 | # texinfo_domain_indices = True 274 | 275 | # How to display URL addresses: 'footnote', 'no', or 'inline'. 276 | # texinfo_show_urls = 'footnote' 277 | 278 | # If true, do not generate a @detailmenu in the "Top" node's menu. 279 | # texinfo_no_detailmenu = False 280 | -------------------------------------------------------------------------------- /tests/test_data_initialization.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | 3 | import pytest 4 | 5 | from jsonmodels import errors, fields, models 6 | 7 | 8 | def test_initialization(): 9 | class Person(models.Base): 10 | name = fields.StringField() 11 | surname = fields.StringField() 12 | age = fields.IntField() 13 | cash = fields.FloatField() 14 | extra_data = fields.DictField() 15 | 16 | data = dict( 17 | name="Alan", 18 | surname="Wake", 19 | age=24, 20 | cash=2445.45, 21 | extra_data={"location": "Oviedo, Spain", "gender": "Unknown"}, 22 | trash="123qwe", 23 | ) 24 | 25 | alan1 = Person(**data) 26 | alan2 = Person() 27 | alan2.populate(**data) 28 | for alan in [alan1, alan2]: 29 | assert alan.name == "Alan" 30 | assert alan.surname == "Wake" 31 | assert alan.age == 24 32 | assert alan.cash == 2445.45 33 | assert alan.extra_data == {"location": "Oviedo, Spain", "gender": "Unknown"} 34 | 35 | assert not hasattr(alan, "trash") 36 | 37 | 38 | def test_deep_initialization(): 39 | class Car(models.Base): 40 | brand = fields.StringField() 41 | extra = fields.DictField() 42 | 43 | class ParkingPlace(models.Base): 44 | location = fields.StringField() 45 | car = fields.EmbeddedField(Car) 46 | 47 | data = { 48 | "location": "somewhere", 49 | "car": { 50 | "brand": "awesome brand", 51 | "extra": { 52 | "extra_int": 1, 53 | "extra_str": "a", 54 | "extra_bool": True, 55 | "extra_dict": {"I am extra": True}, 56 | }, 57 | }, 58 | } 59 | 60 | parking1 = ParkingPlace(**data) 61 | parking2 = ParkingPlace() 62 | parking2.populate(**data) 63 | for parking in [parking1, parking2]: 64 | assert parking.location == "somewhere" 65 | car = parking.car 66 | assert isinstance(car, Car) 67 | assert car.brand == "awesome brand" 68 | assert car.extra == { 69 | "extra_int": 1, 70 | "extra_str": "a", 71 | "extra_bool": True, 72 | "extra_dict": {"I am extra": True}, 73 | } 74 | 75 | assert parking.location == "somewhere" 76 | car = parking.car 77 | assert isinstance(car, Car) 78 | assert car.brand == "awesome brand" 79 | assert car.extra == { 80 | "extra_int": 1, 81 | "extra_str": "a", 82 | "extra_bool": True, 83 | "extra_dict": {"I am extra": True}, 84 | } 85 | 86 | 87 | def test_deep_initialization_error_with_multitypes(): 88 | class Viper(models.Base): 89 | brand = fields.StringField() 90 | 91 | class Lamborghini(models.Base): 92 | brand = fields.StringField() 93 | 94 | class ParkingPlace(models.Base): 95 | location = fields.StringField() 96 | car = fields.EmbeddedField([Viper, Lamborghini]) 97 | 98 | data = {"location": "somewhere", "car": {"brand": "awesome brand"}} 99 | 100 | with pytest.raises(errors.ValidationError): 101 | ParkingPlace(**data) 102 | 103 | place = ParkingPlace() 104 | with pytest.raises(errors.ValidationError): 105 | place.populate(**data) 106 | 107 | 108 | def test_deep_initialization_with_list(): 109 | class Car(models.Base): 110 | brand = fields.StringField() 111 | 112 | class Parking(models.Base): 113 | location = fields.StringField() 114 | cars = fields.ListField(items_types=Car) 115 | 116 | data = { 117 | "location": "somewhere", 118 | "cars": [ 119 | { 120 | "brand": "one", 121 | }, 122 | { 123 | "brand": "two", 124 | }, 125 | { 126 | "brand": "three", 127 | }, 128 | ], 129 | } 130 | 131 | parking1 = Parking(**data) 132 | parking2 = Parking() 133 | parking2.populate(**data) 134 | for parking in [parking1, parking2]: 135 | assert parking.location == "somewhere" 136 | cars = parking.cars 137 | assert isinstance(cars, list) 138 | assert len(cars) == 3 139 | 140 | values = [] 141 | for car in cars: 142 | assert isinstance(car, Car) 143 | values.append(car.brand) 144 | assert "one" in values 145 | assert "two" in values 146 | assert "three" in values 147 | 148 | 149 | def test_deep_initialization_error_with_list_and_multitypes(): 150 | class Viper(models.Base): 151 | brand = fields.StringField() 152 | 153 | class Lamborghini(models.Base): 154 | brand = fields.StringField() 155 | 156 | class Parking(models.Base): 157 | location = fields.StringField() 158 | cars = fields.ListField([Viper, Lamborghini]) 159 | 160 | data = { 161 | "location": "somewhere", 162 | "cars": [ 163 | { 164 | "brand": "one", 165 | }, 166 | { 167 | "brand": "two", 168 | }, 169 | { 170 | "brand": "three", 171 | }, 172 | ], 173 | } 174 | 175 | with pytest.raises(errors.ValidationError): 176 | Parking(**data) 177 | 178 | parking = Parking() 179 | with pytest.raises(errors.ValidationError): 180 | parking.populate(**data) 181 | 182 | 183 | def test_deep_initialization_error_when_result_non_iterable(): 184 | class Viper(models.Base): 185 | brand = fields.StringField() 186 | 187 | class Lamborghini(models.Base): 188 | brand = fields.StringField() 189 | 190 | class Parking(models.Base): 191 | location = fields.StringField() 192 | cars = fields.ListField([Viper, Lamborghini]) 193 | 194 | data = { 195 | "location": "somewhere", 196 | "cars": object(), 197 | } 198 | 199 | with pytest.raises(errors.ValidationError): 200 | Parking(**data) 201 | 202 | parking = Parking() 203 | with pytest.raises(errors.ValidationError): 204 | parking.populate(**data) 205 | 206 | 207 | def test_initialization_with_non_models_types(): 208 | class Person(models.Base): 209 | names = fields.ListField(str) 210 | surname = fields.StringField() 211 | 212 | data = {"names": ["Chuck", "Testa"], "surname": "Norris"} 213 | 214 | person1 = Person(**data) 215 | person2 = Person() 216 | person2.populate(**data) 217 | 218 | for person in [person1, person2]: 219 | assert person.surname == "Norris" 220 | assert len(person.names) == 2 221 | assert "Chuck" in person.names 222 | assert "Testa" in person.names 223 | 224 | 225 | def test_initialization_with_multi_non_models_types(): 226 | class Person(models.Base): 227 | name = fields.StringField() 228 | mix = fields.ListField((str, float)) 229 | 230 | data = {"name": "Chuck", "mix": ["something", 42.0, "weird"]} 231 | 232 | person1 = Person(**data) 233 | person2 = Person() 234 | person2.populate(**data) 235 | 236 | for person in [person1, person2]: 237 | assert person.name == "Chuck" 238 | assert len(person.mix) == 3 239 | assert "something" in person.mix 240 | assert 42.0 in person.mix 241 | assert "weird" in person.mix 242 | 243 | 244 | def test_initialization_with_wrong_types(): 245 | class Person(models.Base): 246 | name = fields.StringField() 247 | mix = fields.ListField((str, float)) 248 | 249 | data = {"name": "Chuck", "mix": ["something", 42.0, "weird"]} 250 | 251 | Person(**data) 252 | 253 | 254 | def test_deep_initialization_for_embed_field(): 255 | class Car(models.Base): 256 | brand = fields.StringField() 257 | 258 | class ParkingPlace(models.Base): 259 | location = fields.StringField() 260 | car = fields.EmbeddedField(Car) 261 | 262 | data = { 263 | "location": "somewhere", 264 | "car": Car(brand="awesome brand"), 265 | } 266 | 267 | parking1 = ParkingPlace(**data) 268 | parking2 = ParkingPlace() 269 | parking2.populate(**data) 270 | for parking in [parking1, parking2]: 271 | assert parking.location == "somewhere" 272 | car = parking.car 273 | assert isinstance(car, Car) 274 | assert car.brand == "awesome brand" 275 | 276 | assert parking.location == "somewhere" 277 | car = parking.car 278 | assert isinstance(car, Car) 279 | assert car.brand == "awesome brand" 280 | 281 | 282 | def test_int_field_parsing(): 283 | class Counter(models.Base): 284 | value = fields.IntField() 285 | 286 | counter0 = Counter(value=None) 287 | assert counter0.value is None 288 | counter1 = Counter(value=1) 289 | assert isinstance(counter1.value, int) 290 | assert counter1.value == 1 291 | counter2 = Counter(value="2") 292 | assert isinstance(counter2.value, int) 293 | assert counter2.value == 2 294 | 295 | 296 | def test_default_value(): 297 | class Job(models.Base): 298 | title = fields.StringField() 299 | company = fields.StringField() 300 | 301 | default_job = Job(tile="Unemployed", company="N/A") 302 | default_age = 18 303 | default_name = "John Doe" 304 | default_height = 1.70 305 | default_hobbies = ["eating", "reading"] 306 | default_last_ate = datetime.time() 307 | default_birthday = datetime.date.today() 308 | default_time_of_death = datetime.datetime.now() 309 | 310 | class Person(models.Base): 311 | name = fields.StringField(default=default_name) 312 | age = fields.IntField(default=default_age) 313 | height = fields.FloatField(default=default_height) 314 | job = fields.EmbeddedField(Job, default=default_job) 315 | hobbies = fields.ListField(items_types=str, default=default_hobbies) 316 | last_ate = fields.TimeField(default=default_last_ate) 317 | birthday = fields.DateField(default=default_birthday) 318 | time_of_death = fields.DateTimeField(default=default_time_of_death) 319 | 320 | p = Person() 321 | assert p.name == default_name 322 | assert p.age == default_age 323 | assert p.height == default_height 324 | assert p.hobbies == default_hobbies 325 | assert p.job == default_job 326 | assert p.last_ate == default_last_ate 327 | assert p.birthday == default_birthday 328 | assert p.time_of_death == default_time_of_death 329 | -------------------------------------------------------------------------------- /tests/test_validation.py: -------------------------------------------------------------------------------- 1 | """Test for validators.""" 2 | 3 | import pytest 4 | 5 | from jsonmodels import errors, fields, models, validators 6 | 7 | 8 | class FakeValidator: 9 | def __init__(self): 10 | self.called_with = None 11 | self.called_amount = 0 12 | 13 | def validate(self, value): 14 | self.called_amount = self.called_amount + 1 15 | self.called_with = value 16 | 17 | def assert_called_once_with(self, value): 18 | if value != self.called_with or self.called_amount != 1: 19 | raise AssertionError('Assert called once with "{}" failed!') 20 | 21 | 22 | def test_validation(): 23 | validator1 = FakeValidator() 24 | validator2 = FakeValidator() 25 | 26 | called = [] 27 | arg = [] 28 | 29 | def validator3(value): 30 | called.append(1) 31 | arg.append(value) 32 | 33 | class Person(models.Base): 34 | name = fields.StringField(required=True, validators=[validator1, validator2]) 35 | surname = fields.StringField(required=True) 36 | age = fields.IntField(validators=validator3) 37 | cash = fields.FloatField() 38 | 39 | person = Person() 40 | person.name = "John" 41 | person.surname = "Smith" 42 | person.age = 33 43 | person.cash = 123567.89 44 | 45 | validator1.assert_called_once_with("John") 46 | validator2.assert_called_once_with("John") 47 | 48 | assert 1 == sum(called) 49 | assert 33 == arg.pop() 50 | 51 | 52 | def test_validators_are_always_iterable(): 53 | class Person(models.Base): 54 | children = fields.ListField() 55 | 56 | alan = Person() 57 | 58 | assert isinstance(alan.get_field("children").validators, list) 59 | 60 | 61 | def test_get_field_not_found(): 62 | class Person(models.Base): 63 | children = fields.ListField() 64 | 65 | alan = Person() 66 | 67 | with pytest.raises(errors.FieldNotFound): 68 | alan.get_field("bazinga") 69 | 70 | 71 | def test_min_validation(): 72 | validator = validators.Min(3) 73 | assert 3 == validator.minimum_value 74 | 75 | validator.validate(4) 76 | validator.validate(3) 77 | 78 | with pytest.raises(errors.ValidationError): 79 | validator.validate(2) 80 | with pytest.raises(errors.ValidationError): 81 | validator.validate(-2) 82 | 83 | 84 | def test_exclusive_validation(): 85 | validator = validators.Min(3, True) 86 | assert 3 == validator.minimum_value 87 | 88 | validator.validate(4) 89 | with pytest.raises(errors.ValidationError): 90 | validator.validate(3) 91 | with pytest.raises(errors.ValidationError): 92 | validator.validate(2) 93 | with pytest.raises(errors.ValidationError): 94 | validator.validate(-2) 95 | 96 | 97 | def test_max_validation(): 98 | validator = validators.Max(42) 99 | assert 42 == validator.maximum_value 100 | 101 | validator.validate(4) 102 | validator.validate(42) 103 | with pytest.raises(errors.ValidationError): 104 | validator.validate(42.01) 105 | with pytest.raises(errors.ValidationError): 106 | validator.validate(43) 107 | 108 | 109 | def test_max_exclusive_validation(): 110 | validator = validators.Max(42, True) 111 | assert 42 == validator.maximum_value 112 | 113 | validator.validate(4) 114 | with pytest.raises(errors.ValidationError): 115 | validator.validate(42) 116 | with pytest.raises(errors.ValidationError): 117 | validator.validate(42.01) 118 | with pytest.raises(errors.ValidationError): 119 | validator.validate(43) 120 | 121 | 122 | def test_regex_validation(): 123 | validator = validators.Regex("some") 124 | assert "some" == validator.pattern 125 | 126 | validator.validate("some string") 127 | validator.validate("get some chips") 128 | with pytest.raises(errors.ValidationError): 129 | validator.validate("asdf") 130 | with pytest.raises(errors.ValidationError): 131 | validator.validate("trololo") 132 | 133 | 134 | def test_regex_validation_flags(): 135 | # Invalid flags ignored 136 | validator = validators.Regex("foo", bla=True, ble=False, ignorecase=True) 137 | assert validator.flags == [validators.Regex.FLAGS["ignorecase"]] 138 | 139 | # Flag kwargs must be True-y 140 | validator = validators.Regex("foo", ignorecase=False, multiline=True) 141 | assert validator.flags == [validators.Regex.FLAGS["multiline"]] 142 | 143 | # ECMA pattern flags recognized 144 | validator = validators.Regex("/foo/im") 145 | assert sorted(validator.flags) == sorted( 146 | [ 147 | validators.Regex.FLAGS["multiline"], 148 | validators.Regex.FLAGS["ignorecase"], 149 | ] 150 | ) 151 | 152 | # ECMA pattern overrides flags kwargs 153 | validator = validators.Regex("/foo/", ignorecase=True, multiline=True) 154 | assert validator.flags == [] 155 | 156 | 157 | def test_regex_validation_for_wrong_type(): 158 | validator = validators.Regex("some") 159 | assert "some" == validator.pattern 160 | 161 | with pytest.raises(errors.ValidationError): 162 | validator.validate(1) 163 | 164 | 165 | def test_validation_2(): 166 | validator = validators.Regex("^some[0-9]$") 167 | assert "^some[0-9]$" == validator.pattern 168 | 169 | validator.validate("some0") 170 | with pytest.raises(errors.ValidationError): 171 | validator.validate("some") 172 | with pytest.raises(errors.ValidationError): 173 | validator.validate(" some") 174 | with pytest.raises(errors.ValidationError): 175 | validator.validate("asdf") 176 | with pytest.raises(errors.ValidationError): 177 | validator.validate("trololo") 178 | 179 | 180 | def test_validation_ignorecase(): 181 | validator = validators.Regex("^some$") 182 | validator.validate("some") 183 | with pytest.raises(errors.ValidationError): 184 | validator.validate("sOmE") 185 | 186 | validator = validators.Regex("^some$", ignorecase=True) 187 | validator.validate("some") 188 | validator.validate("SoMe") 189 | 190 | 191 | def test_validation_multiline(): 192 | validator = validators.Regex("^s.*e$") 193 | validator.validate("some") 194 | with pytest.raises(errors.ValidationError): 195 | validator.validate("some\nso more") 196 | 197 | validator = validators.Regex("^s.*e$", multiline=True) 198 | validator.validate("some") 199 | validator.validate("some\nso more") 200 | 201 | 202 | def test_regex_validator(): 203 | class Person(models.Base): 204 | name = fields.StringField( 205 | validators=validators.Regex("^[a-z]+$", ignorecase=True) 206 | ) 207 | 208 | person = Person() 209 | 210 | with pytest.raises(errors.ValidationError): 211 | person.name = "123" 212 | 213 | person.name = "Jimmy" 214 | 215 | 216 | def test_regex_validator_when_ecma_regex_given(): 217 | class Person(models.Base): 218 | name = fields.StringField( 219 | validators=validators.Regex("/^[a-z]+$/i", ignorecase=False) 220 | ) 221 | 222 | person = Person() 223 | 224 | with pytest.raises(errors.ValidationError): 225 | person.name = "123" 226 | 227 | person.name = "Jimmy" 228 | 229 | 230 | def test_init(): 231 | validator = validators.Length(0, 10) 232 | assert 0 == validator.minimum_value 233 | assert 10 == validator.maximum_value 234 | 235 | validator = validators.Length(0) 236 | assert 0 == validator.minimum_value 237 | assert validator.maximum_value is None 238 | 239 | validator = validators.Length(maximum_value=10) 240 | assert 10 == validator.maximum_value 241 | assert validator.minimum_value is None 242 | 243 | with pytest.raises(ValueError): 244 | validators.Length() 245 | 246 | 247 | def test_length_validation_string_min_max(): 248 | validator = validators.Length(1, 10) 249 | validator.validate("word") 250 | validator.validate("w" * 10) 251 | validator.validate("w") 252 | 253 | with pytest.raises(errors.ValidationError): 254 | validator.validate("") 255 | with pytest.raises(errors.ValidationError): 256 | validator.validate("na" * 10) 257 | 258 | 259 | def test_length_validation_string_min(): 260 | validator = validators.Length(minimum_value=1) 261 | validator.validate("a") 262 | validator.validate("aasdasd" * 1000) 263 | with pytest.raises(errors.ValidationError): 264 | validator.validate("") 265 | 266 | 267 | def test_length_validation_string_max(): 268 | validator = validators.Length(maximum_value=10) 269 | validator.validate("") 270 | validator.validate("a") 271 | validator.validate("a" * 10) 272 | with pytest.raises(errors.ValidationError): 273 | validator.validate("a" * 11) 274 | 275 | 276 | def test_length_validation_list_min_max(): 277 | validator = validators.Length(1, 10) 278 | validator.validate([1, 2, 3, 4]) 279 | validator.validate([1] * 10) 280 | validator.validate([1]) 281 | 282 | with pytest.raises(errors.ValidationError): 283 | validator.validate([]) 284 | with pytest.raises(errors.ValidationError): 285 | validator.validate([1, 2] * 10) 286 | 287 | 288 | def test_length_validation_list_min(): 289 | validator = validators.Length(minimum_value=1) 290 | validator.validate([1]) 291 | validator.validate(range(1000)) 292 | with pytest.raises(errors.ValidationError): 293 | validator.validate([]) 294 | 295 | 296 | def test_length_validation_list_max(): 297 | validator = validators.Length(maximum_value=10) 298 | validator.validate([]) 299 | validator.validate([1]) 300 | validator.validate([1] * 10) 301 | with pytest.raises(errors.ValidationError): 302 | validator.validate([1] * 11) 303 | 304 | 305 | def test_validation_nullable(): 306 | class Emb(models.Base): 307 | name = fields.StringField(nullable=True) 308 | 309 | class User(models.Base): 310 | name = fields.StringField(nullable=True) 311 | props = fields.ListField([str, int, float], nullable=True) 312 | embedded = fields.EmbeddedField(Emb, nullable=True) 313 | 314 | user = User(name=None, props=None) 315 | user.validate() 316 | 317 | 318 | def test_enum_validation(): 319 | validator = validators.Enum("cat", "dog", "fish") 320 | 321 | validator.validate("cat") 322 | validator.validate("dog") 323 | validator.validate("fish") 324 | 325 | with pytest.raises(errors.ValidationError): 326 | validator.validate("horse") 327 | -------------------------------------------------------------------------------- /tests/test_schema.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from jsonmodels import builders, errors, fields, models, validators 4 | from jsonmodels.utilities import compare_schemas 5 | 6 | from .utilities import get_fixture 7 | 8 | 9 | def test_model1(): 10 | class Person(models.Base): 11 | name = fields.StringField(required=True) 12 | surname = fields.StringField(required=True) 13 | age = fields.IntField() 14 | extra = fields.DictField() 15 | 16 | alan = Person() 17 | schema = alan.to_json_schema() 18 | 19 | pattern = get_fixture("schema1.json") 20 | assert compare_schemas(pattern, schema) is True 21 | 22 | 23 | def test_model2(): 24 | class Car(models.Base): 25 | brand = fields.StringField(required=True) 26 | registration = fields.StringField(required=True) 27 | extra = fields.DictField(required=True) 28 | 29 | class Toy(models.Base): 30 | name = fields.StringField(required=True) 31 | 32 | class Kid(models.Base): 33 | name = fields.StringField(required=True) 34 | surname = fields.StringField(required=True) 35 | age = fields.IntField() 36 | toys = fields.ListField(Toy) 37 | 38 | class Person(models.Base): 39 | name = fields.StringField(required=True) 40 | surname = fields.StringField(required=True) 41 | age = fields.IntField() 42 | kids = fields.ListField(Kid) 43 | car = fields.EmbeddedField(Car) 44 | 45 | chuck = Person() 46 | schema = chuck.to_json_schema() 47 | 48 | pattern = get_fixture("schema2.json") 49 | assert compare_schemas(pattern, schema) is True 50 | 51 | 52 | def test_model3(): 53 | class Viper(models.Base): 54 | brand = fields.StringField() 55 | capacity = fields.FloatField() 56 | 57 | class Lamborghini(models.Base): 58 | brand = fields.StringField() 59 | velocity = fields.FloatField() 60 | 61 | class PC(models.Base): 62 | name = fields.StringField() 63 | ports = fields.StringField() 64 | 65 | class Laptop(models.Base): 66 | name = fields.StringField() 67 | battery_voltage = fields.FloatField() 68 | 69 | class Tablet(models.Base): 70 | name = fields.StringField() 71 | os = fields.StringField() 72 | 73 | class Person(models.Base): 74 | name = fields.StringField(required=True) 75 | surname = fields.StringField(required=True) 76 | age = fields.IntField() 77 | car = fields.EmbeddedField([Viper, Lamborghini]) 78 | computer = fields.ListField([PC, Laptop, Tablet]) 79 | 80 | chuck = Person() 81 | schema = chuck.to_json_schema() 82 | 83 | pattern = get_fixture("schema3.json") 84 | assert compare_schemas(pattern, schema) is True 85 | 86 | 87 | def test_model_with_constructors(): 88 | class Car(models.Base): 89 | brand = fields.StringField(required=True) 90 | registration = fields.StringField(required=True) 91 | extra = fields.DictField(required=True) 92 | 93 | def __init__(self, some_value): 94 | pass 95 | 96 | class Toy(models.Base): 97 | name = fields.StringField(required=True) 98 | 99 | def __init__(self, some_value): 100 | pass 101 | 102 | class Kid(models.Base): 103 | name = fields.StringField(required=True) 104 | surname = fields.StringField(required=True) 105 | age = fields.IntField() 106 | toys = fields.ListField(Toy) 107 | 108 | def __init__(self, some_value): 109 | pass 110 | 111 | class Person(models.Base): 112 | name = fields.StringField(required=True) 113 | surname = fields.StringField(required=True) 114 | age = fields.IntField() 115 | kids = fields.ListField(Kid) 116 | car = fields.EmbeddedField(Car) 117 | 118 | def __init__(self, some_value): 119 | pass 120 | 121 | schema = Person.to_json_schema() 122 | 123 | pattern = get_fixture("schema2.json") 124 | assert compare_schemas(pattern, schema) is True 125 | 126 | 127 | def test_datetime_fields(): 128 | class Event(models.Base): 129 | time = fields.TimeField() 130 | date = fields.DateField() 131 | end = fields.DateTimeField() 132 | 133 | schema = Event.to_json_schema() 134 | 135 | pattern = get_fixture("schema4.json") 136 | assert compare_schemas(pattern, schema) is True 137 | 138 | 139 | def test_bool_field(): 140 | class Person(models.Base): 141 | has_childen = fields.BoolField() 142 | 143 | schema = Person.to_json_schema() 144 | 145 | pattern = get_fixture("schema5.json") 146 | assert compare_schemas(pattern, schema) is True 147 | 148 | 149 | def test_unsupported_field(): 150 | class NewField(fields.BaseField): 151 | pass 152 | 153 | class Person(models.Base): 154 | some_property = NewField() 155 | 156 | with pytest.raises(errors.FieldNotSupported): 157 | Person.to_json_schema() 158 | 159 | 160 | def test_validators_can_modify_schema(): 161 | class ClassBasedValidator: 162 | def validate(self, value): 163 | raise RuntimeError() 164 | 165 | def modify_schema(self, field_schema): 166 | field_schema["some"] = "unproper value" 167 | 168 | def function_validator(value): 169 | raise RuntimeError() 170 | 171 | class Person(models.Base): 172 | name = fields.StringField(validators=ClassBasedValidator()) 173 | surname = fields.StringField(validators=function_validator) 174 | 175 | for person in [Person, Person()]: 176 | schema = person.to_json_schema() 177 | 178 | pattern = get_fixture("schema6.json") 179 | assert compare_schemas(pattern, schema) is True 180 | 181 | 182 | def test_min_validator(): 183 | class Person(models.Base): 184 | name = fields.StringField() 185 | surname = fields.StringField() 186 | age = fields.IntField(validators=validators.Min(18)) 187 | 188 | schema = Person.to_json_schema() 189 | 190 | pattern = get_fixture("schema_min.json") 191 | assert compare_schemas(pattern, schema) 192 | 193 | 194 | def test_min_validator_with_exclusive(): 195 | class Person(models.Base): 196 | name = fields.StringField() 197 | surname = fields.StringField() 198 | age = fields.IntField(validators=validators.Min(18, True)) 199 | 200 | schema = Person.to_json_schema() 201 | 202 | pattern = get_fixture("schema_min_exclusive.json") 203 | assert compare_schemas(pattern, schema) 204 | 205 | 206 | def test_max_validator(): 207 | class Person(models.Base): 208 | name = fields.StringField() 209 | surname = fields.StringField() 210 | age = fields.IntField(validators=validators.Max(18)) 211 | 212 | schema = Person.to_json_schema() 213 | 214 | pattern = get_fixture("schema_max.json") 215 | assert compare_schemas(pattern, schema) 216 | 217 | 218 | def test_max_validator_with_exclusive(): 219 | class Person(models.Base): 220 | name = fields.StringField() 221 | surname = fields.StringField() 222 | age = fields.IntField(validators=validators.Max(18, True)) 223 | 224 | schema = Person.to_json_schema() 225 | 226 | pattern = get_fixture("schema_max_exclusive.json") 227 | assert compare_schemas(pattern, schema) 228 | 229 | 230 | def test_regex_validator(): 231 | class Person(models.Base): 232 | name = fields.StringField(validators=validators.Regex("^some pattern$")) 233 | 234 | schema = Person.to_json_schema() 235 | 236 | pattern = get_fixture("schema_pattern.json") 237 | assert compare_schemas(pattern, schema) 238 | 239 | 240 | def test_regex_validator_when_ecma_regex_given(): 241 | class Person(models.Base): 242 | name = fields.StringField(validators=validators.Regex("/^some pattern$/")) 243 | 244 | schema = Person.to_json_schema() 245 | 246 | pattern = get_fixture("schema_pattern.json") 247 | assert compare_schemas(pattern, schema) 248 | 249 | 250 | def test_regex_validator_with_flag(): 251 | class Person(models.Base): 252 | name = fields.StringField( 253 | validators=validators.Regex("^some pattern$", ignorecase=True) 254 | ) 255 | 256 | schema = Person.to_json_schema() 257 | 258 | pattern = get_fixture("schema_pattern_flag.json") 259 | assert compare_schemas(pattern, schema) 260 | 261 | 262 | def test_length_validator_min(): 263 | class Person(models.Base): 264 | name = fields.StringField(validators=validators.Length(5)) 265 | surname = fields.StringField() 266 | age = fields.IntField() 267 | 268 | schema = Person.to_json_schema() 269 | 270 | pattern = get_fixture("schema_length_min.json") 271 | assert compare_schemas(pattern, schema) 272 | 273 | 274 | def test_length_validator(): 275 | class Person(models.Base): 276 | name = fields.StringField(validators=validators.Length(5, 20)) 277 | surname = fields.StringField() 278 | age = fields.IntField() 279 | 280 | schema = Person.to_json_schema() 281 | 282 | pattern = get_fixture("schema_length.json") 283 | assert compare_schemas(pattern, schema) 284 | 285 | 286 | def test_length_validator_list(): 287 | class People(models.Base): 288 | min_max_len = fields.ListField(str, validators=validators.Length(2, 4)) 289 | min_len = fields.ListField(str, validators=validators.Length(2)) 290 | max_len = fields.ListField(str, validators=validators.Length(4)) 291 | item_validator_int = fields.ListField( 292 | int, item_validators=[validators.Min(10), validators.Max(20)] 293 | ) 294 | item_validator_str = fields.ListField( 295 | str, 296 | item_validators=[validators.Length(10, 20), validators.Regex(r"\w+")], 297 | validators=[validators.Length(1, 2)], 298 | ) 299 | surname = fields.StringField() 300 | 301 | schema = People.to_json_schema() 302 | 303 | pattern = get_fixture("schema_length_list.json") 304 | assert compare_schemas(pattern, schema) 305 | 306 | 307 | def test_item_validator_for_simple_functions(): 308 | def only_odd_numbers(item): 309 | if item % 2 != 1: 310 | raise validators.ValidationError("Only odd numbers are accepted") 311 | 312 | class Person(models.Base): 313 | lucky_numbers = fields.ListField(int, item_validators=[only_odd_numbers]) 314 | 315 | Person(lucky_numbers=[1, 3]) 316 | with pytest.raises(validators.ValidationError): 317 | Person(lucky_numbers=[1, 2, 3]) 318 | 319 | schema = Person.to_json_schema() 320 | pattern = get_fixture("schema_list_item_simple.json") 321 | assert compare_schemas(pattern, schema) 322 | 323 | 324 | def test_max_only_validator(): 325 | class Person(models.Base): 326 | name = fields.StringField(validators=validators.Length(maximum_value=20)) 327 | surname = fields.StringField() 328 | age = fields.IntField() 329 | 330 | schema = Person.to_json_schema() 331 | 332 | pattern = get_fixture("schema_length_max.json") 333 | assert compare_schemas(pattern, schema) 334 | 335 | 336 | def test_schema_for_list_and_primitives(): 337 | class Event(models.Base): 338 | time = fields.TimeField() 339 | date = fields.DateField() 340 | end = fields.DateTimeField() 341 | 342 | class Person(models.Base): 343 | names = fields.ListField([str, int, float, bool, Event]) 344 | 345 | schema = Person.to_json_schema() 346 | 347 | pattern = get_fixture("schema_with_list.json") 348 | assert compare_schemas(pattern, schema) 349 | 350 | 351 | def test_schema_for_unsupported_primitive(): 352 | class Person(models.Base): 353 | names = fields.ListField([str, object]) 354 | 355 | with pytest.raises(errors.FieldNotSupported): 356 | Person.to_json_schema() 357 | 358 | 359 | def test_enum_validator(): 360 | class Person(models.Base): 361 | handness = fields.StringField(validators=validators.Enum("left", "right")) 362 | 363 | schema = Person.to_json_schema() 364 | pattern = get_fixture("schema_enum.json") 365 | 366 | assert compare_schemas(pattern, schema) 367 | 368 | 369 | def test_default_value(): 370 | class Pet(models.Base): 371 | kind = fields.StringField(default="Dog") 372 | 373 | class Person(models.Base): 374 | name = fields.StringField(default="John Doe") 375 | age = fields.IntField(default=18) 376 | pet = fields.EmbeddedField(Pet, default=Pet(kind="Cat")) 377 | nicknames = fields.ListField(items_types=(str,), default=["yo", "dawg"]) 378 | profession = fields.StringField(default=None) 379 | 380 | schema = Person.to_json_schema() 381 | pattern = get_fixture("schema_with_defaults.json") 382 | 383 | assert compare_schemas(pattern, schema) 384 | 385 | 386 | def test_primitives(): 387 | cases = ( 388 | (str, "string"), 389 | (bool, "boolean"), 390 | (int, "number"), 391 | (float, "number"), 392 | (dict, "object"), 393 | ) 394 | for pytpe, jstype in cases: 395 | b = builders.PrimitiveBuilder(pytpe) 396 | assert b.build() == {"type": jstype} 397 | b = builders.PrimitiveBuilder(pytpe, nullable=True) 398 | assert b.build() == {"type": [jstype, "null"]} 399 | b = builders.PrimitiveBuilder(pytpe, nullable=True, default=0) 400 | assert b.build() == {"type": [jstype, "null"], "default": 0} 401 | b = builders.PrimitiveBuilder(pytpe, nullable=True, default=0) 402 | assert b.build() == {"type": [jstype, "null"], "default": 0} 403 | -------------------------------------------------------------------------------- /tests/test_jsonmodels.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from jsonmodels import errors, fields, models 4 | 5 | 6 | def test_model1(): 7 | class Person(models.Base): 8 | name = fields.StringField() 9 | surname = fields.StringField() 10 | age = fields.IntField() 11 | extra = fields.DictField() 12 | 13 | alan = Person() 14 | 15 | alan.name = "Alan" 16 | alan.surname = "Wake" 17 | alan.age = 34 18 | alan.extra = {"extra_value": 1} 19 | 20 | 21 | def test_required(): 22 | class Person(models.Base): 23 | name = fields.StringField(required=True) 24 | surname = fields.StringField() 25 | age = fields.IntField() 26 | 27 | alan = Person() 28 | with pytest.raises(errors.ValidationError): 29 | alan.validate() 30 | 31 | alan.name = "Chuck" 32 | alan.validate() 33 | 34 | 35 | def test_type_validation(): 36 | class Person(models.Base): 37 | name = fields.StringField() 38 | age = fields.IntField() 39 | 40 | alan = Person() 41 | 42 | alan.age = 42 43 | 44 | 45 | def test_base_field_should_not_be_usable(): 46 | class Person(models.Base): 47 | name = fields.BaseField() 48 | 49 | alan = Person() 50 | 51 | with pytest.raises(errors.ValidationError): 52 | alan.name = "some name" 53 | 54 | with pytest.raises(errors.ValidationError): 55 | alan.name = 2345 56 | 57 | 58 | def test_value_replacements(): 59 | class Person(models.Base): 60 | name = fields.StringField() 61 | age = fields.IntField() 62 | cash = fields.FloatField() 63 | children = fields.ListField() 64 | 65 | alan = Person() 66 | assert alan.name is None 67 | assert alan.age is None 68 | assert alan.cash is None 69 | assert isinstance(alan.children, list) 70 | 71 | 72 | def test_list_field(): 73 | class Car(models.Base): 74 | wheels = fields.ListField() 75 | 76 | viper = Car() 77 | 78 | viper.wheels.append("some") 79 | viper.wheels.append("not necessarily") 80 | viper.wheels.append("proper") 81 | viper.wheels.append("wheels") 82 | 83 | 84 | def test_list_field_types(): 85 | class Wheel(models.Base): 86 | pass 87 | 88 | class Wheel2(models.Base): 89 | pass 90 | 91 | class Car(models.Base): 92 | wheels = fields.ListField(items_types=[Wheel]) 93 | 94 | viper = Car() 95 | 96 | viper.wheels.append(Wheel()) 97 | viper.wheels.append(Wheel()) 98 | 99 | with pytest.raises(errors.ValidationError): 100 | viper.wheels.append(Wheel2) 101 | 102 | 103 | def test_list_field_types_when_assigning(): 104 | class Wheel(models.Base): 105 | pass 106 | 107 | class Wheel2(models.Base): 108 | pass 109 | 110 | class Car(models.Base): 111 | wheels = fields.ListField(items_types=[Wheel]) 112 | 113 | viper = Car() 114 | 115 | viper.wheels.append(Wheel()) 116 | viper.wheels[0] = Wheel() 117 | 118 | with pytest.raises(errors.ValidationError): 119 | viper.wheels[1] = Wheel2 120 | 121 | 122 | def test_list_field_for_subtypes(): 123 | class Car(models.Base): 124 | pass 125 | 126 | class Viper(Car): 127 | pass 128 | 129 | class Lamborghini(Car): 130 | pass 131 | 132 | class Garage1(models.Base): 133 | cars = fields.ListField(items_types=[Car]) 134 | 135 | garage = Garage1() 136 | garage.cars.append(Car()) 137 | garage.cars.append(Viper()) 138 | garage.cars.append(Lamborghini()) 139 | 140 | class Garage2(models.Base): 141 | cars = fields.ListField(items_types=[Viper, Lamborghini]) 142 | 143 | garage = Garage2() 144 | garage.cars.append(Viper()) 145 | garage.cars.append(Lamborghini()) 146 | 147 | with pytest.raises(errors.ValidationError): 148 | garage.cars.append(Car()) 149 | 150 | 151 | def test_list_validation(): 152 | class Garage(models.Base): 153 | cars = fields.ListField() 154 | 155 | garage = Garage() 156 | 157 | with pytest.raises(errors.ValidationError): 158 | garage.cars = "some string" 159 | 160 | 161 | def test_embedded_model(): 162 | class Secondary(models.Base): 163 | data = fields.IntField() 164 | 165 | class Primary(models.Base): 166 | name = fields.StringField() 167 | secondary = fields.EmbeddedField(Secondary) 168 | 169 | entity = Primary() 170 | assert entity.secondary is None 171 | entity.name = "chuck" 172 | entity.secondary = Secondary() 173 | entity.secondary.data = 42 174 | 175 | with pytest.raises(errors.ValidationError): 176 | entity.secondary = "something different" 177 | 178 | entity.secondary = None 179 | 180 | 181 | def test_embedded_required_validation(): 182 | class Secondary(models.Base): 183 | data = fields.IntField(required=True) 184 | 185 | class Primary(models.Base): 186 | name = fields.StringField() 187 | secondary = fields.EmbeddedField(Secondary) 188 | 189 | entity = Primary() 190 | sec = Secondary() 191 | sec.data = 33 192 | entity.secondary = sec 193 | 194 | with pytest.raises(errors.ValidationError): 195 | entity.secondary.data = None 196 | 197 | entity.secondary = None 198 | 199 | class Primary(models.Base): 200 | name = fields.StringField() 201 | secondary = fields.EmbeddedField(Secondary, required=True) 202 | 203 | entity = Primary() 204 | sec = Secondary() 205 | sec.data = 33 206 | entity.secondary = sec 207 | 208 | with pytest.raises(errors.ValidationError): 209 | entity.secondary.data = None 210 | 211 | 212 | def test_embedded_inheritance(): 213 | class Car(models.Base): 214 | pass 215 | 216 | class Viper(Car): 217 | pass 218 | 219 | class Lamborghini(Car): 220 | pass 221 | 222 | class ParkingPlace(models.Base): 223 | location = fields.StringField() 224 | car = fields.EmbeddedField([Viper, Lamborghini]) 225 | 226 | place = ParkingPlace() 227 | 228 | place.car = Viper() 229 | place.car = Lamborghini() 230 | 231 | with pytest.raises(errors.ValidationError): 232 | place.car = Car() 233 | 234 | class ParkingPlace(models.Base): 235 | location = fields.StringField() 236 | car = fields.EmbeddedField(Car) 237 | 238 | place = ParkingPlace() 239 | 240 | place.car = Viper() 241 | place.car = Lamborghini() 242 | place.car = Car() 243 | 244 | 245 | def test_iterable(): 246 | class Person(models.Base): 247 | name = fields.StringField() 248 | surname = fields.StringField() 249 | age = fields.IntField() 250 | cash = fields.FloatField() 251 | 252 | alan = Person() 253 | 254 | alan.name = "Alan" 255 | alan.surname = "Wake" 256 | alan.age = 24 257 | alan.cash = 2445.45 258 | 259 | pattern = { 260 | "name": "Alan", 261 | "surname": "Wake", 262 | "age": 24, 263 | "cash": 2445.45, 264 | } 265 | 266 | result = {} 267 | for name, field in alan: 268 | result[name] = field.__get__(alan) 269 | 270 | assert pattern == result 271 | 272 | 273 | def test_get_field(): 274 | name_field = fields.StringField() 275 | surname_field = fields.StringField() 276 | age_field = fields.IntField() 277 | 278 | class Person(models.Base): 279 | name = name_field 280 | surname = surname_field 281 | age = age_field 282 | 283 | alan = Person() 284 | 285 | assert alan.get_field("name") is name_field 286 | assert alan.get_field("surname") is surname_field 287 | assert alan.get_field("age") is age_field 288 | 289 | 290 | def test_repr(): 291 | class Person(models.Base): 292 | name = fields.StringField() 293 | surname = fields.StringField() 294 | age = fields.IntField() 295 | 296 | chuck = Person() 297 | 298 | assert chuck.__repr__() == "Person()" 299 | assert chuck.__str__() == "Person object" 300 | 301 | class Person2(models.Base): 302 | name = fields.StringField() 303 | surname = fields.StringField() 304 | age = fields.IntField() 305 | 306 | def __str__(self): 307 | return self.name 308 | 309 | chuck = Person2() 310 | 311 | assert chuck.__repr__() == "Person2()" 312 | 313 | chuck.name = "Chuck" 314 | assert chuck.__repr__() == "Person2(name='Chuck')" 315 | assert chuck.__str__() == "Chuck" 316 | 317 | chuck.name = "Testa" 318 | chuck.age = 42 319 | assert chuck.__repr__() == "Person2(age=42, name='Testa')" 320 | assert chuck.__str__() == "Testa" 321 | 322 | 323 | def test_list_field_with_non_model_types(): 324 | class Person(models.Base): 325 | names = fields.ListField(str) 326 | surname = fields.StringField() 327 | 328 | person = Person(surname="Norris") 329 | person.names.append("Chuck") 330 | person.names.append("Testa") 331 | 332 | 333 | def test_help_text(): 334 | class Person(models.Base): 335 | name = fields.StringField(help_text="Name of person.") 336 | age = fields.IntField(help_text="Age of person.") 337 | 338 | person = Person() 339 | assert person.get_field("name").help_text == "Name of person." 340 | assert person.get_field("age").help_text == "Age of person." 341 | 342 | 343 | def test_types(): 344 | class Person: 345 | pass 346 | 347 | class Person2: 348 | pass 349 | 350 | allowed_types = (Person,) 351 | 352 | field = fields.EmbeddedField(allowed_types) 353 | assert allowed_types == field.types 354 | 355 | allowed_types = (Person, Person2) 356 | 357 | field = fields.EmbeddedField(allowed_types) 358 | assert allowed_types == field.types 359 | 360 | 361 | def test_items_types(): 362 | class Person: 363 | pass 364 | 365 | class Person2: 366 | pass 367 | 368 | allowed_types = (Person,) 369 | 370 | field = fields.ListField(allowed_types) 371 | assert allowed_types == field.items_types 372 | 373 | allowed_types = (Person, Person2) 374 | 375 | field = fields.ListField(allowed_types) 376 | assert allowed_types == field.items_types 377 | 378 | field = fields.ListField() 379 | assert tuple() == field.items_types 380 | 381 | 382 | def test_required_embedded_field(): 383 | class Secondary(models.Base): 384 | data = fields.IntField() 385 | 386 | class Primary(models.Base): 387 | name = fields.StringField() 388 | secondary = fields.EmbeddedField(Secondary, required=True) 389 | 390 | entity = Primary() 391 | with pytest.raises(errors.ValidationError): 392 | entity.validate() 393 | entity.secondary = Secondary() 394 | entity.validate() 395 | 396 | class Primary(models.Base): 397 | name = fields.StringField() 398 | secondary = fields.EmbeddedField(Secondary, required=False) 399 | 400 | entity = Primary() 401 | entity.validate() 402 | 403 | entity.secondary = None 404 | entity.validate() 405 | 406 | 407 | def test_assignation_of_list_of_models(): 408 | class Wheel(models.Base): 409 | pass 410 | 411 | class Car(models.Base): 412 | wheels = fields.ListField(items_types=[Wheel]) 413 | 414 | viper = Car() 415 | viper.wheels = None 416 | viper.wheels = [Wheel()] 417 | 418 | 419 | def test_equality_of_different_types(): 420 | class A(models.Base): 421 | pass 422 | 423 | class B(A): 424 | pass 425 | 426 | class C(models.Base): 427 | pass 428 | 429 | assert A() == A() 430 | assert A() != B() 431 | assert B() != A() 432 | assert A() != C() 433 | 434 | 435 | def test_equality_of_simple_models(): 436 | class Person(models.Base): 437 | name = fields.StringField() 438 | age = fields.IntField() 439 | 440 | p1 = Person(name="Jack") 441 | p2 = Person(name="Jack") 442 | 443 | assert p1 == p2 444 | assert p2 == p1 445 | 446 | p3 = Person(name="Jack", age=100) 447 | assert p1 != p3 448 | assert p3 != p1 449 | 450 | p4 = Person(name="Jill") 451 | assert p1 != p4 452 | assert p4 != p1 453 | 454 | 455 | def test_equality_embedded_objects(): 456 | class Person(models.Base): 457 | name = fields.StringField() 458 | 459 | class Company(models.Base): 460 | chairman = fields.EmbeddedField(Person) 461 | 462 | c1 = Company(chairman=Person(name="Pete")) 463 | c2 = Company(chairman=Person(name="Pete")) 464 | 465 | assert c1 == c2 466 | assert c2 == c1 467 | 468 | c3 = Company(chairman=Person(name="Joshua")) 469 | 470 | assert c1 != c3 471 | assert c3 != c1 472 | 473 | 474 | def test_equality_list_fields(): 475 | class Wheel(models.Base): 476 | pressure = fields.FloatField() 477 | 478 | class Car(models.Base): 479 | wheels = fields.ListField(items_types=[Wheel]) 480 | 481 | car = Car( 482 | wheels=[ 483 | Wheel(pressure=1), 484 | Wheel(pressure=2), 485 | Wheel(pressure=3), 486 | Wheel(pressure=4), 487 | ], 488 | ) 489 | 490 | another_car = Car( 491 | wheels=[ 492 | Wheel(pressure=1), 493 | Wheel(pressure=2), 494 | Wheel(pressure=3), 495 | Wheel(pressure=4), 496 | ], 497 | ) 498 | 499 | assert car == another_car 500 | 501 | different_car = Car( 502 | wheels=[ 503 | Wheel(pressure=4), 504 | Wheel(pressure=3), 505 | Wheel(pressure=2), 506 | Wheel(pressure=1), 507 | ], 508 | ) 509 | assert car != different_car 510 | 511 | 512 | def test_equality_missing_required_field(): 513 | class Model(models.Base): 514 | name = fields.StringField(required=True) 515 | age = fields.IntField() 516 | 517 | assert Model(age=1) == Model(age=1) 518 | assert Model(age=1) != Model(age=2) 519 | assert Model(name="William", age=1) != Model(age=1) 520 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | =========== 2 | JSON models 3 | =========== 4 | 5 | .. image:: https://jazzband.co/static/img/badge.svg 6 | :target: https://jazzband.co/ 7 | :alt: Jazzband 8 | 9 | .. image:: https://badge.fury.io/py/jsonmodels.svg 10 | :target: http://badge.fury.io/py/jsonmodels 11 | 12 | .. image:: https://github.com/jazzband/jsonmodels/workflows/Test/badge.svg 13 | :target: https://github.com/jazzband/jsonmodels/actions 14 | :alt: Tests 15 | 16 | .. image:: https://img.shields.io/pypi/dm/jsonmodels.svg 17 | :target: https://pypi.python.org/pypi/jsonmodels 18 | :alt: PyPI 19 | 20 | .. image:: https://codecov.io/gh/jazzband/jsonmodels/branch/master/graph/badge.svg 21 | :target: https://codecov.io/gh/jazzband/jsonmodels 22 | :alt: Coverage 23 | 24 | `jsonmodels` is library to make it easier for you to deal with structures that 25 | are converted to, or read from JSON. 26 | 27 | * Free software: BSD license 28 | * Documentation: http://jsonmodels.rtfd.org 29 | * Source: https://github.com/jazzband/jsonmodels 30 | 31 | Features 32 | -------- 33 | 34 | * Fully tested with Python 3.8+. 35 | 36 | * Support for PyPy 3.9 and 3.10 (see implementation notes in docs for more details). 37 | 38 | * Create Django-like models: 39 | 40 | .. code-block:: python 41 | 42 | from jsonmodels import models, fields, errors, validators 43 | 44 | 45 | class Cat(models.Base): 46 | 47 | name = fields.StringField(required=True) 48 | breed = fields.StringField() 49 | love_humans = fields.IntField(nullable=True) 50 | 51 | 52 | class Dog(models.Base): 53 | 54 | name = fields.StringField(required=True) 55 | age = fields.IntField() 56 | 57 | 58 | class Car(models.Base): 59 | 60 | registration_number = fields.StringField(required=True) 61 | engine_capacity = fields.FloatField() 62 | color = fields.StringField() 63 | 64 | 65 | class Person(models.Base): 66 | 67 | name = fields.StringField(required=True) 68 | surname = fields.StringField(required=True) 69 | nickname = fields.StringField(nullable=True) 70 | car = fields.EmbeddedField(Car) 71 | pets = fields.ListField([Cat, Dog], nullable=True) 72 | 73 | * Access to values through attributes: 74 | 75 | .. code-block:: python 76 | 77 | >>> cat = Cat() 78 | >>> cat.populate(name='Garfield') 79 | >>> cat.name 80 | 'Garfield' 81 | >>> cat.breed = 'mongrel' 82 | >>> cat.breed 83 | 'mongrel' 84 | 85 | * Validate models: 86 | 87 | .. code-block:: python 88 | 89 | >>> person = Person(name='Chuck', surname='Norris') 90 | >>> person.validate() 91 | None 92 | 93 | >>> dog = Dog() 94 | >>> dog.validate() 95 | *** ValidationError: Field "name" is required! 96 | 97 | * Cast models to python struct and JSON: 98 | 99 | .. code-block:: python 100 | 101 | >>> cat = Cat(name='Garfield') 102 | >>> dog = Dog(name='Dogmeat', age=9) 103 | >>> car = Car(registration_number='ASDF 777', color='red') 104 | >>> person = Person(name='Johny', surname='Bravo', pets=[cat, dog]) 105 | >>> person.car = car 106 | >>> person.to_struct() 107 | { 108 | 'car': { 109 | 'color': 'red', 110 | 'registration_number': 'ASDF 777' 111 | }, 112 | 'surname': 'Bravo', 113 | 'name': 'Johny', 114 | 'nickname': None, 115 | 'pets': [ 116 | {'name': 'Garfield'}, 117 | {'age': 9, 'name': 'Dogmeat'} 118 | ] 119 | } 120 | 121 | >>> import json 122 | >>> person_json = json.dumps(person.to_struct()) 123 | 124 | * You don't like to write JSON Schema? Let `jsonmodels` do it for you: 125 | 126 | .. code-block:: python 127 | 128 | >>> person = Person() 129 | >>> person.to_json_schema() 130 | { 131 | 'additionalProperties': False, 132 | 'required': ['surname', 'name'], 133 | 'type': 'object', 134 | 'properties': { 135 | 'car': { 136 | 'additionalProperties': False, 137 | 'required': ['registration_number'], 138 | 'type': 'object', 139 | 'properties': { 140 | 'color': {'type': 'string'}, 141 | 'engine_capacity': {'type': ''}, 142 | 'registration_number': {'type': 'string'} 143 | } 144 | }, 145 | 'surname': {'type': 'string'}, 146 | 'name': {'type': 'string'}, 147 | 'nickname': {'type': ['string', 'null']} 148 | 'pets': { 149 | 'items': { 150 | 'oneOf': [ 151 | { 152 | 'additionalProperties': False, 153 | 'required': ['name'], 154 | 'type': 'object', 155 | 'properties': { 156 | 'breed': {'type': 'string'}, 157 | 'name': {'type': 'string'} 158 | } 159 | }, 160 | { 161 | 'additionalProperties': False, 162 | 'required': ['name'], 163 | 'type': 'object', 164 | 'properties': { 165 | 'age': {'type': 'number'}, 166 | 'name': {'type': 'string'} 167 | } 168 | }, 169 | { 170 | 'type': 'null' 171 | } 172 | ] 173 | }, 174 | 'type': 'array' 175 | } 176 | } 177 | } 178 | 179 | * Validate models and use validators, that affect generated schema: 180 | 181 | .. code-block:: python 182 | 183 | >>> class Person(models.Base): 184 | ... 185 | ... name = fields.StringField( 186 | ... required=True, 187 | ... validators=[ 188 | ... validators.Regex('^[A-Za-z]+$'), 189 | ... validators.Length(3, 25), 190 | ... ], 191 | ... ) 192 | ... age = fields.IntField( 193 | ... nullable=True, 194 | ... validators=[ 195 | ... validators.Min(18), 196 | ... validators.Max(101), 197 | ... ] 198 | ... ) 199 | ... nickname = fields.StringField( 200 | ... required=True, 201 | ... nullable=True 202 | ... ) 203 | ... 204 | 205 | >>> person = Person() 206 | >>> person.age = 11 207 | >>> person.validate() 208 | *** ValidationError: '11' is lower than minimum ('18'). 209 | >>> person.age = None 210 | >>> person.validate() 211 | None 212 | 213 | >>> person.age = 19 214 | >>> person.name = 'Scott_' 215 | >>> person.validate() 216 | *** ValidationError: Value "Scott_" did not match pattern "^[A-Za-z]+$". 217 | 218 | >>> person.name = 'Scott' 219 | >>> person.validate() 220 | None 221 | 222 | >>> person.nickname = None 223 | >>> person.validate() 224 | *** ValidationError: Field is required! 225 | 226 | >>> person.to_json_schema() 227 | { 228 | "additionalProperties": false, 229 | "properties": { 230 | "age": { 231 | "maximum": 101, 232 | "minimum": 18, 233 | "type": ["number", "null"] 234 | }, 235 | "name": { 236 | "maxLength": 25, 237 | "minLength": 3, 238 | "pattern": "/^[A-Za-z]+$/", 239 | "type": "string" 240 | }, 241 | "nickname": {, 242 | "type": ["string", "null"] 243 | } 244 | }, 245 | "required": [ 246 | "nickname", 247 | "name" 248 | ], 249 | "type": "object" 250 | } 251 | 252 | You can also validate scalars, when needed: 253 | 254 | .. code-block:: python 255 | 256 | >>> class Person(models.Base): 257 | ... 258 | ... name = fields.StringField( 259 | ... required=True, 260 | ... validators=[ 261 | ... validators.Regex('^[A-Za-z]+$'), 262 | ... validators.Length(3, 25), 263 | ... ], 264 | ... ) 265 | ... age = fields.IntField( 266 | ... nullable=True, 267 | ... validators=[ 268 | ... validators.Min(18), 269 | ... validators.Max(101), 270 | ... ] 271 | ... ) 272 | ... nickname = fields.StringField( 273 | ... required=True, 274 | ... nullable=True 275 | ... ) 276 | ... 277 | 278 | >>> def only_odd_numbers(item): 279 | ... if item % 2 != 1: 280 | ... raise validators.ValidationError("Only odd numbers are accepted") 281 | ... 282 | >>> class Person(models.Base): 283 | ... lucky_numbers = fields.ListField(int, item_validators=[only_odd_numbers]) 284 | ... item_validator_str = fields.ListField( 285 | ... str, 286 | ... item_validators=[validators.Length(10, 20), validators.Regex(r"\w+")], 287 | ... validators=[validators.Length(1, 2)], 288 | ... ) 289 | ... 290 | >>> Person.to_json_schema() 291 | { 292 | "type": "object", 293 | "additionalProperties": false, 294 | "properties": { 295 | "item_validator_str": { 296 | "type": "array", 297 | "items": { 298 | "type": "string", 299 | "minLength": 10, 300 | "maxLength": 20, 301 | "pattern": "/\\w+/" 302 | }, 303 | "minItems": 1, 304 | "maxItems": 2 305 | }, 306 | "lucky_numbers": { 307 | "type": "array", 308 | "items": { 309 | "type": "number" 310 | } 311 | } 312 | } 313 | } 314 | 315 | (Note that `only_odd_numbers` did not modify schema, since only class based validators are 316 | able to do that, though it will still work as expected in python. Use class based validators 317 | that can be expressed in json schema if you want to be 100% correct on schema side.) 318 | 319 | * Lazy loading, best for circular references: 320 | 321 | .. code-block:: python 322 | 323 | >>> class Primary(models.Base): 324 | ... 325 | ... name = fields.StringField() 326 | ... secondary = fields.EmbeddedField('Secondary') 327 | 328 | >>> class Secondary(models.Base): 329 | ... 330 | ... data = fields.IntField() 331 | ... first = fields.EmbeddedField('Primary') 332 | 333 | You can use either `Model`, full path `path.to.Model` or relative imports 334 | `.Model` or `...Model`. 335 | 336 | * Using definitions to generate schema for circular references: 337 | 338 | .. code-block:: python 339 | 340 | >>> class File(models.Base): 341 | ... 342 | ... name = fields.StringField() 343 | ... size = fields.FloatField() 344 | 345 | >>> class Directory(models.Base): 346 | ... 347 | ... name = fields.StringField() 348 | ... children = fields.ListField(['Directory', File]) 349 | 350 | >>> class Filesystem(models.Base): 351 | ... 352 | ... name = fields.StringField() 353 | ... children = fields.ListField([Directory, File]) 354 | 355 | >>> Filesystem.to_json_schema() 356 | { 357 | "type": "object", 358 | "properties": { 359 | "name": {"type": "string"} 360 | "children": { 361 | "items": { 362 | "oneOf": [ 363 | "#/definitions/directory", 364 | "#/definitions/file" 365 | ] 366 | }, 367 | "type": "array" 368 | } 369 | }, 370 | "additionalProperties": false, 371 | "definitions": { 372 | "directory": { 373 | "additionalProperties": false, 374 | "properties": { 375 | "children": { 376 | "items": { 377 | "oneOf": [ 378 | "#/definitions/directory", 379 | "#/definitions/file" 380 | ] 381 | }, 382 | "type": "array" 383 | }, 384 | "name": {"type": "string"} 385 | }, 386 | "type": "object" 387 | }, 388 | "file": { 389 | "additionalProperties": false, 390 | "properties": { 391 | "name": {"type": "string"}, 392 | "size": {"type": "number"} 393 | }, 394 | "type": "object" 395 | } 396 | } 397 | } 398 | 399 | * Dealing with schemaless data 400 | 401 | (Plese note that using schemaless fields can cause your models to get out of control - especially if 402 | you are the one responsible for data schema. On the other hand there is usually the case when incomming 403 | data are with no schema defined and schemaless fields are the way to go.) 404 | 405 | .. code-block:: python 406 | 407 | >>> class Event(models.Base): 408 | ... 409 | ... name = fields.StringField() 410 | ... size = fields.FloatField() 411 | ... extra = fields.DictField() 412 | 413 | >>> Event.to_json_schema() 414 | { 415 | "type": "object", 416 | "additionalProperties": false, 417 | "properties": { 418 | "extra": { 419 | "type": "object" 420 | }, 421 | "name": { 422 | "type": "string" 423 | }, 424 | "size": { 425 | "type": "float" 426 | } 427 | } 428 | } 429 | 430 | `DictField` allow to pass any dict of values (`"type": "object"`), but note, that it will not make any validation 431 | on values except for the dict type. 432 | 433 | * Compare JSON schemas: 434 | 435 | .. code-block:: python 436 | 437 | >>> from jsonmodels.utils import compare_schemas 438 | >>> schema1 = {'type': 'object'} 439 | >>> schema2 = {'type': 'array'} 440 | >>> compare_schemas(schema1, schema1) 441 | True 442 | >>> compare_schemas(schema1, schema2) 443 | False 444 | 445 | More 446 | ---- 447 | 448 | For more examples and better description see full documentation: 449 | http://jsonmodels.rtfd.org. 450 | -------------------------------------------------------------------------------- /jsonmodels/fields.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import re 3 | from weakref import WeakKeyDictionary 4 | 5 | from dateutil.parser import parse 6 | 7 | from .collections import ModelCollection 8 | from .errors import ValidationError 9 | 10 | # unique marker for "no default value specified". None is not good enough since 11 | # it is a completely valid default value. 12 | NotSet = object() 13 | 14 | 15 | class BaseField: 16 | 17 | """Base class for all fields.""" 18 | 19 | types = None 20 | 21 | def __init__( 22 | self, 23 | required=False, 24 | nullable=False, 25 | help_text=None, 26 | validators=None, 27 | default=NotSet, 28 | name=None, 29 | ): 30 | self.memory = WeakKeyDictionary() 31 | self.required = required 32 | self.help_text = help_text 33 | self.nullable = nullable 34 | self._assign_validators(validators) 35 | self.name = name 36 | self._validate_name() 37 | if default is not NotSet: 38 | self.validate(default) 39 | self._default = default 40 | 41 | @property 42 | def has_default(self): 43 | return self._default is not NotSet 44 | 45 | def _assign_validators(self, validators): 46 | if validators and not isinstance(validators, list): 47 | validators = [validators] 48 | self.validators = validators or [] 49 | 50 | def __set__(self, instance, value): 51 | self._finish_initialization(type(instance)) 52 | value = self.parse_value(value) 53 | self.validate(value) 54 | self.memory[instance._cache_key] = value 55 | 56 | def __get__(self, instance, owner=None): 57 | if instance is None: 58 | self._finish_initialization(owner) 59 | return self 60 | 61 | self._finish_initialization(type(instance)) 62 | 63 | self._check_value(instance) 64 | return self.memory[instance._cache_key] 65 | 66 | def _finish_initialization(self, owner): 67 | pass 68 | 69 | def _check_value(self, obj): 70 | if obj._cache_key not in self.memory: 71 | self.__set__(obj, self.get_default_value()) 72 | 73 | def validate_for_object(self, obj): 74 | value = self.__get__(obj) 75 | self.validate(value) 76 | 77 | def validate(self, value): 78 | self._check_types() 79 | self._validate_against_types(value) 80 | self._check_against_required(value) 81 | self._validate_with_custom_validators(value) 82 | 83 | def _check_against_required(self, value): 84 | if value is None and self.required: 85 | raise ValidationError("Field is required!") 86 | 87 | def _validate_against_types(self, value): 88 | if value is not None and not isinstance(value, self.types): 89 | raise ValidationError( 90 | 'Value is wrong, expected type "{types}"'.format( 91 | types=", ".join([t.__name__ for t in self.types]) 92 | ), 93 | value, 94 | ) 95 | 96 | def _check_types(self): 97 | if self.types is None: 98 | raise ValidationError( 99 | 'Field "{type}" is not usable, try ' 100 | "different field type.".format(type=type(self).__name__) 101 | ) 102 | 103 | def to_struct(self, value): 104 | """Cast value to Python structure.""" 105 | return value 106 | 107 | def parse_value(self, value): 108 | """Parse value from primitive to desired format. 109 | 110 | Each field can parse value to form it wants it to be (like string or 111 | int). 112 | 113 | """ 114 | return value 115 | 116 | def _validate_with_custom_validators(self, value): 117 | if value is None and self.nullable: 118 | return 119 | 120 | for validator in self.validators: 121 | try: 122 | validator.validate(value) 123 | except AttributeError: 124 | validator(value) 125 | 126 | def get_default_value(self): 127 | """Get default value for field. 128 | 129 | Each field can specify its default. 130 | 131 | """ 132 | return self._default if self.has_default else None 133 | 134 | def _validate_name(self): 135 | if self.name is None: 136 | return 137 | if not re.match(r"^[A-Za-z_](([\w\-]*)?\w+)?$", self.name): 138 | raise ValueError("Wrong name", self.name) 139 | 140 | def structue_name(self, default): 141 | return self.name if self.name is not None else default 142 | 143 | 144 | class StringField(BaseField): 145 | 146 | """String field.""" 147 | 148 | types = (str,) 149 | 150 | 151 | class IntField(BaseField): 152 | 153 | """Integer field.""" 154 | 155 | types = (int,) 156 | 157 | def parse_value(self, value): 158 | """Cast value to `int`, e.g. from string or long""" 159 | parsed = super().parse_value(value) 160 | if parsed is None: 161 | return parsed 162 | return int(parsed) 163 | 164 | 165 | class FloatField(BaseField): 166 | 167 | """Float field.""" 168 | 169 | types = (float, int) 170 | 171 | 172 | class BoolField(BaseField): 173 | 174 | """Bool field.""" 175 | 176 | types = (bool,) 177 | 178 | def parse_value(self, value): 179 | """Cast value to `bool`.""" 180 | parsed = super().parse_value(value) 181 | return bool(parsed) if parsed is not None else None 182 | 183 | 184 | class DictField(BaseField): 185 | 186 | """Dict field.""" 187 | 188 | types = (dict,) 189 | 190 | 191 | class ListField(BaseField): 192 | 193 | """List field.""" 194 | 195 | types = (list,) 196 | 197 | def __init__(self, items_types=None, item_validators=(), *args, **kwargs): 198 | """Init. 199 | 200 | `ListField` is **always not required**. If you want to control number 201 | of items use validators. If you want to validate each individual item, 202 | use `item_validators`. 203 | 204 | """ 205 | self._assign_types(items_types) 206 | self.item_validators = item_validators 207 | super().__init__(*args, **kwargs) 208 | self.required = False 209 | 210 | def get_default_value(self): 211 | default = super().get_default_value() 212 | if default is None: 213 | return ModelCollection(self) 214 | return default 215 | 216 | def _assign_types(self, items_types): 217 | if items_types: 218 | try: 219 | self.items_types = tuple(items_types) 220 | except TypeError: 221 | self.items_types = (items_types,) 222 | else: 223 | self.items_types = tuple() 224 | 225 | types = [] 226 | for type_ in self.items_types: 227 | if isinstance(type_, str): 228 | types.append(_LazyType(type_)) 229 | else: 230 | types.append(type_) 231 | self.items_types = tuple(types) 232 | 233 | def validate(self, value): 234 | super().validate(value) 235 | 236 | for item in value: 237 | self.validate_single_value(item) 238 | 239 | def validate_single_value(self, value): 240 | for validator in self.item_validators: 241 | try: 242 | validator.validate(value) 243 | except AttributeError: # Case when validator is simple function. 244 | validator(value) 245 | 246 | if len(self.items_types) == 0: 247 | return 248 | 249 | if not isinstance(value, self.items_types): 250 | raise ValidationError( 251 | "All items must be instances " 252 | 'of "{types}", and not "{type}".'.format( 253 | types=", ".join([t.__name__ for t in self.items_types]), 254 | type=type(value).__name__, 255 | ) 256 | ) 257 | 258 | def parse_value(self, values): 259 | """Cast value to proper collection.""" 260 | result = self.get_default_value() 261 | 262 | if not values: 263 | return result 264 | 265 | if not isinstance(values, list): 266 | return values 267 | 268 | return [self._cast_value(value) for value in values] 269 | 270 | def _cast_value(self, value): 271 | if isinstance(value, self.items_types): 272 | return value 273 | else: 274 | if len(self.items_types) != 1: 275 | tpl = 'Cannot decide which type to choose from "{types}".' 276 | raise ValidationError( 277 | tpl.format(types=", ".join([t.__name__ for t in self.items_types])) 278 | ) 279 | return self.items_types[0](**value) 280 | 281 | def _finish_initialization(self, owner): 282 | super()._finish_initialization(owner) 283 | 284 | types = [] 285 | for type in self.items_types: 286 | if isinstance(type, _LazyType): 287 | types.append(type.evaluate(owner)) 288 | else: 289 | types.append(type) 290 | self.items_types = tuple(types) 291 | 292 | def _elem_to_struct(self, value): 293 | try: 294 | return value.to_struct() 295 | except AttributeError: 296 | return value 297 | 298 | def to_struct(self, values): 299 | return [self._elem_to_struct(v) for v in values] 300 | 301 | 302 | class EmbeddedField(BaseField): 303 | 304 | """Field for embedded models.""" 305 | 306 | def __init__(self, model_types, *args, **kwargs): 307 | self._assign_model_types(model_types) 308 | super().__init__(*args, **kwargs) 309 | 310 | def _assign_model_types(self, model_types): 311 | if not isinstance(model_types, (list, tuple)): 312 | model_types = (model_types,) 313 | 314 | types = [] 315 | for type_ in model_types: 316 | if isinstance(type_, str): 317 | types.append(_LazyType(type_)) 318 | else: 319 | types.append(type_) 320 | self.types = tuple(types) 321 | 322 | def _finish_initialization(self, owner): 323 | super()._finish_initialization(owner) 324 | 325 | types = [] 326 | for type in self.types: 327 | if isinstance(type, _LazyType): 328 | types.append(type.evaluate(owner)) 329 | else: 330 | types.append(type) 331 | self.types = tuple(types) 332 | 333 | def validate(self, value): 334 | super().validate(value) 335 | try: 336 | value.validate() 337 | except AttributeError: 338 | pass 339 | 340 | def parse_value(self, value): 341 | """Parse value to proper model type.""" 342 | if not isinstance(value, dict): 343 | return value 344 | 345 | embed_type = self._get_embed_type() 346 | return embed_type(**value) 347 | 348 | def _get_embed_type(self): 349 | if len(self.types) != 1: 350 | raise ValidationError( 351 | 'Cannot decide which type to choose from "{types}".'.format( 352 | types=", ".join([t.__name__ for t in self.types]) 353 | ) 354 | ) 355 | return self.types[0] 356 | 357 | def to_struct(self, value): 358 | return value.to_struct() 359 | 360 | 361 | class _LazyType: 362 | def __init__(self, path): 363 | self.path = path 364 | 365 | def evaluate(self, base_cls): 366 | module, type_name = _evaluate_path(self.path, base_cls) 367 | return _import(module, type_name) 368 | 369 | 370 | def _evaluate_path(relative_path, base_cls): 371 | base_module = base_cls.__module__ 372 | 373 | modules = _get_modules(relative_path, base_module) 374 | 375 | type_name = modules.pop() 376 | module = ".".join(modules) 377 | if not module: 378 | module = base_module 379 | return module, type_name 380 | 381 | 382 | def _get_modules(relative_path, base_module): 383 | canonical_path = relative_path.lstrip(".") 384 | canonical_modules = canonical_path.split(".") 385 | 386 | if not relative_path.startswith("."): 387 | return canonical_modules 388 | 389 | parents_amount = len(relative_path) - len(canonical_path) 390 | parent_modules = base_module.split(".") 391 | parents_amount = max(0, parents_amount - 1) 392 | if parents_amount > len(parent_modules): 393 | raise ValueError(f"Can't evaluate path '{relative_path}'") 394 | return parent_modules[: parents_amount * -1] + canonical_modules 395 | 396 | 397 | def _import(module_name, type_name): 398 | module = __import__(module_name, fromlist=[type_name]) 399 | try: 400 | return getattr(module, type_name) 401 | except AttributeError: 402 | raise ValueError(f"Can't find type '{module_name}.{type_name}'.") 403 | 404 | 405 | class TimeField(StringField): 406 | 407 | """Time field.""" 408 | 409 | types = (datetime.time,) 410 | 411 | def __init__(self, str_format=None, *args, **kwargs): 412 | """Init. 413 | 414 | :param str str_format: Format to cast time to (if `None` - casting to 415 | ISO 8601 format). 416 | 417 | """ 418 | self.str_format = str_format 419 | super().__init__(*args, **kwargs) 420 | 421 | def to_struct(self, value): 422 | """Cast `time` object to string.""" 423 | if self.str_format: 424 | return value.strftime(self.str_format) 425 | return value.isoformat() 426 | 427 | def parse_value(self, value): 428 | """Parse string into instance of `time`.""" 429 | if value is None: 430 | return value 431 | if isinstance(value, datetime.time): 432 | return value 433 | return parse(value).timetz() 434 | 435 | 436 | class DateField(StringField): 437 | 438 | """Date field.""" 439 | 440 | types = (datetime.date,) 441 | default_format = "%Y-%m-%d" 442 | 443 | def __init__(self, str_format=None, *args, **kwargs): 444 | """Init. 445 | 446 | :param str str_format: Format to cast date to (if `None` - casting to 447 | %Y-%m-%d format). 448 | 449 | """ 450 | self.str_format = str_format 451 | super().__init__(*args, **kwargs) 452 | 453 | def to_struct(self, value): 454 | """Cast `date` object to string.""" 455 | if self.str_format: 456 | return value.strftime(self.str_format) 457 | return value.strftime(self.default_format) 458 | 459 | def parse_value(self, value): 460 | """Parse string into instance of `date`.""" 461 | if value is None: 462 | return value 463 | if isinstance(value, datetime.date): 464 | return value 465 | return parse(value).date() 466 | 467 | 468 | class DateTimeField(StringField): 469 | 470 | """Datetime field.""" 471 | 472 | types = (datetime.datetime,) 473 | 474 | def __init__(self, str_format=None, *args, **kwargs): 475 | """Init. 476 | 477 | :param str str_format: Format to cast datetime to (if `None` - casting 478 | to ISO 8601 format). 479 | 480 | """ 481 | self.str_format = str_format 482 | super().__init__(*args, **kwargs) 483 | 484 | def to_struct(self, value): 485 | """Cast `datetime` object to string.""" 486 | if self.str_format: 487 | return value.strftime(self.str_format) 488 | return value.isoformat() 489 | 490 | def parse_value(self, value): 491 | """Parse string into instance of `datetime`.""" 492 | if isinstance(value, datetime.datetime): 493 | return value 494 | if value: 495 | return parse(value) 496 | else: 497 | return None 498 | --------------------------------------------------------------------------------