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.
- dockflow_artifacts/__init__.py +17 -0
- dockflow_artifacts/_paths.py +91 -0
- dockflow_artifacts/_storage.py +239 -0
- dockflow_artifacts/errors.py +13 -0
- dockflow_artifacts/identity.py +61 -0
- dockflow_artifacts/kfp.py +48 -0
- dockflow_artifacts/persistence.py +147 -0
- dockflow_artifacts/publication.py +407 -0
- dockflow_artifacts/py.typed +1 -0
- dockflow_artifacts-0.2.0.dist-info/METADATA +91 -0
- dockflow_artifacts-0.2.0.dist-info/RECORD +13 -0
- dockflow_artifacts-0.2.0.dist-info/WHEEL +4 -0
- dockflow_artifacts-0.2.0.dist-info/licenses/LICENSE +21 -0
|
@@ -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,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.
|