style(mypy): Remove most “type: ignore” pragmas

Description

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.

Info

Hash

76638c3d09c47d58febbc9e2e2cc80e84c98ac33

Date

2020-10-04 20:36:50 +0200

Parents
  • docs(domain): Add diagram of entities for each domain context [bb5e73eb]2020-10-04 11:50:37 +0200

Children
  • style(entity): Change the world “model” by “entity” [c2dd0fc6]2020-10-04 21:07:00 +0200

Branches
Tags

(No tags)

Changes

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

Type

Modified

Stats

+6 -8

@@ -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:

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

Type

Modified

Stats

+3 -5

@@ -4,7 +4,7 @@ from isshub.domain.contexts.code_repository.entities.namespace import Namespace
 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")

isshub/domain/utils/entity.py

Type

Modified

Stats

+86 -21

@@ -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

isshub/domain/utils/mypy_plugin.py

Type

Added

Stats

+45 -0

@@ -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

setup.cfg

Type

Modified

Stats

+3 -1

@@ -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