prizepicksapi 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.
- prizepicksapi-0.1.0/LICENSE +21 -0
- prizepicksapi-0.1.0/PKG-INFO +142 -0
- prizepicksapi-0.1.0/README.md +119 -0
- prizepicksapi-0.1.0/pyproject.toml +32 -0
- prizepicksapi-0.1.0/setup.cfg +4 -0
- prizepicksapi-0.1.0/src/prizepicksapi/__init__.py +10 -0
- prizepicksapi-0.1.0/src/prizepicksapi/client.py +220 -0
- prizepicksapi-0.1.0/src/prizepicksapi/errors.py +27 -0
- prizepicksapi-0.1.0/src/prizepicksapi.egg-info/PKG-INFO +142 -0
- prizepicksapi-0.1.0/src/prizepicksapi.egg-info/SOURCES.txt +11 -0
- prizepicksapi-0.1.0/src/prizepicksapi.egg-info/dependency_links.txt +1 -0
- prizepicksapi-0.1.0/src/prizepicksapi.egg-info/top_level.txt +1 -0
- prizepicksapi-0.1.0/tests/test_client.py +110 -0
|
@@ -0,0 +1,21 @@
|
|
|
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
|
|
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,142 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: prizepicksapi
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python client for prizepicksapi.com: live PrizePicks lines, goblin and demon tiers, line history and graded results.
|
|
5
|
+
Author-email: Stunning Couches LLC <support@prizepicksapi.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Repository, https://github.com/prizepicksapi/prizepicksapi-python
|
|
8
|
+
Project-URL: Issues, https://github.com/prizepicksapi/prizepicksapi-python/issues
|
|
9
|
+
Project-URL: Homepage, https://prizepicksapi.com
|
|
10
|
+
Project-URL: Documentation, https://prizepicksapi.com/docs
|
|
11
|
+
Project-URL: API reference, https://prizepicksapi.com/openapi.json
|
|
12
|
+
Project-URL: Pricing, https://prizepicksapi.com/pricing
|
|
13
|
+
Keywords: prizepicks,player props,dfs,pick'em,sports data,api
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Operating System :: OS Independent
|
|
17
|
+
Classifier: Intended Audience :: Developers
|
|
18
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
19
|
+
Requires-Python: >=3.9
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
License-File: LICENSE
|
|
22
|
+
Dynamic: license-file
|
|
23
|
+
|
|
24
|
+
# prizepicksapi (Python)
|
|
25
|
+
|
|
26
|
+
Python client for [prizepicksapi.com](https://prizepicksapi.com): live PrizePicks player-prop lines, goblin and demon tiers on each selection, recorded line history, the change feed, graded results and game logs. Standard library only, Python 3.9+.
|
|
27
|
+
|
|
28
|
+
prizepicksapi.com is an independent service operated by Stunning Couches LLC. It is not affiliated with or endorsed by PrizePicks.
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
pip install prizepicksapi
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Try it with no key
|
|
35
|
+
|
|
36
|
+
The `demo_*` methods call the keyless demo endpoints. They return fictional rows (`is_demo: true`) with the same fields as the live board, so you can write your parser first.
|
|
37
|
+
|
|
38
|
+
```python
|
|
39
|
+
from prizepicksapi import PrizePicksAPI
|
|
40
|
+
|
|
41
|
+
api = PrizePicksAPI(api_key="")
|
|
42
|
+
for p in api.demo_projections(league="NBA", limit=3)["data"]:
|
|
43
|
+
print(p["player"]["name"], p["market"]["stat"], p["line"], p["selections"][0]["tier"], "opened", p["line_context"]["opening_line"])
|
|
44
|
+
|
|
45
|
+
for row in api.demo_payouts()["data"][:2]:
|
|
46
|
+
print(row["entry_type"], row["picks"], "break-even", row["derived"]["break_even_probability"])
|
|
47
|
+
|
|
48
|
+
print(api.credit_cost("/api/v1/projections?league=NBA&limit=200")["quoted_credits"])
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Output on 15 Nov 2026:
|
|
52
|
+
|
|
53
|
+
```
|
|
54
|
+
Jordan Ellis Points 27.5 standard opened 26.5
|
|
55
|
+
Marcus Cole Rebounds 8.5 standard opened 7.5
|
|
56
|
+
Devin Brooks Assists 6.5 standard opened 5.5
|
|
57
|
+
power 2 break-even 0.57735
|
|
58
|
+
power 3 break-even 0.55032
|
|
59
|
+
6
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## Live data
|
|
63
|
+
|
|
64
|
+
Get a free key (100 credits a month, no card) at https://prizepicksapi.com/sign-up and export it:
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
export PRIZEPICKS_API_KEY=...
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
```python
|
|
71
|
+
from prizepicksapi import PrizePicksAPI
|
|
72
|
+
|
|
73
|
+
api = PrizePicksAPI(user_request="NBA points model: pull today's board") # optional X-User-Request header
|
|
74
|
+
|
|
75
|
+
# Every current NBA points line, following cursors for you
|
|
76
|
+
for p in api.iter_projections(league="NBA", stat="Points", limit=200):
|
|
77
|
+
tiers = {s["side"]: s["tier"] for s in p["selections"]}
|
|
78
|
+
print(p["player"]["name"], p["line"], tiers)
|
|
79
|
+
|
|
80
|
+
# Only goblin lines, compact rows (up to 1,000 per page)
|
|
81
|
+
goblins = api.projections(league="NBA", tier="goblin", view="compact", limit=1000)
|
|
82
|
+
|
|
83
|
+
# Recorded moves for one projection
|
|
84
|
+
history = api.history(goblins["data"][0]["id"])
|
|
85
|
+
|
|
86
|
+
# Change feed: new, moved, pulled and restored lines. Store meta.next_cursor and resume from it.
|
|
87
|
+
feed = api.changes(league="NBA", limit=200)
|
|
88
|
+
|
|
89
|
+
# Graded results and hit rates
|
|
90
|
+
graded = api.results(league="NBA", date="2026-11-14")
|
|
91
|
+
rates = api.hit_rates(player="Victor Wembanyama", stat="Points")
|
|
92
|
+
|
|
93
|
+
# The board as it stood at a past moment
|
|
94
|
+
past = api.board(as_of="2026-11-10T23:00:00Z", league="NBA")
|
|
95
|
+
|
|
96
|
+
# CSV page for a spreadsheet
|
|
97
|
+
csv_text = api.export_csv(league="NBA", limit=200)
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
## Errors and limits
|
|
101
|
+
|
|
102
|
+
Non-2xx responses raise `APIError` with the API's stable `code` (for example `INVALID_API_KEY`). HTTP 429 raises `RateLimitError`. Per-minute limits (`RATE_LIMIT_EXCEEDED`) and 503s are retried up to `max_retries` times using the `Retry-After` header; a monthly quota (`MONTHLY_QUOTA_EXCEEDED`) is raised at once.
|
|
103
|
+
|
|
104
|
+
```python
|
|
105
|
+
from prizepicksapi import APIError, RateLimitError
|
|
106
|
+
|
|
107
|
+
try:
|
|
108
|
+
api.projections(league="NBA")
|
|
109
|
+
except RateLimitError as e:
|
|
110
|
+
print("limited:", e.code, e.retry_after)
|
|
111
|
+
except APIError as e:
|
|
112
|
+
print(e.status, e.code, e.message)
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
## Methods
|
|
116
|
+
|
|
117
|
+
| Method | Endpoint | Key |
|
|
118
|
+
|---|---|---|
|
|
119
|
+
| `demo_projections`, `demo_history`, `demo_changes`, `demo_payouts`, `demo_coverage`, `demo_export_csv` | `/api/v1/demo/*` | no |
|
|
120
|
+
| `credit_cost(path)` | `/api/v1/credit-costs` | no |
|
|
121
|
+
| `projections`, `iter_projections` | `/api/v1/projections` | yes |
|
|
122
|
+
| `history(id)` | `/api/v1/projections/{id}/history` | yes |
|
|
123
|
+
| `trending` | `/api/v1/projections/trending` | yes |
|
|
124
|
+
| `changes` | `/api/v1/changes` | yes |
|
|
125
|
+
| `board(as_of)` | `/api/v1/board` | yes |
|
|
126
|
+
| `results`, `hit_rates`, `game_logs`, `training_set` | `/api/v1/results` etc. | yes |
|
|
127
|
+
| `coverage`, `payouts`, `compare`, `players`, `fixtures` | `/api/v1/*` | yes |
|
|
128
|
+
| `export_csv` | `/api/v1/export` | yes |
|
|
129
|
+
| `request(path, params)`, `paginate(path, params)` | any GET endpoint | |
|
|
130
|
+
|
|
131
|
+
Full reference: https://prizepicksapi.com/docs and https://prizepicksapi.com/openapi.json. Plans: https://prizepicksapi.com/pricing.
|
|
132
|
+
|
|
133
|
+
## Tests
|
|
134
|
+
|
|
135
|
+
```bash
|
|
136
|
+
python3 -m unittest discover -s tests # mocked transport
|
|
137
|
+
PRIZEPICKS_LIVE_TEST=1 python3 -m unittest discover -s tests # also calls the keyless demo endpoint
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
## License
|
|
141
|
+
|
|
142
|
+
MIT.
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# prizepicksapi (Python)
|
|
2
|
+
|
|
3
|
+
Python client for [prizepicksapi.com](https://prizepicksapi.com): live PrizePicks player-prop lines, goblin and demon tiers on each selection, recorded line history, the change feed, graded results and game logs. Standard library only, Python 3.9+.
|
|
4
|
+
|
|
5
|
+
prizepicksapi.com is an independent service operated by Stunning Couches LLC. It is not affiliated with or endorsed by PrizePicks.
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install prizepicksapi
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Try it with no key
|
|
12
|
+
|
|
13
|
+
The `demo_*` methods call the keyless demo endpoints. They return fictional rows (`is_demo: true`) with the same fields as the live board, so you can write your parser first.
|
|
14
|
+
|
|
15
|
+
```python
|
|
16
|
+
from prizepicksapi import PrizePicksAPI
|
|
17
|
+
|
|
18
|
+
api = PrizePicksAPI(api_key="")
|
|
19
|
+
for p in api.demo_projections(league="NBA", limit=3)["data"]:
|
|
20
|
+
print(p["player"]["name"], p["market"]["stat"], p["line"], p["selections"][0]["tier"], "opened", p["line_context"]["opening_line"])
|
|
21
|
+
|
|
22
|
+
for row in api.demo_payouts()["data"][:2]:
|
|
23
|
+
print(row["entry_type"], row["picks"], "break-even", row["derived"]["break_even_probability"])
|
|
24
|
+
|
|
25
|
+
print(api.credit_cost("/api/v1/projections?league=NBA&limit=200")["quoted_credits"])
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Output on 15 Nov 2026:
|
|
29
|
+
|
|
30
|
+
```
|
|
31
|
+
Jordan Ellis Points 27.5 standard opened 26.5
|
|
32
|
+
Marcus Cole Rebounds 8.5 standard opened 7.5
|
|
33
|
+
Devin Brooks Assists 6.5 standard opened 5.5
|
|
34
|
+
power 2 break-even 0.57735
|
|
35
|
+
power 3 break-even 0.55032
|
|
36
|
+
6
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## Live data
|
|
40
|
+
|
|
41
|
+
Get a free key (100 credits a month, no card) at https://prizepicksapi.com/sign-up and export it:
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
export PRIZEPICKS_API_KEY=...
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
```python
|
|
48
|
+
from prizepicksapi import PrizePicksAPI
|
|
49
|
+
|
|
50
|
+
api = PrizePicksAPI(user_request="NBA points model: pull today's board") # optional X-User-Request header
|
|
51
|
+
|
|
52
|
+
# Every current NBA points line, following cursors for you
|
|
53
|
+
for p in api.iter_projections(league="NBA", stat="Points", limit=200):
|
|
54
|
+
tiers = {s["side"]: s["tier"] for s in p["selections"]}
|
|
55
|
+
print(p["player"]["name"], p["line"], tiers)
|
|
56
|
+
|
|
57
|
+
# Only goblin lines, compact rows (up to 1,000 per page)
|
|
58
|
+
goblins = api.projections(league="NBA", tier="goblin", view="compact", limit=1000)
|
|
59
|
+
|
|
60
|
+
# Recorded moves for one projection
|
|
61
|
+
history = api.history(goblins["data"][0]["id"])
|
|
62
|
+
|
|
63
|
+
# Change feed: new, moved, pulled and restored lines. Store meta.next_cursor and resume from it.
|
|
64
|
+
feed = api.changes(league="NBA", limit=200)
|
|
65
|
+
|
|
66
|
+
# Graded results and hit rates
|
|
67
|
+
graded = api.results(league="NBA", date="2026-11-14")
|
|
68
|
+
rates = api.hit_rates(player="Victor Wembanyama", stat="Points")
|
|
69
|
+
|
|
70
|
+
# The board as it stood at a past moment
|
|
71
|
+
past = api.board(as_of="2026-11-10T23:00:00Z", league="NBA")
|
|
72
|
+
|
|
73
|
+
# CSV page for a spreadsheet
|
|
74
|
+
csv_text = api.export_csv(league="NBA", limit=200)
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
## Errors and limits
|
|
78
|
+
|
|
79
|
+
Non-2xx responses raise `APIError` with the API's stable `code` (for example `INVALID_API_KEY`). HTTP 429 raises `RateLimitError`. Per-minute limits (`RATE_LIMIT_EXCEEDED`) and 503s are retried up to `max_retries` times using the `Retry-After` header; a monthly quota (`MONTHLY_QUOTA_EXCEEDED`) is raised at once.
|
|
80
|
+
|
|
81
|
+
```python
|
|
82
|
+
from prizepicksapi import APIError, RateLimitError
|
|
83
|
+
|
|
84
|
+
try:
|
|
85
|
+
api.projections(league="NBA")
|
|
86
|
+
except RateLimitError as e:
|
|
87
|
+
print("limited:", e.code, e.retry_after)
|
|
88
|
+
except APIError as e:
|
|
89
|
+
print(e.status, e.code, e.message)
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
## Methods
|
|
93
|
+
|
|
94
|
+
| Method | Endpoint | Key |
|
|
95
|
+
|---|---|---|
|
|
96
|
+
| `demo_projections`, `demo_history`, `demo_changes`, `demo_payouts`, `demo_coverage`, `demo_export_csv` | `/api/v1/demo/*` | no |
|
|
97
|
+
| `credit_cost(path)` | `/api/v1/credit-costs` | no |
|
|
98
|
+
| `projections`, `iter_projections` | `/api/v1/projections` | yes |
|
|
99
|
+
| `history(id)` | `/api/v1/projections/{id}/history` | yes |
|
|
100
|
+
| `trending` | `/api/v1/projections/trending` | yes |
|
|
101
|
+
| `changes` | `/api/v1/changes` | yes |
|
|
102
|
+
| `board(as_of)` | `/api/v1/board` | yes |
|
|
103
|
+
| `results`, `hit_rates`, `game_logs`, `training_set` | `/api/v1/results` etc. | yes |
|
|
104
|
+
| `coverage`, `payouts`, `compare`, `players`, `fixtures` | `/api/v1/*` | yes |
|
|
105
|
+
| `export_csv` | `/api/v1/export` | yes |
|
|
106
|
+
| `request(path, params)`, `paginate(path, params)` | any GET endpoint | |
|
|
107
|
+
|
|
108
|
+
Full reference: https://prizepicksapi.com/docs and https://prizepicksapi.com/openapi.json. Plans: https://prizepicksapi.com/pricing.
|
|
109
|
+
|
|
110
|
+
## Tests
|
|
111
|
+
|
|
112
|
+
```bash
|
|
113
|
+
python3 -m unittest discover -s tests # mocked transport
|
|
114
|
+
PRIZEPICKS_LIVE_TEST=1 python3 -m unittest discover -s tests # also calls the keyless demo endpoint
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
## License
|
|
118
|
+
|
|
119
|
+
MIT.
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "prizepicksapi"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Python client for prizepicksapi.com: live PrizePicks lines, goblin and demon tiers, 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@prizepicksapi.com" }]
|
|
13
|
+
keywords = ["prizepicks", "player props", "dfs", "pick'em", "sports data", "api"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Programming Language :: Python :: 3",
|
|
16
|
+
"License :: OSI Approved :: MIT License",
|
|
17
|
+
"Operating System :: OS Independent",
|
|
18
|
+
"Intended Audience :: Developers",
|
|
19
|
+
"Topic :: Software Development :: Libraries",
|
|
20
|
+
]
|
|
21
|
+
dependencies = []
|
|
22
|
+
|
|
23
|
+
[project.urls]
|
|
24
|
+
Repository = "https://github.com/prizepicksapi/prizepicksapi-python"
|
|
25
|
+
Issues = "https://github.com/prizepicksapi/prizepicksapi-python/issues"
|
|
26
|
+
Homepage = "https://prizepicksapi.com"
|
|
27
|
+
Documentation = "https://prizepicksapi.com/docs"
|
|
28
|
+
"API reference" = "https://prizepicksapi.com/openapi.json"
|
|
29
|
+
Pricing = "https://prizepicksapi.com/pricing"
|
|
30
|
+
|
|
31
|
+
[tool.setuptools.packages.find]
|
|
32
|
+
where = ["src"]
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""Python client for prizepicksapi.com: live PrizePicks lines, goblin and demon tiers, line history and graded results.
|
|
2
|
+
|
|
3
|
+
Independent service operated by Stunning Couches LLC; not affiliated with PrizePicks.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from .client import DEFAULT_BASE_URL, PrizePicksAPI
|
|
7
|
+
from .errors import APIError, PrizePicksAPIError, RateLimitError, TransportError
|
|
8
|
+
|
|
9
|
+
__version__ = "0.1.0"
|
|
10
|
+
__all__ = ["PrizePicksAPI", "DEFAULT_BASE_URL", "APIError", "PrizePicksAPIError", "RateLimitError", "TransportError", "__version__"]
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
"""HTTP client for the prizepicksapi.com REST API (https://prizepicksapi.com/docs).
|
|
2
|
+
|
|
3
|
+
Standard library only. Every method maps to one documented endpoint and returns the decoded JSON body
|
|
4
|
+
(a dict with ``data`` and ``meta``), except ``export_csv`` which returns CSV text.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import time
|
|
12
|
+
from typing import Any, Callable, Dict, Iterator, Mapping, Optional, Tuple
|
|
13
|
+
from urllib.error import HTTPError, URLError
|
|
14
|
+
from urllib.parse import quote, urlencode
|
|
15
|
+
from urllib.request import Request, urlopen
|
|
16
|
+
|
|
17
|
+
from .errors import APIError, RateLimitError, TransportError
|
|
18
|
+
|
|
19
|
+
__all__ = ["PrizePicksAPI", "DEFAULT_BASE_URL"]
|
|
20
|
+
|
|
21
|
+
DEFAULT_BASE_URL = "https://prizepicksapi.com"
|
|
22
|
+
USER_AGENT = "prizepicksapi-python/0.1.0"
|
|
23
|
+
|
|
24
|
+
# A transport takes (method, url, headers, timeout) and returns (status, headers, body_bytes).
|
|
25
|
+
Transport = Callable[[str, str, Dict[str, str], float], Tuple[int, Mapping[str, str], bytes]]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _urllib_transport(method: str, url: str, headers: Dict[str, str], timeout: float):
|
|
29
|
+
req = Request(url, headers=headers, method=method)
|
|
30
|
+
try:
|
|
31
|
+
with urlopen(req, timeout=timeout) as res:
|
|
32
|
+
return res.status, dict(res.headers.items()), res.read()
|
|
33
|
+
except HTTPError as err:
|
|
34
|
+
return err.code, dict(err.headers.items()) if err.headers else {}, err.read() or b""
|
|
35
|
+
except URLError as err:
|
|
36
|
+
raise TransportError(str(err.reason)) from err
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _clean(params: Mapping[str, Any]) -> Dict[str, str]:
|
|
40
|
+
out: Dict[str, str] = {}
|
|
41
|
+
for key, value in params.items():
|
|
42
|
+
if value is None:
|
|
43
|
+
continue
|
|
44
|
+
if isinstance(value, bool):
|
|
45
|
+
out[key] = "true" if value else "false"
|
|
46
|
+
elif isinstance(value, (list, tuple)):
|
|
47
|
+
out[key] = ",".join(str(v) for v in value)
|
|
48
|
+
else:
|
|
49
|
+
out[key] = str(value)
|
|
50
|
+
return out
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class PrizePicksAPI:
|
|
54
|
+
"""Client for prizepicksapi.com.
|
|
55
|
+
|
|
56
|
+
>>> api = PrizePicksAPI() # reads PRIZEPICKS_API_KEY from the environment
|
|
57
|
+
>>> api.demo_projections(league="NBA", limit=2)["data"][0]["player"]["name"]
|
|
58
|
+
|
|
59
|
+
``user_request`` is sent as the optional X-User-Request header: one sentence describing the task,
|
|
60
|
+
with no personal data. It never changes the data or the cost.
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
def __init__(
|
|
64
|
+
self,
|
|
65
|
+
api_key: Optional[str] = None,
|
|
66
|
+
*,
|
|
67
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
68
|
+
user_request: Optional[str] = None,
|
|
69
|
+
timeout: float = 30.0,
|
|
70
|
+
max_retries: int = 2,
|
|
71
|
+
transport: Optional[Transport] = None,
|
|
72
|
+
sleep: Callable[[float], None] = time.sleep,
|
|
73
|
+
) -> None:
|
|
74
|
+
self.api_key = api_key if api_key is not None else os.environ.get("PRIZEPICKS_API_KEY")
|
|
75
|
+
self.base_url = base_url.rstrip("/")
|
|
76
|
+
self.user_request = user_request
|
|
77
|
+
self.timeout = timeout
|
|
78
|
+
self.max_retries = max_retries
|
|
79
|
+
self._transport = transport or _urllib_transport
|
|
80
|
+
self._sleep = sleep
|
|
81
|
+
|
|
82
|
+
# ------------------------------------------------------------------ core
|
|
83
|
+
def request(self, path: str, params: Optional[Mapping[str, Any]] = None, *, auth: bool = True, raw: bool = False) -> Any:
|
|
84
|
+
"""GET ``path`` with query ``params``. Retries 429 per-minute limits and 503s using Retry-After."""
|
|
85
|
+
query = urlencode(_clean(params or {}))
|
|
86
|
+
url = f"{self.base_url}{path}" + (f"?{query}" if query else "")
|
|
87
|
+
headers = {"Accept": "text/csv" if raw else "application/json", "User-Agent": USER_AGENT}
|
|
88
|
+
if auth:
|
|
89
|
+
if not self.api_key:
|
|
90
|
+
raise APIError(401, "INVALID_API_KEY", "Set PRIZEPICKS_API_KEY or pass api_key=. Free keys: https://prizepicksapi.com/sign-up. The demo_* methods need no key.")
|
|
91
|
+
headers["Authorization"] = f"Bearer {self.api_key}"
|
|
92
|
+
if self.user_request:
|
|
93
|
+
headers["X-User-Request"] = quote(self.user_request, safe=" ,.;:!?()'-/")
|
|
94
|
+
attempt = 0
|
|
95
|
+
while True:
|
|
96
|
+
status, resp_headers, body = self._transport("GET", url, headers, self.timeout)
|
|
97
|
+
lower = {k.lower(): v for k, v in resp_headers.items()}
|
|
98
|
+
if 200 <= status < 300 or status == 304:
|
|
99
|
+
if raw:
|
|
100
|
+
return body.decode("utf-8")
|
|
101
|
+
return json.loads(body.decode("utf-8")) if body else None
|
|
102
|
+
error = self._error(status, lower, body)
|
|
103
|
+
retryable = (isinstance(error, RateLimitError) and error.code != "MONTHLY_QUOTA_EXCEEDED") or status == 503
|
|
104
|
+
if retryable and attempt < self.max_retries:
|
|
105
|
+
attempt += 1
|
|
106
|
+
self._sleep(min(error.retry_after or 2 ** attempt, 60))
|
|
107
|
+
continue
|
|
108
|
+
raise error
|
|
109
|
+
|
|
110
|
+
@staticmethod
|
|
111
|
+
def _error(status: int, headers: Mapping[str, str], body: bytes) -> APIError:
|
|
112
|
+
code, message, payload = "HTTP_ERROR", f"HTTP {status}", None
|
|
113
|
+
try:
|
|
114
|
+
payload = json.loads(body.decode("utf-8"))
|
|
115
|
+
err = payload.get("error", {}) if isinstance(payload, dict) else {}
|
|
116
|
+
code = err.get("code", code)
|
|
117
|
+
message = err.get("message", message)
|
|
118
|
+
except (ValueError, UnicodeDecodeError):
|
|
119
|
+
pass
|
|
120
|
+
retry_after = None
|
|
121
|
+
if headers.get("retry-after"):
|
|
122
|
+
try:
|
|
123
|
+
retry_after = float(headers["retry-after"])
|
|
124
|
+
except ValueError:
|
|
125
|
+
retry_after = None
|
|
126
|
+
cls = RateLimitError if status == 429 else APIError
|
|
127
|
+
return cls(status, code, message, retry_after=retry_after, body=payload)
|
|
128
|
+
|
|
129
|
+
def paginate(self, path: str, params: Optional[Mapping[str, Any]] = None, *, auth: bool = True, max_pages: Optional[int] = None) -> Iterator[Dict[str, Any]]:
|
|
130
|
+
"""Yield every row across pages by following meta.next_cursor. Keep filters unchanged while paging."""
|
|
131
|
+
params = dict(params or {})
|
|
132
|
+
pages = 0
|
|
133
|
+
while True:
|
|
134
|
+
page = self.request(path, params, auth=auth)
|
|
135
|
+
yield from page.get("data", [])
|
|
136
|
+
pages += 1
|
|
137
|
+
cursor = (page.get("meta") or {}).get("next_cursor")
|
|
138
|
+
if not cursor or (max_pages is not None and pages >= max_pages):
|
|
139
|
+
return
|
|
140
|
+
params["cursor"] = cursor
|
|
141
|
+
|
|
142
|
+
# ------------------------------------------------------------ keyless demo
|
|
143
|
+
def demo_projections(self, **params: Any) -> Dict[str, Any]:
|
|
144
|
+
"""Fictional sample rows with the live schema. No key, no quota."""
|
|
145
|
+
return self.request("/api/v1/demo/projections", params, auth=False)
|
|
146
|
+
|
|
147
|
+
def demo_history(self, projection_id: str, **params: Any) -> Dict[str, Any]:
|
|
148
|
+
return self.request(f"/api/v1/demo/projections/{quote(projection_id, safe='')}/history", params, auth=False)
|
|
149
|
+
|
|
150
|
+
def demo_changes(self, **params: Any) -> Dict[str, Any]:
|
|
151
|
+
return self.request("/api/v1/demo/changes", params, auth=False)
|
|
152
|
+
|
|
153
|
+
def demo_payouts(self) -> Dict[str, Any]:
|
|
154
|
+
return self.request("/api/v1/demo/payouts", auth=False)
|
|
155
|
+
|
|
156
|
+
def demo_coverage(self) -> Dict[str, Any]:
|
|
157
|
+
return self.request("/api/v1/demo/coverage", auth=False)
|
|
158
|
+
|
|
159
|
+
def demo_export_csv(self, **params: Any) -> str:
|
|
160
|
+
return self.request("/api/v1/demo/export", params, auth=False, raw=True)
|
|
161
|
+
|
|
162
|
+
def credit_cost(self, path: str) -> Dict[str, Any]:
|
|
163
|
+
"""Quote the credit cost of a request path, for example '/api/v1/projections?league=NBA&limit=200'. No key."""
|
|
164
|
+
return self.request("/api/v1/credit-costs", {"path": path}, auth=False)
|
|
165
|
+
|
|
166
|
+
# ------------------------------------------------------------- live (key)
|
|
167
|
+
def projections(self, **params: Any) -> Dict[str, Any]:
|
|
168
|
+
"""Current board. Filters: league, search, stat, tier, status, start_after, start_before, limit, cursor, view."""
|
|
169
|
+
return self.request("/api/v1/projections", params)
|
|
170
|
+
|
|
171
|
+
def iter_projections(self, *, max_pages: Optional[int] = None, **params: Any) -> Iterator[Dict[str, Any]]:
|
|
172
|
+
return self.paginate("/api/v1/projections", params, max_pages=max_pages)
|
|
173
|
+
|
|
174
|
+
def history(self, projection_id: str, **params: Any) -> Dict[str, Any]:
|
|
175
|
+
"""Every recorded change for one projection."""
|
|
176
|
+
return self.request(f"/api/v1/projections/{quote(projection_id, safe='')}/history", params)
|
|
177
|
+
|
|
178
|
+
def trending(self, **params: Any) -> Dict[str, Any]:
|
|
179
|
+
return self.request("/api/v1/projections/trending", params)
|
|
180
|
+
|
|
181
|
+
def changes(self, **params: Any) -> Dict[str, Any]:
|
|
182
|
+
"""Change feed: new, moved, pulled and restored lines. Resume with cursor."""
|
|
183
|
+
return self.request("/api/v1/changes", params)
|
|
184
|
+
|
|
185
|
+
def board(self, as_of: str, **params: Any) -> Dict[str, Any]:
|
|
186
|
+
"""The board as it stood at ``as_of`` (ISO 8601)."""
|
|
187
|
+
return self.request("/api/v1/board", {"as_of": as_of, **params})
|
|
188
|
+
|
|
189
|
+
def results(self, **params: Any) -> Dict[str, Any]:
|
|
190
|
+
"""Graded lines: line at game time, actual stat and over, under, push or void."""
|
|
191
|
+
return self.request("/api/v1/results", params)
|
|
192
|
+
|
|
193
|
+
def hit_rates(self, **params: Any) -> Dict[str, Any]:
|
|
194
|
+
return self.request("/api/v1/hit-rates", params)
|
|
195
|
+
|
|
196
|
+
def game_logs(self, **params: Any) -> Dict[str, Any]:
|
|
197
|
+
return self.request("/api/v1/game-logs", params)
|
|
198
|
+
|
|
199
|
+
def training_set(self, **params: Any) -> Dict[str, Any]:
|
|
200
|
+
return self.request("/api/v1/training-set", params)
|
|
201
|
+
|
|
202
|
+
def coverage(self) -> Dict[str, Any]:
|
|
203
|
+
return self.request("/api/v1/coverage")
|
|
204
|
+
|
|
205
|
+
def payouts(self) -> Dict[str, Any]:
|
|
206
|
+
return self.request("/api/v1/payouts")
|
|
207
|
+
|
|
208
|
+
def compare(self, **params: Any) -> Dict[str, Any]:
|
|
209
|
+
"""PrizePicks lines next to matched sportsbook and exchange quotes (NBA, NFL, NHL where matched)."""
|
|
210
|
+
return self.request("/api/v1/compare", params)
|
|
211
|
+
|
|
212
|
+
def players(self, **params: Any) -> Dict[str, Any]:
|
|
213
|
+
return self.request("/api/v1/players", params)
|
|
214
|
+
|
|
215
|
+
def fixtures(self, **params: Any) -> Dict[str, Any]:
|
|
216
|
+
return self.request("/api/v1/fixtures", params)
|
|
217
|
+
|
|
218
|
+
def export_csv(self, **params: Any) -> str:
|
|
219
|
+
"""One CSV page (up to 200 rows) of current projections with the same filters as projections()."""
|
|
220
|
+
return self.request("/api/v1/export", params, raw=True)
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, Optional
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class PrizePicksAPIError(Exception):
|
|
7
|
+
"""Base class for every error raised by this package."""
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class TransportError(PrizePicksAPIError):
|
|
11
|
+
"""The request never got an HTTP response (DNS, TLS, timeout)."""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class APIError(PrizePicksAPIError):
|
|
15
|
+
"""A non-2xx response. ``code`` is the API's stable error code, for example INVALID_API_KEY."""
|
|
16
|
+
|
|
17
|
+
def __init__(self, status: int, code: str, message: str, *, retry_after: Optional[float] = None, body: Any = None) -> None:
|
|
18
|
+
super().__init__(f"{status} {code}: {message}")
|
|
19
|
+
self.status = status
|
|
20
|
+
self.code = code
|
|
21
|
+
self.message = message
|
|
22
|
+
self.retry_after = retry_after
|
|
23
|
+
self.body = body
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class RateLimitError(APIError):
|
|
27
|
+
"""HTTP 429. Per-minute limits are retried automatically; a monthly quota is not."""
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: prizepicksapi
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python client for prizepicksapi.com: live PrizePicks lines, goblin and demon tiers, line history and graded results.
|
|
5
|
+
Author-email: Stunning Couches LLC <support@prizepicksapi.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Repository, https://github.com/prizepicksapi/prizepicksapi-python
|
|
8
|
+
Project-URL: Issues, https://github.com/prizepicksapi/prizepicksapi-python/issues
|
|
9
|
+
Project-URL: Homepage, https://prizepicksapi.com
|
|
10
|
+
Project-URL: Documentation, https://prizepicksapi.com/docs
|
|
11
|
+
Project-URL: API reference, https://prizepicksapi.com/openapi.json
|
|
12
|
+
Project-URL: Pricing, https://prizepicksapi.com/pricing
|
|
13
|
+
Keywords: prizepicks,player props,dfs,pick'em,sports data,api
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Operating System :: OS Independent
|
|
17
|
+
Classifier: Intended Audience :: Developers
|
|
18
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
19
|
+
Requires-Python: >=3.9
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
License-File: LICENSE
|
|
22
|
+
Dynamic: license-file
|
|
23
|
+
|
|
24
|
+
# prizepicksapi (Python)
|
|
25
|
+
|
|
26
|
+
Python client for [prizepicksapi.com](https://prizepicksapi.com): live PrizePicks player-prop lines, goblin and demon tiers on each selection, recorded line history, the change feed, graded results and game logs. Standard library only, Python 3.9+.
|
|
27
|
+
|
|
28
|
+
prizepicksapi.com is an independent service operated by Stunning Couches LLC. It is not affiliated with or endorsed by PrizePicks.
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
pip install prizepicksapi
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Try it with no key
|
|
35
|
+
|
|
36
|
+
The `demo_*` methods call the keyless demo endpoints. They return fictional rows (`is_demo: true`) with the same fields as the live board, so you can write your parser first.
|
|
37
|
+
|
|
38
|
+
```python
|
|
39
|
+
from prizepicksapi import PrizePicksAPI
|
|
40
|
+
|
|
41
|
+
api = PrizePicksAPI(api_key="")
|
|
42
|
+
for p in api.demo_projections(league="NBA", limit=3)["data"]:
|
|
43
|
+
print(p["player"]["name"], p["market"]["stat"], p["line"], p["selections"][0]["tier"], "opened", p["line_context"]["opening_line"])
|
|
44
|
+
|
|
45
|
+
for row in api.demo_payouts()["data"][:2]:
|
|
46
|
+
print(row["entry_type"], row["picks"], "break-even", row["derived"]["break_even_probability"])
|
|
47
|
+
|
|
48
|
+
print(api.credit_cost("/api/v1/projections?league=NBA&limit=200")["quoted_credits"])
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Output on 15 Nov 2026:
|
|
52
|
+
|
|
53
|
+
```
|
|
54
|
+
Jordan Ellis Points 27.5 standard opened 26.5
|
|
55
|
+
Marcus Cole Rebounds 8.5 standard opened 7.5
|
|
56
|
+
Devin Brooks Assists 6.5 standard opened 5.5
|
|
57
|
+
power 2 break-even 0.57735
|
|
58
|
+
power 3 break-even 0.55032
|
|
59
|
+
6
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## Live data
|
|
63
|
+
|
|
64
|
+
Get a free key (100 credits a month, no card) at https://prizepicksapi.com/sign-up and export it:
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
export PRIZEPICKS_API_KEY=...
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
```python
|
|
71
|
+
from prizepicksapi import PrizePicksAPI
|
|
72
|
+
|
|
73
|
+
api = PrizePicksAPI(user_request="NBA points model: pull today's board") # optional X-User-Request header
|
|
74
|
+
|
|
75
|
+
# Every current NBA points line, following cursors for you
|
|
76
|
+
for p in api.iter_projections(league="NBA", stat="Points", limit=200):
|
|
77
|
+
tiers = {s["side"]: s["tier"] for s in p["selections"]}
|
|
78
|
+
print(p["player"]["name"], p["line"], tiers)
|
|
79
|
+
|
|
80
|
+
# Only goblin lines, compact rows (up to 1,000 per page)
|
|
81
|
+
goblins = api.projections(league="NBA", tier="goblin", view="compact", limit=1000)
|
|
82
|
+
|
|
83
|
+
# Recorded moves for one projection
|
|
84
|
+
history = api.history(goblins["data"][0]["id"])
|
|
85
|
+
|
|
86
|
+
# Change feed: new, moved, pulled and restored lines. Store meta.next_cursor and resume from it.
|
|
87
|
+
feed = api.changes(league="NBA", limit=200)
|
|
88
|
+
|
|
89
|
+
# Graded results and hit rates
|
|
90
|
+
graded = api.results(league="NBA", date="2026-11-14")
|
|
91
|
+
rates = api.hit_rates(player="Victor Wembanyama", stat="Points")
|
|
92
|
+
|
|
93
|
+
# The board as it stood at a past moment
|
|
94
|
+
past = api.board(as_of="2026-11-10T23:00:00Z", league="NBA")
|
|
95
|
+
|
|
96
|
+
# CSV page for a spreadsheet
|
|
97
|
+
csv_text = api.export_csv(league="NBA", limit=200)
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
## Errors and limits
|
|
101
|
+
|
|
102
|
+
Non-2xx responses raise `APIError` with the API's stable `code` (for example `INVALID_API_KEY`). HTTP 429 raises `RateLimitError`. Per-minute limits (`RATE_LIMIT_EXCEEDED`) and 503s are retried up to `max_retries` times using the `Retry-After` header; a monthly quota (`MONTHLY_QUOTA_EXCEEDED`) is raised at once.
|
|
103
|
+
|
|
104
|
+
```python
|
|
105
|
+
from prizepicksapi import APIError, RateLimitError
|
|
106
|
+
|
|
107
|
+
try:
|
|
108
|
+
api.projections(league="NBA")
|
|
109
|
+
except RateLimitError as e:
|
|
110
|
+
print("limited:", e.code, e.retry_after)
|
|
111
|
+
except APIError as e:
|
|
112
|
+
print(e.status, e.code, e.message)
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
## Methods
|
|
116
|
+
|
|
117
|
+
| Method | Endpoint | Key |
|
|
118
|
+
|---|---|---|
|
|
119
|
+
| `demo_projections`, `demo_history`, `demo_changes`, `demo_payouts`, `demo_coverage`, `demo_export_csv` | `/api/v1/demo/*` | no |
|
|
120
|
+
| `credit_cost(path)` | `/api/v1/credit-costs` | no |
|
|
121
|
+
| `projections`, `iter_projections` | `/api/v1/projections` | yes |
|
|
122
|
+
| `history(id)` | `/api/v1/projections/{id}/history` | yes |
|
|
123
|
+
| `trending` | `/api/v1/projections/trending` | yes |
|
|
124
|
+
| `changes` | `/api/v1/changes` | yes |
|
|
125
|
+
| `board(as_of)` | `/api/v1/board` | yes |
|
|
126
|
+
| `results`, `hit_rates`, `game_logs`, `training_set` | `/api/v1/results` etc. | yes |
|
|
127
|
+
| `coverage`, `payouts`, `compare`, `players`, `fixtures` | `/api/v1/*` | yes |
|
|
128
|
+
| `export_csv` | `/api/v1/export` | yes |
|
|
129
|
+
| `request(path, params)`, `paginate(path, params)` | any GET endpoint | |
|
|
130
|
+
|
|
131
|
+
Full reference: https://prizepicksapi.com/docs and https://prizepicksapi.com/openapi.json. Plans: https://prizepicksapi.com/pricing.
|
|
132
|
+
|
|
133
|
+
## Tests
|
|
134
|
+
|
|
135
|
+
```bash
|
|
136
|
+
python3 -m unittest discover -s tests # mocked transport
|
|
137
|
+
PRIZEPICKS_LIVE_TEST=1 python3 -m unittest discover -s tests # also calls the keyless demo endpoint
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
## License
|
|
141
|
+
|
|
142
|
+
MIT.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
src/prizepicksapi/__init__.py
|
|
5
|
+
src/prizepicksapi/client.py
|
|
6
|
+
src/prizepicksapi/errors.py
|
|
7
|
+
src/prizepicksapi.egg-info/PKG-INFO
|
|
8
|
+
src/prizepicksapi.egg-info/SOURCES.txt
|
|
9
|
+
src/prizepicksapi.egg-info/dependency_links.txt
|
|
10
|
+
src/prizepicksapi.egg-info/top_level.txt
|
|
11
|
+
tests/test_client.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
prizepicksapi
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
import sys
|
|
4
|
+
import unittest
|
|
5
|
+
from urllib.parse import parse_qs, urlparse
|
|
6
|
+
|
|
7
|
+
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src"))
|
|
8
|
+
|
|
9
|
+
from prizepicksapi import APIError, PrizePicksAPI, RateLimitError # noqa: E402
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class FakeTransport:
|
|
13
|
+
def __init__(self, responses):
|
|
14
|
+
self.responses = list(responses)
|
|
15
|
+
self.calls = []
|
|
16
|
+
|
|
17
|
+
def __call__(self, method, url, headers, timeout):
|
|
18
|
+
self.calls.append((method, url, headers))
|
|
19
|
+
status, body, headers_out = self.responses.pop(0)
|
|
20
|
+
raw = body.encode() if isinstance(body, str) else json.dumps(body).encode()
|
|
21
|
+
return status, headers_out, raw
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def page(rows, cursor=None):
|
|
25
|
+
return {"data": rows, "meta": {"next_cursor": cursor}}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class ClientTests(unittest.TestCase):
|
|
29
|
+
def test_demo_needs_no_key(self):
|
|
30
|
+
t = FakeTransport([(200, page([{"id": "demo_nba_001"}]), {})])
|
|
31
|
+
api = PrizePicksAPI(api_key="", transport=t)
|
|
32
|
+
out = api.demo_projections(league="NBA", limit=2)
|
|
33
|
+
self.assertEqual(out["data"][0]["id"], "demo_nba_001")
|
|
34
|
+
_, url, headers = t.calls[0]
|
|
35
|
+
self.assertNotIn("Authorization", headers)
|
|
36
|
+
self.assertEqual(urlparse(url).path, "/api/v1/demo/projections")
|
|
37
|
+
self.assertEqual(parse_qs(urlparse(url).query), {"league": ["NBA"], "limit": ["2"]})
|
|
38
|
+
|
|
39
|
+
def test_live_sends_bearer_and_user_request(self):
|
|
40
|
+
t = FakeTransport([(200, page([]), {})])
|
|
41
|
+
api = PrizePicksAPI(api_key="pk_test", user_request="NBA dashboard check", transport=t)
|
|
42
|
+
api.projections(league=["NBA", "NFL"], view="compact", tier=None)
|
|
43
|
+
_, url, headers = t.calls[0]
|
|
44
|
+
self.assertEqual(headers["Authorization"], "Bearer pk_test")
|
|
45
|
+
self.assertEqual(headers["X-User-Request"], "NBA dashboard check")
|
|
46
|
+
self.assertEqual(parse_qs(urlparse(url).query), {"league": ["NBA,NFL"], "view": ["compact"]})
|
|
47
|
+
|
|
48
|
+
def test_missing_key_raises_before_request(self):
|
|
49
|
+
t = FakeTransport([])
|
|
50
|
+
api = PrizePicksAPI(api_key="", transport=t)
|
|
51
|
+
with self.assertRaises(APIError) as ctx:
|
|
52
|
+
api.projections()
|
|
53
|
+
self.assertEqual(ctx.exception.code, "INVALID_API_KEY")
|
|
54
|
+
self.assertEqual(t.calls, [])
|
|
55
|
+
|
|
56
|
+
def test_paginate_follows_cursor_and_keeps_filters(self):
|
|
57
|
+
t = FakeTransport([(200, page([{"id": 1}, {"id": 2}], "c1"), {}), (200, page([{"id": 3}]), {})])
|
|
58
|
+
api = PrizePicksAPI(api_key="k", transport=t)
|
|
59
|
+
rows = list(api.iter_projections(league="NBA", limit=2))
|
|
60
|
+
self.assertEqual([r["id"] for r in rows], [1, 2, 3])
|
|
61
|
+
q2 = parse_qs(urlparse(t.calls[1][1]).query)
|
|
62
|
+
self.assertEqual(q2, {"league": ["NBA"], "limit": ["2"], "cursor": ["c1"]})
|
|
63
|
+
|
|
64
|
+
def test_api_error_code(self):
|
|
65
|
+
body = {"error": {"code": "INVALID_API_KEY", "message": "Provide a valid API key"}}
|
|
66
|
+
t = FakeTransport([(401, body, {})])
|
|
67
|
+
api = PrizePicksAPI(api_key="bad", transport=t)
|
|
68
|
+
with self.assertRaises(APIError) as ctx:
|
|
69
|
+
api.coverage()
|
|
70
|
+
self.assertEqual((ctx.exception.status, ctx.exception.code), (401, "INVALID_API_KEY"))
|
|
71
|
+
|
|
72
|
+
def test_per_minute_429_is_retried_with_retry_after(self):
|
|
73
|
+
slept = []
|
|
74
|
+
limited = {"error": {"code": "RATE_LIMIT_EXCEEDED", "message": "slow down"}}
|
|
75
|
+
t = FakeTransport([(429, limited, {"Retry-After": "7"}), (200, page([]), {})])
|
|
76
|
+
api = PrizePicksAPI(api_key="k", transport=t, sleep=slept.append)
|
|
77
|
+
api.changes(league="NBA")
|
|
78
|
+
self.assertEqual(slept, [7.0])
|
|
79
|
+
self.assertEqual(len(t.calls), 2)
|
|
80
|
+
|
|
81
|
+
def test_monthly_quota_is_not_retried(self):
|
|
82
|
+
quota = {"error": {"code": "MONTHLY_QUOTA_EXCEEDED", "message": "monthly limit"}}
|
|
83
|
+
t = FakeTransport([(429, quota, {"Retry-After": "60"})])
|
|
84
|
+
api = PrizePicksAPI(api_key="k", transport=t, sleep=lambda s: None)
|
|
85
|
+
with self.assertRaises(RateLimitError):
|
|
86
|
+
api.projections()
|
|
87
|
+
self.assertEqual(len(t.calls), 1)
|
|
88
|
+
|
|
89
|
+
def test_history_path_is_encoded(self):
|
|
90
|
+
t = FakeTransport([(200, page([]), {})])
|
|
91
|
+
PrizePicksAPI(api_key="k", transport=t).history("abc/1")
|
|
92
|
+
self.assertTrue(t.calls[0][1].startswith("https://prizepicksapi.com/api/v1/projections/abc%2F1/history"))
|
|
93
|
+
|
|
94
|
+
def test_export_returns_csv_text(self):
|
|
95
|
+
t = FakeTransport([(200, "id,player\n1,A\n", {})])
|
|
96
|
+
csv = PrizePicksAPI(api_key="", transport=t).demo_export_csv(league="NBA", limit=1)
|
|
97
|
+
self.assertTrue(csv.startswith("id,player"))
|
|
98
|
+
self.assertEqual(t.calls[0][2]["Accept"], "text/csv")
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@unittest.skipUnless(os.environ.get("PRIZEPICKS_LIVE_TEST"), "set PRIZEPICKS_LIVE_TEST=1 to call the keyless demo endpoint")
|
|
102
|
+
class LiveDemoTest(unittest.TestCase):
|
|
103
|
+
def test_demo_projections_live(self):
|
|
104
|
+
out = PrizePicksAPI(api_key="").demo_projections(league="NBA", limit=2)
|
|
105
|
+
self.assertTrue(out["meta"]["is_demo"])
|
|
106
|
+
self.assertEqual(len(out["data"]), 2)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
if __name__ == "__main__":
|
|
110
|
+
unittest.main()
|