seatlayer 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.
seatlayer/__init__.py ADDED
@@ -0,0 +1,32 @@
1
+ """Official Python server SDK for the SeatLayer reserved-seating API.
2
+
3
+ Server-side only: this package authenticates with your secret key.
4
+ """
5
+
6
+ from .client import SeatLayer
7
+ from .errors import (
8
+ SeatLayerAuthError,
9
+ SeatLayerConflictError,
10
+ SeatLayerConnectionError,
11
+ SeatLayerError,
12
+ SeatLayerNotFoundError,
13
+ SeatLayerRateLimitError,
14
+ SeatLayerValidationError,
15
+ )
16
+ from .webhooks import WebhookVerificationError, verify_webhook
17
+
18
+ __version__ = "0.1.0"
19
+
20
+ __all__ = [
21
+ "SeatLayer",
22
+ "SeatLayerAuthError",
23
+ "SeatLayerConflictError",
24
+ "SeatLayerConnectionError",
25
+ "SeatLayerError",
26
+ "SeatLayerNotFoundError",
27
+ "SeatLayerRateLimitError",
28
+ "SeatLayerValidationError",
29
+ "WebhookVerificationError",
30
+ "__version__",
31
+ "verify_webhook",
32
+ ]
seatlayer/client.py ADDED
@@ -0,0 +1,51 @@
1
+ """The SeatLayer client.
2
+
3
+ Secret-key only. This package must never run anywhere a ticket buyer can reach it —
4
+ browser surfaces get short-lived scoped tokens minted via ``sessions``.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from collections.abc import Callable
10
+ from typing import Any
11
+
12
+ from .http import DEFAULT_BASE_URL, DEFAULT_MAX_RETRIES, DEFAULT_TIMEOUT, HttpClient
13
+ from .resources import Charts, Events, Inventory, Sessions, Webhooks, Workspaces
14
+
15
+
16
+ class SeatLayer:
17
+ def __init__(
18
+ self,
19
+ secret_key: str,
20
+ base_url: str = DEFAULT_BASE_URL,
21
+ max_retries: int = DEFAULT_MAX_RETRIES,
22
+ timeout: float = DEFAULT_TIMEOUT,
23
+ transport: Callable[..., Any] | None = None,
24
+ ) -> None:
25
+ self._http = HttpClient(
26
+ secret_key=secret_key,
27
+ base_url=base_url,
28
+ max_retries=max_retries,
29
+ timeout=timeout,
30
+ transport=transport,
31
+ )
32
+ #: ``"test"`` or ``"live"``, derived from the key prefix.
33
+ self.mode = self._http.mode
34
+
35
+ self.charts = Charts(self._http)
36
+ self.events = Events(self._http)
37
+ self.inventory = Inventory(self._http)
38
+ self.sessions = Sessions(self._http)
39
+ self.webhooks = Webhooks(self._http)
40
+ self.workspaces = Workspaces(self._http)
41
+
42
+ def ready(self) -> Any:
43
+ """Dependency-aware readiness probe."""
44
+ return self._http.get("/health/ready")
45
+
46
+ def request(self, method: str, path: str, **kwargs: Any) -> Any:
47
+ """Escape hatch for surface this SDK does not wrap yet.
48
+
49
+ Carries the same auth, retries, idempotency and error mapping.
50
+ """
51
+ return self._http.request(method, path, **kwargs)
seatlayer/errors.py ADDED
@@ -0,0 +1,104 @@
1
+ """Typed errors.
2
+
3
+ The API answers failures with ``{"error": ..., "code": ..., "message": ...}`` and a
4
+ status. Surfacing that as one opaque exception leaves every caller string-matching on
5
+ ``error``. The classes below are the ones an integration actually branches on — a
6
+ sold-out seat is a business outcome that belongs in an ``if``, not in an ``except``
7
+ that also swallows a bad key.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from typing import Any
13
+
14
+
15
+ class SeatLayerError(Exception):
16
+ """Base class for every API error."""
17
+
18
+ def __init__(
19
+ self,
20
+ status: int,
21
+ body: dict[str, Any],
22
+ request_id: str | None,
23
+ ) -> None:
24
+ code = body.get("code") or body.get("error") or "unknown_error"
25
+ super().__init__(body.get("message") or f"SeatLayer API error {status} ({code})")
26
+ self.status = status
27
+ self.code = code
28
+ self.body = body
29
+ #: Correlation id from ``X-Request-ID``. Quote it in support requests.
30
+ self.request_id = request_id
31
+
32
+
33
+ class SeatLayerAuthError(SeatLayerError):
34
+ """401/403 — bad key, revoked key, or a live key used against a test event."""
35
+
36
+ @property
37
+ def is_mode_mismatch(self) -> bool:
38
+ """The key's mode and the event's mode disagree.
39
+
40
+ The most common cause of a "works locally, 403s in production" report.
41
+ """
42
+ return self.code == "mode_mismatch"
43
+
44
+
45
+ class SeatLayerNotFoundError(SeatLayerError):
46
+ """404 — including another organisation's resource, which is never disclosed."""
47
+
48
+
49
+ class SeatLayerConflictError(SeatLayerError):
50
+ """409 — the seats moved under you.
51
+
52
+ Normal in ticketing, not exceptional: two buyers wanted the same seat.
53
+ """
54
+
55
+ def __init__(self, status: int, body: dict[str, Any], request_id: str | None) -> None:
56
+ super().__init__(status, body, request_id)
57
+ conflicts = body.get("conflicts")
58
+ self.conflicts: list[dict[str, Any]] = conflicts if isinstance(conflicts, list) else []
59
+
60
+ @property
61
+ def is_sold_out(self) -> bool:
62
+ """Best-available could not find enough free inventory."""
63
+ return self.body.get("reason") in ("sold_out", "not_enough_together")
64
+
65
+
66
+ class SeatLayerValidationError(SeatLayerError):
67
+ """422 — the request was understood and rejected."""
68
+
69
+
70
+ class SeatLayerRateLimitError(SeatLayerError):
71
+ """429. ``retry_after_seconds`` prefers the header over the JSON field."""
72
+
73
+ def __init__(
74
+ self,
75
+ status: int,
76
+ body: dict[str, Any],
77
+ request_id: str | None,
78
+ retry_after_seconds: float,
79
+ ) -> None:
80
+ super().__init__(status, body, request_id)
81
+ self.retry_after_seconds = retry_after_seconds
82
+
83
+
84
+ class SeatLayerConnectionError(Exception):
85
+ """The request never got an answer: DNS, TLS, socket, or timeout."""
86
+
87
+
88
+ def error_from_response(
89
+ status: int,
90
+ body: dict[str, Any],
91
+ request_id: str | None,
92
+ retry_after_seconds: float,
93
+ ) -> SeatLayerError:
94
+ if status in (401, 403):
95
+ return SeatLayerAuthError(status, body, request_id)
96
+ if status == 404:
97
+ return SeatLayerNotFoundError(status, body, request_id)
98
+ if status == 409:
99
+ return SeatLayerConflictError(status, body, request_id)
100
+ if status == 422:
101
+ return SeatLayerValidationError(status, body, request_id)
102
+ if status == 429:
103
+ return SeatLayerRateLimitError(status, body, request_id, retry_after_seconds)
104
+ return SeatLayerError(status, body, request_id)
seatlayer/http.py ADDED
@@ -0,0 +1,213 @@
1
+ """The transport: auth, idempotency, retry, and error mapping.
2
+
3
+ Deliberately built on the standard library. A server SDK that drags in a dependency
4
+ tree becomes a supply-chain surface for every customer who installs it, and this
5
+ client needs nothing ``urllib`` cannot do.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import random
12
+ import re
13
+ import time
14
+ import urllib.error
15
+ import urllib.parse
16
+ import urllib.request
17
+ import uuid
18
+ from collections.abc import Callable
19
+ from typing import Any
20
+
21
+ from .errors import SeatLayerConnectionError, error_from_response
22
+
23
+ DEFAULT_BASE_URL = "https://api.seatlayer.io"
24
+ DEFAULT_MAX_RETRIES = 3
25
+ DEFAULT_TIMEOUT = 30.0
26
+
27
+ #: The API's own charset for Idempotency-Key.
28
+ IDEMPOTENCY_KEY_PATTERN = re.compile(r"^[A-Za-z0-9._:-]{1,128}$")
29
+
30
+ USER_AGENT = "seatlayer-python"
31
+
32
+
33
+ def assert_valid_idempotency_key(key: str) -> None:
34
+ if not IDEMPOTENCY_KEY_PATTERN.match(key):
35
+ raise ValueError(
36
+ f"Invalid Idempotency-Key {key!r}: allowed characters are "
37
+ "A-Z a-z 0-9 . _ : - and the length must be 1-128."
38
+ )
39
+
40
+
41
+ def _should_send_idempotency_key(method: str) -> bool:
42
+ """Every mutation carries one.
43
+
44
+ A retried POST that creates a second hold is worse than a failed POST, and the
45
+ caller cannot tell the difference from outside — so the SDK, which knows it
46
+ retried, is the right place to guarantee it.
47
+ """
48
+ return method not in ("GET", "HEAD")
49
+
50
+
51
+ def _is_retryable_status(status: int) -> bool:
52
+ """Retry only what is safe to retry.
53
+
54
+ 429 and 5xx are transient by definition. A 4xx is the API saying the request
55
+ itself is wrong; retrying burns rate-limit budget and delays the real error.
56
+ """
57
+ return status == 429 or status == 408 or 500 <= status < 600
58
+
59
+
60
+ def _backoff_seconds(attempt: int, retry_after: float | None) -> float:
61
+ # The server's instruction wins — it knows when the window rolls over.
62
+ if retry_after is not None:
63
+ return retry_after
64
+ # Otherwise exponential with full jitter, so a fleet of workers limited at the
65
+ # same moment does not retry in lockstep and re-limit itself.
66
+ ceiling = min(8.0, 0.25 * (2**attempt))
67
+ return float(random.random() * ceiling)
68
+
69
+
70
+ class HttpClient:
71
+ def __init__(
72
+ self,
73
+ secret_key: str,
74
+ base_url: str = DEFAULT_BASE_URL,
75
+ max_retries: int = DEFAULT_MAX_RETRIES,
76
+ timeout: float = DEFAULT_TIMEOUT,
77
+ transport: Callable[..., Any] | None = None,
78
+ ) -> None:
79
+ if not secret_key:
80
+ raise ValueError("A SeatLayer secret key is required.")
81
+ # Caught here rather than as a 401 three round-trips later. The pk_ case
82
+ # gets its own message: it is the one people paste by mistake.
83
+ if secret_key.startswith("pk_"):
84
+ raise ValueError(
85
+ "That is a publishable key. The server SDK needs a secret key "
86
+ "(sk_live_… or sk_test_…)."
87
+ )
88
+ if not secret_key.startswith("sk_"):
89
+ raise ValueError("A SeatLayer secret key starts with sk_live_ or sk_test_.")
90
+
91
+ self._secret_key = secret_key
92
+ self.base_url = base_url.rstrip("/")
93
+ self._max_retries = max_retries
94
+ self._timeout = timeout
95
+ self._transport = transport or self._urlopen
96
+ self.mode = (
97
+ "test"
98
+ if secret_key.startswith("sk_test_")
99
+ else "live"
100
+ if secret_key.startswith("sk_live_")
101
+ else "unknown"
102
+ )
103
+
104
+ @staticmethod
105
+ def _urlopen(request: urllib.request.Request, timeout: float) -> Any:
106
+ return urllib.request.urlopen(request, timeout=timeout)
107
+
108
+ def request(
109
+ self,
110
+ method: str,
111
+ path: str,
112
+ query: dict[str, Any] | None = None,
113
+ body: Any = None,
114
+ idempotency_key: str | None = None,
115
+ ) -> Any:
116
+ url = self.base_url + path
117
+ if query:
118
+ filtered = {k: v for k, v in query.items() if v is not None}
119
+ if filtered:
120
+ url += "?" + urllib.parse.urlencode(filtered)
121
+
122
+ headers = {
123
+ "Authorization": f"Bearer {self._secret_key}",
124
+ "Accept": "application/json",
125
+ "User-Agent": USER_AGENT,
126
+ }
127
+ payload = None
128
+ if body is not None:
129
+ payload = json.dumps(body).encode("utf-8")
130
+ headers["Content-Type"] = "application/json"
131
+
132
+ if _should_send_idempotency_key(method):
133
+ key = idempotency_key or str(uuid.uuid4())
134
+ assert_valid_idempotency_key(key)
135
+ # Deliberately stable across retries: that is the point — the server
136
+ # collapses the duplicates.
137
+ headers["Idempotency-Key"] = key
138
+
139
+ last_error: Exception | None = None
140
+ for attempt in range(self._max_retries):
141
+ request = urllib.request.Request(url, data=payload, headers=headers, method=method)
142
+ try:
143
+ with self._transport(request, self._timeout) as response:
144
+ raw = response.read()
145
+ if response.status == 204 or not raw:
146
+ return None
147
+ return json.loads(raw)
148
+ except urllib.error.HTTPError as http_error:
149
+ raw = http_error.read()
150
+ try:
151
+ error_body = json.loads(raw) if raw else {}
152
+ except json.JSONDecodeError:
153
+ error_body = {}
154
+ if not isinstance(error_body, dict):
155
+ error_body = {}
156
+
157
+ request_id = http_error.headers.get("X-Request-ID")
158
+ retry_after = _parse_retry_after(http_error.headers, error_body)
159
+ # typeshed types HTTPError.status as Optional; a None here would
160
+ # silently take the non-retryable branch, so pin it to a real code.
161
+ status = http_error.status if http_error.status is not None else 500
162
+
163
+ if _is_retryable_status(status) and attempt < self._max_retries - 1:
164
+ time.sleep(_backoff_seconds(attempt, retry_after if status == 429 else None))
165
+ continue
166
+
167
+ raise error_from_response(status, error_body, request_id, retry_after) from None
168
+ except (urllib.error.URLError, TimeoutError, OSError) as cause:
169
+ last_error = SeatLayerConnectionError(
170
+ f"Request to {method} {path} failed: {cause}"
171
+ )
172
+ if attempt < self._max_retries - 1:
173
+ time.sleep(_backoff_seconds(attempt, None))
174
+ continue
175
+ raise last_error from cause
176
+
177
+ raise last_error or SeatLayerConnectionError("Request failed with no attempts made.")
178
+
179
+ def get(self, path: str, **kwargs: Any) -> Any:
180
+ return self.request("GET", path, **kwargs)
181
+
182
+ def post(self, path: str, **kwargs: Any) -> Any:
183
+ return self.request("POST", path, **kwargs)
184
+
185
+ def put(self, path: str, **kwargs: Any) -> Any:
186
+ return self.request("PUT", path, **kwargs)
187
+
188
+ def patch(self, path: str, **kwargs: Any) -> Any:
189
+ return self.request("PATCH", path, **kwargs)
190
+
191
+ def delete(self, path: str, **kwargs: Any) -> Any:
192
+ return self.request("DELETE", path, **kwargs)
193
+
194
+
195
+ def _parse_retry_after(headers: Any, body: dict[str, Any]) -> float:
196
+ header = headers.get("Retry-After") if headers else None
197
+ if header:
198
+ try:
199
+ seconds = float(str(header))
200
+ if seconds >= 0:
201
+ return seconds
202
+ except (TypeError, ValueError):
203
+ pass
204
+ # Fall back to the JSON field for routes that predate the headers.
205
+ field = body.get("retryAfterSeconds")
206
+ if isinstance(field, (int, float)):
207
+ return float(field)
208
+ return 1.0
209
+
210
+
211
+ def quote(value: str) -> str:
212
+ """Percent-encode a path segment, including slashes."""
213
+ return urllib.parse.quote(str(value), safe="")
seatlayer/py.typed ADDED
File without changes