├── .editorconfig ├── .gitchangelog.rc ├── .github └── workflows │ ├── pre-commit.yml │ └── python-test.yml ├── .gitignore ├── .pre-commit-config.yaml ├── .travis.yml ├── CHANGELOG.md ├── LICENSE-MIT ├── MANIFEST.in ├── README.rst ├── pyproject.toml ├── requirements.txt ├── schema ├── __init__.py └── py.typed ├── setup.cfg ├── setup.py ├── test_schema.py └── tox.ini /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig file: http://EditorConfig.org 2 | 3 | # top-most EditorConfig file 4 | root = true 5 | 6 | # Unix-style newlines with a newline ending every file 7 | [*] 8 | end_of_line = lf 9 | insert_final_newline = true 10 | 11 | # 4 space indentation 12 | [*.py] 13 | indent_style = space 14 | indent_size = 4 15 | trim_trailing_whitespace = true 16 | -------------------------------------------------------------------------------- /.gitchangelog.rc: -------------------------------------------------------------------------------- 1 | ## 2 | ## Format 3 | ## 4 | ## ACTION: [AUDIENCE:] COMMIT_MSG [!TAG ...] 5 | ## 6 | ## Description 7 | ## 8 | ## ACTION is one of 'chg', 'fix', 'new' 9 | ## 10 | ## Is WHAT the change is about. 11 | ## 12 | ## 'chg' is for refactor, small improvement, cosmetic changes... 13 | ## 'fix' is for bug fixes 14 | ## 'new' is for new features, big improvement 15 | ## 16 | ## AUDIENCE is optional and one of 'dev', 'usr', 'pkg', 'test', 'doc' 17 | ## 18 | ## Is WHO is concerned by the change. 19 | ## 20 | ## 'dev' is for developpers (API changes, refactors...) 21 | ## 'usr' is for final users (UI changes) 22 | ## 'pkg' is for packagers (packaging changes) 23 | ## 'test' is for testers (test only related changes) 24 | ## 'doc' is for doc guys (doc only changes) 25 | ## 26 | ## COMMIT_MSG is ... well ... the commit message itself. 27 | ## 28 | ## TAGs are additionnal adjective as 'refactor' 'minor' 'cosmetic' 29 | ## 30 | ## They are preceded with a '!' or a '@' (prefer the former, as the 31 | ## latter is wrongly interpreted in github.) Commonly used tags are: 32 | ## 33 | ## 'refactor' is obviously for refactoring code only 34 | ## 'minor' is for a very meaningless change (a typo, adding a comment) 35 | ## 'cosmetic' is for cosmetic driven change (re-indentation, 80-col...) 36 | ## 'wip' is for partial functionality but complete subfunctionality. 37 | ## 38 | ## Example: 39 | ## 40 | ## new: usr: support of bazaar implemented 41 | ## chg: re-indentend some lines !cosmetic 42 | ## new: dev: updated code to be compatible with last version of killer lib. 43 | ## fix: pkg: updated year of licence coverage. 44 | ## new: test: added a bunch of test around user usability of feature X. 45 | ## fix: typo in spelling my name in comment. !minor 46 | ## 47 | ## Please note that multi-line commit message are supported, and only the 48 | ## first line will be considered as the "summary" of the commit message. So 49 | ## tags, and other rules only applies to the summary. The body of the commit 50 | ## message will be displayed in the changelog without reformatting. 51 | 52 | 53 | ## 54 | ## ``ignore_regexps`` is a line of regexps 55 | ## 56 | ## Any commit having its full commit message matching any regexp listed here 57 | ## will be ignored and won't be reported in the changelog. 58 | ## 59 | ignore_regexps = [ 60 | r'@minor', r'!minor', 61 | r'@cosmetic', r'!cosmetic', 62 | r'@refactor', r'!refactor', 63 | r'@wip', r'!wip', 64 | r'^([cC]hg|[fF]ix|[nN]ew|[fF]eat)\s*:\s*[p|P]kg:', 65 | r'^([cC]hg|[fF]ix|[nN]ew|[Ff]eat)\s*:\s*[d|D]ev:', 66 | r'^(.{3,3}\s*:)?\s*[fF]irst commit.?\s*$', 67 | r'^\d+\.\d+\.\d+$', 68 | ] 69 | 70 | 71 | ## ``section_regexps`` is a list of 2-tuples associating a string label and a 72 | ## list of regexp 73 | ## 74 | ## Commit messages will be classified in sections thanks to this. Section 75 | ## titles are the label, and a commit is classified under this section if any 76 | ## of the regexps associated is matching. 77 | ## 78 | ## Please note that ``section_regexps`` will only classify commits and won't 79 | ## make any changes to the contents. So you'll probably want to go check 80 | ## ``subject_process`` (or ``body_process``) to do some changes to the subject, 81 | ## whenever you are tweaking this variable. 82 | ## 83 | section_regexps = [ 84 | ('Features', [ 85 | r'^([nN]ew|[fF]eat)\s*:\s*((dev|use?r|pkg|test|doc)\s*:\s*)?([^\n]*)$', 86 | ]), 87 | ('Changes', [ 88 | r'^[cC]hg\s*:\s*((dev|use?r|pkg|test|doc)\s*:\s*)?([^\n]*)$', 89 | ]), 90 | ('Fixes', [ 91 | r'^[fF]ix\s*:\s*((dev|use?r|pkg|test|doc)\s*:\s*)?([^\n]*)$', 92 | ]), 93 | ] 94 | 95 | 96 | ## ``body_process`` is a callable 97 | ## 98 | ## This callable will be given the original body and result will 99 | ## be used in the changelog. 100 | ## 101 | ## Available constructs are: 102 | ## 103 | ## - any python callable that take one txt argument and return txt argument. 104 | ## 105 | ## - ReSub(pattern, replacement): will apply regexp substitution. 106 | ## 107 | ## - Indent(chars=" "): will indent the text with the prefix 108 | ## Please remember that template engines gets also to modify the text and 109 | ## will usually indent themselves the text if needed. 110 | ## 111 | ## - Wrap(regexp=r"\n\n"): re-wrap text in separate paragraph to fill 80-Columns 112 | ## 113 | ## - noop: do nothing 114 | ## 115 | ## - ucfirst: ensure the first letter is uppercase. 116 | ## (usually used in the ``subject_process`` pipeline) 117 | ## 118 | ## - final_dot: ensure text finishes with a dot 119 | ## (usually used in the ``subject_process`` pipeline) 120 | ## 121 | ## - strip: remove any spaces before or after the content of the string 122 | ## 123 | ## Additionally, you can `pipe` the provided filters, for instance: 124 | #body_process = Wrap(regexp=r'\n(?=\w+\s*:)') | Indent(chars=" ") 125 | #body_process = Wrap(regexp=r'\n(?=\w+\s*:)') 126 | #body_process = noop 127 | #body_process = ReSub(r'((^|\n)[A-Z]\w+(-\w+)*: .*(\n\s+.*)*)+$', r'') | strip 128 | body_process = lambda text: "" 129 | 130 | 131 | ## ``subject_process`` is a callable 132 | ## 133 | ## This callable will be given the original subject and result will 134 | ## be used in the changelog. 135 | ## 136 | ## Available constructs are those listed in ``body_process`` doc. 137 | subject_process = (strip | 138 | ReSub(r'^([cC]hg|[fF]ix|[nN]ew|[fF]eat)\s*:\s*((dev|use?r|pkg|test|doc)\s*:\s*)?([^\n@]*)(@[a-z]+\s+)*$', r'\4') | 139 | ucfirst | final_dot) 140 | 141 | 142 | ## ``tag_filter_regexp`` is a regexp 143 | ## 144 | ## Tags that will be used for the changelog must match this regexp. 145 | ## 146 | tag_filter_regexp = r'^v?[0-9]+\.[0-9]+(\.[0-9]+)?$' 147 | 148 | 149 | ## ``unreleased_version_label`` is a string 150 | ## 151 | ## This label will be used as the changelog Title of the last set of changes 152 | ## between last valid tag and HEAD if any. 153 | unreleased_version_label = "Unreleased" 154 | 155 | 156 | ## ``output_engine`` is a callable 157 | ## 158 | ## This will change the output format of the generated changelog file 159 | ## 160 | ## Available choices are: 161 | ## 162 | ## - rest_py 163 | ## 164 | ## Legacy pure python engine, outputs ReSTructured text. 165 | ## This is the default. 166 | ## 167 | ## - mustache() 168 | ## 169 | ## Template name could be any of the available templates in 170 | ## ``templates/mustache/*.tpl``. 171 | ## Requires python package ``pystache``. 172 | ## Examples: 173 | ## - mustache("markdown") 174 | ## - mustache("restructuredtext") 175 | ## 176 | ## - makotemplate() 177 | ## 178 | ## Template name could be any of the available templates in 179 | ## ``templates/mako/*.tpl``. 180 | ## Requires python package ``mako``. 181 | ## Examples: 182 | ## - makotemplate("restructuredtext") 183 | ## 184 | #output_engine = rest_py 185 | #output_engine = mustache("restructuredtext") 186 | output_engine = mustache("markdown") 187 | #output_engine = makotemplate("restructuredtext") 188 | 189 | 190 | ## ``include_merge`` is a boolean 191 | ## 192 | ## This option tells git-log whether to include merge commits in the log. 193 | ## The default is to include them. 194 | include_merge = True 195 | -------------------------------------------------------------------------------- /.github/workflows/pre-commit.yml: -------------------------------------------------------------------------------- 1 | name: pre-commit 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: [master] 7 | 8 | jobs: 9 | pre-commit: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v3 13 | - uses: actions/setup-python@v3 14 | - uses: pre-commit/action@v3.0.1 15 | with: 16 | extra_args: -a --hook-stage manual 17 | -------------------------------------------------------------------------------- /.github/workflows/python-test.yml: -------------------------------------------------------------------------------- 1 | name: Python Test on Pull Requests 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - master 7 | 8 | jobs: 9 | build-and-test: 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - name: Check out repository 14 | uses: actions/checkout@v2 15 | 16 | - name: Set up Python 17 | uses: actions/setup-python@v2 18 | with: 19 | python-version: '3.10' 20 | 21 | - name: Install dependencies 22 | run: | 23 | python -m pip install --upgrade pip 24 | pip install pytest 25 | if [ -f requirements.txt ]; then pip install -r requirements.txt; fi 26 | 27 | - name: Run tests 28 | run: pytest 29 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.py[co] 2 | 3 | # Vim 4 | *.swp 5 | *.swo 6 | 7 | # Packages 8 | *.egg 9 | *.egg-info 10 | dist 11 | build 12 | eggs 13 | parts 14 | bin 15 | var 16 | sdist 17 | develop-eggs 18 | .installed.cfg 19 | 20 | # Installer logs 21 | pip-log.txt 22 | 23 | # Unit test / coverage reports 24 | .coverage 25 | .tox 26 | nosetests.xml 27 | .pytest_cache/ 28 | 29 | #Translations 30 | *.mo 31 | 32 | #Mr Developer 33 | .mr.developer.cfg 34 | 35 | # Sphinx 36 | docs/_* 37 | 38 | # Created by https://www.gitignore.io/api/ython,python,osx,pycharm 39 | 40 | ### Python ### 41 | # Byte-compiled / optimized / DLL files 42 | __pycache__/ 43 | *.py[cod] 44 | *$py.class 45 | 46 | # C extensions 47 | *.so 48 | 49 | # Distribution / packaging 50 | .Python 51 | env/ 52 | build/ 53 | develop-eggs/ 54 | dist/ 55 | downloads/ 56 | eggs/ 57 | .eggs/ 58 | lib/ 59 | lib64/ 60 | parts/ 61 | sdist/ 62 | var/ 63 | *.egg-info/ 64 | .installed.cfg 65 | *.egg 66 | 67 | # PyInstaller 68 | # Usually these files are written by a python script from a template 69 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 70 | *.manifest 71 | *.spec 72 | 73 | # Installer logs 74 | pip-log.txt 75 | pip-delete-this-directory.txt 76 | 77 | # Unit test / coverage reports 78 | htmlcov/ 79 | .tox/ 80 | .coverage 81 | .coverage.* 82 | .cache 83 | nosetests.xml 84 | coverage.xml 85 | *,cover 86 | 87 | # Translations 88 | *.mo 89 | *.pot 90 | 91 | # Django stuff: 92 | *.log 93 | 94 | # Sphinx documentation 95 | docs/_build/ 96 | 97 | # PyBuilder 98 | target/ 99 | 100 | 101 | ### OSX ### 102 | .DS_Store 103 | .AppleDouble 104 | .LSOverride 105 | 106 | # Icon must end with two \r 107 | Icon 108 | 109 | 110 | # Thumbnails 111 | ._* 112 | 113 | # Files that might appear in the root of a volume 114 | .DocumentRevisions-V100 115 | .fseventsd 116 | .Spotlight-V100 117 | .TemporaryItems 118 | .Trashes 119 | .VolumeIcon.icns 120 | 121 | # Directories potentially created on remote AFP share 122 | .AppleDB 123 | .AppleDesktop 124 | Network Trash Folder 125 | Temporary Items 126 | .apdisk 127 | 128 | 129 | ### PyCharm ### 130 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio 131 | 132 | *.iml 133 | 134 | ## Directory-based project format: 135 | .idea/ 136 | # if you remove the above rule, at least ignore the following: 137 | 138 | # User-specific stuff: 139 | # .idea/workspace.xml 140 | # .idea/tasks.xml 141 | # .idea/dictionaries 142 | 143 | # Sensitive or high-churn files: 144 | # .idea/dataSources.ids 145 | # .idea/dataSources.xml 146 | # .idea/sqlDataSources.xml 147 | # .idea/dynamic.xml 148 | # .idea/uiDesigner.xml 149 | 150 | # Gradle: 151 | # .idea/gradle.xml 152 | # .idea/libraries 153 | 154 | # Mongo Explorer plugin: 155 | # .idea/mongoSettings.xml 156 | 157 | ## File-based project format: 158 | *.ipr 159 | *.iws 160 | 161 | ## Plugin-specific files: 162 | 163 | # IntelliJ 164 | /out/ 165 | 166 | # mpeltonen/sbt-idea plugin 167 | .idea_modules/ 168 | 169 | # JIRA plugin 170 | atlassian-ide-plugin.xml 171 | 172 | # Crashlytics plugin (for Android Studio and IntelliJ) 173 | com_crashlytics_export_strings.xml 174 | crashlytics.properties 175 | crashlytics-build.properties 176 | 177 | venv/ 178 | env/ 179 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/astral-sh/ruff-pre-commit 3 | rev: v0.4.3 4 | hooks: 5 | - id: ruff 6 | args: [ --fix ] 7 | - id: ruff-format 8 | - repo: local 9 | hooks: 10 | - id: gitchangelog 11 | language: system 12 | always_run: true 13 | pass_filenames: false 14 | name: Generate changelog 15 | entry: bash -c "gitchangelog > CHANGELOG.md" 16 | stages: [commit] 17 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | os: linux 2 | dist: xenial 3 | language: python 4 | 5 | jobs: 6 | include: 7 | - env: TOXENV=py27 8 | python: 2.7 9 | - env: TOXENV=py34 10 | python: 3.4 11 | - env: TOXENV=py35 12 | python: 3.5 13 | - env: TOXENV=py36 14 | python: 3.6 15 | - env: TOXENV=py37 16 | python: 3.7 17 | - env: TOXENV=py38 18 | python: 3.8 19 | - env: TOXENV=py39 20 | python: 3.9 21 | - env: TOXENV=coverage 22 | python: 3.8 23 | - env: TOXENV=checks 24 | python: 3.8 25 | 26 | cache: 27 | pip: true 28 | directories: 29 | - .tox 30 | 31 | install: pip install codecov tox 32 | 33 | script: 34 | - tox 35 | 36 | # publish coverage only after a successful build 37 | after_success: 38 | - codecov 39 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | 4 | ## Unreleased 5 | 6 | ### Fixes 7 | 8 | * Include py.typed module when building package. [Stavros Korokithakis] 9 | 10 | 11 | ## v0.7.7 (2024-05-04) 12 | 13 | ### Fixes 14 | 15 | * Fix setuptools not finding the schema package (fixes #311) [Stavros Korokithakis] 16 | 17 | 18 | ## v0.7.6 (2024-03-26) 19 | 20 | ### Fixes 21 | 22 | * Trim trailing whitespace. [Stavros Korokithakis] 23 | 24 | 25 | ## v0.7.4 (2021-02-01) 26 | 27 | ### Fixes 28 | 29 | * Don't double-format errors. fixes #240 (#247) [Leif Ryge] 30 | 31 | * Fix "Unknown format code" in Python 3.8 (#245) [Denis Blanchette] 32 | 33 | * JSON Schema: Allow using $ref when schema is not a dict (#244) [Denis Blanchette] 34 | 35 | * JSON Schema: Set additionalProperties true when dict contains str as key (#243) [Denis Blanchette] 36 | 37 | 38 | ## v0.7.3 (2020-07-31) 39 | 40 | ### Fixes 41 | 42 | * JSON Schema: Support schemas where the root is not a dict. [Stavros Korokithakis] 43 | 44 | * Do not drop previous errors within an Or criterion. [Stavros Korokithakis] 45 | 46 | 47 | ## v0.7.1 (2019-09-09) 48 | 49 | ### Features 50 | 51 | * JSON Schema: Include default values. [Stavros Korokithakis] 52 | 53 | * JSON schema with common definitions + Update README. [Stavros Korokithakis] 54 | 55 | * Add references to JSON schema rendering. [Stavros Korokithakis] 56 | 57 | * Add the "Literal" type for JSONSchema. [Stavros Korokithakis] 58 | 59 | * Improve JSON schema generation (#206) [Denis Blanchette] 60 | 61 | ### Fixes 62 | 63 | * JSON Schema: Fix allOf and oneOf with only one condition. [Stavros Korokithakis] 64 | 65 | * Fix readme code block typo. [Stavros Korokithakis] 66 | 67 | * JSON Schema: Don't add a description in a ref. [Stavros Korokithakis] 68 | 69 | * JSON Schema: Fix using `dict` as type. [Stavros Korokithakis] 70 | 71 | * Fix using Literal in enum in JSON Schema. [Stavros Korokithakis] 72 | 73 | 74 | ## v0.7.0 (2019-02-25) 75 | 76 | ### Features 77 | 78 | * Add Hook class. Allows to introduce custom handlers (#175) [Julien Duchesne] 79 | 80 | ### Fixes 81 | 82 | * Add pre-commit to CI (#187) [Stavros Korokithakis] 83 | 84 | * Use correct singular/plural form of “key(s)” in error messages (#184) [Joel Rosdahl] 85 | 86 | * When ignoring extra keys, Or's only_one should still be handled (#181) [Julien Duchesne] 87 | 88 | * Fix Or reset() when Or is Optional (#178) [Julien Duchesne] 89 | 90 | * Don't accept boolens as instances of ints (#176) [Brandon Skari] 91 | 92 | * Remove assert statements (#170) [Ryan Morshead] 93 | 94 | 95 | ## v0.6.8 (2018-06-14) 96 | 97 | ### Features 98 | 99 | * Add an is_valid method to the schema (as in #134) (#150) [Shailyn Ortiz] 100 | 101 | ### Fixes 102 | 103 | * Fix typo in schema.py: vaidated->validated (#151) [drootnar] 104 | 105 | * Fix callable check under PyPy2 (#149) [cfs-pure] 106 | 107 | 108 | ## v0.6.6 (2017-04-26) 109 | 110 | ### Fixes 111 | 112 | * Schema can be inherited (#127) [Hiroyuki Ishii] 113 | 114 | * Show a key error if a dict error happens. [Stavros Korokithakis] 115 | 116 | 117 | ## v0.6.4 (2016-09-19) 118 | 119 | ### Fixes 120 | 121 | * Revert the optional error commit. [Stavros Korokithakis] 122 | 123 | 124 | ## v0.6.3 (2016-09-19) 125 | 126 | ### Fixes 127 | 128 | * Sort missing keys. [Stavros Korokithakis] 129 | 130 | 131 | ## v0.6.2 (2016-07-27) 132 | 133 | ### Fixes 134 | 135 | * Add SchemaError SubClasses: SchemaWrongKey, SchemaMissingKeyError (#111) [Stavros Korokithakis] 136 | 137 | 138 | ## v0.6.1 (2016-07-27) 139 | 140 | ### Fixes 141 | 142 | * Handle None as the error message properly. [Stavros Korokithakis] 143 | 144 | 145 | ## v0.6.0 (2016-07-18) 146 | 147 | ### Features 148 | 149 | * Add the "Regex" class. [Stavros Korokithakis] 150 | 151 | 152 | -------------------------------------------------------------------------------- /LICENSE-MIT: -------------------------------------------------------------------------------- 1 | Copyright (c) 2012 Vladimir Keleshev, 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of 4 | this software and associated documentation files (the "Software"), to deal in 5 | the Software without restriction, including without limitation the rights to 6 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies 7 | of the Software, and to permit persons to whom the Software is furnished to do 8 | so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. 20 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.rst requirements.txt LICENSE-MIT *.py 2 | include schema/py.typed 3 | include tox.ini 4 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | Schema validation just got Pythonic 2 | =============================================================================== 3 | 4 | **schema** is a library for validating Python data structures, such as those 5 | obtained from config-files, forms, external services or command-line 6 | parsing, converted from JSON/YAML (or something else) to Python data-types. 7 | 8 | 9 | .. image:: https://secure.travis-ci.org/keleshev/schema.svg?branch=master 10 | :target: https://travis-ci.org/keleshev/schema 11 | 12 | .. image:: https://img.shields.io/codecov/c/github/keleshev/schema.svg 13 | :target: http://codecov.io/github/keleshev/schema 14 | 15 | Example 16 | ---------------------------------------------------------------------------- 17 | 18 | Here is a quick example to get a feeling of **schema**, validating a list of 19 | entries with personal information: 20 | 21 | .. code:: python 22 | 23 | from schema import Schema, And, Use, Optional, SchemaError 24 | 25 | schema = Schema( 26 | [ 27 | { 28 | "name": And(str, len), 29 | "age": And(Use(int), lambda n: 18 <= n <= 99), 30 | Optional("gender"): And( 31 | str, 32 | Use(str.lower), 33 | lambda s: s in ("squid", "kid"), 34 | ), 35 | } 36 | ] 37 | ) 38 | 39 | data = [ 40 | {"name": "Sue", "age": "28", "gender": "Squid"}, 41 | {"name": "Sam", "age": "42"}, 42 | {"name": "Sacha", "age": "20", "gender": "KID"}, 43 | ] 44 | 45 | validated = schema.validate(data) 46 | 47 | assert validated == [ 48 | {"name": "Sue", "age": 28, "gender": "squid"}, 49 | {"name": "Sam", "age": 42}, 50 | {"name": "Sacha", "age": 20, "gender": "kid"}, 51 | ] 52 | 53 | 54 | 55 | If data is valid, ``Schema.validate`` will return the validated data 56 | (optionally converted with `Use` calls, see below). 57 | 58 | If data is invalid, ``Schema`` will raise ``SchemaError`` exception. 59 | If you just want to check that the data is valid, ``schema.is_valid(data)`` will 60 | return ``True`` or ``False``. 61 | 62 | 63 | Installation 64 | ------------------------------------------------------------------------------- 65 | 66 | Use `pip `_ or easy_install:: 67 | 68 | pip install schema 69 | 70 | Alternatively, you can just drop ``schema.py`` file into your project—it is 71 | self-contained. 72 | 73 | - **schema** is tested with Python 2.6, 2.7, 3.2, 3.3, 3.4, 3.5, 3.6, 3.7, 3.8, 3.9 and PyPy. 74 | - **schema** follows `semantic versioning `_. 75 | 76 | How ``Schema`` validates data 77 | ------------------------------------------------------------------------------- 78 | 79 | Types 80 | ~~~~~ 81 | 82 | If ``Schema(...)`` encounters a type (such as ``int``, ``str``, ``object``, 83 | etc.), it will check if the corresponding piece of data is an instance of that type, 84 | otherwise it will raise ``SchemaError``. 85 | 86 | .. code:: python 87 | 88 | >>> from schema import Schema 89 | 90 | >>> Schema(int).validate(123) 91 | 123 92 | 93 | >>> Schema(int).validate('123') 94 | Traceback (most recent call last): 95 | ... 96 | schema.SchemaUnexpectedTypeError: '123' should be instance of 'int' 97 | 98 | >>> Schema(object).validate('hai') 99 | 'hai' 100 | 101 | Callables 102 | ~~~~~~~~~ 103 | 104 | If ``Schema(...)`` encounters a callable (function, class, or object with 105 | ``__call__`` method) it will call it, and if its return value evaluates to 106 | ``True`` it will continue validating, else—it will raise ``SchemaError``. 107 | 108 | .. code:: python 109 | 110 | >>> import os 111 | 112 | >>> Schema(os.path.exists).validate('./') 113 | './' 114 | 115 | >>> Schema(os.path.exists).validate('./non-existent/') 116 | Traceback (most recent call last): 117 | ... 118 | schema.SchemaError: exists('./non-existent/') should evaluate to True 119 | 120 | >>> Schema(lambda n: n > 0).validate(123) 121 | 123 122 | 123 | >>> Schema(lambda n: n > 0).validate(-12) 124 | Traceback (most recent call last): 125 | ... 126 | schema.SchemaError: (-12) should evaluate to True 127 | 128 | "Validatables" 129 | ~~~~~~~~~~~~~~ 130 | 131 | If ``Schema(...)`` encounters an object with method ``validate`` it will run 132 | this method on corresponding data as ``data = obj.validate(data)``. This method 133 | may raise ``SchemaError`` exception, which will tell ``Schema`` that that piece 134 | of data is invalid, otherwise—it will continue validating. 135 | 136 | An example of "validatable" is ``Regex``, that tries to match a string or a 137 | buffer with the given regular expression (itself as a string, buffer or 138 | compiled regex ``SRE_Pattern``): 139 | 140 | .. code:: python 141 | 142 | >>> from schema import Regex 143 | >>> import re 144 | 145 | >>> Regex(r'^foo').validate('foobar') 146 | 'foobar' 147 | 148 | >>> Regex(r'^[A-Z]+$', flags=re.I).validate('those-dashes-dont-match') 149 | Traceback (most recent call last): 150 | ... 151 | schema.SchemaError: Regex('^[A-Z]+$', flags=re.IGNORECASE) does not match 'those-dashes-dont-match' 152 | 153 | For a more general case, you can use ``Use`` for creating such objects. 154 | ``Use`` helps to use a function or type to convert a value while validating it: 155 | 156 | .. code:: python 157 | 158 | >>> from schema import Use 159 | 160 | >>> Schema(Use(int)).validate('123') 161 | 123 162 | 163 | >>> Schema(Use(lambda f: open(f, 'a'))).validate('LICENSE-MIT') 164 | <_io.TextIOWrapper name='LICENSE-MIT' mode='a' encoding='UTF-8'> 165 | 166 | Dropping the details, ``Use`` is basically: 167 | 168 | .. code:: python 169 | 170 | class Use(object): 171 | 172 | def __init__(self, callable_): 173 | self._callable = callable_ 174 | 175 | def validate(self, data): 176 | try: 177 | return self._callable(data) 178 | except Exception as e: 179 | raise SchemaError('%r raised %r' % (self._callable.__name__, e)) 180 | 181 | 182 | Sometimes you need to transform and validate part of data, but keep original data unchanged. 183 | ``Const`` helps to keep your data safe: 184 | 185 | .. code:: python 186 | 187 | >> from schema import Use, Const, And, Schema 188 | 189 | >> from datetime import datetime 190 | 191 | >> is_future = lambda date: datetime.now() > date 192 | 193 | >> to_json = lambda v: {"timestamp": v} 194 | 195 | >> Schema(And(Const(And(Use(datetime.fromtimestamp), is_future)), Use(to_json))).validate(1234567890) 196 | {"timestamp": 1234567890} 197 | 198 | Now you can write your own validation-aware classes and data types. 199 | 200 | Lists, similar containers 201 | ~~~~~~~~~~~~~~~~~~~~~~~~~ 202 | 203 | If ``Schema(...)`` encounters an instance of ``list``, ``tuple``, ``set`` 204 | or ``frozenset``, it will validate contents of corresponding data container 205 | against all schemas listed inside that container and aggregate all errors: 206 | 207 | .. code:: python 208 | 209 | >>> Schema([1, 0]).validate([1, 1, 0, 1]) 210 | [1, 1, 0, 1] 211 | 212 | >>> Schema((int, float)).validate((5, 7, 8, 'not int or float here')) 213 | Traceback (most recent call last): 214 | ... 215 | schema.SchemaError: Or(, ) did not validate 'not int or float here' 216 | 'not int or float here' should be instance of 'int' 217 | 'not int or float here' should be instance of 'float' 218 | 219 | Dictionaries 220 | ~~~~~~~~~~~~ 221 | 222 | If ``Schema(...)`` encounters an instance of ``dict``, it will validate data 223 | key-value pairs: 224 | 225 | .. code:: python 226 | 227 | >>> d = Schema( 228 | ... {"name": str, "age": lambda n: 18 <= n <= 99} 229 | ... ).validate( 230 | ... {"name": "Sue", "age": 28} 231 | ... ) 232 | 233 | >>> assert d == {'name': 'Sue', 'age': 28} 234 | 235 | You can specify keys as schemas too: 236 | 237 | .. code:: python 238 | 239 | >>> schema = Schema({ 240 | ... str: int, # string keys should have integer values 241 | ... int: None, # int keys should be always None 242 | ... }) 243 | 244 | >>> data = schema.validate({ 245 | ... "key1": 1, 246 | ... "key2": 2, 247 | ... 10: None, 248 | ... 20: None, 249 | ... }) 250 | 251 | >>> schema.validate({ 252 | ... "key1": 1, 253 | ... 10: "not None here", 254 | ... }) 255 | Traceback (most recent call last): 256 | ... 257 | schema.SchemaError: Key '10' error: 258 | None does not match 'not None here' 259 | 260 | This is useful if you want to check certain key-values, but don't care 261 | about others: 262 | 263 | .. code:: python 264 | 265 | >>> schema = Schema({ 266 | ... "": int, 267 | ... "": Use(open), 268 | ... str: object, # don't care about other str keys 269 | ... }) 270 | 271 | >>> data = schema.validate({ 272 | ... "": 10, 273 | ... "": "README.rst", 274 | ... "--verbose": True, 275 | ... }) 276 | 277 | You can mark a key as optional as follows: 278 | 279 | .. code:: python 280 | 281 | >>> Schema({ 282 | ... "name": str, 283 | ... Optional("occupation"): str, 284 | ... }).validate({"name": "Sam"}) 285 | {'name': 'Sam'} 286 | 287 | ``Optional`` keys can also carry a ``default``, to be used when no key in the 288 | data matches: 289 | 290 | .. code:: python 291 | 292 | >>> Schema({ 293 | ... Optional("color", default="blue"): str, 294 | ... str: str, 295 | ... }).validate({"texture": "furry"}) == { 296 | ... "color": "blue", 297 | ... "texture": "furry", 298 | ... } 299 | True 300 | 301 | Defaults are used verbatim, not passed through any validators specified in the 302 | value. 303 | 304 | default can also be a callable: 305 | 306 | .. code:: python 307 | 308 | >>> from schema import Schema, Optional 309 | >>> Schema({Optional('data', default=dict): {}}).validate({}) == {'data': {}} 310 | True 311 | 312 | Also, a caveat: If you specify types, **schema** won't validate the empty dict: 313 | 314 | .. code:: python 315 | 316 | >>> Schema({int:int}).is_valid({}) 317 | False 318 | 319 | To do that, you need ``Schema(Or({int:int}, {}))``. This is unlike what happens with 320 | lists, where ``Schema([int]).is_valid([])`` will return True. 321 | 322 | 323 | **schema** has classes ``And`` and ``Or`` that help validating several schemas 324 | for the same data: 325 | 326 | .. code:: python 327 | 328 | >>> from schema import And, Or 329 | 330 | >>> Schema({'age': And(int, lambda n: 0 < n < 99)}).validate({'age': 7}) 331 | {'age': 7} 332 | 333 | >>> Schema({'password': And(str, lambda s: len(s) > 6)}).validate({'password': 'hai'}) 334 | Traceback (most recent call last): 335 | ... 336 | schema.SchemaError: Key 'password' error: 337 | ('hai') should evaluate to True 338 | 339 | >>> Schema(And(Or(int, float), lambda x: x > 0)).validate(3.1415) 340 | 3.1415 341 | 342 | In a dictionary, you can also combine two keys in a "one or the other" manner. To do 343 | so, use the `Or` class as a key: 344 | 345 | .. code:: python 346 | 347 | >>> from schema import Or, Schema 348 | >>> schema = Schema({ 349 | ... Or("key1", "key2", only_one=True): str 350 | ... }) 351 | 352 | >>> schema.validate({"key1": "test"}) # Ok 353 | {'key1': 'test'} 354 | 355 | >>> schema.validate({"key1": "test", "key2": "test"}) # SchemaError 356 | Traceback (most recent call last): 357 | ... 358 | schema.SchemaOnlyOneAllowedError: There are multiple keys present from the Or('key1', 'key2') condition 359 | 360 | Hooks 361 | ~~~~~~~~~~ 362 | You can define hooks which are functions that are executed whenever a valid key:value is found. 363 | The `Forbidden` class is an example of this. 364 | 365 | You can mark a key as forbidden as follows: 366 | 367 | .. code:: python 368 | 369 | >>> from schema import Forbidden 370 | >>> Schema({Forbidden('age'): object}).validate({'age': 50}) 371 | Traceback (most recent call last): 372 | ... 373 | schema.SchemaForbiddenKeyError: Forbidden key encountered: 'age' in {'age': 50} 374 | 375 | A few things are worth noting. First, the value paired with the forbidden 376 | key determines whether it will be rejected: 377 | 378 | .. code:: python 379 | 380 | >>> Schema({Forbidden('age'): str, 'age': int}).validate({'age': 50}) 381 | {'age': 50} 382 | 383 | Note: if we hadn't supplied the 'age' key here, the call would have failed too, but with 384 | SchemaWrongKeyError, not SchemaForbiddenKeyError. 385 | 386 | Second, Forbidden has a higher priority than standard keys, and consequently than Optional. 387 | This means we can do that: 388 | 389 | .. code:: python 390 | 391 | >>> Schema({Forbidden('age'): object, Optional(str): object}).validate({'age': 50}) 392 | Traceback (most recent call last): 393 | ... 394 | schema.SchemaForbiddenKeyError: Forbidden key encountered: 'age' in {'age': 50} 395 | 396 | You can also define your own hooks. The following hook will call `_my_function` if `key` is encountered. 397 | 398 | .. code:: python 399 | 400 | from schema import Hook 401 | def _my_function(key, scope, error): 402 | print(key, scope, error) 403 | 404 | Hook("key", handler=_my_function) 405 | 406 | Here's an example where a `Deprecated` class is added to log warnings whenever a key is encountered: 407 | 408 | .. code:: python 409 | 410 | from schema import Hook, Schema 411 | class Deprecated(Hook): 412 | def __init__(self, *args, **kwargs): 413 | kwargs["handler"] = lambda key, *args: logging.warn(f"`{key}` is deprecated. " + (self._error or "")) 414 | super(Deprecated, self).__init__(*args, **kwargs) 415 | 416 | Schema({Deprecated("test", "custom error message."): object}, ignore_extra_keys=True).validate({"test": "value"}) 417 | ... 418 | WARNING: `test` is deprecated. custom error message. 419 | 420 | Extra Keys 421 | ~~~~~~~~~~ 422 | 423 | The ``Schema(...)`` parameter ``ignore_extra_keys`` causes validation to ignore extra keys in a dictionary, and also to not return them after validating. 424 | 425 | .. code:: python 426 | 427 | >>> schema = Schema({'name': str}, ignore_extra_keys=True) 428 | >>> schema.validate({'name': 'Sam', 'age': '42'}) 429 | {'name': 'Sam'} 430 | 431 | If you would like any extra keys returned, use ``object: object`` as one of the key/value pairs, which will match any key and any value. 432 | Otherwise, extra keys will raise a ``SchemaError``. 433 | 434 | 435 | Customized Validation 436 | ~~~~~~~~~~~~~~~~~~~~~~~ 437 | 438 | The ``Schema.validate`` method accepts additional keyword arguments. The 439 | keyword arguments will be propagated to the ``validate`` method of any 440 | child validatables (including any ad-hoc ``Schema`` objects), or the default 441 | value callable (if a callable is specified) for ``Optional`` keys. 442 | 443 | This feature can be used together with inheritance of the ``Schema`` class 444 | for customized validation. 445 | 446 | Here is an example where a "post-validation" hook that runs after validation 447 | against a sub-schema in a larger schema: 448 | 449 | .. code:: python 450 | 451 | class EventSchema(schema.Schema): 452 | 453 | def validate(self, data, _is_event_schema=True): 454 | data = super(EventSchema, self).validate(data, _is_event_schema=False) 455 | if _is_event_schema and data.get("minimum", None) is None: 456 | data["minimum"] = data["capacity"] 457 | return data 458 | 459 | 460 | events_schema = schema.Schema( 461 | { 462 | str: EventSchema({ 463 | "capacity": int, 464 | schema.Optional("minimum"): int, # default to capacity 465 | }) 466 | } 467 | ) 468 | 469 | 470 | data = {'event1': {'capacity': 1}, 'event2': {'capacity': 2, 'minimum': 3}} 471 | events = events_schema.validate(data) 472 | 473 | assert events['event1']['minimum'] == 1 # == capacity 474 | assert events['event2']['minimum'] == 3 475 | 476 | 477 | Note that the additional keyword argument ``_is_event_schema`` is necessary to 478 | limit the customized behavior to the ``EventSchema`` object itself so that it 479 | won't affect any recursive invoke of the ``self.__class__.validate`` for the 480 | child schemas (e.g., the call to ``Schema("capacity").validate("capacity")``). 481 | 482 | 483 | User-friendly error reporting 484 | ------------------------------------------------------------------------------- 485 | 486 | You can pass a keyword argument ``error`` to any of validatable classes 487 | (such as ``Schema``, ``And``, ``Or``, ``Regex``, ``Use``) to report this error 488 | instead of a built-in one. 489 | 490 | .. code:: python 491 | 492 | >>> Schema(Use(int, error='Invalid year')).validate('XVII') 493 | Traceback (most recent call last): 494 | ... 495 | schema.SchemaError: Invalid year 496 | 497 | You can see all errors that occurred by accessing exception's ``exc.autos`` 498 | for auto-generated error messages, and ``exc.errors`` for errors 499 | which had ``error`` text passed to them. 500 | 501 | You can exit with ``sys.exit(exc.code)`` if you want to show the messages 502 | to the user without traceback. ``error`` messages are given precedence in that 503 | case. 504 | 505 | A JSON API example 506 | ------------------------------------------------------------------------------- 507 | 508 | Here is a quick example: validation of 509 | `create a gist `_ 510 | request from github API. 511 | 512 | .. code:: python 513 | 514 | >>> gist = '''{"description": "the description for this gist", 515 | ... "public": true, 516 | ... "files": { 517 | ... "file1.txt": {"content": "String file contents"}, 518 | ... "other.txt": {"content": "Another file contents"}}}''' 519 | 520 | >>> from schema import Schema, And, Use, Optional 521 | 522 | >>> import json 523 | 524 | >>> gist_schema = Schema( 525 | ... And( 526 | ... Use(json.loads), # first convert from JSON 527 | ... # use str since json returns unicode 528 | ... { 529 | ... Optional("description"): str, 530 | ... "public": bool, 531 | ... "files": {str: {"content": str}}, 532 | ... }, 533 | ... ) 534 | ... ) 535 | 536 | >>> gist = gist_schema.validate(gist) 537 | 538 | # gist: 539 | {u'description': u'the description for this gist', 540 | u'files': {u'file1.txt': {u'content': u'String file contents'}, 541 | u'other.txt': {u'content': u'Another file contents'}}, 542 | u'public': True} 543 | 544 | Using **schema** with `docopt `_ 545 | ------------------------------------------------------------------------------- 546 | 547 | Assume you are using **docopt** with the following usage-pattern: 548 | 549 | Usage: my_program.py [--count=N] ... 550 | 551 | and you would like to validate that ```` are readable, and that 552 | ```` exists, and that ``--count`` is either integer from 0 to 5, or 553 | ``None``. 554 | 555 | Assuming **docopt** returns the following dict: 556 | 557 | .. code:: python 558 | 559 | >>> args = { 560 | ... "": ["LICENSE-MIT", "setup.py"], 561 | ... "": "../", 562 | ... "--count": "3", 563 | ... } 564 | 565 | this is how you validate it using ``schema``: 566 | 567 | .. code:: python 568 | 569 | >>> from schema import Schema, And, Or, Use 570 | >>> import os 571 | 572 | >>> s = Schema({ 573 | ... "": [Use(open)], 574 | ... "": os.path.exists, 575 | ... "--count": Or(None, And(Use(int), lambda n: 0 < n < 5)), 576 | ... }) 577 | 578 | 579 | >>> args = s.validate(args) 580 | 581 | >>> args[''] 582 | [<_io.TextIOWrapper name='LICENSE-MIT' ...>, <_io.TextIOWrapper name='setup.py' ...] 583 | 584 | >>> args[''] 585 | '../' 586 | 587 | >>> args['--count'] 588 | 3 589 | 590 | As you can see, **schema** validated data successfully, opened files and 591 | converted ``'3'`` to ``int``. 592 | 593 | JSON schema 594 | ----------- 595 | 596 | You can also generate standard `draft-07 JSON schema `_ from a dict ``Schema``. 597 | This can be used to add word completion, validation, and documentation directly in code editors. 598 | The output schema can also be used with JSON schema compatible libraries. 599 | 600 | JSON: Generating 601 | ~~~~~~~~~~~~~~~~ 602 | 603 | Just define your schema normally and call ``.json_schema()`` on it. The output is a Python dict, you need to dump it to JSON. 604 | 605 | .. code:: python 606 | 607 | >>> from schema import Optional, Schema 608 | >>> import json 609 | >>> s = Schema({ 610 | ... "test": str, 611 | ... "nested": {Optional("other"): str}, 612 | ... }) 613 | >>> json_schema = json.dumps(s.json_schema("https://example.com/my-schema.json")) 614 | 615 | # json_schema 616 | { 617 | "type":"object", 618 | "properties": { 619 | "test": {"type": "string"}, 620 | "nested": { 621 | "type":"object", 622 | "properties": { 623 | "other": {"type": "string"} 624 | }, 625 | "required": [], 626 | "additionalProperties": false 627 | } 628 | }, 629 | "required":[ 630 | "test", 631 | "nested" 632 | ], 633 | "additionalProperties":false, 634 | "$id":"https://example.com/my-schema.json", 635 | "$schema":"http://json-schema.org/draft-07/schema#" 636 | } 637 | 638 | You can add descriptions for the schema elements using the ``Literal`` object instead of a string. The main schema can also have a description. 639 | 640 | These will appear in IDEs to help your users write a configuration. 641 | 642 | .. code:: python 643 | 644 | >>> from schema import Literal, Schema 645 | >>> import json 646 | >>> s = Schema( 647 | ... {Literal("project_name", description="Names must be unique"): str}, 648 | ... description="Project schema", 649 | ... ) 650 | >>> json_schema = json.dumps(s.json_schema("https://example.com/my-schema.json"), indent=4) 651 | 652 | # json_schema 653 | { 654 | "type": "object", 655 | "properties": { 656 | "project_name": { 657 | "description": "Names must be unique", 658 | "type": "string" 659 | } 660 | }, 661 | "required": [ 662 | "project_name" 663 | ], 664 | "additionalProperties": false, 665 | "$id": "https://example.com/my-schema.json", 666 | "$schema": "http://json-schema.org/draft-07/schema#", 667 | "description": "Project schema" 668 | } 669 | 670 | 671 | JSON: Supported validations 672 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~ 673 | 674 | The resulting JSON schema is not guaranteed to accept the same objects as the library would accept, since some validations are not implemented or 675 | have no JSON schema equivalent. This is the case of the ``Use`` and ``Hook`` objects for example. 676 | 677 | Implemented 678 | ''''''''''' 679 | 680 | `Object properties `_ 681 | Use a dict literal. The dict keys are the JSON schema properties. 682 | 683 | Example: 684 | 685 | ``Schema({"test": str})`` 686 | 687 | becomes 688 | 689 | ``{'type': 'object', 'properties': {'test': {'type': 'string'}}, 'required': ['test'], 'additionalProperties': False}``. 690 | 691 | Please note that attributes are required by default. To create optional attributes use ``Optional``, like so: 692 | 693 | ``Schema({Optional("test"): str})`` 694 | 695 | becomes 696 | 697 | ``{'type': 'object', 'properties': {'test': {'type': 'string'}}, 'required': [], 'additionalProperties': False}`` 698 | 699 | additionalProperties is set to true when at least one of the conditions is met: 700 | - ignore_extra_keys is True 701 | - at least one key is `str` or `object` 702 | 703 | For example: 704 | 705 | ``Schema({str: str})`` and ``Schema({}, ignore_extra_keys=True)`` 706 | 707 | both becomes 708 | 709 | ``{'type': 'object', 'properties' : {}, 'required': [], 'additionalProperties': True}`` 710 | 711 | and 712 | 713 | ``Schema({})`` 714 | 715 | becomes 716 | 717 | ``{'type': 'object', 'properties' : {}, 'required': [], 'additionalProperties': False}`` 718 | 719 | Types 720 | Use the Python type name directly. It will be converted to the JSON name: 721 | 722 | - ``str`` -> `string `_ 723 | - ``int`` -> `integer `_ 724 | - ``float`` -> `number `_ 725 | - ``bool`` -> `boolean `_ 726 | - ``list`` -> `array `_ 727 | - ``dict`` -> `object `_ 728 | 729 | Example: 730 | 731 | ``Schema(float)`` 732 | 733 | becomes 734 | 735 | ``{"type": "number"}`` 736 | 737 | `Array items `_ 738 | Surround a schema with ``[]``. 739 | 740 | Example: 741 | 742 | ``Schema([str])`` means an array of string and becomes: 743 | 744 | ``{'type': 'array', 'items': {'type': 'string'}}`` 745 | 746 | `Enumerated values `_ 747 | Use `Or`. 748 | 749 | Example: 750 | 751 | ``Schema(Or(1, 2, 3))`` becomes 752 | 753 | ``{"enum": [1, 2, 3]}`` 754 | 755 | `Constant values `_ 756 | Use the value itself. 757 | 758 | Example: 759 | 760 | ``Schema("name")`` becomes 761 | 762 | ``{"const": "name"}`` 763 | 764 | `Regular expressions `_ 765 | Use ``Regex``. 766 | 767 | Example: 768 | 769 | ``Schema(Regex("^v\d+"))`` becomes 770 | 771 | ``{'type': 'string', 'pattern': '^v\\d+'}`` 772 | 773 | `Annotations (title and description) `_ 774 | You can use the ``name`` and ``description`` parameters of the ``Schema`` object init method. 775 | 776 | To add description to keys, replace a str with a ``Literal`` object. 777 | 778 | Example: 779 | 780 | ``Schema({Literal("test", description="A description"): str})`` 781 | 782 | is equivalent to 783 | 784 | ``Schema({"test": str})`` 785 | 786 | with the description added to the resulting JSON schema. 787 | 788 | `Combining schemas with allOf `_ 789 | Use ``And`` 790 | 791 | Example: 792 | 793 | ``Schema(And(str, "value"))`` 794 | 795 | becomes 796 | 797 | ``{"allOf": [{"type": "string"}, {"const": "value"}]}`` 798 | 799 | Note that this example is not really useful in the real world, since ``const`` already implies the type. 800 | 801 | `Combining schemas with anyOf `_ 802 | Use ``Or`` 803 | 804 | Example: 805 | 806 | ``Schema(Or(str, int))`` 807 | 808 | becomes 809 | 810 | ``{"anyOf": [{"type": "string"}, {"type": "integer"}]}`` 811 | 812 | 813 | Not implemented 814 | ''''''''''''''' 815 | 816 | The following JSON schema validations cannot be generated from this library. 817 | 818 | - `String length `_ 819 | However, those can be implemented using ``Regex`` 820 | - `String format `_ 821 | However, those can be implemented using ``Regex`` 822 | - `Object dependencies `_ 823 | - `Array length `_ 824 | - `Array uniqueness `_ 825 | - `Numeric multiples `_ 826 | - `Numeric ranges `_ 827 | - `Property Names `_ 828 | Not implemented. We suggest listing the possible keys instead. As a tip, you can use ``Or`` as a dict key. 829 | 830 | Example: 831 | 832 | ``Schema({Or("name1", "name2"): str})`` 833 | - `Annotations (default and examples) `_ 834 | - `Combining schemas with oneOf `_ 835 | - `Not `_ 836 | - `Object size `_ 837 | - `additionalProperties having a different schema (true and false is supported)` 838 | 839 | 840 | JSON: Minimizing output size 841 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 842 | 843 | Explicit Reuse 844 | '''''''''''''' 845 | 846 | If your JSON schema is big and has a lot of repetition, it can be made simpler and smaller by defining Schema objects as reference. 847 | These references will be placed in a "definitions" section in the main schema. 848 | 849 | `You can look at the JSON schema documentation for more information `_ 850 | 851 | .. code:: python 852 | 853 | >>> from schema import Optional, Schema 854 | >>> import json 855 | >>> s = Schema({ 856 | ... "test": str, 857 | ... "nested": Schema({Optional("other"): str}, name="nested", as_reference=True) 858 | ... }) 859 | >>> json_schema = json.dumps(s.json_schema("https://example.com/my-schema.json"), indent=4) 860 | 861 | # json_schema 862 | { 863 | "type": "object", 864 | "properties": { 865 | "test": { 866 | "type": "string" 867 | }, 868 | "nested": { 869 | "$ref": "#/definitions/nested" 870 | } 871 | }, 872 | "required": [ 873 | "test", 874 | "nested" 875 | ], 876 | "additionalProperties": false, 877 | "$id": "https://example.com/my-schema.json", 878 | "$schema": "http://json-schema.org/draft-07/schema#", 879 | "definitions": { 880 | "nested": { 881 | "type": "object", 882 | "properties": { 883 | "other": { 884 | "type": "string" 885 | } 886 | }, 887 | "required": [], 888 | "additionalProperties": false 889 | } 890 | } 891 | } 892 | 893 | This becomes really useful when using the same object several times 894 | 895 | .. code:: python 896 | 897 | >>> from schema import Optional, Or, Schema 898 | >>> import json 899 | >>> language_configuration = Schema( 900 | ... {"autocomplete": bool, "stop_words": [str]}, 901 | ... name="language", 902 | ... as_reference=True, 903 | ... ) 904 | >>> s = Schema({Or("ar", "cs", "de", "el", "eu", "en", "es", "fr"): language_configuration}) 905 | >>> json_schema = json.dumps(s.json_schema("https://example.com/my-schema.json"), indent=4) 906 | 907 | # json_schema 908 | { 909 | "type": "object", 910 | "properties": { 911 | "ar": { 912 | "$ref": "#/definitions/language" 913 | }, 914 | "cs": { 915 | "$ref": "#/definitions/language" 916 | }, 917 | "de": { 918 | "$ref": "#/definitions/language" 919 | }, 920 | "el": { 921 | "$ref": "#/definitions/language" 922 | }, 923 | "eu": { 924 | "$ref": "#/definitions/language" 925 | }, 926 | "en": { 927 | "$ref": "#/definitions/language" 928 | }, 929 | "es": { 930 | "$ref": "#/definitions/language" 931 | }, 932 | "fr": { 933 | "$ref": "#/definitions/language" 934 | } 935 | }, 936 | "required": [], 937 | "additionalProperties": false, 938 | "$id": "https://example.com/my-schema.json", 939 | "$schema": "http://json-schema.org/draft-07/schema#", 940 | "definitions": { 941 | "language": { 942 | "type": "object", 943 | "properties": { 944 | "autocomplete": { 945 | "type": "boolean" 946 | }, 947 | "stop_words": { 948 | "type": "array", 949 | "items": { 950 | "type": "string" 951 | } 952 | } 953 | }, 954 | "required": [ 955 | "autocomplete", 956 | "stop_words" 957 | ], 958 | "additionalProperties": false 959 | } 960 | } 961 | } 962 | 963 | Automatic reuse 964 | ''''''''''''''' 965 | 966 | If you want to minimize the output size without using names explicitly, you can have the library generate hashes of parts of the output JSON 967 | schema and use them as references throughout. 968 | 969 | Enable this behaviour by providing the parameter ``use_refs`` to the json_schema method. 970 | 971 | Be aware that this method is less often compatible with IDEs and JSON schema libraries. 972 | It produces a JSON schema that is more difficult to read by humans. 973 | 974 | .. code:: python 975 | 976 | >>> from schema import Optional, Or, Schema 977 | >>> import json 978 | >>> language_configuration = Schema({"autocomplete": bool, "stop_words": [str]}) 979 | >>> s = Schema({Or("ar", "cs", "de", "el", "eu", "en", "es", "fr"): language_configuration}) 980 | >>> json_schema = json.dumps(s.json_schema("https://example.com/my-schema.json", use_refs=True), indent=4) 981 | 982 | # json_schema 983 | { 984 | "type": "object", 985 | "properties": { 986 | "ar": { 987 | "type": "object", 988 | "properties": { 989 | "autocomplete": { 990 | "type": "boolean", 991 | "$id": "#6456104181059880193" 992 | }, 993 | "stop_words": { 994 | "type": "array", 995 | "items": { 996 | "type": "string", 997 | "$id": "#1856069563381977338" 998 | } 999 | } 1000 | }, 1001 | "required": [ 1002 | "autocomplete", 1003 | "stop_words" 1004 | ], 1005 | "additionalProperties": false 1006 | }, 1007 | "cs": { 1008 | "type": "object", 1009 | "properties": { 1010 | "autocomplete": { 1011 | "$ref": "#6456104181059880193" 1012 | }, 1013 | "stop_words": { 1014 | "type": "array", 1015 | "items": { 1016 | "$ref": "#1856069563381977338" 1017 | }, 1018 | "$id": "#-5377945144312515805" 1019 | } 1020 | }, 1021 | "required": [ 1022 | "autocomplete", 1023 | "stop_words" 1024 | ], 1025 | "additionalProperties": false 1026 | }, 1027 | "de": { 1028 | "type": "object", 1029 | "properties": { 1030 | "autocomplete": { 1031 | "$ref": "#6456104181059880193" 1032 | }, 1033 | "stop_words": { 1034 | "$ref": "#-5377945144312515805" 1035 | } 1036 | }, 1037 | "required": [ 1038 | "autocomplete", 1039 | "stop_words" 1040 | ], 1041 | "additionalProperties": false, 1042 | "$id": "#-8142886105174600858" 1043 | }, 1044 | "el": { 1045 | "$ref": "#-8142886105174600858" 1046 | }, 1047 | "eu": { 1048 | "$ref": "#-8142886105174600858" 1049 | }, 1050 | "en": { 1051 | "$ref": "#-8142886105174600858" 1052 | }, 1053 | "es": { 1054 | "$ref": "#-8142886105174600858" 1055 | }, 1056 | "fr": { 1057 | "$ref": "#-8142886105174600858" 1058 | } 1059 | }, 1060 | "required": [], 1061 | "additionalProperties": false, 1062 | "$id": "https://example.com/my-schema.json", 1063 | "$schema": "http://json-schema.org/draft-07/schema#" 1064 | } 1065 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.ruff.lint] 2 | extend-select = ["I"] 3 | ignore = ["F403", "E501", "N802", "N803", "N806", "C901", "D100", "D102", "D102", "D10"] 4 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | contextlib2>=0.5.5; python_version < "3.3" 2 | -------------------------------------------------------------------------------- /schema/__init__.py: -------------------------------------------------------------------------------- 1 | """schema is a library for validating Python data structures, such as those 2 | obtained from config-files, forms, external services or command-line 3 | parsing, converted from JSON/YAML (or something else) to Python data-types.""" 4 | 5 | import inspect 6 | import re 7 | from typing import ( 8 | TYPE_CHECKING, 9 | Any, 10 | Callable, 11 | Dict, 12 | Generic, 13 | Iterable, 14 | List, 15 | NoReturn, 16 | Sequence, 17 | Set, 18 | Sized, 19 | Tuple, 20 | Type, 21 | TypeVar, 22 | Union, 23 | cast, 24 | ) 25 | 26 | # Use TYPE_CHECKING to determine the correct type hint but avoid runtime import errors 27 | if TYPE_CHECKING: 28 | # Only for type checking purposes, we import the standard ExitStack 29 | from contextlib import ExitStack 30 | else: 31 | try: 32 | from contextlib import ExitStack # Python 3.3 and later 33 | except ImportError: 34 | from contextlib2 import ExitStack # Python 2.x/3.0-3.2 fallback 35 | 36 | 37 | __version__ = "0.7.7" 38 | __all__ = [ 39 | "Schema", 40 | "And", 41 | "Or", 42 | "Regex", 43 | "Optional", 44 | "Use", 45 | "Forbidden", 46 | "Const", 47 | "Literal", 48 | "SchemaError", 49 | "SchemaWrongKeyError", 50 | "SchemaMissingKeyError", 51 | "SchemaForbiddenKeyError", 52 | "SchemaUnexpectedTypeError", 53 | "SchemaOnlyOneAllowedError", 54 | ] 55 | 56 | 57 | class SchemaError(Exception): 58 | """Error during Schema validation.""" 59 | 60 | def __init__( 61 | self, 62 | autos: Union[Sequence[Union[str, None]], None], 63 | errors: Union[List, str, None] = None, 64 | ): 65 | self.autos = autos if isinstance(autos, List) else [autos] 66 | self.errors = errors if isinstance(errors, List) else [errors] 67 | Exception.__init__(self, self.code) 68 | 69 | @property 70 | def code(self) -> str: 71 | """Remove duplicates in autos and errors list and combine them into a single message.""" 72 | 73 | def uniq(seq: Iterable[Union[str, None]]) -> List[str]: 74 | """Utility function to remove duplicates while preserving the order.""" 75 | seen: Set[str] = set() 76 | unique_list: List[str] = [] 77 | for x in seq: 78 | if x is not None and x not in seen: 79 | seen.add(x) 80 | unique_list.append(x) 81 | return unique_list 82 | 83 | data_set = uniq(self.autos) 84 | error_list = uniq(self.errors) 85 | 86 | return "\n".join(error_list if error_list else data_set) 87 | 88 | 89 | class SchemaWrongKeyError(SchemaError): 90 | """Error Should be raised when an unexpected key is detected within the 91 | data set being.""" 92 | 93 | pass 94 | 95 | 96 | class SchemaMissingKeyError(SchemaError): 97 | """Error should be raised when a mandatory key is not found within the 98 | data set being validated""" 99 | 100 | pass 101 | 102 | 103 | class SchemaOnlyOneAllowedError(SchemaError): 104 | """Error should be raised when an only_one Or key has multiple matching candidates""" 105 | 106 | pass 107 | 108 | 109 | class SchemaForbiddenKeyError(SchemaError): 110 | """Error should be raised when a forbidden key is found within the 111 | data set being validated, and its value matches the value that was specified""" 112 | 113 | pass 114 | 115 | 116 | class SchemaUnexpectedTypeError(SchemaError): 117 | """Error should be raised when a type mismatch is detected within the 118 | data set being validated.""" 119 | 120 | pass 121 | 122 | 123 | # Type variable to represent a Schema-like type 124 | TSchema = TypeVar("TSchema", bound="Schema") 125 | 126 | 127 | class And(Generic[TSchema]): 128 | """ 129 | Utility function to combine validation directives in AND Boolean fashion. 130 | """ 131 | 132 | def __init__( 133 | self, 134 | *args: Union[TSchema, Callable[..., Any]], 135 | error: Union[str, None] = None, 136 | ignore_extra_keys: bool = False, 137 | schema: Union[Type[TSchema], None] = None, 138 | ) -> None: 139 | self._args: Tuple[Union[TSchema, Callable[..., Any]], ...] = args 140 | self._error: Union[str, None] = error 141 | self._ignore_extra_keys: bool = ignore_extra_keys 142 | self._schema_class: Type[TSchema] = schema if schema is not None else Schema 143 | 144 | def __repr__(self) -> str: 145 | return f"{self.__class__.__name__}({', '.join(repr(a) for a in self._args)})" 146 | 147 | @property 148 | def args(self) -> Tuple[Union[TSchema, Callable[..., Any]], ...]: 149 | """The provided parameters""" 150 | return self._args 151 | 152 | def validate(self, data: Any, **kwargs: Any) -> Any: 153 | """ 154 | Validate data using defined sub schema/expressions ensuring all 155 | values are valid. 156 | :param data: Data to be validated with sub defined schemas. 157 | :return: Returns validated data. 158 | """ 159 | # Annotate sub_schema with the type returned by _build_schema 160 | for sub_schema in self._build_schemas(): # type: TSchema 161 | data = sub_schema.validate(data, **kwargs) 162 | return data 163 | 164 | def _build_schemas(self) -> List[TSchema]: 165 | return [self._build_schema(s) for s in self._args] 166 | 167 | def _build_schema(self, arg: Any) -> TSchema: 168 | # Assume self._schema_class(arg, ...) returns an instance of TSchema 169 | return self._schema_class( 170 | arg, error=self._error, ignore_extra_keys=self._ignore_extra_keys 171 | ) 172 | 173 | 174 | class Or(And[TSchema]): 175 | """Utility function to combine validation directives in a OR Boolean 176 | fashion. 177 | 178 | If one wants to make an xor, one can provide only_one=True optional argument 179 | to the constructor of this object. When a validation was performed for an 180 | xor-ish Or instance and one wants to use it another time, one needs to call 181 | reset() to put the match_count back to 0.""" 182 | 183 | def __init__( 184 | self, 185 | *args: Union[TSchema, Callable[..., Any]], 186 | only_one: bool = False, 187 | **kwargs: Any, 188 | ) -> None: 189 | self.only_one: bool = only_one 190 | self.match_count: int = 0 191 | super().__init__(*args, **kwargs) 192 | 193 | def reset(self) -> None: 194 | failed: bool = self.match_count > 1 and self.only_one 195 | self.match_count = 0 196 | if failed: 197 | raise SchemaOnlyOneAllowedError( 198 | ["There are multiple keys present from the %r condition" % self] 199 | ) 200 | 201 | def validate(self, data: Any, **kwargs: Any) -> Any: 202 | """ 203 | Validate data using sub defined schema/expressions ensuring at least 204 | one value is valid. 205 | :param data: data to be validated by provided schema. 206 | :return: return validated data if not validation 207 | """ 208 | autos: List[str] = [] 209 | errors: List[Union[str, None]] = [] 210 | for sub_schema in self._build_schemas(): 211 | try: 212 | validation: Any = sub_schema.validate(data, **kwargs) 213 | self.match_count += 1 214 | if self.match_count > 1 and self.only_one: 215 | break 216 | return validation 217 | except SchemaError as _x: 218 | autos += _x.autos 219 | errors += _x.errors 220 | raise SchemaError( 221 | ["%r did not validate %r" % (self, data)] + autos, 222 | [self._error.format(data) if self._error else None] + errors, 223 | ) 224 | 225 | 226 | class Regex: 227 | """ 228 | Enables schema.py to validate string using regular expressions. 229 | """ 230 | 231 | # Map all flags bits to a more readable description 232 | NAMES = [ 233 | "re.ASCII", 234 | "re.DEBUG", 235 | "re.VERBOSE", 236 | "re.UNICODE", 237 | "re.DOTALL", 238 | "re.MULTILINE", 239 | "re.LOCALE", 240 | "re.IGNORECASE", 241 | "re.TEMPLATE", 242 | ] 243 | 244 | def __init__( 245 | self, pattern_str: str, flags: int = 0, error: Union[str, None] = None 246 | ) -> None: 247 | self._pattern_str: str = pattern_str 248 | flags_list = [ 249 | Regex.NAMES[i] for i, f in enumerate(f"{flags:09b}") if f != "0" 250 | ] # Name for each bit 251 | 252 | self._flags_names: str = ", flags=" + "|".join(flags_list) if flags_list else "" 253 | self._pattern: re.Pattern = re.compile(pattern_str, flags=flags) 254 | self._error: Union[str, None] = error 255 | 256 | def __repr__(self) -> str: 257 | return f"{self.__class__.__name__}({self._pattern_str!r}{self._flags_names})" 258 | 259 | @property 260 | def pattern_str(self) -> str: 261 | """The pattern string for the represented regular expression""" 262 | return self._pattern_str 263 | 264 | def validate(self, data: str, **kwargs: Any) -> str: 265 | """ 266 | Validates data using the defined regex. 267 | :param data: Data to be validated. 268 | :return: Returns validated data. 269 | """ 270 | e = self._error 271 | 272 | try: 273 | if self._pattern.search(data): 274 | return data 275 | else: 276 | error_message = ( 277 | e.format(data) 278 | if e 279 | else f"{data!r} does not match {self._pattern_str!r}" 280 | ) 281 | raise SchemaError(error_message) 282 | except TypeError: 283 | error_message = ( 284 | e.format(data) if e else f"{data!r} is not string nor buffer" 285 | ) 286 | raise SchemaError(error_message) 287 | 288 | 289 | class Use: 290 | """ 291 | For more general use cases, you can use the Use class to transform 292 | the data while it is being validated. 293 | """ 294 | 295 | def __init__( 296 | self, callable_: Callable[[Any], Any], error: Union[str, None] = None 297 | ) -> None: 298 | if not callable(callable_): 299 | raise TypeError(f"Expected a callable, not {callable_!r}") 300 | self._callable: Callable[[Any], Any] = callable_ 301 | self._error: Union[str, None] = error 302 | 303 | def __repr__(self) -> str: 304 | return f"{self.__class__.__name__}({self._callable!r})" 305 | 306 | def validate(self, data: Any, **kwargs: Any) -> Any: 307 | try: 308 | return self._callable(data) 309 | except SchemaError as x: 310 | raise SchemaError( 311 | [None] + x.autos, 312 | [self._error.format(data) if self._error else None] + x.errors, 313 | ) 314 | except BaseException as x: 315 | f = _callable_str(self._callable) 316 | raise SchemaError( 317 | "%s(%r) raised %r" % (f, data, x), 318 | self._error.format(data) if self._error else None, 319 | ) 320 | 321 | 322 | COMPARABLE, CALLABLE, VALIDATOR, TYPE, DICT, ITERABLE = range(6) 323 | 324 | 325 | def _priority(s: Any) -> int: 326 | """Return priority for a given object.""" 327 | if type(s) in (list, tuple, set, frozenset): 328 | return ITERABLE 329 | if isinstance(s, dict): 330 | return DICT 331 | if issubclass(type(s), type): 332 | return TYPE 333 | if isinstance(s, Literal): 334 | return COMPARABLE 335 | if hasattr(s, "validate"): 336 | return VALIDATOR 337 | if callable(s): 338 | return CALLABLE 339 | else: 340 | return COMPARABLE 341 | 342 | 343 | def _invoke_with_optional_kwargs(f: Callable[..., Any], **kwargs: Any) -> Any: 344 | s = inspect.signature(f) 345 | if len(s.parameters) == 0: 346 | return f() 347 | return f(**kwargs) 348 | 349 | 350 | class Schema(object): 351 | """ 352 | Entry point of the library, use this class to instantiate validation 353 | schema for the data that will be validated. 354 | """ 355 | 356 | def __init__( 357 | self, 358 | schema: Any, 359 | error: Union[str, None] = None, 360 | ignore_extra_keys: bool = False, 361 | name: Union[str, None] = None, 362 | description: Union[str, None] = None, 363 | as_reference: bool = False, 364 | ) -> None: 365 | self._schema: Any = schema 366 | self._error: Union[str, None] = error 367 | self._ignore_extra_keys: bool = ignore_extra_keys 368 | self._name: Union[str, None] = name 369 | self._description: Union[str, None] = description 370 | self.as_reference: bool = as_reference 371 | 372 | if as_reference and name is None: 373 | raise ValueError("Schema used as reference should have a name") 374 | 375 | def __repr__(self): 376 | return "%s(%r)" % (self.__class__.__name__, self._schema) 377 | 378 | @property 379 | def schema(self) -> Any: 380 | return self._schema 381 | 382 | @property 383 | def description(self) -> Union[str, None]: 384 | return self._description 385 | 386 | @property 387 | def name(self) -> Union[str, None]: 388 | return self._name 389 | 390 | @property 391 | def ignore_extra_keys(self) -> bool: 392 | return self._ignore_extra_keys 393 | 394 | @staticmethod 395 | def _dict_key_priority(s) -> float: 396 | """Return priority for a given key object.""" 397 | if isinstance(s, Hook): 398 | return _priority(s._schema) - 0.5 399 | if isinstance(s, Optional): 400 | return _priority(s._schema) + 0.5 401 | return _priority(s) 402 | 403 | @staticmethod 404 | def _is_optional_type(s: Any) -> bool: 405 | """Return True if the given key is optional (does not have to be found)""" 406 | return any(isinstance(s, optional_type) for optional_type in [Optional, Hook]) 407 | 408 | def is_valid(self, data: Any, **kwargs: Dict[str, Any]) -> bool: 409 | """Return whether the given data has passed all the validations 410 | that were specified in the given schema. 411 | """ 412 | try: 413 | self.validate(data, **kwargs) 414 | except SchemaError: 415 | return False 416 | else: 417 | return True 418 | 419 | def _prepend_schema_name(self, message: str) -> str: 420 | """ 421 | If a custom schema name has been defined, prepends it to the error 422 | message that gets raised when a schema error occurs. 423 | """ 424 | if self._name: 425 | message = "{0!r} {1!s}".format(self._name, message) 426 | return message 427 | 428 | def validate(self, data: Any, **kwargs: Dict[str, Any]) -> Any: 429 | Schema = self.__class__ 430 | s: Any = self._schema 431 | e: Union[str, None] = self._error 432 | i: bool = self._ignore_extra_keys 433 | 434 | if isinstance(s, Literal): 435 | s = s.schema 436 | 437 | flavor = _priority(s) 438 | if flavor == ITERABLE: 439 | data = Schema(type(s), error=e).validate(data, **kwargs) 440 | o: Or = Or(*s, error=e, schema=Schema, ignore_extra_keys=i) 441 | return type(data)(o.validate(d, **kwargs) for d in data) 442 | if flavor == DICT: 443 | exitstack = ExitStack() 444 | data = Schema(dict, error=e).validate(data, **kwargs) 445 | new: Dict = type(data)() # new - is a dict of the validated values 446 | coverage: Set = set() # matched schema keys 447 | # for each key and value find a schema entry matching them, if any 448 | sorted_skeys = sorted(s, key=self._dict_key_priority) 449 | for skey in sorted_skeys: 450 | if hasattr(skey, "reset"): 451 | exitstack.callback(skey.reset) 452 | 453 | with exitstack: 454 | # Evaluate dictionaries last 455 | data_items = sorted( 456 | data.items(), key=lambda value: isinstance(value[1], dict) 457 | ) 458 | for key, value in data_items: 459 | for skey in sorted_skeys: 460 | svalue = s[skey] 461 | try: 462 | nkey = Schema(skey, error=e).validate(key, **kwargs) 463 | except SchemaError: 464 | pass 465 | else: 466 | if isinstance(skey, Hook): 467 | # As the content of the value makes little sense for 468 | # keys with a hook, we reverse its meaning: 469 | # we will only call the handler if the value does match 470 | # In the case of the forbidden key hook, 471 | # we will raise the SchemaErrorForbiddenKey exception 472 | # on match, allowing for excluding a key only if its 473 | # value has a certain type, and allowing Forbidden to 474 | # work well in combination with Optional. 475 | try: 476 | nvalue = Schema(svalue, error=e).validate( 477 | value, **kwargs 478 | ) 479 | except SchemaError: 480 | continue 481 | skey.handler(nkey, data, e) 482 | else: 483 | try: 484 | nvalue = Schema( 485 | svalue, error=e, ignore_extra_keys=i 486 | ).validate(value, **kwargs) 487 | except SchemaError as x: 488 | k = "Key '%s' error:" % nkey 489 | message = self._prepend_schema_name(k) 490 | raise SchemaError( 491 | [message] + x.autos, 492 | [e.format(data) if e else None] + x.errors, 493 | ) 494 | else: 495 | new[nkey] = nvalue 496 | coverage.add(skey) 497 | break 498 | required = set(k for k in s if not self._is_optional_type(k)) 499 | if not required.issubset(coverage): 500 | missing_keys = required - coverage 501 | s_missing_keys = ", ".join( 502 | repr(k) for k in sorted(missing_keys, key=repr) 503 | ) 504 | message = "Missing key%s: %s" % ( 505 | _plural_s(missing_keys), 506 | s_missing_keys, 507 | ) 508 | message = self._prepend_schema_name(message) 509 | raise SchemaMissingKeyError(message, e.format(data) if e else None) 510 | if not self._ignore_extra_keys and (len(new) != len(data)): 511 | wrong_keys = set(data.keys()) - set(new.keys()) 512 | s_wrong_keys = ", ".join(repr(k) for k in sorted(wrong_keys, key=repr)) 513 | message = "Wrong key%s %s in %r" % ( 514 | _plural_s(wrong_keys), 515 | s_wrong_keys, 516 | data, 517 | ) 518 | message = self._prepend_schema_name(message) 519 | raise SchemaWrongKeyError(message, e.format(data) if e else None) 520 | 521 | # Apply default-having optionals that haven't been used: 522 | defaults = ( 523 | set(k for k in s if isinstance(k, Optional) and hasattr(k, "default")) 524 | - coverage 525 | ) 526 | for default in defaults: 527 | new[default.key] = ( 528 | _invoke_with_optional_kwargs(default.default, **kwargs) 529 | if callable(default.default) 530 | else default.default 531 | ) 532 | 533 | return new 534 | if flavor == TYPE: 535 | if isinstance(data, s) and not (isinstance(data, bool) and s == int): 536 | return data 537 | else: 538 | message = "%r should be instance of %r" % (data, s.__name__) 539 | message = self._prepend_schema_name(message) 540 | raise SchemaUnexpectedTypeError(message, e.format(data) if e else None) 541 | if flavor == VALIDATOR: 542 | try: 543 | return s.validate(data, **kwargs) 544 | except SchemaError as x: 545 | raise SchemaError( 546 | [None] + x.autos, [e.format(data) if e else None] + x.errors 547 | ) 548 | except BaseException as x: 549 | message = "%r.validate(%r) raised %r" % (s, data, x) 550 | message = self._prepend_schema_name(message) 551 | raise SchemaError(message, e.format(data) if e else None) 552 | if flavor == CALLABLE: 553 | f = _callable_str(s) 554 | try: 555 | if s(data): 556 | return data 557 | except SchemaError as x: 558 | raise SchemaError( 559 | [None] + x.autos, [e.format(data) if e else None] + x.errors 560 | ) 561 | except BaseException as x: 562 | message = "%s(%r) raised %r" % (f, data, x) 563 | message = self._prepend_schema_name(message) 564 | raise SchemaError(message, e.format(data) if e else None) 565 | message = "%s(%r) should evaluate to True" % (f, data) 566 | message = self._prepend_schema_name(message) 567 | raise SchemaError(message, e.format(data) if e else None) 568 | if s == data: 569 | return data 570 | else: 571 | message = "%r does not match %r" % (s, data) 572 | message = self._prepend_schema_name(message) 573 | raise SchemaError(message, e.format(data) if e else None) 574 | 575 | def json_schema( 576 | self, schema_id: str, use_refs: bool = False, **kwargs: Any 577 | ) -> Dict[str, Any]: 578 | """Generate a draft-07 JSON schema dict representing the Schema. 579 | This method must be called with a schema_id. 580 | 581 | :param schema_id: The value of the $id on the main schema 582 | :param use_refs: Enable reusing object references in the resulting JSON schema. 583 | Schemas with references are harder to read by humans, but are a lot smaller when there 584 | is a lot of reuse 585 | """ 586 | 587 | seen: Dict[int, Dict[str, Any]] = {} 588 | definitions_by_name: Dict[str, Dict[str, Any]] = {} 589 | 590 | def _json_schema( 591 | schema: "Schema", 592 | is_main_schema: bool = True, 593 | title: Union[str, None] = None, 594 | description: Union[str, None] = None, 595 | allow_reference: bool = True, 596 | ) -> Dict[str, Any]: 597 | def _create_or_use_ref(return_dict: Dict[str, Any]) -> Dict[str, Any]: 598 | """If not already seen, return the provided part of the schema unchanged. 599 | If already seen, give an id to the already seen dict and return a reference to the previous part 600 | of the schema instead. 601 | """ 602 | if not use_refs or is_main_schema: 603 | return return_schema 604 | 605 | hashed = hash(repr(sorted(return_dict.items()))) 606 | if hashed not in seen: 607 | seen[hashed] = return_dict 608 | return return_dict 609 | else: 610 | id_str = "#" + str(hashed) 611 | seen[hashed]["$id"] = id_str 612 | return {"$ref": id_str} 613 | 614 | def _get_type_name(python_type: Type) -> str: 615 | """Return the JSON schema name for a Python type""" 616 | if python_type == str: 617 | return "string" 618 | elif python_type == int: 619 | return "integer" 620 | elif python_type == float: 621 | return "number" 622 | elif python_type == bool: 623 | return "boolean" 624 | elif python_type == list: 625 | return "array" 626 | elif python_type == dict: 627 | return "object" 628 | return "string" 629 | 630 | def _to_json_type(value: Any) -> Any: 631 | """Attempt to convert a constant value (for "const" and "default") to a JSON serializable value""" 632 | if value is None or type(value) in (str, int, float, bool, list, dict): 633 | return value 634 | 635 | if type(value) in (tuple, set, frozenset): 636 | return list(value) 637 | 638 | if isinstance(value, Literal): 639 | return value.schema 640 | 641 | return str(value) 642 | 643 | def _to_schema(s: Any, ignore_extra_keys: bool) -> Schema: 644 | if not isinstance(s, Schema): 645 | return Schema(s, ignore_extra_keys=ignore_extra_keys) 646 | 647 | return s 648 | 649 | s: Any = schema.schema 650 | i: bool = schema.ignore_extra_keys 651 | flavor = _priority(s) 652 | 653 | return_schema: Dict[str, Any] = {} 654 | 655 | return_description: Union[str, None] = description or schema.description 656 | if return_description: 657 | return_schema["description"] = return_description 658 | if title: 659 | return_schema["title"] = title 660 | 661 | # Check if we have to create a common definition and use as reference 662 | if allow_reference and schema.as_reference: 663 | # Generate sub schema if not already done 664 | if schema.name not in definitions_by_name: 665 | definitions_by_name[ 666 | cast(str, schema.name) 667 | ] = {} # Avoid infinite loop 668 | definitions_by_name[cast(str, schema.name)] = _json_schema( 669 | schema, is_main_schema=False, allow_reference=False 670 | ) 671 | 672 | return_schema["$ref"] = "#/definitions/" + cast(str, schema.name) 673 | else: 674 | if schema.name and not title: 675 | return_schema["title"] = schema.name 676 | 677 | if flavor == TYPE: 678 | # Handle type 679 | return_schema["type"] = _get_type_name(s) 680 | elif flavor == ITERABLE: 681 | # Handle arrays or dict schema 682 | 683 | return_schema["type"] = "array" 684 | if len(s) == 1: 685 | return_schema["items"] = _json_schema( 686 | _to_schema(s[0], i), is_main_schema=False 687 | ) 688 | elif len(s) > 1: 689 | return_schema["items"] = _json_schema( 690 | Schema(Or(*s)), is_main_schema=False 691 | ) 692 | elif isinstance(s, Or): 693 | # Handle Or values 694 | 695 | # Check if we can use an enum 696 | if all( 697 | priority == COMPARABLE 698 | for priority in [_priority(value) for value in s.args] 699 | ): 700 | or_values = [ 701 | str(s) if isinstance(s, Literal) else s for s in s.args 702 | ] 703 | # All values are simple, can use enum or const 704 | if len(or_values) == 1: 705 | or_value = or_values[0] 706 | if or_value is None: 707 | return_schema["type"] = "null" 708 | else: 709 | return_schema["const"] = _to_json_type(or_value) 710 | return return_schema 711 | return_schema["enum"] = or_values 712 | else: 713 | # No enum, let's go with recursive calls 714 | any_of_values = [] 715 | for or_key in s.args: 716 | new_value = _json_schema( 717 | _to_schema(or_key, i), is_main_schema=False 718 | ) 719 | if new_value != {} and new_value not in any_of_values: 720 | any_of_values.append(new_value) 721 | if len(any_of_values) == 1: 722 | # Only one representable condition remains, do not put under anyOf 723 | return_schema.update(any_of_values[0]) 724 | else: 725 | return_schema["anyOf"] = any_of_values 726 | elif isinstance(s, And): 727 | # Handle And values 728 | all_of_values = [] 729 | for and_key in s.args: 730 | new_value = _json_schema( 731 | _to_schema(and_key, i), is_main_schema=False 732 | ) 733 | if new_value != {} and new_value not in all_of_values: 734 | all_of_values.append(new_value) 735 | if len(all_of_values) == 1: 736 | # Only one representable condition remains, do not put under allOf 737 | return_schema.update(all_of_values[0]) 738 | else: 739 | return_schema["allOf"] = all_of_values 740 | elif flavor == COMPARABLE: 741 | if s is None: 742 | return_schema["type"] = "null" 743 | else: 744 | return_schema["const"] = _to_json_type(s) 745 | elif flavor == VALIDATOR and type(s) == Regex: 746 | return_schema["type"] = "string" 747 | # JSON schema uses ECMAScript regex syntax 748 | # Translating one to another is not easy, but this should work for simple cases 749 | return_schema["pattern"] = re.sub( 750 | r"\(\?P<[a-z\d_]+>", "(", s.pattern_str 751 | ).replace("/", r"\/") 752 | else: 753 | if flavor != DICT: 754 | # If not handled, do not check 755 | return return_schema 756 | 757 | # Schema is a dict 758 | 759 | required_keys = [] 760 | expanded_schema = {} 761 | additional_properties = i 762 | for key in s: 763 | if isinstance(key, Hook): 764 | continue 765 | 766 | def _key_allows_additional_properties(key: Any) -> bool: 767 | """Check if a key is broad enough to allow additional properties""" 768 | if isinstance(key, Optional): 769 | return _key_allows_additional_properties(key.schema) 770 | 771 | return key == str or key == object 772 | 773 | def _get_key_title(key: Any) -> Union[str, None]: 774 | """Get the title associated to a key (as specified in a Literal object). Return None if not a Literal""" 775 | if isinstance(key, Optional): 776 | return _get_key_title(key.schema) 777 | 778 | if isinstance(key, Literal): 779 | return key.title 780 | 781 | return None 782 | 783 | def _get_key_description(key: Any) -> Union[str, None]: 784 | """Get the description associated to a key (as specified in a Literal object). Return None if not a Literal""" 785 | if isinstance(key, Optional): 786 | return _get_key_description(key.schema) 787 | 788 | if isinstance(key, Literal): 789 | return key.description 790 | 791 | return None 792 | 793 | def _get_key_name(key: Any) -> Any: 794 | """Get the name of a key (as specified in a Literal object). Return the key unchanged if not a Literal""" 795 | if isinstance(key, Optional): 796 | return _get_key_name(key.schema) 797 | 798 | if isinstance(key, Literal): 799 | return key.schema 800 | 801 | return key 802 | 803 | additional_properties = ( 804 | additional_properties 805 | or _key_allows_additional_properties(key) 806 | ) 807 | sub_schema = _to_schema(s[key], ignore_extra_keys=i) 808 | key_name = _get_key_name(key) 809 | 810 | if isinstance(key_name, str): 811 | if not isinstance(key, Optional): 812 | required_keys.append(key_name) 813 | expanded_schema[key_name] = _json_schema( 814 | sub_schema, 815 | is_main_schema=False, 816 | title=_get_key_title(key), 817 | description=_get_key_description(key), 818 | ) 819 | if isinstance(key, Optional) and hasattr(key, "default"): 820 | expanded_schema[key_name]["default"] = _to_json_type( 821 | _invoke_with_optional_kwargs(key.default, **kwargs) 822 | if callable(key.default) 823 | else key.default 824 | ) 825 | elif isinstance(key_name, Or): 826 | # JSON schema does not support having a key named one name or another, so we just add both options 827 | # This is less strict because we cannot enforce that one or the other is required 828 | 829 | for or_key in key_name.args: 830 | expanded_schema[_get_key_name(or_key)] = _json_schema( 831 | sub_schema, 832 | is_main_schema=False, 833 | description=_get_key_description(or_key), 834 | ) 835 | 836 | return_schema.update( 837 | { 838 | "type": "object", 839 | "properties": expanded_schema, 840 | "required": required_keys, 841 | "additionalProperties": additional_properties, 842 | } 843 | ) 844 | 845 | if is_main_schema: 846 | return_schema.update( 847 | { 848 | "$id": schema_id, 849 | "$schema": "http://json-schema.org/draft-07/schema#", 850 | } 851 | ) 852 | if self._name: 853 | return_schema["title"] = self._name 854 | 855 | if definitions_by_name: 856 | return_schema["definitions"] = {} 857 | for definition_name, definition in definitions_by_name.items(): 858 | return_schema["definitions"][definition_name] = definition 859 | 860 | return _create_or_use_ref(return_schema) 861 | 862 | return _json_schema(self, True) 863 | 864 | 865 | class Optional(Schema): 866 | """Marker for an optional part of the validation Schema.""" 867 | 868 | _MARKER = object() 869 | 870 | def __init__(self, *args: Any, **kwargs: Any) -> None: 871 | default: Any = kwargs.pop("default", self._MARKER) 872 | super(Optional, self).__init__(*args, **kwargs) 873 | if default is not self._MARKER: 874 | if _priority(self._schema) != COMPARABLE: 875 | raise TypeError( 876 | "Optional keys with defaults must have simple, " 877 | "predictable values, like literal strings or ints. " 878 | f'"{self._schema!r}" is too complex.' 879 | ) 880 | self.default = default 881 | self.key = str(self._schema) 882 | 883 | def __hash__(self) -> int: 884 | return hash(self._schema) 885 | 886 | def __eq__(self, other: Any) -> bool: 887 | return ( 888 | self.__class__ is other.__class__ 889 | and getattr(self, "default", self._MARKER) 890 | == getattr(other, "default", self._MARKER) 891 | and self._schema == other._schema 892 | ) 893 | 894 | def reset(self) -> None: 895 | if hasattr(self._schema, "reset"): 896 | self._schema.reset() 897 | 898 | 899 | class Hook(Schema): 900 | def __init__(self, *args: Any, **kwargs: Any) -> None: 901 | self.handler: Callable[..., Any] = kwargs.pop("handler", lambda *args: None) 902 | super(Hook, self).__init__(*args, **kwargs) 903 | self.key = self._schema 904 | 905 | 906 | class Forbidden(Hook): 907 | def __init__(self, *args: Any, **kwargs: Any) -> None: 908 | kwargs["handler"] = self._default_function 909 | super(Forbidden, self).__init__(*args, **kwargs) 910 | 911 | @staticmethod 912 | def _default_function(nkey: Any, data: Any, error: Any) -> NoReturn: 913 | raise SchemaForbiddenKeyError( 914 | f"Forbidden key encountered: {nkey!r} in {data!r}", error 915 | ) 916 | 917 | 918 | class Literal: 919 | def __init__( 920 | self, 921 | value: Any, 922 | description: Union[str, None] = None, 923 | title: Union[str, None] = None, 924 | ) -> None: 925 | self._schema: Any = value 926 | self._description: Union[str, None] = description 927 | self._title: Union[str, None] = title 928 | 929 | def __str__(self) -> str: 930 | return str(self._schema) 931 | 932 | def __repr__(self) -> str: 933 | return f'Literal("{self._schema}", description="{self._description or ""}")' 934 | 935 | @property 936 | def description(self) -> Union[str, None]: 937 | return self._description 938 | 939 | @property 940 | def title(self) -> Union[str, None]: 941 | return self._title 942 | 943 | @property 944 | def schema(self) -> Any: 945 | return self._schema 946 | 947 | 948 | class Const(Schema): 949 | def validate(self, data: Any, **kwargs: Any) -> Any: 950 | super(Const, self).validate(data, **kwargs) 951 | return data 952 | 953 | 954 | def _callable_str(callable_: Callable[..., Any]) -> str: 955 | if hasattr(callable_, "__name__"): 956 | return callable_.__name__ 957 | return str(callable_) 958 | 959 | 960 | def _plural_s(sized: Sized) -> str: 961 | return "s" if len(sized) > 1 else "" 962 | -------------------------------------------------------------------------------- /schema/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/keleshev/schema/3e06d37994442ef3ae5b9a1f8564d5ad598c9a68/schema/py.typed -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [wheel] 2 | universal = 1 3 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import codecs 2 | import os 3 | import re 4 | import sys 5 | 6 | from setuptools import setup 7 | 8 | version_file = os.path.join("schema", "__init__.py") 9 | 10 | with open(version_file) as f: 11 | for line in f.read().split("\n"): 12 | if line.startswith("__version__ ="): 13 | version = re.match( 14 | r"^\s*__version__\s*=\s*['\"](.*?)['\"]\s*$", line 15 | ).group(1) 16 | break 17 | else: 18 | print("No __version__ attribute found in %r" % version_file) 19 | sys.exit(1) 20 | 21 | setup( 22 | name="schema", 23 | version=version, 24 | author="Vladimir Keleshev", 25 | author_email="vladimir@keleshev.com", 26 | description="Simple data validation library", 27 | license="MIT", 28 | keywords="schema json validation", 29 | url="https://github.com/keleshev/schema", 30 | packages=["schema"], 31 | include_package_data=True, 32 | long_description=codecs.open("README.rst", "r", "utf-8").read(), 33 | long_description_content_type="text/x-rst", 34 | install_requires=open("requirements.txt", "r").read().split("\n"), 35 | classifiers=[ 36 | "Development Status :: 3 - Alpha", 37 | "Topic :: Utilities", 38 | "Programming Language :: Python :: 3.6", 39 | "Programming Language :: Python :: 3.7", 40 | "Programming Language :: Python :: 3.8", 41 | "Programming Language :: Python :: 3.9", 42 | "Programming Language :: Python :: 3.10", 43 | "Programming Language :: Python :: 3.11", 44 | "Programming Language :: Python :: Implementation :: PyPy", 45 | "License :: OSI Approved :: MIT License", 46 | ], 47 | ) 48 | -------------------------------------------------------------------------------- /test_schema.py: -------------------------------------------------------------------------------- 1 | from __future__ import with_statement 2 | 3 | import copy 4 | import json 5 | import os 6 | import platform 7 | import re 8 | import sys 9 | from collections import defaultdict, namedtuple 10 | from functools import partial 11 | from operator import methodcaller 12 | 13 | try: 14 | from unittest.mock import Mock 15 | except ImportError: 16 | from mock import Mock 17 | from pytest import mark, raises 18 | 19 | from schema import ( 20 | And, 21 | Const, 22 | Forbidden, 23 | Hook, 24 | Literal, 25 | Optional, 26 | Or, 27 | Regex, 28 | Schema, 29 | SchemaError, 30 | SchemaForbiddenKeyError, 31 | SchemaMissingKeyError, 32 | SchemaUnexpectedTypeError, 33 | SchemaWrongKeyError, 34 | Use, 35 | ) 36 | 37 | if sys.version_info[0] == 3: 38 | basestring = str # Python 3 does not have basestring 39 | unicode = str # Python 3 does not have unicode 40 | 41 | 42 | SE = raises(SchemaError) 43 | 44 | 45 | def ve(_): 46 | raise ValueError() 47 | 48 | 49 | def se(_): 50 | raise SchemaError("first auto", "first error") 51 | 52 | 53 | def sorted_dict(to_sort): 54 | """Helper function to sort list of string inside dictionaries in order to compare them""" 55 | if isinstance(to_sort, dict): 56 | new_dict = {} 57 | for k in sorted(to_sort.keys()): 58 | new_dict[k] = sorted_dict(to_sort[k]) 59 | return new_dict 60 | if isinstance(to_sort, list) and to_sort: 61 | if isinstance(to_sort[0], str): 62 | return sorted(to_sort) 63 | else: 64 | return [sorted_dict(element) for element in to_sort] 65 | return to_sort 66 | 67 | 68 | def test_schema(): 69 | assert Schema(1).validate(1) == 1 70 | with SE: 71 | Schema(1).validate(9) 72 | 73 | assert Schema(int).validate(1) == 1 74 | with SE: 75 | Schema(int).validate("1") 76 | assert Schema(Use(int)).validate("1") == 1 77 | with SE: 78 | Schema(int).validate(int) 79 | with SE: 80 | Schema(int).validate(True) 81 | with SE: 82 | Schema(int).validate(False) 83 | 84 | assert Schema(str).validate("hai") == "hai" 85 | with SE: 86 | Schema(str).validate(1) 87 | assert Schema(Use(str)).validate(1) == "1" 88 | 89 | assert Schema(list).validate(["a", 1]) == ["a", 1] 90 | assert Schema(dict).validate({"a": 1}) == {"a": 1} 91 | with SE: 92 | Schema(dict).validate(["a", 1]) 93 | 94 | assert Schema(lambda n: 0 < n < 5).validate(3) == 3 95 | with SE: 96 | Schema(lambda n: 0 < n < 5).validate(-1) 97 | 98 | 99 | def test_validate_file(): 100 | assert Schema(Use(open)).validate("LICENSE-MIT").read().startswith("Copyright") 101 | with SE: 102 | Schema(Use(open)).validate("NON-EXISTENT") 103 | assert Schema(os.path.exists).validate(".") == "." 104 | with SE: 105 | Schema(os.path.exists).validate("./non-existent/") 106 | assert Schema(os.path.isfile).validate("LICENSE-MIT") == "LICENSE-MIT" 107 | with SE: 108 | Schema(os.path.isfile).validate("NON-EXISTENT") 109 | 110 | 111 | def test_and(): 112 | assert And(int, lambda n: 0 < n < 5).validate(3) == 3 113 | with SE: 114 | And(int, lambda n: 0 < n < 5).validate(3.33) 115 | assert And(Use(int), lambda n: 0 < n < 5).validate(3.33) == 3 116 | with SE: 117 | And(Use(int), lambda n: 0 < n < 5).validate("3.33") 118 | 119 | 120 | def test_or(): 121 | assert Or(int, dict).validate(5) == 5 122 | assert Or(int, dict).validate({}) == {} 123 | with SE: 124 | Or(int, dict).validate("hai") 125 | assert Or(int).validate(4) 126 | with SE: 127 | Or().validate(2) 128 | 129 | 130 | def test_or_only_one(): 131 | or_rule = Or("test1", "test2", only_one=True) 132 | schema = Schema( 133 | {or_rule: str, Optional("sub_schema"): {Optional(copy.deepcopy(or_rule)): str}} 134 | ) 135 | assert schema.validate({"test1": "value"}) 136 | assert schema.validate({"test1": "value", "sub_schema": {"test2": "value"}}) 137 | assert schema.validate({"test2": "other_value"}) 138 | with SE: 139 | schema.validate({"test1": "value", "test2": "other_value"}) 140 | with SE: 141 | schema.validate( 142 | {"test1": "value", "sub_schema": {"test1": "value", "test2": "value"}} 143 | ) 144 | with SE: 145 | schema.validate({"othertest": "value"}) 146 | 147 | extra_keys_schema = Schema({or_rule: str}, ignore_extra_keys=True) 148 | assert extra_keys_schema.validate({"test1": "value", "other-key": "value"}) 149 | assert extra_keys_schema.validate({"test2": "other_value"}) 150 | with SE: 151 | extra_keys_schema.validate({"test1": "value", "test2": "other_value"}) 152 | 153 | 154 | def test_test(): 155 | def unique_list(_list): 156 | return len(_list) == len(set(_list)) 157 | 158 | def dict_keys(key, _list): 159 | return list(map(lambda d: d[key], _list)) 160 | 161 | schema = Schema(Const(And(Use(partial(dict_keys, "index")), unique_list))) 162 | data = [{"index": 1, "value": "foo"}, {"index": 2, "value": "bar"}] 163 | assert schema.validate(data) == data 164 | 165 | bad_data = [{"index": 1, "value": "foo"}, {"index": 1, "value": "bar"}] 166 | with SE: 167 | schema.validate(bad_data) 168 | 169 | 170 | def test_regex(): 171 | # Simple case: validate string 172 | assert Regex(r"foo").validate("afoot") == "afoot" 173 | with SE: 174 | Regex(r"bar").validate("afoot") 175 | 176 | # More complex case: validate string 177 | assert Regex(r"^[a-z]+$").validate("letters") == "letters" 178 | with SE: 179 | Regex(r"^[a-z]+$").validate("letters + spaces") == "letters + spaces" 180 | 181 | # Validate dict key 182 | assert Schema({Regex(r"^foo"): str}).validate({"fookey": "value"}) == { 183 | "fookey": "value" 184 | } 185 | with SE: 186 | Schema({Regex(r"^foo"): str}).validate({"barkey": "value"}) 187 | 188 | # Validate dict value 189 | assert Schema({str: Regex(r"^foo")}).validate({"key": "foovalue"}) == { 190 | "key": "foovalue" 191 | } 192 | with SE: 193 | Schema({str: Regex(r"^foo")}).validate({"key": "barvalue"}) 194 | 195 | # Error if the value does not have a buffer interface 196 | with SE: 197 | Regex(r"bar").validate(1) 198 | with SE: 199 | Regex(r"bar").validate({}) 200 | with SE: 201 | Regex(r"bar").validate([]) 202 | with SE: 203 | Regex(r"bar").validate(None) 204 | 205 | # Validate that the pattern has a buffer interface 206 | assert Regex(re.compile(r"foo")).validate("foo") == "foo" 207 | assert Regex(unicode("foo")).validate("foo") == "foo" 208 | with raises(TypeError): 209 | Regex(1).validate("bar") 210 | with raises(TypeError): 211 | Regex({}).validate("bar") 212 | with raises(TypeError): 213 | Regex([]).validate("bar") 214 | with raises(TypeError): 215 | Regex(None).validate("bar") 216 | 217 | 218 | def test_validate_list(): 219 | assert Schema([1, 0]).validate([1, 0, 1, 1]) == [1, 0, 1, 1] 220 | assert Schema([1, 0]).validate([]) == [] 221 | with SE: 222 | Schema([1, 0]).validate(0) 223 | with SE: 224 | Schema([1, 0]).validate([2]) 225 | assert And([1, 0], lambda lst: len(lst) > 2).validate([0, 1, 0]) == [0, 1, 0] 226 | with SE: 227 | And([1, 0], lambda lst: len(lst) > 2).validate([0, 1]) 228 | 229 | 230 | def test_list_tuple_set_frozenset(): 231 | assert Schema([int]).validate([1, 2]) 232 | with SE: 233 | Schema([int]).validate(["1", 2]) 234 | assert Schema(set([int])).validate(set([1, 2])) == set([1, 2]) 235 | with SE: 236 | Schema(set([int])).validate([1, 2]) # not a set 237 | with SE: 238 | Schema(set([int])).validate(["1", 2]) 239 | assert Schema(tuple([int])).validate(tuple([1, 2])) == tuple([1, 2]) 240 | with SE: 241 | Schema(tuple([int])).validate([1, 2]) # not a set 242 | 243 | 244 | def test_strictly(): 245 | assert Schema(int).validate(1) == 1 246 | with SE: 247 | Schema(int).validate("1") 248 | 249 | 250 | def test_dict(): 251 | assert Schema({"key": 5}).validate({"key": 5}) == {"key": 5} 252 | with SE: 253 | Schema({"key": 5}).validate({"key": "x"}) 254 | with SE: 255 | Schema({"key": 5}).validate(["key", 5]) 256 | assert Schema({"key": int}).validate({"key": 5}) == {"key": 5} 257 | assert Schema({"n": int, "f": float}).validate({"n": 5, "f": 3.14}) == { 258 | "n": 5, 259 | "f": 3.14, 260 | } 261 | with SE: 262 | Schema({"n": int, "f": float}).validate({"n": 3.14, "f": 5}) 263 | with SE: 264 | try: 265 | Schema({}).validate({"abc": None, 1: None}) 266 | except SchemaWrongKeyError as e: 267 | assert e.args[0].startswith("Wrong keys 'abc', 1 in") 268 | raise 269 | with SE: 270 | try: 271 | Schema({"key": 5}).validate({}) 272 | except SchemaMissingKeyError as e: 273 | assert e.args[0] == "Missing key: 'key'" 274 | raise 275 | with SE: 276 | try: 277 | Schema({"key": 5}).validate({"n": 5}) 278 | except SchemaMissingKeyError as e: 279 | assert e.args[0] == "Missing key: 'key'" 280 | raise 281 | with SE: 282 | try: 283 | Schema({"key": 5, "key2": 5}).validate({"n": 5}) 284 | except SchemaMissingKeyError as e: 285 | assert e.args[0] == "Missing keys: 'key', 'key2'" 286 | raise 287 | with SE: 288 | try: 289 | Schema({}).validate({"n": 5}) 290 | except SchemaWrongKeyError as e: 291 | assert e.args[0] == "Wrong key 'n' in {'n': 5}" 292 | raise 293 | with SE: 294 | try: 295 | Schema({"key": 5}).validate({"key": 5, "bad": 5}) 296 | except SchemaWrongKeyError as e: 297 | assert e.args[0] in [ 298 | "Wrong key 'bad' in {'key': 5, 'bad': 5}", 299 | "Wrong key 'bad' in {'bad': 5, 'key': 5}", 300 | ] 301 | raise 302 | with SE: 303 | try: 304 | Schema({}).validate({"a": 5, "b": 5}) 305 | except SchemaError as e: 306 | assert e.args[0] in [ 307 | "Wrong keys 'a', 'b' in {'a': 5, 'b': 5}", 308 | "Wrong keys 'a', 'b' in {'b': 5, 'a': 5}", 309 | ] 310 | raise 311 | 312 | with SE: 313 | try: 314 | Schema({int: int}).validate({"": ""}) 315 | except SchemaUnexpectedTypeError as e: 316 | assert e.args[0] in ["'' should be instance of 'int'"] 317 | 318 | 319 | def test_dict_keys(): 320 | assert Schema({str: int}).validate({"a": 1, "b": 2}) == {"a": 1, "b": 2} 321 | with SE: 322 | Schema({str: int}).validate({1: 1, "b": 2}) 323 | assert Schema({Use(str): Use(int)}).validate({1: 3.14, 3.14: 1}) == { 324 | "1": 3, 325 | "3.14": 1, 326 | } 327 | 328 | 329 | def test_ignore_extra_keys(): 330 | assert Schema({"key": 5}, ignore_extra_keys=True).validate( 331 | {"key": 5, "bad": 4} 332 | ) == {"key": 5} 333 | assert Schema({"key": 5, "dk": {"a": "a"}}, ignore_extra_keys=True).validate( 334 | {"key": 5, "bad": "b", "dk": {"a": "a", "bad": "b"}} 335 | ) == {"key": 5, "dk": {"a": "a"}} 336 | assert Schema([{"key": "v"}], ignore_extra_keys=True).validate( 337 | [{"key": "v", "bad": "bad"}] 338 | ) == [{"key": "v"}] 339 | assert Schema([{"key": "v"}], ignore_extra_keys=True).validate( 340 | [{"key": "v", "bad": "bad"}] 341 | ) == [{"key": "v"}] 342 | 343 | 344 | def test_ignore_extra_keys_validation_and_return_keys(): 345 | assert Schema({"key": 5, object: object}, ignore_extra_keys=True).validate( 346 | {"key": 5, "bad": 4} 347 | ) == { 348 | "key": 5, 349 | "bad": 4, 350 | } 351 | assert Schema( 352 | {"key": 5, "dk": {"a": "a", object: object}}, ignore_extra_keys=True 353 | ).validate({"key": 5, "dk": {"a": "a", "bad": "b"}}) == { 354 | "key": 5, 355 | "dk": {"a": "a", "bad": "b"}, 356 | } 357 | 358 | 359 | def test_dict_forbidden_keys(): 360 | with raises(SchemaForbiddenKeyError): 361 | Schema({Forbidden("b"): object}).validate({"b": "bye"}) 362 | with raises(SchemaWrongKeyError): 363 | Schema({Forbidden("b"): int}).validate({"b": "bye"}) 364 | assert Schema({Forbidden("b"): int, Optional("b"): object}).validate( 365 | {"b": "bye"} 366 | ) == {"b": "bye"} 367 | with raises(SchemaForbiddenKeyError): 368 | Schema({Forbidden("b"): object, Optional("b"): object}).validate({"b": "bye"}) 369 | 370 | 371 | def test_dict_hook(): 372 | function_mock = Mock(return_value=None) 373 | hook = Hook("b", handler=function_mock) 374 | 375 | assert Schema({hook: str, Optional("b"): object}).validate({"b": "bye"}) == { 376 | "b": "bye" 377 | } 378 | function_mock.assert_called_once() 379 | 380 | assert Schema({hook: int, Optional("b"): object}).validate({"b": "bye"}) == { 381 | "b": "bye" 382 | } 383 | function_mock.assert_called_once() 384 | 385 | assert Schema({hook: str, "b": object}).validate({"b": "bye"}) == {"b": "bye"} 386 | assert function_mock.call_count == 2 387 | 388 | 389 | def test_dict_optional_keys(): 390 | with SE: 391 | Schema({"a": 1, "b": 2}).validate({"a": 1}) 392 | assert Schema({"a": 1, Optional("b"): 2}).validate({"a": 1}) == {"a": 1} 393 | assert Schema({"a": 1, Optional("b"): 2}).validate({"a": 1, "b": 2}) == { 394 | "a": 1, 395 | "b": 2, 396 | } 397 | # Make sure Optionals are favored over types: 398 | assert Schema({basestring: 1, Optional("b"): 2}).validate({"a": 1, "b": 2}) == { 399 | "a": 1, 400 | "b": 2, 401 | } 402 | # Make sure Optionals hash based on their key: 403 | assert len({Optional("a"): 1, Optional("a"): 1, Optional("b"): 2}) == 2 404 | 405 | 406 | def test_dict_optional_defaults(): 407 | # Optionals fill out their defaults: 408 | assert Schema( 409 | {Optional("a", default=1): 11, Optional("b", default=2): 22} 410 | ).validate({"a": 11}) == {"a": 11, "b": 2} 411 | 412 | # Optionals take precedence over types. Here, the "a" is served by the 413 | # Optional: 414 | assert Schema({Optional("a", default=1): 11, basestring: 22}).validate( 415 | {"b": 22} 416 | ) == {"a": 1, "b": 22} 417 | 418 | with raises(TypeError): 419 | Optional(And(str, Use(int)), default=7) 420 | 421 | 422 | def test_dict_subtypes(): 423 | d = defaultdict(int, key=1) 424 | v = Schema({"key": 1}).validate(d) 425 | assert v == d 426 | assert isinstance(v, defaultdict) 427 | # Please add tests for Counter and OrderedDict once support for Python2.6 428 | # is dropped! 429 | 430 | 431 | def test_dict_key_error(): 432 | try: 433 | Schema({"k": int}).validate({"k": "x"}) 434 | except SchemaError as e: 435 | assert e.code == "Key 'k' error:\n'x' should be instance of 'int'" 436 | try: 437 | Schema({"k": {"k2": int}}).validate({"k": {"k2": "x"}}) 438 | except SchemaError as e: 439 | code = "Key 'k' error:\nKey 'k2' error:\n'x' should be instance of 'int'" 440 | assert e.code == code 441 | try: 442 | Schema({"k": {"k2": int}}, error="k2 should be int").validate( 443 | {"k": {"k2": "x"}} 444 | ) 445 | except SchemaError as e: 446 | assert e.code == "k2 should be int" 447 | 448 | 449 | def test_complex(): 450 | s = Schema( 451 | { 452 | "": And([Use(open)], lambda lst: len(lst)), 453 | "": os.path.exists, 454 | Optional("--count"): And(int, lambda n: 0 <= n <= 5), 455 | } 456 | ) 457 | data = s.validate({"": ["./LICENSE-MIT"], "": "./"}) 458 | assert len(data) == 2 459 | assert len(data[""]) == 1 460 | assert data[""][0].read().startswith("Copyright") 461 | assert data[""] == "./" 462 | 463 | 464 | def test_nice_errors(): 465 | try: 466 | Schema(int, error="should be integer").validate("x") 467 | except SchemaError as e: 468 | assert e.errors == ["should be integer"] 469 | try: 470 | Schema(Use(float), error="should be a number").validate("x") 471 | except SchemaError as e: 472 | assert e.code == "should be a number" 473 | try: 474 | Schema({Optional("i"): Use(int, error="should be a number")}).validate( 475 | {"i": "x"} 476 | ) 477 | except SchemaError as e: 478 | assert e.code == "should be a number" 479 | 480 | 481 | def test_use_error_handling(): 482 | try: 483 | Use(ve).validate("x") 484 | except SchemaError as e: 485 | assert e.autos == ["ve('x') raised ValueError()"] 486 | assert e.errors == [None] 487 | try: 488 | Use(ve, error="should not raise").validate("x") 489 | except SchemaError as e: 490 | assert e.autos == ["ve('x') raised ValueError()"] 491 | assert e.errors == ["should not raise"] 492 | try: 493 | Use(se).validate("x") 494 | except SchemaError as e: 495 | assert e.autos == [None, "first auto"] 496 | assert e.errors == [None, "first error"] 497 | try: 498 | Use(se, error="second error").validate("x") 499 | except SchemaError as e: 500 | assert e.autos == [None, "first auto"] 501 | assert e.errors == ["second error", "first error"] 502 | 503 | 504 | def test_or_error_handling(): 505 | try: 506 | Or(ve).validate("x") 507 | except SchemaError as e: 508 | assert e.autos[0].startswith("Or(") 509 | assert e.autos[0].endswith(") did not validate 'x'") 510 | assert e.autos[1] == "ve('x') raised ValueError()" 511 | assert len(e.autos) == 2 512 | assert e.errors == [None, None] 513 | try: 514 | Or(ve, error="should not raise").validate("x") 515 | except SchemaError as e: 516 | assert e.autos[0].startswith("Or(") 517 | assert e.autos[0].endswith(") did not validate 'x'") 518 | assert e.autos[1] == "ve('x') raised ValueError()" 519 | assert len(e.autos) == 2 520 | assert e.errors == ["should not raise", "should not raise"] 521 | try: 522 | Or("o").validate("x") 523 | except SchemaError as e: 524 | assert e.autos == ["Or('o') did not validate 'x'", "'o' does not match 'x'"] 525 | assert e.errors == [None, None] 526 | try: 527 | Or("o", error="second error").validate("x") 528 | except SchemaError as e: 529 | assert e.autos == ["Or('o') did not validate 'x'", "'o' does not match 'x'"] 530 | assert e.errors == ["second error", "second error"] 531 | 532 | 533 | def test_and_error_handling(): 534 | try: 535 | And(ve).validate("x") 536 | except SchemaError as e: 537 | assert e.autos == ["ve('x') raised ValueError()"] 538 | assert e.errors == [None] 539 | try: 540 | And(ve, error="should not raise").validate("x") 541 | except SchemaError as e: 542 | assert e.autos == ["ve('x') raised ValueError()"] 543 | assert e.errors == ["should not raise"] 544 | try: 545 | And(str, se).validate("x") 546 | except SchemaError as e: 547 | assert e.autos == [None, "first auto"] 548 | assert e.errors == [None, "first error"] 549 | try: 550 | And(str, se, error="second error").validate("x") 551 | except SchemaError as e: 552 | assert e.autos == [None, "first auto"] 553 | assert e.errors == ["second error", "first error"] 554 | 555 | 556 | def test_schema_error_handling(): 557 | try: 558 | Schema(Use(ve)).validate("x") 559 | except SchemaError as e: 560 | assert e.autos == [None, "ve('x') raised ValueError()"] 561 | assert e.errors == [None, None] 562 | try: 563 | Schema(Use(ve), error="should not raise").validate("x") 564 | except SchemaError as e: 565 | assert e.autos == [None, "ve('x') raised ValueError()"] 566 | assert e.errors == ["should not raise", None] 567 | try: 568 | Schema(Use(se)).validate("x") 569 | except SchemaError as e: 570 | assert e.autos == [None, None, "first auto"] 571 | assert e.errors == [None, None, "first error"] 572 | try: 573 | Schema(Use(se), error="second error").validate("x") 574 | except SchemaError as e: 575 | assert e.autos == [None, None, "first auto"] 576 | assert e.errors == ["second error", None, "first error"] 577 | 578 | 579 | def test_use_json(): 580 | import json 581 | 582 | gist_schema = Schema( 583 | And( 584 | Use(json.loads), # first convert from JSON 585 | { 586 | Optional("description"): basestring, 587 | "public": bool, 588 | "files": {basestring: {"content": basestring}}, 589 | }, 590 | ) 591 | ) 592 | gist = """{"description": "the description for this gist", 593 | "public": true, 594 | "files": { 595 | "file1.txt": {"content": "String file contents"}, 596 | "other.txt": {"content": "Another file contents"}}}""" 597 | assert gist_schema.validate(gist) 598 | 599 | 600 | def test_error_reporting(): 601 | s = Schema( 602 | { 603 | "": [Use(open, error=" should be readable")], 604 | "": And(os.path.exists, error=" should exist"), 605 | "--count": Or( 606 | None, 607 | And(Use(int), lambda n: 0 < n < 5), 608 | error="--count should be integer 0 < n < 5", 609 | ), 610 | }, 611 | error="Error:", 612 | ) 613 | s.validate({"": [], "": "./", "--count": 3}) 614 | 615 | try: 616 | s.validate({"": [], "": "./", "--count": "10"}) 617 | except SchemaError as e: 618 | assert e.code == "Error:\n--count should be integer 0 < n < 5" 619 | try: 620 | s.validate({"": [], "": "./hai", "--count": "2"}) 621 | except SchemaError as e: 622 | assert e.code == "Error:\n should exist" 623 | try: 624 | s.validate({"": ["hai"], "": "./", "--count": "2"}) 625 | except SchemaError as e: 626 | assert e.code == "Error:\n should be readable" 627 | 628 | 629 | def test_schema_repr(): # what about repr with `error`s? 630 | schema = Schema([Or(None, And(str, Use(float)))]) 631 | repr_ = "Schema([Or(None, And(, Use()))])" 632 | # in Python 3 repr contains , not 633 | assert repr(schema).replace("class", "type") == repr_ 634 | 635 | 636 | def test_validate_object(): 637 | schema = Schema({object: str}) 638 | assert schema.validate({42: "str"}) == {42: "str"} 639 | with SE: 640 | schema.validate({42: 777}) 641 | 642 | 643 | def test_issue_9_prioritized_key_comparison(): 644 | validate = Schema({"key": 42, object: 42}).validate 645 | assert validate({"key": 42, 777: 42}) == {"key": 42, 777: 42} 646 | 647 | 648 | def test_issue_9_prioritized_key_comparison_in_dicts(): 649 | # http://stackoverflow.com/questions/14588098/docopt-schema-validation 650 | s = Schema( 651 | { 652 | "ID": Use(int, error="ID should be an int"), 653 | "FILE": Or(None, Use(open, error="FILE should be readable")), 654 | Optional(str): object, 655 | } 656 | ) 657 | data = {"ID": 10, "FILE": None, "other": "other", "other2": "other2"} 658 | assert s.validate(data) == data 659 | data = {"ID": 10, "FILE": None} 660 | assert s.validate(data) == data 661 | 662 | 663 | def test_missing_keys_exception_with_non_str_dict_keys(): 664 | s = Schema({And(str, Use(str.lower), "name"): And(str, len)}) 665 | with SE: 666 | s.validate(dict()) 667 | with SE: 668 | try: 669 | Schema({1: "x"}).validate(dict()) 670 | except SchemaMissingKeyError as e: 671 | assert e.args[0] == "Missing key: 1" 672 | raise 673 | 674 | 675 | # PyPy does have a __name__ attribute for its callables. 676 | @mark.skipif(platform.python_implementation() == "PyPy", reason="Running on PyPy") 677 | def test_issue_56_cant_rely_on_callables_to_have_name(): 678 | s = Schema(methodcaller("endswith", ".csv")) 679 | assert s.validate("test.csv") == "test.csv" 680 | with SE: 681 | try: 682 | s.validate("test.py") 683 | except SchemaError as e: 684 | assert "operator.methodcaller" in e.args[0] 685 | raise 686 | 687 | 688 | def test_exception_handling_with_bad_validators(): 689 | BadValidator = namedtuple("BadValidator", ["validate"]) 690 | s = Schema(BadValidator("haha")) 691 | with SE: 692 | try: 693 | s.validate("test") 694 | except SchemaError as e: 695 | assert "TypeError" in e.args[0] 696 | raise 697 | 698 | 699 | def test_issue_83_iterable_validation_return_type(): 700 | TestSetType = type("TestSetType", (set,), dict()) 701 | data = TestSetType(["test", "strings"]) 702 | s = Schema(set([str])) 703 | assert isinstance(s.validate(data), TestSetType) 704 | 705 | 706 | def test_optional_key_convert_failed_randomly_while_with_another_optional_object(): 707 | """ 708 | In this test, created_at string "2015-10-10 00:00:00" is expected to be converted 709 | to a datetime instance. 710 | - it works when the schema is 711 | 712 | s = Schema({ 713 | 'created_at': _datetime_validator, 714 | Optional(basestring): object, 715 | }) 716 | 717 | - but when wrapping the key 'created_at' with Optional, it fails randomly 718 | :return: 719 | """ 720 | import datetime 721 | 722 | fmt = "%Y-%m-%d %H:%M:%S" 723 | _datetime_validator = Or(None, Use(lambda i: datetime.datetime.strptime(i, fmt))) 724 | # FIXME given tests enough 725 | for i in range(1024): 726 | s = Schema( 727 | { 728 | Optional("created_at"): _datetime_validator, 729 | Optional("updated_at"): _datetime_validator, 730 | Optional("birth"): _datetime_validator, 731 | Optional(basestring): object, 732 | } 733 | ) 734 | data = {"created_at": "2015-10-10 00:00:00"} 735 | validated_data = s.validate(data) 736 | # is expected to be converted to a datetime instance, but fails randomly 737 | # (most of the time) 738 | assert isinstance(validated_data["created_at"], datetime.datetime) 739 | # assert isinstance(validated_data['created_at'], basestring) 740 | 741 | 742 | def test_copy(): 743 | s1 = SchemaError("a", None) 744 | s2 = copy.deepcopy(s1) 745 | assert s1 is not s2 746 | assert type(s1) is type(s2) 747 | 748 | 749 | def test_inheritance(): 750 | def convert(data): 751 | if isinstance(data, int): 752 | return data + 1 753 | return data 754 | 755 | class MySchema(Schema): 756 | def validate(self, data): 757 | return super(MySchema, self).validate(convert(data)) 758 | 759 | s = {"k": int, "d": {"k": int, "l": [{"l": [int]}]}} 760 | v = {"k": 1, "d": {"k": 2, "l": [{"l": [3, 4, 5]}]}} 761 | d = MySchema(s).validate(v) 762 | assert d["k"] == 2 and d["d"]["k"] == 3 and d["d"]["l"][0]["l"] == [4, 5, 6] 763 | 764 | 765 | def test_inheritance_validate_kwargs(): 766 | def convert(data, increment): 767 | if isinstance(data, int): 768 | return data + increment 769 | return data 770 | 771 | class MySchema(Schema): 772 | def validate(self, data, increment=1): 773 | return super(MySchema, self).validate( 774 | convert(data, increment), increment=increment 775 | ) 776 | 777 | s = {"k": int, "d": {"k": int, "l": [{"l": [int]}]}} 778 | v = {"k": 1, "d": {"k": 2, "l": [{"l": [3, 4, 5]}]}} 779 | d = MySchema(s).validate(v, increment=1) 780 | assert d["k"] == 2 and d["d"]["k"] == 3 and d["d"]["l"][0]["l"] == [4, 5, 6] 781 | d = MySchema(s).validate(v, increment=10) 782 | assert d["k"] == 11 and d["d"]["k"] == 12 and d["d"]["l"][0]["l"] == [13, 14, 15] 783 | 784 | 785 | def test_inheritance_validate_kwargs_passed_to_nested_schema(): 786 | def convert(data, increment): 787 | if isinstance(data, int): 788 | return data + increment 789 | return data 790 | 791 | class MySchema(Schema): 792 | def validate(self, data, increment=1): 793 | return super(MySchema, self).validate( 794 | convert(data, increment), increment=increment 795 | ) 796 | 797 | # note only d.k is under MySchema, and all others are under Schema without 798 | # increment 799 | s = {"k": int, "d": MySchema({"k": int, "l": [Schema({"l": [int]})]})} 800 | v = {"k": 1, "d": {"k": 2, "l": [{"l": [3, 4, 5]}]}} 801 | d = Schema(s).validate(v, increment=1) 802 | assert d["k"] == 1 and d["d"]["k"] == 3 and d["d"]["l"][0]["l"] == [3, 4, 5] 803 | d = Schema(s).validate(v, increment=10) 804 | assert d["k"] == 1 and d["d"]["k"] == 12 and d["d"]["l"][0]["l"] == [3, 4, 5] 805 | 806 | 807 | def test_optional_callable_default_get_inherited_schema_validate_kwargs(): 808 | def convert(data, increment): 809 | if isinstance(data, int): 810 | return data + increment 811 | return data 812 | 813 | s = { 814 | "k": int, 815 | "d": { 816 | Optional("k", default=lambda **kw: convert(2, kw["increment"])): int, 817 | "l": [{"l": [int]}], 818 | }, 819 | } 820 | v = {"k": 1, "d": {"l": [{"l": [3, 4, 5]}]}} 821 | d = Schema(s).validate(v, increment=1) 822 | assert d["k"] == 1 and d["d"]["k"] == 3 and d["d"]["l"][0]["l"] == [3, 4, 5] 823 | d = Schema(s).validate(v, increment=10) 824 | assert d["k"] == 1 and d["d"]["k"] == 12 and d["d"]["l"][0]["l"] == [3, 4, 5] 825 | 826 | 827 | def test_optional_callable_default_ignore_inherited_schema_validate_kwargs(): 828 | def convert(data, increment): 829 | if isinstance(data, int): 830 | return data + increment 831 | return data 832 | 833 | s = {"k": int, "d": {Optional("k", default=lambda: 42): int, "l": [{"l": [int]}]}} 834 | v = {"k": 1, "d": {"l": [{"l": [3, 4, 5]}]}} 835 | d = Schema(s).validate(v, increment=1) 836 | assert d["k"] == 1 and d["d"]["k"] == 42 and d["d"]["l"][0]["l"] == [3, 4, 5] 837 | d = Schema(s).validate(v, increment=10) 838 | assert d["k"] == 1 and d["d"]["k"] == 42 and d["d"]["l"][0]["l"] == [3, 4, 5] 839 | 840 | 841 | def test_inheritance_optional(): 842 | def convert(data, increment): 843 | if isinstance(data, int): 844 | return data + increment 845 | return data 846 | 847 | class MyOptional(Optional): 848 | """This overrides the default property so it increments according 849 | to kwargs passed to validate() 850 | """ 851 | 852 | @property 853 | def default(self): 854 | def wrapper(**kwargs): 855 | if "increment" in kwargs: 856 | return convert(self._default, kwargs["increment"]) 857 | return self._default 858 | 859 | return wrapper 860 | 861 | @default.setter 862 | def default(self, value): 863 | self._default = value 864 | 865 | s = {"k": int, "d": {MyOptional("k", default=2): int, "l": [{"l": [int]}]}} 866 | v = {"k": 1, "d": {"l": [{"l": [3, 4, 5]}]}} 867 | d = Schema(s).validate(v, increment=1) 868 | assert d["k"] == 1 and d["d"]["k"] == 3 and d["d"]["l"][0]["l"] == [3, 4, 5] 869 | d = Schema(s).validate(v, increment=10) 870 | assert d["k"] == 1 and d["d"]["k"] == 12 and d["d"]["l"][0]["l"] == [3, 4, 5] 871 | 872 | 873 | def test_literal_repr(): 874 | assert ( 875 | repr(Literal("test", description="testing")) 876 | == 'Literal("test", description="testing")' 877 | ) 878 | assert repr(Literal("test")) == 'Literal("test", description="")' 879 | 880 | 881 | def test_json_schema(): 882 | s = Schema({"test": str}) 883 | assert s.json_schema("my-id") == { 884 | "$schema": "http://json-schema.org/draft-07/schema#", 885 | "$id": "my-id", 886 | "properties": {"test": {"type": "string"}}, 887 | "required": ["test"], 888 | "additionalProperties": False, 889 | "type": "object", 890 | } 891 | 892 | 893 | def test_json_schema_with_title(): 894 | s = Schema({"test": str}, name="Testing a schema") 895 | assert s.json_schema("my-id") == { 896 | "$schema": "http://json-schema.org/draft-07/schema#", 897 | "$id": "my-id", 898 | "title": "Testing a schema", 899 | "properties": {"test": {"type": "string"}}, 900 | "required": ["test"], 901 | "additionalProperties": False, 902 | "type": "object", 903 | } 904 | 905 | 906 | def test_json_schema_types(): 907 | s = Schema( 908 | { 909 | Optional("test_str"): str, 910 | Optional("test_int"): int, 911 | Optional("test_float"): float, 912 | Optional("test_bool"): bool, 913 | } 914 | ) 915 | assert s.json_schema("my-id") == { 916 | "$schema": "http://json-schema.org/draft-07/schema#", 917 | "$id": "my-id", 918 | "properties": { 919 | "test_str": {"type": "string"}, 920 | "test_int": {"type": "integer"}, 921 | "test_float": {"type": "number"}, 922 | "test_bool": {"type": "boolean"}, 923 | }, 924 | "required": [], 925 | "additionalProperties": False, 926 | "type": "object", 927 | } 928 | 929 | 930 | def test_json_schema_other_types(): 931 | """Test that data types not supported by JSON schema are returned as strings""" 932 | s = Schema({Optional("test_other"): bytes}) 933 | assert s.json_schema("my-id") == { 934 | "$schema": "http://json-schema.org/draft-07/schema#", 935 | "$id": "my-id", 936 | "properties": {"test_other": {"type": "string"}}, 937 | "required": [], 938 | "additionalProperties": False, 939 | "type": "object", 940 | } 941 | 942 | 943 | def test_json_schema_nested(): 944 | s = Schema({"test": {"other": str}}, ignore_extra_keys=True) 945 | assert s.json_schema("my-id") == { 946 | "$schema": "http://json-schema.org/draft-07/schema#", 947 | "$id": "my-id", 948 | "properties": { 949 | "test": { 950 | "type": "object", 951 | "properties": {"other": {"type": "string"}}, 952 | "additionalProperties": True, 953 | "required": ["other"], 954 | } 955 | }, 956 | "required": ["test"], 957 | "additionalProperties": True, 958 | "type": "object", 959 | } 960 | 961 | 962 | def test_json_schema_nested_schema(): 963 | s = Schema({"test": Schema({"other": str}, ignore_extra_keys=True)}) 964 | assert s.json_schema("my-id") == { 965 | "$schema": "http://json-schema.org/draft-07/schema#", 966 | "$id": "my-id", 967 | "properties": { 968 | "test": { 969 | "type": "object", 970 | "properties": {"other": {"type": "string"}}, 971 | "additionalProperties": True, 972 | "required": ["other"], 973 | } 974 | }, 975 | "required": ["test"], 976 | "additionalProperties": False, 977 | "type": "object", 978 | } 979 | 980 | 981 | def test_json_schema_optional_key(): 982 | s = Schema({Optional("test"): str}) 983 | assert s.json_schema("my-id") == { 984 | "$schema": "http://json-schema.org/draft-07/schema#", 985 | "$id": "my-id", 986 | "properties": {"test": {"type": "string"}}, 987 | "required": [], 988 | "additionalProperties": False, 989 | "type": "object", 990 | } 991 | 992 | 993 | def test_json_schema_optional_key_nested(): 994 | s = Schema({"test": {Optional("other"): str}}) 995 | assert s.json_schema("my-id") == { 996 | "$schema": "http://json-schema.org/draft-07/schema#", 997 | "$id": "my-id", 998 | "properties": { 999 | "test": { 1000 | "type": "object", 1001 | "properties": {"other": {"type": "string"}}, 1002 | "additionalProperties": False, 1003 | "required": [], 1004 | } 1005 | }, 1006 | "required": ["test"], 1007 | "additionalProperties": False, 1008 | "type": "object", 1009 | } 1010 | 1011 | 1012 | def test_json_schema_or_key(): 1013 | s = Schema({Or("test1", "test2"): str}) 1014 | assert s.json_schema("my-id") == { 1015 | "$schema": "http://json-schema.org/draft-07/schema#", 1016 | "$id": "my-id", 1017 | "properties": {"test1": {"type": "string"}, "test2": {"type": "string"}}, 1018 | "required": [], 1019 | "additionalProperties": False, 1020 | "type": "object", 1021 | } 1022 | 1023 | 1024 | def test_json_schema_or_values(): 1025 | s = Schema({"param": Or("test1", "test2")}) 1026 | assert s.json_schema("my-id") == { 1027 | "$schema": "http://json-schema.org/draft-07/schema#", 1028 | "$id": "my-id", 1029 | "properties": {"param": {"enum": ["test1", "test2"]}}, 1030 | "required": ["param"], 1031 | "additionalProperties": False, 1032 | "type": "object", 1033 | } 1034 | 1035 | 1036 | def test_json_schema_or_values_nested(): 1037 | s = Schema({"param": Or([str], [list])}) 1038 | assert s.json_schema("my-id") == { 1039 | "$schema": "http://json-schema.org/draft-07/schema#", 1040 | "$id": "my-id", 1041 | "properties": { 1042 | "param": { 1043 | "anyOf": [ 1044 | {"type": "array", "items": {"type": "string"}}, 1045 | {"type": "array", "items": {"type": "array"}}, 1046 | ] 1047 | } 1048 | }, 1049 | "required": ["param"], 1050 | "additionalProperties": False, 1051 | "type": "object", 1052 | } 1053 | 1054 | 1055 | def test_json_schema_or_values_with_optional(): 1056 | s = Schema({Optional("whatever"): Or("test1", "test2")}) 1057 | assert s.json_schema("my-id") == { 1058 | "$schema": "http://json-schema.org/draft-07/schema#", 1059 | "$id": "my-id", 1060 | "properties": {"whatever": {"enum": ["test1", "test2"]}}, 1061 | "required": [], 1062 | "additionalProperties": False, 1063 | "type": "object", 1064 | } 1065 | 1066 | 1067 | def test_json_schema_regex(): 1068 | s = Schema({Optional("username"): Regex("[a-zA-Z][a-zA-Z0-9]{3,}")}) 1069 | assert s.json_schema("my-id") == { 1070 | "$schema": "http://json-schema.org/draft-07/schema#", 1071 | "$id": "my-id", 1072 | "properties": { 1073 | "username": {"type": "string", "pattern": "[a-zA-Z][a-zA-Z0-9]{3,}"} 1074 | }, 1075 | "required": [], 1076 | "additionalProperties": False, 1077 | "type": "object", 1078 | } 1079 | 1080 | 1081 | def test_json_schema_ecma_compliant_regex(): 1082 | s = Schema({Optional("username"): Regex("^(?P[a-zA-Z_][a-zA-Z0-9_]*)/$")}) 1083 | assert s.json_schema("my-id") == { 1084 | "$schema": "http://json-schema.org/draft-07/schema#", 1085 | "$id": "my-id", 1086 | "properties": { 1087 | "username": {"type": "string", "pattern": "^([a-zA-Z_][a-zA-Z0-9_]*)\/$"} 1088 | }, 1089 | "required": [], 1090 | "additionalProperties": False, 1091 | "type": "object", 1092 | } 1093 | 1094 | 1095 | def test_json_schema_or_types(): 1096 | s = Schema({"test": Or(str, int)}) 1097 | assert s.json_schema("my-id") == { 1098 | "$schema": "http://json-schema.org/draft-07/schema#", 1099 | "$id": "my-id", 1100 | "properties": {"test": {"anyOf": [{"type": "string"}, {"type": "integer"}]}}, 1101 | "required": ["test"], 1102 | "additionalProperties": False, 1103 | "type": "object", 1104 | } 1105 | 1106 | 1107 | def test_json_schema_or_only_one(): 1108 | s = Schema({"test": Or(str, lambda x: len(x) < 5)}) 1109 | assert s.json_schema("my-id") == { 1110 | "$schema": "http://json-schema.org/draft-07/schema#", 1111 | "$id": "my-id", 1112 | "properties": {"test": {"type": "string"}}, 1113 | "required": ["test"], 1114 | "additionalProperties": False, 1115 | "type": "object", 1116 | } 1117 | 1118 | 1119 | def test_json_schema_and_types(): 1120 | # Can't determine the type, it will not be checked 1121 | s = Schema({"test": And(str, lambda x: len(x) < 5)}) 1122 | assert s.json_schema("my-id") == { 1123 | "$schema": "http://json-schema.org/draft-07/schema#", 1124 | "$id": "my-id", 1125 | "properties": {"test": {"type": "string"}}, 1126 | "required": ["test"], 1127 | "additionalProperties": False, 1128 | "type": "object", 1129 | } 1130 | 1131 | 1132 | def test_json_schema_or_one_value(): 1133 | s = Schema({"test": Or(True)}) 1134 | assert s.json_schema("my-id") == { 1135 | "$schema": "http://json-schema.org/draft-07/schema#", 1136 | "$id": "my-id", 1137 | "properties": {"test": {"const": True}}, 1138 | "required": ["test"], 1139 | "additionalProperties": False, 1140 | "type": "object", 1141 | } 1142 | 1143 | 1144 | def test_json_schema_const_is_none(): 1145 | s = Schema({"test": None}) 1146 | assert s.json_schema("my-id") == { 1147 | "$schema": "http://json-schema.org/draft-07/schema#", 1148 | "$id": "my-id", 1149 | "properties": {"test": {"type": "null"}}, 1150 | "required": ["test"], 1151 | "additionalProperties": False, 1152 | "type": "object", 1153 | } 1154 | 1155 | 1156 | def test_json_schema_const_is_callable(): 1157 | def something_callable(x): 1158 | return x * 2 1159 | 1160 | s = Schema({"test": something_callable}) 1161 | assert s.json_schema("my-id") == { 1162 | "$schema": "http://json-schema.org/draft-07/schema#", 1163 | "$id": "my-id", 1164 | "properties": {"test": {}}, 1165 | "required": ["test"], 1166 | "additionalProperties": False, 1167 | "type": "object", 1168 | } 1169 | 1170 | 1171 | def test_json_schema_const_is_custom_type(): 1172 | class SomethingSerializable: 1173 | def __str__(self): 1174 | return "Hello!" 1175 | 1176 | s = Schema({"test": SomethingSerializable()}) 1177 | assert s.json_schema("my-id") == { 1178 | "$schema": "http://json-schema.org/draft-07/schema#", 1179 | "$id": "my-id", 1180 | "properties": {"test": {"const": "Hello!"}}, 1181 | "required": ["test"], 1182 | "additionalProperties": False, 1183 | "type": "object", 1184 | } 1185 | 1186 | 1187 | def test_json_schema_default_is_custom_type(): 1188 | class SomethingSerializable: 1189 | def __str__(self): 1190 | return "Hello!" 1191 | 1192 | s = Schema({Optional("test", default=SomethingSerializable()): str}) 1193 | assert s.json_schema("my-id") == { 1194 | "$schema": "http://json-schema.org/draft-07/schema#", 1195 | "$id": "my-id", 1196 | "properties": {"test": {"default": "Hello!", "type": "string"}}, 1197 | "required": [], 1198 | "additionalProperties": False, 1199 | "type": "object", 1200 | } 1201 | 1202 | 1203 | def test_json_schema_default_is_callable(): 1204 | def default_func(): 1205 | return "Hello!" 1206 | 1207 | s = Schema({Optional("test", default=default_func): str}) 1208 | assert s.json_schema("my-id") == { 1209 | "$schema": "http://json-schema.org/draft-07/schema#", 1210 | "$id": "my-id", 1211 | "properties": {"test": {"default": "Hello!", "type": "string"}}, 1212 | "required": [], 1213 | "additionalProperties": False, 1214 | "type": "object", 1215 | } 1216 | 1217 | 1218 | def test_json_schema_default_is_callable_with_args_passed_from_json_schema(): 1219 | def default_func(**kwargs): 1220 | return "Hello, " + kwargs["name"] 1221 | 1222 | s = Schema({Optional("test", default=default_func): str}) 1223 | assert s.json_schema("my-id", name="World!") == { 1224 | "$schema": "http://json-schema.org/draft-07/schema#", 1225 | "$id": "my-id", 1226 | "properties": {"test": {"default": "Hello, World!", "type": "string"}}, 1227 | "required": [], 1228 | "additionalProperties": False, 1229 | "type": "object", 1230 | } 1231 | 1232 | 1233 | def test_json_schema_object_or_array_of_object(): 1234 | # Complex test where "test" accepts either an object or an array of that object 1235 | o = {"param1": "test1", Optional("param2"): "test2"} 1236 | s = Schema({"test": Or(o, [o])}) 1237 | assert s.json_schema("my-id") == { 1238 | "$schema": "http://json-schema.org/draft-07/schema#", 1239 | "$id": "my-id", 1240 | "properties": { 1241 | "test": { 1242 | "anyOf": [ 1243 | { 1244 | "additionalProperties": False, 1245 | "properties": { 1246 | "param1": {"const": "test1"}, 1247 | "param2": {"const": "test2"}, 1248 | }, 1249 | "required": ["param1"], 1250 | "type": "object", 1251 | }, 1252 | { 1253 | "type": "array", 1254 | "items": { 1255 | "additionalProperties": False, 1256 | "properties": { 1257 | "param1": {"const": "test1"}, 1258 | "param2": {"const": "test2"}, 1259 | }, 1260 | "required": ["param1"], 1261 | "type": "object", 1262 | }, 1263 | }, 1264 | ] 1265 | } 1266 | }, 1267 | "required": ["test"], 1268 | "additionalProperties": False, 1269 | "type": "object", 1270 | } 1271 | 1272 | 1273 | def test_json_schema_and_simple(): 1274 | s = Schema({"test1": And(str, "test2")}) 1275 | assert s.json_schema("my-id") == { 1276 | "$schema": "http://json-schema.org/draft-07/schema#", 1277 | "$id": "my-id", 1278 | "properties": {"test1": {"allOf": [{"type": "string"}, {"const": "test2"}]}}, 1279 | "required": ["test1"], 1280 | "additionalProperties": False, 1281 | "type": "object", 1282 | } 1283 | 1284 | 1285 | def test_json_schema_and_list(): 1286 | s = Schema({"param1": And(["choice1", "choice2"], list)}) 1287 | assert s.json_schema("my-id") == { 1288 | "$schema": "http://json-schema.org/draft-07/schema#", 1289 | "$id": "my-id", 1290 | "properties": { 1291 | "param1": { 1292 | "allOf": [ 1293 | {"type": "array", "items": {"enum": ["choice1", "choice2"]}}, 1294 | {"type": "array"}, 1295 | ] 1296 | } 1297 | }, 1298 | "required": ["param1"], 1299 | "additionalProperties": False, 1300 | "type": "object", 1301 | } 1302 | 1303 | 1304 | def test_json_schema_forbidden_key_ignored(): 1305 | s = Schema({Forbidden("forbidden"): str, "test": str}) 1306 | assert s.json_schema("my-id") == { 1307 | "$schema": "http://json-schema.org/draft-07/schema#", 1308 | "$id": "my-id", 1309 | "properties": {"test": {"type": "string"}}, 1310 | "required": ["test"], 1311 | "additionalProperties": False, 1312 | "type": "object", 1313 | } 1314 | 1315 | 1316 | @mark.parametrize( 1317 | "input_schema, ignore_extra_keys, additional_properties", 1318 | [ 1319 | ({}, False, False), 1320 | ({str: str}, False, True), 1321 | ({Optional(str): str}, False, True), 1322 | ({object: int}, False, True), 1323 | ({}, True, True), 1324 | ], 1325 | ) 1326 | def test_json_schema_additional_properties( 1327 | input_schema, ignore_extra_keys, additional_properties 1328 | ): 1329 | s = Schema(input_schema, ignore_extra_keys=ignore_extra_keys) 1330 | assert s.json_schema("my-id") == { 1331 | "$schema": "http://json-schema.org/draft-07/schema#", 1332 | "$id": "my-id", 1333 | "required": [], 1334 | "properties": {}, 1335 | "additionalProperties": additional_properties, 1336 | "type": "object", 1337 | } 1338 | 1339 | 1340 | def test_json_schema_additional_properties_multiple(): 1341 | s = Schema({"named_property": bool, object: int}) 1342 | assert s.json_schema("my-id") == { 1343 | "$schema": "http://json-schema.org/draft-07/schema#", 1344 | "$id": "my-id", 1345 | "required": ["named_property"], 1346 | "properties": {"named_property": {"type": "boolean"}}, 1347 | "additionalProperties": True, 1348 | "type": "object", 1349 | } 1350 | 1351 | 1352 | @mark.parametrize( 1353 | "input_schema, expected_keyword, expected_value", 1354 | [ 1355 | (int, "type", "integer"), 1356 | (float, "type", "number"), 1357 | (list, "type", "array"), 1358 | (bool, "type", "boolean"), 1359 | (dict, "type", "object"), 1360 | ("test", "const", "test"), 1361 | (Or(1, 2, 3), "enum", [1, 2, 3]), 1362 | (Or(str, int), "anyOf", [{"type": "string"}, {"type": "integer"}]), 1363 | (And(str, "value"), "allOf", [{"type": "string"}, {"const": "value"}]), 1364 | ], 1365 | ) 1366 | def test_json_schema_root_not_dict(input_schema, expected_keyword, expected_value): 1367 | """Test generating simple JSON Schemas where the root element is not a dict""" 1368 | json_schema = Schema(input_schema).json_schema("my-id") 1369 | 1370 | assert json_schema == { 1371 | expected_keyword: expected_value, 1372 | "$id": "my-id", 1373 | "$schema": "http://json-schema.org/draft-07/schema#", 1374 | } 1375 | 1376 | 1377 | @mark.parametrize( 1378 | "input_schema, expected_keyword, expected_value", 1379 | [([1, 2, 3], "enum", [1, 2, 3]), ([1], "const", 1), ([str], "type", "string")], 1380 | ) 1381 | def test_json_schema_array(input_schema, expected_keyword, expected_value): 1382 | json_schema = Schema(input_schema).json_schema("my-id") 1383 | 1384 | assert json_schema == { 1385 | "type": "array", 1386 | "items": {expected_keyword: expected_value}, 1387 | "$id": "my-id", 1388 | "$schema": "http://json-schema.org/draft-07/schema#", 1389 | } 1390 | 1391 | 1392 | def test_json_schema_regex_root(): 1393 | json_schema = Schema(Regex("^v\\d+")).json_schema("my-id") 1394 | 1395 | assert json_schema == { 1396 | "type": "string", 1397 | "pattern": "^v\\d+", 1398 | "$id": "my-id", 1399 | "$schema": "http://json-schema.org/draft-07/schema#", 1400 | } 1401 | 1402 | 1403 | def test_json_schema_dict_type(): 1404 | json_schema = Schema({Optional("test1", default={}): dict}).json_schema("my-id") 1405 | 1406 | assert json_schema == { 1407 | "type": "object", 1408 | "properties": {"test1": {"default": {}, "type": "object"}}, 1409 | "required": [], 1410 | "additionalProperties": False, 1411 | "$id": "my-id", 1412 | "$schema": "http://json-schema.org/draft-07/schema#", 1413 | } 1414 | 1415 | 1416 | def test_json_schema_title_and_description(): 1417 | s = Schema( 1418 | { 1419 | Literal( 1420 | "productId", 1421 | title="Product ID", 1422 | description="The unique identifier for a product", 1423 | ): int 1424 | }, 1425 | name="Product", 1426 | description="A product in the catalog", 1427 | ) 1428 | assert s.json_schema("my-id") == { 1429 | "$schema": "http://json-schema.org/draft-07/schema#", 1430 | "$id": "my-id", 1431 | "title": "Product", 1432 | "description": "A product in the catalog", 1433 | "properties": { 1434 | "productId": { 1435 | "title": "Product ID", 1436 | "description": "The unique identifier for a product", 1437 | "type": "integer", 1438 | } 1439 | }, 1440 | "required": ["productId"], 1441 | "additionalProperties": False, 1442 | "type": "object", 1443 | } 1444 | 1445 | 1446 | def test_json_schema_title_in_or(): 1447 | s = Schema( 1448 | { 1449 | "test": Or( 1450 | Schema( 1451 | "option1", name="Option 1", description="This is the first option" 1452 | ), 1453 | Schema( 1454 | "option2", 1455 | name="Option 2", 1456 | description="This is the second option", 1457 | ), 1458 | ) 1459 | } 1460 | ) 1461 | assert s.json_schema("my-id") == { 1462 | "$schema": "http://json-schema.org/draft-07/schema#", 1463 | "$id": "my-id", 1464 | "properties": { 1465 | "test": { 1466 | "anyOf": [ 1467 | { 1468 | "const": "option1", 1469 | "title": "Option 1", 1470 | "description": "This is the first option", 1471 | }, 1472 | { 1473 | "const": "option2", 1474 | "title": "Option 2", 1475 | "description": "This is the second option", 1476 | }, 1477 | ] 1478 | } 1479 | }, 1480 | "required": ["test"], 1481 | "additionalProperties": False, 1482 | "type": "object", 1483 | } 1484 | 1485 | 1486 | def test_json_schema_description_nested(): 1487 | s = Schema( 1488 | { 1489 | Optional( 1490 | Literal("test1", description="A description here"), default={} 1491 | ): Or([str], [list]) 1492 | } 1493 | ) 1494 | assert s.json_schema("my-id") == { 1495 | "$schema": "http://json-schema.org/draft-07/schema#", 1496 | "$id": "my-id", 1497 | "properties": { 1498 | "test1": { 1499 | "default": {}, 1500 | "description": "A description here", 1501 | "anyOf": [ 1502 | {"items": {"type": "string"}, "type": "array"}, 1503 | {"items": {"type": "array"}, "type": "array"}, 1504 | ], 1505 | } 1506 | }, 1507 | "required": [], 1508 | "additionalProperties": False, 1509 | "type": "object", 1510 | } 1511 | 1512 | 1513 | def test_json_schema_description_or_nested(): 1514 | s = Schema( 1515 | { 1516 | Optional( 1517 | Or( 1518 | Literal("test1", description="A description here"), 1519 | Literal("test2", description="Another"), 1520 | ) 1521 | ): Or([str], [list]) 1522 | } 1523 | ) 1524 | assert s.json_schema("my-id") == { 1525 | "type": "object", 1526 | "properties": { 1527 | "test1": { 1528 | "description": "A description here", 1529 | "anyOf": [ 1530 | {"items": {"type": "string"}, "type": "array"}, 1531 | {"items": {"type": "array"}, "type": "array"}, 1532 | ], 1533 | }, 1534 | "test2": { 1535 | "description": "Another", 1536 | "anyOf": [ 1537 | {"items": {"type": "string"}, "type": "array"}, 1538 | {"items": {"type": "array"}, "type": "array"}, 1539 | ], 1540 | }, 1541 | }, 1542 | "required": [], 1543 | "additionalProperties": False, 1544 | "$id": "my-id", 1545 | "$schema": "http://json-schema.org/draft-07/schema#", 1546 | } 1547 | 1548 | 1549 | def test_json_schema_literal_with_enum(): 1550 | s = Schema( 1551 | { 1552 | Literal("test", description="A test"): Or( 1553 | Literal("literal1", description="A literal with description"), 1554 | Literal("literal2", description="Another literal with description"), 1555 | ) 1556 | } 1557 | ) 1558 | assert s.json_schema("my-id") == { 1559 | "type": "object", 1560 | "properties": { 1561 | "test": {"description": "A test", "enum": ["literal1", "literal2"]} 1562 | }, 1563 | "required": ["test"], 1564 | "additionalProperties": False, 1565 | "$id": "my-id", 1566 | "$schema": "http://json-schema.org/draft-07/schema#", 1567 | } 1568 | 1569 | 1570 | def test_json_schema_description_and_nested(): 1571 | s = Schema( 1572 | { 1573 | Optional( 1574 | Or( 1575 | Literal("test1", description="A description here"), 1576 | Literal("test2", description="Another"), 1577 | ) 1578 | ): And([str], [list]) 1579 | } 1580 | ) 1581 | assert s.json_schema("my-id") == { 1582 | "type": "object", 1583 | "properties": { 1584 | "test1": { 1585 | "description": "A description here", 1586 | "allOf": [ 1587 | {"items": {"type": "string"}, "type": "array"}, 1588 | {"items": {"type": "array"}, "type": "array"}, 1589 | ], 1590 | }, 1591 | "test2": { 1592 | "description": "Another", 1593 | "allOf": [ 1594 | {"items": {"type": "string"}, "type": "array"}, 1595 | {"items": {"type": "array"}, "type": "array"}, 1596 | ], 1597 | }, 1598 | }, 1599 | "required": [], 1600 | "additionalProperties": False, 1601 | "$id": "my-id", 1602 | "$schema": "http://json-schema.org/draft-07/schema#", 1603 | } 1604 | 1605 | 1606 | def test_description(): 1607 | s = Schema( 1608 | {Optional(Literal("test1", description="A description here"), default={}): dict} 1609 | ) 1610 | assert s.validate({"test1": {}}) 1611 | 1612 | 1613 | def test_description_with_default(): 1614 | s = Schema( 1615 | {Optional(Literal("test1", description="A description here"), default={}): dict} 1616 | ) 1617 | assert s.validate({}) == {"test1": {}} 1618 | 1619 | 1620 | def test_json_schema_ref_in_list(): 1621 | s = Schema( 1622 | Or( 1623 | Schema([str], name="Inner test", as_reference=True), 1624 | Schema([str], name="Inner test2", as_reference=True), 1625 | ) 1626 | ) 1627 | generated_json_schema = s.json_schema("my-id") 1628 | 1629 | assert generated_json_schema == { 1630 | "definitions": { 1631 | "Inner test": { 1632 | "items": {"type": "string"}, 1633 | "type": "array", 1634 | "title": "Inner test", 1635 | }, 1636 | "Inner test2": { 1637 | "items": {"type": "string"}, 1638 | "type": "array", 1639 | "title": "Inner test2", 1640 | }, 1641 | }, 1642 | "anyOf": [ 1643 | {"$ref": "#/definitions/Inner test"}, 1644 | {"$ref": "#/definitions/Inner test2"}, 1645 | ], 1646 | "$id": "my-id", 1647 | "$schema": "http://json-schema.org/draft-07/schema#", 1648 | } 1649 | 1650 | 1651 | def test_json_schema_refs(): 1652 | s = Schema({"test1": str, "test2": str, "test3": str}) 1653 | hashed = "#" + str(hash(repr(sorted({"type": "string"}.items())))) 1654 | generated_json_schema = s.json_schema("my-id", use_refs=True) 1655 | 1656 | # The order can change, so let's check indirectly 1657 | assert generated_json_schema["type"] == "object" 1658 | assert sorted(generated_json_schema["required"]) == ["test1", "test2", "test3"] 1659 | assert generated_json_schema["additionalProperties"] is False 1660 | assert generated_json_schema["$id"] == "my-id" 1661 | assert generated_json_schema["$schema"] == "http://json-schema.org/draft-07/schema#" 1662 | 1663 | # There will be one of the property being the id and 2 referencing it, but which one is random 1664 | id_schema_part = {"type": "string", "$id": hashed} 1665 | ref_schema_part = {"$ref": hashed} 1666 | 1667 | nb_id_schema = 0 1668 | nb_ref_schema = 0 1669 | for v in generated_json_schema["properties"].values(): 1670 | if v == id_schema_part: 1671 | nb_id_schema += 1 1672 | elif v == ref_schema_part: 1673 | nb_ref_schema += 1 1674 | assert nb_id_schema == 1 1675 | assert nb_ref_schema == 2 1676 | 1677 | 1678 | def test_json_schema_refs_is_smaller(): 1679 | key_names = [ 1680 | "a", 1681 | "b", 1682 | "c", 1683 | "d", 1684 | "e", 1685 | "f", 1686 | "g", 1687 | "h", 1688 | "i", 1689 | "j", 1690 | "k", 1691 | "l", 1692 | "m", 1693 | "n", 1694 | "o", 1695 | "p", 1696 | "q", 1697 | "r", 1698 | "s", 1699 | "t", 1700 | ] 1701 | key_values = [ 1702 | 1, 1703 | 2, 1704 | 3, 1705 | 4, 1706 | 5, 1707 | 6, 1708 | 7, 1709 | 8, 1710 | 9, 1711 | 10, 1712 | "value1", 1713 | "value2", 1714 | "value3", 1715 | "value4", 1716 | "value5", 1717 | None, 1718 | ] 1719 | s = Schema( 1720 | { 1721 | Literal( 1722 | Or(*key_names), description="A key that can have many names" 1723 | ): key_values 1724 | } 1725 | ) 1726 | assert len(json.dumps(s.json_schema("my-id", use_refs=False))) > len( 1727 | json.dumps(s.json_schema("my-id", use_refs=True)) 1728 | ) 1729 | 1730 | 1731 | def test_json_schema_refs_no_missing(): 1732 | key_names = [ 1733 | "a", 1734 | "b", 1735 | "c", 1736 | "d", 1737 | "e", 1738 | "f", 1739 | "g", 1740 | "h", 1741 | "i", 1742 | "j", 1743 | "k", 1744 | "l", 1745 | "m", 1746 | "n", 1747 | "o", 1748 | "p", 1749 | "q", 1750 | "r", 1751 | "s", 1752 | "t", 1753 | ] 1754 | key_values = [ 1755 | 1, 1756 | 2, 1757 | 3, 1758 | 4, 1759 | 5, 1760 | 6, 1761 | 7, 1762 | 8, 1763 | 9, 1764 | 10, 1765 | "value1", 1766 | "value2", 1767 | "value3", 1768 | "value4", 1769 | "value5", 1770 | None, 1771 | ] 1772 | s = Schema( 1773 | { 1774 | Literal( 1775 | Or(*key_names), description="A key that can have many names" 1776 | ): key_values 1777 | } 1778 | ) 1779 | json_s = s.json_schema("my-id", use_refs=True) 1780 | schema_ids = [] 1781 | refs = [] 1782 | 1783 | def _get_ids_and_refs(schema_dict): 1784 | for k, v in schema_dict.items(): 1785 | if isinstance(v, dict): 1786 | _get_ids_and_refs(v) 1787 | continue 1788 | 1789 | if k == "$id" and v != "my-id": 1790 | schema_ids.append(v) 1791 | elif k == "$ref": 1792 | refs.append(v) 1793 | 1794 | _get_ids_and_refs(json_s) 1795 | 1796 | # No ID is repeated 1797 | assert len(schema_ids) == len(set(schema_ids)) 1798 | 1799 | # All IDs are used in a ref 1800 | for schema_id in schema_ids: 1801 | assert schema_id in refs 1802 | 1803 | # All refs have an associated ID 1804 | for ref in refs: 1805 | assert ref in schema_ids 1806 | 1807 | 1808 | def test_json_schema_definitions(): 1809 | sub_schema = Schema({"sub_key1": int}, name="sub_schema", as_reference=True) 1810 | main_schema = Schema({"main_key1": str, "main_key2": sub_schema}) 1811 | 1812 | json_schema = main_schema.json_schema("my-id") 1813 | assert sorted_dict(json_schema) == { 1814 | "type": "object", 1815 | "properties": { 1816 | "main_key1": {"type": "string"}, 1817 | "main_key2": {"$ref": "#/definitions/sub_schema"}, 1818 | }, 1819 | "required": ["main_key1", "main_key2"], 1820 | "additionalProperties": False, 1821 | "$id": "my-id", 1822 | "$schema": "http://json-schema.org/draft-07/schema#", 1823 | "definitions": { 1824 | "sub_schema": { 1825 | "type": "object", 1826 | "title": "sub_schema", 1827 | "properties": {"sub_key1": {"type": "integer"}}, 1828 | "required": ["sub_key1"], 1829 | "additionalProperties": False, 1830 | } 1831 | }, 1832 | } 1833 | 1834 | 1835 | def test_json_schema_definitions_and_literals(): 1836 | sub_schema = Schema( 1837 | {Literal("sub_key1", description="Sub key 1"): int}, 1838 | name="sub_schema", 1839 | as_reference=True, 1840 | description="Sub Schema", 1841 | ) 1842 | main_schema = Schema( 1843 | { 1844 | Literal("main_key1", description="Main Key 1"): str, 1845 | Literal("main_key2", description="Main Key 2"): sub_schema, 1846 | Literal("main_key3", description="Main Key 3"): sub_schema, 1847 | } 1848 | ) 1849 | 1850 | json_schema = main_schema.json_schema("my-id") 1851 | assert sorted_dict(json_schema) == { 1852 | "type": "object", 1853 | "properties": { 1854 | "main_key1": {"description": "Main Key 1", "type": "string"}, 1855 | "main_key2": { 1856 | "$ref": "#/definitions/sub_schema", 1857 | "description": "Main Key 2", 1858 | }, 1859 | "main_key3": { 1860 | "$ref": "#/definitions/sub_schema", 1861 | "description": "Main Key 3", 1862 | }, 1863 | }, 1864 | "required": ["main_key1", "main_key2", "main_key3"], 1865 | "additionalProperties": False, 1866 | "$id": "my-id", 1867 | "$schema": "http://json-schema.org/draft-07/schema#", 1868 | "definitions": { 1869 | "sub_schema": { 1870 | "description": "Sub Schema", 1871 | "type": "object", 1872 | "properties": { 1873 | "sub_key1": {"description": "Sub key 1", "type": "integer"} 1874 | }, 1875 | "required": ["sub_key1"], 1876 | "title": "sub_schema", 1877 | "additionalProperties": False, 1878 | } 1879 | }, 1880 | } 1881 | 1882 | 1883 | def test_json_schema_definitions_nested(): 1884 | sub_sub_schema = Schema( 1885 | {"sub_sub_key1": int}, name="sub_sub_schema", as_reference=True 1886 | ) 1887 | sub_schema = Schema( 1888 | {"sub_key1": int, "sub_key2": sub_sub_schema}, 1889 | name="sub_schema", 1890 | as_reference=True, 1891 | ) 1892 | main_schema = Schema({"main_key1": str, "main_key2": sub_schema}) 1893 | 1894 | json_schema = main_schema.json_schema("my-id") 1895 | assert sorted_dict(json_schema) == { 1896 | "type": "object", 1897 | "properties": { 1898 | "main_key1": {"type": "string"}, 1899 | "main_key2": {"$ref": "#/definitions/sub_schema"}, 1900 | }, 1901 | "required": ["main_key1", "main_key2"], 1902 | "additionalProperties": False, 1903 | "$id": "my-id", 1904 | "$schema": "http://json-schema.org/draft-07/schema#", 1905 | "definitions": { 1906 | "sub_schema": { 1907 | "type": "object", 1908 | "title": "sub_schema", 1909 | "properties": { 1910 | "sub_key1": {"type": "integer"}, 1911 | "sub_key2": {"$ref": "#/definitions/sub_sub_schema"}, 1912 | }, 1913 | "required": ["sub_key1", "sub_key2"], 1914 | "additionalProperties": False, 1915 | }, 1916 | "sub_sub_schema": { 1917 | "type": "object", 1918 | "title": "sub_sub_schema", 1919 | "properties": {"sub_sub_key1": {"type": "integer"}}, 1920 | "required": ["sub_sub_key1"], 1921 | "additionalProperties": False, 1922 | }, 1923 | }, 1924 | } 1925 | 1926 | 1927 | def test_json_schema_definitions_recursive(): 1928 | """Create a JSON schema with an object that refers to itself 1929 | 1930 | This is the example from here: https://json-schema.org/understanding-json-schema/structuring.html#recursion 1931 | """ 1932 | children = [] 1933 | person = Schema( 1934 | {Optional("name"): str, Optional("children"): children}, 1935 | name="person", 1936 | as_reference=True, 1937 | ) 1938 | children.append(person) 1939 | 1940 | json_schema = person.json_schema("my-id") 1941 | assert json_schema == { 1942 | "$ref": "#/definitions/person", 1943 | "$id": "my-id", 1944 | "$schema": "http://json-schema.org/draft-07/schema#", 1945 | "title": "person", 1946 | "definitions": { 1947 | "person": { 1948 | "type": "object", 1949 | "title": "person", 1950 | "properties": { 1951 | "name": {"type": "string"}, 1952 | "children": { 1953 | "type": "array", 1954 | "items": {"$ref": "#/definitions/person"}, 1955 | }, 1956 | }, 1957 | "required": [], 1958 | "additionalProperties": False, 1959 | } 1960 | }, 1961 | } 1962 | 1963 | 1964 | def test_json_schema_definitions_invalid(): 1965 | with raises(ValueError): 1966 | _ = Schema({"test1": str}, as_reference=True) 1967 | 1968 | 1969 | def test_json_schema_default_value(): 1970 | s = Schema({Optional("test1", default=42): int}) 1971 | assert s.json_schema("my-id") == { 1972 | "type": "object", 1973 | "properties": {"test1": {"type": "integer", "default": 42}}, 1974 | "required": [], 1975 | "additionalProperties": False, 1976 | "$id": "my-id", 1977 | "$schema": "http://json-schema.org/draft-07/schema#", 1978 | } 1979 | 1980 | 1981 | def test_json_schema_default_value_with_literal(): 1982 | s = Schema({Optional(Literal("test1"), default=False): bool}) 1983 | assert s.json_schema("my-id") == { 1984 | "type": "object", 1985 | "properties": {"test1": {"type": "boolean", "default": False}}, 1986 | "required": [], 1987 | "additionalProperties": False, 1988 | "$id": "my-id", 1989 | "$schema": "http://json-schema.org/draft-07/schema#", 1990 | } 1991 | 1992 | 1993 | def test_json_schema_default_is_none(): 1994 | s = Schema({Optional("test1", default=None): str}) 1995 | assert s.json_schema("my-id") == { 1996 | "type": "object", 1997 | "properties": {"test1": {"type": "string", "default": None}}, 1998 | "required": [], 1999 | "additionalProperties": False, 2000 | "$id": "my-id", 2001 | "$schema": "http://json-schema.org/draft-07/schema#", 2002 | } 2003 | 2004 | 2005 | def test_json_schema_default_is_tuple(): 2006 | s = Schema({Optional("test1", default=(1, 2)): list}) 2007 | assert s.json_schema("my-id") == { 2008 | "type": "object", 2009 | "properties": {"test1": {"type": "array", "default": [1, 2]}}, 2010 | "required": [], 2011 | "additionalProperties": False, 2012 | "$id": "my-id", 2013 | "$schema": "http://json-schema.org/draft-07/schema#", 2014 | } 2015 | 2016 | 2017 | def test_json_schema_default_is_literal(): 2018 | s = Schema({Optional("test1", default=Literal("Hello!")): str}) 2019 | assert s.json_schema("my-id") == { 2020 | "type": "object", 2021 | "properties": {"test1": {"type": "string", "default": "Hello!"}}, 2022 | "required": [], 2023 | "additionalProperties": False, 2024 | "$id": "my-id", 2025 | "$schema": "http://json-schema.org/draft-07/schema#", 2026 | } 2027 | 2028 | 2029 | def test_prepend_schema_name(): 2030 | try: 2031 | Schema({"key1": int}).validate({"key1": "a"}) 2032 | except SchemaError as e: 2033 | assert str(e) == "Key 'key1' error:\n'a' should be instance of 'int'" 2034 | 2035 | try: 2036 | Schema({"key1": int}, name="custom_schemaname").validate({"key1": "a"}) 2037 | except SchemaError as e: 2038 | assert ( 2039 | str(e) 2040 | == "'custom_schemaname' Key 'key1' error:\n'a' should be instance of 'int'" 2041 | ) 2042 | 2043 | try: 2044 | Schema(int, name="custom_schemaname").validate("a") 2045 | except SchemaUnexpectedTypeError as e: 2046 | assert str(e) == "'custom_schemaname' 'a' should be instance of 'int'" 2047 | 2048 | 2049 | def test_dict_literal_error_string(): 2050 | # this is a simplified regression test of the bug in github issue #240 2051 | assert Schema(Or({"a": 1}, error="error: {}")).is_valid(dict(a=1)) 2052 | 2053 | 2054 | def test_callable_error(): 2055 | # this tests for the behavior desired in github pull request #238 2056 | e = None 2057 | try: 2058 | Schema(lambda d: False, error="{}").validate("This is the error message") 2059 | except SchemaError as ex: 2060 | e = ex 2061 | assert e.errors == ["This is the error message"] 2062 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | # Tox (http://tox.testrun.org/) is a tool for running tests in 2 | # multiple virtualenvs. This configuration file will run the 3 | # test suite on all supported python versions. To use it, "pip 4 | # install tox" and then run "tox" from this directory. 5 | 6 | [tox] 7 | envlist = py26, py27, py32, py33, py34, py35, py36, py37, py38, py39, pypy3, coverage, checks 8 | 9 | [testenv] 10 | commands = py.test 11 | recreate = true 12 | deps = pytest 13 | mock 14 | 15 | 16 | [testenv:py38] 17 | commands = py.test --doctest-glob=README.rst # test documentation 18 | deps = pytest 19 | mock 20 | 21 | [testenv:checks] 22 | basepython=python3 23 | commands = pre-commit run -a --hook-stage=manual 24 | deps = pre-commit 25 | 26 | [testenv:coverage] 27 | basepython=python3 28 | commands = coverage erase 29 | py.test --doctest-glob=README.rst --cov schema 30 | coverage report -m 31 | deps = pytest 32 | pytest-cov 33 | coverage 34 | mock 35 | --------------------------------------------------------------------------------