underdog-api 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.
- underdog_api-0.1.0/.github/workflows/publish.yml +22 -0
- underdog_api-0.1.0/LICENSE +16 -0
- underdog_api-0.1.0/PKG-INFO +104 -0
- underdog_api-0.1.0/README.md +84 -0
- underdog_api-0.1.0/pyproject.toml +30 -0
- underdog_api-0.1.0/src/underdog_api/__init__.py +184 -0
- underdog_api-0.1.0/tests/test_client.py +87 -0
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
name: Publish to PyPI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
release:
|
|
5
|
+
types: [published]
|
|
6
|
+
workflow_dispatch:
|
|
7
|
+
|
|
8
|
+
jobs:
|
|
9
|
+
publish:
|
|
10
|
+
runs-on: ubuntu-latest
|
|
11
|
+
environment: pypi
|
|
12
|
+
permissions:
|
|
13
|
+
id-token: write
|
|
14
|
+
contents: read
|
|
15
|
+
steps:
|
|
16
|
+
- uses: actions/checkout@v4
|
|
17
|
+
- uses: actions/setup-python@v5
|
|
18
|
+
with:
|
|
19
|
+
python-version: "3.12"
|
|
20
|
+
- run: python -m pip install --upgrade build
|
|
21
|
+
- run: python -m build
|
|
22
|
+
- uses: pypa/gh-action-pypi-publish@release/v1
|
|
@@ -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.
|
|
@@ -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,84 @@
|
|
|
1
|
+
# underdog-api (Python)
|
|
2
|
+
|
|
3
|
+
Python client for [Underdog API](https://underdogapi.com): Underdog Fantasy pick'em lines with separate Higher and
|
|
4
|
+
Lower payout multipliers, every recorded line move since 6 Nov 2026, the board at any past instant, and graded results.
|
|
5
|
+
Standard library only; Python 3.9+.
|
|
6
|
+
|
|
7
|
+
Underdog API is an independent data service operated by Stunning Couches LLC. It is not affiliated with, endorsed by
|
|
8
|
+
or operated by Underdog Fantasy.
|
|
9
|
+
|
|
10
|
+
## Install
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
pip install underdog-api
|
|
14
|
+
export UNDERDOG_API_KEY=ud_... # free key, no card: https://underdogapi.com/sign-up
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Try it without a key
|
|
18
|
+
|
|
19
|
+
```python
|
|
20
|
+
from underdog_api import Client
|
|
21
|
+
|
|
22
|
+
demo = Client(api_key=None).demo("projections", league="NBA", limit=1)
|
|
23
|
+
print(demo["data"][0]["player"]["name"], demo["data"][0]["selections"]) # fictional rows, live shape
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## The current board
|
|
27
|
+
|
|
28
|
+
```python
|
|
29
|
+
from underdog_api import Client
|
|
30
|
+
|
|
31
|
+
ud = Client() # reads UNDERDOG_API_KEY
|
|
32
|
+
board = ud.projections(league="NBA", view="compact", limit=20)
|
|
33
|
+
for line in board["data"]:
|
|
34
|
+
print(line)
|
|
35
|
+
print(board["meta"]["observed_at"])
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Everything that moved
|
|
39
|
+
|
|
40
|
+
```python
|
|
41
|
+
for change in ud.pages("/api/v1/changes", league="NFL", event_type="line.moved", limit=200):
|
|
42
|
+
print(change["observed_at"], change["player_name"], change["stat"], change["previous_line"], "->", change["line"])
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## The board at a past moment, and graded results (paid plans)
|
|
46
|
+
|
|
47
|
+
```python
|
|
48
|
+
past = ud.board(as_of="2026-11-14T23:00:00Z", league="NHL", limit=100)
|
|
49
|
+
graded = ud.results(league="NBA", date="2026-11-13", limit=200)
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
On the free key, graded results, hit rates, game logs, exports and the training set raise `PlanLimitError` with a
|
|
53
|
+
few labelled sample rows and the upgrade link; no credits are charged:
|
|
54
|
+
|
|
55
|
+
```python
|
|
56
|
+
from underdog_api import PlanLimitError
|
|
57
|
+
|
|
58
|
+
try:
|
|
59
|
+
ud.results(league="NBA")
|
|
60
|
+
except PlanLimitError as e:
|
|
61
|
+
print(e.message, e.upgrade_url, e.sample[:2])
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## Into pandas
|
|
65
|
+
|
|
66
|
+
```python
|
|
67
|
+
import pandas as pd
|
|
68
|
+
rows = list(ud.pages("/api/v1/projections", league="NBA", view="compact", limit=200))
|
|
69
|
+
df = pd.json_normalize(rows)
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## Notes
|
|
73
|
+
|
|
74
|
+
- Every method returns the parsed JSON body (`data` and `meta`). `ud.get(path, **params)` reaches any documented path.
|
|
75
|
+
- Per-minute 429s and 5xx answers are retried with backoff; the monthly credit 429 is raised at once.
|
|
76
|
+
- Credits: one per started 100 rows on simple reads; `ud.credit_costs()` returns the policy with no key.
|
|
77
|
+
- Reference: https://underdogapi.com/docs · Plans: https://underdogapi.com/pricing
|
|
78
|
+
|
|
79
|
+
## Tests
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
python -m unittest discover -s tests # offline, fake transport
|
|
83
|
+
UNDERDOG_LIVE=1 python -m unittest discover -s tests # also calls the keyless demo
|
|
84
|
+
```
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.21"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "underdog-api"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Python client for Underdog API: Underdog Fantasy pick'em lines, Higher/Lower multipliers, line history and graded results."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [{ name = "Stunning Couches LLC", email = "support@underdogapi.com" }]
|
|
13
|
+
keywords = ["underdog", "underdog fantasy", "pick'em", "player props", "dfs", "sports data", "api"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Programming Language :: Python :: 3",
|
|
16
|
+
"License :: OSI Approved :: MIT License",
|
|
17
|
+
"Operating System :: OS Independent",
|
|
18
|
+
"Topic :: Software Development :: Libraries",
|
|
19
|
+
]
|
|
20
|
+
dependencies = []
|
|
21
|
+
|
|
22
|
+
[project.urls]
|
|
23
|
+
Repository = "https://github.com/underdogapi/underdogapi-python"
|
|
24
|
+
Issues = "https://github.com/underdogapi/underdogapi-python/issues"
|
|
25
|
+
Homepage = "https://underdogapi.com"
|
|
26
|
+
Documentation = "https://underdogapi.com/docs"
|
|
27
|
+
Pricing = "https://underdogapi.com/pricing"
|
|
28
|
+
|
|
29
|
+
[tool.hatch.build.targets.wheel]
|
|
30
|
+
packages = ["src/underdog_api"]
|
|
@@ -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,87 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
import sys
|
|
4
|
+
import unittest
|
|
5
|
+
|
|
6
|
+
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src"))
|
|
7
|
+
from underdog_api import Client, PlanLimitError, UnderdogAPIError # noqa: E402
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Fake:
|
|
11
|
+
"""Records requests and replays canned answers, so the tests never touch the network."""
|
|
12
|
+
|
|
13
|
+
def __init__(self, answers):
|
|
14
|
+
self.answers = list(answers)
|
|
15
|
+
self.calls = []
|
|
16
|
+
|
|
17
|
+
def __call__(self, method, url, headers, timeout):
|
|
18
|
+
self.calls.append((method, url, headers))
|
|
19
|
+
status, body = self.answers.pop(0)
|
|
20
|
+
return status, {"Content-Type": "application/json"}, json.dumps(body).encode()
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class ClientTest(unittest.TestCase):
|
|
24
|
+
def test_sends_bearer_key_and_filters(self):
|
|
25
|
+
fake = Fake([(200, {"data": [{"id": "udm_1"}], "meta": {"next_cursor": None}})])
|
|
26
|
+
body = Client(api_key="ud_test", transport=fake).projections(league="NBA", view="compact", limit=5)
|
|
27
|
+
self.assertEqual(body["data"][0]["id"], "udm_1")
|
|
28
|
+
method, url, headers = fake.calls[0]
|
|
29
|
+
self.assertEqual(method, "GET")
|
|
30
|
+
self.assertTrue(url.startswith("https://underdogapi.com/api/v1/projections?"))
|
|
31
|
+
self.assertIn("league=NBA", url)
|
|
32
|
+
self.assertIn("view=compact", url)
|
|
33
|
+
self.assertEqual(headers["Authorization"], "Bearer ud_test")
|
|
34
|
+
|
|
35
|
+
def test_demo_needs_no_key(self):
|
|
36
|
+
fake = Fake([(200, {"data": [], "meta": {}})])
|
|
37
|
+
Client(api_key="ud_test", transport=fake).demo("projections", league="NBA")
|
|
38
|
+
self.assertNotIn("Authorization", fake.calls[0][2])
|
|
39
|
+
self.assertIn("/api/v1/demo/projections", fake.calls[0][1])
|
|
40
|
+
|
|
41
|
+
def test_pages_follow_cursor(self):
|
|
42
|
+
fake = Fake([
|
|
43
|
+
(200, {"data": [{"id": 1}, {"id": 2}], "meta": {"next_cursor": "udm_x"}}),
|
|
44
|
+
(200, {"data": [{"id": 3}], "meta": {"next_cursor": None}}),
|
|
45
|
+
])
|
|
46
|
+
rows = list(Client(api_key="k", transport=fake).pages("/api/v1/changes", league="NBA"))
|
|
47
|
+
self.assertEqual([r["id"] for r in rows], [1, 2, 3])
|
|
48
|
+
self.assertIn("cursor=udm_x", fake.calls[1][1])
|
|
49
|
+
|
|
50
|
+
def test_402_is_a_plan_limit_with_sample_and_link(self):
|
|
51
|
+
err = {"error": {"code": "UPGRADE_REQUIRED", "message": "Free does not include graded results.", "upgrade_url": "https://underdogapi.com/upgrade?plan=developer", "sample": [{"result": "higher"}]}}
|
|
52
|
+
fake = Fake([(402, err)])
|
|
53
|
+
with self.assertRaises(PlanLimitError) as ctx:
|
|
54
|
+
Client(api_key="k", transport=fake).results(league="NBA")
|
|
55
|
+
self.assertEqual(ctx.exception.code, "UPGRADE_REQUIRED")
|
|
56
|
+
self.assertEqual(ctx.exception.upgrade_url, "https://underdogapi.com/upgrade?plan=developer")
|
|
57
|
+
self.assertEqual(ctx.exception.sample, [{"result": "higher"}])
|
|
58
|
+
|
|
59
|
+
def test_retries_per_minute_429_but_not_monthly(self):
|
|
60
|
+
fake = Fake([(429, {"error": {"code": "RATE_LIMITED"}}), (200, {"data": [], "meta": {}})])
|
|
61
|
+
c = Client(api_key="k", transport=fake, retries=1)
|
|
62
|
+
import underdog_api
|
|
63
|
+
underdog_api.time.sleep = lambda s: None
|
|
64
|
+
c.coverage()
|
|
65
|
+
self.assertEqual(len(fake.calls), 2)
|
|
66
|
+
fake2 = Fake([(429, {"error": {"code": "MONTHLY_QUOTA_EXCEEDED", "message": "out of credits"}})])
|
|
67
|
+
with self.assertRaises(UnderdogAPIError) as ctx:
|
|
68
|
+
Client(api_key="k", transport=fake2, retries=3).coverage()
|
|
69
|
+
self.assertEqual(ctx.exception.status, 429)
|
|
70
|
+
self.assertEqual(len(fake2.calls), 1)
|
|
71
|
+
|
|
72
|
+
def test_history_path_is_quoted(self):
|
|
73
|
+
fake = Fake([(200, {"data": [], "meta": {}})])
|
|
74
|
+
Client(api_key="k", transport=fake).history("udm_00000000-0000-4000-8000-000000000001")
|
|
75
|
+
self.assertIn("/api/v1/projections/udm_00000000-0000-4000-8000-000000000001/history", fake.calls[0][1])
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@unittest.skipUnless(os.environ.get("UNDERDOG_LIVE") == "1", "set UNDERDOG_LIVE=1 to call the real keyless demo")
|
|
79
|
+
class LiveDemoTest(unittest.TestCase):
|
|
80
|
+
def test_keyless_demo(self):
|
|
81
|
+
body = Client(api_key=None).demo("projections", league="NBA", limit=1)
|
|
82
|
+
self.assertTrue(body["data"])
|
|
83
|
+
self.assertIn("selections", body["data"][0])
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
if __name__ == "__main__":
|
|
87
|
+
unittest.main()
|