entity.py

Last source

View documentation

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
"""Package to handle isshub entities validation.

It is an adapter over the ``attrs`` external dependency.

"""
from typing import (
    TYPE_CHECKING,
    Any,
    Callable,
    Generic,
    Optional,
    Type,
    TypeVar,
    Union,
    cast,
)
from uuid import UUID

import attr


_T = TypeVar("_T")

if TYPE_CHECKING:
    from attr.__init__ import Attribute  # isort:skip
else:

    class Attribute(Generic[_T]):
        """Class for typing when not using mypy, for example when using ``get_type_hints``.

        :meta private:
        """


class _InstanceOfSelfValidator(
    attr.validators._InstanceOfValidator  # type: ignore  # pylint: disable=protected-access
):
    """Validator checking that the field holds an instance of its own entity."""

    def __call__(self, inst, attr, value):  # type: ignore  # pylint: disable=redefined-outer-name
        """Validate that the `value` is an instance of the class of `inst`.

        For the parameters, see ``attr.validators._InstanceOfValidator``
        """
        self.type = inst.__class__
        super().__call__(inst, attr, value)


def instance_of_self() -> _InstanceOfSelfValidator:
    """Return a validator checking that the field holds an instance of its own entity.

    Returns
    -------
    _InstanceOfSelfValidator
        The instantiated validator
    """
    return _InstanceOfSelfValidator(type=None)


def optional_field(
    field_type: Union[Type[_T], str], relation_verbose_name: Optional[str] = None
) -> Optional[_T]:
    """Define an optional field of the specified `field_type`.

    Parameters
    ----------
    field_type : Union[type, str]
        The expected type of the field. Use the string "self" to reference the current field's
        entity
    relation_verbose_name : Optional[str]
        A verbose name to describe the relation between the entity linked to the field, and the
        entity pointed by `field_type`

    Returns
    -------
    Any
        An ``attrs`` attribute, with a default value set to ``None``, and a validator checking
        that this field is optional and, if set, of the correct type.

    Raises
    ------
    AssertionError
        If `field_type` is a string and this string is not "self"

    Examples
    --------
    >>> from isshub.domain.utils.entity import optional_field, validated, BaseEntity
    >>>
    >>> @validated()
    ... class MyEntity(BaseEntity):
    ...     my_field: str = optional_field(str)
    >>>
    >>> from isshub.domain.utils.testing.validation import check_field_nullable
    >>> check_field_nullable(MyEntity, 'my_field', my_field='foo')

    """
    metadata = {}
    if relation_verbose_name:
        metadata["relation_verbose_name"] = relation_verbose_name

    assert not isinstance(field_type, str) or field_type == "self"

    return attr.ib(
        default=None,
        validator=attr.validators.optional(
            instance_of_self()
            if isinstance(field_type, str)
            else attr.validators.instance_of(field_type)
        ),
        metadata=metadata,
    )


def required_field(
    field_type: Union[Type[_T], str],
    frozen: bool = False,
    relation_verbose_name: Optional[str] = None,
) -> _T:
    """Define a required field of the specified `field_type`.

    Parameters
    ----------
    field_type : Union[type, str]
        The expected type of the field. Use the string "self" to reference the current field's
        entity
    frozen : bool
        If set to ``False`` (the default), the field can be updated after being set at init time.
        If set to ``True``, the field can be set at init time but cannot be changed later, else a
        ``FrozenAttributeError`` exception will be raised.
    relation_verbose_name : Optional[str]
        A verbose name to describe the relation between the entity linked to the field, and the
        entity pointed by `field_type`

    Returns
    -------
    Any
        An ``attrs`` attribute, and a validator checking that this field is of the correct type.

    Raises
    ------
    AssertionError
        If `field_type` is a string and this string is not "self"

    Examples
    --------
    >>> from isshub.domain.utils.entity import required_field, validated, BaseEntity
    >>>
    >>> @validated()
    ... class MyEntity(BaseEntity):
    ...     my_field: str = required_field(str)
    >>>
    >>> from isshub.domain.utils.testing.validation import check_field_not_nullable
    >>> check_field_not_nullable(MyEntity, 'my_field', my_field='foo')

    """
    metadata = {}
    if relation_verbose_name:
        metadata["relation_verbose_name"] = relation_verbose_name

    assert not isinstance(field_type, str) or field_type == "self"

    kwargs = {
        "validator": instance_of_self()
        if isinstance(field_type, str)
        else attr.validators.instance_of(field_type),
        "metadata": metadata,
    }
    if frozen:
        kwargs["on_setattr"] = attr.setters.frozen

    return attr.ib(**kwargs)  # type: ignore


def validated() -> Any:
    """Decorate an entity to handle validation.

    This will let ``attrs`` manage the class, using slots for fields, and forcing attributes to
    be passed as named arguments (this allows to not have to defined all required fields first, then
    optional ones, and resolves problems with inheritance where we can't handle the order)

    Returns
    -------
    type
        The decorated class.

    Examples
    --------
    >>> from isshub.domain.utils.entity import required_field, validated, BaseEntity
    >>>
    >>> @validated()
    ... class MyEntity(BaseEntity):
    ...     my_field: str = required_field(str)
    >>>
    >>> MyEntity.__slots__
    ('my_field',)
    >>>
    >>> instance = MyEntity()
    Traceback (most recent call last):
        ...
    TypeError: __init__() missing 1 required keyword-only argument: 'my_field'
    >>> instance = MyEntity(my_field='foo')
    >>> instance.my_field
    'foo'
    >>> instance.validate()
    >>> instance.my_field = None
    >>> instance.validate()
    Traceback (most recent call last):
        ...
    TypeError: ("'my_field' must be <class 'str'> (got None that is a <class 'NoneType'>)...

    """
    return attr.s(slots=True, kw_only=True, eq=False)


TValidateMethod = TypeVar(
    "TValidateMethod", bound=Callable[[Any, "Attribute[_T]", _T], None]
)


class field_validator:  # pylint: disable=invalid-name
    """Decorate an entity method to make it a validator of the given `field`.

    Notes
    -----
    It's easier to implement as a function but we couldn't make mypy work with it.
    Thanks to https://github.com/python/mypy/issues/1551#issuecomment-253978622

    Parameters
    ----------
    field : Any
        The field to validate.

    Examples
    --------
    >>> from isshub.domain.utils.entity import field_validator, required_field, BaseEntity
    >>>
    >>> @validated()
    ... class MyEntity(BaseEntity):
    ...    my_field: str = required_field(str)
    ...
    ...    @field_validator(my_field)
    ...    def validate_my_field(self, field, value):
    ...        if value != 'foo':
    ...            raise ValueError(f'{self.__class__.__name__}.my_field must be "foo"')
    >>>
    >>> instance = MyEntity(my_field='bar')
    Traceback (most recent call last):
        ...
    ValueError: MyEntity.my_field must be "foo"
    >>> instance = MyEntity(my_field='foo')
    >>> instance.my_field
    'foo'
    >>> instance.my_field = 'bar'
    >>> instance.validate()
    Traceback (most recent call last):
        ...
    ValueError: MyEntity.my_field must be "foo"
    >>> instance.my_field = 'foo'
    >>> instance.validate()
    >>> instance.my_field
    'foo'

    """

    def __init__(self, field: "Attribute[_T]") -> None:
        """Save the given field."""
        self.field = field

    def __call__(self, func: TValidateMethod) -> TValidateMethod:
        """Decorate the given function.

        Parameters
        ----------
        func: Callable
            The validation method to decorate

        Returns
        -------
        Callable
            The decorated method.

        """
        return cast(TValidateMethod, self.field.validator(func))


