sharpapi 0.2.2__py3-none-any.whl → 0.2.5__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.
sharpapi/__init__.py CHANGED
@@ -17,9 +17,12 @@ Example::
17
17
  print(f"+{opp.ev_percentage}% on {opp.selection} @ {opp.sportsbook}")
18
18
  """
19
19
 
20
+ from ._utils import american_to_decimal, american_to_probability, decimal_to_american
20
21
  from .async_client import AsyncSharpAPI
21
22
  from .client import SharpAPI
22
23
  from .exceptions import (
24
+ ERROR_CODE_DESCRIPTIONS,
25
+ ERROR_CODE_TO_EXCEPTION,
23
26
  AuthenticationError,
24
27
  RateLimitedError,
25
28
  SharpAPIError,
@@ -28,16 +31,20 @@ from .exceptions import (
28
31
  ValidationError,
29
32
  )
30
33
  from .models import (
31
- APIResponse,
32
34
  AccountInfo,
35
+ APIKey,
36
+ APIResponse,
33
37
  ArbitrageLeg,
34
38
  ArbitrageOpportunity,
35
- EVOpportunity,
39
+ ClosingOddsLine,
40
+ ClosingSnapshot,
36
41
  Event,
42
+ EVOpportunity,
37
43
  GameState,
38
44
  League,
39
45
  LowHoldOpportunity,
40
46
  LowHoldSide,
47
+ Market,
41
48
  MiddleOpportunity,
42
49
  MiddleSide,
43
50
  OddsLine,
@@ -49,25 +56,28 @@ from .models import (
49
56
  Sportsbook,
50
57
  )
51
58
  from .streaming import EventStream
52
- from ._utils import american_to_decimal, american_to_probability, decimal_to_american
53
59
 
54
- __version__ = "0.2.1"
60
+ __version__ = "0.2.5"
55
61
 
56
62
  __all__ = [
57
63
  # Clients
58
64
  "SharpAPI",
59
65
  "AsyncSharpAPI",
60
66
  # Models
67
+ "APIKey",
61
68
  "APIResponse",
62
69
  "AccountInfo",
63
70
  "ArbitrageLeg",
64
71
  "ArbitrageOpportunity",
72
+ "ClosingOddsLine",
73
+ "ClosingSnapshot",
65
74
  "EVOpportunity",
66
75
  "Event",
67
76
  "GameState",
68
77
  "League",
69
78
  "LowHoldOpportunity",
70
79
  "LowHoldSide",
80
+ "Market",
71
81
  "MiddleOpportunity",
72
82
  "MiddleSide",
73
83
  "OddsLine",
@@ -86,6 +96,9 @@ __all__ = [
86
96
  "StreamError",
87
97
  "TierRestrictedError",
88
98
  "ValidationError",
99
+ # Error-code registry
100
+ "ERROR_CODE_DESCRIPTIONS",
101
+ "ERROR_CODE_TO_EXCEPTION",
89
102
  # Utilities
90
103
  "american_to_decimal",
91
104
  "american_to_probability",
sharpapi/_base.py CHANGED
@@ -3,21 +3,29 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import random
6
+ from typing import Literal
6
7
 
7
8
  import httpx
8
9
 
9
10
  from .exceptions import (
11
+ ERROR_CODE_TO_EXCEPTION,
10
12
  AuthenticationError,
11
13
  RateLimitedError,
12
14
  SharpAPIError,
13
15
  TierRestrictedError,
14
16
  ValidationError,
17
+ canonical_code,
15
18
  )
16
19
  from .models import APIResponse, RateLimitInfo, ResponseMeta
17
20
 
18
21
  DEFAULT_BASE_URL = "https://api.sharpapi.io"
19
22
  DEFAULT_TIMEOUT = 30.0
20
- USER_AGENT = "sharpapi-python/0.2.2"
23
+ USER_AGENT = "sharpapi-python/0.2.5"
24
+
25
+ # Supported REST authentication methods. SSE always uses ``?api_key=`` query
26
+ # regardless of this setting because EventSource cannot set custom headers.
27
+ AuthMethod = Literal["x-api-key", "bearer"]
28
+ DEFAULT_AUTH_METHOD: AuthMethod = "x-api-key"
21
29
 
22
30
  RETRY_STATUSES = frozenset({502, 503, 504})
23
31
  RETRY_MAX_ATTEMPTS = 3
@@ -90,6 +98,30 @@ def handle_errors(response: httpx.Response) -> None:
90
98
  code = body.get("code", "unknown_error")
91
99
  status = response.status_code
92
100
 
101
+ # Resolve deprecated code aliases (bad_request, invalid_request → validation_error).
102
+ code = canonical_code(code)
103
+
104
+ # Prefer the canonical code→exception mapping for well-known codes; fall back
105
+ # to HTTP-status-based routing for responses that omit an error code.
106
+ exc_class = ERROR_CODE_TO_EXCEPTION.get(code or "")
107
+ if exc_class is TierRestrictedError:
108
+ raise TierRestrictedError(
109
+ error_msg,
110
+ code=code,
111
+ status=status,
112
+ required_tier=body.get("required_tier"),
113
+ )
114
+ if exc_class is RateLimitedError:
115
+ raise RateLimitedError(
116
+ error_msg,
117
+ code=code,
118
+ status=status,
119
+ retry_after=body.get("retry_after"),
120
+ )
121
+ if exc_class is not None and exc_class is not SharpAPIError:
122
+ raise exc_class(error_msg, code=code, status=status)
123
+
124
+ # No canonical code match — route by HTTP status.
93
125
  if status == 401:
94
126
  raise AuthenticationError(error_msg, code=code, status=status)
95
127
  elif status == 403:
@@ -112,13 +144,28 @@ def handle_errors(response: httpx.Response) -> None:
112
144
  raise SharpAPIError(error_msg, code=code, status=status)
113
145
 
114
146
 
115
- def make_headers(api_key: str) -> dict[str, str]:
116
- """Build default request headers."""
117
- return {
118
- "X-API-Key": api_key,
147
+ def make_headers(
148
+ api_key: str,
149
+ auth_method: AuthMethod = DEFAULT_AUTH_METHOD,
150
+ ) -> dict[str, str]:
151
+ """Build default request headers.
152
+
153
+ Args:
154
+ api_key: The SharpAPI key (e.g. ``sk_live_...``).
155
+ auth_method: Either ``"x-api-key"`` (default — sends an
156
+ ``X-API-Key`` header) or ``"bearer"`` (sends
157
+ ``Authorization: Bearer <key>``). Useful when proxies, IAM
158
+ layers, or SSO gateways strip non-standard custom headers.
159
+ """
160
+ headers: dict[str, str] = {
119
161
  "Content-Type": "application/json",
120
162
  "User-Agent": USER_AGENT,
121
163
  }
164
+ if auth_method == "bearer":
165
+ headers["Authorization"] = f"Bearer {api_key}"
166
+ else:
167
+ headers["X-API-Key"] = api_key
168
+ return headers
122
169
 
123
170
 
124
171
  def _int_or_none(value: str | None) -> int | None:
sharpapi/async_client.py CHANGED
@@ -3,14 +3,16 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import asyncio
6
- from typing import Any, Optional, Union
6
+ from typing import Any
7
7
 
8
8
  import httpx
9
9
 
10
10
  from ._base import (
11
+ DEFAULT_AUTH_METHOD,
11
12
  DEFAULT_BASE_URL,
12
13
  DEFAULT_TIMEOUT,
13
14
  RETRY_MAX_ATTEMPTS,
15
+ AuthMethod,
14
16
  handle_errors,
15
17
  make_headers,
16
18
  parse_rate_limit,
@@ -20,18 +22,21 @@ from ._base import (
20
22
  )
21
23
  from ._utils import _clean_params
22
24
  from .models import (
23
- APIResponse,
24
25
  AccountInfo,
26
+ APIKey,
27
+ APIResponse,
25
28
  ArbitrageOpportunity,
26
- EVOpportunity,
29
+ ClosingSnapshot,
27
30
  Event,
31
+ EVOpportunity,
28
32
  League,
29
33
  LowHoldOpportunity,
34
+ Market,
30
35
  MiddleOpportunity,
31
36
  OddsLine,
32
37
  RateLimitInfo,
33
- Sportsbook,
34
38
  Sport,
39
+ Sportsbook,
35
40
  )
36
41
 
37
42
 
@@ -41,6 +46,17 @@ class AsyncSharpAPI:
41
46
  Provides typed access to odds, +EV, arbitrage, middles, and streaming
42
47
  endpoints using ``async``/``await``.
43
48
 
49
+ Args:
50
+ api_key: Your SharpAPI key (e.g. ``sk_live_...``).
51
+ base_url: Override the API base URL (defaults to production).
52
+ timeout: HTTP timeout in seconds.
53
+ auth_method: How to send the API key on REST requests. ``"x-api-key"``
54
+ (default) sends the ``X-API-Key`` header. ``"bearer"`` sends
55
+ ``Authorization: Bearer <key>`` instead — useful when running
56
+ behind IAM layers, SSO, or API gateways that strip custom
57
+ headers. SSE streams (sync client only) always authenticate via
58
+ ``?api_key=`` query and are unaffected.
59
+
44
60
  Example::
45
61
 
46
62
  import asyncio
@@ -52,6 +68,10 @@ class AsyncSharpAPI:
52
68
  for arb in arbs.data:
53
69
  print(f"{arb.profit_percent}% — {arb.event_name}")
54
70
 
71
+ # Or, behind a proxy that requires standard Bearer auth:
72
+ async with AsyncSharpAPI("sk_live_xxx", auth_method="bearer") as c:
73
+ ...
74
+
55
75
  asyncio.run(main())
56
76
  """
