From a804a4d0a81869d4149819ec472c72dc2bd18b50 Mon Sep 17 00:00:00 2001 From: layday Date: Wed, 10 Aug 2022 20:17:39 +0300 Subject: [PATCH 01/11] Fix type errors in Pyright MyPy treats the user-declared `MYPY` constant equivalently to `typing.TYPE_CHECKING` (see https://mypy.readthedocs.io/en/stable/more_types.html#conditional-overloads). This can be used to trick other type checkers into ignoring MyPy-specific types. `AttrsInstance` needs to be declared in a separate stub file so as to not export `MYPY`. --- src/attr/__init__.pyi | 7 +----- src/attr/_typing_compat.pyi | 12 ++++++++++ tests/test_pyright.py | 46 ++++++++++++++++++++++++++++++------- 3 files changed, 51 insertions(+), 14 deletions(-) create mode 100644 src/attr/_typing_compat.pyi diff --git a/src/attr/__init__.pyi b/src/attr/__init__.pyi index 03cc4c82d..2186ce139 100644 --- a/src/attr/__init__.pyi +++ b/src/attr/__init__.pyi @@ -3,13 +3,11 @@ import sys from typing import ( Any, Callable, - ClassVar, Dict, Generic, List, Mapping, Optional, - Protocol, Sequence, Tuple, Type, @@ -26,6 +24,7 @@ from . import setters as setters from . import validators as validators from ._cmp import cmp_using as cmp_using from ._version_info import VersionInfo +from ._typing_compat import AttrsInstance_ as AttrsInstance __version__: str __version_info__: VersionInfo @@ -59,10 +58,6 @@ _FieldTransformer = Callable[ # _ValidatorType from working when passed in a list or tuple. _ValidatorArgType = Union[_ValidatorType[_T], Sequence[_ValidatorType[_T]]] -# A protocol to be able to statically accept an attrs class. -class AttrsInstance(Protocol): - __attrs_attrs__: ClassVar[Any] - # _make -- NOTHING: object diff --git a/src/attr/_typing_compat.pyi b/src/attr/_typing_compat.pyi new file mode 100644 index 000000000..e66ce0fd7 --- /dev/null +++ b/src/attr/_typing_compat.pyi @@ -0,0 +1,12 @@ +from typing import Any, ClassVar, Protocol + +MYPY = False + +# A protocol to be able to statically accept an attrs class. +class AttrsInstance(Protocol): + __attrs_attrs__: ClassVar[Any] + +if MYPY: + AttrsInstance_ = AttrsInstance +else: + AttrsInstance_ = Any diff --git a/tests/test_pyright.py b/tests/test_pyright.py index eddb31ae9..987693237 100644 --- a/tests/test_pyright.py +++ b/tests/test_pyright.py @@ -11,6 +11,7 @@ _found_pyright = shutil.which("pyright") +pytestmark = pytest.mark.skipif(not _found_pyright, reason="Requires pyright.") @attr.s(frozen=True) @@ -19,14 +20,7 @@ class PyrightDiagnostic: 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" - +def parse_pyright_output(test_file): pyright = subprocess.run( ["pyright", "--outputjson", str(test_file)], capture_output=True ) @@ -37,6 +31,17 @@ def test_pyright_baseline(): for d in pyright_result["generalDiagnostics"] } + return diagnostics + + +def test_pyright_baseline(): + """The __dataclass_transform__ decorator allows pyright to determine + attrs decorated class types. + """ + diagnostics = parse_pyright_output( + os.path.dirname(__file__) + "/dataclass_transform_example.py" + ) + # Expected diagnostics as per pyright 1.1.135 expected_diagnostics = { PyrightDiagnostic( @@ -65,3 +70,28 @@ def test_pyright_baseline(): } assert diagnostics == expected_diagnostics + + +def test_pyright_attrsinstance_is_any(tmp_path): + """ + Test that `AttrsInstance` is `Any` under Pyright. + """ + test_pyright_attrsinstance_is_any_path = ( + tmp_path / "test_pyright_attrsinstance_is_any.py" + ) + test_pyright_attrsinstance_is_any_path.write_text( + """\ +import attrs + +reveal_type(attrs.AttrsInstance) +""" + ) + + diagnostics = parse_pyright_output(test_pyright_attrsinstance_is_any_path) + expected_diagnostics = { + PyrightDiagnostic( + severity="information", + message='Type of "attrs.AttrsInstance" is "Any"', + ), + } + assert diagnostics == expected_diagnostics From add19e53c9f888fbb24aaada5344ad85026b94ed Mon Sep 17 00:00:00 2001 From: layday Date: Wed, 10 Aug 2022 20:31:47 +0300 Subject: [PATCH 02/11] Fix `AttrsInstance` re-export `x as y` only works if x and y are the same. --- src/attr/__init__.pyi | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/attr/__init__.pyi b/src/attr/__init__.pyi index 2186ce139..7cc2e91ac 100644 --- a/src/attr/__init__.pyi +++ b/src/attr/__init__.pyi @@ -24,7 +24,9 @@ from . import setters as setters from . import validators as validators from ._cmp import cmp_using as cmp_using from ._version_info import VersionInfo -from ._typing_compat import AttrsInstance_ as AttrsInstance +from ._typing_compat import AttrsInstance_ + +AttrsInstance = AttrsInstance_ __version__: str __version_info__: VersionInfo From 8ed30cffd1e9e56b4c6235a1c487957545f51bec Mon Sep 17 00:00:00 2001 From: layday Date: Mon, 15 Aug 2022 17:15:20 +0300 Subject: [PATCH 03/11] Type check `_typing_compat` --- tox.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tox.ini b/tox.ini index b518bd19e..506f99080 100644 --- a/tox.ini +++ b/tox.ini @@ -93,7 +93,7 @@ commands = towncrier build --version UNRELEASED --draft basepython = python3.10 deps = mypy>=0.902 commands = - mypy src/attrs/__init__.pyi 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 src/attrs/__init__.pyi src/attr/__init__.pyi src/attr/_typing_compat.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 From ce67df183c5fcdbb2895461d7e119be338ebae10 Mon Sep 17 00:00:00 2001 From: layday Date: Fri, 19 Aug 2022 10:47:06 +0300 Subject: [PATCH 04/11] Conditionally define the protocol instead --- src/attr/__init__.pyi | 7 +++++-- src/attr/_typing_compat.pyi | 12 ++++++------ tests/test_pyright.py | 3 ++- 3 files changed, 13 insertions(+), 9 deletions(-) diff --git a/src/attr/__init__.pyi b/src/attr/__init__.pyi index 126db1ac0..600dd613a 100644 --- a/src/attr/__init__.pyi +++ b/src/attr/__init__.pyi @@ -8,6 +8,7 @@ from typing import ( List, Mapping, Optional, + Protocol, Sequence, Tuple, Type, @@ -26,8 +27,6 @@ from ._cmp import cmp_using as cmp_using from ._version_info import VersionInfo from ._typing_compat import AttrsInstance_ -AttrsInstance = AttrsInstance_ - if sys.version_info >= (3, 10): from typing import TypeGuard else: @@ -65,6 +64,10 @@ _FieldTransformer = Callable[ # _ValidatorType from working when passed in a list or tuple. _ValidatorArgType = Union[_ValidatorType[_T], Sequence[_ValidatorType[_T]]] +# We subclass this here to keep the protocol's qualified name clean. +class AttrsInstance(AttrsInstance_, Protocol): + pass + # _make -- NOTHING: object diff --git a/src/attr/_typing_compat.pyi b/src/attr/_typing_compat.pyi index e66ce0fd7..fb6c8ac48 100644 --- a/src/attr/_typing_compat.pyi +++ b/src/attr/_typing_compat.pyi @@ -2,11 +2,11 @@ from typing import Any, ClassVar, Protocol MYPY = False -# A protocol to be able to statically accept an attrs class. -class AttrsInstance(Protocol): - __attrs_attrs__: ClassVar[Any] - if MYPY: - AttrsInstance_ = AttrsInstance + # A protocol to be able to statically accept an attrs class. + class AttrsInstance_(Protocol): + __attrs_attrs__: ClassVar[Any] + else: - AttrsInstance_ = Any + class AttrsInstance_(Protocol): + pass diff --git a/tests/test_pyright.py b/tests/test_pyright.py index 7dc8ee13f..9b7945117 100644 --- a/tests/test_pyright.py +++ b/tests/test_pyright.py @@ -91,6 +91,7 @@ def test_pyright_attrsinstance_is_any(tmp_path): """\ import attrs +foo: attrs.AttrsInstance = object() # We can assign any old object to `AttrsInstance`. reveal_type(attrs.AttrsInstance) """ ) @@ -99,7 +100,7 @@ def test_pyright_attrsinstance_is_any(tmp_path): expected_diagnostics = { PyrightDiagnostic( severity="information", - message='Type of "attrs.AttrsInstance" is "Any"', + message='Type of "attrs.AttrsInstance" is "Type[AttrsInstance]"', ), } assert diagnostics == expected_diagnostics From f52783d53b97d34bc5bdf49652bedfe63472da51 Mon Sep 17 00:00:00 2001 From: layday Date: Fri, 19 Aug 2022 11:01:56 +0300 Subject: [PATCH 05/11] Rename test case --- tests/test_pyright.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/tests/test_pyright.py b/tests/test_pyright.py index 9b7945117..0c53268cf 100644 --- a/tests/test_pyright.py +++ b/tests/test_pyright.py @@ -80,14 +80,14 @@ def test_pyright_baseline(): assert diagnostics == expected_diagnostics -def test_pyright_attrsinstance_is_any(tmp_path): +def test_pyright_attrsinstance_compat(tmp_path): """ - Test that `AttrsInstance` is `Any` under Pyright. + Test that `AttrsInstance` is compatible with Pyright. """ - test_pyright_attrsinstance_is_any_path = ( - tmp_path / "test_pyright_attrsinstance_is_any.py" + test_pyright_attrsinstance_compat_path = ( + tmp_path / "test_pyright_attrsinstance_compat.py" ) - test_pyright_attrsinstance_is_any_path.write_text( + test_pyright_attrsinstance_compat_path.write_text( """\ import attrs @@ -96,7 +96,7 @@ def test_pyright_attrsinstance_is_any(tmp_path): """ ) - diagnostics = parse_pyright_output(test_pyright_attrsinstance_is_any_path) + diagnostics = parse_pyright_output(test_pyright_attrsinstance_compat_path) expected_diagnostics = { PyrightDiagnostic( severity="information", From c64057aacbcad151d0ec7a2ce0304fffa0ee36a2 Mon Sep 17 00:00:00 2001 From: layday Date: Fri, 19 Aug 2022 11:02:02 +0300 Subject: [PATCH 06/11] Add inline comments --- src/attr/_typing_compat.pyi | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/attr/_typing_compat.pyi b/src/attr/_typing_compat.pyi index fb6c8ac48..ca7b71e90 100644 --- a/src/attr/_typing_compat.pyi +++ b/src/attr/_typing_compat.pyi @@ -1,5 +1,6 @@ from typing import Any, ClassVar, Protocol +# MYPY is a special constant in mypy which works the same way as `TYPE_CHECKING`. MYPY = False if MYPY: @@ -8,5 +9,7 @@ if MYPY: __attrs_attrs__: ClassVar[Any] else: + # For type checkers without plug-in support use an empty protocol that + # will (hopefully) be combined into a union. class AttrsInstance_(Protocol): pass From 66309097cf57cf96cc338a42a04fbb334b68a5af Mon Sep 17 00:00:00 2001 From: layday Date: Mon, 22 Aug 2022 14:41:15 +0300 Subject: [PATCH 07/11] Lint --- tests/test_pyright.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/test_pyright.py b/tests/test_pyright.py index 0c53268cf..44b308906 100644 --- a/tests/test_pyright.py +++ b/tests/test_pyright.py @@ -91,7 +91,9 @@ def test_pyright_attrsinstance_compat(tmp_path): """\ import attrs -foo: attrs.AttrsInstance = object() # We can assign any old object to `AttrsInstance`. +# We can assign any old object to `AttrsInstance`. +foo: attrs.AttrsInstance = object() + reveal_type(attrs.AttrsInstance) """ ) From 6240fb5e1e797533a847bcd7b639370c640b20f5 Mon Sep 17 00:00:00 2001 From: layday Date: Tue, 30 Aug 2022 09:34:51 +0300 Subject: [PATCH 08/11] Add runtime copy of `AttrsInstance` --- src/attr/__init__.py | 6 ++++++ src/attrs/__init__.py | 1 + 2 files changed, 7 insertions(+) diff --git a/src/attr/__init__.py b/src/attr/__init__.py index c3f0937e7..33b663e5e 100644 --- a/src/attr/__init__.py +++ b/src/attr/__init__.py @@ -52,8 +52,14 @@ ib = attr = attrib dataclass = partial(attrs, auto_attribs=True) # happy Easter ;) + +class AttrsInstance: + pass + + __all__ = [ "Attribute", + "AttrsInstance", "Factory", "NOTHING", "asdict", diff --git a/src/attrs/__init__.py b/src/attrs/__init__.py index a704b8b56..be09c0977 100644 --- a/src/attrs/__init__.py +++ b/src/attrs/__init__.py @@ -3,6 +3,7 @@ from attr import ( NOTHING, Attribute, + AttrsInstance, Factory, __author__, __copyright__, From 80464b23aabf57855231c662d83c121b322561dd Mon Sep 17 00:00:00 2001 From: layday Date: Tue, 30 Aug 2022 09:34:56 +0300 Subject: [PATCH 09/11] Sort imports --- src/attr/__init__.pyi | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/attr/__init__.pyi b/src/attr/__init__.pyi index 600dd613a..3e6acb446 100644 --- a/src/attr/__init__.pyi +++ b/src/attr/__init__.pyi @@ -24,8 +24,8 @@ from . import filters as filters from . import setters as setters from . import validators as validators from ._cmp import cmp_using as cmp_using -from ._version_info import VersionInfo from ._typing_compat import AttrsInstance_ +from ._version_info import VersionInfo if sys.version_info >= (3, 10): from typing import TypeGuard From fda451ee9799943939d7f484e36e008a66d9eeea Mon Sep 17 00:00:00 2001 From: layday Date: Tue, 30 Aug 2022 09:35:49 +0300 Subject: [PATCH 10/11] fixup! Add runtime copy of `AttrsInstance` --- src/attrs/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/attrs/__init__.py b/src/attrs/__init__.py index be09c0977..81dd6b2f0 100644 --- a/src/attrs/__init__.py +++ b/src/attrs/__init__.py @@ -49,6 +49,7 @@ "assoc", "astuple", "Attribute", + "AttrsInstance", "cmp_using", "converters", "define", From 36530d0eb1ca8c16ab5afb668bb7b8994ba6326a Mon Sep 17 00:00:00 2001 From: layday Date: Tue, 30 Aug 2022 13:27:24 +0300 Subject: [PATCH 11/11] Add news fragment --- changelog.d/999.change.rst | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 changelog.d/999.change.rst diff --git a/changelog.d/999.change.rst b/changelog.d/999.change.rst new file mode 100644 index 000000000..977483386 --- /dev/null +++ b/changelog.d/999.change.rst @@ -0,0 +1,2 @@ +Made ``attrs.AttrsInstance`` stub available at runtime and fixed type errors +related to the usage of ``attrs.AttrsInstance`` in Pyright.