[ty] Add a --quiet mode (#19233)
Some checks are pending
CI / cargo build (msrv) (push) Blocked by required conditions
CI / Determine changes (push) Waiting to run
CI / cargo fmt (push) Waiting to run
CI / cargo clippy (push) Blocked by required conditions
CI / cargo test (linux) (push) Blocked by required conditions
CI / cargo test (linux, release) (push) Blocked by required conditions
CI / cargo test (windows) (push) Blocked by required conditions
CI / cargo test (wasm) (push) Blocked by required conditions
CI / cargo build (release) (push) Waiting to run
CI / cargo fuzz build (push) Blocked by required conditions
CI / fuzz parser (push) Blocked by required conditions
CI / test scripts (push) Blocked by required conditions
CI / ecosystem (push) Blocked by required conditions
CI / Fuzz for new ty panics (push) Blocked by required conditions
CI / cargo shear (push) Blocked by required conditions
CI / python package (push) Waiting to run
CI / pre-commit (push) Waiting to run
CI / mkdocs (push) Waiting to run
CI / formatter instabilities and black similarity (push) Blocked by required conditions
CI / test ruff-lsp (push) Blocked by required conditions
CI / check playground (push) Blocked by required conditions
CI / benchmarks-instrumented (push) Blocked by required conditions
CI / benchmarks-walltime (push) Blocked by required conditions
[ty Playground] Release / publish (push) Waiting to run

Adds a `--quiet` flag which silences diagnostic, warning logs, and
messages like "all checks passed" while retaining summary messages that
indicate problems, e.g., the number of diagnostics.

I'm a bit on the fence regarding filtering out warning logs, because it
can omit important details, e.g., the message that a fatal diagnostic
was encountered. Let's discuss that in
https://github.com/astral-sh/ruff/pull/19233#discussion_r2195408693

The implementation recycles the `Printer` abstraction used in uv, which
is intended to replace all direct usage of `std::io::stdout`. See
https://github.com/astral-sh/ruff/pull/19233#discussion_r2195140197

I ended up futzing with the progress bar more than I probably should
have to ensure it was also using the printer, but it doesn't seem like a
big deal. See
https://github.com/astral-sh/ruff/pull/19233#discussion_r2195330467

Closes https://github.com/astral-sh/ty/issues/772
This commit is contained in:
Zanie Blue 2025-07-10 09:40:47 -05:00 committed by GitHub
parent 83b5bbf004
commit 965f415212
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 335 additions and 44 deletions

3
crates/ty/docs/cli.md generated
View file

@ -84,7 +84,8 @@ over all configuration files.</p>
<li><code>3.11</code></li>
<li><code>3.12</code></li>
<li><code>3.13</code></li>
</ul></dd><dt id="ty-check--respect-ignore-files"><a href="#ty-check--respect-ignore-files"><code>--respect-ignore-files</code></a></dt><dd><p>Respect file exclusions via <code>.gitignore</code> and other standard ignore files. Use <code>--no-respect-gitignore</code> to disable</p>
</ul></dd><dt id="ty-check--quiet"><a href="#ty-check--quiet"><code>--quiet</code></a></dt><dd><p>Use quiet output</p>
</dd><dt id="ty-check--respect-ignore-files"><a href="#ty-check--respect-ignore-files"><code>--respect-ignore-files</code></a></dt><dd><p>Respect file exclusions via <code>.gitignore</code> and other standard ignore files. Use <code>--no-respect-gitignore</code> to disable</p>
</dd><dt id="ty-check--typeshed"><a href="#ty-check--typeshed"><code>--typeshed</code></a>, <code>--custom-typeshed-dir</code> <i>path</i></dt><dd><p>Custom directory to use for stdlib typeshed stubs</p>
</dd><dt id="ty-check--verbose"><a href="#ty-check--verbose"><code>--verbose</code></a>, <code>-v</code></dt><dd><p>Use verbose output (or <code>-vv</code> and <code>-vvv</code> for more verbose output)</p>
</dd><dt id="ty-check--warn"><a href="#ty-check--warn"><code>--warn</code></a> <i>rule</i></dt><dd><p>Treat the given rule as having severity 'warn'. Can be specified multiple times.</p>

View file

@ -1,12 +1,13 @@
mod args;
mod logging;
mod printer;
mod python_version;
mod version;
pub use args::Cli;
use ty_static::EnvVars;
use std::io::{self, BufWriter, Write, stdout};
use std::fmt::Write;
use std::process::{ExitCode, Termination};
use anyhow::Result;
@ -14,6 +15,7 @@ use std::sync::Mutex;
use crate::args::{CheckCommand, Command, TerminalColor};
use crate::logging::setup_tracing;
use crate::printer::Printer;
use anyhow::{Context, anyhow};
use clap::{CommandFactory, Parser};
use colored::Colorize;
@ -25,7 +27,7 @@ use ruff_db::system::{OsSystem, SystemPath, SystemPathBuf};
use salsa::plumbing::ZalsaDatabase;
use ty_project::metadata::options::ProjectOptionsOverrides;
use ty_project::watch::ProjectWatcher;
use ty_project::{Db, DummyReporter, Reporter, watch};
use ty_project::{Db, watch};
use ty_project::{ProjectDatabase, ProjectMetadata};
use ty_server::run_server;
@ -42,6 +44,8 @@ pub fn run() -> anyhow::Result<ExitStatus> {
Command::Check(check_args) => run_check(check_args),
Command::Version => version().map(|()| ExitStatus::Success),
Command::GenerateShellCompletion { shell } => {
use std::io::stdout;
shell.generate(&mut Cli::command(), &mut stdout());
Ok(ExitStatus::Success)
}
@ -49,7 +53,7 @@ pub fn run() -> anyhow::Result<ExitStatus> {
}
pub(crate) fn version() -> Result<()> {
let mut stdout = BufWriter::new(io::stdout().lock());
let mut stdout = Printer::default().stream_for_requested_summary().lock();
let version_info = crate::version::version();
writeln!(stdout, "ty {}", &version_info)?;
Ok(())
@ -61,6 +65,8 @@ fn run_check(args: CheckCommand) -> anyhow::Result<ExitStatus> {
let verbosity = args.verbosity.level();
let _guard = setup_tracing(verbosity, args.color.unwrap_or_default())?;
let printer = Printer::default().with_verbosity(verbosity);
tracing::warn!(
"ty is pre-release software and not ready for production use. \
Expect to encounter bugs, missing features, and fatal errors.",
@ -125,7 +131,8 @@ fn run_check(args: CheckCommand) -> anyhow::Result<ExitStatus> {
}
let project_options_overrides = ProjectOptionsOverrides::new(config_file, options);
let (main_loop, main_loop_cancellation_token) = MainLoop::new(project_options_overrides);
let (main_loop, main_loop_cancellation_token) =
MainLoop::new(project_options_overrides, printer);
// Listen to Ctrl+C and abort the watch mode.
let main_loop_cancellation_token = Mutex::new(Some(main_loop_cancellation_token));
@ -143,7 +150,7 @@ fn run_check(args: CheckCommand) -> anyhow::Result<ExitStatus> {
main_loop.run(&mut db)?
};
let mut stdout = stdout().lock();
let mut stdout = printer.stream_for_requested_summary().lock();
match std::env::var(EnvVars::TY_MEMORY_REPORT).as_deref() {
Ok("short") => write!(stdout, "{}", db.salsa_memory_dump().display_short())?,
Ok("mypy_primer") => write!(stdout, "{}", db.salsa_memory_dump().display_mypy_primer())?,
@ -192,12 +199,16 @@ struct MainLoop {
/// The file system watcher, if running in watch mode.
watcher: Option<ProjectWatcher>,
/// Interface for displaying information to the user.
printer: Printer,
project_options_overrides: ProjectOptionsOverrides,
}
impl MainLoop {
fn new(
project_options_overrides: ProjectOptionsOverrides,
printer: Printer,
) -> (Self, MainLoopCancellationToken) {
let (sender, receiver) = crossbeam_channel::bounded(10);
@ -207,6 +218,7 @@ impl MainLoop {
receiver,
watcher: None,
project_options_overrides,
printer,
},
MainLoopCancellationToken { sender },
)
@ -223,32 +235,24 @@ impl MainLoop {
// Do not show progress bars with `--watch`, indicatif does not seem to
// handle cancelling independent progress bars very well.
self.run_with_progress::<DummyReporter>(db)?;
// TODO(zanieb): We can probably use `MultiProgress` to handle this case in the future.
self.printer = self.printer.with_no_progress();
self.run(db)?;
Ok(ExitStatus::Success)
}
fn run(self, db: &mut ProjectDatabase) -> Result<ExitStatus> {
self.run_with_progress::<IndicatifReporter>(db)
}
fn run_with_progress<R>(mut self, db: &mut ProjectDatabase) -> Result<ExitStatus>
where
R: Reporter + Default + 'static,
{
self.sender.send(MainLoopMessage::CheckWorkspace).unwrap();
let result = self.main_loop::<R>(db);
let result = self.main_loop(db);
tracing::debug!("Exiting main loop");
result
}
fn main_loop<R>(&mut self, db: &mut ProjectDatabase) -> Result<ExitStatus>
where
R: Reporter + Default + 'static,
{
fn main_loop(mut self, db: &mut ProjectDatabase) -> Result<ExitStatus> {
// Schedule the first check.
tracing::debug!("Starting main loop");
@ -264,7 +268,7 @@ impl MainLoop {
// to prevent blocking the main loop here.
rayon::spawn(move || {
match salsa::Cancelled::catch(|| {
let mut reporter = R::default();
let mut reporter = IndicatifReporter::from(self.printer);
db.check_with_reporter(&mut reporter)
}) {
Ok(result) => {
@ -299,10 +303,12 @@ impl MainLoop {
return Ok(ExitStatus::Success);
}
let mut stdout = stdout().lock();
if result.is_empty() {
writeln!(stdout, "{}", "All checks passed!".green().bold())?;
writeln!(
self.printer.stream_for_success_summary(),
"{}",
"All checks passed!".green().bold()
)?;
if self.watcher.is_none() {
return Ok(ExitStatus::Success);
@ -311,14 +317,19 @@ impl MainLoop {
let mut max_severity = Severity::Info;
let diagnostics_count = result.len();
let mut stdout = self.printer.stream_for_details().lock();
for diagnostic in result {
write!(stdout, "{}", diagnostic.display(db, &display_config))?;
// Only render diagnostics if they're going to be displayed, since doing
// so is expensive.
if stdout.is_enabled() {
write!(stdout, "{}", diagnostic.display(db, &display_config))?;
}
max_severity = max_severity.max(diagnostic.severity());
}
writeln!(
stdout,
self.printer.stream_for_failure_summary(),
"Found {} diagnostic{}",
diagnostics_count,
if diagnostics_count > 1 { "s" } else { "" }
@ -378,27 +389,53 @@ impl MainLoop {
}
/// A progress reporter for `ty check`.
#[derive(Default)]
struct IndicatifReporter(Option<indicatif::ProgressBar>);
enum IndicatifReporter {
/// A constructed reporter that is not yet ready, contains the target for the progress bar.
Pending(indicatif::ProgressDrawTarget),
/// A reporter that is ready, containing a progress bar to report to.
///
/// Initialization of the bar is deferred to [`ty_project::ProgressReporter::set_files`] so we
/// do not initialize the bar too early as it may take a while to collect the number of files to
/// process and we don't want to display an empty "0/0" bar.
Initialized(indicatif::ProgressBar),
}
impl ty_project::Reporter for IndicatifReporter {
impl From<Printer> for IndicatifReporter {
fn from(printer: Printer) -> Self {
Self::Pending(printer.progress_target())
}
}
impl ty_project::ProgressReporter for IndicatifReporter {
fn set_files(&mut self, files: usize) {
let progress = indicatif::ProgressBar::new(files as u64);
progress.set_style(
let target = match std::mem::replace(
self,
IndicatifReporter::Pending(indicatif::ProgressDrawTarget::hidden()),
) {
Self::Pending(target) => target,
Self::Initialized(_) => panic!("The progress reporter should only be initialized once"),
};
let bar = indicatif::ProgressBar::with_draw_target(Some(files as u64), target);
bar.set_style(
indicatif::ProgressStyle::with_template(
"{msg:8.dim} {bar:60.green/dim} {pos}/{len} files",
)
.unwrap()
.progress_chars("--"),
);
progress.set_message("Checking");
self.0 = Some(progress);
bar.set_message("Checking");
*self = Self::Initialized(bar);
}
fn report_file(&self, _file: &ruff_db::files::File) {
if let Some(ref progress_bar) = self.0 {
progress_bar.inc(1);
match self {
IndicatifReporter::Initialized(progress_bar) => {
progress_bar.inc(1);
}
IndicatifReporter::Pending(_) => {
panic!("`report_file` called before `set_files`")
}
}
}
}

View file

@ -24,15 +24,32 @@ pub(crate) struct Verbosity {
help = "Use verbose output (or `-vv` and `-vvv` for more verbose output)",
action = clap::ArgAction::Count,
global = true,
overrides_with = "quiet",
)]
verbose: u8,
#[arg(
long,
help = "Use quiet output",
action = clap::ArgAction::Count,
global = true,
overrides_with = "verbose",
)]
quiet: u8,
}
impl Verbosity {
/// Returns the verbosity level based on the number of `-v` flags.
/// Returns the verbosity level based on the number of `-v` and `-q` flags.
///
/// Returns `None` if the user did not specify any verbosity flags.
pub(crate) fn level(&self) -> VerbosityLevel {
// `--quiet` and `--verbose` are mutually exclusive in Clap, so we can just check one first.
match self.quiet {
0 => {}
_ => return VerbosityLevel::Quiet,
// TODO(zanieb): Add support for `-qq` with a "silent" mode
}
match self.verbose {
0 => VerbosityLevel::Default,
1 => VerbosityLevel::Verbose,
@ -42,9 +59,14 @@ impl Verbosity {
}
}
#[derive(Debug, Copy, Clone, Eq, PartialEq, Ord, PartialOrd)]
#[derive(Debug, Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Default)]
pub(crate) enum VerbosityLevel {
/// Quiet output. Only shows Ruff and ty events up to the [`ERROR`](tracing::Level::ERROR).
/// Silences output except for summary information.
Quiet,
/// Default output level. Only shows Ruff and ty events up to the [`WARN`](tracing::Level::WARN).
#[default]
Default,
/// Enables verbose output. Emits Ruff and ty events up to the [`INFO`](tracing::Level::INFO).
@ -62,6 +84,7 @@ pub(crate) enum VerbosityLevel {
impl VerbosityLevel {
const fn level_filter(self) -> LevelFilter {
match self {
VerbosityLevel::Quiet => LevelFilter::ERROR,
VerbosityLevel::Default => LevelFilter::WARN,
VerbosityLevel::Verbose => LevelFilter::INFO,
VerbosityLevel::ExtraVerbose => LevelFilter::DEBUG,

172
crates/ty/src/printer.rs Normal file
View file

@ -0,0 +1,172 @@
use std::io::StdoutLock;
use indicatif::ProgressDrawTarget;
use crate::logging::VerbosityLevel;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub(crate) struct Printer {
verbosity: VerbosityLevel,
no_progress: bool,
}
impl Printer {
#[must_use]
pub(crate) fn with_no_progress(self) -> Self {
Self {
verbosity: self.verbosity,
no_progress: true,
}
}
#[must_use]
pub(crate) fn with_verbosity(self, verbosity: VerbosityLevel) -> Self {
Self {
verbosity,
no_progress: self.no_progress,
}
}
/// Return the [`ProgressDrawTarget`] for this printer.
pub(crate) fn progress_target(self) -> ProgressDrawTarget {
if self.no_progress {
return ProgressDrawTarget::hidden();
}
match self.verbosity {
VerbosityLevel::Quiet => ProgressDrawTarget::hidden(),
VerbosityLevel::Default => ProgressDrawTarget::stderr(),
// Hide the progress bar when in verbose mode.
// Otherwise, it gets interleaved with log messages.
VerbosityLevel::Verbose => ProgressDrawTarget::hidden(),
VerbosityLevel::ExtraVerbose => ProgressDrawTarget::hidden(),
VerbosityLevel::Trace => ProgressDrawTarget::hidden(),
}
}
/// Return the [`Stdout`] stream for important messages.
///
/// Unlike [`Self::stdout_general`], the returned stream will be enabled when
/// [`VerbosityLevel::Quiet`] is used.
fn stdout_important(self) -> Stdout {
match self.verbosity {
VerbosityLevel::Quiet => Stdout::enabled(),
VerbosityLevel::Default => Stdout::enabled(),
VerbosityLevel::Verbose => Stdout::enabled(),
VerbosityLevel::ExtraVerbose => Stdout::enabled(),
VerbosityLevel::Trace => Stdout::enabled(),
}
}
/// Return the [`Stdout`] stream for general messages.
///
/// The returned stream will be disabled when [`VerbosityLevel::Quiet`] is used.
fn stdout_general(self) -> Stdout {
match self.verbosity {
VerbosityLevel::Quiet => Stdout::disabled(),
VerbosityLevel::Default => Stdout::enabled(),
VerbosityLevel::Verbose => Stdout::enabled(),
VerbosityLevel::ExtraVerbose => Stdout::enabled(),
VerbosityLevel::Trace => Stdout::enabled(),
}
}
/// Return the [`Stdout`] stream for a summary message that was explicitly requested by the
/// user.
///
/// For example, in `ty version` the user has requested the version information and we should
/// display it even if [`VerbosityLevel::Quiet`] is used. Or, in `ty check`, if the
/// `TY_MEMORY_REPORT` variable has been set, we should display the memory report because the
/// user has opted-in to display.
pub(crate) fn stream_for_requested_summary(self) -> Stdout {
self.stdout_important()
}
/// Return the [`Stdout`] stream for a summary message on failure.
///
/// For example, in `ty check`, this would be used for the message indicating the number of
/// diagnostics found. The failure summary should capture information that is not reflected in
/// the exit code.
pub(crate) fn stream_for_failure_summary(self) -> Stdout {
self.stdout_important()
}
/// Return the [`Stdout`] stream for a summary message on success.
///
/// For example, in `ty check`, this would be used for the message indicating that no diagnostic
/// were found. The success summary does not capture important information for users that have
/// opted-in to [`VerbosityLevel::Quiet`].
pub(crate) fn stream_for_success_summary(self) -> Stdout {
self.stdout_general()
}
/// Return the [`Stdout`] stream for detailed messages.
///
/// For example, in `ty check`, this would be used for the diagnostic output.
pub(crate) fn stream_for_details(self) -> Stdout {
self.stdout_general()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum StreamStatus {
Enabled,
Disabled,
}
#[derive(Debug)]
pub(crate) struct Stdout {
status: StreamStatus,
lock: Option<StdoutLock<'static>>,
}
impl Stdout {
fn enabled() -> Self {
Self {
status: StreamStatus::Enabled,
lock: None,
}
}
fn disabled() -> Self {
Self {
status: StreamStatus::Disabled,
lock: None,
}
}
pub(crate) fn lock(mut self) -> Self {
match self.status {
StreamStatus::Enabled => {
// Drop the previous lock first, to avoid deadlocking
self.lock.take();
self.lock = Some(std::io::stdout().lock());
}
StreamStatus::Disabled => self.lock = None,
}
self
}
fn handle(&mut self) -> Box<dyn std::io::Write + '_> {
match self.lock.as_mut() {
Some(lock) => Box::new(lock),
None => Box::new(std::io::stdout()),
}
}
pub(crate) fn is_enabled(&self) -> bool {
matches!(self.status, StreamStatus::Enabled)
}
}
impl std::fmt::Write for Stdout {
fn write_str(&mut self, s: &str) -> std::fmt::Result {
match self.status {
StreamStatus::Enabled => {
let _ = write!(self.handle(), "{s}");
Ok(())
}
StreamStatus::Disabled => Ok(()),
}
}
}

View file

@ -14,6 +14,64 @@ use std::{
};
use tempfile::TempDir;
#[test]
fn test_quiet_output() -> anyhow::Result<()> {
let case = CliTest::with_file("test.py", "x: int = 1")?;
// By default, we emit an "all checks passed" message
assert_cmd_snapshot!(case.command(), @r"
success: true
exit_code: 0
----- stdout -----
All checks passed!
----- stderr -----
WARN ty is pre-release software and not ready for production use. Expect to encounter bugs, missing features, and fatal errors.
");
// With `quiet`, the message is not displayed
assert_cmd_snapshot!(case.command().arg("--quiet"), @r"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
");
let case = CliTest::with_file("test.py", "x: int = 'foo'")?;
// By default, we emit a diagnostic
assert_cmd_snapshot!(case.command(), @r#"
success: false
exit_code: 1
----- stdout -----
error[invalid-assignment]: Object of type `Literal["foo"]` is not assignable to `int`
--> test.py:1:1
|
1 | x: int = 'foo'
| ^
|
info: rule `invalid-assignment` is enabled by default
Found 1 diagnostic
----- stderr -----
WARN ty is pre-release software and not ready for production use. Expect to encounter bugs, missing features, and fatal errors.
"#);
// With `quiet`, the diagnostic is not displayed, just the summary message
assert_cmd_snapshot!(case.command().arg("--quiet"), @r"
success: false
exit_code: 1
----- stdout -----
Found 1 diagnostic
----- stderr -----
");
Ok(())
}
#[test]
fn test_run_in_sub_directory() -> anyhow::Result<()> {
let case = CliTest::with_files([("test.py", "~"), ("subdir/nothing", "")])?;

View file

@ -5,7 +5,7 @@ use std::{cmp, fmt};
use crate::metadata::settings::file_settings;
use crate::{DEFAULT_LINT_REGISTRY, DummyReporter};
use crate::{Project, ProjectMetadata, Reporter};
use crate::{ProgressReporter, Project, ProjectMetadata};
use ruff_db::Db as SourceDb;
use ruff_db::diagnostic::Diagnostic;
use ruff_db::files::{File, Files};
@ -87,7 +87,7 @@ impl ProjectDatabase {
}
/// Checks all open files in the project and its dependencies, using the given reporter.
pub fn check_with_reporter(&self, reporter: &mut dyn Reporter) -> Vec<Diagnostic> {
pub fn check_with_reporter(&self, reporter: &mut dyn ProgressReporter) -> Vec<Diagnostic> {
let reporter = AssertUnwindSafe(reporter);
self.project().check(self, CheckMode::OpenFiles, reporter)
}
@ -95,7 +95,7 @@ impl ProjectDatabase {
/// Check the project with the given mode.
pub fn check_with_mode(&self, mode: CheckMode) -> Vec<Diagnostic> {
let mut reporter = DummyReporter;
let reporter = AssertUnwindSafe(&mut reporter as &mut dyn Reporter);
let reporter = AssertUnwindSafe(&mut reporter as &mut dyn ProgressReporter);
self.project().check(self, mode, reporter)
}

View file

@ -113,7 +113,7 @@ pub struct Project {
}
/// A progress reporter.
pub trait Reporter: Send + Sync {
pub trait ProgressReporter: Send + Sync {
/// Initialize the reporter with the number of files.
fn set_files(&mut self, files: usize);
@ -121,11 +121,11 @@ pub trait Reporter: Send + Sync {
fn report_file(&self, file: &File);
}
/// A no-op implementation of [`Reporter`].
/// A no-op implementation of [`ProgressReporter`].
#[derive(Default)]
pub struct DummyReporter;
impl Reporter for DummyReporter {
impl ProgressReporter for DummyReporter {
fn set_files(&mut self, _files: usize) {}
fn report_file(&self, _file: &File) {}
}
@ -212,7 +212,7 @@ impl Project {
self,
db: &ProjectDatabase,
mode: CheckMode,
mut reporter: AssertUnwindSafe<&mut dyn Reporter>,
mut reporter: AssertUnwindSafe<&mut dyn ProgressReporter>,
) -> Vec<Diagnostic> {
let project_span = tracing::debug_span!("Project::check");
let _span = project_span.enter();