├── tests ├── __init__.py └── cfgv_test.py ├── .gitignore ├── requirements-dev.txt ├── setup.py ├── .github └── workflows │ └── main.yml ├── tox.ini ├── setup.cfg ├── LICENSE ├── .pre-commit-config.yaml ├── README.md └── cfgv.py /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.egg-info 2 | *.pyc 3 | /.coverage 4 | /.tox 5 | -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | covdefaults 2 | coverage 3 | pytest 4 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from setuptools import setup 4 | setup() 5 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: main 2 | 3 | on: 4 | push: 5 | branches: [main, test-me-*] 6 | tags: '*' 7 | pull_request: 8 | 9 | jobs: 10 | main: 11 | uses: asottile/workflows/.github/workflows/tox.yml@v1.8.1 12 | with: 13 | env: '["py310", "py311", "py312", "py313"]' 14 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py,pre-commit 3 | 4 | [testenv] 5 | deps = -rrequirements-dev.txt 6 | commands = 7 | coverage erase 8 | coverage run -m pytest {posargs:tests} 9 | coverage report 10 | 11 | [testenv:pre-commit] 12 | skip_install = true 13 | deps = pre-commit 14 | commands = pre-commit run --all-files --show-diff-on-failure 15 | 16 | [pep8] 17 | ignore = E265,E501,W504 18 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | name = cfgv 3 | version = 3.5.0 4 | description = Validate configuration and produce human readable error messages. 5 | long_description = file: README.md 6 | long_description_content_type = text/markdown 7 | url = https://github.com/asottile/cfgv 8 | author = Anthony Sottile 9 | author_email = asottile@umich.edu 10 | license = MIT 11 | license_files = LICENSE 12 | classifiers = 13 | Programming Language :: Python :: 3 14 | Programming Language :: Python :: 3 :: Only 15 | Programming Language :: Python :: Implementation :: CPython 16 | Programming Language :: Python :: Implementation :: PyPy 17 | 18 | [options] 19 | py_modules = cfgv 20 | python_requires = >=3.10 21 | 22 | [bdist_wheel] 23 | universal = True 24 | 25 | [coverage:run] 26 | plugins = covdefaults 27 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2018 Anthony Sottile 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in 11 | all 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 19 | THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v6.0.0 4 | hooks: 5 | - id: trailing-whitespace 6 | - id: end-of-file-fixer 7 | - id: check-yaml 8 | - id: debug-statements 9 | - id: double-quote-string-fixer 10 | - id: name-tests-test 11 | - id: requirements-txt-fixer 12 | - repo: https://github.com/asottile/setup-cfg-fmt 13 | rev: v3.2.0 14 | hooks: 15 | - id: setup-cfg-fmt 16 | - repo: https://github.com/asottile/reorder-python-imports 17 | rev: v3.16.0 18 | hooks: 19 | - id: reorder-python-imports 20 | args: [--py310-plus, --add-import, 'from __future__ import annotations'] 21 | - repo: https://github.com/asottile/add-trailing-comma 22 | rev: v4.0.0 23 | hooks: 24 | - id: add-trailing-comma 25 | - repo: https://github.com/asottile/pyupgrade 26 | rev: v3.21.2 27 | hooks: 28 | - id: pyupgrade 29 | args: [--py310-plus] 30 | - repo: https://github.com/hhatto/autopep8 31 | rev: v2.3.2 32 | hooks: 33 | - id: autopep8 34 | - repo: https://github.com/PyCQA/flake8 35 | rev: 7.3.0 36 | hooks: 37 | - id: flake8 38 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![build status](https://github.com/asottile/cfgv/actions/workflows/main.yml/badge.svg)](https://github.com/asottile/cfgv/actions/workflows/main.yml) 2 | [![pre-commit.ci status](https://results.pre-commit.ci/badge/github/asottile/cfgv/main.svg)](https://results.pre-commit.ci/latest/github/asottile/cfgv/main) 3 | 4 | cfgv 5 | ==== 6 | 7 | Validate configuration and produce human readable error messages. 8 | 9 | ## Installation 10 | 11 | ```bash 12 | pip install cfgv 13 | ``` 14 | 15 | ## Sample error messages 16 | 17 | These are easier to see by example. Here's an example where I typo'd `true` 18 | in a [pre-commit](https://pre-commit.com) configuration. 19 | 20 | ``` 21 | pre_commit.clientlib.InvalidConfigError: 22 | ==> File /home/asottile/workspace/pre-commit/.pre-commit-config.yaml 23 | ==> At Config() 24 | ==> At key: repos 25 | ==> At Repository(repo='https://github.com/pre-commit/pre-commit-hooks') 26 | ==> At key: hooks 27 | ==> At Hook(id='flake8') 28 | ==> At key: always_run 29 | =====> Expected bool got str 30 | ``` 31 | 32 | ## API 33 | 34 | ### `cfgv.validate(value, schema)` 35 | 36 | Perform validation on the schema: 37 | - raises `ValidationError` on failure 38 | - returns the value on success (for convenience) 39 | 40 | ### `cfgv.apply_defaults(value, schema)` 41 | 42 | Returns a new value which sets all missing optional values to their defaults. 43 | 44 | ### `cfgv.remove_defaults(value, schema)` 45 | 46 | Returns a new value which removes all optional values that are set to their 47 | defaults. 48 | 49 | ### `cfgv.load_from_filename(filename, schema, load_strategy, exc_tp=ValidationError)` 50 | 51 | Load a file given the `load_strategy`. Reraise any errors as `exc_tp`. All 52 | defaults will be populated in the resulting value. 53 | 54 | Most useful when used with `functools.partial` as follows: 55 | 56 | ```python 57 | load_my_cfg = functools.partial( 58 | cfgv.load_from_filename, 59 | schema=MY_SCHEMA, 60 | load_strategy=json.loads, 61 | exc_tp=MyError, 62 | ) 63 | ``` 64 | 65 | ## Making a schema 66 | 67 | A schema validates a container -- `cfgv` provides `Map` and `Array` for 68 | most normal cases. 69 | 70 | ### writing your own schema container 71 | 72 | If the built-in containers below don't quite satisfy your usecase, you can 73 | always write your own. Containers use the following interface: 74 | 75 | ```python 76 | class Container(object): 77 | def check(self, v): 78 | """check the passed in value (do not modify `v`)""" 79 | 80 | def apply_defaults(self, v): 81 | """return a new value with defaults applied (do not modify `v`)""" 82 | 83 | def remove_defaults(self, v): 84 | """return a new value with defaults removed (do not modify `v`)""" 85 | ``` 86 | 87 | ### `Map(object_name, id_key, *items)` 88 | 89 | The most basic building block for creating a schema is a `Map` 90 | 91 | - `object_name`: will be displayed in error messages 92 | - `id_key`: will be used to identify the object in error messages. Set to 93 | `None` if there is no identifying key for the object. 94 | - `items`: validator objects such as `Required` or `Optional` 95 | 96 | Consider the following schema: 97 | 98 | ```python 99 | Map( 100 | 'Repo', 'url', 101 | Required('url', check_any), 102 | ) 103 | ``` 104 | 105 | In an error message, the map may be displayed as: 106 | 107 | - `Repo(url='https://github.com/pre-commit/pre-commit')` 108 | - `Repo(url=MISSING)` (if the key is not present) 109 | 110 | ### `Array(of, allow_empty=True)` 111 | 112 | Used to nest maps inside of arrays. For arrays of scalars, see `check_array`. 113 | 114 | - `of`: A `Map` / `Array` or other sub-schema. 115 | - `allow_empty`: when `False`, `Array` will ensure at least one element. 116 | 117 | When validated, this will check that each element adheres to the sub-schema. 118 | 119 | ### `KeyValueMap(object_name, key_check_fn, value_schema)` 120 | 121 | Used to make a schema representing a homogenous mapping where the keys are 122 | of a specific type and the values match a schema 123 | 124 | - `object_name`: will be displayed in error messages 125 | - `key_check_fn`: a [check function](#check-functions) for the key 126 | - `value_schema`: a `Map` / `Array` or other sub-schema. 127 | 128 | ## Validator objects 129 | 130 | Validator objects are used to validate key-value-pairs of a `Map`. 131 | 132 | ### writing your own validator 133 | 134 | If the built-in validators below don't quite satisfy your usecase, you can 135 | always write your own. Validators use the following interface: 136 | 137 | ```python 138 | class Validator(object): 139 | def check(self, dct): 140 | """check that your specific key has the appropriate value in `dct`""" 141 | 142 | def apply_default(self, dct): 143 | """modify `dct` and set the default value if it is missing""" 144 | 145 | def remove_default(self, dct): 146 | """modify `dct` and remove the default value if it is present""" 147 | ``` 148 | 149 | It may make sense to _borrow_ functions from the built in validators. They 150 | additionally use the following interface(s): 151 | 152 | - `self.key`: the key to check 153 | - `self.check_fn`: the [check function](#check-functions) 154 | - `self.default`: a default value to set. 155 | 156 | ### `Required(key, check_fn)` 157 | 158 | Ensure that a key is present in a `Map` and adheres to the 159 | [check function](#check-functions). 160 | 161 | ### `RequiredRecurse(key, schema)` 162 | 163 | Similar to `Required`, but uses a [schema](#making-a-schema). 164 | 165 | ### `Optional(key, check_fn, default)` 166 | 167 | If a key is present, check that it adheres to the 168 | [check function](#check-functions). 169 | 170 | - `apply_defaults` will set the `default` if it is not present. 171 | - `remove_defaults` will remove the value if it is equal to `default`. 172 | 173 | ### `OptionalRecurse(key, schema, default)` 174 | 175 | Similar to `Optional` but uses a [schema](#making-a-schema). 176 | 177 | - `apply_defaults` will set the `default` if it is not present and then 178 | validate it with the schema. 179 | - `remove_defaults` will remove defaults using the schema, and then remove the 180 | value it if it is equal to `default`. 181 | 182 | ### `OptionalNoDefault(key, check_fn)` 183 | 184 | Like `Optional`, but does not `apply_defaults` or `remove_defaults`. 185 | 186 | ### `Conditional(key, check_fn, condition_key, condition_value, ensure_absent=False)` 187 | 188 | - If `condition_key` is equal to the `condition_value`, the specific `key` 189 | will be checked using the [check function](#check-functions). 190 | - If `ensure_absent` is `True` and the condition check fails, the `key` will 191 | be checked for absense. 192 | 193 | Note that the `condition_value` is checked for equality, so any object 194 | implementing `__eq__` may be used. A few are provided out of the box 195 | for this purpose, see [equality helpers](#equality-helpers). 196 | 197 | ### `ConditionalOptional(key, check_fn, default, condition_key, condition_value, ensure_absent=False)` 198 | 199 | Similar to ``Conditional`` and ``Optional``. 200 | 201 | ### `ConditionalRecurse(key, schema, condition_key, condition_value, ensure_absent=True)` 202 | 203 | Similar to `Conditional`, but uses a [schema](#making-a-schema). 204 | 205 | ### `NoAdditionalKeys(keys)` 206 | 207 | Use in a mapping to ensure that only the `keys` specified are present. 208 | 209 | ## Equality helpers 210 | 211 | Equality helpers at the very least implement `__eq__` for their behaviour. 212 | 213 | They may also implement `def describe_opposite(self):` for use in the 214 | `ensure_absent=True` error message (otherwise, the `__repr__` will be used). 215 | 216 | ### `Not(val)` 217 | 218 | Returns `True` if the value is not equal to `val`. 219 | 220 | ### `In(*values)` 221 | 222 | Returns `True` if the value is contained in `values`. 223 | 224 | ### `NotIn(*values)` 225 | 226 | Returns `True` if the value is not contained in `values`. 227 | 228 | ## Check functions 229 | 230 | A number of check functions are provided out of the box. 231 | 232 | A check function takes a single parameter, the `value`, and either raises a 233 | `ValidationError` or returns nothing. 234 | 235 | ### `check_any(_)` 236 | 237 | A noop check function. 238 | 239 | ### `check_type(tp, typename=None)` 240 | 241 | Returns a check function to check for a specific type. Setting `typename` 242 | will replace the type's name in the error message. 243 | 244 | For example: 245 | 246 | ```python 247 | Required('key', check_type(int)) 248 | # 'Expected bytes' in both python2 and python3. 249 | Required('key', check_type(bytes, typename='bytes')) 250 | ``` 251 | 252 | Several type checking functions are provided out of the box: 253 | 254 | - `check_bool` 255 | - `check_bytes` 256 | - `check_int` 257 | - `check_string` 258 | - `check_text` 259 | 260 | ### `check_one_of(possible)` 261 | 262 | Returns a function that checks that the value is contained in `possible`. 263 | 264 | For example: 265 | 266 | ```python 267 | Required('language', check_one_of(('javascript', 'python', 'ruby'))) 268 | ``` 269 | 270 | ### `check_regex(v)` 271 | 272 | Ensures that `v` is a valid python regular expression. 273 | 274 | ### `check_array(inner_check)` 275 | 276 | Returns a function that checks that a value is a sequence and that each 277 | value in that sequence adheres to the `inner_check`. 278 | 279 | For example: 280 | 281 | ```python 282 | Required('args', check_array(check_string)) 283 | ``` 284 | 285 | ### `check_and(*fns)` 286 | 287 | Returns a function that performs multiple checks on a value. 288 | 289 | For example: 290 | 291 | ```python 292 | Required('language', check_and(check_string, my_check_language)) 293 | ``` 294 | -------------------------------------------------------------------------------- /cfgv.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import collections 4 | import contextlib 5 | import os.path 6 | import re 7 | import sys 8 | 9 | 10 | class ValidationError(ValueError): 11 | def __init__(self, error_msg, ctx=None): 12 | super().__init__(error_msg) 13 | self.error_msg = error_msg 14 | self.ctx = ctx 15 | 16 | def __str__(self): 17 | out = '\n' 18 | err = self 19 | while err.ctx is not None: 20 | out += f'==> {err.ctx}\n' 21 | err = err.error_msg 22 | out += f'=====> {err.error_msg}' 23 | return out 24 | 25 | 26 | MISSING = collections.namedtuple('Missing', ())() 27 | type(MISSING).__repr__ = lambda self: 'MISSING' 28 | 29 | 30 | @contextlib.contextmanager 31 | def validate_context(msg): 32 | try: 33 | yield 34 | except ValidationError as e: 35 | _, _, tb = sys.exc_info() 36 | raise ValidationError(e, ctx=msg).with_traceback(tb) from None 37 | 38 | 39 | @contextlib.contextmanager 40 | def reraise_as(tp): 41 | try: 42 | yield 43 | except ValidationError as e: 44 | _, _, tb = sys.exc_info() 45 | raise tp(e).with_traceback(tb) from None 46 | 47 | 48 | def _dct_noop(self, dct): 49 | pass 50 | 51 | 52 | def _check_optional(self, dct): 53 | if self.key not in dct: 54 | return 55 | with validate_context(f'At key: {self.key}'): 56 | self.check_fn(dct[self.key]) 57 | 58 | 59 | def _apply_default_optional(self, dct): 60 | dct.setdefault(self.key, self.default) 61 | 62 | 63 | def _remove_default_optional(self, dct): 64 | if dct.get(self.key, MISSING) == self.default: 65 | del dct[self.key] 66 | 67 | 68 | def _require_key(self, dct): 69 | if self.key not in dct: 70 | raise ValidationError(f'Missing required key: {self.key}') 71 | 72 | 73 | def _check_required(self, dct): 74 | _require_key(self, dct) 75 | _check_optional(self, dct) 76 | 77 | 78 | @property 79 | def _check_fn_recurse(self): 80 | def check_fn(val): 81 | validate(val, self.schema) 82 | return check_fn 83 | 84 | 85 | def _apply_default_required_recurse(self, dct): 86 | dct[self.key] = apply_defaults(dct[self.key], self.schema) 87 | 88 | 89 | def _remove_default_required_recurse(self, dct): 90 | dct[self.key] = remove_defaults(dct[self.key], self.schema) 91 | 92 | 93 | def _apply_default_optional_recurse(self, dct): 94 | if self.key not in dct: 95 | _apply_default_optional(self, dct) 96 | _apply_default_required_recurse(self, dct) 97 | 98 | 99 | def _remove_default_optional_recurse(self, dct): 100 | if self.key in dct: 101 | _remove_default_required_recurse(self, dct) 102 | _remove_default_optional(self, dct) 103 | 104 | 105 | def _get_check_conditional(inner): 106 | def _check_conditional(self, dct): 107 | if dct.get(self.condition_key, MISSING) == self.condition_value: 108 | inner(self, dct) 109 | elif ( 110 | self.condition_key in dct and 111 | self.ensure_absent and self.key in dct 112 | ): 113 | if hasattr(self.condition_value, 'describe_opposite'): 114 | explanation = self.condition_value.describe_opposite() 115 | else: 116 | explanation = f'is not {self.condition_value!r}' 117 | raise ValidationError( 118 | f'Expected {self.key} to be absent when {self.condition_key} ' 119 | f'{explanation}, found {self.key}: {dct[self.key]!r}', 120 | ) 121 | return _check_conditional 122 | 123 | 124 | def _apply_default_conditional_optional(self, dct): 125 | if dct.get(self.condition_key, MISSING) == self.condition_value: 126 | _apply_default_optional(self, dct) 127 | 128 | 129 | def _remove_default_conditional_optional(self, dct): 130 | if dct.get(self.condition_key, MISSING) == self.condition_value: 131 | _remove_default_optional(self, dct) 132 | 133 | 134 | def _apply_default_conditional_recurse(self, dct): 135 | if dct.get(self.condition_key, MISSING) == self.condition_value: 136 | _apply_default_required_recurse(self, dct) 137 | 138 | 139 | def _remove_default_conditional_recurse(self, dct): 140 | if dct.get(self.condition_key, MISSING) == self.condition_value: 141 | _remove_default_required_recurse(self, dct) 142 | 143 | 144 | def _no_additional_keys_check(self, dct): 145 | extra = sorted(set(dct) - set(self.keys)) 146 | if extra: 147 | extra_s = ', '.join(str(x) for x in extra) 148 | keys_s = ', '.join(str(x) for x in self.keys) 149 | raise ValidationError( 150 | f'Additional keys found: {extra_s}. ' 151 | f'Only these keys are allowed: {keys_s}', 152 | ) 153 | 154 | 155 | def _warn_additional_keys_check(self, dct): 156 | extra = sorted(set(dct) - set(self.keys)) 157 | if extra: 158 | self.callback(extra, self.keys, dct) 159 | 160 | 161 | Required = collections.namedtuple('Required', ('key', 'check_fn')) 162 | Required.check = _check_required 163 | Required.apply_default = _dct_noop 164 | Required.remove_default = _dct_noop 165 | RequiredRecurse = collections.namedtuple('RequiredRecurse', ('key', 'schema')) 166 | RequiredRecurse.check = _check_required 167 | RequiredRecurse.check_fn = _check_fn_recurse 168 | RequiredRecurse.apply_default = _apply_default_required_recurse 169 | RequiredRecurse.remove_default = _remove_default_required_recurse 170 | Optional = collections.namedtuple('Optional', ('key', 'check_fn', 'default')) 171 | Optional.check = _check_optional 172 | Optional.apply_default = _apply_default_optional 173 | Optional.remove_default = _remove_default_optional 174 | OptionalRecurse = collections.namedtuple( 175 | 'OptionalRecurse', ('key', 'schema', 'default'), 176 | ) 177 | OptionalRecurse.check = _check_optional 178 | OptionalRecurse.check_fn = _check_fn_recurse 179 | OptionalRecurse.apply_default = _apply_default_optional_recurse 180 | OptionalRecurse.remove_default = _remove_default_optional_recurse 181 | OptionalNoDefault = collections.namedtuple( 182 | 'OptionalNoDefault', ('key', 'check_fn'), 183 | ) 184 | OptionalNoDefault.check = _check_optional 185 | OptionalNoDefault.apply_default = _dct_noop 186 | OptionalNoDefault.remove_default = _dct_noop 187 | Conditional = collections.namedtuple( 188 | 'Conditional', 189 | ('key', 'check_fn', 'condition_key', 'condition_value', 'ensure_absent'), 190 | ) 191 | Conditional.__new__.__defaults__ = (False,) 192 | Conditional.check = _get_check_conditional(_check_required) 193 | Conditional.apply_default = _dct_noop 194 | Conditional.remove_default = _dct_noop 195 | ConditionalOptional = collections.namedtuple( 196 | 'ConditionalOptional', 197 | ( 198 | 'key', 'check_fn', 'default', 'condition_key', 'condition_value', 199 | 'ensure_absent', 200 | ), 201 | ) 202 | ConditionalOptional.__new__.__defaults__ = (False,) 203 | ConditionalOptional.check = _get_check_conditional(_check_optional) 204 | ConditionalOptional.apply_default = _apply_default_conditional_optional 205 | ConditionalOptional.remove_default = _remove_default_conditional_optional 206 | ConditionalRecurse = collections.namedtuple( 207 | 'ConditionalRecurse', 208 | ('key', 'schema', 'condition_key', 'condition_value', 'ensure_absent'), 209 | ) 210 | ConditionalRecurse.__new__.__defaults__ = (False,) 211 | ConditionalRecurse.check = _get_check_conditional(_check_required) 212 | ConditionalRecurse.check_fn = _check_fn_recurse 213 | ConditionalRecurse.apply_default = _apply_default_conditional_recurse 214 | ConditionalRecurse.remove_default = _remove_default_conditional_recurse 215 | NoAdditionalKeys = collections.namedtuple('NoAdditionalKeys', ('keys',)) 216 | NoAdditionalKeys.check = _no_additional_keys_check 217 | NoAdditionalKeys.apply_default = _dct_noop 218 | NoAdditionalKeys.remove_default = _dct_noop 219 | WarnAdditionalKeys = collections.namedtuple( 220 | 'WarnAdditionalKeys', ('keys', 'callback'), 221 | ) 222 | WarnAdditionalKeys.check = _warn_additional_keys_check 223 | WarnAdditionalKeys.apply_default = _dct_noop 224 | WarnAdditionalKeys.remove_default = _dct_noop 225 | 226 | 227 | class Map(collections.namedtuple('Map', ('object_name', 'id_key', 'items'))): 228 | __slots__ = () 229 | 230 | def __new__(cls, object_name, id_key, *items): 231 | return super().__new__(cls, object_name, id_key, items) 232 | 233 | def check(self, v): 234 | if not isinstance(v, dict): 235 | raise ValidationError( 236 | f'Expected a {self.object_name} map but got a ' 237 | f'{type(v).__name__}', 238 | ) 239 | if self.id_key is None: 240 | context = f'At {self.object_name}()' 241 | else: 242 | key_v_s = v.get(self.id_key, MISSING) 243 | context = f'At {self.object_name}({self.id_key}={key_v_s!r})' 244 | with validate_context(context): 245 | for item in self.items: 246 | item.check(v) 247 | 248 | def apply_defaults(self, v): 249 | ret = v.copy() 250 | for item in self.items: 251 | item.apply_default(ret) 252 | return ret 253 | 254 | def remove_defaults(self, v): 255 | ret = v.copy() 256 | for item in self.items: 257 | item.remove_default(ret) 258 | return ret 259 | 260 | 261 | class KeyValueMap( 262 | collections.namedtuple( 263 | 'KeyValueMap', 264 | ('object_name', 'check_key_fn', 'value_schema'), 265 | ), 266 | ): 267 | __slots__ = () 268 | 269 | def check(self, v): 270 | if not isinstance(v, dict): 271 | raise ValidationError( 272 | f'Expected a {self.object_name} map but got a ' 273 | f'{type(v).__name__}', 274 | ) 275 | with validate_context(f'At {self.object_name}()'): 276 | for k, val in v.items(): 277 | with validate_context(f'For key: {k}'): 278 | self.check_key_fn(k) 279 | with validate_context(f'At key: {k}'): 280 | validate(val, self.value_schema) 281 | 282 | def apply_defaults(self, v): 283 | return { 284 | k: apply_defaults(val, self.value_schema) 285 | for k, val in v.items() 286 | } 287 | 288 | def remove_defaults(self, v): 289 | return { 290 | k: remove_defaults(val, self.value_schema) 291 | for k, val in v.items() 292 | } 293 | 294 | 295 | class Array(collections.namedtuple('Array', ('of', 'allow_empty'))): 296 | __slots__ = () 297 | 298 | def __new__(cls, of, allow_empty=True): 299 | return super().__new__(cls, of=of, allow_empty=allow_empty) 300 | 301 | def check(self, v): 302 | check_array(check_any)(v) 303 | if not self.allow_empty and not v: 304 | raise ValidationError( 305 | f"Expected at least 1 '{self.of.object_name}'", 306 | ) 307 | for val in v: 308 | validate(val, self.of) 309 | 310 | def apply_defaults(self, v): 311 | return [apply_defaults(val, self.of) for val in v] 312 | 313 | def remove_defaults(self, v): 314 | return [remove_defaults(val, self.of) for val in v] 315 | 316 | 317 | class Not(collections.namedtuple('Not', ('val',))): 318 | __slots__ = () 319 | 320 | def describe_opposite(self): 321 | return f'is {self.val!r}' 322 | 323 | def __eq__(self, other): 324 | return other is not MISSING and other != self.val 325 | 326 | 327 | class NotIn(collections.namedtuple('NotIn', ('values',))): 328 | __slots__ = () 329 | 330 | def __new__(cls, *values): 331 | return super().__new__(cls, values=values) 332 | 333 | def describe_opposite(self): 334 | return f'is any of {self.values!r}' 335 | 336 | def __eq__(self, other): 337 | return other is not MISSING and other not in self.values 338 | 339 | 340 | class In(collections.namedtuple('In', ('values',))): 341 | __slots__ = () 342 | 343 | def __new__(cls, *values): 344 | return super().__new__(cls, values=values) 345 | 346 | def describe_opposite(self): 347 | return f'is not any of {self.values!r}' 348 | 349 | def __eq__(self, other): 350 | return other is not MISSING and other in self.values 351 | 352 | 353 | def check_any(_): 354 | pass 355 | 356 | 357 | def check_type(tp, typename=None): 358 | def check_type_fn(v): 359 | if not isinstance(v, tp): 360 | typename_s = typename or tp.__name__ 361 | raise ValidationError( 362 | f'Expected {typename_s} got {type(v).__name__}', 363 | ) 364 | return check_type_fn 365 | 366 | 367 | check_bool = check_type(bool) 368 | check_bytes = check_type(bytes) 369 | check_int = check_type(int) 370 | check_string = check_type(str, typename='string') 371 | check_text = check_type(str, typename='text') 372 | 373 | 374 | def check_one_of(possible): 375 | def check_one_of_fn(v): 376 | if v not in possible: 377 | possible_s = ', '.join(str(x) for x in sorted(possible)) 378 | raise ValidationError( 379 | f'Expected one of {possible_s} but got: {v!r}', 380 | ) 381 | return check_one_of_fn 382 | 383 | 384 | def check_regex(v): 385 | try: 386 | re.compile(v) 387 | except re.error: 388 | raise ValidationError(f'{v!r} is not a valid python regex') 389 | 390 | 391 | def check_array(inner_check): 392 | def check_array_fn(v): 393 | if not isinstance(v, (list, tuple)): 394 | raise ValidationError( 395 | f'Expected array but got {type(v).__name__!r}', 396 | ) 397 | 398 | for i, val in enumerate(v): 399 | with validate_context(f'At index {i}'): 400 | inner_check(val) 401 | return check_array_fn 402 | 403 | 404 | def check_and(*fns): 405 | def check(v): 406 | for fn in fns: 407 | fn(v) 408 | return check 409 | 410 | 411 | def validate(v, schema): 412 | schema.check(v) 413 | return v 414 | 415 | 416 | def apply_defaults(v, schema): 417 | return schema.apply_defaults(v) 418 | 419 | 420 | def remove_defaults(v, schema): 421 | return schema.remove_defaults(v) 422 | 423 | 424 | def load_from_filename( 425 | filename, 426 | schema, 427 | load_strategy, 428 | exc_tp=ValidationError, 429 | *, 430 | display_filename=None, 431 | ): 432 | display_filename = display_filename or filename 433 | with reraise_as(exc_tp): 434 | if not os.path.isfile(filename): 435 | raise ValidationError(f'{display_filename} is not a file') 436 | 437 | with validate_context(f'File {display_filename}'): 438 | try: 439 | with open(filename, encoding='utf-8') as f: 440 | contents = f.read() 441 | except UnicodeDecodeError as e: 442 | raise ValidationError(str(e)) 443 | 444 | try: 445 | data = load_strategy(contents) 446 | except Exception as e: 447 | raise ValidationError(str(e)) 448 | 449 | validate(data, schema) 450 | return apply_defaults(data, schema) 451 | -------------------------------------------------------------------------------- /tests/cfgv_test.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import json 4 | from unittest import mock 5 | 6 | import pytest 7 | 8 | from cfgv import apply_defaults 9 | from cfgv import Array 10 | from cfgv import check_and 11 | from cfgv import check_any 12 | from cfgv import check_array 13 | from cfgv import check_bool 14 | from cfgv import check_int 15 | from cfgv import check_one_of 16 | from cfgv import check_regex 17 | from cfgv import check_string 18 | from cfgv import check_type 19 | from cfgv import Conditional 20 | from cfgv import ConditionalOptional 21 | from cfgv import ConditionalRecurse 22 | from cfgv import In 23 | from cfgv import KeyValueMap 24 | from cfgv import load_from_filename 25 | from cfgv import Map 26 | from cfgv import MISSING 27 | from cfgv import NoAdditionalKeys 28 | from cfgv import Not 29 | from cfgv import NotIn 30 | from cfgv import Optional 31 | from cfgv import OptionalNoDefault 32 | from cfgv import OptionalRecurse 33 | from cfgv import remove_defaults 34 | from cfgv import Required 35 | from cfgv import RequiredRecurse 36 | from cfgv import validate 37 | from cfgv import ValidationError 38 | from cfgv import WarnAdditionalKeys 39 | 40 | 41 | def _assert_exception_trace(e, trace): 42 | parts = [] 43 | while e.ctx is not None: 44 | parts.append(e.ctx) 45 | e = e.error_msg 46 | parts.append(e.error_msg) 47 | assert tuple(parts) == trace 48 | 49 | 50 | def test_ValidationError_simple_str(): 51 | assert str(ValidationError('error msg')) == ( 52 | '\n' 53 | '=====> error msg' 54 | ) 55 | 56 | 57 | def test_ValidationError_nested(): 58 | error = ValidationError( 59 | ValidationError( 60 | ValidationError('error msg'), 61 | ctx='At line 1', 62 | ), 63 | ctx='In file foo', 64 | ) 65 | assert str(error) == ( 66 | '\n' 67 | '==> In file foo\n' 68 | '==> At line 1\n' 69 | '=====> error msg' 70 | ) 71 | 72 | 73 | def test_check_one_of(): 74 | with pytest.raises(ValidationError) as excinfo: 75 | check_one_of((1, 2))(3) 76 | assert excinfo.value.error_msg == 'Expected one of 1, 2 but got: 3' 77 | 78 | 79 | def test_check_one_of_ok(): 80 | check_one_of((1, 2))(2) 81 | 82 | 83 | def test_check_regex(): 84 | with pytest.raises(ValidationError) as excinfo: 85 | check_regex('(') 86 | assert excinfo.value.error_msg == "'(' is not a valid python regex" 87 | 88 | 89 | def test_check_regex_ok(): 90 | check_regex('^$') 91 | 92 | 93 | def test_check_array_failed_inner_check(): 94 | check = check_array(check_bool) 95 | with pytest.raises(ValidationError) as excinfo: 96 | check([True, False, 5]) 97 | _assert_exception_trace( 98 | excinfo.value, ('At index 2', 'Expected bool got int'), 99 | ) 100 | 101 | 102 | def test_check_array_ok(): 103 | check_array(check_bool)([True, False]) 104 | 105 | 106 | def test_check_and(): 107 | check = check_and(check_type(str), check_regex) 108 | with pytest.raises(ValidationError) as excinfo: 109 | check(True) 110 | assert excinfo.value.error_msg == 'Expected str got bool' 111 | with pytest.raises(ValidationError) as excinfo: 112 | check('(') 113 | assert excinfo.value.error_msg == "'(' is not a valid python regex" 114 | 115 | 116 | def test_check_and_ok(): 117 | check = check_and(check_type(str), check_regex) 118 | check('^$') 119 | 120 | 121 | @pytest.mark.parametrize( 122 | ('val', 'expected'), 123 | (('bar', True), ('foo', False), (MISSING, False)), 124 | ) 125 | def test_not(val, expected): 126 | compared = Not('foo') 127 | assert (val == compared) is expected 128 | assert (compared == val) is expected 129 | 130 | 131 | @pytest.mark.parametrize( 132 | ('values', 'expected'), 133 | (('bar', True), ('foo', False), (MISSING, False)), 134 | ) 135 | def test_not_in(values, expected): 136 | compared = NotIn('baz', 'foo') 137 | assert (values == compared) is expected 138 | assert (compared == values) is expected 139 | 140 | 141 | @pytest.mark.parametrize( 142 | ('values', 'expected'), 143 | (('bar', False), ('foo', True), ('baz', True), (MISSING, False)), 144 | ) 145 | def test_in(values, expected): 146 | compared = In('baz', 'foo') 147 | assert (values == compared) is expected 148 | assert (compared == values) is expected 149 | 150 | 151 | trivial_array_schema = Array(Map('foo', 'id')) 152 | trivial_array_schema_nonempty = Array(Map('foo', 'id'), allow_empty=False) 153 | 154 | 155 | def test_validate_top_level_array_not_an_array(): 156 | with pytest.raises(ValidationError) as excinfo: 157 | validate({}, trivial_array_schema) 158 | assert excinfo.value.error_msg == "Expected array but got 'dict'" 159 | 160 | 161 | def test_validate_top_level_array_no_objects(): 162 | with pytest.raises(ValidationError) as excinfo: 163 | validate([], trivial_array_schema_nonempty) 164 | assert excinfo.value.error_msg == "Expected at least 1 'foo'" 165 | 166 | 167 | def test_trivial_array_schema_ok_empty(): 168 | validate([], trivial_array_schema) 169 | 170 | 171 | @pytest.mark.parametrize('v', (({},), [{}])) 172 | def test_ok_both_types(v): 173 | validate(v, trivial_array_schema) 174 | 175 | 176 | map_required = Map('foo', 'key', Required('key', check_bool)) 177 | map_optional = Map('foo', 'key', Optional('key', check_bool, False)) 178 | map_no_default = Map('foo', 'key', OptionalNoDefault('key', check_bool)) 179 | map_no_id_key = Map('foo', None, Required('key', check_bool)) 180 | 181 | 182 | def test_map_wrong_type(): 183 | with pytest.raises(ValidationError) as excinfo: 184 | validate([], map_required) 185 | assert excinfo.value.error_msg == 'Expected a foo map but got a list' 186 | 187 | 188 | def test_required_missing_key(): 189 | with pytest.raises(ValidationError) as excinfo: 190 | validate({}, map_required) 191 | expected = ('At foo(key=MISSING)', 'Missing required key: key') 192 | _assert_exception_trace(excinfo.value, expected) 193 | 194 | 195 | @pytest.mark.parametrize( 196 | 'schema', (map_required, map_optional, map_no_default), 197 | ) 198 | def test_map_value_wrong_type(schema): 199 | with pytest.raises(ValidationError) as excinfo: 200 | validate({'key': 5}, schema) 201 | expected = ('At foo(key=5)', 'At key: key', 'Expected bool got int') 202 | _assert_exception_trace(excinfo.value, expected) 203 | 204 | 205 | @pytest.mark.parametrize( 206 | 'schema', (map_required, map_optional, map_no_default), 207 | ) 208 | def test_map_value_correct_type(schema): 209 | validate({'key': True}, schema) 210 | 211 | 212 | @pytest.mark.parametrize('schema', (map_optional, map_no_default)) 213 | def test_optional_key_missing(schema): 214 | validate({}, schema) 215 | 216 | 217 | def test_error_message_no_id_key(): 218 | with pytest.raises(ValidationError) as excinfo: 219 | validate({'key': 5}, map_no_id_key) 220 | expected = ('At foo()', 'At key: key', 'Expected bool got int') 221 | _assert_exception_trace(excinfo.value, expected) 222 | 223 | 224 | map_conditional = Map( 225 | 'foo', 'key', 226 | Conditional( 227 | 'key2', check_bool, condition_key='key', condition_value=True, 228 | ), 229 | ) 230 | map_conditional_not = Map( 231 | 'foo', 'key', 232 | Conditional( 233 | 'key2', check_bool, condition_key='key', condition_value=Not(False), 234 | ), 235 | ) 236 | map_conditional_absent = Map( 237 | 'foo', 'key', 238 | Conditional( 239 | 'key2', check_bool, 240 | condition_key='key', condition_value=True, ensure_absent=True, 241 | ), 242 | ) 243 | map_conditional_absent_not = Map( 244 | 'foo', 'key', 245 | Conditional( 246 | 'key2', check_bool, 247 | condition_key='key', condition_value=Not(True), ensure_absent=True, 248 | ), 249 | ) 250 | map_conditional_absent_not_in = Map( 251 | 'foo', 'key', 252 | Conditional( 253 | 'key2', check_bool, 254 | condition_key='key', condition_value=NotIn(1, 2), ensure_absent=True, 255 | ), 256 | ) 257 | map_conditional_absent_in = Map( 258 | 'foo', 'key', 259 | Conditional( 260 | 'key2', check_bool, 261 | condition_key='key', condition_value=In(1, 2), ensure_absent=True, 262 | ), 263 | ) 264 | 265 | 266 | @pytest.mark.parametrize('schema', (map_conditional, map_conditional_not)) 267 | @pytest.mark.parametrize( 268 | 'v', 269 | ( 270 | # Conditional check passes, key2 is checked and passes 271 | {'key': True, 'key2': True}, 272 | # Conditional check fails, key2 is not checked 273 | {'key': False, 'key2': 'ohai'}, 274 | ), 275 | ) 276 | def test_ok_conditional_schemas(v, schema): 277 | validate(v, schema) 278 | 279 | 280 | @pytest.mark.parametrize('schema', (map_conditional, map_conditional_not)) 281 | def test_not_ok_conditional_schemas(schema): 282 | with pytest.raises(ValidationError) as excinfo: 283 | validate({'key': True, 'key2': 5}, schema) 284 | expected = ('At foo(key=True)', 'At key: key2', 'Expected bool got int') 285 | _assert_exception_trace(excinfo.value, expected) 286 | 287 | 288 | def test_ensure_absent_conditional(): 289 | with pytest.raises(ValidationError) as excinfo: 290 | validate({'key': False, 'key2': True}, map_conditional_absent) 291 | expected = ( 292 | 'At foo(key=False)', 293 | 'Expected key2 to be absent when key is not True, ' 294 | 'found key2: True', 295 | ) 296 | _assert_exception_trace(excinfo.value, expected) 297 | 298 | 299 | def test_ensure_absent_conditional_not(): 300 | with pytest.raises(ValidationError) as excinfo: 301 | validate({'key': True, 'key2': True}, map_conditional_absent_not) 302 | expected = ( 303 | 'At foo(key=True)', 304 | 'Expected key2 to be absent when key is True, ' 305 | 'found key2: True', 306 | ) 307 | _assert_exception_trace(excinfo.value, expected) 308 | 309 | 310 | def test_ensure_absent_conditional_not_in(): 311 | with pytest.raises(ValidationError) as excinfo: 312 | validate({'key': 1, 'key2': True}, map_conditional_absent_not_in) 313 | expected = ( 314 | 'At foo(key=1)', 315 | 'Expected key2 to be absent when key is any of (1, 2), ' 316 | 'found key2: True', 317 | ) 318 | _assert_exception_trace(excinfo.value, expected) 319 | 320 | 321 | def test_ensure_absent_conditional_in(): 322 | with pytest.raises(ValidationError) as excinfo: 323 | validate({'key': 3, 'key2': True}, map_conditional_absent_in) 324 | expected = ( 325 | 'At foo(key=3)', 326 | 'Expected key2 to be absent when key is not any of (1, 2), ' 327 | 'found key2: True', 328 | ) 329 | _assert_exception_trace(excinfo.value, expected) 330 | 331 | 332 | def test_no_error_conditional_absent(): 333 | validate({}, map_conditional_absent) 334 | validate({}, map_conditional_absent_not) 335 | validate({'key2': True}, map_conditional_absent) 336 | validate({'key2': True}, map_conditional_absent_not) 337 | 338 | 339 | def test_apply_defaults_copies_object(): 340 | val = {} 341 | ret = apply_defaults(val, map_optional) 342 | assert ret is not val 343 | 344 | 345 | def test_apply_defaults_sets_default(): 346 | ret = apply_defaults({}, map_optional) 347 | assert ret == {'key': False} 348 | 349 | 350 | def test_apply_defaults_does_not_change_non_default(): 351 | ret = apply_defaults({'key': True}, map_optional) 352 | assert ret == {'key': True} 353 | 354 | 355 | def test_apply_defaults_does_nothing_on_non_optional(): 356 | ret = apply_defaults({}, map_required) 357 | assert ret == {} 358 | 359 | 360 | def test_apply_defaults_map_in_list(): 361 | ret = apply_defaults([{}], Array(map_optional)) 362 | assert ret == [{'key': False}] 363 | 364 | 365 | def test_remove_defaults_copies_object(): 366 | val = {'key': False} 367 | ret = remove_defaults(val, map_optional) 368 | assert ret is not val 369 | 370 | 371 | def test_remove_defaults_removes_defaults(): 372 | ret = remove_defaults({'key': False}, map_optional) 373 | assert ret == {} 374 | 375 | 376 | def test_remove_defaults_nothing_to_remove(): 377 | ret = remove_defaults({}, map_optional) 378 | assert ret == {} 379 | 380 | 381 | def test_remove_defaults_does_not_change_non_default(): 382 | ret = remove_defaults({'key': True}, map_optional) 383 | assert ret == {'key': True} 384 | 385 | 386 | def test_remove_defaults_map_in_list(): 387 | ret = remove_defaults([{'key': False}], Array(map_optional)) 388 | assert ret == [{}] 389 | 390 | 391 | def test_remove_defaults_does_nothing_on_non_optional(): 392 | ret = remove_defaults({'key': True}, map_required) 393 | assert ret == {'key': True} 394 | 395 | 396 | nested_schema_required = Map( 397 | 'Repository', 'repo', 398 | Required('repo', check_any), 399 | RequiredRecurse('hooks', Array(map_required)), 400 | ) 401 | nested_schema_optional = Map( 402 | 'Repository', 'repo', 403 | Required('repo', check_any), 404 | RequiredRecurse('hooks', Array(map_optional)), 405 | ) 406 | 407 | 408 | def test_validate_failure_nested(): 409 | with pytest.raises(ValidationError) as excinfo: 410 | validate({'repo': 1, 'hooks': [{}]}, nested_schema_required) 411 | expected = ( 412 | 'At Repository(repo=1)', 413 | 'At key: hooks', 414 | 'At foo(key=MISSING)', 415 | 'Missing required key: key', 416 | ) 417 | _assert_exception_trace(excinfo.value, expected) 418 | 419 | 420 | def test_apply_defaults_nested(): 421 | val = {'repo': 'repo1', 'hooks': [{}]} 422 | ret = apply_defaults(val, nested_schema_optional) 423 | assert ret == {'repo': 'repo1', 'hooks': [{'key': False}]} 424 | 425 | 426 | def test_remove_defaults_nested(): 427 | val = {'repo': 'repo1', 'hooks': [{'key': False}]} 428 | ret = remove_defaults(val, nested_schema_optional) 429 | assert ret == {'repo': 'repo1', 'hooks': [{}]} 430 | 431 | 432 | link = Map('Link', 'key', Required('key', check_bool)) 433 | optional_nested_schema = Map( 434 | 'Config', None, 435 | OptionalRecurse('links', Array(link), []), 436 | ) 437 | 438 | 439 | def test_validate_failure_optional_recurse(): 440 | with pytest.raises(ValidationError) as excinfo: 441 | validate({'links': [{}]}, optional_nested_schema) 442 | expected = ( 443 | 'At Config()', 444 | 'At key: links', 445 | 'At Link(key=MISSING)', 446 | 'Missing required key: key', 447 | ) 448 | _assert_exception_trace(excinfo.value, expected) 449 | 450 | 451 | def test_optional_recurse_ok_missing(): 452 | validate({}, optional_nested_schema) 453 | 454 | 455 | def test_apply_defaults_optional_recurse_missing(): 456 | ret = apply_defaults({}, optional_nested_schema) 457 | assert ret == {'links': []} 458 | 459 | 460 | def test_apply_defaults_optional_recurse_already_present(): 461 | ret = apply_defaults({'links': [{'key': True}]}, optional_nested_schema) 462 | assert ret == {'links': [{'key': True}]} 463 | 464 | 465 | def test_remove_defaults_optional_recurse_not_present(): 466 | assert remove_defaults({}, optional_nested_schema) == {} 467 | 468 | 469 | def test_remove_defaults_optional_recurse_present_at_default(): 470 | assert remove_defaults({'links': []}, optional_nested_schema) == {} 471 | 472 | 473 | def test_remove_defaults_optional_recurse_non_default(): 474 | ret = remove_defaults({'links': [{'key': True}]}, optional_nested_schema) 475 | assert ret == {'links': [{'key': True}]} 476 | 477 | 478 | builder_opts = Map('BuilderOpts', None, Optional('noop', check_bool, True)) 479 | optional_nested_optional_schema = Map( 480 | 'Config', None, 481 | OptionalRecurse('builder', builder_opts, {}), 482 | ) 483 | 484 | 485 | def test_optional_optional_apply_defaults(): 486 | ret = apply_defaults({}, optional_nested_optional_schema) 487 | assert ret == {'builder': {'noop': True}} 488 | 489 | 490 | def test_optional_optional_remove_defaults(): 491 | val = {'builder': {'noop': True}} 492 | ret = remove_defaults(val, optional_nested_optional_schema) 493 | assert ret == {} 494 | 495 | 496 | params1_schema = Map('Params1', None, Required('p1', check_bool)) 497 | params2_schema = Map('Params2', None, Required('p2', check_bool)) 498 | conditional_nested_schema = Map( 499 | 'Config', None, 500 | Required('type', check_any), 501 | ConditionalRecurse('params', params1_schema, 'type', 'type1'), 502 | ConditionalRecurse('params', params2_schema, 'type', 'type2'), 503 | ) 504 | 505 | 506 | @pytest.mark.parametrize( 507 | 'val', 508 | ( 509 | {'type': 'type3'}, # matches no condition 510 | {'type': 'type1', 'params': {'p1': True}}, 511 | {'type': 'type2', 'params': {'p2': True}}, 512 | ), 513 | ) 514 | def test_conditional_recurse_ok(val): 515 | validate(val, conditional_nested_schema) 516 | 517 | 518 | def test_conditional_recurse_error(): 519 | with pytest.raises(ValidationError) as excinfo: 520 | val = {'type': 'type1', 'params': {'p2': True}} 521 | validate(val, conditional_nested_schema) 522 | expected = ( 523 | 'At Config()', 524 | 'At key: params', 525 | 'At Params1()', 526 | 'Missing required key: p1', 527 | ) 528 | _assert_exception_trace(excinfo.value, expected) 529 | 530 | 531 | class Error(Exception): 532 | pass 533 | 534 | 535 | def test_load_from_filename_file_does_not_exist(): 536 | with pytest.raises(Error) as excinfo: 537 | load_from_filename('does_not_exist', map_required, json.loads, Error) 538 | assert excinfo.value.args[0].error_msg == 'does_not_exist is not a file' 539 | 540 | 541 | def test_load_from_filename_not_a_file(tmpdir): 542 | with tmpdir.as_cwd(): 543 | tmpdir.join('f').ensure_dir() 544 | with pytest.raises(Error) as excinfo: 545 | load_from_filename('f', map_required, json.loads, Error) 546 | assert excinfo.value.args[0].error_msg == 'f is not a file' 547 | 548 | 549 | def test_load_from_filename_unicode_error(tmp_path): 550 | f = tmp_path.joinpath('f') 551 | f.write_bytes(b'\x98\xae\xfe') 552 | 553 | with pytest.raises(Error) as excinfo: 554 | load_from_filename(f, map_required, json.loads, Error) 555 | expected = (f'File {f}', mock.ANY) 556 | _assert_exception_trace(excinfo.value.args[0], expected) 557 | 558 | 559 | def test_load_from_filename_fails_load_strategy(tmpdir): 560 | f = tmpdir.join('foo.notjson') 561 | f.write('totes not json') 562 | with pytest.raises(Error) as excinfo: 563 | load_from_filename(f.strpath, map_required, json.loads, Error) 564 | # ANY is json's error message 565 | expected = (f'File {f.strpath}', mock.ANY) 566 | _assert_exception_trace(excinfo.value.args[0], expected) 567 | 568 | 569 | def test_load_from_filename_validation_error(tmpdir): 570 | f = tmpdir.join('foo.json') 571 | f.write('{}') 572 | with pytest.raises(Error) as excinfo: 573 | load_from_filename(f.strpath, map_required, json.loads, Error) 574 | expected = ( 575 | f'File {f.strpath}', 576 | 'At foo(key=MISSING)', 577 | 'Missing required key: key', 578 | ) 579 | _assert_exception_trace(excinfo.value.args[0], expected) 580 | 581 | 582 | def test_load_from_filename_applies_defaults(tmpdir): 583 | f = tmpdir.join('foo.json') 584 | f.write('{}') 585 | ret = load_from_filename(f.strpath, map_optional, json.loads, Error) 586 | assert ret == {'key': False} 587 | 588 | 589 | def test_load_from_filename_custom_display_no_file(tmp_path): 590 | with pytest.raises(ValidationError) as excinfo: 591 | load_from_filename( 592 | tmp_path.joinpath('cfg.json'), 593 | map_required, 594 | json.loads, 595 | display_filename='cfg.json', 596 | ) 597 | _assert_exception_trace(excinfo.value.args[0], ('cfg.json is not a file',)) 598 | 599 | 600 | def test_load_from_filename_custom_display_error(tmp_path): 601 | f = tmp_path.joinpath('cfg.json') 602 | f.write_text('{}') 603 | with pytest.raises(ValidationError) as excinfo: 604 | load_from_filename( 605 | f, 606 | map_required, 607 | json.loads, 608 | display_filename='cfg.json', 609 | ) 610 | expected = ( 611 | 'File cfg.json', 612 | 'At foo(key=MISSING)', 613 | 'Missing required key: key', 614 | ) 615 | _assert_exception_trace(excinfo.value.args[0], expected) 616 | 617 | 618 | conditional_recurse = Map( 619 | 'Map', None, 620 | 621 | Required('t', check_bool), 622 | ConditionalRecurse( 623 | 'v', Map('Inner', 'k', Optional('k', check_bool, True)), 624 | 't', True, 625 | ), 626 | ConditionalRecurse( 627 | 'v', Map('Inner', 'k', Optional('k', check_bool, False)), 628 | 't', False, 629 | ), 630 | ) 631 | 632 | 633 | @pytest.mark.parametrize('tvalue', (True, False)) 634 | def test_conditional_recurse_apply_defaults(tvalue): 635 | val = {'t': tvalue, 'v': {}} 636 | ret = apply_defaults(val, conditional_recurse) 637 | assert ret == {'t': tvalue, 'v': {'k': tvalue}} 638 | 639 | val = {'t': tvalue, 'v': {'k': not tvalue}} 640 | ret = apply_defaults(val, conditional_recurse) 641 | assert ret == {'t': tvalue, 'v': {'k': not tvalue}} 642 | 643 | 644 | @pytest.mark.parametrize('tvalue', (True, False)) 645 | def test_conditional_recurse_remove_defaults(tvalue): 646 | val = {'t': tvalue, 'v': {'k': tvalue}} 647 | ret = remove_defaults(val, conditional_recurse) 648 | assert ret == {'t': tvalue, 'v': {}} 649 | 650 | val = {'t': tvalue, 'v': {'k': not tvalue}} 651 | ret = remove_defaults(val, conditional_recurse) 652 | assert ret == {'t': tvalue, 'v': {'k': not tvalue}} 653 | 654 | 655 | conditional_optional = Map( 656 | 'Map', None, 657 | 658 | Required('t', check_bool), 659 | ConditionalOptional('v', check_bool, True, 't', True), 660 | ConditionalOptional('v', check_bool, False, 't', False), 661 | ) 662 | 663 | 664 | @pytest.mark.parametrize('tvalue', (True, False)) 665 | def test_conditional_optional_check(tvalue): 666 | with pytest.raises(ValidationError) as excinfo: 667 | validate({'t': tvalue, 'v': 2}, conditional_optional) 668 | expected = ( 669 | 'At Map()', 670 | 'At key: v', 671 | 'Expected bool got int', 672 | ) 673 | _assert_exception_trace(excinfo.value, expected) 674 | 675 | validate({'t': tvalue, 'v': tvalue}, conditional_optional) 676 | 677 | 678 | @pytest.mark.parametrize('tvalue', (True, False)) 679 | def test_conditional_optional_apply_default(tvalue): 680 | ret = apply_defaults({'t': tvalue}, conditional_optional) 681 | assert ret == {'t': tvalue, 'v': tvalue} 682 | 683 | 684 | @pytest.mark.parametrize('tvalue', (True, False)) 685 | def test_conditional_optional_remove_default(tvalue): 686 | ret = remove_defaults({'t': tvalue, 'v': tvalue}, conditional_optional) 687 | assert ret == {'t': tvalue} 688 | ret = remove_defaults({'t': tvalue, 'v': not tvalue}, conditional_optional) 689 | assert ret == {'t': tvalue, 'v': not tvalue} 690 | 691 | 692 | no_additional_keys = Map( 693 | 'Map', None, 694 | Required(True, check_bool), 695 | NoAdditionalKeys((True,)), 696 | ) 697 | 698 | 699 | def test_no_additional_keys(): 700 | with pytest.raises(ValidationError) as excinfo: 701 | validate({True: True, False: False}, no_additional_keys) 702 | expected = ( 703 | 'At Map()', 704 | 'Additional keys found: False. Only these keys are allowed: True', 705 | ) 706 | _assert_exception_trace(excinfo.value, expected) 707 | 708 | validate({True: True}, no_additional_keys) 709 | 710 | 711 | @pytest.fixture 712 | def warn_additional_keys(): 713 | ret = mock.Mock() 714 | 715 | def callback(extra, keys, dct): 716 | return ret.record(extra, keys, dct) 717 | 718 | ret.schema = Map( 719 | 'Map', None, 720 | Required(True, check_bool), 721 | WarnAdditionalKeys((True,), callback), 722 | ) 723 | yield ret 724 | 725 | 726 | def test_warn_additional_keys_when_has_extra_keys(warn_additional_keys): 727 | validate({True: True, False: False}, warn_additional_keys.schema) 728 | assert warn_additional_keys.record.called 729 | 730 | 731 | def test_warn_additional_keys_when_no_extra_keys(warn_additional_keys): 732 | validate({True: True}, warn_additional_keys.schema) 733 | assert not warn_additional_keys.record.called 734 | 735 | 736 | key_value_map_schema = KeyValueMap( 737 | 'Container', 738 | check_string, 739 | Map( 740 | 'Object', 'name', 741 | Required('name', check_string), 742 | Optional('setting', check_bool, False), 743 | ), 744 | ) 745 | key_value_map_ints_schema = KeyValueMap( 746 | 'Container', 747 | check_int, 748 | Array(Map('Object', 'nane', Required('name', check_string))), 749 | ) 750 | 751 | 752 | def test_key_value_map_schema_ok(): 753 | validate( 754 | {'hello': {'name': 'hello'}, 'world': {'name': 'world'}}, 755 | key_value_map_schema, 756 | ) 757 | validate( 758 | {1: [{'name': 'hello'}], 2: [{'name': 'world'}]}, 759 | key_value_map_ints_schema, 760 | ) 761 | 762 | 763 | def test_key_value_map_apply_defaults(): 764 | orig = {'hello': {'name': 'hello'}} 765 | ret = apply_defaults(orig, key_value_map_schema) 766 | assert orig == {'hello': {'name': 'hello'}} 767 | assert ret == {'hello': {'name': 'hello', 'setting': False}} 768 | 769 | 770 | def test_key_value_map_remove_defaults(): 771 | orig = {'hello': {'name': 'hello', 'setting': False}} 772 | ret = remove_defaults(orig, key_value_map_schema) 773 | assert orig == {'hello': {'name': 'hello', 'setting': False}} 774 | assert ret == {'hello': {'name': 'hello'}} 775 | 776 | 777 | def test_key_value_map_not_a_map(): 778 | with pytest.raises(ValidationError) as excinfo: 779 | validate([], key_value_map_schema) 780 | expected = ( 781 | 'Expected a Container map but got a list', 782 | ) 783 | _assert_exception_trace(excinfo.value, expected) 784 | 785 | 786 | def test_key_value_map_wrong_key_type(): 787 | with pytest.raises(ValidationError) as excinfo: 788 | val = {1: {'name': 'hello'}} 789 | validate(val, key_value_map_schema) 790 | expected = ( 791 | 'At Container()', 792 | 'For key: 1', 793 | 'Expected string got int', 794 | ) 795 | _assert_exception_trace(excinfo.value, expected) 796 | 797 | 798 | def test_key_value_map_error_in_child_schema(): 799 | with pytest.raises(ValidationError) as excinfo: 800 | val = {'hello': {'name': 1}} 801 | validate(val, key_value_map_schema) 802 | expected = ( 803 | 'At Container()', 804 | 'At key: hello', 805 | 'At Object(name=1)', 806 | 'At key: name', 807 | 'Expected string got int', 808 | ) 809 | _assert_exception_trace(excinfo.value, expected) 810 | --------------------------------------------------------------------------------