boomerang-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.
boomerang/__init__.py ADDED
@@ -0,0 +1,29 @@
1
+ from .client import BoomerangClient
2
+ from .errors import (
3
+ BoomerangConflictError,
4
+ BoomerangError,
5
+ BoomerangForbiddenError,
6
+ BoomerangServiceUnavailableError,
7
+ BoomerangUnauthorizedError,
8
+ )
9
+ from .models import (
10
+ BoomerangJobStatus,
11
+ BoomerangPayload,
12
+ BoomerangTriggerRequest,
13
+ BoomerangTriggerResponse,
14
+ )
15
+ from .signature import BoomerangSignature
16
+
17
+ __all__ = [
18
+ "BoomerangClient",
19
+ "BoomerangSignature",
20
+ "BoomerangError",
21
+ "BoomerangUnauthorizedError",
22
+ "BoomerangForbiddenError",
23
+ "BoomerangConflictError",
24
+ "BoomerangServiceUnavailableError",
25
+ "BoomerangTriggerRequest",
26
+ "BoomerangTriggerResponse",
27
+ "BoomerangJobStatus",
28
+ "BoomerangPayload",
29
+ ]
boomerang/client.py ADDED
@@ -0,0 +1,152 @@
1
+ from __future__ import annotations
2
+
3
+ from urllib.parse import quote
4
+
5
+ import httpx
6
+
7
+ from .errors import (
8
+ BoomerangConflictError,
9
+ BoomerangError,
10
+ BoomerangForbiddenError,
11
+ BoomerangServiceUnavailableError,
12
+ BoomerangUnauthorizedError,
13
+ )
14
+ from .models import (
15
+ BoomerangJobStatus,
16
+ BoomerangTriggerRequest,
17
+ BoomerangTriggerResponse,
18
+ )
19
+
20
+ _STATUS_MAP: dict[int, type[BoomerangError]] = {
21
+ 401: BoomerangUnauthorizedError,
22
+ 403: BoomerangForbiddenError,
23
+ 409: BoomerangConflictError,
24
+ 503: BoomerangServiceUnavailableError,
25
+ }
26
+
27
+
28
+ class BoomerangClient:
29
+ """Thin HTTP client for the Boomerang async webhook service."""
30
+
31
+ def __init__(self, base_url: str, token: str) -> None:
32
+ self._base_url = base_url.rstrip("/")
33
+ self._token = token
34
+ self._client = httpx.Client()
35
+ self._async_client = httpx.AsyncClient()
36
+
37
+ # --- lifecycle ---
38
+
39
+ def close(self) -> None:
40
+ """Close the underlying connection pools."""
41
+ self._client.close()
42
+
43
+ async def aclose(self) -> None:
44
+ """Close the underlying async connection pool."""
45
+ await self._async_client.aclose()
46
+
47
+ def __enter__(self) -> BoomerangClient:
48
+ return self
49
+
50
+ def __exit__(self, *args: object) -> None:
51
+ self.close()
52
+
53
+ async def __aenter__(self) -> BoomerangClient:
54
+ return self
55
+
56
+ async def __aexit__(self, *args: object) -> None:
57
+ await self.aclose()
58
+
59
+ # --- headers ---
60
+
61
+ def _auth_headers(self) -> dict[str, str]:
62
+ return {
63
+ "Authorization": f"Bearer {self._token}",
64
+ "Content-Type": "application/json",
65
+ }
66
+
67
+ # --- error handling ---
68
+
69
+ @staticmethod
70
+ def _raise_for_status(response: httpx.Response) -> None:
71
+ if response.is_success:
72
+ return
73
+ try:
74
+ body = response.json() if response.content else {}
75
+ except Exception:
76
+ body = {}
77
+ message = body.get("error", response.reason_phrase or "Unknown error")
78
+ error_cls = _STATUS_MAP.get(response.status_code)
79
+ if error_cls is BoomerangConflictError:
80
+ raise BoomerangConflictError(
81
+ message,
82
+ retry_after_seconds=body.get("retryAfterSeconds"),
83
+ )
84
+ if error_cls is not None:
85
+ raise error_cls(message)
86
+ raise BoomerangError(response.status_code, message)
87
+
88
+ # --- sync ---
89
+
90
+ def trigger(
91
+ self,
92
+ worker_url: str,
93
+ callback_url: str,
94
+ callback_secret: str | None = None,
95
+ idempotency_key: str | None = None,
96
+ ) -> BoomerangTriggerResponse:
97
+ """Submit a job via ``POST /sync``. Returns the assigned job ID."""
98
+ req = BoomerangTriggerRequest(
99
+ worker_url=worker_url,
100
+ callback_url=callback_url,
101
+ callback_secret=callback_secret,
102
+ idempotency_key=idempotency_key,
103
+ )
104
+ response = self._client.post(
105
+ f"{self._base_url}/sync",
106
+ headers=self._auth_headers(),
107
+ content=req.model_dump_json(by_alias=True, exclude_none=True),
108
+ )
109
+ self._raise_for_status(response)
110
+ return BoomerangTriggerResponse.model_validate(response.json())
111
+
112
+ def poll(self, job_id: str) -> BoomerangJobStatus:
113
+ """Poll job status via ``GET /sync/{jobId}``."""
114
+ response = self._client.get(
115
+ f"{self._base_url}/sync/{quote(job_id, safe='')}",
116
+ headers=self._auth_headers(),
117
+ )
118
+ self._raise_for_status(response)
119
+ return BoomerangJobStatus.model_validate(response.json())
120
+
121
+ # --- async ---
122
+
123
+ async def trigger_async(
124
+ self,
125
+ worker_url: str,
126
+ callback_url: str,
127
+ callback_secret: str | None = None,
128
+ idempotency_key: str | None = None,
129
+ ) -> BoomerangTriggerResponse:
130
+ """Async variant of :meth:`trigger`."""
131
+ req = BoomerangTriggerRequest(
132
+ worker_url=worker_url,
133
+ callback_url=callback_url,
134
+ callback_secret=callback_secret,
135
+ idempotency_key=idempotency_key,
136
+ )
137
+ response = await self._async_client.post(
138
+ f"{self._base_url}/sync",
139
+ headers=self._auth_headers(),
140
+ content=req.model_dump_json(by_alias=True, exclude_none=True),
141
+ )
142
+ self._raise_for_status(response)
143
+ return BoomerangTriggerResponse.model_validate(response.json())
144
+
145
+ async def poll_async(self, job_id: str) -> BoomerangJobStatus:
146
+ """Async variant of :meth:`poll`."""
147
+ response = await self._async_client.get(
148
+ f"{self._base_url}/sync/{quote(job_id, safe='')}",
149
+ headers=self._auth_headers(),
150
+ )
151
+ self._raise_for_status(response)
152
+ return BoomerangJobStatus.model_validate(response.json())
boomerang/errors.py ADDED
@@ -0,0 +1,60 @@
1
+ from __future__ import annotations
2
+
3
+
4
+ class BoomerangError(Exception):
5
+ """Base class for all Boomerang SDK errors."""
6
+
7
+ status_code: int = 0
8
+
9
+ def __init__(self, status_code: int, message: str) -> None:
10
+ self.status_code = status_code
11
+ self.message = message
12
+ super().__init__(message)
13
+
14
+ def __str__(self) -> str:
15
+ return f"[{self.status_code}] {self.message}"
16
+
17
+
18
+ class BoomerangUnauthorizedError(BoomerangError):
19
+ """401 — missing or invalid JWT."""
20
+
21
+ def __init__(self, message: str = "Unauthorized") -> None:
22
+ super().__init__(401, message)
23
+
24
+
25
+ class BoomerangForbiddenError(BoomerangError):
26
+ """403 — callbackUrl or workerUrl not in the server's allowlist."""
27
+
28
+ def __init__(self, message: str = "Forbidden") -> None:
29
+ super().__init__(403, message)
30
+
31
+
32
+ class BoomerangConflictError(BoomerangError):
33
+ """409 — duplicate job submitted within idempotency cooldown."""
34
+
35
+ def __init__(self, message: str = "Conflict", retry_after_seconds: int | None = None) -> None:
36
+ super().__init__(409, message)
37
+ self.retry_after_seconds = retry_after_seconds
38
+
39
+
40
+ class BoomerangServiceUnavailableError(BoomerangError):
41
+ """503 — Boomerang worker pool is saturated."""
42
+
43
+ def __init__(self, message: str = "Service Unavailable") -> None:
44
+ super().__init__(503, message)
45
+
46
+
47
+ STATUS_CODE_TO_ERROR: dict[int, type[BoomerangError]] = {
48
+ 401: BoomerangUnauthorizedError,
49
+ 403: BoomerangForbiddenError,
50
+ 409: BoomerangConflictError,
51
+ 503: BoomerangServiceUnavailableError,
52
+ }
53
+
54
+
55
+ def raise_for_status(status_code: int, message: str) -> None:
56
+ """Raise the appropriate ``BoomerangError`` subclass for *status_code*."""
57
+ error_cls = STATUS_CODE_TO_ERROR.get(status_code)
58
+ if error_cls is not None:
59
+ raise error_cls(message)
60
+ raise BoomerangError(status_code, message)
File without changes
@@ -0,0 +1,42 @@
1
+ """FastAPI dependency for verifying Boomerang webhook signatures."""
2
+
3
+ from typing import Callable
4
+
5
+ from ..models import BoomerangPayload
6
+ from ..signature import BoomerangSignature
7
+
8
+
9
+ def boomerang_webhook(secret: str) -> Callable:
10
+ """Return a FastAPI dependency callable that verifies the webhook
11
+ signature and returns a :class:`BoomerangPayload`.
12
+
13
+ Usage::
14
+
15
+ from fastapi import Depends
16
+
17
+ @app.post("/hooks/sync-done")
18
+ async def on_sync_done(
19
+ payload: BoomerangPayload = Depends(boomerang_webhook(secret)),
20
+ ):
21
+ ...
22
+ """
23
+ from fastapi import HTTPException, Request
24
+
25
+ async def _verify(request: Request) -> BoomerangPayload:
26
+ signature = request.headers.get("x-signature-sha256")
27
+ if not signature:
28
+ raise HTTPException(status_code=401, detail="Missing X-Signature-SHA256 header")
29
+
30
+ body = await request.body()
31
+
32
+ try:
33
+ valid = BoomerangSignature.verify(body, signature, secret)
34
+ except ValueError:
35
+ raise HTTPException(status_code=401, detail="Malformed signature header")
36
+
37
+ if not valid:
38
+ raise HTTPException(status_code=401, detail="Invalid signature")
39
+
40
+ return BoomerangPayload.model_validate_json(body)
41
+
42
+ return _verify
@@ -0,0 +1,48 @@
1
+ """Flask decorator for verifying Boomerang webhook signatures."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import functools
6
+ from typing import Any, Callable
7
+
8
+ from ..models import BoomerangPayload
9
+ from ..signature import BoomerangSignature
10
+
11
+
12
+ def boomerang_webhook(secret: str) -> Callable:
13
+ """Decorator that verifies the webhook signature and injects a
14
+ :class:`BoomerangPayload` as the first positional argument.
15
+
16
+ Usage::
17
+
18
+ @app.post("/hooks/sync-done")
19
+ @boomerang_webhook(secret=os.environ["WEBHOOK_SECRET"])
20
+ def on_sync_done(payload: BoomerangPayload):
21
+ ...
22
+ """
23
+
24
+ def decorator(fn: Callable[..., Any]) -> Callable[..., Any]:
25
+ @functools.wraps(fn)
26
+ def wrapper(*args: Any, **kwargs: Any) -> Any:
27
+ from flask import abort, request # noqa: lazy import
28
+
29
+ signature = request.headers.get("x-signature-sha256")
30
+ if not signature:
31
+ abort(401, description="Missing X-Signature-SHA256 header")
32
+
33
+ body = request.get_data()
34
+
35
+ try:
36
+ valid = BoomerangSignature.verify(body, signature, secret)
37
+ except ValueError:
38
+ abort(401, description="Malformed signature header")
39
+
40
+ if not valid:
41
+ abort(401, description="Invalid signature")
42
+
43
+ payload = BoomerangPayload.model_validate_json(body)
44
+ return fn(payload, *args, **kwargs)
45
+
46
+ return wrapper
47
+
48
+ return decorator
boomerang/models.py ADDED
@@ -0,0 +1,55 @@
1
+ from __future__ import annotations
2
+
3
+ from datetime import datetime
4
+ from typing import Any, Literal
5
+
6
+ from pydantic import BaseModel, ConfigDict, Field
7
+
8
+
9
+ class _CamelModel(BaseModel):
10
+ """Base model that serialises snake_case fields to camelCase JSON."""
11
+
12
+ model_config = ConfigDict(
13
+ populate_by_name=True,
14
+ frozen=True,
15
+ )
16
+
17
+
18
+ class BoomerangTriggerRequest(_CamelModel):
19
+ """Request body for ``POST /sync``."""
20
+
21
+ worker_url: str = Field(alias="workerUrl")
22
+ callback_url: str = Field(alias="callbackUrl")
23
+ callback_secret: str | None = Field(default=None, alias="callbackSecret")
24
+ idempotency_key: str | None = Field(default=None, alias="idempotencyKey")
25
+
26
+
27
+ class BoomerangTriggerResponse(_CamelModel):
28
+ """Response body returned by ``POST /sync``."""
29
+
30
+ job_id: str = Field(alias="jobId")
31
+
32
+
33
+ class BoomerangJobStatus(_CamelModel):
34
+ """Response body returned by ``GET /sync/{jobId}``."""
35
+
36
+ job_id: str = Field(alias="jobId")
37
+ status: Literal["PENDING", "IN_PROGRESS", "DONE", "FAILED"]
38
+ created_at: str | None = Field(default=None, alias="createdAt")
39
+ completed_at: str | None = Field(default=None, alias="completedAt")
40
+ result: dict[str, Any] | None = None
41
+ error: str | None = None
42
+
43
+
44
+ class BoomerangPayload(_CamelModel):
45
+ """Webhook callback payload delivered to the consumer's ``callbackUrl``.
46
+
47
+ This model is immutable (frozen) so middleware can safely pass it around.
48
+ """
49
+
50
+ boomerang_version: str = Field(alias="boomerangVersion")
51
+ job_id: str = Field(alias="jobId")
52
+ status: Literal["DONE", "FAILED"]
53
+ completed_at: datetime = Field(alias="completedAt")
54
+ result: dict[str, Any] | None = None
55
+ error: str | None = None
boomerang/py.typed ADDED
File without changes
boomerang/signature.py ADDED
@@ -0,0 +1,37 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import hmac
5
+
6
+
7
+ class BoomerangSignature:
8
+ """HMAC-SHA256 signature verification for Boomerang webhooks."""
9
+
10
+ _PREFIX = "sha256="
11
+
12
+ @staticmethod
13
+ def verify(body: bytes, signature_header: str, secret: str) -> bool:
14
+ """Return ``True`` if *signature_header* is a valid HMAC for *body*.
15
+
16
+ Uses constant-time comparison to prevent timing attacks.
17
+
18
+ Raises ``ValueError`` if *signature_header* does not start with
19
+ ``sha256=``.
20
+ """
21
+ if not signature_header.startswith(BoomerangSignature._PREFIX):
22
+ raise ValueError(
23
+ f"Malformed signature header: expected 'sha256=...' prefix, "
24
+ f"got {signature_header!r}"
25
+ )
26
+ expected = BoomerangSignature.compute(body, secret)
27
+ return hmac.compare_digest(expected, signature_header)
28
+
29
+ @staticmethod
30
+ def compute(body: bytes, secret: str) -> str:
31
+ """Return the signature header value: ``sha256=<lowercase hex>``."""
32
+ digest = hmac.new(
33
+ secret.encode("utf-8"),
34
+ body,
35
+ hashlib.sha256,
36
+ ).hexdigest()
37
+ return f"sha256={digest}"
@@ -0,0 +1,23 @@
1
+ Metadata-Version: 2.4
2
+ Name: boomerang-python
3
+ Version: 1.0.0
4
+ Summary: Python SDK for the Boomerang async webhook platform
5
+ License-Expression: Apache-2.0
6
+ Requires-Python: >=3.10
7
+ Requires-Dist: httpx<1,>=0.27
8
+ Requires-Dist: pydantic<3,>=2.0
9
+ Provides-Extra: dev
10
+ Requires-Dist: fastapi>=0.100; extra == 'dev'
11
+ Requires-Dist: flask>=2.0; extra == 'dev'
12
+ Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
13
+ Requires-Dist: pytest-httpx>=0.30; extra == 'dev'
14
+ Requires-Dist: pytest>=8.0; extra == 'dev'
15
+ Provides-Extra: fastapi
16
+ Requires-Dist: fastapi>=0.100; extra == 'fastapi'
17
+ Provides-Extra: flask
18
+ Requires-Dist: flask>=2.0; extra == 'flask'
19
+ Description-Content-Type: text/markdown
20
+
21
+ # boomerang-client
22
+
23
+ Python SDK for the [Boomerang](https://github.com/sameerchereddy/boomerang) async webhook platform.
@@ -0,0 +1,12 @@
1
+ boomerang/__init__.py,sha256=ynfhztblaMXn7PyrCap--bt8QreBGMcUKi6zaO9IqQE,712
2
+ boomerang/client.py,sha256=_QAztZONDMDVaNcJwSHx55rTXjEA4Y6IXQqspkEquGY,4802
3
+ boomerang/errors.py,sha256=ITOj3XgcoRr8cjNVHw7AEKZfvnicXS0pgGpLcAWRKqM,1872
4
+ boomerang/models.py,sha256=7BHm3G0EdKFI5EG-n9D68WAq1VCBIUc_wRHrbEddd90,1710
5
+ boomerang/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
6
+ boomerang/signature.py,sha256=W1ngRfZgZQ0h98zIWjS1mykYNQN68VvQh4YGyZ3C4bY,1198
7
+ boomerang/middleware/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
8
+ boomerang/middleware/fastapi.py,sha256=AqTgWv2QKD5gF9yUVM8FaGCbH_8EwOTWq7man_7QUsE,1291
9
+ boomerang/middleware/flask.py,sha256=bS--oA9J8YmD85NTthzW5n7pMxppoK2NkSeRAPSia6Q,1477
10
+ boomerang_python-1.0.0.dist-info/METADATA,sha256=8_wWBca8__-8vKktiOOzEX3K38viaMmz_qoEQaiCJLw,792
11
+ boomerang_python-1.0.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
12
+ boomerang_python-1.0.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any