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

Fix typing of reversed #10655

Merged
merged 19 commits into from
Feb 16, 2024
Merged

Fix typing of reversed #10655

merged 19 commits into from
Feb 16, 2024

Conversation

plokmijnuhby
Copy link
Contributor

@plokmijnuhby plokmijnuhby commented Sep 3, 2023

Reversed has a __new__ method, not an __init__ method. Note that it does not normally actually return an instance of the reversed class.

@plokmijnuhby
Copy link
Contributor Author

Huh, mypy thinks __new__ should return an instance of reversed anyway? But, it doesn't though?

@github-actions

This comment has been minimized.

@github-actions

This comment has been minimized.

@Daverball
Copy link
Contributor

Daverball commented Sep 3, 2023

__new__ must return a subclass in a typing context, ignoring the error won't change that, mypy will still treat it as a reversed object, so the proper way to do this would be to make reversed a function and have it return the Iterator protocol. But reversed is actually a class in CPython, it just has a weird __new__ function, that can return something other than a reversed instance if the object being passed in implements __reversed__.

So there's no completely type safe to annotate reversed. I think the current way it's annotated is fine. While going the function + Protocol route would be a little bit more type safe in some situations, it also takes away the ability to check for actual reversed instances, which are a thing that does exist.

@plokmijnuhby
Copy link
Contributor Author

Yes, I see that. I would argue that, if that's the case, this PR is still (marginally) the best way of doing things. In the very rare case where a user calls reversed.__new__ directly, the return type given here is a better fit, and in all other cases the return type is ignored.
While the return type could theoretically be anything, we've specified that the input sequence needs to be an instance of Reversible (ie, with __reversed__ returning an Iterator), a SupportsLenAndGetItem (so the return type will be actually an instance of reversed), or, most likely, both (in which case, we get an Iterator again). So Iterator is always correct given the assumptions we've already made.

@Daverball
Copy link
Contributor

Daverball commented Sep 3, 2023

I don't think we should encourage patterns in typeshed that are not considered valid by type checkers, even if that's what the implementation does under the hood.

In this case the lie the type checker tells is rather harmless, since reversed pretty much only provides the minimal Iterator interface anyways. The only bad case is manually calling __length_hint__, since that might not exist.

So I'm personally not convinced that the change is a positive one, but the maintainers of typeshed will have to decide whether it is or not.

BTW minor nit: You used self instead of cls in the annotation of __new__.

@github-actions

This comment has been minimized.

@Viicos
Copy link
Contributor

Viicos commented Sep 11, 2023

Note this might be working with python/mypy#16020

@JelleZijlstra
Copy link
Member

What concrete problem does this solve, in terms of code examples that will typecheck better with this change?

Given that mypy doesn't seem to support this pattern for now, I think we should add test cases to make sure this doesn't regress type checking with mypy (e.g., make sure it doesn't start inferring Any).

@plokmijnuhby
Copy link
Contributor Author

The main reason this is relevant is if you have a subclass of reversed (which is rare but allowed). An example might look something like this:

class Foo(reversed):
    def __new__(cls, arg):
        res = super().__new__(cls, arg) # shouldn't raise a type error, but currently does
        print(res.__length_hint__()) # should raise a type error, but currently doesn't
        return res

@Akuli
Copy link
Collaborator

Akuli commented Sep 24, 2023

Annotating something with reversed, as in foo: reversed[int], is very unlikely to do the right thing. For example, if you assign reversed([1, 2, 3]) to a reversed[int], the type checker is happy but inconsistent with runtime:

>>> type(reversed([1, 2, 3]))
<class 'list_reverseiterator'>
>>> isinstance(reversed([1, 2, 3]), reversed)
False

Maybe we should pretend that reversed is a function, as in def reversed(stuff) -> Iterator[_T]? This would give you type checker errors for foo: reversed[int]. You would also get an error for if isinstance(foo, reversed), which IMO would be good because it produces confusing results.

@plokmijnuhby
Copy link
Contributor Author

plokmijnuhby commented Sep 26, 2023

The thing is, I think it is was the intention of the cpython developers that __new__ methods could return an object of literally any type. The most obvious evidence for this is that reversed does it, but for more proof look at the documentation for object.__new__ (emphasis added):

Called to create a new instance of class cls. __new__ is a static method (special-cased so you need not declare it as such) that takes the class of which an instance was requested as its first argument. The remaining arguments are those passed to the object constructor expression (the call to the class). The return value of __new__() should be the new object instance (usually an instance of cls). ... If __new__() does not return an instance of cls, then the new instance’s __init__() method will not be invoked.

This seems fairly clearly to allow for this sort of behaviour. If mypy doesn't like it, then mypy is just wrong.

@JelleZijlstra
Copy link
Member

If mypy doesn't like it, then mypy is just wrong.

True, but mypy is among the most significant ways people use typeshed, and we'd be doing our end users a disservice if we break type checking reversed() with mypy. That's especially true because the motivation for change here feels very thin: why would you want to subclass reversed?

I'd prefer to have stubs that correctly reflect reality, so we do need a change like the one in this PR, but we should have test cases that demonstrate that this change doesn't cause new problems. Test cases should look something like this:

x: list[int]
assert_type(list(reversed(x)), list[int])

To make sure that we don't get a list[Any] or similar.

@github-actions

This comment has been minimized.

@github-actions

This comment has been minimized.

@github-actions

This comment has been minimized.

Copy link
Member

@JelleZijlstra JelleZijlstra left a comment

Choose a reason for hiding this comment

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

Thanks, and sorry for the wait!

Copy link
Contributor

According to mypy_primer, this change has no effect on the checked open source code. 🤖🎉

@JelleZijlstra JelleZijlstra merged commit c5c2c14 into python:main Feb 16, 2024
57 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

5 participants