From 0194452928d7aa9b935b66ef2120ac2c70f80ead Mon Sep 17 00:00:00 2001 From: Micha Reiser Date: Tue, 24 Jun 2025 14:40:44 +0200 Subject: [PATCH] [ty] Rename `src.root` setting to `environment.root` (#18760) Co-authored-by: Alex Waygood --- crates/ruff_db/src/diagnostic/mod.rs | 4 + crates/ruff_macros/src/combine.rs | 1 + crates/ty/docs/configuration.md | 29 ++++ crates/ty/src/args.rs | 1 + crates/ty/tests/cli/python_environment.rs | 120 ++++++++++++++++ crates/ty_project/src/metadata/options.rs | 168 +++++++++++++++------- ty.schema.json | 8 ++ 7 files changed, 281 insertions(+), 50 deletions(-) diff --git a/crates/ruff_db/src/diagnostic/mod.rs b/crates/ruff_db/src/diagnostic/mod.rs index 6caefbe0c9..bb4633a9b2 100644 --- a/crates/ruff_db/src/diagnostic/mod.rs +++ b/crates/ruff_db/src/diagnostic/mod.rs @@ -735,6 +735,9 @@ pub enum DiagnosticId { /// # no `[overrides.rules]` /// ``` UselessOverridesSection, + + /// Use of a deprecated setting. + DeprecatedSetting, } impl DiagnosticId { @@ -773,6 +776,7 @@ impl DiagnosticId { DiagnosticId::EmptyInclude => "empty-include", DiagnosticId::UnnecessaryOverridesSection => "unnecessary-overrides-section", DiagnosticId::UselessOverridesSection => "useless-overrides-section", + DiagnosticId::DeprecatedSetting => "deprecated-setting", } } diff --git a/crates/ruff_macros/src/combine.rs b/crates/ruff_macros/src/combine.rs index 3d755d8ac0..f18ecd60ee 100644 --- a/crates/ruff_macros/src/combine.rs +++ b/crates/ruff_macros/src/combine.rs @@ -20,6 +20,7 @@ pub(crate) fn derive_impl(input: DeriveInput) -> syn::Result/` directory exists, include `.` and `./` in the first party search path +* otherwise, default to `.` (flat layout) + +Besides, if a `./tests` directory exists and is not a package (i.e. it does not contain an `__init__.py` file), +it will also be included in the first party search path. + +**Default value**: `null` + +**Type**: `str` + +**Example usage** (`pyproject.toml`): + +```toml +[tool.ty.environment] +root = "./app" +``` + +--- + #### `typeshed` Optional path to a "typeshed" directory on disk for us to use for standard-library types. @@ -388,6 +414,9 @@ respect-ignore-files = false #### `root` +> [!WARN] "Deprecated" +> This option has been deprecated. Use `environment.root` instead. + The root of the project, used for finding first-party modules. If left unspecified, ty will try to detect common project layouts and initialize `src.root` accordingly: diff --git a/crates/ty/src/args.rs b/crates/ty/src/args.rs index 0d48bba54a..c177c9d64b 100644 --- a/crates/ty/src/args.rs +++ b/crates/ty/src/args.rs @@ -193,6 +193,7 @@ impl CheckCommand { .map(RelativePathBuf::cli) .collect() }), + ..EnvironmentOptions::default() }), terminal: Some(TerminalOptions { output_format: self diff --git a/crates/ty/tests/cli/python_environment.rs b/crates/ty/tests/cli/python_environment.rs index 8a63e5e267..cd98e68b50 100644 --- a/crates/ty/tests/cli/python_environment.rs +++ b/crates/ty/tests/cli/python_environment.rs @@ -929,3 +929,123 @@ fn check_conda_prefix_var_to_resolve_path() -> anyhow::Result<()> { Ok(()) } + +#[test] +fn src_root_deprecation_warning() -> anyhow::Result<()> { + let case = CliTest::with_files([ + ( + "pyproject.toml", + r#" + [tool.ty.src] + root = "./src" + "#, + ), + ("src/test.py", ""), + ])?; + + assert_cmd_snapshot!(case.command(), @r#" + success: true + exit_code: 0 + ----- stdout ----- + warning[deprecated-setting]: The `src.root` setting is deprecated. Use `environment.root` instead. + --> pyproject.toml:3:8 + | + 2 | [tool.ty.src] + 3 | root = "./src" + | ^^^^^^^ + | + + Found 1 diagnostic + + ----- stderr ----- + WARN ty is pre-release software and not ready for production use. Expect to encounter bugs, missing features, and fatal errors. + "#); + + Ok(()) +} + +#[test] +fn src_root_deprecation_warning_with_environment_root() -> anyhow::Result<()> { + let case = CliTest::with_files([ + ( + "pyproject.toml", + r#" + [tool.ty.src] + root = "./src" + + [tool.ty.environment] + root = "./app" + "#, + ), + ("app/test.py", ""), + ])?; + + assert_cmd_snapshot!(case.command(), @r#" + success: true + exit_code: 0 + ----- stdout ----- + warning[deprecated-setting]: The `src.root` setting is deprecated. Use `environment.root` instead. + --> pyproject.toml:3:8 + | + 2 | [tool.ty.src] + 3 | root = "./src" + | ^^^^^^^ + 4 | + 5 | [tool.ty.environment] + | + info: The `src.root` setting was ignored in favor of the `environment.root` setting + + Found 1 diagnostic + + ----- stderr ----- + WARN ty is pre-release software and not ready for production use. Expect to encounter bugs, missing features, and fatal errors. + "#); + + Ok(()) +} + +#[test] +fn environment_root_takes_precedence_over_src_root() -> anyhow::Result<()> { + let case = CliTest::with_files([ + ( + "pyproject.toml", + r#" + [tool.ty.src] + root = "./src" + + [tool.ty.environment] + root = "./app" + "#, + ), + ("src/test.py", "import my_module"), + ( + "app/my_module.py", + "# This module exists in app/ but not src/", + ), + ])?; + + // The test should pass because environment.root points to ./app where my_module.py exists + // If src.root took precedence, it would fail because my_module.py doesn't exist in ./src + assert_cmd_snapshot!(case.command(), @r#" + success: true + exit_code: 0 + ----- stdout ----- + warning[deprecated-setting]: The `src.root` setting is deprecated. Use `environment.root` instead. + --> pyproject.toml:3:8 + | + 2 | [tool.ty.src] + 3 | root = "./src" + | ^^^^^^^ + 4 | + 5 | [tool.ty.environment] + | + info: The `src.root` setting was ignored in favor of the `environment.root` setting + + Found 1 diagnostic + + ----- stderr ----- + WARN ty is pre-release software and not ready for production use. Expect to encounter bugs, missing features, and fatal errors. + "#); + + Ok(()) +} diff --git a/crates/ty_project/src/metadata/options.rs b/crates/ty_project/src/metadata/options.rs index 7af7bf6591..564f703952 100644 --- a/crates/ty_project/src/metadata/options.rs +++ b/crates/ty_project/src/metadata/options.rs @@ -105,23 +105,25 @@ impl Options { project_name: &str, system: &dyn System, ) -> ProgramSettings { - let python_version = self - .environment - .as_ref() - .and_then(|env| env.python_version.as_ref()) - .map(|ranged_version| PythonVersionWithSource { - version: **ranged_version, - source: match ranged_version.source() { - ValueSource::Cli => PythonVersionSource::Cli, - ValueSource::File(path) => PythonVersionSource::ConfigFile( - PythonVersionFileSource::new(path.clone(), ranged_version.range()), - ), - }, - }); - let python_platform = self - .environment - .as_ref() - .and_then(|env| env.python_platform.as_deref().cloned()) + let environment = self.environment.or_default(); + + let python_version = + environment + .python_version + .as_ref() + .map(|ranged_version| PythonVersionWithSource { + version: **ranged_version, + source: match ranged_version.source() { + ValueSource::Cli => PythonVersionSource::Cli, + ValueSource::File(path) => PythonVersionSource::ConfigFile( + PythonVersionFileSource::new(path.clone(), ranged_version.range()), + ), + }, + }); + let python_platform = environment + .python_platform + .as_deref() + .cloned() .unwrap_or_else(|| { let default = PythonPlatform::default(); tracing::info!("Defaulting to python-platform `{default}`"); @@ -140,7 +142,15 @@ impl Options { project_name: &str, system: &dyn System, ) -> SearchPathSettings { - let src_roots = if let Some(src_root) = self.src.as_ref().and_then(|src| src.root.as_ref()) + let environment = self.environment.or_default(); + let src = self.src.or_default(); + + #[allow(deprecated)] + let src_roots = if let Some(src_root) = self + .environment + .as_ref() + .and_then(|environment| environment.root.as_ref()) + .or_else(|| src.root.as_ref()) { vec![src_root.absolute(project_root, system)] } else { @@ -185,27 +195,22 @@ impl Options { roots }; - let (extra_paths, python, typeshed) = self - .environment - .as_ref() - .map(|env| { - ( - env.extra_paths.clone(), - env.python.clone(), - env.typeshed.clone(), - ) - }) - .unwrap_or_default(); - SearchPathSettings { - extra_paths: extra_paths + extra_paths: environment + .extra_paths + .as_deref() .unwrap_or_default() - .into_iter() + .iter() .map(|path| path.absolute(project_root, system)) .collect(), src_roots, - custom_typeshed: typeshed.map(|path| path.absolute(project_root, system)), - python_path: python + custom_typeshed: environment + .typeshed + .as_ref() + .map(|path| path.absolute(project_root, system)), + python_path: environment + .python + .as_ref() .map(|python_path| { let origin = match python_path.source() { ValueSource::Cli => SysPrefixPathOrigin::PythonCliFlag, @@ -253,11 +258,35 @@ impl Options { error_on_warning: terminal_options.error_on_warning.unwrap_or_default(), }; - let src_options = if let Some(src) = self.src.as_ref() { - Cow::Borrowed(src) - } else { - Cow::Owned(SrcOptions::default()) - }; + let src_options = self.src.or_default(); + + #[allow(deprecated)] + if let Some(src_root) = src_options.root.as_ref() { + let mut diagnostic = OptionDiagnostic::new( + DiagnosticId::DeprecatedSetting, + "The `src.root` setting is deprecated. Use `environment.root` instead.".to_string(), + Severity::Warning, + ); + + if let Some(file) = src_root + .source() + .file() + .and_then(|path| system_path_to_file(db.upcast(), path).ok()) + { + diagnostic = diagnostic.with_annotation(Some(Annotation::primary( + Span::from(file).with_optional_range(src_root.range()), + ))); + } + + if self.environment.or_default().root.is_some() { + diagnostic = diagnostic.sub(SubDiagnostic::new( + Severity::Info, + "The `src.root` setting was ignored in favor of the `environment.root` setting", + )); + } + + diagnostics.push(diagnostic); + } let src = src_options .to_settings(db, project_root, &mut diagnostics) @@ -291,11 +320,7 @@ impl Options { db: &dyn Db, diagnostics: &mut Vec, ) -> RuleSelection { - if let Some(rules) = self.rules.as_ref() { - rules.to_rule_selection(db, diagnostics) - } else { - RuleSelection::from_registry(db.lint_registry()) - } + self.rules.or_default().to_rule_selection(db, diagnostics) } fn to_overrides_settings( @@ -327,6 +352,26 @@ impl Options { #[serde(rename_all = "kebab-case", deny_unknown_fields)] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] pub struct EnvironmentOptions { + /// The root of the project, used for finding first-party modules. + /// + /// If left unspecified, ty will try to detect common project layouts and initialize `src.root` accordingly: + /// + /// * if a `./src` directory exists, include `.` and `./src` in the first party search path (src layout or flat) + /// * if a `.//` directory exists, include `.` and `./` in the first party search path + /// * otherwise, default to `.` (flat layout) + /// + /// Besides, if a `./tests` directory exists and is not a package (i.e. it does not contain an `__init__.py` file), + /// it will also be included in the first party search path. + #[serde(skip_serializing_if = "Option::is_none")] + #[option( + default = r#"null"#, + value_type = "str", + example = r#" + root = "./app" + "# + )] + pub root: Option, + /// Specifies the version of Python that will be used to analyze the source code. /// The version should be specified as a string in the format `M.m` where `M` is the major version /// and `m` is the minor (e.g. `"3.0"` or `"3.6"`). @@ -443,6 +488,7 @@ pub struct SrcOptions { root = "./app" "# )] + #[deprecated(note = "Use `environment.root` instead.")] pub root: Option, /// Whether to automatically exclude files that are ignored by `.ignore`, @@ -1056,8 +1102,10 @@ impl RangedValue { global_rules: Option<&Rules>, diagnostics: &mut Vec, ) -> Result, Box> { + let rules = self.rules.or_default(); + // First, warn about incorrect or useless overrides. - if self.rules.as_ref().is_none_or(Rules::is_empty) { + if rules.is_empty() { let mut diagnostic = OptionDiagnostic::new( DiagnosticId::UselessOverridesSection, "Useless `overrides` section".to_string(), @@ -1174,11 +1222,11 @@ impl RangedValue { let files = IncludeExcludeFilter::new(include, exclude); // Merge global rules with override rules, with override rules taking precedence - let merged_rules = self - .rules - .clone() - .combine(global_rules.cloned()) - .expect("method to have early returned if rules is None"); + let mut merged_rules = rules.into_owned(); + + if let Some(global_rules) = global_rules { + merged_rules = merged_rules.combine(global_rules.clone()); + } // Convert merged rules to rule selection let rule_selection = merged_rules.to_rule_selection(db, diagnostics); @@ -1391,3 +1439,23 @@ impl ProjectOptionsOverrides { } } } + +trait OrDefault { + type Target: ToOwned; + + fn or_default(&self) -> Cow<'_, Self::Target>; +} + +impl OrDefault for Option +where + T: Default + ToOwned, +{ + type Target = T; + + fn or_default(&self) -> Cow<'_, Self::Target> { + match self { + Some(value) => Cow::Borrowed(value), + None => Cow::Owned(T::default()), + } + } +} diff --git a/ty.schema.json b/ty.schema.json index 169000158b..cec275cd8b 100644 --- a/ty.schema.json +++ b/ty.schema.json @@ -119,6 +119,13 @@ } ] }, + "root": { + "description": "The root of the project, used for finding first-party modules.\n\nIf left unspecified, ty will try to detect common project layouts and initialize `src.root` accordingly:\n\n* if a `./src` directory exists, include `.` and `./src` in the first party search path (src layout or flat) * if a `.//` directory exists, include `.` and `./` in the first party search path * otherwise, default to `.` (flat layout)\n\nBesides, if a `./tests` directory exists and is not a package (i.e. it does not contain an `__init__.py` file), it will also be included in the first party search path.", + "type": [ + "string", + "null" + ] + }, "typeshed": { "description": "Optional path to a \"typeshed\" directory on disk for us to use for standard-library types. If this is not provided, we will fallback to our vendored typeshed stubs for the stdlib, bundled as a zip file in the binary", "type": [ @@ -958,6 +965,7 @@ }, "root": { "description": "The root of the project, used for finding first-party modules.\n\nIf left unspecified, ty will try to detect common project layouts and initialize `src.root` accordingly:\n\n* if a `./src` directory exists, include `.` and `./src` in the first party search path (src layout or flat) * if a `.//` directory exists, include `.` and `./` in the first party search path * otherwise, default to `.` (flat layout)\n\nBesides, if a `./tests` directory exists and is not a package (i.e. it does not contain an `__init__.py` file), it will also be included in the first party search path.", + "deprecated": true, "type": [ "string", "null"