From ce1761d422c6960c581087dbe8bf27bf8c8e40f2 Mon Sep 17 00:00:00 2001 From: sasha Date: Mon, 20 Apr 2026 01:28:13 +0300 Subject: [PATCH] feat: add uv run support --- README.md | 1 + src/cmds/python/README.md | 1 + src/cmds/python/uv_cmd.rs | 323 ++++++++++++++++++++++++++++++++++++++ src/discover/registry.rs | 43 +++++ src/discover/rules.rs | 9 ++ src/hooks/init.rs | 2 + src/main.rs | 12 +- 7 files changed, 390 insertions(+), 1 deletion(-) create mode 100644 src/cmds/python/uv_cmd.rs diff --git a/README.md b/README.md index 1452b1ca8..eef904122 100644 --- a/README.md +++ b/README.md @@ -200,6 +200,7 @@ rtk rubocop # Ruby linting (JSON, -60%+) ### Package Managers ```bash rtk pnpm list # Compact dependency tree +rtk uv run pytest # Preserve uv env, errors only rtk pip list # Python packages (auto-detect uv) rtk pip outdated # Outdated packages rtk bundle install # Ruby gems (strip Using lines) diff --git a/src/cmds/python/README.md b/src/cmds/python/README.md index 7295ded35..912b69869 100644 --- a/src/cmds/python/README.md +++ b/src/cmds/python/README.md @@ -7,6 +7,7 @@ - `pytest_cmd.rs` uses a state machine text parser (no JSON available from pytest) - `ruff_cmd.rs` uses JSON for check mode (`--output-format=json`) and text filtering for format mode - `pip_cmd.rs` auto-detects `uv` as a pip alternative and routes accordingly +- `uv_cmd.rs` preserves `uv run` environment semantics while filtering down to relevant failures - `python -m pytest` and `python3 -m mypy` are rewritten by the hook registry to `rtk pytest` / `rtk mypy` ## Cross-command diff --git a/src/cmds/python/uv_cmd.rs b/src/cmds/python/uv_cmd.rs new file mode 100644 index 000000000..6372941bb --- /dev/null +++ b/src/cmds/python/uv_cmd.rs @@ -0,0 +1,323 @@ +//! Filters `uv run` output while preserving uv-managed environment semantics. + +use crate::core::runner; +use crate::core::stream::{self, FilterMode, StdinMode}; +use crate::core::tracking; +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 JS_FRAME_RE: Regex = Regex::new(r"^\s*at .+:\d+:\d+.*$").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 TRACEBACK_FRAME_LIMIT: usize = 4; +const ERROR_CONTINUATION_LIMIT: usize = 4; +const FALLBACK_TAIL_LIMIT: usize = 8; + +pub fn run(args: &[String], verbose: u8) -> Result { + let timer = tracking::TimedExecution::start(); + let args_display = args.join(" "); + let original_cmd = display_command("uv", &args_display); + let rtk_cmd = display_command("rtk uv", &args_display); + + let mut cmd = resolved_command("uv"); + 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 uv")?; + timer.track_passthrough(&original_cmd, &format!("{rtk_cmd} (passthrough)")); + return Ok(exit_code_from_status(&status, "uv")); + } + + let result = stream::run_streaming(&mut cmd, StdinMode::Inherit, FilterMode::CaptureOnly) + .context("Failed to run uv")?; + let filtered = filter_uv_run_output(&result.raw, result.exit_code); + + runner::print_with_hint(&filtered, &result.raw, "uv", 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_uv_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] uv run failed (exit code: {exit_code})"); + } + + let summary = tail + .into_iter() + .rev() + .take(FALLBACK_TAIL_LIMIT) + .collect::>() + .into_iter() + .rev() + .collect::>(); + + format!( + "[FAIL] uv 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(TRACEBACK_FRAME_LIMIT).cloned()); + if frames.len() > TRACEBACK_FRAME_LIMIT { + block.push(format!( + "... +{} more frames", + frames.len() - TRACEBACK_FRAME_LIMIT + )); + } + + 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 <= ERROR_CONTINUATION_LIMIT { + block.push(truncate(trimmed, 200)); + } + + idx += 1; + } + + if continuation_count > ERROR_CONTINUATION_LIMIT { + block.push(format!( + "... +{} more lines", + continuation_count - ERROR_CONTINUATION_LIMIT + )); + } + + (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) + || JS_FRAME_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) + || JS_FRAME_RE.is_match(trimmed) +} + +#[cfg(test)] +mod tests { + use super::filter_uv_run_output; + + #[test] + fn test_filter_uv_run_suppresses_success_noise() { + let output = r#" +Using CPython 3.12.2 +Resolved 12 packages in 48ms +Installed 1 package in 5ms +hello from script +"#; + + assert_eq!(filter_uv_run_output(output, 0), "ok"); + } + + #[test] + fn test_filter_uv_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() + File "/tmp/project/lib.py", line 33, in inner + boom() + File "/tmp/project/helpers.py", line 44, in boom + raise RuntimeError("kaboom") +RuntimeError: kaboom +"#; + + let result = filter_uv_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_uv_run_keeps_failure_summary_lines() { + let output = r#" +Resolved 8 packages in 30ms +============================= test session starts ============================= +FAILED tests/test_api.py::test_healthcheck - AssertionError: expected 200 +1 failed, 12 passed in 0.31s +"#; + + let result = filter_uv_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("Resolved 8 packages")); + } + + #[test] + fn test_filter_uv_run_has_failure_fallback() { + let output = "sync aborted by signal"; + let result = filter_uv_run_output(output, 2); + + assert!(result.contains("[FAIL] uv run failed (exit code: 2)")); + assert!(result.contains("sync aborted by signal")); + } +} diff --git a/src/discover/registry.rs b/src/discover/registry.rs index bb7b11f2a..0531ddb0c 100644 --- a/src/discover/registry.rs +++ b/src/discover/registry.rs @@ -2326,6 +2326,49 @@ mod tests { ); } + #[test] + fn test_classify_uv_run() { + let commands = vec![ + "uv run python script.py", + "uv run pytest", + "uv run ruff check", + "uv run --project backend --extra dev python script.py", + ]; + + for command in commands { + assert!( + matches!( + classify_command(command), + Classification::Supported { + rtk_equivalent: "rtk uv", + .. + } + ), + "Failed for command: {}", + command + ); + } + } + + #[test] + fn test_rewrite_uv_run() { + let commands = vec![ + "uv run python script.py", + "uv run pytest", + "uv run ruff check", + "uv run --project backend --extra dev python script.py", + ]; + + for command in commands { + assert_eq!( + rewrite_command_no_prefixes(command, &[]), + Some(format!("rtk {command}")), + "Failed for command: {}", + command + ); + } + } + // --- Go tooling --- #[test] diff --git a/src/discover/rules.rs b/src/discover/rules.rs index df7c72d03..ca34bcecb 100644 --- a/src/discover/rules.rs +++ b/src/discover/rules.rs @@ -461,6 +461,15 @@ pub const RULES: &[RtkRule] = &[ subcmd_savings: &[("list", 75.0), ("outdated", 80.0)], subcmd_status: &[], }, + RtkRule { + pattern: r"^uv\s+run(?:\s|$)", + rtk_cmd: "rtk uv", + rewrite_prefixes: &["uv"], + 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/hooks/init.rs b/src/hooks/init.rs index c6bd05c2b..f85ebf5e1 100644 --- a/src/hooks/init.rs +++ b/src/hooks/init.rs @@ -158,6 +158,7 @@ rtk pnpm install # Compact install output (90%) rtk npm run