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.
- icpc/__init__.py +32 -0
- icpc/api/__init__.py +18 -0
- icpc/api/common.py +97 -0
- icpc/api/contest.py +253 -0
- icpc/api/person.py +139 -0
- icpc/api/public.py +65 -0
- icpc/api/staff.py +83 -0
- icpc/api/team.py +335 -0
- icpc/auth/__init__.py +28 -0
- icpc/auth/cognito.py +142 -0
- icpc/auth/flows.py +314 -0
- icpc/auth/provider.py +29 -0
- icpc/auth/srp.py +185 -0
- icpc/auth/store.py +223 -0
- icpc/auth/tokens.py +86 -0
- icpc/cli/__init__.py +20 -0
- icpc/cli/columns.py +555 -0
- icpc/cli/main.py +1156 -0
- icpc/cli/render.py +160 -0
- icpc/config.py +57 -0
- icpc/errors.py +159 -0
- icpc/facade/__init__.py +6 -0
- icpc/facade/client.py +606 -0
- icpc/facade/domain.py +198 -0
- icpc/models/__init__.py +60 -0
- icpc/models/_generated.py +566 -0
- icpc/models/base.py +41 -0
- icpc/models/blobs.py +81 -0
- icpc/models/common.py +61 -0
- icpc/models/entities.py +522 -0
- icpc/models/enums.py +192 -0
- icpc/models/mixins.py +44 -0
- icpc/py.typed +0 -0
- icpc/search/__init__.py +99 -0
- icpc/search/_generated.py +1814 -0
- icpc/search/dsl.py +124 -0
- icpc/search/endpoint.py +173 -0
- icpc/search/fields.py +59 -0
- icpc/transport/__init__.py +29 -0
- icpc/transport/_shared.py +121 -0
- icpc/transport/async_client.py +120 -0
- icpc/transport/operation.py +139 -0
- icpc/transport/sync_client.py +121 -0
- icpc_api-0.1.0.dist-info/METADATA +143 -0
- icpc_api-0.1.0.dist-info/RECORD +50 -0
- icpc_api-0.1.0.dist-info/WHEEL +5 -0
- icpc_api-0.1.0.dist-info/entry_points.txt +2 -0
- icpc_api-0.1.0.dist-info/licenses/LICENSE +21 -0
- icpc_api-0.1.0.dist-info/licenses/THIRD-PARTY-LICENSES.md +220 -0
- 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()
|