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.
@@ -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"]])
@@ -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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ pgchangefeed