driftstack-sdk 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.
@@ -0,0 +1,156 @@
1
+ """Sessions resource.
2
+
3
+ Wraps every ``/v1/sessions[/...]`` route. Both sync and async variants
4
+ share the URL/parameter shapes; the only difference is which HTTP
5
+ client they call.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Any
11
+ from urllib.parse import quote
12
+
13
+ from pydantic import BaseModel
14
+
15
+ from driftstack._generated.models import (
16
+ CaptureRequest,
17
+ CaptureResponse,
18
+ CreateSessionRequest,
19
+ CreateSessionResponse,
20
+ InteractRequest,
21
+ InteractResponse,
22
+ NavigateRequest,
23
+ NavigateResponse,
24
+ PaginationQuery,
25
+ Session,
26
+ SessionState,
27
+ WaitRequest,
28
+ WaitResponse,
29
+ )
30
+ from driftstack.http import AsyncHttpClient, HttpClient
31
+ from driftstack.resources._common import coerce_body, coerce_query
32
+
33
+
34
+ class SessionsListPage(BaseModel):
35
+ """Paginated list of sessions returned by ``GET /v1/sessions``."""
36
+
37
+ data: list[Session]
38
+ has_more: bool
39
+ next_cursor: str | None
40
+
41
+
42
+ def _session_path(session_id: str, suffix: str = "") -> str:
43
+ return f"/v1/sessions/{quote(session_id, safe='')}{suffix}"
44
+
45
+
46
+ class SessionsResource:
47
+ """Synchronous sessions resource."""
48
+
49
+ def __init__(self, http: HttpClient) -> None:
50
+ self._http = http
51
+
52
+ def create(
53
+ self, body: CreateSessionRequest | dict[str, Any] | None = None
54
+ ) -> CreateSessionResponse:
55
+ """Create a new session. Returns the new ``Session`` row."""
56
+ data = self._http.request("POST", "/v1/sessions", json_body=coerce_body(body) or {})
57
+ return CreateSessionResponse.model_validate(data)
58
+
59
+ def list(self, query: PaginationQuery | dict[str, Any] | None = None) -> SessionsListPage:
60
+ """List sessions for the current account, newest first."""
61
+ data = self._http.request("GET", "/v1/sessions", params=coerce_query(query))
62
+ return SessionsListPage.model_validate(data)
63
+
64
+ def get(self, session_id: str) -> Session:
65
+ data = self._http.request("GET", _session_path(session_id))
66
+ return Session.model_validate(data)
67
+
68
+ def navigate(self, session_id: str, body: NavigateRequest | dict[str, Any]) -> NavigateResponse:
69
+ data = self._http.request(
70
+ "POST", _session_path(session_id, "/navigate"), json_body=coerce_body(body)
71
+ )
72
+ return NavigateResponse.model_validate(data)
73
+
74
+ def interact(self, session_id: str, body: InteractRequest | dict[str, Any]) -> InteractResponse:
75
+ data = self._http.request(
76
+ "POST", _session_path(session_id, "/interact"), json_body=coerce_body(body)
77
+ )
78
+ return InteractResponse.model_validate(data)
79
+
80
+ def wait(self, session_id: str, body: WaitRequest | dict[str, Any]) -> WaitResponse:
81
+ data = self._http.request(
82
+ "POST", _session_path(session_id, "/wait"), json_body=coerce_body(body)
83
+ )
84
+ return WaitResponse.model_validate(data)
85
+
86
+ def get_state(self, session_id: str) -> SessionState:
87
+ data = self._http.request("GET", _session_path(session_id, "/state"))
88
+ return SessionState.model_validate(data)
89
+
90
+ def capture(self, session_id: str, body: CaptureRequest | dict[str, Any]) -> CaptureResponse:
91
+ data = self._http.request(
92
+ "POST", _session_path(session_id, "/capture"), json_body=coerce_body(body)
93
+ )
94
+ return CaptureResponse.model_validate(data)
95
+
96
+ def destroy(self, session_id: str) -> None:
97
+ """Destroy the session. Idempotent (safe to call twice)."""
98
+ self._http.request("DELETE", _session_path(session_id))
99
+
100
+
101
+ class AsyncSessionsResource:
102
+ """Async sessions resource. Mirrors :class:`SessionsResource`."""
103
+
104
+ def __init__(self, http: AsyncHttpClient) -> None:
105
+ self._http = http
106
+
107
+ async def create(
108
+ self, body: CreateSessionRequest | dict[str, Any] | None = None
109
+ ) -> CreateSessionResponse:
110
+ data = await self._http.request("POST", "/v1/sessions", json_body=coerce_body(body) or {})
111
+ return CreateSessionResponse.model_validate(data)
112
+
113
+ async def list(self, query: PaginationQuery | dict[str, Any] | None = None) -> SessionsListPage:
114
+ data = await self._http.request("GET", "/v1/sessions", params=coerce_query(query))
115
+ return SessionsListPage.model_validate(data)
116
+
117
+ async def get(self, session_id: str) -> Session:
118
+ data = await self._http.request("GET", _session_path(session_id))
119
+ return Session.model_validate(data)
120
+
121
+ async def navigate(
122
+ self, session_id: str, body: NavigateRequest | dict[str, Any]
123
+ ) -> NavigateResponse:
124
+ data = await self._http.request(
125
+ "POST", _session_path(session_id, "/navigate"), json_body=coerce_body(body)
126
+ )
127
+ return NavigateResponse.model_validate(data)
128
+
129
+ async def interact(
130
+ self, session_id: str, body: InteractRequest | dict[str, Any]
131
+ ) -> InteractResponse:
132
+ data = await self._http.request(
133
+ "POST", _session_path(session_id, "/interact"), json_body=coerce_body(body)
134
+ )
135
+ return InteractResponse.model_validate(data)
136
+
137
+ async def wait(self, session_id: str, body: WaitRequest | dict[str, Any]) -> WaitResponse:
138
+ data = await self._http.request(
139
+ "POST", _session_path(session_id, "/wait"), json_body=coerce_body(body)
140
+ )
141
+ return WaitResponse.model_validate(data)
142
+
143
+ async def get_state(self, session_id: str) -> SessionState:
144
+ data = await self._http.request("GET", _session_path(session_id, "/state"))
145
+ return SessionState.model_validate(data)
146
+
147
+ async def capture(
148
+ self, session_id: str, body: CaptureRequest | dict[str, Any]
149
+ ) -> CaptureResponse:
150
+ data = await self._http.request(
151
+ "POST", _session_path(session_id, "/capture"), json_body=coerce_body(body)
152
+ )
153
+ return CaptureResponse.model_validate(data)
154
+
155
+ async def destroy(self, session_id: str) -> None:
156
+ await self._http.request("DELETE", _session_path(session_id))
@@ -0,0 +1,29 @@
1
+ """Usage resource — /v1/usage."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from driftstack._generated.models import UsagePeriodSummary
6
+ from driftstack.http import AsyncHttpClient, HttpClient
7
+
8
+
9
+ class UsageResource:
10
+ """Synchronous usage resource."""
11
+
12
+ def __init__(self, http: HttpClient) -> None:
13
+ self._http = http
14
+
15
+ def current_period(self) -> UsagePeriodSummary:
16
+ """Current calendar-month UTC totals + tier quotas."""
17
+ data = self._http.request("GET", "/v1/usage")
18
+ return UsagePeriodSummary.model_validate(data)
19
+
20
+
21
+ class AsyncUsageResource:
22
+ """Async usage resource."""
23
+
24
+ def __init__(self, http: AsyncHttpClient) -> None:
25
+ self._http = http
26
+
27
+ async def current_period(self) -> UsagePeriodSummary:
28
+ data = await self._http.request("GET", "/v1/usage")
29
+ return UsagePeriodSummary.model_validate(data)
@@ -0,0 +1,110 @@
1
+ """Webhooks resource — /v1/webhooks."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+ from urllib.parse import quote
7
+
8
+ from pydantic import BaseModel
9
+
10
+ from driftstack._generated.models import (
11
+ CreateWebhookRequest,
12
+ CreateWebhookResponse,
13
+ ListDeliveriesQuery,
14
+ WebhookDelivery,
15
+ WebhookEndpoint,
16
+ )
17
+ from driftstack.http import AsyncHttpClient, HttpClient
18
+ from driftstack.resources._common import coerce_body, coerce_query
19
+
20
+
21
+ class WebhookEndpointList(BaseModel):
22
+ """Response shape for ``GET /v1/webhooks``."""
23
+
24
+ data: list[WebhookEndpoint]
25
+
26
+
27
+ class WebhookDeliveryListPage(BaseModel):
28
+ """Response shape for ``GET /v1/webhooks/{id}/deliveries``."""
29
+
30
+ data: list[WebhookDelivery]
31
+ has_more: bool
32
+ next_cursor: str | None
33
+
34
+
35
+ def _webhook_path(webhook_id: str, suffix: str = "") -> str:
36
+ return f"/v1/webhooks/{quote(webhook_id, safe='')}{suffix}"
37
+
38
+
39
+ class WebhooksResource:
40
+ """Synchronous webhooks resource."""
41
+
42
+ def __init__(self, http: HttpClient) -> None:
43
+ self._http = http
44
+
45
+ def create(self, body: CreateWebhookRequest | dict[str, Any]) -> CreateWebhookResponse:
46
+ """Create a webhook subscription.
47
+
48
+ Plaintext signing secret is returned ONCE; store it now — it
49
+ cannot be retrieved later. Requires the ``admin`` scope.
50
+ """
51
+ data = self._http.request("POST", "/v1/webhooks", json_body=coerce_body(body))
52
+ return CreateWebhookResponse.model_validate(data)
53
+
54
+ def list(self) -> WebhookEndpointList:
55
+ data = self._http.request("GET", "/v1/webhooks")
56
+ return WebhookEndpointList.model_validate(data)
57
+
58
+ def get(self, webhook_id: str) -> WebhookEndpoint:
59
+ data = self._http.request("GET", _webhook_path(webhook_id))
60
+ return WebhookEndpoint.model_validate(data)
61
+
62
+ def delete(self, webhook_id: str) -> None:
63
+ """Soft-delete (disable) the endpoint. Idempotent."""
64
+ self._http.request("DELETE", _webhook_path(webhook_id))
65
+
66
+ def list_deliveries(
67
+ self,
68
+ webhook_id: str,
69
+ query: ListDeliveriesQuery | dict[str, Any] | None = None,
70
+ ) -> WebhookDeliveryListPage:
71
+ data = self._http.request(
72
+ "GET",
73
+ _webhook_path(webhook_id, "/deliveries"),
74
+ params=coerce_query(query),
75
+ )
76
+ return WebhookDeliveryListPage.model_validate(data)
77
+
78
+
79
+ class AsyncWebhooksResource:
80
+ """Async webhooks resource."""
81
+
82
+ def __init__(self, http: AsyncHttpClient) -> None:
83
+ self._http = http
84
+
85
+ async def create(self, body: CreateWebhookRequest | dict[str, Any]) -> CreateWebhookResponse:
86
+ data = await self._http.request("POST", "/v1/webhooks", json_body=coerce_body(body))
87
+ return CreateWebhookResponse.model_validate(data)
88
+
89
+ async def list(self) -> WebhookEndpointList:
90
+ data = await self._http.request("GET", "/v1/webhooks")
91
+ return WebhookEndpointList.model_validate(data)
92
+
93
+ async def get(self, webhook_id: str) -> WebhookEndpoint:
94
+ data = await self._http.request("GET", _webhook_path(webhook_id))
95
+ return WebhookEndpoint.model_validate(data)
96
+
97
+ async def delete(self, webhook_id: str) -> None:
98
+ await self._http.request("DELETE", _webhook_path(webhook_id))
99
+
100
+ async def list_deliveries(
101
+ self,
102
+ webhook_id: str,
103
+ query: ListDeliveriesQuery | dict[str, Any] | None = None,
104
+ ) -> WebhookDeliveryListPage:
105
+ data = await self._http.request(
106
+ "GET",
107
+ _webhook_path(webhook_id, "/deliveries"),
108
+ params=coerce_query(query),
109
+ )
110
+ return WebhookDeliveryListPage.model_validate(data)
driftstack/retry.py ADDED
@@ -0,0 +1,105 @@
1
+ """Exponential-backoff retry policy with full jitter.
2
+
3
+ Mirrors `packages/sdk-typescript/src/retry.ts`. Honours `Retry-After`
4
+ when the server set one (the SDK's HTTP layer maps it onto the
5
+ RateLimitError before retry decides). Idempotent or read-shaped
6
+ methods are retried; mutating methods that lack server-side idempotency
7
+ keys are NOT retried by default — callers can opt in via the
8
+ ``retry`` argument on the HTTP client.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import random
14
+ import time
15
+ from collections.abc import Callable
16
+ from dataclasses import dataclass, field
17
+ from typing import TypeVar
18
+
19
+ from driftstack.errors import (
20
+ DriftstackError,
21
+ RateLimitError,
22
+ TransportError,
23
+ )
24
+
25
+ T = TypeVar("T")
26
+
27
+
28
+ @dataclass
29
+ class RetryConfig:
30
+ """Tuning knobs for the retry loop. Defaults match the TypeScript SDK."""
31
+
32
+ max_retries: int = 3
33
+ initial_delay_ms: int = 200
34
+ max_delay_ms: int = 10_000
35
+ backoff_multiplier: float = 2.0
36
+ """If True, retry on TransportError + RateLimitError. If False, never retry."""
37
+ enabled: bool = True
38
+ """Errors that ARE retryable when retries are enabled."""
39
+ retryable_errors: tuple[type[BaseException], ...] = field(
40
+ default_factory=lambda: (TransportError, RateLimitError)
41
+ )
42
+
43
+
44
+ def _backoff_delay_ms(attempt: int, cfg: RetryConfig, retry_after_seconds: int | None) -> int:
45
+ """Compute the next sleep with full jitter; cap at ``max_delay_ms``.
46
+
47
+ If the server set a ``Retry-After`` (rate-limit case), it wins —
48
+ we never retry sooner than the server asks. Otherwise it's
49
+ exponential-backoff with full jitter (random uniform between 0
50
+ and the next exponential value).
51
+ """
52
+ if retry_after_seconds is not None:
53
+ return min(retry_after_seconds * 1000, cfg.max_delay_ms)
54
+ capped = min(cfg.initial_delay_ms * (cfg.backoff_multiplier**attempt), cfg.max_delay_ms)
55
+ return int(random.uniform(0, capped))
56
+
57
+
58
+ def with_retry(fn: Callable[[], T], cfg: RetryConfig | None = None) -> T:
59
+ """Run ``fn`` with retries per ``cfg``. Synchronous variant."""
60
+ config = cfg or RetryConfig()
61
+ if not config.enabled:
62
+ return fn()
63
+
64
+ attempt = 0
65
+ while True:
66
+ try:
67
+ return fn()
68
+ except config.retryable_errors as err:
69
+ if attempt >= config.max_retries:
70
+ raise
71
+ retry_after = err.retry_after_seconds if isinstance(err, RateLimitError) else None
72
+ time.sleep(_backoff_delay_ms(attempt, config, retry_after) / 1000)
73
+ attempt += 1
74
+ except DriftstackError:
75
+ # Non-retryable typed error — propagate immediately.
76
+ raise
77
+
78
+
79
+ async def with_retry_async(
80
+ fn: Callable[[], _Awaitable[T]],
81
+ cfg: RetryConfig | None = None,
82
+ ) -> T:
83
+ """Run an async ``fn`` with retries. Mirrors :func:`with_retry`."""
84
+ import asyncio
85
+
86
+ config = cfg or RetryConfig()
87
+ if not config.enabled:
88
+ return await fn()
89
+
90
+ attempt = 0
91
+ while True:
92
+ try:
93
+ return await fn()
94
+ except config.retryable_errors as err:
95
+ if attempt >= config.max_retries:
96
+ raise
97
+ retry_after = err.retry_after_seconds if isinstance(err, RateLimitError) else None
98
+ await asyncio.sleep(_backoff_delay_ms(attempt, config, retry_after) / 1000)
99
+ attempt += 1
100
+ except DriftstackError:
101
+ raise
102
+
103
+
104
+ # Forward-declare for the async type hint above.
105
+ from collections.abc import Awaitable as _Awaitable # noqa: E402
@@ -0,0 +1,106 @@
1
+ """Webhook signature verification helper.
2
+
3
+ Header format (Stripe-style): ``t=<unix-seconds>,v1=<hex hmac>``.
4
+ HMAC = HMAC-SHA256(``<unix-seconds>.<raw body>``, ``<secret>``).
5
+
6
+ Mirrors :func:`verifyWebhookSignature` from the TypeScript SDK so a
7
+ multi-language receiver fleet works against the same wire format.
8
+
9
+ Example::
10
+
11
+ from driftstack import verify_webhook_signature
12
+
13
+ @app.post("/driftstack-webhook")
14
+ def receive():
15
+ sig = request.headers["x-driftstack-signature"]
16
+ ok = verify_webhook_signature(
17
+ body=request.body, # bytes or str
18
+ header=sig,
19
+ secret=os.environ["DRIFTSTACK_WEBHOOK_SECRET"],
20
+ )
21
+ if not ok:
22
+ return Response(status=401)
23
+ # ... process event ...
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import hashlib
29
+ import hmac
30
+ import time
31
+ from dataclasses import dataclass
32
+
33
+ DEFAULT_TOLERANCE_SEC = 300
34
+
35
+
36
+ @dataclass
37
+ class _ParsedSignature:
38
+ timestamp_seconds: int
39
+ signature_hex: str
40
+
41
+
42
+ def _parse_signature_header(header: str) -> _ParsedSignature | None:
43
+ """Parse ``t=...,v1=...`` (order-independent). Return None on shape failure."""
44
+ timestamp: int | None = None
45
+ signature: str | None = None
46
+ for part in header.split(","):
47
+ eq_idx = part.find("=")
48
+ if eq_idx < 0:
49
+ continue
50
+ key = part[:eq_idx].strip()
51
+ value = part[eq_idx + 1 :].strip()
52
+ if key == "t":
53
+ try:
54
+ timestamp = int(value)
55
+ except ValueError:
56
+ continue
57
+ elif key == "v1":
58
+ signature = value
59
+ if timestamp is None or signature is None:
60
+ return None
61
+ return _ParsedSignature(timestamp_seconds=timestamp, signature_hex=signature)
62
+
63
+
64
+ def verify_webhook_signature(
65
+ *,
66
+ body: bytes | str,
67
+ header: str | None,
68
+ secret: str,
69
+ tolerance_sec: int = DEFAULT_TOLERANCE_SEC,
70
+ now_seconds: float | None = None,
71
+ ) -> bool:
72
+ """Verify an inbound webhook signature header.
73
+
74
+ Returns ``True`` iff the header is well-formed, the timestamp is
75
+ within ``tolerance_sec`` of now, and the HMAC matches in
76
+ constant time. Returns ``False`` on any failure mode — never
77
+ raises.
78
+
79
+ ``body`` must be the EXACT raw bytes the server signed. If your
80
+ framework re-encodes JSON before passing it to your handler,
81
+ you'll need to use a raw-body access path (Flask:
82
+ ``request.get_data()``; FastAPI: ``await request.body()``;
83
+ Django: ``request.body``).
84
+ """
85
+ if not header or not isinstance(header, str):
86
+ return False
87
+
88
+ parsed = _parse_signature_header(header)
89
+ if parsed is None:
90
+ return False
91
+
92
+ now = now_seconds if now_seconds is not None else time.time()
93
+ if abs(now - parsed.timestamp_seconds) > tolerance_sec:
94
+ return False
95
+
96
+ body_bytes = body.encode("utf-8") if isinstance(body, str) else bytes(body)
97
+ payload = f"{parsed.timestamp_seconds}.".encode() + body_bytes
98
+
99
+ expected = hmac.new(
100
+ secret.encode("utf-8"),
101
+ payload,
102
+ hashlib.sha256,
103
+ ).hexdigest()
104
+
105
+ # ``compare_digest`` is constant-time on equal-length strings.
106
+ return hmac.compare_digest(expected, parsed.signature_hex)