onefirstflock-donations-embed 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,157 @@
1
+ """Server-side Python SDK for the 1st Flock Donations Embed API.
2
+
3
+ This package provides:
4
+
5
+ - :class:`Client` — async client for the Customer SDK endpoints
6
+ (donations, recurring, webhook test).
7
+ - :func:`verify_webhook` / :func:`verify_webhook_async` — constant-time
8
+ HMAC-SHA256 signature verification for inbound webhook deliveries,
9
+ returning a parsed :data:`WebhookEvent`.
10
+ - :func:`idempotency_key` — convenience generator for ``Idempotency-Key``
11
+ header values.
12
+ - A full set of typed Pydantic models for every documented request,
13
+ response, and webhook payload.
14
+
15
+ Quickstart::
16
+
17
+ >>> from donations_embed import Client, verify_webhook
18
+ >>> async with Client(api_key="sk_test_…") as client:
19
+ ... donation = await client.get_donation("8a1b9c4d-1234-4321-9abc-def012345678")
20
+ ... refund = await client.refund_donation(donation.id, amount_cents=1000)
21
+
22
+ >>> # In an inbound webhook handler
23
+ >>> event = verify_webhook(
24
+ ... body=raw_body_bytes,
25
+ ... signature=request.headers["Flock-Signature"],
26
+ ... secret=os.environ["FLOCK_WEBHOOK_SECRET"],
27
+ ... )
28
+
29
+ See the README for FastAPI / Starlette / script examples and detailed
30
+ error-handling guidance.
31
+ """
32
+
33
+ from __future__ import annotations
34
+
35
+ from donations_embed._idempotency import idempotency_key
36
+ from donations_embed._version import __version__
37
+ from donations_embed.client import Client
38
+ from donations_embed.errors import (
39
+ ApiError,
40
+ AuthError,
41
+ BadGatewayError,
42
+ ConflictError,
43
+ DonationsEmbedError,
44
+ ForbiddenError,
45
+ GoneError,
46
+ NotFoundError,
47
+ RateLimitError,
48
+ ServiceUnavailableError,
49
+ ValidationError,
50
+ WebhookError,
51
+ WebhookFormatError,
52
+ WebhookSignatureError,
53
+ WebhookTimestampError,
54
+ )
55
+ from donations_embed.models import (
56
+ Donation,
57
+ DonationCompletedEvent,
58
+ DonationCreatedEvent,
59
+ DonationCreatedEventObject,
60
+ DonationEventDonor,
61
+ DonationEventFundAllocation,
62
+ DonationEventObject,
63
+ DonationFailedEvent,
64
+ DonationListResponse,
65
+ DonationRefundedEvent,
66
+ DonationRefundedEventObject,
67
+ DonationStatus,
68
+ EventPaymentMethod,
69
+ FundAllocation,
70
+ KeyFrozenEvent,
71
+ KeyFrozenEventObject,
72
+ KeyFrozenReason,
73
+ PaymentMethodType,
74
+ Recurring,
75
+ RecurringActivatedEvent,
76
+ RecurringCancelledEvent,
77
+ RecurringCancelledEventObject,
78
+ RecurringCancelResponse,
79
+ RecurringEventObject,
80
+ RecurringInterval,
81
+ RecurringListResponse,
82
+ RecurringPaymentFailedEvent,
83
+ RecurringPaymentSucceededEvent,
84
+ RecurringStatus,
85
+ RefundRequest,
86
+ RefundResponse,
87
+ WebhookEnvelope,
88
+ WebhookEvent,
89
+ WebhookEventType,
90
+ WebhookTestRequest,
91
+ WebhookTestResponse,
92
+ )
93
+ from donations_embed.webhooks import (
94
+ DEFAULT_TOLERANCE_SECONDS,
95
+ verify_webhook,
96
+ verify_webhook_async,
97
+ )
98
+
99
+ __all__ = [
100
+ "DEFAULT_TOLERANCE_SECONDS",
101
+ "ApiError",
102
+ "AuthError",
103
+ "BadGatewayError",
104
+ "Client",
105
+ "ConflictError",
106
+ "Donation",
107
+ "DonationCompletedEvent",
108
+ "DonationCreatedEvent",
109
+ "DonationCreatedEventObject",
110
+ "DonationEventDonor",
111
+ "DonationEventFundAllocation",
112
+ "DonationEventObject",
113
+ "DonationFailedEvent",
114
+ "DonationListResponse",
115
+ "DonationRefundedEvent",
116
+ "DonationRefundedEventObject",
117
+ "DonationStatus",
118
+ "DonationsEmbedError",
119
+ "EventPaymentMethod",
120
+ "ForbiddenError",
121
+ "FundAllocation",
122
+ "GoneError",
123
+ "KeyFrozenEvent",
124
+ "KeyFrozenEventObject",
125
+ "KeyFrozenReason",
126
+ "NotFoundError",
127
+ "PaymentMethodType",
128
+ "RateLimitError",
129
+ "Recurring",
130
+ "RecurringActivatedEvent",
131
+ "RecurringCancelResponse",
132
+ "RecurringCancelledEvent",
133
+ "RecurringCancelledEventObject",
134
+ "RecurringEventObject",
135
+ "RecurringInterval",
136
+ "RecurringListResponse",
137
+ "RecurringPaymentFailedEvent",
138
+ "RecurringPaymentSucceededEvent",
139
+ "RecurringStatus",
140
+ "RefundRequest",
141
+ "RefundResponse",
142
+ "ServiceUnavailableError",
143
+ "ValidationError",
144
+ "WebhookEnvelope",
145
+ "WebhookError",
146
+ "WebhookEvent",
147
+ "WebhookEventType",
148
+ "WebhookFormatError",
149
+ "WebhookSignatureError",
150
+ "WebhookTestRequest",
151
+ "WebhookTestResponse",
152
+ "WebhookTimestampError",
153
+ "__version__",
154
+ "idempotency_key",
155
+ "verify_webhook",
156
+ "verify_webhook_async",
157
+ ]
@@ -0,0 +1,318 @@
1
+ """Internal HTTP transport — typed errors, retry, and idempotency forwarding.
2
+
3
+ The :class:`HttpTransport` wraps an ``httpx.AsyncClient`` and applies the
4
+ SDK's cross-cutting policies on every request:
5
+
6
+ - Adds the ``Authorization: Bearer <api_key>`` header.
7
+ - Adds a ``User-Agent`` header identifying the SDK + Python version so the
8
+ platform can attribute traffic and warn customers running EOL versions.
9
+ - Forwards an optional ``Idempotency-Key`` header.
10
+ - Maps non-2xx responses to typed exceptions
11
+ (:class:`ApiError` and subclasses).
12
+ - Retries 5xx and 429 responses with exponential backoff + jitter via
13
+ :mod:`tenacity`.
14
+
15
+ The transport is internal — public callers always interact through
16
+ :class:`donations_embed.client.Client`.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import json
22
+ import logging
23
+ import platform
24
+ import sys
25
+ from typing import Any
26
+
27
+ import httpx
28
+ from tenacity import (
29
+ AsyncRetrying,
30
+ RetryError,
31
+ retry_if_exception_type,
32
+ stop_after_attempt,
33
+ wait_random_exponential,
34
+ )
35
+
36
+ from donations_embed._version import __version__
37
+ from donations_embed.errors import (
38
+ ApiError,
39
+ AuthError,
40
+ BadGatewayError,
41
+ ConflictError,
42
+ ForbiddenError,
43
+ GoneError,
44
+ NotFoundError,
45
+ RateLimitError,
46
+ ServiceUnavailableError,
47
+ ValidationError,
48
+ )
49
+
50
+ DEFAULT_BASE_URL = "https://api.1stflock.com"
51
+ DEFAULT_TIMEOUT_SECONDS = 30.0
52
+ DEFAULT_MAX_RETRIES = 3
53
+
54
+ _RETRYABLE_STATUS_CODES = frozenset({500, 502, 503, 504})
55
+ _RETRYABLE_HTTPX_EXCEPTIONS = (
56
+ httpx.ConnectError,
57
+ httpx.ConnectTimeout,
58
+ httpx.ReadTimeout,
59
+ httpx.WriteTimeout,
60
+ httpx.RemoteProtocolError,
61
+ )
62
+
63
+
64
+ def _build_user_agent() -> str:
65
+ """Build the ``User-Agent`` string for outbound requests."""
66
+ py = f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}"
67
+ impl = platform.python_implementation().lower()
68
+ return f"onefirstflock-donations-embed/{__version__} python/{py} ({impl})"
69
+
70
+
71
+ class _RetryableStatusError(Exception):
72
+ """Internal sentinel — wraps a 5xx response so :mod:`tenacity` can retry it.
73
+
74
+ We deliberately do NOT make :class:`ApiError` itself retryable, because
75
+ we want callers to see a typed final error after exhaustion rather than
76
+ a tenacity-internal type.
77
+ """
78
+
79
+ def __init__(self, response: httpx.Response) -> None:
80
+ self.response = response
81
+ super().__init__(f"retryable {response.status_code} from {response.request.url}")
82
+
83
+
84
+ class HttpTransport:
85
+ """Async HTTP transport used by :class:`donations_embed.client.Client`.
86
+
87
+ Args:
88
+ api_key: The Bearer token (an ``sk_live_*`` or ``sk_test_*``
89
+ secret key).
90
+ base_url: API base URL; trailing slashes are tolerated.
91
+ http_client: Optional pre-configured ``httpx.AsyncClient``. Pass
92
+ one in to share a connection pool with other code, customize
93
+ proxies, or inject a transport for testing. The transport will
94
+ **not** close a caller-supplied client.
95
+ max_retries: Maximum retry attempts for retryable failures
96
+ (5xx + transient network errors). The original attempt counts
97
+ as one — ``max_retries=3`` performs at most 4 requests.
98
+ timeout: Per-request timeout in seconds, applied to connect, read,
99
+ write, and pool acquisition.
100
+ logger: Optional :class:`logging.Logger` for low-cardinality
101
+ request/retry events. Set to ``None`` (default) to suppress
102
+ logging entirely.
103
+ """
104
+
105
+ def __init__(
106
+ self,
107
+ api_key: str,
108
+ *,
109
+ base_url: str = DEFAULT_BASE_URL,
110
+ http_client: httpx.AsyncClient | None = None,
111
+ max_retries: int = DEFAULT_MAX_RETRIES,
112
+ timeout: float = DEFAULT_TIMEOUT_SECONDS,
113
+ logger: logging.Logger | None = None,
114
+ ) -> None:
115
+ self._api_key = api_key
116
+ self._base_url = base_url.rstrip("/")
117
+ self._max_retries = max_retries
118
+ self._timeout = timeout
119
+ self._owns_client = http_client is None
120
+ self._client = http_client or httpx.AsyncClient(timeout=timeout)
121
+ self._logger = logger or logging.getLogger("donations_embed")
122
+ self._user_agent = _build_user_agent()
123
+
124
+ # ------------------------------------------------------------------ public
125
+
126
+ async def request(
127
+ self,
128
+ method: str,
129
+ path: str,
130
+ *,
131
+ params: dict[str, Any] | None = None,
132
+ json_body: Any | None = None,
133
+ idempotency_key: str | None = None,
134
+ ) -> Any:
135
+ """Send a request and return the parsed JSON response body.
136
+
137
+ Returns ``None`` for 204 responses; raises a typed
138
+ :class:`ApiError` subclass on every non-2xx status code that
139
+ survives the retry policy.
140
+ """
141
+ url = f"{self._base_url}{path}"
142
+ headers = self._build_headers(idempotency_key)
143
+
144
+ retrying = AsyncRetrying(
145
+ stop=stop_after_attempt(self._max_retries + 1),
146
+ wait=wait_random_exponential(multiplier=0.25, max=20),
147
+ retry=retry_if_exception_type(_RETRYABLE_STATUS_CODES_TYPE),
148
+ reraise=True,
149
+ )
150
+
151
+ response: httpx.Response | None = None
152
+ try:
153
+ async for attempt in retrying:
154
+ with attempt:
155
+ response = await self._send_once(
156
+ method, url, headers=headers, params=params, json_body=json_body
157
+ )
158
+ if response.status_code in _RETRYABLE_STATUS_CODES:
159
+ # Retryable status — wrap in our sentinel and let
160
+ # tenacity decide. Don't drain the body until we're
161
+ # ready to surface the error, so the next attempt
162
+ # gets a clean response object.
163
+ raise _RetryableStatusError(response)
164
+ except RetryError as exc:
165
+ # Should not happen with reraise=True, but keep the branch for
166
+ # static analyzers that don't see through tenacity's internals.
167
+ last_result = exc.last_attempt.result()
168
+ assert isinstance(last_result, httpx.Response)
169
+ raise self._build_error_from_response(last_result) from exc
170
+ except _RetryableStatusError as exc:
171
+ raise self._build_error_from_response(exc.response) from exc
172
+
173
+ # ``response`` is guaranteed to be set: tenacity raised on each
174
+ # failed attempt; on success we fell through with the assignment.
175
+ assert response is not None
176
+
177
+ if response.status_code >= 400:
178
+ raise self._build_error_from_response(response)
179
+ if response.status_code == 204 or not response.content:
180
+ return None
181
+ try:
182
+ return response.json()
183
+ except json.JSONDecodeError as exc: # pragma: no cover — server contract violation
184
+ raise ApiError(
185
+ response.status_code,
186
+ f"response body did not parse as JSON: {exc}",
187
+ request_id=response.headers.get("x-request-id"),
188
+ ) from exc
189
+
190
+ async def aclose(self) -> None:
191
+ """Close the underlying ``httpx.AsyncClient`` if we own it."""
192
+ if self._owns_client:
193
+ await self._client.aclose()
194
+
195
+ # ----------------------------------------------------------------- private
196
+
197
+ def _build_headers(self, idempotency_key: str | None) -> dict[str, str]:
198
+ headers = {
199
+ "Authorization": f"Bearer {self._api_key}",
200
+ "User-Agent": self._user_agent,
201
+ "Accept": "application/json",
202
+ }
203
+ if idempotency_key is not None:
204
+ headers["Idempotency-Key"] = idempotency_key
205
+ return headers
206
+
207
+ async def _send_once(
208
+ self,
209
+ method: str,
210
+ url: str,
211
+ *,
212
+ headers: dict[str, str],
213
+ params: dict[str, Any] | None,
214
+ json_body: Any | None,
215
+ ) -> httpx.Response:
216
+ try:
217
+ return await self._client.request(
218
+ method,
219
+ url,
220
+ params=_drop_none_params(params),
221
+ json=json_body,
222
+ headers=headers,
223
+ timeout=self._timeout,
224
+ )
225
+ except _RETRYABLE_HTTPX_EXCEPTIONS as exc:
226
+ # tenacity sees the raised exception type and will retry — we
227
+ # log at DEBUG so production deployments don't drown in transient
228
+ # network noise.
229
+ self._logger.debug(
230
+ "donations-embed: transport error on %s %s: %s",
231
+ method,
232
+ url,
233
+ exc,
234
+ )
235
+ raise
236
+
237
+ def _build_error_from_response(self, response: httpx.Response) -> ApiError:
238
+ """Map a 4xx / 5xx response onto the right :class:`ApiError` subclass."""
239
+ status = response.status_code
240
+ request_id = response.headers.get("x-request-id")
241
+ retry_after = _parse_retry_after(response.headers.get("retry-after"))
242
+
243
+ code: str | None = None
244
+ message: str = response.reason_phrase or f"HTTP {status}"
245
+ details: dict[str, Any] | None = None
246
+ try:
247
+ payload = response.json()
248
+ if isinstance(payload, dict):
249
+ raw_code = payload.get("code")
250
+ raw_message = payload.get("message")
251
+ raw_details = payload.get("details")
252
+ if isinstance(raw_code, str):
253
+ code = raw_code
254
+ if isinstance(raw_message, str):
255
+ message = raw_message
256
+ if isinstance(raw_details, dict):
257
+ details = raw_details
258
+ except (json.JSONDecodeError, ValueError): # pragma: no cover — non-JSON error body
259
+ pass
260
+
261
+ common: dict[str, Any] = {
262
+ "code": code,
263
+ "details": details,
264
+ "request_id": request_id,
265
+ }
266
+
267
+ if status == 401:
268
+ return AuthError(status, message, **common)
269
+ if status == 403:
270
+ return ForbiddenError(status, message, **common)
271
+ if status == 404:
272
+ return NotFoundError(status, message, **common)
273
+ if status in (400, 422):
274
+ return ValidationError(status, message, **common)
275
+ if status == 409:
276
+ return ConflictError(status, message, **common)
277
+ if status == 410:
278
+ return GoneError(status, message, **common)
279
+ if status == 429:
280
+ return RateLimitError(status, message, retry_after=retry_after, **common)
281
+ if status == 502:
282
+ return BadGatewayError(status, message, **common)
283
+ if status == 503:
284
+ return ServiceUnavailableError(status, message, **common)
285
+ return ApiError(status, message, **common)
286
+
287
+
288
+ def _drop_none_params(params: dict[str, Any] | None) -> dict[str, Any] | None:
289
+ """Strip ``None`` values from query params so they aren't serialized as the literal string ``"None"``."""
290
+ if params is None:
291
+ return None
292
+ return {k: v for k, v in params.items() if v is not None}
293
+
294
+
295
+ def _parse_retry_after(value: str | None) -> float | None:
296
+ """Parse a numeric ``Retry-After`` value into seconds.
297
+
298
+ The HTTP spec also allows an HTTP-date format. We accept seconds (as
299
+ integer or float) and silently return ``None`` for date-based values
300
+ rather than synthesizing a synthetic delay — callers can pivot on
301
+ ``RateLimitError.retry_after is None`` to fall back to a default.
302
+ """
303
+ if value is None:
304
+ return None
305
+ try:
306
+ return float(value.strip())
307
+ except ValueError:
308
+ return None
309
+
310
+
311
+ # Tuple form for tenacity's ``retry_if_exception_type`` predicate. We have
312
+ # to define this at module scope because tenacity inspects it at decorator-
313
+ # build time, and we want network errors AND our retryable-status sentinel
314
+ # both to trigger a retry.
315
+ _RETRYABLE_STATUS_CODES_TYPE: tuple[type[BaseException], ...] = (
316
+ _RetryableStatusError,
317
+ *_RETRYABLE_HTTPX_EXCEPTIONS,
318
+ )
@@ -0,0 +1,53 @@
1
+ """Idempotency-key helper.
2
+
3
+ The platform's idempotency contract: replaying a request with the same
4
+ ``Idempotency-Key`` returns the original response without performing the
5
+ side effect a second time. Keys are scoped per (``api_key``, ``endpoint``)
6
+ and persisted for 24 hours.
7
+
8
+ This module exposes :func:`idempotency_key`, a tiny convenience wrapper
9
+ that prefixes a fresh UUID4 with a use-case tag so observability tooling
10
+ can group related retries (e.g. all ``refund:*`` keys belong to the refund
11
+ flow). Callers MAY also supply their own keys directly.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import re
17
+ import uuid
18
+
19
+ # Match the OpenAPI ``Idempotency-Key`` schema constraint (max 255 chars).
20
+ _MAX_KEY_LENGTH = 255
21
+
22
+ # Conservative validation for the use-case tag — keep it printable ASCII
23
+ # so the resulting key fits cleanly in HTTP headers and log lines.
24
+ _USE_CASE_RE = re.compile(r"^[a-z0-9][a-z0-9_\-.]*$")
25
+
26
+
27
+ def idempotency_key(use_case: str) -> str:
28
+ """Generate a fresh idempotency key tagged with a use-case prefix.
29
+
30
+ The returned key has the form ``"<use_case>:<uuid4>"``, e.g.
31
+ ``"refund:1f2dab36-2a55-4f11-bdc8-2a2a4a95b001"``.
32
+
33
+ Args:
34
+ use_case: A short tag describing the operation
35
+ (``"refund"``, ``"cancel-recurring"``, ``"replay-confirm"``).
36
+ Must be lowercase ASCII letters, digits, ``_``, ``-``, or ``.``;
37
+ an empty or oddly-shaped tag raises :class:`ValueError`.
38
+
39
+ Returns:
40
+ A string suitable for the ``Idempotency-Key`` HTTP header.
41
+ """
42
+ if not use_case or not _USE_CASE_RE.match(use_case):
43
+ raise ValueError(
44
+ "idempotency use_case must be a non-empty lowercase ASCII tag "
45
+ "matching ^[a-z0-9][a-z0-9_\\-.]*$"
46
+ )
47
+ key = f"{use_case}:{uuid.uuid4()}"
48
+ if len(key) > _MAX_KEY_LENGTH:
49
+ # Practically impossible (use_case + UUID4 is well under 255), but
50
+ # we enforce the contract explicitly so a future caller can't blow
51
+ # past the limit by passing a 250-char tag.
52
+ raise ValueError(f"generated idempotency key exceeds {_MAX_KEY_LENGTH} chars")
53
+ return key
@@ -0,0 +1,9 @@
1
+ """Single source of truth for the SDK version.
2
+
3
+ Kept in its own module so internal modules (e.g. :mod:`donations_embed._http`)
4
+ can import it without dragging in the public API.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ __version__ = "1.0.0"