This commit is contained in:
celestialorb 2025-12-16 16:39:34 -05:00 committed by GitHub
commit 38e1a49916
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 506 additions and 45 deletions

View File

@ -246,12 +246,40 @@ pub struct CheckCommand {
/// Output serialization format for violations. /// Output serialization format for violations.
/// The default serialization format is "full". /// The default serialization format is "full".
///
/// Note: For multiple output formats, use --output-file with format:target syntax instead.
/// Example: --output-file gitlab:gl-code-quality.json --output-file full:stdout
#[arg(long, value_enum, env = "RUFF_OUTPUT_FORMAT")] #[arg(long, value_enum, env = "RUFF_OUTPUT_FORMAT")]
pub output_format: Option<OutputFormat>, pub output_format: Option<OutputFormat>,
/// Specify file to write the linter output to (default: stdout). /// Specify output destination(s) using format:target syntax.
#[arg(short, long, env = "RUFF_OUTPUT_FILE")] /// Can be specified multiple times for multiple outputs.
pub output_file: Option<PathBuf>, /// Also supports comma-separated values: --output-file gitlab:file.json,full:stdout
///
/// Examples:
/// --output-file gitlab:gl-code-quality.json
/// --output-file full:stdout
/// --output-file concise:stderr
/// --output-file gitlab:gl-code-quality.json --output-file full:stdout
/// --output-file gitlab:file.json,full:stdout
///
/// Environment variable `RUFF_OUTPUT_FILE` also supports comma-separated values:
/// RUFF_OUTPUT_FILE="gitlab:gl-code-quality.json,full:stdout"
///
/// Targets can be: stdout, stderr, or a file path.
/// Formats: full, concise, grouped, json, json-lines, junit, github, gitlab, pylint, rdjson, azure, sarif
///
/// Legacy: If used with --output-format (without format: prefix), writes that format to the specified file.
#[arg(
short,
long,
action = clap::ArgAction::Append,
value_parser = OutputTargetPairParser,
env = "RUFF_OUTPUT_FILE",
value_delimiter = ',',
value_name = "FORMAT:TARGET"
)]
pub output_file: Vec<OutputTargetPairOrPath>,
/// The minimum Python version that should be supported. /// The minimum Python version that should be supported.
#[arg(long, value_enum)] #[arg(long, value_enum)]
pub target_version: Option<PythonVersion>, pub target_version: Option<PythonVersion>,
@ -746,6 +774,7 @@ impl CheckCommand {
ignore_noqa: self.ignore_noqa, ignore_noqa: self.ignore_noqa,
no_cache: self.no_cache, no_cache: self.no_cache,
output_file: self.output_file, output_file: self.output_file,
output_format: self.output_format,
show_files: self.show_files, show_files: self.show_files,
show_settings: self.show_settings, show_settings: self.show_settings,
statistics: self.statistics, statistics: self.statistics,
@ -898,6 +927,136 @@ impl InvalidConfigFlagReason {
} }
} }
/// Represents a destination for output (stdout, stderr, or a file path).
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum OutputTarget {
Stdout,
Stderr,
File(PathBuf),
}
/// Represents a format:target pair for output (e.g., "gitlab:gl-code-quality.json" or "full:stdout").
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct OutputTargetPair {
pub format: OutputFormat,
pub target: OutputTarget,
}
/// Parser for output target pairs in the format "FORMAT:target".
/// Examples:
/// - "gitlab:gl-code-quality.json" -> `OutputFormat::Gitlab`, `OutputTarget::File("gl-code-quality.json`")
/// - "full:stdout" -> `OutputFormat::Full`, `OutputTarget::Stdout`
/// - "concise:stderr" -> `OutputFormat::Concise`, `OutputTarget::Stderr`
#[derive(Clone)]
pub struct OutputTargetPairParser;
/// Represents either a format:target pair or a plain path (for backward compatibility).
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum OutputTargetPairOrPath {
Pair(OutputTargetPair),
Path(PathBuf),
}
impl TypedValueParser for OutputTargetPairParser {
type Value = OutputTargetPairOrPath;
fn parse_ref(
&self,
cmd: &clap::Command,
arg: Option<&clap::Arg>,
value: &std::ffi::OsStr,
) -> Result<Self::Value, clap::Error> {
let value = value
.to_str()
.ok_or_else(|| clap::Error::new(clap::error::ErrorKind::InvalidUtf8))?;
// If it contains a colon, parse as format:target
if let Some((format_str, target_str)) = value.split_once(':') {
// Parse the format using clap's ValueEnum parsing
// We need to handle both kebab-case (from clap) and the display format
let format = match format_str {
"full" => OutputFormat::Full,
"concise" => OutputFormat::Concise,
"grouped" => OutputFormat::Grouped,
"json" => OutputFormat::Json,
"json-lines" | "json_lines" => OutputFormat::JsonLines,
"junit" => OutputFormat::Junit,
"github" => OutputFormat::Github,
"gitlab" => OutputFormat::Gitlab,
"pylint" => OutputFormat::Pylint,
"rdjson" => OutputFormat::Rdjson,
"azure" => OutputFormat::Azure,
"sarif" => OutputFormat::Sarif,
_ => {
let mut error =
clap::Error::new(clap::error::ErrorKind::ValueValidation).with_cmd(cmd);
if let Some(arg) = arg {
error.insert(
clap::error::ContextKind::InvalidArg,
clap::error::ContextValue::String(arg.to_string()),
);
}
error.insert(
clap::error::ContextKind::InvalidValue,
clap::error::ContextValue::String(format!(
"Invalid output format '{}'. Valid formats: {}",
format_str,
[
"full",
"concise",
"grouped",
"json",
"json-lines",
"junit",
"github",
"gitlab",
"pylint",
"rdjson",
"azure",
"sarif"
]
.join(", ")
)),
);
return Err(error);
}
};
let target = match target_str {
"stdout" => OutputTarget::Stdout,
"stderr" => OutputTarget::Stderr,
path => {
// Expand environment variables and tildes
let expanded_path = shellexpand::full(path)
.map(|expanded| PathBuf::from(expanded.as_ref()))
.unwrap_or_else(|_| PathBuf::from(path));
OutputTarget::File(expanded_path)
}
};
Ok(OutputTargetPairOrPath::Pair(OutputTargetPair {
format,
target,
}))
} else {
// No colon: treat as a plain path (for backward compatibility with --output-format)
// Expand environment variables and tildes
let expanded_path = shellexpand::full(value)
.map(|expanded| PathBuf::from(expanded.as_ref()))
.unwrap_or_else(|_| PathBuf::from(value));
Ok(OutputTargetPairOrPath::Path(expanded_path))
}
}
}
impl ValueParserFactory for OutputTargetPairOrPath {
type Parser = OutputTargetPairParser;
fn value_parser() -> Self::Parser {
OutputTargetPairParser
}
}
/// Enumeration to represent a single `--config` argument /// Enumeration to represent a single `--config` argument
/// passed via the CLI. /// passed via the CLI.
/// ///
@ -1080,7 +1239,8 @@ pub struct CheckArguments {
pub files: Vec<PathBuf>, pub files: Vec<PathBuf>,
pub ignore_noqa: bool, pub ignore_noqa: bool,
pub no_cache: bool, pub no_cache: bool,
pub output_file: Option<PathBuf>, pub output_file: Vec<OutputTargetPairOrPath>,
pub output_format: Option<OutputFormat>,
pub show_files: bool, pub show_files: bool,
pub show_settings: bool, pub show_settings: bool,
pub statistics: bool, pub statistics: bool,
@ -1088,6 +1248,56 @@ pub struct CheckArguments {
pub watch: bool, pub watch: bool,
} }
impl CheckArguments {
/// Resolve output targets, handling backward compatibility with old --output-format and --output-file flags.
/// Returns a list of format:target pairs.
pub fn resolve_output_targets(&self) -> anyhow::Result<Vec<OutputTargetPair>> {
let mut targets = Vec::new();
// Process output_file entries
for entry in &self.output_file {
match entry {
OutputTargetPairOrPath::Pair(pair) => {
targets.push(pair.clone());
}
OutputTargetPairOrPath::Path(path) => {
// Backward compatibility: if --output-format is set, use it with this path
if let Some(format) = self.output_format {
targets.push(OutputTargetPair {
format,
target: OutputTarget::File(path.clone()),
});
} else {
return Err(anyhow::anyhow!(
"Cannot use plain file path '{}' without --output-format. Use format:target syntax instead (e.g., 'full:{}').",
path.display(),
path.display()
));
}
}
}
}
// If no output_file entries but --output-format is specified, default to stdout
if targets.is_empty() {
if let Some(format) = self.output_format {
targets.push(OutputTargetPair {
format,
target: OutputTarget::Stdout,
});
} else {
// Default: full format to stdout
targets.push(OutputTargetPair {
format: OutputFormat::Full,
target: OutputTarget::Stdout,
});
}
}
Ok(targets)
}
}
/// CLI settings that are distinct from configuration (commands, lists of files, /// CLI settings that are distinct from configuration (commands, lists of files,
/// etc.). /// etc.).
#[expect(clippy::struct_excessive_bools)] #[expect(clippy::struct_excessive_bools)]

