Skip to content

Commit 747d93b

Browse files
Merge pull request #108 from miladmahmoodi/feat/type-safe-field-references
feat(models): add type-safe field name references for Pydantic DTOs
2 parents d4e4922 + 9c0c6d7 commit 747d93b

4 files changed

Lines changed: 264 additions & 6 deletions

File tree

archipy/models/dtos/base_dtos.py

Lines changed: 79 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,92 @@
11
from enum import Enum
2-
from typing import TypeVar
2+
from typing import Any, Self, TypeVar
33

44
from pydantic import BaseModel, ConfigDict
5+
from pydantic._internal._model_construction import ModelMetaclass
56

67
# Generic types
78
T = TypeVar("T", bound=Enum)
89

910

10-
class BaseDTO(BaseModel):
11+
class FieldStr(str):
12+
"""Type-safe field name reference string.
13+
14+
Allows referencing Pydantic model field names as class attributes
15+
instead of hardcoded strings, enabling IDE autocompletion and
16+
refactoring support.
17+
18+
Examples:
19+
>>> class UserDTO(BaseDTO):
20+
... name: str
21+
... email: str
22+
>>> UserDTO.name # returns FieldStr("name")
23+
'name'
24+
>>> UserDTO.name == "name"
25+
True
26+
"""
27+
28+
__slots__ = ("name",)
29+
30+
def __new__(cls, value: str) -> Self:
31+
"""Create a new FieldStr instance.
32+
33+
Args:
34+
value: The field name string.
35+
36+
Returns:
37+
FieldStr: A string subclass carrying the field name.
38+
"""
39+
obj = super().__new__(cls, value)
40+
obj.name = value
41+
return obj
42+
43+
44+
class BaseMeta(ModelMetaclass):
45+
"""Metaclass that adds FieldStr class attributes for each Pydantic model field.
46+
47+
After Pydantic's ModelMetaclass constructs the class and populates
48+
``model_fields``, this metaclass overwrites the corresponding class
49+
attributes with :class:`FieldStr` instances so that
50+
``MyDTO.field_name`` returns a type-safe string equal to ``"field_name"``.
51+
52+
Instance attribute access is unaffected because Python resolves
53+
instance ``__dict__`` entries before class attributes.
54+
"""
55+
56+
def __new__(mcs, name: str, bases: tuple[type, ...], namespace: dict[str, Any], **kwargs: Any) -> type: # noqa: ANN401
57+
"""Create a new class with FieldStr attributes for each model field.
58+
59+
Args:
60+
name: The class name.
61+
bases: The base classes.
62+
namespace: The class namespace.
63+
**kwargs: Additional keyword arguments forwarded to ModelMetaclass.
64+
65+
Returns:
66+
The newly created class with FieldStr attributes.
67+
"""
68+
cls = super().__new__(mcs, name, bases, namespace, **kwargs)
69+
for field_name in cls.model_fields: # ty:ignore[unresolved-attribute]
70+
setattr(cls, field_name, FieldStr(field_name))
71+
return cls
72+
73+
74+
class BaseDTO(BaseModel, metaclass=BaseMeta):
1175
"""Base Data Transfer Object class.
1276
13-
This class extends Pydantic's BaseModel to provide common configuration
14-
for all DTOs in the application.
77+
This class extends Pydantic's BaseModel with a custom metaclass that
78+
provides type-safe field name references. After class construction,
79+
each field name is accessible as a :class:`FieldStr` class attribute.
80+
81+
Examples:
82+
>>> class ProductDTO(BaseDTO):
83+
... title: str
84+
... price: float
85+
>>> ProductDTO.title # FieldStr("title")
86+
'title'
87+
>>> product = ProductDTO(title="Widget", price=9.99)
88+
>>> product.title # actual value
89+
'Widget'
1590
"""
1691

