From e9edd04475ef6bfb4a9a8535b2617443e7bc71d2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 8 Oct 2024 14:02:48 +0200 Subject: [PATCH] Migrate to using propcache for property caching (#9394) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Sviatoslav Sydorenko (Святослав Сидоренко) --- .github/ISSUE_TEMPLATE/bug_report.yml | 9 ++++ .gitignore | 2 - CHANGES/9394.packaging.rst | 6 +++ aiohttp/_helpers.pyi | 6 --- aiohttp/_helpers.pyx | 35 ---------------- aiohttp/helpers.py | 50 +---------------------- docs/conf.py | 1 + requirements/base.txt | 2 + requirements/constraints.txt | 2 + requirements/dev.txt | 2 + requirements/runtime-deps.in | 1 + requirements/runtime-deps.txt | 2 + setup.cfg | 1 + setup.py | 1 - tests/test_helpers.py | 59 +-------------------------- 15 files changed, 29 insertions(+), 150 deletions(-) create mode 100644 CHANGES/9394.packaging.rst delete mode 100644 aiohttp/_helpers.pyi delete mode 100644 aiohttp/_helpers.pyx diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 8ae7af0fff5..eb92f768a29 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -86,6 +86,15 @@ body: $ python -m pip show multidict validations: required: true +- type: textarea + attributes: + label: propcache Version + description: Attach your version of propcache. + render: console + value: | + $ python -m pip show propcache + validations: + required: true - type: textarea attributes: label: yarl Version diff --git a/.gitignore b/.gitignore index 7d38dd91998..62770ddc80a 100644 --- a/.gitignore +++ b/.gitignore @@ -37,8 +37,6 @@ aiohttp/_find_header.c aiohttp/_headers.html aiohttp/_headers.pxi -aiohttp/_helpers.c -aiohttp/_helpers.html aiohttp/_http_parser.c aiohttp/_http_parser.html aiohttp/_http_writer.c diff --git a/CHANGES/9394.packaging.rst b/CHANGES/9394.packaging.rst new file mode 100644 index 00000000000..456ac0f52c8 --- /dev/null +++ b/CHANGES/9394.packaging.rst @@ -0,0 +1,6 @@ +Switched to using the :mod:`propcache ` package for property caching +-- by :user:`bdraco`. + +The :mod:`propcache ` package is derived from the property caching +code in :mod:`yarl` and has been broken out to avoid maintaining it for multiple +projects. diff --git a/aiohttp/_helpers.pyi b/aiohttp/_helpers.pyi deleted file mode 100644 index 1e358937024..00000000000 --- a/aiohttp/_helpers.pyi +++ /dev/null @@ -1,6 +0,0 @@ -from typing import Any - -class reify: - def __init__(self, wrapped: Any) -> None: ... - def __get__(self, inst: Any, owner: Any) -> Any: ... - def __set__(self, inst: Any, value: Any) -> None: ... diff --git a/aiohttp/_helpers.pyx b/aiohttp/_helpers.pyx deleted file mode 100644 index 5f089225dc8..00000000000 --- a/aiohttp/_helpers.pyx +++ /dev/null @@ -1,35 +0,0 @@ - -cdef _sentinel = object() - -cdef class reify: - """Use as a class method decorator. It operates almost exactly like - the Python `@property` decorator, but it puts the result of the - method it decorates into the instance dict after the first call, - effectively replacing the function it decorates with an instance - variable. It is, in Python parlance, a data descriptor. - - """ - - cdef object wrapped - cdef object name - - def __init__(self, wrapped): - self.wrapped = wrapped - self.name = wrapped.__name__ - - @property - def __doc__(self): - return self.wrapped.__doc__ - - def __get__(self, inst, owner): - if inst is None: - return self - cdef dict cache = inst._cache - val = cache.get(self.name, _sentinel) - if val is _sentinel: - val = self.wrapped(inst) - cache[self.name] = val - return val - - def __set__(self, inst, value): - raise AttributeError("reified property is read-only") diff --git a/aiohttp/helpers.py b/aiohttp/helpers.py index ec67abf5ebf..55b363003fc 100644 --- a/aiohttp/helpers.py +++ b/aiohttp/helpers.py @@ -49,6 +49,7 @@ from urllib.request import getproxies, proxy_bypass from multidict import CIMultiDict, MultiDict, MultiDictProxy, MultiMapping +from propcache.api import under_cached_property as reify from yarl import URL from . import hdrs @@ -60,7 +61,7 @@ else: import async_timeout -__all__ = ("BasicAuth", "ChainMapProxy", "ETag") +__all__ = ("BasicAuth", "ChainMapProxy", "ETag", "reify") PY_310 = sys.version_info >= (3, 10) @@ -440,53 +441,6 @@ def is_expected_content_type( return expected_content_type in response_content_type -class _TSelf(Protocol, Generic[_T]): - _cache: Dict[str, _T] - - -class reify(Generic[_T]): - """Use as a class method decorator. - - It operates almost exactly like - the Python `@property` decorator, but it puts the result of the - method it decorates into the instance dict after the first call, - effectively replacing the function it decorates with an instance - variable. It is, in Python parlance, a data descriptor. - """ - - def __init__(self, wrapped: Callable[..., _T]) -> None: - self.wrapped = wrapped - self.__doc__ = wrapped.__doc__ - self.name = wrapped.__name__ - - def __get__(self, inst: _TSelf[_T], owner: Optional[Type[Any]] = None) -> _T: - try: - try: - return inst._cache[self.name] - except KeyError: - val = self.wrapped(inst) - inst._cache[self.name] = val - return val - except AttributeError: - if inst is None: - return self - raise - - def __set__(self, inst: _TSelf[_T], value: _T) -> None: - raise AttributeError("reified property is read-only") - - -reify_py = reify - -try: - from ._helpers import reify as reify_c - - if not NO_EXTENSIONS: - reify = reify_c # type: ignore[misc,assignment] -except ImportError: - pass - - def is_ip_address(host: Optional[str]) -> bool: """Check if host looks like an IP Address. diff --git a/docs/conf.py b/docs/conf.py index c8b41edda44..2deabea1b4f 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -74,6 +74,7 @@ "pytest": ("http://docs.pytest.org/en/latest/", None), "python": ("http://docs.python.org/3", None), "multidict": ("https://multidict.readthedocs.io/en/stable/", None), + "propcache": ("https://propcache.aio-libs.org/en/stable", None), "yarl": ("https://yarl.readthedocs.io/en/stable/", None), "aiosignal": ("https://aiosignal.readthedocs.io/en/stable/", None), "aiohttpjinja2": ("https://aiohttp-jinja2.readthedocs.io/en/stable/", None), diff --git a/requirements/base.txt b/requirements/base.txt index ead3ffeaea2..5d793411e2e 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -30,6 +30,8 @@ multidict==6.1.0 # yarl packaging==24.1 # via gunicorn +propcache==0.2.0 + # via -r requirements/runtime-deps.in pycares==4.4.0 # via aiodns pycparser==2.22 diff --git a/requirements/constraints.txt b/requirements/constraints.txt index c3456fcea05..104c55dfa89 100644 --- a/requirements/constraints.txt +++ b/requirements/constraints.txt @@ -134,6 +134,8 @@ pluggy==1.5.0 # via pytest pre-commit==3.5.0 # via -r requirements/lint.in +propcache==0.2.0 + # via -r requirements/runtime-deps.in proxy-py==2.4.8 # via # -r requirements/lint.in diff --git a/requirements/dev.txt b/requirements/dev.txt index e62f22aad1a..65b8d49a5f4 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -131,6 +131,8 @@ pluggy==1.5.0 # via pytest pre-commit==3.5.0 # via -r requirements/lint.in +propcache==0.2.0 + # via -r requirements/runtime-deps.in proxy-py==2.4.8 # via # -r requirements/lint.in diff --git a/requirements/runtime-deps.in b/requirements/runtime-deps.in index 269118d5cb8..800420b9130 100644 --- a/requirements/runtime-deps.in +++ b/requirements/runtime-deps.in @@ -8,4 +8,5 @@ Brotli; platform_python_implementation == 'CPython' brotlicffi; platform_python_implementation != 'CPython' frozenlist >= 1.1.1 multidict >=4.5, < 7.0 +propcache >= 0.2.0 yarl >= 1.13.0, < 2.0 diff --git a/requirements/runtime-deps.txt b/requirements/runtime-deps.txt index 777c7f19354..92c8b131bd0 100644 --- a/requirements/runtime-deps.txt +++ b/requirements/runtime-deps.txt @@ -26,6 +26,8 @@ multidict==6.1.0 # via # -r requirements/runtime-deps.in # yarl +propcache==0.2.0 + # via -r requirements/runtime-deps.in pycares==4.4.0 # via aiodns pycparser==2.22 diff --git a/setup.cfg b/setup.cfg index af021dc2877..42ae12855b8 100644 --- a/setup.cfg +++ b/setup.cfg @@ -54,6 +54,7 @@ install_requires = async-timeout >= 4.0, < 5.0 ; python_version < "3.11" frozenlist >= 1.1.1 multidict >=4.5, < 7.0 + propcache >= 0.2.0 yarl >= 1.13.0, < 2.0 [options.exclude_package_data] diff --git a/setup.py b/setup.py index ce38273c573..cc66fe214ca 100644 --- a/setup.py +++ b/setup.py @@ -40,7 +40,6 @@ define_macros=[("LLHTTP_STRICT_MODE", 0)], include_dirs=["vendor/llhttp/build"], ), - Extension("aiohttp._helpers", ["aiohttp/_helpers.c"]), Extension("aiohttp._http_writer", ["aiohttp/_http_writer.c"]), ] diff --git a/tests/test_helpers.py b/tests/test_helpers.py index 9da3565acd9..27de7174b27 100644 --- a/tests/test_helpers.py +++ b/tests/test_helpers.py @@ -2,12 +2,11 @@ import base64 import datetime import gc -import platform import sys import weakref from math import ceil, modf from pathlib import Path -from typing import Any, Dict, Iterator, Optional, Type, Union +from typing import Dict, Iterator, Optional, Union from unittest import mock from urllib.request import getproxies_environment # type: ignore[attr-defined] @@ -24,9 +23,6 @@ should_remove_content_length, ) -IS_PYPY = platform.python_implementation() == "PyPy" - - # ------------------- parse_mimetype ---------------------------------- @@ -227,59 +223,6 @@ def test_basic_auth_from_not_url() -> None: helpers.BasicAuth.from_url("http://user:pass@example.com") # type: ignore[arg-type] -class ReifyMixin: - reify: Type["helpers.reify[Any]"] - - def test_reify(self) -> None: - class A: - def __init__(self) -> None: - self._cache: Dict[str, str] = {} - - @self.reify # type: ignore[misc] - def prop(self) -> int: - return 1 - - a = A() - assert 1 == a.prop - - def test_reify_class(self) -> None: - class A: - def __init__(self) -> None: - self._cache: Dict[str, str] = {} - - @self.reify # type: ignore[misc] - def prop(self) -> int: - """Docstring.""" - return 1 - - assert isinstance(A.prop, self.reify) # type: ignore[arg-type] - assert "Docstring." == A.prop.__doc__ # type: ignore[arg-type] - - def test_reify_assignment(self) -> None: - class A: - def __init__(self) -> None: - self._cache: Dict[str, str] = {} - - @self.reify # type: ignore[misc] - def prop(self) -> int: - return 1 - - a = A() - - with pytest.raises(AttributeError): - a.prop = 123 - - -class TestPyReify(ReifyMixin): - reify = helpers.reify_py - - -if not helpers.NO_EXTENSIONS and not IS_PYPY and hasattr(helpers, "reify_c"): - - class TestCReify(ReifyMixin): - reify = helpers.reify_c # type: ignore[attr-defined,assignment] - - # ----------------------------------- is_ip_address() ----------------------