View File

@ -21,7 +21,7 @@ use ruff_linter::{fs, warn_user, warn_user_once};
use ruff_workspace::Settings; use ruff_workspace::Settings;
use crate::args::{ use crate::args::{
AnalyzeCommand, AnalyzeGraphCommand, Args, CheckCommand, Command, FormatCommand, AnalyzeCommand, AnalyzeGraphCommand, Args, CheckCommand, Command, FormatCommand, OutputTarget,
}; };
use crate::printer::{Flags as PrinterFlags, Printer}; use crate::printer::{Flags as PrinterFlags, Printer};
@ -240,23 +240,45 @@ pub fn check(args: CheckCommand, global_options: GlobalConfigArgs) -> Result<Exi
// files are present, or files are injected from outside of the hierarchy. // files are present, or files are injected from outside of the hierarchy.
let pyproject_config = resolve::resolve(&config_arguments, cli.stdin_filename.as_deref())?; let pyproject_config = resolve::resolve(&config_arguments, cli.stdin_filename.as_deref())?;
let mut writer: Box<dyn Write> = match cli.output_file { // Resolve output targets (handles backward compatibility)
Some(path) if !cli.watch => { let output_targets = cli.resolve_output_targets()?;
colored::control::set_override(false);
if let Some(parent) = path.parent() { // Helper function to create a default writer for show_settings/show_files
std::fs::create_dir_all(parent)?; let create_default_writer = || -> Box<dyn Write> {
} output_targets
let file = File::create(path)?; .iter()
Box::new(BufWriter::new(file)) .find(|t| t.format.is_human_readable())
} .or(output_targets.first())
_ => Box::new(BufWriter::new(io::stdout())), .map(|t| -> Box<dyn Write> {
match &t.target {
OutputTarget::Stdout => Box::new(BufWriter::new(io::stdout())),
OutputTarget::Stderr => Box::new(BufWriter::new(io::stderr())),
OutputTarget::File(path) => {
if !cli.watch {
colored::control::set_override(false);
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
if let Ok(file) = File::create(path) {
Box::new(BufWriter::new(file))
} else {
Box::new(BufWriter::new(io::stdout()))
}
} else {
Box::new(BufWriter::new(io::stdout()))
}
}
}
})
.unwrap_or_else(|| Box::new(BufWriter::new(io::stdout())))
}; };
let stderr_writer = Box::new(BufWriter::new(io::stderr()));
let is_stdin = is_stdin(&cli.files, cli.stdin_filename.as_deref()); let is_stdin = is_stdin(&cli.files, cli.stdin_filename.as_deref());
let files = resolve_default_files(cli.files, is_stdin); let files = resolve_default_files(cli.files, is_stdin);
if cli.show_settings { if cli.show_settings {
// For show_settings, use stdout (these are informational commands)
let mut writer = BufWriter::new(io::stdout());
commands::show_settings::show_settings( commands::show_settings::show_settings(
&files, &files,
&pyproject_config, &pyproject_config,
@ -266,6 +288,8 @@ pub fn check(args: CheckCommand, global_options: GlobalConfigArgs) -> Result<Exi
return Ok(ExitStatus::Success); return Ok(ExitStatus::Success);
} }
if cli.show_files { if cli.show_files {
// For show_files, use stdout (these are informational commands)
let mut writer = BufWriter::new(io::stdout());
commands::show_files::show_files( commands::show_files::show_files(
&files, &files,
&pyproject_config, &pyproject_config,
@ -281,7 +305,7 @@ pub fn check(args: CheckCommand, global_options: GlobalConfigArgs) -> Result<Exi
fix, fix,
fix_only, fix_only,
unsafe_fixes, unsafe_fixes,
output_format, output_format: _,
show_fixes, show_fixes,
.. ..
} = pyproject_config.settings; } = pyproject_config.settings;
@ -343,14 +367,6 @@ pub fn check(args: CheckCommand, global_options: GlobalConfigArgs) -> Result<Exi
return Ok(ExitStatus::Success); return Ok(ExitStatus::Success);
} }
let printer = Printer::new(
output_format,
config_arguments.log_level,
fix_mode,
unsafe_fixes,
printer_flags,
);
// the settings should already be combined with the CLI overrides at this point // the settings should already be combined with the CLI overrides at this point
// TODO(jane): let's make this `PreviewMode` // TODO(jane): let's make this `PreviewMode`
// TODO: this should reference the global preview mode once https://github.com/astral-sh/ruff/issues/8232 // TODO: this should reference the global preview mode once https://github.com/astral-sh/ruff/issues/8232
@ -358,7 +374,11 @@ pub fn check(args: CheckCommand, global_options: GlobalConfigArgs) -> Result<Exi
let preview = pyproject_config.settings.linter.preview.is_enabled(); let preview = pyproject_config.settings.linter.preview.is_enabled();
if cli.watch { if cli.watch {
if output_format != OutputFormat::default() { if !output_targets.is_empty()
&& output_targets
.iter()
.any(|t| t.format != OutputFormat::default())
{
warn_user!( warn_user!(
"`--output-format {}` is always used in watch mode.", "`--output-format {}` is always used in watch mode.",
OutputFormat::default() OutputFormat::default()
@ -377,7 +397,15 @@ pub fn check(args: CheckCommand, global_options: GlobalConfigArgs) -> Result<Exi
// Perform an initial run instantly. // Perform an initial run instantly.
Printer::clear_screen()?; Printer::clear_screen()?;
printer.write_to_user("Starting linter in watch mode...\n"); // Create a printer for watch mode (always uses default format)
let watch_printer = Printer::new(
OutputFormat::default(),
config_arguments.log_level,
fix_mode,
unsafe_fixes,
printer_flags,
);
watch_printer.write_to_user("Starting linter in watch mode...\n");
let diagnostics = commands::check::check( let diagnostics = commands::check::check(
&files, &files,
@ -388,7 +416,8 @@ pub fn check(args: CheckCommand, global_options: GlobalConfigArgs) -> Result<Exi
fix_mode, fix_mode,
unsafe_fixes, unsafe_fixes,
)?; )?;
printer.write_continuously(&mut writer, &diagnostics, preview)?; let mut watch_writer = create_default_writer();
watch_printer.write_continuously(&mut *watch_writer, &diagnostics, preview)?;
// In watch mode, we may need to re-resolve the configuration. // In watch mode, we may need to re-resolve the configuration.
// TODO(charlie): Re-compute other derivative values, like the `printer`. // TODO(charlie): Re-compute other derivative values, like the `printer`.
@ -406,7 +435,7 @@ pub fn check(args: CheckCommand, global_options: GlobalConfigArgs) -> Result<Exi
resolve::resolve(&config_arguments, cli.stdin_filename.as_deref())?; resolve::resolve(&config_arguments, cli.stdin_filename.as_deref())?;
} }
Printer::clear_screen()?; Printer::clear_screen()?;
printer.write_to_user("File change detected...\n"); watch_printer.write_to_user("File change detected...\n");
let diagnostics = commands::check::check( let diagnostics = commands::check::check(
&files, &files,
@ -417,7 +446,8 @@ pub fn check(args: CheckCommand, global_options: GlobalConfigArgs) -> Result<Exi
fix_mode, fix_mode,
unsafe_fixes, unsafe_fixes,
)?; )?;
printer.write_continuously(&mut writer, &diagnostics, preview)?; let mut watch_writer = create_default_writer();
watch_printer.write_continuously(&mut *watch_writer, &diagnostics, preview)?;
} }
Err(err) => return Err(err.into()), Err(err) => return Err(err.into()),
} }
@ -444,18 +474,79 @@ pub fn check(args: CheckCommand, global_options: GlobalConfigArgs) -> Result<Exi
)? )?
}; };
// Always try to print violations (though the printer itself may suppress output) // Write to all output targets
// If we're writing fixes via stdin, the transformed source code goes to the writer // Special case: If we're writing fixes via stdin, the transformed source code goes to stdout,
// so send the summary to stderr instead // so we need to handle summary separately for stdout targets
let mut summary_writer = if is_stdin && matches!(fix_mode, FixMode::Apply | FixMode::Diff) { let is_stdin_with_fixes = is_stdin && matches!(fix_mode, FixMode::Apply | FixMode::Diff);
stderr_writer
} else { for target_pair in &output_targets {
writer // For stdin with fixes, if this is a stdout target, skip it (source code already written)
}; // and we'll handle the summary separately
if cli.statistics { if is_stdin_with_fixes && matches!(target_pair.target, OutputTarget::Stdout) {
printer.write_statistics(&diagnostics, &mut summary_writer)?; // Skip writing diagnostics to stdout when fixes are applied via stdin
} else { // The source code transformation is already written to stdout
printer.write_once(&diagnostics, &mut summary_writer, preview)?; continue;
}
let mut writer: Box<dyn Write> = match &target_pair.target {
OutputTarget::Stdout => Box::new(BufWriter::new(io::stdout())),
OutputTarget::Stderr => Box::new(BufWriter::new(io::stderr())),
OutputTarget::File(path) => {
colored::control::set_override(false);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let file = File::create(path)?;
Box::new(BufWriter::new(file))
}
};
let target_printer = Printer::new(
target_pair.format,
config_arguments.log_level,
fix_mode,
unsafe_fixes,
printer_flags,
);
if cli.statistics {
target_printer.write_statistics(&diagnostics, &mut *writer)?;
} else {
target_printer.write_once(&diagnostics, &mut *writer, preview)?;
}
}
// Special handling for stdin with fixes: write summary to stderr
if is_stdin_with_fixes {
// Check if we have a stderr target, otherwise create a temporary one for the summary
let has_stderr_target = output_targets
.iter()
.any(|t| matches!(t.target, OutputTarget::Stderr));
if !has_stderr_target {
// Write summary to stderr (old behavior)
let mut stderr_writer = BufWriter::new(io::stderr());
// Find the format to use for the summary (prefer human-readable, or first format)
let summary_format = output_targets
.iter()
.find(|t| t.format.is_human_readable())
.or(output_targets.first())
.map(|t| t.format)
.unwrap_or(OutputFormat::Full);
let summary_printer = Printer::new(
summary_format,
config_arguments.log_level,
fix_mode,
unsafe_fixes,
printer_flags,
);
if cli.statistics {
summary_printer.write_statistics(&diagnostics, &mut stderr_writer)?;
} else {
summary_printer.write_once(&diagnostics, &mut stderr_writer, preview)?;
}
}
} }
if !cli.exit_zero { if !cli.exit_zero {

View File

@ -3876,3 +3876,161 @@ fn supported_file_extensions_preview_enabled() -> Result<()> {
"); ");
Ok(()) Ok(())
} }
#[test]
fn multiple_output_formats() -> Result<()> {
let test = CliTest::new()?;
test.write_file("test.py", "import os # F401\n")?;
let output = test
.command()
.args([
"check",
"--no-cache",
"--output-file",
"gitlab:gl-code-quality.json",
"--output-file",
"full:stdout",
"--select",
"F401",
"test.py",
])
.output()?;
// Check that stdout contains the full format output
let stdout = str::from_utf8(&output.stdout)?;
// Full format shows diagnostics differently than concise format
assert!(
stdout.contains("F401") && stdout.contains("test.py"),
"Expected full format output with F401 and test.py. Got: {stdout}"
);
assert!(stdout.contains("Found") || stdout.contains("All checks passed"));
// Check that the GitLab format file was created
let gitlab_content = fs::read_to_string(test.root().join("gl-code-quality.json"))?;
assert!(gitlab_content.contains("check_name"));
assert!(gitlab_content.contains("F401"));
Ok(())
}
#[test]
fn multiple_output_formats_env_var_comma_separated() -> Result<()> {
let test = CliTest::new()?;
test.write_file("test.py", "import os # F401\n")?;
let output = test
.command()
.env(
"RUFF_OUTPUT_FILE",
"gitlab:gl-code-quality.json,full:stdout",
)
.args(["check", "--no-cache", "--select", "F401", "test.py"])
.output()?;
// Check that stdout contains the full format output
let stdout = str::from_utf8(&output.stdout)?;
assert!(
stdout.contains("F401") && stdout.contains("test.py"),
"Expected full format output with F401 and test.py. Got: {stdout}"
);
// Check that the GitLab format file was created
let gitlab_content = fs::read_to_string(test.root().join("gl-code-quality.json"))?;
assert!(gitlab_content.contains("check_name"));
assert!(gitlab_content.contains("F401"));
Ok(())
}
#[test]
fn multiple_output_formats_cli_comma_separated() -> Result<()> {
let test = CliTest::new()?;
test.write_file("test.py", "import os # F401\n")?;
let output = test
.command()
.args([
"check",
"--no-cache",
"--output-file",
"gitlab:gl-code-quality.json,full:stdout",
"--select",
"F401",
"test.py",
])
.output()?;
// Check that stdout contains the full format output
let stdout = str::from_utf8(&output.stdout)?;
assert!(
stdout.contains("F401") && stdout.contains("test.py"),
"Expected full format output with F401 and test.py. Got: {stdout}"
);
// Check that the GitLab format file was created
let gitlab_content = fs::read_to_string(test.root().join("gl-code-quality.json"))?;
assert!(gitlab_content.contains("check_name"));
assert!(gitlab_content.contains("F401"));
Ok(())
}
#[test]
fn multiple_output_formats_stdout_stderr() -> Result<()> {
let test = CliTest::new()?;
test.write_file("test.py", "import os # F401\n")?;
let output = test
.command()
.args([
"check",
"--no-cache",
"--output-file",
"concise:stdout",
"--output-file",
"json:stderr",
"--select",
"F401",
"test.py",
])
.output()?;
// Check that stdout contains concise format
let stdout = str::from_utf8(&output.stdout)?;
assert!(stdout.contains("test.py:1:8: F401"));
// Check that stderr contains JSON format
let stderr = str::from_utf8(&output.stderr)?;
assert!(stderr.contains("\"code\""));
assert!(stderr.contains("F401"));
Ok(())
}
#[test]
fn backward_compatibility_output_format_file() -> Result<()> {
let test = CliTest::new()?;
test.write_file("test.py", "import os # F401\n")?;
test.command()
.args([
"check",
"--no-cache",
"--output-format",
"gitlab",
"--output-file",
"gl-code-quality.json",
"--select",
"F401",
"test.py",
])
.output()?;
// Check that the GitLab format file was created
let gitlab_content = fs::read_to_string(test.root().join("gl-code-quality.json"))?;
assert!(gitlab_content.contains("check_name"));
assert!(gitlab_content.contains("F401"));
Ok(())
}

View File

@ -605,9 +605,11 @@ Options:
format is "full" [env: RUFF_OUTPUT_FORMAT=] [possible values: format is "full" [env: RUFF_OUTPUT_FORMAT=] [possible values:
concise, full, json, json-lines, junit, grouped, github, gitlab, concise, full, json, json-lines, junit, grouped, github, gitlab,
pylint, rdjson, azure, sarif] pylint, rdjson, azure, sarif]
-o, --output-file <OUTPUT_FILE> -o, --output-file <FORMAT:TARGET>
Specify file to write the linter output to (default: stdout) [env: Specify output destination(s) using format:target syntax. Can be
RUFF_OUTPUT_FILE=] specified multiple times for multiple outputs. Also supports
comma-separated values: --output-file gitlab:file.json,full:stdout
[env: RUFF_OUTPUT_FILE=]
--target-version <TARGET_VERSION> --target-version <TARGET_VERSION>
The minimum Python version that should be supported [possible values: The minimum Python version that should be supported [possible values:
py37, py38, py39, py310, py311, py312, py313, py314] py37, py38, py39, py310, py311, py312, py313, py314]