From bb03975ece754d9c01028e6e71fe7558c1cdec80 Mon Sep 17 00:00:00 2001 From: Konstantin Fickel Date: Sat, 21 Feb 2026 18:14:09 +0100 Subject: [PATCH] feat: add content target type for writing literal text to files Content targets write a string directly to the output file without invoking any AI provider. They don't require API keys and are not archived when overwritten. Example usage in .hokusai.yaml: file.txt: content: ABC --- hokusai/builder.py | 35 +++++++++++++--- hokusai/config.py | 18 ++++++-- hokusai/state.py | 12 ++++++ tests/test_builder.py | 95 +++++++++++++++++++++++++++++++++++++++++++ tests/test_config.py | 13 +++++- 5 files changed, 163 insertions(+), 10 deletions(-) diff --git a/hokusai/builder.py b/hokusai/builder.py index 34b6ec5..a64fc3c 100644 --- a/hokusai/builder.py +++ b/hokusai/builder.py @@ -12,7 +12,12 @@ from pathlib import Path import httpx from hokusai.archive import archive_file -from hokusai.config import DownloadTargetConfig, GenerateTargetConfig, ProjectConfig +from hokusai.config import ( + ContentTargetConfig, + DownloadTargetConfig, + GenerateTargetConfig, + ProjectConfig, +) from hokusai.graph import build_graph, get_build_order, get_subgraph_for_target from hokusai.prompt import extract_placeholder_files, resolve_prompt from hokusai.providers import Provider @@ -151,12 +156,17 @@ async def _build_single_target( # Ensure parent directories exist for targets in subfolders. output_path.parent.mkdir(parents=True, exist_ok=True) + target_cfg = config.targets[target_name] + + # Content targets write literal text — no archiving, no provider needed. + if isinstance(target_cfg, ContentTargetConfig): + _ = output_path.write_text(target_cfg.content) + return + # Archive the existing artifact before overwriting. if config.archive_folder is not None: _ = archive_file(output_path, project_dir, config.archive_folder) - target_cfg = config.targets[target_name] - if isinstance(target_cfg, DownloadTargetConfig): await _download_target(target_name, target_cfg, project_dir) return @@ -267,6 +277,14 @@ def _is_dirty( """Check if a target needs rebuilding.""" target_cfg = config.targets[target_name] + if isinstance(target_cfg, ContentTargetConfig): + return is_target_dirty( + target_name, + content=target_cfg.content, + state=state, + project_dir=project_dir, + ) + if isinstance(target_cfg, DownloadTargetConfig): return is_target_dirty( target_name, @@ -300,7 +318,7 @@ def _has_provider( ) -> bool: """Check that the required provider is available; record failure if not.""" target_cfg = config.targets[target_name] - if isinstance(target_cfg, DownloadTargetConfig): + if isinstance(target_cfg, (DownloadTargetConfig, ContentTargetConfig)): return True model_info = resolve_model(target_name, target_cfg, config.defaults) if model_info.name not in provider_index: @@ -345,7 +363,14 @@ def _process_outcomes( else: target_cfg = config.targets[name] - if isinstance(target_cfg, DownloadTargetConfig): + if isinstance(target_cfg, ContentTargetConfig): + record_target_state( + name, + content=target_cfg.content, + state=state, + project_dir=project_dir, + ) + elif isinstance(target_cfg, DownloadTargetConfig): record_target_state( name, download=target_cfg.download, diff --git a/hokusai/config.py b/hokusai/config.py index 6c13384..40d37df 100644 --- a/hokusai/config.py +++ b/hokusai/config.py @@ -47,16 +47,26 @@ class DownloadTargetConfig(BaseModel): download: str +class ContentTargetConfig(BaseModel): + """Configuration for a target that writes literal content to a file.""" + + content: str + + def _target_discriminator(raw: object) -> str: - """Discriminate between generate and download target configs.""" - if isinstance(raw, dict) and "download" in raw: - return "download" + """Discriminate between generate, download, and content target configs.""" + if isinstance(raw, dict): + if "download" in raw: + return "download" + if "content" in raw: + return "content" return "generate" TargetConfig = Annotated[ Annotated[GenerateTargetConfig, Tag("generate")] - | Annotated[DownloadTargetConfig, Tag("download")], + | Annotated[DownloadTargetConfig, Tag("download")] + | Annotated[ContentTargetConfig, Tag("content")], Discriminator(_target_discriminator), ] diff --git a/hokusai/state.py b/hokusai/state.py index 8aee172..f2b1f84 100644 --- a/hokusai/state.py +++ b/hokusai/state.py @@ -25,6 +25,7 @@ class TargetState(BaseModel): prompt: str | None = None model: str | None = None extra_params: dict[str, object] = {} + content: str | None = None download: str | None = None @@ -74,6 +75,7 @@ def is_target_dirty( model: str | None = None, dep_files: list[Path] | None = None, extra_params: dict[str, object] | None = None, + content: str | None = None, download: str | None = None, state: BuildState, project_dir: Path, @@ -83,6 +85,7 @@ def is_target_dirty( A target is dirty if: - Its output file does not exist - It has never been built (not recorded in state) + - For content targets: the content string has changed - For download targets: the download URL has changed - For generate targets: any dependency file hash, prompt, model, or extra params changed """ @@ -95,6 +98,10 @@ def is_target_dirty( prev = state.targets[target_name] + # Content targets only compare the content string. + if content is not None: + return prev.content != content + # Download targets only compare the URL. if download is not None: return prev.download != download @@ -125,11 +132,16 @@ def record_target_state( model: str | None = None, dep_files: list[Path] | None = None, extra_params: dict[str, object] | None = None, + content: str | None = None, download: str | None = None, state: BuildState, project_dir: Path, ) -> None: """Record the state of a successfully built target.""" + if content is not None: + state.targets[target_name] = TargetState(content=content) + return + if download is not None: state.targets[target_name] = TargetState(download=download) return diff --git a/tests/test_builder.py b/tests/test_builder.py index 20c77c7..340f32f 100644 --- a/tests/test_builder.py +++ b/tests/test_builder.py @@ -614,6 +614,101 @@ class TestDownloadTarget: assert (project_dir / "description.txt").exists() +class TestContentTarget: + """Tests for content-type targets that write literal text.""" + + async def test_content_target_writes_file( + self, project_dir: Path, write_config: WriteConfig + ) -> None: + config = write_config({"targets": {"file.txt": {"content": "ABC"}}}) + with patch("hokusai.builder._create_providers", return_value=_fake_providers()): + result = await run_build(config, project_dir, _PROJECT) + + assert result.built == ["file.txt"] + assert (project_dir / "file.txt").read_text() == "ABC" + + async def test_content_target_incremental_skip( + self, project_dir: Path, write_config: WriteConfig + ) -> None: + config = write_config({"targets": {"file.txt": {"content": "ABC"}}}) + with patch("hokusai.builder._create_providers", return_value=_fake_providers()): + r1 = await run_build(config, project_dir, _PROJECT) + assert r1.built == ["file.txt"] + + r2 = await run_build(config, project_dir, _PROJECT) + assert r2.skipped == ["file.txt"] + assert r2.built == [] + + async def test_content_target_rebuild_on_change( + self, project_dir: Path, write_config: WriteConfig + ) -> None: + config1 = write_config({"targets": {"file.txt": {"content": "ABC"}}}) + with patch("hokusai.builder._create_providers", return_value=_fake_providers()): + r1 = await run_build(config1, project_dir, _PROJECT) + assert r1.built == ["file.txt"] + + config2 = write_config({"targets": {"file.txt": {"content": "XYZ"}}}) + r2 = await run_build(config2, project_dir, _PROJECT) + assert r2.built == ["file.txt"] + assert (project_dir / "file.txt").read_text() == "XYZ" + + async def test_content_target_no_archive( + self, project_dir: Path, write_config: WriteConfig + ) -> None: + config1 = write_config( + { + "archive_folder": "archive", + "targets": {"file.txt": {"content": "v1"}}, + } + ) + with patch("hokusai.builder._create_providers", return_value=_fake_providers()): + r1 = await run_build(config1, project_dir, _PROJECT) + assert r1.built == ["file.txt"] + + config2 = write_config( + { + "archive_folder": "archive", + "targets": {"file.txt": {"content": "v2"}}, + } + ) + r2 = await run_build(config2, project_dir, _PROJECT) + assert r2.built == ["file.txt"] + + assert (project_dir / "file.txt").read_text() == "v2" + assert not (project_dir / "archive").exists() + + async def test_content_target_as_dependency( + self, project_dir: Path, write_config: WriteConfig + ) -> None: + config = write_config( + { + "targets": { + "data.txt": {"content": "some data"}, + "output.md": { + "prompt": "Process the data", + "inputs": ["data.txt"], + }, + } + } + ) + with patch("hokusai.builder._create_providers", return_value=_fake_providers()): + result = await run_build(config, project_dir, _PROJECT) + + assert "data.txt" in result.built + assert "output.md" in result.built + assert (project_dir / "data.txt").read_text() == "some data" + + async def test_content_target_no_provider_needed( + self, project_dir: Path, write_config: WriteConfig + ) -> None: + config = write_config({"targets": {"file.txt": {"content": "ABC"}}}) + with patch("hokusai.builder._create_providers", return_value=[]): + result = await run_build(config, project_dir, _PROJECT) + + assert result.built == ["file.txt"] + assert result.failed == {} + + class TestPlaceholderPrompts: """Tests for prompt placeholder substitution in builds.""" diff --git a/tests/test_config.py b/tests/test_config.py index 83d25b4..e782157 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -7,7 +7,7 @@ from pathlib import Path import pytest import yaml -from hokusai.config import GenerateTargetConfig, load_config +from hokusai.config import ContentTargetConfig, GenerateTargetConfig, load_config class TestLoadConfig: @@ -83,3 +83,14 @@ class TestLoadConfig: with pytest.raises(Exception): _ = load_config(config_path) + + def test_content_target(self, project_dir: Path) -> None: + config_path = project_dir / "test.hokusai.yaml" + _ = config_path.write_text( + yaml.dump({"targets": {"file.txt": {"content": "ABC"}}}) + ) + config = load_config(config_path) + + target = config.targets["file.txt"] + assert isinstance(target, ContentTargetConfig) + assert target.content == "ABC"