From cd821f478fadd5e37b02ae7533f7f635256d235d Mon Sep 17 00:00:00 2001 From: Wilfred Wong Date: Tue, 26 May 2020 04:01:43 -0400 Subject: [PATCH] Add type stubs and py.typed. Add type checking to tox Signed-off-by: Wilfred Wong --- .gitignore | 3 +- MANIFEST.in | 3 ++ attrs_strict/_error.py | 42 +++++++++++++++------- attrs_strict/_type_validation.py | 39 ++++++++++++--------- attrs_strict/py.typed | 0 setup.py | 1 + tox.ini | 21 +++++++++++ types/__init__.pyi | 2 ++ types/_commons.pyi | 4 +++ types/_error.pyi | 54 ++++++++++++++++++++++++++++ types/_type_validation.pyi | 60 ++++++++++++++++++++++++++++++++ 11 files changed, 199 insertions(+), 30 deletions(-) create mode 100644 MANIFEST.in create mode 100644 attrs_strict/py.typed create mode 100644 types/__init__.pyi create mode 100644 types/_commons.pyi create mode 100644 types/_error.pyi create mode 100644 types/_type_validation.pyi diff --git a/.gitignore b/.gitignore index 1a09f1e..5750a84 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,7 @@ __pycache__ /.vscode # tools -/.*_cache +.*_cache + pip-wheel-metadata diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..02377d2 --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,3 @@ +include attrs_strict/py.typed +recursive-include attrs_strict *.pyi +recursive-include attrs_strict *.py diff --git a/attrs_strict/_error.py b/attrs_strict/_error.py index 292c546..e001595 100644 --- a/attrs_strict/_error.py +++ b/attrs_strict/_error.py @@ -24,18 +24,26 @@ def _render(self, error): class AttributeTypeError(BadTypeError): - def __init__(self, container, attribute): + def __init__(self, value, attribute): super(AttributeTypeError, self).__init__() - self.container = container + self.value = value self.attribute = attribute def __str__(self): - error = "{} must be {} (got {} that is a {})".format( - self.attribute.name, - format_type(self.attribute.type), - self.container, - type(self.container), - ) + if self.attribute.type is None: + error = ( + "attrs-strict error: AttributeTypeError was " + "raised on an attribute ({}) with no defined type".format( + self.attribute.name + ) + ) + else: + error = "{} must be {} (got {} that is a {})".format( + self.attribute.name, + format_type(self.attribute.type), + self.value, + type(self.value), + ) return self._render(error) @@ -77,11 +85,19 @@ def __init__(self, container, attribute): self.attribute = attribute def __str__(self): - error = "{} can not be empty and must be {} (got {})".format( - self.attribute.name, - format_type(self.attribute.type), - self.container, - ) + if self.attribute.type is None: + error = ( + "attrs-strict error: AttributeTypeError was " + "raised on an attribute ({}) with no defined type".format( + self.attribute.name + ) + ) + else: + error = "{} can not be empty and must be {} (got {})".format( + self.attribute.name, + format_type(self.attribute.type), + self.container, + ) return self._render(error) diff --git a/attrs_strict/_type_validation.py b/attrs_strict/_type_validation.py index 4ab51e3..98029aa 100644 --- a/attrs_strict/_type_validation.py +++ b/attrs_strict/_type_validation.py @@ -19,12 +19,14 @@ try: from inspect import signature except ImportError: - from funcsigs import signature + # silencing type error so mypy doesn't complain about duplicate import + from funcsigs import signature # type: ignore try: from itertools import zip_longest except ImportError: - from itertools import izip_longest as zip_longest + # silencing type error so mypy doesn't complain about duplicate import + from itertools import izip_longest as zip_longest # type: ignore class SimilarTypes: @@ -64,29 +66,34 @@ def _validator(instance, attribute, field): def _validate_elements(attribute, value, expected_type): + if expected_type is None: + return + base_type = _get_base_type(expected_type) - if base_type is None or base_type == typing.Any: + if base_type == typing.Any: return - if base_type != typing.Union and not isinstance(value, base_type): + if base_type != typing.Union and not isinstance( + value, base_type + ): # type: ignore raise AttributeTypeError(value, attribute) - if base_type in SimilarTypes.List: + if base_type == typing.Union: # type: ignore + _handle_union(attribute, value, expected_type) + elif base_type in SimilarTypes.List: _handle_set_or_list(attribute, value, expected_type) elif base_type in SimilarTypes.Dict: _handle_dict(attribute, value, expected_type) elif base_type in SimilarTypes.Tuple: _handle_tuple(attribute, value, expected_type) - elif base_type == typing.Union: - _handle_union(attribute, value, expected_type) - elif base_type in SimilarTypes.Callable: + elif base_type in SimilarTypes.Callable: # type: ignore _handle_callable(attribute, value, expected_type) def _get_base_type(type_): if hasattr(type_, "__origin__") and type_.__origin__ is not None: - base_type = type_.__origin__ + base_type = type_.__origin__ # type: typing.Type[typing.Any] elif is_newtype(type_): base_type = type_.__supertype__ else: @@ -104,7 +111,7 @@ def _type_matching(actual, expected): base_type = _get_base_type(expected) - if base_type == typing.Union: + if base_type == typing.Union: # type: ignore return any( _type_matching(actual, expected_candidate) for expected_candidate in expected.__args__ @@ -132,11 +139,11 @@ def _handle_callable(attribute, callable_, expected_type): param.annotation for param in _signature.parameters.values() ] callable_args.append(_signature.return_annotation) - if not expected_type.__args__: + if not expected_type.__args__: # type: ignore return # No annotations specified on type, matches all Callables for callable_arg, expected_arg in zip_longest( - callable_args, expected_type.__args__ + callable_args, expected_type.__args__ # type: ignore ): if not _type_matching(callable_arg, expected_arg): raise CallableError( @@ -145,7 +152,7 @@ def _handle_callable(attribute, callable_, expected_type): def _handle_set_or_list(attribute, container, expected_type): - (element_type,) = expected_type.__args__ + (element_type,) = expected_type.__args__ # type: ignore for element in container: try: @@ -156,7 +163,7 @@ def _handle_set_or_list(attribute, container, expected_type): def _handle_dict(attribute, container, expected_type): - key_type, value_type = expected_type.__args__ + key_type, value_type = expected_type.__args__ # type: ignore for key in container: try: @@ -168,10 +175,10 @@ def _handle_dict(attribute, container, expected_type): def _handle_tuple(attribute, container, expected_type): - tuple_types = expected_type.__args__ + tuple_types = expected_type.__args__ # type: ignore if len(tuple_types) == 2 and tuple_types[1] == Ellipsis: element_type = tuple_types[0] - tuple_types = (element_type, ) * len(container) + tuple_types = (element_type,) * len(container) if len(container) != len(tuple_types): raise TupleError(container, attribute.type, tuple_types) diff --git a/attrs_strict/py.typed b/attrs_strict/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/setup.py b/setup.py index 8c45026..f7bc40a 100755 --- a/setup.py +++ b/setup.py @@ -22,6 +22,7 @@ author_email="eseulean@bloomberg.net", license="Apache 2.0", packages=["attrs_strict"], + include_package_data=True, install_requires=["attrs", "typing; python_version<'3.5'"], tests_require=["mock", "pytest"], python_requires=">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, <4", diff --git a/tox.ini b/tox.ini index 05a81f0..35a6063 100644 --- a/tox.ini +++ b/tox.ini @@ -6,7 +6,9 @@ envlist = py38, pypy, pypy3, + mypy, coverage, + merge, fix_lint, docs, package_description, @@ -32,6 +34,25 @@ commands = python -m pytest \ --junitxml {toxworkdir}/junit.{envname}.xml \ {posargs:.} + +[testenv:mypy] +description = check that the type annotations within attrs-strict are self-consistent. Does NOT check if stub files align correctly with actual code. +basepython = python3.7 +deps = mypy +setenv = + {[testenv]setenv} + MYPYPATH={toxinidir} +commands = mypy types --py2 --strict + mypy types --strict + +[testenv:merge] +description = try to merge our types against our source +deps = {[testenv:mypy]deps} + retype +changedir = {envtmpdir} +commands = python -m retype -p {toxinidir}/types -t {envtmpdir}/attrs_strict {toxinidir}/attrs_strict + mypy -p {envtmpdir}/attrs_strict --strict --ignore-missing-imports {posargs} + [testenv:docs] description = invoke sphinx-build to build the HTML docs basepython = python3.7 diff --git a/types/__init__.pyi b/types/__init__.pyi new file mode 100644 index 0000000..9f374e2 --- /dev/null +++ b/types/__init__.pyi @@ -0,0 +1,2 @@ +from ._error import AttributeTypeError as AttributeTypeError, BadTypeError as BadTypeError, TupleError as TupleError, TypeValidationError as TypeValidationError, UnionError as UnionError +from ._type_validation import type_validator as type_validator diff --git a/types/_commons.pyi b/types/_commons.pyi new file mode 100644 index 0000000..83dde04 --- /dev/null +++ b/types/_commons.pyi @@ -0,0 +1,4 @@ +import typing + +def is_newtype(type_: typing.Type[typing.Any]) -> bool: ... +def format_type(type_: typing.Type[typing.Any]) -> str: ... diff --git a/types/_error.pyi b/types/_error.pyi new file mode 100644 index 0000000..6eda6d8 --- /dev/null +++ b/types/_error.pyi @@ -0,0 +1,54 @@ +import typing +import attr +import inspect + +class TypeValidationError(Exception): + def __repr__(self) -> str: ... + +class BadTypeError(TypeValidationError, ValueError): + def __init__(self) -> None: + self.containers: typing.List[typing.Iterable[typing.Any]] + def add_container(self, container: typing.Any) -> None: ... + def _render(self, error: str) -> str: ... + +class AttributeTypeError(BadTypeError): + def __init__( + self, value: typing.Any, attribute: attr.Attribute[typing.Any] + ) -> None: ... + def __str__(self) -> str: ... + +class CallableError(BadTypeError): + def __init__( + self, + attribute: attr.Attribute[typing.Any], + callable_signature: inspect.Signature, + expected_signature: typing.Type[typing.Callable[..., typing.Any]], + mismatch_callable_arg: inspect.Parameter, + expected_callable_arg: inspect.Parameter, + ) -> None: ... + def __str__(self) -> str: ... + +class EmptyError(BadTypeError): + def __init__( + self, container: typing.Any, attribute: attr.Attribute[typing.Any] + ) -> None: ... + def __str__(self) -> str: ... + +class TupleError(BadTypeError): + def __init__( + self, + container: typing.Any, + attribute: typing.Optional[typing.Type[typing.Any]], + tuple_types: typing.Tuple[typing.Type[typing.Any]], + ) -> None: ... + def __str__(self) -> str: ... + def _more_or_less(self) -> str: ... + +class UnionError(BadTypeError): + def __init__( + self, + container: typing.Any, + attribute: str, + expected_type: typing.Type[typing.Any], + ) -> None: ... + def __str__(self) -> str: ... diff --git a/types/_type_validation.pyi b/types/_type_validation.pyi new file mode 100644 index 0000000..05d63eb --- /dev/null +++ b/types/_type_validation.pyi @@ -0,0 +1,60 @@ +import typing +import attr + +def type_validator( + empty_ok: bool = True, +) -> typing.Callable[ + [typing.Any, attr.Attribute[typing.Any], typing.Any], None +]: + def _validator( + instance: typing.Any, + attribute: attr.Attribute[typing.Any], + field: typing.Any, + ) -> None: ... + return _validator + +def _validate_elements( + attribute: attr.Attribute[typing.Any], + value: typing.Any, + expected_type: typing.Optional[typing.Type[typing.Any]], +) -> None: ... +def _get_base_type( + type_: typing.Type[typing.Any], +) -> typing.Type[typing.Any]: ... +def _type_matching( + actual: typing.Type[typing.Any], expected: typing.Type[typing.Any] +) -> bool: ... +def _handle_callable( + attribute: attr.Attribute[typing.Any], + callable_: typing.Callable[..., typing.Any], + expected_type: typing.Type[typing.Callable[..., typing.Any]], +) -> None: ... +def _handle_set_or_list( + attribute: attr.Attribute[typing.Any], + container: typing.Union[typing.Set[typing.Any], typing.List[typing.Any]], + expected_type: typing.Union[ + typing.Type[typing.Set[typing.Any]], + typing.Type[typing.List[typing.Any]], + ], +) -> None: ... +def _handle_dict( + attribute: attr.Attribute[typing.Any], + container: typing.Union[ + typing.Mapping[typing.Any, typing.Any], + typing.MutableMapping[typing.Any, typing.Any], + ], + expected_type: typing.Union[ + typing.Type[typing.Mapping[typing.Any, typing.Any]], + typing.Type[typing.MutableMapping[typing.Any, typing.Any]], + ], +) -> None: ... +def _handle_tuple( + attribute: attr.Attribute[typing.Any], + container: typing.Tuple[typing.Any], + expected_type: typing.Type[typing.Tuple[typing.Any]], +) -> None: ... +def _handle_union( + attribute: attr.Attribute[typing.Any], + value: typing.Any, + expected_type: typing.Type[typing.Any], +) -> None: ...