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:
+71
-8
@@ -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,13 +1372,30 @@ 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 ----
|
||||
# ---- 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)
|
||||
|
||||
# ---- 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)
|
||||
@@ -1388,17 +1413,20 @@ def style_sweep( # noqa: PLR0915
|
||||
if unknown:
|
||||
console.print(f"[yellow]Unknown template keys ignored:[/yellow] {sorted(unknown)}")
|
||||
|
||||
base_prompt = tpl_data.get("prompt")
|
||||
if not base_prompt or not isinstance(base_prompt, str):
|
||||
# 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
|
||||
# =============================================================================
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user