mirror of https://github.com/astral-sh/ruff
2 Commits
| Author | SHA1 | Message | Date |
|---|---|---|---|
|
|
2c0c5ff4e7
|
[ty] handle recursive type inference properly (#20566)
## Summary Derived from #17371 Fixes astral-sh/ty#256 Fixes https://github.com/astral-sh/ty/issues/1415 Fixes https://github.com/astral-sh/ty/issues/1433 Fixes https://github.com/astral-sh/ty/issues/1524 Properly handles any kind of recursive inference and prevents panics. --- Let me explain techniques for converging fixed-point iterations during recursive type inference. There are two types of type inference that naively don't converge (causing salsa to panic): divergent type inference and oscillating type inference. ### Divergent type inference Divergent type inference occurs when eagerly expanding a recursive type. A typical example is this: ```python class C: def f(self, other: "C"): self.x = (other.x, 1) reveal_type(C().x) # revealed: Unknown | tuple[Unknown | tuple[Unknown | tuple[..., Literal[1]], Literal[1]], Literal[1]] ``` To solve this problem, we have already introduced `Divergent` types (https://github.com/astral-sh/ruff/pull/20312). `Divergent` types are treated as a kind of dynamic type [^1]. ```python Unknown | tuple[Unknown | tuple[Unknown | tuple[..., Literal[1]], Literal[1]], Literal[1]] => Unknown | tuple[Divergent, Literal[1]] ``` When a query function that returns a type enters a cycle, it sets `Divergent` as the cycle initial value (instead of `Never`). Then, in the cycle recovery function, it reduces the nesting of types containing `Divergent` to converge. ```python 0th: Divergent 1st: Unknown | tuple[Divergent, Literal[1]] 2nd: Unknown | tuple[Unknown | tuple[Divergent, Literal[1]], Literal[1]] => Unknown | tuple[Divergent, Literal[1]] ``` Each cycle recovery function for each query should operate only on the `Divergent` type originating from that query. For this reason, while `Divergent` appears the same as `Any` to the user, it internally carries some information: the location where the cycle occurred. Previously, we roughly identified this by having the scope where the cycle occurred, but with the update to salsa, functions that create cycle initial values can now receive a `salsa::Id` (https://github.com/salsa-rs/salsa/pull/1012). This is an opaque ID that uniquely identifies the cycle head (the query that is the starting point for the fixed-point iteration). `Divergent` now has this `salsa::Id`. ### Oscillating type inference Now, another thing to consider is oscillating type inference. Oscillating type inference arises from the fact that monotonicity is broken. Monotonicity here means that for a query function, if it enters a cycle, the calculation must start from a "bottom value" and progress towards the final result with each cycle. Monotonicity breaks down in type systems that have features like overloading and overriding. ```python class Base: def flip(self) -> "Sub": return Sub() class Sub(Base): def flip(self) -> "Base": return Base() class C: def __init__(self, x: Sub): self.x = x def replace_with(self, other: "C"): self.x = other.x.flip() reveal_type(C(Sub()).x) ``` Naive fixed-point iteration results in `Divergent -> Sub -> Base -> Sub -> ...`, which oscillates forever without diverging or converging. To address this, the salsa API has been modified so that the cycle recovery function receives the value of the previous cycle (https://github.com/salsa-rs/salsa/pull/1012). The cycle recovery function returns the union type of the current cycle and the previous cycle. In the above example, the result type for each cycle is `Divergent -> Sub -> Base (= Sub | Base) -> Base`, which converges. The final result of oscillating type inference does not contain `Divergent` because `Divergent` that appears in a union type can be removed, as is clear from the expansion. This simplification is performed at the same time as nesting reduction. ``` T | Divergent = T | (T | (T | ...)) = T ``` [^1]: In theory, it may be possible to strictly treat types containing `Divergent` types as recursive types, but we probably shouldn't go that deep yet. (AFAIK, there are no PEPs that specify how to handle implicitly recursive types that aren't named by type aliases) ## Performance analysis A happy side effect of this PR is that we've observed widespread performance improvements! This is likely due to the removal of the `ITERATIONS_BEFORE_FALLBACK` and max-specialization depth trick (https://github.com/astral-sh/ty/issues/1433, https://github.com/astral-sh/ty/issues/1415), which means we reach a fixed point much sooner. ## Ecosystem analysis The changes look good overall. You may notice changes in the converged values for recursive types, this is because the way recursive types are normalized has been changed. Previously, types containing `Divergent` types were normalized by replacing them with the `Divergent` type itself, but in this PR, types with a nesting level of 2 or more that contain `Divergent` types are normalized by replacing them with a type with a nesting level of 1. This means that information about the non-divergent parts of recursive types is no longer lost. ```python # previous tuple[tuple[Divergent, int], int] => Divergent # now tuple[tuple[Divergent, int], int] => tuple[Divergent, int] ``` The false positive error introduced in this PR occurs in class definitions with self-referential base classes, such as the one below. ```python from typing_extensions import Generic, TypeVar T = TypeVar("T") U = TypeVar("U") class Base2(Generic[T, U]): ... # TODO: no error # error: [unsupported-base] "Unsupported class base with type `<class 'Base2[Sub2, U@Sub2]'> | <class 'Base2[Sub2[Unknown], U@Sub2]'>`" class Sub2(Base2["Sub2", U]): ... ``` This is due to the lack of support for unions of MROs, or because cyclic legacy generic types are not inferred as generic types early in the query cycle. ## Test Plan All samples listed in astral-sh/ty#256 are tested and passed without any panic! ## Acknowledgments Thanks to @MichaReiser for working on bug fixes and improvements to salsa for this PR. @carljm also contributed early on to the discussion of the query convergence mechanism proposed in this PR. --------- Co-authored-by: Carl Meyer <carl@astral.sh> |
|
|
|
142c1bc760
|
[ty] Recognize submodules in self-referential imports (#18005)
## Summary Fix the lookup of `submodule`s in cases where the `parent` module has a self-referential import like `from parent import submodule`. This allows us to infer proper types for many symbols where we previously inferred `Never`. This leads to many new false (and true) positives across the ecosystem because the fact that we previously inferred `Never` shadowed a lot of problems. For example, we inferred `Never` for `os.path`, which is why we now see a lot of new diagnostics related to `os.path.abspath` and similar. ```py import os reveal_type(os.path) # previously: Never, now: <module 'os.path'> ``` closes https://github.com/astral-sh/ty/issues/261 closes https://github.com/astral-sh/ty/issues/307 ## Ecosystem analysis ``` ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━┳━━━━━━━━━┳━━━━━━━┳━━━━━━━━━━━━┓ ┃ Diagnostic ID ┃ Severity ┃ Removed ┃ Added ┃ Net Change ┃ ┡━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━╇━━━━━━━━━╇━━━━━━━╇━━━━━━━━━━━━┩ │ call-non-callable │ error │ 1 │ 5 │ +4 │ │ call-possibly-unbound-method │ warning │ 6 │ 26 │ +20 │ │ invalid-argument-type │ error │ 26 │ 94 │ +68 │ │ invalid-assignment │ error │ 18 │ 46 │ +28 │ │ invalid-context-manager │ error │ 9 │ 4 │ -5 │ │ invalid-raise │ error │ 1 │ 1 │ 0 │ │ invalid-return-type │ error │ 3 │ 20 │ +17 │ │ invalid-super-argument │ error │ 4 │ 0 │ -4 │ │ invalid-type-form │ error │ 573 │ 0 │ -573 │ │ missing-argument │ error │ 2 │ 10 │ +8 │ │ no-matching-overload │ error │ 0 │ 715 │ +715 │ │ non-subscriptable │ error │ 0 │ 35 │ +35 │ │ not-iterable │ error │ 6 │ 7 │ +1 │ │ possibly-unbound-attribute │ warning │ 14 │ 31 │ +17 │ │ possibly-unbound-import │ warning │ 13 │ 0 │ -13 │ │ possibly-unresolved-reference │ warning │ 0 │ 8 │ +8 │ │ redundant-cast │ warning │ 1 │ 0 │ -1 │ │ too-many-positional-arguments │ error │ 2 │ 0 │ -2 │ │ unknown-argument │ error │ 2 │ 0 │ -2 │ │ unresolved-attribute │ error │ 583 │ 304 │ -279 │ │ unresolved-import │ error │ 0 │ 96 │ +96 │ │ unsupported-operator │ error │ 0 │ 17 │ +17 │ │ unused-ignore-comment │ warning │ 29 │ 2 │ -27 │ ├───────────────────────────────┼──────────┼─────────┼───────┼────────────┤ │ TOTAL │ │ 1293 │ 1421 │ +128 │ └───────────────────────────────┴──────────┴─────────┴───────┴────────────┘ Analysis complete. Found 23 unique diagnostic IDs. Total diagnostics removed: 1293 Total diagnostics added: 1421 Net change: +128 ``` * We see a lot of new errors (`no-matching-overload`) related to `os.path.dirname` and other `os.path` operations because we infer `str | None` for `__file__`, but many projects use something like `os.path.dirname(__file__)`. * We also see many new `unresolved-attribute` errors related to the fact that we now infer proper module types for some imports (e.g. `import kornia.augmentation as K`), but we don't allow implicit imports (e.g. accessing `K.auto.operations` without also importing `K.auto`). See https://github.com/astral-sh/ty/issues/133. * Many false positive `invalid-type-form` are removed because we now infer the correct type for some type expression instead of `Never`, which is not valid in a type annotation/expression context. ## Test Plan Added new Markdown tests |