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 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
@@ -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,4 @@
1
+ from schemmy.exceptions import FieldValidationError, ValidationError
2
+ from schemmy.schema import Schema
3
+
4
+ __all__ = ["FieldValidationError", "Schema", "ValidationError"]
@@ -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, []