feat: add click-based colorized output with progress events and build timer
- Add click as explicit dependency (already bundled with typer) - Replace typer.echo calls with click.echo + click.style for colorized output - Add BuildEvent enum and ProgressCallback to builder for decoupled progress reporting - Remove direct typer dependency from builder module - Show per-target status with colored labels (skip/ok/fail/...) - Display elapsed build time in summary - Colorize graph and clean command output - Update CLI tests to match new output format
This commit is contained in:
parent
6a9d7efd5d
commit
ee6c411f3c
5 changed files with 141 additions and 26 deletions
109
bulkgen/cli.py
109
bulkgen/cli.py
|
|
@ -3,13 +3,15 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Annotated
|
||||
|
||||
import click
|
||||
import typer
|
||||
|
||||
from bulkgen.builder import run_build
|
||||
from bulkgen.config import load_config
|
||||
from bulkgen.builder import BuildEvent, BuildResult, run_build
|
||||
from bulkgen.config import ProjectConfig, load_config
|
||||
from bulkgen.graph import build_graph, get_build_order
|
||||
|
||||
app = typer.Typer(name="bulkgen", help="AI artifact build tool.")
|
||||
|
|
@ -19,15 +21,76 @@ def _find_config(directory: Path) -> Path:
|
|||
"""Find the single ``*.bulkgen.yaml`` file in *directory*."""
|
||||
candidates = list(directory.glob("*.bulkgen.yaml"))
|
||||
if len(candidates) == 0:
|
||||
typer.echo("Error: No .bulkgen.yaml file found in current directory", err=True)
|
||||
click.echo(
|
||||
click.style("Error: ", fg="red", bold=True)
|
||||
+ "No .bulkgen.yaml file found in current directory",
|
||||
err=True,
|
||||
)
|
||||
raise typer.Exit(code=1)
|
||||
if len(candidates) > 1:
|
||||
names = ", ".join(str(c.name) for c in candidates)
|
||||
typer.echo(f"Error: Multiple .bulkgen.yaml files found: {names}", err=True)
|
||||
click.echo(
|
||||
click.style("Error: ", fg="red", bold=True)
|
||||
+ f"Multiple .bulkgen.yaml files found: {names}",
|
||||
err=True,
|
||||
)
|
||||
raise typer.Exit(code=1)
|
||||
return candidates[0]
|
||||
|
||||
|
||||
def _format_elapsed(seconds: float) -> str:
|
||||
"""Format elapsed time as a human-readable string."""
|
||||
if seconds < 60:
|
||||
return f"{seconds:.1f}s"
|
||||
minutes = int(seconds // 60)
|
||||
secs = seconds % 60
|
||||
return f"{minutes}m{secs:.1f}s"
|
||||
|
||||
|
||||
def _run_build(
|
||||
config: ProjectConfig, project_dir: Path, target: str | None
|
||||
) -> tuple[BuildResult, float]:
|
||||
"""Run the async build with click-styled progress output and timing."""
|
||||
|
||||
def on_progress(event: BuildEvent, name: str, detail: str) -> None:
|
||||
if event is BuildEvent.TARGET_SKIPPED:
|
||||
click.echo(
|
||||
click.style(" skip ", fg="yellow")
|
||||
+ click.style(name, bold=True)
|
||||
+ click.style(f" ({detail})", dim=True)
|
||||
)
|
||||
elif event is BuildEvent.TARGET_BUILDING:
|
||||
click.echo(click.style(" ... ", fg="cyan") + click.style(name, bold=True))
|
||||
elif event is BuildEvent.TARGET_OK:
|
||||
click.echo(click.style(" ok ", fg="green") + click.style(name, bold=True))
|
||||
elif event is BuildEvent.TARGET_FAILED:
|
||||
click.echo(
|
||||
click.style(" fail ", fg="red", bold=True)
|
||||
+ click.style(name, bold=True)
|
||||
+ f" {detail}"
|
||||
)
|
||||
elif event is BuildEvent.TARGET_DEP_FAILED:
|
||||
click.echo(
|
||||
click.style(" fail ", fg="red")
|
||||
+ click.style(name, bold=True)
|
||||
+ click.style(f" ({detail})", dim=True)
|
||||
)
|
||||
elif event is BuildEvent.TARGET_NO_PROVIDER:
|
||||
click.echo(
|
||||
click.style(" fail ", fg="red", bold=True)
|
||||
+ click.style(name, bold=True)
|
||||
+ f" {detail}"
|
||||
)
|
||||
|
||||
start = time.monotonic()
|
||||
result = asyncio.run(
|
||||
run_build(config, project_dir, target, on_progress=on_progress)
|
||||
)
|
||||
elapsed = time.monotonic() - start
|
||||
|
||||
return result, elapsed
|
||||
|
||||
|
||||
@app.command()
|
||||
def build(
|
||||
target: Annotated[
|
||||
|
|
@ -39,16 +102,25 @@ def build(
|
|||
config_path = _find_config(project_dir)
|
||||
config = load_config(config_path)
|
||||
|
||||
result = asyncio.run(run_build(config, project_dir, target))
|
||||
click.echo(click.style("bulkgen", fg="cyan", bold=True) + " building targets...\n")
|
||||
|
||||
result, elapsed = _run_build(config, project_dir, target)
|
||||
|
||||
# Summary
|
||||
click.echo("")
|
||||
parts: list[str] = []
|
||||
if result.built:
|
||||
typer.echo(f"\nBuilt {len(result.built)} target(s)")
|
||||
parts.append(click.style(f"{len(result.built)} built", fg="green", bold=True))
|
||||
if result.skipped:
|
||||
typer.echo(f"Skipped {len(result.skipped)} target(s) (up to date)")
|
||||
parts.append(click.style(f"{len(result.skipped)} skipped", fg="yellow"))
|
||||
if result.failed:
|
||||
parts.append(click.style(f"{len(result.failed)} failed", fg="red", bold=True))
|
||||
|
||||
summary = ", ".join(parts) if parts else click.style("nothing to do", dim=True)
|
||||
timer = click.style(f" in {_format_elapsed(elapsed)}", dim=True)
|
||||
click.echo(summary + timer)
|
||||
|
||||
if result.failed:
|
||||
typer.echo(f"Failed {len(result.failed)} target(s):", err=True)
|
||||
for name, err in result.failed.items():
|
||||
typer.echo(f" {name}: {err}", err=True)
|
||||
raise typer.Exit(code=1)
|
||||
|
||||
|
||||
|
|
@ -64,15 +136,15 @@ def clean() -> None:
|
|||
target_path = project_dir / target_name
|
||||
if target_path.exists():
|
||||
target_path.unlink()
|
||||
typer.echo(f"Removed: {target_name}")
|
||||
click.echo(click.style(" rm ", fg="red") + target_name)
|
||||
removed += 1
|
||||
|
||||
state_path = project_dir / ".bulkgen.state.yaml"
|
||||
if state_path.exists():
|
||||
state_path.unlink()
|
||||
typer.echo("Removed: .bulkgen.state.yaml")
|
||||
click.echo(click.style(" rm ", fg="red") + ".bulkgen.state.yaml")
|
||||
|
||||
typer.echo(f"Cleaned {removed} artifact(s)")
|
||||
click.echo(click.style(f"\nCleaned {removed} artifact(s)", bold=True))
|
||||
|
||||
|
||||
@app.command()
|
||||
|
|
@ -91,11 +163,16 @@ def graph() -> None:
|
|||
externals_in_gen = [n for n in gen if n not in target_names]
|
||||
|
||||
if externals_in_gen:
|
||||
typer.echo(f"Stage {i} (inputs): {', '.join(externals_in_gen)}")
|
||||
label = click.style(f"Stage {i}", fg="cyan", bold=True)
|
||||
kind = click.style(" (inputs)", dim=True)
|
||||
click.echo(f"{label}{kind}: {', '.join(externals_in_gen)}")
|
||||
if targets_in_gen:
|
||||
typer.echo(f"Stage {i} (targets): {', '.join(targets_in_gen)}")
|
||||
label = click.style(f"Stage {i}", fg="green", bold=True)
|
||||
kind = click.style(" (targets)", dim=True)
|
||||
click.echo(f"{label}{kind}: {', '.join(targets_in_gen)}")
|
||||
|
||||
for node in gen:
|
||||
preds: list[str] = list(dep_graph.predecessors(node))
|
||||
if preds:
|
||||
typer.echo(f" {node} <- {', '.join(preds)}")
|
||||
arrow = click.style(" <- ", dim=True)
|
||||
click.echo(f" {node}{arrow}{', '.join(preds)}")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue