From 344d02eca0c4e8850a21286e889e1f8fafc6c9db Mon Sep 17 00:00:00 2001 From: David Peter Date: Thu, 25 Jun 2026 12:41:45 +0200 Subject: [PATCH 1/3] [ty] A configuration setting for `isinstance` narrowing --- crates/ty/docs/configuration.md | 37 ++++ crates/ty/tests/cli/config_option.rs | 2 +- crates/ty_project/src/db.rs | 12 +- crates/ty_project/src/metadata/options.rs | 68 +++++- crates/ty_project/src/metadata/settings.rs | 7 +- .../resources/mdtest/narrow/isinstance.md | 209 ++++++++++++++++++ crates/ty_python_semantic/src/db.rs | 10 +- crates/ty_python_semantic/src/lib.rs | 18 ++ crates/ty_python_semantic/src/types/narrow.rs | 69 ++++-- crates/ty_python_semantic/tests/corpus.rs | 10 +- crates/ty_test/src/config.rs | 10 +- crates/ty_test/src/db.rs | 24 +- crates/ty_test/src/lib.rs | 1 + ty.schema.json | 35 +++ 14 files changed, 480 insertions(+), 32 deletions(-) diff --git a/crates/ty/docs/configuration.md b/crates/ty/docs/configuration.md index d1a12f159c09f..5a4232b6fb2e9 100644 --- a/crates/ty/docs/configuration.md +++ b/crates/ty/docs/configuration.md @@ -654,6 +654,43 @@ Defaults to `true`. --- +## `semantics` + +### `isinstance-narrowing` + +Controls how ty narrows to unspecialized generic classes in `isinstance()` checks. + +With `strict`, ty narrows to the top materialization of the class. For example, +`isinstance(value, list)` narrows an `object` value to `Top[list[Unknown]]`, representing +a list with any possible specialization. + +With `relaxed`, ty narrows to the class's default specialization instead. The same check +narrows an `object` value to `list[Unknown]`. + +Defaults to `strict`. + +**Default value**: `strict` + +**Type**: `strict | relaxed` + +**Example usage**: + +=== "pyproject.toml" + + ```toml + [tool.ty.semantics] + isinstance-narrowing = "relaxed" + ``` + +=== "ty.toml" + + ```toml + [semantics] + isinstance-narrowing = "relaxed" + ``` + +--- + ## `src` ### `exclude` diff --git a/crates/ty/tests/cli/config_option.rs b/crates/ty/tests/cli/config_option.rs index 196a1a11c3c4c..f2dd9676c9cc0 100644 --- a/crates/ty/tests/cli/config_option.rs +++ b/crates/ty/tests/cli/config_option.rs @@ -128,7 +128,7 @@ fn cli_config_args_invalid_option() -> anyhow::Result<()> { | 1 | bad-option=true | ^^^^^^^^^^ - unknown field `bad-option`, expected one of `environment`, `src`, `rules`, `terminal`, `analysis`, `overrides` + unknown field `bad-option`, expected one of `environment`, `src`, `rules`, `terminal`, `analysis`, `semantics`, `overrides` Usage: ty diff --git a/crates/ty_project/src/db.rs b/crates/ty_project/src/db.rs index c104af0534947..48bfabed46d03 100644 --- a/crates/ty_project/src/db.rs +++ b/crates/ty_project/src/db.rs @@ -19,7 +19,7 @@ use ty_python_core::program::{ FallibleStrategy, MisconfigurationStrategy, Program, UseDefaultStrategy, }; use ty_python_semantic::lint::{LintRegistry, RuleSelection}; -use ty_python_semantic::{AnalysisSettings, Db as SemanticDb}; +use ty_python_semantic::{AnalysisSettings, Db as SemanticDb, SemanticSettings}; mod changes; mod ignore; @@ -534,6 +534,10 @@ impl SemanticDb for ProjectDatabase { settings.analysis(self) } + fn semantic_settings(&self, _file: File) -> &SemanticSettings { + self.project().settings(self).semantics() + } + fn verbose(&self) -> bool { self.project().verbose(self) } @@ -614,7 +618,7 @@ pub(crate) mod testing { use ty_python_core::platform::PythonPlatform; use ty_python_core::program::{FallibleStrategy, Program, ProgramSettings}; use ty_python_semantic::lint::{LintRegistry, RuleSelection}; - use ty_python_semantic::{AnalysisSettings, PythonVersionWithSource}; + use ty_python_semantic::{AnalysisSettings, PythonVersionWithSource, SemanticSettings}; use crate::db::Db; use crate::{Project, ProjectMetadata}; @@ -763,6 +767,10 @@ pub(crate) mod testing { self.project().settings(self).analysis() } + fn semantic_settings(&self, _file: ruff_db::files::File) -> &SemanticSettings { + self.project().settings(self).semantics() + } + fn verbose(&self) -> bool { false } diff --git a/crates/ty_project/src/metadata/options.rs b/crates/ty_project/src/metadata/options.rs index 63e5c0589573d..628592e92c1ff 100644 --- a/crates/ty_project/src/metadata/options.rs +++ b/crates/ty_project/src/metadata/options.rs @@ -37,9 +37,9 @@ use ty_python_core::platform::PythonPlatform; use ty_python_core::program::{MisconfigurationStrategy, ProgramSettings}; use ty_python_semantic::lint::{Level, LintSource, RuleSelection}; use ty_python_semantic::{ - AnalysisSettings, PythonEnvironment, PythonVersionFileSource, PythonVersionSource, - PythonVersionWithSource, SitePackagesPaths, SysPrefixPathOrigin, - inferred_python_version_source_annotation, + AnalysisSettings, IsInstanceNarrowing, PythonEnvironment, PythonVersionFileSource, + PythonVersionSource, PythonVersionWithSource, SemanticSettings, SitePackagesPaths, + SysPrefixPathOrigin, inferred_python_version_source_annotation, }; use ty_static::EnvVars; @@ -100,6 +100,11 @@ pub struct Options { #[option_group] pub analysis: Option, + /// Configures semantic type inference behavior. + #[serde(skip_serializing_if = "Option::is_none")] + #[option_group] + pub semantics: Option, + /// Override configurations for specific file patterns. /// /// Each override specifies include/exclude patterns and rule configurations @@ -490,6 +495,8 @@ impl Options { }; let analysis = strategy.fallback(analysis_result, |_| AnalysisSettings::default())?; + let semantics = self.semantics.or_default().to_settings(); + let overrides = self .to_overrides_settings(db, project_root, &mut diagnostics) .map_err(|err| ToSettingsError { @@ -504,6 +511,7 @@ impl Options { terminal, src, analysis, + semantics, overrides, }; @@ -1583,6 +1591,60 @@ impl AnalysisOptions { } } +#[derive( + Debug, + Default, + Clone, + Eq, + PartialEq, + Serialize, + Deserialize, + OptionsMetadata, + get_size2::GetSize, +)] +#[serde(rename_all = "kebab-case", deny_unknown_fields)] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +pub struct SemanticsOptions { + /// Controls how ty narrows to unspecialized generic classes in `isinstance()` checks. + /// + /// With `strict`, ty narrows to the top materialization of the class. For example, + /// `isinstance(value, list)` narrows an `object` value to `Top[list[Unknown]]`, representing + /// a list with any possible specialization. + /// + /// With `relaxed`, ty narrows to the class's default specialization instead. The same check + /// narrows an `object` value to `list[Unknown]`. + /// + /// Defaults to `strict`. + #[option( + default = "strict", + value_type = "strict | relaxed", + example = r#" + isinstance-narrowing = "relaxed" + "# + )] + pub isinstance_narrowing: Option>, +} + +impl SemanticsOptions { + fn to_settings(&self) -> SemanticSettings { + SemanticSettings { + isinstance_narrowing: self + .isinstance_narrowing + .as_deref() + .copied() + .unwrap_or_default(), + } + } +} + +impl Combine for SemanticsOptions { + fn combine_with(&mut self, other: Self) { + if self.isinstance_narrowing.is_none() { + self.isinstance_narrowing = other.isinstance_narrowing; + } + } +} + fn build_module_glob_set( db: &dyn Db, patterns: &[RangedValue], diff --git a/crates/ty_project/src/metadata/settings.rs b/crates/ty_project/src/metadata/settings.rs index eaf9966f400fc..46c71a095ddea 100644 --- a/crates/ty_project/src/metadata/settings.rs +++ b/crates/ty_project/src/metadata/settings.rs @@ -2,8 +2,8 @@ use std::sync::Arc; use ruff_db::files::File; use ty_combine::Combine; -use ty_python_semantic::AnalysisSettings; use ty_python_semantic::lint::RuleSelection; +use ty_python_semantic::{AnalysisSettings, SemanticSettings}; use crate::metadata::options::{InnerOverrideOptions, OutputFormat}; use crate::{Db, glob::IncludeExcludeFilter}; @@ -27,6 +27,7 @@ pub struct Settings { pub(super) terminal: TerminalSettings, pub(super) src: SrcSettings, pub(super) analysis: AnalysisSettings, + pub(super) semantics: SemanticSettings, /// Settings for configuration overrides that apply to specific file patterns. /// @@ -60,6 +61,10 @@ impl Settings { pub fn analysis(&self) -> &AnalysisSettings { &self.analysis } + + pub fn semantics(&self) -> &SemanticSettings { + &self.semantics + } } #[derive(Debug, Clone, PartialEq, Eq, get_size2::GetSize)] diff --git a/crates/ty_python_semantic/resources/mdtest/narrow/isinstance.md b/crates/ty_python_semantic/resources/mdtest/narrow/isinstance.md index 8cc098df0714a..d8e23dd583af1 100644 --- a/crates/ty_python_semantic/resources/mdtest/narrow/isinstance.md +++ b/crates/ty_python_semantic/resources/mdtest/narrow/isinstance.md @@ -728,6 +728,215 @@ def _(x: type[object], y: type[object], z: type[object]): reveal_type(z) # revealed: type[Top[Invariant[Unknown]]] ``` +## Use cases: `isinstance` narrowing and generics + +### Strict mode + +```toml +[semantics] +isinstance-narrowing = "strict" +``` + +#### 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]] +``` + +### Relaxed mode + +The `semantics.isinstance-narrowing` option can be set to `relaxed` to narrow to the default +specialization of a generic class without top-materializing it: + +```toml +[semantics] +isinstance-narrowing = "relaxed" +``` + +#### 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[Unknown] +``` + +Narrowing from `Item | Sequence[Item]` via `isinstance(.., Sequence)`: + +```py +@final +class Item: ... + +def _(xs: Item | Sequence[Item]): + if isinstance(xs, Sequence): + # TODO: we might want to simplify this to `Sequence[Item & Unknown]` + reveal_type(xs) # revealed: Sequence[Item] & Sequence[Unknown] + for x in xs: + reveal_type(x) # revealed: Item & Unknown + else: + reveal_type(xs) # revealed: Item | (Sequence[Item] & ~Sequence[Unknown]) +``` + +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] & Sequence[Unknown]) + for x in xs: + reveal_type(x) # revealed: Unknown + else: + reveal_type(xs) # revealed: (OpenItem & ~Sequence[Unknown]) | (Sequence[OpenItem] & ~Sequence[Unknown]) +``` + +#### 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 + + # In relaxed mode, this is fine + xs.append(1) + + else: + reveal_type(xs) # revealed: ~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 | (list[Item] & ~list[Unknown]) +``` + +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 & ~list[Unknown]) | (list[OpenItem] & ~list[Unknown]) +``` + ## Narrowing generic defaults in Python 3.13 When a type parameter has a bare `Any` default, narrowing still materializes the substituted diff --git a/crates/ty_python_semantic/src/db.rs b/crates/ty_python_semantic/src/db.rs index e33ff5f2e90c1..df084810714c8 100644 --- a/crates/ty_python_semantic/src/db.rs +++ b/crates/ty_python_semantic/src/db.rs @@ -1,5 +1,5 @@ -use crate::AnalysisSettings; use crate::lint::{LintRegistry, RuleSelection}; +use crate::{AnalysisSettings, SemanticSettings}; use ruff_db::diagnostic::Diagnostic; use ruff_db::files::File; use ty_python_core::Db as PythonCoreDb; @@ -16,6 +16,8 @@ pub trait Db: PythonCoreDb { fn analysis_settings(&self, file: File) -> &AnalysisSettings; + fn semantic_settings(&self, file: File) -> &SemanticSettings; + /// Whether ty is running with logging verbosity INFO or higher (`-v` or more). fn verbose(&self) -> bool; @@ -55,6 +57,7 @@ pub(crate) mod tests { events: Events, rule_selection: Arc, analysis_settings: Arc, + semantic_settings: Arc, } impl TestDb { @@ -75,6 +78,7 @@ pub(crate) mod tests { files: Files::default(), rule_selection: Arc::new(RuleSelection::from_registry(default_lint_registry())), analysis_settings: AnalysisSettings::default().into(), + semantic_settings: SemanticSettings::default().into(), } } @@ -152,6 +156,10 @@ pub(crate) mod tests { &self.analysis_settings } + fn semantic_settings(&self, _file: File) -> &SemanticSettings { + &self.semantic_settings + } + fn verbose(&self) -> bool { false } diff --git a/crates/ty_python_semantic/src/lib.rs b/crates/ty_python_semantic/src/lib.rs index 1a7b6262e049e..4508a8dace0ef 100644 --- a/crates/ty_python_semantic/src/lib.rs +++ b/crates/ty_python_semantic/src/lib.rs @@ -103,6 +103,24 @@ pub struct AnalysisSettings { pub replace_imports_with_any: ModuleGlobSet, } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, get_size2::GetSize)] +#[cfg_attr( + feature = "serde", + derive(serde::Serialize, serde::Deserialize), + serde(rename_all = "kebab-case") +)] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +pub enum IsInstanceNarrowing { + #[default] + Strict, + Relaxed, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, get_size2::GetSize)] +pub struct SemanticSettings { + pub isinstance_narrowing: IsInstanceNarrowing, +} + impl Default for AnalysisSettings { fn default() -> Self { Self { diff --git a/crates/ty_python_semantic/src/types/narrow.rs b/crates/ty_python_semantic/src/types/narrow.rs index 06180f33409de..5983cd39c3759 100644 --- a/crates/ty_python_semantic/src/types/narrow.rs +++ b/crates/ty_python_semantic/src/types/narrow.rs @@ -1,7 +1,6 @@ use std::borrow::Cow; use std::collections::{BTreeMap, btree_map::Entry as BTreeEntry, hash_map::Entry}; -use crate::Db; use crate::reachability::{narrow_type_by_constraint, type_narrowed_by_previous_patterns}; use crate::subscript::PyIndex; use crate::types::function::KnownFunction; @@ -20,6 +19,7 @@ use crate::types::{ pattern_binding_fallthrough_type, pattern_fallthrough_type, sequence_pattern_type_builder, singleton_pattern_type, starred_sequence_pattern_type, }; +use crate::{Db, IsInstanceNarrowing}; use ty_python_core::expression::Expression; use ty_python_core::frozen::FrozenMap; use ty_python_core::place::{PlaceExpr, PlaceTable, ScopedPlaceId}; @@ -401,20 +401,28 @@ impl ClassInfoConstraintFunction { db: &'db dyn Db, classinfo: Type<'db>, is_positive: bool, + isinstance_narrowing: IsInstanceNarrowing, ) -> Option> { let constraint_from_class_literal = |class: ClassLiteral<'db>| match self { - ClassInfoConstraintFunction::IsInstance => { - Type::instance(db, class.top_materialization(db)) - } + ClassInfoConstraintFunction::IsInstance => Type::instance( + db, + match isinstance_narrowing { + IsInstanceNarrowing::Strict => class.top_materialization(db), + IsInstanceNarrowing::Relaxed => class.default_specialization(db), + }, + ), ClassInfoConstraintFunction::IsSubclass => { SubclassOfType::from(db, class.top_materialization(db)) } }; match classinfo { - Type::TypeAlias(alias) => { - self.generate_constraint(db, alias.value_type(db), is_positive) - } + Type::TypeAlias(alias) => self.generate_constraint( + db, + alias.value_type(db), + is_positive, + isinstance_narrowing, + ), 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)` @@ -449,6 +457,7 @@ impl ClassInfoConstraintFunction { db, *element, is_positive, + isinstance_narrowing, )?); } Some(builder.build()) @@ -458,16 +467,20 @@ impl ClassInfoConstraintFunction { } } Type::Union(union) => union.try_map(db, |element| { - self.generate_constraint(db, *element, is_positive) + self.generate_constraint(db, *element, is_positive, isinstance_narrowing) }), Type::TypeVar(bound_typevar) => { match bound_typevar.typevar(db).bound_or_constraints(db)? { TypeVarBoundOrConstraints::UpperBound(bound) => { - self.generate_constraint(db, bound, is_positive) - } - TypeVarBoundOrConstraints::Constraints(constraints) => { - self.generate_constraint(db, constraints.as_type(db), is_positive) + self.generate_constraint(db, bound, is_positive, isinstance_narrowing) } + TypeVarBoundOrConstraints::Constraints(constraints) => self + .generate_constraint( + db, + constraints.as_type(db), + is_positive, + isinstance_narrowing, + ), } } @@ -478,9 +491,9 @@ impl ClassInfoConstraintFunction { Type::NominalInstance(nominal) => nominal.tuple_spec(db).and_then(|tuple| { UnionType::try_from_elements( db, - tuple - .iter_all_elements() - .map(|element| self.generate_constraint(db, element, is_positive)), + tuple.iter_all_elements().map(|element| { + self.generate_constraint(db, element, is_positive, isinstance_narrowing) + }), ) }), @@ -497,9 +510,10 @@ impl ClassInfoConstraintFunction { db, KnownClass::NoneType.to_class_literal(db), is_positive, + isinstance_narrowing, ) } else { - self.generate_constraint(db, element, is_positive) + self.generate_constraint(db, element, is_positive, isinstance_narrowing) } }), ) @@ -510,15 +524,20 @@ impl ClassInfoConstraintFunction { db, alias.aliased_class().to_class_literal(db), is_positive, + isinstance_narrowing, ), SpecialFormType::Tuple => self.generate_constraint( db, KnownClass::Tuple.to_class_literal(db), is_positive, + isinstance_narrowing, + ), + SpecialFormType::Type => self.generate_constraint( + db, + KnownClass::Type.to_class_literal(db), + is_positive, + isinstance_narrowing, ), - SpecialFormType::Type => { - self.generate_constraint(db, KnownClass::Type.to_class_literal(db), is_positive) - } // We don't have a good meta-type for `Callable`s right now, // so only apply `isinstance()` narrowing, not `issubclass()` @@ -883,6 +902,7 @@ fn positive_class_pattern_type<'db>( db, class_expression_ty, true, + IsInstanceNarrowing::Strict, ) } _ => None, @@ -2731,8 +2751,16 @@ impl<'db> NarrowingConstraintsBuilder<'db, '_> { let class_info_ty = inference.expression_type(second_arg); + let isinstance_narrowing = if function == ClassInfoConstraintFunction::IsInstance { + self.db + .semantic_settings(self.scope().file(self.db)) + .isinstance_narrowing + } else { + IsInstanceNarrowing::Strict + }; + function - .generate_constraint(self.db, class_info_ty, is_positive) + .generate_constraint(self.db, class_info_ty, is_positive, isinstance_narrowing) .map(|constraint| { NarrowingConstraints::from_iter([( place, @@ -2849,6 +2877,7 @@ impl<'db> NarrowingConstraintsBuilder<'db, '_> { self.db, KnownClass::Mapping.to_class_literal(self.db), true, + IsInstanceNarrowing::Strict, )?; Some(NarrowingConstraints::from_iter([( diff --git a/crates/ty_python_semantic/tests/corpus.rs b/crates/ty_python_semantic/tests/corpus.rs index b2bfcff38b7b8..004868a9bc52a 100644 --- a/crates/ty_python_semantic/tests/corpus.rs +++ b/crates/ty_python_semantic/tests/corpus.rs @@ -12,7 +12,9 @@ use ty_python_core::platform::PythonPlatform; use ty_python_core::program::{FallibleStrategy, Program, ProgramSettings}; use ty_python_semantic::lint::{LintRegistry, RuleSelection}; use ty_python_semantic::pull_types::pull_types; -use ty_python_semantic::{AnalysisSettings, check_file_unwrap, default_lint_registry}; +use ty_python_semantic::{ + AnalysisSettings, SemanticSettings, check_file_unwrap, default_lint_registry, +}; use ty_site_packages::{PythonVersionSource, PythonVersionWithSource}; use ruff_db::diagnostic::Diagnostic; @@ -184,6 +186,7 @@ pub struct CorpusDb { system: TestSystem, vendored: VendoredFileSystem, analysis_settings: Arc, + semantic_settings: Arc, } impl CorpusDb { @@ -196,6 +199,7 @@ impl CorpusDb { rule_selection: RuleSelection::from_registry(default_lint_registry()), files: Files::default(), analysis_settings: Arc::new(AnalysisSettings::default()), + semantic_settings: Arc::new(SemanticSettings::default()), }; Program::from_settings( @@ -285,6 +289,10 @@ impl ty_python_semantic::Db for CorpusDb { &self.analysis_settings } + fn semantic_settings(&self, _file: File) -> &SemanticSettings { + &self.semantic_settings + } + fn dyn_clone(&self) -> Box { Box::new(self.clone()) } diff --git a/crates/ty_test/src/config.rs b/crates/ty_test/src/config.rs index 554d6eb7fdb4f..486918ca6655f 100644 --- a/crates/ty_test/src/config.rs +++ b/crates/ty_test/src/config.rs @@ -21,7 +21,7 @@ use ruff_db::system::{SystemPath, SystemPathBuf}; use ruff_python_ast::PythonVersion; use serde::{Deserialize, Serialize}; use ty_python_core::platform::PythonPlatform; -use ty_python_semantic::lint::Level; +use ty_python_semantic::{IsInstanceNarrowing, lint::Level}; #[derive(Deserialize, Debug, Default, Clone)] #[serde(rename_all = "kebab-case", deny_unknown_fields)] @@ -34,6 +34,8 @@ pub(crate) struct MarkdownTestConfig { pub(crate) analysis: Option, + pub(crate) semantics: Option, + /// The [`ruff_db::system::System`] to use for tests. /// /// Defaults to the case-sensitive [`ruff_db::system::InMemorySystem`]. @@ -129,6 +131,12 @@ pub(crate) struct Analysis { pub(crate) replace_imports_with_any: Option>, } +#[derive(Deserialize, Default, Debug, Clone)] +#[serde(rename_all = "kebab-case", deny_unknown_fields)] +pub(crate) struct Semantics { + pub(crate) isinstance_narrowing: Option, +} + #[derive(Deserialize, Debug, Clone)] #[serde(untagged)] pub(crate) enum Log { diff --git a/crates/ty_test/src/db.rs b/crates/ty_test/src/db.rs index 56e9151ef0ad2..bc05de27b1b49 100644 --- a/crates/ty_test/src/db.rs +++ b/crates/ty_test/src/db.rs @@ -1,4 +1,4 @@ -use crate::config::{Analysis, Rules}; +use crate::config::{Analysis, Rules, Semantics}; use camino::{Utf8Component, Utf8PathBuf}; use ruff_db::Db as SourceDb; use ruff_db::diagnostic::{Diagnostic, Severity}; @@ -18,7 +18,7 @@ use ty_python_core::Db as _; use ty_python_core::program::Program; use ty_python_semantic::lint::{LintRegistry, RuleSelection}; use ty_python_semantic::{ - AnalysisSettings, Db as SemanticDb, check_file_unwrap, default_lint_registry, + AnalysisSettings, Db as SemanticDb, SemanticSettings, check_file_unwrap, default_lint_registry, }; #[salsa::db] @@ -110,6 +110,19 @@ impl Db { } } + pub(crate) fn update_semantics_options(&mut self, options: Option<&Semantics>) { + let semantics = SemanticSettings { + isinstance_narrowing: options + .and_then(|options| options.isinstance_narrowing) + .unwrap_or_default(), + }; + + let settings = self.settings(); + if settings.semantics(self) != &semantics { + settings.set_semantics(self).to(semantics); + } + } + pub(crate) fn update_mdtest_rule_selection( &mut self, rules: Option<&Rules>, @@ -199,6 +212,10 @@ impl SemanticDb for Db { self.settings().analysis(self) } + fn semantic_settings(&self, _file: File) -> &SemanticSettings { + self.settings().semantics(self) + } + fn dyn_clone(&self) -> Box { Box::new(self.clone()) } @@ -220,6 +237,9 @@ struct Settings { #[returns(ref)] analysis: AnalysisSettings, #[default] + #[returns(ref)] + semantics: SemanticSettings, + #[default] #[returns(deref)] rule_selection: MdtestRuleSelection, #[default] diff --git a/crates/ty_test/src/lib.rs b/crates/ty_test/src/lib.rs index a5a60304fa0af..76a93bddbc813 100644 --- a/crates/ty_test/src/lib.rs +++ b/crates/ty_test/src/lib.rs @@ -318,6 +318,7 @@ fn run_test( Program::init_or_update(db, settings); db.update_analysis_options(configuration.analysis.as_ref()); + db.update_semantics_options(configuration.semantics.as_ref()); db.update_mdtest_rule_selection(configuration.rules.as_ref(), options.default_error_rule); db.set_verbosity(test.configuration().verbose()); diff --git a/ty.schema.json b/ty.schema.json index 11fd52f332894..f845d6e5fc140 100644 --- a/ty.schema.json +++ b/ty.schema.json @@ -46,6 +46,17 @@ } ] }, + "semantics": { + "description": "Configures semantic type inference behavior.", + "anyOf": [ + { + "$ref": "#/definitions/SemanticsOptions" + }, + { + "type": "null" + } + ] + }, "src": { "anyOf": [ { @@ -178,6 +189,13 @@ }, "additionalProperties": false }, + "IsInstanceNarrowing": { + "type": "string", + "enum": [ + "strict", + "relaxed" + ] + }, "Level": { "oneOf": [ { @@ -1545,6 +1563,23 @@ "$ref": "#/definitions/Level" } }, + "SemanticsOptions": { + "type": "object", + "properties": { + "isinstance-narrowing": { + "description": "Controls how ty narrows to unspecialized generic classes in `isinstance()` checks.\n\nWith `strict`, ty narrows to the top materialization of the class. For example,\n`isinstance(value, list)` narrows an `object` value to `Top[list[Unknown]]`, representing\na list with any possible specialization.\n\nWith `relaxed`, ty narrows to the class's default specialization instead. The same check\nnarrows an `object` value to `list[Unknown]`.\n\nDefaults to `strict`.", + "anyOf": [ + { + "$ref": "#/definitions/IsInstanceNarrowing" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + }, "SrcOptions": { "type": "object", "properties": { From f44019a7be7219205dd0a58ac707e2efc8f0871e Mon Sep 17 00:00:00 2001 From: David Peter Date: Thu, 25 Jun 2026 13:12:25 +0200 Subject: [PATCH 2/3] [ty] Fix CI for isinstance narrowing setting --- .../e2e/snapshots/e2e__commands__debug_command.snap | 3 +++ fuzz/fuzz_targets/ty_check_invalid_syntax.rs | 10 ++++++++-- 2 files changed, 11 insertions(+), 2 deletions(-) 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 fe4a338748601..f0fdc1e27c45d 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 @@ -171,6 +171,9 @@ Settings: Settings { globs: [], }, }, + semantics: SemanticSettings { + isinstance_narrowing: Strict, + }, overrides: [], } diff --git a/fuzz/fuzz_targets/ty_check_invalid_syntax.rs b/fuzz/fuzz_targets/ty_check_invalid_syntax.rs index 274c8851d087a..fec2b667f506d 100644 --- a/fuzz/fuzz_targets/ty_check_invalid_syntax.rs +++ b/fuzz/fuzz_targets/ty_check_invalid_syntax.rs @@ -23,8 +23,8 @@ use ty_python_core::program::{FallibleStrategy, Program, ProgramSettings}; use ty_python_semantic::lint::LintRegistry; use ty_python_semantic::types::check_types; use ty_python_semantic::{ - AnalysisSettings, Db as SemanticDb, PythonVersionWithSource, default_lint_registry, - lint::RuleSelection, + AnalysisSettings, Db as SemanticDb, PythonVersionWithSource, SemanticSettings, + default_lint_registry, lint::RuleSelection, }; /// Database that can be used for testing. @@ -39,6 +39,7 @@ struct TestDb { vendored: VendoredFileSystem, rule_selection: Arc, analysis_settings: Arc, + semantic_settings: Arc, } impl TestDb { @@ -54,6 +55,7 @@ impl TestDb { files: Files::default(), rule_selection: RuleSelection::from_registry(default_lint_registry()).into(), analysis_settings: AnalysisSettings::default().into(), + semantic_settings: SemanticSettings::default().into(), } } } @@ -119,6 +121,10 @@ impl SemanticDb for TestDb { &self.analysis_settings } + fn semantic_settings(&self, _file: File) -> &SemanticSettings { + &self.semantic_settings + } + fn lint_registry(&self) -> &LintRegistry { default_lint_registry() } From 7d7972e89370df1f120740c37eb5e31eadf7b631 Mon Sep 17 00:00:00 2001 From: David Peter Date: Thu, 25 Jun 2026 17:39:28 +0200 Subject: [PATCH 3/3] Add test for exhaustiveness checking --- .../resources/mdtest/narrow/isinstance.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/crates/ty_python_semantic/resources/mdtest/narrow/isinstance.md b/crates/ty_python_semantic/resources/mdtest/narrow/isinstance.md index d8e23dd583af1..c48517e690dba 100644 --- a/crates/ty_python_semantic/resources/mdtest/narrow/isinstance.md +++ b/crates/ty_python_semantic/resources/mdtest/narrow/isinstance.md @@ -937,6 +937,20 @@ def _(xs: OpenItem | list[OpenItem]): reveal_type(xs) # revealed: (OpenItem & ~list[Unknown]) | (list[OpenItem] & ~list[Unknown]) ``` +#### Exhaustiveness checking + +In relaxed mode, exhaustiveness checking is harder to achieve: + +```py +# TODO +# error: [invalid-return-type] "Function can implicitly return `None`, which is not assignable to return type `str`" +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 generic defaults in Python 3.13 When a type parameter has a bare `Any` default, narrowing still materializes the substituted