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
|
import httpx
|
||||||
|
|
||||||
from hokusai.archive import archive_file
|
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.graph import build_graph, get_build_order, get_subgraph_for_target
|
||||||
from hokusai.prompt import extract_placeholder_files, resolve_prompt
|
from hokusai.prompt import extract_placeholder_files, resolve_prompt
|
||||||
from hokusai.providers import Provider
|
from hokusai.providers import Provider
|
||||||
|
|
@ -151,12 +156,17 @@ async def _build_single_target(
|
||||||
# Ensure parent directories exist for targets in subfolders.
|
# Ensure parent directories exist for targets in subfolders.
|
||||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
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.
|
# Archive the existing artifact before overwriting.
|
||||||
if config.archive_folder is not None:
|
if config.archive_folder is not None:
|
||||||
_ = archive_file(output_path, project_dir, config.archive_folder)
|
_ = archive_file(output_path, project_dir, config.archive_folder)
|
||||||
|
|
||||||
target_cfg = config.targets[target_name]
|
|
||||||
|
|
||||||
if isinstance(target_cfg, DownloadTargetConfig):
|
if isinstance(target_cfg, DownloadTargetConfig):
|
||||||
await _download_target(target_name, target_cfg, project_dir)
|
await _download_target(target_name, target_cfg, project_dir)
|
||||||
return
|
return
|
||||||
|
|
@ -267,6 +277,14 @@ def _is_dirty(
|
||||||
"""Check if a target needs rebuilding."""
|
"""Check if a target needs rebuilding."""
|
||||||
target_cfg = config.targets[target_name]
|
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):
|
if isinstance(target_cfg, DownloadTargetConfig):
|
||||||
return is_target_dirty(
|
return is_target_dirty(
|
||||||
target_name,
|
target_name,
|
||||||
|
|
@ -300,7 +318,7 @@ def _has_provider(
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""Check that the required provider is available; record failure if not."""
|
"""Check that the required provider is available; record failure if not."""
|
||||||
target_cfg = config.targets[target_name]
|
target_cfg = config.targets[target_name]
|
||||||
if isinstance(target_cfg, DownloadTargetConfig):
|
if isinstance(target_cfg, (DownloadTargetConfig, ContentTargetConfig)):
|
||||||
return True
|
return True
|
||||||
model_info = resolve_model(target_name, target_cfg, config.defaults)
|
model_info = resolve_model(target_name, target_cfg, config.defaults)
|
||||||
if model_info.name not in provider_index:
|
if model_info.name not in provider_index:
|
||||||
|
|
@ -345,7 +363,14 @@ def _process_outcomes(
|
||||||
else:
|
else:
|
||||||
target_cfg = config.targets[name]
|
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(
|
record_target_state(
|
||||||
name,
|
name,
|
||||||
download=target_cfg.download,
|
download=target_cfg.download,
|
||||||
|
|
|
||||||
|
|
@ -47,16 +47,26 @@ class DownloadTargetConfig(BaseModel):
|
||||||
download: str
|
download: str
|
||||||
|
|
||||||
|
|
||||||
|
class ContentTargetConfig(BaseModel):
|
||||||
|
"""Configuration for a target that writes literal content to a file."""
|
||||||
|
|
||||||
|
content: str
|
||||||
|
|
||||||
|
|
||||||
def _target_discriminator(raw: object) -> str:
|
def _target_discriminator(raw: object) -> str:
|
||||||
"""Discriminate between generate and download target configs."""
|
"""Discriminate between generate, download, and content target configs."""
|
||||||
if isinstance(raw, dict) and "download" in raw:
|
if isinstance(raw, dict):
|
||||||
|
if "download" in raw:
|
||||||
return "download"
|
return "download"
|
||||||
|
if "content" in raw:
|
||||||
|
return "content"
|
||||||
return "generate"
|
return "generate"
|
||||||
|
|
||||||
|
|
||||||
TargetConfig = Annotated[
|
TargetConfig = Annotated[
|
||||||
Annotated[GenerateTargetConfig, Tag("generate")]
|
Annotated[GenerateTargetConfig, Tag("generate")]
|
||||||
| Annotated[DownloadTargetConfig, Tag("download")],
|
| Annotated[DownloadTargetConfig, Tag("download")]
|
||||||
|
| Annotated[ContentTargetConfig, Tag("content")],
|
||||||
Discriminator(_target_discriminator),
|
Discriminator(_target_discriminator),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ class TargetState(BaseModel):
|
||||||
prompt: str | None = None
|
prompt: str | None = None
|
||||||
model: str | None = None
|
model: str | None = None
|
||||||
extra_params: dict[str, object] = {}
|
extra_params: dict[str, object] = {}
|
||||||
|
content: str | None = None
|
||||||
download: str | None = None
|
download: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -74,6 +75,7 @@ def is_target_dirty(
|
||||||
model: str | None = None,
|
model: str | None = None,
|
||||||
dep_files: list[Path] | None = None,
|
dep_files: list[Path] | None = None,
|
||||||
extra_params: dict[str, object] | None = None,
|
extra_params: dict[str, object] | None = None,
|
||||||
|
content: str | None = None,
|
||||||
download: str | None = None,
|
download: str | None = None,
|
||||||
state: BuildState,
|
state: BuildState,
|
||||||
project_dir: Path,
|
project_dir: Path,
|
||||||
|
|
@ -83,6 +85,7 @@ def is_target_dirty(
|
||||||
A target is dirty if:
|
A target is dirty if:
|
||||||
- Its output file does not exist
|
- Its output file does not exist
|
||||||
- It has never been built (not recorded in state)
|
- 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 download targets: the download URL has changed
|
||||||
- For generate targets: any dependency file hash, prompt, model, or extra params 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]
|
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.
|
# Download targets only compare the URL.
|
||||||
if download is not None:
|
if download is not None:
|
||||||
return prev.download != download
|
return prev.download != download
|
||||||
|
|
@ -125,11 +132,16 @@ def record_target_state(
|
||||||
model: str | None = None,
|
model: str | None = None,
|
||||||
dep_files: list[Path] | None = None,
|
dep_files: list[Path] | None = None,
|
||||||
extra_params: dict[str, object] | None = None,
|
extra_params: dict[str, object] | None = None,
|
||||||
|
content: str | None = None,
|
||||||
download: str | None = None,
|
download: str | None = None,
|
||||||
state: BuildState,
|
state: BuildState,
|
||||||
project_dir: Path,
|
project_dir: Path,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Record the state of a successfully built target."""
|
"""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:
|
if download is not None:
|
||||||
state.targets[target_name] = TargetState(download=download)
|
state.targets[target_name] = TargetState(download=download)
|
||||||
return
|
return
|
||||||
|
|
|
||||||
|
|
@ -614,6 +614,101 @@ class TestDownloadTarget:
|
||||||
assert (project_dir / "description.txt").exists()
|
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:
|
class TestPlaceholderPrompts:
|
||||||
"""Tests for prompt placeholder substitution in builds."""
|
"""Tests for prompt placeholder substitution in builds."""
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ from pathlib import Path
|
||||||
import pytest
|
import pytest
|
||||||
import yaml
|
import yaml
|
||||||
|
|
||||||
from hokusai.config import GenerateTargetConfig, load_config
|
from hokusai.config import ContentTargetConfig, GenerateTargetConfig, load_config
|
||||||
|
|
||||||
|
|
||||||
class TestLoadConfig:
|
class TestLoadConfig:
|
||||||
|
|
@ -83,3 +83,14 @@ class TestLoadConfig:
|
||||||
|
|
||||||
with pytest.raises(Exception):
|
with pytest.raises(Exception):
|
||||||
_ = load_config(config_path)
|
_ = 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