From e1f4676a77ff26fcab0fe5afdbdd30e9cdea1381 Mon Sep 17 00:00:00 2001 From: Peng Ding Date: Thu, 16 Jul 2026 10:04:12 +0700 Subject: [PATCH 1/3] feat(validate): accept dataclass instances as input to validate() Previously validate() required dict input even when the schema type was a dataclass. Now dataclass instances are auto-converted via vars() at each nesting level, enabling recursive validation of nested dataclass trees and cross-type validation (DC instance against TypedDict schema). --- validate/test_validate_correctness.py | 73 +++++++++++++++++++++++++++ validate/validate.py | 2 + 2 files changed, 75 insertions(+) diff --git a/validate/test_validate_correctness.py b/validate/test_validate_correctness.py index 2ba9703..6ff1543 100644 --- a/validate/test_validate_correctness.py +++ b/validate/test_validate_correctness.py @@ -293,6 +293,79 @@ def test_with_default(self): # z has a default, so it's optional assert validate({"x": 1.0, "y": 2.0}, PointWithDefault) == {"x": 1.0, "y": 2.0} + def test_instance_flat(self): + p = Point(x=1.0, y=2.0) + result = validate(p, Point) + assert result == {"x": 1.0, "y": 2.0} + + def test_instance_invalid_field(self): + p = Point(x="bad", y=2.0) # type: ignore[arg-type] + with pytest.raises(ValidationError) as exc_info: + validate(p, Point) + assert any("x" in e.path for e in exc_info.value.errors) + + def test_instance_nested(self): + @dataclasses.dataclass + class Inner: + value: int + + @dataclasses.dataclass + class Outer: + name: str + inner: Inner + + obj = Outer(name="test", inner=Inner(value=42)) + result = validate(obj, Outer) + assert result["name"] == "test" + # vars() is shallow: nested DC instances stay as-is in the returned dict + assert isinstance(result["inner"], Inner) + assert result["inner"].value == 42 + + def test_instance_nested_invalid(self): + @dataclasses.dataclass + class Inner: + value: int + + @dataclasses.dataclass + class Outer: + name: str + inner: Inner + + obj = Outer(name="test", inner=Inner(value="bad")) # type: ignore[arg-type] + with pytest.raises(ValidationError) as exc_info: + validate(obj, Outer) + assert any("inner.value" in e.path for e in exc_info.value.errors) + + def test_instance_against_typeddict(self): + """DC instance validated against a mismatched TypedDict fails.""" + p = Point(x=1.0, y=2.0) + with pytest.raises(ValidationError): + validate(p, SimpleUser) # Point has x,y but SimpleUser requires name,age + + def test_instance_cross_schema(self): + """DC instance validated against a TypedDict with matching fields.""" + + class PointTD(TypedDict): + x: float + y: float + + p = Point(x=1.0, y=2.0) + result = validate(p, PointTD) + assert result == {"x": 1.0, "y": 2.0} + + def test_instance_with_default_fields(self): + p = PointWithDefault(x=1.0, y=2.0) + result = validate(p, PointWithDefault) + assert result == {"x": 1.0, "y": 2.0, "z": 0.0} + + def test_instance_in_list(self): + points = [Point(x=1.0, y=2.0), Point(x=3.0, y=4.0)] + result = validate(points, list[Point]) + assert len(result) == 2 + # validate doesn't replace list items; originals stay as DC instances + assert isinstance(result[0], Point) + assert result[0].x == 1.0 + # ── Annotated Constraints ── diff --git a/validate/validate.py b/validate/validate.py index 710a699..b894b0d 100644 --- a/validate/validate.py +++ b/validate/validate.py @@ -740,6 +740,8 @@ def _validate_struct_fields( fields: dict[str, tuple[Any, bool]], ) -> Any: """Validate a struct-like type (TypedDict or dataclass) against its fields.""" + if dataclasses.is_dataclass(value) and not isinstance(value, type): + value = vars(value) if not isinstance(value, dict): errors.append( ErrorDetail( From 3cb8abd012f56cfbbae5b54f49ef8d143bd2094d Mon Sep 17 00:00:00 2001 From: Peng Ding Date: Thu, 16 Jul 2026 10:30:23 +0700 Subject: [PATCH 2/3] fix(validate): use dataclasses.fields() instead of vars() for instance conversion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses review feedback: 1. vars() returns __dict__ reference — mutating the returned dict would alter the original instance. Now builds a fresh dict via {f.name: getattr(value, f.name) for f in dataclasses.fields(value)}. 2. dataclass(slots=True) removes __dict__, causing vars() to raise TypeError. dataclasses.fields() + getattr() works with both regular and slotted dataclasses. Adds tests for slots=True support and mutation safety. --- validate/test_validate_correctness.py | 19 +++++++++++++++++++ validate/validate.py | 2 +- 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/validate/test_validate_correctness.py b/validate/test_validate_correctness.py index 6ff1543..2f86a38 100644 --- a/validate/test_validate_correctness.py +++ b/validate/test_validate_correctness.py @@ -366,6 +366,25 @@ def test_instance_in_list(self): assert isinstance(result[0], Point) assert result[0].x == 1.0 + def test_instance_slots(self): + """dataclass(slots=True) instances work (no __dict__).""" + + @dataclasses.dataclass(slots=True) + class SlottedPoint: + x: float + y: float + + p = SlottedPoint(x=1.0, y=2.0) + result = validate(p, SlottedPoint) + assert result == {"x": 1.0, "y": 2.0} + + def test_instance_no_mutation(self): + """Modifying the returned dict must not alter the original instance.""" + p = Point(x=1.0, y=2.0) + result = validate(p, Point) + result["x"] = 999.0 + assert p.x == 1.0 + # ── Annotated Constraints ── diff --git a/validate/validate.py b/validate/validate.py index b894b0d..345ae21 100644 --- a/validate/validate.py +++ b/validate/validate.py @@ -741,7 +741,7 @@ def _validate_struct_fields( ) -> Any: """Validate a struct-like type (TypedDict or dataclass) against its fields.""" if dataclasses.is_dataclass(value) and not isinstance(value, type): - value = vars(value) + value = {f.name: getattr(value, f.name) for f in dataclasses.fields(value)} if not isinstance(value, dict): errors.append( ErrorDetail( From 66da4ae416ef84dff25052c16b16890d7ba9e596 Mon Sep 17 00:00:00 2001 From: Peng Ding Date: Thu, 16 Jul 2026 12:58:01 +0700 Subject: [PATCH 3/3] style: fix stale vars() comment in test per review --- validate/test_validate_correctness.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/validate/test_validate_correctness.py b/validate/test_validate_correctness.py index 2f86a38..3dc08f2 100644 --- a/validate/test_validate_correctness.py +++ b/validate/test_validate_correctness.py @@ -317,7 +317,7 @@ class Outer: obj = Outer(name="test", inner=Inner(value=42)) result = validate(obj, Outer) assert result["name"] == "test" - # vars() is shallow: nested DC instances stay as-is in the returned dict + # Conversion is shallow: nested DC instances stay as-is in the returned dict assert isinstance(result["inner"], Inner) assert result["inner"].value == 42