statica 1.0.2__tar.gz → 1.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.
- {statica-1.0.2/statica.egg-info → statica-1.1.0}/PKG-INFO +1 -1
- {statica-1.0.2 → statica-1.1.0}/pyproject.toml +1 -9
- {statica-1.0.2 → statica-1.1.0}/statica/core.py +64 -125
- statica-1.1.0/statica/types_utils.py +64 -0
- statica-1.1.0/statica/validators.py +69 -0
- {statica-1.0.2 → statica-1.1.0/statica.egg-info}/PKG-INFO +1 -1
- {statica-1.0.2 → statica-1.1.0}/statica.egg-info/SOURCES.txt +6 -1
- statica-1.1.0/tests/test_aliasing.py +163 -0
- {statica-1.0.2 → statica-1.1.0}/tests/test_statica.py +5 -53
- statica-1.1.0/tests/test_types_utils.py +33 -0
- statica-1.1.0/tests/test_validators.py +37 -0
- {statica-1.0.2 → statica-1.1.0}/LICENSE +0 -0
- {statica-1.0.2 → statica-1.1.0}/README.md +0 -0
- {statica-1.0.2 → statica-1.1.0}/setup.cfg +0 -0
- {statica-1.0.2 → statica-1.1.0}/statica/__init__.py +0 -0
- {statica-1.0.2 → statica-1.1.0}/statica/exceptions.py +0 -0
- {statica-1.0.2 → statica-1.1.0}/statica.egg-info/dependency_links.txt +0 -0
- {statica-1.0.2 → statica-1.1.0}/statica.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "statica"
|
|
3
|
-
version = "1.0
|
|
3
|
+
version = "1.1.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" }]
|
|
@@ -59,18 +59,10 @@ ignore = [
|
|
|
59
59
|
"ANN202", # Missing return type annotation for private function
|
|
60
60
|
"ANN401", # Dynamically typed expressions (typing.Any) are disallowed
|
|
61
61
|
"D", # docstrings
|
|
62
|
-
"E501", # line length
|
|
63
62
|
"ERA001", # Found commented-out code
|
|
64
|
-
"FBT001", # Boolean-typed positional argument in function definition
|
|
65
|
-
"FIX002", # Line contains TODO, consider resolving the issue
|
|
66
63
|
"INP001", # File is part of an implicit namespace package. Add an `__init__.py`.
|
|
67
64
|
"S101", # Use of `assert` detected
|
|
68
65
|
"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
66
|
"W191", # indentation contains tabs
|
|
75
67
|
"PLR0913", # Too many arguments in function definition (6 > 5)
|
|
76
68
|
]
|
|
@@ -18,6 +18,8 @@ from typing import (
|
|
|
18
18
|
)
|
|
19
19
|
|
|
20
20
|
from statica.exceptions import ConstraintValidationError, TypeValidationError
|
|
21
|
+
from statica.types_utils import get_expected_type
|
|
22
|
+
from statica.validators import validate_constraints, validate_type
|
|
21
23
|
|
|
22
24
|
if TYPE_CHECKING:
|
|
23
25
|
from collections.abc import Callable, Mapping
|
|
@@ -25,95 +27,6 @@ if TYPE_CHECKING:
|
|
|
25
27
|
T = TypeVar("T")
|
|
26
28
|
|
|
27
29
|
|
|
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
30
|
########################################################################################
|
|
118
31
|
#### MARK: Field descriptor
|
|
119
32
|
|
|
@@ -135,10 +48,14 @@ class FieldDescriptor(Generic[T]):
|
|
|
135
48
|
strip_whitespace: bool | None = None
|
|
136
49
|
cast_to: Callable[..., T] | None = None
|
|
137
50
|
|
|
51
|
+
alias: str | None = None
|
|
52
|
+
alias_for_parsing: str | None = None
|
|
53
|
+
alias_for_serialization: str | None = None
|
|
54
|
+
|
|
138
55
|
def __set_name__(self, owner: Any, name: str) -> None:
|
|
139
56
|
self.name = name
|
|
140
57
|
self.owner = owner
|
|
141
|
-
self.expected_type =
|
|
58
|
+
self.expected_type = get_expected_type(owner, name)
|
|
142
59
|
|
|
143
60
|
@overload
|
|
144
61
|
def __get__(self, instance: None, owner: Any) -> FieldDescriptor[T]: ...
|
|
@@ -159,45 +76,49 @@ class FieldDescriptor(Generic[T]):
|
|
|
159
76
|
if self.cast_to is not None:
|
|
160
77
|
value = self.cast_to(value)
|
|
161
78
|
|
|
162
|
-
|
|
79
|
+
validate_type(value, self.expected_type)
|
|
163
80
|
|
|
164
81
|
if value is not None:
|
|
165
|
-
value =
|
|
82
|
+
value = validate_constraints(
|
|
83
|
+
value,
|
|
84
|
+
min_length=self.min_length,
|
|
85
|
+
max_length=self.max_length,
|
|
86
|
+
min_value=self.min_value,
|
|
87
|
+
max_value=self.max_value,
|
|
88
|
+
strip_whitespace=self.strip_whitespace,
|
|
89
|
+
)
|
|
166
90
|
|
|
167
91
|
except TypeValidationError as e:
|
|
168
92
|
msg = f"{self.name}: {e!s}"
|
|
169
93
|
error_class = getattr(self.owner, "type_error_class", TypeValidationError)
|
|
170
94
|
raise error_class(msg) from e
|
|
95
|
+
|
|
171
96
|
except ConstraintValidationError as e:
|
|
172
97
|
msg = f"{self.name}: {e!s}"
|
|
173
98
|
error_class = getattr(self.owner, "constraint_error_class", ConstraintValidationError)
|
|
174
99
|
raise error_class(msg) from e
|
|
100
|
+
|
|
175
101
|
except ValueError as e:
|
|
176
102
|
msg = f"{self.name}: {e!s}"
|
|
177
103
|
raise TypeValidationError(msg) from e
|
|
104
|
+
|
|
178
105
|
return value
|
|
179
106
|
|
|
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
107
|
|
|
200
|
-
|
|
108
|
+
def get_field_descriptors(cls: type[Statica]) -> list[FieldDescriptor]:
|
|
109
|
+
"""
|
|
110
|
+
Get all Field descriptors for a class.
|
|
111
|
+
Returns a list of FieldDescriptor instances.
|
|
112
|
+
"""
|
|
113
|
+
|
|
114
|
+
descriptors = []
|
|
115
|
+
|
|
116
|
+
for field_name in cls.__annotations__:
|
|
117
|
+
field_descriptor = getattr(cls, field_name)
|
|
118
|
+
assert isinstance(field_descriptor, FieldDescriptor)
|
|
119
|
+
descriptors.append(field_descriptor)
|
|
120
|
+
|
|
121
|
+
return descriptors
|
|
201
122
|
|
|
202
123
|
|
|
203
124
|
########################################################################################
|
|
@@ -212,6 +133,9 @@ def Field( # noqa: N802
|
|
|
212
133
|
max_value: float | None = None,
|
|
213
134
|
strip_whitespace: bool | None = None,
|
|
214
135
|
cast_to: Callable[..., T] | None = None,
|
|
136
|
+
alias: str | None = None,
|
|
137
|
+
alias_for_parsing: str | None = None,
|
|
138
|
+
alias_for_serialization: str | None = None,
|
|
215
139
|
) -> Any:
|
|
216
140
|
"""
|
|
217
141
|
Type-safe field function that returns the correct type for type checkers
|
|
@@ -225,6 +149,9 @@ def Field( # noqa: N802
|
|
|
225
149
|
max_value=max_value,
|
|
226
150
|
strip_whitespace=strip_whitespace,
|
|
227
151
|
cast_to=cast_to,
|
|
152
|
+
alias=alias,
|
|
153
|
+
alias_for_parsing=alias_for_parsing,
|
|
154
|
+
alias_for_serialization=alias_for_serialization,
|
|
228
155
|
)
|
|
229
156
|
|
|
230
157
|
if TYPE_CHECKING:
|
|
@@ -248,8 +175,6 @@ class StaticaMeta(type):
|
|
|
248
175
|
already, but only for subclasses of Statica.
|
|
249
176
|
"""
|
|
250
177
|
|
|
251
|
-
print(f"__new__({name=}, {bases=}, {namespace=})")
|
|
252
|
-
|
|
253
178
|
if name == "Statica":
|
|
254
179
|
return super().__new__(cls, name, bases, namespace)
|
|
255
180
|
|
|
@@ -296,15 +221,36 @@ class StaticaMeta(type):
|
|
|
296
221
|
class Statica(metaclass=StaticaMeta):
|
|
297
222
|
@classmethod
|
|
298
223
|
def from_map(cls, mapping: Mapping[str, Any]) -> Self:
|
|
299
|
-
|
|
224
|
+
mapping_key_to_field_keys = {} # Maps alias to field name
|
|
225
|
+
|
|
226
|
+
for field_descriptor in get_field_descriptors(cls):
|
|
227
|
+
# Use alias for parsing if it exists
|
|
228
|
+
alias = field_descriptor.alias_for_parsing or field_descriptor.alias or field_descriptor.name
|
|
229
|
+
mapping_key_to_field_keys[alias] = field_descriptor.name
|
|
230
|
+
|
|
231
|
+
parsed_mapping = {mapping_key_to_field_keys[k]: v for k, v in mapping.items()}
|
|
232
|
+
|
|
233
|
+
instance = cls(**parsed_mapping)
|
|
300
234
|
|
|
301
235
|
# Go through type hints and set values
|
|
302
236
|
for attribute_name in get_type_hints(instance.__class__):
|
|
303
|
-
value =
|
|
237
|
+
value = parsed_mapping.get(attribute_name)
|
|
304
238
|
setattr(instance, attribute_name, value) # Descriptor __set__ validates
|
|
305
239
|
|
|
306
240
|
return instance
|
|
307
241
|
|
|
242
|
+
def to_dict(self) -> dict[str, Any]:
|
|
243
|
+
"""
|
|
244
|
+
Convert the instance to a dictionary, using the field names as keys.
|
|
245
|
+
"""
|
|
246
|
+
result = {}
|
|
247
|
+
for field_descriptor in get_field_descriptors(self.__class__):
|
|
248
|
+
# Use alias for serialization if it exists
|
|
249
|
+
alias = field_descriptor.alias_for_serialization or field_descriptor.alias or field_descriptor.name
|
|
250
|
+
result[alias] = getattr(self, field_descriptor.name)
|
|
251
|
+
|
|
252
|
+
return result
|
|
253
|
+
|
|
308
254
|
|
|
309
255
|
########################################################################################
|
|
310
256
|
#### MARK: Main
|
|
@@ -317,13 +263,13 @@ if __name__ == "__main__":
|
|
|
317
263
|
name: str = Field(min_length=3, max_length=50, strip_whitespace=True)
|
|
318
264
|
description: str | None = Field(max_length=200)
|
|
319
265
|
num: int | float
|
|
320
|
-
float_num: float | None
|
|
266
|
+
float_num: float | None = Field(alias="floatNum")
|
|
321
267
|
|
|
322
268
|
data = {
|
|
323
269
|
"name": "Test Payload",
|
|
324
270
|
"description": "ddf",
|
|
325
271
|
"num": 5,
|
|
326
|
-
"
|
|
272
|
+
"floatNum": 5.5,
|
|
327
273
|
}
|
|
328
274
|
|
|
329
275
|
payload = Payload.from_map(data)
|
|
@@ -334,10 +280,3 @@ if __name__ == "__main__":
|
|
|
334
280
|
num=42,
|
|
335
281
|
float_num=3.14,
|
|
336
282
|
)
|
|
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,64 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from types import UnionType
|
|
4
|
+
from typing import Any, get_args, get_origin, get_type_hints
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def type_allows_none(expected_type: Any) -> bool:
|
|
8
|
+
"""
|
|
9
|
+
Check if the expected type allows None.
|
|
10
|
+
|
|
11
|
+
Examples:
|
|
12
|
+
.. code-block:: python
|
|
13
|
+
_allows_none(int | None) # True
|
|
14
|
+
_allows_none(int) # False
|
|
15
|
+
"""
|
|
16
|
+
if isinstance(expected_type, UnionType):
|
|
17
|
+
return type(None) in get_args(expected_type)
|
|
18
|
+
return expected_type is type(None) or expected_type is Any
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def value_matches_type(value: Any | None, expected_type: Any) -> bool:
|
|
22
|
+
"""
|
|
23
|
+
Check if the value matches the expected type.
|
|
24
|
+
Handles basic types, Union types, and generic types.
|
|
25
|
+
|
|
26
|
+
Examples:
|
|
27
|
+
.. code-block:: python
|
|
28
|
+
_value_matches_type(1, int) # True
|
|
29
|
+
_value_matches_type(None, int | None) # True
|
|
30
|
+
_value_matches_type(None, int) # False
|
|
31
|
+
"""
|
|
32
|
+
# If expected_type is e.g. int | None, pass if value is None
|
|
33
|
+
if type_allows_none(expected_type) and value is None:
|
|
34
|
+
return True
|
|
35
|
+
|
|
36
|
+
# Basic types like int, str, etc.
|
|
37
|
+
if (origin := get_origin(expected_type)) is None:
|
|
38
|
+
return isinstance(value, expected_type)
|
|
39
|
+
|
|
40
|
+
# Handle Union types
|
|
41
|
+
if origin is UnionType or origin is type(None) or origin is Any:
|
|
42
|
+
types = get_args(expected_type)
|
|
43
|
+
return any(value_matches_type(value, t) for t in types if t is not type(None))
|
|
44
|
+
|
|
45
|
+
# Handle generic types
|
|
46
|
+
return isinstance(value, origin)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def get_expected_type(cls: type, attr_name: str) -> Any:
|
|
50
|
+
"""
|
|
51
|
+
Get the expected type for a class attribute.
|
|
52
|
+
Handles type hints and Field descriptors.
|
|
53
|
+
|
|
54
|
+
Examples:
|
|
55
|
+
.. code-block:: python
|
|
56
|
+
class MyClass(Statica):
|
|
57
|
+
age: int | None
|
|
58
|
+
name: str = Field()
|
|
59
|
+
|
|
60
|
+
_get_expected_type(MyClass, "age") # int | None
|
|
61
|
+
_get_expected_type(MyClass, "name") # str
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
return get_type_hints(cls).get(attr_name, Any)
|
|
@@ -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
|
+
from statica.types_utils import value_matches_type
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def validate_type(value: Any, expected_type: type | UnionType) -> None:
|
|
11
|
+
"""
|
|
12
|
+
Validate that the value matches the expected type.
|
|
13
|
+
Throws TypeValidationError if the type does not match.
|
|
14
|
+
|
|
15
|
+
Examples:
|
|
16
|
+
.. code-block:: python
|
|
17
|
+
_validate_type(1, int) # No exception
|
|
18
|
+
_validate_type("abc", str) # No exception
|
|
19
|
+
_validate_type(1, str) # Raises TypeValidationError
|
|
20
|
+
_validate_type(None, int | None) # No exception
|
|
21
|
+
_validate_type(None, int) # Raises TypeValidationError
|
|
22
|
+
"""
|
|
23
|
+
if not value_matches_type(value, expected_type):
|
|
24
|
+
expected_type_str = str(expected_type) if type(expected_type) is UnionType else expected_type.__name__
|
|
25
|
+
|
|
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
|
|
@@ -4,8 +4,13 @@ pyproject.toml
|
|
|
4
4
|
statica/__init__.py
|
|
5
5
|
statica/core.py
|
|
6
6
|
statica/exceptions.py
|
|
7
|
+
statica/types_utils.py
|
|
8
|
+
statica/validators.py
|
|
7
9
|
statica.egg-info/PKG-INFO
|
|
8
10
|
statica.egg-info/SOURCES.txt
|
|
9
11
|
statica.egg-info/dependency_links.txt
|
|
10
12
|
statica.egg-info/top_level.txt
|
|
11
|
-
tests/
|
|
13
|
+
tests/test_aliasing.py
|
|
14
|
+
tests/test_statica.py
|
|
15
|
+
tests/test_types_utils.py
|
|
16
|
+
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,50 +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)
|
|
3
|
+
from statica.core import ConstraintValidationError, Field, Statica, TypeValidationError, get_expected_type
|
|
48
4
|
|
|
49
5
|
|
|
50
6
|
def test_get_expected_type() -> None:
|
|
@@ -54,10 +10,10 @@ def test_get_expected_type() -> None:
|
|
|
54
10
|
required_with_field: str = Field()
|
|
55
11
|
optional_with_field: str | None = Field()
|
|
56
12
|
|
|
57
|
-
assert
|
|
58
|
-
assert
|
|
59
|
-
assert
|
|
60
|
-
assert
|
|
13
|
+
assert get_expected_type(TestClass, "required_field") is str
|
|
14
|
+
assert get_expected_type(TestClass, "optional_field") == str | None
|
|
15
|
+
assert get_expected_type(TestClass, "required_with_field") is str
|
|
16
|
+
assert get_expected_type(TestClass, "optional_with_field") == str | None
|
|
61
17
|
|
|
62
18
|
|
|
63
19
|
def test_basic_syntax() -> None:
|
|
@@ -231,7 +187,3 @@ def test_cast() -> None:
|
|
|
231
187
|
|
|
232
188
|
with pytest.raises(TypeValidationError):
|
|
233
189
|
IntTest.from_map({"num": "5.5"})
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
if __name__ == "__main__":
|
|
237
|
-
pytest.main([__file__])
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
from typing import Generic, TypeVar
|
|
2
|
+
|
|
3
|
+
from statica.types_utils import type_allows_none, value_matches_type
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def test_allows_none() -> None:
|
|
7
|
+
assert type_allows_none(type(None))
|
|
8
|
+
assert type_allows_none(int | None)
|
|
9
|
+
assert not type_allows_none(int)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def test_value_matches_type() -> None:
|
|
13
|
+
assert value_matches_type(1, int)
|
|
14
|
+
assert value_matches_type("abc", str)
|
|
15
|
+
assert not value_matches_type(1, str)
|
|
16
|
+
assert value_matches_type(None, int | None)
|
|
17
|
+
assert value_matches_type(1, int | None)
|
|
18
|
+
assert not value_matches_type(None, int)
|
|
19
|
+
assert value_matches_type(1, int | None)
|
|
20
|
+
assert not value_matches_type("abc", int | None)
|
|
21
|
+
|
|
22
|
+
# Test with generics
|
|
23
|
+
|
|
24
|
+
T = TypeVar("T")
|
|
25
|
+
|
|
26
|
+
class GenericTest(Generic[T]):
|
|
27
|
+
value: T
|
|
28
|
+
|
|
29
|
+
def __init__(self, value: T) -> None:
|
|
30
|
+
self.value = value
|
|
31
|
+
|
|
32
|
+
assert value_matches_type(GenericTest[int](1), GenericTest[int])
|
|
33
|
+
assert value_matches_type(GenericTest[int](1), GenericTest[str]) # Is this correct?
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
from typing import Generic, TypeVar
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
from statica.core import TypeValidationError
|
|
6
|
+
from statica.validators import validate_type
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def test_validate_type() -> None:
|
|
10
|
+
# No exceptions should be raised for valid types
|
|
11
|
+
|
|
12
|
+
validate_type(1, int)
|
|
13
|
+
validate_type("abc", str)
|
|
14
|
+
validate_type(None, int | None)
|
|
15
|
+
validate_type(1, int | None)
|
|
16
|
+
|
|
17
|
+
# Exceptions should be raised for invalid types
|
|
18
|
+
|
|
19
|
+
with pytest.raises(TypeValidationError):
|
|
20
|
+
validate_type(1, str)
|
|
21
|
+
with pytest.raises(TypeValidationError):
|
|
22
|
+
validate_type("abc", int)
|
|
23
|
+
with pytest.raises(TypeValidationError):
|
|
24
|
+
validate_type(None, int)
|
|
25
|
+
with pytest.raises(TypeValidationError):
|
|
26
|
+
validate_type("abc", int | None)
|
|
27
|
+
|
|
28
|
+
# Test with generics
|
|
29
|
+
|
|
30
|
+
T = TypeVar("T")
|
|
31
|
+
|
|
32
|
+
class GenericTest(Generic[T]):
|
|
33
|
+
value: T
|
|
34
|
+
|
|
35
|
+
validate_type(GenericTest[int](), GenericTest[int])
|
|
36
|
+
|
|
37
|
+
validate_type(GenericTest[str](), GenericTest[str]) # Is this correct?
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|