Skip to content

Commit 71a6704

Browse files
gencurrentclaude
andauthored
fix(typing): preserve decorated function type signatures with ParamSpec (#376)
* fix(typing): preserve decorated function type signatures with ParamSpec * fix: Revert uv.lock * fix: Preserve the KW args; New tests * test(typing): address review comments on PR * fix(typing): Ignore the type hints-related code from coverage * ci: skip docformatter on pre-commit.ci docformatter v1.7.7 transitively pulls untokenize, whose setup.py uses ast.Constant.s (removed in Python 3.12+) and fails to install on pre-commit.ci's runners. Skip the hook on pre-commit.ci until docformatter v1.7.8 ships (which drops the untokenize dep). The pre-commit GitHub Actions job still runs docformatter, so coverage of the hook isn't lost. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent ece168c commit 71a6704

4 files changed

Lines changed: 322 additions & 17 deletions

File tree

.pre-commit-config.yaml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,12 @@ ci:
66
autoupdate_commit_msg: "[pre-commit.ci] pre-commit suggestions"
77
autoupdate_schedule: quarterly
88
# submodules: true
9+
# docformatter v1.7.7 transitively pulls `untokenize`, whose setup.py
10+
# uses `ast.Constant.s` (removed in Python 3.12+) and fails to install
11+
# on pre-commit.ci's runners. The `pre-commit` GitHub Actions job still
12+
# runs docformatter, so coverage isn't lost. Remove this once docformatter
13+
# ships v1.7.8 (which drops the untokenize dep).
14+
skip: [docformatter]
915

1016
repos:
1117
- repo: https://github.com/pre-commit/pre-commit-hooks

src/cachier/core.py

Lines changed: 45 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
from concurrent.futures import ThreadPoolExecutor
1717
from datetime import datetime, timedelta
1818
from functools import wraps
19-
from typing import Any, Callable, Optional, Union
19+
from typing import Any, Callable, Optional, ParamSpec, Protocol, TypeVar, Union
2020
from warnings import warn
2121

2222
from ._types import RedisClient, S3Client
@@ -31,6 +31,34 @@
3131
from .metrics import CacheMetrics, MetricsContext
3232
from .util import parse_bytes
3333

34+
_P = ParamSpec("_P")
35+
_R = TypeVar("_R")
36+
_R_co = TypeVar("_R_co", covariant=True)
37+
38+
39+
class _CachierWrappedFunc(Protocol[_P, _R_co]):
40+
"""Callable returned by ``@cachier`` with the decorated function's signature.
41+
42+
Preserves the original function's parameter and return types via ``ParamSpec``
43+
while also exposing the cache-management attributes attached by the decorator.
44+
Per-call cachier options such as ``max_age`` and ``cachier__skip_cache`` are
45+
accepted at runtime but are not surfaced in the ``__call__`` signature here;
46+
PEP 612 does not permit mixing ParamSpec kwargs with additional keyword-only
47+
parameters.
48+
49+
"""
50+
51+
def __call__(self, *args: _P.args, **kwargs: _P.kwargs) -> _R_co: ... # pragma: no cover
52+
53+
clear_cache: Callable[[], Any]
54+
clear_being_calculated: Callable[[], Any]
55+
aclear_cache: Callable[[], Any]
56+
aclear_being_calculated: Callable[[], Any]
57+
cache_dpath: Callable[[], Optional[str]]
58+
precache_value: Callable[..., Any]
59+
metrics: Optional[CacheMetrics]
60+
61+
3462
MAX_WORKERS_ENVAR_NAME = "CACHIER_MAX_WORKERS"
3563
DEFAULT_MAX_WORKERS = 8
3664
ZERO_TIMEDELTA = timedelta(seconds=0)
@@ -221,7 +249,7 @@ def cachier(
221249
allow_non_static_methods: Optional[bool] = None,
222250
enable_metrics: bool = False,
223251
metrics_sampling_rate: float = 1.0,
224-
):
252+
) -> Callable[[Callable[_P, _R]], _CachierWrappedFunc[_P, _R]]:
225253
"""Wrap as a persistent, stale-free memoization decorator.
226254
227255
The positional and keyword arguments to the wrapped function must be
@@ -400,7 +428,7 @@ def cachier(
400428
else:
401429
raise ValueError("specified an invalid core: %s" % backend)
402430

403-
def _cachier_decorator(func):
431+
def _cachier_decorator(func: Callable[_P, _R]) -> _CachierWrappedFunc[_P, _R]:
404432
core.set_func(func)
405433

406434
# Guard: raise TypeError when decorating an instance method unless
@@ -513,7 +541,7 @@ def _call(*args, max_age: Optional[timedelta] = None, **kwds):
513541
from .config import _global_params
514542

515543
if ignore_cache or not _global_params.caching_enabled:
516-
return func(args[0], **kwargs) if core.func_is_method else func(**kwargs)
544+
return func(args[0], **kwargs) if core.func_is_method else func(**kwargs) # type: ignore[call-arg]
517545

518546
with MetricsContext(cache_metrics) as _mctx:
519547
key, entry = core.get_entry((), kwargs)
@@ -629,7 +657,7 @@ async def _call_async(*args, max_age: Optional[timedelta] = None, **kwds):
629657
from .config import _global_params
630658

631659
if ignore_cache or not _global_params.caching_enabled:
632-
return await func(args[0], **kwargs) if core.func_is_method else await func(**kwargs)
660+
return await func(args[0], **kwargs) if core.func_is_method else await func(**kwargs) # type: ignore[call-arg,misc]
633661

634662
with MetricsContext(cache_metrics) as _mctx:
635663
key, entry = await core.aget_entry((), kwargs)
@@ -699,14 +727,14 @@ async def _call_async(*args, max_age: Optional[timedelta] = None, **kwds):
699727
if is_coroutine:
700728

701729
@wraps(func)
702-
async def func_wrapper(*args, **kwargs):
703-
return await _call_async(*args, **kwargs)
730+
async def func_wrapper(*args: _P.args, **kwargs: _P.kwargs) -> _R:
731+
return await _call_async(*args, **kwargs) # type: ignore[arg-type]
704732

705733
else:
706734

707735
@wraps(func)
708-
def func_wrapper(*args, **kwargs):
709-
return _call(*args, **kwargs)
736+
def func_wrapper(*args: _P.args, **kwargs: _P.kwargs) -> _R:
737+
return _call(*args, **kwargs) # type: ignore[arg-type]
710738

711739
def _clear_cache():
712740
"""Clear the cache."""
@@ -751,13 +779,13 @@ def _precache_value(*args, value_to_cache, **kwds):
751779
kwargs = _convert_args_kwargs(func, _is_method=core.func_is_method, args=args, kwds=kwds)
752780
return core.precache_value((), kwargs, value_to_cache)
753781

754-
func_wrapper.clear_cache = _clear_cache
755-
func_wrapper.clear_being_calculated = _clear_being_calculated
756-
func_wrapper.aclear_cache = _aclear_cache
757-
func_wrapper.aclear_being_calculated = _aclear_being_calculated
758-
func_wrapper.cache_dpath = _cache_dpath
759-
func_wrapper.precache_value = _precache_value
760-
func_wrapper.metrics = cache_metrics # Expose metrics object
761-
return func_wrapper
782+
func_wrapper.clear_cache = _clear_cache # type: ignore[attr-defined]
783+
func_wrapper.clear_being_calculated = _clear_being_calculated # type: ignore[attr-defined]
784+
func_wrapper.aclear_cache = _aclear_cache # type: ignore[attr-defined]
785+
func_wrapper.aclear_being_calculated = _aclear_being_calculated # type: ignore[attr-defined]
786+
func_wrapper.cache_dpath = _cache_dpath # type: ignore[attr-defined]
787+
func_wrapper.precache_value = _precache_value # type: ignore[attr-defined]
788+
func_wrapper.metrics = cache_metrics # type: ignore[attr-defined]
789+
return func_wrapper # type: ignore[return-value]
762790

763791
return _cachier_decorator

tests/requirements.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ pytest-rerunfailures # for retrying flaky tests
77
coverage
88
pytest-cov
99
birch
10+
# type checking
11+
mypy
1012
# to be able to run `python setup.py checkdocs`
1113
collective.checkdocs
1214
pygments

0 commit comments

Comments
 (0)