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/cli/main.py
ADDED
|
@@ -0,0 +1,1156 @@
|
|
|
1
|
+
"""``icpc`` — command-line access to the icpc.global API.
|
|
2
|
+
|
|
3
|
+
The CLI drives the synchronous client; that is the main reason a synchronous
|
|
4
|
+
client exists.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
import sys
|
|
11
|
+
from collections.abc import Sequence
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from datetime import UTC, datetime
|
|
14
|
+
from typing import Annotated, Any
|
|
15
|
+
|
|
16
|
+
import typer
|
|
17
|
+
|
|
18
|
+
from icpc import errors
|
|
19
|
+
from icpc.api import common as common_api
|
|
20
|
+
from icpc.api import contest as contest_api
|
|
21
|
+
from icpc.api import person as person_api
|
|
22
|
+
from icpc.api import public as public_api
|
|
23
|
+
from icpc.api import staff as staff_api
|
|
24
|
+
from icpc.api import team as team_api
|
|
25
|
+
from icpc.api.person import ReferenceRole
|
|
26
|
+
from icpc.api.team import NewTeam, NewTeamMember
|
|
27
|
+
from icpc.auth.cognito import Challenge
|
|
28
|
+
from icpc.auth.flows import CognitoAuth
|
|
29
|
+
from icpc.auth.store import CredentialStore
|
|
30
|
+
from icpc.cli.columns import (
|
|
31
|
+
JOIN,
|
|
32
|
+
ROSTERS,
|
|
33
|
+
RowKind,
|
|
34
|
+
apply_columns,
|
|
35
|
+
fields_of,
|
|
36
|
+
resolve_columns,
|
|
37
|
+
rows_of,
|
|
38
|
+
tables_for,
|
|
39
|
+
validate,
|
|
40
|
+
)
|
|
41
|
+
from icpc.cli.render import OutputFormat, fail, note, render, warn
|
|
42
|
+
from icpc.config import Settings
|
|
43
|
+
from icpc.facade.client import Icpc, Include
|
|
44
|
+
from icpc.models.enums import ExportType, MemberRole, TeamStatus
|
|
45
|
+
from icpc.search import _generated as endpoints
|
|
46
|
+
from icpc.search.dsl import Filter, SortKey
|
|
47
|
+
from icpc.search.endpoint import SearchEndpoint
|
|
48
|
+
from icpc.transport.operation import Request, json_op
|
|
49
|
+
|
|
50
|
+
app = typer.Typer(no_args_is_help=True, help=__doc__, add_completion=True)
|
|
51
|
+
auth_app = typer.Typer(no_args_is_help=True, help="Log in and manage cached tokens.")
|
|
52
|
+
contest_app = typer.Typer(no_args_is_help=True, help="Read contests and their grids.")
|
|
53
|
+
team_app = typer.Typer(no_args_is_help=True, help="Read and modify teams.")
|
|
54
|
+
public_app = typer.Typer(no_args_is_help=True, help="Public endpoints; no login needed.")
|
|
55
|
+
person_app = typer.Typer(no_args_is_help=True, help="Look people up.")
|
|
56
|
+
staff_app = typer.Typer(no_args_is_help=True, help="Contest staff.")
|
|
57
|
+
app.add_typer(auth_app, name="auth")
|
|
58
|
+
app.add_typer(contest_app, name="contest")
|
|
59
|
+
app.add_typer(team_app, name="team")
|
|
60
|
+
app.add_typer(public_app, name="public")
|
|
61
|
+
app.add_typer(person_app, name="person")
|
|
62
|
+
app.add_typer(staff_app, name="staff")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@dataclass
|
|
66
|
+
class Context:
|
|
67
|
+
output: OutputFormat
|
|
68
|
+
username: str | None
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _ctx(ctx: typer.Context) -> Context:
|
|
72
|
+
return ctx.ensure_object(Context)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _client(ctx: typer.Context) -> Icpc:
|
|
76
|
+
"""Build a client from whatever credentials are available.
|
|
77
|
+
|
|
78
|
+
An explicit token in the environment wins, so CI and one-off shells do not
|
|
79
|
+
need a login; otherwise the tokens cached by ``icpc auth login`` are used.
|
|
80
|
+
"""
|
|
81
|
+
state = _ctx(ctx)
|
|
82
|
+
if os.environ.get("ICPC_ID_TOKEN") or os.environ.get("ICPC_REFRESH_TOKEN"):
|
|
83
|
+
return Icpc.from_token()
|
|
84
|
+
return Icpc.from_store(state.username)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
#: The search grids exposed as `icpc contest <name>` and `icpc search <name>`.
|
|
88
|
+
GRIDS: dict[str, Any] = {
|
|
89
|
+
"teams": endpoints.contest_teams,
|
|
90
|
+
"members": endpoints.contest_team_members,
|
|
91
|
+
"institutions": endpoints.contest_institutions,
|
|
92
|
+
"participants": endpoints.contest_participants,
|
|
93
|
+
"staff": endpoints.contest_staff,
|
|
94
|
+
"staff-members": endpoints.contest_staff_members,
|
|
95
|
+
"standings": endpoints.contest_standings,
|
|
96
|
+
"top20": endpoints.contest_top20,
|
|
97
|
+
"summaries": endpoints.contest_team_summaries,
|
|
98
|
+
"promote-candidates": endpoints.contest_promote_candidates,
|
|
99
|
+
"certificates": endpoints.contest_team_certificates,
|
|
100
|
+
"site-teams": endpoints.site_teams,
|
|
101
|
+
"site-members": endpoints.site_team_members,
|
|
102
|
+
"site-institutions": endpoints.site_institutions,
|
|
103
|
+
"site-participants": endpoints.site_participants,
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@app.callback()
|
|
108
|
+
def main(
|
|
109
|
+
ctx: typer.Context,
|
|
110
|
+
output: Annotated[
|
|
111
|
+
OutputFormat, typer.Option("--output", "-o", help="Output format.")
|
|
112
|
+
] = OutputFormat.AUTO,
|
|
113
|
+
username: Annotated[
|
|
114
|
+
str | None,
|
|
115
|
+
typer.Option("--user", "-u", envvar="ICPC_USERNAME", help="Which cached account to use."),
|
|
116
|
+
] = None,
|
|
117
|
+
) -> None:
|
|
118
|
+
ctx.obj = Context(output=output, username=username)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
# -------------------------------------------------------------------- auth --
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
@auth_app.command("login")
|
|
125
|
+
def auth_login(
|
|
126
|
+
ctx: typer.Context,
|
|
127
|
+
username: Annotated[str | None, typer.Option("--username", prompt=True)] = None,
|
|
128
|
+
password: Annotated[
|
|
129
|
+
str | None, typer.Option("--password", prompt=True, hide_input=True)
|
|
130
|
+
] = None,
|
|
131
|
+
save_password: Annotated[
|
|
132
|
+
bool,
|
|
133
|
+
typer.Option(
|
|
134
|
+
"--save-password/--no-save-password",
|
|
135
|
+
help="Store the password so the session can renew itself after an hour.",
|
|
136
|
+
),
|
|
137
|
+
] = True,
|
|
138
|
+
) -> None:
|
|
139
|
+
"""Log in with your icpc.global email and password.
|
|
140
|
+
|
|
141
|
+
Tokens are cached under ``~/.config/icpc``. By default the password is cached
|
|
142
|
+
alongside them, because this Cognito app client has no working refresh-token
|
|
143
|
+
flow: without a stored password every command more than an hour after a login
|
|
144
|
+
would prompt again, and unattended jobs could not run at all.
|
|
145
|
+
|
|
146
|
+
It is stored base64-encoded, which is obfuscation and not encryption — anyone
|
|
147
|
+
who can read the file can recover it. Pass ``--no-save-password`` to keep it
|
|
148
|
+
out of the file and accept the hourly re-prompt.
|
|
149
|
+
"""
|
|
150
|
+
store = CredentialStore()
|
|
151
|
+
auth = CognitoAuth(
|
|
152
|
+
store=store,
|
|
153
|
+
settings=Settings(),
|
|
154
|
+
save_password=save_password,
|
|
155
|
+
# A fresh login is the account you meant to use from now on.
|
|
156
|
+
make_default=True,
|
|
157
|
+
)
|
|
158
|
+
assert username is not None and password is not None # noqa: S101 - typer prompts fill these
|
|
159
|
+
try:
|
|
160
|
+
tokens = auth.login(username, password)
|
|
161
|
+
except errors.MfaRequired as required:
|
|
162
|
+
code = typer.prompt("MFA code")
|
|
163
|
+
challenge = Challenge(required.challenge, required.session, {})
|
|
164
|
+
tokens = auth.complete_mfa(challenge, username, code)
|
|
165
|
+
note(f"logged in as {tokens.username}")
|
|
166
|
+
if save_password:
|
|
167
|
+
note(
|
|
168
|
+
f"password saved base64-encoded (not encrypted) in {store.path}; "
|
|
169
|
+
f"`icpc auth forget-password` removes it"
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
@auth_app.command("use")
|
|
174
|
+
def auth_use(username: Annotated[str, typer.Argument(help="Account to make the default.")]) -> None:
|
|
175
|
+
"""Choose which cached account commands use when none is named."""
|
|
176
|
+
if not CredentialStore().set_default(username):
|
|
177
|
+
fail(f"{username} is not cached; `icpc auth status` lists what is")
|
|
178
|
+
raise typer.Exit(1)
|
|
179
|
+
note(f"now using {username}")
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
@auth_app.command("status")
|
|
183
|
+
def auth_status(ctx: typer.Context) -> None:
|
|
184
|
+
"""Show the cached accounts and whether their sessions can renew themselves."""
|
|
185
|
+
store = CredentialStore()
|
|
186
|
+
current = store.default_username()
|
|
187
|
+
rows = []
|
|
188
|
+
for username in store.usernames():
|
|
189
|
+
account = store.load(username)
|
|
190
|
+
if account is None:
|
|
191
|
+
continue
|
|
192
|
+
tokens = account.tokens
|
|
193
|
+
expires = (
|
|
194
|
+
datetime.fromtimestamp(tokens.expires_at, UTC).strftime("%Y-%m-%d %H:%M UTC")
|
|
195
|
+
if tokens and tokens.expires_at
|
|
196
|
+
else None
|
|
197
|
+
)
|
|
198
|
+
rows.append(
|
|
199
|
+
{
|
|
200
|
+
"username": username,
|
|
201
|
+
"in_use": username == current,
|
|
202
|
+
"token_expires": expires,
|
|
203
|
+
"expired": tokens.expired(0) if tokens else True,
|
|
204
|
+
# Never the password itself, only whether one is on disk.
|
|
205
|
+
"password": account.password is not None,
|
|
206
|
+
}
|
|
207
|
+
)
|
|
208
|
+
if not rows:
|
|
209
|
+
warn("no cached credentials; run `icpc auth login`")
|
|
210
|
+
raise typer.Exit(1)
|
|
211
|
+
render(rows, _ctx(ctx).output)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
@auth_app.command("logout")
|
|
215
|
+
def auth_logout(
|
|
216
|
+
username: Annotated[str | None, typer.Argument(help="Account to forget; omit for all.")] = None,
|
|
217
|
+
) -> None:
|
|
218
|
+
"""Delete cached credentials — tokens and any stored password."""
|
|
219
|
+
if not CredentialStore().delete(username):
|
|
220
|
+
warn("nothing to delete")
|
|
221
|
+
raise typer.Exit(1)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
@auth_app.command("forget-password")
|
|
225
|
+
def auth_forget_password(
|
|
226
|
+
username: Annotated[str | None, typer.Argument(help="Account; omit for all.")] = None,
|
|
227
|
+
) -> None:
|
|
228
|
+
"""Remove the stored password but stay logged in.
|
|
229
|
+
|
|
230
|
+
The current token keeps working until it expires; after that, commands will
|
|
231
|
+
prompt for the password again.
|
|
232
|
+
"""
|
|
233
|
+
if not CredentialStore().forget_password(username):
|
|
234
|
+
warn("no stored password to remove")
|
|
235
|
+
raise typer.Exit(1)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
@auth_app.command("token")
|
|
239
|
+
def auth_token(ctx: typer.Context) -> None:
|
|
240
|
+
"""Print the current id token. Treat it as a password."""
|
|
241
|
+
with _client(ctx) as icpc:
|
|
242
|
+
token = icpc.id_token()
|
|
243
|
+
sys.stdout.write(token + "\n")
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
@app.command("my-contests")
|
|
247
|
+
def my_contests(
|
|
248
|
+
ctx: typer.Context,
|
|
249
|
+
icpc_year: Annotated[
|
|
250
|
+
int, typer.Argument(help="ICPC season year, as the cabinet's year picker shows it.")
|
|
251
|
+
],
|
|
252
|
+
role: Annotated[
|
|
253
|
+
ReferenceRole, typer.Option("--role", help="Which kind of access to list.")
|
|
254
|
+
] = ReferenceRole.CONTEST_MANAGER,
|
|
255
|
+
) -> None:
|
|
256
|
+
"""Contests you have access to, for one ICPC season.
|
|
257
|
+
|
|
258
|
+
With the default role this is what the icpc.global cabinet lists on its
|
|
259
|
+
front page: the contests you can administer.
|
|
260
|
+
|
|
261
|
+
The year is the ICPC season, not the calendar year — a contest held in 2026
|
|
262
|
+
is listed under 2027.
|
|
263
|
+
"""
|
|
264
|
+
with _client(ctx) as icpc:
|
|
265
|
+
rows = icpc.my_contests(icpc_year, role)
|
|
266
|
+
if not rows:
|
|
267
|
+
warn(f"no contests for {role} in season {icpc_year}")
|
|
268
|
+
raise typer.Exit(1)
|
|
269
|
+
columns = ["contestId", "contest"]
|
|
270
|
+
if any(r.team_id for r in rows):
|
|
271
|
+
columns += ["team", "teamRole", "site"]
|
|
272
|
+
render(rows, _ctx(ctx).output, columns=columns)
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
@app.command("whoami")
|
|
276
|
+
def whoami(ctx: typer.Context) -> None:
|
|
277
|
+
"""Show which account the cached token belongs to."""
|
|
278
|
+
with _client(ctx) as icpc:
|
|
279
|
+
render(icpc.whoami(), _ctx(ctx).output)
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def _one_id(ctx: typer.Context, rows: list[Any], term: str) -> None:
|
|
283
|
+
"""Print exactly one id, for shell substitution.
|
|
284
|
+
|
|
285
|
+
Refuses to guess: several matches means the caller must narrow the search,
|
|
286
|
+
because silently taking the first would put the wrong id into a command.
|
|
287
|
+
"""
|
|
288
|
+
ids = [r.id for r in rows if getattr(r, "id", None) is not None]
|
|
289
|
+
if len(ids) > 1:
|
|
290
|
+
fail(f"{len(ids)} matches for {term!r}; narrow it: " + ", ".join(str(i) for i in ids[:8]))
|
|
291
|
+
raise typer.Exit(1)
|
|
292
|
+
sys.stdout.write(f"{ids[0]}\n")
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
# ------------------------------------------------------------------ people --
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
@person_app.command("find")
|
|
299
|
+
def person_find(
|
|
300
|
+
ctx: typer.Context,
|
|
301
|
+
name: Annotated[str, typer.Argument(help="Name or email; three characters or more.")],
|
|
302
|
+
limit: Annotated[int, typer.Option("--limit", help="How many matches to return.")] = 10,
|
|
303
|
+
id_only: Annotated[
|
|
304
|
+
bool, typer.Option("--id", help="Print just the id, for $(...). Fails unless unique.")
|
|
305
|
+
] = False,
|
|
306
|
+
) -> None:
|
|
307
|
+
"""Find a person id by name or email.
|
|
308
|
+
|
|
309
|
+
This is the lookup behind the web UI's person picker, and the way to get the
|
|
310
|
+
person id that `team register` and `staff add` need.
|
|
311
|
+
|
|
312
|
+
icpc staff add 12345 --person $(icpc person find root@nsychev.ru --id) \\
|
|
313
|
+
--badge-role Judge
|
|
314
|
+
"""
|
|
315
|
+
with _client(ctx) as icpc:
|
|
316
|
+
rows = icpc.send(person_api.suggest(name, size=limit))
|
|
317
|
+
if not rows:
|
|
318
|
+
warn(f"nobody matches {name!r}")
|
|
319
|
+
raise typer.Exit(1)
|
|
320
|
+
if id_only:
|
|
321
|
+
_one_id(ctx, list(rows), name)
|
|
322
|
+
return
|
|
323
|
+
render(rows, _ctx(ctx).output, columns=["id", "username", "firstName", "lastName"])
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
@person_app.command("show")
|
|
327
|
+
def person_show(ctx: typer.Context, person_id: int) -> None:
|
|
328
|
+
"""A person's record."""
|
|
329
|
+
with _client(ctx) as icpc:
|
|
330
|
+
render(icpc.send(person_api.get(person_id)), _ctx(ctx).output)
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
@app.command("institution")
|
|
334
|
+
def institution_find(
|
|
335
|
+
ctx: typer.Context,
|
|
336
|
+
name: Annotated[str, typer.Argument(help="Institution name; three characters or more.")],
|
|
337
|
+
limit: Annotated[int, typer.Option("--limit")] = 10,
|
|
338
|
+
id_only: Annotated[
|
|
339
|
+
bool, typer.Option("--id", help="Print just the id, for $(...). Fails unless unique.")
|
|
340
|
+
] = False,
|
|
341
|
+
) -> None:
|
|
342
|
+
"""Find an institution id, for registering a team.
|
|
343
|
+
|
|
344
|
+
The id printed here is the one `team register --institution` wants. It is a
|
|
345
|
+
different number from the `instId` and `instUnitId` columns of the
|
|
346
|
+
`institutions` grid, which point at other tables.
|
|
347
|
+
"""
|
|
348
|
+
with _client(ctx) as icpc:
|
|
349
|
+
rows = icpc.send(common_api.institution_suggest(name, size=limit))
|
|
350
|
+
if not rows:
|
|
351
|
+
warn(f"no institution matches {name!r}")
|
|
352
|
+
raise typer.Exit(1)
|
|
353
|
+
if id_only:
|
|
354
|
+
_one_id(ctx, list(rows), name)
|
|
355
|
+
return
|
|
356
|
+
render(rows, _ctx(ctx).output, columns=["id", "name", "country", "url"])
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
# ------------------------------------------------------------------- staff --
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
@staff_app.command("add")
|
|
363
|
+
def staff_add(
|
|
364
|
+
ctx: typer.Context,
|
|
365
|
+
site_id: Annotated[int, typer.Argument(help="Site to attach the person to.")],
|
|
366
|
+
person_id: Annotated[int, typer.Option("--person", help="Person id; see `icpc person find`.")],
|
|
367
|
+
badge_role: Annotated[str, typer.Option("--badge-role", help="Printed on the badge.")],
|
|
368
|
+
certificate_role: Annotated[
|
|
369
|
+
str | None, typer.Option("--certificate-role", help="Defaults to --badge-role.")
|
|
370
|
+
] = None,
|
|
371
|
+
public: Annotated[
|
|
372
|
+
bool, typer.Option("--public", help="Show on the contest's public pages.")
|
|
373
|
+
] = False,
|
|
374
|
+
) -> None:
|
|
375
|
+
"""Attach a person to a site as staff."""
|
|
376
|
+
with _client(ctx) as icpc:
|
|
377
|
+
result = icpc.send(
|
|
378
|
+
staff_api.add(
|
|
379
|
+
site_id,
|
|
380
|
+
person_id,
|
|
381
|
+
badge_role=badge_role,
|
|
382
|
+
certificate_role=certificate_role or badge_role,
|
|
383
|
+
show_in_public_pages=public,
|
|
384
|
+
)
|
|
385
|
+
)
|
|
386
|
+
note(f"staff member {result.get('id')} added to site {site_id}")
|
|
387
|
+
render({"id": result.get("id")}, _ctx(ctx).output)
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
@staff_app.command("list")
|
|
391
|
+
def staff_list(ctx: typer.Context, contest_id: int) -> None:
|
|
392
|
+
"""Staff of a contest."""
|
|
393
|
+
endpoint = endpoints.contest_staff_members(contest_id)
|
|
394
|
+
with _client(ctx) as icpc:
|
|
395
|
+
rows = icpc.all(endpoint)
|
|
396
|
+
render(rows, _ctx(ctx).output, columns=list(endpoint.default_proj))
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
@staff_app.command("remove")
|
|
400
|
+
def staff_remove(ctx: typer.Context, staff_member_id: int) -> None:
|
|
401
|
+
"""Remove a staff member."""
|
|
402
|
+
with _client(ctx) as icpc:
|
|
403
|
+
icpc.send(staff_api.delete(staff_member_id))
|
|
404
|
+
note(f"staff member {staff_member_id} removed")
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
# ----------------------------------------------------------------- contest --
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
@contest_app.command("show")
|
|
411
|
+
def contest_show(ctx: typer.Context, contest_id: int) -> None:
|
|
412
|
+
"""Contest metadata and settings."""
|
|
413
|
+
with _client(ctx) as icpc:
|
|
414
|
+
render(icpc.send(contest_api.get(contest_id)), _ctx(ctx).output)
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
@contest_app.command("sites")
|
|
418
|
+
def contest_sites(ctx: typer.Context, contest_id: int) -> None:
|
|
419
|
+
"""Sites of a contest, with capacity and registration flags."""
|
|
420
|
+
with _client(ctx) as icpc:
|
|
421
|
+
render(icpc.send(contest_api.site_table(contest_id)), _ctx(ctx).output)
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
@contest_app.command("stats")
|
|
425
|
+
def contest_stats(ctx: typer.Context, contest_id: int) -> None:
|
|
426
|
+
"""Counts of sites, managers, and pending versus accepted teams."""
|
|
427
|
+
with _client(ctx) as icpc:
|
|
428
|
+
render(icpc.send(contest_api.stats(contest_id)), _ctx(ctx).output)
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
@contest_app.command("set")
|
|
432
|
+
def contest_set(
|
|
433
|
+
ctx: typer.Context,
|
|
434
|
+
contest_id: int,
|
|
435
|
+
changes: Annotated[list[str], typer.Argument(help="KEY=VALUE, e.g. name='New name'.")],
|
|
436
|
+
) -> None:
|
|
437
|
+
"""Change a contest's own fields.
|
|
438
|
+
|
|
439
|
+
icpc contest set 1235 geographicArea="Northern Eurasia" email=me@example.com
|
|
440
|
+
"""
|
|
441
|
+
_edit(
|
|
442
|
+
ctx,
|
|
443
|
+
"contest",
|
|
444
|
+
contest_api.get(contest_id),
|
|
445
|
+
lambda o: contest_api.update(contest_id, o),
|
|
446
|
+
changes,
|
|
447
|
+
)
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
@contest_app.command("set-settings")
|
|
451
|
+
def contest_set_settings(
|
|
452
|
+
ctx: typer.Context,
|
|
453
|
+
contest_id: int,
|
|
454
|
+
changes: Annotated[list[str], typer.Argument(help="KEY=VALUE.")],
|
|
455
|
+
) -> None:
|
|
456
|
+
"""Change the contest settings block.
|
|
457
|
+
|
|
458
|
+
icpc contest set-settings 1235 requireCertification=true
|
|
459
|
+
icpc contest set-settings 1235 showPublicPages=WITH_PEOPLE
|
|
460
|
+
"""
|
|
461
|
+
_edit(
|
|
462
|
+
ctx,
|
|
463
|
+
"contest settings",
|
|
464
|
+
contest_api.settings(contest_id),
|
|
465
|
+
lambda o: contest_api.update_settings(contest_id, o),
|
|
466
|
+
changes,
|
|
467
|
+
)
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
@contest_app.command("set-registration")
|
|
471
|
+
def contest_set_registration(
|
|
472
|
+
ctx: typer.Context,
|
|
473
|
+
contest_id: int,
|
|
474
|
+
changes: Annotated[list[str], typer.Argument(help="KEY=VALUE.")],
|
|
475
|
+
) -> None:
|
|
476
|
+
"""Change the registration rules.
|
|
477
|
+
|
|
478
|
+
icpc contest set-registration 1235 allowStudentCoach=true maxCoaches=2
|
|
479
|
+
"""
|
|
480
|
+
_edit(
|
|
481
|
+
ctx,
|
|
482
|
+
"registration info",
|
|
483
|
+
contest_api.registration_info(contest_id),
|
|
484
|
+
lambda o: contest_api.update_registration_info(contest_id, o),
|
|
485
|
+
changes,
|
|
486
|
+
)
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
@contest_app.command("set-site")
|
|
490
|
+
def contest_set_site(
|
|
491
|
+
ctx: typer.Context,
|
|
492
|
+
contest_id: int,
|
|
493
|
+
site_id: Annotated[int, typer.Option("--site", help="Site whose settings to change.")],
|
|
494
|
+
changes: Annotated[list[str], typer.Argument(help="KEY=VALUE.")],
|
|
495
|
+
) -> None:
|
|
496
|
+
"""Change a site's settings.
|
|
497
|
+
|
|
498
|
+
icpc contest set-site 1235 --site 12345 allowRegistration=true allowTeamChanges=true
|
|
499
|
+
|
|
500
|
+
While `allowTeamChanges` is false the server answers 500 — not a clean
|
|
501
|
+
refusal — to any attempt to add or remove a team member.
|
|
502
|
+
"""
|
|
503
|
+
_edit(
|
|
504
|
+
ctx,
|
|
505
|
+
"site settings",
|
|
506
|
+
contest_api.site_settings(site_id),
|
|
507
|
+
lambda o: contest_api.update_site_settings(contest_id, o),
|
|
508
|
+
changes,
|
|
509
|
+
)
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
@contest_app.command("summary")
|
|
513
|
+
def contest_summary(ctx: typer.Context, contest_id: int) -> None:
|
|
514
|
+
"""Team counts by site and by status, from one joined fetch."""
|
|
515
|
+
with _client(ctx) as icpc:
|
|
516
|
+
view = icpc.load_contest(contest_id, Include.TEAMS)
|
|
517
|
+
rows = [
|
|
518
|
+
{"site": site or "—", "teams": len(teams)}
|
|
519
|
+
for site, teams in sorted(view.by_site().items(), key=lambda item: str(item[0]))
|
|
520
|
+
]
|
|
521
|
+
render(rows, _ctx(ctx).output, columns=["site", "teams"])
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
def _include(kind: RowKind, paths: Sequence[str]) -> Include:
|
|
525
|
+
"""Which tables the requested columns actually need.
|
|
526
|
+
|
|
527
|
+
Every table is a full search over the contest, so a sheet that never
|
|
528
|
+
mentions an institution has no reason to wait for the institution table.
|
|
529
|
+
|
|
530
|
+
With no columns the whole row is the output — and `-o json` carries all of
|
|
531
|
+
it — so there is nothing to read the answer off, and the usual four tables
|
|
532
|
+
are fetched.
|
|
533
|
+
"""
|
|
534
|
+
if not paths:
|
|
535
|
+
return Include.DEFAULT
|
|
536
|
+
tables = tables_for(kind, paths)
|
|
537
|
+
note(f"fetching {', '.join(sorted(tables))}")
|
|
538
|
+
return Include.named(tables)
|
|
539
|
+
|
|
540
|
+
|
|
541
|
+
@contest_app.command("load")
|
|
542
|
+
def contest_load(
|
|
543
|
+
ctx: typer.Context,
|
|
544
|
+
contest_id: Annotated[
|
|
545
|
+
int | None,
|
|
546
|
+
typer.Argument(help="Contest to load. Not needed with --fields."),
|
|
547
|
+
] = None,
|
|
548
|
+
rows: Annotated[
|
|
549
|
+
RowKind, typer.Option("--rows", help="What one output row is.")
|
|
550
|
+
] = RowKind.TEAMS,
|
|
551
|
+
col: Annotated[
|
|
552
|
+
list[str] | None,
|
|
553
|
+
typer.Option("--col", help="NAME=PATH, or bare PATH; repeatable."),
|
|
554
|
+
] = None,
|
|
555
|
+
join: Annotated[str, typer.Option("--join", help="Separator for a `[*]` column.")] = JOIN,
|
|
556
|
+
repeat: Annotated[
|
|
557
|
+
list[str] | None,
|
|
558
|
+
typer.Option("--repeat", help="LIST=COUNT: pin a repeated column's width."),
|
|
559
|
+
] = None,
|
|
560
|
+
fields: Annotated[
|
|
561
|
+
bool, typer.Option("--fields", help="List the paths `--col` can take, and exit.")
|
|
562
|
+
] = False,
|
|
563
|
+
) -> None:
|
|
564
|
+
"""Fetch a whole contest in one go and flatten it into a table.
|
|
565
|
+
|
|
566
|
+
This is `load_contest`: teams, rosters, institutions and metadata in a
|
|
567
|
+
single joined fetch, rather than four grid queries stitched together by
|
|
568
|
+
hand. Every column of the underlying grids is asked for, so `--col` can
|
|
569
|
+
reach anything by its icpc.global name.
|
|
570
|
+
|
|
571
|
+
icpc -o csv contest load 1234 --col id --col Team=name \\
|
|
572
|
+
--col Coach='coaches[0].firstName' --col Uni='institution.instName'
|
|
573
|
+
|
|
574
|
+
`--col NAME=PATH` names a column; a bare `--col PATH` keeps the grid's own
|
|
575
|
+
name. A path may index a roster — `coaches`, `contestants`, `other`,
|
|
576
|
+
`members` — or step into `institution`, `participant` and `extras`.
|
|
577
|
+
|
|
578
|
+
`[{i}]` repeats a column across a list, `{n}` being the 1-based counter for
|
|
579
|
+
the header:
|
|
580
|
+
|
|
581
|
+
--col 'C{n}.Id=contestants[{i}].username'
|
|
582
|
+
|
|
583
|
+
That widens the table to the largest roster in the contest; `--repeat
|
|
584
|
+
contestants=3` pins it, which is what a spreadsheet with fixed columns
|
|
585
|
+
wants.
|
|
586
|
+
|
|
587
|
+
`[*]` instead collects the whole list into one cell, joined by `--join`:
|
|
588
|
+
|
|
589
|
+
icpc contest load 1234 --rows participants \\
|
|
590
|
+
--col Id=username --col TeamIds='memberships[*].teamId'
|
|
591
|
+
|
|
592
|
+
Which tables are fetched follows from the columns: naming an `institution`
|
|
593
|
+
field fetches institutions, a roster field fetches the rosters, and nothing
|
|
594
|
+
else is waited for. With no `--col` the whole row is the output, so the
|
|
595
|
+
usual four tables are fetched.
|
|
596
|
+
"""
|
|
597
|
+
if fields:
|
|
598
|
+
# Read off the models, so this answers without fetching the contest.
|
|
599
|
+
if rows is RowKind.TEAMS:
|
|
600
|
+
note(f"the roster fields work under any of: {', '.join(ROSTERS)}")
|
|
601
|
+
render(fields_of(rows), _ctx(ctx).output, columns=["path", "from"])
|
|
602
|
+
return
|
|
603
|
+
if contest_id is None:
|
|
604
|
+
raise typer.BadParameter("a contest id is required unless you pass --fields")
|
|
605
|
+
try:
|
|
606
|
+
pins = {k: int(v) for k, v in _assignments(repeat or []).items()}
|
|
607
|
+
except (TypeError, ValueError) as exc:
|
|
608
|
+
raise typer.BadParameter("--repeat wants LIST=COUNT, e.g. contestants=3") from exc
|
|
609
|
+
# The path is the half after the name; both the check below and the choice
|
|
610
|
+
# of tables read it, and neither wants the `NAME=` in front.
|
|
611
|
+
paths = [spec.rpartition("=")[2] for spec in col or []]
|
|
612
|
+
# Check them against the row's shape first: a typo should cost a message,
|
|
613
|
+
# not a ten-second fetch that ends in an empty column.
|
|
614
|
+
for path in paths:
|
|
615
|
+
try:
|
|
616
|
+
validate(rows, path)
|
|
617
|
+
except ValueError as exc:
|
|
618
|
+
raise typer.BadParameter(
|
|
619
|
+
f"{exc}\nRun `icpc contest load --fields` to see them."
|
|
620
|
+
) from exc
|
|
621
|
+
with _client(ctx) as icpc:
|
|
622
|
+
view = icpc.load_contest(contest_id, _include(rows, paths))
|
|
623
|
+
table = rows_of(view, rows)
|
|
624
|
+
if not col:
|
|
625
|
+
# No layout asked for: every scalar field, which is the grid's own shape.
|
|
626
|
+
flat = [k for k, v in (table[0] if table else {}).items() if not isinstance(v, list | dict)]
|
|
627
|
+
render(table, _ctx(ctx).output, columns=flat)
|
|
628
|
+
return
|
|
629
|
+
try:
|
|
630
|
+
columns = resolve_columns(col, table, pins)
|
|
631
|
+
except ValueError as exc:
|
|
632
|
+
raise typer.BadParameter(str(exc)) from exc
|
|
633
|
+
render(
|
|
634
|
+
apply_columns(table, columns, join), _ctx(ctx).output, columns=[name for name, _ in columns]
|
|
635
|
+
)
|
|
636
|
+
|
|
637
|
+
|
|
638
|
+
def _assignments(pairs: list[str]) -> dict[str, Any]:
|
|
639
|
+
"""Parse ``key=value`` arguments, coercing the obvious literals.
|
|
640
|
+
|
|
641
|
+
``true``/``false``/``null`` and bare numbers become JSON values; everything
|
|
642
|
+
else stays a string. Wrap a value in quotes to force a string.
|
|
643
|
+
"""
|
|
644
|
+
out: dict[str, Any] = {}
|
|
645
|
+
for pair in pairs:
|
|
646
|
+
key, sep, raw = pair.partition("=")
|
|
647
|
+
if not sep:
|
|
648
|
+
raise typer.BadParameter(f"expected KEY=VALUE, got {pair!r}")
|
|
649
|
+
low = raw.lower()
|
|
650
|
+
if low in ("true", "false"):
|
|
651
|
+
out[key] = low == "true"
|
|
652
|
+
elif low in ("null", "none"):
|
|
653
|
+
out[key] = None
|
|
654
|
+
elif raw.lstrip("-").isdigit():
|
|
655
|
+
out[key] = int(raw)
|
|
656
|
+
else:
|
|
657
|
+
out[key] = raw.strip('"')
|
|
658
|
+
return out
|
|
659
|
+
|
|
660
|
+
|
|
661
|
+
def _edit(ctx: typer.Context, label: str, read: Any, write: Any, pairs: list[str]) -> None:
|
|
662
|
+
"""Read a settings object, apply ``key=value`` changes, write it back.
|
|
663
|
+
|
|
664
|
+
These endpoints are full-object replaces, so the read is not optional: send
|
|
665
|
+
only the changed keys and everything else is wiped.
|
|
666
|
+
"""
|
|
667
|
+
changes = _assignments(pairs)
|
|
668
|
+
with _client(ctx) as icpc:
|
|
669
|
+
current = icpc.send(read)
|
|
670
|
+
obj = current if isinstance(current, dict) else current.model_dump(by_alias=True)
|
|
671
|
+
unknown = [k for k in changes if k not in obj]
|
|
672
|
+
if unknown:
|
|
673
|
+
fail(f"{label} has no field(s) {unknown}; known: {', '.join(sorted(obj))}")
|
|
674
|
+
raise typer.Exit(1)
|
|
675
|
+
for key, value in changes.items():
|
|
676
|
+
note(f"{key}: {obj.get(key)!r} -> {value!r}")
|
|
677
|
+
icpc.send(write({**obj, **changes}))
|
|
678
|
+
after = icpc.send(read)
|
|
679
|
+
render(after, _ctx(ctx).output)
|
|
680
|
+
|
|
681
|
+
|
|
682
|
+
def _grid(name: str, entity_id: int) -> SearchEndpoint[Any, Any]:
|
|
683
|
+
factory = GRIDS.get(name)
|
|
684
|
+
if factory is None:
|
|
685
|
+
raise typer.BadParameter(f"unknown grid {name!r}; try one of: {', '.join(GRIDS)}")
|
|
686
|
+
return factory(entity_id)
|
|
687
|
+
|
|
688
|
+
|
|
689
|
+
def _parse_filters(raw: list[str]) -> list[Filter]:
|
|
690
|
+
filters = []
|
|
691
|
+
for item in raw:
|
|
692
|
+
column, _, value = item.partition("#")
|
|
693
|
+
if not value:
|
|
694
|
+
raise typer.BadParameter(f"filter must be COLUMN#VALUE, got {item!r}")
|
|
695
|
+
filters.append(Filter.of(column, value))
|
|
696
|
+
return filters
|
|
697
|
+
|
|
698
|
+
|
|
699
|
+
def _parse_sort(raw: list[str]) -> list[SortKey]:
|
|
700
|
+
keys = []
|
|
701
|
+
for item in raw:
|
|
702
|
+
column, _, direction = item.partition(":")
|
|
703
|
+
if direction not in {"", "asc", "desc"}:
|
|
704
|
+
raise typer.BadParameter(f"sort direction must be asc or desc, got {direction!r}")
|
|
705
|
+
keys.append(SortKey(column, direction or "asc")) # type: ignore[arg-type]
|
|
706
|
+
return keys
|
|
707
|
+
|
|
708
|
+
|
|
709
|
+
@app.command("search")
|
|
710
|
+
def search(
|
|
711
|
+
ctx: typer.Context,
|
|
712
|
+
grid: Annotated[str, typer.Argument(help=f"One of: {', '.join(GRIDS)}")],
|
|
713
|
+
entity_id: Annotated[int, typer.Argument(help="Contest id, or site id for site-* grids.")],
|
|
714
|
+
proj: Annotated[
|
|
715
|
+
str | None, typer.Option("--proj", help="Comma-separated columns; default is the grid's.")
|
|
716
|
+
] = None,
|
|
717
|
+
filter_: Annotated[
|
|
718
|
+
list[str] | None, typer.Option("--filter", help="COLUMN#VALUE, repeatable.")
|
|
719
|
+
] = None,
|
|
720
|
+
sort: Annotated[
|
|
721
|
+
list[str] | None, typer.Option("--sort", help="COLUMN[:asc|desc], repeatable.")
|
|
722
|
+
] = None,
|
|
723
|
+
any_filter: Annotated[
|
|
724
|
+
bool, typer.Option("--any", help="Combine filters with OR instead of AND.")
|
|
725
|
+
] = False,
|
|
726
|
+
limit: Annotated[int | None, typer.Option("--limit", help="Stop after this many rows.")] = None,
|
|
727
|
+
fields: Annotated[
|
|
728
|
+
bool, typer.Option("--fields", help="List the grid's columns and exit.")
|
|
729
|
+
] = False,
|
|
730
|
+
) -> None:
|
|
731
|
+
"""Run one of the search grids.
|
|
732
|
+
|
|
733
|
+
Column names are the server's own; ``--fields`` lists them. An unknown column
|
|
734
|
+
is rejected here rather than silently ignored by the server.
|
|
735
|
+
"""
|
|
736
|
+
endpoint = _grid(grid, entity_id)
|
|
737
|
+
if fields:
|
|
738
|
+
render(
|
|
739
|
+
[{"column": name} for name in endpoint.all_fields],
|
|
740
|
+
_ctx(ctx).output,
|
|
741
|
+
columns=["column"],
|
|
742
|
+
)
|
|
743
|
+
return
|
|
744
|
+
columns = proj.split(",") if proj else list(endpoint.default_proj)
|
|
745
|
+
q = endpoint.query(
|
|
746
|
+
proj=columns,
|
|
747
|
+
filters=_parse_filters(filter_ or []),
|
|
748
|
+
sort=_parse_sort(sort or []),
|
|
749
|
+
mode="or" if any_filter else "and",
|
|
750
|
+
)
|
|
751
|
+
with _client(ctx) as icpc:
|
|
752
|
+
rows = icpc.all(endpoint, q, max_rows=limit)
|
|
753
|
+
render(rows, _ctx(ctx).output, columns=columns)
|
|
754
|
+
|
|
755
|
+
|
|
756
|
+
@app.command("count")
|
|
757
|
+
def count(
|
|
758
|
+
ctx: typer.Context,
|
|
759
|
+
grid: Annotated[str, typer.Argument(help=f"One of: {', '.join(GRIDS)}")],
|
|
760
|
+
entity_id: int,
|
|
761
|
+
filter_: Annotated[list[str] | None, typer.Option("--filter")] = None,
|
|
762
|
+
) -> None:
|
|
763
|
+
"""Count matching rows without fetching them."""
|
|
764
|
+
endpoint = _grid(grid, entity_id)
|
|
765
|
+
q = endpoint.query(filters=_parse_filters(filter_ or []))
|
|
766
|
+
with _client(ctx) as icpc:
|
|
767
|
+
sys.stdout.write(f"{icpc.count(endpoint, q)}\n")
|
|
768
|
+
|
|
769
|
+
|
|
770
|
+
@app.command("export")
|
|
771
|
+
def export(
|
|
772
|
+
ctx: typer.Context,
|
|
773
|
+
grid: Annotated[str, typer.Argument(help=f"One of: {', '.join(GRIDS)}")],
|
|
774
|
+
entity_id: int,
|
|
775
|
+
export_type: Annotated[ExportType, typer.Option("--type")] = ExportType.CSV,
|
|
776
|
+
out: Annotated[
|
|
777
|
+
str | None, typer.Option("--out", "-O", help="Write here instead of stdout.")
|
|
778
|
+
] = None,
|
|
779
|
+
) -> None:
|
|
780
|
+
"""Ask the server to render a grid as a file."""
|
|
781
|
+
endpoint = _grid(grid, entity_id)
|
|
782
|
+
with _client(ctx) as icpc:
|
|
783
|
+
result = icpc.send(endpoint.export(endpoint.query(), export_type))
|
|
784
|
+
content = result.content()
|
|
785
|
+
if content is None:
|
|
786
|
+
render(result, _ctx(ctx).output)
|
|
787
|
+
return
|
|
788
|
+
if out:
|
|
789
|
+
with open(out, "wb") as handle: # noqa: PTH123 - a user-supplied path
|
|
790
|
+
handle.write(content)
|
|
791
|
+
warn(f"wrote {len(content)} bytes to {out}")
|
|
792
|
+
else:
|
|
793
|
+
sys.stdout.buffer.write(content)
|
|
794
|
+
|
|
795
|
+
|
|
796
|
+
# -------------------------------------------------------------------- team --
|
|
797
|
+
|
|
798
|
+
|
|
799
|
+
@team_app.command("show")
|
|
800
|
+
def team_show(ctx: typer.Context, team_id: int) -> None:
|
|
801
|
+
"""One team."""
|
|
802
|
+
with _client(ctx) as icpc:
|
|
803
|
+
render(icpc.send(team_api.get(team_id)), _ctx(ctx).output)
|
|
804
|
+
|
|
805
|
+
|
|
806
|
+
@team_app.command("members")
|
|
807
|
+
def team_members(ctx: typer.Context, team_id: int) -> None:
|
|
808
|
+
"""A team's roster."""
|
|
809
|
+
with _client(ctx) as icpc:
|
|
810
|
+
render(icpc.send(team_api.members(team_id)), _ctx(ctx).output)
|
|
811
|
+
|
|
812
|
+
|
|
813
|
+
@team_app.command("files")
|
|
814
|
+
def team_files(ctx: typer.Context, team_id: int) -> None:
|
|
815
|
+
"""Attachments on a team."""
|
|
816
|
+
with _client(ctx) as icpc:
|
|
817
|
+
render(icpc.send(team_api.files(team_id)), _ctx(ctx).output)
|
|
818
|
+
|
|
819
|
+
|
|
820
|
+
@team_app.command("can")
|
|
821
|
+
def team_can(ctx: typer.Context, team_id: int) -> None:
|
|
822
|
+
"""What this account is allowed to do to a team."""
|
|
823
|
+
with _client(ctx) as icpc:
|
|
824
|
+
render(icpc.send(team_api.view_restrictions(team_id)), _ctx(ctx).output)
|
|
825
|
+
|
|
826
|
+
|
|
827
|
+
@team_app.command("register")
|
|
828
|
+
def team_register(
|
|
829
|
+
ctx: typer.Context,
|
|
830
|
+
site_id: Annotated[int, typer.Argument(help="Site to register into.")],
|
|
831
|
+
name: Annotated[str, typer.Option("--name", help="Team name.")],
|
|
832
|
+
institution: Annotated[
|
|
833
|
+
int, typer.Option("--institution", help="Institution id; see `icpc institution`.")
|
|
834
|
+
],
|
|
835
|
+
contestant: Annotated[
|
|
836
|
+
list[int] | None, typer.Option("--contestant", help="Person id; repeatable.")
|
|
837
|
+
] = None,
|
|
838
|
+
coach: Annotated[
|
|
839
|
+
int | None,
|
|
840
|
+
typer.Option("--coach", help="Person id of the coach. Defaults to you."),
|
|
841
|
+
] = None,
|
|
842
|
+
student_coach: Annotated[
|
|
843
|
+
bool,
|
|
844
|
+
typer.Option(
|
|
845
|
+
"--student-coach",
|
|
846
|
+
help="Make the coach a CONTESTANT_COACH: you, or `--coach`'s person.",
|
|
847
|
+
),
|
|
848
|
+
] = False,
|
|
849
|
+
id_only: Annotated[bool, typer.Option("--id", help="Print just the new team id.")] = False,
|
|
850
|
+
) -> None:
|
|
851
|
+
"""Register a team.
|
|
852
|
+
|
|
853
|
+
Without `--coach` your own account becomes the coach, because the bulk
|
|
854
|
+
registration endpoint always adds the registering account and the usual
|
|
855
|
+
limit is one coach per team.
|
|
856
|
+
|
|
857
|
+
With `--coach` a different endpoint is used that does not add you, and the
|
|
858
|
+
named person is attached afterwards. Note the server refuses to make someone
|
|
859
|
+
a coach if they are already a contestant in the same contest.
|
|
860
|
+
|
|
861
|
+
`--student-coach` makes that coach a `CONTESTANT_COACH` either way — the
|
|
862
|
+
registering account on the first path, `--coach`'s person on the second.
|
|
863
|
+
"""
|
|
864
|
+
contestants = [
|
|
865
|
+
NewTeamMember(role="CONTESTANT", person=pid, badgeRole=None, certificateRole=None)
|
|
866
|
+
for pid in (contestant or [])
|
|
867
|
+
]
|
|
868
|
+
team = NewTeam(
|
|
869
|
+
name=name,
|
|
870
|
+
siteId=site_id,
|
|
871
|
+
institutionUnitId=institution,
|
|
872
|
+
studentCoach=student_coach,
|
|
873
|
+
teamMembers=contestants,
|
|
874
|
+
)
|
|
875
|
+
with _client(ctx) as icpc:
|
|
876
|
+
if not icpc.send(team_api.site_registrable(site_id)):
|
|
877
|
+
warn(f"site {site_id} is not open for registration; the server may refuse this")
|
|
878
|
+
if coach is None:
|
|
879
|
+
created = icpc.send(team_api.register([team]))
|
|
880
|
+
rows = [{"id": int(k), "name": v} for k, v in created.items()]
|
|
881
|
+
else:
|
|
882
|
+
# Two calls, and the second can fail on its own: say so precisely
|
|
883
|
+
# rather than leaving a coachless team and an opaque error.
|
|
884
|
+
team_id = icpc.send(team_api.register_with_coach(team))
|
|
885
|
+
role = MemberRole.CONTESTANT_COACH if student_coach else MemberRole.COACH
|
|
886
|
+
try:
|
|
887
|
+
icpc.send(team_api.set_coach(team_id, coach, role=str(role)))
|
|
888
|
+
except errors.ApiError as exc:
|
|
889
|
+
fail(
|
|
890
|
+
f"team {team_id} was created, but person {coach} could not be made "
|
|
891
|
+
f"its coach: {exc.body.strip()[:160]}\n"
|
|
892
|
+
f"The team now exists with no coach. Attach one with:\n"
|
|
893
|
+
f" icpc team set-coach {team_id} --person <PERSON_ID>"
|
|
894
|
+
f"{' --student-coach' if student_coach else ''}\n"
|
|
895
|
+
f"A person who is already a contestant in this contest cannot "
|
|
896
|
+
f"also be its coach"
|
|
897
|
+
+ (
|
|
898
|
+
", and a CONTESTANT_COACH may not be a contestant on another team in it."
|
|
899
|
+
if student_coach
|
|
900
|
+
else "."
|
|
901
|
+
)
|
|
902
|
+
)
|
|
903
|
+
raise typer.Exit(1) from exc
|
|
904
|
+
rows = [{"id": team_id, "name": name}]
|
|
905
|
+
for row in rows:
|
|
906
|
+
note(f"registered team {row['id']}: {row['name']}")
|
|
907
|
+
if id_only:
|
|
908
|
+
sys.stdout.write(f"{rows[0]['id']}\n")
|
|
909
|
+
return
|
|
910
|
+
render(rows, _ctx(ctx).output, columns=["id", "name"])
|
|
911
|
+
|
|
912
|
+
|
|
913
|
+
@team_app.command("add-member")
|
|
914
|
+
def team_add_member(
|
|
915
|
+
ctx: typer.Context,
|
|
916
|
+
team_id: int,
|
|
917
|
+
person_id: Annotated[int, typer.Option("--person", help="Person id; see `icpc person find`.")],
|
|
918
|
+
role: Annotated[MemberRole, typer.Option("--role")] = MemberRole.CONTESTANT,
|
|
919
|
+
badge_role: Annotated[str | None, typer.Option("--badge-role")] = None,
|
|
920
|
+
) -> None:
|
|
921
|
+
"""Add someone to a team.
|
|
922
|
+
|
|
923
|
+
icpc team add-member 1234568 --person 234568 --role CONTESTANT_COACH
|
|
924
|
+
|
|
925
|
+
`CONTESTANT_COACH` counts against the contest's coach limit, so a team that
|
|
926
|
+
already has a coach will refuse one until the coach is removed or
|
|
927
|
+
`maxCoaches` is raised.
|
|
928
|
+
"""
|
|
929
|
+
label = badge_role or str(role).replace("_", " ").title()
|
|
930
|
+
with _client(ctx) as icpc:
|
|
931
|
+
added = icpc.send(
|
|
932
|
+
team_api.add_members(
|
|
933
|
+
team_id,
|
|
934
|
+
[
|
|
935
|
+
{
|
|
936
|
+
"person": {"id": person_id},
|
|
937
|
+
"role": str(role),
|
|
938
|
+
"badgeRole": label,
|
|
939
|
+
"certificateRole": label,
|
|
940
|
+
}
|
|
941
|
+
],
|
|
942
|
+
)
|
|
943
|
+
)
|
|
944
|
+
for m in added:
|
|
945
|
+
note(f"added {m.name} <{m.email}> as {m.role} (memberId {m.member_id})")
|
|
946
|
+
render(added, _ctx(ctx).output, columns=["memberId", "personId", "name", "email", "role"])
|
|
947
|
+
|
|
948
|
+
|
|
949
|
+
@team_app.command("remove-member")
|
|
950
|
+
def team_remove_member(
|
|
951
|
+
ctx: typer.Context,
|
|
952
|
+
member_id: Annotated[int, typer.Argument(help="Member id — not person id.")],
|
|
953
|
+
) -> None:
|
|
954
|
+
"""Remove someone from a team. `icpc team members` lists the member ids."""
|
|
955
|
+
with _client(ctx) as icpc:
|
|
956
|
+
icpc.send(team_api.remove_member(member_id))
|
|
957
|
+
note(f"removed member {member_id}")
|
|
958
|
+
|
|
959
|
+
|
|
960
|
+
@team_app.command("set-role")
|
|
961
|
+
def team_set_role(
|
|
962
|
+
ctx: typer.Context,
|
|
963
|
+
team_id: int,
|
|
964
|
+
person_id: Annotated[int, typer.Option("--person", help="Person id.")],
|
|
965
|
+
role: Annotated[MemberRole, typer.Option("--role")],
|
|
966
|
+
) -> None:
|
|
967
|
+
"""Change someone's role on a team.
|
|
968
|
+
|
|
969
|
+
icpc team set-role 1234568 --person 234568 --role CONTESTANT_COACH
|
|
970
|
+
|
|
971
|
+
The API has no role-change operation, so this removes the membership and
|
|
972
|
+
creates a new one. The member id changes and per-member settings — badge and
|
|
973
|
+
certificate roles, attendance, certificate flags — are reset to defaults.
|
|
974
|
+
"""
|
|
975
|
+
with _client(ctx) as icpc:
|
|
976
|
+
roster = icpc.send(team_api.members(team_id))
|
|
977
|
+
existing = next((m for m in roster if m.person_id == person_id), None)
|
|
978
|
+
if existing is None or existing.member_id is None:
|
|
979
|
+
fail(f"person {person_id} is not on team {team_id}")
|
|
980
|
+
raise typer.Exit(1)
|
|
981
|
+
if existing.role == role:
|
|
982
|
+
note(f"already {role}; nothing to do")
|
|
983
|
+
return
|
|
984
|
+
label = str(role).replace("_", " ").title()
|
|
985
|
+
icpc.send(team_api.remove_member(existing.member_id))
|
|
986
|
+
try:
|
|
987
|
+
added = icpc.send(
|
|
988
|
+
team_api.add_members(
|
|
989
|
+
team_id,
|
|
990
|
+
[
|
|
991
|
+
{
|
|
992
|
+
"person": {"id": person_id},
|
|
993
|
+
"role": str(role),
|
|
994
|
+
"badgeRole": label,
|
|
995
|
+
"certificateRole": label,
|
|
996
|
+
}
|
|
997
|
+
],
|
|
998
|
+
)
|
|
999
|
+
)
|
|
1000
|
+
except errors.ApiError as exc:
|
|
1001
|
+
fail(
|
|
1002
|
+
f"removed the old membership but could not add the new one: "
|
|
1003
|
+
f"{exc.body.strip()[:160]}\n"
|
|
1004
|
+
f"Person {person_id} is no longer on team {team_id}. Re-add them with:\n"
|
|
1005
|
+
f" icpc team add-member {team_id} --person {person_id} "
|
|
1006
|
+
f"--role {existing.role}"
|
|
1007
|
+
)
|
|
1008
|
+
raise typer.Exit(1) from exc
|
|
1009
|
+
note(f"{person_id} is now {role} on team {team_id}")
|
|
1010
|
+
render(added, _ctx(ctx).output, columns=["memberId", "personId", "name", "email", "role"])
|
|
1011
|
+
|
|
1012
|
+
|
|
1013
|
+
@team_app.command("set-coach")
|
|
1014
|
+
def team_set_coach(
|
|
1015
|
+
ctx: typer.Context,
|
|
1016
|
+
team_id: int,
|
|
1017
|
+
person_id: Annotated[int, typer.Option("--person", help="Person id; see `icpc person find`.")],
|
|
1018
|
+
student_coach: Annotated[
|
|
1019
|
+
bool, typer.Option("--student-coach", help="Install them as a CONTESTANT_COACH.")
|
|
1020
|
+
] = False,
|
|
1021
|
+
badge_role: Annotated[str | None, typer.Option("--badge-role")] = None,
|
|
1022
|
+
) -> None:
|
|
1023
|
+
"""Make someone the team's coach.
|
|
1024
|
+
|
|
1025
|
+
This fills the coach slot rather than adding to it: an incumbent
|
|
1026
|
+
`CONTESTANT_COACH` is demoted to `CONTESTANT` in place, keeping their member
|
|
1027
|
+
id and per-member settings, and the contest's `maxCoaches` is not checked.
|
|
1028
|
+
So swapping in a real coach over a contestant coach is this one command --
|
|
1029
|
+
do not free the slot first.
|
|
1030
|
+
|
|
1031
|
+
Refused if that person is already on the team, or already a contestant in
|
|
1032
|
+
the same contest.
|
|
1033
|
+
"""
|
|
1034
|
+
role = MemberRole.CONTESTANT_COACH if student_coach else MemberRole.COACH
|
|
1035
|
+
with _client(ctx) as icpc:
|
|
1036
|
+
member = icpc.send(
|
|
1037
|
+
team_api.set_coach(
|
|
1038
|
+
team_id,
|
|
1039
|
+
person_id,
|
|
1040
|
+
role=str(role),
|
|
1041
|
+
badge_role=badge_role,
|
|
1042
|
+
certificate_role=badge_role,
|
|
1043
|
+
)
|
|
1044
|
+
)
|
|
1045
|
+
note(f"{member.name} <{member.email}> is now the {member.role} of team {team_id}")
|
|
1046
|
+
render(member, _ctx(ctx).output)
|
|
1047
|
+
|
|
1048
|
+
|
|
1049
|
+
@team_app.command("promote")
|
|
1050
|
+
def team_promote(
|
|
1051
|
+
ctx: typer.Context,
|
|
1052
|
+
team_id: int,
|
|
1053
|
+
site_id: Annotated[int, typer.Option("--site", help="Target site id.")],
|
|
1054
|
+
) -> None:
|
|
1055
|
+
"""Promote a team to a site of the parent contest."""
|
|
1056
|
+
with _client(ctx) as icpc:
|
|
1057
|
+
try:
|
|
1058
|
+
icpc.send(team_api.promote(team_id, site_id))
|
|
1059
|
+
except errors.TeamNotPromotable as exc:
|
|
1060
|
+
fail(str(exc))
|
|
1061
|
+
raise typer.Exit(1) from exc
|
|
1062
|
+
warn(f"team {team_id} promoted to site {site_id}")
|
|
1063
|
+
|
|
1064
|
+
|
|
1065
|
+
@team_app.command("bulk-status")
|
|
1066
|
+
def team_bulk_status(
|
|
1067
|
+
ctx: typer.Context,
|
|
1068
|
+
contest_id: int,
|
|
1069
|
+
status: Annotated[TeamStatus, typer.Option("--status")],
|
|
1070
|
+
ids: Annotated[str, typer.Option("--ids", help="Comma-separated team ids.")],
|
|
1071
|
+
yes: Annotated[bool, typer.Option("--yes", help="Skip the confirmation prompt.")] = False,
|
|
1072
|
+
) -> None:
|
|
1073
|
+
"""Set the status of many teams of one contest."""
|
|
1074
|
+
team_ids = [int(part) for part in ids.split(",") if part.strip()]
|
|
1075
|
+
if not team_ids:
|
|
1076
|
+
raise typer.BadParameter("--ids is empty")
|
|
1077
|
+
if not yes:
|
|
1078
|
+
typer.confirm(
|
|
1079
|
+
f"set {len(team_ids)} team(s) to {status} in contest {contest_id}?", abort=True
|
|
1080
|
+
)
|
|
1081
|
+
with _client(ctx) as icpc:
|
|
1082
|
+
icpc.send(team_api.bulk_update_status(contest_id, team_ids, status))
|
|
1083
|
+
warn(f"updated {len(team_ids)} team(s)")
|
|
1084
|
+
|
|
1085
|
+
|
|
1086
|
+
# ------------------------------------------------------------------ public --
|
|
1087
|
+
|
|
1088
|
+
|
|
1089
|
+
@public_app.command("regionals")
|
|
1090
|
+
def public_regionals(ctx: typer.Context, year: int) -> None:
|
|
1091
|
+
"""Regional contests of a season."""
|
|
1092
|
+
with Icpc.anonymous() as icpc:
|
|
1093
|
+
render(icpc.send(public_api.regionals(year)), _ctx(ctx).output)
|
|
1094
|
+
|
|
1095
|
+
|
|
1096
|
+
@public_app.command("under")
|
|
1097
|
+
def public_under(ctx: typer.Context, contest_id: int) -> None:
|
|
1098
|
+
"""Sub-contests of a contest, with registration counts."""
|
|
1099
|
+
with Icpc.anonymous() as icpc:
|
|
1100
|
+
render(icpc.send(public_api.contests_under(contest_id)), _ctx(ctx).output)
|
|
1101
|
+
|
|
1102
|
+
|
|
1103
|
+
@public_app.command("contest")
|
|
1104
|
+
def public_contest(ctx: typer.Context, abbreviation: str) -> None:
|
|
1105
|
+
"""A public contest page, by abbreviation (e.g. ``NERC-2026``)."""
|
|
1106
|
+
with Icpc.anonymous() as icpc:
|
|
1107
|
+
render(icpc.send(public_api.contest(abbreviation)), _ctx(ctx).output)
|
|
1108
|
+
|
|
1109
|
+
|
|
1110
|
+
@public_app.command("standings")
|
|
1111
|
+
def public_standings(ctx: typer.Context, contest_id: int) -> None:
|
|
1112
|
+
"""Published standings for a contest."""
|
|
1113
|
+
with Icpc.anonymous() as icpc:
|
|
1114
|
+
render(icpc.send(public_api.standings(contest_id)), _ctx(ctx).output)
|
|
1115
|
+
|
|
1116
|
+
|
|
1117
|
+
# ------------------------------------------------------------------- misc --
|
|
1118
|
+
|
|
1119
|
+
|
|
1120
|
+
@app.command("schema")
|
|
1121
|
+
def schema(ctx: typer.Context, java_class: str) -> None:
|
|
1122
|
+
"""Fetch a server-side form definition from ``/aspectfaces``.
|
|
1123
|
+
|
|
1124
|
+
The best available source for enum option lists and required-field constraints
|
|
1125
|
+
when building a write payload.
|
|
1126
|
+
"""
|
|
1127
|
+
with _client(ctx) as icpc:
|
|
1128
|
+
render(icpc.send(common_api.schema(java_class)), _ctx(ctx).output)
|
|
1129
|
+
|
|
1130
|
+
|
|
1131
|
+
@app.command("raw")
|
|
1132
|
+
def raw(
|
|
1133
|
+
ctx: typer.Context,
|
|
1134
|
+
path: Annotated[str, typer.Argument(help="Path under /api, e.g. /team/1234567")],
|
|
1135
|
+
param: Annotated[list[str] | None, typer.Option("--param", "-p", help="key=value")] = None,
|
|
1136
|
+
) -> None:
|
|
1137
|
+
"""GET any path. The escape hatch for endpoints this SDK does not model."""
|
|
1138
|
+
params: dict[str, str | int] = {}
|
|
1139
|
+
for item in param or []:
|
|
1140
|
+
key, _, value = item.partition("=")
|
|
1141
|
+
params[key] = value
|
|
1142
|
+
with _client(ctx) as icpc:
|
|
1143
|
+
render(icpc.send(json_op(Request("GET", path, params=params))), _ctx(ctx).output)
|
|
1144
|
+
|
|
1145
|
+
|
|
1146
|
+
def run() -> None:
|
|
1147
|
+
"""Entry point that turns SDK errors into terse messages."""
|
|
1148
|
+
try:
|
|
1149
|
+
app()
|
|
1150
|
+
except errors.IcpcError as exc:
|
|
1151
|
+
fail(f"{type(exc).__name__}: {exc}")
|
|
1152
|
+
raise SystemExit(1) from exc
|
|
1153
|
+
|
|
1154
|
+
|
|
1155
|
+
if __name__ == "__main__":
|
|
1156
|
+
run()
|