Use subcommands for CLI instead of incompatible boolean flags

This commit greatly simplifies the implementation of the CLI,
as well as the user expierence (since --help no longer lists all
options even though many of them are in fact incompatible).

To preserve backwards-compatability as much as possible aliases have
been added for the new subcommands, so for example the following two
commands are equivalent:

    ruff explain E402 --format json
    ruff --explain E402 --format json

However for this to work the legacy-format double-dash command has to
come first, i.e. the following no longer works:

    ruff --format json --explain E402

Since ruff previously had an implicitly default subcommand,
this is preserved for backwards compatibility, i.e. the following two
commands are equivalent:

    ruff .
    ruff check .

Previously ruff didn't complain about several argument combinations that
should have never been allowed, e.g:

    ruff --explain RUF001 --line-length 33

previously worked but now rightfully fails since the explain command
doesn't support a `--line-length` option.
This commit is contained in:
Martin Fischer 2023-01-25 15:09:32 +01:00 committed by Charlie Marsh
parent 57a68f7c7d
commit eda2be6350
7 changed files with 173 additions and 188 deletions

View file

@ -1,5 +1,40 @@
# Breaking Changes
## Unreleased
`--explain`, `--clean` and `--generate-shell-completion` are now
implemented as subcommands:
ruff . # still works and will always work
ruff check . # now also works
ruff --explain E402 # still works
ruff explain E402 # now also works
ruff --format json --explain E402 # no longer works
# the command has to come first:
ruff --explain E402 --format json # or using the new syntax:
ruff explain E402 --format json
Please also note that:
* the subcommands will now fail when invoked with unsupported arguments
instead of silently ignoring them, e.g. the following will now fail:
ruff --explain E402 --respect-gitignore
Since the `explain` command doesn't support `--respect-gitignore`.
* The semantics of `ruff <arg>` has changed when `<arg>` is a
subcommand, e.g. before `ruff explain` would look for a file or
directory `explain` in the current directory but now it just invokes
the explain command. Note that scripts invoking ruff should supply
`--` anyway before any positional arguments and the semantics of
`ruff -- <arg>` have not changed.
* `--explain` previously treated `--format grouped` just like `--format text`
(this is no longer supported, use `--format text` instead)
## 0.0.226
### `misplaced-comparison-constant` (`PLC2201`) was deprecated in favor of `SIM300` ([#1980](https://github.com/charliermarsh/ruff/pull/1980))

View file

@ -363,77 +363,24 @@ See `ruff --help` for more:
```
Ruff: An extremely fast Python linter.
Usage: ruff [OPTIONS] [FILES]...
Usage: ruff [OPTIONS] <COMMAND>
Arguments:
[FILES]...
Commands:
check Run ruff on the given files or directories (this command is used by default and may be omitted)
explain Explain a rule
clean Clear any caches in the current directory or any subdirectories
help Print this message or the help of the given subcommand(s)
Options:
--fix Attempt to automatically fix lint violations
--show-source Show violations with source code
--diff Avoid writing any fixed files back; instead, output a diff for each changed file to stdout
-w, --watch Run in watch mode by re-running whenever files change
--fix-only Fix any fixable lint violations, but don't report on leftover violations. Implies `--fix`
--format <FORMAT> Output serialization format for violations [env: RUFF_FORMAT=] [possible values: text, json, junit, grouped, github, gitlab, pylint]
--config <CONFIG> Path to the `pyproject.toml` or `ruff.toml` file to use for configuration
--add-noqa Enable automatic additions of `noqa` directives to failing lines
--show-files See the files Ruff will be run against with the current settings
--show-settings See the settings Ruff will use to lint a given Python file
-h, --help Print help
-V, --version Print version
Rule selection:
--select <RULE_CODE>
Comma-separated list of rule codes to enable (or ALL, to enable all rules)
--ignore <RULE_CODE>
Comma-separated list of rule codes to disable
--extend-select <RULE_CODE>
Like --select, but adds additional rule codes on top of the selected ones
--extend-ignore <RULE_CODE>
Like --ignore, but adds additional rule codes on top of the ignored ones
--per-file-ignores <PER_FILE_IGNORES>
List of mappings from file pattern to code to exclude
--fixable <RULE_CODE>
List of rule codes to treat as eligible for autofix. Only applicable when autofix itself is enabled (e.g., via `--fix`)
--unfixable <RULE_CODE>
List of rule codes to treat as ineligible for autofix. Only applicable when autofix itself is enabled (e.g., via `--fix`)
File selection:
--exclude <FILE_PATTERN> List of paths, used to omit files and/or directories from analysis
--extend-exclude <FILE_PATTERN> Like --exclude, but adds additional files and directories on top of those already excluded
--respect-gitignore Respect file exclusions via `.gitignore` and other standard ignore files
--force-exclude Enforce exclusions, even for paths passed to Ruff directly on the command-line
Rule configuration:
--target-version <TARGET_VERSION>
The minimum Python version that should be supported
--line-length <LINE_LENGTH>
Set the line-length for length-associated rules and automatic formatting
--dummy-variable-rgx <DUMMY_VARIABLE_RGX>
Regular expression matching the name of dummy variables
Miscellaneous:
-n, --no-cache
Disable cache reads
--isolated
Ignore all configuration files
--cache-dir <CACHE_DIR>
Path to the cache directory [env: RUFF_CACHE_DIR=]
--stdin-filename <STDIN_FILENAME>
The name of the file when passing it through stdin
-e, --exit-zero
Exit with status code "0", even upon detecting lint violations
--update-check
Enable or disable automatic update checks
Subcommands:
--explain <EXPLAIN> Explain a rule
--clean Clear any caches in the current directory or any subdirectories
-h, --help Print help
-V, --version Print version
Log levels:
-v, --verbose Enable verbose logging
-q, --quiet Print lint violations, but nothing else
-s, --silent Disable all logging (but still exit with status code "1" upon detecting lint violations)
To get help about a specific command, see 'ruff help <command>'.
```
<!-- End auto-generated cli help. -->

