diff --git a/mypy.ini b/mypy.ini --- a/mypy.ini +++ b/mypy.ini @@ -1,10 +1,13 @@ [mypy] namespace_packages = True warn_unused_ignores = True - +plugins = swh.model.mypy_plugin # 3rd party libraries without stubs (yet) +[mypy-attrs_strict.*] # a bit sad, but... +ignore_missing_imports = True + [mypy-django.*] # false positive, only used my hypotesis' extras ignore_missing_imports = True diff --git a/requirements-test.txt b/requirements-test.txt --- a/requirements-test.txt +++ b/requirements-test.txt @@ -1,4 +1,5 @@ Click dulwich +mypy pytest pytz diff --git a/requirements.txt b/requirements.txt --- a/requirements.txt +++ b/requirements.txt @@ -3,6 +3,7 @@ # dependency lines, see https://pip.readthedocs.org/en/1.1/requirements.html vcversioner attrs +attrs_strict hypothesis python-dateutil iso8601 diff --git a/swh/model/model.py b/swh/model/model.py --- a/swh/model/model.py +++ b/swh/model/model.py @@ -7,9 +7,10 @@ from abc import ABCMeta, abstractmethod from enum import Enum -from typing import List, Optional, Dict, Union +from typing import Any, Callable, Collection, Dict, List, Optional, Type, Union import attr +from attrs_strict import type_validator import dateutil.parser import iso8601 @@ -32,6 +33,18 @@ Sha1Git = bytes +# cannot use a partial here because of +# https://github.com/bloomberg/attrs-strict/issues/24 +def attrib_typecheck(default: Any = attr.NOTHING, + type: Optional[Type] = None, + validator: Collection[Callable] = ()): + "A 'partial' of attr.ib that prefill the validator with type_validator" + return attr.attrib( + default=default, + type=type, + validator=[type_validator(), *validator]) + + def dictify(value): "Helper function used by BaseModel.to_dict()" if isinstance(value, BaseModel): @@ -84,9 +97,9 @@ @attr.s(frozen=True) class Person(BaseModel): """Represents the author/committer of a revision or release.""" - fullname = attr.ib(type=bytes) - name = attr.ib(type=Optional[bytes]) - email = attr.ib(type=Optional[bytes]) + fullname = attrib_typecheck(type=bytes) + name = attrib_typecheck(type=Optional[bytes]) + email = attrib_typecheck(type=Optional[bytes]) @classmethod def from_fullname(cls, fullname: bytes): @@ -131,8 +144,8 @@ @attr.s(frozen=True) class Timestamp(BaseModel): """Represents a naive timestamp from a VCS.""" - seconds = attr.ib(type=int) - microseconds = attr.ib(type=int) + seconds = attrib_typecheck(type=int) + microseconds = attrib_typecheck(type=int) @seconds.validator def check_seconds(self, attribute, value): @@ -150,9 +163,9 @@ @attr.s(frozen=True) class TimestampWithTimezone(BaseModel): """Represents a TZ-aware timestamp from a VCS.""" - timestamp = attr.ib(type=Timestamp) - offset = attr.ib(type=int) - negative_utc = attr.ib(type=bool) + timestamp = attrib_typecheck(type=Timestamp) + offset = attrib_typecheck(type=int) + negative_utc = attrib_typecheck(type=bool) @offset.validator def check_offset(self, attribute, value): @@ -193,25 +206,24 @@ @attr.s(frozen=True) class Origin(BaseModel): """Represents a software source: a VCS and an URL.""" - url = attr.ib(type=str) + url = attrib_typecheck(type=str) @attr.s(frozen=True) class OriginVisit(BaseModel): """Represents a visit of an origin at a given point in time, by a SWH loader.""" - origin = attr.ib(type=str) - date = attr.ib(type=datetime.datetime) + origin = attrib_typecheck(type=str) + date = attrib_typecheck(type=datetime.datetime) status = attr.ib( type=str, validator=attr.validators.in_(['ongoing', 'full', 'partial'])) - type = attr.ib(type=str) - snapshot = attr.ib(type=Optional[Sha1Git]) - metadata = attr.ib(type=Optional[Dict[str, object]], - default=None) - - visit = attr.ib(type=Optional[int], - default=None) + type = attrib_typecheck(type=str) + snapshot = attrib_typecheck(type=Optional[Sha1Git]) + metadata = attrib_typecheck(type=Optional[Dict[str, object]], + default=None) + visit = attrib_typecheck(type=Optional[int], + default=None) """Should not be set before calling 'origin_visit_add()'.""" def to_dict(self): @@ -257,8 +269,8 @@ @attr.s(frozen=True) class SnapshotBranch(BaseModel): """Represents one of the branches of a snapshot.""" - target = attr.ib(type=bytes) - target_type = attr.ib(type=TargetType) + target = attrib_typecheck(type=bytes) + target_type = attrib_typecheck(type=TargetType) @target.validator def check_target(self, attribute, value): @@ -279,8 +291,8 @@ @attr.s(frozen=True) class Snapshot(BaseModel, HashableObject): """Represents the full state of an origin at a given point in time.""" - branches = attr.ib(type=Dict[bytes, Optional[SnapshotBranch]]) - id = attr.ib(type=Sha1Git, default=b'') + branches = attrib_typecheck(type=Dict[bytes, Optional[SnapshotBranch]]) + id = attrib_typecheck(type=Sha1Git, default=b'') @staticmethod def compute_hash(object_dict): @@ -299,18 +311,18 @@ @attr.s(frozen=True) class Release(BaseModel, HashableObject): - name = attr.ib(type=bytes) - message = attr.ib(type=bytes) - target = attr.ib(type=Optional[Sha1Git]) - target_type = attr.ib(type=ObjectType) - synthetic = attr.ib(type=bool) - author = attr.ib(type=Optional[Person], - default=None) - date = attr.ib(type=Optional[TimestampWithTimezone], - default=None) - metadata = attr.ib(type=Optional[Dict[str, object]], - default=None) - id = attr.ib(type=Sha1Git, default=b'') + name = attrib_typecheck(type=bytes) + message = attrib_typecheck(type=bytes) + target = attrib_typecheck(type=Optional[Sha1Git]) + target_type = attrib_typecheck(type=ObjectType) + synthetic = attrib_typecheck(type=bool) + author = attrib_typecheck(type=Optional[Person], + default=None) + date = attrib_typecheck(type=Optional[TimestampWithTimezone], + default=None) + metadata = attrib_typecheck(type=Optional[Dict[str, object]], + default=None) + id = attrib_typecheck(type=Sha1Git, default=b'') @staticmethod def compute_hash(object_dict): @@ -350,19 +362,19 @@ @attr.s(frozen=True) class Revision(BaseModel, HashableObject): - message = attr.ib(type=bytes) - author = attr.ib(type=Person) - committer = attr.ib(type=Person) - date = attr.ib(type=Optional[TimestampWithTimezone]) - committer_date = attr.ib(type=Optional[TimestampWithTimezone]) - type = attr.ib(type=RevisionType) - directory = attr.ib(type=Sha1Git) - synthetic = attr.ib(type=bool) - metadata = attr.ib(type=Optional[Dict[str, object]], - default=None) - parents = attr.ib(type=List[Sha1Git], - default=attr.Factory(list)) - id = attr.ib(type=Sha1Git, default=b'') + message = attrib_typecheck(type=bytes) + author = attrib_typecheck(type=Person) + committer = attrib_typecheck(type=Person) + date = attrib_typecheck(type=Optional[TimestampWithTimezone]) + committer_date = attrib_typecheck(type=Optional[TimestampWithTimezone]) + type = attrib_typecheck(type=RevisionType) + directory = attrib_typecheck(type=Sha1Git) + synthetic = attrib_typecheck(type=bool) + metadata = attrib_typecheck(type=Optional[Dict[str, object]], + default=None) + parents = attrib_typecheck(type=List[Sha1Git], + default=attr.Factory(list)) + id = attrib_typecheck(type=Sha1Git, default=b'') @staticmethod def compute_hash(object_dict): @@ -391,18 +403,18 @@ @attr.s(frozen=True) class DirectoryEntry(BaseModel): - name = attr.ib(type=bytes) + name = attrib_typecheck(type=bytes) type = attr.ib(type=str, validator=attr.validators.in_(['file', 'dir', 'rev'])) - target = attr.ib(type=Sha1Git) - perms = attr.ib(type=int) + target = attrib_typecheck(type=Sha1Git) + perms = attrib_typecheck(type=int) """Usually one of the values of `swh.model.from_disk.DentryPerms`.""" @attr.s(frozen=True) class Directory(BaseModel, HashableObject): - entries = attr.ib(type=List[DirectoryEntry]) - id = attr.ib(type=Sha1Git, default=b'') + entries = attrib_typecheck(type=List[DirectoryEntry]) + id = attrib_typecheck(type=Sha1Git, default=b'') @staticmethod def compute_hash(object_dict): @@ -461,22 +473,22 @@ @attr.s(frozen=True) class Content(BaseContent): - sha1 = attr.ib(type=bytes) - sha1_git = attr.ib(type=Sha1Git) - sha256 = attr.ib(type=bytes) - blake2s256 = attr.ib(type=bytes) + sha1 = attrib_typecheck(type=bytes) + sha1_git = attrib_typecheck(type=Sha1Git) + sha256 = attrib_typecheck(type=bytes) + blake2s256 = attrib_typecheck(type=bytes) - length = attr.ib(type=int) + length = attrib_typecheck(type=int) status = attr.ib( type=str, default='visible', validator=attr.validators.in_(['visible', 'hidden'])) - data = attr.ib(type=Optional[bytes], default=None) + data = attrib_typecheck(type=Optional[bytes], default=None) - ctime = attr.ib(type=Optional[datetime.datetime], - default=None) + ctime = attrib_typecheck(type=Optional[datetime.datetime], + default=None) @length.validator def check_length(self, attribute, value): @@ -518,24 +530,24 @@ @attr.s(frozen=True) class SkippedContent(BaseContent): - sha1 = attr.ib(type=Optional[bytes]) - sha1_git = attr.ib(type=Optional[Sha1Git]) - sha256 = attr.ib(type=Optional[bytes]) - blake2s256 = attr.ib(type=Optional[bytes]) + sha1 = attrib_typecheck(type=Optional[bytes]) + sha1_git = attrib_typecheck(type=Optional[Sha1Git]) + sha256 = attrib_typecheck(type=Optional[bytes]) + blake2s256 = attrib_typecheck(type=Optional[bytes]) - length = attr.ib(type=Optional[int]) + length = attrib_typecheck(type=Optional[int]) status = attr.ib( type=str, validator=attr.validators.in_(['absent'])) - reason = attr.ib(type=Optional[str], - default=None) + reason = attrib_typecheck(type=Optional[str], + default=None) - origin = attr.ib(type=Optional[Origin], - default=None) + origin = attrib_typecheck(type=Optional[Origin], + default=None) - ctime = attr.ib(type=Optional[datetime.datetime], - default=None) + ctime = attrib_typecheck(type=Optional[datetime.datetime], + default=None) @reason.validator def check_reason(self, attribute, value): diff --git a/swh/model/mypy_plugin.py b/swh/model/mypy_plugin.py new file mode 100644 --- /dev/null +++ b/swh/model/mypy_plugin.py @@ -0,0 +1,16 @@ +from mypy.plugin import Plugin +from mypy.plugins.attrs import ( + attr_attrib_makers, +) + +# These are our attr.ib makers. +attr_attrib_makers.add("swh.model.model.attrib_typecheck") + + +class MyPlugin(Plugin): + # Our plugin does nothing but it has to exist so this file gets loaded. + pass + + +def plugin(version): + return MyPlugin