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
|
|
@ -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, "")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue