Compare commits

...
6 Commits
Author SHA1 Message Date
ChuckandClaude Opus 5.5 456a1ac16c chore: prepare the 3.8.7 release (#797)
Bumps src.__version__ to 3.8.7 and turns Unreleased into ## 3.8.7:
src.common.sports_view_model (#796, sports family 10), which the scoreboards
adopt by flooring on 3.8.7. src/common/README.md and
docs/SPORTS_UNIFICATION.md say 3.8.7 for the module.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 13:41:40 -04:00
ChuckandClaude Opus 5.5 f4435d79e0 feat(common): sports_view_model -- the reconciled game view model (sports family 10) (#796)
* feat(common): sports_view_model -- the reconciled game view model (sports family 10)

New hardware-free module src/common/sports_view_model.py, copied from
ledmatrix-plugins claude/family10-reconcile once the nine scoreboards made
_extract_game_details_common (nine bodies) one. It lives on the plugins'
SportsCore, so one mixin, SportsViewModelMixin, carries exactly that method,
the helpers it calls (_status_is_final, _score_text, _team_logo_url,
_extract_team_record), the _logo_filename seam and the two status-name
sets; no manager gains a method it lacks today. The module docstring lists
every key of the game dict with its type and default; keys are additive
only.

Owner decisions (2026-10-13):
- One final rule: state "post", not completed: false (a payload without
  the flag is judged on state and name), and not a not-played status (the
  union of the nine lists). A forfeit is final. A called-off game is never
  live, even while ESPN still reports it "in".
- Scores are integer text everywhere; a missing, empty or non-numeric
  score is "0".
- A time without an offset is UTC.
- Halftime reads "HALF" with no clock (each sport's own extractor; not in
  this module).
- Superset with safe defaults: period (0) and clock (""). period_text has
  none: the renderers read its absence (baseball) as "draw ESPN's status
  text", and an empty default blanked baseball's Recent "Final" in the
  harness.

- test/test_sports_view_model.py: the plugins' pinned cases (states, the
  final rule, scores, records, logos, ids, broadcast, dates, offsets, DST,
  malformed events), the seam, and the host contract (every self read
  documented, every key in the docstring's table). Mutation-checked (11
  mutants, all caught).
- test/test_sports_view_model_parity.py: with LEDMATRIX_PLUGINS, compares
  each method and both status sets with every plugin copy. Passes against
  claude/family10-reconcile; fails against the pre-reconcile tree.
- test_sports_twins.py, sports_card and sports_game_renderer tests pass
  with the reconciled plugins as the corpus.
- mypy ratchet, src/common/README.md, CHANGELOG (Unreleased: New modules),
  docs/SPORTS_UNIFICATION.md family 10 status and decisions, and the
  _logo_filename seam in the override-points table.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs: cite ledmatrix-plugins #654 for the family 10 reconcile

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(sports): view-model parity requires SportsCore to inherit the mixin

A plugin without a copy counted as adopted if its source merely mentioned
the module. It now also has to list SportsViewModelMixin among SportsCore's
bases (CodeRabbit on #796).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 13:07:38 -04:00
ChuckandClaude Opus 5.5 c5ce497c37 chore: prepare the 3.8.6 release (#794)
Bumps src.__version__ to 3.8.6 and turns Unreleased into ## 3.8.6: the Start
Display on Boot toggle (#792), the rankings edge-case fixes (#791), and
src.common.sports_live_fetch with the get_espn_scoreboard null-events fix
(#793, sports family 9), which the scoreboards adopt by flooring on 3.8.6.
src/common/README.md and docs/SPORTS_UNIFICATION.md say 3.8.6 for the module,
and family 9's heading no longer says it is awaiting a release.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 09:39:40 -04:00
ChuckandClaude Opus 5.5 f9b0b237a3 feat(common): sports_live_fetch -- the reconciled live fetch and odds (sports family 9) (#793)
New hardware-free module src/common/sports_live_fetch.py, copied from
ledmatrix-plugins claude/family9-reconcile once the nine scoreboards made
_fetch_todays_games (seven bodies when measured) and _fetch_odds (three) one
body each. Both live on the plugins' SportsCore, so one mixin,
SportsLiveFetchMixin, carries exactly those two plus the odds worker
_drain_odds_queue; no manager gains a method it lacks today.

Owner decisions (2026-10-12):
- Live scores are always fresh: every poll asks ESPN through
  get_espn_scoreboard 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 is reused). The plugins' 30 s
  <sport_key>_scoreboard_current cache is gone.
- College basketball is asked by date like every other league (ESPN
  answered single dates for both college leagues on 2026-10-12).
- 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, every dict queued for an id gets
  the one answer. show_odds, the two odds intervals and a missing odds
  manager (debug, not ERROR) are respected.
- An answer whose events is not a list is no games.

Fix: get_espn_scoreboard() no longer caches an answer whose events is not a
list ("events": null), which every reader of the shared key inherited; it is
still returned to the caller.

- test/test_sports_live_fetch.py: the plugins' pinned cases (requests,
  dates and keys at 10:00 and 02:00 Eastern, freshness, malformed and failed
  answers, odds that never block and land later, the intervals, one batch per
  manager, a worker that cannot start, update and display on two threads),
  plus the host contract. Mutation-checked (11 mutants, all caught).
- test/test_sports_live_fetch_parity.py: with LEDMATRIX_PLUGINS, compares
  each body with every plugin copy. Passes against claude/family9-reconcile;
  fails against the pre-reconcile tree.
- test/test_espn_scoreboard_cache.py: the null-events regression.
- mypy ratchet, src/common/README.md, CHANGELOG (Unreleased: New modules,
  Fixes), docs/SPORTS_UNIFICATION.md family 9 status and decisions.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 09:21:50 -04:00
ChuckandClaude Opus 5.5 69d89a5d69 feat(web): Start Display on Boot toggle on the General tab (#792)
Turns ledmatrix.service's autostart on or off at once through the existing
enable_autostart / disable_autostart system actions (sudo grants already in
lib_sudoers.sh). The checkbox shows systemd's own state, read with
`systemctl is-enabled`, so nothing is stored in config.json and the default
stays on. Hidden when the state cannot be read; a refused change puts the
box back and shows the route's message.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 09:06:27 -04:00
ChuckandClaude Opus 5.5 886e199cae fix(sports_rankings): skip a malformed poll entry; rank each side by its own id (#791)
* fix(sports_rankings): skip a malformed poll entry; rank each side by its own id

The two review points deferred on #789 until sports_rankings was the only
copy (the nine scoreboards adopted it in ledmatrix-plugins #648):

- A poll entry whose "current" is None or a string raised TypeError at
  `current_rank <= 0`; the broad except returned {} without setting the
  cache timestamp, so every team lost its rank and the standings were
  re-fetched on every update. The entry is now skipped.
- _ranked_positions() fell back to abbreviations for BOTH sides when only
  one side's id was unparseable, so a colliding abbreviation could rank the
  wrong school. It now decides id-or-abbreviation per side.

Well-formed ESPN data (ids on both sides, int ranks) ranks as before.
SPORTS_UNIFICATION.md: family 8 marked done and adopted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(changelog): a numeric-string rank is still accepted; say which values are skipped

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 14:07:58 -04:00
21 changed files with 1994 additions and 35 deletions
+69
View File
@@ -19,6 +19,75 @@ accepts both, but the store flags the old spelling as deprecated
## Unreleased
## 3.8.7
Sports consolidation family 10: `src.common.sports_view_model`, the game
dict every scoreboard screen draws, which the scoreboards adopt by flooring
on 3.8.7 (#796).
### New modules
- `src/common/sports_view_model.py` -- sports consolidation family 10, once
the plugins made `_extract_game_details_common` (nine bodies) one:
`SportsViewModelMixin` (`SportsCore`), the game dict every scoreboard screen
draws from one ESPN event, with `_status_is_final`, `_score_text`,
`_team_logo_url`, `_extract_team_record` and the `_logo_filename` seam
beside it. One rule for a final (state "post", not `completed: false`, not
postponed, canceled, suspended, abandoned, delayed or pending; a forfeit is
final); a called-off game is never live; scores are integer text; a time
without an offset is UTC. Keys are additive only; the module docstring
lists every key, its type and default. A plugin may inherit the mixin once
it floors on 3.8.7, and deletes its copies then. (#796)
## 3.8.6
A **Start Display on Boot** toggle on the General tab (#792), two fixes to
the shared rankings code (#791), `get_espn_scoreboard()` no longer caching a
null-events answer, and sports consolidation family 9:
`src.common.sports_live_fetch`, which the scoreboards adopt by flooring on
3.8.6 (#793).
### Features
- General tab: a **Start Display on Boot** toggle turns
`ledmatrix.service`'s autostart on or off right away (no Save needed),
through the existing `enable_autostart` / `disable_autostart` system
actions. It shows systemd's own state, so nothing is stored in
`config.json`; the default stays on (the installer enables the unit). The
web interface still starts on boot either way. Hidden when the unit's state
cannot be read (not installed, or no systemd).
### 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 3.8.6, and deletes its copies then.
(#793)
### 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
+123 -15
View File
@@ -95,6 +95,8 @@ more. Shared sports code lives in `src/common`:
| `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) |
| `sports_live_fetch.py` | 3.8.6 | `SportsLiveFetchMixin` — the live poll's scoreboard request (always fresh, shared key) and odds fetched off the update path by one worker per manager (family 9) |
| `sports_view_model.py` | 3.8.7 | `SportsViewModelMixin` — the game dict every screen draws (`_extract_game_details_common`), the final and live rules, score text, logos, records; the `_logo_filename` seam (family 10) |
Each is described in [src/common/README.md](../src/common/README.md).
@@ -132,6 +134,7 @@ deprecation cycle.
| `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 |
| `_logo_filename(abbr, team_id)` | The file under `logo_dir` a team's logo is kept in (`sports_view_model`) | `<ABBR>.png`. nrl returns `<ABBR>_<id>.png`: its abbreviations are not unique |
| `_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 |
@@ -383,7 +386,7 @@ ufc's MMA managers override `update()` and never build the pools, so the
rotation is dormant there. `_best_rank`, `_is_ranked_game` and
`_passes_other_filters` were family 8.
### Family 8: rankings (reconciled; promoted, core 3.8.5)
### 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
@@ -415,6 +418,82 @@ 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, core 3.8.6)
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 #650)
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.
### Family 10: the view model (reconciled; promoted, core 3.8.7)
ledmatrix-plugins `scripts/test_view_model.py` (#653) pinned both
`_extract_game_details_common` and each plugin's final `_extract_game_details`
first, key by key, over 52 ESPN-shaped events: every state, OT, postponed,
canceled, delayed and forfeit games, a missing `completed` flag, scores as
int, "21.0", a dict, None, "" or missing, the two record shapes, missing logos
or ids, colliding abbreviations, broadcast objects, and dates with offsets,
without them, "TBD", and both DST changes. The nine bodies differed in how a
final was decided (five rules), how a score was read (raw in five), which
record and logo shapes were read, where a team id came from, how a time
without an offset was read (UTC, US Eastern in hockey and lacrosse, the host's
zone in ufc), and whether `favorite_teams` and basketball's tournament keys
were built there. The reconcile (ledmatrix-plugins #654)
made the method one body on `SportsCore`, with `_status_is_final`,
`_score_text`, `_team_logo_url`, `_extract_team_record` and the
`_logo_filename` seam beside it; basketball's tournament keys moved to its own
`_extract_game_details` (its dict keeps them), football's missing-badge
request to its own, and ufc's MMA extractor, which builds its own dict, uses the
shared helpers. Every changed cell is one of the decisions below or a fix.
All 208 harness renders of the nine are pixel-identical on core main.
`src/common/sports_view_model.py` holds the bodies in one mixin,
`SportsViewModelMixin`, whose docstring lists every key, its type and default;
`test/test_sports_view_model_parity.py` compares them with the plugin copies.
`period_text` is deliberately not given a default: the renderers read its
absence (baseball's) as "draw ESPN's status text", which is how an
extra-inning final reads "Final/10"; an empty default blanked baseball's
Recent "Final". `period` (0) and `clock` ("") are, and baseball now carries
them. `is_within_window` and `broadcast` are never read but are kept: keys are
additive only.
Left for later: the period labels stay in each sport's
`_extract_game_details` (quarters, periods, halves, extra time, rounds), and
soccer still names extra time's half-time "ETH"; ufc's MMA dict keeps its own
keys and its ordinal date (`display_manager.format_date_with_ordinal`).
### Why the method changes
Byte-identical promotion has nearly run dry. Measured on ledmatrix-plugins
@@ -502,9 +581,9 @@ 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`; shipped in 3.8.1 and adopted. See [Family 5](#family-5-the-game-over-check-done-core-381-adopted) |
| 6 | Favourite matching | `_is_favorite_game` (7 across three classes), `_select_games_for_display` (2: nrl), `_select_recent_games_for_display` (3) | Everything that asks "is this a favourite" goes through the 3.5.0 `_favorite_key` seam. Reconciled to one body each and promoted as `sports_favorites`; shipped in 3.8.2 and adopted. See [Family 6](#family-6-favourite-matching-done-core-382-adopted) |
| 7 | Other-games rotation | `_by_importance`, `_other_games_window`, `_advance_other_games_if_due` (2 each: football), `_rotate_other_games_on_display` (2: ufc), with `_attach_odds_to_rotated_games` (3; ufc had none) | One outlier each; football carried two fixes the other eight lacked. Reconciled to one body each, on a `_rankings_loaded` seam, and promoted as `sports_rotation`; done: core 3.8.4, adopted (ledmatrix-plugins #643). See [Family 7](#family-7-the-other-games-rotation-done-core-384-adopted) |
| 8 | Rankings | `_fetch_team_rankings` (3), `_choose_poll` (3), `_load_division_team_ids`, `_passes_other_filters`, `_best_rank`, `_is_ranked_game` (2 each: football) | Needs 7; the rank badge and the "ranked only" filter read it. Reconciled to one body each and promoted as `sports_rankings` (core 3.8.5). See [Family 8](#family-8-rankings-reconciled-promoted-core-385) |
| 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` |
| 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` (core 3.8.6). See [Family 9](#family-9-live-fetch-and-odds-reconciled-promoted-core-386) |
| 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`. Reconciled to one body and promoted as `sports_view_model` (core 3.8.7). See [Family 10](#family-10-the-view-model-reconciled-promoted-core-387) |
| 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 |
| 13 | Mode lifecycle | `sports.py`: `update` (23), `__init__` (16), `display` (13), `_advance_live_game_if_due` (6) | Where per-sport behaviour lives; last in `sports.py`. Each difference becomes a seam or a strategy chosen by name (live rotation already has three) |
@@ -598,17 +677,46 @@ suspected behaviour that needs a payload or a rig to confirm first.
seasons. A block without a readable `endDate` counts as current. (f)
baseball's MLB, MiLB and NCAA Baseball managers drop their class-level
shared copy of the table (none of those leagues publishes a poll).
- **9, live fetch and odds.** (a) afl, basketball, nrl and soccer cache the
live scoreboard for 30 s under `<sport_key>_scoreboard_current`; the others
do not (the harness fixtures seed that key, so the change shows up there).
(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.
- **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.
- **10, view model. Decided 2026-10-13, reconciled.** Additive only: no key
is renamed or removed. (a) One rule for a final, in all nine: state "post",
not `completed: false` (a payload without the flag is judged on state and
name), and not a not-played status, the union of the nine lists (postponed,
canceled, suspended, abandoned, delayed, a pending forfeit or no-contest).
A forfeit is a result (basketball and soccer had it as not played). (b)
Scores are integer text everywhere ("21" for 21, "21.0" or {"value": 21};
"0" for a missing or empty one, never "None"). (c) Halftime reads "HALF",
with no period or clock, in every sport's `period_text` (basketball,
football, hockey and lacrosse showed the period while ESPN said "in"; nrl
and soccer appended "0:00"). (d) A time without an offset is UTC (hockey and
lacrosse read US Eastern, ufc the host's zone). Fixes ported: football's
postponed or canceled game says so, not "Final"; the team-schedule record
shape is read in all nine; a logo URL is `team.logo`, else `logos[0].href`;
a team id is the competitor's, else its team's (eight dropped the game);
ufc never shows a score of "None"; a called-off game ESPN still reports as
"in" is not live (baseball's guard). Superset: ufc's fights carry
`favorite_teams` ([]), baseball's games `period` and `clock`. Per-sport:
ufc's own dict and ordinal date, nrl's id-based logo file and favourites,
the period labels.
- **11 and 12, the scorebug and the card.** The pinned divergences in
`test/test_sports_twins.py`, where switch mode and scroll mode draw the
same game differently:
+2
View File
@@ -39,6 +39,7 @@ 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
@@ -46,6 +47,7 @@ src/common/sports_rotation.py
src/common/sports_scroll.py
src/common/sports_timezone.py
src/common/sports_vegas.py
src/common/sports_view_model.py
src/config_service.py
src/core_config_keys.py
src/deprecation.py
+1 -1
View File
@@ -4,5 +4,5 @@ LEDMatrix Display System
Core source package for the LED Matrix Display project.
"""
__version__ = "3.8.5"
__version__ = "3.8.7"
+29
View File
@@ -51,6 +51,7 @@ 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) | 3.8.6 |
| [`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 |
@@ -59,6 +60,7 @@ Rules for the package:
| [`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 |
| [`sports_timezone`](#sports_timezone) | Which timezone a scoreboard draws start times in | Yes (scoreboards) | 3.6.0 |
| [`sports_view_model`](#sports_view_model) | The game dict every scoreboard screen draws, built from one ESPN event | Yes (scoreboards) | 3.8.7 |
| [`sync_manager`](#sync_manager) | Leader/follower sync between two displays | No, core-internal | n/a |
| [`text_helper`](#text_helper) | Outlined text, wrapping, measurement | Yes | — |
@@ -370,6 +372,19 @@ 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:
@@ -433,6 +448,20 @@ through a live poll that left them out), and `finished_games()` /
FINAL). `SportsScrollDisplay.build_vegas_elements()` in `sports_scroll` puts
them together; a scoreboard not built on it (UFC) uses them directly.
### sports_view_model
[`sports_view_model.py`](sports_view_model.py). Sports family 10:
`SportsViewModelMixin` (`SportsCore`). `_extract_game_details_common` turns
one ESPN scoreboard event into the game dict every renderer reads (the key
table is in the module docstring; keys are additive only) and hands back the
two competitors, the status and the situation for the sport's own
`_extract_game_details`. One rule decides a final (`_status_is_final`: state
"post", not `completed: false`, not a postponed, canceled, suspended,
abandoned, delayed or pending status; a forfeit is final); a called-off game
is never live; scores are integer text (`_score_text`); a time without an
offset is UTC. `_logo_filename(abbr, team_id)` is the seam for the logo file
under `logo_dir` (NRL names it by team id too).
### sports_timezone
[`sports_timezone.py`](sports_timezone.py).
+4 -1
View File
@@ -947,7 +947,8 @@ 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. ``session=None`` uses the fetch service's
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
pooled session for the ESPN host.
"""
key = espn_scoreboard_cache_key(sport, league, dates)
@@ -969,5 +970,7 @@ 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)
return data
+200
View File
@@ -0,0 +1,200 @@
"""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
+23 -16
View File
@@ -216,7 +216,12 @@ class SportsRankingsMixin:
for team_data in poll.get("ranks", []):
team_info = team_data.get("team", {})
team_abbr = team_info.get("abbreviation", "")
current_rank = team_data.get("current", 0)
# 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:
@@ -337,28 +342,30 @@ class SportsRankingsMixin:
return self._division_team_ids
def _ranked_positions(self, game: Dict) -> List[int]:
"""Poll positions held by either side: ids first, abbreviations second.
"""Poll positions held by either side: by id, else by abbreviation.
The id table is authoritative whenever the game carries ids and a poll
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.
The abbreviation table stays as the fallback for a game dict with no
ids on it.
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 {}
ids = []
for side in ("home_id", "away_id"):
try:
ids.append(int(game.get(side))) # type: ignore[arg-type] # None: caught below
except (TypeError, ValueError):
ids = []
break
if by_id and ids:
return [rank for rank in (by_id.get(i, 0) for i in ids) if rank]
by_abbr = getattr(self, "_team_rankings_cache", None) or {}
return [rank for rank in (by_abbr.get(game.get("home_abbr"), 0),
by_abbr.get(game.get("away_abbr"), 0)) if rank]
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))
+336
View File
@@ -0,0 +1,336 @@
"""The game dict every scoreboard screen draws (sports family 10).
The scoreboards' ``_extract_game_details_common``, reconciled in
ledmatrix-plugins (family 10) from nine bodies into one, and copied here
under the existing names with the helpers it calls. It lives on the plugins'
``SportsCore``, so there is one mixin, ``SportsViewModelMixin``:
- ``_extract_game_details_common(game_event)``: one ESPN scoreboard event to
``(details, home competitor, away competitor, status, situation)``, or five
Nones when the event has no competition, status, date or home/away pair.
Each sport's ``_extract_game_details`` adds its extras to ``details``.
- ``_status_is_final(status_type)``: whether ``status.type`` is a result.
- ``_score_text(score)``: a competitor's score as integer text.
- ``_team_logo_url(competitor)``, ``_extract_team_record(competitor)``.
- ``_logo_filename(abbr, team_id)``: the seam below.
THE KEYS
--------
Additive only: a key is never renamed or dropped, because every renderer
(``sports.py``'s scorebug, ``game_renderer.py``'s card, ``sports_card``,
``sports_game_renderer``, ``sports_shared``, ``sports_game_over``,
``sports_vegas``...) reads them. A sport's extras (down and distance, innings,
shots, tournament seeds...) belong in its own ``_extract_game_details``.
================== ================== ==========================================
key type value (default)
================== ================== ==========================================
id str | None the event id
game_time str local start, "7:00PM" ("" without a date)
game_date str local "MM/DD"; "M/D" with
``display.use_short_date_format`` ("")
start_time_utc datetime | None aware, UTC; a time without an offset
is read as UTC (None if unparseable)
status_text str ESPN ``status.type.shortDetail``
is_live bool state "in", not a called-off status
is_final bool ``_status_is_final(status.type)``
is_upcoming bool state "pre", or a scheduled status name
is_halftime bool state "halftime" or STATUS_HALFTIME
is_period_break bool STATUS_END_PERIOD
broadcast str ``str()`` of ESPN's field (""); unread
home_abbr/away_abbr str abbreviation, else the name's first 3
home_id/away_id str | None competitor id, else its team's id
home_score/ str ``_score_text``: integer text ("0")
away_score
home_logo_path/ Path ``logo_dir / _logo_filename(abbr, id)``
away_logo_path
home_logo_url/ str | None ``_team_logo_url``: ``team.logo``, else
away_logo_url ``team.logos[0].href`` (None)
home_record/ str ``_extract_team_record``; "0-0" and
away_record "0-0-0" read as "" ("")
is_within_window bool always True; unread
favorite_teams list the resolved favourites, for the card's
result colour ([])
period int 0 until the sport sets it
clock str "" until the sport sets it
================== ================== ==========================================
``period_text`` is not set here: each sport's extractor sets it, and the
renderers read its absence (baseball's) as "draw ESPN's status text", which
is how an extra-inning final reads "Final/10".
THE RULES (decided 2026-10-13)
------------------------------
A game is final when its state is "post", ESPN has not marked it
``completed: false`` (a payload without the flag is judged on state and name
alone), and its status is not one of ``_NOT_PLAYED_STATUS_NAMES``: postponed,
canceled, suspended, abandoned (``_CALLED_OFF_STATUS_NAMES``), delayed, or a
pending forfeit or no-contest. A forfeit is a result. A called-off game is
never live, even while ESPN still reports it "in". Scores are integer text in
every sport: 21, "21.0" and {"value": 21} are "21"; a missing, empty or
non-numeric score is "0". Halftime reads "HALF" with no clock in every
sport's ``period_text`` (each sport's own extractor, not this module).
THE SEAM
--------
``_logo_filename(abbr, team_id)``: the file under ``logo_dir`` a team's logo
is kept in. Default ``<ABBR>.png`` (``LogoDownloader.normalize_abbreviation``);
NRL overrides it with ``<ABBR>_<id>.png`` because its abbreviations are not
unique ("NEW" is two clubs).
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_view_model.py`` fails if a read is added without
being listed here. Every scoreboard ``SportsCore`` supplies all of them.
- ``logger``, ``config`` (``display.use_short_date_format``), ``logo_dir``
(a ``Path``), ``favorite_teams`` (a list or None), ``_get_timezone``
(a callable returning the zone game times are shown in).
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 re
from datetime import datetime
from pathlib import Path
from typing import Any, Callable, ClassVar, Dict, List, Optional, Tuple
import pytz
from src.logo_downloader import LogoDownloader
class SportsViewModelMixin:
"""``SportsCore``'s game dict. See module docstring."""
# The host contract, declared for type checking only.
logger: Any
config: Dict[str, Any]
logo_dir: Path
favorite_teams: Optional[List[str]]
_get_timezone: Callable[[], Any]
#: ESPN status names for a game called off: never live, never a result.
#: ESPN files a postponed or washed-out game under state "post" with both
#: scores "0" (so "post" alone drew it on Recent as "Final 0-0"), and a
#: suspended one can still read "in".
_CALLED_OFF_STATUS_NAMES: ClassVar[frozenset] = frozenset({
"STATUS_POSTPONED", "STATUS_CANCELED", "STATUS_CANCELLED",
"STATUS_SUSPENDED", "STATUS_ABANDONED",
})
#: Status names under state "post" that are not a result. A forfeit
#: (STATUS_FORFEIT) is one; a pending forfeit or no-contest is not.
_NOT_PLAYED_STATUS_NAMES: ClassVar[frozenset] = _CALLED_OFF_STATUS_NAMES | frozenset({
"STATUS_DELAYED", "STATUS_FORFEIT_PENDING", "STATUS_NO_CONTEST_PENDING",
})
@classmethod
def _status_is_final(cls, status_type: Any) -> bool:
"""Whether an ESPN ``status.type`` is a game played to a result.
State "post", a ``completed`` flag that is not False, and a name
outside ``_NOT_PLAYED_STATUS_NAMES``. A payload without the flag is
judged on state and name alone; ESPN sends False for a postponement.
"""
if not isinstance(status_type, dict) or status_type.get("state") != "post":
return False
if status_type.get("completed") is False:
return False
return str(status_type.get("name") or "").upper() not in cls._NOT_PLAYED_STATUS_NAMES
def _score_text(self, score: Any) -> str:
"""A competitor's score as integer text: "21" for 21, "21.0" or
{"value": 21.0}; "0" when it is missing or holds no number."""
if isinstance(score, dict):
value = score.get("value", 0)
score = value if value != 0 else score.get("displayValue", score.get("score", 0))
if isinstance(score, str):
text = score.strip()
try:
score = float(text)
except ValueError:
digits = re.findall(r"\d+", text)
if not digits:
if text:
self.logger.warning(f"Could not extract score from string: {text!r}")
return "0"
score = float(digits[0])
if score is None:
return "0"
try:
return str(int(float(score)))
except (TypeError, ValueError, OverflowError) as e:
self.logger.warning(f"Error extracting score: {e}, score value: {score!r}")
return "0"
@staticmethod
def _team_logo_url(competitor: Dict) -> Optional[str]:
"""The team's ``logo``, else the first of its ``logos``, else None."""
team = competitor.get("team") or {}
logos = team.get("logos") or []
first = logos[0] if logos and isinstance(logos[0], dict) else {}
return team.get("logo") or first.get("href")
@staticmethod
def _extract_team_record(team_data: Dict) -> str:
"""Extract the overall record string from a competitor/team object.
The ESPN scoreboard API uses ``records`` (plural) with a ``summary``
field, while the team-schedule API uses ``record`` (singular) with a
``displayValue`` field. This helper handles both formats so that
records display correctly regardless of which API provided the data.
"""
# Scoreboard API format: records[0].summary (e.g. "10-2")
records = team_data.get("records")
if records and isinstance(records, list) and len(records) > 0:
return records[0].get("summary", "") # type: ignore[no-any-return]
# Team-schedule API format: record[0].displayValue (e.g. "7-0")
record = team_data.get("record")
if record and isinstance(record, list) and len(record) > 0:
return record[0].get("displayValue", record[0].get("summary", "")) # type: ignore[no-any-return]
return ""
def _logo_filename(self, abbr: str, team_id: Any) -> str:
"""Seam: the file a team's logo is kept in under ``logo_dir``."""
return f"{LogoDownloader.normalize_abbreviation(abbr)}.png"
def _extract_game_details_common(
self, game_event: Dict
) -> Tuple[Optional[Dict], Optional[Dict], Optional[Dict], Optional[Dict], Optional[Dict]]:
"""The game dict every renderer reads, built from one ESPN event.
Returns (details, home competitor, away competitor, status,
situation), or five Nones when the event has no competition, status,
date or home/away pair. Keys are additive only: never rename or drop
one. Sport extras belong in the sport's ``_extract_game_details``.
"""
if not game_event:
return None, None, None, None, None
try:
competitions = game_event.get("competitions", [])
if not competitions:
self.logger.warning(f"No competitions data for game {game_event.get('id', 'unknown')}")
return None, None, None, None, None
competition = competitions[0]
status = competition.get("status")
if not status:
self.logger.warning(f"No status data for game {game_event.get('id', 'unknown')}")
return None, None, None, None, None
competitors = competition.get("competitors", [])
game_date_str = game_event["date"]
situation = competition.get("situation")
start_time_utc = None
try:
dt = datetime.fromisoformat(game_date_str.replace("Z", "+00:00"))
# A time without an offset is read as UTC, as ESPN sends it.
start_time_utc = (dt.replace(tzinfo=pytz.UTC) if dt.tzinfo is None
else dt.astimezone(pytz.UTC))
except ValueError:
self.logger.warning(f"Could not parse game date: {game_date_str}")
home_team = next(
(c for c in competitors if c.get("homeAway") == "home"), None
)
away_team = next(
(c for c in competitors if c.get("homeAway") == "away"), None
)
if not home_team or not away_team:
self.logger.warning(
f"Could not find home or away team in event: {game_event.get('id')}"
)
return None, None, None, None, None
try:
home_abbr = home_team["team"]["abbreviation"]
except KeyError:
home_abbr = home_team["team"]["name"][:3]
try:
away_abbr = away_team["team"]["abbreviation"]
except KeyError:
away_abbr = away_team["team"]["name"][:3]
home_id = home_team.get("id") or home_team["team"].get("id")
away_id = away_team.get("id") or away_team["team"].get("id")
game_time, game_date = "", ""
if start_time_utc:
local_time = start_time_utc.astimezone(self._get_timezone())
game_time = local_time.strftime("%I:%M%p").lstrip("0")
if self.config.get("display", {}).get("use_short_date_format", False):
# %-m/%-d are glibc extensions: strftime raises ValueError on
# Windows and musl. Build the same text portably instead.
game_date = f"{local_time.month}/{local_time.day}"
else:
game_date = local_time.strftime("%m/%d")
home_record = self._extract_team_record(home_team)
away_record = self._extract_team_record(away_team)
# Don't show "0-0" records - set to blank instead
if home_record in {"0-0", "0-0-0"}:
home_record = ""
if away_record in {"0-0", "0-0-0"}:
away_record = ""
status_type = status["type"]
status_name = str(status_type.get("name") or "").upper()
details = {
"id": game_event.get("id"),
"game_time": game_time,
"game_date": game_date,
"start_time_utc": start_time_utc,
"status_text": status_type["shortDetail"], # e.g. "Final", "7:30 PM", "Q1 12:34"
"is_live": (status_type["state"] == "in"
and status_name not in self._CALLED_OFF_STATUS_NAMES),
"is_final": self._status_is_final(status_type),
"is_upcoming": (
status_type["state"] == "pre"
or status_type["name"].lower()
in ["scheduled", "pre-game", "status_scheduled"]
),
"is_halftime": status_type["state"] == "halftime"
or status_type["name"] == "STATUS_HALFTIME",
"is_period_break": status_type["name"] == "STATUS_END_PERIOD",
# str(): ESPN's undocumented payloads have served this field as
# an object in some sports. Plain game metadata: nothing reads it.
"broadcast": str(competition.get("broadcast") or ""),
"home_abbr": home_abbr,
"home_id": home_id,
"home_score": self._score_text(home_team.get("score")),
"home_logo_path": self.logo_dir / Path(self._logo_filename(home_abbr, home_id)),
"home_logo_url": self._team_logo_url(home_team),
"home_record": home_record,
"away_record": away_record,
"away_abbr": away_abbr,
"away_id": away_id,
"away_score": self._score_text(away_team.get("score")),
"away_logo_path": self.logo_dir / Path(self._logo_filename(away_abbr, away_id)),
"away_logo_url": self._team_logo_url(away_team),
"is_within_window": True, # Whether game is within display window
# The resolved favorites for this league (dynamic groups such
# as AP_TOP_25 already expanded). Carried on the game so the
# scroll/Vegas renderer, which only ever sees the game dict and
# the raw config, can color a final score by the result.
"favorite_teams": list(self.favorite_teams or []),
# Defaults; a sport's _extract_game_details sets its own. No
# period_text: the renderers read its absence (baseball) as
# "draw ESPN's status text", e.g. "Final/10".
"period": 0,
"clock": "",
}
return details, home_team, away_team, status, situation
except Exception as e:
self.logger.error(
f"Error extracting game details: {e} from event: {game_event.get('id')}",
exc_info=True,
)
return None, None, None, None, None
+2 -1
View File
@@ -32,7 +32,8 @@ const UNIT = ['unit/test_list_filter.js', 'unit/test_render_cards.js',
'unit/test_page_registry.js', 'unit/test_core_modules.js',
'unit/test_overview_reconciliation_poll.js',
'unit/test_display_partial_ids.js',
'unit/test_general_web_login_token.js'];
'unit/test_general_web_login_token.js',
'unit/test_general_display_autostart.js'];
const DOM = ['dom/test_installed_dom.js', 'dom/test_store_dom.js', 'dom/test_no_double_fetch.js',
'dom/test_tools_sections.js', 'dom/test_cache_page.js',
'dom/test_durations_page.js', 'dom/test_operation_history_page.js',
@@ -0,0 +1,77 @@
// The General tab's "Start Display on Boot" checkbox (setDisplayAutostart in
// static/v3/js/pages/general.js) posts enable_autostart / disable_autostart
// to /api/v3/system/action and puts the box back when that is refused --
// including the route's HTTP-200 {status: "error"} answer for a failed
// systemctl (e.g. the sudo rule is missing).
//
// Fake fetch, no jsdom and no server needed.
const path = require('path');
const { pathToFileURL } = require('url');
const JS = path.resolve(__dirname, '../../../web_interface/static/v3/js');
const load = f => import(pathToFileURL(path.join(JS, f)).href);
let pass = 0, fail = 0;
const ok = (label, cond, extra) => cond
? (pass++, console.log(' ok ' + label))
: (fail++, console.log(' FAIL ' + label + (extra !== undefined ? ' ' + JSON.stringify(extra) : '')));
async function run(checked, answer) {
const { createApi } = await load('core/api.js');
const general = await load('pages/general.js');
const sent = [];
const notes = [];
const fetch = (url, init) => {
sent.push([url, JSON.parse(init.body)]);
if (answer === 'network') return Promise.reject(new TypeError('Failed to fetch'));
return Promise.resolve({
ok: answer.ok, status: answer.ok ? 200 : 500,
headers: { get: () => null },
text: () => Promise.resolve(JSON.stringify(answer.body)),
});
};
const ctx = { root: {}, state: {}, signal: { aborted: false },
api: createApi({ fetch }), notify: (m, t) => notes.push([m, t]) };
const box = { checked, disabled: false };
await general.setDisplayAutostart(ctx, box);
return { box, sent, notes };
}
(async () => {
console.log('\n── General tab: Start Display on Boot ──');
{
const t = await run(false, { ok: true, body: { status: 'success', message: 'ok' } });
ok('unchecking posts disable_autostart',
t.sent.length === 1 && t.sent[0][0] === '/api/v3/system/action' && t.sent[0][1].action === 'disable_autostart', t.sent);
ok('...stays unchecked and re-enabled', t.box.checked === false && t.box.disabled === false);
ok('...and reports success', t.notes.some(([, type]) => type === 'success'), t.notes);
}
{
const t = await run(true, { ok: true, body: { status: 'success' } });
ok('checking posts enable_autostart', t.sent[0][1].action === 'enable_autostart', t.sent);
}
{
const t = await run(false, { ok: true, body: { status: 'error', message: 'sudo: a password is required' } });
ok('a failed systemctl (HTTP 200, status error) puts the box back', t.box.checked === true && !t.box.disabled);
ok('...and shows the route\'s message',
t.notes.some(([m, type]) => type === 'error' && /sudo/.test(m)), t.notes);
}
{
const t = await run(true, { ok: false, body: { status: 'error', message: 'boom' } });
ok('an HTTP error puts the box back', t.box.checked === false && !t.box.disabled);
}
{
const t = await run(true, 'network');
ok('a network failure puts the box back', t.box.checked === false && !t.box.disabled);
ok('...and reports it', t.notes.some(([, type]) => type === 'error'), t.notes);
}
console.log(`\n${pass} passed, ${fail} failed\n`);
process.exit(fail ? 1 : 0);
})().catch(e => { console.log('HARNESS ERROR: ' + e.stack); process.exit(1); });
+9
View File
@@ -392,6 +392,15 @@ 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")
+60
View File
@@ -0,0 +1,60 @@
"""The General tab's "Start Display on Boot" toggle reads systemd's state.
ledmatrix.service enabled -> checked, disabled -> unchecked, unreadable (no
systemctl, unit not installed) -> no toggle. The toggle itself posts the
existing enable_autostart / disable_autostart system actions, whose sudo
grants test_systemctl_sudoers_alignment.py already checks.
"""
import subprocess
import sys
from pathlib import Path
import pytest
PROJECT_ROOT = Path(__file__).parent.parent
sys.path.insert(0, str(PROJECT_ROOT))
from test.test_web_settings_ui import client # noqa: F401,E402
from web_interface.blueprints import pages_v3 # noqa: E402
REAL_RUN = subprocess.run
UNIT_QUERY = ['systemctl', 'is-enabled', 'ledmatrix.service']
def _fake_systemctl(monkeypatch, answer):
"""Answer the unit query with `answer` (stdout, or an exception to
raise); every other command (the update channel's git) runs for real."""
calls = []
def run(cmd, *args, **kwargs):
if cmd != UNIT_QUERY:
return REAL_RUN(cmd, *args, **kwargs)
calls.append(cmd)
if isinstance(answer, Exception):
raise answer
return subprocess.CompletedProcess(cmd, 0, stdout=answer, stderr='')
monkeypatch.setattr(pages_v3.subprocess, 'run', run)
return calls
def _toggle(body):
start = body.find('data-action="display-autostart"')
return None if start < 0 else body[start:body.find('>', start)]
@pytest.mark.parametrize('stdout, checked', [('enabled\n', True), ('disabled\n', False)])
def test_toggle_reflects_the_unit(client, monkeypatch, stdout, checked):
calls = _fake_systemctl(monkeypatch, stdout)
tag = _toggle(client.get('/v3/partials/general').get_data(as_text=True))
assert calls == [UNIT_QUERY]
assert tag is not None
assert ('checked' in tag) is checked
@pytest.mark.parametrize('outcome', [FileNotFoundError(), 'not-found\n'])
def test_toggle_hidden_when_state_unreadable(client, monkeypatch, outcome):
_fake_systemctl(monkeypatch, outcome)
body = client.get('/v3/partials/general').get_data(as_text=True)
assert _toggle(body) is None
assert 'name="plugins_directory"' in body # rest of the tab still renders
+399
View File
@@ -0,0 +1,399 @@
"""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)] == []
+104
View File
@@ -0,0 +1,104 @@
"""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
+16
View File
@@ -173,6 +173,11 @@ def test_only_a_league_with_a_poll_is_asked(clock, league, asked):
{"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)
@@ -320,6 +325,17 @@ def test_ranked_and_best_rank(clock, 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"),
+346
View File
@@ -0,0 +1,346 @@
"""src.common.sports_view_model: the game dict built from one ESPN event.
The cases follow ledmatrix-plugins' ``scripts/test_view_model.py`` (the table
the family 10 reconcile was checked against); the expected values are its
``EXPECTED_COMMON`` cells, which agree across all nine scoreboards once the
reconcile landed (nrl differs only through the ``_logo_filename`` seam and
its id favourites, which are host data). Each row is one change to one base
event, a scheduled AAA (home, id 1) v BBB (away, id 2) at 7:00 PM EDT, and
lists only the keys whose value differs from the base game. The host shows
times in America/New_York. Nothing reaches the network.
"""
import ast
import copy
import logging
from datetime import datetime
from pathlib import Path
import pytest
import pytz
from src.common import sports_view_model
from src.common.sports_view_model import SportsViewModelMixin
LOGO_DIR = Path("logos")
class Host(SportsViewModelMixin):
def __init__(self, config=None):
self.logger = logging.getLogger("test_sports_view_model")
self.config = config or {}
self.logo_dir = LOGO_DIR
self.favorite_teams = ["AAA"]
def _get_timezone(self):
return pytz.timezone("America/New_York")
def competitor(home_away, tid, abbr, name, record):
return {"id": tid, "homeAway": home_away, "score": "0",
"team": {"id": tid, "abbreviation": abbr, "name": name,
"logo": f"https://a.espncdn.com/i/teamlogos/{abbr.lower()}.png"},
"records": [{"type": "total", "summary": record}]}
def base_event():
return {"id": "401", "date": "2026-10-12T23:00Z", "competitions": [{
"id": "4011", "broadcast": "ESPN",
"status": {"displayClock": "0:00", "period": 0, "type": {
"name": "STATUS_SCHEDULED", "state": "pre", "completed": False,
"shortDetail": "10/12 - 7:00 PM EDT"}},
"competitors": [competitor("home", "1", "AAA", "Alpha", "3-1"),
competitor("away", "2", "BBB", "Bravo", "2-2")]}]}
BASE = {
"id": "401", "game_time": "7:00PM", "game_date": "10/12",
"start_time_utc": datetime(2026, 10, 12, 23, 0, tzinfo=pytz.UTC),
"status_text": "10/12 - 7:00 PM EDT", "is_live": False, "is_final": False,
"is_upcoming": True, "is_halftime": False, "is_period_break": False,
"broadcast": "ESPN", "home_abbr": "AAA", "home_id": "1", "home_score": "0",
"home_logo_path": LOGO_DIR / "AAA.png",
"home_logo_url": "https://a.espncdn.com/i/teamlogos/aaa.png",
"home_record": "3-1", "away_record": "2-2", "away_abbr": "BBB", "away_id": "2",
"away_score": "0", "away_logo_path": LOGO_DIR / "BBB.png",
"away_logo_url": "https://a.espncdn.com/i/teamlogos/bbb.png",
"is_within_window": True, "favorite_teams": ["AAA"],
"period": 0, "clock": "",
}
def comp(e):
return e["competitions"][0]
def side(e, home_away):
return next(c for c in comp(e)["competitors"] if c["homeAway"] == home_away)
def status(state, name, short, completed=False, drop_completed=False):
def edit(e):
st = {"name": name, "state": state, "completed": completed, "shortDetail": short}
if drop_completed:
del st["completed"]
comp(e)["status"] = {"displayClock": "5:32", "period": 2, "type": st}
return edit
def scores(home, away):
def edit(e):
side(e, "home")["score"], side(e, "away")["score"] = home, away
return edit
def drop_scores(e):
for c in comp(e)["competitors"]:
del c["score"]
def both(fn):
def edit(e):
for c in comp(e)["competitors"]:
fn(c)
return edit
def dated(date):
def edit(e):
e["date"] = date
return edit
def chain(*edits):
def edit(e):
for fn in edits:
fn(e)
return edit
def utc(*args):
return datetime(*args, tzinfo=pytz.UTC)
FINAL = status("post", "STATUS_FINAL", "Final", completed=True)
POST = {"is_upcoming": False, "is_final": True, "status_text": "Final"}
#: label -> (edit, extra host config, keys that differ from BASE; None: no game)
ROWS = {
"pre": (None, None, {}),
"short date format": (dated("2026-03-05T23:00Z"), {"display": {"use_short_date_format": True}},
{"game_date": "3/5", "game_time": "6:00PM",
"start_time_utc": utc(2026, 3, 5, 23, 0)}),
"in progress": (status("in", "STATUS_IN_PROGRESS", "5:32 - 2nd"), None,
{"is_live": True, "is_upcoming": False, "status_text": "5:32 - 2nd"}),
"halftime": (status("in", "STATUS_HALFTIME", "Halftime"), None,
{"is_live": True, "is_halftime": True, "is_upcoming": False,
"status_text": "Halftime"}),
"end of period": (status("in", "STATUS_END_PERIOD", "End of 1st"), None,
{"is_live": True, "is_period_break": True, "is_upcoming": False,
"status_text": "End of 1st"}),
"suspended while in": (status("in", "STATUS_SUSPENDED", "Suspended"), None,
{"is_upcoming": False, "status_text": "Suspended"}),
"final": (FINAL, None, POST),
"final, no completed flag": (status("post", "STATUS_FINAL", "Final", drop_completed=True),
None, POST),
"final, completed false": (status("post", "STATUS_FINAL", "Final"), None,
{"is_upcoming": False, "status_text": "Final"}),
"postponed": (status("post", "STATUS_POSTPONED", "Postponed"), None,
{"is_upcoming": False, "status_text": "Postponed"}),
"canceled": (status("post", "STATUS_CANCELED", "Canceled"), None,
{"is_upcoming": False, "status_text": "Canceled"}),
"delayed, completed": (status("post", "STATUS_DELAYED", "Delayed", completed=True), None,
{"is_upcoming": False, "status_text": "Delayed"}),
"forfeit, completed": (status("post", "STATUS_FORFEIT", "Forfeit", completed=True), None,
{**POST, "status_text": "Forfeit"}),
"forfeit pending": (status("post", "STATUS_FORFEIT_PENDING", "Forfeit", completed=True),
None, {"is_upcoming": False, "status_text": "Forfeit"}),
"scores int": (chain(FINAL, scores(21, 3)), None, {**POST, "home_score": "21", "away_score": "3"}),
"scores '21.0'": (chain(FINAL, scores("21.0", " 3 ")), None,
{**POST, "home_score": "21", "away_score": "3"}),
"scores dict": (chain(FINAL, scores({"value": 21.0, "displayValue": "21"},
{"value": 0, "displayValue": "4"})), None,
{**POST, "home_score": "21", "away_score": "4"}),
"scores None and ''": (chain(FINAL, scores(None, "")), None, POST),
"scores missing": (chain(FINAL, drop_scores), None, POST),
"scores not numbers": (chain(FINAL, scores("SO", "12 (3)")), None, {**POST, "away_score": "12"}),
"records 0-0 and 0-0-0": (lambda e: (side(e, "home")["records"][0].update(summary="0-0"),
side(e, "away")["records"][0].update(summary="0-0-0")),
None, {"home_record": "", "away_record": ""}),
"records missing": (both(lambda c: c.pop("records")), None,
{"home_record": "", "away_record": ""}),
"record, team-schedule shape": (both(lambda c: (c.pop("records"),
c.update(record=[{"displayValue": "7-0"}]))),
None, {"home_record": "7-0", "away_record": "7-0"}),
"no abbreviation": (both(lambda c: c["team"].pop("abbreviation")), None,
{"home_abbr": "Alp", "away_abbr": "Bra",
"home_logo_path": LOGO_DIR / "ALP.png",
"away_logo_path": LOGO_DIR / "BRA.png"}),
"logos array only": (both(lambda c: c["team"].update(
logo=None, logos=[{"href": "https://a.espncdn.com/x.png"}])), None,
{"home_logo_url": "https://a.espncdn.com/x.png", "away_logo_url": "https://a.espncdn.com/x.png"}),
"logo and logos": (both(lambda c: c["team"].update(logos=[{"href": "https://a.espncdn.com/x.png"}])),
None, {}),
"no logos": (both(lambda c: c["team"].pop("logo")), None,
{"home_logo_url": None, "away_logo_url": None}),
"competitor without id": (lambda e: side(e, "home").pop("id"), None, {}),
"no ids at all": (lambda e: (side(e, "home").pop("id"), side(e, "home")["team"].pop("id")),
None, {"home_id": None}),
"broadcast object": (lambda e: comp(e).update(broadcast={"names": ["ESPN"]}), None,
{"broadcast": "{'names': ['ESPN']}"}),
"broadcast missing": (lambda e: comp(e).pop("broadcast"), None, {"broadcast": ""}),
"date 'TBD'": (dated("TBD"), None, {"game_date": "", "game_time": "", "start_time_utc": None}),
"date +10:00": (dated("2026-10-13T19:40+10:00"), None,
{"game_date": "10/13", "game_time": "5:40AM",
"start_time_utc": utc(2026, 10, 13, 9, 40)}),
"date naive, read as UTC": (dated("2026-10-12T23:00:00"), None, {}),
"late UTC, previous local day": (dated("2026-10-13T03:30Z"), None,
{"game_time": "11:30PM", "start_time_utc": utc(2026, 10, 13, 3, 30)}),
"DST ends, 05:30Z (EDT)": (dated("2026-11-01T05:30Z"), None,
{"game_date": "11/01", "game_time": "1:30AM",
"start_time_utc": utc(2026, 11, 1, 5, 30)}),
"DST ends, 06:30Z (EST)": (dated("2026-11-01T06:30Z"), None,
{"game_date": "11/01", "game_time": "1:30AM",
"start_time_utc": utc(2026, 11, 1, 6, 30)}),
"DST starts, 07:30Z": (dated("2026-03-08T07:30Z"), None,
{"game_date": "03/08", "game_time": "3:30AM",
"start_time_utc": utc(2026, 3, 8, 7, 30)}),
"naive, repeated DST hour": (dated("2026-11-01T01:30:00"), None,
{"game_date": "10/31", "game_time": "9:30PM",
"start_time_utc": utc(2026, 11, 1, 1, 30)}),
"one competitor": (lambda e: comp(e)["competitors"].pop(), None, None),
"no competitors": (lambda e: comp(e).pop("competitors"), None, None),
"no competitions": (lambda e: e.update(competitions=[]), None, None),
"no status": (lambda e: comp(e).pop("status"), None, None),
"no date": (lambda e: e.pop("date"), None, None),
}
@pytest.mark.parametrize("label", ROWS)
def test_the_game_dict(label):
edit, config, changed = ROWS[label]
event = base_event()
if edit:
edit(event)
details, home, away, st, situation = Host(config)._extract_game_details_common(event)
if changed is None:
assert (details, home, away, st, situation) == (None,) * 5
return
assert details == {**BASE, **changed}
assert home is side(event, "home") and away is side(event, "away")
assert st is comp(event)["status"] and situation is None
def test_an_empty_event_is_no_game():
assert Host()._extract_game_details_common({}) == (None,) * 5
def test_the_situation_is_handed_back():
event = base_event()
comp(event)["situation"] = {"possession": "1"}
assert Host()._extract_game_details_common(event)[4] == {"possession": "1"}
def test_the_logo_filename_seam():
class ById(Host):
def _logo_filename(self, abbr, team_id):
return f"{abbr}_{team_id}.png"
details = ById()._extract_game_details_common(base_event())[0]
assert (details["home_logo_path"], details["away_logo_path"]) == (
LOGO_DIR / "AAA_1.png", LOGO_DIR / "BBB_2.png")
def test_favourites_are_a_copy():
host = Host()
host.favorite_teams = None
assert host._extract_game_details_common(base_event())[0]["favorite_teams"] == []
host.favorite_teams = ["AAA"]
details = host._extract_game_details_common(base_event())[0]
assert details["favorite_teams"] == ["AAA"] and details["favorite_teams"] is not host.favorite_teams
@pytest.mark.parametrize("status_type, final", [
({"state": "post", "name": "STATUS_FINAL", "completed": True}, True),
({"state": "post", "name": "STATUS_FINAL"}, True),
({"state": "post", "name": "status_final", "completed": True}, True),
({"state": "post", "name": "STATUS_FINAL", "completed": False}, False),
({"state": "post", "name": "STATUS_FORFEIT", "completed": True}, True),
({"state": "in", "name": "STATUS_IN_PROGRESS"}, False),
({"state": "post"}, True),
(None, False),
("post", False),
] + [({"state": "post", "name": n, "completed": True}, False)
for n in sorted(SportsViewModelMixin._NOT_PLAYED_STATUS_NAMES)])
def test_status_is_final(status_type, final):
assert SportsViewModelMixin._status_is_final(status_type) is final
def test_the_status_lists():
assert SportsViewModelMixin._CALLED_OFF_STATUS_NAMES == {
"STATUS_POSTPONED", "STATUS_CANCELED", "STATUS_CANCELLED",
"STATUS_SUSPENDED", "STATUS_ABANDONED"}
assert SportsViewModelMixin._NOT_PLAYED_STATUS_NAMES == (
SportsViewModelMixin._CALLED_OFF_STATUS_NAMES
| {"STATUS_DELAYED", "STATUS_FORFEIT_PENDING", "STATUS_NO_CONTEST_PENDING"})
@pytest.mark.parametrize("score, text", [
(21, "21"), (21.9, "21"), ("21", "21"), ("21.0", "21"), (" 7 ", "7"), ("", "0"),
(None, "0"), ("SO", "0"), ("12 (3)", "12"), ({"value": 21.0}, "21"),
({"value": 0, "displayValue": "4"}, "4"), ({"value": None}, "0"), ([], "0"),
(float("nan"), "0"), (float("inf"), "0"),
])
def test_score_text(score, text):
assert Host()._score_text(score) == text
def test_records_do_not_mutate_the_event():
event = base_event()
before = copy.deepcopy(event)
Host()._extract_game_details_common(event)
assert event == before
# ---------------------------------------------------------------------------
# Carrier and host contract
# ---------------------------------------------------------------------------
MEMBERS = ["_CALLED_OFF_STATUS_NAMES", "_NOT_PLAYED_STATUS_NAMES",
"_extract_game_details_common", "_extract_team_record", "_logo_filename",
"_score_text", "_status_is_final", "_team_logo_url"]
def _self_reads():
tree = ast.parse(Path(sports_view_model.__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 in ("self", "cls")}
class TestHostContract:
def test_the_mixin_carries_only_the_family(self):
assert sorted(n for n in vars(SportsViewModelMixin) 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_view_model.__doc__)
assert undocumented == [], f"read but not in the host contract: {undocumented}"
def test_every_key_is_in_the_key_table(self):
doc = sports_view_model.__doc__
table = doc[doc.index("THE KEYS"):doc.index("THE RULES")]
missing = [k for k in BASE if k not in table]
assert missing == []
def test_no_other_shared_mixin_defines_these(self):
from src.common import (sports_display_rules, sports_favorites, sports_fetch,
sports_helpers, sports_live_fetch, 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, sports_fetch.SportsFetchMixin,
sports_live_fetch.SportsLiveFetchMixin,
sports_display_rules.SportsGameRulesMixin]
assert [m for m in MEMBERS if any(m in vars(o) for o in others)] == []
+134
View File
@@ -0,0 +1,134 @@
"""sports_view_model still matches every plugin copy.
``src.common.sports_view_model`` was copied from the scoreboards once family
10 had made ``_extract_game_details_common`` one body in all nine
``SportsCore`` classes, with the helpers it calls (``_status_is_final``,
``_score_text``, ``_team_logo_url``, ``_extract_team_record``, the
``_logo_filename`` seam) and the two status-name sets. The plugins delete
their copies once they floor on the release that ships this module. nrl
overrides the seam on ``BaseNrlManager``, not on ``SportsCore``. 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_view_model
REPO = Path(__file__).resolve().parents[1]
SPORTS = ("afl", "baseball", "basketball", "football", "hockey", "lacrosse",
"nrl", "soccer", "ufc")
CARRIER, MIXIN = "SportsCore", "SportsViewModelMixin"
METHODS = ("_extract_game_details_common", "_status_is_final", "_score_text",
"_team_logo_url", "_extract_team_record", "_logo_filename")
CONSTANTS = ("_CALLED_OFF_STATUS_NAMES", "_NOT_PLAYED_STATUS_NAMES")
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 assigned to ``name`` in the class body, as an AST dump."""
return next((ast.dump(n.value) for n in cls.body
if isinstance(n, ast.AnnAssign) and getattr(n.target, "id", None) == name),
None)
def _adopted(source, tree):
"""The plugin imports core's module and its carrier inherits the mixin."""
bases = _class(tree, CARRIER).bases
return (sports_view_model.__name__ in source
and any(getattr(b, "id", getattr(b, "attr", None)) == MIXIN for b in bases))
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_view_model.__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 _adopted(source, tree), (
f"{sport}: no {name} on {CARRIER}, and {CARRIER} does not inherit {MIXIN}")
else:
assert _fingerprint(copy) == _fingerprint(_method(OURS, name)), (
f"{CARRIER}.{name} in {sport} differs from sports_view_model. "
f"Port the change to both, or stop treating it as shared.")
@pytest.mark.parametrize("sport, name", [(s, n) for s in SPORTS for n in CONSTANTS])
def test_every_remaining_status_set_matches(sport, name):
source, tree = _plugin_tree(_plugins_root(), sport)
copy = _constant(_class(tree, CARRIER), name)
if copy is None:
assert _adopted(source, tree), (
f"{sport}: no {name} on {CARRIER}, and {CARRIER} does not inherit {MIXIN}")
else:
assert copy == _constant(OURS, name), f"{CARRIER}.{name} in {sport} differs"
@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
+17
View File
@@ -4,6 +4,7 @@ from html.parser import HTMLParser
import json
import logging
import re
import subprocess
from pathlib import Path
# Strict allowlists for URL-derived values used in path and script operations.
@@ -487,9 +488,25 @@ def _load_general_partial():
main_config=main_config,
auto_update_status=auto_update_status,
update_channel_status=update_channel_status,
display_autostart=_display_autostart_state(),
web_login=_web_login_state())
def _display_autostart_state():
"""Whether ledmatrix.service starts at boot; None hides the toggle.
systemd's enablement is the setting itself (the installer enables the
unit), so nothing is stored in config.json. None when it cannot be read:
no systemctl (a dev machine) or the unit is not installed.
"""
try:
result = subprocess.run(['systemctl', 'is-enabled', 'ledmatrix.service'],
capture_output=True, text=True, timeout=5)
except (OSError, subprocess.SubprocessError):
return None
return {'enabled': True, 'disabled': False}.get(result.stdout.strip())
def _web_login_state():
"""What the General tab's Security section shows; None hides it.
@@ -191,6 +191,28 @@ export function revokeToken(ctx, id, name) {
}).catch(failed(ctx));
}
/**
* The "Start Display on Boot" checkbox: enable or disable ledmatrix.service
* at once. A refusal (e.g. the sudo rule is missing) puts the box back.
*/
export function setDisplayAutostart(ctx, box) {
const on = box.checked;
box.disabled = true;
return send(ctx, 'POST', '/api/v3/system/action', { action: on ? 'enable_autostart' : 'disable_autostart' }).then(function(res) {
if (!res) return false;
const ok = res.ok && res.d.status === 'success';
if (!ok) box.checked = !on;
ctx.notify(ok ? (on ? 'The display will start on boot' : 'The display will not start on boot')
: (res.d.message || 'Could not change display autostart'), ok ? 'success' : 'error');
return ok;
}).catch(function(err) {
box.checked = !on;
failed(ctx)(err);
}).finally(function() {
box.disabled = false;
});
}
const FORM_ACTIONS = { 'set-password': setPassword, 'disable-login': disableLogin, 'create-token': createToken };
function drawTimezone(root, ctx) {
@@ -238,6 +260,10 @@ export function init(root, ctx) {
if (row) revokeToken(ctx, row.dataset.tokenId, button.dataset.tokenName);
}
}, on);
root.addEventListener('change', function(event) {
const box = event.target;
if (box && box.dataset && box.dataset.action === 'display-autostart') setDisplayAutostart(ctx, box);
}, on);
drawTimezone(root, ctx);
active = ctx;
}
@@ -12,6 +12,22 @@
{{ ui.settings_filter() }}
{# Display autostart is systemd's enable/disable of ledmatrix.service, not a
config.json key: outside the form so Save never posts it. general.js
applies it on change. Hidden when the unit's state cannot be read. #}
{% if display_autostart is not none %}
<div class="form-group mb-6" id="setting-general-display_autostart" data-setting-key="display_autostart">
<label class="flex items-center">
<input type="checkbox"
data-action="display-autostart"
{% if display_autostart %}checked{% endif %}
class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded">
<span class="ml-2 text-sm font-medium text-gray-900">Start Display on Boot</span>
{{ ui.help_tip('Start the LED display automatically when the device powers on.\nApplies right away; no Save needed.\nWhen off, start it with Start Display on the Overview tab. The web interface still starts on boot.\nDefault: on.', 'Start Display on Boot') }}
</label>
</div>
{% endif %}
<form hx-post="/api/v3/config/main"
hx-ext="json-enc"
hx-headers='{"Content-Type": "application/json"}'