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

@ -23,6 +23,8 @@ from bulkgen.state import load_state
WriteConfig = Callable[[dict[str, object]], ProjectConfig]
_PROJECT = "project"
_FAKE_TEXT_MODELS = [
ModelInfo(
@ -196,7 +198,7 @@ class TestRunBuild:
self, project_dir: Path, simple_text_config: ProjectConfig
) -> None:
with patch("bulkgen.builder._create_providers", return_value=_fake_providers()):
result = await run_build(simple_text_config, project_dir)
result = await run_build(simple_text_config, project_dir, _PROJECT)
assert result.built == ["output.txt"]
assert result.skipped == []
@ -207,7 +209,7 @@ class TestRunBuild:
self, project_dir: Path, multi_target_config: ProjectConfig
) -> None:
with patch("bulkgen.builder._create_providers", return_value=_fake_providers()):
result = await run_build(multi_target_config, project_dir)
result = await run_build(multi_target_config, project_dir, _PROJECT)
assert "summary.md" in result.built
assert "final.txt" in result.built
@ -222,10 +224,10 @@ class TestRunBuild:
self, project_dir: Path, simple_text_config: ProjectConfig
) -> None:
with patch("bulkgen.builder._create_providers", return_value=_fake_providers()):
result1 = await run_build(simple_text_config, project_dir)
result1 = await run_build(simple_text_config, project_dir, _PROJECT)
assert result1.built == ["output.txt"]
result2 = await run_build(simple_text_config, project_dir)
result2 = await run_build(simple_text_config, project_dir, _PROJECT)
assert result2.skipped == ["output.txt"]
assert result2.built == []
@ -234,11 +236,11 @@ class TestRunBuild:
) -> None:
config1 = write_config({"targets": {"out.txt": {"prompt": "version 1"}}})
with patch("bulkgen.builder._create_providers", return_value=_fake_providers()):
r1 = await run_build(config1, project_dir)
r1 = await run_build(config1, project_dir, _PROJECT)
assert r1.built == ["out.txt"]
config2 = write_config({"targets": {"out.txt": {"prompt": "version 2"}}})
r2 = await run_build(config2, project_dir)
r2 = await run_build(config2, project_dir, _PROJECT)
assert r2.built == ["out.txt"]
async def test_rebuild_after_input_change(
@ -249,11 +251,11 @@ class TestRunBuild:
{"targets": {"out.md": {"prompt": "x", "inputs": ["data.txt"]}}}
)
with patch("bulkgen.builder._create_providers", return_value=_fake_providers()):
r1 = await run_build(config, project_dir)
r1 = await run_build(config, project_dir, _PROJECT)
assert r1.built == ["out.md"]
_ = (project_dir / "data.txt").write_text("modified")
r2 = await run_build(config, project_dir)
r2 = await run_build(config, project_dir, _PROJECT)
assert r2.built == ["out.md"]
async def test_selective_build_single_target(
@ -261,7 +263,7 @@ class TestRunBuild:
) -> None:
with patch("bulkgen.builder._create_providers", return_value=_fake_providers()):
result = await run_build(
multi_target_config, project_dir, target="summary.md"
multi_target_config, project_dir, _PROJECT, target="summary.md"
)
assert "summary.md" in result.built
@ -274,7 +276,7 @@ class TestRunBuild:
with patch("bulkgen.builder._create_providers", return_value=_fake_providers()):
with pytest.raises(ValueError, match="Unknown target"):
_ = await run_build(
simple_text_config, project_dir, target="nonexistent.txt"
simple_text_config, project_dir, _PROJECT, target="nonexistent.txt"
)
async def test_failed_target_isolates_independent(
@ -322,7 +324,7 @@ class TestRunBuild:
"bulkgen.builder._create_providers",
return_value=[routing_provider, FakeImageProvider()],
):
result = await run_build(config, project_dir)
result = await run_build(config, project_dir, _PROJECT)
assert "fail.txt" in result.failed
assert "ok.txt" in result.built
@ -343,7 +345,7 @@ class TestRunBuild:
"bulkgen.builder._create_providers",
return_value=[FailingTextProvider(), FakeImageProvider()],
):
result = await run_build(config, project_dir)
result = await run_build(config, project_dir, _PROJECT)
assert "base.txt" in result.failed
assert "child.txt" in result.failed
@ -356,7 +358,7 @@ class TestRunBuild:
"bulkgen.builder._create_providers",
return_value=[],
):
result = await run_build(simple_text_config, project_dir)
result = await run_build(simple_text_config, project_dir, _PROJECT)
assert "output.txt" in result.failed
assert "No provider available" in result.failed["output.txt"]
@ -373,9 +375,9 @@ class TestRunBuild:
}
)
with patch("bulkgen.builder._create_providers", return_value=_fake_providers()):
_ = await run_build(config, project_dir)
_ = await run_build(config, project_dir, _PROJECT)
state = load_state(project_dir)
state = load_state(project_dir, _PROJECT)
assert "a.txt" in state.targets
assert "b.txt" in state.targets
@ -385,7 +387,7 @@ class TestRunBuild:
config = write_config({"targets": {"out.txt": {"prompt": prompt_file.name}}})
with patch("bulkgen.builder._create_providers", return_value=_fake_providers()):
result = await run_build(config, project_dir)
result = await run_build(config, project_dir, _PROJECT)
assert result.built == ["out.txt"]
content = (project_dir / "out.txt").read_text()
@ -395,12 +397,12 @@ class TestRunBuild:
self, project_dir: Path, simple_text_config: ProjectConfig
) -> None:
with patch("bulkgen.builder._create_providers", return_value=_fake_providers()):
r1 = await run_build(simple_text_config, project_dir)
r1 = await run_build(simple_text_config, project_dir, _PROJECT)
assert r1.built == ["output.txt"]
(project_dir / "output.txt").unlink()
r2 = await run_build(simple_text_config, project_dir)
r2 = await run_build(simple_text_config, project_dir, _PROJECT)
assert r2.built == ["output.txt"]
async def test_diamond_dependency_all_built(
@ -420,7 +422,7 @@ class TestRunBuild:
}
)
with patch("bulkgen.builder._create_providers", return_value=_fake_providers()):
result = await run_build(config, project_dir)
result = await run_build(config, project_dir, _PROJECT)
assert set(result.built) == {"left.md", "right.md", "merge.txt"}
assert result.failed == {}

