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

Broaden type annotation for verbose_name(_plural) to accept lazystr. #1139

Merged
merged 5 commits into from
Sep 19, 2022

Conversation

PIG208
Copy link
Contributor

@PIG208 PIG208 commented Aug 29, 2022

I have made things!

There might be other use cases of lazystr that need to be considered. It needs to be verified that Promise objects are actually expected for the callee. Right now we are following a conservative approach to first mark ones that do commonly accept Promise.

Related issues

Fixes typeddjango#1137.

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.

Is there any way to make str and Promise compatible?

@PIG208
Copy link
Contributor Author

PIG208 commented Aug 29, 2022

str and Promise are not compatible in terms of non-callable attribute access, isinstance check, etc. Making them incompatible is the whole point of typing the return types of these helpers with a separate Promise class for Promise-like objects. The interchangeable use cases arise in user-facing text such as Field.help_text or Field.verbose_name, where laziness is intended to be preserved, which we do want to explicitly type as a Union of the two types.

I did a quick search on GitHub and found that people use gettext_lazy with ValidationError, label/help_text/verbose_name/name argument of Field.__init__. These use cases should be pretty manageable, and we can add support to other ones. Moreover, in places where str is expected, the caller should force the _StrPromise into str with force_str or str.

PIG208 added 3 commits August 29, 2022 18:57
Signed-off-by: Zixuan James Li <p359101898@gmail.com>
Signed-off-by: Zixuan James Li <p359101898@gmail.com>
Signed-off-by: Zixuan James Li <p359101898@gmail.com>
@PIG208 PIG208 force-pushed the strpromise-follow branch from 6ccb678 to e95c760 Compare August 29, 2022 22:57
@flaeppe
Copy link
Member

flaeppe commented Aug 30, 2022

I'm not following on why we need to type Promise at all? Isn't it an implementation detail? That just proxies whatever type/value you put in to it.

I mean, as a user of lazy/promise etc, you'll never need to know or directly consider that it's a Promise and e.g. not a str, since the string itself is just proxied. So you as a user can handle it as a regular string and the Promise falls under the radar. Or am I missing something?


Though the Union option suggested here looks a bit never ending, as it'll likely pop up quite a lot of cases where it'll have to be Union[str, Promise] instead of just str. While, typing and runtime wise, it would've been just fine to have seen str.

In addition to that, that's just to cover gettext_lazy, I mean, hypothetically there could be some getint_lazy which would then have to become Union[int, Promise], etc, and on it goes.

I think what I'm saying is that there have to be some Promise[str], that can be accepted as str, it doesn't look viable doing Union[str, Promise] everywhere. And in addition to that, pushing it to users of django-stubs to also start writing Union[str, Promise] in their own code.

@sobolevn
Copy link
Member

Yeah, I agree with @flaeppe
This change seems quite invasive.

@PIG208
Copy link
Contributor Author

PIG208 commented Aug 30, 2022

I agree that this is probably invasive in terms of the functions that might be affected within Django. If that's the issue we can try to implement method+function hooks ensuring that Django's functions/methods that accept str should also accept _StrPromise, assuming that Django itself is well aware of the Promise objects and always correctly handles them.

However, it might be an overkill since it is unclear whether the Promise objects are commonly used Django APIs other than the above mentioned ones. Practically, I still prefer the current approach and wait and see if it is necessary to switch to a plugin implementation.

I mean, as a user of lazy/promise etc, you'll never need to know or directly consider that it's a Promise and e.g. not a str, since the string itself is just proxied. So you as a user can handle it as a regular string and the Promise falls under the radar. Or am I missing something?

When using lazy_str externally, it is more important to let the user know that this lazy object is not actually an instance of str. isinstance(gettext_lazy("foo"), str) for example returns False, which is something the callee in an external application wouldn't expect. There are more things that make Promise different from str in runtime.

In fact, the documentation explicitly states the difference between a str and Promise (referred as "lazy object" in the text) and that it does not work with arbitrary code. This actually has caused bugs downstream, so I think it is worth it to make this explicit.

pushing it to users of django-stubs to also start writing Union[str, Promise] in their own code.

We have already done something similar by typing the HttpResponse objects returned by the test client as _WSGIHttpResponse. This encourages the users to type the test responses as this fictional type we created in their helper functions.

Passing _StrPromise to APIs that expect str can lead to unexpected issues. django-stubs won't block users from using these APIs because they can always force the Promise object into str before calling.

In addition to that, that's just to cover gettext_lazy, I mean, hypothetically there could be some getint_lazy which would then have to become Union[int, Promise], etc, and on it goes.

