feat: add content target type for writing literal text to files
All checks were successful
Continuous Integration / Build Package (push) Successful in 43s
Continuous Integration / Lint, Check & Test (push) Successful in 1m1s

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
This commit is contained in:
Konstantin Fickel 2026-02-21 18:14:09 +01:00
parent fda28b5afa
commit bb03975ece
Signed by: kfickel
GPG key ID: A793722F9933C1A5
5 changed files with 163 additions and 10 deletions

View file

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

View file

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

View file

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

View file

@ -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."""

View file

@ -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"