Skip to content

Conversation

@sharkdp
Copy link
Contributor

@sharkdp sharkdp commented Jul 23, 2025

Summary

Add more precise type inference for a limited set of isinstance(…) calls, i.e. return Literal[True] if we can be sure that this is the correct result. This improves exhaustiveness checking / reachability analysis for if-elif-else chains with isinstance checks. For example:

def is_number(x: int | str) -> bool:  # no "can implicitly return `None` error here anymore
    if isinstance(x, int):
        return True
    elif isinstance(x, str):
        return False

    # code here is now detected as being unreachable

This PR also adds a new test suite for exhaustiveness checking.

Test Plan

New Markdown tests

Ecosystem analysis

The removed diagnostics look good. There's one case where a "true positive" is removed in unreachable code. src is annotated as being of type str, but there is an elif isinstance(src, bytes) branch, which we now detect as unreachable. And so the diagnostic inside that branch is silenced. I don't think this is a problem, especially once we have a "graying out" feature, or a lint that warns about unreachable code.

@sharkdp sharkdp added ty Multi-file analysis & type inference ecosystem-analyzer labels Jul 23, 2025
elif isinstance(x, C):
pass
else:
no_diagnostic_here
Copy link
Contributor Author

Choose a reason for hiding this comment

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

On main, we emit a diagnostic here, i.e. we do not detect this branch as unreachable. On this branch, we do detect this as unreachable.

@github-actions
Copy link
Contributor

github-actions bot commented Jul 23, 2025

mypy_primer results

