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.
- pgchangefeed-0.1.0/PKG-INFO +39 -0
- pgchangefeed-0.1.0/README.md +25 -0
- pgchangefeed-0.1.0/pyproject.toml +37 -0
- pgchangefeed-0.1.0/setup.cfg +4 -0
- pgchangefeed-0.1.0/src/pgchangefeed/__init__.py +35 -0
- pgchangefeed-0.1.0/src/pgchangefeed/exceptions.py +57 -0
- pgchangefeed-0.1.0/src/pgchangefeed/http_client.py +257 -0
- pgchangefeed-0.1.0/src/pgchangefeed/models.py +260 -0
- pgchangefeed-0.1.0/src/pgchangefeed/options.py +35 -0
- pgchangefeed-0.1.0/src/pgchangefeed.egg-info/PKG-INFO +39 -0
- pgchangefeed-0.1.0/src/pgchangefeed.egg-info/SOURCES.txt +14 -0
- pgchangefeed-0.1.0/src/pgchangefeed.egg-info/dependency_links.txt +1 -0
- pgchangefeed-0.1.0/src/pgchangefeed.egg-info/requires.txt +4 -0
- pgchangefeed-0.1.0/src/pgchangefeed.egg-info/top_level.txt +1 -0
- pgchangefeed-0.1.0/tests/test_http_client.py +337 -0
- pgchangefeed-0.1.0/tests/test_options.py +19 -0
|
@@ -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,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 @@
|
|
|
1
|
+
|
|
@@ -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="")
|