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
This commit is contained in:
parent
fda28b5afa
commit
bb03975ece
5 changed files with 163 additions and 10 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
"""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),
|
||||
]
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue