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 +29 -0
- boomerang/client.py +152 -0
- boomerang/errors.py +60 -0
- boomerang/middleware/__init__.py +0 -0
- boomerang/middleware/fastapi.py +42 -0
- boomerang/middleware/flask.py +48 -0
- boomerang/models.py +55 -0
- boomerang/py.typed +0 -0
- boomerang/signature.py +37 -0
- boomerang_python-1.0.0.dist-info/METADATA +23 -0
- boomerang_python-1.0.0.dist-info/RECORD +12 -0
- boomerang_python-1.0.0.dist-info/WHEEL +4 -0
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,,
|