statica 1.0.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.0/LICENSE +21 -0
- statica-1.0.0/PKG-INFO +38 -0
- statica-1.0.0/README.md +2 -0
- statica-1.0.0/pyproject.toml +95 -0
- statica-1.0.0/setup.cfg +4 -0
- statica-1.0.0/statica/__init__.py +9 -0
- statica-1.0.0/statica/core.py +355 -0
- statica-1.0.0/statica.egg-info/PKG-INFO +38 -0
- statica-1.0.0/statica.egg-info/SOURCES.txt +10 -0
- statica-1.0.0/statica.egg-info/dependency_links.txt +1 -0
- statica-1.0.0/statica.egg-info/top_level.txt +1 -0
- statica-1.0.0/tests/test_statica.py +237 -0
statica-1.0.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 mkrd
|
|
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.
|
statica-1.0.0/PKG-INFO
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: statica
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Minimalistic validation library for Python 3.11+
|
|
5
|
+
Author-email: Marcel Kröker <kroeker.marcel@gmail.com>
|
|
6
|
+
License: MIT License
|
|
7
|
+
|
|
8
|
+
Copyright (c) 2025 mkrd
|
|
9
|
+
|
|
10
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
11
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
12
|
+
in the Software without restriction, including without limitation the rights
|
|
13
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
14
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
15
|
+
furnished to do so, subject to the following conditions:
|
|
16
|
+
|
|
17
|
+
The above copyright notice and this permission notice shall be included in all
|
|
18
|
+
copies or substantial portions of the Software.
|
|
19
|
+
|
|
20
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
21
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
22
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
23
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
24
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
25
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
26
|
+
SOFTWARE.
|
|
27
|
+
|
|
28
|
+
Classifier: Programming Language :: Python :: 3
|
|
29
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
30
|
+
Classifier: Operating System :: OS Independent
|
|
31
|
+
Classifier: Intended Audience :: Developers
|
|
32
|
+
Classifier: Programming Language :: Python
|
|
33
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
34
|
+
Requires-Python: <3.14,>=3.11
|
|
35
|
+
Description-Content-Type: text/markdown
|
|
36
|
+
|
|
37
|
+
# Statica
|
|
38
|
+
A minimalistic data validation library.
|
statica-1.0.0/README.md
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "statica"
|
|
3
|
+
version = "1.0.0"
|
|
4
|
+
description = "Minimalistic validation library for Python 3.11+"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
authors = [{ name = "Marcel Kröker", email = "kroeker.marcel@gmail.com" }]
|
|
7
|
+
license = { file = "LICENSE" }
|
|
8
|
+
classifiers=[
|
|
9
|
+
"Programming Language :: Python :: 3",
|
|
10
|
+
"License :: OSI Approved :: MIT License",
|
|
11
|
+
"Operating System :: OS Independent",
|
|
12
|
+
"Intended Audience :: Developers",
|
|
13
|
+
"Programming Language :: Python",
|
|
14
|
+
"Topic :: Software Development :: Libraries :: Python Modules"
|
|
15
|
+
]
|
|
16
|
+
requires-python = ">=3.11, <3.14"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
########################################################################################
|
|
20
|
+
#### MARK: Dependencies
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
dependencies = []
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
[dependency-groups]
|
|
27
|
+
dev = [
|
|
28
|
+
"mypy ~= 1.16.0",
|
|
29
|
+
"pytest-cov ~= 6.1.0",
|
|
30
|
+
"ruff ~= 0.11.0",
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
########################################################################################
|
|
35
|
+
#### MARK: Packaging
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
[tool.setuptools]
|
|
39
|
+
packages = ["statica"]
|
|
40
|
+
license-files = [] # Workaround for https://github.com/astral-sh/uv/issues/9513
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
[tool.uv]
|
|
44
|
+
package = true
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
########################################################################################
|
|
48
|
+
#### MARK: Ruff
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
[tool.ruff]
|
|
52
|
+
show-fixes = true
|
|
53
|
+
line-length = 120
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
[tool.ruff.lint]
|
|
57
|
+
select = ["ALL"]
|
|
58
|
+
ignore = [
|
|
59
|
+
"ANN202", # Missing return type annotation for private function
|
|
60
|
+
"D", # docstrings
|
|
61
|
+
"E501", # line length
|
|
62
|
+
"ERA001", # Found commented-out code
|
|
63
|
+
"FBT001", # Boolean-typed positional argument in function definition
|
|
64
|
+
"FIX002", # Line contains TODO, consider resolving the issue
|
|
65
|
+
"INP001", # File is part of an implicit namespace package. Add an `__init__.py`.
|
|
66
|
+
"S101", # Use of `assert` detected
|
|
67
|
+
"T201", # `print` found
|
|
68
|
+
"TD002", # Missing author in TODO; try: `# TODO(<author_name>): ...`
|
|
69
|
+
"TD003", # Missing issue link on the line following this TODO
|
|
70
|
+
"UP006", # use Dict (not possible in python 3.8)
|
|
71
|
+
"UP007", # use X | Y for union (not possible in python 3.8)
|
|
72
|
+
"UP035", # use typing Dict and List (not possible in python 3.8)
|
|
73
|
+
"W191", # indentation contains tabs
|
|
74
|
+
]
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
[tool.ruff.format]
|
|
78
|
+
indent-style = "tab"
|
|
79
|
+
quote-style = "double"
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
########################################################################################
|
|
83
|
+
#### MARK: Testing
|
|
84
|
+
|
|
85
|
+
[tool.coverage.report]
|
|
86
|
+
exclude_also = [
|
|
87
|
+
"if TYPE_CHECKING:",
|
|
88
|
+
]
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
########################################################################################
|
|
92
|
+
#### MARK: Mypy
|
|
93
|
+
|
|
94
|
+
[tool.mypy]
|
|
95
|
+
ignore_missing_imports = true
|
statica-1.0.0/setup.cfg
ADDED
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from dataclasses import field as dataclass_field
|
|
5
|
+
from types import UnionType
|
|
6
|
+
from typing import (
|
|
7
|
+
TYPE_CHECKING,
|
|
8
|
+
Any,
|
|
9
|
+
Self,
|
|
10
|
+
TypeVar,
|
|
11
|
+
cast,
|
|
12
|
+
dataclass_transform,
|
|
13
|
+
get_args,
|
|
14
|
+
get_origin,
|
|
15
|
+
get_type_hints,
|
|
16
|
+
overload,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING:
|
|
20
|
+
from collections.abc import Callable, Mapping
|
|
21
|
+
|
|
22
|
+
T = TypeVar("T")
|
|
23
|
+
|
|
24
|
+
########################################################################################
|
|
25
|
+
#### MARK: Exceptions
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class ValidationError(Exception):
|
|
29
|
+
"""Base validation error."""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class TypeValidationError(ValidationError):
|
|
33
|
+
"""Raised when type validation fails."""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class ConstraintValidationError(ValidationError):
|
|
37
|
+
"""Raised when constraint validation fails."""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
########################################################################################
|
|
41
|
+
#### MARK: Internal functions
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _type_allows_none(expected_type: Any) -> bool:
|
|
45
|
+
"""
|
|
46
|
+
Check if the expected type allows None.
|
|
47
|
+
|
|
48
|
+
Examples:
|
|
49
|
+
.. code-block:: python
|
|
50
|
+
_allows_none(int | None) # True
|
|
51
|
+
_allows_none(int) # False
|
|
52
|
+
"""
|
|
53
|
+
if isinstance(expected_type, UnionType):
|
|
54
|
+
return type(None) in get_args(expected_type)
|
|
55
|
+
return expected_type is type(None) or expected_type is Any
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _value_matches_type(value: Any | None, expected_type: Any) -> bool:
|
|
59
|
+
"""
|
|
60
|
+
Check if the value matches the expected type.
|
|
61
|
+
Handles basic types, Union types, and generic types.
|
|
62
|
+
|
|
63
|
+
Examples:
|
|
64
|
+
.. code-block:: python
|
|
65
|
+
_value_matches_type(1, int) # True
|
|
66
|
+
_value_matches_type(None, int | None) # True
|
|
67
|
+
_value_matches_type(None, int) # False
|
|
68
|
+
"""
|
|
69
|
+
# If expected_type is e.g. int | None, pass if value is None
|
|
70
|
+
if _type_allows_none(expected_type) and value is None:
|
|
71
|
+
return True
|
|
72
|
+
|
|
73
|
+
# Basic types like int, str, etc.
|
|
74
|
+
if (origin := get_origin(expected_type)) is None:
|
|
75
|
+
return isinstance(value, expected_type)
|
|
76
|
+
|
|
77
|
+
# Handle Union types
|
|
78
|
+
if origin is UnionType or origin is type(None) or origin is Any:
|
|
79
|
+
types = get_args(expected_type)
|
|
80
|
+
return any(_value_matches_type(value, t) for t in types if t is not type(None))
|
|
81
|
+
|
|
82
|
+
# Handle generic types
|
|
83
|
+
return isinstance(value, origin)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _validate_type(value: Any, expected_type: type | UnionType) -> None:
|
|
87
|
+
"""
|
|
88
|
+
Validate that the value matches the expected type.
|
|
89
|
+
Throws TypeValidationError if the type does not match.
|
|
90
|
+
|
|
91
|
+
Examples:
|
|
92
|
+
.. code-block:: python
|
|
93
|
+
_validate_type(1, int) # No exception
|
|
94
|
+
_validate_type("abc", str) # No exception
|
|
95
|
+
_validate_type(1, str) # Raises TypeValidationError
|
|
96
|
+
_validate_type(None, int | None) # No exception
|
|
97
|
+
_validate_type(None, int) # Raises TypeValidationError
|
|
98
|
+
"""
|
|
99
|
+
if not _value_matches_type(value, expected_type):
|
|
100
|
+
expected_type_str = str(expected_type) if type(expected_type) is UnionType else expected_type.__name__
|
|
101
|
+
|
|
102
|
+
msg = f"expected type '{expected_type_str}', got '{type(value).__name__}'"
|
|
103
|
+
raise TypeValidationError(msg)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _get_expected_type(cls: type, attr_name: str) -> Any:
|
|
107
|
+
"""
|
|
108
|
+
Get the expected type for a class attribute.
|
|
109
|
+
Handles type hints and Field descriptors.
|
|
110
|
+
|
|
111
|
+
Examples:
|
|
112
|
+
.. code-block:: python
|
|
113
|
+
class MyClass(Statica):
|
|
114
|
+
age: int | None
|
|
115
|
+
name: str = Field()
|
|
116
|
+
|
|
117
|
+
_get_expected_type(MyClass, "age") # int | None
|
|
118
|
+
_get_expected_type(MyClass, "name") # str
|
|
119
|
+
"""
|
|
120
|
+
|
|
121
|
+
expected_type = get_type_hints(cls).get(attr_name, Any)
|
|
122
|
+
|
|
123
|
+
if get_origin(expected_type) is FieldDescriptor:
|
|
124
|
+
return get_args(expected_type)[0] # Get type from generic
|
|
125
|
+
|
|
126
|
+
return expected_type
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
########################################################################################
|
|
130
|
+
#### MARK: Field descriptor
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
@dataclass
|
|
134
|
+
class FieldDescriptor[T]:
|
|
135
|
+
"""
|
|
136
|
+
Descriptor for validated fields.
|
|
137
|
+
"""
|
|
138
|
+
|
|
139
|
+
name: str = dataclass_field(init=False, repr=False)
|
|
140
|
+
owner: type = dataclass_field(init=False, repr=False)
|
|
141
|
+
expected_type: type = dataclass_field(init=False, repr=False)
|
|
142
|
+
|
|
143
|
+
min_length: int | None = None
|
|
144
|
+
max_length: int | None = None
|
|
145
|
+
min_value: float | None = None
|
|
146
|
+
max_value: float | None = None
|
|
147
|
+
strip_whitespace: bool | None = None
|
|
148
|
+
cast_to: Callable[..., T] | None = None
|
|
149
|
+
|
|
150
|
+
def __set_name__(self, owner: Any, name: str) -> None:
|
|
151
|
+
self.name = name
|
|
152
|
+
self.owner = owner
|
|
153
|
+
self.expected_type = _get_expected_type(owner, name)
|
|
154
|
+
|
|
155
|
+
@overload
|
|
156
|
+
def __get__(self, instance: None, owner: Any) -> FieldDescriptor[T]: ...
|
|
157
|
+
|
|
158
|
+
@overload
|
|
159
|
+
def __get__(self, instance: object, owner: Any) -> T: ...
|
|
160
|
+
|
|
161
|
+
def __get__(self, instance: object | None, owner: Any) -> Any:
|
|
162
|
+
if instance is None:
|
|
163
|
+
return self # Accessed on the class, return the descriptor
|
|
164
|
+
return instance.__dict__.get(self.name)
|
|
165
|
+
|
|
166
|
+
def __set__(self, instance: object, value: T) -> None:
|
|
167
|
+
instance.__dict__[self.name] = self.validate(value)
|
|
168
|
+
|
|
169
|
+
def validate(self, value: Any) -> Any:
|
|
170
|
+
try:
|
|
171
|
+
if self.cast_to is not None:
|
|
172
|
+
value = self.cast_to(value)
|
|
173
|
+
|
|
174
|
+
_validate_type(value, self.expected_type)
|
|
175
|
+
|
|
176
|
+
if value is not None:
|
|
177
|
+
value = self._validate_constraints(value)
|
|
178
|
+
|
|
179
|
+
except TypeValidationError as e:
|
|
180
|
+
msg = f"{self.name}: {e!s}"
|
|
181
|
+
error_class = getattr(self.owner, "type_error_class", TypeValidationError)
|
|
182
|
+
raise error_class(msg) from e
|
|
183
|
+
except ConstraintValidationError as e:
|
|
184
|
+
msg = f"{self.name}: {e!s}"
|
|
185
|
+
error_class = getattr(self.owner, "constraint_error_class", ConstraintValidationError)
|
|
186
|
+
raise error_class(msg) from e
|
|
187
|
+
except ValueError as e:
|
|
188
|
+
msg = f"{self.name}: {e!s}"
|
|
189
|
+
raise TypeValidationError(msg) from e
|
|
190
|
+
return value
|
|
191
|
+
|
|
192
|
+
def _validate_constraints(self, value: Any) -> Any:
|
|
193
|
+
if self.strip_whitespace and isinstance(value, str):
|
|
194
|
+
value = value.strip()
|
|
195
|
+
|
|
196
|
+
if isinstance(value, str | list | tuple | dict):
|
|
197
|
+
if self.min_length is not None and len(value) < self.min_length:
|
|
198
|
+
msg = f"length must be at least {self.min_length}"
|
|
199
|
+
raise ConstraintValidationError(msg)
|
|
200
|
+
if self.max_length is not None and len(value) > self.max_length:
|
|
201
|
+
msg = f"length must be at most {self.max_length}"
|
|
202
|
+
raise ConstraintValidationError(msg)
|
|
203
|
+
|
|
204
|
+
if isinstance(value, int | float):
|
|
205
|
+
if self.min_value is not None and value < self.min_value:
|
|
206
|
+
msg = f"must be at least {self.min_value}"
|
|
207
|
+
raise ConstraintValidationError(msg)
|
|
208
|
+
if self.max_value is not None and value > self.max_value:
|
|
209
|
+
msg = f"must be at most {self.max_value}"
|
|
210
|
+
raise ConstraintValidationError(msg)
|
|
211
|
+
|
|
212
|
+
return value
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
########################################################################################
|
|
216
|
+
#### MARK: Type-safe field function
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def Field( # noqa: N802
|
|
220
|
+
*,
|
|
221
|
+
min_length: int | None = None,
|
|
222
|
+
max_length: int | None = None,
|
|
223
|
+
min_value: float | None = None,
|
|
224
|
+
max_value: float | None = None,
|
|
225
|
+
strip_whitespace: bool | None = None,
|
|
226
|
+
cast_to: Callable[..., T] | None = None,
|
|
227
|
+
) -> Any:
|
|
228
|
+
"""
|
|
229
|
+
Type-safe field function that returns the correct type for type checkers
|
|
230
|
+
but creates a Field descriptor at runtime.
|
|
231
|
+
"""
|
|
232
|
+
|
|
233
|
+
fd = FieldDescriptor(
|
|
234
|
+
min_length=min_length,
|
|
235
|
+
max_length=max_length,
|
|
236
|
+
min_value=min_value,
|
|
237
|
+
max_value=max_value,
|
|
238
|
+
strip_whitespace=strip_whitespace,
|
|
239
|
+
cast_to=cast_to,
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
if TYPE_CHECKING:
|
|
243
|
+
return cast(Any, fd)
|
|
244
|
+
|
|
245
|
+
return fd # type: ignore[unreachable]
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
########################################################################################
|
|
249
|
+
#### MARK: Internal metaclass
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
@dataclass_transform(kw_only_default=True)
|
|
253
|
+
class StaticaMeta(type):
|
|
254
|
+
def __new__(cls, name: str, bases: tuple, namespace: dict[str, Any]) -> type:
|
|
255
|
+
"""
|
|
256
|
+
Set up Field descriptors for each type-hinted attribute which does not have one
|
|
257
|
+
already, but only for subclasses of Statica.
|
|
258
|
+
"""
|
|
259
|
+
|
|
260
|
+
print(f"__new__({name=}, {bases=}, {namespace=})")
|
|
261
|
+
|
|
262
|
+
if name == "Statica":
|
|
263
|
+
return super().__new__(cls, name, bases, namespace)
|
|
264
|
+
|
|
265
|
+
annotations = namespace.get("__annotations__", {})
|
|
266
|
+
|
|
267
|
+
# Generate custom __init__ method
|
|
268
|
+
|
|
269
|
+
def custom_init(self: Statica, **kwargs: Any) -> None:
|
|
270
|
+
for field_name, field_type in annotations.items():
|
|
271
|
+
# If it is union type with none continuation, skip it
|
|
272
|
+
if get_origin(field_type) is UnionType and type(None) in get_args(field_type):
|
|
273
|
+
if field_name not in kwargs:
|
|
274
|
+
setattr(self, field_name, None)
|
|
275
|
+
continue
|
|
276
|
+
|
|
277
|
+
if field_name not in kwargs:
|
|
278
|
+
msg = f"Missing required field: {field_name}"
|
|
279
|
+
raise TypeValidationError(msg)
|
|
280
|
+
setattr(self, field_name, kwargs[field_name])
|
|
281
|
+
|
|
282
|
+
namespace["__init__"] = custom_init
|
|
283
|
+
|
|
284
|
+
# Set up Field descriptors for type-hinted attributes
|
|
285
|
+
|
|
286
|
+
for attr_annotated in namespace.get("__annotations__", {}):
|
|
287
|
+
existing_value = namespace.get(attr_annotated)
|
|
288
|
+
|
|
289
|
+
if isinstance(existing_value, FieldDescriptor):
|
|
290
|
+
# Case 1: name: Field[str] = Field(...) OR name: str = field(...)
|
|
291
|
+
# Both cases work - the Field is already there
|
|
292
|
+
continue
|
|
293
|
+
|
|
294
|
+
# Case 3: name: str (no assignment) or name: Field[str] (no assignment)
|
|
295
|
+
# Create a default Field descriptor
|
|
296
|
+
namespace[attr_annotated] = FieldDescriptor()
|
|
297
|
+
|
|
298
|
+
return super().__new__(cls, name, bases, namespace)
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
########################################################################################
|
|
302
|
+
#### MARK: Statica base class
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
class Statica(metaclass=StaticaMeta):
|
|
306
|
+
type_error_class: type[Exception] = TypeValidationError
|
|
307
|
+
constraint_error_class: type[Exception] = ConstraintValidationError
|
|
308
|
+
|
|
309
|
+
@classmethod
|
|
310
|
+
def from_map(cls, mapping: Mapping[str, Any]) -> Self:
|
|
311
|
+
instance = cls(**mapping)
|
|
312
|
+
|
|
313
|
+
# Go through type hints and set values
|
|
314
|
+
for attribute_name in get_type_hints(instance.__class__):
|
|
315
|
+
value = mapping.get(attribute_name)
|
|
316
|
+
setattr(instance, attribute_name, value) # Descriptor __set__ validates
|
|
317
|
+
|
|
318
|
+
return instance
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
########################################################################################
|
|
322
|
+
#### MARK: Main
|
|
323
|
+
|
|
324
|
+
if __name__ == "__main__":
|
|
325
|
+
|
|
326
|
+
class Payload(Statica):
|
|
327
|
+
type_error_class = ValueError
|
|
328
|
+
|
|
329
|
+
name: str = Field(min_length=3, max_length=50, strip_whitespace=True)
|
|
330
|
+
description: str | None = Field(max_length=200)
|
|
331
|
+
num: int | float
|
|
332
|
+
float_num: float | None
|
|
333
|
+
|
|
334
|
+
data = {
|
|
335
|
+
"name": "Test Payload",
|
|
336
|
+
"description": "ddf",
|
|
337
|
+
"num": 5,
|
|
338
|
+
"float_num": 5.5,
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
payload = Payload.from_map(data)
|
|
342
|
+
|
|
343
|
+
direct_init = Payload(
|
|
344
|
+
name="Test",
|
|
345
|
+
description="This is a test description.",
|
|
346
|
+
num=42,
|
|
347
|
+
float_num=3.14,
|
|
348
|
+
)
|
|
349
|
+
|
|
350
|
+
class ShortSyntax(Statica):
|
|
351
|
+
name: str = Field(min_length=3, max_length=5, strip_whitespace=True)
|
|
352
|
+
|
|
353
|
+
print("Testing ShortSyntax...")
|
|
354
|
+
short = ShortSyntax(name="test")
|
|
355
|
+
print(f"short.name = {short.name}")
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: statica
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Minimalistic validation library for Python 3.11+
|
|
5
|
+
Author-email: Marcel Kröker <kroeker.marcel@gmail.com>
|
|
6
|
+
License: MIT License
|
|
7
|
+
|
|
8
|
+
Copyright (c) 2025 mkrd
|
|
9
|
+
|
|
10
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
11
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
12
|
+
in the Software without restriction, including without limitation the rights
|
|
13
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
14
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
15
|
+
furnished to do so, subject to the following conditions:
|
|
16
|
+
|
|
17
|
+
The above copyright notice and this permission notice shall be included in all
|
|
18
|
+
copies or substantial portions of the Software.
|
|
19
|
+
|
|
20
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
21
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
22
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
23
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
24
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
25
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
26
|
+
SOFTWARE.
|
|
27
|
+
|
|
28
|
+
Classifier: Programming Language :: Python :: 3
|
|
29
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
30
|
+
Classifier: Operating System :: OS Independent
|
|
31
|
+
Classifier: Intended Audience :: Developers
|
|
32
|
+
Classifier: Programming Language :: Python
|
|
33
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
34
|
+
Requires-Python: <3.14,>=3.11
|
|
35
|
+
Description-Content-Type: text/markdown
|
|
36
|
+
|
|
37
|
+
# Statica
|
|
38
|
+
A minimalistic data validation library.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
statica
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import pytest
|
|
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
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def test_basic_syntax() -> None:
|
|
64
|
+
class RequiredStr(Statica):
|
|
65
|
+
name: str
|
|
66
|
+
|
|
67
|
+
i1 = RequiredStr.from_map({"name": "Test"})
|
|
68
|
+
assert i1.name == "Test"
|
|
69
|
+
|
|
70
|
+
i1 = RequiredStr(name="Test")
|
|
71
|
+
assert i1.name == "Test"
|
|
72
|
+
|
|
73
|
+
with pytest.raises(TypeValidationError):
|
|
74
|
+
RequiredStr.from_map({})
|
|
75
|
+
|
|
76
|
+
with pytest.raises(TypeValidationError):
|
|
77
|
+
RequiredStr.from_map({"name": 123})
|
|
78
|
+
|
|
79
|
+
class OptionalStr(Statica):
|
|
80
|
+
name: str | None
|
|
81
|
+
|
|
82
|
+
i2 = OptionalStr.from_map({"name": "Test"})
|
|
83
|
+
assert i2.name == "Test"
|
|
84
|
+
i2 = OptionalStr.from_map({"name": None})
|
|
85
|
+
assert i2.name is None
|
|
86
|
+
i2 = OptionalStr.from_map({})
|
|
87
|
+
assert i2.name is None
|
|
88
|
+
with pytest.raises(TypeValidationError):
|
|
89
|
+
OptionalStr.from_map({"name": 123})
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def test_different_field_syntax_variations() -> None:
|
|
93
|
+
class StringTestSyntax1(Statica):
|
|
94
|
+
name: str
|
|
95
|
+
|
|
96
|
+
class StringTestSyntax2(Statica):
|
|
97
|
+
name: str
|
|
98
|
+
|
|
99
|
+
class StringTestSyntax3(Statica):
|
|
100
|
+
name: str = Field()
|
|
101
|
+
|
|
102
|
+
for cls in (StringTestSyntax1, StringTestSyntax2, StringTestSyntax3):
|
|
103
|
+
instance = cls.from_map({"name": "Test"})
|
|
104
|
+
|
|
105
|
+
assert getattr(instance, "name", None) == "Test"
|
|
106
|
+
|
|
107
|
+
with pytest.raises(TypeValidationError):
|
|
108
|
+
cls.from_map({})
|
|
109
|
+
|
|
110
|
+
with pytest.raises(TypeValidationError):
|
|
111
|
+
cls.from_map({"name": 123})
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def test_required_string_field() -> None:
|
|
115
|
+
class StringTest(Statica):
|
|
116
|
+
name: str
|
|
117
|
+
|
|
118
|
+
instance = StringTest(name="Test")
|
|
119
|
+
assert instance.name == "Test"
|
|
120
|
+
|
|
121
|
+
instance = StringTest.from_map({"name": "Test"})
|
|
122
|
+
assert instance.name == "Test"
|
|
123
|
+
|
|
124
|
+
with pytest.raises(TypeValidationError):
|
|
125
|
+
StringTest.from_map({})
|
|
126
|
+
|
|
127
|
+
with pytest.raises(TypeValidationError):
|
|
128
|
+
StringTest.from_map({"name": 123})
|
|
129
|
+
|
|
130
|
+
with pytest.raises(TypeValidationError):
|
|
131
|
+
StringTest.from_map({"name": None})
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def test_optional_string_field() -> None:
|
|
135
|
+
class StringTest(Statica):
|
|
136
|
+
name: str | None
|
|
137
|
+
|
|
138
|
+
instance = StringTest.from_map({"name": "Test"})
|
|
139
|
+
assert instance.name == "Test"
|
|
140
|
+
|
|
141
|
+
instance = StringTest.from_map({"name": None})
|
|
142
|
+
assert instance.name is None
|
|
143
|
+
|
|
144
|
+
instance = StringTest.from_map({})
|
|
145
|
+
assert instance.name is None
|
|
146
|
+
|
|
147
|
+
with pytest.raises(TypeValidationError):
|
|
148
|
+
StringTest.from_map({"name": 123})
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def test_string_field_with_length_constraints() -> None:
|
|
152
|
+
class StringTest(Statica):
|
|
153
|
+
name: str = Field(min_length=3, max_length=5)
|
|
154
|
+
|
|
155
|
+
instance = StringTest.from_map({"name": "Test"})
|
|
156
|
+
assert instance.name == "Test"
|
|
157
|
+
|
|
158
|
+
with pytest.raises(ConstraintValidationError) as exc_info:
|
|
159
|
+
StringTest.from_map({"name": "Te"})
|
|
160
|
+
assert "length must be at least 3" in str(exc_info.value)
|
|
161
|
+
|
|
162
|
+
with pytest.raises(ConstraintValidationError) as exc_info:
|
|
163
|
+
StringTest.from_map({"name": "Testing"})
|
|
164
|
+
assert "length must be at most 5" in str(exc_info.value)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def test_string_field_with_whitespace_stripping() -> None:
|
|
168
|
+
class StringTest(Statica):
|
|
169
|
+
name: str = Field(strip_whitespace=True, max_length=4)
|
|
170
|
+
|
|
171
|
+
instance = StringTest.from_map({"name": " Test "})
|
|
172
|
+
assert instance.name == "Test"
|
|
173
|
+
|
|
174
|
+
with pytest.raises(ConstraintValidationError) as exc_info:
|
|
175
|
+
StringTest.from_map({"name": " TestLong "})
|
|
176
|
+
assert "length must be at most 4" in str(exc_info.value)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def test_integer_field_with_value_constraints() -> None:
|
|
180
|
+
class IntTest(Statica):
|
|
181
|
+
num: int = Field(min_value=1, max_value=10)
|
|
182
|
+
|
|
183
|
+
num = 5
|
|
184
|
+
|
|
185
|
+
instance = IntTest.from_map({"num": num})
|
|
186
|
+
assert instance.num == num
|
|
187
|
+
|
|
188
|
+
with pytest.raises(ConstraintValidationError) as exc_info:
|
|
189
|
+
IntTest.from_map({"num": 0})
|
|
190
|
+
assert "must be at least 1" in str(exc_info.value)
|
|
191
|
+
|
|
192
|
+
with pytest.raises(ConstraintValidationError) as exc_info:
|
|
193
|
+
IntTest.from_map({"num": 11})
|
|
194
|
+
assert "must be at most 10" in str(exc_info.value)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def test_custom_error_class() -> None:
|
|
198
|
+
class CustomError(Exception):
|
|
199
|
+
pass
|
|
200
|
+
|
|
201
|
+
class IntTest(Statica):
|
|
202
|
+
constraint_error_class = CustomError
|
|
203
|
+
|
|
204
|
+
num: int = Field(min_value=1, max_value=10)
|
|
205
|
+
|
|
206
|
+
with pytest.raises(CustomError) as exc_info:
|
|
207
|
+
IntTest.from_map({"num": 0})
|
|
208
|
+
assert "must be at least 1" in str(exc_info.value)
|
|
209
|
+
|
|
210
|
+
class FloatTest(Statica):
|
|
211
|
+
num: float = Field(min_value=1, max_value=10)
|
|
212
|
+
|
|
213
|
+
num_float = 5.5
|
|
214
|
+
|
|
215
|
+
instance = FloatTest.from_map({"num": num_float})
|
|
216
|
+
assert instance.num == num_float
|
|
217
|
+
|
|
218
|
+
with pytest.raises(TypeValidationError):
|
|
219
|
+
FloatTest.from_map({"num": "4"})
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def test_cast() -> None:
|
|
223
|
+
class IntTest(Statica):
|
|
224
|
+
num: int = Field(cast_to=int)
|
|
225
|
+
|
|
226
|
+
num_int = 5
|
|
227
|
+
num_str = str(num_int)
|
|
228
|
+
|
|
229
|
+
instance = IntTest.from_map({"num": num_str})
|
|
230
|
+
assert instance.num == num_int
|
|
231
|
+
|
|
232
|
+
with pytest.raises(TypeValidationError):
|
|
233
|
+
IntTest.from_map({"num": "5.5"})
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
if __name__ == "__main__":
|
|
237
|
+
pytest.main([__file__])
|