generated from coulomb/repo-seed
IB-WP-0014: archive-list, restore, retention annotation, docs (T03-T05)
Round out IB-WP-0014 with the remaining archive operations and docs. - restore_archive() and `infospace-bench restore <pkg> --target <dir>` round-trip a finalized package's bytes back to disk. Refuses to overwrite a non-empty target unless --force. --from <infospace-root> resolves the store location. - archive-list CLI with --with-retention flag; annotate_retention() opens the per-infospace registry and joins each record with its current retention state (effective class, expires, holds, eligibility). - docs/archive-integration.md covers when to archive, the include set, retention classes, storage layout, credentials policy, and the explicit non-goal that S3/git backends live in artifact-store. - SCOPE.md cross-links the new doc. - Workplan flipped to status: done. Full pytest suite: 72 passed. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import filecmp
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
@@ -8,10 +9,13 @@ import yaml
|
||||
from infospace_bench import (
|
||||
ArchiveRecord,
|
||||
InfospaceError,
|
||||
RestoredArchive,
|
||||
add_artifact,
|
||||
annotate_retention,
|
||||
archive_infospace,
|
||||
create_infospace,
|
||||
list_archives,
|
||||
restore_archive,
|
||||
)
|
||||
from infospace_bench.archive import (
|
||||
ARCHIVE_INDEX_PATH,
|
||||
@@ -99,3 +103,96 @@ def test_archive_rejects_empty_include(tmp_path: Path) -> None:
|
||||
with pytest.raises(InfospaceError) as excinfo:
|
||||
archive_infospace(root, include=["does-not-exist"])
|
||||
assert excinfo.value.code == "empty_archive"
|
||||
|
||||
|
||||
def test_restore_archive_round_trips_bytes(tmp_path: Path) -> None:
|
||||
root = _seed_infospace(tmp_path)
|
||||
record = archive_infospace(root, note="round trip")
|
||||
|
||||
target = tmp_path / "restored"
|
||||
result = restore_archive(
|
||||
record.package_id,
|
||||
target=target,
|
||||
source_infospace=root,
|
||||
)
|
||||
|
||||
assert isinstance(result, RestoredArchive)
|
||||
assert result.manifest_digest == record.manifest_digest
|
||||
assert result.file_count == record.file_count
|
||||
|
||||
for rel in result.restored_paths:
|
||||
original = root / rel
|
||||
restored = target / rel
|
||||
assert restored.is_file()
|
||||
assert filecmp.cmp(original, restored, shallow=False), rel
|
||||
|
||||
|
||||
def test_restore_archive_refuses_non_empty_target(tmp_path: Path) -> None:
|
||||
root = _seed_infospace(tmp_path)
|
||||
record = archive_infospace(root)
|
||||
|
||||
target = tmp_path / "filled"
|
||||
target.mkdir()
|
||||
(target / "existing.txt").write_text("hi", encoding="utf-8")
|
||||
|
||||
with pytest.raises(InfospaceError) as excinfo:
|
||||
restore_archive(
|
||||
record.package_id,
|
||||
target=target,
|
||||
source_infospace=root,
|
||||
)
|
||||
assert excinfo.value.code == "restore_target_not_empty"
|
||||
|
||||
|
||||
def test_restore_archive_force_overwrites_non_empty_target(tmp_path: Path) -> None:
|
||||
root = _seed_infospace(tmp_path)
|
||||
record = archive_infospace(root)
|
||||
|
||||
target = tmp_path / "filled-force"
|
||||
target.mkdir()
|
||||
(target / "leftover.txt").write_text("old", encoding="utf-8")
|
||||
|
||||
result = restore_archive(
|
||||
record.package_id,
|
||||
target=target,
|
||||
source_infospace=root,
|
||||
force=True,
|
||||
)
|
||||
assert result.file_count == record.file_count
|
||||
# Pre-existing files that are not in the manifest are left in place.
|
||||
assert (target / "leftover.txt").read_text(encoding="utf-8") == "old"
|
||||
|
||||
|
||||
def test_restore_archive_requires_store_location(tmp_path: Path) -> None:
|
||||
with pytest.raises(InfospaceError) as excinfo:
|
||||
restore_archive("00000000-0000-0000-0000-000000000000", target=tmp_path)
|
||||
assert excinfo.value.code == "missing_archive_store"
|
||||
|
||||
|
||||
def test_annotate_retention_returns_state_for_each_archive(tmp_path: Path) -> None:
|
||||
root = _seed_infospace(tmp_path)
|
||||
first = archive_infospace(root)
|
||||
second = archive_infospace(root)
|
||||
|
||||
archives = list_archives(root)
|
||||
annotated = annotate_retention(archives, source_infospace=root)
|
||||
|
||||
assert [item["archive"]["package_id"] for item in annotated] == [
|
||||
first.package_id,
|
||||
second.package_id,
|
||||
]
|
||||
for item in annotated:
|
||||
retention = item["retention"]
|
||||
assert retention is not None
|
||||
assert retention["effective_class"] == DEFAULT_RETENTION_CLASS
|
||||
assert retention["eligible_for_deletion"] is False
|
||||
|
||||
|
||||
def test_annotate_retention_returns_none_when_store_missing(tmp_path: Path) -> None:
|
||||
root = _seed_infospace(tmp_path)
|
||||
archive_infospace(root, store_root=tmp_path / "external-store")
|
||||
|
||||
archives = list_archives(root)
|
||||
# Source infospace's store doesn't exist (we overrode store_root)
|
||||
annotated = annotate_retention(archives, source_infospace=root)
|
||||
assert annotated[0]["retention"] is None
|
||||
|
||||
@@ -57,3 +57,53 @@ def test_cli_returns_structured_error(tmp_path: Path) -> None:
|
||||
assert result.returncode == 2
|
||||
payload = json.loads(result.stderr)
|
||||
assert payload["error"]["code"] == "missing_infospace"
|
||||
|
||||
|
||||
def test_cli_archive_list_and_restore(tmp_path: Path) -> None:
|
||||
create = run_cli(
|
||||
"create",
|
||||
str(tmp_path),
|
||||
"cli-archive",
|
||||
"--name",
|
||||
"CLI Archive",
|
||||
)
|
||||
assert create.returncode == 0, create.stderr
|
||||
root = tmp_path / "infospaces" / "cli-archive"
|
||||
|
||||
source = tmp_path / "src.md"
|
||||
source.write_text("# src\n", encoding="utf-8")
|
||||
add = run_cli(
|
||||
"add-artifact", str(root), str(source), "--kind", "source", "--title", "Src",
|
||||
)
|
||||
assert add.returncode == 0, add.stderr
|
||||
|
||||
archive = run_cli("archive", str(root), "--note", "via cli")
|
||||
assert archive.returncode == 0, archive.stderr
|
||||
record = json.loads(archive.stdout)
|
||||
assert record["note"] == "via cli"
|
||||
assert record["manifest_digest"].startswith("blake3:")
|
||||
|
||||
listing = run_cli("archive-list", str(root))
|
||||
assert listing.returncode == 0, listing.stderr
|
||||
assert json.loads(listing.stdout)["archives"][0]["package_id"] == record["package_id"]
|
||||
|
||||
listing_with_retention = run_cli(
|
||||
"archive-list", str(root), "--with-retention",
|
||||
)
|
||||
assert listing_with_retention.returncode == 0, listing_with_retention.stderr
|
||||
annotated = json.loads(listing_with_retention.stdout)["archives"]
|
||||
assert annotated[0]["retention"]["effective_class"] == "release-evidence"
|
||||
|
||||
target = tmp_path / "restored"
|
||||
restore = run_cli(
|
||||
"restore",
|
||||
record["package_id"],
|
||||
"--target",
|
||||
str(target),
|
||||
"--from",
|
||||
str(root),
|
||||
)
|
||||
assert restore.returncode == 0, restore.stderr
|
||||
result = json.loads(restore.stdout)
|
||||
assert result["file_count"] == record["file_count"]
|
||||
assert (target / "infospace.yaml").is_file()
|
||||
|
||||
Reference in New Issue
Block a user