provium 0.1.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.
- provium/__init__.py +45 -0
- provium/artifact.py +125 -0
- provium/catalog.py +80 -0
- provium/config.py +90 -0
- provium/context.py +40 -0
- provium/discovery.py +44 -0
- provium/header.py +148 -0
- provium/procedure.py +413 -0
- provium/provenance.py +371 -0
- provium/reader.py +59 -0
- provium/region.py +125 -0
- provium/writer.py +91 -0
- provium-0.1.0.dist-info/METADATA +70 -0
- provium-0.1.0.dist-info/RECORD +16 -0
- provium-0.1.0.dist-info/WHEEL +5 -0
- provium-0.1.0.dist-info/top_level.txt +1 -0
provium/__init__.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
from importlib.metadata import version
|
|
2
|
+
|
|
3
|
+
from .artifact import Artifact, open_artifact
|
|
4
|
+
from .catalog import ArtifactCatalog, ArtifactRegistration
|
|
5
|
+
from .config import ConfigCodec, ConfigurationSnapshot, JsonValue
|
|
6
|
+
from .discovery import discover_catalogs, reset_discovery
|
|
7
|
+
from .header import ArtifactHeader, decode_header, encode_header
|
|
8
|
+
from .procedure import ExecutionContext, Procedure, current_execution
|
|
9
|
+
from .provenance import (
|
|
10
|
+
ArtifactLineage,
|
|
11
|
+
ArtifactRecord,
|
|
12
|
+
ArtifactReference,
|
|
13
|
+
ProcedureExecutionRecord,
|
|
14
|
+
ProcedureRecord,
|
|
15
|
+
)
|
|
16
|
+
from .reader import ArtifactReader
|
|
17
|
+
from .writer import ArtifactWriter
|
|
18
|
+
|
|
19
|
+
__version__ = version("provium")
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
"Artifact",
|
|
23
|
+
"ArtifactCatalog",
|
|
24
|
+
"ArtifactHeader",
|
|
25
|
+
"ArtifactLineage",
|
|
26
|
+
"ArtifactReader",
|
|
27
|
+
"ArtifactRecord",
|
|
28
|
+
"ArtifactReference",
|
|
29
|
+
"ArtifactRegistration",
|
|
30
|
+
"ArtifactWriter",
|
|
31
|
+
"ConfigCodec",
|
|
32
|
+
"ConfigurationSnapshot",
|
|
33
|
+
"ExecutionContext",
|
|
34
|
+
"JsonValue",
|
|
35
|
+
"Procedure",
|
|
36
|
+
"ProcedureExecutionRecord",
|
|
37
|
+
"ProcedureRecord",
|
|
38
|
+
"__version__",
|
|
39
|
+
"decode_header",
|
|
40
|
+
"discover_catalogs",
|
|
41
|
+
"encode_header",
|
|
42
|
+
"current_execution",
|
|
43
|
+
"open_artifact",
|
|
44
|
+
"reset_discovery",
|
|
45
|
+
]
|
provium/artifact.py
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""Generic typed artifact definitions and lazy provider resolution."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from os import PathLike
|
|
7
|
+
from typing import Any, ClassVar, cast, overload
|
|
8
|
+
|
|
9
|
+
from .context import current_context
|
|
10
|
+
from .reader import ArtifactReader
|
|
11
|
+
from .writer import ArtifactWriter
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Artifact[ReaderT: ArtifactReader, WriterT: ArtifactWriter]:
|
|
15
|
+
"""Bind a logical artifact type to its concrete reader and writer types."""
|
|
16
|
+
|
|
17
|
+
reader: type[ReaderT] | Callable[[], type[ReaderT]]
|
|
18
|
+
writer: type[WriterT] | Callable[[], type[WriterT]]
|
|
19
|
+
_reader_type_cache: ClassVar[type[ArtifactReader] | None] = None
|
|
20
|
+
_writer_type_cache: ClassVar[type[ArtifactWriter] | None] = None
|
|
21
|
+
|
|
22
|
+
def __init_subclass__(cls, **kwargs: Any) -> None:
|
|
23
|
+
super().__init_subclass__(**kwargs)
|
|
24
|
+
cls._reader_type_cache = None
|
|
25
|
+
cls._writer_type_cache = None
|
|
26
|
+
|
|
27
|
+
@classmethod
|
|
28
|
+
def _resolve_reader(cls) -> type[ReaderT]:
|
|
29
|
+
cached = cls._reader_type_cache
|
|
30
|
+
if cached is not None:
|
|
31
|
+
return cast(type[ReaderT], cached)
|
|
32
|
+
provider = getattr(cls, "reader", None)
|
|
33
|
+
candidate = (
|
|
34
|
+
provider
|
|
35
|
+
if isinstance(provider, type)
|
|
36
|
+
else provider()
|
|
37
|
+
if callable(provider)
|
|
38
|
+
else None
|
|
39
|
+
)
|
|
40
|
+
if not isinstance(candidate, type) or not issubclass(candidate, ArtifactReader):
|
|
41
|
+
raise TypeError("reader provider must resolve to an ArtifactReader type")
|
|
42
|
+
cls._reader_type_cache = candidate
|
|
43
|
+
return cast(type[ReaderT], candidate)
|
|
44
|
+
|
|
45
|
+
@classmethod
|
|
46
|
+
def _resolve_writer(cls) -> type[WriterT]:
|
|
47
|
+
cached = cls._writer_type_cache
|
|
48
|
+
if cached is not None:
|
|
49
|
+
return cast(type[WriterT], cached)
|
|
50
|
+
provider = getattr(cls, "writer", None)
|
|
51
|
+
candidate = (
|
|
52
|
+
provider
|
|
53
|
+
if isinstance(provider, type)
|
|
54
|
+
else provider()
|
|
55
|
+
if callable(provider)
|
|
56
|
+
else None
|
|
57
|
+
)
|
|
58
|
+
if not isinstance(candidate, type) or not issubclass(candidate, ArtifactWriter):
|
|
59
|
+
raise TypeError("writer provider must resolve to an ArtifactWriter type")
|
|
60
|
+
cls._writer_type_cache = candidate
|
|
61
|
+
return cast(type[WriterT], candidate)
|
|
62
|
+
|
|
63
|
+
@classmethod
|
|
64
|
+
def open(cls, path: str | PathLike[str]) -> ReaderT:
|
|
65
|
+
context = current_context()
|
|
66
|
+
if context is None:
|
|
67
|
+
raise RuntimeError("artifact I/O requires an active execution context")
|
|
68
|
+
opener = getattr(context, "open_artifact", None)
|
|
69
|
+
if not callable(opener):
|
|
70
|
+
raise TypeError("active context does not support artifact opening")
|
|
71
|
+
return cast(ReaderT, opener(cls, path, cls._resolve_reader()))
|
|
72
|
+
|
|
73
|
+
@classmethod
|
|
74
|
+
def create(cls, path: str | PathLike[str]) -> WriterT:
|
|
75
|
+
context = current_context()
|
|
76
|
+
if context is None:
|
|
77
|
+
raise RuntimeError("artifact I/O requires an active execution context")
|
|
78
|
+
creator = getattr(context, "create_artifact", None)
|
|
79
|
+
if not callable(creator):
|
|
80
|
+
raise TypeError("active context does not support artifact creating")
|
|
81
|
+
return cast(WriterT, creator(cls, path, cls._resolve_writer()))
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
__all__ = ["Artifact"]
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@overload
|
|
88
|
+
def open_artifact(path: str | PathLike[str]) -> ArtifactReader: ...
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@overload
|
|
92
|
+
def open_artifact[ReaderT: ArtifactReader, WriterT: ArtifactWriter](
|
|
93
|
+
path: str | PathLike[str],
|
|
94
|
+
*,
|
|
95
|
+
expected: type[Artifact[ReaderT, WriterT]],
|
|
96
|
+
) -> ReaderT: ...
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@overload
|
|
100
|
+
def open_artifact(
|
|
101
|
+
path: str | PathLike[str],
|
|
102
|
+
*,
|
|
103
|
+
expected: tuple[type[Artifact], ...],
|
|
104
|
+
) -> ArtifactReader: ...
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def open_artifact(
|
|
108
|
+
path: str | PathLike[str],
|
|
109
|
+
*,
|
|
110
|
+
expected: type[Artifact] | tuple[type[Artifact], ...] | None = None,
|
|
111
|
+
) -> ArtifactReader:
|
|
112
|
+
"""Open an artifact whose concrete type will be discovered from its header."""
|
|
113
|
+
context = current_context()
|
|
114
|
+
if context is None:
|
|
115
|
+
raise RuntimeError("artifact I/O requires an active execution context")
|
|
116
|
+
opener = getattr(context, "open_unknown_artifact", None)
|
|
117
|
+
if not callable(opener):
|
|
118
|
+
raise TypeError("active context does not support artifact opening")
|
|
119
|
+
expected_types = None
|
|
120
|
+
if expected is not None:
|
|
121
|
+
expected_types = expected if isinstance(expected, tuple) else (expected,)
|
|
122
|
+
return cast(ArtifactReader, opener(path, expected_types))
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
__all__.append("open_artifact")
|
provium/catalog.py
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""Explicit registration of artifact classes and their persistent identifiers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from types import MappingProxyType
|
|
8
|
+
|
|
9
|
+
from .artifact import Artifact
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _require_identifier(value: str, field_name: str) -> None:
|
|
13
|
+
if not isinstance(value, str) or not value:
|
|
14
|
+
raise ValueError(f"{field_name} must be a non-empty string")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True, slots=True)
|
|
18
|
+
class ArtifactRegistration:
|
|
19
|
+
canonical_identifier: str
|
|
20
|
+
artifact: type[Artifact]
|
|
21
|
+
aliases: tuple[str, ...] = ()
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ArtifactCatalog:
|
|
25
|
+
"""Map canonical identifiers and aliases to typed artifact definitions."""
|
|
26
|
+
|
|
27
|
+
def __init__(self) -> None:
|
|
28
|
+
self._identifiers: dict[str, ArtifactRegistration] = {}
|
|
29
|
+
self._artifacts: dict[type[Artifact], ArtifactRegistration] = {}
|
|
30
|
+
|
|
31
|
+
def register(
|
|
32
|
+
self,
|
|
33
|
+
canonical_identifier: str,
|
|
34
|
+
artifact: type[Artifact],
|
|
35
|
+
*,
|
|
36
|
+
aliases: tuple[str, ...] = (),
|
|
37
|
+
) -> ArtifactRegistration:
|
|
38
|
+
_require_identifier(canonical_identifier, "canonical identifier")
|
|
39
|
+
if not isinstance(artifact, type) or not issubclass(artifact, Artifact):
|
|
40
|
+
raise TypeError("artifact must be an Artifact class")
|
|
41
|
+
for alias in aliases:
|
|
42
|
+
_require_identifier(alias, "alias")
|
|
43
|
+
if len(aliases) != len(set(aliases)):
|
|
44
|
+
raise ValueError("duplicate alias in registration")
|
|
45
|
+
if canonical_identifier in aliases:
|
|
46
|
+
raise ValueError("alias must differ from the canonical identifier")
|
|
47
|
+
if canonical_identifier in self._identifiers:
|
|
48
|
+
raise ValueError(
|
|
49
|
+
f"canonical identifier is already registered: {canonical_identifier}"
|
|
50
|
+
)
|
|
51
|
+
if artifact in self._artifacts:
|
|
52
|
+
raise ValueError("artifact class is already registered")
|
|
53
|
+
for alias in aliases:
|
|
54
|
+
if alias in self._identifiers:
|
|
55
|
+
raise ValueError(f"alias is already registered: {alias}")
|
|
56
|
+
|
|
57
|
+
registration = ArtifactRegistration(canonical_identifier, artifact, aliases)
|
|
58
|
+
self._identifiers[canonical_identifier] = registration
|
|
59
|
+
self._identifiers.update((alias, registration) for alias in aliases)
|
|
60
|
+
self._artifacts[artifact] = registration
|
|
61
|
+
return registration
|
|
62
|
+
|
|
63
|
+
def resolve(self, identifier: str) -> ArtifactRegistration:
|
|
64
|
+
return self._identifiers[identifier]
|
|
65
|
+
|
|
66
|
+
def registration_for(self, artifact: type[Artifact]) -> ArtifactRegistration:
|
|
67
|
+
return self._artifacts[artifact]
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def registrations(self) -> Mapping[str, ArtifactRegistration]:
|
|
71
|
+
"""Canonical registrations keyed by canonical identifier."""
|
|
72
|
+
return MappingProxyType(
|
|
73
|
+
{
|
|
74
|
+
registration.canonical_identifier: registration
|
|
75
|
+
for registration in self._artifacts.values()
|
|
76
|
+
}
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
__all__ = ["ArtifactCatalog", "ArtifactRegistration"]
|
provium/config.py
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""Typed, dependency-free procedure configuration serialization."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import math
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from typing import Protocol
|
|
9
|
+
|
|
10
|
+
type JsonScalar = None | bool | int | float | str
|
|
11
|
+
type JsonValue = JsonScalar | list[JsonValue] | dict[str, JsonValue]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ConfigCodec[ConfigT](Protocol):
|
|
15
|
+
"""Encode and decode one procedure configuration type."""
|
|
16
|
+
|
|
17
|
+
identifier: str
|
|
18
|
+
|
|
19
|
+
def encode(self, config: ConfigT) -> JsonValue: ...
|
|
20
|
+
|
|
21
|
+
def decode(self, value: JsonValue) -> ConfigT: ...
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _canonical_json_value(value: object) -> JsonValue:
|
|
25
|
+
"""Validate a JSON value and return an independent normalized copy."""
|
|
26
|
+
_validate_json_value(value)
|
|
27
|
+
encoded = json.dumps(
|
|
28
|
+
value,
|
|
29
|
+
allow_nan=False,
|
|
30
|
+
ensure_ascii=False,
|
|
31
|
+
separators=(",", ":"),
|
|
32
|
+
sort_keys=True,
|
|
33
|
+
)
|
|
34
|
+
decoded: JsonValue = json.loads(encoded)
|
|
35
|
+
return decoded
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _validate_json_value(value: object) -> None:
|
|
39
|
+
if value is None or isinstance(value, (bool, int, str)):
|
|
40
|
+
return
|
|
41
|
+
if isinstance(value, float):
|
|
42
|
+
if not math.isfinite(value):
|
|
43
|
+
raise ValueError("configuration must contain finite JSON numbers")
|
|
44
|
+
return
|
|
45
|
+
if isinstance(value, list):
|
|
46
|
+
for item in value:
|
|
47
|
+
_validate_json_value(item)
|
|
48
|
+
return
|
|
49
|
+
if isinstance(value, dict):
|
|
50
|
+
if any(not isinstance(key, str) for key in value):
|
|
51
|
+
raise TypeError("configuration JSON object keys must be strings")
|
|
52
|
+
for item in value.values():
|
|
53
|
+
_validate_json_value(item)
|
|
54
|
+
return
|
|
55
|
+
raise TypeError("configuration must be a JSON value")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass(frozen=True, slots=True)
|
|
59
|
+
class ConfigurationSnapshot:
|
|
60
|
+
"""A JSON-compatible configuration value paired with its codec identity."""
|
|
61
|
+
|
|
62
|
+
codec_identifier: str
|
|
63
|
+
value: JsonValue
|
|
64
|
+
|
|
65
|
+
def __post_init__(self) -> None:
|
|
66
|
+
if not isinstance(self.codec_identifier, str) or not self.codec_identifier:
|
|
67
|
+
raise ValueError("codec_identifier must be a non-empty string")
|
|
68
|
+
object.__setattr__(self, "value", _canonical_json_value(self.value))
|
|
69
|
+
|
|
70
|
+
def to_json(self) -> str:
|
|
71
|
+
return json.dumps(
|
|
72
|
+
{"codec_identifier": self.codec_identifier, "value": self.value},
|
|
73
|
+
allow_nan=False,
|
|
74
|
+
ensure_ascii=False,
|
|
75
|
+
separators=(",", ":"),
|
|
76
|
+
sort_keys=True,
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
@classmethod
|
|
80
|
+
def from_json(cls, value: str) -> ConfigurationSnapshot:
|
|
81
|
+
decoded = json.loads(value)
|
|
82
|
+
if not isinstance(decoded, dict) or set(decoded) != {
|
|
83
|
+
"codec_identifier",
|
|
84
|
+
"value",
|
|
85
|
+
}:
|
|
86
|
+
raise ValueError("invalid configuration snapshot")
|
|
87
|
+
return cls(decoded["codec_identifier"], decoded["value"])
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
__all__ = ["ConfigCodec", "ConfigurationSnapshot", "JsonValue"]
|
provium/context.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Logical execution-context state shared by context-bound resources."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Generator
|
|
6
|
+
from contextlib import contextmanager
|
|
7
|
+
from contextvars import ContextVar, Token
|
|
8
|
+
|
|
9
|
+
_active_context: ContextVar[object | None] = ContextVar(
|
|
10
|
+
"provium_active_execution_context",
|
|
11
|
+
default=None,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def current_context() -> object | None:
|
|
16
|
+
"""Return the active execution-context owner in this logical context."""
|
|
17
|
+
return _active_context.get()
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def set_context(owner: object) -> Token[object | None]:
|
|
21
|
+
"""Set the active owner and return the token required to restore it."""
|
|
22
|
+
return _active_context.set(owner)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def reset_context(token: Token[object | None]) -> None:
|
|
26
|
+
"""Restore the logical context represented by a prior token."""
|
|
27
|
+
_active_context.reset(token)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@contextmanager
|
|
31
|
+
def activate_context(owner: object) -> Generator[None]:
|
|
32
|
+
"""Activate an owner temporarily, restoring the previous owner on exit."""
|
|
33
|
+
token = set_context(owner)
|
|
34
|
+
try:
|
|
35
|
+
yield
|
|
36
|
+
finally:
|
|
37
|
+
reset_context(token)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
__all__ = ["activate_context", "current_context", "reset_context", "set_context"]
|
provium/discovery.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Discover explicitly published artifact catalogs through package entry points."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from importlib import metadata
|
|
6
|
+
|
|
7
|
+
from .catalog import ArtifactCatalog
|
|
8
|
+
|
|
9
|
+
ENTRY_POINT_GROUP = "provium.catalogs"
|
|
10
|
+
_discovered_catalog: ArtifactCatalog | None = None
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def discover_catalogs() -> ArtifactCatalog:
|
|
14
|
+
"""Load and combine installed catalogs, caching a successful discovery."""
|
|
15
|
+
global _discovered_catalog
|
|
16
|
+
if _discovered_catalog is not None:
|
|
17
|
+
return _discovered_catalog
|
|
18
|
+
|
|
19
|
+
discovered = ArtifactCatalog()
|
|
20
|
+
for entry_point in metadata.entry_points().select(group=ENTRY_POINT_GROUP):
|
|
21
|
+
catalog = entry_point.load()
|
|
22
|
+
if not isinstance(catalog, ArtifactCatalog):
|
|
23
|
+
message = (
|
|
24
|
+
f"catalog entry point {entry_point.name!r} must expose "
|
|
25
|
+
"an ArtifactCatalog"
|
|
26
|
+
)
|
|
27
|
+
raise TypeError(message)
|
|
28
|
+
for registration in catalog.registrations.values():
|
|
29
|
+
discovered.register(
|
|
30
|
+
registration.canonical_identifier,
|
|
31
|
+
registration.artifact,
|
|
32
|
+
aliases=registration.aliases,
|
|
33
|
+
)
|
|
34
|
+
_discovered_catalog = discovered
|
|
35
|
+
return discovered
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def reset_discovery() -> None:
|
|
39
|
+
"""Clear cached discovery state, primarily for isolated tests."""
|
|
40
|
+
global _discovered_catalog
|
|
41
|
+
_discovered_catalog = None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
__all__ = ["discover_catalogs", "reset_discovery"]
|
provium/header.py
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"""Binary container prefix and deterministic artifact metadata encoding."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import struct
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from .provenance import ArtifactLineage
|
|
11
|
+
|
|
12
|
+
MAGIC = b"PROVIUM\0"
|
|
13
|
+
CONTAINER_VERSION = 1
|
|
14
|
+
_PREFIX = struct.Struct(">8sHQQ")
|
|
15
|
+
PREFIX_SIZE = _PREFIX.size
|
|
16
|
+
_METADATA_KEYS = {
|
|
17
|
+
"artifact_identifier",
|
|
18
|
+
"artifact_identity",
|
|
19
|
+
"body_offset",
|
|
20
|
+
"body_length",
|
|
21
|
+
"body_digest",
|
|
22
|
+
"lineage",
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _require_text(value: str, field_name: str) -> None:
|
|
27
|
+
if not isinstance(value, str) or not value:
|
|
28
|
+
raise ValueError(f"{field_name} must be a non-empty string")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _metadata_bytes(header: ArtifactHeader) -> bytes:
|
|
32
|
+
return json.dumps(
|
|
33
|
+
{
|
|
34
|
+
"artifact_identifier": header.artifact_identifier,
|
|
35
|
+
"artifact_identity": header.artifact_identity,
|
|
36
|
+
"body_digest": header.body_digest,
|
|
37
|
+
"body_length": header.body_length,
|
|
38
|
+
"body_offset": header.body_offset,
|
|
39
|
+
"lineage": header.lineage.to_dict(),
|
|
40
|
+
},
|
|
41
|
+
ensure_ascii=False,
|
|
42
|
+
separators=(",", ":"),
|
|
43
|
+
sort_keys=True,
|
|
44
|
+
).encode("utf-8")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass(frozen=True, slots=True)
|
|
48
|
+
class ArtifactHeader:
|
|
49
|
+
"""The generic metadata required to locate and identify an artifact body."""
|
|
50
|
+
|
|
51
|
+
artifact_identifier: str
|
|
52
|
+
artifact_identity: str
|
|
53
|
+
body_offset: int
|
|
54
|
+
body_length: int
|
|
55
|
+
body_digest: str
|
|
56
|
+
lineage: ArtifactLineage
|
|
57
|
+
metadata_offset: int = PREFIX_SIZE
|
|
58
|
+
metadata_length: int = field(init=False)
|
|
59
|
+
|
|
60
|
+
def __post_init__(self) -> None:
|
|
61
|
+
_require_text(self.artifact_identifier, "artifact_identifier")
|
|
62
|
+
_require_text(self.artifact_identity, "artifact_identity")
|
|
63
|
+
if not isinstance(self.body_offset, int) or self.body_offset < 0:
|
|
64
|
+
raise ValueError("body_offset must be a non-negative integer")
|
|
65
|
+
if not isinstance(self.body_length, int) or self.body_length < 0:
|
|
66
|
+
raise ValueError("body_length must be a non-negative integer")
|
|
67
|
+
_require_text(self.body_digest, "body_digest")
|
|
68
|
+
if not isinstance(self.lineage, ArtifactLineage):
|
|
69
|
+
raise TypeError("lineage must be an ArtifactLineage")
|
|
70
|
+
if (
|
|
71
|
+
not isinstance(self.metadata_offset, int)
|
|
72
|
+
or self.metadata_offset < PREFIX_SIZE
|
|
73
|
+
):
|
|
74
|
+
raise ValueError("metadata_offset must not overlap the fixed header")
|
|
75
|
+
object.__setattr__(self, "metadata_length", len(_metadata_bytes(self)))
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def encode_header(header: ArtifactHeader) -> bytes:
|
|
79
|
+
"""Encode a complete fixed prefix and its canonical metadata."""
|
|
80
|
+
if not isinstance(header, ArtifactHeader):
|
|
81
|
+
raise TypeError("header must be an ArtifactHeader")
|
|
82
|
+
metadata = _metadata_bytes(header)
|
|
83
|
+
prefix = _PREFIX.pack(
|
|
84
|
+
MAGIC,
|
|
85
|
+
CONTAINER_VERSION,
|
|
86
|
+
header.metadata_offset,
|
|
87
|
+
len(metadata),
|
|
88
|
+
)
|
|
89
|
+
padding = bytes(header.metadata_offset - PREFIX_SIZE)
|
|
90
|
+
return prefix + padding + metadata
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _decode_metadata(metadata_bytes: bytes, metadata_offset: int) -> ArtifactHeader:
|
|
94
|
+
metadata: Any = json.loads(metadata_bytes.decode("utf-8"))
|
|
95
|
+
if not isinstance(metadata, dict) or set(metadata) != _METADATA_KEYS:
|
|
96
|
+
raise ValueError("invalid metadata shape")
|
|
97
|
+
return ArtifactHeader(
|
|
98
|
+
artifact_identifier=metadata["artifact_identifier"],
|
|
99
|
+
artifact_identity=metadata["artifact_identity"],
|
|
100
|
+
body_offset=metadata["body_offset"],
|
|
101
|
+
body_length=metadata["body_length"],
|
|
102
|
+
body_digest=metadata["body_digest"],
|
|
103
|
+
lineage=ArtifactLineage.from_dict(metadata["lineage"]),
|
|
104
|
+
metadata_offset=metadata_offset,
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def decode_header(data: bytes | bytearray | memoryview) -> ArtifactHeader:
|
|
109
|
+
"""Decode and validate a complete artifact header from bytes."""
|
|
110
|
+
if len(data) < PREFIX_SIZE:
|
|
111
|
+
raise ValueError("truncated fixed header")
|
|
112
|
+
magic, version, metadata_offset, metadata_length = _PREFIX.unpack_from(data)
|
|
113
|
+
if magic != MAGIC:
|
|
114
|
+
raise ValueError("invalid artifact magic bytes")
|
|
115
|
+
if version != CONTAINER_VERSION:
|
|
116
|
+
raise ValueError(f"unsupported container version: {version}")
|
|
117
|
+
if metadata_offset < PREFIX_SIZE:
|
|
118
|
+
raise ValueError("metadata offset overlaps the fixed header")
|
|
119
|
+
metadata_end = metadata_offset + metadata_length
|
|
120
|
+
if len(data) < metadata_end:
|
|
121
|
+
raise ValueError("truncated metadata")
|
|
122
|
+
metadata_bytes = bytes(data[metadata_offset:metadata_end])
|
|
123
|
+
try:
|
|
124
|
+
header = _decode_metadata(metadata_bytes, metadata_offset)
|
|
125
|
+
except (
|
|
126
|
+
KeyError,
|
|
127
|
+
TypeError,
|
|
128
|
+
UnicodeDecodeError,
|
|
129
|
+
json.JSONDecodeError,
|
|
130
|
+
ValueError,
|
|
131
|
+
) as error:
|
|
132
|
+
raise ValueError("malformed artifact metadata") from error
|
|
133
|
+
if (
|
|
134
|
+
metadata_offset != header.metadata_offset
|
|
135
|
+
or metadata_length != header.metadata_length
|
|
136
|
+
):
|
|
137
|
+
raise ValueError("malformed artifact metadata encoding")
|
|
138
|
+
return header
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
__all__ = [
|
|
142
|
+
"CONTAINER_VERSION",
|
|
143
|
+
"MAGIC",
|
|
144
|
+
"PREFIX_SIZE",
|
|
145
|
+
"ArtifactHeader",
|
|
146
|
+
"decode_header",
|
|
147
|
+
"encode_header",
|
|
148
|
+
]
|