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.
Files changed (34) hide show
  1. nl2data_semantic_catalog_postgres/__init__.py +18 -0
  2. nl2data_semantic_catalog_postgres/client.py +139 -0
  3. nl2data_semantic_catalog_postgres/config.py +136 -0
  4. nl2data_semantic_catalog_postgres/envelope.py +327 -0
  5. nl2data_semantic_catalog_postgres/errors.py +188 -0
  6. nl2data_semantic_catalog_postgres/fake_postgres/__init__.py +53 -0
  7. nl2data_semantic_catalog_postgres/fake_postgres/driver.py +209 -0
  8. nl2data_semantic_catalog_postgres/fake_postgres/handlers_audit.py +243 -0
  9. nl2data_semantic_catalog_postgres/fake_postgres/handlers_drafts.py +84 -0
  10. nl2data_semantic_catalog_postgres/fake_postgres/handlers_maintenance.py +263 -0
  11. nl2data_semantic_catalog_postgres/fake_postgres/handlers_publications.py +249 -0
  12. nl2data_semantic_catalog_postgres/fake_postgres/handlers_schema.py +23 -0
  13. nl2data_semantic_catalog_postgres/fake_postgres/handlers_snapshots.py +197 -0
  14. nl2data_semantic_catalog_postgres/fake_postgres/handlers_versions.py +330 -0
  15. nl2data_semantic_catalog_postgres/fake_postgres/keys.py +99 -0
  16. nl2data_semantic_catalog_postgres/fake_postgres/pool.py +152 -0
  17. nl2data_semantic_catalog_postgres/fake_postgres/registry.py +144 -0
  18. nl2data_semantic_catalog_postgres/maintenance.py +235 -0
  19. nl2data_semantic_catalog_postgres/py.typed +0 -0
  20. nl2data_semantic_catalog_postgres/repositories/__init__.py +26 -0
  21. nl2data_semantic_catalog_postgres/repositories/activation.py +849 -0
  22. nl2data_semantic_catalog_postgres/repositories/audit_evidence.py +221 -0
  23. nl2data_semantic_catalog_postgres/repositories/drafts.py +156 -0
  24. nl2data_semantic_catalog_postgres/repositories/evidence.py +415 -0
  25. nl2data_semantic_catalog_postgres/repositories/publications.py +486 -0
  26. nl2data_semantic_catalog_postgres/repositories/snapshots.py +372 -0
  27. nl2data_semantic_catalog_postgres/schema.py +337 -0
  28. nl2data_semantic_catalog_postgres/sql.py +520 -0
  29. nl2data_semantic_catalog_postgres/store.py +716 -0
  30. nl2data_semantic_catalog_postgres/unit_of_work.py +589 -0
  31. nl2data_semantic_catalog_postgres-0.1.0.dist-info/METADATA +76 -0
  32. nl2data_semantic_catalog_postgres-0.1.0.dist-info/RECORD +34 -0
  33. nl2data_semantic_catalog_postgres-0.1.0.dist-info/WHEEL +5 -0
  34. 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"]