sharpapi 0.2.1__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
@@ -2,20 +2,48 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import random
6
+ from typing import Literal
7
+
5
8
  import httpx
6
9
 
7
10
  from .exceptions import (
11
+ ERROR_CODE_TO_EXCEPTION,
8
12
  AuthenticationError,
9
13
  RateLimitedError,
10
14
  SharpAPIError,
11
15
  TierRestrictedError,
12
16
  ValidationError,
17
+ canonical_code,
13
18
  )
14
19
  from .models import APIResponse, RateLimitInfo, ResponseMeta
15
20
 
16
21
  DEFAULT_BASE_URL = "https://api.sharpapi.io"
17
22
  DEFAULT_TIMEOUT = 30.0
18
- USER_AGENT = "sharpapi-python/0.2.0"
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"
29
+
30
+ RETRY_STATUSES = frozenset({502, 503, 504})
31
+ RETRY_MAX_ATTEMPTS = 3
32
+ RETRY_BASE_DELAY = 0.5
33
+ RETRY_MAX_DELAY = 4.0
34
+
35
+
36
+ def should_retry(response: httpx.Response | None, exc: Exception | None) -> bool:
37
+ """True for transient upstream failures worth retrying."""
38
+ if exc is not None:
39
+ return isinstance(exc, (httpx.ConnectError, httpx.ReadError, httpx.RemoteProtocolError))
40
+ return response is not None and response.status_code in RETRY_STATUSES
41
+
42
+
43
+ def retry_delay(attempt: int) -> float:
44
+ """Exponential backoff with full jitter. attempt is 1-indexed."""
45
+ ceiling = min(RETRY_BASE_DELAY * (2 ** (attempt - 1)), RETRY_MAX_DELAY)
46
+ return random.uniform(0, ceiling)
19
47
 
20
48
 
21
49
  def parse_response(raw: dict, model_class: type) -> APIResponse:
@@ -70,6 +98,30 @@ def handle_errors(response: httpx.Response) -> None:
70
98
  code = body.get("code", "unknown_error")
71
99
  status = response.status_code
72
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.
73
125
  if status == 401:
74
126
  raise AuthenticationError(error_msg, code=code, status=status)
75
127
  elif status == 403:
@@ -92,13 +144,28 @@ def handle_errors(response: httpx.Response) -> None:
92
144
  raise SharpAPIError(error_msg, code=code, status=status)
93
145
 
94
146
 
95
- def make_headers(api_key: str) -> dict[str, str]:
96
- """Build default request headers."""
97
- return {
98
- "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] = {
99
161
  "Content-Type": "application/json",
100
162
  "User-Agent": USER_AGENT,
101
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
102
169
 
103
170
 
104
171
  def _int_or_none(value: str | None) -> int | None:
sharpapi/async_client.py CHANGED
@@ -2,32 +2,41 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- from typing import Any, Optional, Union
5
+ import asyncio
6
+ from typing import Any
6
7
 
7
8
  import httpx
8
9
 
9
10
  from ._base import (
11
+ DEFAULT_AUTH_METHOD,
10
12
  DEFAULT_BASE_URL,
11
13
  DEFAULT_TIMEOUT,
14
+ RETRY_MAX_ATTEMPTS,
15
+ AuthMethod,
12
16
  handle_errors,
13
17
  make_headers,
14
18
  parse_rate_limit,
15
19
  parse_response,
20
+ retry_delay,
21
+ should_retry,
16
22
  )
17
23
  from ._utils import _clean_params
18
24
  from .models import (
19
- APIResponse,
20
25
  AccountInfo,
26
+ APIKey,
27
+ APIResponse,
21
28
  ArbitrageOpportunity,
22
- EVOpportunity,
29
+ ClosingSnapshot,
23
30
  Event,
31
+ EVOpportunity,
24
32
  League,
25
33
  LowHoldOpportunity,
34
+ Market,
26
35
  MiddleOpportunity,
27
36
  OddsLine,
28
37
  RateLimitInfo,
29
- Sportsbook,
30
38
  Sport,
39
+ Sportsbook,
31
40
  )
32
41
 
33
42
 
@@ -37,6 +46,17 @@ class AsyncSharpAPI:
37
46
  Provides typed access to odds, +EV, arbitrage, middles, and streaming
38
47
  endpoints using ``async``/``await``.
39
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
+
40
60
  Example::
41
61
 
42
62
  import asyncio
@@ -48,6 +68,10 @@ class AsyncSharpAPI:
48
68
  for arb in arbs.data:
49
69
  print(f"{arb.profit_percent}% — {arb.event_name}")
50
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
+
51
75
  asyncio.run(main())
52
76
  """
53
77
 
@@ -57,16 +81,18 @@ class AsyncSharpAPI:
57
81
  *,
58
82
  base_url: str = DEFAULT_BASE_URL,
59
83
  timeout: float = DEFAULT_TIMEOUT,
84
+ auth_method: AuthMethod = DEFAULT_AUTH_METHOD,
60
85
  ):
61
86
  if not api_key:
62
87
  raise ValueError("api_key is required")
63
88
 
64
89
  self._api_key = api_key
90
+ self._auth_method: AuthMethod = auth_method
65
91
  self._base_url = base_url.rstrip("/")
66
92
  self._timeout = timeout
67
93
  self._http = httpx.AsyncClient(
68
94
  base_url=f"{self._base_url}/api/v1",
69
- headers=make_headers(api_key),
95
+ headers=make_headers(api_key, auth_method),
70
96
  timeout=timeout,
71
97
  )
72
98
  self._last_rate_limit = RateLimitInfo()
@@ -82,6 +108,7 @@ class AsyncSharpAPI:
82
108
  self.sportsbooks = _AsyncSportsbooksResource(self)
83
109
  self.events = _AsyncEventsResource(self)
84
110
  self.account = _AsyncAccountResource(self)
111
+ self.keys = _AsyncKeysResource(self)
85
112
 
86
113
  @property
87
114
  def rate_limit(self) -> RateLimitInfo:
@@ -89,11 +116,26 @@ class AsyncSharpAPI:
89
116
  return self._last_rate_limit
90
117
 
91
118
  async def _request(self, method: str, path: str, params: dict | None = None, **kwargs) -> Any:
92
- """Make an async API request and return parsed JSON."""
119
+ """Make an async request, return parsed JSON. Retries 502/503/504 with jittered backoff."""
93
120
  if params:
94
121
  params = _clean_params(params)
95
122
 
96
- response = await self._http.request(method, path, params=params, **kwargs)
123
+ response: httpx.Response | None = None
124
+ for attempt in range(1, RETRY_MAX_ATTEMPTS + 1):
125
+ exc: Exception | None = None
126
+ try:
127
+ response = await self._http.request(method, path, params=params, **kwargs)
128
+ except (httpx.ConnectError, httpx.ReadError, httpx.RemoteProtocolError) as e:
129
+ exc = e
130
+
131
+ if attempt < RETRY_MAX_ATTEMPTS and should_retry(response, exc):
132
+ await asyncio.sleep(retry_delay(attempt))
133
+ continue
134
+ if exc is not None:
135
+ raise exc
136
+ break
137
+
138
+ assert response is not None
97
139
  self._last_rate_limit = parse_rate_limit(response)
98
140
  handle_errors(response)
99
141
  return response.json()
@@ -129,18 +171,18 @@ class _AsyncOddsResource:
129
171
  async def get(
130
172
  self,
131
173
  *,
132
- sportsbook: Optional[Union[str, list[str]]] = None,
133
- add_sportsbook: Optional[Union[str, list[str]]] = None,
134
- sport: Optional[Union[str, list[str]]] = None,
135
- league: Optional[Union[str, list[str]]] = None,
136
- market: Optional[Union[str, list[str]]] = None,
137
- event: Optional[Union[str, list[str]]] = None,
138
- live: Optional[bool] = None,
139
- sort: Optional[str] = None,
140
- group_by: Optional[str] = None,
141
- fields: Optional[Union[str, list[str]]] = None,
142
- limit: Optional[int] = None,
143
- 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,
144
186
  ) -> APIResponse[list[OddsLine]]:
145
187
  """Get current odds snapshot."""
146
188
  data = await self._client._get("/odds", {
@@ -162,15 +204,15 @@ class _AsyncOddsResource:
162
204
  async def best(
163
205
  self,
164
206
  *,
165
- sport: Optional[Union[str, list[str]]] = None,
166
- league: Optional[Union[str, list[str]]] = None,
167
- market: Optional[Union[str, list[str]]] = None,
168
- event: Optional[Union[str, list[str]]] = None,
169
- live: Optional[bool] = None,
170
- sportsbook: Optional[Union[str, list[str]]] = None,
171
- add_sportsbook: Optional[Union[str, list[str]]] = None,
172
- limit: Optional[int] = None,
173
- 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,
174
216
  ) -> APIResponse[list[OddsLine]]:
175
217
  """Get best odds per selection across all sportsbooks."""
176
218
  data = await self._client._get("/odds/best", {
@@ -190,7 +232,7 @@ class _AsyncOddsResource:
190
232
  self,
191
233
  event_id: str,
192
234
  *,
193
- market: Optional[str] = None,
235
+ market: str | None = None,
194
236
  ) -> APIResponse[list[OddsLine]]:
195
237
  """Get side-by-side odds comparison for an event."""
196
238
  data = await self._client._get("/odds/comparison", {
@@ -204,6 +246,25 @@ class _AsyncOddsResource:
204
246
  data = await self._client._post("/odds/batch", {"event_ids": event_ids})
205
247
  return parse_response(data, OddsLine)
206
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
+
207
268
 
208
269
  class _AsyncEVResource:
209
270
  """Async access to +EV opportunities."""
@@ -214,21 +275,21 @@ class _AsyncEVResource:
214
275
  async def get(
215
276
  self,
216
277
  *,
217
- sport: Optional[Union[str, list[str]]] = None,
218
- league: Optional[Union[str, list[str]]] = None,
219
- sportsbook: Optional[Union[str, list[str]]] = None,
220
- add_sportsbook: Optional[Union[str, list[str]]] = None,
221
- market: Optional[Union[str, list[str]]] = None,
222
- min_ev: Optional[float] = None,
223
- max_ev: Optional[float] = None,
224
- min_market_width: Optional[float] = None,
225
- max_market_width: Optional[float] = None,
226
- max_odds_age: Optional[int] = None,
227
- date_range: Optional[str] = None,
228
- live: Optional[bool] = None,
229
- sort: Optional[str] = None,
230
- limit: Optional[int] = None,
231
- 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,
232
293
  ) -> APIResponse[list[EVOpportunity]]:
233
294
  """Get +EV opportunities. Requires Pro tier or higher."""
234
295
  data = await self._client._get("/opportunities/ev", {
@@ -260,18 +321,18 @@ class _AsyncArbitrageResource:
260
321
  async def get(
261
322
  self,
262
323
  *,
263
- sport: Optional[Union[str, list[str]]] = None,
264
- league: Optional[Union[str, list[str]]] = None,
265
- sportsbook: Optional[Union[str, list[str]]] = None,
266
- add_sportsbook: Optional[Union[str, list[str]]] = None,
267
- market: Optional[Union[str, list[str]]] = None,
268
- min_profit: Optional[float] = None,
269
- max_odds_age: Optional[int] = None,
270
- live: Optional[bool] = None,
271
- sort: Optional[str] = None,
272
- group: Optional[str] = None,
273
- limit: Optional[int] = None,
274
- 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,
275
336
  ) -> APIResponse[list[ArbitrageOpportunity]]:
276
337
  """Get arbitrage opportunities. Requires Hobby tier or higher."""
277
338
  data = await self._client._get("/opportunities/arbitrage", {
@@ -300,17 +361,17 @@ class _AsyncMiddlesResource:
300
361
  async def get(
301
362
  self,
302
363
  *,
303
- sport: Optional[Union[str, list[str]]] = None,
304
- league: Optional[Union[str, list[str]]] = None,
305
- sportsbook: Optional[Union[str, list[str]]] = None,
306
- market: Optional[Union[str, list[str]]] = None,
307
- min_size: Optional[float] = None,
308
- max_odds_age: Optional[int] = None,
309
- live: Optional[bool] = None,
310
- state: Optional[str] = None,
311
- sort: Optional[str] = None,
312
- limit: Optional[int] = None,
313
- 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,
314
375
  ) -> APIResponse[list[MiddleOpportunity]]:
315
376
  """Get middle opportunities. Requires Pro tier or higher."""
316
377
  data = await self._client._get("/opportunities/middles", {
@@ -338,16 +399,16 @@ class _AsyncLowHoldResource:
338
399
  async def get(
339
400
  self,
340
401
  *,
341
- sport: Optional[Union[str, list[str]]] = None,
342
- league: Optional[Union[str, list[str]]] = None,
343
- sportsbook: Optional[Union[str, list[str]]] = None,
344
- market: Optional[Union[str, list[str]]] = None,
345
- max_hold: Optional[float] = None,
346
- live: Optional[bool] = None,
347
- state: Optional[str] = None,
348
- sort: Optional[str] = None,
349
- limit: Optional[int] = None,
350
- 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,
351
412
  ) -> APIResponse[list[LowHoldOpportunity]]:
352
413
  """Get low-hold opportunities."""
353
414
  data = await self._client._get("/opportunities/low_hold", {
@@ -385,7 +446,7 @@ class _AsyncLeaguesResource:
385
446
  def __init__(self, client: AsyncSharpAPI):
386
447
  self._client = client
387
448
 
388
- async def list(self, *, sport: Optional[str] = None) -> APIResponse[list[League]]:
449
+ async def list(self, *, sport: str | None = None) -> APIResponse[list[League]]:
389
450
  """List all leagues, optionally filtered by sport."""
390
451
  data = await self._client._get("/leagues", {"sport": sport})
391
452
  return parse_response(data, League)
@@ -420,11 +481,11 @@ class _AsyncEventsResource:
420
481
  async def list(
421
482
  self,
422
483
  *,
423
- sport: Optional[str] = None,
424
- league: Optional[Union[str, list[str]]] = None,
425
- live: Optional[bool] = None,
426
- limit: Optional[int] = None,
427
- 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,
428
489
  ) -> APIResponse[list[Event]]:
429
490
  """List events."""
430
491
  data = await self._client._get("/events", {
@@ -442,6 +503,11 @@ class _AsyncEventsResource:
442
503
  raw = data.get("data", data)
443
504
  return Event.model_validate(raw)
444
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
+
445
511
 
446
512
  class _AsyncAccountResource:
447
513
  def __init__(self, client: AsyncSharpAPI):
@@ -457,3 +523,33 @@ class _AsyncAccountResource:
457
523
  """Get current usage stats."""
458
524
  data = await self._client._get("/account/usage")
459
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)