refactor: use project-named state file and store prompt/params directly
All checks were successful
Continuous Integration / Build Package (push) Successful in 48s
Continuous Integration / Lint, Check & Test (push) Successful in 1m1s

- 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:
Konstantin Fickel 2026-02-15 13:56:12 +01:00
parent 870023865d
commit 0ecf1f0f9e
Signed by: kfickel
GPG key ID: A793722F9933C1A5
7 changed files with 98 additions and 82 deletions

View file

@ -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,
)