View file

@ -132,7 +132,8 @@ class TestBuildCommand:
assert result.exit_code == 0
call_args = mock_run.call_args
assert call_args[0][2] == "output.txt"
# positional args: (config, project_dir, project_name, target)
assert call_args[0][3] == "output.txt"
class TestCleanCommand:
@ -141,7 +142,8 @@ class TestCleanCommand:
def test_clean_removes_targets(self, cli_project: Path) -> None:
_ = (cli_project / "output.txt").write_text("generated")
_ = (cli_project / "image.png").write_bytes(b"\x89PNG")
_ = (cli_project / ".bulkgen.state.yaml").write_text("targets: {}")
state_file = ".project.bulkgen-state.yaml"
_ = (cli_project / state_file).write_text("targets: {}")
with patch("bulkgen.cli.Path") as mock_path_cls:
mock_path_cls.cwd.return_value = cli_project
@ -150,7 +152,7 @@ class TestCleanCommand:
assert result.exit_code == 0
assert not (cli_project / "output.txt").exists()
assert not (cli_project / "image.png").exists()
assert not (cli_project / ".bulkgen.state.yaml").exists()
assert not (cli_project / state_file).exists()
assert "Cleaned 2 artifact(s)" in result.output
def test_clean_no_artifacts(self, cli_project: Path) -> None:

View file