def validate_instance(instance: Any) -> Any:
    """Validate a whole instance.

    Parameters
    ----------
    instance : Any
        The instance to validate.

    Raises
    ------
    TypeError, ValueError
        If a field in the `instance` is not valid.

    Examples
    --------
    >>> from isshub.domain.utils.entity import required_field, validate_instance, BaseEntity
    >>>
    >>> @validated()
    ... class MyEntity(BaseEntity):
    ...    my_field: str = required_field(str)
    >>>
    >>> instance = MyEntity(my_field='foo')
    >>> validate_instance(instance)
    >>> instance.my_field = None
    >>> validate_instance(instance)
    Traceback (most recent call last):
        ...
    TypeError: ("'my_field' must be <class 'str'> (got None that is a <class 'NoneType'>)...

    """
    attr.validate(instance)


def validate_positive_integer(
    value: Any, none_allowed: bool, display_name: str
) -> None:
    """Validate that the given `value` is a positive integer (``None`` accepted if `none_allowed`).

    Parameters
    ----------
    value : Any
        The value to validate as a positive integer.
    none_allowed : bool
        If ``True``, the value can be ``None``. If ``False``, the value must be a positive integer.
    display_name : str
        The name of the field to display in errors.

    Raises
    ------
    TypeError
        If `value` is not of type ``int``.
    ValueError
        If `value` is not a positive integer (ie > 0), or ``None`` if `none_allowed` is ``True``.

    Examples
    --------
    >>> from isshub.domain.utils.entity import field_validator, required_field, BaseEntity
    >>>
    >>> @validated()
    ... class MyEntity(BaseEntity):
    ...    my_field: int = required_field(int)
    ...
    ...    @field_validator(my_field)
    ...    def validate_my_field(self, field, value):
    ...        validate_positive_integer(
    ...            value=value,
    ...            none_allowed=False,
    ...            display_name=f"{self.__class__.__name__}.my_field",
    ...        )
    >>>
    >>> instance = MyEntity(my_field='foo')
    Traceback (most recent call last):
        ...
    TypeError: ("'my_field' must be <class 'int'> (got 'foo' that is a <class 'str'>)...
    >>> instance = MyEntity(my_field=-2)
    Traceback (most recent call last):
        ...
    ValueError: MyEntity.my_field must be a positive integer
    >>> instance = MyEntity(my_field=0)
    Traceback (most recent call last):
        ...
    ValueError: MyEntity.my_field must be a positive integer
    >>> instance = MyEntity(my_field=1.1)
    Traceback (most recent call last):
        ...
    TypeError: ("'my_field' must be <class 'int'> (got 1.1 that is a <class 'float'>)...
    >>> instance = MyEntity(my_field=1)
    >>> instance.my_field = -2
    >>> instance.validate()
    Traceback (most recent call last):
        ...
    ValueError: MyEntity.my_field must be a positive integer

    """
    if none_allowed and value is None:
        return

    if not isinstance(value, int):
        raise TypeError(f"{display_name} must be a positive integer")
    if value <= 0:
        raise ValueError(f"{display_name} must be a positive integer")


def validate_uuid(value: Any, none_allowed: bool, display_name: str) -> None:
    """Validate that the given `value` is a uuid (version 4) (``None`` accepted if `none_allowed`).

    Parameters
    ----------
    value : Any
        The value to validate as a uuid.
    none_allowed : bool
        If ``True``, the value can be ``None``. If ``False``, the value must be a uuid.
    display_name : str
        The name of the field to display in errors.

    Raises
    ------
    TypeError
        If `value` is not of type ``UUID`` version 4 .

    Examples
    --------
    >>> from uuid import UUID
    >>> from isshub.domain.utils.entity import field_validator, required_field, BaseEntity
    >>>
    >>> @validated()
    ... class MyEntity(BaseEntity):
    ...    my_field: UUID = required_field(UUID)
    ...
    ...    @field_validator(my_field)
    ...    def validate_my_field(self, field, value):
    ...        validate_uuid(
    ...            value=value,
    ...            none_allowed=False,
    ...            display_name=f"{self.__class__.__name__}.my_field",
    ...        )
    >>>
    >>> instance = MyEntity(my_field='foo')
    Traceback (most recent call last):
        ...
    TypeError: ("'my_field' must be <class 'uuid.UUID'> (got 'foo' that is a <class 'str'>)...
    >>> instance = MyEntity(my_field='7298d61a-f08f-4f83-b75e-934e786eb43d')
    Traceback (most recent call last):
        ...
    TypeError: ("'my_field' must be <class 'uuid.UUID'> (got '7298d61a-f08f-4f83-b75e-934e786eb43d' that is a <class 'str'>)...
    >>> instance = MyEntity(my_field=UUID('19f49bc8-06e5-11eb-8465-bf44725d7bd3'))
    Traceback (most recent call last):
        ...
    TypeError: MyEntity.my_field must be a UUID version 4
    >>> instance = MyEntity(my_field=UUID('7298d61a-f08f-4f83-b75e-934e786eb43d'))
    >>> instance.my_field = UUID('19f49bc8-06e5-11eb-8465-bf44725d7bd3')
    >>> instance.validate()
    Traceback (most recent call last):
        ...
    TypeError: MyEntity.my_field must be a UUID version 4

    """
    if none_allowed and value is None:
        return

    if not isinstance(value, UUID) or value.version != 4:
        raise TypeError(f"{display_name} must be a UUID version 4")


@validated()
class BaseEntity:
    """A base entity without any field, that is able to validate itself."""

    def validate(self) -> None:
        """Validate all fields of the current instance.

        Raises
        ------
        TypeError, ValueError
            If a field is not valid.

        """
        validate_instance(self)


@validated()
class BaseEntityWithIdentifier(BaseEntity):
    """A base entity with an :obj:`~BaseEntityWithIdentifier.identifier`, that is able to validate itself.

    Attributes
    ----------
    identifier : UUID
        The identifier of the instance. Validated to be a UUID version 4.

    """

    identifier: UUID = required_field(UUID, frozen=True)

    @field_validator(identifier)
    def validate_id_is_uuid(  # noqa  # pylint: disable=unused-argument
        self, field: "Attribute[_T]", value: _T
    ) -> None:
        """Validate that the :obj:`BaseEntityWithIdentifier.identifier` field is a uuid.

        Parameters
        ----------
        field : Any
            The field to validate.
        value : Any
            The value to validate for the `field`.

        """
        validate_uuid(
            value=value,
            none_allowed=False,
            display_name=f"{self.__class__.__name__}.identifier",
        )

    def __hash__(self) -> int:
        """Compute the hash of the entity for python internal hashing.

        The hash is purely based on the entity's identifier, ie two different with the same
        identifier will share the same hash. And as it's the same for ``__eq__`` method, two
        different instances of the same entity class with the same identifier will always have the
        same hash.

        Returns
        -------
        int
            The hash for the entity

        """
        return hash(self.identifier)

    def __eq__(self, other: Any) -> bool:
        """Check if the `other` object is the same as the current entity.

        Parameters
        ----------
        other : Any
            The object to compare with the actual entity.

        Returns
        -------
        bool
            ``True`` if the given `other` object is an instance of the same class as the current
            entity, with the same identifier.

        """
        return self.__class__ is other.__class__ and self.identifier == other.identifier

