├── .coveragerc ├── .coveralls.yml ├── .gitignore ├── .travis.yml ├── LICENSE ├── MANIFEST.in ├── README.rst ├── docs ├── Makefile └── source │ ├── api │ ├── document.rst │ ├── exceptions.rst │ ├── fields.rst │ ├── resolutionscope.rst │ └── roles.rst │ ├── changelog.rst │ ├── conf.py │ ├── contributing.rst │ ├── index.rst │ ├── installing.rst │ └── tutorial.rst ├── jsl ├── __init__.py ├── _compat │ ├── __init__.py │ ├── ordereddict.py │ └── prepareable.py ├── document.py ├── exceptions.py ├── fields │ ├── __init__.py │ ├── base.py │ ├── compound.py │ ├── primitive.py │ └── util.py ├── registry.py ├── resolutionscope.py └── roles.py ├── requirements-dev.txt ├── setup.py ├── test.sh └── tests ├── test_document.py ├── test_documentmeta.py ├── test_errors.py ├── test_fields.py ├── test_inheritance.py ├── test_iter_methods.py ├── test_registry.py ├── test_resolutionscope.py ├── test_roles.py └── util.py /.coveragerc: -------------------------------------------------------------------------------- 1 | [report] 2 | omit = jsl/_compat/ordereddict.py -------------------------------------------------------------------------------- /.coveralls.yml: -------------------------------------------------------------------------------- 1 | service_name: travis-ci 2 | repo_token: 9cTPYochei3Y9KlVzejQPH82XCrgE9AEO -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.egg* 2 | *.py[co] 3 | __pycache__/ 4 | 5 | /.coverage 6 | /docs/build 7 | /dist 8 | /env 9 | /.python-version 10 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | python: 3 | - "2.6" 4 | - "2.7" 5 | - "3.3" 6 | - "3.4" 7 | - "pypy" 8 | install: 9 | - pip install -e . 10 | - pip install -r requirements-dev.txt 11 | - pip install python-coveralls 12 | script: 13 | - ./test.sh 14 | after_success: 15 | - coveralls 16 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2016 by Anton Romanovich. 2 | 3 | Some rights reserved. 4 | 5 | Redistribution and use in source and binary forms, with or without 6 | modification, are permitted provided that the following conditions are 7 | met: 8 | 9 | * Redistributions of source code must retain the above copyright 10 | notice, this list of conditions and the following disclaimer. 11 | 12 | * Redistributions in binary form must reproduce the above 13 | copyright notice, this list of conditions and the following 14 | disclaimer in the documentation and/or other materials provided 15 | with the distribution. 16 | 17 | * The names of the contributors may not be used to endorse or 18 | promote products derived from this software without specific 19 | prior written permission. 20 | 21 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 22 | "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 23 | LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 24 | A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT 25 | OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 26 | SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT 27 | LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 28 | DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY 29 | THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 30 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 31 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE 2 | include README.rst -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | JSL 2 | === 3 | 4 | .. image:: https://travis-ci.org/aromanovich/jsl.svg?branch=master 5 | :target: https://travis-ci.org/aromanovich/jsl 6 | :alt: Build Status 7 | 8 | .. image:: https://coveralls.io/repos/aromanovich/jsl/badge.svg?branch=master 9 | :target: https://coveralls.io/r/aromanovich/jsl?branch=master 10 | :alt: Coverage 11 | 12 | .. image:: https://readthedocs.org/projects/jsl/badge/?version=latest 13 | :target: https://readthedocs.org/projects/jsl/ 14 | :alt: Documentation 15 | 16 | .. image:: http://img.shields.io/pypi/v/jsl.svg 17 | :target: https://pypi.python.org/pypi/jsl 18 | :alt: PyPI Version 19 | 20 | Documentation_ | GitHub_ | PyPI_ 21 | 22 | JSL is a Python DSL for defining JSON Schemas. 23 | 24 | Example 25 | ------- 26 | 27 | .. code:: python 28 | 29 | import jsl 30 | 31 | class Entry(jsl.Document): 32 | name = jsl.StringField(required=True) 33 | 34 | class File(Entry): 35 | content = jsl.StringField(required=True) 36 | 37 | class Directory(Entry): 38 | content = jsl.ArrayField(jsl.OneOfField([ 39 | jsl.DocumentField(File, as_ref=True), 40 | jsl.DocumentField(jsl.RECURSIVE_REFERENCE_CONSTANT) 41 | ]), required=True) 42 | 43 | ``Directory.get_schema(ordered=True)`` will return the following JSON schema: 44 | 45 | .. code:: json 46 | 47 | { 48 | "$schema": "http://json-schema.org/draft-04/schema#", 49 | "definitions": { 50 | "directory": { 51 | "type": "object", 52 | "properties": { 53 | "name": {"type": "string"}, 54 | "content": { 55 | "type": "array", 56 | "items": { 57 | "oneOf": [ 58 | {"$ref": "#/definitions/file"}, 59 | {"$ref": "#/definitions/directory"} 60 | ] 61 | } 62 | } 63 | }, 64 | "required": ["name", "content"], 65 | "additionalProperties": false 66 | }, 67 | "file": { 68 | "type": "object", 69 | "properties": { 70 | "name": {"type": "string"}, 71 | "content": {"type": "string"} 72 | }, 73 | "required": ["name", "content"], 74 | "additionalProperties": false 75 | } 76 | }, 77 | "$ref": "#/definitions/directory" 78 | } 79 | 80 | Installing 81 | ---------- 82 | 83 | :: 84 | 85 | pip install jsl 86 | 87 | License 88 | ------- 89 | 90 | `BSD license`_ 91 | 92 | .. _Documentation: https://jsl.readthedocs.io/ 93 | .. _GitHub: https://github.com/aromanovich/jsl 94 | .. _PyPI: https://pypi.python.org/pypi/jsl 95 | .. _BSD license: https://github.com/aromanovich/jsl/blob/master/LICENSE 96 | -------------------------------------------------------------------------------- /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) source 19 | # the i18n builder cannot share the environment and doctrees with the others 20 | I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) source 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/JSL.qhcp" 89 | @echo "To view the help file:" 90 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/JSL.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/JSL" 98 | @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/JSL" 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." 178 | -------------------------------------------------------------------------------- /docs/source/api/document.rst: -------------------------------------------------------------------------------- 1 | .. _document: 2 | 3 | ======== 4 | Document 5 | ======== 6 | 7 | .. module:: jsl.document 8 | 9 | .. autodata:: ALL_OF 10 | 11 | .. autodata:: ANY_OF 12 | 13 | .. autodata:: ONE_OF 14 | 15 | .. autodata:: INLINE 16 | 17 | .. autoclass:: Options 18 | :members: 19 | 20 | .. autoclass:: Document 21 | :members: get_schema, get_definitions_and_schema, is_recursive, get_definition_id, 22 | resolve_field, iter_fields, resolve_and_iter_fields, walk, resolve_and_walk 23 | 24 | .. autoclass:: DocumentMeta 25 | :members: options_container, collect_fields, collect_options, create_options 26 | -------------------------------------------------------------------------------- /docs/source/api/exceptions.rst: -------------------------------------------------------------------------------- 1 | .. _exceptions: 2 | 3 | ========== 4 | Exceptions 5 | ========== 6 | 7 | .. module:: jsl.exceptions 8 | 9 | .. autoclass:: SchemaGenerationException 10 | :members: 11 | 12 | Steps 13 | ----- 14 | 15 | Steps attached to a :class:`~.SchemaGenerationException` serve as a traceback 16 | and help a user to debug the error in the document or field description. 17 | 18 | .. autoclass:: Step 19 | :members: 20 | .. autoclass:: DocumentStep 21 | :show-inheritance: 22 | .. autoclass:: FieldStep 23 | :show-inheritance: 24 | .. autoclass:: AttributeStep 25 | :show-inheritance: 26 | .. autoclass:: ItemStep 27 | :show-inheritance: 28 | -------------------------------------------------------------------------------- /docs/source/api/fields.rst: -------------------------------------------------------------------------------- 1 | .. _fields: 2 | 3 | ====== 4 | Fields 5 | ====== 6 | 7 | .. module:: jsl.fields 8 | 9 | Primitive Fields 10 | ================ 11 | 12 | .. autoclass:: NullField 13 | :members: 14 | 15 | .. autoclass:: BooleanField 16 | :members: 17 | 18 | .. autoclass:: NumberField 19 | :members: 20 | 21 | .. autoclass:: IntField 22 | :members: 23 | :show-inheritance: 24 | .. autoclass:: StringField 25 | :members: 26 | 27 | .. autoclass:: EmailField 28 | :members: 29 | :show-inheritance: 30 | 31 | .. autoclass:: IPv4Field 32 | :members: 33 | :show-inheritance: 34 | 35 | .. autoclass:: DateTimeField 36 | :members: 37 | :show-inheritance: 38 | 39 | .. autoclass:: UriField 40 | :members: 41 | :show-inheritance: 42 | 43 | Compound Fields 44 | =============== 45 | 46 | .. data:: RECURSIVE_REFERENCE_CONSTANT 47 | 48 | A special value to be used as an argument to create 49 | a recursive :class:`.DocumentField`. 50 | 51 | .. autoclass:: DocumentField 52 | :members: 53 | 54 | .. autoclass:: RefField 55 | :members: 56 | 57 | .. autoclass:: ArrayField 58 | :members: 59 | 60 | .. autoclass:: DictField 61 | :members: 62 | 63 | .. autoclass:: NotField 64 | :members: 65 | 66 | .. autoclass:: OneOfField 67 | :members: 68 | 69 | .. autoclass:: AnyOfField 70 | :members: 71 | 72 | .. autoclass:: AllOfField 73 | :members: 74 | 75 | Base Classes 76 | ============ 77 | 78 | .. autodata:: Null 79 | 80 | .. autoclass:: BaseField 81 | :members: 82 | 83 | .. autoclass:: BaseSchemaField 84 | :members: 85 | -------------------------------------------------------------------------------- /docs/source/api/resolutionscope.rst: -------------------------------------------------------------------------------- 1 | .. _resolutionscope: 2 | 3 | ================ 4 | Resolution Scope 5 | ================ 6 | 7 | .. module:: jsl.resolutionscope 8 | 9 | .. autoclass:: ResolutionScope 10 | :members: 11 | 12 | .. autodata:: EMPTY_SCOPE 13 | :annotation: 14 | -------------------------------------------------------------------------------- /docs/source/api/roles.rst: -------------------------------------------------------------------------------- 1 | .. _roles: 2 | 3 | ===== 4 | Roles 5 | ===== 6 | 7 | .. module:: jsl.roles 8 | 9 | .. autodata:: DEFAULT_ROLE 10 | :annotation: 11 | 12 | .. autoclass:: Resolution 13 | 14 | .. autoclass:: Resolvable 15 | :members: 16 | 17 | .. autoclass:: Var 18 | :members: 19 | 20 | .. autoclass:: Scope 21 | :members: 22 | 23 | Helpers 24 | ======= 25 | 26 | .. autofunction:: all_ 27 | 28 | .. autofunction:: not_ 29 | 30 | -------------------------------------------------------------------------------- /docs/source/changelog.rst: -------------------------------------------------------------------------------- 1 | Changelog 2 | ========= 3 | 4 | 0.2.4 2016-05-11 5 | ~~~~~~~~~~~~~~~~ 6 | 7 | - Subschema definitions are now sorted when ``ordered=True`` (issue `#24`_). 8 | 9 | 0.2.3 2016-04-24 10 | ~~~~~~~~~~~~~~~~ 11 | 12 | - Introduction of two new :ref:`inheritance modes `, ``oneOf`` and ``anyOf``, 13 | by Steven Seguin (issue `#22`_). 14 | 15 | 0.2.2 2016-02-06 16 | ~~~~~~~~~~~~~~~~ 17 | 18 | - Documentation fixes by mulhern (issue `#17`_). 19 | 20 | 0.2.1 2015-11-23 21 | ~~~~~~~~~~~~~~~~ 22 | 23 | - Fix a bug when referencing a recursive document using :class:`.DocumentField` with ``as_ref=True`` 24 | produced circular references (issue `#16`_). 25 | 26 | 0.2.0 2015-11-08 27 | ~~~~~~~~~~~~~~~~ 28 | 29 | - Minor breaking change for the issue `#15`_: :meth:`.Document.resolve_and_iter_fields` 30 | now iterates only over fields that are attached as attributes 31 | (fields specified in document ``Options`` as ``pattern_properties`` or 32 | ``additional_properties`` won't be processed), and yields tuples of (field name, field). 33 | 34 | 0.1.5: 2015-10-22 35 | ~~~~~~~~~~~~~~~~~ 36 | 37 | - Fix a bug when using RECURSIVE_REFERENCE_CONSTANT under a scope caused 38 | infinite recursion (issue `#14`_). 39 | 40 | 0.1.4: 2015-10-11 41 | ~~~~~~~~~~~~~~~~~ 42 | 43 | - Introduce :ref:`inheritance modes `. 44 | 45 | 0.1.3: 2015-08-12 46 | ~~~~~~~~~~~~~~~~~ 47 | 48 | - Add a ``name`` parameter to :class:`.BaseField` which makes it possible to create documents 49 | with fields whose names contain symbols that are not allowed in Python variable 50 | names (such as hyphen); 51 | - Introduce :class:`.RefField`. 52 | 53 | 0.1.2: 2015-06-12 54 | ~~~~~~~~~~~~~~~~~ 55 | 56 | - Allow specifying a null default value for fields (see :data:`.Null` value) by Nathan Hoad. 57 | 58 | 0.1.1: 2015-05-29 59 | ~~~~~~~~~~~~~~~~~ 60 | 61 | - Fix :meth:`.Document.resolve_field` method; 62 | - Allow specifying a resolvable as a ``definition_id`` (see :class:`document options <.Options>`). 63 | 64 | 0.1.0: 2015-05-13 65 | ~~~~~~~~~~~~~~~~~ 66 | 67 | - Introduce :ref:`roles `, :class:`variables <.Var>` and :class:`scopes <.Scope>`; 68 | - :class:`.NullField` by Igor Davydenko; 69 | - Almost completely rewritten documentation; 70 | - Various minor fixes. 71 | 72 | 0.0.10: 2015-04-28 73 | ~~~~~~~~~~~~~~~~~~ 74 | 75 | - Fix spelling of ``exclusiveMinimum`` by Keith T. Star. 76 | 77 | 0.0.9: 2015-04-10 78 | ~~~~~~~~~~~~~~~~~ 79 | 80 | - Introduce the ``ordered`` argument for :meth:`~jsl.document.Document.get_schema` that 81 | adds the ability to create more readable JSON schemas with ordered parameters. 82 | 83 | 0.0.8: 2015-03-21 84 | ~~~~~~~~~~~~~~~~~ 85 | 86 | - Add the ability to specify an `id`_ for documents and fields. 87 | 88 | 0.0.7: 2015-03-11 89 | ~~~~~~~~~~~~~~~~~ 90 | 91 | - More subclassing-friendly :class:`~jsl.document.DocumentMeta` which allows to 92 | override methods for collecting document fields and options and 93 | choose a container class for storing options; 94 | - Various minor bugfixes. 95 | 96 | 0.0.5: 2015-03-01 97 | ~~~~~~~~~~~~~~~~~ 98 | 99 | - Python 3 support by Igor Davydenko. 100 | 101 | .. _id: http://tools.ietf.org/html/draft-zyp-json-schema-04#section-7.2 102 | .. _#14: https://github.com/aromanovich/jsl/issues/14 103 | .. _#15: https://github.com/aromanovich/jsl/issues/15 104 | .. _#16: https://github.com/aromanovich/jsl/issues/16 105 | .. _#17: https://github.com/aromanovich/jsl/issues/17 106 | .. _#22: https://github.com/aromanovich/jsl/issues/22 107 | .. _#24: https://github.com/aromanovich/jsl/issues/24 108 | -------------------------------------------------------------------------------- /docs/source/conf.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | import sys 3 | import os 4 | 5 | on_rtd = os.environ.get('READTHEDOCS', None) == 'True' 6 | if not on_rtd: 7 | import sphinx_rtd_theme 8 | 9 | sys.path.insert(0, os.path.abspath('../..')) 10 | import jsl 11 | 12 | extensions = [ 13 | 'sphinx.ext.autodoc', 14 | 'sphinx.ext.viewcode', 15 | ] 16 | 17 | templates_path = ['_templates'] 18 | source_suffix = '.rst' 19 | master_doc = 'index' 20 | 21 | project = u'JSL' 22 | copyright = u'2016, Anton Romanovich' 23 | version = jsl.__version__ 24 | release = jsl.__version__ 25 | 26 | language = 'en' 27 | pygments_style = 'sphinx' 28 | autodoc_member_order = 'bysource' 29 | autoclass_content = 'both' 30 | 31 | # Options for HTML output 32 | 33 | if not on_rtd: 34 | html_theme = 'sphinx_rtd_theme' 35 | html_theme_path = [sphinx_rtd_theme.get_html_theme_path()] 36 | html_static_path = ['_static'] 37 | htmlhelp_basename = 'JSLdoc' 38 | 39 | # Options for manual page output 40 | 41 | man_pages = [ 42 | ('index', 'jsl', u'JSL Documentation', [u'Anton Romanovich'], 1), 43 | ] 44 | 45 | 46 | 47 | 48 | -------------------------------------------------------------------------------- /docs/source/contributing.rst: -------------------------------------------------------------------------------- 1 | Contributing 2 | ============ 3 | 4 | The project is hosted on GitHub_. 5 | Please feel free to send a pull request or open an issue. 6 | 7 | .. _GitHub: https://github.com/aromanovich/jsl 8 | 9 | Running the Tests 10 | ~~~~~~~~~~~~~~~~~ 11 | 12 | .. code-block:: sh 13 | 14 | $ pip install -r ./requirements-dev.txt 15 | $ ./test.sh -------------------------------------------------------------------------------- /docs/source/index.rst: -------------------------------------------------------------------------------- 1 | =============================== 2 | Welcome to JSL's documentation! 3 | =============================== 4 | 5 | JSL is a `DSL`_ for describing JSON schemas. 6 | 7 | Its code is open source and available at `GitHub`_. 8 | 9 | .. _GitHub: https://github.com/aromanovich/jsl 10 | .. _DSL: http://en.wikipedia.org/wiki/Domain-specific_language 11 | 12 | Tutorial 13 | -------- 14 | 15 | For an overview of JSL's basic functionality, please see the :doc:`tutorial`. 16 | 17 | .. toctree:: 18 | :caption: User Guide 19 | :name: user-guide 20 | :hidden: 21 | 22 | tutorial 23 | 24 | API Documentation 25 | ----------------- 26 | 27 | .. toctree:: 28 | :caption: API Documentation 29 | :name: api-documentation 30 | 31 | api/document 32 | api/fields 33 | api/roles 34 | api/exceptions 35 | api/resolutionscope 36 | 37 | .. toctree:: 38 | :caption: Misc 39 | :name: misc 40 | :hidden: 41 | 42 | changelog 43 | installing 44 | contributing 45 | -------------------------------------------------------------------------------- /docs/source/installing.rst: -------------------------------------------------------------------------------- 1 | Installation 2 | ============ 3 | 4 | .. code-block:: sh 5 | 6 | $ pip install jsl -------------------------------------------------------------------------------- /docs/source/tutorial.rst: -------------------------------------------------------------------------------- 1 | ===================== 2 | Overview and Tutorial 3 | ===================== 4 | 5 | Welcome to JSL! 6 | 7 | This document is a brief tour of JSL's features and a quick guide to its 8 | use. Additional documentation can be found in the :ref:`API documentation `. 9 | 10 | Introduction 11 | ------------ 12 | 13 | `JSON Schema`_ is a JSON-based format to define the structure of JSON data 14 | for validation and documentation. 15 | 16 | JSL is a Python library that provides a DSL for describing JSON schemas. 17 | 18 | Why invent a DSL? 19 | 20 | * A JSON schema in terms of the Python language is a dictionary. A JSON schema 21 | of a more or less complex data structure is a dictionary which most likely 22 | contains a lot of nested dictionaries of dictionaries of dictionaries. 23 | Writing and maintaining the readability of such a dictionary are not very 24 | rewarding tasks. They require typing a lot of quotes, braces, colons and commas 25 | and carefully indenting everything. 26 | 27 | * The JSON schema standard is not always intuitive. It takes a little bit of practice 28 | to remember where to use the ``maxItems`` keyword and where the ``maxLength``, 29 | or not to forget to set ``additionalProperties`` to false, and so on. 30 | 31 | * The syntax is not very concise. The signal-to-noise ratio increases rapidly 32 | with the complexity of the schema, which makes large schemas difficult to read. 33 | 34 | JSL is created to address these issues. 35 | It allows you to define JSON schemas as if they were ORM models -- 36 | using classes and fields and relying on the deep metaclass magic under the hood. 37 | 38 | Such an approach makes writing and reading schemas easier. 39 | It encourages the decomposition of large schemas into smaller readable pieces 40 | and makes schemas extendable using class inheritance. It enables the autocomplete 41 | feature or IDEs and makes any mistype in a JSON schema keyword cause a RuntimeError. 42 | 43 | Since every JSON schema object is itself valid JSON, the ``json`` module in 44 | the Python standard library can be used for printing and serialization of 45 | a generated schema. 46 | 47 | .. links 48 | 49 | .. _Python implementation: https://python-jsonschema.readthedocs.io/en/latest/ 50 | .. _JSON Schema: http://json-schema.org/ 51 | 52 | Quick Example 53 | ------------- 54 | 55 | :: 56 | 57 | import jsl 58 | 59 | class Entry(jsl.Document): 60 | name = jsl.StringField(required=True) 61 | 62 | class File(Entry): 63 | content = jsl.StringField(required=True) 64 | 65 | class Directory(Entry): 66 | content = jsl.ArrayField(jsl.OneOfField([ 67 | jsl.DocumentField(File, as_ref=True), 68 | jsl.DocumentField(jsl.RECURSIVE_REFERENCE_CONSTANT) 69 | ]), required=True) 70 | 71 | ``Directory.get_schema(ordered=True)`` returns the following schema: 72 | 73 | :: 74 | 75 | { 76 | "$schema": "http://json-schema.org/draft-04/schema#", 77 | "definitions": { 78 | "directory": { 79 | "type": "object", 80 | "properties": { 81 | "name": {"type": "string"}, 82 | "content": { 83 | "type": "array", 84 | "items": { 85 | "oneOf": [ 86 | {"$ref": "#/definitions/file"}, 87 | {"$ref": "#/definitions/directory"} 88 | ] 89 | } 90 | } 91 | }, 92 | "required": ["name", "content"], 93 | "additionalProperties": false 94 | }, 95 | "file": { 96 | "type": "object", 97 | "properties": { 98 | "name": {"type": "string"}, 99 | "content": {"type": "string"} 100 | }, 101 | "required": ["name", "content"], 102 | "additionalProperties": false 103 | } 104 | }, 105 | "$ref": "#/definitions/directory" 106 | } 107 | 108 | Main Features 109 | ------------- 110 | 111 | JSL introduces the notion of a :ref:`document ` and provides a set of :ref:`fields `. 112 | 113 | The schema of a document is always ``{"type": "object"}``, whose ``properties`` contain the 114 | schemas of the fields of the document. A document may be thought of as a :class:`.DictField` 115 | with some special abilities. A document is a class, thus it has a name, by which it can be 116 | referenced from another document and either inlined or included using the 117 | ``{"$ref": "..."}`` syntax (see :class:`.DocumentField` and its ``as_ref`` parameter). 118 | Also documents can be recursive. 119 | 120 | The most useful method of :class:`.Document` and the fields is :meth:`.Document.get_schema`. 121 | 122 | Fields and their parameters are named correspondingly to the keywords described in the 123 | JSON Schema standard. So getting started with JSL will be easy for those familiar with 124 | `the standard`_. 125 | 126 | .. _the standard: https://tools.ietf.org/html/draft-zyp-json-schema-04 127 | 128 | Variables and Scopes 129 | -------------------- 130 | 131 | Suppose there is an application that provides a JSON RESTful API backed by MongoDB. 132 | Let's describe a ``User`` data model:: 133 | 134 | class User(jsl.Document): 135 | id = jsl.StringField(required=True) 136 | login = jsl.StringField(required=True, min_length=3, max_length=20) 137 | 138 | ``User.get_schema(ordered=True)`` produces the following schema:: 139 | 140 | { 141 | "$schema": "http://json-schema.org/draft-04/schema#", 142 | "type": "object", 143 | "additionalProperties": false, 144 | "properties": { 145 | "id": {"type": "string"}, 146 | "login": { 147 | "type": "string", 148 | "minLength": 3, 149 | "maxLength": 20 150 | } 151 | }, 152 | "required": ["id", "login"] 153 | } 154 | 155 | It describes a response of the imaginary ``/users//`` endpoint and 156 | perhaps a database document structure (if the application stores users "as is"). 157 | 158 | Let's now describe a structure of the data required to create a new user 159 | (i.e., a JSON-payload of ``POST``-requests to the imaginary ``/users/`` endpoint). 160 | The data may and may not contain ``id``; if ``id`` is not present, it will 161 | be generated by the application:: 162 | 163 | class UserCreationRequest(jsl.Document): 164 | id = jsl.StringField() 165 | login = jsl.StringField(required=True, min_length=3, max_length=20) 166 | 167 | The only difference between ``User`` and ``UserCreationRequest`` is whether 168 | the ``"id"`` field is required or not. 169 | 170 | JSL provides means not to repeat ourselves. 171 | 172 | Using Variables 173 | +++++++++++++++ 174 | 175 | :class:`Variables <.Var>`. are objects which value depends on a given role. 176 | Which value must be used for which role is determined by a list of rules. 177 | A rule is a pair of a matcher and a value. A matcher is a callable that returns 178 | ``True`` or ``False`` (or a string or an iterable that will be converted to a lambda). 179 | Here's what it may look like:: 180 | 181 | >>> var = jsl.Var([ 182 | ... # the same as (lambda r: r == 'role_1', 'A') 183 | ... ('role_1', 'A'), 184 | ... # the same as (lambda r: r in ('role_2', 'role_3'), 'A') 185 | ... (('role_2', 'role_3'), 'B'), 186 | ... (lambda r: r.startswith('bad_role_'), 'C'), 187 | ... ], default='D') 188 | >>> var.resolve('role_1') 189 | Resolution(value='A', role='role_1') 190 | >>> var.resolve('role_2') 191 | Resolution(value='B', role='role_2') 192 | >>> var.resolve('bad_role_1') 193 | Resolution(value='C', role='bad_role_1') 194 | >>> var.resolve('qwerty') 195 | Resolution(value='D', role='qwerty') 196 | 197 | Variables can be used instead of regular values almost everywhere in JSL -- 198 | e.g., they can be added to documents, passed as arguments to :class:`fields <.BaseField>` 199 | or even used as properties of a :class:`.DictField`. 200 | 201 | Let's introduce a couple of **roles** for our ``User`` document:: 202 | 203 | # to describe structures of POST requests 204 | REQUEST_ROLE = 'request' 205 | # to describe structures of responses 206 | RESPONSE_ROLE = 'response' 207 | # to describe structures of database documents 208 | DB_ROLE = 'db' 209 | 210 | Create a variable ``true_if_not_requests`` which is only ``True`` when the role is 211 | ``REQUEST_ROLE``:: 212 | 213 | true_if_not_request = jsl.Var({ 214 | jsl.not_(REQUEST_ROLE): True 215 | }) 216 | 217 | And describe ``User`` and ``UserCreationRequest`` in a single document 218 | using ``true_if_not_requests`` for the ``required`` argument of the ``id`` field:: 219 | 220 | class User(jsl.Document): 221 | id = jsl.StringField(required=true_if_not_request) 222 | login = jsl.StringField(required=True, min_length=3, max_length=20) 223 | 224 | The ``role`` argument can be specified for the :meth:`.Document.get_schema` method:: 225 | 226 | User.get_schema(ordered=True, role=REQUEST_ROLE) 227 | 228 | That call will return the following schema. Note that ``"id"`` is not listed as required:: 229 | 230 | { 231 | "$schema": "http://json-schema.org/draft-04/schema#", 232 | "type": "object", 233 | "additionalProperties": false, 234 | "properties": { 235 | "id": {"type": "string"}, 236 | "login": { 237 | "type": "string", 238 | "minLength": 3, 239 | "maxLength": 20 240 | } 241 | }, 242 | "required": ["login"] 243 | } 244 | 245 | 246 | Using Scopes 247 | ++++++++++++ 248 | 249 | Let's add a ``version`` field to the ``User`` document with the following 250 | requirements in mind: it is stored in the database, but must not appear 251 | neither in the request nor the response (a reason for this can be that HTTP 252 | headers such as ``ETag`` and ``If-Match`` are used for concurrency control). 253 | 254 | One way is to turn the ``version`` field into a variable that only resolves 255 | to the field when the current role is ``DB_ROLE`` and resolves to 256 | ``None`` otherwise:: 257 | 258 | class User(jsl.Document): 259 | id = jsl.StringField(required=true_if_not_request) 260 | login = jsl.StringField(required=True, min_length=3, max_length=20) 261 | version = jsl.Var({ 262 | DB_ROLE: jsl.StringField(required=True) 263 | }) 264 | 265 | Another (and more preferable) way is to use :class:`scopes <.Scope>`:: 266 | 267 | class User(jsl.Document): 268 | id = jsl.StringField(required=true_if_not_request) 269 | login = jsl.StringField(required=True, min_length=3, max_length=20) 270 | 271 | with jsl.Scope(DB_ROLE) as db_scope: 272 | db_scope.version = jsl.StringField(required=True) 273 | 274 | A scope is a set of :class:`fields <.BaseField>` and a matcher. 275 | A scope can be added to a document, and if the matcher of a scope returns ``True``, 276 | its fields will be present in the resulting schema. 277 | 278 | A document may contain arbitrary number of scopes:: 279 | 280 | class Message(jsl.Document): 281 | created_at = jsl.IntField(required=True) 282 | content = jsl.StringField(required=True) 283 | 284 | class User(jsl.Document): 285 | id = jsl.StringField(required=true_if_not_request) 286 | login = jsl.StringField(required=True, min_length=3, max_length=20) 287 | 288 | with jsl.Scope(jsl.not_(REQUEST_ROLE)) as full_scope: 289 | # a new user can not have messages 290 | full_scope.messages = jsl.ArrayField( 291 | jsl.DocumentField(Message), required=True) 292 | 293 | with jsl.Scope(DB_ROLE) as db_scope: 294 | db_scope.version = jsl.StringField(required=True) 295 | 296 | Now ``User.get_schema(ordered=True, role=DB_ROLE)`` returns the following schema:: 297 | 298 | { 299 | "$schema": "http://json-schema.org/draft-04/schema#", 300 | "type": "object", 301 | "additionalProperties": false, 302 | "properties": { 303 | "id": {"type": "string"}, 304 | "login": { 305 | "type": "string", 306 | "minLength": 3, 307 | "maxLength": 20 308 | }, 309 | "messages": { 310 | "type": "array", 311 | "items": { 312 | "type": "object", 313 | "additionalProperties": false, 314 | "properties": { 315 | "created_at": { 316 | "type": "integer" 317 | }, 318 | "content": { 319 | "type": "string" 320 | } 321 | }, 322 | "required": ["created_at", "content"] 323 | } 324 | }, 325 | "version": {"type": "string"} 326 | }, 327 | "required": ["id", "login", "messages", "version"] 328 | } 329 | 330 | .. _inheritance: 331 | 332 | Document Inheritance 333 | -------------------- 334 | There are four inheritance modes available in JSL: **inline**, **all-of**, **any-of**, and **one-of**. 335 | 336 | In the inline mode (used by default), a schema of the child document contains a copy 337 | of its parent's fields. 338 | 339 | In the the other three modes a schema of the child document is a validator of the type allOf, anyOf, 340 | or oneOf that contains references to all parent schemas along with the schema that defines the 341 | child's fields. 342 | 343 | The inheritance mode can be set using the ``inheritance_mode`` document :class:`option <.Options>`. 344 | 345 | Example 346 | +++++++ 347 | 348 | Suppose we have a `Shape` document:: 349 | 350 | class Shape(Base): 351 | class Options(object): 352 | definition_id = 'shape' 353 | 354 | color = StringField() 355 | 356 | The table below shows the difference between inline and all-of modes: 357 | 358 | .. list-table:: 359 | :widths: 50 50 360 | :header-rows: 1 361 | 362 | * - Inline 363 | - All-of 364 | * - :: 365 | 366 | class Circle(Shape): 367 | class Options(object): 368 | definition_id = 'circle' 369 | # inheritance_mode = INLINE 370 | 371 | radius = NumberField() 372 | 373 | - :: 374 | 375 | class Circle(Shape): 376 | class Options(object): 377 | definition_id = 'circle' 378 | inheritance_mode = ALL_OF 379 | 380 | radius = NumberField() 381 | * - Resulting schema:: 382 | 383 | { 384 | "type": "object", 385 | "properties": { 386 | "color": { 387 | "type": "string" 388 | }, 389 | "radius": { 390 | "type": "number" 391 | } 392 | } 393 | } 394 | 395 | - Resulting schema:: 396 | 397 | { 398 | "definitions": { 399 | "shape": { 400 | "type": "object", 401 | "properties": { 402 | "color": { 403 | "type": "string" 404 | } 405 | } 406 | } 407 | }, 408 | "allOf": [ 409 | { 410 | "$ref": "#/definitions/shape" 411 | }, 412 | { 413 | "type": "object", 414 | "properties": { 415 | "radius": { 416 | "type": "number" 417 | } 418 | } 419 | } 420 | ] 421 | } 422 | 423 | More Examples 424 | ------------- 425 | 426 | A `JSON schema from the official documentation`_ defined using JSL: 427 | 428 | :: 429 | 430 | class DiskDevice(jsl.Document): 431 | type = jsl.StringField(enum=['disk'], required=True) 432 | device = jsl.StringField(pattern='^/dev/[^/]+(/[^/]+)*$', required=True) 433 | 434 | class DiskUUID(jsl.Document): 435 | type = jsl.StringField(enum=['disk'], required=True) 436 | label = jsl.StringField(pattern='^[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-' 437 | '[a-fA-F0-9]{4}-[a-fA-F0-9]{12}$', 438 | required=True) 439 | 440 | class NFS(jsl.Document): 441 | type = jsl.StringField(enum=['nfs'], required=True) 442 | remotePath = jsl.StringField(pattern='^(/[^/]+)+$', required=True) 443 | server = jsl.OneOfField([ 444 | jsl.StringField(format='ipv4'), 445 | jsl.StringField(format='ipv6'), 446 | jsl.StringField(format='host-name'), 447 | ], required=True) 448 | 449 | class TmpFS(jsl.Document): 450 | type = jsl.StringField(enum=['tmpfs'], required=True) 451 | sizeInMb = jsl.IntField(minimum=16, maximum=512, required=True) 452 | 453 | class FSTabEntry(jsl.Document): 454 | class Options(object): 455 | description = 'schema for an fstab entry' 456 | 457 | storage = jsl.OneOfField([ 458 | jsl.DocumentField(DiskDevice, as_ref=True), 459 | jsl.DocumentField(DiskUUID, as_ref=True), 460 | jsl.DocumentField(NFS, as_ref=True), 461 | jsl.DocumentField(TmpFS, as_ref=True), 462 | ], required=True) 463 | fstype = jsl.StringField(enum=['ext3', 'ext4', 'btrfs']) 464 | options = jsl.ArrayField(jsl.StringField(), min_items=1, unique_items=True) 465 | readonly = jsl.BooleanField() 466 | 467 | .. _JSON schema from the official documentation: http://json-schema.org/example2.html 468 | -------------------------------------------------------------------------------- /jsl/__init__.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | """ 3 | JSL 4 | === 5 | A Python DSL for describing JSON schemas. 6 | See https://jsl.readthedocs.io/ for documentation. 7 | :copyright: (c) 2016 Anton Romanovich 8 | :license: BSD 9 | """ 10 | 11 | __title__ = 'JSL' 12 | __author__ = 'Anton Romanovich' 13 | __license__ = 'BSD' 14 | __copyright__ = 'Copyright 2016 Anton Romanovich' 15 | __version__ = '0.2.4' 16 | __version_info__ = tuple(int(i) for i in __version__.split('.')) 17 | 18 | 19 | from .document import Document, ALL_OF, INLINE, ANY_OF, ONE_OF 20 | from .fields import * 21 | from .roles import * 22 | from .exceptions import SchemaGenerationException 23 | -------------------------------------------------------------------------------- /jsl/_compat/__init__.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | """ 3 | Compatibility utils for Python 2 & 3. 4 | """ 5 | import sys 6 | 7 | 8 | IS_PY3 = sys.version_info[0] == 3 9 | string_types = (str, ) if IS_PY3 else (basestring, ) 10 | text_type = str if IS_PY3 else unicode 11 | _identity = lambda x: x 12 | 13 | 14 | if IS_PY3: 15 | from urllib.parse import urljoin, urlunsplit, urlsplit 16 | 17 | implements_to_string = _identity 18 | else: 19 | from urlparse import urljoin, urlunsplit, urlsplit 20 | 21 | def implements_to_string(cls): 22 | cls.__unicode__ = cls.__str__ 23 | cls.__str__ = lambda x: x.__unicode__().encode('utf-8') 24 | return cls 25 | 26 | 27 | def iterkeys(obj, **kwargs): 28 | """Iterate over dict keys in Python 2 & 3.""" 29 | return (obj.iterkeys(**kwargs) 30 | if hasattr(obj, 'iterkeys') 31 | else iter(obj.keys(**kwargs))) 32 | 33 | 34 | def iteritems(obj, **kwargs): 35 | """Iterate over dict items in Python 2 & 3.""" 36 | return (obj.iteritems(**kwargs) 37 | if hasattr(obj, 'iteritems') 38 | else iter(obj.items(**kwargs))) 39 | 40 | 41 | def itervalues(obj, **kwargs): 42 | """Iterate over dict values in Python 2 & 3.""" 43 | return (obj.itervalues(**kwargs) 44 | if hasattr(obj, 'itervalues') 45 | else iter(obj.values(**kwargs))) 46 | 47 | 48 | def with_metaclass(meta, *bases): 49 | """Create a base class with a metaclass. 50 | 51 | Function copied from `six `_ package. 52 | """ 53 | # This requires a bit of explanation: the basic idea is to make a dummy 54 | # metaclass for one level of class instantiation that replaces itself with 55 | # the actual metaclass. 56 | class metaclass(meta): 57 | def __new__(cls, name, this_bases, d): 58 | return meta(name, bases, d) 59 | return type.__new__(metaclass, 'temporary_class', (), {}) 60 | 61 | 62 | # On python < 3.3 fragments are not handled properly with unknown schemes 63 | 64 | def urldefrag(url): 65 | if "#" in url: 66 | s, n, p, q, frag = urlsplit(url) 67 | defrag = urlunsplit((s, n, p, q, '')) 68 | else: 69 | defrag = url 70 | frag = '' 71 | return defrag, frag 72 | 73 | 74 | try: 75 | from collections import OrderedDict 76 | except ImportError: 77 | from .ordereddict import OrderedDict 78 | 79 | 80 | from .prepareable import Prepareable -------------------------------------------------------------------------------- /jsl/_compat/ordereddict.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2009 Raymond Hettinger 2 | # 3 | # Permission is hereby granted, free of charge, to any person 4 | # obtaining a copy of this software and associated documentation files 5 | # (the "Software"), to deal in the Software without restriction, 6 | # including without limitation the rights to use, copy, modify, merge, 7 | # publish, distribute, sublicense, and/or sell copies of the Software, 8 | # and to permit persons to whom the Software is furnished to do so, 9 | # subject to the following conditions: 10 | # 11 | # The above copyright notice and this permission notice shall be 12 | # included in all copies or substantial portions of the Software. 13 | # 14 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES 16 | # OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | # NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 18 | # HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 19 | # WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 20 | # FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 21 | # OTHER DEALINGS IN THE SOFTWARE. 22 | 23 | from UserDict import DictMixin 24 | 25 | class OrderedDict(dict, DictMixin): 26 | 27 | def __init__(self, *args, **kwds): 28 | if len(args) > 1: 29 | raise TypeError('expected at most 1 arguments, got %d' % len(args)) 30 | try: 31 | self.__end 32 | except AttributeError: 33 | self.clear() 34 | self.update(*args, **kwds) 35 | 36 | def clear(self): 37 | self.__end = end = [] 38 | end += [None, end, end] # sentinel node for doubly linked list 39 | self.__map = {} # key --> [key, prev, next] 40 | dict.clear(self) 41 | 42 | def __setitem__(self, key, value): 43 | if key not in self: 44 | end = self.__end 45 | curr = end[1] 46 | curr[2] = end[1] = self.__map[key] = [key, curr, end] 47 | dict.__setitem__(self, key, value) 48 | 49 | def __delitem__(self, key): 50 | dict.__delitem__(self, key) 51 | key, prev, next = self.__map.pop(key) 52 | prev[2] = next 53 | next[1] = prev 54 | 55 | def __iter__(self): 56 | end = self.__end 57 | curr = end[2] 58 | while curr is not end: 59 | yield curr[0] 60 | curr = curr[2] 61 | 62 | def __reversed__(self): 63 | end = self.__end 64 | curr = end[1] 65 | while curr is not end: 66 | yield curr[0] 67 | curr = curr[1] 68 | 69 | def popitem(self, last=True): 70 | if not self: 71 | raise KeyError('dictionary is empty') 72 | if last: 73 | key = reversed(self).next() 74 | else: 75 | key = iter(self).next() 76 | value = self.pop(key) 77 | return key, value 78 | 79 | def __reduce__(self): 80 | items = [[k, self[k]] for k in self] 81 | tmp = self.__map, self.__end 82 | del self.__map, self.__end 83 | inst_dict = vars(self).copy() 84 | self.__map, self.__end = tmp 85 | if inst_dict: 86 | return (self.__class__, (items,), inst_dict) 87 | return self.__class__, (items,) 88 | 89 | def keys(self): 90 | return list(self) 91 | 92 | setdefault = DictMixin.setdefault 93 | update = DictMixin.update 94 | pop = DictMixin.pop 95 | values = DictMixin.values 96 | items = DictMixin.items 97 | iterkeys = DictMixin.iterkeys 98 | itervalues = DictMixin.itervalues 99 | iteritems = DictMixin.iteritems 100 | 101 | def __repr__(self): 102 | if not self: 103 | return '%s()' % (self.__class__.__name__,) 104 | return '%s(%r)' % (self.__class__.__name__, self.items()) 105 | 106 | def copy(self): 107 | return self.__class__(self) 108 | 109 | @classmethod 110 | def fromkeys(cls, iterable, value=None): 111 | d = cls() 112 | for key in iterable: 113 | d[key] = value 114 | return d 115 | 116 | def __eq__(self, other): 117 | if isinstance(other, OrderedDict): 118 | if len(self) != len(other): 119 | return False 120 | for p, q in zip(self.items(), other.items()): 121 | if p != q: 122 | return False 123 | return True 124 | return dict.__eq__(self, other) 125 | 126 | def __ne__(self, other): 127 | return not self == other 128 | -------------------------------------------------------------------------------- /jsl/_compat/prepareable.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import inspect 3 | from functools import wraps 4 | 5 | from . import IS_PY3 6 | 7 | 8 | class Prepareable(type): 9 | # this code is taken from https://gist.github.com/DasIch/5562625 with minor fixes 10 | if not IS_PY3: 11 | def __new__(cls, name, bases, attributes): 12 | try: 13 | constructor = attributes["__new__"] 14 | except KeyError: 15 | return type.__new__(cls, name, bases, attributes) 16 | 17 | def preparing_constructor(cls, name, bases, attributes): 18 | try: 19 | cls.__prepare__ 20 | except AttributeError: 21 | return constructor(cls, name, bases, attributes) 22 | namespace = cls.__prepare__(name, bases) 23 | defining_frame = sys._getframe(1) 24 | for constant in reversed(defining_frame.f_code.co_consts): 25 | if inspect.iscode(constant) and constant.co_name == name: 26 | def get_index(attribute_name, _names=constant.co_names): 27 | try: 28 | return _names.index(attribute_name) 29 | except ValueError: 30 | return 0 31 | break 32 | else: 33 | return constructor(cls, name, bases, attributes) 34 | 35 | by_appearance = sorted( 36 | attributes.items(), key=lambda item: get_index(item[0]) 37 | ) 38 | for key, value in by_appearance: 39 | namespace[key] = value 40 | return constructor(cls, name, bases, namespace) 41 | attributes["__new__"] = wraps(constructor)(preparing_constructor) 42 | return type.__new__(cls, name, bases, attributes) -------------------------------------------------------------------------------- /jsl/document.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | import inspect 3 | 4 | from . import registry 5 | from .exceptions import processing, DocumentStep 6 | from .fields import BaseField, DocumentField, DictField 7 | from .roles import DEFAULT_ROLE, Var, Scope, all_, construct_matcher, Resolvable, Resolution 8 | from .resolutionscope import ResolutionScope, EMPTY_SCOPE 9 | from ._compat import iteritems, iterkeys, with_metaclass, OrderedDict, Prepareable 10 | 11 | 12 | def _set_owner_to_document_fields(cls): 13 | for field in cls.walk(through_document_fields=False, visited_documents=set([cls])): 14 | if isinstance(field, DocumentField): 15 | field.owner_cls = cls 16 | 17 | 18 | # INHERITANCE CONSTANTS AND MAPPING 19 | 20 | INLINE = 'inline' # default inheritance mode 21 | ALL_OF = 'all_of' 22 | ANY_OF = 'any_of' 23 | ONE_OF = 'one_of' 24 | 25 | _INHERITANCE_MODES = { 26 | INLINE: 'allOf', # used in the case that an inline class inherits from document bases 27 | ALL_OF: 'allOf', 28 | ANY_OF: 'anyOf', 29 | ONE_OF: 'oneOf' 30 | } 31 | 32 | 33 | class Options(object): 34 | """ 35 | A container for options. 36 | 37 | All the arguments are the same and work exactly as for :class:`.fields.DictField` 38 | except ``properties`` (since it is automatically populated with the document fields) 39 | and these: 40 | 41 | :param definition_id: 42 | A unique string to be used as a key for this document in the "definitions" 43 | schema section. If not specified, will be generated from module and class names. 44 | :type definition_id: str or :class:`.Resolvable` 45 | :param str schema_uri: 46 | An URI of the JSON Schema meta-schema. 47 | :param roles_to_propagate: 48 | A matcher. If it returns ``True`` for a role, it will be passed to nested 49 | documents. 50 | :type roles_to_propagate: callable, string or iterable 51 | :param str inheritance_mode: 52 | An :ref:`inheritance mode `: one of :data:`INLINE` (default), 53 | :data:`ALL_OF`, :data:`ANY_OF`, or :data:`ONE_OF` 54 | 55 | .. versionadded:: 0.1.4 56 | """ 57 | 58 | def __init__(self, additional_properties=False, pattern_properties=None, 59 | min_properties=None, max_properties=None, 60 | title=None, description=None, 61 | default=None, enum=None, 62 | id='', schema_uri='http://json-schema.org/draft-04/schema#', 63 | definition_id=None, roles_to_propagate=None, 64 | inheritance_mode=INLINE): 65 | self.pattern_properties = pattern_properties 66 | self.additional_properties = additional_properties 67 | self.min_properties = min_properties 68 | self.max_properties = max_properties 69 | self.title = title 70 | self.description = description 71 | self.default = default 72 | self.enum = enum 73 | self.id = id 74 | 75 | self.schema_uri = schema_uri 76 | self.definition_id = definition_id 77 | self.roles_to_propagate = construct_matcher(roles_to_propagate or all_) 78 | if inheritance_mode not in _INHERITANCE_MODES: 79 | raise ValueError( 80 | 'Unknown inheritance mode: {0!r}. ' 81 | 'Must be one of the following: {1!r}'.format( 82 | inheritance_mode, 83 | sorted([m for m in _INHERITANCE_MODES]) 84 | ) 85 | ) 86 | self.inheritance_mode = inheritance_mode 87 | 88 | 89 | class DocumentBackend(DictField): 90 | def _get_property_key(self, prop, field): 91 | return prop if field.name is None else field.name 92 | 93 | def resolve_and_iter_properties(self, role=DEFAULT_ROLE): 94 | for name, field in iteritems(self.properties): 95 | field = field.resolve(role).value 96 | if isinstance(field, BaseField): 97 | yield name, field 98 | 99 | 100 | class DocumentMeta(with_metaclass(Prepareable, type)): 101 | """ 102 | A metaclass for :class:`~.Document`. It's responsible for collecting 103 | options, fields and scopes registering the document in the registry, making 104 | it the owner of nested :class:`document fields <.DocumentField>` s and so on. 105 | """ 106 | options_container = Options 107 | """ 108 | A class to be used by :meth:`~.DocumentMeta.create_options`. 109 | Must be a subclass of :class:`~.Options`. 110 | """ 111 | 112 | @classmethod 113 | def __prepare__(mcs, name, bases): 114 | return OrderedDict() 115 | 116 | def __new__(mcs, name, bases, attrs): 117 | options_data = mcs.collect_options(bases, attrs) 118 | options = mcs.create_options(options_data) 119 | 120 | if options.inheritance_mode == INLINE: 121 | fields = mcs.collect_fields(bases, attrs) 122 | parent_documents = set() 123 | for base in bases: 124 | if issubclass(base, Document) and base is not Document: 125 | parent_documents.update(base._parent_documents) 126 | else: 127 | fields = mcs.collect_fields([], attrs) 128 | parent_documents = [base for base in bases 129 | if issubclass(base, Document) and base is not Document] 130 | 131 | attrs['_fields'] = fields 132 | attrs['_parent_documents'] = sorted(parent_documents, key=lambda d: d.get_definition_id()) 133 | attrs['_options'] = options 134 | attrs['_backend'] = DocumentBackend( 135 | properties=fields, 136 | pattern_properties=options.pattern_properties, 137 | additional_properties=options.additional_properties, 138 | min_properties=options.min_properties, 139 | max_properties=options.max_properties, 140 | title=options.title, 141 | description=options.description, 142 | enum=options.enum, 143 | default=options.default, 144 | id=options.id, 145 | ) 146 | 147 | klass = type.__new__(mcs, name, bases, attrs) 148 | registry.put_document(klass.__name__, klass, module=klass.__module__) 149 | _set_owner_to_document_fields(klass) 150 | return klass 151 | 152 | @classmethod 153 | def collect_fields(mcs, bases, attrs): 154 | """ 155 | Collects fields from the current class and its parent classes. 156 | 157 | :rtype: a dictionary mapping field names to fields 158 | """ 159 | fields = OrderedDict() 160 | # fields from parent classes: 161 | for base in reversed(bases): 162 | if hasattr(base, '_fields'): 163 | fields.update(base._fields) 164 | 165 | to_be_replaced = object() 166 | 167 | # and from the current class: 168 | pre_fields = OrderedDict() 169 | scopes = [] 170 | for key, value in iteritems(attrs): 171 | if isinstance(value, (BaseField, Resolvable)): 172 | pre_fields[key] = value 173 | elif isinstance(value, Scope): 174 | scopes.append(value) 175 | for scope_key in iterkeys(value.__fields__): 176 | pre_fields[scope_key] = to_be_replaced 177 | 178 | for name, field in iteritems(pre_fields): 179 | if field is to_be_replaced: 180 | values = [] 181 | for scope in scopes: 182 | if name in scope.__fields__: 183 | values.append((scope.__matcher__, scope.__fields__[name])) 184 | fields[name] = Var(values) 185 | else: 186 | fields[name] = field 187 | 188 | return fields 189 | 190 | @classmethod 191 | def collect_options(mcs, bases, attrs): 192 | """ 193 | Collects options from the current class and its parent classes. 194 | 195 | :returns: a dictionary of options 196 | """ 197 | options = {} 198 | # options from parent classes: 199 | for base in reversed(bases): 200 | if hasattr(base, '_options'): 201 | for key, value in inspect.getmembers(base._options): 202 | if not key.startswith('_') and value is not None: 203 | options[key] = value 204 | 205 | # options from the current class: 206 | if 'Options' in attrs: 207 | for key, value in inspect.getmembers(attrs['Options']): 208 | if not key.startswith('_') and value is not None: 209 | # HACK HACK HACK 210 | if inspect.ismethod(value) and value.im_self is None: 211 | value = value.im_func 212 | options[key] = value 213 | return options 214 | 215 | @classmethod 216 | def create_options(cls, options): 217 | """ 218 | Wraps ``options`` into a container class 219 | (see :attr:`~.DocumentMeta.options_container`). 220 | 221 | :param options: a dictionary of options 222 | :return: an instance of :attr:`~.DocumentMeta.options_container` 223 | """ 224 | return cls.options_container(**options) 225 | 226 | 227 | class Document(with_metaclass(DocumentMeta)): 228 | """A document. Can be thought as a kind of :class:`.fields.DictField`, which 229 | properties are defined by the fields and scopes added to the document class. 230 | 231 | It can be tuned using special ``Options`` attribute (see :class:`.Options` 232 | for available settings):: 233 | 234 | class User(Document): 235 | class Options(object): 236 | title = 'User' 237 | description = 'A person who uses a computer or network service.' 238 | login = StringField(required=True) 239 | 240 | .. note:: 241 | A subclass inherits options of its parent documents. 242 | """ 243 | 244 | @classmethod 245 | def is_recursive(cls, role=DEFAULT_ROLE): 246 | """Returns ``True`` if there is a :class:`.DocumentField`-references cycle 247 | that contains ``cls``. 248 | 249 | :param str role: A current role. 250 | """ 251 | for field in cls.resolve_and_walk(through_document_fields=True, 252 | role=role, visited_documents=set([cls])): 253 | if isinstance(field, DocumentField): 254 | if field.document_cls == cls: 255 | return True 256 | return False 257 | 258 | @classmethod 259 | def get_definition_id(cls, role=DEFAULT_ROLE): 260 | """Returns a unique string to be used as a key for this document 261 | in the ``"definitions"`` schema section. 262 | """ 263 | definition_id = cls._options.definition_id 264 | if isinstance(definition_id, Resolvable): 265 | definition_id = definition_id.resolve(role).value 266 | return definition_id or '{0}.{1}'.format(cls.__module__, cls.__name__) 267 | 268 | @classmethod 269 | def resolve_field(cls, field, role=DEFAULT_ROLE): 270 | """Resolves a field with the name ``field`` using ``role``. 271 | 272 | :raises: :class:`AttributeError` 273 | """ 274 | properties = cls._backend.properties 275 | if field in properties: 276 | return properties[field].resolve(role) 277 | else: 278 | return Resolution(None, role) 279 | 280 | @classmethod 281 | def resolve_and_iter_fields(cls, role=DEFAULT_ROLE): 282 | """Resolves each resolvable attribute of a document using the specified role 283 | and yields a tuple of (attribute name, field) in case the result is a JSL field. 284 | 285 | .. versionchanged:: 0.2 286 | The method has been changed to iterate only over fields that attached as attributes, 287 | and yield tuples instead of plain :class:`.BaseField`. 288 | 289 | :rtype: iterable of (str, :class:`.BaseField`) 290 | """ 291 | return cls._backend.resolve_and_iter_properties(role=role) 292 | 293 | @classmethod 294 | def resolve_and_walk(cls, role=DEFAULT_ROLE, through_document_fields=False, 295 | visited_documents=frozenset()): 296 | """The same as :meth:`.walk`, but :class:`resolvables <.Resolvable>` are 297 | resolved using ``role``. 298 | """ 299 | fields = cls._backend.resolve_and_walk( 300 | role=role, through_document_fields=through_document_fields, 301 | visited_documents=visited_documents) 302 | next(fields) # we don't want to yield _field itself 303 | return fields 304 | 305 | @classmethod 306 | def iter_fields(cls): 307 | """Iterates over the fields of the document, resolving its 308 | :class:`resolvables <.Resolvable>` to all possible values. 309 | """ 310 | return cls._backend.iter_fields() 311 | 312 | @classmethod 313 | def walk(cls, through_document_fields=False, visited_documents=frozenset()): 314 | """ 315 | Iterates recursively over the fields of the document, resolving 316 | occurring :class:`resolvables <.Resolvable>` to their all possible values. 317 | 318 | Visits fields in a DFS order. 319 | 320 | :param bool through_document_fields: 321 | If ``True``, walks through nested :class:`.DocumentField` fields. 322 | :param set visited_documents: 323 | Keeps track of visited :class:`documents <.Document>` to avoid infinite 324 | recursion when ``through_document_field`` is ``True``. 325 | :returns: iterable of :class:`.BaseField` 326 | """ 327 | fields = cls._backend.walk(through_document_fields=through_document_fields, 328 | visited_documents=visited_documents) 329 | next(fields) # we don't want to yield _field itself 330 | return fields 331 | 332 | @classmethod 333 | def get_schema(cls, role=DEFAULT_ROLE, ordered=False): 334 | """Returns a JSON schema (draft v4) of the document. 335 | 336 | :param str role: A role. 337 | :param bool ordered: 338 | If ``True``, the resulting schema dictionary is ordered. Fields are 339 | listed in the order they are added to the class. Schema properties are 340 | also ordered in a sensible and consistent way, making the schema more 341 | human-readable. 342 | :raises: :class:`.SchemaGenerationException` 343 | :rtype: dict or OrderedDict 344 | """ 345 | definitions, schema = cls.get_definitions_and_schema( 346 | role=role, ordered=ordered, 347 | res_scope=ResolutionScope(base=cls._options.id, current=cls._options.id) 348 | ) 349 | rv = OrderedDict() if ordered else {} 350 | if cls._options.id: 351 | rv['id'] = cls._options.id 352 | if cls._options.schema_uri is not None: 353 | rv['$schema'] = cls._options.schema_uri 354 | if definitions: 355 | rv['definitions'] = definitions 356 | rv.update(schema) 357 | return rv 358 | 359 | @classmethod 360 | def get_definitions_and_schema(cls, role=DEFAULT_ROLE, res_scope=EMPTY_SCOPE, 361 | ordered=False, ref_documents=None): 362 | """Returns a tuple of two elements. 363 | 364 | The second element is a JSON schema of the document, and the first is 365 | a dictionary that contains definitions that are referenced from the schema. 366 | 367 | :param str role: A role. 368 | :param bool ordered: 369 | If ``True``, the resulting schema dictionary is ordered. Fields are 370 | listed in the order they are added to the class. Schema properties are 371 | also ordered in a sensible and consistent way, making the schema more 372 | human-readable. 373 | :param res_scope: 374 | The current resolution scope. 375 | :type res_scope: :class:`~.ResolutionScope` 376 | :param set ref_documents: 377 | If subclass of :class:`.Document` is in this set, all :class:`.DocumentField` s 378 | pointing to it will be resolved as a reference: ``{"$ref": "#/definitions/..."}``. 379 | Note: resulting definitions will not contain schema for this document. 380 | :raises: :class:`~.SchemaGenerationException` 381 | :rtype: (dict or OrderedDict) 382 | """ 383 | is_recursive = cls.is_recursive(role=role) 384 | 385 | if is_recursive: 386 | ref_documents = set(ref_documents) if ref_documents else set() 387 | ref_documents.add(cls) 388 | res_scope = res_scope.replace(output=res_scope.base) 389 | 390 | with processing(DocumentStep(cls, role=role)): 391 | definitions, schema = cls._backend.get_definitions_and_schema( 392 | role=role, res_scope=res_scope, ordered=ordered, ref_documents=ref_documents) 393 | 394 | if cls._parent_documents: 395 | mode = _INHERITANCE_MODES[cls._options.inheritance_mode] 396 | contents = [] 397 | for parent_document in cls._parent_documents: 398 | parent_definitions, parent_schema = parent_document.get_definitions_and_schema( 399 | role=role, res_scope=res_scope, ordered=ordered, ref_documents=ref_documents) 400 | parent_definition_id = parent_document.get_definition_id() 401 | definitions.update(parent_definitions) 402 | definitions[parent_definition_id] = parent_schema 403 | contents.append(res_scope.create_ref(parent_definition_id)) 404 | contents.append(schema) 405 | schema = {mode: contents} 406 | 407 | if is_recursive: 408 | definition_id = cls.get_definition_id() 409 | definitions[definition_id] = schema 410 | schema = res_scope.create_ref(definition_id) 411 | 412 | if ordered: 413 | definitions = OrderedDict(sorted(definitions.items())) 414 | 415 | return definitions, schema 416 | 417 | 418 | # Remove Document itself from registry 419 | registry.remove_document(Document.__name__, module=Document.__module__) 420 | -------------------------------------------------------------------------------- /jsl/exceptions.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | import collections 3 | import contextlib 4 | 5 | from .roles import DEFAULT_ROLE 6 | from ._compat import implements_to_string, text_type 7 | 8 | 9 | @contextlib.contextmanager 10 | def processing(step): 11 | """ 12 | A context manager. If an :class:`SchemaGenerationException` occurs within 13 | its nested code block, it adds ``step`` to it and reraises. 14 | """ 15 | try: 16 | yield 17 | except SchemaGenerationException as e: 18 | e.steps.appendleft(step) 19 | raise 20 | 21 | 22 | class Step(object): 23 | """A step of the schema generation process that caused the error.""" 24 | 25 | def __init__(self, entity, role=DEFAULT_ROLE): 26 | """ 27 | :param entity: An entity being processed. 28 | :param str role: A current role. 29 | """ 30 | #: An entity being processed. 31 | self.entity = entity 32 | #: A current role. 33 | self.role = role 34 | 35 | def __eq__(self, other): 36 | if isinstance(other, self.__class__): 37 | return self.__dict__ == other.__dict__ 38 | return NotImplemented 39 | 40 | def __ne__(self, other): 41 | if isinstance(other, self.__class__): 42 | return not self.__eq__(other) 43 | return NotImplemented 44 | 45 | def __repr__(self): 46 | return '{0}({1!r}, role={2})'.format( 47 | self.__class__.__name__, self.entity, self.role) 48 | 49 | 50 | @implements_to_string 51 | class DocumentStep(Step): 52 | """ 53 | A step of processing a :class:`document <.Document>`. 54 | 55 | :type entity: subclass of :class:`~.Document` 56 | """ 57 | 58 | def __str__(self): 59 | return self.entity.__name__ 60 | 61 | 62 | @implements_to_string 63 | class FieldStep(Step): 64 | """ 65 | A step of processing a :class:`field <.BaseField>`. 66 | 67 | :type entity: instance of :class:`~.BaseField` 68 | """ 69 | 70 | def __str__(self): 71 | return self.entity.__class__.__name__ 72 | 73 | 74 | @implements_to_string 75 | class AttributeStep(Step): 76 | """ 77 | A step of processing an attribute of a field. 78 | 79 | ``entity`` is the name of an attribute 80 | (e.g., ``"properties"``, ``"additional_properties"``, etc.) 81 | 82 | :type entity: str 83 | """ 84 | 85 | def __str__(self): 86 | return self.entity 87 | 88 | 89 | @implements_to_string 90 | class ItemStep(Step): 91 | """ 92 | A step of processing an item of an attribute. 93 | 94 | ``entity`` is either a key or an index (e.g., it can be ``"created_at"`` 95 | if the current attribute is ``properties`` of a :class:`~.DictField` or 96 | ``0`` if the current attribute is ``items`` of a :class:`~.ArrayField`). 97 | 98 | :type entity: str or int 99 | """ 100 | 101 | def __str__(self): 102 | return repr(self.entity) 103 | 104 | 105 | @implements_to_string 106 | class SchemaGenerationException(Exception): 107 | """ 108 | Raised when a valid JSON schema can not be generated from a JSL object. 109 | 110 | Examples of such situation are the following: 111 | 112 | * A :class:`variable <.Var>` resolves to an integer but a 113 | :class:`.BaseField` expected; 114 | * All choices of :class:`.OneOfField` are variables and all resolve to ``None``. 115 | 116 | Note: this error can only happen if variables are used in a document or field 117 | description. 118 | 119 | :param str message: A message. 120 | """ 121 | 122 | def __init__(self, message): 123 | self.message = message 124 | """A message.""" 125 | self.steps = collections.deque() 126 | """ 127 | A deque of :class:`steps <.Step>`, ordered from the first (the least specific) 128 | to the last (the most specific). 129 | """ 130 | 131 | def _format_steps(self): 132 | if not self.steps: 133 | return '' 134 | parts = [] 135 | steps = iter(self.steps) 136 | parts.append(str(next(steps))) 137 | for step in steps: 138 | if isinstance(step, (DocumentStep, FieldStep)): 139 | parts.append(' -> {0}'.format(step)) 140 | elif isinstance(step, AttributeStep): 141 | parts.append('.{0}'.format(step)) 142 | elif isinstance(step, ItemStep): 143 | parts.append('[{0}]'.format(step)) 144 | return ''.join(parts) 145 | 146 | def __str__(self): 147 | rv = text_type(self.message) 148 | steps = self._format_steps() 149 | if steps: 150 | rv += u'\nSteps: {0}'.format(steps) 151 | return rv 152 | -------------------------------------------------------------------------------- /jsl/fields/__init__.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | from .base import * 3 | from .compound import * 4 | from .primitive import * 5 | 6 | -------------------------------------------------------------------------------- /jsl/fields/base.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | from ..exceptions import processing, FieldStep 3 | from ..resolutionscope import EMPTY_SCOPE 4 | from ..roles import Resolvable, Resolution, DEFAULT_ROLE 5 | 6 | 7 | __all__ = ['Null', 'BaseField', 'BaseSchemaField'] 8 | 9 | 10 | class NullSentinel(object): 11 | """A class which instance represents a null value. 12 | Allows specifying fields with a default value of null. 13 | """ 14 | 15 | def __bool__(self): 16 | return False 17 | 18 | __nonzero__ = __bool__ 19 | 20 | 21 | Null = NullSentinel() 22 | """ 23 | A special value that can be used to set the default value 24 | of a field to null. 25 | """ 26 | 27 | 28 | # make sure nobody creates another Null value 29 | def _failing_new(*args, **kwargs): 30 | raise TypeError('Can\'t create another NullSentinel instance') 31 | 32 | 33 | NullSentinel.__new__ = staticmethod(_failing_new) 34 | del _failing_new 35 | 36 | 37 | class BaseField(Resolvable): 38 | """A base class for fields of :class:`documents <.Document>`. 39 | Instances of this class may be added to a document to define its properties. 40 | 41 | Implements the :class:`.Resolvable` interface. 42 | 43 | :param required: 44 | Whether the field is required. Defaults to ``False``. 45 | :type required: bool or :class:`.Resolvable` 46 | :param str name: 47 | If specified, used as a key under which the field schema 48 | appears in :class:`document <.Document>` schema properties. 49 | 50 | .. versionadded:: 0.1.3 51 | """ 52 | 53 | def __init__(self, name=None, required=False, **kwargs): 54 | #: Name 55 | self.name = name 56 | #: Whether the field is required. 57 | self.required = required 58 | self._kwargs = kwargs 59 | 60 | def resolve(self, role): 61 | """ 62 | Implements the :class:`.Resolvable` interface. 63 | 64 | Always returns a ``Resolution(self, role)``. 65 | 66 | :rtype: :class:`.Resolution` 67 | """ 68 | return Resolution(self, role) 69 | 70 | def iter_possible_values(self): 71 | """Implements the :class:`.Resolvable` interface. 72 | 73 | Yields a single value -- ``self``. 74 | """ 75 | yield self 76 | 77 | def get_definitions_and_schema(self, role=DEFAULT_ROLE, res_scope=EMPTY_SCOPE, 78 | ordered=False, ref_documents=None): # pragma: no cover 79 | """Returns a tuple of two elements. 80 | 81 | The second element is a JSON schema of the data described by this field, 82 | and the first is a dictionary that contains definitions that are referenced 83 | from the schema. 84 | 85 | :param str role: A role. 86 | :param bool ordered: 87 | If ``True``, the resulting schema dictionary is ordered. Fields are 88 | listed in the order they are added to the class. Schema properties are 89 | also ordered in a sensible and consistent way, making the schema more 90 | human-readable. 91 | :param res_scope: 92 | The current resolution scope. 93 | :type res_scope: :class:`~.ResolutionScope` 94 | :param set ref_documents: 95 | If subclass of :class:`Document` is in this set, all :class:`DocumentField` s 96 | pointing to it will be resolved to a reference: ``{"$ref": "#/definitions/..."}``. 97 | Note: resulting definitions will not contain schema for this document. 98 | :raises: :class:`.SchemaGenerationException` 99 | :rtype: (dict, dict or OrderedDict) 100 | """ 101 | with processing(FieldStep(self, role=role)): 102 | definitions, schema = self._get_definitions_and_schema( 103 | role=role, res_scope=res_scope, ordered=ordered, ref_documents=ref_documents) 104 | return definitions, self._extend_schema(schema, role=role, res_scope=res_scope, 105 | ordered=ordered, ref_documents=ref_documents) 106 | 107 | def _extend_schema(self, schema, role, res_scope, ordered, ref_documents): 108 | return schema 109 | 110 | def _get_definitions_and_schema(self, role=DEFAULT_ROLE, res_scope=EMPTY_SCOPE, 111 | ordered=False, ref_documents=None): # pragma: no cover 112 | raise NotImplementedError 113 | 114 | def iter_fields(self): 115 | """Iterates over the nested fields of the document examining all 116 | possible values of the occuring :class:`resolvables <.Resolvable>`. 117 | """ 118 | return iter([]) 119 | 120 | def walk(self, through_document_fields=False, visited_documents=frozenset()): 121 | """Iterates recursively over the nested fields, examining all 122 | possible values of the occuring :class:`resolvables <.Resolvable>`. 123 | 124 | Visits fields in a DFS order. 125 | 126 | :param bool through_document_fields: 127 | If ``True``, walks through nested :class:`.DocumentField` fields. 128 | :param set visited_documents: 129 | Keeps track of visited :class:`documents <.Document>` to avoid infinite 130 | recursion when ``through_document_field`` is ``True``. 131 | :returns: iterable of :class:`.BaseField` 132 | """ 133 | yield self 134 | for field in self.iter_fields(): 135 | for field_ in field.walk(through_document_fields=through_document_fields, 136 | visited_documents=visited_documents): 137 | yield field_ 138 | 139 | def resolve_and_iter_fields(self, role=DEFAULT_ROLE): 140 | """The same as :meth:`.iter_fields`, but :class:`resolvables <.Resolvable>` 141 | are resolved using ``role``. 142 | """ 143 | return iter([]) 144 | 145 | def resolve_and_walk(self, role=DEFAULT_ROLE, through_document_fields=False, 146 | visited_documents=frozenset()): 147 | """The same as :meth:`.walk`, but :class:`resolvables <.Resolvable>` are 148 | resolved using ``role``. 149 | """ 150 | yield self 151 | for field in self.resolve_and_iter_fields(role=role): 152 | field, field_role = field.resolve(role) 153 | for field_ in field.resolve_and_walk(role=field_role, 154 | through_document_fields=through_document_fields, 155 | visited_documents=visited_documents): 156 | yield field_ 157 | 158 | def get_schema(self, ordered=False, role=DEFAULT_ROLE): 159 | """Returns a JSON schema (draft v4) of the field. 160 | 161 | :param str role: A role. 162 | :param bool ordered: 163 | If ``True``, the resulting schema dictionary is ordered. Fields are 164 | listed in the order they are added to the class. Schema properties are 165 | also ordered in a sensible and consistent way, making the schema more 166 | human-readable. 167 | :raises: :class:`.SchemaGenerationException` 168 | :rtype: dict or OrderedDict 169 | """ 170 | definitions, schema = self.get_definitions_and_schema(ordered=ordered, role=role) 171 | if definitions: 172 | schema['definitions'] = definitions 173 | return schema 174 | 175 | def resolve_attr(self, attr, role=DEFAULT_ROLE): 176 | """ 177 | Resolves an attribure with the name ``field`` using ``role``. 178 | 179 | If the value of ``attr`` is :class:`resolvable <.Resolvable>`, 180 | it resolves it using a given ``role`` and returns the result. 181 | Otherwise it returns the raw value and ``role`` unchanged. 182 | 183 | :raises: :class:`AttributeError` 184 | :rtype: :class:`.Resolution` 185 | """ 186 | value = getattr(self, attr) 187 | if isinstance(value, Resolvable): 188 | return value.resolve(role) 189 | return Resolution(value, role) 190 | 191 | 192 | class BaseSchemaField(BaseField): 193 | """A base class for fields that directly map to JSON Schema validator. 194 | 195 | :param required: 196 | If the field is required. Defaults to ``False``. 197 | :type required: bool or :class:`.Resolvable` 198 | :param str id: 199 | A string to be used as a value of the `"id" keyword`_ of the resulting schema. 200 | :param default: 201 | The default value for this field. May be :data:`.Null` (a special value 202 | to set the default value to null) or a callable. 203 | :type default: any JSON-representable object, a callable or a :class:`.Resolvable` 204 | :param enum: 205 | A list of valid choices. May be a callable. 206 | :type enum: list, tuple, set, callable or :class:`.Resolvable` 207 | :param title: 208 | A short explanation about the purpose of the data described by this field. 209 | :type title: str or :class:`.Resolvable` 210 | :param description: 211 | A detailed explanation about the purpose of the data described by this field. 212 | :type description: str or :class:`.Resolvable` 213 | 214 | .. _"id" keyword: https://tools.ietf.org/html/draft-zyp-json-schema-04#section-7.2 215 | """ 216 | 217 | def __init__(self, id='', default=None, enum=None, title=None, description=None, **kwargs): 218 | #: A string to be used as a value of the `"id" keyword`_ of the resulting schema. 219 | self.id = id 220 | #: A short explanation about the purpose of the data. 221 | self.title = title 222 | #: A detailed explanation about the purpose of the data. 223 | self.description = description 224 | self._enum = enum 225 | self._default = default 226 | super(BaseSchemaField, self).__init__(**kwargs) 227 | 228 | def get_enum(self, role=DEFAULT_ROLE): 229 | """Returns a list to be used as a value of the ``"enum"`` schema keyword.""" 230 | enum = self.resolve_attr('_enum', role).value 231 | if callable(enum): 232 | enum = enum() 233 | return enum 234 | 235 | def get_default(self, role=DEFAULT_ROLE): 236 | """Returns a value of the ``"default"`` schema keyword.""" 237 | default = self.resolve_attr('_default', role).value 238 | if callable(default): 239 | default = default() 240 | return default 241 | 242 | def _get_definitions_and_schema(self, role=DEFAULT_ROLE, res_scope=EMPTY_SCOPE, 243 | ordered=False, ref_documents=None): # pragma: no cover 244 | raise NotImplementedError 245 | 246 | def _update_schema_with_common_fields(self, schema, id='', role=DEFAULT_ROLE): 247 | if id: 248 | schema['id'] = id 249 | title = self.resolve_attr('title', role).value 250 | if title is not None: 251 | schema['title'] = title 252 | description = self.resolve_attr('description', role).value 253 | if description is not None: 254 | schema['description'] = description 255 | enum = self.get_enum(role=role) 256 | if enum: 257 | schema['enum'] = list(enum) 258 | default = self.get_default(role=role) 259 | if default is not None: 260 | if default is Null: 261 | default = None 262 | schema['default'] = default 263 | return schema 264 | -------------------------------------------------------------------------------- /jsl/fields/compound.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | import itertools 3 | 4 | from .. import registry 5 | from ..roles import DEFAULT_ROLE, Resolvable 6 | from ..resolutionscope import EMPTY_SCOPE 7 | from ..exceptions import SchemaGenerationException, processing, AttributeStep, ItemStep 8 | from .._compat import iteritems, iterkeys, itervalues, string_types, OrderedDict 9 | from .base import BaseSchemaField, BaseField 10 | from .util import validate_regex 11 | 12 | 13 | __all__ = [ 14 | 'ArrayField', 'DictField', 'OneOfField', 'AnyOfField', 'AllOfField', 15 | 'NotField', 'DocumentField', 'RefField', 'RECURSIVE_REFERENCE_CONSTANT' 16 | ] 17 | 18 | RECURSIVE_REFERENCE_CONSTANT = 'self' 19 | 20 | 21 | class ArrayField(BaseSchemaField): 22 | """An array field. 23 | 24 | :param items: 25 | Either of the following: 26 | 27 | * :class:`BaseField` -- all items of the array must match the field schema; 28 | * a list or a tuple of :class:`fields <.BaseField>` -- all items of the array must be 29 | valid according to the field schema at the corresponding index (tuple typing); 30 | * a :class:`.Resolvable` resolving to either of the first two options. 31 | 32 | :param min_items: 33 | A minimum length of an array. 34 | :type min_items: int or :class:`.Resolvable` 35 | :param max_items: 36 | A maximum length of an array. 37 | :type max_items: int or :class:`.Resolvable` 38 | :param unique_items: 39 | Whether all the values in the array must be distinct. 40 | :type unique_items: bool or :class:`.Resolvable` 41 | :param additional_items: 42 | If the value of ``items`` is a list or a tuple, and the array length is larger than 43 | the number of fields in ``items``, then the additional items are described 44 | by the :class:`.BaseField` passed using this argument. 45 | :type additional_items: bool or :class:`.BaseField` or :class:`.Resolvable` 46 | """ 47 | 48 | def __init__(self, items=None, additional_items=None, 49 | min_items=None, max_items=None, unique_items=None, **kwargs): 50 | self.items = items #: 51 | self.min_items = min_items #: 52 | self.max_items = max_items #: 53 | self.unique_items = unique_items #: 54 | self.additional_items = additional_items #: 55 | super(ArrayField, self).__init__(**kwargs) 56 | 57 | def _get_definitions_and_schema(self, role=DEFAULT_ROLE, res_scope=EMPTY_SCOPE, 58 | ordered=False, ref_documents=None): 59 | id, res_scope = res_scope.alter(self.id) 60 | schema = (OrderedDict if ordered else dict)(type='array') 61 | schema = self._update_schema_with_common_fields(schema, id=id, role=role) 62 | nested_definitions = {} 63 | 64 | items, items_role = self.resolve_attr('items', role) 65 | if items is not None: 66 | with processing(AttributeStep('items', role=role)): 67 | if isinstance(items, (list, tuple)): 68 | items_schema = [] 69 | for i, item in enumerate(items): 70 | with processing(ItemStep(i, role=items_role)): 71 | if not isinstance(item, Resolvable): 72 | raise SchemaGenerationException(u'{0} is not resolvable'.format(item)) 73 | item, item_role = item.resolve(items_role) 74 | if item is None: 75 | continue 76 | item_definitions, item_schema = item.get_definitions_and_schema( 77 | role=item_role, res_scope=res_scope, 78 | ordered=ordered, ref_documents=ref_documents) 79 | nested_definitions.update(item_definitions) 80 | items_schema.append(item_schema) 81 | if not items_schema: 82 | raise SchemaGenerationException(u'Items tuple is empty') 83 | elif isinstance(items, BaseField): 84 | items_definitions, items_schema = items.get_definitions_and_schema( 85 | role=items_role, res_scope=res_scope, ordered=ordered, 86 | ref_documents=ref_documents) 87 | nested_definitions.update(items_definitions) 88 | else: 89 | raise SchemaGenerationException( 90 | u'{0} is not a BaseField, a list or a tuple'.format(items)) 91 | schema['items'] = items_schema 92 | 93 | additional_items, additional_items_role = self.resolve_attr('additional_items', role) 94 | if additional_items is not None: 95 | with processing(AttributeStep('additional_items', role=role)): 96 | if isinstance(additional_items, bool): 97 | schema['additionalItems'] = additional_items 98 | elif isinstance(additional_items, BaseField): 99 | items_definitions, items_schema = additional_items.get_definitions_and_schema( 100 | role=additional_items_role, res_scope=res_scope, 101 | ordered=ordered, ref_documents=ref_documents) 102 | schema['additionalItems'] = items_schema 103 | nested_definitions.update(items_definitions) 104 | else: 105 | raise SchemaGenerationException( 106 | u'{0} is not a BaseField or a boolean'.format(additional_items)) 107 | 108 | min_items = self.resolve_attr('min_items', role).value 109 | if min_items is not None: 110 | schema['minItems'] = min_items 111 | max_items = self.resolve_attr('max_items', role).value 112 | if max_items is not None: 113 | schema['maxItems'] = max_items 114 | unique_items = self.resolve_attr('unique_items', role).value 115 | if unique_items is not None: 116 | schema['uniqueItems'] = unique_items 117 | return nested_definitions, schema 118 | 119 | def iter_fields(self): 120 | rv = [] 121 | if isinstance(self.items, (list, tuple)): 122 | for item in self.items: 123 | if isinstance(item, Resolvable): 124 | rv.append(item.iter_possible_values()) 125 | elif isinstance(self.items, Resolvable): 126 | for items_value in self.items.iter_possible_values(): 127 | if isinstance(items_value, (list, tuple)): 128 | for item in items_value: 129 | if isinstance(item, Resolvable): 130 | rv.append(item.iter_possible_values()) 131 | else: 132 | if isinstance(items_value, Resolvable): 133 | rv.append(items_value.iter_possible_values()) 134 | if isinstance(self.additional_items, Resolvable): 135 | rv.append(self.additional_items.iter_possible_values()) 136 | return itertools.chain.from_iterable(rv) 137 | 138 | def resolve_and_iter_fields(self, role=DEFAULT_ROLE): 139 | items, items_role = self.resolve_attr('items', role) 140 | if isinstance(items, (list, tuple)): 141 | for item in items: 142 | if isinstance(item, Resolvable): 143 | item_value = item.resolve(items_role).value 144 | if isinstance(item_value, BaseField): 145 | yield item_value 146 | elif isinstance(items, Resolvable): 147 | yield items 148 | additional_items = self.resolve_attr('additional_items', role).value 149 | if isinstance(additional_items, BaseField): 150 | yield additional_items 151 | 152 | 153 | class DictField(BaseSchemaField): 154 | """A dictionary field. 155 | 156 | :param properties: 157 | A dictionary containing fields. 158 | :type properties: dict[str -> :class:`.BaseField` or :class:`.Resolvable`] 159 | :param pattern_properties: 160 | A dictionary whose keys are regular expressions (ECMA 262). 161 | Properties match against these regular expressions, and for any that match, 162 | the property is described by the corresponding field schema. 163 | :type pattern_properties: dict[str -> :class:`.BaseField` or :class:`.Resolvable`] 164 | :param additional_properties: 165 | Describes properties that are not described by the ``properties`` or ``pattern_properties``. 166 | :type additional_properties: bool or :class:`.BaseField` or :class:`.Resolvable` 167 | :param min_properties: 168 | A minimum number of properties. 169 | :type min_properties: int or :class:`.Resolvable` 170 | :param max_properties: 171 | A maximum number of properties 172 | :type max_properties: int or :class:`.Resolvable` 173 | """ 174 | 175 | def __init__(self, properties=None, pattern_properties=None, additional_properties=None, 176 | min_properties=None, max_properties=None, **kwargs): 177 | self.properties = properties #: 178 | self.pattern_properties = pattern_properties #: 179 | self.additional_properties = additional_properties #: 180 | self.min_properties = min_properties #: 181 | self.max_properties = max_properties #: 182 | super(DictField, self).__init__(**kwargs) 183 | 184 | def _process_properties(self, attr, properties, res_scope, ordered=False, 185 | ref_documents=None, role=DEFAULT_ROLE): 186 | if attr == 'properties': 187 | key_getter = self._get_property_key 188 | elif attr == 'pattern_properties': 189 | key_getter = self._get_pattern_property_key 190 | else: 191 | raise ValueError('attr must be either "properties" or "pattern_properties"') # pragma: no cover 192 | nested_definitions = {} 193 | schema = OrderedDict() if ordered else {} 194 | required = [] 195 | for prop, field in iteritems(properties): 196 | with processing(ItemStep(prop, role=role)): 197 | if not isinstance(field, Resolvable): 198 | raise SchemaGenerationException(u'{0} is not resolvable'.format(field)) 199 | field, field_role = field.resolve(role) 200 | if field is None: 201 | continue 202 | field_definitions, field_schema = field.get_definitions_and_schema( 203 | role=field_role, res_scope=res_scope, 204 | ordered=ordered, ref_documents=ref_documents) 205 | key = key_getter(prop, field) 206 | if field.resolve_attr('required', field_role).value: 207 | required.append(key) 208 | schema[key] = field_schema 209 | nested_definitions.update(field_definitions) 210 | return nested_definitions, required, schema 211 | 212 | def _get_property_key(self, prop, field): 213 | return prop 214 | 215 | def _get_pattern_property_key(self, prop, field): 216 | return prop 217 | 218 | def _update_schema_with_processed_properties(self, schema, nested_definitions, 219 | role=DEFAULT_ROLE, res_scope=EMPTY_SCOPE, 220 | ordered=False, ref_documents=None): 221 | with processing(AttributeStep('properties', role=role)): 222 | properties, properties_role = self.resolve_attr('properties', role) 223 | if properties is not None: 224 | if not isinstance(properties, dict): 225 | raise SchemaGenerationException(u'{0} is not a dict'.format(properties)) 226 | properties_definitions, properties_required, properties_schema = \ 227 | self._process_properties('properties', properties, res_scope, 228 | ordered=ordered, ref_documents=ref_documents, 229 | role=properties_role) 230 | schema['properties'] = properties_schema 231 | if properties_required: 232 | schema['required'] = properties_required 233 | nested_definitions.update(properties_definitions) 234 | 235 | def _update_schema_with_processed_pattern_properties(self, schema, nested_definitions, 236 | role=DEFAULT_ROLE, res_scope=EMPTY_SCOPE, 237 | ordered=False, ref_documents=None): 238 | with processing(AttributeStep('pattern_properties', role=role)): 239 | pattern_properties, pattern_properties_role = \ 240 | self.resolve_attr('pattern_properties', role) 241 | if pattern_properties is not None: 242 | if not isinstance(pattern_properties, dict): 243 | raise SchemaGenerationException(u'{0} is not a dict'.format(pattern_properties)) 244 | for key in iterkeys(pattern_properties): 245 | try: 246 | validate_regex(key) 247 | except ValueError as e: 248 | raise SchemaGenerationException(u'Invalid regexp: {0}'.format(e)) 249 | properties_definitions, _, properties_schema = self._process_properties( 250 | 'pattern_properties', pattern_properties, res_scope, 251 | ordered=ordered, ref_documents=ref_documents, 252 | role=pattern_properties_role) 253 | schema['patternProperties'] = properties_schema 254 | nested_definitions.update(properties_definitions) 255 | 256 | def _update_schema_with_processed_additional_properties(self, schema, nested_definitions, 257 | role=DEFAULT_ROLE, res_scope=EMPTY_SCOPE, 258 | ordered=False, ref_documents=None): 259 | with processing(AttributeStep('additional_properties', role=role)): 260 | additional_properties, additional_properties_role = \ 261 | self.resolve_attr('additional_properties', role) 262 | if additional_properties is not None: 263 | if isinstance(additional_properties, bool): 264 | schema['additionalProperties'] = additional_properties 265 | elif isinstance(additional_properties, BaseField): 266 | additional_properties_definitions, additional_properties_schema = \ 267 | additional_properties.get_definitions_and_schema( 268 | role=additional_properties_role, res_scope=res_scope, 269 | ordered=ordered, ref_documents=ref_documents) 270 | schema['additionalProperties'] = additional_properties_schema 271 | nested_definitions.update(additional_properties_definitions) 272 | else: 273 | raise SchemaGenerationException( 274 | u'{0} is not a BaseField or a boolean'.format(additional_properties)) 275 | 276 | def _get_definitions_and_schema(self, role=DEFAULT_ROLE, res_scope=EMPTY_SCOPE, 277 | ordered=False, ref_documents=None): 278 | id, res_scope = res_scope.alter(self.id) 279 | schema = (OrderedDict if ordered else dict)(type='object') 280 | schema = self._update_schema_with_common_fields(schema, id=id, role=role) 281 | nested_definitions = {} 282 | 283 | for f in ( 284 | self._update_schema_with_processed_properties, 285 | self._update_schema_with_processed_pattern_properties, 286 | self._update_schema_with_processed_additional_properties, 287 | ): 288 | f(schema, nested_definitions, role=role, res_scope=res_scope, 289 | ordered=ordered, ref_documents=ref_documents) 290 | 291 | min_properties = self.resolve_attr('min_properties', role).value 292 | if min_properties is not None: 293 | schema['minProperties'] = min_properties 294 | max_properties = self.resolve_attr('max_properties', role).value 295 | if max_properties is not None: 296 | schema['maxProperties'] = max_properties 297 | 298 | return nested_definitions, schema 299 | 300 | def iter_fields(self): 301 | def _extract_resolvables(dict_or_resolvable): 302 | rv = [] 303 | possible_dicts = [] 304 | if isinstance(dict_or_resolvable, Resolvable): 305 | possible_dicts = dict_or_resolvable.iter_possible_values() 306 | elif isinstance(dict_or_resolvable, dict): 307 | possible_dicts = [dict_or_resolvable] 308 | for possible_dict in possible_dicts: 309 | rv.extend(v for v in itervalues(possible_dict) if v is not None) 310 | return rv 311 | 312 | resolvables = _extract_resolvables(self.properties) 313 | resolvables.extend(_extract_resolvables(self.pattern_properties)) 314 | if isinstance(self.additional_properties, Resolvable): 315 | resolvables.append(self.additional_properties) 316 | return itertools.chain.from_iterable(r.iter_possible_values() for r in resolvables) 317 | 318 | def resolve_and_iter_fields(self, role=DEFAULT_ROLE): 319 | properties, properties_role = self.resolve_attr('properties', role) 320 | if properties is not None: 321 | for field in itervalues(properties): 322 | field = field.resolve(properties_role).value 323 | if isinstance(field, BaseField): 324 | yield field 325 | pattern_properties, pattern_properties_role = \ 326 | self.resolve_attr('pattern_properties', role) 327 | if pattern_properties is not None: 328 | for field in itervalues(pattern_properties): 329 | field = field.resolve(pattern_properties_role).value 330 | if isinstance(field, BaseField): 331 | yield field 332 | additional_properties = self.resolve_attr('additional_properties', role).value 333 | if isinstance(additional_properties, BaseField): 334 | yield additional_properties 335 | 336 | 337 | class BaseOfField(BaseSchemaField): 338 | _KEYWORD = None 339 | 340 | def __init__(self, fields, **kwargs): 341 | self.fields = fields #: 342 | super(BaseOfField, self).__init__(**kwargs) 343 | 344 | def _get_definitions_and_schema(self, role=DEFAULT_ROLE, res_scope=EMPTY_SCOPE, 345 | ordered=False, ref_documents=None): 346 | id, res_scope = res_scope.alter(self.id) 347 | schema = OrderedDict() if ordered else {} 348 | schema = self._update_schema_with_common_fields(schema, id=id) 349 | nested_definitions = {} 350 | 351 | one_of = [] 352 | with processing(AttributeStep('fields', role=role)): 353 | fields, fields_role = self.resolve_attr('fields', role) 354 | if not isinstance(fields, (list, tuple)): 355 | raise SchemaGenerationException(u'{0} is not a list or a tuple'.format(fields)) 356 | for i, field in enumerate(fields): 357 | with processing(ItemStep(i, role=fields_role)): 358 | if not isinstance(field, Resolvable): 359 | raise SchemaGenerationException(u'{0} is not resolvable'.format(field)) 360 | field, field_role = field.resolve(fields_role) 361 | if field is None: 362 | continue 363 | if not isinstance(field, BaseField): 364 | raise SchemaGenerationException(u'{0} is not a BaseField.'.format(field)) 365 | field_definitions, field_schema = field.get_definitions_and_schema( 366 | role=field_role, res_scope=res_scope, 367 | ordered=ordered, ref_documents=ref_documents) 368 | nested_definitions.update(field_definitions) 369 | one_of.append(field_schema) 370 | if not one_of: 371 | raise SchemaGenerationException(u'Fields list is empty') 372 | schema[self._KEYWORD] = one_of 373 | return nested_definitions, schema 374 | 375 | def iter_fields(self): 376 | resolvables = [] 377 | if isinstance(self.fields, (list, tuple)): 378 | resolvables.extend(self.fields) 379 | if isinstance(self.fields, Resolvable): 380 | for fields in self.fields.iter_possible_values(): 381 | if isinstance(fields, (list, tuple)): 382 | resolvables.extend(fields) 383 | elif isinstance(fields, Resolvable): 384 | resolvables.append(fields) 385 | return itertools.chain.from_iterable(r.iter_possible_values() for r in resolvables) 386 | 387 | def resolve_and_iter_fields(self, role=DEFAULT_ROLE): 388 | fields, fields_role = self.resolve_attr('fields', role) 389 | for field in fields: 390 | field = field.resolve(fields_role).value 391 | if isinstance(field, BaseField): 392 | yield field 393 | 394 | 395 | class OneOfField(BaseOfField): 396 | """ 397 | :param fields: A list of fields, exactly one of which describes the data. 398 | :type fields: list[:class:`.BaseField` or :class:`.Resolvable`] 399 | 400 | .. attribute:: fields 401 | :annotation: = None 402 | """ 403 | _KEYWORD = 'oneOf' 404 | 405 | 406 | class AnyOfField(BaseOfField): 407 | """ 408 | :param fields: A list of fields, at least one of which describes the data. 409 | :type fields: list[:class:`.BaseField` or :class:`.Resolvable`] 410 | 411 | .. attribute:: fields 412 | :annotation: = None 413 | """ 414 | _KEYWORD = 'anyOf' 415 | 416 | 417 | class AllOfField(BaseOfField): 418 | """ 419 | :param fields: A list of fields, all of which describe the data. 420 | :type fields: list[:class:`.BaseField` or :class:`.Resolvable`] 421 | 422 | .. attribute:: fields 423 | :annotation: = None 424 | """ 425 | _KEYWORD = 'allOf' 426 | 427 | 428 | class NotField(BaseSchemaField): 429 | """ 430 | :param field: A field to negate. 431 | :type field: :class:`.BaseField` or :class:`.Resolvable` 432 | """ 433 | 434 | def __init__(self, field, **kwargs): 435 | self.field = field #: 436 | super(NotField, self).__init__(**kwargs) 437 | 438 | def iter_fields(self): 439 | return self.field.iter_possible_values() 440 | 441 | def resolve_and_iter_fields(self, role=DEFAULT_ROLE): 442 | field, field_role = self.resolve_attr('field', role) 443 | if isinstance(field, BaseField): 444 | yield field 445 | 446 | def _get_definitions_and_schema(self, role=DEFAULT_ROLE, res_scope=EMPTY_SCOPE, 447 | ordered=False, ref_documents=None): 448 | id, res_scope = res_scope.alter(self.id) 449 | schema = OrderedDict() if ordered else {} 450 | schema = self._update_schema_with_common_fields(schema, id=id, role=role) 451 | with processing(AttributeStep('field', role=role)): 452 | field, field_role = self.resolve_attr('field', role) 453 | if not isinstance(field, BaseField): 454 | raise SchemaGenerationException(u'{0} is not a BaseField.'.format(field)) 455 | field_definitions, field_schema = field.get_definitions_and_schema( 456 | role=field_role, res_scope=res_scope, 457 | ordered=ordered, ref_documents=ref_documents) 458 | schema['not'] = field_schema 459 | return field_definitions, schema 460 | 461 | 462 | class DocumentField(BaseField): 463 | """A reference to a nested document. 464 | 465 | :param document_cls: 466 | A string (dot-separated path to document class, i.e. ``"app.resources.User"``), 467 | :data:`RECURSIVE_REFERENCE_CONSTANT` or a :class:`.Document` subclass. 468 | :param bool as_ref: 469 | If ``True``, the schema of :attr:`document_cls`` is placed into the definitions 470 | dictionary, and the field schema just references to it: 471 | ``{"$ref": "#/definitions/..."}``. 472 | It may make a resulting schema more readable. 473 | """ 474 | 475 | def __init__(self, document_cls, as_ref=False, **kwargs): 476 | self._document_cls = document_cls 477 | #: A :class:`.Document` this field is attached to. 478 | self.owner_cls = None 479 | self.as_ref = as_ref #: 480 | super(DocumentField, self).__init__(**kwargs) 481 | 482 | def iter_fields(self): 483 | return self.document_cls.iter_fields() 484 | 485 | def walk(self, through_document_fields=False, visited_documents=frozenset()): 486 | yield self 487 | if through_document_fields: 488 | document_cls = self.document_cls 489 | if document_cls not in visited_documents: 490 | visited_documents = visited_documents | set([document_cls]) 491 | for field in document_cls.walk( 492 | through_document_fields=through_document_fields, 493 | visited_documents=visited_documents): 494 | yield field 495 | 496 | def resolve_and_walk(self, role=DEFAULT_ROLE, through_document_fields=False, 497 | visited_documents=frozenset()): 498 | yield self 499 | if through_document_fields: 500 | document_cls = self.document_cls 501 | new_role = DEFAULT_ROLE 502 | if self.owner_cls: 503 | if self.owner_cls._options.roles_to_propagate(role): 504 | new_role = role 505 | else: 506 | new_role = role 507 | if document_cls not in visited_documents: 508 | visited_documents = visited_documents | set([document_cls]) 509 | for field in document_cls.resolve_and_walk( 510 | role=new_role, 511 | through_document_fields=through_document_fields, 512 | visited_documents=visited_documents): 513 | yield field 514 | 515 | def _get_definitions_and_schema(self, role=DEFAULT_ROLE, res_scope=EMPTY_SCOPE, 516 | ordered=False, ref_documents=None): 517 | document_cls = self.document_cls 518 | definition_id = document_cls.get_definition_id(role=role) 519 | if ref_documents and document_cls in ref_documents: 520 | return {}, res_scope.create_ref(definition_id) 521 | else: 522 | new_role = DEFAULT_ROLE 523 | if self.owner_cls: 524 | if self.owner_cls._options.roles_to_propagate(role): 525 | new_role = role 526 | else: 527 | new_role = role 528 | document_definitions, document_schema = document_cls.get_definitions_and_schema( 529 | role=new_role, res_scope=res_scope, ordered=ordered, ref_documents=ref_documents) 530 | if self.as_ref and not document_cls.is_recursive(role=new_role): 531 | document_definitions[definition_id] = document_schema 532 | return document_definitions, res_scope.create_ref(definition_id) 533 | else: 534 | return document_definitions, document_schema 535 | 536 | @property 537 | def document_cls(self): 538 | """A :class:`.Document` this field points to.""" 539 | document_cls = self._document_cls 540 | if isinstance(document_cls, string_types): 541 | if document_cls == RECURSIVE_REFERENCE_CONSTANT: 542 | if self.owner_cls is None: 543 | raise ValueError('owner_cls is not set') 544 | document_cls = self.owner_cls 545 | else: 546 | try: 547 | document_cls = registry.get_document(document_cls) 548 | except KeyError: 549 | if self.owner_cls is None: 550 | raise ValueError('owner_cls is not set') 551 | document_cls = registry.get_document(document_cls, 552 | module=self.owner_cls.__module__) 553 | return document_cls 554 | 555 | 556 | class RefField(BaseField): 557 | """A reference. 558 | 559 | :param str pointer: 560 | A `JSON pointer`_. 561 | 562 | .. _JSON pointer: http://tools.ietf.org/html/draft-pbryan-zyp-json-pointer-02 563 | """ 564 | 565 | def __init__(self, pointer, **kwargs): 566 | self.pointer = pointer #: 567 | super(RefField, self).__init__(**kwargs) 568 | 569 | def _get_definitions_and_schema(self, role=DEFAULT_ROLE, res_scope=EMPTY_SCOPE, 570 | ordered=False, ref_documents=None): 571 | with processing(AttributeStep('pointer', role=role)): 572 | pointer, _ = self.resolve_attr('pointer', role) 573 | if not isinstance(pointer, string_types): 574 | raise SchemaGenerationException(u'{0} is not a string.'.format(pointer)) 575 | return {}, {'$ref': pointer} 576 | 577 | def walk(self, through_document_fields=False, visited_documents=frozenset()): 578 | yield self 579 | -------------------------------------------------------------------------------- /jsl/fields/primitive.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | from ..roles import DEFAULT_ROLE 3 | from ..resolutionscope import EMPTY_SCOPE 4 | from .._compat import OrderedDict 5 | from .base import BaseSchemaField 6 | from .util import validate, validate_regex 7 | 8 | 9 | __all__ = [ 10 | 'StringField', 'BooleanField', 'EmailField', 'IPv4Field', 'DateTimeField', 11 | 'UriField', 'NumberField', 'IntField', 'NullField' 12 | ] 13 | 14 | 15 | class BooleanField(BaseSchemaField): 16 | """A boolean field.""" 17 | 18 | def _get_definitions_and_schema(self, role=DEFAULT_ROLE, res_scope=EMPTY_SCOPE, 19 | ordered=False, ref_documents=None): 20 | id, res_scope = res_scope.alter(self.id) 21 | schema = (OrderedDict if ordered else dict)(type='boolean') 22 | schema = self._update_schema_with_common_fields(schema, id=id, role=role) 23 | return {}, schema 24 | 25 | 26 | class StringField(BaseSchemaField): 27 | """A string field. 28 | 29 | :param pattern: 30 | A regular expression (ECMA 262) that a string value must match. 31 | :type pattern: string or :class:`.Resolvable` 32 | :param format: 33 | A semantic format of the string (for example, ``"date-time"``, 34 | ``"email"``, or ``"uri"``). 35 | :type format: string or :class:`.Resolvable` 36 | :param min_length: 37 | A minimum length. 38 | :type min_length: int or :class:`.Resolvable` 39 | :param max_length: 40 | A maximum length. 41 | :type max_length: int or :class:`.Resolvable` 42 | """ 43 | _FORMAT = None 44 | 45 | def __init__(self, pattern=None, format=None, min_length=None, max_length=None, **kwargs): 46 | if pattern is not None: 47 | validate(pattern, validate_regex) 48 | self.pattern = pattern #: 49 | self.format = format or self._FORMAT #: 50 | self.min_length = min_length #: 51 | self.max_length = max_length #: 52 | super(StringField, self).__init__(**kwargs) 53 | 54 | def _get_definitions_and_schema(self, role=DEFAULT_ROLE, res_scope=EMPTY_SCOPE, 55 | ordered=False, ref_documents=None): 56 | id, res_scope = res_scope.alter(self.id) 57 | schema = (OrderedDict if ordered else dict)(type='string') 58 | schema = self._update_schema_with_common_fields(schema, id=id, role=role) 59 | 60 | pattern = self.resolve_attr('pattern', role).value 61 | if pattern: 62 | schema['pattern'] = pattern 63 | min_length = self.resolve_attr('min_length', role).value 64 | if min_length is not None: 65 | schema['minLength'] = min_length 66 | max_length = self.resolve_attr('max_length', role).value 67 | if max_length is not None: 68 | schema['maxLength'] = max_length 69 | format = self.resolve_attr('format', role).value 70 | if format is not None: 71 | schema['format'] = format 72 | return {}, schema 73 | 74 | 75 | class EmailField(StringField): 76 | """An email field.""" 77 | _FORMAT = 'email' 78 | 79 | 80 | class IPv4Field(StringField): 81 | """An IPv4 field.""" 82 | _FORMAT = 'ipv4' 83 | 84 | 85 | class DateTimeField(StringField): 86 | """An ISO 8601 formatted date-time field.""" 87 | _FORMAT = 'date-time' 88 | 89 | 90 | class UriField(StringField): 91 | """A URI field.""" 92 | _FORMAT = 'uri' 93 | 94 | 95 | class NumberField(BaseSchemaField): 96 | """A number field. 97 | 98 | :param multiple_of: 99 | A value must be a multiple of this factor. 100 | :type multiple_of: number or :class:`.Resolvable` 101 | :param minimum: 102 | A minimum allowed value. 103 | :type minimum: number or :class:`.Resolvable` 104 | :param exclusive_minimum: 105 | Whether a value is allowed to exactly equal the minimum. 106 | :type exclusive_minimum: bool or :class:`.Resolvable` 107 | :param maximum: 108 | A maximum allowed value. 109 | :type maximum: number or :class:`.Resolvable` 110 | :param exclusive_maximum: 111 | Whether a value is allowed to exactly equal the maximum. 112 | :type exclusive_maximum: bool or :class:`.Resolvable` 113 | """ 114 | _NUMBER_TYPE = 'number' 115 | 116 | def __init__(self, multiple_of=None, minimum=None, maximum=None, 117 | exclusive_minimum=None, exclusive_maximum=None, **kwargs): 118 | self.multiple_of = multiple_of #: 119 | self.minimum = minimum #: 120 | self.exclusive_minimum = exclusive_minimum #: 121 | self.maximum = maximum #: 122 | self.exclusive_maximum = exclusive_maximum #: 123 | super(NumberField, self).__init__(**kwargs) 124 | 125 | def _get_definitions_and_schema(self, role=DEFAULT_ROLE, res_scope=EMPTY_SCOPE, 126 | ordered=False, ref_documents=None): 127 | id, res_scope = res_scope.alter(self.id) 128 | schema = (OrderedDict if ordered else dict)(type=self._NUMBER_TYPE) 129 | schema = self._update_schema_with_common_fields(schema, id=id, role=role) 130 | multiple_of = self.resolve_attr('multiple_of', role).value 131 | if multiple_of is not None: 132 | schema['multipleOf'] = multiple_of 133 | minimum = self.resolve_attr('minimum', role).value 134 | if minimum is not None: 135 | schema['minimum'] = minimum 136 | exclusive_minimum = self.resolve_attr('exclusive_minimum', role).value 137 | if exclusive_minimum is not None: 138 | schema['exclusiveMinimum'] = exclusive_minimum 139 | maximum = self.resolve_attr('maximum', role).value 140 | if maximum is not None: 141 | schema['maximum'] = maximum 142 | exclusive_maximum = self.resolve_attr('exclusive_maximum', role).value 143 | if exclusive_maximum is not None: 144 | schema['exclusiveMaximum'] = exclusive_maximum 145 | return {}, schema 146 | 147 | 148 | class IntField(NumberField): 149 | """An integer field.""" 150 | _NUMBER_TYPE = 'integer' 151 | 152 | 153 | class NullField(BaseSchemaField): 154 | """A null field.""" 155 | 156 | def _get_definitions_and_schema(self, role=DEFAULT_ROLE, res_scope=EMPTY_SCOPE, 157 | ordered=False, ref_documents=None): 158 | id, res_scope = res_scope.alter(self.id) 159 | schema = (OrderedDict if ordered else dict)(type='null') 160 | schema = self._update_schema_with_common_fields(schema, id=id, role=role) 161 | return {}, schema 162 | -------------------------------------------------------------------------------- /jsl/fields/util.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | import re 3 | import sre_constants 4 | 5 | from ..roles import Resolvable 6 | 7 | 8 | def validate_regex(regex): 9 | """ 10 | :param str regex: A regular expression to validate. 11 | :raises: ValueError 12 | """ 13 | try: 14 | re.compile(regex) 15 | except sre_constants.error as e: 16 | raise ValueError('Invalid regular expression: {0}'.format(e)) 17 | 18 | 19 | def validate(value_or_var, validator): 20 | if isinstance(value_or_var, Resolvable): 21 | for value in value_or_var.iter_possible_values(): 22 | validator(value) 23 | else: 24 | validator(value_or_var) -------------------------------------------------------------------------------- /jsl/registry.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | from ._compat import itervalues 3 | 4 | 5 | _documents_registry = {} 6 | 7 | 8 | def get_document(name, module=None): 9 | if module: 10 | name = '{0}.{1}'.format(module, name) 11 | return _documents_registry[name] 12 | 13 | 14 | def put_document(name, document_cls, module=None): 15 | if module: 16 | name = '{0}.{1}'.format(module, name) 17 | _documents_registry[name] = document_cls 18 | 19 | 20 | def remove_document(name, module=None): 21 | if module: 22 | name = '{0}.{1}'.format(module, name) 23 | del _documents_registry[name] 24 | 25 | 26 | def iter_documents(): 27 | return itervalues(_documents_registry) 28 | 29 | 30 | def clear(): 31 | _documents_registry.clear() -------------------------------------------------------------------------------- /jsl/resolutionscope.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | from ._compat import urljoin, urldefrag 3 | 4 | 5 | class ResolutionScope(object): 6 | """ 7 | An utility class to help with translating ``id`` attributes of 8 | :class:`fields <.BaseSchemaField>` into JSON schema ``"id"`` properties. 9 | 10 | :param str base: 11 | A URI, a resolution scope of the outermost schema. 12 | :param str current: 13 | A URI, a resolution scope of the current schema. 14 | :param str output: 15 | A URI, an output part (expressed by parent schema id properties) scope of 16 | the current schema. 17 | """ 18 | def __init__(self, base='', current='', output=''): 19 | self._base, _ = urldefrag(base) 20 | self._current, _ = urldefrag(current) 21 | self._output, _ = urldefrag(output) 22 | 23 | base = property(lambda self: self._base) 24 | """A resolution scope of the outermost schema.""" 25 | current = property(lambda self: self._current) 26 | """A resolution scope of the current schema.""" 27 | output = property(lambda self: self._output) 28 | """An output part (expressed by parent schema id properties) scope of 29 | the current schema. 30 | """ 31 | 32 | def __repr__(self): 33 | return 'ResolutionScope(\n base={0},\n current={1},\n output={2}\n)'.format( 34 | self._base, self._current, self._output) 35 | 36 | def replace(self, current=None, output=None): 37 | """Returns a copy of the scope with the ``current`` and ``output`` 38 | scopes replaced. 39 | """ 40 | return ResolutionScope( 41 | base=self._base, 42 | current=self._current if current is None else current, 43 | output=self._output if output is None else output 44 | ) 45 | 46 | def alter(self, field_id): 47 | """Returns a pair, where the first element is the identifier to be used 48 | as a value for the ``"id"`` JSON schema field and the second is 49 | a new :class:`.ResolutionScope` to be used when visiting the nested fields 50 | of the field with id ``field_id``. 51 | 52 | :rtype: (str, :class:`.ResolutionScope`) 53 | """ 54 | new_current = urljoin(self._current or self._base, field_id) 55 | if new_current.startswith(self._output): 56 | schema_id = new_current[len(self._output):] 57 | else: 58 | schema_id = new_current 59 | return schema_id, self.replace(current=new_current, output=new_current) 60 | 61 | def create_ref(self, definition_id): 62 | """Returns a reference (``{"$ref": ...}``) relative to the base scope.""" 63 | ref = '{0}#/definitions/{1}'.format( 64 | self._base if self._current and self._base != self._current else '', 65 | definition_id 66 | ) 67 | return {'$ref': ref} 68 | 69 | 70 | EMPTY_SCOPE = ResolutionScope() 71 | """An empty :class:`.ResolutionScope`.""" 72 | -------------------------------------------------------------------------------- /jsl/roles.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | import collections 3 | 4 | from ._compat import OrderedDict, iteritems, string_types 5 | 6 | 7 | __all__ = ['all_', 'not_', 'Var', 'Scope', 'DEFAULT_ROLE'] 8 | 9 | DEFAULT_ROLE = 'default' 10 | """A default role.""" 11 | 12 | 13 | def all_(role): 14 | """ 15 | A matcher that always returns ``True``. 16 | 17 | :rtype: bool 18 | """ 19 | return True 20 | 21 | 22 | def not_(*roles): 23 | """ 24 | Returns a matcher that returns ``True`` for all roles 25 | except those are listed as arguments. 26 | 27 | :rtype: callable 28 | """ 29 | return lambda role: role not in roles 30 | 31 | 32 | def construct_matcher(matcher): 33 | if callable(matcher): 34 | return matcher 35 | elif isinstance(matcher, string_types): 36 | return lambda r: r == matcher 37 | elif isinstance(matcher, collections.Iterable): 38 | choices = frozenset(matcher) 39 | return lambda r: r in choices 40 | else: 41 | raise ValueError( 42 | 'Unknown matcher type {} ({!r}). Only callables, ' 43 | 'strings and iterables are supported.'.format(type(matcher), matcher) 44 | ) 45 | 46 | 47 | Resolution = collections.namedtuple('Resolution', ['value', 'role']) 48 | """ 49 | A resolution result, a :class:`~collections.namedtuple`. 50 | 51 | .. attribute:: value 52 | 53 | A resolved value (the first element). 54 | 55 | .. attribute:: role 56 | 57 | A role to be used for visiting nested objects (the second element). 58 | """ 59 | 60 | 61 | class Resolvable(object): 62 | """An interface that represents an object which value varies 63 | depending on a role. 64 | """ 65 | 66 | def resolve(self, role): # pragma: no cover 67 | """ 68 | Returns a value for a given ``role``. 69 | 70 | :param str role: A role. 71 | :returns: A :class:`resolution <.Resolution>`. 72 | """ 73 | raise NotImplementedError 74 | 75 | def iter_possible_values(self): # pragma: no cover 76 | """Iterates over all possible values except ``None`` ones.""" 77 | raise NotImplementedError 78 | 79 | 80 | class Var(Resolvable): 81 | """ 82 | A :class:`.Resolvable` implementation. 83 | 84 | :param values: 85 | A dictionary or a list of key-value pairs, where keys are matchers 86 | and values are corresponding values. 87 | 88 | Matchers are callables returning boolean values. Strings and 89 | iterables are also accepted and processed as follows: 90 | 91 | * A string ``s`` will be replaced with a lambda ``lambda r: r == s``; 92 | * An iterable ``i`` will be replaced with a lambda ``lambda r: r in i``. 93 | :type values: dict or list of pairs 94 | 95 | :param default: 96 | A value to return if all matchers returned ``False``. 97 | 98 | :param propagate: 99 | A matcher that determines which roles are to be propagated down 100 | to the nested objects. Default is :data:`all_` that matches 101 | all roles. 102 | :type propagate: callable, string or iterable 103 | """ 104 | 105 | def __init__(self, values=None, default=None, propagate=all_): 106 | self._values = [] 107 | if values is not None: 108 | values = iteritems(values) if isinstance(values, dict) else values 109 | for matcher, value in values: 110 | matcher = construct_matcher(matcher) 111 | self._values.append((matcher, value)) 112 | self.default = default 113 | self._propagate = construct_matcher(propagate) 114 | 115 | @property 116 | def values(self): 117 | """A list of pairs (matcher, value).""" 118 | return self._values 119 | 120 | @property 121 | def propagate(self): 122 | """A matcher that determines which roles are to be propagated down 123 | to the nested objects. 124 | """ 125 | return self._propagate 126 | 127 | def iter_possible_values(self): 128 | """ 129 | Implements the :class:`.Resolvable` interface. 130 | 131 | Yields non-``None`` values from :attr:`values`. 132 | """ 133 | return (v for _, v in self._values if v is not None) 134 | 135 | def resolve(self, role): 136 | """ 137 | Implements the :class:`.Resolvable` interface. 138 | 139 | :param str role: A role. 140 | :returns: 141 | A :class:`resolution <.Resolution>`, 142 | 143 | which value is the first value which matcher returns ``True`` and 144 | the role is either a given ``role`` (if :attr:`propagate`` matcher 145 | returns ``True``) or :data:`.DEFAULT_ROLE` (otherwise). 146 | """ 147 | for matcher, matcher_value in self._values: 148 | if matcher(role): 149 | value = matcher_value 150 | break 151 | else: 152 | value = self.default 153 | new_role = role if self._propagate(role) else DEFAULT_ROLE 154 | return Resolution(value, new_role) 155 | 156 | 157 | class Scope(object): 158 | """ 159 | A scope consists of a set of fields and a matcher. 160 | Fields can be added to a scope as attributes:: 161 | 162 | scope = Scope('response') 163 | scope.name = StringField() 164 | scope.age = IntField() 165 | 166 | A scope can then be added to a :class:`~.Document`. 167 | During a document class construction process, fields of each of its scopes 168 | are added to the resulting class as :class:`variables <.Var>` which only 169 | resolve to fields when the matcher of the scope returns ``True``. 170 | 171 | If two fields with the same name are assigned to different document scopes, 172 | the matchers of the corresponding :class:`~.Var` will be the matchers of the 173 | scopes in order they were added to the class. 174 | 175 | :class:`.Scope` can also be used as a context manager. At the moment it 176 | does not do anything and only useful as a syntactic sugar -- to introduce 177 | an extra indentation level for the fields defined within the same scope. 178 | 179 | For example:: 180 | 181 | class User(Document): 182 | with Scope('db_role') as db: 183 | db._id = StringField(required=True) 184 | db.version = StringField(required=True) 185 | with Scope('response_role') as db: 186 | db.version = IntField(required=True) 187 | 188 | Is an equivalent of:: 189 | 190 | class User(Document): 191 | db._id = Var([ 192 | ('db_role', StringField(required=True)) 193 | ]) 194 | db.version = Var([ 195 | ('db_role', StringField(required=True)) 196 | ('response_role', IntField(required=True)) 197 | ]) 198 | 199 | 200 | :param matcher: A matcher. 201 | :type matcher: callable, string or iterable 202 | 203 | .. attribute:: __field__ 204 | 205 | An ordered dictionary of :class:`fields <.BaseField>`. 206 | 207 | .. attribute:: __matcher__ 208 | 209 | A matcher. 210 | """ 211 | 212 | def __init__(self, matcher): 213 | # names are chosen to avoid clashing with user field names 214 | super(Scope, self).__setattr__('__fields__', OrderedDict()) 215 | super(Scope, self).__setattr__('__matcher__', matcher) 216 | 217 | def __getattr__(self, key): 218 | odict = super(Scope, self).__getattribute__('__fields__') 219 | if key in odict: 220 | return odict[key] 221 | return super(Scope, self).__getattribute__(key) 222 | 223 | def __setattr__(self, key, val): 224 | self.__fields__[key] = val 225 | 226 | def __enter__(self): 227 | return self 228 | 229 | def __exit__(self, exc_type, exc_val, exc_tb): 230 | pass 231 | -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | jsonschema==2.4.0 2 | coverage==3.7.1 3 | pytest==2.6.4 4 | pytest-cov==1.8.0 5 | Sphinx==1.3.1 6 | sphinx-rtd-theme==0.1.8 7 | mock==1.0.1 -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import sys 4 | import re 5 | 6 | from setuptools import setup, find_packages 7 | 8 | 9 | __version__ = '' 10 | 11 | with open('jsl/__init__.py', 'r') as fd: 12 | reg = re.compile(r'__version__ = [\'"]([^\'"]*)[\'"]') 13 | for line in fd: 14 | m = reg.match(line) 15 | if m: 16 | __version__ = m.group(1) 17 | break 18 | 19 | 20 | if not __version__: 21 | raise RuntimeError('Cannot find version information') 22 | 23 | 24 | if sys.argv[-1] in ('submit', 'publish'): 25 | os.system('python setup.py sdist upload') 26 | sys.exit() 27 | 28 | 29 | setup( 30 | name='jsl', 31 | version=__version__, 32 | description='A Python DSL for defining JSON schemas', 33 | long_description=open('README.rst').read(), 34 | license='BSD', 35 | author='Anton Romanovich', 36 | author_email='anthony.romanovich@gmail.com', 37 | url='https://jsl.readthedocs.io', 38 | packages=find_packages(exclude=['tests']), 39 | classifiers=[ 40 | 'Development Status :: 4 - Beta', 41 | 'Intended Audience :: Developers', 42 | 'License :: OSI Approved :: BSD License', 43 | 'Operating System :: OS Independent', 44 | 'Programming Language :: Python', 45 | 'Programming Language :: Python :: 2', 46 | 'Programming Language :: Python :: 2.6', 47 | 'Programming Language :: Python :: 2.7', 48 | 'Programming Language :: Python :: 3', 49 | 'Programming Language :: Python :: 3.3', 50 | 'Programming Language :: Python :: 3.4', 51 | 'Programming Language :: Python :: Implementation :: CPython', 52 | 'Programming Language :: Python :: Implementation :: PyPy', 53 | 'Topic :: Software Development :: Libraries :: Python Modules', 54 | ], 55 | ) 56 | -------------------------------------------------------------------------------- /test.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | PYTHONPATH=.:$PYTHONPATH py.test -s --tb=short --showlocals \ 3 | --cov-report term-missing --cov jsl ./tests "$@" -------------------------------------------------------------------------------- /tests/test_document.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | import jsonschema 3 | 4 | from jsl.roles import Scope, Resolution 5 | from jsl.document import Document 6 | from jsl.fields import ( 7 | RECURSIVE_REFERENCE_CONSTANT, StringField, IntField, DocumentField, 8 | DateTimeField, ArrayField, OneOfField) 9 | from jsl._compat import OrderedDict, iterkeys 10 | 11 | from util import normalize 12 | 13 | 14 | def test_to_schema(): 15 | class User(Document): 16 | class Options(object): 17 | additional_properties = True 18 | title = 'User' 19 | 20 | id = IntField(required=True) 21 | 22 | class Resource(Document): 23 | task_id = IntField(required=True) 24 | user = DocumentField(User, required=True) 25 | 26 | class Task(Document): 27 | class Options(object): 28 | title = 'Task' 29 | description = 'A task.' 30 | definition_id = 'task' 31 | 32 | name = StringField(required=True, min_length=5) 33 | type = StringField(required=True, enum=['TYPE_1', 'TYPE_2']) 34 | resources = ArrayField(DocumentField(Resource)) 35 | created_at = DateTimeField(name='created-at', required=True) 36 | author = DocumentField(User) 37 | 38 | assert Resource.get_definition_id() == 'test_document.Resource' 39 | assert Task.get_definition_id() == 'task' 40 | 41 | expected_task_schema = { 42 | '$schema': 'http://json-schema.org/draft-04/schema#', 43 | 'type': 'object', 44 | 'title': 'Task', 45 | 'description': 'A task.', 46 | 'additionalProperties': False, 47 | 'required': ['created-at', 'name', 'type'], 48 | 'properties': { 49 | 'created-at': Task.created_at.get_schema(), 50 | 'type': Task.type.get_schema(), 51 | 'name': Task.name.get_schema(), 52 | 'resources': Task.resources.get_schema(), 53 | 'author': Task.author.get_schema(), 54 | } 55 | } 56 | assert normalize(Task.get_schema()) == expected_task_schema 57 | 58 | 59 | def test_document_options(): 60 | class Parent(Document): 61 | class Options(object): 62 | title = 'Parent' 63 | additional_properties = True 64 | 65 | class Child(Parent): 66 | class Options(object): 67 | title = 'Child' 68 | 69 | assert Parent._options.additional_properties 70 | assert Child._options.title == 'Child' 71 | assert Child._options.additional_properties 72 | 73 | 74 | def test_document_fields_order(): 75 | class Letters(Document): 76 | z = StringField() 77 | x = StringField() 78 | a = StringField() 79 | b = StringField() 80 | c = StringField() 81 | y = StringField() 82 | d = StringField() 83 | e = StringField() 84 | 85 | order = ''.join(Letters.get_schema(ordered=True)['properties']) 86 | expected_order = 'zxabcyde' 87 | assert order == expected_order 88 | 89 | 90 | def test_recursive_definitions_1(): 91 | class A(Document): 92 | class Options(object): 93 | id = 'http://example.com/schema/' 94 | 95 | id = StringField() 96 | b = DocumentField('B') 97 | 98 | class B(Document): 99 | class Options(object): 100 | id = 'segment/' 101 | 102 | a = DocumentField(A) 103 | b = DocumentField('B') 104 | c = DocumentField('C') 105 | 106 | class C(Document): 107 | class Options(object): 108 | id = 'segment2/' 109 | 110 | a = DocumentField(A) 111 | d = DocumentField('D') 112 | 113 | class D(Document): 114 | class Options(object): 115 | id = '#hash' 116 | 117 | id = StringField() 118 | 119 | expected_schema = { 120 | 'id': 'http://example.com/schema/', 121 | '$schema': 'http://json-schema.org/draft-04/schema#', 122 | 'definitions': { 123 | 'test_document.A': { 124 | 'type': 'object', 125 | 'additionalProperties': False, 126 | 'properties': { 127 | 'id': {'type': 'string'}, 128 | 'b': {'$ref': '#/definitions/test_document.B'}, 129 | }, 130 | }, 131 | 'test_document.B': { 132 | 'id': 'segment/', 133 | 'type': 'object', 134 | 'additionalProperties': False, 135 | 'properties': { 136 | 'a': {'$ref': 'http://example.com/schema/#/definitions/test_document.A'}, 137 | 'b': {'$ref': 'http://example.com/schema/#/definitions/test_document.B'}, 138 | 'c': {'$ref': 'http://example.com/schema/#/definitions/test_document.C'}, 139 | }, 140 | }, 141 | 'test_document.C': { 142 | 'id': 'segment/segment2/', 143 | 'type': 'object', 144 | 'additionalProperties': False, 145 | 'properties': { 146 | 'a': {'$ref': 'http://example.com/schema/#/definitions/test_document.A'}, 147 | 'd': { 148 | 'id': '#hash', 149 | 'type': 'object', 150 | 'additionalProperties': False, 151 | 'properties': { 152 | 'id': {'type': 'string'}, 153 | }, 154 | }, 155 | }, 156 | }, 157 | }, 158 | '$ref': '#/definitions/test_document.A', 159 | } 160 | schema = A.get_schema(ordered=True) 161 | assert isinstance(schema, OrderedDict) 162 | assert normalize(schema) == normalize(expected_schema) 163 | assert list(iterkeys(schema)) == ['id', '$schema', 'definitions', '$ref'] 164 | 165 | definitions = schema['definitions'] 166 | assert isinstance(definitions, OrderedDict) 167 | assert list(definitions.keys()) == ['test_document.A', 'test_document.B', 'test_document.C'] 168 | 169 | # let's make sure that all the references in resulting schema 170 | # can be resolved 171 | jsonschema.validate({ 172 | 'id': 'test', 173 | 'b': { 174 | 'a': {'id': 'qqq'}, 175 | 'b': {}, 176 | 'c': { 177 | 'd': {'id': 'www'} 178 | } 179 | } 180 | }, schema) 181 | 182 | 183 | def test_recursive_definitions_2(): 184 | class A(Document): 185 | class Options(object): 186 | id = 'http://example.com/schema' 187 | 188 | b = DocumentField('B') 189 | 190 | class B(Document): 191 | class Options(object): 192 | id = 'http://aromanovich.ru/schema_1' 193 | 194 | c = DocumentField('C') 195 | 196 | class C(Document): 197 | class Options(object): 198 | id = 'schema_2' 199 | 200 | d = DocumentField('D') 201 | a = DocumentField(A) 202 | 203 | class D(Document): 204 | class Options(object): 205 | id = '#hash' 206 | 207 | expected_schema = { 208 | 'id': 'http://example.com/schema', 209 | '$schema': 'http://json-schema.org/draft-04/schema#', 210 | 'definitions': { 211 | 'test_document.A': { 212 | 'type': 'object', 213 | 'additionalProperties': False, 214 | 'properties': { 215 | 'b': {'$ref': '#/definitions/test_document.B'}, 216 | }, 217 | }, 218 | 'test_document.B': { 219 | 'id': 'http://aromanovich.ru/schema_1', 220 | 'type': 'object', 221 | 'additionalProperties': False, 222 | 'properties': { 223 | 'c': {'$ref': 'http://example.com/schema#/definitions/test_document.C'}, 224 | }, 225 | }, 226 | 'test_document.C': { 227 | 'id': 'http://aromanovich.ru/schema_2', 228 | 'type': 'object', 229 | 'additionalProperties': False, 230 | 'properties': { 231 | 'a': {'$ref': 'http://example.com/schema#/definitions/test_document.A'}, 232 | 'd': { 233 | 'id': '#hash', 234 | 'type': 'object', 235 | 'additionalProperties': False, 236 | 'properties': {}, 237 | }, 238 | }, 239 | }, 240 | }, 241 | '$ref': '#/definitions/test_document.A', 242 | } 243 | schema = A.get_schema() 244 | assert normalize(schema) == normalize(expected_schema) 245 | 246 | # let's make sure that all the references in resulting schema 247 | # can be resolved 248 | jsonschema.validate({ 249 | 'b': { 250 | 'c': { 251 | 'a': {}, 252 | 'd': {} 253 | } 254 | } 255 | }, schema) 256 | 257 | 258 | def test_recursive_definitions_3(): 259 | class Main(Document): 260 | a = DocumentField('test_document.A') 261 | b = DocumentField('B') 262 | 263 | class A(Document): 264 | name = StringField() 265 | a = DocumentField('A', as_ref=True) 266 | 267 | class B(Document): 268 | c = DocumentField('C') 269 | 270 | class C(Document): 271 | name = StringField() 272 | c = DocumentField('C') 273 | 274 | expected_schema = { 275 | '$schema': 'http://json-schema.org/draft-04/schema#', 276 | 'definitions': { 277 | 'test_document.A': { 278 | 'type': 'object', 279 | 'additionalProperties': False, 280 | 'properties': { 281 | 'a': {'$ref': '#/definitions/test_document.A'}, 282 | 'name': {'type': 'string'} 283 | }, 284 | }, 285 | 'test_document.C': { 286 | 'type': 'object', 287 | 'additionalProperties': False, 288 | 'properties': { 289 | 'c': {'$ref': '#/definitions/test_document.C'}, 290 | 'name': {'type': 'string'} 291 | }, 292 | } 293 | }, 294 | 'type': 'object', 295 | 'additionalProperties': False, 296 | 'properties': { 297 | 'a': {'$ref': '#/definitions/test_document.A'}, 298 | 'b': { 299 | 'type': 'object', 300 | 'additionalProperties': False, 301 | 'properties': { 302 | 'c': { 303 | '$ref': '#/definitions/test_document.C' 304 | } 305 | }, 306 | } 307 | }, 308 | } 309 | assert normalize(Main.get_schema()) == normalize(expected_schema) 310 | 311 | 312 | def test_recursive_definitions_4(): 313 | class Main(Document): 314 | a = DocumentField('A', as_ref=True) 315 | 316 | class A(Document): 317 | name = StringField() 318 | b = DocumentField('B', as_ref=True) 319 | 320 | class B(Document): 321 | c = DocumentField(Main) 322 | 323 | expected_definitions = { 324 | 'test_document.A': { 325 | 'type': 'object', 326 | 'additionalProperties': False, 327 | 'properties': { 328 | 'b': {'$ref': '#/definitions/test_document.B'}, 329 | 'name': {'type': 'string'} 330 | }, 331 | }, 332 | 'test_document.B': { 333 | 'type': 'object', 334 | 'additionalProperties': False, 335 | 'properties': { 336 | 'c': {'$ref': '#/definitions/test_document.Main'}, 337 | }, 338 | }, 339 | 'test_document.Main': { 340 | 'type': 'object', 341 | 'additionalProperties': False, 342 | 'properties': { 343 | 'a': {'$ref': '#/definitions/test_document.A'}, 344 | }, 345 | } 346 | } 347 | expected_schema = { 348 | '$schema': 'http://json-schema.org/draft-04/schema#', 349 | 'definitions': expected_definitions, 350 | '$ref': '#/definitions/test_document.Main', 351 | } 352 | assert normalize(Main.get_schema()) == normalize(expected_schema) 353 | 354 | class X(Document): 355 | name = StringField() 356 | 357 | class Z(Document): 358 | main_or_x = OneOfField([ 359 | DocumentField(Main), 360 | DocumentField(X) 361 | ]) 362 | 363 | expected_schema = { 364 | '$schema': 'http://json-schema.org/draft-04/schema#', 365 | 'definitions': expected_definitions, 366 | 'type': 'object', 367 | 'additionalProperties': False, 368 | 'properties': { 369 | 'main_or_x': { 370 | 'oneOf': [ 371 | {'$ref': '#/definitions/test_document.Main'}, 372 | { 373 | 'type': 'object', 374 | 'additionalProperties': False, 375 | 'properties': {'name': {'type': 'string'}}, 376 | } 377 | ] 378 | } 379 | }, 380 | } 381 | assert normalize(Z.get_schema()) == normalize(expected_schema) 382 | 383 | 384 | def test_recursive_definitions_5(): 385 | # regression test for https://github.com/aromanovich/jsl/issues/14 386 | class Test(Document): 387 | class Options(object): 388 | definition_id = 'test' 389 | with Scope('test') as test: 390 | test.field = DocumentField(RECURSIVE_REFERENCE_CONSTANT) 391 | 392 | assert normalize(Test.get_schema(role='test')) == normalize({ 393 | '$schema': 'http://json-schema.org/draft-04/schema#', 394 | '$ref': '#/definitions/test', 395 | 'definitions': { 396 | 'test': { 397 | 'additionalProperties': False, 'type': 'object', 398 | 'properties': { 399 | 'field': { 400 | '$ref': '#/definitions/test' 401 | } 402 | } 403 | } 404 | }, 405 | }) 406 | 407 | 408 | def test_recursive_definitions_6(): 409 | # regression test for https://github.com/aromanovich/jsl/issues/16 410 | 411 | class Children(Document): 412 | class Options(object): 413 | definition_id = 'children' 414 | children = OneOfField([ 415 | DocumentField('A',), 416 | ]) 417 | 418 | class A(Document): 419 | class Options(object): 420 | definition_id = 'a' 421 | derived_from = DocumentField(Children, as_ref=True) 422 | 423 | assert normalize(A.get_schema()) == normalize({ 424 | '$schema': 'http://json-schema.org/draft-04/schema#', 425 | 'definitions': { 426 | 'a': { 427 | 'type': 'object', 428 | 'properties': { 429 | 'derived_from': { 430 | '$ref': '#/definitions/children', 431 | }, 432 | }, 433 | 'additionalProperties': False, 434 | }, 435 | 'children': { 436 | 'type': 'object', 437 | 'properties': { 438 | 'children': { 439 | 'oneOf': [{'$ref': '#/definitions/a'}], 440 | }, 441 | }, 442 | 'additionalProperties': False, 443 | }, 444 | }, 445 | '$ref': '#/definitions/a', 446 | }) 447 | 448 | 449 | def test_resolve_field(): 450 | class X(Document): 451 | with Scope('role_1') as s_1: 452 | s_1.name = StringField() 453 | with Scope('role_2') as s_2: 454 | s_2.name = StringField() 455 | 456 | assert X.resolve_field('name', 'xxx') == Resolution(None, 'xxx') 457 | assert X.resolve_field('name', 'role_1') == Resolution(X.s_1.name, 'role_1') 458 | assert X.resolve_field('name', 'role_2') == Resolution(X.s_2.name, 'role_2') 459 | -------------------------------------------------------------------------------- /tests/test_documentmeta.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | import mock 3 | 4 | from jsl import fields 5 | from jsl._compat import iteritems, with_metaclass 6 | from jsl.document import Document, DocumentMeta, Options 7 | 8 | 9 | class OptionsStub(Options): 10 | """An options container that allows storing extra options.""" 11 | def __init__(self, a=None, b=None, c=None, d=None, **kwargs): 12 | super(OptionsStub, self).__init__(**kwargs) 13 | self.a = a 14 | self.b = b 15 | self.c = c 16 | self.d = d 17 | 18 | 19 | def test_collect_fields_and_options(): 20 | with mock.patch.object(DocumentMeta, 'options_container', wraps=OptionsStub): 21 | class ParentOne(Document): 22 | a = fields.StringField() 23 | b = fields.IntField() 24 | c = fields.NumberField() 25 | 26 | class Options(object): 27 | a = 1 28 | b = 1 29 | c = 1 30 | 31 | class ParentTwo(Document): 32 | b = fields.DictField() 33 | 34 | class Options: 35 | b = 2 36 | d = 2 37 | 38 | bases = (ParentTwo, ParentOne) 39 | attrs = { 40 | 'c': fields.BooleanField(), 41 | 'd': fields.BooleanField(), 42 | } 43 | 44 | fields_dict = DocumentMeta.collect_fields(bases, attrs) 45 | assert fields_dict == { 46 | 'a': ParentOne.a, 47 | 'b': ParentTwo.b, 48 | 'c': attrs['c'], 49 | 'd': attrs['d'], 50 | } 51 | 52 | options_dict = DocumentMeta.collect_options(bases, attrs) 53 | for expected_key, expected_value in iteritems({ 54 | 'a': 1, 55 | 'b': 2, 56 | 'c': 1, 57 | 'd': 2 58 | }): 59 | assert options_dict[expected_key] == expected_value 60 | 61 | 62 | def test_overriding_options_container(): 63 | class ParameterOptions(Options): 64 | def __init__(self, repeated=None, location=None, annotations=None, **kwargs): 65 | super(ParameterOptions, self).__init__(**kwargs) 66 | self.repeated = repeated 67 | self.location = location 68 | self.annotations = annotations 69 | 70 | class ParameterMeta(DocumentMeta): 71 | options_container = ParameterOptions 72 | 73 | class Parameter(with_metaclass(ParameterMeta, Document)): 74 | class Options(object): 75 | repeated = True 76 | location = 'query' 77 | title = 'Parameter' 78 | 79 | assert Parameter._options.repeated 80 | assert Parameter._options.location == 'query' 81 | assert Parameter._options.title == 'Parameter' 82 | -------------------------------------------------------------------------------- /tests/test_errors.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from jsl.document import Document 4 | from jsl.fields import (ArrayField, StringField, IntField, BaseSchemaField, 5 | DictField, OneOfField, AnyOfField, AllOfField, NotField, 6 | DocumentField) 7 | from jsl.roles import DEFAULT_ROLE, Var 8 | from jsl.exceptions import (SchemaGenerationException, FieldStep, AttributeStep, 9 | ItemStep, DocumentStep) 10 | from jsl.resolutionscope import EMPTY_SCOPE 11 | 12 | 13 | class FieldStub(BaseSchemaField): 14 | ERROR_MESSAGE = 'FieldStub error' 15 | 16 | def get_definitions_and_schema(self, role=DEFAULT_ROLE, res_scope=EMPTY_SCOPE, 17 | ordered=False, ref_documents=None): 18 | raise SchemaGenerationException(self.ERROR_MESSAGE) 19 | 20 | 21 | def test_exceptions(): 22 | f_1 = StringField() 23 | f_2 = StringField() 24 | 25 | # test __eq__ and __ne__ 26 | assert FieldStep(f_1) == FieldStep(f_1) 27 | assert FieldStep(f_1, role='role_1') != FieldStep(f_1) 28 | assert FieldStep(f_1) != FieldStep(f_2) 29 | assert FieldStep(f_1) != AttributeStep('fields') 30 | assert not (FieldStep(f_1) == AttributeStep('fields')) 31 | 32 | # test __repr__ 33 | r = repr(FieldStep(f_1, role='role_1')) 34 | assert repr(f_1) in r 35 | assert 'role_1' in r 36 | 37 | message = 'Something went wrong' 38 | e = SchemaGenerationException(message) 39 | assert str(e) == message 40 | 41 | step = FieldStep(f_1) 42 | e.steps.appendleft(step) 43 | assert str(e) == '{0}\nSteps: {1}'.format(message, step) 44 | 45 | 46 | def test_error(): 47 | db_role_friends_field = ArrayField((StringField(), None)) 48 | request_role_friends_field = ArrayField(StringField()) 49 | 50 | class User(Document): 51 | login = StringField() 52 | friends = ArrayField(Var({ 53 | 'db_role': db_role_friends_field, 54 | 'request_role': request_role_friends_field, 55 | })) 56 | 57 | class Users(Document): 58 | users = ArrayField(DocumentField(User)) 59 | 60 | Users.get_schema() 61 | 62 | role = 'db_role' 63 | with pytest.raises(SchemaGenerationException) as e: 64 | Users.get_schema(role=role) 65 | e = e.value 66 | 67 | assert list(e.steps) == [ 68 | DocumentStep(Users, role=role), 69 | FieldStep(Users._backend, role=role), 70 | AttributeStep('properties', role=role), 71 | ItemStep('users', role=role), 72 | FieldStep(Users.users, role=role), 73 | AttributeStep('items', role=role), 74 | FieldStep(Users.users.items, role=role), 75 | DocumentStep(User, role=role), 76 | FieldStep(User._backend, role=role), 77 | AttributeStep('properties', role=role), 78 | ItemStep('friends', role=role), 79 | FieldStep(User.friends, role=role), 80 | AttributeStep('items', role=role), 81 | FieldStep(db_role_friends_field, role=role), 82 | AttributeStep('items', role=role), 83 | ItemStep(1, role=role) 84 | ] 85 | assert e.message == 'None is not resolvable' 86 | assert ("Steps: Users -> DocumentBackend.properties['users'] -> " 87 | "ArrayField.items -> DocumentField -> User -> " 88 | "DocumentBackend.properties['friends'] -> ArrayField.items -> " 89 | "ArrayField.items[1]") in str(e) 90 | 91 | 92 | def test_array_field(): 93 | f = ArrayField(items=()) 94 | with pytest.raises(SchemaGenerationException) as e: 95 | f.get_schema() 96 | assert list(e.value.steps) == [FieldStep(f), AttributeStep('items')] 97 | 98 | f = ArrayField(items=( 99 | Var({'role_x': StringField()}), 100 | Var({'role_x': IntField()}), 101 | )) 102 | role = 'role_y' 103 | with pytest.raises(SchemaGenerationException) as e: 104 | f.get_schema(role='role_y') 105 | assert list(e.value.steps) == [FieldStep(f, role=role), AttributeStep('items', role=role)] 106 | 107 | f = ArrayField(items=(None, None)) 108 | with pytest.raises(SchemaGenerationException) as e: 109 | f.get_schema() 110 | assert list(e.value.steps) == [FieldStep(f), AttributeStep('items'), ItemStep(0)] 111 | 112 | f = ArrayField(items=object()) 113 | with pytest.raises(SchemaGenerationException) as e: 114 | f.get_schema() 115 | assert list(e.value.steps) == [FieldStep(f), AttributeStep('items')] 116 | 117 | f = ArrayField(additional_items=object()) 118 | with pytest.raises(SchemaGenerationException) as e: 119 | f.get_schema() 120 | assert list(e.value.steps) == [FieldStep(f), AttributeStep('additional_items')] 121 | 122 | f = ArrayField(items=FieldStub()) 123 | with pytest.raises(SchemaGenerationException) as e: 124 | f.get_schema() 125 | e = e.value 126 | assert e.message == FieldStub.ERROR_MESSAGE 127 | assert list(e.steps) == [FieldStep(f), AttributeStep('items')] 128 | 129 | f = ArrayField(items=(FieldStub(),)) 130 | with pytest.raises(SchemaGenerationException) as e: 131 | f.get_schema() 132 | e = e.value 133 | assert e.message == FieldStub.ERROR_MESSAGE 134 | assert list(e.steps) == [FieldStep(f), AttributeStep('items'), ItemStep(0)] 135 | 136 | f = ArrayField(additional_items=FieldStub()) 137 | with pytest.raises(SchemaGenerationException) as e: 138 | f.get_schema() 139 | e = e.value 140 | assert e.message == FieldStub.ERROR_MESSAGE 141 | assert list(e.steps) == [FieldStep(f), AttributeStep('additional_items')] 142 | 143 | 144 | def test_dict_field(): 145 | f = DictField(properties={'a': object()}) 146 | with pytest.raises(SchemaGenerationException) as e: 147 | f.get_schema() 148 | e = e.value 149 | assert 'not resolvable' in e.message 150 | assert list(e.steps) == [FieldStep(f), AttributeStep('properties'), ItemStep('a')] 151 | 152 | f = DictField(pattern_properties={'a.*': object()}) 153 | with pytest.raises(SchemaGenerationException) as e: 154 | f.get_schema() 155 | e = e.value 156 | assert 'not resolvable' in e.message 157 | assert list(e.steps) == [FieldStep(f), AttributeStep('pattern_properties'), ItemStep('a.*')] 158 | 159 | f = DictField(additional_properties=object()) 160 | with pytest.raises(SchemaGenerationException) as e: 161 | f.get_schema() 162 | e = e.value 163 | assert 'not a BaseField or a bool' in e.message 164 | assert list(e.steps) == [FieldStep(f), AttributeStep('additional_properties')] 165 | 166 | f = DictField(properties={'a': FieldStub()}) 167 | with pytest.raises(SchemaGenerationException) as e: 168 | f.get_schema() 169 | e = e.value 170 | assert e.message == FieldStub.ERROR_MESSAGE 171 | assert list(e.steps) == [FieldStep(f), AttributeStep('properties'), ItemStep('a')] 172 | 173 | f = DictField(pattern_properties={'a.*': FieldStub()}) 174 | with pytest.raises(SchemaGenerationException) as e: 175 | f.get_schema() 176 | e = e.value 177 | assert e.message == FieldStub.ERROR_MESSAGE 178 | assert list(e.steps) == [FieldStep(f), AttributeStep('pattern_properties'), ItemStep('a.*')] 179 | 180 | f = DictField(additional_properties=FieldStub()) 181 | with pytest.raises(SchemaGenerationException) as e: 182 | f.get_schema() 183 | e = e.value 184 | assert e.message == FieldStub.ERROR_MESSAGE 185 | assert list(e.steps) == [FieldStep(f), AttributeStep('additional_properties')] 186 | 187 | for kwarg_value in (object(), Var({'role_x': object()})): 188 | for kwarg in ('properties', 'pattern_properties'): 189 | f = DictField(**{kwarg: kwarg_value}) 190 | with pytest.raises(SchemaGenerationException) as e: 191 | f.get_schema(role='role_x') 192 | e = e.value 193 | assert 'not a dict' in e.message 194 | assert list(e.steps) == [FieldStep(f, role='role_x'), 195 | AttributeStep(kwarg, role='role_x')] 196 | 197 | f = DictField(additional_properties=kwarg_value) 198 | with pytest.raises(SchemaGenerationException) as e: 199 | f.get_schema(role='role_x') 200 | e = e.value 201 | assert 'not a BaseField or a bool' in e.message 202 | assert list(e.steps) == [FieldStep(f, role='role_x'), 203 | AttributeStep('additional_properties', role='role_x')] 204 | 205 | f = DictField(pattern_properties={'((((': StringField()}) 206 | with pytest.raises(SchemaGenerationException) as e: 207 | f.get_schema() 208 | e = e.value 209 | assert 'unbalanced parenthesis' in e.message 210 | assert list(e.steps) == [FieldStep(f), AttributeStep('pattern_properties')] 211 | 212 | 213 | @pytest.mark.parametrize('field_cls', [OneOfField, AnyOfField, AllOfField]) 214 | def test_keyword_of_fields(field_cls): 215 | f = field_cls(object()) 216 | with pytest.raises(SchemaGenerationException) as e: 217 | f.get_schema() 218 | e = e.value 219 | assert 'not a list or a tuple' in e.message 220 | assert list(e.steps) == [FieldStep(f), AttributeStep('fields')] 221 | 222 | f = field_cls([]) 223 | with pytest.raises(SchemaGenerationException) as e: 224 | f.get_schema() 225 | e = e.value 226 | assert 'empty' in e.message 227 | assert list(e.steps) == [FieldStep(f), AttributeStep('fields')] 228 | 229 | f = field_cls([object()]) 230 | with pytest.raises(SchemaGenerationException) as e: 231 | f.get_schema() 232 | e = e.value 233 | assert 'not resolvable' in e.message 234 | assert list(e.steps) == [FieldStep(f), AttributeStep('fields'), ItemStep(0)] 235 | 236 | role = 'role_x' 237 | f = field_cls([Var({role: object()})]) 238 | with pytest.raises(SchemaGenerationException) as e: 239 | f.get_schema(role=role) 240 | e = e.value 241 | assert 'not a BaseField' in e.message 242 | assert list(e.steps) == [FieldStep(f, role), 243 | AttributeStep('fields', role), 244 | ItemStep(0, role)] 245 | 246 | with pytest.raises(SchemaGenerationException) as e: 247 | f.get_schema() 248 | e = e.value 249 | assert 'empty' in e.message 250 | assert list(e.steps) == [FieldStep(f), AttributeStep('fields')] 251 | 252 | # test nested field errors 253 | f = field_cls([FieldStub()]) 254 | with pytest.raises(SchemaGenerationException) as e: 255 | f.get_schema() 256 | e = e.value 257 | assert e.message == FieldStub.ERROR_MESSAGE 258 | assert list(e.steps) == [FieldStep(f), AttributeStep('fields'), ItemStep(0)] 259 | 260 | 261 | def test_not_field(): 262 | for f in [NotField(object()), NotField(Var({'role_x': object()}))]: 263 | with pytest.raises(SchemaGenerationException) as e: 264 | f.get_schema() 265 | e = e.value 266 | assert 'not a BaseField' in e.message 267 | assert list(e.steps) == [FieldStep(f), AttributeStep('field')] 268 | -------------------------------------------------------------------------------- /tests/test_fields.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | import mock 3 | import pytest 4 | 5 | from jsl import fields, Null 6 | from jsl.fields.base import NullSentinel 7 | from jsl.document import Document 8 | from jsl._compat import OrderedDict 9 | 10 | from util import normalize 11 | 12 | 13 | def test_null_sentinel(): 14 | assert not Null 15 | with pytest.raises(TypeError): 16 | NullSentinel() 17 | 18 | 19 | def test_base_schema_field(): 20 | f = fields.BaseSchemaField() 21 | assert not f.required 22 | assert not f.get_default() 23 | assert not f.get_enum() 24 | 25 | assert f._update_schema_with_common_fields({}, id='qwerty') == { 26 | 'id': 'qwerty', 27 | } 28 | 29 | f = fields.BaseSchemaField(default=fields.Null) 30 | assert not f.get_default() 31 | assert f._update_schema_with_common_fields({}, id='qwerty') == { 32 | 'id': 'qwerty', 33 | 'default': None, 34 | } 35 | 36 | f = fields.BaseSchemaField(required=True) 37 | assert f.required 38 | 39 | f = fields.BaseSchemaField(default=123) 40 | assert f.get_default() == 123 41 | 42 | f = fields.BaseSchemaField(default=lambda: 123) 43 | assert f.get_default() == 123 44 | 45 | f = fields.BaseSchemaField(title='Title', description='Description', 46 | enum=lambda: [1, 2, 3], default=lambda: 123) 47 | assert f.title == 'Title' 48 | 49 | assert f._update_schema_with_common_fields({}, id='qwerty') == { 50 | 'id': 'qwerty', 51 | 'title': 'Title', 52 | 'description': 'Description', 53 | 'enum': [1, 2, 3], 54 | 'default': 123, 55 | } 56 | 57 | 58 | def test_string_field(): 59 | f = fields.StringField() 60 | definitions, schema = f.get_definitions_and_schema() 61 | assert normalize(schema) == {'type': 'string'} 62 | 63 | f = fields.StringField(min_length=1, max_length=10, pattern='^test$', 64 | enum=('a', 'b', 'c'), title='Pururum') 65 | 66 | expected_items = [ 67 | ('type', 'string'), 68 | ('title', 'Pururum'), 69 | ('enum', ['a', 'b', 'c']), 70 | ('pattern', '^test$'), 71 | ('minLength', 1), 72 | ('maxLength', 10), 73 | ] 74 | definitions, schema = f.get_definitions_and_schema() 75 | assert normalize(schema) == dict(expected_items) 76 | definitions, ordered_schema = f.get_definitions_and_schema(ordered=True) 77 | assert isinstance(ordered_schema, OrderedDict) 78 | assert normalize(ordered_schema) == OrderedDict(expected_items) 79 | 80 | with pytest.raises(ValueError) as e: 81 | fields.StringField(pattern='(') 82 | assert str(e.value) == 'Invalid regular expression: unbalanced parenthesis' 83 | 84 | 85 | def test_string_derived_fields(): 86 | f = fields.EmailField() 87 | definitions, schema = f.get_definitions_and_schema() 88 | assert normalize(schema) == { 89 | 'type': 'string', 90 | 'format': 'email', 91 | } 92 | 93 | f = fields.IPv4Field() 94 | definitions, schema = f.get_definitions_and_schema() 95 | assert normalize(schema) == { 96 | 'type': 'string', 97 | 'format': 'ipv4', 98 | } 99 | 100 | f = fields.DateTimeField() 101 | definitions, schema = f.get_definitions_and_schema() 102 | assert normalize(schema) == { 103 | 'type': 'string', 104 | 'format': 'date-time', 105 | } 106 | 107 | f = fields.UriField() 108 | definitions, schema = f.get_definitions_and_schema() 109 | assert normalize(schema) == { 110 | 'type': 'string', 111 | 'format': 'uri', 112 | } 113 | 114 | 115 | def test_number_and_int_fields(): 116 | f = fields.NumberField(multiple_of=10) 117 | definitions, schema = f.get_definitions_and_schema() 118 | assert normalize(schema) == { 119 | 'type': 'number', 120 | 'multipleOf': 10, 121 | } 122 | 123 | f = fields.NumberField(minimum=0, maximum=10, 124 | exclusive_minimum=True, exclusive_maximum=True) 125 | definitions, schema = f.get_definitions_and_schema() 126 | assert normalize(schema) == { 127 | 'type': 'number', 128 | 'exclusiveMinimum': True, 129 | 'exclusiveMaximum': True, 130 | 'minimum': 0, 131 | 'maximum': 10, 132 | } 133 | 134 | f = fields.NumberField(enum=(1, 2, 3)) 135 | definitions, schema = f.get_definitions_and_schema() 136 | assert normalize(schema) == { 137 | 'type': 'number', 138 | 'enum': [1, 2, 3], 139 | } 140 | 141 | f = fields.IntField() 142 | definitions, schema = f.get_definitions_and_schema() 143 | assert normalize(schema) == { 144 | 'type': 'integer', 145 | } 146 | 147 | 148 | def test_array_field_to_schema(): 149 | s_f = fields.StringField() 150 | d_f = fields.DictField() 151 | 152 | f = fields.ArrayField(s_f) 153 | definitions, schema = f.get_definitions_and_schema() 154 | assert normalize(schema) == { 155 | 'type': 'array', 156 | 'items': s_f.get_schema(), 157 | } 158 | 159 | expected_items = [ 160 | ('type', 'array'), 161 | ('id', 'test'), 162 | ('title', 'Array'), 163 | ('items', s_f.get_schema()), 164 | ('additionalItems', d_f.get_schema()), 165 | ('minItems', 0), 166 | ('maxItems', 10), 167 | ('uniqueItems', True), 168 | ] 169 | f = fields.ArrayField(s_f, id='test', title='Array', 170 | min_items=0, max_items=10, unique_items=True, 171 | additional_items=d_f) 172 | definitions, schema = f.get_definitions_and_schema() 173 | assert normalize(schema) == dict(expected_items) 174 | definitions, ordered_schema = f.get_definitions_and_schema(ordered=True) 175 | assert isinstance(ordered_schema, OrderedDict) 176 | assert normalize(ordered_schema) == OrderedDict(expected_items) 177 | 178 | f = fields.ArrayField(s_f, additional_items=True) 179 | definitions, schema = f.get_definitions_and_schema() 180 | assert normalize(schema) == { 181 | 'type': 'array', 182 | 'items': s_f.get_schema(), 183 | 'additionalItems': True, 184 | } 185 | 186 | f = fields.ArrayField(s_f, additional_items=d_f) 187 | definitions, schema = f.get_definitions_and_schema() 188 | assert normalize(schema) == { 189 | 'type': 'array', 190 | 'items': s_f.get_schema(), 191 | 'additionalItems': d_f.get_schema(), 192 | } 193 | 194 | n_f = fields.NumberField() 195 | a_f = fields.ArrayField(fields.StringField()) 196 | f = fields.ArrayField([n_f, a_f]) 197 | definitions, schema = f.get_definitions_and_schema() 198 | assert normalize(schema) == { 199 | 'type': 'array', 200 | 'items': [n_f.get_schema(), a_f.get_schema()], 201 | } 202 | 203 | 204 | def test_array_field_walk(): 205 | aa = fields.StringField() 206 | a = fields.DictField(properties={'aa': aa}) 207 | b = fields.StringField() 208 | c = fields.StringField() 209 | 210 | array_field = fields.ArrayField((a, b), additional_items=c) 211 | path = list(array_field.resolve_and_walk()) 212 | expected_path = [array_field, a, aa, b, c] 213 | assert path == expected_path 214 | 215 | array_field = fields.ArrayField(a, additional_items=False) 216 | path = list(array_field.resolve_and_walk()) 217 | expected_path = [array_field, a, aa] 218 | assert path == expected_path 219 | 220 | 221 | def test_dict_field_to_schema(): 222 | f = fields.DictField(title='Hey!', enum=[{'x': 1}, {'y': 2}]) 223 | definitions, schema = f.get_definitions_and_schema() 224 | assert normalize(schema) == { 225 | 'type': 'object', 226 | 'enum': [ 227 | {'x': 1}, 228 | {'y': 2}, 229 | ], 230 | 'title': 'Hey!', 231 | } 232 | 233 | a_field_mock = fields.StringField() 234 | b_field_mock = fields.BooleanField() 235 | c_field_mock = fields.EmailField() 236 | f = fields.DictField(properties={ 237 | 'a': a_field_mock, 238 | 'b': b_field_mock, 239 | }, pattern_properties={ 240 | 'c*': c_field_mock, 241 | }, min_properties=5, max_properties=10) 242 | definitions, schema = f.get_definitions_and_schema() 243 | assert normalize(schema) == { 244 | 'type': 'object', 245 | 'properties': { 246 | 'a': a_field_mock.get_schema(), 247 | 'b': b_field_mock.get_schema(), 248 | }, 249 | 'patternProperties': { 250 | 'c*': c_field_mock.get_schema(), 251 | }, 252 | 'minProperties': 5, 253 | 'maxProperties': 10, 254 | } 255 | 256 | additional_prop_field_mock = fields.OneOfField( 257 | (fields.StringField(), fields.NumberField())) 258 | f = fields.DictField(additional_properties=additional_prop_field_mock) 259 | definitions, schema = f.get_definitions_and_schema() 260 | assert normalize(schema) == normalize({ 261 | 'type': 'object', 262 | 'additionalProperties': additional_prop_field_mock.get_schema(), 263 | }) 264 | f = fields.DictField(additional_properties=False) 265 | assert f.get_schema()['additionalProperties'] is False 266 | 267 | # test nested required fields and make sure that field names 268 | # do not override property names 269 | f = fields.DictField(properties={ 270 | 'a': fields.StringField(name='A', required=True), 271 | }, pattern_properties={ 272 | 'c*': fields.StringField(name='C', required=True), 273 | }) 274 | definitions, schema = f.get_definitions_and_schema() 275 | assert normalize(schema) == normalize({ 276 | 'type': 'object', 277 | 'properties': { 278 | 'a': {'type': 'string'}, 279 | }, 280 | 'patternProperties': { 281 | 'c*': {'type': 'string'}, 282 | }, 283 | 'required': ['a'], 284 | }) 285 | 286 | 287 | def test_dict_field_walk(): 288 | aa = fields.StringField() 289 | a = fields.DictField(properties={'aa': aa}) 290 | bb = fields.StringField() 291 | b = fields.DictField(properties={'bb': bb}) 292 | cc = fields.StringField() 293 | c = fields.DictField(properties={'cc': cc}) 294 | dd = fields.StringField() 295 | d = fields.DictField(properties={'dd': dd}) 296 | dict_field = fields.DictField( 297 | properties={ 298 | 'a': a, 299 | 'b': b, 300 | }, 301 | pattern_properties={ 302 | 'c': c, 303 | }, 304 | additional_properties=d 305 | ) 306 | path = list(dict_field.resolve_and_walk()) 307 | expected_path_1 = [dict_field, a, aa, b, bb, c, cc, d, dd] 308 | expected_path_2 = [dict_field, b, bb, a, aa, c, cc, d, dd] 309 | assert path == expected_path_1 or path == expected_path_2 310 | 311 | 312 | def test_document_field(): 313 | document_cls_mock = mock.Mock() 314 | expected_schema = mock.Mock() 315 | attrs = { 316 | 'get_definitions_and_schema.return_value': ({}, expected_schema), 317 | 'get_definition_id.return_value': 'document.Document', 318 | 'is_recursive.return_value': False, 319 | } 320 | document_cls_mock.configure_mock(**attrs) 321 | 322 | f = fields.DocumentField(document_cls_mock) 323 | definitions, schema = f.get_definitions_and_schema() 324 | assert schema == expected_schema 325 | assert not definitions 326 | 327 | definitions, schema = f.get_definitions_and_schema(ref_documents=set([document_cls_mock])) 328 | assert normalize(schema) == {'$ref': '#/definitions/document.Document'} 329 | 330 | f = fields.DocumentField(document_cls_mock, as_ref=True) 331 | definitions, schema = f.get_definitions_and_schema() 332 | assert definitions == {'document.Document': expected_schema} 333 | assert normalize(schema) == {'$ref': '#/definitions/document.Document'} 334 | 335 | attrs = { 336 | 'get_definitions_and_schema.return_value': ({}, expected_schema), 337 | 'get_definition_id.return_value': 'document.Document', 338 | 'is_recursive.return_value': True, 339 | } 340 | document_cls_mock.reset_mock() 341 | document_cls_mock.configure_mock(**attrs) 342 | 343 | f = fields.DocumentField(document_cls_mock, as_ref=True) 344 | definitions, schema = f.get_definitions_and_schema() 345 | assert schema == expected_schema 346 | assert not definitions 347 | 348 | 349 | def test_recursive_document_field(): 350 | class Tree(Document): 351 | node = fields.OneOfField([ 352 | fields.ArrayField(fields.DocumentField('self')), 353 | fields.StringField(), 354 | ]) 355 | 356 | expected_schema = { 357 | '$schema': 'http://json-schema.org/draft-04/schema#', 358 | 'definitions': { 359 | 'test_fields.Tree': { 360 | 'type': 'object', 361 | 'additionalProperties': False, 362 | 'properties': { 363 | 'node': { 364 | 'oneOf': [ 365 | { 366 | 'type': 'array', 367 | 'items': {'$ref': '#/definitions/test_fields.Tree'}, 368 | }, 369 | { 370 | 'type': 'string', 371 | }, 372 | ], 373 | }, 374 | }, 375 | }, 376 | }, 377 | '$ref': '#/definitions/test_fields.Tree', 378 | } 379 | assert normalize(Tree.get_schema()) == normalize(expected_schema) 380 | 381 | 382 | def test_of_fields(): 383 | field_1_mock = fields.StringField() 384 | field_2_mock = fields.BooleanField() 385 | field_3_mock = fields.ArrayField(fields.IntField()) 386 | of_fields = [field_1_mock, field_2_mock, field_3_mock] 387 | 388 | f = fields.OneOfField(of_fields) 389 | _, schema = f.get_definitions_and_schema() 390 | assert normalize(schema) == { 391 | 'oneOf': [f.get_schema() for f in of_fields] 392 | } 393 | 394 | f = fields.AnyOfField(of_fields) 395 | _, schema = f.get_definitions_and_schema() 396 | assert normalize(schema) == { 397 | 'anyOf': [f.get_schema() for f in of_fields] 398 | } 399 | 400 | f = fields.AllOfField(of_fields) 401 | _, schema = f.get_definitions_and_schema() 402 | assert normalize(schema) == { 403 | 'allOf': [f.get_schema() for f in of_fields] 404 | } 405 | 406 | 407 | def test_not_field(): 408 | f = fields.NotField(fields.StringField(), description='Not a string.') 409 | expected_schema = { 410 | 'description': 'Not a string.', 411 | 'not': {'type': 'string'}, 412 | } 413 | assert normalize(f.get_schema()) == expected_schema 414 | 415 | 416 | def test_null_field(): 417 | f = fields.NullField() 418 | assert normalize(f.get_schema()) == {'type': 'null'} 419 | 420 | 421 | def test_ref_field(): 422 | pointer = '#/definitions/User' 423 | f = fields.RefField(pointer=pointer) 424 | assert f.get_definitions_and_schema() == ({}, {'$ref': pointer}) -------------------------------------------------------------------------------- /tests/test_inheritance.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | import pytest 3 | 4 | from jsl import ( 5 | NumberField, IntField, DocumentField, StringField, BooleanField, 6 | Document, ALL_OF, INLINE, ANY_OF, ONE_OF, RECURSIVE_REFERENCE_CONSTANT 7 | ) 8 | from util import normalize 9 | 10 | 11 | def test_inheritance_mode_inline(): 12 | class Child(Document): 13 | child_attr = IntField() 14 | 15 | class Parent(Child): 16 | class Options(object): 17 | inheritance_mode = INLINE 18 | 19 | parent_attr = IntField() 20 | 21 | expected_schema = { 22 | '$schema': 'http://json-schema.org/draft-04/schema#', 23 | 'type': 'object', 24 | 'properties': { 25 | 'child_attr': {'type': 'integer'}, 26 | 'parent_attr': {'type': 'integer'} 27 | }, 28 | 'additionalProperties': False, 29 | } 30 | actual_schema = Parent.get_schema() 31 | assert normalize(actual_schema) == normalize(expected_schema) 32 | 33 | class A(Document): 34 | a = IntField() 35 | 36 | class B(A): 37 | b = IntField() 38 | 39 | class C(B): 40 | c = IntField() 41 | 42 | expected_schema = { 43 | '$schema': 'http://json-schema.org/draft-04/schema#', 44 | 'type': 'object', 45 | 'properties': { 46 | 'a': {'type': 'integer'}, 47 | 'b': {'type': 'integer'}, 48 | 'c': {'type': 'integer'}, 49 | }, 50 | 'additionalProperties': False, 51 | } 52 | actual_schema = C.get_schema() 53 | assert normalize(actual_schema) == normalize(expected_schema) 54 | 55 | 56 | def test_inheritance_mode_all_of(): 57 | class Child(Document): 58 | class Options(object): 59 | definition_id = 'child' 60 | 61 | child_attr = IntField() 62 | 63 | class Parent(Child): 64 | class Options(object): 65 | inheritance_mode = ALL_OF 66 | 67 | parent_attr = IntField() 68 | 69 | expected_schema = { 70 | '$schema': 'http://json-schema.org/draft-04/schema#', 71 | 'allOf': [ 72 | {'$ref': '#/definitions/child'}, 73 | { 74 | 'type': 'object', 75 | 'properties': { 76 | 'parent_attr': {'type': 'integer'} 77 | }, 78 | 'additionalProperties': False, 79 | } 80 | ], 81 | 'definitions': { 82 | 'child': { 83 | 'type': 'object', 84 | 'properties': { 85 | 'child_attr': {'type': 'integer'} 86 | }, 87 | 'additionalProperties': False, 88 | } 89 | } 90 | } 91 | actual_schema = Parent.get_schema() 92 | assert normalize(actual_schema) == normalize(expected_schema) 93 | 94 | 95 | def test_inheritance_mode_any_of(): 96 | class Child(Document): 97 | class Options(object): 98 | definition_id = 'child' 99 | 100 | child_attr = IntField() 101 | 102 | class Parent(Child): 103 | class Options(object): 104 | inheritance_mode = ANY_OF 105 | 106 | parent_attr = IntField() 107 | 108 | expected_schema = { 109 | '$schema': 'http://json-schema.org/draft-04/schema#', 110 | 'anyOf': [ 111 | {'$ref': '#/definitions/child'}, 112 | { 113 | 'type': 'object', 114 | 'properties': { 115 | 'parent_attr': {'type': 'integer'} 116 | }, 117 | 'additionalProperties': False, 118 | } 119 | ], 120 | 'definitions': { 121 | 'child': { 122 | 'type': 'object', 123 | 'properties': { 124 | 'child_attr': {'type': 'integer'} 125 | }, 126 | 'additionalProperties': False, 127 | } 128 | } 129 | } 130 | actual_schema = Parent.get_schema() 131 | assert normalize(actual_schema) == normalize(expected_schema) 132 | 133 | 134 | def test_inheritance_mode_one_of(): 135 | class Child(Document): 136 | class Options(object): 137 | definition_id = 'child' 138 | 139 | child_attr = IntField() 140 | 141 | class Parent(Child): 142 | class Options(object): 143 | inheritance_mode = ONE_OF 144 | 145 | parent_attr = IntField() 146 | 147 | expected_schema = { 148 | '$schema': 'http://json-schema.org/draft-04/schema#', 149 | 'oneOf': [ 150 | {'$ref': '#/definitions/child'}, 151 | { 152 | 'type': 'object', 153 | 'properties': { 154 | 'parent_attr': {'type': 'integer'} 155 | }, 156 | 'additionalProperties': False, 157 | } 158 | ], 159 | 'definitions': { 160 | 'child': { 161 | 'type': 'object', 162 | 'properties': { 163 | 'child_attr': {'type': 'integer'} 164 | }, 165 | 'additionalProperties': False, 166 | } 167 | } 168 | } 169 | actual_schema = Parent.get_schema() 170 | assert normalize(actual_schema) == normalize(expected_schema) 171 | 172 | 173 | def test_multiple_inheritance(): 174 | class IntChild(Document): 175 | class Options(object): 176 | definition_id = 'int_child' 177 | 178 | foo = IntField() 179 | bar = IntField() 180 | 181 | class StringChild(Document): 182 | class Options(object): 183 | definition_id = 'string_child' 184 | 185 | foo = StringField() 186 | bar = StringField() 187 | 188 | class Parent(IntChild, StringChild): 189 | class Options(object): 190 | inheritance_mode = ONE_OF 191 | 192 | foo = BooleanField() 193 | bar = BooleanField() 194 | 195 | expected_schema = { 196 | '$schema': 'http://json-schema.org/draft-04/schema#', 197 | 'oneOf': [ 198 | {'$ref': '#/definitions/int_child'}, 199 | {'$ref': '#/definitions/string_child'}, 200 | { 201 | 'type': 'object', 202 | 'properties': { 203 | 'foo': {'type': 'boolean'}, 204 | 'bar': {'type': 'boolean'} 205 | }, 206 | 'additionalProperties': False, 207 | } 208 | ], 209 | 'definitions': { 210 | 'int_child': { 211 | 'type': 'object', 212 | 'properties': { 213 | 'foo': {'type': 'integer'}, 214 | 'bar': {'type': 'integer'} 215 | }, 216 | 'additionalProperties': False, 217 | }, 218 | 'string_child': { 219 | 'type': 'object', 220 | 'properties': { 221 | 'foo': {'type': 'string'}, 222 | 'bar': {'type': 'string'} 223 | }, 224 | 'additionalProperties': False, 225 | } 226 | } 227 | } 228 | actual_schema = Parent.get_schema() 229 | assert normalize(actual_schema) == normalize(expected_schema) 230 | 231 | 232 | def test_invalid_inheritance_mode(): 233 | with pytest.raises(ValueError) as e: 234 | class Error(Document): 235 | class Options(object): 236 | inheritance_mode = 'lapapam' 237 | assert str(e.value) == ( 238 | "Unknown inheritance mode: 'lapapam'. " 239 | "Must be one of the following: ['all_of', 'any_of', 'inline', 'one_of']" 240 | ) 241 | 242 | 243 | def test_nested_inheritance_all_of_parent(): 244 | class Base(Document): 245 | class Options(object): 246 | inheritance_mode = ALL_OF 247 | definition_id = 'base' 248 | 249 | created_at = IntField() 250 | 251 | class Shape(Base): 252 | class Options(object): 253 | definition_id = 'shape' 254 | title = 'Shape' 255 | 256 | color = StringField(required=True) 257 | 258 | class Button(Base): 259 | class Options(object): 260 | definition_id = 'button' 261 | title = 'Button' 262 | 263 | on_click = StringField(required=True) 264 | 265 | class Circle(Shape, Button): 266 | class Options(object): 267 | definition_id = 'circle' 268 | title = 'Circle' 269 | 270 | radius = NumberField(required=True) 271 | 272 | class Sector(Circle): 273 | class Options(object): 274 | inheritance_mode = INLINE 275 | definition_id = 'sector' 276 | title = 'Sector' 277 | 278 | angle = NumberField(required=True) 279 | 280 | class CircularSegment(Sector): 281 | class Options(object): 282 | inheritance_mode = ALL_OF 283 | definition_id = 'circular_segment' 284 | title = 'Circular Segment' 285 | 286 | h = NumberField(required=True) 287 | 288 | expected_schema = { 289 | '$schema': 'http://json-schema.org/draft-04/schema#', 290 | 'allOf': [ 291 | {'$ref': '#/definitions/sector'}, 292 | { 293 | 'type': 'object', 294 | 'title': 'Circular Segment', 295 | 'properties': { 296 | 'h': {'type': 'number'}, 297 | }, 298 | 'additionalProperties': False, 299 | 'required': ['h'], 300 | } 301 | ], 302 | 'definitions': { 303 | 'base': { 304 | 'type': 'object', 305 | 'properties': { 306 | 'created_at': {'type': 'integer'}, 307 | }, 308 | 'additionalProperties': False, 309 | }, 310 | 'button': { 311 | 'allOf': [ 312 | {'$ref': '#/definitions/base'}, 313 | { 314 | 'type': 'object', 315 | 'title': 'Button', 316 | 'properties': { 317 | 'on_click': {'type': 'string'}, 318 | }, 319 | 'additionalProperties': False, 320 | 'required': ['on_click'], 321 | }, 322 | ], 323 | }, 324 | 'shape': { 325 | 'allOf': [ 326 | {'$ref': '#/definitions/base'}, 327 | { 328 | 'type': 'object', 329 | 'title': 'Shape', 330 | 'properties': { 331 | 'color': {'type': 'string'}, 332 | }, 333 | 'additionalProperties': False, 334 | 'required': ['color'], 335 | }, 336 | ], 337 | }, 338 | 'sector': { 339 | 'allOf': [ 340 | {'$ref': '#/definitions/button'}, 341 | {'$ref': '#/definitions/shape'}, 342 | { 343 | 'type': 'object', 344 | 'title': 'Sector', 345 | 'properties': { 346 | 'radius': {'type': 'number'}, 347 | 'angle': {'type': 'number'}, 348 | }, 349 | 'additionalProperties': False, 350 | 'required': ['angle', 'radius'], 351 | } 352 | ], 353 | } 354 | }, 355 | } 356 | schema = CircularSegment.get_schema() 357 | assert normalize(schema) == normalize(expected_schema) 358 | 359 | 360 | def test_nested_inheritance_inline_parent(): 361 | class Base(Document): 362 | class Options(object): 363 | inheritance_mode = ALL_OF 364 | definition_id = 'base' 365 | title = 'Base' 366 | 367 | a = StringField() 368 | 369 | class Child(Base): 370 | class Options(object): 371 | definition_id = 'child' 372 | title = 'Child' 373 | 374 | b = StringField() 375 | c = DocumentField(RECURSIVE_REFERENCE_CONSTANT) 376 | 377 | expected_schema = { 378 | 'definitions': { 379 | 'base': { 380 | 'type': 'object', 381 | 'title': 'Base', 382 | 'properties': { 383 | 'a': {'type': 'string'}, 384 | }, 385 | 'additionalProperties': False, 386 | }, 387 | 'child': { 388 | 'allOf': [ 389 | {'$ref': '#/definitions/base'}, 390 | { 391 | 'type': 'object', 392 | 'title': 'Child', 393 | 'properties': { 394 | 'c': {'$ref': '#/definitions/child'}, 395 | 'b': {'type': 'string'} 396 | }, 397 | 'additionalProperties': False, 398 | } 399 | ] 400 | } 401 | }, 402 | '$schema': 'http://json-schema.org/draft-04/schema#', 403 | '$ref': '#/definitions/child' 404 | } 405 | schema = Child.get_schema() 406 | assert normalize(schema) == normalize(expected_schema) 407 | -------------------------------------------------------------------------------- /tests/test_iter_methods.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | from jsl import (StringField, ArrayField, Var, DictField, 3 | NotField, Document, DocumentField) 4 | from jsl.fields.compound import BaseOfField 5 | 6 | 7 | a = StringField() 8 | b = StringField() 9 | c = StringField() 10 | d = StringField() 11 | e = StringField() 12 | f = StringField() 13 | g = StringField() 14 | h = StringField() 15 | j = StringField() 16 | 17 | 18 | def test_array_field(): 19 | field = ArrayField(Var({ 20 | 'role_1': a, 21 | 'role_2': b, 22 | 'role_none': None, 23 | }), additional_items=Var({ 24 | 'role_3': c, 25 | 'role_4': d, 26 | 'role_1': e, 27 | 'role_none': None, 28 | })) 29 | assert set(field.iter_fields()) == set([a, b, c, d, e]) 30 | assert set(field.resolve_and_iter_fields('role_1')) == set([a, e]) 31 | assert set(field.resolve_and_iter_fields('role_3')) == set([c]) 32 | assert set(field.resolve_and_iter_fields('role_none')) == set([]) 33 | 34 | field = ArrayField(Var({ 35 | 'role_1': (a, b), 36 | 'role_2': c 37 | }), additional_items=d) 38 | assert set(field.iter_fields()) == set([a, b, c, d]) 39 | 40 | field = ArrayField((Var({'role_1': a, 'role_2': b, 'role_none': None}), c)) 41 | assert set(field.iter_fields()) == set([a, b, c]) 42 | assert set(field.resolve_and_iter_fields('role_1')) == set([a, c]) 43 | assert set(field.resolve_and_iter_fields('role_none')) == set([c]) 44 | 45 | field = ArrayField(a, additional_items=b) 46 | assert set(field.iter_fields()) == set([a, b]) 47 | assert set(field.resolve_and_iter_fields('some_role')) == set([a, b]) 48 | 49 | field = ArrayField() 50 | assert set(field.iter_fields()) == set([]) 51 | 52 | 53 | def test_dict_field(): 54 | field = DictField(properties=Var({ 55 | 'role_1': { 56 | 'a': Var({ 57 | 'role_a': a, 58 | 'role_none': None, 59 | }), 60 | 'b': b, 61 | 'role_none': None, 62 | }, 63 | 'role_2': {'c': c}, 64 | 'role_none': None, 65 | }), pattern_properties=Var({ 66 | 'role_3': { 67 | 'x*': Var({ 68 | 'role_b': d, 69 | 'role_none': None, 70 | }), 71 | }, 72 | 'role_4': {'y*': e}, 73 | 'role_none': None, 74 | }), additional_properties=Var({ 75 | 'role_5': f, 76 | 'role_6': g, 77 | 'role_none': None, 78 | })) 79 | assert set(field.iter_fields()) == set([a, b, c, d, e, f, g]) 80 | 81 | field = DictField( 82 | properties={'a': a}, 83 | pattern_properties={'b': b}, 84 | additional_properties=c 85 | ) 86 | assert set(field.iter_fields()) == set([a, b, c]) 87 | 88 | field = DictField() 89 | assert set(field.iter_fields()) == set([]) 90 | 91 | 92 | def test_base_of_field(): 93 | field = BaseOfField((a, b)) 94 | assert set(field.iter_fields()) == set([a, b]) 95 | 96 | field = BaseOfField(Var({ 97 | 'role_1': (a, b), 98 | 'role_2': c, 99 | 'role_3': None, # probably should raise? 100 | })) 101 | assert set(field.iter_fields()) == set([a, b, c]) 102 | 103 | 104 | def test_not_field(): 105 | field = NotField(a) 106 | assert set(field.iter_fields()) == set([a]) 107 | assert set(field.resolve_and_iter_fields('some_role')) == set([a]) 108 | 109 | field = NotField(Var({ 110 | 'role_1': a, 111 | 'role_2': b, 112 | 'role_3': None, # probably should raise? 113 | })) 114 | assert set(field.iter_fields()) == set([a, b]) 115 | assert set(field.resolve_and_iter_fields('role_1')) == set([a]) 116 | assert set(field.resolve_and_iter_fields('role_3')) == set([]) 117 | 118 | 119 | def test_document_field(): 120 | class A(Document): 121 | a = a 122 | b = b 123 | 124 | field = DocumentField(A) 125 | assert set(field.iter_fields()) == set([a, b]) 126 | 127 | class B(Document): 128 | field = Var({ 129 | 'a': a, 130 | 'b': b 131 | }) 132 | b = c 133 | 134 | field = DocumentField(B) 135 | assert set(field.iter_fields()) == set([a, b, c]) 136 | 137 | class C(Document): 138 | pass 139 | 140 | field = DocumentField(C) 141 | assert set(field.iter_fields()) == set([]) 142 | -------------------------------------------------------------------------------- /tests/test_registry.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | import pytest 3 | 4 | from jsl import registry 5 | 6 | 7 | def test_registry(): 8 | registry.clear() 9 | 10 | assert not list(registry.iter_documents()) 11 | 12 | a = object() 13 | registry.put_document('A', a, module='qwe.rty') 14 | assert registry.get_document('qwe.rty.A') is a 15 | 16 | b = object() 17 | registry.put_document('B', b) 18 | assert registry.get_document('B') is b 19 | 20 | assert set(registry.iter_documents()) == set([a, b]) 21 | 22 | registry.remove_document('B') 23 | 24 | assert set(registry.iter_documents()) == set([a]) 25 | 26 | with pytest.raises(KeyError): 27 | registry.remove_document('A') 28 | 29 | registry.remove_document('A', module='qwe.rty') 30 | -------------------------------------------------------------------------------- /tests/test_resolutionscope.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | from jsl.resolutionscope import ResolutionScope 3 | 4 | 5 | def test_scope(): 6 | scope = ResolutionScope() 7 | id, scope = scope.alter('q') 8 | assert id == 'q' 9 | id, scope = scope.alter('w') 10 | assert id == 'w' 11 | 12 | assert scope.base == '' 13 | assert scope.current == 'w' 14 | assert scope.output == 'w' 15 | 16 | scope = ResolutionScope(base='http://example.com/#garbage') 17 | assert scope.create_ref('a') == {'$ref': '#/definitions/a'} 18 | id, scope = scope.alter('schema/') 19 | assert id == 'http://example.com/schema/' 20 | assert scope.create_ref('a') == {'$ref': 'http://example.com/#/definitions/a'} 21 | id, scope = scope.alter('subschema.json') 22 | assert id == 'subschema.json' 23 | id, scope = scope.alter('#hash') 24 | assert id == '#hash' 25 | 26 | assert scope.base == 'http://example.com/' 27 | assert scope.current == scope.output == 'http://example.com/schema/subschema.json' 28 | 29 | # test __repr__ 30 | assert scope.base in repr(scope) 31 | -------------------------------------------------------------------------------- /tests/test_roles.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | import pytest 3 | 4 | from jsl import (Document, BaseSchemaField, StringField, ArrayField, 5 | DocumentField, IntField, DateTimeField, NumberField, 6 | DictField, NotField, AllOfField, AnyOfField, OneOfField, 7 | DEFAULT_ROLE) 8 | from jsl.roles import Var, Scope, not_, Resolution 9 | from jsl.exceptions import SchemaGenerationException 10 | 11 | from util import normalize, sort_required_keys 12 | 13 | 14 | def test_var(): 15 | value_1 = object() 16 | value_2 = object() 17 | value_3 = object() 18 | var = Var([ 19 | ('role_1', value_1), 20 | ('role_2', value_2), 21 | (not_('role_3'), value_3), 22 | ]) 23 | assert len(var.values) == 3 24 | for matcher, value in var.values: 25 | assert callable(matcher) 26 | assert type(value) == object 27 | 28 | assert var.resolve('role_1') == Resolution(value_1, 'role_1') 29 | assert var.resolve('role_2') == Resolution(value_2, 'role_2') 30 | assert var.resolve('default') == Resolution(value_3, 'default') 31 | 32 | var = Var([ 33 | (not_('role_3'), value_3), 34 | ('role_1', value_1), 35 | ('role_2', value_2), 36 | ]) 37 | assert var.resolve('role_1') == Resolution(value_3, 'role_1') 38 | assert var.resolve('role_2') == Resolution(value_3, 'role_2') 39 | assert var.resolve('default') == Resolution(value_3, 'default') 40 | assert var.resolve('role_3') == Resolution(None, 'role_3') 41 | 42 | var = Var([ 43 | ('role_1', value_1), 44 | ('role_2', value_2), 45 | ], propagate='role_2') 46 | assert callable(var.propagate) 47 | 48 | 49 | DB_ROLE = 'db' 50 | REQUEST_ROLE = 'request' 51 | RESPONSE_ROLE = 'response' 52 | PARTIAL_RESPONSE_ROLE = RESPONSE_ROLE + '_partial' 53 | 54 | 55 | def test_helpers(): 56 | when = lambda *args: Var({ 57 | not_(*args): False 58 | }, default=True) 59 | 60 | assert when(RESPONSE_ROLE).resolve(RESPONSE_ROLE).value 61 | assert not when(RESPONSE_ROLE).resolve(REQUEST_ROLE).value 62 | 63 | 64 | def test_scope(): 65 | scope = Scope(DB_ROLE) 66 | f = StringField() 67 | scope.login = f 68 | assert scope.login == f 69 | 70 | assert scope.__fields__ == { 71 | 'login': f, 72 | } 73 | 74 | with pytest.raises(AttributeError): 75 | scope.gsomgsom 76 | 77 | 78 | def test_scopes_basics(): 79 | when_not = lambda *args: Var({ 80 | not_(*args): True 81 | }, default=False) 82 | 83 | class Message(Document): 84 | with Scope(DB_ROLE) as db: 85 | db.uuid = StringField(required=True) 86 | created_at = IntField(required=when_not(PARTIAL_RESPONSE_ROLE, REQUEST_ROLE)) 87 | text = StringField(required=when_not(PARTIAL_RESPONSE_ROLE)) 88 | field_that_is_never_present = Var({ 89 | 'NEVER': StringField(required=True) 90 | }) 91 | 92 | class User(Document): 93 | class Options(object): 94 | roles_to_propagate = not_(PARTIAL_RESPONSE_ROLE) 95 | 96 | with Scope(DB_ROLE) as db: 97 | db._id = StringField(required=True) 98 | db.version = StringField(required=True) 99 | with Scope(lambda r: r.startswith(RESPONSE_ROLE) or r == REQUEST_ROLE) as response: 100 | response.id = StringField(required=when_not(PARTIAL_RESPONSE_ROLE)) 101 | with Scope(not_(REQUEST_ROLE)) as not_request: 102 | not_request.messages = ArrayField(DocumentField(Message), required=when_not(PARTIAL_RESPONSE_ROLE)) 103 | 104 | resolution = Message.resolve_field('text') 105 | assert resolution.value == Message.text 106 | assert resolution.role == DEFAULT_ROLE 107 | 108 | resolution = Message.resolve_field('field_that_is_never_present') 109 | assert resolution.value is None 110 | assert resolution.role == DEFAULT_ROLE 111 | 112 | resolution = Message.resolve_field('non-existent') 113 | assert resolution.value is None 114 | assert resolution.role == DEFAULT_ROLE 115 | 116 | schema = User.get_schema(role=DB_ROLE) 117 | expected_required = sorted(['_id', 'version', 'messages']) 118 | expected_properties = { 119 | '_id': {'type': 'string'}, 120 | 'version': {'type': 'string'}, 121 | 'messages': { 122 | 'type': 'array', 123 | 'items': { 124 | 'type': 'object', 125 | 'additionalProperties': False, 126 | 'properties': { 127 | 'created_at': {'type': 'integer'}, 128 | 'text': {'type': 'string'}, 129 | 'uuid': {'type': 'string'} 130 | }, 131 | 'required': sorted(['uuid', 'created_at', 'text']), 132 | }, 133 | }, 134 | } 135 | assert sorted(schema['required']) == expected_required 136 | assert sort_required_keys(schema['properties']) == sort_required_keys(expected_properties) 137 | assert dict(User.resolve_and_iter_fields(DB_ROLE)) == { 138 | '_id': User.db._id, 139 | 'version': User.db.version, 140 | 'messages': User.not_request.messages, 141 | } 142 | 143 | schema = User.get_schema(role=REQUEST_ROLE) 144 | expected_required = sorted(['id']) 145 | expected_properties = { 146 | 'id': {'type': 'string'}, 147 | } 148 | assert sorted(schema['required']) == expected_required 149 | assert sort_required_keys(schema['properties']) == sort_required_keys(expected_properties) 150 | assert dict(User.resolve_and_iter_fields(REQUEST_ROLE)) == { 151 | 'id': User.response.id, 152 | } 153 | 154 | schema = User.get_schema(role=RESPONSE_ROLE) 155 | expected_required = sorted(['id', 'messages']) 156 | expected_properties = { 157 | 'id': {'type': 'string'}, 158 | 'messages': { 159 | 'type': 'array', 160 | 'items': { 161 | 'type': 'object', 162 | 'additionalProperties': False, 163 | 'properties': { 164 | 'created_at': {'type': 'integer'}, 165 | 'text': {'type': 'string'}, 166 | }, 167 | 'required': sorted(['created_at', 'text']), 168 | }, 169 | }, 170 | } 171 | assert sorted(schema['required']) == expected_required 172 | assert sort_required_keys(schema['properties']) == sort_required_keys(expected_properties) 173 | assert dict(User.resolve_and_iter_fields(RESPONSE_ROLE)) == { 174 | 'id': User.response.id, 175 | 'messages': User.not_request.messages, 176 | } 177 | 178 | schema = User.get_schema(role=PARTIAL_RESPONSE_ROLE) 179 | expected_properties = { 180 | 'id': {'type': 'string'}, 181 | 'messages': { 182 | 'type': 'array', 183 | 'items': { 184 | 'type': 'object', 185 | 'additionalProperties': False, 186 | 'properties': { 187 | 'created_at': {'type': 'integer'}, 188 | 'text': {'type': 'string'}, 189 | }, 190 | 'required': sorted(['created_at', 'text']), 191 | }, 192 | }, 193 | } 194 | assert 'required' not in schema 195 | assert sort_required_keys(schema['properties']) == sort_required_keys(expected_properties) 196 | assert dict(Message.resolve_and_iter_fields(PARTIAL_RESPONSE_ROLE)) == { 197 | 'created_at': Message.created_at, 198 | 'text': Message.text, 199 | } 200 | 201 | 202 | def test_base_field(): 203 | _ = lambda value: Var({'role_1': value}) 204 | field = BaseSchemaField(default=_(lambda: 1), enum=_(lambda: [1, 2, 3]), title=_('Title'), 205 | description=_('Description')) 206 | schema = field._update_schema_with_common_fields({}) 207 | assert schema == {} 208 | 209 | schema = field._update_schema_with_common_fields(schema, role='role_1') 210 | assert schema == { 211 | 'title': 'Title', 212 | 'description': 'Description', 213 | 'enum': [1, 2, 3], 214 | 'default': 1, 215 | } 216 | 217 | 218 | def test_string_field(): 219 | _ = lambda value: Var({'role_1': value}) 220 | field = StringField(format=_('date-time'), min_length=_(1), max_length=_(2)) 221 | assert normalize(field.get_schema()) == normalize({ 222 | 'type': 'string' 223 | }) 224 | assert normalize(field.get_schema(role='role_1')) == normalize({ 225 | 'type': 'string', 226 | 'format': 'date-time', 227 | 'minLength': 1, 228 | 'maxLength': 2, 229 | }) 230 | 231 | with pytest.raises(ValueError) as e: 232 | StringField(pattern=_('(')) 233 | assert str(e.value) == 'Invalid regular expression: unbalanced parenthesis' 234 | 235 | 236 | def test_array_field(): 237 | s_f = StringField() 238 | n_f = NumberField() 239 | field = ArrayField(Var({ 240 | 'role_1': s_f, 241 | 'role_2': n_f, 242 | })) 243 | schema = normalize(field.get_schema(role='role_1')) 244 | assert normalize(schema['items']) == s_f.get_schema() 245 | 246 | schema = normalize(field.get_schema(role='role_2')) 247 | assert schema['items'] == n_f.get_schema() 248 | 249 | schema = normalize(field.get_schema()) 250 | assert 'items' not in schema 251 | 252 | _ = lambda value: Var({'role_1': value}) 253 | field = ArrayField(s_f, min_items=_(1), max_items=_(2), unique_items=_(True), additional_items=_(True)) 254 | assert normalize(field.get_schema()) == normalize({ 255 | 'type': 'array', 256 | 'items': s_f.get_schema(), 257 | }) 258 | assert field.get_schema(role='role_1') == normalize({ 259 | 'type': 'array', 260 | 'items': s_f.get_schema(), 261 | 'minItems': 1, 262 | 'maxItems': 2, 263 | 'uniqueItems': True, 264 | 'additionalItems': True, 265 | }) 266 | 267 | 268 | def test_dict_field(): 269 | s_f = StringField() 270 | _ = lambda value: Var({'role_1': value}) 271 | field = DictField(properties=Var( 272 | { 273 | 'role_1': {'name': Var({'role_1': s_f})}, 274 | 'role_2': {'name': Var({'role_2': s_f})}, 275 | }, 276 | propagate='role_1' 277 | ), pattern_properties=Var( 278 | { 279 | 'role_1': {'.*': Var({'role_1': s_f})}, 280 | 'role_2': {'.*': Var({'role_2': s_f})}, 281 | }, 282 | propagate='role_1' 283 | ), additional_properties=_(s_f), min_properties=_(1), max_properties=_(2)) 284 | assert normalize(field.get_schema()) == normalize({ 285 | 'type': 'object' 286 | }) 287 | assert normalize(field.get_schema(role='role_1')) == normalize({ 288 | 'type': 'object', 289 | 'properties': { 290 | 'name': s_f.get_schema(), 291 | }, 292 | 'patternProperties': { 293 | '.*': s_f.get_schema(), 294 | }, 295 | 'additionalProperties': s_f.get_schema(), 296 | 'minProperties': 1, 297 | 'maxProperties': 2, 298 | }) 299 | assert normalize(field.get_schema(role='role_2')) == normalize({ 300 | 'type': 'object', 301 | 'properties': {}, 302 | 'patternProperties': {}, 303 | }) 304 | 305 | 306 | @pytest.mark.parametrize(('keyword', 'field_cls'), 307 | [('oneOf', OneOfField), ('anyOf', AnyOfField), ('allOf', AllOfField)]) 308 | def test_keyword_of_fields(keyword, field_cls): 309 | s_f = StringField() 310 | n_f = NumberField() 311 | i_f = IntField() 312 | field = field_cls([n_f, Var({'role_1': s_f}), Var({'role_2': i_f})]) 313 | assert normalize(field.get_schema()) == { 314 | keyword: [n_f.get_schema()] 315 | } 316 | assert normalize(field.get_schema(role='role_1')) == { 317 | keyword: [n_f.get_schema(), s_f.get_schema()] 318 | } 319 | assert normalize(field.get_schema(role='role_2')) == { 320 | keyword: [n_f.get_schema(), i_f.get_schema()] 321 | } 322 | 323 | field = field_cls(Var({ 324 | 'role_1': [n_f, Var({'role_1': s_f}), Var({'role_2': i_f})], 325 | 'role_2': [Var({'role_2': i_f})], 326 | }, propagate='role_1')) 327 | assert normalize(field.get_schema(role='role_1')) == { 328 | keyword: [n_f.get_schema(), s_f.get_schema()] 329 | } 330 | with pytest.raises(SchemaGenerationException): 331 | field.get_schema(role='role_2') 332 | 333 | 334 | def test_not_field(): 335 | s_f = StringField() 336 | field = NotField(Var({'role_1': s_f})) 337 | assert normalize(field.get_schema(role='role_1')) == {'not': s_f.get_schema()} 338 | 339 | 340 | def test_document_field(): 341 | class B(Document): 342 | name = Var({ 343 | 'response': StringField(required=True), 344 | 'request': StringField(), 345 | }) 346 | 347 | class A(Document): 348 | id = Var({'response': StringField(required=True)}) 349 | b = DocumentField(B) 350 | 351 | field = DocumentField(A) 352 | 353 | assert list(field.resolve_and_walk()) == [field] 354 | 355 | assert (sorted(field.resolve_and_walk(through_document_fields=True), key=id) == 356 | sorted([field, A.b], key=id)) 357 | 358 | assert (sorted(field.resolve_and_walk(role='response', through_document_fields=True), key=id) == 359 | sorted([ 360 | field, 361 | A.b, 362 | A.resolve_field('id', 'response').value, 363 | B.resolve_field('name', 'response').value, 364 | ], key=id)) 365 | 366 | assert sorted(field.resolve_and_walk(through_document_fields=True, role='request'), key=id) == sorted([ 367 | field, 368 | A.b, 369 | B.resolve_field('name', 'request').value, 370 | ], key=id) 371 | 372 | 373 | def test_basics(): 374 | class User(Document): 375 | id = Var({ 376 | 'response': IntField(required=True) 377 | }) 378 | login = StringField(required=True) 379 | 380 | class Task(Document): 381 | class Options(object): 382 | title = 'Task' 383 | description = 'A task.' 384 | definition_id = 'task' 385 | 386 | id = IntField(required=Var({'response': True})) 387 | name = StringField(required=True, min_length=5) 388 | type = StringField(required=True, enum=['TYPE_1', 'TYPE_2']) 389 | created_at = DateTimeField(required=True) 390 | author = Var({'response': DocumentField(User)}) 391 | 392 | assert normalize(Task.get_schema()) == normalize({ 393 | '$schema': 'http://json-schema.org/draft-04/schema#', 394 | 'additionalProperties': False, 395 | 'description': 'A task.', 396 | 'properties': { 397 | 'created_at': {'format': 'date-time', 'type': 'string'}, 398 | 'id': {'type': 'integer'}, 399 | 'name': {'minLength': 5, 'type': 'string'}, 400 | 'type': {'enum': ['TYPE_1', 'TYPE_2'], 'type': 'string'} 401 | }, 402 | 'required': ['created_at', 'type', 'name'], 403 | 'title': 'Task', 404 | 'type': 'object' 405 | }) 406 | 407 | assert normalize(Task.get_schema(role='response')) == normalize({ 408 | '$schema': 'http://json-schema.org/draft-04/schema#', 409 | 'title': 'Task', 410 | 'description': 'A task.', 411 | 'type': 'object', 412 | 'additionalProperties': False, 413 | 'properties': { 414 | 'created_at': {'format': 'date-time', 'type': 'string'}, 415 | 'id': {'type': 'integer'}, 416 | 'name': {'minLength': 5, 'type': 'string'}, 417 | 'type': {'enum': ['TYPE_1', 'TYPE_2'], 'type': 'string'}, 418 | 'author': { 419 | 'additionalProperties': False, 420 | 'properties': { 421 | 'id': {'type': 'integer'}, 422 | 'login': {'type': 'string'} 423 | }, 424 | 'required': ['id', 'login'], 425 | 'type': 'object' 426 | }, 427 | }, 428 | 'required': ['created_at', 'type', 'name', 'id'], 429 | }) 430 | 431 | 432 | def test_document(): 433 | class A(Document): 434 | a = Var({'role_1': DocumentField('self')}) 435 | 436 | assert not A.is_recursive() 437 | assert A.is_recursive(role='role_1') 438 | 439 | class A(Document): 440 | class Options(object): 441 | definition_id = Var({'role_1': 'a'}) 442 | 443 | assert A.get_definition_id(role='role_1') == 'a' 444 | assert A.get_definition_id(role='role_2').endswith(A.__name__) 445 | -------------------------------------------------------------------------------- /tests/util.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | import collections 3 | 4 | import jsonschema 5 | 6 | from jsl._compat import iteritems, string_types 7 | 8 | 9 | def sort_required_keys(schema): 10 | for key, value in iteritems(schema): 11 | if (key == 'required' and 12 | isinstance(value, list) and 13 | all(isinstance(v, string_types) for v in value)): 14 | value.sort() 15 | elif isinstance(value, dict): 16 | sort_required_keys(value) 17 | elif isinstance(value, collections.Iterable): 18 | for v in value: 19 | if isinstance(v, dict): 20 | sort_required_keys(v) 21 | 22 | 23 | def normalize(schema): 24 | jsonschema.Draft4Validator.check_schema(schema) 25 | sort_required_keys(schema) 26 | return schema 27 | --------------------------------------------------------------------------------