From 25187b4cdcf6e9010cfdbb44c6c05174c52e48bc Mon Sep 17 00:00:00 2001 From: Chris Brown <257249062+albatrossflyon-coder@users.noreply.github.com> Date: Tue, 14 Jul 2026 19:52:28 -0500 Subject: [PATCH] feat(python): recognize poetry run, filtering output like uv run poetry run never reached the registry -- it's the single most frequent unhandled command in `rtk discover` output (408 calls/30d, ahead of every other unhandled entry), leaving ~96% noise unfiltered on a typical verbose pytest run through it. Added as a first-class command (`rtk poetry`, mirroring how `uv run` already works via `rtk uv`) rather than as a transparent-prefix strip: poetry run wraps an arbitrary tool the same way uv run does, so the same generic traceback/error-block filter applies regardless of which tool is being run underneath. poetry_cmd.rs mirrors uv_cmd.rs structurally for that reason -- same filtering approach, same passthrough-for-non-run behavior, same exit code propagation. Wiring: new RtkRule (`^poetry\s+run` -> `rtk poetry`, prefix `poetry`) so `rewrite_command` picks it up the same way it already does for uv; Commands::Poetry variant + dispatch; added to is_operational_command's whitelist (this gates hook-pipeline integrity verification -- a forgotten command here fails open, per that function's own doc comment, so it needs the same treatment as every other command in that list, not just to make rewrite work); added to PASSTHROUGH so the "every subcommand classified" consistency test passes. Fixes #2969 --- src/cmds/python/poetry_cmd.rs | 327 ++++++++++++++++++++++++++++++++++ src/discover/rules.rs | 9 + src/main.rs | 13 +- 3 files changed, 348 insertions(+), 1 deletion(-) create mode 100644 src/cmds/python/poetry_cmd.rs diff --git a/src/cmds/python/poetry_cmd.rs b/src/cmds/python/poetry_cmd.rs new file mode 100644 index 0000000000..e7960bd12e --- /dev/null +++ b/src/cmds/python/poetry_cmd.rs @@ -0,0 +1,327 @@ +//! Filters `poetry run` output while preserving poetry-managed environment semantics. +//! +//! Structurally mirrors uv_cmd.rs: same generic traceback/error filtering, +//! since `poetry run ` and `uv run ` both just wrap an arbitrary +//! tool invocation, and the noise worth filtering (Python tracebacks, error/ +//! failure summary lines) is tool-specific, not wrapper-specific. + +use crate::core::runner; +use crate::core::stream::{self, FilterMode, StdinMode}; +use crate::core::tracking; +use crate::core::truncate::CAP_WARNINGS; +use crate::core::utils::{exit_code_from_status, resolved_command, strip_ansi, truncate}; +use anyhow::{Context, Result}; +use lazy_static::lazy_static; +use regex::Regex; + +lazy_static! { + static ref PYTHON_FRAME_RE: Regex = Regex::new(r#"^\s*File ".*", line \d+.*$"#).unwrap(); + static ref PYTHON_EXCEPTION_RE: Regex = + Regex::new(r"^\s*[A-Za-z_][A-Za-z0-9_.]*(?:Error|Exception):").unwrap(); + static ref ERROR_START_PATTERNS: Vec = vec![ + Regex::new(r"(?i)\berror\b").unwrap(), + Regex::new(r"(?i)\bfailed\b").unwrap(), + Regex::new(r"(?i)\bfailure\b").unwrap(), + Regex::new(r"(?i)\bexception\b").unwrap(), + Regex::new(r"(?i)\bpanic\b").unwrap(), + Regex::new(r"(?i)\bwarn(?:ing)?\b").unwrap(), + Regex::new(r"(?i)\bassert(?:ion)?\b").unwrap(), + Regex::new(r"^\s*FAILED\b").unwrap(), + Regex::new(r"^\s*ERROR\b").unwrap(), + Regex::new(r"^\s*E\s+").unwrap(), + Regex::new(r"^\s*Caused by:").unwrap(), + Regex::new(r"^\s*note:").unwrap(), + Regex::new(r"^\s*help:").unwrap(), + ]; +} + +const MAX_TRACEBACK_FRAMES: usize = CAP_WARNINGS; +const MAX_ERROR_CONTINUATION_LINES: usize = CAP_WARNINGS; +const MAX_FALLBACK_TAIL_LINES: usize = CAP_WARNINGS; + +pub fn run(args: &[String], verbose: u8) -> Result { + let timer = tracking::TimedExecution::start(); + let args_display = args.join(" "); + let original_cmd = display_command("poetry", &args_display); + let rtk_cmd = display_command("rtk poetry", &args_display); + + let mut cmd = resolved_command("poetry"); + cmd.args(args); + + if verbose > 0 { + eprintln!("Running: {}", original_cmd); + } + + if args.first().map(String::as_str) != Some("run") { + let status = cmd.status().context("Failed to run poetry")?; + timer.track_passthrough(&original_cmd, &format!("{rtk_cmd} (passthrough)")); + return Ok(exit_code_from_status(&status, "poetry")); + } + + let result = stream::run_streaming(&mut cmd, StdinMode::Inherit, FilterMode::CaptureOnly) + .context("Failed to run poetry")?; + let filtered = filter_poetry_run_output(&result.raw, result.exit_code); + + runner::print_with_hint(&filtered, &result.raw, &result.raw, "poetry", result.exit_code); + timer.track(&original_cmd, &rtk_cmd, &result.raw, &filtered); + + Ok(result.exit_code) +} + +fn display_command(prefix: &str, args_display: &str) -> String { + if args_display.trim().is_empty() { + prefix.to_string() + } else { + format!("{prefix} {args_display}") + } +} + +fn filter_poetry_run_output(output: &str, exit_code: i32) -> String { + let clean = strip_ansi(output); + let lines: Vec<&str> = clean.lines().collect(); + let mut selected: Vec = Vec::new(); + let mut i = 0; + + while i < lines.len() { + let line = lines[i]; + let trimmed = line.trim(); + + if trimmed.is_empty() { + i += 1; + continue; + } + + if is_traceback_start(trimmed) { + let (block, next_idx) = collect_traceback_block(&lines, i); + selected.extend(block); + selected.push(String::new()); + i = next_idx; + continue; + } + + if is_error_start(trimmed) { + let (block, next_idx) = collect_error_block(&lines, i); + selected.extend(block); + selected.push(String::new()); + i = next_idx; + continue; + } + + i += 1; + } + + let filtered = selected.join("\n").trim().to_string(); + if !filtered.is_empty() { + return filtered; + } + + if exit_code == 0 { + return "ok".to_string(); + } + + let tail: Vec = clean + .lines() + .map(str::trim) + .filter(|line| !line.is_empty()) + .map(|line| truncate(line, 200)) + .collect(); + + if tail.is_empty() { + return format!("[FAIL] poetry run failed (exit code: {exit_code})"); + } + + let summary = tail + .into_iter() + .rev() + .take(MAX_FALLBACK_TAIL_LINES) + .collect::>() + .into_iter() + .rev() + .collect::>(); + + format!( + "[FAIL] poetry run failed (exit code: {exit_code})\n{}", + summary.join("\n") + ) +} + +fn collect_traceback_block(lines: &[&str], start_idx: usize) -> (Vec, usize) { + let mut block = vec![lines[start_idx].trim().to_string()]; + let mut frames = Vec::new(); + let mut tail = Vec::new(); + let mut idx = start_idx + 1; + + while idx < lines.len() { + let trimmed = lines[idx].trim(); + if trimmed.is_empty() { + break; + } + + if PYTHON_FRAME_RE.is_match(trimmed) { + frames.push(truncate(trimmed, 160)); + } else { + tail.push(truncate(trimmed, 200)); + } + + idx += 1; + } + + block.extend(frames.iter().take(MAX_TRACEBACK_FRAMES).cloned()); + if frames.len() > MAX_TRACEBACK_FRAMES { + block.push(format!( + "... +{} more frames", + frames.len() - MAX_TRACEBACK_FRAMES + )); + let full_traceback = lines[start_idx..idx].join("\n"); + if let Some(hint) = crate::core::tee::force_tee_hint(&full_traceback, "poetry-traceback") { + block.push(format!(" {hint}")); + } + } + + let tail_lines = tail + .into_iter() + .rev() + .take(2) + .collect::>() + .into_iter() + .rev() + .collect::>(); + block.extend(tail_lines); + + (dedupe_preserving_order(block), idx) +} + +fn collect_error_block(lines: &[&str], start_idx: usize) -> (Vec, usize) { + let mut block = vec![truncate(lines[start_idx].trim(), 200)]; + let mut continuation_count = 0; + let mut idx = start_idx + 1; + + while idx < lines.len() { + let line = lines[idx]; + let trimmed = line.trim(); + + if trimmed.is_empty() || !is_error_continuation(line) { + break; + } + + continuation_count += 1; + if continuation_count <= MAX_ERROR_CONTINUATION_LINES { + block.push(truncate(trimmed, 200)); + } + + idx += 1; + } + + if continuation_count > MAX_ERROR_CONTINUATION_LINES { + block.push(format!( + "... +{} more lines", + continuation_count - MAX_ERROR_CONTINUATION_LINES + )); + let full_block = lines[start_idx..idx].join("\n"); + if let Some(hint) = crate::core::tee::force_tee_hint(&full_block, "poetry-error-block") { + block.push(format!(" {hint}")); + } + } + + (dedupe_preserving_order(block), idx) +} + +fn dedupe_preserving_order(lines: Vec) -> Vec { + let mut deduped = Vec::new(); + for line in lines { + if deduped.last() != Some(&line) { + deduped.push(line); + } + } + deduped +} + +fn is_traceback_start(line: &str) -> bool { + line.starts_with("Traceback ") +} + +fn is_error_start(line: &str) -> bool { + if is_traceback_start(line) || PYTHON_FRAME_RE.is_match(line) || PYTHON_EXCEPTION_RE.is_match(line) { + return true; + } + + if line.contains("No module named ") { + return true; + } + + ERROR_START_PATTERNS.iter().any(|pattern| pattern.is_match(line)) +} + +fn is_error_continuation(line: &str) -> bool { + let trimmed = line.trim(); + line.starts_with(' ') + || line.starts_with('\t') + || trimmed.starts_with('>') + || trimmed.starts_with('|') + || trimmed.starts_with("During handling of the above exception") + || trimmed.starts_with("The above exception") + || trimmed.starts_with("Caused by:") + || trimmed.starts_with("note:") + || trimmed.starts_with("help:") + || PYTHON_FRAME_RE.is_match(trimmed) + || PYTHON_EXCEPTION_RE.is_match(trimmed) +} + +#[cfg(test)] +mod tests { + use super::filter_poetry_run_output; + + #[test] + fn test_filter_poetry_run_suppresses_success_noise() { + let output = r#" +Installing dependencies from lock file + +No dependencies to install or update +hello from script +"#; + + assert_eq!(filter_poetry_run_output(output, 0), "ok"); + } + + #[test] + fn test_filter_poetry_run_truncates_python_tracebacks() { + let output = r#" +Traceback (most recent call last): + File "/tmp/project/main.py", line 10, in + run() + File "/tmp/project/app.py", line 22, in run + inner() +RuntimeError: kaboom +"#; + + let result = filter_poetry_run_output(output, 1); + assert!(result.contains("Traceback (most recent call last):")); + assert!(result.contains(r#"File "/tmp/project/main.py", line 10, in "#)); + assert!(result.contains("RuntimeError: kaboom")); + assert!(!result.contains("run()")); + } + + #[test] + fn test_filter_poetry_run_keeps_failure_summary_lines() { + let output = r#" +Installing dependencies from lock file +============================= test session starts ============================= +FAILED tests/test_api.py::test_healthcheck - AssertionError: expected 200 +1 failed, 12 passed in 0.31s +"#; + + let result = filter_poetry_run_output(output, 1); + assert!(result.contains("FAILED tests/test_api.py::test_healthcheck")); + assert!(result.contains("1 failed, 12 passed in 0.31s")); + assert!(!result.contains("Installing dependencies")); + } + + #[test] + fn test_filter_poetry_run_has_failure_fallback() { + let output = "poetry aborted by signal"; + let result = filter_poetry_run_output(output, 2); + + assert!(result.contains("[FAIL] poetry run failed (exit code: 2)")); + assert!(result.contains("poetry aborted by signal")); + } +} diff --git a/src/discover/rules.rs b/src/discover/rules.rs index 9de6e45b01..94dfad9c36 100644 --- a/src/discover/rules.rs +++ b/src/discover/rules.rs @@ -488,6 +488,15 @@ pub const RULES: &[RtkRule] = &[ subcmd_savings: &[], subcmd_status: &[], }, + RtkRule { + pattern: r"^poetry\s+run(?:\s|$)", + rtk_cmd: "rtk poetry", + rewrite_prefixes: &["poetry"], + category: "Python", + savings_pct: 70.0, + subcmd_savings: &[], + subcmd_status: &[], + }, RtkRule { pattern: r"^go\s+(test|build|vet)", rtk_cmd: "rtk go", diff --git a/src/main.rs b/src/main.rs index 1c85bb55f9..4f5f22c846 100644 --- a/src/main.rs +++ b/src/main.rs @@ -17,7 +17,7 @@ use cmds::js::{ }; use cmds::jvm::{gradlew_cmd, mvn_cmd}; use cmds::php::{ecs_cmd, paratest_cmd, pest_cmd, php_cmd, phpstan_cmd, phpunit_cmd, pint_cmd}; -use cmds::python::{mypy_cmd, pip_cmd, pytest_cmd, ruff_cmd, uv_cmd}; +use cmds::python::{mypy_cmd, pip_cmd, poetry_cmd, pytest_cmd, ruff_cmd, uv_cmd}; use cmds::ruby::{rake_cmd, rspec_cmd, rubocop_cmd}; use cmds::rust::{cargo_cmd, runner}; use cmds::system::{ @@ -780,6 +780,13 @@ enum Commands { args: Vec, }, + /// poetry run with compact output while preserving poetry-managed environment semantics + Poetry { + /// poetry arguments (e.g., run pytest, run ruff check .) + #[arg(trailing_var_arg = true, allow_hyphen_values = true)] + args: Vec, + }, + /// Go commands with compact output Go { #[command(subcommand)] @@ -2343,6 +2350,8 @@ fn run_cli() -> Result { Commands::Uv { args } => uv_cmd::run(&args, cli.verbose)?, + Commands::Poetry { args } => poetry_cmd::run(&args, cli.verbose)?, + Commands::Go { command } => match command { GoCommands::Test { args } => go_cmd::run_test(&args, cli.verbose)?, GoCommands::Build { args } => go_cmd::run_build(&args, cli.verbose)?, @@ -2717,6 +2726,7 @@ fn is_operational_command(cmd: &Commands) -> bool { | Commands::Rspec { .. } | Commands::Pip { .. } | Commands::Uv { .. } + | Commands::Poetry { .. } | Commands::Go { .. } | Commands::GolangciLint { .. } | Commands::Gt { .. } @@ -3105,6 +3115,7 @@ mod tests { "ecs", "pint", "uv", + "poetry", ]; let unclassified: Vec = Cli::command()