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

Large update #909

Merged
merged 103 commits into from
Apr 3, 2022
Merged

Large update #909

merged 103 commits into from
Apr 3, 2022

Conversation

sterliakov
Copy link
Contributor

Major fix in different areas

I found out that django.forms were typed very poorly and forked to improve it. Then I wrote a small script to check this stubs with real code (targeting 3.2 because stubs are created for this version): it a) reports all incompatible stubs and b) allows me to run mypy on Django itself. Django code style is far from type safety, of course, but "Incompatible return value", for instance, is often a bad sign.

Major steps:

  • Sync outdated and deprecated code (SafeText, OrderedDict)
  • Fix wrong signatures (where some arguments were renamed/added/removed)
  • Fix wrong decorators (missing @property)
  • Replace None as return type or variable type where needed (often within generator functions)
  • Define common type aliases and reuse them (more DRY)
  • Where possible, replace Any with more specific type.
  • Replace WSGIRequest with HttpRequest in most files (not related to handlers).
  • When callable is assigned as instance attribute, use protocol instead of Callable to avoid mypy confusion (false-positives)

I also added a few new test cases (and will add more for forms and views, they are not really covered now).

Related issues

…tOrTuple now can live here. Added _IndexableCollection (often useful as alias for 'sequence or queryset')
…tualize model fields. Mark keyword-only args explicitly in stubs (where code uses **kwargs). Disallow bytes for verbose_name.
…ations in wrong places, improve some wrong annotations.
…re needed: at least HttpResponseNotModified/HttpResponseRedirect can be returned instead of it, so annotation was wrong.
…ath, because many methods expect str-only paths. Make File inherit from IO[AnyStr] instead of IO[Any]: it makes impossible to instantiate file of union type, but allows precise types for some methods.
def copy(self: _D) -> _D: ...
def __getitem__(self, key: _K) -> Union[_V, List[object]]: ... # type: ignore
Copy link
Collaborator

@intgr intgr Apr 6, 2022

Choose a reason for hiding this comment

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

This is also related to the change of MultiValueDict.dict(): technically correct, but for most use cases, a step backward.

The Django tutorial contains this example:

def vote(request, question_id):
    question = get_object_or_404(Question, pk=question_id)
    try:
        selected_choice = question.choice_set.get(pk=request.POST['choice'])
        #                                         ^^^^^^^^^^^^^^^^^^^^^^^^^
    except (KeyError, Choice.DoesNotExist):
        ...

But now accessing .get(pk=request.POST['choice']) will cause mypy errors like:

Incompatible type for lookup 'pk': (got "Union[str, List[object]]", expected "Union[str, int]") [misc]

What's your proposed solution for this common usage pattern?

One could add a guard like if isinstance(request.POST['choice'], str): or assert. But that gets very unwieldy and as explained in #899 (comment), the list return cannot really occur with request.GET and request.POST.

Copy link
Collaborator

Choose a reason for hiding this comment

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

One option would be to keep MultiValueDict as you have changed, but override QueryDict back to the old behavior of assuming no empty lists, for the request.POST/GET use case. Is that a compromise you could live with?

Although that way this issue would still affect request.FILES.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Overriding QueryDict to old behavior could be a better choice, also I don't insist on preserving this change if you think it's troublesome and doesn't really help.

However, I should also note that there is QueryDict.get interface, that works exactly like dict.get, returning None or default if key is missing or corresponds to empty list.

Also I have one more solution, which may be a massive overhead but should provide very descriptive interface: define in stubs fake _ImmutableQueryDict with get methods that behave as expected (getitem: (_K) -> _V; it is perfectly valid assuming that this querydict was created by django and is coming from request.GET(POST)), modifying methods disallowed (__setitem__: (_K, _V) -> NoReturn etc.) and copy: () -> QueryDict. This way we can additionally detect almost all disallowed operations (with --warn-unreachable) while keeping edge-cases checked for mutable copy. It does not eliminate issue with request.FILES, but for this using request.FILES.get(key) is almost always convenient.

Copy link
Collaborator

Choose a reason for hiding this comment

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

Hmm, yes, the _ImmutableQueryDict option may work. Although it looks like there are some situations where mutation is considered legal in Django:

class HttpRequest:
    def __init__(self):
        self.GET = QueryDict(mutable=True)
        self.POST = QueryDict(mutable=True)

Apparently this was introduced in django/django#2778 and the reason given is "GET and POST on a vanilla HttpRequest object to be mutable QueryDicts (mutable because the Django tests, and probably many third party tests were expecting it)."

Not sure if this is relevant any more in 2022.

Copy link
Contributor Author

@sterliakov sterliakov Apr 6, 2022

Choose a reason for hiding this comment

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

