Skip to content

Commit c4af757

Browse files
authored
Add inherited dataclass annotation validation (#1)
1 parent 808d99e commit c4af757

6 files changed

Lines changed: 35 additions & 9 deletions

File tree

docs/source/conf.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
project = "python-introspect"
1717
copyright = "2025, Tristan Simas"
1818
author = "Tristan Simas"
19-
release = "0.1.6"
19+
release = "0.1.7"
2020
version = "0.1"
2121

2222
# -- General configuration ---------------------------------------------------

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "python-introspect"
7-
version = "0.1.6"
7+
version = "0.1.7"
88
description = "Pure Python introspection toolkit for function signatures, dataclasses, and type hints"
99
readme = "README.md"
1010
requires-python = ">=3.10"

src/python_introspect/__init__.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
type resolution for framework-specific types (lazy configs, proxies, etc.)
1010
"""
1111

12-
__version__ = "0.1.6"
12+
__version__ = "0.1.7"
1313

1414
from .signature_analyzer import (
1515
SignatureAnalyzer,
@@ -41,6 +41,7 @@
4141
mark_enableable,
4242
)
4343
from .validation import (
44+
AnnotatedDataclassValidationMixin,
4445
AnnotationValidationError,
4546
overlay_non_none_dataclass,
4647
validate_annotated_dataclass,
@@ -95,6 +96,7 @@
9596
"is_enableable",
9697
"mark_enableable",
9798
# Runtime annotation validation
99+
"AnnotatedDataclassValidationMixin",
98100
"AnnotationValidationError",
99101
"overlay_non_none_dataclass",
100102
"validate_annotated_dataclass",

src/python_introspect/validation.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,19 @@ class AnnotationValidationError(ValueError):
2727
DataclassT = TypeVar("DataclassT")
2828

2929

30+
class AnnotatedDataclassValidationMixin:
31+
"""Validate a dataclass instance from its resolved annotations after construction.
32+
33+
Dataclass declarations inherit this mixin when their annotations are the
34+
authoritative runtime schema. Keeping the lifecycle hook on a nominal base
35+
also preserves validation when another package recreates the dataclass with
36+
the same bases.
37+
"""
38+
39+
def __post_init__(self) -> None:
40+
validate_annotated_dataclass(self)
41+
42+
3043
def overlay_non_none_dataclass(
3144
base: DataclassT,
3245
overlay: object,

tests/test_init.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ def test_version_available(self):
1111
"""Test that __version__ is available."""
1212
assert hasattr(python_introspect, "__version__")
1313
assert isinstance(python_introspect.__version__, str)
14-
assert python_introspect.__version__ == "0.1.6"
14+
assert python_introspect.__version__ == "0.1.7"
1515

1616
def test_signature_analyzer_import(self):
1717
"""Test SignatureAnalyzer is importable."""

tests/test_validation.py

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
1-
from dataclasses import dataclass
1+
from dataclasses import dataclass, make_dataclass
22
from enum import Enum
33
from typing import Annotated, Literal
44

55
import pytest
66
from annotated_types import Ge, Gt, Le, MinLen, Predicate
77

88
from python_introspect import (
9+
AnnotatedDataclassValidationMixin,
910
overlay_non_none_dataclass,
1011
validate_annotated_dataclass,
1112
)
@@ -21,13 +22,10 @@ class Mode(Enum):
2122

2223

2324
@dataclass(frozen=True)
24-
class BaseConfig:
25+
class BaseConfig(AnnotatedDataclassValidationMixin):
2526
port: Port = 7000
2627
mode: Mode = Mode.FIRST
2728

28-
def __post_init__(self) -> None:
29-
validate_annotated_dataclass(self)
30-
3129

3230
@dataclass(frozen=True)
3331
class ChildConfig(BaseConfig):
@@ -53,6 +51,19 @@ def test_validation_rejects_wrong_nominal_types_without_coercion() -> None:
5351
ChildConfig(mode="first")
5452

5553

54+
def test_inherited_validation_survives_dataclass_recreation() -> None:
55+
RecreatedConfig = make_dataclass(
56+
"RecreatedConfig",
57+
(("port", Port, 7000),),
58+
bases=(AnnotatedDataclassValidationMixin,),
59+
frozen=True,
60+
)
61+
62+
assert RecreatedConfig(port=8000).port == 8000
63+
with pytest.raises(ValueError, match="port must be at least"):
64+
RecreatedConfig(port=0)
65+
66+
5667
@dataclass(frozen=True)
5768
class OptionalConfig:
5869
port: Port | None = None

0 commit comments

Comments
 (0)