icpc-api 0.2.1__tar.gz → 0.4.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. {icpc_api-0.2.1/icpc_api.egg-info → icpc_api-0.4.0}/PKG-INFO +1 -1
  2. {icpc_api-0.2.1 → icpc_api-0.4.0}/icpc/__init__.py +1 -1
  3. icpc_api-0.4.0/icpc/api/common.py +207 -0
  4. {icpc_api-0.2.1 → icpc_api-0.4.0}/icpc/cli/main.py +283 -28
  5. {icpc_api-0.2.1 → icpc_api-0.4.0}/icpc/models/common.py +13 -0
  6. icpc_api-0.4.0/icpc/models/countries.py +555 -0
  7. {icpc_api-0.2.1 → icpc_api-0.4.0}/icpc/models/entities.py +85 -0
  8. {icpc_api-0.2.1 → icpc_api-0.4.0}/icpc/search/__init__.py +2 -0
  9. {icpc_api-0.2.1 → icpc_api-0.4.0}/icpc/search/endpoint.py +3 -1
  10. icpc_api-0.4.0/icpc/search/institutions.py +40 -0
  11. {icpc_api-0.2.1 → icpc_api-0.4.0/icpc_api.egg-info}/PKG-INFO +1 -1
  12. {icpc_api-0.2.1 → icpc_api-0.4.0}/icpc_api.egg-info/SOURCES.txt +3 -0
  13. icpc_api-0.4.0/tests/unit/test_institutions.py +257 -0
  14. icpc_api-0.2.1/icpc/api/common.py +0 -97
  15. {icpc_api-0.2.1 → icpc_api-0.4.0}/LICENSE +0 -0
  16. {icpc_api-0.2.1 → icpc_api-0.4.0}/MANIFEST.in +0 -0
  17. {icpc_api-0.2.1 → icpc_api-0.4.0}/README.md +0 -0
  18. {icpc_api-0.2.1 → icpc_api-0.4.0}/THIRD-PARTY-LICENSES.md +0 -0
  19. {icpc_api-0.2.1 → icpc_api-0.4.0}/icpc/api/__init__.py +0 -0
  20. {icpc_api-0.2.1 → icpc_api-0.4.0}/icpc/api/contest.py +0 -0
  21. {icpc_api-0.2.1 → icpc_api-0.4.0}/icpc/api/person.py +0 -0
  22. {icpc_api-0.2.1 → icpc_api-0.4.0}/icpc/api/public.py +0 -0
  23. {icpc_api-0.2.1 → icpc_api-0.4.0}/icpc/api/staff.py +0 -0
  24. {icpc_api-0.2.1 → icpc_api-0.4.0}/icpc/api/survey.py +0 -0
  25. {icpc_api-0.2.1 → icpc_api-0.4.0}/icpc/api/team.py +0 -0
  26. {icpc_api-0.2.1 → icpc_api-0.4.0}/icpc/auth/__init__.py +0 -0
  27. {icpc_api-0.2.1 → icpc_api-0.4.0}/icpc/auth/cognito.py +0 -0
  28. {icpc_api-0.2.1 → icpc_api-0.4.0}/icpc/auth/flows.py +0 -0
  29. {icpc_api-0.2.1 → icpc_api-0.4.0}/icpc/auth/provider.py +0 -0
  30. {icpc_api-0.2.1 → icpc_api-0.4.0}/icpc/auth/srp.py +0 -0
  31. {icpc_api-0.2.1 → icpc_api-0.4.0}/icpc/auth/store.py +0 -0
  32. {icpc_api-0.2.1 → icpc_api-0.4.0}/icpc/auth/tokens.py +0 -0
  33. {icpc_api-0.2.1 → icpc_api-0.4.0}/icpc/cli/__init__.py +0 -0
  34. {icpc_api-0.2.1 → icpc_api-0.4.0}/icpc/cli/columns.py +0 -0
  35. {icpc_api-0.2.1 → icpc_api-0.4.0}/icpc/cli/render.py +0 -0
  36. {icpc_api-0.2.1 → icpc_api-0.4.0}/icpc/config.py +0 -0
  37. {icpc_api-0.2.1 → icpc_api-0.4.0}/icpc/errors.py +0 -0
  38. {icpc_api-0.2.1 → icpc_api-0.4.0}/icpc/facade/__init__.py +0 -0
  39. {icpc_api-0.2.1 → icpc_api-0.4.0}/icpc/facade/client.py +0 -0
  40. {icpc_api-0.2.1 → icpc_api-0.4.0}/icpc/facade/domain.py +0 -0
  41. {icpc_api-0.2.1 → icpc_api-0.4.0}/icpc/models/__init__.py +0 -0
  42. {icpc_api-0.2.1 → icpc_api-0.4.0}/icpc/models/_generated.py +0 -0
  43. {icpc_api-0.2.1 → icpc_api-0.4.0}/icpc/models/base.py +0 -0
  44. {icpc_api-0.2.1 → icpc_api-0.4.0}/icpc/models/blobs.py +0 -0
  45. {icpc_api-0.2.1 → icpc_api-0.4.0}/icpc/models/enums.py +0 -0
  46. {icpc_api-0.2.1 → icpc_api-0.4.0}/icpc/models/mixins.py +0 -0
  47. {icpc_api-0.2.1 → icpc_api-0.4.0}/icpc/models/surveys.py +0 -0
  48. {icpc_api-0.2.1 → icpc_api-0.4.0}/icpc/py.typed +0 -0
  49. {icpc_api-0.2.1 → icpc_api-0.4.0}/icpc/search/_generated.py +0 -0
  50. {icpc_api-0.2.1 → icpc_api-0.4.0}/icpc/search/dsl.py +0 -0
  51. {icpc_api-0.2.1 → icpc_api-0.4.0}/icpc/search/fields.py +0 -0
  52. {icpc_api-0.2.1 → icpc_api-0.4.0}/icpc/search/surveys.py +0 -0
  53. {icpc_api-0.2.1 → icpc_api-0.4.0}/icpc/transport/__init__.py +0 -0
  54. {icpc_api-0.2.1 → icpc_api-0.4.0}/icpc/transport/_shared.py +0 -0
  55. {icpc_api-0.2.1 → icpc_api-0.4.0}/icpc/transport/async_client.py +0 -0
  56. {icpc_api-0.2.1 → icpc_api-0.4.0}/icpc/transport/operation.py +0 -0
  57. {icpc_api-0.2.1 → icpc_api-0.4.0}/icpc/transport/sync_client.py +0 -0
  58. {icpc_api-0.2.1 → icpc_api-0.4.0}/icpc_api.egg-info/dependency_links.txt +0 -0
  59. {icpc_api-0.2.1 → icpc_api-0.4.0}/icpc_api.egg-info/entry_points.txt +0 -0
  60. {icpc_api-0.2.1 → icpc_api-0.4.0}/icpc_api.egg-info/requires.txt +0 -0
  61. {icpc_api-0.2.1 → icpc_api-0.4.0}/icpc_api.egg-info/top_level.txt +0 -0
  62. {icpc_api-0.2.1 → icpc_api-0.4.0}/pyproject.toml +0 -0
  63. {icpc_api-0.2.1 → icpc_api-0.4.0}/setup.cfg +0 -0
  64. {icpc_api-0.2.1 → icpc_api-0.4.0}/tests/__init__.py +0 -0
  65. {icpc_api-0.2.1 → icpc_api-0.4.0}/tests/unit/__init__.py +0 -0
  66. {icpc_api-0.2.1 → icpc_api-0.4.0}/tests/unit/test_auth.py +0 -0
  67. {icpc_api-0.2.1 → icpc_api-0.4.0}/tests/unit/test_columns.py +0 -0
  68. {icpc_api-0.2.1 → icpc_api-0.4.0}/tests/unit/test_dsl.py +0 -0
  69. {icpc_api-0.2.1 → icpc_api-0.4.0}/tests/unit/test_join.py +0 -0
  70. {icpc_api-0.2.1 → icpc_api-0.4.0}/tests/unit/test_models.py +0 -0
  71. {icpc_api-0.2.1 → icpc_api-0.4.0}/tests/unit/test_paging.py +0 -0
  72. {icpc_api-0.2.1 → icpc_api-0.4.0}/tests/unit/test_reference.py +0 -0
  73. {icpc_api-0.2.1 → icpc_api-0.4.0}/tests/unit/test_srp.py +0 -0
  74. {icpc_api-0.2.1 → icpc_api-0.4.0}/tests/unit/test_surveys.py +0 -0
  75. {icpc_api-0.2.1 → icpc_api-0.4.0}/tests/unit/test_transport.py +0 -0
  76. {icpc_api-0.2.1 → icpc_api-0.4.0}/tests/unit/test_transport_parity.py +0 -0
  77. {icpc_api-0.2.1 → icpc_api-0.4.0}/tests/unit/test_typing.py +0 -0
  78. {icpc_api-0.2.1 → icpc_api-0.4.0}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: icpc-api
