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