statica 1.0.2__tar.gz → 1.2.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.
- {statica-1.0.2/statica.egg-info → statica-1.2.0}/PKG-INFO +5 -5
- {statica-1.0.2 → statica-1.2.0}/README.md +3 -3
- {statica-1.0.2 → statica-1.2.0}/pyproject.toml +6 -10
- {statica-1.0.2 → statica-1.2.0}/statica/core.py +76 -140
- statica-1.2.0/statica/validators.py +69 -0
- {statica-1.0.2 → statica-1.2.0/statica.egg-info}/PKG-INFO +5 -5
- {statica-1.0.2 → statica-1.2.0}/statica.egg-info/SOURCES.txt +4 -1
- statica-1.2.0/tests/test_aliasing.py +163 -0
- {statica-1.0.2 → statica-1.2.0}/tests/test_statica.py +1 -62
- statica-1.2.0/tests/test_validators.py +24 -0
- {statica-1.0.2 → statica-1.2.0}/LICENSE +0 -0
- {statica-1.0.2 → statica-1.2.0}/setup.cfg +0 -0
- {statica-1.0.2 → statica-1.2.0}/statica/__init__.py +0 -0
- {statica-1.0.2 → statica-1.2.0}/statica/exceptions.py +0 -0
- {statica-1.0.2 → statica-1.2.0}/statica.egg-info/dependency_links.txt +0 -0
- {statica-1.0.2 → statica-1.2.0}/statica.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: statica
|
|
3
|
-
Version: 1.0
|
|
3
|
+
Version: 1.2.0
|
|
4
4
|
Summary: A minimalistic data validation library
|
|
5
5
|
Author-email: Marcel Kröker <kroeker.marcel@gmail.com>
|
|
6
6
|
License: MIT License
|
|
@@ -31,7 +31,7 @@ Classifier: Operating System :: OS Independent
|
|
|
31
31
|
Classifier: Intended Audience :: Developers
|
|
32
32
|
Classifier: Programming Language :: Python
|
|
33
33
|
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
34
|
-
Requires-Python: <3.14,>=3.
|
|
34
|
+
Requires-Python: <3.14,>=3.10
|
|
35
35
|
Description-Content-Type: text/markdown
|
|
36
36
|
|
|
37
37
|
# Statica
|
|
@@ -44,13 +44,13 @@ Statica is a Python library for defining and validating structured data with typ
|
|
|
44
44
|
|
|
45
45
|
## Why Statica?
|
|
46
46
|
|
|
47
|
-
Statica was created to address the need for a lightweight, flexible, and dependency-free alternative to libraries like pydantic.
|
|
47
|
+
Statica was created to address the need for a lightweight, flexible, and dependency-free alternative to libraries like pydantic.
|
|
48
48
|
While pydantic is a powerful tool for data validation and parsing, Statica offers some distinct advantages in specific situations:
|
|
49
49
|
|
|
50
50
|
1. **Lightweight**: Statica does not rely on any third-party dependencies, making it ideal for projects where minimizing external dependencies is a priority.
|
|
51
|
-
2. **
|
|
51
|
+
2. **Performance**: For use cases where performance is critical. Pydantic needs `3x` more memory than Statica for the same models.
|
|
52
52
|
3. **Ease of Use**: With its simple, Pythonic design, Statica is intuitive for developers already familiar with Python's `dataclasses` and type hinting. It avoids much of the magic that pydantic employs.
|
|
53
|
-
4. **
|
|
53
|
+
4. **Customizable Validation**: Statica allows fine-grained control over type and constraint validation through customizable field descriptors (`Field`) and error classes.
|
|
54
54
|
|
|
55
55
|
## Features
|
|
56
56
|
|
|
@@ -8,13 +8,13 @@ Statica is a Python library for defining and validating structured data with typ
|
|
|
8
8
|
|
|
9
9
|
## Why Statica?
|
|
10
10
|
|
|
11
|
-
Statica was created to address the need for a lightweight, flexible, and dependency-free alternative to libraries like pydantic.
|
|
11
|
+
Statica was created to address the need for a lightweight, flexible, and dependency-free alternative to libraries like pydantic.
|
|
12
12
|
While pydantic is a powerful tool for data validation and parsing, Statica offers some distinct advantages in specific situations:
|
|
13
13
|
|
|
14
14
|
1. **Lightweight**: Statica does not rely on any third-party dependencies, making it ideal for projects where minimizing external dependencies is a priority.
|
|
15
|
-
2. **
|
|
15
|
+
2. **Performance**: For use cases where performance is critical. Pydantic needs `3x` more memory than Statica for the same models.
|
|
16
16
|
3. **Ease of Use**: With its simple, Pythonic design, Statica is intuitive for developers already familiar with Python's `dataclasses` and type hinting. It avoids much of the magic that pydantic employs.
|
|
17
|
-
4. **
|
|
17
|
+
4. **Customizable Validation**: Statica allows fine-grained control over type and constraint validation through customizable field descriptors (`Field`) and error classes.
|
|
18
18
|
|
|
19
19
|
## Features
|
|
20
20
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "statica"
|
|
3
|
-
version = "1.0
|
|
3
|
+
version = "1.2.0"
|
|
4
4
|
description = "A minimalistic data validation library"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
authors = [{ name = "Marcel Kröker", email = "kroeker.marcel@gmail.com" }]
|
|
@@ -13,7 +13,7 @@ classifiers=[
|
|
|
13
13
|
"Programming Language :: Python",
|
|
14
14
|
"Topic :: Software Development :: Libraries :: Python Modules"
|
|
15
15
|
]
|
|
16
|
-
requires-python = ">=3.
|
|
16
|
+
requires-python = ">=3.10, <3.14"
|
|
17
17
|
|
|
18
18
|
|
|
19
19
|
########################################################################################
|
|
@@ -26,6 +26,7 @@ dependencies = []
|
|
|
26
26
|
[dependency-groups]
|
|
27
27
|
dev = [
|
|
28
28
|
"mypy ~= 1.16.0",
|
|
29
|
+
"pyinstrument>=5.0.2",
|
|
29
30
|
"pytest-cov ~= 6.1.0",
|
|
30
31
|
"ruff ~= 0.11.0",
|
|
31
32
|
]
|
|
@@ -59,18 +60,10 @@ ignore = [
|
|
|
59
60
|
"ANN202", # Missing return type annotation for private function
|
|
60
61
|
"ANN401", # Dynamically typed expressions (typing.Any) are disallowed
|
|
61
62
|
"D", # docstrings
|
|
62
|
-
"E501", # line length
|
|
63
63
|
"ERA001", # Found commented-out code
|
|
64
|
-
"FBT001", # Boolean-typed positional argument in function definition
|
|
65
|
-
"FIX002", # Line contains TODO, consider resolving the issue
|
|
66
64
|
"INP001", # File is part of an implicit namespace package. Add an `__init__.py`.
|
|
67
65
|
"S101", # Use of `assert` detected
|
|
68
66
|
"T201", # `print` found
|
|
69
|
-
"TD002", # Missing author in TODO; try: `# TODO(<author_name>): ...`
|
|
70
|
-
"TD003", # Missing issue link on the line following this TODO
|
|
71
|
-
"UP006", # use Dict (not possible in python 3.8)
|
|
72
|
-
"UP007", # use X | Y for union (not possible in python 3.8)
|
|
73
|
-
"UP035", # use typing Dict and List (not possible in python 3.8)
|
|
74
67
|
"W191", # indentation contains tabs
|
|
75
68
|
"PLR0913", # Too many arguments in function definition (6 > 5)
|
|
76
69
|
]
|
|
@@ -85,6 +78,9 @@ quote-style = "double"
|
|
|
85
78
|
#### MARK: Testing
|
|
86
79
|
|
|
87
80
|
[tool.coverage.report]
|
|
81
|
+
omit = [
|
|
82
|
+
"benchmark_*.py",
|
|
83
|
+
]
|
|
88
84
|
exclude_also = [
|
|
89
85
|
"if TYPE_CHECKING:",
|
|
90
86
|
'if __name__ == "__main__":',
|
|
@@ -2,7 +2,6 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
from dataclasses import dataclass
|
|
4
4
|
from dataclasses import field as dataclass_field
|
|
5
|
-
from types import UnionType
|
|
6
5
|
from typing import (
|
|
7
6
|
TYPE_CHECKING,
|
|
8
7
|
Any,
|
|
@@ -11,13 +10,12 @@ from typing import (
|
|
|
11
10
|
TypeVar,
|
|
12
11
|
cast,
|
|
13
12
|
dataclass_transform,
|
|
14
|
-
get_args,
|
|
15
|
-
get_origin,
|
|
16
13
|
get_type_hints,
|
|
17
14
|
overload,
|
|
18
15
|
)
|
|
19
16
|
|
|
20
17
|
from statica.exceptions import ConstraintValidationError, TypeValidationError
|
|
18
|
+
from statica.validators import validate_constraints, validate_type
|
|
21
19
|
|
|
22
20
|
if TYPE_CHECKING:
|
|
23
21
|
from collections.abc import Callable, Mapping
|
|
@@ -25,95 +23,6 @@ if TYPE_CHECKING:
|
|
|
25
23
|
T = TypeVar("T")
|
|
26
24
|
|
|
27
25
|
|
|
28
|
-
########################################################################################
|
|
29
|
-
#### MARK: Internal functions
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
def _type_allows_none(expected_type: Any) -> bool:
|
|
33
|
-
"""
|
|
34
|
-
Check if the expected type allows None.
|
|
35
|
-
|
|
36
|
-
Examples:
|
|
37
|
-
.. code-block:: python
|
|
38
|
-
_allows_none(int | None) # True
|
|
39
|
-
_allows_none(int) # False
|
|
40
|
-
"""
|
|
41
|
-
if isinstance(expected_type, UnionType):
|
|
42
|
-
return type(None) in get_args(expected_type)
|
|
43
|
-
return expected_type is type(None) or expected_type is Any
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
def _value_matches_type(value: Any | None, expected_type: Any) -> bool:
|
|
47
|
-
"""
|
|
48
|
-
Check if the value matches the expected type.
|
|
49
|
-
Handles basic types, Union types, and generic types.
|
|
50
|
-
|
|
51
|
-
Examples:
|
|
52
|
-
.. code-block:: python
|
|
53
|
-
_value_matches_type(1, int) # True
|
|
54
|
-
_value_matches_type(None, int | None) # True
|
|
55
|
-
_value_matches_type(None, int) # False
|
|
56
|
-
"""
|
|
57
|
-
# If expected_type is e.g. int | None, pass if value is None
|
|
58
|
-
if _type_allows_none(expected_type) and value is None:
|
|
59
|
-
return True
|
|
60
|
-
|
|
61
|
-
# Basic types like int, str, etc.
|
|
62
|
-
if (origin := get_origin(expected_type)) is None:
|
|
63
|
-
return isinstance(value, expected_type)
|
|
64
|
-
|
|
65
|
-
# Handle Union types
|
|
66
|
-
if origin is UnionType or origin is type(None) or origin is Any:
|
|
67
|
-
types = get_args(expected_type)
|
|
68
|
-
return any(_value_matches_type(value, t) for t in types if t is not type(None))
|
|
69
|
-
|
|
70
|
-
# Handle generic types
|
|
71
|
-
return isinstance(value, origin)
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
def _validate_type(value: Any, expected_type: type | UnionType) -> None:
|
|
75
|
-
"""
|
|
76
|
-
Validate that the value matches the expected type.
|
|
77
|
-
Throws TypeValidationError if the type does not match.
|
|
78
|
-
|
|
79
|
-
Examples:
|
|
80
|
-
.. code-block:: python
|
|
81
|
-
_validate_type(1, int) # No exception
|
|
82
|
-
_validate_type("abc", str) # No exception
|
|
83
|
-
_validate_type(1, str) # Raises TypeValidationError
|
|
84
|
-
_validate_type(None, int | None) # No exception
|
|
85
|
-
_validate_type(None, int) # Raises TypeValidationError
|
|
86
|
-
"""
|
|
87
|
-
if not _value_matches_type(value, expected_type):
|
|
88
|
-
expected_type_str = str(expected_type) if type(expected_type) is UnionType else expected_type.__name__
|
|
89
|
-
|
|
90
|
-
msg = f"expected type '{expected_type_str}', got '{type(value).__name__}'"
|
|
91
|
-
raise TypeValidationError(msg)
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
def _get_expected_type(cls: type, attr_name: str) -> Any:
|
|
95
|
-
"""
|
|
96
|
-
Get the expected type for a class attribute.
|
|
97
|
-
Handles type hints and Field descriptors.
|
|
98
|
-
|
|
99
|
-
Examples:
|
|
100
|
-
.. code-block:: python
|
|
101
|
-
class MyClass(Statica):
|
|
102
|
-
age: int | None
|
|
103
|
-
name: str = Field()
|
|
104
|
-
|
|
105
|
-
_get_expected_type(MyClass, "age") # int | None
|
|
106
|
-
_get_expected_type(MyClass, "name") # str
|
|
107
|
-
"""
|
|
108
|
-
|
|
109
|
-
expected_type = get_type_hints(cls).get(attr_name, Any)
|
|
110
|
-
|
|
111
|
-
if get_origin(expected_type) is FieldDescriptor:
|
|
112
|
-
return get_args(expected_type)[0] # Get type from generic
|
|
113
|
-
|
|
114
|
-
return expected_type
|
|
115
|
-
|
|
116
|
-
|
|
117
26
|
########################################################################################
|
|
118
27
|
#### MARK: Field descriptor
|
|
119
28
|
|
|
@@ -135,10 +44,14 @@ class FieldDescriptor(Generic[T]):
|
|
|
135
44
|
strip_whitespace: bool | None = None
|
|
136
45
|
cast_to: Callable[..., T] | None = None
|
|
137
46
|
|
|
47
|
+
alias: str | None = None
|
|
48
|
+
alias_for_parsing: str | None = None
|
|
49
|
+
alias_for_serialization: str | None = None
|
|
50
|
+
|
|
138
51
|
def __set_name__(self, owner: Any, name: str) -> None:
|
|
139
52
|
self.name = name
|
|
140
53
|
self.owner = owner
|
|
141
|
-
self.expected_type =
|
|
54
|
+
self.expected_type = owner.__annotations__.get(name)
|
|
142
55
|
|
|
143
56
|
@overload
|
|
144
57
|
def __get__(self, instance: None, owner: Any) -> FieldDescriptor[T]: ...
|
|
@@ -159,45 +72,55 @@ class FieldDescriptor(Generic[T]):
|
|
|
159
72
|
if self.cast_to is not None:
|
|
160
73
|
value = self.cast_to(value)
|
|
161
74
|
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
if
|
|
165
|
-
|
|
75
|
+
validate_type(value, self.expected_type)
|
|
76
|
+
|
|
77
|
+
if (
|
|
78
|
+
self.min_length is not None
|
|
79
|
+
or self.max_length is not None
|
|
80
|
+
or self.min_value is not None
|
|
81
|
+
or self.max_value is not None
|
|
82
|
+
or self.strip_whitespace is not None
|
|
83
|
+
):
|
|
84
|
+
value = validate_constraints(
|
|
85
|
+
value,
|
|
86
|
+
min_length=self.min_length,
|
|
87
|
+
max_length=self.max_length,
|
|
88
|
+
min_value=self.min_value,
|
|
89
|
+
max_value=self.max_value,
|
|
90
|
+
strip_whitespace=self.strip_whitespace,
|
|
91
|
+
)
|
|
166
92
|
|
|
167
93
|
except TypeValidationError as e:
|
|
168
94
|
msg = f"{self.name}: {e!s}"
|
|
169
95
|
error_class = getattr(self.owner, "type_error_class", TypeValidationError)
|
|
170
96
|
raise error_class(msg) from e
|
|
97
|
+
|
|
171
98
|
except ConstraintValidationError as e:
|
|
172
99
|
msg = f"{self.name}: {e!s}"
|
|
173
100
|
error_class = getattr(self.owner, "constraint_error_class", ConstraintValidationError)
|
|
174
101
|
raise error_class(msg) from e
|
|
102
|
+
|
|
175
103
|
except ValueError as e:
|
|
176
104
|
msg = f"{self.name}: {e!s}"
|
|
177
105
|
raise TypeValidationError(msg) from e
|
|
106
|
+
|
|
178
107
|
return value
|
|
179
108
|
|
|
180
|
-
def _validate_constraints(self, value: Any) -> Any:
|
|
181
|
-
if self.strip_whitespace and isinstance(value, str):
|
|
182
|
-
value = value.strip()
|
|
183
|
-
|
|
184
|
-
if isinstance(value, str | list | tuple | dict):
|
|
185
|
-
if self.min_length is not None and len(value) < self.min_length:
|
|
186
|
-
msg = f"length must be at least {self.min_length}"
|
|
187
|
-
raise ConstraintValidationError(msg)
|
|
188
|
-
if self.max_length is not None and len(value) > self.max_length:
|
|
189
|
-
msg = f"length must be at most {self.max_length}"
|
|
190
|
-
raise ConstraintValidationError(msg)
|
|
191
|
-
|
|
192
|
-
if isinstance(value, int | float):
|
|
193
|
-
if self.min_value is not None and value < self.min_value:
|
|
194
|
-
msg = f"must be at least {self.min_value}"
|
|
195
|
-
raise ConstraintValidationError(msg)
|
|
196
|
-
if self.max_value is not None and value > self.max_value:
|
|
197
|
-
msg = f"must be at most {self.max_value}"
|
|
198
|
-
raise ConstraintValidationError(msg)
|
|
199
109
|
|
|
200
|
-
|
|
110
|
+
def get_field_descriptors(cls: type[Statica]) -> list[FieldDescriptor]:
|
|
111
|
+
"""
|
|
112
|
+
Get all Field descriptors for a class.
|
|
113
|
+
Returns a list of FieldDescriptor instances.
|
|
114
|
+
"""
|
|
115
|
+
|
|
116
|
+
descriptors = []
|
|
117
|
+
|
|
118
|
+
for field_name in cls.__annotations__:
|
|
119
|
+
field_descriptor = getattr(cls, field_name)
|
|
120
|
+
assert isinstance(field_descriptor, FieldDescriptor)
|
|
121
|
+
descriptors.append(field_descriptor)
|
|
122
|
+
|
|
123
|
+
return descriptors
|
|
201
124
|
|
|
202
125
|
|
|
203
126
|
########################################################################################
|
|
@@ -212,6 +135,9 @@ def Field( # noqa: N802
|
|
|
212
135
|
max_value: float | None = None,
|
|
213
136
|
strip_whitespace: bool | None = None,
|
|
214
137
|
cast_to: Callable[..., T] | None = None,
|
|
138
|
+
alias: str | None = None,
|
|
139
|
+
alias_for_parsing: str | None = None,
|
|
140
|
+
alias_for_serialization: str | None = None,
|
|
215
141
|
) -> Any:
|
|
216
142
|
"""
|
|
217
143
|
Type-safe field function that returns the correct type for type checkers
|
|
@@ -225,6 +151,9 @@ def Field( # noqa: N802
|
|
|
225
151
|
max_value=max_value,
|
|
226
152
|
strip_whitespace=strip_whitespace,
|
|
227
153
|
cast_to=cast_to,
|
|
154
|
+
alias=alias,
|
|
155
|
+
alias_for_parsing=alias_for_parsing,
|
|
156
|
+
alias_for_serialization=alias_for_serialization,
|
|
228
157
|
)
|
|
229
158
|
|
|
230
159
|
if TYPE_CHECKING:
|
|
@@ -248,8 +177,6 @@ class StaticaMeta(type):
|
|
|
248
177
|
already, but only for subclasses of Statica.
|
|
249
178
|
"""
|
|
250
179
|
|
|
251
|
-
print(f"__new__({name=}, {bases=}, {namespace=})")
|
|
252
|
-
|
|
253
180
|
if name == "Statica":
|
|
254
181
|
return super().__new__(cls, name, bases, namespace)
|
|
255
182
|
|
|
@@ -257,20 +184,15 @@ class StaticaMeta(type):
|
|
|
257
184
|
|
|
258
185
|
# Generate custom __init__ method
|
|
259
186
|
|
|
260
|
-
def
|
|
187
|
+
def statica_init(self: Statica, **kwargs: Any) -> None:
|
|
261
188
|
for field_name, field_type in annotations.items():
|
|
262
|
-
|
|
263
|
-
if get_origin(field_type) is UnionType and type(None) in get_args(field_type):
|
|
264
|
-
if field_name not in kwargs:
|
|
265
|
-
setattr(self, field_name, None)
|
|
266
|
-
continue
|
|
267
|
-
|
|
268
|
-
if field_name not in kwargs:
|
|
189
|
+
if field_name not in kwargs and not isinstance(None, field_type):
|
|
269
190
|
msg = f"Missing required field: {field_name}"
|
|
270
191
|
raise TypeValidationError(msg)
|
|
271
|
-
setattr(self, field_name, kwargs[field_name])
|
|
272
192
|
|
|
273
|
-
|
|
193
|
+
setattr(self, field_name, kwargs.get(field_name))
|
|
194
|
+
|
|
195
|
+
namespace["__init__"] = statica_init
|
|
274
196
|
|
|
275
197
|
# Set up Field descriptors for type-hinted attributes
|
|
276
198
|
|
|
@@ -296,15 +218,36 @@ class StaticaMeta(type):
|
|
|
296
218
|
class Statica(metaclass=StaticaMeta):
|
|
297
219
|
@classmethod
|
|
298
220
|
def from_map(cls, mapping: Mapping[str, Any]) -> Self:
|
|
299
|
-
|
|
221
|
+
mapping_key_to_field_keys = {} # Maps alias to field name
|
|
222
|
+
|
|
223
|
+
for field_descriptor in get_field_descriptors(cls):
|
|
224
|
+
# Use alias for parsing if it exists
|
|
225
|
+
alias = field_descriptor.alias_for_parsing or field_descriptor.alias or field_descriptor.name
|
|
226
|
+
mapping_key_to_field_keys[alias] = field_descriptor.name
|
|
227
|
+
|
|
228
|
+
parsed_mapping = {mapping_key_to_field_keys[k]: v for k, v in mapping.items()}
|
|
229
|
+
|
|
230
|
+
instance = cls(**parsed_mapping)
|
|
300
231
|
|
|
301
232
|
# Go through type hints and set values
|
|
302
233
|
for attribute_name in get_type_hints(instance.__class__):
|
|
303
|
-
value =
|
|
234
|
+
value = parsed_mapping.get(attribute_name)
|
|
304
235
|
setattr(instance, attribute_name, value) # Descriptor __set__ validates
|
|
305
236
|
|
|
306
237
|
return instance
|
|
307
238
|
|
|
239
|
+
def to_dict(self) -> dict[str, Any]:
|
|
240
|
+
"""
|
|
241
|
+
Convert the instance to a dictionary, using the field names as keys.
|
|
242
|
+
"""
|
|
243
|
+
result = {}
|
|
244
|
+
for field_descriptor in get_field_descriptors(self.__class__):
|
|
245
|
+
# Use alias for serialization if it exists
|
|
246
|
+
alias = field_descriptor.alias_for_serialization or field_descriptor.alias or field_descriptor.name
|
|
247
|
+
result[alias] = getattr(self, field_descriptor.name)
|
|
248
|
+
|
|
249
|
+
return result
|
|
250
|
+
|
|
308
251
|
|
|
309
252
|
########################################################################################
|
|
310
253
|
#### MARK: Main
|
|
@@ -317,13 +260,13 @@ if __name__ == "__main__":
|
|
|
317
260
|
name: str = Field(min_length=3, max_length=50, strip_whitespace=True)
|
|
318
261
|
description: str | None = Field(max_length=200)
|
|
319
262
|
num: int | float
|
|
320
|
-
float_num: float | None
|
|
263
|
+
float_num: float | None = Field(alias="floatNum")
|
|
321
264
|
|
|
322
265
|
data = {
|
|
323
266
|
"name": "Test Payload",
|
|
324
267
|
"description": "ddf",
|
|
325
268
|
"num": 5,
|
|
326
|
-
"
|
|
269
|
+
"floatNum": 5.5,
|
|
327
270
|
}
|
|
328
271
|
|
|
329
272
|
payload = Payload.from_map(data)
|
|
@@ -334,10 +277,3 @@ if __name__ == "__main__":
|
|
|
334
277
|
num=42,
|
|
335
278
|
float_num=3.14,
|
|
336
279
|
)
|
|
337
|
-
|
|
338
|
-
class ShortSyntax(Statica):
|
|
339
|
-
name: str = Field(min_length=3, max_length=5, strip_whitespace=True)
|
|
340
|
-
|
|
341
|
-
print("Testing ShortSyntax...")
|
|
342
|
-
short = ShortSyntax(name="test")
|
|
343
|
-
print(f"short.name = {short.name}")
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from types import UnionType
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from statica.exceptions import ConstraintValidationError, TypeValidationError
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def validate_type(value: Any, expected_type: type | UnionType) -> None:
|
|
10
|
+
"""
|
|
11
|
+
Validate that the value matches the expected type.
|
|
12
|
+
Throws TypeValidationError if the type does not match.
|
|
13
|
+
|
|
14
|
+
Examples:
|
|
15
|
+
.. code-block:: python
|
|
16
|
+
_validate_type(1, int) # No exception
|
|
17
|
+
_validate_type("abc", str) # No exception
|
|
18
|
+
_validate_type(1, str) # Raises TypeValidationError
|
|
19
|
+
_validate_type(None, int | None) # No exception
|
|
20
|
+
_validate_type(None, int) # Raises TypeValidationError
|
|
21
|
+
"""
|
|
22
|
+
if isinstance(value, expected_type):
|
|
23
|
+
return
|
|
24
|
+
|
|
25
|
+
expected_type_str = str(expected_type) if type(expected_type) is UnionType else expected_type.__name__
|
|
26
|
+
msg = f"expected type '{expected_type_str}', got '{type(value).__name__}'"
|
|
27
|
+
raise TypeValidationError(msg)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def validate_constraints(
|
|
31
|
+
value: Any,
|
|
32
|
+
min_length: int | None = None,
|
|
33
|
+
max_length: int | None = None,
|
|
34
|
+
min_value: float | None = None,
|
|
35
|
+
max_value: float | None = None,
|
|
36
|
+
strip_whitespace: bool | None = None,
|
|
37
|
+
) -> Any:
|
|
38
|
+
"""
|
|
39
|
+
If the value is a string, strip the whitespace if `strip_whitespace` is True.
|
|
40
|
+
|
|
41
|
+
If the value is a string, list, tuple, or dict, check its length against
|
|
42
|
+
the `min_length` and `max_length` constraints.
|
|
43
|
+
|
|
44
|
+
If the value is an int or float, check its value against the `min_value`
|
|
45
|
+
and `max_value` constraints.
|
|
46
|
+
|
|
47
|
+
Throws ConstraintValidationError if any constraints are violated.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
if strip_whitespace and isinstance(value, str):
|
|
51
|
+
value = value.strip()
|
|
52
|
+
|
|
53
|
+
if isinstance(value, str | list | tuple | dict):
|
|
54
|
+
if min_length is not None and len(value) < min_length:
|
|
55
|
+
msg = f"length must be at least {min_length}"
|
|
56
|
+
raise ConstraintValidationError(msg)
|
|
57
|
+
if max_length is not None and len(value) > max_length:
|
|
58
|
+
msg = f"length must be at most {max_length}"
|
|
59
|
+
raise ConstraintValidationError(msg)
|
|
60
|
+
|
|
61
|
+
if isinstance(value, int | float):
|
|
62
|
+
if min_value is not None and value < min_value:
|
|
63
|
+
msg = f"must be at least {min_value}"
|
|
64
|
+
raise ConstraintValidationError(msg)
|
|
65
|
+
if max_value is not None and value > max_value:
|
|
66
|
+
msg = f"must be at most {max_value}"
|
|
67
|
+
raise ConstraintValidationError(msg)
|
|
68
|
+
|
|
69
|
+
return value
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: statica
|
|
3
|
-
Version: 1.0
|
|
3
|
+
Version: 1.2.0
|
|
4
4
|
Summary: A minimalistic data validation library
|
|
5
5
|
Author-email: Marcel Kröker <kroeker.marcel@gmail.com>
|
|
6
6
|
License: MIT License
|
|
@@ -31,7 +31,7 @@ Classifier: Operating System :: OS Independent
|
|
|
31
31
|
Classifier: Intended Audience :: Developers
|
|
32
32
|
Classifier: Programming Language :: Python
|
|
33
33
|
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
34
|
-
Requires-Python: <3.14,>=3.
|
|
34
|
+
Requires-Python: <3.14,>=3.10
|
|
35
35
|
Description-Content-Type: text/markdown
|
|
36
36
|
|
|
37
37
|
# Statica
|
|
@@ -44,13 +44,13 @@ Statica is a Python library for defining and validating structured data with typ
|
|
|
44
44
|
|
|
45
45
|
## Why Statica?
|
|
46
46
|
|
|
47
|
-
Statica was created to address the need for a lightweight, flexible, and dependency-free alternative to libraries like pydantic.
|
|
47
|
+
Statica was created to address the need for a lightweight, flexible, and dependency-free alternative to libraries like pydantic.
|
|
48
48
|
While pydantic is a powerful tool for data validation and parsing, Statica offers some distinct advantages in specific situations:
|
|
49
49
|
|
|
50
50
|
1. **Lightweight**: Statica does not rely on any third-party dependencies, making it ideal for projects where minimizing external dependencies is a priority.
|
|
51
|
-
2. **
|
|
51
|
+
2. **Performance**: For use cases where performance is critical. Pydantic needs `3x` more memory than Statica for the same models.
|
|
52
52
|
3. **Ease of Use**: With its simple, Pythonic design, Statica is intuitive for developers already familiar with Python's `dataclasses` and type hinting. It avoids much of the magic that pydantic employs.
|
|
53
|
-
4. **
|
|
53
|
+
4. **Customizable Validation**: Statica allows fine-grained control over type and constraint validation through customizable field descriptors (`Field`) and error classes.
|
|
54
54
|
|
|
55
55
|
## Features
|
|
56
56
|
|
|
@@ -4,8 +4,11 @@ pyproject.toml
|
|
|
4
4
|
statica/__init__.py
|
|
5
5
|
statica/core.py
|
|
6
6
|
statica/exceptions.py
|
|
7
|
+
statica/validators.py
|
|
7
8
|
statica.egg-info/PKG-INFO
|
|
8
9
|
statica.egg-info/SOURCES.txt
|
|
9
10
|
statica.egg-info/dependency_links.txt
|
|
10
11
|
statica.egg-info/top_level.txt
|
|
11
|
-
tests/
|
|
12
|
+
tests/test_aliasing.py
|
|
13
|
+
tests/test_statica.py
|
|
14
|
+
tests/test_validators.py
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
|
|
3
|
+
from statica.core import Field, Statica, TypeValidationError
|
|
4
|
+
from statica.exceptions import ConstraintValidationError
|
|
5
|
+
|
|
6
|
+
INTEGER = 42
|
|
7
|
+
FLOAT = 19.99
|
|
8
|
+
STRING = "test string"
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def test_basic_alias() -> None:
|
|
12
|
+
class AliasTest(Statica):
|
|
13
|
+
full_name: str = Field(alias="fullName")
|
|
14
|
+
age: int = Field(alias="userAge")
|
|
15
|
+
|
|
16
|
+
# Test parsing with alias
|
|
17
|
+
data = {"fullName": "John Doe", "userAge": INTEGER}
|
|
18
|
+
instance = AliasTest.from_map(data)
|
|
19
|
+
assert instance.full_name == "John Doe"
|
|
20
|
+
assert instance.age == INTEGER
|
|
21
|
+
assert instance.to_dict() == data
|
|
22
|
+
|
|
23
|
+
# Test that original field names don't work when alias is used
|
|
24
|
+
with pytest.raises(KeyError):
|
|
25
|
+
AliasTest.from_map({"full_name": "John Doe", "age": INTEGER})
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def test_alias_for_parsing() -> None:
|
|
29
|
+
"""Test alias_for_parsing specifically."""
|
|
30
|
+
|
|
31
|
+
class ParseAliasTest(Statica):
|
|
32
|
+
user_name: str = Field(alias_for_parsing="userName")
|
|
33
|
+
user_id: int = Field(alias_for_parsing="userId")
|
|
34
|
+
|
|
35
|
+
# Test parsing with parsing alias
|
|
36
|
+
instance = ParseAliasTest.from_map({"userName": "alice", "userId": INTEGER})
|
|
37
|
+
assert instance.user_name == "alice"
|
|
38
|
+
assert instance.user_id == INTEGER
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def test_alias_priority() -> None:
|
|
42
|
+
"""Test that alias_for_parsing takes priority over general alias."""
|
|
43
|
+
|
|
44
|
+
class PriorityTest(Statica):
|
|
45
|
+
field_name: str = Field(alias="generalAlias", alias_for_parsing="parseAlias")
|
|
46
|
+
|
|
47
|
+
# Should use alias_for_parsing
|
|
48
|
+
instance = PriorityTest.from_map({"parseAlias": STRING})
|
|
49
|
+
assert instance.field_name == STRING
|
|
50
|
+
|
|
51
|
+
# General alias should not work when alias_for_parsing exists
|
|
52
|
+
with pytest.raises(KeyError):
|
|
53
|
+
PriorityTest.from_map({"generalAlias": STRING})
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def test_alias_with_optional_fields() -> None:
|
|
57
|
+
"""Test aliases with optional fields."""
|
|
58
|
+
|
|
59
|
+
class OptionalAliasTest(Statica):
|
|
60
|
+
required_field: str = Field(alias="reqField")
|
|
61
|
+
optional_field: str | None = Field(alias="optField")
|
|
62
|
+
|
|
63
|
+
# Test with both fields provided
|
|
64
|
+
instance = OptionalAliasTest.from_map({"reqField": "required", "optField": "optional"})
|
|
65
|
+
assert instance.required_field == "required"
|
|
66
|
+
assert instance.optional_field == "optional"
|
|
67
|
+
|
|
68
|
+
# Test with only required field
|
|
69
|
+
instance = OptionalAliasTest.from_map({"reqField": "required"})
|
|
70
|
+
assert instance.required_field == "required"
|
|
71
|
+
assert instance.optional_field is None
|
|
72
|
+
|
|
73
|
+
# Test missing required field should still raise error
|
|
74
|
+
with pytest.raises(TypeValidationError):
|
|
75
|
+
OptionalAliasTest.from_map({"optField": "optional"})
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def test_alias_with_constraints() -> None:
|
|
79
|
+
"""Test that aliases work with field constraints."""
|
|
80
|
+
|
|
81
|
+
class ConstraintAliasTest(Statica):
|
|
82
|
+
name: str = Field(alias="userName", min_length=3, max_length=10, strip_whitespace=True)
|
|
83
|
+
score: int = Field(alias="userScore", min_value=0, max_value=100)
|
|
84
|
+
|
|
85
|
+
# Test valid values
|
|
86
|
+
instance = ConstraintAliasTest.from_map({"userName": " Alice ", "userScore": INTEGER})
|
|
87
|
+
assert instance.name == "Alice" # Whitespace should be stripped
|
|
88
|
+
assert instance.score == INTEGER
|
|
89
|
+
|
|
90
|
+
# Test constraint violations with aliases
|
|
91
|
+
with pytest.raises(ConstraintValidationError): # Should be ConstraintValidationError
|
|
92
|
+
ConstraintAliasTest.from_map({"userName": "Al", "userScore": INTEGER}) # Too short
|
|
93
|
+
|
|
94
|
+
with pytest.raises(ConstraintValidationError): # Should be ConstraintValidationError
|
|
95
|
+
ConstraintAliasTest.from_map({"userName": "Alice", "userScore": 150}) # Too high
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def test_alias_with_casting() -> None:
|
|
99
|
+
"""Test that aliases work with type casting."""
|
|
100
|
+
|
|
101
|
+
class CastAliasTest(Statica):
|
|
102
|
+
number: int = Field(alias="numStr", cast_to=int)
|
|
103
|
+
price: float = Field(alias="priceStr", cast_to=float)
|
|
104
|
+
|
|
105
|
+
instance = CastAliasTest.from_map({"numStr": "42", "priceStr": "19.99"})
|
|
106
|
+
assert instance.number == INTEGER
|
|
107
|
+
assert instance.price == FLOAT
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def test_mixed_alias_and_no_alias() -> None:
|
|
111
|
+
"""Test mixing fields with and without aliases."""
|
|
112
|
+
|
|
113
|
+
class MixedTest(Statica):
|
|
114
|
+
normal_field: str
|
|
115
|
+
aliased_field: str = Field(alias="aliasedField")
|
|
116
|
+
another_normal: int
|
|
117
|
+
|
|
118
|
+
instance = MixedTest.from_map(
|
|
119
|
+
{
|
|
120
|
+
"normal_field": "normal",
|
|
121
|
+
"aliasedField": "aliased",
|
|
122
|
+
"another_normal": INTEGER,
|
|
123
|
+
},
|
|
124
|
+
)
|
|
125
|
+
assert instance.normal_field == "normal"
|
|
126
|
+
assert instance.aliased_field == "aliased"
|
|
127
|
+
assert instance.another_normal == INTEGER
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def test_alias_serialization() -> None:
|
|
131
|
+
class SerializationTest(Statica):
|
|
132
|
+
field_name: str = Field(alias_for_serialization="serialAlias")
|
|
133
|
+
|
|
134
|
+
instance = SerializationTest(field_name=STRING)
|
|
135
|
+
|
|
136
|
+
assert instance.to_dict() == {"serialAlias": STRING}
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def test_all_alias_types_together() -> None:
|
|
140
|
+
"""Test a field with all three alias types."""
|
|
141
|
+
|
|
142
|
+
class AllAliasTest(Statica):
|
|
143
|
+
field_name: str = Field(
|
|
144
|
+
alias="generalAlias",
|
|
145
|
+
alias_for_parsing="parseAlias",
|
|
146
|
+
alias_for_serialization="serializeAlias",
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
# Should use alias_for_parsing for parsing
|
|
150
|
+
instance = AllAliasTest.from_map({"parseAlias": STRING})
|
|
151
|
+
assert instance.field_name == STRING
|
|
152
|
+
assert instance.to_dict() == {"serializeAlias": STRING}
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def test_empty_alias_mapping() -> None:
|
|
156
|
+
"""Test behavior when no matching aliases are found in the mapping."""
|
|
157
|
+
|
|
158
|
+
class EmptyMappingTest(Statica):
|
|
159
|
+
field_name: str = Field(alias="expectedAlias")
|
|
160
|
+
|
|
161
|
+
# Should raise KeyError when the aliased field is missing
|
|
162
|
+
with pytest.raises(KeyError):
|
|
163
|
+
EmptyMappingTest.from_map({"wrongAlias": "value"})
|
|
@@ -1,63 +1,6 @@
|
|
|
1
1
|
import pytest
|
|
2
2
|
|
|
3
|
-
from statica.core import
|
|
4
|
-
ConstraintValidationError,
|
|
5
|
-
Field,
|
|
6
|
-
Statica,
|
|
7
|
-
TypeValidationError,
|
|
8
|
-
_get_expected_type,
|
|
9
|
-
_type_allows_none,
|
|
10
|
-
_validate_type,
|
|
11
|
-
_value_matches_type,
|
|
12
|
-
)
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
def test_allows_none() -> None:
|
|
16
|
-
assert _type_allows_none(type(None))
|
|
17
|
-
assert _type_allows_none(int | None)
|
|
18
|
-
assert not _type_allows_none(int)
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
def test_value_matches_type() -> None:
|
|
22
|
-
assert _value_matches_type(1, int)
|
|
23
|
-
assert _value_matches_type("abc", str)
|
|
24
|
-
assert not _value_matches_type(1, str)
|
|
25
|
-
assert _value_matches_type(None, int | None)
|
|
26
|
-
assert _value_matches_type(1, int | None)
|
|
27
|
-
assert not _value_matches_type(None, int)
|
|
28
|
-
assert _value_matches_type(1, int | None)
|
|
29
|
-
assert not _value_matches_type("abc", int | None)
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
def test_validate_type() -> None:
|
|
33
|
-
# No exceptions should be raised for valid types
|
|
34
|
-
_validate_type(1, int)
|
|
35
|
-
_validate_type("abc", str)
|
|
36
|
-
_validate_type(None, int | None)
|
|
37
|
-
_validate_type(1, int | None)
|
|
38
|
-
|
|
39
|
-
# Exceptions should be raised for invalid types
|
|
40
|
-
with pytest.raises(TypeValidationError):
|
|
41
|
-
_validate_type(1, str)
|
|
42
|
-
with pytest.raises(TypeValidationError):
|
|
43
|
-
_validate_type("abc", int)
|
|
44
|
-
with pytest.raises(TypeValidationError):
|
|
45
|
-
_validate_type(None, int)
|
|
46
|
-
with pytest.raises(TypeValidationError):
|
|
47
|
-
_validate_type("abc", int | None)
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
def test_get_expected_type() -> None:
|
|
51
|
-
class TestClass(Statica):
|
|
52
|
-
required_field: str
|
|
53
|
-
optional_field: str | None
|
|
54
|
-
required_with_field: str = Field()
|
|
55
|
-
optional_with_field: str | None = Field()
|
|
56
|
-
|
|
57
|
-
assert _get_expected_type(TestClass, "required_field") is str
|
|
58
|
-
assert _get_expected_type(TestClass, "optional_field") == str | None
|
|
59
|
-
assert _get_expected_type(TestClass, "required_with_field") is str
|
|
60
|
-
assert _get_expected_type(TestClass, "optional_with_field") == str | None
|
|
3
|
+
from statica.core import ConstraintValidationError, Field, Statica, TypeValidationError
|
|
61
4
|
|
|
62
5
|
|
|
63
6
|
def test_basic_syntax() -> None:
|
|
@@ -231,7 +174,3 @@ def test_cast() -> None:
|
|
|
231
174
|
|
|
232
175
|
with pytest.raises(TypeValidationError):
|
|
233
176
|
IntTest.from_map({"num": "5.5"})
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
if __name__ == "__main__":
|
|
237
|
-
pytest.main([__file__])
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
|
|
3
|
+
from statica.core import TypeValidationError
|
|
4
|
+
from statica.validators import validate_type
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def test_validate_type() -> None:
|
|
8
|
+
# No exceptions should be raised for valid types
|
|
9
|
+
|
|
10
|
+
validate_type(1, int)
|
|
11
|
+
validate_type("abc", str)
|
|
12
|
+
validate_type(None, int | None)
|
|
13
|
+
validate_type(1, int | None)
|
|
14
|
+
|
|
15
|
+
# Exceptions should be raised for invalid types
|
|
16
|
+
|
|
17
|
+
with pytest.raises(TypeValidationError):
|
|
18
|
+
validate_type(1, str)
|
|
19
|
+
with pytest.raises(TypeValidationError):
|
|
20
|
+
validate_type("abc", int)
|
|
21
|
+
with pytest.raises(TypeValidationError):
|
|
22
|
+
validate_type(None, int)
|
|
23
|
+
with pytest.raises(TypeValidationError):
|
|
24
|
+
validate_type("abc", int | None)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|