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 +32 -0
- seatlayer/client.py +51 -0
- seatlayer/errors.py +104 -0
- seatlayer/http.py +213 -0
- seatlayer/py.typed +0 -0
- seatlayer/resources.py +541 -0
- seatlayer/webhooks.py +61 -0
- seatlayer-0.1.0.dist-info/METADATA +337 -0
- seatlayer-0.1.0.dist-info/RECORD +11 -0
- seatlayer-0.1.0.dist-info/WHEEL +4 -0
- seatlayer-0.1.0.dist-info/licenses/LICENSE +21 -0
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
|