artifactum 0.2.0__py3-none-any.whl
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.
- artifactum/__init__.py +221 -0
- artifactum/cache.py +43 -0
- artifactum/cli/__init__.py +0 -0
- artifactum/cli/init_cmd.py +95 -0
- artifactum/cli/main.py +340 -0
- artifactum/cli/shell_cmd.py +187 -0
- artifactum/cli/tui.py +308 -0
- artifactum/client.py +224 -0
- artifactum/config.py +86 -0
- artifactum/exceptions.py +28 -0
- artifactum/hashing.py +13 -0
- artifactum/provenance.py +180 -0
- artifactum/storage/__init__.py +59 -0
- artifactum/storage/base.py +53 -0
- artifactum/storage/gcs.py +48 -0
- artifactum/storage/local.py +61 -0
- artifactum/storage/move.py +53 -0
- artifactum/tracking.py +175 -0
- artifactum-0.2.0.dist-info/METADATA +155 -0
- artifactum-0.2.0.dist-info/RECORD +23 -0
- artifactum-0.2.0.dist-info/WHEEL +4 -0
- artifactum-0.2.0.dist-info/entry_points.txt +2 -0
- artifactum-0.2.0.dist-info/licenses/LICENSE +21 -0
artifactum/__init__.py
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
"""Artifactum: a registry and lineage tracker for the files data teams produce."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import logging
|
|
7
|
+
import mimetypes
|
|
8
|
+
import tempfile
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import TYPE_CHECKING, Any
|
|
11
|
+
|
|
12
|
+
from artifactum.client import ArtifactMeta
|
|
13
|
+
from artifactum.exceptions import ArtifactNotFoundError, PlanLimitError
|
|
14
|
+
from artifactum.tracking import current_scope, record_input, step
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from artifactum.storage import Storage
|
|
18
|
+
|
|
19
|
+
__all__ = ["artifact", "publish", "search", "step", "PlanLimitError"]
|
|
20
|
+
|
|
21
|
+
log = logging.getLogger("artifactum")
|
|
22
|
+
|
|
23
|
+
# Code snapshots already uploaded by this process (content-addressed, so one upload is enough)
|
|
24
|
+
_uploaded_code: set[str] = set()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def artifact(id: str) -> str:
|
|
28
|
+
"""Resolve an artifact ID to a local file path, fetching it from storage if needed."""
|
|
29
|
+
from artifactum.cache import Cache
|
|
30
|
+
from artifactum.client import ArtifactumClient
|
|
31
|
+
from artifactum.config import load_config
|
|
32
|
+
from artifactum.hashing import sha256_file
|
|
33
|
+
from artifactum.storage import storage_for
|
|
34
|
+
|
|
35
|
+
config = load_config()
|
|
36
|
+
client = ArtifactumClient(config)
|
|
37
|
+
meta = client.get_artifact(id)
|
|
38
|
+
record_input(meta.id)
|
|
39
|
+
if meta.superseded_by:
|
|
40
|
+
log.warning(
|
|
41
|
+
"artifactum: %s is an older version of %s; the newest is %s",
|
|
42
|
+
meta.id,
|
|
43
|
+
meta.name or "this artifact",
|
|
44
|
+
meta.superseded_by,
|
|
45
|
+
)
|
|
46
|
+
elif meta.stale:
|
|
47
|
+
log.warning("artifactum: %s was built from inputs that have newer versions since", meta.id)
|
|
48
|
+
|
|
49
|
+
cache = Cache(config)
|
|
50
|
+
cached = cache.get(meta.hash)
|
|
51
|
+
if cached:
|
|
52
|
+
return str(cached)
|
|
53
|
+
|
|
54
|
+
with tempfile.NamedTemporaryFile(delete=False) as tmp:
|
|
55
|
+
tmp_path = Path(tmp.name)
|
|
56
|
+
|
|
57
|
+
try:
|
|
58
|
+
storage_for(meta.gcs_url).download(meta.gcs_url, tmp_path)
|
|
59
|
+
actual_hash = sha256_file(tmp_path)
|
|
60
|
+
if actual_hash != meta.hash:
|
|
61
|
+
tmp_path.unlink(missing_ok=True)
|
|
62
|
+
raise ArtifactNotFoundError(id)
|
|
63
|
+
cached = cache.put(meta.hash, tmp_path)
|
|
64
|
+
finally:
|
|
65
|
+
tmp_path.unlink(missing_ok=True)
|
|
66
|
+
|
|
67
|
+
return str(cached)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def publish(
|
|
71
|
+
path: str | Path,
|
|
72
|
+
*,
|
|
73
|
+
parents: list[str] | None = None,
|
|
74
|
+
parent: str | None = None,
|
|
75
|
+
description: str | None = None,
|
|
76
|
+
tags: list[str] | None = None,
|
|
77
|
+
docs: str | Path | None = None,
|
|
78
|
+
name: str | None = None,
|
|
79
|
+
) -> str:
|
|
80
|
+
"""Publish a file to the registry. Returns the canonical short ID.
|
|
81
|
+
|
|
82
|
+
Parents default to every artifact read with ``artifact()`` in the current
|
|
83
|
+
``step()`` (or the whole process, outside a step). Pass ``parents=[...]`` to
|
|
84
|
+
set them explicitly, or ``parents=[]`` to record none.
|
|
85
|
+
|
|
86
|
+
``docs`` is markdown documentation shown on the artifact's page: either the
|
|
87
|
+
text itself or a path to a markdown file (e.g. a README next to your code).
|
|
88
|
+
|
|
89
|
+
``name`` says what this is a version of: publishing again under the same name
|
|
90
|
+
records a new version, and anything built from the old one is flagged stale. It
|
|
91
|
+
defaults to the file name, so give one when file names are generic (``data.csv``).
|
|
92
|
+
"""
|
|
93
|
+
return _publish(path, parents=parents, parent=parent, description=description, tags=tags, docs=docs, name=name)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _publish(
|
|
97
|
+
path: str | Path,
|
|
98
|
+
*,
|
|
99
|
+
parents: list[str] | None = None,
|
|
100
|
+
parent: str | None = None,
|
|
101
|
+
description: str | None = None,
|
|
102
|
+
tags: list[str] | None = None,
|
|
103
|
+
docs: str | Path | None = None,
|
|
104
|
+
name: str | None = None,
|
|
105
|
+
environment: dict[str, Any] | None = None,
|
|
106
|
+
code_path: Path | None = None,
|
|
107
|
+
) -> str:
|
|
108
|
+
"""publish(), plus the CLI's way to describe a shell run: ``environment`` replaces the
|
|
109
|
+
captured Python environment and ``code_path`` is snapshotted instead of the running script."""
|
|
110
|
+
from artifactum.client import ArtifactumClient
|
|
111
|
+
from artifactum.config import load_config
|
|
112
|
+
from artifactum.hashing import sha256_file
|
|
113
|
+
from artifactum.storage import storage_for
|
|
114
|
+
|
|
115
|
+
config = load_config()
|
|
116
|
+
client = ArtifactumClient(config)
|
|
117
|
+
storage = storage_for(config.storage)
|
|
118
|
+
storage.check() # before registering, so a bad location doesn't leave a record with no file
|
|
119
|
+
scope = current_scope()
|
|
120
|
+
|
|
121
|
+
if parents is None and parent is None:
|
|
122
|
+
parent_ids = list(scope.inputs)
|
|
123
|
+
else:
|
|
124
|
+
parent_ids = list(dict.fromkeys([*([parent] if parent else []), *(parents or [])]))
|
|
125
|
+
|
|
126
|
+
local = Path(path).expanduser().resolve()
|
|
127
|
+
content_hash = sha256_file(local)
|
|
128
|
+
size_bytes = local.stat().st_size
|
|
129
|
+
content_type, _ = mimetypes.guess_type(local.name)
|
|
130
|
+
url = storage.url_for(content_hash, local.name)
|
|
131
|
+
|
|
132
|
+
docs_text = _read_docs(docs)
|
|
133
|
+
|
|
134
|
+
# The first publish in a scope sends the full run record; later ones just reference it.
|
|
135
|
+
run = None if scope.run_registered else _capture_run(scope, config, storage, environment, code_path)
|
|
136
|
+
|
|
137
|
+
artifact_id = client.register_artifact(
|
|
138
|
+
hash=content_hash,
|
|
139
|
+
gcs_url=url,
|
|
140
|
+
size_bytes=size_bytes,
|
|
141
|
+
content_type=content_type,
|
|
142
|
+
parent_ids=parent_ids,
|
|
143
|
+
description=description,
|
|
144
|
+
tags=tags,
|
|
145
|
+
run_id=scope.run_id,
|
|
146
|
+
run=run,
|
|
147
|
+
docs=docs_text,
|
|
148
|
+
name=name,
|
|
149
|
+
)
|
|
150
|
+
scope.mark_registered()
|
|
151
|
+
|
|
152
|
+
storage.upload(local, url)
|
|
153
|
+
return artifact_id
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _read_docs(docs: str | Path | None) -> str | None:
|
|
157
|
+
if docs is None:
|
|
158
|
+
return None
|
|
159
|
+
if isinstance(docs, Path):
|
|
160
|
+
return docs.expanduser().read_text()
|
|
161
|
+
candidate = Path(docs).expanduser() if "\n" not in docs and len(docs) < 1024 else None
|
|
162
|
+
if candidate is not None and candidate.suffix.lower() in (".md", ".markdown", ".txt") and candidate.is_file():
|
|
163
|
+
return candidate.read_text()
|
|
164
|
+
return docs
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _capture_run(
|
|
168
|
+
scope: Any,
|
|
169
|
+
config: Any,
|
|
170
|
+
storage: Storage,
|
|
171
|
+
environment: dict[str, Any] | None = None,
|
|
172
|
+
code_path: Path | None = None,
|
|
173
|
+
) -> dict[str, Any]:
|
|
174
|
+
"""Describe the code and environment behind this scope. Never raises."""
|
|
175
|
+
from artifactum.provenance import MACHINE_FIELDS, CodeSnapshot, capture_environment, code_snapshot
|
|
176
|
+
|
|
177
|
+
run: dict[str, Any] = {"name": scope.name, "started_at": scope.started_at.isoformat()}
|
|
178
|
+
try:
|
|
179
|
+
machine = getattr(config, "capture_machine", True)
|
|
180
|
+
env = dict(environment) if environment is not None else capture_environment(machine=machine)
|
|
181
|
+
if not machine: # the shell commands pass their own environment
|
|
182
|
+
for field in MACHINE_FIELDS:
|
|
183
|
+
env.pop(field, None)
|
|
184
|
+
run.update(env)
|
|
185
|
+
except Exception as e: # provenance is best-effort; never block a publish on it
|
|
186
|
+
log.warning("artifactum: could not capture run environment: %s", e)
|
|
187
|
+
|
|
188
|
+
if not config.capture_code:
|
|
189
|
+
return run
|
|
190
|
+
try:
|
|
191
|
+
snapshot = (
|
|
192
|
+
CodeSnapshot(code_path.name, code_path.read_bytes())
|
|
193
|
+
if code_path
|
|
194
|
+
else (None if environment is not None else code_snapshot())
|
|
195
|
+
)
|
|
196
|
+
if snapshot is not None:
|
|
197
|
+
code_hash = hashlib.sha256(snapshot.content).hexdigest()
|
|
198
|
+
code_url = storage.url_for(code_hash, snapshot.filename)
|
|
199
|
+
if code_url not in _uploaded_code:
|
|
200
|
+
with tempfile.NamedTemporaryFile(delete=False) as tmp:
|
|
201
|
+
tmp.write(snapshot.content)
|
|
202
|
+
try:
|
|
203
|
+
storage.upload(Path(tmp.name), code_url)
|
|
204
|
+
finally:
|
|
205
|
+
Path(tmp.name).unlink(missing_ok=True)
|
|
206
|
+
_uploaded_code.add(code_url)
|
|
207
|
+
run["code_hash"] = code_hash
|
|
208
|
+
run["code_url"] = code_url
|
|
209
|
+
except Exception as e:
|
|
210
|
+
log.warning("artifactum: could not upload code snapshot: %s", e)
|
|
211
|
+
return run
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def search(query: str, *, tags: list[str] | None = None) -> list[ArtifactMeta]:
|
|
215
|
+
"""Search the registry by free text and/or tags."""
|
|
216
|
+
from artifactum.client import ArtifactumClient
|
|
217
|
+
from artifactum.config import load_config
|
|
218
|
+
|
|
219
|
+
config = load_config()
|
|
220
|
+
client = ArtifactumClient(config)
|
|
221
|
+
return client.search(query, tags=tags)
|
artifactum/cache.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import shutil
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from artifactum.config import Config
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Cache:
|
|
10
|
+
def __init__(self, config: Config):
|
|
11
|
+
self._root = config.cache_path
|
|
12
|
+
self._limit_bytes = int(config.cache_limit_gb * 1024**3)
|
|
13
|
+
self._root.mkdir(parents=True, exist_ok=True)
|
|
14
|
+
|
|
15
|
+
def _entry(self, content_hash: str) -> Path:
|
|
16
|
+
return self._root / content_hash
|
|
17
|
+
|
|
18
|
+
def get(self, content_hash: str) -> Path | None:
|
|
19
|
+
p = self._entry(content_hash)
|
|
20
|
+
if p.exists():
|
|
21
|
+
p.touch() # update atime for LRU tracking
|
|
22
|
+
return p
|
|
23
|
+
return None
|
|
24
|
+
|
|
25
|
+
def put(self, content_hash: str, src: Path) -> Path:
|
|
26
|
+
dest = self._entry(content_hash)
|
|
27
|
+
if not dest.exists():
|
|
28
|
+
shutil.copy2(src, dest)
|
|
29
|
+
self._evict_if_needed()
|
|
30
|
+
return dest
|
|
31
|
+
|
|
32
|
+
def _evict_if_needed(self) -> None:
|
|
33
|
+
entries = list(self._root.iterdir())
|
|
34
|
+
total = sum(e.stat().st_size for e in entries)
|
|
35
|
+
if total <= self._limit_bytes:
|
|
36
|
+
return
|
|
37
|
+
# Sort by atime ascending (oldest first)
|
|
38
|
+
entries.sort(key=lambda e: e.stat().st_atime)
|
|
39
|
+
for e in entries:
|
|
40
|
+
if total <= self._limit_bytes:
|
|
41
|
+
break
|
|
42
|
+
total -= e.stat().st_size
|
|
43
|
+
e.unlink()
|
|
File without changes
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import replace
|
|
4
|
+
|
|
5
|
+
from rich.console import Console
|
|
6
|
+
from rich.prompt import Prompt
|
|
7
|
+
|
|
8
|
+
from artifactum.config import _CONFIG_PATH, Config, load_config, save_config
|
|
9
|
+
from artifactum.exceptions import ArtifactumError, ConfigError
|
|
10
|
+
from artifactum.storage import LocalStorage, normalise_location, storage_for
|
|
11
|
+
|
|
12
|
+
console = Console()
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def run_init(token: str | None = None, storage: str | None = None, server: str | None = None) -> None:
|
|
16
|
+
"""Set up ~/.artifactum/config.toml.
|
|
17
|
+
|
|
18
|
+
With ``token`` and ``storage`` given (``atfm init --token ... --storage ...``, the line
|
|
19
|
+
the web app's getting-started steps show), nothing is asked: everything else keeps its
|
|
20
|
+
current value or default. Otherwise a short wizard asks for whatever wasn't given.
|
|
21
|
+
"""
|
|
22
|
+
# Offer existing values (including a config from before the rename) as defaults
|
|
23
|
+
try:
|
|
24
|
+
current = load_config()
|
|
25
|
+
except ConfigError:
|
|
26
|
+
current = Config()
|
|
27
|
+
if server:
|
|
28
|
+
current = replace(current, server_url=server)
|
|
29
|
+
|
|
30
|
+
if token and storage:
|
|
31
|
+
config = replace(current, api_token=token, storage=normalise_location(storage))
|
|
32
|
+
else:
|
|
33
|
+
config = _ask(current, token, storage)
|
|
34
|
+
|
|
35
|
+
save_config(config)
|
|
36
|
+
cache_path = config.cache_path
|
|
37
|
+
cache_path.mkdir(parents=True, exist_ok=True)
|
|
38
|
+
|
|
39
|
+
console.print(f"[green]✓[/green] Config saved to {_CONFIG_PATH}")
|
|
40
|
+
console.print(f"[green]✓[/green] Cache at {cache_path}")
|
|
41
|
+
if config.storage:
|
|
42
|
+
_ready_storage(config.storage)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _ask(current: Config, token: str | None, storage: str | None) -> Config:
|
|
46
|
+
console.print("\n[bold cyan]Artifactum setup[/bold cyan]\n")
|
|
47
|
+
console.print(f"This will create [bold]{_CONFIG_PATH}[/bold].\n")
|
|
48
|
+
|
|
49
|
+
server_url = Prompt.ask("Server URL", default=current.server_url)
|
|
50
|
+
api_token = token or (
|
|
51
|
+
Prompt.ask(f"API token (from {server_url.rstrip('/')}/settings)", password=True) or current.api_token
|
|
52
|
+
)
|
|
53
|
+
if not storage:
|
|
54
|
+
console.print(
|
|
55
|
+
"\nFiles stay in storage you control: a directory (to try it on your own, ~/atfm-files; for a team, a"
|
|
56
|
+
" shared one everyone reaches at the same path, like /groups/lab/atfm), or a Google Cloud Storage"
|
|
57
|
+
" bucket (gs://my-team-bucket/atfm). You can move later with `atfm storage move`."
|
|
58
|
+
)
|
|
59
|
+
storage = Prompt.ask("Storage location", default=current.storage or "~/atfm-files")
|
|
60
|
+
cache_dir = Prompt.ask("Local cache directory", default=current.cache_dir)
|
|
61
|
+
cache_limit_gb_str = Prompt.ask("Cache size limit (GB)", default=f"{current.cache_limit_gb:g}")
|
|
62
|
+
try:
|
|
63
|
+
cache_limit_gb = float(cache_limit_gb_str)
|
|
64
|
+
except ValueError:
|
|
65
|
+
cache_limit_gb = 2.0
|
|
66
|
+
console.print()
|
|
67
|
+
|
|
68
|
+
return replace(
|
|
69
|
+
current,
|
|
70
|
+
api_token=api_token,
|
|
71
|
+
server_url=server_url,
|
|
72
|
+
storage=normalise_location(storage) if storage else "",
|
|
73
|
+
cache_dir=cache_dir,
|
|
74
|
+
cache_limit_gb=cache_limit_gb,
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _ready_storage(location: str) -> None:
|
|
79
|
+
"""Check the storage works. A directory that doesn't exist yet is created: naming it in
|
|
80
|
+
init is a clear enough request, unlike a publish to a drive that isn't mounted."""
|
|
81
|
+
backend = storage_for(location)
|
|
82
|
+
if isinstance(backend, LocalStorage):
|
|
83
|
+
path = backend.path_of(backend.root)
|
|
84
|
+
if not path.exists():
|
|
85
|
+
try:
|
|
86
|
+
path.mkdir(parents=True)
|
|
87
|
+
console.print(f"[green]✓[/green] Created {path} for your files")
|
|
88
|
+
except OSError as e:
|
|
89
|
+
console.print(f"[yellow]Couldn't create {path}: {e.strerror}[/yellow]")
|
|
90
|
+
return
|
|
91
|
+
try:
|
|
92
|
+
backend.check()
|
|
93
|
+
console.print(f"[green]✓[/green] Files will be stored in {backend.display(backend.root)}")
|
|
94
|
+
except ArtifactumError as e:
|
|
95
|
+
console.print(f"[yellow]{e}[/yellow]")
|