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

Description

Abstract

Use attrs package to define entities and to validate attributes.

Motivation

We need to be sure data we manage is correct.

Rationale

Using attrs, we have validation of data when creating an object.

We added the validate method to also validate instances after updating them.

Info

Hash

86ad505796b742a391684e2ef93695fdfb077abb

Date

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

Parents
  • feat(repository): Add 1st domain context (core) and entity (Repository) [37d8930e]2019-06-07 21:03:50 +0200

Children
  • feat(namespace): Add Namespace entity in core domain context [3a2dd3cc]2019-06-07 21:03:50 +0200

Branches
Tags

(No tags)

Changes

isshub/domain/contexts/core/entities/repository/__init__.py

Type

Modified

Stats

+5 -8

@@ -1,10 +1,10 @@
 """Package defining the ``Repository`` entity."""

-from dataclasses import dataclass
+from isshub.domain.utils.entity import BaseModelWithId, required_field, validated


-@dataclass
-class Repository:
+@validated()  # type: ignore
+class Repository(BaseModelWithId):
     """A repository holds code, issues, code requests...

     Attributes
@@ -18,8 +18,5 @@ class Repository:

     """

-    __slots__ = ["id", "name", "namespace"]
-
-    id: int
-    name: str
-    namespace: str
+    name: str = required_field(str)  # type: ignore
+    namespace: str = required_field(str)  # type: ignore

isshub/domain/contexts/core/entities/repository/features/describe.feature

Type

Modified

Stats

+24 -0

@@ -4,10 +4,34 @@ Feature: Describing a Repository
         Given a Repository
         Then it must have a field named id

+    Scenario: A Repository id is a positive integer
+        Given a Repository
+        Then its id must be a positive integer
+
+    Scenario: A Repository id cannot be None
+        Given a Repository
+        Then its id cannot be None
+
     Scenario: A Repository has a name
         Given a Repository
         Then it must have a field named name

+    Scenario: A Repository name is a string
+        Given a Repository
+        Then its name must be a string
+
+    Scenario: A Repository name cannot be None
+        Given a Repository
+        Then its name cannot be None
+
     Scenario: A Repository has a namespace
         Given a Repository
         Then it must have a field named namespace
+
+    Scenario: A Repository namespace is a string
+        Given a Repository
+        Then its namespace must be a string
+
+    Scenario: A Repository namespace cannot be None
+        Given a Repository
+        Then its namespace cannot be None

isshub/domain/contexts/core/entities/repository/tests/test_describe.py

Type

Modified

Stats

+65 -1

@@ -1,10 +1,32 @@
 """Module holding BDD tests for isshub Repository core entity."""

-from pytest_bdd import given, parsers, scenarios, then
+import pytest
+from pytest import mark
+from pytest_bdd import given, parsers, scenario, scenarios, then
+
+from isshub.domain.utils.testing.validation import positive_integer_only, string_only

 from .fixtures import repository_factory


+@mark.parametrize(["value", "exception"], positive_integer_only)
+@scenario("../features/describe.feature", "A Repository id is a positive integer")
+def test_repository_id_is_a_positive_integer(value, exception):
+    pass
+
+
+@mark.parametrize(["value", "exception"], string_only)
+@scenario("../features/describe.feature", "A Repository name is a string")
+def test_repository_name_is_a_string(value, exception):
+    pass
+
+
+@mark.parametrize(["value", "exception"], string_only)
+@scenario("../features/describe.feature", "A Repository namespace is a string")
+def test_repository_namespace_is_a_string(value, exception):
+    pass
+
+
 scenarios("../features/describe.feature")


@@ -16,3 +38,45 @@ def repository(repository_factory):
 @then(parsers.parse("it must have a field named {field_name:w}"))
 def repository_has_field(repository, field_name):
     assert hasattr(repository, field_name)
+
+
+@then(parsers.parse("its {field_name:w} must be a {field_type}"))
+def repository_field_is_of_a_certain_type(
+    repository_factory,
+    field_name,
+    field_type,
+    # next args are for parametrize
+    value,
+    exception,
+):
+    # `field_type` is ignored: the type must be managed via parametrize at the
+    # scenario level, passing values to test and the exception that must be raised
+    # in case of failure, or `None` if the value is valid
+    if exception:
+        # When creating an instance
+        with pytest.raises(exception):
+            repository_factory(**{field_name: value})
+        # When updating the value
+        repository = repository_factory()
+        setattr(repository, field_name, value)
+        with pytest.raises(exception):
+            repository.validate()
+    else:
+        # When creating an instance
+        repository_factory(**{field_name: value})
+        # When updating the value
+        repository = repository_factory()
+        setattr(repository, field_name, value)
+        repository.validate()
+
+
+@then(parsers.parse("its {field_name:w} cannot be none"))
+def repository_field_cannot_be_none(repository_factory, field_name):
+    # When creating an instance
+    with pytest.raises(TypeError):
+        repository_factory(**{field_name: None})
+    # When updating the value
+    repository = repository_factory()
+    repository.id = None
+    with pytest.raises(TypeError):
+        repository.validate()

isshub/domain/utils/__init__.py

Type

Added

Stats

+1 -0

@@ -0,0 +1 @@
+"""Utils for isshub domain code."""

isshub/domain/utils/entity.py

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",
+        )

isshub/domain/utils/testing/__init__.py

Type

Added

Stats

+1 -0

@@ -0,0 +1 @@
+"""Utils for isshub domain tests."""

isshub/domain/utils/testing/validation.py

Type

Added

Stats

+24 -0

@@ -0,0 +1,24 @@
+"""Validation helpers for BDD tests for isshub entity models."""
+
+from typing import Any, List, Optional, Tuple, Type
+
+
+ValuesValidation = List[Tuple[Any, Optional[Type[Exception]]]]
+
+integer_only: ValuesValidation = [
+    ("foo", TypeError),
+    (-123, ValueError),
+    (-1.5, TypeError),
+    (-1, ValueError),
+    (-0.001, TypeError),
+    (0.001, TypeError),
+    (1, None),
+    (1.5, TypeError),
+    (123, None),
+]
+
+no_zero: ValuesValidation = [(0, ValueError)]
+
+positive_integer_only: ValuesValidation = integer_only + no_zero
+
+string_only: ValuesValidation = [("foo", None), (1, TypeError), (-0.1, TypeError)]

setup.cfg

Type

Modified

Stats

+6 -0

@@ -24,6 +24,8 @@ requires-python = >=3.7
 [options]
 zip_safe = True
 packages = find:
+install_requires =
+    attrs

 [options.packages.find]
 exclude =
@@ -82,6 +84,9 @@ warn_incomplete_stub = true
 [mypy-isshub.*.tests.*]
 ignore_errors = True

+[mypy-isshub.domain.utils.entity]
+ignore_errors = True
+
 [flake8]
 ignore =
     # Line too long: we let black manage it
@@ -124,6 +129,7 @@ per-file-ignores =
     # ignore mypy missing annotations in tests
     test_*:T4
     factories.py:T4
+    isshub/domain/utils/entity.py:T4

 [pycodestyle]
 max-line-length = 99