├── requirements-test.txt ├── docker └── test ├── .isort.cfg ├── Dockerfile ├── simple_value_object ├── exceptions.py ├── __init__.py ├── decorators.py └── value_object.py ├── AUTHORS ├── .github └── workflows │ └── python-package.yml ├── .gitignore ├── setup.py ├── LICENSE ├── CHANGELOG.md ├── README.md └── specs └── value_object_spec.py /requirements-test.txt: -------------------------------------------------------------------------------- 1 | expects==0.9.0 2 | mamba==0.11.3 3 | -------------------------------------------------------------------------------- /docker/test: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | docker build -t simple-value-object:latest . 4 | docker run --rm -it simple-value-object:latest mamba --f documentation 5 | -------------------------------------------------------------------------------- /.isort.cfg: -------------------------------------------------------------------------------- 1 | [settings] 2 | line_length=99 3 | known_third_party=expects,mamba 4 | indent=4 5 | multi_line_output=3 6 | default_section=FIRSTPARTY 7 | order_by_type=true 8 | force_grid_wrap=true -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.12-alpine 2 | 3 | WORKDIR /app 4 | 5 | ENV PYTHONPATH="/app" 6 | 7 | COPY ./requirements-test.txt / 8 | 9 | RUN pip install --upgrade pip 10 | RUN pip install -r /requirements-test.txt 11 | 12 | COPY . /app 13 | -------------------------------------------------------------------------------- /simple_value_object/exceptions.py: -------------------------------------------------------------------------------- 1 | class SimpleValueObjectException(Exception): 2 | pass 3 | 4 | 5 | class InvariantViolation(SimpleValueObjectException): 6 | pass 7 | 8 | 9 | class InvariantMustReturnBool(SimpleValueObjectException): 10 | def __init__(self): 11 | super().__init__("Invariants must return a boolean value") 12 | -------------------------------------------------------------------------------- /AUTHORS: -------------------------------------------------------------------------------- 1 | Author 2 | ------ 3 | Quique Porta https://github.com/quiqueporta 4 | 5 | 6 | Contributors 7 | --------------- 8 | William Travis Jones https://github.com/wtjones 9 | David Arias http://davidarias.net 10 | Hugo Leonardo Costa e Silva https://github.com/hugoleodev 11 | Jesse Heitler https://github.com/jesseh 12 | -------------------------------------------------------------------------------- /simple_value_object/__init__.py: -------------------------------------------------------------------------------- 1 | from .value_object import ValueObject 2 | from .decorators import invariant 3 | 4 | VERSION = (3, 3, 0, "final") 5 | __version__ = VERSION 6 | 7 | 8 | def get_version(): 9 | version = "{}.{}".format(VERSION[0], VERSION[1]) 10 | if VERSION[2]: 11 | version = "{}.{}".format(version, VERSION[2]) 12 | if VERSION[3:] == ("alpha", 0): 13 | version = "{} pre-alpha".format(version) 14 | else: 15 | if VERSION[3] != "final": 16 | version = "{} {}".format(version, VERSION[3]) 17 | return version 18 | -------------------------------------------------------------------------------- /.github/workflows/python-package.yml: -------------------------------------------------------------------------------- 1 | name: Python package 2 | 3 | on: [push] 4 | 5 | jobs: 6 | build: 7 | 8 | runs-on: ubuntu-latest 9 | strategy: 10 | matrix: 11 | python-version: ["3.7", "3.8", "3.9", "3.10", "3.11", "3.12"] 12 | 13 | steps: 14 | - uses: actions/checkout@v4 15 | - name: Set up Python ${{ matrix.python-version }} 16 | uses: actions/setup-python@v5 17 | with: 18 | python-version: ${{ matrix.python-version }} 19 | - name: Install dependencies 20 | run: | 21 | python -m pip install --upgrade pip 22 | pip install -r requirements-test.txt 23 | - name: Test with mamba 24 | run: | 25 | PYTHONPATH=$PYTHONPATH:. mamba -f documentation 26 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | 5 | # C extensions 6 | *.so 7 | 8 | # Distribution / packaging 9 | .Python 10 | env/ 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | *.egg-info/ 23 | .installed.cfg 24 | *.egg 25 | 26 | # PyInstaller 27 | # Usually these files are written by a python script from a template 28 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 29 | *.manifest 30 | *.spec 31 | 32 | # Installer logs 33 | pip-log.txt 34 | pip-delete-this-directory.txt 35 | 36 | # Unit test / coverage reports 37 | htmlcov/ 38 | .tox/ 39 | .coverage 40 | .coverage.* 41 | .cache 42 | nosetests.xml 43 | coverage.xml 44 | *,cover 45 | 46 | # Translations 47 | *.mo 48 | *.pot 49 | 50 | *.log 51 | 52 | # PyBuilder 53 | target/ 54 | 55 | .idea/ 56 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | from simple_value_object import get_version 4 | 5 | 6 | setup( 7 | name="simple-value-object", 8 | version=get_version(), 9 | license="MIT/X11", 10 | author="Quique Porta", 11 | author_email="me@quiqueporta.com", 12 | description="A simple library to create Value Objects", 13 | long_description=open("README.md").read(), 14 | long_description_content_type="text/markdown", 15 | url="https://github.com/quiqueporta/value-object", 16 | download_url="https://github.com/quiqueporta/simple-value-object/releases", 17 | keywords=["python", "ddd"], 18 | packages=["simple_value_object"], 19 | classifiers=[ 20 | "Development Status :: 5 - Production/Stable", 21 | "Intended Audience :: Developers", 22 | "License :: OSI Approved :: MIT License", 23 | "Natural Language :: English", 24 | "Operating System :: OS Independent", 25 | "Programming Language :: Python", 26 | "Topic :: Utilities", 27 | ], 28 | ) 29 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2015 Quique Porta 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of 4 | this software and associated documentation files (the "Software"), to deal in 5 | the Software without restriction, including without limitation the rights to 6 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 7 | the Software, and to permit persons to whom the Software is furnished to do so, 8 | subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 15 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 16 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 17 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 18 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 19 | 20 | -------------------------------------------------------------------------------- /simple_value_object/decorators.py: -------------------------------------------------------------------------------- 1 | import functools 2 | from .exceptions import InvariantMustReturnBool, InvariantViolation 3 | 4 | 5 | def invariant(func=None, *, exception_type=None): 6 | # Check if func is actually the exception type (when called as @invariant(MyException)) 7 | if isinstance(func, type) and issubclass(func, Exception): 8 | exception_type = func 9 | func = None 10 | 11 | # Decorator called with parentheses and arguments 12 | if func is None: 13 | return lambda f: invariant(f, exception_type=exception_type) 14 | 15 | # Decorator called without parentheses or with parentheses but without arguments 16 | @functools.wraps(func) 17 | def wrapper(*args, **kwargs): 18 | result = func(*args, **kwargs) 19 | message = "" 20 | 21 | if isinstance(result, tuple): 22 | result, message = result[0], result[1] 23 | 24 | if not isinstance(result, bool): 25 | raise InvariantMustReturnBool() 26 | 27 | if result is False: 28 | if exception_type: 29 | raise exception_type(message) 30 | raise InvariantViolation(f"Invariant violation: {func.__name__}") 31 | 32 | return result 33 | 34 | setattr(wrapper, "_invariant", True) 35 | 36 | return wrapper 37 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## 3.3.0 (2024-05-25) 4 | 5 | - Fix hash for value objects. 6 | - Remove the CannotBeChanged exception. It is not necessary anymore. 7 | 8 | ## 3.2.0 (2024-05-25) 9 | 10 | - Fix custom exceptions to allow to pass them as no kwargs 11 | 12 | ## 3.1.1 (2024-05-24) 13 | 14 | - Add customs exceptions for invariants 15 | 16 | ## 3.0.1 (2024-05-10) 17 | 18 | - Remove unused imports 19 | 20 | ## 3.0.0 (2024-04-13) 21 | 22 | - Use python dataclass to create ValueObject 23 | 24 | ## 2.0.0 (2022-10-27) 25 | 26 | - Rename CannotBeChangeException to CannotBeChanged 27 | - Rename InvariantReturnValueException to InvariantMustReturnBool 28 | - Rename NotDeclaredArgsException to ConstructorWithoutArguments 29 | - Rename ViolatedInvariantException to InvariantViolation 30 | - Simplifly invariants now receives `self` attribute only 31 | - Fix replace_mutable_kwargs_with_immutable_types for `set` kwargs 32 | 33 | ## 1.5.0 (2020-12-07) 34 | 35 | - Allow None as params. You can control it with invariants. 36 | 37 | ## 1.4.0 (2020-06-13) 38 | 39 | - Removing deprecation warnings 40 | - Change license to MIT/X11 41 | 42 | ## 1.3.0 (2019-04-29) 43 | 44 | - Allow mutable data types but restricted for modifications. 45 | 46 | ## 1.2.0 (2019-01-15) 47 | 48 | - Reduced the creation time 49 | 50 | ## 1.1.1 (2015-09-05) 51 | 52 | - Fix hash for value objects within value objects. 53 | 54 | ## 1.1.0 (2018-09-04) 55 | 56 | - Fix value objects within value objects. 57 | - Add value objects representation. 58 | 59 | ## 1.0.1 (2018-06-22) 60 | 61 | - Mutable types are not allowed. 62 | 63 | ## 0.2.1 (2015-10-05) 64 | 65 | - Removed unnecessary code. 66 | 67 | ## 0.2.0 (2015-10-03) 68 | 69 | - Created a invariant decorator. 70 | 71 | ## 0.1.0 (2015-10-01) 72 | 73 | - Initial release. 74 | -------------------------------------------------------------------------------- /simple_value_object/value_object.py: -------------------------------------------------------------------------------- 1 | import hashlib 2 | from dataclasses import dataclass, FrozenInstanceError 3 | 4 | 5 | class ValueObject: 6 | def __init_subclass__(cls): 7 | cls = dataclass(cls, frozen=True) 8 | 9 | def __post_init__(self): 10 | self.__replace_mutable_fields_with_immutable() 11 | self.__check_invariants() 12 | 13 | @property 14 | def hash(self): 15 | return int(self.__calculate_hash(), 16) 16 | 17 | def __replace_mutable_fields_with_immutable(self): 18 | mutable_types = { 19 | dict: immutable_dict, 20 | list: immutable_list, 21 | set: immutable_set, 22 | } 23 | for field in self.__get_mutable_fields(): 24 | object.__setattr__( 25 | self, 26 | field.name, 27 | mutable_types[field.type](getattr(self, field.name)), 28 | ) 29 | 30 | def __get_mutable_fields(self): 31 | return filter( 32 | lambda field: field.type in (dict, list, set), 33 | self.__dataclass_fields__.values(), 34 | ) 35 | 36 | def __check_invariants(self): 37 | for invariant in self.__obtain_invariants(): 38 | invariant() 39 | 40 | def __obtain_invariants(self): 41 | invariant_methods = [ 42 | method 43 | for method in dir(self) 44 | if callable(getattr(self, method)) 45 | and hasattr(getattr(self, method), "_invariant") 46 | ] 47 | for invariant in invariant_methods: 48 | yield getattr(self, invariant) 49 | 50 | def __calculate_hash(self): 51 | hash_content = "".join(str(value) for value in self.__dict__.values()) 52 | return hashlib.sha256(hash_content.encode()).hexdigest() 53 | 54 | def __hash__(self): 55 | return self.hash 56 | 57 | def __str__(self): 58 | return repr(self) 59 | 60 | def __repr__(self): 61 | class_name = self.__class__.__name__.split(".")[-1] 62 | attribute_str = ", ".join( 63 | f"{key}={value}" for key, value in self.__dict__.items() 64 | ) 65 | return f"{class_name}({attribute_str})" 66 | 67 | 68 | class immutable_dict(dict): 69 | 70 | def _immutable(self, *args, **kwargs): 71 | raise FrozenInstanceError() 72 | 73 | __setitem__ = _immutable 74 | __delitem__ = _immutable 75 | clear = _immutable 76 | update = _immutable 77 | setdefault = _immutable 78 | pop = _immutable 79 | popitem = _immutable 80 | 81 | 82 | class immutable_list(list): 83 | 84 | def _immutable(self, *args, **kwargs): 85 | raise FrozenInstanceError() 86 | 87 | __setitem__ = _immutable 88 | __delitem__ = _immutable 89 | append = _immutable 90 | extend = _immutable 91 | insert = _immutable 92 | pop = _immutable 93 | remove = _immutable 94 | clear = _immutable 95 | reverse = _immutable 96 | sort = _immutable 97 | 98 | 99 | class immutable_set(set): 100 | 101 | def _immutable(self, *args, **kwargs): 102 | raise FrozenInstanceError() 103 | 104 | add = _immutable 105 | clear = _immutable 106 | difference_update = _immutable 107 | discard = _immutable 108 | intersection_update = _immutable 109 | pop = _immutable 110 | remove = _immutable 111 | symmetric_difference_update = _immutable 112 | update = _immutable 113 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Value Object 2 | 3 | ![Version number](https://img.shields.io/badge/version-3.3.0-blue.svg) ![License MIT](https://img.shields.io/github/license/quiqueporta/simple-value-object) ![Python Version](https://img.shields.io/badge/python-3.7,_3.8,_3.9,_3.10,3.11,3.12-blue.svg) 4 | 5 | Based on Ruby Gem by [NoFlopSquad](https://github.com/noflopsquad/value-object) 6 | 7 | A **value object** is a small object that represents a simple entity whose equality isn't based on identity: 8 | i.e. two value objects are equal when they have the same value, not necessarily being the same object. 9 | 10 | [Wikipedia](http://en.wikipedia.org/wiki/Value_object) 11 | 12 | ## New version 3.x 13 | 14 | This new version is a complete rewrite of the library, now it uses data classes to define the value objects. 15 | With this change we can use type hints to define the fields and the library will take care of the rest. 16 | Now you have autocomplete and type checking in your IDE. With the previous version, you did no autocomplete or type-checking. 17 | You should be able to use this library with any version of Python 3.7 or higher. 18 | 19 | ## Installation 20 | 21 | ```sh 22 | pip install simple-value-object 23 | ``` 24 | 25 | ## Usage 26 | 27 | ### Constructor and field readers 28 | 29 | ```python 30 | from simple_value_object import ValueObject 31 | 32 | class Point(ValueObject): 33 | x: int 34 | y: int 35 | 36 | point = Point(1, 2) 37 | 38 | point.x 39 | # 1 40 | 41 | point.y 42 | # 2 43 | 44 | point.x = 5 45 | # CannotBeChanged: You cannot change values from a Value Object, create a new one 46 | 47 | class Date(ValueObject): 48 | day: int 49 | month: int 50 | year: int 51 | 52 | date = Date(1, 10, 2015) 53 | 54 | date.day 55 | # 1 56 | 57 | date.month 58 | # 10 59 | 60 | date.year 61 | # 2015 62 | 63 | date.month = 5 64 | # CannotBeChanged: You cannot change values from a Value Object, create a new one 65 | ``` 66 | 67 | ### Equality based on field values 68 | 69 | ```python 70 | from simple_value_object import ValueObject 71 | 72 | class Point(ValueObject): 73 | x: int 74 | y: int 75 | 76 | 77 | a_point = Point(5, 3) 78 | 79 | same_point = Point(5, 3) 80 | 81 | a_point == same_point 82 | # True 83 | 84 | a_different_point = Point(6, 3) 85 | 86 | a_point == a_different_point 87 | # False 88 | ``` 89 | 90 | ### Hash code based on field values 91 | 92 | ```python 93 | from simple_value_object import ValueObject 94 | 95 | class Point(ValueObject): 96 | x: int 97 | y: int 98 | 99 | a_point = Point(5, 3) 100 | 101 | same_point = Point(5, 3) 102 | 103 | a_point.hash == same_point.hash 104 | # True 105 | 106 | a_different_point = Point.new(6, 3) 107 | 108 | a_point.hash == a_different_point.hash 109 | # False 110 | ``` 111 | 112 | ### Invariants 113 | 114 | Invariants **must** return a boolean value. 115 | 116 | ```python 117 | from simple_value_object import ValueObject, invariant 118 | 119 | class Point(ValueObject): 120 | x: int 121 | y: int 122 | 123 | @invariant 124 | def inside_first_quadrant(self): 125 | return self.x > 0 and self.y > 0 126 | 127 | @invariant 128 | def x_lower_than_y(self): 129 | return self.x < self.y 130 | 131 | Point(-5, 3) 132 | #InvariantViolation: inside_first_cuadrant 133 | 134 | Point(6, 3) 135 | #InvariantViolation: x_lower_than_y 136 | 137 | Point(1,3) 138 | #<__main__.Point at 0x7f2bd043c780> 139 | 140 | ``` 141 | 142 | #### Custom exceptions for invariants 143 | 144 | You can throw custom exceptions when an invariant is violated and also return the message of 145 | the exception that will be raised. 146 | 147 | ```python 148 | 149 | from simple_value_object import ValueObject, invariant 150 | 151 | class MyException(Exception): 152 | pass 153 | 154 | 155 | class Point(ValueObject): 156 | x: int 157 | y: int 158 | 159 | @invariant(exception_type=MyException) 160 | def inside_first_quadrant(self): 161 | return self.x > 0 and self.y > 0, "You must be inside the first quadrant" 162 | 163 | @invariant(MyException) 164 | def x_lower_than_y(self): 165 | return self.x < self.y, "X must be lower than Y" 166 | 167 | Point(-5, 3) 168 | #MyException: "You must be inside the first quadrant" 169 | ``` 170 | 171 | ### ValueObject within ValueObject 172 | 173 | ```python 174 | from simple_value_object import ValueObject, invariant 175 | 176 | class Currency(ValueObject): 177 | symbol: str 178 | 179 | class Money(ValueObject): 180 | amount: Decimal 181 | currency: Currency 182 | 183 | Money(amount=Decimal("100"), currency=Currency(symbol="€")) 184 | ``` 185 | 186 | ## Tests 187 | 188 | ```sh 189 | docker/test 190 | ``` 191 | -------------------------------------------------------------------------------- /specs/value_object_spec.py: -------------------------------------------------------------------------------- 1 | from dataclasses import FrozenInstanceError 2 | from expects import * 3 | from decimal import Decimal 4 | from mamba import context, description, it 5 | 6 | from simple_value_object import ValueObject, invariant 7 | 8 | from simple_value_object.exceptions import ( 9 | InvariantViolation, 10 | InvariantMustReturnBool, 11 | ) 12 | 13 | 14 | class Point(ValueObject): 15 | x: int 16 | y: int 17 | 18 | 19 | class PointWithDefault(ValueObject): 20 | x: int 21 | y: int = 3 22 | 23 | 24 | class Currency(ValueObject): 25 | symbol: str 26 | 27 | 28 | class Money(ValueObject): 29 | amount: Decimal 30 | currency: Currency 31 | 32 | 33 | class ValueObjectWithDict(ValueObject): 34 | a_dict: dict 35 | 36 | 37 | class ValueObjectWithList(ValueObject): 38 | a_list: list 39 | 40 | 41 | class ValueObjectWithSet(ValueObject): 42 | a_set: set 43 | 44 | 45 | class PointWithInvariants(ValueObject): 46 | x: int 47 | y: int 48 | 49 | @invariant 50 | def inside_first_quadrant(self): 51 | return self.x > 0 and self.y > 0 52 | 53 | @invariant 54 | def x_lower_than_y(self): 55 | return self.x < self.y 56 | 57 | 58 | with description("Value Object"): 59 | 60 | with context("standard behavior"): 61 | 62 | with it("generates constructor, fields and accessors for declared fields"): 63 | a_point = Point(5, 3) 64 | 65 | expect(a_point.x).to(equal(5)) 66 | expect(a_point.y).to(equal(3)) 67 | 68 | with it("provides equality based on declared fields values"): 69 | a_point = Point(5, 3) 70 | same_point = Point(5, 3) 71 | different_point = Point(6, 3) 72 | 73 | expect(a_point).to(equal(same_point)) 74 | expect(a_point).not_to(equal(different_point)) 75 | expect(a_point != different_point).to(be_true) 76 | 77 | with it("provides hash code generation based on declared fields values"): 78 | a_point = Point(5, 3) 79 | same_point = Point(5, 3) 80 | different_point = Point(6, 3) 81 | 82 | expect(hash(a_point)).to(equal(hash(same_point))) 83 | expect(hash(a_point)).not_to(equal(hash(different_point))) 84 | 85 | with it("values can not be changed"): 86 | a_point = Point(5, 3) 87 | 88 | def change_point(): 89 | a_point.x = 4 90 | 91 | expect(lambda: change_point()).to( 92 | raise_error( 93 | FrozenInstanceError, 94 | "cannot assign to field 'x'", 95 | ) 96 | ) 97 | 98 | with it("can set default values"): 99 | 100 | class MyPoint(ValueObject): 101 | x: int 102 | y: int = 3 103 | 104 | my_point = MyPoint(5) 105 | expect(my_point.x).to(equal(5)) 106 | expect(my_point.y).to(equal(3)) 107 | 108 | with it("can set another value object as parameter"): 109 | a_money = Money(Decimal("100"), Currency("€")) 110 | 111 | expect(a_money).to(equal(Money(Decimal("100"), Currency("€")))) 112 | expect(a_money.currency).to(equal(Currency("€"))) 113 | 114 | with it("can compare hash for value objects within value objects"): 115 | a_money = Money(Decimal("100"), Currency("€")) 116 | 117 | another_money = Money(Decimal("100"), Currency("€")) 118 | expect(hash(a_money)).to(equal(hash(another_money))) 119 | 120 | with it("provides a representation"): 121 | 122 | a_point_object = Point(6, 7) 123 | a_point_object_with_defaults = PointWithDefault(6) 124 | a_point_object_within_value_object = Money(Decimal("100"), Currency("€")) 125 | 126 | expect(str(a_point_object)).to(equal("Point(x=6, y=7)")) 127 | expect(repr(a_point_object)).to(equal("Point(x=6, y=7)")) 128 | expect(str(a_point_object_with_defaults)).to( 129 | equal("PointWithDefault(x=6, y=3)") 130 | ) 131 | expect(repr(a_point_object_with_defaults)).to( 132 | equal("PointWithDefault(x=6, y=3)") 133 | ) 134 | expect(str(a_point_object_within_value_object)).to( 135 | equal("Money(amount=Decimal('100'), currency=Currency(symbol='€'))") 136 | ) 137 | expect(repr(a_point_object_within_value_object)).to( 138 | equal("Money(amount=Decimal('100'), currency=Currency(symbol='€'))") 139 | ) 140 | 141 | with context("inheritance"): 142 | 143 | with it("can inherit from another value object"): 144 | 145 | class MyPointException(Exception): 146 | pass 147 | 148 | class Point(ValueObject): 149 | x: int 150 | y: int 151 | 152 | @invariant 153 | def x_not_negative(self): 154 | return self.x >= 0 155 | 156 | class MyPoint(Point): 157 | 158 | @invariant(MyPointException) 159 | def y_not_negative(self): 160 | return self.y >= 0, "Y must be positive" 161 | 162 | expect(lambda: MyPoint(6, -1)).to( 163 | raise_error(MyPointException, "Y must be positive") 164 | ) 165 | 166 | with context("restrictions"): 167 | 168 | with context("with mutable data types"): 169 | 170 | with it("does not allow to change dicts arguments"): 171 | 172 | a_value_object = ValueObjectWithDict(a_dict={"key": "value"}) 173 | another_value_object = ValueObjectWithDict({"key": "value"}) 174 | 175 | expect( 176 | lambda: a_value_object.a_dict.update({"key": "another_value"}) 177 | ).to(raise_error(FrozenInstanceError)) 178 | expect( 179 | lambda: another_value_object.a_dict.update({"key": "another_value"}) 180 | ).to(raise_error(FrozenInstanceError)) 181 | expect(lambda: a_value_object.a_dict.clear()).to( 182 | raise_error(FrozenInstanceError) 183 | ) 184 | expect(lambda: another_value_object.a_dict.clear()).to( 185 | raise_error(FrozenInstanceError) 186 | ) 187 | 188 | def remove_key(): 189 | del a_value_object.a_dict["key"] 190 | del another_value_object.a_dict["key"] 191 | 192 | expect(lambda: remove_key()).to(raise_error(FrozenInstanceError)) 193 | 194 | def change_key(): 195 | a_value_object.a_dict["key"] = "new_value" 196 | another_value_object.a_dict["key"] = "new_value" 197 | 198 | expect(lambda: change_key()).to(raise_error(FrozenInstanceError)) 199 | expect(lambda: a_value_object.a_dict.pop()).to( 200 | raise_error(FrozenInstanceError) 201 | ) 202 | expect(lambda: another_value_object.a_dict.pop()).to( 203 | raise_error(FrozenInstanceError) 204 | ) 205 | expect(lambda: a_value_object.a_dict.popitem()).to( 206 | raise_error(FrozenInstanceError) 207 | ) 208 | expect(lambda: another_value_object.a_dict.popitem()).to( 209 | raise_error(FrozenInstanceError) 210 | ) 211 | expect(lambda: a_value_object.a_dict.setdefault("key")).to( 212 | raise_error(FrozenInstanceError) 213 | ) 214 | expect(lambda: another_value_object.a_dict.setdefault("key")).to( 215 | raise_error(FrozenInstanceError) 216 | ) 217 | 218 | with it("does not allow to change lists arguments"): 219 | 220 | a_value_object = ValueObjectWithList(a_list=[1, 2, 3]) 221 | another_value_object = ValueObjectWithList([1, 2, 3]) 222 | 223 | def change_list_item(): 224 | a_value_object.a_list[0] = 4 225 | another_value_object.a_list[0] = 4 226 | 227 | expect(lambda: change_list_item()).to(raise_error(FrozenInstanceError)) 228 | 229 | def delete_list_item(): 230 | del a_value_object.a_list[0] 231 | del another_value_object.a_list[0] 232 | 233 | expect(lambda: delete_list_item()).to(raise_error(FrozenInstanceError)) 234 | expect(lambda: a_value_object.a_list.clear()).to( 235 | raise_error(FrozenInstanceError) 236 | ) 237 | expect(lambda: another_value_object.a_list.clear()).to( 238 | raise_error(FrozenInstanceError) 239 | ) 240 | 241 | with it("does not allow to change set arguments"): 242 | 243 | a_value_object = ValueObjectWithSet(a_set=set([1, 2, 3])) 244 | another_value_object = ValueObjectWithSet(set([1, 2, 3])) 245 | 246 | def change_list_item(): 247 | a_value_object.a_set.clear() 248 | another_value_object.a_set.clear() 249 | 250 | expect(lambda: change_list_item()).to(raise_error(FrozenInstanceError)) 251 | 252 | with context("forcing invariants"): 253 | 254 | with it("forces declared invariants"): 255 | 256 | expect(lambda: PointWithInvariants(5, -3)).to( 257 | raise_error( 258 | InvariantViolation, "Invariant violation: inside_first_quadrant" 259 | ) 260 | ) 261 | 262 | expect(lambda: PointWithInvariants(6, 3)).to( 263 | raise_error(InvariantViolation, "Invariant violation: x_lower_than_y") 264 | ) 265 | 266 | with it( 267 | "raises an exception when a declared invariant does not return a boolean value" 268 | ): 269 | 270 | class Date(ValueObject): 271 | day: int 272 | month: int 273 | year: int 274 | 275 | @invariant 276 | def first_year_quarter(self): 277 | return 0 278 | 279 | expect(lambda: Date(8, 6, 2002)).to(raise_error(InvariantMustReturnBool)) 280 | 281 | with context("custom invariant exceptions"): 282 | 283 | with it("raises a custom exception"): 284 | 285 | class MyCustomException(Exception): 286 | pass 287 | 288 | class Foo(ValueObject): 289 | any: str 290 | 291 | @invariant(exception_type=MyCustomException) 292 | def bar(self): 293 | return self.any == "foo" 294 | 295 | expect(lambda: Foo("buzz")).to(raise_error(MyCustomException)) 296 | 297 | with it("can return a custom message for the exception"): 298 | 299 | class MyCustomException(Exception): 300 | pass 301 | 302 | class Foo(ValueObject): 303 | any: str 304 | 305 | @invariant(exception_type=MyCustomException) 306 | def bar(self): 307 | return self.any == "foo", "This is a custom message" 308 | 309 | expect(lambda: Foo("buzz")).to( 310 | raise_error(MyCustomException, "This is a custom message") 311 | ) 312 | 313 | with it( 314 | "can return a custom message for the exception when passed as no kwargs" 315 | ): 316 | 317 | class MyCustomException(Exception): 318 | pass 319 | 320 | class Foo(ValueObject): 321 | any: str 322 | 323 | @invariant(MyCustomException) 324 | def bar(self): 325 | return self.any == "foo", "This is a custom message" 326 | 327 | expect(lambda: Foo("buzz")).to( 328 | raise_error(MyCustomException, "This is a custom message") 329 | ) 330 | --------------------------------------------------------------------------------