Skip to content

Commit e6e1000

Browse files
Mlaz-codeclaude
andcommitted
feat(sdk): add Odds.closing, Events.markets, Keys CRUD
Three coverage gaps in the Python SDK against the live REST surface: - Odds.closing(event_id, sportsbook=None) → GET /odds/closing Returns ClosingSnapshot with per-book ClosingOddsLine arrays. - Events.markets(event_id) → GET /events/{event_id}/markets Returns APIResponse[list[Market]] with market enumeration. - Keys namespace → full CRUD on /account/keys list() — GET /account/keys create(name) — POST /account/keys revoke(key_id) — DELETE /account/keys/{key_id} rotate(key_id) — POST /account/keys/{key_id}/rotate Sync (client.py) and async (async_client.py) parity. New models (Market, ClosingOddsLine, ClosingSnapshot, APIKey) re-exported from the package root. Bumped 0.2.3 → 0.2.4. Pyright 0/0, pytest 87/87. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent de45ffb commit e6e1000

6 files changed

Lines changed: 206 additions & 3 deletions

File tree

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
44

55
[project]
66
name = "sharpapi"
7-
version = "0.2.3"
7+
version = "0.2.4"
88
description = "Official Python SDK for the SharpAPI real-time sports betting odds API"
99
readme = "README.md"
1010
license = "MIT"

src/sharpapi/__init__.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,15 +32,19 @@
3232
)
3333
from .models import (
3434
AccountInfo,
35+
APIKey,
3536
APIResponse,
3637
ArbitrageLeg,
3738
ArbitrageOpportunity,
39+
ClosingOddsLine,
40+
ClosingSnapshot,
3841
Event,
3942
EVOpportunity,
4043
GameState,
4144
League,
4245
LowHoldOpportunity,
4346
LowHoldSide,
47+
Market,
4448
MiddleOpportunity,
4549
MiddleSide,
4650
OddsLine,
@@ -53,23 +57,27 @@
5357
)
5458
from .streaming import EventStream
5559

56-
__version__ = "0.2.3"
60+
__version__ = "0.2.4"
5761

