full-event-hub 1.1.4__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,91 @@
1
+ Metadata-Version: 2.4
2
+ Name: full-event-hub
3
+ Version: 1.1.4
4
+ Summary: Zero-dependency Python client for the event-hub webhook fan-out service
5
+ Author-email: Ravi Leal <ravi@example.com>
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/ravileal/event-hub
8
+ Project-URL: Repository, https://github.com/ravileal/event-hub
9
+ Project-URL: Issues, https://github.com/ravileal/event-hub/issues
10
+ Keywords: event-hub,webhooks,hmac,fan-out,events,sdk
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Operating System :: OS Independent
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3 :: Only
17
+ Classifier: Programming Language :: Python :: 3.9
18
+ Classifier: Programming Language :: Python :: 3.10
19
+ Classifier: Programming Language :: Python :: 3.11
20
+ Classifier: Programming Language :: Python :: 3.12
21
+ Classifier: Programming Language :: Python :: 3.13
22
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
23
+ Classifier: Topic :: Internet :: WWW/HTTP
24
+ Requires-Python: >=3.9
25
+ Description-Content-Type: text/markdown
26
+ Provides-Extra: test
27
+
28
+ # full-event-hub (Python client)
29
+
30
+ Zero-dependency Python client for [event-hub](https://github.com/ravileal/event-hub) — a
31
+ webhook fan-out service (ingest, fan-out to N subscribers, HMAC signing, retry/backoff,
32
+ attempts, retention).
33
+
34
+ Only the standard library is used (`urllib.request`, `json`, `hmac`, `hashlib`). The
35
+ package version matches the hub/image version (`ghcr.io/ravileal/event-hub`); because
36
+ they move together, the client sends the English `X-Idempotency` header that hubs
37
+ since 1.1.0 read, while a hub older than 1.1.0 ignores it.
38
+
39
+ ## Install
40
+
41
+ ```bash
42
+ pip install full-event-hub
43
+ ```
44
+
45
+ ## Publish an event
46
+
47
+ ```python
48
+ from event_hub import EventHubClient
49
+
50
+ hub = EventHubClient("http://127.0.0.1:18295")
51
+
52
+ hub.create_topic("orders", description="order lifecycle")
53
+ hub.create_subscriber("orders", "billing",
54
+ url="http://localhost:9000/hook", secret="s3cr3t",
55
+ max_attempts=3, backoff_ms=200)
56
+
57
+ resp = hub.publish_event("orders", {"order_id": 1}, idempotency_key="order-1")
58
+ print(resp.status, resp.body) # 202 {'id': 1, 'deliveries_created': 1, 'duplicado': False}
59
+ ```
60
+
61
+ `publish_event` sends the idempotency key as `X-Idempotency` (the header the hub
62
+ reads since 1.1.0; it falls back to the legacy `X-Idempotencia`). Use
63
+ `sign(secret, raw_body)` to sign the exact bytes you send.
64
+
65
+ ## Verify an incoming delivery
66
+
67
+ event-hub signs every delivery with HMAC-SHA256 of the **raw** body using the
68
+ subscriber secret. Pass the exact bytes received; re-serializing JSON breaks the HMAC.
69
+
70
+ ```python
71
+ from event_hub import verify_delivery_signature
72
+
73
+ # in your webhook receiver:
74
+ valid = verify_delivery_signature(
75
+ secret, raw_body, request.headers["X-Event-Hub-Signature"]
76
+ )
77
+ ```
78
+
79
+ `verify_delivery_signature` returns `False` for a missing header, a wrong `sha256=`
80
+ prefix, or a non-matching digest, and compares in constant time
81
+ (`hmac.compare_digest`).
82
+
83
+ ## Notes
84
+
85
+ - Idempotency header is **`X-Idempotency`** (English) since 1.1.0; the legacy
86
+ **`X-Idempotencia`** is still accepted for old producers but new code should not
87
+ send it.
88
+ - Deliveries carry `X-Event-Hub-Event`, `X-Event-Hub-Attempt` and
89
+ `X-Event-Hub-Signature` (`sha256=<hex>`).
90
+
91
+ MIT licensed.
@@ -0,0 +1,64 @@
1
+ # full-event-hub (Python client)
2
+
3
+ Zero-dependency Python client for [event-hub](https://github.com/ravileal/event-hub) — a
4
+ webhook fan-out service (ingest, fan-out to N subscribers, HMAC signing, retry/backoff,
5
+ attempts, retention).
6
+
7
+ Only the standard library is used (`urllib.request`, `json`, `hmac`, `hashlib`). The
8
+ package version matches the hub/image version (`ghcr.io/ravileal/event-hub`); because
9
+ they move together, the client sends the English `X-Idempotency` header that hubs
10
+ since 1.1.0 read, while a hub older than 1.1.0 ignores it.
11
+
12
+ ## Install
13
+
14
+ ```bash
15
+ pip install full-event-hub
16
+ ```
17
+
18
+ ## Publish an event
19
+
20
+ ```python
21
+ from event_hub import EventHubClient
22
+
23
+ hub = EventHubClient("http://127.0.0.1:18295")
24
+
25
+ hub.create_topic("orders", description="order lifecycle")
26
+ hub.create_subscriber("orders", "billing",
27
+ url="http://localhost:9000/hook", secret="s3cr3t",
28
+ max_attempts=3, backoff_ms=200)
29
+
30
+ resp = hub.publish_event("orders", {"order_id": 1}, idempotency_key="order-1")
31
+ print(resp.status, resp.body) # 202 {'id': 1, 'deliveries_created': 1, 'duplicado': False}
32
+ ```
33
+
34
+ `publish_event` sends the idempotency key as `X-Idempotency` (the header the hub
35
+ reads since 1.1.0; it falls back to the legacy `X-Idempotencia`). Use
36
+ `sign(secret, raw_body)` to sign the exact bytes you send.
37
+
38
+ ## Verify an incoming delivery
39
+
40
+ event-hub signs every delivery with HMAC-SHA256 of the **raw** body using the
41
+ subscriber secret. Pass the exact bytes received; re-serializing JSON breaks the HMAC.
42
+
43
+ ```python
44
+ from event_hub import verify_delivery_signature
45
+
46
+ # in your webhook receiver:
47
+ valid = verify_delivery_signature(
48
+ secret, raw_body, request.headers["X-Event-Hub-Signature"]
49
+ )
50
+ ```
51
+
52
+ `verify_delivery_signature` returns `False` for a missing header, a wrong `sha256=`
53
+ prefix, or a non-matching digest, and compares in constant time
54
+ (`hmac.compare_digest`).
55
+
56
+ ## Notes
57
+
58
+ - Idempotency header is **`X-Idempotency`** (English) since 1.1.0; the legacy
59
+ **`X-Idempotencia`** is still accepted for old producers but new code should not
60
+ send it.
61
+ - Deliveries carry `X-Event-Hub-Event`, `X-Event-Hub-Attempt` and
62
+ `X-Event-Hub-Signature` (`sha256=<hex>`).
63
+
64
+ MIT licensed.
@@ -0,0 +1,46 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "full-event-hub"
7
+ version = "1.1.4"
8
+ description = "Zero-dependency Python client for the event-hub webhook fan-out service"
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = { text = "MIT" }
12
+ authors = [{ name = "Ravi Leal", email = "ravi@example.com" }]
13
+ keywords = ["event-hub", "webhooks", "hmac", "fan-out", "events", "sdk"]
14
+ classifiers = [
15
+ "Development Status :: 3 - Alpha",
16
+ "Intended Audience :: Developers",
17
+ "License :: OSI Approved :: MIT License",
18
+ "Operating System :: OS Independent",
19
+ "Programming Language :: Python :: 3",
20
+ "Programming Language :: Python :: 3 :: Only",
21
+ "Programming Language :: Python :: 3.9",
22
+ "Programming Language :: Python :: 3.10",
23
+ "Programming Language :: Python :: 3.11",
24
+ "Programming Language :: Python :: 3.12",
25
+ "Programming Language :: Python :: 3.13",
26
+ "Topic :: Software Development :: Libraries :: Python Modules",
27
+ "Topic :: Internet :: WWW/HTTP",
28
+ ]
29
+ dependencies = []
30
+
31
+ [project.optional-dependencies]
32
+ # The suite uses only the stdlib (unittest), so no test dependency is required.
33
+ # This extra exists so tooling can target an explicit test set without pulling
34
+ # anything heavy; the runtime `dependencies` above stay empty.
35
+ test = []
36
+
37
+ [project.urls]
38
+ Homepage = "https://github.com/ravileal/event-hub"
39
+ Repository = "https://github.com/ravileal/event-hub"
40
+ Issues = "https://github.com/ravileal/event-hub/issues"
41
+
42
+ [tool.setuptools.packages.find]
43
+ where = ["src"]
44
+
45
+ [tool.setuptools.package-data]
46
+ event_hub = ["py.typed"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,37 @@
1
+ """event-hub — zero-dependency Python client.
2
+
3
+ from event_hub import EventHubClient, verify_delivery_signature
4
+
5
+ hub = EventHubClient("http://localhost:18295")
6
+ hub.create_topic("orders")
7
+ hub.create_subscriber("orders", "billing", url="http://localhost:9000/hook", secret="s3cr3t")
8
+ hub.publish_event("orders", {"id": 1}, idempotency_key="order-1")
9
+
10
+ # in your webhook receiver:
11
+ ok = verify_delivery_signature(secret, raw_body, request.headers["X-Event-Hub-Signature"])
12
+ """
13
+
14
+ from .client import (
15
+ IDEMPOTENCY_HEADER,
16
+ LEGACY_IDEMPOTENCY_HEADER,
17
+ EventHubClient,
18
+ EventHubError,
19
+ HTTPError,
20
+ Response,
21
+ )
22
+ from .signature import SIGNATURE_PREFIX, sign, verify_delivery_signature
23
+
24
+ __version__ = "0.0.0"
25
+
26
+ __all__ = [
27
+ "EventHubClient",
28
+ "EventHubError",
29
+ "HTTPError",
30
+ "Response",
31
+ "IDEMPOTENCY_HEADER",
32
+ "LEGACY_IDEMPOTENCY_HEADER",
33
+ "SIGNATURE_PREFIX",
34
+ "sign",
35
+ "verify_delivery_signature",
36
+ "__version__",
37
+ ]
@@ -0,0 +1,266 @@
1
+ """Zero-dependency client for the event-hub HTTP API.
2
+
3
+ Uses only the stdlib (``urllib.request`` + ``json``). Every method returns a
4
+ :class:`Response` (``status``, ``headers``, ``body``, ``raw``) so callers can assert on
5
+ exact status codes — the hub uses 201/202/200/404/401 meaningfully.
6
+
7
+ The critical wire details baked into this client:
8
+
9
+ * the idempotency header is **``X-Idempotency``** (English) since hub 1.1.0. The hub
10
+ still falls back to the legacy Portuguese ``X-Idempotencia`` for old producers, but
11
+ new code should always send the English name.
12
+ * the ingest signature header is ``X-Signature`` and is only required when the hub runs
13
+ with ``--hmac-secret``.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import json as _json
19
+ import urllib.error
20
+ import urllib.parse
21
+ import urllib.request
22
+ from typing import Any, Dict, List, Optional
23
+
24
+ __all__ = [
25
+ "EventHubError",
26
+ "HTTPError",
27
+ "Response",
28
+ "EventHubClient",
29
+ "IDEMPOTENCY_HEADER",
30
+ "LEGACY_IDEMPOTENCY_HEADER",
31
+ ]
32
+
33
+ #: Inbound idempotency header the hub reads (English, since hub 1.1.0).
34
+ IDEMPOTENCY_HEADER = "X-Idempotency"
35
+
36
+ #: Legacy Portuguese alias, still accepted by the hub for old producers.
37
+ #: Do not send it from new code.
38
+ LEGACY_IDEMPOTENCY_HEADER = "X-Idempotencia"
39
+
40
+
41
+ class EventHubError(Exception):
42
+ """Base error raised by the client (transport-level)."""
43
+
44
+
45
+ class HTTPError(EventHubError):
46
+ """Raised by :meth:`Response.raise_for_status` on a >=400 response."""
47
+
48
+ def __init__(self, response: "Response") -> None:
49
+ self.response = response
50
+ detail = response.body if response.body is not None else response.raw
51
+ super().__init__("HTTP %s from event-hub: %r" % (response.status, detail))
52
+
53
+
54
+ class Response:
55
+ """A parsed HTTP response from the hub."""
56
+
57
+ __slots__ = ("status", "headers", "body", "raw")
58
+
59
+ def __init__(self, status: int, headers: Dict[str, str], raw: bytes) -> None:
60
+ self.status = status
61
+ self.headers = headers
62
+ self.raw = raw
63
+ text = raw.decode("utf-8", errors="replace") if raw else ""
64
+ if text:
65
+ try:
66
+ self.body: Any = _json.loads(text)
67
+ except ValueError:
68
+ self.body = text
69
+ else:
70
+ self.body = None
71
+
72
+ @property
73
+ def ok(self) -> bool:
74
+ return 200 <= self.status < 300
75
+
76
+ def raise_for_status(self) -> "Response":
77
+ if not self.ok:
78
+ raise HTTPError(self)
79
+ return self
80
+
81
+ def __repr__(self) -> str:
82
+ return "<Response status=%s body=%r>" % (self.status, self.body)
83
+
84
+
85
+ class EventHubClient:
86
+ """Client for an event-hub instance.
87
+
88
+ :param base_url: e.g. ``http://localhost:18295``.
89
+ :param timeout: per-request timeout in seconds.
90
+ """
91
+
92
+ def __init__(self, base_url: str = "http://localhost:18295", timeout: float = 10.0) -> None:
93
+ self.base_url = base_url.rstrip("/")
94
+ self.timeout = timeout
95
+
96
+ # -- low level ---------------------------------------------------------
97
+ def request(
98
+ self,
99
+ method: str,
100
+ path: str,
101
+ *,
102
+ body: Optional[bytes] = None,
103
+ headers: Optional[Dict[str, str]] = None,
104
+ query: Optional[Dict[str, Any]] = None,
105
+ ) -> Response:
106
+ url = self.base_url + path
107
+ if query:
108
+ clean = {k: v for k, v in query.items() if v is not None}
109
+ if clean:
110
+ url += "?" + urllib.parse.urlencode(clean)
111
+ req = urllib.request.Request(url, data=body, method=method.upper())
112
+ for key, value in (headers or {}).items():
113
+ req.add_header(key, value)
114
+ try:
115
+ with urllib.request.urlopen(req, timeout=self.timeout) as resp:
116
+ return Response(resp.status, dict(resp.headers.items()), resp.read())
117
+ except urllib.error.HTTPError as exc: # 4xx/5xx still carry a useful body
118
+ return Response(exc.code, dict(exc.headers.items()), exc.read())
119
+ except urllib.error.URLError as exc:
120
+ raise EventHubError("cannot reach event-hub at %s: %s" % (self.base_url, exc)) from exc
121
+
122
+ def _json_request(
123
+ self,
124
+ method: str,
125
+ path: str,
126
+ payload: Any = None,
127
+ *,
128
+ headers: Optional[Dict[str, str]] = None,
129
+ query: Optional[Dict[str, Any]] = None,
130
+ ) -> Response:
131
+ body = None
132
+ hdrs = dict(headers or {})
133
+ if payload is not None:
134
+ body = _json.dumps(payload).encode("utf-8")
135
+ hdrs.setdefault("Content-Type", "application/json")
136
+ return self.request(method, path, body=body, headers=hdrs, query=query)
137
+
138
+ # -- health / metrics --------------------------------------------------
139
+ def health(self) -> Response:
140
+ """``GET /health`` -> 200 ``{"ok":true,"version":...}``."""
141
+ return self.request("GET", "/health")
142
+
143
+ def metrics(self) -> Response:
144
+ """``GET /metrics`` -> 200 counters."""
145
+ return self.request("GET", "/metrics")
146
+
147
+ # -- topics ------------------------------------------------------------
148
+ def create_topic(self, name: str, description: Optional[str] = None) -> Response:
149
+ """``POST /topics`` -> 201 Topic."""
150
+ return self._json_request("POST", "/topics", {"name": name, "description": description})
151
+
152
+ def list_topics(self) -> Response:
153
+ """``GET /topics`` -> 200 Topic[]."""
154
+ return self.request("GET", "/topics")
155
+
156
+ # -- subscribers -------------------------------------------------------
157
+ def create_subscriber(
158
+ self,
159
+ topic: str,
160
+ name: str,
161
+ *,
162
+ url: Optional[str] = None,
163
+ secret: Optional[str] = None,
164
+ max_attempts: Optional[int] = None,
165
+ backoff_ms: Optional[int] = None,
166
+ kind: str = "webhook",
167
+ command: Optional[str] = None,
168
+ timeout_ms: Optional[int] = None,
169
+ ) -> Response:
170
+ """``POST /topics/{topic}/subscribers`` -> 201 subscriber.
171
+
172
+ ``url`` is mandatory for ``kind="webhook"``; ``command`` for ``kind="command"``.
173
+ """
174
+ payload: Dict[str, Any] = {"name": name, "kind": kind}
175
+ for key, value in (
176
+ ("url", url),
177
+ ("secret", secret),
178
+ ("max_attempts", max_attempts),
179
+ ("backoff_ms", backoff_ms),
180
+ ("command", command),
181
+ ("timeout_ms", timeout_ms),
182
+ ):
183
+ if value is not None:
184
+ payload[key] = value
185
+ return self._json_request(
186
+ "POST", "/topics/%s/subscribers" % urllib.parse.quote(topic, safe=""), payload
187
+ )
188
+
189
+ # -- events ------------------------------------------------------------
190
+ def publish_event(
191
+ self,
192
+ topic: str,
193
+ payload: Any,
194
+ *,
195
+ idempotency_key: Optional[str] = None,
196
+ signature: Optional[str] = None,
197
+ raw: Optional[bytes] = None,
198
+ ) -> Response:
199
+ """``POST /events/{topic}`` -> 202 (new) or 200 with ``duplicado=true``.
200
+
201
+ ``idempotency_key`` is sent as ``X-Idempotency`` (the header the hub reads
202
+ since 1.1.0; it falls back to the legacy ``X-Idempotencia``). ``signature``
203
+ (``sha256=<hex>``) is sent as ``X-Signature`` and is only enforced by hubs
204
+ started with ``--hmac-secret``. Pass ``raw`` to send exact bytes (e.g. to match
205
+ a pre-computed signature) instead of a JSON re-serialisation.
206
+ """
207
+ headers: Dict[str, str] = {}
208
+ if idempotency_key is not None:
209
+ headers[IDEMPOTENCY_HEADER] = idempotency_key
210
+ if signature is not None:
211
+ headers["X-Signature"] = signature
212
+ if raw is not None:
213
+ headers.setdefault("Content-Type", "application/json")
214
+ body = raw
215
+ else:
216
+ body = _json.dumps(payload).encode("utf-8")
217
+ headers.setdefault("Content-Type", "application/json")
218
+ return self.request(
219
+ "POST", "/events/%s" % urllib.parse.quote(topic, safe=""), body=body, headers=headers
220
+ )
221
+
222
+ # -- ingest ------------------------------------------------------------
223
+ def ingest(self, raw: bytes, *, origem: Optional[str] = None) -> Response:
224
+ """``POST /ingest?origem=`` with a raw body -> 202.
225
+
226
+ 404 ``{"error":"routing disabled..."}`` when the hub runs without ``--routes``.
227
+ """
228
+ body = raw.encode("utf-8") if isinstance(raw, str) else raw
229
+ return self.request(
230
+ "POST", "/ingest", body=body, headers={"Content-Type": "application/json"},
231
+ query={"origem": origem},
232
+ )
233
+
234
+ # -- deliveries --------------------------------------------------------
235
+ def list_deliveries(
236
+ self,
237
+ *,
238
+ status: Optional[str] = None,
239
+ topic: Optional[str] = None,
240
+ limite: Optional[int] = None,
241
+ ) -> Response:
242
+ """``GET /deliveries?status=&topic=&limite=``."""
243
+ return self.request(
244
+ "GET", "/deliveries", query={"status": status, "topic": topic, "limite": limite}
245
+ )
246
+
247
+ def retry_delivery(self, delivery_id: int) -> Response:
248
+ """``POST /deliveries/{id}/retry``."""
249
+ return self.request("POST", "/deliveries/%s/retry" % delivery_id)
250
+
251
+ def retry_failures(self) -> Response:
252
+ """``POST /deliveries/retry-falhas``."""
253
+ return self.request("POST", "/deliveries/retry-falhas")
254
+
255
+ # -- observability -----------------------------------------------------
256
+ def list_proxied(self, *, source: Optional[str] = None, destination: Optional[str] = None,
257
+ limite: Optional[int] = None) -> Response:
258
+ """``GET /proxied``."""
259
+ return self.request(
260
+ "GET", "/proxied",
261
+ query={"source": source, "destination": destination, "limite": limite},
262
+ )
263
+
264
+ def trace(self, **anchor: str) -> Response:
265
+ """``GET /trace`` — exactly one anchor (tvdb/tmdb/imdb/torrent/path)."""
266
+ return self.request("GET", "/trace", query=anchor)
File without changes
@@ -0,0 +1,53 @@
1
+ """HMAC-SHA256 signing helpers for event-hub.
2
+
3
+ Two distinct signatures exist in event-hub:
4
+
5
+ * **Ingest signature** (``X-Signature`` on ``POST /events/{topic}``): only used when the
6
+ hub is started with ``--hmac-secret``. Same algorithm/format as below.
7
+ * **Delivery signature** (``X-Event-Hub-Signature`` on the HTTP request the hub makes to a
8
+ subscriber): HMAC-SHA256 of the *raw* request body with the subscriber's secret.
9
+
10
+ Both use the same wire format: ``sha256=<hex lowercase digest>``.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import hashlib
16
+ import hmac
17
+ from typing import Union
18
+
19
+ __all__ = ["SIGNATURE_PREFIX", "sign", "verify_delivery_signature"]
20
+
21
+ SIGNATURE_PREFIX = "sha256="
22
+
23
+ BytesLike = Union[bytes, bytearray, str]
24
+
25
+
26
+ def _as_bytes(value: BytesLike) -> bytes:
27
+ if isinstance(value, str):
28
+ return value.encode("utf-8")
29
+ return bytes(value)
30
+
31
+
32
+ def sign(secret: BytesLike, body: BytesLike) -> str:
33
+ """Return ``sha256=<hex>`` = HMAC-SHA256(secret, body)."""
34
+ digest = hmac.new(_as_bytes(secret), _as_bytes(body), hashlib.sha256).hexdigest()
35
+ return SIGNATURE_PREFIX + digest
36
+
37
+
38
+ def verify_delivery_signature(secret: BytesLike, raw_body: BytesLike, header: str) -> bool:
39
+ """Constant-time verification of a delivery signature header.
40
+
41
+ ``header`` is the raw value of ``X-Event-Hub-Signature`` (e.g. ``"sha256=ab12..."``).
42
+ ``raw_body`` MUST be the exact bytes received on the wire — re-serialising JSON would
43
+ change whitespace/key order and break the HMAC.
44
+ """
45
+ if not header or not isinstance(header, str):
46
+ return False
47
+ if not header.startswith(SIGNATURE_PREFIX):
48
+ return False
49
+ provided = header[len(SIGNATURE_PREFIX) :]
50
+ expected = hmac.new(
51
+ _as_bytes(secret), _as_bytes(raw_body), hashlib.sha256
52
+ ).hexdigest()
53
+ return hmac.compare_digest(expected, provided)
@@ -0,0 +1,91 @@
1
+ Metadata-Version: 2.4
2
+ Name: full-event-hub
3
+ Version: 1.1.4
4
+ Summary: Zero-dependency Python client for the event-hub webhook fan-out service
5
+ Author-email: Ravi Leal <ravi@example.com>
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/ravileal/event-hub
8
+ Project-URL: Repository, https://github.com/ravileal/event-hub
9
+ Project-URL: Issues, https://github.com/ravileal/event-hub/issues
10
+ Keywords: event-hub,webhooks,hmac,fan-out,events,sdk
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Operating System :: OS Independent
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3 :: Only
17
+ Classifier: Programming Language :: Python :: 3.9
18
+ Classifier: Programming Language :: Python :: 3.10
19
+ Classifier: Programming Language :: Python :: 3.11
20
+ Classifier: Programming Language :: Python :: 3.12
21
+ Classifier: Programming Language :: Python :: 3.13
22
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
23
+ Classifier: Topic :: Internet :: WWW/HTTP
24
+ Requires-Python: >=3.9
25
+ Description-Content-Type: text/markdown
26
+ Provides-Extra: test
27
+
28
+ # full-event-hub (Python client)
29
+
30
+ Zero-dependency Python client for [event-hub](https://github.com/ravileal/event-hub) — a
31
+ webhook fan-out service (ingest, fan-out to N subscribers, HMAC signing, retry/backoff,
32
+ attempts, retention).
33
+
34
+ Only the standard library is used (`urllib.request`, `json`, `hmac`, `hashlib`). The
35
+ package version matches the hub/image version (`ghcr.io/ravileal/event-hub`); because
36
+ they move together, the client sends the English `X-Idempotency` header that hubs
37
+ since 1.1.0 read, while a hub older than 1.1.0 ignores it.
38
+
39
+ ## Install
40
+
41
+ ```bash
42
+ pip install full-event-hub
43
+ ```
44
+
45
+ ## Publish an event
46
+
47
+ ```python
48
+ from event_hub import EventHubClient
49
+
50
+ hub = EventHubClient("http://127.0.0.1:18295")
51
+
52
+ hub.create_topic("orders", description="order lifecycle")
53
+ hub.create_subscriber("orders", "billing",
54
+ url="http://localhost:9000/hook", secret="s3cr3t",
55
+ max_attempts=3, backoff_ms=200)
56
+
57
+ resp = hub.publish_event("orders", {"order_id": 1}, idempotency_key="order-1")
58
+ print(resp.status, resp.body) # 202 {'id': 1, 'deliveries_created': 1, 'duplicado': False}
59
+ ```
60
+
61
+ `publish_event` sends the idempotency key as `X-Idempotency` (the header the hub
62
+ reads since 1.1.0; it falls back to the legacy `X-Idempotencia`). Use
63
+ `sign(secret, raw_body)` to sign the exact bytes you send.
64
+
65
+ ## Verify an incoming delivery
66
+
67
+ event-hub signs every delivery with HMAC-SHA256 of the **raw** body using the
68
+ subscriber secret. Pass the exact bytes received; re-serializing JSON breaks the HMAC.
69
+
70
+ ```python
71
+ from event_hub import verify_delivery_signature
72
+
73
+ # in your webhook receiver:
74
+ valid = verify_delivery_signature(
75
+ secret, raw_body, request.headers["X-Event-Hub-Signature"]
76
+ )
77
+ ```
78
+
79
+ `verify_delivery_signature` returns `False` for a missing header, a wrong `sha256=`
80
+ prefix, or a non-matching digest, and compares in constant time
81
+ (`hmac.compare_digest`).
82
+
83
+ ## Notes
84
+
85
+ - Idempotency header is **`X-Idempotency`** (English) since 1.1.0; the legacy
86
+ **`X-Idempotencia`** is still accepted for old producers but new code should not
87
+ send it.
88
+ - Deliveries carry `X-Event-Hub-Event`, `X-Event-Hub-Attempt` and
89
+ `X-Event-Hub-Signature` (`sha256=<hex>`).
90
+
91
+ MIT licensed.
@@ -0,0 +1,12 @@
1
+ README.md
2
+ pyproject.toml
3
+ src/event_hub/__init__.py
4
+ src/event_hub/client.py
5
+ src/event_hub/py.typed
6
+ src/event_hub/signature.py
7
+ src/full_event_hub.egg-info/PKG-INFO
8
+ src/full_event_hub.egg-info/SOURCES.txt
9
+ src/full_event_hub.egg-info/dependency_links.txt
10
+ src/full_event_hub.egg-info/requires.txt
11
+ src/full_event_hub.egg-info/top_level.txt
12
+ tests/test_signature.py
@@ -0,0 +1,74 @@
1
+ """Offline unit tests for the signature helpers (stdlib unittest, no hub).
2
+
3
+ Run from the repository root:
4
+
5
+ python -m unittest discover -s packages/python/tests -p 'test_*.py'
6
+
7
+ The three expected values are the shared cross-language contract; the same
8
+ strings live in tests/signature_vectors.rs (Rust reference) and
9
+ packages/npm/test/signature.test.mjs. If the implementations drift apart,
10
+ whichever one changed fails here.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import os
16
+ import sys
17
+ import unittest
18
+
19
+ sys.path.insert(0, os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "src"))
20
+
21
+ from event_hub import sign, verify_delivery_signature # noqa: E402
22
+
23
+ SECRET = "shared-vector-secret"
24
+
25
+ VECTORS = [
26
+ (
27
+ "simple JSON body",
28
+ '{"title":"hello","count":1}',
29
+ "sha256=1a7ee7637482254acf1a219fc564033b5824ee012bf857d4d6960fbdd7ea7f46",
30
+ ),
31
+ (
32
+ "non-ASCII UTF-8 body",
33
+ "olá, 世界 — café",
34
+ "sha256=c1d18fed87c1c08e3c6be675ac6b1bf3c7f329ef0c0ea9f520b804a413bcd1ad",
35
+ ),
36
+ (
37
+ "empty body",
38
+ "",
39
+ "sha256=092d0e58dcde35f9641e53be782cd12d4fdff4e6a515ec9556d66527513a6dfe",
40
+ ),
41
+ ]
42
+
43
+
44
+ class SignatureVectorsTest(unittest.TestCase):
45
+ def test_sign_matches_vectors(self) -> None:
46
+ for name, body, expected in VECTORS:
47
+ with self.subTest(name=name):
48
+ self.assertEqual(sign(SECRET, body), expected)
49
+
50
+ def test_verify_accepts_vectors(self) -> None:
51
+ for name, body, expected in VECTORS:
52
+ with self.subTest(name=name):
53
+ self.assertTrue(verify_delivery_signature(SECRET, body, expected))
54
+
55
+
56
+ class VerifyNegativeTest(unittest.TestCase):
57
+ def setUp(self) -> None:
58
+ _, self.body, self.expected = VECTORS[0]
59
+
60
+ def test_rejects_tampered_body(self) -> None:
61
+ self.assertFalse(verify_delivery_signature(SECRET, self.body + " ", self.expected))
62
+
63
+ def test_rejects_wrong_secret(self) -> None:
64
+ self.assertFalse(verify_delivery_signature("another-secret", self.body, self.expected))
65
+
66
+ def test_rejects_missing_prefix(self) -> None:
67
+ self.assertFalse(verify_delivery_signature(SECRET, self.body, self.expected[len("sha256="):]))
68
+
69
+ def test_rejects_non_hex_digest(self) -> None:
70
+ self.assertFalse(verify_delivery_signature(SECRET, self.body, "sha256=zzzz"))
71
+
72
+
73
+ if __name__ == "__main__":
74
+ unittest.main()