cue-notify-client 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
cue_client/__init__.py ADDED
@@ -0,0 +1,16 @@
1
+ """Python client for Cue: send events and messages, and verify Cue's signed webhooks."""
2
+
3
+ __version__ = "0.1.0"
4
+
5
+ from cue_client.client import AsyncCue, Cue, CueError, Importance
6
+ from cue_client.webhooks import SIGNATURE_HEADER, InvalidSignatureError, verify_webhook
7
+
8
+ __all__ = [
9
+ "SIGNATURE_HEADER",
10
+ "AsyncCue",
11
+ "Cue",
12
+ "CueError",
13
+ "Importance",
14
+ "InvalidSignatureError",
15
+ "verify_webhook",
16
+ ]
cue_client/client.py ADDED
@@ -0,0 +1,333 @@
1
+ """Synchronous and asynchronous clients for Cue's HTTP API."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import time
7
+ from collections.abc import Mapping
8
+ from datetime import datetime
9
+ from typing import Any, Literal
10
+ from urllib.parse import quote
11
+
12
+ import httpx
13
+
14
+ from cue_client import __version__
15
+
16
+ Importance = Literal["low", "normal", "high", "critical"]
17
+ JSON = dict[str, Any]
18
+
19
+ RETRY_STATUSES = frozenset({429, 502, 503, 504})
20
+
21
+
22
+ class CueError(Exception):
23
+ """An error response from Cue (an RFC 9457 problem document)."""
24
+
25
+ def __init__(self, status: int, title: str, detail: str, errors: list[Any] | None) -> None:
26
+ super().__init__(f"{status} {title}: {detail}")
27
+ self.status = status
28
+ self.title = title
29
+ self.detail = detail
30
+ self.errors = errors or []
31
+
32
+
33
+ def _raise_for_problem(response: httpx.Response) -> None:
34
+ if response.is_success:
35
+ return
36
+ try:
37
+ problem: Mapping[str, Any] = response.json()
38
+ except ValueError:
39
+ problem = {}
40
+ raise CueError(
41
+ response.status_code,
42
+ str(problem.get("title", response.reason_phrase)),
43
+ str(problem.get("detail", response.text[:300])),
44
+ problem.get("errors"),
45
+ )
46
+
47
+
48
+ def _drop_none(values: Mapping[str, Any]) -> JSON:
49
+ return {key: value for key, value in values.items() if value is not None}
50
+
51
+
52
+ def _iso(value: datetime | None) -> str | None:
53
+ return value.isoformat() if value is not None else None
54
+
55
+
56
+ def _event(
57
+ name: str,
58
+ recipient: str | Mapping[str, Any],
59
+ data: Mapping[str, Any] | None,
60
+ idempotency_key: str | None,
61
+ occurred_at: datetime | None,
62
+ ) -> JSON:
63
+ return _drop_none(
64
+ {
65
+ "name": name,
66
+ "recipient": recipient,
67
+ "data": dict(data or {}),
68
+ "idempotency_key": idempotency_key,
69
+ "occurred_at": _iso(occurred_at),
70
+ }
71
+ )
72
+
73
+
74
+ def _message(
75
+ recipient: str,
76
+ template: str,
77
+ channels: list[str],
78
+ data: Mapping[str, Any] | None,
79
+ category: str,
80
+ importance: Importance,
81
+ expires_at: datetime | None,
82
+ send_at: datetime | None,
83
+ idempotency_key: str | None,
84
+ ) -> JSON:
85
+ return _drop_none(
86
+ {
87
+ "recipient": recipient,
88
+ "template": template,
89
+ "channels": channels,
90
+ "data": dict(data or {}),
91
+ "category": category,
92
+ "importance": importance,
93
+ "expires_at": _iso(expires_at),
94
+ "send_at": _iso(send_at),
95
+ "idempotency_key": idempotency_key,
96
+ }
97
+ )
98
+
99
+
100
+ def _profile(
101
+ locale: str | None,
102
+ timezone: str | None,
103
+ attributes: Mapping[str, Any] | None,
104
+ addresses: list[Mapping[str, str]] | None,
105
+ ) -> JSON:
106
+ return _drop_none(
107
+ {
108
+ "locale": locale,
109
+ "timezone": timezone,
110
+ "attributes": dict(attributes) if attributes is not None else None,
111
+ "addresses": [dict(a) for a in addresses] if addresses is not None else None,
112
+ }
113
+ )
114
+
115
+
116
+ def _headers(api_key: str) -> dict[str, str]:
117
+ return {
118
+ "Authorization": f"Bearer {api_key}",
119
+ "User-Agent": f"cue-notify-client-python/{__version__}",
120
+ }
121
+
122
+
123
+ class Cue:
124
+ """Client for Cue's API.
125
+
126
+ ``retries`` applies to rate limiting and gateway errors on requests that are safe to
127
+ repeat: reads, profile updates, and sends that carry an idempotency key.
128
+ """
129
+
130
+ def __init__(
131
+ self,
132
+ api_key: str,
133
+ base_url: str = "http://127.0.0.1:8000",
134
+ *,
135
+ timeout: float = 10.0,
136
+ retries: int = 2,
137
+ transport: httpx.BaseTransport | None = None,
138
+ ) -> None:
139
+ self._http = httpx.Client(
140
+ base_url=base_url, headers=_headers(api_key), timeout=timeout, transport=transport
141
+ )
142
+ self._retries = retries
143
+
144
+ def __enter__(self) -> Cue:
145
+ return self
146
+
147
+ def __exit__(self, *_: object) -> None:
148
+ self.close()
149
+
150
+ def close(self) -> None:
151
+ self._http.close()
152
+
153
+ def _request(self, method: str, path: str, *, safe: bool, **kwargs: Any) -> Any:
154
+ attempts = self._retries + 1 if safe else 1
155
+ response = self._http.request(method, path, **kwargs)
156
+ for attempt in range(1, attempts):
157
+ if response.status_code not in RETRY_STATUSES:
158
+ break
159
+ time.sleep(_backoff(response, attempt))
160
+ response = self._http.request(method, path, **kwargs)
161
+ _raise_for_problem(response)
162
+ return response.json() if response.content else None
163
+
164
+ def send_event(
165
+ self,
166
+ name: str,
167
+ recipient: str | Mapping[str, Any],
168
+ data: Mapping[str, Any] | None = None,
169
+ *,
170
+ idempotency_key: str | None = None,
171
+ occurred_at: datetime | None = None,
172
+ ) -> JSON:
173
+ """Report that something happened; Cue's rules decide what to send."""
174
+ body = _event(name, recipient, data, idempotency_key, occurred_at)
175
+ return self._request("POST", "/v1/events", json=body, safe=idempotency_key is not None)
176
+
177
+ def send_message(
178
+ self,
179
+ recipient: str,
180
+ template: str,
181
+ channels: list[str],
182
+ data: Mapping[str, Any] | None = None,
183
+ *,
184
+ category: str = "transactional",
185
+ importance: Importance = "normal",
186
+ expires_at: datetime | None = None,
187
+ send_at: datetime | None = None,
188
+ idempotency_key: str | None = None,
189
+ ) -> JSON:
190
+ """Send a template directly. Check ``status``: it may be ``suppressed``."""
191
+ body = _message(
192
+ recipient,
193
+ template,
194
+ channels,
195
+ data,
196
+ category,
197
+ importance,
198
+ expires_at,
199
+ send_at,
200
+ idempotency_key,
201
+ )
202
+ return self._request("POST", "/v1/messages", json=body, safe=idempotency_key is not None)
203
+
204
+ def upsert_recipient(
205
+ self,
206
+ external_id: str,
207
+ *,
208
+ locale: str | None = None,
209
+ timezone: str | None = None,
210
+ attributes: Mapping[str, Any] | None = None,
211
+ addresses: list[Mapping[str, str]] | None = None,
212
+ ) -> JSON:
213
+ body = _profile(locale, timezone, attributes, addresses)
214
+ return self._request("PUT", f"/v1/recipients/{_segment(external_id)}", json=body, safe=True)
215
+
216
+ def get_message(self, message_id: str) -> JSON:
217
+ return self._request("GET", f"/v1/messages/{_segment(message_id)}", safe=True)
218
+
219
+ def list_messages(self, **filters: str | int) -> JSON:
220
+ """Filters: ``recipient``, ``status``, ``event_id``, ``agent``, ``limit``, ``cursor``."""
221
+ return self._request("GET", "/v1/messages", params=filters, safe=True)
222
+
223
+
224
+ class AsyncCue:
225
+ """The asynchronous twin of :class:`Cue`."""
226
+
227
+ def __init__(
228
+ self,
229
+ api_key: str,
230
+ base_url: str = "http://127.0.0.1:8000",
231
+ *,
232
+ timeout: float = 10.0,
233
+ retries: int = 2,
234
+ transport: httpx.AsyncBaseTransport | None = None,
235
+ ) -> None:
236
+ self._http = httpx.AsyncClient(
237
+ base_url=base_url, headers=_headers(api_key), timeout=timeout, transport=transport
238
+ )
239
+ self._retries = retries
240
+
241
+ async def __aenter__(self) -> AsyncCue:
242
+ return self
243
+
244
+ async def __aexit__(self, *_: object) -> None:
245
+ await self.aclose()
246
+
247
+ async def aclose(self) -> None:
248
+ await self._http.aclose()
249
+
250
+ async def _request(self, method: str, path: str, *, safe: bool, **kwargs: Any) -> Any:
251
+ attempts = self._retries + 1 if safe else 1
252
+ response = await self._http.request(method, path, **kwargs)
253
+ for attempt in range(1, attempts):
254
+ if response.status_code not in RETRY_STATUSES:
255
+ break
256
+ await asyncio.sleep(_backoff(response, attempt))
257
+ response = await self._http.request(method, path, **kwargs)
258
+ _raise_for_problem(response)
259
+ return response.json() if response.content else None
260
+
261
+ async def send_event(
262
+ self,
263
+ name: str,
264
+ recipient: str | Mapping[str, Any],
265
+ data: Mapping[str, Any] | None = None,
266
+ *,
267
+ idempotency_key: str | None = None,
268
+ occurred_at: datetime | None = None,
269
+ ) -> JSON:
270
+ body = _event(name, recipient, data, idempotency_key, occurred_at)
271
+ return await self._request(
272
+ "POST", "/v1/events", json=body, safe=idempotency_key is not None
273
+ )
274
+
275
+ async def send_message(
276
+ self,
277
+ recipient: str,
278
+ template: str,
279
+ channels: list[str],
280
+ data: Mapping[str, Any] | None = None,
281
+ *,
282
+ category: str = "transactional",
283
+ importance: Importance = "normal",
284
+ expires_at: datetime | None = None,
285
+ send_at: datetime | None = None,
286
+ idempotency_key: str | None = None,
287
+ ) -> JSON:
288
+ body = _message(
289
+ recipient,
290
+ template,
291
+ channels,
292
+ data,
293
+ category,
294
+ importance,
295
+ expires_at,
296
+ send_at,
297
+ idempotency_key,
298
+ )
299
+ return await self._request(
300
+ "POST", "/v1/messages", json=body, safe=idempotency_key is not None
301
+ )
302
+
303
+ async def upsert_recipient(
304
+ self,
305
+ external_id: str,
306
+ *,
307
+ locale: str | None = None,
308
+ timezone: str | None = None,
309
+ attributes: Mapping[str, Any] | None = None,
310
+ addresses: list[Mapping[str, str]] | None = None,
311
+ ) -> JSON:
312
+ body = _profile(locale, timezone, attributes, addresses)
313
+ return await self._request(
314
+ "PUT", f"/v1/recipients/{_segment(external_id)}", json=body, safe=True
315
+ )
316
+
317
+ async def get_message(self, message_id: str) -> JSON:
318
+ return await self._request("GET", f"/v1/messages/{_segment(message_id)}", safe=True)
319
+
320
+ async def list_messages(self, **filters: str | int) -> JSON:
321
+ return await self._request("GET", "/v1/messages", params=filters, safe=True)
322
+
323
+
324
+ def _segment(value: str) -> str:
325
+ """A path segment that cannot step into another endpoint."""
326
+ return quote(value, safe="")
327
+
328
+
329
+ def _backoff(response: httpx.Response, attempt: int) -> float:
330
+ retry_after = response.headers.get("Retry-After")
331
+ if retry_after and retry_after.isdigit():
332
+ return min(float(retry_after), 30.0)
333
+ return min(0.5 * 2 ** (attempt - 1), 8.0)
cue_client/py.typed ADDED
File without changes
cue_client/webhooks.py ADDED
@@ -0,0 +1,48 @@
1
+ """Verify the signature on requests Cue's webhook connector sends to your service.
2
+
3
+ Cue signs the raw body: ``Cue-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256 of
4
+ "<t>.<raw body>">``. Verify against the bytes you received, before parsing them, and
5
+ reject old timestamps so a captured request cannot be replayed.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import hashlib
11
+ import hmac
12
+ import time
13
+
14
+ SIGNATURE_HEADER = "Cue-Signature"
15
+ DEFAULT_TOLERANCE = 300
16
+
17
+
18
+ class InvalidSignatureError(Exception):
19
+ """The request was not signed by Cue with this secret, or is too old."""
20
+
21
+
22
+ def verify_webhook(
23
+ body: bytes,
24
+ signature: str | None,
25
+ secret: str,
26
+ *,
27
+ tolerance: int = DEFAULT_TOLERANCE,
28
+ now: float | None = None,
29
+ ) -> None:
30
+ """Raise :class:`InvalidSignatureError` unless ``signature`` (the ``Cue-Signature``
31
+ header) is a valid, fresh signature of ``body``.
32
+
33
+ ``tolerance`` is the accepted clock difference in seconds.
34
+ """
35
+ if not signature:
36
+ raise InvalidSignatureError("missing Cue-Signature header")
37
+ parts = dict(part.split("=", 1) for part in signature.split(",") if "=" in part)
38
+ try:
39
+ timestamp = int(parts["t"])
40
+ candidate = parts["v1"]
41
+ except (KeyError, ValueError) as exc:
42
+ raise InvalidSignatureError("malformed Cue-Signature header") from exc
43
+ current = time.time() if now is None else now
44
+ if abs(current - timestamp) > tolerance:
45
+ raise InvalidSignatureError("signature timestamp is outside the tolerance")
46
+ expected = hmac.new(secret.encode(), f"{timestamp}.".encode() + body, hashlib.sha256)
47
+ if not hmac.compare_digest(expected.hexdigest(), candidate):
48
+ raise InvalidSignatureError("signature does not match")
@@ -0,0 +1,66 @@
1
+ Metadata-Version: 2.5
2
+ Name: cue-notify-client
3
+ Version: 0.1.0
4
+ Summary: Python client for Cue: send events and messages, and verify Cue's signed webhooks.
5
+ Project-URL: Homepage, https://github.com/murtazox04/Cue/tree/main/sdks/python
6
+ Project-URL: Documentation, https://murtazox04.github.io/Cue
7
+ Author: Murtazo Xurramov
8
+ License-Expression: MIT
9
+ Keywords: cue,notifications,sdk,webhooks
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Typing :: Typed
13
+ Requires-Python: >=3.10
14
+ Requires-Dist: httpx>=0.27
15
+ Description-Content-Type: text/markdown
16
+
17
+ # cue-notify-client
18
+
19
+ Python client for [Cue](https://github.com/murtazox04/Cue), the attention layer between
20
+ software, AI agents and people. Its only dependency is `httpx`.
21
+
22
+ ```bash
23
+ pip install cue-notify-client
24
+ ```
25
+
26
+ The package is imported as `cue_client`.
27
+
28
+ ```python
29
+ from cue_client import Cue
30
+
31
+ with Cue(api_key="ck_…", base_url="https://cue.example.com") as cue:
32
+ cue.upsert_recipient(
33
+ "user-42",
34
+ timezone="Europe/Berlin",
35
+ addresses=[{"channel": "email", "value": "ann@example.com"}],
36
+ )
37
+ cue.send_event(
38
+ "order.shipped", "user-42", {"order_id": "1001"}, idempotency_key="order-1001-shipped"
39
+ )
40
+ ```
41
+
42
+ `AsyncCue` has the same methods for `asyncio`. Errors raise `CueError` with the problem
43
+ details Cue returns (`status`, `title`, `detail`, `errors`). Rate limits and gateway
44
+ errors are retried automatically for requests that are safe to repeat: reads, profile
45
+ updates, and sends with an idempotency key.
46
+
47
+ ## Verifying webhooks
48
+
49
+ If Cue delivers to your service through its webhook connector, check every request
50
+ before trusting it:
51
+
52
+ ```python
53
+ from cue_client import InvalidSignatureError, verify_webhook
54
+
55
+
56
+ def handle(request):
57
+ try:
58
+ verify_webhook(request.body, request.headers.get("Cue-Signature"), secret=WEBHOOK_SECRET)
59
+ except InvalidSignatureError:
60
+ return 401
61
+ message = json.loads(request.body)
62
+ ...
63
+ ```
64
+
65
+ Verify the raw body bytes, before parsing them. Requests signed more than five minutes
66
+ ago are rejected (`tolerance=` changes this).
@@ -0,0 +1,7 @@
1
+ cue_client/__init__.py,sha256=coalsqGvq4IyuVoUZUYY64TmzabDO7MAOgdZ-ETeoKo,418
2
+ cue_client/client.py,sha256=7OKYADQ7mK6R1ed1AAkI6dGg-gPWoH_4iKHeMx7OGZI,10350
3
+ cue_client/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
4
+ cue_client/webhooks.py,sha256=vfr1Ns_9iV7uoLNt47xNGPa1XSIVbDx1s4pensb9NXQ,1728
5
+ cue_notify_client-0.1.0.dist-info/METADATA,sha256=DzUx8Dt4imhvE20ZwqkMrtXGC_qKP9xh8bCQGtKLBvs,2114
6
+ cue_notify_client-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
7
+ cue_notify_client-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any