pyingestkit 1.0.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.
Files changed (137) hide show
  1. pyingestkit/__init__.py +111 -0
  2. pyingestkit/_version.py +1 -0
  3. pyingestkit/artifacts/__init__.py +17 -0
  4. pyingestkit/artifacts/base.py +145 -0
  5. pyingestkit/artifacts/factory.py +30 -0
  6. pyingestkit/artifacts/filesystem.py +97 -0
  7. pyingestkit/artifacts/naming.py +32 -0
  8. pyingestkit/artifacts/raw.py +43 -0
  9. pyingestkit/artifacts/s3.py +271 -0
  10. pyingestkit/artifacts/stored.py +26 -0
  11. pyingestkit/artifacts/uri.py +89 -0
  12. pyingestkit/cli/__init__.py +5 -0
  13. pyingestkit/cli/app.py +73 -0
  14. pyingestkit/cli/commands/__init__.py +23 -0
  15. pyingestkit/cli/commands/config.py +232 -0
  16. pyingestkit/cli/commands/inspect.py +68 -0
  17. pyingestkit/cli/commands/jobs.py +72 -0
  18. pyingestkit/cli/commands/published.py +40 -0
  19. pyingestkit/cli/commands/replay.py +92 -0
  20. pyingestkit/cli/commands/run.py +162 -0
  21. pyingestkit/cli/commands/runs.py +89 -0
  22. pyingestkit/cli/commands/status.py +264 -0
  23. pyingestkit/cli/commands/versions.py +56 -0
  24. pyingestkit/cli/common.py +149 -0
  25. pyingestkit/cli/console.py +6 -0
  26. pyingestkit/cli/main.py +60 -0
  27. pyingestkit/config/__init__.py +33 -0
  28. pyingestkit/config/loader.py +122 -0
  29. pyingestkit/config/models.py +166 -0
  30. pyingestkit/contracts/__init__.py +3 -0
  31. pyingestkit/contracts/dataset.py +613 -0
  32. pyingestkit/core/__init__.py +22 -0
  33. pyingestkit/core/context.py +27 -0
  34. pyingestkit/core/events.py +80 -0
  35. pyingestkit/core/exceptions.py +70 -0
  36. pyingestkit/core/job.py +28 -0
  37. pyingestkit/core/pipeline.py +20 -0
  38. pyingestkit/core/registry.py +34 -0
  39. pyingestkit/core/result.py +49 -0
  40. pyingestkit/core/step.py +21 -0
  41. pyingestkit/core/types.py +7 -0
  42. pyingestkit/dataset.py +86 -0
  43. pyingestkit/declarative/__init__.py +6 -0
  44. pyingestkit/declarative/builder.py +44 -0
  45. pyingestkit/declarative/decorators.py +62 -0
  46. pyingestkit/declarative/invocation.py +20 -0
  47. pyingestkit/declarative/job_definition.py +68 -0
  48. pyingestkit/declarative/step_definition.py +70 -0
  49. pyingestkit/deprecations.py +35 -0
  50. pyingestkit/diff/__init__.py +4 -0
  51. pyingestkit/diff/engine.py +243 -0
  52. pyingestkit/diff/models.py +149 -0
  53. pyingestkit/diff/report.py +119 -0
  54. pyingestkit/errors.py +74 -0
  55. pyingestkit/logging/__init__.py +13 -0
  56. pyingestkit/logging/context.py +38 -0
  57. pyingestkit/logging/filters.py +79 -0
  58. pyingestkit/logging/formatters.py +75 -0
  59. pyingestkit/logging/setup.py +113 -0
  60. pyingestkit/metadata/__init__.py +51 -0
  61. pyingestkit/metadata/_artifact_locations.py +49 -0
  62. pyingestkit/metadata/_schema.py +266 -0
  63. pyingestkit/metadata/_sqlalchemy.py +703 -0
  64. pyingestkit/metadata/_target_loads.py +178 -0
  65. pyingestkit/metadata/_types.py +35 -0
  66. pyingestkit/metadata/base.py +112 -0
  67. pyingestkit/metadata/capabilities.py +108 -0
  68. pyingestkit/metadata/factory.py +26 -0
  69. pyingestkit/metadata/memory.py +292 -0
  70. pyingestkit/metadata/models.py +216 -0
  71. pyingestkit/metadata/postgres.py +79 -0
  72. pyingestkit/metadata/sqlite.py +67 -0
  73. pyingestkit/parsers/__init__.py +16 -0
  74. pyingestkit/parsers/base.py +14 -0
  75. pyingestkit/parsers/csv.py +69 -0
  76. pyingestkit/parsers/excel.py +121 -0
  77. pyingestkit/parsers/json.py +76 -0
  78. pyingestkit/parsers/ndjson.py +51 -0
  79. pyingestkit/parsers/parquet.py +89 -0
  80. pyingestkit/plugins/__init__.py +19 -0
  81. pyingestkit/plugins/discovery.py +138 -0
  82. pyingestkit/profiling/__init__.py +4 -0
  83. pyingestkit/profiling/models.py +63 -0
  84. pyingestkit/profiling/profiler.py +120 -0
  85. pyingestkit/provenance/__init__.py +4 -0
  86. pyingestkit/provenance/hashing.py +16 -0
  87. pyingestkit/provenance/manifest.py +82 -0
  88. pyingestkit/publication/__init__.py +3 -0
  89. pyingestkit/publication/atomic.py +27 -0
  90. pyingestkit/py.typed +0 -0
  91. pyingestkit/quality/__init__.py +3 -0
  92. pyingestkit/quality/report.py +31 -0
  93. pyingestkit/replay/__init__.py +11 -0
  94. pyingestkit/replay/models.py +117 -0
  95. pyingestkit/replay/resolver.py +63 -0
  96. pyingestkit/replay/service.py +227 -0
  97. pyingestkit/retry/__init__.py +15 -0
  98. pyingestkit/retry/policy.py +175 -0
  99. pyingestkit/runtime/__init__.py +3 -0
  100. pyingestkit/runtime/runner.py +699 -0
  101. pyingestkit/sources/__init__.py +4 -0
  102. pyingestkit/sources/base.py +12 -0
  103. pyingestkit/sources/http/__init__.py +23 -0
  104. pyingestkit/sources/http/client.py +75 -0
  105. pyingestkit/sources/http/exceptions.py +47 -0
  106. pyingestkit/sources/http/request.py +71 -0
  107. pyingestkit/sources/http/response.py +65 -0
  108. pyingestkit/sources/http/security.py +57 -0
  109. pyingestkit/sources/http/source.py +185 -0
  110. pyingestkit/sources/local.py +39 -0
  111. pyingestkit/targets/__init__.py +45 -0
  112. pyingestkit/targets/base.py +50 -0
  113. pyingestkit/targets/capabilities.py +18 -0
  114. pyingestkit/targets/errors.py +35 -0
  115. pyingestkit/targets/idempotency.py +258 -0
  116. pyingestkit/targets/models.py +141 -0
  117. pyingestkit/targets/postgres.py +333 -0
  118. pyingestkit/targets/schema.py +160 -0
  119. pyingestkit/validation/__init__.py +15 -0
  120. pyingestkit/validation/report.py +81 -0
  121. pyingestkit/validation/result.py +42 -0
  122. pyingestkit/validation/rules.py +92 -0
  123. pyingestkit/versioning/__init__.py +17 -0
  124. pyingestkit/versioning/_canonical.py +66 -0
  125. pyingestkit/versioning/_metadata.py +61 -0
  126. pyingestkit/versioning/_s3_objects.py +196 -0
  127. pyingestkit/versioning/fingerprint.py +83 -0
  128. pyingestkit/versioning/models.py +31 -0
  129. pyingestkit/versioning/s3.py +279 -0
  130. pyingestkit/versioning/snapshot.py +147 -0
  131. pyingestkit/versioning/store.py +341 -0
  132. pyingestkit-1.0.0.dist-info/METADATA +325 -0
  133. pyingestkit-1.0.0.dist-info/RECORD +137 -0
  134. pyingestkit-1.0.0.dist-info/WHEEL +5 -0
  135. pyingestkit-1.0.0.dist-info/entry_points.txt +2 -0
  136. pyingestkit-1.0.0.dist-info/licenses/LICENSE +21 -0
  137. pyingestkit-1.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,111 @@
