From 9b508d34d3c80d5a8d3db25ac11d05ea360abd25 Mon Sep 17 00:00:00 2001 From: Alpamys Date: Thu, 16 Jul 2026 16:45:11 +0500 Subject: [PATCH] fix(cli): escape soup-cli[extra] so Rich stops eating the bracket Every "you're missing an extra, install it" hint rendered as `pip install 'soup-cli'` -- Rich parsed [eval]/[onnx]/[serve]/[wandb]/... as a markup tag and dropped it. The suggested command therefore installs the base package WITHOUT the extra the user is missing, so following the hint appears to succeed and the feature still fails. Pre-existing across many releases. Same class as the v0.71.28 \[mcp] fix, which only fixed its own call site. 17 sites escaped across 9 command modules. Typer help is affected too -- rich_markup_mode="rich" renders help through Rich, so `--semantic` shipped as "Requires soup-cli." and --track-energy lost its [carbon] hint. Deliberately NOT escaped: `raise ImportError("... 'soup-cli[mlx]'")`, errors.append(...), docstrings and YAML samples never reach Rich, so a backslash there would surface literally. An initial blanket sweep hit 48 sites; ~31 were of that kind and were reverted. If such an exception text is ever printed via console.print, the fix belongs at the print site (rich.markup.escape), not in the message. Tests: pin the Rich behaviour both ways (eaten unescaped / survives escaped), scan every markup-bearing hint line, render both Typer helps end-to-end, and assert the non-Rich counter-case stays unescaped. --- src/soup_cli/commands/data.py | 2 +- src/soup_cli/commands/eval.py | 2 +- src/soup_cli/commands/export.py | 8 +- src/soup_cli/commands/generate.py | 2 +- .../commands/merge_sharded_fsdp_weights.py | 2 +- src/soup_cli/commands/serve.py | 6 +- src/soup_cli/commands/train.py | 8 +- src/soup_cli/commands/tui.py | 2 +- src/soup_cli/commands/ui.py | 2 +- tests/test_v07136.py | 101 ++++++++++++++++++ 10 files changed, 118 insertions(+), 17 deletions(-) diff --git a/src/soup_cli/commands/data.py b/src/soup_cli/commands/data.py index 3df4489..92703dc 100644 --- a/src/soup_cli/commands/data.py +++ b/src/soup_cli/commands/data.py @@ -301,7 +301,7 @@ def dedup( semantic: bool = typer.Option( False, "--semantic", help="Use embedding cosine (SemDeDup) instead of MinHash. Catches " - "paraphrases MinHash misses. Requires soup-cli[train].", + "paraphrases MinHash misses. Requires soup-cli\\[train].", ), embed_model: str = typer.Option( DEFAULT_EMBED_MODEL, "--embed-model", diff --git a/src/soup_cli/commands/eval.py b/src/soup_cli/commands/eval.py index f2410b3..c0ccbbe 100644 --- a/src/soup_cli/commands/eval.py +++ b/src/soup_cli/commands/eval.py @@ -108,7 +108,7 @@ def benchmark( except ImportError: console.print( "[red]lm-eval not installed.[/]\n" - "Install with: [bold]pip install 'soup-cli[eval]'[/]" + "Install with: [bold]pip install 'soup-cli\\[eval]'[/]" ) raise typer.Exit(1) diff --git a/src/soup_cli/commands/export.py b/src/soup_cli/commands/export.py index 96f61be..a43c88b 100644 --- a/src/soup_cli/commands/export.py +++ b/src/soup_cli/commands/export.py @@ -579,7 +579,7 @@ def _export_onnx( except ImportError: console.print( "[red]optimum not installed.[/]\n" - "Install with: [bold]pip install 'soup-cli[onnx]'[/]\n" + "Install with: [bold]pip install 'soup-cli\\[onnx]'[/]\n" "Or directly: [bold]pip install optimum[onnx][/]" ) raise typer.Exit(1) @@ -664,7 +664,7 @@ def _export_tensorrt( if not trtllm_available: console.print( "[red]tensorrt_llm not installed.[/]\n" - "Install with: [bold]pip install 'soup-cli[tensorrt]'[/]\n" + "Install with: [bold]pip install 'soup-cli\\[tensorrt]'[/]\n" "Or follow: https://github.com/NVIDIA/TensorRT-LLM#installation" ) raise typer.Exit(1) @@ -857,7 +857,7 @@ def _export_awq( except ImportError: console.print( "[red]autoawq not installed.[/]\n" - "Install with: [bold]pip install 'soup-cli[awq]'[/]\n" + "Install with: [bold]pip install 'soup-cli\\[awq]'[/]\n" "Or directly: [bold]pip install autoawq[/]" ) raise typer.Exit(1) @@ -989,7 +989,7 @@ def _export_gptq( except ImportError: console.print( "[red]auto-gptq not installed.[/]\n" - "Install with: [bold]pip install 'soup-cli[gptq]'[/]\n" + "Install with: [bold]pip install 'soup-cli\\[gptq]'[/]\n" "Or directly: [bold]pip install auto-gptq[/]" ) raise typer.Exit(1) diff --git a/src/soup_cli/commands/generate.py b/src/soup_cli/commands/generate.py index d361ea5..46c28da 100644 --- a/src/soup_cli/commands/generate.py +++ b/src/soup_cli/commands/generate.py @@ -487,7 +487,7 @@ def _run_dedup_pipeline(path: Path) -> None: except ImportError: console.print( "[yellow]Dedup: datasketch not installed, skipping. " - "Install: pip install 'soup-cli[data]'[/]" + "Install: pip install 'soup-cli\\[data]'[/]" ) diff --git a/src/soup_cli/commands/merge_sharded_fsdp_weights.py b/src/soup_cli/commands/merge_sharded_fsdp_weights.py index 0255c25..5906a91 100644 --- a/src/soup_cli/commands/merge_sharded_fsdp_weights.py +++ b/src/soup_cli/commands/merge_sharded_fsdp_weights.py @@ -70,7 +70,7 @@ def merge_sharded_fsdp_weights( except ImportError as exc: console.print( "[red]torch + safetensors are required for consolidation. " - "Install with: [bold]pip install 'soup-cli[train]'[/][/]" + "Install with: [bold]pip install 'soup-cli\\[train]'[/][/]" ) raise typer.Exit(code=1) from exc diff --git a/src/soup_cli/commands/serve.py b/src/soup_cli/commands/serve.py index bb9d725..a9b3930 100644 --- a/src/soup_cli/commands/serve.py +++ b/src/soup_cli/commands/serve.py @@ -460,7 +460,7 @@ def serve( except ImportError: console.print( "[red]FastAPI/uvicorn not installed.[/]\n" - "Install with: [bold]pip install 'soup-cli[serve]'[/]" + "Install with: [bold]pip install 'soup-cli\\[serve]'[/]" ) raise typer.Exit(1) @@ -537,7 +537,7 @@ def serve( if not is_vllm_available(): console.print( "[red]vLLM not installed.[/]\n" - "Install with: [bold]pip install 'soup-cli[serve-fast]'[/]" + "Install with: [bold]pip install 'soup-cli\\[serve-fast]'[/]" ) raise typer.Exit(1) @@ -548,7 +548,7 @@ def serve( if not check_sglang_available(): console.print( "[red]SGLang not installed.[/]\n" - "Install with: [bold]pip install 'soup-cli[sglang]'[/]" + "Install with: [bold]pip install 'soup-cli\\[sglang]'[/]" ) raise typer.Exit(1) diff --git a/src/soup_cli/commands/train.py b/src/soup_cli/commands/train.py index d9a2121..b96bea8 100644 --- a/src/soup_cli/commands/train.py +++ b/src/soup_cli/commands/train.py @@ -377,7 +377,7 @@ def train( "--track-energy", help=( "Measure the training window's energy + CO2 via codecarbon " - "(offline; requires `pip install soup-cli[carbon]`). Feeds the " + "(offline; requires `pip install soup-cli\\[carbon]`). Feeds the " "kWh / CO2 into --annex-xi. v0.71.3." ), ), @@ -711,7 +711,7 @@ def train( except ImportError: console.print( "[red]wandb not installed.[/]\n" - "Run: [bold]pip install 'soup-cli[wandb]'[/]" + "Run: [bold]pip install 'soup-cli\\[wandb]'[/]" ) raise typer.Exit(1) except Exception as wandb_err: @@ -1484,7 +1484,7 @@ def train( else: console.print( "[yellow]--track-energy:[/] no reading " - "(install `pip install soup-cli[carbon]`)" + "(install `pip install soup-cli\\[carbon]`)" ) # --- v0.71.15 #244 --energy-out: persist for `soup bom emit --energy` - @@ -1504,7 +1504,7 @@ def train( else: console.print( "[yellow]--energy-out skipped:[/] no energy reading " - "(set --track-energy + install `pip install soup-cli[carbon]`)" + "(set --track-energy + install `pip install soup-cli\\[carbon]`)" ) # --- v0.59.0 --annex-xi: Annex XI/XII auto-doc ----------------------- diff --git a/src/soup_cli/commands/tui.py b/src/soup_cli/commands/tui.py index 9b2a509..1c9c66b 100644 --- a/src/soup_cli/commands/tui.py +++ b/src/soup_cli/commands/tui.py @@ -17,7 +17,7 @@ console = Console() def _missing_dep_panel() -> str: return ( "[red]Textual is not installed.[/]\n\n" - "[bold]Install:[/] pip install 'soup-cli[tui]'\n" + "[bold]Install:[/] pip install 'soup-cli\\[tui]'\n" "[dim]Or directly:[/] pip install textual" ) diff --git a/src/soup_cli/commands/ui.py b/src/soup_cli/commands/ui.py index 1af3467..69a90b1 100644 --- a/src/soup_cli/commands/ui.py +++ b/src/soup_cli/commands/ui.py @@ -68,7 +68,7 @@ def ui( except ImportError: console.print( "[red]FastAPI/uvicorn not installed.[/]\n" - "Install with: [bold]pip install 'soup-cli[ui]'[/]" + "Install with: [bold]pip install 'soup-cli\\[ui]'[/]" ) raise typer.Exit(1) diff --git a/tests/test_v07136.py b/tests/test_v07136.py index dbc1782..d9e10fb 100644 --- a/tests/test_v07136.py +++ b/tests/test_v07136.py @@ -378,6 +378,107 @@ class TestGreedySemdedup: rep.threshold = 0.5 +class TestExtrasHintsAreEscaped: + """Rich eats `[extra]` as a markup tag unless it is escaped. + + Unescaped, `pip install 'soup-cli[eval]'` renders as + `pip install 'soup-cli'` -- which installs the base package WITHOUT the + extra the user is missing, so following the hint appears to succeed and + the feature still fails. Found during v0.71.36; same class as the + v0.71.28 \\[mcp] fix, which only fixed its own site. + + Sites that do NOT go through Rich (plain exception text, docstrings) + must NOT be escaped -- a backslash would show up literally. + """ + + # Rich style tags. A hint string carrying one of these is console.print + # output; a bare `raise ImportError("... soup-cli[mlx]")` is NOT and must + # be left alone (escaping it would print a literal backslash). + _RICH_TAGS = ("[/]", "[bold]", "[red]", "[yellow]", "[green]", "[dim]") + + def test_rich_renders_unescaped_bracket_away(self): + """Pin the underlying Rich behaviour this whole class exists for.""" + from io import StringIO + + from rich.console import Console + + def render(markup): + buf = StringIO() + Console(file=buf, force_terminal=False, width=100).print(markup) + return buf.getvalue().strip() + + assert render("[bold]pip install 'soup-cli[train]'[/]") == ( + "pip install 'soup-cli'" + ), "unescaped bracket must be eaten (this is the bug)" + assert render("[bold]pip install 'soup-cli\\[train]'[/]") == ( + "pip install 'soup-cli[train]'" + ), "escaped bracket must survive (this is the fix)" + + def test_no_unescaped_extras_hint_in_rich_markup(self): + """Every hint carrying Rich markup must escape its bracket.""" + import pathlib + import re + + import soup_cli + + root = pathlib.Path(soup_cli.__file__).parent + bad_re = re.compile(r"(?