esportsodds 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.
- esportsodds-0.1.0/.gitignore +15 -0
- esportsodds-0.1.0/CHANGELOG.md +27 -0
- esportsodds-0.1.0/LICENSE +21 -0
- esportsodds-0.1.0/PKG-INFO +385 -0
- esportsodds-0.1.0/README.md +351 -0
- esportsodds-0.1.0/examples/backfill_matches.py +61 -0
- esportsodds-0.1.0/examples/follow_changes.py +70 -0
- esportsodds-0.1.0/examples/handle_errors.py +51 -0
- esportsodds-0.1.0/examples/quickstart.py +11 -0
- esportsodds-0.1.0/examples/team_report.py +46 -0
- esportsodds-0.1.0/examples/upcoming_with_odds.py +29 -0
- esportsodds-0.1.0/examples/watch_matches.py +83 -0
- esportsodds-0.1.0/pyproject.toml +114 -0
- esportsodds-0.1.0/src/esportsodds/__init__.py +66 -0
- esportsodds-0.1.0/src/esportsodds/_client.py +631 -0
- esportsodds-0.1.0/src/esportsodds/_errors.py +261 -0
- esportsodds-0.1.0/src/esportsodds/_headers.py +74 -0
- esportsodds-0.1.0/src/esportsodds/_pagination.py +102 -0
- esportsodds-0.1.0/src/esportsodds/_resources.py +1041 -0
- esportsodds-0.1.0/src/esportsodds/_routes.py +161 -0
- esportsodds-0.1.0/src/esportsodds/_time.py +76 -0
- esportsodds-0.1.0/src/esportsodds/_transport.py +157 -0
- esportsodds-0.1.0/src/esportsodds/_version.py +3 -0
- esportsodds-0.1.0/src/esportsodds/py.typed +0 -0
- esportsodds-0.1.0/tests/__init__.py +0 -0
- esportsodds-0.1.0/tests/conftest.py +192 -0
- esportsodds-0.1.0/tests/localserver.py +116 -0
- esportsodds-0.1.0/tests/readme_blocks.py +92 -0
- esportsodds-0.1.0/tests/test_changes.py +301 -0
- esportsodds-0.1.0/tests/test_client.py +660 -0
- esportsodds-0.1.0/tests/test_deadline.py +62 -0
- esportsodds-0.1.0/tests/test_errors.py +275 -0
- esportsodds-0.1.0/tests/test_examples.py +691 -0
- esportsodds-0.1.0/tests/test_headers.py +77 -0
- esportsodds-0.1.0/tests/test_integration.py +210 -0
- esportsodds-0.1.0/tests/test_package.py +86 -0
- esportsodds-0.1.0/tests/test_pagination.py +194 -0
- esportsodds-0.1.0/tests/test_readme.py +932 -0
- esportsodds-0.1.0/tests/test_readme_claims.py +300 -0
- esportsodds-0.1.0/tests/test_resources.py +532 -0
- esportsodds-0.1.0/tests/test_retries.py +375 -0
- esportsodds-0.1.0/tests/test_time.py +142 -0
- esportsodds-0.1.0/tests/test_transport.py +251 -0
- esportsodds-0.1.0/tests/test_websocket.py +85 -0
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# Build and tooling artifacts. This file ships with the public mirror, and it also covers the
|
|
2
|
+
# monorepo copy (the root .gitignore only knows model-lab's Python artifacts).
|
|
3
|
+
.venv/
|
|
4
|
+
venv/
|
|
5
|
+
build/
|
|
6
|
+
dist/
|
|
7
|
+
*.egg-info/
|
|
8
|
+
__pycache__/
|
|
9
|
+
*.pyc
|
|
10
|
+
.pytest_cache/
|
|
11
|
+
.mypy_cache/
|
|
12
|
+
.ruff_cache/
|
|
13
|
+
.coverage
|
|
14
|
+
htmlcov/
|
|
15
|
+
.DS_Store
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this package are recorded here. The format follows
|
|
4
|
+
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and versions follow
|
|
5
|
+
[Semantic Versioning](https://semver.org/spec/v2.0.0.html). Until 1.0, a minor version may change
|
|
6
|
+
the API; the change will be listed here first.
|
|
7
|
+
|
|
8
|
+
## [Unreleased]
|
|
9
|
+
|
|
10
|
+
## [0.1.0] - 2026-10-08
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- `Client`, with one method for each public endpoint of the esportsodds.gg CS2 API: matches (with
|
|
15
|
+
maps, stats, vetoes, rounds, history, depth, lineups and streams), odds, teams, tournaments,
|
|
16
|
+
players, rankings, coverage and model metrics, plus `health()`.
|
|
17
|
+
- Pagination: every list call returns a `Page`, and `pages()` / `iter()` walk a whole list lazily,
|
|
18
|
+
one request per page. The bulk export has `pages_bulk()` / `iter_bulk()`; `matches.iter_changes()`
|
|
19
|
+
follows the change feed with its overlap handled for you; `matches.iter_history()` walks a
|
|
20
|
+
match's revisions.
|
|
21
|
+
- Errors: a typed exception for every API error code, all subclasses of `EsportsOddsError`, each
|
|
22
|
+
carrying the status, code, request id and `Retry-After`.
|
|
23
|
+
- Retries for what is safe to retry (`429 rate_limited`, `503`, `502`, `504`, network failures and,
|
|
24
|
+
at most twice, `500`), never for `quota_exceeded`. `client.last_response` holds the request id and
|
|
25
|
+
the request budget of the latest response.
|
|
26
|
+
- `ws_token()` and `ws_url()` for the WebSocket; the package does not open the socket itself.
|
|
27
|
+
- Standard library only, Python 3.10 and newer, typed (`py.typed`, checked with `mypy --strict`).
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Sellframe Ltd
|
|
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,385 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: esportsodds
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python client for the esportsodds.gg CS2 (Counter-Strike 2) match data and stats API.
|
|
5
|
+
Project-URL: Homepage, https://esportsodds.gg/cs2-api
|
|
6
|
+
Project-URL: Documentation, https://docs.esportsodds.gg/docs
|
|
7
|
+
Project-URL: Repository, https://github.com/esportsodds/esportsodds-python
|
|
8
|
+
Project-URL: Changelog, https://github.com/esportsodds/esportsodds-python/blob/main/CHANGELOG.md
|
|
9
|
+
Project-URL: Issues, https://github.com/esportsodds/esportsodds-python/issues
|
|
10
|
+
Author: Sellframe Ltd
|
|
11
|
+
License-Expression: MIT
|
|
12
|
+
License-File: LICENSE
|
|
13
|
+
Keywords: api-client,counter-strike,counter-strike-2,cs2,cs2-api,cs2-stats,csgo,esports,esports-api,esports-data,match-data,sdk
|
|
14
|
+
Classifier: Development Status :: 4 - Beta
|
|
15
|
+
Classifier: Intended Audience :: Developers
|
|
16
|
+
Classifier: Operating System :: OS Independent
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
23
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
24
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
25
|
+
Classifier: Typing :: Typed
|
|
26
|
+
Requires-Python: >=3.10
|
|
27
|
+
Provides-Extra: dev
|
|
28
|
+
Requires-Dist: build>=1.2; extra == 'dev'
|
|
29
|
+
Requires-Dist: mypy>=1.11; extra == 'dev'
|
|
30
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
31
|
+
Requires-Dist: ruff>=0.6; extra == 'dev'
|
|
32
|
+
Requires-Dist: twine>=6.1; extra == 'dev'
|
|
33
|
+
Description-Content-Type: text/markdown
|
|
34
|
+
|
|
35
|
+
# esportsodds
|
|
36
|
+
|
|
37
|
+
**The official Python client for the [esportsodds.gg](https://esportsodds.gg/cs2-api) CS2 data API.**
|
|
38
|
+
|
|
39
|
+
Counter-Strike 2 matches and results, team and player stats, tournaments, rankings and a derived
|
|
40
|
+
market line, returned as plain Python dicts. Standard library only: there is nothing else to install.
|
|
41
|
+
|
|
42
|
+
Get CS2 match data and stats without scraping HLTV. This package is a client for a paid API; it is
|
|
43
|
+
not an official HLTV/Valve API.
|
|
44
|
+
|
|
45
|
+
## Install
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
pip install esportsodds
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Python 3.10 or newer. No dependencies.
|
|
52
|
+
|
|
53
|
+
## Quickstart
|
|
54
|
+
|
|
55
|
+
```python
|
|
56
|
+
from esportsodds import Client
|
|
57
|
+
|
|
58
|
+
client = Client() # reads ESPORTSODDS_API_KEY from the environment
|
|
59
|
+
|
|
60
|
+
for match in client.matches.list(status="scheduled", limit=5).data:
|
|
61
|
+
print(match["scheduled_at"], match["team_a_name"], "vs", match["team_b_name"])
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
```text
|
|
65
|
+
2026-10-08T14:00:00Z Team A vs Team B
|
|
66
|
+
2026-10-08T17:30:00Z Team C vs Team D
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Every call returns what the API sent, as ordinary dicts and lists. A list call returns a `Page`
|
|
70
|
+
(`page.data`, `page.next_cursor`, `page.has_more`, `page.meta`); a call for one thing returns a dict.
|
|
71
|
+
|
|
72
|
+
## Your API key and the plan
|
|
73
|
+
|
|
74
|
+
There is one plan: **$49 a month for 50,000 requests**, per account, counted per calendar month
|
|
75
|
+
(UTC) and paid up front. There is no free tier and no trial: a key exists only for a paid account.
|
|
76
|
+
Past the 50,000, requests get a `429` with the code `quota_exceeded` until the month resets, and
|
|
77
|
+
this client raises `QuotaExceededError` without retrying (retrying cannot help).
|
|
78
|
+
|
|
79
|
+
Create a key in your account after you [subscribe](https://esportsodds.gg/pricing), then keep it
|
|
80
|
+
out of your code:
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
export ESPORTSODDS_API_KEY="eo_live_..."
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
- The client sends the key only in the `Authorization: Bearer` header, never in a URL. It shows
|
|
87
|
+
only the last four characters in `repr()` and never puts the key in an error message or a log line.
|
|
88
|
+
- It talks HTTPS only. Plain `http://` is accepted for `localhost` and other loopback addresses
|
|
89
|
+
(`127.0.0.1`, `::1`) alone, and redirects are never followed, so a key cannot be sent to a
|
|
90
|
+
different host.
|
|
91
|
+
- Keep the key on your server. A browser or mobile app should call your backend, not the API.
|
|
92
|
+
- You can look at real responses before subscribing on the [public demo](https://demo.esportsodds.gg).
|
|
93
|
+
|
|
94
|
+
## Configuration
|
|
95
|
+
|
|
96
|
+
| Environment variable | Meaning |
|
|
97
|
+
| --- | --- |
|
|
98
|
+
| `ESPORTSODDS_API_KEY` | Your API key, used when `api_key` is not passed. |
|
|
99
|
+
| `ESPORTSODDS_BASE_URL` | The API address, used when `base_url` is not passed. |
|
|
100
|
+
|
|
101
|
+
| `Client(...)` argument | Default | Meaning |
|
|
102
|
+
| --- | --- | --- |
|
|
103
|
+
| `api_key` | `None` | Your key. When omitted, `ESPORTSODDS_API_KEY` is read; with neither, `ValueError`. |
|
|
104
|
+
| `base_url` | `https://api.esportsodds.gg` | The API address. HTTPS only, except `localhost` and other loopback addresses. |
|
|
105
|
+
| `game` | `"cs2"` | The title to query. CS2 is the only one with data today. |
|
|
106
|
+
| `timeout` | `30.0` | Seconds to wait for a reply. The bulk export always waits at least 120. |
|
|
107
|
+
| `max_retries` | `3` | Retries per call for the failures listed under [Errors](#errors). `0` turns retries off. |
|
|
108
|
+
| `transport` | `None` | Your own HTTP stack: any object with `send(TransportRequest) -> TransportResponse`. |
|
|
109
|
+
|
|
110
|
+
A `Client` is safe to share between threads. Use it as a context manager (`with Client() as client:`)
|
|
111
|
+
or call `client.close()` when you are finished: it closes a custom `transport` that has a `close()`
|
|
112
|
+
method (the default one keeps no connections open, so it has nothing to release).
|
|
113
|
+
|
|
114
|
+
## Methods
|
|
115
|
+
|
|
116
|
+
Every endpoint is one method. `{game}` is the client's `game`, and `{id}` accepts the UUID or, for
|
|
117
|
+
teams, players and tournaments, the slug.
|
|
118
|
+
|
|
119
|
+
| Call | Endpoint | Returns | What it gives you |
|
|
120
|
+
| --- | --- | --- | --- |
|
|
121
|
+
| `client.matches.list()` | `GET /v1/{game}/matches` | `Page` | Fixtures and results, newest first; filter by `status`, `tournament`, `team`, dates and `has`. |
|
|
122
|
+
| `client.matches.bulk()` | `GET /v1/{game}/matches/bulk` | `Page` | Matches with their maps, stats and more embedded (`include=`), 25 per request. |
|
|
123
|
+
| `client.matches.get(match)` | `GET /v1/{game}/matches/{id}` | `dict` | One match; `include=` embeds teams, tournament, odds or sub-resources. |
|
|
124
|
+
| `client.matches.maps(match)` | `GET /v1/{game}/matches/{id}/maps` | `Page` | Map-by-map results. |
|
|
125
|
+
| `client.matches.stats(match)` | `GET /v1/{game}/matches/{id}/stats` | `Page` | Per-player stat lines, whole match and per map. |
|
|
126
|
+
| `client.matches.vetoes(match)` | `GET /v1/{game}/matches/{id}/vetoes` | `Page` | The map pick and ban sequence. |
|
|
127
|
+
| `client.matches.rounds(match)` | `GET /v1/{game}/matches/{id}/rounds` | `Page` | Round-by-round team stats: economy, opening duels, clutches, bomb events. |
|
|
128
|
+
| `client.matches.history(match)` | `GET /v1/{game}/matches/{id}/history` | `Page` | What a match used to say: each change with its value before and after. |
|
|
129
|
+
| `client.matches.depth(match)` | `GET /v1/{game}/matches/{id}/depth` | `dict` | Per-player weapons, grenades, hit groups, duels and flashes. |
|
|
130
|
+
| `client.matches.lineups(match)` | `GET /v1/{game}/matches/{id}/lineups` | `Page` | Each team's listed roster before the match. |
|
|
131
|
+
| `client.matches.streams(match)` | `GET /v1/{game}/matches/{id}/streams` | `Page` | The channels broadcasting a match. |
|
|
132
|
+
| `client.odds.list()` | `GET /v1/{game}/odds` | `Page` | The derived market line, as history or as the current board. |
|
|
133
|
+
| `client.teams.list()` | `GET /v1/{game}/teams` | `Page` | Every team, alphabetically. |
|
|
134
|
+
| `client.teams.get(team)` | `GET /v1/{game}/teams/{id}` | `dict` | One team. |
|
|
135
|
+
| `client.teams.h2h(team, opponent=...)` | `GET /v1/{game}/teams/{id}/h2h` | `dict` | The head-to-head record between two teams. |
|
|
136
|
+
| `client.teams.form(team)` | `GET /v1/{game}/teams/{id}/form` | `Page` | Recent completed results, newest first. |
|
|
137
|
+
| `client.teams.ratings(team)` | `GET /v1/{game}/teams/{id}/ratings` | `Page` | The team's rating history, one point per match. |
|
|
138
|
+
| `client.teams.maps(team)` | `GET /v1/{game}/teams/{id}/maps` | `Page` | The team's record on each map. |
|
|
139
|
+
| `client.teams.round_stats(team)` | `GET /v1/{game}/teams/{id}/round-stats` | `dict` | Round-level playstyle rates: side win rates, pistols, opening duels. |
|
|
140
|
+
| `client.teams.roster_history(team)` | `GET /v1/{game}/teams/{id}/roster-history` | `Page` | The five players fielded in each recent match, with roster changes. |
|
|
141
|
+
| `client.tournaments.list()` | `GET /v1/{game}/tournaments` | `Page` | Every tournament, newest first. |
|
|
142
|
+
| `client.tournaments.get(tournament)` | `GET /v1/{game}/tournaments/{id}` | `dict` | One tournament. |
|
|
143
|
+
| `client.tournaments.teams(tournament)` | `GET /v1/{game}/tournaments/{id}/teams` | `Page` | The participating teams, by seed. |
|
|
144
|
+
| `client.players.list()` | `GET /v1/{game}/players` | `Page` | Every player, alphabetically by nickname. |
|
|
145
|
+
| `client.players.get(player)` | `GET /v1/{game}/players/{id}` | `dict` | One player. |
|
|
146
|
+
| `client.players.career(player)` | `GET /v1/{game}/players/{id}/stats` | `dict` | A player's career totals and averages. |
|
|
147
|
+
| `client.rankings.list()` | `GET /v1/{game}/rankings` | `Page` | A team or player leaderboard over a rolling three-month window. |
|
|
148
|
+
| `client.coverage.list()` | `GET /v1/{game}/coverage` | `Page` | What data exists and how often it refreshes. |
|
|
149
|
+
| `client.model.metrics()` | `GET /v1/{game}/model/metrics` | `Page` | Transparency metrics for the model line, per market. The model line is coming soon; the list may be empty. |
|
|
150
|
+
| `client.ws_token()` | `POST /v1/{game}/ws-token` | `dict` | A 60-second ticket for the [WebSocket](#websocket-match-updates). Costs no requests. |
|
|
151
|
+
| `client.health()` | `GET /health` | `dict` | The service status. Needs no key and is never retried. |
|
|
152
|
+
|
|
153
|
+
Filters are keyword arguments named as in the [API reference](https://docs.esportsodds.gg/docs).
|
|
154
|
+
A list given for a comma-separated filter (`has=["rounds", "depth"]`, `include=["maps", "stats"]`)
|
|
155
|
+
is joined for you; a `datetime` or `date` is formatted for you (a naive `datetime` means UTC); an
|
|
156
|
+
unknown argument raises `TypeError` instead of being sent. Ids are checked and escaped before they
|
|
157
|
+
go into a URL.
|
|
158
|
+
|
|
159
|
+
The odds are our own derived lines (`source="eo_market"`), not any bookmaker's price. The model
|
|
160
|
+
line (`source="eo_model"`) is coming soon and is not returned yet.
|
|
161
|
+
|
|
162
|
+
## Pagination, the bulk export and the change feed
|
|
163
|
+
|
|
164
|
+
List calls return one page and a cursor. Walk them yourself:
|
|
165
|
+
|
|
166
|
+
```python
|
|
167
|
+
page = client.matches.list(status="completed", limit=100)
|
|
168
|
+
while True:
|
|
169
|
+
for match in page.data:
|
|
170
|
+
print(match["id"])
|
|
171
|
+
if not page.has_more:
|
|
172
|
+
break
|
|
173
|
+
page = client.matches.list(status="completed", limit=100, cursor=page.next_cursor)
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
or let the client do it. `iter()` yields rows, `pages()` yields pages, and both fetch lazily, one
|
|
177
|
+
request per page, so stopping early costs nothing more. `max_pages` is a safety limit; the last page
|
|
178
|
+
yielded still carries its `next_cursor`, so a job that stops (or crashes) can resume with `cursor=`.
|
|
179
|
+
|
|
180
|
+
```python
|
|
181
|
+
for match in client.matches.iter(status="completed", date_from="2026-09-01"):
|
|
182
|
+
print(match["id"])
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
`iter` and `pages` exist on `matches`, `odds`, `teams`, `tournaments` and `players`.
|
|
186
|
+
|
|
187
|
+
**The bulk export** is the cheap way to pull history: 25 matches per request, each with the
|
|
188
|
+
sub-resources you ask for embedded, and one request against your allowance per page.
|
|
189
|
+
|
|
190
|
+
```python
|
|
191
|
+
for match in client.matches.iter_bulk(status="completed", include=["maps", "stats"]):
|
|
192
|
+
print(match["id"], len(match["maps"]))
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
A busy moment answers `503` with a short `Retry-After`; the client waits and retries for you.
|
|
196
|
+
|
|
197
|
+
**The change feed** keeps a local copy current. Give `iter_changes` the `changed_at` of the newest
|
|
198
|
+
row you stored last time and it yields every match that changed since, oldest change first:
|
|
199
|
+
|
|
200
|
+
```python
|
|
201
|
+
from datetime import datetime, timedelta, timezone
|
|
202
|
+
|
|
203
|
+
since = datetime.now(timezone.utc) - timedelta(hours=1)
|
|
204
|
+
newest = since.isoformat()
|
|
205
|
+
for match in client.matches.iter_changes(since):
|
|
206
|
+
newest = match["changed_at"] # rows arrive in changed_at order
|
|
207
|
+
print(match["id"], match["status"])
|
|
208
|
+
# Store `newest`; it is the `since` for the next run.
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
It starts ten minutes before `since` (`overlap=`) because a row can become visible shortly after the
|
|
212
|
+
`changed_at` it carries, and drops the repeats this causes. A quiet hour costs one request.
|
|
213
|
+
`matches.history(match)` and `matches.iter_history(match)` show what each of those changes was.
|
|
214
|
+
|
|
215
|
+
## Errors
|
|
216
|
+
|
|
217
|
+
Every error is an `EsportsOddsError`. A reply with an error status raises an `ApiError` subclass;
|
|
218
|
+
branch on the class, or on `error.code`, never on `error.message`.
|
|
219
|
+
|
|
220
|
+
| Exception | API `code` | Status | Retried automatically |
|
|
221
|
+
| --- | --- | --- | --- |
|
|
222
|
+
| `AuthenticationError` | `missing_api_key`, `invalid_api_key`, `unauthorized` | 401 | No |
|
|
223
|
+
| `PermissionDeniedError` | `forbidden` | 403 | No |
|
|
224
|
+
| `NotFoundError` | `not_found`, `unknown_game` | 404 | No |
|
|
225
|
+
| `InvalidRequestError` | `invalid_parameter` | 400 | No |
|
|
226
|
+
| `InvalidCursorError` | `invalid_cursor` | 400 | No: start the walk again from the first page |
|
|
227
|
+
| `ConflictError` | `conflict` | 409 | No |
|
|
228
|
+
| `RateLimitError` | `rate_limited` | 429 | Yes, after the `Retry-After` the API sends |
|
|
229
|
+
| `QuotaExceededError` | `quota_exceeded` | 429 | Never: nothing changes until the month resets |
|
|
230
|
+
| `ServiceUnavailableError` | `unavailable` | 503 | Yes, after the `Retry-After` the API sends |
|
|
231
|
+
| `ServerError` | `internal_error` | 500 | Yes, but at most twice |
|
|
232
|
+
| `ApiError` | any other | other | Only 502 and 504 |
|
|
233
|
+
| `TransportError` | none: the request never got an answer | none | Yes, with backoff |
|
|
234
|
+
| `InvalidResponseError` | none: the reply was not the shape the API documents | 2xx | No |
|
|
235
|
+
|
|
236
|
+
An unknown path id is a `404` (`NotFoundError`), not a `400`, and an unknown id on a sub-resource
|
|
237
|
+
such as `matches.maps(...)` returns an empty page rather than an error.
|
|
238
|
+
|
|
239
|
+
An `ApiError` carries `status`, `code`, `message`, `request_id`, `retry_after`, `headers` and `body`.
|
|
240
|
+
Quote `request_id` when you report a problem; it is also in `client.last_response.request_id`.
|
|
241
|
+
|
|
242
|
+
```python
|
|
243
|
+
from esportsodds import QuotaExceededError, RateLimitError
|
|
244
|
+
|
|
245
|
+
try:
|
|
246
|
+
client.matches.get("00000000-0000-7000-8000-000000000001")
|
|
247
|
+
except QuotaExceededError as error:
|
|
248
|
+
print("out of requests until", error.reset_at)
|
|
249
|
+
except RateLimitError as error:
|
|
250
|
+
print("slow down; retry in", error.retry_after, "seconds")
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
## Rate limits and retries
|
|
254
|
+
|
|
255
|
+
Two limits apply: a per-key token bucket (20 requests a second sustained, bursts of 100) and the
|
|
256
|
+
monthly allowance of 50,000 requests per account, shared by all of an account's keys. After every
|
|
257
|
+
call, `client.last_response.rate_limit` has the budget the API reported:
|
|
258
|
+
|
|
259
|
+
```python
|
|
260
|
+
info = client.last_response # None until the first call has been made
|
|
261
|
+
if info is not None:
|
|
262
|
+
budget = info.rate_limit
|
|
263
|
+
print(budget.quota_remaining, "of", budget.quota_limit, "left; resets", budget.quota_reset)
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
The client retries only what is safe to retry, up to `max_retries` times (3 by default), and logs
|
|
267
|
+
each retry at `INFO` on the `esportsodds` logger:
|
|
268
|
+
|
|
269
|
+
- A `429 rate_limited` or `503` waits the `Retry-After` the API sent, plus up to half a second of
|
|
270
|
+
jitter. A wait longer than a minute is raised instead of slept through.
|
|
271
|
+
- A `502` or `504` and a network failure back off from half a second, doubling, up to eight seconds,
|
|
272
|
+
with jitter.
|
|
273
|
+
- A `500` is retried at most twice however high `max_retries` is: the API counts it against your
|
|
274
|
+
allowance, so each retry spends a request you are paying for.
|
|
275
|
+
- A `429 quota_exceeded` is never retried, and nothing else is.
|
|
276
|
+
|
|
277
|
+
Polling is rarely the cheapest design: the [change feed](#pagination-the-bulk-export-and-the-change-feed)
|
|
278
|
+
and the WebSocket cost far fewer requests. The
|
|
279
|
+
[request budget guide](https://docs.esportsodds.gg/docs/how-to/request-budget) has the numbers.
|
|
280
|
+
|
|
281
|
+
## WebSocket (match updates)
|
|
282
|
+
|
|
283
|
+
The WebSocket pushes score and odds changes for the matches you subscribe to, and it costs no
|
|
284
|
+
requests: neither the ticket, nor the connection, nor the messages count against your allowance.
|
|
285
|
+
Updates arrive when our own collection sees a change, so the socket removes your polling delay but
|
|
286
|
+
cannot make the data newer than that collection; the guide linked below lists the typical ages.
|
|
287
|
+
|
|
288
|
+
This package does not open sockets itself (that would need a dependency). It mints the ticket and
|
|
289
|
+
builds the address; any WebSocket library connects, for example
|
|
290
|
+
[`websockets`](https://websockets.readthedocs.io/) (`pip install websockets`):
|
|
291
|
+
|
|
292
|
+
```python
|
|
293
|
+
import asyncio
|
|
294
|
+
import json
|
|
295
|
+
|
|
296
|
+
import websockets
|
|
297
|
+
|
|
298
|
+
from esportsodds import Client
|
|
299
|
+
|
|
300
|
+
client = Client()
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
async def watch(match_ids: list[str]) -> None:
|
|
304
|
+
ticket = await asyncio.to_thread(client.ws_token)
|
|
305
|
+
async with websockets.connect(client.ws_url(ticket)) as socket:
|
|
306
|
+
await socket.send(json.dumps({"type": "subscribe", "data": {"match_ids": match_ids}}))
|
|
307
|
+
async for raw in socket:
|
|
308
|
+
frame = json.loads(raw)
|
|
309
|
+
if frame["type"] in ("snapshot", "update"):
|
|
310
|
+
print(frame["match_id"], frame["seq"], frame["type"])
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
asyncio.run(watch(["00000000-0000-7000-8000-000000000001"]))
|
|
314
|
+
```
|
|
315
|
+
|
|
316
|
+
- A ticket lasts 60 seconds and is not single-use, so a retry inside that window can reuse it; mint
|
|
317
|
+
a new one once it has expired. Your key never appears in the socket address.
|
|
318
|
+
- A `snapshot` is the full current state of a match and an `update` is a change. `seq` counts up per
|
|
319
|
+
match; if one is skipped, subscribe to that match again to get a fresh `snapshot`.
|
|
320
|
+
- Reconnect with exponential backoff and jitter, and re-send your `subscribe`. There is no replay.
|
|
321
|
+
- A key may hold five connections at once, so subscribe to many matches on one connection.
|
|
322
|
+
- The server pings every 54 seconds; `websockets` answers for you.
|
|
323
|
+
|
|
324
|
+
The [WebSocket guide](https://docs.esportsodds.gg/docs/live-data) lists every frame type.
|
|
325
|
+
|
|
326
|
+
## Examples
|
|
327
|
+
|
|
328
|
+
The [`examples/`](https://github.com/esportsodds/esportsodds-python/tree/main/examples) folder has
|
|
329
|
+
runnable scripts. Each reads `ESPORTSODDS_API_KEY`.
|
|
330
|
+
|
|
331
|
+
| Script | What it does |
|
|
332
|
+
| --- | --- |
|
|
333
|
+
| [`quickstart.py`](https://github.com/esportsodds/esportsodds-python/blob/main/examples/quickstart.py) | The quickstart above. |
|
|
334
|
+
| [`upcoming_with_odds.py`](https://github.com/esportsodds/esportsodds-python/blob/main/examples/upcoming_with_odds.py) | Upcoming matches with their current market line. |
|
|
335
|
+
| [`team_report.py`](https://github.com/esportsodds/esportsodds-python/blob/main/examples/team_report.py) | A team's form, head-to-head record and ratings. |
|
|
336
|
+
| [`backfill_matches.py`](https://github.com/esportsodds/esportsodds-python/blob/main/examples/backfill_matches.py) | A resumable bulk pull of completed matches to a JSON Lines file. |
|
|
337
|
+
| [`follow_changes.py`](https://github.com/esportsodds/esportsodds-python/blob/main/examples/follow_changes.py) | Keeps a local file current from the change feed. |
|
|
338
|
+
| [`handle_errors.py`](https://github.com/esportsodds/esportsodds-python/blob/main/examples/handle_errors.py) | Reading the budget and handling each kind of error. |
|
|
339
|
+
| [`watch_matches.py`](https://github.com/esportsodds/esportsodds-python/blob/main/examples/watch_matches.py) | Match updates over the WebSocket. |
|
|
340
|
+
|
|
341
|
+
## FAQ
|
|
342
|
+
|
|
343
|
+
**Is this an official API?** No. It is not an official HLTV/Valve API, and esportsodds.gg is not
|
|
344
|
+
affiliated with or endorsed by Valve or any stats site.
|
|
345
|
+
|
|
346
|
+
**Why not scrape?** Scraping breaks whenever a page changes and many sites forbid it. With this API
|
|
347
|
+
you build without scraping HLTV or anyone else: you call stable, documented endpoints instead.
|
|
348
|
+
|
|
349
|
+
**Is there a free tier?** No. There is no free tier and no trial; the plan is $49 a month for
|
|
350
|
+
50,000 requests, paid up front. The [public demo](https://demo.esportsodds.gg) shows real
|
|
351
|
+
responses without an account.
|
|
352
|
+
|
|
353
|
+
**What does a call cost?** One request against the 50,000, whatever it returns. A bulk page, which
|
|
354
|
+
carries up to 25 matches with their detail, is also one request. A walker (`iter`, `pages`,
|
|
355
|
+
`iter_bulk`, `iter_changes`) costs one request per page it fetches. The WebSocket costs none.
|
|
356
|
+
|
|
357
|
+
**Does it work with asyncio?** The client is synchronous and safe to share between threads. From
|
|
358
|
+
async code, run a call in a thread:
|
|
359
|
+
|
|
360
|
+
```python
|
|
361
|
+
matches = await asyncio.to_thread(client.matches.list, status="scheduled", limit=10)
|
|
362
|
+
```
|
|
363
|
+
|
|
364
|
+
**Can I use another HTTP library?** Yes. Pass `transport=` any object with a
|
|
365
|
+
`send(TransportRequest) -> TransportResponse` method. To go through a proxy with the default
|
|
366
|
+
transport, pass `UrllibTransport(proxies={"https": "http://proxy.example:3128"})`.
|
|
367
|
+
|
|
368
|
+
**Which games are supported?** CS2 (`game="cs2"`). The `game` argument exists for later titles.
|
|
369
|
+
|
|
370
|
+
**Is it typed?** Yes: the package ships `py.typed` and passes `mypy --strict`. Responses are plain
|
|
371
|
+
dicts, so they are `dict[str, Any]`.
|
|
372
|
+
|
|
373
|
+
**Where do I report a bug in this client?** In this repository's issues. Questions about your
|
|
374
|
+
account, billing or the data go through [esportsodds.gg](https://esportsodds.gg/contact).
|
|
375
|
+
|
|
376
|
+
## Licence
|
|
377
|
+
|
|
378
|
+
The code in this repository is released under the
|
|
379
|
+
[MIT licence](https://github.com/esportsodds/esportsodds-python/blob/main/LICENSE). The licence
|
|
380
|
+
covers this client, not the data it fetches: the data is licensed to you under sections 4 and 5 of
|
|
381
|
+
the [Terms of Service](https://esportsodds.gg/terms), which allow you to build on it for your own
|
|
382
|
+
purposes and do not allow redistributing it.
|
|
383
|
+
|
|
384
|
+
Counter-Strike and CS2 are trademarks of Valve Corporation. esportsodds.gg is independent and is
|
|
385
|
+
not affiliated with, endorsed by or sponsored by Valve.
|