-
Notifications
You must be signed in to change notification settings - Fork 74
Expand file tree
/
Copy pathbase.py
More file actions
92 lines (78 loc) · 3.71 KB
/
base.py
File metadata and controls
92 lines (78 loc) · 3.71 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
from dataclasses import Field, dataclass, fields
from functools import cached_property
from typing import Any, ClassVar, Final, get_args, get_origin
from app.domain.exceptions.base import DomainFieldError
@dataclass(frozen=True, repr=False)
class ValueObject:
"""
Base class for immutable value objects (VO) in domain.
Defined by its instance attributes, which must themselves be immutable.
For simple cases where only type distinction is required,
consider using `typing.NewType` instead of subclassing this class.
Warning: due to mismatch between typing rules and runtime behavior
for `Final[ClassVar[T]]`, using `__slots__` in subclasses is not
recommended — such class constants are turned into `member_descriptor`
at runtime instead of preserving their declared values.
Note: I considered using plain `ClassVar[T]` for class constants
(as `ClassVar[Final[T]]` currently has no semantic meaning anyway)
which would immediately resolve the slots compatibility and `fields()`
behavior issues. However, I chose to prioritize correct typing
semantics with `Final[ClassVar[T]]` and observe how this inconsistency
between typing rules and runtime behavior gets resolved.
https://github.com/python/cpython/issues/89547
https://github.com/python/mypy/issues/19607
"""
def __post_init__(self) -> None:
"""
:raises DomainFieldError:
Hook for additional initialization and ensuring invariants.
Subclasses can override this method to implement custom logic, while
still calling `super().__post_init__()` to preserve base checks.
"""
self.__forbid_base_class_instantiation()
self.__check_field_existence()
def __forbid_base_class_instantiation(self) -> None:
""":raises DomainFieldError:"""
if type(self) is ValueObject:
raise DomainFieldError("Base ValueObject cannot be instantiated directly.")
def __check_field_existence(self) -> None:
""":raises DomainFieldError:"""
if not self.__instance_fields:
raise DomainFieldError(
f"{type(self).__name__} must have at least one field!",
)
@cached_property
def __instance_fields(self) -> tuple[Field[Any], ...]:
"""
Return only instance fields, exclude `Final[ClassVar[T]]`.
Since Python 3.13 `Final[ClassVar[T]]` is valid for class constants.
By typing rules `Final` must wrap `ClassVar`. However, dataclass
implementation erroneously reports such class variables via
`fields()`, unlike plain `ClassVar`. We drop them to avoid treating
class constants as instance attributes.
"""
instance_fields: list[Field[Any]] = []
for f in fields(self):
tp = f.type
if get_origin(tp) is Final and get_origin(get_args(tp)[0]) is ClassVar:
continue
instance_fields.append(f)
return tuple(instance_fields)
def __repr__(self) -> str:
"""
Return string representation of value object.
- With 1 field: outputs the value only.
- With 2+ fields: outputs in `name=value` format.
Subclasses must set `repr=False` for this to take effect.
"""
return f"{type(self).__name__}({self.__repr_value()})"
def __repr_value(self) -> str:
"""
Build string representation of value object.
- If one field, returns its value.
- Otherwise, returns comma-separated list of `name=value` pairs.
"""
items = self.__instance_fields
if len(items) == 1:
return f"{getattr(self, items[0].name)!r}"
return ", ".join(f"{f.name}={getattr(self, f.name)!r}" for f in items)