Repository: typeddjango/django-stubs

Issue:
`gettext_lazy` should return a Promise object
# Bug report

The type annotation added in #126 is incorrect. 

<!--
Hi, thanks for submitting a bug. We appreciate that.

But, we will need some information about what's wrong to help you.
-->

## What's wrong

```
from django.utils.translation import gettext_lazy, ngettext_lazy, pgettext_lazy, ugettext_lazy
from django.utils.functional import Promise

a = gettext_lazy("Help")
b = ugettext_lazy("Help")
c = pgettext_lazy("Help")
d = ngettext_lazy("help", "helps")
e = ugettext_lazy("Help")

assert isinstance(a, Promise)
assert isinstance(b, Promise)
assert isinstance(c, Promise)
assert isinstance(d, Promise)
assert isinstance(e, Promise)
```

<!--
Describe what is not working.

Please, attach a traceback.
We would also appreciate a failing test case.
That is EXTREMELY helpful!
-->

## How is that should be

For all lazy functions above, django-stubs consider the return type to be `str`, which is actually incorrect. All of them are in fact instances of `django.utils.functional.Promise`

<!-- Describe how it should work. -->



Discussion:
@PIG208 PRs are welcome!
@sobolevn Just created a PR for this :)

Resolving patch:
diff --git a/mypy_django_plugin/lib/fullnames.py b/mypy_django_plugin/lib/fullnames.py
--- 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
--- 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
--- /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),
+    )


Test patch:
diff --git a/tests/typecheck/utils/test_functional.yml b/tests/typecheck/utils/test_functional.yml
--- 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)