Interesting, I haven't noticed that before. HttpRequest.{GET, POST} are really mutable, while {W, A}SGIRequest.{GET, POST} are not. As far as I understand (quick grep 'HttpRequest('), it's used directly only in tests. We don't use {W, A}SGIRequest in stubs because it depends on deploy method, not on code itself, so we have to make HttpRequest.GET immutable too in stubs. I see two options:

  • add _CommonHttpRequest(HttpRequest) with immutable GET/POST and use it in stubs instead of HttpRequest. Will require some work and is hard to maintain.
  • make HttpRequest.{GET, POST} immutable. It will break code that instantiates HttpRequest manually.

I think that the latter is a reasonable choice, but I'm not sure about it. Is there any scenario when somebody can intentionally create HttpRequest manually and modify its GET/POST data?

Copy link
Contributor Author

@sterliakov sterliakov Apr 6, 2022

Choose a reason for hiding this comment

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

I found one more solution which looks very promising. mypy does not 100% support this currently, but the same can be achieved with a tweak (requiring type: ignore on __init__ in stub, because mypy doesn't consider it valid, but working outside like a charm):

class _ImmutableQueryDict(QueryDict): ...  # declaring immutability here
class HttpRequest:
    GET: _ImmutableQueryDict
    POST: _ImmutableQueryDict
    # Magic happens here:
    def __init__(self: _MutableHttpRequest) -> None: ...  # type: ignore
    # If mypy issue #1020 is resolved, the following (better) will work:
    # def __new__(cls, *args, **kwargs) -> _MutableHttpRequest: ...
class _MutableHttpRequest(HttpRequest):
    GET: QueryDict  # type: ignore
    POST: QueryDict  # type: ignore

And now

from django.http.request import HttpRequest

request = HttpRequest()
reveal_type(request)  # N: Revealed type is "django.http.request._MutableHttpRequest"
reveal_type(request.GET)  # N: Revealed type is "django.http.request.QueryDict"
request.GET['foo'] = 'bar'

def mk_request() -> HttpRequest:
    return HttpRequest()

req = mk_request()
reveal_type(req)  # N: Revealed type is "django.http.request.HttpRequest"
reveal_type(req.GET)  # N: Revealed type is "django.http.request._ImmutableQueryDict"
req.GET['foo'] = 'bar'

This way any code that does for some reason request = HttpRequest() will get mutable version while all request: HttpRequest will have immutable dict.

Copy link
Collaborator

Choose a reason for hiding this comment

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

If mypy issue #1020 is resolved, the following (better) will work

Hmm, this issue python/mypy#1020 is closed. Wasn't it solved by python/mypy#7188 already?

Copy link
Collaborator

@intgr intgr Apr 13, 2022

Choose a reason for hiding this comment

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

Anyway this solution is a bit hacky, but seems fine to me. Of course in the end it's up to django-stubs reviewers.

If your changes are in a good enough shape, I would gladly test out your branch (or maybe open a draft PR already).


_R = TypeVar("_R", bound=HttpRequest)

class _MonkeyPatchedHttpResponseBase(Generic[_R], HttpResponseBase):
Copy link
Collaborator

Choose a reason for hiding this comment

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

Should this derive from HttpResponse instead?

I have a test that accesses resp.content and it works as expected. The content attribute is defined in HttpResponse not HttpResponseBase.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

No: it can be FileResponse that inherits from HttpResponseBase.

Copy link
Collaborator

Choose a reason for hiding this comment

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

Looks like the __iter__ and getvalue() APIs are "the right way" to access the response content in a universal manner -- both StreamingHttpResponse and HttpResponse define these methods.

But since these methods aren't defined on the level of HttpResponseBase either, this is still an issue.

Should we add __iter__ and getvalue() to HttpResponseBase since presumably all subclasses are expected to implement these methods?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yeah, it should be great solution, I don't see any drawbacks. I'll add it to the patch.

@sterliakov sterliakov mentioned this pull request Apr 17, 2022
sobolevn pushed a commit that referenced this pull request Apr 28, 2022
* Fix stubs related to `(Async)RequestFactory` and `(Async)Client`

* Revert incorrect removal.

* Allow set as `unique_together`, use shared type alias.

* Revert `Q.__init__` to use only `*args, **kwargs` to remove false-positive with `Q(**{...})`

* Add abstract methods to `HttpResponseBase` to create common interface.

* Remove monkey-patched attributes from `HttpResponseBase` subclasses.

* Add QueryDict mutability checks (+ plugin support)

* Fix lint

* Return back GenericForeignKey to `Options.get_fields`

* Minor fixup

* Make plugin code typecheck with `--warn-unreachable`, minor performance increase.

* Better types for `{unique, index}_together` and Options.

* Fix odd type of `URLResolver.urlconf_name` which isn't a str actually.

* Better types for field migration operations.

* Revert form.files to `MultiValueDict[str, UploadedFile]`

* Compatibility fix (#916)

* Do not assume that `Annotated` is always related to django-stubs (fixes #893)

* Restrict `FormView.get_form` return type to `_FormT` (class type argument). Now it is resolved to `form_class` argument if present, but also errors if it is not subclass of _FormT

* Fix CI (make test runnable on 3.8)

* Fix CI (make test runnable on 3.8 _again_)
@@ -93,7 +102,7 @@ class URLResolver:
_reverse_dict: MultiValueDict
def __init__(
self,
pattern: Any,
pattern: LocalePrefixPattern,
Copy link
Collaborator

Choose a reason for hiding this comment

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

This also allows RegexPattern, RoutePattern. I have opened a PR for this: #941

Dict[str, str],
Dict[str, ValidationError],
List[str],
List[ValidationError],
Copy link
Collaborator

Choose a reason for hiding this comment

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

This is not correct. ValidationError accepts arbitrarily nested data structures as the message argument.

For example this correct code from django-rest-framework produces a false positive error: https://github.com/encode/django-rest-framework/blob/3.13.1/tests/test_fields.py#L2428-L2433

Mypy does not support recursive type aliases, so it's impossible to describe this type accurately AFAIK.

For now I think it's preferable to revert than to have false positives. I have opened PR #943 for that. If you have any thoughts, please comment there.

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