diff --git a/CHANGES.txt b/CHANGES.txt index 5de7638..caab20f 100644 --- a/CHANGES.txt +++ b/CHANGES.txt @@ -4,7 +4,8 @@ CHANGES 1.0.1 (unreleased) ================== -- Nothing changed yet. +- Fix ``arginfo`` and ``arginfo.is_cached`` for unhashable callable instances + by using the existing bound-method cache fallback. 1.0.0 (2026-10-10) diff --git a/reg/arginfo.py b/reg/arginfo.py index 13da286..1c57d34 100644 --- a/reg/arginfo.py +++ b/reg/arginfo.py @@ -52,8 +52,9 @@ def arginfo(callable: Callable[..., Any]) -> inspect.FullArgSpec | None: arginfo returns ``None`` if given something that is not callable. - arginfo caches previous calls (except for instances with a - __call__), making calling it repeatedly cheap. + arginfo caches previous calls, making calling it repeatedly cheap. + Callable instances are cached by their bound __call__ method, so the + instances themselves need not be hashable. This was originally inspired by the pytest.core varnames() function, but has been completely rewritten to handle class constructors, @@ -61,7 +62,7 @@ def arginfo(callable: Callable[..., Any]) -> inspect.FullArgSpec | None: """ try: return arginfo._cache[callable] # pyright: ignore[reportPrivateUsage] - except KeyError: + except (KeyError, TypeError): # Try to get __call__ function from the cache. try: return arginfo._cache[callable.__call__] # type: ignore[operator] @@ -136,8 +137,11 @@ def arginfo(callable: Callable[..., Any]) -> inspect.FullArgSpec | None: def is_cached(callable: Callable[..., Any]) -> bool: - if callable in arginfo._cache: # pyright: ignore[reportPrivateUsage] - return True + try: + if callable in arginfo._cache: # pyright: ignore[reportPrivateUsage] + return True + except TypeError: + pass return callable.__call__ in arginfo._cache # type: ignore diff --git a/reg/tests/test_arginfo.py b/reg/tests/test_arginfo.py index c8890b0..858d992 100644 --- a/reg/tests/test_arginfo.py +++ b/reg/tests/test_arginfo.py @@ -1,5 +1,6 @@ from __future__ import annotations +from dataclasses import dataclass from typing import TYPE_CHECKING, Any, assert_type import pytest @@ -489,3 +490,32 @@ def __call__(self) -> None: assert not arginfo.is_cached(foo) arginfo(foo) assert arginfo.is_cached(foo) + + +@dataclass +class UnhashableCallable: + def __call__(self, value: int = 3, *, enabled: bool = False) -> int: + return value + + +def test_arginfo_unhashable_callable() -> None: + info = arginfo(UnhashableCallable()) + assert info is not None + assert info.args == ["value"] + assert info.defaults == (3,) + assert info.kwonlyargs == ["enabled"] + assert info.kwonlydefaults == {"enabled": False} + assert info.annotations == {"value": "int", "enabled": "bool", "return": "int"} + + +def test_arginfo_cache_unhashable_callable() -> None: + obj = UnhashableCallable() + assert not arginfo.is_cached(obj) + info = arginfo(obj) + assert arginfo.is_cached(obj) + assert arginfo(obj) is info + + +@pytest.mark.parametrize("obj", [[], {}]) +def test_arginfo_unhashable_non_callable(obj: Any) -> None: + assert arginfo(obj) is None