From 76638c3d09c47d58febbc9e2e2cc80e84c98ac33 Mon Sep 17 00:00:00 2001 From: "Stephane Angel (Twidi)" Date: Sun, 4 Oct 2020 20:07:05 +0200 Subject: [PATCH 1/5] style(mypy): Remove most "type: ignore" pragmas Abstract ======== Be more precise about typing to be able to remove many or our pragmas telling mypy to ignore some parts of the code, especially in the `isshub.domain.utils.entity` module. Motivation ========== Simply calling `Repository(name=name, namespace=namespace)` made mypy fail. So it was necessary to let mypy understand our layer above the `attr` package. Also, having all these `# type: ignore` pragmas was not really what we wanted, it was temporary until having time to fix it. Rationale ========= - Add a mypy plugin to let mypy know about our own functions to define `attr` classes and fields - Add typing in the `isshub.domain.utils.entity` to be able to remove all our `# type: ignore` pragmas when defining classes and fields During this last step, we had to change the `field_validator` decorator from a function to a class because of an error from mypy that kept telling us that the functions decorated by this decorated were made untyped because the decorator was untyped... but it was not. --- .../entities/namespace/__init__.py | 14 +-- .../entities/repository/__init__.py | 8 +- isshub/domain/utils/entity.py | 107 ++++++++++++++---- isshub/domain/utils/mypy_plugin.py | 45 ++++++++ setup.cfg | 4 +- 5 files changed, 143 insertions(+), 35 deletions(-) create mode 100644 isshub/domain/utils/mypy_plugin.py diff --git a/isshub/domain/contexts/code_repository/entities/namespace/__init__.py b/isshub/domain/contexts/code_repository/entities/namespace/__init__.py index 07413db..ac26cbe 100644 --- a/isshub/domain/contexts/code_repository/entities/namespace/__init__.py +++ b/isshub/domain/contexts/code_repository/entities/namespace/__init__.py @@ -20,7 +20,7 @@ class NamespaceKind(enum.Enum): GROUP = "Group" -@validated() # type: ignore +@validated() class Namespace(BaseModelWithId): """A namespace can contain namespaces and repositories. @@ -39,16 +39,14 @@ class Namespace(BaseModelWithId): """ - name: str = required_field(str) # type: ignore - kind: NamespaceKind = required_field( # type: ignore - NamespaceKind, relation_verbose_name="is a" - ) - namespace: Optional["Namespace"] = optional_field( # type: ignore + name: str = required_field(str) + kind: NamespaceKind = required_field(NamespaceKind, relation_verbose_name="is a") + namespace: Optional["Namespace"] = optional_field( "self", relation_verbose_name="may belongs to" ) - description: Optional[str] = optional_field(str) # type: ignore + description: Optional[str] = optional_field(str) - @field_validator(namespace) # type: ignore + @field_validator(namespace) def validate_namespace_is_not_in_a_loop( # noqa # pylint: disable=unused-argument self, field: Any, value: Any ) -> None: diff --git a/isshub/domain/contexts/code_repository/entities/repository/__init__.py b/isshub/domain/contexts/code_repository/entities/repository/__init__.py index 5100f62..d16070a 100644 --- a/isshub/domain/contexts/code_repository/entities/repository/__init__.py +++ b/isshub/domain/contexts/code_repository/entities/repository/__init__.py @@ -4,7 +4,7 @@ from isshub.domain.utils.entity import BaseModelWithId, required_field, validated -@validated() # type: ignore +@validated() class Repository(BaseModelWithId): """A repository holds code, issues, code requests... @@ -19,7 +19,5 @@ class Repository(BaseModelWithId): """ - name: str = required_field(str) # type: ignore - namespace: Namespace = required_field( # type: ignore - Namespace, relation_verbose_name="belongs to" - ) + name: str = required_field(str) + namespace: Namespace = required_field(Namespace, relation_verbose_name="belongs to") diff --git a/isshub/domain/utils/entity.py b/isshub/domain/utils/entity.py index a130aeb..c45ca15 100644 --- a/isshub/domain/utils/entity.py +++ b/isshub/domain/utils/entity.py @@ -3,18 +3,37 @@ It is an adapter over the ``attrs`` external dependency. """ - -# type: ignore +from typing import ( + TYPE_CHECKING, + Any, + Callable, + Generic, + Optional, + Type, + TypeVar, + Union, + cast, +) import attr +_T = TypeVar("_T") + +if TYPE_CHECKING: + from attr.__init__ import Attribute # isort:skip +else: + + class Attribute(Generic[_T]): + """Class for typing when not using mypy, for example when using ``get_type_hints``.""" + + class _InstanceOfSelfValidator( - attr.validators._InstanceOfValidator # pylint: disable=protected-access + attr.validators._InstanceOfValidator # type: ignore # pylint: disable=protected-access ): """Validator checking that the field holds an instance of its own model.""" - def __call__(self, inst, attr, value): # pylint: disable=redefined-outer-name + def __call__(self, inst, attr, value): # type: ignore # pylint: disable=redefined-outer-name """Validate that the `value` is an instance of the class of `inst`. For the parameters, see ``attr.validators._InstanceOfValidator`` @@ -34,7 +53,9 @@ def instance_of_self() -> _InstanceOfSelfValidator: return _InstanceOfSelfValidator(type=None) -def optional_field(field_type, relation_verbose_name=None): +def optional_field( + field_type: Union[Type[_T], str], relation_verbose_name: Optional[str] = None +) -> Optional[_T]: """Define an optional field of the specified `field_type`. Parameters @@ -51,6 +72,11 @@ def optional_field(field_type, relation_verbose_name=None): An ``attrs`` attribute, with a default value set to ``None``, and a validator checking that this field is optional and, if set, of the correct type. + Raises + ------ + AssertionError + If `field_type` is a string and this string is not "self" + Examples -------- >>> from isshub.domain.utils.entity import optional_field, validated, BaseModel @@ -67,18 +93,24 @@ def optional_field(field_type, relation_verbose_name=None): if relation_verbose_name: metadata["relation_verbose_name"] = relation_verbose_name + assert not isinstance(field_type, str) or field_type == "self" + return attr.ib( default=None, validator=attr.validators.optional( instance_of_self() - if field_type == "self" + if isinstance(field_type, str) else attr.validators.instance_of(field_type) ), metadata=metadata, ) -def required_field(field_type, frozen=False, relation_verbose_name=None): +def required_field( + field_type: Union[Type[_T], str], + frozen: bool = False, + relation_verbose_name: Optional[str] = None, +) -> _T: """Define a required field of the specified `field_type`. Parameters @@ -98,6 +130,11 @@ def required_field(field_type, frozen=False, relation_verbose_name=None): Any An ``attrs`` attribute, and a validator checking that this field is of the correct type. + Raises + ------ + AssertionError + If `field_type` is a string and this string is not "self" + Examples -------- >>> from isshub.domain.utils.entity import required_field, validated, BaseModel @@ -114,19 +151,21 @@ def required_field(field_type, frozen=False, relation_verbose_name=None): if relation_verbose_name: metadata["relation_verbose_name"] = relation_verbose_name + assert not isinstance(field_type, str) or field_type == "self" + kwargs = { "validator": instance_of_self() - if field_type == "self" + if isinstance(field_type, str) else attr.validators.instance_of(field_type), "metadata": metadata, } if frozen: kwargs["on_setattr"] = attr.setters.frozen - return attr.ib(**kwargs) + return attr.ib(**kwargs) # type: ignore -def validated(): +def validated() -> Any: """Decorate an entity to handle validation. This will let ``attrs`` manage the class, using slots for fields, and forcing attributes to @@ -167,19 +206,24 @@ def validated(): return attr.s(slots=True, kw_only=True) -def field_validator(field): +TValidateMethod = TypeVar( + "TValidateMethod", bound=Callable[[Any, "Attribute[_T]", _T], None] +) + + +class field_validator: # pylint: disable=invalid-name """Decorate an entity method to make it a validator of the given `field`. + Notes + ----- + It's easier to implement as a function but we couldn't make mypy work with it. + Thanks to https://github.com/python/mypy/issues/1551#issuecomment-253978622 + Parameters ---------- field : Any The field to validate. - Returns - ------- - Callable - The decorated method. - Examples -------- >>> from isshub.domain.utils.entity import field_validator, required_field, BaseModel @@ -211,10 +255,29 @@ def field_validator(field): 'foo' """ - return field.validator + + def __init__(self, field: "Attribute[_T]") -> None: + """Save the given field.""" + self.field = field + + def __call__(self, func: TValidateMethod) -> TValidateMethod: + """Decorate the given function. + + Parameters + ---------- + func: Callable + The validation method to decorate + + Returns + ------- + Callable + The decorated method. + + """ + return cast(TValidateMethod, self.field.validator(func)) -def validate_instance(instance): +def validate_instance(instance: Any) -> Any: """Validate a whole instance. Parameters @@ -247,7 +310,9 @@ def validate_instance(instance): attr.validate(instance) -def validate_positive_integer(value, none_allowed, display_name): +def validate_positive_integer( + value: Any, none_allowed: bool, display_name: str +) -> None: """Validate that the given `value` is a positive integer (``None`` accepted if `none_allowed`). Parameters @@ -346,8 +411,8 @@ class BaseModelWithId(BaseModel): @field_validator(id) def validate_id_is_positive_integer( # noqa # pylint: disable=unused-argument - self, field, value - ): + self, field: "Attribute[_T]", value: _T + ) -> None: """Validate that the ``id`` field is a positive integer. Parameters diff --git a/isshub/domain/utils/mypy_plugin.py b/isshub/domain/utils/mypy_plugin.py new file mode 100644 index 0000000..9ea2697 --- /dev/null +++ b/isshub/domain/utils/mypy_plugin.py @@ -0,0 +1,45 @@ +"""Mypy plugin to declare our own functions to create ``attr`` classes and fields. + +Inspired by https://github.com/python/mypy/issues/5406#issuecomment-490547091 + +""" + +# type: ignore + +# pylint: disable=no-name-in-module +from mypy.plugin import Plugin +from mypy.plugins.attrs import attr_attrib_makers, attr_class_makers + + +# pylint: enable=no-name-in-module + + +attr_class_makers.add("isshub.domain.utils.entity.validated") +attr_attrib_makers.add("isshub.domain.utils.entity.optional_field") +attr_attrib_makers.add("isshub.domain.utils.entity.required_field") + + +class MypyPlugin(Plugin): + """Plugin class for mypy. + + Notes + ----- + Our plugin does nothing but it has to exist so this file gets loaded. + """ + + +def plugin(version: str): # pylint: disable=unused-argument + """Define the plugin to use. + + Parameters + ---------- + version : str + The version for which to return a plugin class. Ignored in our case. + + Returns + ------- + Type[Plugin] + The plugin class to be used by mypy + + """ + return MypyPlugin diff --git a/setup.cfg b/setup.cfg index 372ffd9..dfe7a9c 100644 --- a/setup.cfg +++ b/setup.cfg @@ -81,13 +81,15 @@ ignore_missing_imports = true python_version = 3.6 strict_optional = true warn_incomplete_stub = true +plugins = isshub.domain.utils.mypy_plugin [mypy-isshub.*.tests.*] ignore_errors = True -[mypy-isshub.domain.utils.entity] +[mypy-isshub.domain.utils.mypy_plugin] ignore_errors = True + [flake8] ignore = # Line too long: we let black manage it From c2dd0fc606636dd72c1b55d30095bbeb622b788d Mon Sep 17 00:00:00 2001 From: "Stephane Angel (Twidi)" Date: Sun, 4 Oct 2020 21:07:00 +0200 Subject: [PATCH 2/5] style(entity): Change the world "model" by "entity" Abstract ======== Rename `BaseModel` and `BaseModelWithId` to `BaseEntity` and `BaseEntityWithId`, and replace the word "model" by "entity" everywhere, in comments and function names. Motivation ========== To keep things consistent, it's better to have one and only one word to name a thing, and we started with entity, and it's the name used in the DDD word. Rationale ========= N/A --- docs/domain_contexts_diagrams.py | 114 +++++++++--------- .../contexts/code_repository/__init__.py | 2 +- .../entities/namespace/__init__.py | 4 +- .../entities/repository/__init__.py | 4 +- isshub/domain/utils/entity.py | 86 ++++++------- isshub/domain/utils/testing/validation.py | 18 +-- 6 files changed, 115 insertions(+), 113 deletions(-) diff --git a/docs/domain_contexts_diagrams.py b/docs/domain_contexts_diagrams.py index 3f45025..eedbd2f 100755 --- a/docs/domain_contexts_diagrams.py +++ b/docs/domain_contexts_diagrams.py @@ -1,6 +1,6 @@ #!/usr/bin/env python -"""Make the diagrams of models for each isshub domain contexts.""" +"""Make the diagrams of entities for each isshub domain contexts.""" import importlib import os.path @@ -28,7 +28,7 @@ from attr import _Fields # pylint: disable=no-name-in-module from isshub.domain import contexts -from isshub.domain.utils.entity import BaseModel +from isshub.domain.utils.entity import BaseEntity def import_submodules( @@ -157,13 +157,13 @@ def render_enum(enum: Type[Enum]) -> Tuple[str, str]: ) -def validate_model( +def validate_entity( name: str, - model: Type[BaseModel], + entity: Type[BaseEntity], context: str, - linkable_models: Dict[str, Type[BaseModel]], + linkable_entities: Dict[str, Type[BaseEntity]], ) -> Dict[str, Tuple[Any, bool]]: - """Validate that we can handle the given model and its fields. + """Validate that we can handle the given entity and its fields. We only handle fields defined with a "type hint", restricted to: - the ones with a direct type @@ -171,20 +171,20 @@ def validate_model( ``NoneType``) The direct type, if in the ``isshub`` namespace, must be in the given `context` (in the given - `linkable_models`. + `linkable_entities`. Parameters ---------- name : str - The name of the `model` - model : Type[BaseModel] - The model to validate + The name of the `entity` + entity : Type[BaseEntity] + The entity to validate context : str - The name of the context, ie the name of the module containing the `model` and the - `linkable_models` - linkable_models : Dict[str, Type[BaseModel]] - A dict containing all the models the `model` to validate can link to, with their full python - path as keys, and the models themselves as values + The name of the context, ie the name of the module containing the `entity` and the + `linkable_entities` + linkable_entities : Dict[str, Type[BaseEntity]] + A dict containing all the entities the `entity` to validate can link to, with their full python + path as keys, and the entities themselves as values Returns ------- @@ -198,10 +198,10 @@ def validate_model( If the type is a ``Union`` of more than two types or with one not being ``NoneType`` TypeError If the type is an object in the ``isshub`` namespace that is not in the given - `linkable_models` (except for enums, actually) + `linkable_entities` (except for enums, actually) """ - types = get_type_hints(model) + types = get_type_hints(entity) fields = {} for field_name, field_type in types.items(): required = True @@ -223,10 +223,10 @@ def validate_model( if field_type.__module__.startswith("isshub") and not issubclass( field_type, Enum ): - if get_python_path(field_type) not in linkable_models: + if get_python_path(field_type) not in linkable_entities: raise TypeError( f"{name}.{field_name} : {field_type}" - f" - It's not a valid model in context {context}" + f" - It's not a valid entity in context {context}" ) fields[field_name] = (field_type, required) @@ -241,12 +241,12 @@ def render_link( required: bool, attr_fields: "_Fields", ) -> str: - """Render a link between the field of a model to another class. + """Render a link between the field of an entity to another class. Parameters ---------- source_name : str - The dot identifier of the source class. The source class is expected to be a entity model + The dot identifier of the source class. The source class is expected to be an entity class field_name : str The field in the source class that is linked to the dest class dest_name : str @@ -275,49 +275,49 @@ def render_link( return f'{source_name}:{field_name} -> {dest_name}:__class__ [label="{link_label}"]' -def render_model( +def render_entity( name: str, - model: Type[BaseModel], + entity: Type[BaseEntity], context: str, - linkable_models: Dict[str, Type[BaseModel]], + linkable_entities: Dict[str, Type[BaseEntity]], ) -> Tuple[Dict[str, str], Set[str]]: - """Render the given `model` to be incorporated in a dot file, with links. + """Render the given `entity` to be incorporated in a dot file, with links. Parameters ---------- name : str - The name of the `model` - model : Type[BaseModel] - The model to render + The name of the `entity` + entity : Type[BaseEntity] + The entity to render context : str - The name of the context, ie the name of the module containing the `model` and the - `linkable_models` - linkable_models : Dict[str, Type[BaseModel]] - A dict containing all the models the `model` to validate can link to, with their full python - path as keys, and the models themselves as values + The name of the context, ie the name of the module containing the `entity` and the + `linkable_entities` + linkable_entities : Dict[str, Type[BaseEntity]] + A dict containing all the entities the `entity` to validate can link to, with their full python + path as keys, and the entities themselves as values Returns ------- Dict[str, str] - Lines representing the models (or enums) to render in the graph. - The keys are the dot identifier of the model (or enum), and the values are the line to put + Lines representing the entities (or enums) to render in the graph. + The keys are the dot identifier of the entity (or enum), and the values are the line to put in the dot file to render them. - There is at least one entry, the rendered `model`, but there can be more entries, if the - `model` is linked to some enums (we use a dict to let the caller to deduplicate enums with - the same identifiers if called from many models) + There is at least one entry, the rendered `entity`, but there can be more entries, if the + `entity` is linked to some enums (we use a dict to let the caller to deduplicate enums with + the same identifiers if called from many entities) Set[str] - Lines representing the links between the `model` and other models or enums. + Lines representing the links between the `entity` and other entities or enums. """ lines = {} links = set() dot_name = get_dot_identifier(name) - attr_fields = attr.fields(model) + attr_fields = attr.fields(entity) fields = {} - for field_name, (field_type, required) in validate_model( - name, model, context, linkable_models + for field_name, (field_type, required) in validate_entity( + name, entity, context, linkable_entities ).items(): link_to = None @@ -341,50 +341,50 @@ def render_model( ) lines[ dot_name - ] = f'{dot_name} [label="<__class__> Model: {model.__name__}|{fields_parts}"]' + ] = f'{dot_name} [label="<__class__> Entity: {entity.__name__}|{fields_parts}"]' return lines, links def make_domain_context_graph( - context_name: str, subclasses: Dict[str, Type[BaseModel]], output_path: str + context_name: str, subclasses: Dict[str, Type[BaseEntity]], output_path: str ) -> None: - """Make the graph of models in the given contexts. + """Make the graph of entities in the given contexts. Parameters ---------- context_name : str The name of the context, represented by the python path of its module - subclasses : Dict[str, Type[BaseModel]] - All the subclasses of ``BaseModel`` from which to extract the modules to render. + subclasses : Dict[str, Type[BaseEntity]] + All the subclasses of ``BaseEntity`` from which to extract the modules to render. Only subclasses present in the given context will be rendered. output_path : str The path where to save the generated graph """ - # restrict the subclasses of ``BaseModel`` to the ones in the given module name + # restrict the subclasses of ``BaseEntity`` to the ones in the given module name context_subclasses = { subclass_name: subclass for subclass_name, subclass in subclasses.items() if subclass_name.startswith(context_name + ".") } - # render models and all links between them - model_lines, links = {}, set() + # render entities and all links between them + entity_lines, links = {}, set() for subclass_name, subclass in context_subclasses.items(): - subclass_lines, subclass_links = render_model( + subclass_lines, subclass_links = render_entity( subclass_name, subclass, context_name, context_subclasses, ) - model_lines.update(subclass_lines) + entity_lines.update(subclass_lines) links.update(subclass_links) # compose the content of the dot file dot_file_content = ( """\ -digraph domain_context_models { +digraph domain_context_entities { label = "Domain context [%s]" #labelloc = "t" rankdir=LR @@ -392,7 +392,7 @@ def make_domain_context_graph( """ % context_name ) - for line in tuple(model_lines.values()) + tuple(links): + for line in tuple(entity_lines.values()) + tuple(links): dot_file_content += f" {line}\n" dot_file_content += "}" @@ -403,7 +403,7 @@ def make_domain_context_graph( def make_domain_contexts_diagrams(output_path: str) -> None: - """Make the diagrams of models for each domain contexts. + """Make the diagrams of entities for each domain contexts. Parameters ---------- @@ -411,9 +411,9 @@ def make_domain_contexts_diagrams(output_path: str) -> None: The path where to save the generated diagrams """ - # we need to import all python files (except tests) to find all submodels of ``BaseModel`` + # we need to import all python files (except tests) to find all subclasses of ``BaseEntity`` import_submodules(contexts, skip_names=["tests"]) - subclasses = get_final_subclasses(BaseModel) + subclasses = get_final_subclasses(BaseEntity) # we render each context independently, assuming that each one is directly at the root of # the ``contexts`` package diff --git a/isshub/domain/contexts/code_repository/__init__.py b/isshub/domain/contexts/code_repository/__init__.py index 789a14b..a010c99 100644 --- a/isshub/domain/contexts/code_repository/__init__.py +++ b/isshub/domain/contexts/code_repository/__init__.py @@ -1,6 +1,6 @@ """Package to handle isshub domain code_repository context. -The "code_repository" context defines every models that are related to code repositories (like +The "code_repository" context defines every entities that are related to code repositories (like Github, Gitlab...): - repositories diff --git a/isshub/domain/contexts/code_repository/entities/namespace/__init__.py b/isshub/domain/contexts/code_repository/entities/namespace/__init__.py index ac26cbe..e254848 100644 --- a/isshub/domain/contexts/code_repository/entities/namespace/__init__.py +++ b/isshub/domain/contexts/code_repository/entities/namespace/__init__.py @@ -4,7 +4,7 @@ from typing import Any, Optional from isshub.domain.utils.entity import ( - BaseModelWithId, + BaseEntityWithId, field_validator, optional_field, required_field, @@ -21,7 +21,7 @@ class NamespaceKind(enum.Enum): @validated() -class Namespace(BaseModelWithId): +class Namespace(BaseEntityWithId): """A namespace can contain namespaces and repositories. Attributes diff --git a/isshub/domain/contexts/code_repository/entities/repository/__init__.py b/isshub/domain/contexts/code_repository/entities/repository/__init__.py index d16070a..3d7e8ec 100644 --- a/isshub/domain/contexts/code_repository/entities/repository/__init__.py +++ b/isshub/domain/contexts/code_repository/entities/repository/__init__.py @@ -1,11 +1,11 @@ """Package defining the ``Repository`` entity.""" from isshub.domain.contexts.code_repository.entities.namespace import Namespace -from isshub.domain.utils.entity import BaseModelWithId, required_field, validated +from isshub.domain.utils.entity import BaseEntityWithId, required_field, validated @validated() -class Repository(BaseModelWithId): +class Repository(BaseEntityWithId): """A repository holds code, issues, code requests... Attributes diff --git a/isshub/domain/utils/entity.py b/isshub/domain/utils/entity.py index c45ca15..10ae073 100644 --- a/isshub/domain/utils/entity.py +++ b/isshub/domain/utils/entity.py @@ -31,7 +31,7 @@ class Attribute(Generic[_T]): class _InstanceOfSelfValidator( attr.validators._InstanceOfValidator # type: ignore # pylint: disable=protected-access ): - """Validator checking that the field holds an instance of its own model.""" + """Validator checking that the field holds an instance of its own entity.""" def __call__(self, inst, attr, value): # type: ignore # pylint: disable=redefined-outer-name """Validate that the `value` is an instance of the class of `inst`. @@ -43,7 +43,7 @@ def __call__(self, inst, attr, value): # type: ignore # pylint: disable=redefi def instance_of_self() -> _InstanceOfSelfValidator: - """Return a validator checking that the field holds an instance of its own model. + """Return a validator checking that the field holds an instance of its own entity. Returns ------- @@ -61,10 +61,11 @@ def optional_field( Parameters ---------- field_type : Union[type, str] - The expected type of the field. Use the string "self" to reference the current field's model + The expected type of the field. Use the string "self" to reference the current field's + entity relation_verbose_name : Optional[str] - A verbose name to describe the relation between the model linked to the field, and the - model pointed by `field_type` + A verbose name to describe the relation between the entity linked to the field, and the + entity pointed by `field_type` Returns ------- @@ -79,14 +80,14 @@ def optional_field( Examples -------- - >>> from isshub.domain.utils.entity import optional_field, validated, BaseModel + >>> from isshub.domain.utils.entity import optional_field, validated, BaseEntity >>> >>> @validated() - ... class MyModel(BaseModel): + ... class MyEntity(BaseEntity): ... my_field: str = optional_field(str) >>> >>> from isshub.domain.utils.testing.validation import check_field_nullable - >>> check_field_nullable(MyModel, 'my_field', my_field='foo') + >>> check_field_nullable(MyEntity, 'my_field', my_field='foo') """ metadata = {} @@ -116,14 +117,15 @@ def required_field( Parameters ---------- field_type : Union[type, str] - The expected type of the field. Use the string "self" to reference the current field's model + The expected type of the field. Use the string "self" to reference the current field's + entity frozen : bool If set to ``False`` (the default), the field can be updated after being set at init time. If set to ``True``, the field can be set at init time but cannot be changed later, else a ``FrozenAttributeError`` exception will be raised. relation_verbose_name : Optional[str] - A verbose name to describe the relation between the model linked to the field, and the - model pointed by `field_type` + A verbose name to describe the relation between the entity linked to the field, and the + entity pointed by `field_type` Returns ------- @@ -137,14 +139,14 @@ def required_field( Examples -------- - >>> from isshub.domain.utils.entity import required_field, validated, BaseModel + >>> from isshub.domain.utils.entity import required_field, validated, BaseEntity >>> >>> @validated() - ... class MyModel(BaseModel): + ... class MyEntity(BaseEntity): ... my_field: str = required_field(str) >>> >>> from isshub.domain.utils.testing.validation import check_field_not_nullable - >>> check_field_not_nullable(MyModel, 'my_field', my_field='foo') + >>> check_field_not_nullable(MyEntity, 'my_field', my_field='foo') """ metadata = {} @@ -179,20 +181,20 @@ def validated() -> Any: Examples -------- - >>> from isshub.domain.utils.entity import required_field, validated, BaseModel + >>> from isshub.domain.utils.entity import required_field, validated, BaseEntity >>> >>> @validated() - ... class MyModel(BaseModel): + ... class MyEntity(BaseEntity): ... my_field: str = required_field(str) >>> - >>> MyModel.__slots__ + >>> MyEntity.__slots__ ('my_field',) >>> - >>> instance = MyModel() + >>> instance = MyEntity() Traceback (most recent call last): ... TypeError: __init__() missing 1 required keyword-only argument: 'my_field' - >>> instance = MyModel(my_field='foo') + >>> instance = MyEntity(my_field='foo') >>> instance.my_field 'foo' >>> instance.validate() @@ -226,10 +228,10 @@ class field_validator: # pylint: disable=invalid-name Examples -------- - >>> from isshub.domain.utils.entity import field_validator, required_field, BaseModel + >>> from isshub.domain.utils.entity import field_validator, required_field, BaseEntity >>> >>> @validated() - ... class MyModel(BaseModel): + ... class MyEntity(BaseEntity): ... my_field: str = required_field(str) ... ... @field_validator(my_field) @@ -237,18 +239,18 @@ class field_validator: # pylint: disable=invalid-name ... if value != 'foo': ... raise ValueError(f'{self.__class__.__name__}.my_field must be "foo"') >>> - >>> instance = MyModel(my_field='bar') + >>> instance = MyEntity(my_field='bar') Traceback (most recent call last): ... - ValueError: MyModel.my_field must be "foo" - >>> instance = MyModel(my_field='foo') + ValueError: MyEntity.my_field must be "foo" + >>> instance = MyEntity(my_field='foo') >>> instance.my_field 'foo' >>> instance.my_field = 'bar' >>> instance.validate() Traceback (most recent call last): ... - ValueError: MyModel.my_field must be "foo" + ValueError: MyEntity.my_field must be "foo" >>> instance.my_field = 'foo' >>> instance.validate() >>> instance.my_field @@ -292,13 +294,13 @@ def validate_instance(instance: Any) -> Any: Examples -------- - >>> from isshub.domain.utils.entity import required_field, validate_instance, BaseModel + >>> from isshub.domain.utils.entity import required_field, validate_instance, BaseEntity >>> >>> @validated() - ... class MyModel(BaseModel): + ... class MyEntity(BaseEntity): ... my_field: str = required_field(str) >>> - >>> instance = MyModel(my_field='foo') + >>> instance = MyEntity(my_field='foo') >>> validate_instance(instance) >>> instance.my_field = None >>> validate_instance(instance) @@ -333,10 +335,10 @@ def validate_positive_integer( Examples -------- - >>> from isshub.domain.utils.entity import field_validator, required_field, BaseModel + >>> from isshub.domain.utils.entity import field_validator, required_field, BaseEntity >>> >>> @validated() - ... class MyModel(BaseModel): + ... class MyEntity(BaseEntity): ... my_field: int = required_field(int) ... ... @field_validator(my_field) @@ -347,28 +349,28 @@ def validate_positive_integer( ... display_name=f"{self.__class__.__name__}.my_field", ... ) >>> - >>> instance = MyModel(my_field='foo') + >>> instance = MyEntity(my_field='foo') Traceback (most recent call last): ... TypeError: ("'my_field' must be (got 'foo' that is a )... - >>> instance = MyModel(my_field=-2) + >>> instance = MyEntity(my_field=-2) Traceback (most recent call last): ... - ValueError: MyModel.my_field must be a positive integer - >>> instance = MyModel(my_field=0) + ValueError: MyEntity.my_field must be a positive integer + >>> instance = MyEntity(my_field=0) Traceback (most recent call last): ... - ValueError: MyModel.my_field must be a positive integer - >>> instance = MyModel(my_field=1.1) + ValueError: MyEntity.my_field must be a positive integer + >>> instance = MyEntity(my_field=1.1) Traceback (most recent call last): ... TypeError: ("'my_field' must be (got 1.1 that is a )... - >>> instance = MyModel(my_field=1) + >>> instance = MyEntity(my_field=1) >>> instance.my_field = -2 >>> instance.validate() Traceback (most recent call last): ... - ValueError: MyModel.my_field must be a positive integer + ValueError: MyEntity.my_field must be a positive integer """ if none_allowed and value is None: @@ -381,8 +383,8 @@ def validate_positive_integer( @validated() -class BaseModel: - """A base model without any field, that is able to validate itself.""" +class BaseEntity: + """A base entity without any field, that is able to validate itself.""" def validate(self) -> None: """Validate all fields of the current instance. @@ -397,8 +399,8 @@ def validate(self) -> None: @validated() -class BaseModelWithId(BaseModel): - """A base model with an ``id``, that is able to validate itself. +class BaseEntityWithId(BaseEntity): + """A base entity with an ``id``, that is able to validate itself. Attributes ---------- diff --git a/isshub/domain/utils/testing/validation.py b/isshub/domain/utils/testing/validation.py index e7b7d98..fe82bb8 100644 --- a/isshub/domain/utils/testing/validation.py +++ b/isshub/domain/utils/testing/validation.py @@ -6,7 +6,7 @@ from attr.exceptions import FrozenAttributeError -from isshub.domain.utils.entity import BaseModel +from isshub.domain.utils.entity import BaseEntity ValuesValidation = List[Tuple[Any, Optional[Type[Exception]]]] @@ -30,12 +30,12 @@ string_only: ValuesValidation = [("foo", None), (1, TypeError), (-0.1, TypeError)] -def check_field(obj: BaseModel, field_name: str) -> None: +def check_field(obj: BaseEntity, field_name: str) -> None: """Assert that the given `obj` has an attribute named `field_name`. Parameters ---------- - obj : BaseModel + obj : BaseEntity The object to test field_name : str The field name to search for @@ -50,7 +50,7 @@ def check_field(obj: BaseModel, field_name: str) -> None: def check_field_value( - factory: Callable[..., BaseModel], + factory: Callable[..., BaseEntity], field_name: str, value: Any, exception: Optional[Type[Exception]], @@ -60,7 +60,7 @@ def check_field_value( Parameters ---------- - factory : Callable[...,BaseModel] + factory : Callable[...,BaseEntity] The factory to use to create the object to test field_name : str The name of the field to check @@ -109,13 +109,13 @@ def check_field_value( def check_field_not_nullable( - factory: Callable[..., BaseModel], field_name: str, **factory_kwargs: Any + factory: Callable[..., BaseEntity], field_name: str, **factory_kwargs: Any ) -> None: """Assert that an object cannot have a specific field set to ``None``. Parameters ---------- - factory : Callable[...,BaseModel] + factory : Callable[...,BaseEntity] The factory to use to create the object to test field_name : str The name of the field to check @@ -147,13 +147,13 @@ def check_field_not_nullable( def check_field_nullable( - factory: Callable[..., BaseModel], field_name: str, **factory_kwargs: Any + factory: Callable[..., BaseEntity], field_name: str, **factory_kwargs: Any ) -> None: """Assert that an object can have a specific field set to ``None``. Parameters ---------- - factory : Callable[...,BaseModel] + factory : Callable[...,BaseEntity] The factory to use to create the object to test field_name : str The name of the field to check From 79f704bde4575a9ddeb623d67d8965a62138adc9 Mon Sep 17 00:00:00 2001 From: "Stephane Angel (Twidi)" Date: Mon, 5 Oct 2020 10:51:49 +0200 Subject: [PATCH 3/5] fix(entity): `id` changed from `int` to `uuid4`, renamed to `identifier` Abstract ======== The `id` field of entities is renamed to `identifier` and the type is changed from `int`, validated as positive integer, to `uuid.UUID`, validated to be a UUID version 4. Motivation ========== The `id` term is more a "technical" term, that is a shortened version of the word `identifier`. And it cannot be used as an argument without shadowing the `id` python builtin. So it's better to use the full word. And being an integer means that checks have to be done for uniqueness. UUIDs exists exactly to solve this. Rationale ========= N/A --- .../entities/namespace/__init__.py | 6 +- .../namespace/features/describe.feature | 16 ++-- .../entities/namespace/tests/factories.py | 2 +- .../entities/namespace/tests/test_describe.py | 19 +++-- .../entities/repository/__init__.py | 10 ++- .../repository/features/describe.feature | 16 ++-- .../entities/repository/tests/factories.py | 2 +- .../repository/tests/test_describe.py | 19 +++-- isshub/domain/utils/entity.py | 82 ++++++++++++++++--- isshub/domain/utils/testing/validation.py | 7 ++ pylintrc | 3 +- setup.cfg | 3 +- 12 files changed, 131 insertions(+), 54 deletions(-) diff --git a/isshub/domain/contexts/code_repository/entities/namespace/__init__.py b/isshub/domain/contexts/code_repository/entities/namespace/__init__.py index e254848..b2eab32 100644 --- a/isshub/domain/contexts/code_repository/entities/namespace/__init__.py +++ b/isshub/domain/contexts/code_repository/entities/namespace/__init__.py @@ -4,7 +4,7 @@ from typing import Any, Optional from isshub.domain.utils.entity import ( - BaseEntityWithId, + BaseEntityWithIdentifier, field_validator, optional_field, required_field, @@ -21,12 +21,12 @@ class NamespaceKind(enum.Enum): @validated() -class Namespace(BaseEntityWithId): +class Namespace(BaseEntityWithIdentifier): """A namespace can contain namespaces and repositories. Attributes ---------- - id : int + identifier : UUID The unique identifier of the namespace name : str The name of the namespace. Unique in its parent namespace. diff --git a/isshub/domain/contexts/code_repository/entities/namespace/features/describe.feature b/isshub/domain/contexts/code_repository/entities/namespace/features/describe.feature index 944f24a..3ff044e 100644 --- a/isshub/domain/contexts/code_repository/entities/namespace/features/describe.feature +++ b/isshub/domain/contexts/code_repository/entities/namespace/features/describe.feature @@ -1,20 +1,20 @@ Feature: Describing a Namespace - Scenario: A Namespace has an id + Scenario: A Namespace has an identifier Given a Namespace - Then it must have a field named id + Then it must have a field named identifier - Scenario: A Namespace id is a positive integer + Scenario: A Namespace identifier is a uuid Given a Namespace - Then its id must be a positive integer + Then its identifier must be a uuid - Scenario: A Namespace id is mandatory + Scenario: A Namespace identifier is mandatory Given a Namespace - Then its id is mandatory + Then its identifier is mandatory - Scenario: A Namespace id cannot be changed + Scenario: A Namespace identifier cannot be changed Given a Namespace - Then its id cannot be changed + Then its identifier cannot be changed Scenario: A Namespace has a name Given a Namespace diff --git a/isshub/domain/contexts/code_repository/entities/namespace/tests/factories.py b/isshub/domain/contexts/code_repository/entities/namespace/tests/factories.py index 62d1469..724f6ad 100644 --- a/isshub/domain/contexts/code_repository/entities/namespace/tests/factories.py +++ b/isshub/domain/contexts/code_repository/entities/namespace/tests/factories.py @@ -21,6 +21,6 @@ class Meta: model = Namespace - id = factory.Faker("pyint", min_value=1) + identifier = factory.Faker("uuid4", cast_to=None) name = factory.Faker("pystr", min_chars=2) kind = factory.Faker("enum", enum_cls=NamespaceKind) diff --git a/isshub/domain/contexts/code_repository/entities/namespace/tests/test_describe.py b/isshub/domain/contexts/code_repository/entities/namespace/tests/test_describe.py index 3815e8c..924a7c6 100644 --- a/isshub/domain/contexts/code_repository/entities/namespace/tests/test_describe.py +++ b/isshub/domain/contexts/code_repository/entities/namespace/tests/test_describe.py @@ -1,4 +1,5 @@ """Module holding BDD tests for isshub Namespace code_repository entity.""" +from uuid import uuid4 import pytest from pytest import mark @@ -11,16 +12,16 @@ check_field_not_nullable, check_field_nullable, check_field_value, - positive_integer_only, string_only, + uuid4_only, ) from .fixtures import namespace, namespace_factory -@mark.parametrize(["value", "exception"], positive_integer_only) -@scenario("../features/describe.feature", "A Namespace id is a positive integer") -def test_namespace_id_is_a_positive_integer(value, exception): +@mark.parametrize(["value", "exception"], uuid4_only) +@scenario("../features/describe.feature", "A Namespace identifier is a uuid") +def test_namespace_identifier_is_a_uuid(value, exception): pass @@ -89,15 +90,15 @@ def namespace_field_is_optional(namespace_factory, field_name): check_field_nullable(namespace_factory, field_name) -@scenario("../features/describe.feature", "A Namespace id cannot be changed") -def test_namespace_id_cannot_be_changed(): +@scenario("../features/describe.feature", "A Namespace identifier cannot be changed") +def test_namespace_identifier_cannot_be_changed(): pass -@then("its id cannot be changed") -def namespace_id_cannot_be_changed(namespace): +@then("its identifier cannot be changed") +def namespace_identifier_cannot_be_changed(namespace): with pytest.raises(FrozenAttributeError): - namespace.id = namespace.id + 1 + namespace.identifier = uuid4() @scenario("../features/describe.feature", "A Namespace cannot be contained in itself") diff --git a/isshub/domain/contexts/code_repository/entities/repository/__init__.py b/isshub/domain/contexts/code_repository/entities/repository/__init__.py index 3d7e8ec..44ab068 100644 --- a/isshub/domain/contexts/code_repository/entities/repository/__init__.py +++ b/isshub/domain/contexts/code_repository/entities/repository/__init__.py @@ -1,16 +1,20 @@ """Package defining the ``Repository`` entity.""" from isshub.domain.contexts.code_repository.entities.namespace import Namespace -from isshub.domain.utils.entity import BaseEntityWithId, required_field, validated +from isshub.domain.utils.entity import ( + BaseEntityWithIdentifier, + required_field, + validated, +) @validated() -class Repository(BaseEntityWithId): +class Repository(BaseEntityWithIdentifier): """A repository holds code, issues, code requests... Attributes ---------- - id : int + identifier : UUID The unique identifier of the repository name : str The name of the repository. Unique in its namespace. diff --git a/isshub/domain/contexts/code_repository/entities/repository/features/describe.feature b/isshub/domain/contexts/code_repository/entities/repository/features/describe.feature index bf91113..a6a74b3 100644 --- a/isshub/domain/contexts/code_repository/entities/repository/features/describe.feature +++ b/isshub/domain/contexts/code_repository/entities/repository/features/describe.feature @@ -1,20 +1,20 @@ Feature: Describing a Repository - Scenario: A Repository has an id + Scenario: A Repository has an identifier Given a Repository - Then it must have a field named id + Then it must have a field named identifier - Scenario: A Repository id is a positive integer + Scenario: A Repository identifier is a uuid Given a Repository - Then its id must be a positive integer + Then its identifier must be a uuid - Scenario: A Repository id is mandatory + Scenario: A Repository identifier is mandatory Given a Repository - Then its id is mandatory + Then its identifier is mandatory - Scenario: A Repository id cannot be changed + Scenario: A Repository identifier cannot be changed Given a Repository - Then its id cannot be changed + Then its identifier cannot be changed Scenario: A Repository has a name Given a Repository diff --git a/isshub/domain/contexts/code_repository/entities/repository/tests/factories.py b/isshub/domain/contexts/code_repository/entities/repository/tests/factories.py index 4d5fd66..2770030 100644 --- a/isshub/domain/contexts/code_repository/entities/repository/tests/factories.py +++ b/isshub/domain/contexts/code_repository/entities/repository/tests/factories.py @@ -15,6 +15,6 @@ class Meta: model = Repository - id = factory.Faker("pyint", min_value=1) + identifier = factory.Faker("uuid4", cast_to=None) name = factory.Faker("pystr", min_chars=2) namespace = factory.SubFactory(NamespaceFactory) diff --git a/isshub/domain/contexts/code_repository/entities/repository/tests/test_describe.py b/isshub/domain/contexts/code_repository/entities/repository/tests/test_describe.py index b58de94..f7a1787 100644 --- a/isshub/domain/contexts/code_repository/entities/repository/tests/test_describe.py +++ b/isshub/domain/contexts/code_repository/entities/repository/tests/test_describe.py @@ -1,4 +1,5 @@ """Module holding BDD tests for isshub Repository code_repository entity.""" +from uuid import uuid4 import pytest from pytest import mark @@ -9,17 +10,17 @@ check_field, check_field_not_nullable, check_field_value, - positive_integer_only, string_only, + uuid4_only, ) from ...namespace.tests.fixtures import namespace from .fixtures import repository_factory -@mark.parametrize(["value", "exception"], positive_integer_only) -@scenario("../features/describe.feature", "A Repository id is a positive integer") -def test_repository_id_is_a_positive_integer(value, exception): +@mark.parametrize(["value", "exception"], uuid4_only) +@scenario("../features/describe.feature", "A Repository identifier is a uuid") +def test_repository_identifier_is_a_uuid(value, exception): pass @@ -68,12 +69,12 @@ def repository_field_is_mandatory(repository_factory, field_name): check_field_not_nullable(repository_factory, field_name) -@scenario("../features/describe.feature", "A Repository id cannot be changed") -def test_repository_id_cannot_be_changed(): +@scenario("../features/describe.feature", "A Repository identifier cannot be changed") +def test_repository_identifier_cannot_be_changed(): pass -@then("its id cannot be changed") -def repository_id_cannot_be_changed(repository): +@then("its identifier cannot be changed") +def repository_identifier_cannot_be_changed(repository): with pytest.raises(FrozenAttributeError): - repository.id = repository.id + 1 + repository.identifier = uuid4() diff --git a/isshub/domain/utils/entity.py b/isshub/domain/utils/entity.py index 10ae073..0a5dc72 100644 --- a/isshub/domain/utils/entity.py +++ b/isshub/domain/utils/entity.py @@ -14,6 +14,7 @@ Union, cast, ) +from uuid import UUID import attr @@ -382,6 +383,67 @@ def validate_positive_integer( raise ValueError(f"{display_name} must be a positive integer") +def validate_uuid(value: Any, none_allowed: bool, display_name: str) -> None: + """Validate that the given `value` is a uuid (version 4) (``None`` accepted if `none_allowed`). + + Parameters + ---------- + value : Any + The value to validate as a uuid. + none_allowed : bool + If ``True``, the value can be ``None``. If ``False``, the value must be a uuid. + display_name : str + The name of the field to display in errors. + + Raises + ------ + TypeError + If `value` is not of type ``UUID`` version 4 . + + Examples + -------- + >>> from uuid import UUID + >>> from isshub.domain.utils.entity import field_validator, required_field, BaseEntity + >>> + >>> @validated() + ... class MyEntity(BaseEntity): + ... my_field: UUID = required_field(UUID) + ... + ... @field_validator(my_field) + ... def validate_my_field(self, field, value): + ... validate_uuid( + ... value=value, + ... none_allowed=False, + ... display_name=f"{self.__class__.__name__}.my_field", + ... ) + >>> + >>> instance = MyEntity(my_field='foo') + Traceback (most recent call last): + ... + TypeError: ("'my_field' must be (got 'foo' that is a )... + >>> instance = MyEntity(my_field='7298d61a-f08f-4f83-b75e-934e786eb43d') + Traceback (most recent call last): + ... + TypeError: ("'my_field' must be (got '7298d61a-f08f-4f83-b75e-934e786eb43d' that is a )... + >>> instance = MyEntity(my_field=UUID('19f49bc8-06e5-11eb-8465-bf44725d7bd3')) + Traceback (most recent call last): + ... + TypeError: MyEntity.my_field must be a UUID version 4 + >>> instance = MyEntity(my_field=UUID('7298d61a-f08f-4f83-b75e-934e786eb43d')) + >>> instance.my_field = UUID('19f49bc8-06e5-11eb-8465-bf44725d7bd3') + >>> instance.validate() + Traceback (most recent call last): + ... + TypeError: MyEntity.my_field must be a UUID version 4 + + """ + if none_allowed and value is None: + return + + if not isinstance(value, UUID) or value.version != 4: + raise TypeError(f"{display_name} must be a UUID version 4") + + @validated() class BaseEntity: """A base entity without any field, that is able to validate itself.""" @@ -399,23 +461,23 @@ def validate(self) -> None: @validated() -class BaseEntityWithId(BaseEntity): - """A base entity with an ``id``, that is able to validate itself. +class BaseEntityWithIdentifier(BaseEntity): + """A base entity with an ``identifier``, that is able to validate itself. Attributes ---------- - id : int - The identifier of the instance. Validated to be a positive integer. + identifier : UUID + The identifier of the instance. Validated to be a UUID version 4. """ - id: int = required_field(int, frozen=True) + identifier: UUID = required_field(UUID, frozen=True) - @field_validator(id) - def validate_id_is_positive_integer( # noqa # pylint: disable=unused-argument + @field_validator(identifier) + def validate_id_is_uuid( # noqa # pylint: disable=unused-argument self, field: "Attribute[_T]", value: _T ) -> None: - """Validate that the ``id`` field is a positive integer. + """Validate that the ``identifier`` field is a uuid. Parameters ---------- @@ -425,8 +487,8 @@ def validate_id_is_positive_integer( # noqa # pylint: disable=unused-argument The value to validate for the `field`. """ - validate_positive_integer( + validate_uuid( value=value, none_allowed=False, - display_name=f"{self.__class__.__name__}.id", + display_name=f"{self.__class__.__name__}.identifier", ) diff --git a/isshub/domain/utils/testing/validation.py b/isshub/domain/utils/testing/validation.py index fe82bb8..9fe4c31 100644 --- a/isshub/domain/utils/testing/validation.py +++ b/isshub/domain/utils/testing/validation.py @@ -1,6 +1,7 @@ """Validation helpers for BDD tests for isshub entities.""" from typing import Any, Callable, List, Optional, Tuple, Type +from uuid import UUID import pytest @@ -29,6 +30,12 @@ string_only: ValuesValidation = [("foo", None), (1, TypeError), (-0.1, TypeError)] +uuid4_only: ValuesValidation = [ + (UUID("19f49bc8-06e5-11eb-8465-bf44725d7bd3"), TypeError), + ("7298d61a-f08f-4f83-b75e-934e786eb43d", TypeError), + (UUID("7298d61a-f08f-4f83-b75e-934e786eb43d"), None), +] + def check_field(obj: BaseEntity, field_name: str) -> None: """Assert that the given `obj` has an attribute named `field_name`. diff --git a/pylintrc b/pylintrc index 5181880..8f00d27 100644 --- a/pylintrc +++ b/pylintrc @@ -142,7 +142,8 @@ disable=print-statement, too-few-public-methods, bad-continuation, duplicate-code, - wrong-import-position + wrong-import-position, + line-too-long, # Enable the message, report, category or checker with the given id(s). You can # either give multiple identifier separated by comma (,) or put this option diff --git a/setup.cfg b/setup.cfg index dfe7a9c..802694e 100644 --- a/setup.cfg +++ b/setup.cfg @@ -98,7 +98,8 @@ ignore = W503 # Allow assigning lambda expressions E731 -max-line-length = 99 + # Ignore line length, handled by black + B950 max-complexity = 15 select = # flake8 error class From 27f013e2a3722926a9bbe300a77a493604f0993c Mon Sep 17 00:00:00 2001 From: "Stephane Angel (Twidi)" Date: Mon, 5 Oct 2020 14:47:01 +0200 Subject: [PATCH 4/5] feat(repository): Add domain repositories 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 --- .gitignore | 1 + docs/_static/css/gherkin.css | 6 +- docs/conf.py | 3 +- .../entities/namespace/__init__.py | 6 +- .../namespace/features/describe.feature | 80 ++-- .../entities/namespace/tests/test_describe.py | 36 +- .../entities/repository/__init__.py | 2 +- .../repository/features/describe.feature | 42 +- .../repository/tests/test_describe.py | 22 +- .../code_repository/repositories/__init__.py | 1 + .../repositories/namespace/__init__.py | 78 ++++ .../namespace/features/storage.feature | 119 ++++++ .../repositories/namespace/tests/__init__.py | 1 + .../namespace/tests/test_storage.py | 378 ++++++++++++++++++ .../repositories/repository/__init__.py | 75 ++++ .../repository/features/storage.feature | 80 ++++ .../repositories/repository/tests/__init__.py | 1 + .../repository/tests/test_storage.py | 290 ++++++++++++++ isshub/domain/utils/entity.py | 46 ++- isshub/domain/utils/repository.py | 329 +++++++++++++++ 20 files changed, 1500 insertions(+), 96 deletions(-) create mode 100644 isshub/domain/contexts/code_repository/repositories/__init__.py create mode 100644 isshub/domain/contexts/code_repository/repositories/namespace/__init__.py create mode 100644 isshub/domain/contexts/code_repository/repositories/namespace/features/storage.feature create mode 100644 isshub/domain/contexts/code_repository/repositories/namespace/tests/__init__.py create mode 100644 isshub/domain/contexts/code_repository/repositories/namespace/tests/test_storage.py create mode 100644 isshub/domain/contexts/code_repository/repositories/repository/__init__.py create mode 100644 isshub/domain/contexts/code_repository/repositories/repository/features/storage.feature create mode 100644 isshub/domain/contexts/code_repository/repositories/repository/tests/__init__.py create mode 100644 isshub/domain/contexts/code_repository/repositories/repository/tests/test_storage.py create mode 100644 isshub/domain/utils/repository.py diff --git a/.gitignore b/.gitignore index 961ad6d..fe58458 100644 --- a/.gitignore +++ b/.gitignore @@ -72,6 +72,7 @@ instance/ # Sphinx documentation docs/_build/ +docs/_build_txt/ # Doc from code docs/source/ # Doc from bdd features diff --git a/docs/_static/css/gherkin.css b/docs/_static/css/gherkin.css index d236b29..47d88e3 100644 --- a/docs/_static/css/gherkin.css +++ b/docs/_static/css/gherkin.css @@ -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 { diff --git a/docs/conf.py b/docs/conf.py index bef672e..a35de76 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -93,6 +93,7 @@ # -- 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) ] ) diff --git a/isshub/domain/contexts/code_repository/entities/namespace/__init__.py b/isshub/domain/contexts/code_repository/entities/namespace/__init__.py index b2eab32..5f0da1b 100644 --- a/isshub/domain/contexts/code_repository/entities/namespace/__init__.py +++ b/isshub/domain/contexts/code_repository/entities/namespace/__init__.py @@ -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`. diff --git a/isshub/domain/contexts/code_repository/entities/namespace/features/describe.feature b/isshub/domain/contexts/code_repository/entities/namespace/features/describe.feature index 3ff044e..bfbc5eb 100644 --- a/isshub/domain/contexts/code_repository/entities/namespace/features/describe.feature +++ b/isshub/domain/contexts/code_repository/entities/namespace/features/describe.feature @@ -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 diff --git a/isshub/domain/contexts/code_repository/entities/namespace/tests/test_describe.py b/isshub/domain/contexts/code_repository/entities/namespace/tests/test_describe.py index 924a7c6..3244d80 100644 --- a/isshub/domain/contexts/code_repository/entities/namespace/tests/test_describe.py +++ b/isshub/domain/contexts/code_repository/entities/namespace/tests/test_describe.py @@ -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 .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) diff --git a/isshub/domain/contexts/code_repository/entities/repository/__init__.py b/isshub/domain/contexts/code_repository/entities/repository/__init__.py index 44ab068..2bc339f 100644 --- a/isshub/domain/contexts/code_repository/entities/repository/__init__.py +++ b/isshub/domain/contexts/code_repository/entities/repository/__init__.py @@ -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 ( diff --git a/isshub/domain/contexts/code_repository/entities/repository/features/describe.feature b/isshub/domain/contexts/code_repository/entities/repository/features/describe.feature index a6a74b3..2d3d342 100644 --- a/isshub/domain/contexts/code_repository/entities/repository/features/describe.feature +++ b/isshub/domain/contexts/code_repository/entities/repository/features/describe.feature @@ -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 diff --git a/isshub/domain/contexts/code_repository/entities/repository/tests/test_describe.py b/isshub/domain/contexts/code_repository/entities/repository/tests/test_describe.py index f7a1787..61626c7 100644 --- a/isshub/domain/contexts/code_repository/entities/repository/tests/test_describe.py +++ b/isshub/domain/contexts/code_repository/entities/repository/tests/test_describe.py @@ -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 .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) diff --git a/isshub/domain/contexts/code_repository/repositories/__init__.py b/isshub/domain/contexts/code_repository/repositories/__init__.py new file mode 100644 index 0000000..d941634 --- /dev/null +++ b/isshub/domain/contexts/code_repository/repositories/__init__.py @@ -0,0 +1 @@ +"""Package to handle repositories for the isshub entities for domain code_repository context.""" diff --git a/isshub/domain/contexts/code_repository/repositories/namespace/__init__.py b/isshub/domain/contexts/code_repository/repositories/namespace/__init__.py new file mode 100644 index 0000000..b33b0e3 --- /dev/null +++ b/isshub/domain/contexts/code_repository/repositories/namespace/__init__.py @@ -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) diff --git a/isshub/domain/contexts/code_repository/repositories/namespace/features/storage.feature b/isshub/domain/contexts/code_repository/repositories/namespace/features/storage.feature new file mode 100644 index 0000000..8894eca --- /dev/null +++ b/isshub/domain/contexts/code_repository/repositories/namespace/features/storage.feature @@ -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 diff --git a/isshub/domain/contexts/code_repository/repositories/namespace/tests/__init__.py b/isshub/domain/contexts/code_repository/repositories/namespace/tests/__init__.py new file mode 100644 index 0000000..1302862 --- /dev/null +++ b/isshub/domain/contexts/code_repository/repositories/namespace/tests/__init__.py @@ -0,0 +1 @@ +"""Package holding the tests for the ``Namespace`` code_repository repository.""" diff --git a/isshub/domain/contexts/code_repository/repositories/namespace/tests/test_storage.py b/isshub/domain/contexts/code_repository/repositories/namespace/tests/test_storage.py new file mode 100644 index 0000000..b8c6728 --- /dev/null +++ b/isshub/domain/contexts/code_repository/repositories/namespace/tests/test_storage.py @@ -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) diff --git a/isshub/domain/contexts/code_repository/repositories/repository/__init__.py b/isshub/domain/contexts/code_repository/repositories/repository/__init__.py new file mode 100644 index 0000000..fdf0ed9 --- /dev/null +++ b/isshub/domain/contexts/code_repository/repositories/repository/__init__.py @@ -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) diff --git a/isshub/domain/contexts/code_repository/repositories/repository/features/storage.feature b/isshub/domain/contexts/code_repository/repositories/repository/features/storage.feature new file mode 100644 index 0000000..4920739 --- /dev/null +++ b/isshub/domain/contexts/code_repository/repositories/repository/features/storage.feature @@ -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 diff --git a/isshub/domain/contexts/code_repository/repositories/repository/tests/__init__.py b/isshub/domain/contexts/code_repository/repositories/repository/tests/__init__.py new file mode 100644 index 0000000..973be95 --- /dev/null +++ b/isshub/domain/contexts/code_repository/repositories/repository/tests/__init__.py @@ -0,0 +1 @@ +"""Package holding the tests for the ``Repository`` code_repository repository.""" diff --git a/isshub/domain/contexts/code_repository/repositories/repository/tests/test_storage.py b/isshub/domain/contexts/code_repository/repositories/repository/tests/test_storage.py new file mode 100644 index 0000000..f9796b7 --- /dev/null +++ b/isshub/domain/contexts/code_repository/repositories/repository/tests/test_storage.py @@ -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) diff --git a/isshub/domain/utils/entity.py b/isshub/domain/utils/entity.py index 0a5dc72..c8a1d63 100644 --- a/isshub/domain/utils/entity.py +++ b/isshub/domain/utils/entity.py @@ -26,7 +26,10 @@ 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 (got None that is a )... """ - return attr.s(slots=True, kw_only=True) + return attr.s(slots=True, kw_only=True, eq=False) TValidateMethod = TypeVar( @@ -462,7 +465,7 @@ def validate(self) -> None: @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 @@ def validate_id_is_uuid( # noqa # pylint: disable=unused-argument 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 diff --git a/isshub/domain/utils/repository.py b/isshub/domain/utils/repository.py new file mode 100644 index 0000000..ee3ea6a --- /dev/null +++ b/isshub/domain/utils/repository.py @@ -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) From 34f4694cc9f5649a16e1952276eb5e26d4f84d00 Mon Sep 17 00:00:00 2001 From: "Stephane Angel (Twidi)" Date: Wed, 7 Oct 2020 11:57:16 +0200 Subject: [PATCH 5/5] docs(domain): Add diagrams for repositories Abstract ======== In the specifications part of the documentation, on each context, add a diagram representing all repositories Motivation ========== We already have the interface of the repositories available in the "Python packages" part of the documentation, but it's great to have a summary, and an auto-generated one, as we did for entities Rationale ========= N/A --- docs/conf.py | 10 +- docs/domain_contexts_diagrams.py | 331 ++++++++++++++++-- .../repositories/namespace/__init__.py | 10 +- 3 files changed, 312 insertions(+), 39 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index a35de76..af7d885 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -159,7 +159,15 @@ def run_gherkindoc(_): rst_file = os.path.join(output_path, f"{base_name}-toc.rst") with open(rst_file, "r") as file_d: rst_lines = file_d.readlines() - rst_lines.insert(3, f".. graphviz:: {base_name}-entities.dot\n\n") + rst_lines.insert( + 3, + "Diagrams\n--------\n\n" + "Entities\n~~~~~~~~\n\n" + f".. graphviz:: {base_name}-entities.dot\n\n" + "Repositories\n~~~~~~~~~~~~\n\n" + f".. graphviz:: {base_name}-repositories.dot\n\n" + "BDD features\n------------\n\n", + ) with open(rst_file, "w") as file_d: file_d.write("".join(rst_lines)) diff --git a/docs/domain_contexts_diagrams.py b/docs/domain_contexts_diagrams.py index eedbd2f..fb92c78 100755 --- a/docs/domain_contexts_diagrams.py +++ b/docs/domain_contexts_diagrams.py @@ -3,8 +3,10 @@ """Make the diagrams of entities for each isshub domain contexts.""" import importlib +import inspect import os.path import pkgutil +import re import sys from enum import Enum from types import ModuleType @@ -12,6 +14,7 @@ TYPE_CHECKING, Any, Dict, + Iterable, List, Optional, Set, @@ -23,12 +26,28 @@ import attr +from isshub.domain import contexts +from isshub.domain.utils.entity import BaseEntity +from isshub.domain.utils.repository import AbstractRepository + if TYPE_CHECKING: from attr import _Fields # pylint: disable=no-name-in-module -from isshub.domain import contexts -from isshub.domain.utils.entity import BaseEntity + try: + from typing import get_args, get_origin # type: ignore + except ImportError: + # pylint: disable=C,W + # this happen in my python 3.8 virtualenv: it shouldn't but can't figure out the problem + def get_args(tp: Any) -> Any: # noqa + return getattr(tp, "__args__", ()) + + def get_origin(tp: Any) -> Any: # noqa + return getattr(tp, "__origin__", None) + + +else: + from typing import get_args, get_origin def import_submodules( @@ -133,6 +152,97 @@ def get_final_subclasses(klass: Type) -> Dict[str, Type]: AlignLeft = chr(92) + "l" # "\l" +def filter_classes_from_module( + classes: Dict[str, Type], module_name: str +) -> Dict[str, Type]: + """Restrict the given classes to the one found in the given module. + + Parameters + ---------- + classes : Dict[str, Type] + A dict of classes from which to extract the ones to return. Full python path as keys, and + the classes as values. + module_name : str + The python path of the module for which we want the classes + + Returns + ------- + Dict[str, Type] + The filtered `classes` (same format as the given `classes` argument) + + """ + prefix = f"{module_name}." + return { + class_name: klass + for class_name, klass in classes.items() + if class_name.startswith(prefix) + } + + +def render_dot_file(output_path: str, name: str, content: str) -> None: + """Save `content` of a dot file. + + Parameters + ---------- + output_path : str + The directory where to store the dot file + name : str + The base name (without extension) of the final file + content : str + The content to save in the dot file + """ + dot_path = os.path.join(output_path, f"{name}.dot") + print(f"Writing diagram {dot_path}") + with open(dot_path, "w") as file_d: + file_d.write(content) + + +def render_dot_record(identifier: str, title: str, lines: Iterable[str]) -> str: + """Render a record in a dot file. + + Parameters + ---------- + identifier : str + The identifier of the record in the dot file + title : str + The title of the record. Will be centered. + lines : Iterable[str] + The lines of the record. Will be left aligned. + + Returns + ------- + str + The line representing the record for the dot file. + + """ + lines_parts = "|".join(f"{line} {AlignLeft}" for line in lines) + return f'{identifier} [label="{title}|{lines_parts}"]' + + +def render_dot_link(source: str, dest: str, label: Optional[str]) -> str: + """Render a link between a `source` and a `dest` in a dot file. + + Parameters + ---------- + source : str + The source of the link in the dot file + dest : str + The destination of the link in the dot file + label : Optional[str] + If set, will be the label of the link. + + Returns + ------- + str + The line representing the link for the dot file. + + """ + result = f"{source} -> {dest}" + if label: + result += f' [label="{label}"]' + return result + + def render_enum(enum: Type[Enum]) -> Tuple[str, str]: """Render the given `enum` to be incorporated in a dot file. @@ -146,17 +256,44 @@ def render_enum(enum: Type[Enum]) -> Tuple[str, str]: str The name of the enum as a dot identifier str - The definition of the enum to represent it in the graph + The definition of the enum to represent it in the diagram """ dot_name = get_dot_identifier(get_python_path(enum)) - enum_parts = "|".join(f"{value.value} {AlignLeft}" for value in enum) - return ( - dot_name, - f'{dot_name} [label="<__class__> Enum: {enum.__name__}|{enum_parts}"]', + return dot_name, render_dot_record( + dot_name, f"<__class__> Enum: {enum.__name__}", (value.value for value in enum) ) +def get_optional_type(type_: Any) -> Union[None, Any]: + """Get the optional type defined in the given `type_`. + + Only works for one of these syntax: + + - ``Optional[TheType]`` + - ``Union[TheType, None'`` + + Parameters + ---------- + type_ : Any + The type (from from a call to ``get_type_hints``) to analyse + + Returns + ------- + Union[None, Any] + Will be ``None`` if the `type_` + + """ + if get_origin(type_) is not Union: + return None + args = get_args(type_) + if len(args) != 2: + return None + if NoneType not in args: + return None + return [arg for arg in args if arg is not NoneType][0] + + def validate_entity( name: str, entity: Type[BaseEntity], @@ -206,19 +343,20 @@ def validate_entity( for field_name, field_type in types.items(): required = True - if getattr(field_type, "__origin__", None) is Union: - if len(field_type.__args__) != 2: + if get_origin(field_type) is Union: + args = get_args(field_type) + if len(args) != 2: raise NotImplementedError( f"{name}.{field_name} : {field_type}" " - Union type with more that two choices is not implemented" ) - if NoneType not in field_type.__args__: + if NoneType not in args: raise NotImplementedError( f"{name}.{field_name} : {field_type}" " - Union type without None is not implemented" ) required = False - field_type = [arg for arg in field_type.__args__ if arg is not NoneType][0] + field_type = [arg for arg in args if arg is not NoneType][0] if field_type.__module__.startswith("isshub") and not issubclass( field_type, Enum @@ -234,7 +372,7 @@ def validate_entity( return fields -def render_link( +def render_entity_link( source_name: str, field_name: str, dest_name: str, @@ -272,7 +410,9 @@ def render_link( except Exception: # pylint: disable=broad-except link_label = "(" + ("1" if required else "0..1") + ")" - return f'{source_name}:{field_name} -> {dest_name}:__class__ [label="{link_label}"]' + return render_dot_link( + f"{source_name}:{field_name}", f"{dest_name}:__class__", link_label + ) def render_entity( @@ -329,24 +469,24 @@ def render_entity( link_to = get_dot_identifier(get_python_path(field_type)) if link_to: - links.add(render_link(dot_name, field_name, link_to, required, attr_fields)) + links.add( + render_entity_link(dot_name, field_name, link_to, required, attr_fields) + ) fields[field_name] = field_type.__name__ if not required: fields[field_name] = f"{fields[field_name]} (optional)" - fields_parts = "|".join( - f"<{f_name}> {f_name} : {f_type} {AlignLeft}" - for f_name, f_type in fields.items() + lines[dot_name] = render_dot_record( + dot_name, + f"<__class__> Entity: {entity.__name__}", + (f"<{f_name}> {f_name} : {f_type}" for f_name, f_type in fields.items()), ) - lines[ - dot_name - ] = f'{dot_name} [label="<__class__> Entity: {entity.__name__}|{fields_parts}"]' return lines, links -def make_domain_context_graph( +def make_domain_context_entities_diagram( context_name: str, subclasses: Dict[str, Type[BaseEntity]], output_path: str ) -> None: """Make the graph of entities in the given contexts. @@ -356,18 +496,14 @@ def make_domain_context_graph( context_name : str The name of the context, represented by the python path of its module subclasses : Dict[str, Type[BaseEntity]] - All the subclasses of ``BaseEntity`` from which to extract the modules to render. + All the subclasses of ``BaseEntity`` from which to extract the ones to render. Only subclasses present in the given context will be rendered. output_path : str The path where to save the generated graph """ # restrict the subclasses of ``BaseEntity`` to the ones in the given module name - context_subclasses = { - subclass_name: subclass - for subclass_name, subclass in subclasses.items() - if subclass_name.startswith(context_name + ".") - } + context_subclasses = filter_classes_from_module(subclasses, context_name) # render entities and all links between them entity_lines, links = {}, set() @@ -385,7 +521,7 @@ def make_domain_context_graph( dot_file_content = ( """\ digraph domain_context_entities { - label = "Domain context [%s]" + label = "Domain context entities [%s]" #labelloc = "t" rankdir=LR node[shape=record] @@ -396,10 +532,132 @@ def make_domain_context_graph( dot_file_content += f" {line}\n" dot_file_content += "}" - dot_path = os.path.join(output_path, f"{context_name}-entities.dot") - print(f"Writing graph for domain context {context_name} in {dot_path}") - with open(dot_path, "w") as file_d: - file_d.write(dot_file_content) + render_dot_file(output_path, f"{context_name}-entities", dot_file_content) + + +re_optional = re.compile(r"(?:typing\.)?Union\[(.*), NoneType]") +re_literal = re.compile(r"(?:typing\.)?Literal\[(.*?)]") + + +def render_repository( # pylint: disable=too-many-locals + name: str, repository: Type[AbstractRepository], context: str +) -> str: + """Render the content of the dot file for the given `repository`. + + Parameters + ---------- + name : str + The name of the `repository` + repository : Type[AbstractRepository] + The repository to render + context : str + The name of the context containing the `repository` + + Returns + ------- + str + The content of the dot file for the diagram of the given `repository` + + """ + members = { + name: value + for name, value in inspect.getmembers(repository) + if not name.startswith("_") + } + methods = { + name: value for name, value in members.items() if inspect.isfunction(value) + } + entity_class = members["entity_class"] + + re_context = re.compile(context + r".(?:\w+\.)*(\w+)") + + def optimize_annotation(type_: Any) -> str: # pylint: disable=W + if isinstance(type_, type): + return type_.__name__ + result = str(type_) + for regexp, replacement in ( + (re_context, r"\1"), + (re_literal, r"\1"), + (re_optional, r"Optional[\1]"), + ): + result = regexp.sub(replacement, result) + return result.replace("~Entity", entity_class.__name__).replace("typing.", "") + + methods_lines = [] + for method_name, method in methods.items(): + signature = inspect.signature(method) + params = [] + for param_name, param in signature.parameters.items(): + if param_name == "self": + continue + params.append( + "".join( + ( + param_name, + "" + if not param.annotation or param.annotation is param.empty + else ": %s" % optimize_annotation(param.annotation), + "" if param.default is param.empty else " = %s" % param.default, + ) + ) + ) + methods_lines.append( + f"{method_name}(%s)%s" + % ( + ", ".join(params), + "" + if not signature.return_annotation + or signature.return_annotation is signature.empty + else " → %s" % optimize_annotation(signature.return_annotation), + ) + ) + + return render_dot_record( + get_dot_identifier(get_python_path(repository)), + f"{repository.__name__} (for {entity_class.__name__} entity)", + methods_lines, + ) + + +def make_domain_context_repositories_diagram( + context_name: str, subclasses: Dict[str, Type[AbstractRepository]], output_path: str +) -> None: + """Make the graph of entities in the given contexts. + + Parameters + ---------- + context_name : str + The name of the context, represented by the python path of its module + subclasses : Dict[str, Type[AbstractRepository]] + All the subclasses of ``AbstractRepository`` from which to extract the ones to render. + Only subclasses present in the given context will be rendered. + output_path : str + The path where to save the generated graph + + """ + # restrict the subclasses of ``AbstractRepository`` to the ones in the given module name + context_subclasses = filter_classes_from_module(subclasses, context_name) + rendered_repositories = [ + render_repository(subclass_name, subclass, context_name) + for subclass_name, subclass in context_subclasses.items() + ] + + # compose the content of the dot file + dot_file_content = ( + """\ +digraph domain_context_repositories { + label = "Domain context repositories [%s]" + #labelloc = "t" + rankdir=LR + node[shape=record] +""" + % context_name + ) + for line in rendered_repositories: + dot_file_content += f" {line}\n" + dot_file_content += "}" + + render_dot_file(output_path, f"{context_name}-repositories", dot_file_content) def make_domain_contexts_diagrams(output_path: str) -> None: @@ -411,16 +669,19 @@ def make_domain_contexts_diagrams(output_path: str) -> None: The path where to save the generated diagrams """ - # we need to import all python files (except tests) to find all subclasses of ``BaseEntity`` + # we need to import all python files (except tests) to be sure we have access to all python code import_submodules(contexts, skip_names=["tests"]) - subclasses = get_final_subclasses(BaseEntity) + + entities = get_final_subclasses(BaseEntity) + repositories = get_final_subclasses(AbstractRepository) # we render each context independently, assuming that each one is directly at the root of # the ``contexts`` package for module in pkgutil.iter_modules( path=contexts.__path__, prefix=contexts.__name__ + "." # type: ignore ): - make_domain_context_graph(module.name, subclasses, output_path) + make_domain_context_entities_diagram(module.name, entities, output_path) + make_domain_context_repositories_diagram(module.name, repositories, output_path) if __name__ == "__main__": diff --git a/isshub/domain/contexts/code_repository/repositories/namespace/__init__.py b/isshub/domain/contexts/code_repository/repositories/namespace/__init__.py index b33b0e3..9e3bebb 100644 --- a/isshub/domain/contexts/code_repository/repositories/namespace/__init__.py +++ b/isshub/domain/contexts/code_repository/repositories/namespace/__init__.py @@ -1,7 +1,7 @@ """Package defining the repository for the :obj:`.Namespace` entity.""" import abc -from typing import Iterable, Union +from typing import Iterable, Literal, Union # type: ignore from .....utils.repository import AbstractInMemoryRepository, AbstractRepository from ...entities import Namespace @@ -13,7 +13,9 @@ class AbstractNamespaceRepository( """Base repository for the :obj:`.Namespace` entity.""" @abc.abstractmethod - def for_namespace(self, namespace: Union[Namespace, None]) -> Iterable[Namespace]: + def for_namespace( + self, namespace: Union[Namespace, Literal[None]] + ) -> Iterable[Namespace]: """Iterate on namespaces found in the given `namespace`, or with no namespace if ``None``. Parameters @@ -64,7 +66,9 @@ def add(self, entity: Namespace) -> Namespace: ) return super().add(entity) - def for_namespace(self, namespace: Union[Namespace, None]) -> Iterable[Namespace]: + def for_namespace( + self, namespace: Union[Namespace, Literal[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`