We did consider making a generic Promise[T], but this use case is less prevalent compared to the work required to implement it.

@andersk
Copy link
Contributor

andersk commented Aug 30, 2022

As an example of what can go wrong if we aren’t truthful to mypy, consider

def maybe_decode(s: str | bytes) -> str:
    if isinstance(s, str):
        return s
    else:
        return s.decode()

maybe_decode(gettext_lazy("promise"))

If we incorrectly told mypy that gettext_lazy returns str or a subtype of str, then mypy finds no problem with this code, but this would be a false negative—it will crash at runtime.

There would also be false positives, such an incorrect “error: Statement is unreachable” on this else block with mypy --warn-unreachable:

def force(s: str | Promise) -> str:
    if isinstance(s, str):
        return s
    else:
        return str(s)

force(gettext_lazy("foo"))

Another reason that the user needs to care about the difference between Promise and str is that a Promise from gettext_lazy evaluates to different strings depending on the language of the current request, or within a with translation.override(language) block. It really is a different API, not just an implementation detail.

Copy link
Member

@flaeppe flaeppe left a comment

Choose a reason for hiding this comment

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

I can't come up with any better idea really. But I'm curious if these changes could result in users prematurely evaluating lazy strings.

But I guess those cases could be mitigated by patching the stubs.

Suppose we'll notice in time

@@ -43,6 +43,7 @@ class _StrPromise(Promise, Sequence[str]):
# Mypy requires this for the attribute hook to take effect
def __getattribute__(self, __name: str) -> Any: ...

_StrOrPromise = Union[str, _StrPromise]
Copy link
Member

Choose a reason for hiding this comment

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

Maybe _StrPromise should be public? Since it'll be exposed to users, right?

So that one can do str | StrPromise if passing around/accepting gettext_lazy values

Suggested change
_StrOrPromise = Union[str, _StrPromise]
_StrOrPromise = Union[str, StrPromise]

@PIG208
Copy link
Contributor Author

PIG208 commented Sep 15, 2022

_StrPromise -> StrPromise, _StrOrPromise -> StrOrPromise in the latest commit.

@intgr
Copy link
Collaborator

intgr commented Sep 15, 2022

If we expect users to import and use StrPromise and StrOrPromise, they should go in django_stubs_ext instead.

Otherwise using them would be annoying, import would need if TYPE_CHECKING and the types must be quoted always.

@PIG208
Copy link
Contributor Author

PIG208 commented Sep 16, 2022

The PR is updated with aliases made available in django_stubs_ext.

Comment on lines 8 to 9
StrOrPromise = StrOrPromise
StrPromise = StrPromise
Copy link
Contributor

@andersk andersk Sep 16, 2022

Choose a reason for hiding this comment

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

This isn’t how re-exports work in mypy.

But I think your initial inclination to use leading underscores in django-stubs/utils/functional.pyi was correct, because these don’t exist in django.utils.functional at runtime. We should use underscores in django-stubs and re-export with no underscores here in django_stubs_ext.

django_stubs_ext/django_stubs_ext/aliases.py Outdated Show resolved Hide resolved
Comment on lines 8 to 9
StrOrPromise = _StrOrPromise
StrPromise = _StrPromise
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
StrOrPromise = _StrOrPromise
StrPromise = _StrPromise

else:
from django.db.models.query import QuerySet
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.

Suggested change
from django.utils.functional import Promise
from django.utils.functional import Promise as StrPromise

django_stubs_ext/django_stubs_ext/aliases.py Outdated Show resolved Hide resolved
@PIG208 PIG208 force-pushed the strpromise-follow branch 2 times, most recently from 59bfd6f to 1ba1dfd Compare September 18, 2022 06:24
We make StrPromise and StrOrPromise available via django_stubs_ext so
that conditional imports with TYPE_CHECKING is not required.
These aliases fall back to Promise or Union[str, Promise]
when not TYPE_CHECKING.

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.

LGTM, any other comments?

Thanks for working on this!

Copy link
Member

@flaeppe flaeppe left a comment

Choose a reason for hiding this comment

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

All good from my end

@sobolevn sobolevn merged commit 9a41aa6 into typeddjango:master Sep 19, 2022
@PIG208 PIG208 deleted the strpromise-follow branch September 19, 2022 16:15
@PIG208
Copy link
Contributor Author

PIG208 commented Sep 19, 2022

Thanks. If any of you have time, #1072 and #1037 are also awaiting review. These are stub fixes that are relatively straightforward.

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.

Error while passing gettext_lazy to arguments expecting strings
5 participants