diff --git a/django-stubs/core/validators.pyi b/django-stubs/core/validators.pyi index 89653a556..d5b24d44b 100644 --- a/django-stubs/core/validators.pyi +++ b/django-stubs/core/validators.pyi @@ -3,6 +3,7 @@ from re import RegexFlag from typing import Any, Callable, Collection, Dict, List, Optional, Pattern, Sequence, Sized, Tuple, Union from django.core.files.base import File +from django.utils.functional import _StrPromise EMPTY_VALUES: Any @@ -11,14 +12,14 @@ _ValidatorCallable = Callable[[Any], None] class RegexValidator: regex: _Regex = ... # Pattern[str] on instance, but may be str on class definition - message: str = ... + message: Union[str, _StrPromise] = ... code: str = ... inverse_match: bool = ... flags: int = ... def __init__( self, regex: Optional[_Regex] = ..., - message: Optional[str] = ..., + message: Union[str, _StrPromise, None] = ..., code: Optional[str] = ..., inverse_match: Optional[bool] = ..., flags: Optional[RegexFlag] = ..., diff --git a/django-stubs/utils/functional.pyi b/django-stubs/utils/functional.pyi index 0f1e3495a..677582bc1 100644 --- a/django-stubs/utils/functional.pyi +++ b/django-stubs/utils/functional.pyi @@ -1,8 +1,8 @@ from functools import wraps as wraps # noqa: F401 -from typing import Any, Callable, Generic, List, Optional, Tuple, Type, TypeVar, Union, overload +from typing import Any, Callable, Generic, List, Optional, Sequence, Tuple, Type, TypeVar, Union, overload from django.db.models.base import Model -from typing_extensions import Protocol +from typing_extensions import Protocol, SupportsIndex _T = TypeVar("_T") @@ -15,12 +15,38 @@ class cached_property(Generic[_T]): @overload def __get__(self, instance: object, cls: Type[Any] = ...) -> _T: ... -class Promise: ... +# Promise is only subclassed by a proxy class defined in the lazy function +# so it makes sense for it to have all the methods available in that proxy class +class Promise: + def __init__(self, args: Any, kw: Any) -> None: ... + def __reduce__(self) -> Tuple[Any, Tuple[Any]]: ... + def __lt__(self, other: Any) -> bool: ... + def __mod__(self, rhs: Any) -> Any: ... + def __add__(self, other: Any) -> Any: ... + def __radd__(self, other: Any) -> Any: ... + def __deepcopy__(self, memo: Any): ... + +class _StrPromise(Promise, Sequence[str]): + def __add__(self, __s: str) -> str: ... + # Incompatible with Sequence.__contains__ + def __contains__(self, __o: str) -> bool: ... # type: ignore[override] + def __ge__(self, __x: str) -> bool: ... + def __getitem__(self, __i: SupportsIndex | slice) -> str: ... + def __gt__(self, __x: str) -> bool: ... + def __le__(self, __x: str) -> bool: ... + # __len__ needed here because it defined abstract in Sequence[str] + def __len__(self) -> int: ... + def __lt__(self, __x: str) -> bool: ... + def __mod__(self, __x: Any) -> str: ... + def __mul__(self, __n: SupportsIndex) -> str: ... + def __rmul__(self, __n: SupportsIndex) -> str: ... + # Mypy requires this for the attribute hook to take effect + def __getattribute__(self, __name: str) -> Any: ... _C = TypeVar("_C", bound=Callable) def lazy(func: _C, *resultclasses: Any) -> _C: ... -def lazystr(text: Any) -> str: ... +def lazystr(text: Any) -> _StrPromise: ... def keep_lazy(*resultclasses: Any) -> Callable: ... def keep_lazy_text(func: Callable) -> Callable: ... diff --git a/django-stubs/utils/translation/__init__.pyi b/django-stubs/utils/translation/__init__.pyi index a867ca686..d82de72c9 100644 --- a/django-stubs/utils/translation/__init__.pyi +++ b/django-stubs/utils/translation/__init__.pyi @@ -4,6 +4,7 @@ from contextlib import ContextDecorator from typing import Any, Callable, Optional, Type, Union from django.http.request import HttpRequest +from django.utils.functional import _StrPromise LANGUAGE_SESSION_KEY: str @@ -26,21 +27,26 @@ class Trans: def __getattr__(self, real_name: Any): ... def gettext_noop(message: str) -> str: ... -def ugettext_noop(message: str) -> str: ... def gettext(message: str) -> str: ... -def ugettext(message: str) -> str: ... def ngettext(singular: str, plural: str, number: float) -> str: ... -def ungettext(singular: str, plural: str, number: float) -> str: ... def pgettext(context: str, message: str) -> str: ... def npgettext(context: str, singular: str, plural: str, number: int) -> str: ... -gettext_lazy = gettext -pgettext_lazy = pgettext +# lazy evaluated translation functions +def gettext_lazy(message: str) -> _StrPromise: ... +def pgettext_lazy(context: str, message: str) -> _StrPromise: ... +def ngettext_lazy(singular: str, plural: str, number: Union[int, str, None] = ...) -> _StrPromise: ... +def npgettext_lazy(context: str, singular: str, plural: str, number: Union[int, str, None] = ...) -> _StrPromise: ... + +# NOTE: These translation functions are deprecated and removed in Django 4.0. We should remove them when we drop +# support for 3.2 +def ugettext_noop(message: str) -> str: ... +def ugettext(message: str) -> str: ... +def ungettext(singular: str, plural: str, number: float) -> str: ... + +ugettext_lazy = gettext_lazy +ungettext_lazy = ngettext_lazy -def ugettext_lazy(message: str) -> str: ... -def ngettext_lazy(singular: str, plural: str, number: Union[int, str, None] = ...) -> str: ... -def ungettext_lazy(singular: str, plural: str, number: Union[int, str, None] = ...) -> str: ... -def npgettext_lazy(context: str, singular: str, plural: str, number: Union[int, str, None] = ...) -> str: ... def activate(language: str) -> None: ... def deactivate() -> None: ... diff --git a/mypy_django_plugin/lib/fullnames.py b/mypy_django_plugin/lib/fullnames.py index bb530d1df..17027aa22 100644 --- a/mypy_django_plugin/lib/fullnames.py +++ b/mypy_django_plugin/lib/fullnames.py @@ -41,3 +41,5 @@ F_EXPRESSION_FULLNAME = "django.db.models.expressions.F" ANY_ATTR_ALLOWED_CLASS_FULLNAME = "django_stubs_ext.AnyAttrAllowed" + +STR_PROMISE_FULLNAME = "django.utils.functional._StrPromise" diff --git a/mypy_django_plugin/main.py b/mypy_django_plugin/main.py index ca4b680f0..dde0b20d5 100644 --- a/mypy_django_plugin/main.py +++ b/mypy_django_plugin/main.py @@ -22,6 +22,7 @@ from mypy_django_plugin.django.context import DjangoContext from mypy_django_plugin.lib import fullnames, helpers from mypy_django_plugin.transformers import fields, forms, init_create, meta, querysets, request, settings +from mypy_django_plugin.transformers.functional import resolve_str_promise_attribute from mypy_django_plugin.transformers.managers import ( create_new_manager_class_from_from_queryset_method, resolve_manager_method, @@ -285,6 +286,9 @@ def get_attribute_hook(self, fullname: str) -> Optional[Callable[[AttributeConte ): return resolve_manager_method + if info and info.has_base(fullnames.STR_PROMISE_FULLNAME): + return resolve_str_promise_attribute + return None def get_type_analyze_hook(self, fullname: str) -> Optional[Callable[[AnalyzeTypeContext], MypyType]]: diff --git a/mypy_django_plugin/transformers/functional.py b/mypy_django_plugin/transformers/functional.py new file mode 100644 index 000000000..ebea7cc40 --- /dev/null +++ b/mypy_django_plugin/transformers/functional.py @@ -0,0 +1,35 @@ +from mypy.checkmember import analyze_member_access +from mypy.errorcodes import ATTR_DEFINED +from mypy.nodes import CallExpr, MemberExpr +from mypy.plugin import AttributeContext +from mypy.types import AnyType, Instance +from mypy.types import Type as MypyType +from mypy.types import TypeOfAny + +from mypy_django_plugin.lib import helpers + + +def resolve_str_promise_attribute(ctx: AttributeContext) -> MypyType: + if isinstance(ctx.context, MemberExpr): + method_name = ctx.context.name + elif isinstance(ctx.context, CallExpr) and isinstance(ctx.context.callee, MemberExpr): + method_name = ctx.context.callee.name + else: + ctx.api.fail(f'Cannot resolve the attribute of "{ctx.type}"', ctx.context, code=ATTR_DEFINED) + return AnyType(TypeOfAny.from_error) + + str_info = helpers.lookup_fully_qualified_typeinfo(helpers.get_typechecker_api(ctx), f"builtins.str") + assert str_info is not None + str_type = Instance(str_info, []) + return analyze_member_access( + method_name, + str_type, + ctx.context, + is_lvalue=False, + is_super=False, + # operators are already handled with magic methods defined in the stubs for _StrPromise + is_operator=False, + msg=ctx.api.msg, + original_type=ctx.type, + chk=helpers.get_typechecker_api(ctx), + ) diff --git a/tests/typecheck/utils/test_functional.yml b/tests/typecheck/utils/test_functional.yml index eb4417bfa..cb9ac299e 100644 --- a/tests/typecheck/utils/test_functional.yml +++ b/tests/typecheck/utils/test_functional.yml @@ -16,3 +16,36 @@ f = Foo() reveal_type(f.attr) # N: Revealed type is "builtins.list[builtins.str]" f.attr.name # E: "List[str]" has no attribute "name" + +- case: str_promise_proxy + main: | + from typing import Union + + from django.utils.functional import Promise, lazystr, _StrPromise + + s = lazystr("asd") + + reveal_type(s) # N: Revealed type is "django.utils.functional._StrPromise" + + reveal_type(s.format("asd")) # N: Revealed type is "builtins.str" + reveal_type(s.capitalize()) # N: Revealed type is "builtins.str" + reveal_type(s.swapcase) # N: Revealed type is "def () -> builtins.str" + reveal_type(s.__getnewargs__) # N: Revealed type is "def () -> Tuple[builtins.str]" + s.nonsense # E: "_StrPromise" has no attribute "nonsense" + f: Union[_StrPromise, str] + reveal_type(f.format("asd")) # N: Revealed type is "builtins.str" + reveal_type(f + "asd") # N: Revealed type is "builtins.str" + reveal_type("asd" + f) # N: Revealed type is "Union[Any, builtins.str]" + + reveal_type(s + "bar") # N: Revealed type is "builtins.str" + reveal_type("foo" + s) # N: Revealed type is "Any" + reveal_type(s % "asd") # N: Revealed type is "builtins.str" + + def foo(content: str) -> None: + ... + + def bar(content: Promise) -> None: + ... + + foo(s) # E: Argument 1 to "foo" has incompatible type "_StrPromise"; expected "str" + bar(s)