artifact-tool-v3 3.0.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,11 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ *.egg-info/
4
+ .eggs/
5
+ dist/
6
+ build/
7
+ .venv/
8
+ venv/
9
+ .pytest_cache/
10
+ .mypy_cache/
11
+ .ruff_cache/
@@ -0,0 +1,51 @@
1
+ Metadata-Version: 2.5
2
+ Name: artifact_tool_v3
3
+ Version: 3.0.0
4
+ Summary: Host-side library for managing files generated in an agent sandbox.
5
+ Author: artifact_tool_v3
6
+ License-Expression: MIT
7
+ Classifier: License :: OSI Approved :: MIT License
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: Typing :: Typed
10
+ Requires-Python: >=3.10
11
+ Provides-Extra: dev
12
+ Requires-Dist: pytest>=8; extra == 'dev'
13
+ Description-Content-Type: text/markdown
14
+
15
+ # artifact_tool_v3
16
+
17
+ Host-side library for managing artifacts an agent writes into a sandbox workspace.
18
+
19
+ Same core as `artifact_tool_v2`, plus glob filters, a JSON manifest, a size summary, and workspace cleanup.
20
+
21
+ ## Install
22
+
23
+ ```bash
24
+ pip install artifact_tool_v3
25
+ ```
26
+
27
+ ## Workspace location
28
+
29
+ Each sandbox lives at `{root}/{sandbox_id}/`.
30
+
31
+ Root is resolved in this order:
32
+
33
+ 1. The `root=` argument to `ArtifactManager`
34
+ 2. The `ARTIFACT_TOOL_V3_ROOT` environment variable
35
+ 3. `~/.artifact_tool_v3`
36
+
37
+ ## Usage
38
+
39
+ ```python
40
+ from artifact_tool_v3 import ArtifactManager
41
+
42
+ mgr = ArtifactManager("run-123")
43
+ workspace = mgr.ensure_workspace()
44
+
45
+ artifacts = mgr.discover()
46
+ pngs = mgr.list(glob="**/*.png")
47
+ print(mgr.summary())
48
+ mgr.write_manifest(workspace / "manifest.json")
49
+ mgr.export("/tmp/out.zip", format="zip")
50
+ mgr.cleanup()
51
+ ```
@@ -0,0 +1,37 @@
1
+ # artifact_tool_v3
2
+
3
+ Host-side library for managing artifacts an agent writes into a sandbox workspace.
4
+
5
+ Same core as `artifact_tool_v2`, plus glob filters, a JSON manifest, a size summary, and workspace cleanup.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ pip install artifact_tool_v3
11
+ ```
12
+
13
+ ## Workspace location
14
+
15
+ Each sandbox lives at `{root}/{sandbox_id}/`.
16
+
17
+ Root is resolved in this order:
18
+
19
+ 1. The `root=` argument to `ArtifactManager`
20
+ 2. The `ARTIFACT_TOOL_V3_ROOT` environment variable
21
+ 3. `~/.artifact_tool_v3`
22
+
23
+ ## Usage
24
+
25
+ ```python
26
+ from artifact_tool_v3 import ArtifactManager
27
+
28
+ mgr = ArtifactManager("run-123")
29
+ workspace = mgr.ensure_workspace()
30
+
31
+ artifacts = mgr.discover()
32
+ pngs = mgr.list(glob="**/*.png")
33
+ print(mgr.summary())
34
+ mgr.write_manifest(workspace / "manifest.json")
35
+ mgr.export("/tmp/out.zip", format="zip")
36
+ mgr.cleanup()
37
+ ```
@@ -0,0 +1,24 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "artifact_tool_v3"
7
+ version = "3.0.0"
8
+ description = "Host-side library for managing files generated in an agent sandbox."
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ requires-python = ">=3.10"
12
+ authors = [{ name = "artifact_tool_v3" }]
13
+ classifiers = [
14
+ "Programming Language :: Python :: 3",
15
+ "License :: OSI Approved :: MIT License",
16
+ "Typing :: Typed",
17
+ ]
18
+
19
+ [project.optional-dependencies]
20
+ dev = ["pytest>=8"]
21
+
22
+ [tool.pytest.ini_options]
23
+ testpaths = ["tests"]
24
+ pythonpath = ["src"]
@@ -0,0 +1,7 @@
1
+ """Host-side helpers for artifacts written into an agent sandbox."""
2
+
3
+ from artifact_tool_v3.manager import ArtifactManager
4
+ from artifact_tool_v3.models import Artifact
5
+
6
+ __all__ = ["Artifact", "ArtifactManager"]
7
+ __version__ = "3.0.0"
@@ -0,0 +1,165 @@
1
+ """Scan, filter, and export files an agent wrote into a sandbox workspace."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import mimetypes
7
+ import os
8
+ import shutil
9
+ import zipfile
10
+ from datetime import datetime, timezone
11
+ from fnmatch import fnmatch
12
+ from pathlib import Path
13
+ from typing import Literal
14
+
15
+ from artifact_tool_v3.models import Artifact
16
+ from artifact_tool_v3.paths import workspace_path
17
+
18
+ ExportFormat = Literal["dir", "zip"]
19
+
20
+
21
+ class ArtifactManager:
22
+ """Host handle for one sandbox workspace keyed by sandbox_id."""
23
+
24
+ def __init__(
25
+ self,
26
+ sandbox_id: str,
27
+ *,
28
+ root: str | os.PathLike[str] | None = None,
29
+ ) -> None:
30
+ self.sandbox_id = sandbox_id
31
+ self._workspace = workspace_path(sandbox_id, root)
32
+
33
+ @property
34
+ def workspace_path(self) -> Path:
35
+ """Absolute path the agent should write artifacts into."""
36
+ return self._workspace
37
+
38
+ def ensure_workspace(self) -> Path:
39
+ """Create the workspace directory if needed and return its path."""
40
+ self._workspace.mkdir(parents=True, exist_ok=True)
41
+ return self._workspace
42
+
43
+ def discover(self) -> list[Artifact]:
44
+ """Walk the workspace and return metadata for every file and directory.
45
+
46
+ Missing workspaces are treated as empty so the host can call discover
47
+ before the agent has created anything.
48
+ """
49
+ if not self._workspace.exists():
50
+ return []
51
+
52
+ artifacts: list[Artifact] = []
53
+ for path in sorted(self._workspace.rglob("*")):
54
+ artifacts.append(_artifact_from_path(path, self._workspace))
55
+ return artifacts
56
+
57
+ def list(
58
+ self,
59
+ *,
60
+ name: str | None = None,
61
+ suffix: str | None = None,
62
+ type: str | None = None,
63
+ glob: str | None = None,
64
+ ) -> list[Artifact]:
65
+ """Discover artifacts and keep those matching the given filters.
66
+
67
+ `glob` is matched against the posix relative path (for example `**/*.png`).
68
+ """
69
+ results = self.discover()
70
+ if name is not None:
71
+ results = [item for item in results if item.name == name]
72
+ if suffix is not None:
73
+ expected = suffix if suffix.startswith(".") else f".{suffix}"
74
+ results = [
75
+ item
76
+ for item in results
77
+ if Path(item.name).suffix.lower() == expected.lower()
78
+ ]
79
+ if type is not None:
80
+ results = [item for item in results if item.type == type]
81
+ if glob is not None:
82
+ results = [item for item in results if fnmatch(item.relative_path, glob)]
83
+ return results
84
+
85
+ def summary(self) -> dict[str, int]:
86
+ """Return file/dir counts and total file bytes for this workspace."""
87
+ artifacts = self.discover()
88
+ files = [item for item in artifacts if not item.is_dir]
89
+ dirs = [item for item in artifacts if item.is_dir]
90
+ return {
91
+ "files": len(files),
92
+ "directories": len(dirs),
93
+ "total_bytes": sum(item.size for item in files),
94
+ }
95
+
96
+ def write_manifest(self, destination: str | os.PathLike[str] | None = None) -> Path:
97
+ """Write a JSON listing of every discovered artifact and return its path."""
98
+ dest = Path(destination) if destination is not None else self.ensure_workspace() / "manifest.json"
99
+ dest.parent.mkdir(parents=True, exist_ok=True)
100
+ payload = {
101
+ "sandbox_id": self.sandbox_id,
102
+ "workspace": str(self._workspace),
103
+ "artifacts": [item.to_dict() for item in self.discover()],
104
+ }
105
+ dest.write_text(json.dumps(payload, indent=2) + "\n")
106
+ return dest.resolve()
107
+
108
+ def cleanup(self) -> None:
109
+ """Delete the sandbox workspace directory if it exists."""
110
+ if self._workspace.exists():
111
+ shutil.rmtree(self._workspace)
112
+
113
+ def export(
114
+ self,
115
+ destination: str | os.PathLike[str],
116
+ *,
117
+ format: ExportFormat = "dir",
118
+ ) -> Path:
119
+ """Copy the workspace to a directory or zip archive of relative paths."""
120
+ dest = Path(destination)
121
+ if format == "dir":
122
+ return self._export_dir(dest)
123
+ if format == "zip":
124
+ return self._export_zip(dest)
125
+ raise ValueError(f"unsupported export format: {format!r}")
126
+
127
+ def _export_dir(self, destination: Path) -> Path:
128
+ """Copy the workspace tree into destination, creating parents as needed."""
129
+ if not self._workspace.exists():
130
+ destination.mkdir(parents=True, exist_ok=True)
131
+ return destination.resolve()
132
+
133
+ if destination.exists():
134
+ shutil.copytree(self._workspace, destination, dirs_exist_ok=True)
135
+ else:
136
+ shutil.copytree(self._workspace, destination)
137
+ return destination.resolve()
138
+
139
+ def _export_zip(self, destination: Path) -> Path:
140
+ """Write a zip whose member names are paths relative to the workspace."""
141
+ destination.parent.mkdir(parents=True, exist_ok=True)
142
+ with zipfile.ZipFile(destination, "w", compression=zipfile.ZIP_DEFLATED) as archive:
143
+ if self._workspace.exists():
144
+ for path in sorted(self._workspace.rglob("*")):
145
+ archive.write(path, arcname=path.relative_to(self._workspace).as_posix())
146
+ return destination.resolve()
147
+
148
+
149
+ def _artifact_from_path(path: Path, workspace: Path) -> Artifact:
150
+ """Build Artifact metadata from a filesystem stat of path."""
151
+ stat = path.stat()
152
+ is_dir = path.is_dir()
153
+ mime_type, _ = mimetypes.guess_type(path.name)
154
+ if is_dir:
155
+ mime_type = "inode/directory"
156
+
157
+ return Artifact(
158
+ name=path.name,
159
+ relative_path=path.relative_to(workspace).as_posix(),
160
+ path=path.resolve(),
161
+ type=mime_type,
162
+ size=0 if is_dir else stat.st_size,
163
+ created_at=datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc),
164
+ is_dir=is_dir,
165
+ )
@@ -0,0 +1,32 @@
1
+ """In-memory description of one file or directory in a sandbox workspace."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from datetime import datetime
7
+ from pathlib import Path
8
+
9
+
10
+ @dataclass(frozen=True)
11
+ class Artifact:
12
+ """Metadata for one path discovered under a sandbox workspace."""
13
+
14
+ name: str
15
+ relative_path: str
16
+ path: Path
17
+ type: str | None
18
+ size: int
19
+ created_at: datetime
20
+ is_dir: bool
21
+
22
+ def to_dict(self) -> dict[str, object]:
23
+ """JSON-friendly record used by manifests and summaries."""
24
+ return {
25
+ "name": self.name,
26
+ "relative_path": self.relative_path,
27
+ "path": str(self.path),
28
+ "type": self.type,
29
+ "size": self.size,
30
+ "created_at": self.created_at.isoformat(),
31
+ "is_dir": self.is_dir,
32
+ }
@@ -0,0 +1,50 @@
1
+ """Resolve the on-disk root and per-sandbox workspace path."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from pathlib import Path
7
+
8
+ # Used when the caller does not pass root= and the env var is unset.
9
+ DEFAULT_ROOT_DIRNAME = ".artifact_tool_v3"
10
+ ROOT_ENV_VAR = "ARTIFACT_TOOL_V3_ROOT"
11
+
12
+
13
+ class InvalidSandboxIdError(ValueError):
14
+ """Raised when sandbox_id would escape or collide with the store root."""
15
+
16
+
17
+ def resolve_root(root: str | os.PathLike[str] | None = None) -> Path:
18
+ """Pick the store root: explicit path, env var, then ~/.artifact_tool_v3."""
19
+ if root is not None:
20
+ return Path(root).expanduser().resolve()
21
+
22
+ env_root = os.environ.get(ROOT_ENV_VAR)
23
+ if env_root:
24
+ return Path(env_root).expanduser().resolve()
25
+
26
+ return (Path.home() / DEFAULT_ROOT_DIRNAME).resolve()
27
+
28
+
29
+ def validate_sandbox_id(sandbox_id: str) -> str:
30
+ """Reject ids that contain path separators or parent-directory references."""
31
+ if not sandbox_id or not sandbox_id.strip():
32
+ raise InvalidSandboxIdError("sandbox_id must be a non-empty string")
33
+
34
+ if sandbox_id != sandbox_id.strip():
35
+ raise InvalidSandboxIdError("sandbox_id must not have leading or trailing whitespace")
36
+
37
+ # Block both POSIX and Windows separators so an id cannot walk the tree.
38
+ if "/" in sandbox_id or "\\" in sandbox_id or os.sep in sandbox_id:
39
+ raise InvalidSandboxIdError("sandbox_id must not contain path separators")
40
+
41
+ if sandbox_id in {".", ".."} or ".." in sandbox_id:
42
+ raise InvalidSandboxIdError("sandbox_id must not contain '..' or be '.'")
43
+
44
+ return sandbox_id
45
+
46
+
47
+ def workspace_path(sandbox_id: str, root: str | os.PathLike[str] | None = None) -> Path:
48
+ """Return `{root}/{sandbox_id}` after validating the id."""
49
+ safe_id = validate_sandbox_id(sandbox_id)
50
+ return resolve_root(root) / safe_id
File without changes
@@ -0,0 +1,157 @@
1
+ """Tests for workspace creation, discovery, filters, and export."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import zipfile
7
+ from pathlib import Path
8
+
9
+ import pytest
10
+
11
+ from artifact_tool_v3 import ArtifactManager
12
+ from artifact_tool_v3.paths import InvalidSandboxIdError
13
+
14
+
15
+ def _write_file(path: Path, content: str = "hello") -> None:
16
+ path.parent.mkdir(parents=True, exist_ok=True)
17
+ path.write_text(content)
18
+
19
+
20
+ def test_ensure_workspace_creates_sandbox_directory(tmp_path: Path) -> None:
21
+ mgr = ArtifactManager("run-123", root=tmp_path)
22
+
23
+ workspace = mgr.ensure_workspace()
24
+
25
+ assert workspace == tmp_path / "run-123"
26
+ assert workspace.is_dir()
27
+ assert mgr.workspace_path == workspace
28
+
29
+
30
+ @pytest.mark.parametrize("sandbox_id", ["", " ", "a/b", r"a\b", "..", "foo..bar", "."])
31
+ def test_invalid_sandbox_ids_are_rejected(tmp_path: Path, sandbox_id: str) -> None:
32
+ with pytest.raises(InvalidSandboxIdError):
33
+ ArtifactManager(sandbox_id, root=tmp_path)
34
+
35
+
36
+ def test_discover_returns_empty_for_missing_or_empty_workspace(tmp_path: Path) -> None:
37
+ mgr = ArtifactManager("empty-run", root=tmp_path)
38
+
39
+ assert mgr.discover() == []
40
+
41
+ mgr.ensure_workspace()
42
+ assert mgr.discover() == []
43
+
44
+
45
+ def test_discover_collects_file_and_directory_metadata(tmp_path: Path) -> None:
46
+ mgr = ArtifactManager("run-meta", root=tmp_path)
47
+ workspace = mgr.ensure_workspace()
48
+ plots = workspace / "plots"
49
+ report = workspace / "report.json"
50
+ image = plots / "chart.png"
51
+ _write_file(report, '{"ok": true}')
52
+ _write_file(image, "png-bytes")
53
+
54
+ artifacts = {item.relative_path: item for item in mgr.discover()}
55
+
56
+ assert set(artifacts) == {"plots", "plots/chart.png", "report.json"}
57
+
58
+ json_artifact = artifacts["report.json"]
59
+ assert json_artifact.name == "report.json"
60
+ assert json_artifact.path == report.resolve()
61
+ assert json_artifact.type == "application/json"
62
+ assert json_artifact.size == report.stat().st_size
63
+ assert json_artifact.is_dir is False
64
+ assert json_artifact.created_at.tzinfo is not None
65
+
66
+ dir_artifact = artifacts["plots"]
67
+ assert dir_artifact.is_dir is True
68
+ assert dir_artifact.type == "inode/directory"
69
+ assert dir_artifact.size == 0
70
+
71
+
72
+ def test_list_filters_by_name_suffix_type_and_glob(tmp_path: Path) -> None:
73
+ mgr = ArtifactManager("run-filter", root=tmp_path)
74
+ workspace = mgr.ensure_workspace()
75
+ _write_file(workspace / "notes.txt", "txt")
76
+ _write_file(workspace / "plots" / "chart.PNG", "img")
77
+ _write_file(workspace / "report.json", "{}")
78
+
79
+ by_name = mgr.list(name="notes.txt")
80
+ assert [item.relative_path for item in by_name] == ["notes.txt"]
81
+
82
+ by_suffix = mgr.list(suffix=".png")
83
+ assert [item.relative_path for item in by_suffix] == ["plots/chart.PNG"]
84
+
85
+ by_type = mgr.list(type="application/json")
86
+ assert [item.relative_path for item in by_type] == ["report.json"]
87
+
88
+ by_glob = mgr.list(glob="plots/*")
89
+ assert [item.relative_path for item in by_glob] == ["plots/chart.PNG"]
90
+
91
+
92
+ def test_summary_counts_files_and_bytes(tmp_path: Path) -> None:
93
+ mgr = ArtifactManager("run-summary", root=tmp_path)
94
+ workspace = mgr.ensure_workspace()
95
+ _write_file(workspace / "a.txt", "ab")
96
+ _write_file(workspace / "nested" / "b.txt", "cd")
97
+
98
+ assert mgr.summary() == {"files": 2, "directories": 1, "total_bytes": 4}
99
+
100
+
101
+ def test_write_manifest_and_cleanup(tmp_path: Path) -> None:
102
+ mgr = ArtifactManager("run-manifest", root=tmp_path)
103
+ workspace = mgr.ensure_workspace()
104
+ _write_file(workspace / "out.txt", "payload")
105
+
106
+ dest = tmp_path / "manifest.json"
107
+ result = mgr.write_manifest(dest)
108
+ payload = json.loads(result.read_text())
109
+
110
+ assert payload["sandbox_id"] == "run-manifest"
111
+ assert any(item["name"] == "out.txt" for item in payload["artifacts"])
112
+
113
+ mgr.cleanup()
114
+ assert not workspace.exists()
115
+ assert mgr.discover() == []
116
+
117
+
118
+ def test_export_dir_copies_workspace_tree(tmp_path: Path) -> None:
119
+ mgr = ArtifactManager("run-export-dir", root=tmp_path)
120
+ workspace = mgr.ensure_workspace()
121
+ _write_file(workspace / "nested" / "out.txt", "payload")
122
+
123
+ dest = tmp_path / "bundle"
124
+ result = mgr.export(dest, format="dir")
125
+
126
+ assert result == dest.resolve()
127
+ assert (dest / "nested" / "out.txt").read_text() == "payload"
128
+
129
+
130
+ def test_export_zip_uses_relative_member_names(tmp_path: Path) -> None:
131
+ mgr = ArtifactManager("run-export-zip", root=tmp_path)
132
+ workspace = mgr.ensure_workspace()
133
+ _write_file(workspace / "nested" / "out.txt", "payload")
134
+
135
+ dest = tmp_path / "bundle.zip"
136
+ result = mgr.export(dest, format="zip")
137
+
138
+ assert result == dest.resolve()
139
+ with zipfile.ZipFile(dest) as archive:
140
+ names = set(archive.namelist())
141
+ assert "nested/out.txt" in names
142
+ assert archive.read("nested/out.txt") == b"payload"
143
+
144
+
145
+ def test_export_empty_sandbox_creates_empty_dir_and_zip(tmp_path: Path) -> None:
146
+ mgr = ArtifactManager("run-export-empty", root=tmp_path)
147
+
148
+ dir_dest = tmp_path / "empty-bundle"
149
+ zip_dest = tmp_path / "empty.zip"
150
+
151
+ mgr.export(dir_dest, format="dir")
152
+ mgr.export(zip_dest, format="zip")
153
+
154
+ assert dir_dest.is_dir()
155
+ assert list(dir_dest.iterdir()) == []
156
+ with zipfile.ZipFile(zip_dest) as archive:
157
+ assert archive.namelist() == []
@@ -0,0 +1,27 @@
1
+ """Tests for store-root resolution and sandbox_id rules."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ import pytest
8
+
9
+ from artifact_tool_v3.paths import ROOT_ENV_VAR, resolve_root, workspace_path
10
+
11
+
12
+ def test_resolve_root_prefers_argument_over_env(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
13
+ monkeypatch.setenv(ROOT_ENV_VAR, str(tmp_path / "from-env"))
14
+ explicit = tmp_path / "explicit"
15
+
16
+ assert resolve_root(explicit) == explicit.resolve()
17
+
18
+
19
+ def test_resolve_root_uses_env_when_argument_missing(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
20
+ env_root = tmp_path / "from-env"
21
+ monkeypatch.setenv(ROOT_ENV_VAR, str(env_root))
22
+
23
+ assert resolve_root() == env_root.resolve()
24
+
25
+
26
+ def test_workspace_path_nests_under_root(tmp_path: Path) -> None:
27
+ assert workspace_path("run-abc", root=tmp_path) == (tmp_path / "run-abc").resolve()