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

Conversation

PIG208
Copy link
Contributor

@PIG208 PIG208 commented Aug 11, 2021

Django uses a proxy method for lazy translation functions that actually returns instances of Promise instead of str. To be accurate, we should change the signatures for these functions.

Related issues

@@ -3,6 +3,7 @@ from contextlib import ContextDecorator
from typing import Any, Callable, Optional, Union

from django.core.handlers.wsgi import WSGIRequest
from django.utils.functional import Promise
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think that Promise is a new thing, isn't it? Looks like that latest 3.2.x does not have this primitive: https://github.com/django/django/tree/3.2.6/django/utils

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Moreover, even in latest master it is just a plain class with no methods or anything: https://github.com/django/django/blob/main/django/utils/functional.py#L68

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm.. Promise has been there for quite a while: django/django@e4e28d9. But you are right that Promise is just a plain class. Since the proxy method knows about the resultclasses, I guess creating generics like Promise[str] would be better?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe we can add these methods to Promise as well? https://github.com/django/django/blob/3.2.6/django/utils/functional.py#L84-L191

@PIG208
Copy link
Contributor Author

PIG208 commented Aug 2, 2022

Hi, I have updated the PR. I think it is probably not necessary to make Promise generic at this point, having these methods should be sufficient.

@sobolevn sobolevn closed this Aug 2, 2022
@sobolevn sobolevn reopened this Aug 2, 2022
django-stubs/utils/functional.pyi Outdated Show resolved Hide resolved
django-stubs/utils/functional.pyi Show resolved Hide resolved
django-stubs/utils/functional.pyi Outdated Show resolved Hide resolved
@PIG208
Copy link
Contributor Author

PIG208 commented Aug 2, 2022

There is still a problem with this. Promise proxied other methods of resultclasses, so we probably want those be available on the Promise object as well. Otherwise, these methods won't be usable. I will try to update the plugin for this.

@PIG208

This comment was marked as resolved.

@PIG208 PIG208 force-pushed the lazy branch 4 times, most recently from 0a8e100 to 71a6b57 Compare August 8, 2022 16:22
@PIG208
Copy link
Contributor Author

PIG208 commented Aug 8, 2022

I believe that this implementation should be good enough for another review. @sobolevn could you take a look at this?

I ditched the generic approach and reimplement this with a simpler plugin handling the use case for lazystr (and *_lazy as well) suggested by @andersk . This is tested with some additional test cases and the entire codebase of Zulip (edit, there still seems to be some issues to be resolved. Will update soon ). The earlier commits are left unchanged.

Looks like the CI is still failing for another unrelated issue. Will look into this in a separate PR probably. Otherwise, I think we are good to go.

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 <p359101898@gmail.com>
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 <p359101898@gmail.com>
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 <p359101898@gmail.com>
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 <p359101898@gmail.com>
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 <p359101898@gmail.com>
@PIG208
Copy link
Contributor Author

PIG208 commented Aug 27, 2022

@sobolevn Hi! The PR has been rebased and the CI is passing. I think it is ready for review.


str_info = helpers.lookup_fully_qualified_typeinfo(helpers.get_typechecker_api(ctx), f"builtins.str")
assert str_info is not None
method = str_info.get(method_name)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think that you should use analyze_member_access here from mypy/checkmember.py

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated.

Signed-off-by: Zixuan James Li <p359101898@gmail.com>
Copy link
Member

@sobolevn sobolevn left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's try it out! Thank you!

@sobolevn sobolevn merged commit 18a0551 into typeddjango:master Aug 27, 2022
@hannseman
Copy link
Contributor

This PR causes errors when passing gettext_lazy strings to parameters expecting strings like verbose_name and help_text on model fields:

error: Argument "verbose_name" to "PositiveBigIntegerField" has incompatible type
"_StrPromise"; expected "Optional[str]"  [arg-type]
            null=True, blank=True, verbose_name=_("number")

(where _ is defined as from django.utils.translation import gettext_lazy as _)

@sobolevn
Copy link
Member

Please, open a new issue. I am not releasing new version yet, we have time to fix it.

@terencehonles
Copy link
Contributor

terencehonles commented Nov 25, 2022

@PIG208 was there a reason _StrPromise

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

Is a subclass of Sequence[str] and not str itself? A string is slice-able so I understand the thought process at a high level, but lazystr expects/returns a string not a sequence of strings and I'm not sure why this was made this generic / explicit. This has caused a lot of downstream code (even in this codebase) to need to declare everything as StrOrPromise. While this doesn't seem like that big of a lift, I'm not sure it should be needed at all.

I'm raising this question here instead of creating a separate issue since I figured I'd ask about the intent before completely suggesting this should change (maybe there was a reason I am not aware of).

It seems like Promise should have been made generic and returns and possibly subclasses the type it is so that callers can treat a promise and the object itself interchangeably.

@sobolevn not sure if you have any thoughts or would prefer this to be a separate issue.

@PIG208 PIG208 deleted the lazy branch November 25, 2022 22:57
@PIG208
Copy link
Contributor Author

PIG208 commented Nov 25, 2022

@terencehonles Thanks for bringing this up!

_StrPromise is a subclass of Sequence[str] because this is how builtins.str is typed in typeshed. _StrPromise is defined only because operators don't work well with mypy plugins, while we want methods on a regular str to be all available. These are just the implementation details.

The point of having _StrPromise is to avoid using them with regular str interchangeably. This can cause real bugs when dealing with third-party libraries, because Promise is not str.

We did not make Promise generic because that would require more work to implement a full-blown mypy plugin to support arbitrary type arguments, while the most prevalent use case of Promise is with lazily evaluated strings.

@andersk
Copy link
Contributor

andersk commented Nov 25, 2022

See also #1139 (comment) for concrete examples of what would go wrong if we incorrectly told mypy that _StrPromise is a subclass of str.

@terencehonles
Copy link
Contributor

ok, thanks for the explanation.

The point of having _StrPromise is to avoid using them with regular str interchangeably. This can cause real bugs when dealing with third-party libraries, because Promise is not str.

A Promise[str] seems like it should be mostly considered a string and I'm not sure I agree or have run into an issue that this comment refers to, but I understand it would fail an isinstance(...) check and for mypy and potential future compilation would be concerned that would be an issue.

I had believed that Django's Promise implementation should be a more or less drop in replacement for the object it's promising since I thought it's blockingly coerced into the object when it's used, but it sounds like there's been some discussion of this and I will check out the comment @andersk pointed to 👍

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Development

Successfully merging this pull request may close these issues.

gettext_lazy should return a Promise object
5 participants