Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion CHANGES.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
14 changes: 9 additions & 5 deletions reg/arginfo.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,16 +52,17 @@ 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,
also show other getarginfo() information, and for readability.
"""
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]
Expand Down Expand Up @@ -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


Expand Down
30 changes: 30 additions & 0 deletions reg/tests/test_arginfo.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from __future__ import annotations

from dataclasses import dataclass
from typing import TYPE_CHECKING, Any, assert_type

import pytest
Expand Down Expand Up @@ -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
Loading