provium 0.1.0__tar.gz
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-0.1.0/PKG-INFO +70 -0
- provium-0.1.0/README.md +53 -0
- provium-0.1.0/pyproject.toml +58 -0
- provium-0.1.0/setup.cfg +4 -0
- provium-0.1.0/src/provium/__init__.py +45 -0
- provium-0.1.0/src/provium/artifact.py +125 -0
- provium-0.1.0/src/provium/catalog.py +80 -0
- provium-0.1.0/src/provium/config.py +90 -0
- provium-0.1.0/src/provium/context.py +40 -0
- provium-0.1.0/src/provium/discovery.py +44 -0
- provium-0.1.0/src/provium/header.py +148 -0
- provium-0.1.0/src/provium/procedure.py +413 -0
- provium-0.1.0/src/provium/provenance.py +371 -0
- provium-0.1.0/src/provium/reader.py +59 -0
- provium-0.1.0/src/provium/region.py +125 -0
- provium-0.1.0/src/provium/writer.py +91 -0
- provium-0.1.0/src/provium.egg-info/PKG-INFO +70 -0
- provium-0.1.0/src/provium.egg-info/SOURCES.txt +19 -0
- provium-0.1.0/src/provium.egg-info/dependency_links.txt +1 -0
- provium-0.1.0/src/provium.egg-info/requires.txt +5 -0
- provium-0.1.0/src/provium.egg-info/top_level.txt +1 -0
provium-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: provium
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Typed binary artifacts with automatic provenance
|
|
5
|
+
Project-URL: Homepage, https://github.com/SirDavidLudwig/provium
|
|
6
|
+
Project-URL: Issues, https://github.com/SirDavidLudwig/provium/issues
|
|
7
|
+
Project-URL: Repository, https://github.com/SirDavidLudwig/provium
|
|
8
|
+
Classifier: Development Status :: 2 - Pre-Alpha
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
11
|
+
Requires-Python: >=3.12
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
Provides-Extra: test
|
|
14
|
+
Requires-Dist: pydantic>=2.10; extra == "test"
|
|
15
|
+
Requires-Dist: pytest>=8.3; extra == "test"
|
|
16
|
+
Requires-Dist: pytest-cov>=6.0; extra == "test"
|
|
17
|
+
|
|
18
|
+
# Provium
|
|
19
|
+
|
|
20
|
+
Provium is a small standalone Python package for typed binary artifacts with
|
|
21
|
+
automatic provenance.
|
|
22
|
+
|
|
23
|
+
Provium artifact files use the `.pa` extension.
|
|
24
|
+
|
|
25
|
+
Artifact I/O occurs only inside a scoped procedure execution. Every artifact
|
|
26
|
+
opened in that scope is tracked automatically as an input, and every artifact
|
|
27
|
+
created in that scope is tracked automatically as an output. Users do not
|
|
28
|
+
construct or supply lineage manually.
|
|
29
|
+
|
|
30
|
+
## Status
|
|
31
|
+
|
|
32
|
+
Provium is in pre-alpha development. Implementation follows the test-driven
|
|
33
|
+
sequence in [`provium_tdd_plan.md`](provium_tdd_plan.md), with each step reviewed
|
|
34
|
+
before the next begins.
|
|
35
|
+
|
|
36
|
+
## Development setup
|
|
37
|
+
|
|
38
|
+
Provium requires Python 3.12 or newer and has no required runtime dependencies.
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
python3 -m venv .venv
|
|
42
|
+
source .venv/bin/activate
|
|
43
|
+
python -m pip install --upgrade pip
|
|
44
|
+
python -m pip install -e '.[test]'
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Run the test suite with:
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
pytest
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
The project configuration requires 100% statement and branch coverage for the
|
|
54
|
+
`provium` package.
|
|
55
|
+
|
|
56
|
+
## Planned API
|
|
57
|
+
|
|
58
|
+
```python
|
|
59
|
+
from provium import Procedure
|
|
60
|
+
|
|
61
|
+
ADD = Procedure(name="add", version="1")
|
|
62
|
+
|
|
63
|
+
with ADD.execute():
|
|
64
|
+
left = Integer.open("left.pa")
|
|
65
|
+
right = Integer.open("right.pa")
|
|
66
|
+
result = Integer.create("sum.pa")
|
|
67
|
+
result.write(left.read() + right.read())
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
The API shown above is the target design and is not implemented yet.
|
provium-0.1.0/README.md
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# Provium
|
|
2
|
+
|
|
3
|
+
Provium is a small standalone Python package for typed binary artifacts with
|
|
4
|
+
automatic provenance.
|
|
5
|
+
|
|
6
|
+
Provium artifact files use the `.pa` extension.
|
|
7
|
+
|
|
8
|
+
Artifact I/O occurs only inside a scoped procedure execution. Every artifact
|
|
9
|
+
opened in that scope is tracked automatically as an input, and every artifact
|
|
10
|
+
created in that scope is tracked automatically as an output. Users do not
|
|
11
|
+
construct or supply lineage manually.
|
|
12
|
+
|
|
13
|
+
## Status
|
|
14
|
+
|
|
15
|
+
Provium is in pre-alpha development. Implementation follows the test-driven
|
|
16
|
+
sequence in [`provium_tdd_plan.md`](provium_tdd_plan.md), with each step reviewed
|
|
17
|
+
before the next begins.
|
|
18
|
+
|
|
19
|
+
## Development setup
|
|
20
|
+
|
|
21
|
+
Provium requires Python 3.12 or newer and has no required runtime dependencies.
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
python3 -m venv .venv
|
|
25
|
+
source .venv/bin/activate
|
|
26
|
+
python -m pip install --upgrade pip
|
|
27
|
+
python -m pip install -e '.[test]'
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Run the test suite with:
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
pytest
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
The project configuration requires 100% statement and branch coverage for the
|
|
37
|
+
`provium` package.
|
|
38
|
+
|
|
39
|
+
## Planned API
|
|
40
|
+
|
|
41
|
+
```python
|
|
42
|
+
from provium import Procedure
|
|
43
|
+
|
|
44
|
+
ADD = Procedure(name="add", version="1")
|
|
45
|
+
|
|
46
|
+
with ADD.execute():
|
|
47
|
+
left = Integer.open("left.pa")
|
|
48
|
+
right = Integer.open("right.pa")
|
|
49
|
+
result = Integer.create("sum.pa")
|
|
50
|
+
result.write(left.read() + right.read())
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
The API shown above is the target design and is not implemented yet.
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=75"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "provium"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Typed binary artifacts with automatic provenance"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.12"
|
|
11
|
+
classifiers = [
|
|
12
|
+
"Development Status :: 2 - Pre-Alpha",
|
|
13
|
+
"Programming Language :: Python :: 3",
|
|
14
|
+
"Programming Language :: Python :: 3.12",
|
|
15
|
+
]
|
|
16
|
+
dependencies = []
|
|
17
|
+
|
|
18
|
+
[project.urls]
|
|
19
|
+
Homepage = "https://github.com/SirDavidLudwig/provium"
|
|
20
|
+
Issues = "https://github.com/SirDavidLudwig/provium/issues"
|
|
21
|
+
Repository = "https://github.com/SirDavidLudwig/provium"
|
|
22
|
+
|
|
23
|
+
[project.optional-dependencies]
|
|
24
|
+
test = [
|
|
25
|
+
"pydantic>=2.10",
|
|
26
|
+
"pytest>=8.3",
|
|
27
|
+
"pytest-cov>=6.0",
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
[tool.setuptools.packages.find]
|
|
31
|
+
where = ["src"]
|
|
32
|
+
|
|
33
|
+
[tool.pytest.ini_options]
|
|
34
|
+
addopts = [
|
|
35
|
+
"--strict-config",
|
|
36
|
+
"--strict-markers",
|
|
37
|
+
"--cov=provium",
|
|
38
|
+
"--cov-branch",
|
|
39
|
+
"--cov-report=term-missing",
|
|
40
|
+
"--cov-fail-under=100",
|
|
41
|
+
]
|
|
42
|
+
testpaths = ["test"]
|
|
43
|
+
|
|
44
|
+
[tool.coverage.run]
|
|
45
|
+
branch = true
|
|
46
|
+
source = ["provium"]
|
|
47
|
+
|
|
48
|
+
[tool.coverage.report]
|
|
49
|
+
fail_under = 100
|
|
50
|
+
show_missing = true
|
|
51
|
+
skip_covered = false
|
|
52
|
+
|
|
53
|
+
[tool.ruff]
|
|
54
|
+
target-version = "py312"
|
|
55
|
+
|
|
56
|
+
[tool.ruff.lint]
|
|
57
|
+
select = ["E", "F", "I", "TRY", "UP"]
|
|
58
|
+
ignore = ["TRY003"]
|
provium-0.1.0/setup.cfg
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
|
+
]
|
|
@@ -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")
|
|
@@ -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"]
|
|
@@ -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"]
|
|
@@ -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"]
|
|
@@ -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"]
|