[ty] Emit more specific diagnostics for "possibly unbound" errors from context manager dunder methods invoked on a union. (#24662)
<!--
Thank you for contributing to Ruff/ty! To help us out with reviewing,
please consider the following:
- Does this pull request include a summary of the change? (See below.)
- Does this pull request include a descriptive title? (Please prefix
with `[ty]` for ty pull
requests.)
- Does this pull request include references to any relevant issues?
- Does this PR follow our AI policy
(https://github.com/astral-sh/.github/blob/main/AI_POLICY.md)?
-->
## Summary
As part of https://github.com/astral-sh/ty/issues/940, this helps us
emit more specific diagnostics for possibly unbound context manager
dunders (e.g., `__enter__`, `__exit__`) invoked on a union type.
Where previously the following snippet would produce just the top-level
diagnostic commented below:
```py
class Context:
def __enter__(self): ...
def __exit__(self, *args): ...
class NotContext:
pass
def _(x: Context | NotContext):
# error: [invalid-context-manager] "Object of type `Context | NotContext` cannot be used with `with` because the methods `__enter__` and `__exit__` are possibly unbound"
with x:
pass
```
We will now produce two further "info" sub-diagnostics:
```
info: `NotContext` does not implement `__enter__`
info: `NotContext` does not implement `__exit__`
```
## Approach
- This implements the approach suggested by `@carljm`
[here](https://github.com/astral-sh/ruff/pull/20199#pullrequestreview-3178490835)
from a previous attempt to address
https://github.com/astral-sh/ty/issues/940; it extends
`CallDunderError::PossiblyUnbound` with a new `unbound_on` field that
stores a list of the union members on which a particular dunder is
unbound. We create the new, richer error with a new
`UnionType.try_call_dunder_with_policy` method that looks up the dunder
on each member of the union, and then aggregates the results. This is
supersedes the previous `UnionType.map_with_boundness_and_qualifiers`
approach, and allows us to preserve the per-member binding information
that we use to produce the more detailed diagnostic.
- There are two alternatives to this approach that I considered but
rejected:
- Rebuild the specific union member diagnositic information at each
callsite, and only when relevant. This was the approach originally taken
by https://github.com/astral-sh/ruff/pull/20199, but I think it will
lead to some unnecessary code duplication across callsites (of which
there are at least three more).
- Refactor such that `UnionType.map_with_boundness_and_qualifiers` such
that it no longer loses member-specific binding information when
producing its result. This would have required an extension to
`PlaceAndQualifiers`, which would have a large blast radius and also
introduce overhead in several cases where member-specific information
for unions is not necessary.
- There are more implicit dunder calls that can benefit from the new
shape of `CallDunderError::PossiblyUnbound`, but I have intentionally
deferred those to [a
follow-up](https://github.com/astral-sh/ruff/pull/24676) in order to
first collect feedback on a more targeted changeset.
- The first three commits in this PR
(926bcec8d80862bb63a4ffb991f0e92853686656,
65dc3fbd5ed7920b03df040c54b796007b90fdb6,
988e81d027e8e629bad1fce9ee6e05b8ebbc3af4) are "prefactors" that do not
change any observable behaviour. The fourth
(2422844d9a281240aad89148e9052e5e1208fbdf) actually implements the
improvement, and deserves the most scrutiny.
## Test Plan
Please see updated mdtests and associated snapshots.