1792
model_config = ConfigDict(

archipy/models/dtos/range_dtos.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ def validate_range(self) -> Self:
4646
# The protocol ensures both values support comparison
4747
try:
4848
if self.from_ > self.to: # type: ignore[operator]
49-
raise OutOfRangeError(field_name="from_")
49+
raise OutOfRangeError(field_name=type(self).from_) # type: ignore[arg-type]
5050
except TypeError:
5151
# If comparison fails, skip validation (shouldn't happen with proper types)
5252
pass
@@ -175,7 +175,7 @@ def validate_interval_constraints(self) -> Self:
175175
if max_to_age:
176176
age_threshold = current_time - max_to_age
177177
if self.to < age_threshold:
178-
raise OutOfRangeError(field_name="to")
178+
raise OutOfRangeError(field_name=type(self).to) # type: ignore[arg-type]
179179

180180
# Calculate number of intervals
181181
step = self.INTERVAL_TO_TIMEDELTA[self.interval]

features/base_dtos.feature

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
Feature: Type-Safe Field Name References for DTOs
2+
3+
Scenario: FieldStr class attributes exist on BaseDTO subclasses
4+
Given a DTO subclass with fields "name" and "email"
5+
When I access the field names as class attributes
6+
Then each attribute should be a FieldStr instance
7+
And each attribute value should match its field name
8+
9+
Scenario: Instance attribute access returns actual values
10+
Given a DTO subclass with fields "name" and "email"
11+
When I create an instance with name "Alice" and email "alice@example.com"
12+
Then instance attribute "name" should return "Alice"
13+
And instance attribute "email" should return "alice@example.com"
14+
And class attribute "name" should still return FieldStr "name"
15+
16+
Scenario: FieldStr works as dictionary key
17+
Given a DTO subclass with fields "name" and "email"
18+
When I use a FieldStr class attribute as a dictionary key
19+
Then the dictionary should be accessible with the equivalent plain string
20+
21+
Scenario: FieldStr works in string comparisons
22+
Given a DTO subclass with fields "name" and "email"
23+
When I compare the FieldStr class attribute to a plain string
24+
Then the comparison should return true for matching strings
25+
And the comparison should return false for non-matching strings
26+
27+
Scenario: Inherited DTOs get FieldStr attributes
28+
Given a parent DTO with field "base_field" and a child DTO with field "child_field"
29+
When I access field names on the child class
30+
Then the child should have FieldStr for "base_field"
31+
And the child should have FieldStr for "child_field"
32+
33+
Scenario: Existing PaginationDTO has FieldStr attributes
34+
Given the PaginationDTO class
35+
When I access its field name class attributes
36+
Then "page" should be a FieldStr with value "page"
37+
And "page_size" should be a FieldStr with value "page_size"

features/steps/base_dtos_steps.py

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
from behave import given, then, when
2+
from features.test_helpers import get_current_scenario_context
3+
4+
from archipy.models.dtos.base_dtos import BaseDTO, FieldStr
5+
from archipy.models.dtos.pagination_dto import PaginationDTO
6+
7+
8+
@given('a DTO subclass with fields "name" and "email"')
9+
def step_given_dto_subclass(context):
10+
scenario_context = get_current_scenario_context(context)
11+
12+
class SampleDTO(BaseDTO):
13+
name: str
14+
email: str
15+
16+
scenario_context.store("dto_class", SampleDTO)
17+
18+
19+
@when("I access the field names as class attributes")
20+
def step_when_access_field_names(context):
21+
scenario_context = get_current_scenario_context(context)
22+
dto_class = scenario_context.get("dto_class")
23+
scenario_context.store("name_attr", dto_class.name)
24+
scenario_context.store("email_attr", dto_class.email)
25+
26+
27+
@then("each attribute should be a FieldStr instance")
28+
def step_then_attributes_are_fieldstr(context):
29+
scenario_context = get_current_scenario_context(context)
30+
assert isinstance(scenario_context.get("name_attr"), FieldStr)
31+
assert isinstance(scenario_context.get("email_attr"), FieldStr)
32+
33+
34+
@then("each attribute value should match its field name")
35+
def step_then_values_match_field_names(context):
36+
scenario_context = get_current_scenario_context(context)
37+
assert scenario_context.get("name_attr") == "name"
38+
assert scenario_context.get("email_attr") == "email"
39+
40+
41+
@when('I create an instance with name "{name}" and email "{email}"')
42+
def step_when_create_instance(context, name, email):
43+
scenario_context = get_current_scenario_context(context)
44+
dto_class = scenario_context.get("dto_class")
45+
instance = dto_class(name=name, email=email)
46+
scenario_context.store("instance", instance)
47+
48+
49+
@then('instance attribute "{attr}" should return "{expected}"')
50+
def step_then_instance_attr_returns(context, attr, expected):
51+
scenario_context = get_current_scenario_context(context)
52+
instance = scenario_context.get("instance")
53+
assert getattr(instance, attr) == expected
54+
55+
56+
@then('class attribute "{attr}" should still return FieldStr "{expected}"')
57+
def step_then_class_attr_still_fieldstr(context, attr, expected):
58+
scenario_context = get_current_scenario_context(context)
59+
dto_class = scenario_context.get("dto_class")
60+
class_attr = getattr(dto_class, attr)
61+
assert isinstance(class_attr, FieldStr)
62+
assert class_attr == expected
63+
64+
65+
@when("I use a FieldStr class attribute as a dictionary key")
66+
def step_when_use_fieldstr_as_dict_key(context):
67+
scenario_context = get_current_scenario_context(context)
68+
dto_class = scenario_context.get("dto_class")
69+
test_dict = {dto_class.name: "Alice", dto_class.email: "alice@example.com"}
70+
scenario_context.store("test_dict", test_dict)
71+
72+
73+
@then("the dictionary should be accessible with the equivalent plain string")
74+
def step_then_dict_accessible_with_plain_string(context):
75+
scenario_context = get_current_scenario_context(context)
76+
test_dict = scenario_context.get("test_dict")
77+
assert test_dict["name"] == "Alice"
78+
assert test_dict["email"] == "alice@example.com"
79+
80+
81+
@when("I compare the FieldStr class attribute to a plain string")
82+
def step_when_compare_fieldstr(context):
83+
scenario_context = get_current_scenario_context(context)
84+
dto_class = scenario_context.get("dto_class")
85+
scenario_context.store("match_result", dto_class.name == "name")
86+
scenario_context.store("no_match_result", dto_class.name == "other")
87+
88+
89+
@then("the comparison should return true for matching strings")
90+
def step_then_comparison_true(context):
91+
scenario_context = get_current_scenario_context(context)
92+
assert scenario_context.get("match_result") is True
93+
94+
95+
@then("the comparison should return false for non-matching strings")
96+
def step_then_comparison_false(context):
97+
scenario_context = get_current_scenario_context(context)
98+
assert scenario_context.get("no_match_result") is False
99+
100+
101+
@given('a parent DTO with field "base_field" and a child DTO with field "child_field"')
102+
def step_given_parent_child_dto(context):
103+
scenario_context = get_current_scenario_context(context)
104+
105+
class ParentDTO(BaseDTO):
106+
base_field: str
107+
108+
class ChildDTO(ParentDTO):
109+
child_field: str
110+
111+
scenario_context.store("parent_class", ParentDTO)
112+
scenario_context.store("child_class", ChildDTO)
113+
114+
115+
@when("I access field names on the child class")
116+
def step_when_access_child_fields(context):
117+
pass
118+
119+
120+
@then('the child should have FieldStr for "{field_name}"')
121+
def step_then_child_has_fieldstr(context, field_name):
122+
scenario_context = get_current_scenario_context(context)
123+
child_class = scenario_context.get("child_class")
124+
attr = getattr(child_class, field_name)
125+
assert isinstance(attr, FieldStr)
126+
assert attr == field_name
127+
128+
129+
@given("the PaginationDTO class")
130+
def step_given_pagination_dto(context):
131+
scenario_context = get_current_scenario_context(context)
132+
scenario_context.store("pagination_class", PaginationDTO)
133+
134+
135+
@when("I access its field name class attributes")
136+
def step_when_access_pagination_fields(context):
137+
pass
138+
139+
140+
@then('"{field_name}" should be a FieldStr with value "{expected}"')
141+
def step_then_field_is_fieldstr_with_value(context, field_name, expected):
142+
scenario_context = get_current_scenario_context(context)
143+
pagination_class = scenario_context.get("pagination_class")
144+
attr = getattr(pagination_class, field_name)
145+
assert isinstance(attr, FieldStr), f"Expected FieldStr but got {type(attr).__name__}"
146+
assert attr == expected, f"Expected '{expected}' but got '{attr}'"

0 commit comments

Comments
 (0)