Compare commits

..
Author SHA1 Message Date
Chuck 185d5f0aa0 Merge main into claude/on-demand-error-clears-session 2026-10-09 12:15:52 -04:00
Chuck 9234332c5a Merge main into claude/on-demand-error-clears-session 2026-10-09 11:55:22 -04:00
ChuckandClaude Opus 5.5 4b9fc3c755 fix(display): a failed on-demand request drops the session it ended
_set_on_demand_error ends any running session (_reset_on_demand_fields)
but left its saved copy, display_on_demand_config, in the cache. A failed
request that replaced a running session therefore made the next restart
resume the session that had already ended.

Clear the saved copy where every error path goes through, and drop the
two restore-failed callers' own clears, which this now covers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 17:18:36 -04:00
15 changed files with 48 additions and 2354 deletions
-54
View File
@@ -19,45 +19,6 @@ 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
(#759); a failed on-demand request ends its own session (#779); exception
messages stay out of API responses (#778); and sports consolidation family 7,
`src.common.sports_rotation`, which the scoreboards adopt by flooring on
3.8.4 (#786).
### Scroll speed: a panel slower than its refresh cap is reported
- Scroll speeds are solved against `limit_refresh_rate_hz`, so a panel that
@@ -89,21 +50,6 @@ messages stay out of API responses (#778); and sports consolidation family 7,
but drop systemctl's `stdout`/`stderr`; WiFi, unit-refresh and
config-save failures say what failed and point at the log.
### New modules
- `src/common/sports_rotation.py` -- sports consolidation family 7, once the
plugins made `_by_importance`, `_other_games_window`,
`_advance_other_games_if_due` (two bodies each), `_rotate_other_games_on_display`
(two) and `_attach_odds_to_rotated_games` (three; ufc had none) one each:
`SportsRotationMixin` (`SportsCore`), the other-games rotation. The window
advances under `_games_lock`, so `update()` and `display()` advancing it at
once no longer skip a window; the display path's due-check reads the pool
`_compose_selection` will cut, unfiltered fallback included; rotated-in
games get odds when `show_odds` is on. `_rankings_loaded()` is the seam
`_by_importance` asks (default: the abbreviation table is non-empty;
football also counts its by-id table). A plugin may inherit the mixin once
it floors on 3.8.4, and deletes its copies then. (#786)
## 3.8.3
Fresh installs on Raspberry Pi OS Lite work again: 3.8.2's installer reported
+27 -122
View File
@@ -93,8 +93,6 @@ more. Shared sports code lives in `src/common`:
| `sports_font_path.py` | 3.8.0 | `resolve_font_path` — what the plugins' `_resolve_font_path` copies return |
| `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).
@@ -130,7 +128,6 @@ deprecation cycle.
| `_custom_scorebug_layout(game, draw)` | Per-sport overlay on the base layout | no-op |
| `score_phrase(points, team_abbr)` | Celebration wording (`"GOOOOAAALLL!"` vs `"TOUCHDOWN!"`). `points` is the score delta, which sports with variable-value scores use to name the play | `"<abbr> SCORES!"` — only consulted when `CelebrationMixin` is present |
| `win_phrase(team_abbr)` | Win-celebration wording | `"<abbr> WINS!"` — mixin only |
| `_rankings_loaded()` | Whether a poll loaded, so `_by_importance` orders by rank (`sports_rotation`) | `_team_rankings_cache` is non-empty. football also counts its rankings keyed by team id |
| `_favorite_key(game, side)` | Which view-model field identifies a team for favorites matching. `sports_favorites` compares it, and each `favorite_teams` entry, stripped and upper-cased; a `None` matches nothing | `game["<side>_abbr"]`. nrl returns the ESPN team id, `None` when it is missing |
| `_config_schema_path()` | Plugin's `config_schema.json` — returning it routes `_get_layout_offset` through the `src.element_style` resolver (and gives it the defaults to compare against) | `None`, i.e. the classic inline `customization.layout` read |
| `_font_root()` | Directory to resolve `assets/fonts` against | core install root |
@@ -271,7 +268,7 @@ gave pixel-identical output for all 399 frames (192 harness screens across the
nine plugins at the eight default sizes, 72 scroll/Vegas cards, 135
celebration frames), with a parent-vs-parent rerun as the determinism control.
### Stage 4: the identical sweep (done: core 3.8.0, adopted)
### Stage 4: the identical sweep (core done; adoption waits for a release)
Re-measured on ledmatrix-plugins `56c4f15` (2026-09-30) the report still
lists 58 families identical in every copy. Stage 4 moves the ones that are
@@ -306,7 +303,7 @@ Left in the plugins, though identical:
renderers) is already core's, in `SportsHelpersMixin`; a renderer that
wants it can inherit that.
### Family 5: the game-over check (done: core 3.8.1, adopted)
### Family 5: the game-over check (core done; adoption waits for a release)
The pilot of the method below. ledmatrix-plugins `scripts/test_game_over_check.py`
(#621) pinned 3,115 answers across the nine plugins first; the reconcile
@@ -318,10 +315,9 @@ clock rule (65 cells), baseball's dormant one (53, every one a game with a
cells in hockey, basketball, football and lacrosse). The harness renders
were pixel-identical. `src/common/sports_game_over.py` holds the body;
`test/test_sports_game_over_parity.py` compares it, and each plugin's
`FINAL_PERIOD`, with the plugin copies. Core 3.8.1 shipped it, and all nine
scoreboards inherit it and floor on 3.8.1 (ledmatrix-plugins #631).
`FINAL_PERIOD`, with the plugin copies.
### Family 6: favourite matching (done: core 3.8.2, adopted)
### Family 6: favourite matching (core done; adoption waits for a release)
ledmatrix-plugins `scripts/test_favourite_matching.py` (#634) pinned 204 rows
across the nine plugins first: `_is_favorite_game` on each manager role, the
@@ -341,8 +337,7 @@ each), nrl's key (6) and its "None" match (6), and the INFO line in baseball,
football and ufc. The harness renders were byte-identical. `src/common/sports_favorites.py` holds the
bodies, one mixin per carrying class; `test/test_sports_favorites_parity.py`
compares them with the plugin copies and checks that only nrl overrides
`_favorite_key`. Core 3.8.2 shipped it, and all nine scoreboards inherit it and
floor on 3.8.2 (ledmatrix-plugins #637).
`_favorite_key`.
Left for later families: the live screens' favourites-only filter
(`_classify_live_game` and its inline copies) and favourites-first sort still
@@ -351,70 +346,6 @@ 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 (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
`_favorites_first` pools per favourites, quality, divisions and rankings, the
window over time, the real `update()` followed by `display()`'s rotation call
(the list, the card on screen, redraws and how often the list is recomposed),
`update()` and `display()` advancing the window in sequence and interleaved on
two threads, odds for rotated-in games, and `favorite_rotation_boost`'s
switch order. The reconcile (ledmatrix-plugins #641)
made `_by_importance`, `_other_games_window`, `_advance_other_games_if_due`,
`_rotate_other_games_on_display` and `_attach_odds_to_rotated_games` one body
on `SportsCore` (ufc gains the odds helper), with `_rankings_loaded` as the
seam `_by_importance` asks: the abbreviation table by default, football's
override also counts its rankings keyed by team id. Of 864 cells only those
the decisions below explain changed: the window lock (the interleaved row, in
the seven plugins with a reachable `update()` other than football), the
due-check's fallback pool (two rows in the same seven) and ufc's rotated-in
odds (one cell). The harness renders were byte-identical (208 PNGs).
`src/common/sports_rotation.py` holds the bodies in one mixin,
`SportsRotationMixin`; `test/test_sports_rotation_parity.py` compares them
with the plugin copies and checks that only football overrides
`_rankings_loaded`.
Left for later families: in eight plugins the no-favourites branch of
`update()` still picks a fixed "next N" through `_filtered_or_all` and never
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` 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
Byte-identical promotion has nearly run dry. Measured on ledmatrix-plugins
@@ -498,11 +429,11 @@ release.
| # | Family | Methods (variants) | Why here |
|---|---|---|---|
| 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`; 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) |
| 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. Core side done; see [Stage 4](#stage-4-the-identical-sweep-core-done-adoption-waits-for-a-release) |
| 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`; adoption waits for the release that ships it. See [Family 5](#family-5-the-game-over-check-core-done-adoption-waits-for-a-release) |
| 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`; adoption waits for the release that ships it. See [Family 6](#family-6-favourite-matching-core-done-adoption-waits-for-a-release) |
| 7 | Other-games rotation | `_by_importance`, `_other_games_window`, `_advance_other_games_if_due` (2 each: football), `_rotate_other_games_on_display` (2: ufc) | One outlier each; football carries two fixes the other eight lack |
| 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 |
| 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 |
@@ -555,49 +486,23 @@ suspected behaviour that needs a payload or a rig to confirm first.
nine. ufc stays on the shared body, dormant: its favourites are fighters,
which its MMA managers match themselves (a follow-up). Fix ported: only a
game with an id can be a duplicate in the selection methods.
- **7, other-games rotation. Decided 2026-10-09, done:** two fixes ported
from football. The window advances under `_games_lock`: `update()` and
`display()` both advance it, and interleaved, each added a width and a
window of games was never shown. The display path's due-check looks at the
pool `_compose_selection` actually cuts from, including the unfiltered
fallback when nothing else survived. The other eight never rotated that
fallback between fetches (it moved only when `update()` ran, then several
windows at once); guessing it whenever the filtered pool was empty would
recompose an identical list on every frame while a favourite played.
Rotated-in fights in ufc follow its `show_odds` like every other fight (no
separate toggle; the rotation is dormant in ufc today, so no board
changes). `_rankings_loaded` is a seam (default: the abbreviation table is
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. 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).
- **7, other-games rotation.** football advances the rotation window under
`_games_lock` (update() and display() both advance it; interleaved, a
window of games is skipped) and fixes a favourites-only pool that recomposed
the list on every frame. Port both. ufc does not attach odds to fights
rotated in: decide whether rotated fights show odds.
- **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.
- **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).
-2
View File
@@ -41,8 +41,6 @@ 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
src/common/sports_vegas.py
+1 -1
View File
@@ -4,5 +4,5 @@ LEDMatrix Display System
Core source package for the LED Matrix Display project.
"""
__version__ = "3.8.5"
__version__ = "3.8.3"
-33
View File
@@ -53,8 +53,6 @@ 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 |
| [`sports_vegas`](#sports_vegas) | Live Vegas cards: keys, card cache, sticky odds, finished games | Yes (scoreboards) | 3.8.0 |
@@ -370,37 +368,6 @@ 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:
`SportsRotationMixin` (`SportsCore`), the other-games rotation.
`_by_importance` orders the non-favourite pool best matchup first, one game per
team, when `_rankings_loaded()` says a poll loaded (football overrides that to
count its by-id rankings). `_other_games_window` cuts the slice on screen,
advancing by its width every `other_rotation_interval_seconds` under
`_games_lock`, catching up on missed intervals and wrapping.
`_rotate_other_games_on_display` (with `_advance_other_games_if_due`) re-cuts
it from `display()` between fetches, looking at the pool `_compose_selection`
will cut, unfiltered fallback included, and keeps the card on screen when it
survives; `_attach_odds_to_rotated_games` fetches odds for the games it brought
in when `show_odds` is on.
### sports_scroll
[`sports_scroll.py`](sports_scroll.py). `SportsScrollDisplay` and
+14 -156
View File
@@ -66,39 +66,19 @@ entry older than the reader's own ``max_age``, whoever wrote it and whatever
ttl they stored with it. Old keys are passed as ``legacy_keys`` and read
after the canonical one, so an upgrade does not refetch everything at once;
they can go one release after the one that added this.
Chunks whose days are long over are kept in memory between fetches. The
scoreboards re-fetch their whole Recent/Upcoming window (14 days back, 7
ahead) every hour, and since ranges went away that is 22 day requests per
league. Measured on hdpi on 2026-10-02 (NFL, college football, MLB, college
baseball, NHL): the hourly window refresh was ~270 of 321 ESPN requests and
~21 of 24.6MB in the hour, and the 12 days that ended three or more days ago
were 68% of those bytes (6.9 of 10.2MB per copy of the five windows). A
settled chunk is answered from memory for ``SETTLED_CHUNK_TTL_SECONDS``,
stored as zlib-compressed JSON (~13x smaller than the body, and far smaller
than the parsed objects), so the hourly refresh only goes to ESPN for the
days that can still change.
"""
import contextvars
import json
import logging
import math
import re
import threading
import time
import zlib
from collections import OrderedDict
from concurrent.futures import ThreadPoolExecutor
from datetime import date, datetime, timedelta, timezone, tzinfo
from datetime import date, datetime, timedelta, tzinfo
from functools import partial
from typing import Any, Callable, Dict, Iterable, List, Optional, Tuple, cast
try:
import orjson
except ImportError: # optional; the stdlib parser gives the same objects
orjson = None
try:
from src.common.json_body import response_json
except ImportError:
@@ -182,38 +162,12 @@ _range_lock = threading.Lock()
# answered -- to learn what every start learns.
_ranges_rejected_until = time.monotonic() + RANGE_RETRY_SECONDS
# A chunk is "settled" once its last day is this many UTC days back. ESPN
# files games under the US Eastern date, and a late West-coast game ends after
# midnight UTC; three days leaves a full day of margin past both, so nothing
# still being played, finalised or rescheduled is ever served from memory.
SETTLED_AFTER_DAYS = 3
# How long a settled chunk is trusted. A day's finals do not change, but a
# rare correction (or an empty answer during an ESPN outage) should not live
# forever: once a day is plenty, and still skips 23 of every 24 hourly asks.
SETTLED_CHUNK_TTL_SECONDS = 24 * 60 * 60
# Bounds on the settled-chunk memory. A settled day measured 90KB (NHL) to
# 990KB (a college-football Saturday) of JSON and 9-74KB compressed; the five
# windows on hdpi need 60 entries and ~0.55MB. The caps only matter for a
# board fetching whole past seasons.
SETTLED_CACHE_MAX_ENTRIES = 512
SETTLED_CACHE_MAX_BYTES = 8 * 1024 * 1024
_settled_lock = threading.Lock()
# key -> (stored_at monotonic, compressed JSON)
_settled_chunks: "OrderedDict[Any, Tuple[float, bytes]]" = OrderedDict()
_settled_bytes = 0
__all__ = [
"ESPN_MAX_LIMIT",
"ESPN_CHUNK_WORKERS",
"ESPN_MONTH_COVER_MIN_DAYS",
"RANGE_RETRY_SECONDS",
"SETTLED_AFTER_DAYS",
"SETTLED_CHUNK_TTL_SECONDS",
"clamp_espn_limit",
"clear_settled_chunk_cache",
"parse_espn_date_range",
"espn_date_chunks",
"espn_request_chunks",
@@ -306,88 +260,6 @@ def _days_of_month(chunk: str) -> List[str]:
return days
def _utc_today() -> date:
return datetime.now(timezone.utc).date()
def _chunk_last_day(chunk: str) -> Optional[date]:
try:
if len(chunk) == 8:
return date(int(chunk[:4]), int(chunk[4:6]), int(chunk[6:]))
if len(chunk) == 6:
first = date(int(chunk[:4]), int(chunk[4:6]), 1)
return _first_of_next_month(first) - timedelta(days=1)
except ValueError:
pass
return None
def _settled_key(url: str, params: Dict[str, Any], chunk: str) -> Optional[Any]:
"""Memory key for a chunk that can no longer change, else None."""
last_day = _chunk_last_day(chunk)
if last_day is None:
return None
if last_day > _utc_today() - timedelta(days=SETTLED_AFTER_DAYS):
return None
# dates is the chunk itself and limit is always ESPN_MAX_LIMIT here;
# anything else (groups=80 for FBS, a team filter) changes the answer.
rest = tuple(sorted(
(str(k), str(v)) for k, v in params.items() if k not in ("dates", "limit")
))
return (url, rest, chunk)
def _settled_get(key: Any) -> Optional[Dict[str, Any]]:
with _settled_lock:
entry = _settled_chunks.get(key)
if entry is None:
return None
if time.monotonic() - entry[0] > SETTLED_CHUNK_TTL_SECONDS:
_settled_drop(key)
return None
_settled_chunks.move_to_end(key)
blob = entry[1]
# Decompress and parse outside the lock: every hit gets its own objects,
# so a caller mutating its payload cannot reach another caller's.
body = zlib.decompress(blob)
return cast(Dict[str, Any], orjson.loads(body) if orjson else json.loads(body))
def _settled_drop(key: Any) -> None:
"""Remove one entry. Caller holds _settled_lock."""
global _settled_bytes
entry = _settled_chunks.pop(key, None)
if entry is not None:
_settled_bytes -= len(entry[1])
def _settled_put(key: Any, response: Any, payload: Dict[str, Any]) -> None:
global _settled_bytes
body = getattr(response, "content", None)
if not isinstance(body, (bytes, bytearray)):
body = json.dumps(payload).encode("utf-8")
blob = zlib.compress(bytes(body), 6)
if len(blob) > SETTLED_CACHE_MAX_BYTES:
return
with _settled_lock:
_settled_drop(key)
_settled_chunks[key] = (time.monotonic(), blob)
_settled_bytes += len(blob)
while _settled_chunks and (
len(_settled_chunks) > SETTLED_CACHE_MAX_ENTRIES
or _settled_bytes > SETTLED_CACHE_MAX_BYTES
):
_settled_drop(next(iter(_settled_chunks)))
def clear_settled_chunk_cache() -> None:
"""Forget every remembered settled chunk (tests, or a manual refresh)."""
global _settled_bytes
with _settled_lock:
_settled_chunks.clear()
_settled_bytes = 0
def espn_date_chunks(start: date, end: date) -> List[str]:
"""Cover ``[start, end]`` inclusive with ``dates=`` values ESPN accepts.
@@ -519,10 +391,6 @@ def _fetch_one_chunk(
One bad chunk must not sink the rest of the season, so every error is
logged and swallowed here rather than raised to the gather below.
A chunk whose days are settled (see ``SETTLED_AFTER_DAYS``) is answered
from memory when it was fetched in the last day. What is remembered is
the month as ESPN sent it, so each window still trims it to its own days.
A month that comes back at the cap is truncated: it returns ``_CAPPED``,
its payload dropped here before it is ever held beside the others. A
month in ``trims`` loses its events outside the days given there.
@@ -530,34 +398,24 @@ def _fetch_one_chunk(
The request holds one of the process-wide ``_chunk_slots`` while it runs.
"""
try:
settled = _settled_key(url, params, chunk)
payload = _settled_get(settled) if settled is not None else None
if payload is None:
with _chunk_slots:
response = fetch_get(
session,
url,
params=dict(params, dates=chunk, limit=ESPN_MAX_LIMIT),
headers=headers,
timeout=timeout,
**_memo_kwargs(cache_max_age),
)
response.raise_for_status()
payload = response_json(response)
if len(chunk) == 6 and isinstance(payload, dict):
if len(payload.get("events") or []) >= ESPN_MAX_LIMIT:
return _CAPPED
if settled is not None and isinstance(payload, dict):
events = payload.get("events")
# A capped month never gets here; a day at the cap is
# truncated too, and remembering it would only cost memory.
if isinstance(events, list) and len(events) < ESPN_MAX_LIMIT:
_settled_put(settled, response, payload)
with _chunk_slots:
response = fetch_get(
session,
url,
params=dict(params, dates=chunk, limit=ESPN_MAX_LIMIT),
headers=headers,
timeout=timeout,
**_memo_kwargs(cache_max_age),
)
response.raise_for_status()
payload = response_json(response)
except Exception as exc: # noqa: BLE001 - see docstring
if logger:
logger.warning("ESPN chunk %s failed, skipping it: %s", chunk, exc)
return None
if len(chunk) == 6 and isinstance(payload, dict):
if len(payload.get("events") or []) >= ESPN_MAX_LIMIT:
return _CAPPED
trim = (trims or {}).get(chunk)
if trim is not None:
payload = _trim_to_days(payload, *trim)
-411
View File
@@ -1,411 +0,0 @@
"""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)
-342
View File
@@ -1,342 +0,0 @@
"""Which non-favourite games a scoreboard shows, and when the slice moves (sports family 7).
The scoreboards' other-games rotation, reconciled in ledmatrix-plugins
(family 7) from two bodies each into one, and copied here under the existing
names. All of it lives on the plugins' ``SportsCore``, so there is one mixin,
``SportsRotationMixin``:
- ``_by_importance(games, newest_first)``: the non-favourite pool, best
matchup first and one game per team, when a poll has loaded; kickoff order
otherwise. ``SportsCoreSharedMixin._favorites_first`` asks it for both the
filtered and the unfiltered pool.
- ``_other_games_window(others, limit)``: the slice of a pool on screen now.
It advances by its own width every ``other_rotation_interval_seconds``
(catching up on intervals that passed unseen) and wraps.
``SportsCoreSharedMixin._compose_selection`` cuts with it.
- ``_advance_other_games_if_due`` and ``_rotate_other_games_on_display``:
the display path's re-cut between fetches. The plugins' ``display()`` calls
``_rotate_other_games_on_display`` before its dwell check; the card on
screen keeps its place if it survived the cut.
- ``_attach_odds_to_rotated_games``: odds for the games a rotation
brought in, on a daemon thread, when ``show_odds`` is on and there is an
odds manager.
- ``_rankings_loaded()``: the override point (below).
THE RULES
---------
``update()`` (through ``_favorites_first``) and ``display()`` (through
``_rotate_other_games_on_display``) both advance the window, so the advance
holds ``_games_lock`` (the plugins' RLock): interleaved without it, both saw
the interval elapse and each added a width, skipping a window nobody saw.
The display path's due-check looks at the pool ``_compose_selection`` will
actually cut from: the filtered others, or, when nothing survived at all (no
favourite to show either), the unfiltered fallback. Guessing the unfiltered
pool whenever the others were empty recomposed an identical list on every
frame while a favourite was playing; never looking at it left the fallback
moving only when ``update()`` ran.
Rotated-in games get odds by the same rule as the games ``update()`` picks:
``show_odds`` on (decided 2026-10-09, which brought ufc-scoreboard in line).
OVERRIDE POINT
--------------
``_rankings_loaded()`` -- did a poll load? ``_by_importance`` keeps kickoff
order when not. The default is ``_team_rankings_cache`` being non-empty.
football-scoreboard overrides it to count its rankings keyed by ESPN team id
(``_ranked_team_ids``) as well, the table its ``_best_rank`` reads first.
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_rotation.py`` fails if a read is added without being
listed here. Every scoreboard ``SportsCore`` supplies all of them.
- ``other_rotation_interval_seconds`` -- seconds per window; 0 pins it.
- ``_other_window_start`` and ``_other_window_rotated_at`` -- the window's
position and the monotonic time it last moved (0: never cut). Written here.
- ``_games_lock`` -- the re-entrant lock around ``games_list``.
- ``_selection_pools`` -- what ``_favorites_first`` settled (``favorites``,
``others``, ``unfiltered``, ``favorite_limit``, ``other_limit``); read with
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: ``src.common.sports_rankings``).
- ``_team_rankings_cache`` -- read with getattr by the default
``_rankings_loaded``.
- ``games_list``, ``current_game``, ``current_game_index``,
``last_game_switch`` -- the switch-mode state a rotation swaps.
- ``logger`` -- one INFO line per rotation.
- ``show_odds`` and ``odds_manager`` (read with getattr), ``mode_config``
(``odds_update_interval``), ``sport``, ``league`` and ``sport_key`` -- the
rotated-in odds fetch.
The methods read the game dict's ``id``, ``start_time_utc``, ``home_abbr``,
``away_abbr`` and ``odds``; any may be missing.
BASE ORDER
----------
No other mixin defines these methods, so the position in the bases does not
change which body runs; a method on the plugin's own class (football's
``_rankings_loaded``) still wins. The mixin has no ``__init__``; the state it
writes is the host's.
"""
import threading
import time
from datetime import datetime, timezone
from typing import Any, Callable, Dict, List, Optional
class SportsRotationMixin:
"""``SportsCore``'s other-games rotation. See module docstring."""
# The host contract, declared for type checking only.
other_rotation_interval_seconds: int
_other_window_start: int
_other_window_rotated_at: float
_games_lock: Any
_compose_selection: Callable[[], List[Dict]]
_best_rank: Callable[[Dict], int]
games_list: List[Dict]
current_game: Optional[Dict]
current_game_index: int
last_game_switch: float
logger: Any
odds_manager: Any
mode_config: Dict[str, Any]
sport: str
league: str
sport_key: str
def _rankings_loaded(self) -> bool:
"""Did a poll load at all? The ranking reads fail open when not.
The seam ``_by_importance`` asks before ordering by rank: by default
the abbreviation table (``_team_rankings_cache``). football-scoreboard
overrides it to count its table keyed by ESPN team id as well.
"""
return bool(getattr(self, "_team_rankings_cache", None))
def _by_importance(self, games: List[Dict], newest_first: bool = False) -> List[Dict]:
"""Non-favourite games, best matchup first.
The quality filter already declares the poll to be the thing worth
showing -- and then selection ignored the number entirely. #1 against #2
and #25 against an unranked side were interchangeable, and whichever
kicked off sooner took the slot, so the biggest game of the week had no
better chance of being seen than any other.
The rotation still walks the entire pool, so nothing is lost and
coverage is unchanged; it now walks DOWN the ladder instead of along the
clock. The first window after a restart holds the best games available
rather than the earliest ones, which is the case that matters -- a board
is far more often freshly started or freshly updated than three hours
into a lap.
Ties fall back to kickoff order, and a league with no poll keeps the
chronological order it had, because there is nothing to sort on.
One game per team, which is the part rank ordering cannot do without.
The upcoming pool is not a week of fixtures -- for college football it
is the whole season, 947 games on a real board -- so ordering by rank
alone put all twelve of the #1 team's games above the #2 team's first
one, and the board walked one team's season. Measured on ledpi the
moment this shipped: KENT@OSU, ILL@OSU, then OSU@IOWA, MD@OSU. Keeping
only the soonest game per team makes the pool "what each team has
next", which is both what an upcoming board means and inherently
near-term, since a team's next game is by definition the closest one.
"""
if not self._rankings_loaded():
return games
if newest_first:
def key(game):
when = game.get("start_time_utc") or datetime.min.replace(tzinfo=timezone.utc)
return (self._best_rank(game), -when.timestamp())
else:
def key(game):
when = game.get("start_time_utc") or datetime.max.replace(tzinfo=timezone.utc)
return (self._best_rank(game), when.timestamp())
# Soonest-first so "one per team" keeps each team's NEXT game, then
# re-ordered by rank. Doing it the other way round would keep whichever
# of a team's games happened to sort first by rank, which for a game
# between two ranked sides is not necessarily the next one.
soonest_first = sorted(
games,
key=lambda g: (g.get("start_time_utc")
or datetime.max.replace(tzinfo=timezone.utc)).timestamp(),
reverse=newest_first,
)
seen, once_each = set(), [] # type: ignore[var-annotated]
for game in soonest_first:
sides = (game.get("home_abbr"), game.get("away_abbr"))
if any(side in seen for side in sides):
continue
seen.update(s for s in sides if s)
once_each.append(game)
return sorted(once_each, key=key)
def _other_games_window(self, others: List[Dict], limit: int) -> List[Dict]:
"""A rotating slice of the non-favourite games.
The window advances by its own width, so consecutive windows are
disjoint and the board walks the schedule rather than resampling the
same front of it. It wraps, so a short list still cycles.
Advancing is time-based, not per-update. update() runs every 30s; if
the window moved with it the games list would change identity on every
pass, reset the display index, and no card past the first would ever be
reached.
"""
if limit <= 0 or not others:
return []
if len(others) <= limit:
return others[:limit]
interval = self.other_rotation_interval_seconds
# Under the lock: update() advances this window through
# _favorites_first, and display() advances it through
# _rotate_other_games_on_display, so the read-modify-write below has two
# writers. Interleaved, both can see the interval elapsed and each add a
# width, skipping a window of games nobody ever sees. _games_lock is an
# RLock and the display path takes it again straight after, which is
# why this can be the same lock rather than another one to reason about.
with self._games_lock:
if interval > 0:
now = time.monotonic()
if not self._other_window_rotated_at:
self._other_window_rotated_at = now
elapsed = now - self._other_window_rotated_at
if elapsed >= interval:
# Advance by however many intervals actually passed. The
# board is not guaranteed to be running -- or this mode
# displayed -- for every one of them, and stepping once
# would let a plugin that sat idle crawl a step at a time.
steps = int(elapsed // interval)
self._other_window_start += steps * limit
self._other_window_rotated_at = now
start = self._other_window_start % len(others)
window = others[start:start + limit]
if len(window) < limit:
window += others[:limit - len(window)]
return window
def _rotate_other_games_on_display(self) -> bool:
"""Swap in a freshly cut slice when the rotation interval has passed.
Returns True when the list changed, so the caller forces a redraw.
The card currently on screen keeps its place if it survived the cut:
rotating the pool should change what comes NEXT, not interrupt whatever
someone is reading. Only when it is gone does the index reset, and then
the dwell resets with it so the replacement gets a full turn rather than
the tail of its predecessor's.
"""
rebuilt = self._advance_other_games_if_due()
if not rebuilt:
return False
with self._games_lock:
if [g.get("id") for g in rebuilt] == [g.get("id") for g in self.games_list]:
return False
current_id = (self.current_game or {}).get("id")
self.games_list = rebuilt
for index, game in enumerate(rebuilt):
if game.get("id") == current_id:
self.current_game_index = index
self.current_game = game
break
else:
self.current_game_index = 0
self.current_game = rebuilt[0]
self.last_game_switch = time.time()
self.logger.info(
"Rotated the other-games slice to: %s",
", ".join("%s@%s" % (g.get("away_abbr"), g.get("home_abbr"))
for g in rebuilt),
)
self._attach_odds_to_rotated_games(rebuilt)
return True
def _attach_odds_to_rotated_games(self, games: List[Dict]) -> None:
"""Fetch odds for freshly rotated-in games off the display path.
The rotation deliberately does no network work, but odds are only
attached in update(), and for an upcoming list that runs hourly --
far longer than any rotated-in card stays on screen. Every slice cut
between updates therefore rendered without a line even though ESPN
had one, while the favourites, which survive every cut, kept the
odds update() gave them.
One daemon thread per rotation, bounded by the slice size rather
than the pool's: only games actually going on screen are asked
about, and get_odds caches per game, so one re-entering the window
inside its TTL costs a cache lookup rather than a request. The
thread mutates each game dict in place; the renderer re-reads
game["odds"] every frame, so a line appears as soon as its fetch
lands, mid-dwell included. Same as football-scoreboard #343.
"""
# getattr: managers are built partially in places (the plugin tests
# among them) that never set show_odds or an odds manager.
if not getattr(self, "show_odds", False) or not getattr(self, "odds_manager", None):
return
pending = [g for g in games if not g.get("odds")]
if not pending:
return
interval = self.mode_config.get("odds_update_interval", 3600)
def fetch() -> None:
for game in pending:
try:
odds = self.odds_manager.get_odds(
sport=self.sport,
league=self.league,
event_id=game["id"],
update_interval_seconds=interval,
)
if odds:
game["odds"] = odds
except Exception as exc:
self.logger.debug(
"Odds fetch for rotated-in game %s failed: %s",
game.get("id"), exc)
threading.Thread(
target=fetch, daemon=True,
name="%s-rotated-odds" % self.sport_key).start()
def _advance_other_games_if_due(self) -> List[Dict]:
"""Re-cut the non-favourite slice on the display path, or [] if not due.
Costs one list slice and a sort of at most a few games -- no fetch, no
parsing, no network. Returns the new list rather than assigning it,
because the two callers keep different bookkeeping around games_list
and both hold their own lock while they swap it in.
"""
pools = getattr(self, "_selection_pools", None)
if not pools:
return []
interval = self.other_rotation_interval_seconds
limit = max(0, pools["other_limit"])
# Whichever pool _compose_selection will actually slice. It falls back
# to the unfiltered list only when NOTHING survived -- favourites
# included. With a favourite playing and the filters rejecting every
# other game, compose keeps the favourites-only list, so guessing the
# unfiltered pool here made the due-check fire on every display() call
# forever, recomposing an identical list each frame.
others = pools["others"]
favorites_fill = pools["favorites"] and pools["favorite_limit"] > 0
if not others and limit > 0 and not favorites_fill:
others = pools["unfiltered"]
if interval <= 0 or limit <= 0 or len(others) <= limit:
return [] # pinned, favourites-only, or nothing to rotate through
if not self._other_window_rotated_at:
return [] # no window has been cut yet; update() does the first
if time.monotonic() - self._other_window_rotated_at < interval:
return []
return self._compose_selection()
+6 -10
View File
@@ -54,16 +54,12 @@ 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``, ``_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``; 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``.
``_get_layout_offset``, ``_by_importance``, ``_other_games_window``,
``_upcoming_date_and_time_text``, ``_extract_game_details_common``,
``_load_division_team_ids``, ``_get_timezone``, ``_is_favorite_game``,
``_is_ranked_game``, ``_passes_other_filters``. (``_is_game_really_over``,
which ``_detect_stale_games`` below calls, was here too until the plugins
reconciled it; it is now ``src.common.sports_game_over``.)
Of the fourteen shared class constants, thirteen are identical everywhere and
live here. Only ``_SCORE_PROBE_TEXT`` varies -- afl and basketball reach three digits
-18
View File
@@ -343,24 +343,6 @@ def _hermetic_unit_refresh(monkeypatch, tmp_path_factory):
monkeypatch.setattr(unit_refresh, 'SYSTEMD_DIR', str(tmp_path_factory.getbasetemp() / 'no-systemd'))
@pytest.fixture(autouse=True)
def _forget_settled_espn_chunks(monkeypatch):
"""src.common.espn_dates remembers past days process-wide; tests fake
different answers for the same dates, so none may inherit another's.
"Today" is also pinned to 2000-01-01, so no date a test uses counts as
settled unless the test says so (by pinning _utc_today itself). Without
that, a test asking for last month twice passes while that month is
recent and fails once it is three days old: the second ask is answered
from memory."""
from datetime import date
from src.common import espn_dates
monkeypatch.setattr(espn_dates, "_utc_today", lambda: date(2000, 1, 1))
espn_dates.clear_settled_chunk_cache()
yield
espn_dates.clear_settled_chunk_cache()
@pytest.fixture(autouse=True)
def reset_logging():
"""Reset logging configuration before each test."""
-121
View File
@@ -39,14 +39,6 @@ def forget_rejected_ranges(monkeypatch):
monkeypatch.setattr(espn_dates, "_ranges_rejected_until", 0.0)
@pytest.fixture(autouse=True)
def nothing_is_settled_yet(monkeypatch):
"""Pin "today" before every date these tests use, so the settled-chunk
memory stays out of tests that are not about it whatever the real date.
TestSettledChunkCache moves it forward."""
monkeypatch.setattr(espn_dates, "_utc_today", lambda: date(2000, 1, 1))
class FakeResponse:
def __init__(self, status_code=200, payload=None):
self.status_code = status_code
@@ -499,100 +491,6 @@ class TestConcurrency:
assert live["peak"] > 1, "chunks should actually overlap"
class TestSettledChunkCache:
"""Days that ended three or more days ago are fetched once a day, not hourly.
The scoreboards re-fetch a 22-day window every hour; on hdpi (2026-10-02)
the 12 settled days were 68% of that window's bytes.
"""
TODAY = date(2026, 10, 2)
# The scoreboards' default window on that day: 14 back, 7 ahead.
WINDOW = "20260918-20261009"
@pytest.fixture(autouse=True)
def frozen_today(self, monkeypatch):
monkeypatch.setattr(espn_dates, "_utc_today", lambda: self.TODAY)
# These count day requests; whole edge months are covered below.
monkeypatch.setattr(espn_dates, "ESPN_MONTH_COVER_MIN_DAYS", 0)
def _events(self):
days = [(9, d) for d in range(18, 31)] + [(10, d) for d in range(1, 10)]
return {"2026%02d%02d" % (m, d): [{"id": f"{m}-{d}"}] for m, d in days}
def test_the_second_refresh_only_asks_for_unsettled_days(self):
session = FakeSession(self._events())
first = fetch_espn_scoreboard(session, URL, params={"dates": self.WINDOW})
session.calls.clear()
second = fetch_espn_scoreboard(session, URL, params={"dates": self.WINDOW})
asked = sorted(call["dates"] for call in session.calls)
# Sep 29 is the last settled day (today minus three).
assert asked == ["20260930"] + ["202610%02d" % d for d in range(1, 10)]
assert second["events"] == first["events"] # same events, same order
assert len(second["events"]) == 22
def test_a_hit_is_a_fresh_copy(self):
session = FakeSession(self._events())
fetch_espn_date_chunks(session, URL, params={"dates": self.WINDOW})
hit = fetch_espn_date_chunks(session, URL, params={"dates": self.WINDOW})
hit["events"][0]["id"] = "mutated"
again = fetch_espn_date_chunks(session, URL, params={"dates": self.WINDOW})
assert again["events"][0]["id"] == "9-18"
def test_other_params_are_part_of_the_key(self):
session = FakeSession(self._events())
fetch_espn_date_chunks(session, URL, params={"dates": self.WINDOW, "groups": 80})
session.calls.clear()
fetch_espn_date_chunks(session, URL, params={"dates": self.WINDOW})
assert len(session.calls) == 22 # a different question, nothing reused
def test_entries_expire_after_a_day(self, monkeypatch):
clock = [1000.0]
monkeypatch.setattr(espn_dates.time, "monotonic", lambda: clock[0])
session = FakeSession(self._events())
fetch_espn_date_chunks(session, URL, params={"dates": self.WINDOW})
clock[0] += espn_dates.SETTLED_CHUNK_TTL_SECONDS + 1
session.calls.clear()
fetch_espn_date_chunks(session, URL, params={"dates": self.WINDOW})
assert len(session.calls) == 22
def test_failed_chunks_are_not_remembered(self):
session = FakeSession(self._events(), fail_chunks={"20260920"})
fetch_espn_date_chunks(session, URL, params={"dates": self.WINDOW})
session.fail_chunks.clear()
session.calls.clear()
data = fetch_espn_date_chunks(session, URL, params={"dates": self.WINDOW})
assert "20260920" in [call["dates"] for call in session.calls]
assert len(data["events"]) == 22
def test_a_capped_month_is_not_remembered_but_its_days_are(self):
full = [{"id": f"x{i}"} for i in range(ESPN_MAX_LIMIT)]
session = FakeSession({"202608": full, "20260801": [{"id": "d1"}]})
fetch_espn_date_chunks(session, URL, params={"dates": "20260801-20260831"})
session.calls.clear()
data = fetch_espn_date_chunks(session, URL, params={"dates": "20260801-20260831"})
assert [call["dates"] for call in session.calls] == ["202608"]
assert [event["id"] for event in data["events"]] == ["d1"]
def test_memory_is_bounded(self, monkeypatch):
monkeypatch.setattr(espn_dates, "SETTLED_CACHE_MAX_ENTRIES", 5)
session = FakeSession(self._events())
fetch_espn_date_chunks(session, URL, params={"dates": self.WINDOW})
assert len(espn_dates._settled_chunks) == 5
assert espn_dates._settled_bytes == sum(
len(blob) for _, blob in espn_dates._settled_chunks.values())
def test_single_day_requests_are_untouched(self):
# The live path asks for today (or one day) as a plain request; that
# never goes through chunks or the memory.
session = FakeSession(self._events())
for _ in range(2):
fetch_espn_scoreboard(session, URL, params={"dates": "20260918"})
assert len(session.calls) == 2
class TestEdgeMonths:
"""A window's partial edge months are asked whole and trimmed.
@@ -675,25 +573,6 @@ class TestEdgeMonths:
# An event with no readable date is kept, never dropped on a guess.
assert [e["id"] for e in data["events"]] == ["first", "late", "oct1", "last", "undated"]
def test_a_remembered_month_is_trimmed_per_window(self, monkeypatch):
"""The settled-chunk memory holds the month whole; each window trims it."""
monkeypatch.setattr(espn_dates, "_utc_today", lambda: date(2026, 12, 20))
espn_dates.clear_settled_chunk_cache()
november = [
{"id": "early", "date": "2026-11-02T18:00Z"},
{"id": "late", "date": "2026-11-25T18:00Z"},
]
session = FakeSession({"202611": november})
late = fetch_espn_date_chunks(session, URL, params={"dates": "20261120-20261215"})
session.calls.clear()
early = fetch_espn_date_chunks(session, URL, params={"dates": "20261101-20261110"})
assert [e["id"] for e in late["events"]] == ["late"]
assert [e["id"] for e in early["events"]] == ["early"]
assert not any(call["dates"] == "202611" for call in session.calls)
espn_dates.clear_settled_chunk_cache()
def test_eastern_standard_time_is_honoured_after_the_clocks_change(self):
# 2026-11-01 ends daylight saving: Eastern is UTC-5 from then on.
november = [
-379
View File
@@ -1,379 +0,0 @@
"""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)] == []
-132
View File
@@ -1,132 +0,0 @@
"""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
-439
View File
@@ -1,439 +0,0 @@
"""src.common.sports_rotation: behaviour, the lock, the seam, host contract.
The cases follow ledmatrix-plugins' ``scripts/test_other_games_rotation.py``
(the tables the family 7 reconcile was checked against): the expected values
are its columns, for the abbreviation-ranked sports and, where the
``_rankings_loaded`` seam is overridden, for football. The host is the mixin
over core's ``SportsCoreSharedMixin`` (``_favorites_first`` and
``_compose_selection`` call into it) with the family-8 ranking reads copied in,
and ``_favorites_first`` stands in for the plugins' ``update()``.
"""
import ast
import logging
import threading
from datetime import datetime, timedelta, timezone
from pathlib import Path
from unittest.mock import MagicMock
import pytest
from src.common import sports_rotation
from src.common.sports_favorites import SportsFavoritesMixin
from src.common.sports_helpers import SportsHelpersMixin
from src.common.sports_rotation import SportsRotationMixin
from src.common.sports_shared import SportsCoreSharedMixin
LOG = logging.getLogger("test_sports_rotation")
NOW = datetime(2026, 10, 9, 12, tzinfo=timezone.utc)
T0 = 100_000.0 # not 0: the window reads a zero stamp as "never cut"
TEAM = {str(n): chr(64 + n) * 3 for n in range(1, 17)} # 1 AAA ... 16 PPP
ID_OF = {abbr: int(tid) for tid, abbr in TEAM.items()}
def game(gid, home, away, hours, recent=False):
g = {"id": gid, "home_id": home, "away_id": away,
"home_abbr": TEAM[home], "away_abbr": TEAM[away]}
if hours is not None:
g["start_time_utc"] = NOW + timedelta(hours=-hours if recent else hours)
return g
class Host(SportsRotationMixin, SportsCoreSharedMixin, SportsFavoritesMixin,
SportsHelpersMixin):
"""A scoreboard SportsCore as far as the rotation reaches."""
league = sport = "test"
sport_key = "test"
def __init__(self, favorites=(), interval=60, quality="any", rankings=None):
self.logger = LOG
self.favorite_teams = list(favorites)
self.other_rotation_interval_seconds = interval
self.other_games_min_quality = quality
self.other_games_divisions = []
self._team_rankings_cache = dict(rankings or {})
self._ranking_coverage_logged_at = 0.0
self._other_window_start = 0
self._other_window_rotated_at = 0.0
self._games_lock = threading.RLock()
self.games_list, self.current_game, self.current_game_index = [], None, 0
self.last_game_switch = 0.0
self.mode_config = {}
# Family 8, still per plugin: the abbreviation sports' bodies.
def _is_ranked_game(self, game):
rankings = self._team_rankings_cache
return bool(rankings.get(game.get("home_abbr"), 0)
or rankings.get(game.get("away_abbr"), 0))
def _best_rank(self, game):
rankings = self._team_rankings_cache
ranked = [r for r in (rankings.get(game.get("home_abbr"), 0),
rankings.get(game.get("away_abbr"), 0)) if r]
return min(ranked) if ranked else 99
def _passes_other_filters(self, game):
return not (self.other_games_min_quality == "ranked"
and self._team_rankings_cache and not self._is_ranked_game(game))
class IdRankedHost(Host):
"""football's override of the seam, and its by-id first rank read."""
def __init__(self, ranked_ids=None, **kw):
super().__init__(**kw)
self._ranked_team_ids = dict(ranked_ids or {})
def _rankings_loaded(self):
return bool(self._ranked_team_ids or self._team_rankings_cache)
def _best_rank(self, game):
try:
ids = [int(game["home_id"]), int(game["away_id"])]
except (KeyError, TypeError, ValueError):
ids = []
if self._ranked_team_ids and ids:
found = [r for r in (self._ranked_team_ids.get(i, 0) for i in ids) if r]
return min(found) if found else 99
return super()._best_rank(game)
class Clock:
def __init__(self):
self.now = T0
def __call__(self):
return self.now
@pytest.fixture
def clock(monkeypatch):
c = Clock()
monkeypatch.setattr(sports_rotation.time, "monotonic", c)
monkeypatch.setattr(sports_rotation.time, "time", c)
return c
def ids(games):
return ",".join(g["id"] for g in games) or "none"
# ---------------------------------------------------------------------------
# _by_importance and the _rankings_loaded seam
# ---------------------------------------------------------------------------
def importance_slate():
no_abbr = game("n9", "15", "16", 9)
del no_abbr["home_abbr"], no_abbr["away_abbr"]
return [game("i3", "6", "7", 3), game("i1", "1", "3", 1), game("i2", "4", "5", 2),
game("i4", "8", "9", 4), game("i5", "2", "10", 5), game("i6", "11", "12", 6),
game("i7", "4", "11", 7), game("i8", "13", "14", None), no_abbr,
game("i10", "2", "15", 10)]
RANKED = {"HHH": 1, "FFF": 2, "DDD": 3, "BBB": 4}
TIED = {"HHH": 1, "FFF": 2, "DDD": 2, "BBB": 4}
AS_LISTED = "i3,i1,i2,i4,i5,i6,i7,i8,n9,i10"
@pytest.mark.parametrize("rankings, newest, expected", [
({}, False, AS_LISTED),
({}, True, AS_LISTED),
(RANKED, False, "i4,i3,i2,i5,i1,i6,n9,i8"),
(RANKED, True, "i4,i3,i7,i10,n9,i1,i8"),
(TIED, False, "i4,i2,i3,i5,i1,i6,n9,i8"), # a tie keeps kickoff order
(TIED, True, "i4,i7,i3,i10,n9,i1,i8"),
])
def test_by_importance(rankings, newest, expected):
assert ids(Host(rankings=rankings)._by_importance(importance_slate(), newest)) == expected
BY_ID = {ID_OF[a]: r for a, r in RANKED.items()}
@pytest.mark.parametrize("abbr, by_id, newest, default, overridden", [
({}, BY_ID, False, AS_LISTED, "i4,i3,i2,i5,i1,i6,n9,i8"),
({}, BY_ID, True, AS_LISTED, "i4,i3,i7,i10,n9,i1,i8"),
({"HHH": 1, "FFF": 2}, {ID_OF["CCC"]: 1}, False,
"i4,i3,i1,i2,i5,i6,n9,i8", "i1,i2,i3,i4,i5,i6,n9,i8"),
({"HHH": 1, "FFF": 2}, {ID_OF["CCC"]: 1}, True,
"i4,i3,i10,n9,i7,i1,i8", "i1,i10,n9,i7,i4,i3,i8"),
])
def test_the_rankings_loaded_seam(abbr, by_id, newest, default, overridden):
"""The default asks the abbreviation table; football counts its id table too."""
plain = Host(rankings=abbr)
plain._ranked_team_ids = by_id # read by nothing in the default
assert ids(plain._by_importance(importance_slate(), newest)) == default
football = IdRankedHost(ranked_ids=by_id, rankings=abbr)
assert ids(football._by_importance(importance_slate(), newest)) == overridden
def test_rankings_loaded_default():
assert Host()._rankings_loaded() is False
assert Host(rankings={"AAA": 1})._rankings_loaded() is True
# ---------------------------------------------------------------------------
# _other_games_window
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("size, limit, interval, expected", [
(0, 3, 60, "none / none / none / none / none / none"),
(7, 0, 60, "none / none / none / none / none / none"),
(1, 3, 60, "o1 / o1 / o1 / o1 / o1 / o1"),
(2, 2, 60, "o1,o2 / o1,o2 / o1,o2 / o1,o2 / o1,o2 / o1,o2"),
(7, 3, 0, "o1,o2,o3 / o1,o2,o3 / o1,o2,o3 / o1,o2,o3 / o1,o2,o3 / o1,o2,o3"),
(7, 2, 60, "o1,o2 / o1,o2 / o3,o4 / o3,o4 / o5,o6 / o4,o5"),
(7, 3, 60, "o1,o2,o3 / o1,o2,o3 / o4,o5,o6 / o4,o5,o6 / o7,o1,o2 / o2,o3,o4"),
])
def test_the_window_advances_catches_up_and_wraps(clock, size, limit, interval, expected):
"""At 0, 30, 60, 119, 120 and 300 s: 300 is three intervals after 120."""
host = Host(interval=interval)
pool = [{"id": f"o{n}"} for n in range(1, size + 1)]
seen = []
for t in (0, 30, 60, 119, 120, 300):
clock.now = T0 + t
seen.append(ids(host._other_games_window(pool, limit)))
assert " / ".join(seen) == expected
# ---------------------------------------------------------------------------
# The display path: _advance_other_games_if_due, _rotate_other_games_on_display
# ---------------------------------------------------------------------------
UPDATE_SLATE = (("u1", "1", "2", 1), ("u2", "3", "4", 2), ("u3", "5", "6", 3),
("u4", "7", "8", 4), ("u5", "2", "3", 5), ("u6", "4", "5", 6),
("u7", "6", "7", 7), ("u8", "1", "8", 8))
def updated(clock, favorites, favorite_limit=2, other_limit=2, recent=False, **kw):
"""What the plugins' update() leaves: _favorites_first's pick, its last card on screen."""
host = Host(favorites=favorites, **kw)
clock.now = T0
games = [game(*row, recent=recent) for row in UPDATE_SLATE]
host.games_list = host._favorites_first(games, favorite_limit, other_limit,
newest_first=recent)
if host.games_list:
host.current_game_index = len(host.games_list) - 1
host.current_game = host.games_list[-1]
return host
def ticks(clock, host):
"""games_list@on screen after update() and each tick (^: redraw), and #composes."""
calls = []
compose = host._compose_selection
def counted():
calls.append(1)
return compose()
host._compose_selection = counted
def shown():
return f"{ids(host.games_list)}@{(host.current_game or {}).get('id')}"
out = [shown()]
for t in (30, 60, 90, 125, 305):
clock.now = T0 + t
redraw = host._rotate_other_games_on_display()
out.append(shown() + ("^" if redraw else ""))
return f"{' > '.join(out)} #{len(calls)}"
ROTATION = {
"AAA, 2 others, 60s": (
{"favorites": ["AAA"]},
"u1,u2,u3,u8@u8 > u1,u2,u3,u8@u8 > u1,u4,u5,u8@u8^ > u1,u4,u5,u8@u8 > "
"u1,u6,u7,u8@u8^ > u1,u6,u7,u8@u8 #3"),
"pinned (0s)": (
{"favorites": ["AAA"], "interval": 0},
"u1,u2,u3,u8@u8 > u1,u2,u3,u8@u8 > u1,u2,u3,u8@u8 > u1,u2,u3,u8@u8 > "
"u1,u2,u3,u8@u8 > u1,u2,u3,u8@u8 #0"),
"0 others": (
{"favorites": ["AAA"], "other_limit": 0},
"u1,u8@u8 > u1,u8@u8 > u1,u8@u8 > u1,u8@u8 > u1,u8@u8 > u1,u8@u8 #0"),
"no favourite slots, 3 others": (
{"favorites": [], "favorite_limit": 0, "other_limit": 3},
"u1,u2,u3@u3 > u1,u2,u3@u3 > u4,u5,u6@u4^ > u4,u5,u6@u4 > u1,u7,u8@u1^ > "
"u1,u2,u8@u1^ #3"),
"ranked, poll on the slate": (
{"favorites": ["AAA"], "quality": "ranked", "rankings": RANKED},
"u1,u3,u4,u8@u8 > u1,u3,u4,u8@u8 > u1,u2,u4,u8@u8^ > u1,u2,u4,u8@u8 > "
"u1,u2,u3,u8@u8^ > u1,u2,u3,u8@u8 #3"),
# The favourite still fills the board: no recompose on any frame.
"ranked, poll matches nothing, favourite playing": (
{"favorites": ["AAA"], "quality": "ranked", "rankings": {"ZZZ": 1}},
"u1,u8@u8 > u1,u8@u8 > u1,u8@u8 > u1,u8@u8 > u1,u8@u8 > u1,u8@u8 #0"),
# Nothing survived, so compose cuts the unfiltered fallback: it rotates too.
"ranked, poll matches nothing, favourite not playing": (
{"favorites": ["ZZZ"], "quality": "ranked", "rankings": {"ZZZ": 1}},
"u1,u2@u2 > u1,u2@u2 > u3,u4@u3^ > u3,u4@u3 > u1,u2@u1^ > u3,u4@u3^ #3"),
}
@pytest.mark.parametrize("recent", [False, True], ids=["upcoming", "recent"])
@pytest.mark.parametrize("scenario", list(ROTATION))
def test_the_display_path_rotation(clock, scenario, recent):
settings, expected = ROTATION[scenario]
assert ticks(clock, updated(clock, recent=recent, **settings)) == expected
def test_no_pools_no_rotation(clock):
"""A manager whose update() never built the pools (ufc's MMA ones) never rotates."""
host = Host()
clock.now = T0 + 3600
assert host._advance_other_games_if_due() == []
assert host._rotate_other_games_on_display() is False
# ---------------------------------------------------------------------------
# update() and display() both advancing: the lock
# ---------------------------------------------------------------------------
def _racing(host):
"""Run display()'s rotation on a second thread inside update()'s advance.
The first time this thread reads ``_other_window_start`` -- the advance's
read-modify-write, after the interval was found elapsed -- the display path
starts; it either finishes first (no lock) or waits on ``_games_lock``.
"""
owner, threads = threading.get_ident(), []
class Interleaved(type(host)):
@property
def _other_window_start(self):
if threading.get_ident() == owner and not threads:
display = threading.Thread(target=self._rotate_other_games_on_display,
daemon=True)
threads.append(display)
display.start()
display.join(2.0)
return self.__dict__["_other_window_start"]
@_other_window_start.setter
def _other_window_start(self, value):
self.__dict__["_other_window_start"] = value
host.__class__ = Interleaved
return threads
class _NoLock:
def __enter__(self):
return self
def __exit__(self, *exc):
return False
def _update_again(host):
host.games_list = host._favorites_first(
[game(*row) for row in UPDATE_SLATE], 2, 2)
@pytest.mark.parametrize("lock, windows, shown", [
(True, 1, "u1,u4,u5,u8"),
(False, 2, "u1,u6,u7,u8"), # what the eight plugins did before
])
def test_display_inside_updates_advance(clock, lock, windows, shown):
host = updated(clock, ["AAA"])
if not lock:
host._games_lock = _NoLock()
clock.now = T0 + 61
threads = _racing(host)
_update_again(host)
for t in threads:
t.join(10)
assert threads, "update() never advanced the window"
assert (host.__dict__["_other_window_start"] // 2, ids(host.games_list)) == (windows, shown)
@pytest.mark.parametrize("display_first", [False, True])
def test_update_and_display_in_sequence(clock, display_first):
host = updated(clock, ["AAA"])
clock.now = T0 + 61
if display_first:
host._rotate_other_games_on_display()
_update_again(host)
if not display_first:
host._rotate_other_games_on_display()
assert (host._other_window_start // 2, ids(host.games_list)) == (1, "u1,u4,u5,u8")
# ---------------------------------------------------------------------------
# Odds for rotated-in games
# ---------------------------------------------------------------------------
def _rotate_with_odds(clock, show_odds=True, manager=True, preset=None):
host = updated(clock, ["AAA"])
host.show_odds = show_odds
host.odds_manager = MagicMock() if manager else None
if manager:
host.odds_manager.get_odds.side_effect = lambda **k: {"details": k["event_id"]}
for g in host._selection_pools["favorites"] + host._selection_pools["others"]:
if g["id"] in (preset or ()):
g["odds"] = {"details": "already"}
clock.now = T0 + 61
assert host._rotate_other_games_on_display()
for t in threading.enumerate():
if t.name == "test-rotated-odds":
t.join(10)
return host
def test_rotated_in_games_get_odds(clock):
host = _rotate_with_odds(clock, preset={"u1"})
assert ids(host.games_list) == "u1,u4,u5,u8"
assert {g["id"]: g["odds"]["details"] for g in host.games_list} == {
"u1": "already", "u4": "u4", "u5": "u5", "u8": "u8"}
asked = [c.kwargs["event_id"] for c in host.odds_manager.get_odds.call_args_list]
assert asked == ["u4", "u5", "u8"]
@pytest.mark.parametrize("show_odds, manager", [(False, True), (True, False)])
def test_no_odds_without_show_odds_or_a_manager(clock, show_odds, manager):
host = _rotate_with_odds(clock, show_odds=show_odds, manager=manager)
assert not any(g.get("odds") for g in host.games_list)
if manager:
host.odds_manager.get_odds.assert_not_called()
# ---------------------------------------------------------------------------
# Carrier and host contract
# ---------------------------------------------------------------------------
METHODS = ["_advance_other_games_if_due", "_attach_odds_to_rotated_games",
"_by_importance", "_other_games_window", "_rankings_loaded",
"_rotate_other_games_on_display"]
def _self_reads():
tree = ast.parse(Path(sports_rotation.__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 six, so adopting it adds none."""
assert sorted(n for n in vars(SportsRotationMixin)
if not n.startswith("__")) == METHODS
def test_every_host_read_is_documented(self):
undocumented = sorted(n for n in _self_reads()
if f"``{n}``" not in sports_rotation.__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, sports_display_rules.SportsGameRulesMixin]
assert [m for m in METHODS if any(m in vars(o) for o in others)] == []
def test_the_host_class_wins(self):
"""football keeps its _rankings_loaded; the plugin's own method runs."""
assert IdRankedHost(ranked_ids={1: 1})._rankings_loaded() is True
assert Host()._rankings_loaded() is False
-134
View File
@@ -1,134 +0,0 @@
"""sports_rotation still matches every plugin copy, and only football overrides the seam.
``src.common.sports_rotation`` was copied from the scoreboards once family 7
had made each method one body in all nine ``SportsCore`` classes:
``_by_importance``, ``_other_games_window``, ``_advance_other_games_if_due``,
``_rotate_other_games_on_display``, ``_attach_odds_to_rotated_games`` and the
default ``_rankings_loaded``. 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. A copy that is gone counts as adopted when the plugin's
``sports.py`` names the module. football's ``_rankings_loaded`` is the
decided override (it counts its rankings keyed by team id), checked as the only
one; it stays in the plugin after adoption. 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_rotation
REPO = Path(__file__).resolve().parents[1]
SPORTS = ("afl", "baseball", "basketball", "football", "hockey", "lacrosse",
"nrl", "soccer", "ufc")
CARRIER, MIXIN = "SportsCore", "SportsRotationMixin"
METHODS = ("_by_importance", "_other_games_window", "_advance_other_games_if_due",
"_rotate_other_games_on_display", "_attach_odds_to_rotated_games",
"_rankings_loaded")
SEAM = "_rankings_loaded"
#: The owner's decision (docs/SPORTS_UNIFICATION.md, family 7): the sports
#: whose own _rankings_loaded replaces the default.
OVERRIDES_RANKINGS_LOADED = {"football"}
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 _fingerprint(node):
return (DRIFT._digest(node, DRIFT._Canonical()),
tuple(ast.unparse(d) for d in node.decorator_list))
def _ours(name):
tree = ast.parse(Path(sports_rotation.__file__).read_text(encoding="utf-8"))
return _method(_class(tree, MIXIN), name)
def _plugin_tree(root, sport):
source = (root / f"{sport}-scoreboard" / "sports.py").read_text(encoding="utf-8")
return source, ast.parse(source)
CASES = [(sport, name) for sport in SPORTS for name in METHODS
if not (name == SEAM and sport in OVERRIDES_RANKINGS_LOADED)]
@pytest.mark.parametrize("sport, name", CASES)
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_rotation.__name__ in source, (
f"{sport}: no {name} on {CARRIER} and no {sports_rotation.__name__} import")
else:
assert _fingerprint(copy) == _fingerprint(_ours(name)), (
f"{CARRIER}.{name} in {sport} differs from sports_rotation. "
f"Port the change to both, or stop treating it as shared.")
@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_only_the_decided_sports_override_the_seam():
root = _plugins_root()
ours = _fingerprint(_ours(SEAM))
overriding = set()
for sport in SPORTS:
copy = _method(_class(_plugin_tree(root, sport)[1], CARRIER), SEAM)
if copy is not None and _fingerprint(copy) != ours:
overriding.add(sport)
assert overriding == OVERRIDES_RANKINGS_LOADED
def test_the_drift_report_still_calls_them_identical():
root = _plugins_root()
families = DRIFT.build(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:
if name == SEAM:
continue # the default and football's override: two by design
row = rows.get(("sports.py", name))
assert row is None or row["worst_class_variants"] == 1, name