View file

@ -15,12 +15,44 @@ use rustc_hash::FxHashMap;
#[command(
author,
name = "ruff",
about = "Ruff: An extremely fast Python linter."
about = "Ruff: An extremely fast Python linter.",
after_help = "To get help about a specific command, see 'ruff help <command>'."
)]
#[command(version)]
#[allow(clippy::struct_excessive_bools)]
pub struct Args {
#[arg(required_unless_present_any = ["clean", "explain", "generate_shell_completion"])]
#[command(subcommand)]
pub command: Command,
#[clap(flatten)]
pub log_level_args: LogLevelArgs,
}
#[allow(clippy::large_enum_variant)]
#[derive(Debug, clap::Subcommand)]
pub enum Command {
/// Run ruff on the given files or directories (this command is used by
/// default and may be omitted)
Check(CheckArgs),
/// Explain a rule.
#[clap(alias = "--explain")]
Explain {
#[arg(value_parser=Rule::from_code)]
rule: &'static Rule,
/// Output serialization format for violations.
#[arg(long, value_enum, env = "RUFF_FORMAT", default_value = "text")]
format: HelpFormat,
},
/// Clear any caches in the current directory or any subdirectories.
#[clap(alias = "--clean")]
Clean,
/// Generate shell completion
#[clap(alias = "--generate-shell-completion", hide = true)]
GenerateShellCompletion { shell: clap_complete_command::Shell },
}
#[derive(Debug, clap::Args)]
#[allow(clippy::struct_excessive_bools, clippy::module_name_repetitions)]
pub struct CheckArgs {
pub files: Vec<PathBuf>,
/// Attempt to automatically fix lint violations.
#[arg(long, overrides_with("no_fix"))]
@ -183,9 +215,6 @@ pub struct Args {
#[arg(
long,
// conflicts_with = "add_noqa",
conflicts_with = "clean",
conflicts_with = "explain",
conflicts_with = "generate_shell_completion",
conflicts_with = "show_files",
conflicts_with = "show_settings",
// Unsupported default-command arguments.
@ -193,64 +222,11 @@ pub struct Args {
conflicts_with = "watch",
)]
pub add_noqa: bool,
/// Explain a rule.
#[arg(
long,
value_parser=Rule::from_code,
help_heading="Subcommands",
// Fake subcommands.
conflicts_with = "add_noqa",
conflicts_with = "clean",
// conflicts_with = "explain",
conflicts_with = "generate_shell_completion",
conflicts_with = "show_files",
conflicts_with = "show_settings",
// Unsupported default-command arguments.
conflicts_with = "stdin_filename",
conflicts_with = "watch",
)]
pub explain: Option<&'static Rule>,
/// Clear any caches in the current directory or any subdirectories.
#[arg(
long,
help_heading="Subcommands",
// Fake subcommands.
conflicts_with = "add_noqa",
// conflicts_with = "clean",
conflicts_with = "explain",
conflicts_with = "generate_shell_completion",
conflicts_with = "show_files",
conflicts_with = "show_settings",
// Unsupported default-command arguments.
conflicts_with = "stdin_filename",
conflicts_with = "watch",
)]
pub clean: bool,
/// Generate shell completion
#[arg(
long,
hide = true,
value_name = "SHELL",
// Fake subcommands.
conflicts_with = "add_noqa",
conflicts_with = "clean",
conflicts_with = "explain",
// conflicts_with = "generate_shell_completion",
conflicts_with = "show_files",
conflicts_with = "show_settings",
// Unsupported default-command arguments.
conflicts_with = "stdin_filename",
conflicts_with = "watch",
)]
pub generate_shell_completion: Option<clap_complete_command::Shell>,
/// See the files Ruff will be run against with the current settings.
#[arg(
long,
// Fake subcommands.
conflicts_with = "add_noqa",
conflicts_with = "clean",
conflicts_with = "explain",
conflicts_with = "generate_shell_completion",
// conflicts_with = "show_files",
conflicts_with = "show_settings",
// Unsupported default-command arguments.
@ -263,9 +239,6 @@ pub struct Args {
long,
// Fake subcommands.
conflicts_with = "add_noqa",
conflicts_with = "clean",
conflicts_with = "explain",
conflicts_with = "generate_shell_completion",
conflicts_with = "show_files",
// conflicts_with = "show_settings",
// Unsupported default-command arguments.
@ -273,22 +246,44 @@ pub struct Args {
conflicts_with = "watch",
)]
pub show_settings: bool,
#[clap(flatten)]
pub log_level_args: LogLevelArgs,
}
#[derive(Debug, Clone, Copy, clap::ValueEnum)]
pub enum HelpFormat {
Text,
Json,
}
#[allow(clippy::module_name_repetitions)]
#[derive(Debug, clap::Args)]
pub struct LogLevelArgs {
/// Enable verbose logging.
#[arg(short, long, group = "verbosity", help_heading = "Log levels")]
#[arg(
short,
long,
global = true,
group = "verbosity",
help_heading = "Log levels"
)]
pub verbose: bool,
/// Print lint violations, but nothing else.
#[arg(short, long, group = "verbosity", help_heading = "Log levels")]
#[arg(
short,
long,
global = true,
group = "verbosity",
help_heading = "Log levels"
)]
pub quiet: bool,
/// Disable all logging (but still exit with status code "1" upon detecting
/// lint violations).
#[arg(short, long, group = "verbosity", help_heading = "Log levels")]
#[arg(
short,
long,
global = true,
group = "verbosity",
help_heading = "Log levels"
)]
pub silent: bool,
}
@ -306,20 +301,17 @@ impl From<&LogLevelArgs> for LogLevel {
}
}
impl Args {
impl CheckArgs {
/// Partition the CLI into command-line arguments and configuration
/// overrides.
pub fn partition(self) -> (Arguments, Overrides) {
(
Arguments {
add_noqa: self.add_noqa,
clean: self.clean,
config: self.config,
diff: self.diff,
exit_zero: self.exit_zero,
explain: self.explain,
files: self.files,
generate_shell_completion: self.generate_shell_completion,
isolated: self.isolated,
no_cache: self.no_cache,
show_files: self.show_files,
@ -371,13 +363,10 @@ fn resolve_bool_arg(yes: bool, no: bool) -> Option<bool> {
#[allow(clippy::struct_excessive_bools)]
pub struct Arguments {
pub add_noqa: bool,
pub clean: bool,
pub config: Option<PathBuf>,
pub diff: bool,
pub exit_zero: bool,
pub explain: Option<&'static Rule>,
pub files: Vec<PathBuf>,
pub generate_shell_completion: Option<clap_complete_command::Shell>,
pub isolated: bool,
pub no_cache: bool,
pub show_files: bool,

View file

@ -18,12 +18,11 @@ use ruff::message::{Location, Message};
use ruff::registry::{Linter, Rule, RuleNamespace};
use ruff::resolver::PyprojectDiscovery;
use ruff::settings::flags;
use ruff::settings::types::SerializationFormat;
use ruff::{fix, fs, packaging, resolver, warn_user_once, AutofixAvailability, IOError};
use serde::Serialize;
use walkdir::WalkDir;
use crate::args::Overrides;
use crate::args::{HelpFormat, Overrides};
use crate::cache;
use crate::diagnostics::{lint_path, lint_stdin, Diagnostics};
use crate::iterators::par_iter;
@ -269,10 +268,10 @@ struct Explanation<'a> {
}
/// Explain a `Rule` to the user.
pub fn explain(rule: &Rule, format: SerializationFormat) -> Result<()> {
pub fn explain(rule: &Rule, format: HelpFormat) -> Result<()> {
let (linter, _) = Linter::parse_code(rule.code()).unwrap();
match format {
SerializationFormat::Text | SerializationFormat::Grouped => {
HelpFormat::Text => {
println!("{}\n", rule.as_ref());
println!("Code: {} ({})\n", rule.code(), linter.name());
@ -290,7 +289,7 @@ pub fn explain(rule: &Rule, format: SerializationFormat) -> Result<()> {
println!("* {format}");
}
}
SerializationFormat::Json => {
HelpFormat::Json => {
println!(
"{}",
serde_json::to_string_pretty(&Explanation {
@ -300,24 +299,12 @@ pub fn explain(rule: &Rule, format: SerializationFormat) -> Result<()> {
})?
);
}
SerializationFormat::Junit => {
bail!("`--explain` does not support junit format")
}
SerializationFormat::Github => {
bail!("`--explain` does not support GitHub format")
}
SerializationFormat::Gitlab => {
bail!("`--explain` does not support GitLab format")
}
SerializationFormat::Pylint => {
bail!("`--explain` does not support pylint format")
}
};
Ok(())
}
/// Clear any caches in the current directory or any subdirectories.
pub fn clean(level: &LogLevel) -> Result<()> {
pub fn clean(level: LogLevel) -> Result<()> {
for entry in WalkDir::new(&*path_dedot::CWD)
.into_iter()
.filter_map(Result::ok)
@ -325,7 +312,7 @@ pub fn clean(level: &LogLevel) -> Result<()> {
{
let cache = entry.path().join(CACHE_DIR_NAME);
if cache.is_dir() {
if level >= &LogLevel::Default {
if level >= LogLevel::Default {
eprintln!("Removing cache at: {}", fs::relativize_path(&cache).bold());
}
remove_dir_all(&cache)?;

View file

@ -17,8 +17,8 @@ use ::ruff::resolver::PyprojectDiscovery;
use ::ruff::settings::types::SerializationFormat;
use ::ruff::{fix, fs, warn_user_once};
use anyhow::Result;
use args::Args;
use clap::{CommandFactory, Parser};
use args::{Args, CheckArgs, Command};
use clap::{CommandFactory, Parser, Subcommand};
use colored::Colorize;
use notify::{recommended_watcher, RecursiveMode, Watcher};
use printer::{Printer, Violations};
@ -35,8 +35,32 @@ mod resolve;
pub mod updates;
fn inner_main() -> Result<ExitCode> {
let mut args: Vec<_> = std::env::args_os().collect();
// Clap doesn't support default subcommands but we want to run `check` by
// default for convenience and backwards-compatibility, so we just
// preprocess the arguments accordingly before passing them to clap.
if let Some(arg1) = args.get(1).and_then(|s| s.to_str()) {
if !Command::has_subcommand(arg1)
&& !arg1
.strip_prefix("--")
.map(Command::has_subcommand)
.unwrap_or_default()
&& arg1 != "-h"
&& arg1 != "--help"
&& arg1 != "-v"
&& arg1 != "--version"
&& arg1 != "help"
{
args.insert(1, "check".into());
}
}
// Extract command-line arguments.
let args = Args::parse();
let Args {
command,
log_level_args,
} = Args::parse_from(args);
let default_panic_hook = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
@ -53,20 +77,25 @@ quoting the executed command, along with the relevant file contents and `pyproje
default_panic_hook(info);
}));
let log_level: LogLevel = (&args.log_level_args).into();
let log_level: LogLevel = (&log_level_args).into();
set_up_logging(&log_level)?;
let (cli, overrides) = args.partition();
match command {
Command::Explain { rule, format } => commands::explain(rule, format)?,
Command::Clean => commands::clean(log_level)?,
Command::GenerateShellCompletion { shell } => {
shell.generate(&mut Args::command(), &mut io::stdout());
}
if let Some(shell) = cli.generate_shell_completion {
shell.generate(&mut Args::command(), &mut io::stdout());
return Ok(ExitCode::SUCCESS);
}
if cli.clean {
commands::clean(&log_level)?;
return Ok(ExitCode::SUCCESS);
Command::Check(args) => return check(args, log_level),
}
Ok(ExitCode::SUCCESS)
}
fn check(args: CheckArgs, log_level: LogLevel) -> Result<ExitCode> {
let (cli, overrides) = args.partition();
// Construct the "default" settings. These are used when no `pyproject.toml`
// files are present, or files are injected from outside of the hierarchy.
let pyproject_strategy = resolve::resolve(
@ -76,6 +105,14 @@ quoting the executed command, along with the relevant file contents and `pyproje
cli.stdin_filename.as_deref(),
)?;
if cli.show_settings {
commands::show_settings(&cli.files, &pyproject_strategy, &overrides)?;
return Ok(ExitCode::SUCCESS);
}
if cli.show_files {
commands::show_files(&cli.files, &pyproject_strategy, &overrides)?;
}
// Extract options that are included in `Settings`, but only apply at the top
// level.
let CliSettings {
@ -89,19 +126,6 @@ quoting the executed command, along with the relevant file contents and `pyproje
PyprojectDiscovery::Hierarchical(settings) => settings.cli.clone(),
};
if let Some(rule) = cli.explain {
commands::explain(rule, format)?;
return Ok(ExitCode::SUCCESS);
}
if cli.show_settings {
commands::show_settings(&cli.files, &pyproject_strategy, &overrides)?;
return Ok(ExitCode::SUCCESS);
}
if cli.show_files {
commands::show_files(&cli.files, &pyproject_strategy, &overrides)?;
return Ok(ExitCode::SUCCESS);
}
// Autofix rules are as follows:
// - If `--fix` or `--fix-only` is set, always apply fixes to the filesystem (or
// print them to stdout, if we're reading from stdin).
@ -135,14 +159,17 @@ quoting the executed command, along with the relevant file contents and `pyproje
warn_user_once!("Detected debug build without --no-cache.");
}
let printer = Printer::new(&format, &log_level, &autofix, &violations);
if cli.add_noqa {
let modifications = commands::add_noqa(&cli.files, &pyproject_strategy, &overrides)?;
if modifications > 0 && log_level >= LogLevel::Default {
println!("Added {modifications} noqa directives.");
}
} else if cli.watch {
return Ok(ExitCode::SUCCESS);
}
let printer = Printer::new(&format, &log_level, &autofix, &violations);
if cli.watch {
if !matches!(autofix, fix::FixMode::None) {
warn_user_once!("--fix is not enabled in watch mode.");
}
@ -244,7 +271,6 @@ quoting the executed command, along with the relevant file contents and `pyproje
}
}
}
Ok(ExitCode::SUCCESS)
}

View file

@ -163,8 +163,8 @@ fn test_show_source() -> Result<()> {
#[test]
fn explain_status_codes() -> Result<()> {
let mut cmd = Command::cargo_bin(BIN_NAME)?;
cmd.args(["-", "--explain", "F401"]).assert().success();
cmd.args(["--explain", "F401"]).assert().success();
let mut cmd = Command::cargo_bin(BIN_NAME)?;
cmd.args(["-", "--explain", "RUF404"]).assert().failure();
cmd.args(["--explain", "RUF404"]).assert().failure();
Ok(())
}

View file

@ -46,7 +46,7 @@ macro_rules! notify_user {
}
}
#[derive(Debug, Default, PartialOrd, Ord, PartialEq, Eq)]
#[derive(Debug, Default, PartialOrd, Ord, PartialEq, Eq, Copy, Clone)]
pub enum LogLevel {
// No output (+ `log::LevelFilter::Off`).
Silent,
@ -60,6 +60,7 @@ pub enum LogLevel {
}
impl LogLevel {
#[allow(clippy::trivially_copy_pass_by_ref)]
fn level_filter(&self) -> log::LevelFilter {
match self {
LogLevel::Default => log::LevelFilter::Info,