event-bridge-client 1.0.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,16 @@
1
+ node_modules/
2
+ dist/
3
+ build/
4
+ .next/
5
+ .turbo/
6
+ *.log
7
+ .DS_Store
8
+ .env
9
+ .env.local
10
+ .env.*.local
11
+ .vercel/
12
+ coverage/
13
+ .nyc_output/
14
+ *.tsbuildinfo
15
+ .pnpm-store/
16
+ prisma/migrations/dev.db*
@@ -0,0 +1,157 @@
1
+ Metadata-Version: 2.4
2
+ Name: event-bridge-client
3
+ Version: 1.0.0
4
+ Summary: Lightweight client for registering apps, batching lifecycle events, and handling HMAC-signed remote commands.
5
+ License: UNLICENSED
6
+ Keywords: commands,events,hmac,webhooks
7
+ Requires-Python: >=3.9
8
+ Requires-Dist: httpx>=0.27
9
+ Requires-Dist: pydantic>=2.4
10
+ Provides-Extra: dev
11
+ Requires-Dist: anyio>=4; extra == 'dev'
12
+ Requires-Dist: fastapi>=0.110; extra == 'dev'
13
+ Requires-Dist: pytest>=8; extra == 'dev'
14
+ Provides-Extra: fastapi
15
+ Requires-Dist: fastapi>=0.110; extra == 'fastapi'
16
+ Description-Content-Type: text/markdown
17
+
18
+ # event-bridge-client (Python)
19
+
20
+ A lightweight client for connecting a Python application to a control backend:
21
+ register the app, batch and push lifecycle events, and handle HMAC-signed remote
22
+ commands. Wire-compatible with the Node `event-bridge-client` — same signing
23
+ scheme, same endpoints, same payloads.
24
+
25
+ ## Install
26
+
27
+ ```bash
28
+ pip install event-bridge-client # core (send events, verify commands)
29
+ pip install "event-bridge-client[fastapi]" # + FastAPI inbound adapter
30
+ ```
31
+
32
+ Requires Python 3.9+.
33
+
34
+ ## Quickstart (FastAPI)
35
+
36
+ ```python
37
+ from fastapi import FastAPI, Request
38
+ from event_bridge_client import create_client
39
+
40
+ client = create_client(
41
+ base_url="https://bridge.example.com",
42
+ api_key="...",
43
+ callback_url="https://api.example.com/bridge/commands",
44
+ callback_secret="...",
45
+ capabilities=["user.ban", "user.unban"],
46
+ env="PROD",
47
+ )
48
+
49
+ @client.on_command("user.ban")
50
+ async def _(data, ctx):
51
+ await ban_account(data["externalUserId"], data["reason"])
52
+ return {"ok": True}
53
+
54
+ app = FastAPI()
55
+
56
+ @app.post("/bridge/commands")
57
+ async def commands(request: Request):
58
+ return await client.middleware.fastapi(request)
59
+
60
+ @app.on_event("startup")
61
+ async def _startup():
62
+ await client.register()
63
+
64
+ @app.on_event("shutdown")
65
+ async def _shutdown():
66
+ await client.aclose()
67
+
68
+ # Anywhere in your app:
69
+ client.events.emit("user.created", {"externalUserId": "usr_123", "email": "a@b.c"})
70
+ ```
71
+
72
+ Command handlers may be sync or async. Each receives `(data, ctx)` where `data`
73
+ is the raw command payload (a dict) and `ctx` carries `command_id`, `issued_at`,
74
+ and `issued_by`. Return `{"ok": True, "result": ...}` or `{"ok": False, "error": "..."}`.
75
+
76
+ ## Managed resources
77
+
78
+ Declare an entity the backend can list / search / view / action — entirely from
79
+ the descriptor, with no backend-side code change. Records are never shipped to
80
+ the backend; it proxies `list` / `get` / `action` queries back over the same
81
+ signed channel.
82
+
83
+ ```python
84
+ client.define_resource(
85
+ {
86
+ "key": "widgetUser",
87
+ "label": "Widget User",
88
+ "labelPlural": "Widget Users",
89
+ "titleField": "email",
90
+ "fields": [
91
+ {"key": "id", "label": "ID", "type": "string", "listVisible": False},
92
+ {"key": "email", "label": "Email", "type": "email", "filterable": True},
93
+ {"key": "plan", "label": "Plan", "type": "enum", "enumValues": ["free", "pro"]},
94
+ ],
95
+ "actions": [
96
+ {
97
+ "capability": "widgetUser.ban",
98
+ "label": "Ban",
99
+ "confirm": True,
100
+ "destructive": True,
101
+ "fields": [{"name": "reason", "label": "Reason", "kind": "textarea", "required": True}],
102
+ }
103
+ ],
104
+ },
105
+ # `query` is a ResourceListQuery: query.page, query.page_size, query.q, ...
106
+ list=lambda query: {"records": db.search(query.q, query.page, query.page_size), "total": db.count()},
107
+ get=lambda record_id: db.find(record_id), # optional
108
+ action=lambda inp: ban(inp["recordId"], inp["params"]["reason"]), # optional
109
+ )
110
+ ```
111
+
112
+ Descriptor keys accept either `snake_case` or `camelCase`; they're sent to the
113
+ backend as `camelCase`. Field `type` is one of `string`, `number`, `boolean`,
114
+ `date`, `datetime`, `enum`, `currency`, `badge`, `email`, `url`, `json`.
115
+
116
+ ## Options
117
+
118
+ `create_client(...)` keyword arguments:
119
+
120
+ | Option | Default | Notes |
121
+ |---------------------|-----------|--------------------------------------------------------|
122
+ | `base_url` | required | Control backend base URL |
123
+ | `api_key` | required | API key minted by the backend admin |
124
+ | `callback_url` | required | HTTPS URL the backend POSTs commands to |
125
+ | `callback_secret` | required | HMAC shared secret minted alongside the API key |
126
+ | `capabilities` | `()` | Strings matching command types, e.g. `user.ban` |
127
+ | `env` | `"PROD"` | `PROD` / `STAGING` / `DEV` |
128
+ | `enabled` | `True` | If `False`, all methods are no-ops (staged rollout) |
129
+ | `batch_interval_ms` | `1500` | Event batcher flush interval |
130
+ | `batch_max_size` | `100` | Force-flush when this many events are queued |
131
+ | `max_buffer_size` | `10000` | Hard cap on buffered events; oldest dropped past it |
132
+ | `max_retries` | `6` | Exponential-backoff retries for event batch POSTs |
133
+ | `nonce_store` | in-memory | Replay store; supply a shared one for multi-instance |
134
+
135
+ ## Replay protection across instances
136
+
137
+ The default replay cache is **in-process** — it only protects a single instance.
138
+ If you run more than one instance behind a load balancer, supply a shared
139
+ `nonce_store` (e.g. Redis) so a captured command can't be replayed against
140
+ another instance inside the 300-second signature window:
141
+
142
+ ```python
143
+ class RedisNonceStore:
144
+ def __init__(self, redis): self.r = redis
145
+ async def has(self, nonce: str) -> bool:
146
+ return await self.r.exists(f"bridge:nonce:{nonce}") > 0
147
+ async def add(self, nonce: str, ttl_ms: int) -> None:
148
+ await self.r.set(f"bridge:nonce:{nonce}", "1", px=ttl_ms, nx=True)
149
+
150
+ client = create_client(..., nonce_store=RedisNonceStore(redis))
151
+ ```
152
+
153
+ ## Send-only usage (no FastAPI)
154
+
155
+ If the app only emits events and never receives commands, you don't need
156
+ FastAPI — `pip install event-bridge-client` and use `register()` /
157
+ `events.emit()` / `aclose()`. The middleware is only needed to receive commands.
@@ -0,0 +1,140 @@
1
+ # event-bridge-client (Python)
2
+
3
+ A lightweight client for connecting a Python application to a control backend:
4
+ register the app, batch and push lifecycle events, and handle HMAC-signed remote
5
+ commands. Wire-compatible with the Node `event-bridge-client` — same signing
6
+ scheme, same endpoints, same payloads.
7
+
8
+ ## Install
9
+
10
+ ```bash
11
+ pip install event-bridge-client # core (send events, verify commands)
12
+ pip install "event-bridge-client[fastapi]" # + FastAPI inbound adapter
13
+ ```
14
+
15
+ Requires Python 3.9+.
16
+
17
+ ## Quickstart (FastAPI)
18
+
19
+ ```python
20
+ from fastapi import FastAPI, Request
21
+ from event_bridge_client import create_client
22
+
23
+ client = create_client(
24
+ base_url="https://bridge.example.com",
25
+ api_key="...",
26
+ callback_url="https://api.example.com/bridge/commands",
27
+ callback_secret="...",
28
+ capabilities=["user.ban", "user.unban"],
29
+ env="PROD",
30
+ )
31
+
32
+ @client.on_command("user.ban")
33
+ async def _(data, ctx):
34
+ await ban_account(data["externalUserId"], data["reason"])
35
+ return {"ok": True}
36
+
37
+ app = FastAPI()
38
+
39
+ @app.post("/bridge/commands")
40
+ async def commands(request: Request):
41
+ return await client.middleware.fastapi(request)
42
+
43
+ @app.on_event("startup")
44
+ async def _startup():
45
+ await client.register()
46
+
47
+ @app.on_event("shutdown")
48
+ async def _shutdown():
49
+ await client.aclose()
50
+
51
+ # Anywhere in your app:
52
+ client.events.emit("user.created", {"externalUserId": "usr_123", "email": "a@b.c"})
53
+ ```
54
+
55
+ Command handlers may be sync or async. Each receives `(data, ctx)` where `data`
56
+ is the raw command payload (a dict) and `ctx` carries `command_id`, `issued_at`,
57
+ and `issued_by`. Return `{"ok": True, "result": ...}` or `{"ok": False, "error": "..."}`.
58
+
59
+ ## Managed resources
60
+
61
+ Declare an entity the backend can list / search / view / action — entirely from
62
+ the descriptor, with no backend-side code change. Records are never shipped to
63
+ the backend; it proxies `list` / `get` / `action` queries back over the same
64
+ signed channel.
65
+
66
+ ```python
67
+ client.define_resource(
68
+ {
69
+ "key": "widgetUser",
70
+ "label": "Widget User",
71
+ "labelPlural": "Widget Users",
72
+ "titleField": "email",
73
+ "fields": [
74
+ {"key": "id", "label": "ID", "type": "string", "listVisible": False},
75
+ {"key": "email", "label": "Email", "type": "email", "filterable": True},
76
+ {"key": "plan", "label": "Plan", "type": "enum", "enumValues": ["free", "pro"]},
77
+ ],
78
+ "actions": [
79
+ {
80
+ "capability": "widgetUser.ban",
81
+ "label": "Ban",
82
+ "confirm": True,
83
+ "destructive": True,
84
+ "fields": [{"name": "reason", "label": "Reason", "kind": "textarea", "required": True}],
85
+ }
86
+ ],
87
+ },
88
+ # `query` is a ResourceListQuery: query.page, query.page_size, query.q, ...
89
+ list=lambda query: {"records": db.search(query.q, query.page, query.page_size), "total": db.count()},
90
+ get=lambda record_id: db.find(record_id), # optional
91
+ action=lambda inp: ban(inp["recordId"], inp["params"]["reason"]), # optional
92
+ )
93
+ ```
94
+
95
+ Descriptor keys accept either `snake_case` or `camelCase`; they're sent to the
96
+ backend as `camelCase`. Field `type` is one of `string`, `number`, `boolean`,
97
+ `date`, `datetime`, `enum`, `currency`, `badge`, `email`, `url`, `json`.
98
+
99
+ ## Options
100
+
101
+ `create_client(...)` keyword arguments:
102
+
103
+ | Option | Default | Notes |
104
+ |---------------------|-----------|--------------------------------------------------------|
105
+ | `base_url` | required | Control backend base URL |
106
+ | `api_key` | required | API key minted by the backend admin |
107
+ | `callback_url` | required | HTTPS URL the backend POSTs commands to |
108
+ | `callback_secret` | required | HMAC shared secret minted alongside the API key |
109
+ | `capabilities` | `()` | Strings matching command types, e.g. `user.ban` |
110
+ | `env` | `"PROD"` | `PROD` / `STAGING` / `DEV` |
111
+ | `enabled` | `True` | If `False`, all methods are no-ops (staged rollout) |
112
+ | `batch_interval_ms` | `1500` | Event batcher flush interval |
113
+ | `batch_max_size` | `100` | Force-flush when this many events are queued |
114
+ | `max_buffer_size` | `10000` | Hard cap on buffered events; oldest dropped past it |
115
+ | `max_retries` | `6` | Exponential-backoff retries for event batch POSTs |
116
+ | `nonce_store` | in-memory | Replay store; supply a shared one for multi-instance |
117
+
118
+ ## Replay protection across instances
119
+
120
+ The default replay cache is **in-process** — it only protects a single instance.
121
+ If you run more than one instance behind a load balancer, supply a shared
122
+ `nonce_store` (e.g. Redis) so a captured command can't be replayed against
123
+ another instance inside the 300-second signature window:
124
+
125
+ ```python
126
+ class RedisNonceStore:
127
+ def __init__(self, redis): self.r = redis
128
+ async def has(self, nonce: str) -> bool:
129
+ return await self.r.exists(f"bridge:nonce:{nonce}") > 0
130
+ async def add(self, nonce: str, ttl_ms: int) -> None:
131
+ await self.r.set(f"bridge:nonce:{nonce}", "1", px=ttl_ms, nx=True)
132
+
133
+ client = create_client(..., nonce_store=RedisNonceStore(redis))
134
+ ```
135
+
136
+ ## Send-only usage (no FastAPI)
137
+
138
+ If the app only emits events and never receives commands, you don't need
139
+ FastAPI — `pip install event-bridge-client` and use `register()` /
140
+ `events.emit()` / `aclose()`. The middleware is only needed to receive commands.
@@ -0,0 +1,26 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "event-bridge-client"
7
+ version = "1.0.0"
8
+ description = "Lightweight client for registering apps, batching lifecycle events, and handling HMAC-signed remote commands."
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = { text = "UNLICENSED" }
12
+ keywords = ["events", "hmac", "webhooks", "commands"]
13
+ dependencies = [
14
+ "httpx>=0.27",
15
+ "pydantic>=2.4",
16
+ ]
17
+
18
+ [project.optional-dependencies]
19
+ fastapi = ["fastapi>=0.110"]
20
+ dev = ["pytest>=8", "fastapi>=0.110", "anyio>=4"]
21
+
22
+ [tool.hatch.build.targets.wheel]
23
+ packages = ["src/event_bridge_client"]
24
+
25
+ [tool.pytest.ini_options]
26
+ testpaths = ["tests"]
@@ -0,0 +1,43 @@
1
+ from __future__ import annotations
2
+
3
+ from .batcher import SDK_VERSION
4
+ from .client import EventBridgeClient, create_client
5
+ from .errors import HandlerNotRegisteredError, ManagementError, SignatureError
6
+ from .hmac import HMAC_HEADERS, HMAC_WINDOW_SECONDS, SignedHeaders, sign, verify
7
+ from .nonce import NonceLRU, NonceStore
8
+ from .options import ClientOptions, CommandCtx
9
+ from .schemas import (
10
+ COMMAND_TYPES,
11
+ EVENT_TYPES,
12
+ ResourceAction,
13
+ ResourceDescriptor,
14
+ ResourceField,
15
+ ResourceListQuery,
16
+ )
17
+
18
+ __version__ = SDK_VERSION
19
+
20
+ __all__ = [
21
+ "create_client",
22
+ "EventBridgeClient",
23
+ "ClientOptions",
24
+ "CommandCtx",
25
+ "sign",
26
+ "verify",
27
+ "SignedHeaders",
28
+ "HMAC_HEADERS",
29
+ "HMAC_WINDOW_SECONDS",
30
+ "NonceStore",
31
+ "NonceLRU",
32
+ "ManagementError",
33
+ "SignatureError",
34
+ "HandlerNotRegisteredError",
35
+ "ResourceDescriptor",
36
+ "ResourceField",
37
+ "ResourceAction",
38
+ "ResourceListQuery",
39
+ "EVENT_TYPES",
40
+ "COMMAND_TYPES",
41
+ "SDK_VERSION",
42
+ "__version__",
43
+ ]
@@ -0,0 +1,28 @@
1
+ from __future__ import annotations
2
+
3
+ import inspect
4
+ import logging
5
+ from datetime import datetime, timezone
6
+ from typing import Any
7
+
8
+ _default_logger = logging.getLogger("event_bridge_client")
9
+
10
+
11
+ def now_iso() -> str:
12
+ """ISO-8601 timestamp with millisecond precision and a ``Z`` suffix,
13
+ byte-identical to JavaScript's ``new Date().toISOString()`` so the backend's
14
+ datetime validation accepts it regardless of which SDK produced the event."""
15
+ dt = datetime.now(timezone.utc)
16
+ return dt.strftime("%Y-%m-%dT%H:%M:%S.") + f"{dt.microsecond // 1000:03d}Z"
17
+
18
+
19
+ async def maybe_await(value: Any) -> Any:
20
+ """Await ``value`` if it is awaitable, otherwise return it as-is. Lets
21
+ handlers be either sync or async."""
22
+ if inspect.isawaitable(value):
23
+ return await value
24
+ return value
25
+
26
+
27
+ def get_logger(logger: logging.Logger | None) -> logging.Logger:
28
+ return logger or _default_logger
@@ -0,0 +1,140 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import time
5
+ from typing import Any, Dict, List
6
+
7
+ import httpx
8
+
9
+ from ._util import get_logger, now_iso
10
+ from .options import ClientOptions
11
+ from .ulid import ulid
12
+
13
+ # Keep in sync with pyproject.toml "version" and the Node SDK.
14
+ SDK_VERSION = "1.0.0"
15
+ DEFAULT_MAX_BUFFER = 10_000
16
+
17
+
18
+ class EventBatcher:
19
+ """Buffers events and flushes them to ``/sdk/events`` on an interval or when
20
+ the buffer fills. Mirrors the Node batcher: bounded buffer (drop-oldest),
21
+ single-flight flush, and exponential backoff on failure."""
22
+
23
+ def __init__(self, opts: ClientOptions, http: httpx.AsyncClient) -> None:
24
+ self._o = opts
25
+ self._http = http
26
+ self._buffer: List[Dict[str, Any]] = []
27
+ self._lock = asyncio.Lock()
28
+ self._wake = asyncio.Event()
29
+ self._task: "asyncio.Task[None] | None" = None
30
+ self._stopped = False
31
+ self._retry = 0
32
+ self._next_send_at = 0.0
33
+
34
+ def start(self) -> None:
35
+ if self._task is not None:
36
+ return
37
+ self._stopped = False
38
+ self._task = asyncio.create_task(self._run())
39
+
40
+ def emit(self, event_type: str, data: Any) -> None:
41
+ if not self._o.enabled:
42
+ return
43
+ self._buffer.append(
44
+ {"id": ulid(), "type": event_type, "data": data, "occurredAt": now_iso()}
45
+ )
46
+ self._cap_buffer()
47
+ if len(self._buffer) >= self._o.batch_max_size:
48
+ self._wake.set()
49
+
50
+ def _cap_buffer(self) -> None:
51
+ max_ = self._o.max_buffer_size or DEFAULT_MAX_BUFFER
52
+ if len(self._buffer) > max_:
53
+ dropped = len(self._buffer) - max_
54
+ del self._buffer[:dropped]
55
+ get_logger(self._o.logger).warning(
56
+ "[event-bridge] event buffer exceeded %d; dropped %d oldest event(s)",
57
+ max_,
58
+ dropped,
59
+ )
60
+
61
+ async def _run(self) -> None:
62
+ interval = self._o.batch_interval_ms / 1000.0
63
+ while not self._stopped:
64
+ try:
65
+ await asyncio.wait_for(self._wake.wait(), timeout=interval)
66
+ except asyncio.TimeoutError:
67
+ pass
68
+ self._wake.clear()
69
+ await self.flush()
70
+
71
+ async def flush(self) -> None:
72
+ if not self._o.enabled:
73
+ return
74
+ async with self._lock:
75
+ if not self._buffer:
76
+ return
77
+ # Respect the backoff window set by a previous failure.
78
+ if time.monotonic() < self._next_send_at:
79
+ return
80
+ batch = self._buffer[: self._o.batch_max_size]
81
+ del self._buffer[: len(batch)]
82
+ payload = {
83
+ "events": [
84
+ {
85
+ "id": e["id"],
86
+ "occurredAt": e["occurredAt"],
87
+ "sdkVersion": SDK_VERSION,
88
+ "type": e["type"],
89
+ "data": e["data"],
90
+ }
91
+ for e in batch
92
+ ]
93
+ }
94
+ await self._send(batch, payload)
95
+
96
+ async def _send(self, batch: List[Dict[str, Any]], payload: Dict[str, Any]) -> None:
97
+ try:
98
+ resp = await self._http.post(
99
+ f"{self._o.base_url}/sdk/events",
100
+ headers={"content-type": "application/json", "x-api-key": self._o.api_key},
101
+ json=payload,
102
+ )
103
+ if resp.status_code >= 400:
104
+ raise RuntimeError(f"events POST {resp.status_code}")
105
+ self._retry = 0
106
+ self._next_send_at = 0.0
107
+ except Exception as err: # noqa: BLE001 — network/HTTP failures are expected
108
+ if self._retry >= self._o.max_retries:
109
+ get_logger(self._o.logger).error(
110
+ "[event-bridge] events batch dropped after retries: %s", err
111
+ )
112
+ self._retry = 0
113
+ self._next_send_at = 0.0
114
+ return
115
+ self._retry += 1
116
+ # Re-queue at the head and back off; the interval loop retries.
117
+ self._buffer[0:0] = batch
118
+ self._cap_buffer()
119
+ delay = min(60.0, 1.0 * (2 ** self._retry))
120
+ self._next_send_at = time.monotonic() + delay
121
+ get_logger(self._o.logger).warning(
122
+ "[event-bridge] events retry in %.0fs (attempt %d)", delay, self._retry
123
+ )
124
+
125
+ async def drain(self) -> None:
126
+ """Stop the timer and make a bounded best-effort attempt to flush what's
127
+ left (used on shutdown). Won't spin forever if the backend is down."""
128
+ self._stopped = True
129
+ if self._task is not None:
130
+ self._wake.set()
131
+ await asyncio.gather(self._task, return_exceptions=True)
132
+ self._task = None
133
+ for _ in range(self._o.max_retries + 1):
134
+ if not self._buffer:
135
+ break
136
+ self._next_send_at = 0.0 # ignore backoff on shutdown
137
+ before = len(self._buffer)
138
+ await self.flush()
139
+ if self._buffer and len(self._buffer) >= before:
140
+ break # not making progress — give up rather than hang