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 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]")