From af61ba79c95517b2b084521406cfea3f630fb2d9 Mon Sep 17 00:00:00 2001 From: aladac Date: Sun, 17 May 2026 16:47:52 +0200 Subject: [PATCH] feat(cli): add --list and --style filter flags to style-sweep --list/-L prints the resolved styles list as a two-column rich table (slug + suffix truncated to ~80 chars) and exits without generating. Template becomes optional when --list is paired with an explicit --styles source, so you can inspect any styles file standalone. --style/-S SLUG selects a single style by exact slug match; repeatable for multiple. Unknown slugs error red with the available slug list. Filter applies before --limit and preserves the source file's order. Both flags compose with --limit and --dry-run; when filtering down to a subset, the manifest is still written for the smaller run. --- tensors/cli.py | 107 ++++++++++++++++----- tests/test_style_sweep.py | 196 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 281 insertions(+), 22 deletions(-) diff --git a/tensors/cli.py b/tensors/cli.py index f5a1f36..16228e7 100644 --- a/tensors/cli.py +++ b/tensors/cli.py @@ -1324,12 +1324,12 @@ def _normalize_styles(styles_data: Any) -> list[dict[str, str]]: @app.command(name="style-sweep") def style_sweep( # noqa: PLR0915 template: Annotated[ - Path, + Path | None, typer.Option("--template", "-t", help="Path to template JSON (mirrors `generate --input` keys + output_dir/styles)"), - ], + ] = None, styles: Annotated[ str | None, - typer.Option("--styles", help="Override styles source: path to JSON or inline JSON list/object"), + typer.Option("--styles", help="Styles source: path to JSON or inline JSON list/object (overrides template's styles)"), ] = None, output_dir: Annotated[ Path | None, @@ -1337,8 +1337,16 @@ def style_sweep( # noqa: PLR0915 ] = None, limit: Annotated[ int | None, - typer.Option("--limit", help="Stop after N styles (useful for testing)"), + typer.Option("--limit", help="Stop after N styles (applied after --style filter)"), ] = None, + style_filter: Annotated[ + list[str] | None, + typer.Option("--style", "-S", help="Only run the named slug(s); repeatable for multiple"), + ] = None, + list_styles: Annotated[ + bool, + typer.Option("--list", "-L", help="Print resolved styles list and exit; no generation"), + ] = False, skip_existing: Annotated[ bool, typer.Option("--skip-existing/--no-skip-existing", help="Skip styles whose output file already exists"), @@ -1364,41 +1372,61 @@ def style_sweep( # noqa: PLR0915 Writes a manifest at {output_dir}/_sweep.json with per-style results. + With --list, just prints the resolved styles list (template optional in that + case if --styles is provided directly). + Examples: tsr style-sweep --template woman-black-dress.json tsr style-sweep -t template.json --styles styles.json --limit 3 tsr style-sweep -t template.json --dry-run tsr style-sweep -t template.json --remote junkpile + tsr style-sweep -t template.json --list + tsr style-sweep --styles styles.json --list + tsr style-sweep -t template.json -S 38-manara -S 40-elder-kurtzman """ - # ---- Load template ---- - if not template.is_file(): - console.print(f"[red]Template file not found:[/red] {template}") - raise typer.Exit(1) - try: - tpl_data = json.loads(template.read_text()) - except json.JSONDecodeError as e: - console.print(f"[red]Invalid JSON in template {template}:[/red] {e}") - raise typer.Exit(1) from e - if not isinstance(tpl_data, dict): - console.print("[red]Template JSON must be an object[/red]") + # ---- Validate required inputs ---- + # Template is required for generation, but optional when --list is paired + # with an explicit --styles source. + if template is None and not (list_styles and styles is not None): + console.print( + "[red]--template is required (or use --list with --styles to inspect a styles file)[/red]" + ) raise typer.Exit(1) - # Warn on unknown keys (don't error — forward-compat) - unknown = {k for k in tpl_data if not k.startswith("_") and k not in _STYLE_SWEEP_TEMPLATE_KEYS} - if unknown: - console.print(f"[yellow]Unknown template keys ignored:[/yellow] {sorted(unknown)}") + # ---- Load template (if provided) ---- + tpl_data: dict[str, Any] = {} + if template is not None: + if not template.is_file(): + console.print(f"[red]Template file not found:[/red] {template}") + raise typer.Exit(1) + try: + tpl_data = json.loads(template.read_text()) + except json.JSONDecodeError as e: + console.print(f"[red]Invalid JSON in template {template}:[/red] {e}") + raise typer.Exit(1) from e + if not isinstance(tpl_data, dict): + console.print("[red]Template JSON must be an object[/red]") + raise typer.Exit(1) - base_prompt = tpl_data.get("prompt") - if not base_prompt or not isinstance(base_prompt, str): + # Warn on unknown keys (don't error — forward-compat) + unknown = {k for k in tpl_data if not k.startswith("_") and k not in _STYLE_SWEEP_TEMPLATE_KEYS} + if unknown: + console.print(f"[yellow]Unknown template keys ignored:[/yellow] {sorted(unknown)}") + + # base_prompt is required for generation but irrelevant for --list + base_prompt = tpl_data.get("prompt") if template is not None else None + if not list_styles and (not base_prompt or not isinstance(base_prompt, str)): console.print("[red]Template missing required 'prompt' string[/red]") raise typer.Exit(1) # ---- Resolve styles source ---- # Relative paths inside the template are resolved against the template's # directory (so templates can ship next to their styles files). - tpl_dir = template.resolve().parent + tpl_dir = template.resolve().parent if template is not None else None def _resolve_relative_to_template(val: str) -> str: + if tpl_dir is None: + return val p = Path(val) if not p.is_absolute() and not p.exists(): alt = tpl_dir / p @@ -1425,12 +1453,32 @@ def style_sweep( # noqa: PLR0915 raise typer.Exit(1) style_entries = _normalize_styles(styles_source) + + # ---- Apply --style filter (exact slug match) ---- + if style_filter: + available = [e["slug"] for e in style_entries] + wanted = list(style_filter) + unknown_slugs = [s for s in wanted if s not in available] + if unknown_slugs: + console.print(f"[red]Unknown style slug(s):[/red] {', '.join(unknown_slugs)}") + console.print(f"[dim]Available slugs ({len(available)}):[/dim] {', '.join(available)}") + raise typer.Exit(1) + # Preserve order of the original styles list, but only keep wanted slugs + wanted_set = set(wanted) + style_entries = [e for e in style_entries if e["slug"] in wanted_set] + + # ---- Apply --limit (after filter) ---- if limit is not None: if limit < 0: console.print("[red]--limit must be >= 0[/red]") raise typer.Exit(1) style_entries = style_entries[:limit] + # ---- --list short-circuit: print and exit ---- + if list_styles: + _print_styles_list(styles_origin, style_entries) + return + # ---- Resolve output directory ---- out_dir: Path if output_dir is not None: @@ -1590,6 +1638,21 @@ def _write_sweep_manifest( return manifest_path +def _print_styles_list(styles_origin: str, entries: list[dict[str, str]]) -> None: + """Render the resolved styles as a two-column table. Suffixes truncated to ~80 chars.""" + max_suffix = 80 + console.print(f"[bold]Styles:[/bold] {styles_origin} ({len(entries)} entries)") + table = Table(show_header=True, header_style="bold", box=None, pad_edge=False) + table.add_column("SLUG", style="cyan", no_wrap=True) + table.add_column("SUFFIX", overflow="fold") + for entry in entries: + suffix = entry["suffix"] + if len(suffix) > max_suffix: + suffix = suffix[: max_suffix - 1].rstrip() + "…" + table.add_row(entry["slug"], suffix) + console.print(table) + + # ============================================================================= # Template Dump # ============================================================================= diff --git a/tests/test_style_sweep.py b/tests/test_style_sweep.py index 2a785b5..2132630 100644 --- a/tests/test_style_sweep.py +++ b/tests/test_style_sweep.py @@ -335,3 +335,199 @@ def test_remote_override(tmp_path: Path, calls: list[dict[str, Any]]) -> None: assert result.exit_code == 0, result.output assert calls[0]["remote"] == "junkpile" + + +# ----------------------------------------------------------------------------- +# --list flag +# ----------------------------------------------------------------------------- + + +def test_list_flag_prints_slugs(tmp_path: Path, calls: list[dict[str, Any]]) -> None: + """--list prints all slugs and does not call generate.""" + out_dir = tmp_path / "out" + slugs = [f"{i:02d}-style" for i in range(1, 5)] + styles_file = _write_styles_file( + tmp_path, [{"slug": s, "suffix": f"suffix for {s}"} for s in slugs] + ) + tpl = _write_template(tmp_path, output_dir=out_dir, styles=str(styles_file)) + + result = runner.invoke(app, ["style-sweep", "--template", str(tpl), "--list"]) + + assert result.exit_code == 0, result.output + assert len(calls) == 0 + for slug in slugs: + assert slug in result.output + # Header line names the file and count + assert "4 entries" in result.output + # No manifest written + assert not (out_dir / "_sweep.json").exists() + + +def test_list_with_limit(tmp_path: Path, calls: list[dict[str, Any]]) -> None: + """--list --limit N restricts the table to the first N entries.""" + out_dir = tmp_path / "out" + styles_file = _write_styles_file( + tmp_path, [{"slug": f"{i:02d}-x", "suffix": f"s{i}"} for i in range(1, 6)] + ) + tpl = _write_template(tmp_path, output_dir=out_dir, styles=str(styles_file)) + + result = runner.invoke( + app, ["style-sweep", "--template", str(tpl), "--list", "--limit", "2"] + ) + + assert result.exit_code == 0, result.output + assert "01-x" in result.output + assert "02-x" in result.output + assert "03-x" not in result.output + assert "05-x" not in result.output + + +def test_list_without_template(tmp_path: Path, calls: list[dict[str, Any]]) -> None: + """--styles + --list works without --template.""" + styles_file = _write_styles_file( + tmp_path, + [ + {"slug": "alpha", "suffix": "Alpha suffix"}, + {"slug": "beta", "suffix": "Beta suffix"}, + ], + ) + + result = runner.invoke( + app, ["style-sweep", "--styles", str(styles_file), "--list"] + ) + + assert result.exit_code == 0, result.output + assert "alpha" in result.output + assert "beta" in result.output + assert "2 entries" in result.output + + +def test_list_long_suffix_truncated(tmp_path: Path) -> None: + """Long suffixes are truncated with an ellipsis.""" + long_suffix = "very long " * 20 # ~200 chars + styles_file = _write_styles_file( + tmp_path, [{"slug": "long", "suffix": long_suffix}] + ) + + result = runner.invoke( + app, ["style-sweep", "--styles", str(styles_file), "--list"] + ) + + assert result.exit_code == 0, result.output + assert "long" in result.output + assert "…" in result.output + # Full suffix should not appear verbatim + assert long_suffix not in result.output + + +# ----------------------------------------------------------------------------- +# --style filter +# ----------------------------------------------------------------------------- + + +def test_style_filter_single(tmp_path: Path, calls: list[dict[str, Any]]) -> None: + """--style SLUG only runs the matching entry.""" + out_dir = tmp_path / "out" + styles_file = _write_styles_file( + tmp_path, + [ + {"slug": "01-foo", "suffix": "Foo"}, + {"slug": "02-bar", "suffix": "Bar"}, + {"slug": "03-baz", "suffix": "Baz"}, + ], + ) + tpl = _write_template(tmp_path, output_dir=out_dir, styles=str(styles_file)) + + result = runner.invoke( + app, ["style-sweep", "--template", str(tpl), "--style", "02-bar"] + ) + + assert result.exit_code == 0, result.output + assert len(calls) == 1 + assert calls[0]["output"] == out_dir / "02-bar.png" + assert calls[0]["prompt"].endswith("Bar") + + +def test_style_filter_multiple(tmp_path: Path, calls: list[dict[str, Any]]) -> None: + """Multiple --style flags select multiple entries (preserving styles-file order).""" + out_dir = tmp_path / "out" + styles_file = _write_styles_file( + tmp_path, + [ + {"slug": "01-a", "suffix": "A"}, + {"slug": "02-b", "suffix": "B"}, + {"slug": "03-c", "suffix": "C"}, + {"slug": "04-d", "suffix": "D"}, + ], + ) + tpl = _write_template(tmp_path, output_dir=out_dir, styles=str(styles_file)) + + # Note: pass in non-sorted order; filter should preserve source order. + result = runner.invoke( + app, + [ + "style-sweep", + "--template", str(tpl), + "-S", "03-c", + "-S", "01-a", + ], + ) + + assert result.exit_code == 0, result.output + assert len(calls) == 2 + assert calls[0]["output"].name == "01-a.png" + assert calls[1]["output"].name == "03-c.png" + + +def test_style_filter_unknown_slug(tmp_path: Path, calls: list[dict[str, Any]]) -> None: + """An unknown slug aborts with exit 1 and lists available slugs.""" + out_dir = tmp_path / "out" + styles_file = _write_styles_file( + tmp_path, + [ + {"slug": "01-foo", "suffix": "Foo"}, + {"slug": "02-bar", "suffix": "Bar"}, + ], + ) + tpl = _write_template(tmp_path, output_dir=out_dir, styles=str(styles_file)) + + result = runner.invoke( + app, ["style-sweep", "--template", str(tpl), "--style", "99-nope"] + ) + + assert result.exit_code == 1, result.output + assert "99-nope" in result.output + # Available slugs printed for the user + assert "01-foo" in result.output + assert "02-bar" in result.output + # No generation + assert len(calls) == 0 + + +def test_style_filter_with_list(tmp_path: Path, calls: list[dict[str, Any]]) -> None: + """--list --style SLUG shows only the filtered entry.""" + styles_file = _write_styles_file( + tmp_path, + [ + {"slug": "01-foo", "suffix": "Foo suffix"}, + {"slug": "02-bar", "suffix": "Bar suffix"}, + {"slug": "03-baz", "suffix": "Baz suffix"}, + ], + ) + + result = runner.invoke( + app, + [ + "style-sweep", + "--styles", str(styles_file), + "--list", + "--style", "02-bar", + ], + ) + + assert result.exit_code == 0, result.output + assert len(calls) == 0 + assert "02-bar" in result.output + assert "01-foo" not in result.output + assert "03-baz" not in result.output + assert "1 entries" in result.output