From 9c0c6d7c47f2cced67b4e0d987d4394318828990 Mon Sep 17 00:00:00 2001 From: MiladMahmoodi Date: Sun, 22 Feb 2026 21:07:26 +0330 Subject: [PATCH] feat(models): add type-safe field name references for Pydantic DTOs Add FieldStr utility class and BaseMeta metaclass to BaseDTO so that all DTO subclasses automatically expose field names as type-safe class attributes (e.g., PaginationDTO.page returns FieldStr("page")). This enables IDE autocompletion, refactoring support, and eliminates hardcoded field name strings. Instance attribute access is unaffected. - Add FieldStr(str) with __slots__ for memory efficiency - Add BaseMeta(ModelMetaclass) that sets FieldStr attrs after class construction - Replace hardcoded field_name strings in range_dtos.py model validators - Add BDD feature file with 6 scenarios covering FieldStr behavior Co-authored-by: Cursor --- archipy/models/dtos/base_dtos.py | 83 ++++++++++++++++- archipy/models/dtos/range_dtos.py | 4 +- features/base_dtos.feature | 37 ++++++++ features/steps/base_dtos_steps.py | 146 ++++++++++++++++++++++++++++++ 4 files changed, 264 insertions(+), 6 deletions(-) create mode 100644 features/base_dtos.feature create mode 100644 features/steps/base_dtos_steps.py diff --git a/archipy/models/dtos/base_dtos.py b/archipy/models/dtos/base_dtos.py index 86ca139c..4cbf0487 100644 --- a/archipy/models/dtos/base_dtos.py +++ b/archipy/models/dtos/base_dtos.py @@ -1,17 +1,92 @@ from enum import Enum -from typing import TypeVar +from typing import Any, Self, TypeVar from pydantic import BaseModel, ConfigDict +from pydantic._internal._model_construction import ModelMetaclass # Generic types T = TypeVar("T", bound=Enum) -class BaseDTO(BaseModel): +class FieldStr(str): + """Type-safe field name reference string. + + Allows referencing Pydantic model field names as class attributes + instead of hardcoded strings, enabling IDE autocompletion and + refactoring support. + + Examples: + >>> class UserDTO(BaseDTO): + ... name: str + ... email: str + >>> UserDTO.name # returns FieldStr("name") + 'name' + >>> UserDTO.name == "name" + True + """ + + __slots__ = ("name",) + + def __new__(cls, value: str) -> Self: + """Create a new FieldStr instance. + + Args: + value: The field name string. + + Returns: + FieldStr: A string subclass carrying the field name. + """ + obj = super().__new__(cls, value) + obj.name = value + return obj + + +class BaseMeta(ModelMetaclass): + """Metaclass that adds FieldStr class attributes for each Pydantic model field. + + After Pydantic's ModelMetaclass constructs the class and populates + ``model_fields``, this metaclass overwrites the corresponding class + attributes with :class:`FieldStr` instances so that + ``MyDTO.field_name`` returns a type-safe string equal to ``"field_name"``. + + Instance attribute access is unaffected because Python resolves + instance ``__dict__`` entries before class attributes. + """ + + def __new__(mcs, name: str, bases: tuple[type, ...], namespace: dict[str, Any], **kwargs: Any) -> type: # noqa: ANN401 + """Create a new class with FieldStr attributes for each model field. + + Args: + name: The class name. + bases: The base classes. + namespace: The class namespace. + **kwargs: Additional keyword arguments forwarded to ModelMetaclass. + + Returns: + The newly created class with FieldStr attributes. + """ + cls = super().__new__(mcs, name, bases, namespace, **kwargs) + for field_name in cls.model_fields: # ty:ignore[unresolved-attribute] + setattr(cls, field_name, FieldStr(field_name)) + return cls + + +class BaseDTO(BaseModel, metaclass=BaseMeta): """Base Data Transfer Object class. - This class extends Pydantic's BaseModel to provide common configuration - for all DTOs in the application. + This class extends Pydantic's BaseModel with a custom metaclass that + provides type-safe field name references. After class construction, + each field name is accessible as a :class:`FieldStr` class attribute. + + Examples: + >>> class ProductDTO(BaseDTO): + ... title: str + ... price: float + >>> ProductDTO.title # FieldStr("title") + 'title' + >>> product = ProductDTO(title="Widget", price=9.99) + >>> product.title # actual value + 'Widget' """ model_config = ConfigDict( diff --git a/archipy/models/dtos/range_dtos.py b/archipy/models/dtos/range_dtos.py index bf6d689e..ce0e9ee6 100644 --- a/archipy/models/dtos/range_dtos.py +++ b/archipy/models/dtos/range_dtos.py @@ -46,7 +46,7 @@ def validate_range(self) -> Self: # The protocol ensures both values support comparison try: if self.from_ > self.to: # type: ignore[operator] - raise OutOfRangeError(field_name="from_") + raise OutOfRangeError(field_name=type(self).from_) # type: ignore[arg-type] except TypeError: # If comparison fails, skip validation (shouldn't happen with proper types) pass @@ -175,7 +175,7 @@ def validate_interval_constraints(self) -> Self: if max_to_age: age_threshold = current_time - max_to_age if self.to < age_threshold: - raise OutOfRangeError(field_name="to") + raise OutOfRangeError(field_name=type(self).to) # type: ignore[arg-type] # Calculate number of intervals step = self.INTERVAL_TO_TIMEDELTA[self.interval] diff --git a/features/base_dtos.feature b/features/base_dtos.feature new file mode 100644 index 00000000..a760784b --- /dev/null +++ b/features/base_dtos.feature @@ -0,0 +1,37 @@ +Feature: Type-Safe Field Name References for DTOs + + Scenario: FieldStr class attributes exist on BaseDTO subclasses + Given a DTO subclass with fields "name" and "email" + When I access the field names as class attributes + Then each attribute should be a FieldStr instance + And each attribute value should match its field name + + Scenario: Instance attribute access returns actual values + Given a DTO subclass with fields "name" and "email" + When I create an instance with name "Alice" and email "alice@example.com" + Then instance attribute "name" should return "Alice" + And instance attribute "email" should return "alice@example.com" + And class attribute "name" should still return FieldStr "name" + + Scenario: FieldStr works as dictionary key + Given a DTO subclass with fields "name" and "email" + When I use a FieldStr class attribute as a dictionary key + Then the dictionary should be accessible with the equivalent plain string + + Scenario: FieldStr works in string comparisons + Given a DTO subclass with fields "name" and "email" + When I compare the FieldStr class attribute to a plain string + Then the comparison should return true for matching strings + And the comparison should return false for non-matching strings + + Scenario: Inherited DTOs get FieldStr attributes + Given a parent DTO with field "base_field" and a child DTO with field "child_field" + When I access field names on the child class + Then the child should have FieldStr for "base_field" + And the child should have FieldStr for "child_field" + + Scenario: Existing PaginationDTO has FieldStr attributes + Given the PaginationDTO class + When I access its field name class attributes + Then "page" should be a FieldStr with value "page" + And "page_size" should be a FieldStr with value "page_size" diff --git a/features/steps/base_dtos_steps.py b/features/steps/base_dtos_steps.py new file mode 100644 index 00000000..db688ce9 --- /dev/null +++ b/features/steps/base_dtos_steps.py @@ -0,0 +1,146 @@ +from behave import given, then, when +from features.test_helpers import get_current_scenario_context + +from archipy.models.dtos.base_dtos import BaseDTO, FieldStr +from archipy.models.dtos.pagination_dto import PaginationDTO + + +@given('a DTO subclass with fields "name" and "email"') +def step_given_dto_subclass(context): + scenario_context = get_current_scenario_context(context) + + class SampleDTO(BaseDTO): + name: str + email: str + + scenario_context.store("dto_class", SampleDTO) + + +@when("I access the field names as class attributes") +def step_when_access_field_names(context): + scenario_context = get_current_scenario_context(context) + dto_class = scenario_context.get("dto_class") + scenario_context.store("name_attr", dto_class.name) + scenario_context.store("email_attr", dto_class.email) + + +@then("each attribute should be a FieldStr instance") +def step_then_attributes_are_fieldstr(context): + scenario_context = get_current_scenario_context(context) + assert isinstance(scenario_context.get("name_attr"), FieldStr) + assert isinstance(scenario_context.get("email_attr"), FieldStr) + + +@then("each attribute value should match its field name") +def step_then_values_match_field_names(context): + scenario_context = get_current_scenario_context(context) + assert scenario_context.get("name_attr") == "name" + assert scenario_context.get("email_attr") == "email" + + +@when('I create an instance with name "{name}" and email "{email}"') +def step_when_create_instance(context, name, email): + scenario_context = get_current_scenario_context(context) + dto_class = scenario_context.get("dto_class") + instance = dto_class(name=name, email=email) + scenario_context.store("instance", instance) + + +@then('instance attribute "{attr}" should return "{expected}"') +def step_then_instance_attr_returns(context, attr, expected): + scenario_context = get_current_scenario_context(context) + instance = scenario_context.get("instance") + assert getattr(instance, attr) == expected + + +@then('class attribute "{attr}" should still return FieldStr "{expected}"') +def step_then_class_attr_still_fieldstr(context, attr, expected): + scenario_context = get_current_scenario_context(context) + dto_class = scenario_context.get("dto_class") + class_attr = getattr(dto_class, attr) + assert isinstance(class_attr, FieldStr) + assert class_attr == expected + + +@when("I use a FieldStr class attribute as a dictionary key") +def step_when_use_fieldstr_as_dict_key(context): + scenario_context = get_current_scenario_context(context) + dto_class = scenario_context.get("dto_class") + test_dict = {dto_class.name: "Alice", dto_class.email: "alice@example.com"} + scenario_context.store("test_dict", test_dict) + + +@then("the dictionary should be accessible with the equivalent plain string") +def step_then_dict_accessible_with_plain_string(context): + scenario_context = get_current_scenario_context(context) + test_dict = scenario_context.get("test_dict") + assert test_dict["name"] == "Alice" + assert test_dict["email"] == "alice@example.com" + + +@when("I compare the FieldStr class attribute to a plain string") +def step_when_compare_fieldstr(context): + scenario_context = get_current_scenario_context(context) + dto_class = scenario_context.get("dto_class") + scenario_context.store("match_result", dto_class.name == "name") + scenario_context.store("no_match_result", dto_class.name == "other") + + +@then("the comparison should return true for matching strings") +def step_then_comparison_true(context): + scenario_context = get_current_scenario_context(context) + assert scenario_context.get("match_result") is True + + +@then("the comparison should return false for non-matching strings") +def step_then_comparison_false(context): + scenario_context = get_current_scenario_context(context) + assert scenario_context.get("no_match_result") is False + + +@given('a parent DTO with field "base_field" and a child DTO with field "child_field"') +def step_given_parent_child_dto(context): + scenario_context = get_current_scenario_context(context) + + class ParentDTO(BaseDTO): + base_field: str + + class ChildDTO(ParentDTO): + child_field: str + + scenario_context.store("parent_class", ParentDTO) + scenario_context.store("child_class", ChildDTO) + + +@when("I access field names on the child class") +def step_when_access_child_fields(context): + pass + + +@then('the child should have FieldStr for "{field_name}"') +def step_then_child_has_fieldstr(context, field_name): + scenario_context = get_current_scenario_context(context) + child_class = scenario_context.get("child_class") + attr = getattr(child_class, field_name) + assert isinstance(attr, FieldStr) + assert attr == field_name + + +@given("the PaginationDTO class") +def step_given_pagination_dto(context): + scenario_context = get_current_scenario_context(context) + scenario_context.store("pagination_class", PaginationDTO) + + +@when("I access its field name class attributes") +def step_when_access_pagination_fields(context): + pass + + +@then('"{field_name}" should be a FieldStr with value "{expected}"') +def step_then_field_is_fieldstr_with_value(context, field_name, expected): + scenario_context = get_current_scenario_context(context) + pagination_class = scenario_context.get("pagination_class") + attr = getattr(pagination_class, field_name) + assert isinstance(attr, FieldStr), f"Expected FieldStr but got {type(attr).__name__}" + assert attr == expected, f"Expected '{expected}' but got '{attr}'"