3
- Version: 0.2.1
3
+ Version: 0.4.0
4
4
  Summary: Unofficial Python client and CLI for the icpc.global API
5
5
  Author-email: Nikita Sychev <root@nsychev.ru>
6
6
  License-Expression: MIT
@@ -14,7 +14,7 @@ from icpc.errors import ApiError, AuthError, IcpcError, SearchError
14
14
  from icpc.facade.client import AsyncIcpc, Icpc, Include
15
15
  from icpc.facade.domain import ContestView, Member, Team
16
16
 
17
- __version__ = "0.2.1"
17
+ __version__ = "0.4.0"
18
18
 
19
19
  __all__ = [
20
20
  "ApiError",
@@ -0,0 +1,207 @@
1
+ """``/common``, ``/icpcprofile`` and the ``/aspectfaces`` schema registry."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import mimetypes
6
+
7
+ from icpc.models.base import Row
8
+ from icpc.models.entities import (
9
+ Globals,
10
+ Institution,
11
+ InstitutionSuggestion,
12
+ InstitutionUnit,
13
+ SuggestedInstitution,
14
+ )
15
+ from icpc.transport.operation import Operation, Request, list_op, model_op, none_op, scalar_op
16
+
17
+ __all__ = [
18
+ "AspectFacesField",
19
+ "AspectFacesSchema",
20
+ "approve_suggested_institution",
21
+ "create_suggested_institution",
22
+ "globals_",
23
+ "institution",
24
+ "institution_suggest",
25
+ "institution_unit",
26
+ "institution_units",
27
+ "schema",
28
+ "set_institution_logo",
29
+ "update_institution",
30
+ "update_institution_unit",
31
+ "wf_year",
32
+ ]
33
+
34
+
35
+ def globals_() -> Operation[Globals]:
36
+ """Site-wide settings: the current World Finals and regionals years."""
37
+ return model_op(Request("GET", "/common/globals/all"), Globals)
38
+
39
+
40
+ def wf_year() -> Operation[int]:
41
+ """The current World Finals year."""
42
+ return scalar_op(Request("GET", "/common/globals/WFYear"), int)
43
+
44
+
45
+ def suggested_institution(institution_id: int) -> Operation[dict[str, object]]:
46
+ """A suggested-institution record."""
47
+ return model_op(
48
+ Request("GET", f"/common/suggestedinstitution/{institution_id}"), dict[str, object]
49
+ )
50
+
51
+
52
+ class AspectFacesField(Row):
53
+ """One field of a server-side form definition."""
54
+
55
+ name: str | None = None
56
+ tag: str | None = None
57
+ label: str | None = None
58
+ label_key: str | None = None
59
+ placeholder: str | None = None
60
+ order: int | None = None
61
+ #: Allowed values, when the field is a choice — the closest thing to an enum
62
+ #: definition this API publishes.
63
+ options: list[object] | None = None
64
+ constraints: object | None = None
65
+ tooltip: str | None = None
66
+
67
+
68
+ class AspectFacesSchema(Row):
69
+ """``GET /aspectfaces/<java.class.Name>`` — a form definition."""
70
+
71
+ name: str | None = None
72
+ fields: list[AspectFacesField] | None = None
73
+ obj: object | None = None
74
+
75
+
76
+ def schema(java_class: str, *associations: str) -> Operation[AspectFacesSchema]:
77
+ """Fetch a server-side form definition.
78
+
79
+ This is the only schema the API exposes. It is the authoritative source for
80
+ enum option lists and required-field constraints, and therefore the right place
81
+ to look before constructing a write payload::
82
+
83
+ schema("global.icpc.base.model.team.businessobjects.Team", "teamInfo")
84
+ """
85
+ path = f"/aspectfaces/{java_class}"
86
+ if associations:
87
+ path += "->" + ",".join(associations)
88
+ return model_op(Request("GET", path), AspectFacesSchema)
89
+
90
+
91
+ def countries() -> Operation[list[dict[str, object]]]:
92
+ """The country list used by the registration forms."""
93
+ return list_op(Request("GET", "/common/country/all"), dict[str, object])
94
+
95
+
96
+ def institution_suggest(
97
+ name: str, *, page: int = 1, size: int = 10
98
+ ) -> Operation[list[InstitutionSuggestion]]:
99
+ """Look an institution up by name, as the UI's picker does.
100
+
101
+ The ``id`` it returns is the ``institutionUnitId`` that
102
+ :func:`icpc.api.team.register` expects. Take care: it is a different number
103
+ from both the ``instId`` and the ``instUnitId`` columns of the institution
104
+ search grid, which are ids in other tables entirely.
105
+ """
106
+ return list_op(
107
+ Request(
108
+ "GET",
109
+ "/common/institutionunit/suggest",
110
+ params={"name": name, "page": page, "size": size},
111
+ ),
112
+ InstitutionSuggestion,
113
+ )
114
+
115
+
116
+ def institution(institution_id: int) -> Operation[Institution]:
117
+ """An institution, by the ``instId`` of the institution search grids."""
118
+ return model_op(Request("GET", f"/common/institution/{institution_id}"), Institution)
119
+
120
+
121
+ def institution_units(institution_id: int) -> Operation[list[InstitutionUnit]]:
122
+ """The units of an institution; usually exactly one."""
123
+ return list_op(
124
+ Request("GET", f"/common/institutionunit/inst/{institution_id}"), InstitutionUnit
125
+ )
126
+
127
+
128
+ def institution_unit(unit_id: int) -> Operation[InstitutionUnit]:
129
+ """An institution unit, by the ``instUnitId`` of the institution search grids."""
130
+ return model_op(Request("GET", f"/common/institutionunit/{unit_id}"), InstitutionUnit)
131
+
132
+
133
+ def update_institution(institution: dict[str, object]) -> Operation[Institution]:
134
+ """Overwrite an institution's names and homepage.
135
+
136
+ A full-object replace keyed by ``id`` and ``version``: read with
137
+ :func:`institution`, change what you want, send it all back.
138
+ """
139
+ return model_op(
140
+ Request("POST", "/common/institution", json=institution, idempotent=False), Institution
141
+ )
142
+
143
+
144
+ def update_institution_unit(unit: dict[str, object]) -> Operation[InstitutionUnit]:
145
+ """Overwrite an institution unit, address and social links included.
146
+
147
+ A full-object replace, like :func:`update_institution`. The unit repeats the
148
+ institution's names; the two are not kept in sync by the server.
149
+ """
150
+ return model_op(
151
+ Request("POST", "/common/institutionunit", json=unit, idempotent=False), InstitutionUnit
152
+ )
153
+
154
+
155
+ def create_suggested_institution(
156
+ institution: dict[str, object],
157
+ ) -> Operation[SuggestedInstitution]:
158
+ """Suggest a new institution, as the "can't find my institution" form does.
159
+
160
+ Required: ``name`` (7+ characters), ``shortName``, ``homepageUrl`` and
161
+ ``institutionUnitType``. ``mailingAddress.country`` is a whole country object;
162
+ take it from :func:`icpc.models.countries.country`.
163
+ """
164
+ return model_op(
165
+ Request("POST", "/common/suggestedinstitution/", json=institution, idempotent=False),
166
+ SuggestedInstitution,
167
+ )
168
+
169
+
170
+ def approve_suggested_institution(suggestion_id: int) -> Operation[None]:
171
+ """Turn a suggestion from :func:`create_suggested_institution` into an institution."""
172
+ return none_op(
173
+ Request("PUT", f"/common/suggestedinstitution/approve/{suggestion_id}", idempotent=False)
174
+ )
175
+
176
+
177
+ #: What the logo form accepts; the server enforces the same limits.
178
+ LOGO_TYPES = frozenset({"image/svg+xml", "image/jpeg", "image/bmp", "image/png", "image/gif"})
179
+ LOGO_MAX_BYTES = 3_000_000
180
+
181
+
182
+ def logo_mime(filename: str, size: int) -> str:
183
+ """The content type for a logo upload, or ``ValueError`` if the form would refuse it."""
184
+ mime, _ = mimetypes.guess_type(filename)
185
+ if mime not in LOGO_TYPES:
186
+ raise ValueError(f"{filename}: a logo must be SVG, JPEG, BMP, PNG or GIF")
187
+ if size > LOGO_MAX_BYTES:
188
+ raise ValueError(f"{filename}: {size} bytes; a logo must be under 3000 kB")
189
+ return mime
190
+
191
+
192
+ def set_institution_logo(
193
+ institution_id: int, filename: str, content: bytes, mime: str
194
+ ) -> Operation[None]:
195
+ """Upload an institution's logo, replacing any current one.
196
+
197
+ Keyed by ``instId``. SVG, JPEG, BMP, PNG or GIF, at most 3000 kB;
198
+ :func:`logo_mime` checks both and picks ``mime``.
199
+ """
200
+ return none_op(
201
+ Request(
202
+ "POST",
203
+ f"/common/logo/institution/{institution_id}",
204
+ files={"file": (filename, content, mime)},
205
+ idempotent=False,
206
+ )
207
+ )
@@ -6,14 +6,17 @@ client exists.
6
6
 
7
7
  from __future__ import annotations
8
8
 
9
+ import copy
9
10
  import os
10
11
  import sys
11
12
  from collections.abc import Sequence
12
13
  from dataclasses import dataclass
13
14
  from datetime import UTC, datetime
15
+ from pathlib import Path
14
16
  from typing import Annotated, Any
15
17
 
16
18
  import typer
19
+ from pydantic import BaseModel, TypeAdapter, ValidationError
17
20
 
18
21
  from icpc import errors
19
22
  from icpc.api import common as common_api
@@ -42,10 +45,13 @@ from icpc.cli.columns import (
42
45
  from icpc.cli.render import OutputFormat, fail, note, render, warn
43
46
  from icpc.config import Settings
44
47
  from icpc.facade.client import Icpc, Include
45
- from icpc.models.enums import ExportType, MemberRole, TeamStatus
48
+ from icpc.models.countries import country
49
+ from icpc.models.entities import SiteSettings
50
+ from icpc.models.enums import ExportType, InstitutionUnitType, MemberRole, TeamStatus
46
51
  from icpc.search import _generated as endpoints
47
52
  from icpc.search.dsl import Filter, SortKey
48
53
  from icpc.search.endpoint import SearchEndpoint
54
+ from icpc.search.institutions import institutions
49
55
  from icpc.search.surveys import survey_responses
50
56
  from icpc.transport.operation import Request, json_op
51
57
 
@@ -57,6 +63,7 @@ public_app = typer.Typer(no_args_is_help=True, help="Public endpoints; no login
57
63
  person_app = typer.Typer(no_args_is_help=True, help="Look people up.")
58
64
  staff_app = typer.Typer(no_args_is_help=True, help="Contest staff.")
59
65
  survey_app = typer.Typer(no_args_is_help=True, help="Contest surveys and their answers.")
66
+ institution_app = typer.Typer(no_args_is_help=True, help="Find and edit institutions.")
60
67
  app.add_typer(auth_app, name="auth")
61
68
  app.add_typer(contest_app, name="contest")
62
69
  app.add_typer(team_app, name="team")
@@ -64,6 +71,7 @@ app.add_typer(public_app, name="public")
64
71
  app.add_typer(person_app, name="person")
65
72
  app.add_typer(staff_app, name="staff")
66
73
  app.add_typer(survey_app, name="survey")
74
+ app.add_typer(institution_app, name="institution")
67
75
 
68
76
 
69
77
  @dataclass
@@ -79,12 +87,16 @@ def _ctx(ctx: typer.Context) -> Context:
79
87
  def _client(ctx: typer.Context) -> Icpc:
80
88
  """Build a client from whatever credentials are available.
81
89
 
82
- An explicit token in the environment wins, so CI and one-off shells do not
83
- need a login; otherwise the tokens cached by ``icpc auth login`` are used.
90
+ Looks up in the following order:
91
+ - `ICPC_ID_TOKEN` or `ICPC_REFRESH_TOKEN` environment variables
92
+ - `ICPC_USERNAME` and `ICPC_PASSWORD` variables
93
+ - credentials stored with `icpc auth login`
84
94
  """
85
95
  state = _ctx(ctx)
86
96
  if os.environ.get("ICPC_ID_TOKEN") or os.environ.get("ICPC_REFRESH_TOKEN"):
87
97
  return Icpc.from_token()
98
+ if os.environ.get("ICPC_PASSWORD"):
99
+ return Icpc.from_password(state.username)
88
100
  return Icpc.from_store(state.username)
89
101
 
90
102
 
@@ -334,7 +346,7 @@ def person_show(ctx: typer.Context, person_id: int) -> None:
334
346
  render(icpc.send(person_api.get(person_id)), _ctx(ctx).output)
335
347
 
336
348
 
337
- @app.command("institution")
349
+ @institution_app.command("find")
338
350
  def institution_find(
339
351
  ctx: typer.Context,
340
352
  name: Annotated[str, typer.Argument(help="Institution name; three characters or more.")],
@@ -346,8 +358,8 @@ def institution_find(
346
358
  """Find an institution id, for registering a team.
347
359
 
348
360
  The id printed here is the one `team register --institution` wants. It is a
349
- different number from the `instId` and `instUnitId` columns of the
350
- `institutions` grid, which point at other tables.
361
+ different number from the `instId` and `instUnitId` columns of
362
+ `institution search`, which point at other tables.
351
363
  """
352
364
  with _client(ctx) as icpc:
353
365
  rows = icpc.send(common_api.institution_suggest(name, size=limit))
@@ -360,6 +372,166 @@ def institution_find(
360
372
  render(rows, _ctx(ctx).output, columns=["id", "name", "country", "url"])
361
373
 
362
374
 
375
+ @institution_app.command("search")
376
+ def institution_search(
377
+ ctx: typer.Context,
378
+ name: Annotated[str | None, typer.Argument(help="Substring of instName.")] = None,
379
+ filter_: Annotated[
380
+ list[str] | None, typer.Option("--filter", help="COLUMN#VALUE, repeatable.")
381
+ ] = None,
382
+ limit: Annotated[int, typer.Option("--limit", help="Stop after this many rows.")] = 20,
383
+ ) -> None:
384
+ """Search every institution, as the admin grid does.
385
+
386
+ icpc institution search Massachusetts --filter countryName#"United States"
387
+
388
+ `instId` is what `institution show` and `set` take; `instUnitId` is what
389
+ `unit` and `set-unit` take.
390
+ """
391
+ endpoint = institutions()
392
+ raw = ([f"instName#{name}"] if name else []) + (filter_ or [])
393
+ q = endpoint.query(filters=_parse_filters(raw))
394
+ with _client(ctx) as icpc:
395
+ rows = icpc.all(endpoint, q, max_rows=limit)
396
+ render(rows, _ctx(ctx).output, columns=list(q.proj))
397
+
398
+
399
+ #: Keys of `institution create`: top-level, then the ones that go in mailingAddress.
400
+ _NEW_INSTITUTION = ("name", "shortName", "homepageUrl", "institutionUnitType")
401
+ _NEW_ADDRESS = ("addressLine1", "addressLine2", "addressLine3", "city", "state", "zip", "country")
402
+
403
+
404
+ @institution_app.command("set-logo")
405
+ def institution_set_logo(
406
+ ctx: typer.Context,
407
+ institution_id: int,
408
+ path: Annotated[Path, typer.Argument(exists=True, dir_okay=False, help="The image file.")],
409
+ ) -> None:
410
+ """Upload an institution's logo, by instId: SVG, JPEG, BMP, PNG or GIF, under 3000 kB.
411
+
412
+ icpc institution set-logo 1145 mit.svg
413
+ """
414
+ content = path.read_bytes()
415
+ try:
416
+ mime = common_api.logo_mime(path.name, len(content))
417
+ except ValueError as e:
418
+ raise typer.BadParameter(str(e)) from None
419
+ with _client(ctx) as icpc:
420
+ icpc.send(common_api.set_institution_logo(institution_id, path.name, content, mime))
421
+ note(f"logo of institution {institution_id} set from {path}")
422
+
423
+
424
+ @institution_app.command("create")
425
+ def institution_create(
426
+ ctx: typer.Context,
427
+ fields: Annotated[list[str], typer.Argument(help="KEY=VALUE; see below.")],
428
+ ) -> None:
429
+ """Suggest a new institution, as the "can't find my institution" form does.
430
+
431
+ icpc institution create "name=Massachusetts Institute of Technology" "shortName=MIT" \\
432
+ homepageUrl=https://web.mit.edu/ institutionUnitType=UNIVERSITY_GRADUATE \\
433
+ "addressLine1=77 Massachusetts Avenue" city=Cambridge state=MA zip=02139 country=US
434
+
435
+ Keys: name, shortName, homepageUrl, institutionUnitType (HIGH_SCHOOL,
436
+ UNIVERSITY_NO_GRADUATE, UNIVERSITY_GRADUATE), addressLine1-3, city, state,
437
+ zip, country (ISO code or name). Values stay strings, so zip=01234 is kept.
438
+ """
439
+ values: dict[str, str] = {}
440
+ for pair in fields:
441
+ key, sep, value = pair.partition("=")
442
+ if not sep or key not in _NEW_INSTITUTION + _NEW_ADDRESS:
443
+ raise typer.BadParameter(
444
+ f"expected KEY=VALUE with KEY one of {', '.join(_NEW_INSTITUTION + _NEW_ADDRESS)};"
445
+ f" got {pair!r}"
446
+ )
447
+ values[key] = value
448
+ missing = [k for k in (*_NEW_INSTITUTION, "country") if not values.get(k)]
449
+ if missing:
450
+ raise typer.BadParameter(f"missing {', '.join(missing)}")
451
+ try:
452
+ InstitutionUnitType(values["institutionUnitType"])
453
+ values["country"] = country(values["country"]).model_dump(by_alias=True) # type: ignore[assignment]
454
+ except ValueError as e:
455
+ raise typer.BadParameter(str(e)) from None
456
+ body: dict[str, object] = {k: values[k] for k in _NEW_INSTITUTION}
457
+ body["mailingAddress"] = {k: values[k] for k in _NEW_ADDRESS if k in values}
458
+ with _client(ctx) as icpc:
459
+ render(icpc.send(common_api.create_suggested_institution(body)), _ctx(ctx).output)
460
+
461
+
462
+ @institution_app.command("approve")
463
+ def institution_approve(
464
+ ctx: typer.Context,
465
+ suggestion_id: Annotated[int, typer.Argument(help="The id `institution create` printed.")],
466
+ ) -> None:
467
+ """Approve a suggested institution, turning it into a real one. Needs a moderator."""
468
+ with _client(ctx) as icpc:
469
+ icpc.send(common_api.approve_suggested_institution(suggestion_id))
470
+ note(f"suggested institution {suggestion_id} approved")
471
+
472
+
473
+ @institution_app.command("show")
474
+ def institution_show(ctx: typer.Context, institution_id: int) -> None:
475
+ """An institution, by instId."""
476
+ with _client(ctx) as icpc:
477
+ render(icpc.send(common_api.institution(institution_id)), _ctx(ctx).output)
478
+
479
+
480
+ @institution_app.command("units")
481
+ def institution_units(ctx: typer.Context, institution_id: int) -> None:
482
+ """The units of an institution, by instId; usually exactly one."""
483
+ with _client(ctx) as icpc:
484
+ render(icpc.send(common_api.institution_units(institution_id)), _ctx(ctx).output)
485
+
486
+
487
+ @institution_app.command("unit")
488
+ def institution_unit(ctx: typer.Context, unit_id: int) -> None:
489
+ """An institution unit, by instUnitId."""
490
+ with _client(ctx) as icpc:
491
+ render(icpc.send(common_api.institution_unit(unit_id)), _ctx(ctx).output)
492
+
493
+
494
+ @institution_app.command("set")
495
+ def institution_set(
496
+ ctx: typer.Context,
497
+ institution_id: int,
498
+ changes: Annotated[list[str], typer.Argument(help="KEY=VALUE, e.g. shortName=MIT.")],
499
+ ) -> None:
500
+ """Change an institution's names or homepage.
501
+
502
+ icpc institution set 1145 shortName=MIT homepageUrl=https://web.mit.edu/
503
+
504
+ The unit repeats these names; change it too with `set-unit`.
505
+ """
506
+ _edit(
507
+ ctx,
508
+ "institution",
509
+ common_api.institution(institution_id),
510
+ common_api.update_institution,
511
+ changes,
512
+ )
513
+
514
+
515
+ @institution_app.command("set-unit")
516
+ def institution_set_unit(
517
+ ctx: typer.Context,
518
+ unit_id: int,
519
+ changes: Annotated[list[str], typer.Argument(help="KEY=VALUE; nested as a.b=VALUE.")],
520
+ ) -> None:
521
+ """Change an institution unit, address and social links included.
522
+
523
+ icpc institution set-unit 1220 shortName=MIT mailingAddress.city=Cambridge \\
524
+ socialInfo.twitterName=mit
525
+ """
526
+ _edit(
527
+ ctx,
528
+ "institution unit",
529
+ common_api.institution_unit(unit_id),
530
+ common_api.update_institution_unit,
531
+ changes,
532
+ )
533
+
534
+
363
535
  # ------------------------------------------------------------------- staff --
364
536
 
365
537
 
@@ -568,9 +740,59 @@ def contest_set_site(
568
740
  contest_api.site_settings(site_id),
569
741
  lambda o: contest_api.update_site_settings(contest_id, o),
570
742
  changes,
743
+ SiteSettings,
571
744
  )
572
745
 
573
746
 
747
+ _NEW_SITE_SETTINGS: dict[str, Any] = {
748
+ "details": "",
749
+ "allowRegistration": False,
750
+ "invitationOnly": False,
751
+ "homepage": "",
752
+ "additionalInfo": "",
753
+ "enforceCapacity": False,
754
+ "allowTeamChanges": False,
755
+ }
756
+ _NEW_SITE_REQUIRED = ("locationName", "capacity", "siteDelivery", "siteType")
757
+
758
+
759
+ @contest_app.command("add-site")
760
+ def contest_add_site(
761
+ ctx: typer.Context,
762
+ contest_id: int,
763
+ fields: Annotated[list[str], typer.Argument(help="KEY=VALUE; see below.")],
764
+ ) -> None:
765
+ """Add a site to a contest.
766
+
767
+ icpc contest add-site 1235 "name=World Finals" email=manager@icpc.global \
768
+ locationName=Dubai capacity=150 siteDelivery=ONSITE siteType=NORMAL \
769
+ allowRegistration=true enforceCapacity=true
770
+
771
+ Required: name, email, locationName, capacity, siteDelivery, siteType.
772
+ Optional: allowRegistration, invitationOnly, enforceCapacity, allowTeamChanges,
773
+ details, homepage, additionalInfo.
774
+ """
775
+ values = _assignments(fields)
776
+ body: dict[str, Any] = {"name": values.pop("name", None), "email": values.pop("email", None)}
777
+ missing = [k for k in ("name", "email") if not body[k]]
778
+ missing += [k for k in _NEW_SITE_REQUIRED if values.get(k) in (None, "")]
779
+ if missing:
780
+ raise typer.BadParameter(f"missing {', '.join(missing)}")
781
+ known = (*_NEW_SITE_REQUIRED, *_NEW_SITE_SETTINGS)
782
+ unknown = sorted(set(values) - set(known))
783
+ if unknown:
784
+ raise typer.BadParameter(
785
+ f"unknown {', '.join(unknown)}; known: name, email, {', '.join(known)}"
786
+ )
787
+ try:
788
+ settings = SiteSettings.model_validate({**_NEW_SITE_SETTINGS, **values})
789
+ except ValidationError as e:
790
+ raise typer.BadParameter(_validation_message(e)) from None
791
+ body["siteSettings"] = settings.model_dump(by_alias=True, exclude_unset=True)
792
+ with _client(ctx) as icpc:
793
+ render(icpc.send(contest_api.create_site(contest_id, body)), _ctx(ctx).output)
794
+
795
+
574
796
  @contest_app.command("summary")
575
797
  def contest_summary(ctx: typer.Context, contest_id: int) -> None:
576
798
  """Team counts by site and by status, from one joined fetch."""
@@ -698,49 +920,82 @@ def contest_load(
698
920
 
699
921
 
700
922
  def _assignments(pairs: list[str]) -> dict[str, Any]:
701
- """Parse ``key=value`` arguments, coercing the obvious literals.
923
+ """Parse ``key=value`` arguments; ``null`` becomes None, the rest stay strings.
702
924
 
703
- ``true``/``false``/``null`` and bare numbers become JSON values; everything
704
- else stays a string. Wrap a value in quotes to force a string.
925
+ The caller types them by its model, so ``zip=010000`` keeps its zero while
926
+ ``capacity=50`` turns into a number.
705
927
  """
706
928
  out: dict[str, Any] = {}
707
929
  for pair in pairs:
708
930
  key, sep, raw = pair.partition("=")
709
931
  if not sep:
710
932
  raise typer.BadParameter(f"expected KEY=VALUE, got {pair!r}")
711
- low = raw.lower()
712
- if low in ("true", "false"):
713
- out[key] = low == "true"
714
- elif low in ("null", "none"):
715
- out[key] = None
716
- elif raw.lstrip("-").isdigit():
717
- out[key] = int(raw)
718
- else:
719
- out[key] = raw.strip('"')
933
+ out[key] = None if raw.lower() in ("null", "none") else raw
720
934
  return out
721
935
 
722
936
 
723
- def _edit(ctx: typer.Context, label: str, read: Any, write: Any, pairs: list[str]) -> None:
937
+ def _validation_message(e: ValidationError) -> str:
938
+ return "; ".join(f"{'.'.join(map(str, err['loc']))}: {err['msg']}" for err in e.errors())
939
+
940
+
941
+ def _edit(
942
+ ctx: typer.Context,
943
+ label: str,
944
+ read: Any,
945
+ write: Any,
946
+ pairs: list[str],
947
+ model: type[BaseModel] | None = None,
948
+ ) -> None:
724
949
  """Read a settings object, apply ``key=value`` changes, write it back.
725
950
 
726
951
  These endpoints are full-object replaces, so the read is not optional: send
727
- only the changed keys and everything else is wiped.
952
+ only the changed keys and everything else is wiped. ``a.b=value`` sets a key
953
+ of a nested object.
954
+
955
+ Values are typed by ``model``, by default the model ``read`` returns. A field
956
+ the model doesn't declare takes the type of its current value, or stays a
957
+ string if that is null.
728
958
  """
729
959
  changes = _assignments(pairs)
730
960
  with _client(ctx) as icpc:
731
961
  current = icpc.send(read)
732
- obj = current if isinstance(current, dict) else current.model_dump(by_alias=True)
733
- unknown = [k for k in changes if k not in obj]
734
- if unknown:
735
- fail(f"{label} has no field(s) {unknown}; known: {', '.join(sorted(obj))}")
736
- raise typer.Exit(1)
737
- for key, value in changes.items():
738
- note(f"{key}: {obj.get(key)!r} -> {value!r}")
739
- icpc.send(write({**obj, **changes}))
962
+ if isinstance(current, BaseModel):
963
+ model = model or type(current)
964
+ current = current.model_dump(by_alias=True)
965
+ obj = copy.deepcopy(current)
966
+ try:
967
+ for path, value in changes.items():
968
+ *parents, key = path.split(".")
969
+ target = obj
970
+ for part in parents:
971
+ target = target.get(part) if isinstance(target, dict) else None
972
+ if not isinstance(target, dict) or key not in target:
973
+ known = sorted(target) if isinstance(target, dict) else []
974
+ fail(f"{label} has no field {path!r}; known here: {', '.join(known)}")
975
+ raise typer.Exit(1)
976
+ old = target[key]
977
+ if value is not None and isinstance(old, bool | int | float):
978
+ target[key] = TypeAdapter(type(old)).validate_python(value)
979
+ else:
980
+ target[key] = value
981
+ if model is not None:
982
+ obj = model.model_validate(obj).model_dump(by_alias=True, exclude_unset=True)
983
+ except ValidationError as e:
984
+ fail(f"bad value for {label}: {_validation_message(e)}")
985
+ raise typer.Exit(1) from None
986
+ for path in changes:
987
+ note(f"{path}: {_at(current, path)!r} -> {_at(obj, path)!r}")
988
+ icpc.send(write(obj))
740
989
  after = icpc.send(read)
741
990
  render(after, _ctx(ctx).output)
742
991
 
743
992
 
993
+ def _at(obj: Any, path: str) -> Any:
994
+ for part in path.split("."):
995
+ obj = obj[part]
996
+ return obj
997
+
998
+
744
999
  def _grid(name: str, entity_id: int) -> SearchEndpoint[Any, Any]:
745
1000
  factory = GRIDS.get(name)
746
1001
  if factory is None:
@@ -4,6 +4,9 @@ from __future__ import annotations
4
4
 
5
5
  import base64
6
6
  import binascii
7
+ from typing import Any
8
+
9
+ from pydantic import SerializerFunctionWrapHandler, model_serializer
7
10
 
8
11
  from icpc.models.base import Row
9
12
 
@@ -28,6 +31,16 @@ class Country(Row):
28
31
  number: int | None = None
29
32
  currency: str | None = None
30
33
  available: bool | None = None
34
+ #: Only the frontend's bundled list carries this; the server never sends it.
35
+ uuid: str | None = None
36
+
37
+ @model_serializer(mode="wrap")
38
+ def _drop_missing_uuid(self, handler: SerializerFunctionWrapHandler) -> dict[str, Any]:
39
+ """Leave out a ``uuid`` the server never sent, so edits send back what they read."""
40
+ data = handler(self)
41
+ if data.get("uuid") is None:
42
+ data.pop("uuid", None)
43
+ return data
31
44
 
32
45
 
33
46
  class FileRef(Row):