value-object-sindri 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
__init__.py ADDED
File without changes
@@ -0,0 +1,31 @@
1
+ """Public facade for value object implementations.
2
+
3
+ This module re-exports the most common value objects so they can be
4
+ imported directly from :mod:`value_objects`.
5
+ """
6
+
7
+ __version__ = "0.1.0"
8
+
9
+ from value_object.aggregate import Aggregate
10
+ from value_object.decorators.validation import validate
11
+ from value_object.errors.sindri_validation_error import SindriValidationError
12
+ from value_object.identifiers.string_uuid import StringUuid
13
+ from value_object.primitives.boolean import Boolean
14
+ from value_object.primitives.float import Float
15
+ from value_object.primitives.integer import Integer
16
+ from value_object.primitives.list import List
17
+ from value_object.primitives.string import String
18
+ from value_object.value_object import ValueObject
19
+
20
+ __all__ = [
21
+ "Aggregate",
22
+ "validate",
23
+ "StringUuid",
24
+ "Boolean",
25
+ "Float",
26
+ "Integer",
27
+ "List",
28
+ "String",
29
+ "ValueObject",
30
+ "SindriValidationError",
31
+ ]
@@ -0,0 +1,15 @@
1
+ """Compatibility helpers for typing features across Python versions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ try:
6
+ from typing import Self
7
+ except ImportError:
8
+ from typing_extensions import Self
9
+
10
+ try:
11
+ from typing import override
12
+ except ImportError:
13
+ from typing_extensions import override
14
+
15
+ __all__ = ["Self", "override"]
@@ -0,0 +1,315 @@
1
+ from abc import ABC, abstractmethod
2
+ from enum import Enum
3
+ from inspect import Parameter, _empty, signature
4
+ from typing import Any
5
+
6
+ from value_object._compat import Self, override
7
+ from value_object.value_object import ValueObject
8
+
9
+
10
+ class Aggregate(ABC):
11
+ """
12
+ Abstract base class for implementing aggregates in domain-driven design.
13
+
14
+ Aggregates are clusters of domain objects that can be treated as a single unit.
15
+ They ensure consistency boundaries and encapsulate business rules across
16
+ related entities and value objects.
17
+
18
+ This class provides utilities for:
19
+ - Converting aggregates to/from primitive dictionaries
20
+ - Comparing aggregates for equality
21
+ - String representation for debugging
22
+ - Handling nested value objects and other aggregates
23
+
24
+ Example:
25
+ >>> class User(Aggregate):
26
+ ... def __init__(self, user_id: int, name: str, email: str):
27
+ ... self.user_id = user_id
28
+ ... self.name = name
29
+ ... self.email = email
30
+ ...
31
+ >>> user = User(1, "John Doe", "john@example.com")
32
+ >>> user.to_primitives()
33
+ {'user_id': 1, 'name': 'John Doe', 'email': 'john@example.com'}
34
+ """
35
+
36
+ @abstractmethod
37
+ def __init__(self) -> None:
38
+ """
39
+ Initialize the aggregate.
40
+
41
+ This method must be implemented by all concrete aggregate classes.
42
+ It should set up the aggregate's state and ensure all invariants are met.
43
+
44
+ Raises:
45
+ NotImplementedError: Always raised as this is an abstract method.
46
+
47
+ Example:
48
+ >>> class Product(Aggregate):
49
+ ... def __init__(self, product_id: str, name: str, price: float):
50
+ ... self.product_id = product_id
51
+ ... self.name = name
52
+ ... self.price = price
53
+ ... if price < 0:
54
+ ... raise ValueError("Price cannot be negative")
55
+ """
56
+ raise NotImplementedError
57
+
58
+ @override
59
+ def __repr__(self) -> str:
60
+ """
61
+ Return a string representation suitable for debugging.
62
+
63
+ Creates a string representation showing all non-private attributes
64
+ in a constructor-like format.
65
+
66
+ Returns:
67
+ A string in the format "ClassName(attr1=value1, attr2=value2, ...)"
68
+
69
+ Example:
70
+ >>> class Order(Aggregate):
71
+ ... def __init__(self, order_id: str, customer_id: int, total: float):
72
+ ... self.order_id = order_id
73
+ ... self.customer_id = customer_id
74
+ ... self.total = total
75
+ ...
76
+ >>> order = Order("ORD-001", 123, 99.99)
77
+ >>> repr(order)
78
+ "Order(customer_id=123, order_id='ORD-001', total=99.99)"
79
+ """
80
+ attributes = []
81
+ for key, value in self._to_dict().items():
82
+ attributes.append(f"{key}={value!r}")
83
+
84
+ return f"{self.__class__.__name__}({', '.join(attributes)})"
85
+
86
+ @override
87
+ def __eq__(self, other: Self) -> bool:
88
+ """
89
+ Check equality with another aggregate of the same type.
90
+
91
+ Two aggregates are considered equal if they are of the same class
92
+ and all their non-private attributes have equal values.
93
+
94
+ Args:
95
+ other: Another aggregate of the same type to compare with.
96
+
97
+ Returns:
98
+ True if both aggregates have equal attributes, False otherwise.
99
+ NotImplemented if comparing with a different type.
100
+
101
+ Example:
102
+ >>> class Category(Aggregate):
103
+ ... def __init__(self, cat_id: int, name: str):
104
+ ... self.cat_id = cat_id
105
+ ... self.name = name
106
+ ...
107
+ >>> cat1 = Category(1, "Electronics")
108
+ >>> cat2 = Category(1, "Electronics")
109
+ >>> cat3 = Category(2, "Books")
110
+ >>> cat1 == cat2
111
+ True
112
+ >>> cat1 == cat3
113
+ False
114
+ """
115
+ if not isinstance(other, self.__class__):
116
+ return NotImplemented
117
+
118
+ return self._to_dict() == other._to_dict()
119
+
120
+ def _to_dict(self, *, ignore_private: bool = True) -> dict[str, Any]:
121
+ """
122
+ Convert the aggregate to a dictionary representation.
123
+
124
+ Extracts all instance attributes and converts them to a dictionary,
125
+ optionally filtering out private attributes (those starting with
126
+ double underscore and class name).
127
+
128
+ Args:
129
+ ignore_private: Whether to exclude private attributes from the result.
130
+ Defaults to True.
131
+
132
+ Returns:
133
+ A dictionary mapping attribute names to their values.
134
+
135
+ Example:
136
+ >>> class Invoice(Aggregate):
137
+ ... def __init__(self, invoice_id: str, amount: float):
138
+ ... self.invoice_id = invoice_id
139
+ ... self.amount = amount
140
+ ... self._calculated_tax = amount * 0.1 # private attribute
141
+ ... self.__secret = "hidden" # name-mangled private
142
+ ...
143
+ >>> invoice = Invoice("INV-001", 100.0)
144
+ >>> invoice._to_dict()
145
+ {'invoice_id': 'INV-001', 'amount': 100.0, 'calculated_tax': 10.0}
146
+ >>> invoice._to_dict(ignore_private=False)
147
+ {'invoice_id': 'INV-001', 'amount': 100.0, 'calculated_tax': 10.0, 'secret': 'hidden'}
148
+ """
149
+ dictionary: dict[str, Any] = {}
150
+ for key, value in self.__dict__.items():
151
+ if ignore_private and key.startswith(f"_{self.__class__.__name__}__"):
152
+ continue # ignore private attributes
153
+
154
+ key = key.replace(f"_{self.__class__.__name__}__", "")
155
+
156
+ if key.startswith("_"):
157
+ key = key[1:]
158
+
159
+ dictionary[key] = value
160
+
161
+ return dictionary
162
+
163
+ @classmethod
164
+ def from_primitives(cls, primitives: dict[str, Any]) -> Self:
165
+ """
166
+ Create an aggregate instance from a dictionary of primitive values.
167
+
168
+ This factory method constructs an aggregate by mapping dictionary keys
169
+ to constructor parameters. All required constructor parameters must be
170
+ present in the primitives dictionary.
171
+
172
+ Args:
173
+ primitives: A dictionary mapping parameter names to their values.
174
+ Must contain all required constructor parameters.
175
+
176
+ Returns:
177
+ A new instance of the aggregate class.
178
+
179
+ Raises:
180
+ TypeError: If primitives is not a dictionary with string keys.
181
+ ValueError: If required parameters are missing or extra parameters are provided.
182
+
183
+ Example:
184
+ >>> class Customer(Aggregate):
185
+ ... def __init__(self, customer_id: int, name: str, email: str = None):
186
+ ... self.customer_id = customer_id
187
+ ... self.name = name
188
+ ... self.email = email
189
+ ...
190
+ >>> data = {"customer_id": 42, "name": "Alice Smith"}
191
+ >>> customer = Customer.from_primitives(data)
192
+ >>> customer.name
193
+ 'Alice Smith'
194
+ >>> customer.customer_id
195
+ 42
196
+ >>>
197
+ >>> # Missing required parameter
198
+ >>> Customer.from_primitives({"name": "Bob"}) # Raises ValueError
199
+ >>>
200
+ >>> # Extra parameter
201
+ >>> Customer.from_primitives({"customer_id": 1, "name": "Charlie", "age": 30}) # Raises ValueError
202
+ """
203
+ if not isinstance(primitives, dict) or not all(isinstance(key, str) for key in primitives):
204
+ raise TypeError(f'{cls.__name__} primitives <<<{primitives}>>> must be a dictionary of strings. Got <<<{type(primitives).__name__}>>> type.') # noqa: E501 # fmt: skip
205
+
206
+ constructor_signature = signature(obj=cls.__init__)
207
+ parameters: dict[str, Parameter] = {parameter.name: parameter for parameter in constructor_signature.parameters.values() if parameter.name != 'self'} # noqa: E501 # fmt: skip
208
+ missing = {name for name, parameter in parameters.items() if parameter.default is _empty and name not in primitives} # noqa: E501 # fmt: skip
209
+ extra = set(primitives) - parameters.keys()
210
+
211
+ if missing or extra:
212
+ cls._raise_value_constructor_parameters_mismatch(primitives=set(primitives), missing=missing, extra=extra)
213
+
214
+ return cls(**primitives)
215
+
216
+ @classmethod
217
+ def _raise_value_constructor_parameters_mismatch(
218
+ cls,
219
+ primitives: set[str],
220
+ missing: set[str],
221
+ extra: set[str],
222
+ ) -> None:
223
+ """
224
+ Raise a detailed ValueError for constructor parameter mismatches.
225
+
226
+ This helper method generates informative error messages when the
227
+ primitives dictionary doesn't match the constructor signature.
228
+
229
+ Args:
230
+ primitives: Set of parameter names provided in the primitives dict.
231
+ missing: Set of required parameter names that are missing.
232
+ extra: Set of parameter names that are not in the constructor.
233
+
234
+ Raises:
235
+ ValueError: Always raised with detailed information about the mismatch.
236
+
237
+ Example:
238
+ >>> class Item(Aggregate):
239
+ ... def __init__(self, item_id: str, name: str, price: float):
240
+ ... pass
241
+ ...
242
+ >>> # This would trigger the error method internally:
243
+ >>> Item.from_primitives({"item_id": "123", "description": "test"})
244
+ Traceback (most recent call last):
245
+ ...
246
+ ValueError: Item primitives <<<description, item_id>>> must contain all constructor parameters.
247
+ Missing parameters: <<<name, price>>> and extra parameters: <<<description>>>.
248
+ """
249
+ primitives_names = ", ".join(sorted(primitives))
250
+ missing_names = ", ".join(sorted(missing))
251
+ extra_names = ", ".join(sorted(extra))
252
+
253
+ raise ValueError(f'{cls.__name__} primitives <<<{primitives_names}>>> must contain all constructor parameters. Missing parameters: <<<{missing_names}>>> and extra parameters: <<<{extra_names}>>>.') # noqa: E501 # fmt: skip
254
+
255
+ def to_primitives(self) -> dict[str, Any]:
256
+ """
257
+ Convert the aggregate to a dictionary of primitive values.
258
+
259
+ Recursively converts the aggregate and all nested objects (other aggregates,
260
+ value objects, enums) to their primitive representations. This is useful
261
+ for serialization to JSON, database storage, or API responses.
262
+
263
+ Returns:
264
+ A dictionary with primitive values (strings, numbers, booleans, etc.)
265
+ where complex objects have been converted to their primitive forms.
266
+
267
+ Example:
268
+ >>> from enum import Enum
269
+ >>> from value_objects import ValueObject
270
+ >>> from value_objects.aggregate import Aggregate
271
+ >>>
272
+ >>> class Status(Enum):
273
+ ... ACTIVE = "active"
274
+ ... INACTIVE = "inactive"
275
+ ...
276
+ >>> class UserId(ValueObject[int]):
277
+ ... pass
278
+ ...
279
+ >>> class Account(Aggregate):
280
+ ... def __init__(self, user_id: UserId, status: Status, balance: float):
281
+ ... self.user_id = user_id
282
+ ... self.status = status
283
+ ... self.balance = balance
284
+ ...
285
+ >>> account = Account(UserId(123), Status.ACTIVE, 1500.50)
286
+ >>> account.to_primitives()
287
+ {'user_id': 123, 'status': 'active', 'balance': 1500.5}
288
+ >>>
289
+ >>> # With nested aggregates
290
+ >>> class Order(Aggregate):
291
+ ... def __init__(self, account: Account, item_count: int):
292
+ ... self.account = account
293
+ ... self.item_count = item_count
294
+ ...
295
+ >>> order = Order(account, 3)
296
+ >>> order.to_primitives()
297
+ {'account': {'user_id': 123, 'status': 'active', 'balance': 1500.5}, 'item_count': 3}
298
+ """
299
+ primitives = self._to_dict()
300
+ for key, value in primitives.items():
301
+ if isinstance(value, Aggregate) or hasattr(value, "to_primitives"):
302
+ value = value.to_primitives()
303
+
304
+ elif isinstance(value, Enum):
305
+ value = value.value
306
+
307
+ elif isinstance(value, ValueObject) or hasattr(value, "value"):
308
+ value = value.value
309
+
310
+ if isinstance(value, Enum):
311
+ value = value.value
312
+
313
+ primitives[key] = value
314
+
315
+ return primitives
File without changes
@@ -0,0 +1,28 @@
1
+ from collections.abc import Callable
2
+ from typing import Any, TypeVar
3
+
4
+ F = TypeVar("F", bound=Callable[..., Any])
5
+
6
+
7
+ def validate(func: F | None = None, *, order: int = 0) -> Callable[[F], F] | F:
8
+ """Mark a method as a validator for ValueObject validation.
9
+
10
+ Arguments:
11
+ func: the function to decorate.
12
+ order: order in which this validator should run relative to other validators in the same class. Lower numbers run first.
13
+ """ # noqa: E501
14
+
15
+ def wrapper(fn: F) -> F:
16
+ if not isinstance(order, int):
17
+ raise TypeError(f"Validation order {order} must be an integer. Got {type(order).__name__} type.")
18
+ if order < 0:
19
+ raise ValueError(f"Validation order {order} must be a positive value.")
20
+
21
+ fn._is_validator = True
22
+ fn._order = order
23
+ return fn
24
+
25
+ if func is not None:
26
+ return wrapper(func)
27
+
28
+ return wrapper
File without changes
@@ -0,0 +1,12 @@
1
+ from typing import Any, TypeVar
2
+
3
+ from value_object.errors.sindri_validation_error import SindriValidationError
4
+
5
+ T = TypeVar("T")
6
+
7
+
8
+ class IncorrectValueTypeError(SindriValidationError):
9
+ def __init__(self, value: T, expected_type: type[Any]) -> None:
10
+ super().__init__(
11
+ message=f"Value '{value}' is not of type {expected_type.__name__}",
12
+ )
@@ -0,0 +1,8 @@
1
+ from value_object.errors.sindri_validation_error import SindriValidationError
2
+
3
+
4
+ class InvalidIdFormatError(SindriValidationError):
5
+ def __init__(self) -> None:
6
+ super().__init__(
7
+ message="Id must be a valid UUID",
8
+ )
@@ -0,0 +1,8 @@
1
+ from value_object.errors.sindri_validation_error import SindriValidationError
2
+
3
+
4
+ class RequiredValueError(SindriValidationError):
5
+ def __init__(self) -> None:
6
+ super().__init__(
7
+ message="Value is required, can't be None",
8
+ )
@@ -0,0 +1,10 @@
1
+ class SindriValidationError(Exception):
2
+ """Base class for all controlled errors during validation of value objects."""
3
+
4
+ def __init__(self, message: str) -> None:
5
+ self._message = message
6
+ super().__init__(self._message)
7
+
8
+ @property
9
+ def message(self) -> str:
10
+ return self._message
File without changes
@@ -0,0 +1,55 @@
1
+ from uuid import UUID
2
+
3
+ from value_object.decorators.validation import validate
4
+ from value_object.errors.incorrect_value_type_error import IncorrectValueTypeError
5
+ from value_object.errors.invalid_id_format_error import InvalidIdFormatError
6
+ from value_object.errors.required_value_error import RequiredValueError
7
+ from value_object.value_object import ValueObject
8
+
9
+
10
+ class StringUuid(ValueObject[str]):
11
+ """
12
+ A value object that wraps UUID (Universally Unique Identifier) string values with validation.
13
+
14
+ This class provides a specialized implementation for creating value objects that
15
+ represent UUID values in the domain. It ensures that the wrapped value is a
16
+ valid UUID string format and not None.
17
+
18
+ The class includes built-in validation for:
19
+ - Required value (not None)
20
+ - Type checking (must be a string)
21
+ - UUID format validation (must be a valid UUID format)
22
+
23
+ Inherits all functionality from ValueObject including immutability,
24
+ equality comparison, string representation, and hashing.
25
+
26
+ Example:
27
+ ```python
28
+ class UserId(StringUuid):
29
+ @validate
30
+ def _validate_version(self) -> None:
31
+ parsed_uuid = UUID(self._value)
32
+ if parsed_uuid.version != 4:
33
+ raise ValueError("Only UUID version 4 allowed")
34
+
35
+ user_id = UserId("123e4567-e89b-12d3-a456-426614174000")
36
+ user_id.value # '123e4567-e89b-12d3-a456-426614174000'
37
+ ```
38
+ """
39
+
40
+ @validate
41
+ def _ensure_has_value(self) -> None:
42
+ if self._value is None:
43
+ raise RequiredValueError
44
+
45
+ @validate
46
+ def _ensure_value_is_string(self) -> None:
47
+ if not isinstance(self._value, str):
48
+ raise IncorrectValueTypeError(self._value, str)
49
+
50
+ @validate
51
+ def _ensure_value_has_valid_uuid_format(self) -> None:
52
+ try:
53
+ UUID(self._value)
54
+ except ValueError as error:
55
+ raise InvalidIdFormatError from error
File without changes
@@ -0,0 +1,44 @@
1
+ from value_object.decorators.validation import validate
2
+ from value_object.errors.incorrect_value_type_error import IncorrectValueTypeError
3
+ from value_object.errors.required_value_error import RequiredValueError
4
+ from value_object.value_object import ValueObject
5
+
6
+
7
+ class Boolean(ValueObject[bool]):
8
+ """
9
+ A value object that wraps boolean values with validation.
10
+
11
+ This class provides a base implementation for creating value objects that
12
+ represent boolean values in the domain. It ensures that the wrapped value
13
+ is a valid boolean and not None.
14
+
15
+ The class includes built-in validation for:
16
+ - Required value (not None)
17
+ - Type checking (must be a boolean)
18
+
19
+ Inherits all functionality from ValueObject including immutability,
20
+ equality comparison, string representation, and hashing.
21
+
22
+ Example:
23
+ ```python
24
+ class IsActive(Boolean):
25
+ @validate
26
+ def _validate_true_for_premium(self) -> None:
27
+ # Custom business logic can be added here
28
+ pass
29
+
30
+ is_active = IsActive(True)
31
+ is_active.value # True
32
+ str(is_active) # 'True'
33
+ ```
34
+ """
35
+
36
+ @validate
37
+ def _ensure_has_value(self) -> None:
38
+ if self._value is None:
39
+ raise RequiredValueError
40
+
41
+ @validate
42
+ def _ensure_value_is_boolean(self) -> None:
43
+ if not isinstance(self._value, bool):
44
+ raise IncorrectValueTypeError(self._value, bool)
@@ -0,0 +1,44 @@
1
+ from value_object.decorators.validation import validate
2
+ from value_object.errors.incorrect_value_type_error import IncorrectValueTypeError
3
+ from value_object.errors.required_value_error import RequiredValueError
4
+ from value_object.value_object import ValueObject
5
+
6
+
7
+ class Float(ValueObject[float]):
8
+ """
9
+ A value object that wraps float values with validation.
10
+
11
+ This class provides a base implementation for creating value objects that
12
+ represent float values in the domain. It ensures that the wrapped value
13
+ is a valid float and not None. It accepts both positive and negative values.
14
+
15
+ The class includes built-in validation for:
16
+ - Required value (not None)
17
+ - Type checking (must be a float)
18
+
19
+ Inherits all functionality from ValueObject including immutability,
20
+ equality comparison, string representation, and hashing.
21
+
22
+ Example:
23
+ ```python
24
+ class Price(Float):
25
+ @validate
26
+ def _validate_positive(self) -> None:
27
+ if self._value < 0:
28
+ raise ValueError("Price cannot be negative")
29
+
30
+ price = Price(29.99)
31
+ price.value # 29.99
32
+ str(price) # '29.99'
33
+ ```
34
+ """
35
+
36
+ @validate
37
+ def _ensure_has_value(self) -> None:
38
+ if self._value is None:
39
+ raise RequiredValueError
40
+
41
+ @validate
42
+ def _ensure_value_is_float(self) -> None:
43
+ if not isinstance(self._value, float):
44
+ raise IncorrectValueTypeError(self._value, float)
@@ -0,0 +1,44 @@
1
+ from value_object.decorators.validation import validate
2
+ from value_object.errors.incorrect_value_type_error import IncorrectValueTypeError
3
+ from value_object.errors.required_value_error import RequiredValueError
4
+ from value_object.value_object import ValueObject
5
+
6
+
7
+ class Integer(ValueObject[int]):
8
+ """
9
+ A value object that wraps integer values with validation.
10
+
11
+ This class provides a base implementation for creating value objects that
12
+ represent integer values in the domain. It ensures that the wrapped value
13
+ is a valid integer and not None.
14
+
15
+ The class includes built-in validation for:
16
+ - Required value (not None)
17
+ - Type checking (must be an integer)
18
+
19
+ Inherits all functionality from ValueObject including immutability,
20
+ equality comparison, string representation, and hashing.
21
+
22
+ Example:
23
+ ```python
24
+ class Age(Integer):
25
+ @validate
26
+ def _validate_positive(self) -> None:
27
+ if self._value < 0:
28
+ raise ValueError("Age cannot be negative")
29
+
30
+ age = Age(25)
31
+ age.value # 25
32
+ str(age) # '25'
33
+ ```
34
+ """
35
+
36
+ @validate
37
+ def _ensure_has_value(self) -> None:
38
+ if self._value is None:
39
+ raise RequiredValueError
40
+
41
+ @validate
42
+ def _ensure_value_is_integer(self) -> None:
43
+ if not isinstance(self._value, int):
44
+ raise IncorrectValueTypeError(self._value, int)