├── .gitignore ├── .hgignore ├── .travis.yml ├── Changelog ├── LICENCE ├── MANIFEST.in ├── README ├── README.rst ├── setup.py ├── tox.ini └── valideer ├── __init__.py ├── base.py ├── compat.py ├── tests ├── __init__.py └── test_validators.py └── validators.py /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | build/ 3 | dist/ 4 | valideer.egg-info/ 5 | .coverage 6 | .pydevproject 7 | .project -------------------------------------------------------------------------------- /.hgignore: -------------------------------------------------------------------------------- 1 | syntax: glob 2 | 3 | *.pyc 4 | build/ 5 | dist/ 6 | valideer.egg-info/ 7 | .coverage 8 | .pydevproject 9 | .project -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | # Config file for automatic testing at travis-ci.org 2 | 3 | language: python 4 | python: 5 | - "2.7" 6 | - "3.3" 7 | - "3.4" 8 | - "3.5" 9 | - "3.6" # current default Python on Travis CI 10 | - "3.7" 11 | - "3.8" 12 | - "3.9" 13 | - "pypy" 14 | install: pip install coveralls 15 | script: coverage run --source=valideer setup.py test 16 | after_success: coveralls 17 | 18 | -------------------------------------------------------------------------------- /Changelog: -------------------------------------------------------------------------------- 1 | 0.4.2 2 | ===== 3 | - Added ``ignore_optional_property_errors`` optional parameter in ``parse()`` and 4 | ``parsing()``. 5 | 6 | 0.4.1 7 | ===== 8 | - Added ``returns`` decorator. 9 | 10 | 0.4 11 | === 12 | - Python 3 support. 13 | 14 | 0.3.2 15 | ===== 16 | - Added ``Nullable.default_object_property``. 17 | 18 | 0.3.1 19 | ===== 20 | - Added ``parsing()`` context manager. 21 | - Added ``Object.REMOVE`` sentinel for removing additional properties. 22 | - Made optional the schema parameter of the ``Range`` validator. 23 | - Fixed docstrings to be compatible with Sphinx. 24 | 25 | 0.3 26 | === 27 | - Exposed as top level functions the ``parse``, ``register`` and``register_factory`` 28 | Validator static methods (the latter are still kept for backwards compatibility). 29 | - Allow the Nullable default to be a zero-arg callable. 30 | - Added AllOf() composite validator. 31 | - Added ChainOf() composite validator. 32 | - Allow specifying schema of additional object properties or disallowing them, 33 | either locally (by passing an ``additional`` parameter to ``Object``) or 34 | globally (by setting the ``Object.ADDITIONAL_PROPERTIES`` class attribute). 35 | - Added an optional ``additional_properties`` parameter to ``parse()`` to allow 36 | specifying for a single parse call the handling of additional object properties. 37 | - Added an optional ``required_properties`` parameter to ``parse()`` to allow 38 | specifying for a single parse call whether object properties are required or 39 | optional by default. Specifying the same behaviour globally through the 40 | ``Object.REQUIRED_PROPERTIES`` attribute is still supported. 41 | 42 | 0.2 43 | === 44 | - Better, customizable ``ValidationError`` messages. 45 | - Added ``Condition`` validator. 46 | 47 | 0.1 48 | === 49 | - Initial release. 50 | -------------------------------------------------------------------------------- /LICENCE: -------------------------------------------------------------------------------- 1 | Copyright (C) 2012 Copyright Citrix Systems, Inc. 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of 4 | this software and associated documentation files (the "Software"), to deal in 5 | the Software without restriction, including without limitation the rights to 6 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies 7 | of the Software, and to permit persons to whom the Software is furnished to 8 | do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENCE 2 | include README 3 | include README.rst 4 | include MANIFEST.in 5 | -------------------------------------------------------------------------------- /README: -------------------------------------------------------------------------------- 1 | README.rst -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ======== 2 | Valideer 3 | ======== 4 | 5 | .. image:: https://travis-ci.org/podio/valideer.svg?branch=master 6 | :target: https://travis-ci.org/podio/valideer 7 | 8 | .. image:: https://coveralls.io/repos/podio/valideer/badge.svg?branch=master 9 | :target: https://coveralls.io/r/podio/valideer?branch=master 10 | 11 | .. image:: https://img.shields.io/pypi/status/valideer.svg 12 | :target: https://pypi.python.org/pypi/valideer/ 13 | 14 | .. image:: https://img.shields.io/pypi/v/valideer.svg 15 | :target: https://pypi.python.org/pypi/valideer/ 16 | 17 | .. image:: https://img.shields.io/pypi/pyversions/valideer.svg 18 | :target: https://pypi.python.org/pypi/valideer/ 19 | 20 | .. image:: https://img.shields.io/pypi/l/valideer.svg 21 | :target: https://pypi.python.org/pypi/valideer/ 22 | 23 | Lightweight data validation and adaptation library for Python. 24 | 25 | **At a Glance**: 26 | 27 | - Supports both validation (check if a value is valid) and adaptation (convert 28 | a valid input to an appropriate output). 29 | - Succinct: validation schemas can be specified in a declarative and extensible 30 | mini "language"; no need to define verbose schema classes upfront. A regular 31 | Python API is also available if the compact syntax is not your cup of tea. 32 | - Batteries included: validators for most common types are included out of the box. 33 | - Extensible: New custom validators and adaptors can be easily defined and 34 | registered. 35 | - Informative, customizable error messages: Validation errors include the reason 36 | and location of the error. 37 | - Agnostic: not tied to any particular framework or application domain (e.g. 38 | Web form validation). 39 | - Well tested: Extensive test suite with 100% coverage. 40 | - Production ready: Used for validating every access to the `Podio API`_. 41 | - Licence: MIT. 42 | 43 | 44 | Installation 45 | ------------ 46 | 47 | To install run:: 48 | 49 | pip install valideer 50 | 51 | Or for the latest version:: 52 | 53 | git clone git@github.com:podio/valideer.git 54 | cd valideer 55 | python setup.py install 56 | 57 | You may run the unit tests with:: 58 | 59 | $ python setup.py test --quiet 60 | running test 61 | running egg_info 62 | writing dependency_links to valideer.egg-info/dependency_links.txt 63 | writing requirements to valideer.egg-info/requires.txt 64 | writing valideer.egg-info/PKG-INFO 65 | writing top-level names to valideer.egg-info/top_level.txt 66 | reading manifest file 'valideer.egg-info/SOURCES.txt' 67 | reading manifest template 'MANIFEST.in' 68 | writing manifest file 'valideer.egg-info/SOURCES.txt' 69 | running build_ext 70 | ........................................................................................................................................................................... 71 | ---------------------------------------------------------------------- 72 | Ran 171 tests in 0.106s 73 | 74 | OK 75 | 76 | Basic Usage 77 | ----------- 78 | 79 | We'll demonstrate ``valideer`` using the following `JSON schema example`_:: 80 | 81 | { 82 | "name": "Product", 83 | "properties": { 84 | "id": { 85 | "type": "number", 86 | "description": "Product identifier", 87 | "required": true 88 | }, 89 | "name": { 90 | "type": "string", 91 | "description": "Name of the product", 92 | "required": true 93 | }, 94 | "price": { 95 | "type": "number", 96 | "minimum": 0, 97 | "required": true 98 | }, 99 | "tags": { 100 | "type": "array", 101 | "items": { 102 | "type": "string" 103 | } 104 | }, 105 | "stock": { 106 | "type": "object", 107 | "properties": { 108 | "warehouse": { 109 | "type": "number" 110 | }, 111 | "retail": { 112 | "type": "number" 113 | } 114 | } 115 | } 116 | } 117 | } 118 | 119 | This can be specified by passing a similar but less verbose structure to the 120 | ``valideer.parse`` function:: 121 | 122 | >>> import valideer as V 123 | >>> product_schema = { 124 | >>> "+id": "number", 125 | >>> "+name": "string", 126 | >>> "+price": V.Range("number", min_value=0), 127 | >>> "tags": ["string"], 128 | >>> "stock": { 129 | >>> "warehouse": "number", 130 | >>> "retail": "number", 131 | >>> } 132 | >>> } 133 | >>> validator = V.parse(product_schema) 134 | 135 | ``parse`` returns a ``Validator`` instance, which can be then used to validate 136 | or adapt values. 137 | 138 | Validation 139 | ########## 140 | 141 | To check if an input is valid call the ``is_valid`` method:: 142 | 143 | >>> product1 = { 144 | >>> "id": 1, 145 | >>> "name": "Foo", 146 | >>> "price": 123, 147 | >>> "tags": ["Bar", "Eek"], 148 | >>> "stock": { 149 | >>> "warehouse": 300, 150 | >>> "retail": 20 151 | >>> } 152 | >>> } 153 | >>> validator.is_valid(product1) 154 | True 155 | >>> product2 = { 156 | >>> "id": 1, 157 | >>> "price": 123, 158 | >>> } 159 | >>> validator.is_valid(product2) 160 | False 161 | 162 | Another option is the ``validate`` method. If the input is invalid, it raises 163 | ``ValidationError``:: 164 | 165 | >>> validator.validate(product2) 166 | ValidationError: Invalid value {'price': 123, 'id': 1} (dict): missing required properties: ['name'] 167 | 168 | For the common use case of validating inputs when entering a function, the 169 | ``@accepts`` decorator provides some nice syntax sugar (shamelessly stolen from 170 | typecheck_):: 171 | 172 | >>> from valideer import accepts 173 | >>> @accepts(product=product_schema, quantity="integer") 174 | >>> def get_total_price(product, quantity=1): 175 | >>> return product["price"] * quantity 176 | >>> 177 | >>> get_total_price(product1, 2) 178 | 246 179 | >>> get_total_price(product1, 0.5) 180 | ValidationError: Invalid value 0.5 (float): must be integer (at quantity) 181 | >>> get_total_price(product2) 182 | ValidationError: Invalid value {'price': 123, 'id': 1} (dict): missing required properties: ['name'] (at product) 183 | 184 | Adaptation 185 | ########## 186 | 187 | Often input data have to be converted from their original form before they are 188 | ready to use; for example a number that may arrive as integer or string and 189 | needs to be adapted to a float. Since validation and adaptation usually happen 190 | simultaneously, ``validate`` returns the adapted version of the (valid) input 191 | by default. 192 | 193 | An existing class can be easily used as an adaptor by being wrapped in ``AdaptTo``:: 194 | 195 | >>> import valideer as V 196 | >>> adapt_prices = V.parse({"prices": [V.AdaptTo(float)]}).validate 197 | >>> adapt_prices({"prices": ["2", "3.1", 1]}) 198 | {'prices': [2.0, 3.1, 1.0]} 199 | >>> adapt_prices({"prices": ["2", "3f"]}) 200 | ValidationError: Invalid value '3f' (str): invalid literal for float(): 3f (at prices[1]) 201 | >>> adapt_prices({"prices": ["2", 1, None]}) 202 | ValidationError: Invalid value None (NoneType): float() argument must be a string or a number (at prices[2]) 203 | 204 | Similar to ``@accepts``, the ``@adapts`` decorator provides a convenient syntax 205 | for adapting function inputs:: 206 | 207 | >>> from valideer import adapts 208 | >>> @adapts(json={"prices": [AdaptTo(float)]}) 209 | >>> def get_sum_price(json): 210 | >>> return sum(json["prices"]) 211 | >>> get_sum_price({"prices": ["2", "3.1", 1]}) 212 | 6.1 213 | >>> get_sum_price({"prices": ["2", "3f"]}) 214 | ValidationError: Invalid value '3f' (str): invalid literal for float(): 3f (at json['prices'][1]) 215 | >>> get_sum_price({"prices": ["2", 1, None]}) 216 | ValidationError: Invalid value None (NoneType): float() argument must be a string or a number (at json['prices'][2]) 217 | 218 | Required and optional object properties 219 | ####################################### 220 | 221 | By default object properties are considered optional unless they start with "+". 222 | This default can be inverted by using the ``parsing`` context manager with 223 | ``required_properties=True``. In this case object properties are considered 224 | required by default unless they start with "?". For example:: 225 | 226 | validator = V.parse({ 227 | "+name": "string", 228 | "duration": { 229 | "+hours": "integer", 230 | "+minutes": "integer", 231 | "seconds": "integer" 232 | } 233 | }) 234 | 235 | is equivalent to:: 236 | 237 | with V.parsing(required_properties=True): 238 | validator = V.parse({ 239 | "name": "string", 240 | "?duration": { 241 | "hours": "integer", 242 | "minutes": "integer", 243 | "?seconds": "integer" 244 | } 245 | }) 246 | 247 | Ignoring optional object property errors 248 | ######################################## 249 | 250 | By default an invalid object property value raises ``ValidationError``, 251 | regardless of whether it's required or optional. It is possible to ignore invalid 252 | values for optional properties by using the ``parsing`` context manager with 253 | ``ignore_optional_property_errors=True``:: 254 | 255 | >>> schema = { 256 | ... "+name": "string", 257 | ... "price": "number", 258 | ... } 259 | >>> data = {"name": "wine", "price": "12.50"} 260 | >>> V.parse(schema).validate(data) 261 | valideer.base.ValidationError: Invalid value '12.50' (str): must be number (at price) 262 | >>> with V.parsing(ignore_optional_property_errors=True): 263 | ... print V.parse(schema).validate(data) 264 | {'name': 'wine'} 265 | 266 | Additional object properties 267 | ############################ 268 | 269 | Any properties that are not specified as either required or optional are allowed 270 | by default. This default can be overriden by calling ``parsing`` with 271 | ``additional_properties=`` 272 | 273 | - ``False`` to disallow all additional properties 274 | - ``Object.REMOVE`` to remove all additional properties from the adapted value 275 | - any validator or parseable schema to validate all additional property 276 | values using this schema:: 277 | 278 | >>> schema = { 279 | >>> "name": "string", 280 | >>> "duration": { 281 | >>> "hours": "integer", 282 | >>> "minutes": "integer", 283 | >>> } 284 | >>> } 285 | >>> data = {"name": "lap", "duration": {"hours":3, "minutes":33, "seconds": 12}} 286 | >>> V.parse(schema).validate(data) 287 | {'duration': {'hours': 3, 'minutes': 33, 'seconds': 12}, 'name': 'lap'} 288 | >>> with V.parsing(additional_properties=False): 289 | ... V.parse(schema).validate(data) 290 | ValidationError: Invalid value {'hours': 3, 'seconds': 12, 'minutes': 33} (dict): additional properties: ['seconds'] (at duration) 291 | >>> with V.parsing(additional_properties=V.Object.REMOVE): 292 | ... print V.parse(schema).validate(data) 293 | {'duration': {'hours': 3, 'minutes': 33}, 'name': 'lap'} 294 | >>> with V.parsing(additional_properties="string"): 295 | ... V.parse(schema).validate(data) 296 | ValidationError: Invalid value 12 (int): must be string (at duration['seconds']) 297 | 298 | 299 | Explicit Instantiation 300 | ###################### 301 | 302 | The usual way to create a validator is by passing an appropriate nested structure 303 | to ``parse``, as outlined above. This enables concise schema definitions with 304 | minimal boilerplate. In case this seems too cryptic or "unpythonic" for your 305 | taste, a validator can be also created explicitly from regular Python classes:: 306 | 307 | >>> from valideer import Object, HomogeneousSequence, Number, String, Range 308 | >>> validator = Object( 309 | >>> required={ 310 | >>> "id": Number(), 311 | >>> "name": String(), 312 | >>> "price": Range(Number(), min_value=0), 313 | >>> }, 314 | >>> optional={ 315 | >>> "tags": HomogeneousSequence(String()), 316 | >>> "stock": Object( 317 | >>> optional={ 318 | >>> "warehouse": Number(), 319 | >>> "retail": Number(), 320 | >>> } 321 | >>> ) 322 | >>> } 323 | >>> ) 324 | 325 | 326 | Built-in Validators 327 | ------------------- 328 | ``valideer`` comes with several predefined validators, each implemented as a 329 | ``Validator`` subclass. As shown above, some validator classes also support a 330 | shortcut form that can be used to specify implicitly a validator instance. 331 | 332 | Basic 333 | ##### 334 | 335 | * ``valideer.Boolean()``: Accepts ``bool`` instances. 336 | 337 | :Shortcut: ``"boolean"`` 338 | 339 | * ``valideer.Integer()``: Accepts integers (``numbers.Integral`` instances), 340 | excluding ``bool``. 341 | 342 | :Shortcut: ``"integer"`` 343 | 344 | * ``valideer.Number()``: Accepts numbers (``numbers.Number`` instances), 345 | excluding ``bool``. 346 | 347 | :Shortcut: ``"number"`` 348 | 349 | * ``valideer.Date()``: Accepts ``datetime.date`` instances. 350 | 351 | :Shortcut: ``"date"`` 352 | 353 | * ``valideer.Time()``: Accepts ``datetime.time`` instances. 354 | 355 | :Shortcut: ``"time"`` 356 | 357 | * ``valideer.Datetime()``: Accepts ``datetime.datetime`` instances. 358 | 359 | :Shortcut: ``"datetime"`` 360 | 361 | * ``valideer.String(min_length=None, max_length=None)``: Accepts strings 362 | (``basestring`` instances). 363 | 364 | :Shortcut: ``"string"`` 365 | 366 | * ``valideer.Pattern(regexp)``: Accepts strings that match the given regular 367 | expression. 368 | 369 | :Shortcut: *Compiled regular expression* 370 | 371 | * ``valideer.Condition(predicate, traps=Exception)``: Accepts values for which 372 | ``predicate(value)`` is true. Any raised exception that is instance of ``traps`` 373 | is re-raised as a ``ValidationError``. 374 | 375 | :Shortcut: *Python function or method*. 376 | 377 | * ``valideer.Type(accept_types=None, reject_types=None)``: Accepts instances of 378 | the given ``accept_types`` but excluding instances of ``reject_types``. 379 | 380 | :Shortcut: *Python type*. For example ``int`` is equivalent to ``valideer.Type(int)``. 381 | 382 | * ``valideer.Enum(values)``: Accepts a fixed set of values. 383 | 384 | :Shortcut: *N/A* 385 | 386 | Containers 387 | ########## 388 | 389 | * ``valideer.HomogeneousSequence(item_schema=None, min_length=None, max_length=None)``: 390 | Accepts sequences (``collections.Sequence`` instances excluding strings) with 391 | elements that are valid for ``item_schema`` (if specified) and length between 392 | ``min_length`` and ``max_length`` (if specified). 393 | 394 | :Shortcut: [*item_schema*] 395 | 396 | * ``valideer.HeterogeneousSequence(*item_schemas)``: Accepts fixed length 397 | sequences (``collections.Sequence`` instances excluding strings) where the 398 | ``i``-th element is valid for the ``i``-th ``item_schema``. 399 | 400 | :Shortcut: (*item_schema*, *item_schema*, ..., *item_schema*) 401 | 402 | * ``valideer.Mapping(key_schema=None, value_schema=None)``: Accepts mappings 403 | (``collections.Mapping`` instances) with keys that are valid for ``key_schema`` 404 | (if specified) and values that are valid for ``value_schema`` (if specified). 405 | 406 | :Shortcut: *N/A* 407 | 408 | * ``valideer.Object(optional={}, required={}, additional=True)``: Accepts JSON-like 409 | objects (``collections.Mapping`` instances with string keys). Properties that 410 | are specified as ``optional`` or ``required`` are validated against the respective 411 | value schema. Any additional properties are either allowed (if ``additional`` 412 | is True), disallowed (if ``additional`` is False) or validated against the 413 | ``additional`` schema. 414 | 415 | :Shortcut: {"*property*": *value_schema*, "*property*": *value_schema*, ..., 416 | "*property*": *value_schema*}. Properties that start with ``'+'`` 417 | are required, the rest are optional and additional properties are 418 | allowed. 419 | 420 | Adaptors 421 | ######## 422 | 423 | * ``valideer.AdaptBy(adaptor, traps=Exception)``: Adapts a value by calling 424 | ``adaptor(value)``. Any raised exception that is instance of ``traps`` is 425 | wrapped into a ``ValidationError``. 426 | 427 | :Shortcut: *N/A* 428 | 429 | * ``valideer.AdaptTo(adaptor, traps=Exception, exact=False)``: Similar to 430 | ``AdaptBy`` but for types. Any value that is already instance of ``adaptor`` 431 | is returned as is, otherwise it is adapted by calling ``adaptor(value)``. If 432 | ``exact`` is ``True``, instances of ``adaptor`` subclasses are also adapted. 433 | 434 | :Shortcut: *N/A* 435 | 436 | Composite 437 | ######### 438 | 439 | * ``valideer.Nullable(schema, default=None)``: Accepts values that are valid for 440 | ``schema`` or ``None``. ``default`` is returned as the adapted value of ``None``. 441 | ``default`` can also be a zero-argument callable, in which case the adapted 442 | value of ``None`` is ``default()``. 443 | 444 | :Shortcut: "?{*validator_name*}". For example ``"?integer"`` accepts any integer 445 | or ``None`` value. 446 | 447 | * ``valideer.NonNullable(schema=None)``: Accepts values that are valid for 448 | ``schema`` (if specified) except for ``None``. 449 | 450 | :Shortcut: "+{*validator_name*}" 451 | 452 | * ``valideer.Range(schema, min_value=None, max_value=None)``: Accepts values that 453 | are valid for ``schema`` and within the given ``[min_value, max_value]`` range. 454 | 455 | :Shortcut: *N/A* 456 | 457 | * ``valideer.AnyOf(*schemas)``: Accepts values that are valid for at least one 458 | of the given ``schemas``. 459 | 460 | :Shortcut: *N/A* 461 | 462 | * ``valideer.AllOf(*schemas)``: Accepts values that are valid for all the given 463 | ``schemas``. 464 | 465 | :Shortcut: *N/A* 466 | 467 | * ``valideer.ChainOf(*schemas)``: Passes values through a chain of validator and 468 | adaptor ``schemas``. 469 | 470 | :Shortcut: *N/A* 471 | 472 | 473 | User Defined Validators 474 | ----------------------- 475 | 476 | The set of predefined validators listed above can be easily extended with user 477 | defined validators. All you need to do is extend ``Validator`` (or a more 478 | convenient subclass) and implement the ``validate`` method. Here is an example 479 | of a custom validator that could be used to enforce minimal password strength:: 480 | 481 | from valideer import String, ValidationError 482 | 483 | class Password(String): 484 | 485 | name = "password" 486 | 487 | def __init__(self, min_length=6, min_lower=1, min_upper=1, min_digits=0): 488 | super(Password, self).__init__(min_length=min_length) 489 | self.min_lower = min_lower 490 | self.min_upper = min_upper 491 | self.min_digits = min_digits 492 | 493 | def validate(self, value, adapt=True): 494 | super(Password, self).validate(value) 495 | 496 | if len(filter(str.islower, value)) < self.min_lower: 497 | raise ValidationError("At least %d lowercase characters required" % self.min_lower) 498 | 499 | if len(filter(str.isupper, value)) < self.min_upper: 500 | raise ValidationError("At least %d uppercase characters required" % self.min_upper) 501 | 502 | if len(filter(str.isdigit, value)) < self.min_digits: 503 | raise ValidationError("At least %d digits required" % self.min_digits) 504 | 505 | return value 506 | 507 | A few notes: 508 | 509 | * The optional ``name`` class attribute creates a shortcut for referring to a 510 | default instance of the validator. In this example the string ``"password"`` 511 | becomes an alias to a ``Password()`` instance. 512 | 513 | * ``validate`` takes an optional boolean ``adapt`` parameter that defaults to 514 | ``True``. If it is ``False``, the validator is allowed to skip adaptation and 515 | perform validation only. This is basically an optimization hint that can be 516 | useful if adaptation happens to be significantly more expensive than validation. 517 | This isn't common though and so ``adapt`` is usually ignored. 518 | 519 | Shortcut Registration 520 | ##################### 521 | 522 | Setting a ``name`` class attribute is the simplest way to create a validator 523 | shortcut. A shortcut can also be created explicitly with the ``valideer.register`` 524 | function:: 525 | 526 | >>> import valideer as V 527 | >>> V.register("strong_password", Password(min_length=8, min_digits=1)) 528 | >>> is_fair_password = V.parse("password").is_valid 529 | >>> is_strong_password = V.parse("strong_password").is_valid 530 | >>> for pwd in "passwd", "Passwd", "PASSWd", "Pas5word": 531 | >>> print (pwd, is_fair_password(pwd), is_strong_password(pwd)) 532 | ('passwd', False, False) 533 | ('Passwd', True, False) 534 | ('PASSWd', True, False) 535 | ('Pas5word', True, True) 536 | 537 | Finally it is possible to parse arbitrary Python objects as validator shortcuts. 538 | For example let's define a ``Not`` composite validator, a validator that accepts 539 | a value if and only if it is rejected by another validator:: 540 | 541 | class Not(Validator): 542 | 543 | def __init__(self, schema): 544 | self._validator = Validator.parse(schema) 545 | 546 | def validate(self, value, adapt=True): 547 | if self._validator.is_valid(value): 548 | raise ValidationError("Should not be a %s" % self._validator.__class__.__name__, value) 549 | return value 550 | 551 | If we'd like to parse ``'!foo'`` strings as a shortcut for ``Not('foo')``, we 552 | can do so with the ``valideer.register_factory`` decorator:: 553 | 554 | >>> @V.register_factory 555 | >>> def NotFactory(obj): 556 | >>> if isinstance(obj, basestring) and obj.startswith("!"): 557 | >>> return Not(obj[1:]) 558 | >>> 559 | >>> validate = V.parse({"i": "integer", "s": "!number"}).validate 560 | >>> validate({"i": 4, "s": ""}) 561 | {'i': 4, 's': ''} 562 | >>> validate({"i": 4, "s": 1.2}) 563 | ValidationError: Invalid value 1.2 (float): Should not be a Number (at s) 564 | 565 | 566 | .. _valideer: https://github.com/podio/valideer 567 | .. _JSON Schema: https://tools.ietf.org/html/draft-zyp-json-schema-03 568 | .. _Podio API: https://developers.podio.com 569 | .. _nose: http://pypi.python.org/pypi/nose 570 | .. _coverage: http://pypi.python.org/pypi/coverage 571 | .. _JSON schema example: http://en.wikipedia.org/wiki/JSON#Schema 572 | .. _typecheck: http://pypi.python.org/pypi/typecheck 573 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | from setuptools import setup, find_packages 4 | 5 | setup( 6 | name="valideer", 7 | version="0.4.2", 8 | description="Lightweight data validation and adaptation library for Python", 9 | long_description=open("README.rst").read(), 10 | url="https://github.com/podio/valideer", 11 | author="George Sakkis", 12 | author_email="george.sakkis@gmail.com", 13 | packages=find_packages(), 14 | install_requires=["decorator"], 15 | test_suite="valideer.tests", 16 | platforms=["any"], 17 | keywords="validation adaptation typechecking jsonschema", 18 | classifiers=[ 19 | "Development Status :: 5 - Production/Stable", 20 | "Intended Audience :: Developers", 21 | "Operating System :: OS Independent", 22 | "Programming Language :: Python :: 2", 23 | "Programming Language :: Python :: 2.7", 24 | "Programming Language :: Python :: 3", 25 | "Programming Language :: Python :: 3.3", 26 | "Programming Language :: Python :: 3.4", 27 | "Topic :: Software Development :: Libraries :: Python Modules", 28 | "License :: OSI Approved :: MIT License", 29 | ], 30 | ) 31 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | # Tox (https://tox.readthedocs.io/) is a tool for running tests in multiple virtualenvs. 2 | # This configuration file helps to run the test suite on all supported Python versions. 3 | # To use it, "python -m pip install tox" and then run "tox" from this directory. 4 | 5 | [tox] 6 | envlist = 7 | py27 8 | py35 9 | py36 10 | py37 11 | py38 12 | py39 13 | skip_missing_interpreters = true 14 | minversion = 3.12 15 | 16 | [testenv] 17 | deps = coveralls 18 | commands = 19 | coverage run --source=valideer setup.py test 20 | 21 | [testenv:py38] 22 | basepython = python3 23 | deps = coveralls 24 | commands = 25 | coverage run --source=valideer setup.py test -------------------------------------------------------------------------------- /valideer/__init__.py: -------------------------------------------------------------------------------- 1 | from .base import * 2 | from .validators import * 3 | -------------------------------------------------------------------------------- /valideer/base.py: -------------------------------------------------------------------------------- 1 | import inspect 2 | from contextlib import contextmanager 3 | from threading import RLock 4 | from decorator import decorator 5 | from .compat import with_metaclass, iteritems 6 | 7 | __all__ = [ 8 | "ValidationError", "SchemaError", "Validator", "accepts", "returns", "adapts", 9 | "parse", "parsing", "register", "register_factory", 10 | "set_name_for_types", "reset_type_names", 11 | ] 12 | 13 | _NAMED_VALIDATORS = {} 14 | _VALIDATOR_FACTORIES = [] 15 | _VALIDATOR_FACTORIES_LOCK = RLock() 16 | 17 | 18 | class SchemaError(Exception): 19 | """An object cannot be parsed as a validator.""" 20 | 21 | 22 | class ValidationError(ValueError): 23 | """A value is invalid for a given validator.""" 24 | 25 | _UNDEFINED = object() 26 | 27 | def __init__(self, msg, value=_UNDEFINED): 28 | self.msg = msg 29 | self.value = value 30 | self.context = [] 31 | super(ValidationError, self).__init__() 32 | 33 | def __str__(self): 34 | return self.to_string() 35 | 36 | @property 37 | def message(self): 38 | return self.to_string() 39 | 40 | @property 41 | def args(self): 42 | return (self.to_string(),) 43 | 44 | def to_string(self, repr_value=repr): 45 | msg = self.msg 46 | if self.value is not self._UNDEFINED: 47 | msg = "Invalid value %s (%s): %s" % (repr_value(self.value), 48 | get_type_name(self.value.__class__), 49 | msg) 50 | if self.context: 51 | msg += " (at %s)" % "".join("[%r]" % context if i > 0 else str(context) 52 | for i, context in enumerate(reversed(self.context))) 53 | return msg 54 | 55 | def add_context(self, context): 56 | self.context.append(context) 57 | return self 58 | 59 | 60 | def parse(obj, required_properties=None, additional_properties=None, 61 | ignore_optional_property_errors=None): 62 | """Try to parse the given ``obj`` as a validator instance. 63 | 64 | :param obj: The object to be parsed. If it is a...: 65 | 66 | - :py:class:`Validator` instance, return it. 67 | - :py:class:`Validator` subclass, instantiate it without arguments and 68 | return it. 69 | - :py:attr:`~Validator.name` of a known :py:class:`Validator` subclass, 70 | instantiate the subclass without arguments and return it. 71 | - otherwise find the first registered :py:class:`Validator` factory that 72 | can create it. The search order is the reverse of the factory registration 73 | order. The caller is responsible for ensuring there are no ambiguous 74 | values that can be parsed by more than one factory. 75 | 76 | :param required_properties: Specifies for this parse call whether parsed 77 | :py:class:`~valideer.validators.Object` properties are required or 78 | optional by default. It can be: 79 | 80 | - ``True`` for required. 81 | - ``False`` for optional. 82 | - ``None`` to use the value of the 83 | :py:attr:`~valideer.validators.Object.REQUIRED_PROPERTIES` attribute. 84 | 85 | :param additional_properties: Specifies for this parse call the schema of 86 | all :py:class:`~valideer.validators.Object` properties that are not 87 | explicitly defined as optional or required. It can also be: 88 | 89 | - ``True`` to allow any value for additional properties. 90 | - ``False`` to disallow any additional properties. 91 | - :py:attr:`~valideer.validators.Object.REMOVE` to remove any additional 92 | properties from the adapted object. 93 | - ``None`` to use the value of the 94 | :py:attr:`~valideer.validators.Object.ADDITIONAL_PROPERTIES` attribute. 95 | 96 | :param ignore_optional_property_errors: Determines if invalid optional 97 | properties are ignored: 98 | 99 | - ``True`` to ignore invalid optional properties. 100 | - ``False`` to raise ValidationError for invalid optional properties. 101 | - ``None`` to use the value of the 102 | :py:attr:`~valideer.validators.Object.IGNORE_OPTIONAL_PROPERTY_ERRORS` 103 | attribute. 104 | 105 | :raises SchemaError: If no appropriate validator could be found. 106 | 107 | .. warning:: Passing ``required_properties`` and/or ``additional_properties`` 108 | with value other than ``None`` may be non intuitive for schemas that 109 | involve nested validators. Take for example the following schema:: 110 | 111 | v = V.parse({ 112 | "x": "integer", 113 | "child": V.Nullable({ 114 | "y": "integer" 115 | }) 116 | }, required_properties=True) 117 | 118 | Here the top-level properties 'x' and 'child' are required but the nested 119 | 'y' property is not. This is because by the time :py:meth:`parse` is called, 120 | :py:class:`~valideer.validators.Nullable` has already parsed its argument 121 | with the default value of ``required_properties``. Several other builtin 122 | validators work similarly to :py:class:`~valideer.validators.Nullable`, 123 | accepting one or more schemas to parse. In order to parse an arbitrarily 124 | complex nested validator with the same value for ``required_properties`` 125 | and/or ``additional_properties``, use the :py:func:`parsing` context 126 | manager instead:: 127 | 128 | with V.parsing(required_properties=True): 129 | v = V.parse({ 130 | "x": "integer", 131 | "child": V.Nullable({ 132 | "y": "integer" 133 | }) 134 | }) 135 | """ 136 | if not (required_properties is 137 | additional_properties is 138 | ignore_optional_property_errors is None): 139 | with parsing(required_properties=required_properties, 140 | additional_properties=additional_properties, 141 | ignore_optional_property_errors=ignore_optional_property_errors): 142 | return parse(obj) 143 | 144 | validator = None 145 | 146 | if isinstance(obj, Validator): 147 | validator = obj 148 | elif inspect.isclass(obj) and issubclass(obj, Validator): 149 | validator = obj() 150 | else: 151 | try: 152 | validator = _NAMED_VALIDATORS[obj] 153 | except (KeyError, TypeError): 154 | for factory in _VALIDATOR_FACTORIES: 155 | validator = factory(obj) 156 | if validator is not None: 157 | break 158 | else: 159 | if inspect.isclass(validator) and issubclass(validator, Validator): 160 | _NAMED_VALIDATORS[obj] = validator = validator() 161 | 162 | if not isinstance(validator, Validator): 163 | raise SchemaError("%r cannot be parsed as a Validator" % obj) 164 | 165 | return validator 166 | 167 | 168 | @contextmanager 169 | def parsing(**kwargs): 170 | """ 171 | Context manager for overriding the default validator parsing rules for the 172 | following code block. 173 | """ 174 | 175 | from .validators import Object 176 | with _VALIDATOR_FACTORIES_LOCK: 177 | old_values = {} 178 | for key, value in iteritems(kwargs): 179 | if value is not None: 180 | attr = key.upper() 181 | old_values[key] = getattr(Object, attr) 182 | setattr(Object, attr, value) 183 | try: 184 | yield 185 | finally: 186 | for key, value in iteritems(kwargs): 187 | if value is not None: 188 | setattr(Object, key.upper(), old_values[key]) 189 | 190 | 191 | def register(name, validator): 192 | """Register a validator instance under the given ``name``.""" 193 | if not isinstance(validator, Validator): 194 | raise TypeError("Validator instance expected, %s given" % validator.__class__) 195 | _NAMED_VALIDATORS[name] = validator 196 | 197 | 198 | def register_factory(func): 199 | """Decorator for registering a validator factory. 200 | 201 | The decorated factory must be a callable that takes a single parameter 202 | that can be any arbitrary object and returns a :py:class:`Validator` instance 203 | if it can parse the input object successfully, or ``None`` otherwise. 204 | """ 205 | _VALIDATOR_FACTORIES.insert(0, func) 206 | return func 207 | 208 | 209 | class _MetaValidator(type): 210 | def __new__(mcs, name, bases, attrs): # @NoSelf 211 | validator_type = type.__new__(mcs, name, bases, attrs) 212 | validator_name = attrs.get("name") 213 | if validator_name is not None: 214 | _NAMED_VALIDATORS[validator_name] = validator_type 215 | return validator_type 216 | 217 | 218 | @with_metaclass(_MetaValidator) 219 | class Validator(object): 220 | """Abstract base class of all validators. 221 | 222 | Concrete subclasses must implement :py:meth:`validate`. A subclass may optionally 223 | define a :py:attr:`name` attribute (typically a string) that can be used to specify 224 | a validator in :py:meth:`parse` instead of instantiating it explicitly. 225 | """ 226 | 227 | name = None 228 | 229 | def validate(self, value, adapt=True): 230 | """Check if ``value`` is valid and if so adapt it. 231 | 232 | :param adapt: If ``False``, it indicates that the caller is interested 233 | only on whether ``value`` is valid, not on adapting it. This is 234 | essentially an optimization hint for cases that validation can be 235 | done more efficiently than adaptation. 236 | 237 | :raises ValidationError: If ``value`` is invalid. 238 | :returns: The adapted value if ``adapt`` is ``True``, otherwise anything. 239 | """ 240 | raise NotImplementedError 241 | 242 | def is_valid(self, value): 243 | """Check if the value is valid. 244 | 245 | :returns: ``True`` if the value is valid, ``False`` if invalid. 246 | """ 247 | try: 248 | self.validate(value, adapt=False) 249 | return True 250 | except ValidationError: 251 | return False 252 | 253 | def error(self, value): 254 | """Helper method that can be called when ``value`` is deemed invalid. 255 | 256 | Can be overriden to provide customized :py:exc:`ValidationError` subclasses. 257 | """ 258 | raise ValidationError("must be %s" % self.humanized_name, value) 259 | 260 | @property 261 | def humanized_name(self): 262 | """Return a human-friendly string name for this validator.""" 263 | return self.name or self.__class__.__name__ 264 | 265 | # for backwards compatibility 266 | 267 | parse = staticmethod(parse) 268 | register = staticmethod(register) 269 | register_factory = staticmethod(register_factory) 270 | 271 | 272 | def accepts(**schemas): 273 | """Create a decorator for validating function parameters. 274 | 275 | Example:: 276 | 277 | @accepts(a="number", body={"+field_ids": [int], "is_ok": bool}) 278 | def f(a, body): 279 | print (a, body["field_ids"], body.get("is_ok")) 280 | 281 | :param schemas: The schema for validating a given parameter. 282 | """ 283 | validate = parse(schemas).validate 284 | 285 | @decorator 286 | def validating(func, *args, **kwargs): 287 | validate(inspect.getcallargs(func, *args, **kwargs), adapt=False) 288 | return func(*args, **kwargs) 289 | return validating 290 | 291 | 292 | def returns(schema): 293 | """Create a decorator for validating function return value. 294 | 295 | Example:: 296 | @accepts(a=int, b=int) 297 | @returns(int) 298 | def f(a, b): 299 | return a + b 300 | 301 | :param schema: The schema for adapting a given parameter. 302 | """ 303 | validate = parse(schema).validate 304 | 305 | @decorator 306 | def validating(func, *args, **kwargs): 307 | ret = func(*args, **kwargs) 308 | validate(ret, adapt=False) 309 | return ret 310 | return validating 311 | 312 | 313 | def adapts(**schemas): 314 | """Create a decorator for validating and adapting function parameters. 315 | 316 | Example:: 317 | 318 | @adapts(a="number", body={"+field_ids": [V.AdaptTo(int)], "is_ok": bool}) 319 | def f(a, body): 320 | print (a, body.field_ids, body.is_ok) 321 | 322 | :param schemas: The schema for adapting a given parameter. 323 | """ 324 | validate = parse(schemas).validate 325 | 326 | @decorator 327 | def adapting(func, *args, **kwargs): 328 | adapted = validate(inspect.getcallargs(func, *args, **kwargs), adapt=True) 329 | argspec = inspect.getargspec(func) 330 | 331 | if argspec.varargs is argspec.keywords is None: 332 | # optimization for the common no varargs, no keywords case 333 | return func(**adapted) 334 | 335 | adapted_varargs = adapted.pop(argspec.varargs, ()) 336 | adapted_keywords = adapted.pop(argspec.keywords, {}) 337 | if not adapted_varargs: # keywords only 338 | if adapted_keywords: 339 | adapted.update(adapted_keywords) 340 | return func(**adapted) 341 | 342 | adapted_posargs = [adapted[arg] for arg in argspec.args] 343 | adapted_posargs.extend(adapted_varargs) 344 | return func(*adapted_posargs, **adapted_keywords) 345 | 346 | return adapting 347 | 348 | 349 | _TYPE_NAMES = {} 350 | 351 | 352 | def set_name_for_types(name, *types): 353 | """Associate one or more types with an alternative human-friendly name.""" 354 | for t in types: 355 | _TYPE_NAMES[t] = name 356 | 357 | 358 | def reset_type_names(): 359 | _TYPE_NAMES.clear() 360 | 361 | 362 | def get_type_name(type): 363 | return _TYPE_NAMES.get(type) or type.__name__ 364 | -------------------------------------------------------------------------------- /valideer/compat.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | PY2 = sys.version_info[0] == 2 4 | PY3 = sys.version_info[0] == 3 5 | 6 | if PY2: # pragma: no cover 7 | string_types = basestring 8 | int_types = (int, long) 9 | from itertools import izip, imap 10 | long = long 11 | unicode = unicode 12 | xrange = xrange 13 | iteritems = dict.iteritems 14 | else: # pragma: no cover 15 | string_types = str 16 | int_types = (int,) 17 | izip = zip 18 | imap = map 19 | long = int 20 | unicode = str 21 | iteritems = dict.items 22 | xrange = range 23 | 24 | 25 | def with_metaclass(mcls): 26 | def decorator(cls): 27 | body = vars(cls).copy() 28 | # clean out class body 29 | body.pop('__dict__', None) 30 | body.pop('__weakref__', None) 31 | return mcls(cls.__name__, cls.__bases__, body) 32 | return decorator 33 | -------------------------------------------------------------------------------- /valideer/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/podio/valideer/d8abb3a54ed532a881c51b71f1ee7956fdd97471/valideer/tests/__init__.py -------------------------------------------------------------------------------- /valideer/tests/test_validators.py: -------------------------------------------------------------------------------- 1 | from datetime import date, datetime 2 | from decimal import Decimal 3 | from functools import partial, wraps 4 | import collections 5 | import json 6 | import re 7 | import unittest 8 | import valideer as V 9 | from valideer.compat import long, unicode, xrange, string_types, int_types 10 | 11 | 12 | class Fraction(V.Type): 13 | name = "fraction" 14 | accept_types = (float, complex, Decimal) 15 | 16 | 17 | class Date(V.Type): 18 | accept_types = (date, datetime) 19 | 20 | 21 | class Gender(V.Enum): 22 | name = "gender" 23 | values = ("male", "female", "it's complicated") 24 | 25 | 26 | class TestValidator(unittest.TestCase): 27 | 28 | parse = staticmethod(V.parse) 29 | 30 | def setUp(self): 31 | V.Object.REQUIRED_PROPERTIES = True 32 | V.base.reset_type_names() 33 | self.complex_validator = self.parse({ 34 | "n": "number", 35 | "?i": V.Nullable("integer", 0), 36 | "?b": bool, 37 | "?e": V.Enum(["r", "g", "b"]), 38 | "?d": V.AnyOf("date", "datetime"), 39 | "?s": V.String(min_length=1, max_length=8), 40 | "?p": V.Nullable(re.compile(r"\d{1,4}$")), 41 | "?l": [{"+s2": "string"}], 42 | "?t": (unicode, "number"), 43 | "?h": V.Mapping(int, ["string"]), 44 | "?o": V.NonNullable({"+i2": "integer"}), 45 | }) 46 | 47 | def test_none(self): 48 | for obj in ["boolean", "integer", "number", "string", 49 | V.HomogeneousSequence, V.HeterogeneousSequence, 50 | V.Mapping, V.Object, int, float, str, unicode, 51 | Fraction, Fraction(), Gender, Gender()]: 52 | self.assertFalse(self.parse(obj).is_valid(None)) 53 | 54 | def test_boolean(self): 55 | for obj in "boolean", V.Boolean, V.Boolean(): 56 | self._testValidation(obj, 57 | valid=[True, False], 58 | invalid=[1, 1.1, "foo", u"bar", {}, []]) 59 | 60 | def test_integer(self): 61 | for obj in "integer", V.Integer, V.Integer(): 62 | self._testValidation(obj, 63 | valid=[1], 64 | invalid=[1.1, "foo", u"bar", {}, [], False, True]) 65 | 66 | def test_int(self): 67 | # bools are ints 68 | self._testValidation(int, 69 | valid=[1, True, False], 70 | invalid=[1.1, "foo", u"bar", {}, []]) 71 | 72 | def test_number(self): 73 | for obj in "number", V.Number, V.Number(): 74 | self._testValidation(obj, 75 | valid=[1, 1.1], 76 | invalid=["foo", u"bar", {}, [], False, True]) 77 | 78 | def test_float(self): 79 | self._testValidation(float, 80 | valid=[1.1], 81 | invalid=[1, "foo", u"bar", {}, [], False, True]) 82 | 83 | def test_string(self): 84 | for obj in "string", V.String, V.String(): 85 | self._testValidation(obj, 86 | valid=["foo", u"bar"], 87 | invalid=[1, 1.1, {}, [], False, True]) 88 | 89 | def test_string_min_length(self): 90 | self._testValidation(V.String(min_length=2), 91 | valid=["foo", u"fo"], 92 | invalid=[u"f", "", False]) 93 | 94 | def test_string_max_length(self): 95 | self._testValidation(V.String(max_length=2), 96 | valid=["", "f", u"fo"], 97 | invalid=[u"foo", [1, 2, 3]]) 98 | 99 | def test_pattern(self): 100 | self._testValidation(re.compile(r"a*$"), 101 | valid=["aaa"], 102 | invalid=[u"aba", "baa"]) 103 | 104 | def test_range(self): 105 | self._testValidation(V.Range("integer", 1), 106 | valid=[1, 2, 3], 107 | invalid=[0, -1]) 108 | self._testValidation(V.Range("integer", max_value=2), 109 | valid=[-1, 0, 1, 2], 110 | invalid=[3]) 111 | self._testValidation(V.Range("integer", 1, 2), 112 | valid=[1, 2], 113 | invalid=[-1, 0, 3]) 114 | self._testValidation(V.Range(min_value=1, max_value=2), 115 | valid=[1, 2], 116 | invalid=[-1, 0, 3]) 117 | 118 | def test_homogeneous_sequence(self): 119 | for obj in V.HomogeneousSequence, V.HomogeneousSequence(): 120 | self._testValidation(obj, 121 | valid=[[], [1], (1, 2), [1, (2, 3), 4]], 122 | invalid=[1, 1.1, "foo", u"bar", {}, False, True]) 123 | self._testValidation(["number"], 124 | valid=[[], [1, 2.1, long(3)], (1, long(4), 6)], 125 | invalid=[[1, 2.1, long(3), u"x"]]) 126 | 127 | def test_heterogeneous_sequence(self): 128 | for obj in V.HeterogeneousSequence, V.HeterogeneousSequence(): 129 | self._testValidation(obj, 130 | valid=[(), []], 131 | invalid=[1, 1.1, "foo", u"bar", {}, False, True]) 132 | self._testValidation(("string", "number"), 133 | valid=[("a", 2), [u"b", 4.1]], 134 | invalid=[[], (), (2, "a"), ("a", "b"), (1, 2)]) 135 | 136 | def test_sequence_min_length(self): 137 | self._testValidation(V.HomogeneousSequence(int, min_length=2), 138 | valid=[[1, 2, 4], (1, 2)], 139 | invalid=[[1], [], (), "123", "", False]) 140 | 141 | def test_sequence_max_length(self): 142 | self._testValidation(V.HomogeneousSequence(int, max_length=2), 143 | valid=[[], (), (1,), (1, 2), [1, 2]], 144 | invalid=[[1, 2, 3], "123", "f"]) 145 | 146 | def test_mapping(self): 147 | for obj in V.Mapping, V.Mapping(): 148 | self._testValidation(obj, 149 | valid=[{}, {"foo": 3}], 150 | invalid=[1, 1.1, "foo", u"bar", [], False, True]) 151 | self._testValidation(V.Mapping("string", "number"), 152 | valid=[{"foo": 3}, 153 | {"foo": 3, u"bar": -2.1, "baz": Decimal("12.3")}], 154 | invalid=[{"foo": 3, ("bar",): -2.1}, 155 | {"foo": 3, "bar": "2.1"}]) 156 | 157 | def test_object(self): 158 | for obj in V.Object, V.Object(): 159 | self._testValidation(obj, 160 | valid=[{}, {"foo": 3}], 161 | invalid=[1, 1.1, "foo", u"bar", [], False, True]) 162 | self._testValidation({"foo": "number", "bar": "string"}, 163 | valid=[{"foo": 1, "bar": "baz"}, 164 | {"foo": 1, "bar": "baz", "quux": 42}], 165 | invalid=[{"foo": 1, "bar": []}, 166 | {"foo": "baz", "bar": 2.3}]) 167 | 168 | def test_required_properties_global(self): 169 | self._testValidation({"foo": "number", "?bar": "boolean", "baz": "string"}, 170 | valid=[{"foo": -23., "baz": "yo"}], 171 | invalid=[{}, 172 | {"bar": True}, 173 | {"baz": "yo"}, 174 | {"foo": 3}, 175 | {"bar": False, "baz": "yo"}, 176 | {"bar": True, "foo": 3.1}]) 177 | 178 | def test_required_properties_parse_parameter(self): 179 | schema = { 180 | "foo": "number", 181 | "?bar": "boolean", 182 | "?nested": [{ 183 | "baz": "string" 184 | }] 185 | } 186 | missing_properties = [{}, {"bar": True}, {"foo": 3, "nested": [{}]}] 187 | for _ in xrange(3): 188 | self._testValidation(V.parse(schema, required_properties=True), 189 | invalid=missing_properties) 190 | self._testValidation(V.parse(schema, required_properties=False), 191 | valid=missing_properties) 192 | 193 | def test_parsing_required_properties(self): 194 | get_schema = lambda: { 195 | "foo": V.Nullable("number"), 196 | "?nested": [V.Nullable({ 197 | "baz": "string" 198 | })] 199 | } 200 | valid = [{"foo": 3, "nested": [None]}] 201 | missing_properties = [{}, {"foo": 3, "nested": [{}]}] 202 | for _ in xrange(3): 203 | with V.parsing(required_properties=False): 204 | self._testValidation(get_schema(), 205 | valid=valid + missing_properties) 206 | 207 | with V.parsing(required_properties=True): 208 | self._testValidation(get_schema(), 209 | valid=valid, invalid=missing_properties) 210 | 211 | # gotcha: calling parse() with required_properties=True is not 212 | # equivalent to the above call because the V.Nullable() calls in 213 | # get_schema have already called implicitly parse() without parameters. 214 | if V.Object.REQUIRED_PROPERTIES: 215 | self._testValidation(V.parse(get_schema(), required_properties=True), 216 | invalid=[missing_properties[1]]) 217 | else: 218 | self._testValidation(V.parse(get_schema(), required_properties=True), 219 | valid=[missing_properties[1]]) 220 | 221 | def test_ignore_optional_property_errors_parse_parameter(self): 222 | schema = { 223 | "+foo": "number", 224 | "?bar": "boolean", 225 | "?nested": [{ 226 | "+baz": "string", 227 | "?zoo": "number", 228 | }] 229 | } 230 | invalid_required = [ 231 | {"foo": "2", "bar": True}, 232 | ] 233 | invalid_optional = [ 234 | {"foo": 3, "bar": "nan"}, 235 | {"foo": 3.1, "nested": [{"baz": "x", "zoo": "12"}]}, 236 | {"foo": 0, "nested": [{"baz": 1, "zoo": 2}]}, 237 | ] 238 | adapted = [ 239 | {"foo": 3}, 240 | {"foo": 3.1, "nested": [{"baz": "x"}]}, 241 | {"foo": 0}, 242 | ] 243 | for _ in xrange(3): 244 | self._testValidation(V.parse(schema, ignore_optional_property_errors=False), 245 | invalid=invalid_required + invalid_optional) 246 | self._testValidation(V.parse(schema, ignore_optional_property_errors=True), 247 | invalid=invalid_required, 248 | adapted=zip(invalid_optional, adapted)) 249 | 250 | def test_parsing_ignore_optional_property_errors(self): 251 | get_schema = lambda: V.Nullable({ 252 | "+foo": "number", 253 | "?bar": "boolean", 254 | "?nested": [{ 255 | "+baz": "string", 256 | "?zoo": "number", 257 | }] 258 | }) 259 | invalid_required = [ 260 | {"foo": "2", "bar": True}, 261 | ] 262 | invalid_optional = [ 263 | {"foo": 3, "bar": "nan"}, 264 | {"foo": 3.1, "nested": [{"baz": "x", "zoo": "12"}]}, 265 | {"foo": 0, "nested": [{"baz": 1, "zoo": 2}]}, 266 | ] 267 | adapted = [ 268 | {"foo": 3}, 269 | {"foo": 3.1, "nested": [{"baz": "x"}]}, 270 | {"foo": 0}, 271 | ] 272 | for _ in xrange(3): 273 | with V.parsing(ignore_optional_property_errors=False): 274 | self._testValidation(get_schema(), 275 | invalid=invalid_required + invalid_optional) 276 | with V.parsing(ignore_optional_property_errors=True): 277 | self._testValidation(get_schema(), 278 | invalid=invalid_required, 279 | adapted=zip(invalid_optional, adapted)) 280 | 281 | # gotcha: calling parse() with ignore_optional_property_errors=True 282 | # is not equivalent to the above call because the V.Nullable() calls in 283 | # get_schema have already called implicitly parse() without parameters. 284 | self._testValidation(V.parse(get_schema(), ignore_optional_property_errors=False), 285 | invalid=invalid_required + invalid_optional) 286 | self._testValidation(V.parse(get_schema(), ignore_optional_property_errors=True), 287 | invalid=invalid_required + invalid_optional) 288 | 289 | def test_adapt_missing_property(self): 290 | self._testValidation({"foo": "number", "?bar": V.Nullable("boolean", False)}, 291 | adapted=[({"foo": -12}, {"foo": -12, "bar": False})]) 292 | 293 | def test_no_additional_properties(self): 294 | self._testValidation(V.Object(required={"foo": "number"}, 295 | optional={"bar": "string"}, 296 | additional=False), 297 | valid=[{"foo": 23}, 298 | {"foo": -23., "bar": "yo"}], 299 | invalid=[{"foo": 23, "xyz": 1}, 300 | {"foo": -23., "bar": "yo", "xyz": 1}] 301 | ) 302 | 303 | def test_remove_additional_properties(self): 304 | self._testValidation(V.Object(required={"foo": "number"}, 305 | optional={"bar": "string"}, 306 | additional=V.Object.REMOVE), 307 | adapted=[({"foo": 23}, {"foo": 23}), 308 | ({"foo": -23., "bar": "yo"}, {"foo": -23., "bar": "yo"}), 309 | ({"foo": 23, "xyz": 1}, {"foo": 23}), 310 | ({"foo": -23., "bar": "yo", "xyz": 1}, {"foo": -23., "bar": "yo"})] 311 | ) 312 | 313 | def test_additional_properties_schema(self): 314 | self._testValidation(V.Object(required={"foo": "number"}, 315 | optional={"bar": "string"}, 316 | additional="boolean"), 317 | valid=[{"foo": 23, "bar": "yo", "x1": True, "x2": False}], 318 | invalid=[{"foo": 23, "x1": 1}, 319 | {"foo": -23., "bar": "yo", "x1": True, "x2": 0}] 320 | ) 321 | 322 | def test_additional_properties_parse_parameter(self): 323 | schema = { 324 | "?bar": "boolean", 325 | "?nested": [{ 326 | "?baz": "integer" 327 | }] 328 | } 329 | values = [{"x1": "yes"}, 330 | {"bar": True, "nested": [{"x1": "yes"}]}] 331 | for _ in xrange(3): 332 | self._testValidation(V.parse(schema, additional_properties=True), 333 | valid=values) 334 | self._testValidation(V.parse(schema, additional_properties=False), 335 | invalid=values) 336 | self._testValidation(V.parse(schema, additional_properties=V.Object.REMOVE), 337 | adapted=[(values[0], {}), 338 | (values[1], {"bar": True, "nested": [{}]})]) 339 | self._testValidation(V.parse(schema, additional_properties="string"), 340 | valid=values, 341 | invalid=[{"x1": 42}, 342 | {"bar": True, "nested": [{"x1": 42}]}]) 343 | 344 | def test_parsing_additional_properties(self): 345 | get_schema = lambda: { 346 | "?bar": "boolean", 347 | "?nested": [V.Nullable({ 348 | "?baz": "integer" 349 | })] 350 | } 351 | values = [{"x1": "yes"}, 352 | {"bar": True, "nested": [{"x1": "yes"}]}] 353 | for _ in xrange(3): 354 | with V.parsing(additional_properties=True): 355 | self._testValidation(get_schema(), valid=values) 356 | 357 | with V.parsing(additional_properties=False): 358 | self._testValidation(get_schema(), invalid=values) 359 | # gotcha: calling parse() with additional_properties=False is not 360 | # equivalent to the above call because the V.Nullable() calls in 361 | # get_schema have already called implicitly parse() without parameters. 362 | # The 'additional_properties' parameter effectively is applied at 363 | # the top level dict only 364 | self._testValidation(V.parse(get_schema(), additional_properties=False), 365 | invalid=values[:1], valid=values[1:]) 366 | 367 | with V.parsing(additional_properties=V.Object.REMOVE): 368 | self._testValidation(get_schema(), 369 | adapted=[(values[0], {}), 370 | (values[1], {"bar": True, "nested": [{}]})]) 371 | # same gotcha as above 372 | self._testValidation(V.parse(get_schema(), additional_properties=V.Object.REMOVE), 373 | adapted=[(values[0], {}), 374 | (values[1], values[1])]) 375 | 376 | with V.parsing(additional_properties="string"): 377 | self._testValidation(get_schema(), 378 | valid=values, 379 | invalid=[{"x1": 42}, 380 | {"bar": True, "nested": [{"x1": 42}]}]) 381 | # same gotcha as above 382 | self._testValidation(V.parse(get_schema(), additional_properties="string"), 383 | invalid=[{"x1": 42}], 384 | valid=[{"bar": True, "nested": [{"x1": 42}]}]) 385 | 386 | def test_nested_parsing(self): 387 | get_schema = lambda: { 388 | "bar": "integer", 389 | "?nested": [V.Nullable({ 390 | "baz": "number" 391 | })] 392 | } 393 | values = [ 394 | {"bar": 1}, 395 | {"bar": 1, "nested": [{"baz": 0}, None]}, 396 | {"bar": 1, "xx": 2}, 397 | {"bar": 1, "nested": [{"baz": 2.1, "xx": 1}]}, 398 | {}, 399 | {"bar": 1, "nested": [{}]}, 400 | ] 401 | 402 | if V.Object.REQUIRED_PROPERTIES: 403 | self._testValidation(get_schema(), 404 | valid=values[:4], invalid=values[4:]) 405 | else: 406 | self._testValidation(get_schema(), valid=values) 407 | 408 | with V.parsing(required_properties=True): 409 | self._testValidation(get_schema(), 410 | valid=values[:4], invalid=values[4:]) 411 | with V.parsing(additional_properties=False): 412 | self._testValidation(get_schema(), 413 | valid=values[:2], invalid=values[2:]) 414 | self._testValidation(get_schema(), 415 | valid=values[:4], invalid=values[4:]) 416 | 417 | if V.Object.REQUIRED_PROPERTIES: 418 | self._testValidation(get_schema(), 419 | valid=values[:4], invalid=values[4:]) 420 | else: 421 | self._testValidation(get_schema(), valid=values) 422 | 423 | def test_enum(self): 424 | self._testValidation(V.Enum([1, 2, 3]), 425 | valid=[1, 2, 3], invalid=[0, 4, "1", [1]]) 426 | self._testValidation(V.Enum([u"foo", u"bar"]), 427 | valid=["foo", "bar"], invalid=["", "fooabar", ["foo"]]) 428 | self._testValidation(V.Enum([True]), 429 | valid=[True], invalid=[False, [True]]) 430 | self._testValidation(V.Enum([{"foo": u"bar"}]), 431 | valid=[{u"foo": "bar"}]) 432 | self._testValidation(V.Enum([{"foo": u"quux"}]), 433 | invalid=[{u"foo": u"bar"}]) 434 | 435 | def test_enum_class(self): 436 | for obj in "gender", Gender, Gender(): 437 | self._testValidation(obj, 438 | valid=["male", "female", "it's complicated"], 439 | invalid=["other", ""]) 440 | 441 | def test_nullable(self): 442 | for obj in "?integer", V.Nullable(V.Integer()), V.Nullable("+integer"): 443 | self._testValidation(obj, 444 | valid=[None, 0], 445 | invalid=[1.1, True, False]) 446 | self._testValidation(V.Nullable(["?string"]), 447 | valid=[None, [], ["foo"], [None], ["foo", None]], 448 | invalid=["", [None, "foo", 1]]) 449 | 450 | def test_nullable_with_default(self): 451 | self._testValidation(V.Nullable("integer", -1), 452 | adapted=[(None, -1), (0, 0)], 453 | invalid=[1.1, True, False]) 454 | self._testValidation(V.Nullable("integer", lambda: -1), 455 | adapted=[(None, -1), (0, 0)], 456 | invalid=[1.1, True, False]) 457 | 458 | def test_nullable_with_default_object_property(self): 459 | class ObjectNullable(V.Nullable): 460 | default_object_property = property(lambda self: self.default) 461 | 462 | regular_nullables = [ 463 | "?integer", 464 | V.Nullable("integer"), 465 | V.Nullable("integer", None), 466 | V.Nullable("integer", default=None), 467 | V.Nullable("integer", lambda: None), 468 | V.Nullable("integer", default=lambda: None) 469 | ] 470 | for obj in regular_nullables: 471 | self._testValidation({"?foo": obj}, adapted=[({}, {})]) 472 | 473 | object_nullables = [ 474 | ObjectNullable("integer"), 475 | ObjectNullable("integer", None), 476 | ObjectNullable("integer", default=None), 477 | ObjectNullable("integer", lambda: None), 478 | ObjectNullable("integer", default=lambda: None), 479 | ] 480 | for obj in object_nullables: 481 | self._testValidation({"?foo": obj}, adapted=[({}, {"foo": None})]) 482 | 483 | def test_nonnullable(self): 484 | for obj in V.NonNullable, V.NonNullable(): 485 | self._testValidation(obj, 486 | invalid=[None], 487 | valid=[0, False, "", (), []]) 488 | for obj in "+integer", V.NonNullable(V.Integer()), V.NonNullable("?integer"): 489 | self._testValidation(obj, 490 | invalid=[None, False], 491 | valid=[0, long(2)]) 492 | 493 | def test_anyof(self): 494 | self._testValidation(V.AnyOf("integer", {"foo": "integer"}), 495 | valid=[1, {"foo": 1}], 496 | invalid=[{"foo": 1.1}]) 497 | 498 | def test_allof(self): 499 | self._testValidation(V.AllOf({"id": "integer"}, V.Mapping("string", "number")), 500 | valid=[{"id": 3}, {"id": 3, "bar": 4.5}], 501 | invalid=[{"id": 1.1, "bar": 4.5}, 502 | {"id": 3, "bar": True}, 503 | {"id": 3, 12: 4.5}]) 504 | 505 | self._testValidation(V.AllOf("number", 506 | lambda x: x > 0, 507 | V.AdaptBy(datetime.utcfromtimestamp)), 508 | adapted=[(1373475820, datetime(2013, 7, 10, 17, 3, 40))], 509 | invalid=["1373475820", -1373475820]) 510 | 511 | def test_chainof(self): 512 | self._testValidation(V.ChainOf(V.AdaptTo(int), 513 | V.Condition(lambda x: x > 0), 514 | V.AdaptBy(datetime.utcfromtimestamp)), 515 | adapted=[(1373475820, datetime(2013, 7, 10, 17, 3, 40)), 516 | ("1373475820", datetime(2013, 7, 10, 17, 3, 40))], 517 | invalid=["nan", -1373475820]) 518 | 519 | def test_condition(self): 520 | def is_odd(n): 521 | return n % 2 == 1 522 | is_even = lambda n: n % 2 == 0 523 | 524 | class C(object): 525 | def is_odd_method(self, n): 526 | return is_odd(n) 527 | 528 | def is_even_method(self, n): 529 | return is_even(n) 530 | is_odd_static = staticmethod(is_odd) 531 | is_even_static = staticmethod(is_even) 532 | 533 | for obj in is_odd, C().is_odd_method, C.is_odd_static: 534 | self._testValidation(obj, 535 | valid=[1, long(3), -11, 9.0, True], 536 | invalid=[6, 2.1, False, "1", []]) 537 | 538 | for obj in is_even, C().is_even_method, C.is_even_static: 539 | self._testValidation(obj, 540 | valid=[6, long(2), -42, 4.0, 0, 0.0, False], 541 | invalid=[1, 2.1, True, "2", []]) 542 | 543 | self._testValidation(str.isalnum, 544 | valid=["abc", "123", "ab32c"], 545 | invalid=["a+b", "a 1", "", True, 2]) 546 | 547 | self.assertRaises(TypeError, V.Condition, C) 548 | self.assertRaises(TypeError, V.Condition(is_even, traps=()).validate, [2, 4]) 549 | 550 | def test_condition_partial(self): 551 | def max_range(sequence, range_limit): 552 | return max(sequence) - min(sequence) <= range_limit 553 | 554 | f = wraps(max_range)(partial(max_range, range_limit=10)) 555 | 556 | for obj in f, V.Condition(f): 557 | self._testValidation(obj, 558 | valid=[xrange(11), xrange(1000, 1011)], 559 | invalid=[xrange(12), [0, 1, 2, 3, 4, 11]]) 560 | 561 | def test_adapt_ordered_dict_object(self): 562 | self._testValidation( 563 | {"foo": V.AdaptTo(int), "bar": V.AdaptTo(float)}, 564 | adapted=[( 565 | collections.OrderedDict([("foo", "1"), ("bar", "2")]), 566 | collections.OrderedDict([("foo", 1), ("bar", 2.0)]) 567 | )]) 568 | 569 | def test_adapt_ordered_dict_mapping(self): 570 | self._testValidation( 571 | V.Mapping("string", V.AdaptTo(float)), 572 | adapted=[( 573 | collections.OrderedDict([("foo", "1"), ("bar", "2")]), 574 | collections.OrderedDict([("foo", 1.0), ("bar", 2.0)]) 575 | )]) 576 | 577 | def test_adapt_by(self): 578 | self._testValidation(V.AdaptBy(hex, traps=TypeError), 579 | invalid=[1.2, "1"], 580 | adapted=[(255, "0xff"), (0, "0x0")]) 581 | self._testValidation(V.AdaptBy(int, traps=(ValueError, TypeError)), 582 | invalid=["12b", "1.2", {}, (), []], 583 | adapted=[(12, 12), ("12", 12), (1.2, 1)]) 584 | self.assertRaises(TypeError, V.AdaptBy(hex, traps=()).validate, 1.2) 585 | 586 | def test_adapt_to(self): 587 | self.assertRaises(TypeError, V.AdaptTo, hex) 588 | for exact in False, True: 589 | self._testValidation(V.AdaptTo(int, traps=(ValueError, TypeError), exact=exact), 590 | invalid=["12b", "1.2", {}, (), []], 591 | adapted=[(12, 12), ("12", 12), (1.2, 1)]) 592 | 593 | class smallint(int): 594 | pass 595 | i = smallint(2) 596 | self.assertIs(V.AdaptTo(int).validate(i), i) 597 | self.assertIsNot(V.AdaptTo(int, exact=True).validate(i), i) 598 | 599 | def test_fraction(self): 600 | for obj in "fraction", Fraction, Fraction(): 601 | self._testValidation(obj, 602 | valid=[1.1, 0j, 5 + 3j, Decimal(1) / Decimal(8)], 603 | invalid=[1, "foo", u"bar", {}, [], False, True]) 604 | 605 | def test_reject_types(self): 606 | ExceptionValidator = V.Type(accept_types=Exception, reject_types=Warning) 607 | ExceptionValidator.validate(KeyError()) 608 | self.assertRaises(V.ValidationError, ExceptionValidator.validate, UserWarning()) 609 | 610 | def test_accepts(self): 611 | @V.accepts(a="fraction", b=int, body={"+field_ids": ["integer"], 612 | "?is_ok": bool, 613 | "?sex": "gender"}) 614 | def f(a, b=1, **body): 615 | pass 616 | 617 | valid = [ 618 | partial(f, 2.0, field_ids=[]), 619 | partial(f, Decimal(1), b=5, field_ids=[1], is_ok=True), 620 | partial(f, a=3j, b=-1, field_ids=[long(1), 2, long(5)], sex="male"), 621 | partial(f, 5 + 3j, 0, field_ids=[long(-12), 0, long(0)], is_ok=False, sex="female"), 622 | partial(f, 2.0, field_ids=[], additional="extra param allowed"), 623 | ] 624 | 625 | invalid = [ 626 | partial(f, 1), # 'a' is not a fraction 627 | partial(f, 1.0), # missing 'field_ids' from body 628 | partial(f, 1.0, b=4.1, field_ids=[]), # 'b' is not int 629 | partial(f, 1.0, b=2, field_ids=3), # 'field_ids' is not a list 630 | partial(f, 1.0, b=1, field_ids=[3.0]), # 'field_ids[0]' is not a integer 631 | partial(f, 1.0, b=1, field_ids=[], is_ok=1), # 'is_ok' is not bool 632 | partial(f, 1.0, b=1, field_ids=[], sex="m"), # 'sex' is not a gender 633 | ] 634 | 635 | for fcall in valid: 636 | fcall() 637 | for fcall in invalid: 638 | self.assertRaises(V.ValidationError, fcall) 639 | 640 | def test_returns(self): 641 | @V.returns(int) 642 | def f(a): 643 | return a 644 | 645 | @V.returns(V.Type(type(None))) 646 | def g(a=True): 647 | if a: 648 | return a 649 | else: 650 | pass 651 | 652 | valid = [ 653 | partial(f, 1), 654 | partial(g, False), 655 | ] 656 | 657 | invalid = [ 658 | partial(f, 1.0), 659 | partial(f, 'x'), 660 | partial(g, True), 661 | ] 662 | 663 | for fcall in valid: 664 | fcall() 665 | for fcall in invalid: 666 | self.assertRaises(V.ValidationError, fcall) 667 | 668 | def test_adapts(self): 669 | @V.adapts(body={"+field_ids": ["integer"], 670 | "?scores": V.Mapping("string", float), 671 | "?users": [{ 672 | "+name": ("+string", "+string"), 673 | "?sex": "gender", 674 | "?active": V.Nullable("boolean", True), 675 | }]}) 676 | def f(body): 677 | return body 678 | 679 | adapted = f({ 680 | "field_ids": [1, 5], 681 | "scores": {"foo": 23.1, "bar": 2.0}, 682 | "users": [ 683 | {"name": ("Nick", "C"), "sex": "male"}, 684 | {"name": ("Kim", "B"), "active": False}, 685 | {"name": ("Joe", "M"), "active": None}, 686 | ]}) 687 | 688 | self.assertEqual(adapted["field_ids"], [1, 5]) 689 | self.assertEqual(adapted["scores"]["foo"], 23.1) 690 | self.assertEqual(adapted["scores"]["bar"], 2.0) 691 | 692 | self.assertEqual(adapted["users"][0]["name"], ("Nick", "C")) 693 | self.assertEqual(adapted["users"][0]["sex"], "male") 694 | self.assertEqual(adapted["users"][0]["active"], True) 695 | 696 | self.assertEqual(adapted["users"][1]["name"], ("Kim", "B")) 697 | self.assertEqual(adapted["users"][1].get("sex"), None) 698 | self.assertEqual(adapted["users"][1]["active"], False) 699 | 700 | self.assertEqual(adapted["users"][2]["name"], ("Joe", "M")) 701 | self.assertEqual(adapted["users"][2].get("sex"), None) 702 | self.assertEqual(adapted["users"][2].get("active"), True) 703 | 704 | invalid = [ 705 | # missing 'field_ids' from body 706 | partial(f, {}), 707 | # score value is not float 708 | partial(f, {"field_ids": [], "scores":{"a": "2.3"}}), 709 | # 'name' is not a length-2 tuple 710 | partial(f, {"field_ids": [], "users":[{"name": ("Bob", "R", "Junior")}]}), 711 | # name[1] is not a string 712 | partial(f, {"field_ids": [], "users":[{"name": ("Bob", 12)}]}), 713 | # name[1] is required 714 | partial(f, {"field_ids": [], "users":[{"name": ("Bob", None)}]}), 715 | ] 716 | for fcall in invalid: 717 | self.assertRaises(V.ValidationError, fcall) 718 | 719 | def test_adapts_varargs(self): 720 | @V.adapts(a="integer", 721 | b="number", 722 | nums=["number"]) 723 | def f(a, b=1, *nums, **params): 724 | return a * b + sum(nums) 725 | 726 | self.assertEqual(f(2), 2) 727 | self.assertEqual(f(2, b=2), 4) 728 | self.assertEqual(f(2, 2.5, 3), 8) 729 | self.assertEqual(f(2, 2.5, 3, -2.5), 5.5) 730 | 731 | def test_adapts_kwargs(self): 732 | @V.adapts(a="integer", 733 | b="number", 734 | params={"?foo": int, "?bar": float}) 735 | def f(a, b=1, **params): 736 | return a * b + params.get("foo", 1) * params.get("bar", 0.0) 737 | 738 | self.assertEqual(f(1), 1) 739 | self.assertEqual(f(1, 2), 2) 740 | self.assertEqual(f(1, b=2.5, foo=3), 2.5) 741 | self.assertEqual(f(1, b=2.5, bar=3.5), 6.0) 742 | self.assertEqual(f(1, foo=2, bar=3.5), 8.0) 743 | self.assertEqual(f(1, b=2.5, foo=2, bar=3.5), 9.5) 744 | 745 | def test_adapts_varargs_kwargs(self): 746 | @V.adapts(a="integer", 747 | b="number", 748 | nums=["number"], 749 | params={"?foo": int, "?bar": float}) 750 | def f(a, b=1, *nums, **params): 751 | return a * b + sum(nums) + params.get("foo", 1) * params.get("bar", 0.0) 752 | 753 | self.assertEqual(f(2), 2) 754 | self.assertEqual(f(2, b=2), 4) 755 | self.assertEqual(f(2, 2.5, 3), 8) 756 | self.assertEqual(f(2, 2.5, 3, -2.5), 5.5) 757 | self.assertEqual(f(1, b=2.5, foo=3), 2.5) 758 | self.assertEqual(f(1, b=2.5, bar=3.5), 6.0) 759 | self.assertEqual(f(1, foo=2, bar=3.5), 8.0) 760 | self.assertEqual(f(1, b=2.5, foo=2, bar=3.5), 9.5) 761 | self.assertEqual(f(2, 2.5, 3, foo=2), 8.0) 762 | self.assertEqual(f(2, 2.5, 3, bar=3.5), 11.5) 763 | self.assertEqual(f(2, 2.5, 3, foo=2, bar=3.5), 15.0) 764 | 765 | def test_schema_errors(self): 766 | for obj in [ 767 | True, 768 | 1, 769 | 3.2, 770 | "foo", 771 | object(), 772 | ["foo"], 773 | {"field": "foo"}, 774 | ]: 775 | self.assertRaises(V.SchemaError, self.parse, obj) 776 | 777 | def test_not_implemented_validation(self): 778 | class MyValidator(V.Validator): 779 | pass 780 | 781 | validator = MyValidator() 782 | self.assertRaises(NotImplementedError, validator.validate, 1) 783 | 784 | def test_register(self): 785 | for register in V.register, V.Validator.register: 786 | register("to_int", V.AdaptTo(int, traps=(ValueError, TypeError))) 787 | self._testValidation("to_int", 788 | invalid=["12b", "1.2"], 789 | adapted=[(12, 12), ("12", 12), (1.2, 1)]) 790 | 791 | self.assertRaises(TypeError, register, "to_int", int) 792 | 793 | def test_complex_validation(self): 794 | 795 | for valid in [ 796 | {'n': 2}, 797 | {'n': 2.1, 'i': 3}, 798 | {'n': -1, 'b': False}, 799 | {'n': Decimal(3), 'e': "r"}, 800 | {'n': long(2), 'd': datetime.now()}, 801 | {'n': 0, 'd': date.today()}, 802 | {'n': 0, 's': "abc"}, 803 | {'n': 0, 'p': None}, 804 | {'n': 0, 'p': "123"}, 805 | {'n': 0, 'l': []}, 806 | {'n': 0, 'l': [{"s2": "foo"}, {"s2": ""}]}, 807 | {'n': 0, 't': (u"joe", 3.1)}, 808 | {'n': 0, 'h': {5: ["foo", u"bar"], 0: []}}, 809 | {'n': 0, 'o': {"i2": 3}}, 810 | ]: 811 | self.complex_validator.validate(valid, adapt=False) 812 | 813 | for invalid in [ 814 | None, 815 | {}, 816 | {'n': None}, 817 | {'n': True}, 818 | {'n': 1, 'e': None}, 819 | {'n': 1, 'e': "a"}, 820 | {'n': 1, 'd': None}, 821 | {'n': 1, 's': None}, 822 | {'n': 1, 's': ''}, 823 | {'n': 1, 's': '123456789'}, 824 | {'n': 1, 'p': '123a'}, 825 | {'n': 1, 'l': None}, 826 | {'n': 1, 'l': [None]}, 827 | {'n': 1, 'l': [{}]}, 828 | {'n': 1, 'l': [{'s2': None}]}, 829 | {'n': 1, 'l': [{'s2': 1}]}, 830 | {'n': 1, 't': ()}, 831 | {'n': 0, 't': (3.1, u"joe")}, 832 | {'n': 0, 't': (u"joe", None)}, 833 | {'n': 1, 'h': {5: ["foo", u"bar"], "0": []}}, 834 | {'n': 1, 'h': {5: ["foo", 2.1], 0: []}}, 835 | {'n': 1, 'o': {}}, 836 | {'n': 1, 'o': {"i2": "2"}}, 837 | ]: 838 | self.assertRaises(V.ValidationError, 839 | self.complex_validator.validate, invalid, adapt=False) 840 | 841 | def test_complex_adaptation(self): 842 | for value in [ 843 | {'n': 2}, 844 | {'n': 2.1, 'i': 3}, 845 | {'n': -1, 'b': False}, 846 | {'n': Decimal(3), 'e': "r"}, 847 | {'n': long(2), 'd': datetime.now()}, 848 | {'n': 0, 'd': date.today()}, 849 | {'n': 0, 's': "abc"}, 850 | {'n': 0, 'p': None}, 851 | {'n': 0, 'p': "123"}, 852 | {'n': 0, 'l': []}, 853 | {'n': 0, 'l': [{"s2": "foo"}, {"s2": ""}]}, 854 | {'n': 0, 't': (u"joe", 3.1)}, 855 | {'n': 0, 'h': {5: ["foo", u"bar"], 0: []}}, 856 | {'n': 0, 'o': {"i2": 3}}, 857 | ]: 858 | adapted = self.complex_validator.validate(value) 859 | self.assertTrue(isinstance(adapted["n"], (int, long, float, Decimal))) 860 | self.assertTrue(isinstance(adapted["i"], int_types)) 861 | self.assertTrue(adapted.get("b") is None or isinstance(adapted["b"], bool)) 862 | self.assertTrue(adapted.get("d") is None or isinstance(adapted["d"], (date, datetime))) 863 | self.assertTrue(adapted.get("e") is None or adapted["e"] in "rgb") 864 | self.assertTrue(adapted.get("s") is None or isinstance(adapted["s"], string_types)) 865 | self.assertTrue(adapted.get("l") is None or isinstance(adapted["l"], list)) 866 | self.assertTrue(adapted.get("t") is None or isinstance(adapted["t"], tuple)) 867 | self.assertTrue(adapted.get("h") is None or isinstance(adapted["h"], dict)) 868 | if adapted.get("l") is not None: 869 | self.assertTrue(all(isinstance(item["s2"], string_types) 870 | for item in adapted["l"])) 871 | if adapted.get("t") is not None: 872 | self.assertEqual(len(adapted["t"]), 2) 873 | self.assertTrue(isinstance(adapted["t"][0], unicode)) 874 | self.assertTrue(isinstance(adapted["t"][1], float)) 875 | if adapted.get("h") is not None: 876 | self.assertTrue(all(isinstance(key, int) 877 | for key in adapted["h"].keys())) 878 | self.assertTrue(all(isinstance(value_item, string_types) 879 | for value in adapted["h"].values() 880 | for value_item in value)) 881 | if adapted.get("o") is not None: 882 | self.assertTrue(isinstance(adapted["o"]["i2"], int_types)) 883 | 884 | def test_humanized_names(self): 885 | class DummyValidator(V.Validator): 886 | name = "dummy" 887 | 888 | def validate(self, value, adapt=True): 889 | return value 890 | 891 | self.assertEqual(DummyValidator().humanized_name, "dummy") 892 | self.assertEqual(V.Nullable(DummyValidator()).humanized_name, "dummy or null") 893 | self.assertEqual(V.AnyOf("boolean", DummyValidator()).humanized_name, 894 | "boolean or dummy") 895 | self.assertEqual(V.AllOf("boolean", DummyValidator()).humanized_name, 896 | "boolean and dummy") 897 | self.assertEqual(V.ChainOf("boolean", DummyValidator()).humanized_name, 898 | "boolean chained to dummy") 899 | self.assertEqual(Date().humanized_name, "date or datetime") 900 | 901 | def test_error_message(self): 902 | self._testValidation({"+foo": "number", "?bar": ["integer"]}, errors=[ 903 | (42, 904 | "Invalid value 42 (int): must be Mapping"), 905 | ({}, 906 | "Invalid value {} (dict): missing required properties: ['foo']"), 907 | ({"foo": "3"}, 908 | "Invalid value '3' (str): must be number (at foo)"), 909 | ({"foo": 3, "bar": None}, 910 | "Invalid value None (NoneType): must be Sequence (at bar)"), 911 | ({"foo": 3, "bar": [1, "2", 3]}, 912 | "Invalid value '2' (str): must be integer (at bar[1])"), 913 | ]) 914 | 915 | def test_error_properties(self): 916 | for contexts in [], ['bar'], ['bar', 'baz']: 917 | ex = V.ValidationError('foo') 918 | for context in contexts: 919 | ex.add_context(context) 920 | self.assertEqual(ex.message, str(ex)) 921 | self.assertEqual(ex.args, (str(ex),)) 922 | 923 | def test_error_message_custom_repr_value(self): 924 | self._testValidation({"+foo": "number", "?bar": ["integer"]}, 925 | error_value_repr=json.dumps, 926 | errors= 927 | [(42, "Invalid value 42 (int): must be Mapping"), 928 | ({}, "Invalid value {} (dict): missing required properties: ['foo']"), 929 | ({"foo": "3"}, 930 | 'Invalid value "3" (str): must be number (at foo)'), 931 | ({"foo": [3]}, 932 | 'Invalid value [3] (list): must be number (at foo)'), 933 | ({"foo": 3, "bar": None}, 934 | "Invalid value null (NoneType): must be Sequence (at bar)"), 935 | ({"foo": 3, "bar": False}, 936 | "Invalid value false (bool): must be Sequence (at bar)"), 937 | ({"foo": 3, "bar": [1, {u'a': 3}, 3]}, 938 | 'Invalid value {"a": 3} (dict): must be integer (at bar[1])')]) 939 | 940 | def test_error_message_json_type_names(self): 941 | V.set_name_for_types("null", type(None)) 942 | V.set_name_for_types("integer", int, long) 943 | V.set_name_for_types("number", float) 944 | V.set_name_for_types("string", str, unicode) 945 | V.set_name_for_types("array", list, collections.Sequence) 946 | V.set_name_for_types("object", dict, collections.Mapping) 947 | 948 | self._testValidation({"+foo": "number", 949 | "?bar": ["integer"], 950 | "?baz": V.AnyOf("number", ["number"]), 951 | "?opt": "?string"}, 952 | errors= 953 | [(42, "Invalid value 42 (integer): must be object"), 954 | ({}, 955 | "Invalid value {} (object): missing required properties: ['foo']"), 956 | ({"foo": "3"}, 957 | "Invalid value '3' (string): must be number (at foo)"), 958 | ({"foo": None}, 959 | "Invalid value None (null): must be number (at foo)"), 960 | ({"foo": 3, "bar": None}, 961 | "Invalid value None (null): must be array (at bar)"), 962 | ({"foo": 3, "bar": [1, "2", 3]}, 963 | "Invalid value '2' (string): must be integer (at bar[1])"), 964 | ({"foo": 3, "baz": "23"}, 965 | "Invalid value '23' (string): must be number or must be array (at baz)"), 966 | ({"foo": 3, "opt": 12}, 967 | "Invalid value 12 (integer): must be string (at opt)")]) 968 | 969 | def _testValidation(self, obj, invalid=(), valid=(), adapted=(), errors=(), 970 | error_value_repr=repr): 971 | validator = self.parse(obj) 972 | for from_value, to_value in [(value, value) for value in valid] + list(adapted): 973 | self.assertTrue(validator.is_valid(from_value)) 974 | validator.validate(from_value, adapt=False) 975 | adapted_value = validator.validate(from_value, adapt=True) 976 | self.assertIs(adapted_value.__class__, to_value.__class__) 977 | self.assertEqual(adapted_value, to_value) 978 | for value, error in [(value, None) for value in invalid] + list(errors): 979 | self.assertFalse(validator.is_valid(value)) 980 | for adapt in True, False: 981 | try: 982 | validator.validate(value, adapt=adapt) 983 | except V.ValidationError as ex: 984 | if error: 985 | error_repr = ex.to_string(error_value_repr) 986 | self.assertEqual(error_repr, error, "Actual error: %r" % error_repr) 987 | 988 | 989 | class TestValidatorModuleParse(TestValidator): 990 | 991 | parse = staticmethod(V.Validator.parse) 992 | 993 | 994 | class OptionalPropertiesTestValidator(TestValidator): 995 | 996 | def setUp(self): 997 | super(OptionalPropertiesTestValidator, self).setUp() 998 | V.Object.REQUIRED_PROPERTIES = False 999 | self.complex_validator = self.parse({ 1000 | "+n": "+number", 1001 | "i": V.Nullable("integer", 0), 1002 | "b": bool, 1003 | "e": V.Enum(["r", "g", "b"]), 1004 | "d": V.AnyOf("date", "datetime"), 1005 | "s": V.String(min_length=1, max_length=8), 1006 | "p": V.Nullable(re.compile(r"\d{1,4}$")), 1007 | "l": [{"+s2": "string"}], 1008 | "t": (unicode, "number"), 1009 | "h": V.Mapping(int, ["string"]), 1010 | "o": V.NonNullable({"+i2": "integer"}), 1011 | }) 1012 | 1013 | def test_required_properties_global(self): 1014 | self._testValidation({"+foo": "number", "bar": "boolean", "+baz": "string"}, 1015 | valid=[{"foo": -23., "baz": "yo"}], 1016 | invalid=[{}, 1017 | {"bar": True}, 1018 | {"baz": "yo"}, 1019 | {"foo": 3}, 1020 | {"bar": False, "baz": "yo"}, 1021 | {"bar": True, "foo": 3.1}]) 1022 | 1023 | 1024 | if __name__ == '__main__': 1025 | unittest.main() 1026 | -------------------------------------------------------------------------------- /valideer/validators.py: -------------------------------------------------------------------------------- 1 | from .base import Validator, ValidationError, parse, get_type_name 2 | from .compat import string_types, izip, imap, iteritems 3 | import collections 4 | import datetime 5 | import inspect 6 | import numbers 7 | import re 8 | 9 | __all__ = [ 10 | "AnyOf", "AllOf", "ChainOf", "Nullable", "NonNullable", 11 | "Enum", "Condition", "AdaptBy", "AdaptTo", 12 | "Type", "Boolean", "Integer", "Number", "Range", 13 | "String", "Pattern", "Date", "Datetime", "Time", 14 | "HomogeneousSequence", "HeterogeneousSequence", "Mapping", "Object", 15 | ] 16 | 17 | 18 | class AnyOf(Validator): 19 | """A composite validator that accepts values accepted by any of its component 20 | validators. 21 | 22 | In case of adaptation, the first validator to successfully adapt the value 23 | is used. 24 | """ 25 | 26 | def __init__(self, *schemas): 27 | self._validators = list(imap(parse, schemas)) 28 | 29 | def validate(self, value, adapt=True): 30 | msgs = [] 31 | for validator in self._validators: 32 | try: 33 | return validator.validate(value, adapt) 34 | except ValidationError as ex: 35 | msgs.append(ex.msg) 36 | raise ValidationError(" or ".join(msgs), value) 37 | 38 | @property 39 | def humanized_name(self): 40 | return " or ".join(v.humanized_name for v in self._validators) 41 | 42 | 43 | class AllOf(Validator): 44 | """A composite validator that accepts values accepted by all of its component 45 | validators. 46 | 47 | In case of adaptation, the adapted value from the last validator is returned. 48 | """ 49 | 50 | def __init__(self, *schemas): 51 | self._validators = list(imap(parse, schemas)) 52 | 53 | def validate(self, value, adapt=True): 54 | result = value 55 | for validator in self._validators: 56 | result = validator.validate(value, adapt) 57 | return result 58 | 59 | @property 60 | def humanized_name(self): 61 | return " and ".join(v.humanized_name for v in self._validators) 62 | 63 | 64 | class ChainOf(Validator): 65 | """A composite validator that passes a value through a sequence of validators. 66 | 67 | value -> validator1 -> value2 -> validator2 -> ... -> validatorN -> final_value 68 | """ 69 | 70 | def __init__(self, *schemas): 71 | self._validators = list(imap(parse, schemas)) 72 | 73 | def validate(self, value, adapt=True): 74 | for validator in self._validators: 75 | value = validator.validate(value, adapt) 76 | return value 77 | 78 | @property 79 | def humanized_name(self): 80 | return " chained to ".join(v.humanized_name for v in self._validators) 81 | 82 | 83 | class Nullable(Validator): 84 | """A validator that also accepts ``None``. 85 | 86 | ``None`` is adapted to ``default``. ``default`` can also be a zero-argument 87 | callable, in which case ``None`` is adapted to ``default()``. 88 | 89 | The :py:class:`Object` validator sets the value of missing properties with 90 | :py:class:`Nullable` schema to the respective ``default`` if and only if 91 | the ``default`` is not ``None``. If a different behaviour is desired (e.g. 92 | to always set the value to ``default`` even when it is ``None``), you can 93 | subclass :py:class:`Nullable`` and override the :py:meth:`default_object_property` 94 | property. 95 | """ 96 | 97 | _UNDEFINED = object() 98 | 99 | def __init__(self, schema, default=None): 100 | if isinstance(schema, Validator): 101 | self._validator = schema 102 | else: 103 | validator = parse(schema) 104 | if isinstance(validator, (Nullable, NonNullable)): 105 | validator = validator._validator 106 | self._validator = validator 107 | self._default = default 108 | 109 | def validate(self, value, adapt=True): 110 | if value is None: 111 | return self.default 112 | return self._validator.validate(value, adapt) 113 | 114 | @property 115 | def default(self): 116 | default = self._default 117 | return default if not callable(default) else default() 118 | 119 | @property 120 | def default_object_property(self): 121 | default = self.default 122 | return default if default is not None else self._UNDEFINED 123 | 124 | @property 125 | def humanized_name(self): 126 | return "%s or null" % self._validator.humanized_name 127 | 128 | 129 | @Nullable.register_factory 130 | def _NullableFactory(obj): 131 | """Parse a string starting with "?" as a Nullable validator.""" 132 | if isinstance(obj, string_types) and obj.startswith("?"): 133 | return Nullable(obj[1:]) 134 | 135 | 136 | class NonNullable(Validator): 137 | """A validator that accepts anything but ``None``.""" 138 | 139 | def __init__(self, schema=None): 140 | if schema is not None and not isinstance(schema, Validator): 141 | validator = parse(schema) 142 | if isinstance(validator, (Nullable, NonNullable)): 143 | validator = validator._validator 144 | self._validator = validator 145 | else: 146 | self._validator = schema 147 | 148 | def validate(self, value, adapt=True): 149 | if value is None: 150 | self.error(value) 151 | if self._validator is not None: 152 | return self._validator.validate(value, adapt) 153 | return value 154 | 155 | @property 156 | def humanized_name(self): 157 | return self._validator.humanized_name if self._validator else "non null" 158 | 159 | 160 | @NonNullable.register_factory 161 | def _NonNullableFactory(obj): 162 | """Parse a string starting with "+" as an NonNullable validator.""" 163 | if isinstance(obj, string_types) and obj.startswith("+"): 164 | return NonNullable(obj[1:]) 165 | 166 | 167 | class Enum(Validator): 168 | """A validator that accepts only a finite set of values. 169 | 170 | Attributes: 171 | - values: The collection of valid values. 172 | """ 173 | 174 | values = () 175 | 176 | def __init__(self, values=None): 177 | super(Enum, self).__init__() 178 | if values is None: 179 | values = self.values 180 | try: 181 | self.values = set(values) 182 | except TypeError: # unhashable 183 | self.values = list(values) 184 | 185 | def validate(self, value, adapt=True): 186 | try: 187 | if value in self.values: 188 | return value 189 | except TypeError: # unhashable 190 | pass 191 | self.error(value) 192 | 193 | @property 194 | def humanized_name(self): 195 | return "one of {%s}" % ", ".join(list(imap(repr, self.values))) 196 | 197 | 198 | class Condition(Validator): 199 | """A validator that accepts a value using a callable ``predicate``. 200 | 201 | A value is accepted if ``predicate(value)`` is true. 202 | """ 203 | 204 | def __init__(self, predicate, traps=Exception): 205 | if not (callable(predicate) and not inspect.isclass(predicate)): 206 | raise TypeError("Callable expected, %s given" % predicate.__class__) 207 | self._predicate = predicate 208 | self._traps = traps 209 | 210 | def validate(self, value, adapt=True): 211 | if self._traps: 212 | try: 213 | is_valid = self._predicate(value) 214 | except self._traps: 215 | is_valid = False 216 | else: 217 | is_valid = self._predicate(value) 218 | 219 | if not is_valid: 220 | self.error(value) 221 | 222 | return value 223 | 224 | def error(self, value): 225 | raise ValidationError("must satisfy predicate %s" % self.humanized_name, value) 226 | 227 | @property 228 | def humanized_name(self): 229 | return str(getattr(self._predicate, "__name__", self._predicate)) 230 | 231 | 232 | @Condition.register_factory 233 | def _ConditionFactory(obj): 234 | """Parse a callable as a Condition validator.""" 235 | if callable(obj) and not inspect.isclass(obj): 236 | return Condition(obj) 237 | 238 | 239 | class AdaptBy(Validator): 240 | """A validator that adapts a value using an ``adaptor`` callable.""" 241 | 242 | def __init__(self, adaptor, traps=Exception): 243 | """Instantiate this validator. 244 | 245 | :param adaptor: The callable ``f(value)`` to adapt values. 246 | :param traps: An exception or a tuple of exceptions to catch and wrap 247 | into a :py:exc:`ValidationError`. Any other raised exception is 248 | left to propagate. 249 | """ 250 | self._adaptor = adaptor 251 | self._traps = traps 252 | 253 | def validate(self, value, adapt=True): 254 | if not self._traps: 255 | return self._adaptor(value) 256 | try: 257 | return self._adaptor(value) 258 | except self._traps as ex: 259 | raise ValidationError(str(ex), value) 260 | 261 | 262 | class AdaptTo(AdaptBy): 263 | """A validator that adapts a value to a target class.""" 264 | 265 | def __init__(self, target_cls, traps=Exception, exact=False): 266 | """Instantiate this validator. 267 | 268 | :param target_cls: The target class. 269 | :param traps: An exception or a tuple of exceptions to catch and wrap 270 | into a :py:exc:`ValidationError`. Any other raised exception is left 271 | to propagate. 272 | :param exact: If False, instances of ``target_cls`` or a subclass are 273 | returned as is. If True, only instances of ``target_cls`` are 274 | returned as is. 275 | """ 276 | if not inspect.isclass(target_cls): 277 | raise TypeError("Type expected, %s given" % target_cls.__class__) 278 | self._exact = exact 279 | super(AdaptTo, self).__init__(target_cls, traps) 280 | 281 | def validate(self, value, adapt=True): 282 | if isinstance(value, self._adaptor) and (not self._exact or 283 | value.__class__ == self._adaptor): 284 | return value 285 | return super(AdaptTo, self).validate(value, adapt) 286 | 287 | 288 | class Type(Validator): 289 | """A validator accepting values that are instances of one or more given types. 290 | 291 | Attributes: 292 | - accept_types: A type or tuple of types that are valid. 293 | - reject_types: A type or tuple of types that are invalid. 294 | """ 295 | 296 | accept_types = () 297 | reject_types = () 298 | 299 | def __init__(self, accept_types=None, reject_types=None): 300 | if accept_types is not None: 301 | self.accept_types = accept_types 302 | if reject_types is not None: 303 | self.reject_types = reject_types 304 | 305 | def validate(self, value, adapt=True): 306 | if not isinstance(value, self.accept_types) or isinstance(value, self.reject_types): 307 | self.error(value) 308 | return value 309 | 310 | @property 311 | def humanized_name(self): 312 | return self.name or _format_types(self.accept_types) 313 | 314 | 315 | @Type.register_factory 316 | def _TypeFactory(obj): 317 | """Parse a python type (or "old-style" class) as a :py:class:`Type` instance.""" 318 | if inspect.isclass(obj): 319 | return Type(obj) 320 | 321 | 322 | class Boolean(Type): 323 | """A validator that accepts bool values.""" 324 | 325 | name = "boolean" 326 | accept_types = bool 327 | 328 | 329 | class Integer(Type): 330 | """ 331 | A validator that accepts integers (:py:class:`numbers.Integral` instances) 332 | but not bool. 333 | """ 334 | 335 | name = "integer" 336 | accept_types = numbers.Integral 337 | reject_types = bool 338 | 339 | 340 | class Range(Validator): 341 | """A validator that accepts values within in a certain range.""" 342 | 343 | def __init__(self, schema=None, min_value=None, max_value=None): 344 | """Instantiate a :py:class:`Range` validator. 345 | 346 | :param schema: Optional schema or validator for the value. 347 | :param min_value: If not None, values less than ``min_value`` are 348 | invalid. 349 | :param max_value: If not None, values larger than ``max_value`` are 350 | invalid. 351 | """ 352 | super(Range, self).__init__() 353 | self._validator = parse(schema) if schema is not None else None 354 | self._min_value = min_value 355 | self._max_value = max_value 356 | 357 | def validate(self, value, adapt=True): 358 | if self._validator is not None: 359 | value = self._validator.validate(value, adapt=adapt) 360 | 361 | if self._min_value is not None and value < self._min_value: 362 | raise ValidationError("must not be less than %d" % 363 | self._min_value, value) 364 | if self._max_value is not None and value > self._max_value: 365 | raise ValidationError("must not be larger than %d" % 366 | self._max_value, value) 367 | 368 | return value 369 | 370 | 371 | class Number(Type): 372 | """A validator that accepts any numbers (but not bool).""" 373 | 374 | name = "number" 375 | accept_types = numbers.Number 376 | reject_types = bool 377 | 378 | 379 | class Date(Type): 380 | """A validator that accepts :py:class:`datetime.date` values.""" 381 | 382 | name = "date" 383 | accept_types = datetime.date 384 | 385 | 386 | class Datetime(Type): 387 | """A validator that accepts :py:class:`datetime.datetime` values.""" 388 | 389 | name = "datetime" 390 | accept_types = datetime.datetime 391 | 392 | 393 | class Time(Type): 394 | """A validator that accepts :py:class:`datetime.time` values.""" 395 | 396 | name = "time" 397 | accept_types = datetime.time 398 | 399 | 400 | class String(Type): 401 | """A validator that accepts string values.""" 402 | 403 | name = "string" 404 | accept_types = string_types 405 | 406 | def __init__(self, min_length=None, max_length=None): 407 | """Instantiate a String validator. 408 | 409 | :param min_length: If not None, strings shorter than ``min_length`` are 410 | invalid. 411 | :param max_length: If not None, strings longer than ``max_length`` are 412 | invalid. 413 | """ 414 | super(String, self).__init__() 415 | self._min_length = min_length 416 | self._max_length = max_length 417 | 418 | def validate(self, value, adapt=True): 419 | super(String, self).validate(value) 420 | if self._min_length is not None and len(value) < self._min_length: 421 | raise ValidationError("must be at least %d characters long" % 422 | self._min_length, value) 423 | if self._max_length is not None and len(value) > self._max_length: 424 | raise ValidationError("must be at most %d characters long" % 425 | self._max_length, value) 426 | return value 427 | 428 | 429 | _SRE_Pattern = type(re.compile("")) 430 | 431 | 432 | class Pattern(String): 433 | """A validator that accepts strings that match a given regular expression. 434 | 435 | Attributes: 436 | - regexp: The regular expression (string or compiled) to be matched. 437 | """ 438 | 439 | regexp = None 440 | 441 | def __init__(self, regexp=None): 442 | super(Pattern, self).__init__() 443 | self.regexp = re.compile(regexp or self.regexp) 444 | 445 | def validate(self, value, adapt=True): 446 | super(Pattern, self).validate(value) 447 | if not self.regexp.match(value): 448 | self.error(value) 449 | return value 450 | 451 | def error(self, value): 452 | raise ValidationError("must match %s" % self.humanized_name, value) 453 | 454 | @property 455 | def humanized_name(self): 456 | return "pattern %s" % self.regexp.pattern 457 | 458 | 459 | @Pattern.register_factory 460 | def _PatternFactory(obj): 461 | """Parse a compiled regexp as a :py:class:`Pattern` instance.""" 462 | if isinstance(obj, _SRE_Pattern): 463 | return Pattern(obj) 464 | 465 | 466 | class HomogeneousSequence(Type): 467 | """A validator that accepts homogeneous, non-fixed size sequences.""" 468 | 469 | accept_types = collections.Sequence 470 | reject_types = string_types 471 | 472 | def __init__(self, item_schema=None, min_length=None, max_length=None): 473 | """Instantiate a :py:class:`HomogeneousSequence` validator. 474 | 475 | :param item_schema: If not None, the schema of the items of the list. 476 | """ 477 | super(HomogeneousSequence, self).__init__() 478 | if item_schema is not None: 479 | self._item_validator = parse(item_schema) 480 | else: 481 | self._item_validator = None 482 | self._min_length = min_length 483 | self._max_length = max_length 484 | 485 | def validate(self, value, adapt=True): 486 | super(HomogeneousSequence, self).validate(value) 487 | if self._min_length is not None and len(value) < self._min_length: 488 | raise ValidationError("must contain at least %d elements" % 489 | self._min_length, value) 490 | if self._max_length is not None and len(value) > self._max_length: 491 | raise ValidationError("must contain at most %d elements" % 492 | self._max_length, value) 493 | if self._item_validator is None: 494 | return value 495 | if adapt: 496 | return value.__class__(self._iter_validated_items(value, adapt)) 497 | for _ in self._iter_validated_items(value, adapt): 498 | pass 499 | 500 | def _iter_validated_items(self, value, adapt): 501 | validate_item = self._item_validator.validate 502 | for i, item in enumerate(value): 503 | try: 504 | yield validate_item(item, adapt) 505 | except ValidationError as ex: 506 | raise ex.add_context(i) 507 | 508 | 509 | @HomogeneousSequence.register_factory 510 | def _HomogeneousSequenceFactory(obj): 511 | """ 512 | Parse an empty or 1-element ``[schema]`` list as a :py:class:`HomogeneousSequence` 513 | validator. 514 | """ 515 | if isinstance(obj, list) and len(obj) <= 1: 516 | return HomogeneousSequence(*obj) 517 | 518 | 519 | class HeterogeneousSequence(Type): 520 | """A validator that accepts heterogeneous, fixed size sequences.""" 521 | 522 | accept_types = collections.Sequence 523 | reject_types = string_types 524 | 525 | def __init__(self, *item_schemas): 526 | """Instantiate a :py:class:`HeterogeneousSequence` validator. 527 | 528 | :param item_schemas: The schema of each element of the the tuple. 529 | """ 530 | super(HeterogeneousSequence, self).__init__() 531 | self._item_validators = list(imap(parse, item_schemas)) 532 | 533 | def validate(self, value, adapt=True): 534 | super(HeterogeneousSequence, self).validate(value) 535 | if len(value) != len(self._item_validators): 536 | raise ValidationError("%d items expected, %d found" % 537 | (len(self._item_validators), len(value)), value) 538 | if adapt: 539 | return value.__class__(self._iter_validated_items(value, adapt)) 540 | for _ in self._iter_validated_items(value, adapt): 541 | pass 542 | 543 | def _iter_validated_items(self, value, adapt): 544 | for i, (validator, item) in enumerate(izip(self._item_validators, value)): 545 | try: 546 | yield validator.validate(item, adapt) 547 | except ValidationError as ex: 548 | raise ex.add_context(i) 549 | 550 | 551 | @HeterogeneousSequence.register_factory 552 | def _HeterogeneousSequenceFactory(obj): 553 | """ 554 | Parse a ``(schema1, ..., schemaN)`` tuple as a :py:class:`HeterogeneousSequence` 555 | validator. 556 | """ 557 | if isinstance(obj, tuple): 558 | return HeterogeneousSequence(*obj) 559 | 560 | 561 | class Mapping(Type): 562 | """A validator that accepts mappings (:py:class:`collections.Mapping` instances).""" 563 | 564 | accept_types = collections.Mapping 565 | 566 | def __init__(self, key_schema=None, value_schema=None): 567 | """Instantiate a :py:class:`Mapping` validator. 568 | 569 | :param key_schema: If not None, the schema of the dict keys. 570 | :param value_schema: If not None, the schema of the dict values. 571 | """ 572 | super(Mapping, self).__init__() 573 | if key_schema is not None: 574 | self._key_validator = parse(key_schema) 575 | else: 576 | self._key_validator = None 577 | if value_schema is not None: 578 | self._value_validator = parse(value_schema) 579 | else: 580 | self._value_validator = None 581 | 582 | def validate(self, value, adapt=True): 583 | super(Mapping, self).validate(value) 584 | if adapt: 585 | return value.__class__(self._iter_validated_items(value, adapt)) 586 | for _ in self._iter_validated_items(value, adapt): 587 | pass 588 | 589 | def _iter_validated_items(self, value, adapt): 590 | validate_key = validate_value = None 591 | if self._key_validator is not None: 592 | validate_key = self._key_validator.validate 593 | if self._value_validator is not None: 594 | validate_value = self._value_validator.validate 595 | for k, v in iteritems(value): 596 | if validate_value is not None: 597 | try: 598 | v = validate_value(v, adapt) 599 | except ValidationError as ex: 600 | raise ex.add_context(k) 601 | if validate_key is not None: 602 | k = validate_key(k, adapt) 603 | yield (k, v) 604 | 605 | 606 | class Object(Type): 607 | """A validator that accepts json-like objects. 608 | 609 | A ``json-like object`` here is meant as a dict with a predefined set of 610 | "properties", i.e. string keys. 611 | """ 612 | 613 | accept_types = collections.Mapping 614 | 615 | REQUIRED_PROPERTIES = False 616 | ADDITIONAL_PROPERTIES = True 617 | IGNORE_OPTIONAL_PROPERTY_ERRORS = False 618 | REMOVE = object() 619 | 620 | def __init__(self, optional={}, required={}, additional=None, 621 | ignore_optional_errors=None): 622 | """Instantiate an Object validator. 623 | 624 | :param optional: The schema of optional properties, specified as a 625 | ``{name: schema}`` dict. 626 | :param required: The schema of required properties, specified as a 627 | ``{name: schema}`` dict. 628 | :param additional: The schema of all properties that are not explicitly 629 | defined as ``optional`` or ``required``. It can also be: 630 | 631 | - ``True`` to allow any value for additional properties. 632 | - ``False`` to disallow any additional properties. 633 | - :py:attr:`REMOVE` to remove any additional properties from the 634 | adapted object. 635 | - ``None`` to use the value of the ``ADDITIONAL_PROPERTIES`` class 636 | attribute. 637 | :param ignore_optional_errors: Determines if invalid optional properties 638 | are ignored: 639 | 640 | - ``True`` invalid optional properties are ignored. 641 | - ``False`` invalid optional properties raise ValidationError. 642 | - ``None`` use the value of the ``IGNORE_OPTIONAL_PROPERTY_ERRORS`` 643 | class attribute. 644 | """ 645 | super(Object, self).__init__() 646 | if additional is None: 647 | additional = self.ADDITIONAL_PROPERTIES 648 | if ignore_optional_errors is None: 649 | ignore_optional_errors = self.IGNORE_OPTIONAL_PROPERTY_ERRORS 650 | if not isinstance(additional, bool) and additional is not self.REMOVE: 651 | additional = parse(additional) 652 | self._named_validators = [ 653 | (name, parse(schema)) 654 | for name, schema in iteritems(dict(optional, **required)) 655 | ] 656 | self._required_keys = set(required) 657 | self._all_keys = set(name for name, _ in self._named_validators) 658 | self._additional = additional 659 | self._ignore_optional_errors = ignore_optional_errors 660 | 661 | def validate(self, value, adapt=True): 662 | super(Object, self).validate(value) 663 | missing_required = self._required_keys.difference(value) 664 | if missing_required: 665 | raise ValidationError("missing required properties: %s" % 666 | list(missing_required), value) 667 | 668 | result = value.copy() if adapt else None 669 | for name, validator in self._named_validators: 670 | if name in value: 671 | try: 672 | adapted = validator.validate(value[name], adapt) 673 | if result is not None: 674 | result[name] = adapted 675 | except ValidationError as ex: 676 | if (not self._ignore_optional_errors 677 | or name in self._required_keys): 678 | raise ex.add_context(name) 679 | elif result is not None: 680 | del result[name] 681 | else: 682 | pass 683 | elif result is not None and isinstance(validator, Nullable): 684 | default = validator.default_object_property 685 | if default is not Nullable._UNDEFINED: 686 | result[name] = default 687 | 688 | if self._additional is not True: 689 | all_keys = self._all_keys 690 | additional_properties = [k for k in value if k not in all_keys] 691 | if additional_properties: 692 | if self._additional is False: 693 | raise ValidationError("additional properties: %s" % 694 | additional_properties, value) 695 | elif self._additional is self.REMOVE: 696 | if result is not None: 697 | for name in additional_properties: 698 | del result[name] 699 | else: 700 | additional_validate = self._additional.validate 701 | for name in additional_properties: 702 | try: 703 | adapted = additional_validate(value[name], adapt) 704 | if result is not None: 705 | result[name] = adapted 706 | except ValidationError as ex: 707 | raise ex.add_context(name) 708 | 709 | return result 710 | 711 | 712 | @Object.register_factory 713 | def _ObjectFactory(obj): 714 | """Parse a python ``{name: schema}`` dict as an :py:class:`Object` instance. 715 | 716 | - A property name prepended by "+" is required 717 | - A property name prepended by "?" is optional 718 | - Any other property is required if :py:attr:`Object.REQUIRED_PROPERTIES` 719 | is True else it's optional 720 | """ 721 | if isinstance(obj, dict): 722 | optional, required = {}, {} 723 | for key, value in iteritems(obj): 724 | if key.startswith("+"): 725 | required[key[1:]] = value 726 | elif key.startswith("?"): 727 | optional[key[1:]] = value 728 | elif Object.REQUIRED_PROPERTIES: 729 | required[key] = value 730 | else: 731 | optional[key] = value 732 | return Object(optional, required) 733 | 734 | 735 | def _format_types(types): 736 | if inspect.isclass(types): 737 | types = (types,) 738 | names = list(imap(get_type_name, types)) 739 | s = names[-1] 740 | if len(names) > 1: 741 | s = ", ".join(names[:-1]) + " or " + s 742 | return s 743 | --------------------------------------------------------------------------------