icpc-api 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. icpc/__init__.py +32 -0
  2. icpc/api/__init__.py +18 -0
  3. icpc/api/common.py +97 -0
  4. icpc/api/contest.py +253 -0
  5. icpc/api/person.py +139 -0
  6. icpc/api/public.py +65 -0
  7. icpc/api/staff.py +83 -0
  8. icpc/api/team.py +335 -0
  9. icpc/auth/__init__.py +28 -0
  10. icpc/auth/cognito.py +142 -0
  11. icpc/auth/flows.py +314 -0
  12. icpc/auth/provider.py +29 -0
  13. icpc/auth/srp.py +185 -0
  14. icpc/auth/store.py +223 -0
  15. icpc/auth/tokens.py +86 -0
  16. icpc/cli/__init__.py +20 -0
  17. icpc/cli/columns.py +555 -0
  18. icpc/cli/main.py +1156 -0
  19. icpc/cli/render.py +160 -0
  20. icpc/config.py +57 -0
  21. icpc/errors.py +159 -0
  22. icpc/facade/__init__.py +6 -0
  23. icpc/facade/client.py +606 -0
  24. icpc/facade/domain.py +198 -0
  25. icpc/models/__init__.py +60 -0
  26. icpc/models/_generated.py +566 -0
  27. icpc/models/base.py +41 -0
  28. icpc/models/blobs.py +81 -0
  29. icpc/models/common.py +61 -0
  30. icpc/models/entities.py +522 -0
  31. icpc/models/enums.py +192 -0
  32. icpc/models/mixins.py +44 -0
  33. icpc/py.typed +0 -0
  34. icpc/search/__init__.py +99 -0
  35. icpc/search/_generated.py +1814 -0
  36. icpc/search/dsl.py +124 -0
  37. icpc/search/endpoint.py +173 -0
  38. icpc/search/fields.py +59 -0
  39. icpc/transport/__init__.py +29 -0
  40. icpc/transport/_shared.py +121 -0
  41. icpc/transport/async_client.py +120 -0
  42. icpc/transport/operation.py +139 -0
  43. icpc/transport/sync_client.py +121 -0
  44. icpc_api-0.1.0.dist-info/METADATA +143 -0
  45. icpc_api-0.1.0.dist-info/RECORD +50 -0
  46. icpc_api-0.1.0.dist-info/WHEEL +5 -0
  47. icpc_api-0.1.0.dist-info/entry_points.txt +2 -0
  48. icpc_api-0.1.0.dist-info/licenses/LICENSE +21 -0
  49. icpc_api-0.1.0.dist-info/licenses/THIRD-PARTY-LICENSES.md +220 -0
  50. icpc_api-0.1.0.dist-info/top_level.txt +1 -0
icpc/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