sindripy 0.1.1__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.
Potentially problematic release.
This version of sindripy might be problematic. Click here for more details.
- sindripy/__init__.py +11 -0
- sindripy/_compat.py +15 -0
- sindripy/mothers/__init__.py +24 -0
- sindripy/mothers/identifiers/__init__.py +0 -0
- sindripy/mothers/identifiers/string_uuid_primitives_mother.py +16 -0
- sindripy/mothers/object_mother.py +10 -0
- sindripy/mothers/primitives/__init__.py +0 -0
- sindripy/mothers/primitives/boolean_primitives_mother.py +20 -0
- sindripy/mothers/primitives/float_primitives_mother.py +30 -0
- sindripy/mothers/primitives/integer_primitives_mother.py +36 -0
- sindripy/mothers/primitives/list_primitives_mother.py +10 -0
- sindripy/mothers/primitives/string_primitives_mother.py +48 -0
- sindripy/py.typed +0 -0
- sindripy/value_objects/__init__.py +29 -0
- sindripy/value_objects/aggregate.py +312 -0
- sindripy/value_objects/decorators/__init__.py +0 -0
- sindripy/value_objects/decorators/validation.py +28 -0
- sindripy/value_objects/errors/__init__.py +0 -0
- sindripy/value_objects/errors/incorrect_value_type_error.py +12 -0
- sindripy/value_objects/errors/invalid_id_format_error.py +8 -0
- sindripy/value_objects/errors/required_value_error.py +8 -0
- sindripy/value_objects/errors/sindri_validation_error.py +10 -0
- sindripy/value_objects/identifiers/__init__.py +0 -0
- sindripy/value_objects/identifiers/string_uuid.py +55 -0
- sindripy/value_objects/primitives/__init__.py +0 -0
- sindripy/value_objects/primitives/boolean.py +44 -0
- sindripy/value_objects/primitives/float.py +44 -0
- sindripy/value_objects/primitives/integer.py +44 -0
- sindripy/value_objects/primitives/list.py +307 -0
- sindripy/value_objects/primitives/string.py +43 -0
- sindripy/value_objects/value_object.py +269 -0
- sindripy-0.1.1.dist-info/METADATA +144 -0
- sindripy-0.1.1.dist-info/RECORD +34 -0
- sindripy-0.1.1.dist-info/WHEEL +4 -0
sindripy/__init__.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""Top level package for the value-crafter library.
|
|
2
|
+
|
|
3
|
+
This module exposes the most common entry points so the public API is
|
|
4
|
+
available through the ``sindripy`` namespace when the library is
|
|
5
|
+
installed as a dependency.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from src.sindripy import mothers, value_objects
|
|
9
|
+
|
|
10
|
+
__all__ = ["mothers", "value_objects"]
|
|
11
|
+
__version__ = "0.1.1"
|
sindripy/_compat.py
ADDED
|
@@ -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,24 @@
|
|
|
1
|
+
"""Public facade for object mother helpers.
|
|
2
|
+
|
|
3
|
+
This module re-exports the available object mother implementations so
|
|
4
|
+
that projects using this library can import them from
|
|
5
|
+
``sindripy.mothers`` directly.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from src.sindripy.mothers.identifiers.string_uuid_primitives_mother import StringUuidPrimitivesMother
|
|
9
|
+
from src.sindripy.mothers.object_mother import ObjectMother
|
|
10
|
+
from src.sindripy.mothers.primitives.boolean_primitives_mother import BooleanPrimitivesMother
|
|
11
|
+
from src.sindripy.mothers.primitives.float_primitives_mother import FloatPrimitivesMother
|
|
12
|
+
from src.sindripy.mothers.primitives.integer_primitives_mother import IntegerPrimitivesMother
|
|
13
|
+
from src.sindripy.mothers.primitives.list_primitives_mother import ListPrimitivesMother
|
|
14
|
+
from src.sindripy.mothers.primitives.string_primitives_mother import StringPrimitivesMother
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"ObjectMother",
|
|
18
|
+
"BooleanPrimitivesMother",
|
|
19
|
+
"FloatPrimitivesMother",
|
|
20
|
+
"IntegerPrimitivesMother",
|
|
21
|
+
"ListPrimitivesMother",
|
|
22
|
+
"StringPrimitivesMother",
|
|
23
|
+
"StringUuidPrimitivesMother",
|
|
24
|
+
]
|
|
File without changes
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
from src.sindripy.mothers.object_mother import ObjectMother
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class StringUuidPrimitivesMother(ObjectMother):
|
|
5
|
+
"""Generate string UUID primitive values for testing."""
|
|
6
|
+
|
|
7
|
+
@classmethod
|
|
8
|
+
def any(cls) -> str:
|
|
9
|
+
"""Generate any random UUID string value."""
|
|
10
|
+
return cls._faker().uuid4()
|
|
11
|
+
|
|
12
|
+
@classmethod
|
|
13
|
+
def invalid(cls) -> str:
|
|
14
|
+
"""Generate an invalid UUID string."""
|
|
15
|
+
valid_uuid = cls.any()
|
|
16
|
+
return valid_uuid[:-4]
|
|
File without changes
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
from src.sindripy.mothers.object_mother import ObjectMother
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class BooleanPrimitivesMother(ObjectMother):
|
|
5
|
+
"""Generate boolean primitive values for testing."""
|
|
6
|
+
|
|
7
|
+
@classmethod
|
|
8
|
+
def any(cls) -> bool:
|
|
9
|
+
"""Generate any random boolean value."""
|
|
10
|
+
return cls._faker().boolean()
|
|
11
|
+
|
|
12
|
+
@staticmethod
|
|
13
|
+
def true() -> bool:
|
|
14
|
+
"""Generate True value."""
|
|
15
|
+
return True
|
|
16
|
+
|
|
17
|
+
@staticmethod
|
|
18
|
+
def false() -> bool:
|
|
19
|
+
"""Generate False value."""
|
|
20
|
+
return False
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
from src.sindripy.mothers.object_mother import ObjectMother
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class FloatPrimitivesMother(ObjectMother):
|
|
5
|
+
"""Generate float primitive values for testing."""
|
|
6
|
+
|
|
7
|
+
@classmethod
|
|
8
|
+
def any(cls) -> float:
|
|
9
|
+
"""Generate any random float value."""
|
|
10
|
+
return cls._faker().pyfloat()
|
|
11
|
+
|
|
12
|
+
@classmethod
|
|
13
|
+
def create(cls, is_positive: bool | None = None, min_value: float = -1000.0, max_value: float = 10000.0) -> float:
|
|
14
|
+
"""Generate a float value with specified constraints."""
|
|
15
|
+
return cls._faker().pyfloat(positive=is_positive, min_value=min_value, max_value=max_value)
|
|
16
|
+
|
|
17
|
+
@classmethod
|
|
18
|
+
def positive(cls) -> float:
|
|
19
|
+
"""Generate a positive float value greater than zero."""
|
|
20
|
+
return cls._faker().pyfloat(positive=True, min_value=0.1)
|
|
21
|
+
|
|
22
|
+
@classmethod
|
|
23
|
+
def negative(cls) -> float:
|
|
24
|
+
"""Generate a negative float value less than zero."""
|
|
25
|
+
return cls._faker().pyfloat(positive=False, max_value=-0.1)
|
|
26
|
+
|
|
27
|
+
@staticmethod
|
|
28
|
+
def zero() -> float:
|
|
29
|
+
"""Generate zero as a float."""
|
|
30
|
+
return 0.0
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
from src.sindripy.mothers.object_mother import ObjectMother
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class IntegerPrimitivesMother(ObjectMother):
|
|
5
|
+
"""Generate int primitive values for testing."""
|
|
6
|
+
|
|
7
|
+
@classmethod
|
|
8
|
+
def any(cls) -> int:
|
|
9
|
+
"""Generate any random int value."""
|
|
10
|
+
return cls._faker().random_int()
|
|
11
|
+
|
|
12
|
+
@classmethod
|
|
13
|
+
def create(cls, is_positive: bool | None = None, min_value: int = -10000, max_value: int = 1000) -> int:
|
|
14
|
+
"""Generate an int value with specified constraints."""
|
|
15
|
+
if is_positive:
|
|
16
|
+
return cls._faker().random_int(min=1, max=abs(max_value))
|
|
17
|
+
|
|
18
|
+
if is_positive is False:
|
|
19
|
+
return cls._faker().random_int(min=-abs(min_value), max=-1)
|
|
20
|
+
|
|
21
|
+
return cls._faker().random_int(min=min_value, max=max_value)
|
|
22
|
+
|
|
23
|
+
@classmethod
|
|
24
|
+
def positive(cls) -> int:
|
|
25
|
+
"""Generate a positive int value greater than zero."""
|
|
26
|
+
return cls._faker().random_int(min=1)
|
|
27
|
+
|
|
28
|
+
@classmethod
|
|
29
|
+
def negative(cls) -> int:
|
|
30
|
+
"""Generate a negative int value less than zero."""
|
|
31
|
+
return cls._faker().random_int(min=-(2**31), max=-1)
|
|
32
|
+
|
|
33
|
+
@staticmethod
|
|
34
|
+
def zero() -> int:
|
|
35
|
+
"""Generate zero as an int."""
|
|
36
|
+
return 0
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
from src.sindripy.mothers.object_mother import ObjectMother
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class StringPrimitivesMother(ObjectMother):
|
|
5
|
+
"""Generate string primitive values for testing."""
|
|
6
|
+
|
|
7
|
+
@classmethod
|
|
8
|
+
def any(cls) -> str:
|
|
9
|
+
"""Generate any random string value."""
|
|
10
|
+
return cls._faker().word()
|
|
11
|
+
|
|
12
|
+
@staticmethod
|
|
13
|
+
def empty() -> str:
|
|
14
|
+
return ""
|
|
15
|
+
|
|
16
|
+
@classmethod
|
|
17
|
+
def containing_character(cls, character: str) -> str:
|
|
18
|
+
"""Generate a string containing a specific character in a random position (not at beginning or end)."""
|
|
19
|
+
base_word = cls._faker().word()
|
|
20
|
+
|
|
21
|
+
if len(base_word) < 3:
|
|
22
|
+
base_word = cls._faker().word() + cls._faker().word()
|
|
23
|
+
|
|
24
|
+
character_position = cls._faker().random_int(min=1, max=len(base_word) - 1)
|
|
25
|
+
|
|
26
|
+
return base_word[:character_position] + character + base_word[character_position + 1 :]
|
|
27
|
+
|
|
28
|
+
@classmethod
|
|
29
|
+
def ending_with(cls, character: str) -> str:
|
|
30
|
+
"""Generate a string ending with a specific character."""
|
|
31
|
+
return cls._faker().word() + character
|
|
32
|
+
|
|
33
|
+
@classmethod
|
|
34
|
+
def beginning_with(cls, character: str) -> str:
|
|
35
|
+
"""Generate a string beginning with a specific character."""
|
|
36
|
+
return character + cls._faker().word()
|
|
37
|
+
|
|
38
|
+
@classmethod
|
|
39
|
+
def with_length(cls, length: int) -> str:
|
|
40
|
+
"""Generate a string with specific length. If length is less than or equal to 0, return an empty string."""
|
|
41
|
+
if length <= 0:
|
|
42
|
+
return ""
|
|
43
|
+
return cls._faker().pystr(min_chars=length, max_chars=length)
|
|
44
|
+
|
|
45
|
+
@classmethod
|
|
46
|
+
def text(cls) -> str:
|
|
47
|
+
"""Generate a text string (can contain spaces and punctuation)."""
|
|
48
|
+
return cls._faker().text(max_nb_chars=200)
|
sindripy/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,29 @@
|
|
|
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:`sindripy.value_object`.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from src.sindripy.value_objects.aggregate import Aggregate
|
|
8
|
+
from src.sindripy.value_objects.decorators.validation import validate
|
|
9
|
+
from src.sindripy.value_objects.errors.sindri_validation_error import SindriValidationError
|
|
10
|
+
from src.sindripy.value_objects.identifiers.string_uuid import StringUuid
|
|
11
|
+
from src.sindripy.value_objects.primitives.boolean import Boolean
|
|
12
|
+
from src.sindripy.value_objects.primitives.float import Float
|
|
13
|
+
from src.sindripy.value_objects.primitives.integer import Integer
|
|
14
|
+
from src.sindripy.value_objects.primitives.list import List
|
|
15
|
+
from src.sindripy.value_objects.primitives.string import String
|
|
16
|
+
from src.sindripy.value_objects.value_object import ValueObject
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
"Aggregate",
|
|
20
|
+
"validate",
|
|
21
|
+
"StringUuid",
|
|
22
|
+
"Boolean",
|
|
23
|
+
"Float",
|
|
24
|
+
"Integer",
|
|
25
|
+
"List",
|
|
26
|
+
"String",
|
|
27
|
+
"ValueObject",
|
|
28
|
+
"SindriValidationError",
|
|
29
|
+
]
|
|
@@ -0,0 +1,312 @@
|
|
|
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 src.sindripy._compat import Self, override
|
|
7
|
+
from src.sindripy.value_objects.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.__Invoice__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': 100.0}
|
|
146
|
+
>>> invoice._to_dict(ignore_private=False)
|
|
147
|
+
{'invoice_id': 'INV-001', 'amount': 100.0, 'calculated_tax': 100.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. Missing parameters: <<<name, price>>> and extra parameters: <<<description>>>.
|
|
247
|
+
"""
|
|
248
|
+
primitives_names = ", ".join(sorted(primitives))
|
|
249
|
+
missing_names = ", ".join(sorted(missing))
|
|
250
|
+
extra_names = ", ".join(sorted(extra))
|
|
251
|
+
|
|
252
|
+
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
|
|
253
|
+
|
|
254
|
+
def to_primitives(self) -> dict[str, Any]:
|
|
255
|
+
"""
|
|
256
|
+
Convert the aggregate to a dictionary of primitive values.
|
|
257
|
+
|
|
258
|
+
Recursively converts the aggregate and all nested objects (other aggregates,
|
|
259
|
+
value objects, enums) to their primitive representations. This is useful
|
|
260
|
+
for serialization to JSON, database storage, or API responses.
|
|
261
|
+
|
|
262
|
+
Returns:
|
|
263
|
+
A dictionary with primitive values (strings, numbers, booleans, etc.)
|
|
264
|
+
where complex objects have been converted to their primitive forms.
|
|
265
|
+
|
|
266
|
+
Example:
|
|
267
|
+
>>> from enum import Enum
|
|
268
|
+
>>>
|
|
269
|
+
>>> class Status(Enum):
|
|
270
|
+
... ACTIVE = "active"
|
|
271
|
+
... INACTIVE = "inactive"
|
|
272
|
+
...
|
|
273
|
+
>>> class UserId(ValueObject[int]):
|
|
274
|
+
... pass
|
|
275
|
+
...
|
|
276
|
+
>>> class Account(Aggregate):
|
|
277
|
+
... def __init__(self, user_id: UserId, status: Status, balance: float):
|
|
278
|
+
... self.user_id = user_id
|
|
279
|
+
... self.status = status
|
|
280
|
+
... self.balance = balance
|
|
281
|
+
...
|
|
282
|
+
>>> account = Account(UserId(123), Status.ACTIVE, 1500.50)
|
|
283
|
+
>>> account.to_primitives()
|
|
284
|
+
{'user_id': 123, 'status': 'active', 'balance': 1500.5}
|
|
285
|
+
>>>
|
|
286
|
+
>>> # With nested aggregates
|
|
287
|
+
>>> class Order(Aggregate):
|
|
288
|
+
... def __init__(self, account: Account, item_count: int):
|
|
289
|
+
... self.account = account
|
|
290
|
+
... self.item_count = item_count
|
|
291
|
+
...
|
|
292
|
+
>>> order = Order(account, 3)
|
|
293
|
+
>>> order.to_primitives()
|
|
294
|
+
{'account': {'user_id': 123, 'status': 'active', 'balance': 1500.5}, 'item_count': 3}
|
|
295
|
+
"""
|
|
296
|
+
primitives = self._to_dict()
|
|
297
|
+
for key, value in primitives.items():
|
|
298
|
+
if isinstance(value, Aggregate) or hasattr(value, "to_primitives"):
|
|
299
|
+
value = value.to_primitives()
|
|
300
|
+
|
|
301
|
+
elif isinstance(value, Enum):
|
|
302
|
+
value = value.value
|
|
303
|
+
|
|
304
|
+
elif isinstance(value, ValueObject) or hasattr(value, "value"):
|
|
305
|
+
value = value.value
|
|
306
|
+
|
|
307
|
+
if isinstance(value, Enum):
|
|
308
|
+
value = value.value
|
|
309
|
+
|
|
310
|
+
primitives[key] = value
|
|
311
|
+
|
|
312
|
+
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 src.sindripy.value_objects.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,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
|