pgchangefeed 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.
- pgchangefeed/__init__.py +35 -0
- pgchangefeed/exceptions.py +57 -0
- pgchangefeed/http_client.py +257 -0
- pgchangefeed/models.py +260 -0
- pgchangefeed/options.py +35 -0
- pgchangefeed-0.1.0.dist-info/METADATA +39 -0
- pgchangefeed-0.1.0.dist-info/RECORD +9 -0
- pgchangefeed-0.1.0.dist-info/WHEEL +5 -0
- pgchangefeed-0.1.0.dist-info/top_level.txt +1 -0
pgchangefeed/__init__.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""PG Change Feed Python client library.
|
|
2
|
+
|
|
3
|
+
This release exposes the shared connection configuration
|
|
4
|
+
(`ClientOptions`) and the HTTP API client surface (`PgChangeFeedHttpClient`):
|
|
5
|
+
the nine SPEC-018 capabilities plus reading persisted changes
|
|
6
|
+
(`ReadChanges`, SPEC-022). gRPC, SSE and NATS-Vollinhalt delivery remain
|
|
7
|
+
out of scope for this package (ADR-0107 Festlegung 1) -- a follow-up
|
|
8
|
+
release would add a separate client surface for them.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from pgchangefeed.exceptions import (
|
|
12
|
+
PgChangeFeedBadRequestError,
|
|
13
|
+
PgChangeFeedError,
|
|
14
|
+
PgChangeFeedForbiddenError,
|
|
15
|
+
PgChangeFeedMalformedResponseError,
|
|
16
|
+
PgChangeFeedNotFoundError,
|
|
17
|
+
PgChangeFeedServerError,
|
|
18
|
+
PgChangeFeedUnauthorizedError,
|
|
19
|
+
PgChangeFeedUnexpectedStatusError,
|
|
20
|
+
)
|
|
21
|
+
from pgchangefeed.http_client import PgChangeFeedHttpClient
|
|
22
|
+
from pgchangefeed.options import ClientOptions
|
|
23
|
+
|
|
24
|
+
__all__ = [
|
|
25
|
+
"ClientOptions",
|
|
26
|
+
"PgChangeFeedHttpClient",
|
|
27
|
+
"PgChangeFeedError",
|
|
28
|
+
"PgChangeFeedBadRequestError",
|
|
29
|
+
"PgChangeFeedUnauthorizedError",
|
|
30
|
+
"PgChangeFeedForbiddenError",
|
|
31
|
+
"PgChangeFeedNotFoundError",
|
|
32
|
+
"PgChangeFeedServerError",
|
|
33
|
+
"PgChangeFeedUnexpectedStatusError",
|
|
34
|
+
"PgChangeFeedMalformedResponseError",
|
|
35
|
+
]
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Typed error hierarchy for PG Change Feed HTTP/JSON API responses.
|
|
2
|
+
|
|
3
|
+
Mirrors the uniform SPEC-018 error body (``{"error": "<text>"}``) as typed
|
|
4
|
+
exceptions instead of a raw ``httpx`` exception, consistent across every
|
|
5
|
+
``PgChangeFeedHttpClient`` method -- one base class a caller can catch
|
|
6
|
+
regardless of the concrete status code.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class PgChangeFeedError(Exception):
|
|
13
|
+
"""Base type for every typed error ``PgChangeFeedHttpClient`` raises."""
|
|
14
|
+
|
|
15
|
+
def __init__(self, status_code: int, message: str) -> None:
|
|
16
|
+
super().__init__(message)
|
|
17
|
+
self.status_code = status_code
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class PgChangeFeedBadRequestError(PgChangeFeedError):
|
|
21
|
+
"""``400`` -- an invalid request body or a violated domain invariant
|
|
22
|
+
(SPEC-018/SPEC-022)."""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class PgChangeFeedUnauthorizedError(PgChangeFeedError):
|
|
26
|
+
"""``401`` -- a missing bearer token, or one that matches no configured
|
|
27
|
+
token class (SPEC-018)."""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class PgChangeFeedForbiddenError(PgChangeFeedError):
|
|
31
|
+
"""``403`` -- a known token whose rights class does not reach the
|
|
32
|
+
called endpoint, e.g. a ``reader`` token against an ``admin`` endpoint
|
|
33
|
+
(SPEC-018)."""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class PgChangeFeedNotFoundError(PgChangeFeedError):
|
|
37
|
+
"""``404`` -- the addressed table is physically missing at the source
|
|
38
|
+
(only ``EnableTable``/``DisableTable``/``GetStatus``, SPEC-018)."""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class PgChangeFeedServerError(PgChangeFeedError):
|
|
42
|
+
"""``500`` -- an unexpected internal server error (SPEC-018/SPEC-022)."""
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class PgChangeFeedUnexpectedStatusError(PgChangeFeedError):
|
|
46
|
+
"""Any non-success status code outside the five SPEC-018/SPEC-022
|
|
47
|
+
document (400/401/403/404/500) -- a defensive fallback that is itself
|
|
48
|
+
not part of the documented wire contract."""
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class PgChangeFeedMalformedResponseError(PgChangeFeedError):
|
|
52
|
+
"""A success status code (``2xx``) whose body does not match the
|
|
53
|
+
expected SPEC-018/SPEC-022 response shape -- invalid JSON, or valid
|
|
54
|
+
JSON missing an expected field. Outside every shape SPEC-018/SPEC-022
|
|
55
|
+
document; kept typed and distinct from the status-code errors above so
|
|
56
|
+
a caller can still catch ``PgChangeFeedError`` uniformly across every
|
|
57
|
+
method."""
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
"""Public entry point for the PG Change Feed HTTP/JSON API.
|
|
2
|
+
|
|
3
|
+
One method per wire capability: the nine port-covered capabilities of
|
|
4
|
+
SPEC-018 (``RegisterConsumer``, ``AcknowledgeConsumer``,
|
|
5
|
+
``GetConsumerPosition``, ``RemoveConsumer``, ``EnableTable``,
|
|
6
|
+
``DisableTable``, ``GetStatus``, ``ListTables``, ``RunRetention``) plus
|
|
7
|
+
reading persisted changes (``ReadChanges``, SPEC-022). Requests/responses
|
|
8
|
+
are typed data classes that mirror the SPEC-018/SPEC-022 JSON schemas
|
|
9
|
+
exactly (``pgchangefeed.models``); every non-success response becomes a
|
|
10
|
+
typed ``PgChangeFeedError`` subclass instead of a raw ``httpx`` exception,
|
|
11
|
+
and a success (``2xx``) response whose body does not match the documented
|
|
12
|
+
shape -- invalid JSON, or valid JSON missing an expected field -- becomes
|
|
13
|
+
a typed ``PgChangeFeedMalformedResponseError`` instead of letting a raw
|
|
14
|
+
parsing exception leak through. Both paths run through the same
|
|
15
|
+
``_handle`` helper, so this holds uniformly across every method rather
|
|
16
|
+
than being reimplemented ten times.
|
|
17
|
+
|
|
18
|
+
The ``httpx.Client`` is injected, not owned -- the caller controls its
|
|
19
|
+
lifetime, connection pooling and transport (including a
|
|
20
|
+
``httpx.MockTransport`` for tests); this type never closes it. The bearer
|
|
21
|
+
token and server address come from ``ClientOptions``, supplied at
|
|
22
|
+
construction -- no module-level or global state, a process can hold
|
|
23
|
+
several independently configured instances at once.
|
|
24
|
+
|
|
25
|
+
Draht-Kenntnis-Quelle: ``spec/pflichtenheft.md`` SPEC-018/SPEC-022
|
|
26
|
+
(direkt), im Zweifel der Go-Server-Adapter selbst (nur gelesen, nicht
|
|
27
|
+
importiert -- kein Python-Import eines privaten Baums dieses Repos) --
|
|
28
|
+
kein ``examples/python/``-Referenz-Client existiert (ADR-0107 §Kontext).
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
from typing import Any, Callable, TypeVar
|
|
34
|
+
|
|
35
|
+
import httpx
|
|
36
|
+
|
|
37
|
+
from pgchangefeed.exceptions import (
|
|
38
|
+
PgChangeFeedBadRequestError,
|
|
39
|
+
PgChangeFeedError,
|
|
40
|
+
PgChangeFeedForbiddenError,
|
|
41
|
+
PgChangeFeedMalformedResponseError,
|
|
42
|
+
PgChangeFeedNotFoundError,
|
|
43
|
+
PgChangeFeedServerError,
|
|
44
|
+
PgChangeFeedUnauthorizedError,
|
|
45
|
+
PgChangeFeedUnexpectedStatusError,
|
|
46
|
+
)
|
|
47
|
+
from pgchangefeed.models import (
|
|
48
|
+
AcknowledgeConsumerRequest,
|
|
49
|
+
AcknowledgeConsumerResponse,
|
|
50
|
+
ConsumerPositionResponse,
|
|
51
|
+
DisableTableRequest,
|
|
52
|
+
DisableTableResponse,
|
|
53
|
+
EnableTableRequest,
|
|
54
|
+
EnableTableResponse,
|
|
55
|
+
ListTablesResponse,
|
|
56
|
+
ReadChangesResponse,
|
|
57
|
+
RegisterConsumerRequest,
|
|
58
|
+
RegisterConsumerResponse,
|
|
59
|
+
RemoveConsumerResponse,
|
|
60
|
+
RunRetentionRequest,
|
|
61
|
+
RunRetentionResponse,
|
|
62
|
+
TableStatusResponse,
|
|
63
|
+
)
|
|
64
|
+
from pgchangefeed.options import ClientOptions
|
|
65
|
+
|
|
66
|
+
T = TypeVar("T")
|
|
67
|
+
|
|
68
|
+
_STATUS_TO_ERROR: dict[int, type[PgChangeFeedError]] = {
|
|
69
|
+
400: PgChangeFeedBadRequestError,
|
|
70
|
+
401: PgChangeFeedUnauthorizedError,
|
|
71
|
+
403: PgChangeFeedForbiddenError,
|
|
72
|
+
404: PgChangeFeedNotFoundError,
|
|
73
|
+
500: PgChangeFeedServerError,
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class PgChangeFeedHttpClient:
|
|
78
|
+
"""Client for the nine SPEC-018 capabilities plus ``ReadChanges`` (SPEC-022)."""
|
|
79
|
+
|
|
80
|
+
def __init__(self, client: httpx.Client, options: ClientOptions) -> None:
|
|
81
|
+
self._client = client
|
|
82
|
+
self._options = options
|
|
83
|
+
|
|
84
|
+
# --- RegisterConsumer -- POST /consumers (admin, LH-FA-CON-001) ---
|
|
85
|
+
|
|
86
|
+
def register_consumer(self, request: RegisterConsumerRequest) -> RegisterConsumerResponse:
|
|
87
|
+
body = {"consumer_id": request.consumer_id, "name": request.name}
|
|
88
|
+
return self._post("/consumers", body, RegisterConsumerResponse.from_json)
|
|
89
|
+
|
|
90
|
+
# --- AcknowledgeConsumer -- POST /consumers/acknowledge (admin, LH-FA-CON-004) ---
|
|
91
|
+
|
|
92
|
+
def acknowledge_consumer(
|
|
93
|
+
self, request: AcknowledgeConsumerRequest
|
|
94
|
+
) -> AcknowledgeConsumerResponse:
|
|
95
|
+
body = {
|
|
96
|
+
"consumer_id": request.consumer_id,
|
|
97
|
+
"source_id": request.source_id,
|
|
98
|
+
"offset": request.offset,
|
|
99
|
+
}
|
|
100
|
+
return self._post("/consumers/acknowledge", body, AcknowledgeConsumerResponse.from_json)
|
|
101
|
+
|
|
102
|
+
# --- GetConsumerPosition -- GET /consumers/position (reader|admin, LH-FA-CON-003/005) ---
|
|
103
|
+
|
|
104
|
+
def get_consumer_position(self, consumer_id: str) -> ConsumerPositionResponse:
|
|
105
|
+
return self._get(
|
|
106
|
+
"/consumers/position",
|
|
107
|
+
{"consumer_id": consumer_id},
|
|
108
|
+
ConsumerPositionResponse.from_json,
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
# --- RemoveConsumer -- POST /consumers/remove (admin, LH-FA-CON-006) ---
|
|
112
|
+
|
|
113
|
+
def remove_consumer(self, consumer_id: str) -> RemoveConsumerResponse:
|
|
114
|
+
return self._post(
|
|
115
|
+
"/consumers/remove", {"consumer_id": consumer_id}, RemoveConsumerResponse.from_json
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
# --- EnableTable -- POST /tables/enable (admin, LH-FA-CFG-001) ---
|
|
119
|
+
|
|
120
|
+
def enable_table(self, request: EnableTableRequest) -> EnableTableResponse:
|
|
121
|
+
body = {
|
|
122
|
+
"source": request.source,
|
|
123
|
+
"schema": request.schema,
|
|
124
|
+
"table": request.table,
|
|
125
|
+
"table_id": request.table_id,
|
|
126
|
+
"schema_version_id": request.schema_version_id,
|
|
127
|
+
"version": request.version,
|
|
128
|
+
"publication": request.publication,
|
|
129
|
+
}
|
|
130
|
+
return self._post("/tables/enable", body, EnableTableResponse.from_json)
|
|
131
|
+
|
|
132
|
+
# --- DisableTable -- POST /tables/disable (admin, LH-FA-CFG-002) ---
|
|
133
|
+
|
|
134
|
+
def disable_table(self, request: DisableTableRequest) -> DisableTableResponse:
|
|
135
|
+
body = {
|
|
136
|
+
"source": request.source,
|
|
137
|
+
"schema": request.schema,
|
|
138
|
+
"table": request.table,
|
|
139
|
+
"publication": request.publication,
|
|
140
|
+
}
|
|
141
|
+
return self._post("/tables/disable", body, DisableTableResponse.from_json)
|
|
142
|
+
|
|
143
|
+
# --- GetStatus -- GET /tables/status (reader|admin, LH-FA-CFG-003) ---
|
|
144
|
+
|
|
145
|
+
def get_status(
|
|
146
|
+
self, source: str, schema: str, table: str, publication: str
|
|
147
|
+
) -> TableStatusResponse:
|
|
148
|
+
return self._get(
|
|
149
|
+
"/tables/status",
|
|
150
|
+
{"source": source, "schema": schema, "table": table, "publication": publication},
|
|
151
|
+
TableStatusResponse.from_json,
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
# --- ListTables -- GET /tables (reader|admin, LH-FA-CFG-004) ---
|
|
155
|
+
|
|
156
|
+
def list_tables(self, source: str, publication: str) -> ListTablesResponse:
|
|
157
|
+
return self._get(
|
|
158
|
+
"/tables",
|
|
159
|
+
{"source": source, "publication": publication},
|
|
160
|
+
ListTablesResponse.from_json,
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
# --- RunRetention -- POST /retention/run (admin, LH-FA-RET-002..004) ---
|
|
164
|
+
|
|
165
|
+
def run_retention(self, request: RunRetentionRequest) -> RunRetentionResponse:
|
|
166
|
+
body = {"source": request.source, "min_age_nanos": request.min_age_nanos}
|
|
167
|
+
return self._post("/retention/run", body, RunRetentionResponse.from_json)
|
|
168
|
+
|
|
169
|
+
# --- ReadChanges -- GET /changes (reader|admin, SPEC-022) ---
|
|
170
|
+
|
|
171
|
+
def read_changes(
|
|
172
|
+
self,
|
|
173
|
+
source: str,
|
|
174
|
+
schema: str | None = None,
|
|
175
|
+
table: str | None = None,
|
|
176
|
+
from_: int | None = None,
|
|
177
|
+
to: int | None = None,
|
|
178
|
+
limit: int | None = None,
|
|
179
|
+
) -> ReadChangesResponse:
|
|
180
|
+
"""``from``/``to`` are ``commit_position`` values, ``from``
|
|
181
|
+
inclusive and ``to`` exclusive (SPEC-022); ``from_`` avoids
|
|
182
|
+
shadowing the Python keyword ``from`` while sending the ``from``
|
|
183
|
+
query parameter the wire contract expects. There is no default
|
|
184
|
+
``limit``.
|
|
185
|
+
"""
|
|
186
|
+
return self._get(
|
|
187
|
+
"/changes",
|
|
188
|
+
{
|
|
189
|
+
"source": source,
|
|
190
|
+
"schema": schema,
|
|
191
|
+
"table": table,
|
|
192
|
+
"from": from_,
|
|
193
|
+
"to": to,
|
|
194
|
+
"limit": limit,
|
|
195
|
+
},
|
|
196
|
+
ReadChangesResponse.from_json,
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
# --- transport plumbing ---
|
|
200
|
+
|
|
201
|
+
def _url(self, path: str) -> str:
|
|
202
|
+
return f"{self._options.address.rstrip('/')}{path}"
|
|
203
|
+
|
|
204
|
+
def _headers(self) -> dict[str, str]:
|
|
205
|
+
return {"Authorization": f"Bearer {self._options.api_token}"}
|
|
206
|
+
|
|
207
|
+
def _get(self, path: str, params: dict[str, Any], mapper: Callable[[Any], T]) -> T:
|
|
208
|
+
response = self._client.get(
|
|
209
|
+
self._url(path), params=_drop_none(params), headers=self._headers()
|
|
210
|
+
)
|
|
211
|
+
return self._handle(response, mapper)
|
|
212
|
+
|
|
213
|
+
def _post(self, path: str, body: dict[str, Any], mapper: Callable[[Any], T]) -> T:
|
|
214
|
+
response = self._client.post(self._url(path), json=body, headers=self._headers())
|
|
215
|
+
return self._handle(response, mapper)
|
|
216
|
+
|
|
217
|
+
def _handle(self, response: httpx.Response, mapper: Callable[[Any], T]) -> T:
|
|
218
|
+
if not 200 <= response.status_code < 300:
|
|
219
|
+
raise _build_error(response)
|
|
220
|
+
try:
|
|
221
|
+
payload = response.json()
|
|
222
|
+
except ValueError as exc:
|
|
223
|
+
raise PgChangeFeedMalformedResponseError(
|
|
224
|
+
response.status_code,
|
|
225
|
+
f"PG Change Feed HTTP API returned status {response.status_code} with a response "
|
|
226
|
+
"body that is not valid JSON -- a protocol violation outside SPEC-018/SPEC-022's "
|
|
227
|
+
"documented shapes.",
|
|
228
|
+
) from exc
|
|
229
|
+
try:
|
|
230
|
+
return mapper(payload)
|
|
231
|
+
except (KeyError, TypeError) as exc:
|
|
232
|
+
raise PgChangeFeedMalformedResponseError(
|
|
233
|
+
response.status_code,
|
|
234
|
+
f"PG Change Feed HTTP API returned status {response.status_code} with a response "
|
|
235
|
+
"body missing an expected SPEC-018/SPEC-022 field -- a protocol violation outside "
|
|
236
|
+
"the documented shapes.",
|
|
237
|
+
) from exc
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _drop_none(params: dict[str, Any]) -> dict[str, Any]:
|
|
241
|
+
return {key: value for key, value in params.items() if value is not None}
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _build_error(response: httpx.Response) -> PgChangeFeedError:
|
|
245
|
+
message = _extract_error_message(response)
|
|
246
|
+
error_cls = _STATUS_TO_ERROR.get(response.status_code, PgChangeFeedUnexpectedStatusError)
|
|
247
|
+
return error_cls(response.status_code, message)
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _extract_error_message(response: httpx.Response) -> str:
|
|
251
|
+
try:
|
|
252
|
+
body = response.json()
|
|
253
|
+
except ValueError:
|
|
254
|
+
return response.text
|
|
255
|
+
if isinstance(body, dict) and "error" in body:
|
|
256
|
+
return str(body["error"])
|
|
257
|
+
return response.text
|
pgchangefeed/models.py
ADDED
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
"""Typed request/response data classes mirroring the SPEC-018/SPEC-022 JSON
|
|
2
|
+
schemas exactly.
|
|
3
|
+
|
|
4
|
+
Field names and types are taken directly from spec/pflichtenheft.md
|
|
5
|
+
SPEC-018 (HTTP-API: Endpunkte und Token-Header-Form) and SPEC-022
|
|
6
|
+
(HTTP-API: Changes lesen, GET /changes) -- not from the C# sibling
|
|
7
|
+
package, which serves only as a structural reference, not a wire
|
|
8
|
+
reference (ADR-0107). Every attribute name equals the JSON field name
|
|
9
|
+
verbatim, so no case-mapping layer is needed between the two.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
# --- RegisterConsumer -- POST /consumers (admin, LH-FA-CON-001) ---
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True)
|
|
22
|
+
class RegisterConsumerRequest:
|
|
23
|
+
consumer_id: str
|
|
24
|
+
name: str
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True)
|
|
28
|
+
class RegisterConsumerResponse:
|
|
29
|
+
consumer_id: str
|
|
30
|
+
name: str
|
|
31
|
+
already_registered: bool
|
|
32
|
+
|
|
33
|
+
@classmethod
|
|
34
|
+
def from_json(cls, data: dict[str, Any]) -> RegisterConsumerResponse:
|
|
35
|
+
return cls(
|
|
36
|
+
consumer_id=data["consumer_id"],
|
|
37
|
+
name=data["name"],
|
|
38
|
+
already_registered=data["already_registered"],
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
# --- AcknowledgeConsumer -- POST /consumers/acknowledge (admin, LH-FA-CON-004) ---
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass(frozen=True)
|
|
46
|
+
class AcknowledgeConsumerRequest:
|
|
47
|
+
consumer_id: str
|
|
48
|
+
source_id: str
|
|
49
|
+
offset: int
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass(frozen=True)
|
|
53
|
+
class AcknowledgeConsumerResponse:
|
|
54
|
+
consumer_id: str
|
|
55
|
+
source_id: str
|
|
56
|
+
offset: int
|
|
57
|
+
|
|
58
|
+
@classmethod
|
|
59
|
+
def from_json(cls, data: dict[str, Any]) -> AcknowledgeConsumerResponse:
|
|
60
|
+
return cls(
|
|
61
|
+
consumer_id=data["consumer_id"],
|
|
62
|
+
source_id=data["source_id"],
|
|
63
|
+
offset=data["offset"],
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
# --- GetConsumerPosition -- GET /consumers/position (reader|admin, LH-FA-CON-003/005) ---
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@dataclass(frozen=True)
|
|
71
|
+
class ConsumerPositionResponse:
|
|
72
|
+
consumer_id: str
|
|
73
|
+
source_id: str
|
|
74
|
+
offset: int
|
|
75
|
+
acknowledged: bool
|
|
76
|
+
|
|
77
|
+
@classmethod
|
|
78
|
+
def from_json(cls, data: dict[str, Any]) -> ConsumerPositionResponse:
|
|
79
|
+
return cls(
|
|
80
|
+
consumer_id=data["consumer_id"],
|
|
81
|
+
source_id=data["source_id"],
|
|
82
|
+
offset=data["offset"],
|
|
83
|
+
acknowledged=data["acknowledged"],
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
# --- RemoveConsumer -- POST /consumers/remove (admin, LH-FA-CON-006) ---
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@dataclass(frozen=True)
|
|
91
|
+
class RemoveConsumerResponse:
|
|
92
|
+
consumer_id: str
|
|
93
|
+
removed: bool
|
|
94
|
+
|
|
95
|
+
@classmethod
|
|
96
|
+
def from_json(cls, data: dict[str, Any]) -> RemoveConsumerResponse:
|
|
97
|
+
return cls(consumer_id=data["consumer_id"], removed=data["removed"])
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
# --- EnableTable -- POST /tables/enable (admin, LH-FA-CFG-001) ---
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@dataclass(frozen=True)
|
|
104
|
+
class EnableTableRequest:
|
|
105
|
+
source: str
|
|
106
|
+
schema: str
|
|
107
|
+
table: str
|
|
108
|
+
table_id: str
|
|
109
|
+
schema_version_id: str
|
|
110
|
+
version: int
|
|
111
|
+
publication: str
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@dataclass(frozen=True)
|
|
115
|
+
class EnableTableResponse:
|
|
116
|
+
table_id: str
|
|
117
|
+
source: str
|
|
118
|
+
schema: str
|
|
119
|
+
table: str
|
|
120
|
+
already_enabled: bool
|
|
121
|
+
|
|
122
|
+
@classmethod
|
|
123
|
+
def from_json(cls, data: dict[str, Any]) -> EnableTableResponse:
|
|
124
|
+
return cls(
|
|
125
|
+
table_id=data["table_id"],
|
|
126
|
+
source=data["source"],
|
|
127
|
+
schema=data["schema"],
|
|
128
|
+
table=data["table"],
|
|
129
|
+
already_enabled=data["already_enabled"],
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
# --- DisableTable -- POST /tables/disable (admin, LH-FA-CFG-002) ---
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
@dataclass(frozen=True)
|
|
137
|
+
class DisableTableRequest:
|
|
138
|
+
source: str
|
|
139
|
+
schema: str
|
|
140
|
+
table: str
|
|
141
|
+
publication: str
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
@dataclass(frozen=True)
|
|
145
|
+
class DisableTableResponse:
|
|
146
|
+
removed: bool
|
|
147
|
+
retained: bool
|
|
148
|
+
|
|
149
|
+
@classmethod
|
|
150
|
+
def from_json(cls, data: dict[str, Any]) -> DisableTableResponse:
|
|
151
|
+
return cls(removed=data["removed"], retained=data["retained"])
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
# --- GetStatus -- GET /tables/status (reader|admin, LH-FA-CFG-003) ---
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
@dataclass(frozen=True)
|
|
158
|
+
class TableStatusResponse:
|
|
159
|
+
enabled: bool
|
|
160
|
+
retained: bool
|
|
161
|
+
|
|
162
|
+
@classmethod
|
|
163
|
+
def from_json(cls, data: dict[str, Any]) -> TableStatusResponse:
|
|
164
|
+
return cls(enabled=data["enabled"], retained=data["retained"])
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
# --- ListTables -- GET /tables (reader|admin, LH-FA-CFG-004) ---
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
@dataclass(frozen=True)
|
|
171
|
+
class TableInfo:
|
|
172
|
+
table_id: str
|
|
173
|
+
source: str
|
|
174
|
+
schema: str
|
|
175
|
+
table: str
|
|
176
|
+
|
|
177
|
+
@classmethod
|
|
178
|
+
def from_json(cls, data: dict[str, Any]) -> TableInfo:
|
|
179
|
+
return cls(
|
|
180
|
+
table_id=data["table_id"],
|
|
181
|
+
source=data["source"],
|
|
182
|
+
schema=data["schema"],
|
|
183
|
+
table=data["table"],
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
@dataclass(frozen=True)
|
|
188
|
+
class ListTablesResponse:
|
|
189
|
+
tables: list[TableInfo] = field(default_factory=list)
|
|
190
|
+
retained: list[TableInfo] = field(default_factory=list)
|
|
191
|
+
|
|
192
|
+
@classmethod
|
|
193
|
+
def from_json(cls, data: dict[str, Any]) -> ListTablesResponse:
|
|
194
|
+
return cls(
|
|
195
|
+
tables=[TableInfo.from_json(item) for item in data["tables"]],
|
|
196
|
+
retained=[TableInfo.from_json(item) for item in data["retained"]],
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
# --- RunRetention -- POST /retention/run (admin, LH-FA-RET-002..004) ---
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
@dataclass(frozen=True)
|
|
204
|
+
class RunRetentionRequest:
|
|
205
|
+
source: str
|
|
206
|
+
min_age_nanos: int
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
@dataclass(frozen=True)
|
|
210
|
+
class RunRetentionResponse:
|
|
211
|
+
deleted: int
|
|
212
|
+
|
|
213
|
+
@classmethod
|
|
214
|
+
def from_json(cls, data: dict[str, Any]) -> RunRetentionResponse:
|
|
215
|
+
return cls(deleted=data["deleted"])
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
# --- ReadChanges -- GET /changes (reader|admin, SPEC-022) ---
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
@dataclass(frozen=True)
|
|
222
|
+
class Change:
|
|
223
|
+
commit_position: int
|
|
224
|
+
change_id: str
|
|
225
|
+
transaction_id: str
|
|
226
|
+
source_table_id: str
|
|
227
|
+
schema: str
|
|
228
|
+
table: str
|
|
229
|
+
sequence: int
|
|
230
|
+
operation: str
|
|
231
|
+
old_image: Any | None
|
|
232
|
+
new_image: Any | None
|
|
233
|
+
schema_version: str
|
|
234
|
+
committed_at: str
|
|
235
|
+
|
|
236
|
+
@classmethod
|
|
237
|
+
def from_json(cls, data: dict[str, Any]) -> Change:
|
|
238
|
+
return cls(
|
|
239
|
+
commit_position=data["commit_position"],
|
|
240
|
+
change_id=data["change_id"],
|
|
241
|
+
transaction_id=data["transaction_id"],
|
|
242
|
+
source_table_id=data["source_table_id"],
|
|
243
|
+
schema=data["schema"],
|
|
244
|
+
table=data["table"],
|
|
245
|
+
sequence=data["sequence"],
|
|
246
|
+
operation=data["operation"],
|
|
247
|
+
old_image=data["old_image"],
|
|
248
|
+
new_image=data["new_image"],
|
|
249
|
+
schema_version=data["schema_version"],
|
|
250
|
+
committed_at=data["committed_at"],
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
@dataclass(frozen=True)
|
|
255
|
+
class ReadChangesResponse:
|
|
256
|
+
changes: list[Change] = field(default_factory=list)
|
|
257
|
+
|
|
258
|
+
@classmethod
|
|
259
|
+
def from_json(cls, data: dict[str, Any]) -> ReadChangesResponse:
|
|
260
|
+
return cls(changes=[Change.from_json(item) for item in data["changes"]])
|
pgchangefeed/options.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Shared connection configuration for PG Change Feed client surfaces.
|
|
2
|
+
|
|
3
|
+
ADR-0107 Festlegung 1 scopes the first Python package release to the HTTP
|
|
4
|
+
API (SPEC-018) only -- no gRPC/SSE/NATS surface exists yet in this package.
|
|
5
|
+
This class still holds only the two values every wire surface needs
|
|
6
|
+
regardless of transport (base address, bearer token), so a later surface
|
|
7
|
+
can reuse it without a breaking change to this constructor -- no
|
|
8
|
+
anticipation of endpoint methods themselves (analogy:
|
|
9
|
+
sdks/csharp/PgChangeFeed.Client/PgChangeFeedClientOptions.cs).
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True)
|
|
18
|
+
class ClientOptions:
|
|
19
|
+
"""Connection configuration for a PG Change Feed client surface.
|
|
20
|
+
|
|
21
|
+
Attributes:
|
|
22
|
+
address: Base address of the PG Change Feed server (the HTTP
|
|
23
|
+
endpoint, for the surface currently covered by this package).
|
|
24
|
+
api_token: Bearer token sent as an authorization credential
|
|
25
|
+
(SPEC-018).
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
address: str
|
|
29
|
+
api_token: str
|
|
30
|
+
|
|
31
|
+
def __post_init__(self) -> None:
|
|
32
|
+
if not self.address or not self.address.strip():
|
|
33
|
+
raise ValueError("address must not be empty")
|
|
34
|
+
if not self.api_token or not self.api_token.strip():
|
|
35
|
+
raise ValueError("api_token must not be empty")
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pgchangefeed
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Official Python client library for PG Change Feed (HTTP API).
|
|
5
|
+
Author: pt9912
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/pt9912/pg-change-feed
|
|
8
|
+
Project-URL: Repository, https://github.com/pt9912/pg-change-feed
|
|
9
|
+
Requires-Python: >=3.14
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
Requires-Dist: httpx>=0.27
|
|
12
|
+
Provides-Extra: test
|
|
13
|
+
Requires-Dist: pytest>=8; extra == "test"
|
|
14
|
+
|
|
15
|
+
# PG Change Feed — Python SDK
|
|
16
|
+
|
|
17
|
+
Official Python client library for [PG Change Feed](https://github.com/pt9912/pg-change-feed), a durable change feed system for PostgreSQL built on logical replication.
|
|
18
|
+
|
|
19
|
+
This package (`pgchangefeed`) lets a Python application consume PG Change Feed's HTTP API without implementing the wire protocol itself — see [`LH-FA-SST-009`](https://github.com/pt9912/pg-change-feed/blob/main/spec/lastenheft.md) for the requirement this SDK fulfills.
|
|
20
|
+
|
|
21
|
+
## Status
|
|
22
|
+
|
|
23
|
+
This package is at an early, pre-1.0 stage (`0.x.y`, [ADR-0107](https://github.com/pt9912/pg-change-feed/blob/main/docs/plan/adr/0107-python-pypi-zweites-sdk-package.md)). The current release provides the shared connection configuration (`ClientOptions`: server address and bearer token) and a full HTTP API client surface (`PgChangeFeedHttpClient`): consumer registration/acknowledgement/position/removal, table enable/disable, status, table listing, retention, and reading changes — the nine `SPEC-018` capabilities plus `GET /changes` (`SPEC-022`). gRPC, SSE and NATS-vollinhalt delivery remain out of scope for this package's first release and would be added by a follow-up release (`ADR-0107` Festlegung 1).
|
|
24
|
+
|
|
25
|
+
If a surface you need isn't covered yet, the direct wire protocol remains fully usable on its own — no `examples/python/` reference client exists yet (`ADR-0107` §Kontext); see the [`examples`](https://github.com/pt9912/pg-change-feed/tree/main/examples) reference clients for other languages in the main repository.
|
|
26
|
+
|
|
27
|
+
## Installation
|
|
28
|
+
|
|
29
|
+
```
|
|
30
|
+
pip install pgchangefeed
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Documentation
|
|
34
|
+
|
|
35
|
+
This README intentionally does not duplicate the wire protocol documentation. For the full picture — server setup, HTTP/gRPC/SSE/NATS delivery paths, and operational guidance — see the [project README](https://github.com/pt9912/pg-change-feed/blob/main/README.md) and the technical specification (`spec/pflichtenheft.md`, `SPEC-018`) in the main repository.
|
|
36
|
+
|
|
37
|
+
## License
|
|
38
|
+
|
|
39
|
+
MIT — see [LICENSE](https://github.com/pt9912/pg-change-feed/blob/main/LICENSE).
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
pgchangefeed/__init__.py,sha256=0HqyNS2jtcP0FsZV5f-YIc9HelEjQ7L_zPxbMktYqsc,1193
|
|
2
|
+
pgchangefeed/exceptions.py,sha256=RVmpa527IYnbDTwrPJaxVfNETjiRXvdQp0L7piAue_4,2190
|
|
3
|
+
pgchangefeed/http_client.py,sha256=LCJ1YIlrO1RXrKcJRPLvj0-y63VWPW4ov8pRoLNtO2Y,9946
|
|
4
|
+
pgchangefeed/models.py,sha256=eS8-b6U8TSq6ro39qhiJWEVOW2W0Q3-pmFXWJglSOJw,6542
|
|
5
|
+
pgchangefeed/options.py,sha256=ltSxyunEsB70nFzPSDc7O2Z8hJYm5pO-OhEXlnvcyGw,1307
|
|
6
|
+
pgchangefeed-0.1.0.dist-info/METADATA,sha256=mXet3UZWbFw64c3JvyFtm1act_MIiWaBmlAW_W1Xmj8,2502
|
|
7
|
+
pgchangefeed-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
8
|
+
pgchangefeed-0.1.0.dist-info/top_level.txt,sha256=hwai_j5aAFMy69NpZ7RI_HO6h8PBt75WJngzJObZp9Q,13
|
|
9
|
+
pgchangefeed-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
pgchangefeed
|