From a8d99fe3391348d80e60f604291825625fa6f69b Mon Sep 17 00:00:00 2001 From: KotlinIsland <65446343+kotlinisland@users.noreply.github.com> Date: Mon, 6 Jan 2025 17:04:54 +1000 Subject: [PATCH] fix `functools.cache` and `operator.attrgetter` --- CHANGELOG.md | 4 +++ docs/source/based_features.rst | 20 +++++++++++++ mypy/test/typetest/__init__.py | 0 mypy/test/typetest/functools.py | 47 ++++++++++++++++++++++++++++++ mypy/test/typetest/operator.py | 6 ++++ mypy/test/typetest_functools.py | 43 --------------------------- mypy/typeshed/stdlib/builtins.pyi | 1 + mypy/typeshed/stdlib/functools.pyi | 28 ++++++++++-------- mypy/typeshed/stdlib/operator.pyi | 10 +++---- mypy/typeshed/stdlib/types.pyi | 2 ++ 10 files changed, 101 insertions(+), 60 deletions(-) create mode 100644 mypy/test/typetest/__init__.py create mode 100644 mypy/test/typetest/functools.py create mode 100644 mypy/test/typetest/operator.py delete mode 100644 mypy/test/typetest_functools.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 68d1bae2a..738997f59 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +## [2.9.1] +### Fixed +- definition of `functools.cache` and `operator.attrgetter` + ## [2.9.0] ### Added - `collections.User*` should have `__repr__` diff --git a/docs/source/based_features.rst b/docs/source/based_features.rst index ffc6c1a00..8d424836f 100644 --- a/docs/source/based_features.rst +++ b/docs/source/based_features.rst @@ -325,6 +325,26 @@ The defined type of a variable will be shown in the message for `reveal_type`: reveal_type(a) # Revealed type is "int" (narrowed from "object") +Typed ``functools.Cache`` +------------------------- + +In mypy, ``functools.cache`` is unsafe: + +.. code-block:: python + + @cache + def f(): ... + f(1, 2, 3) # no error + +This is resolved: + +.. code-block:: python + + @cache + def f(): ... + f(1, 2, 3) # error: expected no args + + Checked f-strings ----------------- diff --git a/mypy/test/typetest/__init__.py b/mypy/test/typetest/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/mypy/test/typetest/functools.py b/mypy/test/typetest/functools.py new file mode 100644 index 000000000..9aa687adb --- /dev/null +++ b/mypy/test/typetest/functools.py @@ -0,0 +1,47 @@ +from __future__ import annotations + +from functools import lru_cache + +# use `basedtyping` when we drop python 3.8 +from types import MethodType +from typing import TYPE_CHECKING, Callable +from typing_extensions import assert_type + +from mypy_extensions import Arg + +# use `functools.cache` when we drop python 3.8 +cache = lru_cache(None) + + +class A: + @cache + def m(self, a: list[int]): ... + + @classmethod + @cache + def c(cls, a: list[int]): ... + + @staticmethod + @cache + def s(a: list[int]): ... + + +@cache +def f(a: list[int]): ... + + +if TYPE_CHECKING: + from functools import _HashCallable, _LruCacheWrapperBase, _LruCacheWrapperMethod + + ExpectedFunction = _LruCacheWrapperBase[Callable[[Arg(list[int], "a")], None]] + ExpectedMethod = _LruCacheWrapperMethod[Callable[[Arg(list[int], "a")], None]] + ExpectedMethodNone = _LruCacheWrapperMethod["() -> None"] + a = A() + a.m([1]) # type: ignore[arg-type] + assert_type(a.m, ExpectedMethod) + assert_type(a.c, ExpectedMethod) + # this is wrong, it shouldn't eat the `a` argument, but this is because of mypy `staticmethod` special casing + assert_type(a.s, ExpectedMethodNone) + assert_type(a.s, MethodType & (_LruCacheWrapperBase[Callable[[Arg(list[int], "a")], None]] | _HashCallable)) # type: ignore[assert-type] + assert_type(f.__get__(1), ExpectedMethodNone) + f([1]) # type: ignore[arg-type] diff --git a/mypy/test/typetest/operator.py b/mypy/test/typetest/operator.py new file mode 100644 index 000000000..fd1cec88d --- /dev/null +++ b/mypy/test/typetest/operator.py @@ -0,0 +1,6 @@ +from operator import attrgetter +from typing_extensions import assert_type + + +def check_attrgetter(): + assert_type(attrgetter("name"), attrgetter[object]) diff --git a/mypy/test/typetest_functools.py b/mypy/test/typetest_functools.py deleted file mode 100644 index c93e63b0f..000000000 --- a/mypy/test/typetest_functools.py +++ /dev/null @@ -1,43 +0,0 @@ -from __future__ import annotations - -from functools import _lru_cache_wrapper, lru_cache - -# use `basedtyping` when we drop python 3.8 -from types import FunctionType -from typing import TYPE_CHECKING, Callable -from typing_extensions import assert_type - -from mypy_extensions import Arg - -cache = lru_cache(None) - - -class A: - @cache - def m(self, a: int): ... - - @classmethod - @cache - def c(cls, a: int): ... - - @staticmethod - @cache - def s(a: int): ... - - -@cache -def f(a: int): ... - - -a = A() -assert_type(a.m.__call__, Callable[[Arg(int, "a")], None]) -assert_type(a.c.__call__, Callable[[Arg(int, "a")], None]) -# should be fixed when 1.14 fork is merged -assert_type(a.s.__call__, Callable[[Arg(int, "a")], None]) # type: ignore[assert-type] - -if TYPE_CHECKING: - assert_type(f, _lru_cache_wrapper[FunctionType[[Arg(int, "a")], None]]) - from functools import _HashCallable, _LruCacheWrapperBase - - assert_type(f.__get__(1), _LruCacheWrapperBase[Callable[[], None]]) - assert_type(f.__call__, FunctionType[[Arg(int, "a")], None] | _HashCallable) diff --git a/mypy/typeshed/stdlib/builtins.pyi b/mypy/typeshed/stdlib/builtins.pyi index 0d02a1845..5badc5433 100644 --- a/mypy/typeshed/stdlib/builtins.pyi +++ b/mypy/typeshed/stdlib/builtins.pyi @@ -140,6 +140,7 @@ class staticmethod(Generic[_P, _R_co]): @property def __isabstractmethod__(self) -> bool: ... def __init__(self, f: Callable[_P, _R_co], /) -> None: ... + # TODO: doesn't actually return `_NamedCallable`, it returns the callable that was passed to the constructor @overload def __get__(self, instance: None, owner: type, /) -> _NamedCallable[_P, _R_co]: ... @overload diff --git a/mypy/typeshed/stdlib/functools.pyi b/mypy/typeshed/stdlib/functools.pyi index 1d4410316..8e5b6c6a1 100644 --- a/mypy/typeshed/stdlib/functools.pyi +++ b/mypy/typeshed/stdlib/functools.pyi @@ -58,10 +58,9 @@ class _HashCallable(Protocol): def __call__(self, /, *args: Hashable, **kwargs: Hashable) -> Never: ... @type_check_only -class _LruCacheWrapperBase(Protocol[_out_TCallable]): - __wrapped__: Final[_out_TCallable] = ... # type: ignore[misc] - __call__: Final[_out_TCallable | _HashCallable] = ... # type: ignore[misc] - +class _LruCacheWrapperBase(Generic[_out_TCallable]): + __wrapped__: Final[_out_TCallable] # type: ignore[misc] + __call__: Final[_out_TCallable | _HashCallable] # type: ignore[misc] def cache_info(self) -> _CacheInfo: ... def cache_clear(self) -> None: ... @@ -71,23 +70,28 @@ class _LruCacheWrapperBase(Protocol[_out_TCallable]): def __copy__(self) -> Self: ... def __deepcopy__(self, memo: Any, /) -> Self: ... -@final + +# replace with `Method & X` once #856 is resolved +@type_check_only +class _LruCacheWrapperMethod(MethodType, _LruCacheWrapperBase[_out_TCallable]): # type: ignore[misc] + __call__: Final[_out_TCallable | _HashCallable] # type: ignore[misc, assignment] + + # actually defined in `_functools` +@final class _lru_cache_wrapper(_LruCacheWrapperBase[_out_TCallable]): def __init__(self, user_function: Never, maxsize: Never, typed: Never, cache_info_type: Never): ... - # TODO: reintroduce this once mypy 1.14 fork is merged - # @overload - # def __get__(self, instance: None, owner: object) -> Self: ... - # @overload + @overload + def __get__(self, instance: None, owner: object) -> Self: ... + @overload def __get__( self: _lru_cache_wrapper[Callable[Concatenate[Never, _PWrapped], _RWrapped]], instance: object, owner: type[object] | None = None, /, - # ideally, we would capture the Callable here, and intersect with `MethodType` - ) -> _LruCacheWrapperBase[Callable[_PWrapped, _RWrapped]]: ... - + # ideally we could capture the `Callable` to account for subtypes and intersect with `MethodType` + ) -> _LruCacheWrapperMethod[Callable[_PWrapped, _RWrapped]]: ... @overload def lru_cache(maxsize: int | None = 128, typed: bool = False) -> FunctionType[[_TCallable], _lru_cache_wrapper[_TCallable]]: ... diff --git a/mypy/typeshed/stdlib/operator.pyi b/mypy/typeshed/stdlib/operator.pyi index b73e037f3..6c104b362 100644 --- a/mypy/typeshed/stdlib/operator.pyi +++ b/mypy/typeshed/stdlib/operator.pyi @@ -183,15 +183,15 @@ if sys.version_info >= (3, 11): @final class attrgetter(Generic[_T_co]): @overload - def __new__(cls, attr: str, /) -> attrgetter[Any]: ... + def __new__(cls, attr: str, /) -> attrgetter[object]: ... @overload - def __new__(cls, attr: str, attr2: str, /) -> attrgetter[tuple[Any, Any]]: ... + def __new__(cls, attr: str, attr2: str, /) -> attrgetter[tuple[object, object]]: ... @overload - def __new__(cls, attr: str, attr2: str, attr3: str, /) -> attrgetter[tuple[Any, Any, Any]]: ... + def __new__(cls, attr: str, attr2: str, attr3: str, /) -> attrgetter[tuple[object, object, object]]: ... @overload - def __new__(cls, attr: str, attr2: str, attr3: str, attr4: str, /) -> attrgetter[tuple[Any, Any, Any, Any]]: ... + def __new__(cls, attr: str, attr2: str, attr3: str, attr4: str, /) -> attrgetter[tuple[object, object, object, object]]: ... @overload - def __new__(cls, attr: str, /, *attrs: str) -> attrgetter[tuple[Any, ...]]: ... + def __new__(cls, attr: str, /, *attrs: str) -> attrgetter[tuple[object, ...]]: ... def __call__(self, obj: Any, /) -> _T_co: ... @final diff --git a/mypy/typeshed/stdlib/types.pyi b/mypy/typeshed/stdlib/types.pyi index 980da37c9..8b5c978ec 100644 --- a/mypy/typeshed/stdlib/types.pyi +++ b/mypy/typeshed/stdlib/types.pyi @@ -73,6 +73,7 @@ _P = ParamSpec("_P") # Make sure this class definition stays roughly in line with `builtins.function` # This class is special-cased +# is actually `builtins.function` @final class FunctionType: @property @@ -430,6 +431,7 @@ class CoroutineType(Coroutine[_YieldT_co, _SendT_contra, _ReturnT_co]): if sys.version_info >= (3, 13): def __class_getitem__(cls, item: Any, /) -> Any: ... +# is actually `builtins.method` @final class MethodType: @property