|
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 |
3 | 3 |
|
4 | 4 | from app.domain.exceptions.base import DomainFieldError |
5 | 5 |
|
6 | 6 |
|
7 | 7 | @dataclass(frozen=True, repr=False) |
8 | | -class ValueObject(ABC): |
| 8 | +class ValueObject: |
9 | 9 | """ |
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. |
18 | 14 | """ |
19 | 15 |
|
20 | 16 | def __post_init__(self) -> None: |
21 | 17 | """ |
22 | | - Hook for additional initialization and ensuring invariants. |
| 18 | + :raises DomainFieldError: |
23 | 19 |
|
| 20 | + Hook for additional initialization and ensuring invariants. |
24 | 21 | Subclasses can override this method to implement custom logic, while |
25 | 22 | still calling `super().__post_init__()` to preserve base checks. |
26 | 23 | """ |
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: |
28 | 35 | raise DomainFieldError( |
29 | 36 | f"{type(self).__name__} must have at least one field!", |
30 | 37 | ) |
31 | 38 |
|
| 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 | + |
32 | 58 | def __repr__(self) -> str: |
33 | 59 | """ |
34 | | - Returns a string representation of the value object. |
| 60 | + Return string representation of value object. |
35 | 61 | - With 1 field: outputs the value only. |
36 | 62 | - 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. |
38 | 64 | """ |
39 | | - return f"{type(self).__name__}({self._repr_value()})" |
| 65 | + return f"{type(self).__name__}({self.__repr_value()})" |
40 | 66 |
|
41 | | - def _repr_value(self) -> str: |
| 67 | + def __repr_value(self) -> str: |
42 | 68 | """ |
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. |
46 | 72 | """ |
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) |
0 commit comments