5862
__all__ = [
5963
# Clients
6064
"SharpAPI",
6165
"AsyncSharpAPI",
6266
# Models
67+
"APIKey",
6368
"APIResponse",
6469
"AccountInfo",
6570
"ArbitrageLeg",
6671
"ArbitrageOpportunity",
72+
"ClosingOddsLine",
73+
"ClosingSnapshot",
6774
"EVOpportunity",
6875
"Event",
6976
"GameState",
7077
"League",
7178
"LowHoldOpportunity",
7279
"LowHoldSide",
80+
"Market",
7381
"MiddleOpportunity",
7482
"MiddleSide",
7583
"OddsLine",

src/sharpapi/_base.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919

2020
DEFAULT_BASE_URL = "https://api.sharpapi.io"
2121
DEFAULT_TIMEOUT = 30.0
22-
USER_AGENT = "sharpapi-python/0.2.3"
22+
USER_AGENT = "sharpapi-python/0.2.4"
2323

2424
RETRY_STATUSES = frozenset({502, 503, 504})
2525
RETRY_MAX_ATTEMPTS = 3

src/sharpapi/async_client.py

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,12 +21,15 @@
2121
from ._utils import _clean_params
2222
from .models import (
2323
AccountInfo,
24+
APIKey,
2425
APIResponse,
2526
ArbitrageOpportunity,
27+
ClosingSnapshot,
2628
Event,
2729
EVOpportunity,
2830
League,
2931
LowHoldOpportunity,
32+
Market,
3033
MiddleOpportunity,
3134
OddsLine,
3235
RateLimitInfo,
@@ -86,6 +89,7 @@ def __init__(
8689
self.sportsbooks = _AsyncSportsbooksResource(self)
8790
self.events = _AsyncEventsResource(self)
8891
self.account = _AsyncAccountResource(self)
92+
self.keys = _AsyncKeysResource(self)
8993

9094
@property
9195
def rate_limit(self) -> RateLimitInfo:
@@ -223,6 +227,25 @@ async def batch(self, event_ids: list[str]) -> APIResponse[list[OddsLine]]:
223227
data = await self._client._post("/odds/batch", {"event_ids": event_ids})
224228
return parse_response(data, OddsLine)
225229

230+
async def closing(
231+
self,
232+
event_id: str,
233+
*,
234+
sportsbook: str | None = None,
235+
) -> ClosingSnapshot:
236+
"""Get closing-line snapshot for an event.
237+
238+
Returns the captured closing odds grouped by sportsbook. If no
239+
closing data has been captured for the event, the returned
240+
``ClosingSnapshot.books`` mapping will be empty.
241+
"""
242+
data = await self._client._get("/odds/closing", {
243+
"event_id": event_id,
244+
"sportsbook": sportsbook or None,
245+
})
246+
raw = data.get("data", data)
247+
return ClosingSnapshot.model_validate(raw)
248+
226249

227250
class _AsyncEVResource:
228251
"""Async access to +EV opportunities."""
@@ -461,6 +484,11 @@ async def get(self, event_id: str) -> Event:
461484
raw = data.get("data", data)
462485
return Event.model_validate(raw)
463486

487+
async def markets(self, event_id: str) -> APIResponse[list[Market]]:
488+
"""List the markets available on a specific event."""
489+
data = await self._client._get(f"/events/{event_id}/markets")
490+
return parse_response(data, Market)
491+
464492

465493
class _AsyncAccountResource:
466494
def __init__(self, client: AsyncSharpAPI):
@@ -476,3 +504,33 @@ async def usage(self) -> dict:
476504
"""Get current usage stats."""
477505
data = await self._client._get("/account/usage")
478506
return data.get("data", data)
507+
508+
509+
class _AsyncKeysResource:
510+
"""Async access to API key CRUD on the current account."""
511+
512+
def __init__(self, client: AsyncSharpAPI):
513+
self._client = client
514+
515+
async def list(self) -> APIResponse[list[APIKey]]:
516+
"""List all API keys on the account."""
517+
data = await self._client._get("/account/keys")
518+
return parse_response(data, APIKey)
519+
520+
async def create(self, name: str) -> APIKey:
521+
"""Create a new API key. Returned ``APIKey.key`` is shown only once."""
522+
data = await self._client._post("/account/keys", {"name": name})
523+
raw = data.get("data", data)
524+
return APIKey.model_validate(raw)
525+
526+
async def revoke(self, key_id: str) -> None:
527+
"""Revoke (delete) an API key by ID."""
528+
await self._client._request("DELETE", f"/account/keys/{key_id}")
529+
530+
async def rotate(self, key_id: str) -> APIKey:
531+
"""Rotate an API key — issues a new key and revokes the old one."""
532+
data = await self._client._post(f"/account/keys/{key_id}/rotate")
533+
raw = data.get("data", data)
534+
if isinstance(raw, dict) and "new_key" in raw:
535+
return APIKey.model_validate(raw["new_key"])
536+
return APIKey.model_validate(raw)

src/sharpapi/client.py

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,12 +21,15 @@
2121
from ._utils import _clean_params
2222
from .models import (
2323
AccountInfo,
24+
APIKey,
2425
APIResponse,
2526
ArbitrageOpportunity,
27+
ClosingSnapshot,
2628
Event,
2729
EVOpportunity,
2830
League,
2931
LowHoldOpportunity,
32+
Market,
3033
MiddleOpportunity,
3134
OddsLine,
3235
RateLimitInfo,
@@ -95,6 +98,7 @@ def __init__(
9598
self.sportsbooks = _SportsbooksResource(self)
9699
self.events = _EventsResource(self)
97100
self.account = _AccountResource(self)
101+
self.keys = _KeysResource(self)
98102
self.stream = _StreamResource(self)
99103

100104
@property
@@ -248,6 +252,29 @@ def batch(self, event_ids: list[str]) -> APIResponse[list[OddsLine]]:
248252
data = self._client._post("/odds/batch", {"event_ids": event_ids})
249253
return _parse_response(data, OddsLine)
250254

255+
def closing(
256+
self,
257+
event_id: str,
258+
*,
259+
sportsbook: str | None = None,
260+
) -> ClosingSnapshot:
261+
"""Get closing-line snapshot for an event.
262+
263+
Returns the captured closing odds grouped by sportsbook. If no
264+
closing data has been captured for the event, the returned
265+
``ClosingSnapshot.books`` mapping will be empty.
266+
267+
Args:
268+
event_id: Event ID to fetch closing odds for.
269+
sportsbook: Optional sportsbook filter (single book ID).
270+
"""
271+
data = self._client._get("/odds/closing", {
272+
"event_id": event_id,
273+
"sportsbook": sportsbook or None,
274+
})
275+
raw = data.get("data", data)
276+
return ClosingSnapshot.model_validate(raw)
277+
251278

252279
class _EVResource:
253280
"""Access +EV opportunities."""
@@ -574,6 +601,11 @@ def get(self, event_id: str) -> Event:
574601
raw = data.get("data", data)
575602
return Event.model_validate(raw)
576603

604+
def markets(self, event_id: str) -> APIResponse[list[Market]]:
605+
"""List the markets available on a specific event."""
606+
data = self._client._get(f"/events/{event_id}/markets")
607+
return _parse_response(data, Market)
608+
577609

578610
class _AccountResource:
579611
def __init__(self, client: SharpAPI):
@@ -591,6 +623,50 @@ def usage(self) -> dict:
591623
return data.get("data", data)
592624

593625

626+
class _KeysResource:
627+
"""Manage API keys on the current account.
628+
629+
Wraps the ``/account/keys`` CRUD endpoints. Requires authentication
630+
with a key whose user has permission to manage keys (typically a
631+
dashboard-issued key).
632+
"""
633+
634+
def __init__(self, client: SharpAPI):
635+
self._client = client
636+
637+
def list(self) -> APIResponse[list[APIKey]]:
638+
"""List all API keys on the account."""
639+
data = self._client._get("/account/keys")
640+
return _parse_response(data, APIKey)
641+
642+
def create(self, name: str) -> APIKey:
643+
"""Create a new API key.
644+
645+
Returns the new ``APIKey`` including the one-time ``key`` secret
646+
in ``APIKey.key``. Store it securely — it will not be shown again.
647+
"""
648+
data = self._client._post("/account/keys", {"name": name})
649+
raw = data.get("data", data)
650+
return APIKey.model_validate(raw)
651+
652+
def revoke(self, key_id: str) -> None:
653+
"""Revoke (delete) an API key by ID."""
654+
self._client._request("DELETE", f"/account/keys/{key_id}")
655+
656+
def rotate(self, key_id: str) -> APIKey:
657+
"""Rotate an API key — issues a new key and revokes the old one.
658+
659+
Returns the newly created ``APIKey`` (including the one-time
660+
``key`` secret).
661+
"""
662+
data = self._client._post(f"/account/keys/{key_id}/rotate")
663+
raw = data.get("data", data)
664+
# Rotate response shape is {"data": {"new_key": {...}, "old_key": {...}}}
665+
if isinstance(raw, dict) and "new_key" in raw:
666+
return APIKey.model_validate(raw["new_key"])
667+
return APIKey.model_validate(raw)
668+
669+
594670
class _StreamResource:
595671
"""Build SSE stream connections."""
596672

src/sharpapi/models.py

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -406,6 +406,67 @@ class Event(BaseModel):
406406
status: str | None = None
407407

408408

409+
class Market(BaseModel):
410+
"""A market available on an event."""
411+
412+
market_type: str
413+
market_label: str | None = None
414+
selection_count: int | None = None
415+
book_count: int | None = None
416+
books: list[str] | None = None
417+
418+
419+
# =============================================================================
420+
# Closing Snapshot
421+
# =============================================================================
422+
423+
424+
class ClosingOddsLine(BaseModel):
425+
"""A single closing-line odds entry within a closing snapshot."""
426+
427+
sportsbook: str
428+
market_type: str
429+
selection: str
430+
selection_type: str | None = None
431+
odds_american: int | float
432+
odds_decimal: float
433+
line: float | None = None
434+
player_name: str | None = None
435+
stat_category: str | None = None
436+
437+
438+
class ClosingSnapshot(BaseModel):
439+
"""Closing-line snapshot for an event, grouped by sportsbook."""
440+
441+
event_id: str
442+
sport: str | None = None
443+
league: str | None = None
444+
home_team: str | None = None
445+
away_team: str | None = None
446+
event_start_time: str | None = None
447+
captured_at: str | None = None
448+
books: dict[str, list[ClosingOddsLine]] = Field(default_factory=dict)
449+
450+
451+
# =============================================================================
452+
# Account / Keys
453+
# =============================================================================
454+
455+
456+
class APIKey(BaseModel):
457+
"""An API key managed via the /account/keys endpoints."""
458+
459+
id: str
460+
id_masked: str | None = None
461+
# Present only on create/rotate responses (one-time secret).
462+
key: str | None = None
463+
name: str | None = None
464+
tier: str | None = None
465+
is_active: bool | None = None
466+
created_at: str | None = None
467+
updated_at: str | None = None
468+
469+
409470
# =============================================================================
410471
# Account
411472
# =============================================================================

0 commit comments

Comments
 (0)