ddd-value-objects 0.1.7__tar.gz → 0.2.0__tar.gz

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.
Files changed (72) hide show
  1. {ddd_value_objects-0.1.7/src/ddd_value_objects.egg-info → ddd_value_objects-0.2.0}/PKG-INFO +1 -1
  2. {ddd_value_objects-0.1.7 → ddd_value_objects-0.2.0}/pyproject.toml +1 -1
  3. {ddd_value_objects-0.1.7 → ddd_value_objects-0.2.0}/src/ddd_value_objects/__init__.py +0 -2
  4. ddd_value_objects-0.2.0/src/ddd_value_objects/bool_value_object.py +21 -0
  5. ddd_value_objects-0.2.0/src/ddd_value_objects/composite_value_object.py +25 -0
  6. {ddd_value_objects-0.1.7 → ddd_value_objects-0.2.0}/src/ddd_value_objects/country_code_value_object.py +7 -7
  7. ddd_value_objects-0.2.0/src/ddd_value_objects/currency_value_object.py +21 -0
  8. ddd_value_objects-0.2.0/src/ddd_value_objects/date_time_value_object.py +20 -0
  9. ddd_value_objects-0.2.0/src/ddd_value_objects/date_value_object.py +18 -0
  10. ddd_value_objects-0.2.0/src/ddd_value_objects/decimal_value_object.py +45 -0
  11. ddd_value_objects-0.2.0/src/ddd_value_objects/email_value_object.py +21 -0
  12. ddd_value_objects-0.2.0/src/ddd_value_objects/enum_value_object.py +21 -0
  13. ddd_value_objects-0.2.0/src/ddd_value_objects/float_value_object.py +44 -0
  14. ddd_value_objects-0.2.0/src/ddd_value_objects/int_value_object.py +44 -0
  15. {ddd_value_objects-0.1.7 → ddd_value_objects-0.2.0}/src/ddd_value_objects/invalid_argument_error.py +2 -0
  16. ddd_value_objects-0.2.0/src/ddd_value_objects/ip_address_value_object.py +21 -0
  17. ddd_value_objects-0.2.0/src/ddd_value_objects/money_value_object.py +25 -0
  18. {ddd_value_objects-0.1.7 → ddd_value_objects-0.2.0}/src/ddd_value_objects/phone_number_value_object.py +12 -6
  19. ddd_value_objects-0.2.0/src/ddd_value_objects/positive_decimal_value_object.py +13 -0
  20. ddd_value_objects-0.2.0/src/ddd_value_objects/positive_float_value_object.py +12 -0
  21. ddd_value_objects-0.2.0/src/ddd_value_objects/positive_int_value_object.py +12 -0
  22. ddd_value_objects-0.2.0/src/ddd_value_objects/string_value_object.py +65 -0
  23. {ddd_value_objects-0.1.7 → ddd_value_objects-0.2.0}/src/ddd_value_objects/url_value_object.py +9 -7
  24. ddd_value_objects-0.2.0/src/ddd_value_objects/uuid_value_object.py +21 -0
  25. ddd_value_objects-0.2.0/src/ddd_value_objects/value_object.py +29 -0
  26. {ddd_value_objects-0.1.7 → ddd_value_objects-0.2.0/src/ddd_value_objects.egg-info}/PKG-INFO +1 -1
  27. {ddd_value_objects-0.1.7 → ddd_value_objects-0.2.0}/src/ddd_value_objects.egg-info/SOURCES.txt +2 -3
  28. ddd_value_objects-0.2.0/tests/test_composite_value_object.py +51 -0
  29. ddd_value_objects-0.2.0/tests/test_integrated_validations.py +67 -0
  30. {ddd_value_objects-0.1.7 → ddd_value_objects-0.2.0}/tests/test_money_value_object.py +1 -1
  31. {ddd_value_objects-0.1.7 → ddd_value_objects-0.2.0}/tests/test_phone_number_value_object.py +1 -1
  32. {ddd_value_objects-0.1.7 → ddd_value_objects-0.2.0}/tests/test_positive_value_objects.py +30 -1
  33. ddd_value_objects-0.2.0/tests/test_primitives.py +192 -0
  34. ddd_value_objects-0.2.0/tests/test_string_length_validations.py +58 -0
  35. {ddd_value_objects-0.1.7 → ddd_value_objects-0.2.0}/tests/test_value_object.py +0 -20
  36. ddd_value_objects-0.1.7/src/ddd_value_objects/bool_value_object.py +0 -15
  37. ddd_value_objects-0.1.7/src/ddd_value_objects/composite_value_object.py +0 -45
  38. ddd_value_objects-0.1.7/src/ddd_value_objects/currency_value_object.py +0 -18
  39. ddd_value_objects-0.1.7/src/ddd_value_objects/date_time_value_object.py +0 -24
  40. ddd_value_objects-0.1.7/src/ddd_value_objects/date_value_object.py +0 -24
  41. ddd_value_objects-0.1.7/src/ddd_value_objects/decimal_value_object.py +0 -16
  42. ddd_value_objects-0.1.7/src/ddd_value_objects/email_value_object.py +0 -19
  43. ddd_value_objects-0.1.7/src/ddd_value_objects/entity.py +0 -12
  44. ddd_value_objects-0.1.7/src/ddd_value_objects/enum_value_object.py +0 -24
  45. ddd_value_objects-0.1.7/src/ddd_value_objects/float_value_object.py +0 -15
  46. ddd_value_objects-0.1.7/src/ddd_value_objects/int_value_object.py +0 -15
  47. ddd_value_objects-0.1.7/src/ddd_value_objects/ip_address_value_object.py +0 -25
  48. ddd_value_objects-0.1.7/src/ddd_value_objects/money_value_object.py +0 -41
  49. ddd_value_objects-0.1.7/src/ddd_value_objects/positive_decimal_value_object.py +0 -17
  50. ddd_value_objects-0.1.7/src/ddd_value_objects/positive_float_value_object.py +0 -16
  51. ddd_value_objects-0.1.7/src/ddd_value_objects/positive_int_value_object.py +0 -16
  52. ddd_value_objects-0.1.7/src/ddd_value_objects/string_value_object.py +0 -15
  53. ddd_value_objects-0.1.7/src/ddd_value_objects/uuid_value_object.py +0 -25
  54. ddd_value_objects-0.1.7/src/ddd_value_objects/value_object.py +0 -46
  55. ddd_value_objects-0.1.7/tests/test_composite_value_object.py +0 -60
  56. ddd_value_objects-0.1.7/tests/test_decimal_value_objects.py +0 -41
  57. ddd_value_objects-0.1.7/tests/test_entity.py +0 -19
  58. ddd_value_objects-0.1.7/tests/test_primitives.py +0 -50
  59. {ddd_value_objects-0.1.7 → ddd_value_objects-0.2.0}/LICENSE +0 -0
  60. {ddd_value_objects-0.1.7 → ddd_value_objects-0.2.0}/README.md +0 -0
  61. {ddd_value_objects-0.1.7 → ddd_value_objects-0.2.0}/setup.cfg +0 -0
  62. {ddd_value_objects-0.1.7 → ddd_value_objects-0.2.0}/src/ddd_value_objects.egg-info/dependency_links.txt +0 -0
  63. {ddd_value_objects-0.1.7 → ddd_value_objects-0.2.0}/src/ddd_value_objects.egg-info/top_level.txt +0 -0
  64. {ddd_value_objects-0.1.7 → ddd_value_objects-0.2.0}/tests/test_country_code_value_object.py +0 -0
  65. {ddd_value_objects-0.1.7 → ddd_value_objects-0.2.0}/tests/test_currency_value_object.py +0 -0
  66. {ddd_value_objects-0.1.7 → ddd_value_objects-0.2.0}/tests/test_date_time_value_object.py +0 -0
  67. {ddd_value_objects-0.1.7 → ddd_value_objects-0.2.0}/tests/test_date_value_object.py +0 -0
  68. {ddd_value_objects-0.1.7 → ddd_value_objects-0.2.0}/tests/test_email_value_object.py +0 -0
  69. {ddd_value_objects-0.1.7 → ddd_value_objects-0.2.0}/tests/test_enum_value_object.py +0 -0
  70. {ddd_value_objects-0.1.7 → ddd_value_objects-0.2.0}/tests/test_ip_address_value_object.py +0 -0
  71. {ddd_value_objects-0.1.7 → ddd_value_objects-0.2.0}/tests/test_url_value_object.py +0 -0
  72. {ddd_value_objects-0.1.7 → ddd_value_objects-0.2.0}/tests/test_uuid_value_object.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ddd-value-objects
