draftkingsapi 0.1.0__tar.gz

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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 draftkingsapi.com
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,152 @@
1
+ Metadata-Version: 2.4
2
+ Name: draftkingsapi
3
+ Version: 0.1.0
4
+ Summary: Python client for draftkingsapi.com: DraftKings Pick6 lines and line history, DFS salaries, sportsbook odds and box scores. Independent, not affiliated with DraftKings.
5
+ License: MIT
6
+ Project-URL: Repository, https://github.com/draftkingsapi/draftkingsapi-python
7
+ Project-URL: Issues, https://github.com/draftkingsapi/draftkingsapi-python/issues
8
+ Project-URL: Homepage, https://draftkingsapi.com
9
+ Project-URL: Documentation, https://draftkingsapi.com/docs
10
+ Project-URL: OpenAPI, https://draftkingsapi.com/openapi.json
11
+ Project-URL: Status, https://draftkingsapi.com/status
12
+ Keywords: draftkings,pick6,dfs,sportsbook,odds,sports-data,api-client
13
+ Classifier: Development Status :: 4 - Beta
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: License :: OSI Approved :: MIT License
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Typing :: Typed
18
+ Classifier: Topic :: Software Development :: Libraries
19
+ Requires-Python: >=3.8
20
+ Description-Content-Type: text/markdown
21
+ License-File: LICENSE
22
+ Dynamic: license-file
23
+
24
+ # draftkingsapi (Python)
25
+
26
+ Python client for [draftkingsapi.com](https://draftkingsapi.com): DraftKings Pick6 lines and line history,
27
+ DFS salaries, contests and slates, sportsbook odds and price history, and box scores. Standard library only,
28
+ no dependencies, typed.
29
+
30
+ draftkingsapi.com is an independent API. It is not affiliated with or endorsed by DraftKings. "DraftKings" is a
31
+ trademark of its owner and is used here only to describe the data.
32
+
33
+ ```bash
34
+ pip install draftkingsapi
35
+ ```
36
+
37
+ ## No key needed
38
+
39
+ The keyless feeds are delayed and capped, cost no credits, and need no account:
40
+
41
+ ```python
42
+ from draftkingsapi import Client
43
+
44
+ dk = Client() # reads DRAFTKINGS_API_KEY if set; keyless calls never send it
45
+
46
+ board = dk.public_board(league="nba", limit=3)
47
+ for prop in board["props"]:
48
+ print(prop["player"], prop["stat"], prop["line"], prop["more_multiplier"])
49
+
50
+ games = dk.public_sportsbook(league="nfl", limit=2)
51
+ for game in games["games"]:
52
+ print(game["away"], "at", game["home"], [(m["market"], m["side"], m["line"], m["american"]) for m in game["markets"]])
53
+
54
+ moves = dk.public_line_moves(market="pick6", limit=5)
55
+ costs = dk.credit_costs() # credits per endpoint
56
+ status = dk.status() # measured collection status
57
+ sample = dk.demo("odds", limit=1) # frozen sample rows, for the response shape only
58
+ ```
59
+
60
+ Output of the first loop, run on 15 Nov 2026 (live data, so yours will differ; a prop with no current line prints None):
61
+
62
+ ```text
63
+ Giannis Antetokounmpo Points 32.5 0.8
64
+ Cade Cunningham Points 30.5 1
65
+ Bam Adebayo Points None None
66
+ ```
67
+
68
+ ## With a key
69
+
70
+ Get a free key (100 credits a month, no card) at https://draftkingsapi.com/sign-up, then:
71
+
72
+ ```bash
73
+ export DRAFTKINGS_API_KEY=dk_...
74
+ ```
75
+
76
+ ```python
77
+ from draftkingsapi import Client
78
+
79
+ dk = Client(user_request="Build a prop screen from current Pick6 lines")
80
+
81
+ lines = dk.pick6_projections(sport="NBA", limit=100)
82
+ history = dk.pick6_line_history(lines["data"][0]["pickable_id"], change_type="line_move")
83
+ odds = dk.odds(sport="NFL", market="moneyline")
84
+ salaries = dk.dfs_players(sport="NFL", format="csv") # CSV text
85
+ box = dk.stats_player_games("NBA", 2026, limit=50)
86
+ edges = dk.ev(league="nba", min_edge=2)
87
+ ```
88
+
89
+ `user_request` is optional: one plain sentence about the task, sent as `X-User-Request`. Never put personal data in it.
90
+
91
+ ### Pagination
92
+
93
+ List routes return `pagination.has_more` with a cursor. `paginate()` follows it with a page budget, so a loop cannot
94
+ spend your whole allowance:
95
+
96
+ ```python
97
+ for row in dk.paginate(dk.odds, sport="NBA", max_pages=5):
98
+ ...
99
+ ```
100
+
101
+ It follows `next_cursor` (sent back as `cursor`), `next` on the stats routes and `next_after` on the change feeds
102
+ (both sent back as `after`).
103
+
104
+ ### Errors
105
+
106
+ Every non-2xx response raises a subclass of `DraftKingsAPIError` with the API's own message:
107
+
108
+ | Status | Exception | What to do |
109
+ | --- | --- | --- |
110
+ | 400 | `InvalidRequestError` | `e.error["query_help"]` often has a corrected example |
111
+ | 401 | `AuthenticationError` | missing or wrong key; raised before any request when a keyed method has no key |
112
+ | 402 | `PaymentRequiredError` | `e.error["upgrade_url"]` |
113
+ | 404 | `NotFoundError` | `e.error["closest_endpoints"]` |
114
+ | 429 | `RateLimitError` | wait `e.retry_after` seconds; if `e.error["limit"] == "month"`, stop retrying |
115
+ | 503 | `DatasetUnavailableError` | dataset temporarily unavailable; not charged, retry later |
116
+
117
+ ```python
118
+ from draftkingsapi import Client, RateLimitError
119
+
120
+ try:
121
+ Client().odds(sport="NFL")
122
+ except RateLimitError as e:
123
+ print("retry in", e.retry_after, "seconds:", e.message)
124
+ ```
125
+
126
+ ## Methods
127
+
128
+ Keyless: `public_board`, `public_sportsbook`, `public_line_moves`, `demo(dataset)`, `credit_costs`, `directory`,
129
+ `status`, `history_coverage`.
130
+
131
+ Keyed: `coverage`, `events`, `odds`, `history`, `pick6_projections`, `pick6_history`, `pick6_line_history`,
132
+ `projections`, `changes`, `dfs_sports`, `dfs_contests`, `dfs_slates`, `dfs_players`, `dfs_changes`, `compare`,
133
+ `compare_books`, `ev`, `arbitrage`, `stats_games`, `stats_player_games`, `stats_team_games`, `context_games`,
134
+ `context_player_stats`, `context_team_stats`, `sportsbook_scopes`, `sportsbook_snapshot`, `stream_snapshot`.
135
+
136
+ Optional filters are keyword arguments, passed through unchanged. Every route's parameters are in the
137
+ [OpenAPI document](https://draftkingsapi.com/openapi.json) and the [docs](https://draftkingsapi.com/docs).
138
+ Not wrapped: the server-sent event stream, the native sportsbook WebSocket, and account, billing and sign-up routes.
139
+
140
+ ## Plans
141
+
142
+ Free 100 credits a month, Developer $49, Production $149, Scale $499 a month. Credits per endpoint:
143
+ `Client().credit_costs()` or https://draftkingsapi.com/api/v1/credit-costs.
144
+
145
+ ## Tests
146
+
147
+ ```bash
148
+ python -m unittest discover -s tests # mocked, no network
149
+ DRAFTKINGS_LIVE=1 python -m unittest tests.test_live -v # live keyless endpoints, no key, no credits
150
+ ```
151
+
152
+ MIT License.
@@ -0,0 +1,129 @@
1
+ # draftkingsapi (Python)
2
+
3
+ Python client for [draftkingsapi.com](https://draftkingsapi.com): DraftKings Pick6 lines and line history,
4
+ DFS salaries, contests and slates, sportsbook odds and price history, and box scores. Standard library only,
5
+ no dependencies, typed.
6
+
7
+ draftkingsapi.com is an independent API. It is not affiliated with or endorsed by DraftKings. "DraftKings" is a
8
+ trademark of its owner and is used here only to describe the data.
9
+
10
+ ```bash
11
+ pip install draftkingsapi
12
+ ```
13
+
14
+ ## No key needed
15
+
16
+ The keyless feeds are delayed and capped, cost no credits, and need no account:
17
+
18
+ ```python
19
+ from draftkingsapi import Client
20
+
21
+ dk = Client() # reads DRAFTKINGS_API_KEY if set; keyless calls never send it
22
+
23
+ board = dk.public_board(league="nba", limit=3)
24
+ for prop in board["props"]:
25
+ print(prop["player"], prop["stat"], prop["line"], prop["more_multiplier"])
26
+
27
+ games = dk.public_sportsbook(league="nfl", limit=2)
28
+ for game in games["games"]:
29
+ print(game["away"], "at", game["home"], [(m["market"], m["side"], m["line"], m["american"]) for m in game["markets"]])
30
+
31
+ moves = dk.public_line_moves(market="pick6", limit=5)
32
+ costs = dk.credit_costs() # credits per endpoint
33
+ status = dk.status() # measured collection status
34
+ sample = dk.demo("odds", limit=1) # frozen sample rows, for the response shape only
35
+ ```
36
+
37
+ Output of the first loop, run on 15 Nov 2026 (live data, so yours will differ; a prop with no current line prints None):
38
+
39
+ ```text
40
+ Giannis Antetokounmpo Points 32.5 0.8
41
+ Cade Cunningham Points 30.5 1
42
+ Bam Adebayo Points None None
43
+ ```
44
+
45
+ ## With a key
46
+
47
+ Get a free key (100 credits a month, no card) at https://draftkingsapi.com/sign-up, then:
48
+
49
+ ```bash
50
+ export DRAFTKINGS_API_KEY=dk_...
51
+ ```
52
+
53
+ ```python
54
+ from draftkingsapi import Client
55
+
56
+ dk = Client(user_request="Build a prop screen from current Pick6 lines")
57
+
58
+ lines = dk.pick6_projections(sport="NBA", limit=100)
59
+ history = dk.pick6_line_history(lines["data"][0]["pickable_id"], change_type="line_move")
60
+ odds = dk.odds(sport="NFL", market="moneyline")
61
+ salaries = dk.dfs_players(sport="NFL", format="csv") # CSV text
62
+ box = dk.stats_player_games("NBA", 2026, limit=50)
63
+ edges = dk.ev(league="nba", min_edge=2)
64
+ ```
65
+
66
+ `user_request` is optional: one plain sentence about the task, sent as `X-User-Request`. Never put personal data in it.
67
+
68
+ ### Pagination
69
+
70
+ List routes return `pagination.has_more` with a cursor. `paginate()` follows it with a page budget, so a loop cannot
71
+ spend your whole allowance:
72
+
73
+ ```python
74
+ for row in dk.paginate(dk.odds, sport="NBA", max_pages=5):
75
+ ...
76
+ ```
77
+
78
+ It follows `next_cursor` (sent back as `cursor`), `next` on the stats routes and `next_after` on the change feeds
79
+ (both sent back as `after`).
80
+
81
+ ### Errors
82
+
83
+ Every non-2xx response raises a subclass of `DraftKingsAPIError` with the API's own message:
84
+
85
+ | Status | Exception | What to do |
86
+ | --- | --- | --- |
87
+ | 400 | `InvalidRequestError` | `e.error["query_help"]` often has a corrected example |
88
+ | 401 | `AuthenticationError` | missing or wrong key; raised before any request when a keyed method has no key |
89
+ | 402 | `PaymentRequiredError` | `e.error["upgrade_url"]` |
90
+ | 404 | `NotFoundError` | `e.error["closest_endpoints"]` |
91
+ | 429 | `RateLimitError` | wait `e.retry_after` seconds; if `e.error["limit"] == "month"`, stop retrying |
92
+ | 503 | `DatasetUnavailableError` | dataset temporarily unavailable; not charged, retry later |
93
+
94
+ ```python
95
+ from draftkingsapi import Client, RateLimitError
96
+
97
+ try:
98
+ Client().odds(sport="NFL")
99
+ except RateLimitError as e:
100
+ print("retry in", e.retry_after, "seconds:", e.message)
101
+ ```
102
+
103
+ ## Methods
104
+
105
+ Keyless: `public_board`, `public_sportsbook`, `public_line_moves`, `demo(dataset)`, `credit_costs`, `directory`,
106
+ `status`, `history_coverage`.
107
+
108
+ Keyed: `coverage`, `events`, `odds`, `history`, `pick6_projections`, `pick6_history`, `pick6_line_history`,
109
+ `projections`, `changes`, `dfs_sports`, `dfs_contests`, `dfs_slates`, `dfs_players`, `dfs_changes`, `compare`,
110
+ `compare_books`, `ev`, `arbitrage`, `stats_games`, `stats_player_games`, `stats_team_games`, `context_games`,
111
+ `context_player_stats`, `context_team_stats`, `sportsbook_scopes`, `sportsbook_snapshot`, `stream_snapshot`.
112
+
113
+ Optional filters are keyword arguments, passed through unchanged. Every route's parameters are in the
114
+ [OpenAPI document](https://draftkingsapi.com/openapi.json) and the [docs](https://draftkingsapi.com/docs).
115
+ Not wrapped: the server-sent event stream, the native sportsbook WebSocket, and account, billing and sign-up routes.
116
+
117
+ ## Plans
118
+
119
+ Free 100 credits a month, Developer $49, Production $149, Scale $499 a month. Credits per endpoint:
120
+ `Client().credit_costs()` or https://draftkingsapi.com/api/v1/credit-costs.
121
+
122
+ ## Tests
123
+
124
+ ```bash
125
+ python -m unittest discover -s tests # mocked, no network
126
+ DRAFTKINGS_LIVE=1 python -m unittest tests.test_live -v # live keyless endpoints, no key, no credits
127
+ ```
128
+
129
+ MIT License.
@@ -0,0 +1,35 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "draftkingsapi"
7
+ version = "0.1.0"
8
+ description = "Python client for draftkingsapi.com: DraftKings Pick6 lines and line history, DFS salaries, sportsbook odds and box scores. Independent, not affiliated with DraftKings."
9
+ readme = "README.md"
10
+ license = { text = "MIT" }
11
+ requires-python = ">=3.8"
12
+ dependencies = []
13
+ keywords = ["draftkings", "pick6", "dfs", "sportsbook", "odds", "sports-data", "api-client"]
14
+ classifiers = [
15
+ "Development Status :: 4 - Beta",
16
+ "Intended Audience :: Developers",
17
+ "License :: OSI Approved :: MIT License",
18
+ "Programming Language :: Python :: 3",
19
+ "Typing :: Typed",
20
+ "Topic :: Software Development :: Libraries",
21
+ ]
22
+
23
+ [project.urls]
24
+ Repository = "https://github.com/draftkingsapi/draftkingsapi-python"
25
+ Issues = "https://github.com/draftkingsapi/draftkingsapi-python/issues"
26
+ Homepage = "https://draftkingsapi.com"
27
+ Documentation = "https://draftkingsapi.com/docs"
28
+ OpenAPI = "https://draftkingsapi.com/openapi.json"
29
+ Status = "https://draftkingsapi.com/status"
30
+
31
+ [tool.setuptools.packages.find]
32
+ where = ["src"]
33
+
34
+ [tool.setuptools.package-data]
35
+ draftkingsapi = ["py.typed"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,31 @@
1
+ """Python client for draftkingsapi.com, an independent DraftKings data API (not affiliated with DraftKings)."""
2
+
3
+ from ._version import __version__
4
+ from .client import DEFAULT_BASE_URL, DEMO_DATASETS, DraftKingsAPI, Response
5
+ from .errors import (
6
+ AuthenticationError,
7
+ DatasetUnavailableError,
8
+ DraftKingsAPIError,
9
+ InvalidRequestError,
10
+ NotFoundError,
11
+ PaymentRequiredError,
12
+ RateLimitError,
13
+ )
14
+
15
+ Client = DraftKingsAPI
16
+
17
+ __all__ = [
18
+ "__version__",
19
+ "Client",
20
+ "DraftKingsAPI",
21
+ "DEFAULT_BASE_URL",
22
+ "DEMO_DATASETS",
23
+ "Response",
24
+ "DraftKingsAPIError",
25
+ "InvalidRequestError",
26
+ "AuthenticationError",
27
+ "PaymentRequiredError",
28
+ "NotFoundError",
29
+ "RateLimitError",
30
+ "DatasetUnavailableError",
31
+ ]
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
@@ -0,0 +1,334 @@
1
+ """Typed client for the draftkingsapi.com REST API. Standard library only.
2
+
3
+ draftkingsapi.com is an independent API, not affiliated with or endorsed by DraftKings.
4
+ """
5
+
6
+ import json
7
+ import os
8
+ import urllib.error
9
+ import urllib.parse
10
+ import urllib.request
11
+ from typing import Any, Callable, Dict, Iterator, Mapping, Optional, Union
12
+
13
+ from ._version import __version__
14
+ from .errors import SIGN_UP_URL, AuthenticationError, DraftKingsAPIError, error_for
15
+
16
+ DEFAULT_BASE_URL = "https://draftkingsapi.com"
17
+
18
+ JSON = Dict[str, Any]
19
+ Params = Mapping[str, Any]
20
+
21
+ DEMO_DATASETS = (
22
+ "pick6",
23
+ "dfs",
24
+ "events",
25
+ "odds",
26
+ "history",
27
+ "pick6-history",
28
+ "coverage",
29
+ "stats-games",
30
+ "stats-player-games",
31
+ "stats-team-games",
32
+ )
33
+
34
+ Transport = Callable[[urllib.request.Request, float], "Response"]
35
+
36
+
37
+ class Response:
38
+ """What a transport returns: status, headers (lower-case keys) and raw body bytes."""
39
+
40
+ def __init__(self, status: int, headers: Mapping[str, str], body: bytes) -> None:
41
+ self.status = status
42
+ self.headers = {k.lower(): v for k, v in headers.items()}
43
+ self.body = body
44
+
45
+
46
+ def _urllib_transport(request: urllib.request.Request, timeout: float) -> Response:
47
+ try:
48
+ with urllib.request.urlopen(request, timeout=timeout) as res:
49
+ return Response(res.status, dict(res.headers.items()), res.read())
50
+ except urllib.error.HTTPError as e:
51
+ return Response(e.code, dict(e.headers.items()) if e.headers else {}, e.read() or b"")
52
+
53
+
54
+ def _clean(params: Optional[Params]) -> Dict[str, str]:
55
+ out: Dict[str, str] = {}
56
+ for key, value in (params or {}).items():
57
+ if value is None:
58
+ continue
59
+ if isinstance(value, bool):
60
+ value = "true" if value else "false"
61
+ out[key] = str(value)
62
+ return out
63
+
64
+
65
+ class DraftKingsAPI:
66
+ """Client for https://draftkingsapi.com/api/v1.
67
+
68
+ Args:
69
+ api_key: your key (``dk_...``). Defaults to the ``DRAFTKINGS_API_KEY`` environment variable.
70
+ Keyless methods (``public_*``, ``demo``, ``credit_costs``, ``status``) work without one.
71
+ base_url: override for testing.
72
+ timeout: seconds per request.
73
+ user_request: optional one-sentence task sent as ``X-User-Request`` on every call, which
74
+ the API uses to improve its answers. Never put anything personal in it.
75
+ transport: injectable HTTP function, used by the tests.
76
+ """
77
+
78
+ def __init__(
79
+ self,
80
+ api_key: Optional[str] = None,
81
+ *,
82
+ base_url: Optional[str] = None,
83
+ timeout: float = 30.0,
84
+ user_request: Optional[str] = None,
85
+ transport: Optional[Transport] = None,
86
+ ) -> None:
87
+ self.api_key = api_key if api_key is not None else os.environ.get("DRAFTKINGS_API_KEY") or None
88
+ self.base_url = (base_url or os.environ.get("DRAFTKINGS_API_BASE") or DEFAULT_BASE_URL).rstrip("/")
89
+ self.timeout = timeout
90
+ self.user_request = user_request
91
+ self._transport = transport or _urllib_transport
92
+ self.last_headers: Dict[str, str] = {}
93
+
94
+ # ------------------------------------------------------------------ core
95
+
96
+ def request(self, path: str, params: Optional[Params] = None, *, keyed: bool = True) -> Union[JSON, str]:
97
+ """GET ``path`` (``/api/v1/...``) and return decoded JSON, or text for ``format=csv``.
98
+
99
+ Raises a subclass of :class:`DraftKingsAPIError` on any non-2xx response.
100
+ """
101
+ if keyed and not self.api_key:
102
+ raise AuthenticationError(
103
+ f"This endpoint needs an API key. Set DRAFTKINGS_API_KEY or pass api_key=. "
104
+ f"Free key, no card: {SIGN_UP_URL}",
105
+ status=401,
106
+ error_type="missing_api_key",
107
+ )
108
+ query = urllib.parse.urlencode(_clean(params))
109
+ url = f"{self.base_url}{path}" + (f"?{query}" if query else "")
110
+ headers = {"Accept": "application/json", "User-Agent": f"draftkingsapi-python/{__version__}"}
111
+ if keyed and self.api_key:
112
+ headers["Authorization"] = f"Bearer {self.api_key}"
113
+ if self.user_request:
114
+ headers["X-User-Request"] = urllib.parse.quote(self.user_request[:500], safe=" ,.:;!?'()-")
115
+ req = urllib.request.Request(url, headers=headers, method="GET")
116
+ try:
117
+ res = self._transport(req, self.timeout)
118
+ except (urllib.error.URLError, OSError) as e:
119
+ raise DraftKingsAPIError(f"Could not reach {self.base_url}: {e}") from e
120
+ self.last_headers = dict(res.headers)
121
+ text = res.body.decode("utf-8", errors="replace")
122
+ is_json = "json" in res.headers.get("content-type", "") or text[:1] in "{["
123
+ body: Any = text
124
+ if is_json and text:
125
+ try:
126
+ body = json.loads(text)
127
+ except ValueError:
128
+ body = text
129
+ if res.status >= 400:
130
+ retry = res.headers.get("retry-after")
131
+ raise error_for(res.status, body, int(retry) if retry and retry.isdigit() else None)
132
+ return body
133
+
134
+ def _get(self, path: str, params: Optional[Params] = None, keyed: bool = True) -> Any:
135
+ return self.request(path, params, keyed=keyed)
136
+
137
+ def paginate(
138
+ self,
139
+ method: Callable[..., Any],
140
+ *,
141
+ max_pages: int = 10,
142
+ **params: Any,
143
+ ) -> Iterator[JSON]:
144
+ """Yield rows across pages of any list method, with a page budget so a loop cannot drain your credits.
145
+
146
+ Follows ``pagination.next_cursor`` (sent back as ``cursor``), ``pagination.next`` (stats routes,
147
+ sent back as ``after``) or ``pagination.next_after`` (change feeds, sent back as ``after``).
148
+
149
+ for row in client.paginate(client.pick6_projections, sport="NBA", max_pages=3):
150
+ ...
151
+ """
152
+ params = dict(params)
153
+ params.pop("offset", None) # cursor and offset cannot be combined
154
+ for _ in range(max(1, max_pages)):
155
+ page = method(**params)
156
+ if not isinstance(page, dict):
157
+ raise TypeError("paginate() needs JSON pages; do not pass format='csv'")
158
+ rows = page.get("data")
159
+ if isinstance(rows, list):
160
+ yield from rows
161
+ elif rows is not None:
162
+ yield rows
163
+ pagination = page.get("pagination") or {}
164
+ if not pagination.get("has_more") or not rows:
165
+ return
166
+ if pagination.get("next_cursor"):
167
+ params["cursor"] = pagination["next_cursor"]
168
+ elif pagination.get("next"):
169
+ params["after"] = pagination["next"]
170
+ elif pagination.get("next_after") not in (None, ""):
171
+ params["after"] = pagination["next_after"]
172
+ else:
173
+ return
174
+
175
+ # -------------------------------------------------------- keyless routes
176
+
177
+ def public_board(self, league: Optional[str] = None, limit: Optional[int] = None) -> JSON:
178
+ """Delayed, capped Pick6 board plus recent line moves. No key, no credits.
179
+
180
+ league: nfl, nba, nhl, cfb, cbb, soccer, mlb, ufc, cs or lol.
181
+ """
182
+ return self._get("/api/v1/public/board", {"league": league, "limit": limit}, keyed=False)
183
+
184
+ def public_sportsbook(self, league: Optional[str] = None, limit: Optional[int] = None) -> JSON:
185
+ """Sportsbook mainlines (moneyline, spread, total). No key, no credits.
186
+
187
+ league: nfl, nba, nhl, cfb, cbb, mlb, epl or mls.
188
+ """
189
+ return self._get("/api/v1/public/sportsbook", {"league": league, "limit": limit}, keyed=False)
190
+
191
+ def public_line_moves(
192
+ self, league: Optional[str] = None, limit: Optional[int] = None, market: Optional[str] = None
193
+ ) -> JSON:
194
+ """Recent Pick6 and sportsbook line moves. market: all, pick6 or sportsbook. No key."""
195
+ return self._get(
196
+ "/api/v1/public/line-moves", {"league": league, "limit": limit, "market": market}, keyed=False
197
+ )
198
+
199
+ def demo(self, dataset: str = "pick6", limit: Optional[int] = None, sport: Optional[str] = None) -> JSON:
200
+ """Frozen sample rows to learn a response shape. No key. Never use them as data.
201
+
202
+ dataset: one of :data:`DEMO_DATASETS`.
203
+ """
204
+ if dataset not in DEMO_DATASETS:
205
+ raise ValueError(f"dataset must be one of {', '.join(DEMO_DATASETS)}")
206
+ return self._get(f"/api/v1/demo/{dataset}", {"limit": limit, "sport": sport}, keyed=False)
207
+
208
+ def credit_costs(self, path: Optional[str] = None, rows: Optional[int] = None) -> JSON:
209
+ """Credits each endpoint costs. No key, no credits."""
210
+ return self._get("/api/v1/credit-costs", {"path": path, "rows": rows}, keyed=False)
211
+
212
+ def directory(self) -> JSON:
213
+ """Every route, the OpenAPI URL and docs. No key."""
214
+ return self._get("/api/v1", keyed=False)
215
+
216
+ def status(self) -> JSON:
217
+ """Measured collection status (jobs, workers, dataset freshness). No key."""
218
+ return self._get("/status.json", keyed=False)
219
+
220
+ def history_coverage(self) -> JSON:
221
+ """Start dates and capture counts of stored history by league. No key."""
222
+ return self._get("/history-coverage.json", keyed=False)
223
+
224
+ # ---------------------------------------------------------- keyed routes
225
+ # Optional filters are passed through as keyword arguments; see https://draftkingsapi.com/docs
226
+ # or https://draftkingsapi.com/openapi.json for each route's parameters.
227
+
228
+ def coverage(self) -> JSON:
229
+ """Live coverage of every dataset for your key."""
230
+ return self._get("/api/v1/coverage")
231
+
232
+ def events(self, **params: Any) -> Any:
233
+ """Sportsbook events. Filters: sport, event_id, status, search, start_after, start_before, limit, cursor, format."""
234
+ return self._get("/api/v1/events", params)
235
+
236
+ def odds(self, **params: Any) -> Any:
237
+ """Current sportsbook odds. Filters: sport, event_id, market, status, view, fresh_only, limit, cursor, format."""
238
+ return self._get("/api/v1/odds", params)
239
+
240
+ def history(self, **params: Any) -> Any:
241
+ """Stored sportsbook price history. Filters: sport, event_id, offer_id, market, limit, cursor, format."""
242
+ return self._get("/api/v1/history", params)
243
+
244
+ def pick6_projections(self, **params: Any) -> Any:
245
+ """Current Pick6 lines. Filters: sport, stat, team, search, status, pickable_id, view, limit, cursor, format."""
246
+ return self._get("/api/v1/pick6/projections", params)
247
+
248
+ def pick6_history(self, **params: Any) -> Any:
249
+ """Pick6 change feed. Filters: sport, stat, pickable_id, after, limit, cursor, format."""
250
+ return self._get("/api/v1/pick6/history", params)
251
+
252
+ def pick6_line_history(self, pickable_id: str, **params: Any) -> JSON:
253
+ """Recorded moves for one Pick6 prop. Filters: stat, pick_group_id, change_type, limit, cursor."""
254
+ return self._get("/api/v1/pick6/line-history", {"pickable_id": pickable_id, **params})
255
+
256
+ def projections(self, **params: Any) -> Any:
257
+ """Alias route of :meth:`pick6_projections`."""
258
+ return self._get("/api/v1/projections", params)
259
+
260
+ def changes(self, **params: Any) -> Any:
261
+ """Every Pick6 line change after your cursor. Filters: after, sport, stat, limit, cursor."""
262
+ return self._get("/api/v1/changes", params)
263
+
264
+ def dfs_sports(self, **params: Any) -> Any:
265
+ """DFS sports in the lobby."""
266
+ return self._get("/api/v1/dfs/sports", params)
267
+
268
+ def dfs_contests(self, **params: Any) -> Any:
269
+ """DFS contests. Filters: sport, slate_id, limit, cursor, format."""
270
+ return self._get("/api/v1/dfs/contests", params)
271
+
272
+ def dfs_slates(self, **params: Any) -> Any:
273
+ """DFS slates. Filters: sport, slate_id, limit, cursor, format."""
274
+ return self._get("/api/v1/dfs/slates", params)
275
+
276
+ def dfs_players(self, **params: Any) -> Any:
277
+ """DFS players with salaries. Filters: sport, slate_id, team, search, view, limit, cursor, format."""
278
+ return self._get("/api/v1/dfs/players", params)
279
+
280
+ def dfs_changes(self, **params: Any) -> JSON:
281
+ """DFS lobby changes. Filters: after, sport, kind, change, slate_id, limit."""
282
+ return self._get("/api/v1/dfs/changes", params)
283
+
284
+ def compare(self, **params: Any) -> JSON:
285
+ """Pick6 lines against DraftKings Sportsbook lines. Filters: sport, stat, player, min_gap, sort, limit, offset."""
286
+ return self._get("/api/v1/compare", params)
287
+
288
+ def compare_books(self, **params: Any) -> JSON:
289
+ """Lines across books. Filters: league, market_type, player, team, stat, with_fair, min_ev, sort, limit, offset."""
290
+ return self._get("/api/v1/compare/books", params)
291
+
292
+ def ev(self, **params: Any) -> JSON:
293
+ """Positive expected value prices. Filters: league, market_type, book, player, min_edge, min_prob, sort, limit."""
294
+ return self._get("/api/v1/ev", params)
295
+
296
+ def arbitrage(self, **params: Any) -> JSON:
297
+ """Arbitrage and middles. Filters: type, league, market_type, book, min_profit, limit, offset."""
298
+ return self._get("/api/v1/arbitrage", params)
299
+
300
+ def stats_games(self, sport: str, season: int, **params: Any) -> JSON:
301
+ """Game results. sport: NBA, NFL, NHL, NCAAF, NCAAB, MLB or WNBA. Filters: event_id, completed, from, to, limit, after."""
302
+ return self._get("/api/v1/stats/games", {"sport": sport, "season": season, **params})
303
+
304
+ def stats_player_games(self, sport: str, season: int, **params: Any) -> JSON:
305
+ """Player box scores. Filters: athlete_id, team_id, event_id, completed, from, to, limit, after."""
306
+ return self._get("/api/v1/stats/player-games", {"sport": sport, "season": season, **params})
307
+
308
+ def stats_team_games(self, sport: str, season: int, **params: Any) -> JSON:
309
+ """Team box scores. Filters: team_id, event_id, completed, from, to, limit, after."""
310
+ return self._get("/api/v1/stats/team-games", {"sport": sport, "season": season, **params})
311
+
312
+ def context_games(self, **params: Any) -> Any:
313
+ """Game context joined to lines. Filters: sport, event_id, athlete_id, limit, cursor, format."""
314
+ return self._get("/api/v1/context/games", params)
315
+
316
+ def context_player_stats(self, **params: Any) -> Any:
317
+ """Player stat context. Filters: sport, event_id, athlete_id, limit, cursor, format."""
318
+ return self._get("/api/v1/context/player-stats", params)
319
+
320
+ def context_team_stats(self, **params: Any) -> Any:
321
+ """Team stat context. Filters: sport, event_id, limit, cursor, format."""
322
+ return self._get("/api/v1/context/team-stats", params)
323
+
324
+ def sportsbook_scopes(self) -> JSON:
325
+ """Native sportsbook scopes you can snapshot."""
326
+ return self._get("/api/v1/sportsbook/scopes")
327
+
328
+ def sportsbook_snapshot(self, scope: str) -> JSON:
329
+ """Snapshot of one native sportsbook scope (from :meth:`sportsbook_scopes`)."""
330
+ return self._get("/api/v1/sportsbook/snapshot", {"scope": scope})
331
+
332
+ def stream_snapshot(self, sport: Optional[str] = None) -> JSON:
333
+ """Current state that the server-sent event stream continues from."""
334
+ return self._get("/api/v1/stream/snapshot", {"sport": sport})
@@ -0,0 +1,88 @@
1
+ """Exceptions raised by the draftkingsapi client.
2
+
3
+ Every API error carries the HTTP status and the API's own error JSON, which looks like
4
+ ``{"error": {"code": 401, "type": "missing_api_key", "message": "..."}}``.
5
+ """
6
+
7
+ from typing import Any, Dict, Optional
8
+
9
+ SIGN_UP_URL = "https://draftkingsapi.com/sign-up"
10
+
11
+
12
+ class DraftKingsAPIError(Exception):
13
+ """Any non-2xx response, or a transport failure (status 0)."""
14
+
15
+ def __init__(
16
+ self,
17
+ message: str,
18
+ status: int = 0,
19
+ error_type: Optional[str] = None,
20
+ body: Optional[Dict[str, Any]] = None,
21
+ retry_after: Optional[int] = None,
22
+ ) -> None:
23
+ super().__init__(message)
24
+ self.message = message
25
+ self.status = status
26
+ self.error_type = error_type
27
+ self.body = body or {}
28
+ self.retry_after = retry_after
29
+
30
+ @property
31
+ def error(self) -> Dict[str, Any]:
32
+ """The ``error`` object from the response body, or an empty dict."""
33
+ value = self.body.get("error") if isinstance(self.body, dict) else None
34
+ return value if isinstance(value, dict) else {}
35
+
36
+ def __str__(self) -> str:
37
+ prefix = f"HTTP {self.status}" if self.status else "Request failed"
38
+ kind = f" {self.error_type}" if self.error_type else ""
39
+ return f"{prefix}{kind}: {self.message}"
40
+
41
+
42
+ class InvalidRequestError(DraftKingsAPIError):
43
+ """400: a query parameter was rejected. ``error['query_help']`` often has a corrected example."""
44
+
45
+
46
+ class AuthenticationError(DraftKingsAPIError):
47
+ """401: the API key is missing or invalid. Free keys: https://draftkingsapi.com/sign-up"""
48
+
49
+
50
+ class PaymentRequiredError(DraftKingsAPIError):
51
+ """402: the request needs a paid plan. ``error['upgrade_url']`` opens checkout."""
52
+
53
+
54
+ class NotFoundError(DraftKingsAPIError):
55
+ """404: unknown route or record. ``error['closest_endpoints']`` lists real routes."""
56
+
57
+
58
+ class RateLimitError(DraftKingsAPIError):
59
+ """429: a minute, day or month limit was reached. Not charged.
60
+
61
+ ``retry_after`` is the Retry-After header in seconds. On a monthly limit, stop retrying:
62
+ ``error['limit'] == 'month'``.
63
+ """
64
+
65
+
66
+ class DatasetUnavailableError(DraftKingsAPIError):
67
+ """503: the dataset is temporarily unavailable. Not charged; retry after ``retry_after``."""
68
+
69
+
70
+ _BY_STATUS = {
71
+ 400: InvalidRequestError,
72
+ 401: AuthenticationError,
73
+ 402: PaymentRequiredError,
74
+ 404: NotFoundError,
75
+ 429: RateLimitError,
76
+ 503: DatasetUnavailableError,
77
+ }
78
+
79
+
80
+ def error_for(status: int, body: Any, retry_after: Optional[int]) -> DraftKingsAPIError:
81
+ """Build the matching exception from a status code and decoded body (dict or text)."""
82
+ data: Dict[str, Any] = body if isinstance(body, dict) else {}
83
+ err = data.get("error") if isinstance(data.get("error"), dict) else {}
84
+ message = err.get("message") or (body if isinstance(body, str) and body else f"HTTP {status}")
85
+ if retry_after is None and isinstance(err.get("retry_after_seconds"), int):
86
+ retry_after = err["retry_after_seconds"]
87
+ cls = _BY_STATUS.get(status, DraftKingsAPIError)
88
+ return cls(str(message)[:2000], status=status, error_type=err.get("type"), body=data, retry_after=retry_after)
File without changes
@@ -0,0 +1,152 @@
1
+ Metadata-Version: 2.4
2
+ Name: draftkingsapi
3
+ Version: 0.1.0
4
+ Summary: Python client for draftkingsapi.com: DraftKings Pick6 lines and line history, DFS salaries, sportsbook odds and box scores. Independent, not affiliated with DraftKings.
5
+ License: MIT
6
+ Project-URL: Repository, https://github.com/draftkingsapi/draftkingsapi-python
7
+ Project-URL: Issues, https://github.com/draftkingsapi/draftkingsapi-python/issues
8
+ Project-URL: Homepage, https://draftkingsapi.com
9
+ Project-URL: Documentation, https://draftkingsapi.com/docs
10
+ Project-URL: OpenAPI, https://draftkingsapi.com/openapi.json
11
+ Project-URL: Status, https://draftkingsapi.com/status
12
+ Keywords: draftkings,pick6,dfs,sportsbook,odds,sports-data,api-client
13
+ Classifier: Development Status :: 4 - Beta
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: License :: OSI Approved :: MIT License
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Typing :: Typed
18
+ Classifier: Topic :: Software Development :: Libraries
19
+ Requires-Python: >=3.8
20
+ Description-Content-Type: text/markdown
21
+ License-File: LICENSE
22
+ Dynamic: license-file
23
+
24
+ # draftkingsapi (Python)
25
+
26
+ Python client for [draftkingsapi.com](https://draftkingsapi.com): DraftKings Pick6 lines and line history,
27
+ DFS salaries, contests and slates, sportsbook odds and price history, and box scores. Standard library only,
28
+ no dependencies, typed.
29
+
30
+ draftkingsapi.com is an independent API. It is not affiliated with or endorsed by DraftKings. "DraftKings" is a
31
+ trademark of its owner and is used here only to describe the data.
32
+
33
+ ```bash
34
+ pip install draftkingsapi
35
+ ```
36
+
37
+ ## No key needed
38
+
39
+ The keyless feeds are delayed and capped, cost no credits, and need no account:
40
+
41
+ ```python
42
+ from draftkingsapi import Client
43
+
44
+ dk = Client() # reads DRAFTKINGS_API_KEY if set; keyless calls never send it
45
+
46
+ board = dk.public_board(league="nba", limit=3)
47
+ for prop in board["props"]:
48
+ print(prop["player"], prop["stat"], prop["line"], prop["more_multiplier"])
49
+
50
+ games = dk.public_sportsbook(league="nfl", limit=2)
51
+ for game in games["games"]:
52
+ print(game["away"], "at", game["home"], [(m["market"], m["side"], m["line"], m["american"]) for m in game["markets"]])
53
+
54
+ moves = dk.public_line_moves(market="pick6", limit=5)
55
+ costs = dk.credit_costs() # credits per endpoint
56
+ status = dk.status() # measured collection status
57
+ sample = dk.demo("odds", limit=1) # frozen sample rows, for the response shape only
58
+ ```
59
+
60
+ Output of the first loop, run on 15 Nov 2026 (live data, so yours will differ; a prop with no current line prints None):
61
+
62
+ ```text
63
+ Giannis Antetokounmpo Points 32.5 0.8
64
+ Cade Cunningham Points 30.5 1
65
+ Bam Adebayo Points None None
66
+ ```
67
+
68
+ ## With a key
69
+
70
+ Get a free key (100 credits a month, no card) at https://draftkingsapi.com/sign-up, then:
71
+
72
+ ```bash
73
+ export DRAFTKINGS_API_KEY=dk_...
74
+ ```
75
+
76
+ ```python
77
+ from draftkingsapi import Client
78
+
79
+ dk = Client(user_request="Build a prop screen from current Pick6 lines")
80
+
81
+ lines = dk.pick6_projections(sport="NBA", limit=100)
82
+ history = dk.pick6_line_history(lines["data"][0]["pickable_id"], change_type="line_move")
83
+ odds = dk.odds(sport="NFL", market="moneyline")
84
+ salaries = dk.dfs_players(sport="NFL", format="csv") # CSV text
85
+ box = dk.stats_player_games("NBA", 2026, limit=50)
86
+ edges = dk.ev(league="nba", min_edge=2)
87
+ ```
88
+
89
+ `user_request` is optional: one plain sentence about the task, sent as `X-User-Request`. Never put personal data in it.
90
+
91
+ ### Pagination
92
+
93
+ List routes return `pagination.has_more` with a cursor. `paginate()` follows it with a page budget, so a loop cannot
94
+ spend your whole allowance:
95
+
96
+ ```python
97
+ for row in dk.paginate(dk.odds, sport="NBA", max_pages=5):
98
+ ...
99
+ ```
100
+
101
+ It follows `next_cursor` (sent back as `cursor`), `next` on the stats routes and `next_after` on the change feeds
102
+ (both sent back as `after`).
103
+
104
+ ### Errors
105
+
106
+ Every non-2xx response raises a subclass of `DraftKingsAPIError` with the API's own message:
107
+
108
+ | Status | Exception | What to do |
109
+ | --- | --- | --- |
110
+ | 400 | `InvalidRequestError` | `e.error["query_help"]` often has a corrected example |
111
+ | 401 | `AuthenticationError` | missing or wrong key; raised before any request when a keyed method has no key |
112
+ | 402 | `PaymentRequiredError` | `e.error["upgrade_url"]` |
113
+ | 404 | `NotFoundError` | `e.error["closest_endpoints"]` |
114
+ | 429 | `RateLimitError` | wait `e.retry_after` seconds; if `e.error["limit"] == "month"`, stop retrying |
115
+ | 503 | `DatasetUnavailableError` | dataset temporarily unavailable; not charged, retry later |
116
+
117
+ ```python
118
+ from draftkingsapi import Client, RateLimitError
119
+
120
+ try:
121
+ Client().odds(sport="NFL")
122
+ except RateLimitError as e:
123
+ print("retry in", e.retry_after, "seconds:", e.message)
124
+ ```
125
+
126
+ ## Methods
127
+
128
+ Keyless: `public_board`, `public_sportsbook`, `public_line_moves`, `demo(dataset)`, `credit_costs`, `directory`,
129
+ `status`, `history_coverage`.
130
+
131
+ Keyed: `coverage`, `events`, `odds`, `history`, `pick6_projections`, `pick6_history`, `pick6_line_history`,
132
+ `projections`, `changes`, `dfs_sports`, `dfs_contests`, `dfs_slates`, `dfs_players`, `dfs_changes`, `compare`,
133
+ `compare_books`, `ev`, `arbitrage`, `stats_games`, `stats_player_games`, `stats_team_games`, `context_games`,
134
+ `context_player_stats`, `context_team_stats`, `sportsbook_scopes`, `sportsbook_snapshot`, `stream_snapshot`.
135
+
136
+ Optional filters are keyword arguments, passed through unchanged. Every route's parameters are in the
137
+ [OpenAPI document](https://draftkingsapi.com/openapi.json) and the [docs](https://draftkingsapi.com/docs).
138
+ Not wrapped: the server-sent event stream, the native sportsbook WebSocket, and account, billing and sign-up routes.
139
+
140
+ ## Plans
141
+
142
+ Free 100 credits a month, Developer $49, Production $149, Scale $499 a month. Credits per endpoint:
143
+ `Client().credit_costs()` or https://draftkingsapi.com/api/v1/credit-costs.
144
+
145
+ ## Tests
146
+
147
+ ```bash
148
+ python -m unittest discover -s tests # mocked, no network
149
+ DRAFTKINGS_LIVE=1 python -m unittest tests.test_live -v # live keyless endpoints, no key, no credits
150
+ ```
151
+
152
+ MIT License.
@@ -0,0 +1,14 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ src/draftkingsapi/__init__.py
5
+ src/draftkingsapi/_version.py
6
+ src/draftkingsapi/client.py
7
+ src/draftkingsapi/errors.py
8
+ src/draftkingsapi/py.typed
9
+ src/draftkingsapi.egg-info/PKG-INFO
10
+ src/draftkingsapi.egg-info/SOURCES.txt
11
+ src/draftkingsapi.egg-info/dependency_links.txt
12
+ src/draftkingsapi.egg-info/top_level.txt
13
+ tests/test_client.py
14
+ tests/test_live.py
@@ -0,0 +1 @@
1
+ draftkingsapi
@@ -0,0 +1,169 @@
1
+ """Mocked tests: no network. Run with `python -m unittest discover -s tests` from packages/python."""
2
+
3
+ import json
4
+ import os
5
+ import sys
6
+ import unittest
7
+ import urllib.parse
8
+
9
+ sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src"))
10
+
11
+ from draftkingsapi import ( # noqa: E402
12
+ AuthenticationError,
13
+ DatasetUnavailableError,
14
+ DraftKingsAPI,
15
+ InvalidRequestError,
16
+ RateLimitError,
17
+ Response,
18
+ )
19
+
20
+
21
+ class FakeTransport:
22
+ def __init__(self, responses):
23
+ self.responses = list(responses)
24
+ self.requests = []
25
+
26
+ def __call__(self, request, timeout):
27
+ self.requests.append(request)
28
+ status, body, headers = self.responses.pop(0)
29
+ raw = body if isinstance(body, bytes) else json.dumps(body).encode()
30
+ base = {"Content-Type": "application/json"} if not isinstance(body, bytes) else {"Content-Type": "text/csv"}
31
+ base.update(headers or {})
32
+ return Response(status, base, raw)
33
+
34
+ def query(self, i=-1):
35
+ url = urllib.parse.urlparse(self.requests[i].full_url)
36
+ return url.path, dict(urllib.parse.parse_qsl(url.query))
37
+
38
+
39
+ def client(responses, key="dk_test", **kw):
40
+ t = FakeTransport(responses)
41
+ return DraftKingsAPI(api_key=key, base_url="https://example.test", transport=t, **kw), t
42
+
43
+
44
+ class KeylessTests(unittest.TestCase):
45
+ def test_public_board_sends_no_key(self):
46
+ c, t = client([(200, {"feed": "pick6-board", "props": []}, None)], key="dk_secret")
47
+ out = c.public_board(league="nba", limit=2)
48
+ self.assertEqual(out["feed"], "pick6-board")
49
+ path, q = t.query()
50
+ self.assertEqual(path, "/api/v1/public/board")
51
+ self.assertEqual(q, {"league": "nba", "limit": "2"})
52
+ self.assertIsNone(t.requests[0].get_header("Authorization"))
53
+
54
+ def test_keyless_without_key(self):
55
+ c, t = client([(200, {"data": []}, None)], key=None)
56
+ os.environ.pop("DRAFTKINGS_API_KEY", None)
57
+ c.api_key = None
58
+ c.demo("odds", limit=1)
59
+ self.assertEqual(t.query()[0], "/api/v1/demo/odds")
60
+
61
+ def test_demo_rejects_unknown_dataset(self):
62
+ c, _ = client([])
63
+ with self.assertRaises(ValueError):
64
+ c.demo("nope")
65
+
66
+ def test_status_and_coverage_paths(self):
67
+ c, t = client([(200, {"status": "ok"}, None), (200, {"page": "x"}, None), (200, {"data": []}, None)])
68
+ c.status()
69
+ self.assertEqual(t.query()[0], "/status.json")
70
+ c.history_coverage()
71
+ self.assertEqual(t.query()[0], "/history-coverage.json")
72
+ c.credit_costs(path="/api/v1/odds", rows=500)
73
+ self.assertEqual(t.query(), ("/api/v1/credit-costs", {"path": "/api/v1/odds", "rows": "500"}))
74
+
75
+
76
+ class KeyedTests(unittest.TestCase):
77
+ def test_missing_key_raises_before_any_request(self):
78
+ c, t = client([], key=None)
79
+ c.api_key = None
80
+ with self.assertRaises(AuthenticationError) as ctx:
81
+ c.odds(sport="NFL")
82
+ self.assertIn("draftkingsapi.com/sign-up", str(ctx.exception))
83
+ self.assertEqual(t.requests, [])
84
+
85
+ def test_bearer_and_task_header(self):
86
+ c, t = client([(200, {"data": [], "pagination": {"has_more": False}}, None)], user_request="Build a prop screen")
87
+ c.pick6_projections(sport="NBA", fresh_only=True, view="compact")
88
+ req = t.requests[0]
89
+ self.assertEqual(req.get_header("Authorization"), "Bearer dk_test")
90
+ self.assertEqual(urllib.parse.unquote(req.get_header("X-user-request")), "Build a prop screen")
91
+ self.assertEqual(t.query()[1], {"sport": "NBA", "fresh_only": "true", "view": "compact"})
92
+
93
+ def test_required_args(self):
94
+ c, t = client([(200, {"data": []}, None), (200, {"data": []}, None), (200, {"data": {}}, None)])
95
+ c.pick6_line_history("123", change_type="line_move")
96
+ self.assertEqual(t.query(), ("/api/v1/pick6/line-history", {"pickable_id": "123", "change_type": "line_move"}))
97
+ c.stats_player_games("NBA", 2026, athlete_id="9")
98
+ self.assertEqual(t.query()[1], {"sport": "NBA", "season": "2026", "athlete_id": "9"})
99
+ c.sportsbook_snapshot("nba-props")
100
+ self.assertEqual(t.query(), ("/api/v1/sportsbook/snapshot", {"scope": "nba-props"}))
101
+
102
+ def test_csv_returns_text(self):
103
+ c, _ = client([(200, b"id,line\n1,20.5\n", None)])
104
+ out = c.dfs_players(sport="NFL", format="csv")
105
+ self.assertTrue(out.startswith("id,line"))
106
+
107
+
108
+ class ErrorTests(unittest.TestCase):
109
+ def test_401_message(self):
110
+ body = {"error": {"code": 401, "type": "invalid_api_key", "message": "Invalid API key."}}
111
+ c, _ = client([(401, body, None)])
112
+ with self.assertRaises(AuthenticationError) as ctx:
113
+ c.odds()
114
+ self.assertEqual(ctx.exception.status, 401)
115
+ self.assertEqual(ctx.exception.error_type, "invalid_api_key")
116
+ self.assertEqual(ctx.exception.message, "Invalid API key.")
117
+
118
+ def test_429_retry_after(self):
119
+ body = {"error": {"code": 429, "type": "minute_rate_limited", "message": "Slow down.", "limit": "minute"}}
120
+ c, _ = client([(429, body, {"Retry-After": "12"})])
121
+ with self.assertRaises(RateLimitError) as ctx:
122
+ c.ev()
123
+ self.assertEqual(ctx.exception.retry_after, 12)
124
+ self.assertEqual(ctx.exception.error["limit"], "minute")
125
+
126
+ def test_503(self):
127
+ body = {"error": {"code": 503, "type": "service_unavailable", "message": "Dataset unavailable."}}
128
+ c, _ = client([(503, body, None)])
129
+ with self.assertRaises(DatasetUnavailableError):
130
+ c.public_board()
131
+
132
+ def test_400_query_help(self):
133
+ body = {"error": {"code": 400, "type": "invalid_query", "message": "Invalid input", "query_help": {"x": 1}}}
134
+ c, _ = client([(400, body, None)])
135
+ with self.assertRaises(InvalidRequestError) as ctx:
136
+ c.odds(limit=99999)
137
+ self.assertEqual(ctx.exception.error["query_help"], {"x": 1})
138
+
139
+
140
+ class PaginationTests(unittest.TestCase):
141
+ def test_cursor_pages(self):
142
+ pages = [
143
+ (200, {"data": [1, 2], "pagination": {"has_more": True, "next_cursor": "c2"}}, None),
144
+ (200, {"data": [3], "pagination": {"has_more": False, "next_cursor": None}}, None),
145
+ ]
146
+ c, t = client(pages)
147
+ rows = list(c.paginate(c.odds, sport="NFL", offset=50))
148
+ self.assertEqual(rows, [1, 2, 3])
149
+ self.assertNotIn("offset", t.query(0)[1])
150
+ self.assertEqual(t.query(1)[1], {"sport": "NFL", "cursor": "c2"})
151
+
152
+ def test_stats_next(self):
153
+ pages = [
154
+ (200, {"data": ["a"], "pagination": {"has_more": True, "next": "k:1"}}, None),
155
+ (200, {"data": ["b"], "pagination": {"has_more": False, "next": None}}, None),
156
+ ]
157
+ c, t = client(pages)
158
+ self.assertEqual(list(c.paginate(c.stats_games, sport="NBA", season=2026)), ["a", "b"])
159
+ self.assertEqual(t.query(1)[1]["after"], "k:1")
160
+
161
+ def test_page_budget(self):
162
+ page = (200, {"data": [0], "pagination": {"has_more": True, "next_cursor": "x"}}, None)
163
+ c, t = client([page] * 5)
164
+ self.assertEqual(len(list(c.paginate(c.odds, max_pages=3))), 3)
165
+ self.assertEqual(len(t.requests), 3)
166
+
167
+
168
+ if __name__ == "__main__":
169
+ unittest.main()
@@ -0,0 +1,50 @@
1
+ """Opt-in live test against the keyless endpoints. Spends no credits and needs no key.
2
+
3
+ DRAFTKINGS_LIVE=1 python -m unittest tests.test_live -v
4
+ """
5
+
6
+ import os
7
+ import sys
8
+ import unittest
9
+
10
+ sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src"))
11
+
12
+ from draftkingsapi import AuthenticationError, DraftKingsAPI # noqa: E402
13
+
14
+ LIVE = os.environ.get("DRAFTKINGS_LIVE") == "1"
15
+
16
+
17
+ @unittest.skipUnless(LIVE, "set DRAFTKINGS_LIVE=1 to call the live keyless endpoints")
18
+ class LiveKeyless(unittest.TestCase):
19
+ @classmethod
20
+ def setUpClass(cls):
21
+ cls.c = DraftKingsAPI(api_key="", user_request="Run the SDK live keyless test")
22
+ cls.c.api_key = None
23
+
24
+ def test_public_board(self):
25
+ out = self.c.public_board(league="nba", limit=2)
26
+ self.assertEqual(out["feed"], "pick6-board")
27
+ self.assertTrue(out["keyless"])
28
+ self.assertIsInstance(out["props"], list)
29
+
30
+ def test_public_sportsbook(self):
31
+ out = self.c.public_sportsbook(league="nfl", limit=2)
32
+ self.assertEqual(out["feed"], "sportsbook-mainlines")
33
+ self.assertIsInstance(out["games"], list)
34
+
35
+ def test_line_moves(self):
36
+ out = self.c.public_line_moves(limit=2)
37
+ self.assertEqual(out["feed"], "line-moves")
38
+
39
+ def test_demo_and_costs(self):
40
+ self.assertTrue(self.c.demo("pick6")["demo"]["sample"])
41
+ self.assertIsInstance(self.c.credit_costs()["data"], list)
42
+ self.assertIn("status", self.c.status())
43
+
44
+ def test_keyed_without_key_is_clear(self):
45
+ with self.assertRaises(AuthenticationError):
46
+ self.c.odds()
47
+
48
+
49
+ if __name__ == "__main__":
50
+ unittest.main()