Repository: aio-libs/yarl

Issue:
Use aiohttp reify as a replacement for yarl's custom cached_property
### Is your feature request related to a problem?

The custom `cached_property` used for `URL` can be replaced with `aiohttp` `reify` which is faster, better tested, and better suited for properties with small amount of code 

### Describe the solution you'd like

n/a

### Describe alternatives you've considered

n/a

### Additional context

_No response_

### Code of Conduct

- [X] I agree to follow the aio-libs Code of Conduct

Discussion:
codecov[bot]: ## [Codecov](https://app.codecov.io/gh/aio-libs/yarl/pull/1070?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aio-libs) Report
Attention: Patch coverage is `75.00000%` with `29 lines` in your changes missing coverage. Please review.
> Project coverage is 62.75%. Comparing base [(`3ce742f`)](https://app.codecov.io/gh/aio-libs/yarl/commit/3ce742f611594c241e20cc74685d5198c511b973?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aio-libs) to head [(`4f0b4ab`)](https://app.codecov.io/gh/aio-libs/yarl/commit/4f0b4ab0db1209fc29285349ce27068c2749c3c4?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aio-libs).

| [Files with missing lines](https://app.codecov.io/gh/aio-libs/yarl/pull/1070?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aio-libs) | Patch % | Lines |
|---|---|---|
| [tests/test\_helpers.py](https://app.codecov.io/gh/aio-libs/yarl/pull/1070?src=pr&el=tree&filepath=tests%2Ftest_helpers.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aio-libs#diff-dGVzdHMvdGVzdF9oZWxwZXJzLnB5) | 55.93% | [26 Missing :warning: ](https://app.codecov.io/gh/aio-libs/yarl/pull/1070?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aio-libs) |
| [yarl/\_helpers.pyi](https://app.codecov.io/gh/aio-libs/yarl/pull/1070?src=pr&el=tree&filepath=yarl%2F_helpers.pyi&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aio-libs#diff-eWFybC9faGVscGVycy5weWk=) | 40.00% | [3 Missing :warning: ](https://app.codecov.io/gh/aio-libs/yarl/pull/1070?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aio-libs) |

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##           master    #1070      +/-   ##
==========================================
+ Coverage   62.37%   62.75%   +0.37%     
==========================================
  Files          38       43       +5     
  Lines        6626     6782     +156     
  Branches      356      376      +20     
==========================================
+ Hits         4133     4256     +123     
- Misses       2466     2499      +33     
  Partials       27       27              
```

| [Flag](https://app.codecov.io/gh/aio-libs/yarl/pull/1070/flags?src=pr&el=flags&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aio-libs) | Coverage Δ | |
|---|---|---|
| [CI-GHA](https://app.codecov.io/gh/aio-libs/yarl/pull/1070/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aio-libs) | `62.71% <75.00%> (+0.36%)` | :arrow_up: |
| [MyPy](https://app.codecov.io/gh/aio-libs/yarl/pull/1070/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aio-libs) | `26.39% <56.84%> (+0.76%)` | :arrow_up: |
| [OS-Linux](https://app.codecov.io/gh/aio-libs/yarl/pull/1070/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aio-libs) | `98.93% <90.38%> (-0.33%)` | :arrow_down: |
| [OS-Windows](https://app.codecov.io/gh/aio-libs/yarl/pull/1070/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aio-libs) | `99.25% <86.11%> (-0.34%)` | :arrow_down: |
| [OS-macOS](https://app.codecov.io/gh/aio-libs/yarl/pull/1070/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aio-libs) | `98.72% <90.38%> (-0.30%)` | :arrow_down: |
| [Py-3.10.11](https://app.codecov.io/gh/aio-libs/yarl/pull/1070/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aio-libs) | `98.61% <90.38%> (-0.29%)` | :arrow_down: |
| [Py-3.10.14](https://app.codecov.io/gh/aio-libs/yarl/pull/1070/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aio-libs) | `98.81% <90.38%> (-0.30%)` | :arrow_down: |
| [Py-3.11.9](https://app.codecov.io/gh/aio-libs/yarl/pull/1070/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aio-libs) | `98.81% <90.38%> (-0.30%)` | :arrow_down: |
| [Py-3.12.5](https://app.codecov.io/gh/aio-libs/yarl/pull/1070/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aio-libs) | `98.81% <90.38%> (-0.30%)` | :arrow_down: |
| [Py-3.13.0-rc.1](https://app.codecov.io/gh/aio-libs/yarl/pull/1070/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aio-libs) | `98.81% <90.38%> (-0.30%)` | :arrow_down: |
| [Py-3.8.10](https://app.codecov.io/gh/aio-libs/yarl/pull/1070/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aio-libs) | `98.58% <90.38%> (-0.29%)` | :arrow_down: |
| [Py-3.8.18](https://app.codecov.io/gh/aio-libs/yarl/pull/1070/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aio-libs) | `98.78% <90.38%> (-0.30%)` | :arrow_down: |
| [Py-3.9.13](https://app.codecov.io/gh/aio-libs/yarl/pull/1070/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aio-libs) | `98.58% <90.38%> (-0.29%)` | :arrow_down: |
| [Py-3.9.19](https://app.codecov.io/gh/aio-libs/yarl/pull/1070/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aio-libs) | `98.78% <90.38%> (-0.30%)` | :arrow_down: |
| [Py-pypy7.3.11](https://app.codecov.io/gh/aio-libs/yarl/pull/1070/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aio-libs) | `?` | |
| [Py-pypy7.3.16](https://app.codecov.io/gh/aio-libs/yarl/pull/1070/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aio-libs) | `?` | |
| [VM-macos-latest](https://app.codecov.io/gh/aio-libs/yarl/pull/1070/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aio-libs) | `98.72% <90.38%> (-0.30%)` | :arrow_down: |
| [VM-ubuntu-latest](https://app.codecov.io/gh/aio-libs/yarl/pull/1070/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aio-libs) | `98.93% <90.38%> (-0.33%)` | :arrow_down: |
| [VM-windows-latest](https://app.codecov.io/gh/aio-libs/yarl/pull/1070/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aio-libs) | `99.25% <86.11%> (-0.34%)` | :arrow_down: |

Flags with carried forward coverage won't be shown. [Click here](https://docs.codecov.io/docs/carryforward-flags?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aio-libs#carryforward-flags-in-the-pull-request-comment) to find out more.

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/aio-libs/yarl/pull/1070?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aio-libs).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aio-libs).

bdraco: added skip label to workaround the bot not working
bdraco: Updated a few production systems. Everything looks great. 
bdraco: Using the C implementation now

<img width="367" alt="Screenshot 2024-08-31 at 5 28 13 PM" src="https://github.com/user-attachments/assets/5756621d-5e32-497f-8d48-8ea486eced02">

bdraco: Testing more widely now, this one's very straightforward, though as aiohttp has been using nearly the same code for a long time, so not expecting any surprises
bdraco: py-spy looks good as well
bdraco: Wider testing looks good as well.

Patch:
diff --git a/CHANGES/1070.misc.rst b/CHANGES/1070.misc.rst
new file mode 100644
index 0000000..c146114
--- /dev/null
+++ b/CHANGES/1070.misc.rst
@@ -0,0 +1,3 @@
+Improved performance of property caching -- by :user:`bdraco`.
+
+The ``reify`` implementation from ``aiohttp`` was adapted to replace the internal ``cached_property`` implementation.
diff --git a/yarl/_helpers.py b/yarl/_helpers.py
new file mode 100644
index 0000000..9098cfb
--- /dev/null
+++ b/yarl/_helpers.py
@@ -0,0 +1,26 @@
+import os
+import sys
+
+__all__ = ("cached_property",)
+
+
+NO_EXTENSIONS = bool(os.environ.get("YARL_NO_EXTENSIONS"))  # type: bool
+if sys.implementation.name != "cpython":
+    NO_EXTENSIONS = True
+
+
+# isort: off
+if not NO_EXTENSIONS:  # pragma: no branch
+    try:
+        from ._helpers_c import cached_property as cached_property_c  # type: ignore[attr-defined, unused-ignore] # noqa: E501
+
+        cached_property = cached_property_c
+    except ImportError:  # pragma: no cover
+        from ._helpers_py import cached_property as cached_property_py
+
+        cached_property = cached_property_py  # type: ignore[assignment, misc]
+else:
+    from ._helpers_py import cached_property as cached_property_py
+
+    cached_property = cached_property_py  # type: ignore[assignment, misc]
+# isort: on
diff --git a/yarl/_helpers_c.pyi b/yarl/_helpers_c.pyi
new file mode 100644
index 0000000..6903492
--- /dev/null
+++ b/yarl/_helpers_c.pyi
@@ -0,0 +1,6 @@
+from typing import Any
+
+class cached_property:
+    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/yarl/_helpers_c.pyx b/yarl/_helpers_c.pyx
new file mode 100644
index 0000000..c428753
--- /dev/null
+++ b/yarl/_helpers_c.pyx
@@ -0,0 +1,37 @@
+# cython: language_level=3
+
+cdef class cached_property:
+    """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):
+        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, value):
+        raise AttributeError("cached property is read-only")
diff --git a/yarl/_helpers_py.py b/yarl/_helpers_py.py
new file mode 100644
index 0000000..5a18afb
--- /dev/null
+++ b/yarl/_helpers_py.py
@@ -0,0 +1,41 @@
+"""Various helper functions."""
+
+from typing import Any, Callable, Dict, Generic, Optional, Protocol, Type, TypeVar
+
+_T = TypeVar("_T")
+
+
+class _TSelf(Protocol, Generic[_T]):
+    _cache: Dict[str, _T]
+
+
+class cached_property(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("cached property is read-only")
diff --git a/yarl/_url.py b/yarl/_url.py
index c64aa3d..5824e78 100644
--- a/yarl/_url.py
+++ b/yarl/_url.py
@@ -11,6 +11,7 @@ from urllib.parse import SplitResult, parse_qsl, quote, urljoin, urlsplit, urlun
 import idna
 from multidict import MultiDict, MultiDictProxy
 
+from ._helpers import cached_property
 from ._quoting import _Quoter, _Unquoter
 
 DEFAULT_PORTS = {"http": 80, "https": 443, "ws": 80, "wss": 443}
@@ -23,37 +24,6 @@ def rewrite_module(obj: object) -> object:
     return obj
 
 
-class cached_property:
-    """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):
-        self.wrapped = wrapped
-        try:
-            self.__doc__ = wrapped.__doc__
-        except AttributeError:  # pragma: no cover
-            self.__doc__ = ""
-        self.name = wrapped.__name__
-
-    def __get__(self, inst, owner, _sentinel=sentinel):
-        if inst is None:
-            return self
-        val = inst._cache.get(self.name, _sentinel)
-        if val is not _sentinel:
-            return val
-        val = self.wrapped(inst)
-        inst._cache[self.name] = val
-        return val
-
-    def __set__(self, inst, value):
-        raise AttributeError("cached property is read-only")
-
-
 def _normalize_path_segments(segments):
     """Drop '.' and '..' from a sequence of str segments"""
 


Test patch:
diff --git a/tests/test_helpers.py b/tests/test_helpers.py
new file mode 100644
index 0000000..cdfff12
--- /dev/null
+++ b/tests/test_helpers.py
@@ -0,0 +1,91 @@
+import platform
+
+import pytest
+
+from yarl import _helpers, _helpers_py
+
+IS_PYPY = platform.python_implementation() == "PyPy"
+
+
+class CachedPropertyMixin:
+    cached_property = NotImplemented
+
+    def test_cached_property(self) -> None:
+        class A:
+            def __init__(self):
+                self._cache = {}
+
+            @self.cached_property  # type: ignore[misc]
+            def prop(self):
+                return 1
+
+        a = A()
+        assert a.prop == 1
+
+    def test_cached_property_class(self) -> None:
+        class A:
+            def __init__(self):
+                """Init."""
+                # self._cache not set because its never accessed in this test
+
+            @self.cached_property  # type: ignore[misc]
+            def prop(self):
+                """Docstring."""
+
+        assert isinstance(A.prop, self.cached_property)
+        assert A.prop.__doc__ == "Docstring."
+
+    def test_cached_property_assignment(self) -> None:
+        class A:
+            def __init__(self):
+                self._cache = {}
+
+            @self.cached_property  # type: ignore[misc]
+            def prop(self):
+                """Mock property."""
+
+        a = A()
+
+        with pytest.raises(AttributeError):
+            a.prop = 123
+
+    def test_cached_property_without_cache(self) -> None:
+        class A:
+            def __init__(self):
+                pass
+
+            @self.cached_property  # type: ignore[misc]
+            def prop(self):
+                """Mock property."""
+
+        a = A()
+
+        with pytest.raises(AttributeError):
+            a.prop = 123
+
+    def test_cached_property_check_without_cache(self) -> None:
+        class A:
+            def __init__(self):
+                pass
+
+            @self.cached_property  # type: ignore[misc]
+            def prop(self):
+                """Mock property."""
+
+        a = A()
+        with pytest.raises(AttributeError):
+            assert a.prop == 1
+
+
+class TestPyCachedProperty(CachedPropertyMixin):
+    cached_property = _helpers_py.cached_property  # type: ignore[assignment]
+
+
+if (
+    not _helpers.NO_EXTENSIONS
+    and not IS_PYPY
+    and hasattr(_helpers, "cached_property_c")
+):
+
+    class TestCCachedProperty(CachedPropertyMixin):
+        cached_property = _helpers.cached_property_c  # type: ignore[assignment, attr-defined, unused-ignore] # noqa: E501