Fallback to requires-python in certain cases when target-version is not found (#16721)

## Summary

Restores https://github.com/astral-sh/ruff/pull/16319 after it got
dropped from the 0.10 release branch :(

---------

Co-authored-by: dylwil3 <dylwil3@gmail.com>
This commit is contained in:
Micha Reiser 2025-03-14 09:36:51 +01:00 committed by GitHub
parent 2382fe1f25
commit 595565015b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 2915 additions and 66 deletions

1
Cargo.lock generated
View file

@ -3192,6 +3192,7 @@ dependencies = [
"thiserror 2.0.12",
"toml",
"tracing",
"tracing-log",
"tracing-subscriber",
]

View file

@ -154,6 +154,7 @@ toml = { version = "0.8.11" }
tracing = { version = "0.1.40" }
tracing-flame = { version = "0.2.0" }
tracing-indicatif = { version = "0.3.6" }
tracing-log = { version = "0.2.0" }
tracing-subscriber = { version = "0.3.18", default-features = false, features = [
"env-filter",
"fmt",

View file

@ -153,7 +153,11 @@ pub fn run(
}));
}
// Don't set up logging for the server command, as it has its own logging setup
// and setting the global logger can only be done once.
if !matches!(command, Command::Server { .. }) {
set_up_logging(global_options.log_level())?;
}
match command {
Command::Version { output_format } => {

View file

@ -5,12 +5,14 @@ use log::debug;
use path_absolutize::path_dedot;
use ruff_workspace::configuration::Configuration;
use ruff_workspace::pyproject;
use ruff_workspace::pyproject::{self, find_fallback_target_version};
use ruff_workspace::resolver::{
resolve_root_settings, ConfigurationTransformer, PyprojectConfig, PyprojectDiscoveryStrategy,
Relativity,
resolve_root_settings, ConfigurationOrigin, ConfigurationTransformer, PyprojectConfig,
PyprojectDiscoveryStrategy,
};
use ruff_python_ast as ast;
use crate::args::ConfigArguments;
/// Resolve the relevant settings strategy and defaults for the current
@ -35,7 +37,11 @@ pub fn resolve(
// `pyproject.toml` for _all_ configuration, and resolve paths relative to the
// current working directory. (This matches ESLint's behavior.)
if let Some(pyproject) = config_arguments.config_file() {
let settings = resolve_root_settings(pyproject, Relativity::Cwd, config_arguments)?;
let settings = resolve_root_settings(
pyproject,
config_arguments,
ConfigurationOrigin::UserSpecified,
)?;
debug!(
"Using user-specified configuration file at: {}",
pyproject.display()
@ -61,7 +67,8 @@ pub fn resolve(
"Using configuration file (via parent) at: {}",
pyproject.display()
);
let settings = resolve_root_settings(&pyproject, Relativity::Parent, config_arguments)?;
let settings =
resolve_root_settings(&pyproject, config_arguments, ConfigurationOrigin::Ancestor)?;
return Ok(PyprojectConfig::new(
PyprojectDiscoveryStrategy::Hierarchical,
settings,
@ -74,11 +81,35 @@ pub fn resolve(
// end up the "closest" `pyproject.toml` file for every Python file later on, so
// these act as the "default" settings.)
if let Some(pyproject) = pyproject::find_user_settings_toml() {
struct FallbackTransformer<'a> {
arguments: &'a ConfigArguments,
}
impl ConfigurationTransformer for FallbackTransformer<'_> {
fn transform(&self, mut configuration: Configuration) -> Configuration {
// The `requires-python` constraint from the `pyproject.toml` takes precedence
// over the `target-version` from the user configuration.
let fallback = find_fallback_target_version(&*path_dedot::CWD);
if let Some(fallback) = fallback {
debug!("Derived `target-version` from found `requires-python`: {fallback:?}");
configuration.target_version = Some(fallback.into());
}
self.arguments.transform(configuration)
}
}
debug!(
"Using configuration file (via cwd) at: {}",
pyproject.display()
);
let settings = resolve_root_settings(&pyproject, Relativity::Cwd, config_arguments)?;
let settings = resolve_root_settings(
&pyproject,
&FallbackTransformer {
arguments: config_arguments,
},
ConfigurationOrigin::UserSettings,
)?;
return Ok(PyprojectConfig::new(
PyprojectDiscoveryStrategy::Hierarchical,
settings,
@ -91,7 +122,24 @@ pub fn resolve(
// "closest" `pyproject.toml` file for every Python file later on, so these act
// as the "default" settings.)
debug!("Using Ruff default settings");
let config = config_arguments.transform(Configuration::default());
let mut config = config_arguments.transform(Configuration::default());
if config.target_version.is_none() {
// If we have arrived here we know that there was no `pyproject.toml`
// containing a `[tool.ruff]` section found in an ancestral directory.
// (This is an implicit requirement in the function
// `pyproject::find_settings_toml`.)
// However, there may be a `pyproject.toml` with a `requires-python`
// specified, and that is what we look for in this step.
let fallback = find_fallback_target_version(
stdin_filename
.as_ref()
.unwrap_or(&path_dedot::CWD.as_path()),
);
if let Some(version) = fallback {
debug!("Derived `target-version` from found `requires-python`: {version:?}");
}
config.target_version = fallback.map(ast::PythonVersion::from);
}
let settings = config.into_settings(&path_dedot::CWD)?;
Ok(PyprojectConfig::new(
PyprojectDiscoveryStrategy::Hierarchical,

File diff suppressed because it is too large Load diff

View file

@ -40,6 +40,7 @@ shellexpand = { workspace = true }
thiserror = { workspace = true }
toml = { workspace = true }
tracing = { workspace = true }
tracing-log = { workspace = true }
tracing-subscriber = { workspace = true, features = ["chrono"] }
[dev-dependencies]

View file

@ -64,6 +64,8 @@ pub(crate) fn init_logging(log_level: LogLevel, log_file: Option<&std::path::Pat
tracing::subscriber::set_global_default(subscriber)
.expect("should be able to set global default subscriber");
tracing_log::LogTracer::init().unwrap();
}
/// The log level for the server as provided by the client during initialization.

View file

@ -9,12 +9,13 @@ use ignore::{WalkBuilder, WalkState};
use ruff_linter::settings::types::GlobPath;
use ruff_linter::{settings::types::FilePattern, settings::types::PreviewMode};
use ruff_workspace::pyproject::find_fallback_target_version;
use ruff_workspace::resolver::match_exclusion;
use ruff_workspace::Settings;
use ruff_workspace::{
configuration::{Configuration, FormatConfiguration, LintConfiguration, RuleSelection},
pyproject::{find_user_settings_toml, settings_toml},
resolver::{ConfigurationTransformer, Relativity},
resolver::ConfigurationTransformer,
};
use crate::session::settings::{
@ -64,12 +65,36 @@ impl RuffSettings {
/// In the absence of a valid configuration file, it gracefully falls back to
/// editor-only settings.
pub(crate) fn fallback(editor_settings: &ResolvedEditorSettings, root: &Path) -> RuffSettings {
struct FallbackTransformer<'a> {
inner: EditorConfigurationTransformer<'a>,
}
impl ConfigurationTransformer for FallbackTransformer<'_> {
fn transform(&self, mut configuration: Configuration) -> Configuration {
let fallback = find_fallback_target_version(self.inner.1);
if let Some(fallback) = fallback {
tracing::debug!(
"Derived `target-version` from found `requires-python`: {fallback:?}"
);
configuration.target_version = Some(fallback.into());
}
self.inner.transform(configuration)
}
}
find_user_settings_toml()
.and_then(|user_settings| {
tracing::debug!(
"Loading settings from user configuration file: `{}`",
user_settings.display()
);
ruff_workspace::resolver::resolve_root_settings(
&user_settings,
Relativity::Cwd,
&EditorConfigurationTransformer(editor_settings, root),
&FallbackTransformer {
inner: EditorConfigurationTransformer(editor_settings, root),
},
ruff_workspace::resolver::ConfigurationOrigin::UserSettings,
)
.ok()
.map(|settings| RuffSettings {
@ -77,21 +102,45 @@ impl RuffSettings {
settings,
})
})
.unwrap_or_else(|| Self::editor_only(editor_settings, root))
.unwrap_or_else(|| {
let fallback = find_fallback_target_version(root);
if let Some(fallback) = fallback {
tracing::debug!(
"Derived `target-version` from found `requires-python` for fallback configuration: {fallback:?}"
);
}
let configuration = Configuration {
target_version: fallback.map(Into::into),
..Configuration::default()
};
Self::with_editor_settings(editor_settings, root, configuration).expect(
"editor configuration should merge successfully with default configuration",
)
})
}
/// Constructs [`RuffSettings`] by merging the editor-defined settings with the
/// default configuration.
fn editor_only(editor_settings: &ResolvedEditorSettings, root: &Path) -> RuffSettings {
let settings = EditorConfigurationTransformer(editor_settings, root)
.transform(Configuration::default())
.into_settings(root)
.expect("editor configuration should merge successfully with default configuration");
Self::with_editor_settings(editor_settings, root, Configuration::default())
.expect("editor configuration should merge successfully with default configuration")
}
RuffSettings {
/// Merges the `configuration` with the editor defined settings.
fn with_editor_settings(
editor_settings: &ResolvedEditorSettings,
root: &Path,
configuration: Configuration,
) -> anyhow::Result<RuffSettings> {
let settings = EditorConfigurationTransformer(editor_settings, root)
.transform(configuration)
.into_settings(root)?;
Ok(RuffSettings {
path: None,
settings,
}
})
}
}
@ -140,10 +189,11 @@ impl RuffSettingsIndex {
Ok(Some(pyproject)) => {
match ruff_workspace::resolver::resolve_root_settings(
&pyproject,
Relativity::Parent,
&EditorConfigurationTransformer(editor_settings, root),
ruff_workspace::resolver::ConfigurationOrigin::Ancestor,
) {
Ok(settings) => {
tracing::debug!("Loaded settings from: `{}`", pyproject.display());
respect_gitignore = Some(settings.file_resolver.respect_gitignore);
index.insert(
@ -264,10 +314,15 @@ impl RuffSettingsIndex {
Ok(Some(pyproject)) => {
match ruff_workspace::resolver::resolve_root_settings(
&pyproject,
Relativity::Parent,
&EditorConfigurationTransformer(editor_settings, root),
ruff_workspace::resolver::ConfigurationOrigin::Ancestor,
) {
Ok(settings) => {
tracing::debug!(
"Loaded settings from: `{}` for `{}`",
pyproject.display(),
directory.display()
);
index.write().unwrap().insert(
directory,
Arc::new(RuffSettings {
@ -437,8 +492,8 @@ impl ConfigurationTransformer for EditorConfigurationTransformer<'_> {
fn open_configuration_file(config_path: &Path) -> crate::Result<Configuration> {
ruff_workspace::resolver::resolve_configuration(
config_path,
Relativity::Cwd,
&IdentityTransformer,
ruff_workspace::resolver::ConfigurationOrigin::UserSpecified,
)
}

View file

@ -42,18 +42,18 @@ impl Pyproject {
/// Parse a `ruff.toml` file.
fn parse_ruff_toml<P: AsRef<Path>>(path: P) -> Result<Options> {
let contents = std::fs::read_to_string(path.as_ref())
.with_context(|| format!("Failed to read {}", path.as_ref().display()))?;
toml::from_str(&contents)
.with_context(|| format!("Failed to parse {}", path.as_ref().display()))
let path = path.as_ref();
let contents = std::fs::read_to_string(path)
.with_context(|| format!("Failed to read {}", path.display()))?;
toml::from_str(&contents).with_context(|| format!("Failed to parse {}", path.display()))
}
/// Parse a `pyproject.toml` file.
fn parse_pyproject_toml<P: AsRef<Path>>(path: P) -> Result<Pyproject> {
let contents = std::fs::read_to_string(path.as_ref())
.with_context(|| format!("Failed to read {}", path.as_ref().display()))?;
toml::from_str(&contents)
.with_context(|| format!("Failed to parse {}", path.as_ref().display()))
let path = path.as_ref();
let contents = std::fs::read_to_string(path)
.with_context(|| format!("Failed to read {}", path.display()))?;
toml::from_str(&contents).with_context(|| format!("Failed to parse {}", path.display()))
}
/// Return `true` if a `pyproject.toml` contains a `[tool.ruff]` section.
@ -65,20 +65,21 @@ pub fn ruff_enabled<P: AsRef<Path>>(path: P) -> Result<bool> {
/// Return the path to the `pyproject.toml` or `ruff.toml` file in a given
/// directory.
pub fn settings_toml<P: AsRef<Path>>(path: P) -> Result<Option<PathBuf>> {
let path = path.as_ref();
// Check for `.ruff.toml`.
let ruff_toml = path.as_ref().join(".ruff.toml");
let ruff_toml = path.join(".ruff.toml");
if ruff_toml.is_file() {
return Ok(Some(ruff_toml));
}
// Check for `ruff.toml`.
let ruff_toml = path.as_ref().join("ruff.toml");
let ruff_toml = path.join("ruff.toml");
if ruff_toml.is_file() {
return Ok(Some(ruff_toml));
}
// Check for `pyproject.toml`.
let pyproject_toml = path.as_ref().join("pyproject.toml");
let pyproject_toml = path.join("pyproject.toml");
if pyproject_toml.is_file() && ruff_enabled(&pyproject_toml)? {
return Ok(Some(pyproject_toml));
}
@ -97,6 +98,17 @@ pub fn find_settings_toml<P: AsRef<Path>>(path: P) -> Result<Option<PathBuf>> {
Ok(None)
}
/// Derive target version from `required-version` in `pyproject.toml`, if
/// such a file exists in an ancestor directory.
pub fn find_fallback_target_version<P: AsRef<Path>>(path: P) -> Option<PythonVersion> {
for directory in path.as_ref().ancestors() {
if let Some(fallback) = get_fallback_target_version(directory) {
return Some(fallback);
}
}
None
}
/// Find the path to the user-specific `pyproject.toml` or `ruff.toml`, if it
/// exists.
#[cfg(not(target_arch = "wasm32"))]
@ -141,9 +153,13 @@ 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) -> Result<Options> {
if path.as_ref().ends_with("pyproject.toml") {
let pyproject = parse_pyproject_toml(&path)?;
pub(super) fn load_options<P: AsRef<Path>>(
path: P,
version_strategy: &TargetVersionStrategy,
) -> Result<Options> {
let path = path.as_ref();
if path.ends_with("pyproject.toml") {
let pyproject = parse_pyproject_toml(path)?;
let mut ruff = pyproject
.tool
.and_then(|tool| tool.ruff)
@ -157,16 +173,55 @@ pub(super) fn load_options<P: AsRef<Path>>(path: P) -> Result<Options> {
}
Ok(ruff)
} else {
let ruff = parse_ruff_toml(path);
if let Ok(ruff) = &ruff {
let mut ruff = parse_ruff_toml(path);
if let Ok(ref mut ruff) = ruff {
if ruff.target_version.is_none() {
debug!("`project.requires_python` in `pyproject.toml` will not be used to set `target_version` when using `ruff.toml`.");
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;
}
}
}
}
}
ruff
}
}
/// Extract `target-version` from `pyproject.toml` in the given directory
/// if the file exists and has `requires-python`.
fn get_fallback_target_version(dir: &Path) -> Option<PythonVersion> {
let pyproject_path = dir.join("pyproject.toml");
if !pyproject_path.exists() {
return None;
}
let parsed_pyproject = parse_pyproject_toml(&pyproject_path);
let pyproject = match parsed_pyproject {
Ok(pyproject) => pyproject,
Err(err) => {
debug!("Failed to find fallback `target-version` due to: {}", err);
return None;
}
};
if let Some(project) = pyproject.project {
if let Some(requires_python) = project.requires_python {
return get_minimum_supported_version(&requires_python);
}
}
None
}
/// Infer the minimum supported [`PythonVersion`] from a `requires-python` specifier.
fn get_minimum_supported_version(requires_version: &VersionSpecifiers) -> Option<PythonVersion> {
/// Truncate a version to its major and minor components.
@ -199,6 +254,15 @@ 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;

View file

@ -23,7 +23,7 @@ use ruff_linter::package::PackageRoot;
use ruff_linter::packaging::is_package;
use crate::configuration::Configuration;
use crate::pyproject::settings_toml;
use crate::pyproject::{settings_toml, TargetVersionStrategy};
use crate::settings::Settings;
use crate::{pyproject, FileResolverSettings};
@ -301,9 +301,10 @@ pub trait ConfigurationTransformer {
// resolving the "default" configuration).
pub fn resolve_configuration(
pyproject: &Path,
relativity: Relativity,
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));
while let Some(path) = next {
@ -319,7 +320,19 @@ 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() {
format!(
"Failed to load configuration `{path}`",
@ -368,10 +381,12 @@ pub fn resolve_configuration(
/// `pyproject.toml`.
fn resolve_scoped_settings<'a>(
pyproject: &'a Path,
relativity: Relativity,
transformer: &dyn ConfigurationTransformer,
origin: ConfigurationOrigin,
) -> Result<(&'a Path, Settings)> {
let configuration = resolve_configuration(pyproject, relativity, transformer)?;
let relativity = Relativity::from(origin);
let configuration = resolve_configuration(pyproject, transformer, origin)?;
let project_root = relativity.resolve(pyproject);
let settings = configuration.into_settings(project_root)?;
Ok((project_root, settings))
@ -381,13 +396,37 @@ fn resolve_scoped_settings<'a>(
/// configuration with the given [`ConfigurationTransformer`].
pub fn resolve_root_settings(
pyproject: &Path,
relativity: Relativity,
transformer: &dyn ConfigurationTransformer,
origin: ConfigurationOrigin,
) -> Result<Settings> {
let (_project_root, settings) = resolve_scoped_settings(pyproject, relativity, transformer)?;
let (_project_root, settings) = resolve_scoped_settings(pyproject, transformer, origin)?;
Ok(settings)
}
#[derive(Debug, Clone, Copy)]
/// How the configuration is provided.
pub enum ConfigurationOrigin {
/// Origin is unknown to the caller
Unknown,
/// User specified path to specific configuration file
UserSpecified,
/// User-level configuration (e.g. in `~/.config/ruff/pyproject.toml`)
UserSettings,
/// In parent or higher ancestor directory of path
Ancestor,
}
impl From<ConfigurationOrigin> for Relativity {
fn from(value: ConfigurationOrigin) -> Self {
match value {
ConfigurationOrigin::Unknown => Self::Parent,
ConfigurationOrigin::UserSpecified => Self::Cwd,
ConfigurationOrigin::UserSettings => Self::Cwd,
ConfigurationOrigin::Ancestor => Self::Parent,
}
}
}
/// Find all Python (`.py`, `.pyi` and `.ipynb` files) in a set of paths.
pub fn python_files_in_path<'a>(
paths: &[PathBuf],
@ -411,8 +450,11 @@ pub fn python_files_in_path<'a>(
for ancestor in path.ancestors() {
if seen.insert(ancestor) {
if let Some(pyproject) = settings_toml(ancestor)? {
let (root, settings) =
resolve_scoped_settings(&pyproject, Relativity::Parent, transformer)?;
let (root, settings) = resolve_scoped_settings(
&pyproject,
transformer,
ConfigurationOrigin::Ancestor,
)?;
resolver.add(root, settings);
// We found the closest configuration.
break;
@ -564,8 +606,8 @@ impl ParallelVisitor for PythonFilesVisitor<'_, '_> {
match settings_toml(entry.path()) {
Ok(Some(pyproject)) => match resolve_scoped_settings(
&pyproject,
Relativity::Parent,
self.transformer,
ConfigurationOrigin::Ancestor,
) {
Ok((root, settings)) => {
self.global.resolver.write().unwrap().add(root, settings);
@ -699,7 +741,7 @@ pub fn python_file_at_path(
for ancestor in path.ancestors() {
if let Some(pyproject) = settings_toml(ancestor)? {
let (root, settings) =
resolve_scoped_settings(&pyproject, Relativity::Parent, transformer)?;
resolve_scoped_settings(&pyproject, transformer, ConfigurationOrigin::Unknown)?;
resolver.add(root, settings);
break;
}
@ -883,7 +925,7 @@ mod tests {
use crate::pyproject::find_settings_toml;
use crate::resolver::{
is_file_excluded, match_exclusion, python_files_in_path, resolve_root_settings,
ConfigurationTransformer, PyprojectConfig, PyprojectDiscoveryStrategy, Relativity,
ConfigurationOrigin, ConfigurationTransformer, PyprojectConfig, PyprojectDiscoveryStrategy,
ResolvedFile, Resolver,
};
use crate::settings::Settings;
@ -904,8 +946,8 @@ mod tests {
PyprojectDiscoveryStrategy::Hierarchical,
resolve_root_settings(
&find_settings_toml(&package_root)?.unwrap(),
Relativity::Parent,
&NoOpTransformer,
ConfigurationOrigin::Ancestor,
)?,
None,
);