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 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.
@@ -0,0 +1,2 @@
1
+ # Statica
2
+ A minimalistic data validation library.
@@ -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
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,9 @@
1
+ from core import ConstraintValidationError, Field, Statica, TypeValidationError, ValidationError
2
+
3
+ __all__ = [
4
+ "ConstraintValidationError",
5
+ "Field",
6
+ "Statica",
7
+ "TypeValidationError",
8
+ "ValidationError",
9
+ ]
@@ -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,10 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ statica/__init__.py
5
+ statica/core.py
6
+ statica.egg-info/PKG-INFO
7
+ statica.egg-info/SOURCES.txt
8
+ statica.egg-info/dependency_links.txt
9
+ statica.egg-info/top_level.txt
10
+ tests/test_statica.py
@@ -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__])