Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Return Promise for lazy functions. #689

Merged
merged 7 commits into from
Aug 27, 2022
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
5 changes: 3 additions & 2 deletions django-stubs/core/validators.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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] = ...,
Expand Down
34 changes: 30 additions & 4 deletions django-stubs/utils/functional.pyi
Original file line number Diff line number Diff line change
@@ -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")

Expand All @@ -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): ...
PIG208 marked this conversation as resolved.
Show resolved Hide resolved

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: ...

Expand Down
24 changes: 15 additions & 9 deletions django-stubs/utils/translation/__init__.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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: ...

Expand Down
2 changes: 2 additions & 0 deletions mypy_django_plugin/lib/fullnames.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
4 changes: 4 additions & 0 deletions mypy_django_plugin/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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]]:
Expand Down
35 changes: 35 additions & 0 deletions mypy_django_plugin/transformers/functional.py
Original file line number Diff line number Diff line change
@@ -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),
)
33 changes: 33 additions & 0 deletions tests/typecheck/utils/test_functional.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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)