Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 79 additions & 4 deletions archipy/models/dtos/base_dtos.py
Original file line number Diff line number Diff line change
@@ -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(
Expand Down
4 changes: 2 additions & 2 deletions archipy/models/dtos/range_dtos.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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]
Expand Down
37 changes: 37 additions & 0 deletions features/base_dtos.feature
Original file line number Diff line number Diff line change
@@ -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"
146 changes: 146 additions & 0 deletions features/steps/base_dtos_steps.py
Original file line number Diff line number Diff line change
@@ -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}'"
Loading