Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment thread
denyszhak marked this conversation as resolved.
linter.unresolved_target_version = 3.11
linter.per_file_target_version = {}
linter.preview = disabled
linter.explicit_preview_rules = false
Expand Down Expand Up @@ -265,7 +265,7 @@ linter.ruff.strictly_empty_init_modules = 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
Expand All @@ -280,7 +280,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 = {}
Expand Down
2 changes: 1 addition & 1 deletion crates/ruff_linter/src/settings/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -504,7 +504,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<PythonVersion>);

impl TargetVersion {
Expand Down
30 changes: 30 additions & 0 deletions crates/ruff_workspace/src/configuration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ use std::path::{Path, PathBuf};
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;
Expand Down Expand Up @@ -53,6 +54,8 @@ use crate::options::{
PydoclintOptions, PydocstyleOptions, PyflakesOptions, PylintOptions, RuffOptions,
validate_required_version,
};
use crate::pyproject;
use crate::resolver::ConfigurationOrigin;
use crate::settings::{
EXCLUDE, FileResolverSettings, FormatterSettings, INCLUDE, INCLUDE_PREVIEW, LineEnding,
Settings,
Expand Down Expand Up @@ -619,6 +622,33 @@ 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())
});
}
// If the origin is UserSettings, we need more information
// to determine where to search for a fallback target version.
// - If Ruff is being invoked via the CLI, then we search in
// the cwd.
// - If Ruff is being invoked via the server, then we search
// in the editor's workspace root.
//
// This logic is implemented manually, at the time of this
// writing 2026-01-30, in `ruff::resolve::resolve` and
// `ruff_server::session::index::ruff_settings::RuffSettings::fallback`, respectively.
self
}
}

#[derive(Clone, Debug, Default)]
Expand Down
42 changes: 7 additions & 35 deletions crates/ruff_workspace/src/pyproject.rs
Original file line number Diff line number Diff line change
Expand Up @@ -181,10 +181,7 @@ pub fn find_user_settings_toml() -> Option<PathBuf> {
}

/// Load `Options` from a `pyproject.toml` or `ruff.toml` file.
pub(super) fn load_options<P: AsRef<Path>>(
path: P,
version_strategy: &TargetVersionStrategy,
) -> Result<Options> {
pub(super) fn load_options<P: AsRef<Path>>(path: P) -> Result<Options> {
let path = path.as_ref();
if path.ends_with("pyproject.toml") {
let pyproject = parse_pyproject_toml(path)?;
Expand All @@ -201,31 +198,15 @@ pub(super) fn load_options<P: AsRef<Path>>(
}
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
}
}

Expand Down Expand Up @@ -286,15 +267,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;
Expand Down
72 changes: 54 additions & 18 deletions crates/ruff_workspace/src/resolver.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};

Expand Down Expand Up @@ -315,13 +315,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<Configuration> {
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!(
Expand All @@ -334,20 +334,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}`",
Expand Down Expand Up @@ -389,6 +376,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))
}

Expand Down Expand Up @@ -963,7 +953,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;
Expand Down Expand Up @@ -1150,4 +1143,47 @@ mod tests {
&make_exclusion(exclude),
));
}

#[test]
fn extend_respects_target_version() -> Result<()> {
Comment thread
denyszhak marked this conversation as resolved.
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(())
}
}