├── .env ├── tests ├── __init__.py ├── validators │ ├── test_validator.py │ ├── test_ipv6.py │ ├── test_any.py │ ├── test_int.py │ ├── test_nstr.py │ ├── test_bytes.py │ ├── test_slug.py │ ├── test_enum.py │ ├── test_date.py │ ├── test_ipv4.py │ ├── test_time.py │ ├── test_float.py │ ├── test_bool.py │ ├── test_url.py │ ├── test_object.py │ ├── test_email.py │ ├── test_str.py │ ├── test_model.py │ ├── test_phone.py │ ├── test_datetime.py │ ├── test_uuid.py │ ├── test_fqdn.py │ ├── test_timedelta.py │ ├── test_idcard.py │ ├── __init__.py │ ├── test_list.py │ ├── test_dict.py │ └── test_union.py ├── test_element.py ├── smoke.py ├── helper.py ├── test_isomorph_schema.py ├── test_custom_validator.py ├── test_compiler.py ├── test_exception.py ├── test_schema.py └── test_model.py ├── src └── validr │ ├── _vendor │ ├── __init__.py │ ├── fqdn.py │ ├── durationpy.py │ └── email_validator.py │ ├── validator.py │ ├── schema.pyi │ ├── model.pyi │ ├── __init__.py │ ├── validator.pyi │ ├── model.py │ ├── schema.py │ └── _validator_c.pyx ├── invoke.yaml ├── .coveragerc_py ├── .coveragerc ├── MANIFEST.in ├── benchmark ├── case_copy.py ├── case_json.py ├── case_voluptuous.py ├── case_schema.py ├── case_schematics.py ├── case_validr.py ├── case_jsonschema.py └── benchmark.py ├── .isort.cfg ├── pytest.ini ├── bootstrap.sh ├── pre-commit-install.sh ├── .github └── workflows │ ├── pre-commit.yml │ └── build-test.yml ├── LICENSE-GPL ├── .pre-commit-config.yaml ├── requirements.txt ├── validr_uncython.py ├── .gitignore ├── LICENSE ├── CHANGELOG.md ├── README.md ├── tasks.py └── setup.py /.env: -------------------------------------------------------------------------------- 1 | PYTHONPATH=src -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/validr/_vendor/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /invoke.yaml: -------------------------------------------------------------------------------- 1 | run: 2 | echo: true 3 | pty: true 4 | -------------------------------------------------------------------------------- /.coveragerc_py: -------------------------------------------------------------------------------- 1 | [run] 2 | omit=src/validr/_vendor/*.py 3 | -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | plugins=Cython.Coverage 3 | omit=src/validr/*_py.py,src/validr/_vendor/*.py 4 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include src/validr/*.pyx 2 | include src/validr/*.pyi 3 | include README* 4 | include LICENSE 5 | -------------------------------------------------------------------------------- /benchmark/case_copy.py: -------------------------------------------------------------------------------- 1 | from copy import deepcopy, copy 2 | 3 | CASES = {"deepcopy": deepcopy, "copy": copy} 4 | -------------------------------------------------------------------------------- /.isort.cfg: -------------------------------------------------------------------------------- 1 | [settings] 2 | multi_line_output=VERTICAL_HANGING_INDENT 3 | include_trailing_comma=true 4 | sections=FUTURE,STDLIB,THIRDPARTY,FIRSTPARTY,LOCALFOLDER 5 | -------------------------------------------------------------------------------- /src/validr/validator.py: -------------------------------------------------------------------------------- 1 | try: 2 | from ._validator_c import * # noqa: F401,F403 3 | except ImportError: 4 | from ._validator_py import * # noqa: F401,F403 5 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | norecursedirs = build tmp benchmark 3 | python_files = src/*.py tests/*.py 4 | addopts = --ignore tasks.py --doctest-modules --cov=validr --cov-report=term-missing 5 | -------------------------------------------------------------------------------- /benchmark/case_json.py: -------------------------------------------------------------------------------- 1 | from json import dumps, loads 2 | 3 | 4 | def loads_dumps(value): 5 | return loads(dumps(value)) 6 | 7 | 8 | CASES = { 9 | 'loads-dumps': loads_dumps 10 | } 11 | -------------------------------------------------------------------------------- /bootstrap.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -ex 3 | 4 | pip install 'pip>=22.3.1' 'wheel>=0.38.4' 5 | pip install -r requirements.txt 6 | pip list 7 | VALIDR_SETUP_MODE=dist pip install --no-build-isolation -e . 8 | -------------------------------------------------------------------------------- /pre-commit-install.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -ex 3 | 4 | python -m venv .pre-commit/ 5 | .pre-commit/bin/pip install 'pre-commit==3.6.0' 6 | .pre-commit/bin/pre-commit install 7 | .pre-commit/bin/pre-commit install-hooks 8 | -------------------------------------------------------------------------------- /tests/validators/test_validator.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from validr import T, SchemaError, Compiler 3 | 4 | 5 | def test_invalid_default(): 6 | with pytest.raises(SchemaError): 7 | Compiler().compile(T.int.default('abc')) 8 | -------------------------------------------------------------------------------- /tests/validators/test_ipv6.py: -------------------------------------------------------------------------------- 1 | from validr import T 2 | from . import case 3 | 4 | 5 | @case({ 6 | T.ipv6: { 7 | 'valid': [ 8 | '2001:db8:2de::e13', 9 | '::1', 10 | ], 11 | 'invalid': [ 12 | None, 123, 13 | '2001::25de::cade', 14 | '127.0.0.1' 15 | ] 16 | } 17 | }) 18 | def test_ipv6(): 19 | pass 20 | -------------------------------------------------------------------------------- /.github/workflows/pre-commit.yml: -------------------------------------------------------------------------------- 1 | name: Pre-Commit 2 | on: [push] 3 | jobs: 4 | build: 5 | runs-on: ubuntu-22.04 6 | env: 7 | PIP_DISABLE_PIP_VERSION_CHECK: "1" 8 | steps: 9 | - uses: actions/checkout@v4 10 | - name: Install pre-commit 11 | run: ./pre-commit-install.sh 12 | - name: Run pre-commit 13 | run: .pre-commit/bin/pre-commit run --verbose --all-files 14 | -------------------------------------------------------------------------------- /tests/validators/test_any.py: -------------------------------------------------------------------------------- 1 | from validr import T 2 | from . import case 3 | 4 | 5 | @case({ 6 | T.any: [ 7 | (1, 1), 8 | ('', ''), 9 | ('hello', 'hello'), 10 | ([1, 2, 3], [1, 2, 3]), 11 | ({'key': 123}, {'key': 123}), 12 | (object, object), 13 | [ 14 | None 15 | ] 16 | ], 17 | T.any.optional: [ 18 | (None, None), 19 | ('', '') 20 | ], 21 | }) 22 | def test_any(): 23 | pass 24 | -------------------------------------------------------------------------------- /tests/validators/test_int.py: -------------------------------------------------------------------------------- 1 | from validr import T 2 | 3 | from . import case 4 | 5 | MAX_INT = 2**64 - 1 6 | 7 | 8 | @case({ 9 | T.int.min(0).max(9): [ 10 | (0, 0), 11 | (9, 9), 12 | ('5', 5), 13 | [-1, 10, 'abc'] 14 | ], 15 | T.int: [ 16 | (MAX_INT, MAX_INT), 17 | (-MAX_INT, -MAX_INT), 18 | (0, 0), 19 | [MAX_INT + 1, -MAX_INT - 1, float('INF'), float('NAN')] 20 | ] 21 | }) 22 | def test_int(): 23 | pass 24 | -------------------------------------------------------------------------------- /benchmark/case_voluptuous.py: -------------------------------------------------------------------------------- 1 | from voluptuous import Required, Schema 2 | 3 | schema = Schema({ 4 | Required('user'): {'userid': int}, 5 | Required('tags'): [int], 6 | Required('style'): { 7 | Required('width'): int, 8 | Required('height'): int, 9 | Required('border_width'): int, 10 | Required('border_style'): str, 11 | Required('border_color'): str, 12 | Required('color'): str 13 | }, 14 | 'optional': str 15 | }) 16 | 17 | CASES = { 18 | 'default': schema 19 | } 20 | -------------------------------------------------------------------------------- /benchmark/case_schema.py: -------------------------------------------------------------------------------- 1 | from schema import And, Optional, Schema, Use 2 | 3 | schema = Schema({ 4 | 'user': {'userid': And(Use(int), lambda x: 0 <= x <= 9)}, 5 | 'tags': [And(Use(int), lambda x: 0 <= x)], 6 | 'style': { 7 | 'width': Use(int), 8 | 'height': Use(int), 9 | 'border_width': Use(int), 10 | 'border_style': str, 11 | 'border_color': str, 12 | 'color': str 13 | }, 14 | Optional('optional'): str 15 | }) 16 | 17 | 18 | CASES = { 19 | 'default': schema.validate 20 | } 21 | -------------------------------------------------------------------------------- /tests/validators/test_nstr.py: -------------------------------------------------------------------------------- 1 | from validr import T 2 | 3 | from . import case 4 | 5 | 6 | @case({ 7 | T.nstr: [ 8 | ('中文', '中文'), 9 | ('123', '123'), 10 | (0, '0'), 11 | ('', ''), 12 | [None, b'', b'abc', '中文'.encode('utf-8')] 13 | ], 14 | T.nstr.default('中文'): [ 15 | (None, '中文'), 16 | ('', ''), 17 | ('abc', 'abc') 18 | ], 19 | T.nstr.optional: [ 20 | ('中文', '中文'), 21 | (0, '0'), 22 | ('', ''), 23 | (None, None), 24 | [b'', b'abc'] 25 | ], 26 | }) 27 | def test_nstr(): 28 | pass 29 | -------------------------------------------------------------------------------- /tests/validators/test_bytes.py: -------------------------------------------------------------------------------- 1 | from validr import T 2 | from . import case 3 | 4 | 5 | _THREE_BYTES = '字'.encode('utf-8') 6 | 7 | 8 | @case({ 9 | T.bytes: [ 10 | (b'123', b'123'), 11 | (b'', b''), 12 | (_THREE_BYTES * 1024 * 1024, _THREE_BYTES * 1024 * 1024), 13 | ['hello', 123], 14 | ], 15 | T.bytes.minlen(1).maxlen(1): [ 16 | (b'a', b'a'), 17 | [b'ab', b'', _THREE_BYTES] 18 | ], 19 | T.bytes.optional: [ 20 | (None, None), 21 | (b'', b''), 22 | (_THREE_BYTES, _THREE_BYTES), 23 | ], 24 | }) 25 | def test_bytes(): 26 | pass 27 | -------------------------------------------------------------------------------- /tests/validators/test_slug.py: -------------------------------------------------------------------------------- 1 | from validr import T 2 | from . import case 3 | 4 | 5 | @case({ 6 | T.slug: { 7 | 'valid': [ 8 | 'aaa', 9 | 'aa-b-c', 10 | '123-abc' 11 | ], 12 | 'invalid': [ 13 | '-', 14 | 'aa_b', 15 | 'a--b', 16 | 'a-', 17 | '-a', 18 | '中文', 19 | ' whitespace ', 20 | 'x' * 256, 21 | ] 22 | }, 23 | T.slug.maxlen(16): { 24 | 'valid': ['x', 'x' * 16], 25 | 'invalid': ['x' * 17] 26 | }, 27 | }) 28 | def test_slug(): 29 | pass 30 | -------------------------------------------------------------------------------- /src/validr/schema.pyi: -------------------------------------------------------------------------------- 1 | from typing import Callable, Iterable, Union 2 | 3 | 4 | class Schema: 5 | def repr(self, *, prefix=True, desc=True) -> str: 6 | ... 7 | 8 | def copy(self) -> "Schema": 9 | ... 10 | 11 | 12 | class Builder: 13 | def __getitem__(self, keys: Iterable[str]) -> "Builder": ... 14 | def __getattr__(self, name: str) -> "Builder": ... 15 | def __call__(self, *args, **kwargs) -> "Builder": ... 16 | 17 | 18 | T: Builder 19 | 20 | 21 | class Compiler: 22 | def __init__(self, validators: dict = None): 23 | ... 24 | 25 | def compile(self, schema: Union[Schema, Builder]) -> Callable: 26 | ... 27 | -------------------------------------------------------------------------------- /tests/validators/test_enum.py: -------------------------------------------------------------------------------- 1 | from validr import T 2 | 3 | from . import case 4 | 5 | 6 | @case({ 7 | T.enum(['A', 'B', 123]): [ 8 | ('A', 'A'), 9 | ('B', 'B'), 10 | (123, 123), 11 | [ 12 | 'X', 13 | ' A', 14 | 'A ', 15 | '123', 16 | None, 17 | '', 18 | object, 19 | ] 20 | ], 21 | T.enum('A B C'): [ 22 | ('A', 'A'), 23 | ('B', 'B'), 24 | ['X', 123, None, ''], 25 | ], 26 | T.enum('A B C').optional: [ 27 | ('', None), 28 | (None, None), 29 | ], 30 | }) 31 | def test_enum(): 32 | pass 33 | -------------------------------------------------------------------------------- /LICENSE-GPL: -------------------------------------------------------------------------------- 1 | Copyright (C) 2019 guyskk 2 | 3 | This program is free software: you can redistribute it and/or modify 4 | it under the terms of the GNU General Public License as published by 5 | the Free Software Foundation, either version 3 of the License, or 6 | (at your option) any later version. 7 | 8 | This program is distributed in the hope that it will be useful, 9 | but WITHOUT ANY WARRANTY; without even the implied warranty of 10 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 11 | GNU General Public License for more details. 12 | 13 | You should have received a copy of the GNU General Public License 14 | along with this program. If not, see . 15 | -------------------------------------------------------------------------------- /tests/validators/test_date.py: -------------------------------------------------------------------------------- 1 | from datetime import date 2 | from validr import T 3 | from . import case 4 | 5 | 6 | @case({ 7 | T.date: [ 8 | (date(2016, 7, 9), '2016-07-09'), 9 | ('2016-07-09', '2016-07-09'), 10 | ('2016-7-9', '2016-07-09'), 11 | [ 12 | '2016-13-09', '07-09', '16-07-09', 13 | '', None 14 | ] 15 | ], 16 | T.date.optional: [ 17 | (None, ''), 18 | ('', '') 19 | ], 20 | T.date.format('%Y/%m/%d'): [ 21 | (date(2016, 7, 9), '2016/07/09'), 22 | ('2016/07/09', '2016/07/09'), 23 | ('2016/7/9', '2016/07/09'), 24 | ['2016-07-09', '07/09'] 25 | ] 26 | }) 27 | def test_date(): 28 | pass 29 | -------------------------------------------------------------------------------- /tests/validators/test_ipv4.py: -------------------------------------------------------------------------------- 1 | from . import case 2 | from validr import T 3 | 4 | valid_ipv4 = [ 5 | '0.0.0.0', '9.9.9.9', '99.99.99.99', 6 | '29.29.29.29', '39.39.39.39', 7 | '255.255.255.255', '199.199.199.199', 8 | '192.168.191.1', '127.0.0.1' 9 | ] 10 | 11 | invalid_ipv4 = [ 12 | None, 123, '', '127.0.0.', '127.0.0.', '.0.0.1', ' .0.0.1' 13 | '127.0.0.1.2', '127.0.0.', '127.0.0. 1' 14 | 'x.1.1.1', '1.x.1.1', '1.1.x.1', '0.0.0.-', 15 | '256.0.0.0', '0.256.0.0', '0.0.256.0', '0.0.0.256', 16 | '300.400.500.600', '6.66.666.6666', 17 | ] 18 | 19 | 20 | @case({ 21 | T.ipv4: { 22 | 'valid': valid_ipv4, 23 | 'invalid': invalid_ipv4 24 | } 25 | }) 26 | def test_ipv4(): 27 | pass 28 | -------------------------------------------------------------------------------- /src/validr/model.pyi: -------------------------------------------------------------------------------- 1 | import typing 2 | 3 | from .schema import Compiler 4 | 5 | 6 | class ImmutableInstanceError(AttributeError): 7 | ... 8 | 9 | 10 | M = typing.TypeVar('M') 11 | 12 | 13 | @typing.overload 14 | def modelclass( 15 | cls: typing.Type[M], 16 | *, compiler: Compiler = None, 17 | immutable: bool = False, 18 | ) -> typing.Type[M]: 19 | ... 20 | 21 | 22 | @typing.overload 23 | def modelclass( 24 | *, compiler: Compiler = None, 25 | immutable: bool = False, 26 | ) -> typing.Callable[[typing.Type[M]], typing.Type[M]]: 27 | ... 28 | 29 | 30 | def fields(m: typing.Any) -> typing.Set[str]: 31 | ... 32 | 33 | 34 | def asdict( 35 | m: typing.Any, 36 | *, keys: typing.Iterable[str] = None, 37 | ) -> typing.Dict[str, typing.Any]: 38 | ... 39 | -------------------------------------------------------------------------------- /tests/validators/test_time.py: -------------------------------------------------------------------------------- 1 | from validr import T 2 | from datetime import time 3 | 4 | from . import case 5 | 6 | 7 | @case({ 8 | T.time: [ 9 | (time(0, 0, 0), '00:00:00'), 10 | ('12:00:59', '12:00:59'), 11 | ('23:59:59', '23:59:59'), 12 | [ 13 | '59:59', 14 | '24:00:00', 15 | '23:30:60', 16 | '23:60:30', 17 | '2016-07-09T00:00:59.123000Z', 18 | None, 19 | '' 20 | ] 21 | ], 22 | T.time.optional: [ 23 | (None, ''), 24 | ('', '') 25 | ], 26 | T.time.format('%H/%M/%S'): [ 27 | (time(23, 7, 9), '23/07/09'), 28 | ('12/59/00', '12/59/00'), 29 | ('23/7/9', '23/07/09') 30 | ] 31 | }) 32 | def test_time(): 33 | pass 34 | -------------------------------------------------------------------------------- /tests/validators/test_float.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from validr import T 3 | from . import case 4 | 5 | 6 | @case({ 7 | T.float: [ 8 | ('0', 0.0), 9 | (-100, -100.0), 10 | [ 11 | '1.x', 12 | sys.float_info.max * 2, 13 | -sys.float_info.max * 2, 14 | float('INF'), 15 | ] 16 | ], 17 | T.float.default(1.0): [ 18 | (None, 1.0), 19 | (2.0, 2.0), 20 | ], 21 | T.float.min(0).max(1): [ 22 | (0.0, 0.0), 23 | (1.0, 1.0), 24 | [-0.01, 1.01] 25 | ], 26 | T.float.min(0).exmin.max(1).exmax: [ 27 | (0.01, 0.01), 28 | (0.99, 0.99), 29 | [0.0, 1.0] 30 | ], 31 | T.float.exmin(0).exmax(1): [ 32 | (0.01, 0.01), 33 | (0.99, 0.99), 34 | [0.0, 1.0] 35 | ] 36 | }) 37 | def test_float(): 38 | pass 39 | -------------------------------------------------------------------------------- /benchmark/case_schematics.py: -------------------------------------------------------------------------------- 1 | from schematics.models import Model 2 | from schematics.types import IntType, ListType, ModelType, StringType 3 | 4 | 5 | class User(Model): 6 | userid = IntType(required=True) 7 | 8 | 9 | class Style(Model): 10 | width = IntType(required=True) 11 | height = IntType(required=True) 12 | border_width = IntType(required=True) 13 | border_style = StringType(required=True) 14 | border_color = StringType(required=True) 15 | color = StringType(required=True) 16 | 17 | 18 | class Data(Model): 19 | user = ModelType(User, required=True) 20 | tags = ListType(IntType) 21 | style = ModelType(Style, required=True) 22 | optional = StringType(required=False) 23 | 24 | 25 | def validate(data): 26 | m = Data(data) 27 | m.validate() 28 | return m.to_primitive() 29 | 30 | 31 | CASES = { 32 | 'default': validate 33 | } 34 | -------------------------------------------------------------------------------- /src/validr/__init__.py: -------------------------------------------------------------------------------- 1 | """A simple, fast, extensible python library for data validation.""" 2 | from .model import ImmutableInstanceError, asdict, fields, modelclass 3 | from .schema import Builder, Compiler, Schema, T 4 | from .validator import ( 5 | Invalid, 6 | ModelInvalid, 7 | SchemaError, 8 | ValidrError, 9 | builtin_validators, 10 | create_enum_validator, 11 | create_re_validator, 12 | ) 13 | from .validator import py_mark_index as mark_index 14 | from .validator import py_mark_key as mark_key 15 | from .validator import validator 16 | 17 | __all__ = ( 18 | 'ValidrError', 'Invalid', 'ModelInvalid', 'SchemaError', 19 | 'mark_index', 'mark_key', 20 | 'create_re_validator', 'create_enum_validator', 21 | 'builtin_validators', 'validator', 22 | 'Schema', 'Compiler', 'T', 'Builder', 23 | 'modelclass', 'fields', 'asdict', 'ImmutableInstanceError', 24 | ) 25 | -------------------------------------------------------------------------------- /tests/validators/test_bool.py: -------------------------------------------------------------------------------- 1 | from validr import T 2 | from . import case 3 | 4 | 5 | @case({ 6 | T.bool: [ 7 | (True, True), 8 | ('True', True), 9 | ('true', True), 10 | ('TRUE', True), 11 | ('Yes', True), 12 | ('YES', True), 13 | ('yes', True), 14 | ('ON', True), 15 | ('on', True), 16 | ('Y', True), 17 | ('y', True), 18 | (1, True), 19 | ('1', True), 20 | (False, False), 21 | ('False', False), 22 | ('false', False), 23 | ('FALSE', False), 24 | ('No', False), 25 | ('NO', False), 26 | ('no', False), 27 | ('OFF', False), 28 | ('off', False), 29 | ('N', False), 30 | ('n', False), 31 | (0, False), 32 | ('0', False), 33 | [None, ''] 34 | ], 35 | T.bool.default(False): [ 36 | (None, False), 37 | ('', False), 38 | ], 39 | }) 40 | def test_bool(): 41 | pass 42 | -------------------------------------------------------------------------------- /tests/validators/test_url.py: -------------------------------------------------------------------------------- 1 | from validr import T 2 | from . import case 3 | 4 | 5 | @case({ 6 | T.url: { 7 | 'valid': [ 8 | 'http://127.0.0.1:8080/hello?key=中文', 9 | 'http://tool.lu/regex/', 10 | 'https://github.com/guyskk/validator', 11 | 'https://avatars3.githubusercontent.com/u/6367792?v=3&s=40', 12 | 'https://github.com', 13 | 'https://www.google.com/' + 'x' * 128, 14 | ], 15 | 'invalid': [ 16 | None, 17 | 123, 18 | '', 19 | 'mail@qq.com', 20 | 'google', 21 | 'readme.md', 22 | 'github.com', 23 | 'www.google.com', 24 | 'https://www.google.com/' + 'x' * 256, 25 | b'https://github.com', 26 | 'http://www.google.com', 27 | '//cdn.bootcss.com/bootstrap/4.0.0-alpha.3/css/bootstrap.min.css', 28 | ] 29 | } 30 | }) 31 | def test_url(): 32 | pass 33 | -------------------------------------------------------------------------------- /.github/workflows/build-test.yml: -------------------------------------------------------------------------------- 1 | name: Build-Test 2 | on: [push] 3 | jobs: 4 | build: 5 | runs-on: ubuntu-22.04 6 | env: 7 | PIP_DISABLE_PIP_VERSION_CHECK: "1" 8 | strategy: 9 | matrix: 10 | python-version: 11 | - "3.9" 12 | - "3.10" 13 | - "3.11" 14 | - "3.12" 15 | - "pypy3.9" 16 | - "pypy3.10" 17 | steps: 18 | - uses: actions/checkout@v4 19 | - name: Setup Python ${{ matrix.python-version }} 20 | uses: actions/setup-python@v4 21 | with: 22 | python-version: ${{ matrix.python-version }} 23 | - name: Display Python version 24 | run: python -VV && pip --version 25 | - name: Bootstrap requirements 26 | run: ./bootstrap.sh 27 | - name: Run test 28 | run: inv test 29 | - name: Run e2e-test 30 | run: inv e2e-test 31 | - name: Run benchmark 32 | run: inv benchmark 33 | - uses: codecov/codecov-action@v3 34 | with: 35 | token: ${{ secrets.CODECOV_TOKEN }} 36 | -------------------------------------------------------------------------------- /tests/validators/test_object.py: -------------------------------------------------------------------------------- 1 | from validr import T 2 | import uuid 3 | from datetime import date, time, datetime 4 | 5 | from . import case 6 | 7 | 8 | UUID4 = uuid.uuid4() 9 | UUID5 = uuid.uuid5(uuid.NAMESPACE_URL, 'test') 10 | 11 | 12 | @case({ 13 | T.time.object.optional: [ 14 | (time(0, 0, 0), time(0, 0, 0)), 15 | (time(12, 00, 59), time(12, 00, 59)), 16 | (time(12, 59, 59), time(12, 59, 59)), 17 | ('12:59:59', time(12, 59, 59)), 18 | ('', None), 19 | (None, None), 20 | ], 21 | T.date.object: [ 22 | (datetime(2019, 1, 1), date(2019, 1, 1)), 23 | ('2019-01-01', date(2019, 1, 1)), 24 | ], 25 | T.datetime.object: [ 26 | (datetime(2019, 1, 1, 23, 59, 59), datetime(2019, 1, 1, 23, 59, 59)), 27 | ('2019-01-01T23:59:59.0Z', datetime(2019, 1, 1, 23, 59, 59)), 28 | ], 29 | T.uuid.object.optional: [ 30 | (UUID4, UUID4), 31 | (UUID5, UUID5), 32 | ('', None), 33 | (None, None), 34 | ], 35 | }) 36 | def test_output_object(): 37 | pass 38 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v4.5.0 4 | hooks: 5 | - id: trailing-whitespace 6 | files: \.(py|pyx|sh|md|txt|in|ini|json|yaml|yml)$ 7 | - id: end-of-file-fixer 8 | files: \.(py|pyx|sh|md|txt|in|ini|json|yaml|yml)$ 9 | - id: fix-byte-order-marker 10 | files: \.(py|pyx)$ 11 | - id: check-added-large-files 12 | args: 13 | - '--maxkb=2000' 14 | - id: check-docstring-first 15 | files: \.(py|pyx)$ 16 | - id: fix-encoding-pragma 17 | files: \.(py|pyx)$ 18 | args: 19 | - '--remove' 20 | - id: mixed-line-ending 21 | args: 22 | - '--fix=no' 23 | - id: check-executables-have-shebangs 24 | - id: check-case-conflict 25 | - id: check-merge-conflict 26 | - id: check-symlinks 27 | - id: check-json 28 | - id: check-yaml 29 | - id: debug-statements 30 | - repo: https://github.com/PyCQA/flake8 31 | rev: 6.1.0 32 | hooks: 33 | - id: flake8 34 | args: 35 | - '--max-line-length=119' 36 | - '--ignore=E203,E231,W503,W504' 37 | -------------------------------------------------------------------------------- /tests/test_element.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from validr import SchemaError, Schema, T 3 | 4 | elements = { 5 | 'int': T.int, 6 | 'int.optional': T.int.optional, 7 | 'int.min(0).max(10)': T.int.min(0).max(10), 8 | 'int.default(5)': T.int.default(5), 9 | 'int.desc("a number")': T.int.desc('a number'), 10 | 'int.min(0).max(10).optional.default(5).desc("a number")': 11 | T.int.min(0).max(10).optional.default(5).desc('a number'), 12 | 'abc("A B C")': T.abc('A B C'), 13 | "abc('A B C')": T.abc('A B C'), 14 | } 15 | 16 | invalid_elements = [ 17 | None, 18 | '', 19 | 'int.', 20 | 'int.min()()', 21 | 'int.range(0,10)', 22 | 'int.range([0,10])', 23 | 'abc([1,2,3])', 24 | ] 25 | 26 | 27 | @pytest.mark.parametrize('string, expect', elements.items()) 28 | def test_elements(string, expect): 29 | e = Schema.parse_element(string) 30 | assert repr(e) 31 | assert e == expect.__schema__ 32 | 33 | 34 | @pytest.mark.parametrize('string', invalid_elements) 35 | def test_invalid_elements(string): 36 | with pytest.raises(SchemaError): 37 | Schema.parse_element(string) 38 | -------------------------------------------------------------------------------- /tests/validators/test_email.py: -------------------------------------------------------------------------------- 1 | from validr import T 2 | from . import case 3 | 4 | 5 | @case({ 6 | T.email: { 7 | 'valid': [ 8 | '12345678@qq.com', 9 | 'python@gmail.com', 10 | '123@163.com', 11 | 'test-demo@vip.qq.com', 12 | 'i+box@gmail.com', 13 | ], 14 | 'invalid': [ 15 | '123' 16 | '123@' 17 | '123@163' 18 | '123@@163.com' 19 | '123@163.' 20 | '123@163.com' 21 | '123@163com', 22 | '123 @163.com', 23 | '123@ 163.com', 24 | 'qq.com', 25 | ' @163.com', 26 | '中文@qq.com', 27 | 'i' * 256 + '@gmail.com', 28 | str, 29 | None, 30 | 123, 31 | ], 32 | 'expect': [ 33 | (' 123@163.com', '123@163.com'), 34 | ('123@163.com ', '123@163.com'), 35 | (' 123@163.com ', '123@163.com'), 36 | ] 37 | }, 38 | T.email.optional: [ 39 | ('', ''), 40 | (None, '') 41 | ], 42 | }) 43 | def test_email(): 44 | pass 45 | -------------------------------------------------------------------------------- /tests/validators/test_str.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from validr import T, SchemaError 3 | from . import case, compiler 4 | 5 | 6 | @case({ 7 | T.str: [ 8 | ('中文', '中文'), 9 | ('123', '123'), 10 | (str('abc'), 'abc'), 11 | (123, '123'), 12 | [None, '', b'', b'abc', '中文'.encode('utf-8')] 13 | ], 14 | T.str.default('中文'): [ 15 | (None, '中文'), 16 | ('', '中文'), 17 | ('abc', 'abc') 18 | ], 19 | T.str.minlen(1).maxlen(1): [ 20 | ('中', '中'), 21 | ('a', 'a'), 22 | ['中文', 'ab', ''] 23 | ], 24 | T.str.minlen(2): [ 25 | ['中', 'A'] 26 | ], 27 | T.str.escape: [ 28 | ('中文', '中文'), 29 | ("&><'\"", '&><'"') 30 | ], 31 | T.str.strip: [ 32 | (' aaa ', 'aaa'), 33 | (' 中文 ', '中文'), 34 | [' ', ' ', None] 35 | ], 36 | T.str.match("[a-z]+"): [ 37 | ('aaz', 'aaz'), 38 | [None, '', 'aA', 'zZ', 'a0', 'a-'], 39 | ] 40 | }) 41 | def test_str(): 42 | pass 43 | 44 | 45 | def test_invalid_match_regex(): 46 | with pytest.raises(SchemaError): 47 | compiler.compile(T.str.match('?')) 48 | -------------------------------------------------------------------------------- /benchmark/case_validr.py: -------------------------------------------------------------------------------- 1 | from validr import T, Compiler, modelclass, asdict, builtin_validators 2 | 3 | 4 | @modelclass 5 | class Model: 6 | user = T.dict(userid=T.int.min(0).max(9).desc("UserID")) 7 | tags = T.union([ 8 | T.int.min(0), 9 | T.list(T.int.min(0)), 10 | ]) 11 | style = T.dict( 12 | width=T.int.desc("width"), 13 | height=T.int.desc("height"), 14 | border_width=T.int.desc("border_width"), 15 | border_style=T.str.desc("border_style"), 16 | border_color=T.str.desc("border_color"), 17 | color=T.str.desc("color"), 18 | ) 19 | optional = T.str.optional.desc("unknown value") 20 | 21 | 22 | compiler = Compiler() 23 | default = compiler.compile(T(Model)) 24 | 25 | any_validators = {} 26 | for name, v in builtin_validators.items(): 27 | if name in ('list', 'dict'): 28 | continue 29 | any_validators[name] = builtin_validators['any'] 30 | 31 | any_compiler = Compiler(validators=any_validators) 32 | any_case = any_compiler.compile(T(Model)) 33 | 34 | 35 | def model(value): 36 | return asdict(Model(value)) 37 | 38 | 39 | CASES = {"default": default, "model": model, "any": any_case} 40 | -------------------------------------------------------------------------------- /tests/validators/test_model.py: -------------------------------------------------------------------------------- 1 | from validr import T, modelclass 2 | 3 | from . import case, compiler 4 | 5 | 6 | @modelclass(immutable=True) 7 | class User: 8 | name = T.str 9 | age = T.int.min(0) 10 | 11 | 12 | @case({ 13 | T.model(User): [ 14 | (dict(name='kk', age=12), User(name='kk', age=12)), 15 | (User(name='kk', age=12), User(name='kk', age=12)), 16 | [dict(name='kk', age=-1), "", 123] 17 | ], 18 | T.model(User).optional: [ 19 | (None, None), 20 | ["", 123] 21 | ] 22 | }) 23 | def test_model(): 24 | pass 25 | 26 | 27 | def test_union_list_model(): 28 | schema = T.union([T.model(User), T.int]) 29 | f = compiler.compile(schema) 30 | assert f(123) == 123 31 | data = dict(name='kk', age=12) 32 | assert f(data) == User(**data) 33 | assert f(User(**data)) == User(**data) 34 | 35 | 36 | def test_union_dict_model(): 37 | schema = T.union(user=T.model(User), dict=T.dict(label=T.str)).by('name') 38 | f = compiler.compile(schema) 39 | data = dict(name='user', age=12) 40 | assert f(data) == User(**data) 41 | assert f(User(**data)) == User(**data) 42 | data = dict(name='dict', label='test') 43 | assert f(data) == data 44 | -------------------------------------------------------------------------------- /tests/validators/test_phone.py: -------------------------------------------------------------------------------- 1 | from validr import T 2 | from . import case 3 | 4 | # 手机号码测试用例 5 | # http://blog.csdn.net/mr_lady/article/details/50245223 6 | phone_headers = [133, 153, 180, 181, 189, 177, 130, 131, 132, 7 | 155, 156, 145, 185, 186, 176, 185, 134, 135, 8 | 136, 137, 138, 139, 150, 151, 152, 158, 159, 9 | 182, 183, 184, 157, 187, 188, 147, 178] 10 | valid_phone = ['%d87654321' % x for x in phone_headers] 11 | valid_phone.extend(['+86%s' % x for x in valid_phone[:5]]) 12 | valid_phone.extend(['+86 %s' % x for x in valid_phone[:5]]) 13 | valid_phone.extend(['86 %s' % x for x in valid_phone[:5]]) 14 | 15 | invalid_phone = ['%d87654321' for x in range(10, 20) 16 | if x not in [13, 14, 15, 17, 18]] 17 | invalid_phone.extend([ 18 | '1331234567', 19 | '1331234', 20 | '1331234567x', 21 | '13312345678x', 22 | 'x1331234567', 23 | '.1331234567', 24 | '#1331234567', 25 | '13312345678 ', 26 | ' 13312345678', 27 | '1331234 5678' 28 | '+86 133123456785', 29 | ]) 30 | 31 | 32 | @case({ 33 | T.phone: { 34 | 'valid': valid_phone, 35 | 'invalid': invalid_phone 36 | } 37 | }) 38 | def test_phone(): 39 | pass 40 | -------------------------------------------------------------------------------- /tests/validators/test_datetime.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from validr import T 3 | from . import case 4 | 5 | 6 | @case({ 7 | T.datetime: [ 8 | (datetime(2016, 7, 9), '2016-07-09T00:00:00.000000Z'), 9 | (datetime(2016, 7, 9, 14, 47, 30, 123), '2016-07-09T14:47:30.000123Z'), 10 | ('2016-07-09T00:00:00.000000Z', '2016-07-09T00:00:00.000000Z'), 11 | ('2016-07-09T00:00:00.123Z', '2016-07-09T00:00:00.123000Z'), 12 | ('2016-7-9T00:00:00.000000Z', '2016-07-09T00:00:00.000000Z'), 13 | [ 14 | '2016-07-09T00:00:00.000000', 15 | '2016-07-09 00:00:00.000000Z', 16 | '2016-07-09T00:00:00Z', 17 | '2016-07-09T00:00:60.123000Z', 18 | ] 19 | ], 20 | T.datetime.optional: [ 21 | (None, ''), 22 | ('', '') 23 | ], 24 | T.datetime.format('%Y-%m-%d %H:%M:%S.%f'): [ 25 | (datetime(2016, 7, 9), '2016-07-09 00:00:00.000000'), 26 | ('2016-07-09 00:00:00.123', '2016-07-09 00:00:00.123000'), 27 | [ 28 | '2016-07-09T00:00:00.000000', 29 | '2016-07-09 00:00:00.000000Z', 30 | '2016-07-09 00:00:00', 31 | ] 32 | ], 33 | }) 34 | def test_datetime(): 35 | pass 36 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | appdirs==1.4.3 2 | attrs==19.3.0 3 | autopep8==2.0.4 4 | bleach==6.1.0 5 | certifi==2023.7.22 6 | chardet==3.0.4 7 | charset-normalizer==2.1.1 8 | Click==7.0 9 | codecov==2.1.13 10 | contextlib2==0.5.5 11 | coverage==7.3.2 12 | Cython==3.0.6 13 | distlib==0.3.8 14 | docutils==0.16 15 | entrypoints==0.3 16 | exceptiongroup==1.2.0 17 | filelock==3.0.12 18 | flake8==6.1.0 19 | idna==2.8 20 | importlib-metadata==7.0.0 21 | importlib-resources==1.0.2 22 | iniconfig==2.0.0 23 | invoke==2.2.0 24 | isort==5.13.1 25 | jaraco.classes==3.3.0 26 | jsonschema==3.2.0 27 | keyring==24.3.0 28 | markdown-it-py==3.0.0 29 | mccabe==0.7.0 30 | mdurl==0.1.2 31 | more-itertools==8.2.0 32 | nh3==0.2.15 33 | packaging==20.1 34 | pathlib2==2.3.5 35 | pkginfo==1.9.6 36 | pluggy==0.13.1 37 | py==1.11.0 38 | pycodestyle==2.11.1 39 | pyflakes==3.1.0 40 | Pygments==2.17.2 41 | pyparsing==3.1.1 42 | pyrsistent==0.15.7 43 | pytest==7.4.3 44 | pytest-cov==4.1.0 45 | readme-renderer==42.0 46 | requests==2.31.0 47 | requests-toolbelt==1.0.0 48 | rfc3986==2.0.0 49 | rich==13.7.0 50 | schema==0.7.1 51 | schematics==2.1.1 52 | six==1.16.0 53 | tomli==2.0.1 54 | tqdm==4.42.1 55 | twine==4.0.2 56 | urllib3==1.26.18 57 | voluptuous==0.14.1 58 | wcwidth==0.1.8 59 | webencodings==0.5.1 60 | zipp==1.1.0 61 | -------------------------------------------------------------------------------- /tests/smoke.py: -------------------------------------------------------------------------------- 1 | import os 2 | from platform import python_implementation 3 | 4 | from validr import T, asdict, modelclass 5 | 6 | 7 | @modelclass 8 | class Model: 9 | """Base Model""" 10 | 11 | 12 | class Person(Model): 13 | name = T.str.maxlen(16).desc('at most 16 chars') 14 | website = T.url.optional.desc('website is optional') 15 | 16 | 17 | def check_feature(): 18 | data = dict(name='guyskk', website='https://github.com/guyskk') 19 | guyskk = Person(**data) 20 | assert asdict(guyskk) == data, 'smkoe test failed' 21 | 22 | 23 | def check_pure_python(): 24 | import validr._validator_py # noqa: F401 25 | try: 26 | import validr._validator_c # noqa: F401 27 | except ImportError: 28 | pass 29 | else: 30 | assert False, 'Pure Python mode not work!' 31 | 32 | 33 | def check_c_python(): 34 | import validr._validator_c # noqa: F401 35 | import validr._validator_py # noqa: F401 36 | 37 | 38 | def check(): 39 | check_feature() 40 | is_cpython = python_implementation() == 'CPython' 41 | is_mode_py = os.getenv('VALIDR_SETUP_MODE') == 'py' 42 | if is_mode_py or not is_cpython: 43 | check_pure_python() 44 | else: 45 | check_c_python() 46 | 47 | 48 | if __name__ == "__main__": 49 | check() 50 | -------------------------------------------------------------------------------- /tests/validators/test_uuid.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | import pytest 3 | from validr import T, Compiler, SchemaError 4 | from . import case 5 | 6 | _UUID_1 = '2049d70e-bbd9-11e7-aaa2-a0c5896f8c2c' 7 | _UUID_4 = '905ab193-d74d-48e7-be30-c2554b78074a' 8 | 9 | 10 | @case({ 11 | T.uuid: { 12 | 'valid': [ 13 | '12345678-1234-5678-1234-567812345678', 14 | _UUID_1, _UUID_4 15 | ], 16 | 'expect': [ 17 | ('12345678123456781234567812345678', '12345678-1234-5678-1234-567812345678'), 18 | (uuid.UUID(_UUID_1), _UUID_1), 19 | (uuid.UUID(_UUID_4), _UUID_4), 20 | ], 21 | 'invalid': [ 22 | None, 23 | 123, 24 | 'abcd', 25 | 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx', 26 | ] 27 | }, 28 | T.uuid.version(4): { 29 | 'valid': [ 30 | _UUID_4 31 | ], 32 | 'invalid': [ 33 | _UUID_1 34 | ] 35 | }, 36 | T.uuid.version(1): { 37 | 'valid': [ 38 | _UUID_1 39 | ], 40 | 'invalid': [ 41 | _UUID_4 42 | ] 43 | }, 44 | }) 45 | def test_uuid(): 46 | pass 47 | 48 | 49 | def test_illegal_version(): 50 | with pytest.raises(SchemaError): 51 | Compiler().compile(T.uuid.version(10)) 52 | -------------------------------------------------------------------------------- /src/validr/validator.pyi: -------------------------------------------------------------------------------- 1 | from typing import Callable, Dict, List 2 | 3 | 4 | class ValidrError(ValueError): 5 | 6 | @property 7 | def value(self): ... 8 | 9 | @property 10 | def field(self) -> str: ... 11 | 12 | @property 13 | def position(self) -> str: ... 14 | 15 | @property 16 | def message(self) -> str: ... 17 | 18 | 19 | class Invalid(ValidrError): 20 | ... 21 | 22 | 23 | class ModelInvalid(Invalid): 24 | ... 25 | 26 | 27 | class SchemaError(ValidrError): 28 | ... 29 | 30 | 31 | def validator(string: bool = None, *, accept=None, output=None): 32 | ... 33 | 34 | 35 | builtin_validators: Dict[str, Callable] 36 | 37 | 38 | def create_enum_validator( 39 | name: str, 40 | items: List, 41 | string: bool = True, 42 | ) -> Callable: 43 | ... 44 | 45 | 46 | def create_re_validator( 47 | name: str, 48 | r: str, 49 | maxlen: int = None, 50 | strip: bool = False, 51 | ) -> Callable: 52 | ... 53 | 54 | 55 | class py_mark_index(): 56 | def __init__(self, index: int = -1): 57 | ... 58 | 59 | def __enter__(self): 60 | ... 61 | 62 | def __exit__(self, exc_type, exc_val, exc_tb): 63 | ... 64 | 65 | 66 | class py_mark_key(): 67 | def __init__(self, key: str): 68 | ... 69 | 70 | def __enter__(self): 71 | ... 72 | 73 | def __exit__(self, exc_type, exc_val, exc_tb): 74 | ... 75 | -------------------------------------------------------------------------------- /tests/helper.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import functools 3 | import pytest 4 | from validr import ValidrError, Invalid, SchemaError, Compiler 5 | 6 | 7 | def skipif_dict_not_ordered(): 8 | return pytest.mark.skipif( 9 | sys.version_info < (3, 6), 10 | reason='require python3.6 or higher') 11 | 12 | 13 | compiler = Compiler() 14 | 15 | 16 | def schema_error_position(*items): 17 | def decorator(f): 18 | @pytest.mark.parametrize('schema,expect', items) 19 | def wrapped(schema, expect): 20 | with pytest.raises(SchemaError) as exinfo: 21 | compiler.compile(schema) 22 | assert exinfo.value.position == expect 23 | return wrapped 24 | return decorator 25 | 26 | 27 | def expect_position(pos): 28 | def decorator(f): 29 | @functools.wraps(f) 30 | def wrapped(*args, **kwargs): 31 | with pytest.raises(ValidrError) as exinfo: 32 | f(*args, **kwargs) 33 | position = exinfo.value.position 34 | assert position == pos, (position, pos) 35 | return wrapped 36 | return decorator 37 | 38 | 39 | def invalid_position(*items): 40 | def decorator(f): 41 | @pytest.mark.parametrize('schema,expect', items) 42 | def wrapped(schema, expect): 43 | with pytest.raises(Invalid) as exinfo: 44 | compiler.compile(schema) 45 | assert exinfo.value.position == expect 46 | return wrapped 47 | return decorator 48 | -------------------------------------------------------------------------------- /benchmark/case_jsonschema.py: -------------------------------------------------------------------------------- 1 | from jsonschema import Draft3Validator, Draft4Validator 2 | 3 | schema = { 4 | 'type': 'object', 5 | 'properties': { 6 | 'user': { 7 | 'type': 'object', 8 | 'properties': { 9 | 'userid': {'type': 'number'} 10 | }, 11 | 'required': ['userid'] 12 | }, 13 | 'tags': { 14 | 'type': 'array', 15 | 'items': {'type': 'number'} 16 | }, 17 | 'style': { 18 | 'type': 'object', 19 | 'properties': { 20 | 'width': {'type': 'number'}, 21 | 'height': {'type': 'number'}, 22 | 'border_width': {'type': 'number'}, 23 | 'border_style': {'type': 'string'}, 24 | 'border_color': {'type': 'string'}, 25 | 'color': {'type': 'string'}, 26 | }, 27 | 'required': [ 28 | 'width', 'height', 'border_width', 29 | 'border_style', 'border_color', 'color' 30 | ] 31 | }, 32 | 'optional': {'type': 'string'}, 33 | }, 34 | 'required': ['user', 'tags', 'style'] 35 | } 36 | 37 | d3 = Draft3Validator(schema) 38 | d4 = Draft4Validator(schema) 39 | 40 | 41 | def draft3(data): 42 | d3.validate(data) 43 | return data 44 | 45 | 46 | def draft4(data): 47 | d4.validate(data) 48 | return data 49 | 50 | 51 | CASES = { 52 | 'draft3': draft3, 53 | 'draft4': draft4, 54 | } 55 | -------------------------------------------------------------------------------- /tests/test_isomorph_schema.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from validr import SchemaError, Schema, T 3 | 4 | from .helper import schema_error_position 5 | 6 | isomorph_schema = Schema.parse_isomorph_schema 7 | 8 | 9 | def test_list(): 10 | schema = isomorph_schema([ 11 | 'list.unique.maxlen(10).desc("a list")', 12 | 'int' 13 | ]) 14 | assert schema == T.list(T.int).unique.maxlen(10).desc('a list') 15 | 16 | schema = isomorph_schema(['int']) 17 | assert schema == T.list(T.int) 18 | 19 | with pytest.raises(SchemaError): 20 | isomorph_schema([ 21 | 'list', 22 | 'int', 23 | 'str' 24 | ]) 25 | 26 | 27 | def test_invalid_list(): 28 | with pytest.raises(SchemaError): 29 | isomorph_schema([]) 30 | with pytest.raises(SchemaError): 31 | isomorph_schema(['unknown', 1, 2, 3]) 32 | 33 | 34 | def test_dict(): 35 | schema = isomorph_schema({ 36 | '$self': 'dict.optional.desc("a dict")', 37 | 'key': 'str', 38 | }) 39 | assert schema == T.dict(key=T.str).optional.desc('a dict') 40 | 41 | schema = isomorph_schema({'key': 'str'}) 42 | assert schema == T.dict(key=T.str) 43 | 44 | with pytest.raises(SchemaError): 45 | isomorph_schema({'$self': ''}) 46 | 47 | 48 | @schema_error_position( 49 | (isomorph_schema({'key': 'unknown'}), 'key'), 50 | (isomorph_schema([{'key': 'unknown'}]), '[].key'), 51 | (isomorph_schema({'key': [{'key': 'unknown'}]}), 'key[].key'), 52 | ) 53 | def test_schema_error_position(): 54 | pass 55 | -------------------------------------------------------------------------------- /tests/validators/test_fqdn.py: -------------------------------------------------------------------------------- 1 | from validr import T 2 | from . import case 3 | 4 | 5 | @case({ 6 | T.fqdn: { 7 | 'valid': [ 8 | 'github.com', 9 | 'www.google.com', 10 | 'aaa-bbb.example-website.io', 11 | 'a.bc', 12 | '1.2.3.4.com', 13 | '127.0.0.1', 14 | 'xn--kxae4bafwg.xn--pxaix.gr', 15 | 'a.b', 16 | 'aaa.123', 17 | '999.999.999.999', 18 | 'a23456789-123456789-123456789-123456789-123456789-123456789-123.b23.com', 19 | ( 20 | 'a23456789-a23456789-a234567890.a23456789.a23456789.a23456789.' 21 | 'a23456789.a23456789.a23456789.a23456789.a23456789.a23456789.' 22 | 'a23456789.a23456789.a23456789.a23456789.a23456789.a23456789.' 23 | 'a23456789.a23456789.a23456789.a23456789.a23456789.a23456789.a2345678.com' 24 | ), 25 | ], 26 | 'invalid': [ 27 | 'a' * 128 + '.' + 'b' * 128 + '.com', 28 | 'a', 29 | 'localhost', 30 | 'a..bc', 31 | 'ec2-35-160-210-253.us-west-2-.compute.amazonaws.com', 32 | 'a23456789-123456789-123456789-123456789-123456789-123456789-1234.b23.com', 33 | 'aaa_bbb.com', 34 | 'a-', 35 | '-a', 36 | '中文', 37 | str, 38 | ] 39 | }, 40 | T.fqdn.optional: [ 41 | ('mx.gmail.com.', 'mx.gmail.com'), 42 | ('localhost.localdomain.', 'localhost.localdomain'), 43 | ('', ''), 44 | (None, ''), 45 | ] 46 | }) 47 | def test_fqdn(): 48 | pass 49 | -------------------------------------------------------------------------------- /tests/validators/test_timedelta.py: -------------------------------------------------------------------------------- 1 | from datetime import timedelta 2 | 3 | import pytest 4 | 5 | from validr import T, SchemaError 6 | from validr._vendor import durationpy 7 | 8 | from . import case, compiler 9 | 10 | 11 | def seconds(value): 12 | return durationpy.from_str(value).total_seconds() 13 | 14 | 15 | @case({ 16 | T.timedelta: [ 17 | (timedelta(seconds=10), seconds('10s')), 18 | ('12h59s', seconds('12h59s')), 19 | ('23h59m59s', seconds('23h59m59s')), 20 | ('2d59m59s', seconds('48h59m59s')), 21 | [ 22 | '10x', 23 | '23:30:30', 24 | '2016-07-09T00:00:59.123000Z', 25 | None, 26 | '', 27 | object, 28 | ] 29 | ], 30 | T.timedelta.string: [ 31 | (timedelta(seconds=10), '10s'), 32 | ('12h59s', '12h59s'), 33 | ('23h59m59s', '23h59m59s'), 34 | ('2d59m59s', '48h59m59s'), 35 | ], 36 | T.timedelta.min(10).max('24h'): [ 37 | (10, seconds('10s')), 38 | ('24h', seconds('24h')), 39 | ['9s', 9.9, '24h1s'] 40 | ], 41 | T.timedelta.object.optional: [ 42 | ('12h59s', timedelta(hours=12, seconds=59)), 43 | ('', None), 44 | ], 45 | T.timedelta.optional: [ 46 | (None, None), 47 | ('', None) 48 | ], 49 | T.timedelta.string.optional: [ 50 | (None, ''), 51 | ('', '') 52 | ], 53 | }) 54 | def test_timedelta(): 55 | pass 56 | 57 | 58 | def test_timedelta_schema(): 59 | with pytest.raises(SchemaError) as exinfo: 60 | compiler.compile(T.timedelta.min('1x')) 61 | assert 'min' in exinfo.value.message 62 | with pytest.raises(SchemaError) as exinfo: 63 | compiler.compile(T.timedelta.max('1x')) 64 | assert 'max' in exinfo.value.message 65 | -------------------------------------------------------------------------------- /tests/validators/test_idcard.py: -------------------------------------------------------------------------------- 1 | from validr import T 2 | from . import case 3 | 4 | # 身份证号测试用例,只校验数字位数和xX 5 | # http://id.8684.cn/ 6 | valid_idcard = [ 7 | '210727198507128796', '652826198609135797', 8 | '430204197501228571', '652823197903247972', 9 | '44172319870827259X', '431302198807267352', 10 | '330701197107203338', '650103197605126317', 11 | '211403198001282511', '150900197608286734', 12 | '140421198905176811', '52030319770113997X', 13 | '210782198909223256', '430300197806216838', 14 | '370403197801263078', '371602198601115150', 15 | '230521197105208136', '120109198702188412', 16 | '430381198502107002', '34180119780914174X', 17 | '441700197702251887', '511500197606221220', 18 | '654226197901272021', '131003198406139543', 19 | '130431197702191284', '370705198407284720', 20 | '532600198802191668', '13063019850715310X', 21 | '320301198405188229', '370102197901149480', 22 | '210411198206281861', '620321198208147387', 23 | '150421197708131881', '310110198101127447', 24 | '652300199008251729', '370982198103104843', 25 | '431023198202192429', '532926198303176068', 26 | '44172319870827259x', '44172312340827259x' 27 | ] 28 | 29 | # https://github.com/mc-zone/IDValidator 30 | valid_idcard.extend([ 31 | '431389760616601', 32 | '431389990616601', 33 | '431389000616601', 34 | ]) 35 | 36 | 37 | @case({ 38 | T.idcard: { 39 | 'valid': valid_idcard, 40 | 'invalid': [ 41 | '43138976061660X', 42 | '43138976061660x', 43 | '21072719850712', 44 | '2107271985071287', 45 | '21072719850712879', 46 | '21072719850712879 ', 47 | ' 21072719850712879', 48 | '210727198507128796x', 49 | '210727198507128796X', 50 | ] 51 | } 52 | }) 53 | def test_idcard(): 54 | pass 55 | -------------------------------------------------------------------------------- /tests/test_custom_validator.py: -------------------------------------------------------------------------------- 1 | from validr import Invalid, Compiler, validator, T 2 | from validr import create_enum_validator, builtin_validators 3 | 4 | 5 | def test_custom_validator(): 6 | @validator(accept=str, output=str) 7 | def choice_validator(compiler, items): 8 | choices = set(items.split()) 9 | 10 | def validate(value): 11 | if value in choices: 12 | return value 13 | raise Invalid('invalid choice') 14 | 15 | return validate 16 | 17 | compiler = Compiler(validators={'choice': choice_validator}) 18 | schema = T.list(T.choice('A B C D').default('A')) 19 | assert T(schema) == schema # test copy custom validator 20 | validate = compiler.compile(schema) 21 | assert validate(['A', 'B', 'C', 'D', None]) == ['A', 'B', 'C', 'D', 'A'] 22 | 23 | 24 | def test_wrapped_validator(): 25 | str_validator = builtin_validators['str'] 26 | assert str_validator.is_string 27 | assert str_validator.accept_string 28 | assert str_validator.accept_object 29 | assert str_validator.output_string 30 | assert not str_validator.output_object 31 | 32 | logs = [] 33 | 34 | @validator(accept=(str, object), string=True) 35 | def wrapped_str_validator(*args, **kwargs): 36 | _validate = str_validator.validator(*args, **kwargs) 37 | 38 | def validate(value): 39 | logs.append(value) 40 | return _validate(value) 41 | 42 | return validate 43 | 44 | compiler = Compiler(validators={'str': wrapped_str_validator}) 45 | validate = compiler.compile(T.str.optional) 46 | assert validate('abc') == 'abc' 47 | assert logs == ['abc'] 48 | 49 | 50 | def test_create_enum_validator(): 51 | abcd_validator = create_enum_validator('abcd', ['A', 'B', 'C', 'D']) 52 | compiler = Compiler(validators={'abcd': abcd_validator}) 53 | schema = T.list(T.abcd.default('A')) 54 | validate = compiler.compile(schema) 55 | assert validate(['A', 'B', 'C', 'D', None]) == ['A', 'B', 'C', 'D', 'A'] 56 | -------------------------------------------------------------------------------- /validr_uncython.py: -------------------------------------------------------------------------------- 1 | import re 2 | from glob import glob 3 | 4 | 5 | def pyx_to_py(text: str, debug=False): 6 | """Only support validr's usage.""" 7 | lines = [] 8 | for i, line in enumerate(text.splitlines(keepends=True), 1): 9 | origin = line 10 | if line.lstrip().startswith('cdef class'): 11 | line = line.replace('cdef class', 'class') 12 | for pre in ['cpdef inline', 'cdef inline', 'cpdef', 'cdef']: 13 | for pre_type in ['bint', 'str', 'int', 'float', 'dict', 'list', '']: 14 | t = (pre + ' ' + pre_type).strip() 15 | if line.lstrip().startswith(t) and line.rstrip().endswith(':'): 16 | line = line.replace(t, 'def') 17 | for pre in ['cdef', 'cpdef']: 18 | for t in ['bint', 'str', 'int', 'float', 'dict', 'list']: 19 | cdef_t = '{} {} '.format(pre, t) 20 | if line.lstrip().startswith(cdef_t): 21 | if '=' in line: 22 | line = line.replace(cdef_t, '') 23 | else: 24 | line = re.sub(r'(\s*)(\S.*)', r'\1# \2', line) 25 | if re.match(r'\s*\w*def\s\w+\(.*(,|\):)', line) or re.match(r'\s+.*=.*(\):$|,$)', line): 26 | line = re.sub(r'(bint|str|int|float|dict|list)\s(\w+)', r'\2', line) 27 | if debug and origin != line: 28 | print('{:>3d}- '.format(i) + origin, end='') 29 | print('{:>3d}+ '.format(i) + line, end='') 30 | lines.append(line) 31 | return ''.join(lines) 32 | 33 | 34 | def compile_pyx_to_py(filepaths, debug=False): 35 | """Compile *.pyx to pure python using regex and string replaces.""" 36 | for filepath in filepaths: 37 | py_filepath = filepath.replace('_c.pyx', '_py.py') 38 | with open(filepath, encoding='utf-8') as f: 39 | text = f.read() 40 | pure_py = pyx_to_py(text, debug=debug) 41 | with open(py_filepath, 'w', encoding='utf-8') as f: 42 | f.write(pure_py) 43 | 44 | 45 | if __name__ == "__main__": 46 | compile_pyx_to_py(glob('src/validr/*.pyx'), debug=True) 47 | -------------------------------------------------------------------------------- /tests/validators/__init__.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from validr import Invalid, Compiler 3 | 4 | 5 | def expend(cases): 6 | """ 7 | Expend cases 8 | 9 | Args: 10 | cases: 11 | 12 | { 13 | "schema": [ 14 | (value, expect), # tuple, valid value 15 | ... 16 | [value, ...] # list, invalid value 17 | ] 18 | } 19 | 20 | or 21 | 22 | { 23 | "schema": { 24 | "valid": [value, ...] # valid value, and expect=value 25 | "invalid": [value, ...] # invalid value 26 | "expect": [ # valid value and expect 27 | (value, expect), 28 | ... 29 | ] 30 | } 31 | } 32 | Yields: 33 | schema, value, expect/Invalid 34 | """ 35 | for schema, items in cases.items(): 36 | if isinstance(items, dict): 37 | for value in items.get('valid', []): 38 | yield schema, value, value 39 | for value in items.get('invalid', []): 40 | yield schema, value, Invalid 41 | for value, expect in items.get('expect', []): 42 | yield schema, value, expect 43 | else: 44 | for item in items: 45 | if type(item) is tuple: 46 | value, expect = item 47 | yield schema, value, expect 48 | else: 49 | for value in item: 50 | yield schema, value, Invalid 51 | 52 | 53 | compiler = Compiler() 54 | 55 | 56 | def case(cases): 57 | """Genereate test from cases data""" 58 | def decorator(f): 59 | @pytest.mark.parametrize('schema,value,expect', expend(cases)) 60 | def wrapped(schema, value, expect): 61 | f = compiler.compile(schema) 62 | if expect is Invalid: 63 | with pytest.raises(Invalid): 64 | f(value) 65 | else: 66 | result = f(value) 67 | assert result == expect, 'result={!r} expect={!r}'.format(result, expect) 68 | return wrapped 69 | return decorator 70 | -------------------------------------------------------------------------------- /tests/validators/test_list.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from validr import T, SchemaError 4 | from . import case, compiler 5 | 6 | 7 | @case({ 8 | T.list(T.int): [ 9 | ([], []), 10 | ([1, 2], [1, 2]), 11 | (range(3), [0, 1, 2]), 12 | ], 13 | T.list(T.int).optional: { 14 | 'valid': [ 15 | None, 16 | [], 17 | ], 18 | 'invalid': [ 19 | 123, 20 | ] 21 | }, 22 | T.list(T.int).unique: { 23 | 'valid': [ 24 | [1, 2, 3], 25 | ], 26 | 'invalid': [ 27 | [1, 2, '2'], 28 | ] 29 | }, 30 | T.list(T.dict(key=T.int)).unique: { 31 | 'valid': [ 32 | [{'key': 1}, {'key': 2}], 33 | ], 34 | 'invalid': [ 35 | [{'key': 1}, {'key': 1}], 36 | ] 37 | }, 38 | T.list(T.dict(key=T.dict(key=T.int))).unique: { 39 | 'valid': [ 40 | [{'key': {'key': 1}}, {'key': {'key': 2}}], 41 | ], 42 | 'invalid': [ 43 | [{'key': {'key': 1}}, {'key': {'key': 1}}] 44 | ] 45 | }, 46 | T.list(T.list(T.int)).unique: { 47 | 'valid': [ 48 | [ 49 | [1, 1], 50 | [2, 2], 51 | ], 52 | ], 53 | 'invalid': [ 54 | [ 55 | [1, 2], 56 | [1, 2], 57 | ], 58 | ] 59 | }, 60 | T.list(T.list(T.dict(key=T.int))).unique: { 61 | 'valid': [ 62 | [ 63 | [{'key': 1}, {'key': 1}], 64 | [{'key': 2}, {'key': 2}], 65 | ], 66 | ], 67 | 'invalid': [ 68 | [ 69 | [{'key': 1}, {'key': 2}], 70 | [{'key': 1}, {'key': 2}], 71 | ], 72 | ] 73 | }, 74 | T.list(T.int).minlen(1).maxlen(3): { 75 | 'valid': [ 76 | [1], 77 | [1, 2, 3], 78 | ], 79 | 'invalid': [ 80 | [], 81 | [1, 2, 3, 4], 82 | ] 83 | } 84 | }) 85 | def test_list(): 86 | pass 87 | 88 | 89 | @pytest.mark.parametrize('schema', [ 90 | T.list.unique, 91 | T.list(T.dict).unique, 92 | T.list(T.list).unique, 93 | ]) 94 | def test_unable_check_unique(schema): 95 | with pytest.raises(SchemaError) as exinfo: 96 | compiler.compile(schema) 97 | assert 'unable to check unique' in exinfo.value.message 98 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ### macOS ### 2 | # General 3 | .DS_Store 4 | .AppleDouble 5 | .LSOverride 6 | 7 | # Icon must end with two \r 8 | Icon 9 | 10 | # Thumbnails 11 | ._* 12 | 13 | # Files that might appear in the root of a volume 14 | .DocumentRevisions-V100 15 | .fseventsd 16 | .Spotlight-V100 17 | .TemporaryItems 18 | .Trashes 19 | .VolumeIcon.icns 20 | .com.apple.timemachine.donotpresent 21 | 22 | # Directories potentially created on remote AFP share 23 | .AppleDB 24 | .AppleDesktop 25 | Network Trash Folder 26 | Temporary Items 27 | .apdisk 28 | 29 | ### macOS Patch ### 30 | # iCloud generated files 31 | *.icloud 32 | 33 | tmp/ 34 | *_py.py 35 | .test-venv 36 | 37 | # Byte-compiled / optimized / DLL files 38 | __pycache__/ 39 | *.py[cod] 40 | *$py.class 41 | 42 | # C extensions 43 | *.so 44 | *.c 45 | *.out 46 | 47 | # Distribution / packaging 48 | .Python 49 | build/ 50 | develop-eggs/ 51 | dist/ 52 | downloads/ 53 | eggs/ 54 | .eggs/ 55 | lib/ 56 | lib64/ 57 | parts/ 58 | sdist/ 59 | var/ 60 | wheels/ 61 | *.egg-info/ 62 | .installed.cfg 63 | *.egg 64 | 65 | # PyInstaller 66 | # Usually these files are written by a python script from a template 67 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 68 | *.manifest 69 | *.spec 70 | 71 | # Installer logs 72 | pip-log.txt 73 | pip-delete-this-directory.txt 74 | 75 | # Unit test / coverage reports 76 | htmlcov/ 77 | .tox/ 78 | .coverage 79 | .coverage.* 80 | .cache 81 | .pytest_cache/ 82 | nosetests.xml 83 | coverage.xml 84 | *.cover 85 | .hypothesis/ 86 | .pytest_cache/ 87 | 88 | # Translations 89 | *.mo 90 | *.pot 91 | 92 | # Django stuff: 93 | *.log 94 | local_settings.py 95 | 96 | # Flask stuff: 97 | instance/ 98 | .webassets-cache 99 | 100 | # Scrapy stuff: 101 | .scrapy 102 | 103 | # Sphinx documentation 104 | docs/_build/ 105 | 106 | # PyBuilder 107 | target/ 108 | 109 | # Jupyter Notebook 110 | .ipynb_checkpoints 111 | 112 | # pyenv 113 | .python-version 114 | 115 | # celery beat schedule file 116 | celerybeat-schedule 117 | 118 | # SageMath parsed files 119 | *.sage.py 120 | 121 | # Environments 122 | .venv 123 | env/ 124 | venv/ 125 | ENV/ 126 | 127 | # Spyder project settings 128 | .spyderproject 129 | .spyproject 130 | 131 | # Rope project settings 132 | .ropeproject 133 | 134 | # mkdocs documentation 135 | /site 136 | 137 | # mypy 138 | .mypy_cache/ 139 | 140 | # editor 141 | .vscode/ 142 | 143 | .pre-commit/ 144 | -------------------------------------------------------------------------------- /tests/test_compiler.py: -------------------------------------------------------------------------------- 1 | from datetime import date 2 | 3 | import pytest 4 | from validr import Invalid, SchemaError, Compiler, T 5 | 6 | from .helper import schema_error_position 7 | 8 | _ = Compiler().compile 9 | 10 | 11 | def test_optional(): 12 | assert _(T.int.optional)(None) is None 13 | assert _(T.int.optional)('') is None 14 | assert _(T.str.optional)(None) == '' 15 | assert _(T.str.optional)('') == '' 16 | assert _(T.list(T.int).optional)(None) is None 17 | assert _(T.dict(key=T.int).optional)(None) is None 18 | 19 | with pytest.raises(Invalid): 20 | assert _(T.dict(key=T.int).optional)('') 21 | 22 | with pytest.raises(Invalid): 23 | assert _(T.int)(None) 24 | with pytest.raises(Invalid): 25 | assert _(T.str)(None) 26 | with pytest.raises(Invalid): 27 | assert _(T.dict(key=T.int))(None) 28 | with pytest.raises(Invalid): 29 | assert _(T.list(T.int))(None) 30 | 31 | 32 | def test_default(): 33 | assert _(T.int.default(0))(None) == 0 34 | assert _(T.str.default('x'))(None) == 'x' 35 | assert _(T.int.optional.default(0))(None) == 0 36 | assert _(T.str.optional.default('x'))(None) == 'x' 37 | 38 | 39 | def test_invalid_to(): 40 | assert _(T.int.invalid_to(1))('x') == 1 41 | assert _(T.int.default(1).invalid_to_default)('x') == 1 42 | assert _(T.int.optional.invalid_to_default)('x') is None 43 | assert _(T.date.optional.invalid_to_default)('x') == '' 44 | assert _(T.date.object.optional.invalid_to_default)('x') is None 45 | assert _(T.date.invalid_to('2019-01-01'))('x') == '2019-01-01' 46 | assert _(T.date.object.invalid_to('2019-01-01'))('x') == date(2019, 1, 1) 47 | 48 | 49 | @pytest.mark.parametrize('schema', [ 50 | T.int.invalid_to_default, 51 | T.int.invalid_to(0).invalid_to_default, 52 | T.int.invalid_to('x'), 53 | ]) 54 | def test_invalid_to_schema_error(schema): 55 | with pytest.raises(SchemaError): 56 | _(schema) 57 | 58 | 59 | @pytest.mark.parametrize('schema,value,expect', [ 60 | (T.int, 'x', 'x'), 61 | (T.dict(key=T.int), {'key': 'x'}, 'x'), 62 | (T.list(T.int), [1, 'x'], 'x'), 63 | ]) 64 | def test_exception_value(schema, value, expect): 65 | with pytest.raises(Invalid) as exinfo: 66 | _(schema)(value) 67 | assert exinfo.value.value == expect 68 | 69 | 70 | @schema_error_position( 71 | (T.unknown, ''), 72 | (T.str.unknown, ''), 73 | (T.dict(key=T.list(T.dict(key=T.unknown))), 'key[].key'), 74 | ) 75 | def test_schema_error_position(): 76 | pass 77 | -------------------------------------------------------------------------------- /tests/test_exception.py: -------------------------------------------------------------------------------- 1 | from validr import Invalid, SchemaError, T, mark_index, mark_key 2 | 3 | from .helper import expect_position 4 | 5 | 6 | def test_exception_message(): 7 | assert Invalid('invalid').message == 'invalid' 8 | assert Invalid().message is None 9 | 10 | 11 | def test_exception_position(): 12 | e = Invalid('invalid').mark_key('key') 13 | assert e.position == 'key' 14 | 15 | e = Invalid('invalid').mark_index(0) 16 | assert e.position == '[0]' 17 | 18 | e = Invalid('invalid').mark_index() 19 | assert e.position == '[]' 20 | 21 | e = Invalid('invalid').mark_key('key').mark_index(0).mark_index() 22 | assert e.position == '[][0].key' 23 | 24 | e = Invalid('invalid').mark_index().mark_index(0).mark_key('key') 25 | assert e.position == 'key[0][]' 26 | 27 | 28 | def test_exception_field(): 29 | e = Invalid('invalid').mark_key('key') 30 | assert e.field == 'key' 31 | 32 | e = Invalid('invalid').mark_key('key').mark_index(0) 33 | assert e.field == 0 34 | 35 | e = Invalid('invalid').mark_index(0).mark_key('key') 36 | assert e.field == 'key' 37 | 38 | 39 | def test_exception_str(): 40 | ex = Invalid('invalid').mark_index(0).mark_key('key') 41 | assert str(ex) == 'key[0]: invalid' 42 | 43 | ex = Invalid().mark_index(0).mark_key('key') 44 | assert str(ex) == 'key[0]: invalid' 45 | 46 | ex = Invalid('invalid') 47 | assert str(ex) == 'invalid' 48 | 49 | ex = Invalid() 50 | assert str(ex) == 'invalid' 51 | 52 | assert str(Invalid('invalid', value=123)) == 'invalid, value=123' 53 | 54 | assert str(SchemaError('invalid', value=T.str.__schema__)) == 'invalid, schema=str' 55 | 56 | ex = Invalid(value='x' * 1000) 57 | assert len(str(ex)) < 100 58 | 59 | 60 | @expect_position('[0]') 61 | def test_mark_index(): 62 | with mark_index(0): 63 | raise Invalid('invalid') 64 | 65 | 66 | @expect_position('[]') 67 | def test_mark_index_uncertainty(): 68 | with mark_index(): 69 | raise Invalid('invalid') 70 | 71 | 72 | @expect_position('key') 73 | def test_mark_key(): 74 | with mark_key('key'): 75 | raise Invalid('invalid') 76 | 77 | 78 | @expect_position('[][0].key') 79 | def test_mark_index_key(): 80 | with mark_index(): 81 | with mark_index(0): 82 | with mark_key('key'): 83 | raise Invalid('invalid') 84 | 85 | 86 | @expect_position('key[0][]') 87 | def test_mark_key_index(): 88 | with mark_key('key'): 89 | with mark_index(0): 90 | with mark_index(): 91 | raise Invalid('invalid') 92 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2019 guyskk 2 | 3 | Anti-996 License Version 1.0 (Draft) 4 | 5 | Permission is hereby granted to any individual or legal entity 6 | obtaining a copy of this licensed work (including the source code, 7 | documentation and/or related items, hereinafter collectively referred 8 | to as the "licensed work"), free of charge, to deal with the licensed 9 | work for any purpose, including without limitation, the rights to use, 10 | reproduce, modify, prepare derivative works of, distribute, publish 11 | and sublicense the licensed work, subject to the following conditions: 12 | 13 | 1. The individual or the legal entity must conspicuously display, 14 | without modification, this License and the notice on each redistributed 15 | or derivative copy of the Licensed Work. 16 | 17 | 2. The individual or the legal entity must strictly comply with all 18 | applicable laws, regulations, rules and standards of the jurisdiction 19 | relating to labor and employment where the individual is physically 20 | located or where the individual was born or naturalized; or where the 21 | legal entity is registered or is operating (whichever is stricter). In 22 | case that the jurisdiction has no such laws, regulations, rules and 23 | standards or its laws, regulations, rules and standards are 24 | unenforceable, the individual or the legal entity are required to 25 | comply with Core International Labor Standards. 26 | 27 | 3. The individual or the legal entity shall not induce or force its 28 | employee(s), whether full-time or part-time, or its independent 29 | contractor(s), in any methods, to agree in oral or written form, to 30 | directly or indirectly restrict, weaken or relinquish his or her 31 | rights or remedies under such laws, regulations, rules and standards 32 | relating to labor and employment as mentioned above, no matter whether 33 | such written or oral agreement are enforceable under the laws of the 34 | said jurisdiction, nor shall such individual or the legal entity 35 | limit, in any methods, the rights of its employee(s) or independent 36 | contractor(s) from reporting or complaining to the copyright holder or 37 | relevant authorities monitoring the compliance of the license about 38 | its violation(s) of the said license. 39 | 40 | THE LICENSED WORK IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 41 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 42 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 43 | IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, 44 | DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR 45 | OTHERWISE, ARISING FROM, OUT OF OR IN ANY WAY CONNECTION WITH THE 46 | LICENSED WORK OR THE USE OR OTHER DEALINGS IN THE LICENSED WORK. 47 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 | 8 | ## 1.2.2 9 | 10 | ### Added 11 | 12 | - add nstr validator #63 13 | 14 | ### Fixed 15 | 16 | - support Python 3.10 ~ 3.12 #61 17 | - fix some typing hints #61 18 | - fix int validator default max value #62 19 | 20 | ## 1.2.1 21 | 22 | ### Added 23 | 24 | - add bytes validator 25 | - add typing hints for public APIs 26 | 27 | ### Changed 28 | 29 | - support T.enum with enum class parameter 30 | 31 | ## 1.2.0 32 | 33 | ### Added 34 | 35 | - add union validator. #36 36 | - support dynamic dict validator. #38 37 | - add timedelta validator. #39 38 | - add enum validator. #40 39 | - add slug validator. #41 40 | - add fqdn validator. #41 41 | - support nested model class. #7520d96 42 | - str validator accept int objects, and support match parameter. #38 43 | - dict validator support slim parameter. #47 44 | - add maxlen parameter to dict, url, and create_re_validator. #a64640 45 | 46 | ### Changed 47 | 48 | - replaced custom enum validator with builtin enum validator. 49 | 50 | ### Deprecated 51 | 52 | - `create_enum_validator` functon is deprecated, use builtin enum validator instead. 53 | 54 | ## 1.1.3 55 | 56 | ### Added 57 | 58 | - fields function support dict schema, eg: T.dict({...}). #32 59 | 60 | ### Changed 61 | 62 | - Change behavior from deepcopy to copy when use dict and list validator without inner validator, improve performance. #31 63 | - Deprecate Python 3.4, Add Python 3.8 to CI #33 64 | 65 | ## 1.1.2 66 | 67 | ### Added 68 | 69 | - Support pure Python mode. #30 70 | 71 | ## 1.1.1 - 2019-06-01 72 | 73 | ### Added 74 | 75 | - Add ModelInvalid exception, support get error datails when modelclass invalid. #28 #28 76 | 77 | ## 1.1.0 - 2019-03-23 78 | 79 | ### Added 80 | 81 | - Support control validator accept and output type, add `object` parameter. #22 #24 82 | - Add `accept_object` parameter to `str` validator. #24 83 | - Handle invalid values more flexibly. #23 #25 84 | - Add `invalid_to` and `invalid_to_default` parameter. #25 85 | - Add `field` and `value` attributes to ValidrError #25 86 | 87 | ### Changed 88 | 89 | - `validator` decorator now use `accept` and `output` to control data types, `string` argument is deprecated. #24 90 | 91 | ## 1.0.6 - 2018-10-16 92 | 93 | ### Fixed 94 | 95 | - Fix list unique check slow #17,#21 96 | - Fix install error when system encoding not UTF-8 #19 97 | 98 | ## 1.0.4 - 2018-08-02 99 | 100 | ### Added 101 | 102 | - Add `is_string` and `validator` attributes to validator 103 | 104 | ## 1.0.3 - 2018-07-03 105 | 106 | ### Changed 107 | 108 | - Support set and frozenset as schema slice keys 109 | - Support create model with dict as position argument 110 | - Fix copy custom validator 111 | 112 | ## 1.0.0 - 2018-06-29 113 | 114 | ### Added 115 | 116 | - A Python based schema and validators, easy to write schema with fewer mistakes 117 | - Model class, similar to dataclass in python 3.7 118 | 119 | ### Changed 120 | 121 | - Not compatible with previous schema! 122 | 123 | ## 0.14.1 - 2017-05-25 124 | 125 | This is the last version of **old schema syntax**. 126 | 127 | ### Added 128 | 129 | - A JSON String based schema and validators, works for an internal web application 130 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Validr 2 | 3 | [![github-action](https://github.com/guyskk/validr/actions/workflows/build-test.yml/badge.svg?branch=main)](https://github.com/guyskk/validr/actions/workflows/build-test.yml) [![codecov](https://codecov.io/gh/guyskk/validr/branch/master/graph/badge.svg)](https://codecov.io/gh/guyskk/validr) 4 | 5 | A simple, fast, extensible python library for data validation. 6 | 7 | - Simple and readable schema 8 | - 10X faster than [jsonschema](https://github.com/Julian/jsonschema), 9 | 40X faster than [schematics](https://github.com/schematics/schematics) 10 | - Can validate and serialize any object 11 | - Easy to create custom validators 12 | - Accurate and friendly error messages 13 | 14 | 简单,快速,可拓展的数据校验库。 15 | 16 | - 简洁,易读的 Schema 17 | - 比 [jsonschema](https://github.com/Julian/jsonschema) 快 10 倍,比 [schematics](https://github.com/schematics/schematics) 快 40 倍 18 | - 能够校验&序列化任意类型对象 19 | - 易于拓展自定义校验器 20 | - 准确友好的错误提示 21 | 22 | ## Overview 23 | 24 | ```python 25 | from validr import T, modelclass, asdict 26 | 27 | @modelclass 28 | class Model: 29 | """Base Model""" 30 | 31 | class Person(Model): 32 | name=T.str.maxlen(16).desc('at most 16 chars') 33 | website=T.url.optional.desc('website is optional') 34 | 35 | guyskk = Person(name='guyskk', website='https://github.com/guyskk') 36 | print(asdict(guyskk)) 37 | ``` 38 | 39 | ## Install 40 | 41 | Note: Only support Python 3.5+ 42 | 43 | pip install validr 44 | 45 | When you have c compiler in your system, validr will be c speedup mode. 46 | Otherwise validr will fallback to pure python mode. 47 | 48 | To force c speedup mode: 49 | 50 | VALIDR_SETUP_MODE=c pip install validr 51 | 52 | To force pure python mode: 53 | 54 | VALIDR_SETUP_MODE=py pip install validr 55 | 56 | ## Document 57 | 58 | https://github.com/guyskk/validr/wiki 59 | 60 | ## Performance 61 | 62 | benchmark result in Travis-CI: 63 | 64 | ``` 65 | --------------------------timeits--------------------------- 66 | voluptuous:default 10000 loops cost 0.368s 67 | schema:default 1000 loops cost 0.318s 68 | json:loads-dumps 100000 loops cost 1.380s 69 | validr:default 100000 loops cost 0.719s 70 | validr:model 100000 loops cost 1.676s 71 | jsonschema:draft3 10000 loops cost 0.822s 72 | jsonschema:draft4 10000 loops cost 0.785s 73 | schematics:default 1000 loops cost 0.792s 74 | ---------------------------scores--------------------------- 75 | voluptuous:default 375 76 | schema:default 43 77 | json:loads-dumps 1000 78 | validr:default 1918 79 | validr:model 823 80 | jsonschema:draft3 168 81 | jsonschema:draft4 176 82 | schematics:default 17 83 | ``` 84 | 85 | ## Develop 86 | 87 | Validr is implemented by [Cython](http://cython.org/) since v0.14.0, it's 5X 88 | faster than pure Python implemented. 89 | 90 | **setup**: 91 | 92 | It's better to use [virtualenv](https://virtualenv.pypa.io/en/stable/) or 93 | similar tools to create isolated Python environment for develop. 94 | 95 | After that, install dependencys: 96 | 97 | ``` 98 | ./bootstrap.sh 99 | ``` 100 | 101 | **build, test and benchmark**: 102 | 103 | ``` 104 | inv build 105 | inv test 106 | inv benchmark 107 | ``` 108 | 109 | ## License 110 | 111 | The project is open source under [Anti-996 License](/LICENSE) and [GNU GPL License](/LICENSE-GPL), you can choose one of them. 112 | -------------------------------------------------------------------------------- /tests/validators/test_dict.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from validr import Compiler, T, Invalid, SchemaError 3 | 4 | from ..helper import expect_position 5 | from . import case 6 | 7 | 8 | class User: 9 | 10 | def __init__(self, userid): 11 | self.userid = userid 12 | 13 | 14 | @case({ 15 | T.dict(userid=T.int): [ 16 | ({'userid': 1}, {'userid': 1}), 17 | (User(1), {'userid': 1}), 18 | ({'userid': 1, 'extra': 'xxx'}, {'userid': 1}), 19 | ], 20 | T.dict(userid=T.int).optional: { 21 | 'valid': [ 22 | None, 23 | ], 24 | 'invalid': [ 25 | {}, 26 | {'extra': 1} 27 | ] 28 | }, 29 | T.dict: { 30 | 'valid': [ 31 | {}, 32 | {'key': 'value'}, 33 | ], 34 | 'invalid': [ 35 | None, 36 | 123, 37 | ] 38 | } 39 | }) 40 | def test_dict(): 41 | pass 42 | 43 | 44 | compiler = Compiler() 45 | 46 | 47 | @expect_position('key[0].key') 48 | def test_dict_error_position(): 49 | validate = compiler.compile(T.dict(key=T.list(T.dict(key=T.int)))) 50 | validate({ 51 | 'key': [ 52 | {'key': 'x'} 53 | ] 54 | }) 55 | 56 | 57 | @case({ 58 | T.dict(userid=T.int).key(T.str.minlen(4)).value(T.int): [ 59 | ({'userid': 1}, {'userid': 1}), 60 | ({'userid': 1, 'extra': 123}, {'userid': 1, 'extra': 123}), 61 | [ 62 | User(1), 63 | {'userid': 1, 'xx': 123}, 64 | {'userid': 1, 'extra': 'xx'}, 65 | {'userid': 1, 123: 123}, 66 | ] 67 | ], 68 | T.dict(userid=T.int).key(T.str.minlen(4)): [ 69 | ({'userid': 1, 'extra': 123}, {'userid': 1, 'extra': 123}), 70 | ({'userid': 1, 'extra': 'abc'}, {'userid': 1, 'extra': 'abc'}), 71 | ], 72 | T.dict(userid=T.int).value(T.int): [ 73 | ({'userid': 1, 'yy': 123}, {'userid': 1, 'yy': 123}), 74 | ], 75 | }) 76 | def test_dynamic_dict(): 77 | pass 78 | 79 | 80 | def test_dynamic_dict_error(): 81 | with pytest.raises(SchemaError) as exinfo: 82 | compiler.compile(T.dict.key(T.int.default('xxx'))) 83 | assert exinfo.value.position == '$self_key' 84 | 85 | with pytest.raises(SchemaError) as exinfo: 86 | compiler.compile(T.dict.value(T.int.default('xxx'))) 87 | assert exinfo.value.position == '$self_value' 88 | 89 | f = compiler.compile(T.dict.key(T.str.minlen(4)).value(T.int)) 90 | 91 | with pytest.raises(Invalid) as exinfo: 92 | f({'xx': 123}) 93 | assert exinfo.value.position == '$self_key' 94 | assert exinfo.value.value == 'xx' 95 | 96 | with pytest.raises(Invalid) as exinfo: 97 | f({'yyyy': 'xx'}) 98 | assert exinfo.value.position == 'yyyy' 99 | assert exinfo.value.value == 'xx' 100 | 101 | 102 | @pytest.mark.parametrize('schema', [ 103 | T.dict.minlen(2).maxlen(3), 104 | T.dict.key(T.str).value(T.int).minlen(2).maxlen(3), 105 | ]) 106 | def test_dict_length(schema): 107 | f = compiler.compile(schema) 108 | assert f({'xxx': 123, 'yyy': 123}) 109 | assert f({'xxx': 123, 'yyy': 123, 'zzz': 123}) 110 | with pytest.raises(Invalid) as exinfo: 111 | f({'xxx': 123}) 112 | assert 'must >=' in exinfo.value.message 113 | with pytest.raises(Invalid) as exinfo: 114 | assert f({'xxx': 123, 'yyy': 123, 'zzz': 123, 'kkk': 123}) 115 | assert 'must <=' in exinfo.value.message 116 | 117 | 118 | @pytest.mark.parametrize('schema', [ 119 | T.dict.slim, 120 | T.dict(key1=T.str.optional, key2=T.int.optional).value(T.str).slim, 121 | ]) 122 | def test_slim_dict(schema): 123 | f = compiler.compile(schema) 124 | data = {'key1': '', 'key2': None, 'key3': 'xxx'} 125 | assert f(data) == {'key3': 'xxx'} 126 | -------------------------------------------------------------------------------- /tasks.py: -------------------------------------------------------------------------------- 1 | import os 2 | import re 3 | from invoke import task 4 | 5 | 6 | @task 7 | def clean(ctx): 8 | ctx.run('rm -rf build/*') 9 | ctx.run('rm -rf dist/*') 10 | ctx.run('rm -rf .pytest_cache') 11 | ctx.run('rm -rf src/validr/*.c') 12 | ctx.run('rm -rf src/validr/*.so') 13 | ctx.run('rm -rf src/validr/*_py.py') 14 | ctx.run(r'find . | grep -E "(__pycache__|\.egg-info|\.so|\.c|\.pyc|\.pyo)$" | xargs rm -rf') 15 | 16 | 17 | @task(pre=[clean]) 18 | def test(ctx, benchmark=False, profile=False, k=None): 19 | pytest_k = ' -k {}'.format(k) if k is not None else '' 20 | os.environ['VALIDR_SETUP_MODE'] = 'dist_dbg' 21 | ctx.run('pip install --no-deps -e .') 22 | # cython speedup mode 23 | ctx.run("pytest" + pytest_k) 24 | if benchmark: 25 | ctx.run('python benchmark/benchmark.py benchmark --validr') 26 | if profile: 27 | ctx.run('python benchmark/benchmark.py profile') 28 | # pure python mode 29 | ctx.run('rm -rf src/validr/*.so') 30 | ctx.run("pytest --cov-config=.coveragerc_py" + pytest_k) 31 | if benchmark: 32 | ctx.run('python benchmark/benchmark.py benchmark --validr') 33 | if profile: 34 | ctx.run('python benchmark/benchmark.py profile') 35 | 36 | 37 | @task(pre=[clean]) 38 | def build(ctx): 39 | os.environ['VALIDR_SETUP_MODE'] = 'dist' 40 | ctx.run('python setup.py build') 41 | ctx.run('python setup.py sdist') 42 | 43 | 44 | @task(pre=[build]) 45 | def e2e_test(ctx): 46 | PYTHON = '.test-venv/bin/python' 47 | PIP = '{PYTHON} -m pip'.format(PYTHON=PYTHON) 48 | 49 | os.environ['VALIDR_SETUP_MODE'] = 'py' 50 | ctx.run('python -m venv --clear .test-venv') 51 | ctx.run('{PIP} install dist/*'.format(PIP=PIP)) 52 | ctx.run('{PYTHON} tests/smoke.py'.format(PYTHON=PYTHON)) 53 | 54 | os.environ['VALIDR_SETUP_MODE'] = '' 55 | ctx.run('python -m venv --clear .test-venv') 56 | ctx.run('{PIP} install dist/*'.format(PIP=PIP)) 57 | ctx.run('{PYTHON} tests/smoke.py'.format(PYTHON=PYTHON)) 58 | 59 | 60 | @task(pre=[test, e2e_test, build]) 61 | def publish(ctx): 62 | ctx.run('twine upload dist/*') 63 | 64 | 65 | @task(pre=[build]) 66 | def benchmark(ctx, validr=False): 67 | os.environ['VALIDR_SETUP_MODE'] = '' 68 | ctx.run('pip uninstall -y validr') 69 | ctx.run('pip install dist/*') 70 | benchmark_command = 'python benchmark/benchmark.py benchmark' 71 | if validr: 72 | benchmark_command += ' --validr' 73 | ctx.run(benchmark_command) 74 | 75 | 76 | @task() 77 | def bumpversion(ctx, version=None): 78 | """ 79 | Fix bumpversion not support pre-release versioning: 80 | - https://github.com/peritus/bumpversion/issues/128 81 | - https://packaging.python.org/guides/distributing-packages-using-setuptools/#pre-release-versioning 82 | """ 83 | with open('setup.py') as f: 84 | text = f.read() 85 | # https://www.python.org/dev/peps/pep-0440/#appendix-b-parsing-version-strings-with-regular-expressions 86 | version_format = r'(0|[1-9][0-9]*)(\.(0|[1-9][0-9]*))*((a|b|rc)(0|[1-9][0-9]*))?' 87 | version_re = "version='({})'".format(version_format) 88 | match = re.search(version_re, text) 89 | assert match, 'version not found' 90 | old_version = match.group(1) 91 | print('old version: {}'.format(old_version)) 92 | if version: 93 | print('new version: {}'.format(version)) 94 | else: 95 | version = input('new version: ') 96 | assert re.fullmatch(version_format, version), 'new version invalid' 97 | assert version != old_version, 'version not changed' 98 | new_text = re.sub(version_re, "version='{}'".format(version), text) 99 | with open('setup.py', 'w') as f: 100 | f.write(new_text) 101 | os.system('git add setup.py') 102 | os.system('git commit -m "Bump version: {} → {}"'.format(old_version, version)) 103 | os.system('git tag v{}'.format(version)) 104 | -------------------------------------------------------------------------------- /src/validr/_vendor/fqdn.py: -------------------------------------------------------------------------------- 1 | """ 2 | https://github.com/ypcrts/fqdn/blob/develop/fqdn/__init__.py 3 | 4 | Changes: disable cached_property 5 | """ 6 | import re 7 | 8 | try: 9 | from functools import cached_property # Python 3.8 10 | except ImportError: 11 | cached_property = property 12 | 13 | 14 | class FQDN: 15 | """ 16 | From https://tools.ietf.org/html/rfc1035#page-9, RFC 1035 3.1. Name space 17 | definitions: 18 | 19 | Domain names in messages are expressed in terms of a sequence of 20 | labels. Each label is represented as a one octet length field followed 21 | by that number of octets. Since every domain name ends with the null 22 | label of the root, a domain name is terminated by a length byte of 23 | zero. The high order two bits of every length octet must be zero, and 24 | the remaining six bits of the length field limit the label to 63 octets 25 | or less. 26 | 27 | To simplify implementations, the total length of a domain name (i.e., 28 | label octets and label length octets) is restricted to 255 octets or 29 | less. 30 | 31 | 32 | Therefore the max length of a domain name is actually 253 ASCII bytes 33 | without the trailing null byte or the leading length byte, and the max 34 | length of a label is 62 bytes without the leading length byte. 35 | """ 36 | 37 | FQDN_REGEX = re.compile( 38 | r"^((?!-)[-A-Z\d]{1,63}(? 253: 67 | return False 68 | return bool(self.FQDN_REGEX.match(self.fqdn)) 69 | 70 | @cached_property 71 | def is_valid_absolute(self): 72 | """ 73 | True for a fully-qualified domain name (FQDN) that is RFC 74 | preferred-form compliant and ends with a `.`. 75 | 76 | With relative FQDNS in DNS lookups, the current hosts domain name or 77 | search domains may be appended. 78 | """ 79 | return self.fqdn.endswith(".") and self.is_valid 80 | 81 | @cached_property 82 | def is_valid_relative(self): 83 | """ 84 | True for a validated fully-qualified domain name that compiles with the 85 | RFC preferred-form and does not ends with a `.`. 86 | """ 87 | return not self.fqdn.endswith(".") and self.is_valid 88 | 89 | @cached_property 90 | def absolute(self): 91 | """ 92 | The FQDN as a string in absolute form 93 | """ 94 | if not self.is_valid: 95 | raise ValueError("invalid FQDN `{0}`".format(self.fqdn)) 96 | 97 | if self.is_valid_absolute: 98 | return self.fqdn 99 | 100 | return "{0}.".format(self.fqdn) 101 | 102 | @cached_property 103 | def relative(self): 104 | """ 105 | The FQDN as a string in relative form 106 | """ 107 | if not self.is_valid: 108 | raise ValueError("invalid FQDN `{0}`".format(self.fqdn)) 109 | 110 | if self.is_valid_absolute: 111 | return self.fqdn[:-1] 112 | 113 | return self.fqdn 114 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import os 2 | from glob import glob 3 | from multiprocessing import cpu_count 4 | from os.path import basename, dirname, splitext 5 | from platform import python_implementation 6 | 7 | from setuptools import Extension, setup 8 | 9 | 10 | def _read_file(filepath): 11 | filepath = os.path.join(dirname(__file__), filepath) 12 | with open(filepath, 'r', encoding='utf-8') as f: 13 | return f.read() 14 | 15 | 16 | _SETUP_OPTIONS = dict( 17 | name='validr', 18 | version='1.2.2', 19 | keywords='validation validator validate schema jsonschema', 20 | description=( 21 | 'A simple, fast, extensible python library for data validation.'), 22 | long_description=_read_file('README.md'), 23 | long_description_content_type="text/markdown", 24 | author='guyskk', 25 | author_email='guyskk@qq.com', 26 | url='https://github.com/guyskk/validr', 27 | license='MIT', 28 | packages=['validr', 'validr._vendor'], 29 | package_dir={'': 'src'}, 30 | package_data={'validr': ['*.pyi']}, 31 | include_package_data=True, 32 | install_requires=[ 33 | 'idna>=2.5', 34 | 'pyparsing>=2.1.0', 35 | ], 36 | zip_safe=False, 37 | classifiers=[ 38 | 'Intended Audience :: Developers', 39 | 'License :: OSI Approved :: MIT License', 40 | 'Operating System :: OS Independent', 41 | 'Programming Language :: Python :: 3 :: Only', 42 | 'Topic :: Software Development :: Libraries :: Python Modules' 43 | ] 44 | ) 45 | 46 | 47 | _SETUP_MODES = { 48 | 'pyx', # cythonize *.pyx 49 | 'pyx_dbg', # cythonize *.pyx with debug info 50 | 'c', # ext_modules from *.c 51 | 'c_dbg', # ext_modules from *.c with debug info 52 | 'py', # pure python 53 | 'dist', # build *_c.c and *_py.py for release 54 | 'dist_dbg', # build *_c.c and *_py.py for release with debug info 55 | } 56 | 57 | 58 | def _has_c_compiler(): 59 | try: 60 | import distutils.ccompiler 61 | cc = distutils.ccompiler.new_compiler() 62 | return cc.has_function('rand', includes=['stdlib.h']) 63 | except Exception as ex: 64 | print('failed to check c compiler: {}'.format(ex)) 65 | return False 66 | 67 | 68 | def _get_validr_setup_mode(): 69 | mode = os.getenv('VALIDR_SETUP_MODE') 70 | if mode: 71 | mode = mode.strip().lower() 72 | if mode not in _SETUP_MODES: 73 | err_msg = 'unknown validr setup mode {}'.format(mode) 74 | raise RuntimeError(err_msg) 75 | return mode 76 | if _has_c_compiler(): 77 | return 'c' 78 | else: 79 | return 'py' 80 | 81 | 82 | def _prepare_setup_options(mode): 83 | is_pyx = mode in ['pyx', 'pyx_dbg'] 84 | is_c = mode in ['c', 'c_dbg'] 85 | is_debug = mode.endswith('_dbg') 86 | is_dist = mode in ['dist', 'dist_dbg'] 87 | enable_c = python_implementation() == 'CPython' 88 | ext_modules = None 89 | if enable_c and (is_pyx or is_c or is_dist): 90 | if is_pyx or is_dist: 91 | from Cython.Build import cythonize 92 | directives = {'language_level': 3} 93 | if is_debug: 94 | directives.update({ 95 | 'profile': True, 96 | 'linetrace': True, 97 | }) 98 | ext_modules = cythonize( 99 | 'src/validr/*.pyx', 100 | nthreads=cpu_count(), 101 | compiler_directives=directives 102 | ) 103 | if is_c: 104 | sources = list(glob('src/validr/*.c')) 105 | if not sources: 106 | raise RuntimeError('Not found any *.c source files') 107 | ext_modules = [] 108 | for filepath in sources: 109 | module_name = 'validr.' + splitext(basename(filepath))[0] 110 | ext_modules.append(Extension(module_name, [filepath])) 111 | if is_debug: 112 | for m in ext_modules: 113 | m.define_macros.extend([ 114 | ('CYTHON_TRACE', '1'), 115 | ('CYTHON_TRACE_NOGIL', '1'), 116 | ('CYTHON_PROFILE', '1'), 117 | ]) 118 | if is_dist: 119 | from validr_uncython import compile_pyx_to_py 120 | sources = list(glob('src/validr/*.pyx')) 121 | compile_pyx_to_py(sources, debug=is_debug) 122 | 123 | return dict(ext_modules=ext_modules, **_SETUP_OPTIONS) 124 | 125 | 126 | def _validr_setup(): 127 | mode = _get_validr_setup_mode() 128 | print('VALIDR_SETUP_MODE={}'.format(mode)) 129 | options = _prepare_setup_options(mode) 130 | setup(**options) 131 | 132 | 133 | if __name__ == '__main__': 134 | _validr_setup() 135 | -------------------------------------------------------------------------------- /benchmark/benchmark.py: -------------------------------------------------------------------------------- 1 | """ 2 | benchmark data validation librarys 3 | 4 | usage: 5 | 6 | python benchmark.py 7 | 8 | add case: 9 | 1. create case_{CASE_NAME}.py 10 | 2. implement one or more funcs which can validate the `DATA` in this module 11 | 3. put validate funcs in a dict named `CASES` in case module 12 | """ 13 | import json 14 | from profile import runctx 15 | from glob import glob 16 | from os.path import basename, dirname, splitext 17 | from timeit import Timer as BaseTimer 18 | from pprint import pprint as pp 19 | 20 | import click 21 | 22 | DATA = { 23 | 'user': {'userid': 5}, 24 | 'tags': [1, 2, 5, 9999, 1234567890], 25 | 'style': { 26 | 'width': 400, 27 | 'height': 400, 28 | 'border_width': 5, 29 | 'border_style': 'solid', 30 | 'border_color': 'red', 31 | 'color': 'black' 32 | }, 33 | # "optional": "string" 34 | } 35 | TEXT = json.dumps(DATA) 36 | 37 | 38 | def make_data(): 39 | return json.loads(TEXT) 40 | 41 | 42 | def glob_cases(): 43 | files = glob(dirname(__file__) + '/case_*.py') 44 | cases = {} 45 | for filename in files: 46 | module = splitext(basename(filename))[0] 47 | name = module[len('case_'):] 48 | cases[name] = __import__(module).CASES 49 | return cases 50 | 51 | 52 | CASES = glob_cases() 53 | 54 | # support Timer.autorange which add in python 3.6 55 | if hasattr(BaseTimer, 'autorange'): 56 | Timer = BaseTimer 57 | else: 58 | class Timer(BaseTimer): 59 | def autorange(self, callback=None): 60 | """Return the number of loops and time taken so that total time >= 0.2. 61 | Calls the timeit method with *number* set to successive powers of 62 | ten (10, 100, 1000, ...) up to a maximum of one billion, until 63 | the time taken is at least 0.2 second, or the maximum is reached. 64 | Returns ``(number, time_taken)``. 65 | If *callback* is given and is not None, it will be called after 66 | each trial with two arguments: ``callback(number, time_taken)``. 67 | """ 68 | for i in range(1, 10): 69 | number = 10**i 70 | time_taken = self.timeit(number) 71 | if callback: 72 | callback(number, time_taken) 73 | if time_taken >= 0.2: 74 | break 75 | return (number, time_taken) 76 | 77 | 78 | @click.group() 79 | def cli(): 80 | pass 81 | 82 | 83 | @cli.command() 84 | def show(): 85 | """show all cases""" 86 | pp({name: list(cases) for name, cases in CASES.items()}) 87 | 88 | 89 | def print_item(name, subname, value): 90 | print('{:>12}:{:<16} {}'.format(name, subname, value)) 91 | 92 | 93 | @cli.command() 94 | def test(): 95 | """test all cases""" 96 | for name, subcases in CASES.items(): 97 | for subname, f in subcases.items(): 98 | try: 99 | value = f(make_data()) 100 | assert value['user'] == DATA['user'] 101 | assert value['tags'] == DATA['tags'] 102 | assert value['style'] == DATA['style'] 103 | msg = 'OK' 104 | except AssertionError: 105 | msg = 'Failed\n{line}\n{value}{line}'.format( 106 | line='-' * 60, value=pp(value, output=False)) 107 | except Exception as ex: 108 | msg = 'Failed: ' + str(ex) 109 | print_item(name, subname, msg) 110 | 111 | 112 | @cli.command() 113 | @click.option('--validr', is_flag=True, help='only benchmark validr') 114 | def benchmark(validr): 115 | """do benchmark""" 116 | if validr: 117 | cases = {k: CASES[k] for k in ['json', 'validr']} 118 | else: 119 | cases = CASES 120 | result = {} 121 | 122 | print('timeits'.center(60, '-')) 123 | for name, suncases in cases.items(): 124 | for subname, f in suncases.items(): 125 | data = make_data() 126 | n, t = Timer(lambda: f(data)).autorange() 127 | result[name, subname] = t / n 128 | print_item(name, subname, '{:>8} loops cost {:.3f}s'.format(n, t)) 129 | 130 | print('scores'.center(60, '-')) 131 | base = result['json', 'loads-dumps'] 132 | for (name, subname), v in result.items(): 133 | print_item(name, subname, '{:>8}'.format(round(base / v * 1000))) 134 | 135 | 136 | @cli.command() 137 | def profile(): 138 | """profile validr""" 139 | for name, f in CASES['validr'].items(): 140 | print(name.center(60, '-')) 141 | params = {'f': f, 'data': make_data()} 142 | runctx('for i in range(10**5): f(data)', globals=params, locals=None) 143 | 144 | 145 | if __name__ == '__main__': 146 | cli() 147 | -------------------------------------------------------------------------------- /tests/test_schema.py: -------------------------------------------------------------------------------- 1 | import json 2 | import enum 3 | import pytest 4 | from validr import T, Schema, Compiler, SchemaError, modelclass 5 | 6 | from .helper import skipif_dict_not_ordered 7 | 8 | EXPECT = { 9 | '$self': "dict.optional.desc('a dict')", 10 | 'key': [ 11 | 'list.unique', 12 | 'int.min(0).max(9)' 13 | ], 14 | 'tag': "str.desc('a tag')" 15 | } 16 | 17 | 18 | @skipif_dict_not_ordered() 19 | def test_str_copy_and_to_primitive(): 20 | schema = T.dict( 21 | key=T.list(T.int.min(0).max(9)).unique.optional(False), 22 | tag=T.str.desc('a tag'), 23 | ).optional.desc('a dict').__schema__ 24 | assert schema.to_primitive() == EXPECT 25 | assert json.loads(str(schema)) == EXPECT 26 | copy = schema.copy() 27 | assert copy.to_primitive() == EXPECT 28 | # verify copy is deep copy 29 | schema.items['key'].items = T.int 30 | assert copy.to_primitive() == EXPECT 31 | 32 | 33 | def test_param_type(): 34 | T.str.default(None) 35 | T.str.default("") 36 | T.int.default(0) 37 | T.bool.default(True) 38 | T.float.default(1.5) 39 | with pytest.raises(SchemaError): 40 | T.xxx.param([]) 41 | with pytest.raises(SchemaError): 42 | T.xxx.param({}) 43 | with pytest.raises(SchemaError): 44 | T.xxx.param(object) 45 | 46 | 47 | def test_repr(): 48 | schema = T.dict( 49 | key=T.list(T.int).unique, 50 | ).optional.desc('a dict') 51 | assert repr(schema) == "T.dict({key}).optional.desc('a dict')" 52 | schema = T.list(T.int.min(0)).unique 53 | assert repr(schema) == 'T.list(int).unique' 54 | schema = T.str.minlen(10).optional(False) 55 | assert repr(schema) == 'T.str.minlen(10)' 56 | assert repr(Schema()) == 'Schema<>' 57 | 58 | 59 | def test_compiled_items(): 60 | compiler = Compiler() 61 | value = compiler.compile(T.int.min(0)) 62 | assert repr(T.dict(key=value)) == 'T.dict({key})' 63 | assert repr(T.list(value)) == 'T.list(int)' 64 | 65 | 66 | def test_load_schema(): 67 | compiler = Compiler() 68 | schema = T.list(T.int.min(0)) 69 | assert T(schema) == schema 70 | assert T(compiler.compile(schema)) == schema 71 | assert T(['int.min(0)']) == schema 72 | 73 | 74 | def test_slice(): 75 | schema = T.dict( 76 | id=T.int, 77 | age=T.int.min(0), 78 | name=T.str, 79 | ).optional 80 | assert schema['id'] == T.dict(id=T.int).optional 81 | assert schema['age', 'name'] == T.dict( 82 | age=T.int.min(0), 83 | name=T.str, 84 | ).optional 85 | 86 | 87 | def test_union_list(): 88 | schema = T.union([ 89 | T.str, 90 | T.list(T.str), 91 | T.dict(key1=T.str), 92 | T.dict(key1=T.str, key2=T.str), 93 | ]).optional 94 | assert schema.__schema__.copy() == schema.__schema__ 95 | assert T(json.loads(str(schema))) == schema 96 | assert T(schema.__schema__.to_primitive()) == schema 97 | with pytest.raises(SchemaError): 98 | T.union(T.str, T.int) 99 | with pytest.raises(SchemaError): 100 | T.union([T.str, int]) 101 | 102 | 103 | def test_union_dict(): 104 | schema = T.union( 105 | type1=T.dict(key1=T.str), 106 | type2=T.dict(key2=T.str, key3=T.list(T.int)), 107 | ).by('type').optional 108 | assert T(json.loads(str(schema))) == schema 109 | assert T(schema.__schema__.to_primitive()) == schema 110 | with pytest.raises(SchemaError): 111 | T.union(type1=T.dict, type2=dict) 112 | 113 | 114 | def test_dynamic_dict(): 115 | schema = T.dict(labels=T.list(T.str)).key( 116 | T.str.minlen(2) 117 | ).value( 118 | T.list(T.int) 119 | ).optional 120 | assert T(json.loads(str(schema))) == schema 121 | assert T(schema.__schema__.to_primitive()) == schema 122 | with pytest.raises(SchemaError): 123 | T.dict.key('abc') 124 | with pytest.raises(SchemaError): 125 | T.dict.value('abc') 126 | 127 | 128 | def test_enum(): 129 | schema = T.enum([123, 'xyz', True]).optional 130 | assert T(json.loads(str(schema))) == schema 131 | assert T(schema.__schema__.to_primitive()) == schema 132 | T.enum('A B C') == T.enum(['A', 'B', 'C']) 133 | with pytest.raises(SchemaError): 134 | T.enum([T.str]) 135 | with pytest.raises(SchemaError): 136 | T.enum([object]) 137 | 138 | 139 | def test_enum_class(): 140 | class ABC(enum.IntEnum): 141 | A = 1 142 | B = 2 143 | assert T.enum(ABC) == T.enum([1, 2]) 144 | assert T.enum(enum.Enum('ABC', 'A,B')) == T.enum([1, 2]) 145 | 146 | 147 | def test_model(): 148 | 149 | @modelclass 150 | class UserModel: 151 | name = T.str 152 | age = T.int.min(0) 153 | 154 | class ABC: 155 | value = 123 156 | 157 | T.model(UserModel).optional 158 | 159 | with pytest.raises(SchemaError): 160 | T.model(ABC) 161 | 162 | with pytest.raises(SchemaError): 163 | T.model(a=UserModel, b=UserModel) 164 | -------------------------------------------------------------------------------- /tests/validators/test_union.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from validr import T, SchemaError, Invalid 3 | from . import case, compiler 4 | 5 | 6 | @case({ 7 | T.union([T.int, T.list(T.int)]): [ 8 | (111, 111), 9 | ([111, 222], [111, 222]), 10 | ([], []), 11 | [None, '', 'xxx'], 12 | ], 13 | T.union([T.str, T.list(T.str)]).optional: [ 14 | ([], []), 15 | (None, ''), 16 | ('', ''), 17 | [object], 18 | ], 19 | T.union([T.list(T.int)]).optional: [ 20 | ([111, 222], [111, 222]), 21 | (None, None), 22 | ['xxx', '', ['yyy']], 23 | ], 24 | T.union([ 25 | T.int, 26 | T.list(T.int), 27 | T.dict(key=T.int), 28 | ]): [ 29 | (111, 111), 30 | ([111, 222], [111, 222]), 31 | ({'key': 333}, {'key': 333}), 32 | ] 33 | }) 34 | def test_union_list(): 35 | pass 36 | 37 | 38 | @case({ 39 | T.union( 40 | type1=T.dict(key=T.str), 41 | type2=T.dict(key=T.list(T.int)), 42 | ).by('type'): [ 43 | ({'type': 'type1', 'key': 'xxx'}, {'type': 'type1', 'key': 'xxx'}), 44 | ({'type': 'type2', 'key': [1, 2, 3]}, {'type': 'type2', 'key': [1, 2, 3]}), 45 | [ 46 | {'type': 'xxx', 'key': 'xxx'}, 47 | {'key': 'xxx'}, 48 | 'xxx', 49 | None, 50 | ] 51 | ], 52 | T.union( 53 | type1=T.dict(key=T.str), 54 | ).by('type').optional: [ 55 | ({'type': 'type1', 'key': 'xxx'}, {'type': 'type1', 'key': 'xxx'}), 56 | (None, None), 57 | [ 58 | {'type': 'xxx', 'key': 'xxx'}, 59 | ] 60 | ] 61 | }) 62 | def test_union_dict(): 63 | pass 64 | 65 | 66 | def test_compile_union(): 67 | with pytest.raises(SchemaError) as exinfo: 68 | compiler.compile(T.union) 69 | assert 'union schemas not provided' in exinfo.value.message 70 | 71 | with pytest.raises(SchemaError) as exinfo: 72 | compiler.compile(T.union([T.str]).default('xxx')) 73 | assert 'default' in exinfo.value.message 74 | 75 | with pytest.raises(SchemaError) as exinfo: 76 | compiler.compile(T.union([T.str]).by('type')) 77 | assert 'by' in exinfo.value.message 78 | 79 | with pytest.raises(SchemaError) as exinfo: 80 | compiler.compile(T.union(t1=T.dict(k1=T.str), t2=T.dict(k2=T.str))) 81 | assert 'by' in exinfo.value.message 82 | 83 | with pytest.raises(SchemaError) as exinfo: 84 | compiler.compile(T.union(t1=T.dict(k1=T.str)).by(123)) 85 | assert 'by' in exinfo.value.message 86 | 87 | 88 | def test_compile_union_list(): 89 | with pytest.raises(SchemaError) as exinfo: 90 | compiler.compile(T.union([T.union([T.str])])) 91 | assert 'ambiguous' in exinfo.value.message 92 | 93 | with pytest.raises(SchemaError) as exinfo: 94 | compiler.compile(T.union([T.str.optional])) 95 | assert 'optional' in exinfo.value.message 96 | 97 | with pytest.raises(SchemaError) as exinfo: 98 | compiler.compile(T.union([T.str.default('xxx')])) 99 | assert 'default' in exinfo.value.message 100 | 101 | with pytest.raises(SchemaError) as exinfo: 102 | compiler.compile(T.union([T.int, T.str])) 103 | assert 'ambiguous' in exinfo.value.message 104 | 105 | with pytest.raises(SchemaError) as exinfo: 106 | compiler.compile(T.union([T.list(T.int), T.list(T.int)])) 107 | assert 'ambiguous' in exinfo.value.message 108 | 109 | with pytest.raises(SchemaError) as exinfo: 110 | compiler.compile(T.union([T.dict(k1=T.str), T.dict(k2=T.int)])) 111 | assert 'ambiguous' in exinfo.value.message 112 | 113 | 114 | def test_compile_union_dict(): 115 | with pytest.raises(SchemaError) as exinfo: 116 | compiler.compile(T.union(k1=T.str).by('type')) 117 | assert 'dict' in exinfo.value.message 118 | 119 | with pytest.raises(SchemaError) as exinfo: 120 | compiler.compile(T.union(k1=T.dict.optional).by('type')) 121 | assert 'optional' in exinfo.value.message 122 | 123 | 124 | def test_union_list_error_position(): 125 | f = compiler.compile(T.list(T.union([ 126 | T.int, 127 | T.list(T.int), 128 | T.dict(k=T.int), 129 | ]))) 130 | 131 | with pytest.raises(Invalid) as exinfo: 132 | f([123, 'xxx']) 133 | assert exinfo.value.position == '[1]' 134 | 135 | with pytest.raises(Invalid) as exinfo: 136 | f([123, [456, 'xxx']]) 137 | assert exinfo.value.position == '[1][1]' 138 | 139 | with pytest.raises(Invalid) as exinfo: 140 | f([123, {'k': 'xxx'}]) 141 | assert exinfo.value.position == '[1].k' 142 | 143 | 144 | def test_union_dict_error_position(): 145 | f = compiler.compile(T.union( 146 | t1=T.dict(k1=T.int), 147 | t2=T.dict(k2=T.list(T.int)), 148 | ).by('type')) 149 | 150 | with pytest.raises(Invalid) as exinfo: 151 | f({'k1': 123}) 152 | assert exinfo.value.position == 'type' 153 | 154 | with pytest.raises(Invalid) as exinfo: 155 | f({'k1': 'xxx', 'type': 'txxx'}) 156 | assert exinfo.value.position == 'type' 157 | 158 | with pytest.raises(Invalid) as exinfo: 159 | f({'k1': 'xxx', 'type': 't1'}) 160 | assert exinfo.value.position == 'k1' 161 | 162 | with pytest.raises(Invalid) as exinfo: 163 | f({'k2': ['xxx'], 'type': 't2'}) 164 | assert exinfo.value.position == 'k2[0]' 165 | -------------------------------------------------------------------------------- /src/validr/_vendor/durationpy.py: -------------------------------------------------------------------------------- 1 | """ 2 | https://github.com/icholy/durationpy/blob/master/durationpy/duration.py 3 | 4 | >>> int(from_str('0').total_seconds()) 5 | 0 6 | >>> int(from_str('60s').total_seconds()) 7 | 60 8 | >>> int(from_str('3m30s').total_seconds()) 9 | 210 10 | >>> int(from_str('2h3m30s').total_seconds()) 11 | 7410 12 | >>> int(from_str('1d10s').total_seconds()) 13 | 86410 14 | >>> int(from_str('-1s').total_seconds()) 15 | -1 16 | >>> print(to_str(datetime.timedelta(seconds=5))) 17 | 5s 18 | >>> print(to_str(datetime.timedelta(seconds=59))) 19 | 59s 20 | >>> print(to_str(datetime.timedelta(seconds=61))) 21 | 1m1s 22 | >>> print(to_str(datetime.timedelta(seconds=3600))) 23 | 1h 24 | >>> print(to_str(datetime.timedelta(seconds=3600 + 60 + 5))) 25 | 1h1m5s 26 | >>> print(to_str(datetime.timedelta(seconds=23*60*60 + 60 + 5))) 27 | 23h1m5s 28 | >>> print(to_str(datetime.timedelta(seconds=0.005))) 29 | 5ms 30 | >>> print(to_str(datetime.timedelta(seconds=0.000005))) 31 | 5us 32 | >>> D = 24*60*60 33 | >>> print(to_str(datetime.timedelta(seconds=2*D + 60 + 5), extended=True)) 34 | 2d1m5s 35 | >>> print(to_str(datetime.timedelta(seconds=35*D + 60 + 5), extended=True)) 36 | 1mo5d1m5s 37 | >>> print(to_str(datetime.timedelta(seconds=370*D + 60 + 5), extended=True)) 38 | 1y5d1m5s 39 | >>> from_str('10x') 40 | Traceback (most recent call last): 41 | ... 42 | validr._vendor.durationpy.DurationError: Unknown unit x in duration 10x 43 | >>> from_str('1..0h') 44 | Traceback (most recent call last): 45 | ... 46 | validr._vendor.durationpy.DurationError: Invalid value 1..0 in duration 1..0h 47 | """ 48 | import re 49 | import datetime 50 | 51 | _nanosecond_size = 1 52 | _microsecond_size = 1000 * _nanosecond_size 53 | _millisecond_size = 1000 * _microsecond_size 54 | _second_size = 1000 * _millisecond_size 55 | _minute_size = 60 * _second_size 56 | _hour_size = 60 * _minute_size 57 | _day_size = 24 * _hour_size 58 | _week_size = 7 * _day_size 59 | _month_size = 30 * _day_size 60 | _year_size = 365 * _day_size 61 | 62 | _units = { 63 | "ns": _nanosecond_size, 64 | "us": _microsecond_size, 65 | "µs": _microsecond_size, 66 | "μs": _microsecond_size, 67 | "ms": _millisecond_size, 68 | "s": _second_size, 69 | "m": _minute_size, 70 | "h": _hour_size, 71 | "d": _day_size, 72 | "w": _week_size, 73 | "mm": _month_size, # deprecated, use 'mo' instead 74 | "mo": _month_size, 75 | "y": _year_size, 76 | } 77 | 78 | 79 | class DurationError(ValueError): 80 | """duration error""" 81 | 82 | 83 | _RE_DURATION = re.compile(r'([\d\.]+)([a-zµμ]+)') 84 | 85 | 86 | def from_str(duration: str) -> datetime.timedelta: 87 | """Parse a duration string to a datetime.timedelta""" 88 | 89 | duration = duration.strip() 90 | 91 | if duration in ("0", "+0", "-0"): 92 | return datetime.timedelta() 93 | 94 | total = 0 95 | sign = -1 if duration[0] == '-' else 1 96 | matches = _RE_DURATION.findall(duration) 97 | 98 | if not len(matches): 99 | raise DurationError("Invalid duration {}".format(duration)) 100 | 101 | for (value, unit) in matches: 102 | if unit not in _units: 103 | err_msg = "Unknown unit {} in duration {}".format(unit, duration) 104 | raise DurationError(err_msg) 105 | try: 106 | total += float(value) * _units[unit] 107 | except Exception: 108 | err_msg = "Invalid value {} in duration {}".format(value, duration) 109 | raise DurationError(err_msg) 110 | 111 | microseconds = total / _microsecond_size 112 | return datetime.timedelta(microseconds=sign * microseconds) 113 | 114 | 115 | def to_str(delta: datetime.timedelta, extended=False) -> str: 116 | """Format a datetime.timedelta to a duration string""" 117 | 118 | total_seconds = delta.total_seconds() 119 | sign = "-" if total_seconds < 0 else "" 120 | nanoseconds = abs(total_seconds * _second_size) 121 | 122 | if total_seconds < 1: 123 | result_str = _to_str_small(nanoseconds, extended) 124 | else: 125 | result_str = _to_str_large(nanoseconds, extended) 126 | 127 | return "{}{}".format(sign, result_str) 128 | 129 | 130 | def _to_str_small(nanoseconds, extended): 131 | 132 | result_str = "" 133 | 134 | if not nanoseconds: 135 | return "0" 136 | 137 | milliseconds = int(nanoseconds / _millisecond_size) 138 | if milliseconds: 139 | nanoseconds -= _millisecond_size * milliseconds 140 | result_str += "{:g}ms".format(milliseconds) 141 | 142 | microseconds = int(nanoseconds / _microsecond_size) 143 | if microseconds: 144 | nanoseconds -= _microsecond_size * microseconds 145 | result_str += "{:g}us".format(microseconds) 146 | 147 | if nanoseconds: 148 | result_str += "{:g}ns".format(nanoseconds) 149 | 150 | return result_str 151 | 152 | 153 | def _to_str_large(nanoseconds, extended): 154 | 155 | result_str = "" 156 | 157 | if extended: 158 | 159 | years = int(nanoseconds / _year_size) 160 | if years: 161 | nanoseconds -= _year_size * years 162 | result_str += "{:g}y".format(years) 163 | 164 | months = int(nanoseconds / _month_size) 165 | if months: 166 | nanoseconds -= _month_size * months 167 | result_str += "{:g}mo".format(months) 168 | 169 | days = int(nanoseconds / _day_size) 170 | if days: 171 | nanoseconds -= _day_size * days 172 | result_str += "{:g}d".format(days) 173 | 174 | hours = int(nanoseconds / _hour_size) 175 | if hours: 176 | nanoseconds -= _hour_size * hours 177 | result_str += "{:g}h".format(hours) 178 | 179 | minutes = int(nanoseconds / _minute_size) 180 | if minutes: 181 | nanoseconds -= _minute_size * minutes 182 | result_str += "{:g}m".format(minutes) 183 | 184 | seconds = float(nanoseconds) / float(_second_size) 185 | if seconds: 186 | nanoseconds -= _second_size * seconds 187 | result_str += "{:g}s".format(seconds) 188 | 189 | return result_str 190 | -------------------------------------------------------------------------------- /tests/test_model.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from validr import ( 3 | T, 4 | modelclass, 5 | fields, 6 | asdict, 7 | Compiler, 8 | Invalid, 9 | ModelInvalid, 10 | ImmutableInstanceError, 11 | ) 12 | 13 | from .helper import skipif_dict_not_ordered 14 | 15 | 16 | @modelclass 17 | class MyModel: 18 | 19 | id = T.int.min(0) 20 | 21 | def __post_init__(self): 22 | self.id_x2 = self.id * 2 23 | 24 | 25 | class UserLabel(MyModel): 26 | value = T.str 27 | 28 | 29 | class User(MyModel): 30 | 31 | id = T.int.min(100).default(100) 32 | name = T.str 33 | label = T.model(UserLabel).optional 34 | 35 | def __post_init__(self): 36 | self.id_x3 = self.id * 3 37 | 38 | 39 | class LabelList(MyModel): 40 | labels = T.list(T.model(UserLabel)) 41 | 42 | 43 | class CustomModel(MyModel): 44 | def __eq__(self, other): 45 | return id(self) == id(other) 46 | 47 | def get_id(self): 48 | return self.id 49 | 50 | 51 | @modelclass(compiler=Compiler(), immutable=True) 52 | class ImmutableModel: 53 | id = T.int.min(0) 54 | 55 | def __init__(self, id=None): 56 | self.id = id 57 | 58 | 59 | def test_model(): 60 | user = User(name="test", label=dict(id=1, value='cool')) 61 | assert user.id == 100 62 | assert user.name == "test" 63 | assert isinstance(user.label, UserLabel) 64 | assert user.label.value == 'cool' 65 | with pytest.raises(Invalid): 66 | user.id = -1 67 | 68 | 69 | def test_model_list(): 70 | label_list = LabelList(id=42, labels=[ 71 | dict(id=1, value='cool'), 72 | UserLabel(id=2, value='nice'), 73 | ]) 74 | assert len(label_list.labels) == 2 75 | assert label_list.labels[0].value == 'cool' 76 | assert label_list.labels[1].value == 'nice' 77 | 78 | 79 | def test_post_init(): 80 | user = User(id=100, name="test") 81 | assert user.id == 100 82 | assert user.id_x2 == 200 83 | assert user.id_x3 == 300 84 | 85 | 86 | def test_immutable(): 87 | m = ImmutableModel(id=1) 88 | assert m.id == 1 89 | with pytest.raises(ImmutableInstanceError): 90 | m.id = 2 91 | with pytest.raises(ImmutableInstanceError): 92 | del m.id 93 | 94 | 95 | def test_custom_method(): 96 | m1 = MyModel(id=1) 97 | m2 = MyModel(id=1) 98 | assert m1 == m2 99 | x1 = CustomModel(id=1) 100 | x2 = CustomModel(id=1) 101 | assert x1.get_id() == x2.get_id() 102 | assert x1 != x2 103 | 104 | 105 | @skipif_dict_not_ordered() 106 | def test_repr(): 107 | assert repr(MyModel) == "MyModel" 108 | assert repr(CustomModel) == "CustomModel" 109 | assert repr(User) == "User" 110 | assert repr(User.id) == "Field(name='id', schema=Schema)" 111 | assert repr(User.label) == "Field(name='label', schema=Schema)" 112 | user = User(id=100, name="test") 113 | assert repr(user) == "User(id=100, name='test', label=None)" 114 | 115 | 116 | def test_schema(): 117 | assert T(MyModel) == T.dict(id=T.int.min(0)) 118 | assert T(User.__schema__.to_primitive()) == T.dict( 119 | id=T.int.min(100).default(100), 120 | name=T.str, 121 | label=T.dict( 122 | id=T.int.min(0), 123 | value=T.str, 124 | ).optional, 125 | ) 126 | 127 | 128 | def test_fields(): 129 | assert fields(User) == {"id", "name", "label"} 130 | user = User(id=123, name="test") 131 | assert fields(user) == {"id", "name", "label"} 132 | assert fields(T.dict) == set() 133 | assert fields(T(User)) == {"id", "name", "label"} 134 | assert fields(T(User).__schema__) == {"id", "name", "label"} 135 | with pytest.raises(TypeError): 136 | fields(T.list(T.str)) 137 | 138 | 139 | def test_asdict(): 140 | label = UserLabel(id=1, value="cool") 141 | user = User(id=123, name="test", label=label) 142 | assert asdict(user) == {"id": 123, "name": "test", "label": {"id": 1, "value": "cool"}} 143 | assert asdict(user, keys=["name"]) == {"name": "test"} 144 | 145 | 146 | @skipif_dict_not_ordered() 147 | def test_model_data(): 148 | 149 | @modelclass 150 | class Model: 151 | pass 152 | 153 | class TaskTarget(Model): 154 | name = T.str 155 | image = T.str.optional 156 | 157 | class TaskStep(Model): 158 | command = T.str 159 | target = T.model(TaskTarget) 160 | 161 | class Task(Model): 162 | name = T.str 163 | steps = T.list(T.model(TaskStep)) 164 | 165 | data = dict( 166 | name='deploy', 167 | steps=[ 168 | dict( 169 | command='git clone', 170 | target=dict( 171 | name='docker', 172 | image='ubuntu:16.04', 173 | ) 174 | ), 175 | dict( 176 | command='bash build.sh', 177 | target=dict( 178 | name='docker', 179 | image='ubuntu:16.04', 180 | ) 181 | ), 182 | ] 183 | ) 184 | task = Task(data) 185 | assert asdict(task) == data 186 | 187 | 188 | def test_slice(): 189 | expect = T.int.min(100).default(100) 190 | assert User["id"] == expect 191 | assert User["id", ] == T.dict(id=expect) 192 | assert T(User.id) == expect 193 | assert User["id", "name"] == T.dict(id=expect, name=T.str) 194 | assert User["label"] == T.model(UserLabel).optional 195 | with pytest.raises(KeyError): 196 | User["unknown"] 197 | with pytest.raises(KeyError): 198 | User["id", "unknown"] 199 | 200 | 201 | def test_init(): 202 | with pytest.raises(TypeError): 203 | User(1, 2) 204 | user = User({"id": 123, "name": "test", "unknown": "xxx"}) 205 | assert user == User(id=123, name="test") 206 | u2 = User(user, id=456) 207 | assert u2.id == 456 208 | with pytest.raises(ModelInvalid) as exinfo: 209 | User(id=-1, name=object()) 210 | assert len(exinfo.value.errors) == 2 211 | with pytest.raises(ModelInvalid) as exinfo: 212 | User(id=123, name="test", unknown=0) 213 | assert len(exinfo.value.errors) == 1 214 | assert 'undesired key' in str(exinfo.value) 215 | -------------------------------------------------------------------------------- /src/validr/model.py: -------------------------------------------------------------------------------- 1 | """ 2 | Model class is a convenient way to use schema, it's inspired by data class 3 | but works differently. 4 | """ 5 | from .schema import Compiler, Schema, T 6 | from .validator import Field, py_model_asdict, py_model_init 7 | 8 | 9 | class ImmutableInstanceError(AttributeError): 10 | """Raised when an attempt is modify a immutable class""" 11 | 12 | 13 | def modelclass(cls=None, *, compiler=None, immutable=False): 14 | if cls is not None: 15 | return _create_model_class(cls, compiler, immutable) 16 | 17 | def decorator(cls): 18 | return _create_model_class(cls, compiler, immutable) 19 | 20 | return decorator 21 | 22 | 23 | def _extract_schemas(cls): 24 | schemas = {} 25 | for k, v in vars(cls).items(): 26 | if k == "__schema__": 27 | continue 28 | if hasattr(v, "__schema__"): 29 | v = v.__schema__ 30 | if isinstance(v, Schema): 31 | schemas[k] = v 32 | return schemas 33 | 34 | 35 | def _extract_post_init(cls): 36 | f = vars(cls).get("__post_init__", None) 37 | if f is None or not callable(f): 38 | return None 39 | return f 40 | 41 | 42 | def _create_model_class(model_cls, compiler, immutable): 43 | 44 | compiler = compiler or Compiler() 45 | 46 | class ModelMeta(type): 47 | def __init__(cls, *args, **kwargs): 48 | super().__init__(*args, **kwargs) 49 | schemas = {} 50 | post_inits = [] 51 | for cls_or_base in reversed(cls.__mro__): 52 | post_init = _extract_post_init(cls_or_base) 53 | if post_init is not None: 54 | post_inits.append(post_init) 55 | for name, schema in _extract_schemas(cls_or_base).items(): 56 | schemas[name] = schema 57 | for name, schema in schemas.items(): 58 | setattr(cls, name, Field(name, schema, compiler)) 59 | cls.__post_inits = post_inits 60 | cls.__schema__ = T.dict(schemas).__schema__ 61 | cls.__fields__ = frozenset(schemas) 62 | 63 | def post_init(cls, instance): 64 | for post_init in cls.__post_inits: 65 | post_init(instance) 66 | 67 | def __repr__(cls): 68 | # use __schema__ can keep fields order in python>=3.6 69 | fields = ", ".join(cls.__schema__.items) 70 | return "{}<{}>".format(cls.__name__, fields) 71 | 72 | def __getitem__(self, keys): 73 | s = self.__schema__ 74 | items = s.items or {} 75 | if not isinstance(keys, (list, tuple, set, frozenset)): 76 | if keys not in items: 77 | raise KeyError("key {!r} is not exists".format(keys)) 78 | return items[keys] 79 | schema = Schema(validator=s.validator, params=s.params.copy()) 80 | schema.items = {} 81 | for k in keys: 82 | if k not in items: 83 | raise KeyError("key {!r} is not exists".format(k)) 84 | schema.items[k] = items[k] 85 | return T(schema) 86 | 87 | class Model(model_cls, metaclass=ModelMeta): 88 | 89 | if "__init__" not in model_cls.__dict__: 90 | 91 | def __init__(self, *obj, **params): 92 | self.__dict__["__immutable__"] = False 93 | py_model_init(self, obj, params) 94 | type(self).post_init(self) 95 | self.__dict__["__immutable__"] = immutable 96 | 97 | else: 98 | 99 | def __init__(self, *args, **kwargs): 100 | self.__dict__["__immutable__"] = False 101 | super().__init__(*args, **kwargs) 102 | type(self).post_init(self) 103 | self.__dict__["__immutable__"] = immutable 104 | 105 | if immutable: 106 | 107 | def __setattr__(self, name, value): 108 | if self.__immutable__: 109 | msg = "{} object is immutable!".format(type(self).__name__) 110 | raise ImmutableInstanceError(msg) 111 | return object.__setattr__(self, name, value) 112 | 113 | def __delattr__(self, name): 114 | if self.__immutable__: 115 | msg = "{} object is immutable!".format(type(self).__name__) 116 | raise ImmutableInstanceError(msg) 117 | return object.__delattr__(self, name) 118 | 119 | if "__repr__" not in model_cls.__dict__: 120 | 121 | def __repr__(self): 122 | params = [] 123 | # use __schema__ can keep fields order 124 | for k in self.__schema__.items: 125 | v = getattr(self, k) 126 | params.append("{}={!r}".format(k, v)) 127 | params = ", ".join(params) 128 | return "{}({})".format(type(self).__name__, params) 129 | 130 | if "__eq__" not in model_cls.__dict__: 131 | 132 | def __eq__(self, other): 133 | fields = getattr(other, "__fields__", None) 134 | if not fields: 135 | return False 136 | if self.__fields__ != fields: 137 | return False 138 | for k in self.__fields__: 139 | if getattr(self, k, None) != getattr(other, k, None): 140 | return False 141 | return True 142 | 143 | def __asdict__(self, *, keys=None): 144 | return py_model_asdict(self, keys=keys) 145 | 146 | Model.__module__ = model_cls.__module__ 147 | Model.__name__ = model_cls.__name__ 148 | Model.__qualname__ = model_cls.__qualname__ 149 | Model.__doc__ = model_cls.__doc__ 150 | 151 | return Model 152 | 153 | 154 | def fields(m) -> set: 155 | """Get fields of model or dict schema""" 156 | if hasattr(m, '__fields__'): # modelclass 157 | return m.__fields__ 158 | if hasattr(m, '__schema__'): 159 | schema = m.__schema__ # T.dict({...}) 160 | else: 161 | schema = m # Schema 162 | if isinstance(schema, Schema): 163 | if schema.validator == 'dict': 164 | if schema.items: 165 | return set(schema.items.keys()) 166 | else: 167 | return set() 168 | raise TypeError("can not find fields of {!r}".format(m)) 169 | 170 | 171 | def asdict(m, *, keys=None) -> dict: 172 | """Convert model instance to dict""" 173 | return m.__asdict__(keys=keys) 174 | -------------------------------------------------------------------------------- /src/validr/_vendor/email_validator.py: -------------------------------------------------------------------------------- 1 | """ 2 | https://github.com/JoshData/python-email-validator/blob/master/email_validator/__init__.py 3 | 4 | Changes: remove check_deliverability, remove allow_smtputf8 5 | """ 6 | import sys 7 | import re 8 | import idna # implements IDNA 2008; Python's codec is only IDNA 2003 9 | 10 | 11 | # Based on RFC 2822 section 3.2.4 / RFC 5322 section 3.2.3, these 12 | # characters are permitted in email addresses (not taking into 13 | # account internationalization): 14 | ATEXT = r'a-zA-Z0-9_!#\$%&\'\*\+\-/=\?\^`\{\|\}~' 15 | 16 | # A "dot atom text", per RFC 2822 3.2.4: 17 | DOT_ATOM_TEXT = '[' + ATEXT + ']+(?:\\.[' + ATEXT + ']+)*' 18 | 19 | # RFC 6531 section 3.3 extends the allowed characters in internationalized 20 | # addresses to also include three specific ranges of UTF8 defined in 21 | # RFC3629 section 4, which appear to be the Unicode code points from 22 | # U+0080 to U+10FFFF. 23 | ATEXT_UTF8 = ATEXT + u"\u0080-\U0010FFFF" 24 | DOT_ATOM_TEXT_UTF8 = '[' + ATEXT_UTF8 + ']+(?:\\.[' + ATEXT_UTF8 + ']+)*' 25 | 26 | # The domain part of the email address, after IDNA (ASCII) encoding, 27 | # must also satisfy the requirements of RFC 952/RFC 1123 which restrict 28 | # the allowed characters of hostnames further. The hyphen cannot be at 29 | # the beginning or end of a *dot-atom component* of a hostname either. 30 | ATEXT_HOSTNAME = r'(?:(?:[a-zA-Z0-9][a-zA-Z0-9\-]*)?[a-zA-Z0-9])' 31 | 32 | # ease compatibility in type checking 33 | if sys.version_info >= (3,): 34 | unicode_class = str 35 | else: 36 | unicode_class = unicode # noqa: F821 37 | 38 | # turn regexes to unicode (because 'ur' literals are not allowed in Py3) 39 | ATEXT = ATEXT.decode("ascii") 40 | DOT_ATOM_TEXT = DOT_ATOM_TEXT.decode("ascii") 41 | ATEXT_HOSTNAME = ATEXT_HOSTNAME.decode("ascii") 42 | 43 | DEFAULT_TIMEOUT = 15 # secs 44 | 45 | 46 | class EmailNotValidError(ValueError): 47 | """Parent class of all exceptions raised by this module.""" 48 | pass 49 | 50 | 51 | class EmailSyntaxError(EmailNotValidError): 52 | """Exception raised when an email address fails validation because of its form.""" 53 | pass 54 | 55 | 56 | class EmailUndeliverableError(EmailNotValidError): 57 | """Exception raised when an email address fails validation because its domain name does not appear deliverable.""" 58 | pass 59 | 60 | 61 | def validate_email( 62 | email, 63 | allow_smtputf8=True, 64 | allow_empty_local=False, 65 | check_deliverability=True, 66 | timeout=DEFAULT_TIMEOUT, 67 | ): 68 | """ 69 | Validates an email address, raising an EmailNotValidError if the address is not valid or returning a dict of 70 | information when the address is valid. The email argument can be a str or a bytes instance, 71 | but if bytes it must be ASCII-only. 72 | """ 73 | 74 | # Allow email to be a str or bytes instance. If bytes, 75 | # it must be ASCII because that's how the bytes work 76 | # on the wire with SMTP. 77 | if not isinstance(email, (str, unicode_class)): 78 | try: 79 | email = email.decode("ascii") 80 | except ValueError: 81 | raise EmailSyntaxError("The email address is not valid ASCII.") 82 | 83 | # At-sign. 84 | parts = email.split('@') 85 | if len(parts) != 2: 86 | raise EmailSyntaxError("The email address is not valid. It must have exactly one @-sign.") 87 | 88 | # Prepare a dict to return on success. 89 | ret = {} 90 | 91 | # Validate the email address's local part syntax and update the return 92 | # dict with metadata. 93 | ret.update(validate_email_local_part(parts[0], allow_smtputf8=allow_smtputf8, allow_empty_local=allow_empty_local)) 94 | 95 | # Validate the email address's domain part syntax and update the return 96 | # dict with metadata. 97 | ret.update(validate_email_domain_part(parts[1])) 98 | 99 | # If the email address has an ASCII form, add it. 100 | ret["email"] = ret["local"] + "@" + ret["domain_i18n"] 101 | if not ret["smtputf8"]: 102 | ret["email_ascii"] = ret["local"] + "@" + ret["domain"] 103 | 104 | return ret 105 | 106 | 107 | def validate_email_local_part(local, allow_smtputf8=True, allow_empty_local=False): 108 | # Validates the local part of an email address. 109 | 110 | if len(local) == 0: 111 | if not allow_empty_local: 112 | raise EmailSyntaxError("There must be something before the @-sign.") 113 | else: 114 | # The caller allows an empty local part. Useful for validating certain 115 | # Postfix aliases. 116 | return { 117 | "local": local, 118 | "smtputf8": False, 119 | } 120 | 121 | # RFC 5321 4.5.3.1.1 122 | if len(local) > 64: 123 | raise EmailSyntaxError("The email address is too long before the @-sign.") 124 | 125 | # Check the local part against the regular expression for the older ASCII requirements. 126 | m = re.match(DOT_ATOM_TEXT + "$", local) 127 | if m: 128 | # Return the local part unchanged and flag that SMTPUTF8 is not needed. 129 | return { 130 | "local": local, 131 | "smtputf8": False, 132 | } 133 | 134 | else: 135 | # The local part failed the ASCII check. Now try the extended internationalized requirements. 136 | m = re.match(DOT_ATOM_TEXT_UTF8 + "$", local) 137 | if not m: 138 | # It's not a valid internationalized address either. Report which characters were not valid. 139 | bad_chars = ', '.join(sorted(set( 140 | c for c in local if not re.match(u"[" + (ATEXT if not allow_smtputf8 else ATEXT_UTF8) + u"]", c) 141 | ))) 142 | raise EmailSyntaxError("The email address contains invalid characters before the @-sign: %s." % bad_chars) 143 | 144 | # It would be valid if internationalized characters were allowed by the caller. 145 | raise EmailSyntaxError("Internationalized characters before the @-sign are not supported.") 146 | 147 | 148 | def validate_email_domain_part(domain): 149 | # Empty? 150 | if len(domain) == 0: 151 | raise EmailSyntaxError("There must be something after the @-sign.") 152 | 153 | # Perform UTS-46 normalization, which includes casefolding, NFC normalization, 154 | # and converting all label separators (the period/full stop, fullwidth full stop, 155 | # ideographic full stop, and halfwidth ideographic full stop) to basic periods. 156 | # It will also raise an exception if there is an invalid character in the input, 157 | # such as "⒈" which is invalid because it would expand to include a period. 158 | try: 159 | domain = idna.uts46_remap(domain, std3_rules=False, transitional=False) 160 | except idna.IDNAError as e: 161 | raise EmailSyntaxError("The domain name %s contains invalid characters (%s)." % (domain, str(e))) 162 | 163 | # Now we can perform basic checks on the use of periods (since equivalent 164 | # symbols have been mapped to periods). These checks are needed because the 165 | # IDNA library doesn't handle well domains that have empty labels (i.e. initial 166 | # dot, trailing dot, or two dots in a row). 167 | if domain.endswith("."): 168 | raise EmailSyntaxError("An email address cannot end with a period.") 169 | if domain.startswith("."): 170 | raise EmailSyntaxError("An email address cannot have a period immediately after the @-sign.") 171 | if ".." in domain: 172 | raise EmailSyntaxError("An email address cannot have two periods in a row.") 173 | 174 | # Regardless of whether international characters are actually used, 175 | # first convert to IDNA ASCII. For ASCII-only domains, the transformation 176 | # does nothing. If internationalized characters are present, the MTA 177 | # must either support SMTPUTF8 or the mail client must convert the 178 | # domain name to IDNA before submission. 179 | # 180 | # Unfortunately this step incorrectly 'fixes' domain names with leading 181 | # periods by removing them, so we have to check for this above. It also gives 182 | # a funky error message ("No input") when there are two periods in a 183 | # row, also checked separately above. 184 | try: 185 | domain = idna.encode(domain, uts46=False).decode("ascii") 186 | except idna.IDNAError as e: 187 | raise EmailSyntaxError("The domain name %s contains invalid characters (%s)." % (domain, str(e))) 188 | 189 | # We may have been given an IDNA ASCII domain to begin with. Check 190 | # that the domain actually conforms to IDNA. It could look like IDNA 191 | # but not be actual IDNA. For ASCII-only domains, the conversion out 192 | # of IDNA just gives the same thing back. 193 | # 194 | # This gives us the canonical internationalized form of the domain, 195 | # which we should use in all error messages. 196 | try: 197 | domain_i18n = idna.decode(domain.encode('ascii')) 198 | except idna.IDNAError as e: 199 | raise EmailSyntaxError("The domain name %s is not valid IDNA (%s)." % (domain, str(e))) 200 | 201 | # RFC 5321 4.5.3.1.2 202 | if len(domain) > 255: 203 | raise EmailSyntaxError("The email address is too long after the @-sign.") 204 | 205 | # A "dot atom text", per RFC 2822 3.2.4, but using the restricted 206 | # characters allowed in a hostname (see ATEXT_HOSTNAME above). 207 | DOT_ATOM_TEXT = ATEXT_HOSTNAME + r'(?:\.' + ATEXT_HOSTNAME + r')*' 208 | 209 | # Check the regular expression. This is probably entirely redundant 210 | # with idna.decode, which also checks this format. 211 | m = re.match(DOT_ATOM_TEXT + "$", domain) 212 | if not m: 213 | raise EmailSyntaxError("The email address contains invalid characters after the @-sign.") 214 | 215 | # All publicly deliverable addresses have domain named with at least 216 | # one period. We also know that all TLDs end with a letter. 217 | if "." not in domain: 218 | raise EmailSyntaxError("The domain name %s is not valid. It should have a period." % domain_i18n) 219 | if not re.search(r"[A-Za-z]$", domain): 220 | raise EmailSyntaxError( 221 | "The domain name %s is not valid. It is not within a valid top-level domain." % domain_i18n 222 | ) 223 | 224 | # Return the IDNA ASCII-encoded form of the domain, which is how it 225 | # would be transmitted on the wire (except when used with SMTPUTF8 226 | # possibly), as well as the canonical Unicode form of the domain, 227 | # which is better for display purposes. This should also take care 228 | # of RFC 6532 section 3.1's suggestion to apply Unicode NFC 229 | # normalization to addresses. 230 | return { 231 | "domain": domain, 232 | "domain_i18n": domain_i18n, 233 | } 234 | 235 | 236 | def main(): 237 | import sys 238 | import json 239 | 240 | if len(sys.argv) == 1: 241 | # Read lines for STDIN and validate the email address on each line. 242 | allow_smtputf8 = True 243 | for line in sys.stdin: 244 | try: 245 | email = line.strip() 246 | if sys.version_info < (3,): 247 | email = email.decode("utf8") # assume utf8 in input 248 | validate_email(email, allow_smtputf8=allow_smtputf8) 249 | except EmailNotValidError as e: 250 | print(email, e) 251 | else: 252 | # Validate the email address passed on the command line. 253 | email = sys.argv[1] 254 | allow_smtputf8 = True 255 | check_deliverability = True 256 | if sys.version_info < (3,): 257 | email = email.decode("utf8") # assume utf8 in input 258 | try: 259 | result = validate_email(email, allow_smtputf8=allow_smtputf8, check_deliverability=check_deliverability) 260 | print(json.dumps(result, indent=2, sort_keys=True, ensure_ascii=False)) 261 | except EmailNotValidError as e: 262 | if sys.version_info < (3,): 263 | print(unicode_class(e).encode("utf8")) 264 | else: 265 | print(e) 266 | 267 | 268 | if __name__ == "__main__": 269 | main() 270 | -------------------------------------------------------------------------------- /src/validr/schema.py: -------------------------------------------------------------------------------- 1 | """ 2 | Schema and Compiler 3 | 4 | schema is instance of Schema or object which has __schema__ attribute, 5 | and the __schema__ is instance of Schema. 6 | 7 | compiler can compile schema: 8 | 9 | compiler.compile(schema) -> validate function. 10 | 11 | validate function has __schema__ attribute, it's also a schema. 12 | 13 | the Builder's instance T can build schema. 14 | in addition, T can be called directly, which convert schema-like things to 15 | instance of Builder: 16 | 17 | T(JSON) -> Isomorph Schema 18 | T(func) -> Schema of validate func 19 | T(Schema) -> Copy of Schema 20 | T(Model) -> Schema of Model 21 | 22 | Builder support schema slice: 23 | 24 | T[...keys] -> sub schema 25 | 26 | relations: 27 | 28 | T(schema) -> T 29 | T.__schema__ -> Schema 30 | """ 31 | import copy 32 | import enum 33 | import inspect 34 | import json 35 | 36 | from pyparsing import ( 37 | Group, 38 | Keyword, 39 | Optional, 40 | ParseBaseException, 41 | StringEnd, 42 | StringStart, 43 | Suppress, 44 | ZeroOrMore, 45 | pyparsing_common, 46 | quotedString, 47 | removeQuotes, 48 | replaceWith, 49 | ) 50 | 51 | from .validator import SchemaError, builtin_validators 52 | from .validator import py_mark_index as mark_index 53 | from .validator import py_mark_key as mark_key 54 | 55 | 56 | def _make_keyword(kwd_str, kwd_value): 57 | return Keyword(kwd_str).setParseAction(replaceWith(kwd_value)) 58 | 59 | 60 | def _define_value(): 61 | TRUE = _make_keyword('true', True) 62 | FALSE = _make_keyword('false', False) 63 | NULL = _make_keyword('null', None) 64 | STRING = quotedString().setParseAction(removeQuotes) 65 | NUMBER = pyparsing_common.number() 66 | return TRUE | FALSE | NULL | STRING | NUMBER 67 | 68 | 69 | def _define_element(): 70 | VALIDATOR = pyparsing_common.identifier.setName('validator').setResultsName('validator') 71 | ITEMS = _define_value().setName('items').setResultsName('items') 72 | ITEMS_WRAPPER = Optional(Suppress('(') + ITEMS + Suppress(')')) 73 | PARAMS_KEY = pyparsing_common.identifier.setName('key').setResultsName('key') 74 | PARAMS_VALUE = _define_value().setName('value').setResultsName('value') 75 | PARAMS_VALUE_WRAPPER = Optional(Suppress('(') + PARAMS_VALUE + Suppress(')')) 76 | PARAMS_KEY_VALUE = Group(Suppress('.') + PARAMS_KEY + PARAMS_VALUE_WRAPPER) 77 | PARAMS = Group(ZeroOrMore(PARAMS_KEY_VALUE)).setName('params').setResultsName('params') 78 | return StringStart() + VALIDATOR + ITEMS_WRAPPER + PARAMS + StringEnd() 79 | 80 | 81 | ELEMENT_GRAMMAR = _define_element() 82 | 83 | 84 | def _dump_value(value): 85 | if value is None: 86 | return 'null' 87 | elif value is False: 88 | return 'false' 89 | elif value is True: 90 | return 'true' 91 | elif isinstance(value, str): 92 | return repr(value) # single quotes by default 93 | elif isinstance(value, Schema): 94 | return value.validator 95 | else: 96 | return str(value) # number 97 | 98 | 99 | def _pair(k, v): 100 | return '{}({})'.format(k, _dump_value(v)) 101 | 102 | 103 | def _sort_schema_params(params): 104 | def key(item): 105 | k, v = item 106 | if k == 'desc': 107 | return 3 108 | if k == 'optional': 109 | return 2 110 | if k == 'default': 111 | return 1 112 | if isinstance(v, bool): 113 | return -1 114 | if isinstance(v, str): 115 | return -2 116 | else: 117 | return -3 118 | return list(sorted(params, key=key)) 119 | 120 | 121 | def _schema_of(obj) -> "Schema": 122 | if hasattr(obj, '__schema__'): 123 | obj = obj.__schema__ 124 | return obj 125 | 126 | 127 | def _schema_copy_of(obj) -> "Schema": 128 | if isinstance(obj, Schema): 129 | obj = obj.copy() 130 | return obj 131 | 132 | 133 | def _schema_primitive_of(obj): 134 | if isinstance(obj, Schema): 135 | obj = obj.to_primitive() 136 | return obj 137 | 138 | 139 | def _is_model(obj): 140 | return inspect.isclass(obj) and hasattr(obj, '__schema__') 141 | 142 | 143 | class Schema: 144 | 145 | def __init__(self, *, validator=None, items=None, params=None): 146 | self.validator = validator 147 | self.items = items 148 | self.params = params or {} 149 | 150 | def __eq__(self, other): 151 | other = _schema_of(other) 152 | if not isinstance(other, Schema): 153 | return False 154 | return (self.validator == other.validator and 155 | self.items == other.items and 156 | self.params == other.params) 157 | 158 | def __hash__(self): 159 | params = tuple(sorted(self.params.items())) 160 | items = self.items 161 | if isinstance(items, dict): 162 | items = tuple(sorted(items.items())) 163 | elif isinstance(items, list): 164 | items = tuple(items) 165 | return hash((self.validator, items, params)) 166 | 167 | def __str__(self): 168 | return json.dumps(self.to_primitive(), indent=4, 169 | ensure_ascii=False, sort_keys=True) 170 | 171 | def repr(self, *, prefix=True, desc=True): 172 | if not self.validator: 173 | return 'T' if prefix else '' 174 | ret = ['T'] if prefix else [] 175 | if self.items is None: 176 | ret.append(self.validator) 177 | else: 178 | if self.validator == 'dict': 179 | keys = ', '.join(sorted(self.items)) if self.items else '' 180 | ret.append('{}({})'.format(self.validator, '{' + keys + '}')) 181 | elif self.validator == 'list': 182 | ret.append('{}({})'.format(self.validator, self.items.validator)) 183 | elif self.validator == 'enum': 184 | values = ', '.join(map(_dump_value, self.items)) if self.items else '' 185 | ret.append('{}({})'.format(self.validator, '{' + values + '}')) 186 | elif self.validator == 'union': 187 | if self.items and isinstance(self.items, list): 188 | keys = ', '.join(x.validator for x in self.items) 189 | ret.append('{}([{}])'.format(self.validator, keys)) 190 | else: 191 | keys = ', '.join(sorted(self.items)) if self.items else '' 192 | ret.append('{}({})'.format(self.validator, '{' + keys + '}')) 193 | elif self.validator == 'model' and self.items is not None: 194 | ret.append('{}({})'.format(self.validator, self.items.__name__)) 195 | else: 196 | ret.append(_pair(self.validator, self.items)) 197 | for k, v in _sort_schema_params(self.params.items()): 198 | if not desc and k == 'desc': 199 | continue 200 | if v is False: 201 | continue 202 | if v is True: 203 | ret.append(k) 204 | else: 205 | ret.append(_pair(k, v)) 206 | return '.'.join(ret) 207 | 208 | def __repr__(self): 209 | r = self.repr(prefix=False) 210 | return '{}<{}>'.format(type(self).__name__, r) 211 | 212 | def copy(self): 213 | params = {k: _schema_copy_of(v)for k, v in self.params.items()} 214 | schema = type(self)(validator=self.validator, params=params) 215 | if self.validator == 'dict' and self.items is not None: 216 | items = {k: _schema_copy_of(v) for k, v in self.items.items()} 217 | elif self.validator == 'list' and self.items is not None: 218 | items = _schema_copy_of(self.items) 219 | elif self.validator == 'union' and self.items is not None: 220 | if isinstance(self.items, list): 221 | items = [_schema_copy_of(x) for x in self.items] 222 | else: 223 | items = {k: _schema_copy_of(v) for k, v in self.items.items()} 224 | else: 225 | items = copy.copy(self.items) 226 | schema.items = items 227 | return schema 228 | 229 | def __copy__(self): 230 | return self.copy() 231 | 232 | def __deepcopy__(self, memo): 233 | return self.copy() 234 | 235 | def to_primitive(self): 236 | if not self.validator: 237 | return None 238 | # T.model not support in JSON, convert it to T.dict 239 | if self.validator == 'model': 240 | if self.items is None: 241 | items = None 242 | else: 243 | items = _schema_of(self.items).items 244 | self = Schema(validator='dict', items=items, params=self.params) 245 | ret = [] 246 | if self.validator in {'dict', 'list', 'union', 'enum'} or self.items is None: 247 | ret.append(self.validator) 248 | else: 249 | ret.append(_pair(self.validator, self.items)) 250 | for k, v in _sort_schema_params(self.params.items()): 251 | if self.validator == 'dict' and k in {'key', 'value'}: 252 | continue 253 | if v is False: 254 | continue 255 | if v is True: 256 | ret.append(k) 257 | else: 258 | ret.append(_pair(k, v)) 259 | ret = '.'.join(ret) 260 | if self.validator == 'dict': 261 | ret = {'$self': ret} 262 | for pkey in ['key', 'value']: 263 | pvalue = self.params.get(pkey) 264 | if pvalue is not None: 265 | ret['$self_{}'.format(pkey)] = _schema_primitive_of(pvalue) 266 | if self.items is not None: 267 | for k, v in self.items.items(): 268 | ret[k] = _schema_primitive_of(v) 269 | elif self.validator == 'list' and self.items is not None: 270 | ret = [ret, _schema_primitive_of(self.items)] 271 | elif self.validator == 'enum' and self.items is not None: 272 | ret = [ret, *self.items] 273 | elif self.validator == 'union' and self.items is not None: 274 | if isinstance(self.items, list): 275 | ret = [ret] 276 | for x in self.items: 277 | ret.append(_schema_primitive_of(x)) 278 | else: 279 | ret = {'$self': ret} 280 | for k, v in self.items.items(): 281 | ret[k] = _schema_primitive_of(v) 282 | return ret 283 | 284 | @classmethod 285 | def parse_element(cls, text): 286 | if text is None: 287 | raise SchemaError("can't parse None") 288 | text = text.strip() 289 | if not text: 290 | raise SchemaError("can't parse empty string") 291 | try: 292 | result = ELEMENT_GRAMMAR.parseString(text, parseAll=True) 293 | except ParseBaseException as ex: 294 | msg = 'invalid syntax in col {} of {!r}'.format(ex.col, repr(ex.line)) 295 | raise SchemaError(msg) from None 296 | validator = result['validator'] 297 | items = None 298 | if 'items' in result: 299 | items = result['items'] 300 | params = {} 301 | for item in result['params']: 302 | value = True 303 | if 'value' in item: 304 | value = item['value'] 305 | params[item['key']] = value 306 | return cls(validator=validator, items=items, params=params) 307 | 308 | @classmethod 309 | def parse_isomorph_schema(cls, obj): 310 | if isinstance(obj, str): 311 | return cls.parse_element(obj) 312 | elif isinstance(obj, dict): 313 | e = cls.parse_element(obj.pop('$self', 'dict')) 314 | items = {} 315 | for k, v in obj.items(): 316 | with mark_key(k): 317 | items[k] = cls.parse_isomorph_schema(v) 318 | for pkey in ['key', 'value']: 319 | pvalue = items.pop('$self_{}'.format(pkey), None) 320 | if pvalue is not None: 321 | e.params[pkey] = pvalue 322 | return cls(validator=e.validator, items=items, params=e.params) 323 | elif isinstance(obj, list): 324 | if len(obj) == 1: 325 | validator = 'list' 326 | params = None 327 | items = cls.parse_isomorph_schema(obj[0]) 328 | elif len(obj) >= 2: 329 | e = cls.parse_element(obj[0]) 330 | validator = e.validator 331 | params = e.params 332 | if validator == 'list': 333 | if len(obj) > 2: 334 | raise SchemaError('invalid list schema') 335 | with mark_index(): 336 | items = cls.parse_isomorph_schema(obj[1]) 337 | elif validator == 'enum': 338 | items = list(obj[1:]) 339 | elif validator == 'union': 340 | items = [] 341 | for i, x in enumerate(obj[1:]): 342 | with mark_index(i): 343 | items.append(cls.parse_isomorph_schema(x)) 344 | else: 345 | raise SchemaError('unknown {} schema'.format(validator)) 346 | else: 347 | raise SchemaError('invalid list schema') 348 | return cls(validator=validator, items=items, params=params) 349 | else: 350 | raise SchemaError('{} object is not schema'.format(type(obj))) 351 | 352 | 353 | class Compiler: 354 | 355 | def __init__(self, validators=None, is_dump=False): 356 | self.validators = builtin_validators.copy() 357 | if validators: 358 | self.validators.update(validators) 359 | self.is_dump = is_dump 360 | 361 | def compile(self, schema): 362 | schema = _schema_of(schema) 363 | if not isinstance(schema, Schema): 364 | raise SchemaError('{} object is not schema'.format(type(schema))) 365 | if not schema.validator: 366 | raise SchemaError('incomplete schema') 367 | validator = self.validators.get(schema.validator) 368 | if not validator: 369 | raise SchemaError('validator {!r} not found'.format(schema.validator)) 370 | return validator(self, schema) 371 | 372 | 373 | _BUILDER_INIT = 'init' 374 | _EXP_ATTR = 'expect-attr' 375 | _EXP_ATTR_OR_ITEMS = 'expect-attr-or-items' 376 | _EXP_ATTR_OR_CALL = 'expect-attr-or-call' 377 | 378 | 379 | class Builder: 380 | 381 | def __init__(self, state=_BUILDER_INIT, *, validator=None, 382 | items=None, params=None, last_attr=None): 383 | self._state = state 384 | self._schema = Schema(validator=validator, items=items, params=params) 385 | self._last_attr = last_attr 386 | 387 | @property 388 | def __schema__(self): 389 | return self._schema 390 | 391 | def __repr__(self): 392 | return self._schema.repr() 393 | 394 | def __str__(self): 395 | return self._schema.__str__() 396 | 397 | def __eq__(self, other): 398 | return self._schema == _schema_of(other) 399 | 400 | def __hash__(self): 401 | return self._schema.__hash__() 402 | 403 | def __getitem__(self, keys): 404 | if not self._schema.validator: 405 | raise ValueError('can not slice empty schema') 406 | if self._schema.validator != 'dict': 407 | raise ValueError('can not slice non-dict schema') 408 | if not isinstance(keys, (list, tuple)): 409 | keys = (keys,) 410 | schema = Schema(validator=self._schema.validator, 411 | params=self._schema.params.copy()) 412 | schema.items = {} 413 | items = self._schema.items or {} 414 | for k in keys: 415 | if k not in items: 416 | raise ValueError('key {!r} is not exists'.format(k)) 417 | schema.items[k] = items[k] 418 | return T(schema) 419 | 420 | def __getattr__(self, name): 421 | if name.startswith('__') and name.endswith('__'): 422 | raise AttributeError('{!r} object has no attribute {!r}'.format( 423 | type(self).__name__, name)) 424 | if self._state == _BUILDER_INIT: 425 | return Builder(_EXP_ATTR_OR_ITEMS, validator=name) 426 | else: 427 | params = self._schema.params.copy() 428 | params[name] = True 429 | return Builder( 430 | _EXP_ATTR_OR_CALL, validator=self._schema.validator, 431 | items=self._schema.items, params=params, last_attr=name) 432 | 433 | def __call__(self, *args, **kwargs): 434 | if self._state == _BUILDER_INIT: 435 | return self._load_schema(*args, **kwargs) 436 | if self._state not in [_EXP_ATTR_OR_ITEMS, _EXP_ATTR_OR_CALL]: 437 | raise SchemaError('current state not callable') 438 | if self._state == _EXP_ATTR_OR_ITEMS: 439 | if args and kwargs: 440 | raise SchemaError("can't call with both positional argument and keyword argument") 441 | if len(args) > 1: 442 | raise SchemaError("can't call with more than one positional argument") 443 | if self._schema.validator in {'dict', 'union'}: 444 | items = args[0] if args else kwargs 445 | elif self._schema.validator == 'model': 446 | if len(args) != 1 or kwargs: 447 | raise SchemaError('require exactly one positional argument') 448 | items = args[0] 449 | else: 450 | if kwargs: 451 | raise SchemaError("can't call with keyword argument") 452 | if not args: 453 | raise SchemaError('require one positional argument') 454 | items = args[0] 455 | items = self._check_items(items) 456 | params = self._schema.params 457 | else: 458 | if kwargs: 459 | raise SchemaError("can't call with keyword argument") 460 | if not args: 461 | raise SchemaError('require one positional argument') 462 | if len(args) > 1: 463 | raise SchemaError("can't call with more than one positional argument") 464 | param_value = self._check_param_value(self._last_attr, args[0]) 465 | items = self._schema.items 466 | params = self._schema.params.copy() 467 | params[self._last_attr] = param_value 468 | return Builder(_EXP_ATTR, validator=self._schema.validator, 469 | items=items, params=params, last_attr=None) 470 | 471 | def _load_schema(self, obj): 472 | obj = _schema_of(obj) 473 | if isinstance(obj, Schema): 474 | obj = obj.copy() 475 | elif isinstance(obj, (str, list, dict)): 476 | obj = Schema.parse_isomorph_schema(obj) 477 | else: 478 | raise SchemaError('{} object is not schema'.format(type(obj))) 479 | if not obj.validator: 480 | state = _BUILDER_INIT 481 | elif not obj.items and not obj.params: 482 | state = _EXP_ATTR_OR_ITEMS 483 | else: 484 | state = _EXP_ATTR 485 | return Builder(state, validator=obj.validator, 486 | items=obj.items, params=obj.params, last_attr=None) 487 | 488 | def _check_dict_items(self, items): 489 | if not isinstance(items, dict): 490 | raise SchemaError('items must be dict') 491 | ret = {} 492 | for k, v in items.items(): 493 | v = _schema_of(v) 494 | if not isinstance(v, Schema): 495 | raise SchemaError('items[{}] is not schema'.format(k)) 496 | ret[k] = v 497 | return ret 498 | 499 | def _check_items(self, items): 500 | if self._schema.validator == 'dict': 501 | ret = self._check_dict_items(items) 502 | elif self._schema.validator == 'list': 503 | items = _schema_of(items) 504 | if not isinstance(items, Schema): 505 | raise SchemaError('items is not schema') 506 | ret = items 507 | elif self._schema.validator == 'enum': 508 | if isinstance(items, str): 509 | items = set(items.replace(',', ' ').strip().split()) 510 | if inspect.isclass(items) and issubclass(items, enum.Enum): 511 | items = [x.value for x in items.__members__.values()] 512 | if not isinstance(items, (list, tuple, set)): 513 | raise SchemaError('items is not list or set') 514 | ret = [] 515 | for i, v in enumerate(items): 516 | if not isinstance(v, (bool, int, float, str)): 517 | raise SchemaError('enum value must be bool, int, float or str') 518 | ret.append(v) 519 | ret = list(sorted(set(ret), key=lambda x: (str(type(x)), str(x)))) 520 | elif self._schema.validator == 'union': 521 | if isinstance(items, list): 522 | ret = [] 523 | for i, v in enumerate(items): 524 | v = _schema_of(v) 525 | if not isinstance(v, Schema): 526 | raise SchemaError('items[{}] is not schema'.format(i)) 527 | ret.append(v) 528 | else: 529 | ret = self._check_dict_items(items) 530 | elif self._schema.validator == 'model': 531 | if not _is_model(items): 532 | raise SchemaError('items must be model class') 533 | ret = items 534 | else: 535 | if not isinstance(items, (bool, int, float, str)): 536 | raise SchemaError('items must be bool, int, float or str') 537 | ret = items 538 | return ret 539 | 540 | def _check_param_value(self, key, value): 541 | if self._schema.validator == 'dict': 542 | if key in {'key', 'value'}: 543 | return self._check_dict_param_value(key, value) 544 | if value is not None and not isinstance(value, (bool, int, float, str)): 545 | raise SchemaError('parameter value must be bool, int, float or str') 546 | return value 547 | 548 | def _check_dict_param_value(self, key, value): 549 | value = _schema_of(value) 550 | if value is not None and not isinstance(value, Schema): 551 | raise SchemaError('dict {} parameter is not schema'.format(key)) 552 | return value 553 | 554 | 555 | T = Builder() 556 | -------------------------------------------------------------------------------- /src/validr/_validator_c.pyx: -------------------------------------------------------------------------------- 1 | import re 2 | import sys 3 | import uuid 4 | import time 5 | import datetime 6 | import ipaddress 7 | import typing 8 | from copy import copy 9 | from functools import partial 10 | from urllib.parse import urlparse, urlunparse 11 | 12 | from ._vendor import durationpy 13 | from ._vendor.email_validator import validate_email, EmailNotValidError 14 | from ._vendor.fqdn import FQDN 15 | 16 | 17 | _NOT_SET = object() 18 | 19 | 20 | cdef _shorten(str text, int length): 21 | if len(text) > length: 22 | return text[:length] + '..' 23 | return text 24 | 25 | 26 | cdef _format_value(value): 27 | if isinstance(value, str): 28 | return repr(_shorten(value, 75)) 29 | else: 30 | return _shorten(str(value), 75) 31 | 32 | 33 | cdef _format_error(args, str position, str value_clause=None): 34 | cdef str msg = str(args[0]) if args else 'invalid' 35 | if position: 36 | msg = '%s: %s' % (position, msg) 37 | if value_clause: 38 | msg = '%s, %s' % (msg, value_clause) 39 | return msg 40 | 41 | 42 | class ValidrError(ValueError): 43 | """Base exception of validr""" 44 | 45 | def __init__(self, *args, value=_NOT_SET, **kwargs): 46 | super().__init__(*args, **kwargs) 47 | self._value = value 48 | # marks item: (is_key, index_or_key) 49 | self.marks = [] 50 | 51 | def mark_index(self, int index=-1): 52 | self.marks.append((False, index)) 53 | return self 54 | 55 | def mark_key(self, str key): 56 | self.marks.append((True, key)) 57 | return self 58 | 59 | @property 60 | def has_value(self): 61 | """Check has value set""" 62 | return self._value is not _NOT_SET 63 | 64 | def set_value(self, value): 65 | """Set value if not set""" 66 | if self._value is _NOT_SET: 67 | self._value = value 68 | 69 | @property 70 | def value(self): 71 | """The invalid value""" 72 | if self._value is _NOT_SET: 73 | return None 74 | return self._value 75 | 76 | @property 77 | def field(self): 78 | """First level index or key, usually it's the field""" 79 | if not self.marks: 80 | return None 81 | __, index_or_key = self.marks[-1] 82 | return index_or_key 83 | 84 | @property 85 | def position(self): 86 | """A string which represent the position of invalid. 87 | 88 | For example: 89 | 90 | { 91 | "tags": ["ok", "invalid"], # tags[1] 92 | "user": { 93 | "name": "invalid", # user.name 94 | "age": 500 # user.age 95 | } 96 | } 97 | """ 98 | cdef str text = '' 99 | cdef bint is_key 100 | for is_key, index_or_key in reversed(self.marks): 101 | if is_key: 102 | text = '%s.%s' % (text, index_or_key) 103 | else: 104 | if index_or_key == -1: 105 | text = '%s[]' % text 106 | else: 107 | text = '%s[%d]' % (text, index_or_key) 108 | if text and text[0] == '.': 109 | text = text[1:] 110 | return text 111 | 112 | @property 113 | def message(self): 114 | """Error message""" 115 | if self.args: 116 | return self.args[0] 117 | else: 118 | return None 119 | 120 | def __str__(self): 121 | return _format_error(self.args, self.position) 122 | 123 | 124 | class Invalid(ValidrError): 125 | """Data invalid""" 126 | def __str__(self): 127 | cdef str value_clause = None 128 | if self.has_value: 129 | value_clause = 'value=%s' % _format_value(self.value) 130 | return _format_error(self.args, self.position, value_clause) 131 | 132 | 133 | class ModelInvalid(Invalid): 134 | """Model data invalid""" 135 | def __init__(self, errors): 136 | if not errors: 137 | raise ValueError('errors is required') 138 | self.errors = errors 139 | message = errors[0].message or 'invalid' 140 | message += ' ...total {} errors'.format(len(errors)) 141 | super().__init__(message) 142 | 143 | def __str__(self): 144 | error_line_s = [] 145 | for ex in self.errors: 146 | error_line_s.append('{} {}'.format(ex.position, ex.message)) 147 | return '; '.join(error_line_s) 148 | 149 | 150 | class SchemaError(ValidrError): 151 | """Schema error""" 152 | def __str__(self): 153 | cdef str value_clause = None 154 | if self.has_value: 155 | value_clause = 'schema=%s' % self.value.repr(prefix=False, desc=False) 156 | return _format_error(self.args, self.position, value_clause) 157 | 158 | 159 | cdef class mark_index: 160 | """Add current index to Invalid/SchemaError""" 161 | 162 | cdef int index 163 | 164 | def __init__(self, index=-1): 165 | """index = -1 means the position is uncertainty""" 166 | self.index = index 167 | 168 | def __enter__(self): 169 | return self 170 | 171 | def __exit__(self, exc_type, exc_val, exc_tb): 172 | if exc_type is not None and issubclass(exc_type, ValidrError): 173 | exc_val.mark_index(self.index) 174 | 175 | 176 | cdef class mark_key: 177 | """Add current key to Invalid/SchemaError""" 178 | 179 | cdef str key 180 | 181 | def __init__(self, key): 182 | self.key = key 183 | 184 | def __enter__(self): 185 | return self 186 | 187 | def __exit__(self, exc_type, exc_val, exc_tb): 188 | if exc_type is not None and issubclass(exc_type, ValidrError): 189 | exc_val.mark_key(self.key) 190 | 191 | 192 | class py_mark_index(mark_index): pass 193 | class py_mark_key(mark_key): pass 194 | 195 | 196 | cdef bint is_dict(obj): 197 | # use isinstance(obj, Mapping) is slow, 198 | # hasattr check can speed up about 30% 199 | return hasattr(obj, '__getitem__') and hasattr(obj, 'get') 200 | 201 | 202 | cdef inline get_dict_value(obj, str key): 203 | return obj.get(key, None) 204 | 205 | 206 | cdef inline get_object_value(obj, str key): 207 | return getattr(obj, key, None) 208 | 209 | 210 | cdef inline bint _is_empty(value): 211 | return value is None or value == '' 212 | 213 | 214 | cdef _update_validate_func_info(validate_func, origin_func, schema): 215 | # make friendly validate func representation 216 | m_repr = schema.repr(prefix=False, desc=False) 217 | validate_func.__schema__ = schema 218 | validate_func.__module__ = origin_func.__module__ 219 | validate_func.__name__ = '{}<{}>'.format(origin_func.__name__, m_repr) 220 | if hasattr(origin_func, '__qualname__'): 221 | qualname = '{}<{}>'.format(origin_func.__qualname__, m_repr) 222 | validate_func.__qualname__ = qualname 223 | if origin_func.__doc__: 224 | validate_func.__doc__ = origin_func.__doc__ 225 | else: 226 | validate_func.__doc__ = schema.params.get('desc') 227 | 228 | 229 | def _parse_hints(hints): 230 | if not isinstance(hints, (tuple, set, list)): 231 | hints = [hints] 232 | is_string = False 233 | is_object = False 234 | types = [] 235 | for hint in hints: 236 | if hint is str: 237 | is_string = True 238 | else: 239 | is_object = True 240 | if hint is object: 241 | hint = typing.Any 242 | types.append(hint) 243 | return is_string, is_object, tuple(types) 244 | 245 | 246 | def _update_validate_func_type_hints( 247 | validate_func, 248 | optional, has_default, 249 | accept_hints, output_hints, 250 | ): 251 | assert accept_hints, 'no accept_hints' 252 | assert output_hints, 'no output_hints' 253 | if len(accept_hints) == 1: 254 | value_typing = accept_hints[0] 255 | else: 256 | value_typing = typing.Union[accept_hints] 257 | if optional: 258 | value_typing = typing.Optional[value_typing] 259 | if len(output_hints) == 1: 260 | return_typing = output_hints[0] 261 | else: 262 | return_typing = typing.Union[output_hints] 263 | if optional and not has_default: 264 | return_typing = typing.Optional[return_typing] 265 | annotations = {'value': value_typing, 'return': return_typing} 266 | validate_func.__annotations__ = annotations 267 | 268 | 269 | def validator(string=None, *, accept=None, output=None): 270 | """Decorator for create validator 271 | 272 | It will handle params default,optional,desc automatically. 273 | 274 | Usage: 275 | 276 | @validator(accept=(str,object), output=(str, object)) 277 | def xxx_validator(compiler, output_object, **params): 278 | def validate(value): 279 | try: 280 | # validate/convert the value 281 | except Exception: 282 | # raise Invalid('invalid xxx') 283 | if output_object: 284 | # return python object 285 | else: 286 | # return string 287 | return validate 288 | 289 | Args: 290 | accept (str | object | (str,object)): 291 | str: the validator accept only string, treat both None and empty string as None 292 | object: the validator accept only object 293 | (str,object): (default) the validator accept both string and object, 294 | treat both None and empty string as None 295 | output (str | object | (str,object)): 296 | str: (default) the validator always output string, convert None to empty string 297 | object: the validator always output object 298 | (str, object): the validator can output both string and object, 299 | and has an `object` parameter to control which to output 300 | string (bool): deprecated in v1.1.0. 301 | string=True equal to accept=(str, object), output=str 302 | string=False equal to accept=(str, object), output=object 303 | """ 304 | cdef bint accept_string, accept_object, output_string, output_object 305 | if accept is None or not accept: 306 | accept_string = True 307 | accept_object = True 308 | accept_hints = (str, typing.Any) 309 | else: 310 | accept_string, accept_object, accept_hints = _parse_hints(accept) 311 | if not (accept_string or accept_object): 312 | raise ValueError('invalid accept argument {}'.format(accept)) 313 | if output is None or not output: 314 | output_string = string 315 | output_object = not string 316 | output_hints = (str,) if string else (typing.Any,) 317 | else: 318 | output_string, output_object, output_hints = _parse_hints(output) 319 | if not (output_string or output_object): 320 | raise ValueError('invalid output argument {}'.format(output)) 321 | del string, accept, output 322 | 323 | def decorator(f): 324 | 325 | def _m_validator(compiler, schema): 326 | params = schema.params.copy() 327 | if schema.items is not None: 328 | params['items'] = schema.items 329 | cdef bint local_output_object = output_object 330 | if output_string and output_object: 331 | local_output_object = bool(params.get('object', None)) 332 | # TODO: fix special case of timedelta 333 | if schema.validator == 'timedelta': 334 | local_output_object = not bool(params.get('string', None)) 335 | if output_object and 'object' in params: 336 | params['output_object'] = bool(params.pop('object', None)) 337 | if local_output_object: 338 | null_output = None 339 | else: 340 | null_output = '' 341 | cdef bint optional = params.pop('optional', False) 342 | default = params.pop('default', None) 343 | params.pop('desc', None) 344 | cdef bint invalid_to_default = params.pop('invalid_to_default', False) 345 | cdef bint has_invalid_to = 'invalid_to' in params 346 | invalid_to = params.pop('invalid_to', None) 347 | cdef bint has_default 348 | if accept_string: 349 | has_default = not (default is None or default == '') 350 | else: 351 | has_default = not (default is None) 352 | if has_invalid_to and invalid_to_default: 353 | raise SchemaError('can not set both invalid_to and invalid_to_default') 354 | if invalid_to_default and (not has_default) and (not optional): 355 | raise SchemaError('default or optional must be set when set invalid_to_default') 356 | try: 357 | validate = f(compiler, **params) 358 | except TypeError as e: 359 | raise SchemaError(str(e)) from None 360 | # check default value 361 | if has_default: 362 | try: 363 | default = validate(default) 364 | except Invalid: 365 | msg = 'invalid default value {!r}'.format(default) 366 | raise SchemaError(msg) from None 367 | if invalid_to_default: 368 | invalid_to = default 369 | else: 370 | if invalid_to_default: 371 | invalid_to = null_output 372 | # check invalid_to value 373 | if has_invalid_to: 374 | try: 375 | invalid_to = validate(invalid_to) 376 | except Invalid: 377 | msg = 'invalid invalid_to value {!r}'.format(invalid_to) 378 | raise SchemaError(msg) from None 379 | 380 | # check null, empty string and default 381 | def _m_validate(value): 382 | cdef bint is_null 383 | if accept_string: 384 | is_null = _is_empty(value) 385 | else: 386 | is_null = value is None 387 | if is_null: 388 | if has_default: 389 | return default 390 | elif optional: 391 | return null_output 392 | else: 393 | raise Invalid('required') 394 | if not accept_object and not isinstance(value, str): 395 | raise Invalid('require string value') 396 | value = validate(value) 397 | # check again after validate 398 | if accept_string: 399 | is_null = _is_empty(value) 400 | else: 401 | is_null = value is None 402 | if is_null: 403 | if has_default: 404 | return default 405 | elif optional: 406 | return null_output 407 | else: 408 | raise Invalid('required') 409 | return value 410 | 411 | supress_invalid = has_invalid_to or invalid_to_default 412 | 413 | def m_validate(value): 414 | try: 415 | return _m_validate(value) 416 | except Invalid as ex: 417 | ex.set_value(value) 418 | if supress_invalid: 419 | return invalid_to 420 | else: 421 | raise 422 | 423 | _update_validate_func_info(m_validate, f, schema) 424 | 425 | # _update_validate_func_type_hints( 426 | # m_validate, optional=optional, has_default=has_default, 427 | # accept_hints=accept_hints, output_hints=output_hints) 428 | 429 | return m_validate 430 | 431 | def m_validator(compiler, schema): 432 | try: 433 | return _m_validator(compiler, schema) 434 | except SchemaError as ex: 435 | ex.set_value(schema) 436 | raise 437 | 438 | # TODO: deprecate below attributes because they are implement details 439 | m_validator.is_string = output_string 440 | m_validator.accept_string = accept_string 441 | m_validator.accept_object = accept_object 442 | m_validator.output_string = output_string 443 | m_validator.output_object = output_object 444 | m_validator.validator = f 445 | # end deprecate 446 | 447 | m_validator.__module__ = f.__module__ 448 | m_validator.__name__ = f.__name__ 449 | if hasattr(f, '__qualname__'): 450 | m_validator.__qualname__ = f.__qualname__ 451 | m_validator.__doc__ = f.__doc__ 452 | return m_validator 453 | return decorator 454 | 455 | 456 | cdef str _UNIQUE_CHECK_ERROR_MESSAGE = "unable to check unique for non-hashable types" 457 | 458 | 459 | cdef inline _key_of_scalar(v): 460 | return v 461 | 462 | 463 | def _key_func_of_schema(schema): 464 | if schema is None: 465 | raise SchemaError(_UNIQUE_CHECK_ERROR_MESSAGE) 466 | 467 | if schema.validator == 'dict': 468 | if schema.items is None: 469 | raise SchemaError(_UNIQUE_CHECK_ERROR_MESSAGE) 470 | keys = [] 471 | for k, v in schema.items.items(): 472 | keys.append((k, _key_func_of_schema(v))) 473 | 474 | def key_of(dict v): 475 | cdef str k 476 | return tuple(key_of_value(v[k]) for k, key_of_value in keys) 477 | 478 | elif schema.validator == 'list': 479 | if schema.items is None: 480 | raise SchemaError(_UNIQUE_CHECK_ERROR_MESSAGE) 481 | key_of_value = _key_func_of_schema(schema.items) 482 | 483 | def key_of(list v): 484 | return tuple(key_of_value(x) for x in v) 485 | 486 | else: 487 | key_of = _key_of_scalar 488 | 489 | return key_of 490 | 491 | 492 | @validator(accept=typing.Iterable, output=typing.List) 493 | def list_validator(compiler, items=None, int minlen=0, int maxlen=1024, 494 | bint unique=False): 495 | if items is None: 496 | inner = None 497 | else: 498 | with mark_index(): 499 | inner = compiler.compile(items) 500 | if unique: 501 | key_of = _key_func_of_schema(items) 502 | del compiler, items 503 | 504 | def validate(value): 505 | try: 506 | value = enumerate(value) 507 | except TypeError: 508 | raise Invalid('not list') 509 | result = [] 510 | if unique: 511 | keys = set() 512 | cdef int i = -1 513 | for i, x in value: 514 | if i >= maxlen: 515 | raise Invalid('list length must <= %d' % maxlen) 516 | with mark_index(i): 517 | v = inner(x) if inner is not None else copy(x) 518 | if unique: 519 | k = key_of(v) 520 | if k in keys: 521 | raise Invalid('not unique') 522 | keys.add(k) 523 | result.append(v) 524 | if minlen > 0 and i + 1 < minlen: 525 | raise Invalid('list length must >= %d' % minlen) 526 | return result 527 | return validate 528 | 529 | 530 | cdef inline dict _slim_dict(dict value): 531 | return {k: v for k, v in value.items() if not _is_empty(v)} 532 | 533 | 534 | @validator(accept=(typing.Mapping, typing.Any), output=dict) 535 | def dict_validator(compiler, items=None, key=None, value=None, 536 | int minlen=0, int maxlen=1024, bint slim=False): 537 | if items is None: 538 | inners = None 539 | else: 540 | inners = [] 541 | for k, v in items.items(): 542 | with mark_key(k): 543 | inners.append((k, compiler.compile(v))) 544 | validate_extra_key = validate_extra_value = None 545 | if key is not None: 546 | with mark_key('$self_key'): 547 | validate_extra_key = compiler.compile(key) 548 | if value is not None: 549 | with mark_key('$self_value'): 550 | validate_extra_value = compiler.compile(value) 551 | cdef bint is_dynamic 552 | is_dynamic = bool(validate_extra_key or validate_extra_value) 553 | del compiler, items, key, value 554 | 555 | def validate(value): 556 | if inners is None and not is_dynamic: 557 | if not is_dict(value): 558 | raise Invalid('must be dict') 559 | if len(value) > maxlen: 560 | raise Invalid('dict length must <= %d' % maxlen) 561 | elif minlen > 0 and len(value) < minlen: 562 | raise Invalid('dict length must >= %d' % minlen) 563 | if slim: 564 | value = _slim_dict(value) 565 | return copy(value) 566 | if is_dict(value): 567 | getter = get_dict_value 568 | if is_dynamic: 569 | if len(value) > maxlen: 570 | raise Invalid('dict length must <= %d' % maxlen) 571 | elif minlen > 0 and len(value) < minlen: 572 | raise Invalid('dict length must >= %d' % minlen) 573 | else: 574 | getter = get_object_value 575 | if is_dynamic: 576 | raise Invalid("dynamic dict not allowed non-dict value") 577 | result = {} 578 | cdef str k 579 | if inners is not None: 580 | for k, inner in inners: 581 | with mark_key(k): 582 | result[k] = inner(getter(value, k)) 583 | if is_dynamic: 584 | extra_keys = map(str, set(value) - set(result)) 585 | for k in extra_keys: 586 | if validate_extra_key: 587 | with mark_key('$self_key'): 588 | k = str(validate_extra_key(k)) 589 | with mark_key(k): 590 | v = getter(value, k) 591 | if validate_extra_value is not None: 592 | result[k] = validate_extra_value(v) 593 | else: 594 | result[k] = copy(v) 595 | if slim: 596 | result = _slim_dict(result) 597 | return result 598 | return validate 599 | 600 | 601 | @validator(accept=(typing.Mapping, typing.Any), output=object) 602 | def model_validator(compiler, items=None): 603 | if items is None: 604 | raise SchemaError('model class not provided') 605 | 606 | def validate(value): 607 | return items(value) 608 | 609 | return validate 610 | 611 | 612 | cdef _dump_enum_value(value): 613 | if value is None: 614 | return 'null' 615 | elif value is False: 616 | return 'false' 617 | elif value is True: 618 | return 'true' 619 | elif isinstance(value, str): 620 | return repr(value) # single quotes by default 621 | else: 622 | return str(value) # number 623 | 624 | 625 | @validator(output=object) 626 | def enum_validator(compiler, items): 627 | if not items: 628 | raise SchemaError("enum items not provided") 629 | expects = '{' + ', '.join(map(_dump_enum_value, items)) + '}' 630 | items = frozenset(items) 631 | 632 | def validate(value): 633 | if value in items: 634 | return value 635 | raise Invalid("expect one of {}".format(expects)) 636 | 637 | return validate 638 | 639 | 640 | cdef union_validator(compiler, schema): 641 | if not schema.items: 642 | raise SchemaError('union schemas not provided') 643 | default = schema.params.get('default') 644 | if default is not None: 645 | raise SchemaError("not allowed default for union schema") 646 | by = schema.params.get('by') 647 | if isinstance(schema.items, list): 648 | if by is not None: 649 | raise SchemaError("not allowed 'by' argument for union list schema") 650 | return _union_list_validator(compiler, schema) 651 | elif isinstance(schema.items, dict): 652 | if by is None or by == "": 653 | raise SchemaError("required 'by' argument for union dict schema") 654 | if not isinstance(by, str): 655 | raise SchemaError("'by' argument must be str type for union schema") 656 | return _union_dict_validator(compiler, schema) 657 | else: 658 | raise SchemaError('union schemas type invalid') 659 | 660 | 661 | cdef _optional_or_has_default(schema): 662 | if schema.params.get('optional'): 663 | return True 664 | if schema.params.get('default') is not None: 665 | return True 666 | return False 667 | 668 | 669 | def _union_list_validator(compiler, schema): 670 | scalar_inner = None 671 | list_inner = None 672 | dict_inner = None 673 | for i, inner_schema in enumerate(schema.items): 674 | with mark_index(i): 675 | if inner_schema.validator == 'union': 676 | raise SchemaError('ambiguous union schema') 677 | if _optional_or_has_default(inner_schema): 678 | raise SchemaError('not allowed optional or default for union schemas') 679 | if schema.params.get('optional'): 680 | inner_schema = inner_schema.copy() 681 | inner_schema.params['optional'] = True 682 | if inner_schema.validator == 'list': 683 | if list_inner is not None: 684 | raise SchemaError('ambiguous union schema') 685 | list_inner = compiler.compile(inner_schema) 686 | elif inner_schema.validator in ('dict', 'model'): 687 | if dict_inner is not None: 688 | raise SchemaError('ambiguous union schema') 689 | dict_inner = compiler.compile(inner_schema) 690 | else: 691 | if scalar_inner is not None: 692 | raise SchemaError('ambiguous union schema') 693 | scalar_inner = compiler.compile(inner_schema) 694 | 695 | def validate(value): 696 | if isinstance(value, list): 697 | if list_inner is None: 698 | raise Invalid('not allowed list') 699 | return list_inner(value) 700 | elif is_dict(value) or hasattr(value, '__asdict__'): 701 | if dict_inner is None: 702 | raise Invalid('not allowed dict') 703 | return dict_inner(value) 704 | elif value is None: 705 | return (scalar_inner or list_inner or dict_inner)(value) 706 | else: 707 | if scalar_inner is None: 708 | raise Invalid('not allowed scalar value') 709 | return scalar_inner(value) 710 | 711 | _update_validate_func_info(validate, union_validator, schema) 712 | 713 | return validate 714 | 715 | 716 | @validator(accept=object, output=object) 717 | def _union_dict_validator(compiler, items, str by): 718 | inners = {} 719 | for key, schema in items.items(): 720 | with mark_key(key): 721 | if schema.validator not in ('dict', 'model'): 722 | raise SchemaError('must be dict or model schema') 723 | if _optional_or_has_default(schema): 724 | raise SchemaError('not allowed optional or default for union schemas') 725 | is_model = schema.validator == 'model' 726 | inners[key] = (is_model, compiler.compile(schema)) 727 | expect_bys = '{' + ', '.join(sorted(inners.keys())) + '}' 728 | del key, schema, is_model 729 | 730 | def validate(value): 731 | if is_dict(value): 732 | getter = get_dict_value 733 | else: 734 | getter = get_object_value 735 | cdef str by_name 736 | with mark_key(by): 737 | by_name = getter(value, by) 738 | if not by_name: 739 | raise Invalid('required', value=by_name) 740 | inner_info = inners.get(by_name) 741 | if inner_info is None: 742 | err_msg = 'expect one of {}'.format(expect_bys) 743 | raise Invalid(err_msg, value=by_name) 744 | is_model, inner = inner_info 745 | result = inner(value) 746 | if not is_model: 747 | result[by] = by_name 748 | return result 749 | 750 | return validate 751 | 752 | 753 | cdef any_validate(value): 754 | return copy(value) 755 | 756 | 757 | @validator(accept=object, output=object) 758 | def any_validator(compiler, **ignore_kwargs): 759 | """Accept any value""" 760 | return any_validate 761 | 762 | 763 | MAX_INT = 2**64 - 1 764 | 765 | @validator(accept=(int, float, str), output=int) 766 | def int_validator(compiler, min=-MAX_INT, max=MAX_INT): 767 | """Validate int or convert string to int 768 | 769 | Args: 770 | min (int): the min value, default -(2**64 - 1) 771 | max (int): the max value, default (2**64 - 1) 772 | """ 773 | min, max = int(min), int(max) 774 | 775 | def validate(value): 776 | try: 777 | v = int(value) 778 | except Exception: 779 | raise Invalid('invalid int') from None 780 | if v < min: 781 | raise Invalid('value must >= %d' % min) 782 | elif v > max: 783 | raise Invalid('value must <= %d' % max) 784 | return v 785 | return validate 786 | 787 | 788 | _TRUE_VALUES = { 789 | True, 1, '1', 790 | 'True', 'true', 'TRUE', 791 | 'Yes', 'yes', 'YES', 'y', 'Y', 792 | 'On', 'on', 'ON', 793 | } 794 | _FALSE_VALUES = { 795 | False, 0, '0', 796 | 'False', 'false', 'FALSE', 797 | 'No', 'no', 'NO', 'n', 'N', 798 | 'Off', 'off', 'OFF', 799 | } 800 | 801 | 802 | @validator(accept=(bool, int, str), output=bool) 803 | def bool_validator(compiler): 804 | """Validate bool""" 805 | def validate(value): 806 | if value in _TRUE_VALUES: 807 | return True 808 | elif value in _FALSE_VALUES: 809 | return False 810 | else: 811 | raise Invalid('invalid bool') 812 | return validate 813 | 814 | 815 | @validator(accept=(int, float, str), output=float) 816 | def float_validator(compiler, min=-sys.float_info.max, max=sys.float_info.max, 817 | exmin=False, exmax=False): 818 | """Validate float string 819 | 820 | Args: 821 | min (float): the min value, default -sys.float_info.max 822 | max (float): the max value, default sys.float_info.max 823 | exmin (bool,float): exclude min value or not, default false 824 | exmax (bool,float): exclude max value or not, default false 825 | """ 826 | min, max = float(min), float(max) 827 | if isinstance(exmin, (int, float)) and not isinstance(exmin, bool): 828 | min = float(exmin) 829 | exmin = True 830 | else: 831 | exmin = bool(exmin) 832 | if isinstance(exmax, (int, float)) and not isinstance(exmax, bool): 833 | max = float(exmax) 834 | exmax = True 835 | else: 836 | exmax = bool(exmax) 837 | 838 | def validate(value): 839 | try: 840 | v = float(value) 841 | except Exception: 842 | raise Invalid('invalid float') from None 843 | if exmin: 844 | if v <= min: 845 | raise Invalid('value must > %d' % min) 846 | else: 847 | if v < min: 848 | raise Invalid('value must >= %d' % min) 849 | if exmax: 850 | if v >= max: 851 | raise Invalid('value must < %d' % max) 852 | else: 853 | if v > max: 854 | raise Invalid('value must <= %d' % max) 855 | return v 856 | return validate 857 | 858 | 859 | def _str_validator(compiler, int minlen=0, int maxlen=1024 * 1024, 860 | bint strip=False, bint escape=False, str match=None, 861 | bint accept_object=False): 862 | """Validate string 863 | 864 | Args: 865 | minlen (int): min length of string, default 0 866 | maxlen (int): max length of string, default 1024*1024 867 | strip (bool): strip white space or not, default false 868 | escape (bool): escape to html safe string or not, default false 869 | match (str): regex to match, default None 870 | """ 871 | 872 | # To make sure that the entire string matches 873 | if match: 874 | try: 875 | re_match = re.compile(r'(?:%s)\Z' % match).match 876 | except Exception as ex: 877 | raise SchemaError('match regex %s compile failed' % match) from ex 878 | else: 879 | re_match = None 880 | 881 | def validate(value): 882 | if not isinstance(value, str): 883 | if accept_object or isinstance(value, int): 884 | value = str(value) 885 | else: 886 | raise Invalid('invalid string') 887 | if strip: 888 | value = value.strip() 889 | cdef int length = len(value) 890 | if length < minlen: 891 | raise Invalid('string length must >= %d' % minlen) 892 | elif length > maxlen: 893 | raise Invalid('string length must <= %d' % maxlen) 894 | if escape: 895 | value = (value.replace('&', '&') 896 | .replace('>', '>') 897 | .replace('<', '<') 898 | .replace("'", ''') 899 | .replace('"', '"')) 900 | if re_match is not None and not re_match(value): 901 | raise Invalid("string not match regex %s" % match) 902 | return value 903 | return validate 904 | 905 | 906 | str_validator = validator(accept=(str, object), output=str)(_str_validator) 907 | nstr_validator = validator(accept=object, output=object)(_str_validator) 908 | 909 | 910 | @validator(accept=bytes, output=bytes) 911 | def bytes_validator(compiler, int minlen=0, int maxlen=-1): 912 | """Validate bytes 913 | 914 | Args: 915 | minlen (int): min length of bytes, default 0 916 | maxlen (int): max length of bytes, default unlimited 917 | """ 918 | 919 | def validate(value): 920 | if not isinstance(value, bytes): 921 | raise Invalid('invalid bytes') 922 | cdef int length = len(value) 923 | if length < minlen: 924 | raise Invalid('bytes length must >= %d' % minlen) 925 | elif maxlen > -1 and length > maxlen: 926 | raise Invalid('bytes length must <= %d' % maxlen) 927 | return value 928 | 929 | return validate 930 | 931 | 932 | @validator(accept=(str, datetime.date), output=(str, datetime.date)) 933 | def date_validator(compiler, str format='%Y-%m-%d', bint output_object=False): 934 | """Validate date string or convert date to string 935 | 936 | Args: 937 | format (str): date format, default ISO8601 format 938 | """ 939 | def validate(value): 940 | try: 941 | if not isinstance(value, (datetime.datetime, datetime.date)): 942 | value = datetime.datetime.strptime(value, format) 943 | if isinstance(value, datetime.datetime): 944 | value = value.date() 945 | if output_object: 946 | return value 947 | else: 948 | return value.strftime(format) 949 | except Exception: 950 | raise Invalid('invalid date') from None 951 | return validate 952 | 953 | 954 | @validator(accept=(str, datetime.time), output=(str, datetime.time)) 955 | def time_validator(compiler, str format='%H:%M:%S', bint output_object=False): 956 | """Validate time string or convert time to string 957 | 958 | Args: 959 | format (str): time format, default ISO8601 format 960 | """ 961 | def validate(value): 962 | try: 963 | if not isinstance(value, (datetime.datetime, datetime.time)): 964 | value = datetime.datetime.strptime(value, format) 965 | if isinstance(value, datetime.datetime): 966 | value = value.time() 967 | if output_object: 968 | return value 969 | else: 970 | return value.strftime(format) 971 | except Exception: 972 | raise Invalid('invalid time') from None 973 | return validate 974 | 975 | 976 | @validator(accept=(str, datetime.datetime), output=(str, datetime.datetime)) 977 | def datetime_validator(compiler, str format='%Y-%m-%dT%H:%M:%S.%fZ', bint output_object=False): 978 | """Validate datetime string or convert datetime to string 979 | 980 | Args: 981 | format (str): datetime format, default ISO8601 format 982 | """ 983 | def validate(value): 984 | try: 985 | if isinstance(value, tuple): 986 | value = datetime.datetime.fromtimestamp(time.mktime(value)) 987 | elif not isinstance(value, datetime.datetime): 988 | value = datetime.datetime.strptime(value, format) 989 | if output_object: 990 | return value 991 | else: 992 | return value.strftime(format) 993 | except Exception: 994 | raise Invalid('invalid datetime') from None 995 | return validate 996 | 997 | 998 | cdef _parse_timedelta(value): 999 | if isinstance(value, (int, float)): 1000 | value = datetime.timedelta(seconds=value) 1001 | elif isinstance(value, str): 1002 | value = durationpy.from_str(value) 1003 | else: 1004 | if not isinstance(value, datetime.timedelta): 1005 | raise ValueError("invalid timedelta") 1006 | return value 1007 | 1008 | 1009 | @validator(accept=(int, float, str, datetime.timedelta), output=(str, float, datetime.timedelta)) 1010 | def timedelta_validator(compiler, min=None, max=None, bint string=False, 1011 | bint extended=False, bint output_object=False): 1012 | """Validate timedelta string or convert timedelta to string 1013 | 1014 | Format (Go's Duration strings): 1015 | ns - nanoseconds 1016 | us - microseconds 1017 | ms - milliseconds 1018 | s - seconds 1019 | m - minutes 1020 | h - hours 1021 | d - days 1022 | mo - months 1023 | y - years 1024 | """ 1025 | if string and output_object: 1026 | raise SchemaError('can not output both string and object') 1027 | try: 1028 | min_value = _parse_timedelta(min) if min is not None else None 1029 | except (durationpy.DurationError, ValueError, TypeError) as ex: 1030 | raise SchemaError('invalid min timedelta') from ex 1031 | try: 1032 | max_value = _parse_timedelta(max) if max is not None else None 1033 | except (durationpy.DurationError, ValueError, TypeError) as ex: 1034 | raise SchemaError('invalid max timedelta') from ex 1035 | del min, max 1036 | min_repr = max_repr = None 1037 | if min_value is not None: 1038 | min_repr = durationpy.to_str(min_value, extended=True) 1039 | if max_value is not None: 1040 | max_repr = durationpy.to_str(max_value, extended=True) 1041 | 1042 | def validate(value): 1043 | try: 1044 | value = _parse_timedelta(value) 1045 | except (durationpy.DurationError, ValueError, TypeError) as ex: 1046 | raise Invalid('invalid timedelta') from ex 1047 | if min_value is not None: 1048 | if value < min_value: 1049 | raise Invalid('value must >= {}'.format(min_repr)) 1050 | if max_value is not None: 1051 | if value > max_value: 1052 | raise Invalid('value must <= {}'.format(max_repr)) 1053 | if output_object: 1054 | return value 1055 | if string: 1056 | value = durationpy.to_str(value, extended=extended) 1057 | else: 1058 | value = value.total_seconds() 1059 | return value 1060 | return validate 1061 | 1062 | 1063 | @validator(accept=(str, ipaddress.IPv4Address), output=(str, ipaddress.IPv4Address)) 1064 | def ipv4_validator(compiler, bint output_object=False): 1065 | def validate(value): 1066 | try: 1067 | value = ipaddress.IPv4Address(value.strip()) 1068 | except ipaddress.AddressValueError as ex: 1069 | raise Invalid(str(ex)) from None 1070 | except Exception: 1071 | raise Invalid('invalid ipv4 address') from None 1072 | if output_object: 1073 | return value 1074 | else: 1075 | return value.compressed 1076 | return validate 1077 | 1078 | 1079 | @validator(accept=(str, ipaddress.IPv6Address), output=(str, ipaddress.IPv6Address)) 1080 | def ipv6_validator(compiler, bint output_object=False): 1081 | def validate(value): 1082 | try: 1083 | value = ipaddress.IPv6Address(value.strip()) 1084 | except ipaddress.AddressValueError as ex: 1085 | raise Invalid(str(ex)) from None 1086 | except Exception: 1087 | raise Invalid('invalid ipv6 address') from None 1088 | if output_object: 1089 | return value 1090 | else: 1091 | return value.compressed 1092 | return validate 1093 | 1094 | 1095 | @validator(accept=str, output=str) 1096 | def email_validator(compiler): 1097 | # https://stackoverflow.com/questions/201323/using-a-regular-expression-to-validate-an-email-address 1098 | # http://emailregex.com/ 1099 | # https://github.com/JoshData/python-email-validator 1100 | _validate = partial( 1101 | validate_email, 1102 | allow_smtputf8=False, 1103 | check_deliverability=False, 1104 | allow_empty_local=False, 1105 | ) 1106 | 1107 | def validate(value): 1108 | try: 1109 | value = _validate(value.strip()) 1110 | except EmailNotValidError as ex: 1111 | raise Invalid(str(ex)) from None 1112 | except Exception: 1113 | raise Invalid('invalid email address') from None 1114 | return value['email'] 1115 | return validate 1116 | 1117 | 1118 | @validator(output=(str, object)) 1119 | def url_validator(compiler, str scheme='http https', int maxlen=255, bint output_object=False): 1120 | # https://stackoverflow.com/questions/7160737/python-how-to-validate-a-url-in-python-malformed-or-not 1121 | # https://stackoverflow.com/questions/827557/how-do-you-validate-a-url-with-a-regular-expression-in-python 1122 | # https://github.com/python-hyper/rfc3986 1123 | # https://github.com/dgerber/rfc3987 1124 | # https://github.com/tkem/uritools 1125 | allow_scheme = set(scheme.split()) 1126 | 1127 | def validate(value): 1128 | try: 1129 | value = value.strip() 1130 | except Exception: 1131 | raise Invalid('invalid url') from None 1132 | if len(value) > maxlen: 1133 | raise Invalid('url length must <= {}'.format(maxlen)) 1134 | try: 1135 | parsed = urlparse(value) 1136 | except Exception: 1137 | raise Invalid('invalid url') from None 1138 | if not parsed.scheme or parsed.scheme not in allow_scheme: 1139 | raise Invalid('invalid url scheme, expect {}'.format(allow_scheme)) 1140 | if output_object: 1141 | return parsed 1142 | else: 1143 | return urlunparse(parsed) 1144 | return validate 1145 | 1146 | 1147 | @validator(output=str) 1148 | def fqdn_validator(compiler): 1149 | def validate(value): 1150 | try: 1151 | value = value.strip() 1152 | fqdn_obj = FQDN(value) 1153 | if fqdn_obj.is_valid: 1154 | return fqdn_obj.relative 1155 | except (ValueError, TypeError, AttributeError) as ex: 1156 | raise Invalid("invalid fqdn") from ex 1157 | raise Invalid("invalid fqdn") 1158 | return validate 1159 | 1160 | 1161 | @validator(output=(str, uuid.UUID)) 1162 | def uuid_validator(compiler, version=None, bint output_object=False): 1163 | if version is None: 1164 | msg = 'invalid uuid' 1165 | else: 1166 | if version not in {1, 3, 4, 5}: 1167 | raise SchemaError('illegal version number') 1168 | msg = 'invalid uuid{}'.format(version) 1169 | 1170 | def validate(value): 1171 | if not isinstance(value, uuid.UUID): 1172 | try: 1173 | value = uuid.UUID(value.strip()) 1174 | except Exception: 1175 | raise Invalid(msg) from None 1176 | if version is not None and value.version != version: 1177 | raise Invalid(msg) 1178 | if output_object: 1179 | return value 1180 | else: 1181 | return str(value) 1182 | return validate 1183 | 1184 | 1185 | def create_re_validator(str name, str r, int default_maxlen=255): 1186 | """Create validator by regex string 1187 | 1188 | It will make sure that the entire string matches, so needn't 1189 | add `^`,`$` to regex string. 1190 | 1191 | Args: 1192 | name (str): validator name, used in error message 1193 | r (str): regex string 1194 | """ 1195 | # To make sure that the entire string matches 1196 | match = re.compile(r'(?:%s)\Z' % r).match 1197 | message = 'invalid %s' % name 1198 | 1199 | def re_validator(compiler, int minlen=0, int maxlen=default_maxlen, bint strip=False): 1200 | def validate(value): 1201 | if not isinstance(value, str): 1202 | raise Invalid('value must be string') 1203 | if strip: 1204 | value = value.strip() 1205 | cdef int length = len(value) 1206 | if length < minlen: 1207 | raise Invalid('%s length must >= %d' % (name, minlen)) 1208 | elif length > maxlen: 1209 | raise Invalid('%s length must <= %d' % (name, maxlen)) 1210 | if match(value): 1211 | return value 1212 | else: 1213 | raise Invalid(message) 1214 | return validate 1215 | re_validator.__name__ = name + '_validator' 1216 | re_validator.__qualname__ = name + '_validator' 1217 | return validator(accept=str, output=str)(re_validator) 1218 | 1219 | 1220 | builtin_validators = { 1221 | 'list': list_validator, 1222 | 'dict': dict_validator, 1223 | 'model': model_validator, 1224 | 'union': union_validator, 1225 | 'enum': enum_validator, 1226 | 'any': any_validator, 1227 | 'int': int_validator, 1228 | 'bool': bool_validator, 1229 | 'float': float_validator, 1230 | 'str': str_validator, 1231 | 'nstr': nstr_validator, 1232 | 'bytes': bytes_validator, 1233 | 'date': date_validator, 1234 | 'time': time_validator, 1235 | 'datetime': datetime_validator, 1236 | 'timedelta': timedelta_validator, 1237 | 'ipv4': ipv4_validator, 1238 | 'ipv6': ipv6_validator, 1239 | 'email': email_validator, 1240 | 'url': url_validator, 1241 | 'fqdn': fqdn_validator, 1242 | 'uuid': uuid_validator, 1243 | } 1244 | 1245 | regexs = { 1246 | 'phone': (r'((\+\d{2}\s?)|(\d{2}\s?))?1\d{10}', 15), 1247 | 'idcard': (r'(\d{17}[\d|x|X])|(\d{15})', 18), 1248 | 'slug': (r'[a-z0-9]+(?:-[a-z0-9]+)*', 255), 1249 | } 1250 | for name, options in regexs.items(): 1251 | builtin_validators[name] = create_re_validator(name, *options) 1252 | 1253 | 1254 | def create_enum_validator(str name, items, bint string=True): 1255 | """Create validator by enum items 1256 | 1257 | Args: 1258 | name (str): validator name, used in error message 1259 | items (iterable): enum items 1260 | string (bool): is string like or not 1261 | 1262 | Deprecated since v1.2.0, use enum validator instead. 1263 | """ 1264 | items = set(items) 1265 | message = 'invalid {}, expect one of {}'.format(name, list(sorted(items))) 1266 | 1267 | def enum_validator(compiler): 1268 | def validate(value): 1269 | if value in items: 1270 | return value 1271 | raise Invalid(message) 1272 | return validate 1273 | enum_validator.__name__ = name + '_validator' 1274 | enum_validator.__qualname__ = name + '_validator' 1275 | if string: 1276 | return validator(accept=str, output=str)(enum_validator) 1277 | else: 1278 | return validator(accept=object, output=object)(enum_validator) 1279 | 1280 | 1281 | cdef class _Field: 1282 | 1283 | cdef str name 1284 | 1285 | def __init__(self, str name, schema, compiler): 1286 | self.name = name 1287 | self.__schema__ = schema 1288 | with mark_key(self.name): 1289 | self.validate = compiler.compile(schema) 1290 | 1291 | def __repr__(self): 1292 | info = "schema={!r}".format(self.__schema__) 1293 | return "Field(name={!r}, {})".format(self.name, info) 1294 | 1295 | def __get__(self, obj, obj_type): 1296 | if obj is None: 1297 | return self 1298 | return obj.__dict__.get(self.name, None) 1299 | 1300 | def __set__(self, obj, value): 1301 | with mark_key(self.name): 1302 | value = self.validate(value) 1303 | obj.__dict__[self.name] = value 1304 | 1305 | 1306 | class Field(_Field): pass 1307 | 1308 | 1309 | cdef _value_asdict(value): 1310 | if hasattr(value, '__asdict__'): 1311 | return value.__asdict__() 1312 | elif is_dict(value): 1313 | return {k: _value_asdict(v) for k, v in value.items()} 1314 | elif isinstance(value, (list, tuple, set)): 1315 | return [_value_asdict(x) for x in value] 1316 | else: 1317 | return value 1318 | 1319 | 1320 | def py_model_init(self, obj, params): 1321 | params_set = set(params) 1322 | errors = [] 1323 | cdef str k 1324 | if obj: 1325 | if len(obj) > 1: 1326 | msg = ( 1327 | "__init__() takes 2 positional arguments " 1328 | "but {} were given".format(len(obj) + 1) 1329 | ) 1330 | raise TypeError(msg) 1331 | obj = obj[0] 1332 | if is_dict(obj): 1333 | getter = get_dict_value 1334 | else: 1335 | getter = get_object_value 1336 | for k in self.__fields__ - params_set: 1337 | try: 1338 | setattr(self, k, getter(obj, k)) 1339 | except Invalid as ex: 1340 | errors.append(ex) 1341 | else: 1342 | for k in self.__fields__ - params_set: 1343 | try: 1344 | setattr(self, k, None) 1345 | except Invalid as ex: 1346 | errors.append(ex) 1347 | for k in self.__fields__ & params_set: 1348 | try: 1349 | setattr(self, k, params[k]) 1350 | except Invalid as ex: 1351 | errors.append(ex) 1352 | for k in params_set - self.__fields__: 1353 | errors.append(Invalid("undesired key").mark_key(k)) 1354 | if errors: 1355 | raise ModelInvalid(errors) 1356 | 1357 | 1358 | def py_model_asdict(self, keys=None): 1359 | if not keys: 1360 | keys = self.__fields__ 1361 | else: 1362 | keys = set(keys) & self.__fields__ 1363 | ret = {} 1364 | cdef str k 1365 | for k in keys: 1366 | v = getattr(self, k) 1367 | if v is not None: 1368 | v = _value_asdict(v) 1369 | ret[k] = v 1370 | return ret 1371 | --------------------------------------------------------------------------------