From d9c3063c29c2e8bafdad53756b280a4464823239 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 29 Jul 2026 21:52:34 -0400 Subject: [PATCH 1/3] Catch team-picker drift against ESPN instead of waiting for a bug report MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A favorite_teams checkbox-group is a hand-maintained copy of a league's roster, and rosters change — clubs get renamed, relocated, or added. When the copy drifts nothing errors. The picker simply fails to offer a team that exists, or offers a code that matches nothing, and the user gets an empty screen. odds-ticker's NHL list had both faults at once, and had had them long enough for a user to hit it: it still listed UTA, a retired code, under the club's former name, and omitted the Seattle Kraken entirely so that team could not be selected at all. Nothing in CI could have noticed. scripts/check_team_pickers.py compares every picker in the repo against ESPN and separates the two kinds of difference, because they are not equally serious: enum which codes exist. A mismatch is a bug, so it fails the check and names the teams that cannot be selected. labels the display names. ESPN's own text is sometimes worse than the hand-written label — it calls the Clippers "LA Clippers" where the schema says "Los Angeles Clippers" — so this only warns, and --apply never overwrites an existing label unless asked. --apply regenerates the enums, fills in labels only where one is missing, and preserves the file's existing escaping style so a three-line fix does not arrive as hundreds of lines of reformatting. Verified by reintroducing the exact pre-fix NHL state, which the checker catches and reports precisely: odds-ticker: nhl - offers 1 which ESPN does not have: UTA odds-ticker: nhl - cannot select 2 real team(s): SEA (Seattle Kraken), UTAH (Utah Mammoth) and confirming --apply restores it with the hand-written NBA label intact. All four current pickers (NFL, NBA, MLB, NHL) pass. Unreachable ESPN warns rather than fails, so an outage cannot turn into a red build. A picker keyed by an unknown league is reported rather than skipped, so adding a league cannot quietly opt out of the check. Intended to run on a weekly schedule rather than per-PR, since the check needs the network and a roster does not change because someone opened a pull request. The workflow file is not included here: pushing .github/workflows requires an OAuth token with the workflow scope, which this one does not have. It is ready to add separately. No plugin code or schema changes here — tooling only. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ --- CLAUDE.md | 3 +- scripts/check_team_pickers.py | 245 ++++++++++++++++++++++++++++++++++ 2 files changed, 247 insertions(+), 1 deletion(-) create mode 100755 scripts/check_team_pickers.py diff --git a/CLAUDE.md b/CLAUDE.md index c3a2e124..d0597854 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -10,7 +10,7 @@ plugin store reads. - `plugins//` — Each plugin's source code, manifest, config schema, README, tests - `plugins.json` — Central registry consumed by the LEDMatrix plugin store (auto-generated; do not hand-edit) - `update_registry.py` — Syncs `plugins.json` `latest_version` from local plugin manifests -- `scripts/` — `check_module_collisions.py`, `pre-commit` hook, `archive_old_repos.sh` +- `scripts/` — `check_module_collisions.py`, `check_team_pickers.py`, `pre-commit` hook, `archive_old_repos.sh` - `.github/workflows/` — CI: module-collisions, plugin safety harness, registry auto-update - `schema/` reference and `docs/` — supporting material; canonical `manifest_schema.json` lives in the **core** repo @@ -264,6 +264,7 @@ Third-party plugins keep their own `repo` URL and empty `plugin_path`. - `python update_registry.py` — Update plugins.json from manifests - `python update_registry.py --dry-run` — Preview without writing - `python scripts/check_module_collisions.py` — Cross-plugin module-collision check +- `python scripts/check_team_pickers.py` — Compare `favorite_teams` pickers against ESPN (`--apply` regenerates the enums; label differences only warn) - `scripts/archive_old_repos.sh` — Archive old individual repos (one-time, use `--apply`) ## Git Hooks diff --git a/scripts/check_team_pickers.py b/scripts/check_team_pickers.py new file mode 100755 index 00000000..6e06ec21 --- /dev/null +++ b/scripts/check_team_pickers.py @@ -0,0 +1,245 @@ +#!/usr/bin/env python3 +""" +Check (or regenerate) the team pickers in plugin config schemas against ESPN. + +A ``favorite_teams`` picker is a hand-maintained copy of a league's roster, and +rosters change: a club is renamed, relocated, or added. When the copy drifts the +failure is invisible in the worst way — the picker simply does not offer a team +that exists, or offers a code that no longer matches anything, and the user gets +an empty screen with no error. odds-ticker's NHL list had both problems at once: +it still listed ``UTA`` under the club's former name, and omitted the Seattle +Kraken entirely so they could not be selected at all. + +Two kinds of difference, treated differently: + +* **enum** — which codes exist. A mismatch here is a bug, so it fails. +* **labels** — the display names. ESPN's own text is sometimes worse than the + hand-written label ("LA Clippers" against "Los Angeles Clippers"), so a + mismatch only warns and is never rewritten unless asked for. + +Usage:: + + python scripts/check_team_pickers.py # check, non-zero on drift + python scripts/check_team_pickers.py --apply # rewrite the enums + python scripts/check_team_pickers.py --apply --labels +""" + +import argparse +import json +import os +import re +import sys +import urllib.request + +TEAMS_URL = "https://site.api.espn.com/apis/site/v2/sports/{path}/teams?limit=1000" + +# ESPN sport/league path for each league key a picker may be keyed by. A picker +# whose league key is not listed here is reported as unknown rather than skipped +# silently, so adding a new league cannot quietly opt out of the check. +LEAGUE_PATHS = { + "nfl": "football/nfl", + "ncaa_fb": "football/college-football", + "nba": "basketball/nba", + "wnba": "basketball/wnba", + "ncaam": "basketball/mens-college-basketball", + "ncaaw": "basketball/womens-college-basketball", + "mlb": "baseball/mlb", + "ncaa_baseball": "baseball/college-baseball", + "nhl": "hockey/nhl", + "ncaa_mens": "hockey/mens-college-hockey", + "ncaa_womens": "hockey/womens-college-hockey", + "afl": "australian-football/afl", + "nrl": "rugby-league/3", +} + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + + +def fetch_teams(path): + """ESPN's {abbreviation: display name}. ``limit=1000`` is not optional here: + the default page size truncates the NCAA rosters to about half.""" + with urllib.request.urlopen(TEAMS_URL.format(path=path), timeout=45) as response: + payload = json.load(response) + entries = payload["sports"][0]["leagues"][0]["teams"] + return { + t["team"]["abbreviation"]: t["team"]["displayName"] + for t in entries if t.get("team", {}).get("abbreviation") + } + + +def find_pickers(schema): + """Yield (league_key, node) for each favorite_teams checkbox-group.""" + def walk(node, trail): + if isinstance(node, dict): + if (node.get("x-widget") == "checkbox-group" + and trail and trail[-1] == "favorite_teams"): + # .../leagues/properties//properties/favorite_teams + league = trail[-3] if len(trail) >= 3 else None + yield league, node + for key, value in node.items(): + for hit in walk(value, trail + [key]): + yield hit + elif isinstance(node, list): + for value in node: + for hit in walk(value, trail): + yield hit + + for league, node in walk(schema, []): + yield league, node + + +def enum_of(node): + items = node.get("items") or {} + return items.get("enum") if "enum" in items else node.get("enum") + + +def set_enum(node, values): + items = node.get("items") + if isinstance(items, dict) and "enum" in items: + items["enum"] = values + else: + node["enum"] = values + + +def rewrite(path, mutate): + """Rewrite a schema in place, preserving its existing escaping style. + + Reformatting a whole schema to fix three lines buries the change in hundreds + of lines of churn, so keep ``ensure_ascii`` as the file already had it. + """ + with open(path, "r", encoding="utf-8") as fh: + raw = fh.read() + schema = json.loads(raw) + mutate(schema) + with open(path, "w", encoding="utf-8") as fh: + json.dump(schema, fh, indent=2, ensure_ascii="\\u" in raw) + fh.write("\n") + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--apply", action="store_true", + help="rewrite the enums to match ESPN") + parser.add_argument("--labels", action="store_true", + help="with --apply, also overwrite labels with ESPN's text") + parser.add_argument("--plugin", help="check only this plugin id") + args = parser.parse_args() + + plugins_dir = os.path.join(ROOT, "plugins") + schemas = [] + for plugin in sorted(os.listdir(plugins_dir)): + if args.plugin and plugin != args.plugin: + continue + path = os.path.join(plugins_dir, plugin, "config_schema.json") + if os.path.exists(path): + schemas.append((plugin, path)) + + rosters = {} + problems = [] + warnings = [] + checked = 0 + pending = {} + + for plugin, path in schemas: + with open(path, encoding="utf-8") as fh: + schema = json.load(fh) + + for league, node in find_pickers(schema): + enum = enum_of(node) + if enum is None: + continue + checked += 1 + where = "{}: {}".format(plugin, league) + + espn_path = LEAGUE_PATHS.get(league) + if not espn_path: + problems.append("{} - unknown league key; add it to LEAGUE_PATHS" + .format(where)) + continue + + if espn_path not in rosters: + try: + rosters[espn_path] = fetch_teams(espn_path) + except Exception as exc: + warnings.append("{} - could not reach ESPN ({})".format(where, exc)) + rosters[espn_path] = None + live = rosters[espn_path] + if not live: + continue + + labels = (node.get("x-options") or {}).get("labels") or {} + unreal = [c for c in enum if c not in live] + missing = [c for c in live if c not in enum] + unlabelled = [c for c in enum if c not in labels] + mislabelled = {c: (labels[c], live[c]) for c in enum + if c in live and c in labels and labels[c] != live[c]} + + if unreal: + problems.append( + "{} - offers {} which ESPN does not have: {}".format( + where, len(unreal), ", ".join(sorted(unreal)))) + if missing: + problems.append( + "{} - cannot select {} real team(s): {}".format( + where, len(missing), + ", ".join("{} ({})".format(c, live[c]) + for c in sorted(missing)))) + if unlabelled: + problems.append("{} - no label for: {}".format( + where, ", ".join(sorted(unlabelled)))) + for code, (was, now) in sorted(mislabelled.items()): + warnings.append("{} - {} is labelled {!r}, ESPN says {!r}".format( + where, code, was, now)) + + if unreal or missing or unlabelled: + print(" DRIFT {}".format(where)) + pending.setdefault(path, []).append((league, espn_path)) + elif mislabelled: + # Cosmetic only, so do not call it drift and do not fail on it. + print(" OK* {} ({} teams, {} label(s) differ from ESPN)" + .format(where, len(enum), len(mislabelled))) + else: + print(" OK {} ({} teams)".format(where, len(enum))) + + if args.apply and pending: + for path, entries in pending.items(): + def mutate(schema, entries=entries): + for league, espn_path in entries: + live = rosters.get(espn_path) or {} + if not live: + continue + for found_league, node in find_pickers(schema): + if found_league != league: + continue + set_enum(node, sorted(live)) + options = node.setdefault("x-options", {}) + existing = options.setdefault("labels", {}) + # Keep hand-written labels unless asked otherwise; only + # fill in the ones that are missing entirely. + merged = {} + for code in sorted(live): + if args.labels or code not in existing: + merged[code] = live[code] + else: + merged[code] = existing[code] + options["labels"] = merged + rewrite(path, mutate) + print(" rewrote {}".format(os.path.relpath(path, ROOT))) + print("\nEnums regenerated. Bump the affected plugin versions before committing.") + return 0 + + for warning in warnings: + print(" warn {}".format(warning)) + if problems: + print("\n{} problem(s) across {} picker(s):".format(len(problems), checked)) + for problem in problems: + print(" - {}".format(problem)) + print("\nRun with --apply to regenerate the enums from ESPN.") + return 1 + + print("\nOK: {} picker(s) match ESPN.".format(checked)) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) From fe99f8f88e996a15a58a6ceb982050df61ff10f1 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Jul 2026 10:06:37 -0400 Subject: [PATCH 2/3] Address static-analysis findings on the picker checker Two issues flagged by Codacy on the previous commit: - Dropped an unused 're' import left over from an earlier draft. - Pinned the URL scheme to https before opening it. The league path is interpolated into the URL, and urlopen would honour file:// or a custom scheme if a path ever arrived from somewhere less trustworthy than the hardcoded table. Bandit's B310 is a syntactic blacklist rule so it still fires on the call itself; annotated with the reason rather than left to look unexamined. Verified: bandit clean, the guard rejects a file:// URL, and the checker still fetches all 32 NHL teams and still exits 1 against main (which is correct until #234 lands). Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ MSG --- scripts/check_team_pickers.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/scripts/check_team_pickers.py b/scripts/check_team_pickers.py index 6e06ec21..a9c628e4 100755 --- a/scripts/check_team_pickers.py +++ b/scripts/check_team_pickers.py @@ -27,7 +27,6 @@ import argparse import json import os -import re import sys import urllib.request @@ -58,7 +57,15 @@ def fetch_teams(path): """ESPN's {abbreviation: display name}. ``limit=1000`` is not optional here: the default page size truncates the NCAA rosters to about half.""" - with urllib.request.urlopen(TEAMS_URL.format(path=path), timeout=45) as response: + url = TEAMS_URL.format(path=path) + # The league path is interpolated into the URL, so pin the scheme rather + # than trusting the result: urlopen would honour file:// or a custom scheme + # if a path ever arrived from somewhere less trustworthy than the table above. + if not url.startswith("https://"): + raise ValueError("refusing to fetch a non-HTTPS URL: {!r}".format(url)) + # nosec B310 - the scheme is pinned to https by the check above; B310 is a + # syntactic blacklist rule and fires on the call regardless of the guard. + with urllib.request.urlopen(url, timeout=45) as response: # nosec B310 payload = json.load(response) entries = payload["sports"][0]["leagues"][0]["teams"] return { From 40f2a13c7365510f463b17187e815f8208fabede Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 31 Jul 2026 15:57:53 -0400 Subject: [PATCH 3/3] Fix two CodeRabbit-flagged gaps in check_team_pickers.py MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An ESPN fetch that succeeds but returns an empty roster was silently skipped and counted toward the final "OK" tally with no warning. Warn instead, distinguishing it from the existing connectivity-failure warning. --apply returned 0 immediately after rewriting fixable schemas, without ever printing warnings or problems that never made it into `pending` — an unknown-league entry is the only such case, and it was silently dropped from --apply's output entirely, exiting 0 on a real unresolved config problem. Track those as `unresolved` separately so --apply always surfaces and fails on them. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ --- scripts/check_team_pickers.py | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/scripts/check_team_pickers.py b/scripts/check_team_pickers.py index a9c628e4..3fdeb5bc 100755 --- a/scripts/check_team_pickers.py +++ b/scripts/check_team_pickers.py @@ -144,6 +144,11 @@ def main(): rosters = {} problems = [] warnings = [] + # Problems that --apply can never fix (an unknown league key has no roster + # to regenerate the enum from), tracked separately so they cannot be + # silently dropped from --apply's output just because they never made it + # into `pending`. + unresolved = [] checked = 0 pending = {} @@ -160,8 +165,9 @@ def main(): espn_path = LEAGUE_PATHS.get(league) if not espn_path: - problems.append("{} - unknown league key; add it to LEAGUE_PATHS" - .format(where)) + msg = "{} - unknown league key; add it to LEAGUE_PATHS".format(where) + problems.append(msg) + unresolved.append(msg) continue if espn_path not in rosters: @@ -172,6 +178,12 @@ def main(): rosters[espn_path] = None live = rosters[espn_path] if not live: + if live is not None: + # Fetch succeeded but returned no teams (e.g. ESPN entries + # missing 'abbreviation', or an API shape change) — distinct + # from the connectivity failure above, which already warned. + warnings.append( + "{} - ESPN returned no teams for this league".format(where)) continue labels = (node.get("x-options") or {}).get("labels") or {} @@ -233,6 +245,14 @@ def mutate(schema, entries=entries): rewrite(path, mutate) print(" rewrote {}".format(os.path.relpath(path, ROOT))) print("\nEnums regenerated. Bump the affected plugin versions before committing.") + for warning in warnings: + print(" warn {}".format(warning)) + if unresolved: + print("\n{} problem(s) could not be regenerated automatically:" + .format(len(unresolved))) + for problem in unresolved: + print(" - {}".format(problem)) + return 1 return 0 for warning in warnings: