icpc-api 0.1.0__py3-none-any.whl

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.
Files changed (50) hide show
  1. icpc/__init__.py +32 -0
  2. icpc/api/__init__.py +18 -0
  3. icpc/api/common.py +97 -0
  4. icpc/api/contest.py +253 -0
  5. icpc/api/person.py +139 -0
  6. icpc/api/public.py +65 -0
  7. icpc/api/staff.py +83 -0
  8. icpc/api/team.py +335 -0
  9. icpc/auth/__init__.py +28 -0
  10. icpc/auth/cognito.py +142 -0
  11. icpc/auth/flows.py +314 -0
  12. icpc/auth/provider.py +29 -0
  13. icpc/auth/srp.py +185 -0
  14. icpc/auth/store.py +223 -0
  15. icpc/auth/tokens.py +86 -0
  16. icpc/cli/__init__.py +20 -0
  17. icpc/cli/columns.py +555 -0
  18. icpc/cli/main.py +1156 -0
  19. icpc/cli/render.py +160 -0
  20. icpc/config.py +57 -0
  21. icpc/errors.py +159 -0
  22. icpc/facade/__init__.py +6 -0
  23. icpc/facade/client.py +606 -0
  24. icpc/facade/domain.py +198 -0
  25. icpc/models/__init__.py +60 -0
  26. icpc/models/_generated.py +566 -0
  27. icpc/models/base.py +41 -0
  28. icpc/models/blobs.py +81 -0
  29. icpc/models/common.py +61 -0
  30. icpc/models/entities.py +522 -0
  31. icpc/models/enums.py +192 -0
  32. icpc/models/mixins.py +44 -0
  33. icpc/py.typed +0 -0
  34. icpc/search/__init__.py +99 -0
  35. icpc/search/_generated.py +1814 -0
  36. icpc/search/dsl.py +124 -0
  37. icpc/search/endpoint.py +173 -0
  38. icpc/search/fields.py +59 -0
  39. icpc/transport/__init__.py +29 -0
  40. icpc/transport/_shared.py +121 -0
  41. icpc/transport/async_client.py +120 -0
  42. icpc/transport/operation.py +139 -0
  43. icpc/transport/sync_client.py +121 -0
  44. icpc_api-0.1.0.dist-info/METADATA +143 -0
  45. icpc_api-0.1.0.dist-info/RECORD +50 -0
  46. icpc_api-0.1.0.dist-info/WHEEL +5 -0
  47. icpc_api-0.1.0.dist-info/entry_points.txt +2 -0
  48. icpc_api-0.1.0.dist-info/licenses/LICENSE +21 -0
  49. icpc_api-0.1.0.dist-info/licenses/THIRD-PARTY-LICENSES.md +220 -0
  50. icpc_api-0.1.0.dist-info/top_level.txt +1 -0
icpc/facade/client.py ADDED
@@ -0,0 +1,606 @@
1
+ """The clients users actually reach for.
2
+
3
+ ``AsyncIcpc`` and ``Icpc`` are thin: they own a transport and an authenticator, add
4
+ auto-paging on top of :class:`~icpc.search.endpoint.SearchEndpoint`, and expose the
5
+ one join that needs several requests. Everything else is a low-level operation from
6
+ :mod:`icpc.api`, sent through ``send()``.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import asyncio
12
+ import os
13
+ from collections.abc import AsyncIterator, Iterable, Iterator
14
+ from enum import Flag, auto
15
+ from typing import Any, Self
16
+
17
+ from icpc import errors
18
+ from icpc.api import contest as contest_api
19
+ from icpc.api import person as person_api
20
+ from icpc.api import team as team_api
21
+ from icpc.api.person import ReferenceRole
22
+ from icpc.auth.flows import AsyncCognitoAuth, CognitoAuth, StaticTokenAuth, SyncStaticTokenAuth
23
+ from icpc.auth.store import Account, CredentialStore
24
+ from icpc.auth.tokens import TokenSet
25
+ from icpc.config import DEFAULT_PAGE_SIZE, Settings
26
+ from icpc.facade.domain import ContestView, join
27
+ from icpc.models.entities import ContestReference, PersonBasic, Team
28
+ from icpc.search import _generated as endpoints
29
+ from icpc.search.dsl import Q
30
+ from icpc.search.endpoint import SearchEndpoint
31
+ from icpc.transport.async_client import AsyncTransport
32
+ from icpc.transport.operation import Operation
33
+ from icpc.transport.sync_client import Transport
34
+
35
+ __all__ = ["AsyncIcpc", "Icpc", "Include"]
36
+
37
+
38
+ def _full(endpoint: SearchEndpoint[Any, Any]) -> Q:
39
+ """Every column of a grid.
40
+
41
+ The join needs columns the grid's default projection leaves out — ``instId``
42
+ to attach an institution, ``teamId`` to attach a roster — and asking for the
43
+ full set costs nothing extra on the wire. The four grids used by
44
+ :meth:`AsyncIcpc.load_contest` all accept their full field set; some others
45
+ do not, which is why this is not the global default.
46
+ """
47
+ return endpoint.query(proj=endpoint.all_fields)
48
+
49
+
50
+ def _stored_account(store: CredentialStore, username: str | None) -> Account:
51
+ """Load an account, failing with a message that says what is actually wrong."""
52
+ account = store.load(username)
53
+ if account is not None:
54
+ return account
55
+ known = store.usernames()
56
+ if username is not None:
57
+ raise errors.ConfigError(
58
+ f"no cached credentials for {username}"
59
+ + (f"; cached: {', '.join(known)}" if known else "; run `icpc auth login`")
60
+ )
61
+ if known:
62
+ # Several accounts and no recorded default: say so instead of claiming
63
+ # nothing is cached, which is what a fresh login looks like otherwise.
64
+ raise errors.ConfigError(
65
+ f"several accounts are cached ({', '.join(known)}) and none is the "
66
+ f"default; pass --user/username=, or run `icpc auth use <username>`"
67
+ )
68
+ raise errors.ConfigError("no cached credentials; run `icpc auth login` first")
69
+
70
+
71
+ class Include(Flag):
72
+ """Which tables :meth:`AsyncIcpc.load_contest` should fetch.
73
+
74
+ Each one is a separate search over the whole contest, so ask only for what you
75
+ need. ``TEAMS`` is implied by everything else.
76
+ """
77
+
78
+ TEAMS = auto()
79
+ MEMBERS = auto()
80
+ INSTITUTIONS = auto()
81
+ PARTICIPANTS = auto()
82
+ METADATA = auto()
83
+
84
+ #: Teams, rosters, institutions and contest metadata — not the participant table.
85
+ DEFAULT = TEAMS | MEMBERS | INSTITUTIONS | METADATA
86
+ ALL = TEAMS | MEMBERS | INSTITUTIONS | PARTICIPANTS | METADATA
87
+
88
+ @classmethod
89
+ def named(cls, names: Iterable[str]) -> Include:
90
+ """The union of the flags called ``names``, case-insensitively.
91
+
92
+ The names are the flags' own, lowercased — the vocabulary a caller
93
+ deciding tables from data works in, rather than one it has to keep in
94
+ step by hand. ``default`` and ``all`` are accepted too.
95
+
96
+ Raises :class:`ValueError` naming the tables, which a lookup by
97
+ attribute cannot do: it would answer the same for a typo as for a
98
+ method, and say nothing about what was allowed.
99
+ """
100
+ include = cls(0)
101
+ for name in names:
102
+ try:
103
+ include |= cls[name.upper()]
104
+ except KeyError:
105
+ raise ValueError(
106
+ f"unknown table {name!r}; pick from "
107
+ f"{', '.join(flag.name.lower() for flag in cls if flag.name)}, default, all"
108
+ ) from None
109
+ return include
110
+
111
+
112
+ def _resolve_credentials(
113
+ username: str | None, password: str | None
114
+ ) -> tuple[str | None, str | None]:
115
+ return (
116
+ username or os.environ.get("ICPC_USERNAME"),
117
+ password or os.environ.get("ICPC_PASSWORD"),
118
+ )
119
+
120
+
121
+ class AsyncIcpc:
122
+ """Asynchronous client."""
123
+
124
+ def __init__(self, transport: AsyncTransport) -> None:
125
+ self._t = transport
126
+
127
+ # ------------------------------------------------------- construction --
128
+
129
+ @classmethod
130
+ def from_password(
131
+ cls,
132
+ username: str | None = None,
133
+ password: str | None = None,
134
+ *,
135
+ store: CredentialStore | None = None,
136
+ settings: Settings | None = None,
137
+ ) -> Self:
138
+ """Log in with an email and password over Cognito SRP.
139
+
140
+ Nothing is sent until the first request; the login happens lazily. Falls
141
+ back to ``ICPC_USERNAME`` / ``ICPC_PASSWORD``.
142
+ """
143
+ resolved = settings or Settings()
144
+ user, secret = _resolve_credentials(username, password)
145
+ if not user or not secret:
146
+ raise errors.ConfigError("username and password are required")
147
+ auth = AsyncCognitoAuth(username=user, password=secret, store=store, settings=resolved)
148
+ return cls(AsyncTransport(auth, settings=resolved))
149
+
150
+ @classmethod
151
+ def from_token(
152
+ cls,
153
+ id_token: str | None = None,
154
+ *,
155
+ refresh_token: str | None = None,
156
+ settings: Settings | None = None,
157
+ ) -> Self:
158
+ """Use an existing token.
159
+
160
+ With only an id token there is no renewal and it stops working an hour
161
+ after Cognito issued it; a refresh token is renewable indefinitely.
162
+ """
163
+ resolved = settings or Settings()
164
+ id_token = id_token or os.environ.get("ICPC_ID_TOKEN")
165
+ refresh_token = refresh_token or os.environ.get("ICPC_REFRESH_TOKEN")
166
+ if refresh_token:
167
+ tokens = TokenSet(id_token=id_token or "", refresh_token=refresh_token)
168
+ return cls(
169
+ AsyncTransport(
170
+ AsyncCognitoAuth(tokens=tokens, settings=resolved), settings=resolved
171
+ )
172
+ )
173
+ if not id_token:
174
+ raise errors.ConfigError("pass id_token or refresh_token, or set ICPC_ID_TOKEN")
175
+ return cls(AsyncTransport(StaticTokenAuth(id_token), settings=resolved))
176
+
177
+ @classmethod
178
+ def from_store(
179
+ cls,
180
+ username: str | None = None,
181
+ *,
182
+ store: CredentialStore | None = None,
183
+ settings: Settings | None = None,
184
+ ) -> Self:
185
+ """Use the tokens cached by ``icpc auth login``."""
186
+ resolved = settings or Settings()
187
+ token_store = store or CredentialStore()
188
+ account = _stored_account(token_store, username)
189
+ auth = AsyncCognitoAuth(
190
+ username=account.username,
191
+ password=account.password,
192
+ tokens=account.tokens,
193
+ store=token_store,
194
+ settings=resolved,
195
+ )
196
+ return cls(AsyncTransport(auth, settings=resolved))
197
+
198
+ @classmethod
199
+ def anonymous(cls, *, settings: Settings | None = None) -> Self:
200
+ """A client with no credentials, for ``/contest/public/*`` only."""
201
+ return cls(AsyncTransport(None, settings=settings or Settings()))
202
+
203
+ # ------------------------------------------------------------ requests --
204
+
205
+ async def send[T](self, operation: Operation[T]) -> T:
206
+ """Issue one operation and return its typed result."""
207
+ return await self._t.send(operation)
208
+
209
+ async def whoami(self) -> PersonBasic:
210
+ """The account behind the current token."""
211
+ return await self.send(person_api.whoami())
212
+
213
+ async def id_token(self) -> str:
214
+ """The current bearer token, refreshing it first if it is stale.
215
+
216
+ Useful for handing the session to another tool. Treat it as a password.
217
+ """
218
+ if self._t.auth is None:
219
+ raise errors.ConfigError("this client has no credentials")
220
+ return await self._t.auth.id_token()
221
+
222
+ async def my_contests(
223
+ self, icpc_year: int, role: ReferenceRole | str = ReferenceRole.CONTEST_MANAGER
224
+ ) -> list[ContestReference]:
225
+ """Contests the signed-in account is attached to, for one ICPC season.
226
+
227
+ With the default role this is the list of contests you can administer —
228
+ what the icpc.global cabinet shows on its front page.
229
+
230
+ ``icpc_year`` is the ICPC year, not the calendar year: a contest held in
231
+ 2026 belongs to season 2027.
232
+ """
233
+ me = await self.whoami()
234
+ if me.id is None:
235
+ raise errors.ConfigError("could not determine the signed-in person id")
236
+ return await self.send(person_api.references(me.id, icpc_year, role))
237
+
238
+ # -------------------------------------------------------------- search --
239
+
240
+ async def count[R, F](self, endpoint: SearchEndpoint[R, F], q: Q | None = None) -> int:
241
+ """How many rows the query matches."""
242
+ return await self.send(endpoint.count(q))
243
+
244
+ async def page[R, F](
245
+ self,
246
+ endpoint: SearchEndpoint[R, F],
247
+ q: Q | None = None,
248
+ *,
249
+ page: int = 1,
250
+ size: int = DEFAULT_PAGE_SIZE,
251
+ ) -> list[R]:
252
+ """One page of results, 1-based."""
253
+ return await self.send(endpoint.rows(q, page=page, size=size))
254
+
255
+ async def all[R, F](
256
+ self,
257
+ endpoint: SearchEndpoint[R, F],
258
+ q: Q | None = None,
259
+ *,
260
+ size: int = DEFAULT_PAGE_SIZE,
261
+ max_rows: int | None = None,
262
+ ) -> list[R]:
263
+ """Every matching row, fetching pages until the total is covered.
264
+
265
+ The total comes from the ``/count`` sibling, which is fetched alongside the
266
+ first page. The data is live, so a row added between calls can shift the
267
+ pages; :meth:`iter` has the same caveat.
268
+ """
269
+ query = q if q is not None else endpoint.query()
270
+ total, first = await asyncio.gather(
271
+ self.send(endpoint.count(query)),
272
+ self.send(endpoint.rows(query, page=1, size=size)),
273
+ )
274
+ rows = list(first)
275
+ limit = total if max_rows is None else min(total, max_rows)
276
+ if len(rows) >= limit or not first:
277
+ return rows[:limit]
278
+
279
+ remaining = range(2, (limit + size - 1) // size + 1)
280
+ gate = asyncio.Semaphore(self._t.settings.max_concurrency)
281
+
282
+ async def fetch(number: int) -> list[R]:
283
+ async with gate:
284
+ return await self.send(endpoint.rows(query, page=number, size=size))
285
+
286
+ for chunk in await asyncio.gather(*(fetch(n) for n in remaining)):
287
+ rows.extend(chunk)
288
+ return rows[:limit]
289
+
290
+ async def iter[R, F](
291
+ self,
292
+ endpoint: SearchEndpoint[R, F],
293
+ q: Q | None = None,
294
+ *,
295
+ size: int = DEFAULT_PAGE_SIZE,
296
+ ) -> AsyncIterator[R]:
297
+ """Stream results, one page at a time, stopping on the first short page."""
298
+ query = q if q is not None else endpoint.query()
299
+ number = 1
300
+ while True:
301
+ rows = await self.send(endpoint.rows(query, page=number, size=size))
302
+ for row in rows:
303
+ yield row
304
+ if len(rows) < size:
305
+ return
306
+ number += 1
307
+
308
+ async def _all_columns[R, F](self, endpoint: SearchEndpoint[R, F]) -> list[R]:
309
+ return await self.all(endpoint, _full(endpoint))
310
+
311
+ # ------------------------------------------------------ joined contest --
312
+
313
+ async def load_contest(
314
+ self, contest_id: int, include: Include = Include.DEFAULT
315
+ ) -> ContestView:
316
+ """Fetch a contest's tables in parallel and join them.
317
+
318
+ This is the unified search: one call replaces the four separate grid
319
+ queries plus the manual stitching every consumer would otherwise write.
320
+ """
321
+ async with asyncio.TaskGroup() as group:
322
+ teams = group.create_task(self._all_columns(endpoints.contest_teams(contest_id)))
323
+ metadata = (
324
+ group.create_task(self.send(contest_api.get(contest_id)))
325
+ if Include.METADATA in include
326
+ else None
327
+ )
328
+ sites = (
329
+ group.create_task(self.send(contest_api.sites(contest_id)))
330
+ if Include.METADATA in include
331
+ else None
332
+ )
333
+ members = (
334
+ group.create_task(self._all_columns(endpoints.contest_team_members(contest_id)))
335
+ if Include.MEMBERS in include
336
+ else None
337
+ )
338
+ institutions = (
339
+ group.create_task(self._all_columns(endpoints.contest_institutions(contest_id)))
340
+ if Include.INSTITUTIONS in include
341
+ else None
342
+ )
343
+ participants = (
344
+ group.create_task(self._all_columns(endpoints.contest_participants(contest_id)))
345
+ if Include.PARTICIPANTS in include
346
+ else None
347
+ )
348
+
349
+ return join(
350
+ teams.result(),
351
+ contest=metadata.result() if metadata else None,
352
+ sites=sites.result() if sites else (),
353
+ members=members.result() if members else (),
354
+ institutions=institutions.result() if institutions else (),
355
+ participants=participants.result() if participants else (),
356
+ )
357
+
358
+ # -------------------------------------------------------------- writes --
359
+
360
+ async def update_team(self, team_id: int, **changes: Any) -> Team:
361
+ """Read a team, apply ``changes``, and write the whole object back.
362
+
363
+ ``POST /team/{id}`` is a full-object replace, not a patch, so this reads
364
+ the team first and sends it back with ``changes`` applied. The server
365
+ recomputes the team's eligibility as a result and drops any verified
366
+ status — the web UI warns about the same thing before saving.
367
+
368
+ Returns the team as the server reports it afterwards, so the caller can
369
+ see whatever else moved.
370
+ """
371
+ current = await self.send(team_api.get(team_id))
372
+ payload = current.model_dump(by_alias=True, exclude_none=False)
373
+ payload.update(changes)
374
+ await self.send(team_api.replace(team_id, payload))
375
+ return await self.send(team_api.get(team_id))
376
+
377
+ # ------------------------------------------------------------ lifetime --
378
+
379
+ async def aclose(self) -> None:
380
+ await self._t.aclose()
381
+
382
+ async def __aenter__(self) -> Self:
383
+ return self
384
+
385
+ async def __aexit__(self, *exc: object) -> None:
386
+ await self.aclose()
387
+
388
+
389
+ class Icpc:
390
+ """Synchronous client. Same surface as :class:`AsyncIcpc`, minus the streaming."""
391
+
392
+ def __init__(self, transport: Transport) -> None:
393
+ self._t = transport
394
+
395
+ # ------------------------------------------------------- construction --
396
+
397
+ @classmethod
398
+ def from_password(
399
+ cls,
400
+ username: str | None = None,
401
+ password: str | None = None,
402
+ *,
403
+ store: CredentialStore | None = None,
404
+ settings: Settings | None = None,
405
+ ) -> Self:
406
+ """Log in with an email and password over Cognito SRP."""
407
+ resolved = settings or Settings()
408
+ user, secret = _resolve_credentials(username, password)
409
+ if not user or not secret:
410
+ raise errors.ConfigError("username and password are required")
411
+ auth = CognitoAuth(username=user, password=secret, store=store, settings=resolved)
412
+ return cls(Transport(auth, settings=resolved))
413
+
414
+ @classmethod
415
+ def from_token(
416
+ cls,
417
+ id_token: str | None = None,
418
+ *,
419
+ refresh_token: str | None = None,
420
+ settings: Settings | None = None,
421
+ ) -> Self:
422
+ """Use an existing id token, or a renewable refresh token."""
423
+ resolved = settings or Settings()
424
+ id_token = id_token or os.environ.get("ICPC_ID_TOKEN")
425
+ refresh_token = refresh_token or os.environ.get("ICPC_REFRESH_TOKEN")
426
+ if refresh_token:
427
+ tokens = TokenSet(id_token=id_token or "", refresh_token=refresh_token)
428
+ return cls(Transport(CognitoAuth(tokens=tokens, settings=resolved), settings=resolved))
429
+ if not id_token:
430
+ raise errors.ConfigError("pass id_token or refresh_token, or set ICPC_ID_TOKEN")
431
+ return cls(Transport(SyncStaticTokenAuth(id_token), settings=resolved))
432
+
433
+ @classmethod
434
+ def from_store(
435
+ cls,
436
+ username: str | None = None,
437
+ *,
438
+ store: CredentialStore | None = None,
439
+ settings: Settings | None = None,
440
+ ) -> Self:
441
+ """Use the tokens cached by ``icpc auth login``."""
442
+ resolved = settings or Settings()
443
+ token_store = store or CredentialStore()
444
+ account = _stored_account(token_store, username)
445
+ auth = CognitoAuth(
446
+ username=account.username,
447
+ password=account.password,
448
+ tokens=account.tokens,
449
+ store=token_store,
450
+ settings=resolved,
451
+ )
452
+ return cls(Transport(auth, settings=resolved))
453
+
454
+ @classmethod
455
+ def anonymous(cls, *, settings: Settings | None = None) -> Self:
456
+ """A client with no credentials, for ``/contest/public/*`` only."""
457
+ return cls(Transport(None, settings=settings or Settings()))
458
+
459
+ # ------------------------------------------------------------ requests --
460
+
461
+ def send[T](self, operation: Operation[T]) -> T:
462
+ """Issue one operation and return its typed result."""
463
+ return self._t.send(operation)
464
+
465
+ def whoami(self) -> PersonBasic:
466
+ """The account behind the current token."""
467
+ return self.send(person_api.whoami())
468
+
469
+ def id_token(self) -> str:
470
+ """The current bearer token, refreshing it first if it is stale.
471
+
472
+ Useful for handing the session to another tool. Treat it as a password.
473
+ """
474
+ if self._t.auth is None:
475
+ raise errors.ConfigError("this client has no credentials")
476
+ return self._t.auth.id_token()
477
+
478
+ def my_contests(
479
+ self, icpc_year: int, role: ReferenceRole | str = ReferenceRole.CONTEST_MANAGER
480
+ ) -> list[ContestReference]:
481
+ """Contests the signed-in account is attached to, for one ICPC season.
482
+
483
+ See :meth:`AsyncIcpc.my_contests`.
484
+ """
485
+ me = self.whoami()
486
+ if me.id is None:
487
+ raise errors.ConfigError("could not determine the signed-in person id")
488
+ return self.send(person_api.references(me.id, icpc_year, role))
489
+
490
+ # -------------------------------------------------------------- search --
491
+
492
+ def count[R, F](self, endpoint: SearchEndpoint[R, F], q: Q | None = None) -> int:
493
+ """How many rows the query matches."""
494
+ return self.send(endpoint.count(q))
495
+
496
+ def page[R, F](
497
+ self,
498
+ endpoint: SearchEndpoint[R, F],
499
+ q: Q | None = None,
500
+ *,
501
+ page: int = 1,
502
+ size: int = DEFAULT_PAGE_SIZE,
503
+ ) -> list[R]:
504
+ """One page of results, 1-based."""
505
+ return self.send(endpoint.rows(q, page=page, size=size))
506
+
507
+ def all[R, F](
508
+ self,
509
+ endpoint: SearchEndpoint[R, F],
510
+ q: Q | None = None,
511
+ *,
512
+ size: int = DEFAULT_PAGE_SIZE,
513
+ max_rows: int | None = None,
514
+ ) -> list[R]:
515
+ """Every matching row, paging until the ``/count`` total is covered."""
516
+ query = q if q is not None else endpoint.query()
517
+ total = self.send(endpoint.count(query))
518
+ limit = total if max_rows is None else min(total, max_rows)
519
+ rows: list[R] = []
520
+ number = 1
521
+ while len(rows) < limit:
522
+ chunk = self.send(endpoint.rows(query, page=number, size=size))
523
+ if not chunk:
524
+ break
525
+ rows.extend(chunk)
526
+ number += 1
527
+ return rows[:limit]
528
+
529
+ def iter[R, F](
530
+ self,
531
+ endpoint: SearchEndpoint[R, F],
532
+ q: Q | None = None,
533
+ *,
534
+ size: int = DEFAULT_PAGE_SIZE,
535
+ ) -> Iterator[R]:
536
+ """Stream results, one page at a time, stopping on the first short page."""
537
+ query = q if q is not None else endpoint.query()
538
+ number = 1
539
+ while True:
540
+ rows = self.send(endpoint.rows(query, page=number, size=size))
541
+ yield from rows
542
+ if len(rows) < size:
543
+ return
544
+ number += 1
545
+
546
+ def _all_columns[R, F](self, endpoint: SearchEndpoint[R, F]) -> list[R]:
547
+ return self.all(endpoint, _full(endpoint))
548
+
549
+ # ------------------------------------------------------ joined contest --
550
+
551
+ def load_contest(self, contest_id: int, include: Include = Include.DEFAULT) -> ContestView:
552
+ """Fetch a contest's tables and join them.
553
+
554
+ The async client fetches them in parallel; here they are sequential, which
555
+ is the honest cost of a synchronous API.
556
+ """
557
+ teams = self._all_columns(endpoints.contest_teams(contest_id))
558
+ metadata = self.send(contest_api.get(contest_id)) if Include.METADATA in include else None
559
+ sites = self.send(contest_api.sites(contest_id)) if Include.METADATA in include else []
560
+ members = (
561
+ self._all_columns(endpoints.contest_team_members(contest_id))
562
+ if Include.MEMBERS in include
563
+ else []
564
+ )
565
+ institutions = (
566
+ self._all_columns(endpoints.contest_institutions(contest_id))
567
+ if Include.INSTITUTIONS in include
568
+ else []
569
+ )
570
+ participants = (
571
+ self._all_columns(endpoints.contest_participants(contest_id))
572
+ if Include.PARTICIPANTS in include
573
+ else []
574
+ )
575
+ return join(
576
+ teams,
577
+ contest=metadata,
578
+ sites=sites,
579
+ members=members,
580
+ institutions=institutions,
581
+ participants=participants,
582
+ )
583
+
584
+ # -------------------------------------------------------------- writes --
585
+
586
+ def update_team(self, team_id: int, **changes: Any) -> Team:
587
+ """Read a team, apply ``changes``, and write the whole object back.
588
+
589
+ A full-object replace — see :meth:`AsyncIcpc.update_team`.
590
+ """
591
+ current = self.send(team_api.get(team_id))
592
+ payload = current.model_dump(by_alias=True, exclude_none=False)
593
+ payload.update(changes)
594
+ self.send(team_api.replace(team_id, payload))
595
+ return self.send(team_api.get(team_id))
596
+
597
+ # ------------------------------------------------------------ lifetime --
598
+
599
+ def close(self) -> None:
600
+ self._t.close()
601
+
602
+ def __enter__(self) -> Self:
603
+ return self
604
+
605
+ def __exit__(self, *exc: object) -> None:
606
+ self.close()