diff --git a/hokusai/archive.py b/hokusai/archive.py new file mode 100644 index 0000000..64fc22d --- /dev/null +++ b/hokusai/archive.py @@ -0,0 +1,41 @@ +"""Archive helper: move files to a numbered archive folder.""" + +from __future__ import annotations + +import shutil +from pathlib import Path + + +def _next_archive_path(archive_dir: Path, relative: Path) -> Path: + """Compute the next available numbered archive path. + + ``img/x.jpg`` becomes ``archive/img/x.01.jpg``, then ``x.02.jpg``, etc. + """ + stem = relative.stem + suffix = relative.suffix + parent = archive_dir / relative.parent + + n = 1 + while True: + candidate = parent / f"{stem}.{n:02d}{suffix}" + if not candidate.exists(): + return candidate + n += 1 + + +def archive_file( + file_path: Path, project_dir: Path, archive_folder: str +) -> Path | None: + """Move *file_path* into the archive folder with an incremented number. + + Returns the destination path, or ``None`` if *file_path* does not exist. + """ + if not file_path.exists(): + return None + + relative = file_path.relative_to(project_dir) + archive_dir = project_dir / archive_folder + dest = _next_archive_path(archive_dir, relative) + dest.parent.mkdir(parents=True, exist_ok=True) + _ = shutil.move(str(file_path), dest) + return dest diff --git a/hokusai/builder.py b/hokusai/builder.py index 08774b5..0a908b3 100644 --- a/hokusai/builder.py +++ b/hokusai/builder.py @@ -11,6 +11,7 @@ from pathlib import Path import httpx +from hokusai.archive import archive_file from hokusai.config import 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 @@ -145,8 +146,14 @@ async def _build_single_target( provider_index: dict[str, Provider], ) -> None: """Build a single target by dispatching to the appropriate provider.""" + output_path = project_dir / target_name + # Ensure parent directories exist for targets in subfolders. - (project_dir / target_name).parent.mkdir(parents=True, exist_ok=True) + output_path.parent.mkdir(parents=True, exist_ok=True) + + # 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] diff --git a/hokusai/cli.py b/hokusai/cli.py index df96bc5..5c149a4 100644 --- a/hokusai/cli.py +++ b/hokusai/cli.py @@ -12,6 +12,7 @@ import typer import yaml from typer.core import TyperGroup +from hokusai.archive import archive_file from hokusai.builder import BuildEvent, BuildResult, run_build from hokusai.config import GenerateTargetConfig, ProjectConfig, load_config from hokusai.graph import build_graph, get_build_order @@ -193,18 +194,33 @@ def clean() -> None: config = load_config(config_path) state_name = state_filename(_project_name(config_path)) + archive_folder = config.archive_folder + removed = 0 dirs_to_check: set[Path] = set() for target_name in config.targets: target_path = project_dir / target_name - if target_path.exists(): + if not target_path.exists(): + continue + + if archive_folder is not None: + dest = archive_file(target_path, project_dir, archive_folder) + if dest is not None: + rel_dest = dest.relative_to(project_dir) + click.echo( + click.style(" mv ", fg="yellow") + + click.style(target_name, bold=True) + + click.style(f" -> {rel_dest}", dim=True) + ) + else: target_path.unlink() click.echo(click.style(" rm ", fg="red") + target_name) - removed += 1 - # Track parent dirs that may now be empty. - parent = target_path.parent - if parent != project_dir: - dirs_to_check.add(parent) + + removed += 1 + # Track parent dirs that may now be empty. + parent = target_path.parent + if parent != project_dir: + dirs_to_check.add(parent) # Remove empty subdirectories left behind, bottom-up. for d in sorted(dirs_to_check, key=lambda p: len(p.parts), reverse=True): @@ -220,7 +236,8 @@ def clean() -> None: state_path.unlink() click.echo(click.style(" rm ", fg="red") + state_name) - click.echo(click.style(f"\nCleaned {removed} artifact(s)", bold=True)) + verb = "Archived" if archive_folder is not None else "Cleaned" + click.echo(click.style(f"\n{verb} {removed} artifact(s)", bold=True)) @app.command() diff --git a/hokusai/config.py b/hokusai/config.py index ac397d8..6c13384 100644 --- a/hokusai/config.py +++ b/hokusai/config.py @@ -65,6 +65,7 @@ class ProjectConfig(BaseModel): """Top-level configuration parsed from ``.hokusai.yaml``.""" defaults: Defaults = Defaults() + archive_folder: str | None = None targets: dict[str, TargetConfig] @model_validator(mode="after") diff --git a/tests/test_archive.py b/tests/test_archive.py new file mode 100644 index 0000000..c5e14e3 --- /dev/null +++ b/tests/test_archive.py @@ -0,0 +1,75 @@ +"""Tests for hokusai.archive.""" + +from __future__ import annotations + +from pathlib import Path + +from hokusai.archive import archive_file + + +class TestArchiveFile: + """Test the archive_file helper.""" + + def test_archives_with_01_suffix(self, tmp_path: Path) -> None: + src = tmp_path / "image.jpg" + _ = src.write_text("v1") + + dest = archive_file(src, tmp_path, "archive") + + assert dest is not None + assert dest == tmp_path / "archive" / "image.01.jpg" + assert dest.read_text() == "v1" + assert not src.exists() + + def test_increments_number(self, tmp_path: Path) -> None: + archive_dir = tmp_path / "archive" + archive_dir.mkdir() + _ = (archive_dir / "image.01.jpg").write_text("old") + + src = tmp_path / "image.jpg" + _ = src.write_text("v2") + + dest = archive_file(src, tmp_path, "archive") + + assert dest is not None + assert dest == archive_dir / "image.02.jpg" + assert dest.read_text() == "v2" + + def test_preserves_subfolder_structure(self, tmp_path: Path) -> None: + sub = tmp_path / "img" + sub.mkdir() + src = sub / "photo.png" + _ = src.write_text("data") + + dest = archive_file(src, tmp_path, "archive") + + assert dest is not None + assert dest == tmp_path / "archive" / "img" / "photo.01.png" + + def test_returns_none_for_missing_file(self, tmp_path: Path) -> None: + src = tmp_path / "nonexistent.txt" + assert archive_file(src, tmp_path, "archive") is None + + def test_creates_archive_dir(self, tmp_path: Path) -> None: + src = tmp_path / "file.txt" + _ = src.write_text("content") + + dest = archive_file(src, tmp_path, "my_archive") + + assert dest is not None + assert (tmp_path / "my_archive").is_dir() + + def test_skips_existing_numbers(self, tmp_path: Path) -> None: + archive_dir = tmp_path / "archive" + archive_dir.mkdir() + _ = (archive_dir / "x.01.txt").write_text("a") + _ = (archive_dir / "x.02.txt").write_text("b") + _ = (archive_dir / "x.03.txt").write_text("c") + + src = tmp_path / "x.txt" + _ = src.write_text("d") + + dest = archive_file(src, tmp_path, "archive") + + assert dest is not None + assert dest == archive_dir / "x.04.txt" diff --git a/tests/test_builder.py b/tests/test_builder.py index 974a62a..20c77c7 100644 --- a/tests/test_builder.py +++ b/tests/test_builder.py @@ -412,6 +412,66 @@ class TestRunBuild: assert result.failed == {} +class TestArchiveOnBuild: + """Test that build archives existing artifacts when archive_folder is set.""" + + async def test_build_archives_existing_file( + self, project_dir: Path, write_config: WriteConfig + ) -> None: + config = write_config( + { + "archive_folder": "archive", + "targets": {"out.txt": {"prompt": "version 1"}}, + } + ) + with patch("hokusai.builder._create_providers", return_value=_fake_providers()): + r1 = await run_build(config, project_dir, _PROJECT) + assert r1.built == ["out.txt"] + v1_content = (project_dir / "out.txt").read_text() + + config2 = write_config( + { + "archive_folder": "archive", + "targets": {"out.txt": {"prompt": "version 2"}}, + } + ) + r2 = await run_build(config2, project_dir, _PROJECT) + assert r2.built == ["out.txt"] + + # v1 should be archived, v2 should be current + archived = project_dir / "archive" / "out.01.txt" + assert archived.exists() + assert archived.read_text() == v1_content + assert (project_dir / "out.txt").exists() + + async def test_build_no_archive_without_setting( + self, project_dir: Path, simple_text_config: ProjectConfig + ) -> None: + with patch("hokusai.builder._create_providers", return_value=_fake_providers()): + r1 = await run_build(simple_text_config, project_dir, _PROJECT) + assert r1.built == ["output.txt"] + + assert not (project_dir / "archive").exists() + + async def test_build_archives_increment( + self, project_dir: Path, write_config: WriteConfig + ) -> None: + config_raw: dict[str, object] = { + "archive_folder": "archive", + "targets": {"out.txt": {"prompt": "v"}}, + } + with patch("hokusai.builder._create_providers", return_value=_fake_providers()): + for i in range(1, 4): + cfg = write_config( + {**config_raw, "targets": {"out.txt": {"prompt": f"v{i}"}}} + ) + _ = await run_build(cfg, project_dir, _PROJECT) + + assert (project_dir / "archive" / "out.01.txt").exists() + assert (project_dir / "archive" / "out.02.txt").exists() + assert not (project_dir / "archive" / "out.03.txt").exists() + + class TestDownloadTarget: """Tests for download-type targets that fetch files from URLs.""" diff --git a/tests/test_cli.py b/tests/test_cli.py index 7750481..ff4cfb1 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -174,6 +174,69 @@ class TestCleanCommand: assert "Cleaned 1 artifact(s)" in result.output assert not (cli_project / "output.txt").exists() + def test_clean_archives_when_archive_folder_set(self, tmp_path: Path) -> None: + config = { + "archive_folder": "archive", + "targets": { + "output.txt": {"prompt": "Generate text"}, + "image.png": {"prompt": "Generate image"}, + }, + } + _ = (tmp_path / "project.hokusai.yaml").write_text( + yaml.dump(config, default_flow_style=False) + ) + _ = (tmp_path / "output.txt").write_text("generated text") + _ = (tmp_path / "image.png").write_bytes(b"\x89PNG") + + with patch("hokusai.cli.Path") as mock_path_cls: + mock_path_cls.cwd.return_value = tmp_path + result = runner.invoke(app, ["clean"]) + + assert result.exit_code == 0 + assert "Archived 2 artifact(s)" in result.output + assert "mv" in result.output + assert not (tmp_path / "output.txt").exists() + assert not (tmp_path / "image.png").exists() + assert (tmp_path / "archive" / "output.01.txt").read_text() == "generated text" + assert (tmp_path / "archive" / "image.01.png").read_bytes() == b"\x89PNG" + + def test_clean_archive_preserves_subfolders(self, tmp_path: Path) -> None: + config = { + "archive_folder": "archive", + "targets": {"img/photo.png": {"prompt": "photo"}}, + } + _ = (tmp_path / "project.hokusai.yaml").write_text( + yaml.dump(config, default_flow_style=False) + ) + (tmp_path / "img").mkdir() + _ = (tmp_path / "img" / "photo.png").write_bytes(b"img") + + with patch("hokusai.cli.Path") as mock_path_cls: + mock_path_cls.cwd.return_value = tmp_path + result = runner.invoke(app, ["clean"]) + + assert result.exit_code == 0 + assert (tmp_path / "archive" / "img" / "photo.01.png").exists() + + def test_clean_archive_still_deletes_state(self, tmp_path: Path) -> None: + config = { + "archive_folder": "archive", + "targets": {"output.txt": {"prompt": "text"}}, + } + _ = (tmp_path / "project.hokusai.yaml").write_text( + yaml.dump(config, default_flow_style=False) + ) + _ = (tmp_path / "output.txt").write_text("data") + state_file = ".project.hokusai-state.yaml" + _ = (tmp_path / state_file).write_text("targets: {}") + + with patch("hokusai.cli.Path") as mock_path_cls: + mock_path_cls.cwd.return_value = tmp_path + result = runner.invoke(app, ["clean"]) + + assert result.exit_code == 0 + assert not (tmp_path / state_file).exists() + class TestGraphCommand: """Test the graph CLI command."""