schemmy 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- schemmy-0.1.0/LICENSE +21 -0
- schemmy-0.1.0/PKG-INFO +149 -0
- schemmy-0.1.0/README.md +126 -0
- schemmy-0.1.0/pyproject.toml +105 -0
- schemmy-0.1.0/pyproject.toml.orig +95 -0
- schemmy-0.1.0/src/schemmy/__init__.py +4 -0
- schemmy-0.1.0/src/schemmy/exceptions.py +32 -0
- schemmy-0.1.0/src/schemmy/py.typed +0 -0
- schemmy-0.1.0/src/schemmy/schema.py +651 -0
schemmy-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Ruslan Glotov
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
schemmy-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: schemmy
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Validate data with plain type annotations. No dependencies.
|
|
5
|
+
Keywords: validation,schema,type-hints,annotations,typing
|
|
6
|
+
Author: Ruslan Glotov
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Classifier: Development Status :: 3 - Alpha
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: Operating System :: OS Independent
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
17
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
18
|
+
Classifier: Typing :: Typed
|
|
19
|
+
Requires-Python: >=3.12
|
|
20
|
+
Project-URL: Repository, https://github.com/rusih100/schemmy
|
|
21
|
+
Project-URL: Issues, https://github.com/rusih100/schemmy/issues
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
|
|
24
|
+
# schemmy
|
|
25
|
+
|
|
26
|
+
Validate data with plain type annotations. No dependencies.
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
pip install schemmy
|
|
30
|
+
# or
|
|
31
|
+
uv add schemmy
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Requires Python 3.12+.
|
|
35
|
+
|
|
36
|
+
## Example
|
|
37
|
+
|
|
38
|
+
```python
|
|
39
|
+
from typing import Annotated
|
|
40
|
+
|
|
41
|
+
from schemmy import Schema, ValidationError
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def positive(value: int) -> int:
|
|
45
|
+
if value <= 0:
|
|
46
|
+
raise ValueError("must be greater than zero")
|
|
47
|
+
return value
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class Item(Schema):
|
|
51
|
+
sku: str
|
|
52
|
+
qty: Annotated[int, positive]
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class Order(Schema):
|
|
56
|
+
id: int
|
|
57
|
+
items: list[Item]
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
order = Order.validate({"id": 1, "items": [{"sku": "A1", "qty": 2}]})
|
|
61
|
+
order.items[0].qty # 2
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
All errors are collected at once, not just the first one. Each error carries the path to the field:
|
|
65
|
+
|
|
66
|
+
```python
|
|
67
|
+
try:
|
|
68
|
+
Order.validate({"id": "1", "items": [{"sku": "A1", "qty": 0}, {"qty": 3}], "note": "x"})
|
|
69
|
+
except ValidationError as exc:
|
|
70
|
+
for error in exc.exceptions:
|
|
71
|
+
print(error.location, error.message)
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
```text
|
|
75
|
+
['note'] Unknown field
|
|
76
|
+
['id'] Expected int, got str
|
|
77
|
+
['items', 0, 'qty'] Validator positive failed: must be greater than zero
|
|
78
|
+
['items', 1, 'sku'] Field required
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Errors come in a stable order: missing and unknown fields first, then value errors in field declaration order.
|
|
82
|
+
|
|
83
|
+
`ValidationError` is an `ExceptionGroup`, so `except*` works too.
|
|
84
|
+
|
|
85
|
+
## What is supported
|
|
86
|
+
|
|
87
|
+
| Annotation | Behavior |
|
|
88
|
+
|---|---|
|
|
89
|
+
| `int`, `str`, `float`, `bool`, any class | `isinstance` check, no type coercion. `bool` is rejected for `int` fields |
|
|
90
|
+
| `float` | Also accepts an `int` that `float` represents exactly and converts it, since JSON sends `1` for `1.0` |
|
|
91
|
+
| `Any` | Value is accepted as is |
|
|
92
|
+
| `list[T]` | Each item is checked against `T`; the item index is added to the error path |
|
|
93
|
+
| `tuple[T, ...]`, `tuple[T1, T2]` | Accepts a tuple or a list and stores a tuple, since JSON has no tuples. A fixed tuple requires the exact length |
|
|
94
|
+
| `dict[K, V]` | Each key is checked against `K` and each value against `V`; the key is added to the error path |
|
|
95
|
+
| `Literal[...]` | The value must equal one of the literals and have the same type, so `True` does not match `Literal[1]` |
|
|
96
|
+
| `X \| Y`, `Optional[X]` | Members are tried in order, the first match wins. For `X \| None`, errors of `X` are reported as is |
|
|
97
|
+
| `Schema` subclass | Accepts a schema instance or a `dict` |
|
|
98
|
+
| `Annotated[T, *validators]` | `T` is checked first, then validators run in order |
|
|
99
|
+
|
|
100
|
+
A validator is a callable that takes a value and returns it, possibly changed. To report an error, a validator raises an exception. The returned value is stored in the schema, so validators can normalize data, for example bring a phone number to a single format.
|
|
101
|
+
|
|
102
|
+
Unknown fields and missing required fields are validation errors as well.
|
|
103
|
+
|
|
104
|
+
A field with a default value is optional. If the field is not passed, the schema gets a copy of the default. The default value itself is not validated.
|
|
105
|
+
|
|
106
|
+
`ClassVar` annotations are not fields.
|
|
107
|
+
|
|
108
|
+
## Working with instances
|
|
109
|
+
|
|
110
|
+
```python
|
|
111
|
+
order.to_dict()
|
|
112
|
+
# {'id': 1, 'items': [{'sku': 'A1', 'qty': 2}]}
|
|
113
|
+
|
|
114
|
+
order
|
|
115
|
+
# Order(id=1, items=[Item(sku='A1', qty=2)])
|
|
116
|
+
|
|
117
|
+
Order.validate(order.to_dict()) == order
|
|
118
|
+
# True
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
`to_dict` converts nested schemas recursively and keeps field declaration order. Schemas compare by class and field values. Schemas are mutable and therefore unhashable.
|
|
122
|
+
|
|
123
|
+
## Type checking
|
|
124
|
+
|
|
125
|
+
Type checkers derive the constructor signature from the annotations, so wrong types, unknown and missing fields are reported before runtime:
|
|
126
|
+
|
|
127
|
+
```python
|
|
128
|
+
Item(sku=1, qty=2) # error: expected str
|
|
129
|
+
Item(sku="A1", qyt=2) # error: unknown argument
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
The constructor is typed for already typed values. Use `Schema.validate` for raw data such as parsed JSON, where nested schemas arrive as dicts.
|
|
133
|
+
|
|
134
|
+
## Limitations
|
|
135
|
+
|
|
136
|
+
`set`, `frozenset`, `TypedDict` and other annotations not listed above are not supported yet. A field with such an annotation produces an "unsupported type" error.
|
|
137
|
+
|
|
138
|
+
## Development
|
|
139
|
+
|
|
140
|
+
```bash
|
|
141
|
+
uv sync
|
|
142
|
+
uv run pytest
|
|
143
|
+
uv run ruff check . && uv run ruff format --check .
|
|
144
|
+
uv run ty check
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
## License
|
|
148
|
+
|
|
149
|
+
MIT
|
schemmy-0.1.0/README.md
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
# schemmy
|
|
2
|
+
|
|
3
|
+
Validate data with plain type annotations. No dependencies.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
pip install schemmy
|
|
7
|
+
# or
|
|
8
|
+
uv add schemmy
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Requires Python 3.12+.
|
|
12
|
+
|
|
13
|
+
## Example
|
|
14
|
+
|
|
15
|
+
```python
|
|
16
|
+
from typing import Annotated
|
|
17
|
+
|
|
18
|
+
from schemmy import Schema, ValidationError
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def positive(value: int) -> int:
|
|
22
|
+
if value <= 0:
|
|
23
|
+
raise ValueError("must be greater than zero")
|
|
24
|
+
return value
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class Item(Schema):
|
|
28
|
+
sku: str
|
|
29
|
+
qty: Annotated[int, positive]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class Order(Schema):
|
|
33
|
+
id: int
|
|
34
|
+
items: list[Item]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
order = Order.validate({"id": 1, "items": [{"sku": "A1", "qty": 2}]})
|
|
38
|
+
order.items[0].qty # 2
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
All errors are collected at once, not just the first one. Each error carries the path to the field:
|
|
42
|
+
|
|
43
|
+
```python
|
|
44
|
+
try:
|
|
45
|
+
Order.validate({"id": "1", "items": [{"sku": "A1", "qty": 0}, {"qty": 3}], "note": "x"})
|
|
46
|
+
except ValidationError as exc:
|
|
47
|
+
for error in exc.exceptions:
|
|
48
|
+
print(error.location, error.message)
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
```text
|
|
52
|
+
['note'] Unknown field
|
|
53
|
+
['id'] Expected int, got str
|
|
54
|
+
['items', 0, 'qty'] Validator positive failed: must be greater than zero
|
|
55
|
+
['items', 1, 'sku'] Field required
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Errors come in a stable order: missing and unknown fields first, then value errors in field declaration order.
|
|
59
|
+
|
|
60
|
+
`ValidationError` is an `ExceptionGroup`, so `except*` works too.
|
|
61
|
+
|
|
62
|
+
## What is supported
|
|
63
|
+
|
|
64
|
+
| Annotation | Behavior |
|
|
65
|
+
|---|---|
|
|
66
|
+
| `int`, `str`, `float`, `bool`, any class | `isinstance` check, no type coercion. `bool` is rejected for `int` fields |
|
|
67
|
+
| `float` | Also accepts an `int` that `float` represents exactly and converts it, since JSON sends `1` for `1.0` |
|
|
68
|
+
| `Any` | Value is accepted as is |
|
|
69
|
+
| `list[T]` | Each item is checked against `T`; the item index is added to the error path |
|
|
70
|
+
| `tuple[T, ...]`, `tuple[T1, T2]` | Accepts a tuple or a list and stores a tuple, since JSON has no tuples. A fixed tuple requires the exact length |
|
|
71
|
+
| `dict[K, V]` | Each key is checked against `K` and each value against `V`; the key is added to the error path |
|
|
72
|
+
| `Literal[...]` | The value must equal one of the literals and have the same type, so `True` does not match `Literal[1]` |
|
|
73
|
+
| `X \| Y`, `Optional[X]` | Members are tried in order, the first match wins. For `X \| None`, errors of `X` are reported as is |
|
|
74
|
+
| `Schema` subclass | Accepts a schema instance or a `dict` |
|
|
75
|
+
| `Annotated[T, *validators]` | `T` is checked first, then validators run in order |
|
|
76
|
+
|
|
77
|
+
A validator is a callable that takes a value and returns it, possibly changed. To report an error, a validator raises an exception. The returned value is stored in the schema, so validators can normalize data, for example bring a phone number to a single format.
|
|
78
|
+
|
|
79
|
+
Unknown fields and missing required fields are validation errors as well.
|
|
80
|
+
|
|
81
|
+
A field with a default value is optional. If the field is not passed, the schema gets a copy of the default. The default value itself is not validated.
|
|
82
|
+
|
|
83
|
+
`ClassVar` annotations are not fields.
|
|
84
|
+
|
|
85
|
+
## Working with instances
|
|
86
|
+
|
|
87
|
+
```python
|
|
88
|
+
order.to_dict()
|
|
89
|
+
# {'id': 1, 'items': [{'sku': 'A1', 'qty': 2}]}
|
|
90
|
+
|
|
91
|
+
order
|
|
92
|
+
# Order(id=1, items=[Item(sku='A1', qty=2)])
|
|
93
|
+
|
|
94
|
+
Order.validate(order.to_dict()) == order
|
|
95
|
+
# True
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
`to_dict` converts nested schemas recursively and keeps field declaration order. Schemas compare by class and field values. Schemas are mutable and therefore unhashable.
|
|
99
|
+
|
|
100
|
+
## Type checking
|
|
101
|
+
|
|
102
|
+
Type checkers derive the constructor signature from the annotations, so wrong types, unknown and missing fields are reported before runtime:
|
|
103
|
+
|
|
104
|
+
```python
|
|
105
|
+
Item(sku=1, qty=2) # error: expected str
|
|
106
|
+
Item(sku="A1", qyt=2) # error: unknown argument
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
The constructor is typed for already typed values. Use `Schema.validate` for raw data such as parsed JSON, where nested schemas arrive as dicts.
|
|
110
|
+
|
|
111
|
+
## Limitations
|
|
112
|
+
|
|
113
|
+
`set`, `frozenset`, `TypedDict` and other annotations not listed above are not supported yet. A field with such an annotation produces an "unsupported type" error.
|
|
114
|
+
|
|
115
|
+
## Development
|
|
116
|
+
|
|
117
|
+
```bash
|
|
118
|
+
uv sync
|
|
119
|
+
uv run pytest
|
|
120
|
+
uv run ruff check . && uv run ruff format --check .
|
|
121
|
+
uv run ty check
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
## License
|
|
125
|
+
|
|
126
|
+
MIT
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "schemmy"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Validate data with plain type annotations. No dependencies."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
license-files = ["LICENSE"]
|
|
8
|
+
requires-python = ">=3.12"
|
|
9
|
+
dependencies = []
|
|
10
|
+
keywords = [
|
|
11
|
+
"validation",
|
|
12
|
+
"schema",
|
|
13
|
+
"type-hints",
|
|
14
|
+
"annotations",
|
|
15
|
+
"typing",
|
|
16
|
+
]
|
|
17
|
+
classifiers = [
|
|
18
|
+
"Development Status :: 3 - Alpha",
|
|
19
|
+
"Intended Audience :: Developers",
|
|
20
|
+
"Operating System :: OS Independent",
|
|
21
|
+
"Programming Language :: Python :: 3",
|
|
22
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
23
|
+
"Programming Language :: Python :: 3.12",
|
|
24
|
+
"Programming Language :: Python :: 3.13",
|
|
25
|
+
"Programming Language :: Python :: 3.14",
|
|
26
|
+
"Topic :: Software Development :: Libraries",
|
|
27
|
+
"Typing :: Typed",
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
[[project.authors]]
|
|
31
|
+
name = "Ruslan Glotov"
|
|
32
|
+
|
|
33
|
+
[project.urls]
|
|
34
|
+
Repository = "https://github.com/rusih100/schemmy"
|
|
35
|
+
Issues = "https://github.com/rusih100/schemmy/issues"
|
|
36
|
+
|
|
37
|
+
[build-system]
|
|
38
|
+
requires = ["uv_build>=0.13.1,<0.14"]
|
|
39
|
+
build-backend = "uv_build"
|
|
40
|
+
|
|
41
|
+
[dependency-groups]
|
|
42
|
+
dev = [
|
|
43
|
+
"pre-commit>=4.7.0",
|
|
44
|
+
"pytest>=9.1.1",
|
|
45
|
+
"pytest-cov>=7.1.0",
|
|
46
|
+
"ruff>=0.17.1",
|
|
47
|
+
"ty>=0.0.88",
|
|
48
|
+
]
|
|
49
|
+
|
|
50
|
+
[tool.ruff]
|
|
51
|
+
target-version = "py312"
|
|
52
|
+
line-length = 120
|
|
53
|
+
|
|
54
|
+
[tool.ruff.lint]
|
|
55
|
+
select = [
|
|
56
|
+
"E",
|
|
57
|
+
"W",
|
|
58
|
+
"F",
|
|
59
|
+
"I",
|
|
60
|
+
"Q",
|
|
61
|
+
"UP",
|
|
62
|
+
"C",
|
|
63
|
+
"B",
|
|
64
|
+
"S",
|
|
65
|
+
"ISC",
|
|
66
|
+
"T20",
|
|
67
|
+
"ANN",
|
|
68
|
+
"TC",
|
|
69
|
+
"PT",
|
|
70
|
+
"SIM",
|
|
71
|
+
"RUF",
|
|
72
|
+
]
|
|
73
|
+
ignore = [
|
|
74
|
+
"E501",
|
|
75
|
+
"ANN401",
|
|
76
|
+
]
|
|
77
|
+
|
|
78
|
+
[tool.ruff.lint.per-file-ignores]
|
|
79
|
+
"tests/**" = ["S101"]
|
|
80
|
+
|
|
81
|
+
[tool.pytest.ini_options]
|
|
82
|
+
testpaths = ["tests"]
|
|
83
|
+
addopts = [
|
|
84
|
+
"--strict-markers",
|
|
85
|
+
"--strict-config",
|
|
86
|
+
]
|
|
87
|
+
|
|
88
|
+
[tool.coverage.run]
|
|
89
|
+
branch = true
|
|
90
|
+
source = ["schemmy"]
|
|
91
|
+
|
|
92
|
+
[tool.coverage.report]
|
|
93
|
+
show_missing = true
|
|
94
|
+
skip_covered = true
|
|
95
|
+
|
|
96
|
+
[tool.ty.terminal]
|
|
97
|
+
error-on-warning = true
|
|
98
|
+
|
|
99
|
+
[[tool.ty.overrides]]
|
|
100
|
+
include = ["tests/**"]
|
|
101
|
+
|
|
102
|
+
[tool.ty.overrides.rules]
|
|
103
|
+
invalid-argument-type = "ignore"
|
|
104
|
+
missing-argument = "ignore"
|
|
105
|
+
unknown-argument = "ignore"
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "schemmy"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Validate data with plain type annotations. No dependencies."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
license-files = ["LICENSE"]
|
|
8
|
+
authors = [{ name = "Ruslan Glotov" }]
|
|
9
|
+
requires-python = ">=3.12"
|
|
10
|
+
dependencies = []
|
|
11
|
+
keywords = ["validation", "schema", "type-hints", "annotations", "typing"]
|
|
12
|
+
classifiers = [
|
|
13
|
+
"Development Status :: 3 - Alpha",
|
|
14
|
+
"Intended Audience :: Developers",
|
|
15
|
+
"Operating System :: OS Independent",
|
|
16
|
+
"Programming Language :: Python :: 3",
|
|
17
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
18
|
+
"Programming Language :: Python :: 3.12",
|
|
19
|
+
"Programming Language :: Python :: 3.13",
|
|
20
|
+
"Programming Language :: Python :: 3.14",
|
|
21
|
+
"Topic :: Software Development :: Libraries",
|
|
22
|
+
"Typing :: Typed",
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
[project.urls]
|
|
26
|
+
Repository = "https://github.com/rusih100/schemmy"
|
|
27
|
+
Issues = "https://github.com/rusih100/schemmy/issues"
|
|
28
|
+
|
|
29
|
+
[build-system]
|
|
30
|
+
requires = ["uv_build>=0.13.1,<0.14"]
|
|
31
|
+
build-backend = "uv_build"
|
|
32
|
+
|
|
33
|
+
[dependency-groups]
|
|
34
|
+
dev = [
|
|
35
|
+
"pre-commit>=4.7.0",
|
|
36
|
+
"pytest>=9.1.1",
|
|
37
|
+
"pytest-cov>=7.1.0",
|
|
38
|
+
"ruff>=0.17.1",
|
|
39
|
+
"ty>=0.0.88",
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
[tool.ruff]
|
|
43
|
+
target-version = "py312"
|
|
44
|
+
line-length = 120
|
|
45
|
+
|
|
46
|
+
[tool.ruff.lint]
|
|
47
|
+
select = [
|
|
48
|
+
"E", # pycodestyle errors
|
|
49
|
+
"W", # pycodestyle warnings
|
|
50
|
+
"F", # pyflakes
|
|
51
|
+
"I", # isort
|
|
52
|
+
"Q", # flake8-quotes
|
|
53
|
+
"UP", # pyupgrade
|
|
54
|
+
"C", # flake8-comprehensions + mccabe
|
|
55
|
+
"B", # flake8-bugbear
|
|
56
|
+
"S", # flake8-bandit
|
|
57
|
+
"ISC", # flake8-implicit-str-concat
|
|
58
|
+
"T20", # print and pprint
|
|
59
|
+
"ANN", # flake8-annotations
|
|
60
|
+
"TC", # flake8-type-checking
|
|
61
|
+
"PT", # flake8-pytest-style
|
|
62
|
+
"SIM", # flake8-simplify
|
|
63
|
+
"RUF", # ruff-specific rules
|
|
64
|
+
]
|
|
65
|
+
ignore = [
|
|
66
|
+
"E501", # line length is handled by ruff format
|
|
67
|
+
"ANN401", # Any is allowed: schemas accept arbitrary values
|
|
68
|
+
]
|
|
69
|
+
|
|
70
|
+
[tool.ruff.lint.per-file-ignores]
|
|
71
|
+
"tests/**" = ["S101"]
|
|
72
|
+
|
|
73
|
+
[tool.pytest.ini_options]
|
|
74
|
+
testpaths = ["tests"]
|
|
75
|
+
addopts = ["--strict-markers", "--strict-config"]
|
|
76
|
+
|
|
77
|
+
[tool.coverage.run]
|
|
78
|
+
branch = true
|
|
79
|
+
source = ["schemmy"]
|
|
80
|
+
|
|
81
|
+
[tool.coverage.report]
|
|
82
|
+
show_missing = true
|
|
83
|
+
skip_covered = true
|
|
84
|
+
|
|
85
|
+
[tool.ty.terminal]
|
|
86
|
+
error-on-warning = true
|
|
87
|
+
|
|
88
|
+
# Tests pass invalid data on purpose.
|
|
89
|
+
[[tool.ty.overrides]]
|
|
90
|
+
include = ["tests/**"]
|
|
91
|
+
|
|
92
|
+
[tool.ty.overrides.rules]
|
|
93
|
+
invalid-argument-type = "ignore"
|
|
94
|
+
missing-argument = "ignore"
|
|
95
|
+
unknown-argument = "ignore"
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import cast
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class FieldValidationError(Exception):
|
|
7
|
+
"""
|
|
8
|
+
Validation error of a single schema field.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
def __init__(self, *, field: str, message: str, location: list[str | int]) -> None:
|
|
12
|
+
self.field: str = field
|
|
13
|
+
self.message: str = message
|
|
14
|
+
self.location: list[str | int] = location
|
|
15
|
+
|
|
16
|
+
def __str__(self) -> str:
|
|
17
|
+
return f"({self.field}: {self.message}. {self.location})"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ValidationError(ExceptionGroup[FieldValidationError]):
|
|
21
|
+
"""
|
|
22
|
+
Group of schema validation errors.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
@property
|
|
26
|
+
def exceptions(self) -> tuple[FieldValidationError, ...]:
|
|
27
|
+
"""
|
|
28
|
+
Field validation errors.
|
|
29
|
+
Contains no nested groups.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
return cast("tuple[FieldValidationError, ...]", super().exceptions)
|
|
File without changes
|
|
@@ -0,0 +1,651 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from copy import deepcopy
|
|
4
|
+
from types import NoneType, UnionType
|
|
5
|
+
from typing import (
|
|
6
|
+
TYPE_CHECKING,
|
|
7
|
+
Annotated,
|
|
8
|
+
Any,
|
|
9
|
+
ClassVar,
|
|
10
|
+
Literal,
|
|
11
|
+
Union,
|
|
12
|
+
dataclass_transform,
|
|
13
|
+
get_args,
|
|
14
|
+
get_origin,
|
|
15
|
+
get_type_hints,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
from schemmy.exceptions import FieldValidationError, ValidationError
|
|
19
|
+
|
|
20
|
+
if TYPE_CHECKING:
|
|
21
|
+
from types import EllipsisType
|
|
22
|
+
from typing import Self
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
type FieldName = str
|
|
26
|
+
type FieldValue = Any
|
|
27
|
+
|
|
28
|
+
type FieldsMapping = dict[FieldName, FieldValue]
|
|
29
|
+
type TypeHintsMapping = dict[FieldName, Any]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _type_repr(annotation: Any) -> str:
|
|
33
|
+
"""
|
|
34
|
+
Returns a readable name of an annotation for error messages.
|
|
35
|
+
Unions are written as X | Y.
|
|
36
|
+
Annotated metadata is omitted.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
if annotation is NoneType:
|
|
40
|
+
return "None"
|
|
41
|
+
|
|
42
|
+
if annotation is Ellipsis:
|
|
43
|
+
return "..."
|
|
44
|
+
|
|
45
|
+
origin = get_origin(annotation)
|
|
46
|
+
args = get_args(annotation)
|
|
47
|
+
|
|
48
|
+
if origin is Union or origin is UnionType:
|
|
49
|
+
return " | ".join(_type_repr(arg) for arg in args)
|
|
50
|
+
|
|
51
|
+
if origin is Annotated:
|
|
52
|
+
return _type_repr(args[0])
|
|
53
|
+
|
|
54
|
+
if origin is not None:
|
|
55
|
+
return f"{_type_repr(origin)}[{', '.join(_type_repr(arg) for arg in args)}]"
|
|
56
|
+
|
|
57
|
+
if isinstance(annotation, type):
|
|
58
|
+
return annotation.__name__
|
|
59
|
+
|
|
60
|
+
return repr(annotation).removeprefix("typing.")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _dump(value: Any) -> Any:
|
|
64
|
+
"""
|
|
65
|
+
Converts schemas inside a value to dicts.
|
|
66
|
+
Lists, tuples and dicts are traversed recursively.
|
|
67
|
+
Other values are returned unchanged.
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
if isinstance(value, Schema):
|
|
71
|
+
return value.to_dict()
|
|
72
|
+
|
|
73
|
+
if isinstance(value, list):
|
|
74
|
+
return [_dump(item) for item in value]
|
|
75
|
+
|
|
76
|
+
if isinstance(value, tuple):
|
|
77
|
+
return tuple(_dump(item) for item in value)
|
|
78
|
+
|
|
79
|
+
if isinstance(value, dict):
|
|
80
|
+
return {key: _dump(item) for key, item in value.items()}
|
|
81
|
+
|
|
82
|
+
return value
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@dataclass_transform(kw_only_default=True)
|
|
86
|
+
class Schema:
|
|
87
|
+
"""
|
|
88
|
+
Base class for data schemas described with type annotations.
|
|
89
|
+
Validates and normalizes input data on instantiation.
|
|
90
|
+
Type checkers derive the constructor signature from the field annotations.
|
|
91
|
+
"""
|
|
92
|
+
|
|
93
|
+
_schemmy_fields: ClassVar[tuple[TypeHintsMapping, FieldsMapping]]
|
|
94
|
+
_origin_resolvers: ClassVar[dict[Any, str]] = {
|
|
95
|
+
Annotated: "_resolve_validators",
|
|
96
|
+
Union: "_resolve_union_type",
|
|
97
|
+
UnionType: "_resolve_union_type",
|
|
98
|
+
Literal: "_resolve_literal_type",
|
|
99
|
+
list: "_resolve_list_type",
|
|
100
|
+
tuple: "_resolve_tuple_type",
|
|
101
|
+
dict: "_resolve_dict_type",
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
def __init__(self, **fields: FieldValue) -> None:
|
|
105
|
+
"""
|
|
106
|
+
Validates the fields and sets them as instance attributes.
|
|
107
|
+
|
|
108
|
+
:raise ValidationError: Schema validation failed.
|
|
109
|
+
"""
|
|
110
|
+
|
|
111
|
+
values, errors = type(self)._run_validation(fields)
|
|
112
|
+
|
|
113
|
+
for k, v in values.items():
|
|
114
|
+
setattr(self, k, v)
|
|
115
|
+
|
|
116
|
+
if errors:
|
|
117
|
+
raise ValidationError(f"{type(self).__name__}: validation failed", errors)
|
|
118
|
+
|
|
119
|
+
def __repr__(self) -> str:
|
|
120
|
+
type_hints, _ = type(self)._get_fields()
|
|
121
|
+
fields = ", ".join(f"{name}={getattr(self, name)!r}" for name in type_hints)
|
|
122
|
+
return f"{type(self).__name__}({fields})"
|
|
123
|
+
|
|
124
|
+
def __eq__(self, other: object) -> bool:
|
|
125
|
+
if type(other) is not type(self):
|
|
126
|
+
return NotImplemented
|
|
127
|
+
type_hints, _ = type(self)._get_fields()
|
|
128
|
+
return all(getattr(self, name) == getattr(other, name) for name in type_hints)
|
|
129
|
+
|
|
130
|
+
def to_dict(self) -> dict[str, Any]:
|
|
131
|
+
"""
|
|
132
|
+
Returns field values as a dict in field declaration order.
|
|
133
|
+
Nested schemas are converted to dicts.
|
|
134
|
+
Lists, tuples and dicts are traversed recursively.
|
|
135
|
+
"""
|
|
136
|
+
|
|
137
|
+
type_hints, _ = type(self)._get_fields()
|
|
138
|
+
return {name: _dump(getattr(self, name)) for name in type_hints}
|
|
139
|
+
|
|
140
|
+
@classmethod
|
|
141
|
+
def validate(cls, fields: FieldsMapping) -> Self:
|
|
142
|
+
"""
|
|
143
|
+
Validates a mapping and returns a schema instance.
|
|
144
|
+
|
|
145
|
+
:raise ValidationError: Schema validation failed.
|
|
146
|
+
"""
|
|
147
|
+
|
|
148
|
+
non_str_keys = [key for key in fields if not isinstance(key, str)]
|
|
149
|
+
if non_str_keys:
|
|
150
|
+
raise ValidationError(
|
|
151
|
+
f"{cls.__name__}: validation failed",
|
|
152
|
+
[
|
|
153
|
+
FieldValidationError(
|
|
154
|
+
field=repr(key),
|
|
155
|
+
message="Field name must be a string",
|
|
156
|
+
location=[repr(key)],
|
|
157
|
+
)
|
|
158
|
+
for key in non_str_keys
|
|
159
|
+
],
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
return cls(**fields)
|
|
163
|
+
|
|
164
|
+
@classmethod
|
|
165
|
+
def _get_fields(cls) -> tuple[TypeHintsMapping, FieldsMapping]:
|
|
166
|
+
"""
|
|
167
|
+
Returns field annotations and field default values.
|
|
168
|
+
ClassVar annotations are not fields.
|
|
169
|
+
The result is cached on the schema class.
|
|
170
|
+
|
|
171
|
+
:returns: Field annotations, default values
|
|
172
|
+
"""
|
|
173
|
+
|
|
174
|
+
cached = cls.__dict__.get("_schemmy_fields")
|
|
175
|
+
if cached is not None:
|
|
176
|
+
return cached
|
|
177
|
+
|
|
178
|
+
type_hints: TypeHintsMapping = {
|
|
179
|
+
name: hint
|
|
180
|
+
for name, hint in get_type_hints(cls, include_extras=True).items()
|
|
181
|
+
if hint is not ClassVar and get_origin(hint) is not ClassVar
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
defaults: FieldsMapping = {}
|
|
185
|
+
for klass in reversed(cls.__mro__):
|
|
186
|
+
if klass is Schema or not issubclass(klass, Schema):
|
|
187
|
+
continue
|
|
188
|
+
for name in type_hints:
|
|
189
|
+
if name in klass.__dict__:
|
|
190
|
+
defaults[name] = klass.__dict__[name]
|
|
191
|
+
|
|
192
|
+
cls._schemmy_fields = (type_hints, defaults)
|
|
193
|
+
return cls._schemmy_fields
|
|
194
|
+
|
|
195
|
+
@classmethod
|
|
196
|
+
def _run_validation(cls, fields: FieldsMapping) -> tuple[FieldsMapping, list[FieldValidationError]]:
|
|
197
|
+
"""
|
|
198
|
+
Validates and normalizes input data against the schema annotations.
|
|
199
|
+
Missing and unknown fields are reported first.
|
|
200
|
+
Value errors follow in field declaration order.
|
|
201
|
+
|
|
202
|
+
:returns: Normalized values, validation errors
|
|
203
|
+
"""
|
|
204
|
+
|
|
205
|
+
errors: list[FieldValidationError] = []
|
|
206
|
+
type_hints, defaults = cls._get_fields()
|
|
207
|
+
|
|
208
|
+
errors += cls._validate_missing_fields(fields, type_hints, defaults)
|
|
209
|
+
errors += cls._validate_disallowed_fields(fields, type_hints)
|
|
210
|
+
|
|
211
|
+
validated_fields, fields_errors = cls._validate_allowed_fields(fields, type_hints)
|
|
212
|
+
errors += fields_errors
|
|
213
|
+
|
|
214
|
+
for field, default in defaults.items():
|
|
215
|
+
if field not in fields:
|
|
216
|
+
validated_fields[field] = deepcopy(default)
|
|
217
|
+
|
|
218
|
+
return validated_fields, errors
|
|
219
|
+
|
|
220
|
+
@staticmethod
|
|
221
|
+
def _validate_missing_fields(
|
|
222
|
+
fields: FieldsMapping, type_hints: TypeHintsMapping, defaults: FieldsMapping
|
|
223
|
+
) -> list[FieldValidationError]:
|
|
224
|
+
"""
|
|
225
|
+
Reports required fields absent from the input.
|
|
226
|
+
A field with a default value is not required.
|
|
227
|
+
|
|
228
|
+
:returns: Validation errors
|
|
229
|
+
"""
|
|
230
|
+
|
|
231
|
+
return [
|
|
232
|
+
FieldValidationError(
|
|
233
|
+
field=field,
|
|
234
|
+
message="Field required",
|
|
235
|
+
location=[field],
|
|
236
|
+
)
|
|
237
|
+
for field in type_hints
|
|
238
|
+
if field not in fields and field not in defaults
|
|
239
|
+
]
|
|
240
|
+
|
|
241
|
+
@staticmethod
|
|
242
|
+
def _validate_disallowed_fields(fields: FieldsMapping, type_hints: TypeHintsMapping) -> list[FieldValidationError]:
|
|
243
|
+
"""
|
|
244
|
+
Reports input fields not declared in the schema.
|
|
245
|
+
|
|
246
|
+
:returns: Validation errors
|
|
247
|
+
"""
|
|
248
|
+
|
|
249
|
+
return [
|
|
250
|
+
FieldValidationError(
|
|
251
|
+
field=field,
|
|
252
|
+
message="Unknown field",
|
|
253
|
+
location=[field],
|
|
254
|
+
)
|
|
255
|
+
for field in fields
|
|
256
|
+
if field not in type_hints
|
|
257
|
+
]
|
|
258
|
+
|
|
259
|
+
@classmethod
|
|
260
|
+
def _validate_allowed_fields(
|
|
261
|
+
cls, fields: FieldsMapping, type_hints: TypeHintsMapping
|
|
262
|
+
) -> tuple[FieldsMapping, list[FieldValidationError]]:
|
|
263
|
+
"""
|
|
264
|
+
Validates the values of declared fields present in the input.
|
|
265
|
+
|
|
266
|
+
:returns: Normalized values, validation errors
|
|
267
|
+
"""
|
|
268
|
+
|
|
269
|
+
errors: list[FieldValidationError] = []
|
|
270
|
+
allowed_fields = [field for field in type_hints if field in fields]
|
|
271
|
+
validated_fields: FieldsMapping = {}
|
|
272
|
+
|
|
273
|
+
for field in allowed_fields:
|
|
274
|
+
value = fields[field]
|
|
275
|
+
expected_type = type_hints[field]
|
|
276
|
+
|
|
277
|
+
validated_value, validation_errors = cls._check_field_type(field, value, expected_type)
|
|
278
|
+
|
|
279
|
+
validated_fields[field] = validated_value
|
|
280
|
+
errors += validation_errors
|
|
281
|
+
|
|
282
|
+
return validated_fields, errors
|
|
283
|
+
|
|
284
|
+
@classmethod
|
|
285
|
+
def _check_field_type(
|
|
286
|
+
cls, field: FieldName, value: FieldValue, expected_type: Any
|
|
287
|
+
) -> tuple[FieldValue | EllipsisType, list[FieldValidationError]]:
|
|
288
|
+
"""
|
|
289
|
+
Dispatches a value to the resolver for its annotation.
|
|
290
|
+
Supported annotations are Any, Annotated, unions, Literal, list, tuple, dict, Schema subclasses and plain classes.
|
|
291
|
+
A value that fails validation is replaced with ellipsis.
|
|
292
|
+
|
|
293
|
+
:returns: Normalized value, validation errors
|
|
294
|
+
"""
|
|
295
|
+
|
|
296
|
+
origin = get_origin(expected_type)
|
|
297
|
+
|
|
298
|
+
if expected_type is Any:
|
|
299
|
+
return cls._resolve_any_type(field, value, expected_type)
|
|
300
|
+
|
|
301
|
+
resolver_name = cls._origin_resolvers.get(origin)
|
|
302
|
+
if resolver_name is not None:
|
|
303
|
+
return getattr(cls, resolver_name)(field, value, expected_type)
|
|
304
|
+
|
|
305
|
+
if origin is not None or not isinstance(expected_type, type):
|
|
306
|
+
return cls._resolve_unsupported_type(field, value, expected_type)
|
|
307
|
+
|
|
308
|
+
if issubclass(expected_type, Schema):
|
|
309
|
+
return cls._resolve_schema_type(field, value, expected_type)
|
|
310
|
+
|
|
311
|
+
if expected_type is float and type(value) is int:
|
|
312
|
+
return cls._resolve_int_as_float(field, value)
|
|
313
|
+
|
|
314
|
+
# bool is a subclass of int but is rejected for int fields.
|
|
315
|
+
if not isinstance(value, expected_type) or (expected_type is int and isinstance(value, bool)):
|
|
316
|
+
return cls._resolve_scalar_type(field, value, expected_type)
|
|
317
|
+
|
|
318
|
+
return value, []
|
|
319
|
+
|
|
320
|
+
@classmethod
|
|
321
|
+
def _resolve_any_type(
|
|
322
|
+
cls, field: FieldName, value: FieldValue, expected_type: Any
|
|
323
|
+
) -> tuple[FieldValue | EllipsisType, list[FieldValidationError]]:
|
|
324
|
+
"""
|
|
325
|
+
Resolver for Any.
|
|
326
|
+
The value is returned unchanged.
|
|
327
|
+
No errors are produced.
|
|
328
|
+
|
|
329
|
+
:returns: Normalized value, validation errors
|
|
330
|
+
"""
|
|
331
|
+
|
|
332
|
+
return value, []
|
|
333
|
+
|
|
334
|
+
@classmethod
|
|
335
|
+
def _resolve_int_as_float(
|
|
336
|
+
cls, field: FieldName, value: int
|
|
337
|
+
) -> tuple[FieldValue | EllipsisType, list[FieldValidationError]]:
|
|
338
|
+
"""
|
|
339
|
+
Resolver for an int passed to a float field.
|
|
340
|
+
JSON numbers without a fractional part arrive as int.
|
|
341
|
+
The int is converted only when float represents it exactly.
|
|
342
|
+
bool is not converted.
|
|
343
|
+
|
|
344
|
+
:returns: Normalized value, validation errors
|
|
345
|
+
"""
|
|
346
|
+
|
|
347
|
+
try:
|
|
348
|
+
converted = float(value)
|
|
349
|
+
except OverflowError:
|
|
350
|
+
converted = None
|
|
351
|
+
|
|
352
|
+
if converted is None or converted != value:
|
|
353
|
+
return ..., [
|
|
354
|
+
FieldValidationError(
|
|
355
|
+
field=field,
|
|
356
|
+
message=f"Expected float, got int {value} that float cannot represent exactly",
|
|
357
|
+
location=[field],
|
|
358
|
+
)
|
|
359
|
+
]
|
|
360
|
+
|
|
361
|
+
return converted, []
|
|
362
|
+
|
|
363
|
+
@classmethod
|
|
364
|
+
def _resolve_union_type(
|
|
365
|
+
cls, field: FieldName, value: FieldValue, expected_type: Any
|
|
366
|
+
) -> tuple[FieldValue | EllipsisType, list[FieldValidationError]]:
|
|
367
|
+
"""
|
|
368
|
+
Resolver for unions such as X | Y and Optional[X].
|
|
369
|
+
None members are skipped for a value other than None.
|
|
370
|
+
A single remaining member validates the value directly and reports its own errors.
|
|
371
|
+
Otherwise members are tried in declaration order and the first match wins.
|
|
372
|
+
|
|
373
|
+
:returns: Normalized value, validation errors
|
|
374
|
+
"""
|
|
375
|
+
|
|
376
|
+
members = [member for member in get_args(expected_type) if value is None or member is not NoneType]
|
|
377
|
+
|
|
378
|
+
if len(members) == 1:
|
|
379
|
+
return cls._check_field_type(field, value, members[0])
|
|
380
|
+
|
|
381
|
+
for member in members:
|
|
382
|
+
validated_value, errors = cls._check_field_type(field, value, member)
|
|
383
|
+
if not errors:
|
|
384
|
+
return validated_value, []
|
|
385
|
+
|
|
386
|
+
return ..., [
|
|
387
|
+
FieldValidationError(
|
|
388
|
+
field=field,
|
|
389
|
+
message=f"Expected {_type_repr(expected_type)}, got {type(value).__name__}",
|
|
390
|
+
location=[field],
|
|
391
|
+
)
|
|
392
|
+
]
|
|
393
|
+
|
|
394
|
+
@classmethod
|
|
395
|
+
def _resolve_literal_type(
|
|
396
|
+
cls, field: FieldName, value: FieldValue, expected_type: Any
|
|
397
|
+
) -> tuple[FieldValue | EllipsisType, list[FieldValidationError]]:
|
|
398
|
+
"""
|
|
399
|
+
Resolver for Literal[...].
|
|
400
|
+
The value must equal one of the literals and have the same type.
|
|
401
|
+
The type check keeps True from matching Literal[1].
|
|
402
|
+
|
|
403
|
+
:returns: Normalized value, validation errors
|
|
404
|
+
"""
|
|
405
|
+
|
|
406
|
+
for literal in get_args(expected_type):
|
|
407
|
+
if type(value) is type(literal) and value == literal:
|
|
408
|
+
return value, []
|
|
409
|
+
|
|
410
|
+
return ..., [
|
|
411
|
+
FieldValidationError(
|
|
412
|
+
field=field,
|
|
413
|
+
message=f"Expected {_type_repr(expected_type)}, got {value!r}",
|
|
414
|
+
location=[field],
|
|
415
|
+
)
|
|
416
|
+
]
|
|
417
|
+
|
|
418
|
+
@classmethod
|
|
419
|
+
def _resolve_list_type(
|
|
420
|
+
cls, field: FieldName, value: FieldValue, expected_type: Any
|
|
421
|
+
) -> tuple[FieldValue | EllipsisType, list[FieldValidationError]]:
|
|
422
|
+
"""
|
|
423
|
+
Resolver for list[T].
|
|
424
|
+
The value must be a list.
|
|
425
|
+
Each item is validated against T.
|
|
426
|
+
|
|
427
|
+
:returns: Normalized value, validation errors
|
|
428
|
+
"""
|
|
429
|
+
|
|
430
|
+
if not isinstance(value, list):
|
|
431
|
+
return cls._resolve_scalar_type(field, value, expected_type)
|
|
432
|
+
|
|
433
|
+
item_type = get_args(expected_type)[0]
|
|
434
|
+
return cls._validate_items(field, value, [item_type] * len(value))
|
|
435
|
+
|
|
436
|
+
@classmethod
|
|
437
|
+
def _resolve_tuple_type(
|
|
438
|
+
cls, field: FieldName, value: FieldValue, expected_type: Any
|
|
439
|
+
) -> tuple[FieldValue | EllipsisType, list[FieldValidationError]]:
|
|
440
|
+
"""
|
|
441
|
+
Resolver for tuple[T, ...] and tuple[T1, T2, ...].
|
|
442
|
+
The value must be a tuple or a list.
|
|
443
|
+
A list is converted to a tuple, because JSON has no tuples.
|
|
444
|
+
tuple[T, ...] validates each item against T.
|
|
445
|
+
tuple[T1, T2, ...] requires the exact number of items.
|
|
446
|
+
|
|
447
|
+
:returns: Normalized value, validation errors
|
|
448
|
+
"""
|
|
449
|
+
|
|
450
|
+
if not isinstance(value, tuple | list):
|
|
451
|
+
return cls._resolve_scalar_type(field, value, expected_type)
|
|
452
|
+
|
|
453
|
+
args = get_args(expected_type)
|
|
454
|
+
|
|
455
|
+
if len(args) == 2 and args[1] is Ellipsis:
|
|
456
|
+
item_types = [args[0]] * len(value)
|
|
457
|
+
elif len(args) == len(value):
|
|
458
|
+
item_types = list(args)
|
|
459
|
+
else:
|
|
460
|
+
return ..., [
|
|
461
|
+
FieldValidationError(
|
|
462
|
+
field=field,
|
|
463
|
+
message=f"Expected {_type_repr(expected_type)} with {len(args)} items, got {len(value)}",
|
|
464
|
+
location=[field],
|
|
465
|
+
)
|
|
466
|
+
]
|
|
467
|
+
|
|
468
|
+
validated_items, errors = cls._validate_items(field, value, item_types)
|
|
469
|
+
if errors:
|
|
470
|
+
return ..., errors
|
|
471
|
+
|
|
472
|
+
return tuple(validated_items), []
|
|
473
|
+
|
|
474
|
+
@classmethod
|
|
475
|
+
def _resolve_dict_type(
|
|
476
|
+
cls, field: FieldName, value: FieldValue, expected_type: Any
|
|
477
|
+
) -> tuple[FieldValue | EllipsisType, list[FieldValidationError]]:
|
|
478
|
+
"""
|
|
479
|
+
Resolver for dict[K, V].
|
|
480
|
+
The value must be a dict.
|
|
481
|
+
Each key is validated against K and each value against V.
|
|
482
|
+
The key is added to the location of key and value errors.
|
|
483
|
+
Key error messages start with "Invalid key".
|
|
484
|
+
|
|
485
|
+
:returns: Normalized value, validation errors
|
|
486
|
+
"""
|
|
487
|
+
|
|
488
|
+
if not isinstance(value, dict):
|
|
489
|
+
return cls._resolve_scalar_type(field, value, expected_type)
|
|
490
|
+
|
|
491
|
+
key_type, value_type = get_args(expected_type)
|
|
492
|
+
validated_dict: dict[Any, Any] = {}
|
|
493
|
+
errors: list[FieldValidationError] = []
|
|
494
|
+
|
|
495
|
+
for key, item in value.items():
|
|
496
|
+
location_key = key if isinstance(key, str | int) else repr(key)
|
|
497
|
+
|
|
498
|
+
validated_key, key_errors = cls._check_field_type(field, key, key_type)
|
|
499
|
+
for error in key_errors:
|
|
500
|
+
error.message = f"Invalid key: {error.message}"
|
|
501
|
+
error.location.insert(1, location_key)
|
|
502
|
+
errors.extend(key_errors)
|
|
503
|
+
|
|
504
|
+
validated_item, item_errors = cls._check_field_type(field, item, value_type)
|
|
505
|
+
for error in item_errors:
|
|
506
|
+
error.location.insert(1, location_key)
|
|
507
|
+
errors.extend(item_errors)
|
|
508
|
+
|
|
509
|
+
if not key_errors and not item_errors:
|
|
510
|
+
validated_dict[validated_key] = validated_item
|
|
511
|
+
|
|
512
|
+
if errors:
|
|
513
|
+
return ..., errors
|
|
514
|
+
|
|
515
|
+
return validated_dict, []
|
|
516
|
+
|
|
517
|
+
@classmethod
|
|
518
|
+
def _validate_items(
|
|
519
|
+
cls, field: FieldName, items: list[Any] | tuple[Any, ...], item_types: list[Any]
|
|
520
|
+
) -> tuple[list[Any], list[FieldValidationError]]:
|
|
521
|
+
"""
|
|
522
|
+
Validates items of a sequence against their types.
|
|
523
|
+
The item index is added to the location of item errors.
|
|
524
|
+
A failed item is replaced with ellipsis.
|
|
525
|
+
|
|
526
|
+
:returns: Normalized items, validation errors
|
|
527
|
+
"""
|
|
528
|
+
|
|
529
|
+
validated_items: list[Any] = []
|
|
530
|
+
errors: list[FieldValidationError] = []
|
|
531
|
+
|
|
532
|
+
for index, (item, item_type) in enumerate(zip(items, item_types, strict=True)):
|
|
533
|
+
validated_item, item_errors = cls._check_field_type(field, item, item_type)
|
|
534
|
+
for error in item_errors:
|
|
535
|
+
error.location.insert(1, index)
|
|
536
|
+
errors.extend(item_errors)
|
|
537
|
+
validated_items.append(... if item_errors else validated_item)
|
|
538
|
+
|
|
539
|
+
return validated_items, errors
|
|
540
|
+
|
|
541
|
+
@classmethod
|
|
542
|
+
def _resolve_unsupported_type(
|
|
543
|
+
cls, field: FieldName, value: FieldValue, expected_type: Any
|
|
544
|
+
) -> tuple[FieldValue | EllipsisType, list[FieldValidationError]]:
|
|
545
|
+
"""
|
|
546
|
+
Resolver for annotations without support, such as set and TypedDict.
|
|
547
|
+
Always produces a single error.
|
|
548
|
+
|
|
549
|
+
:returns: Normalized value, validation errors
|
|
550
|
+
"""
|
|
551
|
+
|
|
552
|
+
return ..., [
|
|
553
|
+
FieldValidationError(
|
|
554
|
+
field=field,
|
|
555
|
+
message=f"Unsupported type: {_type_repr(expected_type)}",
|
|
556
|
+
location=[field],
|
|
557
|
+
)
|
|
558
|
+
]
|
|
559
|
+
|
|
560
|
+
@classmethod
|
|
561
|
+
def _resolve_schema_type(
|
|
562
|
+
cls, field: FieldName, value: FieldValue, expected_type: Any
|
|
563
|
+
) -> tuple[FieldValue | EllipsisType, list[FieldValidationError]]:
|
|
564
|
+
"""
|
|
565
|
+
Resolver for nested Schema subclasses.
|
|
566
|
+
An instance of the expected schema is returned unchanged.
|
|
567
|
+
A dict is validated through expected_type.validate.
|
|
568
|
+
The current field is prepended to the location of nested errors.
|
|
569
|
+
|
|
570
|
+
:returns: Normalized value, validation errors
|
|
571
|
+
"""
|
|
572
|
+
|
|
573
|
+
if isinstance(value, expected_type):
|
|
574
|
+
return value, []
|
|
575
|
+
|
|
576
|
+
if isinstance(value, dict):
|
|
577
|
+
try:
|
|
578
|
+
return expected_type.validate(value), []
|
|
579
|
+
except ValidationError as exc:
|
|
580
|
+
return ..., [
|
|
581
|
+
FieldValidationError(
|
|
582
|
+
field=error.field,
|
|
583
|
+
message=error.message,
|
|
584
|
+
location=[field, *error.location],
|
|
585
|
+
)
|
|
586
|
+
for error in exc.exceptions
|
|
587
|
+
]
|
|
588
|
+
|
|
589
|
+
return ..., [
|
|
590
|
+
FieldValidationError(
|
|
591
|
+
field=field,
|
|
592
|
+
message=f"Expected dict or {expected_type.__name__}, got {type(value).__name__}",
|
|
593
|
+
location=[field],
|
|
594
|
+
)
|
|
595
|
+
]
|
|
596
|
+
|
|
597
|
+
@classmethod
|
|
598
|
+
def _resolve_scalar_type(
|
|
599
|
+
cls, field: FieldName, value: FieldValue, expected_type: Any
|
|
600
|
+
) -> tuple[FieldValue | EllipsisType, list[FieldValidationError]]:
|
|
601
|
+
"""
|
|
602
|
+
Resolver for a value that does not match a plain class annotation.
|
|
603
|
+
Always produces a single error with the expected and actual types.
|
|
604
|
+
|
|
605
|
+
:returns: Normalized value, validation errors
|
|
606
|
+
"""
|
|
607
|
+
|
|
608
|
+
return ..., [
|
|
609
|
+
FieldValidationError(
|
|
610
|
+
field=field,
|
|
611
|
+
message=f"Expected {_type_repr(expected_type)}, got {type(value).__name__}",
|
|
612
|
+
location=[field],
|
|
613
|
+
)
|
|
614
|
+
]
|
|
615
|
+
|
|
616
|
+
@classmethod
|
|
617
|
+
def _resolve_validators(
|
|
618
|
+
cls, field: FieldName, value: FieldValue, expected_type: Any
|
|
619
|
+
) -> tuple[FieldValue | EllipsisType, list[FieldValidationError]]:
|
|
620
|
+
"""
|
|
621
|
+
Resolver for Annotated[T, *validators].
|
|
622
|
+
The value is checked against T first.
|
|
623
|
+
Validators run in order, each receives the result of the previous one.
|
|
624
|
+
|
|
625
|
+
:returns: Normalized value, validation errors
|
|
626
|
+
"""
|
|
627
|
+
|
|
628
|
+
base_type, *validators = get_args(expected_type)
|
|
629
|
+
value, errors = cls._check_field_type(field, value, base_type)
|
|
630
|
+
|
|
631
|
+
if errors:
|
|
632
|
+
return ..., errors
|
|
633
|
+
|
|
634
|
+
validator_errors: list[FieldValidationError] = []
|
|
635
|
+
for validator in validators:
|
|
636
|
+
try:
|
|
637
|
+
value = validator(value)
|
|
638
|
+
except Exception as exc:
|
|
639
|
+
validator_name = getattr(validator, "__name__", type(validator).__name__)
|
|
640
|
+
validator_errors.append(
|
|
641
|
+
FieldValidationError(
|
|
642
|
+
field=field,
|
|
643
|
+
message=f"Validator {validator_name} failed: {exc}",
|
|
644
|
+
location=[field],
|
|
645
|
+
)
|
|
646
|
+
)
|
|
647
|
+
|
|
648
|
+
if validator_errors:
|
|
649
|
+
return ..., validator_errors
|
|
650
|
+
|
|
651
|
+
return value, []
|