Skip to content

Comments

[ty] Avoid false positive for not-iterable with no-positive intersection types#22089

Merged
charliermarsh merged 13 commits intomainfrom
charlie/neg
Jan 29, 2026
Merged

[ty] Avoid false positive for not-iterable with no-positive intersection types#22089
charliermarsh merged 13 commits intomainfrom
charlie/neg

Conversation

@charliermarsh
Copy link
Member

Summary

When checking if list[~str] is iterable, we end up looking for a positive element to satisfy assignability to Iterable[~str]. But when there are no positive elements, we return false, rather than falling back to object.

Closes astral-sh/ty#1880.

@charliermarsh charliermarsh changed the title Avoid false positive for not-iterable with no-positive intersection types [ty] Avoid false positive for not-iterable with no-positive intersection types Dec 19, 2025
@charliermarsh charliermarsh added the ty Multi-file analysis & type inference label Dec 19, 2025
@astral-sh-bot
Copy link

astral-sh-bot bot commented Dec 19, 2025

Typing conformance results

No changes detected ✅

@astral-sh-bot
Copy link

astral-sh-bot bot commented Dec 19, 2025

mypy_primer results

Changes were detected when running on open source projects
openlibrary (https://github.com/internetarchive/openlibrary)
- openlibrary/core/lending.py:814:22: error[invalid-argument-type] Argument expression after ** must be a mapping type: Found `dict[Unknown | str, Unknown | str | ~AlwaysTruthy | dict[Unknown | str, Unknown] | int]`
- Found 1136 diagnostics
+ Found 1135 diagnostics

prefect (https://github.com/PrefectHQ/prefect)
+ src/integrations/prefect-dbt/prefect_dbt/core/settings.py:94:28: error[invalid-assignment] Object of type `dict[str, Any] | int | dict[Any, Any] | ... omitted 4 union elements` is not assignable to `dict[str, Any]`
+ src/integrations/prefect-dbt/prefect_dbt/core/settings.py:99:28: error[invalid-assignment] Object of type `int | dict[Any, Any] | float | ... omitted 3 union elements` is not assignable to `dict[str, Any]`
+ src/prefect/cli/deploy/_core.py:86:21: error[invalid-assignment] Object of type `dict[str, Any] | int | dict[Any, Any] | ... omitted 4 union elements` is not assignable to `dict[str, Any]`
+ src/prefect/cli/deploy/_core.py:87:21: error[invalid-assignment] Object of type `int | dict[Any, Any] | float | ... omitted 3 union elements` is not assignable to `dict[str, Any]`
+ src/prefect/deployments/steps/core.py:137:38: error[invalid-argument-type] Argument is incorrect: Argument type `dict[str, Any] | int | dict[Any, Any] | ... omitted 4 union elements` does not satisfy constraints (`str`, `int`, `int | float`, `bool`, `dict[Any, Any]`, `list[Any]`, `None`) of type variable `T`
- src/prefect/utilities/templating.py:320:13: error[invalid-assignment] Invalid subscript assignment with key of type `object` and value of type `Unknown | dict[str, Any]` on object of type `dict[str, Any]`
+ src/prefect/utilities/templating.py:320:13: error[invalid-assignment] Invalid subscript assignment with key of type `object` and value of type `dict[str, Any] | int | Unknown | ... omitted 4 union elements` on object of type `dict[str, Any]`
- src/prefect/utilities/templating.py:323:16: error[invalid-return-type] Return type does not match returned value: expected `T@resolve_block_document_references | dict[str, Any]`, found `list[Unknown | dict[str, Any]]`
+ src/prefect/utilities/templating.py:323:16: error[invalid-return-type] Return type does not match returned value: expected `T@resolve_block_document_references | dict[str, Any]`, found `list[Unknown | dict[str, Any] | int | ... omitted 4 union elements]`
- src/prefect/utilities/templating.py:437:16: error[invalid-return-type] Return type does not match returned value: expected `T@resolve_variables`, found `dict[object, Unknown]`
+ src/prefect/utilities/templating.py:437:16: error[invalid-return-type] Return type does not match returned value: expected `T@resolve_variables`, found `dict[object, Unknown | int | float | ... omitted 4 union elements]`
- src/prefect/utilities/templating.py:442:16: error[invalid-return-type] Return type does not match returned value: expected `T@resolve_variables`, found `list[Unknown]`
+ src/prefect/utilities/templating.py:442:16: error[invalid-return-type] Return type does not match returned value: expected `T@resolve_variables`, found `list[Unknown | int | float | ... omitted 4 union elements]`
- src/prefect/workers/base.py:232:13: error[invalid-argument-type] Argument is incorrect: Argument type `str | dict[str, Any]` does not satisfy constraints (`str`, `int`, `int | float`, `bool`, `dict[Any, Any]`, `list[Any]`, `None`) of type variable `T`
+ src/prefect/workers/base.py:232:13: error[invalid-argument-type] Argument is incorrect: Argument type `dict[str, Any] | int | str | ... omitted 3 union elements` does not satisfy constraints (`str`, `int`, `int | float`, `bool`, `dict[Any, Any]`, `list[Any]`, `None`) of type variable `T`
+ src/prefect/workers/base.py:234:20: error[invalid-argument-type] Argument expression after ** must be a mapping type: Found `int | Unknown | float | ... omitted 4 union elements`
- Found 5368 diagnostics
+ Found 5374 diagnostics

strawberry (https://github.com/strawberry-graphql/strawberry)
- strawberry/permission.py:163:35: error[not-iterable] Object of type `list[Unknown | ~AlwaysFalsy]` is not iterable
- Found 345 diagnostics
+ Found 344 diagnostics

scikit-build-core (https://github.com/scikit-build/scikit-build-core)
+ src/scikit_build_core/build/wheel.py:99:20: error[no-matching-overload] No overload of bound method `__init__` matches arguments
- Found 46 diagnostics
+ Found 47 diagnostics

altair (https://github.com/vega/altair)
- altair/vegalite/v6/api.py:263:42: error[invalid-argument-type] Argument expression after ** must be a mapping type: Found `dict[Unknown, Unknown] | dict[Unknown | str, Unknown] | dict[Unknown | ~Literal["values"], object]`
+ altair/vegalite/v6/api.py:263:42: error[invalid-argument-type] Argument expression after ** must be a mapping with `str` key type: Found `object`
+ altair/vegalite/v6/api.py:263:42: error[invalid-argument-type] Argument to bound method `__init__` is incorrect: Expected `SchemaBase | Mapping[str, Any] | UndefinedType`, found `object`
- Found 1082 diagnostics
+ Found 1083 diagnostics

rotki (https://github.com/rotki/rotki)
- rotkehlchen/chain/decoding/tools.py:96:44: warning[unused-type-ignore-comment] Unused blanket `type: ignore` directive
- rotkehlchen/chain/decoding/tools.py:99:13: error[invalid-argument-type] Argument to function `decode_transfer_direction` is incorrect: Expected `Sequence[A@BaseDecoderTools]`, found `Unknown | tuple[BTCAddress, ...] | tuple[ChecksumAddress, ...] | tuple[SubstrateAddress, ...] | tuple[SolanaAddress, ...]`
- rotkehlchen/chain/decoding/tools.py:100:62: warning[unused-type-ignore-comment] Unused blanket `type: ignore` directive
+ rotkehlchen/chain/decoding/tools.py:97:13: error[invalid-argument-type] Argument to function `decode_transfer_direction` is incorrect: Expected `BTCAddress | ChecksumAddress | SubstrateAddress | SolanaAddress`, found `A@BaseDecoderTools`
+ rotkehlchen/chain/decoding/tools.py:98:13: error[invalid-argument-type] Argument to function `decode_transfer_direction` is incorrect: Expected `BTCAddress | ChecksumAddress | SubstrateAddress | SolanaAddress | None`, found `A@BaseDecoderTools | None`
- Found 2050 diagnostics
+ Found 2049 diagnostics

core (https://github.com/home-assistant/core)
- homeassistant/components/bluetooth/passive_update_coordinator.py:78:20: error[not-iterable] Object of type `GeneratorType[~None, None, None]` is not iterable
- homeassistant/helpers/update_coordinator.py:217:20: error[not-iterable] Object of type `GeneratorType[~None, None, None]` is not iterable
- homeassistant/util/variance.py:47:12: error[invalid-return-type] Return type does not match returned value: expected `(**_P@ignore_variance) -> _R@ignore_variance`, found `_Wrapped[_P@ignore_variance, int | _R@ignore_variance | float | datetime, _P@ignore_variance, _R@ignore_variance | int | float | datetime]`
- Found 14503 diagnostics
+ Found 14500 diagnostics

No memory usage changes detected ✅

@charliermarsh charliermarsh marked this pull request as ready for review December 19, 2025 16:35
@codspeed-hq
Copy link

codspeed-hq bot commented Dec 19, 2025

Merging this PR will not alter performance

✅ 24 untouched benchmarks
⏩ 30 skipped benchmarks1


Comparing charlie/neg (87233b9) with main (1b2a0e6)

Open in CodSpeed

Footnotes

  1. 30 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports.

@charliermarsh
Copy link
Member Author

Ok, wasn't expecting that!

@AlexWaygood
Copy link
Member

This does look correct, but we obviously can't merge this if it causes the benchmarks to start timing out 🙃

I think we probably need to do similar for the when_any calls in the Type::Intersection branch above this one, too

@charliermarsh
Copy link
Member Author

(Looking into it...)

@charliermarsh charliermarsh marked this pull request as draft December 19, 2025 18:34
// we treat `object` as the implicit positive element (e.g., `~str` is semantically
// `object & ~str`).
intersection
.positive_elements_or_object(db)
Copy link
Contributor

@carljm carljm Dec 19, 2025

Choose a reason for hiding this comment

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

I expect this fallback to object is the source of the regression, and I'm surprised that we would need it here. If we are going to treat an intersection type with no positive elements as equivalent to object, then the only thing it can be a subtype of is object (and the only things it can be assignable to are Any/Unknown and object), and those cases should already be handled above. What was failing without this change? That is, in what form does the list[~str] iteration case reach here?

Copy link
Member

Choose a reason for hiding this comment

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

~str can also be a subtype of a union, like ~str | int -- but I guess that's also handled by a higher-up branch.

Copy link
Member

Choose a reason for hiding this comment

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

oh, and ~str & ~int is a subtype of both ~str and ~str & ~int, right?

Copy link
Contributor

@carljm carljm Dec 19, 2025

Choose a reason for hiding this comment

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

Yes, seems like we could add consideration of cases like that to get more precise results here, but that seems like a separate issue. We don't consider negative types in the intersection either before or after the current change in this PR. The only change here is to explicitly test object, which seems to me like it shouldn't be necessary (but clearly I'm missing something, if it makes the test in this PR pass.)

Copy link
Member

Choose a reason for hiding this comment

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

I was just answering the immediate question, not trying to say that these cases were relevant. I think they're handled by the branch immediately above this one, anyway

Copy link
Member Author

Choose a reason for hiding this comment

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

I am of course not an expert here but my understanding is that when we try_iterate over list[~str], we eventually check list.__iter__ which is def __iter__(self) -> Iterator[_T], and then need to infer specialization to determine that _T is ~str.

We then end up in has_relation_to_impl and in this big match on (self, target), self is an intersection (~str) and target is a type var (_T), which don't match any of the above cases... So we land in this case, and at present, intersection.positive(db).iter() is empty, so we return false.

Copy link
Contributor

Choose a reason for hiding this comment

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

That makes sense. Sorry it took me a while to get back to this!

After thinking it over, I do feel like the change made here is correct and necessary. But the combination of benchmark and ecosystem results suggests that most of the time we still end up finding that object is not assignable to target (that is, in most cases this PR doesn't change our eventual behavior) -- but we now spend a fair amount of extra time reaching that conclusion.

Still, it would be great if we could bring down the perf impact more here. I feel like the most promising way to do that is probably more of what you already did? That is, find the next-most-common case (via profiling) where we spend a lot of time concluding that object is not assignable to something, and see if we can add a short-circuit test for that.

Copy link
Member Author

Choose a reason for hiding this comment

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

On it.

@charliermarsh charliermarsh force-pushed the charlie/neg branch 2 times, most recently from a5cc211 to 6de4b1d Compare January 14, 2026 01:07
charliermarsh and others added 5 commits January 28, 2026 10:53
This handles the common case where we're checking if `object` (the
implicit positive element of a pure negation like `~str`) is assignable
to a generic type parameter like `_T` in `Iterator[_T]`.

Co-Authored-By: Claude Opus 4.5 <[email protected]>
@charliermarsh
Copy link
Member Author

Omg regression is gone. Now to remove all the fast paths and see if I can limit it to the last one.

@charliermarsh charliermarsh marked this pull request as ready for review January 28, 2026 21:41
@AlexWaygood
Copy link
Member

uhh we're back to a 39% regression after 01c4d3d 😆

@charliermarsh
Copy link
Member Author

I will try to remove some of the fast paths, but the regression is gone at least.

@AlexWaygood
Copy link
Member

AlexWaygood commented Jan 28, 2026

I think you can quite easily combine several of the fast paths. I don't think it's really necessary to have a separate comment for every one:

diff --git a/crates/ty_python_semantic/src/types/relation.rs b/crates/ty_python_semantic/src/types/relation.rs
index efd95bdb30..32e2de1b35 100644
--- a/crates/ty_python_semantic/src/types/relation.rs
+++ b/crates/ty_python_semantic/src/types/relation.rs
@@ -662,18 +662,15 @@ impl<'db> Type<'db> {
                 ConstraintSet::from(true)
             }
 
-            // Fast path: `object` is not a subtype of any nominal instance type other than itself.
-            // This is important for performance when checking intersections with no positive
-            // elements (pure negations like `~str`), which are treated as having `object` as
-            // the implicit positive element.
-            (Type::NominalInstance(source), Type::NominalInstance(_)) if source.is_object() => {
-                ConstraintSet::from(false)
-            }
-
-            // Fast path: `object` (an instance type) is not a subtype of any `type[X]` (a class type).
-            (Type::NominalInstance(source), Type::SubclassOf(_)) if source.is_object() => {
-                ConstraintSet::from(false)
-            }
+            // Fast path for various types that we know `object` is never a subtype of
+            // (`object` can be a subtype of some protocols, but that case is handled above).
+            (
+                Type::NominalInstance(source),
+                Type::NominalInstance(_)
+                | Type::SubclassOf(_)
+                | Type::Callable(_)
+                | Type::ProtocolInstance(_),
+            ) if source.is_object() => ConstraintSet::from(false),
 
             // Fast path: `object` is not a subtype of any non-inferable type variable, since the
             // type variable could be specialized to a type smaller than `object`.
@@ -698,19 +695,6 @@ impl<'db> Type<'db> {
                 ConstraintSet::from(true)
             }
 
-            // Fast path: `object` is not a subtype of any callable type, since not all objects
-            // are callable.
-            (Type::NominalInstance(source), Type::Callable(_)) if source.is_object() => {
-                ConstraintSet::from(false)
-            }
-
-            // Fast path: `object` is only a subtype/assignable to a protocol if the protocol
-            // is equivalent to `object` (handled above). Otherwise, not all objects satisfy
-            // the protocol.
-            (Type::NominalInstance(source), Type::ProtocolInstance(_)) if source.is_object() => {
-                ConstraintSet::from(false)
-            }
-
             // Fast path: `object` is only a subtype/assignable to unions that contain a type
             // that can definitely accept `object`. If a union contains only types that cannot
             // accept `object`, we can short-circuit to false.

Copy link
Contributor

@carljm carljm left a comment

Choose a reason for hiding this comment

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

Awesome! Great work eliminating the regression here.

@charliermarsh charliermarsh merged commit 2d16d84 into main Jan 29, 2026
49 checks passed
@charliermarsh charliermarsh deleted the charlie/neg branch January 29, 2026 00:55
carljm added a commit that referenced this pull request Jan 30, 2026
* main: (76 commits)
  [ty] Improve the check for `NewType`s with generic bases (#22961)
  [ty] Ban legacy `TypeVar` bounds or constraints from containing type variables (#22949)
  Bump the typing conformance suite pin (#22960)
  [ty] Emit an error if a TypeVarTuple is used to subscript `Generic` or `Protocol` without being unpacked (#22952)
  [ty] Reduce false positives when subscripting classes generic over `TypeVarTuple`s (#22950)
  [ty] Detect invalid attempts to subclass `Protocol[]` and `Generic[]` simultaneously (#22948)
  Fix suppression indentation matching (#22903)
  Remove hidden `--output-format` warning (#22944)
  [ty] Validate signatures of dataclass `__post_init__` methods (#22730)
  [ty] extend special-cased `numbers` diagnostic to `invalid-argument-type` errors (#22938)
  [ty] Avoid false positive for `not-iterable` with no-positive intersection types (#22089)
  [ty] Preserve pure negation types in descriptor protocol (#22907)
  [ty] add special-case diagnostic for `numbers` module (#22931)
  [ty] Move the location of more `invalid-overload` diagnostics (#22933)
  [ty] Fix unary and comparison operators for TypeVars with union bounds (#22925)
  [ty] Rule Selection: ignore/warn/select all rules (unless subsequently overriden) (#22832)
  [ty] Fix TypedDict construction from existing TypedDict values (#22904)
  [ty] fix bug in string annotations and clean up diagnostics (#22913)
  [ty] Improve support for goto-type, goto-declaration, hover, and highlighting of string annotations (#22878)
  [ty] Rename old typing imports to new on `unresolved-reference`. (#22827)
  ...
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ty Multi-file analysis & type inference

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Type list[...] containing negated type is not iterable

3 participants