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

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,
);