mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-12 18:16:35 +00:00
feat(common): sports_rankings -- the reconciled rankings (sports family 8) (#789)
* feat(common): sports_rankings -- the reconciled rankings family (sports family 8) New hardware-free module src/common/sports_rankings.py, copied from ledmatrix-plugins claude/family8-reconcile once the nine scoreboards made _fetch_team_rankings, _choose_poll (three bodies each), _load_division_team_ids, _passes_other_filters, _best_rank and _is_ranked_game (two each) one body each. All of them live on the plugins' SportsCore, so one mixin, SportsRankingsMixin, carries exactly those six plus _ranked_positions and _poll_season_ended and the constants they read; no manager gains a method it lacks today. - Ranks are kept by ESPN team id as well as abbreviation, and a game with ids is ranked by id (ESPN lists Ohio State and Ohio State Newark both as OSU). The badge still reads the abbreviation table. - A poll whose season's endDate has passed loads nothing, so no badge shows and the "ranked" filter fails open between seasons (ESPN served April's college basketball poll on 2026-10-12); read from the payload, no calendar, no sport names. - College football's division rosters are asked for the season year (football's July rule; only that league has rosters, so no seam). - choose_top_division_poll(): the one poll rule, for the dynamic team resolvers that expand AP_TOP_n. - test/test_sports_rankings.py: the plugins' pinned cases (poll choice, which leagues are asked, payload shapes, hourly cache, the finished-season rows, the rosters' season year and caching, ranked/best rank/filter over colliding abbreviations), plus the host contract. Mutation-checked. - test/test_sports_rankings_parity.py: with LEDMATRIX_PLUGINS, compares each body and constant with every plugin copy. Passes against claude/family8-reconcile; fails against the pre-reconcile tree. - mypy ratchet, src/common/README.md, CHANGELOG (Unreleased, New modules). - sports_shared and sports_rotation docstrings: the three methods leave the "stay per-plugin" list. - docs/SPORTS_UNIFICATION.md: family 8 status and decisions; item 8 restated to match what measuring found (the standings branch never ran; the season year only matters to college football). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs: cite ledmatrix-plugins #645 for the family 8 reconcile Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * ci: re-run code scanning (the Python analysis job never started) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,132 @@
|
||||
"""sports_rankings still matches every plugin copy.
|
||||
|
||||
``src.common.sports_rankings`` was copied from the scoreboards once family 8
|
||||
had made each method one body in all nine ``SportsCore`` classes:
|
||||
``_choose_poll``, ``_fetch_team_rankings``, ``_poll_season_ended``,
|
||||
``_load_division_team_ids``, ``_ranked_positions``, ``_is_ranked_game``,
|
||||
``_best_rank`` and ``_passes_other_filters``, with the class constants they
|
||||
read. The plugins delete their copies once they floor on the release that
|
||||
ships this module. Until each has, a copy that changes on its own is a fix one
|
||||
side has and the other lacks.
|
||||
|
||||
Point LEDMATRIX_PLUGINS at a ledmatrix-plugins checkout and each method is
|
||||
compared with every plugin copy using ``scripts/sports_drift_report.py``'s own
|
||||
normalisation (the AST with docstrings and annotations dropped), plus the
|
||||
decorators, and each constant a plugin still defines by its expression. A copy that is
|
||||
gone counts as adopted when the plugin's ``sports.py`` names the module.
|
||||
Without the variable this skips: core CI has no plugins checkout.
|
||||
"""
|
||||
|
||||
import ast
|
||||
import importlib.util
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from src.common import sports_rankings
|
||||
|
||||
REPO = Path(__file__).resolve().parents[1]
|
||||
SPORTS = ("afl", "baseball", "basketball", "football", "hockey", "lacrosse",
|
||||
"nrl", "soccer", "ufc")
|
||||
CARRIER, MIXIN = "SportsCore", "SportsRankingsMixin"
|
||||
METHODS = ("_choose_poll", "_fetch_team_rankings", "_poll_season_ended",
|
||||
"_load_division_team_ids", "_ranked_positions", "_is_ranked_game",
|
||||
"_best_rank", "_passes_other_filters")
|
||||
CONSTANTS = ("_NON_TOP_POLL_TYPES", "_NON_TOP_POLL_NAMES", "_DIVISION_GROUPS_BY_LEAGUE",
|
||||
"_DIVISION_CACHE_TTL", "_DIVISION_RETRY_SECONDS")
|
||||
|
||||
|
||||
def _drift_report():
|
||||
"""scripts/sports_drift_report.py, loaded by path (scripts/ is no package)."""
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
"sports_drift_report", REPO / "scripts" / "sports_drift_report.py")
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
DRIFT = _drift_report()
|
||||
|
||||
|
||||
def _plugins_root():
|
||||
root = DRIFT.resolve_plugins_dir(os.environ.get("LEDMATRIX_PLUGINS"))
|
||||
if root is None:
|
||||
pytest.skip("set LEDMATRIX_PLUGINS to a ledmatrix-plugins checkout to "
|
||||
"compare this module against the plugin copies")
|
||||
return root
|
||||
|
||||
|
||||
def _class(tree, name):
|
||||
return next(n for n in tree.body if isinstance(n, ast.ClassDef) and n.name == name)
|
||||
|
||||
|
||||
def _method(cls, name):
|
||||
return next((n for n in cls.body
|
||||
if isinstance(n, ast.FunctionDef) and n.name == name), None)
|
||||
|
||||
|
||||
def _constant(cls, name):
|
||||
"""The value a class body assigns to ``name`` (plain or annotated), or None."""
|
||||
for node in cls.body:
|
||||
if isinstance(node, ast.Assign) and any(
|
||||
isinstance(t, ast.Name) and t.id == name for t in node.targets):
|
||||
return node.value
|
||||
if isinstance(node, ast.AnnAssign) and isinstance(node.target, ast.Name) \
|
||||
and node.target.id == name:
|
||||
return node.value
|
||||
return None
|
||||
|
||||
|
||||
def _fingerprint(node):
|
||||
return (DRIFT._digest(node, DRIFT._Canonical()),
|
||||
tuple(ast.unparse(d) for d in node.decorator_list))
|
||||
|
||||
|
||||
def _plugin_tree(root, sport):
|
||||
source = (root / f"{sport}-scoreboard" / "sports.py").read_text(encoding="utf-8")
|
||||
return source, ast.parse(source)
|
||||
|
||||
|
||||
OURS = _class(ast.parse(Path(sports_rankings.__file__).read_text(encoding="utf-8")), MIXIN)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("sport, name", [(s, n) for s in SPORTS for n in METHODS])
|
||||
def test_every_remaining_plugin_copy_matches(sport, name):
|
||||
source, tree = _plugin_tree(_plugins_root(), sport)
|
||||
copy = _method(_class(tree, CARRIER), name)
|
||||
if copy is None:
|
||||
assert sports_rankings.__name__ in source, (
|
||||
f"{sport}: no {name} on {CARRIER} and no {sports_rankings.__name__} import")
|
||||
else:
|
||||
assert _fingerprint(copy) == _fingerprint(_method(OURS, name)), (
|
||||
f"{CARRIER}.{name} in {sport} differs from sports_rankings. "
|
||||
f"Port the change to both, or stop treating it as shared.")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("sport", SPORTS)
|
||||
def test_the_plugin_constants_match(sport):
|
||||
_, tree = _plugin_tree(_plugins_root(), sport)
|
||||
core = _class(tree, CARRIER)
|
||||
for name in CONSTANTS:
|
||||
value = _constant(core, name)
|
||||
if value is not None:
|
||||
assert ast.dump(value) == ast.dump(_constant(OURS, name)), f"{sport}: {name}"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("sport", SPORTS)
|
||||
def test_no_other_plugin_class_carries_a_copy(sport):
|
||||
"""A copy on another class would shadow the shared one for that class."""
|
||||
_, tree = _plugin_tree(_plugins_root(), sport)
|
||||
strays = [f"{node.name}.{name}" for node in tree.body if isinstance(node, ast.ClassDef)
|
||||
for name in METHODS if node.name != CARRIER and _method(node, name) is not None]
|
||||
assert strays == []
|
||||
|
||||
|
||||
def test_the_drift_report_calls_them_identical():
|
||||
families = DRIFT.build(_plugins_root(), ("sports.py",))
|
||||
rows = {(r["file"], r["family"]): r
|
||||
for r in (DRIFT.summarise(k, v) for k, v in families.items())}
|
||||
for name in METHODS:
|
||||
row = rows.get(("sports.py", name))
|
||||
assert row is None or row["worst_class_variants"] == 1, name
|
||||
Reference in New Issue
Block a user