mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-12 18:16:35 +00:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ef97bd158d | ||
|
|
6bb6e877b4 | ||
|
|
e43708c878 |
@@ -19,6 +19,37 @@ accepts both, but the store flags the old spelling as deprecated
|
||||
|
||||
## Unreleased
|
||||
|
||||
## 3.8.5
|
||||
|
||||
Fewer ESPN requests on the hourly schedule refresh (#754), and sports
|
||||
consolidation family 8: `src.common.sports_rankings`, which the scoreboards
|
||||
adopt by flooring on 3.8.5 (#789).
|
||||
|
||||
### ESPN: settled days are not fetched again
|
||||
|
||||
- A scoreboard's hourly Recent/Upcoming refresh asks ESPN for its window one
|
||||
day at a time, and most of those days can no longer change. A chunk whose
|
||||
last day is three or more days back is now kept in memory for 24 hours
|
||||
(compressed, at most 512 entries / 8 MB) and answered from there, for
|
||||
`fetch_espn_scoreboard` and the background service alike. Failed and
|
||||
capped chunks are never kept, and each hit is parsed afresh. Against live
|
||||
ESPN, a second refresh of five scoreboard windows went from 111 requests
|
||||
and 10.2 MB to 50 requests and 3.2 MB with identical events. (#754)
|
||||
|
||||
### New modules
|
||||
|
||||
- `src/common/sports_rankings.py` -- sports consolidation family 8, once the
|
||||
plugins 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 each: `SportsRankingsMixin` (`SportsCore`),
|
||||
with `_ranked_positions` and `_poll_season_ended` beside them, and
|
||||
`choose_top_division_poll()` for the dynamic team resolvers. Ranks are kept
|
||||
by ESPN team id as well as abbreviation and a game with ids is ranked by id;
|
||||
a poll from a season that has ended (its `endDate` has passed) loads
|
||||
nothing, so no rank badge shows between seasons; the college football
|
||||
division rosters are asked for the season year. A plugin may inherit the
|
||||
mixin once it floors on 3.8.5, and deletes its copies then. (#789)
|
||||
|
||||
## 3.8.4
|
||||
|
||||
A panel that cannot reach its refresh cap is reported with a cap it can hold
|
||||
|
||||
+65
-16
@@ -94,6 +94,7 @@ more. Shared sports code lives in `src/common`:
|
||||
| `sports_game_over.py` | 3.8.1 | `SportsGameOverMixin` — `_is_game_really_over`, with the `FINAL_PERIOD` seam (family 5) |
|
||||
| `sports_favorites.py` | 3.8.2 | `SportsFavoritesMixin`, `SportsUpcomingFavoritesMixin`, `SportsRecentFavoritesMixin` — `_is_favorite_game` and the favourites-only picks, on the `_favorite_key` seam (family 6) |
|
||||
| `sports_rotation.py` | 3.8.4 | `SportsRotationMixin` — the other-games rotation (importance order, the window, odds on rotated-in games), on the `_rankings_loaded` seam (family 7) |
|
||||
| `sports_rankings.py` | 3.8.5 | `SportsRankingsMixin` — which teams are ranked (by id and abbreviation), the poll choice, stale polls hidden, the "ranked" filter; `choose_top_division_poll()` (family 8) |
|
||||
|
||||
Each is described in [src/common/README.md](../src/common/README.md).
|
||||
|
||||
@@ -350,7 +351,7 @@ compare abbreviations exactly, and
|
||||
abbreviation (or by `_team_in` where a plugin has one) instead of through
|
||||
`_favorite_key`. The result-colour helpers also wait (decision above).
|
||||
|
||||
### Family 7: the other-games rotation (core done; adoption waits for a release)
|
||||
### Family 7: the other-games rotation (done: core 3.8.4, adopted)
|
||||
|
||||
ledmatrix-plugins `scripts/test_other_games_rotation.py` (#640) pinned 96 rows
|
||||
across the nine plugins first: `_by_importance` per rankings table, core's
|
||||
@@ -380,7 +381,39 @@ builds the pools, so nothing rotates on a board with no favourites; football
|
||||
routes it through `_favorites_first(games, 0, N)` (decision below, family 13).
|
||||
ufc's MMA managers override `update()` and never build the pools, so the
|
||||
rotation is dormant there. `_best_rank`, `_is_ranked_game` and
|
||||
`_passes_other_filters` are family 8.
|
||||
`_passes_other_filters` were family 8.
|
||||
|
||||
### Family 8: rankings (reconciled; promoted, core 3.8.5)
|
||||
|
||||
ledmatrix-plugins `scripts/test_rankings.py` (#644) pinned 79 rows across the
|
||||
nine plugins first, every ESPN request answered offline from recorded-shape
|
||||
payloads: which endpoints `_fetch_team_rankings` asks per league and the
|
||||
tables it keeps, `_choose_poll` over nine poll sets, the division rosters'
|
||||
season year across six dates, the rankings and roster caches, a poll from a
|
||||
finished season, malformed payloads, and `_is_ranked_game`, `_best_rank` and
|
||||
`_passes_other_filters` over games whose abbreviations collide. The reconcile
|
||||
(ledmatrix-plugins #645) 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 on `SportsCore`, with football's `_ranked_positions` and a new
|
||||
`_poll_season_ended` beside them, and dropped baseball's per-league
|
||||
class-level rankings copy. Every changed cell is one of the decisions below:
|
||||
the id table in all nine (and the games it re-ranks), /rankings first for
|
||||
every college or NCAA league in afl, basketball, nrl and soccer, the season
|
||||
year in the eight that asked for the calendar year, the stale-poll rows, and
|
||||
baseball's shared copy. The harness renders were byte-identical.
|
||||
`src/common/sports_rankings.py` holds the bodies in one mixin,
|
||||
`SportsRankingsMixin`, plus `choose_top_division_poll()` for the dynamic team
|
||||
resolvers; `test/test_sports_rankings_parity.py` compares them, and the
|
||||
constants, with the plugin copies.
|
||||
|
||||
Left for later families: the rank badge (switch scorebug, scroll card and the
|
||||
plugins' `manager.py`, families 11, 12 and 14) still reads the abbreviation
|
||||
table; `manager.py`'s `_get_rankings_cache` merges every league's table for
|
||||
the scroll cards. `_rankings_loaded` stays the family 7 seam (football counts
|
||||
its id table too); the two differ only for a poll whose blocks carry ids and
|
||||
no abbreviations. The data sources' `fetch_standings` (held with
|
||||
`data_sources.py`) is still four bodies; only its endpoint order changed.
|
||||
|
||||
### Why the method changes
|
||||
|
||||
@@ -468,8 +501,8 @@ release.
|
||||
| 4 | Identical sweep | `manager.py`: `_dispatch_switch_refresh`, `_favorite_team_is_live`, `get_vegas_priority_weight`, `_game_involves`, `_favorite_scan_targets`, `_favorite_scan_games`, `_get_total_games_for_manager` (all nine, 1); the live-scroll helpers `_preserving_scroll_position`, `_refresh_live_scroll_managers`, `_live_scroll_managers`, `_note_live_scroll_built`, `_live_scroll_needs_rebuild`, `_live_scroll_fields` (eight, 1). `sports.py`: `_card_option`, `_filtered_or_all`, `_effective_live_duration`, `_recent_date_text` (eight, 1). 58 identical families in all | Nothing to decide; brings `manager.py` into core as a `SportsPluginHostMixin`. `_resolve_font_path` (identical in nine `sports.py` and eight renderers) becomes `sports_font_path.resolve_font_path`, not `font_layout.resolve_asset_path`, which skips the cwd. Done: core 3.8.0, adopted (ledmatrix-plugins #594); see [Stage 4](#stage-4-the-identical-sweep-done-core-380-adopted) |
|
||||
| 5 | Game-over check | `SportsLive._is_game_really_over` (5) | Pure logic, no pixels; one seam, `FINAL_PERIOD`. The pilot for the procedure. Reconciled to one body and promoted as `sports_game_over`; shipped in 3.8.1 and adopted. See [Family 5](#family-5-the-game-over-check-done-core-381-adopted) |
|
||||
| 6 | Favourite matching | `_is_favorite_game` (7 across three classes), `_select_games_for_display` (2: nrl), `_select_recent_games_for_display` (3) | Everything that asks "is this a favourite" goes through the 3.5.0 `_favorite_key` seam. Reconciled to one body each and promoted as `sports_favorites`; shipped in 3.8.2 and adopted. See [Family 6](#family-6-favourite-matching-done-core-382-adopted) |
|
||||
| 7 | Other-games rotation | `_by_importance`, `_other_games_window`, `_advance_other_games_if_due` (2 each: football), `_rotate_other_games_on_display` (2: ufc), with `_attach_odds_to_rotated_games` (3; ufc had none) | One outlier each; football carried two fixes the other eight lacked. Reconciled to one body each, on a `_rankings_loaded` seam, and promoted as `sports_rotation`; adoption waits for the release that ships it. See [Family 7](#family-7-the-other-games-rotation-core-done-adoption-waits-for-a-release) |
|
||||
| 8 | Rankings | `_fetch_team_rankings` (3), `_choose_poll` (3), `_load_division_team_ids`, `_passes_other_filters`, `_best_rank`, `_is_ranked_game` (2 each: football) | Needs 7; the rank badge and the "ranked only" filter read it |
|
||||
| 7 | Other-games rotation | `_by_importance`, `_other_games_window`, `_advance_other_games_if_due` (2 each: football), `_rotate_other_games_on_display` (2: ufc), with `_attach_odds_to_rotated_games` (3; ufc had none) | One outlier each; football carried two fixes the other eight lacked. Reconciled to one body each, on a `_rankings_loaded` seam, and promoted as `sports_rotation`; done: core 3.8.4, adopted (ledmatrix-plugins #643). See [Family 7](#family-7-the-other-games-rotation-done-core-384-adopted) |
|
||||
| 8 | Rankings | `_fetch_team_rankings` (3), `_choose_poll` (3), `_load_division_team_ids`, `_passes_other_filters`, `_best_rank`, `_is_ranked_game` (2 each: football) | Needs 7; the rank badge and the "ranked only" filter read it. Reconciled to one body each and promoted as `sports_rankings` (core 3.8.5). See [Family 8](#family-8-rankings-reconciled-promoted-core-385) |
|
||||
| 9 | Live fetch and odds | `_fetch_todays_games` (5), `_fetch_odds` (3), `_attach_odds_to_rotated_games` (3) | The prerequisite for one shared ESPN poller across plugins |
|
||||
| 10 | View model | `_extract_game_details_common` (9 of 9) | Every renderer reads it; its keys are additive-only, so reconcile to the superset and leave sport extras in `_extract_game_details` |
|
||||
| 11 | Switch scorebug | `_load_fonts` (4), `_load_custom_font_from_element_config` (6), `_get_layout_offset` (5), `_fit_score_font` (2: football), `_load_and_resize_logo` (9), `_draw_dynamic_odds` (9), then `_draw_scorebug_layout` (20: Upcoming 9, Recent 8, Live 2, ufc's Core 1) | Needs 10 and the twin decisions below. Several releases: fonts and offsets, logos, odds, then one mode's layout per release |
|
||||
@@ -537,18 +570,34 @@ suspected behaviour that needs a payload or a rig to confirm first.
|
||||
non-empty; football counts its by-id table too). Kept for family 13: the
|
||||
no-favourites branch of `update()` keeps its fixed "next N" in the eight
|
||||
plugins that have it, rather than football's rotating pools.
|
||||
- **8, rankings.** (a) afl, basketball, nrl and soccer turn a *standings*
|
||||
payload into ranks (a pro league's standings position becomes the rank
|
||||
badge); baseball, hockey, lacrosse, ufc and football do not. Which is
|
||||
right is visible on every pro-league card with "show ranking" on.
|
||||
(b) football also keys ranks by team id, so two schools sharing an
|
||||
abbreviation across divisions cannot be confused: adopt for all.
|
||||
(c) football asks for the division roster of the *season* year (July
|
||||
onward is this year's season), which is right for football and wrong for
|
||||
college basketball, hockey and lacrosse, whose ESPN season is the year it
|
||||
ends: a per-sport seam, not football's constant. (d) baseball's
|
||||
`_choose_poll` calls `dynamic_team_resolver.choose_top_division_poll`, the
|
||||
others inline it: one home, in core.
|
||||
- **8, rankings. Decided 2026-10-12, reconciled.** Measured first, two of
|
||||
the questions as first written did not hold. (a) afl, basketball, nrl and
|
||||
soccer could turn a *standings* payload into ranks, but the branch never
|
||||
ran: rankings are fetched only where `_league_has_rankings()` (a college or
|
||||
NCAA league), and ESPN's site-API /standings answers `{}` or a link stub,
|
||||
never a table. Deleted. The same four data sources asked /standings first
|
||||
for any college league outside a fixed list, so a custom NCAA soccer
|
||||
league (`usa.ncaa.m.1`) never showed its coaches poll; every college or
|
||||
NCAA league now asks /rankings first, as in the other five (fix, visible
|
||||
only there). (b) Ranks are kept by ESPN team id too, in all nine, and a
|
||||
game with ids is ranked by id: ESPN lists Ohio State and Ohio State Newark
|
||||
both as OSU. Changes the "ranked" filter and the other-games order only
|
||||
where abbreviations collide; the badge still reads the abbreviation table.
|
||||
(c) Only college football has division rosters (all nine carry the same
|
||||
one-league table; college basketball, hockey and lacrosse publish none), so
|
||||
football's July rule is promoted as is and there is no seam. In the other
|
||||
eight the calendar year was unreachable. (d) One poll rule, in core:
|
||||
`_choose_poll`, with `choose_top_division_poll()` for the resolvers that
|
||||
expand `AP_TOP_n` (baseball's helper and the others' inline copies call it
|
||||
once they adopt). (e) A poll from a season that has ended loads nothing:
|
||||
ESPN keeps serving the last poll between seasons (on 2026-10-12, college
|
||||
basketball's April final and college lacrosse's May poll), and each block
|
||||
carries its season's `endDate`, so no calendar and no per-sport seam. No
|
||||
badge shows and the "ranked" filter fails open until the new season's
|
||||
first poll; visible on college basketball and lacrosse boards between
|
||||
seasons. A block without a readable `endDate` counts as current. (f)
|
||||
baseball's MLB, MiLB and NCAA Baseball managers drop their class-level
|
||||
shared copy of the table (none of those leagues publishes a poll).
|
||||
- **9, live fetch and odds.** (a) afl, basketball, nrl and soccer cache the
|
||||
live scoreboard for 30 s under `<sport_key>_scoreboard_current`; the others
|
||||
do not (the harness fixtures seed that key, so the change shows up there).
|
||||
|
||||
@@ -41,6 +41,7 @@ src/common/sports_font_path.py
|
||||
src/common/sports_game_over.py
|
||||
src/common/sports_live_scroll.py
|
||||
src/common/sports_plugin_host.py
|
||||
src/common/sports_rankings.py
|
||||
src/common/sports_rotation.py
|
||||
src/common/sports_scroll.py
|
||||
src/common/sports_timezone.py
|
||||
|
||||
+1
-1
@@ -4,5 +4,5 @@ LEDMatrix Display System
|
||||
Core source package for the LED Matrix Display project.
|
||||
"""
|
||||
|
||||
__version__ = "3.8.4"
|
||||
__version__ = "3.8.5"
|
||||
|
||||
|
||||
@@ -53,6 +53,7 @@ Rules for the package:
|
||||
| [`sports_helpers`](#sports_helpers) | Small helpers every scoreboard `sports.py` copies | Yes (scoreboards) | 3.5.0 |
|
||||
| [`sports_live_scroll`](#sports_live_scroll) | Rebuild a live scroll strip mid-cycle without moving it | Yes (scoreboards) | 3.8.0 |
|
||||
| [`sports_plugin_host`](#sports_plugin_host) | Helpers of a scoreboard's plugin class (`manager.py`) | Yes (scoreboards) | 3.8.0 |
|
||||
| [`sports_rankings`](#sports_rankings) | Which teams are ranked, in which poll, and the "ranked" filter | Yes (scoreboards) | 3.8.5 |
|
||||
| [`sports_rotation`](#sports_rotation) | Which non-favourite games a scoreboard shows, and when the slice moves | Yes (scoreboards) | 3.8.4 |
|
||||
| [`sports_scroll`](#sports_scroll) | Scoreboard scroll-display orchestration | Yes (scoreboards) | 3.2.0 |
|
||||
| [`sports_shared`](#sports_shared) | Sport-independent `sports.py` methods | Yes (scoreboards) | 3.3.0 |
|
||||
@@ -369,6 +370,22 @@ shape), `_dispatch_switch_refresh()` (a manager refresh on a daemon thread, so
|
||||
`display()` never waits on the network), `get_vegas_content_type()` and small
|
||||
dynamic-duration helpers. List it before `BasePlugin`.
|
||||
|
||||
### sports_rankings
|
||||
|
||||
[`sports_rankings.py`](sports_rankings.py). Sports family 8:
|
||||
`SportsRankingsMixin` (`SportsCore`), the rankings. `_fetch_team_rankings`
|
||||
loads the top-division poll hourly (only where `_league_has_rankings()`) as an
|
||||
abbreviation table, which the rank badge draws, and an ESPN team id table,
|
||||
which `_ranked_positions` reads first, so a school sharing a ranked team's
|
||||
abbreviation is not ranked. `_choose_poll` steps over seedings and lower
|
||||
divisions' polls and otherwise keeps ESPN's order;
|
||||
`choose_top_division_poll()` is the same rule for code without a scoreboard
|
||||
(the dynamic team resolvers). A poll whose season's `endDate` has passed loads
|
||||
nothing (`_poll_season_ended`), so between seasons no badge shows and the
|
||||
"ranked" filter fails open. `_load_division_team_ids` reads college football's
|
||||
FBS and FCS rosters for the season year; `_is_ranked_game`, `_best_rank` and
|
||||
`_passes_other_filters` serve the other-games selection.
|
||||
|
||||
### sports_rotation
|
||||
|
||||
[`sports_rotation.py`](sports_rotation.py). Sports family 7:
|
||||
|
||||
@@ -0,0 +1,411 @@
|
||||
"""Which teams are ranked, and in which poll (sports family 8).
|
||||
|
||||
The scoreboards' rankings code, reconciled in ledmatrix-plugins (family 8)
|
||||
from three, three and two bodies into one each, and copied here under the
|
||||
existing names. All of it lives on the plugins' ``SportsCore``, so there is
|
||||
one mixin, ``SportsRankingsMixin``:
|
||||
|
||||
- ``_fetch_team_rankings()``: the top-division poll from the data source,
|
||||
hourly, as ``_team_rankings_cache`` ({abbreviation: rank}, what the rank
|
||||
badge draws) and ``_ranked_team_ids`` ({ESPN team id: rank}). Only for a
|
||||
league ``_league_has_rankings()`` says publishes a poll.
|
||||
- ``_choose_poll(rankings_data)``: the first block ESPN lists that is a
|
||||
top-division poll; seedings and the polls of lower divisions are stepped
|
||||
over, ESPN's order is otherwise kept (so the CFP rankings take over from AP
|
||||
when ESPN fronts them). ``choose_top_division_poll`` below is the same rule
|
||||
for code with no scoreboard to hand (the dynamic team resolvers).
|
||||
- ``_poll_season_ended(poll, now)``: whether the poll's season is over.
|
||||
- ``_load_division_team_ids()``: team ids per college football division
|
||||
(FBS, FCS) from ESPN's group rosters, for the season year.
|
||||
- ``_ranked_positions(game)``, ``_is_ranked_game(game)``, ``_best_rank(game)``:
|
||||
the poll positions either side holds, by team id first, abbreviation second.
|
||||
- ``_passes_other_filters(game)``: whether a non-favourite game is worth a
|
||||
slot under ``other_games_min_quality`` and ``other_games_divisions``.
|
||||
|
||||
THE RULES (decided 2026-10-12)
|
||||
------------------------------
|
||||
Ranks are kept by ESPN team id as well as by abbreviation, and a game with ids
|
||||
is ranked by id: abbreviations are not unique (ESPN lists Ohio State and Ohio
|
||||
State Newark both as OSU) and an id cannot rank the wrong school. The badge
|
||||
still draws from the abbreviation table.
|
||||
|
||||
A poll whose season has ended loads nothing. ESPN keeps serving the last poll
|
||||
it published until the next one (on 2026-10-12 college basketball's /rankings
|
||||
was still April's final poll, college lacrosse's May's), and every block names
|
||||
its season with an ``endDate``. Past it the table stays empty, so no badge
|
||||
shows and the "ranked" filter fails open, until the new season's first poll
|
||||
arrives. Read from the payload, not a calendar, so no league needs its own
|
||||
season dates; a block without a readable ``endDate`` counts as current.
|
||||
|
||||
The division rosters are asked for the season year (July onward is this
|
||||
year's season): college football's season runs into January, and the next
|
||||
season's groups do not exist then. Only college football has division
|
||||
rosters (``_DIVISION_GROUPS_BY_LEAGUE``), so this is football's rule as is,
|
||||
not an override point.
|
||||
|
||||
Everything fails open: no poll, an empty roster or a failed request allows
|
||||
every game rather than blanking the board.
|
||||
|
||||
The data source decides the endpoint (``data_source.fetch_standings``, still
|
||||
per plugin); every scoreboard asks /rankings first for a college or NCAA
|
||||
league. A standings table is never turned into ranks.
|
||||
|
||||
A new module rather than more methods on ``sports_shared``, for the reason
|
||||
``sports_helpers`` gives: a missing module fails at load, where the version
|
||||
checks see it; a missing method fails mid-update.
|
||||
|
||||
WHAT A HOST MUST PROVIDE
|
||||
------------------------
|
||||
Derived by walking every ``self.<attr>`` the mixin reads; the host-contract
|
||||
test in ``test/test_sports_rankings.py`` fails if a read is added without being
|
||||
listed here. Every scoreboard ``SportsCore`` supplies all of them.
|
||||
|
||||
- ``logger``, ``sport``, ``league``.
|
||||
- ``data_source`` -- ``fetch_standings(sport, league)`` returns the payload
|
||||
(``{}`` on failure).
|
||||
- ``session`` -- ``get(url, params=..., timeout=...)`` for the division
|
||||
rosters; ``cache_manager`` (may be ``None``) -- ``get(key)`` and
|
||||
``set(key, value, ttl=...)`` keep them a day.
|
||||
- ``_team_rankings_cache``, ``_ranked_team_ids`` and
|
||||
``_rankings_cache_timestamp`` -- the tables and when they were fetched
|
||||
(0: never). Written here. ``_rankings_cache_duration`` -- seconds between
|
||||
fetches.
|
||||
- ``_division_team_ids`` (``None``: never loaded) and ``_division_loaded_at``
|
||||
-- the rosters and the monotonic time they were loaded. Written here.
|
||||
- ``other_games_min_quality`` and ``other_games_divisions`` -- the filters.
|
||||
- ``_league_has_rankings`` and ``_game_divisions`` -- from
|
||||
``SportsCoreSharedMixin``.
|
||||
- ``_rankings_loaded`` -- from ``SportsRotationMixin`` (football-scoreboard
|
||||
overrides it to count the id table too).
|
||||
|
||||
The methods read the game dict's ``home_id``, ``away_id``, ``home_abbr`` and
|
||||
``away_abbr``; any may be missing.
|
||||
|
||||
BASE ORDER
|
||||
----------
|
||||
No other mixin defines these names, so the position in the bases does not
|
||||
change which body runs; a method on the plugin's own class still wins. The
|
||||
mixin has no ``__init__``; the state it writes is the host's.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import re
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Callable, ClassVar, Dict, List, Optional, Tuple
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SportsRankingsMixin:
|
||||
"""``SportsCore``'s rankings. See module docstring."""
|
||||
|
||||
# The host contract, declared for type checking only.
|
||||
logger: Any
|
||||
sport: str
|
||||
league: str
|
||||
data_source: Any
|
||||
session: Any
|
||||
cache_manager: Any
|
||||
_team_rankings_cache: Dict[str, int]
|
||||
_ranked_team_ids: Dict[int, int]
|
||||
_rankings_cache_timestamp: float
|
||||
_rankings_cache_duration: float
|
||||
_division_team_ids: Optional[Dict[str, set]]
|
||||
_division_loaded_at: float
|
||||
other_games_min_quality: str
|
||||
other_games_divisions: Any
|
||||
_league_has_rankings: Callable[[], bool]
|
||||
_game_divisions: Callable[[Dict], Optional[set]]
|
||||
_rankings_loaded: Callable[[], bool]
|
||||
|
||||
# Which ranking block the badge reads. ESPN answers /rankings with more
|
||||
# than one block for several leagues, and the FIRST is not always a poll:
|
||||
# men's and women's college hockey front "NCAA Men's/Women's Hockey
|
||||
# Tournament Seedings", and college lacrosse publishes seedings beside its
|
||||
# Inside Lacrosse poll. College football fronts the AP Top 25 but also
|
||||
# carries the FCS and Division II polls and gains the CFP rankings in
|
||||
# November. An EXCLUDE list, not an allow list, so a poll ESPN invents
|
||||
# still counts; type first and name second, because the Division II block
|
||||
# types as "afca" while the FBS coaches poll -- also an AFCA poll -- types
|
||||
# as "usa".
|
||||
_NON_TOP_POLL_TYPES: ClassVar[frozenset] = frozenset({"tournament", "fcs"})
|
||||
_NON_TOP_POLL_NAMES: ClassVar[Tuple[str, ...]] = (
|
||||
"tournament", "seedings", "fcs",
|
||||
"division ii", "division iii", "div ii", "div iii",
|
||||
)
|
||||
|
||||
# ESPN group ids for the college football divisions, from its own group
|
||||
# rosters (disjoint: 148 FBS team ids, 130 FCS). Keyed by league because
|
||||
# no other league publishes usable rosters: college baseball and lacrosse
|
||||
# answer HTTP 500, college basketball and hockey an empty list.
|
||||
_DIVISION_GROUPS_BY_LEAGUE: ClassVar[Dict[str, Dict[str, int]]] = {
|
||||
"college-football": {"fbs": 80, "fcs": 81},
|
||||
}
|
||||
_DIVISION_CACHE_TTL: ClassVar[int] = 24 * 60 * 60
|
||||
# A lookup that came back empty is retried on this shorter clock.
|
||||
_DIVISION_RETRY_SECONDS: ClassVar[int] = 10 * 60
|
||||
|
||||
def _choose_poll(self, rankings_data: Optional[List[Dict]]) -> Dict:
|
||||
"""The first block ESPN lists that is an actual top-division poll.
|
||||
|
||||
ESPN's own order is otherwise kept, so whichever poll it fronts is the
|
||||
one that drives the badge.
|
||||
"""
|
||||
for block in rankings_data or []:
|
||||
name = str(block.get("name") or "").lower()
|
||||
kind = str(block.get("type") or "").lower()
|
||||
if kind in self._NON_TOP_POLL_TYPES or any(
|
||||
marker in name for marker in self._NON_TOP_POLL_NAMES
|
||||
):
|
||||
self.logger.debug(
|
||||
"%s: skipping %s -- not a top-division poll",
|
||||
getattr(self, "league", "?"), block.get("name") or kind)
|
||||
continue
|
||||
return block
|
||||
return {}
|
||||
|
||||
def _fetch_team_rankings(self) -> Dict[str, int]:
|
||||
"""Fetch team rankings using the new architecture components."""
|
||||
current_time = time.time()
|
||||
|
||||
# No poll, no fetch. _league_has_rankings already gated the
|
||||
# quality-filter call site; the show_ranking call sites did not, so
|
||||
# ticking "Show Ranking" on a league that publishes no poll sent two
|
||||
# requests an hour to endpoints that cannot answer. Gating here rather
|
||||
# than at each call site covers every caller at once and cannot drift
|
||||
# apart again.
|
||||
#
|
||||
# It is also the setting's only honest answer on those leagues: the
|
||||
# rank badge replaces the record, so an empty table meant "Show
|
||||
# Ranking" quietly erased the records "Show Records" was drawing. The
|
||||
# schema now hides the toggle wherever the league has no poll, but a
|
||||
# config saved before that still carries it.
|
||||
if not self._league_has_rankings():
|
||||
return self._team_rankings_cache
|
||||
|
||||
# Check if we have cached rankings that are still valid
|
||||
# Gate on when the last look happened, not on whether it found
|
||||
# anything. Professional leagues publish no poll, so `rankings`
|
||||
# comes back empty -- and an empty dict is falsy, so this guard
|
||||
# never short-circuited and the standings endpoint was re-fetched
|
||||
# on every update instead of hourly. Measured 2026-09-19 on an
|
||||
# MLB-only rig: 453 standings requests in a day against the 24
|
||||
# the one-hour duration intends. An empty result is a result.
|
||||
if (
|
||||
self._rankings_cache_timestamp
|
||||
and current_time - self._rankings_cache_timestamp
|
||||
< self._rankings_cache_duration
|
||||
):
|
||||
return self._team_rankings_cache
|
||||
|
||||
try:
|
||||
data = self.data_source.fetch_standings(self.sport, self.league)
|
||||
|
||||
rankings: Dict[str, int] = {}
|
||||
ranked_ids: Dict[int, int] = {}
|
||||
poll = self._choose_poll(data.get("rankings", []))
|
||||
if self._poll_season_ended(poll, current_time):
|
||||
self.logger.info(
|
||||
"%s: %s is from a season that has ended; no rank badges "
|
||||
"until the new season's first poll", self.league,
|
||||
poll.get("name") or "the poll",
|
||||
)
|
||||
poll = {}
|
||||
|
||||
for team_data in poll.get("ranks", []):
|
||||
team_info = team_data.get("team", {})
|
||||
team_abbr = team_info.get("abbreviation", "")
|
||||
current_rank = team_data.get("current", 0)
|
||||
if current_rank <= 0:
|
||||
continue
|
||||
if team_abbr:
|
||||
rankings[team_abbr] = current_rank
|
||||
# Kept by id as well as by abbreviation. The badge draws the
|
||||
# abbreviation, but abbreviations are not unique across
|
||||
# divisions and the FBS and FCS schedules arrive in the SAME
|
||||
# scoreboard payload -- ESPN has SDSU for San Diego State and
|
||||
# SDST for South Dakota State today, and nothing promises it
|
||||
# stays that way. An id cannot rank the wrong school.
|
||||
try:
|
||||
ranked_ids[int(team_info.get("id"))] = current_rank
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
|
||||
# Cache the results
|
||||
self._team_rankings_cache = rankings
|
||||
self._ranked_team_ids = ranked_ids
|
||||
self._rankings_cache_timestamp = current_time
|
||||
|
||||
if rankings:
|
||||
self.logger.info(
|
||||
"%s: %d ranked teams loaded from %s", self.league,
|
||||
len(rankings), poll.get("name") or "an unnamed poll",
|
||||
)
|
||||
else:
|
||||
self.logger.debug("Fetched rankings for 0 teams")
|
||||
return rankings
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error fetching team rankings: {e}")
|
||||
return {}
|
||||
|
||||
@staticmethod
|
||||
def _poll_season_ended(poll: Dict, now: float) -> bool:
|
||||
"""Is this poll from a season that is already over?
|
||||
|
||||
ESPN keeps serving the last poll it published until the next one
|
||||
appears: in October, college basketball's /rankings is still April's
|
||||
final poll and college lacrosse's is May's. Every block names its
|
||||
season with an ``endDate``; once that has passed, the badge would rank
|
||||
teams on a finished season, so the table stays empty until the new
|
||||
season's first poll arrives. Asked of the data rather than a calendar,
|
||||
so no league needs its own season dates. A block without a readable
|
||||
``endDate`` counts as current, as before.
|
||||
"""
|
||||
season = poll.get("season")
|
||||
end = str(season.get("endDate") or "") if isinstance(season, dict) else ""
|
||||
try:
|
||||
ended = datetime.strptime(end[:16], "%Y-%m-%dT%H:%M")
|
||||
except ValueError:
|
||||
return False
|
||||
return now > ended.replace(tzinfo=timezone.utc).timestamp()
|
||||
|
||||
def _load_division_team_ids(self) -> Dict[str, set]:
|
||||
"""Team ids per college division. Two requests a day, one league.
|
||||
|
||||
Returns empty sets on any failure -- the caller treats "unknown" as
|
||||
"allowed", because a division lookup that fails must not blank the
|
||||
board.
|
||||
|
||||
The in-memory copy expires like the stored one. Holding it for the life
|
||||
of the process meant two things, both silent: a board that happened to
|
||||
be offline for the first lookup had division filtering disabled until
|
||||
someone restarted the service, which on a display running for weeks is
|
||||
indefinitely; and a roster that changed between seasons was never
|
||||
picked up. A failed lookup is retried sooner than a good one, so a
|
||||
blip costs minutes rather than a day, without retrying per frame.
|
||||
"""
|
||||
now = time.monotonic()
|
||||
if self._division_team_ids is not None:
|
||||
resolved = any(self._division_team_ids.values())
|
||||
age_limit = self._DIVISION_CACHE_TTL if resolved else self._DIVISION_RETRY_SECONDS
|
||||
if now - self._division_loaded_at < age_limit:
|
||||
return self._division_team_ids
|
||||
self._division_team_ids = {}
|
||||
self._division_loaded_at = now
|
||||
groups = self._DIVISION_GROUPS_BY_LEAGUE.get((self.league or "").lower())
|
||||
if not groups:
|
||||
return self._division_team_ids # no divisions to speak of
|
||||
for name, group in groups.items():
|
||||
ids = set()
|
||||
key = f"{self.league}_division_teams_{group}"
|
||||
try:
|
||||
cached = self.cache_manager.get(key) if self.cache_manager else None
|
||||
if cached:
|
||||
ids = {int(i) for i in cached}
|
||||
else:
|
||||
# The SEASON year, not the calendar year. College
|
||||
# football's 2026 season runs into January 2027, and asking
|
||||
# for season 2027 in January returns groups that do not
|
||||
# exist yet: the roster comes back empty, division
|
||||
# filtering fails open, and it does so through the bowls
|
||||
# and the playoff -- the weeks anyone is watching.
|
||||
today = datetime.now()
|
||||
season = today.year if today.month >= 7 else today.year - 1
|
||||
url = (
|
||||
"https://sports.core.api.espn.com/v2/sports/"
|
||||
f"{self.sport}/leagues/{self.league}/seasons/"
|
||||
f"{season}/types/2/groups/{group}/teams"
|
||||
)
|
||||
resp = self.session.get(url, params={"limit": 300}, timeout=15)
|
||||
resp.raise_for_status()
|
||||
for item in resp.json().get("items", []):
|
||||
found = re.search(r"/teams/(\d+)", item.get("$ref", ""))
|
||||
if found:
|
||||
ids.add(int(found.group(1)))
|
||||
if ids and self.cache_manager:
|
||||
self.cache_manager.set(
|
||||
key, sorted(ids), ttl=self._DIVISION_CACHE_TTL
|
||||
)
|
||||
except Exception as exc:
|
||||
self.logger.warning(
|
||||
"Could not resolve %s teams for %s (%s); division filtering "
|
||||
"will allow everything", name, self.league, exc
|
||||
)
|
||||
self._division_team_ids[name] = ids
|
||||
return self._division_team_ids
|
||||
|
||||
def _ranked_positions(self, game: Dict) -> List[int]:
|
||||
"""Poll positions held by either side: ids first, abbreviations second.
|
||||
|
||||
The id table is authoritative whenever the game carries ids and a poll
|
||||
has been parsed, because it is built from a top-division poll alone --
|
||||
so an FCS side cannot appear in it however it is abbreviated, and an
|
||||
abbreviation shared across divisions cannot promote the wrong school.
|
||||
The abbreviation table stays as the fallback for a game dict with no
|
||||
ids on it.
|
||||
"""
|
||||
by_id = getattr(self, "_ranked_team_ids", None) or {}
|
||||
ids = []
|
||||
for side in ("home_id", "away_id"):
|
||||
try:
|
||||
ids.append(int(game.get(side))) # type: ignore[arg-type] # None: caught below
|
||||
except (TypeError, ValueError):
|
||||
ids = []
|
||||
break
|
||||
if by_id and ids:
|
||||
return [rank for rank in (by_id.get(i, 0) for i in ids) if rank]
|
||||
by_abbr = getattr(self, "_team_rankings_cache", None) or {}
|
||||
return [rank for rank in (by_abbr.get(game.get("home_abbr"), 0),
|
||||
by_abbr.get(game.get("away_abbr"), 0)) if rank]
|
||||
|
||||
def _is_ranked_game(self, game: Dict) -> bool:
|
||||
return bool(self._ranked_positions(game))
|
||||
|
||||
def _best_rank(self, game: Dict) -> int:
|
||||
"""The better of the two sides' poll positions, or 99 if neither ranks."""
|
||||
found = self._ranked_positions(game)
|
||||
return min(found) if found else 99
|
||||
|
||||
def _passes_other_filters(self, game: Dict) -> bool:
|
||||
"""Is this non-favourite game worth one of the remaining slots?
|
||||
|
||||
"ranked" means ranked in the TOP division's poll -- see _choose_poll.
|
||||
An FCS side ranked in its own poll does not qualify the game it is
|
||||
playing in, which for college football is most often an FCS school
|
||||
visiting an unranked FBS one.
|
||||
|
||||
Every check fails OPEN. If rankings could not be fetched or the
|
||||
division rosters did not resolve, the game is allowed: a board showing
|
||||
filler is a poor board, but a board showing nothing is a broken one.
|
||||
"""
|
||||
if self.other_games_min_quality == "ranked":
|
||||
if self._rankings_loaded() and not self._is_ranked_game(game):
|
||||
return False
|
||||
|
||||
wanted = self.other_games_divisions
|
||||
if wanted:
|
||||
present = self._game_divisions(game)
|
||||
if present is not None and not (present & set(wanted)):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
class _PollChooser(SportsRankingsMixin):
|
||||
"""Just enough host for ``_choose_poll``."""
|
||||
|
||||
def __init__(self, logger: Any, league: str) -> None:
|
||||
self.logger = logger
|
||||
self.league = league
|
||||
|
||||
|
||||
def choose_top_division_poll(rankings_data: Optional[List[Dict]], logger: Any = None,
|
||||
label: Optional[str] = None) -> Dict:
|
||||
"""``SportsRankingsMixin._choose_poll`` for code without a scoreboard.
|
||||
|
||||
The one rule, for the dynamic team resolvers that expand ``AP_TOP_25`` and
|
||||
the like, so the badge and the favourites they resolve read the same poll.
|
||||
``label`` names the league in the debug line for a skipped block.
|
||||
"""
|
||||
return _PollChooser(logger or _LOGGER, label or "?")._choose_poll(rankings_data)
|
||||
@@ -65,7 +65,7 @@ listed here. Every scoreboard ``SportsCore`` supplies all of them.
|
||||
getattr, so a manager that never built it does not rotate.
|
||||
- ``_compose_selection`` -- from ``SportsCoreSharedMixin``.
|
||||
- ``_best_rank`` -- a game's better poll position, 99 if neither side ranks
|
||||
(family 8, still per-plugin).
|
||||
(family 8: ``src.common.sports_rankings``).
|
||||
- ``_team_rankings_cache`` -- read with getattr by the default
|
||||
``_rankings_loaded``.
|
||||
- ``games_list``, ``current_game``, ``current_game_index``,
|
||||
|
||||
@@ -55,13 +55,15 @@ State: ``config``, ``fonts``, ``logger``, ``display_width``, ``display_height``,
|
||||
Methods that stay per-plugin, because they are not identical across the eight
|
||||
(or, for ``_get_timezone``, because they bind per-plugin modules):
|
||||
``_get_layout_offset``, ``_upcoming_date_and_time_text``,
|
||||
``_extract_game_details_common``, ``_load_division_team_ids``,
|
||||
``_get_timezone``, ``_is_ranked_game``, ``_passes_other_filters``. Three
|
||||
families were here until the plugins reconciled them: ``_is_game_really_over``,
|
||||
which ``_detect_stale_games`` below calls, is now ``src.common.sports_game_over``;
|
||||
``_is_favorite_game`` is ``src.common.sports_favorites``; and
|
||||
``_extract_game_details_common``, ``_get_timezone``. Four families were
|
||||
here until the plugins reconciled them: ``_is_game_really_over``, which
|
||||
``_detect_stale_games`` below calls, is now ``src.common.sports_game_over``;
|
||||
``_is_favorite_game`` is ``src.common.sports_favorites``;
|
||||
``_by_importance`` and ``_other_games_window``, which ``_favorites_first`` and
|
||||
``_compose_selection`` below call, are ``src.common.sports_rotation``.
|
||||
``_compose_selection`` below call, are ``src.common.sports_rotation``; and
|
||||
``_load_division_team_ids``, ``_is_ranked_game`` and ``_passes_other_filters``
|
||||
(``_game_divisions``, ``_check_ranking_coverage`` and ``_favorites_first``
|
||||
below call them) are ``src.common.sports_rankings``.
|
||||
|
||||
Of the fourteen shared class constants, thirteen are identical everywhere and
|
||||
live here. Only ``_SCORE_PROBE_TEXT`` varies -- afl and basketball reach three digits
|
||||
|
||||
@@ -0,0 +1,379 @@
|
||||
"""src.common.sports_rankings: the poll, the tables, the season, the filter.
|
||||
|
||||
The cases follow ledmatrix-plugins' ``scripts/test_rankings.py`` (the tables
|
||||
the family 8 reconcile was checked against); the expected values are its
|
||||
columns, which agree across all nine scoreboards for everything this module
|
||||
does. The host is the mixin over core's ``SportsCoreSharedMixin``
|
||||
(``_league_has_rankings``, ``_game_divisions``) and ``SportsRotationMixin``
|
||||
(``_rankings_loaded``), with a data source and an HTTP session that answer
|
||||
from fixtures shaped like ESPN's payloads. Nothing reaches the network.
|
||||
"""
|
||||
|
||||
import ast
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from src.common import sports_rankings
|
||||
from src.common.sports_favorites import SportsFavoritesMixin
|
||||
from src.common.sports_helpers import SportsHelpersMixin
|
||||
from src.common.sports_rankings import SportsRankingsMixin, choose_top_division_poll
|
||||
from src.common.sports_rotation import SportsRotationMixin
|
||||
from src.common.sports_shared import SportsCoreSharedMixin
|
||||
|
||||
LOG = logging.getLogger("test_sports_rankings")
|
||||
CORE_API = "https://sports.core.api.espn.com/v2/sports/"
|
||||
|
||||
UGA, ND, OSU, TEX, VAN, SDSU = ("61", "UGA"), ("87", "ND"), ("194", "OSU"), \
|
||||
("251", "TEX"), ("238", "VAN"), ("21", "SDSU")
|
||||
SDST, NDSU, NEWARK, FRST, DEN = ("2571", "SDST"), ("2449", "NDSU"), \
|
||||
("9194", "OSU"), ("9001", "FRST"), ("2172", "DEN")
|
||||
FBS_IDS, FCS_IDS = (UGA, ND, OSU, TEX, VAN, SDSU), (SDST, NDSU)
|
||||
|
||||
|
||||
def poll(name, kind, *teams, **extra):
|
||||
return {"name": name, "shortName": name, "type": kind, **extra, "ranks": [
|
||||
{"current": n, "previous": n, "team": {"id": tid, "abbreviation": abbr}}
|
||||
for n, (tid, abbr) in enumerate(teams, 1)]}
|
||||
|
||||
|
||||
AP = poll("AP Top 25", "ap", UGA, ND, OSU, SDSU)
|
||||
COACHES = poll("AFCA Coaches Poll", "usa", ND, UGA, TEX)
|
||||
CFP = poll("College Football Playoff Rankings", "cfp", OSU, UGA)
|
||||
FCS = poll("FCS Coaches Poll", "fcs", NDSU, SDST)
|
||||
D2 = poll("AFCA Division II Coaches Poll", "afca", FRST)
|
||||
SEEDS = poll("NCAA Men's Hockey Tournament Seedings", "tournament", DEN)
|
||||
USCHO = poll("USCHO Men's Poll", "USCHOMENSPOLL", DEN, ND)
|
||||
AP_TABLE = {"ND": 2, "OSU": 3, "SDSU": 4, "UGA": 1}
|
||||
AP_IDS = {194: 3, 21: 4, 61: 1, 87: 2}
|
||||
|
||||
|
||||
def at(text):
|
||||
return datetime.fromisoformat(text).replace(tzinfo=timezone.utc).timestamp()
|
||||
|
||||
|
||||
class Clock:
|
||||
def __init__(self):
|
||||
self.now = 100_000.0
|
||||
|
||||
def __call__(self):
|
||||
return self.now
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def clock(monkeypatch):
|
||||
c = Clock()
|
||||
monkeypatch.setattr(sports_rankings.time, "time", c)
|
||||
monkeypatch.setattr(sports_rankings.time, "monotonic", c)
|
||||
return c
|
||||
|
||||
|
||||
class Rosters:
|
||||
"""The core API's division rosters, published up to ``last`` season."""
|
||||
|
||||
def __init__(self, last=2026):
|
||||
self.last, self.urls = last, []
|
||||
|
||||
def get(self, url, params=None, timeout=None):
|
||||
self.urls.append(url[len(CORE_API):])
|
||||
season, group = url.split("/seasons/")[1].split("/")[0], url.split("/groups/")[1][:2]
|
||||
ids = {"80": FBS_IDS, "81": FCS_IDS}[group] if int(season) <= self.last else ()
|
||||
resp = MagicMock()
|
||||
resp.json.return_value = {"items": [{"$ref": f"{CORE_API}x/teams/{t}?lang=en"}
|
||||
for t, _ in ids]}
|
||||
return resp
|
||||
|
||||
|
||||
class Host(SportsRankingsMixin, SportsRotationMixin, SportsCoreSharedMixin,
|
||||
SportsFavoritesMixin, SportsHelpersMixin):
|
||||
"""A scoreboard SportsCore as far as the rankings reach."""
|
||||
|
||||
sport = "football"
|
||||
|
||||
def __init__(self, league="college-football", payload=None, quality="any",
|
||||
divisions=(), rosters=None):
|
||||
self.logger = LOG
|
||||
self.league = league
|
||||
self.data_source = MagicMock()
|
||||
self.data_source.fetch_standings.return_value = (
|
||||
{"rankings": [AP, COACHES]} if payload is None else payload)
|
||||
self.session = rosters or Rosters()
|
||||
self.cache_manager = MagicMock()
|
||||
self.cache_manager.get.return_value = None
|
||||
self._team_rankings_cache, self._ranked_team_ids = {}, {}
|
||||
self._rankings_cache_timestamp, self._rankings_cache_duration = 0, 3600
|
||||
self._division_team_ids, self._division_loaded_at = None, 0.0
|
||||
self.other_games_min_quality = quality
|
||||
self.other_games_divisions = list(divisions)
|
||||
|
||||
|
||||
def tables(host):
|
||||
return host._team_rankings_cache, host._ranked_team_ids
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Which poll
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
POLL_SETS = [
|
||||
([AP, COACHES], "AP Top 25"),
|
||||
([COACHES, AP], "AFCA Coaches Poll"),
|
||||
([CFP, AP, COACHES], "College Football Playoff Rankings"),
|
||||
([FCS, D2, AP, COACHES], "AP Top 25"),
|
||||
([FCS, D2], None),
|
||||
([poll("Coaches Poll", "fcs", NDSU), AP], "AP Top 25"), # by type alone
|
||||
([SEEDS, USCHO], "USCHO Men's Poll"),
|
||||
([], None),
|
||||
(None, None),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("blocks, expected", POLL_SETS)
|
||||
def test_the_poll_chosen(blocks, expected):
|
||||
assert Host()._choose_poll(blocks).get("name") == expected
|
||||
assert choose_top_division_poll(blocks).get("name") == expected
|
||||
|
||||
|
||||
def test_an_untyped_unnamed_block_counts():
|
||||
"""An exclude list: a block ESPN invents is a poll unless it says otherwise."""
|
||||
assert choose_top_division_poll([{"ranks": []}]) == {"ranks": []}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The tables
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.parametrize("league, asked", [
|
||||
("college-football", True), ("mens-college-basketball", True),
|
||||
("usa.ncaa.m.1", True), ("nfl", False), ("nba", False), ("eng.1", False)])
|
||||
def test_only_a_league_with_a_poll_is_asked(clock, league, asked):
|
||||
host = Host(league)
|
||||
host._fetch_team_rankings()
|
||||
assert host.data_source.fetch_standings.called is asked
|
||||
assert tables(host) == ((AP_TABLE, AP_IDS) if asked else ({}, {}))
|
||||
|
||||
|
||||
@pytest.mark.parametrize("payload, abbr, ids", [
|
||||
({}, {}, {}),
|
||||
({"rankings": []}, {}, {}),
|
||||
({"rankings": None}, {}, {}),
|
||||
({"rankings": [{"name": "AP Top 25", "type": "ap"}]}, {}, {}),
|
||||
({"rankings": [{"name": "AP Top 25", "type": "ap", "ranks": [
|
||||
{"current": 0, "team": {"id": "61", "abbreviation": "UGA"}},
|
||||
{"current": -1, "team": {"id": "87", "abbreviation": "ND"}},
|
||||
{"team": {"id": "194", "abbreviation": "OSU"}},
|
||||
{"current": 4, "team": {"id": "251", "abbreviation": "TEX"}}]}]},
|
||||
{"TEX": 4}, {251: 4}),
|
||||
({"rankings": [{"name": "AP Top 25", "type": "ap", "ranks": [
|
||||
{"current": 1},
|
||||
{"current": 2, "team": {"id": "87"}},
|
||||
{"current": 3, "team": {"id": "abc", "abbreviation": "OSU"}},
|
||||
{"current": 4, "team": {"abbreviation": "TEX"}}]}]},
|
||||
{"OSU": 3, "TEX": 4}, {87: 2}),
|
||||
])
|
||||
def test_payload_shapes(clock, payload, abbr, ids):
|
||||
host = Host(payload=payload)
|
||||
host._fetch_team_rankings()
|
||||
assert tables(host) == (abbr, ids)
|
||||
assert host._rankings_cache_timestamp == clock.now # an empty answer is cached too
|
||||
|
||||
|
||||
def test_a_failed_fetch_is_not_cached(clock):
|
||||
host = Host(payload={"rankings": {"name": "AP Top 25"}}) # not a list
|
||||
assert host._fetch_team_rankings() == {}
|
||||
assert host._rankings_cache_timestamp == 0
|
||||
|
||||
|
||||
@pytest.mark.parametrize("payload", [{"rankings": [AP, COACHES]}, {"rankings": []}])
|
||||
def test_rankings_are_fetched_hourly(clock, payload):
|
||||
host = Host(payload=payload)
|
||||
calls = []
|
||||
for step in (0, 60, 3599, 3601):
|
||||
clock.now = 100_000.0 + step
|
||||
before = host.data_source.fetch_standings.call_count
|
||||
host._fetch_team_rankings()
|
||||
calls.append(host.data_source.fetch_standings.call_count - before)
|
||||
assert calls == [1, 0, 0, 1]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# A poll from a season that has ended
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.parametrize("end, now, kept", [
|
||||
("2026-04-08T06:59Z", "2026-10-12T12:00", False), # college basketball in October
|
||||
("2026-04-08T06:59Z", "2026-04-08T06:58", True),
|
||||
("2026-04-08T06:59Z", "2026-04-08T06:59", True), # at endDate: not yet over
|
||||
("2026-04-08T06:59Z", "2026-04-08T07:00", False),
|
||||
("2027-01-28T07:59Z", "2027-01-10T12:00", True), # bowl week
|
||||
("2027-04-08T06:59Z", "2026-11-03T12:00", True), # the new season's first poll
|
||||
("2026-04-08T06:59:00Z", "2026-10-12T12:00", False),
|
||||
("April", "2026-10-12T12:00", True), # unreadable: current
|
||||
(None, "2026-10-12T12:00", True), # no season block
|
||||
])
|
||||
def test_a_finished_seasons_poll_loads_nothing(clock, end, now, kept):
|
||||
block = dict(AP, season={"year": 2026, "endDate": end}) if end else AP
|
||||
host = Host(payload={"rankings": [block, COACHES]})
|
||||
clock.now = at(now)
|
||||
host._fetch_team_rankings()
|
||||
assert tables(host) == ((AP_TABLE, AP_IDS) if kept else ({}, {}))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Division rosters
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class Frozen(datetime):
|
||||
at = datetime(2026, 10, 12, 12)
|
||||
|
||||
@classmethod
|
||||
def now(cls, tz=None):
|
||||
return cls.at
|
||||
|
||||
|
||||
@pytest.mark.parametrize("today, season, sizes", [
|
||||
("2026-01-15", 2025, (6, 2)), ("2026-06-30", 2025, (6, 2)),
|
||||
("2026-07-01", 2026, (6, 2)), ("2026-10-12", 2026, (6, 2)),
|
||||
("2026-12-31", 2026, (6, 2)), ("2027-01-15", 2026, (6, 2))])
|
||||
def test_the_rosters_are_for_the_season_year(clock, monkeypatch, today, season, sizes):
|
||||
monkeypatch.setattr(sports_rankings, "datetime", Frozen)
|
||||
Frozen.at = datetime.fromisoformat(today)
|
||||
host = Host()
|
||||
rosters = host._load_division_team_ids()
|
||||
assert host.session.urls == [
|
||||
f"football/leagues/college-football/seasons/{season}/types/2/groups/{g}/teams"
|
||||
for g in (80, 81)]
|
||||
assert (len(rosters["fbs"]), len(rosters["fcs"])) == sizes
|
||||
|
||||
|
||||
def test_no_rosters_outside_college_football(clock):
|
||||
host = Host("mens-college-basketball")
|
||||
assert host._load_division_team_ids() == {}
|
||||
assert host.session.urls == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize("last, cached, calls, sizes", [
|
||||
(2026, None, [2, 0, 0, 2], (6, 2)), # a day
|
||||
(0, None, [2, 0, 2, 2], (0, 0)), # empty: retried after 10 minutes
|
||||
(2026, "cache", [0, 0, 0, 0], (2, 1)), # the cache manager's copy
|
||||
])
|
||||
def test_roster_caching(clock, last, cached, calls, sizes):
|
||||
host = Host(rosters=Rosters(last))
|
||||
if cached:
|
||||
host.cache_manager.get.side_effect = lambda key, *a, **k: (
|
||||
["61", "87"] if key.endswith("_80") else ["2571"])
|
||||
made = []
|
||||
for step in (0, 60, 601, 86_401):
|
||||
clock.now = 100_000.0 + step
|
||||
before = len(host.session.urls)
|
||||
host._load_division_team_ids()
|
||||
made.append(len(host.session.urls) - before)
|
||||
rosters = host._division_team_ids
|
||||
assert made == calls
|
||||
assert (len(rosters["fbs"]), len(rosters["fcs"])) == sizes
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Ranked games and the filter
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
GAMES = {
|
||||
"UGA v TEX": (UGA, TEX),
|
||||
"TEX v VAN": (TEX, VAN),
|
||||
"OSU v VAN": (OSU, VAN),
|
||||
"Newark 'OSU' v VAN": (NEWARK, VAN),
|
||||
"SDSU v SDST": (SDSU, SDST),
|
||||
"SDST v NDSU": (SDST, NDSU),
|
||||
"'OSU' v VAN, no ids": ((None, "OSU"), (None, "VAN")),
|
||||
"61 v 238, no abbrs": (("61", None), ("238", None)),
|
||||
}
|
||||
|
||||
|
||||
def game(label):
|
||||
g = {"id": "g"}
|
||||
for side, (tid, abbr) in zip(("home", "away"), GAMES[label]):
|
||||
if tid is not None:
|
||||
g[f"{side}_id"] = tid
|
||||
if abbr is not None:
|
||||
g[f"{side}_abbr"] = abbr
|
||||
return g
|
||||
|
||||
|
||||
def loaded(**kwargs):
|
||||
host = Host(**kwargs)
|
||||
host._fetch_team_rankings()
|
||||
return host
|
||||
|
||||
|
||||
@pytest.mark.parametrize("label, best", [
|
||||
("UGA v TEX", 1), ("TEX v VAN", 99), ("OSU v VAN", 3),
|
||||
("Newark 'OSU' v VAN", 99), # same abbreviation, another school: by id
|
||||
("SDSU v SDST", 4), ("SDST v NDSU", 99),
|
||||
("'OSU' v VAN, no ids", 3), # no ids: by abbreviation
|
||||
("61 v 238, no abbrs", 1)])
|
||||
def test_ranked_and_best_rank(clock, label, best):
|
||||
host = loaded()
|
||||
assert host._best_rank(game(label)) == best
|
||||
assert host._is_ranked_game(game(label)) is (best != 99)
|
||||
|
||||
|
||||
#: filter -> (quality, divisions, poll loaded?, kept: one Y/. per GAMES entry)
|
||||
FILTERS = {
|
||||
"any": ("any", (), True, "YYYYYYYY"),
|
||||
"ranked": ("ranked", (), True, "Y.Y.Y.YY"),
|
||||
"ranked, no poll loaded": ("ranked", (), False, "YYYYYYYY"),
|
||||
"any, fbs": ("any", ("fbs",), True, "YYYYY.YY"),
|
||||
"ranked, fbs": ("ranked", ("fbs",), True, "Y.Y.Y.YY"),
|
||||
"any, fcs": ("any", ("fcs",), True, "....YYY."),
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", FILTERS)
|
||||
def test_passes_other_filters(clock, monkeypatch, name):
|
||||
quality, divisions, with_poll, kept = FILTERS[name]
|
||||
monkeypatch.setattr(sports_rankings, "datetime", Frozen)
|
||||
Frozen.at = datetime(2026, 10, 12, 12)
|
||||
host = loaded(payload={"rankings": [AP, COACHES] if with_poll else []},
|
||||
quality=quality, divisions=divisions)
|
||||
assert "".join("Y" if host._passes_other_filters(game(g)) else "." for g in GAMES) == kept
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Carrier and host contract
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
MEMBERS = ["_DIVISION_CACHE_TTL", "_DIVISION_GROUPS_BY_LEAGUE", "_DIVISION_RETRY_SECONDS",
|
||||
"_NON_TOP_POLL_NAMES", "_NON_TOP_POLL_TYPES", "_best_rank", "_choose_poll",
|
||||
"_fetch_team_rankings", "_is_ranked_game", "_load_division_team_ids",
|
||||
"_passes_other_filters", "_poll_season_ended", "_ranked_positions"]
|
||||
|
||||
|
||||
def _self_reads():
|
||||
tree = ast.parse(Path(sports_rankings.__file__).read_text(encoding="utf-8"))
|
||||
return {node.attr for node in ast.walk(tree)
|
||||
if isinstance(node, ast.Attribute) and isinstance(node.ctx, ast.Load)
|
||||
and isinstance(node.value, ast.Name) and node.value.id == "self"}
|
||||
|
||||
|
||||
class TestHostContract:
|
||||
def test_the_mixin_carries_only_the_family(self):
|
||||
"""Every scoreboard's SportsCore has all of them, so adopting it adds none."""
|
||||
assert sorted(n for n in vars(SportsRankingsMixin)
|
||||
if not n.startswith("__")) == MEMBERS
|
||||
|
||||
def test_every_host_read_is_documented(self):
|
||||
own = set(MEMBERS)
|
||||
undocumented = sorted(n for n in _self_reads() - own
|
||||
if f"``{n}``" not in sports_rankings.__doc__)
|
||||
assert undocumented == [], f"read but not in the host contract: {undocumented}"
|
||||
|
||||
def test_no_other_shared_mixin_defines_these(self):
|
||||
from src.common import sports_display_rules, sports_shared
|
||||
others = [sports_shared.SportsCoreSharedMixin, sports_shared.SportsRecentSharedMixin,
|
||||
sports_shared.SportsLiveSharedMixin, SportsHelpersMixin,
|
||||
SportsFavoritesMixin, SportsRotationMixin,
|
||||
sports_display_rules.SportsGameRulesMixin]
|
||||
assert [m for m in MEMBERS if any(m in vars(o) for o in others)] == []
|
||||
@@ -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