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¶
isshub/domain/contexts/code_repository/entities/namespace/__init__.py
isshub/domain/contexts/code_repository/entities/namespace/features/describe.feature
isshub/domain/contexts/code_repository/entities/namespace/tests/test_describe.py
isshub/domain/contexts/code_repository/entities/repository/__init__.py
isshub/domain/contexts/code_repository/entities/repository/features/describe.feature
isshub/domain/contexts/code_repository/entities/repository/tests/test_describe.py
isshub/domain/contexts/code_repository/repositories/__init__.py
isshub/domain/contexts/code_repository/repositories/namespace/__init__.py
isshub/domain/contexts/code_repository/repositories/namespace/features/storage.feature
isshub/domain/contexts/code_repository/repositories/namespace/tests/__init__.py
isshub/domain/contexts/code_repository/repositories/namespace/tests/test_storage.py
isshub/domain/contexts/code_repository/repositories/repository/__init__.py
isshub/domain/contexts/code_repository/repositories/repository/features/storage.feature
isshub/domain/contexts/code_repository/repositories/repository/tests/__init__.py
isshub/domain/contexts/code_repository/repositories/repository/tests/test_storage.py
.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)