posthook-python 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,10 @@
1
+ from ._hooks import AsyncBulkActions, AsyncHooksService, BulkActions, HooksService
2
+ from ._signatures import SignaturesService
3
+
4
+ __all__ = [
5
+ "HooksService",
6
+ "AsyncHooksService",
7
+ "BulkActions",
8
+ "AsyncBulkActions",
9
+ "SignaturesService",
10
+ ]
@@ -0,0 +1,517 @@
1
+ from __future__ import annotations
2
+
3
+ from datetime import datetime, timezone
4
+ from typing import Any, AsyncIterator, Iterator
5
+ from urllib.parse import quote
6
+
7
+
8
+ from .._errors import NotFoundError
9
+ from .._http import AsyncHttpClient, SyncHttpClient, _parse_quota
10
+ from .._models import BulkActionResult, Hook, HookRetryOverride
11
+
12
+
13
+ def _build_schedule_body(
14
+ path: str,
15
+ *,
16
+ data: Any = None,
17
+ post_at: datetime | str | None = None,
18
+ post_at_local: str | None = None,
19
+ timezone_str: str | None = None,
20
+ post_in: str | None = None,
21
+ retry_override: HookRetryOverride | None = None,
22
+ ) -> dict[str, Any]:
23
+ modes = sum(x is not None for x in (post_at, post_at_local, post_in))
24
+ if modes == 0:
25
+ raise ValueError(
26
+ "Exactly one scheduling mode is required: post_at, post_at_local, or post_in"
27
+ )
28
+ if modes > 1:
29
+ raise ValueError(
30
+ "Only one scheduling mode allowed: post_at, post_at_local, or post_in"
31
+ )
32
+
33
+ body: dict[str, Any] = {"path": path}
34
+ if data is not None:
35
+ body["data"] = data
36
+ if post_at is not None:
37
+ if isinstance(post_at, datetime):
38
+ if post_at.tzinfo is None:
39
+ raise ValueError(
40
+ "post_at datetime must be timezone-aware. "
41
+ "Use datetime.now(timezone.utc) or datetime(..., tzinfo=timezone.utc)"
42
+ )
43
+ body["postAt"] = post_at.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
44
+ else:
45
+ body["postAt"] = post_at
46
+ elif post_at_local is not None:
47
+ body["postAtLocal"] = post_at_local
48
+ if timezone_str is not None:
49
+ body["timezone"] = timezone_str
50
+ elif post_in is not None:
51
+ body["postIn"] = post_in
52
+ if retry_override is not None:
53
+ body["retryOverride"] = retry_override.to_dict()
54
+ return body
55
+
56
+
57
+ def _build_list_params(
58
+ *,
59
+ status: str | None = None,
60
+ limit: int | None = None,
61
+ offset: int | None = None,
62
+ post_at_before: str | None = None,
63
+ post_at_after: str | None = None,
64
+ created_at_before: str | None = None,
65
+ created_at_after: str | None = None,
66
+ sort_by: str | None = None,
67
+ sort_order: str | None = None,
68
+ ) -> dict[str, Any]:
69
+ params: dict[str, Any] = {}
70
+ if status is not None:
71
+ params["status"] = status
72
+ if limit is not None:
73
+ params["limit"] = limit
74
+ if offset is not None:
75
+ params["offset"] = offset
76
+ if post_at_before is not None:
77
+ params["postAtBefore"] = post_at_before
78
+ if post_at_after is not None:
79
+ params["postAtAfter"] = post_at_after
80
+ if created_at_before is not None:
81
+ params["createdAtBefore"] = created_at_before
82
+ if created_at_after is not None:
83
+ params["createdAtAfter"] = created_at_after
84
+ if sort_by is not None:
85
+ params["sortBy"] = sort_by
86
+ if sort_order is not None:
87
+ params["sortOrder"] = sort_order
88
+ return params
89
+
90
+
91
+ def _build_bulk_body_by_ids(
92
+ hook_ids: list[str],
93
+ ) -> dict[str, Any]:
94
+ return {"hookIDs": hook_ids}
95
+
96
+
97
+ def _build_bulk_body_by_filter(
98
+ start_time: str,
99
+ end_time: str,
100
+ limit: int,
101
+ *,
102
+ endpoint_key: str | None = None,
103
+ sequence_id: str | None = None,
104
+ ) -> dict[str, Any]:
105
+ body: dict[str, Any] = {
106
+ "startTime": start_time,
107
+ "endTime": end_time,
108
+ "limit": limit,
109
+ }
110
+ if endpoint_key is not None:
111
+ body["endpointKey"] = endpoint_key
112
+ if sequence_id is not None:
113
+ body["sequenceID"] = sequence_id
114
+ return body
115
+
116
+
117
+ class BulkActions:
118
+ """Synchronous sub-resource for bulk hook actions."""
119
+
120
+ def __init__(self, http: SyncHttpClient) -> None:
121
+ self._http = http
122
+
123
+ def _do(
124
+ self, path: str, body: dict[str, Any], *, timeout: float | None = None,
125
+ ) -> BulkActionResult:
126
+ data, _ = self._http.request_data("POST", path, json=body, timeout=timeout)
127
+ return BulkActionResult.from_dict(data)
128
+
129
+ def retry(
130
+ self, hook_ids: list[str], *, timeout: float | None = None,
131
+ ) -> BulkActionResult:
132
+ return self._do(
133
+ "/v1/hooks/bulk/retry", _build_bulk_body_by_ids(hook_ids),
134
+ timeout=timeout,
135
+ )
136
+
137
+ def retry_by_filter(
138
+ self,
139
+ start_time: str,
140
+ end_time: str,
141
+ limit: int,
142
+ *,
143
+ endpoint_key: str | None = None,
144
+ sequence_id: str | None = None,
145
+ timeout: float | None = None,
146
+ ) -> BulkActionResult:
147
+ return self._do(
148
+ "/v1/hooks/bulk/retry",
149
+ _build_bulk_body_by_filter(
150
+ start_time, end_time, limit,
151
+ endpoint_key=endpoint_key, sequence_id=sequence_id,
152
+ ),
153
+ timeout=timeout,
154
+ )
155
+
156
+ def replay(
157
+ self, hook_ids: list[str], *, timeout: float | None = None,
158
+ ) -> BulkActionResult:
159
+ return self._do(
160
+ "/v1/hooks/bulk/replay", _build_bulk_body_by_ids(hook_ids),
161
+ timeout=timeout,
162
+ )
163
+
164
+ def replay_by_filter(
165
+ self,
166
+ start_time: str,
167
+ end_time: str,
168
+ limit: int,
169
+ *,
170
+ endpoint_key: str | None = None,
171
+ sequence_id: str | None = None,
172
+ timeout: float | None = None,
173
+ ) -> BulkActionResult:
174
+ return self._do(
175
+ "/v1/hooks/bulk/replay",
176
+ _build_bulk_body_by_filter(
177
+ start_time, end_time, limit,
178
+ endpoint_key=endpoint_key, sequence_id=sequence_id,
179
+ ),
180
+ timeout=timeout,
181
+ )
182
+
183
+ def cancel(
184
+ self, hook_ids: list[str], *, timeout: float | None = None,
185
+ ) -> BulkActionResult:
186
+ return self._do(
187
+ "/v1/hooks/bulk/cancel", _build_bulk_body_by_ids(hook_ids),
188
+ timeout=timeout,
189
+ )
190
+
191
+ def cancel_by_filter(
192
+ self,
193
+ start_time: str,
194
+ end_time: str,
195
+ limit: int,
196
+ *,
197
+ endpoint_key: str | None = None,
198
+ sequence_id: str | None = None,
199
+ timeout: float | None = None,
200
+ ) -> BulkActionResult:
201
+ return self._do(
202
+ "/v1/hooks/bulk/cancel",
203
+ _build_bulk_body_by_filter(
204
+ start_time, end_time, limit,
205
+ endpoint_key=endpoint_key, sequence_id=sequence_id,
206
+ ),
207
+ timeout=timeout,
208
+ )
209
+
210
+
211
+ class HooksService:
212
+ """Synchronous resource for managing hooks."""
213
+
214
+ def __init__(self, http: SyncHttpClient) -> None:
215
+ self._http = http
216
+ self.bulk = BulkActions(http)
217
+
218
+ def schedule(
219
+ self,
220
+ path: str,
221
+ *,
222
+ data: Any = None,
223
+ post_at: datetime | str | None = None,
224
+ post_at_local: str | None = None,
225
+ timezone: str | None = None,
226
+ post_in: str | None = None,
227
+ retry_override: HookRetryOverride | None = None,
228
+ timeout: float | None = None,
229
+ ) -> Hook:
230
+ body = _build_schedule_body(
231
+ path,
232
+ data=data,
233
+ post_at=post_at,
234
+ post_at_local=post_at_local,
235
+ timezone_str=timezone,
236
+ post_in=post_in,
237
+ retry_override=retry_override,
238
+ )
239
+ resp_data, headers = self._http.request_data(
240
+ "POST", "/v1/hooks", json=body, timeout=timeout,
241
+ )
242
+ hook = Hook.from_dict(resp_data)
243
+ hook.quota = _parse_quota(headers)
244
+ return hook
245
+
246
+ def get(self, id: str, *, timeout: float | None = None) -> Hook:
247
+ if not id:
248
+ raise ValueError("hook id is required")
249
+ data, _ = self._http.request_data(
250
+ "GET", f"/v1/hooks/{quote(id, safe='')}", timeout=timeout,
251
+ )
252
+ return Hook.from_dict(data)
253
+
254
+ def list(
255
+ self,
256
+ *,
257
+ status: str | None = None,
258
+ limit: int | None = None,
259
+ offset: int | None = None,
260
+ post_at_before: str | None = None,
261
+ post_at_after: str | None = None,
262
+ created_at_before: str | None = None,
263
+ created_at_after: str | None = None,
264
+ sort_by: str | None = None,
265
+ sort_order: str | None = None,
266
+ timeout: float | None = None,
267
+ ) -> list[Hook]:
268
+ params = _build_list_params(
269
+ status=status,
270
+ limit=limit,
271
+ offset=offset,
272
+ post_at_before=post_at_before,
273
+ post_at_after=post_at_after,
274
+ created_at_before=created_at_before,
275
+ created_at_after=created_at_after,
276
+ sort_by=sort_by,
277
+ sort_order=sort_order,
278
+ )
279
+ raw_list, _ = self._http.request_data(
280
+ "GET", "/v1/hooks", params=params, timeout=timeout,
281
+ )
282
+ return [Hook.from_dict(h) for h in (raw_list or [])]
283
+
284
+ def list_all(
285
+ self,
286
+ *,
287
+ status: str | None = None,
288
+ post_at_after: str | None = None,
289
+ page_size: int = 100,
290
+ timeout: float | None = None,
291
+ ) -> Iterator[Hook]:
292
+ cursor: str | None = post_at_after
293
+ while True:
294
+ hooks = self.list(
295
+ status=status,
296
+ limit=page_size,
297
+ sort_by="postAt",
298
+ sort_order="ASC",
299
+ post_at_after=cursor,
300
+ timeout=timeout,
301
+ )
302
+ yield from hooks
303
+ if len(hooks) < page_size:
304
+ break
305
+ cursor = hooks[-1].post_at.astimezone(timezone.utc).isoformat()
306
+
307
+ def delete(self, id: str, *, timeout: float | None = None) -> None:
308
+ if not id:
309
+ raise ValueError("hook id is required")
310
+ try:
311
+ self._http.request_data(
312
+ "DELETE", f"/v1/hooks/{quote(id, safe='')}", timeout=timeout,
313
+ )
314
+ except NotFoundError:
315
+ pass
316
+
317
+
318
+ class AsyncBulkActions:
319
+ """Asynchronous sub-resource for bulk hook actions."""
320
+
321
+ def __init__(self, http: AsyncHttpClient) -> None:
322
+ self._http = http
323
+
324
+ async def _do(
325
+ self, path: str, body: dict[str, Any], *, timeout: float | None = None,
326
+ ) -> BulkActionResult:
327
+ data, _ = await self._http.request_data("POST", path, json=body, timeout=timeout)
328
+ return BulkActionResult.from_dict(data)
329
+
330
+ async def retry(
331
+ self, hook_ids: list[str], *, timeout: float | None = None,
332
+ ) -> BulkActionResult:
333
+ return await self._do(
334
+ "/v1/hooks/bulk/retry", _build_bulk_body_by_ids(hook_ids),
335
+ timeout=timeout,
336
+ )
337
+
338
+ async def retry_by_filter(
339
+ self,
340
+ start_time: str,
341
+ end_time: str,
342
+ limit: int,
343
+ *,
344
+ endpoint_key: str | None = None,
345
+ sequence_id: str | None = None,
346
+ timeout: float | None = None,
347
+ ) -> BulkActionResult:
348
+ return await self._do(
349
+ "/v1/hooks/bulk/retry",
350
+ _build_bulk_body_by_filter(
351
+ start_time, end_time, limit,
352
+ endpoint_key=endpoint_key, sequence_id=sequence_id,
353
+ ),
354
+ timeout=timeout,
355
+ )
356
+
357
+ async def replay(
358
+ self, hook_ids: list[str], *, timeout: float | None = None,
359
+ ) -> BulkActionResult:
360
+ return await self._do(
361
+ "/v1/hooks/bulk/replay", _build_bulk_body_by_ids(hook_ids),
362
+ timeout=timeout,
363
+ )
364
+
365
+ async def replay_by_filter(
366
+ self,
367
+ start_time: str,
368
+ end_time: str,
369
+ limit: int,
370
+ *,
371
+ endpoint_key: str | None = None,
372
+ sequence_id: str | None = None,
373
+ timeout: float | None = None,
374
+ ) -> BulkActionResult:
375
+ return await self._do(
376
+ "/v1/hooks/bulk/replay",
377
+ _build_bulk_body_by_filter(
378
+ start_time, end_time, limit,
379
+ endpoint_key=endpoint_key, sequence_id=sequence_id,
380
+ ),
381
+ timeout=timeout,
382
+ )
383
+
384
+ async def cancel(
385
+ self, hook_ids: list[str], *, timeout: float | None = None,
386
+ ) -> BulkActionResult:
387
+ return await self._do(
388
+ "/v1/hooks/bulk/cancel", _build_bulk_body_by_ids(hook_ids),
389
+ timeout=timeout,
390
+ )
391
+
392
+ async def cancel_by_filter(
393
+ self,
394
+ start_time: str,
395
+ end_time: str,
396
+ limit: int,
397
+ *,
398
+ endpoint_key: str | None = None,
399
+ sequence_id: str | None = None,
400
+ timeout: float | None = None,
401
+ ) -> BulkActionResult:
402
+ return await self._do(
403
+ "/v1/hooks/bulk/cancel",
404
+ _build_bulk_body_by_filter(
405
+ start_time, end_time, limit,
406
+ endpoint_key=endpoint_key, sequence_id=sequence_id,
407
+ ),
408
+ timeout=timeout,
409
+ )
410
+
411
+
412
+ class AsyncHooksService:
413
+ """Asynchronous resource for managing hooks."""
414
+
415
+ def __init__(self, http: AsyncHttpClient) -> None:
416
+ self._http = http
417
+ self.bulk = AsyncBulkActions(http)
418
+
419
+ async def schedule(
420
+ self,
421
+ path: str,
422
+ *,
423
+ data: Any = None,
424
+ post_at: datetime | str | None = None,
425
+ post_at_local: str | None = None,
426
+ timezone: str | None = None,
427
+ post_in: str | None = None,
428
+ retry_override: HookRetryOverride | None = None,
429
+ timeout: float | None = None,
430
+ ) -> Hook:
431
+ body = _build_schedule_body(
432
+ path,
433
+ data=data,
434
+ post_at=post_at,
435
+ post_at_local=post_at_local,
436
+ timezone_str=timezone,
437
+ post_in=post_in,
438
+ retry_override=retry_override,
439
+ )
440
+ resp_data, headers = await self._http.request_data(
441
+ "POST", "/v1/hooks", json=body, timeout=timeout,
442
+ )
443
+ hook = Hook.from_dict(resp_data)
444
+ hook.quota = _parse_quota(headers)
445
+ return hook
446
+
447
+ async def get(self, id: str, *, timeout: float | None = None) -> Hook:
448
+ if not id:
449
+ raise ValueError("hook id is required")
450
+ data, _ = await self._http.request_data(
451
+ "GET", f"/v1/hooks/{quote(id, safe='')}", timeout=timeout,
452
+ )
453
+ return Hook.from_dict(data)
454
+
455
+ async def list(
456
+ self,
457
+ *,
458
+ status: str | None = None,
459
+ limit: int | None = None,
460
+ offset: int | None = None,
461
+ post_at_before: str | None = None,
462
+ post_at_after: str | None = None,
463
+ created_at_before: str | None = None,
464
+ created_at_after: str | None = None,
465
+ sort_by: str | None = None,
466
+ sort_order: str | None = None,
467
+ timeout: float | None = None,
468
+ ) -> list[Hook]:
469
+ params = _build_list_params(
470
+ status=status,
471
+ limit=limit,
472
+ offset=offset,
473
+ post_at_before=post_at_before,
474
+ post_at_after=post_at_after,
475
+ created_at_before=created_at_before,
476
+ created_at_after=created_at_after,
477
+ sort_by=sort_by,
478
+ sort_order=sort_order,
479
+ )
480
+ raw_list, _ = await self._http.request_data(
481
+ "GET", "/v1/hooks", params=params, timeout=timeout,
482
+ )
483
+ return [Hook.from_dict(h) for h in (raw_list or [])]
484
+
485
+ async def list_all(
486
+ self,
487
+ *,
488
+ status: str | None = None,
489
+ post_at_after: str | None = None,
490
+ page_size: int = 100,
491
+ timeout: float | None = None,
492
+ ) -> AsyncIterator[Hook]:
493
+ cursor: str | None = post_at_after
494
+ while True:
495
+ hooks = await self.list(
496
+ status=status,
497
+ limit=page_size,
498
+ sort_by="postAt",
499
+ sort_order="ASC",
500
+ post_at_after=cursor,
501
+ timeout=timeout,
502
+ )
503
+ for hook in hooks:
504
+ yield hook
505
+ if len(hooks) < page_size:
506
+ break
507
+ cursor = hooks[-1].post_at.astimezone(timezone.utc).isoformat()
508
+
509
+ async def delete(self, id: str, *, timeout: float | None = None) -> None:
510
+ if not id:
511
+ raise ValueError("hook id is required")
512
+ try:
513
+ await self._http.request_data(
514
+ "DELETE", f"/v1/hooks/{quote(id, safe='')}", timeout=timeout,
515
+ )
516
+ except NotFoundError:
517
+ pass
@@ -0,0 +1,169 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import hmac
5
+ import json
6
+ import os
7
+ import time
8
+ from collections.abc import Mapping
9
+ from typing import Any
10
+
11
+ from .._errors import SignatureVerificationError
12
+ from .._models import Delivery, _parse_dt
13
+
14
+ DEFAULT_TOLERANCE = 300 # 5 minutes in seconds
15
+
16
+
17
+ def _get_header(headers: Mapping[str, Any], name: str) -> str | None:
18
+ """Case-insensitive header lookup."""
19
+ # Try exact match first
20
+ val = headers.get(name)
21
+ if val is not None:
22
+ if isinstance(val, list):
23
+ return val[0] if val else None
24
+ return str(val)
25
+
26
+ # Try lowercase
27
+ lower = name.lower()
28
+ for key, value in headers.items():
29
+ if key.lower() == lower:
30
+ if isinstance(value, list):
31
+ return value[0] if value else None
32
+ return str(value)
33
+ return None
34
+
35
+
36
+ def _compute_signature(key: str, timestamp: int, body_bytes: bytes) -> str:
37
+ """Compute HMAC-SHA256 signature: v1,<hex>.
38
+
39
+ Uses incremental mac.update() to avoid copying the body for large payloads.
40
+ """
41
+ mac = hmac.new(key.encode(), digestmod=hashlib.sha256)
42
+ mac.update(f"{timestamp}.".encode())
43
+ mac.update(body_bytes)
44
+ return f"v1,{mac.hexdigest()}"
45
+
46
+
47
+ def _safe_compare(a: str, b: str) -> bool:
48
+ """Constant-time string comparison."""
49
+ return hmac.compare_digest(a.encode(), b.encode())
50
+
51
+
52
+ class SignaturesService:
53
+ """Webhook signature verification. Shared by sync and async clients (no I/O)."""
54
+
55
+ def __init__(self, signing_key: str | None = None) -> None:
56
+ self._signing_key = signing_key
57
+
58
+ def parse_delivery(
59
+ self,
60
+ body: bytes | str,
61
+ headers: Mapping[str, Any],
62
+ *,
63
+ signing_key: str | None = None,
64
+ tolerance: int = DEFAULT_TOLERANCE,
65
+ ) -> Delivery:
66
+ """Verify the webhook signature and parse the delivery payload.
67
+
68
+ Args:
69
+ body: The raw HTTP request body (bytes or string).
70
+ headers: The request headers as a mapping (dict, HTTPMessage, etc.).
71
+ signing_key: Override the client's signing key for this call.
72
+ tolerance: Maximum age of the timestamp in seconds (default: 300).
73
+
74
+ Returns:
75
+ A Delivery object with the parsed and verified payload.
76
+
77
+ Raises:
78
+ SignatureVerificationError: If verification fails.
79
+ """
80
+ key = signing_key or self._signing_key
81
+ if not key:
82
+ raise SignatureVerificationError(
83
+ "No signing key provided. Pass signing_key to the Posthook constructor "
84
+ "or to parse_delivery()."
85
+ )
86
+
87
+ hook_id = _get_header(headers, "Posthook-Id") or ""
88
+
89
+ timestamp_str = _get_header(headers, "Posthook-Timestamp")
90
+ if not timestamp_str:
91
+ raise SignatureVerificationError("Missing Posthook-Timestamp header")
92
+
93
+ signature = _get_header(headers, "Posthook-Signature")
94
+ if not signature:
95
+ raise SignatureVerificationError("Missing Posthook-Signature header")
96
+
97
+ try:
98
+ timestamp = int(timestamp_str)
99
+ except ValueError:
100
+ raise SignatureVerificationError(
101
+ f"Invalid Posthook-Timestamp: {timestamp_str}"
102
+ )
103
+
104
+ now = int(time.time())
105
+ diff = abs(now - timestamp)
106
+ if diff > tolerance:
107
+ raise SignatureVerificationError(
108
+ f"Timestamp too old: {diff}s difference exceeds {tolerance}s tolerance"
109
+ )
110
+
111
+ body_bytes = body if isinstance(body, bytes) else body.encode("utf-8")
112
+ expected_sig = _compute_signature(key, timestamp, body_bytes)
113
+
114
+ signatures = signature.split(" ")
115
+ verified = False
116
+ for sig in signatures:
117
+ if _safe_compare(sig, expected_sig):
118
+ verified = True
119
+ break
120
+
121
+ if not verified:
122
+ raise SignatureVerificationError("Signature verification failed")
123
+
124
+ try:
125
+ payload = json.loads(body_bytes)
126
+ except (json.JSONDecodeError, UnicodeDecodeError) as exc:
127
+ raise SignatureVerificationError(
128
+ f"Failed to parse delivery payload: {exc}"
129
+ )
130
+
131
+ return Delivery(
132
+ hook_id=hook_id,
133
+ timestamp=timestamp,
134
+ path=payload.get("path", ""),
135
+ data=payload.get("data"),
136
+ body=body_bytes,
137
+ post_at=_parse_dt(payload.get("postAt", "")),
138
+ posted_at=_parse_dt(payload.get("postedAt", "")),
139
+ created_at=_parse_dt(payload.get("createdAt", "")),
140
+ updated_at=_parse_dt(payload.get("updatedAt", "")),
141
+ )
142
+
143
+
144
+ def create_signatures(signing_key: str | None = None) -> SignaturesService:
145
+ """Create a standalone SignaturesService with fail-fast key validation.
146
+
147
+ This is the recommended way to create a ``SignaturesService`` for standalone
148
+ webhook verification (outside the ``Posthook`` / ``AsyncPosthook`` clients).
149
+ It ensures a valid signing key is available at construction time rather than
150
+ deferring the error to the first ``parse_delivery()`` call.
151
+
152
+ Args:
153
+ signing_key: Your Posthook signing key. Falls back to the
154
+ ``POSTHOOK_SIGNING_KEY`` environment variable if not provided.
155
+
156
+ Returns:
157
+ A configured ``SignaturesService`` instance.
158
+
159
+ Raises:
160
+ ValueError: If no signing key is provided and the environment variable
161
+ is not set or empty.
162
+ """
163
+ resolved = signing_key or os.environ.get("POSTHOOK_SIGNING_KEY", "")
164
+ if not resolved:
165
+ raise ValueError(
166
+ "No signing key provided. Pass signing_key to create_signatures() "
167
+ "or set the POSTHOOK_SIGNING_KEY environment variable."
168
+ )
169
+ return SignaturesService(resolved)
posthook/_version.py ADDED
@@ -0,0 +1 @@
1
+ VERSION = "1.0.0"
posthook/py.typed ADDED
File without changes