refactor: use project-named state file and store prompt/params directly
- State filename now derives from config: cards.bulkgen.yaml produces .cards.bulkgen-state.yaml instead of .bulkgen.state.yaml - Store resolved prompt text and extra params directly in state file instead of hashing them, making state files human-readable - Only file input contents remain hashed (SHA-256) - Thread project_name through builder and CLI - Remove hash_string() and _extra_hash() helpers - Update .gitignore pattern to .*.bulkgen-state.yaml
This commit is contained in:
parent
870023865d
commit
0ecf1f0f9e
7 changed files with 98 additions and 82 deletions
|
|
@ -147,6 +147,7 @@ async def _build_single_target(
|
|||
async def run_build(
|
||||
config: ProjectConfig,
|
||||
project_dir: Path,
|
||||
project_name: str,
|
||||
target: str | None = None,
|
||||
on_progress: ProgressCallback = _noop_callback,
|
||||
) -> BuildResult:
|
||||
|
|
@ -171,7 +172,7 @@ async def run_build(
|
|||
raise ValueError(msg)
|
||||
graph = get_subgraph_for_target(graph, target)
|
||||
|
||||
state = load_state(project_dir)
|
||||
state = load_state(project_dir, project_name)
|
||||
generations = get_build_order(graph)
|
||||
target_names = set(config.targets)
|
||||
|
||||
|
|
@ -209,7 +210,7 @@ async def run_build(
|
|||
)
|
||||
|
||||
_process_outcomes(outcomes, config, project_dir, state, result, on_progress)
|
||||
save_state(state, project_dir)
|
||||
save_state(state, project_dir, project_name)
|
||||
|
||||
return result
|
||||
|
||||
|
|
|
|||
|
|
@ -14,9 +14,21 @@ from bulkgen.builder import BuildEvent, BuildResult, run_build
|
|||
from bulkgen.config import ProjectConfig, load_config
|
||||
from bulkgen.graph import build_graph, get_build_order
|
||||
from bulkgen.providers.registry import get_all_models
|
||||
from bulkgen.state import state_filename
|
||||
|
||||
app = typer.Typer(name="bulkgen", help="AI artifact build tool.")
|
||||
|
||||
_CONFIG_SUFFIX = ".bulkgen.yaml"
|
||||
|
||||
|
||||
def _project_name(config_path: Path) -> str:
|
||||
"""Derive the project name from a config path.
|
||||
|
||||
``cards.bulkgen.yaml`` → ``cards``
|
||||
"""
|
||||
name = config_path.name
|
||||
return name.removesuffix(_CONFIG_SUFFIX)
|
||||
|
||||
|
||||
def _find_config(directory: Path) -> Path:
|
||||
"""Find the single ``*.bulkgen.yaml`` file in *directory*."""
|
||||
|
|
@ -49,7 +61,7 @@ def _format_elapsed(seconds: float) -> str:
|
|||
|
||||
|
||||
def _run_build(
|
||||
config: ProjectConfig, project_dir: Path, target: str | None
|
||||
config: ProjectConfig, project_dir: Path, project_name: str, target: str | None
|
||||
) -> tuple[BuildResult, float]:
|
||||
"""Run the async build with click-styled progress output and timing."""
|
||||
|
||||
|
|
@ -85,7 +97,7 @@ def _run_build(
|
|||
|
||||
start = time.monotonic()
|
||||
result = asyncio.run(
|
||||
run_build(config, project_dir, target, on_progress=on_progress)
|
||||
run_build(config, project_dir, project_name, target, on_progress=on_progress)
|
||||
)
|
||||
elapsed = time.monotonic() - start
|
||||
|
||||
|
|
@ -102,10 +114,11 @@ def build(
|
|||
project_dir = Path.cwd()
|
||||
config_path = _find_config(project_dir)
|
||||
config = load_config(config_path)
|
||||
name = _project_name(config_path)
|
||||
|
||||
click.echo(click.style("bulkgen", fg="cyan", bold=True) + " building targets...\n")
|
||||
|
||||
result, elapsed = _run_build(config, project_dir, target)
|
||||
result, elapsed = _run_build(config, project_dir, name, target)
|
||||
|
||||
# Summary
|
||||
click.echo("")
|
||||
|
|
@ -131,6 +144,7 @@ def clean() -> None:
|
|||
project_dir = Path.cwd()
|
||||
config_path = _find_config(project_dir)
|
||||
config = load_config(config_path)
|
||||
state_name = state_filename(_project_name(config_path))
|
||||
|
||||
removed = 0
|
||||
for target_name in config.targets:
|
||||
|
|
@ -140,10 +154,10 @@ def clean() -> None:
|
|||
click.echo(click.style(" rm ", fg="red") + target_name)
|
||||
removed += 1
|
||||
|
||||
state_path = project_dir / ".bulkgen.state.yaml"
|
||||
state_path = project_dir / state_name
|
||||
if state_path.exists():
|
||||
state_path.unlink()
|
||||
click.echo(click.style(" rm ", fg="red") + ".bulkgen.state.yaml")
|
||||
click.echo(click.style(" rm ", fg="red") + state_name)
|
||||
|
||||
click.echo(click.style(f"\nCleaned {removed} artifact(s)", bold=True))
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
"""Incremental build state tracking via ``.bulkgen.state.yaml``."""
|
||||
"""Incremental build state tracking via ``.<project>.bulkgen-state.yaml``."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
|
@ -8,16 +8,23 @@ from pathlib import Path
|
|||
import yaml
|
||||
from pydantic import BaseModel
|
||||
|
||||
STATE_FILENAME = ".bulkgen.state.yaml"
|
||||
|
||||
def state_filename(project_name: str) -> str:
|
||||
"""Return the state filename for a given project name.
|
||||
|
||||
For a config file named ``cards.bulkgen.yaml`` the project name is
|
||||
``cards`` and the state file is ``.cards.bulkgen-state.yaml``.
|
||||
"""
|
||||
return f".{project_name}.bulkgen-state.yaml"
|
||||
|
||||
|
||||
class TargetState(BaseModel):
|
||||
"""Recorded state of a single target from its last successful build."""
|
||||
|
||||
input_hashes: dict[str, str]
|
||||
prompt_hash: str
|
||||
prompt: str
|
||||
model: str
|
||||
extra_hash: str = ""
|
||||
extra_params: dict[str, object] = {}
|
||||
|
||||
|
||||
class BuildState(BaseModel):
|
||||
|
|
@ -35,14 +42,9 @@ def hash_file(path: Path) -> str:
|
|||
return h.hexdigest()
|
||||
|
||||
|
||||
def hash_string(value: str) -> str:
|
||||
"""Compute the SHA-256 hex digest of a string."""
|
||||
return hashlib.sha256(value.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def load_state(project_dir: Path) -> BuildState:
|
||||
def load_state(project_dir: Path, project_name: str) -> BuildState:
|
||||
"""Load build state from disk, returning empty state if the file is missing."""
|
||||
state_path = project_dir / STATE_FILENAME
|
||||
state_path = project_dir / state_filename(project_name)
|
||||
if not state_path.exists():
|
||||
return BuildState()
|
||||
with state_path.open() as f:
|
||||
|
|
@ -52,20 +54,13 @@ def load_state(project_dir: Path) -> BuildState:
|
|||
return BuildState.model_validate(raw)
|
||||
|
||||
|
||||
def save_state(state: BuildState, project_dir: Path) -> None:
|
||||
def save_state(state: BuildState, project_dir: Path, project_name: str) -> None:
|
||||
"""Persist build state to disk."""
|
||||
state_path = project_dir / STATE_FILENAME
|
||||
state_path = project_dir / state_filename(project_name)
|
||||
with state_path.open("w") as f:
|
||||
yaml.dump(state.model_dump(), f, default_flow_style=False, sort_keys=False)
|
||||
|
||||
|
||||
def _extra_hash(params: dict[str, object]) -> str:
|
||||
"""Hash extra target parameters (width, height, etc.) for change detection."""
|
||||
if not params:
|
||||
return ""
|
||||
return hash_string(str(sorted(params.items())))
|
||||
|
||||
|
||||
def is_target_dirty(
|
||||
target_name: str,
|
||||
*,
|
||||
|
|
@ -98,10 +93,10 @@ def is_target_dirty(
|
|||
if prev.model != model:
|
||||
return True
|
||||
|
||||
if prev.prompt_hash != hash_string(resolved_prompt):
|
||||
if prev.prompt != resolved_prompt:
|
||||
return True
|
||||
|
||||
if prev.extra_hash != _extra_hash(extra_params):
|
||||
if prev.extra_params != extra_params:
|
||||
return True
|
||||
|
||||
for dep_path in dep_files:
|
||||
|
|
@ -131,7 +126,7 @@ def record_target_state(
|
|||
|
||||
state.targets[target_name] = TargetState(
|
||||
input_hashes=input_hashes,
|
||||
prompt_hash=hash_string(resolved_prompt),
|
||||
prompt=resolved_prompt,
|
||||
model=model,
|
||||
extra_hash=_extra_hash(extra_params),
|
||||
extra_params=extra_params,
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue