pgchangefeed 0.1.0__tar.gz

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.
@@ -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,25 @@
1
+ # PG Change Feed — Python SDK
2
+
3
+ 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.
4
+
5
+ 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.
6
+
7
+ ## Status
8
+
9
+ 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).
10
+
11
+ 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.
12
+
13
+ ## Installation
14
+
15
+ ```
16
+ pip install pgchangefeed
17
+ ```
18
+
19
+ ## Documentation
20
+
21
+ 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.
22
+
23
+ ## License
24
+
25
+ MIT — see [LICENSE](https://github.com/pt9912/pg-change-feed/blob/main/LICENSE).
@@ -0,0 +1,37 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "pgchangefeed"
7
+ version = "0.1.0"
8
+ # README.md liegt eine Ebene hoeher (sdks/python/README.md, Sprachwurzel-
9
+ # Ebene, analog sdks/csharp/README.md) -- setuptools erlaubt fuer `readme`
10
+ # aber keinen Pfad ausserhalb des Projekt-Wurzelverzeichnisses (real geprueft:
11
+ # `DistutilsOptionError: Cannot access '.../README.md' (or anything outside
12
+ # '...')`, Plan-Nachzug §3). Der Docker-Bau (sdks/python/Dockerfile) kopiert
13
+ # die Datei deshalb an diese lokale Stelle -- dasselbe Muster wie die
14
+ # `.proto`-Einspeisung in sdks/csharp/Dockerfile (COPY an eine vom
15
+ # Projekt erwartete relative Stelle, ohne sie im committeten Baum zu
16
+ # duplizieren).
17
+ description = "Official Python client library for PG Change Feed (HTTP API)."
18
+ readme = "README.md"
19
+ license = "MIT"
20
+ authors = [{ name = "pt9912" }]
21
+ requires-python = ">=3.14"
22
+ # Einzige Fremdabhaengigkeit (ADR-0107 Festlegung 1: httpx, keine weitere,
23
+ # keine schwere Fremdabhaengigkeit) -- wird von der HTTP-API-Client-Flaeche
24
+ # (src/pgchangefeed/http_client.py) fuer die HTTP-Requests genutzt.
25
+ dependencies = [
26
+ "httpx>=0.27",
27
+ ]
28
+
29
+ [project.urls]
30
+ Homepage = "https://github.com/pt9912/pg-change-feed"
31
+ Repository = "https://github.com/pt9912/pg-change-feed"
32
+
33
+ [project.optional-dependencies]
34
+ test = ["pytest>=8"]
35
+
36
+ [tool.setuptools.packages.find]
37
+ where = ["src"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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
@@ -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"]])
@@ -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,14 @@
1
+ README.md
2
+ pyproject.toml
3
+ src/pgchangefeed/__init__.py
4
+ src/pgchangefeed/exceptions.py
5
+ src/pgchangefeed/http_client.py
6
+ src/pgchangefeed/models.py
7
+ src/pgchangefeed/options.py
8
+ src/pgchangefeed.egg-info/PKG-INFO
9
+ src/pgchangefeed.egg-info/SOURCES.txt
10
+ src/pgchangefeed.egg-info/dependency_links.txt
11
+ src/pgchangefeed.egg-info/requires.txt
12
+ src/pgchangefeed.egg-info/top_level.txt
13
+ tests/test_http_client.py
14
+ tests/test_options.py
@@ -0,0 +1,4 @@
1
+ httpx>=0.27
2
+
3
+ [test]
4
+ pytest>=8
@@ -0,0 +1 @@
1
+ pgchangefeed
@@ -0,0 +1,337 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Callable
4
+
5
+ import httpx
6
+ import pytest
7
+
8
+ from pgchangefeed.exceptions import (
9
+ PgChangeFeedBadRequestError,
10
+ PgChangeFeedForbiddenError,
11
+ PgChangeFeedMalformedResponseError,
12
+ PgChangeFeedNotFoundError,
13
+ PgChangeFeedServerError,
14
+ PgChangeFeedUnauthorizedError,
15
+ PgChangeFeedUnexpectedStatusError,
16
+ )
17
+ from pgchangefeed.http_client import PgChangeFeedHttpClient
18
+ from pgchangefeed.models import (
19
+ AcknowledgeConsumerRequest,
20
+ DisableTableRequest,
21
+ EnableTableRequest,
22
+ RegisterConsumerRequest,
23
+ RunRetentionRequest,
24
+ )
25
+ from pgchangefeed.options import ClientOptions
26
+
27
+ ADDRESS = "https://feed.example.invalid"
28
+ ADMIN_TOKEN = "admin-token"
29
+ READER_TOKEN = "reader-token"
30
+
31
+
32
+ def _make_client(
33
+ handler: Callable[[httpx.Request], httpx.Response], token: str = ADMIN_TOKEN
34
+ ) -> PgChangeFeedHttpClient:
35
+ transport = httpx.MockTransport(handler)
36
+ http_client = httpx.Client(transport=transport)
37
+ options = ClientOptions(address=ADDRESS, api_token=token)
38
+ return PgChangeFeedHttpClient(http_client, options)
39
+
40
+
41
+ def _handler_returning(status_code: int, payload: object) -> Callable[[httpx.Request], httpx.Response]:
42
+ def handler(request: httpx.Request) -> httpx.Response:
43
+ return httpx.Response(status_code, json=payload)
44
+
45
+ return handler
46
+
47
+
48
+ # --- Happy path, one per SPEC-018 capability plus ReadChanges (SPEC-022) ---
49
+
50
+
51
+ def test_register_consumer_happy_path() -> None:
52
+ def handler(request: httpx.Request) -> httpx.Response:
53
+ assert request.method == "POST"
54
+ assert request.url.path == "/consumers"
55
+ assert request.headers["authorization"] == f"Bearer {ADMIN_TOKEN}"
56
+ return httpx.Response(
57
+ 201,
58
+ json={"consumer_id": "c1", "name": "Consumer 1", "already_registered": False},
59
+ )
60
+
61
+ client = _make_client(handler)
62
+ response = client.register_consumer(RegisterConsumerRequest(consumer_id="c1", name="Consumer 1"))
63
+ assert response.consumer_id == "c1"
64
+ assert response.name == "Consumer 1"
65
+ assert response.already_registered is False
66
+
67
+
68
+ def test_acknowledge_consumer_happy_path() -> None:
69
+ def handler(request: httpx.Request) -> httpx.Response:
70
+ assert request.method == "POST"
71
+ assert request.url.path == "/consumers/acknowledge"
72
+ return httpx.Response(
73
+ 200,
74
+ json={"consumer_id": "c1", "source_id": "s1", "offset": 42},
75
+ )
76
+
77
+ client = _make_client(handler)
78
+ response = client.acknowledge_consumer(
79
+ AcknowledgeConsumerRequest(consumer_id="c1", source_id="s1", offset=42)
80
+ )
81
+ assert response.consumer_id == "c1"
82
+ assert response.source_id == "s1"
83
+ assert response.offset == 42
84
+
85
+
86
+ def test_get_consumer_position_happy_path() -> None:
87
+ def handler(request: httpx.Request) -> httpx.Response:
88
+ assert request.method == "GET"
89
+ assert request.url.path == "/consumers/position"
90
+ assert request.url.params["consumer_id"] == "c1"
91
+ return httpx.Response(
92
+ 200,
93
+ json={"consumer_id": "c1", "source_id": "s1", "offset": 7, "acknowledged": True},
94
+ )
95
+
96
+ client = _make_client(handler)
97
+ response = client.get_consumer_position("c1")
98
+ assert response.offset == 7
99
+ assert response.acknowledged is True
100
+
101
+
102
+ def test_remove_consumer_happy_path() -> None:
103
+ def handler(request: httpx.Request) -> httpx.Response:
104
+ assert request.method == "POST"
105
+ assert request.url.path == "/consumers/remove"
106
+ return httpx.Response(200, json={"consumer_id": "c1", "removed": True})
107
+
108
+ client = _make_client(handler)
109
+ response = client.remove_consumer("c1")
110
+ assert response.removed is True
111
+
112
+
113
+ def test_enable_table_happy_path() -> None:
114
+ def handler(request: httpx.Request) -> httpx.Response:
115
+ assert request.method == "POST"
116
+ assert request.url.path == "/tables/enable"
117
+ return httpx.Response(
118
+ 201,
119
+ json={
120
+ "table_id": "t1",
121
+ "source": "src",
122
+ "schema": "public",
123
+ "table": "orders",
124
+ "already_enabled": False,
125
+ },
126
+ )
127
+
128
+ client = _make_client(handler)
129
+ response = client.enable_table(
130
+ EnableTableRequest(
131
+ source="src",
132
+ schema="public",
133
+ table="orders",
134
+ table_id="t1",
135
+ schema_version_id="sv1",
136
+ version=1,
137
+ publication="pub",
138
+ )
139
+ )
140
+ assert response.table_id == "t1"
141
+ assert response.already_enabled is False
142
+
143
+
144
+ def test_disable_table_happy_path() -> None:
145
+ def handler(request: httpx.Request) -> httpx.Response:
146
+ assert request.method == "POST"
147
+ assert request.url.path == "/tables/disable"
148
+ return httpx.Response(200, json={"removed": True, "retained": False})
149
+
150
+ client = _make_client(handler)
151
+ response = client.disable_table(
152
+ DisableTableRequest(source="src", schema="public", table="orders", publication="pub")
153
+ )
154
+ assert response.removed is True
155
+ assert response.retained is False
156
+
157
+
158
+ def test_get_status_happy_path() -> None:
159
+ def handler(request: httpx.Request) -> httpx.Response:
160
+ assert request.method == "GET"
161
+ assert request.url.path == "/tables/status"
162
+ assert request.url.params["source"] == "src"
163
+ assert request.url.params["publication"] == "pub"
164
+ return httpx.Response(200, json={"enabled": True, "retained": False})
165
+
166
+ client = _make_client(handler)
167
+ response = client.get_status(source="src", schema="public", table="orders", publication="pub")
168
+ assert response.enabled is True
169
+ assert response.retained is False
170
+
171
+
172
+ def test_list_tables_happy_path() -> None:
173
+ def handler(request: httpx.Request) -> httpx.Response:
174
+ assert request.method == "GET"
175
+ assert request.url.path == "/tables"
176
+ return httpx.Response(
177
+ 200,
178
+ json={
179
+ "tables": [{"table_id": "t1", "source": "src", "schema": "public", "table": "orders"}],
180
+ "retained": [],
181
+ },
182
+ )
183
+
184
+ client = _make_client(handler)
185
+ response = client.list_tables(source="src", publication="pub")
186
+ assert len(response.tables) == 1
187
+ assert response.tables[0].table_id == "t1"
188
+ assert response.retained == []
189
+
190
+
191
+ def test_run_retention_happy_path() -> None:
192
+ def handler(request: httpx.Request) -> httpx.Response:
193
+ assert request.method == "POST"
194
+ assert request.url.path == "/retention/run"
195
+ return httpx.Response(200, json={"deleted": 3})
196
+
197
+ client = _make_client(handler)
198
+ response = client.run_retention(RunRetentionRequest(source="src", min_age_nanos=0))
199
+ assert response.deleted == 3
200
+
201
+
202
+ def test_read_changes_happy_path() -> None:
203
+ def handler(request: httpx.Request) -> httpx.Response:
204
+ assert request.method == "GET"
205
+ assert request.url.path == "/changes"
206
+ assert request.url.params["source"] == "src"
207
+ assert request.url.params["from"] == "1"
208
+ assert "limit" not in request.url.params
209
+ return httpx.Response(
210
+ 200,
211
+ json={
212
+ "changes": [
213
+ {
214
+ "commit_position": 1,
215
+ "change_id": "chg1",
216
+ "transaction_id": "tx1",
217
+ "source_table_id": "t1",
218
+ "schema": "public",
219
+ "table": "orders",
220
+ "sequence": 1,
221
+ "operation": "INSERT",
222
+ "old_image": None,
223
+ "new_image": {"id": 1},
224
+ "schema_version": "sv1",
225
+ "committed_at": "2026-09-19T00:00:00Z",
226
+ }
227
+ ]
228
+ },
229
+ )
230
+
231
+ client = _make_client(handler)
232
+ response = client.read_changes(source="src", from_=1)
233
+ assert len(response.changes) == 1
234
+ change = response.changes[0]
235
+ assert change.operation == "INSERT"
236
+ assert change.new_image == {"id": 1}
237
+ assert change.old_image is None
238
+
239
+
240
+ def test_read_changes_empty_result_is_not_an_error() -> None:
241
+ client = _make_client(_handler_returning(200, {"changes": []}))
242
+ response = client.read_changes(source="src")
243
+ assert response.changes == []
244
+
245
+
246
+ # --- Auth boundary (SPEC-018: 401 missing/unknown token, 403 reader-vs-admin) ---
247
+
248
+
249
+ def _auth_aware_handler(admin_only_paths: set[str]) -> Callable[[httpx.Request], httpx.Response]:
250
+ def handler(request: httpx.Request) -> httpx.Response:
251
+ auth = request.headers.get("authorization", "")
252
+ if not auth.startswith("Bearer "):
253
+ return httpx.Response(401, json={"error": "missing bearer token"})
254
+ token = auth.removeprefix("Bearer ")
255
+ if token not in (ADMIN_TOKEN, READER_TOKEN):
256
+ return httpx.Response(401, json={"error": "unknown bearer token"})
257
+ if request.url.path in admin_only_paths and token != ADMIN_TOKEN:
258
+ return httpx.Response(403, json={"error": "insufficient rights"})
259
+ return httpx.Response(200, json={"consumer_id": "c1", "removed": False})
260
+
261
+ return handler
262
+
263
+
264
+ def test_unknown_token_returns_typed_unauthorized_error() -> None:
265
+ client = _make_client(_auth_aware_handler({"/consumers/remove"}), token="unknown-token")
266
+ with pytest.raises(PgChangeFeedUnauthorizedError) as exc_info:
267
+ client.remove_consumer("c1")
268
+ assert exc_info.value.status_code == 401
269
+
270
+
271
+ def test_reader_token_against_admin_endpoint_returns_typed_forbidden_error() -> None:
272
+ client = _make_client(_auth_aware_handler({"/consumers/remove"}), token=READER_TOKEN)
273
+ with pytest.raises(PgChangeFeedForbiddenError) as exc_info:
274
+ client.remove_consumer("c1")
275
+ assert exc_info.value.status_code == 403
276
+
277
+
278
+ # --- Remaining status-code mapping (SPEC-018/SPEC-022: 400/404/500, defensive fallback) ---
279
+
280
+
281
+ def test_400_returns_typed_bad_request_error_with_error_message() -> None:
282
+ client = _make_client(_handler_returning(400, {"error": "source ist Pflichtfeld"}))
283
+ with pytest.raises(PgChangeFeedBadRequestError) as exc_info:
284
+ client.read_changes(source="src")
285
+ assert exc_info.value.status_code == 400
286
+ assert "Pflichtfeld" in str(exc_info.value)
287
+
288
+
289
+ def test_404_returns_typed_not_found_error() -> None:
290
+ client = _make_client(_handler_returning(404, {"error": "Tabelle existiert nicht an der Quelle"}))
291
+ with pytest.raises(PgChangeFeedNotFoundError) as exc_info:
292
+ client.get_status(source="src", schema="public", table="missing", publication="pub")
293
+ assert exc_info.value.status_code == 404
294
+
295
+
296
+ def test_500_returns_typed_server_error() -> None:
297
+ client = _make_client(_handler_returning(500, {"error": "interner Fehler"}))
298
+ with pytest.raises(PgChangeFeedServerError) as exc_info:
299
+ client.list_tables(source="src", publication="pub")
300
+ assert exc_info.value.status_code == 500
301
+
302
+
303
+ def test_undocumented_status_returns_typed_unexpected_status_error() -> None:
304
+ client = _make_client(_handler_returning(409, {"error": "conflict"}))
305
+ with pytest.raises(PgChangeFeedUnexpectedStatusError) as exc_info:
306
+ client.list_tables(source="src", publication="pub")
307
+ assert exc_info.value.status_code == 409
308
+
309
+
310
+ def test_error_body_without_error_field_falls_back_to_raw_text() -> None:
311
+ def handler(request: httpx.Request) -> httpx.Response:
312
+ return httpx.Response(400, content=b"not json at all")
313
+
314
+ client = _make_client(handler)
315
+ with pytest.raises(PgChangeFeedBadRequestError) as exc_info:
316
+ client.list_tables(source="src", publication="pub")
317
+ assert "not json at all" in str(exc_info.value)
318
+
319
+
320
+ # --- Malformed 2xx body: must raise a typed error, never a raw parsing exception ---
321
+
322
+
323
+ def test_non_json_success_body_raises_typed_malformed_response_error() -> None:
324
+ def handler(request: httpx.Request) -> httpx.Response:
325
+ return httpx.Response(200, content=b"not json at all")
326
+
327
+ client = _make_client(handler)
328
+ with pytest.raises(PgChangeFeedMalformedResponseError) as exc_info:
329
+ client.list_tables(source="src", publication="pub")
330
+ assert exc_info.value.status_code == 200
331
+
332
+
333
+ def test_success_body_missing_expected_field_raises_typed_malformed_response_error() -> None:
334
+ client = _make_client(_handler_returning(200, {"unexpected": "shape"}))
335
+ with pytest.raises(PgChangeFeedMalformedResponseError) as exc_info:
336
+ client.list_tables(source="src", publication="pub")
337
+ assert exc_info.value.status_code == 200
@@ -0,0 +1,19 @@
1
+ import pytest
2
+
3
+ from pgchangefeed import ClientOptions
4
+
5
+
6
+ def test_client_options_holds_address_and_token() -> None:
7
+ options = ClientOptions(address="https://example.invalid", api_token="secret")
8
+ assert options.address == "https://example.invalid"
9
+ assert options.api_token == "secret"
10
+
11
+
12
+ def test_client_options_rejects_empty_address() -> None:
13
+ with pytest.raises(ValueError):
14
+ ClientOptions(address="", api_token="secret")
15
+
16
+
17
+ def test_client_options_rejects_empty_token() -> None:
18
+ with pytest.raises(ValueError):
19
+ ClientOptions(address="https://example.invalid", api_token="")