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

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}")

setup.cfg

Type

Modified

Stats

+1 -0

@@ -42,6 +42,7 @@ tests =
     pytest
     pytest-bdd
     pytest-cov
+    pytest-lazy-fixture
     pytest-sugar
 lint=
     black