statica 1.0.0__py3-none-any.whl
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/__init__.py +9 -0
- statica/core.py +355 -0
- statica-1.0.0.dist-info/METADATA +38 -0
- statica-1.0.0.dist-info/RECORD +6 -0
- statica-1.0.0.dist-info/WHEEL +5 -0
- statica-1.0.0.dist-info/top_level.txt +1 -0
statica/__init__.py
ADDED
statica/core.py
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,6 @@
|
|
|
1
|
+
statica/__init__.py,sha256=rGZ31Fq5HVNeEV8vJtccI5rfI6dKNDa-VnOByL0SYzg,208
|
|
2
|
+
statica/core.py,sha256=AivwJvufDuabhur1nwngluUpxD7yHJwutXysnxR47hA,10126
|
|
3
|
+
statica-1.0.0.dist-info/METADATA,sha256=0LIBmRgZiRUxTHNXpnGCXQ4QEI-tPNt7O-viV0lHnX4,1831
|
|
4
|
+
statica-1.0.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
5
|
+
statica-1.0.0.dist-info/top_level.txt,sha256=SP5DfdcupL3LMALxUSeSoi7wCOYLp131_qXi7fdSa6Y,8
|
|
6
|
+
statica-1.0.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
statica
|