├── .github └── workflows │ └── main.yml ├── .gitignore ├── .pre-commit-config.yaml ├── LICENSE ├── README.md ├── cfgv.py ├── requirements-dev.txt ├── setup.cfg ├── setup.py ├── tests ├── __init__.py └── cfgv_test.py └── tox.ini /.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: '["py39", "py310", "py311", "py312"]' 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.egg-info 2 | *.pyc 3 | /.coverage 4 | /.tox 5 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v5.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: v2.8.0 14 | hooks: 15 | - id: setup-cfg-fmt 16 | - repo: https://github.com/asottile/reorder-python-imports 17 | rev: v3.15.0 18 | hooks: 19 | - id: reorder-python-imports 20 | args: [--py39-plus, --add-import, 'from __future__ import annotations'] 21 | - repo: https://github.com/asottile/add-trailing-comma 22 | rev: v3.2.0 23 | hooks: 24 | - id: add-trailing-comma 25 | - repo: https://github.com/asottile/pyupgrade 26 | rev: v3.20.0 27 | hooks: 28 | - id: pyupgrade 29 | args: [--py39-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.2.0 36 | hooks: 37 | - id: flake8 38 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | ## Validator objects 120 | 121 | Validator objects are used to validate key-value-pairs of a `Map`. 122 | 123 | ### writing your own validator 124 | 125 | If the built-in validators below don't quite satisfy your usecase, you can 126 | always write your own. Validators use the following interface: 127 | 128 | ```python 129 | class Validator(object): 130 | def check(self, dct): 131 | """check that your specific key has the appropriate value in `dct`""" 132 | 133 | def apply_default(self, dct): 134 | """modify `dct` and set the default value if it is missing""" 135 | 136 | def remove_default(self, dct): 137 | """modify `dct` and remove the default value if it is present""" 138 | ``` 139 | 140 | It may make sense to _borrow_ functions from the built in validators. They 141 | additionally use the following interface(s): 142 | 143 | - `self.key`: the key to check 144 | - `self.check_fn`: the [check function](#check-functions) 145 | - `self.default`: a default value to set. 146 | 147 | ### `Required(key, check_fn)` 148 | 149 | Ensure that a key is present in a `Map` and adheres to the 150 | [check function](#check-functions). 151 | 152 | ### `RequiredRecurse(key, schema)` 153 | 154 | Similar to `Required`, but uses a [schema](#making-a-schema). 155 | 156 | ### `Optional(key, check_fn, default)` 157 | 158 | If a key is present, check that it adheres to the 159 | [check function](#check-functions). 160 | 161 | - `apply_defaults` will set the `default` if it is not present. 162 | - `remove_defaults` will remove the value if it is equal to `default`. 163 | 164 | ### `OptionalRecurse(key, schema, default)` 165 | 166 | Similar to `Optional` but uses a [schema](#making-a-schema). 167 | 168 | - `apply_defaults` will set the `default` if it is not present and then 169 | validate it with the schema. 170 | - `remove_defaults` will remove defaults using the schema, and then remove the 171 | value it if it is equal to `default`. 172 | 173 | ### `OptionalNoDefault(key, check_fn)` 174 | 175 | Like `Optional`, but does not `apply_defaults` or `remove_defaults`. 176 | 177 | ### `Conditional(key, check_fn, condition_key, condition_value, ensure_absent=False)` 178 | 179 | - If `condition_key` is equal to the `condition_value`, the specific `key` 180 | will be checked using the [check function](#check-functions). 181 | - If `ensure_absent` is `True` and the condition check fails, the `key` will 182 | be checked for absense. 183 | 184 | Note that the `condition_value` is checked for equality, so any object 185 | implementing `__eq__` may be used. A few are provided out of the box 186 | for this purpose, see [equality helpers](#equality-helpers). 187 | 188 | ### `ConditionalOptional(key, check_fn, default, condition_key, condition_value, ensure_absent=False)` 189 | 190 | Similar to ``Conditional`` and ``Optional``. 191 | 192 | ### `ConditionalRecurse(key, schema, condition_key, condition_value, ensure_absent=True)` 193 | 194 | Similar to `Conditional`, but uses a [schema](#making-a-schema). 195 | 196 | ### `NoAdditionalKeys(keys)` 197 | 198 | Use in a mapping to ensure that only the `keys` specified are present. 199 | 200 | ## Equality helpers 201 | 202 | Equality helpers at the very least implement `__eq__` for their behaviour. 203 | 204 | They may also implement `def describe_opposite(self):` for use in the 205 | `ensure_absent=True` error message (otherwise, the `__repr__` will be used). 206 | 207 | ### `Not(val)` 208 | 209 | Returns `True` if the value is not equal to `val`. 210 | 211 | ### `In(*values)` 212 | 213 | Returns `True` if the value is contained in `values`. 214 | 215 | ### `NotIn(*values)` 216 | 217 | Returns `True` if the value is not contained in `values`. 218 | 219 | ## Check functions 220 | 221 | A number of check functions are provided out of the box. 222 | 223 | A check function takes a single parameter, the `value`, and either raises a 224 | `ValidationError` or returns nothing. 225 | 226 | ### `check_any(_)` 227 | 228 | A noop check function. 229 | 230 | ### `check_type(tp, typename=None)` 231 | 232 | Returns a check function to check for a specific type. Setting `typename` 233 | will replace the type's name in the error message. 234 | 235 | For example: 236 | 237 | ```python 238 | Required('key', check_type(int)) 239 | # 'Expected bytes' in both python2 and python3. 240 | Required('key', check_type(bytes, typename='bytes')) 241 | ``` 242 | 243 | Several type checking functions are provided out of the box: 244 | 245 | - `check_bool` 246 | - `check_bytes` 247 | - `check_int` 248 | - `check_string` 249 | - `check_text` 250 | 251 | ### `check_one_of(possible)` 252 | 253 | Returns a function that checks that the value is contained in `possible`. 254 | 255 | For example: 256 | 257 | ```python 258 | Required('language', check_one_of(('javascript', 'python', 'ruby'))) 259 | ``` 260 | 261 | ### `check_regex(v)` 262 | 263 | Ensures that `v` is a valid python regular expression. 264 | 265 | ### `check_array(inner_check)` 266 | 267 | Returns a function that checks that a value is a sequence and that each 268 | value in that sequence adheres to the `inner_check`. 269 | 270 | For example: 271 | 272 | ```python 273 | Required('args', check_array(check_string)) 274 | ``` 275 | 276 | ### `check_and(*fns)` 277 | 278 | Returns a function that performs multiple checks on a value. 279 | 280 | For example: 281 | 282 | ```python 283 | Required('language', check_and(check_string, my_check_language)) 284 | ``` 285 | -------------------------------------------------------------------------------- /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 Array(collections.namedtuple('Array', ('of', 'allow_empty'))): 262 | __slots__ = () 263 | 264 | def __new__(cls, of, allow_empty=True): 265 | return super().__new__(cls, of=of, allow_empty=allow_empty) 266 | 267 | def check(self, v): 268 | check_array(check_any)(v) 269 | if not self.allow_empty and not v: 270 | raise ValidationError( 271 | f"Expected at least 1 '{self.of.object_name}'", 272 | ) 273 | for val in v: 274 | validate(val, self.of) 275 | 276 | def apply_defaults(self, v): 277 | return [apply_defaults(val, self.of) for val in v] 278 | 279 | def remove_defaults(self, v): 280 | return [remove_defaults(val, self.of) for val in v] 281 | 282 | 283 | class Not(collections.namedtuple('Not', ('val',))): 284 | __slots__ = () 285 | 286 | def describe_opposite(self): 287 | return f'is {self.val!r}' 288 | 289 | def __eq__(self, other): 290 | return other is not MISSING and other != self.val 291 | 292 | 293 | class NotIn(collections.namedtuple('NotIn', ('values',))): 294 | __slots__ = () 295 | 296 | def __new__(cls, *values): 297 | return super().__new__(cls, values=values) 298 | 299 | def describe_opposite(self): 300 | return f'is any of {self.values!r}' 301 | 302 | def __eq__(self, other): 303 | return other is not MISSING and other not in self.values 304 | 305 | 306 | class In(collections.namedtuple('In', ('values',))): 307 | __slots__ = () 308 | 309 | def __new__(cls, *values): 310 | return super().__new__(cls, values=values) 311 | 312 | def describe_opposite(self): 313 | return f'is not any of {self.values!r}' 314 | 315 | def __eq__(self, other): 316 | return other is not MISSING and other in self.values 317 | 318 | 319 | def check_any(_): 320 | pass 321 | 322 | 323 | def check_type(tp, typename=None): 324 | def check_type_fn(v): 325 | if not isinstance(v, tp): 326 | typename_s = typename or tp.__name__ 327 | raise ValidationError( 328 | f'Expected {typename_s} got {type(v).__name__}', 329 | ) 330 | return check_type_fn 331 | 332 | 333 | check_bool = check_type(bool) 334 | check_bytes = check_type(bytes) 335 | check_int = check_type(int) 336 | check_string = check_type(str, typename='string') 337 | check_text = check_type(str, typename='text') 338 | 339 | 340 | def check_one_of(possible): 341 | def check_one_of_fn(v): 342 | if v not in possible: 343 | possible_s = ', '.join(str(x) for x in sorted(possible)) 344 | raise ValidationError( 345 | f'Expected one of {possible_s} but got: {v!r}', 346 | ) 347 | return check_one_of_fn 348 | 349 | 350 | def check_regex(v): 351 | try: 352 | re.compile(v) 353 | except re.error: 354 | raise ValidationError(f'{v!r} is not a valid python regex') 355 | 356 | 357 | def check_array(inner_check): 358 | def check_array_fn(v): 359 | if not isinstance(v, (list, tuple)): 360 | raise ValidationError( 361 | f'Expected array but got {type(v).__name__!r}', 362 | ) 363 | 364 | for i, val in enumerate(v): 365 | with validate_context(f'At index {i}'): 366 | inner_check(val) 367 | return check_array_fn 368 | 369 | 370 | def check_and(*fns): 371 | def check(v): 372 | for fn in fns: 373 | fn(v) 374 | return check 375 | 376 | 377 | def validate(v, schema): 378 | schema.check(v) 379 | return v 380 | 381 | 382 | def apply_defaults(v, schema): 383 | return schema.apply_defaults(v) 384 | 385 | 386 | def remove_defaults(v, schema): 387 | return schema.remove_defaults(v) 388 | 389 | 390 | def load_from_filename( 391 | filename, 392 | schema, 393 | load_strategy, 394 | exc_tp=ValidationError, 395 | *, 396 | display_filename=None, 397 | ): 398 | display_filename = display_filename or filename 399 | with reraise_as(exc_tp): 400 | if not os.path.isfile(filename): 401 | raise ValidationError(f'{display_filename} is not a file') 402 | 403 | with validate_context(f'File {display_filename}'): 404 | try: 405 | with open(filename, encoding='utf-8') as f: 406 | contents = f.read() 407 | except UnicodeDecodeError as e: 408 | raise ValidationError(str(e)) 409 | 410 | try: 411 | data = load_strategy(contents) 412 | except Exception as e: 413 | raise ValidationError(str(e)) 414 | 415 | validate(data, schema) 416 | return apply_defaults(data, schema) 417 | -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | covdefaults 2 | coverage 3 | pytest 4 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | name = cfgv 3 | version = 3.4.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.9 21 | 22 | [bdist_wheel] 23 | universal = True 24 | 25 | [coverage:run] 26 | plugins = covdefaults 27 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from setuptools import setup 4 | setup() 5 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/asottile/cfgv/90c14ab5056602d2cc198c8fc681b147ea5aad05/tests/__init__.py -------------------------------------------------------------------------------- /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_one_of 15 | from cfgv import check_regex 16 | from cfgv import check_type 17 | from cfgv import Conditional 18 | from cfgv import ConditionalOptional 19 | from cfgv import ConditionalRecurse 20 | from cfgv import In 21 | from cfgv import load_from_filename 22 | from cfgv import Map 23 | from cfgv import MISSING 24 | from cfgv import NoAdditionalKeys 25 | from cfgv import Not 26 | from cfgv import NotIn 27 | from cfgv import Optional 28 | from cfgv import OptionalNoDefault 29 | from cfgv import OptionalRecurse 30 | from cfgv import remove_defaults 31 | from cfgv import Required 32 | from cfgv import RequiredRecurse 33 | from cfgv import validate 34 | from cfgv import ValidationError 35 | from cfgv import WarnAdditionalKeys 36 | 37 | 38 | def _assert_exception_trace(e, trace): 39 | parts = [] 40 | while e.ctx is not None: 41 | parts.append(e.ctx) 42 | e = e.error_msg 43 | parts.append(e.error_msg) 44 | assert tuple(parts) == trace 45 | 46 | 47 | def test_ValidationError_simple_str(): 48 | assert str(ValidationError('error msg')) == ( 49 | '\n' 50 | '=====> error msg' 51 | ) 52 | 53 | 54 | def test_ValidationError_nested(): 55 | error = ValidationError( 56 | ValidationError( 57 | ValidationError('error msg'), 58 | ctx='At line 1', 59 | ), 60 | ctx='In file foo', 61 | ) 62 | assert str(error) == ( 63 | '\n' 64 | '==> In file foo\n' 65 | '==> At line 1\n' 66 | '=====> error msg' 67 | ) 68 | 69 | 70 | def test_check_one_of(): 71 | with pytest.raises(ValidationError) as excinfo: 72 | check_one_of((1, 2))(3) 73 | assert excinfo.value.error_msg == 'Expected one of 1, 2 but got: 3' 74 | 75 | 76 | def test_check_one_of_ok(): 77 | check_one_of((1, 2))(2) 78 | 79 | 80 | def test_check_regex(): 81 | with pytest.raises(ValidationError) as excinfo: 82 | check_regex('(') 83 | assert excinfo.value.error_msg == "'(' is not a valid python regex" 84 | 85 | 86 | def test_check_regex_ok(): 87 | check_regex('^$') 88 | 89 | 90 | def test_check_array_failed_inner_check(): 91 | check = check_array(check_bool) 92 | with pytest.raises(ValidationError) as excinfo: 93 | check([True, False, 5]) 94 | _assert_exception_trace( 95 | excinfo.value, ('At index 2', 'Expected bool got int'), 96 | ) 97 | 98 | 99 | def test_check_array_ok(): 100 | check_array(check_bool)([True, False]) 101 | 102 | 103 | def test_check_and(): 104 | check = check_and(check_type(str), check_regex) 105 | with pytest.raises(ValidationError) as excinfo: 106 | check(True) 107 | assert excinfo.value.error_msg == 'Expected str got bool' 108 | with pytest.raises(ValidationError) as excinfo: 109 | check('(') 110 | assert excinfo.value.error_msg == "'(' is not a valid python regex" 111 | 112 | 113 | def test_check_and_ok(): 114 | check = check_and(check_type(str), check_regex) 115 | check('^$') 116 | 117 | 118 | @pytest.mark.parametrize( 119 | ('val', 'expected'), 120 | (('bar', True), ('foo', False), (MISSING, False)), 121 | ) 122 | def test_not(val, expected): 123 | compared = Not('foo') 124 | assert (val == compared) is expected 125 | assert (compared == val) is expected 126 | 127 | 128 | @pytest.mark.parametrize( 129 | ('values', 'expected'), 130 | (('bar', True), ('foo', False), (MISSING, False)), 131 | ) 132 | def test_not_in(values, expected): 133 | compared = NotIn('baz', 'foo') 134 | assert (values == compared) is expected 135 | assert (compared == values) is expected 136 | 137 | 138 | @pytest.mark.parametrize( 139 | ('values', 'expected'), 140 | (('bar', False), ('foo', True), ('baz', True), (MISSING, False)), 141 | ) 142 | def test_in(values, expected): 143 | compared = In('baz', 'foo') 144 | assert (values == compared) is expected 145 | assert (compared == values) is expected 146 | 147 | 148 | trivial_array_schema = Array(Map('foo', 'id')) 149 | trivial_array_schema_nonempty = Array(Map('foo', 'id'), allow_empty=False) 150 | 151 | 152 | def test_validate_top_level_array_not_an_array(): 153 | with pytest.raises(ValidationError) as excinfo: 154 | validate({}, trivial_array_schema) 155 | assert excinfo.value.error_msg == "Expected array but got 'dict'" 156 | 157 | 158 | def test_validate_top_level_array_no_objects(): 159 | with pytest.raises(ValidationError) as excinfo: 160 | validate([], trivial_array_schema_nonempty) 161 | assert excinfo.value.error_msg == "Expected at least 1 'foo'" 162 | 163 | 164 | def test_trivial_array_schema_ok_empty(): 165 | validate([], trivial_array_schema) 166 | 167 | 168 | @pytest.mark.parametrize('v', (({},), [{}])) 169 | def test_ok_both_types(v): 170 | validate(v, trivial_array_schema) 171 | 172 | 173 | map_required = Map('foo', 'key', Required('key', check_bool)) 174 | map_optional = Map('foo', 'key', Optional('key', check_bool, False)) 175 | map_no_default = Map('foo', 'key', OptionalNoDefault('key', check_bool)) 176 | map_no_id_key = Map('foo', None, Required('key', check_bool)) 177 | 178 | 179 | def test_map_wrong_type(): 180 | with pytest.raises(ValidationError) as excinfo: 181 | validate([], map_required) 182 | assert excinfo.value.error_msg == 'Expected a foo map but got a list' 183 | 184 | 185 | def test_required_missing_key(): 186 | with pytest.raises(ValidationError) as excinfo: 187 | validate({}, map_required) 188 | expected = ('At foo(key=MISSING)', 'Missing required key: key') 189 | _assert_exception_trace(excinfo.value, expected) 190 | 191 | 192 | @pytest.mark.parametrize( 193 | 'schema', (map_required, map_optional, map_no_default), 194 | ) 195 | def test_map_value_wrong_type(schema): 196 | with pytest.raises(ValidationError) as excinfo: 197 | validate({'key': 5}, schema) 198 | expected = ('At foo(key=5)', 'At key: key', 'Expected bool got int') 199 | _assert_exception_trace(excinfo.value, expected) 200 | 201 | 202 | @pytest.mark.parametrize( 203 | 'schema', (map_required, map_optional, map_no_default), 204 | ) 205 | def test_map_value_correct_type(schema): 206 | validate({'key': True}, schema) 207 | 208 | 209 | @pytest.mark.parametrize('schema', (map_optional, map_no_default)) 210 | def test_optional_key_missing(schema): 211 | validate({}, schema) 212 | 213 | 214 | def test_error_message_no_id_key(): 215 | with pytest.raises(ValidationError) as excinfo: 216 | validate({'key': 5}, map_no_id_key) 217 | expected = ('At foo()', 'At key: key', 'Expected bool got int') 218 | _assert_exception_trace(excinfo.value, expected) 219 | 220 | 221 | map_conditional = Map( 222 | 'foo', 'key', 223 | Conditional( 224 | 'key2', check_bool, condition_key='key', condition_value=True, 225 | ), 226 | ) 227 | map_conditional_not = Map( 228 | 'foo', 'key', 229 | Conditional( 230 | 'key2', check_bool, condition_key='key', condition_value=Not(False), 231 | ), 232 | ) 233 | map_conditional_absent = Map( 234 | 'foo', 'key', 235 | Conditional( 236 | 'key2', check_bool, 237 | condition_key='key', condition_value=True, ensure_absent=True, 238 | ), 239 | ) 240 | map_conditional_absent_not = Map( 241 | 'foo', 'key', 242 | Conditional( 243 | 'key2', check_bool, 244 | condition_key='key', condition_value=Not(True), ensure_absent=True, 245 | ), 246 | ) 247 | map_conditional_absent_not_in = Map( 248 | 'foo', 'key', 249 | Conditional( 250 | 'key2', check_bool, 251 | condition_key='key', condition_value=NotIn(1, 2), ensure_absent=True, 252 | ), 253 | ) 254 | map_conditional_absent_in = Map( 255 | 'foo', 'key', 256 | Conditional( 257 | 'key2', check_bool, 258 | condition_key='key', condition_value=In(1, 2), ensure_absent=True, 259 | ), 260 | ) 261 | 262 | 263 | @pytest.mark.parametrize('schema', (map_conditional, map_conditional_not)) 264 | @pytest.mark.parametrize( 265 | 'v', 266 | ( 267 | # Conditional check passes, key2 is checked and passes 268 | {'key': True, 'key2': True}, 269 | # Conditional check fails, key2 is not checked 270 | {'key': False, 'key2': 'ohai'}, 271 | ), 272 | ) 273 | def test_ok_conditional_schemas(v, schema): 274 | validate(v, schema) 275 | 276 | 277 | @pytest.mark.parametrize('schema', (map_conditional, map_conditional_not)) 278 | def test_not_ok_conditional_schemas(schema): 279 | with pytest.raises(ValidationError) as excinfo: 280 | validate({'key': True, 'key2': 5}, schema) 281 | expected = ('At foo(key=True)', 'At key: key2', 'Expected bool got int') 282 | _assert_exception_trace(excinfo.value, expected) 283 | 284 | 285 | def test_ensure_absent_conditional(): 286 | with pytest.raises(ValidationError) as excinfo: 287 | validate({'key': False, 'key2': True}, map_conditional_absent) 288 | expected = ( 289 | 'At foo(key=False)', 290 | 'Expected key2 to be absent when key is not True, ' 291 | 'found key2: True', 292 | ) 293 | _assert_exception_trace(excinfo.value, expected) 294 | 295 | 296 | def test_ensure_absent_conditional_not(): 297 | with pytest.raises(ValidationError) as excinfo: 298 | validate({'key': True, 'key2': True}, map_conditional_absent_not) 299 | expected = ( 300 | 'At foo(key=True)', 301 | 'Expected key2 to be absent when key is True, ' 302 | 'found key2: True', 303 | ) 304 | _assert_exception_trace(excinfo.value, expected) 305 | 306 | 307 | def test_ensure_absent_conditional_not_in(): 308 | with pytest.raises(ValidationError) as excinfo: 309 | validate({'key': 1, 'key2': True}, map_conditional_absent_not_in) 310 | expected = ( 311 | 'At foo(key=1)', 312 | 'Expected key2 to be absent when key is any of (1, 2), ' 313 | 'found key2: True', 314 | ) 315 | _assert_exception_trace(excinfo.value, expected) 316 | 317 | 318 | def test_ensure_absent_conditional_in(): 319 | with pytest.raises(ValidationError) as excinfo: 320 | validate({'key': 3, 'key2': True}, map_conditional_absent_in) 321 | expected = ( 322 | 'At foo(key=3)', 323 | 'Expected key2 to be absent when key is not any of (1, 2), ' 324 | 'found key2: True', 325 | ) 326 | _assert_exception_trace(excinfo.value, expected) 327 | 328 | 329 | def test_no_error_conditional_absent(): 330 | validate({}, map_conditional_absent) 331 | validate({}, map_conditional_absent_not) 332 | validate({'key2': True}, map_conditional_absent) 333 | validate({'key2': True}, map_conditional_absent_not) 334 | 335 | 336 | def test_apply_defaults_copies_object(): 337 | val = {} 338 | ret = apply_defaults(val, map_optional) 339 | assert ret is not val 340 | 341 | 342 | def test_apply_defaults_sets_default(): 343 | ret = apply_defaults({}, map_optional) 344 | assert ret == {'key': False} 345 | 346 | 347 | def test_apply_defaults_does_not_change_non_default(): 348 | ret = apply_defaults({'key': True}, map_optional) 349 | assert ret == {'key': True} 350 | 351 | 352 | def test_apply_defaults_does_nothing_on_non_optional(): 353 | ret = apply_defaults({}, map_required) 354 | assert ret == {} 355 | 356 | 357 | def test_apply_defaults_map_in_list(): 358 | ret = apply_defaults([{}], Array(map_optional)) 359 | assert ret == [{'key': False}] 360 | 361 | 362 | def test_remove_defaults_copies_object(): 363 | val = {'key': False} 364 | ret = remove_defaults(val, map_optional) 365 | assert ret is not val 366 | 367 | 368 | def test_remove_defaults_removes_defaults(): 369 | ret = remove_defaults({'key': False}, map_optional) 370 | assert ret == {} 371 | 372 | 373 | def test_remove_defaults_nothing_to_remove(): 374 | ret = remove_defaults({}, map_optional) 375 | assert ret == {} 376 | 377 | 378 | def test_remove_defaults_does_not_change_non_default(): 379 | ret = remove_defaults({'key': True}, map_optional) 380 | assert ret == {'key': True} 381 | 382 | 383 | def test_remove_defaults_map_in_list(): 384 | ret = remove_defaults([{'key': False}], Array(map_optional)) 385 | assert ret == [{}] 386 | 387 | 388 | def test_remove_defaults_does_nothing_on_non_optional(): 389 | ret = remove_defaults({'key': True}, map_required) 390 | assert ret == {'key': True} 391 | 392 | 393 | nested_schema_required = Map( 394 | 'Repository', 'repo', 395 | Required('repo', check_any), 396 | RequiredRecurse('hooks', Array(map_required)), 397 | ) 398 | nested_schema_optional = Map( 399 | 'Repository', 'repo', 400 | Required('repo', check_any), 401 | RequiredRecurse('hooks', Array(map_optional)), 402 | ) 403 | 404 | 405 | def test_validate_failure_nested(): 406 | with pytest.raises(ValidationError) as excinfo: 407 | validate({'repo': 1, 'hooks': [{}]}, nested_schema_required) 408 | expected = ( 409 | 'At Repository(repo=1)', 410 | 'At key: hooks', 411 | 'At foo(key=MISSING)', 412 | 'Missing required key: key', 413 | ) 414 | _assert_exception_trace(excinfo.value, expected) 415 | 416 | 417 | def test_apply_defaults_nested(): 418 | val = {'repo': 'repo1', 'hooks': [{}]} 419 | ret = apply_defaults(val, nested_schema_optional) 420 | assert ret == {'repo': 'repo1', 'hooks': [{'key': False}]} 421 | 422 | 423 | def test_remove_defaults_nested(): 424 | val = {'repo': 'repo1', 'hooks': [{'key': False}]} 425 | ret = remove_defaults(val, nested_schema_optional) 426 | assert ret == {'repo': 'repo1', 'hooks': [{}]} 427 | 428 | 429 | link = Map('Link', 'key', Required('key', check_bool)) 430 | optional_nested_schema = Map( 431 | 'Config', None, 432 | OptionalRecurse('links', Array(link), []), 433 | ) 434 | 435 | 436 | def test_validate_failure_optional_recurse(): 437 | with pytest.raises(ValidationError) as excinfo: 438 | validate({'links': [{}]}, optional_nested_schema) 439 | expected = ( 440 | 'At Config()', 441 | 'At key: links', 442 | 'At Link(key=MISSING)', 443 | 'Missing required key: key', 444 | ) 445 | _assert_exception_trace(excinfo.value, expected) 446 | 447 | 448 | def test_optional_recurse_ok_missing(): 449 | validate({}, optional_nested_schema) 450 | 451 | 452 | def test_apply_defaults_optional_recurse_missing(): 453 | ret = apply_defaults({}, optional_nested_schema) 454 | assert ret == {'links': []} 455 | 456 | 457 | def test_apply_defaults_optional_recurse_already_present(): 458 | ret = apply_defaults({'links': [{'key': True}]}, optional_nested_schema) 459 | assert ret == {'links': [{'key': True}]} 460 | 461 | 462 | def test_remove_defaults_optional_recurse_not_present(): 463 | assert remove_defaults({}, optional_nested_schema) == {} 464 | 465 | 466 | def test_remove_defaults_optional_recurse_present_at_default(): 467 | assert remove_defaults({'links': []}, optional_nested_schema) == {} 468 | 469 | 470 | def test_remove_defaults_optional_recurse_non_default(): 471 | ret = remove_defaults({'links': [{'key': True}]}, optional_nested_schema) 472 | assert ret == {'links': [{'key': True}]} 473 | 474 | 475 | builder_opts = Map('BuilderOpts', None, Optional('noop', check_bool, True)) 476 | optional_nested_optional_schema = Map( 477 | 'Config', None, 478 | OptionalRecurse('builder', builder_opts, {}), 479 | ) 480 | 481 | 482 | def test_optional_optional_apply_defaults(): 483 | ret = apply_defaults({}, optional_nested_optional_schema) 484 | assert ret == {'builder': {'noop': True}} 485 | 486 | 487 | def test_optional_optional_remove_defaults(): 488 | val = {'builder': {'noop': True}} 489 | ret = remove_defaults(val, optional_nested_optional_schema) 490 | assert ret == {} 491 | 492 | 493 | params1_schema = Map('Params1', None, Required('p1', check_bool)) 494 | params2_schema = Map('Params2', None, Required('p2', check_bool)) 495 | conditional_nested_schema = Map( 496 | 'Config', None, 497 | Required('type', check_any), 498 | ConditionalRecurse('params', params1_schema, 'type', 'type1'), 499 | ConditionalRecurse('params', params2_schema, 'type', 'type2'), 500 | ) 501 | 502 | 503 | @pytest.mark.parametrize( 504 | 'val', 505 | ( 506 | {'type': 'type3'}, # matches no condition 507 | {'type': 'type1', 'params': {'p1': True}}, 508 | {'type': 'type2', 'params': {'p2': True}}, 509 | ), 510 | ) 511 | def test_conditional_recurse_ok(val): 512 | validate(val, conditional_nested_schema) 513 | 514 | 515 | def test_conditional_recurse_error(): 516 | with pytest.raises(ValidationError) as excinfo: 517 | val = {'type': 'type1', 'params': {'p2': True}} 518 | validate(val, conditional_nested_schema) 519 | expected = ( 520 | 'At Config()', 521 | 'At key: params', 522 | 'At Params1()', 523 | 'Missing required key: p1', 524 | ) 525 | _assert_exception_trace(excinfo.value, expected) 526 | 527 | 528 | class Error(Exception): 529 | pass 530 | 531 | 532 | def test_load_from_filename_file_does_not_exist(): 533 | with pytest.raises(Error) as excinfo: 534 | load_from_filename('does_not_exist', map_required, json.loads, Error) 535 | assert excinfo.value.args[0].error_msg == 'does_not_exist is not a file' 536 | 537 | 538 | def test_load_from_filename_not_a_file(tmpdir): 539 | with tmpdir.as_cwd(): 540 | tmpdir.join('f').ensure_dir() 541 | with pytest.raises(Error) as excinfo: 542 | load_from_filename('f', map_required, json.loads, Error) 543 | assert excinfo.value.args[0].error_msg == 'f is not a file' 544 | 545 | 546 | def test_load_from_filename_unicode_error(tmp_path): 547 | f = tmp_path.joinpath('f') 548 | f.write_bytes(b'\x98\xae\xfe') 549 | 550 | with pytest.raises(Error) as excinfo: 551 | load_from_filename(f, map_required, json.loads, Error) 552 | expected = (f'File {f}', mock.ANY) 553 | _assert_exception_trace(excinfo.value.args[0], expected) 554 | 555 | 556 | def test_load_from_filename_fails_load_strategy(tmpdir): 557 | f = tmpdir.join('foo.notjson') 558 | f.write('totes not json') 559 | with pytest.raises(Error) as excinfo: 560 | load_from_filename(f.strpath, map_required, json.loads, Error) 561 | # ANY is json's error message 562 | expected = (f'File {f.strpath}', mock.ANY) 563 | _assert_exception_trace(excinfo.value.args[0], expected) 564 | 565 | 566 | def test_load_from_filename_validation_error(tmpdir): 567 | f = tmpdir.join('foo.json') 568 | f.write('{}') 569 | with pytest.raises(Error) as excinfo: 570 | load_from_filename(f.strpath, map_required, json.loads, Error) 571 | expected = ( 572 | f'File {f.strpath}', 573 | 'At foo(key=MISSING)', 574 | 'Missing required key: key', 575 | ) 576 | _assert_exception_trace(excinfo.value.args[0], expected) 577 | 578 | 579 | def test_load_from_filename_applies_defaults(tmpdir): 580 | f = tmpdir.join('foo.json') 581 | f.write('{}') 582 | ret = load_from_filename(f.strpath, map_optional, json.loads, Error) 583 | assert ret == {'key': False} 584 | 585 | 586 | def test_load_from_filename_custom_display_no_file(tmp_path): 587 | with pytest.raises(ValidationError) as excinfo: 588 | load_from_filename( 589 | tmp_path.joinpath('cfg.json'), 590 | map_required, 591 | json.loads, 592 | display_filename='cfg.json', 593 | ) 594 | _assert_exception_trace(excinfo.value.args[0], ('cfg.json is not a file',)) 595 | 596 | 597 | def test_load_from_filename_custom_display_error(tmp_path): 598 | f = tmp_path.joinpath('cfg.json') 599 | f.write_text('{}') 600 | with pytest.raises(ValidationError) as excinfo: 601 | load_from_filename( 602 | f, 603 | map_required, 604 | json.loads, 605 | display_filename='cfg.json', 606 | ) 607 | expected = ( 608 | 'File cfg.json', 609 | 'At foo(key=MISSING)', 610 | 'Missing required key: key', 611 | ) 612 | _assert_exception_trace(excinfo.value.args[0], expected) 613 | 614 | 615 | conditional_recurse = Map( 616 | 'Map', None, 617 | 618 | Required('t', check_bool), 619 | ConditionalRecurse( 620 | 'v', Map('Inner', 'k', Optional('k', check_bool, True)), 621 | 't', True, 622 | ), 623 | ConditionalRecurse( 624 | 'v', Map('Inner', 'k', Optional('k', check_bool, False)), 625 | 't', False, 626 | ), 627 | ) 628 | 629 | 630 | @pytest.mark.parametrize('tvalue', (True, False)) 631 | def test_conditional_recurse_apply_defaults(tvalue): 632 | val = {'t': tvalue, 'v': {}} 633 | ret = apply_defaults(val, conditional_recurse) 634 | assert ret == {'t': tvalue, 'v': {'k': tvalue}} 635 | 636 | val = {'t': tvalue, 'v': {'k': not tvalue}} 637 | ret = apply_defaults(val, conditional_recurse) 638 | assert ret == {'t': tvalue, 'v': {'k': not tvalue}} 639 | 640 | 641 | @pytest.mark.parametrize('tvalue', (True, False)) 642 | def test_conditional_recurse_remove_defaults(tvalue): 643 | val = {'t': tvalue, 'v': {'k': tvalue}} 644 | ret = remove_defaults(val, conditional_recurse) 645 | assert ret == {'t': tvalue, 'v': {}} 646 | 647 | val = {'t': tvalue, 'v': {'k': not tvalue}} 648 | ret = remove_defaults(val, conditional_recurse) 649 | assert ret == {'t': tvalue, 'v': {'k': not tvalue}} 650 | 651 | 652 | conditional_optional = Map( 653 | 'Map', None, 654 | 655 | Required('t', check_bool), 656 | ConditionalOptional('v', check_bool, True, 't', True), 657 | ConditionalOptional('v', check_bool, False, 't', False), 658 | ) 659 | 660 | 661 | @pytest.mark.parametrize('tvalue', (True, False)) 662 | def test_conditional_optional_check(tvalue): 663 | with pytest.raises(ValidationError) as excinfo: 664 | validate({'t': tvalue, 'v': 2}, conditional_optional) 665 | expected = ( 666 | 'At Map()', 667 | 'At key: v', 668 | 'Expected bool got int', 669 | ) 670 | _assert_exception_trace(excinfo.value, expected) 671 | 672 | validate({'t': tvalue, 'v': tvalue}, conditional_optional) 673 | 674 | 675 | @pytest.mark.parametrize('tvalue', (True, False)) 676 | def test_conditional_optional_apply_default(tvalue): 677 | ret = apply_defaults({'t': tvalue}, conditional_optional) 678 | assert ret == {'t': tvalue, 'v': tvalue} 679 | 680 | 681 | @pytest.mark.parametrize('tvalue', (True, False)) 682 | def test_conditional_optional_remove_default(tvalue): 683 | ret = remove_defaults({'t': tvalue, 'v': tvalue}, conditional_optional) 684 | assert ret == {'t': tvalue} 685 | ret = remove_defaults({'t': tvalue, 'v': not tvalue}, conditional_optional) 686 | assert ret == {'t': tvalue, 'v': not tvalue} 687 | 688 | 689 | no_additional_keys = Map( 690 | 'Map', None, 691 | Required(True, check_bool), 692 | NoAdditionalKeys((True,)), 693 | ) 694 | 695 | 696 | def test_no_additional_keys(): 697 | with pytest.raises(ValidationError) as excinfo: 698 | validate({True: True, False: False}, no_additional_keys) 699 | expected = ( 700 | 'At Map()', 701 | 'Additional keys found: False. Only these keys are allowed: True', 702 | ) 703 | _assert_exception_trace(excinfo.value, expected) 704 | 705 | validate({True: True}, no_additional_keys) 706 | 707 | 708 | @pytest.fixture 709 | def warn_additional_keys(): 710 | ret = mock.Mock() 711 | 712 | def callback(extra, keys, dct): 713 | return ret.record(extra, keys, dct) 714 | 715 | ret.schema = Map( 716 | 'Map', None, 717 | Required(True, check_bool), 718 | WarnAdditionalKeys((True,), callback), 719 | ) 720 | yield ret 721 | 722 | 723 | def test_warn_additional_keys_when_has_extra_keys(warn_additional_keys): 724 | validate({True: True, False: False}, warn_additional_keys.schema) 725 | assert warn_additional_keys.record.called 726 | 727 | 728 | def test_warn_additional_keys_when_no_extra_keys(warn_additional_keys): 729 | validate({True: True}, warn_additional_keys.schema) 730 | assert not warn_additional_keys.record.called 731 | -------------------------------------------------------------------------------- /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 | --------------------------------------------------------------------------------