From e35fa57c85e70acb8bdb3ffed9adfa45a30d5656 Mon Sep 17 00:00:00 2001 From: Denys Zhak Date: Sun, 14 Dec 2025 20:27:12 +0100 Subject: [PATCH] fix: target-version fallback with extend --- ...ires_python_extend_from_shared_config.snap | 6 +- crates/ruff_linter/src/settings/mod.rs | 2 +- crates/ruff_workspace/src/configuration.rs | 20 ++++++ crates/ruff_workspace/src/pyproject.rs | 42 ++--------- crates/ruff_workspace/src/resolver.rs | 72 ++++++++++++++----- 5 files changed, 85 insertions(+), 57 deletions(-) diff --git a/crates/ruff/tests/cli/snapshots/cli__lint__requires_python_extend_from_shared_config.snap b/crates/ruff/tests/cli/snapshots/cli__lint__requires_python_extend_from_shared_config.snap index ce6ae89c1a..3c6e60f468 100644 --- a/crates/ruff/tests/cli/snapshots/cli__lint__requires_python_extend_from_shared_config.snap +++ b/crates/ruff/tests/cli/snapshots/cli__lint__requires_python_extend_from_shared_config.snap @@ -76,7 +76,7 @@ linter.rules.should_fix = [ linter.per_file_ignores = {} linter.safety_table.forced_safe = [] linter.safety_table.forced_unsafe = [] -linter.unresolved_target_version = 3.10 +linter.unresolved_target_version = 3.11 linter.per_file_target_version = {} linter.preview = disabled linter.explicit_preview_rules = false @@ -264,7 +264,7 @@ linter.ruff.parenthesize_tuple_in_subscript = false # Formatter Settings formatter.exclude = [] -formatter.unresolved_target_version = 3.10 +formatter.unresolved_target_version = 3.11 formatter.per_file_target_version = {} formatter.preview = disabled formatter.line_width = 88 @@ -279,7 +279,7 @@ formatter.docstring_code_line_width = dynamic # Analyze Settings analyze.exclude = [] analyze.preview = disabled -analyze.target_version = 3.10 +analyze.target_version = 3.11 analyze.string_imports = disabled analyze.extension = ExtensionMapping({}) analyze.include_dependencies = {} diff --git a/crates/ruff_linter/src/settings/mod.rs b/crates/ruff_linter/src/settings/mod.rs index b94e4edafb..c1a41e51e6 100644 --- a/crates/ruff_linter/src/settings/mod.rs +++ b/crates/ruff_linter/src/settings/mod.rs @@ -491,7 +491,7 @@ impl Default for LinterSettings { /// unset. In contrast, we want to default to `PythonVersion::default()` for lint rules. These /// correspond to the [`TargetVersion::parser_version`] and [`TargetVersion::linter_version`] /// methods, respectively. -#[derive(Debug, Clone, Copy, CacheKey)] +#[derive(Debug, Clone, Copy, CacheKey, PartialEq, Eq)] pub struct TargetVersion(pub Option); impl TargetVersion { diff --git a/crates/ruff_workspace/src/configuration.rs b/crates/ruff_workspace/src/configuration.rs index bf50749a45..3bf3f15e41 100644 --- a/crates/ruff_workspace/src/configuration.rs +++ b/crates/ruff_workspace/src/configuration.rs @@ -12,6 +12,7 @@ use std::str::FromStr; use anyhow::{Context, Result, anyhow}; use glob::{GlobError, Paths, PatternError, glob}; use itertools::Itertools; +use log::debug; use regex::Regex; use rustc_hash::{FxHashMap, FxHashSet}; use shellexpand; @@ -54,6 +55,8 @@ use crate::options::{ McCabeOptions, Options, Pep8NamingOptions, PyUpgradeOptions, PycodestyleOptions, PydoclintOptions, PydocstyleOptions, PyflakesOptions, PylintOptions, RuffOptions, }; +use crate::pyproject; +use crate::resolver::ConfigurationOrigin; use crate::settings::{ EXCLUDE, FileResolverSettings, FormatterSettings, INCLUDE, INCLUDE_PREVIEW, LineEnding, Settings, @@ -626,6 +629,23 @@ impl Configuration { analyze: self.analyze.combine(config.analyze), } } + + #[must_use] + pub fn apply_fallbacks( + mut self, + origin: ConfigurationOrigin, + initial_config_path: &Path, + ) -> Self { + if matches!(origin, ConfigurationOrigin::Ancestor) { + self.target_version = self.target_version.or_else(|| { + let dir = initial_config_path.parent()?; + let fallback = pyproject::find_fallback_target_version(dir)?; + debug!("Derived `target-version` from `requires-python`: {fallback:?}"); + Some(fallback.into()) + }); + } + self + } } #[derive(Clone, Debug, Default)] diff --git a/crates/ruff_workspace/src/pyproject.rs b/crates/ruff_workspace/src/pyproject.rs index 53649a31e8..085f08e14a 100644 --- a/crates/ruff_workspace/src/pyproject.rs +++ b/crates/ruff_workspace/src/pyproject.rs @@ -135,10 +135,7 @@ pub fn find_user_settings_toml() -> Option { } /// Load `Options` from a `pyproject.toml` or `ruff.toml` file. -pub(super) fn load_options>( - path: P, - version_strategy: &TargetVersionStrategy, -) -> Result { +pub(super) fn load_options>(path: P) -> Result { let path = path.as_ref(); if path.ends_with("pyproject.toml") { let pyproject = parse_pyproject_toml(path)?; @@ -155,31 +152,15 @@ pub(super) fn load_options>( } Ok(ruff) } else { - let mut ruff = parse_ruff_toml(path); - if let Ok(ref mut ruff) = ruff { + let ruff = parse_ruff_toml(path); + if let Ok(ruff) = ruff { if ruff.target_version.is_none() { - debug!("No `target-version` found in `ruff.toml`"); - match version_strategy { - TargetVersionStrategy::UseDefault => {} - TargetVersionStrategy::RequiresPythonFallback => { - if let Some(dir) = path.parent() { - let fallback = get_fallback_target_version(dir); - if let Some(version) = fallback { - debug!( - "Derived `target-version` from `requires-python` in `pyproject.toml`: {version:?}" - ); - } else { - debug!( - "No `pyproject.toml` with `requires-python` in same directory; `target-version` unspecified" - ); - } - ruff.target_version = fallback; - } - } - } + debug!("No `target-version` found in `{}`", path.display()); } + Ok(ruff) + } else { + ruff } - ruff } } @@ -240,15 +221,6 @@ fn get_minimum_supported_version(requires_version: &VersionSpecifiers) -> Option PythonVersion::iter().find(|version| Version::from(*version) == minimum_version) } -/// Strategy for handling missing `target-version` in configuration. -#[derive(Debug)] -pub(super) enum TargetVersionStrategy { - /// Use default `target-version` - UseDefault, - /// Derive from `requires-python` if available - RequiresPythonFallback, -} - #[cfg(test)] mod tests { use std::fs; diff --git a/crates/ruff_workspace/src/resolver.rs b/crates/ruff_workspace/src/resolver.rs index 1740cc184a..0537df6fb2 100644 --- a/crates/ruff_workspace/src/resolver.rs +++ b/crates/ruff_workspace/src/resolver.rs @@ -23,7 +23,7 @@ use ruff_linter::package::PackageRoot; use ruff_linter::packaging::is_package; use crate::configuration::Configuration; -use crate::pyproject::{TargetVersionStrategy, settings_toml}; +use crate::pyproject::settings_toml; use crate::settings::Settings; use crate::{FileResolverSettings, pyproject}; @@ -300,13 +300,13 @@ pub trait ConfigurationTransformer { // file at least twice (possibly more than twice, since we'll also parse it when // resolving the "default" configuration). pub fn resolve_configuration( - pyproject: &Path, + initial_config_path: &Path, transformer: &dyn ConfigurationTransformer, origin: ConfigurationOrigin, ) -> Result { let relativity = Relativity::from(origin); let mut configurations = indexmap::IndexMap::new(); - let mut next = Some(fs::normalize_path(pyproject)); + let mut next = Some(fs::normalize_path(initial_config_path)); while let Some(path) = next { if configurations.contains_key(&path) { bail!(format!( @@ -319,20 +319,7 @@ pub fn resolve_configuration( )); } - // Resolve the current path. - let version_strategy = - if configurations.is_empty() && matches!(origin, ConfigurationOrigin::Ancestor) { - // For configurations that are discovered by - // walking back from a file, we will attempt to - // infer the `target-version` if it is missing - TargetVersionStrategy::RequiresPythonFallback - } else { - // In all other cases (e.g. for configurations - // inherited via `extend`, or user-level settings) - // we do not attempt to infer a missing `target-version` - TargetVersionStrategy::UseDefault - }; - let options = pyproject::load_options(&path, &version_strategy).with_context(|| { + let options = pyproject::load_options(&path).with_context(|| { if configurations.is_empty() { format!( "Failed to load configuration `{path}`", @@ -374,6 +361,9 @@ pub fn resolve_configuration( for extend in configurations { configuration = configuration.combine(extend); } + + let configuration = configuration.apply_fallbacks(origin, initial_config_path); + Ok(transformer.transform(configuration)) } @@ -944,7 +934,10 @@ mod tests { use path_absolutize::Absolutize; use tempfile::TempDir; - use ruff_linter::settings::types::{FilePattern, GlobPath}; + use ruff_linter::settings::{ + TargetVersion, + types::{FilePattern, GlobPath, PythonVersion}, + }; use crate::configuration::Configuration; use crate::pyproject::find_settings_toml; @@ -1131,4 +1124,47 @@ mod tests { &make_exclusion(exclude), )); } + + #[test] + fn extend_respects_target_version() -> Result<()> { + let tmp_dir = TempDir::new()?; + let root = tmp_dir.path(); + + let ruff_toml = root.join("ruff.toml"); + std::fs::write(&ruff_toml, "target-version = \"py310\"")?; + + let dot_ruff_toml = root.join(".ruff.toml"); + std::fs::write(&dot_ruff_toml, "extend = \"ruff.toml\"")?; + + let pyproject_toml = root.join("pyproject.toml"); + std::fs::write( + &pyproject_toml, + r#"[project] +name = "repro-ruff" +version = "0.1.0" +requires-python = ">=3.13" +"#, + )?; + + let main_py = root.join("main.py"); + std::fs::write( + &main_py, + r#"from typing import TypeAlias + +A: TypeAlias = str | int +"#, + )?; + + let settings = resolve_root_settings( + &dot_ruff_toml, + &NoOpTransformer, + ConfigurationOrigin::Ancestor, + )?; + assert_eq!( + settings.linter.unresolved_target_version, + TargetVersion(Some(PythonVersion::Py310.into())) + ); + + Ok(()) + } }