diff --git a/.gitignore b/.gitignore index 6c895ee1d..5d5f8c6aa 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ .cache .coverage* .hypothesis +.mypy_cache .pytest_cache .tox build diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index d75c0c6e6..4c206776a 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -4,6 +4,9 @@ repos: hooks: - id: black language_version: python3.6 + # override until resolved: https://github.com/ambv/black/issues/402 + files: \.pyi?$ + types: [] - repo: https://github.com/asottile/seed-isort-config rev: v1.0.1 diff --git a/.travis.yml b/.travis.yml index fe197a07a..302a90021 100644 --- a/.travis.yml +++ b/.travis.yml @@ -46,6 +46,8 @@ matrix: env: TOXENV=readme - python: "3.6" env: TOXENV=changelog + - python: "3.6" + env: TOXENV=typing allow_failures: - python: "3.6-dev" diff --git a/MANIFEST.in b/MANIFEST.in index 15bc51003..47b34c1b5 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -3,6 +3,10 @@ include LICENSE *.rst *.toml .readthedocs.yml .pre-commit-config.yaml # Don't package GitHub-specific files. exclude .github/*.md .travis.yml codecov.yml +# Stubs +include src/attr/py.typed +recursive-include src *.pyi + # Tests include tox.ini .coveragerc conftest.py recursive-include tests *.py diff --git a/setup.py b/setup.py index 2c8c23188..b784cf55f 100644 --- a/setup.py +++ b/setup.py @@ -115,4 +115,5 @@ def find_meta(meta): classifiers=CLASSIFIERS, install_requires=INSTALL_REQUIRES, extras_require=EXTRAS_REQUIRE, + include_package_data=True, ) diff --git a/src/attr/__init__.pyi b/src/attr/__init__.pyi new file mode 100644 index 000000000..c8b4c6585 --- /dev/null +++ b/src/attr/__init__.pyi @@ -0,0 +1,240 @@ +from typing import ( + Any, + Callable, + Dict, + Generic, + List, + Optional, + Sequence, + Mapping, + Tuple, + Type, + TypeVar, + Union, + overload, +) + +# `import X as X` is required to make these public +from . import exceptions as exceptions +from . import filters as filters +from . import converters as converters +from . import validators as validators + +_T = TypeVar("_T") +_C = TypeVar("_C", bound=type) + +_ValidatorType = Callable[[Any, Attribute, _T], Any] +_ConverterType = Callable[[Any], _T] +_FilterType = Callable[[Attribute, Any], bool] +# FIXME: in reality, if multiple validators are passed they must be in a list or tuple, +# but those are invariant and so would prevent subtypes of _ValidatorType from working +# when passed in a list or tuple. +_ValidatorArgType = Union[_ValidatorType[_T], Sequence[_ValidatorType[_T]]] + +# _make -- + +NOTHING: object + +# NOTE: Factory lies about its return type to make this possible: `x: List[int] = Factory(list)` +# Work around mypy issue #4554 in the common case by using an overload. +@overload +def Factory(factory: Callable[[], _T]) -> _T: ... +@overload +def Factory( + factory: Union[Callable[[Any], _T], Callable[[], _T]], + takes_self: bool = ..., +) -> _T: ... + +class Attribute(Generic[_T]): + name: str + default: Optional[_T] + validator: Optional[_ValidatorType[_T]] + repr: bool + cmp: bool + hash: Optional[bool] + init: bool + converter: Optional[_ConverterType[_T]] + metadata: Dict[Any, Any] + type: Optional[Type[_T]] + def __lt__(self, x: Attribute) -> bool: ... + def __le__(self, x: Attribute) -> bool: ... + def __gt__(self, x: Attribute) -> bool: ... + def __ge__(self, x: Attribute) -> bool: ... + +# NOTE: We had several choices for the annotation to use for type arg: +# 1) Type[_T] +# - Pros: works in PyCharm without plugin support +# - Cons: produces less informative error in the case of conflicting TypeVars +# e.g. `attr.ib(default='bad', type=int)` +# 2) Callable[..., _T] +# - Pros: more informative errors than #1 +# - Cons: validator tests results in confusing error. +# e.g. `attr.ib(type=int, validator=validate_str)` +# 3) type (and do all of the work in the mypy plugin) +# - Pros: in mypy, the behavior of type argument is exactly the same as with +# annotations. +# - Cons: completely disables type inspections in PyCharm when using the +# type arg. +# We chose option #1 until either PyCharm adds support for attrs, or python 2 +# reaches EOL. + +# `attr` lies about its return type to make the following possible: +# attr() -> Any +# attr(8) -> int +# attr(validator=) -> Whatever the callable expects. +# This makes this type of assignments possible: +# x: int = attr(8) +# +# This form catches explicit None or no default but with no other arguments returns Any. +@overload +def attrib( + default: None = ..., + validator: None = ..., + repr: bool = ..., + cmp: bool = ..., + hash: Optional[bool] = ..., + init: bool = ..., + convert: None = ..., + metadata: Optional[Mapping[Any, Any]] = ..., + type: None = ..., + converter: None = ..., + factory: None = ..., +) -> Any: ... + +# This form catches an explicit None or no default and infers the type from the other arguments. +@overload +def attrib( + default: None = ..., + validator: Optional[_ValidatorArgType[_T]] = ..., + repr: bool = ..., + cmp: bool = ..., + hash: Optional[bool] = ..., + init: bool = ..., + convert: Optional[_ConverterType[_T]] = ..., + metadata: Optional[Mapping[Any, Any]] = ..., + type: Optional[Type[_T]] = ..., + converter: Optional[_ConverterType[_T]] = ..., + factory: Optional[Callable[[], _T]] = ..., +) -> _T: ... + +# This form catches an explicit default argument. +@overload +def attrib( + default: _T, + validator: Optional[_ValidatorArgType[_T]] = ..., + repr: bool = ..., + cmp: bool = ..., + hash: Optional[bool] = ..., + init: bool = ..., + convert: Optional[_ConverterType[_T]] = ..., + metadata: Optional[Mapping[Any, Any]] = ..., + type: Optional[Type[_T]] = ..., + converter: Optional[_ConverterType[_T]] = ..., + factory: Optional[Callable[[], _T]] = ..., +) -> _T: ... + +# This form covers type=non-Type: e.g. forward references (str), Any +@overload +def attrib( + default: Optional[_T] = ..., + validator: Optional[_ValidatorArgType[_T]] = ..., + repr: bool = ..., + cmp: bool = ..., + hash: Optional[bool] = ..., + init: bool = ..., + convert: Optional[_ConverterType[_T]] = ..., + metadata: Optional[Mapping[Any, Any]] = ..., + type: object = ..., + converter: Optional[_ConverterType[_T]] = ..., + factory: Optional[Callable[[], _T]] = ..., +) -> Any: ... +@overload +def attrs( + maybe_cls: _C, + these: Optional[Dict[str, Any]] = ..., + repr_ns: Optional[str] = ..., + repr: bool = ..., + cmp: bool = ..., + hash: Optional[bool] = ..., + init: bool = ..., + slots: bool = ..., + frozen: bool = ..., + str: bool = ..., + auto_attribs: bool = ..., +) -> _C: ... +@overload +def attrs( + maybe_cls: None = ..., + these: Optional[Dict[str, Any]] = ..., + repr_ns: Optional[str] = ..., + repr: bool = ..., + cmp: bool = ..., + hash: Optional[bool] = ..., + init: bool = ..., + slots: bool = ..., + frozen: bool = ..., + str: bool = ..., + auto_attribs: bool = ..., +) -> Callable[[_C], _C]: ... + +# TODO: add support for returning NamedTuple from the mypy plugin +class _Fields(Tuple[Attribute, ...]): + def __getattr__(self, name: str) -> Attribute: ... + +def fields(cls: type) -> _Fields: ... +def fields_dict(cls: type) -> Dict[str, Attribute]: ... +def validate(inst: Any) -> None: ... + +# TODO: add support for returning a proper attrs class from the mypy plugin +# we use Any instead of _CountingAttr so that e.g. `make_class('Foo', [attr.ib()])` is valid +def make_class( + name: str, + attrs: Union[List[str], Tuple[str, ...], Dict[str, Any]], + bases: Tuple[type, ...] = ..., + repr_ns: Optional[str] = ..., + repr: bool = ..., + cmp: bool = ..., + hash: Optional[bool] = ..., + init: bool = ..., + slots: bool = ..., + frozen: bool = ..., + str: bool = ..., + auto_attribs: bool = ..., +) -> type: ... + +# _funcs -- + +# TODO: add support for returning TypedDict from the mypy plugin +# FIXME: asdict/astuple do not honor their factory args. waiting on one of these: +# https://github.com/python/mypy/issues/4236 +# https://github.com/python/typing/issues/253 +def asdict( + inst: Any, + recurse: bool = ..., + filter: Optional[_FilterType] = ..., + dict_factory: Type[Mapping[Any, Any]] = ..., + retain_collection_types: bool = ..., +) -> Dict[str, Any]: ... + +# TODO: add support for returning NamedTuple from the mypy plugin +def astuple( + inst: Any, + recurse: bool = ..., + filter: Optional[_FilterType] = ..., + tuple_factory: Type[Sequence] = ..., + retain_collection_types: bool = ..., +) -> Tuple[Any, ...]: ... +def has(cls: type) -> bool: ... +def assoc(inst: _T, **changes: Any) -> _T: ... +def evolve(inst: _T, **changes: Any) -> _T: ... + +# _config -- + +def set_run_validators(run: bool) -> None: ... +def get_run_validators() -> bool: ... + +# aliases -- + +s = attributes = attrs +ib = attr = attrib +dataclass = attrs # Technically, partial(attrs, auto_attribs=True) ;) diff --git a/src/attr/converters.pyi b/src/attr/converters.pyi new file mode 100644 index 000000000..bdf7a0c2e --- /dev/null +++ b/src/attr/converters.pyi @@ -0,0 +1,8 @@ +from typing import TypeVar, Optional +from . import _ConverterType + +_T = TypeVar("_T") + +def optional( + converter: _ConverterType[_T] +) -> _ConverterType[Optional[_T]]: ... diff --git a/src/attr/exceptions.pyi b/src/attr/exceptions.pyi new file mode 100644 index 000000000..48fffcc1e --- /dev/null +++ b/src/attr/exceptions.pyi @@ -0,0 +1,7 @@ +class FrozenInstanceError(AttributeError): + msg: str = ... + +class AttrsAttributeNotFoundError(ValueError): ... +class NotAnAttrsClassError(ValueError): ... +class DefaultAlreadySetError(RuntimeError): ... +class UnannotatedAttributeError(RuntimeError): ... diff --git a/src/attr/filters.pyi b/src/attr/filters.pyi new file mode 100644 index 000000000..a618140c2 --- /dev/null +++ b/src/attr/filters.pyi @@ -0,0 +1,5 @@ +from typing import Union +from . import Attribute, _FilterType + +def include(*what: Union[type, Attribute]) -> _FilterType: ... +def exclude(*what: Union[type, Attribute]) -> _FilterType: ... diff --git a/src/attr/py.typed b/src/attr/py.typed new file mode 100644 index 000000000..e69de29bb diff --git a/src/attr/validators.pyi b/src/attr/validators.pyi new file mode 100644 index 000000000..abbaedf10 --- /dev/null +++ b/src/attr/validators.pyi @@ -0,0 +1,14 @@ +from typing import Container, List, Union, TypeVar, Type, Any, Optional, Tuple +from . import _ValidatorType + +_T = TypeVar("_T") + +def instance_of( + type: Union[Tuple[Type[_T], ...], Type[_T]] +) -> _ValidatorType[_T]: ... +def provides(interface: Any) -> _ValidatorType[Any]: ... +def optional( + validator: Union[_ValidatorType[_T], List[_ValidatorType[_T]]] +) -> _ValidatorType[Optional[_T]]: ... +def in_(options: Container[_T]) -> _ValidatorType[_T]: ... +def and_(*validators: _ValidatorType[_T]) -> _ValidatorType[_T]: ... diff --git a/tests/typing_example.py b/tests/typing_example.py new file mode 100644 index 000000000..300ac8728 --- /dev/null +++ b/tests/typing_example.py @@ -0,0 +1,80 @@ +from typing import Any, List + +import attr + + +# Typing via "type" Argument --- + + +@attr.s +class C: + a = attr.ib(type=int) + + +c = C(1) +C(a=1) + + +@attr.s +class D: + x = attr.ib(type=List[int]) + + +@attr.s +class E: + y = attr.ib(type="List[int]") + + +@attr.s +class F: + z = attr.ib(type=Any) + + +# Typing via Annotations --- + + +@attr.s +class CC: + a: int = attr.ib() + + +cc = CC(1) +CC(a=1) + + +@attr.s +class DD: + x: List[int] = attr.ib() + + +@attr.s +class EE: + y: "List[int]" = attr.ib() + + +@attr.s +class FF: + z: Any = attr.ib() + + +# Inheritance -- + + +@attr.s +class GG(DD): + y: str = attr.ib() + + +GG(x=[1], y="foo") + + +@attr.s +class HH(DD, EE): + z: float = attr.ib() + + +HH(x=[1], y=[], z=1.1) + + +# same class +c == cc diff --git a/tox.ini b/tox.ini index 57e7d1467..addf75194 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [tox] -envlist = pre-commit,lint,py27,py34,py35,py36,py37,pypy,pypy3,manifest,docs,readme,changelog,coverage-report +envlist = pre-commit,typing,lint,py27,py34,py35,py36,py37,pypy,pypy3,manifest,docs,readme,changelog,coverage-report [testenv] @@ -86,3 +86,8 @@ basepython = python3.6 deps = towncrier skip_install = true commands = towncrier --draft + +[testenv:typing] +basepython = python3.6 +deps = mypy +commands = mypy tests/typing_example.py