57
77
 
@@ -61,16 +81,18 @@ class AsyncSharpAPI:
61
81
  *,
62
82
  base_url: str = DEFAULT_BASE_URL,
63
83
  timeout: float = DEFAULT_TIMEOUT,
84
+ auth_method: AuthMethod = DEFAULT_AUTH_METHOD,
64
85
  ):
65
86
  if not api_key:
66
87
  raise ValueError("api_key is required")
67
88
 
68
89
  self._api_key = api_key
90
+ self._auth_method: AuthMethod = auth_method
69
91
  self._base_url = base_url.rstrip("/")
70
92
  self._timeout = timeout
71
93
  self._http = httpx.AsyncClient(
72
94
  base_url=f"{self._base_url}/api/v1",
73
- headers=make_headers(api_key),
95
+ headers=make_headers(api_key, auth_method),
74
96
  timeout=timeout,
75
97
  )
76
98
  self._last_rate_limit = RateLimitInfo()
@@ -86,6 +108,7 @@ class AsyncSharpAPI:
86
108
  self.sportsbooks = _AsyncSportsbooksResource(self)
87
109
  self.events = _AsyncEventsResource(self)
88
110
  self.account = _AsyncAccountResource(self)
111
+ self.keys = _AsyncKeysResource(self)
89
112
 
