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/api/team.py ADDED
@@ -0,0 +1,335 @@
1
+ """``/team`` endpoints."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Sequence
6
+ from typing import TypedDict
7
+
8
+ from icpc.models.entities import (
9
+ Eligibility,
10
+ Team,
11
+ TeamAction,
12
+ TeamFile,
13
+ TeamMember,
14
+ TeamViewRestrictions,
15
+ )
16
+ from icpc.models.enums import TeamStatus
17
+ from icpc.transport.operation import (
18
+ Operation,
19
+ Request,
20
+ list_op,
21
+ model_op,
22
+ none_op,
23
+ scalar_op,
24
+ )
25
+
26
+ __all__ = [
27
+ "action",
28
+ "add_members",
29
+ "bulk_update_status",
30
+ "delete_file",
31
+ "eligibility",
32
+ "files",
33
+ "get",
34
+ "members",
35
+ "other_sites",
36
+ "promote",
37
+ "register",
38
+ "register_with_coach",
39
+ "remove_member",
40
+ "replace",
41
+ "set_coach",
42
+ "site_registrable",
43
+ "update_member",
44
+ "upload_file",
45
+ "view_restrictions",
46
+ ]
47
+
48
+
49
+ # -------------------------------------------------------------------- read --
50
+
51
+
52
+ def get(team_id: int) -> Operation[Team]:
53
+ """A team, in the exact shape :func:`replace` expects back."""
54
+ return model_op(Request("GET", f"/team/{team_id}"), Team)
55
+
56
+
57
+ def action(team_id: int) -> Operation[TeamAction]:
58
+ """Name, extended status and payment state — what the team page header shows."""
59
+ return model_op(Request("GET", f"/team/{team_id}/action"), TeamAction)
60
+
61
+
62
+ def members(team_id: int) -> Operation[list[TeamMember]]:
63
+ """The team's roster, with per-member certificate and attendance flags."""
64
+ return list_op(Request("GET", f"/team/members/team/{team_id}"), TeamMember)
65
+
66
+
67
+ def files(team_id: int) -> Operation[list[TeamFile]]:
68
+ """Attachments on a team (enrollment letters, proofs of id, and so on)."""
69
+ return list_op(Request("GET", f"/team/file/team/{team_id}"), TeamFile)
70
+
71
+
72
+ def eligibility(team_id: int) -> Operation[Eligibility]:
73
+ """The team's eligibility verdict and its verification state."""
74
+ return model_op(Request("GET", f"/team/eligibility/team/{team_id}"), Eligibility)
75
+
76
+
77
+ def view_restrictions(team_id: int) -> Operation[TeamViewRestrictions]:
78
+ """What the current account is allowed to do to this team."""
79
+ return model_op(Request("GET", f"/team/{team_id}/viewrestrictions"), TeamViewRestrictions)
80
+
81
+
82
+ def other_sites(team_id: int) -> Operation[list[dict[str, object]]]:
83
+ """Sites this team could move to."""
84
+ return list_op(Request("GET", f"/team/{team_id}/othersites"), dict[str, object])
85
+
86
+
87
+ # ------------------------------------------------------------------ writes --
88
+
89
+
90
+ def replace(team_id: int, team: dict[str, object]) -> Operation[None]:
91
+ """Overwrite a team with ``team``.
92
+
93
+ **Destructive.** This is a full-object replace, not a partial update: the server
94
+ recomputes the team's eligibility and clears any verified statuses, exactly as
95
+ the web UI warns before saving. Send back a complete object obtained from
96
+ :func:`get` with only the intended fields changed — the facade's
97
+ ``update_team`` does that read-modify-write for you.
98
+ """
99
+ return none_op(
100
+ Request(
101
+ "POST",
102
+ f"/team/{team_id}",
103
+ json=team,
104
+ idempotent=False,
105
+ )
106
+ )
107
+
108
+
109
+ def bulk_update_status(
110
+ contest_id: int, team_ids: Sequence[int], new_status: TeamStatus | str
111
+ ) -> Operation[None]:
112
+ """Accept, reject or reset many teams of one contest at once."""
113
+ return none_op(
114
+ Request(
115
+ "PUT",
116
+ f"/team/bulkupdate/contest/{contest_id}",
117
+ json={"newStatus": str(new_status), "teamIds": list(team_ids)},
118
+ idempotent=False,
119
+ )
120
+ )
121
+
122
+
123
+ def promote(team_id: int, site_id: int) -> Operation[None]:
124
+ """Promote a team to a site of the parent contest.
125
+
126
+ Refusals come back two ways. A team that is already promoted answers HTTP
127
+ 500 with a body saying so, which the transport turns into
128
+ :class:`~icpc.errors.TeamNotPromotable` rather than a generic server error;
129
+ a conflict with the target site answers a plain 400, "The team cannot be
130
+ promoted, please check the target site for conflicts".
131
+ """
132
+ return none_op(
133
+ Request(
134
+ "POST",
135
+ f"/team/{team_id}/promote/{site_id}",
136
+ idempotent=False,
137
+ )
138
+ )
139
+
140
+
141
+ def upload_file(
142
+ team_id: int, filename: str, content: bytes, mime: str = "application/pdf"
143
+ ) -> Operation[None]:
144
+ """Attach a file to a team.
145
+
146
+ The server renames the upload, inserting a long random number before the
147
+ extension — ``INVITATION-.pdf`` comes back as
148
+ ``INVITATION-9776387880749677008.pdf`` — so match on prefix and suffix when
149
+ looking it up again.
150
+ """
151
+ return none_op(
152
+ Request(
153
+ "POST",
154
+ f"/team/file/{team_id}",
155
+ files={"file": (filename, content, mime)},
156
+ idempotent=False,
157
+ )
158
+ )
159
+
160
+
161
+ def delete_file(file_id: int) -> Operation[None]:
162
+ """Remove one attachment, by *file* id — not team id."""
163
+ return none_op(
164
+ Request(
165
+ "DELETE",
166
+ f"/team/file/{file_id}",
167
+ idempotent=False,
168
+ )
169
+ )
170
+
171
+
172
+ class NewTeamMember(TypedDict, total=False):
173
+ """One member of a team being registered."""
174
+
175
+ #: ``CONTESTANT``, ``COACH``, ``COCOACH`` or ``CONTESTANT_COACH``.
176
+ role: str
177
+ #: A *person id* — a bare number, not an object. Resolve one with
178
+ #: :func:`icpc.api.person.suggest`.
179
+ person: int
180
+ badgeRole: str | None
181
+ certificateRole: str | None
182
+
183
+
184
+ class NewTeam(TypedDict, total=False):
185
+ """One team to register."""
186
+
187
+ name: str
188
+ siteId: int
189
+ #: From :func:`icpc.api.common.institution_suggest` — *not* the ``instId`` or
190
+ #: ``instUnitId`` of the institution search grid.
191
+ institutionUnitId: int
192
+ studentCoach: bool
193
+ teamMembers: list[NewTeamMember]
194
+
195
+
196
+ def register(teams: Sequence[NewTeam]) -> Operation[dict[str, str]]:
197
+ """Register one or more teams, returning ``{new team id: name}``.
198
+
199
+ This is ``/team/register/bulk``, which is what the registration wizard
200
+ actually calls; the singular ``/team/register`` is defined in the frontend
201
+ bundle but never used by it, and rejects everything this sends.
202
+
203
+ Two things the server does that are not obvious:
204
+
205
+ * **The registering account is added as a coach automatically.** Listing
206
+ yourself again gives "Person … is twice in team", and listing another coach
207
+ as well can exceed the contest's coach limit.
208
+ * The whole batch is validated before anything is written, so a rejected
209
+ batch leaves no partial team behind.
210
+
211
+ The UI registers at most ten teams at a time and checks
212
+ ``GET /team/site/{siteId}/registrable`` first.
213
+ """
214
+ return Operation(
215
+ Request("POST", "/team/register/bulk", json=list(teams), idempotent=False),
216
+ lambda r: dict(r.json()) if r.content else {},
217
+ )
218
+
219
+
220
+ def site_registrable(site_id: int) -> Operation[bool]:
221
+ """Whether a site is currently open for team registration."""
222
+ return scalar_op(Request("GET", f"/team/site/{site_id}/registrable"), bool)
223
+
224
+
225
+ class NewMember(TypedDict, total=False):
226
+ """A member being added to an existing team.
227
+
228
+ Note this is not shaped like :class:`NewTeamMember`: here ``person`` is an
229
+ object and the role field is ``role`` (the ``TeamMemberRegistrationDto``),
230
+ whereas bulk registration wants a bare person id.
231
+ """
232
+
233
+ person: dict[str, int]
234
+ #: ``CONTESTANT``, ``COACH``, ``COCOACH``, ``CONTESTANT_COACH``,
235
+ #: ``ATTENDEE``, ``RESERVE`` or ``STAFF``.
236
+ role: str
237
+ badgeRole: str
238
+ certificateRole: str
239
+
240
+
241
+ def register_with_coach(team: NewTeam, coach_id: int | None = None) -> Operation[int]:
242
+ """Register a single team **without** making yourself its coach.
243
+
244
+ ``/team/register/bulk`` always adds the registering account as a coach, so
245
+ with the usual limit of one coach per team there is no room for anybody
246
+ else. This endpoint does not, which is what it is for.
247
+
248
+ It takes a single object, not an array, and returns the new team id as a
249
+ bare number. ``coach_id`` is accepted for symmetry but the server ignores
250
+ it: the team is created with no coach at all, and :func:`set_coach` is how
251
+ you attach one.
252
+ """
253
+ body = dict(team)
254
+ if coach_id is not None:
255
+ body["coachId"] = coach_id # type: ignore[typeddict-unknown-key]
256
+ return scalar_op(
257
+ Request("POST", "/team/register/customcoach", json=body, idempotent=False), int
258
+ )
259
+
260
+
261
+ def set_coach(
262
+ team_id: int,
263
+ person_id: int,
264
+ *,
265
+ role: str = "COACH",
266
+ badge_role: str | None = None,
267
+ certificate_role: str | None = None,
268
+ ) -> Operation[TeamMember]:
269
+ """Make someone the team's coach, or its ``CONTESTANT_COACH``.
270
+
271
+ This endpoint is not the same as :func:`add_members` with a coaching role,
272
+ and the difference matters:
273
+
274
+ * **It fills the coach slot rather than adding to it.** An incumbent
275
+ ``CONTESTANT_COACH`` is demoted to ``CONTESTANT`` in place — same member
276
+ id, and ``registrationComplete``, attendance and the certificate flags
277
+ all survive, unlike the remove-and-re-add that a role change otherwise
278
+ costs.
279
+ * **It ignores ``maxCoaches``.** The same swap through ``/add`` answers
280
+ "2 coaches exceeds the coach limit of 1"; this path just goes through.
281
+
282
+ The person must not already be on the team ("Person … is twice in team"),
283
+ and a person who is already a *contestant* in the same contest cannot be a
284
+ coach there ("Person … can't be coach").
285
+
286
+ ``role`` may be ``CONTESTANT_COACH``, which the server accepts here and
287
+ which is the cheapest way to install one. It then validates the contestant
288
+ half too, so the person must not be a contestant on another team in the
289
+ contest ("Contestant … can participate only in one team").
290
+
291
+ ``badge_role`` and ``certificate_role`` default to ``role`` in title case —
292
+ "Coach", "Contestant Coach".
293
+ """
294
+ label = badge_role or role.replace("_", " ").title()
295
+ return model_op(
296
+ Request(
297
+ "POST",
298
+ f"/team/members/team/{team_id}/coach",
299
+ json={
300
+ "person": {"id": person_id},
301
+ "role": role,
302
+ "badgeRole": label,
303
+ "certificateRole": certificate_role or label,
304
+ },
305
+ idempotent=False,
306
+ ),
307
+ TeamMember,
308
+ )
309
+
310
+
311
+ def add_members(team_id: int, members: Sequence[NewMember]) -> Operation[list[TeamMember]]:
312
+ """Add people to an existing team. The body is an array, even for one member."""
313
+ return list_op(
314
+ Request("POST", f"/team/members/team/{team_id}/add", json=list(members), idempotent=False),
315
+ TeamMember,
316
+ )
317
+
318
+
319
+ def remove_member(member_id: int) -> Operation[None]:
320
+ """Remove a member, by *member* id — not person id."""
321
+ return none_op(Request("DELETE", f"/team/members/{member_id}", idempotent=False))
322
+
323
+
324
+ def update_member(member_id: int, member: dict[str, object]) -> Operation[TeamMember]:
325
+ """Overwrite a member with ``member``, a full object from :func:`members`.
326
+
327
+ A full-object replace, like the team write. The web UI uses this only for
328
+ ``attendingOnsite`` and the two certificate flags; changing ``role`` through
329
+ it answers 500, so change a role by removing the member and adding them back
330
+ with :func:`remove_member` and :func:`add_members`.
331
+ """
332
+ return model_op(
333
+ Request("POST", f"/team/members/{member_id}", json=member, idempotent=False),
334
+ TeamMember,
335
+ )
icpc/auth/__init__.py ADDED
@@ -0,0 +1,28 @@
1
+ """Cognito authentication: SRP password login, refresh, and token storage."""
2
+
3
+ from icpc.auth.cognito import Challenge
4
+ from icpc.auth.flows import (
5
+ AsyncCognitoAuth,
6
+ CognitoAuth,
7
+ StaticTokenAuth,
8
+ SyncStaticTokenAuth,
9
+ )
10
+ from icpc.auth.provider import AsyncTokenProvider, TokenProvider
11
+ from icpc.auth.srp import SrpSession
12
+ from icpc.auth.store import Account, CredentialStore, default_config_dir
13
+ from icpc.auth.tokens import TokenSet
14
+
15
+ __all__ = [
16
+ "Account",
17
+ "AsyncCognitoAuth",
18
+ "AsyncTokenProvider",
19
+ "Challenge",
20
+ "CognitoAuth",
21
+ "CredentialStore",
22
+ "SrpSession",
23
+ "StaticTokenAuth",
24
+ "SyncStaticTokenAuth",
25
+ "TokenProvider",
26
+ "TokenSet",
27
+ "default_config_dir",
28
+ ]
icpc/auth/cognito.py ADDED
@@ -0,0 +1,142 @@
1
+ """Cognito RPCs over plain HTTP — no boto3, no AWS credentials.
2
+
3
+ ``InitiateAuth`` and ``RespondToAuthChallenge`` are unsigned operations on a public
4
+ app client, so they are ordinary JSON-1.1 POSTs. This module holds the pure request
5
+ builders and response parsing; :mod:`icpc.auth.flows` drives them over httpx.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass
11
+ from typing import Any
12
+
13
+ from icpc import errors
14
+ from icpc.auth.srp import SrpSession
15
+ from icpc.auth.tokens import TokenSet
16
+ from icpc.config import Settings
17
+
18
+ __all__ = ["Challenge", "CognitoCall", "Outcome", "parse_outcome"]
19
+
20
+ _TARGET = "AWSCognitoIdentityProviderService"
21
+
22
+ PASSWORD_VERIFIER = "PASSWORD_VERIFIER"
23
+ SOFTWARE_TOKEN_MFA = "SOFTWARE_TOKEN_MFA"
24
+ SMS_MFA = "SMS_MFA"
25
+ NEW_PASSWORD_REQUIRED = "NEW_PASSWORD_REQUIRED"
26
+
27
+ #: First HTTP status Cognito uses to signal a failure.
28
+ _HTTP_ERROR = 400
29
+
30
+
31
+ @dataclass(frozen=True, slots=True)
32
+ class CognitoCall:
33
+ """A single JSON-1.1 RPC: where to send it and what to send."""
34
+
35
+ target: str
36
+ payload: dict[str, Any]
37
+
38
+ @property
39
+ def headers(self) -> dict[str, str]:
40
+ return {
41
+ "Content-Type": "application/x-amz-json-1.1",
42
+ "X-Amz-Target": f"{_TARGET}.{self.target}",
43
+ }
44
+
45
+
46
+ @dataclass(frozen=True, slots=True)
47
+ class Challenge:
48
+ """Cognito wants another round trip before it will issue tokens."""
49
+
50
+ name: str
51
+ session: str
52
+ parameters: dict[str, str]
53
+
54
+
55
+ #: Either we got tokens, or we owe Cognito another answer.
56
+ type Outcome = TokenSet | Challenge
57
+
58
+
59
+ def initiate_srp(settings: Settings, srp: SrpSession) -> CognitoCall:
60
+ """Step 1 of the password login.
61
+
62
+ icpc.global's pool uses ``CUSTOM_AUTH`` with ``CHALLENGE_NAME: SRP_A`` rather
63
+ than the more usual ``USER_SRP_AUTH``; it answers with ``PASSWORD_VERIFIER``.
64
+ """
65
+ return CognitoCall(
66
+ "InitiateAuth",
67
+ {
68
+ "AuthFlow": "CUSTOM_AUTH",
69
+ "ClientId": settings.client_id,
70
+ "AuthParameters": srp.auth_parameters(),
71
+ },
72
+ )
73
+
74
+
75
+ def respond_password_verifier(
76
+ settings: Settings, responses: dict[str, str], session: str
77
+ ) -> CognitoCall:
78
+ """Step 2: the SRP proof. ``Session`` must be echoed back for ``CUSTOM_AUTH``."""
79
+ return CognitoCall(
80
+ "RespondToAuthChallenge",
81
+ {
82
+ "ClientId": settings.client_id,
83
+ "ChallengeName": PASSWORD_VERIFIER,
84
+ "ChallengeResponses": responses,
85
+ "Session": session,
86
+ },
87
+ )
88
+
89
+
90
+ def respond_mfa(settings: Settings, challenge: Challenge, username: str, code: str) -> CognitoCall:
91
+ """Answer a software-token or SMS MFA challenge."""
92
+ key = "SOFTWARE_TOKEN_MFA_CODE" if challenge.name == SOFTWARE_TOKEN_MFA else "SMS_MFA_CODE"
93
+ return CognitoCall(
94
+ "RespondToAuthChallenge",
95
+ {
96
+ "ClientId": settings.client_id,
97
+ "ChallengeName": challenge.name,
98
+ "ChallengeResponses": {"USERNAME": username, key: code},
99
+ "Session": challenge.session,
100
+ },
101
+ )
102
+
103
+
104
+ def refresh(settings: Settings, refresh_token: str) -> CognitoCall:
105
+ """Renew the id token. Needs no password and no AWS credentials."""
106
+ return CognitoCall(
107
+ "InitiateAuth",
108
+ {
109
+ "AuthFlow": "REFRESH_TOKEN_AUTH",
110
+ "ClientId": settings.client_id,
111
+ "AuthParameters": {"REFRESH_TOKEN": refresh_token},
112
+ },
113
+ )
114
+
115
+
116
+ def parse_outcome(payload: dict[str, Any], *, refresh_token: str | None = None) -> Outcome:
117
+ """Turn a Cognito response into tokens or the next challenge."""
118
+ result = payload.get("AuthenticationResult")
119
+ if result:
120
+ return TokenSet.from_cognito(result, refresh_token=refresh_token)
121
+
122
+ name = payload.get("ChallengeName")
123
+ if name is None:
124
+ raise errors.CognitoError("UnexpectedResponse", f"no tokens and no challenge: {payload}")
125
+ if name == NEW_PASSWORD_REQUIRED:
126
+ raise errors.AuthError("Cognito requires a password change before this account can log in")
127
+ return Challenge(
128
+ name=name,
129
+ session=payload.get("Session", ""),
130
+ parameters=payload.get("ChallengeParameters", {}),
131
+ )
132
+
133
+
134
+ def raise_for_error(status: int, payload: dict[str, Any]) -> None:
135
+ """Map a Cognito error body onto :mod:`icpc.errors`."""
136
+ if status < _HTTP_ERROR:
137
+ return
138
+ code = str(payload.get("__type", "CognitoError")).rsplit("#", 1)[-1]
139
+ message = str(payload.get("message", payload))
140
+ if code in {"NotAuthorizedException", "UserNotFoundException"}:
141
+ raise errors.InvalidCredentials(f"{code}: {message}")
142
+ raise errors.CognitoError(code, message)