From 32e6bedf406be1b942d435d5df75faf26ca30569 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 30 Jul 2026 13:20:52 -0400 Subject: [PATCH] Add inherited dataclass annotation validation --- docs/source/conf.py | 2 +- pyproject.toml | 2 +- src/python_introspect/__init__.py | 4 +++- src/python_introspect/validation.py | 13 +++++++++++++ tests/test_init.py | 2 +- tests/test_validation.py | 21 ++++++++++++++++----- 6 files changed, 35 insertions(+), 9 deletions(-) diff --git a/docs/source/conf.py b/docs/source/conf.py index 6c0e69b..350d913 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -16,7 +16,7 @@ project = "python-introspect" copyright = "2025, Tristan Simas" author = "Tristan Simas" -release = "0.1.6" +release = "0.1.7" version = "0.1" # -- General configuration --------------------------------------------------- diff --git a/pyproject.toml b/pyproject.toml index 05cdd60..8062fdd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "python-introspect" -version = "0.1.6" +version = "0.1.7" description = "Pure Python introspection toolkit for function signatures, dataclasses, and type hints" readme = "README.md" requires-python = ">=3.10" diff --git a/src/python_introspect/__init__.py b/src/python_introspect/__init__.py index 8b2380b..8b5d93f 100644 --- a/src/python_introspect/__init__.py +++ b/src/python_introspect/__init__.py @@ -9,7 +9,7 @@ type resolution for framework-specific types (lazy configs, proxies, etc.) """ -__version__ = "0.1.6" +__version__ = "0.1.7" from .signature_analyzer import ( SignatureAnalyzer, @@ -41,6 +41,7 @@ mark_enableable, ) from .validation import ( + AnnotatedDataclassValidationMixin, AnnotationValidationError, overlay_non_none_dataclass, validate_annotated_dataclass, @@ -95,6 +96,7 @@ "is_enableable", "mark_enableable", # Runtime annotation validation + "AnnotatedDataclassValidationMixin", "AnnotationValidationError", "overlay_non_none_dataclass", "validate_annotated_dataclass", diff --git a/src/python_introspect/validation.py b/src/python_introspect/validation.py index e933770..4587bf8 100644 --- a/src/python_introspect/validation.py +++ b/src/python_introspect/validation.py @@ -27,6 +27,19 @@ class AnnotationValidationError(ValueError): DataclassT = TypeVar("DataclassT") +class AnnotatedDataclassValidationMixin: + """Validate a dataclass instance from its resolved annotations after construction. + + Dataclass declarations inherit this mixin when their annotations are the + authoritative runtime schema. Keeping the lifecycle hook on a nominal base + also preserves validation when another package recreates the dataclass with + the same bases. + """ + + def __post_init__(self) -> None: + validate_annotated_dataclass(self) + + def overlay_non_none_dataclass( base: DataclassT, overlay: object, diff --git a/tests/test_init.py b/tests/test_init.py index 4fb4f7b..21c5f7e 100644 --- a/tests/test_init.py +++ b/tests/test_init.py @@ -11,7 +11,7 @@ def test_version_available(self): """Test that __version__ is available.""" assert hasattr(python_introspect, "__version__") assert isinstance(python_introspect.__version__, str) - assert python_introspect.__version__ == "0.1.6" + assert python_introspect.__version__ == "0.1.7" def test_signature_analyzer_import(self): """Test SignatureAnalyzer is importable.""" diff --git a/tests/test_validation.py b/tests/test_validation.py index 69b2542..453b1fb 100644 --- a/tests/test_validation.py +++ b/tests/test_validation.py @@ -1,4 +1,4 @@ -from dataclasses import dataclass +from dataclasses import dataclass, make_dataclass from enum import Enum from typing import Annotated, Literal @@ -6,6 +6,7 @@ from annotated_types import Ge, Gt, Le, MinLen, Predicate from python_introspect import ( + AnnotatedDataclassValidationMixin, overlay_non_none_dataclass, validate_annotated_dataclass, ) @@ -21,13 +22,10 @@ class Mode(Enum): @dataclass(frozen=True) -class BaseConfig: +class BaseConfig(AnnotatedDataclassValidationMixin): port: Port = 7000 mode: Mode = Mode.FIRST - def __post_init__(self) -> None: - validate_annotated_dataclass(self) - @dataclass(frozen=True) class ChildConfig(BaseConfig): @@ -53,6 +51,19 @@ def test_validation_rejects_wrong_nominal_types_without_coercion() -> None: ChildConfig(mode="first") +def test_inherited_validation_survives_dataclass_recreation() -> None: + RecreatedConfig = make_dataclass( + "RecreatedConfig", + (("port", Port, 7000),), + bases=(AnnotatedDataclassValidationMixin,), + frozen=True, + ) + + assert RecreatedConfig(port=8000).port == 8000 + with pytest.raises(ValueError, match="port must be at least"): + RecreatedConfig(port=0) + + @dataclass(frozen=True) class OptionalConfig: port: Port | None = None