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.
- icpc_api-0.3.0/MANIFEST.in +4 -0
- {icpc_api-0.2.0/icpc_api.egg-info → icpc_api-0.3.0}/PKG-INFO +1 -1
- {icpc_api-0.2.0 → icpc_api-0.3.0}/icpc/__init__.py +1 -1
- icpc_api-0.3.0/icpc/api/common.py +207 -0
- {icpc_api-0.2.0 → icpc_api-0.3.0}/icpc/auth/store.py +33 -16
- {icpc_api-0.2.0 → icpc_api-0.3.0}/icpc/cli/main.py +194 -14
- {icpc_api-0.2.0 → icpc_api-0.3.0}/icpc/models/common.py +13 -0
- icpc_api-0.3.0/icpc/models/countries.py +555 -0
- {icpc_api-0.2.0 → icpc_api-0.3.0}/icpc/models/entities.py +66 -0
- {icpc_api-0.2.0 → icpc_api-0.3.0}/icpc/search/__init__.py +2 -0
- {icpc_api-0.2.0 → icpc_api-0.3.0}/icpc/search/endpoint.py +3 -1
- icpc_api-0.3.0/icpc/search/institutions.py +40 -0
- {icpc_api-0.2.0 → icpc_api-0.3.0/icpc_api.egg-info}/PKG-INFO +1 -1
- {icpc_api-0.2.0 → icpc_api-0.3.0}/icpc_api.egg-info/SOURCES.txt +4 -15
- {icpc_api-0.2.0 → icpc_api-0.3.0}/tests/unit/test_auth.py +51 -2
- icpc_api-0.3.0/tests/unit/test_institutions.py +219 -0
- icpc_api-0.2.0/MANIFEST.in +0 -2
- icpc_api-0.2.0/icpc/api/common.py +0 -97
- icpc_api-0.2.0/tests/__pycache__/__init__.cpython-312.pyc +0 -0
- icpc_api-0.2.0/tests/unit/__pycache__/__init__.cpython-312.pyc +0 -0
- icpc_api-0.2.0/tests/unit/__pycache__/test_auth.cpython-312-pytest-9.1.1.pyc +0 -0
- icpc_api-0.2.0/tests/unit/__pycache__/test_columns.cpython-312-pytest-9.1.1.pyc +0 -0
- icpc_api-0.2.0/tests/unit/__pycache__/test_dsl.cpython-312-pytest-9.1.1.pyc +0 -0
- icpc_api-0.2.0/tests/unit/__pycache__/test_join.cpython-312-pytest-9.1.1.pyc +0 -0
- icpc_api-0.2.0/tests/unit/__pycache__/test_models.cpython-312-pytest-9.1.1.pyc +0 -0
- icpc_api-0.2.0/tests/unit/__pycache__/test_paging.cpython-312-pytest-9.1.1.pyc +0 -0
- icpc_api-0.2.0/tests/unit/__pycache__/test_reference.cpython-312-pytest-9.1.1.pyc +0 -0
- icpc_api-0.2.0/tests/unit/__pycache__/test_srp.cpython-312-pytest-9.1.1.pyc +0 -0
- icpc_api-0.2.0/tests/unit/__pycache__/test_surveys.cpython-312-pytest-9.1.1.pyc +0 -0
- icpc_api-0.2.0/tests/unit/__pycache__/test_transport.cpython-312-pytest-9.1.1.pyc +0 -0
- icpc_api-0.2.0/tests/unit/__pycache__/test_transport_parity.cpython-312-pytest-9.1.1.pyc +0 -0
- icpc_api-0.2.0/tests/unit/__pycache__/test_typing.cpython-312-pytest-9.1.1.pyc +0 -0
- {icpc_api-0.2.0 → icpc_api-0.3.0}/LICENSE +0 -0
- {icpc_api-0.2.0 → icpc_api-0.3.0}/README.md +0 -0
- {icpc_api-0.2.0 → icpc_api-0.3.0}/THIRD-PARTY-LICENSES.md +0 -0
- {icpc_api-0.2.0 → icpc_api-0.3.0}/icpc/api/__init__.py +0 -0
- {icpc_api-0.2.0 → icpc_api-0.3.0}/icpc/api/contest.py +0 -0
- {icpc_api-0.2.0 → icpc_api-0.3.0}/icpc/api/person.py +0 -0
- {icpc_api-0.2.0 → icpc_api-0.3.0}/icpc/api/public.py +0 -0
- {icpc_api-0.2.0 → icpc_api-0.3.0}/icpc/api/staff.py +0 -0
- {icpc_api-0.2.0 → icpc_api-0.3.0}/icpc/api/survey.py +0 -0
- {icpc_api-0.2.0 → icpc_api-0.3.0}/icpc/api/team.py +0 -0
- {icpc_api-0.2.0 → icpc_api-0.3.0}/icpc/auth/__init__.py +0 -0
- {icpc_api-0.2.0 → icpc_api-0.3.0}/icpc/auth/cognito.py +0 -0
- {icpc_api-0.2.0 → icpc_api-0.3.0}/icpc/auth/flows.py +0 -0
- {icpc_api-0.2.0 → icpc_api-0.3.0}/icpc/auth/provider.py +0 -0
- {icpc_api-0.2.0 → icpc_api-0.3.0}/icpc/auth/srp.py +0 -0
- {icpc_api-0.2.0 → icpc_api-0.3.0}/icpc/auth/tokens.py +0 -0
- {icpc_api-0.2.0 → icpc_api-0.3.0}/icpc/cli/__init__.py +0 -0
- {icpc_api-0.2.0 → icpc_api-0.3.0}/icpc/cli/columns.py +0 -0
- {icpc_api-0.2.0 → icpc_api-0.3.0}/icpc/cli/render.py +0 -0
- {icpc_api-0.2.0 → icpc_api-0.3.0}/icpc/config.py +0 -0
- {icpc_api-0.2.0 → icpc_api-0.3.0}/icpc/errors.py +0 -0
- {icpc_api-0.2.0 → icpc_api-0.3.0}/icpc/facade/__init__.py +0 -0
- {icpc_api-0.2.0 → icpc_api-0.3.0}/icpc/facade/client.py +0 -0
- {icpc_api-0.2.0 → icpc_api-0.3.0}/icpc/facade/domain.py +0 -0
- {icpc_api-0.2.0 → icpc_api-0.3.0}/icpc/models/__init__.py +0 -0
- {icpc_api-0.2.0 → icpc_api-0.3.0}/icpc/models/_generated.py +0 -0
- {icpc_api-0.2.0 → icpc_api-0.3.0}/icpc/models/base.py +0 -0
- {icpc_api-0.2.0 → icpc_api-0.3.0}/icpc/models/blobs.py +0 -0
- {icpc_api-0.2.0 → icpc_api-0.3.0}/icpc/models/enums.py +0 -0
- {icpc_api-0.2.0 → icpc_api-0.3.0}/icpc/models/mixins.py +0 -0
- {icpc_api-0.2.0 → icpc_api-0.3.0}/icpc/models/surveys.py +0 -0
- {icpc_api-0.2.0 → icpc_api-0.3.0}/icpc/py.typed +0 -0
- {icpc_api-0.2.0 → icpc_api-0.3.0}/icpc/search/_generated.py +0 -0
- {icpc_api-0.2.0 → icpc_api-0.3.0}/icpc/search/dsl.py +0 -0
- {icpc_api-0.2.0 → icpc_api-0.3.0}/icpc/search/fields.py +0 -0
- {icpc_api-0.2.0 → icpc_api-0.3.0}/icpc/search/surveys.py +0 -0
- {icpc_api-0.2.0 → icpc_api-0.3.0}/icpc/transport/__init__.py +0 -0
- {icpc_api-0.2.0 → icpc_api-0.3.0}/icpc/transport/_shared.py +0 -0
- {icpc_api-0.2.0 → icpc_api-0.3.0}/icpc/transport/async_client.py +0 -0
- {icpc_api-0.2.0 → icpc_api-0.3.0}/icpc/transport/operation.py +0 -0
- {icpc_api-0.2.0 → icpc_api-0.3.0}/icpc/transport/sync_client.py +0 -0
- {icpc_api-0.2.0 → icpc_api-0.3.0}/icpc_api.egg-info/dependency_links.txt +0 -0
- {icpc_api-0.2.0 → icpc_api-0.3.0}/icpc_api.egg-info/entry_points.txt +0 -0
- {icpc_api-0.2.0 → icpc_api-0.3.0}/icpc_api.egg-info/requires.txt +0 -0
- {icpc_api-0.2.0 → icpc_api-0.3.0}/icpc_api.egg-info/top_level.txt +0 -0
- {icpc_api-0.2.0 → icpc_api-0.3.0}/pyproject.toml +0 -0
- {icpc_api-0.2.0 → icpc_api-0.3.0}/setup.cfg +0 -0
- {icpc_api-0.2.0 → icpc_api-0.3.0}/tests/__init__.py +0 -0
- {icpc_api-0.2.0 → icpc_api-0.3.0}/tests/unit/__init__.py +0 -0
- {icpc_api-0.2.0 → icpc_api-0.3.0}/tests/unit/test_columns.py +0 -0
- {icpc_api-0.2.0 → icpc_api-0.3.0}/tests/unit/test_dsl.py +0 -0
- {icpc_api-0.2.0 → icpc_api-0.3.0}/tests/unit/test_join.py +0 -0
- {icpc_api-0.2.0 → icpc_api-0.3.0}/tests/unit/test_models.py +0 -0
- {icpc_api-0.2.0 → icpc_api-0.3.0}/tests/unit/test_paging.py +0 -0
- {icpc_api-0.2.0 → icpc_api-0.3.0}/tests/unit/test_reference.py +0 -0
- {icpc_api-0.2.0 → icpc_api-0.3.0}/tests/unit/test_srp.py +0 -0
- {icpc_api-0.2.0 → icpc_api-0.3.0}/tests/unit/test_surveys.py +0 -0
- {icpc_api-0.2.0 → icpc_api-0.3.0}/tests/unit/test_transport.py +0 -0
- {icpc_api-0.2.0 → icpc_api-0.3.0}/tests/unit/test_transport_parity.py +0 -0
- {icpc_api-0.2.0 → icpc_api-0.3.0}/tests/unit/test_typing.py +0 -0
- {icpc_api-0.2.0 → icpc_api-0.3.0}/uv.lock +0 -0
|
@@ -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.
|
|
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
|
|
5
|
-
|
|
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
|
-
|
|
12
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
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
|
-
|
|
83
|
-
|
|
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
|
-
@
|
|
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
|
|
350
|
-
`
|
|
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
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
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):
|