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.
This commit is contained in:
Alpamys 2026-07-16 16:45:11 +05:00
parent 6f6630997e
commit 9b508d34d3
10 changed files with 118 additions and 17 deletions

View File

@ -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",

View File

@ -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)

View File

@ -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)

View File

@ -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]'[/]"
)

View File

@ -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

View File

@ -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)

View File

@ -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 -----------------------

View File

@ -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"
)

View File

@ -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)

View File

@ -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"(?<!\\)soup-cli\[[a-z][a-z0-9-]*\]")
offenders = []
for path in sorted(root.rglob("*.py")):
rel = path.relative_to(root).as_posix()
for lineno, line in enumerate(
path.read_text(encoding="utf-8").splitlines(), start=1
):
if line.strip().startswith("#"):
continue # a code comment never reaches Rich
if not any(tag in line for tag in self._RICH_TAGS):
continue # not Rich output — must NOT be escaped
if bad_re.search(line):
offenders.append(f"{rel}:{lineno}: {line.strip()}")
assert not offenders, (
"unescaped soup-cli[extra] inside Rich markup — the bracket is "
"eaten and the printed command installs WITHOUT the extra:\n"
+ "\n".join(offenders)
)
@pytest.mark.parametrize(
"argv,extra",
[
(["data", "dedup", "--help"], "soup-cli[train]"),
(["train", "--help"], "soup-cli[carbon]"),
],
)
def test_typer_help_keeps_the_extra_bracket(self, argv, extra):
"""Typer help is Rich-rendered too — the bracket must survive.
`--semantic` help shipped as "Requires soup-cli." before this fix.
"""
from typer.testing import CliRunner
from soup_cli.cli import app
res = CliRunner(env={"COLUMNS": "200"}).invoke(app, argv)
assert res.exit_code == 0, (res.output, repr(res.exception))
assert extra in _clean(res.output), (
f"{extra!r} missing from `{' '.join(argv)}` — Rich ate the bracket"
)
def test_plain_exception_hints_are_not_escaped(self):
"""The counter-rule: non-Rich text must NOT gain a backslash.
`raise ImportError("... pip install 'soup-cli[mlx]'")` never reaches
Rich, so escaping it would surface a literal backslash to the user.
"""
import pathlib
import soup_cli
root = pathlib.Path(soup_cli.__file__).parent
text = (root / "trainer" / "mlx_sft.py").read_text(encoding="utf-8")
assert "'soup-cli[mlx]'" in text
assert "soup-cli\\[mlx]" not in text
class TestDedupSemanticCli:
def _write(self, tmp_path, rows):
path = tmp_path / "data.jsonl"