Skip to content

Commit afd080b

Browse files
committed
Improve base value object
1 parent 1bf587a commit afd080b

5 files changed

Lines changed: 87 additions & 32 deletions

File tree

Lines changed: 50 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,50 +1,76 @@
1-
from abc import ABC
2-
from dataclasses import dataclass, fields
1+
from dataclasses import Field, dataclass, fields
2+
from typing import Any, ClassVar, Final, get_args, get_origin
33

44
from app.domain.exceptions.base import DomainFieldError
55

66

77
@dataclass(frozen=True, repr=False)
8-
class ValueObject(ABC):
8+
class ValueObject:
99
"""
10-
Base class for immutable value objects (VO) in the domain.
11-
- Defined by its attributes, which must also be immutable.
12-
- Subclasses should set `repr=False` to use the custom `__repr__` implementation
13-
from this class.
14-
15-
For simple cases where immutability and additional behavior aren't required,
16-
consider using `NewType` from `typing` as a lightweight alternative
17-
to inheriting from this class.
10+
Base class for immutable value objects (VO) in domain.
11+
Defined by its attributes, which must themselves be immutable.
12+
For simple cases where only type distinction is required,
13+
consider using `typing.NewType` instead of subclassing this class.
1814
"""
1915

2016
def __post_init__(self) -> None:
2117
"""
22-
Hook for additional initialization and ensuring invariants.
18+
:raises DomainFieldError:
2319
20+
Hook for additional initialization and ensuring invariants.
2421
Subclasses can override this method to implement custom logic, while
2522
still calling `super().__post_init__()` to preserve base checks.
2623
"""
27-
if not fields(self):
24+
self.__forbid_base_class_instantiation()
25+
self.__check_field_existence()
26+
27+
def __forbid_base_class_instantiation(self) -> None:
28+
""":raises DomainFieldError:"""
29+
if type(self) is ValueObject:
30+
raise DomainFieldError("Base ValueObject cannot be instantiated directly.")
31+
32+
def __check_field_existence(self) -> None:
33+
""":raises DomainFieldError:"""
34+
if not self.__instance_fields:
2835
raise DomainFieldError(
2936
f"{type(self).__name__} must have at least one field!",
3037
)
3138

39+
@property
40+
def __instance_fields(self) -> tuple[Field[Any], ...]:
41+
"""
42+
Return only instance fields, exclude `Final[ClassVar[T]]`.
43+
44+
Since Python 3.13 `Final[ClassVar[T]]` is valid for class constants.
45+
By typing rules `Final` must wrap `ClassVar`. However, dataclass
46+
implementation erroneously reports such class variables via
47+
`fields()`, unlike plain `ClassVar`. We drop them to avoid treating
48+
class constants as instance attributes.
49+
"""
50+
instance_fields: list[Field[Any]] = []
51+
for f in fields(self):
52+
tp = f.type
53+
if get_origin(tp) is Final and get_origin(get_args(tp)[0]) is ClassVar:
54+
continue
55+
instance_fields.append(f)
56+
return tuple(instance_fields)
57+
3258
def __repr__(self) -> str:
3359
"""
34-
Returns a string representation of the value object.
60+
Return string representation of value object.
3561
- With 1 field: outputs the value only.
3662
- With 2+ fields: outputs in `name=value` format.
37-
Subclasses must set `repr=False` in @dataclass for this to work.
63+
Subclasses must set `repr=False` for this to take effect.
3864
"""
39-
return f"{type(self).__name__}({self._repr_value()})"
65+
return f"{type(self).__name__}({self.__repr_value()})"
4066

41-
def _repr_value(self) -> str:
67+
def __repr_value(self) -> str:
4268
"""
43-
Helper to build a string representation of the value object.
44-
- If there is one field, returns the value of that field.
45-
- Otherwise, returns a comma-separated list of `name=value` pairs.
69+
Build string representation of value object.
70+
- If one field, returns its value.
71+
- Otherwise, returns comma-separated list of `name=value` pairs.
4672
"""
47-
all_fields = fields(self)
48-
if len(all_fields) == 1:
49-
return f"{getattr(self, all_fields[0].name)!r}"
50-
return ", ".join(f"{f.name}={getattr(self, f.name)!r}" for f in all_fields)
73+
items = self.__instance_fields
74+
if len(items) == 1:
75+
return f"{getattr(self, items[0].name)!r}"
76+
return ", ".join(f"{f.name}={getattr(self, f.name)!r}" for f in items)

tests/app/unit/domain/value_objects/test_base.py

Lines changed: 33 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
from dataclasses import FrozenInstanceError
1+
from dataclasses import FrozenInstanceError, dataclass
2+
from typing import ClassVar, Final
23

34
import pytest
45

@@ -10,12 +11,28 @@
1011
)
1112

1213

13-
def test_is_not_empty() -> None:
14-
class EmptyValueObject(ValueObject):
14+
def test_cannot_init() -> None:
15+
with pytest.raises(DomainFieldError):
16+
ValueObject()
17+
18+
19+
def test_child_cannot_init_with_no_instance_fields() -> None:
20+
@dataclass(frozen=True)
21+
class EmptyVO(ValueObject):
1522
pass
1623

1724
with pytest.raises(DomainFieldError):
18-
EmptyValueObject()
25+
EmptyVO()
26+
27+
28+
def test_child_cannot_init_with_only_class_fields() -> None:
29+
@dataclass(frozen=True)
30+
class EmptyVO(ValueObject):
31+
foo: Final[ClassVar[int]] = 0
32+
bar: ClassVar[str] = "baz"
33+
34+
with pytest.raises(DomainFieldError):
35+
EmptyVO()
1936

2037

2138
def test_is_immutable() -> None:
@@ -51,3 +68,15 @@ def test_multi_field_vo_repr() -> None:
5168
sut = create_multi_field_vo(value1=123, value2="abc")
5269

5370
assert repr(sut) == "MultiFieldVO(value1=123, value2='abc')"
71+
72+
73+
def test_class_var_not_in_repr() -> None:
74+
@dataclass(frozen=True, repr=False)
75+
class ClassVarVO(ValueObject):
76+
baz: int
77+
foo: Final[ClassVar[int]] = 0
78+
bar: ClassVar[str] = "baz"
79+
80+
sut = ClassVarVO(baz=1)
81+
82+
assert repr(sut) == "ClassVarVO(1)"

tests/app/unit/factories/named_entity.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
from app.domain.value_objects.base import ValueObject
55

66

7-
@dataclass(frozen=True, slots=True, repr=False)
7+
@dataclass(frozen=True, repr=False)
88
class NamedEntityId(ValueObject):
99
value: int
1010

tests/app/unit/factories/tagged_entity.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
from app.domain.value_objects.base import ValueObject
55

66

7-
@dataclass(frozen=True, slots=True, repr=False)
7+
@dataclass(frozen=True, repr=False)
88
class TaggedEntityId(ValueObject):
99
value: int
1010

tests/app/unit/factories/value_objects.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,12 @@
99
from app.domain.value_objects.username.username import Username
1010

1111

12-
@dataclass(frozen=True, slots=True, repr=False)
12+
@dataclass(frozen=True, repr=False)
1313
class SingleFieldVO(ValueObject):
1414
value: int
1515

1616

17-
@dataclass(frozen=True, slots=True, repr=False)
17+
@dataclass(frozen=True, repr=False)
1818
class MultiFieldVO(ValueObject):
1919
value1: int
2020
value2: str

0 commit comments

Comments
 (0)