mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-13 02:26:37 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
185d5f0aa0 | ||
|
|
9234332c5a | ||
|
|
4b9fc3c755 |
@@ -19,76 +19,6 @@ accepts both, but the store flags the old spelling as deprecated
|
||||
|
||||
## Unreleased
|
||||
|
||||
### New modules
|
||||
|
||||
- `src/common/sports_live_fetch.py` -- sports consolidation family 9, once the
|
||||
plugins made `_fetch_todays_games` (seven bodies) and `_fetch_odds` (three)
|
||||
one each: `SportsLiveFetchMixin` (`SportsCore`), with the odds worker
|
||||
`_drain_odds_queue` beside them. The live poll asks ESPN on every poll
|
||||
(a copy another reader fetched within the last second is reused) and
|
||||
leaves the answer under the shared `espn_scoreboard_cache_key` for its
|
||||
dates; college basketball is asked by date like every other league; an
|
||||
answer whose `events` is not a list counts as no games. Odds never block
|
||||
`update()`: one daemon worker per manager answers a queue and attaches each
|
||||
line in place under `_games_lock`, one batch in flight at a time. A plugin
|
||||
may inherit the mixin once it floors on the release that ships it, and
|
||||
deletes its copies then.
|
||||
|
||||
### Fixes
|
||||
|
||||
- `get_espn_scoreboard()` no longer caches an answer whose `events` is not a
|
||||
list (ESPN has sent `"events": null`): every reader of the shared scoreboard
|
||||
key -- odds-ticker, the other scoreboards -- inherited it until it aged out.
|
||||
The answer is still returned to the caller as it came.
|
||||
|
||||
- `SportsRankingsMixin` (#789's deferred review points, now that it is the
|
||||
only copy): a poll entry whose `current` cannot be converted to an
|
||||
integer (`None`, a non-numeric string) is skipped instead of throwing
|
||||
away the whole poll -- which left
|
||||
every team unranked and re-fetched the standings on every update -- and
|
||||
`_ranked_positions()` decides id-or-abbreviation per side, so one side
|
||||
without an id no longer drops the other side's id and lets a shared
|
||||
abbreviation rank the wrong school. Well-formed ESPN data ranks as before.
|
||||
|
||||
## 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
|
||||
@@ -120,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
|
||||
|
||||
+37
-182
@@ -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,109 +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 (done: core 3.8.5, adopted)
|
||||
|
||||
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.
|
||||
|
||||
### Family 9: live fetch and odds (reconciled; promoted, awaiting release)
|
||||
|
||||
ledmatrix-plugins `scripts/test_live_fetch_odds.py` (#649) pinned 44 rows
|
||||
across the nine plugins first, every ESPN request answered offline the way
|
||||
ESPN answered on 2026-10-12 (a single date 200, a range 400): the requests,
|
||||
cache keys and TTLs of `_fetch_todays_games` per league and Eastern time,
|
||||
the live cache over time, empty, null and failed answers, `_fetch_odds` per
|
||||
game kind and odds endpoint on a virtual clock (which thread asks, how long
|
||||
the call waits, when the line lands), the real `update()` of every screen
|
||||
with a 3 s odds endpoint, and the league overrides (MiLB, HockeyTech, ufc's
|
||||
`MMALive`, which stay per-sport). Measuring found seven bodies of
|
||||
`_fetch_todays_games`, not five: the fetch service's shared key had split
|
||||
the old groups. The reconcile (ledmatrix-plugins `claude/family9-reconcile`)
|
||||
made `_fetch_todays_games` and `_fetch_odds` one body each on `SportsCore`,
|
||||
with the odds worker `_drain_odds_queue` beside them, and added a test that
|
||||
runs `update()` and `display()` on two threads against a slow endpoint.
|
||||
Every changed cell is one of the decisions below; the harness renders were
|
||||
byte-identical once the four fixtures that seeded the old live key seed the
|
||||
shared one. `src/common/sports_live_fetch.py` holds the bodies in one mixin,
|
||||
`SportsLiveFetchMixin`; `test/test_sports_live_fetch_parity.py` compares them
|
||||
with the plugin copies.
|
||||
|
||||
Measured `update()` time with a 3 s odds endpoint and four games, before and
|
||||
after: Upcoming 6 s (afl, baseball, football, nrl, soccer, ufc), 12 s
|
||||
(hockey, lacrosse), 0 s (basketball) to 0 s everywhere; Recent 6 s / 12 s to
|
||||
0 s; Live's first pass 8 s / 12 s and next poll 4 s / 6 s to 0 s. The line
|
||||
now lands when ESPN answers (3 s here) instead of at the next `update()`
|
||||
(15 s for a live game, an hour for an upcoming or recent one) in the six.
|
||||
|
||||
Left for later: ufc's MMA Recent manager never asks for odds and its Upcoming
|
||||
manager asks for every fight in the window before trimming to the headliners
|
||||
(both in `mma.py`'s `update()` overrides, family 13); basketball's NCAA
|
||||
Recent/Upcoming fallback still caches the undated scoreboard under
|
||||
`<sport_key>_scoreboard_current` with `limit=1000`, which ESPN truncates (held
|
||||
with the data fetching); `_attach_odds_to_rotated_games` still starts its own
|
||||
thread per rotation and can route through `_fetch_odds` once every plugin
|
||||
inherits this mixin; and ESPN lists only some college basketball games
|
||||
without `groups=50` (7 of 21 on 2026-03-14), dated or not.
|
||||
|
||||
### Why the method changes
|
||||
|
||||
Byte-identical promotion has nearly run dry. Measured on ledmatrix-plugins
|
||||
@@ -537,12 +429,12 @@ 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`; done: core 3.8.5, adopted (ledmatrix-plugins #648). See [Family 8](#family-8-rankings-done-core-385-adopted) |
|
||||
| 9 | Live fetch and odds | `_fetch_todays_games` (7 when measured), `_fetch_odds` (3); `_attach_odds_to_rotated_games` went with 7 | The prerequisite for one shared ESPN poller across plugins. Reconciled to one body each and promoted as `sports_live_fetch` (next release). See [Family 9](#family-9-live-fetch-and-odds-reconciled-promoted-awaiting-release) |
|
||||
| 4 | Identical sweep | `manager.py`: `_dispatch_switch_refresh`, `_favorite_team_is_live`, `get_vegas_priority_weight`, `_game_involves`, `_favorite_scan_targets`, `_favorite_scan_games`, `_get_total_games_for_manager` (all nine, 1); the live-scroll helpers `_preserving_scroll_position`, `_refresh_live_scroll_managers`, `_live_scroll_managers`, `_note_live_scroll_built`, `_live_scroll_needs_rebuild`, `_live_scroll_fields` (eight, 1). `sports.py`: `_card_option`, `_filtered_or_all`, `_effective_live_duration`, `_recent_date_text` (eight, 1). 58 identical families in all | Nothing to decide; brings `manager.py` into core as a `SportsPluginHostMixin`. `_resolve_font_path` (identical in nine `sports.py` and eight renderers) becomes `sports_font_path.resolve_font_path`, not `font_layout.resolve_asset_path`, which skips the cwd. 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 |
|
||||
| 12 | Scroll/Vegas card | `game_renderer.py`: `render_game_card` (6), `_load_and_resize_logo` (8), `_load_custom_font`, `preload_logos`, `__init__` (7 each), `_draw_records_or_rankings` (6), `_load_fonts`, `_draw_dynamic_odds`, `_draw_live_game_status`, `_draw_recent_game_status`, `_get_team_display_text` (5 each), `_draw_text_with_outline` (2: football) | Same decisions as 11; sequence after the scroll-performance work on pre-rendered strips lands |
|
||||
@@ -594,69 +486,32 @@ 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).
|
||||
- **9, live fetch and odds. Decided 2026-10-12, reconciled.** (a) Live
|
||||
scores are always fresh: every poll asks ESPN and leaves the answer under
|
||||
the shared `espn_scoreboard_cache_key` for its dates (`max_age=1`: only a
|
||||
copy another reader fetched within the last second, the managers of one
|
||||
display tick, is reused). afl, basketball, nrl and soccer had held it 30 s
|
||||
under `<sport_key>_scoreboard_current`, so with the 15 s default poll every
|
||||
other poll repeated the last one; lacrosse and ufc now share their copy.
|
||||
(b) College basketball is asked by date like every other league: ESPN
|
||||
answered single dates for both college leagues on 2026-10-12 (the 404 no
|
||||
longer happens), so the lookback now reaches last night's late games there
|
||||
too. `groups=50` is a separate question (left above). (c) Odds never block
|
||||
`update()`: one daemon worker per manager answers a queue and attaches each
|
||||
line in place under `_games_lock`; one batch in flight per manager, games
|
||||
queued meanwhile join it. A card can draw once without odds and gain them;
|
||||
a pre-rendered scroll strip gains them when it is next rebuilt. (d) The
|
||||
missing-odds-manager guard (nrl's, already in `_attach_odds_to_rotated_games`
|
||||
since family 7) is in `_fetch_odds` too, logged at debug, not ERROR. Fix
|
||||
ported: an answer with `"events": null` is no games instead of a
|
||||
`TypeError` out of the fetch (all nine), and `get_espn_scoreboard` no longer
|
||||
caches it under the shared key.
|
||||
- **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).
|
||||
(b) basketball fetches college games with no `dates` parameter, citing a
|
||||
404 (*verify* now that `espn_dates` handles ranges). (c) odds are fetched
|
||||
three ways: blocking (hockey, lacrosse), a thread waited on for 1.5–2 s
|
||||
(six plugins), or fire-and-forget for upcoming games (basketball). This
|
||||
sets how long `update()` takes and when an odds line appears. (d) nrl
|
||||
guards on a missing odds manager; port it.
|
||||
- **10, view model.** Per key, whether every sport emits it. Additive only:
|
||||
no key is renamed or removed.
|
||||
- **11 and 12, the scorebug and the card.** The pinned divergences in
|
||||
|
||||
@@ -39,11 +39,8 @@ src/common/sports_favorites.py
|
||||
src/common/sports_fetch.py
|
||||
src/common/sports_font_path.py
|
||||
src/common/sports_game_over.py
|
||||
src/common/sports_live_fetch.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
@@ -4,5 +4,5 @@ LEDMatrix Display System
|
||||
Core source package for the LED Matrix Display project.
|
||||
"""
|
||||
|
||||
__version__ = "3.8.5"
|
||||
__version__ = "3.8.3"
|
||||
|
||||
|
||||
@@ -51,11 +51,8 @@ Rules for the package:
|
||||
| [`sports_game_over`](#sports_game_over) | Whether a game ESPN still lists as live has ended | Yes (scoreboards) | 3.8.1 |
|
||||
| [`sports_game_renderer`](#sports_game_renderer) | Scoreboard scroll/Vegas card geometry | Yes (scoreboards) | 3.3.0 |
|
||||
| [`sports_helpers`](#sports_helpers) | Small helpers every scoreboard `sports.py` copies | Yes (scoreboards) | 3.5.0 |
|
||||
| [`sports_live_fetch`](#sports_live_fetch) | The live poll's scoreboard request, and odds off the update path | Yes (scoreboards) | next release |
|
||||
| [`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 |
|
||||
@@ -371,50 +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_live_fetch
|
||||
|
||||
[`sports_live_fetch.py`](sports_live_fetch.py). Sports family 9:
|
||||
`SportsLiveFetchMixin` (`SportsCore`). `_fetch_todays_games` asks ESPN for
|
||||
today's scoreboard (and yesterday's while `_needs_previous_day()` says it can
|
||||
still hold a live game) on every live poll through `get_espn_scoreboard`, and
|
||||
leaves it under the shared `espn_scoreboard_cache_key` for odds-ticker and the
|
||||
other scoreboards; an answer without an `events` list is no games.
|
||||
`_fetch_odds` queues a game and returns at once; one daemon worker per manager
|
||||
(`_drain_odds_queue`) asks the odds manager for each queued game and attaches
|
||||
the line in place under `_games_lock`, so a card can draw once without odds and
|
||||
gain them. Games queued while the worker is busy join its batch.
|
||||
|
||||
### 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
|
||||
|
||||
+16
-161
@@ -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)
|
||||
@@ -947,8 +805,7 @@ def get_espn_scoreboard(
|
||||
split as ESPN needs -- caches the result under the canonical key and
|
||||
returns it. ``max_age=0`` always fetches (and still caches, for other
|
||||
readers). Errors raise exactly as :func:`fetch_espn_scoreboard` does,
|
||||
and nothing is cached then; nor is an answer whose ``events`` is not a
|
||||
list (ESPN has sent ``"events": null``), which is returned as it came. ``session=None`` uses the fetch service's
|
||||
and nothing is cached then. ``session=None`` uses the fetch service's
|
||||
pooled session for the ESPN host.
|
||||
"""
|
||||
key = espn_scoreboard_cache_key(sport, league, dates)
|
||||
@@ -970,7 +827,5 @@ def get_espn_scoreboard(
|
||||
# cache read above would have accepted.
|
||||
cache_max_age=None if max_age is None else max(0.0, float(max_age)),
|
||||
)
|
||||
# Every reader of the shared key would inherit a malformed answer.
|
||||
if isinstance(data, dict) and isinstance(data.get("events"), list):
|
||||
store_espn_scoreboard_cache(cache_manager, key, data)
|
||||
store_espn_scoreboard_cache(cache_manager, key, data)
|
||||
return data
|
||||
|
||||
@@ -1,200 +0,0 @@
|
||||
"""The live poll's scoreboard request and the odds requests (sports family 9).
|
||||
|
||||
The scoreboards' live fetch and odds code, reconciled in ledmatrix-plugins
|
||||
(family 9) from seven and three bodies into one each, and copied here under
|
||||
the existing names. All of it lives on the plugins' ``SportsCore``, so there
|
||||
is one mixin, ``SportsLiveFetchMixin``:
|
||||
|
||||
- ``_fetch_todays_games()``: today's scoreboard for the live poll, with
|
||||
yesterday's while it can still hold a live game (``_needs_previous_day``),
|
||||
as ``{"events": [...]}``; None when ESPN cannot be reached.
|
||||
- ``_fetch_odds(game)``: queue one game for an odds request; returns at once.
|
||||
- ``_drain_odds_queue()``: the odds worker that answers the queue.
|
||||
|
||||
THE RULES (decided 2026-10-12)
|
||||
------------------------------
|
||||
Live scores are always fresh. Every poll asks ESPN (``get_espn_scoreboard``
|
||||
with ``max_age=1``: only a copy another reader fetched within the last second,
|
||||
the managers of one display tick, is reused) and leaves the answer under the
|
||||
shared ``espn_scoreboard_cache_key`` for its dates, so odds-ticker and every
|
||||
other reader of the same day share it. The plugins' old 30 s cache under
|
||||
``<sport_key>_scoreboard_current`` is gone. College basketball is asked by
|
||||
date like every other league; ESPN answered single dates for it on 2026-10-12
|
||||
(the old no-dates request cited a 404 that no longer happens). An answer whose
|
||||
``events`` is not a list counts as no games.
|
||||
|
||||
Odds never block ``update()``. ``_fetch_odds`` puts the game on the manager's
|
||||
queue and starts one daemon worker if none is running; the worker asks the
|
||||
odds manager for each queued game in turn and attaches the line to every
|
||||
dict queued for that id, in place, under ``_games_lock`` (display() reads the
|
||||
games under it), so a card can draw once without odds and gain them when
|
||||
ESPN answers. One batch in flight per manager: a game queued while the worker
|
||||
is busy joins its batch, so a slow endpoint never stacks threads or requests.
|
||||
The odds manager bounds each request (core's ``BaseOddsManager``: 5 s, then a
|
||||
60 s hold-off). A live game's line is cached ``live_odds_update_interval``
|
||||
(60 s), any other ``odds_update_interval`` (3600 s). Without ``show_odds``, a
|
||||
game id or an odds manager nothing is asked (the last logged at debug).
|
||||
|
||||
Before the reconcile, six plugins waited on a worker for 1.5 s (2 s for a
|
||||
live game) per game and hockey and lacrosse asked on the calling thread: with
|
||||
a 3 s endpoint, ``update()`` of four upcoming games took 6 s and 12 s, and in
|
||||
the six the line appeared only at the next ``update()`` (an hour later for an
|
||||
upcoming game).
|
||||
|
||||
A new module rather than more methods on ``sports_fetch``, 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_live_fetch.py`` fails if a read is added without
|
||||
being listed here. Every scoreboard ``SportsCore`` supplies all of them.
|
||||
|
||||
- ``session``, ``headers``, ``cache_manager``, ``logger``, ``sport``,
|
||||
``league`` -- the scoreboard request.
|
||||
- ``_needs_previous_day`` -- from ``SportsFetchMixin``.
|
||||
- ``show_odds``, ``mode_config`` (the two odds intervals), ``sport_key`` (the
|
||||
worker's thread name), ``_games_lock`` (a lock display() also holds).
|
||||
- ``odds_manager``, read with ``getattr`` -- ``get_odds(sport=, league=,
|
||||
event_id=, update_interval_seconds=)``.
|
||||
- ``_odds_pending`` and ``_odds_worker_busy`` -- the queue and whether a
|
||||
worker is running; created here on first use, under ``_games_lock``.
|
||||
|
||||
The game dict's ``id`` and ``is_live`` are read; ``odds`` is written.
|
||||
|
||||
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__``.
|
||||
"""
|
||||
|
||||
import threading
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any, Callable, Dict, List, Optional, Tuple
|
||||
|
||||
import pytz
|
||||
import requests
|
||||
|
||||
from src.common.espn_dates import get_espn_scoreboard
|
||||
|
||||
|
||||
class SportsLiveFetchMixin:
|
||||
"""``SportsCore``'s live fetch and odds. See module docstring."""
|
||||
|
||||
# The host contract, declared for type checking only.
|
||||
session: Any
|
||||
headers: Dict[str, str]
|
||||
cache_manager: Any
|
||||
logger: Any
|
||||
sport: str
|
||||
league: str
|
||||
sport_key: str
|
||||
show_odds: bool
|
||||
mode_config: Dict[str, Any]
|
||||
_games_lock: Any
|
||||
_needs_previous_day: Callable[[datetime], bool]
|
||||
odds_manager: Any
|
||||
_odds_pending: Dict[Any, Tuple[List[Dict], Any]]
|
||||
_odds_worker_busy: bool
|
||||
|
||||
def _fetch_todays_games(self) -> Optional[Dict]:
|
||||
"""Today's scoreboard for the live poll, with yesterday's while it can
|
||||
still hold a live game (``_needs_previous_day``)."""
|
||||
# ESPN anchors its calendar to Eastern US time.
|
||||
now = datetime.now(pytz.timezone("America/New_York"))
|
||||
today = now.strftime("%Y%m%d")
|
||||
dates = (
|
||||
f"{(now - timedelta(days=1)).strftime('%Y%m%d')}-{today}"
|
||||
if self._needs_previous_day(now) else today
|
||||
)
|
||||
try:
|
||||
# Asked on every poll and left under core's shared key for this
|
||||
# scoreboard and date, so odds-ticker and every other reader of
|
||||
# the same day reuse it. max_age=1: a copy another reader fetched
|
||||
# within the last second (the managers of one display tick) is the
|
||||
# same answer; anything older is asked again.
|
||||
data = get_espn_scoreboard(
|
||||
self.session, self.sport, self.league, dates,
|
||||
cache_manager=self.cache_manager, max_age=1,
|
||||
headers=self.headers, timeout=10, logger=self.logger,
|
||||
)
|
||||
except requests.exceptions.RequestException as e:
|
||||
self.logger.error(
|
||||
f"API error fetching today's games for {self.sport} - {self.league}: {e}"
|
||||
)
|
||||
return None
|
||||
events = data.get("events") if isinstance(data, dict) else None
|
||||
if not isinstance(events, list):
|
||||
events = [] # ESPN can answer "events": null
|
||||
self.logger.info(
|
||||
f"Fetched {len(events)} games for {dates} for {self.sport} - {self.league}"
|
||||
)
|
||||
return {"events": events}
|
||||
|
||||
def _fetch_odds(self, game: Dict) -> None:
|
||||
"""Queue ``game`` for an odds request; never waits for the answer.
|
||||
|
||||
One daemon worker per manager asks the odds manager for each queued
|
||||
game in turn and attaches the line to the game dict in place, under
|
||||
``_games_lock`` (display() reads the games under it), so a card can
|
||||
draw once without odds and gain them when ESPN answers. A game queued
|
||||
while the worker is busy joins its batch, and every dict queued for one
|
||||
id (update() rebuilds them; the list on screen may keep older ones)
|
||||
gets the one answer, so a slow endpoint never stacks threads or
|
||||
requests. The odds manager bounds each request (5 s, then a 60 s
|
||||
hold-off).
|
||||
"""
|
||||
if not self.show_odds or game.get("id") is None:
|
||||
return
|
||||
if not getattr(self, "odds_manager", None):
|
||||
self.logger.debug(f"No odds manager; no odds for game {game['id']}")
|
||||
return
|
||||
interval = (
|
||||
self.mode_config.get("live_odds_update_interval", 60)
|
||||
if game.get("is_live", False)
|
||||
else self.mode_config.get("odds_update_interval", 3600)
|
||||
)
|
||||
with self._games_lock:
|
||||
pending = getattr(self, "_odds_pending", None)
|
||||
if pending is None:
|
||||
pending = self._odds_pending = {}
|
||||
games, _ = pending.get(game["id"], ([], None))
|
||||
if not any(queued is game for queued in games):
|
||||
games.append(game)
|
||||
pending[game["id"]] = (games, interval)
|
||||
if getattr(self, "_odds_worker_busy", False):
|
||||
return
|
||||
self._odds_worker_busy = True
|
||||
try:
|
||||
threading.Thread(target=self._drain_odds_queue, daemon=True,
|
||||
name=f"{self.sport_key}-odds").start()
|
||||
except RuntimeError as e: # no thread to be had; try again next update
|
||||
with self._games_lock:
|
||||
self._odds_worker_busy = False
|
||||
self.logger.debug(f"Could not start the odds worker: {e}")
|
||||
|
||||
def _drain_odds_queue(self) -> None:
|
||||
"""The odds worker: ask for each queued game until none is left."""
|
||||
while True:
|
||||
with self._games_lock:
|
||||
if not self._odds_pending:
|
||||
self._odds_worker_busy = False
|
||||
return
|
||||
game_id = next(iter(self._odds_pending))
|
||||
games, interval = self._odds_pending.pop(game_id)
|
||||
try:
|
||||
odds = self.odds_manager.get_odds(
|
||||
sport=self.sport,
|
||||
league=self.league,
|
||||
event_id=game_id,
|
||||
update_interval_seconds=interval,
|
||||
)
|
||||
except Exception as e:
|
||||
self.logger.debug(f"Odds fetch failed for game {game_id}: {e}")
|
||||
continue
|
||||
if odds:
|
||||
with self._games_lock:
|
||||
for game in games:
|
||||
game["odds"] = odds
|
||||
@@ -1,418 +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", "")
|
||||
# One malformed entry (a None or non-numeric "current") is
|
||||
# skipped, not allowed to throw away the whole poll.
|
||||
try:
|
||||
current_rank = int(team_data.get("current"))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
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: by id, else by abbreviation.
|
||||
|
||||
The id table is authoritative whenever a side carries an id 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.
|
||||
Decided per side: a side without a usable id falls back to its
|
||||
abbreviation, without costing the other side its id.
|
||||
"""
|
||||
by_id = getattr(self, "_ranked_team_ids", None) or {}
|
||||
by_abbr = getattr(self, "_team_rankings_cache", None) or {}
|
||||
found = []
|
||||
for side in ("home", "away"):
|
||||
rank = None
|
||||
if by_id:
|
||||
try:
|
||||
rank = by_id.get(int(game.get(f"{side}_id")), 0) # type: ignore[arg-type] # None: caught below
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
if rank is None:
|
||||
rank = by_abbr.get(game.get(f"{side}_abbr"), 0)
|
||||
if rank:
|
||||
found.append(rank)
|
||||
return found
|
||||
|
||||
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)
|
||||
@@ -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()
|
||||
@@ -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
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -392,15 +392,6 @@ class TestGetEspnScoreboard:
|
||||
"20261004", cache_manager=cache)
|
||||
assert cache.records == {}
|
||||
|
||||
@pytest.mark.parametrize("payload", [{"events": None}, {}, {"events": "x"}, []])
|
||||
def test_an_answer_without_an_events_list_is_returned_not_cached(self, service, payload):
|
||||
espn = Espn()
|
||||
espn.get = lambda url, **kwargs: _response(payload, url, None)
|
||||
cache = RecordCache()
|
||||
assert get_espn_scoreboard(espn, "football", "nfl", "20261004",
|
||||
cache_manager=cache) == payload
|
||||
assert cache.records == {}
|
||||
|
||||
def test_no_cache_manager_just_fetches(self, service):
|
||||
espn = Espn()
|
||||
get_espn_scoreboard(espn, "football", "nfl", "20261004")
|
||||
|
||||
@@ -1,399 +0,0 @@
|
||||
"""src.common.sports_live_fetch: the live poll's request and the odds queue.
|
||||
|
||||
The cases follow ledmatrix-plugins' ``scripts/test_live_fetch_odds.py`` (the
|
||||
tables the family 9 reconcile was checked against); the expected values are
|
||||
its columns, which agree across all nine scoreboards. The host is the mixin
|
||||
over core's ``SportsFetchMixin`` (``_needs_previous_day``), with a session
|
||||
answering like ESPN did on 2026-10-12 (a single date is 200, a range 400) and
|
||||
a cache that ages entries on a test clock. Nothing reaches the network. The
|
||||
odds endpoint is held on an event, so "update() never waits" is shown by the
|
||||
call returning while the request is still open.
|
||||
"""
|
||||
|
||||
import ast
|
||||
import json
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
|
||||
from src.common import espn_dates, sports_live_fetch
|
||||
from src.common import fetch_service as fs
|
||||
from src.common.fetch_service import FetchService
|
||||
from src.common.sports_fetch import SportsFetchMixin
|
||||
from src.common.sports_live_fetch import SportsLiveFetchMixin
|
||||
|
||||
LOG = logging.getLogger("test_sports_live_fetch")
|
||||
SITE = "https://site.api.espn.com/apis/site/v2/sports/"
|
||||
|
||||
|
||||
def _response(url, answer):
|
||||
resp = requests.Response()
|
||||
resp.url = url
|
||||
resp.status_code = answer if isinstance(answer, int) else 200
|
||||
resp._content = answer if isinstance(answer, bytes) else json.dumps(
|
||||
{} if isinstance(answer, int) else answer).encode()
|
||||
return resp
|
||||
|
||||
|
||||
class Espn(requests.Session):
|
||||
"""Two games a day for a single date; a range is 400, as ESPN answers."""
|
||||
|
||||
def __init__(self, answer=None):
|
||||
super().__init__()
|
||||
self.answer, self.asked = answer, []
|
||||
|
||||
def get(self, url, params=None, **_kwargs):
|
||||
dates = (params or {}).get("dates")
|
||||
self.asked.append((url[len(SITE):], dates))
|
||||
if dates and "-" in dates:
|
||||
return _response(url, 400)
|
||||
if isinstance(self.answer, type):
|
||||
raise self.answer("refused by the test")
|
||||
if self.answer is not None:
|
||||
return _response(url, self.answer)
|
||||
return _response(url, {"events": [{"id": dates[4:] + s} for s in "ab"]})
|
||||
|
||||
|
||||
class Cache:
|
||||
"""``get(key, max_age)`` against entries stamped on ``clock``."""
|
||||
|
||||
def __init__(self, clock):
|
||||
self.clock, self.store, self.reads = clock, {}, []
|
||||
|
||||
def get(self, key, max_age=None):
|
||||
self.reads.append((key, max_age))
|
||||
if key not in self.store:
|
||||
return None
|
||||
data, stamp = self.store[key]
|
||||
return data if max_age is None or self.clock() - stamp <= max_age else None
|
||||
|
||||
def set(self, key, data, ttl=None):
|
||||
self.store[key] = (data, self.clock())
|
||||
|
||||
|
||||
class Clock:
|
||||
def __init__(self):
|
||||
self.now = 100_000.0
|
||||
|
||||
def __call__(self):
|
||||
return self.now
|
||||
|
||||
|
||||
class Frozen(datetime):
|
||||
at = datetime(2026, 10, 12, 10, 0)
|
||||
|
||||
@classmethod
|
||||
def now(cls, tz=None):
|
||||
return tz.localize(cls.at) if tz is not None else cls.at
|
||||
|
||||
|
||||
class Host(SportsLiveFetchMixin, SportsFetchMixin):
|
||||
"""A scoreboard SportsCore as far as the family reaches."""
|
||||
|
||||
sport, league, sport_key = "basketball", "nba", "nba"
|
||||
|
||||
def __init__(self, session=None, cache=None, odds=None, show_odds=True):
|
||||
self.session, self.headers, self.logger = session or Espn(), {}, LOG
|
||||
self.cache_manager = cache
|
||||
self.odds_manager, self.show_odds = odds, show_odds
|
||||
self.mode_config = {}
|
||||
self._games_lock = threading.RLock()
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def offline(monkeypatch):
|
||||
clock = Clock()
|
||||
monkeypatch.setattr(fs, "_service", FetchService({"rate_limits": {}}))
|
||||
monkeypatch.setattr(espn_dates, "_ranges_rejected_until", 0.0)
|
||||
monkeypatch.setattr(sports_live_fetch, "datetime", Frozen)
|
||||
monkeypatch.setattr(time, "time", clock)
|
||||
Frozen.at = datetime(2026, 10, 12, 10, 0)
|
||||
return clock
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The live poll's request
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.parametrize("hour, asked, key, ids", [
|
||||
(10, [("20261012",)], "espn_scoreboard_basketball_nba_20261012", ["1012a", "1012b"]),
|
||||
(2, [("20261011-20261012",), ("20261011",), ("20261012",)],
|
||||
"espn_scoreboard_basketball_nba_20261011-20261012",
|
||||
["1011a", "1011b", "1012a", "1012b"]),
|
||||
])
|
||||
def test_what_the_poll_asks_and_keeps(offline, hour, asked, key, ids):
|
||||
Frozen.at = datetime(2026, 10, 12, hour, 0)
|
||||
espn, cache = Espn(), Cache(offline)
|
||||
data = Host(espn, cache)._fetch_todays_games()
|
||||
assert [(d,) for _, d in espn.asked] == asked
|
||||
assert {path for path, _ in espn.asked} == {"basketball/nba/scoreboard"}
|
||||
assert cache.reads == [(key, 1)]
|
||||
assert list(cache.store) == [key]
|
||||
assert [e["id"] for e in data["events"]] == ids
|
||||
|
||||
|
||||
def test_college_basketball_is_asked_by_date(offline):
|
||||
espn, host = Espn(), Host()
|
||||
host.session, host.league = espn, "mens-college-basketball"
|
||||
host._fetch_todays_games()
|
||||
assert espn.asked == [("basketball/mens-college-basketball/scoreboard", "20261012")]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("seed_age, later, requests_made", [
|
||||
(None, 20, [1, 1]), # every poll asks ESPN; nothing is held 30 s
|
||||
(None, 0.5, [1, 0]), # a copy from this second is the same answer
|
||||
(0, None, [0]), # another reader's copy from this second
|
||||
(2, None, [1]), # ... and one two seconds old is asked again
|
||||
])
|
||||
def test_every_poll_is_fresh(offline, seed_age, later, requests_made):
|
||||
espn, cache = Espn(), Cache(offline)
|
||||
if seed_age is not None:
|
||||
cache.store["espn_scoreboard_basketball_nba_20261012"] = (
|
||||
{"events": [{"id": "seed"}]}, offline.now - seed_age)
|
||||
host, made = Host(espn, cache), []
|
||||
for step in ([0] if later is None else [0, later]):
|
||||
offline.now += step
|
||||
before = len(espn.asked)
|
||||
host._fetch_todays_games()
|
||||
made.append(len(espn.asked) - before)
|
||||
assert made == requests_made
|
||||
|
||||
|
||||
def test_the_old_per_plugin_key_is_not_read(offline):
|
||||
cache = Cache(offline)
|
||||
cache.store["nba_scoreboard_current"] = ({"events": [{"id": "old"}]}, offline.now)
|
||||
data = Host(Espn(), cache)._fetch_todays_games()
|
||||
assert [e["id"] for e in data["events"]] == ["1012a", "1012b"]
|
||||
assert "nba_scoreboard_current" not in {k for k, _ in cache.reads}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("answer, result, cached", [
|
||||
({}, {"events": []}, False),
|
||||
({"events": None}, {"events": []}, False),
|
||||
({"events": []}, {"events": []}, True),
|
||||
(404, None, False),
|
||||
(500, None, False),
|
||||
(requests.ConnectionError, None, False),
|
||||
(requests.Timeout, None, False),
|
||||
(b"<html>", None, False),
|
||||
])
|
||||
def test_what_a_failed_or_malformed_answer_becomes(offline, answer, result, cached):
|
||||
cache = Cache(offline)
|
||||
assert Host(Espn(answer), cache)._fetch_todays_games() == result
|
||||
assert bool(cache.store) is cached
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Odds
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class Odds:
|
||||
"""An odds endpoint held open until ``release()``, counting what is in
|
||||
flight and how many odds workers are alive while it answers."""
|
||||
|
||||
def __init__(self, answer="odds", held=True):
|
||||
self.answer, self.calls, self.gate = answer, [], threading.Event()
|
||||
if not held:
|
||||
self.gate.set()
|
||||
self.lock, self.active, self.most_active, self.most_workers = threading.Lock(), 0, 0, 0
|
||||
|
||||
def get_odds(self, sport, league, event_id, update_interval_seconds=None):
|
||||
workers = sum(t.name == "nba-odds" and t.is_alive() for t in threading.enumerate())
|
||||
with self.lock:
|
||||
self.calls.append((event_id, update_interval_seconds))
|
||||
self.active += 1
|
||||
self.most_active = max(self.most_active, self.active)
|
||||
self.most_workers = max(self.most_workers, workers)
|
||||
self.gate.wait(10)
|
||||
with self.lock:
|
||||
self.active -= 1
|
||||
if isinstance(self.answer, type):
|
||||
raise self.answer("odds endpoint failed")
|
||||
return {"details": f"{event_id} -3.5"} if self.answer else None
|
||||
|
||||
def release(self):
|
||||
self.gate.set()
|
||||
|
||||
|
||||
def settle():
|
||||
for thread in threading.enumerate():
|
||||
if thread.name == "nba-odds":
|
||||
thread.join(10)
|
||||
|
||||
|
||||
def game(gid="g1", **flags):
|
||||
return {"id": gid, **flags}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("flags, interval", [({"is_live": True}, 60), ({"is_upcoming": True}, 3600),
|
||||
({"is_final": True}, 3600)])
|
||||
def test_the_call_never_waits_and_the_line_lands_later(flags, interval):
|
||||
odds = Odds()
|
||||
host, g = Host(odds=odds), game(**flags)
|
||||
host._fetch_odds(g) # returns while the request is held open
|
||||
assert "odds" not in g
|
||||
odds.release()
|
||||
settle()
|
||||
assert g["odds"] == {"details": "g1 -3.5"}
|
||||
assert odds.calls == [("g1", interval)]
|
||||
|
||||
|
||||
def test_the_intervals_come_from_the_mode_config():
|
||||
odds = Odds(held=False)
|
||||
host = Host(odds=odds)
|
||||
host.mode_config = {"live_odds_update_interval": 30, "odds_update_interval": 900}
|
||||
host._fetch_odds(game("l", is_live=True))
|
||||
settle()
|
||||
host._fetch_odds(game("u"))
|
||||
settle()
|
||||
assert odds.calls == [("l", 30), ("u", 900)]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("answer", [None, RuntimeError])
|
||||
def test_no_line_or_a_failed_request_leaves_the_game_bare(caplog, answer):
|
||||
odds = Odds(answer, held=False)
|
||||
host, g = Host(odds=odds), game()
|
||||
with caplog.at_level(logging.DEBUG, logger=LOG.name):
|
||||
host._fetch_odds(g)
|
||||
settle()
|
||||
assert "odds" not in g and host._odds_worker_busy is False
|
||||
assert [r for r in caplog.records if r.levelno >= logging.WARNING] == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize("host", [
|
||||
Host(odds=Odds(held=False), show_odds=False),
|
||||
Host(odds=None),
|
||||
], ids=["show_odds off", "no odds manager"])
|
||||
def test_nothing_is_asked(caplog, host):
|
||||
with caplog.at_level(logging.DEBUG, logger=LOG.name):
|
||||
host._fetch_odds(game())
|
||||
assert not [t for t in threading.enumerate() if t.name == "nba-odds"]
|
||||
assert [r.levelname for r in caplog.records] in ([], ["DEBUG"])
|
||||
|
||||
|
||||
def test_a_game_without_an_id_is_not_asked():
|
||||
odds = Odds(held=False)
|
||||
Host(odds=odds)._fetch_odds({"is_live": True})
|
||||
settle()
|
||||
assert odds.calls == []
|
||||
|
||||
|
||||
def test_one_batch_per_manager_and_every_dict_gets_the_answer():
|
||||
odds = Odds()
|
||||
host = Host(odds=odds)
|
||||
first, again, other = game("g1"), game("g1"), game("g2")
|
||||
with host._games_lock: # queued before the worker takes one
|
||||
for g in (first, again, other): # update() rebuilds the dicts
|
||||
host._fetch_odds(g)
|
||||
odds.release()
|
||||
settle()
|
||||
assert odds.calls == [("g1", 3600), ("g2", 3600)]
|
||||
assert odds.most_workers == 1
|
||||
assert first["odds"] == again["odds"] == {"details": "g1 -3.5"}
|
||||
|
||||
|
||||
def test_a_thread_that_will_not_start_is_tried_again(monkeypatch):
|
||||
class NoThread:
|
||||
def __init__(self, *args, **kwargs):
|
||||
pass
|
||||
|
||||
def start(self):
|
||||
raise RuntimeError("can't start new thread")
|
||||
|
||||
odds = Odds(held=False)
|
||||
host = Host(odds=odds)
|
||||
monkeypatch.setattr(sports_live_fetch.threading, "Thread", NoThread)
|
||||
host._fetch_odds(game("g1"))
|
||||
assert host._odds_worker_busy is False
|
||||
monkeypatch.undo()
|
||||
host._fetch_odds(game("g2"))
|
||||
settle()
|
||||
assert [c[0] for c in odds.calls] == ["g1", "g2"]
|
||||
|
||||
|
||||
def test_update_and_display_on_two_threads():
|
||||
"""Two updaters queueing fresh dicts while a reader walks the games under
|
||||
the lock, as display() does: no exception, one worker, one request at a
|
||||
time, and every dict ends with its line."""
|
||||
odds = Odds(held=False)
|
||||
odds.get_odds = _slowed(odds.get_odds)
|
||||
host, shown, raised = Host(odds=odds), [], []
|
||||
|
||||
def updater():
|
||||
try:
|
||||
for n in range(30):
|
||||
g = game(f"g{n % 4}")
|
||||
with host._games_lock:
|
||||
shown.append(g)
|
||||
host._fetch_odds(g)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
raised.append(exc)
|
||||
|
||||
def display():
|
||||
try:
|
||||
for _ in range(300):
|
||||
with host._games_lock:
|
||||
[g.get("odds") for g in shown]
|
||||
except Exception as exc: # noqa: BLE001
|
||||
raised.append(exc)
|
||||
|
||||
threads = [threading.Thread(target=updater) for _ in range(2)]
|
||||
threads.append(threading.Thread(target=display))
|
||||
for t in threads:
|
||||
t.start()
|
||||
for t in threads:
|
||||
t.join(30)
|
||||
settle()
|
||||
assert raised == []
|
||||
assert odds.most_active == 1 and odds.most_workers == 1
|
||||
assert all(g.get("odds") for g in shown)
|
||||
|
||||
|
||||
def _slowed(get_odds):
|
||||
def slow(**kwargs):
|
||||
time.sleep(0.005)
|
||||
return get_odds(**kwargs)
|
||||
return slow
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Carrier and host contract
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
MEMBERS = ["_drain_odds_queue", "_fetch_odds", "_fetch_todays_games"]
|
||||
|
||||
|
||||
def _self_reads():
|
||||
tree = ast.parse(Path(sports_live_fetch.__file__).read_text(encoding="utf-8"))
|
||||
reads = {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"}
|
||||
gets = {node.args[1].value for node in ast.walk(tree)
|
||||
if isinstance(node, ast.Call) and getattr(node.func, "id", None) == "getattr"
|
||||
and len(node.args) >= 2 and isinstance(node.args[1], ast.Constant)}
|
||||
return reads | gets
|
||||
|
||||
|
||||
class TestHostContract:
|
||||
def test_the_mixin_carries_only_the_family(self):
|
||||
assert sorted(n for n in vars(SportsLiveFetchMixin) if not n.startswith("__")) == MEMBERS
|
||||
|
||||
def test_every_host_read_is_documented(self):
|
||||
undocumented = sorted(n for n in _self_reads() - set(MEMBERS)
|
||||
if f"``{n}``" not in sports_live_fetch.__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_favorites, sports_helpers,
|
||||
sports_rankings, sports_rotation, sports_shared)
|
||||
others = [sports_shared.SportsCoreSharedMixin, sports_shared.SportsRecentSharedMixin,
|
||||
sports_shared.SportsLiveSharedMixin, sports_helpers.SportsHelpersMixin,
|
||||
sports_favorites.SportsFavoritesMixin, sports_rotation.SportsRotationMixin,
|
||||
sports_rankings.SportsRankingsMixin, SportsFetchMixin,
|
||||
sports_display_rules.SportsGameRulesMixin]
|
||||
assert [m for m in MEMBERS if any(m in vars(o) for o in others)] == []
|
||||
@@ -1,104 +0,0 @@
|
||||
"""sports_live_fetch still matches every plugin copy.
|
||||
|
||||
``src.common.sports_live_fetch`` was copied from the scoreboards once family 9
|
||||
had made each method one body in all nine ``SportsCore`` classes:
|
||||
``_fetch_todays_games``, ``_fetch_odds`` and ``_drain_odds_queue``. 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.
|
||||
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_live_fetch
|
||||
|
||||
REPO = Path(__file__).resolve().parents[1]
|
||||
SPORTS = ("afl", "baseball", "basketball", "football", "hockey", "lacrosse",
|
||||
"nrl", "soccer", "ufc")
|
||||
CARRIER, MIXIN = "SportsCore", "SportsLiveFetchMixin"
|
||||
METHODS = ("_fetch_todays_games", "_fetch_odds", "_drain_odds_queue")
|
||||
|
||||
|
||||
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 _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_live_fetch.__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_live_fetch.__name__ in source, (
|
||||
f"{sport}: no {name} on {CARRIER} and no {sports_live_fetch.__name__} import")
|
||||
else:
|
||||
assert _fingerprint(copy) == _fingerprint(_method(OURS, name)), (
|
||||
f"{CARRIER}.{name} in {sport} differs from sports_live_fetch. "
|
||||
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_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
|
||||
@@ -1,395 +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}),
|
||||
({"rankings": [{"name": "AP Top 25", "type": "ap", "ranks": [ # #789: one bad entry
|
||||
{"current": None, "team": {"id": "61", "abbreviation": "UGA"}},
|
||||
{"current": "n/a", "team": {"id": "87", "abbreviation": "ND"}},
|
||||
{"current": 3, "team": {"id": "194", "abbreviation": "OSU"}}]}]},
|
||||
{"OSU": 3}, {194: 3}),
|
||||
])
|
||||
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)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("home, away, best", [
|
||||
(NEWARK, (None, "VAN"), 99), # Newark's id is unranked; "OSU" must not rank it
|
||||
(("61", None), (None, "VAN"), 1), # UGA's id still counts with no id on the other side
|
||||
])
|
||||
def test_each_side_is_ranked_by_its_own_id(clock, home, away, best):
|
||||
"""#789: one side's missing id no longer sends both sides to abbreviations."""
|
||||
g = {f"{side}_{key}": value for side, team in (("home", home), ("away", away))
|
||||
for key, value in zip(("id", "abbr"), team) if value is not None}
|
||||
assert loaded()._best_rank(g) == best
|
||||
|
||||
|
||||
#: 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)] == []
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user