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,188 @@
|
|
|
1
|
+
"""Normalized safe errors for the PostgreSQL semantic catalog.
|
|
2
|
+
|
|
3
|
+
Backend failures are converted into deterministic, serializable error
|
|
4
|
+
records. Records carry only a stable code, category, bounded message, and
|
|
5
|
+
redacted details - never DSNs, credentials, raw backend exception text, or
|
|
6
|
+
native driver objects. Unavailability and timeouts are retryable; schema
|
|
7
|
+
mismatches, conflicts, authorization failures, envelope rejections,
|
|
8
|
+
fingerprint mismatches, and bound violations are not, because retrying them
|
|
9
|
+
without a host decision cannot succeed.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from collections.abc import Mapping
|
|
15
|
+
from enum import StrEnum
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
from nl2data._redact import REDACTED_VALUE, redact_key_value
|
|
19
|
+
from nl2data.errors import ErrorCategory, ErrorCode, ErrorRecord
|
|
20
|
+
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
|
21
|
+
|
|
22
|
+
from .envelope import EnvelopeRejectedError
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class SemanticCatalogErrorCode(StrEnum):
|
|
26
|
+
"""Stable machine-readable catalog error codes."""
|
|
27
|
+
|
|
28
|
+
CATALOG_UNAVAILABLE = "CATALOG_UNAVAILABLE"
|
|
29
|
+
CATALOG_TIMEOUT = "CATALOG_TIMEOUT"
|
|
30
|
+
SCHEMA_MISMATCH = "SCHEMA_MISMATCH"
|
|
31
|
+
CONFLICT = "CONFLICT"
|
|
32
|
+
UNAUTHORIZED = "UNAUTHORIZED"
|
|
33
|
+
ENVELOPE_REJECTED = "ENVELOPE_REJECTED"
|
|
34
|
+
FINGERPRINT_MISMATCH = "FINGERPRINT_MISMATCH"
|
|
35
|
+
BOUNDS_EXCEEDED = "BOUNDS_EXCEEDED"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
#: Codes that are safe to retry without changing the invocation.
|
|
39
|
+
_RETRYABLE_CODES = frozenset(
|
|
40
|
+
{
|
|
41
|
+
SemanticCatalogErrorCode.CATALOG_UNAVAILABLE,
|
|
42
|
+
SemanticCatalogErrorCode.CATALOG_TIMEOUT,
|
|
43
|
+
}
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
#: Mapping onto the stable public error contract for public outcomes.
|
|
47
|
+
_PUBLIC_CODE: dict[SemanticCatalogErrorCode, ErrorCode] = {
|
|
48
|
+
SemanticCatalogErrorCode.CATALOG_UNAVAILABLE: ErrorCode.STORE_UNAVAILABLE,
|
|
49
|
+
SemanticCatalogErrorCode.CATALOG_TIMEOUT: ErrorCode.STORE_TIMEOUT,
|
|
50
|
+
SemanticCatalogErrorCode.SCHEMA_MISMATCH: ErrorCode.UNSUPPORTED_SCHEMA_VERSION,
|
|
51
|
+
SemanticCatalogErrorCode.CONFLICT: ErrorCode.INVALID_TRANSITION,
|
|
52
|
+
SemanticCatalogErrorCode.UNAUTHORIZED: ErrorCode.METADATA_UNAUTHORIZED,
|
|
53
|
+
SemanticCatalogErrorCode.ENVELOPE_REJECTED: ErrorCode.INVALID_INPUT,
|
|
54
|
+
SemanticCatalogErrorCode.FINGERPRINT_MISMATCH: ErrorCode.INVALID_INPUT,
|
|
55
|
+
SemanticCatalogErrorCode.BOUNDS_EXCEEDED: ErrorCode.METADATA_BOUNDS_EXCEEDED,
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class SemanticCatalogError(Exception):
|
|
60
|
+
"""Structured catalog failure; safe to serialize.
|
|
61
|
+
|
|
62
|
+
The original driver exception may be retained for debugging but is
|
|
63
|
+
never part of the serialized record and never leaks through ``str``.
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
def __init__(
|
|
67
|
+
self,
|
|
68
|
+
code: SemanticCatalogErrorCode | str,
|
|
69
|
+
message: str,
|
|
70
|
+
*,
|
|
71
|
+
details: Mapping[str, Any] | None = None,
|
|
72
|
+
cause: BaseException | None = None,
|
|
73
|
+
) -> None:
|
|
74
|
+
super().__init__(message)
|
|
75
|
+
self.code = SemanticCatalogErrorCode(code)
|
|
76
|
+
self.message = message
|
|
77
|
+
self.details = dict(details or {})
|
|
78
|
+
self.cause = cause
|
|
79
|
+
|
|
80
|
+
@property
|
|
81
|
+
def category(self) -> ErrorCategory:
|
|
82
|
+
return ErrorCategory.ADAPTER
|
|
83
|
+
|
|
84
|
+
@property
|
|
85
|
+
def retryable(self) -> bool:
|
|
86
|
+
return self.code in _RETRYABLE_CODES
|
|
87
|
+
|
|
88
|
+
def safe_details(self) -> dict[str, str]:
|
|
89
|
+
"""Scalar-only details; anything unsafe is replaced with a marker."""
|
|
90
|
+
return {str(k): redact_key_value(k, v) for k, v in self.details.items()}
|
|
91
|
+
|
|
92
|
+
def to_record(self) -> SemanticCatalogErrorRecord:
|
|
93
|
+
"""Convert to the transport-neutral safe catalog error record."""
|
|
94
|
+
return SemanticCatalogErrorRecord(
|
|
95
|
+
code=self.code,
|
|
96
|
+
message=self.message,
|
|
97
|
+
retryable=self.retryable,
|
|
98
|
+
details=self.safe_details(),
|
|
99
|
+
cause_type=type(self.cause).__name__ if self.cause is not None else None,
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
def to_public_record(self) -> ErrorRecord:
|
|
103
|
+
"""Convert to the public error contract used by host outcomes."""
|
|
104
|
+
return ErrorRecord(
|
|
105
|
+
code=_PUBLIC_CODE[self.code],
|
|
106
|
+
category=self.category,
|
|
107
|
+
message=self.message,
|
|
108
|
+
retryable=self.retryable,
|
|
109
|
+
details=self.safe_details(),
|
|
110
|
+
cause_type=type(self).__name__,
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class SemanticCatalogErrorRecord(BaseModel):
|
|
115
|
+
"""Immutable normalized catalog error with redacted details only."""
|
|
116
|
+
|
|
117
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
118
|
+
|
|
119
|
+
code: SemanticCatalogErrorCode
|
|
120
|
+
message: str = Field(min_length=1, max_length=2000)
|
|
121
|
+
retryable: bool = False
|
|
122
|
+
details: dict[str, str] = Field(default_factory=dict, max_length=64)
|
|
123
|
+
cause_type: str | None = Field(default=None, max_length=256)
|
|
124
|
+
|
|
125
|
+
@field_validator("details")
|
|
126
|
+
@classmethod
|
|
127
|
+
def _sanitize_details(cls, value: Mapping[str, Any]) -> dict[str, str]:
|
|
128
|
+
sanitized: dict[str, str] = {}
|
|
129
|
+
for key, raw in value.items():
|
|
130
|
+
sanitized[key] = redact_key_value(key, raw)
|
|
131
|
+
return sanitized
|
|
132
|
+
|
|
133
|
+
def safe_dump(self) -> dict[str, Any]:
|
|
134
|
+
"""Serialize with only stable, redacted fields - never raw backend text."""
|
|
135
|
+
return self.model_dump()
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def normalize_catalog_error(error: BaseException) -> SemanticCatalogError:
|
|
139
|
+
"""Convert any exception into a normalized, safe catalog error.
|
|
140
|
+
|
|
141
|
+
Envelope rejections keep their bounded reason code; known driver failure
|
|
142
|
+
names become retryable unavailability/timeout errors; unknown exception
|
|
143
|
+
types become retryable unavailability errors with a redacted message so
|
|
144
|
+
backend internals never leak across the boundary.
|
|
145
|
+
"""
|
|
146
|
+
if isinstance(error, SemanticCatalogError):
|
|
147
|
+
return error
|
|
148
|
+
if isinstance(error, EnvelopeRejectedError):
|
|
149
|
+
return SemanticCatalogError(
|
|
150
|
+
SemanticCatalogErrorCode.ENVELOPE_REJECTED,
|
|
151
|
+
"catalog artifact was rejected by safe envelope validation",
|
|
152
|
+
details={"reason": error.code, "cause_type": type(error).__name__},
|
|
153
|
+
cause=error,
|
|
154
|
+
)
|
|
155
|
+
name = type(error).__name__
|
|
156
|
+
if name in {"TimeoutError", "QueryCanceledError", "QueryCanceled", "Timeout"}:
|
|
157
|
+
return SemanticCatalogError(
|
|
158
|
+
SemanticCatalogErrorCode.CATALOG_TIMEOUT,
|
|
159
|
+
"catalog backend command timed out",
|
|
160
|
+
details={"cause_type": name},
|
|
161
|
+
cause=error,
|
|
162
|
+
)
|
|
163
|
+
if name in {
|
|
164
|
+
"OperationalError",
|
|
165
|
+
"InterfaceError",
|
|
166
|
+
"ConnectionError",
|
|
167
|
+
"PoolTimeout",
|
|
168
|
+
"PoolClosed",
|
|
169
|
+
}:
|
|
170
|
+
return SemanticCatalogError(
|
|
171
|
+
SemanticCatalogErrorCode.CATALOG_UNAVAILABLE,
|
|
172
|
+
"catalog backend is unreachable",
|
|
173
|
+
details={"cause_type": name},
|
|
174
|
+
cause=error,
|
|
175
|
+
)
|
|
176
|
+
if name in {"UniqueViolation", "IntegrityError", "SerializationFailure"}:
|
|
177
|
+
return SemanticCatalogError(
|
|
178
|
+
SemanticCatalogErrorCode.CONFLICT,
|
|
179
|
+
"catalog backend rejected a conflicting record",
|
|
180
|
+
details={"cause_type": name},
|
|
181
|
+
cause=error,
|
|
182
|
+
)
|
|
183
|
+
return SemanticCatalogError(
|
|
184
|
+
SemanticCatalogErrorCode.CATALOG_UNAVAILABLE,
|
|
185
|
+
REDACTED_VALUE,
|
|
186
|
+
details={"cause_type": name},
|
|
187
|
+
cause=error,
|
|
188
|
+
)
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""In-memory PostgreSQL pool for the durable semantic catalog.
|
|
2
|
+
|
|
3
|
+
Executes the exact statement set produced by :data:`SQL_TEMPLATES` against
|
|
4
|
+
plain Python dictionaries, with per-connection transactions, row locks
|
|
5
|
+
(``FOR UPDATE`` / conflict waits), a mutable server clock driving ``NOW()``,
|
|
6
|
+
and failure injection so catalog behavior under backend outages can be
|
|
7
|
+
tested without a real database or the optional driver.
|
|
8
|
+
|
|
9
|
+
The fake mirrors the semantics the store relies on:
|
|
10
|
+
|
|
11
|
+
- every ``connection()`` checkout is one transaction; ``commit`` publishes
|
|
12
|
+
mutations, ``rollback`` restores the pre-transaction values of touched
|
|
13
|
+
rows;
|
|
14
|
+
- rows locked by one connection block conflicting statements from another
|
|
15
|
+
connection (bounded by the statement timeout), matching PostgreSQL row
|
|
16
|
+
locking closely enough for concurrency tests;
|
|
17
|
+
- ``NOW()`` always reads the shared :class:`FakeClock`, so retention expiry
|
|
18
|
+
and cleanup can be advanced deterministically;
|
|
19
|
+
- injected failure exceptions surface with the same class names the lazy
|
|
20
|
+
driver boundary classifies (``OperationalError``, ``TimeoutError``,
|
|
21
|
+
``UniqueViolation``, ``SerializationFailure``), so the store's error
|
|
22
|
+
normalization path is exercised for real.
|
|
23
|
+
|
|
24
|
+
Unrecognized statements fail loudly with ``AssertionError`` so template
|
|
25
|
+
drift between the store and the fake is caught at the first test run.
|
|
26
|
+
|
|
27
|
+
The pool is split by repository domain: shared driver, lock/key, and
|
|
28
|
+
transaction infrastructure live in :mod:`.driver`, :mod:`.keys`, and
|
|
29
|
+
:mod:`.pool`; statement handlers live in one module per repository domain
|
|
30
|
+
and are wired together in :mod:`.registry`.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
from __future__ import annotations
|
|
34
|
+
|
|
35
|
+
from .driver import (
|
|
36
|
+
FakeClock,
|
|
37
|
+
OperationalError,
|
|
38
|
+
PoolClosed,
|
|
39
|
+
SerializationFailure,
|
|
40
|
+
TimeoutError,
|
|
41
|
+
UniqueViolation,
|
|
42
|
+
)
|
|
43
|
+
from .pool import FakePostgresPool
|
|
44
|
+
|
|
45
|
+
__all__ = [
|
|
46
|
+
"FakeClock",
|
|
47
|
+
"FakePostgresPool",
|
|
48
|
+
"OperationalError",
|
|
49
|
+
"PoolClosed",
|
|
50
|
+
"SerializationFailure",
|
|
51
|
+
"TimeoutError",
|
|
52
|
+
"UniqueViolation",
|
|
53
|
+
]
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
"""Shared fake-driver infrastructure: exceptions, clock, transactions.
|
|
2
|
+
|
|
3
|
+
The fake driver exception classes intentionally match the real psycopg
|
|
4
|
+
class names so the store's class-name-first error classification works.
|
|
5
|
+
Connections are one transaction each: mutations are published on commit
|
|
6
|
+
and undone on rollback from the pre-transaction snapshots taken by
|
|
7
|
+
``_touch``.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import builtins
|
|
13
|
+
import contextlib
|
|
14
|
+
import re
|
|
15
|
+
import time
|
|
16
|
+
from collections.abc import Iterator
|
|
17
|
+
from datetime import UTC, datetime, timedelta
|
|
18
|
+
from typing import TYPE_CHECKING, Any
|
|
19
|
+
from uuid import uuid4
|
|
20
|
+
|
|
21
|
+
if TYPE_CHECKING:
|
|
22
|
+
from .pool import FakePostgresPool
|
|
23
|
+
|
|
24
|
+
#: Lock-key kind -> pool attribute holding the backing table (per instance).
|
|
25
|
+
# Lives here (its only consumer) instead of keys.py so the driver->keys
|
|
26
|
+
# import edge does not close a cycle with keys' TYPE_CHECKING driver import.
|
|
27
|
+
_TABLE_ATTRS: dict[str, str] = {
|
|
28
|
+
"snapshots": "snapshots",
|
|
29
|
+
"snapshot_pointers": "snapshot_pointers",
|
|
30
|
+
"proposal_sets": "proposal_sets",
|
|
31
|
+
"assembly_drafts": "assembly_drafts",
|
|
32
|
+
"publications": "publications",
|
|
33
|
+
"accepted_manifests": "accepted_manifests",
|
|
34
|
+
"verification_evidence": "verification_evidence",
|
|
35
|
+
"publish_audits": "publish_audits",
|
|
36
|
+
"published_versions": "published_versions",
|
|
37
|
+
"supersession_edges": "supersession_edges",
|
|
38
|
+
"bundle_pointers": "bundle_pointers",
|
|
39
|
+
"bundle_history": "bundle_history",
|
|
40
|
+
"events": "events",
|
|
41
|
+
"publication_audit_evidence": "publication_audit_evidence",
|
|
42
|
+
"audit_entries": "audit_entries",
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class OperationalError(Exception):
|
|
47
|
+
"""Fake ``psycopg.OperationalError``: connection-level failure."""
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class TimeoutError(builtins.TimeoutError): # noqa: A001 - fake driver class by design
|
|
51
|
+
"""Fake ``psycopg.errors.QueryCanceled``: statement timed out."""
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class UniqueViolation(Exception):
|
|
55
|
+
"""Fake ``psycopg.errors.UniqueViolation``: unique-key conflict."""
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class SerializationFailure(Exception):
|
|
59
|
+
"""Fake ``psycopg.errors.SerializationFailure``: retryable conflict."""
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class PoolClosed(Exception):
|
|
63
|
+
"""Fake ``psycopg_pool.PoolClosed``: pool was closed."""
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class _PassFailure:
|
|
67
|
+
"""Internal marker: a statement slot that succeeds before a failure."""
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
_SCHEMA_TOKEN = re.compile(r'"[A-Za-z0-9_]+"')
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _normalize(sql: str) -> str:
|
|
74
|
+
"""Collapse whitespace and mask the quoted schema for template matching."""
|
|
75
|
+
return _SCHEMA_TOKEN.sub('"S"', " ".join(sql.split()))
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _as_dt(value: Any) -> datetime | None:
|
|
79
|
+
"""Accept datetimes or ISO strings (as the store sends them)."""
|
|
80
|
+
if value is None or isinstance(value, datetime):
|
|
81
|
+
return value
|
|
82
|
+
return datetime.fromisoformat(str(value))
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _monotonic() -> float:
|
|
86
|
+
return time.monotonic()
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class FakeClock:
|
|
90
|
+
"""Mutable deterministic clock standing in for PostgreSQL ``NOW()``."""
|
|
91
|
+
|
|
92
|
+
def __init__(self, start: datetime | None = None) -> None:
|
|
93
|
+
self._now = start or datetime(2026, 1, 1, tzinfo=UTC)
|
|
94
|
+
|
|
95
|
+
def now(self) -> datetime:
|
|
96
|
+
"""The current fake server time (timezone-aware, UTC)."""
|
|
97
|
+
return self._now
|
|
98
|
+
|
|
99
|
+
def advance(self, seconds: float) -> None:
|
|
100
|
+
"""Move the clock forward by ``seconds`` (may be negative)."""
|
|
101
|
+
self._now += timedelta(seconds=seconds)
|
|
102
|
+
|
|
103
|
+
def set(self, value: datetime) -> None:
|
|
104
|
+
"""Pin the clock to an exact instant (tests seed expiry)."""
|
|
105
|
+
self._now = value
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class _FakeConnection:
|
|
109
|
+
"""One transaction; mutations are published on commit, undone on rollback."""
|
|
110
|
+
|
|
111
|
+
def __init__(self, pool: FakePostgresPool) -> None:
|
|
112
|
+
self._pool = pool
|
|
113
|
+
self._active = False
|
|
114
|
+
self._touched: dict[tuple[Any, ...], Any] = {}
|
|
115
|
+
self._owned_keys: set[tuple[Any, ...]] = set()
|
|
116
|
+
self.id = f"conn-{uuid4().hex[:12]}"
|
|
117
|
+
|
|
118
|
+
def cursor(self) -> _FakeCursor:
|
|
119
|
+
return _FakeCursor(self._pool, self, [], 0)
|
|
120
|
+
|
|
121
|
+
def execute(
|
|
122
|
+
self, sql: str, params: tuple[Any, ...] = (), timeout: float = 30.0
|
|
123
|
+
) -> _FakeCursor:
|
|
124
|
+
return self._pool._execute(self, sql, params, timeout)
|
|
125
|
+
|
|
126
|
+
def commit(self) -> None:
|
|
127
|
+
"""Publish the transaction; touched rows become visible to all."""
|
|
128
|
+
self._active = False
|
|
129
|
+
self._touched.clear()
|
|
130
|
+
self._pool._release(self)
|
|
131
|
+
|
|
132
|
+
def rollback(self) -> None:
|
|
133
|
+
"""Undo every mutation this transaction made."""
|
|
134
|
+
if not self._active:
|
|
135
|
+
self._pool._release(self)
|
|
136
|
+
return
|
|
137
|
+
for key, previous in self._touched.items():
|
|
138
|
+
table = getattr(self._pool, _TABLE_ATTRS[key[0]])
|
|
139
|
+
table_key = key[1:]
|
|
140
|
+
if previous is None:
|
|
141
|
+
table.pop(table_key, None)
|
|
142
|
+
else:
|
|
143
|
+
table[table_key] = previous
|
|
144
|
+
self._active = False
|
|
145
|
+
self._touched.clear()
|
|
146
|
+
self._pool._release(self)
|
|
147
|
+
|
|
148
|
+
def _finish(self) -> None:
|
|
149
|
+
"""Context-manager exit: drop any uncommitted transaction."""
|
|
150
|
+
if self._active:
|
|
151
|
+
self.rollback()
|
|
152
|
+
self._pool._release(self)
|
|
153
|
+
|
|
154
|
+
def _touch(self, key: tuple[Any, ...]) -> None:
|
|
155
|
+
"""Remember the pre-transaction value of a key before mutating it."""
|
|
156
|
+
if key in self._touched:
|
|
157
|
+
return
|
|
158
|
+
table = getattr(self._pool, _TABLE_ATTRS[key[0]])
|
|
159
|
+
value = table.get(key[1:])
|
|
160
|
+
if isinstance(value, dict):
|
|
161
|
+
# Snapshot copies so later in-place updates stay reversible.
|
|
162
|
+
value = {
|
|
163
|
+
sub_key: dict(sub_row) if isinstance(sub_row, dict) else sub_row
|
|
164
|
+
for sub_key, sub_row in value.items()
|
|
165
|
+
}
|
|
166
|
+
self._touched[key] = value
|
|
167
|
+
self._active = True
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
class _FakeCursor:
|
|
171
|
+
"""Cursor-shaped result of one executed statement."""
|
|
172
|
+
|
|
173
|
+
def __init__(
|
|
174
|
+
self,
|
|
175
|
+
pool: FakePostgresPool,
|
|
176
|
+
connection: _FakeConnection,
|
|
177
|
+
rows: list[dict[str, Any]],
|
|
178
|
+
rowcount: int,
|
|
179
|
+
) -> None:
|
|
180
|
+
self._pool = pool
|
|
181
|
+
self._connection = connection
|
|
182
|
+
self._rows = list(rows)
|
|
183
|
+
self.rowcount = rowcount
|
|
184
|
+
self.timeout: float = 30.0
|
|
185
|
+
|
|
186
|
+
def execute(
|
|
187
|
+
self, sql: str, params: tuple[Any, ...] = ()
|
|
188
|
+
) -> _FakeCursor:
|
|
189
|
+
return self._pool._execute(self._connection, sql, params, self.timeout)
|
|
190
|
+
|
|
191
|
+
def fetchone(self) -> dict[str, Any] | None:
|
|
192
|
+
return self._rows.pop(0) if self._rows else None
|
|
193
|
+
|
|
194
|
+
def fetchall(self) -> list[dict[str, Any]]:
|
|
195
|
+
rows, self._rows = self._rows, []
|
|
196
|
+
return rows
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
@contextlib.contextmanager
|
|
200
|
+
def _connection(pool: FakePostgresPool) -> Iterator[_FakeConnection]:
|
|
201
|
+
"""One transaction-backed connection; ``commit``/``rollback`` explicit."""
|
|
202
|
+
with pool._struct_lock:
|
|
203
|
+
if pool._closed:
|
|
204
|
+
raise PoolClosed("fake pool is closed")
|
|
205
|
+
connection = _FakeConnection(pool)
|
|
206
|
+
try:
|
|
207
|
+
yield connection
|
|
208
|
+
finally:
|
|
209
|
+
connection._finish()
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
"""Assembly audit-evidence statement handlers for the fake pool.
|
|
2
|
+
|
|
3
|
+
Writers lock the key slot before checking existence so concurrent writers
|
|
4
|
+
serialize like the real unique indexes. The bounded trail handlers mirror
|
|
5
|
+
the real SQL's optional-filter and keyset-cursor semantics exactly: an
|
|
6
|
+
unbound equality filter matches every row (the SQL guard is
|
|
7
|
+
``(%s IS NULL OR col = %s)``), and the cursor keyset resumes strictly
|
|
8
|
+
after ``(occurred_at, event_id)``.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
from typing import TYPE_CHECKING, Any
|
|
15
|
+
|
|
16
|
+
from .driver import UniqueViolation, _as_dt
|
|
17
|
+
from .keys import (
|
|
18
|
+
_audit_entry_key,
|
|
19
|
+
_lock_or_fail,
|
|
20
|
+
_publication_audit_evidence_key,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
if TYPE_CHECKING:
|
|
24
|
+
from .driver import _FakeConnection
|
|
25
|
+
from .pool import FakePostgresPool
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _h_insert_publication_audit_evidence(
|
|
29
|
+
pool: FakePostgresPool, conn: _FakeConnection, params: tuple[Any, ...], timeout: float
|
|
30
|
+
) -> tuple[list[dict[str, Any]], int]:
|
|
31
|
+
key = _publication_audit_evidence_key(params[0], params[1], params[2])
|
|
32
|
+
_lock_or_fail(pool, conn, key, timeout)
|
|
33
|
+
if key[1:] in pool.publication_audit_evidence:
|
|
34
|
+
raise UniqueViolation("publication audit evidence already exists")
|
|
35
|
+
if any(
|
|
36
|
+
row["scope_namespace"] == params[0]
|
|
37
|
+
and row["evidence_fingerprint"] == params[3]
|
|
38
|
+
for row in pool.publication_audit_evidence.values()
|
|
39
|
+
):
|
|
40
|
+
raise UniqueViolation("publication audit evidence fingerprint already exists")
|
|
41
|
+
conn._touch(key)
|
|
42
|
+
pool.publication_audit_evidence[key[1:]] = {
|
|
43
|
+
"scope_namespace": params[0],
|
|
44
|
+
"bundle_id": params[1],
|
|
45
|
+
"bundle_fingerprint": params[2],
|
|
46
|
+
"evidence_fingerprint": params[3],
|
|
47
|
+
"schema_version": params[4],
|
|
48
|
+
"envelope": params[5],
|
|
49
|
+
"created_at": _as_dt(params[6]),
|
|
50
|
+
}
|
|
51
|
+
return ([], 1)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _h_read_publication_audit_evidence(
|
|
55
|
+
pool: FakePostgresPool, conn: _FakeConnection, params: tuple[Any, ...], timeout: float
|
|
56
|
+
) -> tuple[list[dict[str, Any]], int]:
|
|
57
|
+
row = pool.publication_audit_evidence.get(params)
|
|
58
|
+
if row is None:
|
|
59
|
+
return ([], 0)
|
|
60
|
+
return (
|
|
61
|
+
[
|
|
62
|
+
{
|
|
63
|
+
"evidence_fingerprint": row["evidence_fingerprint"],
|
|
64
|
+
"envelope": row["envelope"],
|
|
65
|
+
"schema_version": row["schema_version"],
|
|
66
|
+
}
|
|
67
|
+
],
|
|
68
|
+
0,
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _h_insert_audit_entry(
|
|
73
|
+
pool: FakePostgresPool, conn: _FakeConnection, params: tuple[Any, ...], timeout: float
|
|
74
|
+
) -> tuple[list[dict[str, Any]], int]:
|
|
75
|
+
namespace, event_id = params[0], params[1]
|
|
76
|
+
key = _audit_entry_key(namespace, event_id)
|
|
77
|
+
_lock_or_fail(pool, conn, key, timeout)
|
|
78
|
+
if (namespace, event_id) in pool.audit_entries:
|
|
79
|
+
return ([], 0) # ON CONFLICT (scope_namespace, event_id) DO NOTHING
|
|
80
|
+
conn._touch(key)
|
|
81
|
+
pool.audit_entries[(namespace, event_id)] = {
|
|
82
|
+
"scope_namespace": namespace,
|
|
83
|
+
"event_id": event_id,
|
|
84
|
+
"event_kind": params[2],
|
|
85
|
+
"subject_kind": params[3],
|
|
86
|
+
"subject_reference": params[4],
|
|
87
|
+
"draft_id": params[5],
|
|
88
|
+
"draft_revision": params[6],
|
|
89
|
+
"assertion_id": params[7],
|
|
90
|
+
"bundle_fingerprint": params[8],
|
|
91
|
+
"lifecycle_reference": params[9],
|
|
92
|
+
"entry_fingerprint": params[10],
|
|
93
|
+
"schema_version": params[11],
|
|
94
|
+
"envelope": params[12],
|
|
95
|
+
"occurred_at": _as_dt(params[13]),
|
|
96
|
+
}
|
|
97
|
+
return ([], 1)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _h_read_audit_entry(
|
|
101
|
+
pool: FakePostgresPool, conn: _FakeConnection, params: tuple[Any, ...], timeout: float
|
|
102
|
+
) -> tuple[list[dict[str, Any]], int]:
|
|
103
|
+
row = pool.audit_entries.get(params)
|
|
104
|
+
if row is None:
|
|
105
|
+
return ([], 0)
|
|
106
|
+
return (
|
|
107
|
+
[
|
|
108
|
+
{
|
|
109
|
+
"entry_fingerprint": row["entry_fingerprint"],
|
|
110
|
+
"envelope": row["envelope"],
|
|
111
|
+
"schema_version": row["schema_version"],
|
|
112
|
+
"occurred_at": row["occurred_at"],
|
|
113
|
+
}
|
|
114
|
+
],
|
|
115
|
+
0,
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _h_read_publication_audit_entry(
|
|
120
|
+
pool: FakePostgresPool, conn: _FakeConnection, params: tuple[Any, ...], timeout: float
|
|
121
|
+
) -> tuple[list[dict[str, Any]], int]:
|
|
122
|
+
namespace, lifecycle_reference = params
|
|
123
|
+
for (scope, event_id), row in pool.audit_entries.items():
|
|
124
|
+
if (
|
|
125
|
+
scope == namespace
|
|
126
|
+
and row["lifecycle_reference"] == lifecycle_reference
|
|
127
|
+
and row["event_kind"] == "publication"
|
|
128
|
+
):
|
|
129
|
+
return ([{"event_id": event_id}], 0)
|
|
130
|
+
return ([], 0)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _h_read_latest_publication_entry(
|
|
134
|
+
pool: FakePostgresPool, conn: _FakeConnection, params: tuple[Any, ...], timeout: float
|
|
135
|
+
) -> tuple[list[dict[str, Any]], int]:
|
|
136
|
+
namespace, bundle_fingerprint = params
|
|
137
|
+
matches = [
|
|
138
|
+
(row["occurred_at"], event_id)
|
|
139
|
+
for (scope, event_id), row in pool.audit_entries.items()
|
|
140
|
+
if (
|
|
141
|
+
scope == namespace
|
|
142
|
+
and row["bundle_fingerprint"] == bundle_fingerprint
|
|
143
|
+
and row["event_kind"] == "publication"
|
|
144
|
+
)
|
|
145
|
+
]
|
|
146
|
+
if not matches:
|
|
147
|
+
return ([], 0)
|
|
148
|
+
matches.sort(reverse=True)
|
|
149
|
+
return ([{"event_id": matches[0][1]}], 0)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _audit_entry_matches(
|
|
153
|
+
row: dict[str, Any], event_id: str, params: tuple[Any, ...]
|
|
154
|
+
) -> bool:
|
|
155
|
+
"""Mirror the shared ``count_audit_evidence``/``list_audit_evidence`` WHERE."""
|
|
156
|
+
(
|
|
157
|
+
_namespace,
|
|
158
|
+
draft_id,
|
|
159
|
+
_draft_id,
|
|
160
|
+
assertion_id,
|
|
161
|
+
_assertion_id,
|
|
162
|
+
bundle_fingerprint,
|
|
163
|
+
_bundle_fingerprint,
|
|
164
|
+
lifecycle_reference,
|
|
165
|
+
_lifecycle_reference,
|
|
166
|
+
revision_min,
|
|
167
|
+
_revision_min,
|
|
168
|
+
revision_max,
|
|
169
|
+
_revision_max,
|
|
170
|
+
predecessor_event_id,
|
|
171
|
+
_predecessor,
|
|
172
|
+
cursor_at,
|
|
173
|
+
_cursor_at,
|
|
174
|
+
cursor_id,
|
|
175
|
+
) = params
|
|
176
|
+
if draft_id is not None and row["draft_id"] != draft_id:
|
|
177
|
+
return False
|
|
178
|
+
if assertion_id is not None and row["assertion_id"] != assertion_id:
|
|
179
|
+
return False
|
|
180
|
+
if bundle_fingerprint is not None and row["bundle_fingerprint"] != bundle_fingerprint:
|
|
181
|
+
return False
|
|
182
|
+
if lifecycle_reference is not None and row["lifecycle_reference"] != lifecycle_reference:
|
|
183
|
+
return False
|
|
184
|
+
revision = row["draft_revision"]
|
|
185
|
+
if revision_min is not None and (revision is None or int(revision) < int(revision_min)):
|
|
186
|
+
return False
|
|
187
|
+
if revision_max is not None and (revision is None or int(revision) > int(revision_max)):
|
|
188
|
+
return False
|
|
189
|
+
if predecessor_event_id is not None:
|
|
190
|
+
envelope = json.loads(row["envelope"])
|
|
191
|
+
predecessors = (
|
|
192
|
+
(envelope.get("payload") or {}).get("predecessor_event_ids") or []
|
|
193
|
+
)
|
|
194
|
+
if predecessor_event_id not in predecessors:
|
|
195
|
+
return False
|
|
196
|
+
if cursor_at is not None:
|
|
197
|
+
position = (row["occurred_at"], event_id)
|
|
198
|
+
if position <= (_as_dt(cursor_at), cursor_id):
|
|
199
|
+
return False
|
|
200
|
+
return True
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _h_count_audit_evidence(
|
|
204
|
+
pool: FakePostgresPool, conn: _FakeConnection, params: tuple[Any, ...], timeout: float
|
|
205
|
+
) -> tuple[list[dict[str, Any]], int]:
|
|
206
|
+
namespace = params[0]
|
|
207
|
+
total = sum(
|
|
208
|
+
1
|
|
209
|
+
for (scope, event_id), row in pool.audit_entries.items()
|
|
210
|
+
if scope == namespace and _audit_entry_matches(row, event_id, params)
|
|
211
|
+
)
|
|
212
|
+
return ([{"total": total}], 0)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _h_list_audit_evidence(
|
|
216
|
+
pool: FakePostgresPool, conn: _FakeConnection, params: tuple[Any, ...], timeout: float
|
|
217
|
+
) -> tuple[list[dict[str, Any]], int]:
|
|
218
|
+
namespace, limit = params[0], int(params[-1])
|
|
219
|
+
matches = [
|
|
220
|
+
(
|
|
221
|
+
row["occurred_at"],
|
|
222
|
+
event_id,
|
|
223
|
+
row["entry_fingerprint"],
|
|
224
|
+
row["envelope"],
|
|
225
|
+
row["schema_version"],
|
|
226
|
+
)
|
|
227
|
+
for (scope, event_id), row in pool.audit_entries.items()
|
|
228
|
+
if scope == namespace
|
|
229
|
+
and _audit_entry_matches(row, event_id, params[:-1])
|
|
230
|
+
]
|
|
231
|
+
matches.sort()
|
|
232
|
+
rows = [
|
|
233
|
+
{
|
|
234
|
+
"event_id": event_id,
|
|
235
|
+
"entry_fingerprint": entry_fingerprint,
|
|
236
|
+
"envelope": envelope,
|
|
237
|
+
"schema_version": schema_version,
|
|
238
|
+
"occurred_at": occurred_at,
|
|
239
|
+
}
|
|
240
|
+
for occurred_at, event_id, entry_fingerprint, envelope, schema_version
|
|
241
|
+
in matches[:limit]
|
|
242
|
+
]
|
|
243
|
+
return (rows, len(rows))
|