Skip to content

Commit 18a0551

Browse files
authored
Return Promise for lazy functions. (#689)
* Type the return value of lazy translation functions as Promise. The return value of the lazy translation functions is a proxied `Promise` object. https://github.com/django/django/blob/3.2.6/django/utils/translation/__init__.py#L135-L221. Signed-off-by: Zixuan James Li <[email protected]> * Mark unicode translation functions for deprecation. https://docs.djangoproject.com/en/4.0/releases/4.0/#features-removed-in-4-0. Signed-off-by: Zixuan James Li <[email protected]> * Add proxied functions for Promise. Although there is nothing defined in `Promise` itself, the only instances of `Promise` are created by the `lazy` function, with magic methods defined on it. https://github.com/django/django/blob/3.2.6/django/utils/functional.py#L84-L191. Signed-off-by: Zixuan James Li <[email protected]> * Add _StrPromise as a special type for Promise objects for str. This allows the user to access methods defined on lazy strings while still letting mypy be aware of that they are not instances of `str`. The definitions for some of the magic methods are pulled from typeshed. We need those definitions in the stubs so that `_StrPromise` objects will work properly with operators, as refining operator types is tricky with the mypy plugins API. The rest of the methods will be covered by an attribute hook. Signed-off-by: Zixuan James Li <[email protected]> * Implement _StrPromise attribute hook. This implements an attribute hook that provides type information for methods that are available on `builtins.str` for `_StrPromise` except the supported operators. This allows us to avoid copying stubs from the builtins for all supported methods on `str`. Signed-off-by: Zixuan James Li <[email protected]> * Allow message being a _StrPromise object for RegexValidator. One intended usage of lazystr is to postpone the translation of the error message of a validation error. It is possible that we pass a Promise (specifically _StrPromise) and only evaluate it when a ValidationError is raised. Signed-off-by: Zixuan James Li <[email protected]> * Refactor _StrPromise attribtue hook with analyze_member_access. Signed-off-by: Zixuan James Li <[email protected]> Signed-off-by: Zixuan James Li <[email protected]>
1 parent 11ded9d commit 18a0551

File tree

7 files changed

+122
-15
lines changed

7 files changed

+122
-15
lines changed

Diff for: django-stubs/core/validators.pyi

+3-2
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ from re import RegexFlag
33
from typing import Any, Callable, Collection, Dict, List, Optional, Pattern, Sequence, Sized, Tuple, Union
44

55
from django.core.files.base import File
6+
from django.utils.functional import _StrPromise
67

78
EMPTY_VALUES: Any
89

@@ -11,14 +12,14 @@ _ValidatorCallable = Callable[[Any], None]
1112

1213
class RegexValidator:
1314
regex: _Regex = ... # Pattern[str] on instance, but may be str on class definition
14-
message: str = ...
15+
message: Union[str, _StrPromise] = ...
1516
code: str = ...
1617
inverse_match: bool = ...
1718
flags: int = ...
1819
def __init__(
1920
self,
2021
regex: Optional[_Regex] = ...,
21-
message: Optional[str] = ...,
22+
message: Union[str, _StrPromise, None] = ...,
2223
code: Optional[str] = ...,
2324
inverse_match: Optional[bool] = ...,
2425
flags: Optional[RegexFlag] = ...,

Diff for: django-stubs/utils/functional.pyi

+30-4
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
from functools import wraps as wraps # noqa: F401
2-
from typing import Any, Callable, Generic, List, Optional, Tuple, Type, TypeVar, Union, overload
2+
from typing import Any, Callable, Generic, List, Optional, Sequence, Tuple, Type, TypeVar, Union, overload
33

44
from django.db.models.base import Model
5-
from typing_extensions import Protocol
5+
from typing_extensions import Protocol, SupportsIndex
66

77
_T = TypeVar("_T")
88

@@ -15,12 +15,38 @@ class cached_property(Generic[_T]):
1515
@overload
1616
def __get__(self, instance: object, cls: Type[Any] = ...) -> _T: ...
1717

18-
class Promise: ...
18+
# Promise is only subclassed by a proxy class defined in the lazy function
19+
# so it makes sense for it to have all the methods available in that proxy class
20+
class Promise:
21+
def __init__(self, args: Any, kw: Any) -> None: ...
22+
def __reduce__(self) -> Tuple[Any, Tuple[Any]]: ...
23+
def __lt__(self, other: Any) -> bool: ...
24+
def __mod__(self, rhs: Any) -> Any: ...
25+
def __add__(self, other: Any) -> Any: ...
26+
def __radd__(self, other: Any) -> Any: ...
27+
def __deepcopy__(self, memo: Any): ...
28+
29+
class _StrPromise(Promise, Sequence[str]):
30+
def __add__(self, __s: str) -> str: ...
31+
# Incompatible with Sequence.__contains__
32+
def __contains__(self, __o: str) -> bool: ... # type: ignore[override]
33+
def __ge__(self, __x: str) -> bool: ...
34+
def __getitem__(self, __i: SupportsIndex | slice) -> str: ...
35+
def __gt__(self, __x: str) -> bool: ...
36+
def __le__(self, __x: str) -> bool: ...
37+
# __len__ needed here because it defined abstract in Sequence[str]
38+
def __len__(self) -> int: ...
39+
def __lt__(self, __x: str) -> bool: ...
40+
def __mod__(self, __x: Any) -> str: ...
41+
def __mul__(self, __n: SupportsIndex) -> str: ...
42+
def __rmul__(self, __n: SupportsIndex) -> str: ...
43+
# Mypy requires this for the attribute hook to take effect
44+
def __getattribute__(self, __name: str) -> Any: ...
1945

2046
_C = TypeVar("_C", bound=Callable)
2147

2248
def lazy(func: _C, *resultclasses: Any) -> _C: ...
23-
def lazystr(text: Any) -> str: ...
49+
def lazystr(text: Any) -> _StrPromise: ...
2450
def keep_lazy(*resultclasses: Any) -> Callable: ...
2551
def keep_lazy_text(func: Callable) -> Callable: ...
2652

Diff for: django-stubs/utils/translation/__init__.pyi

+15-9
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ from contextlib import ContextDecorator
44
from typing import Any, Callable, Optional, Type, Union
55

66
from django.http.request import HttpRequest
7+
from django.utils.functional import _StrPromise
78

89
LANGUAGE_SESSION_KEY: str
910

@@ -26,21 +27,26 @@ class Trans:
2627
def __getattr__(self, real_name: Any): ...
2728

2829
def gettext_noop(message: str) -> str: ...
29-
def ugettext_noop(message: str) -> str: ...
3030
def gettext(message: str) -> str: ...
31-
def ugettext(message: str) -> str: ...
3231
def ngettext(singular: str, plural: str, number: float) -> str: ...
33-
def ungettext(singular: str, plural: str, number: float) -> str: ...
3432
def pgettext(context: str, message: str) -> str: ...
3533
def npgettext(context: str, singular: str, plural: str, number: int) -> str: ...
3634

37-
gettext_lazy = gettext
38-
pgettext_lazy = pgettext
35+
# lazy evaluated translation functions
36+
def gettext_lazy(message: str) -> _StrPromise: ...
37+
def pgettext_lazy(context: str, message: str) -> _StrPromise: ...
38+
def ngettext_lazy(singular: str, plural: str, number: Union[int, str, None] = ...) -> _StrPromise: ...
39+
def npgettext_lazy(context: str, singular: str, plural: str, number: Union[int, str, None] = ...) -> _StrPromise: ...
40+
41+
# NOTE: These translation functions are deprecated and removed in Django 4.0. We should remove them when we drop
42+
# support for 3.2
43+
def ugettext_noop(message: str) -> str: ...
44+
def ugettext(message: str) -> str: ...
45+
def ungettext(singular: str, plural: str, number: float) -> str: ...
46+
47+
ugettext_lazy = gettext_lazy
48+
ungettext_lazy = ngettext_lazy
3949

40-
def ugettext_lazy(message: str) -> str: ...
41-
def ngettext_lazy(singular: str, plural: str, number: Union[int, str, None] = ...) -> str: ...
42-
def ungettext_lazy(singular: str, plural: str, number: Union[int, str, None] = ...) -> str: ...
43-
def npgettext_lazy(context: str, singular: str, plural: str, number: Union[int, str, None] = ...) -> str: ...
4450
def activate(language: str) -> None: ...
4551
def deactivate() -> None: ...
4652

Diff for: mypy_django_plugin/lib/fullnames.py

+2
Original file line numberDiff line numberDiff line change
@@ -41,3 +41,5 @@
4141
F_EXPRESSION_FULLNAME = "django.db.models.expressions.F"
4242

4343
ANY_ATTR_ALLOWED_CLASS_FULLNAME = "django_stubs_ext.AnyAttrAllowed"
44+
45+
STR_PROMISE_FULLNAME = "django.utils.functional._StrPromise"

Diff for: mypy_django_plugin/main.py

+4
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
from mypy_django_plugin.django.context import DjangoContext
2323
from mypy_django_plugin.lib import fullnames, helpers
2424
from mypy_django_plugin.transformers import fields, forms, init_create, meta, querysets, request, settings
25+
from mypy_django_plugin.transformers.functional import resolve_str_promise_attribute
2526
from mypy_django_plugin.transformers.managers import (
2627
create_new_manager_class_from_from_queryset_method,
2728
resolve_manager_method,
@@ -285,6 +286,9 @@ def get_attribute_hook(self, fullname: str) -> Optional[Callable[[AttributeConte
285286
):
286287
return resolve_manager_method
287288

289+
if info and info.has_base(fullnames.STR_PROMISE_FULLNAME):
290+
return resolve_str_promise_attribute
291+
288292
return None
289293

290294
def get_type_analyze_hook(self, fullname: str) -> Optional[Callable[[AnalyzeTypeContext], MypyType]]:

Diff for: mypy_django_plugin/transformers/functional.py

+35
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
from mypy.checkmember import analyze_member_access
2+
from mypy.errorcodes import ATTR_DEFINED
3+
from mypy.nodes import CallExpr, MemberExpr
4+
from mypy.plugin import AttributeContext
5+
from mypy.types import AnyType, Instance
6+
from mypy.types import Type as MypyType
7+
from mypy.types import TypeOfAny
8+
9+
from mypy_django_plugin.lib import helpers
10+
11+
12+
def resolve_str_promise_attribute(ctx: AttributeContext) -> MypyType:
13+
if isinstance(ctx.context, MemberExpr):
14+
method_name = ctx.context.name
15+
elif isinstance(ctx.context, CallExpr) and isinstance(ctx.context.callee, MemberExpr):
16+
method_name = ctx.context.callee.name
17+
else:
18+
ctx.api.fail(f'Cannot resolve the attribute of "{ctx.type}"', ctx.context, code=ATTR_DEFINED)
19+
return AnyType(TypeOfAny.from_error)
20+
21+
str_info = helpers.lookup_fully_qualified_typeinfo(helpers.get_typechecker_api(ctx), f"builtins.str")
22+
assert str_info is not None
23+
str_type = Instance(str_info, [])
24+
return analyze_member_access(
25+
method_name,
26+
str_type,
27+
ctx.context,
28+
is_lvalue=False,
29+
is_super=False,
30+
# operators are already handled with magic methods defined in the stubs for _StrPromise
31+
is_operator=False,
32+
msg=ctx.api.msg,
33+
original_type=ctx.type,
34+
chk=helpers.get_typechecker_api(ctx),
35+
)

Diff for: tests/typecheck/utils/test_functional.yml

+33
Original file line numberDiff line numberDiff line change
@@ -16,3 +16,36 @@
1616
f = Foo()
1717
reveal_type(f.attr) # N: Revealed type is "builtins.list[builtins.str]"
1818
f.attr.name # E: "List[str]" has no attribute "name"
19+
20+
- case: str_promise_proxy
21+
main: |
22+
from typing import Union
23+
24+
from django.utils.functional import Promise, lazystr, _StrPromise
25+
26+
s = lazystr("asd")
27+
28+
reveal_type(s) # N: Revealed type is "django.utils.functional._StrPromise"
29+
30+
reveal_type(s.format("asd")) # N: Revealed type is "builtins.str"
31+
reveal_type(s.capitalize()) # N: Revealed type is "builtins.str"
32+
reveal_type(s.swapcase) # N: Revealed type is "def () -> builtins.str"
33+
reveal_type(s.__getnewargs__) # N: Revealed type is "def () -> Tuple[builtins.str]"
34+
s.nonsense # E: "_StrPromise" has no attribute "nonsense"
35+
f: Union[_StrPromise, str]
36+
reveal_type(f.format("asd")) # N: Revealed type is "builtins.str"
37+
reveal_type(f + "asd") # N: Revealed type is "builtins.str"
38+
reveal_type("asd" + f) # N: Revealed type is "Union[Any, builtins.str]"
39+
40+
reveal_type(s + "bar") # N: Revealed type is "builtins.str"
41+
reveal_type("foo" + s) # N: Revealed type is "Any"
42+
reveal_type(s % "asd") # N: Revealed type is "builtins.str"
43+
44+
def foo(content: str) -> None:
45+
...
46+
47+
def bar(content: Promise) -> None:
48+
...
49+
50+
foo(s) # E: Argument 1 to "foo" has incompatible type "_StrPromise"; expected "str"
51+
bar(s)

0 commit comments

Comments
 (0)