copsapi-wrapper 1.0.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.
- copsapi_wrapper-1.0.0/.gitignore +13 -0
- copsapi_wrapper-1.0.0/CHANGELOG.md +15 -0
- copsapi_wrapper-1.0.0/LICENSE +21 -0
- copsapi_wrapper-1.0.0/PKG-INFO +581 -0
- copsapi_wrapper-1.0.0/README.md +546 -0
- copsapi_wrapper-1.0.0/pyproject.toml +59 -0
- copsapi_wrapper-1.0.0/src/copsapi/__init__.py +203 -0
- copsapi_wrapper-1.0.0/src/copsapi/__main__.py +3 -0
- copsapi_wrapper-1.0.0/src/copsapi/_async_client.py +630 -0
- copsapi_wrapper-1.0.0/src/copsapi/_core.py +216 -0
- copsapi_wrapper-1.0.0/src/copsapi/_io_async.py +50 -0
- copsapi_wrapper-1.0.0/src/copsapi/_io_sync.py +26 -0
- copsapi_wrapper-1.0.0/src/copsapi/_sync_client.py +630 -0
- copsapi_wrapper-1.0.0/src/copsapi/cache.py +73 -0
- copsapi_wrapper-1.0.0/src/copsapi/catalog.py +158 -0
- copsapi_wrapper-1.0.0/src/copsapi/cli.py +242 -0
- copsapi_wrapper-1.0.0/src/copsapi/errors.py +129 -0
- copsapi_wrapper-1.0.0/src/copsapi/helpers.py +175 -0
- copsapi_wrapper-1.0.0/src/copsapi/models.py +365 -0
- copsapi_wrapper-1.0.0/src/copsapi/py.typed +0 -0
- copsapi_wrapper-1.0.0/src/copsapi/quota.py +97 -0
- copsapi_wrapper-1.0.0/src/copsapi/watcher.py +346 -0
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 1.0.0
|
|
4
|
+
|
|
5
|
+
First release, covering the Critical Ops Public API v2.0.0:
|
|
6
|
+
|
|
7
|
+
- `CopsClient` and `AsyncCopsClient` with the same methods, built on httpx.
|
|
8
|
+
- Profiles, server status, match history, match scoreboards, weapon stats and leaderboards, returned as typed dataclasses.
|
|
9
|
+
- Autohost: create, poll, control, `host()` in one call, and watchers with live events (background thread or asyncio).
|
|
10
|
+
- Every user supplies their own token, and it is checked up front. `verify_token()` and `autohost.is_available()` report what a token can do. Hosting calls with a token that lacks `Autohost` raise a clear `PermissionDeniedError` without spending more quota.
|
|
11
|
+
- Quota tracking from the rate-limit headers, a reserve for Autohost traffic, and waiting instead of `429`s.
|
|
12
|
+
- Retries for network errors, timeouts and gateway pages that are safe for `POST` requests.
|
|
13
|
+
- Automatic batching, isolation of unknown players, caching with pluggable stores, and combining of duplicate requests (async).
|
|
14
|
+
- Catalog of hostable game modes and maps, label tables and stat helpers.
|
|
15
|
+
- `copsapi` command line tool.
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Matchmaking Live
|
|
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,581 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: copsapi-wrapper
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Typed, quota-aware Python client for the Critical Ops Public API v2, sync and async: profiles, leaderboards, match history, weapon stats, server status and Autohost with a live room watcher.
|
|
5
|
+
Project-URL: Homepage, https://github.com/Matchmaking-Live/copsapi-wrapper-python
|
|
6
|
+
Project-URL: Repository, https://github.com/Matchmaking-Live/copsapi-wrapper-python
|
|
7
|
+
Project-URL: Issues, https://github.com/Matchmaking-Live/copsapi-wrapper-python/issues
|
|
8
|
+
Project-URL: JavaScript version, https://www.npmjs.com/package/copsapi-wrapper
|
|
9
|
+
Author-email: Matchmaking Live <noreply@matchmaking.live>
|
|
10
|
+
License-Expression: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: api,api-wrapper,asyncio,autohost,cops,critical force,critical ops,criticalops,esports,matchmaking
|
|
13
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
14
|
+
Classifier: Framework :: AsyncIO
|
|
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 :: Games/Entertainment
|
|
25
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
26
|
+
Classifier: Typing :: Typed
|
|
27
|
+
Requires-Python: >=3.10
|
|
28
|
+
Requires-Dist: httpx<1,>=0.27
|
|
29
|
+
Provides-Extra: dev
|
|
30
|
+
Requires-Dist: build>=1.2; extra == 'dev'
|
|
31
|
+
Requires-Dist: mypy>=1.11; extra == 'dev'
|
|
32
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
33
|
+
Requires-Dist: twine>=5; extra == 'dev'
|
|
34
|
+
Description-Content-Type: text/markdown
|
|
35
|
+
|
|
36
|
+
# copsapi-wrapper (Python)
|
|
37
|
+
|
|
38
|
+
A typed, quota-aware Python client for the [Critical Ops Public API v2](https://criticalopsgame.com/api/), with both synchronous and `asyncio` clients.
|
|
39
|
+
|
|
40
|
+
```sh
|
|
41
|
+
pip install copsapi-wrapper
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
```python
|
|
45
|
+
from copsapi import CopsClient
|
|
46
|
+
|
|
47
|
+
with CopsClient("cops_your_token_here") as cops:
|
|
48
|
+
player = cops.profiles.get("SomePlayer")
|
|
49
|
+
print(player.name, player.stats.ranked.mmr)
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
It covers every endpoint in the API: player profiles, leaderboards, match history, weapon stats, server status, and hosting custom games (Autohost). It also handles the parts you'd otherwise write yourself:
|
|
53
|
+
|
|
54
|
+
- **Sync and async.** `CopsClient` and `AsyncCopsClient` have the same methods and behave the same way.
|
|
55
|
+
- **Typed models.** Responses come back as frozen dataclasses that match the official OpenAPI spec field for field. Fields the API adds later are kept in `.raw`. The package ships `py.typed` for type checkers.
|
|
56
|
+
- **Quota management.** The client reads the `X-RateLimit-*` headers and waits for the next minute instead of hitting `429`. It can also keep a reserve of requests for hosting traffic.
|
|
57
|
+
- **Safe retries.** Network errors, timeouts and the API's front proxy answering a plain-text `404 Endpoint Not Found.` are retried. `POST` requests are never retried after a failure that could mean the API already acted, so a game is never hosted twice.
|
|
58
|
+
- **Batching.** Profile lookups are split into requests of 20, match lookups into requests of 20, and game polling into requests of 50.
|
|
59
|
+
- **Unknown players don't break a batch.** The API fails a whole profile request when one player doesn't exist. `profiles.many()` narrows down the missing players and returns everyone else.
|
|
60
|
+
- **Caching.** Results are cached in memory, or in your own store such as Redis. The async client also combines identical requests made at the same time into one.
|
|
61
|
+
- **An Autohost toolkit.** Host a room and get its code in one call. A live watcher reports joins, leaves, disconnects, score changes, match results and the room ending.
|
|
62
|
+
- **A game catalog.** Every hostable game mode and map is included, with name lookup and validation before the request is sent.
|
|
63
|
+
- **A CLI.** The `copsapi` command does quick lookups, and hosts and watches rooms from a terminal.
|
|
64
|
+
|
|
65
|
+
It needs Python 3.10 or newer, and its only dependency is [httpx](https://www.python-httpx.org/). A [JavaScript/TypeScript version](https://www.npmjs.com/package/copsapi-wrapper) is also available.
|
|
66
|
+
|
|
67
|
+
## Contents
|
|
68
|
+
|
|
69
|
+
- [Your token](#your-token)
|
|
70
|
+
- [Sync and async](#sync-and-async)
|
|
71
|
+
- [Client options](#client-options)
|
|
72
|
+
- [Profiles](#profiles)
|
|
73
|
+
- [Servers](#servers)
|
|
74
|
+
- [Match history](#match-history)
|
|
75
|
+
- [Weapon stats](#weapon-stats)
|
|
76
|
+
- [Leaderboards](#leaderboards)
|
|
77
|
+
- [Hosting games (Autohost)](#hosting-games-autohost)
|
|
78
|
+
- [Watching hosted games](#watching-hosted-games)
|
|
79
|
+
- [Rate limits and the quota](#rate-limits-and-the-quota)
|
|
80
|
+
- [Errors](#errors)
|
|
81
|
+
- [Caching](#caching)
|
|
82
|
+
- [Game catalog](#game-catalog)
|
|
83
|
+
- [Helpers](#helpers)
|
|
84
|
+
- [Command line](#command-line)
|
|
85
|
+
- [Models](#models)
|
|
86
|
+
- [Development](#development)
|
|
87
|
+
|
|
88
|
+
## Your token
|
|
89
|
+
|
|
90
|
+
You need your own API token, issued by Critical Force to your Critical Ops account. This package doesn't include a token and never shares one. Tokens look like `cops_` followed by 48 hex digits.
|
|
91
|
+
|
|
92
|
+
What a token can do depends on its permissions:
|
|
93
|
+
|
|
94
|
+
| Endpoints | Needs |
|
|
95
|
+
| --- | --- |
|
|
96
|
+
| Profiles, servers, match history, weapon stats, leaderboards | Any valid token |
|
|
97
|
+
| Autohost (`create`, `poll`, `control`, `host`, the watcher) | A token with the `Autohost` permission |
|
|
98
|
+
|
|
99
|
+
There are three ways to pass the token:
|
|
100
|
+
|
|
101
|
+
```python
|
|
102
|
+
cops = CopsClient("cops_your_token_here")
|
|
103
|
+
cops = CopsClient(os.environ["MY_COPS_TOKEN"], timeout=5)
|
|
104
|
+
cops = CopsClient()
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
The last form reads `COPS_API_TOKEN` from the environment.
|
|
108
|
+
|
|
109
|
+
- The constructor raises `AuthenticationError` with code `missing_token` if there is no token.
|
|
110
|
+
- It raises `invalid_token` if the token isn't in the `cops_` + 48 hex format. Pass `validate_token=False` if Critical Force changes the format.
|
|
111
|
+
|
|
112
|
+
A token acts on behalf of the account it was issued to, so keep it on a server and out of your repository.
|
|
113
|
+
|
|
114
|
+
### Check your token and its permissions
|
|
115
|
+
|
|
116
|
+
```python
|
|
117
|
+
check = cops.verify_token()
|
|
118
|
+
can_host = cops.autohost.is_available()
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
- `verify_token` returns `TokenCheck(valid, autohost, error, quota)`.
|
|
122
|
+
- `is_available` returns `True` or `False`. The answer is remembered, so later calls are free.
|
|
123
|
+
|
|
124
|
+
You can also run `copsapi token` from the command line.
|
|
125
|
+
|
|
126
|
+
If your token lacks `Autohost`, hosting calls raise `PermissionDeniedError` with a message saying which permission is missing. After the first refusal, the client remembers the answer, and further hosting calls fail at once without spending quota. A running watcher stops by itself. Every other endpoint keeps working.
|
|
127
|
+
|
|
128
|
+
## Sync and async
|
|
129
|
+
|
|
130
|
+
```python
|
|
131
|
+
from copsapi import CopsClient, AsyncCopsClient
|
|
132
|
+
|
|
133
|
+
with CopsClient() as cops:
|
|
134
|
+
servers = cops.servers.online()
|
|
135
|
+
|
|
136
|
+
async with AsyncCopsClient() as cops:
|
|
137
|
+
servers = await cops.servers.online()
|
|
138
|
+
async for entry in cops.leaderboards.iterate("kills", max_pages=3):
|
|
139
|
+
print(entry.position, entry.basic_info.name)
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
Both clients have the same methods. Every method of the async client is awaited, and its `iterate()` is an async generator.
|
|
143
|
+
|
|
144
|
+
Use the client as a context manager, or call `close()` (`await aclose()` for the async client), so the HTTP connections are released.
|
|
145
|
+
|
|
146
|
+
## Client options
|
|
147
|
+
|
|
148
|
+
| Option | Default | Description |
|
|
149
|
+
| --- | --- | --- |
|
|
150
|
+
| `token` | `COPS_API_TOKEN` | Your own API token. Required. It can also be the first positional argument. |
|
|
151
|
+
| `validate_token` | `True` | Check the token format up front. |
|
|
152
|
+
| `base_url` | `COPS_API_BASE`, or `https://latest.prod.copsapi.criticalforce.fi/api/public/v2` | The API base URL. |
|
|
153
|
+
| `timeout` | `10.0` | Seconds allowed for each attempt. |
|
|
154
|
+
| `retries` | `2` | Retries for network errors, timeouts and `500`/`503` on `GET` requests. |
|
|
155
|
+
| `gateway_retries` | `5` | Retries when the front proxy answers a non-JSON `404`/`5xx` page. |
|
|
156
|
+
| `max_rate_limit_wait` | `15.0` | On a `429`, wait and retry if `Retry-After` is at most this long, otherwise raise. |
|
|
157
|
+
| `rate_limit` | `60` | Assumed requests per minute until the API reports the real number. |
|
|
158
|
+
| `rate_limit_reserve` | `0` | Requests per minute kept back for high-priority calls (all Autohost calls). |
|
|
159
|
+
| `on_quota_exhausted` | `"wait"` | `"wait"` for the next minute, or `"raise"` a local `RateLimitError`. |
|
|
160
|
+
| `max_quota_wait` | `65.0` | Longest time to wait for quota before raising. |
|
|
161
|
+
| `max_concurrent` | `8` | Requests in flight at once (async client). |
|
|
162
|
+
| `dedupe` | `True` | Combine identical `GET` requests made at the same time (async client). |
|
|
163
|
+
| `cache` | `True` | `True` uses an in-memory cache, `False` turns caching off, and any [store](#caching) uses that store. |
|
|
164
|
+
| `cache_ttls` | see [Caching](#caching) | Override TTLs per kind of data, in seconds. |
|
|
165
|
+
| `max_players_per_request` | `20` | Players per `/profile` request. Raise it only if Critical Force raised your token's limit. |
|
|
166
|
+
| `auth_header` | `"bearer"` | `"x-api-key"` sends the token as `X-Api-Key` instead. |
|
|
167
|
+
| `user_agent` | `copsapi-wrapper-python/<version>` | `None` sends no `User-Agent` header. |
|
|
168
|
+
| `http_client` | none | Your own `httpx.Client` or `httpx.AsyncClient`, for proxies or custom TLS. |
|
|
169
|
+
| `transport` | none | An `httpx` transport, such as `httpx.MockTransport` in tests. |
|
|
170
|
+
| `on_request`, `on_response`, `on_retry` | none | Callbacks that receive a dict about each request, for logging and metrics. |
|
|
171
|
+
|
|
172
|
+
```python
|
|
173
|
+
cops = CopsClient(
|
|
174
|
+
rate_limit_reserve=15,
|
|
175
|
+
on_retry=lambda info: log.warning("retrying %s (%s)", info["path"], info["reason"]),
|
|
176
|
+
on_response=lambda info: metrics.observe(info["path"], info["status"], info["duration"]),
|
|
177
|
+
)
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
Every read method accepts `fresh=True` to skip the cache.
|
|
181
|
+
|
|
182
|
+
## Profiles
|
|
183
|
+
|
|
184
|
+
An `int` is a user id, and a `str` is a username.
|
|
185
|
+
|
|
186
|
+
```python
|
|
187
|
+
one = cops.profiles.get(123456)
|
|
188
|
+
maybe = cops.profiles.find("NoSuchPlayer")
|
|
189
|
+
found = cops.profiles.many([123456, "Alice", "Bob"])
|
|
190
|
+
by_id = cops.profiles.by_ids([1, 2, 3])
|
|
191
|
+
by_name = cops.profiles.by_usernames(["Alice", "Bob"])
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
- `get` raises `NotFoundError` when the player doesn't exist.
|
|
195
|
+
- `find` returns `None` instead of raising.
|
|
196
|
+
- `many` takes any number of players. It returns them in request order without duplicates, and skips players who don't exist. Pass `skip_missing=False` to raise instead.
|
|
197
|
+
- `by_ids` returns a `dict[int, Profile]`.
|
|
198
|
+
- `by_usernames` returns a `dict` keyed by the names you asked for.
|
|
199
|
+
|
|
200
|
+
## Servers
|
|
201
|
+
|
|
202
|
+
```python
|
|
203
|
+
servers = cops.servers.list()
|
|
204
|
+
online = cops.servers.online()
|
|
205
|
+
server = cops.servers.pick(region="EU")
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
- `list` returns a `GameServer(id, name, online, region)` for every server. `region` is guessed from the server name; see `region_of()`.
|
|
209
|
+
- `pick` returns a random online server in the region. If none match, it falls back to any online server; pass `fallback_to_any=False` to get `None` instead. You can also filter with `name="DE"`.
|
|
210
|
+
|
|
211
|
+
## Match history
|
|
212
|
+
|
|
213
|
+
```python
|
|
214
|
+
history = cops.matches.history(user_id)
|
|
215
|
+
match = cops.matches.one(history[0].id)
|
|
216
|
+
recent = cops.matches.recent(user_id, limit=5, game_type="ranked")
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
- `history` returns the player's recent matches, newest first. The history resets with each game update.
|
|
220
|
+
- `one` returns a full scoreboard, and `matches.get([...])` returns several (requests are split into groups of 20). Finished matches are cached for an hour.
|
|
221
|
+
- `recent` returns `(entry, match)` pairs, combining history and scoreboards.
|
|
222
|
+
|
|
223
|
+
## Weapon stats
|
|
224
|
+
|
|
225
|
+
```python
|
|
226
|
+
weapons = cops.stats.weapons(user_id, "ranked")
|
|
227
|
+
best = cops.stats.top_weapons(user_id, by="k", limit=5)
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
- The game type is `"ranked"` or `"casual"`. Custom games count as casual.
|
|
231
|
+
- `top_weapons` sorts by kills (`k`), assists (`a`) or headshots (`h`). Each `WeaponStats` also has a `.headshot_rate`.
|
|
232
|
+
|
|
233
|
+
## Leaderboards
|
|
234
|
+
|
|
235
|
+
```python
|
|
236
|
+
page = cops.leaderboards.page("mmr", page=1)
|
|
237
|
+
elite = cops.leaderboards.page("mmr", rank=9)
|
|
238
|
+
|
|
239
|
+
for entry in cops.leaderboards.iterate("kills", max_pages=10):
|
|
240
|
+
print(entry.position, entry.basic_info.name, entry.score)
|
|
241
|
+
|
|
242
|
+
top50 = cops.leaderboards.top("wins", 50)
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
- The types are `mmr`, `kills`, `deaths`, `assists`, `matches`, `wins`, `losses`, `xp` and `mvp`. Pages hold 10 entries.
|
|
246
|
+
- `page` returns a `LeaderboardPage(type, page, rank, size, pages, entries)`. `rank` (0 to 9) filters to one rank.
|
|
247
|
+
- Players move as matches end, so `iterate` skips anyone already yielded on an earlier page.
|
|
248
|
+
|
|
249
|
+
## Hosting games (Autohost)
|
|
250
|
+
|
|
251
|
+
This needs a token with the `Autohost` permission. To check before offering hosting features to your users, call `cops.autohost.is_available()`.
|
|
252
|
+
|
|
253
|
+
### One call: host, wait for the code, add owners
|
|
254
|
+
|
|
255
|
+
```python
|
|
256
|
+
server = cops.servers.pick(region="EU")
|
|
257
|
+
room = cops.autohost.host(
|
|
258
|
+
server_id=server.id,
|
|
259
|
+
mode="tournament",
|
|
260
|
+
map="Castello",
|
|
261
|
+
room_name="Scrim #42",
|
|
262
|
+
owners=[captain_user_id],
|
|
263
|
+
)
|
|
264
|
+
print(f"Join with code {room.code}, password {room.password}")
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
- `mode` and `map` accept names (`"Tournament Mode"`, `"castello"`) or ids. Combinations the API doesn't allow raise `BadRequestError` before the request is sent; pass `validate=False` if Critical Force added a map after this release.
|
|
268
|
+
- A random 6-character password is generated unless you pass `password="..."`. Pass `password=False` for an open room.
|
|
269
|
+
- `host` raises `GameRejectedError` if the game server refuses the game (it's full or in maintenance, or the token already hosts 10 games there). It raises `GameGoneError`, or `ConnectionFailedError` with code `timeout`, if the room never appears.
|
|
270
|
+
|
|
271
|
+
### Step by step
|
|
272
|
+
|
|
273
|
+
```python
|
|
274
|
+
game = cops.autohost.create(server_id=6, mode=15847, map=15391, room_name="Final", password="abc")
|
|
275
|
+
ready = cops.autohost.wait_for_room(game.id)
|
|
276
|
+
games = cops.autohost.poll([game.id, other_id])
|
|
277
|
+
one = cops.autohost.get(game.id)
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
- `wait_for_room` waits until the room code appears.
|
|
281
|
+
- `poll` checks many games at once (up to 50 per request, split automatically).
|
|
282
|
+
- `get` returns `None` once the game is gone.
|
|
283
|
+
|
|
284
|
+
### Room commands
|
|
285
|
+
|
|
286
|
+
```python
|
|
287
|
+
cops.autohost.kick(game_id, user_id)
|
|
288
|
+
cops.autohost.add_owner(game_id, user_id)
|
|
289
|
+
cops.autohost.remove_owner(game_id, user_id)
|
|
290
|
+
cops.autohost.restart(game_id)
|
|
291
|
+
cops.autohost.close(game_id)
|
|
292
|
+
cops.autohost.control(game_id, "kick", user_id)
|
|
293
|
+
```
|
|
294
|
+
|
|
295
|
+
- `kick` removes the player and keeps them out for an hour.
|
|
296
|
+
- `add_owner` works before the player joins.
|
|
297
|
+
- `close` ends the game; the matches played so far stay in its results.
|
|
298
|
+
- `control` is the general form; the other methods call it.
|
|
299
|
+
|
|
300
|
+
### Rules from the API
|
|
301
|
+
|
|
302
|
+
- A room closes when nobody joins within 3 minutes, when everyone leaves, or after 3 hours.
|
|
303
|
+
- Each game server hosts at most 100 games, and at most 10 per token.
|
|
304
|
+
- Game ids are unique within the current game version only.
|
|
305
|
+
- `results` lists every match that was played to the end in the room. A match restarted before it ends leaves no result.
|
|
306
|
+
|
|
307
|
+
## Watching hosted games
|
|
308
|
+
|
|
309
|
+
The watcher polls every game you track in a single request every 5 seconds, which is how often game servers update. It compares each poll with the previous one and calls your handlers.
|
|
310
|
+
|
|
311
|
+
```python
|
|
312
|
+
watcher = cops.autohost.watch()
|
|
313
|
+
watcher.track(room.id, meta={"scrim_id": 42})
|
|
314
|
+
|
|
315
|
+
@watcher.on("player_join")
|
|
316
|
+
def joined(game, player, join):
|
|
317
|
+
print(player.user_id, "joined after", join.since_room_ready, "seconds")
|
|
318
|
+
|
|
319
|
+
@watcher.on("match_result")
|
|
320
|
+
def finished(game, result, index):
|
|
321
|
+
save_result(watcher.meta(game.id), summarize_result(result))
|
|
322
|
+
|
|
323
|
+
watcher.on("player_disconnect", lambda game, player: print(player.user_id, "lost connection"))
|
|
324
|
+
watcher.on("ended", lambda game: print("room closed", game.replay_id))
|
|
325
|
+
|
|
326
|
+
watcher.run()
|
|
327
|
+
```
|
|
328
|
+
|
|
329
|
+
- `run()` blocks until every tracked game has ended.
|
|
330
|
+
- `start()` polls in a background thread instead. Stop it with `stop()`, or use `with watcher:`.
|
|
331
|
+
|
|
332
|
+
With the async client, handlers can be plain functions or coroutines:
|
|
333
|
+
|
|
334
|
+
```python
|
|
335
|
+
watcher = cops.autohost.watch()
|
|
336
|
+
watcher.track(room.id)
|
|
337
|
+
|
|
338
|
+
@watcher.on("score")
|
|
339
|
+
async def score(game, round_, scores):
|
|
340
|
+
await post_to_discord(f"Round {round_}: {scores[0]}-{scores[1]}")
|
|
341
|
+
|
|
342
|
+
await watcher.run()
|
|
343
|
+
```
|
|
344
|
+
|
|
345
|
+
- `await watcher.run()` waits until every tracked game has ended.
|
|
346
|
+
- `watcher.start()` polls in a background task. Stop it with `await watcher.stop()`, or use `async with watcher:`.
|
|
347
|
+
|
|
348
|
+
### Events
|
|
349
|
+
|
|
350
|
+
| Event | Handler arguments | When |
|
|
351
|
+
| --- | --- | --- |
|
|
352
|
+
| `update` | `game, previous` | Every poll. |
|
|
353
|
+
| `room_ready` | `game` | The room code first appears. |
|
|
354
|
+
| `rejected` | `game, reason` | The game server refused the game. |
|
|
355
|
+
| `state` | `game, state, previous` | `match_state` changed. |
|
|
356
|
+
| `match_start` | `game` | A match started or was restarted. |
|
|
357
|
+
| `score` | `game, round, scores` | The round or scores changed. |
|
|
358
|
+
| `player_join` | `game, player, JoinInfo(first_seen_at, since_room_ready)` | A player appeared in the room. |
|
|
359
|
+
| `player_leave` | `game, player` | A player left the room. |
|
|
360
|
+
| `player_disconnect` | `game, player` | Ping dropped to `-1`. |
|
|
361
|
+
| `player_reconnect` | `game, player, offline_seconds` | Ping came back. |
|
|
362
|
+
| `player_team_change` | `game, player, previous_team` | A player switched team. |
|
|
363
|
+
| `match_result` | `game, result, index` | A match finished. Each result fires once. |
|
|
364
|
+
| `ended` | `game` | The room closed. The game is untracked unless `untrack_ended=False`. |
|
|
365
|
+
| `gone` | `game_id, last_known` | Polling stopped returning the game for `missing_grace` seconds. |
|
|
366
|
+
| `poll_error` | `error` | A poll failed. The watcher keeps going, except for token and permission errors. |
|
|
367
|
+
| `handler_error` | `error, event` | One of your handlers raised an exception. |
|
|
368
|
+
|
|
369
|
+
### Options and methods
|
|
370
|
+
|
|
371
|
+
- `watch(interval=5.0, missing_grace=60.0, untrack_ended=True)`
|
|
372
|
+
- `track(game_id, meta=None, seen_results=0)`, `untrack(game_id)`, `poll_now()`
|
|
373
|
+
- `latest(game_id)`, `meta(game_id)`, `join_times(game_id)`, `room_ready_at(game_id)`, `ids`, `len(watcher)`, `running`
|
|
374
|
+
|
|
375
|
+
`missing_grace` exists because a game created a moment ago can be missing from the first polls.
|
|
376
|
+
|
|
377
|
+
### Resuming after a restart
|
|
378
|
+
|
|
379
|
+
To resume after your process restarts, track the game ids again. Pass `seen_results` set to the number of results you already processed, so they aren't reported twice.
|
|
380
|
+
|
|
381
|
+
### Late joiners
|
|
382
|
+
|
|
383
|
+
`join_times(game_id)` and the `player_join` event tell you how long each player took to join after the room was ready. You can use that to flag no-shows.
|
|
384
|
+
|
|
385
|
+
## Rate limits and the quota
|
|
386
|
+
|
|
387
|
+
Each token gets a quota of requests per wall-clock minute (60 by default). Every authenticated request counts, including failed ones.
|
|
388
|
+
|
|
389
|
+
The client reads `X-RateLimit-Limit`, `X-RateLimit-Remaining` and `X-RateLimit-Reset` from every response:
|
|
390
|
+
|
|
391
|
+
```python
|
|
392
|
+
print(cops.quota)
|
|
393
|
+
```
|
|
394
|
+
|
|
395
|
+
This prints `QuotaSnapshot(limit=60, remaining=57, reset_at=..., resets_in=41.2, reserve=0)`.
|
|
396
|
+
|
|
397
|
+
When the quota runs out, requests wait for the next minute by default. With `on_quota_exhausted="raise"` they raise a local `RateLimitError` at once instead.
|
|
398
|
+
|
|
399
|
+
If you host games, set `rate_limit_reserve`, for example to `15`. Autohost calls run at high priority and can use the last requests of each minute. Everything else stops early, so a burst of profile lookups can never starve room polling.
|
|
400
|
+
|
|
401
|
+
Fetching the OpenAPI document (`cops.open_api_spec()`) needs no token and doesn't count toward the quota.
|
|
402
|
+
|
|
403
|
+
## Errors
|
|
404
|
+
|
|
405
|
+
Every failure raises a subclass of `CopsApiError`, which carries a stable `code`:
|
|
406
|
+
|
|
407
|
+
```python
|
|
408
|
+
from copsapi import CopsApiError, NotFoundError, RateLimitError
|
|
409
|
+
|
|
410
|
+
try:
|
|
411
|
+
cops.profiles.get("ghost")
|
|
412
|
+
except NotFoundError:
|
|
413
|
+
...
|
|
414
|
+
except RateLimitError as err:
|
|
415
|
+
print(f"retry in {err.retry_after}s")
|
|
416
|
+
except CopsApiError as err:
|
|
417
|
+
print(err.code, err.status, err)
|
|
418
|
+
```
|
|
419
|
+
|
|
420
|
+
| Exception | Codes |
|
|
421
|
+
| --- | --- |
|
|
422
|
+
| `AuthenticationError` | `missing_token`, `invalid_token`, `token_revoked` |
|
|
423
|
+
| `PermissionDeniedError` | `insufficient_permissions`: the token lacks a permission, such as `Autohost` |
|
|
424
|
+
| `RateLimitError` | `rate_limited`: wait `retry_after` seconds |
|
|
425
|
+
| `BadRequestError` | `bad_request`: a parameter is malformed or out of range |
|
|
426
|
+
| `NotFoundError` | `user_not_found`, `game_not_found` |
|
|
427
|
+
| `ServiceUnavailableError` | `server_unavailable`, `api_disabled`, `internal_error`, `gateway` |
|
|
428
|
+
| `ConnectionFailedError` | `network`, `timeout` |
|
|
429
|
+
| `InvalidResponseError` | `invalid_response`: a 2xx response that isn't JSON |
|
|
430
|
+
| `GameRejectedError` | `game_rejected`: the game server refused a hosted game |
|
|
431
|
+
| `GameGoneError` | `game_gone`: a hosted game disappeared |
|
|
432
|
+
|
|
433
|
+
Other attributes:
|
|
434
|
+
- `err.status` is the HTTP status, or `0` if there was no response.
|
|
435
|
+
- `err.retryable` tells you whether trying again later can work.
|
|
436
|
+
- `err.local` is `True` when the library refused the request without calling the API, for example on validation or to keep the quota reserve.
|
|
437
|
+
- `is_cops_error(err, "user_not_found")` checks for a code without importing the classes.
|
|
438
|
+
|
|
439
|
+
## Caching
|
|
440
|
+
|
|
441
|
+
Responses are cached in memory, with up to 5,000 entries. Default lifetimes:
|
|
442
|
+
|
|
443
|
+
| Kind | Default TTL |
|
|
444
|
+
| --- | --- |
|
|
445
|
+
| `profile` | 5 minutes |
|
|
446
|
+
| `servers` | 30 seconds |
|
|
447
|
+
| `leaderboard` | 1 minute |
|
|
448
|
+
| `history` | 1 minute |
|
|
449
|
+
| `match` | 1 hour |
|
|
450
|
+
| `weapons` | 2 minutes |
|
|
451
|
+
|
|
452
|
+
Autohost calls are never cached. Pass `fresh=True` to any read to skip the cache, change lifetimes with `cache_ttls={"profile": 60}`, or call `cops.clear_cache()`.
|
|
453
|
+
|
|
454
|
+
To share a cache between processes, pass any object with `get(key)` and `set(key, value, ttl_seconds)`, and optionally `clear()`. The async client also accepts async methods. Cached values are model objects, so a shared store needs to pickle them:
|
|
455
|
+
|
|
456
|
+
```python
|
|
457
|
+
import pickle
|
|
458
|
+
import redis
|
|
459
|
+
|
|
460
|
+
class RedisStore:
|
|
461
|
+
def __init__(self, client):
|
|
462
|
+
self.client = client
|
|
463
|
+
|
|
464
|
+
def get(self, key):
|
|
465
|
+
data = self.client.get(f"cops:{key}")
|
|
466
|
+
return pickle.loads(data) if data else None
|
|
467
|
+
|
|
468
|
+
def set(self, key, value, ttl):
|
|
469
|
+
self.client.set(f"cops:{key}", pickle.dumps(value), ex=max(1, int(ttl)))
|
|
470
|
+
|
|
471
|
+
cops = CopsClient(cache=RedisStore(redis.Redis()))
|
|
472
|
+
```
|
|
473
|
+
|
|
474
|
+
## Game catalog
|
|
475
|
+
|
|
476
|
+
The game modes and maps that can be hosted through the API:
|
|
477
|
+
|
|
478
|
+
```python
|
|
479
|
+
from copsapi import GAME_MODES, MAPS, game_mode, map_id, map_name, is_hostable, maps_for, modes_for
|
|
480
|
+
|
|
481
|
+
game_mode("tournament")
|
|
482
|
+
map_id("Castello")
|
|
483
|
+
map_name(613)
|
|
484
|
+
is_hostable("wingman", "Grounded")
|
|
485
|
+
maps_for("tournament")
|
|
486
|
+
modes_for("Purify")
|
|
487
|
+
```
|
|
488
|
+
|
|
489
|
+
- `game_mode("tournament")` returns `GameMode(id=15847, key='tournament', name='Tournament Mode', maps=(...))`.
|
|
490
|
+
- `map_id("Castello")` returns `15391`, and `map_name(613)` returns `'Canals'`.
|
|
491
|
+
- `is_hostable("wingman", "Grounded")` returns `False`.
|
|
492
|
+
|
|
493
|
+
| Mode | `key` | id | Maps |
|
|
494
|
+
| --- | --- | --- | --- |
|
|
495
|
+
| Defuse | `defuse` | 15773 | Canals, Plaza, Legacy, Grounded, Bureau, Uplink, Yard, Raid, Port, Park, Purify, Arctic, Bout, Soar, Shanty, Village, Castello |
|
|
496
|
+
| Elimination | `elimination` | 15775 | Warehouse, Impasse, Overhaul |
|
|
497
|
+
| Gungame | `gungame` | 15776 | Brewery, Division, Uplink, Yard, Heat, Warehouse, Gallery, Park, Cargo, Arctic, Bout, Hangar, Impasse, Overhaul |
|
|
498
|
+
| Team Deathmatch | `tdm` | 15777 | All 26 maps |
|
|
499
|
+
| Manhunt | `manhunt` | 15846 | Purify |
|
|
500
|
+
| Tournament Mode | `tournament` | 15847 | Canals, Plaza, Legacy, Grounded, Bureau, Raid, Port, Soar, Village, Castello |
|
|
501
|
+
| Rush | `rush` | 15860 | All 26 maps |
|
|
502
|
+
| Defend | `defend` | 15861 | Shanty |
|
|
503
|
+
| Manhunt Z | `manhuntz` | 18596 | Purify |
|
|
504
|
+
| Wingman | `wingman` | 18850 | Canals, Plaza, Legacy, Bureau, Raid, Village, Castello |
|
|
505
|
+
|
|
506
|
+
The package also exports these label tables:
|
|
507
|
+
- `BAN_REASONS`, `USER_TYPES`, `ACCOUNT_AGES`, `PARTY_INVITE_PERMISSIONS` and `CLAN_RANKS`, which map API codes to readable text.
|
|
508
|
+
- `LEADERBOARD_TYPES`, `MATCH_STATES`, `GAME_TYPES`, `INPUT_DEVICES` and `AUTOHOST_COMMANDS`.
|
|
509
|
+
|
|
510
|
+
## Helpers
|
|
511
|
+
|
|
512
|
+
| Helper | Returns |
|
|
513
|
+
| --- | --- |
|
|
514
|
+
| `career_totals(profile, "ranked" \| "casual" \| "custom" \| "all")` | `Totals(kills, deaths, assists, wins, losses, matches, kd, win_rate)` over all seasons. |
|
|
515
|
+
| `season_totals(profile, season=None, scope="all")` | The same for one season (the latest by default). |
|
|
516
|
+
| `summarize_result(result)` | Teams sorted by kills, the winner, draw, duration, top fragger and leavers, for autohost results or fetched matches. |
|
|
517
|
+
| `describe_ban(ban)` | Text such as `"Banned for 3 more days (Griefing)"`. |
|
|
518
|
+
| `ban_reason(ban)` | The reason label. |
|
|
519
|
+
| `input_devices(flags)` | `["keyboard_mouse", "touch", "gamepad"]`, decoded from `input_device_flags`. |
|
|
520
|
+
| `account_age_label(profile)` | Text such as `"Under 3 months"`. |
|
|
521
|
+
| `is_new_account(profile)` | `True` for accounts under a month old. |
|
|
522
|
+
| `is_staff(user_type)` | `True` for Critical Ops moderators and developers. |
|
|
523
|
+
| `kd(kills, deaths)` | The K/D ratio, rounded. |
|
|
524
|
+
| `random_password(length=6)` | A room password without look-alike characters. |
|
|
525
|
+
| `region_of(server_name)` | `"EU"`, `"NA"`, `"SA"`, `"AS"`, `"OC"`, `"ME"`, `"AF"` or `None`, guessed from the name. |
|
|
526
|
+
|
|
527
|
+
## Command line
|
|
528
|
+
|
|
529
|
+
```sh
|
|
530
|
+
pip install copsapi-wrapper
|
|
531
|
+
export COPS_API_TOKEN=cops_...
|
|
532
|
+
|
|
533
|
+
copsapi token
|
|
534
|
+
copsapi profile 123456 SomePlayer
|
|
535
|
+
copsapi servers
|
|
536
|
+
copsapi history 123456
|
|
537
|
+
copsapi match <match_id>
|
|
538
|
+
copsapi weapons 123456 casual
|
|
539
|
+
copsapi leaderboard mmr 1 --rank 9
|
|
540
|
+
copsapi host --server 6 --mode tournament --map Castello --name "Scrim" --owner 123456
|
|
541
|
+
copsapi watch 812
|
|
542
|
+
copsapi control 812 kick 654321
|
|
543
|
+
copsapi catalog
|
|
544
|
+
copsapi quota
|
|
545
|
+
```
|
|
546
|
+
|
|
547
|
+
Pass your own token with `--token cops_...`, or set `COPS_API_TOKEN`. Add `--json` before the command for JSON output, for example `copsapi --json servers`. `python -m copsapi` works too.
|
|
548
|
+
|
|
549
|
+
## Models
|
|
550
|
+
|
|
551
|
+
Responses are frozen dataclasses. They keep the API's own snake_case field names, exactly as the OpenAPI spec defines them:
|
|
552
|
+
|
|
553
|
+
- `Profile` has `basic_info`, `account_age`, `ban`, `stats` (`seasonal_stats`, `ranked`, `leaderboard_data`) and `clan`, plus the shortcuts `.user_id` and `.name`.
|
|
554
|
+
- `AutohostGame` has `status`, `room` (`code`, `map_id`, ...), `players`, `scores`, `round`, `match_state` and `results`, plus the shortcuts `.open` and `.code`.
|
|
555
|
+
- `MatchInfo` and `AutohostResult` have `players`, `scores`, `winning_team` and `win_condition`. `MatchInfo` also has `.started` and `.ended` as `datetime`.
|
|
556
|
+
- `Profile`, `MatchInfo`, `MatchHistoryEntry` and `AutohostGame` keep the full JSON in `.raw`, including fields the API adds after this release.
|
|
557
|
+
|
|
558
|
+
Field reference:
|
|
559
|
+
- **Times** are Unix timestamps in seconds.
|
|
560
|
+
- **`team`** is `-1` for a player who hasn't picked a team.
|
|
561
|
+
- **`ping`** is `-1` while the player is disconnected; `PlayerMatchStats.connected` checks this for you.
|
|
562
|
+
- **`winning_team`** is `-1` when no team won.
|
|
563
|
+
- **`ranked.rank`** is `0` while placement matches are left and `9` for Elite Ops.
|
|
564
|
+
- **`ban.days_left`** is `0` for a ban that never ends; `Ban.permanent` tells you whether the ban is permanent.
|
|
565
|
+
|
|
566
|
+
## Development
|
|
567
|
+
|
|
568
|
+
```sh
|
|
569
|
+
python -m venv .venv
|
|
570
|
+
.venv/Scripts/pip install -e ".[dev]"
|
|
571
|
+
pytest
|
|
572
|
+
mypy
|
|
573
|
+
```
|
|
574
|
+
|
|
575
|
+
On macOS and Linux, use `.venv/bin/pip`.
|
|
576
|
+
|
|
577
|
+
The sync client (`_sync_client.py`) is generated from the async source (`_async_client.py`). After changing the async client, run `python scripts/generate_sync.py`; a test fails if the two are out of step.
|
|
578
|
+
|
|
579
|
+
## License
|
|
580
|
+
|
|
581
|
+
MIT. This is an unofficial client; Critical Ops and the Critical Ops API belong to Critical Force.
|