├── .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 | [](https://github.com/guyskk/validr/actions/workflows/build-test.yml) [](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 |
--------------------------------------------------------------------------------