Changes were detected when running on open source projects
kornia (https://github.com/kornia/kornia)
- error[invalid-return-type] kornia/geometry/liegroup/so3.py:77:38: Function can implicitly return `None`, which is not assignable to return type `So3`
- error[invalid-return-type] kornia/geometry/liegroup/so3.py:98:24: Return type does not match returned value: expected `So3`, found `Vector3`
- Found 777 diagnostics
+ Found 775 diagnostics

PyGithub (https://github.com/PyGithub/PyGithub)
- error[invalid-return-type] github/Repository.py:3599:45: Function can implicitly return `None`, which is not assignable to return type `GitRelease`
- error[invalid-assignment] github/Team.py:305:13: Object of type `str` is not assignable to `Repository`
- Found 307 diagnostics
+ Found 305 diagnostics

apprise (https://github.com/caronc/apprise)
- error[unresolved-attribute] test/helpers/rest.py:291:26: Type `NotifyBase` has no attribute `url`
- error[unresolved-attribute] test/test_plugin_email.py:388:30: Type `NotifyBase` has no attribute `url`
- error[unresolved-attribute] test/test_plugin_growl.py:298:30: Type `NotifyBase` has no attribute `url`
- Found 4315 diagnostics
+ Found 4312 diagnostics

pwndbg (https://github.com/pwndbg/pwndbg)
- warning[possibly-unresolved-reference] pwndbg/dbg/gdb/__init__.py:817:13: Name `bp` used when possibly not defined
- warning[possibly-unresolved-reference] pwndbg/dbg/gdb/__init__.py:819:27: Name `bp` used when possibly not defined
- warning[possibly-unresolved-reference] pwndbg/dbg/gdb/__init__.py:833:9: Name `bp` used when possibly not defined
- warning[possibly-unresolved-reference] pwndbg/dbg/lldb/__init__.py:632:16: Name `value` used when possibly not defined
- warning[possibly-unresolved-reference] pwndbg/dbg/lldb/__init__.py:634:55: Name `value` used when possibly not defined
- warning[possibly-unresolved-reference] pwndbg/dbg/lldb/__init__.py:637:26: Name `value` used when possibly not defined
- warning[possibly-unresolved-reference] pwndbg/dbg/lldb/__init__.py:1551:16: Name `bp` used when possibly not defined
- warning[possibly-unresolved-reference] pwndbg/dbg/lldb/__init__.py:1553:60: Name `e` used when possibly not defined
- warning[possibly-unresolved-reference] pwndbg/dbg/lldb/__init__.py:1553:77: Name `e` used when possibly not defined
- warning[possibly-unresolved-reference] pwndbg/dbg/lldb/__init__.py:1576:28: Name `bp` used when possibly not defined
- warning[possibly-unresolved-reference] pwndbg/dbg/lldb/__init__.py:1595:27: Name `bp` used when possibly not defined
- warning[possibly-unresolved-reference] pwndbg/dbg/lldb/__init__.py:1596:88: Name `bp` used when possibly not defined
- warning[possibly-unresolved-reference] pwndbg/dbg/lldb/__init__.py:1597:29: Name `bp` used when possibly not defined
- warning[possibly-unresolved-reference] pwndbg/dbg/lldb/__init__.py:1598:88: Name `bp` used when possibly not defined
- Found 2260 diagnostics
+ Found 2246 diagnostics

vision (https://github.com/pytorch/vision)
- error[invalid-assignment] torchvision/io/video_reader.py:143:17: Object of type `BytesIO` is not assignable to `str`
- Found 1468 diagnostics
+ Found 1467 diagnostics

prefect (https://github.com/PrefectHQ/prefect)
- error[invalid-return-type] src/prefect/server/schemas/schedules.py:565:27: Function can implicitly return `None`, which is not assignable to return type `rrule`
- Found 3688 diagnostics
+ Found 3687 diagnostics

sympy (https://github.com/sympy/sympy)
- warning[possibly-unresolved-reference] sympy/solvers/ode/ode.py:1001:32: Name `deriv` used when possibly not defined
- warning[possibly-unresolved-reference] sympy/solvers/ode/ode.py:1001:66: Name `deriv` used when possibly not defined
- warning[possibly-unresolved-reference] sympy/solvers/ode/ode.py:1002:39: Name `deriv` used when possibly not defined
- warning[possibly-unresolved-reference] sympy/solvers/ode/ode.py:1003:25: Name `deriv` used when possibly not defined
- warning[possibly-unresolved-reference] sympy/solvers/ode/ode.py:1003:54: Name `old` used when possibly not defined
- warning[possibly-unresolved-reference] sympy/solvers/ode/ode.py:1004:21: Name `new` used when possibly not defined
- warning[possibly-unresolved-reference] sympy/solvers/ode/ode.py:1004:45: Name `deriv` used when possibly not defined
- warning[possibly-unresolved-reference] sympy/solvers/ode/ode.py:1005:21: Name `deriv` used when possibly not defined
- warning[possibly-unresolved-reference] sympy/solvers/ode/ode.py:1007:40: Name `deriv` used when possibly not defined
- warning[possibly-unresolved-reference] sympy/solvers/ode/ode.py:1009:44: Name `new` used when possibly not defined
- Found 12963 diagnostics
+ Found 12953 diagnostics
No memory usage changes detected ✅

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I added tests for match statements as well, but they don't work yet (see astral-sh/ty#99 (comment))

@github-actions
Copy link
Contributor

ecosystem-analyzer results

Lint rule Added Removed Changed
possibly-unresolved-reference 0 24 0
invalid-return-type 0 4 0
unresolved-attribute 0 3 0
invalid-assignment 0 2 0
Total 0 33 0

Full report with detailed diff

@sharkdp sharkdp changed the title [ty] Type inference for isinstance(…) calls [ty] Reachability analysis for isinstance(…) branches Jul 23, 2025
@sharkdp sharkdp marked this pull request as ready for review July 23, 2025 09:19
Copy link
Member

@AlexWaygood AlexWaygood left a comment

Choose a reason for hiding this comment

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

Nice!

Comment on lines 1258 to 1272
if match (self, first_arg) {
(KnownFunction::IsInstance, Type::NominalInstance(_)) => is_instance(first_arg),
// We do not handle unions specifically here, because something like `A | SubclassOfA` would
// have been simplified to `A` anyway
(KnownFunction::IsInstance, Type::Intersection(intersection)) => {
intersection.positive(db).iter().any(is_instance)
}
(KnownFunction::IsInstance, ty) => ty
.literal_fallback_instance(db)
.as_ref()
.is_some_and(is_instance),
_ => false,
} {
overload.set_return_type(Type::BooleanLiteral(true));
}
Copy link
Member

Choose a reason for hiding this comment

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

we could possibly also set the return type to Literal[False] if ClassType::could_coexist_in_mro_with() returns false for the two classes (indicating that it would be impossible to create a class that subclasses both classes simultaneously):

/// Return `true` if this class could coexist in an MRO with `other`.
///
/// For two given classes `A` and `B`, it is often possible to say for sure
/// that there could never exist any class `C` that inherits from both `A` and `B`.
/// In these situations, this method returns `false`; in all others, it returns `true`.
pub(super) fn could_coexist_in_mro_with(self, db: &'db dyn Db, other: Self) -> bool {

doesn't have to be done in this PR, though

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Ah, interesting! I'm somehow assuming that negative answers will not yield a lot of benefit, but I might be wrong. Will evaluate this separately.

report_bad_argument_to_get_protocol_members(context, call_expression, *class);
}

KnownFunction::IsInstance | KnownFunction::IsSubclass => {
Copy link
Member

Choose a reason for hiding this comment

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

it might make sense to apply similar handling for issubclass() too, just for symmetry if for nothing else. But again, that doesn't have to be done in this PR

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I did sort of a 80:20 thing here. We could do many sophisticated things, but I'm not sure if it's worth it? Might be quick to try out though, will evaluate separately.

Comment on lines +968 to +969
// We could probably try to infer more precise types in some of these cases, but it's unclear
// if it's worth the effort.
Copy link
Member

Choose a reason for hiding this comment

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

I do think the fact that class-literals are all instances of type comes up fairly often, so I think that would be worth reflecting. (But the same is not true for generic aliases, and thus we also can't apply the same logic to SubclassOf types either.)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Didn't yield any new ecosystem results, but doesn't hurt 😄 — thanks.

Copy link
Member

Choose a reason for hiding this comment

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

For the record -- I experimented in #19507 with what it would look like to do a more exhaustive match here. No ecosystem hits at all, so I think you were right to choose a simpler route here :-)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Thanks!

@sharkdp sharkdp merged commit 905b9d7 into main Jul 23, 2025
38 checks passed
@sharkdp sharkdp deleted the david/isinstance-inference branch July 23, 2025 11:06
UnboundVariable pushed a commit to UnboundVariable/ruff that referenced this pull request Jul 23, 2025
* main: (28 commits)
  [ty] highlight the argument in `static_assert` error messages (astral-sh#19426)
  [ty] Infer single-valuedness for enums based on `int`/`str` (astral-sh#19510)
  [ty] Restructure submodule query around `File` dependency
  [ty] Make `Module` a Salsa ingredient
  [ty] Reachability analysis for `isinstance(…)` branches (astral-sh#19503)
  [ty] Normalize single-member enums to their instance type (astral-sh#19502)
  [ty] Invert `ty_ide` and `ty_project` dependency (astral-sh#19501)
  [ty] Implement mock language server for testing (astral-sh#19391)
  [ty] Detect enums if metaclass is a subtype of EnumType/EnumMeta (astral-sh#19481)
  [ty] perform type narrowing for places marked `global` too (astral-sh#19381)
  [ty] Use `ThinVec` for sub segments in `PlaceExpr` (astral-sh#19470)
  [ty] Splat variadic arguments into parameter list (astral-sh#18996)
  [`flake8-pyi`] Skip fix if all `Union` members are `None` (`PYI016`)  (astral-sh#19416)
  Skip notebook with errors in ecosystem check (astral-sh#19491)
  [ty] Consistent use of American english (in rules) (astral-sh#19488)
  [ty] Support iterating over enums (astral-sh#19486)
  Fix panic for illegal `Literal[…]` annotations with inner subscript expressions (astral-sh#19489)
  Move fix suggestion to subdiagnostic (astral-sh#19464)
  [ty] Implement non-stdlib stub mapping for classes and functions (astral-sh#19471)
  [ty] Disallow illegal uses of `ClassVar` (astral-sh#19483)
  ...

# Conflicts:
#	crates/ty_ide/src/goto.rs
dcreager added a commit that referenced this pull request Jul 23, 2025
* main:
  [ty] Fix narrowing and reachability of class patterns with arguments (#19512)
  [ty] Implemented partial support for "find references" language server feature. (#19475)
  [`flake8-use-pathlib`] Add autofix for `PTH101`, `PTH104`, `PTH105`, `PTH121` (#19404)
  [`perflint`] Parenthesize generator expressions (`PERF401`) (#19325)
  [`pep8-naming`] Fix `N802` false positives for `CGIHTTPRequestHandler` and `SimpleHTTPRequestHandler` (#19432)
  [`pylint`] Handle empty comments after line continuation (`PLR2044`) (#19405)
  Move concise diagnostic rendering to `ruff_db` (#19398)
  [ty] highlight the argument in `static_assert` error messages (#19426)
  [ty] Infer single-valuedness for enums based on `int`/`str` (#19510)
  [ty] Restructure submodule query around `File` dependency
  [ty] Make `Module` a Salsa ingredient
  [ty] Reachability analysis for `isinstance(…)` branches (#19503)
  [ty] Normalize single-member enums to their instance type (#19502)
  [ty] Invert `ty_ide` and `ty_project` dependency (#19501)
  [ty] Implement mock language server for testing (#19391)
  [ty] Detect enums if metaclass is a subtype of EnumType/EnumMeta (#19481)
  [ty] perform type narrowing for places marked `global` too (#19381)
AlexWaygood pushed a commit that referenced this pull request Jul 25, 2025
## Summary

Add more precise type inference for a limited set of `isinstance(…)`
calls, i.e. return `Literal[True]` if we can be sure that this is the
correct result. This improves exhaustiveness checking / reachability
analysis for if-elif-else chains with `isinstance` checks. For example:

```py
def is_number(x: int | str) -> bool:  # no "can implicitly return `None` error here anymore
    if isinstance(x, int):
        return True
    elif isinstance(x, str):
        return False

    # code here is now detected as being unreachable
```

This PR also adds a new test suite for exhaustiveness checking.

## Test Plan

New Markdown tests

### Ecosystem analysis

The removed diagnostics look good. There's [one
case](https://github.com/pytorch/vision/blob/f52c4f1afd7dec25cbe7b98bcf1cbc840298e8da/torchvision/io/video_reader.py#L125-L143)
where a "true positive" is removed in unreachable code. `src` is
annotated as being of type `str`, but there is an `elif isinstance(src,
bytes)` branch, which we now detect as unreachable. And so the
diagnostic inside that branch is silenced. I don't think this is a
problem, especially once we have a "graying out" feature, or a lint that
warns about unreachable code.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ecosystem-analyzer ty Multi-file analysis & type inference

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants