icpc-api 0.2.0__tar.gz → 0.3.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 (93) hide show
  1. icpc_api-0.3.0/MANIFEST.in +4 -0
  2. {icpc_api-0.2.0/icpc_api.egg-info → icpc_api-0.3.0}/PKG-INFO +1 -1
  3. {icpc_api-0.2.0 → icpc_api-0.3.0}/icpc/__init__.py +1 -1
  4. icpc_api-0.3.0/icpc/api/common.py +207 -0
  5. {icpc_api-0.2.0 → icpc_api-0.3.0}/icpc/auth/store.py +33 -16
  6. {icpc_api-0.2.0 → icpc_api-0.3.0}/icpc/cli/main.py +194 -14
  7. {icpc_api-0.2.0 → icpc_api-0.3.0}/icpc/models/common.py +13 -0
  8. icpc_api-0.3.0/icpc/models/countries.py +555 -0
  9. {icpc_api-0.2.0 → icpc_api-0.3.0}/icpc/models/entities.py +66 -0
  10. {icpc_api-0.2.0 → icpc_api-0.3.0}/icpc/search/__init__.py +2 -0
  11. {icpc_api-0.2.0 → icpc_api-0.3.0}/icpc/search/endpoint.py +3 -1
  12. icpc_api-0.3.0/icpc/search/institutions.py +40 -0
  13. {icpc_api-0.2.0 → icpc_api-0.3.0/icpc_api.egg-info}/PKG-INFO +1 -1
  14. {icpc_api-0.2.0 → icpc_api-0.3.0}/icpc_api.egg-info/SOURCES.txt +4 -15
  15. {icpc_api-0.2.0 → icpc_api-0.3.0}/tests/unit/test_auth.py +51 -2
  16. icpc_api-0.3.0/tests/unit/test_institutions.py +219 -0
  17. icpc_api-0.2.0/MANIFEST.in +0 -2
  18. icpc_api-0.2.0/icpc/api/common.py +0 -97
  19. icpc_api-0.2.0/tests/__pycache__/__init__.cpython-312.pyc +0 -0
  20. icpc_api-0.2.0/tests/unit/__pycache__/__init__.cpython-312.pyc +0 -0
  21. icpc_api-0.2.0/tests/unit/__pycache__/test_auth.cpython-312-pytest-9.1.1.pyc +0 -0
  22. icpc_api-0.2.0/tests/unit/__pycache__/test_columns.cpython-312-pytest-9.1.1.pyc +0 -0
  23. icpc_api-0.2.0/tests/unit/__pycache__/test_dsl.cpython-312-pytest-9.1.1.pyc +0 -0
  24. icpc_api-0.2.0/tests/unit/__pycache__/test_join.cpython-312-pytest-9.1.1.pyc +0 -0
  25. icpc_api-0.2.0/tests/unit/__pycache__/test_models.cpython-312-pytest-9.1.1.pyc +0 -0
  26. icpc_api-0.2.0/tests/unit/__pycache__/test_paging.cpython-312-pytest-9.1.1.pyc +0 -0
  27. icpc_api-0.2.0/tests/unit/__pycache__/test_reference.cpython-312-pytest-9.1.1.pyc +0 -0
  28. icpc_api-0.2.0/tests/unit/__pycache__/test_srp.cpython-312-pytest-9.1.1.pyc +0 -0
  29. icpc_api-0.2.0/tests/unit/__pycache__/test_surveys.cpython-312-pytest-9.1.1.pyc +0 -0
  30. icpc_api-0.2.0/tests/unit/__pycache__/test_transport.cpython-312-pytest-9.1.1.pyc +0 -0
  31. icpc_api-0.2.0/tests/unit/__pycache__/test_transport_parity.cpython-312-pytest-9.1.1.pyc +0 -0
  32. icpc_api-0.2.0/tests/unit/__pycache__/test_typing.cpython-312-pytest-9.1.1.pyc +0 -0
  33. {icpc_api-0.2.0 → icpc_api-0.3.0}/LICENSE +0 -0
  34. {icpc_api-0.2.0 → icpc_api-0.3.0}/README.md +0 -0
  35. {icpc_api-0.2.0 → icpc_api-0.3.0}/THIRD-PARTY-LICENSES.md +0 -0
  36. {icpc_api-0.2.0 → icpc_api-0.3.0}/icpc/api/__init__.py +0 -0
  37. {icpc_api-0.2.0 → icpc_api-0.3.0}/icpc/api/contest.py +0 -0
  38. {icpc_api-0.2.0 → icpc_api-0.3.0}/icpc/api/person.py +0 -0
  39. {icpc_api-0.2.0 → icpc_api-0.3.0}/icpc/api/public.py +0 -0
  40. {icpc_api-0.2.0 → icpc_api-0.3.0}/icpc/api/staff.py +0 -0
  41. {icpc_api-0.2.0 → icpc_api-0.3.0}/icpc/api/survey.py +0 -0
  42. {icpc_api-0.2.0 → icpc_api-0.3.0}/icpc/api/team.py +0 -0
  43. {icpc_api-0.2.0 → icpc_api-0.3.0}/icpc/auth/__init__.py +0 -0
  44. {icpc_api-0.2.0 → icpc_api-0.3.0}/icpc/auth/cognito.py +0 -0
  45. {icpc_api-0.2.0 → icpc_api-0.3.0}/icpc/auth/flows.py +0 -0
  46. {icpc_api-0.2.0 → icpc_api-0.3.0}/icpc/auth/provider.py +0 -0
  47. {icpc_api-0.2.0 → icpc_api-0.3.0}/icpc/auth/srp.py +0 -0
  48. {icpc_api-0.2.0 → icpc_api-0.3.0}/icpc/auth/tokens.py +0 -0
  49. {icpc_api-0.2.0 → icpc_api-0.3.0}/icpc/cli/__init__.py +0 -0
  50. {icpc_api-0.2.0 → icpc_api-0.3.0}/icpc/cli/columns.py +0 -0
  51. {icpc_api-0.2.0 → icpc_api-0.3.0}/icpc/cli/render.py +0 -0
  52. {icpc_api-0.2.0 → icpc_api-0.3.0}/icpc/config.py +0 -0
  53. {icpc_api-0.2.0 → icpc_api-0.3.0}/icpc/errors.py +0 -0
  54. {icpc_api-0.2.0 → icpc_api-0.3.0}/icpc/facade/__init__.py +0 -0
  55. {icpc_api-0.2.0 → icpc_api-0.3.0}/icpc/facade/client.py +0 -0
  56. {icpc_api-0.2.0 → icpc_api-0.3.0}/icpc/facade/domain.py +0 -0
  57. {icpc_api-0.2.0 → icpc_api-0.3.0}/icpc/models/__init__.py +0 -0
  58. {icpc_api-0.2.0 → icpc_api-0.3.0}/icpc/models/_generated.py +0 -0
  59. {icpc_api-0.2.0 → icpc_api-0.3.0}/icpc/models/base.py +0 -0
  60. {icpc_api-0.2.0 → icpc_api-0.3.0}/icpc/models/blobs.py +0 -0
  61. {icpc_api-0.2.0 → icpc_api-0.3.0}/icpc/models/enums.py +0 -0
  62. {icpc_api-0.2.0 → icpc_api-0.3.0}/icpc/models/mixins.py +0 -0
  63. {icpc_api-0.2.0 → icpc_api-0.3.0}/icpc/models/surveys.py +0 -0
  64. {icpc_api-0.2.0 → icpc_api-0.3.0}/icpc/py.typed +0 -0
  65. {icpc_api-0.2.0 → icpc_api-0.3.0}/icpc/search/_generated.py +0 -0
  66. {icpc_api-0.2.0 → icpc_api-0.3.0}/icpc/search/dsl.py +0 -0
  67. {icpc_api-0.2.0 → icpc_api-0.3.0}/icpc/search/fields.py +0 -0
  68. {icpc_api-0.2.0 → icpc_api-0.3.0}/icpc/search/surveys.py +0 -0
  69. {icpc_api-0.2.0 → icpc_api-0.3.0}/icpc/transport/__init__.py +0 -0
  70. {icpc_api-0.2.0 → icpc_api-0.3.0}/icpc/transport/_shared.py +0 -0
  71. {icpc_api-0.2.0 → icpc_api-0.3.0}/icpc/transport/async_client.py +0 -0
  72. {icpc_api-0.2.0 → icpc_api-0.3.0}/icpc/transport/operation.py +0 -0
  73. {icpc_api-0.2.0 → icpc_api-0.3.0}/icpc/transport/sync_client.py +0 -0
  74. {icpc_api-0.2.0 → icpc_api-0.3.0}/icpc_api.egg-info/dependency_links.txt +0 -0
  75. {icpc_api-0.2.0 → icpc_api-0.3.0}/icpc_api.egg-info/entry_points.txt +0 -0
  76. {icpc_api-0.2.0 → icpc_api-0.3.0}/icpc_api.egg-info/requires.txt +0 -0
  77. {icpc_api-0.2.0 → icpc_api-0.3.0}/icpc_api.egg-info/top_level.txt +0 -0
  78. {icpc_api-0.2.0 → icpc_api-0.3.0}/pyproject.toml +0 -0
  79. {icpc_api-0.2.0 → icpc_api-0.3.0}/setup.cfg +0 -0
  80. {icpc_api-0.2.0 → icpc_api-0.3.0}/tests/__init__.py +0 -0
  81. {icpc_api-0.2.0 → icpc_api-0.3.0}/tests/unit/__init__.py +0 -0
  82. {icpc_api-0.2.0 → icpc_api-0.3.0}/tests/unit/test_columns.py +0 -0
  83. {icpc_api-0.2.0 → icpc_api-0.3.0}/tests/unit/test_dsl.py +0 -0
  84. {icpc_api-0.2.0 → icpc_api-0.3.0}/tests/unit/test_join.py +0 -0
  85. {icpc_api-0.2.0 → icpc_api-0.3.0}/tests/unit/test_models.py +0 -0
  86. {icpc_api-0.2.0 → icpc_api-0.3.0}/tests/unit/test_paging.py +0 -0
  87. {icpc_api-0.2.0 → icpc_api-0.3.0}/tests/unit/test_reference.py +0 -0
  88. {icpc_api-0.2.0 → icpc_api-0.3.0}/tests/unit/test_srp.py +0 -0
  89. {icpc_api-0.2.0 → icpc_api-0.3.0}/tests/unit/test_surveys.py +0 -0
  90. {icpc_api-0.2.0 → icpc_api-0.3.0}/tests/unit/test_transport.py +0 -0
  91. {icpc_api-0.2.0 → icpc_api-0.3.0}/tests/unit/test_transport_parity.py +0 -0
  92. {icpc_api-0.2.0 → icpc_api-0.3.0}/tests/unit/test_typing.py +0 -0
  93. {icpc_api-0.2.0 → icpc_api-0.3.0}/uv.lock +0 -0
@@ -0,0 +1,4 @@
1
+ graft tests
2
+ include uv.lock
3
+ global-exclude *.py[cod]
4
+ prune **/__pycache__
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: icpc-api
3
- Version: 0.2.0
3
+ Version: 0.3.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.0"
17
+ __version__ = "0.3.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
+ )
@@ -1,26 +1,20 @@
1
1
  """On-disk credential store.
2
2
 
3
3
  Credentials live in ``~/.config/icpc/credentials.json`` (or under
4
- ``$XDG_CONFIG_HOME``), mode 0600 in a 0700 directory, keyed by username so more
5
- than one account can be cached without a profile concept. One account is the
6
- default — the one most recently logged in — so having several cached is never
7
- ambiguous.
4
+ ``$XDG_CONFIG_HOME``), mode 0600 in a 0700 directory. On Windows, where there
5
+ are no mode bits, the file inherits the ACL of the per-user config directory.
8
6
 
9
7
  The file holds the Cognito tokens and, optionally, the account password.
10
8
 
11
- **The password is stored base64-encoded, which is obfuscation and not encryption.**
12
- Anyone who can read the file can recover it in one step. It is stored at all
13
- because this Cognito app client rejects ``REFRESH_TOKEN_AUTH``: the id token dies
14
- after an hour and SRP needs the plaintext password to mint another, so an
15
- unattended job has no other way to carry on. The alternative in practice is
16
- ``ICPC_PASSWORD=…`` on a command line, which additionally leaks into shell
17
- history and every child process.
9
+ icpc.global Cognito rejects ``REFRESH_TOKEN_AUTH``, so the ID Token validity is
10
+ one hour. To renew it unattended, we store the password in the plaintext.
18
11
  """
19
12
 
20
13
  from __future__ import annotations
21
14
 
22
15
  import base64
23
16
  import binascii
17
+ import contextlib
24
18
  import json
25
19
  import os
26
20
  import tempfile
@@ -100,16 +94,17 @@ class CredentialStore:
100
94
  fd, tmp_name = tempfile.mkstemp(dir=self.path.parent, prefix=".credentials-")
101
95
  tmp = Path(tmp_name)
102
96
  try:
103
- os.fchmod(fd, 0o600)
104
- if default not in accounts:
105
- default = None
106
- payload = {"version": VERSION, "default": default, "accounts": accounts}
97
+ # We need to close the file before trying to clean it up.
107
98
  with os.fdopen(fd, "w", encoding="utf-8") as handle:
99
+ _restrict(handle.fileno())
100
+ if default not in accounts:
101
+ default = None
102
+ payload = {"version": VERSION, "default": default, "accounts": accounts}
108
103
  json.dump(payload, handle, indent=2)
109
104
  # Write-then-rename, so a crash cannot leave a truncated file behind.
110
105
  tmp.replace(self.path)
111
106
  except BaseException:
112
- tmp.unlink(missing_ok=True)
107
+ _discard(tmp)
113
108
  raise
114
109
 
115
110
  # ------------------------------------------------------------ accounts --
@@ -214,6 +209,28 @@ class CredentialStore:
214
209
  return sorted(accounts)
215
210
 
216
211
 
212
+ def _restrict(fd: int) -> None:
213
+ """Make the open temp file readable by its owner only.
214
+
215
+ On Windows, there is no POSIX bits, so don't do anything there.
216
+ """
217
+ fchmod = getattr(os, "fchmod", None)
218
+ if fchmod is None:
219
+ return
220
+ try:
221
+ fchmod(fd, 0o600)
222
+ except (NotImplementedError, OSError):
223
+ # Windows implementation of `os.fchmod` has only read-only flag in 3.13.
224
+ if os.name == "posix":
225
+ raise
226
+
227
+
228
+ def _discard(tmp: Path) -> None:
229
+ """Remove a half-written temp file, best effort."""
230
+ with contextlib.suppress(OSError):
231
+ tmp.unlink(missing_ok=True)
232
+
233
+
217
234
  def _decode(value: object) -> str | None:
218
235
  if not isinstance(value, str) or not value:
219
236
  return None
@@ -6,11 +6,14 @@ client exists.
6
6
 
7
7
  from __future__ import annotations
8
8
 
9
+ import copy
9
10
  import os
11
+ import re
10
12
  import sys
11
13
  from collections.abc import Sequence
12
14
  from dataclasses import dataclass
13
15
  from datetime import UTC, datetime
16
+ from pathlib import Path
14
17
  from typing import Annotated, Any
15
18
 
16
19
  import typer
@@ -42,10 +45,12 @@ 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.enums import ExportType, InstitutionUnitType, MemberRole, TeamStatus
46
50
  from icpc.search import _generated as endpoints
47
51
  from icpc.search.dsl import Filter, SortKey
48
52
  from icpc.search.endpoint import SearchEndpoint
53
+ from icpc.search.institutions import institutions
49
54
  from icpc.search.surveys import survey_responses
50
55
  from icpc.transport.operation import Request, json_op
51
56
 