3
- Version: 0.1.7
3
+ Version: 0.2.0
4
4
  Summary: A collection of base classes for Domain-Driven Design (DDD) value objects and entities in Python
5
5
  License: MIT
6
6
  Requires-Python: >=3.10
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "ddd-value-objects"
3
- version = "0.1.7"
3
+ version = "0.2.0"
4
4
  description = "A collection of base classes for Domain-Driven Design (DDD) value objects and entities in Python"
5
5
  readme = "README.md"
6
6
  license = {text = "MIT"}
@@ -19,7 +19,6 @@ from .url_value_object import UrlValueObject
19
19
  from .ip_address_value_object import IpAddressValueObject
20
20
  from .enum_value_object import EnumValueObject
21
21
  from .money_value_object import MoneyValueObject
22
- from .entity import Entity
23
22
  from .invalid_argument_error import InvalidArgumentError
24
23
 
25
24
  __all__ = [
@@ -44,6 +43,5 @@ __all__ = [
44
43
  "IpAddressValueObject",
45
44
  "EnumValueObject",
46
45
  "MoneyValueObject",
47
- "Entity",
48
46
  "InvalidArgumentError",
49
47
  ]
@@ -0,0 +1,21 @@
1
+ from typing import Any
2
+ from dataclasses import dataclass
3
+
4
+ from .invalid_argument_error import InvalidArgumentError
5
+ from .value_object import ValueObject
6
+
7
+
8
+ @dataclass(frozen=True, slots=True)
9
+ class BoolValueObject(ValueObject[bool]):
10
+ def __post_init__(self):
11
+ super().__post_init__()
12
+ self._ensure_value_is_bool(self.value)
13
+
14
+ def _ensure_value_is_bool(self, value: bool) -> None:
15
+ if not isinstance(value, bool):
16
+ raise InvalidArgumentError(
17
+ self.get_invalid_type_error_message(value)
18
+ )
19
+
20
+ def get_invalid_type_error_message(self, value: Any) -> str:
21
+ return f"Value must be a boolean, got {type(value)}"
@@ -0,0 +1,25 @@
1
+ from abc import ABC
2
+ from dataclasses import dataclass, fields
3
+ from typing import Any
4
+
5
+ from .invalid_argument_error import InvalidArgumentError
6
+
7
+
8
+ @dataclass(frozen=True, slots=True)
9
+ class CompositeValueObject(ABC):
10
+ def __post_init__(self):
11
+ for field in fields(self):
12
+ field_name = field.name
13
+ value = getattr(self, field_name)
14
+ self._ensure_value_is_defined(value, field_name)
15
+
16
+ def equals(self, other: Any) -> bool:
17
+ if other is None or other.__class__ != self.__class__:
18
+ return False
19
+ return self == other
20
+
21
+ @staticmethod
22
+ def _ensure_value_is_defined(value: Any, field_name: str = "Value") -> None:
23
+ if value is None:
24
+ raise InvalidArgumentError(f"{field_name} must be defined")
25
+
@@ -1,23 +1,23 @@
1
1
  import re
2
+ from dataclasses import dataclass
2
3
 
3
4
  from .invalid_argument_error import InvalidArgumentError
4
5
  from .string_value_object import StringValueObject
5
6
 
6
7
 
8
+ @dataclass(frozen=True, slots=True)
7
9
  class CountryCodeValueObject(StringValueObject):
8
10
  """
9
11
  Value Object for ISO 3166-1 alpha-2 country codes.
10
12
  """
11
13
 
12
- def __init__(self, value: str):
13
- super().__init__(value)
14
- self._ensure_is_valid_country_code(value)
14
+ def __post_init__(self):
15
+ super().__post_init__()
16
+ self._ensure_is_valid_country_code(self.value)
15
17
 
16
- def _ensure_is_valid_country_code(self, value: str) -> None:
18
+ @staticmethod
19
+ def _ensure_is_valid_country_code(value: str) -> None:
17
20
  if not re.match(r"^[A-Z]{2}$", value):
18
21
  raise InvalidArgumentError(
19
22
  f"'{value}' is not a valid ISO 3166-1 alpha-2 country code"
20
23
  )
21
-
22
- def __repr__(self):
23
- return f"CountryCodeValueObject(value='{self.value}')"
@@ -0,0 +1,21 @@
1
+ import re
2
+ from dataclasses import dataclass
3
+
4
+ from .string_value_object import StringValueObject
5
+ from .invalid_argument_error import InvalidArgumentError
6
+
7
+
8
+ @dataclass(frozen=True, slots=True)
9
+ class CurrencyValueObject(StringValueObject):
10
+ CURRENCY_REGEX = re.compile(r"^[A-Z]{3}$")
11
+
12
+ def __post_init__(self):
13
+ super().__post_init__()
14
+ self._ensure_is_valid_currency(self.value)
15
+
16
+ def _ensure_is_valid_currency(self, value: str) -> None:
17
+ if not CurrencyValueObject.CURRENCY_REGEX.match(value):
18
+ raise InvalidArgumentError(self.get_invalid_currency_error_message(value))
19
+
20
+ def get_invalid_currency_error_message(self, value: str) -> str:
21
+ return f"'{value}' is not a valid ISO 4217 currency code"
@@ -0,0 +1,20 @@
1
+ from dataclasses import dataclass
2
+ from datetime import datetime
3
+ from .value_object import ValueObject
4
+ from .invalid_argument_error import InvalidArgumentError
5
+
6
+
7
+ @dataclass(frozen=True, slots=True)
8
+ class DateTimeValueObject(ValueObject[int]):
9
+ def __post_init__(self):
10
+ super().__post_init__()
11
+ self._ensure_is_valid_timestamp(self.value)
12
+
13
+ def _ensure_is_valid_timestamp(self, value: int) -> None:
14
+ try:
15
+ datetime.fromtimestamp(value)
16
+ except (ValueError, OSError, OverflowError):
17
+ raise InvalidArgumentError(self.get_invalid_timestamp_error_message(value))
18
+
19
+ def get_invalid_timestamp_error_message(self, value: int) -> str:
20
+ return f"'{value}' is not a valid Unix timestamp"
@@ -0,0 +1,18 @@
1
+ from dataclasses import dataclass
2
+ from datetime import date, datetime
3
+ from .value_object import ValueObject
4
+ from .invalid_argument_error import InvalidArgumentError
5
+
6
+
7
+ @dataclass(frozen=True, slots=True)
8
+ class DateValueObject(ValueObject[int]):
9
+ def __post_init__(self):
10
+ super().__post_init__()
11
+ self._ensure_is_valid_timestamp(self.value)
12
+
13
+ @staticmethod
14
+ def _ensure_is_valid_timestamp(value: int) -> None:
15
+ try:
16
+ datetime.fromtimestamp(value)
17
+ except (ValueError, OSError, OverflowError):
18
+ raise InvalidArgumentError(f"'{value}' is not a valid Unix timestamp")
@@ -0,0 +1,45 @@
1
+ from typing import Any
2
+ from dataclasses import dataclass
3
+ from decimal import Decimal
4
+
5
+ from .invalid_argument_error import InvalidArgumentError
6
+ from .value_object import ValueObject
7
+
8
+
9
+ @dataclass(frozen=True, slots=True)
10
+ class DecimalValueObject(ValueObject[Decimal]):
11
+ def __post_init__(self):
12
+ super().__post_init__()
13
+ self._ensure_value_is_decimal(self.value)
14
+ self._ensure_is_within_range(self.value)
15
+
16
+ def _ensure_value_is_decimal(self, value: Decimal) -> None:
17
+ if not isinstance(value, Decimal):
18
+ raise InvalidArgumentError(
19
+ self.get_invalid_type_error_message(value)
20
+ )
21
+
22
+ def _ensure_is_within_range(self, value: Decimal) -> None:
23
+ min_value = self.min_value()
24
+ max_value = self.max_value()
25
+
26
+ if min_value is not None and value < min_value:
27
+ raise InvalidArgumentError(self.get_too_low_error_message(value, min_value))
28
+
29
+ if max_value is not None and value > max_value:
30
+ raise InvalidArgumentError(self.get_too_high_error_message(value, max_value))
31
+
32
+ def min_value(self) -> Decimal | None:
33
+ return None
34
+
35
+ def max_value(self) -> Decimal | None:
36
+ return None
37
+
38
+ def get_invalid_type_error_message(self, value: Any) -> str:
39
+ return f"Value must be a decimal, got {type(value)}"
40
+
41
+ def get_too_low_error_message(self, value: Decimal, min_value: Decimal) -> str:
42
+ return f"Value {value} is less than minimum required {min_value}"
43
+
44
+ def get_too_high_error_message(self, value: Decimal, max_value: Decimal) -> str:
45
+ return f"Value {value} is greater than maximum allowed {max_value}"
@@ -0,0 +1,21 @@
1
+ import re
2
+ from dataclasses import dataclass
3
+
4
+ from .string_value_object import StringValueObject
5
+ from .invalid_argument_error import InvalidArgumentError
6
+
7
+
8
+ @dataclass(frozen=True, slots=True)
9
+ class EmailValueObject(StringValueObject):
10
+ EMAIL_REGEX = re.compile(r"^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$")
11
+
12
+ def __post_init__(self):
13
+ super().__post_init__()
14
+ self._ensure_is_valid_email(self.value)
15
+
16
+ def _ensure_is_valid_email(self, value: str) -> None:
17
+ if not EmailValueObject.EMAIL_REGEX.match(value):
18
+ raise InvalidArgumentError(self.get_invalid_email_error_message(value))
19
+
20
+ def get_invalid_email_error_message(self, value: str) -> str:
21
+ return f"'{value}' is not a valid email address"
@@ -0,0 +1,21 @@
1
+ from dataclasses import dataclass
2
+ from typing import List, Any
3
+
4
+ from .value_object import ValueObject, Primitives
5
+ from .invalid_argument_error import InvalidArgumentError
6
+
7
+
8
+ @dataclass(frozen=True, slots=True)
9
+ class EnumValueObject(ValueObject):
10
+ def __init__(self, value: Primitives, valid_values: List[Primitives]):
11
+ self._ensure_value_is_valid(value, valid_values)
12
+ super().__init__(value)
13
+
14
+ def _ensure_value_is_valid(self, value: Any, valid_values: List[Any]) -> None:
15
+ if value not in valid_values:
16
+ raise InvalidArgumentError(
17
+ self.get_invalid_enum_error_message(value, valid_values)
18
+ )
19
+
20
+ def get_invalid_enum_error_message(self, value: Any, valid_values: List[Any]) -> str:
21
+ return f"'{value}' is not a valid value. Allowed values are: {valid_values}"
@@ -0,0 +1,44 @@
1
+ from typing import Any
2
+ from dataclasses import dataclass
3
+
4
+ from .invalid_argument_error import InvalidArgumentError
5
+ from .value_object import ValueObject
6
+
7
+
8
+ @dataclass(frozen=True, slots=True)
9
+ class FloatValueObject(ValueObject[float]):
10
+ def __post_init__(self):
11
+ super().__post_init__()
12
+ self._ensure_value_is_float(self.value)
13
+ self._ensure_is_within_range(self.value)
14
+
15
+ def _ensure_value_is_float(self, value: float) -> None:
16
+ if not isinstance(value, float):
17
+ raise InvalidArgumentError(
18
+ self.get_invalid_type_error_message(value)
19
+ )
20
+
21
+ def _ensure_is_within_range(self, value: float) -> None:
22
+ min_value = self.min_value()
23
+ max_value = self.max_value()
24
+
25
+ if min_value is not None and value < min_value:
26
+ raise InvalidArgumentError(self.get_too_low_error_message(value, min_value))
27
+
28
+ if max_value is not None and value > max_value:
29
+ raise InvalidArgumentError(self.get_too_high_error_message(value, max_value))
30
+
31
+ def min_value(self) -> float | None:
32
+ return None
33
+
34
+ def max_value(self) -> float | None:
35
+ return None
36
+
37
+ def get_invalid_type_error_message(self, value: Any) -> str:
38
+ return f"Value must be a float, got {type(value)}"
39
+
40
+ def get_too_low_error_message(self, value: float, min_value: float) -> str:
41
+ return f"Value {value} is less than minimum required {min_value}"
42
+
43
+ def get_too_high_error_message(self, value: float, max_value: float) -> str:
44
+ return f"Value {value} is greater than maximum allowed {max_value}"
@@ -0,0 +1,44 @@
1
+ from typing import Any
2
+ from dataclasses import dataclass
3
+
4
+ from .invalid_argument_error import InvalidArgumentError
5
+ from .value_object import ValueObject
6
+
7
+
8
+ @dataclass(frozen=True, slots=True)
9
+ class IntValueObject(ValueObject[int]):
10
+ def __post_init__(self):
11
+ super().__post_init__()
12
+ self._ensure_value_is_integer(self.value)
13
+ self._ensure_is_within_range(self.value)
14
+
15
+ def _ensure_value_is_integer(self, value: int) -> None:
16
+ if not isinstance(value, int):
17
+ raise InvalidArgumentError(
18
+ self.get_invalid_type_error_message(value)
19
+ )
20
+
21
+ def _ensure_is_within_range(self, value: int) -> None:
22
+ min_value = self.min_value()
23
+ max_value = self.max_value()
24
+
25
+ if min_value is not None and value < min_value:
26
+ raise InvalidArgumentError(self.get_too_low_error_message(value, min_value))
27
+
28
+ if max_value is not None and value > max_value:
29
+ raise InvalidArgumentError(self.get_too_high_error_message(value, max_value))
30
+
31
+ def min_value(self) -> int | None:
32
+ return None
33
+
34
+ def max_value(self) -> int | None:
35
+ return None
36
+
37
+ def get_invalid_type_error_message(self, value: Any) -> str:
38
+ return f"Value must be a integer, got {type(value)}"
39
+
40
+ def get_too_low_error_message(self, value: int, min_value: int) -> str:
41
+ return f"Value {value} is less than minimum required {min_value}"
42
+
43
+ def get_too_high_error_message(self, value: int, max_value: int) -> str:
44
+ return f"Value {value} is greater than maximum allowed {max_value}"
@@ -6,6 +6,8 @@ class InvalidArgumentError(Exception):
6
6
  super().__init__(self.message)
7
7
 
8
8
  def __str__(self):
9
+ if not self.params:
10
+ return self.message
9
11
  return f"{self.message} {self.params}"
10
12
 
11
13
  def __repr__(self):
@@ -0,0 +1,21 @@
1
+ import ipaddress
2
+ from dataclasses import dataclass
3
+
4
+ from .invalid_argument_error import InvalidArgumentError
5
+ from .string_value_object import StringValueObject
6
+
7
+
8
+ @dataclass(frozen=True, slots=True)
9
+ class IpAddressValueObject(StringValueObject):
10
+ def __post_init__(self):
11
+ super().__post_init__()
12
+ self._ensure_is_valid_ip(self.value)
13
+
14
+ def _ensure_is_valid_ip(self, value: str) -> None:
15
+ try:
16
+ ipaddress.ip_address(value)
17
+ except ValueError:
18
+ raise InvalidArgumentError(self.get_invalid_ip_error_message(value))
19
+
20
+ def get_invalid_ip_error_message(self, value: str) -> str:
21
+ return f"'{value}' is not a valid IP address"
@@ -0,0 +1,25 @@
1
+ from dataclasses import dataclass
2
+ from decimal import Decimal
3
+ from .composite_value_object import CompositeValueObject
4
+ from .positive_decimal_value_object import PositiveDecimalValueObject
5
+ from .currency_value_object import CurrencyValueObject
6
+ from .invalid_argument_error import InvalidArgumentError
7
+
8
+
9
+ @dataclass(frozen=True, slots=True)
10
+ class MoneyValueObject(CompositeValueObject):
11
+ amount: Decimal
12
+ currency: str
13
+
14
+ def __post_init__(self):
15
+ super().__post_init__()
16
+ PositiveDecimalValueObject(self.amount)
17
+ CurrencyValueObject(self.currency)
18
+
19
+ def add(self, other: 'MoneyValueObject') -> 'MoneyValueObject':
20
+ if self.currency != other.currency:
21
+ raise InvalidArgumentError("Cannot add money with different currencies")
22
+ return MoneyValueObject(self.amount + other.amount, self.currency)
23
+
24
+ def __str__(self):
25
+ return f"{self.amount} {self.currency}"
@@ -1,25 +1,31 @@
1
1
  import re
2
+ from dataclasses import dataclass
2
3
 
3
4
  from .string_value_object import StringValueObject
4
5
  from .invalid_argument_error import InvalidArgumentError
5
6
 
6
7
 
8
+ @dataclass(frozen=True, slots=True)
7
9
  class PhoneNumberValueObject(StringValueObject):
8
10
  PHONE_REGEX = re.compile(r"^\+?[1-9]\d{6,14}$")
9
11
 
10
12
  def __init__(self, value: str):
11
13
  clean_value = self._clean_number(value)
12
14
  super().__init__(clean_value)
13
- self._ensure_is_valid_phone(clean_value)
14
15
 
15
- def _clean_number(self, value: str) -> str:
16
+ def __post_init__(self):
17
+ super().__post_init__()
18
+ self._ensure_is_valid_phone(self.value)
19
+
20
+ @staticmethod
21
+ def _clean_number(value: str) -> str:
16
22
  if not isinstance(value, str):
17
23
  return value
18
24
  return re.sub(r"[\s\-\(\)]", "", value)
19
25
 
20
26
  def _ensure_is_valid_phone(self, value: str) -> None:
21
- if not self.PHONE_REGEX.match(value):
22
- raise InvalidArgumentError(f"'{value}' is not a valid phone number")
27
+ if not PhoneNumberValueObject.PHONE_REGEX.match(value):
28
+ raise InvalidArgumentError(self.get_invalid_phone_error_message(value))
23
29
 
24
- def __repr__(self):
25
- return f"PhoneNumberValueObject(value='{self.value}')"
30
+ def get_invalid_phone_error_message(self, value: str) -> str:
31
+ return f"'{value}' is not a valid phone number"
@@ -0,0 +1,13 @@
1
+ from dataclasses import dataclass
2
+ from decimal import Decimal
3
+
4
+ from .decimal_value_object import DecimalValueObject
5
+
6
+
7
+ @dataclass(frozen=True, slots=True)
8
+ class PositiveDecimalValueObject(DecimalValueObject):
9
+ def min_value(self) -> Decimal | None:
10
+ return Decimal("0")
11
+
12
+ def get_too_low_error_message(self, value: Decimal, min_value: Decimal) -> str:
13
+ return f"'{value}' is not a positive decimal"
@@ -0,0 +1,12 @@
1
+ from dataclasses import dataclass
2
+
3
+ from .float_value_object import FloatValueObject
4
+
5
+
6
+ @dataclass(frozen=True, slots=True)
7
+ class PositiveFloatValueObject(FloatValueObject):
8
+ def min_value(self) -> float | None:
9
+ return 0.0
10
+
11
+ def get_too_low_error_message(self, value: float, min_value: float) -> str:
12
+ return f"'{value}' is not a positive float"
@@ -0,0 +1,12 @@
1
+ from dataclasses import dataclass
2
+
3
+ from .int_value_object import IntValueObject
4
+
5
+
6
+ @dataclass(frozen=True, slots=True)
7
+ class PositiveIntValueObject(IntValueObject):
8
+ def min_value(self) -> int | None:
9
+ return 0
10
+
11
+ def get_too_low_error_message(self, value: int, min_value: int) -> str:
12
+ return f"'{value}' is not a positive integer"
@@ -0,0 +1,65 @@
1
+ import re
2
+ from typing import Any
3
+ from dataclasses import dataclass
4
+
5
+ from .invalid_argument_error import InvalidArgumentError
6
+ from .value_object import ValueObject, Primitives
7
+
8
+
9
+ @dataclass(frozen=True, slots=True)
10
+ class StringValueObject(ValueObject[str]):
11
+ def __post_init__(self):
12
+ super().__post_init__()
13
+ self._ensure_value_is_string(self.value)
14
+ self._ensure_min_length(self.value)
15
+ self._ensure_max_length(self.value)
16
+ self._ensure_matches_regex(self.value)
17
+
18
+ def _ensure_value_is_string(self, value: str) -> None:
19
+ if not isinstance(value, str):
20
+ raise InvalidArgumentError(
21
+ self.get_invalid_type_error_message(value)
22
+ )
23
+
24
+ def _ensure_min_length(self, value: str) -> None:
25
+ min_length = self.min_length()
26
+ if min_length is not None and len(value) < min_length:
27
+ raise InvalidArgumentError(
28
+ self.get_too_short_error_message(value, min_length)
29
+ )
30
+
31
+ def _ensure_max_length(self, value: str) -> None:
32
+ max_length = self.max_length()
33
+ if max_length is not None and len(value) > max_length:
34
+ raise InvalidArgumentError(
35
+ self.get_too_long_error_message(value, max_length)
36
+ )
37
+
38
+ def _ensure_matches_regex(self, value: str) -> None:
39
+ pattern = self.regex_pattern()
40
+ if pattern is not None:
41
+ if not re.match(pattern, value):
42
+ raise InvalidArgumentError(
43
+ self.get_invalid_regex_error_message(value, pattern)
44
+ )
45
+
46
+ def min_length(self) -> int | None:
47
+ return None
48
+
49
+ def max_length(self) -> int | None:
50
+ return None
51
+
52
+ def regex_pattern(self) -> str | None:
53
+ return None
54
+
55
+ def get_invalid_type_error_message(self, value: Any) -> str:
56
+ return f"Value must be a string, got {type(value)}"
57
+
58
+ def get_too_short_error_message(self, value: str, min_length: int) -> str:
59
+ return f"Value is too short. Minimum length is {min_length}, but got {len(value)}"
60
+
61
+ def get_too_long_error_message(self, value: str, max_length: int) -> str:
62
+ return f"Value is too long. Maximum length is {max_length}, but got {len(value)}"
63
+
64
+ def get_invalid_regex_error_message(self, value: str, pattern: str) -> str:
65
+ return f"Value '{value}' does not match pattern '{pattern}'"
@@ -1,9 +1,11 @@
1
1
  import re
2
+ from dataclasses import dataclass
2
3
 
3
4
  from .string_value_object import StringValueObject
4
5
  from .invalid_argument_error import InvalidArgumentError
5
6
 
6
7
 
8
+ @dataclass(frozen=True, slots=True)
7
9
  class UrlValueObject(StringValueObject):
8
10
  URL_REGEX = re.compile(
9
11
  r'^(?:http|ftp)s?://'
@@ -13,13 +15,13 @@ class UrlValueObject(StringValueObject):
13
15
  r'(?::\d+)?'
14
16
  r'(?:/?|[/?]\S+)$', re.IGNORECASE)
15
17
 
16
- def __init__(self, value: str):
17
- super().__init__(value)
18
- self._ensure_is_valid_url(value)
18
+ def __post_init__(self):
19
+ super().__post_init__()
20
+ self._ensure_is_valid_url(self.value)
19
21
 
20
22
  def _ensure_is_valid_url(self, value: str) -> None:
21
- if not self.URL_REGEX.match(value):
22
- raise InvalidArgumentError(f"'{value}' is not a valid URL")
23
+ if not UrlValueObject.URL_REGEX.match(value):
24
+ raise InvalidArgumentError(self.get_invalid_url_error_message(value))
23
25
 
24
- def __repr__(self):
25
- return f"UrlValueObject(value='{self.value}')"
26
+ def get_invalid_url_error_message(self, value: str) -> str:
27
+ return f"'{value}' is not a valid URL"
@@ -0,0 +1,21 @@
1
+ import uuid
2
+ from dataclasses import dataclass
3
+
4
+ from .invalid_argument_error import InvalidArgumentError
5
+ from .value_object import ValueObject, Primitives
6
+
7
+
8
+ @dataclass(frozen=True, slots=True)
9
+ class UuidValueObject(ValueObject[str]):
10
+ def __post_init__(self):
11
+ super().__post_init__()
12
+ self._ensure_validate_is_uuid(self.value)
13
+
14
+ def _ensure_validate_is_uuid(self, value: str):
15
+ try:
16
+ uuid.UUID(value)
17
+ except ValueError:
18
+ raise InvalidArgumentError(message=self.get_invalid_uuid_error_message(value))
19
+
20
+ def get_invalid_uuid_error_message(self, value: str) -> str:
21
+ return f"'{value}' is not a valid UUID."
@@ -0,0 +1,29 @@
1
+ from abc import ABC, abstractmethod
2
+ from dataclasses import dataclass
3
+ from decimal import Decimal
4
+ from typing import TypeVar, Generic, Optional, Any
5
+
6
+ from .invalid_argument_error import InvalidArgumentError
7
+
8
+
9
+ Primitives = TypeVar('Primitives', int, str, float, bool, Decimal)
10
+
11
+ @dataclass(frozen=True, slots=True)
12
+ class ValueObject(ABC, Generic[Primitives]):
13
+ value: Primitives
14
+
15
+ def __post_init__(self):
16
+ self._ensure_value_is_defined(self.value)
17
+
18
+ def equals(self, other: Any) -> bool:
19
+ if other is None or other.__class__ != self.__class__:
20
+ return False
21
+ return self == other
22
+
23
+ def __str__(self) -> str:
24
+ return str(self.value)
25
+
26
+ @staticmethod
27
+ def _ensure_value_is_defined(value: Optional[Primitives]) -> None:
28
+ if value is None:
29
+ raise InvalidArgumentError("Value must be defined")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ddd-value-objects
3
- Version: 0.1.7
3
+ Version: 0.2.0
4
4
  Summary: A collection of base classes for Domain-Driven Design (DDD) value objects and entities in Python
5
5
  License: MIT
6
6
  Requires-Python: >=3.10