Add per-file-target-version option (#16257)

## Summary

This PR is another step in preparing to detect syntax errors in the
parser. It introduces the new `per-file-target-version` top-level
configuration option, which holds a mapping of compiled glob patterns to
Python versions. I intend to use the
`LinterSettings::resolve_target_version` method here to pass to the
parser:


f50849aeef/crates/ruff_linter/src/linter.rs (L491-L493)

## Test Plan

I added two new CLI tests to show that the `per-file-target-version` is
respected in both the formatter and the linter.
This commit is contained in:
Brent Westbrook 2025-02-24 08:47:13 -05:00 committed by GitHub
parent 42a5f5ef6a
commit e7a6c19e3a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
78 changed files with 820 additions and 274 deletions

View file

@ -9,7 +9,7 @@ use std::num::{NonZeroU16, NonZeroU8};
use std::path::{Path, PathBuf};
use std::str::FromStr;
use anyhow::{anyhow, Result};
use anyhow::{anyhow, Context, Result};
use glob::{glob, GlobError, Paths, PatternError};
use itertools::Itertools;
use regex::Regex;
@ -29,8 +29,9 @@ use ruff_linter::rules::{flake8_import_conventions, isort, pycodestyle};
use ruff_linter::settings::fix_safety_table::FixSafetyTable;
use ruff_linter::settings::rule_table::RuleTable;
use ruff_linter::settings::types::{
CompiledPerFileIgnoreList, ExtensionMapping, FilePattern, FilePatternSet, OutputFormat,
PerFileIgnore, PreviewMode, RequiredVersion, UnsafeFixes,
CompiledPerFileIgnoreList, CompiledPerFileTargetVersionList, ExtensionMapping, FilePattern,
FilePatternSet, OutputFormat, PerFileIgnore, PerFileTargetVersion, PreviewMode,
RequiredVersion, UnsafeFixes,
};
use ruff_linter::settings::{LinterSettings, DEFAULT_SELECTORS, DUMMY_VARIABLE_RGX, TASK_TAGS};
use ruff_linter::{
@ -138,6 +139,7 @@ pub struct Configuration {
pub namespace_packages: Option<Vec<PathBuf>>,
pub src: Option<Vec<PathBuf>>,
pub target_version: Option<ast::PythonVersion>,
pub per_file_target_version: Option<Vec<PerFileTargetVersion>>,
// Global formatting options
pub line_length: Option<LineLength>,
@ -174,11 +176,17 @@ impl Configuration {
PreviewMode::Enabled => ruff_python_formatter::PreviewMode::Enabled,
};
let per_file_target_version = CompiledPerFileTargetVersionList::resolve(
self.per_file_target_version.unwrap_or_default(),
)
.context("failed to resolve `per-file-target-version` table")?;
let formatter = FormatterSettings {
exclude: FilePatternSet::try_from_iter(format.exclude.unwrap_or_default())?,
extension: self.extension.clone().unwrap_or_default(),
preview: format_preview,
target_version,
unresolved_target_version: target_version,
per_file_target_version: per_file_target_version.clone(),
line_width: self
.line_length
.map_or(format_defaults.line_width, |length| {
@ -278,7 +286,8 @@ impl Configuration {
exclude: FilePatternSet::try_from_iter(lint.exclude.unwrap_or_default())?,
extension: self.extension.unwrap_or_default(),
preview: lint_preview,
target_version,
unresolved_target_version: target_version,
per_file_target_version,
project_root: project_root.to_path_buf(),
allowed_confusables: lint
.allowed_confusables
@ -533,6 +542,18 @@ impl Configuration {
.map(|src| resolve_src(&src, project_root))
.transpose()?,
target_version: options.target_version.map(ast::PythonVersion::from),
per_file_target_version: options.per_file_target_version.map(|versions| {
versions
.into_iter()
.map(|(pattern, version)| {
PerFileTargetVersion::new(
pattern,
ast::PythonVersion::from(version),
Some(project_root),
)
})
.collect()
}),
// `--extension` is a hidden command-line argument that isn't supported in configuration
// files at present.
extension: None,
@ -580,6 +601,9 @@ impl Configuration {
show_fixes: self.show_fixes.or(config.show_fixes),
src: self.src.or(config.src),
target_version: self.target_version.or(config.target_version),
per_file_target_version: self
.per_file_target_version
.or(config.per_file_target_version),
preview: self.preview.or(config.preview),
extension: self.extension.or(config.extension),

View file

@ -333,6 +333,29 @@ pub struct Options {
)]
pub target_version: Option<PythonVersion>,
/// A list of mappings from glob-style file pattern to Python version to use when checking the
/// corresponding file(s).
///
/// This may be useful for overriding the global Python version settings in `target-version` or
/// `requires-python` for a subset of files. For example, if you have a project with a minimum
/// supported Python version of 3.9 but a subdirectory of developer scripts that want to use a
/// newer feature like the `match` statement from Python 3.10, you can use
/// `per-file-target-version` to specify `"developer_scripts/*.py" = "py310"`.
///
/// This setting is used by the linter to enforce any enabled version-specific lint rules, as
/// well as by the formatter for any version-specific formatting options, such as parenthesizing
/// context managers on Python 3.10+.
#[option(
default = "{}",
value_type = "dict[str, PythonVersion]",
scope = "per-file-target-version",
example = r#"
# Override the project-wide Python version for a developer scripts directory:
"scripts/**.py" = "py312"
"#
)]
pub per_file_target_version: Option<FxHashMap<String, PythonVersion>>,
/// The directories to consider when resolving first- vs. third-party
/// imports.
///

View file

@ -4,11 +4,12 @@ use ruff_formatter::{FormatOptions, IndentStyle, IndentWidth, LineWidth};
use ruff_graph::AnalyzeSettings;
use ruff_linter::display_settings;
use ruff_linter::settings::types::{
ExtensionMapping, FilePattern, FilePatternSet, OutputFormat, UnsafeFixes,
CompiledPerFileTargetVersionList, ExtensionMapping, FilePattern, FilePatternSet, OutputFormat,
UnsafeFixes,
};
use ruff_linter::settings::LinterSettings;
use ruff_macros::CacheKey;
use ruff_python_ast::PySourceType;
use ruff_python_ast::{PySourceType, PythonVersion};
use ruff_python_formatter::{
DocstringCode, DocstringCodeLineWidth, MagicTrailingComma, PreviewMode, PyFormatOptions,
QuoteStyle,
@ -164,7 +165,17 @@ pub struct FormatterSettings {
pub exclude: FilePatternSet,
pub extension: ExtensionMapping,
pub preview: PreviewMode,
pub target_version: ruff_python_ast::PythonVersion,
/// The non-path-resolved Python version specified by the `target-version` input option.
///
/// See [`FormatterSettings::resolve_target_version`] for a way to obtain the Python version for
/// a given file, while respecting the overrides in `per_file_target_version`.
pub unresolved_target_version: PythonVersion,
/// Path-specific overrides to `unresolved_target_version`.
///
/// See [`FormatterSettings::resolve_target_version`] for a way to check a given [`Path`]
/// against these patterns, while falling back to `unresolved_target_version` if none of them
/// match.
pub per_file_target_version: CompiledPerFileTargetVersionList,
pub line_width: LineWidth,
@ -182,7 +193,16 @@ pub struct FormatterSettings {
}
impl FormatterSettings {
pub fn to_format_options(&self, source_type: PySourceType, source: &str) -> PyFormatOptions {
pub fn to_format_options(
&self,
source_type: PySourceType,
source: &str,
path: Option<&Path>,
) -> PyFormatOptions {
let target_version = path
.map(|path| self.resolve_target_version(path))
.unwrap_or(self.unresolved_target_version);
let line_ending = match self.line_ending {
LineEnding::Lf => ruff_formatter::printer::LineEnding::LineFeed,
LineEnding::CrLf => ruff_formatter::printer::LineEnding::CarriageReturnLineFeed,
@ -205,7 +225,7 @@ impl FormatterSettings {
};
PyFormatOptions::from_source_type(source_type)
.with_target_version(self.target_version)
.with_target_version(target_version)
.with_indent_style(self.indent_style)
.with_indent_width(self.indent_width)
.with_quote_style(self.quote_style)
@ -216,6 +236,17 @@ impl FormatterSettings {
.with_docstring_code(self.docstring_code_format)
.with_docstring_code_line_width(self.docstring_code_line_width)
}
/// Resolve the [`PythonVersion`] to use for formatting.
///
/// This method respects the per-file version overrides in
/// [`FormatterSettings::per_file_target_version`] and falls back on
/// [`FormatterSettings::unresolved_target_version`] if none of the override patterns match.
pub fn resolve_target_version(&self, path: &Path) -> PythonVersion {
self.per_file_target_version
.is_match(path)
.unwrap_or(self.unresolved_target_version)
}
}
impl Default for FormatterSettings {
@ -225,7 +256,8 @@ impl Default for FormatterSettings {
Self {
exclude: FilePatternSet::default(),
extension: ExtensionMapping::default(),
target_version: default_options.target_version(),
unresolved_target_version: default_options.target_version(),
per_file_target_version: CompiledPerFileTargetVersionList::default(),
preview: PreviewMode::Disabled,
line_width: default_options.line_width(),
line_ending: LineEnding::Auto,
@ -247,7 +279,8 @@ impl fmt::Display for FormatterSettings {
namespace = "formatter",
fields = [
self.exclude,
self.target_version,
self.unresolved_target_version,
self.per_file_target_version,
self.preview,
self.line_width,
self.line_ending,