From 5ecc39749a98c7ec3fc63b8cbaa82de5eb17c173 Mon Sep 17 00:00:00 2001 From: Hynek Schlawack Date: Tue, 16 Aug 2022 12:15:10 +0200 Subject: [PATCH 1/6] Rename typing to mypy since we also have pyright (#1007) --- tox.ini | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/tox.ini b/tox.ini index 63ec88b64..cca59277b 100644 --- a/tox.ini +++ b/tox.ini @@ -1,17 +1,17 @@ # Keep docs in sync with docs env and .readthedocs.yml. [gh-actions] python = - 3.6: py36 + 3.6: py36, mypy 3.7: py37 3.8: py38, changelog 3.9: py39, pyright - 3.10: py310, manifest, typing, docs + 3.10: py310, manifest, mypy, docs 3.11: py311 pypy-3: pypy3 [tox] -envlist = typing,pre-commit,py36,py37,py38,py39,py310,py311,pypy3,pyright,manifest,docs,pypi-description,changelog,coverage-report +envlist = mypy,pre-commit,py36,py37,py38,py39,py310,py311,pypy3,pyright,manifest,docs,pypi-description,changelog,coverage-report isolated_build = True @@ -89,8 +89,7 @@ skip_install = true commands = towncrier build --version UNRELEASED --draft -[testenv:typing] -basepython = python3.10 +[testenv:mypy] 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 From 89a890a3262273514619d1881c6a8b0fe26c66ac Mon Sep 17 00:00:00 2001 From: Nikola Dipanov Date: Thu, 18 Aug 2022 07:59:50 +0100 Subject: [PATCH 2/6] Change __getstate__ and __setstate__ to use a dict (#1004) (#1009) Co-authored-by: Nikola Dipanov --- src/attr/_make.py | 7 +++--- tests/test_slots.py | 57 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 61 insertions(+), 3 deletions(-) diff --git a/src/attr/_make.py b/src/attr/_make.py index bc296c648..bd666cc35 100644 --- a/src/attr/_make.py +++ b/src/attr/_make.py @@ -922,7 +922,7 @@ def slots_getstate(self): """ Automatically created by attrs. """ - return tuple(getattr(self, name) for name in state_attr_names) + return {name: getattr(self, name) for name in state_attr_names} hash_caching_enabled = self._cache_hash @@ -931,8 +931,9 @@ def slots_setstate(self, state): Automatically created by attrs. """ __bound_setattr = _obj_setattr.__get__(self) - for name, value in zip(state_attr_names, state): - __bound_setattr(name, value) + for name in state_attr_names: + if name in state: + __bound_setattr(name, state[name]) # The hash code cache is not included when the object is # serialized, but it still needs to be initialized to None to diff --git a/tests/test_slots.py b/tests/test_slots.py index 6a1776440..2c561ad61 100644 --- a/tests/test_slots.py +++ b/tests/test_slots.py @@ -9,6 +9,8 @@ import types import weakref +from unittest import mock + import pytest import attr @@ -743,3 +745,58 @@ def f(self): assert B(11).f == 121 assert B(17).f == 289 + + +@attr.s(slots=True) +class A: + x = attr.ib() + b = attr.ib() + c = attr.ib() + + +@pytest.mark.parametrize("cls", [A]) +def test_slots_unpickle_after_attr_removed(cls): + """ + We don't assign attributes we don't have anymore if the class has + removed it. + """ + a = cls(1, 2, 3) + a_pickled = pickle.dumps(a) + a_unpickled = pickle.loads(a_pickled) + assert a_unpickled == a + + @attr.s(slots=True) + class NEW_A: + x = attr.ib() + c = attr.ib() + + with mock.patch(f"{__name__}.A", NEW_A): + new_a = pickle.loads(a_pickled) + assert new_a.x == 1 + assert new_a.c == 3 + assert not hasattr(new_a, "b") + + +@pytest.mark.parametrize("cls", [A]) +def test_slots_unpickle_after_attr_added(cls): + """ + We don't assign attribute we haven't had before if the class has one added. + """ + a = cls(1, 2, 3) + a_pickled = pickle.dumps(a) + a_unpickled = pickle.loads(a_pickled) + assert a_unpickled == a + + @attr.s(slots=True) + class NEW_A: + x = attr.ib() + b = attr.ib() + d = attr.ib() + c = attr.ib() + + with mock.patch(f"{__name__}.A", NEW_A): + new_a = pickle.loads(a_pickled) + assert new_a.x == 1 + assert new_a.b == 2 + assert new_a.c == 3 + assert not hasattr(new_a, "d") From 19f41dda6fefeb9ab099e700f0bf377a67c26275 Mon Sep 17 00:00:00 2001 From: Hynek Schlawack Date: Thu, 18 Aug 2022 09:01:32 +0200 Subject: [PATCH 3/6] Add news fragement for #1009 --- changelog.d/1009.change.rst | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 changelog.d/1009.change.rst diff --git a/changelog.d/1009.change.rst b/changelog.d/1009.change.rst new file mode 100644 index 000000000..c9982c71f --- /dev/null +++ b/changelog.d/1009.change.rst @@ -0,0 +1,2 @@ +``attrs``'s pickling methods now use dicts instead of tuples. +That is safer and more robust across differnt versions of a class. From d0b940c9b5e53114ade75531e9340ac4d22f24bb Mon Sep 17 00:00:00 2001 From: Hynek Schlawack Date: Thu, 18 Aug 2022 09:03:24 +0200 Subject: [PATCH 4/6] pre-commit autoupdate --- .pre-commit-config.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 20c8761e4..954410b7e 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -26,12 +26,12 @@ repos: files: \.py$ - repo: https://github.com/asottile/yesqa - rev: v1.3.0 + rev: v1.4.0 hooks: - id: yesqa - repo: https://github.com/PyCQA/flake8 - rev: 5.0.2 + rev: 5.0.4 hooks: - id: flake8 From cb047e14107db386a2e8925652f5b9dc8bd556b1 Mon Sep 17 00:00:00 2001 From: Hynek Schlawack Date: Thu, 18 Aug 2022 09:06:19 +0200 Subject: [PATCH 5/6] Parametrize pickle tests with frozen --- tests/test_slots.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/tests/test_slots.py b/tests/test_slots.py index 2c561ad61..0ee648d1e 100644 --- a/tests/test_slots.py +++ b/tests/test_slots.py @@ -772,22 +772,24 @@ class NEW_A: with mock.patch(f"{__name__}.A", NEW_A): new_a = pickle.loads(a_pickled) + assert new_a.x == 1 assert new_a.c == 3 assert not hasattr(new_a, "b") @pytest.mark.parametrize("cls", [A]) -def test_slots_unpickle_after_attr_added(cls): +def test_slots_unpickle_after_attr_added(cls, frozen): """ We don't assign attribute we haven't had before if the class has one added. """ a = cls(1, 2, 3) a_pickled = pickle.dumps(a) a_unpickled = pickle.loads(a_pickled) + assert a_unpickled == a - @attr.s(slots=True) + @attr.s(slots=True, frozen=frozen) class NEW_A: x = attr.ib() b = attr.ib() @@ -796,6 +798,7 @@ class NEW_A: with mock.patch(f"{__name__}.A", NEW_A): new_a = pickle.loads(a_pickled) + assert new_a.x == 1 assert new_a.b == 2 assert new_a.c == 3 From 49ec7317c87c4f36f19588f7a4093616abdefd0b Mon Sep 17 00:00:00 2001 From: Hynek Schlawack Date: Thu, 18 Aug 2022 10:25:00 +0200 Subject: [PATCH 6/6] Protect pyright against 3.7 & prepare for #999 Co-authored-by: layday --- tests/test_pyright.py | 41 +++++++++++++++++++++++++++-------------- 1 file changed, 27 insertions(+), 14 deletions(-) diff --git a/tests/test_pyright.py b/tests/test_pyright.py index eddb31ae9..3cc1fa79b 100644 --- a/tests/test_pyright.py +++ b/tests/test_pyright.py @@ -4,39 +4,52 @@ import os.path import shutil import subprocess +import sys import pytest -import attr +import attrs -_found_pyright = shutil.which("pyright") +pytestmark = [ + pytest.mark.skipif( + sys.version_info < (3, 7), reason="Requires Python 3.7+." + ), + pytest.mark.skipif( + shutil.which("pyright") is None, reason="Requires pyright." + ), +] -@attr.s(frozen=True) +@attrs.frozen class PyrightDiagnostic: - severity = attr.ib() - message = attr.ib() + severity: str + message: str -@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 ) + pyright_result = json.loads(pyright.stdout) - diagnostics = { + return { PyrightDiagnostic(d["severity"], d["message"]) for d in pyright_result["generalDiagnostics"] } + +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" + + diagnostics = parse_pyright_output(test_file) + # Expected diagnostics as per pyright 1.1.135 expected_diagnostics = { PyrightDiagnostic(