diff --git a/crates/ruff_benchmark/benches/ty_walltime.rs b/crates/ruff_benchmark/benches/ty_walltime.rs index 9588fb055e85f9..43baf8211a365b 100644 --- a/crates/ruff_benchmark/benches/ty_walltime.rs +++ b/crates/ruff_benchmark/benches/ty_walltime.rs @@ -110,7 +110,7 @@ static ALTAIR: Benchmark = Benchmark::new( max_dep_date: TY_ECOSYSTEM_PIN, python_version: SupportedPythonVersion::Py311, }, - 5, + 9, ); static COLOUR_SCIENCE: Benchmark = Benchmark::new( diff --git a/crates/ty/docs/configuration.md b/crates/ty/docs/configuration.md index 94143a37b7626c..d60457fc2863de 100644 --- a/crates/ty/docs/configuration.md +++ b/crates/ty/docs/configuration.md @@ -266,6 +266,49 @@ def narrow_match(x: str) -> None: --- +### `strict-generic-narrowing` + +Whether ty should use strict narrowing for unspecialized generic classes in +`isinstance()` and `issubclass()` checks, as well as `match` class patterns. + +When enabled, ty narrows to the top materialization of the class. For example, +`isinstance(value, list)` narrows a value of type `object` to `Top[list[Unknown]]`, +representing the (infinite) union of all possible `list` specializations. Iterating +over the list would yield values of type `object`. + +When disabled, ty uses gradual generic narrowing, preserving compatible type +arguments from the original type where possible. For example, +`isinstance(value, list)` narrows a value of type `Sequence[int]` to `list[int]`. +If no specialization is available, the same check narrows a value of type `object` +to `list[Unknown]`; items of any type can then be appended to the list. Class +patterns such as `case list():` follow the same behavior. + +Defaults to `false`. + +**Default value**: `false` + +**Type**: `bool` + +**Example usage**: + +=== "pyproject.toml" + + ```toml + [tool.ty.analysis] + # Use the top materialization when narrowing to an unspecialized generic class + strict-generic-narrowing = true + ``` + +=== "ty.toml" + + ```toml + [analysis] + # Use the top materialization when narrowing to an unspecialized generic class + strict-generic-narrowing = true + ``` + +--- + ## `environment` ### `extra-paths` @@ -866,6 +909,49 @@ def narrow_match(x: str) -> None: --- +#### `strict-generic-narrowing` + +Whether ty should use strict narrowing for unspecialized generic classes in +`isinstance()` and `issubclass()` checks, as well as `match` class patterns. + +When enabled, ty narrows to the top materialization of the class. For example, +`isinstance(value, list)` narrows a value of type `object` to `Top[list[Unknown]]`, +representing the (infinite) union of all possible `list` specializations. Iterating +over the list would yield values of type `object`. + +When disabled, ty uses gradual generic narrowing, preserving compatible type +arguments from the original type where possible. For example, +`isinstance(value, list)` narrows a value of type `Sequence[int]` to `list[int]`. +If no specialization is available, the same check narrows a value of type `object` +to `list[Unknown]`; items of any type can then be appended to the list. Class +patterns such as `case list():` follow the same behavior. + +Defaults to `false`. + +**Default value**: `false` + +**Type**: `bool` + +**Example usage**: + +=== "pyproject.toml" + + ```toml + [tool.ty.overrides.analysis] + # Use the top materialization when narrowing to an unspecialized generic class + strict-generic-narrowing = true + ``` + +=== "ty.toml" + + ```toml + [overrides.analysis] + # Use the top materialization when narrowing to an unspecialized generic class + strict-generic-narrowing = true + ``` + +--- + ## `src` ### `exclude` diff --git a/crates/ty_project/src/metadata/options.rs b/crates/ty_project/src/metadata/options.rs index 446bef4a5610d1..a93d0ea234212a 100644 --- a/crates/ty_project/src/metadata/options.rs +++ b/crates/ty_project/src/metadata/options.rs @@ -1438,6 +1438,32 @@ pub struct TerminalOptions { #[serde(rename_all = "kebab-case", deny_unknown_fields)] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] pub struct AnalysisOptions { + /// Whether ty should use strict narrowing for unspecialized generic classes in + /// `isinstance()` and `issubclass()` checks, as well as `match` class patterns. + /// + /// When enabled, ty narrows to the top materialization of the class. For example, + /// `isinstance(value, list)` narrows a value of type `object` to `Top[list[Unknown]]`, + /// representing the (infinite) union of all possible `list` specializations. Iterating + /// over the list would yield values of type `object`. + /// + /// When disabled, ty uses gradual generic narrowing, preserving compatible type + /// arguments from the original type where possible. For example, + /// `isinstance(value, list)` narrows a value of type `Sequence[int]` to `list[int]`. + /// If no specialization is available, the same check narrows a value of type `object` + /// to `list[Unknown]`; items of any type can then be appended to the list. Class + /// patterns such as `case list():` follow the same behavior. + /// + /// Defaults to `false`. + #[option( + default = r#"false"#, + value_type = "bool", + example = r#" + # Use the top materialization when narrowing to an unspecialized generic class + strict-generic-narrowing = true + "# + )] + pub strict_generic_narrowing: Option, + /// Configure ty's behavior regarding type inference and narrowing of equality /// checks. Defaults to `false`. /// @@ -1604,6 +1630,7 @@ impl AnalysisOptions { diagnostics: &mut Vec, ) -> AnalysisSettings { let Self { + strict_generic_narrowing, strict_equality_semantics, respect_type_ignore_comments, allowed_unresolved_imports, @@ -1611,6 +1638,7 @@ impl AnalysisOptions { } = self; let AnalysisSettings { + strict_generic_narrowing: strict_generic_narrowing_default, strict_equality_semantics: strict_equality_semantics_default, respect_type_ignore_comments: respect_type_ignore_default, allowed_unresolved_imports: allowed_unresolved_imports_default, @@ -1640,6 +1668,8 @@ impl AnalysisOptions { }; AnalysisSettings { + strict_generic_narrowing: strict_generic_narrowing + .unwrap_or(strict_generic_narrowing_default), strict_equality_semantics: strict_equality_semantics .unwrap_or(strict_equality_semantics_default), respect_type_ignore_comments: respect_type_ignore_comments diff --git a/crates/ty_python_semantic/resources/mdtest/call/builtins.md b/crates/ty_python_semantic/resources/mdtest/call/builtins.md index 70172094d61bd1..163523ba4b857b 100644 --- a/crates/ty_python_semantic/resources/mdtest/call/builtins.md +++ b/crates/ty_python_semantic/resources/mdtest/call/builtins.md @@ -517,6 +517,11 @@ for function in map(Function, [object()]): Several `dict` overloads accept one positional argument. When none matches, an arbitrarily selected overload must not make an otherwise compatible return type fail. +```toml +[analysis] +strict-generic-narrowing = true +``` + ```py from collections.abc import Mapping @@ -531,6 +536,11 @@ def copy(value: object) -> dict[str, str]: An invalid `dict` call must not invalidate an assignment inside a branch where the original value has already been narrowed to a mapping. +```toml +[analysis] +strict-generic-narrowing = true +``` + ```py from collections.abc import Mapping diff --git a/crates/ty_python_semantic/resources/mdtest/loops/for.md b/crates/ty_python_semantic/resources/mdtest/loops/for.md index b85d7dadd43ea2..da46edabc708b0 100644 --- a/crates/ty_python_semantic/resources/mdtest/loops/for.md +++ b/crates/ty_python_semantic/resources/mdtest/loops/for.md @@ -679,14 +679,13 @@ def _(x: Sequence[int], y: object): reveal_type(item) # revealed: int if isinstance(y, list): - reveal_type(y) # revealed: Top[list[Unknown]] + reveal_type(y) # revealed: list[Unknown] for item in y: - reveal_type(item) # revealed: object + reveal_type(item) # revealed: Unknown if isinstance(x, list): - reveal_type(x) # revealed: Sequence[int] & Top[list[Unknown]] + reveal_type(x) # revealed: list[int] for item in x: - # int & object simplifies to int reveal_type(item) # revealed: int ``` @@ -1541,12 +1540,10 @@ simplify to `Never`, leaving only the iterable parts. ```py def f[T: tuple[int, ...] | int](x: T): if isinstance(x, tuple): - reveal_type(x) # revealed: T@f & tuple[object, ...] + reveal_type(x) # revealed: T@f & tuple[int, ...] for item in x: - # The intersection `(tuple[int, ...] | int) & tuple[object, ...]` distributes to: - # `(tuple[int, ...] & tuple[object, ...]) | (int & tuple[object, ...])` - # which simplifies to `tuple[int, ...] | Never` = `tuple[int, ...]` - # so iterating gives `int`. + # The `int` alternative in the TypeVar bound is disjoint from `tuple`. The + # remaining `tuple[int, ...]` alternative supplies the narrowed specialization. reveal_type(item) # revealed: int ``` @@ -1558,13 +1555,10 @@ constraint, those parts should also simplify to `Never`. ```py def g[T: tuple[int, ...] | list[str]](x: T): if isinstance(x, tuple): - reveal_type(x) # revealed: T@g & tuple[object, ...] + reveal_type(x) # revealed: T@g & tuple[int, ...] for item in x: - # The intersection `(tuple[int, ...] | list[str]) & tuple[object, ...]` distributes to: - # `(tuple[int, ...] & tuple[object, ...]) | (list[str] & tuple[object, ...])` - # Since `list[str]` is disjoint from `tuple[object, ...]`, this simplifies to: - # `tuple[int, ...] | Never` = `tuple[int, ...]` - # so iterating gives `int`, NOT `int | str`. + # The `list[str]` alternative in the TypeVar bound is disjoint from `tuple`. The + # remaining `tuple[int, ...]` alternative supplies the narrowed specialization. reveal_type(item) # revealed: int ``` diff --git a/crates/ty_python_semantic/resources/mdtest/narrow/callable.md b/crates/ty_python_semantic/resources/mdtest/narrow/callable.md index 613af0996bf0f2..66f5795d939b9d 100644 --- a/crates/ty_python_semantic/resources/mdtest/narrow/callable.md +++ b/crates/ty_python_semantic/resources/mdtest/narrow/callable.md @@ -54,7 +54,15 @@ def f(x: object): ## Calling narrowed callables -The narrowed type `Top[Callable[..., object]]` represents the set of all possible callable types +### Strict generic narrowing mode + +```toml +[analysis] +strict-generic-narrowing = true +``` + +In strict generic narrowing mode, an `isinstance(.., Callable)` check intersects the type with +`Top[Callable[..., object]]`. This type represents the set of all possible callable types (including, e.g., functions that take no arguments and functions that require arguments). While such objects *are* callable (they pass `callable()`), no specific set of arguments can be guaranteed to be valid. @@ -80,6 +88,36 @@ def resolve(value: str): reveal_type(value()) # revealed: object ``` +### Gradual generic narrowing mode + +```toml +[analysis] +strict-generic-narrowing = false +``` + +In gradual generic narrowing mode, an `isinstance(.., Callable)` check narrows to a gradual +callable. Its parameters accept arbitrary arguments, and its return type is `Unknown`: + +```py +from typing import Callable + +def call_with_args(y: object): + if isinstance(y, Callable): + reveal_type(y) # revealed: (...) -> Unknown + + reveal_type(y()) # revealed: Unknown + reveal_type(y(1, "foo")) # revealed: Unknown + reveal_type(y(1, "foo", keyword_arg="bar")) # revealed: Unknown +``` + +An already-specialized callable retains its known parameter and return types: + +```py +def preserve_callable_signature(fn: Callable[[int], str]) -> None: + if isinstance(fn, Callable): + reveal_type(fn) # revealed: (int, /) -> str +``` + ## Narrowing with named expressions (walrus operator) When `callable()` is used with a named expression, the target of the named expression should be @@ -139,9 +177,14 @@ import collections.abc def f(x: object): if isinstance(x, typing.Callable): - reveal_type(x) # revealed: Top[(...) -> object] + reveal_type(x) # revealed: (...) -> Unknown + else: + reveal_type(x) # revealed: ~Top[(...) -> object] + if isinstance(x, collections.abc.Callable): - reveal_type(x) # revealed: Top[(...) -> object] + reveal_type(x) # revealed: (...) -> Unknown + else: + reveal_type(x) # revealed: ~Top[(...) -> object] ``` ## `Callable` special-form identity diff --git a/crates/ty_python_semantic/resources/mdtest/narrow/conditionals/in.md b/crates/ty_python_semantic/resources/mdtest/narrow/conditionals/in.md index f6ae86bf6e5055..c9528545eec608 100644 --- a/crates/ty_python_semantic/resources/mdtest/narrow/conditionals/in.md +++ b/crates/ty_python_semantic/resources/mdtest/narrow/conditionals/in.md @@ -1157,6 +1157,11 @@ After the `isinstance` check, `values` has type `Iterable[Literal[1]] & tuple[ob semantics were checked: the `tuple` component establishes that membership compares against its elements, while the `Iterable` component constrains those elements to `Literal[1]`. +```toml +[analysis] +strict-generic-narrowing = true +``` + ```py from collections.abc import Iterable from typing import Literal, final diff --git a/crates/ty_python_semantic/resources/mdtest/narrow/isinstance.md b/crates/ty_python_semantic/resources/mdtest/narrow/isinstance.md index ebcee31192725c..5c296845f0c802 100644 --- a/crates/ty_python_semantic/resources/mdtest/narrow/isinstance.md +++ b/crates/ty_python_semantic/resources/mdtest/narrow/isinstance.md @@ -305,7 +305,7 @@ def f(x: dict[str, int] | list[str], y: object): reveal_type(x) # revealed: list[str] if isinstance(y, t.Callable): - reveal_type(y) # revealed: Top[(...) -> object] + reveal_type(y) # revealed: (...) -> Unknown ``` ## Class types @@ -603,14 +603,19 @@ def f(x: Foo, y: Intersection[type[Bar], type[list[int]]]): ## Narrowing with generics +### Strict mode + ```toml [environment] python-version = "3.12" + +[analysis] +strict-generic-narrowing = true ``` -Narrowing to a generic class using `isinstance()` uses the top materialization of the generic. With -a covariant generic, this is equivalent to using the upper bound of the type parameter (by default, -`object`): +In strict mode, narrowing to a generic class using `isinstance()` uses the top materialization of +the generic. With a covariant generic, this is equivalent to using the upper bound of the type +parameter (by default, `object`): ```py from typing import Self @@ -841,13 +846,451 @@ def excludes_bounded_generic_subclass( return cls ``` -## Narrowing recursively bounded generics +### Gradual mode + +```toml +[environment] +python-version = "3.12" + +[analysis] +strict-generic-narrowing = false +``` + +In gradual mode, narrowing to a generic class using `isinstance()` preserves any compatible +specialization from the original type. If the original type does not provide a specialization, we +intersect with the `Unknown` specialization. The negative branch still excludes the top +materialization because a failed `isinstance()` check rules out every specialization of the class. + +```py +class Covariant[T]: + def get(self) -> T: + raise NotImplementedError + +def _(x: object): + if isinstance(x, Covariant): + # `object & Covariant[Unknown]` simplifies to `Covariant[Unknown]`. + reveal_type(x) # revealed: Covariant[Unknown] + reveal_type(x.get()) # revealed: Unknown + else: + reveal_type(x) # revealed: ~Covariant[object] +``` + +For contravariant generics, we similarly intersect with the `Unknown` specialization: + +```py +class Contravariant[T]: + def push(self, x: T) -> None: ... + +def _(x: object): + if isinstance(x, Contravariant): + reveal_type(x) # revealed: Contravariant[Unknown] + x.push(42) + x.push("foo") + else: + reveal_type(x) # revealed: ~Contravariant[Never] +``` + +Similarly, for invariant generics we intersect with the `Unknown` specialization. Reading produces +`Unknown`, while writing accepts arguments of any type: + +```py +class Invariant[T]: + def push(self, x: T) -> None: ... + def get(self) -> T: + raise NotImplementedError + +def _(x: object): + if isinstance(x, Invariant): + reveal_type(x) # revealed: Invariant[Unknown] + reveal_type(x.get) # revealed: bound method Invariant[Unknown].get() -> Unknown + reveal_type(x.get()) # revealed: Unknown + reveal_type(x.push) # revealed: bound method Invariant[Unknown].push(x: Unknown) -> None + x.push(42) + x.push("foo") + else: + reveal_type(x) # revealed: ~Top[Invariant[Unknown]] +``` + +Narrowing already specialized generics preserves their concrete type arguments: + +```py +class P: ... + +def _(x: Covariant[P], y: Contravariant[P], z: Invariant[P]): + if isinstance(x, Covariant): + reveal_type(x) # revealed: Covariant[P] + if isinstance(y, Contravariant): + reveal_type(y) # revealed: Contravariant[P] + if isinstance(z, Invariant): + reveal_type(z) # revealed: Invariant[P] +``` + +Specialized base classes also determine the type arguments of matching subclasses, including +subclasses with a stricter variance: + +```py +class SubOfCovariant[T](Covariant[T]): ... +class SubOfContravariant[T](Contravariant[T]): ... +class SubOfInvariant[T](Invariant[T]): ... + +class InvariantSubOfCovariant[T](Covariant[T]): + def push(self, value: T) -> None: ... + +class InvariantSubOfContravariant[T](Contravariant[T]): + def get(self) -> T: + raise NotImplementedError + +def narrow_generic_subclasses(covariant: Covariant[P], contravariant: Contravariant[P], invariant: Invariant[P]) -> None: + if isinstance(covariant, SubOfCovariant): + reveal_type(covariant) # revealed: SubOfCovariant[P] + + if isinstance(contravariant, SubOfContravariant): + reveal_type(contravariant) # revealed: SubOfContravariant[P] + + if isinstance(invariant, SubOfInvariant): + reveal_type(invariant) # revealed: SubOfInvariant[P] + + if isinstance(covariant, InvariantSubOfCovariant): + reveal_type(covariant) # revealed: InvariantSubOfCovariant[P] + + if isinstance(contravariant, InvariantSubOfContravariant): + reveal_type(contravariant) # revealed: InvariantSubOfContravariant[P] +``` + +Narrowing unions and intersections preserves unrelated types when they can overlap with the checked +class, while excluding unrelated final classes: + +```py +from typing import Sequence, final +from ty_extensions import Intersection + +@final +class Item: ... + +class OpenItem: ... + +def _(value: Item | OpenItem | Sequence[int]) -> None: + if isinstance(value, list): + reveal_type(value) # revealed: (OpenItem & list[Unknown]) | list[int] + +def _( + value: Intersection[OpenItem, Sequence[int]], +) -> None: + if isinstance(value, list): + reveal_type(value) # revealed: OpenItem & list[int] +``` + +When an intersection contains multiple specialized bases, each base contributes its known type +arguments to a matching subclass: + +```py +class Left[L]: ... +class Right[R]: ... + +class Both[L, R](Left[L], Right[R]): + left: L + right: R + +def _(value: Intersection[Left[int], Right[str]]) -> None: + if isinstance(value, Both): + reveal_type(value) # revealed: Both[int, str] + reveal_type(value.left) # revealed: int + reveal_type(value.right) # revealed: str +``` + +Subclass type arguments are inferred through their actual inheritance relationship, so this also +works correctly if type parameters change position: + +```py +class Base[A, B]: ... +class Child[X, Y](Base[Y, X]): ... + +def _(value: Base[int, str]) -> None: + if isinstance(value, Child): + reveal_type(value) # revealed: Child[str, int] +``` + +A subclass type parameter that cannot be inferred from its base remains `Unknown`: + +```py +class PartiallyInferredChild[Extra1, T, Extra2](Sequence[T]): ... + +def _(value: Sequence[int]) -> None: + if isinstance(value, PartiallyInferredChild): + reveal_type(value) # revealed: PartiallyInferredChild[Unknown, int, Unknown] +``` + +If we're "narrowing" in the opposite direction, we retain the existing subclass specialization: + +```py +def _(covariant: SubOfCovariant[P], contravariant: SubOfContravariant[P], invariant: SubOfInvariant[P]) -> None: + if isinstance(covariant, Covariant): + reveal_type(covariant) # revealed: SubOfCovariant[P] + + if isinstance(contravariant, Contravariant): + reveal_type(contravariant) # revealed: SubOfContravariant[P] + + if isinstance(invariant, Invariant): + reveal_type(invariant) # revealed: SubOfInvariant[P] +``` + +This also works for runtime-checkable protocols: + +```py +from typing import Protocol, runtime_checkable + +@runtime_checkable +class Reader[T](Protocol): + def read(self) -> T: ... + +class Concrete[T]: + def read(self) -> T: + raise NotImplementedError + +def _(value: Concrete[int]) -> None: + if isinstance(value, Reader): + reveal_type(value) # revealed: Concrete[int] + reveal_type(value.read()) # revealed: int +``` + +## Use cases: `isinstance` narrowing and generics + +### Strict mode + +```toml +[analysis] +strict-generic-narrowing = true +``` + +#### Covariance + +Narrowing from `object` via `isinstance(.., Sequence)`: + +```py +from typing import Sequence, final + +def _(xs: object): + if isinstance(xs, Sequence): + reveal_type(xs) # revealed: Sequence[object] + for x in xs: + reveal_type(x) # revealed: object + else: + reveal_type(xs) # revealed: ~Sequence[object] +``` + +Narrowing from `Item | Sequence[Item]` via `isinstance(.., Sequence)`: + +```py +@final +class Item: ... + +def _(xs: Item | Sequence[Item]): + if isinstance(xs, Sequence): + reveal_type(xs) # revealed: Sequence[Item] + for x in xs: + reveal_type(x) # revealed: Item + else: + reveal_type(xs) # revealed: Item +``` + +Narrowing from (non-final) `OpenItem | Sequence[OpenItem]` via `isinstance(.., Sequence)`: + +```py +class OpenItem: ... + +def _(xs: OpenItem | Sequence[OpenItem]): + if isinstance(xs, Sequence): + reveal_type(xs) # revealed: (OpenItem & Sequence[object]) | Sequence[OpenItem] + for x in xs: + reveal_type(x) # revealed: object + else: + reveal_type(xs) # revealed: OpenItem & ~Sequence[object] +``` + +#### Invariance + +Narrowing from `object` via `isinstance(.., list)`: + +```py +def _(xs: object): + if isinstance(xs, list): + reveal_type(xs) # revealed: Top[list[Unknown]] + for x in xs: + reveal_type(x) # revealed: object + + # This is an error in strict mode: + # error: [invalid-argument-type] "Expected `Never`, found `Literal[1]`" + xs.append(1) + + else: + reveal_type(xs) # revealed: ~Top[list[Unknown]] +``` + +Narrowing from `Item | list[Item]` via `isinstance(.., list)`: + +```py +from typing import final + +@final +class Item: ... + +def _(xs: Item | list[Item]): + if isinstance(xs, list): + reveal_type(xs) # revealed: list[Item] + for x in xs: + reveal_type(x) # revealed: Item + else: + reveal_type(xs) # revealed: Item +``` + +Narrowing from (non-final) `OpenItem | list[OpenItem]` via `isinstance(.., list)`: + +```py +class OpenItem: ... + +def _(xs: OpenItem | list[OpenItem]): + if isinstance(xs, list): + reveal_type(xs) # revealed: (OpenItem & Top[list[Unknown]]) | list[OpenItem] + for x in xs: + reveal_type(x) # revealed: object + else: + reveal_type(xs) # revealed: OpenItem & ~Top[list[Unknown]] +``` + +#### Exhaustiveness checking + +```py +def _(xs: list[str] | set[str]) -> str: + if isinstance(xs, list): + return "it's a list!" + elif isinstance(xs, set): + return "it's a set!" +``` + +### Gradual mode + +```toml +[environment] +python-version = "3.12" + +[analysis] +strict-generic-narrowing = false +``` + +#### Covariance + +Narrowing from `object` via `isinstance(.., Sequence)`: + +```py +from typing import Sequence, final + +def _(xs: object): + if isinstance(xs, Sequence): + reveal_type(xs) # revealed: Sequence[Unknown] + for x in xs: + reveal_type(x) # revealed: Unknown + else: + reveal_type(xs) # revealed: ~Sequence[object] +``` + +Narrowing from `Item | Sequence[Item]` via `isinstance(.., Sequence)`: + +```py +@final +class Item: ... + +def _(xs: Item | Sequence[Item]): + if isinstance(xs, Sequence): + reveal_type(xs) # revealed: Sequence[Item] + for x in xs: + reveal_type(x) # revealed: Item + else: + reveal_type(xs) # revealed: Item +``` + +Narrowing from (non-final) `OpenItem | Sequence[OpenItem]` via `isinstance(.., Sequence)`: + +```py +class OpenItem: ... + +def _(xs: OpenItem | Sequence[OpenItem]): + if isinstance(xs, Sequence): + reveal_type(xs) # revealed: (OpenItem & Sequence[Unknown]) | Sequence[OpenItem] + for x in xs: + reveal_type(x) # revealed: Unknown | OpenItem + else: + reveal_type(xs) # revealed: OpenItem & ~Sequence[object] +``` + +#### Invariance + +Narrowing from `object` via `isinstance(.., list)`: + +```py +def _(xs: object): + if isinstance(xs, list): + reveal_type(xs) # revealed: list[Unknown] + for x in xs: + reveal_type(x) # revealed: Unknown + + xs.append(1) + xs.append("foo") + + else: + reveal_type(xs) # revealed: ~Top[list[Unknown]] +``` + +Narrowing from `Item | list[Item]` via `isinstance(.., list)`: + +```py +from typing import final + +@final +class Item: ... + +def _(xs: Item | list[Item]): + if isinstance(xs, list): + reveal_type(xs) # revealed: list[Item] + for x in xs: + reveal_type(x) # revealed: Item + else: + reveal_type(xs) # revealed: Item +``` + +Narrowing from (non-final) `OpenItem | list[OpenItem]` via `isinstance(.., list)`: + +```py +class OpenItem: ... + +def _(xs: OpenItem | list[OpenItem]): + if isinstance(xs, list): + reveal_type(xs) # revealed: (OpenItem & list[Unknown]) | list[OpenItem] + for x in xs: + reveal_type(x) # revealed: Unknown | OpenItem + else: + reveal_type(xs) # revealed: OpenItem & ~Top[list[Unknown]] +``` + +#### Exhaustiveness checking + +```py +def _(xs: list[str] | set[str]) -> str: + if isinstance(xs, list): + return "it's a list!" + elif isinstance(xs, set): + return "it's a set!" +``` + +## Narrowing recursively bounded generics (strict mode) An `isinstance()` check must not recurse indefinitely when a generic bound refers to its own class. ```toml [environment] python-version = "3.12" + +[analysis] +strict-generic-narrowing = true ``` ```py @@ -886,6 +1329,54 @@ def narrow_mutual(value: object) -> None: reveal_type(value) # revealed: Right[object] ``` +## Narrowing recursively bounded generics (gradual mode) + +An `isinstance()` check must not recurse indefinitely when a generic bound refers to its own class. + +```toml +[environment] +python-version = "3.12" + +[analysis] +strict-generic-narrowing = false +``` + +```py +from typing import Any + +class Recursive[T: "Recursive[Any]"]: ... + +def narrow(value: object) -> None: + if isinstance(value, Recursive): + reveal_type(value) # revealed: Recursive[Unknown] +``` + +A self-referential bound must also be safe when its recursion is hidden behind a type alias. + +```py +class AliasedRecursive[T: "RecursiveAlias"]: ... + +type RecursiveAlias = AliasedRecursive[Any] + +def narrow_alias(value: object) -> None: + if isinstance(value, AliasedRecursive): + reveal_type(value) # revealed: AliasedRecursive[Unknown] +``` + +The same cycle recovery must handle bounds shared by mutually recursive generic classes. + +```py +class Left[T: "Right[Any]"]: ... +class Right[U: Left[Any]]: ... + +def narrow_mutual(value: object) -> None: + if isinstance(value, Left): + reveal_type(value) # revealed: Left[Unknown] + + if isinstance(value, Right): + reveal_type(value) # revealed: Right[Unknown] +``` + ## Narrowing generic defaults in Python 3.13 When a type parameter has a bare `Any` default, narrowing still materializes the substituted @@ -895,6 +1386,9 @@ instead), so the default value is irrelevant here: ```toml [environment] python-version = "3.13" + +[analysis] +strict-generic-narrowing = true ``` ```py @@ -947,8 +1441,9 @@ def box_with_default[T: str = str](value: Box[T] | T) -> Box[T]: assert_never(value) ``` -When `isinstance()` narrows an unknown value to a tuple subclass, its type argument comes from the -declared upper bound, not the default. Its element types are inherited from the specialized base. +When `isinstance()` narrows a value of type `object` to a tuple subclass, its type argument comes +from the declared upper bound, not the default. Its element types are inherited from the specialized +base. ```py class DefaultedTuple[T: int = bool](tuple[T, str]): ... @@ -974,6 +1469,29 @@ def excludes_defaulted_tuple(value: DefaultedTuple[Any] | bool) -> bool: return value ``` +## Narrowing bounded generic defaults in gradual mode + +In gradual mode, narrowing a value of type `object` to a tuple subclass leaves its type argument +`Unknown`. + +```toml +[environment] +python-version = "3.13" + +[analysis] +strict-generic-narrowing = false +``` + +```py +class DefaultedTuple[T: int = bool](tuple[T, str]): ... + +def narrow_defaulted_tuple(value: object) -> None: + if isinstance(value, DefaultedTuple): + reveal_type(value) # revealed: DefaultedTuple[Unknown] + reveal_type(value[0]) # revealed: Unknown + reveal_type(value[1]) # revealed: str +``` + ## Narrowing generic `classmethod` After an `isinstance(..., classmethod)` branch unwraps and replaces a generic `classmethod`, the @@ -1051,3 +1569,75 @@ def f(): reveal_type(value) # revealed: str reveal_type(result) # revealed: Literal[False] ``` + +## Preserving TypedDict interfaces when narrowing mappings + +A `TypedDict` is always a dictionary at runtime, but its static interface deliberately disallows +operations that could remove required keys or introduce undeclared ones. Narrowing to `dict`, +`Mapping`, or `MutableMapping` must not discard these restrictions. + +Use a `TypedDict` with one required key and one optional key to distinguish safe operations from +those that could invalidate its declared shape. + +```py +from typing import TypedDict, Mapping, MutableMapping +from typing_extensions import NotRequired + +class Payload(TypedDict): + key: int + optional: NotRequired[str] +``` + +Narrowing directly to `dict` preserves both the required-key restrictions and the optional key's +known type. + +```py +def narrow_typed_dict_to_dict(value: int | Payload) -> None: + if isinstance(value, dict): + reveal_type(value) # revealed: Payload + reveal_type(value["key"]) # revealed: int + value["key"] = 1 + value["optional"] = "present" + reveal_type(value.pop("optional")) # revealed: str + + # error: [unresolved-attribute] + value.clear() + # error: [invalid-argument-type] "Cannot pop required field 'key' from TypedDict `Payload`" + value.pop("key") + # error: [invalid-key] "Unknown key "unexpected" for TypedDict `Payload`" + value["unexpected"] = 1 + # error: [invalid-argument-type] "Cannot delete required key "key" from TypedDict `Payload`" + del value["key"] +``` + +Same for `MutableMapping`: + +```py +def narrow_typed_dict_to_mutable_mapping(value: Payload) -> None: + if isinstance(value, MutableMapping): + reveal_type(value) # revealed: Payload + # error: [unresolved-attribute] + value.clear() +``` + +And for `Mapping`: + +```py +def narrow_typed_dict_to_mapping(value: Payload) -> None: + if isinstance(value, Mapping): + reveal_type(value) # revealed: Payload + # error: [unresolved-attribute] + value.clear() +``` + +A type alias must retain the same `TypedDict` interface. + +```py +PayloadAlias = Payload + +def narrow_aliased_typed_dict_to_dict(value: PayloadAlias) -> None: + if isinstance(value, dict): + reveal_type(value) # revealed: Payload + # error: [unresolved-attribute] + value.clear() +``` diff --git a/crates/ty_python_semantic/resources/mdtest/narrow/issubclass.md b/crates/ty_python_semantic/resources/mdtest/narrow/issubclass.md index 3209b9fdbfef90..96591c250ae2a4 100644 --- a/crates/ty_python_semantic/resources/mdtest/narrow/issubclass.md +++ b/crates/ty_python_semantic/resources/mdtest/narrow/issubclass.md @@ -280,6 +280,63 @@ def f(x: type[int | str | bytes | range]): reveal_type(x) # revealed: ``` +## Narrowing with generic classes + +### Strict mode + +```toml +[analysis] +strict-generic-narrowing = true +``` + +Without a known specialization, narrowing to a generic class uses the top materialization: + +```py +def _(cls: type) -> None: + if issubclass(cls, list): + reveal_type(cls) # revealed: type[Top[list[Unknown]]] + reveal_type(cls()) # revealed: Top[list[Unknown]] +``` + +When narrowing from a generic superclass to a generic subclass, we intersect with the top +materialization of the subclass: + +```py +from typing import Sequence + +def narrow_sequence_to_list(cls: type[Sequence[int]]) -> None: + if issubclass(cls, list): + reveal_type(cls) # revealed: type[Sequence[int]] & type[Top[list[Unknown]]] + reveal_type(cls()) # revealed: Sequence[int] & Top[list[Unknown]] +``` + +### Gradual mode + +```toml +[analysis] +strict-generic-narrowing = false +``` + +Without a known specialization, narrowing to a generic class leaves its type argument unknown. + +```py +def _(cls: type) -> None: + if issubclass(cls, list): + reveal_type(cls) # revealed: type[list[Unknown]] + reveal_type(cls()) # revealed: list[Unknown] +``` + +Narrowing to a generic subclass preserves the specialized base class's type argument. + +```py +from typing import Sequence + +def _(cls: type[Sequence[int]]) -> None: + if issubclass(cls, list): + reveal_type(cls) # revealed: type[list[int]] + reveal_type(cls()) # revealed: list[int] +``` + ## `classinfo` is a generic final class ```toml diff --git a/crates/ty_python_semantic/resources/mdtest/narrow/match.md b/crates/ty_python_semantic/resources/mdtest/narrow/match.md index 0d266c2a34f4bc..ef3eb87bbb0554 100644 --- a/crates/ty_python_semantic/resources/mdtest/narrow/match.md +++ b/crates/ty_python_semantic/resources/mdtest/narrow/match.md @@ -91,9 +91,16 @@ def exhaustive_pattern_with_guard(x: A, flag: bool) -> None: ## Class patterns with generic classes +### Gradual mode + +Generic class patterns follow the same gradual filtering as `isinstance()` checks. + ```toml [environment] python-version = "3.12" + +[analysis] +strict-generic-narrowing = false ``` ```py @@ -112,6 +119,44 @@ def f(x: Covariant[int]): assert_never(x) ``` +A `list()` pattern preserves the type argument inherited from a specialized `Sequence`. + +```py +from typing import Sequence + +def narrow_sequence_to_list(value: Sequence[int]) -> None: + match value: + case list(): + reveal_type(value) # revealed: list[int] + case _: + reveal_type(value) # revealed: Sequence[int] & ~Top[list[Unknown]] +``` + +### Strict mode + +With strict generic narrowing enabled, class patterns retain their top materializations. + +```toml +[environment] +python-version = "3.12" + +[analysis] +strict-generic-narrowing = true +``` + +A `list()` pattern retains the original `Sequence` alongside the top-materialized list. + +```py +from typing import Sequence + +def narrow_sequence_to_list(value: Sequence[int]) -> None: + match value: + case list(): + reveal_type(value) # revealed: Sequence[int] & Top[list[Unknown]] + case _: + reveal_type(value) # revealed: Sequence[int] & ~Top[list[Unknown]] +``` + ## Generic patterns ignore type parameter defaults A generic class pattern matches every runtime specialization, not only the specialization described @@ -120,6 +165,9 @@ by its type parameter's default. ```toml [environment] python-version = "3.13" + +[analysis] +strict-generic-narrowing = true ``` ```py @@ -203,7 +251,7 @@ from typing import Any def test_isinstance(x: dict[Any, Any] | int) -> None: if isinstance(x, Mapping): - reveal_type(x) # revealed: dict[Any, Any] | (int & Top[Mapping[Unknown, object]]) + reveal_type(x) # revealed: dict[Any, Any] | (int & Mapping[Unknown, Unknown]) else: reveal_type(x) # revealed: int & ~Top[Mapping[Unknown, object]] @@ -976,12 +1024,176 @@ def test_incompatible_declared_class_capture(value: PatternBox[int]) -> None: ## Generic subclass captures -When a generic pattern class inherits from the subject's class through an invariant base, the -subject specialization determines the pattern class's type arguments. This applies to annotated -attributes and properties. Every pattern-class type parameter must have an exact solution; variant -bases and unconstrained parameters retain the existing conservative fallback. When the subject does -not provide type arguments, members declared by the pattern class use `Unknown`; a type parameter -default does not restrict which instances match at runtime. +### Gradual mode + +When a generic pattern class inherits from the subject's class, the subject specialization +determines any inferable pattern-class type arguments. This applies to annotated attributes and +properties, including classes with unconstrained type parameters or variant bases. When the subject +does not provide type arguments, members declared by the pattern class use `Unknown`; a type +parameter default does not restrict which instances match at runtime. + +```toml +[analysis] +strict-generic-narrowing = false +``` + +```py +from typing import final, Generic +from typing_extensions import TypeVar + +GenericPatternT = TypeVar("GenericPatternT") +ExtraGenericPatternT = TypeVar("ExtraGenericPatternT") +CovariantGenericPatternT = TypeVar("CovariantGenericPatternT", covariant=True) +DefaultGenericPatternT = TypeVar("DefaultGenericPatternT", default=str) + +class GenericPatternBase(Generic[GenericPatternT]): ... + +OptionalGenericPatternT = TypeVar( + "OptionalGenericPatternT", + bound=GenericPatternBase[int] | None, +) +UnionBoundGenericPatternT = TypeVar( + "UnionBoundGenericPatternT", + bound=GenericPatternBase[int] | GenericPatternBase[str], +) + +class GenericPatternChild(GenericPatternBase[GenericPatternT]): + item: GenericPatternT + items: list[GenericPatternT] + +class PartiallySpecializedGenericPatternChild( + GenericPatternBase[GenericPatternT], + Generic[GenericPatternT, ExtraGenericPatternT], +): + item: GenericPatternT + +class CovariantGenericPatternBase(Generic[CovariantGenericPatternT]): ... + +class CovariantGenericPatternChild(CovariantGenericPatternBase[CovariantGenericPatternT]): + item: CovariantGenericPatternT + +class GenericMemberBase(Generic[GenericPatternT]): + item: GenericPatternT + +class GenericMemberChild(GenericMemberBase[GenericPatternT]): ... +class IntGenericMemberChild(GenericMemberBase[int]): ... + +@final +class FinalGenericPatternBox(Generic[GenericPatternT]): + value: list[GenericPatternT] + +class DefaultGenericPatternBox(Generic[DefaultGenericPatternT]): + value: DefaultGenericPatternT + +ResultValueT = TypeVar("ResultValueT") +ResultErrorT = TypeVar("ResultErrorT") + +class MatchResult(Generic[ResultValueT, ResultErrorT]): ... + +class MatchOk(MatchResult[ResultValueT, ResultErrorT]): + __match_args__ = ("value",) + + @property + def value(self) -> ResultValueT: + raise NotImplementedError + +class MatchErr(MatchResult[ResultValueT, ResultErrorT]): + __match_args__ = ("error",) + + @property + def error(self) -> ResultErrorT: + raise NotImplementedError + +def test_match_generic_subclass_property_capture( + result: MatchResult[int, str], +) -> int: + match result: + case MatchOk(value): + reveal_type(value) # revealed: int + return value + case MatchErr(error): + reveal_type(error) # revealed: str + raise ValueError(error) + raise AssertionError + +def test_match_generic_subclass_capture(value: GenericPatternBase[int]) -> None: + match value: + case GenericPatternChild(item=item): + reveal_type(item) # revealed: int + +def test_match_generic_subclass_capture_from_optional_typevar_bound( + value: OptionalGenericPatternT, +) -> None: + match value: + case GenericPatternChild(item=item): + reveal_type(item) # revealed: int + +def test_match_generic_subclass_capture_from_union_typevar_bound( + value: UnionBoundGenericPatternT, +) -> None: + match value: + case GenericPatternChild(item=item): + reveal_type(item) # revealed: int | str + +def test_match_nested_generic_subclass_capture(value: GenericPatternBase[int]) -> list[int]: + match value: + case GenericPatternChild(items=items): + reveal_type(items) # revealed: list[int] + return items + return [] + +def test_match_partially_specialized_generic_subclass( + value: GenericPatternBase[int], +) -> None: + match value: + case PartiallySpecializedGenericPatternChild(item=item): + reveal_type(item) # revealed: int + +def test_match_covariant_generic_subclass( + value: CovariantGenericPatternBase[int], +) -> None: + match value: + case CovariantGenericPatternChild(item=item): + reveal_type(item) # revealed: int + +def test_match_inherited_generic_subclass_capture( + value: GenericMemberBase[GenericPatternT], +) -> GenericPatternT: + match value: + case GenericMemberChild(item=item): + # revealed: GenericPatternT@test_match_inherited_generic_subclass_capture + reveal_type(item) + return item + case _: + raise ValueError + +def test_match_generic_base_capture_preserves_subject_specialization( + value: IntGenericMemberChild, +) -> None: + match value: + case GenericMemberBase(item=item): + reveal_type(item) # revealed: int + +def test_match_direct_generic_pattern_preserves_declared_member(value: object) -> None: + match value: + case FinalGenericPatternBox(value=int() as item): + reveal_type(item) # revealed: Never + +def test_match_generic_pattern_ignores_typevar_default(value: object) -> None: + match value: + case DefaultGenericPatternBox(value=int() as item): + reveal_type(item) # revealed: Unknown & int +``` + +### Strict mode + +An invariant generic base determines its subclass's type arguments only when every argument has one +exact solution. Unconstrained arguments and variant bases retain conservative member types. + +```toml +[analysis] +strict-generic-narrowing = true +``` ```py from typing import final, Generic @@ -1093,8 +1305,6 @@ def test_match_partially_specialized_generic_subclass( ) -> None: match value: case PartiallySpecializedGenericPatternChild(item=item): - # `ExtraGenericPatternT` is not constrained by the subject, so the pattern class does - # not have one exact specialization. reveal_type(item) # revealed: Unknown def test_match_covariant_generic_subclass( @@ -1102,7 +1312,6 @@ def test_match_covariant_generic_subclass( ) -> None: match value: case CovariantGenericPatternChild(item=item): - # The subject constrains only one end of the possible pattern-class specializations. reveal_type(item) # revealed: Unknown def test_match_inherited_generic_subclass_capture( @@ -1338,7 +1547,8 @@ Two unrelated non-final classes can have a common subclass through multiple inhe successful pattern therefore preserves both class types. Attributes defined on both classes use the intersection of their declared types, consistent with ordinary attribute access on an intersection. For a generic pattern class whose type arguments are not known from the subject, its attributes use -`Unknown`. +`Unknown`. Iterating over a generic attribute likewise produces an unknown element type in gradual +mode. ```py from typing import Generic, TypeVar @@ -1434,7 +1644,7 @@ def test_match_generic_container_member_keeps_loop_reachable( match value: case GenericListOverlapB(values=items): for item in items: - reveal_type(item) # revealed: object + reveal_type(item) # revealed: Unknown ``` ## Class pattern captures from `Any` and `Unknown` diff --git a/crates/ty_python_semantic/resources/mdtest/typed_dict.md b/crates/ty_python_semantic/resources/mdtest/typed_dict.md index 5c1eac5632eaa4..8f6feb0077ea84 100644 --- a/crates/ty_python_semantic/resources/mdtest/typed_dict.md +++ b/crates/ty_python_semantic/resources/mdtest/typed_dict.md @@ -2644,8 +2644,8 @@ Item = A | B | C | D | E | F | G | H | I | J | K | L | M | N | O | P | Q | R | S def _(item: Item) -> None: reveal_type(dict(item)) # revealed: dict[str, object] -# Runtime narrowing retains a `Top[dict[Unknown, Unknown]]` intersection around each `TypedDict`. -# Those intersections should still reuse the common protocol constraints of the union. +# Runtime narrowing preserves each `TypedDict` schema without exposing unrestricted dictionary +# operations. The union should still reuse its common protocol constraints. # Regression test for https://github.com/astral-sh/ty/issues/3974. def _(item: Item | str) -> None: if isinstance(item, dict): diff --git a/crates/ty_python_semantic/src/lib.rs b/crates/ty_python_semantic/src/lib.rs index 103be21c14214e..68d8346dba0cb9 100644 --- a/crates/ty_python_semantic/src/lib.rs +++ b/crates/ty_python_semantic/src/lib.rs @@ -93,6 +93,9 @@ fn register_lints(registry: &mut LintRegistryBuilder) { #[derive(Debug, Clone, PartialEq, Eq, get_size2::GetSize)] pub struct AnalysisSettings { + /// Whether narrowing with generic classes uses the top materialization. + pub strict_generic_narrowing: bool, + /// Whether ty should use conservative equality and inequality semantics. pub strict_equality_semantics: bool, @@ -113,6 +116,7 @@ pub struct AnalysisSettings { impl Default for AnalysisSettings { fn default() -> Self { Self { + strict_generic_narrowing: false, strict_equality_semantics: false, respect_type_ignore_comments: true, allowed_unresolved_imports: ModuleGlobSet::empty(), diff --git a/crates/ty_python_semantic/src/types/match_pattern.rs b/crates/ty_python_semantic/src/types/match_pattern.rs index 0ee2c3b1fd3d06..4c8db1f59f839e 100644 --- a/crates/ty_python_semantic/src/types/match_pattern.rs +++ b/crates/ty_python_semantic/src/types/match_pattern.rs @@ -109,7 +109,11 @@ fn typed_dict_pattern_domain_satisfies<'db>( } /// Return whether every value in `ty` is represented by a `TypedDict` schema at runtime. -fn is_typed_dict_pattern_domain(db: &dyn Db, env: &ProgramEnvironment<'_>, ty: Type<'_>) -> bool { +pub(super) fn is_typed_dict_runtime_domain( + db: &dyn Db, + env: &ProgramEnvironment<'_>, + ty: Type<'_>, +) -> bool { typed_dict_pattern_domain_satisfies(db, env, ty, &|_| true) } @@ -273,7 +277,7 @@ fn class_pattern_is_exhaustive( kind: &ClassPatternPredicateKind<'_>, ) -> bool { let class_instance_ty = Type::instance(db, env, class.top_materialization(db)); - let is_typed_dict_match = is_typed_dict_pattern_domain(db, env, subject_ty) + let is_typed_dict_match = is_typed_dict_runtime_domain(db, env, subject_ty) && typed_dict_matches_class_pattern(db, env, class); if !is_typed_dict_match && !subject_ty.is_subtype_of(db, env, class_instance_ty) { return false; diff --git a/crates/ty_python_semantic/src/types/narrow.rs b/crates/ty_python_semantic/src/types/narrow.rs index bbecb0734620ff..f65abfc4283d77 100644 --- a/crates/ty_python_semantic/src/types/narrow.rs +++ b/crates/ty_python_semantic/src/types/narrow.rs @@ -43,6 +43,7 @@ use super::equality::{ ComparisonSoundnessPolicy, equality_exclusion_constraint, equality_truthiness, evaluate_type_equality, evaluate_type_inequality, }; +use super::match_pattern::is_typed_dict_runtime_domain; use super::variance::TypeVarVariance; use itertools::Itertools; use ruff_python_ast as ast; @@ -459,20 +460,32 @@ impl ClassInfoConstraintFunction { env: &ProgramEnvironment<'db>, classinfo: Type<'db>, is_positive: bool, + use_generic_filtering: bool, ) -> Option> { - let constraint_from_class_literal = |class: ClassLiteral<'db>| match self { - ClassInfoConstraintFunction::IsInstance => { - Type::instance(db, env, class.top_materialization(db)) - } - ClassInfoConstraintFunction::IsSubclass => { - SubclassOfType::from(db, env, class.top_materialization(db)) + let constraint_from_class_literal = |class: ClassLiteral<'db>| { + let specialization = if use_generic_filtering { + class.unknown_specialization(db) + } else { + // A negative result excludes every specialization of the class. + class.top_materialization(db) + }; + + match self { + ClassInfoConstraintFunction::IsInstance => Type::instance(db, env, specialization), + ClassInfoConstraintFunction::IsSubclass => { + SubclassOfType::from(db, env, specialization) + } } }; match classinfo { - Type::TypeAlias(alias) => { - self.generate_constraint(db, env, alias.value_type(db), is_positive) - } + Type::TypeAlias(alias) => self.generate_constraint( + db, + env, + alias.value_type(db), + is_positive, + use_generic_filtering, + ), Type::ClassLiteral(class_literal) => Some(constraint_from_class_literal(class_literal)), Type::SubclassOf(subclass_of_ty) => { // We can't narrow negatively from a `SubclassOf` type. `if !isinstance(x, y)` @@ -520,7 +533,13 @@ impl ClassInfoConstraintFunction { // target) should be SKIPPED, not abort narrowing on the // whole intersection. Narrowing on the remaining members // is still sound. - if let Some(c) = self.generate_constraint(db, env, *element, is_positive) { + if let Some(c) = self.generate_constraint( + db, + env, + *element, + is_positive, + use_generic_filtering, + ) { builder.add_positive_in_place(c); any_member = true; } @@ -536,16 +555,21 @@ impl ClassInfoConstraintFunction { } } Type::Union(union) => union.try_map(db, env, |element| { - self.generate_constraint(db, env, *element, is_positive) + self.generate_constraint(db, env, *element, is_positive, use_generic_filtering) }), Type::TypeVar(bound_typevar) => { match bound_typevar.typevar(db).bound_or_constraints(db, env)? { TypeVarBoundOrConstraints::UpperBound(bound) => { - self.generate_constraint(db, env, bound, is_positive) - } - TypeVarBoundOrConstraints::Constraints(constraints) => { - self.generate_constraint(db, env, constraints.as_type(db, env), is_positive) + self.generate_constraint(db, env, bound, is_positive, use_generic_filtering) } + TypeVarBoundOrConstraints::Constraints(constraints) => self + .generate_constraint( + db, + env, + constraints.as_type(db, env), + is_positive, + use_generic_filtering, + ), } } @@ -557,9 +581,15 @@ impl ClassInfoConstraintFunction { UnionType::try_from_elements( db, env, - tuple - .iter_element_types(db) - .map(|element| self.generate_constraint(db, env, element, is_positive)), + tuple.iter_element_types(db).map(|element| { + self.generate_constraint( + db, + env, + element, + is_positive, + use_generic_filtering, + ) + }), ) } @@ -581,9 +611,16 @@ impl ClassInfoConstraintFunction { env, KnownClass::NoneType.to_class_literal(db, env), is_positive, + use_generic_filtering, ) } else { - self.generate_constraint(db, env, element, is_positive) + self.generate_constraint( + db, + env, + element, + is_positive, + use_generic_filtering, + ) } }), ) @@ -595,25 +632,31 @@ impl ClassInfoConstraintFunction { env, alias.aliased_class().to_class_literal(db, env), is_positive, + use_generic_filtering, ), SpecialFormType::Tuple => self.generate_constraint( db, env, KnownClass::Tuple.to_class_literal(db, env), is_positive, + use_generic_filtering, ), SpecialFormType::Type => self.generate_constraint( db, env, KnownClass::Type.to_class_literal(db, env), is_positive, + use_generic_filtering, ), - // We don't have a good meta-type for `Callable`s right now, // so only apply `isinstance()` narrowing, not `issubclass()` SpecialFormType::TypingCallable | SpecialFormType::CollectionsAbcCallable => { (self == ClassInfoConstraintFunction::IsInstance).then(|| { - Type::Callable(CallableType::unknown(db)).top_materialization(db, env) + if use_generic_filtering { + Type::Callable(CallableType::unknown(db)) + } else { + callable_pattern_type(db, env) + } }) } @@ -652,20 +695,50 @@ impl ClassInfoConstraintFunction { } } +#[derive(Hash, PartialEq, Debug, Eq, Clone, Copy, get_size2::GetSize, salsa::SalsaValue)] +enum NarrowingOperation<'db> { + /// Narrow the subject by intersecting it directly with this type. + Intersection(Type<'db>), + /// Narrow to this generic type while preserving type arguments already known about the subject. + GenericFiltering(Type<'db>), +} + +impl<'db> NarrowingOperation<'db> { + const fn ty(self) -> Type<'db> { + match self { + Self::Intersection(ty) | Self::GenericFiltering(ty) => ty, + } + } +} + #[derive(Hash, PartialEq, Debug, Eq, Clone, get_size2::GetSize, salsa::SalsaValue)] struct Conjunctions<'db> { - conjuncts: SmallVec<[Type<'db>; 2]>, + conjuncts: SmallVec<[NarrowingOperation<'db>; 2]>, } impl<'db> Conjunctions<'db> { fn singleton(ty: Type<'db>) -> Self { Self { - conjuncts: smallvec![ty], + conjuncts: smallvec![NarrowingOperation::Intersection(ty)], + } + } + + fn generic_filtering(ty: Type<'db>) -> Self { + Self { + conjuncts: smallvec![NarrowingOperation::GenericFiltering(ty)], } } fn and_with(mut self, other: Self) -> Self { - if self.conjuncts.iter().any(Type::is_never) || other.conjuncts.iter().any(Type::is_never) { + if self + .conjuncts + .iter() + .any(|conjunct| conjunct.ty().is_never()) + || other + .conjuncts + .iter() + .any(|conjunct| conjunct.ty().is_never()) + { return Self::singleton(Type::Never); } @@ -679,18 +752,291 @@ impl<'db> Conjunctions<'db> { fn evaluate_constraint_type(self, db: &'db dyn Db, env: &ProgramEnvironment<'db>) -> Type<'db> { if self.conjuncts.len() == 1 { - return self.conjuncts[0]; + return self.conjuncts[0].ty(); } // Collapse shared union arms before distributing the next constraint over them. self.conjuncts .into_iter() - .fold(Type::object(), |accumulated, conjunct| { - IntersectionType::from_two_elements(db, env, accumulated, conjunct) + .fold(Type::object(), |accumulated, conjunct| match conjunct { + NarrowingOperation::Intersection(ty) => { + IntersectionType::from_two_elements(db, env, accumulated, ty) + } + NarrowingOperation::GenericFiltering(ty) => { + filter_generic_narrowing_constraint(db, env, accumulated, ty) + } }) } } +/// Preserve known generic arguments when narrowing a specialized base to one of its subclasses. +/// +/// For example, filtering `Sequence[int]` with `list[Unknown]` first infers `list[int]` from +/// the target class's specialized `Sequence` base. Unrelated union arms and intersection elements +/// are still intersected with the original unknown-specialized target. +fn filter_generic_narrowing_constraint<'db>( + db: &'db dyn Db, + env: &ProgramEnvironment<'db>, + subject: Type<'db>, + target: Type<'db>, +) -> Type<'db> { + match (subject, target) { + (Type::Union(union), target) => union.map(db, env, |element| { + filter_generic_narrowing_constraint(db, env, *element, target) + }), + (subject, Type::Union(union)) => union.map(db, env, |element| { + filter_generic_narrowing_constraint(db, env, subject, *element) + }), + (subject @ Type::Callable(_), Type::Callable(_)) => subject, + (subject, target) + if is_typed_dict_runtime_domain(db, env, subject) + && target.nominal_class(db, env).is_some_and(|class| { + !class.is_protocol(db) + && typed_dict_matches_class_pattern(db, env, class.class_literal(db)) + }) => + { + // A TypedDict is a dictionary at runtime, but intersecting it with the target would + // expose dict's unrestricted mutations and discard its required-key guarantees. + subject + } + (Type::Intersection(intersection), target) => { + let specialized_target = + specialize_narrowing_target_from_intersection(db, env, intersection, target) + .or_else(|| { + intersection.positive(db).iter().find_map(|element| { + specialize_narrowing_target(db, env, *element, target) + }) + }) + .unwrap_or(target); + IntersectionType::from_two_elements(db, env, subject, specialized_target) + } + (subject, target) => { + let specialized_target = + specialize_narrowing_target(db, env, subject, target).unwrap_or(target); + IntersectionType::from_two_elements(db, env, subject, specialized_target) + } + } +} + +/// Combine the constraints contributed by multiple specialized bases in an intersection. +/// +/// For example, if `Both[L, R]` inherits from `Left[L]` and `Right[R]`, narrowing +/// `Left[int] & Right[str]` to `Both` must infer `Both[int, str]`. +fn specialize_narrowing_target_from_intersection<'db>( + db: &'db dyn Db, + env: &ProgramEnvironment<'db>, + intersection: IntersectionType<'db>, + target: Type<'db>, +) -> Option> { + let target_class = target.nominal_class(db, env)?.class_literal(db); + let generic_context = target_class.generic_context(db)?; + let target_identity = target_class.identity_specialization(db); + + let compatible_bases: SmallVec<[(ClassType<'db>, ClassType<'db>); 2]> = intersection + .positive(db) + .iter() + .filter_map(|element| { + let subject_class = element.nominal_class(db, env)?; + subject_class.static_class_literal(db)?.1?; + let target_base = target_identity + .iter_mro(db) + .filter_map(ClassBase::into_class) + .find(|base| base.class_literal(db) == subject_class.class_literal(db))?; + Some((target_base, subject_class)) + }) + .collect(); + + if compatible_bases.len() < 2 { + return None; + } + + let constraints = ConstraintSetBuilder::new(); + let mut base_constraints = compatible_bases + .into_iter() + .map(|(target_base, subject_class)| { + Type::instance(db, env, target_base).when_constraint_set_assignable_to( + db, + env, + Type::instance(db, env, subject_class), + &constraints, + ) + }); + let mut combined_constraints = base_constraints.next()?; + for base_constraint in base_constraints { + combined_constraints.intersect(db, &constraints, base_constraint); + } + + let solutions = combined_constraints.solutions( + db, + env, + &constraints, + generic_context.inferable_typevars(db), + ); + let specialized_class = + specialize_generic_class_from_solutions(db, env, target_class, solutions)?; + Some(Type::instance(db, env, specialized_class)) +} + +fn specialize_narrowing_target<'db>( + db: &'db dyn Db, + env: &ProgramEnvironment<'db>, + subject: Type<'db>, + target: Type<'db>, +) -> Option> { + if let Type::TypeVar(typevar) = subject { + let bound = match typevar.typevar(db).bound_or_constraints(db, env)? { + TypeVarBoundOrConstraints::UpperBound(bound) => bound, + TypeVarBoundOrConstraints::Constraints(constraints) => constraints.as_type(db, env), + }; + + return match bound { + Type::Union(union) => { + let mut candidates = UnionBuilder::new(db, env); + for element in union.elements(db) { + if let Some(specialized) = + specialize_narrowing_target(db, env, *element, target) + { + candidates.add_in_place(specialized); + } + } + (!candidates.is_empty()).then(|| candidates.build()) + } + Type::Intersection(intersection) => intersection + .positive(db) + .iter() + .find_map(|element| specialize_narrowing_target(db, env, *element, target)), + bound if bound != subject => specialize_narrowing_target(db, env, bound, target), + _ => None, + }; + } + + let (target_class, subject_class, is_subclass) = match target { + Type::SubclassOf(target) => { + let SubclassOfInner::Class(target_class) = target.subclass_of() else { + return None; + }; + let Type::SubclassOf(subject) = subject else { + return None; + }; + let SubclassOfInner::Class(subject_class) = subject.subclass_of() else { + return None; + }; + (target_class, subject_class, true) + } + _ => ( + target.nominal_class(db, env)?, + subject.nominal_class(db, env)?, + false, + ), + }; + + // An unspecialized class cannot contribute type arguments to the narrowing target. + subject_class.static_class_literal(db)?.1?; + + let target_class = + if subject_class.is_subtype_of_class_literal(db, target_class.class_literal(db)) { + subject_class + } else { + specialize_generic_class_for_subject( + db, + env, + target_class.class_literal(db), + subject_class, + )? + }; + + Some(if is_subclass { + SubclassOfType::from(db, env, target_class) + } else { + Type::instance(db, env, target_class) + }) +} + +/// Infer a generic subclass specialization from a specialized base class. +/// +/// For example, if `target_class` is `list` and `subject_class` is `Sequence[int]`, +/// this returns the specialized class `list[int]`. +fn specialize_generic_class_for_subject<'db>( + db: &'db dyn Db, + env: &ProgramEnvironment<'db>, + target_class: ClassLiteral<'db>, + subject_class: ClassType<'db>, +) -> Option> { + let generic_context = target_class.generic_context(db)?; + let target_identity = target_class.identity_specialization(db); + let target_base = target_identity + .iter_mro(db) + .filter_map(ClassBase::into_class) + .find(|base| base.class_literal(db) == subject_class.class_literal(db)); + + let (source, target) = if let Some(target_base) = target_base { + (target_base, subject_class) + } else if target_class.is_protocol(db) { + (subject_class, target_identity) + } else if subject_class.is_protocol(db) { + (target_identity, subject_class) + } else { + return None; + }; + + let constraints = ConstraintSetBuilder::new(); + let solutions = Type::instance(db, env, source) + .assignable_solutions_with_inferable( + db, + env, + Type::instance(db, env, target), + generic_context.inferable_typevars(db), + ) + .solve(db, env, &constraints); + + specialize_generic_class_from_solutions(db, env, target_class, solutions) +} + +fn specialize_generic_class_from_solutions<'db>( + db: &'db dyn Db, + env: &ProgramEnvironment<'db>, + target_class: ClassLiteral<'db>, + solutions: Solutions<'db>, +) -> Option> { + let generic_context = target_class.generic_context(db)?; + let Solutions::Constrained(solutions) = solutions else { + return None; + }; + let [solution] = solutions.as_slice() else { + return None; + }; + + let typevars = generic_context.variables(db); + let unknown_specialization = generic_context.unknown_specialization(db, target_class.known(db)); + let types = typevars + .clone() + .map(|typevar| { + solution + .iter() + .find(|binding| binding.bound_typevar == typevar) + .map(|binding| binding.solution) + .or_else(|| unknown_specialization.get(db, typevar)) + }) + .collect::>>()?; + if types.iter().any(|ty| { + typevars + .clone() + .any(|typevar| ty.references_typevar(db, env, typevar.typevar(db).identity(db))) + }) { + return None; + } + + let specialization = if target_class.is_known(db, KnownClass::Tuple) + && let [element] = types.as_slice() + { + generic_context.specialize_tuple(db, *element, TupleType::homogeneous(db, env, *element)) + } else { + generic_context.specialize(db, types) + }; + + Some(target_class.apply_specialization(db, |_| specialization)) +} + /// Represents narrowing constraints in Disjunctive Normal Form (DNF). /// /// This is a disjunction (OR) of conjunctions (AND) of constraints. @@ -736,6 +1082,15 @@ impl<'db> NarrowingConstraint<'db> { } } + /// Create an intersection constraint that preserves generic arguments already known about + /// the subject when narrowing it to a subclass. + fn generic_filtering(constraint: Type<'db>) -> Self { + Self { + intersection_disjuncts: smallvec_inline![Conjunctions::generic_filtering(constraint)], + replacement_disjuncts: smallvec![], + } + } + /// Create a "replacement" constraint: the previous type will be /// replaced wholesale with this constraint fn replacement(constraint: Type<'db>) -> Self { @@ -971,10 +1326,15 @@ fn positive_class_pattern_type<'db>( db: &'db dyn Db, env: &ProgramEnvironment<'db>, class_expression_ty: Type<'db>, + use_generic_filtering: bool, ) -> Option> { match class_expression_ty { Type::SpecialForm(SpecialFormType::CollectionsAbcCallable) => { - Some(callable_pattern_type(db, env)) + Some(if use_generic_filtering { + Type::Callable(CallableType::unknown(db)) + } else { + callable_pattern_type(db, env) + }) } _ if class_expression_ty.is_assignable_to( db, @@ -987,6 +1347,7 @@ fn positive_class_pattern_type<'db>( env, class_expression_ty, true, + use_generic_filtering, ) } _ => None, @@ -1056,6 +1417,7 @@ fn necessary_match_pattern_type<'db>( db, env, infer_same_file_expression_type(db, kind.class, TypeContext::default()), + false, ) .unwrap_or_else(Type::object), PatternPredicateKind::Mapping(_) => mapping_pattern_type(db, env), @@ -1467,6 +1829,12 @@ impl<'db> PatternSuccessAnalyzer<'db> { ComparisonSoundnessPolicy::from_analysis_settings(db.analysis_settings(self.scope.file(db))) } + fn use_generic_filtering(&self) -> bool { + let db = self.db; + !db.analysis_settings(self.scope.file(db)) + .strict_generic_narrowing + } + fn merge_binding( bindings: &mut BTreeMap>, place: ScopedPlaceId, @@ -1762,6 +2130,13 @@ impl<'db> PatternSuccessAnalyzer<'db> { subject_ty: Type<'db>, ) -> Type<'db> { let db = self.db; + let intersect = |subject_ty| { + if self.use_generic_filtering() { + filter_generic_narrowing_constraint(db, &self.env, subject_ty, class_ty) + } else { + self.intersect_types(subject_ty, class_ty) + } + }; match subject_ty { Type::TypeAlias(alias) => { self.filter_class_pattern_subject_type(class, class_ty, alias.value_type(db)) @@ -1770,7 +2145,7 @@ impl<'db> PatternSuccessAnalyzer<'db> { self.filter_class_pattern_subject_type(class, class_ty, *element) }), Type::Intersection(intersection) if intersection.positive(db).is_empty() => { - self.intersect_types(subject_ty, class_ty) + intersect(subject_ty) } Type::Intersection(intersection) => { intersection.map_positive(db, &self.env, |positive| { @@ -1779,7 +2154,7 @@ impl<'db> PatternSuccessAnalyzer<'db> { } Type::NominalInstance(instance) => { let Some(class) = class else { - return self.intersect_types(subject_ty, class_ty); + return intersect(subject_ty); }; let subject_class = instance.class(db, &self.env); if subject_class.is_subtype_of_class_literal(db, class) { @@ -1787,7 +2162,7 @@ impl<'db> PatternSuccessAnalyzer<'db> { } else if subject_ty.is_disjoint_from(db, &self.env, class_ty) { Type::Never } else { - self.intersect_types(subject_ty, class_ty) + intersect(subject_ty) } } Type::TypedDict(_) @@ -1797,9 +2172,10 @@ impl<'db> PatternSuccessAnalyzer<'db> { { subject_ty } + Type::Callable(_) if matches!(class_ty, Type::Callable(_)) => subject_ty, _ if subject_ty.is_subtype_of(db, &self.env, class_ty) => subject_ty, _ if subject_ty.is_disjoint_from(db, &self.env, class_ty) => Type::Never, - _ => self.intersect_types(subject_ty, class_ty), + _ => intersect(subject_ty), } } @@ -1815,17 +2191,38 @@ impl<'db> PatternSuccessAnalyzer<'db> { let subject_is_final = subject_ty .nominal_class(db, &self.env) .is_some_and(|class| class.is_final(db)); - let specialized_pattern_class = - if context.positional_sources.is_empty() && kind.keywords.is_empty() { - None - } else { - context - .class - .zip(filtering_subject_ty.nominal_class(db, &self.env)) - .and_then(|(pattern_class, subject_class)| { - self.specialize_pattern_class_for_subject(pattern_class, subject_class) - }) - }; + let specialized_pattern_class = if context.positional_sources.is_empty() + && kind.keywords.is_empty() + { + None + } else if self.use_generic_filtering() { + context + .class + .filter(|pattern_class| pattern_class.generic_context(db).is_some()) + .and_then(|pattern_class| { + subject_ty + .nominal_class(db, &self.env) + .filter(|subject_class| subject_class.class_literal(db) == pattern_class) + .or_else(|| { + if let Type::Intersection(intersection) = subject_ty { + intersection.positive(db).iter().find_map(|element| { + element + .nominal_class(db, &self.env) + .filter(|class| class.class_literal(db) == pattern_class) + }) + } else { + None + } + }) + }) + } else { + context + .class + .zip(filtering_subject_ty.nominal_class(db, &self.env)) + .and_then(|(pattern_class, subject_class)| { + self.specialize_pattern_class_for_subject(pattern_class, subject_class) + }) + }; let member_type = |name: &Name| { let original_member_ty = original_subject_ty .member(db, &self.env, name.as_str()) @@ -1834,11 +2231,15 @@ impl<'db> PatternSuccessAnalyzer<'db> { let place = subject_ty.member(db, &self.env, name.as_str()).place; let mut member_ty = place.ignore_possibly_undefined(); - if let Some(specialized_pattern_class) = specialized_pattern_class { - member_ty = Type::instance(db, &self.env, specialized_pattern_class) - .member(db, &self.env, name.as_str()) - .place - .ignore_possibly_undefined(); + if let Some(specialized_pattern_class) = specialized_pattern_class + && let Some(specialized_member_ty) = + Type::instance(db, &self.env, specialized_pattern_class) + .member(db, &self.env, name.as_str()) + .place + .ignore_possibly_undefined() + && !specialized_member_ty.is_unknown() + { + member_ty = Some(specialized_member_ty); } else if let Some(pattern_class) = context.class && pattern_class .generic_context(db) @@ -1933,7 +2334,8 @@ impl<'db> PatternSuccessAnalyzer<'db> { /// the existing conservative member type. /// /// ```python - /// class Base[T]: ... + /// class Base[T]: + /// value: T /// /// class Child[T](Base[T]): /// item: T @@ -2009,12 +2411,18 @@ impl<'db> PatternSuccessAnalyzer<'db> { let db = self.db; let class_expr_ty = infer_same_file_expression_type(db, kind.class, TypeContext::default()) .resolve_type_alias(db); + let use_generic_filtering = self.use_generic_filtering(); let context = |class_expr_ty: Type<'db>| { let class = class_expr_ty.as_class_literal(); ClassPatternContext { class, - class_ty: positive_class_pattern_type(db, &self.env, class_expr_ty) - .unwrap_or_else(Type::object), + class_ty: positive_class_pattern_type( + db, + &self.env, + class_expr_ty, + use_generic_filtering, + ) + .unwrap_or_else(Type::object), positional_sources: class.map_or_else( || vec![ClassPatternPositionalSource::Unknown; kind.positional.len()], |class| { @@ -3883,16 +4291,31 @@ impl<'db> NarrowingConstraintsBuilder<'db, '_> { let class_info_ty = inference.expression_type(second_arg); + let use_generic_filtering = is_positive + && !self + .db + .analysis_settings(self.scope().file(self.db)) + .strict_generic_narrowing; function - .generate_constraint(db, &self.env, class_info_ty, is_positive) + .generate_constraint( + db, + &self.env, + class_info_ty, + is_positive, + use_generic_filtering, + ) .map(|constraint| { NarrowingConstraints::from_iter([( place, - NarrowingConstraint::intersection(constraint.negate_if( - db, - &self.env, - !is_positive, - )), + if use_generic_filtering { + NarrowingConstraint::generic_filtering(constraint) + } else { + NarrowingConstraint::intersection(constraint.negate_if( + db, + &self.env, + !is_positive, + )) + }, )]) }) } diff --git a/crates/ty_server/tests/e2e/snapshots/e2e__commands__debug_command.snap b/crates/ty_server/tests/e2e/snapshots/e2e__commands__debug_command.snap index 45c48b2f973d1e..090040611cf884 100644 --- a/crates/ty_server/tests/e2e/snapshots/e2e__commands__debug_command.snap +++ b/crates/ty_server/tests/e2e/snapshots/e2e__commands__debug_command.snap @@ -164,6 +164,7 @@ Settings: Settings { }, }, analysis: AnalysisSettings { + strict_generic_narrowing: false, strict_equality_semantics: false, respect_type_ignore_comments: true, allowed_unresolved_imports: ModuleGlobSet { diff --git a/crates/ty_test/src/config.rs b/crates/ty_test/src/config.rs index 28fbed42975d8b..907dec5aac10b3 100644 --- a/crates/ty_test/src/config.rs +++ b/crates/ty_test/src/config.rs @@ -148,6 +148,9 @@ pub(crate) struct Environment { #[derive(Deserialize, Default, Debug, Clone)] #[serde(rename_all = "kebab-case", deny_unknown_fields)] pub(crate) struct Analysis { + /// Whether narrowing with generic classes uses the top materialization. + pub(crate) strict_generic_narrowing: Option, + /// Whether equality-based checks should preserve possible subclass behavior. #[serde(alias = "strict-literal-narrowing")] pub(crate) strict_equality_semantics: Option, diff --git a/crates/ty_test/src/db.rs b/crates/ty_test/src/db.rs index f2700d6fe089dc..e2f7d747880b3e 100644 --- a/crates/ty_test/src/db.rs +++ b/crates/ty_test/src/db.rs @@ -272,6 +272,7 @@ fn mdtest_analysis_settings(options: Option<&Analysis>) -> AnalysisSettings { }; let AnalysisSettings { + strict_generic_narrowing: strict_generic_narrowing_default, strict_equality_semantics: strict_equality_semantics_default, respect_type_ignore_comments: respect_type_ignore_comments_default, allowed_unresolved_imports: allowed_unresolved_imports_default, @@ -305,6 +306,9 @@ fn mdtest_analysis_settings(options: Option<&Analysis>) -> AnalysisSettings { }; AnalysisSettings { + strict_generic_narrowing: options + .strict_generic_narrowing + .unwrap_or(strict_generic_narrowing_default), strict_equality_semantics: options .strict_equality_semantics .unwrap_or(strict_equality_semantics_default), diff --git a/ty.schema.json b/ty.schema.json index aafd177dd8d85b..14d8b3f14e3394 100644 --- a/ty.schema.json +++ b/ty.schema.json @@ -105,6 +105,13 @@ "boolean", "null" ] + }, + "strict-generic-narrowing": { + "description": "Whether ty should use strict narrowing for unspecialized generic classes in\n`isinstance()` and `issubclass()` checks, as well as `match` class patterns.\n\nWhen enabled, ty narrows to the top materialization of the class. For example,\n`isinstance(value, list)` narrows a value of type `object` to `Top[list[Unknown]]`,\nrepresenting the (infinite) union of all possible `list` specializations. Iterating\nover the list would yield values of type `object`.\n\nWhen disabled, ty uses gradual generic narrowing, preserving compatible type\narguments from the original type where possible. For example,\n`isinstance(value, list)` narrows a value of type `Sequence[int]` to `list[int]`.\nIf no specialization is available, the same check narrows a value of type `object`\nto `list[Unknown]`; items of any type can then be appended to the list. Class\npatterns such as `case list():` follow the same behavior.\n\nDefaults to `false`.", + "type": [ + "boolean", + "null" + ] } }, "additionalProperties": false