1
+ """PyIngestKit public API."""
2
+
3
+ import logging as _stdlib_logging
4
+
5
+ from ._version import __version__ as __version__
6
+ from .artifacts import ArtifactURI, S3ArtifactStore, StoredArtifact
7
+ from .contracts import DatasetContract, FieldContract
8
+ from .core.context import RunContext
9
+ from .core.job import Job
10
+ from .core.pipeline import Pipeline
11
+ from .core.result import RunResult, RunStatus, StepResult
12
+ from .core.step import Step
13
+ from .dataset import Dataset
14
+ from .declarative import JobDefinition, StepDefinition, StepInvocation, job, step
15
+ from .diff import DatasetDiff, DatasetDiffer, DiffEntry, DiffKind, DiffPolicy, SchemaDiff
16
+ from .parsers import CsvParser, ExcelParser, JsonParser, NdjsonParser, ParquetParser
17
+ from .profiling import DatasetProfile, DatasetProfiler, FieldProfile
18
+ from .quality import QualityReport
19
+ from .replay import ReplayContext, ReplayRawArtifact, ReplayResult, ReplayService
20
+ from .runtime.runner import Runner
21
+ from .targets import (
22
+ IdempotencyAction,
23
+ IdempotencyPolicy,
24
+ LoadMode,
25
+ PostgresTarget,
26
+ Target,
27
+ TargetCapabilities,
28
+ TargetLoadDecision,
29
+ TargetLoadExecutor,
30
+ TargetLoadRequest,
31
+ TargetLoadResult,
32
+ TargetLoadStatus,
33
+ )
34
+ from .validation import ValidationIssue, ValidationResult
35
+ from .versioning import (
36
+ DatasetFingerprint,
37
+ DatasetFingerprinter,
38
+ DatasetFingerprintPolicy,
39
+ DatasetVersion,
40
+ DatasetVersionStore,
41
+ FilesystemDatasetVersionStore,
42
+ PublishedDataset,
43
+ S3DatasetVersionStore,
44
+ SnapshotCodec,
45
+ )
46
+
47
+ __all__ = [
48
+ "ArtifactURI",
49
+ "CsvParser",
50
+ "Dataset",
51
+ "DatasetContract",
52
+ "DatasetDiff",
53
+ "DatasetDiffer",
54
+ "DatasetFingerprint",
55
+ "DatasetFingerprinter",
56
+ "DatasetFingerprintPolicy",
57
+ "DatasetProfile",
58
+ "DatasetProfiler",
59
+ "DatasetVersion",
60
+ "DatasetVersionStore",
61
+ "DiffEntry",
62
+ "DiffKind",
63
+ "DiffPolicy",
64
+ "ExcelParser",
65
+ "FieldContract",
66
+ "FieldProfile",
67
+ "FilesystemDatasetVersionStore",
68
+ "Job",
69
+ "JobDefinition",
70
+ "JsonParser",
71
+ "IdempotencyAction",
72
+ "IdempotencyPolicy",
73
+ "NdjsonParser",
74
+ "ParquetParser",
75
+ "Pipeline",
76
+ "PublishedDataset",
77
+ "QualityReport",
78
+ "ReplayContext",
79
+ "ReplayRawArtifact",
80
+ "ReplayResult",
81
+ "ReplayService",
82
+ "RunContext",
83
+ "RunResult",
84
+ "RunStatus",
85
+ "Runner",
86
+ "S3ArtifactStore",
87
+ "S3DatasetVersionStore",
88
+ "StoredArtifact",
89
+ "SchemaDiff",
90
+ "SnapshotCodec",
91
+ "Step",
92
+ "StepDefinition",
93
+ "StepInvocation",
94
+ "StepResult",
95
+ "LoadMode",
96
+ "PostgresTarget",
97
+ "Target",
98
+ "TargetCapabilities",
99
+ "TargetLoadDecision",
100
+ "TargetLoadExecutor",
101
+ "TargetLoadRequest",
102
+ "TargetLoadResult",
103
+ "TargetLoadStatus",
104
+ "ValidationIssue",
105
+ "ValidationResult",
106
+ "job",
107
+ "step",
108
+ ]
109
+
110
+ # Library best practice: never configure application handlers at import time.
111
+ _stdlib_logging.getLogger(__name__).addHandler(_stdlib_logging.NullHandler())
@@ -0,0 +1 @@
1
+ __version__ = "1.0.0"
@@ -0,0 +1,17 @@
1
+ from .base import ArtifactStore
2
+ from .factory import create_artifact_store
3
+ from .filesystem import LocalArtifactStore
4
+ from .raw import RawArtifact
5
+ from .s3 import S3ArtifactStore
6
+ from .stored import StoredArtifact
7
+ from .uri import ArtifactURI
8
+
9
+ __all__ = [
10
+ "ArtifactStore",
11
+ "ArtifactURI",
12
+ "LocalArtifactStore",
13
+ "RawArtifact",
14
+ "S3ArtifactStore",
15
+ "StoredArtifact",
16
+ "create_artifact_store",
17
+ ]
@@ -0,0 +1,145 @@
1
+ from __future__ import annotations
2
+
3
+ from abc import ABC, abstractmethod
4
+ from pathlib import Path
5
+ from typing import Any
6
+ from uuid import UUID
7
+
8
+ from pyingestkit.core.exceptions import StorageError
9
+ from pyingestkit.provenance.hashing import sha256_bytes
10
+
11
+ from .raw import RawArtifact
12
+ from .stored import StoredArtifact
13
+ from .uri import ArtifactURI
14
+
15
+
16
+ class ArtifactStore(ABC):
17
+ """Run-artifact persistence contract.
18
+
19
+ V0.6 separates a durable storage URI from the local materialization path.
20
+ Existing third-party stores remain source-compatible because URI/read/materialize
21
+ methods have conservative local-file defaults rather than new abstract methods.
22
+ """
23
+
24
+ @abstractmethod
25
+ def prepare_run(self, job_id: str, run_id: UUID) -> Path:
26
+ raise NotImplementedError
27
+
28
+ @abstractmethod
29
+ def write_raw(
30
+ self,
31
+ job_id: str,
32
+ run_id: UUID,
33
+ *,
34
+ name: str,
35
+ data: bytes,
36
+ source_uri: str,
37
+ content_type: str | None = None,
38
+ resolved_url: str | None = None,
39
+ status_code: int | None = None,
40
+ etag: str | None = None,
41
+ last_modified: str | None = None,
42
+ ) -> RawArtifact:
43
+ raise NotImplementedError
44
+
45
+ @abstractmethod
46
+ def write_json(self, job_id: str, run_id: UUID, relative_path: str, payload: Any) -> Path:
47
+ raise NotImplementedError
48
+
49
+ @abstractmethod
50
+ def path_for(self, job_id: str, run_id: UUID, relative_path: str) -> Path:
51
+ raise NotImplementedError
52
+
53
+ def uri_for(self, job_id: str, run_id: UUID, relative_path: str) -> ArtifactURI:
54
+ """Return the canonical storage URI for an artifact path.
55
+
56
+ V0.5-compatible stores automatically get a ``file://`` implementation.
57
+ Remote stores override this without changing callers.
58
+ """
59
+
60
+ return ArtifactURI.from_path(self.path_for(job_id, run_id, relative_path))
61
+
62
+ def read_bytes(self, uri: ArtifactURI | str) -> bytes:
63
+ """Read persisted bytes by canonical URI.
64
+
65
+ The default implementation intentionally supports local ``file://`` only.
66
+ Remote backends opt in by overriding this method.
67
+ """
68
+
69
+ location = uri if isinstance(uri, ArtifactURI) else ArtifactURI(uri)
70
+ if not location.is_local:
71
+ raise StorageError(f"Artifact store cannot read remote URI scheme {location.scheme!r}")
72
+ path = location.as_path()
73
+ try:
74
+ return path.read_bytes()
75
+ except OSError as exc:
76
+ raise StorageError(f"Unable to read artifact URI {location}") from exc
77
+
78
+ def write_json_artifact(
79
+ self, job_id: str, run_id: UUID, relative_path: str, payload: Any
80
+ ) -> StoredArtifact:
81
+ """Persist JSON and return its durable URI and integrity metadata.
82
+
83
+ This is additive to the V0.5 ``write_json`` contract. Third-party stores that only
84
+ implement ``write_json`` automatically gain a local-file implementation.
85
+ """
86
+
87
+ path = self.write_json(job_id, run_id, relative_path, payload)
88
+ try:
89
+ data = path.read_bytes()
90
+ except OSError as exc:
91
+ raise StorageError(f"Unable to inspect JSON artifact at {path}") from exc
92
+ return StoredArtifact(
93
+ relative_path=relative_path,
94
+ path=str(path),
95
+ storage_uri=str(self.uri_for(job_id, run_id, relative_path)),
96
+ content_type="application/json",
97
+ size_bytes=len(data),
98
+ sha256=sha256_bytes(data),
99
+ )
100
+
101
+ def _materialize_verified(
102
+ self, *, local_path: Path, storage_uri: ArtifactURI, expected_sha256: str, label: str
103
+ ) -> Path:
104
+ if local_path.is_file():
105
+ try:
106
+ data = local_path.read_bytes()
107
+ except OSError as exc:
108
+ raise StorageError(f"Unable to read {label} materialization {local_path}") from exc
109
+ else:
110
+ data = self.read_bytes(storage_uri)
111
+ local_path.parent.mkdir(parents=True, exist_ok=True)
112
+ temp = local_path.with_name(f".{local_path.name}.materializing")
113
+ try:
114
+ temp.write_bytes(data)
115
+ temp.replace(local_path)
116
+ except OSError as exc:
117
+ temp.unlink(missing_ok=True)
118
+ raise StorageError(f"Unable to materialize {label} at {local_path}") from exc
119
+
120
+ actual = sha256_bytes(data)
121
+ if actual != expected_sha256:
122
+ raise StorageError(
123
+ f"{label} materialization SHA-256 mismatch: expected {expected_sha256}, got {actual}"
124
+ )
125
+ return local_path
126
+
127
+ def materialize_raw(self, artifact: RawArtifact) -> Path:
128
+ """Ensure RAW is present at its local materialization path and verify SHA-256."""
129
+
130
+ return self._materialize_verified(
131
+ local_path=artifact.local_path,
132
+ storage_uri=artifact.location_uri,
133
+ expected_sha256=artifact.sha256,
134
+ label="RAW",
135
+ )
136
+
137
+ def materialize_artifact(self, artifact: StoredArtifact) -> Path:
138
+ """Materialize a non-RAW run artifact from its durable URI and verify SHA-256."""
139
+
140
+ return self._materialize_verified(
141
+ local_path=artifact.local_path,
142
+ storage_uri=artifact.location_uri,
143
+ expected_sha256=artifact.sha256,
144
+ label="artifact",
145
+ )
@@ -0,0 +1,30 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from pathlib import Path
5
+
6
+ from pyingestkit.config.models import ArtifactBackend, ArtifactConfig
7
+ from pyingestkit.core.exceptions import ConfigurationError
8
+
9
+ from .base import ArtifactStore
10
+ from .filesystem import LocalArtifactStore
11
+ from .s3 import S3ArtifactStore
12
+
13
+
14
+ def create_artifact_store(config: ArtifactConfig, *, workspace: str | Path) -> ArtifactStore:
15
+ if config.backend is ArtifactBackend.LOCAL:
16
+ return LocalArtifactStore(workspace)
17
+
18
+ s3 = config.s3
19
+ if s3.bucket is None:
20
+ raise ConfigurationError("S3 artifact backend requires artifacts.s3.bucket")
21
+ endpoint_url = None
22
+ if s3.endpoint_url_env:
23
+ endpoint_url = os.getenv(s3.endpoint_url_env)
24
+ return S3ArtifactStore(
25
+ bucket=s3.bucket,
26
+ prefix=s3.prefix,
27
+ cache_root=s3.cache_path or workspace,
28
+ region_name=s3.region_name,
29
+ endpoint_url=endpoint_url,
30
+ )
@@ -0,0 +1,97 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import logging
5
+ from datetime import UTC, datetime
6
+ from pathlib import Path
7
+ from typing import Any
8
+ from uuid import UUID, uuid4
9
+
10
+ from pyingestkit.core.exceptions import StorageError
11
+ from pyingestkit.provenance.hashing import sha256_bytes
12
+
13
+ from .base import ArtifactStore
14
+ from .naming import job_parts, relative_artifact_path, safe_component
15
+ from .raw import RawArtifact
16
+ from .uri import ArtifactURI
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+
21
+ class LocalArtifactStore(ArtifactStore):
22
+ def __init__(self, root: str | Path = ".pyingest") -> None:
23
+ self.root = Path(root)
24
+
25
+ def _job_parts(self, job_id: str) -> tuple[str, ...]:
26
+ return job_parts(job_id)
27
+
28
+ def run_root(self, job_id: str, run_id: UUID) -> Path:
29
+ return self.root / "runs" / Path(*self._job_parts(job_id)) / str(run_id)
30
+
31
+ def prepare_run(self, job_id: str, run_id: UUID) -> Path:
32
+ run_root = self.run_root(job_id, run_id)
33
+ for name in ("raw", "staging", "candidate", "reports"):
34
+ (run_root / name).mkdir(parents=True, exist_ok=True)
35
+ return run_root
36
+
37
+ def path_for(self, job_id: str, run_id: UUID, relative_path: str) -> Path:
38
+ relative = relative_artifact_path(relative_path)
39
+ path = self.run_root(job_id, run_id).joinpath(*relative.parts)
40
+ path.parent.mkdir(parents=True, exist_ok=True)
41
+ return path
42
+
43
+ def uri_for(self, job_id: str, run_id: UUID, relative_path: str) -> ArtifactURI:
44
+ relative = relative_artifact_path(relative_path)
45
+ path = self.run_root(job_id, run_id).joinpath(*relative.parts)
46
+ return ArtifactURI.from_path(path)
47
+
48
+ def write_raw(
49
+ self,
50
+ job_id: str,
51
+ run_id: UUID,
52
+ *,
53
+ name: str,
54
+ data: bytes,
55
+ source_uri: str,
56
+ content_type: str | None = None,
57
+ resolved_url: str | None = None,
58
+ status_code: int | None = None,
59
+ etag: str | None = None,
60
+ last_modified: str | None = None,
61
+ ) -> RawArtifact:
62
+ self.prepare_run(job_id, run_id)
63
+ digest = sha256_bytes(data)
64
+ relative_path = f"raw/{safe_component(name)}"
65
+ path = self.path_for(job_id, run_id, relative_path)
66
+ try:
67
+ with path.open("xb") as handle:
68
+ handle.write(data)
69
+ except FileExistsError as exc:
70
+ raise StorageError(
71
+ f"RAW artifacts are immutable: refusing to overwrite existing path {path}"
72
+ ) from exc
73
+ logger.debug("RAW artifact written path=%s bytes=%d sha256=%s", path, len(data), digest)
74
+ return RawArtifact(
75
+ artifact_id=str(uuid4()),
76
+ source_uri=source_uri,
77
+ retrieved_at=datetime.now(UTC),
78
+ content_type=content_type,
79
+ size_bytes=len(data),
80
+ sha256=digest,
81
+ path=str(path),
82
+ resolved_url=resolved_url,
83
+ status_code=status_code,
84
+ etag=etag,
85
+ last_modified=last_modified,
86
+ storage_uri=str(self.uri_for(job_id, run_id, relative_path)),
87
+ )
88
+
89
+ def write_json(self, job_id: str, run_id: UUID, relative_path: str, payload: Any) -> Path:
90
+ path = self.path_for(job_id, run_id, relative_path)
91
+ temp = path.with_name(f".{path.name}.tmp")
92
+ temp.write_text(
93
+ json.dumps(payload, indent=2, sort_keys=True, default=str), encoding="utf-8"
94
+ )
95
+ temp.replace(path)
96
+ logger.debug("JSON artifact written path=%s", path)
97
+ return path
@@ -0,0 +1,32 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from pathlib import PurePosixPath
5
+ from uuid import UUID
6
+
7
+ from pyingestkit.core.exceptions import StorageError
8
+
9
+ _SAFE = re.compile(r"[^A-Za-z0-9._-]+")
10
+
11
+
12
+ def safe_component(value: str) -> str:
13
+ cleaned = _SAFE.sub("_", value).strip("._")
14
+ return cleaned or "unnamed"
15
+
16
+
17
+ def job_parts(job_id: str) -> tuple[str, ...]:
18
+ return tuple(safe_component(part) for part in job_id.split("."))
19
+
20
+
21
+ def relative_artifact_path(value: str) -> PurePosixPath:
22
+ if not value or "\\" in value:
23
+ raise StorageError(f"Invalid artifact relative path: {value!r}")
24
+ path = PurePosixPath(value)
25
+ if not path.parts or path.is_absolute() or any(part == ".." for part in path.parts):
26
+ raise StorageError(f"Artifact path must stay inside the run workspace: {value!r}")
27
+ return path
28
+
29
+
30
+ def run_relative_key(job_id: str, run_id: UUID, relative_path: str) -> str:
31
+ relative = relative_artifact_path(relative_path)
32
+ return str(PurePosixPath("runs", *job_parts(job_id), str(run_id), *relative.parts))
@@ -0,0 +1,43 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from datetime import datetime
5
+ from pathlib import Path
6
+
7
+ from .uri import ArtifactURI
8
+
9
+
10
+ @dataclass(frozen=True, slots=True)
11
+ class RawArtifact:
12
+ artifact_id: str
13
+ source_uri: str
14
+ retrieved_at: datetime
15
+ content_type: str | None
16
+ size_bytes: int
17
+ sha256: str
18
+ path: str
19
+ resolved_url: str | None = None
20
+ status_code: int | None = None
21
+ etag: str | None = None
22
+ last_modified: str | None = None
23
+ storage_uri: str | None = None
24
+ acquisition_mode: str = "LIVE"
25
+ origin_run_id: str | None = None
26
+ origin_artifact_id: str | None = None
27
+ origin_retrieved_at: datetime | None = None
28
+
29
+ def __post_init__(self) -> None:
30
+ if self.storage_uri is not None:
31
+ ArtifactURI(self.storage_uri)
32
+
33
+ @property
34
+ def location_uri(self) -> ArtifactURI:
35
+ """Canonical persisted location; V0.5 ``path`` remains the local materialization."""
36
+
37
+ if self.storage_uri is not None:
38
+ return ArtifactURI(self.storage_uri)
39
+ return ArtifactURI.from_path(self.path)
40
+
41
+ @property
42
+ def local_path(self) -> Path:
43
+ return Path(self.path)