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 +0 -0
- statline/cli.py +664 -0
- statline/core/__init__.py +0 -0
- statline/core/adapters/__init__.py +120 -0
- statline/core/adapters/compile.py +166 -0
- statline/core/adapters/hooks.py +33 -0
- statline/core/adapters/loader.py +103 -0
- statline/core/adapters/registry.py +37 -0
- statline/core/adapters/types.py +51 -0
- statline/core/cache.py +164 -0
- statline/core/calculator.py +363 -0
- statline/core/db.py +127 -0
- statline/core/guild_manager.py +183 -0
- statline/core/models.py +189 -0
- statline/core/normalization.py +52 -0
- statline/core/scoring.py +371 -0
- statline/core/setup_service.py +153 -0
- statline/core/sheets_sync.py +237 -0
- statline/core/weights.py +74 -0
- statline/io/__init__.py +0 -0
- statline/io/sheets.py +139 -0
- statline/utils/config.py +47 -0
- statline/utils/logging.py +61 -0
- statline/utils/paths.py +30 -0
- statline/utils/timing.py +27 -0
- statline-0.1.0.dist-info/METADATA +22 -0
- statline-0.1.0.dist-info/RECORD +30 -0
- statline-0.1.0.dist-info/WHEEL +5 -0
- statline-0.1.0.dist-info/licenses/LICENSE +28 -0
- statline-0.1.0.dist-info/top_level.txt +1 -0
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
|