dockflow-artifacts 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.
@@ -0,0 +1,17 @@
1
+ """Explicit artifact publication, with optional storage and orchestration adapters."""
2
+
3
+ from .errors import ArtifactError, ConflictError, IntegrityError
4
+ from .identity import fingerprint, publication_id
5
+ from .publication import Publication, Publisher, format_path, step_path
6
+
7
+ __all__ = [
8
+ "ArtifactError",
9
+ "ConflictError",
10
+ "IntegrityError",
11
+ "Publication",
12
+ "Publisher",
13
+ "format_path",
14
+ "fingerprint",
15
+ "publication_id",
16
+ "step_path",
17
+ ]
@@ -0,0 +1,91 @@
1
+ """Private path and content helpers shared by storage implementations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import re
7
+ import stat
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ from dockflow_core.canonical import stable_json_bytes
12
+ from dockflow_core.filesystem import hash_stream, reject_symlinks
13
+
14
+ from .errors import ArtifactError, IntegrityError
15
+
16
+
17
+ def relative(value: str, *, internal: bool = False) -> str:
18
+ if not isinstance(value, str) or not value or len(value.encode()) > 1024:
19
+ raise ArtifactError(
20
+ "Destination must be a nonempty relative path of at most 1024 bytes"
21
+ )
22
+ for part in value.split("/"):
23
+ if (
24
+ part in {"", ".", ".."}
25
+ or not re.fullmatch(r"[A-Za-z0-9_.-]+", part)
26
+ or part.endswith(".")
27
+ or (part in {".dockflow", ".dockflow-artifacts"} and not internal)
28
+ ):
29
+ raise ArtifactError(
30
+ "Paths require safe relative segments (letters, digits, _, - and .)"
31
+ )
32
+ return value
33
+
34
+
35
+ def no_links(path: Path) -> None:
36
+ try:
37
+ reject_symlinks(path)
38
+ except ValueError:
39
+ raise ArtifactError("Artifact paths cannot contain symlinks") from None
40
+
41
+
42
+ def canonical(value: Any) -> bytes:
43
+ try:
44
+ return stable_json_bytes(value)
45
+ except (TypeError, ValueError) as exc:
46
+ raise ArtifactError("Metadata must contain finite JSON values") from exc
47
+
48
+
49
+ def file_info(path: Path) -> tuple[int, str]:
50
+ no_links(path)
51
+ before = path.stat()
52
+ if not stat.S_ISREG(before.st_mode):
53
+ raise ArtifactError("Only regular files are supported")
54
+ with path.open("rb") as stream:
55
+ result = hash_stream(stream)
56
+ after = path.stat()
57
+ if (before.st_ino, before.st_size, before.st_mtime_ns) != (
58
+ after.st_ino,
59
+ after.st_size,
60
+ after.st_mtime_ns,
61
+ ):
62
+ raise IntegrityError("Artifact source changed while reading")
63
+ return result
64
+
65
+
66
+ def inventory(source: Path) -> tuple[str, list[dict[str, Any]], str]:
67
+ no_links(source)
68
+ if source.is_file():
69
+ size, digest = file_info(source)
70
+ return "file", [{"path": "", "size": size, "sha256": digest}], digest
71
+ if not source.is_dir():
72
+ raise ArtifactError(
73
+ "Artifact source must be an existing regular file or directory"
74
+ )
75
+ entries = []
76
+ for path in sorted(source.rglob("*")):
77
+ no_links(path)
78
+ name = relative(path.relative_to(source).as_posix())
79
+ if path.is_dir():
80
+ if not any(path.iterdir()):
81
+ raise ArtifactError(
82
+ "Empty directories are not supported by portable publication"
83
+ )
84
+ continue
85
+ size, digest = file_info(path)
86
+ entries.append({"path": name, "size": size, "sha256": digest})
87
+ if not entries:
88
+ raise ArtifactError(
89
+ "Empty directories are not supported by portable publication"
90
+ )
91
+ return "directory", entries, hashlib.sha256(canonical(entries)).hexdigest()
@@ -0,0 +1,239 @@
1
+ """Immutable conditional storage primitives, with lazy optional S3 imports."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import tempfile
7
+ from functools import cached_property
8
+ from pathlib import Path
9
+ from typing import Any, BinaryIO
10
+ from urllib.parse import unquote, urlsplit
11
+
12
+ from dockflow_core.filesystem import fsync_ancestors, hash_stream
13
+
14
+ from ._paths import file_info, no_links, relative
15
+ from .errors import ArtifactError, IntegrityError
16
+
17
+
18
+ class FileStore:
19
+ def __init__(self, root: Path):
20
+ self.root = root.absolute()
21
+ no_links(self.root)
22
+ self.uri = self.root.as_uri()
23
+
24
+ def path(self, key: str) -> Path:
25
+ path = self.root / relative(key, internal=True)
26
+ no_links(path)
27
+ return path
28
+
29
+ def get(self, key: str) -> bytes | None:
30
+ path = self.path(key)
31
+ if not path.exists():
32
+ return None
33
+ if not path.is_file() or path.stat().st_size > 16 * 1024 * 1024:
34
+ raise IntegrityError("Invalid or oversized publication record")
35
+ return path.read_bytes()
36
+
37
+ def has_prefix(self, prefix: str) -> bool:
38
+ return self.path(prefix).exists()
39
+
40
+ def sync(self, key: str) -> None:
41
+ path = self.path(key)
42
+ descriptor = os.open(path, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0))
43
+ try:
44
+ os.fsync(descriptor)
45
+ finally:
46
+ os.close(descriptor)
47
+ fsync_ancestors(path.parent)
48
+
49
+ def put(self, key: str, body: BinaryIO) -> bool:
50
+ path = self.path(key)
51
+ path.parent.mkdir(parents=True, exist_ok=True)
52
+ no_links(path)
53
+ staging = self.path(".dockflow/staging")
54
+ staging.mkdir(parents=True, exist_ok=True)
55
+ no_links(staging)
56
+ descriptor, temporary = tempfile.mkstemp(prefix=".publication-", dir=staging)
57
+ try:
58
+ with os.fdopen(descriptor, "wb") as output:
59
+ while chunk := body.read(1024 * 1024):
60
+ output.write(chunk)
61
+ output.flush()
62
+ os.fsync(output.fileno())
63
+ try:
64
+ os.link(temporary, path)
65
+ except FileExistsError:
66
+ # An uncertain previous write or an adopted identical payload
67
+ # still needs a durable file and directory boundary.
68
+ self.sync(key)
69
+ return False
70
+ fsync_ancestors(path.parent)
71
+ return True
72
+ finally:
73
+ Path(temporary).unlink(missing_ok=True)
74
+
75
+ def info(self, key: str) -> tuple[int, str]:
76
+ try:
77
+ return file_info(self.path(key))
78
+ except FileNotFoundError as exc:
79
+ raise IntegrityError("Published payload is missing") from exc
80
+
81
+ def files(self, prefix: str) -> list[str]:
82
+ root = self.path(prefix)
83
+ if not root.is_dir():
84
+ raise IntegrityError("Published directory is missing")
85
+ result = []
86
+ for path in root.rglob("*"):
87
+ no_links(path)
88
+ if path.is_dir():
89
+ continue
90
+ if not path.is_file():
91
+ raise IntegrityError("Published tree contains a nonregular entry")
92
+ result.append(path.relative_to(self.root).as_posix())
93
+ return sorted(result)
94
+
95
+
96
+ class S3Store:
97
+ def __init__(self, root: str, options: dict[str, Any]):
98
+ parsed = urlsplit(root)
99
+ self.bucket = parsed.netloc
100
+ self.prefix = parsed.path.strip("/")
101
+ if self.prefix:
102
+ relative(self.prefix)
103
+ self.uri = root.rstrip("/")
104
+ self.options = dict(options)
105
+
106
+ @cached_property
107
+ def client(self) -> Any:
108
+ # Client creation can consult network credential providers. Defer it
109
+ # until an explicit publication/verification operation reaches storage.
110
+ try:
111
+ import boto3
112
+ import certifi
113
+ from botocore.config import Config
114
+ from botocore.session import get_session
115
+ except ImportError as exc:
116
+ raise ArtifactError(
117
+ "Install dockflow-artifacts[s3] to use object storage"
118
+ ) from exc
119
+ options = dict(self.options)
120
+ verify = options.get("verify")
121
+ if verify is None or verify is True:
122
+ # Use the maintained bundle; botocore vendors a separate older copy.
123
+ options["verify"] = (
124
+ get_session().get_config_variable("ca_bundle")
125
+ if verify is None
126
+ else None
127
+ ) or certifi.where()
128
+ return boto3.client(
129
+ "s3", config=Config(s3={"addressing_style": "path"}), **options
130
+ )
131
+
132
+ def key(self, key: str) -> str:
133
+ return "/".join(filter(None, (self.prefix, relative(key, internal=True))))
134
+
135
+ @staticmethod
136
+ def code(error: Exception) -> str:
137
+ return str(
138
+ getattr(error, "response", {}).get("Error", {}).get("Code", "unknown")
139
+ )
140
+
141
+ def get(self, key: str) -> bytes | None:
142
+ try:
143
+ response = self.client.get_object(Bucket=self.bucket, Key=self.key(key))
144
+ with response["Body"] as stream:
145
+ result = stream.read(16 * 1024 * 1024 + 1)
146
+ if len(result) > 16 * 1024 * 1024:
147
+ raise IntegrityError("Oversized publication record")
148
+ return result
149
+ except ArtifactError:
150
+ raise
151
+ except Exception as exc:
152
+ if self.code(exc) in {"NoSuchKey", "404"}:
153
+ return None
154
+ raise ArtifactError(
155
+ "Cannot read publication record from object storage"
156
+ ) from None
157
+
158
+ def put(self, key: str, body: BinaryIO) -> bool:
159
+ body.seek(0, os.SEEK_END)
160
+ size = body.tell()
161
+ body.seek(0)
162
+ if size > 5 * 1024**3:
163
+ raise ArtifactError("Conditional S3 publication supports files up to 5 GiB")
164
+ try:
165
+ self.client.put_object(
166
+ Bucket=self.bucket,
167
+ Key=self.key(key),
168
+ Body=body,
169
+ ContentLength=size,
170
+ IfNoneMatch="*",
171
+ )
172
+ return True
173
+ except ArtifactError:
174
+ raise
175
+ except Exception as exc:
176
+ if self.code(exc) in {"PreconditionFailed", "412"}:
177
+ return False
178
+ raise ArtifactError(
179
+ "Conditional object creation failed; verify storage access and If-None-Match support"
180
+ ) from None
181
+
182
+ def info(self, key: str) -> tuple[int, str]:
183
+ try:
184
+ response = self.client.get_object(Bucket=self.bucket, Key=self.key(key))
185
+ with response["Body"] as stream:
186
+ return hash_stream(stream)
187
+ except ArtifactError:
188
+ raise
189
+ except Exception:
190
+ raise IntegrityError("Cannot verify published object contents") from None
191
+
192
+ def files(self, prefix: str) -> list[str]:
193
+ try:
194
+ pages = self.client.get_paginator("list_objects_v2").paginate(
195
+ Bucket=self.bucket, Prefix=self.key(prefix) + "/"
196
+ )
197
+ offset = len(self.prefix) + 1 if self.prefix else 0
198
+ return sorted(
199
+ item["Key"][offset:]
200
+ for page in pages
201
+ for item in page.get("Contents", [])
202
+ )
203
+ except ArtifactError:
204
+ raise
205
+ except Exception:
206
+ raise ArtifactError("Cannot inventory published objects") from None
207
+
208
+ def has_prefix(self, prefix: str) -> bool:
209
+ return bool(self.files(prefix))
210
+
211
+
212
+ def make_store(root: str | Path, options: dict[str, Any]) -> FileStore | S3Store:
213
+ text = str(root)
214
+ parsed = urlsplit(text)
215
+ if parsed.username or parsed.password or parsed.query or parsed.fragment:
216
+ raise ArtifactError(
217
+ "Artifact roots cannot contain credentials, queries or fragments"
218
+ )
219
+ if parsed.scheme in {"s3", "minio"}:
220
+ if not parsed.netloc or ":" in parsed.netloc or "%" in text:
221
+ raise ArtifactError(
222
+ "Object storage requires a bucket and an optional safe prefix"
223
+ )
224
+ return S3Store(text, options)
225
+ if options:
226
+ raise ArtifactError("S3 configuration cannot be used with a filesystem root")
227
+ if parsed.scheme == "file":
228
+ if parsed.netloc not in {"", "localhost"}:
229
+ raise ArtifactError("File URIs must refer to the local filesystem")
230
+ path = Path(unquote(parsed.path))
231
+ elif not parsed.scheme:
232
+ path = Path(text)
233
+ else:
234
+ raise ArtifactError(
235
+ "Supported roots are absolute paths, file://, s3:// and minio://"
236
+ )
237
+ if not path.is_absolute():
238
+ raise ArtifactError("Filesystem publication roots must be absolute")
239
+ return FileStore(path)
@@ -0,0 +1,13 @@
1
+ """Public exceptions; storage clients must not expose configuration secrets."""
2
+
3
+
4
+ class ArtifactError(ValueError):
5
+ """Invalid input, unsupported storage or a failed storage operation."""
6
+
7
+
8
+ class ConflictError(ArtifactError):
9
+ """The destination is reserved for another immutable publication."""
10
+
11
+
12
+ class IntegrityError(ArtifactError):
13
+ """Source or published contents do not match their recorded inventory."""
@@ -0,0 +1,61 @@
1
+ """Deterministic publication identity, independent of orchestrator cache records."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from dockflow_core.canonical import canonical_tree
10
+
11
+ from ._paths import canonical, inventory
12
+ from .errors import ArtifactError
13
+
14
+ PUBLICATION_ID_FORMAT = "dockflow-publication-id-v1"
15
+
16
+
17
+ def _typed(value: Any) -> list[Any]:
18
+ """Retain this publication format's typed object keys and strict input types."""
19
+ try:
20
+ return canonical_tree(value, typed_keys=True, strict_types=True)
21
+ except ValueError as exc:
22
+ raise ArtifactError(str(exc)) from None
23
+
24
+
25
+ def publication_id(
26
+ *,
27
+ component_name: str,
28
+ component_version: str,
29
+ parameters: dict[str, Any],
30
+ inputs: dict[str, Any] | None = None,
31
+ ) -> str:
32
+ """Hash a component version and its complete declared invocation inputs.
33
+
34
+ Parameters must include resolved defaults and any execution context. Inputs
35
+ identify named artifacts by immutable fingerprint/version and any metadata
36
+ observed by the component. Nothing is inferred from paths or the environment.
37
+ This identifier does not look up, enable or override an execution cache.
38
+ """
39
+ if any(
40
+ not isinstance(value, str) or not value
41
+ for value in (component_name, component_version)
42
+ ):
43
+ raise ArtifactError("Component name and version must be nonempty strings")
44
+ if type(parameters) is not dict or (
45
+ inputs is not None and type(inputs) is not dict
46
+ ):
47
+ raise ArtifactError("Parameters and input identities must be JSON objects")
48
+ envelope = {
49
+ "format": PUBLICATION_ID_FORMAT,
50
+ "component_name": component_name,
51
+ "component_version": component_version,
52
+ "parameters": parameters,
53
+ "inputs": {} if inputs is None else inputs,
54
+ }
55
+ return hashlib.sha256(canonical(_typed(envelope))).hexdigest()
56
+
57
+
58
+ def fingerprint(source: str | Path) -> dict[str, str]:
59
+ """Fingerprint a local file/tree explicitly, ignoring its temporary location."""
60
+ kind, _, digest = inventory(Path(source).absolute())
61
+ return {"algorithm": "sha256", "kind": kind, "value": digest}
@@ -0,0 +1,48 @@
1
+ """Optional authoring helper; emits only the standard KFP importer contract."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from typing import Any
7
+
8
+
9
+ def importer_component(
10
+ artifact_class: Any,
11
+ *,
12
+ metadata: dict[str, Any] | None = None,
13
+ reimport: bool = False,
14
+ name: str | None = None,
15
+ ) -> Any:
16
+ """Register a published URI as a typed artifact, retaining normal KFP lineage.
17
+
18
+ Call this factory outside the parent pipeline definition. Its result is a
19
+ callable graph component accepting ``uri`` as a string or parameter channel.
20
+ Metadata must be static JSON. Pass the resulting task.output to a standard
21
+ downstream artifact input. The sub-pipeline exposes the URI
22
+ as a parent input, which the native KFP 2.17 importer can resolve.
23
+ The caller owns credentials, payload publication and retention.
24
+ """
25
+ try:
26
+ from kfp import dsl
27
+ except ImportError as exc:
28
+ raise ImportError(
29
+ "Install dockflow-artifacts[kfp] to author KFP pipelines"
30
+ ) from exc
31
+ try:
32
+ frozen_metadata = json.loads(json.dumps(metadata, allow_nan=False))
33
+ except (TypeError, ValueError) as exc:
34
+ raise ValueError("Importer metadata must be static JSON values") from exc
35
+ if frozen_metadata is not None and not isinstance(frozen_metadata, dict):
36
+ raise ValueError("Importer metadata must be a JSON object")
37
+
38
+ def register(uri):
39
+ return dsl.importer(
40
+ artifact_uri=uri,
41
+ artifact_class=artifact_class,
42
+ metadata=frozen_metadata,
43
+ reimport=reimport,
44
+ ).output
45
+
46
+ # KFP inspects concrete annotations when creating the graph component.
47
+ register.__annotations__ = {"uri": str, "return": artifact_class}
48
+ return dsl.pipeline(name=name or "import-published-artifact")(register)
@@ -0,0 +1,147 @@
1
+ """Immutable publication records in the unified Dockflow database.
2
+
3
+ Upload bytes outside SQL transactions. Reserve before uploading, and record
4
+ completion only after verification. Identical retries resolve uncertain commits.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import contextlib
10
+
11
+ from dockflow_core import Database, LockScope, Schema
12
+ from dockflow_core.errors import DockflowError
13
+
14
+ ARTIFACT_TABLE = "dockflow_artifact_records"
15
+ ARTIFACT_SCHEMA = Schema(
16
+ name="artifacts",
17
+ version=1,
18
+ statements=(
19
+ "CREATE TABLE dockflow_artifact_records (project_id TEXT NOT NULL, storage_id TEXT NOT NULL, namespace TEXT NOT NULL, record_key TEXT NOT NULL, value TEXT NOT NULL, PRIMARY KEY(project_id,storage_id,namespace,record_key))",
20
+ ),
21
+ tables=frozenset({ARTIFACT_TABLE}),
22
+ )
23
+
24
+
25
+ def publication_scope(adapter: Database, project_id: str) -> LockScope:
26
+ """Shared with callers that coordinate publication and offline maintenance."""
27
+ return LockScope(adapter, "dockflow-project", project_id)
28
+
29
+
30
+ def initialize_schema(adapter: Database, connection) -> None:
31
+ adapter.initialize_schema(connection, ARTIFACT_SCHEMA)
32
+
33
+
34
+ def has_project_records(adapter: Database, connection, project_id: str) -> bool:
35
+ return (
36
+ adapter.execute(
37
+ connection,
38
+ "SELECT 1 FROM dockflow_artifact_records WHERE project_id=? LIMIT 1",
39
+ (project_id,),
40
+ ).fetchone()
41
+ is not None
42
+ )
43
+
44
+
45
+ def project_storage_ids(adapter: Database, connection, project_id: str) -> set[str]:
46
+ return {
47
+ row[0]
48
+ for row in adapter.execute(
49
+ connection,
50
+ "SELECT DISTINCT storage_id FROM dockflow_artifact_records WHERE project_id=?",
51
+ (project_id,),
52
+ ).fetchall()
53
+ }
54
+
55
+
56
+ def project_destinations(adapter: Database, connection, project_id: str) -> list[str]:
57
+ return [
58
+ row[0]
59
+ for row in adapter.execute(
60
+ connection,
61
+ "SELECT record_key FROM dockflow_artifact_records WHERE project_id=? AND namespace='publication-manifests'",
62
+ (project_id,),
63
+ ).fetchall()
64
+ ]
65
+
66
+
67
+ def project_rows(adapter: Database, connection, project_id: str):
68
+ """Export publication records through a caller's consistent snapshot."""
69
+ cursor = adapter.execute(
70
+ connection,
71
+ "SELECT * FROM dockflow_artifact_records WHERE project_id=?",
72
+ (project_id,),
73
+ )
74
+ yield ARTIFACT_TABLE, [column[0] for column in cursor.description], cursor
75
+
76
+
77
+ class ArtifactRecords:
78
+ """Storage-relative records; a supplied writer preserves execution fencing."""
79
+
80
+ def __init__(
81
+ self, adapter: Database, project_id: str, storage_id: str, *, writer=None
82
+ ):
83
+ self.database = adapter
84
+ self.scope = publication_scope(adapter, project_id)
85
+ self.project_id = project_id
86
+ self.storage_id = storage_id
87
+ self.writer = writer
88
+
89
+ @contextlib.contextmanager
90
+ def transaction(self):
91
+ if self.writer is not None:
92
+ with self.writer() as connection:
93
+ yield connection
94
+ return
95
+ connection = None
96
+ try:
97
+ self.database.prepare()
98
+ connection = self.database.connect()
99
+ self.database.begin(connection)
100
+ self.scope.gate(connection)
101
+ self.scope.serialize(connection)
102
+ initialize_schema(self.database, connection)
103
+ yield connection
104
+ connection.execute("COMMIT")
105
+ except DockflowError:
106
+ raise
107
+ except Exception:
108
+ raise DockflowError(
109
+ "Artifact database operation failed", "UNAVAILABLE", 503
110
+ ) from None
111
+ finally:
112
+ if connection is not None:
113
+ connection.close()
114
+
115
+ def get(self, namespace: str, key: str) -> bytes | None:
116
+ with self.database.snapshot() as connection:
117
+ self.database.validate_schema(connection, ARTIFACT_SCHEMA)
118
+ row = self.database.execute(
119
+ connection,
120
+ "SELECT value FROM dockflow_artifact_records WHERE project_id=? AND storage_id=? AND namespace=? AND record_key=?",
121
+ (self.project_id, self.storage_id, namespace, key),
122
+ ).fetchone()
123
+ return row[0].encode() if row else None
124
+
125
+ def put_many(self, namespace: str, records: dict[str, bytes]) -> None:
126
+ with self.transaction() as connection:
127
+ for key, value in records.items():
128
+ parameters = (self.project_id, self.storage_id, namespace, key)
129
+ self.database.execute(
130
+ connection,
131
+ "INSERT INTO dockflow_artifact_records VALUES (?,?,?,?,?) ON CONFLICT DO NOTHING",
132
+ (*parameters, value.decode()),
133
+ )
134
+ observed = self.database.execute(
135
+ connection,
136
+ "SELECT value FROM dockflow_artifact_records WHERE project_id=? AND storage_id=? AND namespace=? AND record_key=?",
137
+ parameters,
138
+ ).fetchone()
139
+ if observed[0] != value.decode():
140
+ raise DockflowError(
141
+ "Artifact location is reserved for another publication",
142
+ "CONFLICT",
143
+ 409,
144
+ )
145
+
146
+ def put(self, namespace: str, key: str, value: bytes) -> None:
147
+ self.put_many(namespace, {key: value})
@@ -0,0 +1,407 @@
1
+ """Public publication API and versioned completion records."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import functools
6
+ import hashlib
7
+ import io
8
+ import json
9
+ import re
10
+ import shutil
11
+ import string
12
+ import tempfile
13
+ import uuid
14
+ from dataclasses import asdict, dataclass
15
+ from pathlib import Path
16
+ from typing import Any
17
+ from urllib.parse import urlsplit
18
+
19
+ from dockflow_core import Database, DockflowError
20
+ from dockflow_core.database import configured_database
21
+
22
+ from ._paths import canonical, file_info, inventory, no_links, relative
23
+ from ._storage import FileStore, make_store
24
+ from .errors import ArtifactError, ConflictError, IntegrityError
25
+ from .persistence import ArtifactRecords
26
+
27
+
28
+ @dataclass(frozen=True)
29
+ class Publication:
30
+ """Completed artifact address and integrity/provenance metadata."""
31
+
32
+ uri: str
33
+ manifest_uri: str
34
+ digest: str
35
+ kind: str
36
+ artifact_type: str
37
+ metadata: dict[str, Any]
38
+
39
+ def to_dict(self) -> dict[str, Any]:
40
+ """Return a detached JSON-compatible receipt, without credentials."""
41
+ return asdict(self)
42
+
43
+
44
+ def format_path(template: str, **values: str | int) -> str:
45
+ """Expand simple named fields; each value must be a single safe path segment."""
46
+ for _, field, spec, conversion in string.Formatter().parse(template):
47
+ if field is None:
48
+ continue
49
+ if not field.isidentifier() or spec or conversion or field not in values:
50
+ raise ArtifactError("Path templates require provided, simple named fields")
51
+ value = str(values[field])
52
+ if "/" in relative(value):
53
+ raise ArtifactError("Template values must each be a single path segment")
54
+ return relative(template.format(**values))
55
+
56
+
57
+ def step_path(
58
+ filename: str,
59
+ *,
60
+ step_number: int,
61
+ component_name: str,
62
+ pipeline_name: str,
63
+ publication_id: str,
64
+ step_digits: int,
65
+ exploration_id: str = "global",
66
+ ) -> str:
67
+ """Name an output by context, padded step and deterministic publication ID.
68
+
69
+ The pipeline supplies execution order; the component wrapper supplies its
70
+ name and filename. This helper does not inspect a graph or allocate counters.
71
+ """
72
+ if type(step_number) is not int or step_number < 1:
73
+ raise ArtifactError("Step number must be a positive integer")
74
+ if type(step_digits) is not int or not 1 <= step_digits <= 12:
75
+ raise ArtifactError("Step digits must be an integer from 1 to 12")
76
+ if not isinstance(publication_id, str) or not re.fullmatch(
77
+ r"[a-f0-9]{64}", publication_id
78
+ ):
79
+ raise ArtifactError("Publication ID must be a full lowercase SHA-256 digest")
80
+ for value in (filename, component_name, exploration_id, pipeline_name):
81
+ if not isinstance(value, str) or "/" in relative(value):
82
+ raise ArtifactError("Step path values must each be a single path segment")
83
+ return format_path(
84
+ "{exploration}/{pipeline}/step_{number}__{component}__{identity}/{filename}",
85
+ exploration=exploration_id,
86
+ pipeline=pipeline_name,
87
+ number=str(step_number).zfill(step_digits),
88
+ component=component_name,
89
+ identity=publication_id,
90
+ filename=filename,
91
+ )
92
+
93
+
94
+ def _publication_errors(method):
95
+ @functools.wraps(method)
96
+ def call(*args, **kwargs):
97
+ try:
98
+ return method(*args, **kwargs)
99
+ except DockflowError as error:
100
+ exception = ConflictError if error.code == "CONFLICT" else ArtifactError
101
+ raise exception(error.message) from None
102
+ except OSError:
103
+ raise ArtifactError(
104
+ "Artifact publication I/O failed; check storage access and durability"
105
+ ) from None
106
+
107
+ return call
108
+
109
+
110
+ class Publisher:
111
+ """Publish immutable local contents below an explicitly configured storage root."""
112
+
113
+ @_publication_errors
114
+ def __init__(
115
+ self,
116
+ root: str | Path,
117
+ *,
118
+ s3: dict[str, Any] | None = None,
119
+ database: dict | Database | None = None,
120
+ project_id: str = "default",
121
+ ):
122
+ if not isinstance(project_id, str) or not re.fullmatch(
123
+ r"[A-Za-z0-9][A-Za-z0-9_.-]{0,127}", project_id
124
+ ):
125
+ raise ArtifactError(
126
+ "Project ID must contain safe letters, digits, _, - or ."
127
+ )
128
+ options = dict(s3 or {})
129
+ allowed = {
130
+ "endpoint_url",
131
+ "region_name",
132
+ "aws_access_key_id",
133
+ "aws_secret_access_key",
134
+ "aws_session_token",
135
+ "verify",
136
+ }
137
+ if set(options) - allowed:
138
+ raise ArtifactError("Unknown S3 configuration field")
139
+ for key, value in options.items():
140
+ if key == "verify":
141
+ if not isinstance(value, bool | str):
142
+ raise ArtifactError(
143
+ "S3 verify must be a boolean or certificate path"
144
+ )
145
+ elif not isinstance(value, str) or not value:
146
+ raise ArtifactError("S3 configuration fields must be nonempty strings")
147
+ if "endpoint_url" in options:
148
+ endpoint = urlsplit(options["endpoint_url"])
149
+ if (
150
+ endpoint.scheme not in {"http", "https"}
151
+ or not endpoint.hostname
152
+ or endpoint.username
153
+ or endpoint.password
154
+ or endpoint.query
155
+ or endpoint.fragment
156
+ ):
157
+ raise ArtifactError(
158
+ "S3 endpoint must be HTTP(S) without credentials or query"
159
+ )
160
+ self._store = make_store(root, options)
161
+ if database is None:
162
+ if not isinstance(self._store, FileStore):
163
+ raise ArtifactError(
164
+ "Remote artifact storage requires explicit database configuration"
165
+ )
166
+ database = {
167
+ "backend": "sqlite",
168
+ "database": str(self._store.root.parent / ".dockflow/state.sqlite"),
169
+ }
170
+ self.database = configured_database(database)
171
+ if (
172
+ isinstance(self._store, FileStore)
173
+ and self.database.backend == "sqlite"
174
+ and Path(self.database.options["database"]).is_relative_to(self._store.root)
175
+ ):
176
+ raise ArtifactError("SQLite database must be outside the artifact root")
177
+ self.project_id = project_id
178
+
179
+ @classmethod
180
+ def from_config(cls, path: str | Path) -> Publisher:
181
+ """Read schema-version 2 JSON configuration explicitly at invocation time."""
182
+ raw = Path(path).read_bytes()
183
+ if len(raw) > 1024 * 1024:
184
+ raise ArtifactError("Publication configuration is too large")
185
+ try:
186
+ config = json.loads(raw)
187
+ except (ValueError, UnicodeError):
188
+ raise ArtifactError(
189
+ "Publication configuration must be valid JSON"
190
+ ) from None
191
+ if (
192
+ not isinstance(config, dict)
193
+ or set(config) - {"schema_version", "root", "s3", "database", "project_id"}
194
+ or type(config.get("schema_version")) is not int
195
+ or config["schema_version"] != 2
196
+ or not isinstance(config.get("root"), str)
197
+ or ("s3" in config and not isinstance(config["s3"], dict))
198
+ ):
199
+ raise ArtifactError(
200
+ "Expected schema_version=2, root, database and optional s3/project_id configuration"
201
+ )
202
+ options = config.get("database")
203
+ if (
204
+ isinstance(options, dict)
205
+ and options.get("backend") == "sqlite"
206
+ and isinstance(options.get("database"), str)
207
+ ):
208
+ options = {
209
+ **options,
210
+ "database": str(
211
+ (Path(path).absolute().parent / options["database"]).absolute()
212
+ ),
213
+ }
214
+ return cls(
215
+ config["root"],
216
+ s3=config.get("s3"),
217
+ database=options,
218
+ project_id=config.get("project_id", "default"),
219
+ )
220
+
221
+ def _records(self, *, create: bool) -> ArtifactRecords:
222
+ marker = ".dockflow/storage.json"
223
+ raw = self._store.get(marker)
224
+ if raw is None and create:
225
+ self._store.put(
226
+ marker,
227
+ io.BytesIO(
228
+ canonical(
229
+ {
230
+ "format": "dockflow-artifact-store-v1",
231
+ "identity": str(uuid.uuid4()),
232
+ }
233
+ )
234
+ ),
235
+ )
236
+ raw = self._store.get(marker)
237
+ try:
238
+ value = json.loads(raw or b"null")
239
+ if value["format"] != "dockflow-artifact-store-v1":
240
+ raise ValueError
241
+ identity = str(uuid.UUID(value["identity"]))
242
+ except (ValueError, TypeError, KeyError, AttributeError):
243
+ raise IntegrityError(
244
+ "Artifact storage identity is missing or invalid"
245
+ ) from None
246
+ if create and isinstance(self._store, FileStore):
247
+ self._store.sync(marker)
248
+ return ArtifactRecords(self.database, self.project_id, identity)
249
+
250
+ def _receipt(self, manifest: dict[str, Any]) -> Publication:
251
+ base = self._store.uri
252
+ return Publication(
253
+ uri=base + "/" + manifest["destination"],
254
+ manifest_uri=f"dockflow://{self.project_id}/publications/{self._records(create=False).storage_id}/{hashlib.sha256(manifest['destination'].encode()).hexdigest()}",
255
+ digest=manifest["digest"],
256
+ kind=manifest["kind"],
257
+ artifact_type=manifest["artifact_type"],
258
+ metadata=json.loads(canonical(manifest["metadata"])),
259
+ )
260
+
261
+ def _check_layout(self) -> None:
262
+ if self._store.has_prefix(".dockflow-artifacts") or self._store.has_prefix(
263
+ ".dockflow/dockflow-artifacts"
264
+ ):
265
+ raise ArtifactError(
266
+ "Legacy .dockflow-artifacts records found; use a fresh publication "
267
+ "root or reset disposable artifacts before using the new layout"
268
+ )
269
+
270
+ def _verify_contents(self, manifest: dict[str, Any]) -> None:
271
+ destination = relative(manifest["destination"])
272
+ expected = []
273
+ for item in manifest["entries"]:
274
+ suffix = relative(item["path"]) if item["path"] else ""
275
+ key = destination + ("/" + suffix if suffix else "")
276
+ expected.append(key)
277
+ if self._store.info(key) != (item["size"], item["sha256"]):
278
+ raise IntegrityError(
279
+ "Published contents do not match their recorded SHA-256"
280
+ )
281
+ if manifest["kind"] == "directory" and self._store.files(destination) != sorted(
282
+ expected
283
+ ):
284
+ raise IntegrityError("Published directory inventory changed")
285
+
286
+ @_publication_errors
287
+ def verify(self, destination: str) -> Publication:
288
+ """Verify all payload bytes of a completed publication and return its receipt."""
289
+ destination = relative(destination)
290
+ self._check_layout()
291
+ raw = self._records(create=False).get("publication-manifests", destination)
292
+ if raw is None:
293
+ raise IntegrityError("Publication has no completion manifest")
294
+ try:
295
+ manifest = json.loads(raw)
296
+ if (
297
+ manifest["schema_version"] != 1
298
+ or manifest["destination"] != destination
299
+ or manifest["kind"] not in {"file", "directory"}
300
+ or not manifest["entries"]
301
+ or not re.fullmatch(r"[a-f0-9]{64}", manifest["digest"])
302
+ ):
303
+ raise ValueError
304
+ for entry in manifest["entries"]:
305
+ if type(entry["size"]) is not int or entry["size"] < 0:
306
+ raise ValueError
307
+ if not re.fullmatch(r"[a-f0-9]{64}", entry["sha256"]):
308
+ raise ValueError
309
+ if manifest["kind"] == "file":
310
+ if (
311
+ len(manifest["entries"]) != 1
312
+ or manifest["entries"][0]["path"] != ""
313
+ ):
314
+ raise ValueError
315
+ digest = manifest["entries"][0]["sha256"]
316
+ else:
317
+ if any(not entry["path"] for entry in manifest["entries"]):
318
+ raise ValueError
319
+ digest = hashlib.sha256(canonical(manifest["entries"])).hexdigest()
320
+ if digest != manifest["digest"]:
321
+ raise ValueError
322
+ self._verify_contents(manifest)
323
+ return self._receipt(manifest)
324
+ except (KeyError, TypeError, ValueError) as exc:
325
+ if isinstance(exc, ArtifactError):
326
+ raise
327
+ raise IntegrityError("Invalid publication manifest") from None
328
+
329
+ @_publication_errors
330
+ def publish(
331
+ self,
332
+ source: str | Path,
333
+ destination: str,
334
+ *,
335
+ artifact_type: str = "system.Artifact",
336
+ metadata: dict[str, Any] | None = None,
337
+ ) -> Publication:
338
+ """Publish once, resume an identical incomplete write, or verify an identical result."""
339
+ destination = relative(destination)
340
+ self._check_layout()
341
+ source = Path(source).absolute()
342
+ no_links(source)
343
+ if isinstance(self._store, FileStore):
344
+ target = self._store.path(destination)
345
+ if source == target or source in target.parents or target in source.parents:
346
+ raise ArtifactError("Source and destination cannot overlap")
347
+ if not isinstance(artifact_type, str) or not artifact_type:
348
+ raise ArtifactError("Artifact type must be a nonempty schema title")
349
+ if metadata is not None and not isinstance(metadata, dict):
350
+ raise ArtifactError("Artifact metadata must be a JSON object")
351
+ kind, entries, digest = inventory(source)
352
+ manifest = {
353
+ "schema_version": 1,
354
+ "destination": destination,
355
+ "kind": kind,
356
+ "entries": entries,
357
+ "digest": digest,
358
+ "artifact_type": artifact_type,
359
+ "metadata": json.loads(canonical(metadata or {})),
360
+ }
361
+ encoded = canonical(manifest)
362
+ if len(encoded) > 16 * 1024 * 1024:
363
+ raise ArtifactError("Publication inventory exceeds the 16 MiB record limit")
364
+ # The whole namespace reservation is atomic; no SQL transaction spans uploads.
365
+ records = self._records(create=True)
366
+ parts = destination.split("/")
367
+ claims = {
368
+ "/".join(parts[:index]): canonical(
369
+ {
370
+ "schema_version": 1,
371
+ "kind": "namespace",
372
+ "path": "/".join(parts[:index]),
373
+ }
374
+ )
375
+ for index in range(1, len(parts))
376
+ }
377
+ claims[destination] = encoded
378
+ records.put_many("publication-claims", claims)
379
+ completed = records.get("publication-manifests", destination)
380
+ if completed is not None:
381
+ if completed != encoded:
382
+ raise IntegrityError(
383
+ "Completion manifest differs from the reserved publication"
384
+ )
385
+ return self.verify(destination)
386
+ for entry in entries:
387
+ path = source / entry["path"] if kind == "directory" else source
388
+ key = destination + ("/" + entry["path"] if entry["path"] else "")
389
+ no_links(path)
390
+ # Snapshot one file at a time; conditional uploads never stream from
391
+ # a mutable producer file or buffer a whole model in memory.
392
+ with tempfile.TemporaryDirectory(prefix="dockflow-artifacts-") as temporary:
393
+ staged = Path(temporary) / "content"
394
+ shutil.copyfile(path, staged, follow_symlinks=False)
395
+ if file_info(staged) != (entry["size"], entry["sha256"]):
396
+ raise IntegrityError("Source changed during publication")
397
+ with staged.open("rb") as stream:
398
+ self._store.put(key, stream)
399
+ if self._store.info(key) != (entry["size"], entry["sha256"]):
400
+ raise IntegrityError(
401
+ "Destination has different bytes; nothing was overwritten"
402
+ )
403
+ if inventory(source) != (kind, entries, digest):
404
+ raise IntegrityError("Source changed during publication")
405
+ self._verify_contents(manifest)
406
+ records.put("publication-manifests", destination, encoded)
407
+ return self._receipt(manifest)
@@ -0,0 +1 @@
1
+
@@ -0,0 +1,91 @@
1
+ Metadata-Version: 2.4
2
+ Name: dockflow-artifacts
3
+ Version: 0.2.0
4
+ Summary: Reusable immutable artifact publication and orchestration integration
5
+ Author: Dockflow contributors
6
+ License-Expression: MIT
7
+ License-File: LICENSE
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: Typing :: Typed
10
+ Requires-Dist: dockflow-core==0.2.0
11
+ Requires-Dist: kfp>=2.17.0,<2.18 ; extra == 'kfp'
12
+ Requires-Dist: certifi>=2026.10.19,<2027 ; extra == 'kfp'
13
+ Requires-Dist: dockflow-core[postgresql]==0.2.0 ; extra == 'postgresql'
14
+ Requires-Dist: boto3>=1.40,<2 ; extra == 's3'
15
+ Requires-Dist: certifi>=2026.10.19,<2027 ; extra == 's3'
16
+ Requires-Python: >=3.12
17
+ Project-URL: Homepage, https://github.com/dockflow-project/dockflow
18
+ Project-URL: Documentation, https://github.com/dockflow-project/dockflow/blob/main/docs/artifact-library/README.md
19
+ Project-URL: Issues, https://github.com/dockflow-project/dockflow/issues
20
+ Project-URL: Changelog, https://github.com/dockflow-project/dockflow/blob/main/CHANGELOG.md
21
+ Provides-Extra: kfp
22
+ Provides-Extra: postgresql
23
+ Provides-Extra: s3
24
+ Description-Content-Type: text/markdown
25
+
26
+ # Dockflow Artifacts
27
+
28
+ A Python library for immutable artifact publication at caller-chosen paths,
29
+ content verification, and optional orchestration integration. Its first adapters
30
+ support filesystems, S3-compatible stores and KFP typed importers.
31
+
32
+ Python 3.12–3.13. MIT licensed. Developed with AI assistance.
33
+
34
+ Install with Python 3.12 or newer:
35
+
36
+ ```bash
37
+ pip install dockflow-artifacts==0.2.0
38
+ # Optional S3 integration:
39
+ pip install 'dockflow-artifacts[s3]==0.2.0'
40
+ ```
41
+
42
+ For the optional `kfp` extra, follow the installation guide's explicit urllib3
43
+ override. This library can be installed without the Dockflow CLI or service.
44
+
45
+ ```python
46
+ from pathlib import Path
47
+ from dockflow_artifacts import Publisher, format_path
48
+
49
+ store = Publisher(Path("/absolute/path/to/artifacts"))
50
+ result = store.publish(
51
+ "model.json",
52
+ format_path("models/{experiment}/{version}/model.json", experiment="demo", version="v1"),
53
+ artifact_type="system.Model",
54
+ metadata={"algorithm": "example"},
55
+ )
56
+ print(result.uri)
57
+ store.verify("models/demo/v1/model.json")
58
+ ```
59
+
60
+ Publication bookkeeping uses SQLite/PostgreSQL through the shared
61
+ `dockflow-core` package. Filesystem roots default to `.dockflow/state.sqlite`
62
+ under their parent; remote roots require explicit database configuration.
63
+ Pass Pipelines’ database and project ID to share its state. Install the
64
+ `postgresql` extra for an external PostgreSQL database.
65
+
66
+ The file lives at exactly the requested path. Different bytes or metadata cannot
67
+ replace an existing publication. Identical retries verify/reuse it. Importing
68
+ the library performs no configuration, network calls or filesystem writes;
69
+ publishing and verification perform explicit I/O.
70
+
71
+ Component scripts can remain independent. A wrapper runs a script and publishes
72
+ its files; the pipeline uses `dockflow_artifacts.kfp.importer_component` to register
73
+ the returned URI as a normal `Model` or `Dataset`. No Dockflow server is required.
74
+
75
+ [Documentation](https://github.com/dockflow-project/dockflow/blob/main/docs/artifact-library/README.md) covers configuration, publication semantics,
76
+ KFP integration and the public API. [Examples](https://github.com/dockflow-project/dockflow/blob/main/packages/artifacts/examples/) use synthetic data.
77
+ [Contributing](https://github.com/dockflow-project/dockflow/blob/main/CONTRIBUTING.md) describes development checks.
78
+
79
+ ```bash
80
+ uv run pytest packages/artifacts/tests
81
+ uv run ruff check .
82
+ uv run ruff format --check .
83
+ uv run pyright
84
+ uv build --package dockflow-artifacts --force-pep517
85
+ ```
86
+
87
+ The repository-root `VERSION` governs the shared Dockflow application version;
88
+ package metadata is synchronized by the shared tooling.
89
+ The library is a member of the existing monorepo workspace, composition and
90
+ lifecycle. Macrostates baselines remain separate. The root `uv.lock` pins contributor tools,
91
+ not downstream users. Run development commands from the repository root.
@@ -0,0 +1,13 @@
1
+ dockflow_artifacts/__init__.py,sha256=_JzvmLa7kR8BImfxQwU6_wGt8FsdPGx5Du2Fl3aHamM,467
2
+ dockflow_artifacts/_paths.py,sha256=w9GLlcFn_SVb_0jD3v2qrsDR6blwoAVmKw9QQ3gqCZo,3027
3
+ dockflow_artifacts/_storage.py,sha256=DbN76o5DS4NwHHf_xHvPy0tRNnewNgBCO955IvjDBEE,8712
4
+ dockflow_artifacts/errors.py,sha256=aJq_eXPr-cjieb8v6uzkQaiqk1WxC7-4BEmVkRcvG20,419
5
+ dockflow_artifacts/identity.py,sha256=XLxFbav5c7JXJ9KRrYptP5raH-eEfgwnM-181F1EztA,2188
6
+ dockflow_artifacts/kfp.py,sha256=tcWPzcP37VdoCKcW6H01kaJu5KIaydq_cpsIkpj2pqY,1828
7
+ dockflow_artifacts/persistence.py,sha256=EY6cCmi5IAc6sbujTawTclFyjxhwJsRsW-Ycyl0-3mk,5439
8
+ dockflow_artifacts/publication.py,sha256=Gzu4XlJwLdtNHYx82KC3dPNUjdk8EIwEF7eVbtWjKtI,16579
9
+ dockflow_artifacts/py.typed,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
10
+ dockflow_artifacts-0.2.0.dist-info/licenses/LICENSE,sha256=uIXEE60dM30LHZXUm4HhF2tw0Zy02FRsqdapPi9x1tI,1078
11
+ dockflow_artifacts-0.2.0.dist-info/WHEEL,sha256=a0ta4TK7UKMB0EbDesmi2mn0Djkly7aj_7Tu2R7SseE,81
12
+ dockflow_artifacts-0.2.0.dist-info/METADATA,sha256=kcrjMjFX7eA5HumWXuGn5O5Ep_792_-L2Az7iLX1KSw,3987
13
+ dockflow_artifacts-0.2.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: uv 0.11.33
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Dockflow contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.