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 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.
@@ -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"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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"]