diff --git a/changelog.d/796.change.rst b/changelog.d/796.change.rst new file mode 100644 index 00000000..595e449b --- /dev/null +++ b/changelog.d/796.change.rst @@ -0,0 +1,4 @@ +``attrs`` has added provisional support for static typing in ``pyright`` version 1.1.135 via the `dataclass_transforms specification `. +Both the ``pyright`` specification and ``attrs`` implementation may change in future versions of both projects. + +Your constructive feedback is welcome in both `attrs#795 `_ and `pyright#1782 `_. diff --git a/docs/extending.rst b/docs/extending.rst index 25765ee6..8a6767cf 100644 --- a/docs/extending.rst +++ b/docs/extending.rst @@ -46,7 +46,10 @@ An example for that is the package `environ-config `_ mypy's ``attrs`` plugin. +Mypy +************ + +Unfortunately, decorator wrapping currently `confuses `_ mypy's ``attrs`` plugin. At the moment, the best workaround is to hold your nose, write a fake mypy plugin, and mutate a bunch of global variables:: from mypy.plugin import Plugin @@ -86,6 +89,34 @@ Then tell mypy about your plugin using your project's ``mypy.ini``: Please note that it is currently *impossible* to let mypy know that you've changed defaults like *eq* or *order*. You can only use this trick to tell mypy that a class is actually an ``attrs`` class. +Pyright +************* + +Generic decorator wrapping is supported in ``pyright`` via the provisional dataclass_transform_ specification. + +For a custom wrapping of the form:: + + def custom_define(f): + return attr.define(f) + +This is implemented via a ``__dataclass_transform__`` type decorator in the custom extension's ``.pyi`` of the form:: + + def __dataclass_transform__( + *, + eq_default: bool = True, + order_default: bool = False, + kw_only_default: bool = False, + field_descriptors: Tuple[Union[type, Callable[..., Any]], ...] = (()), + ) -> Callable[[_T], _T]: ... + + @__dataclass_transform__(field_descriptors=(attr.attrib, attr.field)) + def custom_define(f): ... + +.. note:: + + ``dataclass_transform`` is supported provisionally as of ``pyright`` 1.1.135. + + Both the ``pyright`` dataclass_transform_ specification and ``attrs`` implementation may changed in future versions. Types ----- @@ -272,3 +303,7 @@ It has the signature {'dt': '2020-05-04T13:37:00'} >>> json.dumps(data) '{"dt": "2020-05-04T13:37:00"}' + +***** + +.. _dataclass_transform: https://github.com/microsoft/pyright/blob/1.1.135/specs/dataclass_transforms.md diff --git a/docs/types.rst b/docs/types.rst index 78290975..0b57e1a7 100644 --- a/docs/types.rst +++ b/docs/types.rst @@ -41,8 +41,11 @@ Also, starting in Python 3.10 (:pep:`526`) **all** annotations will be string li When this happens, ``attrs`` will simply put these string literals into the ``type`` attributes. If you need to resolve these to real types, you can call `attr.resolve_types` which will update the attribute in place. -In practice though, types show their biggest usefulness in combination with tools like mypy_ or pytype_ that both have dedicated support for ``attrs`` classes. +In practice though, types show their biggest usefulness in combination with tools like mypy_, pytype_ or pyright_ that have dedicated support for ``attrs`` classes. +The addition of static types is certainly one of the most exciting features in the Python ecosystem and helps you writing *correct* and *verified self-documenting* code. + +If you don't know where to start, Carl Meyer gave a great talk on `Type-checked Python in the Real World `_ at PyCon US 2018 that will help you to get started in no time. mypy ---- @@ -69,12 +72,38 @@ To mypy, this code is equivalent to the one above: a_number = attr.ib(default=42) # type: int list_of_numbers = attr.ib(factory=list, type=typing.List[int]) + +pyright +------- + +``attrs`` provides support for pyright_ though the dataclass_transform_ specification. +This provides static type inference for a subset of ``attrs`` equivalent to standard-library ``dataclasses``, +and requires explicit type annotations using the :ref:`next-gen` or ``@attr.s(auto_attribs=True)`` API. + +Given the following definition, ``pyright`` will generate static type signatures for ``SomeClass`` attribute access, ``__init__``, ``__eq__``, and comparison methods:: + + @attr.define + class SomeClass(object): + a_number: int = 42 + list_of_numbers: typing.List[int] = attr.field(factory=list) + +.. note:: + + ``dataclass_transform``-based types are supported provisionally as of ``pyright`` 1.1.135 and ``attrs`` 21.1. + Both the ``pyright`` dataclass_transform_ specification and ``attrs`` implementation may changed in future versions. + + The ``pyright`` inferred types are a subset of those supported by ``mypy``, including: + + - The generated ``__init__`` signature only includes the attribute type annotations, + and does not include attribute ``converter`` types. + + - The ``attr.frozen`` decorator is not typed with frozen attributes, which are properly typed via ``attr.define(frozen=True)``. + + Your constructive feedback is welcome in both `attrs#795 `_ and `pyright#1782 `_. + ***** -The addition of static types is certainly one of the most exciting features in the Python ecosystem and helps you writing *correct* and *verified self-documenting* code. - -If you don't know where to start, Carl Meyer gave a great talk on `Type-checked Python in the Real World `_ at PyCon US 2018 that will help you to get started in no time. - - .. _mypy: http://mypy-lang.org .. _pytype: https://google.github.io/pytype/ +.. _pyright: https://github.com/microsoft/pyright +.. _dataclass_transform: https://github.com/microsoft/pyright/blob/1.1.135/specs/dataclass_transforms.md diff --git a/src/attr/__init__.pyi b/src/attr/__init__.pyi index cdcd024a..3503b073 100644 --- a/src/attr/__init__.pyi +++ b/src/attr/__init__.pyi @@ -86,6 +86,21 @@ else: takes_self: bool = ..., ) -> _T: ... +# Static type inference support via __dataclass_transform__ implemented as per: +# https://github.com/microsoft/pyright/blob/1.1.135/specs/dataclass_transforms.md +# This annotation must be applied to all overloads of "define" and "attrs" +# +# NOTE: This is a typing construct and does not exist at runtime. Extensions +# wrapping attrs decorators should declare a separate __dataclass_transform__ +# signature in the extension module using the specification linked above to +# provide pyright support. +def __dataclass_transform__( + *, + eq_default: bool = True, + order_default: bool = False, + kw_only_default: bool = False, + field_descriptors: Tuple[Union[type, Callable[..., Any]], ...] = (()), +) -> Callable[[_T], _T]: ... class Attribute(Generic[_T]): name: str @@ -276,6 +291,7 @@ def field( on_setattr: Optional[_OnSetAttrArgType] = ..., ) -> Any: ... @overload +@__dataclass_transform__(order_default=True, field_descriptors=(attrib, field)) def attrs( maybe_cls: _C, these: Optional[Dict[str, Any]] = ..., @@ -301,6 +317,7 @@ def attrs( field_transformer: Optional[_FieldTransformer] = ..., ) -> _C: ... @overload +@__dataclass_transform__(order_default=True, field_descriptors=(attrib, field)) def attrs( maybe_cls: None = ..., these: Optional[Dict[str, Any]] = ..., @@ -326,6 +343,7 @@ def attrs( field_transformer: Optional[_FieldTransformer] = ..., ) -> Callable[[_C], _C]: ... @overload +@__dataclass_transform__(field_descriptors=(attrib, field)) def define( maybe_cls: _C, *, @@ -349,6 +367,7 @@ def define( field_transformer: Optional[_FieldTransformer] = ..., ) -> _C: ... @overload +@__dataclass_transform__(field_descriptors=(attrib, field)) def define( maybe_cls: None = ..., *, diff --git a/tests/dataclass_transform_example.py b/tests/dataclass_transform_example.py new file mode 100644 index 00000000..f2e949d9 --- /dev/null +++ b/tests/dataclass_transform_example.py @@ -0,0 +1,43 @@ +import attr + + +@attr.define() +class Define: + a: str + b: int + + +reveal_type(Define.__init__) # noqa + + +@attr.define() +class DefineConverter: + # mypy plugin adapts the "int" method signature, pyright does not + with_converter: int = attr.field(converter=int) + + +reveal_type(DefineConverter.__init__) # noqa + + +# mypy plugin supports attr.frozen, pyright does not +@attr.frozen() +class Frozen: + a: str + + +d = Frozen("a") +d.a = "new" + +reveal_type(d.a) # noqa + + +# but pyright supports attr.define(frozen) +@attr.define(frozen=True) +class FrozenDefine: + a: str + + +d2 = FrozenDefine("a") +d2.a = "new" + +reveal_type(d2.a) # noqa diff --git a/tests/test_pyright.py b/tests/test_pyright.py new file mode 100644 index 00000000..60aabe78 --- /dev/null +++ b/tests/test_pyright.py @@ -0,0 +1,69 @@ +import json +import os.path +import shutil +import subprocess +import sys + +import pytest + +import attr + + +if sys.version_info < (3, 6): + _found_pyright = False +else: + _found_pyright = shutil.which("pyright") + + +@attr.s(frozen=True) +class PyrightDiagnostic(object): + severity = attr.ib() + message = attr.ib() + + +@pytest.mark.skipif(not _found_pyright, reason="Requires pyright.") +def test_pyright_baseline(): + """The __dataclass_transform__ decorator allows pyright to determine + attrs decorated class types. + """ + + test_file = os.path.dirname(__file__) + "/dataclass_transform_example.py" + + pyright = subprocess.run( + ["pyright", "--outputjson", str(test_file)], capture_output=True + ) + pyright_result = json.loads(pyright.stdout) + + diagnostics = set( + PyrightDiagnostic(d["severity"], d["message"]) + for d in pyright_result["generalDiagnostics"] + ) + + # Expected diagnostics as per pyright 1.1.135 + expected_diagnostics = { + PyrightDiagnostic( + severity="information", + message='Type of "Define.__init__" is' + ' "(self: Define, a: str, b: int) -> None"', + ), + PyrightDiagnostic( + severity="information", + message='Type of "DefineConverter.__init__" is ' + '"(self: DefineConverter, with_converter: int) -> None"', + ), + PyrightDiagnostic( + severity="information", + message='Type of "d.a" is "Literal[\'new\']"', + ), + PyrightDiagnostic( + severity="error", + message='Cannot assign member "a" for type ' + '"FrozenDefine"\n\xa0\xa0"FrozenDefine" is frozen', + ), + PyrightDiagnostic( + severity="information", + message='Type of "d2.a" is "Literal[\'new\']"', + ), + } + + assert diagnostics == expected_diagnostics diff --git a/tox.ini b/tox.ini index 850b75a4..ad201936 100644 --- a/tox.ini +++ b/tox.ini @@ -15,14 +15,14 @@ python = 3.6: py36 3.7: py37, docs 3.8: py38, lint, manifest, typing, changelog - 3.9: py39 + 3.9: py39, pyright 3.10: py310 pypy2: pypy2 pypy3: pypy3 [tox] -envlist = typing,lint,py27,py35,py36,py37,py38,py39,py310,pypy,pypy3,manifest,docs,pypi-description,changelog,coverage-report +envlist = typing,lint,py27,py35,py36,py37,py38,py39,py310,pypy,pypy3,pyright,manifest,docs,pypi-description,changelog,coverage-report isolated_build = True @@ -121,3 +121,17 @@ deps = mypy>=0.800 commands = mypy src/attr/__init__.pyi src/attr/_version_info.pyi src/attr/converters.pyi src/attr/exceptions.pyi src/attr/filters.pyi src/attr/setters.pyi src/attr/validators.pyi mypy tests/typing_example.py + + +[testenv:pyright] +# Install and configure node and pyright +# This *could* be folded into a custom install_command +# Use nodeenv to configure node in the running tox virtual environment +# Seeing errors using "nodeenv -p" +# Use npm install -g to install "globally" into the virtual environment +basepython = python3.9 +deps = nodeenv +commands = + nodeenv --prebuilt --node=lts --force {envdir} + npm install -g --no-package-lock --no-save pyright@1.1.135 + pytest tests/test_pyright.py -vv