feat: add content targets and loop expansion for target templates
Content targets write literal text to files via 'content:' field, without requiring an AI provider or API keys. They are not archived when overwritten. Loop expansion allows defining 'loops:' at the top level with named lists of values. Targets with [var] in their name are expanded via cartesian product. Variables are substituted in all string fields. Explicit targets override expanded ones. Escaping: \[var] -> [var]. Expansion happens at config load time so the rest of the system (builder, graph, state) sees only expanded targets.
This commit is contained in:
parent
bb03975ece
commit
7503672942
7 changed files with 581 additions and 2 deletions
|
|
@ -709,6 +709,68 @@ class TestContentTarget:
|
|||
assert result.failed == {}
|
||||
|
||||
|
||||
class TestLoopExpansion:
|
||||
"""End-to-end tests for loop-expanded targets in builds."""
|
||||
|
||||
async def test_loop_content_targets_build(
|
||||
self, project_dir: Path, write_config: WriteConfig
|
||||
) -> None:
|
||||
config = write_config(
|
||||
{
|
||||
"loops": {"n": ["1", "2", "3"]},
|
||||
"targets": {"file-[n].txt": {"content": "Value [n]"}},
|
||||
}
|
||||
)
|
||||
with patch("hokusai.builder._create_providers", return_value=_fake_providers()):
|
||||
result = await run_build(config, project_dir, _PROJECT)
|
||||
|
||||
assert set(result.built) == {"file-1.txt", "file-2.txt", "file-3.txt"}
|
||||
assert (project_dir / "file-1.txt").read_text() == "Value 1"
|
||||
assert (project_dir / "file-2.txt").read_text() == "Value 2"
|
||||
assert (project_dir / "file-3.txt").read_text() == "Value 3"
|
||||
|
||||
async def test_loop_incremental_skip(
|
||||
self, project_dir: Path, write_config: WriteConfig
|
||||
) -> None:
|
||||
config = write_config(
|
||||
{
|
||||
"loops": {"n": ["1", "2"]},
|
||||
"targets": {"file-[n].txt": {"content": "Value [n]"}},
|
||||
}
|
||||
)
|
||||
with patch("hokusai.builder._create_providers", return_value=_fake_providers()):
|
||||
r1 = await run_build(config, project_dir, _PROJECT)
|
||||
assert len(r1.built) == 2
|
||||
|
||||
r2 = await run_build(config, project_dir, _PROJECT)
|
||||
assert r2.built == []
|
||||
assert set(r2.skipped) == {"file-1.txt", "file-2.txt"}
|
||||
|
||||
async def test_loop_with_dependency_chain(
|
||||
self, project_dir: Path, write_config: WriteConfig
|
||||
) -> None:
|
||||
config = write_config(
|
||||
{
|
||||
"loops": {"id": ["a", "b"]},
|
||||
"targets": {
|
||||
"data-[id].txt": {"content": "Data for [id]"},
|
||||
"summary-[id].txt": {
|
||||
"prompt": "Summarize [id]",
|
||||
"inputs": ["data-[id].txt"],
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
with patch("hokusai.builder._create_providers", return_value=_fake_providers()):
|
||||
result = await run_build(config, project_dir, _PROJECT)
|
||||
|
||||
assert "data-a.txt" in result.built
|
||||
assert "data-b.txt" in result.built
|
||||
assert "summary-a.txt" in result.built
|
||||
assert "summary-b.txt" in result.built
|
||||
assert result.failed == {}
|
||||
|
||||
|
||||
class TestPlaceholderPrompts:
|
||||
"""Tests for prompt placeholder substitution in builds."""
|
||||
|
||||
|
|
|
|||
|
|
@ -84,6 +84,41 @@ class TestLoadConfig:
|
|||
with pytest.raises(Exception):
|
||||
_ = load_config(config_path)
|
||||
|
||||
def test_config_with_loops(self, project_dir: Path) -> None:
|
||||
config_path = project_dir / "test.hokusai.yaml"
|
||||
_ = config_path.write_text(
|
||||
yaml.dump(
|
||||
{
|
||||
"loops": {"a": [1, 2]},
|
||||
"targets": {"file-[a].txt": {"content": "Value [a]"}},
|
||||
}
|
||||
)
|
||||
)
|
||||
config = load_config(config_path)
|
||||
|
||||
assert "file-1.txt" in config.targets
|
||||
assert "file-2.txt" in config.targets
|
||||
assert "file-[a].txt" not in config.targets
|
||||
|
||||
t1 = config.targets["file-1.txt"]
|
||||
assert isinstance(t1, ContentTargetConfig)
|
||||
assert t1.content == "Value 1"
|
||||
|
||||
def test_config_loops_normalize_values_to_strings(self, project_dir: Path) -> None:
|
||||
config_path = project_dir / "test.hokusai.yaml"
|
||||
_ = config_path.write_text(
|
||||
yaml.dump(
|
||||
{
|
||||
"loops": {"x": [True, 3.14]},
|
||||
"targets": {"out-[x].txt": {"content": "[x]"}},
|
||||
}
|
||||
)
|
||||
)
|
||||
config = load_config(config_path)
|
||||
|
||||
assert "out-True.txt" in config.targets
|
||||
assert "out-3.14.txt" in config.targets
|
||||
|
||||
def test_content_target(self, project_dir: Path) -> None:
|
||||
config_path = project_dir / "test.hokusai.yaml"
|
||||
_ = config_path.write_text(
|
||||
|
|
|
|||
232
tests/test_expand.py
Normal file
232
tests/test_expand.py
Normal file
|
|
@ -0,0 +1,232 @@
|
|||
"""Unit tests for hokusai.expand."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from hokusai.expand import (
|
||||
expand_targets,
|
||||
extract_loop_variables,
|
||||
substitute_loop_variables,
|
||||
)
|
||||
|
||||
|
||||
class TestExtractLoopVariables:
|
||||
"""Tests for extracting [var] references from strings."""
|
||||
|
||||
def test_single_variable(self) -> None:
|
||||
assert extract_loop_variables("image-[a].png") == ["a"]
|
||||
|
||||
def test_multiple_variables(self) -> None:
|
||||
assert extract_loop_variables("card-[size]-[color].png") == ["size", "color"]
|
||||
|
||||
def test_no_variables(self) -> None:
|
||||
assert extract_loop_variables("plain.png") == []
|
||||
|
||||
def test_escaped_variable(self) -> None:
|
||||
assert extract_loop_variables(r"file-\[a].png") == []
|
||||
|
||||
def test_mixed_escaped_and_real(self) -> None:
|
||||
assert extract_loop_variables(r"file-\[a]-[b].png") == ["b"]
|
||||
|
||||
def test_double_backslash_is_not_escaped(self) -> None:
|
||||
assert extract_loop_variables("file-\\\\[a].png") == ["a"]
|
||||
|
||||
def test_deduplicates(self) -> None:
|
||||
assert extract_loop_variables("[a]-[a].png") == ["a"]
|
||||
|
||||
def test_preserves_order(self) -> None:
|
||||
assert extract_loop_variables("[b]-[a]-[c].png") == ["b", "a", "c"]
|
||||
|
||||
|
||||
class TestSubstituteLoopVariables:
|
||||
"""Tests for substituting [var] with values."""
|
||||
|
||||
def test_single_substitution(self) -> None:
|
||||
result = substitute_loop_variables("image-[a].png", {"a": "1"})
|
||||
assert result == "image-1.png"
|
||||
|
||||
def test_multiple_substitutions(self) -> None:
|
||||
result = substitute_loop_variables(
|
||||
"card-[size]-[color].png", {"size": "large", "color": "red"}
|
||||
)
|
||||
assert result == "card-large-red.png"
|
||||
|
||||
def test_escaped_not_substituted(self) -> None:
|
||||
result = substitute_loop_variables(r"file-\[a].png", {"a": "1"})
|
||||
assert result == "file-[a].png"
|
||||
|
||||
def test_double_backslash_substituted(self) -> None:
|
||||
result = substitute_loop_variables("file-\\\\[a].png", {"a": "1"})
|
||||
assert result == "file-\\1.png"
|
||||
|
||||
def test_unknown_variable_left_as_is(self) -> None:
|
||||
result = substitute_loop_variables("file-[unknown].png", {"a": "1"})
|
||||
assert result == "file-[unknown].png"
|
||||
|
||||
def test_no_variables(self) -> None:
|
||||
result = substitute_loop_variables("plain.png", {"a": "1"})
|
||||
assert result == "plain.png"
|
||||
|
||||
|
||||
class TestExpandTargets:
|
||||
"""Tests for full target expansion."""
|
||||
|
||||
def test_single_variable_expansion(self) -> None:
|
||||
raw: dict[str, object] = {"image-[a].png": {"prompt": "Draw [a]"}}
|
||||
loops = {"a": ["1", "2", "3"]}
|
||||
result = expand_targets(raw, loops)
|
||||
|
||||
assert len(result) == 3
|
||||
assert result["image-1.png"] == {"prompt": "Draw 1"}
|
||||
assert result["image-2.png"] == {"prompt": "Draw 2"}
|
||||
assert result["image-3.png"] == {"prompt": "Draw 3"}
|
||||
|
||||
def test_cartesian_product(self) -> None:
|
||||
raw: dict[str, object] = {"card-[a]-[b].png": {"prompt": "[a] [b]"}}
|
||||
loops = {"a": ["1", "2"], "b": ["x", "y"]}
|
||||
result = expand_targets(raw, loops)
|
||||
|
||||
assert len(result) == 4
|
||||
assert result["card-1-x.png"] == {"prompt": "1 x"}
|
||||
assert result["card-1-y.png"] == {"prompt": "1 y"}
|
||||
assert result["card-2-x.png"] == {"prompt": "2 x"}
|
||||
assert result["card-2-y.png"] == {"prompt": "2 y"}
|
||||
|
||||
def test_partial_loop_only_referenced_vars(self) -> None:
|
||||
raw: dict[str, object] = {"image-[a].png": {"prompt": "Draw [a]"}}
|
||||
loops = {"a": ["1", "2"], "b": ["x", "y"]}
|
||||
result = expand_targets(raw, loops)
|
||||
|
||||
assert len(result) == 2
|
||||
assert "image-1.png" in result
|
||||
assert "image-2.png" in result
|
||||
|
||||
def test_non_template_target_passed_through(self) -> None:
|
||||
raw: dict[str, object] = {
|
||||
"image-[a].png": {"prompt": "Draw [a]"},
|
||||
"static.txt": {"content": "hello"},
|
||||
}
|
||||
loops = {"a": ["1", "2"]}
|
||||
result = expand_targets(raw, loops)
|
||||
|
||||
assert len(result) == 3
|
||||
assert result["static.txt"] == {"content": "hello"}
|
||||
|
||||
def test_explicit_target_overrides_expanded(self) -> None:
|
||||
raw: dict[str, object] = {
|
||||
"image-[a].png": {"prompt": "Draw [a]"},
|
||||
"image-1.png": {"prompt": "Custom prompt for 1"},
|
||||
}
|
||||
loops = {"a": ["1", "2"]}
|
||||
result = expand_targets(raw, loops)
|
||||
|
||||
assert len(result) == 2
|
||||
assert result["image-1.png"] == {"prompt": "Custom prompt for 1"}
|
||||
assert result["image-2.png"] == {"prompt": "Draw 2"}
|
||||
|
||||
def test_substitution_in_inputs(self) -> None:
|
||||
raw: dict[str, object] = {
|
||||
"out-[a].txt": {
|
||||
"prompt": "Summarize [a]",
|
||||
"inputs": ["data-[a].txt"],
|
||||
}
|
||||
}
|
||||
loops = {"a": ["x", "y"]}
|
||||
result = expand_targets(raw, loops)
|
||||
|
||||
assert result["out-x.txt"] == {
|
||||
"prompt": "Summarize x",
|
||||
"inputs": ["data-x.txt"],
|
||||
}
|
||||
assert result["out-y.txt"] == {
|
||||
"prompt": "Summarize y",
|
||||
"inputs": ["data-y.txt"],
|
||||
}
|
||||
|
||||
def test_substitution_in_reference_images(self) -> None:
|
||||
raw: dict[str, object] = {
|
||||
"out-[a].png": {
|
||||
"prompt": "Enhance",
|
||||
"reference_images": ["ref-[a].png"],
|
||||
}
|
||||
}
|
||||
loops = {"a": ["1", "2"]}
|
||||
result = expand_targets(raw, loops)
|
||||
|
||||
assert result["out-1.png"]["reference_images"] == ["ref-1.png"] # pyright: ignore[reportIndexIssue]
|
||||
assert result["out-2.png"]["reference_images"] == ["ref-2.png"] # pyright: ignore[reportIndexIssue]
|
||||
|
||||
def test_substitution_in_content(self) -> None:
|
||||
raw: dict[str, object] = {"file-[a].txt": {"content": "Value is [a]"}}
|
||||
loops = {"a": ["x", "y"]}
|
||||
result = expand_targets(raw, loops)
|
||||
|
||||
assert result["file-x.txt"] == {"content": "Value is x"}
|
||||
assert result["file-y.txt"] == {"content": "Value is y"}
|
||||
|
||||
def test_substitution_in_download(self) -> None:
|
||||
raw: dict[str, object] = {
|
||||
"file-[a].png": {"download": "https://example.com/[a].png"}
|
||||
}
|
||||
loops = {"a": ["cat", "dog"]}
|
||||
result = expand_targets(raw, loops)
|
||||
|
||||
assert result["file-cat.png"] == {"download": "https://example.com/cat.png"}
|
||||
assert result["file-dog.png"] == {"download": "https://example.com/dog.png"}
|
||||
|
||||
def test_escaped_brackets_preserved(self) -> None:
|
||||
raw: dict[str, object] = {r"image-[a].png": {"prompt": r"Draw \[a] for [a]"}}
|
||||
loops = {"a": ["1"]}
|
||||
result = expand_targets(raw, loops)
|
||||
|
||||
assert result["image-1.png"] == {"prompt": "Draw [a] for 1"}
|
||||
|
||||
def test_undefined_variable_raises(self) -> None:
|
||||
raw: dict[str, object] = {"image-[missing].png": {"prompt": "x"}}
|
||||
loops = {"a": ["1"]}
|
||||
|
||||
with pytest.raises(ValueError, match="undefined loop variable"):
|
||||
_ = expand_targets(raw, loops)
|
||||
|
||||
def test_duplicate_from_different_templates_raises(self) -> None:
|
||||
raw: dict[str, object] = {
|
||||
"[a]-[b].png": {"prompt": "first"},
|
||||
"[b]-[a].png": {"prompt": "second"},
|
||||
}
|
||||
loops = {"a": ["x"], "b": ["x"]}
|
||||
|
||||
with pytest.raises(ValueError, match="Duplicate expanded target"):
|
||||
_ = expand_targets(raw, loops)
|
||||
|
||||
def test_empty_loops_passes_through(self) -> None:
|
||||
raw: dict[str, object] = {"out.txt": {"prompt": "hello"}}
|
||||
result = expand_targets(raw, {})
|
||||
assert result == {"out.txt": {"prompt": "hello"}}
|
||||
|
||||
def test_cross_reference_between_expanded_targets(self) -> None:
|
||||
raw: dict[str, object] = {
|
||||
"data-[id].txt": {"content": "Data for [id]"},
|
||||
"summary-[id].txt": {
|
||||
"prompt": "Summarize",
|
||||
"inputs": ["data-[id].txt"],
|
||||
},
|
||||
}
|
||||
loops = {"id": ["a", "b"]}
|
||||
result = expand_targets(raw, loops)
|
||||
|
||||
assert len(result) == 4
|
||||
assert result["summary-a.txt"]["inputs"] == ["data-a.txt"] # pyright: ignore[reportIndexIssue]
|
||||
assert result["summary-b.txt"]["inputs"] == ["data-b.txt"] # pyright: ignore[reportIndexIssue]
|
||||
|
||||
def test_substitution_in_control_images(self) -> None:
|
||||
raw: dict[str, object] = {
|
||||
"out-[a].png": {
|
||||
"prompt": "Generate",
|
||||
"control_images": ["ctrl-[a].png"],
|
||||
}
|
||||
}
|
||||
loops = {"a": ["1"]}
|
||||
result = expand_targets(raw, loops)
|
||||
|
||||
assert result["out-1.png"]["control_images"] == ["ctrl-1.png"] # pyright: ignore[reportIndexIssue]
|
||||
Loading…
Add table
Add a link
Reference in a new issue