lumify-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.
lumify/__init__.py ADDED
@@ -0,0 +1,65 @@
1
+ """
2
+ lumify — official Python client for the Lumify agent-ready sports intelligence
3
+ API (schedules, live scores, odds, line movement, betting splits, and AI bet
4
+ intelligence). See https://lumify.ai/docs.
5
+
6
+ This SDK *is* the typed REST path — the same data as the REST API and the
7
+ ``@lumifyai/mcp`` server, not a third implementation. Response models
8
+ (``lumify.models``) are generated from Lumify's live OpenAPI schema, so they
9
+ can't silently drift from what the API returns.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from ._transport import DEFAULT_BASE_URL, LumifyClient
15
+ from .client import Lumify
16
+ from .errors import (
17
+ APIError,
18
+ AuthenticationError,
19
+ ConnectionError,
20
+ FieldError,
21
+ LumifyError,
22
+ NotFoundError,
23
+ PaymentError,
24
+ PermissionError,
25
+ RateLimitError,
26
+ ValidationError,
27
+ )
28
+ from .meta import ResponseMeta, get_meta
29
+ from .pagination import iterate_items, paginate
30
+ from .sse import ScoreStreamEvent, SSEEvent, parse_sse_stream, stream_scores
31
+ from .webhook_signature import WebhookSignatureError, verify_webhook
32
+
33
+ __version__ = "0.1.0"
34
+
35
+ __all__ = [
36
+ "Lumify",
37
+ "LumifyClient",
38
+ "DEFAULT_BASE_URL",
39
+ # errors
40
+ "LumifyError",
41
+ "AuthenticationError",
42
+ "PermissionError",
43
+ "NotFoundError",
44
+ "RateLimitError",
45
+ "ValidationError",
46
+ "PaymentError",
47
+ "APIError",
48
+ "ConnectionError",
49
+ "FieldError",
50
+ # meta
51
+ "ResponseMeta",
52
+ "get_meta",
53
+ # pagination
54
+ "paginate",
55
+ "iterate_items",
56
+ # sse
57
+ "stream_scores",
58
+ "parse_sse_stream",
59
+ "SSEEvent",
60
+ "ScoreStreamEvent",
61
+ # webhooks
62
+ "verify_webhook",
63
+ "WebhookSignatureError",
64
+ "__version__",
65
+ ]
lumify/_transport.py ADDED
@@ -0,0 +1,286 @@
1
+ """
2
+ Low-level HTTP transport shared by every resource (mirrors the TS SDK's
3
+ client.ts). Zero dependencies — the default engine is stdlib ``urllib``.
4
+
5
+ The engine is injectable (``transport=``) so tests and non-standard runtimes
6
+ can supply their own, exactly like passing a custom ``fetch`` to the TS client.
7
+ This also keeps the door open to swapping in an async/``httpx`` engine later
8
+ without touching any resource code.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json as _json
14
+ import random
15
+ import socket
16
+ import time
17
+ import urllib.error
18
+ import urllib.request
19
+ from typing import Any, Callable, Dict, Mapping, Optional
20
+
21
+ from .errors import ConnectionError, LumifyError, RateLimitError, error_from_response
22
+ from .meta import _CaseInsensitiveHeaders, attach_meta, parse_meta
23
+
24
+ DEFAULT_BASE_URL = "https://lumify.ai"
25
+ DEFAULT_TIMEOUT = 30.0
26
+ DEFAULT_MAX_RETRIES = 2
27
+ SDK_VERSION = "0.1.0"
28
+
29
+
30
+ class PreparedRequest:
31
+ """An immutable description of an outbound HTTP request."""
32
+
33
+ __slots__ = ("method", "url", "headers", "body")
34
+
35
+ def __init__(
36
+ self, method: str, url: str, headers: Dict[str, str], body: Optional[str]
37
+ ) -> None:
38
+ self.method = method
39
+ self.url = url
40
+ self.headers = headers
41
+ self.body = body
42
+
43
+
44
+ class RawResponse:
45
+ """The minimal response shape a transport must return."""
46
+
47
+ __slots__ = ("status", "headers", "text")
48
+
49
+ def __init__(self, status: int, headers: Mapping[str, str], text: str) -> None:
50
+ self.status = status
51
+ self.headers = headers
52
+ self.text = text
53
+
54
+
55
+ # A transport is any callable (request, timeout_seconds) -> RawResponse. It must
56
+ # return a RawResponse for *any* HTTP status (including 4xx/5xx) and raise
57
+ # lumify.errors.ConnectionError only for transport-level failures (DNS, refused
58
+ # connection, timeout) where no response was received.
59
+ Transport = Callable[[PreparedRequest, float], RawResponse]
60
+
61
+
62
+ def _urllib_transport(req: PreparedRequest, timeout: float) -> RawResponse:
63
+ request = urllib.request.Request(
64
+ req.url,
65
+ data=req.body.encode("utf-8") if req.body is not None else None,
66
+ method=req.method,
67
+ )
68
+ for key, value in req.headers.items():
69
+ request.add_header(key, value)
70
+
71
+ try:
72
+ resp = urllib.request.urlopen(request, timeout=timeout)
73
+ except urllib.error.HTTPError as exc:
74
+ # A real HTTP response with a non-2xx status — read it and let the
75
+ # client map it to a typed error (uniform with the success path).
76
+ body = exc.read().decode("utf-8", "replace")
77
+ headers = dict(exc.headers.items()) if exc.headers else {}
78
+ return RawResponse(status=exc.code, headers=headers, text=body)
79
+ except (socket.timeout, TimeoutError) as exc:
80
+ raise ConnectionError(
81
+ "Request to %s %s timed out after %ss." % (req.method, req.url, timeout),
82
+ cause=exc,
83
+ )
84
+ except urllib.error.URLError as exc:
85
+ reason = getattr(exc, "reason", exc)
86
+ if isinstance(reason, (socket.timeout, TimeoutError)):
87
+ raise ConnectionError(
88
+ "Request to %s %s timed out after %ss." % (req.method, req.url, timeout),
89
+ cause=exc,
90
+ )
91
+ raise ConnectionError(
92
+ "Request to %s %s failed: %s" % (req.method, req.url, reason),
93
+ cause=exc,
94
+ )
95
+
96
+ with resp:
97
+ body = resp.read().decode("utf-8", "replace")
98
+ status = getattr(resp, "status", None) or resp.getcode()
99
+ headers = dict(resp.headers.items()) if resp.headers else {}
100
+ return RawResponse(status=int(status), headers=headers, text=body)
101
+
102
+
103
+ def _build_query(params: Optional[Mapping[str, Any]]) -> str:
104
+ if not params:
105
+ return ""
106
+ from urllib.parse import urlencode
107
+
108
+ pairs = []
109
+ for key, value in params.items():
110
+ if value is None:
111
+ continue
112
+ if isinstance(value, bool):
113
+ # Match the REST/URLSearchParams contract: lowercase true/false,
114
+ # not Python's "True"/"False".
115
+ pairs.append((key, "true" if value else "false"))
116
+ else:
117
+ pairs.append((key, str(value)))
118
+ if not pairs:
119
+ return ""
120
+ return "?" + urlencode(pairs)
121
+
122
+
123
+ def _safe_json(text: str) -> Any:
124
+ try:
125
+ return _json.loads(text)
126
+ except ValueError:
127
+ return _UNPARSEABLE
128
+
129
+
130
+ _UNPARSEABLE = object()
131
+
132
+
133
+ def _parse_retry_after(value: Optional[str]) -> Optional[float]:
134
+ """Parse ``Retry-After`` as integer seconds; ignore HTTP-date forms."""
135
+ if value is None or value == "":
136
+ return None
137
+ try:
138
+ seconds = float(value)
139
+ except (TypeError, ValueError):
140
+ return None
141
+ return seconds if seconds >= 0 else None
142
+
143
+
144
+ class LumifyClient:
145
+ """The transport underlying every resource. Not usually constructed
146
+ directly — use :class:`lumify.Lumify`."""
147
+
148
+ def __init__(
149
+ self,
150
+ api_key: str,
151
+ *,
152
+ base_url: str = DEFAULT_BASE_URL,
153
+ timeout: float = DEFAULT_TIMEOUT,
154
+ max_retries: int = DEFAULT_MAX_RETRIES,
155
+ transport: Optional[Transport] = None,
156
+ user_agent: Optional[str] = None,
157
+ ) -> None:
158
+ if not api_key:
159
+ raise ValueError(
160
+ "Lumify SDK: `api_key` is required (create one at "
161
+ "https://lumify.ai/api-keys)."
162
+ )
163
+ self._api_key = api_key
164
+ self.base_url = (base_url or DEFAULT_BASE_URL).rstrip("/")
165
+ self.timeout = timeout
166
+ self.max_retries = max_retries
167
+ self._transport: Transport = transport or _urllib_transport
168
+ suffix = (" " + user_agent) if user_agent else ""
169
+ self.user_agent = "lumify-sdk-python/%s%s" % (SDK_VERSION, suffix)
170
+
171
+ def build_url(self, path: str, query: Optional[Mapping[str, Any]] = None) -> str:
172
+ return "%s%s%s" % (self.base_url, path, _build_query(query))
173
+
174
+ def auth_headers(self, accept: str = "application/json") -> Dict[str, str]:
175
+ return {
176
+ "Authorization": "Bearer %s" % self._api_key,
177
+ "Accept": accept,
178
+ "User-Agent": self.user_agent,
179
+ }
180
+
181
+ @property
182
+ def transport(self) -> Transport:
183
+ return self._transport
184
+
185
+ def get(self, path: str, *, query: Optional[Mapping[str, Any]] = None) -> Any:
186
+ return self.request("GET", path, query=query, idempotent=True)
187
+
188
+ def post(
189
+ self,
190
+ path: str,
191
+ *,
192
+ query: Optional[Mapping[str, Any]] = None,
193
+ body: Any = None,
194
+ ) -> Any:
195
+ return self.request("POST", path, query=query, body=body, idempotent=False)
196
+
197
+ def delete(self, path: str, *, query: Optional[Mapping[str, Any]] = None) -> Any:
198
+ return self.request("DELETE", path, query=query, idempotent=True)
199
+
200
+ def request(
201
+ self,
202
+ method: str,
203
+ path: str,
204
+ *,
205
+ query: Optional[Mapping[str, Any]] = None,
206
+ body: Any = None,
207
+ idempotent: Optional[bool] = None,
208
+ ) -> Any:
209
+ if idempotent is None:
210
+ idempotent = method == "GET"
211
+ attempts = max(1, self.max_retries + 1) if idempotent else 1
212
+
213
+ prepared = self._prepare(method, path, query, body)
214
+ last_exc: Optional[LumifyError] = None
215
+
216
+ for attempt in range(attempts):
217
+ if attempt > 0:
218
+ time.sleep(self._backoff(attempt, last_exc))
219
+ try:
220
+ raw = self._transport(prepared, self.timeout)
221
+ return self._process(method, path, raw)
222
+ except LumifyError as exc:
223
+ last_exc = exc
224
+ if not (idempotent and self._should_retry(exc) and attempt < attempts - 1):
225
+ raise
226
+ # Unreachable — the loop always returns or raises.
227
+ assert last_exc is not None
228
+ raise last_exc
229
+
230
+ def _prepare(
231
+ self, method: str, path: str, query: Optional[Mapping[str, Any]], body: Any
232
+ ) -> PreparedRequest:
233
+ headers = self.auth_headers()
234
+ payload: Optional[str] = None
235
+ if body is not None:
236
+ headers["Content-Type"] = "application/json"
237
+ # Drop top-level None values, mirroring the TS SDK's
238
+ # `JSON.stringify` (which silently omits `undefined`-valued keys)
239
+ # — so optional params left unset never serialize as `null`.
240
+ if isinstance(body, dict):
241
+ body = {k: v for k, v in body.items() if v is not None}
242
+ payload = _json.dumps(body)
243
+ return PreparedRequest(method, self.build_url(path, query), headers, payload)
244
+
245
+ def _process(self, method: str, path: str, raw: RawResponse) -> Any:
246
+ headers = _CaseInsensitiveHeaders(raw.headers)
247
+ request_id = headers.get("x-request-id")
248
+ parsed = _safe_json(raw.text) if raw.text else None
249
+ parse_failed = bool(raw.text) and parsed is _UNPARSEABLE
250
+
251
+ if not (200 <= raw.status < 300):
252
+ payload = None
253
+ if isinstance(parsed, dict):
254
+ err = parsed.get("error")
255
+ if isinstance(err, dict):
256
+ payload = err
257
+ raise error_from_response(
258
+ raw.status,
259
+ payload,
260
+ request_id,
261
+ retry_after_header=_parse_retry_after(headers.get("retry-after")),
262
+ )
263
+
264
+ if parse_failed:
265
+ from .errors import APIError
266
+
267
+ raise APIError(
268
+ "Response from %s %s was not valid JSON." % (method, path),
269
+ status=raw.status,
270
+ code="invalid_response",
271
+ request_id=request_id,
272
+ )
273
+
274
+ return attach_meta(parsed, parse_meta(raw.headers))
275
+
276
+ def _should_retry(self, exc: LumifyError) -> bool:
277
+ if isinstance(exc, ConnectionError):
278
+ return True
279
+ return exc.status == 429 or exc.status >= 500
280
+
281
+ def _backoff(self, attempt: int, last_exc: Optional[LumifyError]) -> float:
282
+ if isinstance(last_exc, RateLimitError) and last_exc.retry_after is not None:
283
+ if last_exc.retry_after >= 0:
284
+ return float(last_exc.retry_after)
285
+ base = 0.25 * (2 ** (attempt - 1))
286
+ return base + random.random() * 0.1
lumify/client.py ADDED
@@ -0,0 +1,58 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Optional
4
+
5
+ from ._transport import DEFAULT_BASE_URL, DEFAULT_MAX_RETRIES, DEFAULT_TIMEOUT, LumifyClient, Transport
6
+ from .resources import (
7
+ AgentResource,
8
+ EventsResource,
9
+ PlayersResource,
10
+ SeasonsResource,
11
+ SportsResource,
12
+ TeamsResource,
13
+ WebhooksResource,
14
+ )
15
+
16
+
17
+ class Lumify:
18
+ """The Lumify SDK entry point.
19
+
20
+ Example::
21
+
22
+ from lumify import Lumify
23
+
24
+ client = Lumify(api_key=os.environ["LUMIFY_API_KEY"])
25
+ sports = client.sports.list()
26
+ event = client.events.get(12345, include_odds=True)
27
+ """
28
+
29
+ def __init__(
30
+ self,
31
+ api_key: str,
32
+ *,
33
+ base_url: str = DEFAULT_BASE_URL,
34
+ timeout: float = DEFAULT_TIMEOUT,
35
+ max_retries: int = DEFAULT_MAX_RETRIES,
36
+ transport: Optional[Transport] = None,
37
+ user_agent: Optional[str] = None,
38
+ ) -> None:
39
+ self._client = LumifyClient(
40
+ api_key,
41
+ base_url=base_url,
42
+ timeout=timeout,
43
+ max_retries=max_retries,
44
+ transport=transport,
45
+ user_agent=user_agent,
46
+ )
47
+ self.sports = SportsResource(self._client)
48
+ self.seasons = SeasonsResource(self._client)
49
+ self.events = EventsResource(self._client)
50
+ self.teams = TeamsResource(self._client)
51
+ self.players = PlayersResource(self._client)
52
+ self.webhooks = WebhooksResource(self._client)
53
+ self.agent = AgentResource(self._client)
54
+
55
+ @property
56
+ def base_url(self) -> str:
57
+ """The configured API origin (default ``https://lumify.ai``)."""
58
+ return self._client.base_url
lumify/errors.py ADDED
@@ -0,0 +1,169 @@
1
+ """
2
+ Error taxonomy mirroring api/errors.py's unified envelope (and the TS SDK's
3
+ errors.ts):
4
+
5
+ { "error": { "code", "message", "status", "doc_url", ...extra }, "detail" }
6
+
7
+ Agents should switch on ``err.code``, not parse ``err.message``.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from typing import Any, Dict, List, Optional
13
+
14
+
15
+ class LumifyError(Exception):
16
+ """Base class for every error the SDK raises for a non-2xx API response."""
17
+
18
+ def __init__(
19
+ self,
20
+ message: str,
21
+ *,
22
+ status: int,
23
+ code: str,
24
+ doc_url: Optional[str] = None,
25
+ request_id: Optional[str] = None,
26
+ payload: Optional[Dict[str, Any]] = None,
27
+ ) -> None:
28
+ super().__init__(message)
29
+ self.message = message
30
+ #: Stable machine-readable slug, e.g. ``"not_found"`` — switch on this.
31
+ self.code = code
32
+ #: HTTP status code (0 for connection errors).
33
+ self.status = status
34
+ #: Link to the error-code reference docs, when the envelope carries one.
35
+ self.doc_url = doc_url
36
+ #: Value of the ``X-Request-Id`` response header, if present.
37
+ self.request_id = request_id
38
+ #: The full parsed ``error`` object from the response envelope.
39
+ self.payload = payload or {}
40
+
41
+ def __repr__(self) -> str: # pragma: no cover - cosmetic
42
+ return "%s(code=%r, status=%r, message=%r)" % (
43
+ type(self).__name__,
44
+ self.code,
45
+ self.status,
46
+ self.message,
47
+ )
48
+
49
+
50
+ class AuthenticationError(LumifyError):
51
+ """401 — missing/invalid API key."""
52
+
53
+
54
+ class PaymentError(LumifyError):
55
+ """402 — credit top-up or payment method required."""
56
+
57
+
58
+ class PermissionError(LumifyError):
59
+ """403 — key lacks scope/plan for this resource."""
60
+
61
+ def __init__(self, message: str, **kwargs: Any) -> None:
62
+ super().__init__(message, **kwargs)
63
+ #: Present on sport-scope denials — where to upgrade the key's plan/scope.
64
+ self.upgrade_url: Optional[str] = self.payload.get("upgrade_url")
65
+
66
+
67
+ class NotFoundError(LumifyError):
68
+ """404 — resource does not exist."""
69
+
70
+
71
+ class FieldError:
72
+ """One entry in a 422 validation error's ``errors`` list."""
73
+
74
+ __slots__ = ("field", "message", "type")
75
+
76
+ def __init__(self, field: str, message: str, type: str) -> None: # noqa: A002
77
+ self.field = field
78
+ self.message = message
79
+ self.type = type
80
+
81
+ def __repr__(self) -> str: # pragma: no cover - cosmetic
82
+ return "FieldError(field=%r, message=%r, type=%r)" % (
83
+ self.field,
84
+ self.message,
85
+ self.type,
86
+ )
87
+
88
+
89
+ class ValidationError(LumifyError):
90
+ """422 — request failed server-side validation."""
91
+
92
+ def __init__(self, message: str, **kwargs: Any) -> None:
93
+ super().__init__(message, **kwargs)
94
+ raw = self.payload.get("errors") or []
95
+ self.field_errors: List[FieldError] = [
96
+ FieldError(
97
+ field=str(e.get("field", "")),
98
+ message=str(e.get("message", "")),
99
+ type=str(e.get("type", "")),
100
+ )
101
+ for e in raw
102
+ if isinstance(e, dict)
103
+ ]
104
+
105
+
106
+ class RateLimitError(LumifyError):
107
+ """429 — rate limit exceeded."""
108
+
109
+ def __init__(
110
+ self, message: str, *, retry_after: Optional[float] = None, **kwargs: Any
111
+ ) -> None:
112
+ super().__init__(message, **kwargs)
113
+ # Prefer the envelope field (canonical for Lumify), fall back to the header.
114
+ from_payload = self.payload.get("retry_after")
115
+ if isinstance(from_payload, (int, float)):
116
+ self.retry_after: Optional[float] = float(from_payload)
117
+ elif retry_after is not None:
118
+ self.retry_after = float(retry_after)
119
+ else:
120
+ self.retry_after = None
121
+
122
+
123
+ class APIError(LumifyError):
124
+ """5xx, or any status this SDK has no dedicated class for."""
125
+
126
+
127
+ class ConnectionError(LumifyError):
128
+ """Network failure, timeout, or abort — the request never got a response."""
129
+
130
+ def __init__(self, message: str, *, cause: Optional[BaseException] = None) -> None:
131
+ super().__init__(message, status=0, code="connection_error")
132
+ self.__cause__ = cause
133
+
134
+
135
+ def error_from_response(
136
+ status: int,
137
+ payload: Optional[Dict[str, Any]],
138
+ request_id: Optional[str],
139
+ *,
140
+ retry_after_header: Optional[float] = None,
141
+ ) -> LumifyError:
142
+ """Build the right LumifyError subclass from a parsed error envelope.
143
+
144
+ Falls back to :class:`APIError` for unrecognized status codes.
145
+ """
146
+ payload = payload or None
147
+ code = (payload or {}).get("code") or ("http_%d" % status)
148
+ message = (payload or {}).get("message") or ("Request failed with status %d." % status)
149
+ common = dict(
150
+ status=status,
151
+ code=code,
152
+ doc_url=(payload or {}).get("doc_url"),
153
+ request_id=request_id,
154
+ payload=payload,
155
+ )
156
+
157
+ if status == 401:
158
+ return AuthenticationError(message, **common)
159
+ if status == 402:
160
+ return PaymentError(message, **common)
161
+ if status == 403:
162
+ return PermissionError(message, **common)
163
+ if status == 404:
164
+ return NotFoundError(message, **common)
165
+ if status == 422:
166
+ return ValidationError(message, **common)
167
+ if status == 429:
168
+ return RateLimitError(message, retry_after=retry_after_header, **common)
169
+ return APIError(message, **common)