From a8e24b018608e810c8729b3c1035737797e62fab Mon Sep 17 00:00:00 2001 From: Hynek Schlawack Date: Thu, 29 Aug 2024 17:35:04 +0200 Subject: [PATCH] Drop Python 3.7 (#1340) * Drop Python 3.7 * Add news fragment * update Ruff --- .github/workflows/ci.yml | 3 +-- .pre-commit-config.yaml | 2 +- changelog.d/1340.breaking.md | 1 + pyproject.toml | 5 ++--- src/attr/__init__.py | 8 ++------ src/attr/__init__.pyi | 35 +++++++++++++---------------------- src/attr/_compat.py | 9 --------- src/attr/_make.py | 18 ++++++------------ tests/strategies.py | 6 +----- tests/test_compat.py | 4 +++- tests/test_make.py | 3 +-- tests/test_packaging.py | 9 ++------- tests/test_slots.py | 20 +------------------- tox.ini | 6 +++--- 14 files changed, 37 insertions(+), 92 deletions(-) create mode 100644 changelog.d/1340.breaking.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a814eccf..d41c7679 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -63,7 +63,7 @@ jobs: V=${{ matrix.python-version }} DO_MYPY=1 - if [[ "$V" == "3.7" || "$V" == "3.8" ]]; then + if [[ "$V" == "3.8" ]]; then DO_MYPY=0 fi @@ -73,7 +73,6 @@ jobs: uv pip install --system tox - run: uv pip install --system tox-uv - if: matrix.python-version != '3.7' - run: python -Im tox run -e ${{ env.TOX_PYTHON }}-mypy if: env.DO_MYPY == '1' diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 6679107f..362f4d0f 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -9,7 +9,7 @@ repos: - id: black - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.6.2 + rev: v0.6.3 hooks: - id: ruff args: [--fix, --exit-non-zero-on-fix] diff --git a/changelog.d/1340.breaking.md b/changelog.d/1340.breaking.md new file mode 100644 index 00000000..ff19e59e --- /dev/null +++ b/changelog.d/1340.breaking.md @@ -0,0 +1 @@ +Python 3.7 has been dropped. diff --git a/pyproject.toml b/pyproject.toml index 189745d0..e857b1dc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,13 +9,12 @@ build-backend = "hatchling.build" name = "attrs" authors = [{ name = "Hynek Schlawack", email = "hs@ox.cx" }] license = { text = "MIT" } -requires-python = ">=3.7" +requires-python = ">=3.8" description = "Classes Without Boilerplate" keywords = ["class", "attribute", "boilerplate"] classifiers = [ "Development Status :: 5 - Production/Stable", "License :: OSI Approved :: MIT License", - "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", @@ -26,7 +25,7 @@ classifiers = [ "Programming Language :: Python :: Implementation :: PyPy", "Typing :: Typed", ] -dependencies = ["importlib_metadata;python_version<'3.8'"] +dependencies = [] dynamic = ["version", "readme"] [project.optional-dependencies] diff --git a/src/attr/__init__.py b/src/attr/__init__.py index 51b1c255..9ba03238 100644 --- a/src/attr/__init__.py +++ b/src/attr/__init__.py @@ -5,11 +5,10 @@ Classes Without Boilerplate """ from functools import partial -from typing import Callable +from typing import Callable, Protocol from . import converters, exceptions, filters, setters, validators from ._cmp import cmp_using -from ._compat import Protocol from ._config import get_run_validators, set_run_validators from ._funcs import asdict, assoc, astuple, evolve, has, resolve_types from ._make import ( @@ -85,10 +84,7 @@ def _make_getattr(mod_name: str) -> Callable: msg = f"module {mod_name} has no attribute {name}" raise AttributeError(msg) - try: - from importlib.metadata import metadata - except ImportError: - from importlib_metadata import metadata + from importlib.metadata import metadata meta = metadata("attrs") diff --git a/src/attr/__init__.pyi b/src/attr/__init__.pyi index 6ae0a83d..2bbdc051 100644 --- a/src/attr/__init__.pyi +++ b/src/attr/__init__.pyi @@ -76,29 +76,20 @@ NOTHING = _Nothing.NOTHING # 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. -if sys.version_info >= (3, 8): - from typing import Literal - @overload - def Factory(factory: Callable[[], _T]) -> _T: ... - @overload - def Factory( - factory: Callable[[Any], _T], - takes_self: Literal[True], - ) -> _T: ... - @overload - def Factory( - factory: Callable[[], _T], - takes_self: Literal[False], - ) -> _T: ... +from typing import Literal -else: - @overload - def Factory(factory: Callable[[], _T]) -> _T: ... - @overload - def Factory( - factory: Union[Callable[[Any], _T], Callable[[], _T]], - takes_self: bool = ..., - ) -> _T: ... +@overload +def Factory(factory: Callable[[], _T]) -> _T: ... +@overload +def Factory( + factory: Callable[[Any], _T], + takes_self: Literal[True], +) -> _T: ... +@overload +def Factory( + factory: Callable[[], _T], + takes_self: Literal[False], +) -> _T: ... In = TypeVar("In") Out = TypeVar("Out") diff --git a/src/attr/_compat.py b/src/attr/_compat.py index 104eeb07..22fcd783 100644 --- a/src/attr/_compat.py +++ b/src/attr/_compat.py @@ -10,7 +10,6 @@ from typing import _GenericAlias PYPY = platform.python_implementation() == "PyPy" -PY_3_8_PLUS = sys.version_info[:2] >= (3, 8) PY_3_9_PLUS = sys.version_info[:2] >= (3, 9) PY_3_10_PLUS = sys.version_info[:2] >= (3, 10) PY_3_11_PLUS = sys.version_info[:2] >= (3, 11) @@ -19,14 +18,6 @@ PY_3_13_PLUS = sys.version_info[:2] >= (3, 13) PY_3_14_PLUS = sys.version_info[:2] >= (3, 14) -if sys.version_info < (3, 8): - try: - from typing_extensions import Protocol - except ImportError: # pragma: no cover - Protocol = object -else: - from typing import Protocol # noqa: F401 - if PY_3_14_PLUS: # pragma: no cover import annotationlib diff --git a/src/attr/_make.py b/src/attr/_make.py index 007a3fd8..274d4121 100644 --- a/src/attr/_make.py +++ b/src/attr/_make.py @@ -20,7 +20,6 @@ from operator import itemgetter # having the thread-local in the globals here. from . import _compat, _config, setters from ._compat import ( - PY_3_8_PLUS, PY_3_10_PLUS, PY_3_11_PLUS, _AnnotationExtractor, @@ -790,16 +789,11 @@ class _ClassBuilder: ): names += ("__weakref__",) - if PY_3_8_PLUS: - cached_properties = { - name: cached_property.func - for name, cached_property in cd.items() - if isinstance(cached_property, functools.cached_property) - } - else: - # `functools.cached_property` was introduced in 3.8. - # So can't be used before this. - cached_properties = {} + cached_properties = { + name: cached_property.func + for name, cached_property in cd.items() + if isinstance(cached_property, functools.cached_property) + } # Collect methods with a `__class__` reference that are shadowed in the new class. # To know to update them. @@ -2213,7 +2207,7 @@ def _attrs_to_init_script( # If pre init method has arguments, pass same arguments as `__init__`. lines[0] = f"self.__attrs_pre_init__({pre_init_args})" - # Python 3.7 doesn't allow backslashes in f strings. + # Python <3.12 doesn't allow backslashes in f-strings. NL = "\n " return ( f"""def {method_name}(self, {args}): diff --git a/tests/strategies.py b/tests/strategies.py index cb3f326e..c01f666f 100644 --- a/tests/strategies.py +++ b/tests/strategies.py @@ -13,8 +13,6 @@ from hypothesis import strategies as st import attr -from attr._compat import PY_3_8_PLUS - from .utils import make_class @@ -189,9 +187,7 @@ def simple_classes( cls_dict["__init__"] = init bases = (object,) - if cached_property or ( - PY_3_8_PLUS and cached_property is None and cached_property_flag - ): + if cached_property or (cached_property is None and cached_property_flag): class BaseWithCachedProperty: @functools.cached_property diff --git a/tests/test_compat.py b/tests/test_compat.py index dbd27eda..a7cbf3e0 100644 --- a/tests/test_compat.py +++ b/tests/test_compat.py @@ -2,6 +2,8 @@ import types +from typing import Protocol + import pytest import attr @@ -59,5 +61,5 @@ def test_attrsinstance_subclass_protocol(): It's possible to subclass AttrsInstance and Protocol at once. """ - class Foo(attr.AttrsInstance, attr._compat.Protocol): + class Foo(attr.AttrsInstance, Protocol): def attribute(self) -> int: ... diff --git a/tests/test_make.py b/tests/test_make.py index 7ef80713..a7b87106 100644 --- a/tests/test_make.py +++ b/tests/test_make.py @@ -21,7 +21,7 @@ from hypothesis.strategies import booleans, integers, lists, sampled_from, text import attr from attr import _config -from attr._compat import PY_3_8_PLUS, PY_3_10_PLUS, PY_3_14_PLUS +from attr._compat import PY_3_10_PLUS, PY_3_14_PLUS from attr._make import ( Attribute, Factory, @@ -1837,7 +1837,6 @@ class TestClassBuilder: assert [C2] == C.__subclasses__() - @pytest.mark.skipif(not PY_3_8_PLUS, reason="cached_property is 3.8+") @pytest.mark.xfail(PY_3_14_PLUS, reason="Currently broken on nightly.") def test_no_references_to_original_when_using_cached_property(self): """ diff --git a/tests/test_packaging.py b/tests/test_packaging.py index 8b270b6b..8090b4b8 100644 --- a/tests/test_packaging.py +++ b/tests/test_packaging.py @@ -1,6 +1,7 @@ # SPDX-License-Identifier: MIT -import sys + +from importlib import metadata import pytest @@ -8,12 +9,6 @@ import attr import attrs -if sys.version_info < (3, 8): - import importlib_metadata as metadata -else: - from importlib import metadata - - @pytest.fixture(name="mod", params=(attr, attrs)) def _mod(request): return request.param diff --git a/tests/test_slots.py b/tests/test_slots.py index 66e54f36..9af18e5e 100644 --- a/tests/test_slots.py +++ b/tests/test_slots.py @@ -15,7 +15,7 @@ import pytest import attr import attrs -from attr._compat import PY_3_8_PLUS, PY_3_14_PLUS, PYPY +from attr._compat import PY_3_14_PLUS, PYPY # Pympler doesn't work on PyPy. @@ -722,7 +722,6 @@ def test_slots_super_property_get_shortcut(): assert B(17).f == 289 -@pytest.mark.skipif(not PY_3_8_PLUS, reason="cached_property is 3.8+") def test_slots_cached_property_allows_call(): """ cached_property in slotted class allows call. @@ -739,7 +738,6 @@ def test_slots_cached_property_allows_call(): assert A(11).f == 11 -@pytest.mark.skipif(not PY_3_8_PLUS, reason="cached_property is 3.8+") def test_slots_cached_property_class_does_not_have__dict__(): """ slotted class with cached property has no __dict__ attribute. @@ -757,7 +755,6 @@ def test_slots_cached_property_class_does_not_have__dict__(): assert "__dict__" not in dir(A) -@pytest.mark.skipif(not PY_3_8_PLUS, reason="cached_property is 3.8+") def test_slots_cached_property_works_on_frozen_isntances(): """ Infers type of cached property. @@ -774,7 +771,6 @@ def test_slots_cached_property_works_on_frozen_isntances(): assert A(x=1).f == 1 -@pytest.mark.skipif(not PY_3_8_PLUS, reason="cached_property is 3.8+") @pytest.mark.xfail( PY_3_14_PLUS, reason="3.14 returns weird annotation for cached_properies" ) @@ -794,7 +790,6 @@ def test_slots_cached_property_infers_type(): assert A.__annotations__ == {"x": int, "f": int} -@pytest.mark.skipif(not PY_3_8_PLUS, reason="cached_property is 3.8+") def test_slots_cached_property_with_empty_getattr_raises_attribute_error_of_requested(): """ Ensures error information is not lost. @@ -815,7 +810,6 @@ def test_slots_cached_property_with_empty_getattr_raises_attribute_error_of_requ a.z -@pytest.mark.skipif(not PY_3_8_PLUS, reason="cached_property is 3.8+") def test_slots_cached_property_raising_attributeerror(): """ Ensures AttributeError raised by a property is preserved by __getattr__() @@ -854,7 +848,6 @@ def test_slots_cached_property_raising_attributeerror(): assert a.q == 2 -@pytest.mark.skipif(not PY_3_8_PLUS, reason="cached_property is 3.8+") def test_slots_cached_property_with_getattr_calls_getattr_for_missing_attributes(): """ Ensure __getattr__ implementation is maintained for non cached_properties. @@ -876,7 +869,6 @@ def test_slots_cached_property_with_getattr_calls_getattr_for_missing_attributes assert a.z == "z" -@pytest.mark.skipif(not PY_3_8_PLUS, reason="cached_property is 3.8+") def test_slots_getattr_in_superclass__is_called_for_missing_attributes_when_cached_property_present(): """ Ensure __getattr__ implementation is maintained in subclass. @@ -900,7 +892,6 @@ def test_slots_getattr_in_superclass__is_called_for_missing_attributes_when_cach assert b.z == "z" -@pytest.mark.skipif(not PY_3_8_PLUS, reason="cached_property is 3.8+") def test_slots_getattr_in_subclass_gets_superclass_cached_property(): """ Ensure super() in __getattr__ is not broken through cached_property re-write. @@ -931,7 +922,6 @@ def test_slots_getattr_in_subclass_gets_superclass_cached_property(): assert b.z == "z" -@pytest.mark.skipif(not PY_3_8_PLUS, reason="cached_property is 3.8+") def test_slots_sub_class_with_independent_cached_properties_both_work(): """ Subclassing shouldn't break cached properties. @@ -955,7 +945,6 @@ def test_slots_sub_class_with_independent_cached_properties_both_work(): assert B(1).g == 2 -@pytest.mark.skipif(not PY_3_8_PLUS, reason="cached_property is 3.8+") def test_slots_with_multiple_cached_property_subclasses_works(): """ Multiple sub-classes shouldn't break cached properties. @@ -991,7 +980,6 @@ def test_slots_with_multiple_cached_property_subclasses_works(): assert ab.h == "h" -@pytest.mark.skipif(not PY_3_8_PLUS, reason="cached_property is 3.8+") def test_slotted_cached_property_can_access_super(): """ Multiple sub-classes shouldn't break cached properties. @@ -1010,7 +998,6 @@ def test_slotted_cached_property_can_access_super(): assert B(x=1).f == 2 -@pytest.mark.skipif(not PY_3_8_PLUS, reason="cached_property is 3.8+") def test_slots_sub_class_avoids_duplicated_slots(): """ Duplicating the slots is a waste of memory. @@ -1034,7 +1021,6 @@ def test_slots_sub_class_avoids_duplicated_slots(): assert B.__slots__ == () -@pytest.mark.skipif(not PY_3_8_PLUS, reason="cached_property is 3.8+") def test_slots_sub_class_with_actual_slot(): """ A sub-class can have an explicit attrs field that replaces a cached property. @@ -1056,7 +1042,6 @@ def test_slots_sub_class_with_actual_slot(): assert B.__slots__ == () -@pytest.mark.skipif(not PY_3_8_PLUS, reason="cached_property is 3.8+") def test_slots_cached_property_is_not_called_at_construction(): """ A cached property function should only be called at property access point. @@ -1077,7 +1062,6 @@ def test_slots_cached_property_is_not_called_at_construction(): assert call_count == 0 -@pytest.mark.skipif(not PY_3_8_PLUS, reason="cached_property is 3.8+") def test_slots_cached_property_repeat_call_only_once(): """ A cached property function should be called only once, on repeated attribute access. @@ -1100,7 +1084,6 @@ def test_slots_cached_property_repeat_call_only_once(): assert call_count == 1 -@pytest.mark.skipif(not PY_3_8_PLUS, reason="cached_property is 3.8+") def test_slots_cached_property_called_independent_across_instances(): """ A cached property value should be specific to the given instance. @@ -1121,7 +1104,6 @@ def test_slots_cached_property_called_independent_across_instances(): assert obj_2.f == 2 -@pytest.mark.skipif(not PY_3_8_PLUS, reason="cached_property is 3.8+") def test_slots_cached_properties_work_independently(): """ Multiple cached properties should work independently. diff --git a/tox.ini b/tox.ini index 52b195a4..3b51dbeb 100644 --- a/tox.ini +++ b/tox.ini @@ -2,7 +2,7 @@ min_version = 4 env_list = pre-commit, - py3{7,8,9,10,11,12,13}-tests, + py3{8,9,10,11,12,13}-tests, py3{9,10,11,12,13}-mypy, pypy3, pyright, @@ -26,7 +26,7 @@ commands = mypy: mypy tests/typing_example.py mypy: 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 -[testenv:py3{7,10,12}-tests] +[testenv:py3{8,10,12}-tests] extras = cov # Python 3.6+ has a number of compile-time warnings on invalid string escapes. # PYTHONWARNINGS=d makes them visible during the tox run. @@ -41,7 +41,7 @@ commands = coverage run -m pytest {posargs:-n auto --dist loadfile} # Keep base_python in-sync with .python-version-default base_python = py312 # Keep depends in-sync with testenv above that has cov extra. -depends = py3{7,10,12} +depends = py3{8,10,12} skip_install = true deps = coverage[toml]>=5.3 commands =