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.
This commit is contained in:
2026-05-17 16:47:52 +02:00
parent 0a2da5a98b
commit af61ba79c9
2 changed files with 281 additions and 22 deletions
+85 -22
View File
@@ -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
# =============================================================================
+196
View File
@@ -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