@ -10,13 +10,15 @@ from bulkgen.state import (
BuildState,
TargetState,
hash_file,
hash_string,
is_target_dirty,
load_state,
record_target_state,
save_state,
state_filename,
)
_PROJECT = "test"
class TestHashFunctions:
"""Test hashing helpers."""
@ -34,18 +36,22 @@ class TestHashFunctions:
h2 = hash_file(f)
assert h1 != h2
def test_hash_string_deterministic(self) -> None:
assert hash_string("abc") == hash_string("abc")
def test_hash_string_differs(self) -> None:
assert hash_string("abc") != hash_string("xyz")
class TestStateFilename:
"""Test state filename derivation."""
def test_state_filename(self) -> None:
assert state_filename("cards") == ".cards.bulkgen-state.yaml"
def test_state_filename_simple(self) -> None:
assert state_filename("project") == ".project.bulkgen-state.yaml"
class TestStatePersistence:
"""Test save/load round-trip of build state."""
def test_load_missing_file_returns_empty(self, project_dir: Path) -> None:
state = load_state(project_dir)
state = load_state(project_dir, _PROJECT)
assert state.targets == {}
def test_save_and_load_round_trip(self, project_dir: Path) -> None:
@ -53,40 +59,36 @@ class TestStatePersistence:
targets={
"out.txt": TargetState(
input_hashes={"dep.txt": "abc123"},
prompt_hash="prompt_hash_val",
prompt="Generate something",
model="mistral-large-latest",
extra_hash="",
extra_params={},
)
}
)
save_state(state, project_dir)
loaded = load_state(project_dir)
save_state(state, project_dir, _PROJECT)
loaded = load_state(project_dir, _PROJECT)
assert loaded.targets["out.txt"].model == "mistral-large-latest"
assert loaded.targets["out.txt"].input_hashes == {"dep.txt": "abc123"}
assert loaded.targets["out.txt"].prompt_hash == "prompt_hash_val"
assert loaded.targets["out.txt"].prompt == "Generate something"
def test_load_empty_yaml(self, project_dir: Path) -> None:
_ = (project_dir / ".bulkgen.state.yaml").write_text("")
state = load_state(project_dir)
_ = (project_dir / state_filename(_PROJECT)).write_text("")
state = load_state(project_dir, _PROJECT)
assert state.targets == {}
def test_save_overwrites_existing(self, project_dir: Path) -> None:
state1 = BuildState(
targets={
"a.txt": TargetState(input_hashes={}, prompt_hash="h1", model="m1")
}
targets={"a.txt": TargetState(input_hashes={}, prompt="p1", model="m1")}
)
save_state(state1, project_dir)
save_state(state1, project_dir, _PROJECT)
state2 = BuildState(
targets={
"b.txt": TargetState(input_hashes={}, prompt_hash="h2", model="m2")
}
targets={"b.txt": TargetState(input_hashes={}, prompt="p2", model="m2")}
)
save_state(state2, project_dir)
save_state(state2, project_dir, _PROJECT)
loaded = load_state(project_dir)
loaded = load_state(project_dir, _PROJECT)
assert "b.txt" in loaded.targets
assert "a.txt" not in loaded.targets
@ -95,16 +97,16 @@ class TestStatePersistence:
targets={
"out.txt": TargetState(
input_hashes={"f.txt": "hash"},
prompt_hash="ph",
prompt="do something",
model="m",
extra_hash="eh",
extra_params={"width": 512},
)
}
)
save_state(state, project_dir)
save_state(state, project_dir, _PROJECT)
raw: object = yaml.safe_load( # pyright: ignore[reportAny]
(project_dir / ".bulkgen.state.yaml").read_text()
(project_dir / state_filename(_PROJECT)).read_text()
)
assert isinstance(raw, dict)
assert "targets" in raw
@ -296,9 +298,9 @@ class TestRecordAndDirtyRoundTrip:
state=state,
project_dir=project_dir,
)
save_state(state, project_dir)
save_state(state, project_dir, _PROJECT)
loaded_state = load_state(project_dir)
loaded_state = load_state(project_dir, _PROJECT)
assert not is_target_dirty(
"result.md",
resolved_prompt="do the thing",