Skip to content

Commit

Permalink
Add type stubs and py.typed. Add type checking to tox
Browse files Browse the repository at this point in the history
Signed-off-by: Wilfred Wong <[email protected]>
  • Loading branch information
keiclone committed May 26, 2020
1 parent 0bc0742 commit fd0f1d3
Show file tree
Hide file tree
Showing 11 changed files with 201 additions and 30 deletions.
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ __pycache__
/.vscode

# tools
/.*_cache
.*_cache


pip-wheel-metadata
3 changes: 3 additions & 0 deletions MANIFEST.in
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
include attrs_strict/py.typed
recursive-include attrs_strict *.pyi
recursive-include attrs_strict *.py
42 changes: 29 additions & 13 deletions attrs_strict/_error.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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)

Expand Down
39 changes: 23 additions & 16 deletions attrs_strict/_type_validation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand All @@ -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__
Expand Down Expand Up @@ -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(
Expand All @@ -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:
Expand All @@ -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:
Expand All @@ -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)
Expand Down
Empty file added attrs_strict/py.typed
Empty file.
2 changes: 2 additions & 0 deletions setup.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import os
import textwrap

from setuptools import setup
Expand All @@ -22,6 +23,7 @@
author_email="[email protected]",
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",
Expand Down
22 changes: 22 additions & 0 deletions tox.ini
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,10 @@ envlist =
py38,
pypy,
pypy3,
mypy27
mypy37,
coverage,
merge,
fix_lint,
docs,
package_description,
Expand All @@ -32,6 +35,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:mypy37]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
Expand Down
2 changes: 2 additions & 0 deletions types/__init__.pyi
Original file line number Diff line number Diff line change
@@ -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
4 changes: 4 additions & 0 deletions types/_commons.pyi
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import typing

def is_newtype(type_: typing.Type[typing.Any]) -> bool: ...
def format_type(type_: typing.Type[typing.Any]) -> str: ...
54 changes: 54 additions & 0 deletions types/_error.pyi
Original file line number Diff line number Diff line change
@@ -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: ...
60 changes: 60 additions & 0 deletions types/_type_validation.pyi
Original file line number Diff line number Diff line change
@@ -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: ...

0 comments on commit fd0f1d3

Please sign in to comment.