icpc-api 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- icpc/__init__.py +32 -0
- icpc/api/__init__.py +18 -0
- icpc/api/common.py +97 -0
- icpc/api/contest.py +253 -0
- icpc/api/person.py +139 -0
- icpc/api/public.py +65 -0
- icpc/api/staff.py +83 -0
- icpc/api/team.py +335 -0
- icpc/auth/__init__.py +28 -0
- icpc/auth/cognito.py +142 -0
- icpc/auth/flows.py +314 -0
- icpc/auth/provider.py +29 -0
- icpc/auth/srp.py +185 -0
- icpc/auth/store.py +223 -0
- icpc/auth/tokens.py +86 -0
- icpc/cli/__init__.py +20 -0
- icpc/cli/columns.py +555 -0
- icpc/cli/main.py +1156 -0
- icpc/cli/render.py +160 -0
- icpc/config.py +57 -0
- icpc/errors.py +159 -0
- icpc/facade/__init__.py +6 -0
- icpc/facade/client.py +606 -0
- icpc/facade/domain.py +198 -0
- icpc/models/__init__.py +60 -0
- icpc/models/_generated.py +566 -0
- icpc/models/base.py +41 -0
- icpc/models/blobs.py +81 -0
- icpc/models/common.py +61 -0
- icpc/models/entities.py +522 -0
- icpc/models/enums.py +192 -0
- icpc/models/mixins.py +44 -0
- icpc/py.typed +0 -0
- icpc/search/__init__.py +99 -0
- icpc/search/_generated.py +1814 -0
- icpc/search/dsl.py +124 -0
- icpc/search/endpoint.py +173 -0
- icpc/search/fields.py +59 -0
- icpc/transport/__init__.py +29 -0
- icpc/transport/_shared.py +121 -0
- icpc/transport/async_client.py +120 -0
- icpc/transport/operation.py +139 -0
- icpc/transport/sync_client.py +121 -0
- icpc_api-0.1.0.dist-info/METADATA +143 -0
- icpc_api-0.1.0.dist-info/RECORD +50 -0
- icpc_api-0.1.0.dist-info/WHEEL +5 -0
- icpc_api-0.1.0.dist-info/entry_points.txt +2 -0
- icpc_api-0.1.0.dist-info/licenses/LICENSE +21 -0
- icpc_api-0.1.0.dist-info/licenses/THIRD-PARTY-LICENSES.md +220 -0
- icpc_api-0.1.0.dist-info/top_level.txt +1 -0
icpc/cli/columns.py
ADDED
|
@@ -0,0 +1,555 @@
|
|
|
1
|
+
"""Columns for ``icpc contest load``: what a row can be read with.
|
|
2
|
+
|
|
3
|
+
:meth:`Icpc.load_contest` hands back a joined object graph; a CSV wants a flat
|
|
4
|
+
table. This module is the bridge: it turns the graph into plain dicts keyed by
|
|
5
|
+
the same column names the icpc.global grids use, and evaluates dotted paths —
|
|
6
|
+
``instName``, ``coaches[0].firstName``, ``institution.countryName`` — against
|
|
7
|
+
them, so a spreadsheet layout is expressible on the command line.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import difflib
|
|
13
|
+
import enum
|
|
14
|
+
import re
|
|
15
|
+
import typing
|
|
16
|
+
from collections.abc import Iterable, Sequence
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
from pydantic import BaseModel
|
|
21
|
+
|
|
22
|
+
from icpc.cli.render import cell
|
|
23
|
+
from icpc.facade.domain import ContestView, Member, Team
|
|
24
|
+
from icpc.models._generated import (
|
|
25
|
+
ContestParticipantRow,
|
|
26
|
+
InstitutionRow,
|
|
27
|
+
TeamMemberRow,
|
|
28
|
+
TeamRow,
|
|
29
|
+
)
|
|
30
|
+
from icpc.models.entities import Contest
|
|
31
|
+
|
|
32
|
+
__all__ = [
|
|
33
|
+
"RowKind",
|
|
34
|
+
"apply_columns",
|
|
35
|
+
"fields_of",
|
|
36
|
+
"pluck",
|
|
37
|
+
"resolve_columns",
|
|
38
|
+
"rows_of",
|
|
39
|
+
"source_of",
|
|
40
|
+
"tables_for",
|
|
41
|
+
"validate",
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
#: ``name``, ``name[3]``, ``name[*]`` or ``name[{i}]`` — one step of a path.
|
|
45
|
+
_STEP = re.compile(r"(?P<key>[^.\[\]]+)(?:\[(?P<index>-?\d+|\*)\])?$")
|
|
46
|
+
#: The same, but for a path whose repeats are not resolved yet, so ``[{i}]`` and
|
|
47
|
+
#: ``[{n}]`` count as indexes too. Only :func:`validate` sees those.
|
|
48
|
+
_STEP_ANY = re.compile(r"(?P<key>[^.\[\]]+)(?:\[(?P<index>-?\d+|\*|\{[in]\})\])?$")
|
|
49
|
+
#: The list a repeated column iterates over: the key in front of ``[{i}]``.
|
|
50
|
+
_LOOP = re.compile(r"(?P<list>[^.\[\]]+)\[\{[in]\}\]")
|
|
51
|
+
#: The default separator for a ``[*]`` column.
|
|
52
|
+
JOIN = ","
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class RowKind(enum.StrEnum):
|
|
56
|
+
"""What one row of the output is."""
|
|
57
|
+
|
|
58
|
+
TEAMS = "teams"
|
|
59
|
+
MEMBERS = "members"
|
|
60
|
+
INSTITUTIONS = "institutions"
|
|
61
|
+
PARTICIPANTS = "participants"
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _dump(model: BaseModel | None) -> dict[str, Any]:
|
|
65
|
+
"""Wire-named fields, so paths match the column names the grids use."""
|
|
66
|
+
return model.model_dump(by_alias=True) if model is not None else {}
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _grid(model: BaseModel, columns: type[BaseModel]) -> dict[str, Any]:
|
|
70
|
+
"""Just the grid's own columns of a joined row, plus anything new on the wire.
|
|
71
|
+
|
|
72
|
+
``Team`` and ``Member`` are their grid rows with the join's fields added, so
|
|
73
|
+
the dump is restricted to the row type's columns; the join's own go on below
|
|
74
|
+
under the names the paths use.
|
|
75
|
+
"""
|
|
76
|
+
row = model.model_dump(by_alias=True, include=set(columns.model_fields))
|
|
77
|
+
row.update(model.model_extra or {})
|
|
78
|
+
return row
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _member(member: Member) -> dict[str, Any]:
|
|
82
|
+
row = _grid(member, TeamMemberRow)
|
|
83
|
+
# Without the teammember table the roster comes from each team's embedded
|
|
84
|
+
# blob, whose completeness flag is the only one of these the grid columns do
|
|
85
|
+
# not already carry.
|
|
86
|
+
if row.get("completeRegistration") is None:
|
|
87
|
+
row["completeRegistration"] = member.registration_complete
|
|
88
|
+
row["participant"] = _dump(member.participant)
|
|
89
|
+
row["extras"] = member.extras
|
|
90
|
+
return row
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _team(team: Team, context: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
94
|
+
row = _grid(team, TeamRow)
|
|
95
|
+
# The raw blob is replaced by the three roster lists below; keeping it would
|
|
96
|
+
# put a wall of JSON in the default column set.
|
|
97
|
+
row.pop("teamMembers", None)
|
|
98
|
+
row["members"] = [_member(m) for m in team.members]
|
|
99
|
+
row["coaches"] = [_member(m) for m in team.coaches]
|
|
100
|
+
row["contestants"] = [_member(m) for m in team.contestants]
|
|
101
|
+
row["other"] = [_member(m) for m in team.other]
|
|
102
|
+
row["institution"] = _dump(team.institution)
|
|
103
|
+
row["extras"] = team.extras
|
|
104
|
+
context = context or {}
|
|
105
|
+
row["contest"] = context.get("contest", {})
|
|
106
|
+
# The site's id, joined on by name: the teams grid names a team's site but
|
|
107
|
+
# never numbers it, and the id is what every site-scoped call wants.
|
|
108
|
+
row["siteId"] = context.get("_sites", {}).get(team.site, {}).get("id")
|
|
109
|
+
return row
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _context(view: ContestView) -> dict[str, Any]:
|
|
113
|
+
"""What the metadata fetch adds: the contest, and the sites by name.
|
|
114
|
+
|
|
115
|
+
Team rows name their site but never carry its id, so the id every other
|
|
116
|
+
site-scoped command wants — ``search site-teams``, ``contest set-site`` —
|
|
117
|
+
is only reachable by joining the site list back on.
|
|
118
|
+
"""
|
|
119
|
+
return {
|
|
120
|
+
"contest": _dump(view.contest),
|
|
121
|
+
"_sites": {site.name: _dump(site) for site in view.sites},
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def rows_of(view: ContestView, kind: RowKind) -> list[dict[str, Any]]:
|
|
126
|
+
"""Flatten one table out of the joined view.
|
|
127
|
+
|
|
128
|
+
``members`` is one row per *membership*, not per person: somebody on two
|
|
129
|
+
teams appears twice, each row carrying its own ``teamId``.
|
|
130
|
+
"""
|
|
131
|
+
context = _context(view)
|
|
132
|
+
if kind is RowKind.TEAMS:
|
|
133
|
+
return [_team(team, context) for team in view.teams]
|
|
134
|
+
if kind is RowKind.MEMBERS:
|
|
135
|
+
return [
|
|
136
|
+
{
|
|
137
|
+
**_member(member),
|
|
138
|
+
"team": _team_ref(team, context),
|
|
139
|
+
"contest": context["contest"],
|
|
140
|
+
}
|
|
141
|
+
for team in view.teams
|
|
142
|
+
for member in team.members
|
|
143
|
+
]
|
|
144
|
+
if kind is RowKind.INSTITUTIONS:
|
|
145
|
+
return [_dump(row) for row in view.institutions.values()]
|
|
146
|
+
return _participants(view)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _participants(view: ContestView) -> list[dict[str, Any]]:
|
|
150
|
+
"""One row per *person*, with every membership they hold under ``memberships``.
|
|
151
|
+
|
|
152
|
+
The participant table is one row per person already, but what it says about
|
|
153
|
+
teams is flat text; the per-membership facts — the role, the team, whether
|
|
154
|
+
they attend on site — live on the teammember rows. Collecting them here is
|
|
155
|
+
what lets ``memberships[*].teamId`` answer a question about a person rather
|
|
156
|
+
than about one of their memberships.
|
|
157
|
+
"""
|
|
158
|
+
context = _context(view)
|
|
159
|
+
rows: dict[int, dict[str, Any]] = {
|
|
160
|
+
person_id: {**_dump(row), "memberships": [], "contest": context["contest"]}
|
|
161
|
+
for person_id, row in view.people.items()
|
|
162
|
+
if person_id is not None
|
|
163
|
+
}
|
|
164
|
+
for team in view.teams:
|
|
165
|
+
for member in team.members:
|
|
166
|
+
if member.person_id is None:
|
|
167
|
+
continue
|
|
168
|
+
row = rows.setdefault(
|
|
169
|
+
member.person_id,
|
|
170
|
+
{**_dump(member.participant), "memberships": [], "contest": context["contest"]},
|
|
171
|
+
)
|
|
172
|
+
row.setdefault("personId", member.person_id)
|
|
173
|
+
row.setdefault("username", member.username)
|
|
174
|
+
rows[member.person_id]["memberships"].append(
|
|
175
|
+
{**_member(member), "team": _team_ref(team, context)}
|
|
176
|
+
)
|
|
177
|
+
return list(rows.values())
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _team_ref(team: Team, context: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
181
|
+
sites = (context or {}).get("_sites", {})
|
|
182
|
+
return {
|
|
183
|
+
"id": team.id,
|
|
184
|
+
"name": team.name,
|
|
185
|
+
"site": team.site,
|
|
186
|
+
"siteId": sites.get(team.site, {}).get("id"),
|
|
187
|
+
"status": team.status,
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
#: The four roster lists on a team row. They hold the same fields; which
|
|
192
|
+
#: members land in each is the only difference.
|
|
193
|
+
ROSTERS = ("members", "coaches", "contestants", "other")
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
class Open:
|
|
197
|
+
"""A dict whose keys are data, not schema — ``extras``, keyed by the contest."""
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
@dataclass(frozen=True)
|
|
201
|
+
class ListOf:
|
|
202
|
+
"""A list; ``of`` is the shape of one element."""
|
|
203
|
+
|
|
204
|
+
of: Shape
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
#: One level of an object: field name to whatever is under it. Split out from
|
|
208
|
+
#: :data:`Shape` because everything that *builds* a shape returns this branch,
|
|
209
|
+
#: and only a traversal has to cope with the others.
|
|
210
|
+
type Fields = dict[str, "Shape | ListOf"]
|
|
211
|
+
|
|
212
|
+
#: What a level of a row looks like: ``None`` is a scalar, a dict is an object,
|
|
213
|
+
#: and the two markers above cover lists and free-form dicts.
|
|
214
|
+
type Shape = Fields | type[Open] | None
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _nested(annotation: Any) -> type[BaseModel] | None:
|
|
218
|
+
"""The model inside ``X | None``, ``list[X]`` and friends, if there is one."""
|
|
219
|
+
if isinstance(annotation, type) and issubclass(annotation, BaseModel):
|
|
220
|
+
return annotation
|
|
221
|
+
for argument in typing.get_args(annotation):
|
|
222
|
+
found = _nested(argument)
|
|
223
|
+
if found is not None:
|
|
224
|
+
return found
|
|
225
|
+
return None
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _from_model(model: type[BaseModel], depth: int = 2) -> Fields:
|
|
229
|
+
"""A model's fields, expanding nested models while ``depth`` allows."""
|
|
230
|
+
shape: Fields = {}
|
|
231
|
+
for name, field in model.model_fields.items():
|
|
232
|
+
inner = _nested(field.annotation) if depth > 0 else None
|
|
233
|
+
shape[field.alias or name] = _from_model(inner, depth - 1) if inner else None
|
|
234
|
+
return shape
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _member_shape() -> Fields:
|
|
238
|
+
return {
|
|
239
|
+
**_from_model(TeamMemberRow),
|
|
240
|
+
"participant": _from_model(ContestParticipantRow),
|
|
241
|
+
"extras": Open,
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _team_ref_shape() -> Fields:
|
|
246
|
+
return {"id": None, "name": None, "site": None, "siteId": None, "status": None}
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def shape_of(kind: RowKind) -> Fields:
|
|
250
|
+
"""The full shape of one row kind: what every path is checked against."""
|
|
251
|
+
if kind is RowKind.TEAMS:
|
|
252
|
+
team = dict(_from_model(TeamRow))
|
|
253
|
+
team.pop("teamMembers") # replaced by the roster lists
|
|
254
|
+
return {
|
|
255
|
+
**team,
|
|
256
|
+
"extras": Open,
|
|
257
|
+
"siteId": None,
|
|
258
|
+
"contest": _from_model(Contest),
|
|
259
|
+
"institution": _from_model(InstitutionRow),
|
|
260
|
+
**{roster: ListOf(_member_shape()) for roster in ROSTERS},
|
|
261
|
+
}
|
|
262
|
+
if kind is RowKind.MEMBERS:
|
|
263
|
+
return {**_member_shape(), "team": _team_ref_shape(), "contest": _from_model(Contest)}
|
|
264
|
+
if kind is RowKind.INSTITUTIONS:
|
|
265
|
+
return _from_model(InstitutionRow)
|
|
266
|
+
return {
|
|
267
|
+
**_from_model(ContestParticipantRow),
|
|
268
|
+
"memberships": ListOf({**_member_shape(), "team": _team_ref_shape()}),
|
|
269
|
+
"contest": _from_model(Contest),
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def fields_of(kind: RowKind) -> list[dict[str, str]]:
|
|
274
|
+
"""Every path a ``--col`` can take for one row kind.
|
|
275
|
+
|
|
276
|
+
Read off the models rather than off a fetch, so it answers instantly and
|
|
277
|
+
without a contest. ``extras`` is keyed by the contest's own registration
|
|
278
|
+
questions, so it can only be shown as a placeholder.
|
|
279
|
+
|
|
280
|
+
Each path is reported with the table it reads, which is also what asking
|
|
281
|
+
for it will make ``contest load`` fetch.
|
|
282
|
+
"""
|
|
283
|
+
paths: list[str] = []
|
|
284
|
+
|
|
285
|
+
def walk(shape: Shape, prefix: str) -> None:
|
|
286
|
+
if shape is Open:
|
|
287
|
+
paths.append(f"{prefix}<question>")
|
|
288
|
+
return
|
|
289
|
+
if not isinstance(shape, dict):
|
|
290
|
+
paths.append(prefix.rstrip("."))
|
|
291
|
+
return
|
|
292
|
+
for key, value in shape.items():
|
|
293
|
+
# The other three rosters hold identical fields, and `participant`
|
|
294
|
+
# repeats a table listed in full elsewhere: spelling either out
|
|
295
|
+
# would quadruple the listing without saying anything new.
|
|
296
|
+
if prefix == "" and key in ROSTERS[1:]:
|
|
297
|
+
continue
|
|
298
|
+
if key == "participant":
|
|
299
|
+
paths.append(f"{prefix}participant.<participant field>")
|
|
300
|
+
continue
|
|
301
|
+
if isinstance(value, ListOf):
|
|
302
|
+
walk(value.of, f"{prefix}{key}[i].")
|
|
303
|
+
elif value is None:
|
|
304
|
+
paths.append(f"{prefix}{key}")
|
|
305
|
+
else:
|
|
306
|
+
walk(value, f"{prefix}{key}.")
|
|
307
|
+
|
|
308
|
+
walk(shape_of(kind), "")
|
|
309
|
+
# `[i]` is this listing's way of writing an index; the source rules want a
|
|
310
|
+
# real one.
|
|
311
|
+
return [{"path": path, "from": source_of(kind, path.replace("[i]", "[0]"))} for path in paths]
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
#: What a roster carries when the teammember table was *not* fetched: the fields
|
|
315
|
+
#: of each team row's embedded blob.
|
|
316
|
+
BLOB_FIELDS = frozenset({"personId", "username", "role", "completeRegistration"})
|
|
317
|
+
|
|
318
|
+
#: Which fetched table each top-level key comes from.
|
|
319
|
+
_NEEDS = {
|
|
320
|
+
"institution": "institutions",
|
|
321
|
+
"participant": "participants",
|
|
322
|
+
"contest": "metadata",
|
|
323
|
+
"siteId": "metadata",
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
#: What each row kind needs before any column is considered.
|
|
328
|
+
_BASE = {
|
|
329
|
+
RowKind.TEAMS: {"teams"},
|
|
330
|
+
RowKind.MEMBERS: {"teams", "members"},
|
|
331
|
+
RowKind.INSTITUTIONS: {"teams", "institutions"},
|
|
332
|
+
RowKind.PARTICIPANTS: {"teams", "members", "participants"},
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def source_of(kind: RowKind, path: str) -> str:
|
|
337
|
+
"""Which fetched table a path reads.
|
|
338
|
+
|
|
339
|
+
One table per path: the join hangs each sub-object off exactly one of them,
|
|
340
|
+
and knowing which is what lets a column pay only for what it reads.
|
|
341
|
+
"""
|
|
342
|
+
steps = [(_STEP_ANY.match(step) or {"key": step})["key"] for step in path.split(".")]
|
|
343
|
+
for step in steps:
|
|
344
|
+
if step in _NEEDS:
|
|
345
|
+
return _NEEDS[step]
|
|
346
|
+
if steps[0] in ROSTERS:
|
|
347
|
+
# The embedded blob covers a roster of ids, usernames and roles; a real
|
|
348
|
+
# name or a shirt size means the teammember table.
|
|
349
|
+
return "teams" if set(steps[1:]) <= BLOB_FIELDS else "members"
|
|
350
|
+
if steps[0] == "memberships":
|
|
351
|
+
return "members"
|
|
352
|
+
if steps[0] == "team":
|
|
353
|
+
return "teams"
|
|
354
|
+
return {
|
|
355
|
+
RowKind.TEAMS: "teams",
|
|
356
|
+
RowKind.MEMBERS: "members",
|
|
357
|
+
RowKind.INSTITUTIONS: "institutions",
|
|
358
|
+
RowKind.PARTICIPANTS: "participants",
|
|
359
|
+
}[kind]
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def tables_for(kind: RowKind, paths: Iterable[str]) -> set[str]:
|
|
363
|
+
"""The tables these columns actually need — the rest is not worth fetching.
|
|
364
|
+
|
|
365
|
+
Each table is a full search over the contest, and most sheets touch two or
|
|
366
|
+
three of them. Asking each path where it reads from is enough to skip the
|
|
367
|
+
others.
|
|
368
|
+
"""
|
|
369
|
+
return set(_BASE[kind]) | {source_of(kind, path) for path in paths}
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def validate(kind: RowKind, path: str) -> None:
|
|
373
|
+
"""Check a ``--col`` path against the row's shape, or say what is wrong.
|
|
374
|
+
|
|
375
|
+
The shapes come from the same models the rows are built from, so this
|
|
376
|
+
catches a typo at any depth — not just the first hop — before a fetch that
|
|
377
|
+
would otherwise take ten seconds to produce an empty column.
|
|
378
|
+
"""
|
|
379
|
+
# Rebound to whatever is under each step, so it widens past the dict Fields is.
|
|
380
|
+
shape: Shape | ListOf = shape_of(kind)
|
|
381
|
+
walked: list[str] = []
|
|
382
|
+
for step in path.split("."):
|
|
383
|
+
match = _STEP_ANY.match(step)
|
|
384
|
+
if match is None:
|
|
385
|
+
raise ValueError(f"{step!r} is not a field name in {path!r}")
|
|
386
|
+
key, index = match["key"], match["index"]
|
|
387
|
+
if shape is Open: # below `extras` anything is a question id
|
|
388
|
+
return
|
|
389
|
+
if isinstance(shape, ListOf):
|
|
390
|
+
here = ".".join(walked)
|
|
391
|
+
raise ValueError(f"{here!r} is a list in {path!r}; index it as {here}[i] or {here}[*]")
|
|
392
|
+
if not isinstance(shape, dict):
|
|
393
|
+
raise ValueError(f"{'.'.join(walked)!r} is a value in {path!r}; nothing is under it")
|
|
394
|
+
if key not in shape:
|
|
395
|
+
raise ValueError(_unknown(key, path, walked, shape))
|
|
396
|
+
walked.append(step)
|
|
397
|
+
found = shape[key]
|
|
398
|
+
if index is not None:
|
|
399
|
+
if not isinstance(found, ListOf):
|
|
400
|
+
raise ValueError(f"{key!r} is not a list in {path!r}, so it cannot be indexed")
|
|
401
|
+
found = found.of
|
|
402
|
+
# A bare list is a legal final column; only stepping *into* one needs an
|
|
403
|
+
# index, which the ListOf branch above catches on the next lap.
|
|
404
|
+
shape = found
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def _unknown(key: str, path: str, walked: list[str], shape: dict[str, Any]) -> str:
|
|
408
|
+
where = f" under {'.'.join(walked)!r}" if walked else ""
|
|
409
|
+
close = difflib.get_close_matches(key, list(shape), n=3, cutoff=0.5)
|
|
410
|
+
hint = f"; did you mean {', '.join(close)}?" if close else f"; try --fields{where and ''}"
|
|
411
|
+
return f"unknown field {key!r}{where} in {path!r}{hint}"
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def _is_list(value: Any) -> bool:
|
|
415
|
+
return isinstance(value, Sequence) and not isinstance(value, str | bytes)
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
def pluck(row: dict[str, Any], path: str) -> Any:
|
|
419
|
+
"""Follow a dotted path, returning ``None`` wherever it runs out.
|
|
420
|
+
|
|
421
|
+
A missing key and a short list are both ordinary here — the fourth
|
|
422
|
+
contestant of a three-person team is simply blank, not an error.
|
|
423
|
+
|
|
424
|
+
``[*]`` maps the rest of the path over a list and returns every answer, so
|
|
425
|
+
``memberships[*].teamId`` is all of a person's team ids. Blanks are dropped,
|
|
426
|
+
and nested wildcards flatten into one list.
|
|
427
|
+
"""
|
|
428
|
+
return _walk(row, path.split("."))
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
def _walk(current: Any, steps: Sequence[str]) -> Any:
|
|
432
|
+
for position, step in enumerate(steps):
|
|
433
|
+
match = _STEP.match(step)
|
|
434
|
+
if match is None or not isinstance(current, dict):
|
|
435
|
+
return None
|
|
436
|
+
current = current.get(match["key"])
|
|
437
|
+
index = match["index"]
|
|
438
|
+
if index is not None:
|
|
439
|
+
if not _is_list(current):
|
|
440
|
+
return None
|
|
441
|
+
if index == "*":
|
|
442
|
+
return _map(current, steps[position + 1 :])
|
|
443
|
+
offset = int(index)
|
|
444
|
+
current = current[offset] if -len(current) <= offset < len(current) else None
|
|
445
|
+
if current is None:
|
|
446
|
+
return None
|
|
447
|
+
return current
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
def _map(items: Sequence[Any], steps: Sequence[str]) -> list[Any]:
|
|
451
|
+
out: list[Any] = []
|
|
452
|
+
for item in items:
|
|
453
|
+
value = _walk(item, steps) if steps else item
|
|
454
|
+
if _is_list(value):
|
|
455
|
+
out.extend(v for v in value if v is not None)
|
|
456
|
+
elif value is not None:
|
|
457
|
+
out.append(value)
|
|
458
|
+
return out
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
def _loop_length(rows: Iterable[dict[str, Any]], list_path: str) -> int:
|
|
462
|
+
longest = 0
|
|
463
|
+
for row in rows:
|
|
464
|
+
value = pluck(row, list_path)
|
|
465
|
+
if isinstance(value, Sequence) and not isinstance(value, str | bytes):
|
|
466
|
+
longest = max(longest, len(value))
|
|
467
|
+
return longest
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
def resolve_columns(
|
|
471
|
+
specs: Sequence[str],
|
|
472
|
+
rows: Sequence[dict[str, Any]],
|
|
473
|
+
repeats: dict[str, int] | None = None,
|
|
474
|
+
) -> list[tuple[str, str]]:
|
|
475
|
+
"""Resolve ``NAME=PATH`` specs into the concrete ``(name, path)`` columns.
|
|
476
|
+
|
|
477
|
+
A spec is what a person writes; a column is what a row is read with. The
|
|
478
|
+
difference is the repeats, which only the data can settle.
|
|
479
|
+
|
|
480
|
+
A spec whose path indexes a list with ``[{i}]`` is repeated over that list:
|
|
481
|
+
``'C{n}.First=contestants[{i}].firstName'`` becomes ``C1.First``,
|
|
482
|
+
``C2.First``, … ``{i}`` is the 0-based index used in the path and ``{n}``
|
|
483
|
+
is the 1-based counter that usually reads better in a header.
|
|
484
|
+
|
|
485
|
+
How many times it repeats is the longest such list in the data, so the
|
|
486
|
+
table is as wide as it needs to be; ``repeats`` pins a list to a fixed
|
|
487
|
+
length instead, which is what you want when the columns of a sheet have to
|
|
488
|
+
stay put between runs.
|
|
489
|
+
|
|
490
|
+
Consecutive specs over the *same* list are resolved together, one group per
|
|
491
|
+
index — ``C1.First, C1.Last, C2.First, C2.Last``, not all the firsts
|
|
492
|
+
followed by all the lasts. That is the order a person reads a roster in.
|
|
493
|
+
"""
|
|
494
|
+
repeats = repeats or {}
|
|
495
|
+
out: list[tuple[str, str]] = []
|
|
496
|
+
for list_path, group in _grouped(specs):
|
|
497
|
+
if list_path is None:
|
|
498
|
+
out.extend(group)
|
|
499
|
+
continue
|
|
500
|
+
list_name = list_path.rsplit(".", 1)[-1]
|
|
501
|
+
count = repeats.get(list_name, repeats.get(list_path, _loop_length(rows, list_path)))
|
|
502
|
+
for index in range(count):
|
|
503
|
+
subs = {"{i}": str(index), "{n}": str(index + 1)}
|
|
504
|
+
out.extend((_substitute(name, subs), _substitute(path, subs)) for name, path in group)
|
|
505
|
+
return out
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
def _grouped(specs: Sequence[str]) -> list[tuple[str | None, list[tuple[str, str]]]]:
|
|
509
|
+
"""Split specs into runs, each run being consecutive columns over one list.
|
|
510
|
+
|
|
511
|
+
``None`` marks a run of plain, unrepeated columns.
|
|
512
|
+
"""
|
|
513
|
+
groups: list[tuple[str | None, list[tuple[str, str]]]] = []
|
|
514
|
+
for spec in specs:
|
|
515
|
+
name, sep, path = spec.partition("=")
|
|
516
|
+
if not sep:
|
|
517
|
+
# `--col instName` is `--col instName=instName`: most columns are
|
|
518
|
+
# wanted under the name the grid already gives them.
|
|
519
|
+
name, path = spec, spec
|
|
520
|
+
if not name or not path:
|
|
521
|
+
raise ValueError(f"column {spec!r} is not NAME=PATH")
|
|
522
|
+
loop = _LOOP.search(path)
|
|
523
|
+
key = path[: loop.end("list")] if loop else None
|
|
524
|
+
if groups and groups[-1][0] == key:
|
|
525
|
+
groups[-1][1].append((name, path))
|
|
526
|
+
else:
|
|
527
|
+
groups.append((key, [(name, path)]))
|
|
528
|
+
return groups
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
def _substitute(text: str, subs: dict[str, str]) -> str:
|
|
532
|
+
for placeholder, value in subs.items():
|
|
533
|
+
text = text.replace(placeholder, value)
|
|
534
|
+
return text
|
|
535
|
+
|
|
536
|
+
|
|
537
|
+
def apply_columns(
|
|
538
|
+
rows: Sequence[dict[str, Any]],
|
|
539
|
+
columns: Sequence[tuple[str, str]],
|
|
540
|
+
join: str = JOIN,
|
|
541
|
+
) -> list[dict]:
|
|
542
|
+
"""Apply resolved columns to every row.
|
|
543
|
+
|
|
544
|
+
A ``[*]`` column collapses its list into one cell, joined by ``join`` — a
|
|
545
|
+
spreadsheet wants ``1234571,1234569`` in a ``TeamIds`` column, not a JSON
|
|
546
|
+
array. Every other list is left alone for the renderer to format.
|
|
547
|
+
"""
|
|
548
|
+
out = []
|
|
549
|
+
for row in rows:
|
|
550
|
+
cells: dict[str, Any] = {}
|
|
551
|
+
for name, path in columns:
|
|
552
|
+
value = pluck(row, path)
|
|
553
|
+
cells[name] = join.join(cell(v) for v in value) if "[*]" in path else value
|
|
554
|
+
out.append(cells)
|
|
555
|
+
return out
|