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.
@@ -0,0 +1,13 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.egg-info/
5
+ dist/
6
+ build/
7
+ .pytest_cache/
8
+ .mypy_cache/
9
+ .coverage
10
+ htmlcov/
11
+ .env
12
+ .env.*
13
+ elite-ops.csv
@@ -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.