@@ -57,6 +62,7 @@ public_app = typer.Typer(no_args_is_help=True, help="Public endpoints; no login
57
62
  person_app = typer.Typer(no_args_is_help=True, help="Look people up.")
58
63
  staff_app = typer.Typer(no_args_is_help=True, help="Contest staff.")
59
64
  survey_app = typer.Typer(no_args_is_help=True, help="Contest surveys and their answers.")
65
+ institution_app = typer.Typer(no_args_is_help=True, help="Find and edit institutions.")
60
66
  app.add_typer(auth_app, name="auth")
61
67
  app.add_typer(contest_app, name="contest")
62
68
  app.add_typer(team_app, name="team")
@@ -64,6 +70,7 @@ app.add_typer(public_app, name="public")
64
70
  app.add_typer(person_app, name="person")
65
71
  app.add_typer(staff_app, name="staff")
66
72
  app.add_typer(survey_app, name="survey")
73
+ app.add_typer(institution_app, name="institution")
67
74
 
68
75
 
69
76
  @dataclass
@@ -79,12 +86,16 @@ def _ctx(ctx: typer.Context) -> Context:
79
86
  def _client(ctx: typer.Context) -> Icpc:
80
87
  """Build a client from whatever credentials are available.
81
88
 
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.
89
+ Looks up in the following order:
90
+ - `ICPC_ID_TOKEN` or `ICPC_REFRESH_TOKEN` environment variables
91
+ - `ICPC_USERNAME` and `ICPC_PASSWORD` variables
92
+ - credentials stored with `icpc auth login`
84
93
  """
85
94
  state = _ctx(ctx)
86
95
  if os.environ.get("ICPC_ID_TOKEN") or os.environ.get("ICPC_REFRESH_TOKEN"):
87
96
  return Icpc.from_token()
97
+ if os.environ.get("ICPC_PASSWORD"):
98
+ return Icpc.from_password(state.username)
88
99
  return Icpc.from_store(state.username)
89
100
 
90
101
 
@@ -334,7 +345,7 @@ def person_show(ctx: typer.Context, person_id: int) -> None:
334
345
  render(icpc.send(person_api.get(person_id)), _ctx(ctx).output)
335
346
 
336
347
 
337
- @app.command("institution")
348
+ @institution_app.command("find")
338
349
  def institution_find(
339
350
  ctx: typer.Context,
340
351
  name: Annotated[str, typer.Argument(help="Institution name; three characters or more.")],
@@ -346,8 +357,8 @@ def institution_find(
346
357
  """Find an institution id, for registering a team.
347
358
 
348
359
  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.
360
+ different number from the `instId` and `instUnitId` columns of
361
+ `institution search`, which point at other tables.
351
362
  """
352
363
  with _client(ctx) as icpc:
353
364
  rows = icpc.send(common_api.institution_suggest(name, size=limit))
@@ -360,6 +371,166 @@ def institution_find(
360
371
  render(rows, _ctx(ctx).output, columns=["id", "name", "country", "url"])
361
372
 
362
373
 
374
+ @institution_app.command("search")
375
+ def institution_search(
376
+ ctx: typer.Context,
377
+ name: Annotated[str | None, typer.Argument(help="Substring of instName.")] = None,
378
+ filter_: Annotated[
379
+ list[str] | None, typer.Option("--filter", help="COLUMN#VALUE, repeatable.")
380
+ ] = None,
381
+ limit: Annotated[int, typer.Option("--limit", help="Stop after this many rows.")] = 20,
382
+ ) -> None:
383
+ """Search every institution, as the admin grid does.
384
+
385
+ icpc institution search Massachusetts --filter countryName#"United States"
386
+
387
+ `instId` is what `institution show` and `set` take; `instUnitId` is what
388
+ `unit` and `set-unit` take.
389
+ """
390
+ endpoint = institutions()
391
+ raw = ([f"instName#{name}"] if name else []) + (filter_ or [])
392
+ q = endpoint.query(filters=_parse_filters(raw))
393
+ with _client(ctx) as icpc:
394
+ rows = icpc.all(endpoint, q, max_rows=limit)
395
+ render(rows, _ctx(ctx).output, columns=list(q.proj))
396
+
397
+
398
+ #: Keys of `institution create`: top-level, then the ones that go in mailingAddress.
399
+ _NEW_INSTITUTION = ("name", "shortName", "homepageUrl", "institutionUnitType")
400
+ _NEW_ADDRESS = ("addressLine1", "addressLine2", "addressLine3", "city", "state", "zip", "country")
401
+
402
+
403
+ @institution_app.command("set-logo")
404
+ def institution_set_logo(
405
+ ctx: typer.Context,
406
+ institution_id: int,
407
+ path: Annotated[Path, typer.Argument(exists=True, dir_okay=False, help="The image file.")],
408
+ ) -> None:
409
+ """Upload an institution's logo, by instId: SVG, JPEG, BMP, PNG or GIF, under 3000 kB.
410
+
411
+ icpc institution set-logo 1145 mit.svg
412
+ """
413
+ content = path.read_bytes()
414
+ try:
415
+ mime = common_api.logo_mime(path.name, len(content))
416
+ except ValueError as e:
417
+ raise typer.BadParameter(str(e)) from None
418
+ with _client(ctx) as icpc:
419
+ icpc.send(common_api.set_institution_logo(institution_id, path.name, content, mime))
420
+ note(f"logo of institution {institution_id} set from {path}")
421
+
422
+
423
+ @institution_app.command("create")
424
+ def institution_create(
425
+ ctx: typer.Context,
426
+ fields: Annotated[list[str], typer.Argument(help="KEY=VALUE; see below.")],
427
+ ) -> None:
428
+ """Suggest a new institution, as the "can't find my institution" form does.
429
+
430
+ icpc institution create "name=Massachusetts Institute of Technology" "shortName=MIT" \\
431
+ homepageUrl=https://web.mit.edu/ institutionUnitType=UNIVERSITY_GRADUATE \\
432
+ "addressLine1=77 Massachusetts Avenue" city=Cambridge state=MA zip=02139 country=US
433
+
434
+ Keys: name, shortName, homepageUrl, institutionUnitType (HIGH_SCHOOL,
435
+ UNIVERSITY_NO_GRADUATE, UNIVERSITY_GRADUATE), addressLine1-3, city, state,
436
+ zip, country (ISO code or name). Values stay strings, so zip=01234 is kept.
437
+ """
438
+ values: dict[str, str] = {}
439
+ for pair in fields:
440
+ key, sep, value = pair.partition("=")
441
+ if not sep or key not in _NEW_INSTITUTION + _NEW_ADDRESS:
442
+ raise typer.BadParameter(
443
+ f"expected KEY=VALUE with KEY one of {', '.join(_NEW_INSTITUTION + _NEW_ADDRESS)};"
444
+ f" got {pair!r}"
445
+ )
446
+ values[key] = value
447
+ missing = [k for k in (*_NEW_INSTITUTION, "country") if not values.get(k)]
448
+ if missing:
449
+ raise typer.BadParameter(f"missing {', '.join(missing)}")
450
+ try:
451
+ InstitutionUnitType(values["institutionUnitType"])
452
+ values["country"] = country(values["country"]).model_dump(by_alias=True) # type: ignore[assignment]
453
+ except ValueError as e:
454
+ raise typer.BadParameter(str(e)) from None
455
+ body: dict[str, object] = {k: values[k] for k in _NEW_INSTITUTION}
456
+ body["mailingAddress"] = {k: values[k] for k in _NEW_ADDRESS if k in values}
457
+ with _client(ctx) as icpc:
458
+ render(icpc.send(common_api.create_suggested_institution(body)), _ctx(ctx).output)
459
+
460
+
461
+ @institution_app.command("approve")
462
+ def institution_approve(
463
+ ctx: typer.Context,
464
+ suggestion_id: Annotated[int, typer.Argument(help="The id `institution create` printed.")],
465
+ ) -> None:
466
+ """Approve a suggested institution, turning it into a real one. Needs a moderator."""
467
+ with _client(ctx) as icpc:
468
+ icpc.send(common_api.approve_suggested_institution(suggestion_id))
469
+ note(f"suggested institution {suggestion_id} approved")
470
+
471
+
472
+ @institution_app.command("show")
473
+ def institution_show(ctx: typer.Context, institution_id: int) -> None:
474
+ """An institution, by instId."""
475
+ with _client(ctx) as icpc:
476
+ render(icpc.send(common_api.institution(institution_id)), _ctx(ctx).output)
477
+
478
+
479
+ @institution_app.command("units")
480
+ def institution_units(ctx: typer.Context, institution_id: int) -> None:
481
+ """The units of an institution, by instId; usually exactly one."""
482
+ with _client(ctx) as icpc:
483
+ render(icpc.send(common_api.institution_units(institution_id)), _ctx(ctx).output)
484
+
485
+
486
+ @institution_app.command("unit")
487
+ def institution_unit(ctx: typer.Context, unit_id: int) -> None:
488
+ """An institution unit, by instUnitId."""
489
+ with _client(ctx) as icpc:
490
+ render(icpc.send(common_api.institution_unit(unit_id)), _ctx(ctx).output)
491
+
492
+
493
+ @institution_app.command("set")
494
+ def institution_set(
495
+ ctx: typer.Context,
496
+ institution_id: int,
497
+ changes: Annotated[list[str], typer.Argument(help="KEY=VALUE, e.g. shortName=MIT.")],
498
+ ) -> None:
499
+ """Change an institution's names or homepage.
500
+
501
+ icpc institution set 1145 shortName=MIT homepageUrl=https://web.mit.edu/
502
+
503
+ The unit repeats these names; change it too with `set-unit`.
504
+ """
505
+ _edit(
506
+ ctx,
507
+ "institution",
508
+ common_api.institution(institution_id),
509
+ common_api.update_institution,
510
+ changes,
511
+ )
512
+
513
+
514
+ @institution_app.command("set-unit")
515
+ def institution_set_unit(
516
+ ctx: typer.Context,
517
+ unit_id: int,
518
+ changes: Annotated[list[str], typer.Argument(help="KEY=VALUE; nested as a.b=VALUE.")],
519
+ ) -> None:
520
+ """Change an institution unit, address and social links included.
521
+
522
+ icpc institution set-unit 1220 shortName=MIT mailingAddress.city=Cambridge \\
523
+ socialInfo.twitterName=mit
524
+ """
525
+ _edit(
526
+ ctx,
527
+ "institution unit",
528
+ common_api.institution_unit(unit_id),
529
+ common_api.update_institution_unit,
530
+ changes,
531
+ )
532
+
533
+
363
534
  # ------------------------------------------------------------------- staff --
364
535
 
365
536
 
@@ -715,6 +886,8 @@ def _assignments(pairs: list[str]) -> dict[str, Any]:
715
886
  out[key] = None
716
887
  elif raw.lstrip("-").isdigit():
717
888
  out[key] = int(raw)
889
+ elif re.fullmatch(r"-?\d+\.\d+", raw):
890
+ out[key] = float(raw)
718
891
  else:
719
892
  out[key] = raw.strip('"')
720
893
  return out
@@ -724,19 +897,26 @@ def _edit(ctx: typer.Context, label: str, read: Any, write: Any, pairs: list[str
724
897
  """Read a settings object, apply ``key=value`` changes, write it back.
725
898
 
726
899
  These endpoints are full-object replaces, so the read is not optional: send
727
- only the changed keys and everything else is wiped.
900
+ only the changed keys and everything else is wiped. ``a.b=value`` sets a key
901
+ of a nested object.
728
902
  """
729
903
  changes = _assignments(pairs)
730
904
  with _client(ctx) as icpc:
731
905
  current = icpc.send(read)
732
906
  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}))
907
+ obj = copy.deepcopy(obj)
908
+ for path, value in changes.items():
909
+ *parents, key = path.split(".")
910
+ target = obj
911
+ for part in parents:
912
+ target = target.get(part) if isinstance(target, dict) else None
913
+ if not isinstance(target, dict) or key not in target:
914
+ known = sorted(target) if isinstance(target, dict) else []
915
+ fail(f"{label} has no field {path!r}; known here: {', '.join(known)}")
916
+ raise typer.Exit(1)
917
+ note(f"{path}: {target[key]!r} -> {value!r}")
918
+ target[key] = value
919
+ icpc.send(write(obj))
740
920
  after = icpc.send(read)
741
921
  render(after, _ctx(ctx).output)
742
922
 
@@ -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):