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.
- pyingestkit/__init__.py +111 -0
- pyingestkit/_version.py +1 -0
- pyingestkit/artifacts/__init__.py +17 -0
- pyingestkit/artifacts/base.py +145 -0
- pyingestkit/artifacts/factory.py +30 -0
- pyingestkit/artifacts/filesystem.py +97 -0
- pyingestkit/artifacts/naming.py +32 -0
- pyingestkit/artifacts/raw.py +43 -0
- pyingestkit/artifacts/s3.py +271 -0
- pyingestkit/artifacts/stored.py +26 -0
- pyingestkit/artifacts/uri.py +89 -0
- pyingestkit/cli/__init__.py +5 -0
- pyingestkit/cli/app.py +73 -0
- pyingestkit/cli/commands/__init__.py +23 -0
- pyingestkit/cli/commands/config.py +232 -0
- pyingestkit/cli/commands/inspect.py +68 -0
- pyingestkit/cli/commands/jobs.py +72 -0
- pyingestkit/cli/commands/published.py +40 -0
- pyingestkit/cli/commands/replay.py +92 -0
- pyingestkit/cli/commands/run.py +162 -0
- pyingestkit/cli/commands/runs.py +89 -0
- pyingestkit/cli/commands/status.py +264 -0
- pyingestkit/cli/commands/versions.py +56 -0
- pyingestkit/cli/common.py +149 -0
- pyingestkit/cli/console.py +6 -0
- pyingestkit/cli/main.py +60 -0
- pyingestkit/config/__init__.py +33 -0
- pyingestkit/config/loader.py +122 -0
- pyingestkit/config/models.py +166 -0
- pyingestkit/contracts/__init__.py +3 -0
- pyingestkit/contracts/dataset.py +613 -0
- pyingestkit/core/__init__.py +22 -0
- pyingestkit/core/context.py +27 -0
- pyingestkit/core/events.py +80 -0
- pyingestkit/core/exceptions.py +70 -0
- pyingestkit/core/job.py +28 -0
- pyingestkit/core/pipeline.py +20 -0
- pyingestkit/core/registry.py +34 -0
- pyingestkit/core/result.py +49 -0
- pyingestkit/core/step.py +21 -0
- pyingestkit/core/types.py +7 -0
- pyingestkit/dataset.py +86 -0
- pyingestkit/declarative/__init__.py +6 -0
- pyingestkit/declarative/builder.py +44 -0
- pyingestkit/declarative/decorators.py +62 -0
- pyingestkit/declarative/invocation.py +20 -0
- pyingestkit/declarative/job_definition.py +68 -0
- pyingestkit/declarative/step_definition.py +70 -0
- pyingestkit/deprecations.py +35 -0
- pyingestkit/diff/__init__.py +4 -0
- pyingestkit/diff/engine.py +243 -0
- pyingestkit/diff/models.py +149 -0
- pyingestkit/diff/report.py +119 -0
- pyingestkit/errors.py +74 -0
- pyingestkit/logging/__init__.py +13 -0
- pyingestkit/logging/context.py +38 -0
- pyingestkit/logging/filters.py +79 -0
- pyingestkit/logging/formatters.py +75 -0
- pyingestkit/logging/setup.py +113 -0
- pyingestkit/metadata/__init__.py +51 -0
- pyingestkit/metadata/_artifact_locations.py +49 -0
- pyingestkit/metadata/_schema.py +266 -0
- pyingestkit/metadata/_sqlalchemy.py +703 -0
- pyingestkit/metadata/_target_loads.py +178 -0
- pyingestkit/metadata/_types.py +35 -0
- pyingestkit/metadata/base.py +112 -0
- pyingestkit/metadata/capabilities.py +108 -0
- pyingestkit/metadata/factory.py +26 -0
- pyingestkit/metadata/memory.py +292 -0
- pyingestkit/metadata/models.py +216 -0
- pyingestkit/metadata/postgres.py +79 -0
- pyingestkit/metadata/sqlite.py +67 -0
- pyingestkit/parsers/__init__.py +16 -0
- pyingestkit/parsers/base.py +14 -0
- pyingestkit/parsers/csv.py +69 -0
- pyingestkit/parsers/excel.py +121 -0
- pyingestkit/parsers/json.py +76 -0
- pyingestkit/parsers/ndjson.py +51 -0
- pyingestkit/parsers/parquet.py +89 -0
- pyingestkit/plugins/__init__.py +19 -0
- pyingestkit/plugins/discovery.py +138 -0
- pyingestkit/profiling/__init__.py +4 -0
- pyingestkit/profiling/models.py +63 -0
- pyingestkit/profiling/profiler.py +120 -0
- pyingestkit/provenance/__init__.py +4 -0
- pyingestkit/provenance/hashing.py +16 -0
- pyingestkit/provenance/manifest.py +82 -0
- pyingestkit/publication/__init__.py +3 -0
- pyingestkit/publication/atomic.py +27 -0
- pyingestkit/py.typed +0 -0
- pyingestkit/quality/__init__.py +3 -0
- pyingestkit/quality/report.py +31 -0
- pyingestkit/replay/__init__.py +11 -0
- pyingestkit/replay/models.py +117 -0
- pyingestkit/replay/resolver.py +63 -0
- pyingestkit/replay/service.py +227 -0
- pyingestkit/retry/__init__.py +15 -0
- pyingestkit/retry/policy.py +175 -0
- pyingestkit/runtime/__init__.py +3 -0
- pyingestkit/runtime/runner.py +699 -0
- pyingestkit/sources/__init__.py +4 -0
- pyingestkit/sources/base.py +12 -0
- pyingestkit/sources/http/__init__.py +23 -0
- pyingestkit/sources/http/client.py +75 -0
- pyingestkit/sources/http/exceptions.py +47 -0
- pyingestkit/sources/http/request.py +71 -0
- pyingestkit/sources/http/response.py +65 -0
- pyingestkit/sources/http/security.py +57 -0
- pyingestkit/sources/http/source.py +185 -0
- pyingestkit/sources/local.py +39 -0
- pyingestkit/targets/__init__.py +45 -0
- pyingestkit/targets/base.py +50 -0
- pyingestkit/targets/capabilities.py +18 -0
- pyingestkit/targets/errors.py +35 -0
- pyingestkit/targets/idempotency.py +258 -0
- pyingestkit/targets/models.py +141 -0
- pyingestkit/targets/postgres.py +333 -0
- pyingestkit/targets/schema.py +160 -0
- pyingestkit/validation/__init__.py +15 -0
- pyingestkit/validation/report.py +81 -0
- pyingestkit/validation/result.py +42 -0
- pyingestkit/validation/rules.py +92 -0
- pyingestkit/versioning/__init__.py +17 -0
- pyingestkit/versioning/_canonical.py +66 -0
- pyingestkit/versioning/_metadata.py +61 -0
- pyingestkit/versioning/_s3_objects.py +196 -0
- pyingestkit/versioning/fingerprint.py +83 -0
- pyingestkit/versioning/models.py +31 -0
- pyingestkit/versioning/s3.py +279 -0
- pyingestkit/versioning/snapshot.py +147 -0
- pyingestkit/versioning/store.py +341 -0
- pyingestkit-1.0.0.dist-info/METADATA +325 -0
- pyingestkit-1.0.0.dist-info/RECORD +137 -0
- pyingestkit-1.0.0.dist-info/WHEEL +5 -0
- pyingestkit-1.0.0.dist-info/entry_points.txt +2 -0
- pyingestkit-1.0.0.dist-info/licenses/LICENSE +21 -0
- pyingestkit-1.0.0.dist-info/top_level.txt +1 -0
pyingestkit/__init__.py
ADDED
|
@@ -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())
|
pyingestkit/_version.py
ADDED
|
@@ -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)
|