fix: target-version fallback with extend

This commit is contained in:
Denys Zhak 2025-12-14 20:27:12 +01:00
parent 857fd4f683
commit e35fa57c85
5 changed files with 85 additions and 57 deletions

View File

@ -76,7 +76,7 @@ linter.rules.should_fix = [
linter.per_file_ignores = {} linter.per_file_ignores = {}
linter.safety_table.forced_safe = [] linter.safety_table.forced_safe = []
linter.safety_table.forced_unsafe = [] linter.safety_table.forced_unsafe = []
linter.unresolved_target_version = 3.10 linter.unresolved_target_version = 3.11
linter.per_file_target_version = {} linter.per_file_target_version = {}
linter.preview = disabled linter.preview = disabled
linter.explicit_preview_rules = false linter.explicit_preview_rules = false
@ -264,7 +264,7 @@ linter.ruff.parenthesize_tuple_in_subscript = false
# Formatter Settings # Formatter Settings
formatter.exclude = [] formatter.exclude = []
formatter.unresolved_target_version = 3.10 formatter.unresolved_target_version = 3.11
formatter.per_file_target_version = {} formatter.per_file_target_version = {}
formatter.preview = disabled formatter.preview = disabled
formatter.line_width = 88 formatter.line_width = 88
@ -279,7 +279,7 @@ formatter.docstring_code_line_width = dynamic
# Analyze Settings # Analyze Settings
analyze.exclude = [] analyze.exclude = []
analyze.preview = disabled analyze.preview = disabled
analyze.target_version = 3.10 analyze.target_version = 3.11
analyze.string_imports = disabled analyze.string_imports = disabled
analyze.extension = ExtensionMapping({}) analyze.extension = ExtensionMapping({})
analyze.include_dependencies = {} analyze.include_dependencies = {}

View File

@ -491,7 +491,7 @@ impl Default for LinterSettings {
/// unset. In contrast, we want to default to `PythonVersion::default()` for lint rules. These /// unset. In contrast, we want to default to `PythonVersion::default()` for lint rules. These
/// correspond to the [`TargetVersion::parser_version`] and [`TargetVersion::linter_version`] /// correspond to the [`TargetVersion::parser_version`] and [`TargetVersion::linter_version`]
/// methods, respectively. /// methods, respectively.
#[derive(Debug, Clone, Copy, CacheKey)] #[derive(Debug, Clone, Copy, CacheKey, PartialEq, Eq)]
pub struct TargetVersion(pub Option<PythonVersion>); pub struct TargetVersion(pub Option<PythonVersion>);
impl TargetVersion { impl TargetVersion {

View File

@ -12,6 +12,7 @@ use std::str::FromStr;
use anyhow::{Context, Result, anyhow}; use anyhow::{Context, Result, anyhow};
use glob::{GlobError, Paths, PatternError, glob}; use glob::{GlobError, Paths, PatternError, glob};
use itertools::Itertools; use itertools::Itertools;
use log::debug;
use regex::Regex; use regex::Regex;
use rustc_hash::{FxHashMap, FxHashSet}; use rustc_hash::{FxHashMap, FxHashSet};
use shellexpand; use shellexpand;
@ -54,6 +55,8 @@ use crate::options::{
McCabeOptions, Options, Pep8NamingOptions, PyUpgradeOptions, PycodestyleOptions, McCabeOptions, Options, Pep8NamingOptions, PyUpgradeOptions, PycodestyleOptions,
PydoclintOptions, PydocstyleOptions, PyflakesOptions, PylintOptions, RuffOptions, PydoclintOptions, PydocstyleOptions, PyflakesOptions, PylintOptions, RuffOptions,
}; };
use crate::pyproject;
use crate::resolver::ConfigurationOrigin;
use crate::settings::{ use crate::settings::{
EXCLUDE, FileResolverSettings, FormatterSettings, INCLUDE, INCLUDE_PREVIEW, LineEnding, EXCLUDE, FileResolverSettings, FormatterSettings, INCLUDE, INCLUDE_PREVIEW, LineEnding,
Settings, Settings,
@ -626,6 +629,23 @@ impl Configuration {
analyze: self.analyze.combine(config.analyze), 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)] #[derive(Clone, Debug, Default)]

View File

@ -135,10 +135,7 @@ pub fn find_user_settings_toml() -> Option<PathBuf> {
} }
/// Load `Options` from a `pyproject.toml` or `ruff.toml` file. /// Load `Options` from a `pyproject.toml` or `ruff.toml` file.
pub(super) fn load_options<P: AsRef<Path>>( pub(super) fn load_options<P: AsRef<Path>>(path: P) -> Result<Options> {
path: P,
version_strategy: &TargetVersionStrategy,
) -> Result<Options> {
let path = path.as_ref(); let path = path.as_ref();
if path.ends_with("pyproject.toml") { if path.ends_with("pyproject.toml") {
let pyproject = parse_pyproject_toml(path)?; let pyproject = parse_pyproject_toml(path)?;
@ -155,31 +152,15 @@ pub(super) fn load_options<P: AsRef<Path>>(
} }
Ok(ruff) Ok(ruff)
} else { } else {
let mut ruff = parse_ruff_toml(path); let ruff = parse_ruff_toml(path);
if let Ok(ref mut ruff) = ruff { if let Ok(ruff) = ruff {
if ruff.target_version.is_none() { if ruff.target_version.is_none() {
debug!("No `target-version` found in `ruff.toml`"); debug!("No `target-version` found in `{}`", path.display());
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;
}
}
}
} }
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) 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)] #[cfg(test)]
mod tests { mod tests {
use std::fs; use std::fs;

View File

@ -23,7 +23,7 @@ use ruff_linter::package::PackageRoot;
use ruff_linter::packaging::is_package; use ruff_linter::packaging::is_package;
use crate::configuration::Configuration; use crate::configuration::Configuration;
use crate::pyproject::{TargetVersionStrategy, settings_toml}; use crate::pyproject::settings_toml;
use crate::settings::Settings; use crate::settings::Settings;
use crate::{FileResolverSettings, pyproject}; 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 // file at least twice (possibly more than twice, since we'll also parse it when
// resolving the "default" configuration). // resolving the "default" configuration).
pub fn resolve_configuration( pub fn resolve_configuration(
pyproject: &Path, initial_config_path: &Path,
transformer: &dyn ConfigurationTransformer, transformer: &dyn ConfigurationTransformer,
origin: ConfigurationOrigin, origin: ConfigurationOrigin,
) -> Result<Configuration> { ) -> Result<Configuration> {
let relativity = Relativity::from(origin); let relativity = Relativity::from(origin);
let mut configurations = indexmap::IndexMap::new(); 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 { while let Some(path) = next {
if configurations.contains_key(&path) { if configurations.contains_key(&path) {
bail!(format!( bail!(format!(
@ -319,20 +319,7 @@ pub fn resolve_configuration(
)); ));
} }
// Resolve the current path. let options = pyproject::load_options(&path).with_context(|| {
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(|| {
if configurations.is_empty() { if configurations.is_empty() {
format!( format!(
"Failed to load configuration `{path}`", "Failed to load configuration `{path}`",
@ -374,6 +361,9 @@ pub fn resolve_configuration(
for extend in configurations { for extend in configurations {
configuration = configuration.combine(extend); configuration = configuration.combine(extend);
} }
let configuration = configuration.apply_fallbacks(origin, initial_config_path);
Ok(transformer.transform(configuration)) Ok(transformer.transform(configuration))
} }
@ -944,7 +934,10 @@ mod tests {
use path_absolutize::Absolutize; use path_absolutize::Absolutize;
use tempfile::TempDir; 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::configuration::Configuration;
use crate::pyproject::find_settings_toml; use crate::pyproject::find_settings_toml;
@ -1131,4 +1124,47 @@ mod tests {
&make_exclusion(exclude), &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(())
}
} }