Changes

feat(repository): Add domain repositories

Commit
Hash

27f013e2a3722926a9bbe300a77a493604f0993c

Date

2020-10-06 17:30:45 +0200

Type

Modified

Stats

+41 -5

@@ -26,7 +26,10 @@ if TYPE_CHECKING:
 else:

     class Attribute(Generic[_T]):
-        """Class for typing when not using mypy, for example when using ``get_type_hints``."""
+        """Class for typing when not using mypy, for example when using ``get_type_hints``.
+
+        :meta private:
+        """


 class _InstanceOfSelfValidator(
@@ -206,7 +209,7 @@ def validated() -> Any:
     TypeError: ("'my_field' must be <class 'str'> (got None that is a <class 'NoneType'>)...

     """
-    return attr.s(slots=True, kw_only=True)
+    return attr.s(slots=True, kw_only=True, eq=False)


 TValidateMethod = TypeVar(
@@ -462,7 +465,7 @@ class BaseEntity:

 @validated()
 class BaseEntityWithIdentifier(BaseEntity):
-    """A base entity with an ``identifier``, that is able to validate itself.
+    """A base entity with an :obj:`~BaseEntityWithIdentifier.identifier`, that is able to validate itself.

     Attributes
     ----------
@@ -477,12 +480,12 @@ class BaseEntityWithIdentifier(BaseEntity):
     def validate_id_is_uuid(  # noqa  # pylint: disable=unused-argument
         self, field: "Attribute[_T]", value: _T
     ) -> None:
-        """Validate that the ``identifier`` field is a uuid.
+        """Validate that the :obj:`BaseEntityWithIdentifier.identifier` field is a uuid.

         Parameters
         ----------
         field : Any
-            The field to validate. Passed via the ``@field_validator`` decorator.
+            The field to validate.
         value : Any
             The value to validate for the `field`.

@@ -492,3 +495,36 @@ class BaseEntityWithIdentifier(BaseEntity):
             none_allowed=False,
             display_name=f"{self.__class__.__name__}.identifier",
         )
+
+    def __hash__(self) -> int:
+        """Compute the hash of the entity for python internal hashing.
+
+        The hash is purely based on the entity's identifier, ie two different with the same
+        identifier will share the same hash. And as it's the same for ``__eq__`` method, two
+        different instances of the same entity class with the same identifier will always have the
+        same hash.
+
+        Returns
+        -------
+        int
+            The hash for the entity
+
+        """
+        return hash(self.identifier)
+
+    def __eq__(self, other: Any) -> bool:
+        """Check if the `other` object is the same as the current entity.
+
+        Parameters
+        ----------
+        other : Any
+            The object to compare with the actual entity.
+
+        Returns
+        -------
+        bool
+            ``True`` if the given `other` object is an instance of the same class as the current
+            entity, with the same identifier.
+
+        """
+        return self.__class__ is other.__class__ and self.identifier == other.identifier

fix(entity): id changed from int to uuid4, renamed to identifier

Commit
Hash

79f704bde4575a9ddeb623d67d8965a62138adc9

Date

2020-10-05 10:51:49 +0200

Type

Modified

Stats

+72 -10

@@ -14,6 +14,7 @@ from typing import (
     Union,
     cast,
 )
+from uuid import UUID

 import attr

@@ -382,6 +383,67 @@ def validate_positive_integer(
         raise ValueError(f"{display_name} must be a positive integer")


+def validate_uuid(value: Any, none_allowed: bool, display_name: str) -> None:
+    """Validate that the given `value` is a uuid (version 4) (``None`` accepted if `none_allowed`).
+
+    Parameters
+    ----------
+    value : Any
+        The value to validate as a uuid.
+    none_allowed : bool
+        If ``True``, the value can be ``None``. If ``False``, the value must be a uuid.
+    display_name : str
+        The name of the field to display in errors.
+
+    Raises
+    ------
+    TypeError
+        If `value` is not of type ``UUID`` version 4 .
+
+    Examples
+    --------
+    >>> from uuid import UUID
+    >>> from isshub.domain.utils.entity import field_validator, required_field, BaseEntity
+    >>>
+    >>> @validated()
+    ... class MyEntity(BaseEntity):
+    ...    my_field: UUID = required_field(UUID)
+    ...
+    ...    @field_validator(my_field)
+    ...    def validate_my_field(self, field, value):
+    ...        validate_uuid(
+    ...            value=value,
+    ...            none_allowed=False,
+    ...            display_name=f"{self.__class__.__name__}.my_field",
+    ...        )
+    >>>
+    >>> instance = MyEntity(my_field='foo')
+    Traceback (most recent call last):
+        ...
+    TypeError: ("'my_field' must be <class 'uuid.UUID'> (got 'foo' that is a <class 'str'>)...
+    >>> instance = MyEntity(my_field='7298d61a-f08f-4f83-b75e-934e786eb43d')
+    Traceback (most recent call last):
+        ...
+    TypeError: ("'my_field' must be <class 'uuid.UUID'> (got '7298d61a-f08f-4f83-b75e-934e786eb43d' that is a <class 'str'>)...
+    >>> instance = MyEntity(my_field=UUID('19f49bc8-06e5-11eb-8465-bf44725d7bd3'))
+    Traceback (most recent call last):
+        ...
+    TypeError: MyEntity.my_field must be a UUID version 4
+    >>> instance = MyEntity(my_field=UUID('7298d61a-f08f-4f83-b75e-934e786eb43d'))
+    >>> instance.my_field = UUID('19f49bc8-06e5-11eb-8465-bf44725d7bd3')
+    >>> instance.validate()
+    Traceback (most recent call last):
+        ...
+    TypeError: MyEntity.my_field must be a UUID version 4
+
+    """
+    if none_allowed and value is None:
+        return
+
+    if not isinstance(value, UUID) or value.version != 4:
+        raise TypeError(f"{display_name} must be a UUID version 4")
+
+
 @validated()
 class BaseEntity:
     """A base entity without any field, that is able to validate itself."""
@@ -399,23 +461,23 @@ class BaseEntity:


 @validated()
-class BaseEntityWithId(BaseEntity):
-    """A base entity with an ``id``, that is able to validate itself.
+class BaseEntityWithIdentifier(BaseEntity):
+    """A base entity with an ``identifier``, that is able to validate itself.

     Attributes
     ----------
-    id : int
-        The identifier of the instance. Validated to be a positive integer.
+    identifier : UUID
+        The identifier of the instance. Validated to be a UUID version 4.

     """

-    id: int = required_field(int, frozen=True)
+    identifier: UUID = required_field(UUID, frozen=True)

-    @field_validator(id)
-    def validate_id_is_positive_integer(  # noqa  # pylint: disable=unused-argument
+    @field_validator(identifier)
+    def validate_id_is_uuid(  # noqa  # pylint: disable=unused-argument
         self, field: "Attribute[_T]", value: _T
     ) -> None:
-        """Validate that the ``id`` field is a positive integer.
+        """Validate that the ``identifier`` field is a uuid.

         Parameters
         ----------
@@ -425,8 +487,8 @@ class BaseEntityWithId(BaseEntity):
             The value to validate for the `field`.

         """
-        validate_positive_integer(
+        validate_uuid(
             value=value,
             none_allowed=False,
-            display_name=f"{self.__class__.__name__}.id",
+            display_name=f"{self.__class__.__name__}.identifier",
         )

style(entity): Change the world “model” by “entity”

Commit
Hash

c2dd0fc606636dd72c1b55d30095bbeb622b788d

Date

2020-10-04 21:07:00 +0200

Type

Modified

Stats

+44 -42

@@ -31,7 +31,7 @@ else:
 class _InstanceOfSelfValidator(
     attr.validators._InstanceOfValidator  # type: ignore  # pylint: disable=protected-access
 ):
-    """Validator checking that the field holds an instance of its own model."""
+    """Validator checking that the field holds an instance of its own entity."""

     def __call__(self, inst, attr, value):  # type: ignore  # pylint: disable=redefined-outer-name
         """Validate that the `value` is an instance of the class of `inst`.
@@ -43,7 +43,7 @@ class _InstanceOfSelfValidator(


 def instance_of_self() -> _InstanceOfSelfValidator:
-    """Return a validator checking that the field holds an instance of its own model.
+    """Return a validator checking that the field holds an instance of its own entity.

     Returns
     -------
@@ -61,10 +61,11 @@ def optional_field(
     Parameters
     ----------
     field_type : Union[type, str]
-        The expected type of the field. Use the string "self" to reference the current field's model
+        The expected type of the field. Use the string "self" to reference the current field's
+        entity
     relation_verbose_name : Optional[str]
-        A verbose name to describe the relation between the model linked to the field, and the
-        model pointed by `field_type`
+        A verbose name to describe the relation between the entity linked to the field, and the
+        entity pointed by `field_type`

     Returns
     -------
@@ -79,14 +80,14 @@ def optional_field(

     Examples
     --------
-    >>> from isshub.domain.utils.entity import optional_field, validated, BaseModel
+    >>> from isshub.domain.utils.entity import optional_field, validated, BaseEntity
     >>>
     >>> @validated()
-    ... class MyModel(BaseModel):
+    ... class MyEntity(BaseEntity):
     ...     my_field: str = optional_field(str)
     >>>
     >>> from isshub.domain.utils.testing.validation import check_field_nullable
-    >>> check_field_nullable(MyModel, 'my_field', my_field='foo')
+    >>> check_field_nullable(MyEntity, 'my_field', my_field='foo')

     """
     metadata = {}
@@ -116,14 +117,15 @@ def required_field(
     Parameters
     ----------
     field_type : Union[type, str]
-        The expected type of the field. Use the string "self" to reference the current field's model
+        The expected type of the field. Use the string "self" to reference the current field's
+        entity
     frozen : bool
         If set to ``False`` (the default), the field can be updated after being set at init time.
         If set to ``True``, the field can be set at init time but cannot be changed later, else a
         ``FrozenAttributeError`` exception will be raised.
     relation_verbose_name : Optional[str]
-        A verbose name to describe the relation between the model linked to the field, and the
-        model pointed by `field_type`
+        A verbose name to describe the relation between the entity linked to the field, and the
+        entity pointed by `field_type`

     Returns
     -------
@@ -137,14 +139,14 @@ def required_field(

     Examples
     --------
-    >>> from isshub.domain.utils.entity import required_field, validated, BaseModel
+    >>> from isshub.domain.utils.entity import required_field, validated, BaseEntity
     >>>
     >>> @validated()
-    ... class MyModel(BaseModel):
+    ... class MyEntity(BaseEntity):
     ...     my_field: str = required_field(str)
     >>>
     >>> from isshub.domain.utils.testing.validation import check_field_not_nullable
-    >>> check_field_not_nullable(MyModel, 'my_field', my_field='foo')
+    >>> check_field_not_nullable(MyEntity, 'my_field', my_field='foo')

     """
     metadata = {}
@@ -179,20 +181,20 @@ def validated() -> Any:

     Examples
     --------
-    >>> from isshub.domain.utils.entity import required_field, validated, BaseModel
+    >>> from isshub.domain.utils.entity import required_field, validated, BaseEntity
     >>>
     >>> @validated()
-    ... class MyModel(BaseModel):
+    ... class MyEntity(BaseEntity):
     ...     my_field: str = required_field(str)
     >>>
-    >>> MyModel.__slots__
+    >>> MyEntity.__slots__
     ('my_field',)
     >>>
-    >>> instance = MyModel()
+    >>> instance = MyEntity()
     Traceback (most recent call last):
         ...
     TypeError: __init__() missing 1 required keyword-only argument: 'my_field'
-    >>> instance = MyModel(my_field='foo')
+    >>> instance = MyEntity(my_field='foo')
     >>> instance.my_field
     'foo'
     >>> instance.validate()
@@ -226,10 +228,10 @@ class field_validator:  # pylint: disable=invalid-name

     Examples
     --------
-    >>> from isshub.domain.utils.entity import field_validator, required_field, BaseModel
+    >>> from isshub.domain.utils.entity import field_validator, required_field, BaseEntity
     >>>
     >>> @validated()
-    ... class MyModel(BaseModel):
+    ... class MyEntity(BaseEntity):
     ...    my_field: str = required_field(str)
     ...
     ...    @field_validator(my_field)
@@ -237,18 +239,18 @@ class field_validator:  # pylint: disable=invalid-name
     ...        if value != 'foo':
     ...            raise ValueError(f'{self.__class__.__name__}.my_field must be "foo"')
     >>>
-    >>> instance = MyModel(my_field='bar')
+    >>> instance = MyEntity(my_field='bar')
     Traceback (most recent call last):
         ...
-    ValueError: MyModel.my_field must be "foo"
-    >>> instance = MyModel(my_field='foo')
+    ValueError: MyEntity.my_field must be "foo"
+    >>> instance = MyEntity(my_field='foo')
     >>> instance.my_field
     'foo'
     >>> instance.my_field = 'bar'
     >>> instance.validate()
     Traceback (most recent call last):
         ...
-    ValueError: MyModel.my_field must be "foo"
+    ValueError: MyEntity.my_field must be "foo"
     >>> instance.my_field = 'foo'
     >>> instance.validate()
     >>> instance.my_field
@@ -292,13 +294,13 @@ def validate_instance(instance: Any) -> Any:

     Examples
     --------
-    >>> from isshub.domain.utils.entity import required_field, validate_instance, BaseModel
+    >>> from isshub.domain.utils.entity import required_field, validate_instance, BaseEntity
     >>>
     >>> @validated()
-    ... class MyModel(BaseModel):
+    ... class MyEntity(BaseEntity):
     ...    my_field: str = required_field(str)
     >>>
-    >>> instance = MyModel(my_field='foo')
+    >>> instance = MyEntity(my_field='foo')
     >>> validate_instance(instance)
     >>> instance.my_field = None
     >>> validate_instance(instance)
@@ -333,10 +335,10 @@ def validate_positive_integer(

     Examples
     --------
-    >>> from isshub.domain.utils.entity import field_validator, required_field, BaseModel
+    >>> from isshub.domain.utils.entity import field_validator, required_field, BaseEntity
     >>>
     >>> @validated()
-    ... class MyModel(BaseModel):
+    ... class MyEntity(BaseEntity):
     ...    my_field: int = required_field(int)
     ...
     ...    @field_validator(my_field)
@@ -347,28 +349,28 @@ def validate_positive_integer(
     ...            display_name=f"{self.__class__.__name__}.my_field",
     ...        )
     >>>
-    >>> instance = MyModel(my_field='foo')
+    >>> instance = MyEntity(my_field='foo')
     Traceback (most recent call last):
         ...
     TypeError: ("'my_field' must be <class 'int'> (got 'foo' that is a <class 'str'>)...
-    >>> instance = MyModel(my_field=-2)
+    >>> instance = MyEntity(my_field=-2)
     Traceback (most recent call last):
         ...
-    ValueError: MyModel.my_field must be a positive integer
-    >>> instance = MyModel(my_field=0)
+    ValueError: MyEntity.my_field must be a positive integer
+    >>> instance = MyEntity(my_field=0)
     Traceback (most recent call last):
         ...
-    ValueError: MyModel.my_field must be a positive integer
-    >>> instance = MyModel(my_field=1.1)
+    ValueError: MyEntity.my_field must be a positive integer
+    >>> instance = MyEntity(my_field=1.1)
     Traceback (most recent call last):
         ...
     TypeError: ("'my_field' must be <class 'int'> (got 1.1 that is a <class 'float'>)...
-    >>> instance = MyModel(my_field=1)
+    >>> instance = MyEntity(my_field=1)
     >>> instance.my_field = -2
     >>> instance.validate()
     Traceback (most recent call last):
         ...
-    ValueError: MyModel.my_field must be a positive integer
+    ValueError: MyEntity.my_field must be a positive integer

     """
     if none_allowed and value is None:
@@ -381,8 +383,8 @@ def validate_positive_integer(


 @validated()
-class BaseModel:
-    """A base model without any field, that is able to validate itself."""
+class BaseEntity:
+    """A base entity without any field, that is able to validate itself."""

     def validate(self) -> None:
         """Validate all fields of the current instance.
@@ -397,8 +399,8 @@ class BaseModel:


 @validated()
-class BaseModelWithId(BaseModel):
-    """A base model with an ``id``, that is able to validate itself.
+class BaseEntityWithId(BaseEntity):
+    """A base entity with an ``id``, that is able to validate itself.

     Attributes
     ----------

style(mypy): Remove most “type: ignore” pragmas

Commit
Hash

76638c3d09c47d58febbc9e2e2cc80e84c98ac33

Date

2020-10-04 20:36:50 +0200

Type

Modified

Stats

+86 -21

@@ -3,18 +3,37 @@
 It is an adapter over the ``attrs`` external dependency.

 """
-
-# type: ignore
+from typing import (
+    TYPE_CHECKING,
+    Any,
+    Callable,
+    Generic,
+    Optional,
+    Type,
+    TypeVar,
+    Union,
+    cast,
+)

 import attr


+_T = TypeVar("_T")
+
+if TYPE_CHECKING:
+    from attr.__init__ import Attribute  # isort:skip
+else:
+
+    class Attribute(Generic[_T]):
+        """Class for typing when not using mypy, for example when using ``get_type_hints``."""
+
+
 class _InstanceOfSelfValidator(
-    attr.validators._InstanceOfValidator  # pylint: disable=protected-access
+    attr.validators._InstanceOfValidator  # type: ignore  # pylint: disable=protected-access
 ):
     """Validator checking that the field holds an instance of its own model."""

-    def __call__(self, inst, attr, value):  # pylint: disable=redefined-outer-name
+    def __call__(self, inst, attr, value):  # type: ignore  # pylint: disable=redefined-outer-name
         """Validate that the `value` is an instance of the class of `inst`.

         For the parameters, see ``attr.validators._InstanceOfValidator``
@@ -34,7 +53,9 @@ def instance_of_self() -> _InstanceOfSelfValidator:
     return _InstanceOfSelfValidator(type=None)


-def optional_field(field_type, relation_verbose_name=None):
+def optional_field(
+    field_type: Union[Type[_T], str], relation_verbose_name: Optional[str] = None
+) -> Optional[_T]:
     """Define an optional field of the specified `field_type`.

     Parameters
@@ -51,6 +72,11 @@ def optional_field(field_type, relation_verbose_name=None):
         An ``attrs`` attribute, with a default value set to ``None``, and a validator checking
         that this field is optional and, if set, of the correct type.

+    Raises
+    ------
+    AssertionError
+        If `field_type` is a string and this string is not "self"
+
     Examples
     --------
     >>> from isshub.domain.utils.entity import optional_field, validated, BaseModel
@@ -67,18 +93,24 @@ def optional_field(field_type, relation_verbose_name=None):
     if relation_verbose_name:
         metadata["relation_verbose_name"] = relation_verbose_name

+    assert not isinstance(field_type, str) or field_type == "self"
+
     return attr.ib(
         default=None,
         validator=attr.validators.optional(
             instance_of_self()
-            if field_type == "self"
+            if isinstance(field_type, str)
             else attr.validators.instance_of(field_type)
         ),
         metadata=metadata,
     )


-def required_field(field_type, frozen=False, relation_verbose_name=None):
+def required_field(
+    field_type: Union[Type[_T], str],
+    frozen: bool = False,
+    relation_verbose_name: Optional[str] = None,
+) -> _T:
     """Define a required field of the specified `field_type`.

     Parameters
@@ -98,6 +130,11 @@ def required_field(field_type, frozen=False, relation_verbose_name=None):
     Any
         An ``attrs`` attribute, and a validator checking that this field is of the correct type.

+    Raises
+    ------
+    AssertionError
+        If `field_type` is a string and this string is not "self"
+
     Examples
     --------
     >>> from isshub.domain.utils.entity import required_field, validated, BaseModel
@@ -114,19 +151,21 @@ def required_field(field_type, frozen=False, relation_verbose_name=None):
     if relation_verbose_name:
         metadata["relation_verbose_name"] = relation_verbose_name

+    assert not isinstance(field_type, str) or field_type == "self"
+
     kwargs = {
         "validator": instance_of_self()
-        if field_type == "self"
+        if isinstance(field_type, str)
         else attr.validators.instance_of(field_type),
         "metadata": metadata,
     }
     if frozen:
         kwargs["on_setattr"] = attr.setters.frozen

-    return attr.ib(**kwargs)
+    return attr.ib(**kwargs)  # type: ignore


-def validated():
+def validated() -> Any:
     """Decorate an entity to handle validation.

     This will let ``attrs`` manage the class, using slots for fields, and forcing attributes to
@@ -167,19 +206,24 @@ def validated():
     return attr.s(slots=True, kw_only=True)


-def field_validator(field):
+TValidateMethod = TypeVar(
+    "TValidateMethod", bound=Callable[[Any, "Attribute[_T]", _T], None]
+)
+
+
+class field_validator:  # pylint: disable=invalid-name
     """Decorate an entity method to make it a validator of the given `field`.

+    Notes
+    -----
+    It's easier to implement as a function but we couldn't make mypy work with it.
+    Thanks to https://github.com/python/mypy/issues/1551#issuecomment-253978622
+
     Parameters
     ----------
     field : Any
         The field to validate.

-    Returns
-    -------
-    Callable
-        The decorated method.
-
     Examples
     --------
     >>> from isshub.domain.utils.entity import field_validator, required_field, BaseModel
@@ -211,10 +255,29 @@ def field_validator(field):
     'foo'

     """
-    return field.validator
+
+    def __init__(self, field: "Attribute[_T]") -> None:
+        """Save the given field."""
+        self.field = field
+
+    def __call__(self, func: TValidateMethod) -> TValidateMethod:
+        """Decorate the given function.
+
+        Parameters
+        ----------
+        func: Callable
+            The validation method to decorate
+
+        Returns
+        -------
+        Callable
+            The decorated method.
+
+        """
+        return cast(TValidateMethod, self.field.validator(func))


-def validate_instance(instance):
+def validate_instance(instance: Any) -> Any:
     """Validate a whole instance.

     Parameters
@@ -247,7 +310,9 @@ def validate_instance(instance):
     attr.validate(instance)


-def validate_positive_integer(value, none_allowed, display_name):
+def validate_positive_integer(
+    value: Any, none_allowed: bool, display_name: str
+) -> None:
     """Validate that the given `value` is a positive integer (``None`` accepted if `none_allowed`).

     Parameters
@@ -346,8 +411,8 @@ class BaseModelWithId(BaseModel):

     @field_validator(id)
     def validate_id_is_positive_integer(  # noqa  # pylint: disable=unused-argument
-        self, field, value
-    ):
+        self, field: "Attribute[_T]", value: _T
+    ) -> None:
         """Validate that the ``id`` field is a positive integer.

         Parameters

docs(domain): Add diagram of entities for each domain context

Commit
Hash

bb5e73eb3d816d563f2a58fe65c6bd57b045dbde

Date

2020-10-04 11:50:37 +0200

Type

Modified

Stats

+19 -3

@@ -34,13 +34,16 @@ def instance_of_self() -> _InstanceOfSelfValidator:
     return _InstanceOfSelfValidator(type=None)


-def optional_field(field_type):
+def optional_field(field_type, relation_verbose_name=None):
     """Define an optional field of the specified `field_type`.

     Parameters
     ----------
     field_type : Union[type, str]
         The expected type of the field. Use the string "self" to reference the current field's model
+    relation_verbose_name : Optional[str]
+        A verbose name to describe the relation between the model linked to the field, and the
+        model pointed by `field_type`

     Returns
     -------
@@ -60,6 +63,10 @@ def optional_field(field_type):
     >>> check_field_nullable(MyModel, 'my_field', my_field='foo')

     """
+    metadata = {}
+    if relation_verbose_name:
+        metadata["relation_verbose_name"] = relation_verbose_name
+
     return attr.ib(
         default=None,
         validator=attr.validators.optional(
@@ -67,10 +74,11 @@ def optional_field(field_type):
             if field_type == "self"
             else attr.validators.instance_of(field_type)
         ),
+        metadata=metadata,
     )


-def required_field(field_type, frozen=False):
+def required_field(field_type, frozen=False, relation_verbose_name=None):
     """Define a required field of the specified `field_type`.

     Parameters
@@ -81,6 +89,9 @@ def required_field(field_type, frozen=False):
         If set to ``False`` (the default), the field can be updated after being set at init time.
         If set to ``True``, the field can be set at init time but cannot be changed later, else a
         ``FrozenAttributeError`` exception will be raised.
+    relation_verbose_name : Optional[str]
+        A verbose name to describe the relation between the model linked to the field, and the
+        model pointed by `field_type`

     Returns
     -------
@@ -99,10 +110,15 @@ def required_field(field_type, frozen=False):
     >>> check_field_not_nullable(MyModel, 'my_field', my_field='foo')

     """
+    metadata = {}
+    if relation_verbose_name:
+        metadata["relation_verbose_name"] = relation_verbose_name
+
     kwargs = {
         "validator": instance_of_self()
         if field_type == "self"
-        else attr.validators.instance_of(field_type)
+        else attr.validators.instance_of(field_type),
+        "metadata": metadata,
     }
     if frozen:
         kwargs["on_setattr"] = attr.setters.frozen

refactor(namespace): Remove the intermediary _Namespace model

Commit
Hash

27e4ea0aef91c7e16f966afdb6973f1b29ef6e9f

Date

2020-09-28 18:05:22 +0200

Type

Modified

Stats

+44 -9

@@ -9,13 +9,38 @@ It is an adapter over the ``attrs`` external dependency.
 import attr


+class _InstanceOfSelfValidator(
+    attr.validators._InstanceOfValidator  # pylint: disable=protected-access
+):
+    """Validator checking that the field holds an instance of its own model."""
+
+    def __call__(self, inst, attr, value):  # pylint: disable=redefined-outer-name
+        """Validate that the `value` is an instance of the class of `inst`.
+
+        For the parameters, see ``attr.validators._InstanceOfValidator``
+        """
+        self.type = inst.__class__
+        super().__call__(inst, attr, value)
+
+
+def instance_of_self() -> _InstanceOfSelfValidator:
+    """Return a validator checking that the field holds an instance of its own model.
+
+    Returns
+    -------
+    _InstanceOfSelfValidator
+        The instantiated validator
+    """
+    return _InstanceOfSelfValidator(type=None)
+
+
 def optional_field(field_type):
     """Define an optional field of the specified `field_type`.

     Parameters
     ----------
-    field_type : type
-        The expected type of the field when not ``None``.
+    field_type : Union[type, str]
+        The expected type of the field. Use the string "self" to reference the current field's model

     Returns
     -------
@@ -37,7 +62,11 @@ def optional_field(field_type):
     """
     return attr.ib(
         default=None,
-        validator=attr.validators.optional(attr.validators.instance_of(field_type)),
+        validator=attr.validators.optional(
+            instance_of_self()
+            if field_type == "self"
+            else attr.validators.instance_of(field_type)
+        ),
     )


@@ -46,8 +75,8 @@ def required_field(field_type, frozen=False):

     Parameters
     ----------
-    field_type : type
-        The expected type of the field.
+    field_type : Union[type, str]
+        The expected type of the field. Use the string "self" to reference the current field's model
     frozen : bool
         If set to ``False`` (the default), the field can be updated after being set at init time.
         If set to ``True``, the field can be set at init time but cannot be changed later, else a
@@ -70,7 +99,11 @@ def required_field(field_type, frozen=False):
     >>> check_field_not_nullable(MyModel, 'my_field', my_field='foo')

     """
-    kwargs = {"validator": attr.validators.instance_of(field_type)}
+    kwargs = {
+        "validator": instance_of_self()
+        if field_type == "self"
+        else attr.validators.instance_of(field_type)
+    }
     if frozen:
         kwargs["on_setattr"] = attr.setters.frozen

@@ -80,7 +113,9 @@ def required_field(field_type, frozen=False):
 def validated():
     """Decorate an entity to handle validation.

-    This will let ``attrs`` manage the class, using slots for fields.
+    This will let ``attrs`` manage the class, using slots for fields, and forcing attributes to
+    be passed as named arguments (this allows to not have to defined all required fields first, then
+    optional ones, and resolves problems with inheritance where we can't handle the order)

     Returns
     -------
@@ -101,7 +136,7 @@ def validated():
     >>> instance = MyModel()
     Traceback (most recent call last):
         ...
-    TypeError: __init__() missing 1 required positional argument: 'my_field'
+    TypeError: __init__() missing 1 required keyword-only argument: 'my_field'
     >>> instance = MyModel(my_field='foo')
     >>> instance.my_field
     'foo'
@@ -113,7 +148,7 @@ def validated():
     TypeError: ("'my_field' must be <class 'str'> (got None that is a <class 'NoneType'>)...

     """
-    return attr.s(slots=True)
+    return attr.s(slots=True, kw_only=True)


 def field_validator(field):

fix(entities): Entities id field are frozen once set

Commit
Hash

ef8edc20b6a674bfc98c79028684279bcc9ed324

Date

2020-09-27 09:56:59 +0200

Type

Modified

Stats

+12 -4

@@ -41,13 +41,17 @@ def optional_field(field_type):
     )


-def required_field(field_type):
+def required_field(field_type, frozen=False):
     """Define a required field of the specified `field_type`.

     Parameters
     ----------
     field_type : type
-        The expected type of the field..
+        The expected type of the field.
+    frozen : bool
+        If set to ``False`` (the default), the field can be updated after being set at init time.
+        If set to ``True``, the field can be set at init time but cannot be changed later, else a
+        ``FrozenAttributeError`` exception will be raised.

     Returns
     -------
@@ -66,7 +70,11 @@ def required_field(field_type):
     >>> check_field_not_nullable(MyModel, 'my_field', my_field='foo')

     """
-    return attr.ib(validator=attr.validators.instance_of(field_type))
+    kwargs = {"validator": attr.validators.instance_of(field_type)}
+    if frozen:
+        kwargs["on_setattr"] = attr.setters.frozen
+
+    return attr.ib(**kwargs)


 def validated():
@@ -283,7 +291,7 @@ class BaseModelWithId(BaseModel):

     """

-    id: int = required_field(int)
+    id: int = required_field(int, frozen=True)

     @field_validator(id)
     def validate_id_is_positive_integer(  # noqa  # pylint: disable=unused-argument

docs(examples): Add examples in functions docstring

Commit
Hash

74b050b5285baf8199876881b35345830944a806

Date

2020-09-26 12:03:01 +0200

Type

Modified

Stats

+132 -0

@@ -23,6 +23,17 @@ def optional_field(field_type):
         An ``attrs`` attribute, with a default value set to ``None``, and a validator checking
         that this field is optional and, if set, of the correct type.

+    Examples
+    --------
+    >>> from isshub.domain.utils.entity import optional_field, validated, BaseModel
+    >>>
+    >>> @validated()
+    ... class MyModel(BaseModel):
+    ...     my_field: str = optional_field(str)
+    >>>
+    >>> from isshub.domain.utils.testing.validation import check_field_nullable
+    >>> check_field_nullable(MyModel, 'my_field', my_field='foo')
+
     """
     return attr.ib(
         default=None,
@@ -43,6 +54,17 @@ def required_field(field_type):
     Any
         An ``attrs`` attribute, and a validator checking that this field is of the correct type.

+    Examples
+    --------
+    >>> from isshub.domain.utils.entity import required_field, validated, BaseModel
+    >>>
+    >>> @validated()
+    ... class MyModel(BaseModel):
+    ...     my_field: str = required_field(str)
+    >>>
+    >>> from isshub.domain.utils.testing.validation import check_field_not_nullable
+    >>> check_field_not_nullable(MyModel, 'my_field', my_field='foo')
+
     """
     return attr.ib(validator=attr.validators.instance_of(field_type))

@@ -57,6 +79,31 @@ def validated():
     type
         The decorated class.

+    Examples
+    --------
+    >>> from isshub.domain.utils.entity import required_field, validated, BaseModel
+    >>>
+    >>> @validated()
+    ... class MyModel(BaseModel):
+    ...     my_field: str = required_field(str)
+    >>>
+    >>> MyModel.__slots__
+    ('my_field',)
+    >>>
+    >>> instance = MyModel()
+    Traceback (most recent call last):
+        ...
+    TypeError: __init__() missing 1 required positional argument: 'my_field'
+    >>> instance = MyModel(my_field='foo')
+    >>> instance.my_field
+    'foo'
+    >>> instance.validate()
+    >>> instance.my_field = None
+    >>> instance.validate()
+    Traceback (most recent call last):
+        ...
+    TypeError: ("'my_field' must be <class 'str'> (got None that is a <class 'NoneType'>)...
+
     """
     return attr.s(slots=True)

@@ -74,6 +121,36 @@ def field_validator(field):
     Callable
         The decorated method.

+    Examples
+    --------
+    >>> from isshub.domain.utils.entity import field_validator, required_field, BaseModel
+    >>>
+    >>> @validated()
+    ... class MyModel(BaseModel):
+    ...    my_field: str = required_field(str)
+    ...
+    ...    @field_validator(my_field)
+    ...    def validate_my_field(self, field, value):
+    ...        if value != 'foo':
+    ...            raise ValueError(f'{self.__class__.__name__}.my_field must be "foo"')
+    >>>
+    >>> instance = MyModel(my_field='bar')
+    Traceback (most recent call last):
+        ...
+    ValueError: MyModel.my_field must be "foo"
+    >>> instance = MyModel(my_field='foo')
+    >>> instance.my_field
+    'foo'
+    >>> instance.my_field = 'bar'
+    >>> instance.validate()
+    Traceback (most recent call last):
+        ...
+    ValueError: MyModel.my_field must be "foo"
+    >>> instance.my_field = 'foo'
+    >>> instance.validate()
+    >>> instance.my_field
+    'foo'
+
     """
     return field.validator

@@ -91,6 +168,22 @@ def validate_instance(instance):
     TypeError, ValueError
         If a field in the `instance` is not valid.

+    Examples
+    --------
+    >>> from isshub.domain.utils.entity import required_field, validate_instance, BaseModel
+    >>>
+    >>> @validated()
+    ... class MyModel(BaseModel):
+    ...    my_field: str = required_field(str)
+    >>>
+    >>> instance = MyModel(my_field='foo')
+    >>> validate_instance(instance)
+    >>> instance.my_field = None
+    >>> validate_instance(instance)
+    Traceback (most recent call last):
+        ...
+    TypeError: ("'my_field' must be <class 'str'> (got None that is a <class 'NoneType'>)...
+
     """
     attr.validate(instance)

@@ -114,6 +207,45 @@ def validate_positive_integer(value, none_allowed, display_name):
     ValueError
         If `value` is not a positive integer (ie > 0), or ``None`` if `none_allowed` is ``True``.

+    Examples
+    --------
+    >>> from isshub.domain.utils.entity import field_validator, required_field, BaseModel
+    >>>
+    >>> @validated()
+    ... class MyModel(BaseModel):
+    ...    my_field: int = required_field(int)
+    ...
+    ...    @field_validator(my_field)
+    ...    def validate_my_field(self, field, value):
+    ...        validate_positive_integer(
+    ...            value=value,
+    ...            none_allowed=False,
+    ...            display_name=f"{self.__class__.__name__}.my_field",
+    ...        )
+    >>>
+    >>> instance = MyModel(my_field='foo')
+    Traceback (most recent call last):
+        ...
+    TypeError: ("'my_field' must be <class 'int'> (got 'foo' that is a <class 'str'>)...
+    >>> instance = MyModel(my_field=-2)
+    Traceback (most recent call last):
+        ...
+    ValueError: MyModel.my_field must be a positive integer
+    >>> instance = MyModel(my_field=0)
+    Traceback (most recent call last):
+        ...
+    ValueError: MyModel.my_field must be a positive integer
+    >>> instance = MyModel(my_field=1.1)
+    Traceback (most recent call last):
+        ...
+    TypeError: ("'my_field' must be <class 'int'> (got 1.1 that is a <class 'float'>)...
+    >>> instance = MyModel(my_field=1)
+    >>> instance.my_field = -2
+    >>> instance.validate()
+    Traceback (most recent call last):
+        ...
+    ValueError: MyModel.my_field must be a positive integer
+
     """
     if none_allowed and value is None:
         return

style: Prefix id_is_positive_integer with validate_

Commit
Hash

a307cce6ee202cb7562f2db1afaff499a31508a2

Date

2020-09-25 23:37:35 +0200

Type

Modified

Stats

+1 -1

@@ -154,7 +154,7 @@ class BaseModelWithId(BaseModel):
     id: int = required_field(int)

     @field_validator(id)
-    def id_is_positive_integer(  # noqa  # pylint: disable=unused-argument
+    def validate_id_is_positive_integer(  # noqa  # pylint: disable=unused-argument
         self, field, value
     ):
         """Validate that the ``id`` field is a positive integer.

feat(repository): Introduce entities validation (for Repository entity)

Commit
Hash

86ad505796b742a391684e2ef93695fdfb077abb

Date

2019-06-07 21:03:50 +0200

Type

Added

Stats

+174 -0

@@ -0,0 +1,174 @@
+"""Package to handle isshub entities validation.
+
+It is an adapter over the ``attrs`` external dependency.
+
+"""
+
+# type: ignore
+
+import attr
+
+
+def optional_field(field_type):
+    """Define an optional field of the specified `field_type`.
+
+    Parameters
+    ----------
+    field_type : type
+        The expected type of the field when not ``None``.
+
+    Returns
+    -------
+    Any
+        An ``attrs`` attribute, with a default value set to ``None``, and a validator checking
+        that this field is optional and, if set, of the correct type.
+
+    """
+    return attr.ib(
+        default=None,
+        validator=attr.validators.optional(attr.validators.instance_of(field_type)),
+    )
+
+
+def required_field(field_type):
+    """Define a required field of the specified `field_type`.
+
+    Parameters
+    ----------
+    field_type : type
+        The expected type of the field..
+
+    Returns
+    -------
+    Any
+        An ``attrs`` attribute, and a validator checking that this field is of the correct type.
+
+    """
+    return attr.ib(validator=attr.validators.instance_of(field_type))
+
+
+def validated():
+    """Decorate an entity to handle validation.
+
+    This will let ``attrs`` manage the class, using slots for fields.
+
+    Returns
+    -------
+    type
+        The decorated class.
+
+    """
+    return attr.s(slots=True)
+
+
+def field_validator(field):
+    """Decorate an entity method to make it a validator of the given `field`.
+
+    Parameters
+    ----------
+    field : Any
+        The field to validate.
+
+    Returns
+    -------
+    Callable
+        The decorated method.
+
+    """
+    return field.validator
+
+
+def validate_instance(instance):
+    """Validate a whole instance.
+
+    Parameters
+    ----------
+    instance : Any
+        The instance to validate.
+
+    Raises
+    ------
+    TypeError, ValueError
+        If a field in the `instance` is not valid.
+
+    """
+    attr.validate(instance)
+
+
+def validate_positive_integer(value, none_allowed, display_name):
+    """Validate that the given `value` is a positive integer (``None`` accepted if `none_allowed`).
+
+    Parameters
+    ----------
+    value : Any
+        The value to validate as a positive integer.
+    none_allowed : bool
+        If ``True``, the value can be ``None``. If ``False``, the value must be a positive integer.
+    display_name : str
+        The name of the field to display in errors.
+
+    Raises
+    ------
+    TypeError
+        If `value` is not of type ``int``.
+    ValueError
+        If `value` is not a positive integer (ie > 0), or ``None`` if `none_allowed` is ``True``.
+
+    """
+    if none_allowed and value is None:
+        return
+
+    if not isinstance(value, int):
+        raise TypeError(f"{display_name} must be a positive integer")
+    if value <= 0:
+        raise ValueError(f"{display_name} must be a positive integer")
+
+
+@validated()
+class BaseModel:
+    """A base model without any field, that is able to validate itself."""
+
+    def validate(self) -> None:
+        """Validate all fields of the current instance.
+
+        Raises
+        ------
+        TypeError, ValueError
+            If a field is not valid.
+
+        """
+        validate_instance(self)
+
+
+@validated()
+class BaseModelWithId(BaseModel):
+    """A base model with an ``id``, that is able to validate itself.
+
+    Attributes
+    ----------
+    id : int
+        The identifier of the instance. Validated to be a positive integer.
+
+    """
+
+    id: int = required_field(int)
+
+    @field_validator(id)
+    def id_is_positive_integer(  # noqa  # pylint: disable=unused-argument
+        self, field, value
+    ):
+        """Validate that the ``id`` field is a positive integer.
+
+        Parameters
+        ----------
+        field : Any
+            The field to validate. Passed via the ``@field_validator`` decorator.
+        value : Any
+            The value to validate for the `field`.
+
+        """
+        validate_positive_integer(
+            value=value,
+            none_allowed=False,
+            display_name=f"{self.__class__.__name__}.id",
+        )