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", "thiserror 2.0.12",
"toml", "toml",
"tracing", "tracing",
"tracing-log",
"tracing-subscriber", "tracing-subscriber",
] ]

View file

@ -154,6 +154,7 @@ toml = { version = "0.8.11" }
tracing = { version = "0.1.40" } tracing = { version = "0.1.40" }
tracing-flame = { version = "0.2.0" } tracing-flame = { version = "0.2.0" }
tracing-indicatif = { version = "0.3.6" } tracing-indicatif = { version = "0.3.6" }
tracing-log = { version = "0.2.0" }
tracing-subscriber = { version = "0.3.18", default-features = false, features = [ tracing-subscriber = { version = "0.3.18", default-features = false, features = [
"env-filter", "env-filter",
"fmt", "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())?; set_up_logging(global_options.log_level())?;
}
match command { match command {
Command::Version { output_format } => { Command::Version { output_format } => {

View file

@ -5,12 +5,14 @@ use log::debug;
use path_absolutize::path_dedot; use path_absolutize::path_dedot;
use ruff_workspace::configuration::Configuration; use ruff_workspace::configuration::Configuration;
use ruff_workspace::pyproject; use ruff_workspace::pyproject::{self, find_fallback_target_version};
use ruff_workspace::resolver::{ use ruff_workspace::resolver::{
resolve_root_settings, ConfigurationTransformer, PyprojectConfig, PyprojectDiscoveryStrategy, resolve_root_settings, ConfigurationOrigin, ConfigurationTransformer, PyprojectConfig,
Relativity, PyprojectDiscoveryStrategy,
}; };
use ruff_python_ast as ast;
use crate::args::ConfigArguments; use crate::args::ConfigArguments;
/// Resolve the relevant settings strategy and defaults for the current /// 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 // `pyproject.toml` for _all_ configuration, and resolve paths relative to the
// current working directory. (This matches ESLint's behavior.) // current working directory. (This matches ESLint's behavior.)
if let Some(pyproject) = config_arguments.config_file() { 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!( debug!(
"Using user-specified configuration file at: {}", "Using user-specified configuration file at: {}",
pyproject.display() pyproject.display()
@ -61,7 +67,8 @@ pub fn resolve(
"Using configuration file (via parent) at: {}", "Using configuration file (via parent) at: {}",
pyproject.display() 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( return Ok(PyprojectConfig::new(
PyprojectDiscoveryStrategy::Hierarchical, PyprojectDiscoveryStrategy::Hierarchical,
settings, settings,
@ -74,11 +81,35 @@ pub fn resolve(
// end up the "closest" `pyproject.toml` file for every Python file later on, so // end up the "closest" `pyproject.toml` file for every Python file later on, so
// these act as the "default" settings.) // these act as the "default" settings.)
if let Some(pyproject) = pyproject::find_user_settings_toml() { 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!( debug!(
"Using configuration file (via cwd) at: {}", "Using configuration file (via cwd) at: {}",
pyproject.display() 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( return Ok(PyprojectConfig::new(
PyprojectDiscoveryStrategy::Hierarchical, PyprojectDiscoveryStrategy::Hierarchical,
settings, settings,
@ -91,7 +122,24 @@ pub fn resolve(
// "closest" `pyproject.toml` file for every Python file later on, so these act // "closest" `pyproject.toml` file for every Python file later on, so these act
// as the "default" settings.) // as the "default" settings.)
debug!("Using Ruff 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)?; let settings = config.into_settings(&path_dedot::CWD)?;
Ok(PyprojectConfig::new( Ok(PyprojectConfig::new(
PyprojectDiscoveryStrategy::Hierarchical, PyprojectDiscoveryStrategy::Hierarchical,

File diff suppressed because it is too large Load diff

View file

@ -40,6 +40,7 @@ shellexpand = { workspace = true }
thiserror = { workspace = true } thiserror = { workspace = true }
toml = { workspace = true } toml = { workspace = true }
tracing = { workspace = true } tracing = { workspace = true }
tracing-log = { workspace = true }
tracing-subscriber = { workspace = true, features = ["chrono"] } tracing-subscriber = { workspace = true, features = ["chrono"] }
[dev-dependencies] [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) tracing::subscriber::set_global_default(subscriber)
.expect("should be able to 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. /// 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::GlobPath;
use ruff_linter::{settings::types::FilePattern, settings::types::PreviewMode}; 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::resolver::match_exclusion;
use ruff_workspace::Settings; use ruff_workspace::Settings;
use ruff_workspace::{ use ruff_workspace::{
configuration::{Configuration, FormatConfiguration, LintConfiguration, RuleSelection}, configuration::{Configuration, FormatConfiguration, LintConfiguration, RuleSelection},
pyproject::{find_user_settings_toml, settings_toml}, pyproject::{find_user_settings_toml, settings_toml},
resolver::{ConfigurationTransformer, Relativity}, resolver::ConfigurationTransformer,
}; };
use crate::session::settings::{ use crate::session::settings::{
@ -64,12 +65,36 @@ impl RuffSettings {
/// In the absence of a valid configuration file, it gracefully falls back to /// In the absence of a valid configuration file, it gracefully falls back to
/// editor-only settings. /// editor-only settings.
pub(crate) fn fallback(editor_settings: &ResolvedEditorSettings, root: &Path) -> RuffSettings { 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() find_user_settings_toml()
.and_then(|user_settings| { .and_then(|user_settings| {
tracing::debug!(
"Loading settings from user configuration file: `{}`",
user_settings.display()
);
ruff_workspace::resolver::resolve_root_settings( ruff_workspace::resolver::resolve_root_settings(
&user_settings, &user_settings,
Relativity::Cwd, &FallbackTransformer {
&EditorConfigurationTransformer(editor_settings, root), inner: EditorConfigurationTransformer(editor_settings, root),
},
ruff_workspace::resolver::ConfigurationOrigin::UserSettings,
) )
.ok() .ok()
.map(|settings| RuffSettings { .map(|settings| RuffSettings {
@ -77,21 +102,45 @@ impl RuffSettings {
settings, 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 /// Constructs [`RuffSettings`] by merging the editor-defined settings with the
/// default configuration. /// default configuration.
fn editor_only(editor_settings: &ResolvedEditorSettings, root: &Path) -> RuffSettings { fn editor_only(editor_settings: &ResolvedEditorSettings, root: &Path) -> RuffSettings {
let settings = EditorConfigurationTransformer(editor_settings, root) Self::with_editor_settings(editor_settings, root, Configuration::default())
.transform(Configuration::default()) .expect("editor configuration should merge successfully with default configuration")
.into_settings(root) }
.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, path: None,
settings, settings,
} })
} }
} }
@ -140,10 +189,11 @@ impl RuffSettingsIndex {
Ok(Some(pyproject)) => { Ok(Some(pyproject)) => {
match ruff_workspace::resolver::resolve_root_settings( match ruff_workspace::resolver::resolve_root_settings(
&pyproject, &pyproject,
Relativity::Parent,
&EditorConfigurationTransformer(editor_settings, root), &EditorConfigurationTransformer(editor_settings, root),
ruff_workspace::resolver::ConfigurationOrigin::Ancestor,
) { ) {
Ok(settings) => { Ok(settings) => {
tracing::debug!("Loaded settings from: `{}`", pyproject.display());
respect_gitignore = Some(settings.file_resolver.respect_gitignore); respect_gitignore = Some(settings.file_resolver.respect_gitignore);
index.insert( index.insert(
@ -264,10 +314,15 @@ impl RuffSettingsIndex {
Ok(Some(pyproject)) => { Ok(Some(pyproject)) => {
match ruff_workspace::resolver::resolve_root_settings( match ruff_workspace::resolver::resolve_root_settings(
&pyproject, &pyproject,
Relativity::Parent,
&EditorConfigurationTransformer(editor_settings, root), &EditorConfigurationTransformer(editor_settings, root),
ruff_workspace::resolver::ConfigurationOrigin::Ancestor,
) { ) {
Ok(settings) => { Ok(settings) => {
tracing::debug!(
"Loaded settings from: `{}` for `{}`",
pyproject.display(),
directory.display()
);
index.write().unwrap().insert( index.write().unwrap().insert(
directory, directory,
Arc::new(RuffSettings { Arc::new(RuffSettings {
@ -437,8 +492,8 @@ impl ConfigurationTransformer for EditorConfigurationTransformer<'_> {
fn open_configuration_file(config_path: &Path) -> crate::Result<Configuration> { fn open_configuration_file(config_path: &Path) -> crate::Result<Configuration> {
ruff_workspace::resolver::resolve_configuration( ruff_workspace::resolver::resolve_configuration(
config_path, config_path,
Relativity::Cwd,
&IdentityTransformer, &IdentityTransformer,
ruff_workspace::resolver::ConfigurationOrigin::UserSpecified,
) )
} }

View file

@ -42,18 +42,18 @@ impl Pyproject {
/// Parse a `ruff.toml` file. /// Parse a `ruff.toml` file.
fn parse_ruff_toml<P: AsRef<Path>>(path: P) -> Result<Options> { fn parse_ruff_toml<P: AsRef<Path>>(path: P) -> Result<Options> {
let contents = std::fs::read_to_string(path.as_ref()) let path = path.as_ref();
.with_context(|| format!("Failed to read {}", path.as_ref().display()))?; let contents = std::fs::read_to_string(path)
toml::from_str(&contents) .with_context(|| format!("Failed to read {}", path.display()))?;
.with_context(|| format!("Failed to parse {}", path.as_ref().display())) toml::from_str(&contents).with_context(|| format!("Failed to parse {}", path.display()))
} }
/// Parse a `pyproject.toml` file. /// Parse a `pyproject.toml` file.
fn parse_pyproject_toml<P: AsRef<Path>>(path: P) -> Result<Pyproject> { fn parse_pyproject_toml<P: AsRef<Path>>(path: P) -> Result<Pyproject> {
let contents = std::fs::read_to_string(path.as_ref()) let path = path.as_ref();
.with_context(|| format!("Failed to read {}", path.as_ref().display()))?; let contents = std::fs::read_to_string(path)
toml::from_str(&contents) .with_context(|| format!("Failed to read {}", path.display()))?;
.with_context(|| format!("Failed to parse {}", path.as_ref().display())) toml::from_str(&contents).with_context(|| format!("Failed to parse {}", path.display()))
} }
/// Return `true` if a `pyproject.toml` contains a `[tool.ruff]` section. /// 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 /// Return the path to the `pyproject.toml` or `ruff.toml` file in a given
/// directory. /// directory.
pub fn settings_toml<P: AsRef<Path>>(path: P) -> Result<Option<PathBuf>> { pub fn settings_toml<P: AsRef<Path>>(path: P) -> Result<Option<PathBuf>> {
let path = path.as_ref();
// Check for `.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() { if ruff_toml.is_file() {
return Ok(Some(ruff_toml)); return Ok(Some(ruff_toml));
} }
// Check for `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() { if ruff_toml.is_file() {
return Ok(Some(ruff_toml)); return Ok(Some(ruff_toml));
} }
// Check for `pyproject.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)? { if pyproject_toml.is_file() && ruff_enabled(&pyproject_toml)? {
return Ok(Some(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) 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 /// Find the path to the user-specific `pyproject.toml` or `ruff.toml`, if it
/// exists. /// exists.
#[cfg(not(target_arch = "wasm32"))] #[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. /// Load `Options` from a `pyproject.toml` or `ruff.toml` file.
pub(super) fn load_options<P: AsRef<Path>>(path: P) -> Result<Options> { pub(super) fn load_options<P: AsRef<Path>>(
if path.as_ref().ends_with("pyproject.toml") { path: P,
let pyproject = parse_pyproject_toml(&path)?; 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 let mut ruff = pyproject
.tool .tool
.and_then(|tool| tool.ruff) .and_then(|tool| tool.ruff)
@ -157,16 +173,55 @@ pub(super) fn load_options<P: AsRef<Path>>(path: P) -> Result<Options> {
} }
Ok(ruff) Ok(ruff)
} else { } else {
let ruff = parse_ruff_toml(path); let mut ruff = parse_ruff_toml(path);
if let Ok(ruff) = &ruff { if let Ok(ref mut ruff) = ruff {
if ruff.target_version.is_none() { 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 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. /// Infer the minimum supported [`PythonVersion`] from a `requires-python` specifier.
fn get_minimum_supported_version(requires_version: &VersionSpecifiers) -> Option<PythonVersion> { fn get_minimum_supported_version(requires_version: &VersionSpecifiers) -> Option<PythonVersion> {
/// Truncate a version to its major and minor components. /// 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) 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::settings_toml; use crate::pyproject::{settings_toml, TargetVersionStrategy};
use crate::settings::Settings; use crate::settings::Settings;
use crate::{pyproject, FileResolverSettings}; use crate::{pyproject, FileResolverSettings};
@ -301,9 +301,10 @@ pub trait ConfigurationTransformer {
// resolving the "default" configuration). // resolving the "default" configuration).
pub fn resolve_configuration( pub fn resolve_configuration(
pyproject: &Path, pyproject: &Path,
relativity: Relativity,
transformer: &dyn ConfigurationTransformer, transformer: &dyn ConfigurationTransformer,
origin: ConfigurationOrigin,
) -> Result<Configuration> { ) -> Result<Configuration> {
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(pyproject));
while let Some(path) = next { while let Some(path) = next {
@ -319,7 +320,19 @@ pub fn resolve_configuration(
} }
// Resolve the current path. // 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}`",
@ -368,10 +381,12 @@ pub fn resolve_configuration(
/// `pyproject.toml`. /// `pyproject.toml`.
fn resolve_scoped_settings<'a>( fn resolve_scoped_settings<'a>(
pyproject: &'a Path, pyproject: &'a Path,
relativity: Relativity,
transformer: &dyn ConfigurationTransformer, transformer: &dyn ConfigurationTransformer,
origin: ConfigurationOrigin,
) -> Result<(&'a Path, Settings)> { ) -> 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 project_root = relativity.resolve(pyproject);
let settings = configuration.into_settings(project_root)?; let settings = configuration.into_settings(project_root)?;
Ok((project_root, settings)) Ok((project_root, settings))
@ -381,13 +396,37 @@ fn resolve_scoped_settings<'a>(
/// configuration with the given [`ConfigurationTransformer`]. /// configuration with the given [`ConfigurationTransformer`].
pub fn resolve_root_settings( pub fn resolve_root_settings(
pyproject: &Path, pyproject: &Path,
relativity: Relativity,
transformer: &dyn ConfigurationTransformer, transformer: &dyn ConfigurationTransformer,
origin: ConfigurationOrigin,
) -> Result<Settings> { ) -> Result<Settings> {
let (_project_root, settings) = resolve_scoped_settings(pyproject, relativity, transformer)?; let (_project_root, settings) = resolve_scoped_settings(pyproject, transformer, origin)?;
Ok(settings) 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. /// Find all Python (`.py`, `.pyi` and `.ipynb` files) in a set of paths.
pub fn python_files_in_path<'a>( pub fn python_files_in_path<'a>(
paths: &[PathBuf], paths: &[PathBuf],
@ -411,8 +450,11 @@ pub fn python_files_in_path<'a>(
for ancestor in path.ancestors() { for ancestor in path.ancestors() {
if seen.insert(ancestor) { if seen.insert(ancestor) {
if let Some(pyproject) = settings_toml(ancestor)? { if let Some(pyproject) = settings_toml(ancestor)? {
let (root, settings) = let (root, settings) = resolve_scoped_settings(
resolve_scoped_settings(&pyproject, Relativity::Parent, transformer)?; &pyproject,
transformer,
ConfigurationOrigin::Ancestor,
)?;
resolver.add(root, settings); resolver.add(root, settings);
// We found the closest configuration. // We found the closest configuration.
break; break;
@ -564,8 +606,8 @@ impl ParallelVisitor for PythonFilesVisitor<'_, '_> {
match settings_toml(entry.path()) { match settings_toml(entry.path()) {
Ok(Some(pyproject)) => match resolve_scoped_settings( Ok(Some(pyproject)) => match resolve_scoped_settings(
&pyproject, &pyproject,
Relativity::Parent,
self.transformer, self.transformer,
ConfigurationOrigin::Ancestor,
) { ) {
Ok((root, settings)) => { Ok((root, settings)) => {
self.global.resolver.write().unwrap().add(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() { for ancestor in path.ancestors() {
if let Some(pyproject) = settings_toml(ancestor)? { if let Some(pyproject) = settings_toml(ancestor)? {
let (root, settings) = let (root, settings) =
resolve_scoped_settings(&pyproject, Relativity::Parent, transformer)?; resolve_scoped_settings(&pyproject, transformer, ConfigurationOrigin::Unknown)?;
resolver.add(root, settings); resolver.add(root, settings);
break; break;
} }
@ -883,7 +925,7 @@ mod tests {
use crate::pyproject::find_settings_toml; use crate::pyproject::find_settings_toml;
use crate::resolver::{ use crate::resolver::{
is_file_excluded, match_exclusion, python_files_in_path, resolve_root_settings, is_file_excluded, match_exclusion, python_files_in_path, resolve_root_settings,
ConfigurationTransformer, PyprojectConfig, PyprojectDiscoveryStrategy, Relativity, ConfigurationOrigin, ConfigurationTransformer, PyprojectConfig, PyprojectDiscoveryStrategy,
ResolvedFile, Resolver, ResolvedFile, Resolver,
}; };
use crate::settings::Settings; use crate::settings::Settings;
@ -904,8 +946,8 @@ mod tests {
PyprojectDiscoveryStrategy::Hierarchical, PyprojectDiscoveryStrategy::Hierarchical,
resolve_root_settings( resolve_root_settings(
&find_settings_toml(&package_root)?.unwrap(), &find_settings_toml(&package_root)?.unwrap(),
Relativity::Parent,
&NoOpTransformer, &NoOpTransformer,
ConfigurationOrigin::Ancestor,
)?, )?,
None, None,
); );