statline 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.
statline/__init__.py ADDED
File without changes
statline/cli.py ADDED
@@ -0,0 +1,664 @@
1
+ from __future__ import annotations
2
+
3
+ # ── stdlib ────────────────────────────────────────────────────────────────────
4
+ import contextlib
5
+ import csv
6
+ import importlib
7
+ import io
8
+ import os
9
+ import re
10
+ import sys
11
+ from collections.abc import Mapping as AbcMapping # runtime checks
12
+ from pathlib import Path
13
+ from typing import (
14
+ Any,
15
+ Callable,
16
+ ContextManager,
17
+ Dict,
18
+ Generator,
19
+ Iterable,
20
+ List,
21
+ Mapping,
22
+ Optional,
23
+ Protocol,
24
+ TextIO,
25
+ cast,
26
+ )
27
+
28
+ # ── third-party ───────────────────────────────────────────────────────────────
29
+ import click # Typer is built on Click
30
+ import typer
31
+
32
+ # ── first-party ───────────────────────────────────────────────────────────────
33
+ from statline.core.adapters import list_names
34
+ from statline.core.adapters import load as load_adapter
35
+ from statline.core.calculator import interactive_mode
36
+ from statline.core.scoring import calculate_pri # adapter-driven PRI
37
+ from statline.utils.timing import StageTimes # runtime import
38
+
39
+
40
+ # -- local typing view of StageTimes (for Pyright only) -----------------------
41
+ class _StageTimesProto(Protocol):
42
+ items: List[tuple[str, float]]
43
+ def stage(self, name: str) -> ContextManager[None]: ...
44
+
45
+ # ── typing helpers (reduce "Unknown" noise) ───────────────────────────────────
46
+ Row = Dict[str, Any]
47
+ Rows = List[Row]
48
+ Context = Dict[str, Dict[str, float]]
49
+ AdapterMappingFn = Callable[[Mapping[str, Any]], Mapping[str, Any]]
50
+
51
+
52
+ class AdapterProto(Protocol):
53
+ """Minimal surface we rely on from adapters."""
54
+ KEY: str
55
+
56
+ def map_raw_to_metrics(self, raw: Mapping[str, Any]) -> Mapping[str, Any]: ...
57
+ def map_raw(self, raw: Mapping[str, Any]) -> Mapping[str, Any]: ...
58
+
59
+
60
+ # ── optional YAML (prefer C-accelerated loader if present) ───────────────────
61
+ class _YamlLike(Protocol):
62
+ CSafeLoader: Any
63
+ SafeLoader: Any
64
+ def load(self, stream: str, *, Loader: Any) -> Any: ...
65
+ def safe_load(self, stream: str) -> Any: ...
66
+
67
+ yaml_mod: Optional[_YamlLike]
68
+ _yaml_loader: Optional[Any]
69
+ try:
70
+ import yaml as _yaml_import
71
+ yaml_mod = cast(_YamlLike, _yaml_import)
72
+ _yaml_loader = getattr(_yaml_import, "CSafeLoader", getattr(_yaml_import, "SafeLoader", None))
73
+ except Exception:
74
+ yaml_mod = None
75
+ _yaml_loader = None
76
+
77
+ # env switch; global default is set by root option below
78
+ STATLINE_DEBUG_TIMING: bool = os.getenv("STATLINE_DEBUG") == "1"
79
+
80
+ app = typer.Typer(no_args_is_help=True)
81
+
82
+ # ──────────────────────────────────────────────────────────────────────────────
83
+ # Unified banner helpers
84
+ # ──────────────────────────────────────────────────────────────────────────────
85
+
86
+ _BANNER_LINE: str = "=== StatLine — Adapter-Driven Scoring ==="
87
+ _BANNER_REGEX = re.compile(r"^===\s*StatLine\b.*===\s*$")
88
+
89
+
90
+ def _print_banner() -> None:
91
+ # typer.colors.* is untyped in some stubs; keep fg as Any to avoid unknown-member noise
92
+ fg: Any = getattr(typer.colors, "CYAN", None)
93
+ typer.secho(_BANNER_LINE, fg=fg, bold=True)
94
+
95
+
96
+ def ensure_banner() -> None:
97
+ ctx = click.get_current_context(silent=True)
98
+ if ctx is None:
99
+ _print_banner()
100
+ return
101
+ root = ctx.find_root()
102
+ if root.obj is None:
103
+ root.obj = {}
104
+ if not root.obj.get("_statline_banner_shown"):
105
+ _print_banner()
106
+ root.obj["_statline_banner_shown"] = True
107
+
108
+
109
+ @contextlib.contextmanager
110
+ def suppress_duplicate_banner_stdout() -> Generator[None, None, None]:
111
+ class _Filter(io.TextIOBase):
112
+ def __init__(self, underlying: TextIO) -> None:
113
+ self._u: TextIO = underlying
114
+ self._swallowed: bool = False
115
+ self._buf: str = ""
116
+
117
+ def write(self, s: str) -> int:
118
+ self._buf += s
119
+ out: List[str] = []
120
+ while True:
121
+ if "\n" not in self._buf:
122
+ break
123
+ line, self._buf = self._buf.split("\n", 1)
124
+ if not self._swallowed and _BANNER_REGEX.match(line.strip()):
125
+ self._swallowed = True
126
+ continue
127
+ out.append(line + "\n")
128
+ if out:
129
+ return self._u.write("".join(out))
130
+ return 0
131
+
132
+ def flush(self) -> None:
133
+ if self._buf:
134
+ chunk = self._buf
135
+ self._buf = ""
136
+ self._u.write(chunk)
137
+ self._u.flush()
138
+
139
+ def fileno(self) -> int:
140
+ return self._u.fileno()
141
+
142
+ def isatty(self) -> bool:
143
+ try:
144
+ return self._u.isatty()
145
+ except Exception:
146
+ return False
147
+
148
+ orig: TextIO = sys.stdout
149
+ filt = _Filter(orig)
150
+ try:
151
+ sys.stdout = cast(TextIO, filt)
152
+ yield
153
+ finally:
154
+ try:
155
+ filt.flush()
156
+ except Exception:
157
+ pass
158
+ sys.stdout = orig
159
+
160
+ # ──────────────────────────────────────────────────────────────────────────────
161
+ # Root callback (global options + "no subcommand" UX)
162
+ # ──────────────────────────────────────────────────────────────────────────────
163
+
164
+ def _resolve_timing(ctx: typer.Context, local: Optional[bool]) -> bool:
165
+ """Prefer a command-local --timing value; else inherit from root; else env."""
166
+ if local is not None:
167
+ return local
168
+ try:
169
+ root = ctx.find_root()
170
+ if root.obj and "timing" in root.obj:
171
+ return bool(root.obj["timing"])
172
+ except Exception:
173
+ pass
174
+ return STATLINE_DEBUG_TIMING
175
+
176
+
177
+ @app.callback(invoke_without_command=True)
178
+ def _root( # pyright: ignore[reportUnusedFunction]
179
+ ctx: typer.Context,
180
+ timing: bool = typer.Option(
181
+ True, # default ON at the root; subcommands can override
182
+ "--timing/--no-timing",
183
+ help="Show per-stage timing summaries (default: on; use --no-timing to hide).",
184
+ ),
185
+ ) -> None:
186
+ """Top-level CLI entry. Shows help when run with no subcommand."""
187
+ root = ctx.find_root()
188
+ if root.obj is None:
189
+ root.obj = {}
190
+ root.obj["timing"] = timing
191
+
192
+ ensure_banner()
193
+
194
+ if ctx.invoked_subcommand is None:
195
+ # Mirror the 'startup CLI' style: show commands/usage and exit 0
196
+ typer.echo(ctx.get_help())
197
+ raise typer.Exit(0)
198
+
199
+ # ──────────────────────────────────────────────────────────────────────────────
200
+ # Helpers
201
+ # ──────────────────────────────────────────────────────────────────────────────
202
+
203
+ def _maybe_sanity(adp: Any) -> Optional[Callable[[Mapping[str, Any]], None]]:
204
+ """Return adapter.sanity as a typed callable if present, else None."""
205
+ attr = getattr(adp, "sanity", None)
206
+ if callable(attr):
207
+ return cast(Callable[[Mapping[str, Any]], None], attr)
208
+ return None
209
+
210
+
211
+ def _name_for_row(raw: Mapping[str, Any]) -> str:
212
+ return str(
213
+ raw.get("display_name")
214
+ or raw.get("name")
215
+ or raw.get("player")
216
+ or raw.get("id")
217
+ or ""
218
+ )
219
+
220
+
221
+ def _coerce_float(v: Any) -> float:
222
+ if isinstance(v, (int, float)):
223
+ return float(v)
224
+ if isinstance(v, str):
225
+ try:
226
+ return float(v)
227
+ except ValueError:
228
+ return 0.0
229
+ return 0.0
230
+
231
+
232
+ def _get_adapter_mapper(adp: AdapterProto) -> AdapterMappingFn:
233
+ # Prefer map_raw_to_metrics when present, else map_raw
234
+ if hasattr(adp, "map_raw_to_metrics") and callable(getattr(adp, "map_raw_to_metrics")):
235
+ return cast(AdapterMappingFn, getattr(adp, "map_raw_to_metrics"))
236
+ if hasattr(adp, "map_raw") and callable(getattr(adp, "map_raw")):
237
+ return cast(AdapterMappingFn, getattr(adp, "map_raw"))
238
+ raise typer.BadParameter(
239
+ f"Adapter '{getattr(adp, 'KEY', adp)}' lacks map_raw/map_raw_to_metrics."
240
+ )
241
+
242
+
243
+ def _map_with_adapter(adp: AdapterProto, row: Mapping[str, Any]) -> Dict[str, float]:
244
+ fn: AdapterMappingFn = _get_adapter_mapper(adp)
245
+ out: Mapping[str, Any] = fn(row)
246
+ safe: Dict[str, float] = {}
247
+ for k, v in out.items():
248
+ safe[str(k)] = _coerce_float(v)
249
+ return safe
250
+
251
+
252
+ def _yaml_load_text(text: str) -> Any:
253
+ if yaml_mod is None:
254
+ raise typer.BadParameter("PyYAML not installed; cannot read YAML.")
255
+ if _yaml_loader is not None:
256
+ return yaml_mod.load(text, Loader=_yaml_loader)
257
+ return yaml_mod.safe_load(text)
258
+
259
+
260
+ def _read_rows(input_path: Path) -> Iterable[Row]:
261
+ if str(input_path) == "-":
262
+ reader = csv.DictReader(sys.stdin)
263
+ for row in reader:
264
+ yield {str(k): v for k, v in row.items()}
265
+ return
266
+
267
+ if not input_path.exists():
268
+ raise typer.BadParameter(
269
+ f"Input file not found: {input_path}. Pass a YAML/CSV or use '-' for stdin."
270
+ )
271
+
272
+ suffix = input_path.suffix.lower()
273
+ if suffix in {".yaml", ".yml"}:
274
+ data_text = input_path.read_text(encoding="utf-8")
275
+ data: Any = _yaml_load_text(data_text)
276
+
277
+ # Always build a concretely-typed list for iteration
278
+ src: List[Mapping[str, Any]] = []
279
+
280
+ if isinstance(data, AbcMapping):
281
+ data_map = cast(Mapping[str, Any], data)
282
+ rows_val_obj: Any = data_map.get("rows")
283
+ if not isinstance(rows_val_obj, list):
284
+ raise typer.BadParameter("YAML must be a list[dict] or {rows: list[dict]}.")
285
+ rows_val: List[object] = cast(List[object], rows_val_obj)
286
+ for r_any in rows_val:
287
+ if isinstance(r_any, AbcMapping):
288
+ src.append(cast(Mapping[str, Any], r_any))
289
+ elif isinstance(data, list):
290
+ data_list: List[object] = cast(List[object], data)
291
+ for r_any in data_list:
292
+ if isinstance(r_any, AbcMapping):
293
+ src.append(cast(Mapping[str, Any], r_any))
294
+ else:
295
+ raise typer.BadParameter("YAML must be a list[dict] or {rows: list[dict]}.")
296
+
297
+ for r in src:
298
+ yield {str(k): v for k, v in r.items()}
299
+ return
300
+
301
+ if suffix == ".csv":
302
+ with input_path.open("r", encoding="utf-8", newline="") as f:
303
+ reader = csv.DictReader(f)
304
+ for row in reader:
305
+ yield {str(k): v for k, v in row.items()}
306
+ return
307
+
308
+ raise typer.BadParameter("Input must be .yaml/.yml or .csv (JSON not supported).")
309
+
310
+
311
+ # Match the real csv._writer signature: positional-only and Iterable[Any]
312
+ class _CsvWriter(Protocol):
313
+ def writerow(self, row: Iterable[Any], /) -> Any: ...
314
+
315
+
316
+ def _write_csv(path: Path, rows: Rows, include_headers: bool = True) -> None:
317
+ if not rows:
318
+ path.write_text("", encoding="utf-8")
319
+ return
320
+
321
+ fixed_front = [k for k in ("display_name", "group_name") if k in rows[0]]
322
+ all_keys: set[str] = set()
323
+ for r in rows:
324
+ for k in r.keys():
325
+ all_keys.add(str(k))
326
+ for k in fixed_front:
327
+ all_keys.discard(k)
328
+ headers: List[str] = fixed_front + sorted(all_keys)
329
+
330
+ with path.open("w", newline="", encoding="utf-8") as f:
331
+ writer = csv.writer(f)
332
+ w = cast(_CsvWriter, writer)
333
+ if include_headers:
334
+ w.writerow(headers)
335
+ for r in rows:
336
+ w.writerow([str(r.get(k, "")) for k in headers])
337
+
338
+
339
+ def _load_bucket_weights(
340
+ adapter_obj: AdapterProto,
341
+ weights_path: Optional[Path],
342
+ weights_preset: Optional[str],
343
+ ) -> Optional[Dict[str, float]]:
344
+ if weights_path and weights_preset:
345
+ raise typer.BadParameter("Specify either --weights or --weights-preset, not both.")
346
+
347
+ # ── explicit typing for YAML branch ───────────────────────────────────────
348
+ if weights_path:
349
+ data_any: Any = _yaml_load_text(weights_path.read_text(encoding="utf-8"))
350
+ if not isinstance(data_any, Mapping):
351
+ raise typer.BadParameter("--weights YAML must be a mapping of {bucket: weight}.")
352
+ data_map: Mapping[str, Any] = cast(Mapping[str, Any], data_any)
353
+
354
+ out: Dict[str, float] = {}
355
+ for k_any, v_any in data_map.items():
356
+ out[str(k_any)] = _coerce_float(v_any)
357
+ return out
358
+
359
+ # ── explicit typing for adapter presets ───────────────────────────────────
360
+ presets: Mapping[str, Mapping[str, Any]] = cast(
361
+ Mapping[str, Mapping[str, Any]], getattr(adapter_obj, "weights", {}) or {}
362
+ )
363
+ if not presets:
364
+ return None
365
+
366
+ preset_name = (weights_preset or "pri").lower()
367
+ if preset_name not in presets:
368
+ avail = ", ".join(sorted(presets.keys()))
369
+ raise typer.BadParameter(
370
+ f"Unknown weights preset '{preset_name}'. Available: {avail or '(none)'}"
371
+ )
372
+
373
+ weights_map: Mapping[str, Any] = presets[preset_name]
374
+ out2: Dict[str, float] = {}
375
+ for k_any, v_any in weights_map.items():
376
+ out2[str(k_any)] = _coerce_float(v_any)
377
+ return out2
378
+
379
+
380
+ def _lazy_cache_export(guild_id: str) -> Rows:
381
+ try:
382
+ mod = importlib.import_module("statline.core.cache")
383
+ fn = getattr(mod, "get_mapped_rows_for_scoring", None)
384
+ if not callable(fn):
385
+ return []
386
+
387
+ rows_obj: Any = fn(guild_id)
388
+ out: Rows = []
389
+
390
+ if isinstance(rows_obj, (list, tuple)):
391
+ for r_any in cast(Iterable[Any], rows_obj):
392
+ if isinstance(r_any, Mapping):
393
+ d = cast(Mapping[str, Any], r_any)
394
+ out.append({str(k): v for k, v in d.items()})
395
+ return out
396
+
397
+ if isinstance(rows_obj, Mapping):
398
+ d = cast(Mapping[str, Any], rows_obj)
399
+ return [{str(k): v for k, v in d.items()}]
400
+
401
+ return []
402
+ except Exception:
403
+ return []
404
+
405
+
406
+ def _lazy_cache_context(guild_id: str) -> Optional[Context]:
407
+ try:
408
+ mod = importlib.import_module("statline.core.cache")
409
+ fn = getattr(mod, "get_metric_context_ap", None)
410
+ if not callable(fn):
411
+ return None
412
+
413
+ ctx_obj: Any = fn(guild_id)
414
+ if not isinstance(ctx_obj, Mapping):
415
+ return None
416
+
417
+ safe: Context = {}
418
+ for k, d in cast(Mapping[str, Any], ctx_obj).items():
419
+ if isinstance(d, Mapping):
420
+ dd: Dict[str, float] = {}
421
+ for mk, mv in cast(Mapping[str, Any], d).items():
422
+ try:
423
+ dd[str(mk)] = float(mv) if mv is not None else 0.0
424
+ except Exception:
425
+ dd[str(mk)] = 0.0
426
+ safe[str(k)] = dd
427
+ return safe
428
+ except Exception:
429
+ return None
430
+
431
+
432
+ def _lazy_force_refresh(guild_id: str) -> None:
433
+ try:
434
+ mod = importlib.import_module("statline.core.refresh")
435
+ fn = getattr(mod, "sync_guild_if_stale", None)
436
+ if callable(fn):
437
+ fn(guild_id, force=True)
438
+ except Exception:
439
+ return
440
+
441
+
442
+ def _autobuild_stats_csv(
443
+ output_path: Path, guild_id: str, refresh: bool
444
+ ) -> Rows:
445
+ if refresh:
446
+ _lazy_force_refresh(guild_id)
447
+ rows = _lazy_cache_export(guild_id)
448
+ if not rows:
449
+ raise typer.BadParameter(
450
+ f"No cached rows for guild '{guild_id}'. Run a sync first or provide a CSV/YAML."
451
+ )
452
+ _write_csv(output_path, rows, include_headers=True)
453
+ return rows
454
+
455
+ # ──────────────────────────────────────────────────────────────────────────────
456
+ # Typed shim around calculate_pri
457
+ # ──────────────────────────────────────────────────────────────────────────────
458
+
459
+ def _calc_pri_typed(
460
+ rows: Rows,
461
+ adp: AdapterProto,
462
+ *,
463
+ team_wins: int,
464
+ team_losses: int,
465
+ weights_override: Optional[Dict[str, float]],
466
+ context: Optional[Context],
467
+ _timing: Optional[_StageTimesProto] = None,
468
+ caps_override: Optional[Dict[str, float]] = None,
469
+ ) -> Rows:
470
+ return calculate_pri(
471
+ rows,
472
+ adapter=adp,
473
+ team_wins=team_wins,
474
+ team_losses=team_losses,
475
+ weights_override=weights_override,
476
+ context=context,
477
+ caps_override=caps_override,
478
+ _timing=cast(Any, _timing), # runtime accepts StageTimes; proto satisfies type-checker
479
+ )
480
+
481
+ # ──────────────────────────────────────────────────────────────────────────────
482
+ # Commands
483
+ # ──────────────────────────────────────────────────────────────────────────────
484
+
485
+ @app.command("interactive")
486
+ def interactive(
487
+ ctx: typer.Context,
488
+ timing: Optional[bool] = typer.Option(
489
+ None,
490
+ "--timing/--no-timing",
491
+ help="Show per-row timing inside interactive mode (inherits root default).",
492
+ ),
493
+ ) -> None:
494
+ """Run the interactive calculator UI."""
495
+ ensure_banner()
496
+ show_timing = _resolve_timing(ctx, timing) or STATLINE_DEBUG_TIMING
497
+ try:
498
+ interactive_mode(show_banner=False, show_timing=show_timing)
499
+ except (KeyboardInterrupt, EOFError):
500
+ print("\nExiting StatLine.")
501
+ raise typer.Exit(code=0)
502
+
503
+
504
+ @app.command("adapters")
505
+ def adapters_list() -> None:
506
+ """List available adapter keys."""
507
+ ensure_banner()
508
+ names_iter: Iterable[str] = cast(Iterable[str], list_names())
509
+ for name in sorted(names_iter):
510
+ typer.echo(name)
511
+
512
+
513
+ @app.command("export-csv")
514
+ def export_csv(
515
+ adapter: str = typer.Option(..., "--adapter", help="Adapter key (for validation only)"),
516
+ guild_id: str = typer.Option(..., "--guild-id", help="Guild to export from"),
517
+ out: Path = typer.Option(Path("stats.csv"), "--out", help="Destination CSV path"),
518
+ include_headers: bool = typer.Option(True, "--headers/--no-headers", help="Include header row"),
519
+ refresh: bool = typer.Option(False, "--refresh/--no-refresh", help="Force a Sheets refresh before export"),
520
+ ) -> None:
521
+ """Explicitly export the guild's mapped metrics to CSV (no scoring)."""
522
+ ensure_banner()
523
+ _ = load_adapter(adapter) # validate adapter exists
524
+ rows = _autobuild_stats_csv(out, guild_id=guild_id, refresh=refresh)
525
+ typer.secho(f"Wrote {out} ({len(rows)} rows).", fg=getattr(typer.colors, "GREEN", None))
526
+
527
+
528
+ @app.command("score")
529
+ def score(
530
+ ctx: typer.Context,
531
+ adapter: str = typer.Option(..., "--adapter", help="Adapter key (e.g., rbw5, legacy, valorant)"),
532
+ input_path: Path = typer.Argument(
533
+ Path("stats.csv"),
534
+ help="YAML/CSV understood by the adapter. If missing, use --guild-id to auto-build.",
535
+ ),
536
+ guild_id: Optional[str] = typer.Option(
537
+ None, "--guild-id", help="Guild to export from when auto-generating stats.csv"
538
+ ),
539
+ refresh: bool = typer.Option(
540
+ False, "--refresh/--no-refresh", help="Force a Sheets refresh before auto-generating"
541
+ ),
542
+ weights: Optional[Path] = typer.Option(None, "--weights", help="YAML mapping of {bucket: weight}"),
543
+ weights_preset: Optional[str] = typer.Option("pri", "--weights-preset", help="Adapter preset name (default: 'pri')"),
544
+ out: Optional[Path] = typer.Option(None, "--out", help="Write results CSV (omit to print to stdout)"),
545
+ include_headers: bool = typer.Option(True, "--headers/--no-headers", help="Include header row in CSV output"),
546
+ team_wins: int = typer.Option(0, "--team-wins", help="Team wins for small PRI multiplier"),
547
+ team_losses: int = typer.Option(0, "--team-losses", help="Team losses for small PRI multiplier"),
548
+ timing: Optional[bool] = typer.Option(
549
+ None, "--timing/--no-timing", help="Print per-stage timing summary (inherits root default)."
550
+ ),
551
+ caps_csv: Optional[Path] = typer.Option(
552
+ None, "--caps-csv", help="CSV with per-metric caps (key[,lower,upper,cap])"
553
+ ),
554
+ ) -> None:
555
+ """
556
+ Batch score via an adapter (YAML/CSV input; CSV/STDOUT output).
557
+ """
558
+ ensure_banner()
559
+ show_timing = _resolve_timing(ctx, timing) or STATLINE_DEBUG_TIMING
560
+
561
+ T: _StageTimesProto = cast(_StageTimesProto, StageTimes())
562
+
563
+ with T.stage("adapter"):
564
+ adp = cast(AdapterProto, load_adapter(adapter))
565
+ bucket_weights = _load_bucket_weights(adp, weights, weights_preset)
566
+
567
+ mapped_rows: Optional[Rows] = None
568
+
569
+ if not input_path.exists() and str(input_path) != "-":
570
+ if guild_id is None:
571
+ raise typer.BadParameter(
572
+ f"{input_path} does not exist. Provide --guild-id to auto-generate, "
573
+ "or pass a YAML/CSV file, or use '-' for stdin."
574
+ )
575
+ with T.stage("autobuild"):
576
+ mapped_rows = _autobuild_stats_csv(input_path, guild_id=guild_id, refresh=refresh)
577
+ typer.secho(
578
+ f"Auto-generated {input_path} from guild '{guild_id}'.",
579
+ fg=getattr(typer.colors, "GREEN", None),
580
+ )
581
+
582
+ if mapped_rows is None:
583
+ with T.stage("read"):
584
+ raw_rows: Rows = list(_read_rows(input_path))
585
+
586
+ with T.stage("map"):
587
+ mapped_rows = []
588
+ append_row = mapped_rows.append
589
+ sanity = _maybe_sanity(adp)
590
+ for r in raw_rows:
591
+ m = _map_with_adapter(adp, r)
592
+ if sanity:
593
+ sanity(m)
594
+ append_row(m)
595
+
596
+ with T.stage("context"):
597
+ context = _lazy_cache_context(guild_id) if guild_id else None
598
+
599
+ assert mapped_rows is not None
600
+ mapped_rows_list: Rows = mapped_rows
601
+
602
+ with T.stage("score"):
603
+ results_list: Rows = _calc_pri_typed(
604
+ mapped_rows_list,
605
+ adp,
606
+ team_wins=team_wins,
607
+ team_losses=team_losses,
608
+ weights_override=bucket_weights,
609
+ context=context,
610
+ _timing=T,
611
+ )
612
+
613
+ with T.stage("write"):
614
+ out_fields: List[str] = ["name", "pri", "pri_raw", "context_used"]
615
+ rows_out: Rows = []
616
+ for i in range(len(mapped_rows_list)):
617
+ raw = mapped_rows_list[i]
618
+ res = results_list[i]
619
+ rows_out.append(
620
+ {
621
+ "name": _name_for_row(raw) or raw.get("display_name") or "(unnamed)",
622
+ "pri": int(res.get("pri", 0)),
623
+ "pri_raw": f"{float(res.get('pri_raw', 0.0)):.4f}",
624
+ "context_used": res.get("context_used", ""),
625
+ }
626
+ )
627
+
628
+ if out:
629
+ with out.open("w", newline="", encoding="utf-8") as f:
630
+ writer = csv.writer(f)
631
+ w = cast(_CsvWriter, writer)
632
+ if include_headers:
633
+ w.writerow(out_fields)
634
+ for row in rows_out:
635
+ w.writerow([str(row.get(k, "")) for k in out_fields])
636
+ else:
637
+ writer = csv.writer(sys.stdout)
638
+ w = cast(_CsvWriter, writer)
639
+ if include_headers:
640
+ w.writerow(out_fields)
641
+ for row in rows_out:
642
+ w.writerow([str(row.get(k, "")) for k in out_fields])
643
+
644
+ if show_timing:
645
+ total = sum(ms for _, ms in T.items)
646
+ parts = ", ".join(f"{n} {ms:.2f}" for n, ms in T.items)
647
+ print(file=sys.stderr)
648
+ print(f"⏱ {total:.2f} ms total ({parts})", file=sys.stderr)
649
+
650
+
651
+ def main() -> None:
652
+ try:
653
+ app()
654
+ except click.exceptions.Exit:
655
+ raise
656
+ except KeyboardInterrupt:
657
+ raise typer.Exit(code=130)
658
+ except Exception as e:
659
+ print(f"Error: {e}", file=sys.stderr)
660
+ raise typer.Exit(code=1)
661
+
662
+
663
+ if __name__ == "__main__":
664
+ main()
File without changes