From ee6c411f3cbf7876026f8c125ddfac83ac249d73 Mon Sep 17 00:00:00 2001 From: Konstantin Fickel Date: Sat, 14 Feb 2026 21:25:38 +0100 Subject: [PATCH] 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 --- bulkgen/builder.py | 49 +++++++++++++++++--- bulkgen/cli.py | 109 ++++++++++++++++++++++++++++++++++++++------- pyproject.toml | 1 + tests/test_cli.py | 6 +-- uv.lock | 2 + 5 files changed, 141 insertions(+), 26 deletions(-) diff --git a/bulkgen/builder.py b/bulkgen/builder.py index 7c529a9..5fcc981 100644 --- a/bulkgen/builder.py +++ b/bulkgen/builder.py @@ -3,12 +3,12 @@ from __future__ import annotations import asyncio +import enum import os +from collections.abc import Callable from dataclasses import dataclass, field from pathlib import Path -import typer - from bulkgen.config import ( ProjectConfig, TargetType, @@ -28,6 +28,25 @@ from bulkgen.state import ( ) +class BuildEvent(enum.Enum): + """Events emitted during a build for progress reporting.""" + + TARGET_SKIPPED = "skipped" + TARGET_BUILDING = "building" + TARGET_OK = "ok" + TARGET_FAILED = "failed" + TARGET_DEP_FAILED = "dep_failed" + TARGET_NO_PROVIDER = "no_provider" + + +ProgressCallback = Callable[[BuildEvent, str, str], None] +"""Signature: (event, target_name, detail_message).""" + + +def _noop_callback(_event: BuildEvent, _name: str, _detail: str) -> None: + """Default no-op progress callback.""" + + @dataclass class BuildResult: """Summary of a build run.""" @@ -35,6 +54,7 @@ class BuildResult: built: list[str] = field(default_factory=list) skipped: list[str] = field(default_factory=list) failed: dict[str, str] = field(default_factory=dict) + total_targets: int = 0 def _resolve_prompt(prompt_value: str, project_dir: Path) -> str: @@ -118,6 +138,7 @@ async def run_build( config: ProjectConfig, project_dir: Path, target: str | None = None, + on_progress: ProgressCallback = _noop_callback, ) -> BuildResult: """Execute the build. @@ -143,6 +164,11 @@ async def run_build( generations = get_build_order(graph) target_names = set(config.targets) + # Count total buildable targets for progress reporting. + result.total_targets = sum( + 1 for gen in generations for n in gen if n in target_names + ) + for generation in generations: targets_in_gen = [n for n in generation if n in target_names] @@ -150,23 +176,28 @@ async def run_build( for name in targets_in_gen: if _should_skip_failed_dep(name, config, result): result.failed[name] = "Dependency failed" + on_progress(BuildEvent.TARGET_DEP_FAILED, name, "Dependency failed") continue if _is_dirty(name, config, project_dir, state): - if not _has_provider(name, providers, result): + if not _has_provider(name, providers, result, on_progress): continue dirty_targets.append(name) else: result.skipped.append(name) + on_progress(BuildEvent.TARGET_SKIPPED, name, "up to date") if not dirty_targets: continue + for name in dirty_targets: + on_progress(BuildEvent.TARGET_BUILDING, name, "") + outcomes = await _build_generation( dirty_targets, config, project_dir, providers ) - _process_outcomes(outcomes, config, project_dir, state, result) + _process_outcomes(outcomes, config, project_dir, state, result, on_progress) save_state(state, project_dir) return result @@ -207,6 +238,7 @@ def _has_provider( target_name: str, providers: dict[TargetType, Provider], result: BuildResult, + on_progress: ProgressCallback = _noop_callback, ) -> bool: """Check that the required provider is available; record failure if not.""" target_type = infer_target_type(target_name) @@ -214,7 +246,9 @@ def _has_provider( env_var = ( "BFL_API_KEY" if target_type is TargetType.IMAGE else "MISTRAL_API_KEY" ) - result.failed[target_name] = f"Missing {env_var} environment variable" + msg = f"Missing {env_var} environment variable" + result.failed[target_name] = msg + on_progress(BuildEvent.TARGET_NO_PROVIDER, target_name, msg) return False return True @@ -243,12 +277,13 @@ def _process_outcomes( project_dir: Path, state: BuildState, result: BuildResult, + on_progress: ProgressCallback = _noop_callback, ) -> None: """Process build outcomes: record state for successes, log failures.""" for name, error in outcomes: if error is not None: result.failed[name] = str(error) - typer.echo(f"FAIL: {name} -- {error}", err=True) + on_progress(BuildEvent.TARGET_FAILED, name, str(error)) else: target_cfg = config.targets[name] model = resolve_model(name, target_cfg, config.defaults) @@ -266,4 +301,4 @@ def _process_outcomes( project_dir=project_dir, ) result.built.append(name) - typer.echo(f" OK: {name}") + on_progress(BuildEvent.TARGET_OK, name, "") diff --git a/bulkgen/cli.py b/bulkgen/cli.py index 6c7cb8c..550cc06 100644 --- a/bulkgen/cli.py +++ b/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)}") diff --git a/pyproject.toml b/pyproject.toml index 7aa84b8..6c8a62e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,6 +5,7 @@ description = "Bulk-Generate Images with Generative AI" readme = "README.md" requires-python = ">=3.13" dependencies = [ + "click>=8.0.0", "httpx>=0.27.0", "mistralai>=1.0.0", "networkx>=3.6.1", diff --git a/tests/test_cli.py b/tests/test_cli.py index ca9a187..0b3df92 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -77,7 +77,7 @@ class TestBuildCommand: result = runner.invoke(app, ["build"]) assert result.exit_code == 0 - assert "Built 2 target(s)" in result.output + assert "2 built" in result.output def test_build_with_skipped(self, cli_project: Path) -> None: build_result = BuildResult( @@ -95,7 +95,7 @@ class TestBuildCommand: result = runner.invoke(app, ["build"]) assert result.exit_code == 0 - assert "Skipped 2 target(s) (up to date)" in result.output + assert "2 skipped" in result.output def test_build_with_failures(self, cli_project: Path) -> None: build_result = BuildResult( @@ -115,7 +115,7 @@ class TestBuildCommand: result = runner.invoke(app, ["build"]) assert result.exit_code == 1 - assert "Failed 1 target(s)" in result.output + assert "1 failed" in result.output def test_build_specific_target(self, cli_project: Path) -> None: build_result = BuildResult(built=["output.txt"], skipped=[], failed={}) diff --git a/uv.lock b/uv.lock index e65d1ff..34771f5 100644 --- a/uv.lock +++ b/uv.lock @@ -49,6 +49,7 @@ name = "bulkgen" version = "0.1.0" source = { editable = "." } dependencies = [ + { name = "click" }, { name = "httpx" }, { name = "mistralai" }, { name = "networkx" }, @@ -67,6 +68,7 @@ dev = [ [package.metadata] requires-dist = [ + { name = "click", specifier = ">=8.0.0" }, { name = "httpx", specifier = ">=0.27.0" }, { name = "mistralai", specifier = ">=1.0.0" }, { name = "networkx", specifier = ">=3.6.1" },