Skip to content

Commit 6d0e243

Browse files
denyszhakdylwil3
authored andcommitted
fix: target-version fallback with extend (#21980)
## Summary Closes #21956 **Root cause:** When a .ruff.toml, ruff.toml config was discovered via ancestor search, Ruff eagerly derived target-version from requires-python in pyproject.toml during config loading. That fallback value then participated in config merging and incorrectly overrode an explicit target-version defined in an extended config. **Fix:** Defer the requires-python fallback until after the full extend chain across .ruff.toml / ruff.toml is merged, and apply it only if target-version is still unset. **Impact:** Explicit target-version settings in extended configs are now respected, requires-python is only used as a fallback. ## Test Plan Added a new test. I was useful to validate the fix but may be redundant to keep, lmk if you would love me to remove it --------- Co-authored-by: dylwil3 <dylwil3@gmail.com>
1 parent 532a4e4 commit 6d0e243

5 files changed

Lines changed: 95 additions & 57 deletions

File tree

‎crates/ruff/tests/cli/snapshots/cli__lint__requires_python_extend_from_shared_config.snap‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ linter.rules.should_fix = [
7676
linter.per_file_ignores = {}
7777
linter.safety_table.forced_safe = []
7878
linter.safety_table.forced_unsafe = []
79-
linter.unresolved_target_version = 3.10
79+
linter.unresolved_target_version = 3.11
8080
linter.per_file_target_version = {}
8181
linter.preview = disabled
8282
linter.explicit_preview_rules = false
@@ -265,7 +265,7 @@ linter.ruff.strictly_empty_init_modules = false
265265

266266
# Formatter Settings
267267
formatter.exclude = []
268-
formatter.unresolved_target_version = 3.10
268+
formatter.unresolved_target_version = 3.11
269269
formatter.per_file_target_version = {}
270270
formatter.preview = disabled
271271
formatter.line_width = 88
@@ -280,7 +280,7 @@ formatter.docstring_code_line_width = dynamic
280280
# Analyze Settings
281281
analyze.exclude = []
282282
analyze.preview = disabled
283-
analyze.target_version = 3.10
283+
analyze.target_version = 3.11
284284
analyze.string_imports = disabled
285285
analyze.extension = ExtensionMapping({})
286286
analyze.include_dependencies = {}

‎crates/ruff_linter/src/settings/mod.rs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -504,7 +504,7 @@ impl Default for LinterSettings {
504504
/// unset. In contrast, we want to default to `PythonVersion::default()` for lint rules. These
505505
/// correspond to the [`TargetVersion::parser_version`] and [`TargetVersion::linter_version`]
506506
/// methods, respectively.
507-
#[derive(Debug, Clone, Copy, CacheKey)]
507+
#[derive(Debug, Clone, Copy, CacheKey, PartialEq, Eq)]
508508
pub struct TargetVersion(pub Option<PythonVersion>);
509509

510510
impl TargetVersion {

‎crates/ruff_workspace/src/configuration.rs‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ use std::path::{Path, PathBuf};
1111
use anyhow::{Context, Result, anyhow};
1212
use glob::{GlobError, Paths, PatternError, glob};
1313
use itertools::Itertools;
14+
use log::debug;
1415
use regex::Regex;
1516
use rustc_hash::{FxHashMap, FxHashSet};
1617
use shellexpand;
@@ -53,6 +54,8 @@ use crate::options::{
5354
PydoclintOptions, PydocstyleOptions, PyflakesOptions, PylintOptions, RuffOptions,
5455
validate_required_version,
5556
};
57+
use crate::pyproject;
58+
use crate::resolver::ConfigurationOrigin;
5659
use crate::settings::{
5760
EXCLUDE, FileResolverSettings, FormatterSettings, INCLUDE, INCLUDE_PREVIEW, LineEnding,
5861
Settings,
@@ -619,6 +622,33 @@ impl Configuration {
619622
analyze: self.analyze.combine(config.analyze),
620623
}
621624
}
625+
626+
#[must_use]
627+
pub fn apply_fallbacks(
628+
mut self,
629+
origin: ConfigurationOrigin,
630+
initial_config_path: &Path,
631+
) -> Self {
632+
if matches!(origin, ConfigurationOrigin::Ancestor) {
633+
self.target_version = self.target_version.or_else(|| {
634+
let dir = initial_config_path.parent()?;
635+
let fallback = pyproject::find_fallback_target_version(dir)?;
636+
debug!("Derived `target-version` from `requires-python`: {fallback:?}");
637+
Some(fallback.into())
638+
});
639+
}
640+
// If the origin is UserSettings, we need more information
641+
// to determine where to search for a fallback target version.
642+
// - If Ruff is being invoked via the CLI, then we search in
643+
// the cwd.
644+
// - If Ruff is being invoked via the server, then we search
645+
// in the editor's workspace root.
646+
//
647+
// This logic is implemented manually, at the time of this
648+
// writing 2026-01-30, in `ruff::resolve::resolve` and
649+
// `ruff_server::session::index::ruff_settings::RuffSettings::fallback`, respectively.
650+
self
651+
}
622652
}
623653

624654
#[derive(Clone, Debug, Default)]

‎crates/ruff_workspace/src/pyproject.rs‎

Lines changed: 7 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -181,10 +181,7 @@ pub fn find_user_settings_toml() -> Option<PathBuf> {
181181
}
182182

183183
/// Load `Options` from a `pyproject.toml` or `ruff.toml` file.
184-
pub(super) fn load_options<P: AsRef<Path>>(
185-
path: P,
186-
version_strategy: &TargetVersionStrategy,
187-
) -> Result<Options> {
184+
pub(super) fn load_options<P: AsRef<Path>>(path: P) -> Result<Options> {
188185
let path = path.as_ref();
189186
if path.ends_with("pyproject.toml") {
190187
let pyproject = parse_pyproject_toml(path)?;
@@ -201,31 +198,15 @@ pub(super) fn load_options<P: AsRef<Path>>(
201198
}
202199
Ok(ruff)
203200
} else {
204-
let mut ruff = parse_ruff_toml(path);
205-
if let Ok(ref mut ruff) = ruff {
201+
let ruff = parse_ruff_toml(path);
202+
if let Ok(ruff) = ruff {
206203
if ruff.target_version.is_none() {
207-
debug!("No `target-version` found in `ruff.toml`");
208-
match version_strategy {
209-
TargetVersionStrategy::UseDefault => {}
210-
TargetVersionStrategy::RequiresPythonFallback => {
211-
if let Some(dir) = path.parent() {
212-
let fallback = get_fallback_target_version(dir);
213-
if let Some(version) = fallback {
214-
debug!(
215-
"Derived `target-version` from `requires-python` in `pyproject.toml`: {version:?}"
216-
);
217-
} else {
218-
debug!(
219-
"No `pyproject.toml` with `requires-python` in same directory; `target-version` unspecified"
220-
);
221-
}
222-
ruff.target_version = fallback;
223-
}
224-
}
225-
}
204+
debug!("No `target-version` found in `{}`", path.display());
226205
}
206+
Ok(ruff)
207+
} else {
208+
ruff
227209
}
228-
ruff
229210
}
230211
}
231212

@@ -286,15 +267,6 @@ fn get_minimum_supported_version(requires_version: &VersionSpecifiers) -> Option
286267
PythonVersion::iter().find(|version| Version::from(*version) == minimum_version)
287268
}
288269

289-
/// Strategy for handling missing `target-version` in configuration.
290-
#[derive(Debug)]
291-
pub(super) enum TargetVersionStrategy {
292-
/// Use default `target-version`
293-
UseDefault,
294-
/// Derive from `requires-python` if available
295-
RequiresPythonFallback,
296-
}
297-
298270
#[cfg(test)]
299271
mod tests {
300272
use std::fs;

‎crates/ruff_workspace/src/resolver.rs‎

Lines changed: 54 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ use ruff_linter::package::PackageRoot;
2323
use ruff_linter::packaging::is_package;
2424

2525
use crate::configuration::Configuration;
26-
use crate::pyproject::{TargetVersionStrategy, settings_toml};
26+
use crate::pyproject::settings_toml;
2727
use crate::settings::Settings;
2828
use crate::{FileResolverSettings, pyproject};
2929

@@ -315,13 +315,13 @@ pub trait ConfigurationTransformer {
315315
// file at least twice (possibly more than twice, since we'll also parse it when
316316
// resolving the "default" configuration).
317317
pub fn resolve_configuration(
318-
pyproject: &Path,
318+
initial_config_path: &Path,
319319
transformer: &dyn ConfigurationTransformer,
320320
origin: ConfigurationOrigin,
321321
) -> Result<Configuration> {
322322
let relativity = Relativity::from(origin);
323323
let mut configurations = indexmap::IndexMap::new();
324-
let mut next = Some(fs::normalize_path(pyproject));
324+
let mut next = Some(fs::normalize_path(initial_config_path));
325325
while let Some(path) = next {
326326
if configurations.contains_key(&path) {
327327
bail!(format!(
@@ -334,20 +334,7 @@ pub fn resolve_configuration(
334334
));
335335
}
336336

337-
// Resolve the current path.
338-
let version_strategy =
339-
if configurations.is_empty() && matches!(origin, ConfigurationOrigin::Ancestor) {
340-
// For configurations that are discovered by
341-
// walking back from a file, we will attempt to
342-
// infer the `target-version` if it is missing
343-
TargetVersionStrategy::RequiresPythonFallback
344-
} else {
345-
// In all other cases (e.g. for configurations
346-
// inherited via `extend`, or user-level settings)
347-
// we do not attempt to infer a missing `target-version`
348-
TargetVersionStrategy::UseDefault
349-
};
350-
let options = pyproject::load_options(&path, &version_strategy).with_context(|| {
337+
let options = pyproject::load_options(&path).with_context(|| {
351338
if configurations.is_empty() {
352339
format!(
353340
"Failed to load configuration `{path}`",
@@ -389,6 +376,9 @@ pub fn resolve_configuration(
389376
for extend in configurations {
390377
configuration = configuration.combine(extend);
391378
}
379+
380+
let configuration = configuration.apply_fallbacks(origin, initial_config_path);
381+
392382
Ok(transformer.transform(configuration))
393383
}
394384

@@ -963,7 +953,10 @@ mod tests {
963953
use path_absolutize::Absolutize;
964954
use tempfile::TempDir;
965955

966-
use ruff_linter::settings::types::{FilePattern, GlobPath};
956+
use ruff_linter::settings::{
957+
TargetVersion,
958+
types::{FilePattern, GlobPath, PythonVersion},
959+
};
967960

968961
use crate::configuration::Configuration;
969962
use crate::pyproject::find_settings_toml;
@@ -1150,4 +1143,47 @@ mod tests {
11501143
&make_exclusion(exclude),
11511144
));
11521145
}
1146+
1147+
#[test]
1148+
fn extend_respects_target_version() -> Result<()> {
1149+
let tmp_dir = TempDir::new()?;
1150+
let root = tmp_dir.path();
1151+
1152+
let ruff_toml = root.join("ruff.toml");
1153+
std::fs::write(&ruff_toml, "target-version = \"py310\"")?;
1154+
1155+
let dot_ruff_toml = root.join(".ruff.toml");
1156+
std::fs::write(&dot_ruff_toml, "extend = \"ruff.toml\"")?;
1157+
1158+
let pyproject_toml = root.join("pyproject.toml");
1159+
std::fs::write(
1160+
&pyproject_toml,
1161+
r#"[project]
1162+
name = "repro-ruff"
1163+
version = "0.1.0"
1164+
requires-python = ">=3.13"
1165+
"#,
1166+
)?;
1167+
1168+
let main_py = root.join("main.py");
1169+
std::fs::write(
1170+
&main_py,
1171+
r#"from typing import TypeAlias
1172+
1173+
A: TypeAlias = str | int
1174+
"#,
1175+
)?;
1176+
1177+
let settings = resolve_root_settings(
1178+
&dot_ruff_toml,
1179+
&NoOpTransformer,
1180+
ConfigurationOrigin::Ancestor,
1181+
)?;
1182+
assert_eq!(
1183+
settings.linter.unresolved_target_version,
1184+
TargetVersion(Some(PythonVersion::Py310.into()))
1185+
);
1186+
1187+
Ok(())
1188+
}
11531189
}

0 commit comments

Comments
 (0)