Skip to content

fix: isinstance narrowing regression with dynamic tuple argument#21190

Open
Bahtya wants to merge 5 commits intopython:masterfrom
Bahtya:fix/isinstance-narrowing-union-classinfo
Open

fix: isinstance narrowing regression with dynamic tuple argument#21190
Bahtya wants to merge 5 commits intopython:masterfrom
Bahtya:fix/isinstance-narrowing-union-classinfo

Conversation

@Bahtya
Copy link
Copy Markdown

@Bahtya Bahtya commented Apr 8, 2026

Problem

When isinstance is called with a dynamically-computed tuple of types (e.g. isinstance(exc, tuple(expected_excs))), mypy 1.20 incorrectly narrows the expression to object instead of keeping its already-narrowed type. This is a regression from 1.19.

def f(exc: BaseException | None, expected: set[type[BaseException]]) -> None:
    if exc is not None:
        if isinstance(exc, tuple(expected)):
            reveal_type(exc)  # 1.19: BaseException ✅ | 1.20: object ❌

Root Cause

The second argument to isinstance has its type stored as the expanded _ClassInfo recursive type alias (type | types.UnionType | tuple[_ClassInfo, ...]) rather than the precise tuple[type[BaseException], ...].

In get_type_range_of_type, the UnionType handler decomposes this union into individual members. The bare type member produces TypeRange(object, is_upper_bound=True), which after union simplification becomes the sole result. This causes conditional_types to narrow to object — a broader type than the already-narrowed BaseException.

In mypy 1.19, the raw UnionType was not handled by the type-range logic (it fell through to return None), which correctly preserved the existing narrowed type.

Solution

In get_type_range_of_type UnionType handler:

  1. Propagate None if any sub-item returns None (uncertainty)
  2. Filter out UninhabitedType entries before simplifying
  3. If the simplified result is just object, return None — this indicates type precision was lost (e.g. from the _ClassInfo widening), and we should fall back to keeping the current type

Testing

  • Verified fix against the original issue reproduction
  • All 248 isinstance tests pass
  • All 209 narrowing tests pass
  • All 843 optional/tuple tests pass
  • No regressions observed

Fixes #21181

Bahtya and others added 2 commits April 9, 2026 06:51
When isinstance is called with a dynamically-computed tuple of types
(e.g. isinstance(exc, tuple(expected_excs))), the second argument's
stored type gets widened to match the _ClassInfo recursive type alias
(type | types.UnionType | tuple[_ClassInfo, ...]). The union handler
in get_type_range_of_type then decomposes this into individual members,
producing TypeRange(object, ...) from bare 'type', which incorrectly
narrows the expression to 'object' instead of keeping its existing type.

Fix by checking if the simplified union result is just 'object' — which
indicates we've lost type precision — and returning None to fall back to
keeping the current type, matching the v1.19 behavior.

Also handle None sub-items explicitly (propagate uncertainty) and filter
out UninhabitedType entries before simplifying.

Fixes python#21181

Signed-off-by: bahtya <bahtyar153@qq.com>
@github-actions

This comment has been minimized.

Bahtya and others added 3 commits April 9, 2026 19:27
- Add explicit None check in valid_ranges comprehension to satisfy type checker
- Store get_proper_type(item) result in variable to avoid calling it twice and enable proper type narrowing

This fixes the 3 type checking errors introduced by the isinstance narrowing fix:
- Item "None" of "TypeRange | None" has no attribute "item"
- "ProperType" has no attribute "type"

The logic remains the same, just restructured to pass strict type checking.
The make_simplified_union return type is already specific enough
for isinstance check, no need to unwrap further.

Bahtya
@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Apr 9, 2026

Diff from mypy_primer, showing the effect of this PR on open source code:

psycopg (https://github.com/psycopg/psycopg)
- psycopg/psycopg/types/numeric.py:407: error: No overload variant of "int" matches argument type "object"  [call-overload]
- psycopg/psycopg/types/numeric.py:407: note: Possible overload variants:
- psycopg/psycopg/types/numeric.py:407: note:     def int(str | Buffer | SupportsInt | SupportsIndex = ..., /) -> int
- psycopg/psycopg/types/numeric.py:407: note:     def int(str | bytes | bytearray, /, base: SupportsIndex) -> int

hydra-zen (https://github.com/mit-ll-responsible-ai/hydra-zen)
+ src/hydra_zen/_launch.py:92: error: Redundant cast to "T"  [redundant-cast]

@Bahtya
Copy link
Copy Markdown
Author

Bahtya commented Apr 9, 2026

Hi mypy maintainers! Friendly ping on this PR.

To summarize for ease of review:

  • All CI checks pass (including mypy_primer, mypyc, self-check across Python 3.10–3.14)
  • mypy_primer result: The fix correctly eliminates a false call-overload error in psycopg, which was caused by the regression this PR addresses. The one new note in hydra-zen (redundant-cast) is a pre-existing unrelated issue.
  • The fix is surgical: it only touches the UnionType handling in get_type_range_of_type, with no risk of over-narrowing elsewhere.
  • Fixes regression [1.20 regression] when same object is narrowed multiple times #21181 (isinstance narrowing became overly broad with dynamic tuple args in mypy 1.20).

Happy to make any adjustments if needed. Thanks for your time!

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.

[1.20 regression] when same object is narrowed multiple times

1 participant