feat(repository): Add domain repositories

Description

Abstract

Add base repositories to store entities. With in-memory ones for entities in the “code-repository” context (Repository and Namespace)

Motivation

It’s the next step in DDD: now that we have some entities, we need to store them, and this is done via repositories. For now, simply adding base repositories and in-memory ones is enough. To save entities in a permanent way, we’ll introduce later other repositories via the Django ORM.

Rationale

N/A

Info

Hash

27f013e2a3722926a9bbe300a77a493604f0993c

Date

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

Parents
  • fix(entity): `id` changed from `int` to `uuid4`, renamed to `identifier` [79f704bd]2020-10-05 10:51:49 +0200

Children
  • docs(domain): Add diagrams for repositories [34f4694c]2020-10-07 12:10:48 +0200

Branches
Tags

(No tags)

Changes

.gitignore

Type

Modified

Stats

+1 -0

@@ -72,6 +72,7 @@ instance/

 # Sphinx documentation
 docs/_build/
+docs/_build_txt/
 # Doc from code
 docs/source/
 # Doc from bdd features

docs/_static/css/gherkin.css

Type

Modified

Stats

+4 -2

@@ -1,8 +1,10 @@
 /* Style for doc generated by `sphinx-gherkindoc` */
-div[id^="scenario-"] h2 ~ * {
+div[id^="feature-"].section div[id^="scenario-"] h2 ~ *,
+div[id^="feature-"].section div[id^="background-"] h2 ~ * {
   margin-left: 2em !important;
 }
-.gherkin-scenario-content {
+.gherkin-scenario-content,
+.gherkin-background-content {
     font-weight: normal;
 }
 .gherkin-step-keyword {

docs/conf.py

Type

Modified

Stats

+2 -1

@@ -93,6 +93,7 @@ html_use_old_search_snippets = True
 # -- Run apidoc when building the documentation-------------------------------

 napoleon_use_ivar = True
+autodoc_member_order = "bysource"
 add_module_names = False


@@ -140,7 +141,7 @@ def run_gherkindoc(_):
             "--toc-name",
             "index",
             "--maxtocdepth",
-            "5",
+            "4",  # avoid scenarios for ``isshub.domain.contexts`` (may to too much/not enough later)
         ]
     )

isshub/domain/contexts/code_repository/entities/namespace/__init__.py

Type

Modified

Stats

+3 -3

@@ -1,4 +1,4 @@
-"""Package defining the ``Namespace`` entity."""
+"""Package defining the :obj:`Namespace` entity."""

 import enum
 from typing import Any, Optional
@@ -50,14 +50,14 @@ class Namespace(BaseEntityWithIdentifier):
     def validate_namespace_is_not_in_a_loop(  # noqa  # pylint: disable=unused-argument
         self, field: Any, value: Any
     ) -> None:
-        """Validate that the ``namespace`` field is not in a loop.
+        """Validate that the :obj:`Namespace.namespace` field is not in a loop.

         Being in a loop means that one of the descendants is the parent of one of the ascendants.

         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`.

isshub/domain/contexts/code_repository/entities/namespace/features/describe.feature

Type

Modified

Stats

+40 -40

@@ -1,75 +1,75 @@
-Feature: Describing a Namespace
+Feature: Describing a namespace

-    Scenario: A Namespace has an identifier
-        Given a Namespace
+    Scenario: A namespace has an identifier
+        Given a namespace
         Then it must have a field named identifier

-    Scenario: A Namespace identifier is a uuid
-        Given a Namespace
+    Scenario: A namespace identifier is a uuid
+        Given a namespace
         Then its identifier must be a uuid

-    Scenario: A Namespace identifier is mandatory
-        Given a Namespace
+    Scenario: A namespace identifier is mandatory
+        Given a namespace
         Then its identifier is mandatory

-    Scenario: A Namespace identifier cannot be changed
-        Given a Namespace
+    Scenario: A namespace identifier cannot be changed
+        Given a namespace
         Then its identifier cannot be changed

-    Scenario: A Namespace has a name
-        Given a Namespace
+    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
+    Scenario: A namespace name is a string
+        Given a namespace
         Then its name must be a string

-    Scenario: A Namespace name is mandatory
-        Given a Namespace
+    Scenario: A namespace name is mandatory
+        Given a namespace
         Then its name is mandatory

-    Scenario: A Namespace has a description
-        Given a Namespace
+    Scenario: A namespace has a description
+        Given a namespace
         Then it must have a field named description

-    Scenario: A Namespace description is a string
-        Given a Namespace
+    Scenario: A namespace description is a string
+        Given a namespace
         Then its description must be a string

-    Scenario: A Namespace description is optional
-        Given a Namespace
+    Scenario: A namespace description is optional
+        Given a namespace
         Then its description is optional

-    Scenario: A Namespace has a namespace
-        Given a Namespace
+    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 is a namespace
+        Given a namespace
+        Then its namespace must be a namespace

-    Scenario: A Namespace namespace is optional
-        Given a Namespace
+    Scenario: A namespace namespace is optional
+        Given a namespace
         Then its namespace is optional

-    Scenario: A Namespace has a kind
-        Given a Namespace
+    Scenario: A namespace has a kind
+        Given a namespace
         Then it must have a field named kind

-    Scenario: A Namespace kind is a NamespaceKind
-        Given a Namespace
+    Scenario: A namespace kind is a NamespaceKind
+        Given a namespace
         Then its kind must be a NamespaceKind

-    Scenario: A Namespace kind is mandatory
-        Given a Namespace
+    Scenario: A namespace kind is mandatory
+        Given a namespace
         Then its kind is mandatory

-    Scenario: A Namespace cannot be contained in itself
-        Given a Namespace
+    Scenario: A namespace cannot be contained in itself
+        Given a namespace
         Then its namespace cannot be itself

-    Scenario: A Namespace namespace cannot be in a loop
-        Given a Namespace
-        And a second Namespace
-        And a third Namespace
+    Scenario: A namespace namespace cannot be in a loop
+        Given a namespace
+        And a second namespace
+        And a third namespace
         Then we cannot create a relationships loop with these namespaces

isshub/domain/contexts/code_repository/entities/namespace/tests/test_describe.py

Type

Modified

Stats

+21 -15

@@ -1,4 +1,5 @@
-"""Module holding BDD tests for isshub Namespace code_repository entity."""
+"""Module holding BDD tests for isshub Namespace code_repository entity as defined in ``describe.feature``."""
+from functools import partial
 from uuid import uuid4

 import pytest
@@ -19,14 +20,18 @@ from isshub.domain.utils.testing.validation import (
 from .fixtures import namespace, namespace_factory


+FEATURE_FILE = "../features/describe.feature"
+scenario = partial(scenario, FEATURE_FILE)
+
+
 @mark.parametrize(["value", "exception"], uuid4_only)
-@scenario("../features/describe.feature", "A Namespace identifier is a uuid")
+@scenario("A namespace identifier is a uuid")
 def test_namespace_identifier_is_a_uuid(value, exception):
     pass


 @mark.parametrize(["value", "exception"], string_only)
-@scenario("../features/describe.feature", "A Namespace name is a string")
+@scenario("A namespace name is a string")
 def test_namespace_name_is_a_string(value, exception):
     pass

@@ -35,7 +40,7 @@ def test_namespace_name_is_a_string(value, exception):
     ["value", "exception"],
     [(pytest.lazy_fixture("namespace"), None), ("foo", TypeError), (1, TypeError)],
 )
-@scenario("../features/describe.feature", "A Namespace namespace is a Namespace")
+@scenario("A namespace namespace is a namespace")
 def test_namespace_namespace_is_a_namespace(value, exception):
     pass

@@ -44,21 +49,18 @@ def test_namespace_namespace_is_a_namespace(value, exception):
     ["value", "exception"],
     [(NamespaceKind.GROUP, None), ("foo", TypeError), (1, TypeError)],
 )
-@scenario("../features/describe.feature", "A Namespace kind is a NamespaceKind")
+@scenario("A namespace kind is a NamespaceKind")
 def test_namespace_kind_is_a_namespacekind(value, exception):
     pass


 @mark.parametrize(["value", "exception"], string_only)
-@scenario("../features/describe.feature", "A Namespace description is a string")
+@scenario("A namespace description is a string")
 def test_namespace_description_is_a_string(value, exception):
     pass


-scenarios("../features/describe.feature")
-
-
-@given("a Namespace", target_fixture="namespace")
+@given("a namespace", target_fixture="namespace")
 def a_namespace(namespace_factory):
     return namespace_factory()

@@ -90,7 +92,7 @@ def namespace_field_is_optional(namespace_factory, field_name):
     check_field_nullable(namespace_factory, field_name)


-@scenario("../features/describe.feature", "A Namespace identifier cannot be changed")
+@scenario("A namespace identifier cannot be changed")
 def test_namespace_identifier_cannot_be_changed():
     pass

@@ -101,7 +103,7 @@ def namespace_identifier_cannot_be_changed(namespace):
         namespace.identifier = uuid4()


-@scenario("../features/describe.feature", "A Namespace cannot be contained in itself")
+@scenario("A namespace cannot be contained in itself")
 def test_namespace_namespace_cannot_be_itself():
     pass

@@ -113,17 +115,17 @@ def namespace_namespace_cannot_be_itself(namespace):
         namespace.validate()


-@scenario("../features/describe.feature", "A Namespace namespace cannot be in a loop")
+@scenario("A namespace namespace cannot be in a loop")
 def test_namespace_namespace_cannot_be_in_a_loop():
     pass


-@given("a second Namespace", target_fixture="namespace2")
+@given("a second namespace", target_fixture="namespace2")
 def a_second_namespace(namespace_factory):
     return namespace_factory()


-@given("a third Namespace", target_fixture="namespace3")
+@given("a third namespace", target_fixture="namespace3")
 def a_third_namespace(namespace_factory):
     return namespace_factory()

@@ -149,3 +151,7 @@ def namespace_relationships_cannot_create_a_loop(namespace, namespace2, namespac
     namespace3.validate()
     namespace2.validate()
     namespace.validate()
+
+
+# To make pytest-bdd fail if some scenarios are not implemented. KEEP AT THE END
+scenarios(FEATURE_FILE)

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

Type

Modified

Stats

+1 -1

@@ -1,4 +1,4 @@
-"""Package defining the ``Repository`` entity."""
+"""Package defining the :obj:`Repository` entity."""

 from isshub.domain.contexts.code_repository.entities.namespace import Namespace
 from isshub.domain.utils.entity import (

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

Type

Modified

Stats

+21 -21

@@ -1,41 +1,41 @@
-Feature: Describing a Repository
+Feature: Describing a repository

-    Scenario: A Repository has an identifier
-        Given a Repository
+    Scenario: A repository has an identifier
+        Given a repository
         Then it must have a field named identifier

-    Scenario: A Repository identifier is a uuid
-        Given a Repository
+    Scenario: A repository identifier is a uuid
+        Given a repository
         Then its identifier must be a uuid

-    Scenario: A Repository identifier is mandatory
-        Given a Repository
+    Scenario: A repository identifier is mandatory
+        Given a repository
         Then its identifier is mandatory

-    Scenario: A Repository identifier cannot be changed
-        Given a Repository
+    Scenario: A repository identifier cannot be changed
+        Given a repository
         Then its identifier cannot be changed

-    Scenario: A Repository has a name
-        Given a Repository
+    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
+    Scenario: A repository name is a string
+        Given a repository
         Then its name must be a string

-    Scenario: A Repository name is mandatory
-        Given a Repository
+    Scenario: A repository name is mandatory
+        Given a repository
         Then its name is mandatory

-    Scenario: A Repository has a namespace
-        Given a Repository
+    Scenario: A repository has a namespace
+        Given a repository
         Then it must have a field named namespace

-    Scenario: A Repository namespace is a Namespace
-        Given a Repository
+    Scenario: A repository namespace is a Namespace
+        Given a repository
         Then its namespace must be a Namespace

-    Scenario: A Repository namespace is mandatory
-        Given a Repository
+    Scenario: A repository namespace is mandatory
+        Given a repository
         Then its namespace is mandatory

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

Type

Modified

Stats

+14 -8

@@ -1,4 +1,5 @@
 """Module holding BDD tests for isshub Repository code_repository entity."""
+from functools import partial
 from uuid import uuid4

 import pytest
@@ -18,14 +19,18 @@ from ...namespace.tests.fixtures import namespace
 from .fixtures import repository_factory


+FEATURE_FILE = "../features/describe.feature"
+scenario = partial(scenario, FEATURE_FILE)
+
+
 @mark.parametrize(["value", "exception"], uuid4_only)
-@scenario("../features/describe.feature", "A Repository identifier is a uuid")
+@scenario("A repository identifier is a uuid")
 def test_repository_identifier_is_a_uuid(value, exception):
     pass


 @mark.parametrize(["value", "exception"], string_only)
-@scenario("../features/describe.feature", "A Repository name is a string")
+@scenario("A repository name is a string")
 def test_repository_name_is_a_string(value, exception):
     pass

@@ -34,15 +39,12 @@ def test_repository_name_is_a_string(value, exception):
     ["value", "exception"],
     [(pytest.lazy_fixture("namespace"), None), ("foo", TypeError), (1, TypeError)],
 )
-@scenario("../features/describe.feature", "A Repository namespace is a Namespace")
+@scenario("A repository namespace is a Namespace")
 def test_repository_namespace_is_a_namespace(value, exception):
     pass


-scenarios("../features/describe.feature")
-
-
-@given("a Repository", target_fixture="repository")
+@given("a repository", target_fixture="repository")
 def a_repository(repository_factory):
     return repository_factory()

@@ -69,7 +71,7 @@ def repository_field_is_mandatory(repository_factory, field_name):
     check_field_not_nullable(repository_factory, field_name)


-@scenario("../features/describe.feature", "A Repository identifier cannot be changed")
+@scenario("A repository identifier cannot be changed")
 def test_repository_identifier_cannot_be_changed():
     pass

@@ -78,3 +80,7 @@ def test_repository_identifier_cannot_be_changed():
 def repository_identifier_cannot_be_changed(repository):
     with pytest.raises(FrozenAttributeError):
         repository.identifier = uuid4()
+
+
+# To make pytest-bdd fail if some scenarios are not implemented. KEEP AT THE END
+scenarios(FEATURE_FILE)

isshub/domain/contexts/code_repository/repositories/__init__.py

Type

Added

Stats

+1 -0

@@ -0,0 +1 @@
+"""Package to handle repositories for the isshub entities for domain code_repository context."""

isshub/domain/contexts/code_repository/repositories/namespace/__init__.py

Type

Added

Stats

+78 -0

@@ -0,0 +1,78 @@
+"""Package defining the repository for the :obj:`.Namespace` entity."""
+
+import abc
+from typing import Iterable, Union
+
+from .....utils.repository import AbstractInMemoryRepository, AbstractRepository
+from ...entities import Namespace
+
+
+class AbstractNamespaceRepository(
+    AbstractRepository[Namespace], entity_class=Namespace
+):
+    """Base repository for the :obj:`.Namespace` entity."""
+
+    @abc.abstractmethod
+    def for_namespace(self, namespace: Union[Namespace, None]) -> Iterable[Namespace]:
+        """Iterate on namespaces found in the given `namespace`, or with no namespace if ``None``.
+
+        Parameters
+        ----------
+        namespace : Union[Namespace, None]
+            The namespace for which we want to find the namespaces.
+            If ``None``, will look for namespaces having no parent namespace.
+
+        Returns
+        -------
+        Iterable[Namespace]
+            An iterable of the namespaces found in the `namespace` (or that have no namespace if
+            `namespace` is ``None``)
+
+        """
+
+
+class InMemoryNamespaceRepository(
+    AbstractInMemoryRepository, AbstractNamespaceRepository
+):
+    """Repository to handle :obj:`.Namespace` entities in memory."""
+
+    def add(self, entity: Namespace) -> Namespace:
+        """Add the given Namespace `entity` in the repository.
+
+        For the parameters, see :obj:`AbstractRepository.add`.
+
+        Returns
+        -------
+        Namespace
+            The added Namespace
+
+        Raises
+        ------
+        self.UniquenessError
+            - If a namespace with the same identifier as the given one already exists.
+            - If a namespace with the same name and parent namespace (including no namespace) as
+              the given one already exists.
+
+        """
+        if any(
+            namespace
+            for namespace in self.for_namespace(entity.namespace)
+            if namespace.name == entity.name
+        ):
+            raise self.UniquenessError(
+                f"One already exists with name={entity.name} and namespace={entity.namespace}"
+            )
+        return super().add(entity)
+
+    def for_namespace(self, namespace: Union[Namespace, None]) -> Iterable[Namespace]:
+        """Iterate on namespaces found in the given `namespace`, or with no namespace if ``None``.
+
+        For the parameters, see :obj:`AbstractNamespaceRepository.for_namespace`
+
+        Returns
+        -------
+        Iterable[Namespace]
+            An iterable of the namespaces found in the `namespace`
+
+        """
+        return (entity for entity in self._collection if entity.namespace == namespace)

isshub/domain/contexts/code_repository/repositories/namespace/features/storage.feature

Type

Added

Stats

+119 -0

@@ -0,0 +1,119 @@
+Feature: Storing namespaces
+
+    Background: Given a namespace and a namespace storage
+        Given a namespace with a parent namespace
+        And a namespace storage
+
+    Scenario: A new namespace can be saved and retrieved
+        When the namespace is added to the namespace storage
+        Then I can retrieve it
+
+    Scenario: A new namespace cannot be saved if invalid
+        When the namespace has some invalid content
+        Then I cannot add it because it's invalid
+
+    Scenario: An existing namespace cannot be added
+        When the namespace is added to the namespace storage
+        Then it's not possible to add it again
+
+    Scenario: An existing namespace can be updated
+        When the namespace is added to the namespace storage
+        And it is updated
+        Then I can retrieve its updated version
+
+    Scenario: An existing namespace cannot be saved if invalid
+        When the namespace has some invalid content
+        Then I cannot update it because it's invalid
+
+    Scenario: A non existing namespace cannot be updated
+        When the namespace is not added to the namespace storage
+        Then I cannot update it because it does not exist
+
+    Scenario: An existing namespace can be deleted
+        When the namespace is added to the namespace storage
+        And it is deleted
+        Then I cannot retrieve it
+
+    Scenario: An non existing namespace cannot be deleted
+        When the namespace is not added to the namespace storage
+        Then I cannot delete it
+
+    Scenario: All namespaces in same namespace can be retrieved at once
+        Given a parent namespace with no namespaces in it
+        And a second namespace, in the parent namespace
+        And a third namespace, in the parent namespace
+        When the namespace is added to the namespace storage
+        And the second namespace is added to the namespace storage
+        And the third namespace is added to the namespace storage
+        Then I can retrieve the second and the third namespaces at once
+
+    Scenario: No namespaces returned from a parent namespace without namespaces
+        Given a parent namespace with no namespaces in it
+        Then I got no namespaces for the parent namespace
+
+    Scenario: A namespace cannot be added if another exists with same name in same parent namespace
+        Given a second namespace with same name in the same parent namespace
+        When the namespace is added to the namespace storage
+        Then I cannot add the second one
+
+    Scenario: A namespace cannot be added if another exists with same name both without parent namespace
+        Given a namespace without parent namespace
+        And a second namespace with same name and without parent namespace
+        When the namespace is added to the namespace storage
+        Then I cannot add the second one
+
+    Scenario: A namespace cannot be updated if another exists with same new name in same parent namespace
+        Given a second namespace in the same parent namespace
+        When the namespace is added to the namespace storage
+        And the second namespace is added to the namespace storage
+        And the second namespace name is set as for the first one
+        Then I cannot update the second one
+
+    Scenario: A namespace cannot be updated if another exists with same new name both without parent namespace
+        Given a namespace without parent namespace
+        And a second namespace without parent namespace
+        When the namespace is added to the namespace storage
+        And the second namespace is added to the namespace storage
+        And the second namespace name is set as for the first one
+        Then I cannot update the second one
+
+    Scenario: A namespace cannot be updated if another exists with same name in new same parent namespace
+        Given a second namespace with the same name
+        When the namespace is added to the namespace storage
+        And the second namespace is added to the namespace storage
+        And the second namespace parent namespace is set as for the first one
+        Then I cannot update the second one
+
+    Scenario: A namespace cannot be updated if another exists with same name now both without namespace
+        Given a namespace without parent namespace
+        And a second namespace with the same name and a parent namespace
+        When the namespace is added to the namespace storage
+        And the second namespace is added to the namespace storage
+        And the second namespace parent namespace is cleared
+        Then I cannot update the second one
+
+    Scenario: A namespace can be moved from one parent namespace to another
+        Given a parent namespace with no namespaces in it
+        And a second parent namespace with no namespaces in it
+        When the namespace is added to the namespace storage
+        And the namespace is set in the first parent namespace
+        And I change its namespace
+        Then the namespace is no longer available in the original parent namespace
+        And the namespace is available in the new parent namespace
+
+    Scenario: A namespace without parent namespace can be moved to one
+        Given a namespace without parent namespace
+        And a parent namespace with no namespaces in it
+        When the namespace is added to the namespace storage
+        And the namespace is set in the first parent namespace
+        Then the namespace is no longer available when fetching namespaces without parents
+        And the namespace is available in the parent namespace
+
+    Scenario: A namespace with a parent namespace can have its parent namespace cleared
+        Given a namespace without parent namespace
+        And a parent namespace with no namespaces in it
+        When the namespace is added to the namespace storage
+        And the namespace is set in the parent namespace
+        And the namespace parent namespace is cleared
+        Then the namespace is no longer available in the parent namespace
+        And the namespace is available when fetching namespaces without parents

isshub/domain/contexts/code_repository/repositories/namespace/tests/__init__.py

Type

Added

Stats

+1 -0

@@ -0,0 +1 @@
+"""Package holding the tests for the ``Namespace`` code_repository repository."""

isshub/domain/contexts/code_repository/repositories/namespace/tests/test_storage.py

Type

Added

Stats

+378 -0

@@ -0,0 +1,378 @@
+"""Module holding BDD tests for isshub repository for code_repository Namespace entity as defined in ``storage.feature``."""
+from functools import partial
+
+import pytest
+from pytest_bdd import given, scenario, scenarios, then, when
+
+from isshub.domain.contexts.code_repository.repositories.namespace import (
+    InMemoryNamespaceRepository,
+)
+
+from ....entities.namespace.tests.fixtures import namespace_factory
+
+
+FEATURE_FILE = "../features/storage.feature"
+scenario = partial(scenario, FEATURE_FILE)
+
+
+@scenario("A new namespace can be saved and retrieved")
+def test_add_new_namespace():
+    pass
+
+
+@given("a namespace with a parent namespace", target_fixture="namespace")
+def a_namespace_with_parent_namespace(namespace_factory):
+    return namespace_factory(namespace=namespace_factory(namespace=None))
+
+
+@given("a namespace without parent namespace", target_fixture="namespace")
+def a_namespace_without_namespace(namespace_factory):
+    return namespace_factory(namespace=None)
+
+
+@given("a namespace storage", target_fixture="namespace_storage")
+def a_namespace_storage():
+    return InMemoryNamespaceRepository()
+
+
+@when("the namespace is added to the namespace storage")
+def add_namespace(namespace, namespace_storage):
+    namespace_storage.add(namespace)
+
+
+@then("I can retrieve it")
+def retrieve_new_from_namespace(namespace, namespace_storage):
+    assert namespace_storage.exists(identifier=namespace.identifier)
+    from_namespace = namespace_storage.get(identifier=namespace.identifier)
+    assert from_namespace == namespace
+
+
+@scenario("A new namespace cannot be saved if invalid")
+def test_add_invalid_namespace():
+    pass
+
+
+@when("the namespace has some invalid content")
+def update_namespace_with_invalid_content(namespace):
+    namespace.kind = 123
+
+
+@then("I cannot add it because it's invalid")
+def cannot_add_invalid_namespace(namespace, namespace_storage):
+    with pytest.raises(TypeError):
+        namespace_storage.add(namespace)
+
+
+@scenario("An existing namespace cannot be added")
+def test_add_existing_namespace():
+    pass
+
+
+@then("it's not possible to add it again")
+def cannot_add_existing_namespace(namespace, namespace_storage):
+    with pytest.raises(namespace_storage.UniquenessError):
+        namespace_storage.add(namespace)
+
+
+@scenario("An existing namespace can be updated")
+def test_update_existing_namespace():
+    pass
+
+
+@when("it is updated")
+def namespace_is_updated(namespace, namespace_storage):
+    namespace.name = "new name"
+    namespace_storage.update(namespace)
+
+
+@then("I can retrieve its updated version")
+def retrieve_updated_from_namespace(namespace, namespace_storage):
+    from_namespace = namespace_storage.get(identifier=namespace.identifier)
+    assert from_namespace.name == "new name"
+
+
+@scenario("An existing namespace cannot be saved if invalid")
+def test_update_invalid_namespace():
+    pass
+
+
+@then("I cannot update it because it's invalid")
+def cannot_update_invalid_namespace(namespace, namespace_storage):
+    with pytest.raises(TypeError):
+        namespace_storage.update(namespace)
+
+
+@scenario("A non existing namespace cannot be updated")
+def test_update_non_existing_namespace():
+    pass
+
+
+@when("the namespace is not added to the namespace storage")
+def add_namespace(namespace, namespace_storage):
+    pass
+
+
+@then("I cannot update it because it does not exist")
+def cannot_update_non_existing_namespace(namespace, namespace_storage):
+    namespace.name = "new name"
+    with pytest.raises(namespace_storage.NotFoundError):
+        namespace_storage.update(namespace)
+
+
+@scenario("An existing namespace can be deleted")
+def test_delete_namespace():
+    pass
+
+
+@when("it is deleted")
+def namespace_is_deleted(namespace, namespace_storage):
+    namespace_storage.delete(namespace)
+
+
+@then("I cannot retrieve it")
+def cannot_retrieve_deleted_namespace(namespace, namespace_storage):
+    with pytest.raises(namespace_storage.NotFoundError):
+        namespace_storage.get(identifier=namespace.identifier)
+
+
+@scenario("An non existing namespace cannot be deleted")
+def test_delete_non_existing_namespace():
+    pass
+
+
+@then("I cannot delete it")
+def cannot_delete_non_existing_namespace(namespace, namespace_storage):
+    with pytest.raises(namespace_storage.NotFoundError):
+        namespace_storage.delete(namespace)
+
+
+@scenario("All namespaces in same namespace can be retrieved at once")
+def test_retrieve_all_namespaces_from_namespace():
+    pass
+
+
+@given("a parent namespace with no namespaces in it", target_fixture="parent_namespace")
+def a_parent_namespace(namespace_factory):
+    return namespace_factory()
+
+
+@given("a second namespace, in the parent namespace", target_fixture="namespace1")
+def a_namespace_in_a_namespace(namespace_factory, parent_namespace):
+    return namespace_factory(namespace=parent_namespace)
+
+
+@given("a third namespace, in the parent namespace", target_fixture="namespace2")
+def an_other_namespace_in_a_namespace(namespace_factory, parent_namespace):
+    return namespace_factory(namespace=parent_namespace)
+
+
+@when("the second namespace is added to the namespace storage")
+def add_namespace_in_namespace(namespace1, namespace_storage):
+    namespace_storage.add(namespace1)
+
+
+@when("the third namespace is added to the namespace storage")
+def add_other_namespace_in_namespace(namespace2, namespace_storage):
+    namespace_storage.add(namespace2)
+
+
+@then("I can retrieve the second and the third namespaces at once")
+def retrieve_namespaces_for_namespace(
+    namespace_storage, parent_namespace, namespace1, namespace2
+):
+    namespaces = set(namespace_storage.for_namespace(parent_namespace))
+    assert namespaces == {namespace1, namespace2}
+
+
+@scenario("No namespaces returned from a parent namespace without namespaces")
+def test_retrieve_namespaces_from_empty_namespace():
+    pass
+
+
+@then("I got no namespaces for the parent namespace")
+def retrieve_namespaces_for_empty_namespace(namespace_storage, parent_namespace):
+    namespaces = set(namespace_storage.for_namespace(parent_namespace))
+    assert namespaces == set()
+
+
+@scenario(
+    "A namespace cannot be added if another exists with same name in same parent namespace"
+)
+def test_name_and_namespace_uniqueness_at_create_time():
+    pass
+
+
+@given(
+    "a second namespace with same name in the same parent namespace",
+    target_fixture="namespace1",
+)
+def an_other_namespace_with_same_name_and_namespace(namespace_factory, namespace):
+    return namespace_factory(name=namespace.name, namespace=namespace.namespace)
+
+
+@then("I cannot add the second one")
+def namespace_cannot_be_added(namespace_storage, namespace1):
+    with pytest.raises(namespace_storage.UniquenessError):
+        namespace_storage.add(namespace1)
+
+
+@scenario(
+    "A namespace cannot be added if another exists with same name both without parent namespace"
+)
+def test_name_and_no_namespace_uniqueness_at_create_time():
+    pass
+
+
+@given(
+    "a second namespace with same name and without parent namespace",
+    target_fixture="namespace1",
+)
+def an_other_namespace_with_same_name_and_namespace(namespace_factory, namespace):
+    return namespace_factory(name=namespace.name, namespace=None)
+
+
+@scenario(
+    "A namespace cannot be updated if another exists with same new name in same parent namespace"
+)
+def test_namespace_cannot_be_updated_if_same_new_name_in_same_namespace():
+    pass
+
+
+@given("a second namespace in the same parent namespace", target_fixture="namespace1")
+def an_other_namespace_with_same_namespace(namespace_factory, namespace):
+    return namespace_factory(namespace=namespace.namespace)
+
+
+@when("the second namespace name is set as for the first one")
+def update_other_namespace_name_as_first_one(namespace, namespace1):
+    namespace1.name = namespace.name
+
+
+@then("I cannot update the second one")
+def other_namespace_cannot_be_updated(namespace_storage, namespace1):
+    with pytest.raises(namespace_storage.UniquenessError):
+        namespace_storage.update(namespace1)
+
+
+@scenario(
+    "A namespace cannot be updated if another exists with same new name both without parent namespace"
+)
+def test_namespace_cannot_be_updated_if_same_new_name_no_namespace():
+    pass
+
+
+@given("a second namespace without parent namespace", target_fixture="namespace1")
+def an_other_namespace_without_namespace(namespace_factory):
+    return namespace_factory(namespace=None)
+
+
+@scenario(
+    "A namespace cannot be updated if another exists with same name in new same parent namespace"
+)
+def test_namespace_cannot_be_updated_if_same_name_in_new_same_namespace():
+    pass
+
+
+@given("a second namespace with the same name", target_fixture="namespace1")
+def an_other_namespace_with_same_name(namespace_factory, namespace):
+    return namespace_factory(name=namespace.name)
+
+
+@when("the second namespace parent namespace is set as for the first one")
+def update_other_namespace_namespace_as_first_one(namespace, namespace1):
+    namespace1.namespace = namespace.namespace
+
+
+@scenario(
+    "A namespace cannot be updated if another exists with same name now both without namespace"
+)
+def test_namespace_cannot_be_updated_if_same_name_without_namespace():
+    pass
+
+
+@given(
+    "a second namespace with the same name and a parent namespace",
+    target_fixture="namespace1",
+)
+def an_other_namespace_with_same_name_and_a_namespace(namespace_factory, namespace):
+    return namespace_factory(name=namespace.name, namespace=namespace_factory())
+
+
+@when("the second namespace parent namespace is cleared")
+def clear_other_namespace_namspesace(namespace1):
+    namespace1.namespace = None
+
+
+@scenario("A namespace can be moved from one parent namespace to another")
+def test_move_namespace_from_namespace():
+    pass
+
+
+@given(
+    "a second parent namespace with no namespaces in it",
+    target_fixture="parent_namespace1",
+)
+def another_parent_namespace(namespace_factory):
+    return namespace_factory()
+
+
+@when("the namespace is set in the parent namespace")
+@when("the namespace is set in the first parent namespace")
+def set_namespace_namespace(namespace_storage, namespace, parent_namespace):
+    namespace.namespace = parent_namespace
+    namespace_storage.update(namespace)
+
+
+@when("I change its namespace")
+def update_namespace(namespace_storage, namespace, parent_namespace1):
+    namespace.namespace = parent_namespace1
+    namespace_storage.update(namespace)
+
+
+@then("the namespace is no longer available in the original parent namespace")
+def namespace_not_in_namespace(namespace_storage, namespace, parent_namespace):
+    assert namespace not in namespace_storage.for_namespace(parent_namespace)
+
+
+@then("the namespace is available in the new parent namespace")
+def namespace_not_in_namespace(namespace_storage, namespace, parent_namespace1):
+    assert namespace in namespace_storage.for_namespace(parent_namespace1)
+
+
+@scenario("A namespace without parent namespace can be moved to one")
+def test_move_namespace_to_namespace():
+    pass
+
+
+@then("the namespace is no longer available when fetching namespaces without parents")
+def namespace_not_in_namespace(namespace_storage, namespace):
+    assert namespace not in namespace_storage.for_namespace(None)
+
+
+@then("the namespace is available in the parent namespace")
+def namespace_not_in_namespace(namespace_storage, namespace, parent_namespace):
+    assert namespace in namespace_storage.for_namespace(parent_namespace)
+
+
+@scenario("A namespace with a parent namespace can have its parent namespace cleared")
+def test_clear_parent_namespace():
+    pass
+
+
+@when("the namespace parent namespace is cleared")
+def clear_other_namespace_namspesace(namespace):
+    namespace.namespace = None
+
+
+@then("the namespace is no longer available in the parent namespace")
+def namespace_not_in_namespace(namespace_storage, namespace, parent_namespace):
+    assert namespace not in namespace_storage.for_namespace(parent_namespace)
+
+
+@then("the namespace is available when fetching namespaces without parents")
+def namespace_not_in_namespace(namespace_storage, namespace):
+    assert namespace in namespace_storage.for_namespace(None)
+
+
+# To make pytest-bdd fail if some scenarios are not all implemented. KEEP AT THE END
+scenarios(FEATURE_FILE)

isshub/domain/contexts/code_repository/repositories/repository/__init__.py

Type

Added

Stats

+75 -0

@@ -0,0 +1,75 @@
+"""Package defining the repository for the :obj:`.Repository` entity."""
+
+import abc
+from typing import Iterable
+
+from .....utils.repository import AbstractInMemoryRepository, AbstractRepository
+from ...entities import Namespace, Repository
+
+
+class AbstractRepositoryRepository(
+    AbstractRepository[Repository], entity_class=Repository
+):
+    """Base repository for the :obj:`.Repository` entity."""
+
+    @abc.abstractmethod
+    def for_namespace(self, namespace: Namespace) -> Iterable[Repository]:
+        """Iterate on repositories found in the given `namespace`.
+
+        Parameters
+        ----------
+        namespace : Namespace
+            The namespace for which we want to find the repositories
+
+        Returns
+        -------
+        Iterable[Repository]
+            An iterable of the repositories found in the `namespace`
+
+        """
+
+
+class InMemoryRepositoryRepository(
+    AbstractInMemoryRepository, AbstractRepositoryRepository
+):
+    """Repository to handle :obj:`.Repository` entities in memory."""
+
+    def add(self, entity: Repository) -> Repository:
+        """Add the given Repository `entity` in the repository.
+
+        For the parameters, see :obj:`AbstractRepository.add`.
+
+        Returns
+        -------
+        Repository
+            The added Repository
+
+        Raises
+        ------
+        self.UniquenessError
+            - If a repository with the same identifier as the given one already exists.
+            - If a repository with the same name and namespace as the given one already exists.
+
+        """
+        if any(
+            repository
+            for repository in self.for_namespace(entity.namespace)
+            if repository.name == entity.name
+        ):
+            raise self.UniquenessError(
+                f"One already exists with name={entity.name} and namespace={entity.namespace}"
+            )
+        return super().add(entity)
+
+    def for_namespace(self, namespace: Namespace) -> Iterable[Repository]:
+        """Iterate on repositories found in the given `namespace`.
+
+        For the parameters, see :obj:`AbstractRepositoryRepository.for_namespace`
+
+        Returns
+        -------
+        Iterable[Repository]
+            An iterable of the repositories found in the `namespace`
+
+        """
+        return (entity for entity in self._collection if entity.namespace == namespace)

isshub/domain/contexts/code_repository/repositories/repository/features/storage.feature

Type

Added

Stats

+80 -0

@@ -0,0 +1,80 @@
+Feature: Storing repositories
+
+    Background: Given a repository and a repository storage
+        Given a repository
+        And a repository storage
+
+    Scenario: A new repository can be saved and retrieved
+        When the repository is added to the repository storage
+        Then I can retrieve it
+
+    Scenario: A new repository cannot be saved if invalid
+        When the repository has some invalid content
+        Then I cannot add it because it's invalid
+
+    Scenario: An existing repository cannot be added
+        When the repository is added to the repository storage
+        Then it's not possible to add it again
+
+    Scenario: An existing repository can be updated
+        When the repository is added to the repository storage
+        And it is updated
+        Then I can retrieve its updated version
+
+    Scenario: An existing repository cannot be saved if invalid
+        When the repository has some invalid content
+        Then I cannot update it because it's invalid
+
+    Scenario: A non existing repository cannot be updated
+        When the repository is not added to the repository storage
+        Then I cannot update it because it does not exist
+
+    Scenario: An existing repository can be deleted
+        When the repository is added to the repository storage
+        And it is deleted
+        Then I cannot retrieve it
+
+    Scenario: An non existing repository cannot be deleted
+        When the repository is not added to the repository storage
+        Then I cannot delete it
+
+    Scenario: All repositories in same namespace can be retrieved at once
+        Given a namespace with no repositories in it
+        And a second repository, in the namespace
+        And a third repository, in the namespace
+        When the repository is added to the repository storage
+        And the second repository is added to the repository storage
+        And the third repository is added to the repository storage
+        Then I can retrieve the second and the third repositories at once
+
+    Scenario: No repositories returned from a namespace without repositories
+        Given a namespace with no repositories in it
+        Then I got no repositories for the namespace
+
+    Scenario: A repository cannot be added if another exists with same name in same namespace
+        Given a second repository with same name in the same namespace
+        When the repository is added to the repository storage
+        Then I cannot add the second one
+
+    Scenario: A repository cannot be updated if another exists with same new name in same namespace
+        Given a second repository in the same namespace
+        When the repository is added to the repository storage
+        And the second repository is added to the repository storage
+        And the second repository name is set as for the first one
+        Then I cannot update the second one
+
+    Scenario: A repository cannot be updated if another exists with same name in new same namespace
+        Given a second repository with the same name
+        When the repository is added to the repository storage
+        And the second repository is added to the repository storage
+        And the second repository namespace is set as for the first one
+        Then I cannot update the second one
+
+    Scenario: A repository can be moved from one namespace to another
+        Given a namespace with no repositories in it
+        And a second namespace with no repositories in it
+        When the repository is added to the repository storage
+        And the repository is set in the first namespace
+        And I change its namespace
+        Then the repository is no longer available in the original namespace
+        And the repository is available in the new namespace

isshub/domain/contexts/code_repository/repositories/repository/tests/__init__.py

Type

Added

Stats

+1 -0

@@ -0,0 +1 @@
+"""Package holding the tests for the ``Repository`` code_repository repository."""

isshub/domain/contexts/code_repository/repositories/repository/tests/test_storage.py

Type

Added

Stats

+290 -0

@@ -0,0 +1,290 @@
+"""Module holding BDD tests for isshub repository for code_repository Repository entity as defined in ``storage.feature``."""
+from functools import partial
+
+import pytest
+from pytest_bdd import given, scenario, scenarios, then, when
+
+from isshub.domain.contexts.code_repository.repositories.repository import (
+    InMemoryRepositoryRepository,
+)
+
+from ....entities.namespace.tests.fixtures import namespace_factory
+from ....entities.repository.tests.fixtures import repository_factory
+
+
+FEATURE_FILE = "../features/storage.feature"
+scenario = partial(scenario, FEATURE_FILE)
+
+
+@scenario("A new repository can be saved and retrieved")
+def test_add_new_repository():
+    pass
+
+
+@given("a repository", target_fixture="repository")
+def a_repository(repository_factory):
+    return repository_factory()
+
+
+@given("a repository storage", target_fixture="repository_storage")
+def a_repository_storage():
+    return InMemoryRepositoryRepository()
+
+
+@when("the repository is added to the repository storage")
+def add_repository(repository, repository_storage):
+    repository_storage.add(repository)
+
+
+@then("I can retrieve it")
+def retrieve_new_from_repository(repository, repository_storage):
+    assert repository_storage.exists(identifier=repository.identifier)
+    from_repository = repository_storage.get(identifier=repository.identifier)
+    assert from_repository == repository
+
+
+@scenario("A new repository cannot be saved if invalid")
+def test_add_invalid_repository():
+    pass
+
+
+@when("the repository has some invalid content")
+def update_repository_with_invalid_content(repository):
+    repository.name = None
+
+
+@then("I cannot add it because it's invalid")
+def cannot_add_invalid_repository(repository, repository_storage):
+    with pytest.raises(TypeError):
+        repository_storage.add(repository)
+
+
+@scenario("An existing repository cannot be added")
+def test_add_existing_repository():
+    pass
+
+
+@then("it's not possible to add it again")
+def cannot_add_existing_repository(repository, repository_storage):
+    with pytest.raises(repository_storage.UniquenessError):
+        repository_storage.add(repository)
+
+
+@scenario("An existing repository can be updated")
+def test_update_existing_repository():
+    pass
+
+
+@when("it is updated")
+def repository_is_updated(repository, repository_storage):
+    repository.name = "new name"
+    repository_storage.update(repository)
+
+
+@then("I can retrieve its updated version")
+def retrieve_updated_from_repository(repository, repository_storage):
+    from_repository = repository_storage.get(identifier=repository.identifier)
+    assert from_repository.name == "new name"
+
+
+@scenario("An existing repository cannot be saved if invalid")
+def test_update_invalid_repository():
+    pass
+
+
+@then("I cannot update it because it's invalid")
+def cannot_update_invalid_repository(repository, repository_storage):
+    with pytest.raises(TypeError):
+        repository_storage.update(repository)
+
+
+@scenario("A non existing repository cannot be updated")
+def test_update_non_existing_repository():
+    pass
+
+
+@when("the repository is not added to the repository storage")
+def add_repository(repository, repository_storage):
+    pass
+
+
+@then("I cannot update it because it does not exist")
+def cannot_update_non_existing_repository(repository, repository_storage):
+    repository.name = "new name"
+    with pytest.raises(repository_storage.NotFoundError):
+        repository_storage.update(repository)
+
+
+@scenario("An existing repository can be deleted")
+def test_delete_repository():
+    pass
+
+
+@when("it is deleted")
+def repository_is_deleted(repository, repository_storage):
+    repository_storage.delete(repository)
+
+
+@then("I cannot retrieve it")
+def cannot_retrieve_deleted_repository(repository, repository_storage):
+    with pytest.raises(repository_storage.NotFoundError):
+        repository_storage.get(identifier=repository.identifier)
+
+
+@scenario("An non existing repository cannot be deleted")
+def test_delete_non_existing_repository():
+    pass
+
+
+@then("I cannot delete it")
+def cannot_delete_non_existing_repository(repository, repository_storage):
+    with pytest.raises(repository_storage.NotFoundError):
+        repository_storage.delete(repository)
+
+
+@scenario("All repositories in same namespace can be retrieved at once")
+def test_retrieve_all_repositories_from_namespace():
+    pass
+
+
+@given("a namespace with no repositories in it", target_fixture="namespace")
+def a_namespace(namespace_factory):
+    return namespace_factory()
+
+
+@given("a second repository, in the namespace", target_fixture="repository1")
+def a_repository_in_a_namespace(repository_factory, namespace):
+    return repository_factory(namespace=namespace)
+
+
+@given("a third repository, in the namespace", target_fixture="repository2")
+def an_other_repository_in_a_namespace(repository_factory, namespace):
+    return repository_factory(namespace=namespace)
+
+
+@when("the second repository is added to the repository storage")
+def add_repository_in_namespace(repository1, repository_storage):
+    repository_storage.add(repository1)
+
+
+@when("the third repository is added to the repository storage")
+def add_other_repository_in_namespace(repository2, repository_storage):
+    repository_storage.add(repository2)
+
+
+@then("I can retrieve the second and the third repositories at once")
+def retrieve_repositories_for_namespace(
+    repository_storage, namespace, repository1, repository2
+):
+    repositories = set(repository_storage.for_namespace(namespace))
+    assert repositories == {repository1, repository2}
+
+
+@scenario("No repositories returned from a namespace without repositories")
+def test_retrieve_repositories_from_empty_namespace():
+    pass
+
+
+@then("I got no repositories for the namespace")
+def retrieve_repositories_for_empty_namespace(repository_storage, namespace):
+    repositories = set(repository_storage.for_namespace(namespace))
+    assert repositories == set()
+
+
+@scenario(
+    "A repository cannot be added if another exists with same name in same namespace"
+)
+def test_name_and_namespace_uniqueness_at_create_time():
+    pass
+
+
+@given(
+    "a second repository with same name in the same namespace",
+    target_fixture="repository1",
+)
+def an_other_repository_with_same_name_and_namespace(repository_factory, repository):
+    return repository_factory(name=repository.name, namespace=repository.namespace)
+
+
+@then("I cannot add the second one")
+def repository_cannot_be_added_if_same_name_and_namespace(
+    repository_storage, repository1
+):
+    with pytest.raises(repository_storage.UniquenessError):
+        repository_storage.add(repository1)
+
+
+@scenario(
+    "A repository cannot be updated if another exists with same new name in same namespace"
+)
+def test_repository_cannot_be_updated_if_same_new_name_in_same_namespace():
+    pass
+
+
+@given("a second repository in the same namespace", target_fixture="repository1")
+def an_other_repository_with_same_namespace(repository_factory, repository):
+    return repository_factory(namespace=repository.namespace)
+
+
+@when("the second repository name is set as for the first one")
+def update_other_repository_name_as_first_one(repository, repository1):
+    repository1.name = repository.name
+
+
+@then("I cannot update the second one")
+def other_repository_cannot_be_updated(repository_storage, repository1):
+    with pytest.raises(repository_storage.UniquenessError):
+        repository_storage.update(repository1)
+
+
+@scenario(
+    "A repository cannot be updated if another exists with same name in new same namespace"
+)
+def test_repository_cannot_be_updated_if_same_name_in_new_same_namespace():
+    pass
+
+
+@given("a second repository with the same name", target_fixture="repository1")
+def an_other_repository_with_same_name(repository_factory, repository):
+    return repository_factory(name=repository.name)
+
+
+@when("the second repository namespace is set as for the first one")
+def update_other_repository_namespace_as_first_one(repository, repository1):
+    repository1.namespace = repository.namespace
+
+
+@scenario("A repository can be moved from one namespace to another")
+def test_move_repository_from_namespace():
+    pass
+
+
+@given("a second namespace with no repositories in it", target_fixture="namespace1")
+def another_namespace(namespace_factory):
+    return namespace_factory()
+
+
+@when("the repository is set in the first namespace")
+def set_repository_namespace(repository_storage, repository, namespace):
+    repository.namespace = namespace
+    repository_storage.update(repository)
+
+
+@when("I change its namespace")
+def update_namespace(repository_storage, repository, namespace1):
+    repository.namespace = namespace1
+    repository_storage.update(repository)
+
+
+@then("the repository is no longer available in the original namespace")
+def repository_not_in_namespace(repository_storage, repository, namespace):
+    assert repository not in repository_storage.for_namespace(namespace)
+
+
+@then("the repository is available in the new namespace")
+def repository_not_in_namespace(repository_storage, repository, namespace1):
+    assert repository in repository_storage.for_namespace(namespace1)
+
+
+# To make pytest-bdd fail if some scenarios are not all implemented. KEEP AT THE END
+scenarios(FEATURE_FILE)

isshub/domain/utils/entity.py

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

isshub/domain/utils/repository.py

Type

Added

Stats

+329 -0

@@ -0,0 +1,329 @@
+"""Package defining bases for domain repositories."""
+
+import abc
+from inspect import isabstract
+from typing import Any, Generic, Optional, Set, Type, TypeVar
+from uuid import UUID
+
+from isshub.domain.utils.entity import BaseEntityWithIdentifier
+
+
+Entity = TypeVar("Entity", bound=BaseEntityWithIdentifier)
+
+
+class RepositoryException(Exception):
+    """Exception raised in a repository context.
+
+    Attributes
+    ----------
+    repository: Optional[Type[AbstractRepository]]
+        An optional repository attached to the exception or the exception class.
+
+    """
+
+    repository: Optional[Type["AbstractRepository"]] = None
+
+    def __init__(
+        self,
+        message: str,
+        repository: Optional[Type["AbstractRepository"]] = None,
+    ) -> None:
+        """Create the exception with a repository and formatted message.
+
+        Parameters
+        ----------
+        message : str
+            The message of the exception. Will be prefixed by the name of the repository and its
+            entity class.
+        repository : Optional[Type[AbstractRepository]]
+            The repository (class) having raised the exception. To get the related entity class, use
+            ``the_exception.repository.entity_class``.
+        """
+        if repository:
+            self.repository = repository
+        if self.repository is not None:
+            entity_name = ""
+            if self.repository.entity_class is not None:
+                entity_name = f"[{self.repository.entity_class.__name__}]"
+            message = f"{self.repository.__name__}{entity_name}: {message}"
+        super().__init__(message)
+
+
+class UniquenessError(RepositoryException):
+    """Exception raised when an entity is added/updated that already exists."""
+
+
+class NotFoundError(RepositoryException):
+    """Exception raised when an entity couldn't be found in its repository."""
+
+
+class AbstractRepository(abc.ABC, Generic[Entity]):
+    """Base of all repositories.
+
+    Attributes
+    ----------
+    entity_class : Optional[Type[Entity]]
+        The entity class the repository is designed for. Passed as a named argument while defining
+        the class.
+    NotFoundError : Type["NotFoundError"]
+        Local version of the :obj:`NotFoundError` exception, bound to the current repository.
+    UniquenessError : Type["UniquenessError"]
+        Local version of the :obj:`UniquenessError` exception, bound to the current repository.
+
+    """
+
+    entity_class: Optional[Type[Entity]] = None
+    NotFoundError: Type[NotFoundError]
+    UniquenessError: Type[UniquenessError]
+
+    def __init_subclass__(
+        cls,
+        abstract: bool = False,
+        entity_class: Optional[Type[Entity]] = None,
+        **kwargs: Any,
+    ) -> None:
+        """Initialize the subclass for the given `entity_class`, if the subclass is not abstract.
+
+        Parameters
+        ----------
+        abstract : bool
+            If ``False``, the default, the `entity_class` is mandatory, else it's ignored.
+        entity_class : Optional[Type[Entity]]
+            The entity class the repository is designed for.
+        kwargs : Any
+            Other arguments passed to ``super().__init_subclass__``
+
+        Raises
+        ------
+        TypeError
+            If the class is a concrete subclass and `entity_class` is not given or not present on
+            one of its parent classes.
+
+        """
+        super().__init_subclass__(**kwargs)  # type: ignore
+        if entity_class is None:
+            for klass in cls.mro():
+                if issubclass(klass, AbstractRepository) and klass.entity_class:
+                    entity_class = klass.entity_class
+                    break
+        if entity_class is None:
+            if not (isabstract(cls) or abstract):
+                raise TypeError(
+                    f"`entity_class` is required for non abstract repository {cls}"
+                )
+        else:
+            cls.entity_class = entity_class
+        cls.NotFoundError = type("NotFoundError", (NotFoundError,), {"repository": cls})
+        cls.UniquenessError = type(
+            "UniquenessError", (UniquenessError,), {"repository": cls}
+        )
+
+    def exists(self, identifier: UUID) -> bool:
+        """Tell if an entity with the given identifier exists in the repository.
+
+        Parameters
+        ----------
+        identifier : UUID
+            The UUID to check for in the repository
+
+        Returns
+        -------
+        bool
+            ``True`` if an entity with the given UUID exists. ``False`` otherwise.
+
+        """
+
+    @abc.abstractmethod
+    def add(self, entity: Entity) -> Entity:
+        """Add the given `entity` in the repository.
+
+        Parameters
+        ----------
+        entity : Entity
+            The entity to add to the repository
+
+        Returns
+        -------
+        Entity
+            The added entity
+
+        """
+
+    @abc.abstractmethod
+    def get(self, identifier: UUID) -> Entity:
+        """Get an entity by its `identifier`.
+
+        Parameters
+        ----------
+        identifier : UUID
+            The identifier of the wanted entity
+
+        Returns
+        -------
+        Entity
+            The wanted entity
+
+        Raises
+        ------
+        self.NotFoundError
+            If no entity was found with the given `identifier`
+
+        """
+
+    @abc.abstractmethod
+    def update(self, entity: Entity) -> Entity:
+        """Update the given entity in the repository.
+
+        Parameters
+        ----------
+        entity : Entity
+            The entity to updated in the repository. It must already exist.
+
+        Returns
+        -------
+        Entity
+            The updated entity
+
+        Raises
+        ------
+        self.NotFoundError
+            If no entity was found matching the given one
+
+        """
+
+    @abc.abstractmethod
+    def delete(self, entity: Entity) -> None:
+        """Delete the given `entity` from the repository.
+
+        For the parameters, see :obj:`AbstractRepository.delete`.
+
+        Raises
+        ------
+        self.NotFoundError
+            If no entity was found matching the given one
+
+        """
+
+
+class AbstractInMemoryRepository(AbstractRepository[Entity], abstract=True):
+    """Repository to handle entities in memory.
+
+    Notes
+    -----
+    The class is created with ``abstract=True`` because as all methods from the :obj:`AbstractRepository`
+    are defined, it is not viewed as abstract by ``inspect.isabstract``.
+
+    """
+
+    def __init__(self) -> None:
+        """Initialize the repository with an empty collection (a set)."""
+        self._collection: Set[Entity] = set()
+        super().__init__()
+
+    def exists(self, identifier: UUID) -> bool:
+        """Tell if an entity with the given identifier exists in the repository.
+
+        For the parameters, see :obj:`AbstractRepository.exists`.
+
+        Returns
+        -------
+        bool
+            ``True`` if an entity with the given UUID exists. ``False`` otherwise.
+
+        """
+        return any(
+            entity for entity in self._collection if entity.identifier == identifier
+        )
+
+    def add(self, entity: Entity) -> Entity:
+        """Add the given `entity` in the repository.
+
+        For the parameters, see :obj:`AbstractRepository.add`.
+
+        Notes
+        -----
+        The `entity` will be validated before being saved.
+
+        Returns
+        -------
+        Entity
+            The added entity
+
+        Raises
+        ------
+        self.UniquenessError
+            If an entity with the same identifier as the given one already exists.
+
+        """
+        entity.validate()
+
+        if self.exists(entity.identifier):
+            raise self.UniquenessError(
+                f"One already exists with identifier={entity.identifier}"
+            )
+
+        self._collection.add(entity)
+        return entity
+
+    def get(self, identifier: UUID) -> Entity:
+        """Get an entity by its `identifier`.
+
+        For the parameters, see :obj:`AbstractRepository.get`.
+
+        Returns
+        -------
+        Entity
+            The wanted entity
+
+        Raises
+        ------
+        self.NotFoundError
+            If no entity was found with the given `identifier`
+
+        """
+        try:
+            return [
+                entity for entity in self._collection if entity.identifier == identifier
+            ][0]
+        except IndexError as exception:
+            raise self.NotFoundError(
+                f"Unable to find one with identifier={identifier}"
+            ) from exception
+
+    def update(self, entity: Entity) -> Entity:
+        """Update the given entity in the repository.
+
+        For the parameters, see :obj:`AbstractRepository.update`.
+
+        Notes
+        -----
+        The `entity` will be validated before being saved.
+
+        Returns
+        -------
+        Entity
+            The updated entity
+
+        Raises
+        ------
+        self.NotFoundError
+            If no entity was found matching the given one
+
+        """
+        entity.validate()
+        self.delete(entity)
+        return self.add(entity)
+
+    def delete(self, entity: Entity) -> None:
+        """Delete the given `entity` from the repository.
+
+        For the parameters, see :obj:`AbstractRepository.delete`.
+
+        Raises
+        ------
+        self.NotFoundError
+            If no entity was found matching the given one
+
+        """
+        entity = self.get(entity.identifier)
+        self._collection.remove(entity)