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

Getting a union from an and expression #16565

Open
Dreamsorcerer opened this issue Nov 25, 2023 · 10 comments
Open

Getting a union from an and expression #16565

Dreamsorcerer opened this issue Nov 25, 2023 · 10 comments
Labels
bug mypy got something wrong topic-type-narrowing Conditional type narrowing / binder topic-usability

Comments

@Dreamsorcerer
Copy link
Contributor

Dreamsorcerer commented Nov 25, 2023

Bug Report

I'd hope there's already an issue for this, but couldn't find one.
When using and, mypy infers a union from both sides when the truthy evaluation makes it impossible.

Actual code trigerring the error: https://github.com/aio-libs/aiohttp/pull/7879/files#diff-720910fd022b9ba8b7ddf0cb446ce5e1afe443f4287ad26a1eb4dc4b5b5f4508R944

To Reproduce

class Protocol:
    upgraded: bool

class Connection:
    protocol: Protocol | None

conn: Connection | None
protocol = conn and conn.protocol
assert protocol is not None
protocol.upgraded  # error: Item "Connection" of "Connection | Protocol" has no attribute "upgraded"  [union-attr]

Expected Behavior

protocol can't be Connection, so this error shouldn't happen.

Your Environment

  • Mypy version used: 1.7.1
@Dreamsorcerer Dreamsorcerer added the bug mypy got something wrong label Nov 25, 2023
@AlexWaygood AlexWaygood added the topic-type-narrowing Conditional type narrowing / binder label Nov 25, 2023
@erictraut
Copy link

I think mypy is doing the right thing here. The and operator terminates early if the LHS evaluates to falsy.

# If `a` is `None`, the result of `a and 8` will be `None`
print(None and 8) # Prints "None"

# If `a` is `Literal[0]`, then `a and 8` will evaluate to `Literal[0]`
print(0 and 8) # Prints "0"

If `a` is any other `int` value, then `a and 8` will evaluate to `Literal[8]`
print(1 and 8) # Prints "8"

For comparison, pyright evaluates the type of a and 8 as Literal[8, 0] | None, but mypy's evaluation of int | None is also correct.

@AlexWaygood AlexWaygood closed this as not planned Won't fix, can't repro, duplicate, stale Nov 25, 2023
@Dreamsorcerer
Copy link
Contributor Author

Sorry, I oversimplified it while coming up with the minimal reproducer, it was probably meant to be the inverse of that.

I've just updated the original issue with a slightly more complex example, which better matches the code in the linked PR.

@erictraut
Copy link

Yeah, I agree that the modified example demonstrates a bug (or at least a missing feature) in mypy.

@AlexWaygood, if you agree, could you reopen the issue?

@ikonst
Copy link
Contributor

ikonst commented Nov 26, 2023

Couldn't it be a Connection whose __bool__ returns False?

@erictraut
Copy link

Couldn't it be a Connection whose bool returns False?

That's theoretically possible if the class is not @final, but the behavior is the same even if it is marked @final. That leads me to believe this is a bug, not an intentional design decision. FWIW, the updated sample type checks without errors in pyright.

@ikonst
Copy link
Contributor

ikonst commented Nov 26, 2023

It appears that mypy isn't smart enough to deduce "if @Final without bool or len, then always true", but otherwise adding __bool__ works as expected. Should we make mypy smarter? 👉 #16566

As for a workaround, either seems to work

  1. adding a __bool__

     class Connection:
       protocol: Protocol | None
    +  def __bool__(self) -> Literal[True]: return True

    No need to make it @final since derived class must be covariant on return type.

  2. letting mypy know you never want a falsey Connection:

     def func(conn: Connection | None):
    -  protocol = conn and conn.protocol
    +  protocol = (conn or None) and conn.protocol
       assert protocol is not None
       reveal_type(protocol.upgraded)

@Dreamsorcerer
Copy link
Contributor Author

Dreamsorcerer commented Nov 26, 2023

1. adding a __bool__
2. letting mypy know you never want a falsey Connection:

Would there be an easy way to make this suggestion in the error output? Our example is actually not Final, we just didn't think of that edge case.

@ikonst
Copy link
Contributor

ikonst commented Nov 28, 2023

Assuming you don't want to change Connection i.e. you want to address it at the call site, then "suggestion (2)" would be relevant.

protocol = conn and conn.protocol itself isn't an error. Does it on its own warrant a warning? Probably not, since it could actually be your intention, e.g.

def make_request() -> requests.Response | dict:
  resp = requests.get('http://example.com')
  return resp and resp.json()

Maybe x and y should only warn if x is DontKnowIfTruthy | None and you end up assigning it to a variable rather than just use it in boolean context... I don't know how easy it is to implement, and how noisy it'll be.

Keeping context and suggesting when the union-attr actually happens... sounds pretty complicated, and I can't even think how you pharse it.

@Dreamsorcerer
Copy link
Contributor Author

We decided to go with the first option, as we may have made similar assumptions elsewhere in the code and it keeps the logic simple. Both solutions would be useful to suggest in this context.

Keeping context and suggesting when the union-attr actually happens... sounds pretty complicated, and I can't even think how you pharse it.

Yeah, I figured that might be difficult as you'd need to know the cause was from the code further up.

hauntsaninja pushed a commit that referenced this issue Dec 4, 2023
Once class C is final, we know that a derived class won't add a
`__bool__` or a `__len__` so if they're missing, we can assume every
instance of C to be truthy.

Relates to #16565
@ikonst
Copy link
Contributor

ikonst commented Dec 4, 2023

#16566 was merged so starting from mypy 1.8(?) adding @final should be enough.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
bug mypy got something wrong topic-type-narrowing Conditional type narrowing / binder topic-usability
Projects
None yet
Development

No branches or pull requests

4 participants