underdog-api 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,184 @@
1
+ """Python client for Underdog API (https://underdogapi.com).
2
+
3
+ Underdog API is an independent REST API for Underdog Fantasy pick'em lines: separate Higher and Lower payout
4
+ multipliers, every recorded line move and graded results. It is operated by Stunning Couches LLC and is not
5
+ affiliated with Underdog Fantasy.
6
+
7
+ from underdog_api import Client
8
+ ud = Client() # reads UNDERDOG_API_KEY
9
+ for line in ud.projections(league="NBA", view="compact", limit=5)["data"]:
10
+ print(line)
11
+
12
+ No dependencies: the standard library only.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ import os
18
+ import time
19
+ import urllib.error
20
+ import urllib.parse
21
+ import urllib.request
22
+ from typing import Any, Callable, Dict, Iterator, Optional
23
+
24
+ __all__ = ["Client", "UnderdogAPIError", "PlanLimitError", "__version__"]
25
+ __version__ = "0.1.0"
26
+
27
+ DEFAULT_BASE_URL = "https://underdogapi.com"
28
+ Transport = Callable[[str, str, Dict[str, str], float], "tuple[int, Dict[str, str], bytes]"]
29
+
30
+
31
+ class UnderdogAPIError(Exception):
32
+ """A non-2xx answer. `code`, `message` and the full `body` come from the API's error object."""
33
+
34
+ def __init__(self, status: int, body: Any, headers: Optional[Dict[str, str]] = None):
35
+ err = body.get("error", {}) if isinstance(body, dict) else {}
36
+ self.status = status
37
+ self.body = body
38
+ self.headers = headers or {}
39
+ self.code = err.get("code")
40
+ self.message = err.get("message") or (body if isinstance(body, str) else "") or f"HTTP {status}"
41
+ super().__init__(f"{status} {self.code or ''} {self.message}".strip())
42
+
43
+
44
+ class PlanLimitError(UnderdogAPIError):
45
+ """402 UPGRADE_REQUIRED: the plan does not include this. No credits were charged.
46
+
47
+ `upgrade_url` is a checkout link and `sample` holds a few labelled rows of what was asked for.
48
+ """
49
+
50
+ @property
51
+ def upgrade_url(self) -> Optional[str]:
52
+ return (self.body.get("error") or {}).get("upgrade_url") if isinstance(self.body, dict) else None
53
+
54
+ @property
55
+ def sample(self) -> list:
56
+ return (self.body.get("error") or {}).get("sample") or [] if isinstance(self.body, dict) else []
57
+
58
+
59
+ def _urllib_transport(method: str, url: str, headers: Dict[str, str], timeout: float):
60
+ req = urllib.request.Request(url, method=method, headers=headers)
61
+ try:
62
+ with urllib.request.urlopen(req, timeout=timeout) as res:
63
+ return res.status, dict(res.headers.items()), res.read()
64
+ except urllib.error.HTTPError as e:
65
+ return e.code, dict(e.headers.items()) if e.headers else {}, e.read()
66
+
67
+
68
+ class Client:
69
+ """One API key, one base URL. Every method returns the parsed JSON body: {"data": [...], "meta": {...}}."""
70
+
71
+ def __init__(
72
+ self,
73
+ api_key: Optional[str] = None,
74
+ base_url: str = DEFAULT_BASE_URL,
75
+ timeout: float = 30.0,
76
+ retries: int = 2,
77
+ transport: Optional[Transport] = None,
78
+ user_agent: str = f"underdog-api-python/{__version__}",
79
+ ):
80
+ self.api_key = api_key if api_key is not None else os.environ.get("UNDERDOG_API_KEY")
81
+ self.base_url = base_url.rstrip("/")
82
+ self.timeout = timeout
83
+ self.retries = retries
84
+ self._transport = transport or _urllib_transport
85
+ self.user_agent = user_agent
86
+
87
+ # -- plumbing ---------------------------------------------------------------------------------------------
88
+ def get(self, path: str, **params: Any) -> Any:
89
+ """GET any documented path, for example client.get("/api/v1/coverage")."""
90
+ query = {k: ("true" if v is True else "false" if v is False else v) for k, v in params.items() if v is not None}
91
+ url = f"{self.base_url}{path}" + (f"?{urllib.parse.urlencode(query)}" if query else "")
92
+ headers = {"Accept": "application/json", "User-Agent": self.user_agent}
93
+ if self.api_key and not path.startswith("/api/v1/demo/"):
94
+ headers["Authorization"] = f"Bearer {self.api_key}"
95
+ attempt = 0
96
+ while True:
97
+ status, res_headers, raw = self._transport("GET", url, headers, self.timeout)
98
+ text = raw.decode("utf-8", "replace") if isinstance(raw, (bytes, bytearray)) else str(raw)
99
+ try:
100
+ body: Any = json.loads(text) if text else None
101
+ except ValueError:
102
+ body = text
103
+ if 200 <= status < 300 or status == 304:
104
+ return body
105
+ retry_after = _header(res_headers, "retry-after")
106
+ # Per-minute 429s and 5xx are retried; the monthly 429 (credits used up) is not.
107
+ monthly = isinstance(body, dict) and (body.get("error") or {}).get("code") == "MONTHLY_QUOTA_EXCEEDED"
108
+ if attempt < self.retries and (status >= 500 or (status == 429 and not monthly)):
109
+ attempt += 1
110
+ time.sleep(min(float(retry_after) if retry_after and retry_after.isdigit() else 2 ** attempt, 30))
111
+ continue
112
+ if status == 402:
113
+ raise PlanLimitError(status, body, res_headers)
114
+ raise UnderdogAPIError(status, body, res_headers)
115
+
116
+ def pages(self, path: str, **params: Any) -> Iterator[Dict[str, Any]]:
117
+ """Yield rows across pages, following meta.next_cursor with the same filters."""
118
+ cursor = params.pop("cursor", None)
119
+ while True:
120
+ body = self.get(path, cursor=cursor, **params)
121
+ for row in body.get("data") or []:
122
+ yield row
123
+ cursor = (body.get("meta") or {}).get("next_cursor")
124
+ if not cursor:
125
+ return
126
+
127
+ # -- endpoints --------------------------------------------------------------------------------------------
128
+ def coverage(self, **p: Any) -> Any:
129
+ """Leagues, stats and the first recorded date per league."""
130
+ return self.get("/api/v1/coverage", **p)
131
+
132
+ def projections(self, league: Optional[str] = None, **p: Any) -> Any:
133
+ """The current board. Filters: league, stat, search, payout_tier, status, view=compact, limit, cursor."""
134
+ return self.get("/api/v1/projections", league=league, **p)
135
+
136
+ def odds(self, league: Optional[str] = None, **p: Any) -> Any:
137
+ """Each line with its Higher and Lower side as odds entries (5 minutes behind on Free)."""
138
+ return self.get("/api/v1/odds", league=league, **p)
139
+
140
+ def history(self, projection_id: str, **p: Any) -> Any:
141
+ """Every recorded change of one market."""
142
+ return self.get(f"/api/v1/projections/{urllib.parse.quote(projection_id)}/history", **p)
143
+
144
+ def changes(self, league: Optional[str] = None, **p: Any) -> Any:
145
+ """The change feed: event_type, since, cursor, limit."""
146
+ return self.get("/api/v1/changes", league=league, **p)
147
+
148
+ def board(self, as_of: str, league: Optional[str] = None, **p: Any) -> Any:
149
+ """The board as it stood at as_of (ISO timestamp)."""
150
+ return self.get("/api/v1/board", as_of=as_of, league=league, **p)
151
+
152
+ def results(self, league: Optional[str] = None, **p: Any) -> Any:
153
+ """Graded lines: player, stat, date (US Eastern), result. Paid plans; Free raises PlanLimitError."""
154
+ return self.get("/api/v1/results", league=league, **p)
155
+
156
+ def hit_rates(self, player: str, **p: Any) -> Any:
157
+ """How often a player went Higher or Lower over graded games: stat, league, last."""
158
+ return self.get("/api/v1/hit-rates", player=player, **p)
159
+
160
+ def training_set(self, league: Optional[str] = None, **p: Any) -> Any:
161
+ """One labelled row per graded line, for model training. Paid plans."""
162
+ return self.get("/api/v1/training-set", league=league, **p)
163
+
164
+ def multipliers(self, league: Optional[str] = None, **p: Any) -> Any:
165
+ return self.get("/api/v1/multipliers", league=league, **p)
166
+
167
+ def payouts(self) -> Any:
168
+ """Standard, Flex and Streaks payout reference."""
169
+ return self.get("/api/v1/payouts")
170
+
171
+ def credit_costs(self, path: Optional[str] = None) -> Any:
172
+ """The credit policy, or the quote for one request path. No key needed."""
173
+ return self.get("/api/v1/credit-costs", path=path)
174
+
175
+ def demo(self, resource: str = "projections", **p: Any) -> Any:
176
+ """Fictional rows in the live shape, no key: projections, changes, coverage, export, payouts."""
177
+ return self.get(f"/api/v1/demo/{resource}", **p)
178
+
179
+
180
+ def _header(headers: Dict[str, str], name: str) -> Optional[str]:
181
+ for k, v in (headers or {}).items():
182
+ if k.lower() == name:
183
+ return v
184
+ return None
@@ -0,0 +1,104 @@
1
+ Metadata-Version: 2.5
2
+ Name: underdog-api
3
+ Version: 0.1.0
4
+ Summary: Python client for Underdog API: Underdog Fantasy pick'em lines, Higher/Lower multipliers, line history and graded results.
5
+ Project-URL: Repository, https://github.com/underdogapi/underdogapi-python
6
+ Project-URL: Issues, https://github.com/underdogapi/underdogapi-python/issues
7
+ Project-URL: Homepage, https://underdogapi.com
8
+ Project-URL: Documentation, https://underdogapi.com/docs
9
+ Project-URL: Pricing, https://underdogapi.com/pricing
10
+ Author-email: Stunning Couches LLC <support@underdogapi.com>
11
+ License: MIT
12
+ License-File: LICENSE
13
+ Keywords: api,dfs,pick'em,player props,sports data,underdog,underdog fantasy
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Operating System :: OS Independent
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Topic :: Software Development :: Libraries
18
+ Requires-Python: >=3.9
19
+ Description-Content-Type: text/markdown
20
+
21
+ # underdog-api (Python)
22
+
23
+ Python client for [Underdog API](https://underdogapi.com): Underdog Fantasy pick'em lines with separate Higher and
24
+ Lower payout multipliers, every recorded line move since 6 Nov 2026, the board at any past instant, and graded results.
25
+ Standard library only; Python 3.9+.
26
+
27
+ Underdog API is an independent data service operated by Stunning Couches LLC. It is not affiliated with, endorsed by
28
+ or operated by Underdog Fantasy.
29
+
30
+ ## Install
31
+
32
+ ```bash
33
+ pip install underdog-api
34
+ export UNDERDOG_API_KEY=ud_... # free key, no card: https://underdogapi.com/sign-up
35
+ ```
36
+
37
+ ## Try it without a key
38
+
39
+ ```python
40
+ from underdog_api import Client
41
+
42
+ demo = Client(api_key=None).demo("projections", league="NBA", limit=1)
43
+ print(demo["data"][0]["player"]["name"], demo["data"][0]["selections"]) # fictional rows, live shape
44
+ ```
45
+
46
+ ## The current board
47
+
48
+ ```python
49
+ from underdog_api import Client
50
+
51
+ ud = Client() # reads UNDERDOG_API_KEY
52
+ board = ud.projections(league="NBA", view="compact", limit=20)
53
+ for line in board["data"]:
54
+ print(line)
55
+ print(board["meta"]["observed_at"])
56
+ ```
57
+
58
+ ## Everything that moved
59
+
60
+ ```python
61
+ for change in ud.pages("/api/v1/changes", league="NFL", event_type="line.moved", limit=200):
62
+ print(change["observed_at"], change["player_name"], change["stat"], change["previous_line"], "->", change["line"])
63
+ ```
64
+
65
+ ## The board at a past moment, and graded results (paid plans)
66
+
67
+ ```python
68
+ past = ud.board(as_of="2026-11-14T23:00:00Z", league="NHL", limit=100)
69
+ graded = ud.results(league="NBA", date="2026-11-13", limit=200)
70
+ ```
71
+
72
+ On the free key, graded results, hit rates, game logs, exports and the training set raise `PlanLimitError` with a
73
+ few labelled sample rows and the upgrade link; no credits are charged:
74
+
75
+ ```python
76
+ from underdog_api import PlanLimitError
77
+
78
+ try:
79
+ ud.results(league="NBA")
80
+ except PlanLimitError as e:
81
+ print(e.message, e.upgrade_url, e.sample[:2])
82
+ ```
83
+
84
+ ## Into pandas
85
+
86
+ ```python
87
+ import pandas as pd
88
+ rows = list(ud.pages("/api/v1/projections", league="NBA", view="compact", limit=200))
89
+ df = pd.json_normalize(rows)
90
+ ```
91
+
92
+ ## Notes
93
+
94
+ - Every method returns the parsed JSON body (`data` and `meta`). `ud.get(path, **params)` reaches any documented path.
95
+ - Per-minute 429s and 5xx answers are retried with backoff; the monthly credit 429 is raised at once.
96
+ - Credits: one per started 100 rows on simple reads; `ud.credit_costs()` returns the policy with no key.
97
+ - Reference: https://underdogapi.com/docs · Plans: https://underdogapi.com/pricing
98
+
99
+ ## Tests
100
+
101
+ ```bash
102
+ python -m unittest discover -s tests # offline, fake transport
103
+ UNDERDOG_LIVE=1 python -m unittest discover -s tests # also calls the keyless demo
104
+ ```
@@ -0,0 +1,5 @@
1
+ underdog_api/__init__.py,sha256=t46FhU4r12x69y691OKgNo3RBRXZ4FmmOijiBBO4Bos,8524
2
+ underdog_api-0.1.0.dist-info/METADATA,sha256=cD6Qqr8baC-QqJWa_qCm6Gua5m9g32iDmtL5T9DHtFE,3607
3
+ underdog_api-0.1.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
4
+ underdog_api-0.1.0.dist-info/licenses/LICENSE,sha256=fPaBah2bETIgfvPKPxdsnSYOM8etWW_uvYwTiwARegk,1077
5
+ underdog_api-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.33.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,16 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Stunning Couches LLC
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
6
+ documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
7
+ rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit
8
+ persons to whom the Software is furnished to do so, subject to the following conditions:
9
+
10
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
11
+ Software.
12
+
13
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
14
+ WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
15
+ COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
16
+ OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.