90
113
  @property
91
114
  def rate_limit(self) -> RateLimitInfo:
@@ -93,7 +116,7 @@ class AsyncSharpAPI:
93
116
  return self._last_rate_limit
94
117
 
95
118
  async def _request(self, method: str, path: str, params: dict | None = None, **kwargs) -> Any:
96
- """Make an async API request and return parsed JSON. Retries 502/503/504 with jittered backoff."""
119
+ """Make an async request, return parsed JSON. Retries 502/503/504 with jittered backoff."""
97
120
  if params:
98
121
  params = _clean_params(params)
99
122
 
@@ -148,18 +171,18 @@ class _AsyncOddsResource:
148
171
  async def get(
149
172
  self,
150
173
  *,
151
- sportsbook: Optional[Union[str, list[str]]] = None,
152
- add_sportsbook: Optional[Union[str, list[str]]] = None,
153
- sport: Optional[Union[str, list[str]]] = None,
154
- league: Optional[Union[str, list[str]]] = None,
155
- market: Optional[Union[str, list[str]]] = None,
156
- event: Optional[Union[str, list[str]]] = None,
157
- live: Optional[bool] = None,
158
- sort: Optional[str] = None,
159
- group_by: Optional[str] = None,
160
- fields: Optional[Union[str, list[str]]] = None,
161
- limit: Optional[int] = None,
162
- offset: Optional[int] = None,
174
+ sportsbook: str | list[str] | None = None,
175
+ add_sportsbook: str | list[str] | None = None,
176
+ sport: str | list[str] | None = None,
177
+ league: str | list[str] | None = None,
178
+ market: str | list[str] | None = None,
179
+ event: str | list[str] | None = None,
180
+ live: bool | None = None,
181
+ sort: str | None = None,
182
+ group_by: str | None = None,
183
+ fields: str | list[str] | None = None,
184
+ limit: int | None = None,
185
+ offset: int | None = None,
163
186
  ) -> APIResponse[list[OddsLine]]:
164
187
  """Get current odds snapshot."""
165
188
  data = await self._client._get("/odds", {
@@ -181,15 +204,15 @@ class _AsyncOddsResource:
181
204
  async def best(
182
205
  self,
183
206
  *,
184
- sport: Optional[Union[str, list[str]]] = None,
185
- league: Optional[Union[str, list[str]]] = None,
186
- market: Optional[Union[str, list[str]]] = None,
187
- event: Optional[Union[str, list[str]]] = None,
188
- live: Optional[bool] = None,
189
- sportsbook: Optional[Union[str, list[str]]] = None,
190
- add_sportsbook: Optional[Union[str, list[str]]] = None,
191
- limit: Optional[int] = None,
192
- offset: Optional[int] = None,
207
+ sport: str | list[str] | None = None,
208
+ league: str | list[str] | None = None,
209
+ market: str | list[str] | None = None,
210
+ event: str | list[str] | None = None,
211
+ live: bool | None = None,
212
+ sportsbook: str | list[str] | None = None,
213
+ add_sportsbook: str | list[str] | None = None,
214
+ limit: int | None = None,
215
+ offset: int | None = None,
193
216
  ) -> APIResponse[list[OddsLine]]:
194
217
  """Get best odds per selection across all sportsbooks."""
195
218
  data = await self._client._get("/odds/best", {
@@ -209,7 +232,7 @@ class _AsyncOddsResource:
209
232
  self,
210
233
  event_id: str,
211
234
  *,
212
- market: Optional[str] = None,
235
+ market: str | None = None,
213
236
  ) -> APIResponse[list[OddsLine]]:
214
237
  """Get side-by-side odds comparison for an event."""
215
238
  data = await self._client._get("/odds/comparison", {
@@ -223,6 +246,25 @@ class _AsyncOddsResource:
223
246
  data = await self._client._post("/odds/batch", {"event_ids": event_ids})
224
247
  return parse_response(data, OddsLine)
225
248
 
249
+ async def closing(
250
+ self,
251
+ event_id: str,
252
+ *,
253
+ sportsbook: str | None = None,
254
+ ) -> ClosingSnapshot:
255
+ """Get closing-line snapshot for an event.
256
+
257
+ Returns the captured closing odds grouped by sportsbook. If no
258
+ closing data has been captured for the event, the returned
259
+ ``ClosingSnapshot.books`` mapping will be empty.
260
+ """
261
+ data = await self._client._get("/odds/closing", {
262
+ "event_id": event_id,
263
+ "sportsbook": sportsbook or None,
264
+ })
265
+ raw = data.get("data", data)
266
+ return ClosingSnapshot.model_validate(raw)
267
+
226
268
 
227
269
  class _AsyncEVResource:
228
270
  """Async access to +EV opportunities."""
@@ -233,21 +275,21 @@ class _AsyncEVResource:
233
275
  async def get(
234
276
  self,
235
277
  *,
236
- sport: Optional[Union[str, list[str]]] = None,
237
- league: Optional[Union[str, list[str]]] = None,
238
- sportsbook: Optional[Union[str, list[str]]] = None,
239
- add_sportsbook: Optional[Union[str, list[str]]] = None,
240
- market: Optional[Union[str, list[str]]] = None,
241
- min_ev: Optional[float] = None,
242
- max_ev: Optional[float] = None,
243
- min_market_width: Optional[float] = None,
244
- max_market_width: Optional[float] = None,
245
- max_odds_age: Optional[int] = None,
246
- date_range: Optional[str] = None,
247
- live: Optional[bool] = None,
248
- sort: Optional[str] = None,
249
- limit: Optional[int] = None,
250
- offset: Optional[int] = None,
278
+ sport: str | list[str] | None = None,
279
+ league: str | list[str] | None = None,
280
+ sportsbook: str | list[str] | None = None,
281
+ add_sportsbook: str | list[str] | None = None,
282
+ market: str | list[str] | None = None,
283
+ min_ev: float | None = None,
284
+ max_ev: float | None = None,
285
+ min_market_width: float | None = None,
286
+ max_market_width: float | None = None,
287
+ max_odds_age: int | None = None,
288
+ date_range: str | None = None,
289
+ live: bool | None = None,
290
+ sort: str | None = None,
291
+ limit: int | None = None,
292
+ offset: int | None = None,
251
293
  ) -> APIResponse[list[EVOpportunity]]:
252
294
  """Get +EV opportunities. Requires Pro tier or higher."""
253
295
  data = await self._client._get("/opportunities/ev", {
@@ -279,18 +321,18 @@ class _AsyncArbitrageResource:
279
321
  async def get(
280
322
  self,
281
323
  *,
282
- sport: Optional[Union[str, list[str]]] = None,
283
- league: Optional[Union[str, list[str]]] = None,
284
- sportsbook: Optional[Union[str, list[str]]] = None,
285
- add_sportsbook: Optional[Union[str, list[str]]] = None,
286
- market: Optional[Union[str, list[str]]] = None,
287
- min_profit: Optional[float] = None,
288
- max_odds_age: Optional[int] = None,
289
- live: Optional[bool] = None,
290
- sort: Optional[str] = None,
291
- group: Optional[str] = None,
292
- limit: Optional[int] = None,
293
- offset: Optional[int] = None,
324
+ sport: str | list[str] | None = None,
325
+ league: str | list[str] | None = None,
326
+ sportsbook: str | list[str] | None = None,
327
+ add_sportsbook: str | list[str] | None = None,
328
+ market: str | list[str] | None = None,
329
+ min_profit: float | None = None,
330
+ max_odds_age: int | None = None,
331
+ live: bool | None = None,
332
+ sort: str | None = None,
333
+ group: str | None = None,
334
+ limit: int | None = None,
335
+ offset: int | None = None,
294
336
  ) -> APIResponse[list[ArbitrageOpportunity]]:
295
337
  """Get arbitrage opportunities. Requires Hobby tier or higher."""
296
338
  data = await self._client._get("/opportunities/arbitrage", {
@@ -319,17 +361,17 @@ class _AsyncMiddlesResource:
319
361
  async def get(
320
362
  self,
321
363
  *,
322
- sport: Optional[Union[str, list[str]]] = None,
323
- league: Optional[Union[str, list[str]]] = None,
324
- sportsbook: Optional[Union[str, list[str]]] = None,
325
- market: Optional[Union[str, list[str]]] = None,
326
- min_size: Optional[float] = None,
327
- max_odds_age: Optional[int] = None,
328
- live: Optional[bool] = None,
329
- state: Optional[str] = None,
330
- sort: Optional[str] = None,
331
- limit: Optional[int] = None,
332
- offset: Optional[int] = None,
364
+ sport: str | list[str] | None = None,
365
+ league: str | list[str] | None = None,
366
+ sportsbook: str | list[str] | None = None,
367
+ market: str | list[str] | None = None,
368
+ min_size: float | None = None,
369
+ max_odds_age: int | None = None,
370
+ live: bool | None = None,
371
+ state: str | None = None,
372
+ sort: str | None = None,
373
+ limit: int | None = None,
374
+ offset: int | None = None,
333
375
  ) -> APIResponse[list[MiddleOpportunity]]:
334
376
  """Get middle opportunities. Requires Pro tier or higher."""
335
377
  data = await self._client._get("/opportunities/middles", {
@@ -357,16 +399,16 @@ class _AsyncLowHoldResource:
357
399
  async def get(
358
400
  self,
359
401
  *,
360
- sport: Optional[Union[str, list[str]]] = None,
361
- league: Optional[Union[str, list[str]]] = None,
362
- sportsbook: Optional[Union[str, list[str]]] = None,
363
- market: Optional[Union[str, list[str]]] = None,
364
- max_hold: Optional[float] = None,
365
- live: Optional[bool] = None,
366
- state: Optional[str] = None,
367
- sort: Optional[str] = None,
368
- limit: Optional[int] = None,
369
- offset: Optional[int] = None,
402
+ sport: str | list[str] | None = None,
403
+ league: str | list[str] | None = None,
404
+ sportsbook: str | list[str] | None = None,
405
+ market: str | list[str] | None = None,
406
+ max_hold: float | None = None,
407
+ live: bool | None = None,
408
+ state: str | None = None,
409
+ sort: str | None = None,
410
+ limit: int | None = None,
411
+ offset: int | None = None,
370
412
  ) -> APIResponse[list[LowHoldOpportunity]]:
371
413
  """Get low-hold opportunities."""
372
414
  data = await self._client._get("/opportunities/low_hold", {
@@ -404,7 +446,7 @@ class _AsyncLeaguesResource:
404
446
  def __init__(self, client: AsyncSharpAPI):
405
447
  self._client = client
406
448
 
407
- async def list(self, *, sport: Optional[str] = None) -> APIResponse[list[League]]:
449
+ async def list(self, *, sport: str | None = None) -> APIResponse[list[League]]:
408
450
  """List all leagues, optionally filtered by sport."""
409
451
  data = await self._client._get("/leagues", {"sport": sport})
410
452
  return parse_response(data, League)
@@ -439,11 +481,11 @@ class _AsyncEventsResource:
439
481
  async def list(
440
482
  self,
441
483
  *,
442
- sport: Optional[str] = None,
443
- league: Optional[Union[str, list[str]]] = None,
444
- live: Optional[bool] = None,
445
- limit: Optional[int] = None,
446
- offset: Optional[int] = None,
484
+ sport: str | None = None,
485
+ league: str | list[str] | None = None,
486
+ live: bool | None = None,
487
+ limit: int | None = None,
488
+ offset: int | None = None,
447
489
  ) -> APIResponse[list[Event]]:
448
490
  """List events."""
449
491
  data = await self._client._get("/events", {
@@ -461,6 +503,11 @@ class _AsyncEventsResource:
461
503
  raw = data.get("data", data)
462
504
  return Event.model_validate(raw)
463
505
 
506
+ async def markets(self, event_id: str) -> APIResponse[list[Market]]:
507
+ """List the markets available on a specific event."""
508
+ data = await self._client._get(f"/events/{event_id}/markets")
509
+ return parse_response(data, Market)
510
+
464
511
 
465
512
  class _AsyncAccountResource:
466
513
  def __init__(self, client: AsyncSharpAPI):
@@ -476,3 +523,33 @@ class _AsyncAccountResource:
476
523
  """Get current usage stats."""
477
524
  data = await self._client._get("/account/usage")
478
525
  return data.get("data", data)
526
+
527
+
528
+ class _AsyncKeysResource:
529
+ """Async access to API key CRUD on the current account."""
530
+
531
+ def __init__(self, client: AsyncSharpAPI):
532
+ self._client = client
533
+
534
+ async def list(self) -> APIResponse[list[APIKey]]:
535
+ """List all API keys on the account."""
536
+ data = await self._client._get("/account/keys")
537
+ return parse_response(data, APIKey)
538
+
539
+ async def create(self, name: str) -> APIKey:
540
+ """Create a new API key. Returned ``APIKey.key`` is shown only once."""
541
+ data = await self._client._post("/account/keys", {"name": name})
542
+ raw = data.get("data", data)
543
+ return APIKey.model_validate(raw)
544
+
545
+ async def revoke(self, key_id: str) -> None:
546
+ """Revoke (delete) an API key by ID."""
547
+ await self._client._request("DELETE", f"/account/keys/{key_id}")
548
+
549
+ async def rotate(self, key_id: str) -> APIKey:
550
+ """Rotate an API key — issues a new key and revokes the old one."""
551
+ data = await self._client._post(f"/account/keys/{key_id}/rotate")
552
+ raw = data.get("data", data)
553
+ if isinstance(raw, dict) and "new_key" in raw:
554
+ return APIKey.model_validate(raw["new_key"])
555
+ return APIKey.model_validate(raw)