feat(namespace): Add Namespace entity in core domain context¶
Description¶
Abstract¶
Add the Namespace entity in the core domain context. And add testing utilities to help testing things that look the same.
Motivation¶
On Github, repositories are stored under the name of their owner, which can be users or organizations.
On Gitlab, repositories are stored under namespaces, which can be their owner if it’s a user, or a group (which can also have a parent group, etc)
So the concept of “namespace” allows to represent all of these.
Rationale¶
The type of the namespace field of the Repository entity is changed from str to Namespace, and is still mandatory.
The Namespace entity also has a namespace field, which is an optional Namespace (if None, it won’t have any parent).
We had to do a “trick” to handle a self-reference in type for this namespace field. We created a _Namespace class, defining everything, and a Namespace one, inheriting from it, simply setting the namespace attribute as an optional Namespace.
Many testing helpers have beed added to isshub.domain.utils.testing.validation, because the tests for the Namespace entity needed nearly the same code as the tests for the Repository entity.
To have clear pytest error messages, we told pytest to handle the assert in this file the same way as in test files, via pytest.register_assert_rewrite.
Info¶
- Hash
3a2dd3cc6f6de1fa1b03db95eaa357628824f075
- Date
2019-06-07 21:03:50 +0200
- Parents
feat(repository): Introduce entities validation (for Repository entity) [86ad5057] — 2019-06-07 21:03:50 +0200
- Children
feat(namespace): Add Namespace entity a `kind` field [a6b70f9e] — 2019-06-07 21:03:51 +0200
- Branches
- Tags
(No tags)
Changes¶
isshub/domain/contexts/core/entities/namespace/features/describe.feature
isshub/domain/contexts/core/entities/namespace/tests/__init__.py
isshub/domain/contexts/core/entities/namespace/tests/factories.py
isshub/domain/contexts/core/entities/namespace/tests/fixtures.py
isshub/domain/contexts/core/entities/namespace/tests/test_describe.py
isshub/domain/contexts/core/entities/repository/features/describe.feature
isshub/domain/contexts/core/entities/repository/tests/factories.py
isshub/domain/contexts/core/entities/repository/tests/test_describe.py
README.rst¶
- Type
Modified
- Stats
+3 -1
@@ -471,12 +471,14 @@ The contexts are:
core
''''
-The `core` context will hold the "core" domaines, around repositories, issues, code requests...
+The `core` context will hold the "core" domain, around repositories, issues, code requests...
Its entities are:
Repository
Repositories are the central entity of the whole isshub project. Everything is tied to them, at one level or another.
+Namespace
+ A namespace is a place where repositories or other namespaces are stored.
Fetching
========
isshub/domain/contexts/core/entities/__init__.py¶
- Type
Modified
- Stats
+9 -0
@@ -1 +1,10 @@
"""Package to handle isshub entities for domain core context."""
+
+# Order is important so we disable isort for this file
+# isort:skip_file
+
+# Stop flake8 warning us than these imports are not used
+# flake8: noqa
+
+from .namespace import Namespace
+from .repository import Repository
isshub/domain/contexts/core/entities/namespace/__init__.py¶
- Type
Added
- Stats
+52 -0
@@ -0,0 +1,52 @@
+"""Package defining the ``Namespace`` entity."""
+
+from typing import Optional
+
+from isshub.domain.utils.entity import (
+ BaseModelWithId,
+ optional_field,
+ required_field,
+ validated,
+)
+
+
+@validated() # type: ignore
+class _Namespace(BaseModelWithId):
+ """A namespace can contain namespace and repositories.
+
+ Notes
+ -----
+ This is a base class, used by `Namespace` to be able to have a self-reference for the type
+ of the `namespace` field.
+
+ Attributes
+ ----------
+ id : int
+ The unique identifier of the namespace
+ name : str
+ The name of the namespace. Unique in its parent namespace.
+ namespace : Optional[Namespace]
+ Where the namespace can be found.
+
+ """
+
+ name: str = required_field(str) # type: ignore
+ namespace = None
+
+
+@validated() # type: ignore
+class Namespace(_Namespace):
+ """A namespace can contain namespace and repositories.
+
+ Attributes
+ ----------
+ id : int
+ The unique identifier of the namespace
+ name : str
+ The name of the namespace. Unique in its parent namespace.
+ namespace : Optional[str]
+ Where the namespace can be found.
+
+ """
+
+ namespace: Optional[_Namespace] = optional_field(_Namespace) # type: ignore
isshub/domain/contexts/core/entities/namespace/features/describe.feature¶
- Type
Added
- Stats
+37 -0
@@ -0,0 +1,37 @@
+Feature: Describing a Namespace
+
+ Scenario: A Namespace has an id
+ Given a Namespace
+ Then it must have a field named id
+
+ Scenario: A Namespace id is a positive integer
+ Given a Namespace
+ Then its id must be a positive integer
+
+ Scenario: A Namespace id cannot be None
+ Given a Namespace
+ Then its id cannot be None
+
+ Scenario: A Namespace has a name
+ Given a Namespace
+ Then it must have a field named name
+
+ Scenario: A Namespace name is a string
+ Given a Namespace
+ Then its name must be a string
+
+ Scenario: A Namespace name cannot be None
+ Given a Namespace
+ Then its name cannot be None
+
+ Scenario: A Namespace has a namespace
+ Given a Namespace
+ Then it must have a field named namespace
+
+ Scenario: A Namespace namespace is a Namespace
+ Given a Namespace
+ Then its namespace must be a Namespace
+
+ Scenario: A Namespace namespace can be None
+ Given a Namespace
+ Then its namespace can be None
isshub/domain/contexts/core/entities/namespace/tests/__init__.py¶
- Type
Added
- Stats
+1 -0
@@ -0,0 +1 @@
+"""Package holding the tests for the ``Namespace`` core entity."""
isshub/domain/contexts/core/entities/namespace/tests/factories.py¶
- Type
Added
- Stats
+17 -0
@@ -0,0 +1,17 @@
+"""Module defining factories for the Namespace core entity."""
+
+import factory
+
+from isshub.domain.contexts.core.entities.namespace import Namespace
+
+
+class NamespaceFactory(factory.Factory):
+ """Factory for the ``Namespace`` core entity."""
+
+ class Meta:
+ """Factory config."""
+
+ model = Namespace
+
+ id = factory.Faker("pyint", min=1)
+ name = factory.Faker("pystr", min_chars=2)
isshub/domain/contexts/core/entities/namespace/tests/fixtures.py¶
- Type
Added
- Stats
+36 -0
@@ -0,0 +1,36 @@
+"""Module defining fixtures for the Namespace core entity."""
+
+
+from typing import Type
+
+from pytest import fixture
+
+from isshub.domain.contexts.core.entities.namespace import Namespace
+
+from .factories import NamespaceFactory
+
+
+@fixture # type: ignore
+def namespace_factory() -> Type[NamespaceFactory]:
+ """Fixture to return the factory to create a ``Namespace``.
+
+ Returns
+ -------
+ Type[NamespaceFactory]
+ The ``NamespaceFactory`` class.
+
+ """
+ return NamespaceFactory
+
+
+@fixture # type: ignore
+def namespace() -> Namespace:
+ """Fixture to return a ``Namespace``.
+
+ Returns
+ -------
+ Namespace
+ The created ``Namespace``
+
+ """
+ return NamespaceFactory()
isshub/domain/contexts/core/entities/namespace/tests/test_describe.py¶
- Type
Added
- Stats
+72 -0
@@ -0,0 +1,72 @@
+"""Module holding BDD tests for isshub Namespace core entity."""
+
+import pytest
+from pytest import mark
+from pytest_bdd import given, parsers, scenario, scenarios, then
+
+from isshub.domain.utils.testing.validation import (
+ check_field,
+ check_field_not_nullable,
+ check_field_nullable,
+ check_field_value,
+ positive_integer_only,
+ string_only,
+)
+
+from .fixtures import namespace, namespace_factory
+
+
+@mark.parametrize(["value", "exception"], positive_integer_only)
+@scenario("../features/describe.feature", "A Namespace id is a positive integer")
+def test_namespace_id_is_a_positive_integer(value, exception):
+ pass
+
+
+@mark.parametrize(["value", "exception"], string_only)
+@scenario("../features/describe.feature", "A Namespace name is a string")
+def test_namespace_name_is_a_string(value, exception):
+ pass
+
+
+@mark.parametrize(
+ ["value", "exception"],
+ [(pytest.lazy_fixture("namespace"), None), ("foo", TypeError), (1, TypeError)],
+)
+@scenario("../features/describe.feature", "A Namespace namespace is a Namespace")
+def test_namespace_namespace_is_a_namespace(value, exception):
+ pass
+
+
+scenarios("../features/describe.feature")
+
+
+@given("a Namespace")
+def namespace(namespace_factory):
+ return namespace_factory()
+
+
+@then(parsers.parse("it must have a field named {field_name:w}"))
+def namespace_has_field(namespace, field_name):
+ check_field(namespace, field_name)
+
+
+@then(parsers.parse("its {field_name:w} must be a {field_type}"))
+def namespace_field_is_of_a_certain_type(
+ namespace_factory,
+ field_name,
+ field_type,
+ # next args are for parametrize
+ value,
+ exception,
+):
+ check_field_value(namespace_factory, field_name, value, exception)
+
+
+@then(parsers.parse("its {field_name:w} cannot be none"))
+def namespace_field_cannot_be_none(namespace_factory, field_name):
+ check_field_not_nullable(namespace_factory, field_name)
+
+
+@then(parsers.parse("its {field_name:w} can be none"))
+def namespace_field_can_be_none(namespace_factory, field_name):
+ check_field_nullable(namespace_factory, field_name)
isshub/domain/contexts/core/entities/repository/__init__.py¶
- Type
Modified
- Stats
+3 -2
@@ -1,5 +1,6 @@
"""Package defining the ``Repository`` entity."""
+from isshub.domain.contexts.core.entities.namespace import Namespace
from isshub.domain.utils.entity import BaseModelWithId, required_field, validated
@@ -13,10 +14,10 @@ class Repository(BaseModelWithId):
The unique identifier of the repository
name : str
The name of the repository. Unique in its namespace.
- namespace : str
+ namespace : Namespace
Where the repository can be found.
"""
name: str = required_field(str) # type: ignore
- namespace: str = required_field(str) # type: ignore
+ namespace: Namespace = required_field(Namespace) # type: ignore
isshub/domain/contexts/core/entities/repository/features/describe.feature¶
- Type
Modified
- Stats
+2 -2
@@ -28,9 +28,9 @@ Feature: Describing a Repository
Given a Repository
Then it must have a field named namespace
- Scenario: A Repository namespace is a string
+ Scenario: A Repository namespace is a Namespace
Given a Repository
- Then its namespace must be a string
+ Then its namespace must be a Namespace
Scenario: A Repository namespace cannot be None
Given a Repository
isshub/domain/contexts/core/entities/repository/tests/factories.py¶
- Type
Modified
- Stats
+3 -1
@@ -4,6 +4,8 @@ import factory
from isshub.domain.contexts.core.entities.repository import Repository
+from ...namespace.tests.factories import NamespaceFactory
+
class RepositoryFactory(factory.Factory):
"""Factory for the ``Repository`` core entity."""
@@ -15,4 +17,4 @@ class RepositoryFactory(factory.Factory):
id = factory.Faker("pyint", min=1)
name = factory.Faker("pystr", min_chars=2)
- namespace = factory.Faker("pystr", min_chars=2)
+ namespace = factory.SubFactory(NamespaceFactory)
isshub/domain/contexts/core/entities/repository/tests/test_describe.py¶
- Type
Modified
- Stats
+18 -33
@@ -4,8 +4,15 @@ 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 isshub.domain.utils.testing.validation import (
+ check_field,
+ check_field_not_nullable,
+ check_field_value,
+ positive_integer_only,
+ string_only,
+)
+
+from ...namespace.tests.fixtures import namespace
from .fixtures import repository_factory
@@ -21,9 +28,12 @@ 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):
+@mark.parametrize(
+ ["value", "exception"],
+ [(pytest.lazy_fixture("namespace"), None), ("foo", TypeError), (1, TypeError)],
+)
+@scenario("../features/describe.feature", "A Repository namespace is a Namespace")
+def test_repository_namespace_is_a_namespace(value, exception):
pass
@@ -37,7 +47,7 @@ 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)
+ check_field(repository, field_name)
@then(parsers.parse("its {field_name:w} must be a {field_type}"))
@@ -49,34 +59,9 @@ def repository_field_is_of_a_certain_type(
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()
+ check_field_value(repository_factory, field_name, value, exception)
@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()
+ check_field_not_nullable(repository_factory, field_name)
isshub/domain/utils/testing/__init__.py¶
- Type
Modified
- Stats
+7 -0
@@ -1 +1,8 @@
"""Utils for isshub domain tests."""
+
+try:
+ import pytest
+except ImportError:
+ pass
+else:
+ pytest.register_assert_rewrite("isshub.domain.utils.testing.validation")
isshub/domain/utils/testing/validation.py¶
- Type
Modified
- Stats
+141 -1
@@ -1,7 +1,12 @@
-"""Validation helpers for BDD tests for isshub entity models."""
+"""Validation helpers for BDD tests for isshub entities."""
from typing import Any, List, Optional, Tuple, Type
+import pytest
+from factory import Factory
+
+from isshub.domain.utils.entity import BaseModel
+
ValuesValidation = List[Tuple[Any, Optional[Type[Exception]]]]
@@ -22,3 +27,138 @@ no_zero: ValuesValidation = [(0, ValueError)]
positive_integer_only: ValuesValidation = integer_only + no_zero
string_only: ValuesValidation = [("foo", None), (1, TypeError), (-0.1, TypeError)]
+
+
+def check_field(obj: BaseModel, field_name: str) -> None:
+ """Assert that the given `obj` has an attribute named `field_name`.
+
+ Parameters
+ ----------
+ obj : BaseModel
+ The object to test
+ field_name : str
+ The field name to search for
+
+ Raises
+ ------
+ AssertionError
+ If `obj` does not contain any attribute named `field_name`
+
+ """
+ assert hasattr(obj, field_name)
+
+
+def check_field_value(
+ factory: Type[Factory],
+ field_name: str,
+ value: Any,
+ exception: Optional[Type[Exception]],
+ **factory_kwargs: Any,
+) -> None:
+ """Assert that an object can or cannot have a specific value for a specific field.
+
+ Parameters
+ ----------
+ factory : Type[Factory]
+ The factory to use to create the object to test
+ field_name : str
+ The name of the field to check
+ value : Any
+ The value to set to the field
+ exception : Optional[Type[Exception]]
+ The exception expected to be raised. If ``None``, no exception is expected to be raised.
+ factory_kwargs : Any
+ Any kwargs to pass to the factory to create the object
+
+ Raises
+ ------
+ AssertionError
+ If an exception is raised when setting the `value` to the field when creating or updating
+ an object (when calling ``validate`` in case of updating), and `exception` is ``None``,
+ or if no exception is raised if `exception` is not ``None`` (or the wrong exception).
+
+ """
+ if exception:
+ # When creating an instance
+ with pytest.raises(exception):
+ factory(**{field_name: value}, **factory_kwargs)
+ # When updating the value
+ obj = factory(**factory_kwargs)
+ setattr(obj, field_name, value)
+ with pytest.raises(exception):
+ obj.validate()
+ else:
+ # When creating an instance
+ factory(**{field_name: value}, **factory_kwargs)
+ # When updating the value
+ obj = factory(**factory_kwargs)
+ setattr(obj, field_name, value)
+ obj.validate()
+
+
+def check_field_not_nullable(
+ factory: Type[Factory], field_name: str, **factory_kwargs: Any
+) -> None:
+ """Assert that an object cannot have a specific field set to ``None``.
+
+ Parameters
+ ----------
+ factory : Type[Factory]
+ The factory to use to create the object to test
+ field_name : str
+ The name of the field to check
+ factory_kwargs : Any
+ Any kwargs to pass to the factory to create the object
+
+ Raises
+ ------
+ AssertionError
+ If the field can be set to ``None`` while creating or updating an object (when calling
+ ``validate`` in case of updating)
+
+ """
+ # When creating an instance
+ with pytest.raises(TypeError):
+ factory(**{field_name: None}, **factory_kwargs)
+
+ # When updating the value
+ obj = factory(**factory_kwargs)
+ setattr(obj, field_name, None)
+ with pytest.raises(TypeError):
+ obj.validate()
+
+
+def check_field_nullable(
+ factory: Type[Factory], field_name: str, **factory_kwargs: Any
+) -> None:
+ """Assert that an object can have a specific field set to ``None``.
+
+ Parameters
+ ----------
+ factory : Type[Factory]
+ The factory to use to create the object to test
+ field_name : str
+ The name of the field to check
+ factory_kwargs : Any
+ Any kwargs to pass to the factory to create the object
+
+ Raises
+ ------
+ AssertionError
+ If the field cannot be set to ``None`` while creating or updating an object (when calling
+ ``validate`` in case of updating)
+
+ """
+ # When creating an instance
+ try:
+ factory(**{field_name: None}, **factory_kwargs)
+ except TypeError:
+ pytest.fail(f"DID RAISE {TypeError}")
+
+ # When updating the value
+ obj = factory(**factory_kwargs)
+ setattr(obj, field_name, None)
+ try:
+ obj.validate()
+ except TypeError:
+ pytest.fail(f"DID RAISE {TypeError}")