From 579f856cd830c4d77559d9fc8f66710ea276a567 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcel=20Kr=C3=B6ker?= Date: Sun, 17 Aug 2025 14:40:51 +0200 Subject: [PATCH 1/4] add literal validation --- statica/validation.py | 55 +++++++++++++++++++++++++++++++++++++++- tests/test_validation.py | 38 +++++++++++++++++++++++++++ 2 files changed, 92 insertions(+), 1 deletion(-) diff --git a/statica/validation.py b/statica/validation.py index 7b43cb1..352e905 100644 --- a/statica/validation.py +++ b/statica/validation.py @@ -16,7 +16,11 @@ from __future__ import annotations from types import GenericAlias, UnionType -from typing import Any +from typing import ( + Any, + _LiteralGenericAlias, # type: ignore[attr-defined] + _UnionGenericAlias, # type: ignore[attr-defined] +) from statica.config import StaticaConfig, default_config from statica.exceptions import ConstraintValidationError, TypeValidationError @@ -41,12 +45,24 @@ def validate_or_raise( validate_type_union(value, expected_type, config) return + # Handle union generic aliases + + if isinstance(expected_type, _UnionGenericAlias): + validate_type_union_generic_alias(value, expected_type, config) + return + # Handle generic aliases if isinstance(expected_type, GenericAlias): validate_type_generic_alias(value, expected_type, config) return + # Handle Literal (e.g. Literal["a", "b"], with any number and type of values) + + if isinstance(expected_type, _LiteralGenericAlias): + validate_literal(value, expected_type) + return + # Handle all other types if isinstance(value, expected_type): @@ -59,6 +75,19 @@ def validate_or_raise( raise TypeValidationError(msg) +def validate_literal( + value: Any, + expected_type: _LiteralGenericAlias, +) -> None: + """ + Validate that the value matches one of the literals in the expected_type. + Throws TypeValidationError if the value is not one of the literals. + """ + if value not in expected_type.__args__: + msg = f"expected one of {expected_type.__args__}, got '{value}'" + raise TypeValidationError(msg) + + def validate_type_union( value: Any, expected_type: UnionType, @@ -83,6 +112,30 @@ def validate_type_union( raise TypeValidationError(msg) +def validate_type_union_generic_alias( + value: Any, + expected_type: _UnionGenericAlias, + config: StaticaConfig = default_config, +) -> None: + """ + Validate that the value matches one of the types in the UnionGenericAlias. + Throws TypeValidationError if the type does not match any of the union types. + """ + for sub_type in expected_type.__args__: + try: + validate_or_raise(value, sub_type, config) + except TypeValidationError: + continue # Try the next sub-type + else: + return # Exit if one of the sub-types matches + + msg = config.type_error_message.format( + expected_type=expected_type.__args__, + found_type=type(value).__name__, + ) + raise TypeValidationError(msg) + + def validate_type_generic_alias( value: Any, expected_type: GenericAlias, diff --git a/tests/test_validation.py b/tests/test_validation.py index 5236117..1ee5084 100644 --- a/tests/test_validation.py +++ b/tests/test_validation.py @@ -1,3 +1,5 @@ +from typing import Literal + import pytest from statica import Field, Statica, TypeValidationError @@ -98,3 +100,39 @@ class UnsupportedGeneric(Statica): with pytest.raises(TypeValidationError): UnsupportedGeneric(data=frozenset([1, 2, 3])) + + +def test_validate_literal() -> None: + class LiteralTest(Statica): + data: Literal["a", "b", "c"] + number: Literal[1, 2, 3] + + i1 = LiteralTest.from_map({"data": "a", "number": 1}) + assert i1.data == "a" + assert i1.number == 1 + + with pytest.raises(TypeValidationError): + LiteralTest.from_map({"data": "d", "number": 1}) + + with pytest.raises(TypeValidationError): + LiteralTest.from_map({"data": "a", "number": 4}) + + +def test_validate_literal_optional() -> None: + class LiteralTest(Statica): + data: Literal["a", "b", "c"] | None + number: Literal[1, 2, 3] | None + + i1 = LiteralTest.from_map({"data": "a", "number": 1}) + assert i1.data == "a" + assert i1.number == 1 + + i2 = LiteralTest.from_map({"data": None, "number": None}) + assert i2.data is None + assert i2.number is None + + with pytest.raises(TypeValidationError): + LiteralTest.from_map({"data": "d", "number": 1}) + + with pytest.raises(TypeValidationError): + LiteralTest.from_map({"data": "a", "number": 4}) From 9774eda95b6d7712527182ec42174378cf62b68f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcel=20Kr=C3=B6ker?= Date: Sun, 17 Aug 2025 15:47:25 +0200 Subject: [PATCH 2/4] avoid private import --- statica/validation.py | 58 +++++++++++++++++++++++++++++-------------- 1 file changed, 40 insertions(+), 18 deletions(-) diff --git a/statica/validation.py b/statica/validation.py index 352e905..607c9d0 100644 --- a/statica/validation.py +++ b/statica/validation.py @@ -16,15 +16,37 @@ from __future__ import annotations from types import GenericAlias, UnionType -from typing import ( - Any, - _LiteralGenericAlias, # type: ignore[attr-defined] - _UnionGenericAlias, # type: ignore[attr-defined] -) +from typing import Any, Literal, TypeGuard, Union from statica.config import StaticaConfig, default_config from statica.exceptions import ConstraintValidationError, TypeValidationError +######################################################################################## +#### MARK: Types + + +class LiteralGenericAlias: + """A type used in place of typing._LiteralGenericAlias to avoid private imports.""" + + __origin__ = Literal + __args__: tuple[Any, ...] + + +def is_literal_generic_alias(expected_type: Any) -> TypeGuard[LiteralGenericAlias]: + return hasattr(expected_type, "__origin__") and expected_type.__origin__ is Literal + + +class UnionGenericAlias: + """A type used in place of typing._UnionGenericAlias to avoid private imports.""" + + __origin__ = Union + __args__: tuple[Any, ...] + + +def is_union_generic_alias(expected_type: Any) -> TypeGuard[UnionGenericAlias]: + return hasattr(expected_type, "__origin__") and expected_type.__origin__ is Union + + ######################################################################################## #### MARK: Type Validation @@ -39,28 +61,28 @@ def validate_or_raise( are already initialized Statica objects. """ - # Handle union types + # Handle generic aliases if native python types, e.g. list[int], dict[str, int] - if isinstance(expected_type, UnionType): - validate_type_union(value, expected_type, config) + if isinstance(expected_type, GenericAlias): + validate_type_generic_alias(value, expected_type, config) return - # Handle union generic aliases + # Handle parameterized generic types - if isinstance(expected_type, _UnionGenericAlias): + if is_union_generic_alias(expected_type): validate_type_union_generic_alias(value, expected_type, config) return - # Handle generic aliases + # Handle Literal (e.g. Literal["a", "b"], with any number and type of values) - if isinstance(expected_type, GenericAlias): - validate_type_generic_alias(value, expected_type, config) + if is_literal_generic_alias(expected_type): + validate_literal(value, expected_type) return - # Handle Literal (e.g. Literal["a", "b"], with any number and type of values) + # Handle union types - if isinstance(expected_type, _LiteralGenericAlias): - validate_literal(value, expected_type) + if isinstance(expected_type, UnionType): + validate_type_union(value, expected_type, config) return # Handle all other types @@ -77,7 +99,7 @@ def validate_or_raise( def validate_literal( value: Any, - expected_type: _LiteralGenericAlias, + expected_type: LiteralGenericAlias, ) -> None: """ Validate that the value matches one of the literals in the expected_type. @@ -114,7 +136,7 @@ def validate_type_union( def validate_type_union_generic_alias( value: Any, - expected_type: _UnionGenericAlias, + expected_type: UnionGenericAlias, config: StaticaConfig = default_config, ) -> None: """ From fa79ef713c6da8a18c35807d52b8adf7ae5a7718 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sun, 17 Aug 2025 13:47:56 +0000 Subject: [PATCH 3/4] Updated assets/coverage.svg --- assets/coverage.svg | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/assets/coverage.svg b/assets/coverage.svg index 6bfc8fa..e5db27c 100644 --- a/assets/coverage.svg +++ b/assets/coverage.svg @@ -15,7 +15,7 @@ coverage coverage - 99% - 99% + 100% + 100% From e9a4f2a531981420a45072061ed555a3cb36a30a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcel=20Kr=C3=B6ker?= Date: Sun, 17 Aug 2025 15:51:18 +0200 Subject: [PATCH 4/4] update docstring --- statica/validation.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/statica/validation.py b/statica/validation.py index 607c9d0..f623aac 100644 --- a/statica/validation.py +++ b/statica/validation.py @@ -1,6 +1,16 @@ """ The backus naur grammar for types is as follows: -T ::= Statica | int | float | str | None | (T1 | T2) | list[T] | set[T] | dict[T1, T2] +T ::= Statica + | int + | float + | str + | None + | (T1 | T2) + | list[T] + | set[T] + | dict[T1, T2] + | Literal[V1, ...] + Where: - Statica: A class that inherits from Statica