nl2data-semantic-catalog-postgres 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.
- nl2data_semantic_catalog_postgres/__init__.py +18 -0
- nl2data_semantic_catalog_postgres/client.py +139 -0
- nl2data_semantic_catalog_postgres/config.py +136 -0
- nl2data_semantic_catalog_postgres/envelope.py +327 -0
- nl2data_semantic_catalog_postgres/errors.py +188 -0
- nl2data_semantic_catalog_postgres/fake_postgres/__init__.py +53 -0
- nl2data_semantic_catalog_postgres/fake_postgres/driver.py +209 -0
- nl2data_semantic_catalog_postgres/fake_postgres/handlers_audit.py +243 -0
- nl2data_semantic_catalog_postgres/fake_postgres/handlers_drafts.py +84 -0
- nl2data_semantic_catalog_postgres/fake_postgres/handlers_maintenance.py +263 -0
- nl2data_semantic_catalog_postgres/fake_postgres/handlers_publications.py +249 -0
- nl2data_semantic_catalog_postgres/fake_postgres/handlers_schema.py +23 -0
- nl2data_semantic_catalog_postgres/fake_postgres/handlers_snapshots.py +197 -0
- nl2data_semantic_catalog_postgres/fake_postgres/handlers_versions.py +330 -0
- nl2data_semantic_catalog_postgres/fake_postgres/keys.py +99 -0
- nl2data_semantic_catalog_postgres/fake_postgres/pool.py +152 -0
- nl2data_semantic_catalog_postgres/fake_postgres/registry.py +144 -0
- nl2data_semantic_catalog_postgres/maintenance.py +235 -0
- nl2data_semantic_catalog_postgres/py.typed +0 -0
- nl2data_semantic_catalog_postgres/repositories/__init__.py +26 -0
- nl2data_semantic_catalog_postgres/repositories/activation.py +849 -0
- nl2data_semantic_catalog_postgres/repositories/audit_evidence.py +221 -0
- nl2data_semantic_catalog_postgres/repositories/drafts.py +156 -0
- nl2data_semantic_catalog_postgres/repositories/evidence.py +415 -0
- nl2data_semantic_catalog_postgres/repositories/publications.py +486 -0
- nl2data_semantic_catalog_postgres/repositories/snapshots.py +372 -0
- nl2data_semantic_catalog_postgres/schema.py +337 -0
- nl2data_semantic_catalog_postgres/sql.py +520 -0
- nl2data_semantic_catalog_postgres/store.py +716 -0
- nl2data_semantic_catalog_postgres/unit_of_work.py +589 -0
- nl2data_semantic_catalog_postgres-0.1.0.dist-info/METADATA +76 -0
- nl2data_semantic_catalog_postgres-0.1.0.dist-info/RECORD +34 -0
- nl2data_semantic_catalog_postgres-0.1.0.dist-info/WHEEL +5 -0
- nl2data_semantic_catalog_postgres-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
"""Metadata snapshot and proposal-set persistence repository."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import datetime, timedelta
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from nl2data_core.canonical import strict_sha256_fingerprint
|
|
9
|
+
from nl2data_core.metadata.drift import DriftDecision, DriftOverride
|
|
10
|
+
from nl2data_core.metadata.models import MetadataSnapshot
|
|
11
|
+
from nl2data_core.metadata.policy import (
|
|
12
|
+
SnapshotActivationPolicy,
|
|
13
|
+
check_snapshot_activation,
|
|
14
|
+
)
|
|
15
|
+
from nl2data_core.metadata.production import (
|
|
16
|
+
LedgerActivation,
|
|
17
|
+
SnapshotLifecycleRecord,
|
|
18
|
+
SnapshotLifecycleState,
|
|
19
|
+
)
|
|
20
|
+
from nl2data_core.metadata.proposals import SemanticProposalSet
|
|
21
|
+
|
|
22
|
+
from ..envelope import ENVELOPE_SCHEMA_VERSION, ArtifactKind
|
|
23
|
+
from ..errors import SemanticCatalogError, SemanticCatalogErrorCode
|
|
24
|
+
from ..sql import FINGERPRINT_PATTERN
|
|
25
|
+
from ..unit_of_work import CatalogUnitOfWork, _namespace, _parse_dt
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _proposal_set_payload(proposal_set: SemanticProposalSet) -> dict[str, Any]:
|
|
29
|
+
"""The canonical payload a proposal-set envelope persists."""
|
|
30
|
+
return {
|
|
31
|
+
"snapshot_fingerprint": proposal_set.snapshot_fingerprint,
|
|
32
|
+
"proposals": [
|
|
33
|
+
proposal.canonical_payload() for proposal in proposal_set.proposals
|
|
34
|
+
],
|
|
35
|
+
"reviewed_at": (
|
|
36
|
+
proposal_set.reviewed_at.isoformat()
|
|
37
|
+
if proposal_set.reviewed_at is not None
|
|
38
|
+
else None
|
|
39
|
+
),
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class SnapshotRepository:
|
|
44
|
+
"""Durable metadata snapshots, activation pointers, and proposal sets."""
|
|
45
|
+
|
|
46
|
+
def __init__(self, uow: CatalogUnitOfWork) -> None:
|
|
47
|
+
self._uow = uow
|
|
48
|
+
|
|
49
|
+
def register_snapshot(
|
|
50
|
+
self,
|
|
51
|
+
snapshot: MetadataSnapshot,
|
|
52
|
+
*,
|
|
53
|
+
tenant_scope_fingerprint: str,
|
|
54
|
+
retained_for_seconds: float | None = None,
|
|
55
|
+
) -> SnapshotLifecycleRecord:
|
|
56
|
+
"""Retain one snapshot as evidence (never activates by default)."""
|
|
57
|
+
if FINGERPRINT_PATTERN.fullmatch(tenant_scope_fingerprint) is None:
|
|
58
|
+
raise ValueError("tenant_scope_fingerprint must be a sha256 fingerprint")
|
|
59
|
+
namespace = _namespace(tenant_scope_fingerprint)
|
|
60
|
+
now = self._uow.now()
|
|
61
|
+
envelope = self._uow.encode(
|
|
62
|
+
ArtifactKind.SNAPSHOT, snapshot.canonical_payload(), snapshot.fingerprint
|
|
63
|
+
)
|
|
64
|
+
retained_until = now + timedelta(
|
|
65
|
+
seconds=(
|
|
66
|
+
retained_for_seconds
|
|
67
|
+
if retained_for_seconds is not None
|
|
68
|
+
else self._uow.config.snapshot_retention_seconds
|
|
69
|
+
)
|
|
70
|
+
)
|
|
71
|
+
observed_incomplete = any(obj.observed_incomplete for obj in snapshot.objects)
|
|
72
|
+
with self._uow.transaction() as conn:
|
|
73
|
+
self._uow.execute(
|
|
74
|
+
conn,
|
|
75
|
+
"upsert_snapshot",
|
|
76
|
+
(
|
|
77
|
+
namespace,
|
|
78
|
+
snapshot.fingerprint,
|
|
79
|
+
snapshot.source.source_id,
|
|
80
|
+
SnapshotLifecycleState.INACTIVE.value,
|
|
81
|
+
ENVELOPE_SCHEMA_VERSION,
|
|
82
|
+
envelope,
|
|
83
|
+
snapshot.freshness.discovered_at,
|
|
84
|
+
retained_until,
|
|
85
|
+
now,
|
|
86
|
+
),
|
|
87
|
+
)
|
|
88
|
+
pointer = self._uow.execute(
|
|
89
|
+
conn, "read_snapshot_pointer", (namespace, snapshot.source.source_id)
|
|
90
|
+
).fetchone()
|
|
91
|
+
state = (
|
|
92
|
+
SnapshotLifecycleState.ACTIVE
|
|
93
|
+
if pointer is not None
|
|
94
|
+
and pointer["snapshot_fingerprint"] == snapshot.fingerprint
|
|
95
|
+
else SnapshotLifecycleState.INACTIVE
|
|
96
|
+
)
|
|
97
|
+
if state is SnapshotLifecycleState.ACTIVE:
|
|
98
|
+
self._uow.execute(
|
|
99
|
+
conn,
|
|
100
|
+
"set_snapshot_state",
|
|
101
|
+
(
|
|
102
|
+
SnapshotLifecycleState.ACTIVE.value,
|
|
103
|
+
now,
|
|
104
|
+
namespace,
|
|
105
|
+
snapshot.fingerprint,
|
|
106
|
+
),
|
|
107
|
+
)
|
|
108
|
+
self._uow.insert_event(
|
|
109
|
+
conn,
|
|
110
|
+
"snapshot_registered",
|
|
111
|
+
snapshot.fingerprint,
|
|
112
|
+
namespace=namespace,
|
|
113
|
+
occurred_at=now,
|
|
114
|
+
)
|
|
115
|
+
return SnapshotLifecycleRecord(
|
|
116
|
+
snapshot_fingerprint=snapshot.fingerprint,
|
|
117
|
+
source_id=snapshot.source.source_id,
|
|
118
|
+
tenant_scope_fingerprint=tenant_scope_fingerprint,
|
|
119
|
+
state=state,
|
|
120
|
+
discovered_at=snapshot.freshness.discovered_at,
|
|
121
|
+
retained_until=retained_until,
|
|
122
|
+
activated_at=(now if state is SnapshotLifecycleState.ACTIVE else None),
|
|
123
|
+
activation_evidence=None,
|
|
124
|
+
observed_incomplete=observed_incomplete,
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
def snapshot(
|
|
128
|
+
self,
|
|
129
|
+
snapshot_fingerprint: str,
|
|
130
|
+
*,
|
|
131
|
+
tenant_scope_fingerprint: str,
|
|
132
|
+
) -> MetadataSnapshot | None:
|
|
133
|
+
"""The registered snapshot with the given fingerprint, or ``None``.
|
|
134
|
+
|
|
135
|
+
The read revalidates the fingerprint and tenant scope; a mismatch
|
|
136
|
+
fails closed.
|
|
137
|
+
"""
|
|
138
|
+
namespace = _namespace(tenant_scope_fingerprint)
|
|
139
|
+
with self._uow.transaction() as conn:
|
|
140
|
+
row = self._uow.execute(
|
|
141
|
+
conn,
|
|
142
|
+
"read_snapshot_envelope",
|
|
143
|
+
(namespace, snapshot_fingerprint),
|
|
144
|
+
).fetchone()
|
|
145
|
+
if row is None:
|
|
146
|
+
return None
|
|
147
|
+
envelope = self._uow.decode(
|
|
148
|
+
row["envelope"],
|
|
149
|
+
ArtifactKind.SNAPSHOT,
|
|
150
|
+
row_schema_version=row["schema_version"],
|
|
151
|
+
)
|
|
152
|
+
return self._uow.snapshot_from_envelope(
|
|
153
|
+
envelope, discovered_at=row["discovered_at"]
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
def activate_snapshot(
|
|
157
|
+
self,
|
|
158
|
+
snapshot_fingerprint: str,
|
|
159
|
+
*,
|
|
160
|
+
tenant_scope_fingerprint: str,
|
|
161
|
+
policy: SnapshotActivationPolicy | None = None,
|
|
162
|
+
drift_decision: DriftDecision | None = None,
|
|
163
|
+
overrides: tuple[DriftOverride, ...] = (),
|
|
164
|
+
now: datetime | None = None,
|
|
165
|
+
) -> LedgerActivation:
|
|
166
|
+
"""Atomically activate a registered snapshot under production rules.
|
|
167
|
+
|
|
168
|
+
Only registered, structurally complete snapshots activate; the
|
|
169
|
+
active pointer changes only when every activation check passes, and
|
|
170
|
+
a rejected activation leaves the previous active pointer unchanged.
|
|
171
|
+
"""
|
|
172
|
+
namespace = _namespace(tenant_scope_fingerprint)
|
|
173
|
+
current = self._uow.now() if now is None else now
|
|
174
|
+
with self._uow.transaction() as conn:
|
|
175
|
+
row = self._uow.execute(
|
|
176
|
+
conn,
|
|
177
|
+
"lock_snapshot_row",
|
|
178
|
+
(namespace, snapshot_fingerprint),
|
|
179
|
+
).fetchone()
|
|
180
|
+
if row is None:
|
|
181
|
+
return LedgerActivation(
|
|
182
|
+
activated=False, reason="snapshot_unknown"
|
|
183
|
+
)
|
|
184
|
+
retained_until = _parse_dt(row["retained_until"])
|
|
185
|
+
if current > retained_until:
|
|
186
|
+
return LedgerActivation(
|
|
187
|
+
activated=False, reason="snapshot_expired"
|
|
188
|
+
)
|
|
189
|
+
envelope = self._uow.decode(
|
|
190
|
+
row["envelope"],
|
|
191
|
+
ArtifactKind.SNAPSHOT,
|
|
192
|
+
row_schema_version=row["schema_version"],
|
|
193
|
+
)
|
|
194
|
+
snapshot = self._uow.snapshot_from_envelope(
|
|
195
|
+
envelope, discovered_at=row["discovered_at"]
|
|
196
|
+
)
|
|
197
|
+
if policy is not None:
|
|
198
|
+
check = check_snapshot_activation(
|
|
199
|
+
snapshot,
|
|
200
|
+
policy,
|
|
201
|
+
drift_decision=drift_decision,
|
|
202
|
+
overrides=overrides,
|
|
203
|
+
tenant_scope_fingerprint=tenant_scope_fingerprint,
|
|
204
|
+
now=current,
|
|
205
|
+
)
|
|
206
|
+
if not check.allowed:
|
|
207
|
+
return LedgerActivation(
|
|
208
|
+
activated=False,
|
|
209
|
+
reason=(
|
|
210
|
+
check.issues[0].code if check.issues else "snapshot_rejected"
|
|
211
|
+
),
|
|
212
|
+
)
|
|
213
|
+
elif any(
|
|
214
|
+
obj.observed_incomplete for obj in snapshot.objects
|
|
215
|
+
) or bool(
|
|
216
|
+
snapshot.freshness.bounded_objects
|
|
217
|
+
or snapshot.freshness.bounded_fields
|
|
218
|
+
or snapshot.freshness.bounded_samples
|
|
219
|
+
):
|
|
220
|
+
return LedgerActivation(
|
|
221
|
+
activated=False, reason="snapshot_partial"
|
|
222
|
+
)
|
|
223
|
+
self._uow.execute(
|
|
224
|
+
conn,
|
|
225
|
+
"upsert_snapshot_pointer",
|
|
226
|
+
(
|
|
227
|
+
namespace,
|
|
228
|
+
snapshot.source.source_id,
|
|
229
|
+
snapshot_fingerprint,
|
|
230
|
+
ENVELOPE_SCHEMA_VERSION,
|
|
231
|
+
current,
|
|
232
|
+
),
|
|
233
|
+
)
|
|
234
|
+
self._uow.execute(
|
|
235
|
+
conn,
|
|
236
|
+
"set_snapshot_state",
|
|
237
|
+
(
|
|
238
|
+
SnapshotLifecycleState.ACTIVE.value,
|
|
239
|
+
current,
|
|
240
|
+
namespace,
|
|
241
|
+
snapshot_fingerprint,
|
|
242
|
+
),
|
|
243
|
+
)
|
|
244
|
+
self._uow.insert_event(
|
|
245
|
+
conn,
|
|
246
|
+
"snapshot_activated",
|
|
247
|
+
snapshot_fingerprint,
|
|
248
|
+
namespace=namespace,
|
|
249
|
+
occurred_at=current,
|
|
250
|
+
)
|
|
251
|
+
record = SnapshotLifecycleRecord(
|
|
252
|
+
snapshot_fingerprint=snapshot_fingerprint,
|
|
253
|
+
source_id=snapshot.source.source_id,
|
|
254
|
+
tenant_scope_fingerprint=tenant_scope_fingerprint,
|
|
255
|
+
state=SnapshotLifecycleState.ACTIVE,
|
|
256
|
+
discovered_at=snapshot.freshness.discovered_at,
|
|
257
|
+
retained_until=retained_until,
|
|
258
|
+
activated_at=current,
|
|
259
|
+
activation_evidence=(
|
|
260
|
+
drift_decision.decision_fingerprint
|
|
261
|
+
if drift_decision is not None
|
|
262
|
+
else None
|
|
263
|
+
),
|
|
264
|
+
observed_incomplete=any(
|
|
265
|
+
obj.observed_incomplete for obj in snapshot.objects
|
|
266
|
+
),
|
|
267
|
+
)
|
|
268
|
+
return LedgerActivation(activated=True, reason="activated", record=record)
|
|
269
|
+
|
|
270
|
+
def active_snapshot(
|
|
271
|
+
self, source_id: str, tenant_scope_fingerprint: str
|
|
272
|
+
) -> MetadataSnapshot | None:
|
|
273
|
+
"""The active snapshot for one source/tenant scope, or ``None``."""
|
|
274
|
+
namespace = _namespace(tenant_scope_fingerprint)
|
|
275
|
+
with self._uow.transaction() as conn:
|
|
276
|
+
pointer = self._uow.execute(
|
|
277
|
+
conn, "read_snapshot_pointer", (namespace, source_id)
|
|
278
|
+
).fetchone()
|
|
279
|
+
if pointer is None:
|
|
280
|
+
return None
|
|
281
|
+
row = self._uow.execute(
|
|
282
|
+
conn,
|
|
283
|
+
"read_snapshot_envelope",
|
|
284
|
+
(namespace, pointer["snapshot_fingerprint"]),
|
|
285
|
+
).fetchone()
|
|
286
|
+
if row is None:
|
|
287
|
+
return None
|
|
288
|
+
envelope = self._uow.decode(
|
|
289
|
+
row["envelope"],
|
|
290
|
+
ArtifactKind.SNAPSHOT,
|
|
291
|
+
row_schema_version=row["schema_version"],
|
|
292
|
+
)
|
|
293
|
+
return self._uow.snapshot_from_envelope(
|
|
294
|
+
envelope, discovered_at=row["discovered_at"]
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
# -- proposal sets ------------------------------------------------------
|
|
298
|
+
|
|
299
|
+
def save_proposal_set(
|
|
300
|
+
self,
|
|
301
|
+
proposal_set: SemanticProposalSet,
|
|
302
|
+
*,
|
|
303
|
+
tenant_scope_fingerprint: str,
|
|
304
|
+
) -> None:
|
|
305
|
+
"""Persist the latest reviewed proposal set for its snapshot.
|
|
306
|
+
|
|
307
|
+
The proposal set must be bound to a snapshot registered in the same
|
|
308
|
+
tenant scope; unknown and cross-scope snapshots fail identically so
|
|
309
|
+
the catalog never acts as an existence oracle.
|
|
310
|
+
"""
|
|
311
|
+
namespace = _namespace(tenant_scope_fingerprint)
|
|
312
|
+
now = self._uow.now()
|
|
313
|
+
payload = _proposal_set_payload(proposal_set)
|
|
314
|
+
fingerprint = strict_sha256_fingerprint(payload)
|
|
315
|
+
envelope = self._uow.encode(ArtifactKind.PROPOSAL_SET, payload, fingerprint)
|
|
316
|
+
with self._uow.transaction() as conn:
|
|
317
|
+
exists = self._uow.execute(
|
|
318
|
+
conn,
|
|
319
|
+
"snapshot_exists",
|
|
320
|
+
(namespace, proposal_set.snapshot_fingerprint),
|
|
321
|
+
).fetchone()
|
|
322
|
+
if exists is None:
|
|
323
|
+
raise SemanticCatalogError(
|
|
324
|
+
SemanticCatalogErrorCode.UNAUTHORIZED,
|
|
325
|
+
"proposal set references a snapshot not registered in this "
|
|
326
|
+
"tenant scope",
|
|
327
|
+
details={"cause_type": "UnknownSnapshot"},
|
|
328
|
+
)
|
|
329
|
+
self._uow.execute(
|
|
330
|
+
conn,
|
|
331
|
+
"upsert_proposal_set",
|
|
332
|
+
(
|
|
333
|
+
namespace,
|
|
334
|
+
proposal_set.snapshot_fingerprint,
|
|
335
|
+
ENVELOPE_SCHEMA_VERSION,
|
|
336
|
+
envelope,
|
|
337
|
+
now,
|
|
338
|
+
),
|
|
339
|
+
)
|
|
340
|
+
self._uow.insert_event(
|
|
341
|
+
conn,
|
|
342
|
+
"proposal_set_saved",
|
|
343
|
+
proposal_set.snapshot_fingerprint,
|
|
344
|
+
namespace=namespace,
|
|
345
|
+
occurred_at=now,
|
|
346
|
+
)
|
|
347
|
+
|
|
348
|
+
def proposal_set(
|
|
349
|
+
self,
|
|
350
|
+
snapshot_fingerprint: str,
|
|
351
|
+
*,
|
|
352
|
+
tenant_scope_fingerprint: str,
|
|
353
|
+
) -> SemanticProposalSet | None:
|
|
354
|
+
"""The persisted proposal set for one snapshot, or ``None``."""
|
|
355
|
+
namespace = _namespace(tenant_scope_fingerprint)
|
|
356
|
+
with self._uow.transaction() as conn:
|
|
357
|
+
row = self._uow.execute(
|
|
358
|
+
conn,
|
|
359
|
+
"read_proposal_set",
|
|
360
|
+
(namespace, snapshot_fingerprint),
|
|
361
|
+
).fetchone()
|
|
362
|
+
if row is None:
|
|
363
|
+
return None
|
|
364
|
+
envelope = self._uow.decode(
|
|
365
|
+
row["envelope"],
|
|
366
|
+
ArtifactKind.PROPOSAL_SET,
|
|
367
|
+
row_schema_version=row["schema_version"],
|
|
368
|
+
)
|
|
369
|
+
return self._uow.proposal_set_from_envelope(envelope)
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
__all__ = ["SnapshotRepository"]
|