From 568cb6d77f26277f8589b8530ebae98e858408f0 Mon Sep 17 00:00:00 2001 From: Chuck <33324927+ChuckBuilds@users.noreply.github.com> Date: Sun, 23 Aug 2026 11:42:33 -0400 Subject: [PATCH 01/29] perf(vegas): report the frame rate when it is worth reporting (#487) Vegas logged an FPS line at INFO every five seconds for the whole of every run. Measured over two hours on a rig: 1410 samples, 98.5% of them within 10% of target. The 1.5% that were not included a reading of 8.6fps against a target of 60 -- a real stall, completely invisible inside 1389 lines reading "59.6". INFO is now reserved for a shortfall, the recovery from one, and a slow heartbeat so a healthy marquee still shows a pulse. Scroll-progress tracing drops to DEBUG for the same reason: it runs for the whole of every scroll and is what you turn debug on to watch. Three review findings, all fixed here. 1. Per-frame timing used the wall clock (critical). The loop sleeps the remainder of each frame budget: frame_elapsed = - frame_started time.sleep(max(0.0, frame_interval - frame_elapsed)) These devices have no RTC, so the clock jumps by however wrong boot time was when NTP first syncs. A backward step makes frame_elapsed negative, `frame_interval - frame_elapsed` then exceeds the whole budget, and the render loop stalls for the size of the correction. A forward step instead inflates the p99 and worst-frame figures this telemetry exists to report. Both per-frame timestamps are monotonic now. start_time stays wall-clock: it is only used for the iteration duration report, where a human-readable clock is the point. 2. FPS health state reset every iteration. last_fps_health_log and was_degraded were locals of run_iteration(), which is called once per cycle. Starting at 0.0 against a monotonic clock, `due` was true on the first sample of every iteration, so the 300s heartbeat degenerated into one report per cycle -- reintroducing the noise this change is about. A recovery that crossed an iteration boundary was never reported either, since was_degraded had already gone back to False. Both now live on the coordinator and reset in start(). 3. The degraded threshold read as an off-by-one. 90% of target is deliberate -- a marquee jitters constantly, so "anything below target" would report forever and mean nothing -- but nothing said so, leaving 55fps-against-60 looking like a missed case. The constant now states the band and gives that exact example. Also drops two soccer logo PNGs that a `git add -A` had swept into the first commit. They are unreferenced, unrelated to frame-rate telemetry, and 210KB. Verified: each fix mutation-checked -- restoring the wall clock on either per-frame timestamp, or making the health state local again, fails the new tests. 566 passed across the vegas, coordinator and scroll suites. Claude-Session: https://claude.ai/code/session_01STMbQE4YctTacQXfbYqKuW Co-authored-by: Claude Opus 5 (1M context) --- src/common/scroll_helper.py | 4 ++ src/vegas_mode/coordinator.py | 86 ++++++++++++++++++++++++++---- test/test_vegas_fps_health.py | 99 +++++++++++++++++++++++++++++++++++ 3 files changed, 178 insertions(+), 11 deletions(-) create mode 100644 test/test_vegas_fps_health.py diff --git a/src/common/scroll_helper.py b/src/common/scroll_helper.py index 88c6d498..6e2f4218 100644 --- a/src/common/scroll_helper.py +++ b/src/common/scroll_helper.py @@ -328,6 +328,10 @@ class ScrollHelper: elapsed_time = current_time - (self.scroll_start_time or current_time) # The image already includes display_width padding, so we only need total_scroll_width required_total_distance = self.total_scroll_width + # Progress telemetry, emitted every few seconds for the whole of + # every scroll. It says how far along a marquee is, which is what + # you turn debug on to watch and not something an operator needs + # in the journal on a device that scrolls all day. self.logger.debug( "Scroll progress: elapsed=%.2fs, target=%.2fs, total_scrolled=%.0f/%d px (%.1f%%)", elapsed_time, diff --git a/src/vegas_mode/coordinator.py b/src/vegas_mode/coordinator.py index 430cbef6..b9af5ea6 100644 --- a/src/vegas_mode/coordinator.py +++ b/src/vegas_mode/coordinator.py @@ -31,6 +31,18 @@ if TYPE_CHECKING: logger = logging.getLogger(__name__) +#: Degradation threshold, as a fraction of target_fps. A marquee jitters a +#: little all the time, so "anything under target" would report constantly and +#: mean nothing; 90% of target is the point where a shortfall is real. At a +#: 60fps target that is 54fps -- 55fps is a normal wobble and stays at DEBUG, +#: which is deliberate, not an off-by-one. +_FPS_HEALTHY_FRACTION = 0.9 + +#: A healthy marquee still reports this often, so silence means stopped +#: rather than fine. +_FPS_HEARTBEAT_INTERVAL = 300.0 + + def _percentile(ordered: List[float], fraction: float) -> float: """Nearest-rank percentile of an already-sorted list. @@ -96,6 +108,11 @@ class VegasModeCoordinator: self._is_active = False self._is_paused = False self._should_stop = False + # Frame-rate health, tracked across run_iteration() calls so the + # heartbeat is one-per-interval rather than one-per-cycle, and so a + # recovery spanning two cycles is still reported. Reset on start(). + self._fps_last_health_log = 0.0 + self._fps_was_degraded = False self._state_lock = threading.Lock() # Live priority tracking @@ -248,6 +265,11 @@ class VegasModeCoordinator: self._is_active = True self._should_stop = False self._start_time = time.time() + # A fresh run starts with a clean health slate: no stale + # "was degraded" from the previous run, and a heartbeat that is + # due immediately so the first sample confirms the marquee is up. + self._fps_last_health_log = 0.0 + self._fps_was_degraded = False # Line up the next group immediately, so the first extension is already # warm rather than stalling the scroll to fetch it. @@ -395,8 +417,18 @@ class VegasModeCoordinator: duration = self.render_pipeline.get_dynamic_duration() start_time = time.time() frame_count = 0 - fps_log_interval = 5.0 # Log FPS every 5 seconds - last_fps_log_time = start_time + fps_log_interval = 5.0 # Sample FPS every 5 seconds + # Health state lives on the coordinator, not here: run_iteration() is + # called once per cycle, so locals reset every few seconds. That made + # `last_fps_health_log = 0.0` fire the "heartbeat" on the first sample + # of every iteration rather than once per interval, and a recovery + # that crossed an iteration boundary was never reported at all -- + # was_degraded had already gone back to False. + # Monotonic, and deliberately not start_time: start_time is wall + # clock and is used below to report the iteration's duration. Mixing + # the two here would make every delta hugely negative and silence the + # frame-rate reporting altogether. + last_fps_log_time = time.monotonic() fps_frame_count = 0 # A mean hides stutter completely. At 120fps a five-second window is # ~600 frames, so a 200ms freeze -- plainly visible on a marquee -- @@ -408,7 +440,13 @@ class VegasModeCoordinator: logger.info("Starting Vegas iteration for %.1fs", duration) while True: - frame_started = time.time() + # Monotonic, like the FPS window below. These devices have no RTC, + # so the wall clock jumps by however wrong boot time was the moment + # NTP first syncs. A backward jump makes frame_elapsed negative, + # and `frame_interval - frame_elapsed` then sleeps for longer than + # the whole budget -- the render loop stalls for the size of the + # correction. A forward jump inflates p99 and worst-frame instead. + frame_started = time.monotonic() # Check for STATIC mode plugin that should pause scroll static_plugin = self._check_static_plugin_trigger() @@ -436,7 +474,7 @@ class VegasModeCoordinator: # quarter of the budget spent not rendering. Subtracting the work # already done keeps the pacing target while reclaiming that time, # and yields the GIL either way so other threads still run. - frame_elapsed = time.time() - frame_started + frame_elapsed = time.monotonic() - frame_started time.sleep(max(0.0, frame_interval - frame_elapsed)) # Measured before the sleep: time spent working, not pacing. @@ -448,16 +486,42 @@ class VegasModeCoordinator: frame_count += 1 fps_frame_count += 1 - # Periodic FPS logging - current_time = time.time() + # Periodic FPS logging. Reported at INFO only when the frame rate + # is actually worth an operator's attention -- a shortfall against + # target, or the recovery from one -- with a slow heartbeat so a + # healthy marquee still shows a pulse. + # + # Measured over two hours on a running rig: 1410 samples, 98.5% + # of them within 10% of target. The 1.5% that were not included a + # reading of 8.6fps against a target of 60 -- a real stall, and + # completely invisible inside 1389 lines reading "59.6". + # Monotonic: every use of this value in the block below is a + # duration, and these devices have no RTC, so the wall clock jumps + # by however wrong boot time was the moment NTP first syncs. That + # would not only mis-fire the heartbeat, it would corrupt the + # frame rate itself, since fps is frames divided by this delta. + current_time = time.monotonic() if current_time - last_fps_log_time >= fps_log_interval: fps = fps_frame_count / (current_time - last_fps_log_time) p99 = _percentile(sorted(frame_times), 0.99) - logger.info( - "Vegas FPS: %.1f (target: %d, frames: %d) p99 %.1fms worst %.1fms", - fps, self.vegas_config.target_fps, fps_frame_count, - p99 * 1000.0, frame_worst * 1000.0 - ) + target = self.vegas_config.target_fps + degraded = target > 0 and fps < target * _FPS_HEALTHY_FRACTION + due = (current_time - self._fps_last_health_log + >= _FPS_HEARTBEAT_INTERVAL) + if degraded or self._fps_was_degraded or due: + logger.info( + "Vegas FPS: %.1f (target: %d, frames: %d) p99 %.1fms worst %.1fms", + fps, target, fps_frame_count, + p99 * 1000.0, frame_worst * 1000.0 + ) + self._fps_last_health_log = current_time + else: + logger.debug( + "Vegas FPS: %.1f (target: %d, frames: %d) p99 %.1fms worst %.1fms", + fps, target, fps_frame_count, + p99 * 1000.0, frame_worst * 1000.0 + ) + self._fps_was_degraded = degraded last_fps_log_time = current_time fps_frame_count = 0 frame_worst = 0.0 diff --git a/test/test_vegas_fps_health.py b/test/test_vegas_fps_health.py new file mode 100644 index 00000000..7db126e6 --- /dev/null +++ b/test/test_vegas_fps_health.py @@ -0,0 +1,99 @@ +"""Frame pacing and FPS health reporting must not depend on the wall clock. + +These devices have no RTC, so the system clock jumps by however wrong boot +time was the moment NTP first syncs. The render loop sleeps the *remainder* +of each frame budget: + + frame_elapsed = - frame_started + time.sleep(max(0.0, frame_interval - frame_elapsed)) + +With a wall-clock `now`, a backward jump makes frame_elapsed negative, so +`frame_interval - frame_elapsed` exceeds the whole budget and the render loop +stalls for the size of the correction. A forward jump instead inflates the +p99 and worst-frame numbers the telemetry reports. +""" +import ast +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +COORD = (Path(__file__).resolve().parent.parent + / "src" / "vegas_mode" / "coordinator.py") +TREE = ast.parse(COORD.read_text(encoding="utf-8")) + + +def _assignments_of(name): + """Every `name = ` in the module, as unparsed source.""" + out = [] + for node in ast.walk(TREE): + if isinstance(node, ast.Assign): + for target in node.targets: + if isinstance(target, ast.Name) and target.id == name: + out.append((node.lineno, ast.unparse(node.value))) + return out + + +def test_per_frame_timestamps_are_monotonic(): + for name in ("frame_started", "frame_elapsed"): + assigns = _assignments_of(name) + assert assigns, f"{name} is no longer assigned -- has the loop changed?" + for lineno, expr in assigns: + assert "time.time()" not in expr, ( + f"{name} at line {lineno} uses the wall clock ({expr!r}). A " + "backward NTP step makes the per-frame delta negative and the " + "loop then sleeps longer than the whole frame budget.") + assert "time.monotonic()" in expr, ( + f"{name} at line {lineno} is {expr!r}, expected monotonic") + + +def test_the_fps_window_is_monotonic(): + for lineno, expr in _assignments_of("current_time"): + assert "time.monotonic()" in expr, ( + f"current_time at line {lineno} is {expr!r}; fps is frames divided " + "by this delta, so a clock step would corrupt the rate itself") + + +def test_health_state_is_not_reset_every_iteration(): + """run_iteration() runs once per cycle -- locals here reset every few seconds. + + As locals, `last_fps_health_log = 0.0` made the 300s heartbeat fire on the + first sample of every iteration, and a recovery spanning two iterations was + never reported because was_degraded had already gone back to False. + """ + run_iteration = next( + (n for n in ast.walk(TREE) + if isinstance(n, ast.FunctionDef) and n.name == "run_iteration"), None) + assert run_iteration is not None, "run_iteration() not found" + + local_names = {t.id for n in ast.walk(run_iteration) + if isinstance(n, ast.Assign) + for t in n.targets if isinstance(t, ast.Name)} + for leaked in ("last_fps_health_log", "was_degraded"): + assert leaked not in local_names, ( + f"{leaked} is a local of run_iteration() again, so it resets every " + "cycle -- the heartbeat degenerates to once per iteration") + + body = ast.unparse(run_iteration) + assert "self._fps_last_health_log" in body and "self._fps_was_degraded" in body, ( + "the health state should live on the coordinator, across iterations") + + +def test_start_clears_stale_health_state(): + """A new run must not inherit "was degraded" from the previous one.""" + start = next((n for n in ast.walk(TREE) + if isinstance(n, ast.FunctionDef) and n.name == "start"), None) + assert start is not None, "start() not found" + body = ast.unparse(start) + assert "self._fps_last_health_log" in body and "self._fps_was_degraded" in body, ( + "start() does not reset the FPS health state") + + +def test_the_degraded_threshold_is_documented(): + """The 90% band is deliberate; say so where the constant is defined.""" + source = COORD.read_text(encoding="utf-8") + idx = source.index("_FPS_HEALTHY_FRACTION = ") + preamble = source[max(0, idx - 700):idx] + assert "90%" in preamble or "0.9" in preamble, ( + "the degradation threshold is not explained at its definition, so " + "'below target' reads as a bug rather than a deliberate band") From 6138a3cbefb5c78ded5aec412120c1e51e0a72cb Mon Sep 17 00:00:00 2001 From: Chuck <33324927+ChuckBuilds@users.noreply.github.com> Date: Sun, 23 Aug 2026 11:42:51 -0400 Subject: [PATCH 02/29] test(install): stop assuming pytest's tmp_path is on disk (#492) test_returns_nothing_when_tmpdir_is_already_disk_backed asserted that lm_disk_backed_tmpdir prints nothing when TMPDIR is already disk-backed, and used pytest's tmp_path as the "disk-backed" directory: # tmp_path is on the regular filesystem, so the default must be kept. assert call("lm_disk_backed_tmpdir", env={"TMPDIR": str(tmp_path)}) == "" That premise is false on the platform the helper was written for. Debian 13 mounts /tmp as tmpfs -- which is the entire reason lm_disk_backed_tmpdir exists -- and pytest puts tmp_path under /tmp. So on the target platform TMPDIR is memory-backed, the helper correctly answers /var/tmp, and the test fails: E AssertionError: assert '/var/tmp' == '' The helper is right; the test was wrong. Reproduced on a box where /tmp is tmpfs and / is ext4. The test now looks for a directory whose backing store is actually disk -- tmp_path, else a scratch dir under /var/tmp, else beside the library -- using the same findmnt lookup the helper itself uses, and skips only if no disk-backed directory exists anywhere. An earlier version of this fix skipped whenever tmp_path was tmpfs, which made it skip on every machine with a tmpfs /tmp; that is barely better than asserting the wrong thing, so it now searches instead of giving up. Verified: 31 passed, 0 skipped. Mutation-checked -- deleting the "is the current TMPDIR memory-backed?" guard from lm_disk_backed_tmpdir fails this test, so it still catches the regression it is there for. Claude-Session: https://claude.ai/code/session_01STMbQE4YctTacQXfbYqKuW Co-authored-by: Claude Opus 5 (1M context) --- test/test_install_lowmem.py | 36 ++++++++++++++++++++++++++++++++++-- 1 file changed, 34 insertions(+), 2 deletions(-) diff --git a/test/test_install_lowmem.py b/test/test_install_lowmem.py index 7143176a..7c9fbbc8 100644 --- a/test/test_install_lowmem.py +++ b/test/test_install_lowmem.py @@ -13,6 +13,7 @@ need root and mutate the system, so they are exercised manually instead. """ import subprocess +import tempfile from pathlib import Path import pytest @@ -31,6 +32,16 @@ def run_lib(snippet: str, env: dict | None = None) -> subprocess.CompletedProces ) +def _fstype_of(path: object) -> str: + """Filesystem type backing ``path``, via the same tool the helper uses.""" + result = subprocess.run( + ["findmnt", "-no", "FSTYPE", "--target", str(path)], + capture_output=True, text=True, + env={"PATH": "/usr/bin:/bin:/usr/sbin:/sbin"}, + ) + return result.stdout.strip() + + def call(fn: str, *args: object, env: dict | None = None) -> str: joined = " ".join(str(a) for a in args) result = run_lib(f"{fn} {joined}", env=env) @@ -195,8 +206,29 @@ class TestOomDetection: class TestDiskBackedTmpdir: def test_returns_nothing_when_tmpdir_is_already_disk_backed(self, tmp_path): - # tmp_path is on the regular filesystem, so the default must be kept. - assert call("lm_disk_backed_tmpdir", env={"TMPDIR": str(tmp_path)}) == "" + # Do not assume tmp_path is disk-backed. Debian 13 -- the platform this + # helper exists for -- mounts /tmp as tmpfs, and pytest puts tmp_path + # under /tmp, so this asserted against a *memory*-backed directory and + # failed on the target platform while the helper behaved exactly as + # designed. Search for a directory whose backing store is really disk. + scratch = None + disk_backed = None + for candidate in (tmp_path, Path("/var/tmp"), LIB.parent): + if _fstype_of(candidate) not in ("tmpfs", "ramfs", ""): + if candidate is tmp_path: + disk_backed = candidate + else: + scratch = Path(tempfile.mkdtemp(dir=str(candidate))) + disk_backed = scratch + break + if disk_backed is None: + pytest.skip("no disk-backed directory available to test against") + try: + assert call("lm_disk_backed_tmpdir", + env={"TMPDIR": str(disk_backed)}) == "" + finally: + if scratch is not None: + scratch.rmdir() def test_redirects_away_from_a_memory_backed_tmpdir(self): # Debian 13 mounts /tmp as tmpfs, which would otherwise hold the whole From 5a1f121e6be43513f197608a4fc603cecd186905 Mon Sep 17 00:00:00 2001 From: Chuck <33324927+ChuckBuilds@users.noreply.github.com> Date: Sun, 23 Aug 2026 11:43:08 -0400 Subject: [PATCH 03/29] Stop array-item secrets being wiped, and logging them (#493) Three review findings from #485 that I missed when addressing that PR; it has since merged, so they land here. 1. Array-item secrets destroyed by any unrelated save (data loss). remove_empty_secrets recursed into dicts but let a list fall through to the scalar branch and kept it verbatim. Lists merge by *replacement*, so the blanks the masked form posts back went straight over the stored array: stored [{"name":"a","token":"REAL-A"}, {"name":"b","token":"REAL-B"}] posted [{"name":"a","token":""}, {"name":"b","token":""}] merged [{"name":"a","token":""}, {"name":"b","token":""}] -> both credentials gone Same failure as the scalar api_key case fixed earlier, one container deeper. Lists now prune element-wise, and a list with nothing real in it is dropped so the stored one is left alone. Where one entry does change, the new merge_secrets merges by index instead of replacing. Two details the first attempt got wrong, both caught by existing tests: - An emptied dict item must stay {}, not None. ConfigManager's _strip_secrets_recursive treats a secrets list as *parallel* to the regular one ({} = "item i has no secrets"); a None makes it stop looking parallel, and it then drops the whole key from the main config -- silently deleting the items' non-secret fields too. - The incoming list's length wins. The regular config's list is authoritative about how many items exist, so preserving surplus stored entries would let the two fall out of step and make deleting an entry impossible. 2. Submitted credentials written to the journal (security). save_plugin_config logged `Full config: {plugin_config}` at INFO and `Config that failed: {plugin_config}` at ERROR. Both run before separate_secrets, so plugin_config still held the values just typed into the form. Now keys only. Swept the rest of web_interface/ and src/ for the same shape -- these were the only two. 3. Restart banner kept stale wording. showRestartPending() cleared the stored custom text but left the DOM element alone, so a config save could show the previous update's message. The default is read back from the server-rendered copy rather than duplicated in JS, so the template stays the one owner of the string. Verified: 556 passed, 1 skipped across the web suite. Mutation-checked -- reverting api_v3 fails the logging guard and the array-merge test; reverting either half of the secret_helpers change fails the unit tests. New end-to-end coverage drives the real endpoint, not just the helpers. Claude-Session: https://claude.ai/code/session_01STMbQE4YctTacQXfbYqKuW Co-authored-by: Claude Opus 5 (1M context) --- src/web_interface/secret_helpers.py | 80 ++++++++++++++++++- .../test_api_v3_secret_roundtrip.py | 38 +++++++++ .../test_config_logging_omits_secrets.py | 45 +++++++++++ test/web_interface/test_secret_helpers.py | 65 +++++++++++++++ web_interface/blueprints/api_v3.py | 19 +++-- web_interface/static/v3/app.js | 12 ++- 6 files changed, 252 insertions(+), 7 deletions(-) create mode 100644 test/web_interface/test_config_logging_omits_secrets.py diff --git a/src/web_interface/secret_helpers.py b/src/web_interface/secret_helpers.py index 6a00cb94..9aa2eccc 100644 --- a/src/web_interface/secret_helpers.py +++ b/src/web_interface/secret_helpers.py @@ -5,7 +5,7 @@ Provides functions for identifying, masking, separating, and filtering secret fields in plugin configurations based on JSON Schema x-secret markers. """ -from typing import Any, Dict, Set, Tuple +from typing import Any, Dict, Optional, Set, Tuple def find_secret_fields(properties: Dict[str, Any], prefix: str = '') -> Set[str]: @@ -202,11 +202,89 @@ def remove_empty_secrets(secrets: Dict[str, Any]) -> Dict[str, Any]: nested = remove_empty_secrets(v) if nested: result[k] = nested + elif isinstance(v, list): + # Lists used to fall through to the scalar branch below and be + # kept verbatim, blanks and all. Because lists merge by + # *replacement*, saving any unrelated setting then wrote + # [{"token": ""}, ...] straight over the stored list and + # destroyed every credential in it. + pruned = _prune_secret_list(v) + if pruned is not None: + result[k] = pruned elif v is not None and not (isinstance(v, str) and v.strip() == ''): result[k] = v return result +def _prune_secret_list(items: list) -> Optional[list]: + """Strip blanks from inside a list of secrets, preserving every index. + + The rest of the system treats a secrets list as *parallel* to the regular + one -- ``sec[i]`` holds the secret fields of item ``i``, and ``{}`` means + "item i has none" (see ConfigManager._strip_secrets_recursive). So an + emptied dict item stays ``{}``: putting ``None`` there makes that list stop + looking parallel, and the stripper then drops the whole key from the main + config, taking the non-secret fields with it. + + A blank *scalar* becomes ``None``, meaning "no update at this index" -- + :func:`merge_secrets` substitutes whatever is stored there. Returns + ``None`` when nothing in the list carries a real value, so the caller drops + the key and leaves the stored list untouched. + """ + pruned: list = [] + has_real_value = False + for item in items: + if isinstance(item, dict): + kept = remove_empty_secrets(item) + pruned.append(kept) + has_real_value = has_real_value or bool(kept) + elif isinstance(item, list): + sub = _prune_secret_list(item) + pruned.append(sub if sub is not None else []) + has_real_value = has_real_value or sub is not None + elif item is not None and not (isinstance(item, str) and item.strip() == ''): + pruned.append(item) + has_real_value = True + else: + pruned.append(None) + return pruned if has_real_value else None + + +def merge_secrets(stored: Any, incoming: Any) -> Any: + """Merge submitted secrets over stored ones, element-wise inside lists. + + ``deep_merge`` replaces a list wholesale. For secrets that is destructive: + an incoming list that carries a real value for one entry and ``None`` for + the rest would drop the stored credentials of every other entry. Here a + list merges by index, and ``None`` means "keep what is stored". + + Entries are matched by *position*, which is what the config form gives us + -- there is no schema-declared identity to key on, and it is the same + contract ConfigManager._strip_secrets_recursive already relies on. The + incoming list's length wins, so deleting an item deletes its secrets; + an item the client left blank keeps whatever is stored at that index. + """ + if isinstance(stored, dict) and isinstance(incoming, dict): + merged = dict(stored) + for key, value in incoming.items(): + merged[key] = (merge_secrets(stored[key], value) + if key in stored else value) + return merged + if isinstance(stored, list) and isinstance(incoming, list): + # The incoming list sets the length -- the regular config's list is + # authoritative about how many items exist, and this one runs parallel + # to it. Removing an entry must therefore remove its secrets too. + merged_list = [] + for index, item in enumerate(incoming): + stored_item = stored[index] if index < len(stored) else None + merged_list.append(stored_item if item is None + else merge_secrets(stored_item, item)) + return merged_list + if incoming is None: + return stored + return incoming + + def strip_masked_values(secrets: Dict[str, Any]) -> Dict[str, Any]: """Remove values a client echoed back rather than changed. diff --git a/test/web_interface/test_api_v3_secret_roundtrip.py b/test/web_interface/test_api_v3_secret_roundtrip.py index 1d70f009..9d07a011 100644 --- a/test/web_interface/test_api_v3_secret_roundtrip.py +++ b/test/web_interface/test_api_v3_secret_roundtrip.py @@ -229,6 +229,44 @@ class TestSavePluginConfig: "REAL-KEY-0123456789", "an unrelated edit destroyed the API key" assert env.fresh_load()[PLUGIN_ID]["city"] == "Dallas" + def test_an_unrelated_edit_does_not_erase_array_item_secrets(self, env): + """The scalar api_key case above, but for a list of credentials. + + remove_empty_secrets recursed into dicts only, so a list went into + deep_merge untouched -- and lists merge by *replacement*. Saving any + unrelated field posted [{"token": ""}, ...] straight over the stored + array and destroyed every token in it at once. + """ + assert self._save(env, {"accounts": [ + {"name": "a", "token": "REAL-A"}, + {"name": "b", "token": "REAL-B"}, + ], "city": "Austin"}).status_code == 200 + + # the user changes the city; both masked tokens ride along blank + assert self._save(env, {"accounts": [ + {"name": "a", "token": ""}, + {"name": "b", "token": ""}, + ], "city": "Dallas"}).status_code == 200 + + merged = env.fresh_load()[PLUGIN_ID] + assert [a.get("token") for a in merged["accounts"]] == \ + ["REAL-A", "REAL-B"], "an unrelated edit destroyed the array secrets" + assert [a["name"] for a in merged["accounts"]] == ["a", "b"] + assert merged["city"] == "Dallas" + + def test_one_array_secret_can_be_changed_without_losing_the_rest(self, env): + assert self._save(env, {"accounts": [ + {"name": "a", "token": "REAL-A"}, + {"name": "b", "token": "REAL-B"}, + ]}).status_code == 200 + assert self._save(env, {"accounts": [ + {"name": "a", "token": ""}, + {"name": "b", "token": "NEW-B"}, + ]}).status_code == 200 + + merged = env.fresh_load()[PLUGIN_ID] + assert [a.get("token") for a in merged["accounts"]] == ["REAL-A", "NEW-B"] + def test_a_secret_can_still_be_changed(self, env): """Dropping blanks must not stop a real new value from being saved.""" self._save(env, {"api_key": "first-key"}) diff --git a/test/web_interface/test_config_logging_omits_secrets.py b/test/web_interface/test_config_logging_omits_secrets.py new file mode 100644 index 00000000..031eb4f1 --- /dev/null +++ b/test/web_interface/test_config_logging_omits_secrets.py @@ -0,0 +1,45 @@ +"""The validation logging ran before separate_secrets, so it logged credentials. + +api_v3's plugin-config save logged `Full config: {plugin_config}` at INFO and +`Config that failed: {plugin_config}` at ERROR. Both run *before* +separate_secrets(), so plugin_config still held the values the user just typed +into the form -- API keys and tokens went to the journal in clear text. +""" +import re +from pathlib import Path + +import pytest + +SOURCE = (Path(__file__).resolve().parents[2] + / "web_interface" / "blueprints" / "api_v3.py") + +#: Objects that still hold submitted secret values at the point these log +#: calls run. Interpolating one whole into a log message leaks credentials. +UNREDACTED = ("plugin_config", "secrets_config", "current_secrets") + + +def _logging_lines(): + for number, line in enumerate(SOURCE.read_text(encoding="utf-8").splitlines(), 1): + stripped = line.strip() + if stripped.startswith("#"): + continue + if re.match(r"logger\.(debug|info|warning|error|critical|exception)\(", stripped): + yield number, stripped + + +@pytest.mark.parametrize("name", UNREDACTED) +def test_no_log_call_interpolates_a_whole_secret_bearing_object(name): + # {name} or {name['k']} leaks; {list(name.keys())} and {len(name)} do not. + bare = re.compile(r"\{" + re.escape(name) + r"(\[[^\]]*\])*\}") + offenders = [f"{n}: {text}" for n, text in _logging_lines() if bare.search(text)] + assert not offenders, ( + f"{name} still holds submitted secrets where these log calls run:\n " + + "\n ".join(offenders)) + + +def test_the_guard_would_notice_a_reintroduced_leak(): + """Pin the detector itself, so a rewrite cannot silently stop matching.""" + bare = re.compile(r"\{" + re.escape("plugin_config") + r"(\[[^\]]*\])*\}") + assert bare.search('logger.info(f"Full config: {plugin_config}")') + assert bare.search("logger.error(f\"{plugin_config['api_key']}\")") + assert not bare.search('logger.info(f"{list(plugin_config.keys())}")') diff --git a/test/web_interface/test_secret_helpers.py b/test/web_interface/test_secret_helpers.py index 5f33e899..a9eb4a7a 100644 --- a/test/web_interface/test_secret_helpers.py +++ b/test/web_interface/test_secret_helpers.py @@ -17,6 +17,7 @@ from src.web_interface.secret_helpers import ( separate_secrets, mask_secret_fields, mask_all_secret_values, + merge_secrets, remove_empty_secrets, ) @@ -239,3 +240,67 @@ class TestRemoveEmptySecrets: def test_keeps_falsey_non_string_values(self): # 0 and False are neither None nor blank strings — they are kept. assert remove_empty_secrets({"a": 0, "b": False}) == {"a": 0, "b": False} + + +class TestArrayItemSecrets: + """Lists merge by replacement, so a blanked array wipes stored credentials. + + remove_empty_secrets recursed into dicts but let a list through untouched, + so [{"token": ""}] went straight into deep_merge and overwrote the stored + list. Saving any unrelated setting destroyed every token in the array. + """ + + STORED = {"accounts": [{"name": "a", "token": "REAL-A"}, + {"name": "b", "token": "REAL-B"}]} + + def test_an_unrelated_save_keeps_every_stored_token(self): + posted = {"accounts": [{"name": "a", "token": ""}, + {"name": "b", "token": ""}]} + merged = merge_secrets(self.STORED, remove_empty_secrets(posted)) + assert [a["token"] for a in merged["accounts"]] == ["REAL-A", "REAL-B"] + + def test_editing_one_entry_leaves_the_others_alone(self): + posted = {"accounts": [{"name": "a", "token": ""}, + {"name": "b", "token": "NEW-B"}]} + merged = merge_secrets(self.STORED, remove_empty_secrets(posted)) + assert [a["token"] for a in merged["accounts"]] == ["REAL-A", "NEW-B"] + + def test_a_new_entry_is_appended(self): + posted = {"accounts": [{"name": "a", "token": ""}, + {"name": "b", "token": ""}, + {"name": "c", "token": "NEW-C"}]} + merged = merge_secrets(self.STORED, remove_empty_secrets(posted)) + assert [a["token"] for a in merged["accounts"]] == \ + ["REAL-A", "REAL-B", "NEW-C"] + + def test_a_list_of_bare_strings_merges_by_index(self): + merged = merge_secrets({"keys": ["K1", "K2", "K3"]}, + remove_empty_secrets({"keys": ["", "K2-NEW", ""]})) + assert merged["keys"] == ["K1", "K2-NEW", "K3"] + + def test_an_all_blank_list_is_dropped_entirely(self): + posted = {"accounts": [{"token": ""}, {"token": ""}]} + assert "accounts" not in remove_empty_secrets(posted) + + def test_plain_dict_secrets_are_unaffected(self): + merged = merge_secrets({"api_key": "OLD", "other": "keep"}, + remove_empty_secrets({"api_key": "", "other": "changed"})) + assert merged == {"api_key": "OLD", "other": "changed"} + + def test_a_removed_entry_takes_its_secret_with_it(self): + """The regular config's list is authoritative about how many items + exist, and the secrets list runs parallel to it -- see + ConfigManager._strip_secrets_recursive. So a shorter incoming list + must shorten the stored secrets too, or the two fall out of step.""" + posted = {"accounts": [{"name": "a", "token": "NEW-A"}]} + merged = merge_secrets(self.STORED, remove_empty_secrets(posted)) + assert [a["token"] for a in merged["accounts"]] == ["NEW-A"] + + def test_an_emptied_item_stays_a_dict_not_none(self): + """None there stops the list looking parallel, and + _strip_secrets_recursive then drops the whole key from the main + config -- deleting the item's non-secret fields as well.""" + pruned = remove_empty_secrets( + {"accounts": [{"token": "real"}, {"token": ""}]}) + assert pruned["accounts"] == [{"token": "real"}, {}] + assert None not in pruned["accounts"] diff --git a/web_interface/blueprints/api_v3.py b/web_interface/blueprints/api_v3.py index 37df1141..958f5f0c 100644 --- a/web_interface/blueprints/api_v3.py +++ b/web_interface/blueprints/api_v3.py @@ -22,7 +22,8 @@ logger = logging.getLogger(__name__) from src.web_interface.api_helpers import success_response, error_response, validate_request_json from src.web_interface.errors import ErrorCode from src.web_interface.secret_helpers import (find_secret_fields, mask_all_secret_values, - remove_empty_secrets, separate_secrets, + merge_secrets, remove_empty_secrets, + separate_secrets, strip_masked_values) from src.web_interface.error_handler import describe_exception, redact_text from src.plugin_system.operation_types import OperationType @@ -1296,7 +1297,10 @@ def save_main_config(): if secrets_config: if plugin_id not in current_secrets: current_secrets[plugin_id] = {} - current_secrets[plugin_id] = deep_merge(current_secrets[plugin_id], secrets_config) + # Lists merge by replacement, so deep_merge here wrote a + # blanked array straight over the stored credentials. + current_secrets[plugin_id] = merge_secrets( + current_secrets[plugin_id], secrets_config) # Save secrets file api_v3.config_manager.save_raw_file_content('secrets', current_secrets) @@ -5675,8 +5679,10 @@ def save_plugin_config(): if schema: # Log what we're validating for debugging logger.info(f"Validating config for {plugin_id}") + # Only the shape. plugin_config still holds the submitted secret + # values at this point -- separate_secrets does not run until + # below -- so logging it wrote live credentials to the journal. logger.info(f"Config keys being validated: {list(plugin_config.keys())}") - logger.info(f"Full config: {plugin_config}") # Get enhanced schema keys (including injected core properties) # We need to create an enhanced schema to get the actual allowed keys @@ -5699,7 +5705,8 @@ def save_plugin_config(): # Log validation errors for debugging logger.error(f"Config validation failed for {plugin_id}") logger.error(f"Validation errors: {validation_errors}") - logger.error(f"Config that failed: {plugin_config}") + # Keys only, for the same reason as above. + logger.error(f"Config keys that failed: {list(plugin_config.keys())}") logger.error(f"Schema properties: {list(enhanced_schema.get('properties', {}).keys())}") # Also print to console for immediate visibility @@ -5750,7 +5757,9 @@ def save_plugin_config(): if secrets_config: if plugin_id not in current_secrets: current_secrets[plugin_id] = {} - current_secrets[plugin_id] = deep_merge(current_secrets[plugin_id], secrets_config) + # See above -- secrets lists must merge element-wise. + current_secrets[plugin_id] = merge_secrets( + current_secrets[plugin_id], secrets_config) # Save secrets file try: api_v3.config_manager.save_raw_file_content('secrets', current_secrets) diff --git a/web_interface/static/v3/app.js b/web_interface/static/v3/app.js index 581c66f2..638204b3 100644 --- a/web_interface/static/v3/app.js +++ b/web_interface/static/v3/app.js @@ -126,7 +126,17 @@ window.showRestartPending = function(message) { } catch { /* private browsing */ } const banner = document.getElementById('restart-pending-banner'); const text = document.getElementById('restart-pending-text'); - if (text && message) text.textContent = message; + if (text) { + // Without the else-branch a config save inherited whatever wording the + // previous update left in the DOM: showRestartPending() clears the + // stored text but used to leave the element itself alone. The default + // is read back from the server-rendered copy rather than duplicated + // here, so the template stays the one place that owns the string. + if (text.dataset.defaultText === undefined) { + text.dataset.defaultText = text.textContent.trim(); + } + text.textContent = message || text.dataset.defaultText; + } if (banner) banner.style.display = 'block'; }; From c321b94085bdf5c1e813274a930c0064f71d5612 Mon Sep 17 00:00:00 2001 From: Chuck <33324927+ChuckBuilds@users.noreply.github.com> Date: Sun, 23 Aug 2026 11:43:39 -0400 Subject: [PATCH 04/29] fix(display): retry a plugin that is enabled but failed to load (#495) * fix(display): retry a plugin that is enabled but failed to load A plugin whose validate_config() returns False is treated as a hard load failure. The API then reports enabled=true, loaded=false, error=null: the plugin is simply absent, with nothing saying why. hockey-scoreboard sat in that state on a live rig for four days. The recovery path existed but could not be reached. _reconcile_enabled_plugins computes to_add = desired - current, and a plugin that failed to load is never in current, so it stays in to_add and would be retried. But the reconcile is queued by _enabled_set_changed(), which compares only top-level `enabled` flags -- and the edit that actually fixes such a plugin (enabling a league, filling in an API key) is nested inside the plugin's own config section. No top-level flag changes, so no reconcile is queued, and the save that should have fixed it does nothing. Only toggling some unrelated plugin -- which does change a top-level flag -- queues the global reconcile that recovers it. Add a second gate: queue a reconcile when a discovered plugin is enabled in config but absent from the running set. It is deliberately narrow rather than "reconcile on any config change". Reconcile calls discover_plugins(), a ~39-manifest filesystem scan, and it runs on the render thread; doing that on every config save would trade this bug for a frame hitch. Gating on plugin_manifests also keeps non-plugin sections that carry their own `enabled` flag (schedule, display) from queueing a reconcile they can never satisfy. In the steady state -- every enabled plugin loaded -- the new check is False and costs nothing. The same valid-but-unconfigured => hard-fail shape still exists in text-display, youtube-stats, birdnet-go, ledmatrix-flights and mqtt-notifications; this makes all of them recoverable without a restart. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01STMbQE4YctTacQXfbYqKuW * fix(display): snapshot the plugin mappings under their locks Addresses the review finding on the cross-thread reads. _enabled_plugin_not_running runs on the config-watcher thread and read two mappings the render thread mutates. Catching RuntimeError was not a fix: it turned a torn read into a coin flip between an unnecessary discovery scan and a missed retry, which is the bug this PR exists to remove. Both reads are now snapshots taken under the lock that guards their writes: - plugin_manifests via a new PluginManager.discovered_plugin_ids(), which copies the ids while holding the existing _discovery_lock. Discovery rebuilds that mapping entry by entry, so an unsynchronised reader can see it half-populated. - plugin_display_modes under a new controller lock, taken at the only two sites that mutate it (_register_loaded_plugin / _unregister_plugin). The locks are never nested -- each snapshot is taken and released before the next -- so this cannot deadlock against discovery, which holds _discovery_lock while it rebuilds. No cost on the per-frame path. Both mutation sites run during reconcile, which is rare, and every hot-path read of plugin_display_modes is on the render thread itself, same thread as the writes, so those stay lock-free. Tests: the accessor returns a snapshot rather than a live view, and actually takes the discovery lock (proved from a second thread, since an RLock is reentrant on the owning one) so a later refactor cannot quietly drop it. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01STMbQE4YctTacQXfbYqKuW * fix(display): consume the reconcile request before serving it Addresses the second review finding: a lost update on _pending_plugin_reconcile. The flag was cleared after a successful reconcile. Reconcile has already read its config by that point, so a config change arriving mid-flight set a flag that the trailing clear then erased -- a request that was never served, and the newest config never reconciled. That is the same "my save did nothing" symptom this PR exists to remove, so leaving it would have undercut the fix. Consume the request before running it instead, and re-arm only on a retryable failure. A change that lands during reconcile now stays set and is picked up on the next pass. The per-frame read stays lock-free. It is a fast path that can only produce a false negative -- the watcher setting the flag just after it is read is seen on the next iteration -- never a false positive that loses a request. The lock is taken only when a reconcile is actually pending or a config change arrives. Extracted _service_pending_reconcile() so the sequence is testable rather than buried in run()'s loop; the review asked for a regression test that invokes the subscriber during reconciliation, which is not reachable otherwise. Tests: 4 new, covering a request racing in mid-reconcile, the quiet success, the retryable-failure re-arm, and not reconciling when nothing is pending. Two of them fail against the previous clear-after-success semantics. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01STMbQE4YctTacQXfbYqKuW --------- Co-authored-by: Claude Opus 5 (1M context) --- src/display_controller.py | 92 ++++++++- src/plugin_system/plugin_manager.py | 11 ++ test/test_display_controller_plugin_toggle.py | 180 ++++++++++++++++++ test/test_plugin_manager_discovered_ids.py | 63 ++++++ 4 files changed, 338 insertions(+), 8 deletions(-) create mode 100644 test/test_plugin_manager_discovered_ids.py diff --git a/src/display_controller.py b/src/display_controller.py index e739e08e..bb650698 100644 --- a/src/display_controller.py +++ b/src/display_controller.py @@ -181,6 +181,16 @@ class DisplayController: self.plugin_modes = {} # mode -> plugin_instance mapping for plugin-first dispatch self.mode_to_plugin_id: Dict[str, str] = {} self.plugin_display_modes: Dict[str, List[str]] = {} + # plugin_display_modes is mutated only by _register_loaded_plugin / + # _unregister_plugin on the render thread, but the config-watcher + # thread reads it in _enabled_plugin_not_running. Both mutation sites + # run during reconcile (rare), so this lock never touches the per-frame + # path -- the hot-path reads are same-thread as the writes. + self._plugin_modes_lock = threading.Lock() + # Guards the consume-and-clear of _pending_plugin_reconcile. Only taken + # when a reconcile is actually pending or a config change arrives, both + # rare -- the per-frame path just reads the bool. + self._reconcile_flag_lock = threading.Lock() # Per-plugin config-change callbacks, kept so we can unsubscribe a # plugin when it is disabled live. self._plugin_config_callbacks: Dict[str, Callable] = {} @@ -463,8 +473,10 @@ class DisplayController: self._refresh_config_cache(new_config) # If a plugin was enabled/disabled, flag a reconcile for the main # loop to apply (loading/unloading off the watcher thread is unsafe). - if self._enabled_set_changed(old_config, new_config): - self._pending_plugin_reconcile = True + if (self._enabled_set_changed(old_config, new_config) + or self._enabled_plugin_not_running(new_config)): + with self._reconcile_flag_lock: + self._pending_plugin_reconcile = True self.config_service.subscribe(_controller_config_change) @@ -1749,11 +1761,12 @@ class DisplayController: # rebuilding available_modes happens here on the render thread so # it can't race with rendering. Deferred while on-demand is active # (the flag stays set) so we don't fight its temporary-enable. + # The lock-free read is a fast path only; it can be a false + # negative (the watcher setting the flag just after it is read + # is seen next iteration), never a false positive that loses a + # request. if self._pending_plugin_reconcile and not self.on_demand_active: - # Only clear the flag on success -- a retryable failure - # (e.g. discovery) leaves it set so the request isn't lost. - if self._reconcile_enabled_plugins(): - self._pending_plugin_reconcile = False + self._service_pending_reconcile() if not self.available_modes: # Nothing to render yet. Re-check _pending_plugin_reconcile @@ -2813,7 +2826,8 @@ class DisplayController: logger.debug("Using manifest display_modes for %s: %s", plugin_id, display_modes) if not (isinstance(display_modes, list) and display_modes): display_modes = [plugin_id] - self.plugin_display_modes[plugin_id] = list(display_modes) + with self._plugin_modes_lock: + self.plugin_display_modes[plugin_id] = list(display_modes) # Subscribe to config changes for per-plugin hot-reload. Bind plugin_id # and instance as defaults so each plugin's callback targets its own @@ -2847,7 +2861,8 @@ class DisplayController: def _unregister_plugin(self, plugin_id: str) -> None: """Remove a plugin's modes, config subscription and instance, then unload it. Used by live disable hot-reload.""" - modes = self.plugin_display_modes.pop(plugin_id, []) + with self._plugin_modes_lock: + modes = self.plugin_display_modes.pop(plugin_id, []) for mode in modes: if mode in self.available_modes: self.available_modes.remove(mode) @@ -2892,6 +2907,67 @@ class DisplayController: } return enabled_map(old_config) != enabled_map(new_config) + def _service_pending_reconcile(self) -> None: + """Consume a pending reconcile request and run it. + + The request is consumed BEFORE reconciling, not cleared after. Clearing + after would drop any config change that lands while reconcile is + running: reconcile has already read its config by then, so the clear + erases a request it never served and the newest config never + reconciles -- the same "your save did nothing" failure this whole path + exists to prevent. Consuming first means such a request stays set and + is picked up on the next pass. + + A retryable failure (e.g. discovery) re-arms the flag. + """ + with self._reconcile_flag_lock: + pending = self._pending_plugin_reconcile + self._pending_plugin_reconcile = False + if pending and not self._reconcile_enabled_plugins(): + with self._reconcile_flag_lock: + self._pending_plugin_reconcile = True + + def _enabled_plugin_not_running(self, new_config: Dict[str, Any]) -> bool: + """True when a discovered plugin is enabled in config but not running. + + ``_enabled_set_changed`` compares only top-level ``enabled`` flags, which + misses the case that strands a plugin: one whose ``validate_config()`` + returned False is absent from the running set, and the edit that fixes it + (enabling a league, filling in an API key) lives *nested* inside that + plugin's own section. No top-level flag changes, so no reconcile is + queued, and the save that should have fixed it appears to do nothing -- + only toggling some unrelated plugin recovers it. hockey-scoreboard sat + enabled-but-absent on a live rig for four days this way. + + Deliberately narrow: it fires only for ids the plugin manager has + actually discovered, so non-plugin sections that carry their own + ``enabled`` flag (``schedule``, ``display``, ...) don't queue a reconcile + on every save. In the steady state -- everything enabled is loaded -- + this is False and costs nothing. That matters because reconcile runs + ``discover_plugins()`` on the render thread, where a needless + filesystem scan per config save would show up as a frame hitch. + + Runs on the config-watcher thread, so both mappings it reads are + snapshotted under the lock that guards their writes. + """ + if self.plugin_manager is None: + return False + # Two snapshots, each taken under its own lock and never nested, so a + # half-written mapping is never observed and this can't deadlock + # against discovery (which holds the discovery lock while rebuilding). + try: + known = self.plugin_manager.discovered_plugin_ids() + except AttributeError: + # Older manager without the accessor: fall back to a plain read. + known = set(getattr(self.plugin_manager, 'plugin_manifests', ()) or ()) + with self._plugin_modes_lock: + running = set(self.plugin_display_modes) + for key, value in new_config.items(): + if (key in known and isinstance(value, dict) + and value.get('enabled', False) and key not in running): + return True + return False + def _reconcile_enabled_plugins(self) -> bool: """Load/unload plugins so the running set matches the enabled set in config. Runs on the main display thread (never the config-watcher diff --git a/src/plugin_system/plugin_manager.py b/src/plugin_system/plugin_manager.py index 543017c9..0dc7a428 100644 --- a/src/plugin_system/plugin_manager.py +++ b/src/plugin_system/plugin_manager.py @@ -631,6 +631,17 @@ class PluginManager: return self.load_plugin(plugin_id) + def discovered_plugin_ids(self) -> set: + """Snapshot of the discovered plugin ids, taken under the discovery lock. + + Callers on other threads (the config watcher) must not iterate + ``plugin_manifests`` directly: discovery rebuilds it entry by entry, so + an unsynchronised reader can see a half-populated mapping or raise + "dictionary changed size during iteration". + """ + with self._discovery_lock: + return set(self.plugin_manifests) + def get_plugin(self, plugin_id: str) -> Optional[Any]: """ Get a loaded plugin instance by ID. diff --git a/test/test_display_controller_plugin_toggle.py b/test/test_display_controller_plugin_toggle.py index 0980e729..e09933d0 100644 --- a/test/test_display_controller_plugin_toggle.py +++ b/test/test_display_controller_plugin_toggle.py @@ -6,6 +6,7 @@ These tests cover the reconcile path that loads/unloads plugins and rebuilds the dispatch maps on the main thread when the enabled set changes. """ +import copy from unittest.mock import MagicMock @@ -253,3 +254,182 @@ class TestEnabledSetChanged: {"a": {"enabled": True, "duration": 30}}, {"a": {"enabled": True, "duration": 45}}, ) is False + + +class TestEnabledPluginNotRunning: + """A plugin that fails validate_config() is enabled but absent, and the + config edit that fixes it is nested inside the plugin's own section -- so + the top-level ``enabled`` comparison never sees it. These cover the second + gate that queues a reconcile in that case. + """ + + def test_nested_edit_is_invisible_to_the_enabled_set_check(self, test_display_controller): + """The original gate: proves why a second one is needed.""" + controller = test_display_controller + old = {"hockey-scoreboard": {"enabled": True, "nhl": {"enabled": False}}} + new = {"hockey-scoreboard": {"enabled": True, "nhl": {"enabled": True}}} + # Enabling a league changes no top-level flag. + assert controller._enabled_set_changed(old, new) is False + + def test_queues_reconcile_when_enabled_plugin_is_absent(self, test_display_controller): + controller = test_display_controller + controller.plugin_manager.plugin_manifests = {"hockey-scoreboard": {}} + controller.plugin_manager.discovered_plugin_ids.return_value = {"hockey-scoreboard"} + controller.plugin_display_modes = {} # failed to load + cfg = {"hockey-scoreboard": {"enabled": True, "nhl": {"enabled": True}}} + assert controller._enabled_plugin_not_running(cfg) is True + + def test_quiet_when_every_enabled_plugin_is_running(self, test_display_controller): + controller = test_display_controller + controller.plugin_manager.plugin_manifests = {"hockey-scoreboard": {}} + controller.plugin_manager.discovered_plugin_ids.return_value = {"hockey-scoreboard"} + controller.plugin_display_modes = {"hockey-scoreboard": ["nhl"]} + cfg = {"hockey-scoreboard": {"enabled": True}} + assert controller._enabled_plugin_not_running(cfg) is False + + def test_disabled_plugin_does_not_queue(self, test_display_controller): + controller = test_display_controller + controller.plugin_manager.plugin_manifests = {"hockey-scoreboard": {}} + controller.plugin_manager.discovered_plugin_ids.return_value = {"hockey-scoreboard"} + controller.plugin_display_modes = {} + cfg = {"hockey-scoreboard": {"enabled": False}} + assert controller._enabled_plugin_not_running(cfg) is False + + def test_non_plugin_sections_do_not_queue(self, test_display_controller): + """``schedule``/``display`` carry their own ``enabled`` and are never + in plugin_display_modes -- without the manifest check they would queue + a reconcile, and therefore a filesystem scan, on every config save.""" + controller = test_display_controller + controller.plugin_manager.plugin_manifests = {"hockey-scoreboard": {}} + controller.plugin_manager.discovered_plugin_ids.return_value = {"hockey-scoreboard"} + controller.plugin_display_modes = {"hockey-scoreboard": ["nhl"]} + cfg = { + "hockey-scoreboard": {"enabled": True}, + "schedule": {"enabled": True}, + "display": {"enabled": True}, + } + assert controller._enabled_plugin_not_running(cfg) is False + + def test_non_dict_section_is_ignored(self, test_display_controller): + controller = test_display_controller + controller.plugin_manager.plugin_manifests = {"hockey-scoreboard": {}} + controller.plugin_manager.discovered_plugin_ids.return_value = {"hockey-scoreboard"} + controller.plugin_display_modes = {} + assert controller._enabled_plugin_not_running({"hockey-scoreboard": "nonsense"}) is False + + def test_no_plugin_manager_is_quiet(self, test_display_controller): + controller = test_display_controller + controller.plugin_manager = None + assert controller._enabled_plugin_not_running({"x": {"enabled": True}}) is False + + +class TestReconcileQueuedThroughSubscriber: + """End-to-end through the real config-change subscriber, not the helper. + + Without the second gate this is the four-day-outage path: the plugin is + enabled, absent, and the save that enables its league sets no flag. + """ + + @staticmethod + def _subscriber(controller): + subs = controller.config_service._subscribers['*'] + for cb in subs: + if getattr(cb, '__name__', '') == '_controller_config_change': + return cb + raise AssertionError(f"controller subscriber not found among {subs}") + + @staticmethod + def _configs(controller, plugin_section_old, plugin_section_new): + """Build two full configs differing only inside the plugin section -- + the subscriber refreshes its cache from these, so they must be real.""" + base = copy.deepcopy(controller.config) + old = copy.deepcopy(base) + new = copy.deepcopy(base) + old["hockey-scoreboard"] = plugin_section_old + new["hockey-scoreboard"] = plugin_section_new + return old, new + + def test_nested_edit_queues_reconcile_for_absent_plugin(self, test_display_controller): + controller = test_display_controller + controller.plugin_manager.plugin_manifests = {"hockey-scoreboard": {}} + controller.plugin_manager.discovered_plugin_ids.return_value = {"hockey-scoreboard"} + controller.plugin_display_modes = {} # validate_config() said False + controller._pending_plugin_reconcile = False + + old, new = self._configs( + controller, + {"enabled": True, "nhl": {"enabled": False}}, + {"enabled": True, "nhl": {"enabled": True}}, + ) + # The original gate is blind to this edit ... + assert controller._enabled_set_changed(old, new) is False + self._subscriber(controller)(old, new) + # ... but the reconcile is queued anyway. + assert controller._pending_plugin_reconcile is True + + def test_steady_state_does_not_queue_reconcile(self, test_display_controller): + """Everything enabled is running: an unrelated edit must not queue a + reconcile, or every config save drags a filesystem scan onto the + render thread.""" + controller = test_display_controller + controller.plugin_manager.plugin_manifests = {"hockey-scoreboard": {}} + controller.plugin_manager.discovered_plugin_ids.return_value = {"hockey-scoreboard"} + controller.plugin_display_modes = {"hockey-scoreboard": ["nhl"]} + controller._pending_plugin_reconcile = False + + old, new = self._configs( + controller, + {"enabled": True, "scroll_speed": 1}, + {"enabled": True, "scroll_speed": 2}, + ) + self._subscriber(controller)(old, new) + + assert controller._pending_plugin_reconcile is False + + +class TestPendingReconcileNotLost: + """A config change arriving *during* reconcile must not be discarded. + + The flag used to be cleared after a successful reconcile. Reconcile has + already read its config by then, so that clear erased a request it never + served and the newest config never reconciled -- the same "my save did + nothing" symptom this path exists to prevent. + """ + + def test_request_arriving_during_reconcile_survives(self, test_display_controller): + controller = test_display_controller + controller._pending_plugin_reconcile = True + + def reconcile_and_race(): + # The watcher thread queues another change while we are mid-flight. + with controller._reconcile_flag_lock: + controller._pending_plugin_reconcile = True + return True + + controller._reconcile_enabled_plugins = reconcile_and_race + controller._service_pending_reconcile() + + assert controller._pending_plugin_reconcile is True, \ + "a config change landing during reconcile was discarded" + + def test_flag_cleared_on_a_quiet_success(self, test_display_controller): + controller = test_display_controller + controller._pending_plugin_reconcile = True + controller._reconcile_enabled_plugins = lambda: True + controller._service_pending_reconcile() + assert controller._pending_plugin_reconcile is False + + def test_retryable_failure_rearms(self, test_display_controller): + controller = test_display_controller + controller._pending_plugin_reconcile = True + controller._reconcile_enabled_plugins = lambda: False + controller._service_pending_reconcile() + assert controller._pending_plugin_reconcile is True + + def test_no_reconcile_when_nothing_pending(self, test_display_controller): + controller = test_display_controller + controller._pending_plugin_reconcile = False + calls = [] + controller._reconcile_enabled_plugins = lambda: calls.append(1) or True + controller._service_pending_reconcile() + assert calls == [] diff --git a/test/test_plugin_manager_discovered_ids.py b/test/test_plugin_manager_discovered_ids.py new file mode 100644 index 00000000..5bedba8d --- /dev/null +++ b/test/test_plugin_manager_discovered_ids.py @@ -0,0 +1,63 @@ +"""Tests for PluginManager.discovered_plugin_ids(). + +The config-watcher thread needs the set of discovered plugin ids while the +render thread may be rebuilding plugin_manifests. Iterating that dict directly +can observe a half-populated mapping or raise "dictionary changed size during +iteration", so the accessor snapshots it under the discovery lock. +""" + +import tempfile +import threading +from pathlib import Path + +import pytest + +from src.plugin_system.plugin_manager import PluginManager + + +@pytest.fixture +def pm(): + with tempfile.TemporaryDirectory() as tmp: + yield PluginManager(plugins_dir=str(Path(tmp) / "plugins")) + + +def test_returns_the_discovered_ids(pm): + pm.plugin_manifests = {"clock-simple": {}, "hockey-scoreboard": {}} + assert pm.discovered_plugin_ids() == {"clock-simple", "hockey-scoreboard"} + + +def test_empty_when_nothing_discovered(pm): + pm.plugin_manifests = {} + assert pm.discovered_plugin_ids() == set() + + +def test_is_a_snapshot_not_a_live_view(pm): + """The caller iterates the result on another thread; it must not alias + the mapping discovery is still writing to.""" + pm.plugin_manifests = {"clock-simple": {}} + snapshot = pm.discovered_plugin_ids() + pm.plugin_manifests["hockey-scoreboard"] = {} + assert snapshot == {"clock-simple"} + + +def test_takes_the_discovery_lock(pm): + """Guards against the lock being dropped in a later refactor: with the + lock held by another thread the call must block rather than read.""" + pm.plugin_manifests = {"clock-simple": {}} + finished = threading.Event() + + def call(): + pm.discovered_plugin_ids() + finished.set() + + pm._discovery_lock.acquire() + try: + # RLock is reentrant per-thread, so use a *different* thread to prove + # the accessor actually waits on it. + t = threading.Thread(target=call, daemon=True) + t.start() + assert not finished.wait(timeout=0.3), "accessor did not take the discovery lock" + finally: + pm._discovery_lock.release() + t.join(timeout=2) + assert finished.is_set() From 085fb93a8745ffa80aab2d797a9c57594c5e0fea Mon Sep 17 00:00:00 2001 From: Chuck <33324927+ChuckBuilds@users.noreply.github.com> Date: Sun, 23 Aug 2026 11:44:00 -0400 Subject: [PATCH 05/29] test(logging): stop the location assertion matching the clock (#496) test_location_toggle asserted that ":42" -- a bare colon plus the record's hardcoded lineno -- is absent from a line formatted with include_location=False. But every formatted line starts with an HH:MM:SS.mmm timestamp, so ":42" also matches the clock whenever the minute or the second is 42. The test fails for roughly 3% of runs with nothing wrong: 2026-08-22 08:05:42.274 - INFO - test.logger - hello ^^^ matches ":42" Assert on the whole "module.funcName:lineno" token the format string actually emits ('%(module)s.%(funcName)s:%(lineno)d') instead of a fragment of it. That cannot collide with a timestamp, and it checks the thing the test is named for. Confirmed by formatting a record stamped 08:42:42 -- both minute and second colliding: the old assertion fails, the new one passes. Claude-Session: https://claude.ai/code/session_01STMbQE4YctTacQXfbYqKuW Co-authored-by: Claude Opus 5 (1M context) --- test/test_logging_config.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/test/test_logging_config.py b/test/test_logging_config.py index d0cf0fe8..044a5d92 100644 --- a/test/test_logging_config.py +++ b/test/test_logging_config.py @@ -89,11 +89,17 @@ class TestContextualFormatter: assert "hello" in out def test_location_toggle(self): + # Assert on the whole "module.func:lineno" token, not a bare ":42". + # The formatted line starts with an HH:MM:SS timestamp, so a bare + # ":{lineno}" also matches the clock whenever the minute or second + # happens to equal the line number -- about 3% of runs, which is a + # flaky failure with nothing wrong. record = make_record() + location = f"{record.module}.{record.funcName}:{record.lineno}" with_loc = ContextualFormatter(include_location=True).format(record) without = ContextualFormatter(include_location=False).format(record) - assert f":{record.lineno}" in with_loc - assert f":{record.lineno}" not in without + assert location in with_loc + assert location not in without def test_record_not_mutated_no_double_prefix(self): # Regression: a record is formatted once PER HANDLER. The formatter From a4a55a23fc69bccd3883ad89974b6393fe9cb585 Mon Sep 17 00:00:00 2001 From: Chuck <33324927+ChuckBuilds@users.noreply.github.com> Date: Sun, 23 Aug 2026 11:44:23 -0400 Subject: [PATCH 06/29] fix(web): reject non-finite JSON numbers instead of raising (#497) * fix(web): reject non-finite JSON numbers instead of raising POST /api/v3/config/dim-schedule with {"dim_brightness": Infinity} answered 500. So did /api/v3/errors/clear with max_age_hours, and /api/v3/config/main with multiplexing or row_address_type. json.loads accepts Infinity/-Infinity/NaN by default -- they are not valid JSON, but Python's parser emits them -- and Flask's get_json passes them straight through. int(float('inf')) raises OverflowError, which is neither ValueError nor TypeError, so validation blocks that carefully caught those let it past and Flask turned it into a 500. The status code was not the real damage. dim-schedule answered with CONFIG_SAVE_FAILED and suggested "Check file permissions on config directory" and "Check available disk space" for what was an invalid number. Every one of these sites already had a correct 400 response written; they just never reached it. NaN already returned 400, because int(nan) raises ValueError. That is why this only ever showed up for the infinities, and why it survived: the obvious test case passes. OverflowError is now caught alongside ValueError/TypeError at the 27 sites in this file whose try block performs a numeric coercion. An AST sweep confirms no int()/float() of request-derived data is left outside a block that catches it. Verified end to end through Flask's test client rather than by reasoning about the parser: all four routes returned 500 before and 400 after. Tests: five Infinity cases (which fail against the previous except tuples), two NaN cases pinned so narrowing the tuple cannot quietly break them, and a check that ordinary input is not rejected by the widened guard. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01STMbQE4YctTacQXfbYqKuW * test(web): assert 400 exactly, and prove valid input is accepted Both review points were right, and the first is the failure mode this file exists to catch. Accepting any 4xx meant a 404 would have passed. Renaming one of these routes would have left the test green while it tested nothing -- the same "looks like coverage, points somewhere safe" shape that hid the composer injections. Now asserts exactly 400. Both infinity signs are exercised for every route. int() raises OverflowError either way, but only +Infinity was in the original report, and a guard that special-cased the sign would have passed a one-sided test. The valid-input test previously asserted "not a 400", which did not show what it claimed: the mocked save path fails for any input, so that assertion held whether or not validation had accepted the value. It now gives load_config a real dict and stubs _save_config_atomic, so the endpoint reaches its success response and the test can assert 200 -- which only happens if the value passed validation. 8 of the 11 checks fail with OverflowError removed from the except tuples. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01STMbQE4YctTacQXfbYqKuW --------- Co-authored-by: Claude Opus 5 (1M context) --- test/test_api_v3_non_finite_numbers.py | 85 ++++++++++++++++++++++++++ web_interface/blueprints/api_v3.py | 54 ++++++++-------- 2 files changed, 112 insertions(+), 27 deletions(-) create mode 100644 test/test_api_v3_non_finite_numbers.py diff --git a/test/test_api_v3_non_finite_numbers.py b/test/test_api_v3_non_finite_numbers.py new file mode 100644 index 00000000..4e6c217b --- /dev/null +++ b/test/test_api_v3_non_finite_numbers.py @@ -0,0 +1,85 @@ +"""Non-finite JSON numbers must be rejected, not raise. + +json.loads accepts Infinity/-Infinity/NaN by default (they are not valid JSON, +but Python's parser emits them) and Flask's get_json passes them straight +through. int(float('inf')) raises OverflowError, which is neither ValueError +nor TypeError -- so validation blocks that carefully caught those let it +through and Flask turned it into a 500. + +The damage was not the status code. /config/dim-schedule answered with +CONFIG_SAVE_FAILED and suggested "Check file permissions on config directory" +and "Check available disk space" for what was actually an invalid number. + +NaN already returned 400 (int(nan) raises ValueError), which is why this only +showed up for the infinities. +""" +import sys +from pathlib import Path + +import pytest + +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from test._api_v3_test_helpers import api_v3_client, api_v3_module # noqa: F401,E402 + + +#: (route, field) that returned 500 before OverflowError was caught. Both +#: infinity signs are exercised: int() raises OverflowError for either, but +#: only one of them was in the original report, and a guard that special-cased +#: the sign would pass a one-sided test. +NON_FINITE_ROUTES = [ + ('/api/v3/config/dim-schedule', 'dim_brightness'), + ('/api/v3/errors/clear', 'max_age_hours'), + ('/api/v3/config/main', 'multiplexing'), + ('/api/v3/config/main', 'row_address_type'), +] +NON_FINITE_CASES = [ + (route, '{"%s": %s}' % (field, literal)) + for route, field in NON_FINITE_ROUTES + for literal in ('Infinity', '-Infinity') +] + + +@pytest.mark.parametrize("route,body", NON_FINITE_CASES) +def test_infinity_is_a_client_error_not_a_server_error(api_v3_client, route, body): + """Exactly 400, not merely "some 4xx". + + Accepting any 4xx would let a 404 pass, so renaming one of these routes + would leave the test green while testing nothing -- the failure mode this + whole file exists to catch. + """ + response = api_v3_client.post(route, data=body, content_type='application/json') + assert response.status_code == 400, ( + f"{route} with {body} answered {response.status_code}; expected 400" + ) + + +@pytest.mark.parametrize("route,body", [ + ('/api/v3/config/dim-schedule', '{"dim_brightness": NaN}'), + ('/api/v3/errors/clear', '{"max_age_hours": NaN}'), +]) +def test_nan_is_also_a_client_error(api_v3_client, route, body): + """int(nan) raises ValueError so this path already worked -- pinned so a + refactor that narrows the except tuple cannot quietly break it.""" + response = api_v3_client.post(route, data=body, content_type='application/json') + assert response.status_code == 400 + + +def test_a_valid_number_is_accepted(api_v3_client, api_v3_module, monkeypatch): + """Prove the widened except did not start swallowing ordinary input. + + Asserting "not a 400" would not show that: the mocked save path fails for + any input, so the assertion would hold even if validation had rejected the + value. Give load_config a real dict and stub the atomic save, and the + endpoint reaches its success response -- which only happens if 30 passed + validation. + """ + api_v3_module.api_v3.config_manager.load_config.return_value = {} + monkeypatch.setattr(api_v3_module, '_save_config_atomic', + lambda *a, **k: (True, '')) + response = api_v3_client.post( + '/api/v3/config/dim-schedule', + data='{"dim_brightness": 30}', + content_type='application/json', + ) + assert response.status_code == 200, response.get_data(as_text=True)[:200] diff --git a/web_interface/blueprints/api_v3.py b/web_interface/blueprints/api_v3.py index 958f5f0c..a5a2979f 100644 --- a/web_interface/blueprints/api_v3.py +++ b/web_interface/blueprints/api_v3.py @@ -598,7 +598,7 @@ def save_dim_schedule_config(): dim_brightness = 30 else: dim_brightness = int(dim_brightness_raw) - except (ValueError, TypeError): + except (ValueError, TypeError, OverflowError): return error_response( ErrorCode.VALIDATION_ERROR, "dim_brightness must be an integer between 0 and 100", @@ -798,7 +798,7 @@ def save_main_config(): }), 400 try: target_fps = int(raw_target_fps) - except (ValueError, TypeError): + except (ValueError, TypeError, OverflowError): return jsonify({ 'status': 'error', 'message': "Invalid value for target_fps: must be an integer" @@ -868,7 +868,7 @@ def save_main_config(): mux_val = int(data['multiplexing']) if mux_val < 0 or mux_val > 22: return jsonify({'status': 'error', 'message': f"Invalid multiplexing value '{data['multiplexing']}'. Must be an integer from 0 to 22."}), 400 - except (ValueError, TypeError): + except (ValueError, TypeError, OverflowError): return jsonify({'status': 'error', 'message': f"Invalid multiplexing value '{data['multiplexing']}'. Must be an integer from 0 to 22."}), 400 # Validate pixel_mapper_config (free-form mapper string, e.g. "U-mapper;Rotate:90") @@ -886,7 +886,7 @@ def save_main_config(): rat_val = int(data['row_address_type']) if rat_val < 0 or rat_val > 4: return jsonify({'status': 'error', 'message': f"Invalid row_address_type '{data['row_address_type']}'. Must be an integer from 0 to 4."}), 400 - except (ValueError, TypeError): + except (ValueError, TypeError, OverflowError): return jsonify({'status': 'error', 'message': f"Invalid row_address_type '{data['row_address_type']}'. Must be an integer from 0 to 4."}), 400 # Handle hardware settings @@ -911,7 +911,7 @@ def save_main_config(): if rp1_val not in (0, 1): return jsonify({'status': 'error', 'message': "rp1_rio must be 0 (PIO) or 1 (RIO)"}), 400 current_config['display']['runtime']['rp1_rio'] = rp1_val - except (ValueError, TypeError): + except (ValueError, TypeError, OverflowError): return jsonify({'status': 'error', 'message': "rp1_rio must be 0 or 1"}), 400 # Handle checkboxes - coerce to bool to ensure proper JSON types @@ -964,7 +964,7 @@ def save_main_config(): copies = None try: copies = int(data['double_sided_copies']) - except (ValueError, TypeError): + except (ValueError, TypeError, OverflowError): if enabled: return jsonify({'status': 'error', 'message': "Double-sided copies must be an integer"}), 400 if copies is not None and not (2 <= copies <= 8): @@ -1037,7 +1037,7 @@ def save_main_config(): if data.get('vegas_extend_threshold_screens') not in ('', None): try: screens = float(data['vegas_extend_threshold_screens']) - except (ValueError, TypeError): + except (ValueError, TypeError, OverflowError): return jsonify({ 'status': 'error', 'message': "Invalid value for vegas_extend_threshold_screens: " @@ -1054,7 +1054,7 @@ def save_main_config(): if data.get('vegas_max_plugin_width_ratio') not in ('', None): try: ratio = float(data['vegas_max_plugin_width_ratio']) - except (ValueError, TypeError): + except (ValueError, TypeError, OverflowError): return jsonify({ 'status': 'error', 'message': "Invalid value for vegas_max_plugin_width_ratio: " @@ -1102,7 +1102,7 @@ def save_main_config(): continue try: int_value = int(raw_value) - except (ValueError, TypeError): + except (ValueError, TypeError, OverflowError): return jsonify({ 'status': 'error', 'message': f"Invalid value for {field_name}: must be an integer" @@ -1154,7 +1154,7 @@ def save_main_config(): if not (1024 <= port_val <= 65535): return jsonify({'status': 'error', 'message': "sync_port must be between 1024 and 65535"}), 400 current_config['sync']['port'] = port_val - except (ValueError, TypeError): + except (ValueError, TypeError, OverflowError): return jsonify({'status': 'error', 'message': "sync_port must be an integer"}), 400 if "sync_follower_position" in data: @@ -1198,7 +1198,7 @@ def save_main_config(): raw_value = data.pop(field) try: int_value = int(raw_value) - except (ValueError, TypeError): + except (ValueError, TypeError, OverflowError): return jsonify({'status': 'error', 'message': f"Invalid duration for {field}: must be an integer"}), 400 current_config['display']['display_durations'][field] = int_value @@ -1221,7 +1221,7 @@ def save_main_config(): continue try: int_value = int(raw_value) - except (ValueError, TypeError): + except (ValueError, TypeError, OverflowError): return jsonify({'status': 'error', 'message': f"Invalid duration for mode '{mode_key}': must be an integer"}), 400 current_config['display']['display_durations'][mode_key] = int_value @@ -5122,7 +5122,7 @@ def save_plugin_config(): converted_array.append(int(v)) else: converted_array.append(float(v)) - except (ValueError, TypeError): + except (ValueError, TypeError, OverflowError): converted_array.append(v) else: converted_array.append(v) @@ -5147,7 +5147,7 @@ def save_plugin_config(): converted_array.append(int(v)) else: converted_array.append(float(v)) - except (ValueError, TypeError): + except (ValueError, TypeError, OverflowError): converted_array.append(v) else: converted_array.append(v) @@ -5184,7 +5184,7 @@ def save_plugin_config(): converted_array.append(int(v)) else: converted_array.append(float(v)) - except (ValueError, TypeError): + except (ValueError, TypeError, OverflowError): converted_array.append(v) else: converted_array.append(v) @@ -5208,7 +5208,7 @@ def save_plugin_config(): converted_array.append(int(v)) else: converted_array.append(float(v)) - except (ValueError, TypeError): + except (ValueError, TypeError, OverflowError): converted_array.append(v) else: converted_array.append(v) @@ -5375,7 +5375,7 @@ def save_plugin_config(): if isinstance(v, str): try: converted.append(int(v) if item_type == 'integer' else float(v)) - except (ValueError, TypeError): + except (ValueError, TypeError, OverflowError): converted.append(v) else: converted.append(v) @@ -5500,7 +5500,7 @@ def save_plugin_config(): try: normalized[key] = int(value_stripped) continue - except (ValueError, TypeError): + except (ValueError, TypeError, OverflowError): pass elif isinstance(value, (int, float)): normalized[key] = int(value) @@ -5518,7 +5518,7 @@ def save_plugin_config(): try: normalized[key] = float(value_stripped) continue - except (ValueError, TypeError): + except (ValueError, TypeError, OverflowError): pass elif isinstance(value, (int, float)): normalized[key] = float(value) @@ -5573,7 +5573,7 @@ def save_plugin_config(): try: normalized_array.append(int(v)) continue - except (ValueError, TypeError): + except (ValueError, TypeError, OverflowError): pass elif isinstance(v, (int, float)): normalized_array.append(int(v)) @@ -5583,7 +5583,7 @@ def save_plugin_config(): try: normalized_array.append(float(v)) continue - except (ValueError, TypeError): + except (ValueError, TypeError, OverflowError): pass elif isinstance(v, (int, float)): normalized_array.append(float(v)) @@ -5599,7 +5599,7 @@ def save_plugin_config(): if isinstance(v, str): try: normalized_array.append(int(v)) - except (ValueError, TypeError): + except (ValueError, TypeError, OverflowError): normalized_array.append(v) elif isinstance(v, (int, float)): normalized_array.append(int(v)) @@ -5613,7 +5613,7 @@ def save_plugin_config(): if isinstance(v, str): try: normalized_array.append(float(v)) - except (ValueError, TypeError): + except (ValueError, TypeError, OverflowError): normalized_array.append(v) else: normalized_array.append(v) @@ -5636,7 +5636,7 @@ def save_plugin_config(): if isinstance(value, str): try: normalized[key] = int(value) - except (ValueError, TypeError): + except (ValueError, TypeError, OverflowError): normalized[key] = value else: normalized[key] = value @@ -5645,7 +5645,7 @@ def save_plugin_config(): if isinstance(value, str): try: normalized[key] = float(value) - except (ValueError, TypeError): + except (ValueError, TypeError, OverflowError): normalized[key] = value else: normalized[key] = value @@ -6788,7 +6788,7 @@ def get_font_preview() -> tuple[Response, int] | Response: # Safe integer parsing for size try: size = int(request.args.get('size', 12)) - except (ValueError, TypeError): + except (ValueError, TypeError, OverflowError): return jsonify({'status': 'error', 'message': 'Invalid font size'}), 400 if not font_filename: @@ -8369,7 +8369,7 @@ def clear_old_errors(): context={'provided_value': raw_max_age}, status_code=400 ) - except (ValueError, TypeError): + except (ValueError, TypeError, OverflowError): return error_response( error_code=ErrorCode.INVALID_INPUT, message="max_age_hours must be a valid integer", From 333fd17d28a39e34cf21ee087b7dde27509a9e93 Mon Sep 17 00:00:00 2001 From: Chuck <33324927+ChuckBuilds@users.noreply.github.com> Date: Tue, 25 Aug 2026 08:32:08 -0400 Subject: [PATCH 07/29] fix(memory): release fetched payloads once they have been delivered (#499) * fix(memory): release fetched payloads once they have been delivered BackgroundDataService kept the fetched body on the FetchResult it filed in completed_requests, which is swept hourly and capped at 500 entries by count. For a status record that costs nothing; for a season schedule it costs a tenth of the board. Measured on a 1GB Pi 3B+ with a 1-second RSS profile: the display process sat at 404MB after plugin load, then stepped +21MB when NFL fetched its season and +90MB when NCAA football fetched 946 games for 2026 -- and stayed at 494MB. Not a leak; a staircase that never came down. When a later fetch landed while headroom was low, available memory reached ~70MB, fork() began failing, and the board stopped being able to start a process at all: sshd accepted connections and closed them before its banner, systemd could not respawn the display, and the panel went dark while the kernel carried on answering pings. The cache-hit path was the worse of the two. It runs once per update interval per sport, mints a fresh request_id each time, and files whatever the cache returned. The memory tier is capped at 150 entries on a 1GB board, so a miss re-parses the payload from disk into a genuinely new object -- separate copies accumulating toward the 500-entry cap, not shared references. Releasing is safe: the payload is written to the cache under the request's cache_key before the result is built, the callback is handed the object directly, and consumers read it back from the cache afterwards (the plugins' callbacks use it only in passing, to log a count, before reading the cache). Nothing is lost -- it moves from RAM to the disk cache that was already holding it. Requests submitted without a callback keep their payload, since polling get_result() is then the only way to collect it. That keeps the existing contract, and the existing tests covering it, intact. Not addressed here: max_workers=3 allows three concurrent fetches, so three large parses can peak at once, and there is no in-flight dedupe by cache_key -- a second submit for a key already being fetched starts a second fetch. Both bound the transient peak rather than what stays resident, and both are behaviour changes worth their own review. Co-Authored-By: Claude Opus 5 * fix(memory): file the cache-hit result before running its callback Restores the original ordering. Releasing the payload after the callback meant filing the result after it too, so a callback that queried get_result() or is_request_complete() for its own request would not have found it -- a behaviour change unrelated to the memory fix. The dict holds a reference to the same object, so releasing after filing still clears the payload. Co-Authored-By: Claude Opus 5 * test: wait for the payload release, not just the filing The callback test waited on is_request_complete(), which goes true as soon as the worker files the result in completed_requests. The worker then runs the cleanup pass, then the callback, then releases the payload. Both of the test's assertions therefore raced the worker: `seen` is populated by the callback, and `data is None` only after the release that follows it. It passes today because a one-line callback usually finishes inside the 20ms poll interval. Confirmed by making the callback sleep 0.4s: _wait() returns with seen == {} and the payload still resident. _wait_for_release() polls for the released payload instead. Release happens strictly after the callback returns, so a released payload also means the callback has finished and one wait covers both assertions. Verified against the same 0.4s callback. _wait() stays for the other three fetch-path tests, which assert only what is already true when the result is filed -- the success flag, the error, and the cache write that happened during the fetch itself. Its docstring now says so, so the next reader picks the right one. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01STMbQE4YctTacQXfbYqKuW --------- Co-authored-by: Claude Opus 5 --- src/background_data_service.py | 42 +++++- test/test_background_payload_release.py | 187 ++++++++++++++++++++++++ 2 files changed, 225 insertions(+), 4 deletions(-) create mode 100644 test/test_background_payload_release.py diff --git a/src/background_data_service.py b/src/background_data_service.py index 355c93c4..99e101a4 100644 --- a/src/background_data_service.py +++ b/src/background_data_service.py @@ -57,7 +57,15 @@ class FetchRequest: @dataclass class FetchResult: - """Result of a background fetch operation.""" + """Result of a background fetch operation. + + ``data`` survives on the stored result only for requests submitted without + a ``callback``, where polling ``get_result()`` is the sole way to collect + it. When a callback was given, the payload has already been delivered and + the service releases it -- see :meth:`BackgroundDataService._release_payload`. + Either way the data remains in the cache under the request's ``cache_key``, + which is where consumers read it from. + """ request_id: str success: bool data: Optional[Any] = None @@ -191,14 +199,19 @@ class BackgroundDataService: cached=True, fetch_time=0.0 ) + # Filed before the callback runs, as it always was: a callback + # that queries get_result()/is_request_complete() for its own + # request must still find it. Releasing afterwards mutates the + # same object the dict holds. self.completed_requests[request_id] = result - + if callback: try: callback(result) except Exception as e: logger.error(f"Error in callback for request {request_id}: {e}") - + self._release_payload(result) + logger.debug(f"Cache hit for {sport} {year} data") return request_id @@ -333,8 +346,29 @@ class BackgroundDataService: request.callback(result) except Exception as e: logger.error(f"Error in callback for request {request.id}: {e}") - + # Delivered. Drop both references -- they point at the same + # object, so one survivor keeps the whole payload resident. + self._release_payload(result) + request.result = None + return result + + @staticmethod + def _release_payload(result: FetchResult) -> None: + """Drop a delivered payload, keeping the result's status and timings. + + Only called once a callback has been handed the data. Consumers read + fetched data back from the cache under ``cache_key``; the copy carried + here was pinning a parsed season schedule -- 946 games for NCAA + football, roughly a tenth of total RAM on a 1GB Pi -- in memory until + the hourly sweep. + + The cache-hit path matters most: it runs once per update interval per + sport, mints a fresh request_id each time, and a memory-tier miss + re-parses the payload from disk. Those were genuinely separate copies + accumulating toward the 500-entry cap, not shared references. + """ + result.data = None def _make_request_with_retry(self, request: FetchRequest) -> requests.Response: """ diff --git a/test/test_background_payload_release.py b/test/test_background_payload_release.py new file mode 100644 index 00000000..3f6a72c1 --- /dev/null +++ b/test/test_background_payload_release.py @@ -0,0 +1,187 @@ +"""A delivered fetch payload must not stay resident on the stored result. + +BackgroundDataService kept the fetched body on the FetchResult it filed in +`completed_requests`, which is swept only hourly and capped at 500 entries by +count. For status records that is free; for a season schedule it is not. NCAA +football's 2026 schedule is 946 games, and on a 1GB Pi 3B+ the parsed payload +measured ~90MB -- a tenth of the board's memory, pinned for an hour after the +consumer had already been handed it. + +The cache-hit path was the worse of the two. It runs once per update interval +per sport, mints a fresh request_id each time, and hands back whatever the +cache returns -- so a memory-tier miss (the tier is capped at 150 entries) +re-parses the payload from disk into a genuinely new object. Those accumulate +as separate copies rather than shared references, which is the staircase seen +in the field: RSS stepping up ~90MB per sport as seasons loaded and never +coming back down. + +Releasing is safe because the payload is written to the cache under the +request's cache_key before the result is built, and that is where consumers +read it from -- the callback is handed the object directly and the plugins use +it only in passing before reading the cache back. + +Requests submitted *without* a callback keep their payload: polling +get_result() is then the only way to collect it, so releasing would break that +contract. +""" + +import time +import pytest +from unittest.mock import MagicMock, Mock, patch + +from src.background_data_service import BackgroundDataService + + +PAYLOAD = {"events": [{"id": f"g{i}"} for i in range(50)]} + + +@pytest.fixture +def cache(): + m = MagicMock() + m.get.return_value = None + m.set.return_value = None + return m + + +@pytest.fixture +def service(cache): + svc = BackgroundDataService(cache, max_workers=2, request_timeout=5) + yield svc + svc.shutdown(wait=False) + + +def _wait(service, req_id, timeout=5): + """Wait for the result to be FILED. + + Enough for anything that is true by the time the worker stores the result: + its success flag, its error, the cache write that happened during the + fetch. + """ + deadline = time.time() + timeout + while not service.is_request_complete(req_id) and time.time() < deadline: + time.sleep(0.02) + + +def _wait_for_release(service, req_id, timeout=5): + """Wait for the payload to be RELEASED, which is strictly later. + + The worker files the result, then runs the callback, then releases. So + is_request_complete() goes true while the callback still has not run -- + waiting on it alone leaves a window in which `seen` is empty and the + payload is still resident, and the assertions race the worker. It passes + in practice only because a one-line callback usually beats the 20ms poll. + + Release happens after the callback returns, so a released payload also + means the callback has finished: one wait covers both. + """ + deadline = time.time() + timeout + while time.time() < deadline: + result = service.get_result(req_id) + if result is not None and result.data is None: + return + time.sleep(0.02) + raise AssertionError( + f"payload for {req_id} was never released (callback may not have run)") + + +def _resp(): + r = Mock() + r.json.return_value = PAYLOAD + r.raise_for_status.return_value = None + return r + + +class TestFetchPath: + def test_callback_receives_the_payload_then_it_is_released(self, service, cache): + seen = {} + + def callback(result): + # The consumer's one look at the data happens here. + seen['events'] = len(result.data['events']) + + with patch.object(service.session, "get", return_value=_resp()): + req_id = service.submit_fetch_request( + sport="ncaa_fb", year=2026, url="https://example.com/s", + cache_key="ncaa_fb_2026", callback=callback, max_retries=0, + ) + _wait_for_release(service, req_id) + + assert seen['events'] == 50, "callback must still be handed the payload" + + stored = service.get_result(req_id) + assert stored is not None + assert stored.success is True + assert stored.data is None, "payload must not stay on the stored result" + + def test_nothing_is_lost_the_cache_holds_it(self, service, cache): + with patch.object(service.session, "get", return_value=_resp()): + req_id = service.submit_fetch_request( + sport="ncaa_fb", year=2026, url="https://example.com/s", + cache_key="ncaa_fb_2026", callback=lambda r: None, max_retries=0, + ) + _wait(service, req_id) + + cache.set.assert_called_once() + key, written = cache.set.call_args[0][:2] + assert key == "ncaa_fb_2026" + assert written == PAYLOAD, "the payload must be persisted before release" + + def test_without_a_callback_the_payload_is_kept(self, service, cache): + # Polling get_result() is then the only delivery mechanism. + with patch.object(service.session, "get", return_value=_resp()): + req_id = service.submit_fetch_request( + sport="nfl", year=2026, url="https://example.com/s", + cache_key="nfl_2026", max_retries=0, + ) + _wait(service, req_id) + + assert service.get_result(req_id).data == PAYLOAD + + def test_a_failed_fetch_still_records_its_error(self, service, cache): + with patch.object(service.session, "get", side_effect=Exception("boom")): + req_id = service.submit_fetch_request( + sport="nfl", year=2026, url="https://example.com/s", + cache_key="nfl_2026", callback=lambda r: None, max_retries=0, + ) + _wait(service, req_id) + + stored = service.get_result(req_id) + assert stored.success is False + assert stored.error is not None + + +class TestCacheHitPath: + def test_cache_hit_releases_after_the_callback(self, service, cache): + cache.get.return_value = PAYLOAD + seen = {} + + req_id = service.submit_fetch_request( + sport="ncaa_fb", year=2026, url="https://example.com/s", + cache_key="ncaa_fb_2026", + callback=lambda r: seen.update(events=len(r.data['events'])), + ) + + assert seen['events'] == 50 + assert service.get_result(req_id).data is None + + def test_repeated_cache_hits_do_not_accumulate_payloads(self, service, cache): + # The staircase: one entry per update interval per sport, each one + # potentially a freshly parsed copy after a memory-tier miss. + cache.get.return_value = PAYLOAD + + for _ in range(25): + service.submit_fetch_request( + sport="ncaa_fb", year=2026, url="https://example.com/s", + cache_key="ncaa_fb_2026", callback=lambda r: None, + ) + + retained = [r for r in service.completed_requests.values() if r.data is not None] + assert retained == [], f"{len(retained)} payloads still resident" + + def test_cache_hit_without_a_callback_is_unchanged(self, service, cache): + cache.get.return_value = PAYLOAD + req_id = service.submit_fetch_request( + sport="nfl", year=2026, url="https://example.com/s", + cache_key="nfl_2026", + ) + assert service.get_result(req_id).data == PAYLOAD From f90638a9eca146c93bd3236e23ae1e3fd46e4d49 Mon Sep 17 00:00:00 2001 From: Chuck <33324927+ChuckBuilds@users.noreply.github.com> Date: Tue, 25 Aug 2026 08:40:28 -0400 Subject: [PATCH 08/29] feat(web): show available memory in Tools diagnostics (#500) * feat(web): show available memory in Tools diagnostics System Diagnostics reported memory as used-percent plus used/total GB. Neither distinguishes a healthy board from one about to fail, because page cache counts as used and is reclaimable on demand -- a Pi can read 70% used and be fine, or read the same and be minutes from trouble. MemAvailable is the kernel's own estimate of what a new allocation can actually obtain, and it is the number that tracked the failure on a 1GB Pi 3B+: healthy running sat above 500MB, and the crash came at 73MB. By that point fork() was failing, so sshd could not spawn a session and systemd could not respawn the display, while the kernel carried on answering pings at 0% loss. Used-percent gave no warning at any point on the way there; available memory fell steadily for hours. /api/v3/system/status now returns memory_available_mb from psutil.virtual_memory().available, and Tools renders it as its own tile, coloured against the thresholds that failure implies: red under 150MB, amber under 300MB, green above. The existing memory tile is left alone -- used/total is still what you want when sizing a workload; this answers the different question of how much room is left right now. Co-Authored-By: Claude Opus 5 * fix(web): round available memory once, so the tile agrees with itself The colour was classified from the raw value while the label was rounded, and the API sends one decimal place. At the boundaries the two disagreed: 149.6 rendered as "150 MB" in red, and 299.6 as "300 MB" in amber -- each contradicting the threshold its own colour claims to apply ("red under 150MB"). A reader checking the tile against the documented thresholds would conclude the readout was broken. Rounding once and using that number for both restores agreement. It moves those two boundary cases up a band, which does not matter: the thresholds come from a measured failure at 73MB, so which side of the line a spare 0.4MB falls on carries no information. The tile agreeing with itself does. Null handling and the thresholds themselves are unchanged. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01STMbQE4YctTacQXfbYqKuW --------- Co-authored-by: Claude Opus 5 --- test/test_system_status_available_memory.py | 92 +++++++++++++++++++ web_interface/blueprints/api_v3.py | 5 + .../templates/v3/partials/tools.html | 23 +++++ 3 files changed, 120 insertions(+) create mode 100644 test/test_system_status_available_memory.py diff --git a/test/test_system_status_available_memory.py b/test/test_system_status_available_memory.py new file mode 100644 index 00000000..c08bde22 --- /dev/null +++ b/test/test_system_status_available_memory.py @@ -0,0 +1,92 @@ +"""/api/v3/system/status must report MemAvailable, not just used/total. + +"Memory used %" cannot tell a healthy board from one about to fail. Page cache +counts as used and is reclaimable on demand, so a Pi can read 70% used and be +perfectly fine, or read the same and be minutes from trouble. MemAvailable is +the kernel's own estimate of what a new allocation can actually obtain, and it +is the number that tracked the failure on a 1GB Pi 3B+: healthy running sat at +500MB+, the crash happened at 73MB, and by then fork() was failing -- sshd +could not spawn a session and systemd could not respawn the display, while the +kernel carried on answering pings. + +psutil.virtual_memory().available is MemAvailable on Linux. total - used is not +a substitute: they diverge exactly when unreclaimable memory (shmem, tmpfs) is +in play, which is when the distinction matters. +""" + +import json +import sys +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest +from flask import Flask + +sys.path.insert(0, str(Path(__file__).parent.parent)) + +MB = 1024 * 1024 + + +@pytest.fixture +def client(): + pytest.importorskip("psutil") + app = Flask(__name__) + app.config["TESTING"] = True + from web_interface.blueprints.api_v3 import api_v3 + for attr in ("config_manager", "plugin_manager", "cache_manager"): + setattr(api_v3, attr, MagicMock()) + if "api_v3" not in app.blueprints: + app.register_blueprint(api_v3, url_prefix="/api/v3") + return app.test_client() + + +def _memory(total_mb, used_mb, available_mb): + m = MagicMock() + m.total = total_mb * MB + m.used = used_mb * MB + m.available = available_mb * MB + m.percent = round(used_mb / total_mb * 100, 1) + return m + + +def _get_status(client, memory): + # The endpoint caches for 10s; bypass so each case is measured fresh. + with patch("web_interface.cache.get_cached", return_value=None), \ + patch("psutil.virtual_memory", return_value=memory), \ + patch("psutil.cpu_percent", return_value=5.0), \ + patch("psutil.boot_time", return_value=0.0): + resp = client.get("/api/v3/system/status") + assert resp.status_code == 200, resp.data + return json.loads(resp.data)["data"] + + +def test_available_memory_is_reported(client): + data = _get_status(client, _memory(total_mb=905, used_mb=620, available_mb=284)) + assert "memory_available_mb" in data + assert data["memory_available_mb"] == pytest.approx(284, abs=0.5) + + +def test_available_is_not_total_minus_used(client): + # The case the readout exists for: 600MB is "not used", but only 300MB can + # actually be allocated. Reporting used% alone would call this healthy. + data = _get_status(client, _memory(total_mb=1000, used_mb=400, available_mb=300)) + + derived = data["memory_total_mb"] - data["memory_used_mb"] + assert derived == pytest.approx(600, abs=1) + assert data["memory_available_mb"] == pytest.approx(300, abs=0.5) + assert data["memory_available_mb"] != pytest.approx(derived, abs=1), \ + "available must come from MemAvailable, not be derived from used" + + +def test_existing_memory_fields_are_unchanged(client): + data = _get_status(client, _memory(total_mb=905, used_mb=620, available_mb=284)) + assert data["memory_total_mb"] == pytest.approx(905, abs=0.5) + assert data["memory_used_mb"] == pytest.approx(620, abs=0.5) + assert "memory_used_percent" in data + + +def test_a_nearly_exhausted_board_reports_a_small_number(client): + # 73MB available is what the board actually read when it stopped being able + # to fork. The readout has to surface that rather than round it away. + data = _get_status(client, _memory(total_mb=905, used_mb=800, available_mb=73)) + assert data["memory_available_mb"] == pytest.approx(73, abs=0.5) diff --git a/web_interface/blueprints/api_v3.py b/web_interface/blueprints/api_v3.py index a5a2979f..35dfd885 100644 --- a/web_interface/blueprints/api_v3.py +++ b/web_interface/blueprints/api_v3.py @@ -1563,6 +1563,11 @@ def get_system_status(): 'memory_used_percent': round(memory_percent, 1), 'memory_total_mb': round(memory.total / (1024 * 1024), 1), 'memory_used_mb': round(memory.used / (1024 * 1024), 1), + # MemAvailable, not total-minus-used: it accounts for reclaimable + # page cache, so it is what actually predicts memory trouble. A + # board can read 70% "used" and be fine, or read the same and be + # about to fail fork(), and only this number tells them apart. + 'memory_available_mb': round(memory.available / (1024 * 1024), 1), 'cpu_temp': round(cpu_temp, 1) if cpu_temp is not None else None, 'disk_used_percent': round(disk_percent, 1), 'disk_total_gb': round(disk.total / (1024 * 1024 * 1024), 1), diff --git a/web_interface/templates/v3/partials/tools.html b/web_interface/templates/v3/partials/tools.html index f4f7cfdd..1d0fb104 100644 --- a/web_interface/templates/v3/partials/tools.html +++ b/web_interface/templates/v3/partials/tools.html @@ -687,12 +687,35 @@ const mUsedGb = d.memory_used_mb != null ? (d.memory_used_mb / 1024).toFixed(1) : null; const mTotGb = d.memory_total_mb != null ? (d.memory_total_mb / 1024).toFixed(1) : null; const temp = d.cpu_temp != null ? d.cpu_temp + '°C' : 'N/A'; + // Available memory is the number that predicts trouble. When it + // runs out the board does not fail cleanly: fork() starts + // returning ENOMEM, so sshd cannot spawn a session and systemd + // cannot respawn the display, while the kernel keeps answering + // pings. Thresholds are drawn from that failure -- it was + // measured at 73MB free, and healthy running sits well above. + // Round ONCE, then colour and label off the same number. + // The API sends one decimal place, so classifying the raw + // value and displaying the rounded one disagreed at the + // boundaries: 149.6 rendered as "150 MB" in red, and 299.6 as + // "300 MB" in amber, both contradicting the threshold the + // colour claims to apply. Which side of the line a spare + // 0.4MB falls on does not matter; the tile agreeing with + // itself does. + const availMb = d.memory_available_mb == null + ? null : Math.round(d.memory_available_mb); + const availColor = availMb == null ? 'text-gray-400' + : availMb < 150 ? 'text-red-600' + : availMb < 300 ? 'text-amber-500' + : 'text-green-600'; panel.innerHTML = diagTile('fa-microchip', 'text-blue-600', 'CPU Usage', (d.cpu_percent != null ? d.cpu_percent : '--') + '%', null) + diagTile('fa-memory', 'text-green-600', 'Memory', (d.memory_used_percent != null ? d.memory_used_percent : '--') + '%', (mUsedGb && mTotGb) ? `${mUsedGb} / ${mTotGb} GB` : null) + + diagTile('fa-memory', availColor, 'Available Memory', + availMb != null ? `${availMb} MB` : '--', + mTotGb ? `of ${mTotGb} GB total` : null) + diagTile('fa-thermometer-half', 'text-red-600', 'CPU Temp', temp, null) + diagTile('fa-hdd', 'text-indigo-600', 'Disk', (d.disk_used_percent != null ? d.disk_used_percent : '--') + '%', From 39e7f8cbe0043cbdf8346ea48d722e8586b247f0 Mon Sep 17 00:00:00 2001 From: Ron Pierce Date: Tue, 25 Aug 2026 08:45:17 -0500 Subject: [PATCH 09/29] fix(plugins): cap the per-plugin state transition history (#501) * fix(plugins): cap the per-plugin state transition history PluginStateManager recorded every state transition in a per-plugin list and never trimmed it. The only code that removed entries was clear_state(), called solely from PluginManager.unload_plugin(), so a plugin that stays loaded -- normal operation -- never released one. The list is written on the hot scheduling path. Every update cycle appends twice: _reserve_for_update() sets RUNNING and _finish() sets ENABLED back again. At the default 60s update interval that is 2,880 entries per plugin per day, and nothing reads them -- get_state_info() only takes their len(). Pure dead weight. Measured against the unpatched class, ten plugins on a 60s interval: sim uptime history entries heap growth 1 day 28,810 7.7 MB 7 days 201,610 53.9 MB 30 days 864,010 230.9 MB (still climbing) With the cap it is flat at 2,000 entries / 0.5 MB from day one. On a 1 GB board 231 MB of garbage is fatal on its own, and the failure is not a clean OOM: once MemAvailable falls far enough fork() starts returning ENOMEM, so sshd accepts connections and closes them before its banner while the kernel still answers pings. The board looks like a hardware fault and needs a power cycle. Same family as the ceilings added in #464. Retain the most recent 200 transitions per plugin in a deque and let the rest age out. state_history_count is surfaced through the web API, so the lifetime total is tracked separately rather than plateauing at the cap. get_state_history() now returns a copy under the lock; it was handing out the manager's own list, which a caller could mutate. Co-Authored-By: Claude Opus 5 * fix(plugins): copy history entries out, lock clear_state Review follow-ups on the transition history. get_state_history() copied only the outer list, so a caller holding a returned transition could rewrite the manager's record of what happened -- which contradicted the defensive-copy guarantee in its own docstring. Copy each entry too. Every value in a transition is immutable, so a shallow copy per entry is enough. test_get_state_history_entries_are_copies pins it; without the change it fails with 'tampered' == 'enabled'. clear_state() mutated five shared dicts without holding _lock, while every other mutator takes it. A concurrent set_state() could interleave and leave a plugin with history but no state. Drop the five as one unit. This does not close the wider unload-vs-worker race, which lives in PluginManager.unload_plugin() and predates this change: an update worker still in flight can call set_state() after clear_state() returns and recreate the entry. Serialising that needs the per-plugin lock held across worker join in unload_plugin(), which is a separate change. Co-Authored-By: Claude Opus 5 --------- Co-authored-by: Claude Opus 5 --- src/plugin_system/plugin_state.py | 87 ++++++++++---- test/test_plugin_state_history_cap.py | 166 ++++++++++++++++++++++++++ 2 files changed, 232 insertions(+), 21 deletions(-) create mode 100644 test/test_plugin_state_history_cap.py diff --git a/src/plugin_system/plugin_state.py b/src/plugin_system/plugin_state.py index 269bb423..8b52823e 100644 --- a/src/plugin_system/plugin_state.py +++ b/src/plugin_system/plugin_state.py @@ -6,14 +6,24 @@ with state transitions and queries. """ import threading +from collections import deque from enum import Enum -from typing import Optional, Dict, Any +from typing import Optional, Dict, Any, Deque, List from datetime import datetime import logging from src.logging_config import get_logger +# Transitions retained per plugin. The history is diagnostic only -- nothing +# reads the entries themselves, just their count -- but it is appended to on the +# hot scheduling path: every update cycle records RUNNING on reserve and ENABLED +# on finish. Unbounded, that is 2,880 entries per plugin per day at the default +# 60s interval, which on a 1 GB Pi exhausts memory in weeks. Keep the recent +# tail for debugging and let the rest age out. +MAX_STATE_HISTORY_PER_PLUGIN = 200 + + class PluginState(Enum): """Plugin state enumeration.""" UNLOADED = "unloaded" # Plugin not loaded @@ -37,11 +47,34 @@ class PluginStateManager: self.logger = logger or get_logger(__name__) self._lock = threading.RLock() self._states: Dict[str, PluginState] = {} - self._state_history: Dict[str, list] = {} + self._state_history: Dict[str, Deque[Dict[str, Any]]] = {} + # Lifetime transition totals, kept separately so the count reported by + # get_state_info() stays truthful once the history above starts rolling. + self._state_transition_counts: Dict[str, int] = {} self._error_info: Dict[str, Dict[str, Any]] = {} self._last_update: Dict[str, datetime] = {} self._last_display: Dict[str, datetime] = {} + def _record_transition( + self, + plugin_id: str, + transition: Dict[str, Any] + ) -> None: + """Append a transition to the plugin's bounded history. + + Callers must already hold ``_lock``. The deque discards its oldest + entry once it is full, so the history cannot grow without bound; the + lifetime total is tracked separately for get_state_info(). + """ + history = self._state_history.get(plugin_id) + if history is None: + history = deque(maxlen=MAX_STATE_HISTORY_PER_PLUGIN) + self._state_history[plugin_id] = history + history.append(transition) + self._state_transition_counts[plugin_id] = ( + self._state_transition_counts.get(plugin_id, 0) + 1 + ) + def set_state( self, plugin_id: str, @@ -60,16 +93,13 @@ class PluginStateManager: old_state = self._states.get(plugin_id, PluginState.UNLOADED) self._states[plugin_id] = state - if plugin_id not in self._state_history: - self._state_history[plugin_id] = [] - transition = { 'timestamp': datetime.now(), 'from': old_state.value, 'to': state.value, 'error': str(error) if error else None } - self._state_history[plugin_id].append(transition) + self._record_transition(plugin_id, transition) # Store error info if transitioning to ERROR state if state == PluginState.ERROR and error: @@ -126,17 +156,27 @@ class PluginStateManager: state = self.get_state(plugin_id) return state == PluginState.ENABLED - def get_state_history(self, plugin_id: str) -> list: + def get_state_history(self, plugin_id: str) -> List[Dict[str, Any]]: """ Get state transition history for a plugin. - + + Only the most recent MAX_STATE_HISTORY_PER_PLUGIN transitions are + retained; older ones age out. + Args: plugin_id: Plugin identifier - + Returns: - List of state transitions + List of recent state transitions, oldest first. Both the list and + the transition dicts are copies, so callers cannot mutate the + manager's own history. The values inside a transition are all + immutable, so a shallow copy per entry is enough. """ - return self._state_history.get(plugin_id, []) + with self._lock: + return [ + dict(transition) + for transition in self._state_history.get(plugin_id, ()) + ] def set_error_info(self, plugin_id: str, error_info: Dict[str, Any]) -> None: """ @@ -179,9 +219,7 @@ class PluginStateManager: old_state = self._states.get(plugin_id, PluginState.UNLOADED) self._states[plugin_id] = state - if plugin_id not in self._state_history: - self._state_history[plugin_id] = [] - self._state_history[plugin_id].append({ + self._record_transition(plugin_id, { 'timestamp': datetime.now(), 'from': old_state.value, 'to': state.value, @@ -252,15 +290,22 @@ class PluginStateManager: 'last_update': self.get_last_update(plugin_id), 'last_display': self.get_last_display(plugin_id), 'error_info': self.get_error_info(plugin_id), - 'state_history_count': len(self.get_state_history(plugin_id)) + 'state_history_count': self._state_transition_counts.get(plugin_id, 0) } return info def clear_state(self, plugin_id: str) -> None: - """Clear all state information for a plugin.""" - self._states.pop(plugin_id, None) - self._state_history.pop(plugin_id, None) - self._error_info.pop(plugin_id, None) - self._last_update.pop(plugin_id, None) - self._last_display.pop(plugin_id, None) + """Clear all state information for a plugin. + + Held under ``_lock`` so the five dicts are dropped as one unit: every + other mutator takes the lock, and without it a concurrent set_state() + could interleave and leave a plugin with history but no state. + """ + with self._lock: + self._states.pop(plugin_id, None) + self._state_history.pop(plugin_id, None) + self._state_transition_counts.pop(plugin_id, None) + self._error_info.pop(plugin_id, None) + self._last_update.pop(plugin_id, None) + self._last_display.pop(plugin_id, None) diff --git a/test/test_plugin_state_history_cap.py b/test/test_plugin_state_history_cap.py new file mode 100644 index 00000000..1d5e20d5 --- /dev/null +++ b/test/test_plugin_state_history_cap.py @@ -0,0 +1,166 @@ +"""Plugin state history must not grow without bound. + +`PluginStateManager` recorded every state transition in a per-plugin list and +never trimmed it. The only code that removed entries was `clear_state()`, called +solely from `PluginManager.unload_plugin()`, so a plugin that stays loaded -- +i.e. normal operation -- never released a single entry. + +The list is written on the hot scheduling path. Every update cycle appends +twice: `_reserve_for_update()` sets RUNNING and `_finish()` sets ENABLED back +again. At the default 60-second update interval that is 2,880 entries per +plugin per day, and nothing ever reads the entries -- `get_state_info()` only +takes their `len()`. It is pure dead weight. + +Measured against the unpatched class, ten plugins on a 60s interval retain +864,010 transitions after thirty simulated days, for 231 MB of heap. On a 1 GB +Pi that is fatal on its own, and the failure is not a clean OOM: once +MemAvailable falls far enough, fork() starts returning ENOMEM, so sshd accepts +connections and closes them before its banner while the kernel still answers +pings. The board looks like a hardware fault and needs a power cycle. + +These tests pin the cap, the retention order, and the one piece of behaviour the +cap must not change: `state_history_count` is surfaced through the web API, so +it has to keep reporting the lifetime total rather than plateauing at the cap. +""" + +import os +import sys + +import pytest + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +from src.plugin_system.plugin_state import ( # noqa: E402 + MAX_STATE_HISTORY_PER_PLUGIN, + PluginState, + PluginStateManager, +) + + +def _cycle_updates(manager, plugin_id, cycles): + """Drive the real scheduling path: RUNNING on reserve, ENABLED on finish.""" + for _ in range(cycles): + manager.set_state(plugin_id, PluginState.RUNNING) + manager.set_state(plugin_id, PluginState.ENABLED) + + +def test_state_history_is_capped(): + """A day of updates must not retain a day of transitions.""" + manager = PluginStateManager() + manager.set_state("clock", PluginState.ENABLED) + + # One simulated day at the default 60s update interval. + _cycle_updates(manager, "clock", 1440) + + history = manager.get_state_history("clock") + assert len(history) <= MAX_STATE_HISTORY_PER_PLUGIN, ( + f"history grew to {len(history)} entries; it is never trimmed" + ) + + +def test_state_history_keeps_the_most_recent_transitions(): + """Trimming drops the oldest entries, not the newest.""" + manager = PluginStateManager() + manager.set_state("clock", PluginState.ENABLED) + _cycle_updates(manager, "clock", MAX_STATE_HISTORY_PER_PLUGIN) + + history = manager.get_state_history("clock") + + # The scheduling cycle ends on ENABLED, so the newest entry is the + # RUNNING -> ENABLED half of the last cycle. + assert history[-1]["from"] == PluginState.RUNNING.value + assert history[-1]["to"] == PluginState.ENABLED.value + + # And the very first ENABLED transition has aged out. + assert history[0]["from"] != PluginState.UNLOADED.value + + +def test_state_history_count_reports_lifetime_total(): + """The count exposed through the API must not plateau at the cap. + + `get_state_info()['state_history_count']` is surfaced by the web UI. Capping + the retained list must not turn it into "entries we happen to still hold". + """ + manager = PluginStateManager() + manager.set_state("clock", PluginState.ENABLED) + total = 1 + + cycles = MAX_STATE_HISTORY_PER_PLUGIN * 2 + _cycle_updates(manager, "clock", cycles) + total += cycles * 2 + + info = manager.get_state_info("clock") + assert info["state_history_count"] == total + assert len(manager.get_state_history("clock")) <= MAX_STATE_HISTORY_PER_PLUGIN + + +def test_error_transitions_are_capped_too(): + """set_state_with_error() appends to the same list and needs the same cap.""" + manager = PluginStateManager() + manager.set_state("clock", PluginState.ENABLED) + + for _ in range(MAX_STATE_HISTORY_PER_PLUGIN * 2): + manager.set_state_with_error( + "clock", + PluginState.ENABLED, + {"reason": "update timeout"}, + error=RuntimeError("boom"), + ) + + assert len(manager.get_state_history("clock")) <= MAX_STATE_HISTORY_PER_PLUGIN + + +def test_history_is_isolated_per_plugin(): + """The cap is per plugin, not shared across the manager.""" + manager = PluginStateManager() + for plugin_id in ("clock", "weather"): + manager.set_state(plugin_id, PluginState.ENABLED) + _cycle_updates(manager, plugin_id, 50) + + assert len(manager.get_state_history("clock")) == 101 + assert len(manager.get_state_history("weather")) == 101 + + +def test_get_state_history_returns_a_copy(): + """Callers must not be able to mutate the manager's internal history.""" + manager = PluginStateManager() + manager.set_state("clock", PluginState.ENABLED) + + history = manager.get_state_history("clock") + history.clear() + + assert len(manager.get_state_history("clock")) == 1 + + +def test_get_state_history_entries_are_copies(): + """Copying the outer list is not enough -- the entries are handed out too. + + A caller holding a returned transition must not be able to rewrite the + manager's record of what happened. + """ + manager = PluginStateManager() + manager.set_state("clock", PluginState.ENABLED) + + entry = manager.get_state_history("clock")[0] + entry["to"] = "tampered" + entry["error"] = "injected" + + stored = manager.get_state_history("clock")[0] + assert stored["to"] == PluginState.ENABLED.value + assert stored["error"] is None + + +def test_clear_state_drops_history(): + """Unloading a plugin still releases everything it accumulated.""" + manager = PluginStateManager() + manager.set_state("clock", PluginState.ENABLED) + _cycle_updates(manager, "clock", 10) + + manager.clear_state("clock") + + assert manager.get_state_history("clock") == [] + assert manager.get_state_info("clock")["state_history_count"] == 0 + + +if __name__ == "__main__": + sys.exit(pytest.main([__file__, "-v"])) From bdced206dc05d738914ed7ab6a2c2fc564dafe22 Mon Sep 17 00:00:00 2001 From: Chuck <33324927+ChuckBuilds@users.noreply.github.com> Date: Wed, 26 Aug 2026 08:56:45 -0400 Subject: [PATCH 10/29] fix(plugins): retain state history by age, with the count as a ceiling (#502) * fix(plugins): retain state history by age, with the count as a ceiling Follow-up to the cap in this PR. A flat entry count answers the wrong question: what a reader wants from this history is "the last couple of hours", and how many transitions that is depends entirely on the plugin's update interval. On a real board those span 2s to 3600s, so 200 entries is interval 200 entries covers 2s 3.3 minutes (flights, live) 10s 16.7 minutes (jellyfin) 60s 1.7 hours (default) 300s 8.3 hours (news) 3600s 4.2 days -- the plugin churning hardest, the one worth looking at, keeps the least. So transitions are now trimmed by AGE first (STATE_HISTORY_MAX_AGE_SECONDS, two hours), which makes the retained window comparable whatever the cadence, and the count cap becomes purely a memory ceiling for pollers fast enough to exceed it inside that window. The ceiling rises 200 -> 2000: at ~230 bytes an entry that is ~0.5MB per plugin worst case, and only plugins updating faster than roughly every 4s can reach it. Steady-state memory is unchanged for everything slower, since the age trim binds first. Two details worth stating: - The trim reads time.monotonic(), stored alongside each transition, rather than the datetime already inside it. A DST shift or an NTP step would otherwise make every entry look ancient and flush the history in one go. The human-readable timestamp is untouched and still what get_state_history() returns. - Trimming happens on append, so a plugin that goes quiet keeps its last window until it writes again. That is deliberate: it is bounded either way, and a lazy trim costs nothing on the hot scheduling path. The guarantee is therefore about the SPAN of retained history, not its age against the current clock, and the test asserts it that way. The public shape is unchanged: get_state_history() still returns the same list of transition dicts, and state_history_count is still the lifetime total. test_plugin_state_history_retention.py adds 7 tests. Verified against this branch with only the age trim removed: 4 fail, 3 pass -- the three that survive are testing the count ceiling and the monotonic clock, which this commit does not change. Full suite 3753 passed, 60 skipped. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01STMbQE4YctTacQXfbYqKuW * fix(plugins): build get_state_info() as one locked snapshot Every field was read under its own lock, so an unload running concurrently could be observed half-done: 'state' read before clear_state() removed it and 'state_history_count' read after, handing PluginManager.get_plugin_info() a plugin that is ENABLED with zero transitions. The whole payload is now built in one critical section. _lock is an RLock, so the helpers called inside it can still take it. The regression test runs a reader against a thread that repeatedly fills and clears the same plugin, and fails on the first torn snapshot. Verified by removing only the lock: fails on 3 of 3 runs, passes on 3 of 3 with it. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01STMbQE4YctTacQXfbYqKuW --------- Co-authored-by: Claude Opus 5 (1M context) --- src/plugin_system/plugin_state.py | 86 +++++--- test/test_plugin_state_history_retention.py | 209 ++++++++++++++++++++ 2 files changed, 269 insertions(+), 26 deletions(-) create mode 100644 test/test_plugin_state_history_retention.py diff --git a/src/plugin_system/plugin_state.py b/src/plugin_system/plugin_state.py index 8b52823e..f9b9d7d0 100644 --- a/src/plugin_system/plugin_state.py +++ b/src/plugin_system/plugin_state.py @@ -6,22 +6,38 @@ with state transitions and queries. """ import threading +import time from collections import deque from enum import Enum -from typing import Optional, Dict, Any, Deque, List +from typing import Optional, Dict, Any, Deque, List, Tuple from datetime import datetime import logging from src.logging_config import get_logger -# Transitions retained per plugin. The history is diagnostic only -- nothing -# reads the entries themselves, just their count -- but it is appended to on the -# hot scheduling path: every update cycle records RUNNING on reserve and ENABLED -# on finish. Unbounded, that is 2,880 entries per plugin per day at the default -# 60s interval, which on a 1 GB Pi exhausts memory in weeks. Keep the recent -# tail for debugging and let the rest age out. -MAX_STATE_HISTORY_PER_PLUGIN = 200 +# The history is diagnostic only -- nothing reads the entries themselves, just +# their count -- but it is appended to on the hot scheduling path: every update +# cycle records RUNNING on reserve and ENABLED on finish. Unbounded, that is +# 2,880 entries per plugin per day at the default 60s interval, which on a 1 GB +# Pi exhausts memory in weeks. +# +# Two limits, because a single entry count answers the wrong question. What a +# reader wants is "the last couple of hours", and how many transitions that is +# depends entirely on the plugin's update interval -- which on a real board +# spans 2s to 3600s. A flat 200 entries is 4.2 days for the slowest plugin and +# 3.3 minutes for the fastest, so the plugin churning hardest, the one worth +# looking at, keeps the least history. +# +# So: trim by AGE first, which makes the retained window comparable across +# plugins whatever their cadence... +STATE_HISTORY_MAX_AGE_SECONDS = 2 * 60 * 60 + +# ...and cap by COUNT second, purely as a memory ceiling for the fast pollers +# whose age window would otherwise run to thousands of entries. At ~230 bytes +# an entry this is ~0.5 MB per plugin worst case, and only plugins updating +# faster than roughly every 4s can reach it. +MAX_STATE_HISTORY_PER_PLUGIN = 2000 class PluginState(Enum): @@ -47,7 +63,10 @@ class PluginStateManager: self.logger = logger or get_logger(__name__) self._lock = threading.RLock() self._states: Dict[str, PluginState] = {} - self._state_history: Dict[str, Deque[Dict[str, Any]]] = {} + # (monotonic timestamp, transition). The clock is monotonic so a DST + # shift or an NTP step cannot make entries look old and flush the + # history; the human-readable timestamp lives inside the transition. + self._state_history: Dict[str, Deque[Tuple[float, Dict[str, Any]]]] = {} # Lifetime transition totals, kept separately so the count reported by # get_state_info() stays truthful once the history above starts rolling. self._state_transition_counts: Dict[str, int] = {} @@ -70,7 +89,13 @@ class PluginStateManager: if history is None: history = deque(maxlen=MAX_STATE_HISTORY_PER_PLUGIN) self._state_history[plugin_id] = history - history.append(transition) + now = time.monotonic() + history.append((now, transition)) + # Age out first; the deque's maxlen is the backstop for plugins that + # produce more than the ceiling within the window. + cutoff = now - STATE_HISTORY_MAX_AGE_SECONDS + while history and history[0][0] < cutoff: + history.popleft() self._state_transition_counts[plugin_id] = ( self._state_transition_counts.get(plugin_id, 0) + 1 ) @@ -160,8 +185,10 @@ class PluginStateManager: """ Get state transition history for a plugin. - Only the most recent MAX_STATE_HISTORY_PER_PLUGIN transitions are - retained; older ones age out. + Retention is by age first -- transitions older than + STATE_HISTORY_MAX_AGE_SECONDS are dropped -- and by count second, at + MAX_STATE_HISTORY_PER_PLUGIN, which only binds for plugins updating + fast enough to exceed it inside that window. Args: plugin_id: Plugin identifier @@ -175,7 +202,7 @@ class PluginStateManager: with self._lock: return [ dict(transition) - for transition in self._state_history.get(plugin_id, ()) + for _stamp, transition in self._state_history.get(plugin_id, ()) ] def set_error_info(self, plugin_id: str, error_info: Dict[str, Any]) -> None: @@ -279,19 +306,26 @@ class PluginStateManager: Returns: Dictionary with state information """ - state = self.get_state(plugin_id) - info = { - 'state': state.value, - 'is_loaded': self.is_loaded(plugin_id), - 'is_enabled': self.is_enabled(plugin_id), - 'is_running': self.is_running(plugin_id), - 'is_error': self.is_error(plugin_id), - 'can_execute': self.can_execute(plugin_id), - 'last_update': self.get_last_update(plugin_id), - 'last_display': self.get_last_display(plugin_id), - 'error_info': self.get_error_info(plugin_id), - 'state_history_count': self._state_transition_counts.get(plugin_id, 0) - } + # One snapshot, one critical section. Each field was read under its own + # lock, so an unload running concurrently could be observed half-done: + # 'state' read before clear_state() removed it and + # 'state_history_count' read after, giving a caller a plugin that is + # ENABLED with zero transitions. _lock is an RLock, so the helpers + # below can still take it. + with self._lock: + state = self.get_state(plugin_id) + info = { + 'state': state.value, + 'is_loaded': self.is_loaded(plugin_id), + 'is_enabled': self.is_enabled(plugin_id), + 'is_running': self.is_running(plugin_id), + 'is_error': self.is_error(plugin_id), + 'can_execute': self.can_execute(plugin_id), + 'last_update': self.get_last_update(plugin_id), + 'last_display': self.get_last_display(plugin_id), + 'error_info': self.get_error_info(plugin_id), + 'state_history_count': self._state_transition_counts.get(plugin_id, 0) + } return info def clear_state(self, plugin_id: str) -> None: diff --git a/test/test_plugin_state_history_retention.py b/test/test_plugin_state_history_retention.py new file mode 100644 index 00000000..a7cf1edf --- /dev/null +++ b/test/test_plugin_state_history_retention.py @@ -0,0 +1,209 @@ +"""Retention is bounded by age first and by count second. + +The cap added in the parent change is a flat entry count, and an entry count +answers the wrong question. What a reader wants from this history is "the last +couple of hours"; how many transitions that is depends entirely on the +plugin's update interval, which on a real board spans 2s to 3600s. A flat 200 +entries is 4.2 days of history for the slowest plugin and 3.3 minutes for the +fastest -- so the plugin churning hardest, the one actually worth looking at, +keeps the least. + +Trimming by age makes the retained window comparable whatever the cadence, and +the count then serves only as a memory ceiling for pollers fast enough to +produce thousands of transitions inside that window. +""" + +import time +import pytest + +from src.plugin_system.plugin_state import ( + PluginState, + PluginStateManager, + MAX_STATE_HISTORY_PER_PLUGIN, + STATE_HISTORY_MAX_AGE_SECONDS, +) + + +class FakeClock: + """A monotonic clock the test drives, so no test has to sleep.""" + + def __init__(self): + self.t = 1000.0 + + def __call__(self): + return self.t + + def advance(self, seconds): + self.t += seconds + + +@pytest.fixture +def clock(monkeypatch): + c = FakeClock() + monkeypatch.setattr("src.plugin_system.plugin_state.time.monotonic", c) + return c + + +def _cycle(manager, plugin_id, clock, interval, cycles): + """One update cycle: RUNNING on reserve, ENABLED on finish.""" + for _ in range(cycles): + manager.set_state(plugin_id, PluginState.RUNNING) + manager.set_state(plugin_id, PluginState.ENABLED) + clock.advance(interval) + + +def test_transitions_older_than_the_window_are_dropped(clock): + m = PluginStateManager() + _cycle(m, "clock", clock, interval=60, cycles=10) + assert len(m.get_state_history("clock")) == 20 + + # Nothing happens for longer than the window, then one more cycle. + clock.advance(STATE_HISTORY_MAX_AGE_SECONDS + 1) + _cycle(m, "clock", clock, interval=60, cycles=1) + + assert len(m.get_state_history("clock")) == 2, ( + "only the transitions inside the window should survive") + + +def test_every_plugin_keeps_the_same_WINDOW_not_the_same_COUNT(clock): + """The point of the age policy, stated as the property that distinguishes it. + + Run both plugins for three times the retention window. Under a flat count + cap the slow one would still be holding transitions from hours before the + window, because it never produces enough entries to evict them. Under the + age policy each plugin retains its own last two hours and no more -- + different entry counts, same span of time. + """ + window = STATE_HISTORY_MAX_AGE_SECONDS + m = PluginStateManager() + + _cycle(m, "slow", clock, interval=60, cycles=(3 * window) // 60) + slow = len(m.get_state_history("slow")) + + # Assert the property directly rather than a derived count. The guarantee + # is about the SPAN of retained history, not its age against the current + # clock: trimming happens on append, so a plugin that has gone quiet keeps + # its last window until it writes again. That is intentional -- it is + # bounded either way, and a lazy trim costs nothing on the hot path. + stamps = [stamp for stamp, _ in m._state_history["slow"]] + assert stamps[-1] - stamps[0] <= window, ( + f"retained history spans {stamps[-1] - stamps[0]:.0f}s, " + f"window is {window}s") + assert slow < 2 * ((3 * window) // 60), ( + f"slow plugin kept {slow} entries -- three windows' worth was retained") + + clock.t = 1000.0 + _cycle(m, "fast", clock, interval=2, cycles=(3 * window) // 2) + fast = len(m.get_state_history("fast")) + + # Different counts, and the fast poller keeps more of them -- under a flat + # count cap these would be equal and the fast one would cover minutes. + assert fast > slow, f"fast={fast} slow={slow}" + + +def test_the_count_ceiling_still_bounds_a_fast_poller(clock): + """Age alone would let a 2s plugin hold 7,200 entries.""" + m = PluginStateManager() + _cycle(m, "flights", clock, interval=2, cycles=STATE_HISTORY_MAX_AGE_SECONDS) + assert len(m.get_state_history("flights")) <= MAX_STATE_HISTORY_PER_PLUGIN + + +def test_a_burst_inside_the_window_is_capped_not_kept(clock): + """Transitions with no time between them still cannot grow without bound.""" + m = PluginStateManager() + for _ in range(MAX_STATE_HISTORY_PER_PLUGIN * 3): + m.set_state("flapping", PluginState.RUNNING) # clock never advances + assert len(m.get_state_history("flapping")) <= MAX_STATE_HISTORY_PER_PLUGIN + + +def test_ageing_out_does_not_disturb_the_lifetime_count(clock): + m = PluginStateManager() + _cycle(m, "clock", clock, interval=60, cycles=10) + clock.advance(STATE_HISTORY_MAX_AGE_SECONDS + 1) + _cycle(m, "clock", clock, interval=60, cycles=1) + + assert len(m.get_state_history("clock")) == 2 + assert m.get_state_info("clock")["state_history_count"] == 22, ( + "the lifetime total must survive trimming, it is the flap signal") + + +def test_the_surviving_entries_are_the_recent_ones(clock): + m = PluginStateManager() + _cycle(m, "clock", clock, interval=60, cycles=5) + clock.advance(STATE_HISTORY_MAX_AGE_SECONDS + 1) + m.set_state("clock", PluginState.ERROR) + + history = m.get_state_history("clock") + assert [h["to"] for h in history] == ["error"] + + +def test_a_monotonic_clock_is_used_not_the_wall_clock(clock): + """A DST shift or NTP step must not flush the history. + + The trim reads time.monotonic(); the human-readable datetime inside each + transition is for display only. + """ + m = PluginStateManager() + _cycle(m, "clock", clock, interval=60, cycles=3) + before = len(m.get_state_history("clock")) + + import datetime as real_datetime + + class ShiftedDatetime(real_datetime.datetime): + @classmethod + def now(cls, tz=None): + return real_datetime.datetime(1999, 1, 1) # clock jumps backwards + + import src.plugin_system.plugin_state as ps + original = ps.datetime + ps.datetime = ShiftedDatetime + try: + m.set_state("clock", PluginState.ENABLED) + finally: + ps.datetime = original + + assert len(m.get_state_history("clock")) == before + 1, ( + "a wall-clock jump must not trim anything") + + +def test_get_state_info_is_a_consistent_snapshot(): + """An unload running concurrently must not be observed half-done. + + Each field used to be read under its own lock, so clear_state() could + interleave: 'state' read before the removal, 'state_history_count' after, + handing a caller a plugin that is ENABLED with zero transitions. The whole + payload is now built in one critical section. + """ + import threading + + m = PluginStateManager() + for _ in range(50): + m.set_state("clock", PluginState.RUNNING) + m.set_state("clock", PluginState.ENABLED) + + inconsistent = [] + stop = threading.Event() + + def reader(): + while not stop.is_set(): + info = m.get_state_info("clock") + # Either fully present or fully cleared -- never a live state with + # a wiped count. + if info["state"] != PluginState.UNLOADED.value and \ + info["state_history_count"] == 0: + inconsistent.append(info) + return + + def clearer(): + for _ in range(200): + for _ in range(20): + m.set_state("clock", PluginState.ENABLED) + m.clear_state("clock") + + t = threading.Thread(target=reader, daemon=True) + t.start() + clearer() + stop.set() + t.join(timeout=5) + + assert not inconsistent, f"observed a torn snapshot: {inconsistent[:1]}" From 5e5979973ed401c545e2a69250081f0f5498f4ee Mon Sep 17 00:00:00 2001 From: Chuck <33324927+ChuckBuilds@users.noreply.github.com> Date: Wed, 26 Aug 2026 09:13:23 -0400 Subject: [PATCH 11/29] Update README.md (#504) Signed-off-by: Chuck <33324927+ChuckBuilds@users.noreply.github.com> --- README.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 681176ef..2d61b614 100644 --- a/README.md +++ b/README.md @@ -161,9 +161,11 @@ The system supports live, recent, and upcoming game information for multiple spo ### LED Matrix Panels (2x in a horizontal chain is recommended) - [Adafruit 64×32](https://www.adafruit.com/product/2278) – designed for 128×32 but works with dynamic scaling on many displays (pixel pitch is user preference) +**Warning: Lately the Waveshare Panels have had different variations - only some are compatible with this project. I hope to identify what is different to fix it but so far there is a decent chance you get a mis-matched set of panels if you don't buy them all at once! ** - [Waveshare 64×32](https://amzn.to/3Kw55jK) - Does not require E addressable pad -- [Waveshare 96×48](https://amzn.to/4bydNcv) – higher resolution, requires soldering the **E addressable pad** on the [Adafruit RGB Bonnet](https://www.adafruit.com/product/3211) to “8” **OR** toggling the DIP switch on the Adafruit Triple LED Matrix Bonnet *(no soldering required!)* - > Amazon Affiliate Link – ChuckBuilds receives a small commission on purchases +- [Waveshare 96×48](https://amzn.to/4bydNcv) – higher resolution, requires soldering the **E addressable pad** on the [Adafruit RGB Bonnet](https://www.adafruit.com/product/3211) to “8” **OR** toggling the DIP switch on the Adafruit Triple LED Matrix Bonnet *(no soldering required!)* +- There are some Panels on Aliexpress that have worked fine for me, shop around! I think Adafruit is probably the "safest" but they do have some limitation on resolution and layout. + > Amazon Affiliate Links – ChuckBuilds receives a small commission on purchases ### Power Supply - [5V 4A DC Power Supply](https://www.adafruit.com/product/658) (good for 2 -3 displays, depending on brightness and pixel density, you'll need higher amperage for more) From af96bd5cb063d4f92b7f6dd9fc1376753cf2d744 Mon Sep 17 00:00:00 2001 From: Chuck <33324927+ChuckBuilds@users.noreply.github.com> Date: Wed, 26 Aug 2026 10:03:43 -0400 Subject: [PATCH 12/29] fix(memory): join an in-flight fetch instead of starting a duplicate (#503) * fix(memory): join an in-flight fetch instead of starting a duplicate submit_fetch_request() had no notion of "already fetching this". request_id embeds a millisecond timestamp, so every submit looked new, and active_requests is keyed by that id rather than by what is being fetched. Two submits for the same cache_key therefore started two identical fetches. It is not a rare race. _fetch_data in the sports managers branches: the Live manager fetches only today's games, but Recent and Upcoming both pull the full season schedule under the SAME cache_key. On a cache miss both miss, both submit, and nothing stops the second. On a running 512x64 board: 138 background fetches in 24 hours, arriving in pairs at identical millisecond timestamps, roughly hourly: 2 2026-08-25 11:47:26.612 2 2026-08-25 10:46:28.064 2 2026-08-25 08:01:55.962 Half of them redundant. Each duplicate costs a second download, a second JSON parse -- the expensive part on a Pi -- and a second parsed copy resident at the same time. Schedules on that board run 256KB to 20MB, 106MB across all sports. Because the pairs land in the same millisecond they also occupy two of the three executor slots with identical work, which is what makes two large parses peak simultaneously. A submit for a cache_key already in flight now joins that request: its callback is added to the existing one and the existing request_id is returned, so get_result() works for both. Different keys are untouched, and dedupe applies only while a fetch is in flight -- a submit after completion fetches again, because this is not a second cache layer. Three details: - The in-flight entry is dropped and the callback list snapshotted in the SAME critical section as filing the result. Otherwise a submitter could join a fetch whose callbacks had already run and never be called back. - Cancellation is the other way a request leaves active_requests, so it releases the key too. And the join path looks the request up rather than trusting the id, so an entry stranded any other way cannot wedge a key permanently -- it is dropped and a fresh fetch starts. - One callback raising no longer prevents the others being delivered. Previously there was only ever one. Interaction with #499, whichever merges second: that PR releases the payload after the callback runs. With several callbacks the release must happen after ALL of them, and must not happen at all if a joined submitter passed no callback, since polling get_result() would then be its only delivery path. The callback list built here is the hook for that. test_background_fetch_dedupe.py -- 8 tests, covering the join, callback delivery to both submitters, one callback raising, distinct keys not being coalesced, a post-completion submit fetching again, cancellation releasing the key, a stranded entry not wedging one, and the reported count. Verified non-vacuous by removing only the join branch: 3 fail. The callback tests assert the ids coalesced, without which they would pass trivially on two independent requests. Full suite: 3698 passed, 60 skipped, 1 failure that reproduces identically on unmodified main (test_install_lowmem, environment-dependent: /var/tmp is disk-backed on this machine). Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01STMbQE4YctTacQXfbYqKuW * fix(memory): discard a cancelled fetch instead of letting it commit Review follow-up on the dedupe. Cancelling releases the cache_key, so a replacement fetch for that key can start immediately. But _fetch_data_worker() had no cancellation check: the cancelled worker still wrote its response to the cache, flipped its own status from CANCELLED to COMPLETED, and ran its callbacks. The stale response could therefore land on top of the replacement's fresher data. The worker cannot abort an HTTP call in flight, so the response is discarded on return instead: no cache write, no callbacks, status left CANCELLED. The check sits immediately before the cache write, which is the first side effect. Also fixed, found by the new test rather than by reading: request_id was f"{sport}_{year}_{milliseconds}", which is not unique. Two submits inside the same millisecond produced the SAME id -- the test's two sequential fetches collided on a fast mocked response, and one request silently replaced the other in active_requests and completed_requests. Rare before this PR; load-bearing now, because dedupe hands that id back to every joiner as their handle for get_result(). A per-service counter is appended. Two test problems of my own, both fixed here rather than left to flake: - The cancellation test synchronised with time.sleep(0.4). A slow worker would have made it pass for the wrong reason. It now waits for the request to be filed in completed_requests. - The id-uniqueness test patched session.get, but submits are async: the 50 workers outlived the patch and made real DNS calls to the dummy host. It stubs the executor instead, which is what a submit-time test should exercise. 20 consecutive runs of the dedupe file: 0 failures. Full suite: 3700 passed, 60 skipped, 1 failure that reproduces identically on unmodified main (test_install_lowmem, environment-dependent). Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01STMbQE4YctTacQXfbYqKuW * fix(memory): make cancellation terminal, not advisory Three paths wrote request.status without checking whether the request had already been cancelled, so a cancel could be silently undone and the work it was meant to stop went ahead anyway. - A request cancelled while queued had CANCELLED overwritten with IN_PROGRESS the moment its worker started, defeating the discard check entirely: it downloaded, cached and called back for work the caller had withdrawn. It now skips the fetch outright, which is also the cheapest possible cancel. - The cancelled-check and the cache write were separate critical sections, so a cancel landing between them left the payload in the cache with the callbacks suppressed -- every submitter that joined the fetch waited for a call that never came. The worker now claims the commit in the same critical section that reads the status, and cancel_request refuses once claimed. The write stays outside the lock: it serialises a multi-megabyte payload to the SD card, and holding the service lock across that would stall every submit, status query and cancel behind it. - A cancelled request that then failed was relabelled FAILED, which slipped past the CANCELLED-only callback gate and delivered a spurious error callback. The except path now leaves CANCELLED alone. get_request_status() also reported a cancelled request as FAILED, since it inferred status from result.success; the final status is now recorded on the result. Both early returns assign to `result` so completed_requests files the outcome that was reported rather than the untouched placeholder. Tests cover cancellation before worker start, during the commit, and during an HTTP failure; all three fail against the unfixed code. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01STMbQE4YctTacQXfbYqKuW * test(memory): reach the exception path as a cancelled request test_a_failure_after_cancelling_stays_cancelled cancelled the request while its worker was still queued, so once the pre-start branch landed the worker returned there and never reached the exception handler the test is named for. It passed against the unfixed code only because that branch did not exist yet; with it, the test passed for the wrong reason and reverting the except-path guard did not fail it. Cancel while the worker is parked inside the HTTP call instead, and assert the fetch actually started so the test cannot silently degrade into the pre-start case again. Reverting each of the three guards now fails exactly one test. Also read the payload inside the callback rather than off the FetchResult afterwards: #499 releases result.data once delivery is done, so the later read saw the released object and not what the caller was handed. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01STMbQE4YctTacQXfbYqKuW --------- Co-authored-by: Claude Opus 5 (1M context) --- src/background_data_service.py | 162 +++++++++- test/test_background_fetch_dedupe.py | 450 +++++++++++++++++++++++++++ 2 files changed, 605 insertions(+), 7 deletions(-) create mode 100644 test/test_background_fetch_dedupe.py diff --git a/src/background_data_service.py b/src/background_data_service.py index 99e101a4..b3838322 100644 --- a/src/background_data_service.py +++ b/src/background_data_service.py @@ -14,11 +14,12 @@ Key Features: - Memory-efficient data storage """ +import itertools import time import logging import threading import requests -from typing import Dict, Any, Optional, Callable +from typing import Dict, Any, Optional, Callable, List from dataclasses import dataclass, field from enum import Enum import queue @@ -50,8 +51,19 @@ class FetchRequest: max_retries: int = 3 priority: int = 1 # Higher number = higher priority callback: Optional[Callable] = None + # Callbacks from submitters that JOINED this fetch instead of starting a + # duplicate one. The primary `callback` above belongs to whoever created + # the request; these belong to everyone who asked for the same cache_key + # while it was still in flight. + extra_callbacks: List[Callable] = field(default_factory=list) created_at: float = field(default_factory=time.time) status: FetchStatus = FetchStatus.PENDING + # Set once the worker has decided this response will be cached, while it + # still holds the lock. From that point cancelling is refused: the write + # is already authorised, and abandoning it here would put the payload in + # the cache with the callbacks suppressed -- joiners waiting forever for a + # fetch that did, in fact, succeed. + commit_claimed: bool = False result: Optional[Any] = None error: Optional[str] = None @@ -74,6 +86,10 @@ class FetchResult: fetch_time: float = 0.0 retry_count: int = 0 completed_at: float = field(default_factory=time.time) # Timestamp when request completed + # The request's final status, recorded so a finished request can still be + # reported accurately. Without it a caller can only be told COMPLETED or + # FAILED, which turns "you cancelled this" into "this errored". + final_status: Optional[FetchStatus] = None class BackgroundDataService: """ @@ -98,6 +114,20 @@ class BackgroundDataService: # Thread management self.executor = ThreadPoolExecutor(max_workers=max_workers, thread_name_prefix="BackgroundData") + # cache_key -> request_id for fetches currently in flight. Submitting + # the same key twice used to start two identical fetches: request_id + # carries a millisecond timestamp, so every submit looked new, and + # active_requests is keyed by it rather than by what is being fetched. + # On a real board the season-schedule key is requested by both the + # Recent and the Upcoming manager, which miss the cache in the same + # millisecond and each download and parse the same payload. + self._inflight_by_cache_key: Dict[str, str] = {} + # request_id was sport_year_milliseconds, which is not unique: two + # submits inside the same millisecond produced the SAME id, so one + # silently replaced the other in active_requests and completed_requests. + # Rare before, but dedupe hands this id back to every joiner as their + # handle for get_result(), so it has to be unique. A counter is enough. + self._request_seq = itertools.count() self.active_requests: Dict[str, FetchRequest] = {} self.completed_requests: Dict[str, FetchResult] = {} self.request_queue = queue.PriorityQueue() @@ -185,7 +215,9 @@ class BackgroundDataService: if cache_key is None: cache_key = self.get_sport_cache_key(sport) - request_id = f"{sport}_{year}_{int(time.time() * 1000)}" + with self._lock: + request_id = (f"{sport}_{year}_{int(time.time() * 1000)}" + f"_{next(self._request_seq)}") # Check cache first cached_data = self.cache_manager.get(cache_key) @@ -231,7 +263,29 @@ class BackgroundDataService: ) with self._lock: + existing_id = self._inflight_by_cache_key.get(cache_key) + existing = self.active_requests.get(existing_id) if existing_id else None + if existing_id and existing is None: + # Stranded index entry: the request it names is gone. Drop it and + # fetch normally. Looking the request up rather than trusting the + # id is what stops a stale entry wedging a key forever. + del self._inflight_by_cache_key[cache_key] + if existing is not None: + # Someone is already fetching this key. Ride along rather than + # duplicating the download, the parse and the resident copy. + if callback: + existing.extra_callbacks.append(callback) + self.stats['deduplicated_requests'] = ( + self.stats.get('deduplicated_requests', 0) + 1 + ) + logger.info( + "Joined in-flight fetch %s for %s (cache_key=%s) instead of " + "starting a duplicate", existing_id, sport, cache_key + ) + return existing_id + self.active_requests[request_id] = request + self._inflight_by_cache_key[cache_key] = request_id self.stats['total_requests'] += 1 self.stats['cache_misses'] += 1 @@ -256,7 +310,31 @@ class BackgroundDataService: try: with self._lock: - request.status = FetchStatus.IN_PROGRESS + # A request cancelled while it sat in the executor queue must + # stay cancelled. Overwriting the status here undid the cancel + # outright: the worker went on to download, cache and call back + # for work the caller had already withdrawn. + if request.status == FetchStatus.CANCELLED: + cancelled_before_start = True + else: + cancelled_before_start = False + request.status = FetchStatus.IN_PROGRESS + if cancelled_before_start: + logger.info( + "Request %s was cancelled before its worker started; " + "skipping the fetch entirely", request.id + ) + # Assign before returning: the finally block stores `result` + # in completed_requests, so building a fresh one here would + # file the untouched placeholder instead of this outcome. + result = FetchResult( + request_id=request.id, + success=False, + error="cancelled", + fetch_time=time.time() - start_time, + retry_count=request.retry_count + ) + return result logger.info(f"Starting background fetch for {request.sport} {request.year}") @@ -282,6 +360,37 @@ class BackgroundDataService: # Log data validation logger.debug(f"Validated {len(events)} events for {request.sport} {request.year}") + # A cancelled request must not commit anything. Cancelling + # releases the cache_key, so a replacement fetch for the same key + # may already be in flight or finished -- writing this response to + # the cache now would overwrite fresher data with the response + # nobody wanted. The worker has no way to abort the HTTP call, so + # this is where the work gets discarded. + with self._lock: + cancelled = request.status == FetchStatus.CANCELLED + if not cancelled: + # Claim the commit in the same critical section that read + # the status, so a cancel cannot slip in between the check + # and the cache write below. The write itself stays outside + # the lock: it serialises a multi-megabyte payload to the + # SD card, and holding the service lock across that would + # stall every submit, status query and cancel behind it. + request.commit_claimed = True + if cancelled: + logger.info( + "Discarding response for cancelled request %s; %s may " + "already belong to a replacement fetch", + request.id, request.cache_key + ) + result = FetchResult( + request_id=request.id, + success=False, + error="cancelled", + fetch_time=time.time() - start_time, + retry_count=request.retry_count + ) + return result + # Cache the data self.cache_manager.set(request.cache_key, data) @@ -307,7 +416,12 @@ class BackgroundDataService: logger.error(f"Failed to fetch {request.sport} {request.year} data: {error_msg}") with self._lock: - request.status = FetchStatus.FAILED + # Don't relabel a cancelled request. The callback gate in the + # finally block only suppresses CANCELLED, so promoting it to + # FAILED here delivered an error callback for a fetch nobody + # was waiting on any more. + if request.status != FetchStatus.CANCELLED: + request.status = FetchStatus.FAILED request.error = error_msg result = FetchResult( @@ -321,9 +435,26 @@ class BackgroundDataService: finally: # Store result and clean up with self._lock: + result.final_status = request.status self.completed_requests[request.id] = result if request.id in self.active_requests: del self.active_requests[request.id] + # Stop accepting joiners and take the callback list in the same + # critical section. A submitter that arrives after this point + # finds no in-flight entry and either hits the cache (written + # above, before the result was built) or starts a fresh fetch -- + # what it must never do is join a fetch whose callbacks have + # already run and then never be called. + if self._inflight_by_cache_key.get(request.cache_key) == request.id: + del self._inflight_by_cache_key[request.cache_key] + # A cancelled request delivers nothing: its joiners were told + # about a fetch that has been abandoned, and a replacement will + # call them via its own request. + if request.status == FetchStatus.CANCELLED: + callbacks = [] + else: + callbacks = ([request.callback] if request.callback else []) + callbacks.extend(request.extra_callbacks) # Update statistics if result.success: @@ -340,10 +471,11 @@ class BackgroundDataService: # Periodic cleanup after storing result self._cleanup_completed_requests() - # Call callback if provided - if request.callback: + # Call every callback: the original submitter's and any that joined + # this fetch. One raising must not stop the others being delivered. + for cb in callbacks: try: - request.callback(result) + cb(result) except Exception as e: logger.error(f"Error in callback for request {request.id}: {e}") # Delivered. Drop both references -- they point at the same @@ -456,6 +588,8 @@ class BackgroundDataService: return self.active_requests[request_id].status elif request_id in self.completed_requests: result = self.completed_requests[request_id] + if result.final_status is not None: + return result.final_status return FetchStatus.COMPLETED if result.success else FetchStatus.FAILED return None @@ -472,8 +606,22 @@ class BackgroundDataService: with self._lock: if request_id in self.active_requests: request = self.active_requests[request_id] + if request.commit_claimed: + # Too late: the worker holds an authorised commit. Report + # the failure rather than half-cancelling a request whose + # data is about to land in the cache. + logger.debug( + "Not cancelling %s: its response is already being " + "committed", request_id + ) + return False request.status = FetchStatus.CANCELLED del self.active_requests[request_id] + # Cancelling is the other way a request leaves active_requests, + # so the in-flight index has to be released here too or the key + # stays pointed at a request that no longer exists. + if self._inflight_by_cache_key.get(request.cache_key) == request_id: + del self._inflight_by_cache_key[request.cache_key] logger.info(f"Cancelled request {request_id}") return True return False diff --git a/test/test_background_fetch_dedupe.py b/test/test_background_fetch_dedupe.py new file mode 100644 index 00000000..4398a474 --- /dev/null +++ b/test/test_background_fetch_dedupe.py @@ -0,0 +1,450 @@ +"""A second request for a key already being fetched must join, not duplicate. + +request_id embeds a millisecond timestamp and active_requests is keyed by it, +so every submit looked new and nothing compared what was actually being +fetched. On a real board the season-schedule cache_key is requested by both +the Recent and the Upcoming manager: they miss the cache in the same +millisecond and each start a full download and parse of the same payload. +Measured on a running board, 138 background fetches in 24 hours arriving in +pairs at identical timestamps -- half of them redundant. + +The cost of a duplicate is a second download, a second JSON parse (the +expensive part on a Pi), and a second parsed copy resident at the same time. +Schedules on that board run from 256KB to 20MB. It also consumes a second of +the three executor slots with identical work, which is what makes two large +parses peak simultaneously. +""" + +import threading +import time +from unittest.mock import MagicMock, Mock, patch + +import pytest +import requests + +from src.background_data_service import BackgroundDataService, FetchStatus + + +PAYLOAD = {"events": [{"id": f"g{i}"} for i in range(20)]} + + +@pytest.fixture +def cache(): + m = MagicMock() + m.get.return_value = None # always a miss: force the fetch path + m.set.return_value = None + return m + + +@pytest.fixture +def service(cache): + svc = BackgroundDataService(cache, max_workers=3, request_timeout=5) + yield svc + svc.shutdown(wait=False) + + +def _resp(): + r = Mock() + r.json.return_value = PAYLOAD + r.raise_for_status.return_value = None + return r + + +def _wait(service, req_id, timeout=5): + deadline = time.time() + timeout + while not service.is_request_complete(req_id) and time.time() < deadline: + time.sleep(0.02) + + +class _BlockingSession: + """Holds the first fetch open so a second can be submitted mid-flight.""" + + def __init__(self): + self.calls = 0 + self.release = threading.Event() + self.started = threading.Event() + + def get(self, *a, **k): + self.calls += 1 + self.started.set() + self.release.wait(timeout=5) + return _resp() + + +def test_a_second_submit_for_the_same_key_does_not_fetch_twice(service): + session = _BlockingSession() + with patch.object(service, "session", session): + first = service.submit_fetch_request( + sport="nba", year=2026, url="https://x/s", cache_key="nba_2026", + callback=lambda r: None, max_retries=0) + assert session.started.wait(timeout=5) + + second = service.submit_fetch_request( + sport="nba", year=2026, url="https://x/s", cache_key="nba_2026", + callback=lambda r: None, max_retries=0) + + assert second == first, "the joiner should share the in-flight request id" + session.release.set() + _wait(service, first) + + assert session.calls == 1, f"the payload was fetched {session.calls} times" + + +def test_the_joiner_still_gets_its_callback(service): + session = _BlockingSession() + seen = [] + with patch.object(service, "session", session): + first = service.submit_fetch_request( + sport="nba", year=2026, url="https://x/s", cache_key="k", + callback=lambda r: seen.append("first"), max_retries=0) + assert session.started.wait(timeout=5) + joined = service.submit_fetch_request( + sport="nba", year=2026, url="https://x/s", cache_key="k", + callback=lambda r: seen.append("second"), max_retries=0) + # Assert the coalescing happened, otherwise this passes trivially: + # two independent requests would each fire their own callback and the + # test would say nothing about the joined path. + assert joined == first + session.release.set() + _wait(service, first) + + deadline = time.time() + 5 + while len(seen) < 2 and time.time() < deadline: + time.sleep(0.02) + assert sorted(seen) == ["first", "second"], ( + f"both submitters must be called back, got {seen}") + + +def test_one_callback_raising_does_not_silence_the_other(service): + session = _BlockingSession() + seen = [] + + def boom(result): + raise RuntimeError("consumer blew up") + + with patch.object(service, "session", session): + first = service.submit_fetch_request( + sport="nba", year=2026, url="https://x/s", cache_key="k", + callback=boom, max_retries=0) + assert session.started.wait(timeout=5) + joined = service.submit_fetch_request( + sport="nba", year=2026, url="https://x/s", cache_key="k", + callback=lambda r: seen.append("survivor"), max_retries=0) + # Same reason: without coalescing these are separate requests and + # neither callback can affect the other. + assert joined == first + session.release.set() + _wait(service, first) + + deadline = time.time() + 5 + while not seen and time.time() < deadline: + time.sleep(0.02) + assert seen == ["survivor"] + + +def test_different_keys_are_not_coalesced(service): + session = _BlockingSession() + with patch.object(service, "session", session): + a = service.submit_fetch_request( + sport="nba", year=2026, url="https://x/a", cache_key="key_a", + callback=lambda r: None, max_retries=0) + assert session.started.wait(timeout=5) + b = service.submit_fetch_request( + sport="nhl", year=2026, url="https://x/b", cache_key="key_b", + callback=lambda r: None, max_retries=0) + assert a != b, "different cache keys must not share a request" + session.release.set() + _wait(service, a) + _wait(service, b) + assert session.calls == 2 + + +def test_a_later_submit_after_completion_fetches_again(service): + """Dedupe is for concurrent requests only, not a second cache layer.""" + with patch.object(service.session, "get", side_effect=[_resp(), _resp()]) as get: + first = service.submit_fetch_request( + sport="nba", year=2026, url="https://x/s", cache_key="k", + callback=lambda r: None, max_retries=0) + _wait(service, first) + second = service.submit_fetch_request( + sport="nba", year=2026, url="https://x/s", cache_key="k", + callback=lambda r: None, max_retries=0) + _wait(service, second) + assert first != second + assert get.call_count == 2 + + +def test_cancelling_releases_the_key(service): + """A cancelled request must not wedge its key against future fetches.""" + session = _BlockingSession() + with patch.object(service, "session", session): + first = service.submit_fetch_request( + sport="nba", year=2026, url="https://x/s", cache_key="k", + callback=lambda r: None, max_retries=0) + assert session.started.wait(timeout=5) + service.cancel_request(first) + assert "k" not in service._inflight_by_cache_key + session.release.set() + + +def test_a_stranded_index_entry_cannot_wedge_a_key(service): + """Defensive: the request is looked up, not trusted from the id alone.""" + service._inflight_by_cache_key["ghost"] = "no_such_request" + with patch.object(service.session, "get", return_value=_resp()): + req = service.submit_fetch_request( + sport="nba", year=2026, url="https://x/s", cache_key="ghost", + callback=lambda r: None, max_retries=0) + _wait(service, req) + assert service.get_result(req).success is True + + +def test_the_deduplicated_count_is_reported(service): + session = _BlockingSession() + with patch.object(service, "session", session): + first = service.submit_fetch_request( + sport="nba", year=2026, url="https://x/s", cache_key="k", + callback=lambda r: None, max_retries=0) + assert session.started.wait(timeout=5) + service.submit_fetch_request( + sport="nba", year=2026, url="https://x/s", cache_key="k", + max_retries=0) + session.release.set() + _wait(service, first) + assert service.get_statistics().get("deduplicated_requests") == 1 + + +def test_a_cancelled_worker_cannot_overwrite_its_replacement(service, cache): + """Cancelling frees the key, so a replacement may already own it. + + The worker cannot abort an HTTP call in flight, so when the cancelled one + finally returns it must discard its response rather than write it. Without + that, the sequence is: cancel A, submit B for the same key, B fetches and + caches fresh data, A returns and overwrites it with the response nobody + wanted -- and calls A's callbacks too. + """ + slow = _BlockingSession() + stale = {"events": [{"id": "STALE"}]} + slow_resp = Mock() + slow_resp.json.return_value = stale + slow_resp.raise_for_status.return_value = None + + def blocked_get(*a, **k): + slow.calls += 1 + slow.started.set() + slow.release.wait(timeout=5) + return slow_resp + + called = [] + with patch.object(service.session, "get", side_effect=blocked_get): + first = service.submit_fetch_request( + sport="nba", year=2026, url="https://x/s", cache_key="k", + callback=lambda r: called.append("cancelled_one"), max_retries=0) + assert slow.started.wait(timeout=5) + + service.cancel_request(first) + assert "k" not in service._inflight_by_cache_key + + # The replacement writes the fresh value while the cancelled fetch is held. + fresh = {"events": [{"id": "FRESH"}]} + fresh_resp = Mock() + fresh_resp.json.return_value = fresh + fresh_resp.raise_for_status.return_value = None + with patch.object(service.session, "get", return_value=fresh_resp): + second = service.submit_fetch_request( + sport="nba", year=2026, url="https://x/s", cache_key="k", + callback=lambda r: called.append("replacement"), max_retries=0) + _wait(service, second) + + assert cache.set.call_args[0][1] == fresh, "replacement must own the cache" + + # Now let the cancelled fetch finish. It must write nothing and call nobody. + # Wait for the worker to actually finish rather than sleeping: a fixed + # sleep is a race under load, and a slow worker would make this pass for + # the wrong reason. A cancelled request is still filed in + # completed_requests, so that is the signal it has run to completion. + writes_before = cache.set.call_count + slow.release.set() + deadline = time.time() + 5 + while first not in service.completed_requests and time.time() < deadline: + time.sleep(0.02) + assert first in service.completed_requests, "cancelled worker never finished" + + assert cache.set.call_count == writes_before, ( + "the cancelled worker wrote to the cache after its replacement") + assert cache.set.call_args[0][1] == fresh, "stale data overwrote fresh" + assert "cancelled_one" not in called, ( + "a cancelled request must not deliver callbacks") + + +def test_request_ids_are_unique_within_a_millisecond(service): + """request_id was sport_year_milliseconds, which collides. + + Two submits inside the same millisecond produced the SAME id, so one + silently replaced the other in active_requests and completed_requests. + Dedupe hands this id back to every joiner as their handle for + get_result(), so uniqueness is now load-bearing rather than incidental. + """ + # Stub the executor rather than the session: this is about what submit + # hands back, and letting 50 workers loose would outlive the patch and + # make real network calls. + with patch.object(service.executor, "submit"): + ids = [ + service.submit_fetch_request( + sport="nba", year=2026, url="https://x/s", + cache_key=f"key_{i}", # distinct keys: no dedupe + callback=lambda r: None, max_retries=0) + for i in range(50) + ] + assert len(set(ids)) == len(ids), "request ids collided" + + +# --- cancellation must be terminal ------------------------------------------ +# +# Cancelling used to be advisory: three separate paths wrote request.status +# without checking whether the request had already been cancelled, so a cancel +# could be silently undone and the work it was meant to stop went ahead. + +URL = "http://example.invalid/scores" +KEY = "sched_nfl_2025" + + +class _CountingSession: + """Records whether an HTTP fetch was ever attempted.""" + + def __init__(self): + self.calls = 0 + + def get(self, *a, **k): + self.calls += 1 + return _resp() + + +class _BlockingFailingSession(_CountingSession): + """Holds the fetch open, then fails it. + + Cancelling while the worker is parked inside the HTTP call is the only way + to reach the exception handler as a cancelled request. Cancel it before + the call starts and the worker returns at the pre-start branch instead, + which would leave the except path untested. + """ + + def __init__(self): + super().__init__() + self.started = threading.Event() + self.release = threading.Event() + + def get(self, *a, **k): + self.calls += 1 + self.started.set() + self.release.wait(timeout=5) + raise requests.RequestException("connection reset") + + +def _fill_every_worker_slot(service, slots=3): + """Occupy the pool so the next submit is queued rather than started. + + This is what makes "cancel before the worker runs" deterministic instead + of a race the test would win only sometimes. Returns the gate that + releases the pool. + """ + gate = threading.Event() + for _ in range(slots): + service.executor.submit(gate.wait, 5) + return gate + + +def test_cancelling_before_the_worker_starts_stops_the_fetch(service, cache): + """The queued worker must honour a cancel, not overwrite it with IN_PROGRESS. + + Between submit and the worker picking the job up, the request sits in the + executor queue. Cancelling there is the cheapest possible cancel -- nothing + has been downloaded yet -- and it was the one that did not work. + """ + gate = _fill_every_worker_slot(service) + session = _CountingSession() + delivered = [] + + with patch.object(service, 'session', session): + rid = service.submit_fetch_request( + "nfl", 2025, URL, KEY, max_retries=0, callback=delivered.append + ) + assert service.cancel_request(rid) is True + gate.set() # let the queued worker run + _wait(service, rid) + + assert session.calls == 0, ( + "cancelled before it started, yet the worker still downloaded the payload" + ) + assert cache.set.call_count == 0, "a cancelled request wrote to the cache" + assert delivered == [], "a cancelled request invoked its callbacks" + + +def test_a_cancel_during_the_commit_is_refused(service, cache): + """Once the worker has claimed the commit, cancelling is too late. + + The claim and the cancelled-check happen in one critical section, so a + cancel arriving after it cannot retroactively abandon data already on its + way to the cache. Letting it through stranded every joiner: the payload + landed in the cache but the callbacks were suppressed, so a manager that + joined this fetch waited for a call that never came. + """ + gate = _fill_every_worker_slot(service) + late = {} + delivered = [] + + def cancel_mid_write(key, data, *a, **k): + late['returned'] = service.cancel_request(late['rid']) + + cache.set.side_effect = cancel_mid_write + + # Read the payload inside the callback. The service releases result.data + # once every callback has been delivered, so inspecting the FetchResult + # afterwards sees the released object, not what the caller was handed. + def record(result): + delivered.append((result.success, result.data)) + + with patch.object(service, 'session', _CountingSession()): + late['rid'] = service.submit_fetch_request( + "nfl", 2025, URL, KEY, max_retries=0, callback=record + ) + gate.set() # only now can the worker reach the commit + _wait(service, late['rid']) + + assert late.get('returned') is False, ( + "cancelled a request that had already committed" + ) + assert cache.set.call_count == 1, "the commit itself was lost" + assert delivered == [(True, PAYLOAD)], ( + "data reached the cache but the callbacks were suppressed -- " + "every joined submitter is left waiting forever" + ) + + +def test_a_failure_after_cancelling_stays_cancelled(service, cache): + """A cancelled request that then errors must not resurface as FAILED. + + The except path overwrote CANCELLED with FAILED, and the callback gate in + the finally block only suppresses callbacks for CANCELLED -- so cancelling + a request that was about to time out delivered a spurious error callback. + """ + session = _BlockingFailingSession() + delivered = [] + + with patch.object(service, 'session', session): + rid = service.submit_fetch_request( + "nfl", 2025, URL, KEY, max_retries=0, callback=delivered.append + ) + # Assert the worker is inside the HTTP call before cancelling, + # otherwise this silently degrades into the pre-start case and the + # exception handler is never exercised. + assert session.started.wait(timeout=5) + assert service.cancel_request(rid) is True + session.release.set() + _wait(service, rid) + + assert session.calls == 1, "the fetch never started, so nothing could fail" + + assert delivered == [], "a cancelled request delivered a failure callback" + assert service.get_request_status(rid) is FetchStatus.CANCELLED, ( + "a cancelled request that then errored was reported as FAILED" + ) From eae063700f916f713f618da340466868025cb890 Mon Sep 17 00:00:00 2001 From: Chuck <33324927+ChuckBuilds@users.noreply.github.com> Date: Wed, 26 Aug 2026 11:25:10 -0400 Subject: [PATCH 13/29] test(sports): pin the four-case sunset matrix before B6 runs (#505) B5 and B6 make different promises about an adopted scoreboard, and only the second is obvious. The table: | bundled copy present | bundled copy removed pinned old core | loads (the fallback) | ERROR, naming the module current core | loads, using core code | loads, using core code The top-left cell is the one worth having. Nothing in the suite proved that an adopted plugin still runs on a core predating src.common.sports_scroll, and that claim is the entire basis for having shipped B5 ahead of B6's gate. The bottom-left cell is asserted through PluginManager.load_plugin rather than a bare import, deliberately. The manager catches the ModuleNotFoundError, so a test written around pytest.raises would pass against a core where the module is merely broken rather than absent, and would say nothing about what the user meets: a plugin parked in ERROR and one log line. The assertion is the ERROR state plus an error naming the module, which is what makes the log actionable. Modelling notes, both of which were wrong first time and matter: - The copy-removed shape is an UNGUARDED import, not the guarded one with the legacy file deleted. Keeping the guard while removing its fallback only mislabels the failure -- the plugin reports a missing scroll_display_legacy and never mentions the core module that is actually absent. - Only the leaf module is hidden. A pre-3.2.0 core still ships src/common/; hiding the package would be a harsher core than any that shipped, and would make the failure name the package instead of the module. Ablation: disabling the old-core simulation fails three cells, and dropping the error object from load_plugin's set_state fails the fourth, so none of it passes vacuously. The install gate -- the other half of the guarantee -- stays in test_plugin_compatibility_gate.py rather than being duplicated here. Refs docs/SPORTS_UNIFICATION.md, "B6 -- why the sunset needs more than a version floor". Claude-Session: https://claude.ai/code/session_01STMbQE4YctTacQXfbYqKuW Co-authored-by: Claude Opus 5 (1M context) --- test/test_sports_sunset_matrix.py | 310 ++++++++++++++++++++++++++++++ 1 file changed, 310 insertions(+) create mode 100644 test/test_sports_sunset_matrix.py diff --git a/test/test_sports_sunset_matrix.py b/test/test_sports_sunset_matrix.py new file mode 100644 index 00000000..db6da2fb --- /dev/null +++ b/test/test_sports_sunset_matrix.py @@ -0,0 +1,310 @@ +"""What happens to an adopted sports plugin on each core it can meet. + +B5 moved the eight scoreboards onto `src.common.sports_scroll` behind a guarded +import, keeping a bundled copy as the fallback. B6 deletes those copies. The +two phases make *different* promises, and only the second one is obvious: + +| | bundled copy present | bundled copy removed | +|--------------------|---------------------------------|-----------------------------| +| **pinned old core**| loads -- B5's whole guarantee | ERROR, naming the module | +| **current core** | loads, using core code | loads, using core code | + +The top-left cell is the one worth having: nothing else in this suite proves an +adopted plugin still runs on a core that predates the module, and that claim is +the entire basis for having shipped B5 ahead of B6's gate. + +The bottom-left cell is the B6 failure mode, and it is asserted through +`PluginManager.load_plugin` rather than a bare import on purpose. The manager +catches the `ModuleNotFoundError`, so nothing propagates to a caller: a test +that expected `pytest.raises` would pass against a core where the module is +merely *broken* rather than absent, and would say nothing about what the user +actually experiences. What they get is a plugin parked in ERROR and one log +line -- which is precisely why B6 needs the install gate rather than trusting +the failure to be noticed. + +This covers the load path. The install/update gate -- which is what should stop +a sunset plugin reaching an old core in the first place -- is the other half of +the guarantee and is tested in test_plugin_compatibility_gate.py. + +See docs/SPORTS_UNIFICATION.md, "B6 -- why the sunset needs more than a version +floor". +""" + +import itertools +import json +import sys +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + +project_root = Path(__file__).parent.parent +if str(project_root) not in sys.path: + sys.path.insert(0, str(project_root)) + +from src.plugin_system.plugin_manager import PluginManager +from src.plugin_system.plugin_state import PluginState + + +_PLUGIN_IDS = itertools.count() + +CORE_MODULE = "src.common.sports_scroll" + +# Only the leaf. A pre-3.2.0 core still ships `src/common/` -- scroll_helper +# and friends live there -- and it is `sports_scroll.py` alone that is absent. +# Hiding the whole package would be a different, harsher core than any that +# shipped, and it would make the load failure name the package rather than the +# module, which is the thing a reader needs to see. +HIDDEN = (CORE_MODULE,) + + +class _PinnedOldCore: + """Make `src.common.sports_scroll` un-importable for the duration. + + A meta_path finder rather than a monkeypatched `__import__`: the plugin is + executed by the real loader through `exec_module`, so the block has to live + in the import system itself to be reached. + """ + + def __init__(self, *names): + self.names = set(names) + self._saved = {} + + def find_spec(self, fullname, path=None, target=None): + if fullname in self.names: + raise ModuleNotFoundError(f"No module named {fullname!r}", name=fullname) + return None + + def __enter__(self): + for name in list(sys.modules): + if name in self.names: + self._saved[name] = sys.modules.pop(name) + sys.meta_path.insert(0, self) + return self + + def __exit__(self, *exc): + sys.meta_path.remove(self) + sys.modules.update(self._saved) + return False + + +# The two source shapes, kept as literals rather than copied from a plugin at +# runtime: this file lives in the core repo and must not depend on a plugin +# checkout being present, and pinning the shapes here means a plugin that +# drifts away from one is a visible edit, not a silently weakened test. + +# B5, as the eight scoreboards ship today: prefer the core module, fall back to +# the bundled copy. The except clause is narrow on purpose -- a bare +# `except ImportError` would also swallow a failure raised *inside* a core +# module that is present, quietly loading the legacy copy and hiding a broken +# core install. +ADOPTED_WITH_FALLBACK = ''' +_USING_CORE_SCROLL = False +try: + from src.common.sports_scroll import SportsScrollDisplay as _Base + _USING_CORE_SCROLL = True +except ModuleNotFoundError as exc: + if exc.name not in {"src", "src.common", "src.common.sports_scroll"}: + raise + _Base = None + +if not _USING_CORE_SCROLL: + from scroll_display_legacy import LegacyScrollDisplay as _Base +''' + +# B6, once the copies are deleted: there is nothing to fall back to, so the +# guard goes with them. Keeping the try/except while removing the file it +# falls back to would only mislabel the failure -- the plugin would report a +# missing `scroll_display_legacy` and say nothing about the core module that +# is actually absent. +SUNSET_NO_FALLBACK = ''' +from src.common.sports_scroll import SportsScrollDisplay as _Base + +_USING_CORE_SCROLL = True +''' + +PLUGIN_BODY = ''' +from src.plugin_system.base_plugin import BasePlugin + + +class SunsetProbe(BasePlugin): + """Records which scroll implementation the guarded import selected.""" + + using_core_scroll = _USING_CORE_SCROLL + scroll_base = _Base + + def update(self): + pass + + def display(self, force_clear=False): + pass +''' + +LEGACY_COPY = ''' +class LegacyScrollDisplay: + """Stands in for the bundled pre-3.2.0 implementation.""" +''' + + +def _write_plugin(plugins_dir: Path, plugin_id: str, *, bundled_copy: bool) -> Path: + path = plugins_dir / plugin_id + path.mkdir(parents=True) + (path / "manifest.json").write_text( + json.dumps({ + "id": plugin_id, + "name": "Sunset Probe", + "version": "1.0.0", + "entry_point": "manager.py", + "class_name": "SunsetProbe", + "display_modes": ["sunset_probe"], + }), + encoding="utf-8", + ) + shape = ADOPTED_WITH_FALLBACK if bundled_copy else SUNSET_NO_FALLBACK + (path / "manager.py").write_text(shape + PLUGIN_BODY, encoding="utf-8") + if bundled_copy: + (path / "scroll_display_legacy.py").write_text(LEGACY_COPY, encoding="utf-8") + return path + + +@pytest.fixture +def load(tmp_path): + """Load a synthetic adopted plugin through the real PluginManager. + + Returns a callable taking the two axes of the matrix and handing back the + manager, so the caller can ask it for state and recorded error. + """ + plugins_dir = tmp_path / "plugin-repos" + plugins_dir.mkdir() + + def _load(*, core_has_module: bool, bundled_copy: bool, hide=HIDDEN): + # A distinct id per cell, from a counter that spans the whole session. + # The loader names plugin modules after the plugin id and sys.modules + # is process-global, so a per-test counter would hand the second test + # the first test's already-imported module -- which passes or fails on + # the wrong plugin's import. + plugin_id = f"sunset-probe-{next(_PLUGIN_IDS)}" + _write_plugin(plugins_dir, plugin_id, bundled_copy=bundled_copy) + + with patch('src.common.permission_utils.ensure_directory_permissions'): + manager = PluginManager( + plugins_dir=str(plugins_dir), + config_manager=MagicMock(), + display_manager=MagicMock(), + cache_manager=MagicMock(), + font_manager=MagicMock(), + ) + manager.discover_plugins() + if core_has_module: + ok = manager.load_plugin(plugin_id) + else: + with _PinnedOldCore(*hide): + ok = manager.load_plugin(plugin_id) + return manager, plugin_id, ok + + return _load + + +def _assert_loaded(manager, plugin_id, ok): + assert ok is True, ( + f"load_plugin returned False; state is " + f"{manager.state_manager.get_state(plugin_id)}, error " + f"{manager.state_manager.get_error_info(plugin_id)}" + ) + assert manager.state_manager.get_state(plugin_id) is not PluginState.ERROR + + +class TestBundledCopyPresent: + """B5's shape: the guarded import with the fallback still shipped.""" + + def test_old_core_falls_back_and_still_loads(self, load): + """The claim that made it safe to ship B5 before B6's gate.""" + manager, plugin_id, ok = load(core_has_module=False, bundled_copy=True) + + _assert_loaded(manager, plugin_id, ok) + plugin = manager.plugins[plugin_id] + assert plugin.using_core_scroll is False, ( + "the core module was hidden, so the plugin must be on its bundled copy" + ) + assert plugin.scroll_base.__name__ == "LegacyScrollDisplay" + + def test_current_core_prefers_the_core_module(self, load): + manager, plugin_id, ok = load(core_has_module=True, bundled_copy=True) + + _assert_loaded(manager, plugin_id, ok) + plugin = manager.plugins[plugin_id] + assert plugin.using_core_scroll is True, ( + "the bundled copy must not win while the core module is importable" + ) + assert plugin.scroll_base.__name__ == "SportsScrollDisplay" + + + def test_a_core_without_the_package_at_all_still_falls_back(self, load): + """The guard's other accepted names. + + Its except clause accepts `src` and `src.common` as well as the module + itself, so those branches exist in all eight shipped plugins. No core + that old is likely still running, but the code claiming to handle it is + real and nothing else exercises it -- an untested branch in a fallback + is exactly the kind that rots unnoticed until the fallback is needed. + """ + manager, plugin_id, ok = load( + core_has_module=False, bundled_copy=True, hide=(CORE_MODULE, "src.common") + ) + + _assert_loaded(manager, plugin_id, ok) + assert manager.plugins[plugin_id].using_core_scroll is False + + +class TestBundledCopyRemoved: + """B6's shape: the copies are gone and only the core module remains.""" + + def test_current_core_still_loads(self, load): + manager, plugin_id, ok = load(core_has_module=True, bundled_copy=False) + + _assert_loaded(manager, plugin_id, ok) + assert manager.plugins[plugin_id].using_core_scroll is True + + def test_old_core_errors_and_records_the_missing_module(self, load): + """The B6 failure mode, as the user meets it. + + Not `pytest.raises`: load_plugin catches it, so nothing reaches a + caller. The observable consequences are the ERROR state and the + recorded error -- and the error has to name the module, or whoever + reads the log cannot tell a missing core module from any other + import failure. + """ + manager, plugin_id, ok = load(core_has_module=False, bundled_copy=False) + + assert ok is False, "a plugin with no scroll implementation must not load" + assert manager.state_manager.get_state(plugin_id) is PluginState.ERROR + assert plugin_id not in manager.plugins, ( + "a plugin that failed to load must not be left registered" + ) + + info = manager.state_manager.get_error_info(plugin_id) + assert info is not None, "ERROR state recorded no error to explain it" + assert info['error_type'] == 'ModuleNotFoundError', info + assert CORE_MODULE in info['error'], ( + f"the recorded error must name the module that was missing, got {info['error']!r}" + ) + + +def test_the_matrix_has_one_failing_cell(load): + """Guards the shape of the table itself. + + Each cell above is asserted on its own, so a change that broke two of them + in compensating ways could leave every individual test passing. This says + the outcome depends on both axes and fails in exactly one combination. + """ + outcomes = { + (core, bundled): load(core_has_module=core, bundled_copy=bundled)[2] + for core in (True, False) + for bundled in (True, False) + } + assert outcomes == { + (True, True): True, + (True, False): True, + (False, True): True, + (False, False): False, + }, outcomes From 4aeb0033e0327a3d39d4a40e111e7ef736d43673 Mon Sep 17 00:00:00 2001 From: Chuck <33324927+ChuckBuilds@users.noreply.github.com> Date: Sat, 29 Aug 2026 18:32:50 -0400 Subject: [PATCH 14/29] fix(install): don't abort when journald settings are absent (#507) --- first_time_install.sh | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/first_time_install.sh b/first_time_install.sh index c7ec7cf1..198a8f2e 100755 --- a/first_time_install.sh +++ b/first_time_install.sh @@ -1740,12 +1740,15 @@ journald_effective() { systemd-analyze cat-config systemd/journald.conf >/dev/null 2>&1; then systemd-analyze cat-config systemd/journald.conf 2>/dev/null else - cat /etc/systemd/journald.conf /etc/systemd/journald.conf.d/*.conf 2>/dev/null + cat /etc/systemd/journald.conf /etc/systemd/journald.conf.d/*.conf 2>/dev/null || true fi } journald_conf="$(journald_effective)" -journald_storage="$(printf '%s\n' "$journald_conf" | grep -E '^[[:space:]]*Storage=' | tail -n1 | cut -d= -f2 | tr -d '[:space:]')" -journald_cap="$(printf '%s\n' "$journald_conf" | grep -E '^[[:space:]]*SystemMaxUse=' | tail -n1 | cut -d= -f2 | tr -d '[:space:]')" +# Both settings are optional, and stock images ship them commented out. grep +# exits 1 on no match, which pipefail turns fatal under set -e — an absent +# setting must read as empty, not abort the install. +journald_storage="$(printf '%s\n' "$journald_conf" | grep -E '^[[:space:]]*Storage=' | tail -n1 | cut -d= -f2 | tr -d '[:space:]' || true)" +journald_cap="$(printf '%s\n' "$journald_conf" | grep -E '^[[:space:]]*SystemMaxUse=' | tail -n1 | cut -d= -f2 | tr -d '[:space:]' || true)" if [ "$journald_storage" = "persistent" ] && [ -n "$journald_cap" ]; then echo "Persistent journald storage already configured (SystemMaxUse=$journald_cap)" @@ -1771,7 +1774,7 @@ else # after ledmatrix-persistent.conf (zz-local.conf and friends) still wins. # Writing the file is not evidence it took effect -- re-read and say so # plainly rather than reporting success we cannot confirm. - journald_now="$(journald_effective | grep -E '^[[:space:]]*Storage=' | tail -n1 | cut -d= -f2 | tr -d '[:space:]')" + journald_now="$(journald_effective | grep -E '^[[:space:]]*Storage=' | tail -n1 | cut -d= -f2 | tr -d '[:space:]' || true)" if [ "$journald_now" = "persistent" ]; then echo " Persistent journald storage active" else From cbc540a679b800405780a41c690a9acaf3b70d67 Mon Sep 17 00:00:00 2001 From: Chuck <33324927+ChuckBuilds@users.noreply.github.com> Date: Sun, 30 Aug 2026 09:08:42 -0400 Subject: [PATCH 15/29] fix(assets): drop case-colliding duplicate league logos (#506) Four league fallback logos were each tracked at two paths differing only in case: assets/sports/mlb_logos/MLB.png + mlb.png assets/sports/nba_logos/NBA.png + nba.png assets/sports/nfl_logos/NFL.png + nfl.png assets/sports/nhl_logos/NHL.png + nhl.png On Windows and default macOS the filesystem is case-insensitive, so both index entries map to one physical file. Whichever git writes last wins and the other entry reports as permanently modified, so `git status` can never be clean and any `git pull` flips which one is dirty. For MLB/NBA/NFL the two entries pointed at the same blob, so the collision was only cosmetic. NHL was not: NHL.png is 1439x1621 (672455 B) and nhl.png is 768x768 (107184 B), so which resolution the league fallback logo loaded depended on checkout order rather than on the code. Keep the uppercase path in each pair. Every logo lookup uppercases the abbreviation before building a filename -- LogoDownloader .normalize_abbreviation and .get_logo_filename_variations (src/logo_downloader.py) and LogoHelper.normalize_abbreviation (src/common/logo_helper.py) all do -- and nothing in the tree requests a lowercase league logo, so the uppercase name is what the code actually asks for. For NHL that is also the higher-resolution asset. Removed with `git update-index --force-remove` so the literal index entry is dropped without the case-insensitive working tree deleting the survivor. --- assets/sports/mlb_logos/mlb.png | Bin 16415 -> 0 bytes assets/sports/nba_logos/nba.png | Bin 23678 -> 0 bytes assets/sports/nfl_logos/nfl.png | Bin 40064 -> 0 bytes assets/sports/nhl_logos/nhl.png | Bin 107184 -> 0 bytes 4 files changed, 0 insertions(+), 0 deletions(-) delete mode 100644 assets/sports/mlb_logos/mlb.png delete mode 100644 assets/sports/nba_logos/nba.png delete mode 100644 assets/sports/nfl_logos/nfl.png delete mode 100644 assets/sports/nhl_logos/nhl.png diff --git a/assets/sports/mlb_logos/mlb.png b/assets/sports/mlb_logos/mlb.png deleted file mode 100644 index 7196811cc0735e35c6351ad087c9764d3fbec733..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 16415 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 Q_>ZI@t1MIT-YDRI05vXob^rhX diff --git a/assets/sports/nba_logos/nba.png b/assets/sports/nba_logos/nba.png deleted file mode 100644 index 6738f81803de2be47897924731173a6f340c665d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 23678 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⋘bq#i~1GECZ22Y`m|q{PORA%u$Hy z^E&44$=KI{tp=ll&(@})^&#;E6A8pa`S6od-Jp%s1V9VX4IY&UDsx&;odff-@~omD zFTYDhqveSui>Zt2;i+{$GE-!mx@^Kz6rGwU+45F|iC~!@AC5Sp9Wq?qGUpP@uZ!<= zZ(2K7qbU>DRSecG%Vo_+#{byt6uABaQf%Ie>%`7u2jI|{)5^0SaHHt5Jy9^W zUCMo6$CPcs6R;bqolBXL3H$&Oc|fk1V=HpMVReF?E;NGWT5^-ZQ37ut~9W<3GG zRVR6~@P9b*yc8NKYMjOJBBwAs5}CRGp5Qb6^Mynu5XX4=DVI`PGip4e-QhVjm1;u&UN;8eCA#<@y7R6#&HZ2V3#5Y7uQT zkUe9j@E<&t!q1z|J7wMLHo4Kln^y*dq+qqykT(eWw} z2%}>jK=@|Z!p(#PGaLVzy;C=L#-vA~C1{cby?XYS52sO0(R8iIY;8VI6h`Z*scM+$H(+M zEjSNsV{$F#XLsw$kw=xr)SARbz8mZzabOs zs?Q?-Zb-zJ;Qk{WGnrr|YGaHTP{Aca2?i>oJB{^wN?;U;T-?5WE5xCP{8wZ|I4lhe zK3Jc|YQQmaH_3~gAUpW^>i8{Ia<#T3I`9-vHm^$#oOQW3dL)c1v)DTtw*-T$R~`9% zN8rppPq*U3_q;JICte>gsVNXkeO`POPjzGp9Q4fj#wsjm7W`uan)jsr3@aeVSlD-Z zoO5G5JLcRSCh|t^9j4-sXdEw4i{E+c-7}#V8sZlucV`DrNF{pb>Y(^`*qDnzx?gw! zY=?s?WVdGrOI9xp18{^0a|=8>D7fouFPrvXQWwYmJ}YxfdDr_*MN5~bOZs&|lLPE) zzg~#rOX0zM0eiClxg~_orTK{ZJd~~jkK&yRYJ2d=M!i2U-`zIM=#cL>{}nT^2U8?z z!kE-eckW)C7b=Rai1?Z)O*Ex64g;5ES0Y)Bh^?-)e4-8wDq;D zz+-wRn1(FDJap_JW3sa?O8YHD_2-ogkXE3BoRWarf*6!A7F)okU5a=V;f2xlCM|jHndFvuU7E%&G^A3MD>j9BW9p;Y=p6nX#TpsF!k% z?zC^_?c)eaqcB3TmN)9(!|>BV4pV6U=cmLUGSs*o!O8ZH8D)wM44uOMbVG&F=DZ+5 zq2~`9{i};l$#aCy8JU3TCh`2918bG&;aWS1w6^2rB9c>P!lXf)yPLiVxDpF!2qqp! z55RJOADU5fXu4-^jqthf8sbcWSYvbRguE!c?0&*WY~dQUHq)W9?=M)%Kj~~3Gn8aa~gLUOleiA0icoA=*T__O5l4NgSfxm3R_{dFJ?%zd<#!JvfZz38 zt;%reu_xQF*Eq~mF9$&eOa)$l-me39UBZgliY!7VN=P3mIF|qtl?T`Jb5QNCWkC&6 z1XBN9df~)L0ub94Bi!fSptON^S#z>JE=Xq^J>_csdSviXd*~WX1tgO^6Hk$^JsuQG z3AWAO27AY(UJ+}CRR$V25@(wCqsZA1ZoBi{d>w}JVwUQ2d0WHzjjuI-tAHD(icbST z=73UR{P{59JIDzQQ7qrDI{FFRO>MvH_f7um$b!2>k!=|@QgFBn)0hPca=bq!DrLI>zy8GgPArOY__E6**>h__1&@Wn3 z7w)=|T78P};bSQv1M5T8_c(tZ*jbz~5no5R&d$Ou{ zx2yChI#|I(18u;~O9>Xye2+gysa59Sufg2kDZje6^ST4O<-3gVn0$NUlcNY+>!${= z=acWh=i6S@kDm?r@ zSO13sHjbq>s=Va^>mu zel4RQ{Tn*1X|R%Qw#m$HkhwX0>r?_x2Y{&KQi<^Xx7%+XanB_maVt9da9slDXm8dC zAxo0oF)o$Pb$NnRm#z6Dfo)+i6iv)OCx4@zkh-X4&l2?eW;zeG${P8Y4MoF6 zIOnRUz4V5{`Zfutk0mdj=)z%uGhgh~&6;Tvxsx6suE48wGQ2u9q&03^Q6Mo-rp%}J=iZsp`5OBZmex3+6n#p z{Y~R%kcB#;QjAG%s6NFslFpBtIA)_BZVq{~1 zu3}%vovI@2?=>PXC2NZ{FWk05PeLY{ZA?h?_0-H!CVp z;JC>~er)#|WZ^!h|3nh0zrTB8LJdp7Peov^ptaBE{X+IWsMdW8HYHywksSzv+lnLI zTPM?9kJb^eV6|pVg{DG&8#}2=WTFoVmSirn#M=(t7tbVg0ml>h>DrB(u@qIxFv06> zM$R0Q+0B;}h5`mMV@TP^P@NMu&o6Of0j;}=(F6r)ucX+;k!+VLp z>~;3+IWu$5{izB2q#%ujL5cwf2Ztr|;r(YgIQZqK|7a-S6QA+s?{IMba5C>DRNQCx z(%sy(Cq_e-8srNX3nxruY#k4q3|jpQvKXDwcweG}e_sOMpamj5_e5_Zdsb3+xmd_D zqk7o1Z@GK_pq`9lq@}9y{rjd5rFb80m73RPhL^9|#y~Q>1TND5*WXr7K5Qok21`_b ziz|X3mGlNzSTm{gs*$T&oi~wyom7>(4=rRVP(MgZB05BNz;wHRDi*uMkH1G64_i*dJM9 zOXwKlPo^od6!#2~A>8G^o9-eBe1c41?QFArhD<23&^>+mfJjdTa{mhklQ+X%Mu9-= z2$|8r$6s%e1ymsu={qymnh*$8c|7=tx=zuX7Y17_fWdyffG<&ooFhOWUHVT~FEhX^ zs6fvBJ#7>6VWbR%_)oug>E6-;erOa0z9t{$neN{9bmfQ#{KIkml2iF72*j^E?di78 z+TKkNh>tu3q9u$}rwf_TQh|JTdid{Z3HXeW&!_cGAEL}9w$^(wG;lpjuTT&!5hz}9 zCu7eS!(a+5ub$q+hDc8mGJ&cBu~8LP$b!L?yHftU0gC1<7DFM-^TWOfCf!Tfr+J>; zxc4lC-RMCe`?k2tQs6me|91X2{ehLC>@b)v3}zX%BNkl%OL;l@0(>Em`^TucLdLaD z>2IPC80=8CmhS0tfy&F>V%UzBEr#f05(BsmYnJ(cpF_M~Kp>@-7WwuHJEVgn88Tkj-hrO>Gb)NYRA%&Mt4F$edFD=<^Z9V*TW=zYZDCS88(9~fuxQ*WOg`3 z=8l$xmFDC(d0>CiV#im>ScsbkJDGqr70o8d2>+(C@E86P0f9{X!N*0y)0YsS>QaNx zMZAQ!Ir&h#Cp_eZS*u?y43}SIw`ym{`m*%rZ&5!X1b1{|T15%m_x{K$jc=6*{54t& z->7SeDeEq@#@VqjlvZuTXCD4xSr*SMYO}1Y-DK{BAI^uR&?vt9?>l$y+dQOc3TCg5 zP=Cl%M5GkEK3oo(^6iTbK?4R|^WvZhwq|W9P5bDQh;oR&U@?}8ypX_!CkzyWg33Uq zw8Cq3%7(oztfg29!cjA{Ko`y2pqS!284&L89b&gjq{W^`xl@g;KzbU48y*F=g!qPz~J;47F&cfzi z+eHW8&&vsY_mZ+>Mh0hmMdjLXolW=7oj2Fi%1d-brnMgi!;Z~+_4HcHoU2x~bIJ~o z`V<*5OA*ARlwy(li_c=3HHZxz+X;t%$F0}&s7n5Ibj{6r$^Tk!XaQvg6M_6Un7UCg zSonf%3)&SGM}6_?oOSu}P2#S{>8?Ca?IGHzU;($rRe!4NmGewApPBOGn?OcHyD&zU z|AsTQOAS*SxiOeIa4z?<-_-4>uY%%kf8T^BiB;!hA)I;l>0LD%XbYQYir{nM*g;vd zg=u`HMqoi}Qu5wJU?1zg8CtevzdGM(x;y@ES76KGvh6m&TDyw^-xR9@h9yt~j48Y;=g6?U|)973`D~ z`MGPdu-{(@F<{F|SUk#oLb=0t*H9vei$wcxh}kw#dm?qaA%Y*LOpjdzG^$qMTCS(O z6$l{N7f1c#Gz;|YYuvG;=Ch&{N%oLIPbH;zLlTnzhDk?Cw!hh;&XNI|L*YSC5?CmD zchOba%Urxof$xuixJu)Gjl<7nqq5VtqqEYVrlLCq4e@|}X$N3=Me~qneuo-%xOWX1@#q9Bt;!&$A$h2_5=s>i0IJKcP zWuVkgDD_DHF6Z3bR_7up+1&M_ODctTI#RBmTl%lm zUGT7HQD_XYW->Cjq%GawG85Veg?4OvXl0|MCu9y%^c%Si(Z6J<%1Pve&xB`(3o*8v z&RK-FqsBFtZU3sHL{O+Mk5t!>j)|F*oxLjFx(|MUZ5i4sh2C?1BZEr)>`Ks{2zP1G zN+A6;a}C8x`6HXYovKov7RCjgW>kA;R-ee@(L^L^ynn8d*YDTg5bwy`m6hU6Ni3Qk zC_cbZqDmpygz0)0GW|I<6vbO+V-yidJ2^R-`qD6WT<)#y;=Gh_8r$UAC61{EDLj7D z!2c$7Kvhh;@w;DRZQ6XS<;~ZIJSGf8e46W1(L@fr*|KN`Gx%`?y=pXvglr~45|^e6 ziYrzQJaeo1QGNo+dTACWgn2DwV(m?OH#jJ~mNCK(Z(KC?2h;;ZTcoc-j_H5@c z_6DggV5<{pA-LHDa{cNk7Y4IK^x%R&f5tjdbOT>o#ge5oe(r zd6(s3>aQ5)os_HFh9!rq_vp|C56*WtJPjF@(}s1rWH_@O@k1bLufspHn{L;jqf(RCp{SwelvDWNkjfJ_YcwS0Eoy#L z^%eB-p`Wm>Jne!lq%pz%xz+0RO5&rso#n?;_QL5RY!tw5*vd%Fzg2cDTu_?(vI+0q{vf?~ zjbWm6+Mx@7oA2Vh%Y?D7!AatipTI z`L>?7s^|y7V2|(@A8V@S7}MaiyLfyTX<8pBnsh6>y4|#}Pv;w!%I`mjx%OON=Ub8v z-rcTE?1{w=+u5ae#N!pD%Z!|$ZmhtH@4;>K5a;0H+&gW3aN0EqGiqH;;a`WM&Z~(m zQ^jH4p(a7;@||7VmHgGUCkkylCaQZ&@uzai8WYsS^?J1Q?0#103LT}>aZN+@76l@- z*izr9sQU}EXNo$EJ(!Z!g>xGlPq%n za%?Tc_K#g5zFdS;374K{TRBH8Y5i`@i?2qP&kPzY`~f$@ozY9(5N0ROJaQdNi2J=g zuiO<$35S!ES@bmg{+QtW*M5b>m!K7b>&?Rf}|7T_Fg$_ zVm;O4f(?=5@xNzZ@0u1|EQCLODV*u(X%&l-Qf51BztmzoZe`NW+K8#Dw3@Og%rIqE zP0e8)Rzy-qs&hlPm$1OPe7wgmNfL|PWg`zLb<3;ONvo@n3+)<$!U z^dwgnsTPteb;;@2n7=_CXVoNlK=Fo9{+8L+ue`VUmfz?KhyLo<91Y$eT8n<`z#@uX zN$|iYP9oTkzBfQVLT0GCvqHKMOdoQob7YK$JNI#Lm^~4@TAsB{el}pC5gA;!c9Uz$ zTv%gr(jLM*(|Cwv5vIiTP10X9(K9ZHzh$xWce=*>ZJfotcb~x5J0Br0+s{)c2)WBb zpY6#7&>Hh#J8VBtIJ{uQwSi-ibF8Y)%<)sVHWXySAXm9pBf5(WjIuMfAVJV3_{a+A%q% zit?t`9#oBAMhHin2#Lp@Upw^69@q=@vBFlbL5E!55mlK;r4xCLFz?p5GH~bU`?!Q$ zH18VdRg(PYY}A6Hn8rERENSuN@a>N}$w(p?EG3{2U*5n@TKCp*keJ{I0c|7}z)-m~J*=Y-m>H>PjYdI%Ppn~iy9UpG+2r;~S7-PR(%^C5 zk3~Mj`u!4Y^>&68)9Q7{)TtK~n>Uy}_O~za`?i*FnucuF)$Z-->3&tCsdL*ST**Nu z<_U+Fw`8Diaz^tPn#?A->diGp=8qC&7_v-a>wm64l5HE)Ow5!Clamj!8nvbP+#Svh z{!37Kc`AQt;3goLq})!Wcbx#J%Vv!SeiFYAaoA|q0pTiD__4lrmZoxqbR$eM@>DZh z`UUG@KxN1xDn&`;{ZT@f8F|Ub4GVSlx{rOsyV;TPfnd=d_wBWbqPg^_U(iWTB6vcD ztlw<|g?c4sqrr&Ue7pgfdfeaP!;}s({CVmw!h@^soF4QMVDKkt3Y!MnFG7k~+pUpP zsx^yGQ8QlIq{kCT3jTW2(y_u(6Oryjb@7qgx-+>ANC6;oHgx_sv~- zmL<=?`M;LXgNfa30hRB7AGn|WLT)@iXJAW?ZDUQ0I<(H{tX~X?Lzktsr+4ICd+n=? zO(2jyq^(WQDKG9$Ris&b;bk{76V1JB=1!%6QB3u1_9VS~1%pv~?a#8Jr3cmTNYjX@ zbFb**olPr^2Ikb?3>Kp-2;Lc42-haOjeLHH%(W(tU1tW`ek;T%bkZxord-aE&YIpi zI>uTg+oya%2ZS>Y$0rOuKL5AO_DrNeEx@;)|NmgvceMeR52zQMQCyJT#Y82Ba&;-ey zQr{|SRtEv{YH@cx)mk)Htq(SM({V`EX!|(G(<3^k_V1LrwQ&9&wPhExEV%}Tr%3g= z;lhgzi^Avq8NGANc(i{(#X2)IQ7P@X)@4Kz`#h?4bcAA9Z))`wRJi|eewORFD##Xp zN|n(iBrf|zbMo-dYLleajE6ubN+q%&`)otdmAVprsds&bV#2=uYMHTZgEx&)2p!%F z(&dt`lr~YM)fZy(i!|dlJ}u8gEz}@Evr_oN*c&I4Q>p6pqH(`-O+AR-`v)lpP0JXV zP(0pyvu3g{^?^dw=5<0E>-mZQd}}U_BrG^1A>|~(zY6hkbSX8otclgq z?wU>4D4O>S#PW((8Z%EA(PaDt7nS|PoPa0%Ywo(ABDQb{%+O81K_E@X)a-@1bxj3x zrT;=Qa$}P|x3Y9DmsvY?ZseY#oI9>mfbL=tRTEAW1E#d&M~&ax)De?Z%dN+TMX}oS z2rN~Z-KNaUHD(p2W`yNbrKWq|-rJ@KCzJ=^qobd>m|av!%X1B?3Y$RAix!f*_^mD~ zZq4E8A?KSmR;|(DMGA#&KgJAswvJDEpObFAb2f*IG5GR61o=7Xvh0r*{)c9%#we6$ zLagBkyypzAW`sv8u{H#dahH2;)bPTcYv(SuX5EIwx={V@X9~-VM3IKz_unGka}Yak zuGd3el+`ri?7Es9A6HQ9iE4iEkPl#aE9sByrtb#!;9`c_suYt{oVa=RcmP6@L|x(D zeZIxRr<~5~n%-8iFt=oA_>tx#jj2(8kJbSFr~FA*wz7*288YXus&sUEOvA>NG<`&E znDdT!Ry=t8>I2QYd-S8z?%_F1Jv&5{ChT<84k%{5K$?OAfhs^K-QA)+`8FJDzs8>b zJA7xNV^o_)RXt5k&J6npmSqGVIi1++BnJ|i0ZL*2$Z=n~bIgu8Sw+2r6z9**P7#IE zURD0&!#H;at`>xjAT=Z7ew%>SjMH{vwC*WHbL%-^!iZeB3PAw)Bcy@&r{Hu%0Kpg`y zE*lN&q(ipU%E>TptRPldyKnUz)%~e6!R)QHb%NKz>wnwn%bfMNdOI zr7ZX@m*1|3i7?nl2T6)Umg!D2X4jbSYQYNyt82eDQ#6dOCKSy$1?W+Vr8@KuPd7d& zplcw%5UtdA66j+uU0*wNJ(@X5N4oKfBP5d0e2ern!rD^Lk4Il#UAt#ZocT(qLS6vi zCJ|3Wljakkzk6Rf`*GIAD#}Le%7UaxnS?B0*1VwDTe^A>=5mSLNQ(nGQ?MP zGD%@YGV+Rj9ii3(7{$|?MkrYTGG(3%s|ovBLF!7?w>eU7=qL*lP*RbR4#4U{ug#6kf z!PJkBhpvfW%aJF7u7~jxUS1jYWx-UYQl~wc5{p9|udT1yXx`q*grn07@}=QzJ3Nz( zpy%T8|Mo@?!8__;Z+!frE3ZQ)r|+#zK-ixY6fUR!X&<`GqDC$S0yU*issZ&g;f;7e z4?qQAaYvpIJA-LuIqW%4Dz?Fh#WCj&I{Fbkr%>RRSDeq}!fERDh*#Qtuda!Y&oA2N zWTCZ6P>H9_15`H+p}XVZ|-*`NubXQxUmo^lFF%=`_EmR?~)F(s5d1h zis^JT@3kMMDl@&Yevb=X6bb<>*1yzkSKj^3J92U{%OMzgCD-%VwO9 zSNZe8vgO3lnj5@Ylvz~vvSav*sc&i$l9CXu#bV_}OvzYeWKhLT+$|@M+{ZYQmpv`C zak7akHY41opG0sLIpVf+#?Y|NT$X4C3SyJB{^%{K53EL^T3FX)oTzih8o6H(sc~A( z`}6UB_(V`={1L@&VCjRch0BsRk>(`A`&?Zi0I?JF;=Nx^%u{$_4r{+UW`OJ%680_=HO0WgMHW4#>TVA{tt$I zx$k28*!~lxNr=ydN>T*VLTr=TdcUAk>_k{u)|>z=*LZboS%LA|9`@R+BEsG1LF&2D zTM|)@4E!#LwRnB{gbHkBt`7ha|@_zv<3CwrKX4>Aj{mHaAA%*fQ`ie@})iUeAr z!h+&kXEaWOFHbV%lMqeB-W082^!Iw#=ae{QvQ)W?JB5|6{p&rTon7T$Fu4YRL@lXP%yEnJfBIBUK|@Fo%GNd~}R-ea9k)mpUu4&wFD zqILppG;A2_T9E-H4_a>13S^_1 z&<9uHsR7D~mq37sK%MX$E)#h)Q?o2=ez?gCwtU-n;ZYLW6LMU@InXoWjvw) zg?`>jsiQnEHhNyR?00ciHUY^vgf_qOvlEmM*GS^Y-#J%($)N1{ zW0Tx?(dEf^C@xXCKR!~`WomYu`;H)9^>icCuRH(CF~#pG(28z)f}FrOYjwJ*TZ*5^ z!t!l7E|V|a=D)oMO_HggD6vltN&SfKOcTpZcLhVdWX`EbabEd@KTgYU4?e|~rug=7 zA?VG)B8n)#u0Tp2D3~Mt7HplIDp*EKb9vGwp-q=@dDfI=C_1Kbcak$(3P&jCyXRq0 z3=0<={@$gKLNloGPOpmYwKwbItt)2GdUYP1>~Ya_s^QOt5fMyvCqTGZF#op*1~02mb^oU)%Ii1<)0qaupOb^aRV();X7b&=V8|4 z1_k$ZmK$gZwjx_f2-ZAXSL;~ZSNp|8&$pHySd3l>IA#Y*KioY0f+E zvu;e;avO`yP5@U$idBx+wqUq))vKQiRp?l;2h^oQ;O31{Kwz)FDU`y0;b3{U`u69e z9hKyT|1DF;m=PEhSFihJO08FxJaS~;rPRx)DtoXxe~fu{US6E?9TldU9`_bI2{AOk zCKu8j2=IFh{FRE6?s7O~=w?q>8mZPY-|7Nw?rZY94aI7xV4o1x5s-w!?%dWZ}GtrC1}L*EJ+r9PL=aT;?*c9ugl+*#rqdq9fCWq^`6(Y9!h^)yrEcy=pQNhfC2uawc%{#|jVVsv`b@ z`TDt$a*|2}2tG~q);ph^Nr!#MX~)*Uy^*M2@kJiLi}Q4;Bmw0%T$Y%WDZ%mer(pK9N5e-#hji%!rpon6=^r`ZxJ1?Y`ewTvs~V=%hkC_te$=NsItt;1AE{ zFfo38yik?N2KLuwvxeney!d(Gn?))8ysh!0Qb zDSU6#K`gj6j^5id*GeD9J>t1s;ES9jz?0mWq}-G;-K)=lrI=Ow%MC{+Y@^_I6Ex<( z2g88GKy>is2PgT6+D4c#0A`!_-WJ#pe{{;Y?Se#pgbJ@E1YkUoN`Cf%O`tN4&3Do4 znT+7g2T>kvNuWoPq`ChYJ`-XklBb-v%AwsfG=KDcCJPmIDK{B$Sb0B;Q;bQ|>O+cL z=YE2_n)6m6^=A*3JPk|lX+>5Pim2K!rN`{z&(R-dH~Ce%ysv_Qw>R#S$ZcwG7!ut~ zDWu!3_;@95>v5-}du5NjWFsi&Yr$K?7wYHoskvyiBjz-kSq2^3^PSR%bhy?lK zNtoT)IVio9qe`DVWsk|W@B8@@4A)sDVT*Yb>^H;1$qmiq{SW|$6qViB{|3eHMk!BC?zG&ls{ zhY85mRA#s4OQ3g3c`ESzN>u0vx|iAcIB+}0r1Q?Ne#YaH3>?}vl)Xb%93Uine6Xhn zy18Jn8@o8}8j$1VZ=KxlChY3Zh}cj6?MC$eVeT0HC{>yL=5q_j!$SnK$3`1--D!BA zh~sV=n|!3uJxRS^#GAZP`|a^(cRo>O_vu*$_ad*!Gz*#JC^O!EV(!ueG(U!r>dT1R zx3}a?QP>HinAk@vCnOzOHd`6vV4p%Mgs`F34G_^Xg(v3)hNVYXsAP7<~%8RO9fQ zptP;C=c&~Z3!Co!QF*X^LyR@=+eK&XiYm6ys4h4sXoAdP&Py>H5{-ppeDLpOarAk9 z*&9=+%%-}oMnh&f!gQL#-u@oSM6y>nHr6sgd0O|@??c7of*G97-lJ0pe%P>wO{abS z`x!}HpitVaEy+1Jw>#U)EMI_YJoA`Drr0aqlj2faH0TR@OjYfeHXwcUsW>r!zmyDL z-?}?o$3Fu#J1fd*^7BGCuZNOy6_|v{?_6~~ZYPoQ3F0Tq?AIhL$%H2*q~Y0!h5fBe zq1KnDLNY%x`7d?&(}hdY-6e7Rw2#dwecFFLC*1!e zg@%-5_DRN}WLAF**3A<&vVAiZVj5o=eO@0IVt;*R09gH;1vl;e@Cy1)73o2ft&p-* zZjBWxNVr9BBpi3Ek@eb{oxcKdOAtPaLs#U=KHD20iVcp8Pdn*8W|5h3IqmsZO34nn zDr$I`G~N$x*lsJ(hr)T$VdupSG;)<>h{o=+laA3Z{wZ=mOy;%KZ_r(wUwxh7Zu8P) zVxRrq+_EM{=Tn=Fnw;UE-f?Wqi5ZTqs=K`k^!=O2g%#t?ENx1k<6VVn%RM5>UUs{b z^@MLFi4xL|EgZ48LC0}|IZVYxMm<>~V%il^=M5DF7Ce~_f2#%4tmoanBKCyWJKk^r z;Pm{t;ae|xgxFIS%EZAHhMTe6;scb+z!O64$tPJYvz4N)91uXs|}?>6*KXWSasG z;~@=#6SqTQkaRfy`4UKY!McUNDssP|D4&vu3PlVKeOaToUi3-}#q|RtRXdwd)}RhF zVW8WmjpEnLQtb7!84kyY#NGtqk&(MCG2XX$27-4L1{ za`GV>D^Kq|2dVtv}?g%egZ^Zcdb;UGRxCS*2)s(5XFh`^a;Xf&w zCzn|bo3pXuLK&V;oWsqv*<(@Ue^+VU!KQO0*7NfvlK#(+MlPPenqC zf^~T)oEz)&uLr*b(q7Bfl5d#nlC-q!)7Un;%hi%ozzgWAx9REX4X9aN{2l)&+;Cx7 zGwb2c$9d{X0(&fQycPgn6J-n(Jf89<@Yx%FR0O>F-^Eq=~_l1W864<(LyF` zF^sV`KlWEzZKe0gM%uCTjc})0GYxQYt7Wt={?9n7D)5S!mk6Z@Ld{H$!d(>@a9OEp|8hS(HKm-9v_&l zzjZY3_KSakN^6wW)y;!dtm|XfieJOGT+blSi@0eg2G5Ym6N>3I$89d^NvQhPYkhoc zdc8dXY8wc&)z&5)U7zT52BOB0QVm^Y^~q7hd<2BE4PIyI%i}n(OoJ z@z7WK#^VVojDD+@7WQ8!`GlhpP`Jf*wwb|lS5~+=!l;WtKbWf$4b2Q{J7Axc&jmdW7y_jw8K7?WHx|tkr zSEK`bDL>!JaH*W8S)(=+PLKAT^6hclOiq%hE!^>FRx#n(CxTFrj;O8G?fr*$e|_&q zSaxbJ26omNqp|CKR;MF7GgAjO{py(lt4+d4u(8cd(i(3hoFoEbvp%q5t0*O+`l(IX zj%}kK;>F?eP8p-2qc5By*?8mo@zgEb8Z4Ri=-gFrP8t#fY_ErhAub@MB34cWFO&3A z+6!+-i!N7sM0@<=Et<&)gC=Ht=^M43VOCzbz0W2R)0Tt;6x@o?g~TEDM9kDYB00pg z(OozxUX+X#RP4XOZgD?w64R^hDaQZ%?H}=j{igQYISn=8ma6cjMvhF{Ym+=twaP9j zTNz$v5MrHkxdnpp7w9NpHE#-oSOCjK2W3vlkLy(VB6VUY0;(&m`IeyRCHn7*nivpz zd+#A{bW4;TWjeVh@XoIUvnC@%Wq8q0EK9B>3m|-X$O`3RrMc=BA$tEmX*Tp=cg{-WzJfKo23*A-&0VyffWMs9}++kK?mGOgl@UWKvq%-Y<&iQj|g(3 zKbp9EYL^OrjR)>vs-E4>ky5E?VfsSyHE`W&>Dj(+m9=6^%UF^PfPk$jrH=)?^vRzg z4dsTyvgy6OA(rLka+;eSNn8rb>6J~$()hqFX6?}&QOoLb*yq|qg9tb`(a+(35esZA zH@Xz3>>roJvx(*N?P-6}u9QC~|kV8Lu+wvPG(p_eWZ`<`_I|ZhpX& zJ)>(#+`1Oj{*gP=KD(@bNNtQ2I?US(JhPe(M(_gGeZ60Dj%$>~`bh_&$sGvDm}I z@{0k=IEAv7OO3c7uzo~mB=1(KGEWwn!# zErYR9sO7?*rI$u-0mIy*q#RZ z9>6Y#e>|a0~o$IxJN!G`Jfvj?}G)V*;)w^BPwy+QFO087giE zcMO9szM{V3x}Fr=JwI#fxyENkcB=-6xqnPJRqc08ZFN0m5Tjf7}N!D++1&jK*AImA0?xD_*@2@$Ee=@h44&%R~o43 zrDelc27O4GMiQIOu+6=Hf}qQ9=~hM2g~DBW-1#)tdEam}YRa^7@oQbXF8E?+GFiB^ zaM1ImJ?P+8*szCzJ(76NGqkKR_Y1q)L2eQ3eBcn!kHEU}ZST)=Xa(ptOIA)o5W-UN zR$1Wsuh-B5z*Nnwy|@3%>}HEc%<~5O;W(o3LpZiz7g;o*I_#H&w0y7YrlLndz;tIv z*e`c}%3OcwO2Vwzq8I@LC`C>$xWsg70yb2KycxR#U4$(EVFN#GAeFCZ0_hKcu^GNr z=pg?g<;2|e7NjbBKZM0_RE5x#W8%=U(S)v;S-_RTUJFV>?k>l88*9y(@7LNr_M#(_ zMSYh^;?x0!93U6=XGL{d@Qp4LeI^t3@_^+lh(LgN2o$jAp%d1Sdi!>2%BFo9sh>R= z<@1KxDjXeu4PVW8{yb$?kuz`y@O@AC@g?V9akey2TExP1VE{LV zhM~33^^aIOt11z+2i#v>^D2`h5cZGRv74L^v8X`Sqx%^_dfT#8$nnhR!t90N&ay)V zRV*&WN6q$R?%`rS;it}E2QCLOJMi-40=k`|YKo#pCcHTk!#`JEvCKJMMe0ZNBO)d0U)>~BXj832-5RU%mbQ?!y-?NVIakT-62}%an@op6D3($W%qbM;E+dNOj5?+$7r>K?L3^B^)9N9P+A7Vk8g zxC_U=e*p&OMkLkr#_vBcScU$+81`nu3KEF3>)Ub|x6wS?tr-*~Yur2Ak^(Zz}v1B}(-mX=u1O;5+~Pm?%!uHz#;B^_QQxUWZ?16*~M(5VEi2^tD&Pz<|B6At! zE~!O1cf6pVl=Rz2jKAJBzAD|H7Y#zWt}cZ4lra&_WL@(3eZ*X%TP{dH@h@Jb(s=A; zaNhYEfWWJz`!N#3RV)(y!#mmPwVxOzN*-<(YJQb^N?F{1i7 zv|OE;S=;x`mxe${n8V(%KU@-9Ww&D!l=oINEEiU9VvfjsIHu6OHKgDbpU%WQlVicZa@}b0#olK4hVh=cKqN#)yR%&Mrkb(-phxx z)n4x9*Ag1q6W0jrGMrg?3_WNw8L#Y9>V(fl33A>a&5t3v$H3h>qN+zN$kB11hkEdQ> zu>E1Mtt!pj$gL&I&uVI(NZ=61ee;hGN*=5&e#guQ?k9*B6wE?qwqeRHFAC~T*{P1U zS~mUkW|+aj39v5-C8lM=07q&Y&f8D7GL5EaH5*>+>WAtMyAVavz2qX+VUBA8sA*~x zfzthg+xMiG%)7x=uTX}+i2@SDD-3mQhE`$3NuM!-q!|R#_lJ26w8ix=;J-Q9+(*rQ z?2Xce{^}N=xZ0N4z=E0Z?A8I!ZKNw=&rY#1sb3&66pca7p1=Hrd50e_I76KJXPp)K zLgJ8jFC6xB>`rd(uy|5f%y}?!Q0d*}f_PRQV{oe&MOmWSLCZhp+7hrY3f)}P8RFl) zsBM_Yn6ccPi$WF9Rq125OY5t4lH&g9Cw|it?7)#%1$gzyR4Qnn<+J40^G=Z<|KFZ~ zD7e4aPz>Wgp1s2&7gTDuvupZhku#PgAQD6^PFqdEB403ODI*(csRkdKbo9os9Whx< zC@PhmMB3JY{;4GU05el@a4jvHF*WOGUIR?W@g6zHV>y`QXJ^pm&ig`c1IrpfPZaX3 zBFN4(8J@{^AJ~_B(Pds#%Qp)7A(CBEtZk!~=yxZb>89JC8v$9*aoOD=uN42qs-mGB({N)` zq*}AOvcOdP2Ue!aN`DW@-2ig`@2lcTjsIgTCR8)pP#^ zd07xfe_f%$xP{#1;cL z(=T(L^FnE_8!9obub0G3KRgoikYZt*T~aKJniD*nbQ`*w5bBrDEVwG7D#qI%a#O`+ z;D)IdZcosE1R)aWy1B1la=nSbytnSufqljPC&~tH%z5Aa$%Xgo4gD_KT?>8_*44*@ zzwe9rs>F!^)*R9(-qJSSh_CSM8RcHo`%xMb!H8wLnI+=*5vFk5t#Z{|7?&5AWkohn zH1~bt;M-0zu)%ZcB7|JNPw`cFnyJ>#<^5=^>DNS69p_V0@_Nf4gYz!7Oa!f=W9Aov zg_A%-Q-5?dX6I!Hjj5CKNQV*x`1%~4E{&D3V+ALJvrT3M&ynlAXFuuQcSL#Lw+~-$ z&pTXskSSarp5|h&ch?&UPXzuDo&fNv>rvo6NA!5Ja3yKACpCq6H{={z?#9jGq=#)mK zdv{#Tx=m^#YX8Mwh75SN!fd7Cqp860w5RlwS&?8GF-1GOZA#cBw0#bEg*>bY{Ev6a zJmG(7H!Fm{cB~i!neU$rJ;VQQuxc}o|9t$|6&eCfEMw+=tje?^2(sFYWS~FTTW1w5 z{_z&1W=_@i4@ONXzb=RMf-~f4@F1;oyQ96|W$N~19$1noiZ`T?Q?#EGMISgXt?91a z3#AX}Ri}cAH6HozfUGkmdlPm}mJxjRU92Tx$&#OD3~F(<{U#)iWV&sYvMHv$f^_lP zl$+2Zi1EMN4fdI*O~^2=-Mm+1J9pZp+yPNH2o257&z9A$Im5iGEpfnN!$cDOh(nz40idfiX9aKhRA z*pa6;K3!TtvH4?E#PS6*gUg3;FWNyuvQnS&jDf}z%11Wf4hyqoPOto4Xu>O&TDbtw z*i*kz&J+Z7^6>T!JBHcFhO1%cZ-aOeE;`3O){Sl)}!m0?ZMzPg|L^bVqd{6odwABz_}$) z|2RrDu-ZY!{5ciL-ti^D*5;teSa8;q1&Dm>-!(PVW&QqpQc3|NnDyl_WoA7Kf}s9f zEE9W2=S*TPzJ>s|=9z)XfcmpwqZXi0s|}Ne0mMRbe|sLrQ6-VL9=_dibbet7v?ZX0 za}|I+GUKgrnfTKg&%6m-lIN{I4PH@Fb(5x%cXb-{!9DvJo#jV#Xj7o8@g}_hFS4cnszyTM<8p=O+YCR+nBEO)T)509NWT@B!LCCH_frB|9wO_}q0r z=v8YdyaQcNpy!_GwTG7jhypKp@o7c{3*w{TjR!?wgmi>;%v^MDNP_^YhrnTcCdlc4 zv#rDrVp32lHIOuaxPHKJrR+Q874$)AN&xvJ1wk0Mh-h@=Lq@~$s0ELm-gG9 zAi(+UtPMaV`lOV-(PMZfE4Yp(|E&cF}xmHPOH-VMKGZ;SQ$4f#LH-ZHAHHfkHC zB&0;TMWkC02?1%9mX;6!=|;LkP*MR!It8S=Q%XQex&%wayzkE}saj9=gmOFRU&#!(0$1zQP$IP8jD2Yo&S^T5TZ?tEJ!gY%AQ ziM^8>n#A|brN-o+ndF5}q7T(b-yCfCXi zHDU&dKzjz>p(W85QPxksWr2iVVdzjCT93MHgx!v>&2qd-?lmzzU2X-!{@gYDz}Sy) z(#RIGou3)9xw#I27mmEJ6g@8ub)Pv_^<|-dj)g7i_yj5mKr^R%^utl2@+P;50SW@7 z+mNjiumH`&dxS|422OFWIYHwpXJla$n6s{GWJ34v0Rm=7hvV4;>o*ke0B?~RBqVk7 zVb6CkgxElW#Mf5 z^wBdw>h&{;?~7jCDf8yf{YpV9Dz5y@K`X({ChdIZ5H^c`xA4RT))4OWy!u_@49lKw2t zOxpKt8Ame4J*mCC1g<+AJn4GGUT=gp1^e4sL%#R>+(+aKN*y@>$f6XMpc6HNJ>QCMAj$=Zz z(Q!)Zm)G0G!6)b3)@t#cP9hEhNeIp_s?T+~PT$!iwPNmC?}g_y5+|7$rexib1Jm>0PbwBCP^L*_hpk1=W4LS+2RDZLM{1w$-Po?Q@ovjBy@*J`P z=kE|Jg*zGl7RZx=AN1O6uMdeqD`Q>xn}Yh=q`1@R{b5-MANjVLY|vYfMO>7ii^KNqk2{aRdeN-j3D+-hd#;U%ACuaCDqfo~@GG5+4zV_-&$Hgdq zv#?Y#Ne$>1q|Q**(;+D12)jLr9G0n^-0-YA))0K{%a2UOxJmSO% z*#oE`F!i9jKbNIZE&J5pIMt;Jx-Qw7wNuyL+6weOZjL;`5v04T)}%b#EV)yWHgWL< zi2Bd9s|$JD=~B`xD@T#_pTDq-!RhA52jq`Sj-*mozR8(IAuGZGolq}P`CY*^w!hGs2Z)NEKvpP_uQF6loErRf#-rg{ zRyx-0Zif>1>8zoX7a>=DQ`Fe9?W*#R(?p4JgXJPyj@MHyRLQm54nYUoV_zq}gW0Sv zjzYAbAxX2;e31Ps*FAz%E%ETt_H4$D;R}On7i0HC*~CkA^-ryk6mK-m6l4Jk z$z>Jh#Xgx>+};~F;&`(hc5d)zk7+rb%>0~|BY(6pRKwy zuxp4&2|rs21=dhMRoHAdEGu(eq`N6+>2^?w9rw`7EUz z`j!C%m(u;7YP*G0-bpsLs^O7hah084=5d0~Ncwtqv^IzQ2?gxBRVk)(ml05l_Rupc z%>)EAD zfF#raobeA(wpq3-3`~S5uIjn6eh)%yh_@#622 z-_9>4OV`tVr(x#|+<16sN^|TA6=$Lvs+c9_D{- zB5FN=id}*XLHP2vCfKhzrLmo}i}09={jee(#z=QUsJDbQ$YQ&0^wmH!e)Hh_f|(6D zq6h=z%dfi_#JQ8<>2?BDF*4DBi&>0h+>~w9W?Y~`Pq`NbttzQb9K{P5Yk131(^y1=g|#ag}C0 zCbS6``WV#mDoyvMe7xEljif~;`%jGhmtM~4N_YbiW7dOwp4y7J)csrqU{!2K#>97# zP5Y&uskk#k{jUE7*Z^wld^*vBK&%&F&4HHg0OzPd#~x3x}S1%9w>k@--JSz#PN zA!$bK(19N0ww!L}{N_6BJpZ;kUIz3gf)lQG@Q%gKS*Gw-Dr!>fZ98+ucs+DGD;X4{ zK9h9;R|0yl^#?)#%idCBDnfp$5=NbPCprzH#hJFi8~5$CR=y_#q)DE9wrOlyRmOqO ze3u}pwxc=I?EdrgnX?8wYObtRjJKzG>j})vPH(?@HQ++=fBDFW!TQiuE^p24wCsWI zWx`nXVgELwv!6mR8!Z)d0y$Ep>KNw1{&G|oxyW8(-^CR=_bW4p?812?K5lbYpYr5M zXSome*dwLhLUl0-PYlrNfv>tw&Rquw0zQ2}Di8TyP51P~-K9|KYotnlb(pcn)(?lI zK_}D);U=Kgb)R{HVWZ+OYg=RikOH?2Yad=%RgMejE4O@iI@d1u7V{{>KDR>8M+xhH za+mQ@j$?9AI=A6tDyUxQJvYc|*xm-vNcwl7IT^==9U=)-uPNObgenefIq1Y3pgn$=dB_mV3r%R40yvKgmJ8hJq;||PKO0;57 z)~>di9MIss+;qkWR*Fe1^ZEn8ee`?MM;GULWA|MwWbzINHt{epB8gSBG=?m!*M?8* zqKqUE)PUOes{=?T<+qO>O}zQt9;wO8S3uiz_4mesaOjU9v@>oKySu@T-K$-!w*#Wn z$rC+~2J8_O98%k*A#lqxiG$Pbr-5akSvP1`;$x1CNo#suy&C)I`HXe_T__G1ej;G) z$)M8zTBt4Wu+8ZD?zeGQ!$$Z!P;@r>In!IL1Z`QV-(sBdI6R)#zV?t-phvXPpjaVg zWz6kUdwx4B{F(6L>B(a;TN(U!rt4Q!?tAy%$eSTSHz4`&vLM&`PeIW`MA`o!6muEh zMfc~1y(#B3!SD56z;jM`7cSpLRG4Uf5z@aGk<74He_Oid{ts|uQ|;u;_(qJmyOG5R~Z608Uo#5r&%e7P*$MG2SN;PXppF5 z4QdqTd=H{k)!NTyCi3S#^)6p_e#{(_brcoC# z{folJXTgi6-WwfY!ygA8H>s|LS@PX{3&W%HSH7xm9Qe%)|z4u-9k=tebw**qH>6cehC zA&MObFxRW?-R&t1$Phl;_Wv1pe|xLWnd12B&+wXX2#QhAz1x&DKL-6nXlj^Va-lkQ z;6LKHm`WUgQkVaruj1LIpuUo>&g&dZ=vc+&^16 zVX?SA;!$HUirLM!uHuJi63VaXeg}%RmNgzoF;cNHuhjJhBWzhGHXC!1^_wY_?}UR$ zgux7p`xzy(1{TYqOLeYiNw0wpp5%a6S)3jr)>Qcpu(qe`m-|0f=eA?e8KG_#m3*tE zQqoPtS?S+OGiQw}cM$S%OX2N_Cxe3}QETYQu8vri0DU_M(ERX`!sFS|fp6OrAQ>VM zSZ@m%n>&91?IGLM3+g(eTJ@h|MYSC#1K&hJ{0xy1%Q=+s50$tK+M27kBb@>fXIfbz z63fohnA`mQ$Lx2{z0GmCyz4{Y*%4c8Td=Uz`i|P~-CaoUhESHd*B0naE3G8=?j9Xu zAZn-F^pQvvb@%I@E5c#EIB|9IK5%Ani1(jKX?-?f)ydiaxrU%02r)M3HJcDIMp)P8Zg zWKQ!2_RhWufcu?aFUzUmL4d&qIvV?4{99dEecNi_n-g=eEDX@pI33}481AOJ|ct-UV+h&D=R)BV_ovBlR@kZ;!S z46-^?cde8NbDHeEyIOU60nBppl#f6Cj2LXDrV8^d7s5{|S@LrvGgQbvkd3SfDiYfX zQ1Qj*gP&JXexabK!XO;^r|i;z^OQlI?@PhmYSUN4&93O#ZJxBa+7%+vVhyz)pxv-i z9;d3_^^^i>xDqq6eeuy0?f*DYd5wW?m%`q9$Ww?IiK{44hcdSAP0Ot5j+0&uC zImiU(1-GB7)72G6$?9K(KpnyD&!}K#yDSi#ZOyR{X5Z=)9N723SgqbGZ z$fslNS!=~~ePPhfGC$$yZt&Q|Tku$P9}b}L?$i`#rjIUKD@Gdu!G`Qpr(=HOo!wuK zP^o~QpSvD^ax9zYdY;f5qc&1?N`aT%4M=b*QI+qgV~Q)umRYwb{&WJy1F$Q3I&#*W zlded_I}h9k#Y7i8xVq=nN+yATp8%a5df}olz@T@rN1p|C1sw$>ht3Fy7y94x#*l5E z9X|~C@R+tckJ9z(a;d(_Os6jLRvbJ3tX}yi*qD>v{$;z~8}#JyBy!K?$p9E=yaS9X zJ-?JHQX_PRC{5j7xs?3n)~{3F3b)7OI%LW|xqz$ry=&<0(UkPDZCtXu zQP-mlZL)+`@uzOoBZmqx>JOE*r-1WLo;O1`YmEVj$5#ibQlp#Q3vn)=OT1lz_0q&kjE$>5GkDyYgi55{l*zW=>s!bB3c7X*4vu_-McH{^-`vwPBG>VU3@y z*BAZ`aW|%&4vkI1DS3bIJWjK2r5(cfJGZ18HKX z`*tEYTn!}3#c#yt^rcncKpRz#*FKN{`JgoBH@T?KEsu@)R*SVZZR(hAKu@o5sbxJo zWnFVlnP`cIykY_Q*gSWwcI%5@y9rZMe%93XH@5bY*rO-ac_Q$lN{rc+f^Z3zxdNqhw_-j=0_ThSeeWcG!@5jrr*7ZlXG&)fdLUzGE$rQAF^ zF@fle=6)t(EiOvWS+f)p?%{A8h}=WtfaBLdz2BF_w>`%dgiy7HsEbh6sUk4#213gB zZF1_`q*fqiPqAl@PF4xRZ;P!%H|VyIUEtVf3hk&G@?{qo}NXkX?c@Y!(3Xv z8HI(CDPz)7sjhB^g|UoxkgN?1)|(c$BRj@4JB&Pni&}is*Bh+6iMfi(j_#AdP9R^q z?Y}#}aQsf=h9-G^l{#{?dTcKG22;n|J6M$RU?}DGemC1m813AmM82!XqRaf-w)!*H z*$mmLv%1;E&Y|tiDy-@=i}IG1`^;1C`UA0~ME7yv<>TD9u^pWlb8)=y-OtS5sHp4# z9XOSc@IahPqNr2A7*qf6U$mJwgcaLON|%~1U-kB&W|vJhMV1xf4RwT*1>;#Cn^v^6 zpy~UFG=eI|@#)HN{b`n^QjPGoWJU`cNB9SiExO;6mU^?>g6Y^4w6qt?KU++H-TOIJ zsdCs2#b(=%iIykLr~do`Uw-S@wqHld<(zNuTFm*-T56EwW|&Uuu)g33I(Q?o)e!5; zRwPA9G|YBa4v{AyOIWSWrR;RlGVNp~m}wRJ)+&j$J8WoG-TY!vHdt;$-QqGm$WtVv zG9WEY;4tTP(i17eR&vpR@9Vrv=~`J zCHSkL1}*51F^X;N`&3Gs$E1{@zgj=ugEP6KOn3B5sdN3K>`v2#!&=itR4rFioBM@p z6_)*eOMsT6Bh|TR4mNq?eEyTaRMJZKCyi}KQW3QEohw>9)7U!F-EHCcvg5t|FDMJdgfVI0RDoN z)ds<~YRZ=uRes{C&COioJB} zI~`8!A|t2%-N#8wmd0(-pTaKAy(xQ#3~Q$=u*3N6W-)@2tCMLQi~{%~h6N;g#`i*6 z2CRKlE2ek~bb=?RHyT^kS5~@@oE9-pquO-Wo8s-@t=2e@3AVo{0 zwCbx#?Kd{uwV?^JALrK0{AZe4cvYzJ;x!7s|Lx!|A2>7%@ZU+|dx}Gc@JdWW<<10_Zh2m`jGX7%;!m*Xuk4#BUegowNh)4^=OhDWx~rC&he-J2SJI!tISzo74RzgjXKO$T8ss6SAD9v{sIrxZl3*)gy$|SIy-q9~yFT%FvQBb?n?CDj-Q=Q3dP%8{HVHEj;Yn+FvSd zh!Mscoc7H7$>}ijA;w`&t3uBI{v;-&!fid_pOG(bMfV>thN*H|7d~bPBHOS_X~OpL z@_g*t{uX6UIru+?mYMOJH;YY7LSQctFWWfz_nN?=#mH6- z`=5)Zpa}D9PQTA6RQ%ts8T~0Af4o8Pze~#~j0j?o18?x(GT3rM+>ULqBq1h2tUR27 zm#`;I^8c=Cbj)bBMit=^V4EeX(sTLP^+GagU;OX&d(V#sJx_|^W`pRyqWql$#zb7) zFIBdkF2C*#ShU{}39~4BHCazCt3D#=qsc?I6UT>TZC6aDi1^t`nfC2BCAJzV6(0MN zWd!rs&MEHBxKw@9r~<25&DGfKG0e=9i1_N{cMpUqEfw+Lxojg7dDoB`*zI#gsPpR8 zRQ>BbVl{OfCQNIoBj2~P4|g>+epNd#2v8Y@(MVKG(RwFBJD2dAXeDG~|xJ!>qQ{U=` z!f9rYi*t>nV(Bnrs*Jkbo#KiTJ~-}t=_F*XTY@4BkAX76{qB={Xp?8*Qi2G$Z$@rN zeATSQH3qSC7!wTp2uyuD8=7kfn`x>VJ)Ta_{OtT1W^SOcyRN1wO^=b&4~tQ z0sfX)WhN;x(Ai$I=^l~G`g5u(^c6UGI5rv>F@x{=+uk$VSY)QYDBxq)eWx1Y@ZT{l zSQIuwhX|@lufN`r39Kkj{(nc0LZ5{N0;oTg>w*>*x^EFD5OHchi4#+Wfr(99op$V2 zsCZ$n+dwg5LbwRwE}SM)$w9N7Bz{~4#lRS!x9u;6b7hF$)+lJ5JARMQKuk`O5VN5KD1z zDdDjF`-bu$Iu@*=gEz|RRl5Be%8&Q`Pz|;I`;jBa%j==n^={1W4LXI{lU7l<|2;d2 z9)ae%69XzLHg%ci>l#!eBZfNved56Z)Sq4(S8uw*7RDcMI_hZY{0sATJo}VgB^MV1 zuG>lL3Yde-()`YDVE=m-N5O3N)}wp zMLFrPb7z@axUG0=)PRdiVyhN=&V1;9F82QYj9Ht@C_7O=3&)say!@9+Yg8=BI%;b( z=2uOpxIvlsAW4A--U}HPJqYLb=^$}Flj0BiOwR8hc`ZTX`dDJne?a;P*!6-{!rR;t zWPXPy_Q$%)^>duM$(ES)^XL$#j$>o@H_+@IN;7ro{9BwN(Lb+Xu2p?&#E>B1zyVSM z#Lu)Y6v|pPenx-@%EuN&oY^M_lsDSj4RMsT_}=F@6SlH71=>{gd)`V2JzbzETXXINdl14n%?5McMS&=?{gy;Zo5qU}i>gpM#J(M;xw;Y9ji^JM z*-i#wg^D(d>?Y!cD%N8EHC+DH0V^C7NnK2oRCuY6F<3oj3s%9J&JzWd69! z`FDul?7%c`(Co4-PZS^y>mjGY#T}QEgX9LBbR7>KWe}a9Hhh2@nUkwiMUUgZMTIxM z6uaQ8h0Qr2hag0Nf&v^f;sdZjh^KvV6Y}6Qw4-!G^C}jEEAiu@NA-FZLME|FiK45* zX^jbKQVZ{ZjLd?FLnfKTj;Gp)6S~$v|MYm!Vz=qfZN953L!6ef!!bkq*N=P14d4gt z5P5b)cCv9O;Ly49DMn)@P>CbxgGv&VxBZV#b{7OOlR28h!v(s#|E=ctQ~Kutb2ms+ zcXtVN5HB4bf|G^Iu-BvGMokX*cVZV?lt7pRODP+WmRa!GZBP9;&*I7m&O$8#z6Dk; z);!;=q8APh8{(jbJvLS$YH!Eo)ZP8)6$aNVq?IMV18`JJR`0O-@YOkcG>wZY{iB5#NsGJX?(C${kN2u9^*%M*lW0q&?RK8*c-$C*8R>Fi33SNhXA6C}k zdI>Q}`ip`F(H<>Qije*o7C0DO`XjkH8f-_up0iI(XyMUjnQy+x*VcvrOI*EO2Ixd? z54p4ov?6HQ%e_SoU;4mew^Ruw9?idtlnL1W(*;QsV_%1iTaLsw;>Wy zEPV^UwRmYIBfi&=7Qj26o%Pm~(#O*Q1*xf1mV>#@!io=xQ;%tcNx&AFfhD8K9dOjQ z={X7q*BU=s*dF!iE!Y972M>PRy$gqJoW}?~n0%p0UQaU~;Gm|ayn8SdD0W2FAs!&~ zsVg5BuLJx;JikqhUGjieyyg8@y}j-38)VBII^5f{;c%s~qi`w?xTSCPh217L`Pg3s z95h0|ucEdLGM&x}YgvQWX3K+Iw$3JsM3i)lKF zlgWmb8sXpUjIcCVMrFN-u`3mEgI2?fW@sX}C~k zhLU$uV+0V_rHY%#)X>KFclcMau(v!pj6!F`ZS2)kN>{^!%NUw| zR1u;v;oovMISqb4FuMirw7Xg8rHVQn(iqVTZaD1iGzH4K+HycsPWrNNHvntzm?R(s z={TLZT23HdK-M`1ILpK&EUV#saovA-g{W>aMay8@dRT%@YqQxh4GgbRx^``q(M{{CtQZ4 z9`IfkJX>hYsD0XQA|gB?1CQqvuX|x$0F-)#w)1%0zh>8U>)c$q5i$89_odt0Qd!#e z_-J}`^B%*7)Wm>JIUz4#`j2} zNXLrtevT1)i2f+fJ4ZQsKfO2*{&&x`Zpt4?H>>dKUkmY{V~B;~LJYxpaQhhKYSLoY zf=(%({%cu%FIG^OMsV3@JTW~?TMt+dB17%u4is-8Z$*{hSqW#`Ke$Pc-ZuYw^+MFn zIwS6w#3rrf%@$S{UHyG63tI#X(I$WY7F~Scj!Wiizhgk|=cVGY=}e)Sp9;kc z^R>q#<&Bl&!zX78a3s7PSJERRM6-|UY&1zg&tB*<8_?H??)OxVZBNmZpA~rnb$P{? z)JrlP^bw1SLL?jH7X>5yz&ZXh@Yvy*aEergcRvrvpDagxBZu&ENLuE$!}TTFoFjfj zJcSjLqCr<*N4*gSoD#ja%L~(LIWv16dCGiKR5dFVtc;2}LqG|>TN7?5X^PlFUM{l? zeeI6j!R{`U8{XOfc~bxD*Fa}y#_q29e%-2_KwOiQ4sxrjbphP>lub#SYvPxl+_KLTjx(eE&IgvuQN@f8%gca#y;!KkQQH2eiBIO z8&$K@uQp7NP-`=(h`dMz0OCSKVk!J7 z*E9Z>a)@K5(e2y2a@#;!<%_6L(w{HNcuO(Pv**qNZh+AWqmKFkqI%S}ZaK__#I8N8 z(;f$A{@bhhTVGF`ks-!!jZX-**b*ThpKN}NQdo^Ll%E`FIeVj)M-15{WK2W#KD50Ucm{%YNFLHRawozjRrTVDZlt^?91IFH;9}di zDtfXiX^|(sxEapRN3{5Xj&qP&lxnf~mGxw&^|9gf7J%AxKn&!L;=?coAWxlf|02wo zJp1^_ApDUl!Ec-FuXj4}QZb*{Zn_)_aR8HH2|-HLwsq*4n=8LY>%ZC-PQF}HM>}&; zXwYakcuY$gEXs*@EyvYj&cDda*7`!(*KLX)1Z+Fq|6syHlt?016?E$m3yVxPBGdj` z@RCmsD})X4_%v)jHm*CepAxywLGWo- zwzoZlyB3m}l|M8+)a<45E7{EEA1!}Zdx1_g)0O1gT=t9+r3%>5Q9>qe>sg6?ze~ikq^}lO7AiSP zAtnYHQJVe4{m7kr>UGpQ-l7mfeS2Aqw7)eYkem0lvLZWeBpn;8`T*CaCJRNLdN5@$;P&Jh0d_F}=LJJx-Ky9I?S;8( z&wg{Jj&#H|&gxIQ?rJr@Xb5I~=+*+90L>;XCD598(R)B{n~|oYc^&VIgoBJs_FQ#0 zffr-MqFqB*em_~{3%^ZURNVUqOc);KEV(0VM@7GRU*D|FVTKH#!8uGofsq{p|5vr) z4X6DTBN1mn9j68+A38jMqXj8fX6A1Z6P!lXEQ2RXvg_M(^egMfrlh+&jwz1gxPlB! zSVzb6qi%##(Vk9JS{$fSXw}&hI8;cC$?K$@cu$S{PW%9wN4m8)0gB(iWR!?6;D_N- z>{UdUtE*C9UEN+idG`H7j8rqR=5M~ZVq)T&sM@3L&T=>2UJU%&f`oAUfjbZ$q+j)L zdCa8R4UY^K+tJ}-lNd~vv^p6-JMt8fbUbt~Cm?W*qzK>s^VAiWWM3Bk_>mhslB+^w0y_Hfp>ReS>KO5o*^8PeC>XbBzvQxgV!PbAV$Y=TUBIyII;HdQXp30=542EWKF4m) zikRujM^CkfoPZm;&?Vq2;%9olgi8D5L>sMoMYAVlK~4S8@wcMENlnA=dk6`?ey{4= zb*)}ciq7LHGM%bU&Rq2gF*jxVb<9KL@`^ctH zLcIIPAjkamjpOqDYUq0PEy~EGpPU`l3a#NGWM%O86G)e%<1~YW7nuPxlduwDq$~5> zL$nfGfG*%ET6L9jKpxc;w^_xrd+@R_47EdGs($=*S&!w`v93S(O9A5XJAWEtd6)%j z7gqvB(7bf1I$8qE+1XuF0biDnMevpZwX7iCvtn~Ar zB(s;!kjhsjf=X@GOCJzPM&N&BBoi9uWPVdm4n(Mf1?S`%hlhd*W{(NRM!n}ryVyDk ztv>&TKlAy6)9t*O7JsMnF0O+4_bD&C^Gi_OZC+L9&uxT_tfda5@=y#0y)~k`PXG{M_8AmoHj<#PnJzUs`9j zSlOS?VeUOZ0I#1|c}zU@E%I{;VRwcgi#*s@)6W!XQxGiyZ1ysY@7miVUAGgMNk~Mn z)l8NhfuHMmtRC&Q6GRS@+lLCaS{W^1x53kbVTqN>jy6hdOx|Nkehf+FT%w?tu+UEH zel-iriDI08|DP_hv*Yc({c62Z*HXdp(rdKHrafF8{N-AwyfwtMzJh&5fH`DL@^THM zUaJy)Rhi``_&vLPLs(t?@>aD!zp)f$!@(oZeXEE+F{KV3jCypG07iRs-SVuG`sdP^ zSW<7fin-S>%F)htg2QP?K29B({YEdX(lR}T>2n9`d42<^x?Q|y8fb4b`1xa1=V3OO zH{5n`&##)1Jed^RELR6?L?Wlu^|w9dY?cddBrxaZtVg+tBQxSWU1(W1tz_Em#_|lv zJNW>Xjg6&+IGr3falc*yB+et}9>l^VhP`f!+CSJ2rZ)1&le>Tdu~lt&+eKF}E^zfj z<*55%SbL;?By7w{Z*q7>BTbJVDm0e-+*TB!vR;TMyzpx4_32O0VtcgzoA}Ye^orW(1E&cWwmkDc6UU#+0DNZC zk@zUe#l=s`1Meg$C}=r46jKRF4b?jFS&#cR6z^PE{7%i~E6|f` zS5+c7j9eK&JMri{&m7BuI^g)-N_|ue!zXg<#4GkQZ)dg)G)+VSz=fFfqB=ERQKNt? zs(-OtSR+sI`B(Bl*$r1b0rs+bOuM2E1McA$1*4k2yN^! z>uoI{ncO9tSF86Qi>yKNKr_r{C7H2Kk(!o8U$?!D6PtGwx)czCF# zl$dJXO96p@b9{6_Mm^PtiR9qlcV}Zwn2nc5c-W4c=i91#1CkBhQ==#6gopj4nDtwe z`mtE~lU83`HYG?&me;6;-(OWXkmvRG0UJP6yEY&))+Rfrlt%X9A6D1D71IuXU6aq3 zMoy}Vl$8YSs0)9ZcG(f(S085#npr7k@3)h1xmxIm68^SCNYXt~Xn222YU?QHxal}J zZuPpikKLK7`}J@$8cY}P@5#ph`cE=)?vtT(HZ%|YU7ldh(#za(=ocBkiOsV;?q?3w z4VTR|Ub;To1uL`Ei+&#oN}_jAKR#xSd_K6oOD@*fA0Ze$(OnrJhyb&J{0T^Q70MmE zDaHM%pz!8*q4H_i-a85@qISg|zqi0fQ=I*t8!veA*Jos^iRC2|C)6g7zm}rB2pL&MWha3*c>H)yc5#IXzb8orUHxm9@x1G+8wia#P}=I^%x|{(du4AhU|!?h zXO`@7!uma6D#_D@=;VDmGsd59&ad7`$PawlPc+n2KSb5-jIuFX_h*9OI-9m0Ma-6K z_R541*Tk|)=`IWkVb`ik`834~ATA@2BZK*aeeaNWL4-bI$l?#O$5!_(kEdOypKxa@ zu&rTAXX#$|xWk>`uax+uTy-Bx*U+m-C(PMI_1(7`9qvcYI=_-_tHL?KE?kfR{WF?t z|16YiRK|)GN0>;(5?S}O~9?DgZ zxthn%O&!hiNsC-g0ok%HPJ!sq2@2jI@E7v#>R9LA=`+- zP@}zg8Q`InS?Dl&M3Nxn@d4{k)$pgL1H@w68e;8X=-K3u!F=$-`Q4rJbmo!0Hfi7U zC`?OVOW#;C+2EwtT8NosM%R}?Z`Y4q%v&P*QdNk;(5d|zWdN(mYx3kaEbaqn%NHB?04V?bjZ2n> zy^uX0pMjw3hqKdtbKNp@9q-0GTf2v{xKh48RK<4B=Z58_6;RuWoe-?ZtcFg|i1`u{ zk1kI_$uGN|F9~`N)rOPY$@Rzg;VWG3p?pP2@D(PO7G%v3N2@Lh-Kl79nq>QqRDo+E zs$UuorzmPV%EJA8{t?W7c|9<1%Lg^BAI*QvKq%m81g`00!+Ri}iH31vz_;30w5SLr z-O~F|h#Z8NUtxXp6&w3T<*mb0_GV!11J_AU9srQ}kUio~BdWimx**zmq_smGFmF z-feaxgqlf#vl1_od%@?&KDIE3=pzNGB1o<95QqaO8%766xV{sE1cwY)=#7wdiUo~5 zBTuEV$**z?;&3*NfqEaYh_f?J>pO$7H?-8%A#^6mLv<|s1nkp2Im;g`g_)_D@&3Oo zU`{pS?g9;`m!!^;Ze3jRXU-Up(VI!&WC>OGW}qba)e%d76as^^@)fg6n93^(G=34% zZ9d+`QT}_AH2sYm`OIt~{!qO1jr@4$CH*UqSc=3pg)=Dm+z^!W=mnZFY`IX~y=aME ztGBCY21B8kf`lu}dtO5GU2P2n?ZhP&!iju3R-g*>=aYuxA1N&0IXc38V7TAI{9jLINZy*=F6LD3-*)PZ`gyutQlp>ViS$a3tZH$wm z@sb4aUz|LZbxf15e*U-vD7;8QgKX8W6BZenr4fngtPUZp6DjIH(g+!1G)7gl$D}~e zNh4t<}?&^#&o(DSGFryq1 zr~tdv?5Mo>4F-Clel`R9Ks1cWv2O;0tQfiDmHpq*DC$D z8<3M^B%h?>ck#S}lg9(XhS9-weN5->$A0mnxgpS`UH*L4fk-d~-ODB4in^B}=~y{G zV_Tna{*6X?OhwOI6=w9781~AjWJ2&|EsEKLo4`{+q>i8>5Rg;c+^0BRQ5_-P$SfNU z4Fy$cvOBmZgm%OO^H~Lo+0THi`m$)YRaUObaa9V7Wa?CNxFdDwHu-{*yC<6DkWn1} z{cxD}0op>&qoJ#(y9;)nwCOgwrD#K+IUq`yV((~RL9+&3N#LVOy}ChpL}dhu;9%CG zHD82-e3d&*#1u@#Lku!xGmW^zz+YcB)1`I?Lo;+Zl1RGbE~2yKP8Q-n^cM#y8O`Q~ zzmTH{OsOz`4f-!z%fPD@Ow^fNksLBz{B!yT(6u0Az3}TBc^K6#B`Y;V zKxkcFV|s*h@}TfTd696T4(RR76k0WEm&J|47p&=>RZ z8)(+KI_p`gx9e*J0TKuikCwkeMYQrlzv|pTY>N8SkII0W{0aLQ5G0FAdO07`me^Sq zshCFx)2g8iSOYaSpo2cv`Y`oEQpnFr*IKzr8FDxiHR!8K?&qxG>kcm9W~10>Q|RP7 zgOw}x>>+??H3{_ODSB6`m{3y3EI`?$4KZ5GZkG%+ycqd@^k?wqs^<~wh$Cke==?+1 z9dAmee0cmlU0UyMGw{0rJG`@dd%0+`caK9>Kt)$Nqsu|RUmygfQ6C*#2jUUqOo|e} zKEZU6)rUOaRFRa2N?eV{Fh^ z^g8OL;sc?Rb9XTIeP30B?t>IZ(ASQI70ZL^*k$9i$b5Eo*@#;^(J9ahp~VT;%h)sD%IkQ>j=l3pJy9O!=nB}c1v_f{RmB%$Q|-~5u@l|E!1!z}+6HRYAYSG*%lI5Ii0&g9jI}A(Ld=HMHo~|C6E@Y8 zTGsq*eVb5&U+W?W7EIrN=hAxd7NV@Z*W0B6C6ce7oTwh@|Exq6L@0~{dE1OzTOcJ{ z1L7!h79L$8h5*Wy2nLX&E0A%diIB{(=DU39;XjI{WR5J0vRSnK6QaOk~}5vfqOtZ<$*w-m0Zv>37AkhT{m5+|9sC#B+y3E?5; z#8}i)BHYD}_cJ$c%vS4{O6N6wX-tZ9YcmZ&^-C)R691_?45IzI9WeELhe%+2p|E2DJi zslrxp=Mv5mhuIBLq${NSQug&PvjoJ=zlQK2 z(wEh`^f{8;c4QFpf-kl&WpN}&`|&WPqhn}n{2Aj>tj6B%mC=Uq^FF3_CIPJ%PhUGp z7M}$F-Obu>AZWwRTDCvDKw(^Z?eta4TT7%WB2l0no2_bhAs2t#qdIZ4>~uW7vP9$w1Cp& zDAKem-?Onc)mNqU{yV$l1_Jv#1_IutpEXB`b`L@nh&IJWnJ9)`0$WS%>Zqiu_1a`) zKg2~f@3ed~HqH7Vk|UN;ivHnKoW%1cGu?*$SGnZOD6>dcI8=l0;OuxYz3Xq)r@dn3 z{=hjLyXC-qjyn9XLRoK2O2La)mMwR}YZc8Sq(|UQc~r)yieY}fpM>DmLpb{8HUIcKa=HB7te?y<$nVv7OwE=(!S-%YIBLG*n*P~Rka@8T|LG0 z4EoiSTRj5@#xf?j2F9aEGB}D1ETS+1kf#ka^ja~p+<)IXTFxEg=*b-rjz`ftGr3jg zD{p{)M804j=$cebZ+oO)l+v-RVL=)H&Dtz$K}`hX8QZv?ZpUD$MR)F+fQq^tXsj0$ zSf8A0G1n#H^-)H3jFIZKEoh$_A1Gl?#hqk*<{!10>ao49Os<$Z9vYag>i(2$qe4zf z_*BM0?EdqGrwZ?xT%NGjNd6-G692@|lkd_nV-;S_?3^K7uiQ+2gY?X|qIs_Pq4p=s zwmhWIW%Tcm)BV0Re`;Rw&oI=ip4BhndMxRPy`+A)!NO_OnSA?*nq|dI8kgbqNMgSE zD(~jsN1RNg;aw(woyy`qi}`=a+4&~8iH|n!l@rEv-RNhkKP9hVG&er7;h+V#Gb%2= z`9rxBI_?wW=^rhBmW=PaKED0*GJV%ocivO}r$Ng643p@JzB^8 z^t3H;?6A1S1X%IbR|1ePM8yt!3#izN=6}3r#f#A>{hJ}VF|PN$B)Wnx*lqAb+c-04 z0)No9{=t2hOw6CZ{H#YgH521KAV5#{=hrSgzRJk^@7+4Y{lKEABv zzG?Bm)>Zh-bzbD|f9}LSls_XS-am+Xa|L7;aV2t_6h~6+AcSIIwxGo8t#B#iO>i-* zJt#Ut+Q`SPRtk)Ga{4c+?0pRwO~b-&tp|tW(BUTb%oLHcIL85KPcI` zkm(^r!6>xO#26t_?~{E0(>8z%Du`_1sCZU z;##Rx_;i;pRtUJk=zM(nGz>YN!AoQhP-O0}Lg7NUxpMIe%NgUMCSZjI{_poxlI-v$rKh6a&t4@P zN=4}xt#bB^^7aQMuP7-^)4dZM4#=)3p)59wN6h-pj9_4F6T6#Rpz6y%Iup3TWyTjw z2XrmOgH4zDdW!zy0y>TjAirP|^heZVM7ApSL^QkHi;#vD5Od={4MV5bQbELsE+aO7 z=e#&T|D?o^6!-#@gHSn`W><-JC;``!)1EmbXIf{$L&L_*4t=+Is_07ZibYViW_2!9 zyuaE65vvs4r`xzPK16L($N5gZGyFU=)II`jO0kM-sM6OrjKewKC$%u*1SCaKmxK&f zWUu;?@roQjqNIreyK-7fzQ6dTZ9oDn;1+A6N~cO_+SulHP1d%Mq0?1vvq80^-;m0(Qk!=;d#x35Ph z&nm;xmxXV6bkEpui)Z&b1!eRIetkXEUwknF9hp9qQw-=y*R*&E1c85lsgZvXmhHf~ zDK5qVJU^9pgV-DV4QgxqJ5uzE6r5bX6EsXSGVOPU0xKv4NKSe?1+CyQ*Frt2F6$%r z-o)+_2ZI;$j7Ko|*Z`joah0;{oBexiq1esm+@i0jPTs_F(gqwq;EwBqp2CI+gy?AV zN%SwZ!nojR;ryKgB~jWZv*Y!P^v^&}%E7HYcfOm~b}b{qZ?IV_jgkoHL@gAd%1qP8 z(dgcFwN&cfxyJR{Q~r6A^?2}c6*Ul7;Q1$oRUg%Iv3J_T$4dT+i$U_99m6~!b+J9K z(_rQCwAhNJ4V~Sr)~T|Gw*Pah@_dA8UD#JDFR_2>&dO;w%EG&VALYIVC`p~}zDt^n zP2Cp~xU%K6gZwms9$Db7?JK7?YVwZ!Evx7{&1h)&n`9}Wo$qlhSrm_{g?G+P{%WU+ zlNZ~^I!o}WhY~I0$@S1uo)nZunGhS^T=@jUu)r=qs-ajysEq$v&fvGU!sx@b*`UuO zgt)h{bFU_Pz&D;A&M-}#gW69}m0)d<1ZL*516k$Gzui->j<6*Z@_OV2=YYrH!A@oP zdc=p@D72c<8D_@j0m+QK{^vIFl_^+3Am2T-jt%k9KPFU2MUY6auaUBEVHxQ6TNY=af9MH+_ojp2t$@kNv>;*j$RaVnrQ`<2C=7+pcxvGk!?@oF#* zapi-(p)+uH?m2gECVy!t?JaE~_{5l&>2boX|B`K%k4fwwH6BHNcXxy!jD@#^RkRe- z{1ocq#YL=(!abbPM+urV5L0KZAkui{y%C0xz~V5Yz=PmW;j7_v5a6E9Sli#z*tXQ$ zX}gDIuINfAEr;CY(zoKsvJ|KEdV8v4f4^E=`yHqTsq67TbLy1IZxw=swb5p+)A$2v zWzx9K*es*XAUr}*mv@7f?niidU~s?TufbDG_9A(I#%y2B)2V-kvDuK(X|&cHjqtG)9DuVNs2j97>jZ$ZF4;PSlZk;`Mks`Lt93-Om;2cf)jO zY<_|BihZYOkG@-h*}aE1(Sm)C*v47%Sq50Pxm@`H{t>k#cn-1Q;WRGVLwK-#lop&j z$3U+o%)0)^z9h#68;kn}`%gpZOkzcTdzVG=afa@f8cuRaTVkVWbP&9@ZwUZq1fooQ zz|qmNQ$&18v97*h$ZoLEj$vg~Q=^@E;^5#X<-e1xcDsvtZd3mx_I#UO>-8au5Rntn zB(V;H)zTYT0MW(nv)wFD*pzrEJncdf^%#C=&C5IgQXzFJx*)rC!&pze>O% zC$=&0hJmEJHQ?l~JpUJRAFm z3T}2Sl7?ZxZ|G}MCqtaA8ufGwzBPS`$%q;1PQmPe4MF1sg-?#D_3~P_nD}*J=|=@o!^0@tIu>LLwzS7Kx>fFA8+K9%~KN^$V(+8*Sz`vp_NA) zo^&TwN8)~wFFbBCB&p;4=GNSlN(zgY75&DPU|Bs9-w?FQowT z0X;ckaC`R{+I=t1gJ|hc4Sa>{g!q|R_r;(92enI5CGY=yG96yLakv>`546I!&HO5O zciH?;IlJ>A7}Et{nBELC6XI?8v*JSll`l_#<`BT z(yU3g0OJ>b9Xm~KvUI&T@!!c7_U^@M#oQ-S9o8p*59gs?#Wn7iNy^<0TZte+$22QgP)&& z&t8J(+u}B@YmASSlcXwIR|cHBN<$H?aU5BJ(xDbfs;7c9fbBYGYoLdUh+7u0=IV31 z4fl71`1u!2IAP+5@g);b6Ef+>fRG>{at0xi-gqQw4@9o(3m-yVjQIJ}lw$Cs5QfyM z!rjHwe_g68a^vI82d`=MLB7;Wg6pY}^pBV1Wn_B8q;vg}v^Pa3Ls}r|xJ%~%Q3H5| z(@MEIUpmw&`p8?Wo1QbEKOKKnuN{7G?xxhIGReVf5$$ z1NKe7KjZtZYrD2<*Sq(5p7WgNocrA8eiIDzG$}}#N%8RTD6~LoMtFDt+*bhJ9b()^ zMigEO9v&D^OHIY(_2OPZa1M)UR@<88bJhpr{75_VbWg4KF%I`hJl4UwUnryzH#){wfI3ho_~KyucWyk|4AtbSWR&N$ahQN zZt|QPAV}Op=@E!I`Tk)o3cwXqSZ|?EY)2pS5kOKmy8LX#^|G=-lxoI zdxKa@t5?R2mMCgB39|%`IZ_r)y2~8mms@`c-^1=Qvs5-s+t0Md zkZ~$jrx149AP?|e`tS1uqZRkza5ON+^#cri+=;dauH=<8s^gJ4qpZm zWHSFp0w=iz4+>(M&|Uy9wThg-izLV=lb;+1d^}x@QL|v&**A6r6Twm`UtP0r5njjq zoh<@?4)57kB%-1@rT6aWPs@+|iKXOki4ozjRn-7IL9V8+tO>6KhV4G5^?K%-)u(I_ zA=sk~LzXgCmi^Z#YGok`a|EdX=Mph|+*C91eduXrlBrIfJS0fPp99((g~(;8Jh{a- zu=itcX{K9XnlpC|J|&Adi5?O;40kAun(`eA6>FhB;!NachNL;vw)=rcS!U!A$jjB> zGPU%{Bw;4+j zX@BX4I|7uqks!m*r6!9PSfU%@Liza+YpSqOrZU#LOzjie@|f{o^>YB*nkX`eNa3ml z&d3`EGL#pr;hK4ryrL~634X4=jYdO(82|H@E0*}F4w@mtk95T*iI38L*~F?^<B3=Kq40HcOv5hYM3ax zNMHBgSV!&>6cW~p_PdO`ITkY^2oa{IO6kn!Q-_IAP91hY|4!Wt;*J-|BF120?r;ny zPko;-;uId-bF#;Nb1{HDnj{m7zq;s^11I5o_7wjc#28Gn6}p3RgPF+U#IM)8&W!t}~^{R`QJGm^zUNqxg-Zs!Q({ONY*<{9M{ zGMa-aLzteSVXg636jj8QYN*2a?#RLo!4nnlPTYDX*>}=h)2+iIIDRfQun^$;Jmd=A(&uU6*>3+1JW7TkslG8SC4p8ex%xiPSM)l7$)ZBH zBP*Myp*s&?y9WnD9t$1&pOW(=86&QmiqNC-zvFw(7Y6eCN+@go4#_7Lg*{le+s4Di zp-^8A#i;UPstO^5Ge|>)S4BG#gkQxS$bTboJ4eu<0Gseo7scO?LlB0M=2Yr|C#fKo z_HzJz*8d94@ZRGyX7v~b2czd%<$@#Jc3by@hSK@`8Y0fz&zbFqW$50Z{ddpo4Kl+o zLdfVhz!};O(gOtED$O_DN79E1&IYWYJIk?Z=c($Ywc$W*d>`>AjPtJ`3-p94A}RJa z2AMHX4F|V24#Sxqu6z;E4fGAH0g4~U>*uB5oK`yI!M^rreKwF#-Weh=2E<*x0L+NG zpBBBgaC0500IEdngF+2jQS<7Wn#jy2kb1yo`4q6z6El1Gjf7qN$14&3lM}fpOhHTy zZp-;e0`V-sXd^{+R<17z2{%u6Z12jCmIOMQ6EE9AfT!ZT;9>7(3Osi^yRh_6Dk=qx?r-^*}b_~#qH`$X^Vd=vsAvQhoC}Vo0`Dl*0L-=_m)ud65muJfQ^OC>9Xm3i zu_O=@8;UTzYQt@PbV2^oeWe%&@>?(4v5jLV@6dtiS0Y_^x0QKm@=S)!-1{6y3Y&N5 z8mNY^yCz`!AgAYKHGfkL=N*C=`d_Q6m!L^)#Bco5J@(k<=Ft3Gv~Al#hfT8DTyw?B zAQwd!VXnwTQHzn1SFL*83vU!MJ>C7 zlpZnk*OiqXn_X!R$$t)ebxW+Q`ass382{($W&NiGnl#f~M| z|LW*W9cdMTpKu*aj&N=RjLX^I-YKqNpX>Okp!6u;w+aH0u-M5D;trS6IrzxP0qZDL*Nu8IB_9 z&%ErYiee`ip}WZCOef+GZ>YX`Qtv~u=X>zPGA@9}=p8#T^d^ja2=8>@B9ibz)QD#p zifGwG5svOu(LJ#(0Ke2{6)oQdfULD6J4&-ir+VLw8ZOWe_%4J%t%STgIzlTo46pII z11%yo!^6Y7f>t_yXXp!^4UZCn+L6D5h#6pp^nrNvjyY-Sq>&9mCst~pU#M{{p;#8~ zo6p(Yu|CEHFW0goI>Z}YNsU;#UMT(dazF+)Kx1xUOaIX2FEp7~^&v=6>7aV7>B!iZ zEQyfiIqr?Woe2v71OsgGBN9$AsEpL`z4dD86vo&ehs2bVvJf{RUf^&>Rm9swS-iy3W_;As()Zn;S)thF{ro7Ng`@!eg@k|!rnyvPlSzQo2y3md zfre@gxq!`=qNBC!m(K~=o;oPCy%@Yu`gu2gd2x5-?jW8fXYAm3X?n}cC=sWJhmwdo zFV@pI>p{WH1}6Ih>CC{Z*0$bm5%RK;x@6nQU8gO~gNz|HeT6fVX1AG#YZgm+xGf<`*Z8#%ID2M^!f>wOt@3PO9 zwYVNuo-yotx?+rN=sRR;#)(rum8qXv|hk0RxDTVUp-ygbq-lr)AI&c!31(?x;{Z{FaV*OnVvWs}xOJ z5$x!S0&FYJ7UlBF zAcT;`w^8_mJ*&s>+Z@S${C+;WsF?JtfP8SN#ogI=d$OVu7qKe6cqo>2E+vx+U=2W)F_$W_%vssK2*L2(|E54vCGGGhp_DTnv!V?IiiCm(alZGs zTKL(6iC^ebSVgv3maPm@H{MF)YwjTl_kQvkH0cC?gGzn9;oBO(Zu?+1s-j4kl!M$b zDb{Zdr=99~)TQ#j!h0RVGK|7Hj3ToKrT(d?=*^mqI}O_(i_`An)3z zAy_3D&|HoxX=+ixyPN3<`A&SJVkvfLMkcvUQjY@h2<5eTO?Xo1O;_vRc<*n0bEmn95Z>M%Q-oMaCG#&V}8U)-J7hRV-llzm!{)gELm&L5v z)m41{40X;{%CqbVYiEB~V(XN;oD={z%A|>_YEuS&V=ABbb80^!Oej|RhR}BHh$4Z$ zsKCb2^P<&B1-imW4!El*(z=?Z1z{DtgI;| ze&m7(u`_V3-PF29io2Cz#89?(fbRHLpAq-%^;9f^&y%E6T;1HX3qp3Povz>BeVH4+ z#G#HZJ6(VAHz+{cZf#XBo6m*R{65zc*SE#1&8*%HOSx5_3o9?!%3F?#=!)2jNl8_E zuCxQe_K-$A>&Grdg;ww1t=xyT71&P|DWb*>Y{JN^ISq)j1$~GP8wLdvL#_%{X*$EV z_hC|L9(r8g(iKAGqT%oZdE3 zheqYI2Al5h0ode0up54o1AvXV70>x+uv-^Tdrq>Ng%p$hY}t=*F)E~ASp+T=-uFn% zh#Y2$Y$n5LDB@tDf*djLTM3Y7>k5_7Hv}2b=--b!bckam5yU*)Vh!vmuCC#VkZHpe zm1qfpcB{fyOsNL~^or}Dm6ry$4K43CH=UZ!V_Vvt3*S5#M4ogn$QFiHMwGDkicbC{ z2aBe^k=dB)RY&3pR)2FxJ>{yK<511LvLAH@H$@4XxeFtyCwH{D#5sRtVzN8Xo}X6HgOX zZOTw3U~;}t3fk~p{VI0$?dfA8#AA?e5wm61nlm2&1-QMN5QJ_)_k$+T6);+NPkF+% z;8mGwZ%vDZb9KBf`in?VxLQH|SmHW&oq|#vh-~2eq!0-f#y91*4xqIyx(r)=`!eAA zSAEp0&x8NPk{N5Vh}3LhR80CWU7Kcmtg@p;Y1t#xfe9?BTbWJW*PZIbzTtU`Y#$w= zzdM(pt}y>Cxj^r<7s*vz%xlQBQr#>90>6Nv^o2RxvZIoK`LT+!-H~{5Rv%h;$`s> zFCGHSDfR1T4D(>MoL&gM5Sow6G*!)6DOn9)tV5PoqH!l`D!(KEu;H$&O&>J?5y3fO zVO{hu%VaSQs_Q$|q4SgD=zwy|Y*b(`Re%=nRcC zpHIG>beEBQox>zC3ZD-o-!~QS|L&N7`eu+?@qhR8(d^<2#D9vA#5!2`bboQh?UyNF zUK>Xs{*Qa&WqxY2lgYvl*kROByyVrR!OXQ=rbe0ak@38`7JUG+=*@22%{F&hT|8>=^e{`g?zZXo$<7&Ri->ozP%kK>zBo)3^NV+dERt|bqG;wo{>IBhuo!zc>@5jcmM+~BNc1VZkpW+|hclXS$XZnXJA_xbc)Ty7c zWqFcBB#|#OC%)5|kii>)f=<;!8Up8wBoRMA;jj>g#tgStm^fs9ofuR|$#eoM^ei;( z)j_H(vW^)Y#(ek&A?R$^#vMqK#rEXIlm1!_9(PR+!`oX2%?Ir*3lIn7`D8D7_HR<_ zmBnl6NpX&qR4k*M^L{6&KsmBAnIZ=l^GtfoE*i;Rkq_b@%8!eSi@yzC_KjQV3AePX zljxT3?P)o8MVMQQ{>6==*E#P_+lA(CoLCPZJABnmC-J#YYu-Ri(?~P1H_J(L`!0f| z(pAk%ge>8D-_B*Im^dbc=SzsfVFcp4>Jtk|fRbc?nB<-4EMFGWvZZkzq=(fG9853z z*8S0BT<_$&zzL?4Xn!LMSs)`AS(qUcX_7fr;9|1kK) zk%dE7czA9M$`IimKh1Gh6cpN*los`B|9MEmEHF@?YoeeJ6VuG71^_n1cCQ3Q+}m&}cEYhtF4Q z-gFh7ZSA`uQnqMetab4{SEoOz^yBL}>)>2CoU)gZM8QXLqCn`dg#Ryuc~Zjmnu>OR zPd5mc=0$wmqm|kyw+Z~qFzw?Ly**o6pBbU=_f)v*%eIU5O7CsI5?ckDhip=>j=qf^;;=Ot*BLv4LnlNXd`t0XuB+Y-R< zK6o=?bs(fX3w0Df{i#o8_959K5@wq?otdwX-2rQ3){3AqoNc^JgxeV$ccP_J^zjvm zyY|X|By}*dgnER3mOf0Kws3Lxq(F6@z579J2=ux|6;|yx3O@u`*TUq zm(Y)Uruj6P^kbmTv9h%PMnZUoJDL(v-DC-^UMi*2H(A!@%M$FVY6CU#%E*~{0Z73rV?1r+6#I=PGC z2kpq&dr9gU-$mWsyw2wuZ0AnCr}J(&C`Xa`T~`mIYz_aS7VUy$SPSgUsgT4Vwf=*Cf4s!F`-6aH=2lOQz3*hN~CEX&xJ-5idA6B%ntq zq*=O^EwkR#+po2kKf=!4v4g2D^l;4gH(Omybw6L3)A!dqym|A^z z2?ncm>HE!DBM&^~?&TO*mSu`^&3zre47-vFCg7QBqP-ASLt#C#P?p`Dt5jUT0dl*Y zo!t7!JrBwn}=D6x2IJ~ODQQS#o0($aOh&0*Z!k_=l?q}rrA;c(`d&U zQ5eZMmawCJ+@?3L= z`Op~=2nBWme$-B$P;9%S*r~c8*O{+!#17KTy7yeXv0rW-`xrz4ic~~Svc$N?%eyMf zxZdRWQ|^!=!Kn&wTg*Rrg*W?8J8|UZ{;V7K$q||mpA0&uEkE1afNP<=l`wj3YvHUH zw&`PG=Wz_>k~`spr^sF>-cDJ$vLxXi9yd?VF`mx~{l23g4a-Jjs1$!Td98E=ungD; z?+&Uao}e^$3;zX*LH`FnEn*#1A&Pk8Kzl{aFO&J=hs0{aJxb<0VoyX;*7reI-5^=U3n;fd&H|65wc6)54RK+^-@SO&H!)OjeoG*(0thz^{bZMZM z*L;WU{W$}*Ei*xo%CBc{GW=_eNfz41{%B=%+WNtti8_j7;zAg5-oM}6qKdpw?ZQ!2 z^xY%iSLH&E6@|tBTsR;%3dE5j=npy|?qM1@64Sb31C?KQpW6831;zambiQtA_=0b( zR@ynvWgc||`rh12;>8RFj0L}YQQcFr1hiF32T(o$I%|eg`Y6q zGF}8DQbB5+ANn@~%6F$J08ehIx|GDy?p$>|l&w}r%ggW2y7bFbSF2vI67X#!#aXq_>OWGt`~sfnuPI{f{UD6SWC*5Bf`4gkWZF2IZ%oGyXNaWcqnUZ~027^dj&f-`Bnsz+$q< zC9?%p1tvVvl5vjH=zT6G~}w`64gNWWTb9{ z@zZsWY}zGIpX*DpWVs^3U^P#r6ibJ;EaQ`_vdgzTDUjGCFHa-?B?A}fhVMHLaDVAm zmFeX!@A+*Rzlx1gnYWiOS6nYRc(dCS-_zN-R2X{jCUz);&-uS6;#yW}4>HaYUy7jP zl&(4N^fl&_^T#)ln+?F#(r$4bqsc zf4EkYk7*xCdj&hnEXrBVn-FClYGv7l?9r{fR5GKPn=Gzb06Ad&%7xaA${$T9#H-cZ zKB3s8eoel;i`7N+sE9Iea@A=y$U?^Jly?2|{Rui&WUDt1>!_>MUo#7QJP0T0e5y_xwtWH}*9@Y9X9KSahKc$G<8up2G z22LQX@})$$tO#&Y+u{Vzmub(?lNEr<;-Rg95^l{m>+H8VOYQ!qt)r`B5h+#w(xxiJ z9-4VMDoQ-zg(%YM*!tnhp1CdO5k8y`?Q|6-$Wkw7k4Wc*75xvLQ;y^hS2^(?W}f^04@Akm(#6iN@t13y z!QObbgkG9F_ht^H?Uylcq_`-6d5uTCSZ7({A(J2Qw7pNqq5Hn@?F={#zwXz1xlvU1 zQ&Cdm6c%Su4H~@h zQVqHV{$LoYL_9&2`wH>X2L_u?X=AfzU3(kC`sVI*pG|*DSGKgw*fu%oj<2UGy>Fcj zam;^T09(O1y(81BQwILN{I-cu8leN_-W4YQ&n=SM@tYMeDrxaCzXUyJaO48;y>(2p zl8y#3nRQaL*cka?C_~!QU)}Jd#dsYJn|1QeG1v=xolfMNOX(DDch5a?1;Fcmru8?4 zIEt&grS%8Ur^4fhgLAX4f23CcvF6E3X2b3y`NL5Q&sxj59!lC_le~SqSt~^g(E_p;(6o9CP$W!p=p8#eIc0-`;wKtqeLMBx<6Qq zH{BCqsE2mf>*VQu3WN8dE|;$zM33}8`sFDG<9XplP{>D>A2tLMBY(M#Oo85p)M9Q2 zWK?TNpb!6m_1ry8>D3G1M*?5RhX9kaivjfNXYj@b)h~4B&IspoOgQg(TUI>ecQ%}9 z`5WS%>>Q`zx)Igc)x#Srs-eQJ31D*(u4CbsVj&^GWmNtc6wqnH6Kcag9u~P~Uly8G z82dZIU3^?I*Pt5F-Di=fDym%Kk4NC{`5>|M0T1_!5JyV0PA$iEG+v~#tiD2&12sw6 z5>#c!P&))G64&Hx<~~-a5Nebvf9->YYO-k=Q7SVrnEUK~PCj8|S-PDsOLf4!rb=45 zn#{%fmm1Wh*|c`ElPplg5tdO!3Z&2v%75H-@?c(wCSx|9?Dc|D!{#rRb< zoCfenR05>d7~7k?{GRJ0rneSzH94|`dcLjWbVty4_m31v^MgW+%Y{9}wRP6!Hv}ug zV(~2~-TgWc_S$`)Z$*I==P5miS!7@Q9_m&3$Iy^yV$a3y4^(C~6{gK=9~v06KqC0byyG+E01#O+4dXOJe-c9l_ zSKwYF+ird?l4jwX9a9mJ{ivS(s1ZUqp15m>HC>CCPMf#~)#L5fYl^UmxCg;@AB0EX zU&#C1$1FBeI*I!TPHrPrS>Dx_67;W#KKoreuN=u@%yUrJeykRuGA82-+|k`q-oCUI~Poc zwq8{3>}p7|HZ~=^Fv4nY(6ETaZ2xGXLX{7Ww|CCiA*LS?Dbq}CefC~{_3Zj&+H&`>Z){FothBe?5;XsW!?*5!_vWJhU zneERB^nvyf$!b2^ z?~H8lI-#PUW>BYT;<0{mDYFl<`SdaDd zON@N_VYeEycFY#M1wj));~HvDlyj6mmm#)lfl;e2tF+gtt*|GWUUzP%$1`8#^%wsa z@40ESEaE@($G`QIJ4_SVEgcwUk!t!E zK~-DpsfF~gn{T?~;sieT>w$zuGIBlUFJjjz3skbMUm16~%o5qG ziht`A=TAsLX)|ppVBb4kT~SVfCF9Dys13atX5A~8HVau}qT>M8rg8x*49O|B6II;k zY~In=H8}b3s*qUTFA4Qx^G|_MMRlgV`aNG~+_`3Pb{^`T<)ins?L}w4DwgR`0htp) z2^FccKXi2A)qZTIv^~4IMcSYXnAgg9fYg`WF844pj>4asp8nHd-c;$lJ;`TuTG;Lq z6zW0|O?rd)q2Jj51S~#@6xKwb4 zrl_7!haVQiAGFVsuPA^di^WZy;H!`&Smr} zsfT#KNAs+g&)MDf^3(sGFJ}x8G_=h2umjyBGqnIxGI6LA*o~Lxtl;dTe0qx+#dS(} z!PkBQ<7H=2{OSQ$fA^N;a8a(vUJ1)#j?8z7`^sF`dC;e;EueF+MzYr!jCqRcml~AM zxcf|W{L9ZqXcy}0O%)fR?!bAJUorrRI0*>#(^cG$CwA9a^CSe4f|8l~*ugH{&_R^|pZ4GD- zLH!ZlmQHy_3APozRTw9W;!jzhgjHPN0S>$)Sf7e>@7t_VT78FE4$hJal&k)dk#cGWaHqL%%ot>SI6%Kbrmr*IvYZy*ju9&&Dj(CDh1=NAZ#2KaJ zj1M?7+{Sn8J+Vvzw>pb>Z-71$k#f_Y7N@ymOHb1HXY`z1s4C6_ao{chI?r~lYq9^7 z>x#(Ta{_4E_fpoG*){LN0En1`?wlx6Z!vWnrvqAwm~+ zEPvP!8h`29O)0UHM!wPldpvm9t74h=lIg<}$ZuoY05@@`(fI%tKL2M5<2hIFt}a@a zyvUZ8mPTZxboU++rx^UR{e#)#XKAMaGMD2v=Kqsz(a`TxO~ zK^?VH6XAV(Yb@}B%9)yP=#?`}w>&6u_dboxbXaileqfyG$B~K$ig{(=8Zer``6uT4 zj`nAwi&nA!Qj0^<4Ppib^whrKGfWHwml@7j6cY(dM=BiZp=D+uEOiF_X5;9js=|;D zp17_ZuOYbygfmMUN*aBbB6k1@4e6M2ob@83Rko<#eg0ScN}P=+y~Jv^A&U**e0;|C zZ2pcOzPI0;fvG+ELG0i&2F{6%pFm3ma;nQw18$;GJevfUn+9dlXEoi7-UK~PkW0bI zH%^J2^=?iL+EaCm+&?Oa%9X!rW)Bsosech`&`1ok)jIAYC(&UUcp&&L2%f(X+wPC7 zEaTVyb0t4s*51BQYtb&+_UcEXPA+<7FMp=Og+ck>#IlA!i5(4F*qA}shyAvoXu zGL+mev5TByuo)f;B7B5m!OtRkPBIQ`7Y<6Y|(1OMfPQdpIqGs-|z_P&@T1mk$ zWLVdKK+zVjw<+q0Z&QQ%U$8-mw>e+PET)A(9zk^`+R0Z98mgj%0oc0%(wA@5xpMWn zfwkKvy&2EJ4k$ez%0K{SQ^qA+dE-1r1TN6szh6?t9a}GvOb>`V>bg8h5_5*@@MaMm zhOdcBNZ<$s>q6V6kid39K)oR7MGBUO=5T~T5a+WDhS5O z4rXR@rBg~t*j~dnN1XBdxnmf!%W_TrCG3udn2O^(?OiDFF0oe6X=&GxoZ+_wx9dT=jC%-4u#s3uMiYQoUkmhq0j zC#*wE6vW8~g^vUD+{!vDgRJuar4hX?mk)US-w>)c?t6STdt%6>e!?giU#VB{A*D15 z6E|yNRGo?IYJYk3qL#nbrd#%TzrZJd=bv_no>Y~$q^ za*jpi;|Xk=G$4du>Dd^rHfMkmOUS9Zr?R=-^f4Ll_+z;o7{1@n_YWbDv1IDGpBr++ zi)P6T1Q+mpq??dL`B1{Xt5~d6q&(7WcfMv)lbuNp5SnaRh!lk%_7Z>nCR3p)A}zt6 zP?vgez-D1-nKZy28Ld?{6=7r!zPbvb8}Pk2~09jt%_?Q`EmOhtAJEvXQw6cUP5F?#34= z4wJ#S2@+=n!h)l`Iar@jR-9Lm66gLY=Zeg< zL6KwAjV+W|R;^0Xq+T+;J3)@8R_WxXyD$`2RKzmcm|0qVe$}pzG6&LRygw6;Ch)C!3 z{MCIYF4{yX9MU`io0CU8GD}k9=_LqbpAcYAd1>^d-@|8jQkJjSHEqsG%!s@3r$VUM z@=05q!$!Dj!He>*XyS5>PcQN(2}*Ov+sFNie{@#X)J(NDx6(5TS-FVjLW)c@i~IXE z=m;aeIt-|JT{WVaED<&@E)Dmd$^n>ea z8MVweF62dXDz*KHCKslGa5ZsIKqw#u=0aRmGyZL!^cp79O~vt8JcC1nYwuA(nd~A^ zzU8oIce%+g(=yxn>sl2=By_7A@kZJ()nTVTc=tpCw6st?ozfpEbx4QIQ3Ox(}aS{WuXC1v{Li~;D?uV_U! zC`rLv5cy_DUxU3g(6T=VZ|HS?-1LWKaw~|#)5;7;7^i?BR}KFe+E(3BR9wN!Q*j-h zvOWFo{qk1{>0#?B0-i_y#9UL_oo6!nTur<&CmUX#t9ZB`IyalDXW*GRGWx!4@NP5W zJW)M9(+{SS*=Tm!*&*Av;J>LM<}?c8J@a6;AKjWL(=9RJ7YaSrTKao_w><+;NZ;~D zX~VpKRbssOqM(MI$fsQE8}sjUu5SQCyV3d@rPvc%al9gpFZUj;6eS(tz4`CvjpPjb z=ydkynP@d8Mm%R3XFFLt>+$mvewty~vYjwBsT)b24_90~fS;SIoz*4o2L!j|Va$J( zg_f}tsa%aj6LQ{x#OGVg9ejoUCIesHLzjz)67O#6Kpv8~< zqN0i2YEmN!<%zBz9V$2wKdaVmn|2C%`7|`o)!SPtSEr-#@awvcv9U3iIiCCD1&>(> zQ0#$4XOQQ`@~7uLEv?H6Z0m|iav&i<)!*WiJEVXu=~Sd>D(*84fs#o7b+*{MbO(A` zik_T5M!Rni`tbo}7;vAzRH?wqAM&4u5<#%g1I0|Ph9oc<^~%x2!CHs={Jb`&(fbT2 z1il1qt!Ly0rc6GmC2x`8a1}e$b+%JD$d7WPmRom{G4inTBc77@>OCz-;@II&EQDvX zIqT+n_?(J1t6oAN&v#B~&iX_*{`yGNA$~3(7&Rranfab&$}&p-t1X(vC(<5`G)@ze z1)wEM2bbF2O<#Yf(WG}CFU%0_=Q$p=aM#5(vTaRG85K@OrzUVJkzpcy`Es_8V>0+e z^ncHu%$}+b0~gYKno4y~5%H)$r($-oJQ?l}g)14;$@|Q4Okm?EUxCe>m4!*z)SKZk z5?ZZFp;`rEIaQojtJ&N%!(z}achaFj`s!m7)A196@e^%U2RLDU05P_68D$&YteFo6 zHB3$)m`q1o3bU(n)#Tx{a=z8iey)Jjg4wuYir>vSCy8An+XH<*Tb5OEssBj!@_aYb zQ>Ed8)2_+HS7H_7zayW+qjD1m$KBq9dj(_8s?x}nz0KzfKngP=cV$v5YnrC0`WkWj z91CGnkj*l9jMvX3=KEVMV;o6_Q;O2J9~%|pRjXEO5+Ayna#T1aKT@yOiss-*S^wtMo|`EAH3Nfr-3PPN!-x_B1B3tk+=eUYux}CqlUkx>vu+y^&se-$wc^r zP-@moW0HM))uIyz6v(G#K<$CjEBlZA z3q-=E@%3DWWrS5%hFcwjmFj?Z&FPxVVgVV{cI7wvjhyfw>-%&;>la zYLj8F<*%QSblB6QL#f_N-EeyK@B)A}OmkuG4D_$F93jdez!u6I?p)JBE%UvXLjq4L zU8P)!T{F=xZ*{nug6EY&5I+|dFe$6Qj^7KlLd>*Y=R-hEAf^k;F0KJb&e^3~*gx(z^=E%}s7+?#n-L+@G z;ZIe(0)?}m;c-|`D!v>2^Rv{hqVf)o)t87TfvC!U=xS!~kEFS{H|faby1vFCJ5xg^ zdHdMt6jP-t&vXo$KxV|VT|9=*vp)1wT(6&TwyjDhM!y-CgMDndxv(L@ocNG>yoGaK zeem}oS)A)lSA##fHC033s35zX<8rtSL}652o0GB%#~iyS2D3Xp7FBP`6TTtAMPKxn z)@*|Sy{b+!I{G^&p;y~gJCPSL>_Pf@lHJl?%NIKIyk(oDeLwSG|4s<>4mru|3_`6I zgx+2vzl2NUkAc<`o5@Zg)0YC;6r~b)+K*)`G&xm2!*>Umlfj*7gV>A&n|M%KC1(fr z8LeVtFegn@5o@K&T*B5fcC~mPANYMgyQE`YqzPk3s?U7DJ6qECsVg%tbjEHNy@ z$E%H_&Qpp@LSC0#q5XNq+>Sx84w}Pnqzxa!sJolRT13u}m9X)#Fk$KT$Jz?Eggo_)_@1g~-V<>u~9wLMbJFp4Xv zh>y6S19^@ZeD6RX$)RiCJ6UX9u`KR3vF0`%vz0ScgoD?b0MMreIe!!_1?i*5@+9x2 z$lXfIz5MU(_k4Z#$12pX{gH4-bfJPm&5OF@4GfSX3kjP-zUO0ACNtLqQ`dfB{RQC=Ef@Qgu1WOl8dOPiMN z{(Sj9rE~N1TbAy0z67|IR8P;U($!Yk{%oD4zi_SjIwNUpj~hf3RDycO_3Cng|X>jMMANNvfY=n%b{Ig*nf_DHtX!a4-&tkFHg#bj4KKIbG{SE8q$k6jpj__Kq;#y zE;2U0>?3Y46Fhe`y*yATd^>0pEKLGx!pA}B;l?WbZOpYS;s%zqwdSyLC&4T79#qSu zl>f`L(}^n2T-Gb>ZY0BY&gYW<&|d*&(c};}CD_l&^WKF2^8vY!?tWb`(zXEfA zXvd-nnpjri?q06Zy5k|)FTX<$w?06j+22=~!0#^M%fB1_bU}qAObO_VjI8xfDGGq^ zFgp_xc_mYBoVTh*$#V^K&|UP1Z04y@FW(zDV*Avfi}HDg%H_!?YMi|qS3u{pN`&Z) zM_-Z|^`Q6`m@}V{1LcA|LVo>czy69I-PP!nynS$RfTBk_@mvJ381bM0GTAf+ntw?j zpr5;Y?b5Um-YQsUN&z@gfzD<-cQuH_v1%2QWn%|dUX_u4<*F5|K$G)4m1`YRQ05t@ z!|U;&;ETETz89=f4yZFS5IPU5%Jm5w2ZH&9goZda_-C@KIT&?9Ke`mi)+9`=9~^mt zZid=cY&yl*mNm|H(_SICGj6+1F2merhVT8#m+}A4yveB|eUtz^O$cF@cqskryZY%4p)mzd~w(x2m+L~i%Sfs>k?;n%igR%BKOk}VpP zkJH;$fVxGhlapCo0IRoKw*$`+m^hHbi80Tb!nzMXSM_Zi&E*eSo-tJCxW2@ET%0;r zjy^YCVQ*?c^%a2j<>Rf#J5ts#Qv1;uIOja#R%qj#Mxm23bza6?pG$&&Eztec2I(>L zRO&;1Xa9*-D>pUX{{H^AMMfKbmdNF!U*%dElZKfdIs%DY##61sfufJ}e#4=b0|@V`KUA{!UaK=<^|Ma8a8aNeO;J$EVX$Z|#ZO&IPHp zUjqjxt9r={XTTDZ7~>kh@_c{X!)v65+1zz>q02p!#Ul-C<5#kbpvzpbtH@~3qowL@ z$;|s{cP9s)+ff}$dav$zFLXTab(!FjbDREXGO4G`GEi8r5h-z~?GU~0;e5*Og-p5gKO|j+LzD0K9WBx!-5?T5cZ#H>gn%F*pAo|7 zMj8}}5u+Q0QNloCfFPkrDGVis#9-1L5~KHf{r>&}#`E6iKKGt`&bhGiaP~7MdLu;3 z7nNyNym$?E%=&GMQbc4zI11A{%X2H|oA1?Y=@xWTL);U7eyg{fe81KQi|C$8T}16d z$2}=yUKUBZK*J&zcS{zR)9Xb!HE2DmUlbccg=qDoex}E$~2VAeEhWR_=^ohCxz|K zNTK{#p=wwVqqeWtWh&<>?d{hzaO%7S!)k{W`+FVhA(eCG}~WiGITl9wd;hboruCvswRXGs3Zh|oQub1 z;3%NdT%{t>c(J5_8t_%=-!<`1UnA1xr~f8Nxt~*pR@@ltlbDk0b%%dlYi;EvC z)@rX4H|Po^jorCpWBRqXmP}2LUp#(5gw?`4 zCQWI%r!iK2-5x6pLWKBXn7d`NEz3NGbAbCTkRJR>77qia>=4wJyBXREO)rSfK`bs3j#wh>GS<3jg za76Uve%(VS(=mm(C?dY~t^Y#wGzhVG^e*Qm??{d8t_Yj^$zuZiZ)PD42$Kr4O?^dz z0Tz|9=1NX+<=c(=K_yLy6ul=#-CX9w02Sglt9~IYC_%LNPY?PU6Xbe8b7DV#G;b6B<`R05 z6d%l&QHc*kouhfL79!xg2iAaqQ9w&jauAaYz$5O@q?}-Uy9m^f!7%NDM#_w+ltk2+0nP(T2qRYPQV$QUM= z8GpR4Ug%m9sWgGX6d&6_VTNne%=6aEMFSNm;ondu`dXrQ&|4}3Y$25Nf7H1Wp+;H) z>e=p%)63NLVbMM9DXt7tMkZ;2WW%y31;ok)p76kAGE(xT*`*#9jS|8YL~Gf*@|ymx z*_&D^`tH7`dY9-!AL3IAYV&zlX=4w=Z%?RO8+xt!7fSW*#!hN0y8EkQXEMfr(cwYh zFHF^344yg``(A>QqR**VPGP<0PNIlOU}|pwuHb7o)D>Bw**GT}-0^VuRhy2LVxK2F zA8dXZ+VPTjN4!M2ygSj5ydGM$ad`+}LH^zmbGFG=&ks9D$Btt4l?W~s34N`stXcq0 z?-CJi(c$xUedkWbT>~@a;Jp!RcB`_x{shC+Su2lB6kK1_P(*J;KMDk8Ikv_a`wiSY za-|&cgJmTy3ZzC88)G zl@%g`Xy{MGyE;-NwF_7De$M`$HqnaTFW#q|?;Bou7Mxb{$VTVpo9bAfr}s!Zau$2~ z)n%Q-FbLHf(xx^y4{7mtGNAr?vpsd}#|_3M{bq(?R-qrq8*JyVG3}-A7kBN8DX$GD z9}e*aiaL}_*~Q#!5pzgFEXPLW7dh-B&sR^fQN$eBTff8AxOeA2Nr!LMIKR2v?f8ms78)w4%?EGH%qJukhvwGmt3b-{H^1YS(uNEh0L&u1N zePm&uTU_x}&dt&n`;Xwn+ZfUbMLn|LGEPI&kmAN?!+||iA^cKTk0(Bm6*YKaDvEz) z`;uC)Rm%^^SVNjo&xA&K^|)WW&_-d?PNgrDfqqWRZ!yj(JL$6P??bU$u@aPhA$iKq z46tV$R(!18mBi?<4UU@5=zS3G)1_L`SbqjMJrE*+#M@xJujP~mk*M8IUm~}s_S%d! zgU6JLH5QlcF?)*!+bzepbw4&P&bdl4!3et2ZU^xlMW~mkfpzxc*}d*Rk2skMobiw| zzi;U(ic_HhLsQCx_BJ;ix6z1x;6{D81Uw{KXjG3s_IP1t2T&;EI%M_`rFEO&T(S9q z3Vll_v)kQH+iAEGNPlFnsE5d~Ppbl9<8Lik`-88^L@eMs|M%ufF zGrMr(?v&p3?|pOUTBF4(o*!4-nSkIq$_H8z{Edc$0UqC?#rnF3;coU212!#0V zQiwBhhpxaj0PbAkzd7?vXRt~9caozdJ<^Yyi<)QOis zJCYK1vXB<=Zfmll2B<+p&Gf#^CZ{h9-QzFVGW(!ZjU8L~+MR{P=p{)Ec4q}X@GX$O z8S}$cjzc084+P=GHA#Zm?h*uU&VYoG6)~W+xX7~+6!v90TKT8ivVP!8l6u{>+DWV5 zXRQwqi1TEzj_S+e{Sa5oq}#;S?KgyRPo3vXU6oCpo59aMh*uo}XAlxk?1XQom^mv_QXP@j5E+iVx?bLrQ_$bB) zHyP5KrCd3XN6*T^tMKeGne)8r))V;#+OK;^x|0GJhs#+dDw@KuGT69Yqp6SJ@lC_S z`6JZ!4VFxOgFE8**NG#`E2M-|+X|p*2Ii#JkY!Hgpq0|7$iP2)o%>2F+XToQAHw%eZm7b*Q=fCgE6*UbYGvI zT;;p8kp#)B2#6;`#>^`UpZd9b?#?u6Fv2r#!bN9?peHmD6=s*$jb8g*@7Sn6N(w&z z9O64orgp~bzg1I(W(UU#CAoVwNDN>v)ikiWK!tSJ5x8BZ%M}T~TnO7o8syv`_L^&b zU?;R<#Hby~F_?Xu%SJ%rl%b-mELU@ecq`LgKy+4%LQ9bt%!|X^ex-)_UaxI+0Chm+&U<*mLnRutP7TSebV0W%?aubHWb?l09rN>`OXMkoHi5(-g;kLmo+$G>O_mWw;O}Z z&XtHO{I-=K2+lV{>|*|5nsc;~29jnx_@3TTmQU1xn5xNz- z>d10R8F!hZuk*j&AvWjiAuk(P_5Smon6D>XqlQi~Z1Azdn*balTgQ)yn+b zN5c*Rfv`avgI@MeK_%f6Zm~S?s;a8m{RdH3%LZH&x%Z#Zs~=-EH-Wr-!O2-1X=PPq zCB%4I8Y)Q}#9KT9)DdJaR_KTcd^yqpFAkiZIKJ;o2U#^|;*FP9*+d#2?1nl17ttCo zCe2vuHm`%eb65%Yj(r1Bw-fZd1Q}#?CvwfVml-yjelr;tps0(rO3->qMDIH#1EKOF zKw0N6QQ~v?&3Y>xuS)pabg$`#pJ&ID27PG$K$%`1gX3pdtUafk$;rtnNf!Sk5QRZb zJG+c3Nfo2uaSEw#ovP$uw@Kh;1di7C_ZGV5Y#YP1*E3Djh5^ZemleV$d$tk$c5$J5 zdVh2Ca9-kbU*p|iepdEC5Re2K;W2H5czrxYq(NUu5HAd1`O)q3_h#zVq8!VSfe*x` zz2bUUh6_dzf1R!2#!H30Q3~Z1<52AugcX0yxaNEI8ly8#AMTH{EX^33+Tu9U(mLg{ zHHE>wNAOmDWdNhBF&Mf|klCPHf4;A?s`32~V3S9%ZYXbg?#j&cz^15kYpZrwalI%L z$jlz!ZEP8KACbk@vVZjDm{?e_w+3dYe}*dzmhY8>#NH9LB(`nyIsrx%tHA*QXO1jf zNzxyw^D4$Vl|cO0(s@Kzv)4AMB|Nv}2q9l3p8tVfR}ADmy+m$tq~cup#z|JG^u7eR z(AI<5`KB(|%z7={s_sD$T}+!7(&>r0icU{{aO$~Wg#|?4{e34=TAq*-C35NVs_0EX zgMP|E*u?5-(;V%mCI4PQuwm{gxdnBcc?`{`=Cav0N3BRCa@|=xEcDNZ+WO=5QHS(V zm*yIwr1<`Mrm>@2v7oUd4be8DP&ryST7u~`H)TAL zja%;=y3a6bOtbVJPN}9Sz!&1KSXGBWc;ZNaFkZJ^yg+-G_@fl@5!;A1wP^UnTS(8Y z*=64}{{^bwB52&#+e0^rrAK>pE+VI>By&DYEhh%qMlnBY1Jx>Y~>J~YBqmOksI>DTT|=6r1db?fk| zUk&iP&kb_L9%wmQc(BVRRaOcD^db4lVqofR%19Ug{xD+E8v>yKkf>t&mNPgODvkQ> zzXEH{skiTP8C-i%XuB*V!dwj@yLqp_USqZvZRXxbr%b+iYZ9WdJy~P!zaNDsvkzHj z_YPOc9G-Xk;N`%onleGk^!s&CRJ7O7vekGQKm$ z?rFz2sX(=!Tk#^^|aN{oT=VF~8|rWZ%B=v_Yz>^l<68-FMlnWc|GF zEk-ASbHAxccL%%Fu4srGfFKaTOs-rcR%+DND5z=d)Wa4Z+0%hNZak{3$) z1cP+&`dBykr!OJ`e(fZql$0bkF!HtJXk|<-oALALfQeV~QqTRLkZ!PomwcB8R#B?B z`GZ&rhXKHZVq$q9#eismHi(!{Lb)!b6trhjH69E!uv_GdxHK}gH%^6N)r{y zs|Dhb&7j&JXuT|3#b&mkzz6jote1nl{hQJ?UZP+BK;KS+i~=sdGPUXCD!XDoT^A7) zC)kCRVg#`CaJwVZy_w%TMW6IbO&w{8+uk~|C~6+vCw0s#L89k?=HWUm>S}o@jz+ae z`TBU`uXx0!K{5psMS%a3C4AT(64*!YA;ds;k*Al*+T zwdI55dc;Rh@h%eCehV#kKT~gq^kJtY@370cBOsJ7rNIMGqc@IvEb%LT=c)sFy2<@Z zm>G|?`d*qG!rhdV6WPvMg_<$C1Qgyw3$F}L=QQ7jCgOEEuIuWTnnP54lKo>z%|$Xa z1vF~Y4|m5vwE(Z*8qy~1cbA0~Uh-eOv{NeJ@IZ&6d#O{y)EH(w-R^q)?DX*rx*YSG zw6G;it)A3=xr8;nZfhFma(ik6Zh2dx>sE;PxKf7~?SAs8lT?4cv@4Ub()r59U2(<1 zBA>71n?P7pq)6Jc#3Q$EX$|BBcJD8;Yaeo=XYUQBa2|Om70*UdqY`@oVbI=aD=v%&r|<%s!@Ww$ZUS@l!N zpm}$&uC2ijhk`by1R6+i4H-a>2aELy1ws|df| zC0)b?zjdsp)(CtVKv2A$KC%>ZIU|B~h%V(b>vLBfOHOzDl1tgdaA2kTVzyOshLQ zd5$@3Sr`k9Cz^w*jSS4m9@cza_svR9Et;x5ng5x7Ii#y*7V=|M-gSGMef-TeXS7fy ziw@)MH$})G?M4>N)O`B+(U~uJ|8bRh7}SFHK{fW z_%6xzORJj#rZy!)$45511Vseyl^}w89Nuc3{iQ_w_a6~3kd%Y$I^kFi{f%cRPai)S z;AIF4T&(kSj6_=3Yp}U!HBjC`nk@2@#y^n*7YmoHbXge^j}~|GXQz_)IYgjK*SmSU!$HR^j2Rr`_A`A-RrRDCO0LS#ww?{8Vh&VJBudY zSuMmL$$TEf8(8>=G_nM+-<$0+%DshJnw}(LO zQBngc&sJX^(CtCHhE>;P8;#9tEM#6CZ!7`U8DANgA0!zo@5)GfFI*kxNrKI~*Dw1& zzWg9*F=A8l!)Y8~5mQ7JNO+Ajb?p`x4yLkBZPVmHwL^sY@WS%VE}Y{td%PAURn*ab zPgJz%kEr0)2k%;t$W^tYnJDsxj=$fb+-Ei6-61|?JW03uhM1E3pG#88$AsP=!Cf^f z=XM@_^|$|A8N2V8C4Fk5+Y-}U9P`uxye&rPMRr3aZ1};&V5%2W3K+M%tY%&vOsH?} z(+*Lhb<-JE6k@gMoL}4zYM^!+vSZOUeThifh;l-=pD?vsF(r=6^A=pm!Fhu3j|WG6 zzKkfmEYQKZ`;q20TrMT}+y)M&$++#N zV)j1easzDRctv*uJv<_9)T4gp&=3p+E-6QiS}ZeXY|Q?kGgJJB}YpH-rX9sc?zkv z%L?`hT9?ecL=l1=ng}K`u7177edhFfzNg9hEMOJLZlm4`-kGvn5ScMx`5Jy67VWhz zz&O7csJvWze0h3(!@+qlTgu2MKBQ39gkt?0xVO)CdWnNxUe;a8?ovu(@L6$)sUq82 z)jeEjo4vnRO3n=e($lc%>)?-7Iw=Nkb;{0R?F09o?gJ*PJxDep8lKES?m%PxT~?X_ zkc0sp{ZHUO|0?3nliDhPmGH?#<|8~iy0uhRdjbpI?EN#ZOScThU?w{qHcr<(153yy3=Ut=A zXU13$aJGF5k&996|lbE%5YohvqvXBGi2(ok^`v<3@YciPYa+%pTM!w=JZ| zV5^@N4gt>ra6YnJs;?|$0%u#kF7+q(0bLhBuceOtrwC4=IY<0YJ%PDYS^%ztsfL2ZZaeiQEwL{E*38e(Kj#3L7!uA~llA?{%yTo% z>v@XJu2luLHnANY9Xy4jq`n@&J$vm4lW0<9y0pe#s>=+_bDrH?w4!rR0ZM`Gugc8R zH}I^^-IeU^teY2;uf`!<|1X5=QGJewgww@m3 z6xYa#B=8P+ixIM0OOjd=zVj`TnzeV@u9-AW(hcHS1D>H1$X!jLV_gHx_^Ko6THg{k z*>5e(k!w2bT8|w8HZ#6}S?Y5K2kulh)me6RuW;Pe`Oywg#m6>+2m71XM|SfS{RcEl z_VM>~#nqM^DYcmixKSss!5G! zJzjRFfJ$q-Kt4)CBy*Unb?3`}gvo9cU?yg;zoEzYv~h`C-(36N8<)1>T$$;L$&)8w zPQLSV$?^9t5|y-agn%ontNWE`(s;@wPsAHGZhIGKzB?i!ZLUu+-2)5g2p>nHN%V3| zF-!=_kDO=fuGQT{Zf?&UmbV812P(Bv+|l{xo-o|r3&7<_Wm}a&<)HT(D0fISp1g!W zIpke$%^`vYlR#bQ0%u z8JUR+3|P71^(WUXxZ58jx=dEeKp}b zdA6_N^h}eSVX%?B?>bRBP*i35QAP#fo}^hsVpIyG~btN2gj9?l91V zSHp1k(jg9T`*trVn4x8rt6K~Lx>+A}i}8WU~P zc+^4w8U1E=<`(r=B@WGf8|;x@*p|l|oHz}vh>L7~E!2LHhw@9)K%UqOVLsjOJiqqz zdvN+Qv{72Oe<;%{jro-RqSB*^7G8dGEpIk4UX#`ZRM+p-J&N@f|4wXZH#s9tl$4bH zU5f*QIvN{i@U@LmdDIU*fFAtUxRq06t-lgi#VS@H>F-|%C*^e%kG<8mVn ziJ04|Ldw|iNm4+Gwfz(S_GQ`hlg`Y92>8M_zZZA0{z+3$iFA9K?7-Cw?z-NcvUjWB zbZ)B%=?XK}U1qgueq`C+y#Aiplb&0Fd^kNz6y%ViH&-&Q>%a+GJs48%i{kBSvyldH z8d-oQ>*2LGuUNyftZgbILB^9h44>%UukwRfBH+znZ-grWUvRq`arjr3qo-QI3VCE6 zY@5>8p20rA-UTUP!rJtpLL8-*8j8ZBd)SO;dyGpmfW;;v;P{)f*{tqDjTkSc8pmu&eF^X97OvXV$43lGf~^D}RMu z?X@ait`zz{#Q#V@V39;EX-Kb(tqU#VBOq0{qMB8s$WC14Bnx>NBMX_%f z*?5i6LpCR;bU{|6pW0fwqWq%3OluPYrEyG80Exd>RyO(8;IMC zJbF*-B$Z#%Y=5=Xa!4dh$`wuYBuc#Iq=xO^vIl zNffro1pu0=aEum54MO}dt~T8W>S0236Ype#UZfiF=hRt<=}u>}H#J}X%opsmF(XWn zq3zR{;v+)!XYOD*lskMI!%rS*vRw2HXR7uQIj7haY&KVnm?ZdVE8(;ez<$K%>cn+S zfcflfa5K`3f)1vX=G`tIN-$Lik@fzQxVfsBTX*>>VlZ}b*(5{!zgnD82Y4$ivux1E zRFZmPwaS5>GN^SB@2_dVO=;L>>hdz7ftOa&Rm)sj8C2yF{16b^M-gyUTrhZE8j!k)M-UwtW2&h|mAtgcBS!he`&5+6hE2-g;dSpAk4OXVY&^HkPkCz`Fl@ZFQ^V_&2GLp|9y7dk}-IH z7S@C=c)d8ydM~7;B0BF*Fau z$O6iojN;EMo4Zkw+yfl4S84-9N9iMHuiX8|clLO0aS?enqs4ruKT*JrbWk_?9X=ss zWj%QRulvr72VPpS^X!V)^#qwVgEkW2)~vYccJOi7x*VOJ?s<;=qu1NZ*)nUHs=NSN zv;Ud`XL#7VF7J?+XTZU&8k@c%(SP1tl6&rXfzV&i&(7?P;HuiE)* zX!K`E!5Se=3MmDM;i%2cz$8%>Uq)%+d}TFtOBs}d4{p4vo_@~XW;SQD{z-4_)HXVk zWnq+@Ev6avj8tC9^_}HzVQ+-oxMz)a5Stng08o=w> zG;Bg@kSC+GjevQwsT^1{q%FkfsrpYyx_NEie0UoevXj9!@a=R?V}=i5>ha)|&#QL}R6$g~GpWNgdLs?QGLenlqH-OOhtu01`)#aV)K{*p0|A z)BCXAT4D7w3OLi6axWW31en!*(1gNEP09=neuk^9w;_>%VrrY(C+ClRd#N98PR-A5 zHAF>fkEQO;NeN`y$cah$u}d8Wf9$*^!zVl&+-c-fsU{Az3cr&^c+R7G`6s<{xzjxk zlw;%xDRj;vvo!?C#&lb@3#(JaG*Wus^vViYsiR54e<8+Jpqh)`AJLHmie_Dn%r$Z6 z>H!A;;Y9!GO!CVkrk@5*vo?^^Jq&x-S-&{-HhbZ?BMa4U0??4lb~w8(#zZ_q#TjAb z&WYrCV`xCO{f;WOw;64$aXVr$X5+>-utxZM0)Q}@_u_eejoNs6zI^_1NmI;Pk{q!w zIwKFJef;wm>Y?2a?AGKx#e1HSj~2ys0`PcfH6eYUO_(4(NYqN9nBADw5jxU1)3`ivH{XUTI+^qrww|KzARMyW z!z(Npg+6C_k|UTb!E%% z@v(_rlqQZ$bNZ0cO4acG3L49W_0?*vjlfRXV0s$HH}Br2<*BnJl~Kgf!wf9@pgaJ>S{qj>%_9Bxsy!% z%l+On^33sO*D=&xju-`QH)YGsTUY-HupKL6kJaQK&s4!}_L=p$z>+H8ABx#?(aJ&$Ar}9; zFk}bpaPIvtn3Mm0g|sqX>3JstsjlVHWs#)g6mOW3 zUoh3Xd#4?3Wv&Ktg_WJDn|3~v(&mh&izB&7pd{Gzq<#BOt+I96Gtb1F)c>ssJLuRECG`ERC+dPCseI?ht<73)*%usy#ESNtMFWi9Z^ z+cv#kg+lbD>*zzlJNJegU~JI>tR$av{vLS+@%ximZvF-8vl^;aq8`yU-s9urvow{E zDEDjsA9E8mPAD441B7S!_))6u&Rq^Fg+3O4D0@+cj3RT_&&JgxslN%BBfKCs%xk~y z>9C1(M#YsN{d=#6g$x$4UapgtvG>cay^R2)KB%O+^nd`s#y7px7Z5e8Hy9^f4}W-pu(McAr|O#x&A07MzoArl)+ zt(pUrmD;sTbqfCUP_Y&SFqkQ4SION~uc$tJ3kdRjUrzRzY3a#aHo|)vr9ZHzLxVC> z=ZJuj4TF6rRd0o!4qRR-x)XL<3P_OjM?*m9dW~0vxY>^S+Ggnxz?}LjrLZlaa(uYx zo+*d4pisOuzE91) zknlblNZEYL-7YR8kf|m!kk=7Ea7?Gm&CU)66ek>UBgECOo3qGn>i+pJ#FV+N*@i) z^b^xOBPw!*x_uZ*+`ddB8r#kQ>K0ut@^}E6BmP(x-+<&A4v1trBNL!_XEFBpGm#s< zKxTjy?ay#kqK&nL!?exdb;t(70i18C<&*b@i9kxp;K1>d7IeZEVBfpm5% z@vFbjd6(azBn?J|FJDHQ)jTE|A+@(iECg0aF=uaIy%W+uY1H!u)=~iWVGd+LQwJxh zL}EIXu{^`nLDDhI{G3&<%87p6>`&eWCOc-A zg|jrm=95`t>v2zilv1M+_A}X8ltV1bCdG=i-3U*MjHZGt|{FUSKD`Gq9CNaLSr-cNoTuCAy*=ry3fDG7cC366KnOSl?jS>~1Y zt;}sC1HV8>t_62I2eMc^X&WD6^wpfuXT)49iOK!Y*hlYK?t<;uXLV9eD@XG3;gmuk z+Vf``BvO%j2HO~sd>(V>gHMf~IxBNv9B*7!{HJr5U;p%@(>Ui2Q zqPA@1tf1X_E|ts4OnX+cWgHTd80vmEBkUxC@s3Qhg_r^(XzkDBlY4OP) zNtXWrzS{)U2k|WL!$QJ#=WK`8bN{I{fA;Zd8?TNBFQARsuhw(aIdx1eWQYO?iztUm z6`WtWOm6&oT)A8rV=fv{+7V3_@JUF2w3(H4=fo6?L(kfz8gh|*raGg|_%gCJXeG^9 zQ*!GO8Aws^}nO)FZR z_%rLUy?dj#XCoas^yyau1w!FEqwn2YLF*GGaIv2@ouItwx8@y0XGk%?a3kxz!L2Kp zR6nr!>zsYM3+rv)12oM}4`<>9qrTJmn~_tK81T~}H}7=lnw0H6yi>unslx}u6JIfZ z{9@Yo1XmAj0wyt4S@uC%+af~x!+a&6khb2Qs|bM%lJx7B``}7W=$v1G0%@tY?Qt!MX#0# z$@U9k+4a_oY-6Y)kXFZO4y@e}ZIe07SVG_PuSE9n8DMiDI&65dtOpXWd87WTxV-#Q zfgvhJuLAIZ%HlPoF_W3zj-SS0I8}PwD=^Iv*99Csj3B@D7x=T}@;x1a+z;LXFp7pp zxD7fxkt8!|l|Phtaphr?Zk-0{d6xdGe-wg#8D1Gq^5q%YiTmC)w`435R;Dl1)lh>m%GHn-`m7_A` zUVMRG{+xF2HUIdwVDif-aPJNkfNU7p$0EsPoM={aQZNunrwf%-L$H2zs z2ouM5nCLoWNXdQ*r@ExH*VmQkmE+{m-p1^M%ygOU<_-%+=O-a-XT%4g;1Q_|(N~5A zGUJ-p4$*}$`z^QYvv^7qi=p;Wp4kidY?sXo){oL(AS$m@xFS%j(!DzjV-<^K4BOZO>+Z?%&Ymc@K`#i;5x{n3}MfVR5b;%9={^oeC9F+ zPayXU0zuyO=Jz2#aK`ezV4TXn`fVYSO%uyw;%sA09sC#7j?nVmws;{nyotvtfx}H= zslSI`fZaRnjN^K$9eTvTm z9#>>-sc2B(j*%8A<5@RApL{+!J4-Lv?lP!&kH_t=UeBs0aOf3FpQ8bc)g2m|Gdsl* zHuMwj%~OCUw+Q{1yMNg~VXWM>yr_mJ!2@e(Aix+ii<(JUrR2lA%uo)1=2iOm?0$jv z;@{G{36z#`FM!zYgYwZ|hcwMUYmS&uiW*Dx6fg~2C+l963kv@!7xc)`&or0GY5c$O zZ+cpO6=J|+#}dk%5W;^D_`;Qn=2V|QqqMZcVY~KXBBgQk!2^tyUBt`xw&Xm9D0Zgb?|lXw z%3aes|Bv`FmZN8oT_ty`J#hNVJu_Z%qkLK@6Kb^?Fyynnmg_4c6y`_O`d5#MxWVG zg%gM5B7;1s)WO06HW%u>fZcSPJFq)&06jHz$3%IzrCvPpPx9#rpr7=Yv(0_Ll&s|N z;)MrbP<=c09X&}eg-`=R-J*m@?%o*`SU|15ivDR9d|Gp*3xrQzMlj8}^eVdl6j69x zkD|`}rUF{T?c_Cr`_QeXk3H`XXiKHm^t4=KwdOK!`&D9&bl9i2QN+nnTeg1LX{b3*qSOx+Z z(dT!r6+ry09Q=)dVKun2s!G@DiJyu=zM=vVPn;7pV;qm;(WvGmkLY27=-+MdYq5&vN4hC2zzz92nx++{fXd#}tgrZYZ`9u383v%rDB@1SNQJU?MP>OlT^l`#MxWkyB@P>K6fGmj`oiU2Hi7TP3#`X3)%VI zcuwJP+Eg#!vpG8RNtbgf@5k!TM~mNv2}DyQKi!_6o9TU-k!jJOE#)c~bK+cw%-mGA zy-|?5r^j3Z{4$>b0finRuFsj2r+hM==FwH>iRWDv;gV?Dt+Ucb>1|j2vWMX0)qYZ zM}~Z|gAWUQ%yYpzw}3vJ&8R3y%VVTB;1Zzm8hMs?#aE(PO~B@Jfs0BbHYZo8*E0xn>^x3U!XdxP9W;FyM9h>w%Ml+WnC zQub3-tubOp2aY*}Jld&}Ch`L&7?bjHp$|OE{{tI(AdL9zX1Dm*1s%csT7nW@7JzNc z1u}R3?aOQLiq^}7G$NG$0AAQ4>?89x@L=kwgU_Eg82JSs@LFEz5gQL*oqR*V@2h@~>$(6&)Vq4Z!mzjgAf_!d(=7XEWLIp77vkkX$+w6EY(s>7 zjMSlJ-+VJl=bdvM?Aw=;rl-<4m0OL=Z=QbPleJ6SuB}5(r0*#`o-t#G@nH#qzx!O^ z$&V5Es9D9se?|ALS;jX0Y<%)7<>nv{%+CHyvvR#RtL%XiXi>g=FjY3iXjCrO04}UY zng`X|oW2_#r9?Lh?4XY_p@UIffzBl9HK34V8}@mwVA=7xsBk21_Tu6KVCCGqv*6Q5 zRTR3GpOSAVS1}MZHKYuba>dn^nLD75DSD@hRmKU z|2J)?91L&uo?|o0Qv&w}{zJ2KT7Ok`H!8YZw&um?VpRmfBx`_48JQV0`&*#Mcc1@g zeB=*Qu|c?X8|V86QUY61x1V|)l7CwsOdIREy3-)^{JcZdd~l@DiY!FwckPEAzxcqr zP39Aijq%)k8daV^cCr+hcYft5tt9a2+;dV^L;%Ozi!`f0-FODn|w9B7QQl! z+o!Yna6U9QDD?F3O4KuMUL-WE>+Raq5iqkO_c}CTD0eWn`VYr1@Te(frE#f#;T)BMZv#AbjJY)Sbt} zzB;@oRy?UhP-Sj1TfB@LmS^r*gO3A(DI{7okVWv-t=cJI4|Xu$qLTvahbv{6XlckH4P0HB{{3YNvH$es1(HNB&vKG#UP9 zGqwP{C-#d!f6`VTs=v%4R7|ATHl>i5LbM6|PIVH=aDS@wUGdAR^V0uwC5f{94v`q2bGsdE0COSUj@~aFg1boB0#By#ot787|Md48v+JXJ|#`?8x` zd-jDh?aV_G0kQhtAWH1L;|FaSKS2_~T*kze$A2ap9#TXD6}irRWtj0pQ*rkEneM>k z9@jB1ofP)=@c|mb38hyaPo{sqp!Wl9Mb`}E0o2h4y z%O>W_{U1qJ9aYuSMen1LdeYsENOzZXgD8!(bT>Se5&n(}pMB2R+gl2q7L)wpg=Q1dI@Q6R!3|*C29&xB*hCp3y&rI{>ud=VNsK+I_rwqlY-%hb87v674CVA$IV2X1l ze1$T7RY7UMZ6Xin`-oWPS9*DQeNWW%(>}m)@qOcSKb|PW&yV6pS+Zt()Xz|zrZQQq zMla%L7ux66z;be9huQMx#_C2FZ?MES7H5`(dtM&$S8E| zX*!$|J|S`gj994l112o0?8asM%?qt5b(bd7?H!$QV*UX76@|#?%TV{C^Csy!`nO80 zq;KYUN63~#4W)G)MGKFmXgLu};eEjc+Bc7-#=x|MyU^l!&z3%ki5^gx`Bhs7E(o4_ zI`XqTsU+;23Kz9e?U_)k#RON4SyryUA zuIpd`r*#)*S~;}?aRWf(1wB(FD;kV-GSb}-IP)*d?Zw?ff9`;Zv;;IBr>3T-b(L($ zZLo5BhnQPu6qV#XS$?`OePR7cFP|rwiAD{Hc2*Xk>$j1g$9W*_6kpdkS?cQEeyH%( zA2C|IOa?qJr8YXCjlE|~>Cnv|joa|C*>4@7P$B#oFU`QrMpUihPq)eYV>lhRf$YQ( zBYkjPN36l=AWd{NvctDsDozO`Fu}SGMuJ8LhgAU$*2^Y@9Adf8{=HE$3;|?e*E9#kH*M#v#K=uVC;8 zX~M)^idCZ+{$((Z(%qf=NH5wMpVuF7c163&l05vSaTwO&-CJLl&4aweUHaW>hx~yI zyUA=R+75KC#9t7z)^h5}+{nYB!dXRe@43-_2sq1UgFPKQk!zbsn;0Ko-X76n{I+~= z(s+hUbDib0k!lP2@Y`6Uv{t_syq)*cdW+q0{}3dfa}y>5W6&-$OlnZ~?<#N+i%8$A zCY)qF{EavkKJUl45&D^OS9Tat_VNofd~`GkN{H}Pbs1Jg!bR8s`xJTXN63rF^H_v0 zpZ#5hae2fJR52Dd9r(1;fBHdedivMEbKZSWv3jCF-3eHgkpH70Crr;a+QpS-(Rq`i zSCE6dtu?uu<}Ia31%Y@#-D<(;pKG&J^^u$=m=-A5C6z|e5QWuE3N+L|A30W&y_|^> zhI&3aJM2GhuqQWR9)S1Ir!C!~O1_%BnauNDP-wj8x7tTY%pSjfo_4pk{8X_h(S;n0+ zzh$ZTFP3EqXdAFYbW>&b_^DenX;G??Pf{Txic*(6hrqYtV zl+2off&z=g*mq^o$oZnT8BA92139Oi2jtDRhQB=?Mc>yr>QSa`#QOWyYWmco^-?Ql zRw!|uO!7g21Tn|j&|=pub3Zyk>UprR;DinP*fj;jg#NsywR=hNS>1M5e3qbJUXVVl zqlghFog15hQGZtE;tQy8kn`a0Uxo2(9Je1sqj&BPaMfZRqUk$(rO+&6IP= zitLgo=tYR>$k!|Q_;tzlciY**r{_)X)}COLQ1pGO5SQeB?ZiZl&5r?oA)x>O=65i# z$y8G3^6X_q_Zel_I%-+64^s*PVXoWlTM##hn01$5`=)Ix`!j8PVRdMH+yN4?mb_m7 z{LFG3iclyK5UKUO_tAmIdAsLsco8<;?5hYw1#(D%_rKc=zJg(t#hqYvoYU0Cpt<^u z{ki%-Cli`~cFpdZxq;~4HIScQ_*b3MHlH3iz~;rt$;oCJJ+@*dFk;Fc2b13T!I*!C zw~#s)KYBJu%jf3bMUS6_YeIbwcyIT%8Sq!mJbf;;342WAsI@L93{~sPzp2_*XYeq; zjZA~3mDb@9HF8NzdXMGhAgoI2A)tML`p66INm+)(#H)9&glL0q9dI3-{I5>;w=;ns z=9t;7yW&BDf!TP_`Uiu2FbDnVRGdqTCCjF2_+hzxn<`(wH6YwS_k5I<1wz5=g;R=q ziI?{j^njUtVPM{z=l;THFq0l^5=R~9*lVaNr|Z$2oju091}zJ&s|Z6P_qj3)-XlfB zz+1Teu|dfsj0G}4uE_W4x8M{(x&?FK6zz9yheVkH2N&fH?fd)Rbu}c5WIiL(nB-qS z|KJLx&68^kz3-Le<#c4`>7O?-k|Q_R{oH*X>-|E~p2H?=!Y89NJC?{vhbM!_pN$u4Oh7UiWEpG1oc{=e_<%Lse(n=+%IM;hZ#(!zl*j+D* zpY=ube1t^+=CTqe21gL%I&V={fl<2W<2ZBKrve=gLPQ}V1pOA(w=z8D4iY9ihj`~p zW_!N=6X!Y+m*U}&NEy7qn-2-uF!*}u#gj5fBBA+yoIDqKOKI{F{kO0NodA>y?%`9E z%$l*FUtR&)A|(JRxzlPBe(+3OuojfAXsMAn#2JNEPT`-o+dIBgMlJHx*{vKFf!;pZ zE~^vq$&znYNg~f8Xv8_kK2pzHuq_)$F-8ic^p*HIebr0Atk#$5KWlHITcFX;veo^` zq{Tb`;C#@mb)6%5^9`+*go4x9di1Wm6p4eAM1s8rJHY!!3GUF7e_cE~0^41uhr@$SJBxLN1vFa9Bn+pd%GESz&$4ZmG{@k=F{b?`{9kk zz`4b5HV=-7mn}Q|^!yUM;V5JCO&$Tx?H8Qi?`aRU&%6RE4LuliwtoZCP=Cd|_xj#b2ua2<7V99d!BdULCtD2g zZPm7ol~q9Z@)^8Ma>VEUa83-HU|X?B=8iYwLDtBaCr^46ur49-Bh8l8rNOj7%Hi(?4JZt%ecT;ZwAgySif6|_ zh@Lf!(k}{mVLFdqB^L5pt`p@8O(B`d*52;Eyf)NCaYi!@R|+nEiBYIibkwczuEjZc z&`9KFlH}*K5Pu%KFBYZL+qC+6}3rZ>~ zD#2LGT6m*PpLVL_bJ1_d`3U|6(n{x{V{zb{F@?jn8w1}u6|cyeD7mlyfwiHu4mSmY z9syypW2)9WXnH7)WlW!oR13%L`9@#=e&93;Zib%)=;t)cyB&O!-nGv3uAHt!+$ron z7qE}~_By?vmI8G>x2;nIYwKbjl-8LF#DpMNqkeN^TRN19LUnTS1GP@&@@VEA=w_6? zJ%&b#7tBPc4PU?fs{lLsWCuD)@_?F~Ti&kvc06gRBVP5R?!hWk&%=Ta&0xPCx>tsehz+O2}hBk?8T0{?lCOg z37&)rxAHM%4&m4Kklo49d<)ZWxTI$@wLX}{lA73pUi&qG()Z zl_*_&VtCL;VcGehG=`B15gBTgKV>)~vKmZd`w^|`KDH!r7EFDH10T~g;;^JH%<8Cd zv&SbfxX~ISXEFX@203#?hYdSHm0FY~;tWcG%JjZ2SbI)tEBb(BKptO{w145uihMErQJ*P}@05jbJ;hh78UbnLFyxktFgUXO^ef_37FMskd)AUh` z1bCZJ_GYLAC-FYZvWtF;KMavJ+$KKYX4;05Y?e>fAv?5tInQnD2n;z@noDI%HBsd^ z`Au~{(2A09-hI!T@?_$L>ceCBt+z;-sQ`Vl2@cr0<^pl~zo3e?C-#}$;YMPl2Dlm? zH2;Zl8Txwr&&BSgy}fdTMI`vuU~$_I9gKnP@{A<$LN~6%??AMJe@Q$CWp#qe%EPXr zdM6pI5&K3ACc*8fpzhrkULg=x`!BG;1FHTRJO!HL`GI?N`C03x#3|Ee`oxQ9%qRtY zF4mjHlCB4yHoU_;f|#bZ3AQR`)vcsIUT}ave)EepytvA!h<=&$2gDlT)3=FGjFVlG z(rO{d$Kh2z;Rlc2V~#=#yt5tvMFP}?L*HI89Yv}jPaw0Ov2*i*T%@ZNWrXBYmW=b3 z7~jL}|F6n>^`gTCeKgqQ3sK2_oXd`@oKJ=BtnwWiwkIKE&vd$`cVLOHrfE4PZS`)r zAH4P*&r@#VFg0V&P<;^)(;uj%y` zW)uqLEgYME%tXDDvs;5IB9NR2M~+@5(vkQP^0JsxN~R%Gj8d>OS~>boaoaxdK2o?B zQwX@BScIGSu7u$L%{miSyNR-u$W1>t4-XH$N5twrJ`IQA&zPDs*jJ!N(<^10kBOxm zyP9ay680e0nAmKy1-Vp(M6Khd%}BuP8}pG7;8pX9go*6`_zjzrz5xPeVKpP7Ev~CE zLYKXnuCffIQrLA*-Gmb~3UgBxDdWZNT8};6i~n^6`<1$gX65f=qxH;u+uVEpM=(Ll z|6ddhXp;}}V9g8uu%e9ZheVS4(gt|0E4sS9L^$B)wkw3flN zZL4tbD#enXkoVp%N6E}-eSYje;#}0A-n-IzJj`JnuuylKqwucgz0~P516x}~CSai9 zy&Es5AU@Ikz&>cH0n9%CIap{(Z0>H?Vf%l02OZAH`R0;7SfEK-QNVMhIdWPCDr*Oz z1OwEvR{bL`OyE)27ykRz2S0JYL#$>3UG= zIl0CAd0wZ1Zi+gOF_cTpYY4t1?hjgQ(x>d#{et}bp)8q!?zhDy6>ovkKNIL+@Y)6w zYj708s4zXk?;kuN_9`=$S%t#(cFCZ0YcmpzhHH=4)Dvt|Q@P!)0Ep&WdvDe0tFH<+$=1>oIHl!9YnXDi$%n zZ^oHW*n$PAwK2t8mC~+pvSiO%F>OpOILfO{KO9UmqnY<;VDo~{X)WjiMtfWQ$mKq+ z&Gv8l1Czl-Q4Bq#JiRn{Bgr|Q{ND-fuIX9g@RjFE0V5s=w43uJm3Wo>90t(XYvMh# zzQUAZoRBK)_JSE(t|p`~^KN9(AY$6hWUj&^{BhJ+@(^Lfeq7gbQUbI6M=EFCjoZuF zv*>;_eA~>SI_$ed$V?TeiN_alh%I|-C`!rMiHPTQrnB3{w=Yv}%B_$-X{kFwOcw@le(Ii26}pSv8$uT0V11_2Pvs#aGM+s(}dN;^S{2fJD&E zN8~sVy|8&4o&_PQI3oF^8`XRZ_J zfjd`qh0SvPdSfDZ|M}~;9_5C~M9%h3h3nQ#T|a_(Amz1W7G)$_I!uJT2C4IMoRh~! z@y>p=MY>{9w1aL|;> zQQkWKQP8Qmy=8Dj8l|J!@%J&K-&YL8`p=6_QU<_iS&`BS`O*+)BI2O+9p&+bn$Mk? zk|LPO5Mz( zB8~KX{r>90@*M`G@b{1MIfki`(OkfGehL_73vjN@%agZ^4(eRDg}>rb8~lAh2`vdn zY5s|^C9>&t(}TXRR6ab5gz^@SxzgScl11f=24G6V|Smn zlRhXH67_tHS_zQ#IA?+FaAc}PyVN54B6aLT$$qZfS8v%N5^4Uc;nzvmO6OR+qjGqv zLA!HH1z5z7BEw~6|MC%X_jKIoXOkZF{^_YX{$B6&!=g0F!xSmYUU-^5)U`o&1}jT= zu-6mfncnJ38hn1gBNiS~$3gN~ooNNT*j)}t1xfUY8a#u%ZR4*`t!1oGrC+fALK)k- zf+NZ#%NV$%VM8cKoKPV$>zn(Bz%UUSx6SZ-vcqB4B+Bn}c~TXpa9V;XysCfX_&oWg z(bFRP=ij>|&$~s^(MD0T1QlCJHq#?$$-|i#VR3QiC^?TY`_OtZ!dSCI{j=%S3U%kJ zoGNr=h8BLcI!`h>A8~O&s|2_JPF~X>NMN1}jpQ?ML@w+a>d1zE|F;&BRRzp$KlRP( zhlN+8mF$(@4hyqbO3UKEmLftV8Ej&0rh_8&udy-xjqmHmFUBRLf742KTs#Kh*G(P$ ziXDp0v9pKgAa{AVWhHeCSrPWSwWh=35Pjpm>X2+4{<}Ys)REszLr^P`zIk7;g?`++ z@`|K0bN})_G_w*t$IX@ifuAKZ1%}KmhdAZcoTUf1du83p8uD z$i#4;y;(4TFc#fK z{PPe@sS!vL*nO-qrI@BQ9d*9TVFLXiPIKL=1dmkM_|=i~+w05D>&y!0`D}inKoB}z zJg^B#yE)4s2)A3NdJNm5?hcAa0m}uw)s%T-$2wHZG38~#S&|O_O?nF_8BVok4$zHt zQdm~+dWsKB=e?MP))on7>_qCnEg=6DfxfycOKTyA{HC5HX)#f{V9InR^Nf{v1ONSlY*n1b5_f|U^oaDAKbm67t5*bkUKbwcV>o^_MR zNak{lAJ=6cB9NM9?<4dVC+(&v0)Oob5s#<8 zsvqqLFSf9G49l>+vLKb$*O$U{nw02mrS|{ejfvQoxt4!4_<#xjExW$<0OUxv@$XS& z|9X?wa)veje@lnthJm+Ix=U{g7b+_D#m5kfm239*Z?u1IYW*6YG?+MFiuB+WJ#tmm zTYiK-6pXxFg$S9(K*-Ua+$#19u&g*hS;dZjli0RF&MEA1dqEQVF6x0?LF%8puN)4O zs;mOCbUXj#FM@}wKTe|{;4eIe#TF#28w=pw{OX;g?{rmECC>ecyTxA_uQ49n0#~sA zKNSx~VSK5K;v?c3oCWhEY2<#g8lAVMVIr{xssBvRS6wJy52Z!x*UCK$xW$+KOP~kG zNF7Oqf>F#9uy2{Ju{tsF!eqhXQGdK0Feu-^(iGo zq_+!#5$Ts5&t&KXuq}U)(aU0VP&ykiQjqSDf1c6>xeuB7Uqf)i&_!Ek## zhcEacvsaZDkCNVNc5-Q{__?`N`E_Dq>6%?#PER&@oW&X@E8L7;+0I?zFc1o0n@JGq z2}ujllKV)kLhvB@3}a9zrH@x47Kzo(W#x}vQKzC=0m*>=8gr84N^6@FmX2ppj(t>8Vv8EmN<`KR)h#va@S|Z@POr#G(N8#y<^Y{rex+jrtm~xU!O7YkHO!GrA5uYuM~)z z9ptRX@uzRBJ#qN7?u3Tao+zK3Cgkuy?uD8gTBz;l3zGTsK z@yk%Y|B9;8<-ou@iB1s}p%XR!SH(BJR+|X5Se9Ql(f}QSll2BZzA7ik5luPu1^*j{ zE98iB1R;!|Jq*_)8l*}R1QskEFY9IAS44sRscFCub+do#Ts1+bGeXz%Uo+O-F=I60 zvc7(PejAOeu_J-&9A-~X7ks{cv7Yl}Jh{DT|B~J{8WWwvigG(##~Ln0RQK37$?iq; zpX4dxmi+wuywXz7oKLl+xV&&@+a@h$OvF73XmK8(6N2{_OFh*mQpjJYgFG8$>Qx&1 zdD%Ym^>S2f5F!K>w8!iBi-M@WholoQzJ7~>Ac7MF(S(0NK_yavA4sMk3p*3yzA)8# zW=G}L4LprZg9J=m&sR{rc7>GoM}$%HCsTpE`vyqq_&~x#*;ZZ6o51tq;fL!JMM!pq zd(3m;&`dvaMeLsy10-=|L(c8u;8Zs|Yp4oS+um0%u6kxAjQ(s^J$UWX**Y%h$^A~> ztA0oXeUXn{VJj}k?;87f@O@0?+jtqLx()Dr^*v!Zf$VA|VsGhq^%N2y6k>HK1Km{& z;j=tKiBS=0#fjiTY9#HrJ+jr(ek`2GtSJbZPx*H{vACXs^p~>g5!QSa#%nRS`in0V zDFx3XsJx$DCa7{*X5Q@C<~El*q^j9WekIwYTygzK${+__$3b4w!Y~o)ZY};)wc>uO z>Ld?IxrxaaYsp90q1d4p0DCtHSVJoF#a|ik?sr@kX^seM31#7zFTb!OI=f8y(mgZ; z}eVGnAgZ)A|WRVUCgCl}y*CcJ5<;}a_ ze7iZFlk)hJ^X`JA-BssHR+g5KIzbe@?Ocr9QNsB65Rks)GrRa}Fd1~ZAoN=}me(*L zQ?soxnt?oK+?Db5N}4h@LI>j*tpd}zf+EjWJThB}J~eMaLV}#mDy^7ZfEtdVf)o9D z7F*Mv#zXvCO2Xs*^GEX}$?8GX)a=$oAN(2d$-KLz*}@9g`{i94Y@R0@JX8|8PCX!Z zJB$f5c>M+iz^8efWj4qOc3Z9h#fJ4=(xBtDb{*rOy^D$(vW%5@9MqqqFz_vc68|Zo zLfxZnNLXWK=pzrG$D-e+p>2j&{ZkVY&SG)#XQ&x~RqnCmk!53&WX@JS%4`xey~Q8I z87Js-ZV<8atZfiek5L!2EDhH HoJ5fAZ^a_pCZu%Wv2Vm#)c?1#*NjxK$`~iXVkoFlZoqH+@(hn#D=87lZW$Z$0#u`794lR2M zoiE|#(p{wT&xo%^Pn6XRODcXA^?LcyL&N%KNILaKu?@BVR`#|kS4%Wy$hF&w9e)8T z)9w?b2H^jW?z zS&iBAJqF|>Bq0UR<<@KDXLxjHei$8SzQQXLmRsq^LA1uf8iQ`|D>U_q(wj3W+8|H} zu~>P_60V}ED$whuG|Kx8f3cM!-`aY6B>g;f2#_zeL1%MZ@)+7rURD}VJK(>SciD&$ zT2z|%IcyskcrP_2t_JAwKGFb(<_*8zr*X z#hNS^f)hE=hTeVTIzA3X`URd0h29>rtP*$v-JKc{@Qi7LxVs9*sk^)@WuOQ!j4 zWuyO$p%4n^OO4(*t|)*9IX(21!fZvQZUPmKN{>yAlAPAUA<@Ta3c6N+F#@@95B|bb~tzw-~r?8$T6Os7o0jpws#{fRiTk@QSb zt9a>ez7*R~1)n1wC#XJVy5~z-so$fQ53CBNiqV8zYhMC|{Hn~51Ch3>sF0F$~%dkiueLO6FJR#5Sf=r^Yh_< z$tipwel9gMMPFUZ@=v4S{0|L%Lh5%4egOfW#wRBB^O3a}T2n$kVI?_FU)D_>th;zP}JA|m>PXCz)`_?(0ujy-_6+~7T6uwSJ?do z1aWO--hEj~^}Ic4=or=LK~2NukAc+uFL*+J+wVVibm5Qfe@LqlI(yyS-onwiadPma z@gcBhPPMdzR1-Mt+@e$l)FlY1lM8t_(-j!#2x8Z31%(_6?EMuWQMxp_HjSXL`2d2% zK`VI+S*xoGc?63CqZ47IT<%{S)bBs_QiTLwj;nI%ZczD}KNG^<5sq~< zq=|5)q)<>$m?bhW{>IMEF8A~2Pc%A4t(b*S_?O~=xw$!BV5(|&nb882A9r`V3hZOU zHy@p?7(;Z7FuDn4e`#oRd_H=Au7R)TS)@*LO1b^dE?Ou^eZa^a4*kYQ04gu_zr39m zTj;!^ukP(rjW79W1$Yiz)Q_)bn`p*$P! zOp)xHg0sRTS#83?a%ZPYpgCV{3dn9PRn>RHq`3@1B~UBwas`}ER-E}oY|LGs#=G#g z?SChBM)i&gF7)3X{Qe^n8fOSOg!?f&LzT3>wgs}}#`j1`F_*N%GxAAM@I%~){ptbs z&sx^c5|9+MeJw0rEiANB0XWUxY7cF|aZH2x!+U7iA>L0rQjMy=%fm*mglHQu0jHmh`fWE}>aoN0ZaJ(&b z6AcPe*3sT^O3r^m!e~Ij3|W!3x(Y#GRyeoZ++m7cProBUbj5Xv4044sJly?d`F{Uq zcDlWs+O-3wtx`03d1&t_qUBwVl2-6XK1b-d0~wFerG5KJ5GV>i#ETH$c;gQV$(`Uh z9g9>h93V9S=%ClQYA8hFrY<7bg1v^P(-v$J>da^FkFjw&%D z^W_W6(m2&_1F+#T&7fykV;#gwjmTk-QI8}bUGBf*+@^YZN$p!X?E%Fqt0oHo1%!z-hVZP|CRR3^&4C?cW>gZqbJW-1%)~^7$QQwD}k|e)?9-V;FS?NqKO_uJnFkt zCeweDxo7HsEF3oQpu?chfK*D1`oi|Ezr=fEtguQF`5K3>+|hCRCVWhJcHyGp7kJj| z`QHtz3svW4Dha4UseO!lIU5$E_(1(Al09liSnYltz{M|v(LWz}%;}V3>Rr_AI5SQ^y&e&L zBw0;Gzk4TvvB~X$67m&u%zRIoMjF(wn*IO11QNV6p>gp1H7#{r7VOo8GH3_weCSc6 zAH+-6iY_a8B0*t&fEGuzPPFc=tjxpIMe5rrVjCZ4LEOVI-27qJSJ#r^l@Csb(*fP` zet)MQWhax2DC8GncZ<~#BBK_cRLD2zB7i*GWG)7dx`!Pj26<9!yxe9d5`C z3sZQ){viQp8$VYi3a85)2M1>xjId3=1y)(C&U4;&gzZtN@%wjxA>#2@FEuhj(%Sx( zDhCeP=W=q$C11Wk2szlKVRD`FL~VBEjW<-XaF|ktmj&+#hQ})JyN9WQNV0WY1E~FB z$D)YjMv}qw-|=d9b#1~OY;A`jQaNP{Y$B#G-|}`-dq>BUVv$mpW#u<`4Xn7?-;$2d za6^rE{h^T8ia$IgW!}W~1R;0$k)oD}>+3)F`WL!*ce9i^V|F>dtw1)Rr^6;vq4;7_ z16xa_F^Ba!M#%K=_WMZ4w`8un%1|D`@tF3Ya+eXC zv_4ez2#g4c3ec6Vv`VM6z^+z^%pi+I<`o=mBh=+H_)N1E;;REn$4dI~AxxM!N7F_g zIbDhIH)XxK%!p?2UtRa*SLn&WTcq!a*;{R_8%$XK15HN%|E{yYBX?)EWkp3*u7u=4 zR2W?mEwmeMdg3hzuv2{-6#l>jLN($PyZ?gxZ)1Xoc1hj{~ zg4T!$JLpOPII6RR_02IpbR{bcI~jrhMt!=!W}lX`UA$VEeM?O|?p4#8hXGvVvOWnq zy*ScP8H3S;w>F;T5z11q@kCp1B-EzRk_SnJvM!+W^l1xk=0h5=Bd4|U=!+97Rt`?i z{&cUYnQfQAV~Xs7fnqJ*RLZaK-gt1&^uq15RAeyR1YOEU=6zRjwiT)U7}K}*aV*yA zZtWqCg`l-62(|b#Jorl4mm5_)RVh@+X+CLt=fEbPiRl624Qx_4wnj!fDf{}zkH@e+ z1+ABKc^!i*OG7C4*Tif5Lur1#gG7gkvMlT`4fvn| zN^9BV*snzsg%-u)_!)D`K%9jP_COO}M+`iOTqfoed-%c;B0(0W$z8s-A`R!W9!Of7 zZa)>+1Vl?TTXAuWPd^@AQc-aaM!YE@jIlc_$$hZ;*PP4O4~?gX)758-0YVx5R!`#wTV zhHziU>4TaH$SQ;*U@JG7C9q#?nOcLt!@g%(ZS_;@>pxSlXXA)eJio-D*c$#U);1nJ z3z1{e%>}y3y=^&r(oQ2Mhm<38NDOJx{-T~hg@~cX2OYNYT@MEO!Wsr0YQ;~k)+Vd1q?R&hrG)Oy(M_B232J?Vz&&+mlG2AH@s?jk zeiLJ$vJ(M~f!y~LgU1+C%1$DSrr@K&oK+E0=Fp?puaS-;8Djg4y?&HDo@P9`~wA)Z6+TxrNK88arnS0$DDi&DA zhfqTky0Vsq>+q(m@O65G_SQd)h_2JU8IN%iv%soj&=iZ=yXy9%y43A6rU7Bt&&f%v zdmo1TeDb77w@|@xF8Nys_b@6TG}%8?FZu75otXG6^e_4!|4^cjA5~o0twr)B>oA*V zl+7}GFnocFK{hJx%MC9DQ?F)qFTH@XNLNvDu^w&SmtNMWzr>*hQfB-uw$?(`BXw`( z^kkP1p2!Ix#SDDyv1seoXb}6psyMBmm9r!(Nypp{1;G*ZTQ4d3C?QAKZ}(qW;9xSR z>CWBUd};Xf=sZHp5P?gL8A&I8Cat#S_~6JV1}042VGSV9FO0k3r^d!FXgr3-Qr|r` z4VA+cVFVajbeKtMl>f(U$lVPeFtf?_7+@<5{f&xBH<9$VhqlA%#2TcUQ&M*qBST<0 z)u%!82vslH_BldMNuL_Yl_RwG_L+G%aVXVxzbPDoJoje>T8oOtz#Fc;&WvA$=AF`> zmXteOQ1{uNm6oErN`rGBH!BW4{+P+{o{iBYgoII~wWk!a?KOVn6{+x>W?_3VET_{s zl0H@U5^3Pn1s|;rNOG_J^sj;T5K_-KFuHwDJ{q&X5v`n~P3t{?js#yj{86UkuVjHW z;^%BT3f+Gzkk0d zu^DGn5W@amA3>fDr+%;Cr>{V^)a1cp*7VoX8L&^goGUi`*R1+a=!zNKfA4fKmQn+h zf8d4J(a|Gvu;ML=@St-%d;$>Nuf>4OAdmnBP+xVDw3`#AByGj6MxSjMJk{0xBzY4( z@dN8|Gy`HRk<>#XvrfGYOxXh9!TF$%%>Fn9jv%Y#yp>Z6IDXfII~BjT{`iDaJGEaZ=vJm(C%*(L(nQ$=4&bCTk+r_7p)wF-+%yV*D%>pcxQ z$*u{$@&CQ@Z5(xsb|b>K5z0ZLji+Kei)+N{;KQh2Q$%;AM{q!oy88^geU+8p`dwqP zd<5B^q{vp*o6c|xy8vmpBaQF!t0L9MO-Y{v|Mu(rC1_pF1k0AHDRq-{@gAKztHhup zHacOg5Epa;(7=<2E&qOBP8X8h+B=It=cOI2t|b~wu{AU_O!pOEJTs2{oj&rIJz9wF z&W{~4(jwX@KBxy1=1Pe$!lYZ!L1P4G&$%o%{gqkLK5|;YOk&}PPE^!jZFc)zIv;7; z%B0?hS3HU)Z3T~O{ElrZcvUGoCw#;3&~pbwGBIYLbga+ti9ldHNc>vjX9G&dbLXRw zQP%5V(`V1@f)9=}HBH>Uf7k(S&tCfaM1$2m|Bx^vy0!toD0sYH(q2mGzLl-5pq;nI zLJP_(=HSlLkT~i<9tM75MFN8S(aCF@CO$NN4`fw@3oyA5cu?TO#S>WE2M&Yro}MRy zpsJyPK?eL%WH6KEa*tEvJZ2%u1?nOXiJIe8}(z9Wp= zqg@cA|5U$lMEJ(@_%#;SB=}!yo9KsZ7no>-3a_l6PZ_zir?%e2&h-OSK((`dK#kL{ zi50rG=?w18L*b_HL9hH$(a(|`e^qLmv^cj3xGV;;5c2Ask>0&t6>c~?Wp1&oQfjB@ zB?gDFtW^q(NLCNF2#Tlwwbe}D*t%eFdQQF9*VDApCwTcKM+=)tyI*AU0n>2z&V$0n zO6jG_@?q09ccxc9WChfb@#~m0&br+{5Pf5LnW6jC7!8f+J{|yvnvq?-Jj>!B|8WTT zrrfrAtGB09pIm0c!oq5co6~-z^7V@E^yLnrM$nY&8yM`SdstIGvzP_WxgnQ1?@0cP zgxnrykB{qg)jQ275`>bx{*Qc_HT?1YJ*S2gSZ}NS*}8{w^-or>m!K8m)5zUSsSFi4 zFL;o4BHjF2c$j>Sfyw1J^wg1{Q$9!8e_w5Ve-tE9KYSZnPkU$jwjCDJWF-x`($lT^ zD`?{-ag`FUABA2(kR-(sQmcVGJ41wAGUe&Z(MAk+X9?ES~d|rQo zT+1&sM^IdH#V#O9w}##8mf>jcG_p2>Jw!J|KE=?0ZAUYF@v7cQ=-ZWu*ngD(ZVZHb z3S_lFslrjbN5;1q2Rd;+PQ~H6;&!k-j!sM~c5f@Ii{ZR6F9O54>p!{4&^x?k`xH$x zsmgCdctbtqWwS6}+YWho3TLexpA&=PcP_w9ssh zvZikn4DLVU$2a|2{`eR;6}$F)G}wKFYTlH?%4up#DzZrzsT_;y-UPw(e@j8W)Uq`c zR_+1fhzdFEbN0?(ajx-d%(;L#WDB|2HRG+fECe2At&tX_JEY3dvsVW#E|*apWtHwhUwDazoUIjkc6#~|j0Cyt42vC(g2Y!2++%2Li%2W`ceIEfaH8*SSh9UO z!xtKnk_d}KISGk{oq)r(PvRRNbDx8P;?glkPEv*%TTlQ7%zvph><8R^+VMR4O2{$A zCGq}!DJ&w{!k*OQ$gf;jl=2{i|0P(ts}12BI~M2h9#;g_-{Bz_g?E zJrbMXg7mQ{V34A!cz*gk(<02Dso%GT*H}fbn7;QYZ@-u_x~{GlENpEJG+^7ek6ksk zO?%o_zF8({h|8mBOJh}$q!G@I-9&Ty%Sjbw%{wEh^Ef$rAY2*E zLw1dBky}XETrTF){^!Y=&(~wBt88?dXaOOi_u=1d-+z)nr$hA8u}21UBe^B&R&=#k z*jTOi((F3y?odwf`?N*q5-Nx&PfTWu&wYL@;_%}S8BPaT942TLVeH}8gEHT2!TP*i zh3j++#(-wew%pn6rWWf8AG0Q+pxEC-H}T)8k&7FMUh7TlP;a+)nrC)WkU+qZ=mlwq z59}JJvxwo#tR@oOzY78chl#rwHG8V(&w&qPy(Ch}-joqI`r(_ljy2P!9szfoU+;P^ zHyYZhQh7n43@&TEOoL4t{*9`4cs_W}X<_N+z$d9;(VuT|ZcfaQI5Pz02wAqT<*2ue zbIX^Y6L&H(B^SW{fZZQLWf>V6CVYP$Ag~hpC$%nj9V2C8kw546N}oLa4>mm=L4yj{ z{ijgF!lnQWc&=>`ZzCr6?h1pBHM;lLw5u%%v(KDx$X)pPx z--U{aiKYuJ`A(>TjCCNS8Iw*muvyb060<*md=^lk%K>oWAU2c+Jt?w|_ps|(laU^f z;Yb3zpYq-EmSMx%trrZ04=xg(FVx!Wp11id?dyN(MAkxG{{Ec_fX24k%`~ZN8L726 zx!h3r0O=d-TVz*X-KaQ=N`Q=OKxg1(au9F?Ca$lo4JEbA%{~i3>j;CW!zy3m?jU8u zoh{DfPVg@NB+S5r@Jt`lR=>E2I@%F%pqi-mZmVd{DJH@DlST#q5Y+=a_?a?)tbO06 z#d97wf)rP$_GP!3&*>67;Dve_Z}gtj`)~%wgP|fpI(a8&=X$e%8-ZbwGnF(8*s}qV zrR52Uv{&=77^^sx9V!_8BLZ;U4{z8nw2kqvNT4M&?uQJHf0Wq--b-$LjNPsH`YOX~ zD%DMWx=Ruj*E}}&vB~ADZ;B$p^r6CFpnUfx^X`J?`0MR1%O5Z#7Dm+@57t_G+Ur2r zd!xMNaOW0;y24W6af~l->ZI_dRp?nfAz}%+5rw{lQZI!D$s7sswI#q5M?SH{On-@F zWhHdgQ#8Nr=-T6{rq5aV6+csla_s7!K)3A~vwYp_%|_b*n6OIwJ9<5z?F8ZK^3Mi z*3CvF6hZa#(QW^pbh?iJQx_L^)OCHYFdCw+HZ(Vrr`Oi>t z+kxnrD8A_auPE5MFIik=Bd5m3EWVzMzl)8q+z^qM`>86qz<}I&?AzM)iuG&}dHKoz zNIDC@sJgF<-yw${I)xb;MG-+lX%M896iI2MrF(>-6bVr}q>=7!5JV6V9!k191f=6V z{@y>p56;ZJ=j^@qTHk#-qOzlhAm;DiB0R~n>k^jNb^}?Ym{TxzaY#ST;rr%hIL_NA z$GzNoU=2P_RbE~)&@m)F2DNkN6crWqUk=k-akifQEoIam9sQ+s8#nx4nbnARLMh>+7AoI7z+_eIu-P`7FQ+4X@b zg+i?0g~N+hiKDS8$2>08B&y#}0`nVT=f%KSbCyO>3KRLp@nq7pi*n}5GuD@g1HAZ* zL$H+8_w_xC&bGU=K2y{y7SFB1uzf|=nyT`LW^K=@KD$JQgvf9G6`uXFua>(Y^+KcM zWPzC+Zhv;^+(PpJaZhp_=1DA*#VCge-3 zFWcxe@7gY((^ED#zvPVN{|sP2{lL9@=i?iSf1q4e{d=IEU zDg3X{b0>XJ%q-n^SW%6eDzkArQIje-WmzKL_m6}ehY8JjJRzkp%!LButMtEP0PO{l zO4Ib>pfI0xbsqcdNv5wSg~;6&ucB-(2mki9Kuj6=-p(22Y=ebzS7&P{);{kg z4<9A`Ox-u71ctZJdqs$`UrmO)yBN{SJzNcij&E)7ec>j&bN5c4??%-~KQMVKC5w5z z`CeoHXn4flerinqHfTmeOBlEFvm%vz{}vi+l1eUy)!8Jyv#~^yN6fQ8g>y}!exS|H zW$`(qvfkmyBLmV{o^@Z z#%nv>ICH=InTAf}+|4!F@2;fqU#^etR$s#iE(uU#scMh=;_j)=xsIp~fLyO>ZsIQ> z*-?o3z0;Imo-uRJ!yg&tPe9P#8yQ}3rkA}n}AD-YStpJE?N5P$%c-qs{qRq36S=o zY1q8MO1@9y*l;u0->(ij!|DQ?*cXAEJ8 zk~iCMWuRy$xSsm`yFcJ+YqGz%c3Np`bFBOR!ABOxHd2TTs$=e$lFW|~!QrpkWQEpH zK0=MHbnBG}k`AL}mHWbV4sY)-?_-w54*Lb_PBY)Te}=)NU^z#wBUMJf+1gqN;=sTFE7=VdQCqCUY(W<6Q$(Hu*}~=_1KM`x=cDgD+i$|;|L&Pm*}c%# zwg3%BVyybLsU?u7*TEe}G+Co+KQ%kzPCQW~HWCCW@i6r`gRbHy=s<)IC$ge80STgI za$r@eLzQ)!aNgO&nBX=cYPHS)_Waj}70PF_Pc69G(>cd?5Tmg!O^cy>%_nI_wGOHE z$;8|^^G;7PDv`WthvBrmVg1)@ZxHBcpQt?Pr|s;Zu7NYaQO^g?cy@`V_%eUxSMI^^ zD=IdBCs>dbpM>*=#?#OyR_2avrnn4`jdiDiO;^;jmS1b?lk-@fA&*vCQ9^(YDH&zQFz>#gMPo;%{@ zBD6mb%1*TY_@DvmnJ;f$B<5z|)x0=wy-2xsGM=6k`Sr-N<9dPaBSL4r-=Y^=7$Mn_k7 z@Atd`G%rw4FimD+n>KD2RCZS!XVN~N2;#+Y5|%R#`}exSeA0|@p8ZeuE4LA_)YG&a zARx&)i~nXuZo8*9LL~!N%+WYrdQY32YeVC8T6;uNbg%_IVivVzk3hM~ns1eO+z5P6 zOuQXd(5H*cy@*`D&f+FazW>Vf{JrsIrrWP?w6-I8>SU8OvI@hCnlh%k&y-;3V&hf` zmpQ)^3mVDe{MqSgG2ni*K)A@HK|@UQuU`;nI-h8OdeF&m+Eti`pMEx(!zt|Dk3(h( zs7POz)(gZgKLWMI(wer>gt|>dX@h^)rJt4$sw0-6QLdcMYerI74SuiH1wW%n1`W1K z^@kp!?M_uEoqBLoi5TG$K5`8`D>ljs1plLThgU5=6Ap=#yvjALv6*_!cu3eafffszZ7 zFA=*7g$3WuAB;WTYf7!Zs+pQ7EWJly^HWj&0Y2Oyl#OW=iJ87&<7#ue|;S0ZxVda_|)W3D5!`{6I6aZ4R~uUm?XM@ z3Ep@TZy-Y^el!%8Yol?%#$Rxs7~9@{XQPKDfY)VE{3IU~wM=(GRvl=T{xaW_AZ33+ z=t8RQGKDmz_{2N?B^-sVbm5dP8Dd=^F@5Jjm3eQWJYes6gTYjzka#G86C&g2Sy+U> z#Ja$Y2nRkaOj^s)3OQ(L_Wy34jJ|%N3O^(hq3Gcn%d(c1YM4cTMlUl{&#P+cZ;45p zN;~K9FSL=bwmtG^j%~2%kKk2q9qvGOxtRC04eGl(~l-H;^yEtyB299<@Sl6b-l2sQIc$and#1WruseuLEw_M*-$wMf1o56|#?eOBj?}!j- z`x#BAXJlt&U|)E(nD62cdYIH!#R&e%5sFqK=u&E1KQJsb`S_yU6c7KqZxsHqj>)4$+$ zU=VTqs`7Ac>POdWQC|!2ddhb=l;g5@>#p2+^$uQ(B&tM)Eay>Xwr;9{-aqAV7MiPh z5XL#D4E3MHaFYwKi+AH=L69O`p;4aLU%p++s=UJUkE!?QXI;@>*5>0^4@u|eokRo~ zGdH6LA3jDF?1io&U*_!kc_T7grT-O-&*jpAt+j?kLt9(Is@P3Sj#EaUJ{@{-;lUpTmf*O1hDl=Q>sf^E@9{04DMi}a*x%GO6|v7* zUGkzGrL<(vry5C4CYhG~b^iAqbWw$&ZcVO8E|mHG_p7JuDR0RM`$yRsr-_K(hl!bi zo-h;!u*UQT#jZXPI#%9*X*OwFY1odZa1g(aMr1RB#N0!Q_>}NXO$n3f%jIv-YI{~l zt#6#Qm)Dbn?aILid`;_%L!WqJ-3BtYy@Hwp31r|}+AFtqcE-T!;I#U^mJ6r0xA@%u zJ~RARkA)j}{}=XpcSy$OWM^dVi-{j{>fPWt?D@Ys$t#J zfia6Vh|hg~$buoGEXod&bt?0(|E;~3W6h8}SZ52*ijw%z&gJv_2gIZY#9KPkBo~PZ ztY6EINhYmmRnYlZO~eZmT>l2(+zWIk7TOg|GEIQZf7B#P79~0`L?IEuPu0b`I!&+X zYN*KrT^}g(<o$v_zOi$j%PQ1d-(Cp@{J zG@*{U_DL{Nq|h~T<^Vg(2Sb#Vlz@gF_URJm_Nf)4u!GW!-}tRDO?7eV*RNk^`s|5& zOiLh9XU^NYMqV6lJv+5a#kI2$M@L82DTgs?66VO&plLa{9*s$SdW-lukWOX$E9rab zEOhCkS#XdfCOlopV!!6PFZNSM^#NDPa}92`-hh4ZYo-?%5%EdhLcl&pLAyH9LxfCv zYb7lxqWo{VNXU(`j`$(e1V(U=L-|Ce!JK(ab0gzhbmWrAD7ChZp3U<-p30 z0=E_)b~BEg`;SuWO{7OB1JRa051J9FRHOmWxU(I`7Nz35*EpxS z`co2kuno)YlfM_E=+PpXRpdCX6?5@tXWgG!J1ML+xk&QE>n?lJe*H9hdD zL-QgvY(6Cx^h`_N;*FFH+&6M!W{ziH%6u=+wP`Neda0fk?@!wnil9!oKGwi(bA{LE zu|xl)6pG`Zp=vAJ*DHSlJy+K$CDo~vLJcf+wS!fQQ`a#hL0D}C+J%~+SakosWH%Oy z_*v{fS;Z^2zar_!zRn3bVy_eId)y6)zBE`V>0IabBfE%}CFLhe5$1ECYXFAeX=*Gg zfY#wTe0#B{KJC=`cXO;*pADFPr(E))w*MPkF2I z#D^}H3(txr?Wg4Ro=aN}W8Os<+$MysyJFQ}0Q;?xvQ3(cFomQ!ta`Wk9p0&zkd*o6 zZV25E3Uhl4l79n>;{xWLX1Q?^d3emnbO^;nYBp7~6W)5W!?%%_=6!p3dfLv!wW5K| zC86Lp95rBD+8oz#mdiN#Z~VBsvooq7eSj^@-57$X2y{s!+_%5udJ^3i)A`K-^Bfj) z=1;?{YefBo-+szy3vW67&teitm#_52he{Y6zdA4LB%S#TA>ggoxFB4rd}xoyi4p4+ zy@_dTJ;BG$v@$ggGlKqI>+%RnqyGitar?y{EqkmYwVBIq_q-9I>XXdcao>ZldHs)f>kNza z8-+}havu-|xNu=y_jMqBtb1pPr1Q22KUqrbD&dojsl57dxYO&e47Ql@#?A2M7OM;wsPrHDFeCfR6Q5XNr(b;H&i{EC>iA*|jbHnqmB_D+|X3 zjnZf3;rR$ZiX514h@aVFEA~7L`U#tT%7$pU7M20ysZXU}x=M!JZoSKVL3X{wVh_$N zTtA@712H~ZC^9HOEk988@82OHkq5aJlhTv$eLXIv2e-)}~q+@-6M9t3>6>t-Vy> zxTlvy9L|rg2czq>x+|yC$cZ0h)x+#kByJ;9J)BDSl6vA5acsd4J!k3ZFP|u&V=&ZT zVeiV=rRIhB$Yp-}estQ#jj1M0x=_Enjc7f6BrQ#W&oR|&Brg#Qz{3A+}YAKAtuYD zF_(st6@fW)qudG`Tp`!a+=iwLIjs7ImPtQ4^mO8z-APXWBO1f!REI9frM`wI>mQyU&6iMOwL(u)7^ zR2RwMyN9>W2CwC*cYL~?WG%%`(LS7`+*k@Q=0~?Yrk_vG&N9zp2L{A6m+=&lj+R96fh@r?pL4cs+u*Q86!xYbU%>?NOffwTcEwCleD^r*>&(P`QpAi-5l$) zBiw5J{bws_XIDkw?B;=~sx}oabA1+sj1jV$X$Fv{Q5(n(7+!WN8IfXH-V;C~O- z=`kuB|5{Mo=Pja(YM=-BG%`2YQRT`XrB@=C5>94(94~U>c{>0n@~%>zhL5R*S9WhC z$*a~=5}EIgFuRqMN;GNn2(n^hT*mymu>0vYKdwx{a!;jdPe&+py&-O|(MI|x$8F_Qg^HD4RDU-N+##n?reUjd1bXUhGTVdxyOnCruiB%9 z_(-LmbEk_qvqs5hQ9-~fc&w=|30wu^%FULPXpVw{nlsl@(qQiAk?u9&^#NOV5e?(Q zP#IchlLs_5JMHv^5>=Y~9T-}YJI`b&HfCD@`Rk2{TpCZyT!r7&%h`q$&Fa4v8i7?>7?O(IH=0Q}Pq!JQ z(Hg-ZiIfl}cIk9N=D&}9{iCmx3fQ7~NDf(SpgzE(Z`)hQ2#Of*!;x`mX}e0BlfOSR zj`#j$JcRM96AF4t+)Nf_{w30Wgk(!N9~8q573sEWxMk@8$?@~P3gk#4rKJ_tmHi0@ zY(xuew^0oF6|aATJ5chbevoHjXy}-?o2~;TE?!FwGD*eS@jkwSIMz1vmFS;v+67i>N!Wm2KGkWU+5MjVXBNS6O5I*so2 z4S4VCXlv(@Ni3J;TRfH>xp9fCSY$QQ(J<#G9EJABjeYLcyG@Oih0fmC#TNyx$#@QU zy;X)dBRT{ski5T2I55*cMAh*<#h;+6l8u9`k3RJ2s+h(@!0O?Ulv`;xS+k4OFnU9ANV1hj*##wR`n*rRPwlaf_WM0&kI0y8U z{TdmO1wxS__)2z;|NE*bLB2$8VvMM$wRw5R&9HCHQ4zQ`+Je6uk$gozR$?n1{X!#;b5G}IFQC+6?mEz0TnOT{qh4@PF#XtL>fuZfdLe)+8} zVV-}}D9g?Ncp3+B5~R>q)WI##7mldDBuJ-D&cY~e@ELUBG+^}<>%I_@L&}Uu8!`F2 z=z~yFu3pbA52lC2e)-Axgsd^HZe9e$hPp75&X5md!xnuM6Rq$C8OX)z}G?r4I^eS-|eSiG-HSz3_>;aS7cf?)J z)PrGG9WBJj^jznx_txFYS8Hl~_!kNL6IUj}D(LUlQJ>h}ZhTC9jN-hg!S$tBlm5p! zqBm=lR_|~@0FN?ftoy%CyZ$h8Dpl9m@8N$_Q;j~H&pUi35R~mK9@t>r?@fyfiX9aX zGTCBgoai%5bgjsm5mzy9SRzz7A7f_tAW=()*SMrezjjt^AARMnzPn9gQe&alC#cGJ z6pfU5%C~l@3hBxm$sdJkKo`yfcaThl|Mc*EhygA7J$2Mlfyp1EX=KE_AUm|_GC(fm zT3OJP5Ymad1#CE&zuk^<zE(2V!7kiz5FU+MBJgfL(Z%x(S*=s#1@oKxicp&q=M6r_) zI;48yMeg-Nqmk6z)-baI+1En_T|f!{@1L5@nD#5Lu&gHOyid#2f_|NpVIE>qIN1JN z>qT4NyZ8C5c9N&Rt8I6H_wx0I>u=?=!8eebgx8J2OsCBOTrBn{g2ej-A)|vkv8Wl8 z&zwpyj}>MY_vuFQlJS!U0&Ncls#p7LWXVwy2Vpf}gG`wLS4$A~dWdyd6MY*+ScFq_ zEZi7veI!oo6RA%Z91Yu9!rL;k@_K}{Gpg-iyrViiJX{3K$&JSVfNeodO-KtC!>dB|6B|4^+%IiCR=>s(ZhUA30@Pz{-m`FN z^9w$#eXp?XC`?xXE3OPcPh4I5?BzH%UI`ta+m8*;RD{DS(<@IOR;sL$I-%V}cDz6@U6?&_t(RSPjvV0s_sq*}j zFT3gtf|IuS`!1a>=_`&aF}Z-Ce{y}NubRMgx}8ZV(;IkUQIVEtbnSJzKxA2lp2auk z^`u-ZDFublzhGwltL9h;Jaj@*TF~YGbzaILHZohoAD8deF3gEmuBPNp+sAYbxb5H* zM5i6=f%eA<@zX)`2bX_(Xt{P$++WQG$+tHfY!7BeEGp~(WJiiXg{FeZ( zIA5BZKiup%28;-ekB2Tlq9gYYfS z)a1|?FFL9!>=&y_mX~$vl_)!n=9((O8Il_ZD2h_X-DyBiYFr7UwU}Tg^|kmL@5*atY8W@FL7d(vn0aXR_ovh(aKKvsKxy0XdPTUgN)FU zMkjZ`jNqap=$f#v;**=()~CN)!V@bN7L9%ARjh<1kpYEwnc0V)>5oL$SnA}2J}yb@ zSWF!kA}XEPvEYe2^w?{(nAtaKk%J=mOioa;$g4ml?w_rKeQ&MV?LJdeOJ!(bqmLs= zF95FK+3%P7&C^HY*ryYD{q+l$s;i#9GY)X|-E>*&>snlqQy_uhx0OOEsGxvw>9lo( z_|4Kjnz8_Y?DyDf4_DVW5hboG*l@?2*7~AC6DGDfRDi?tF5;l@6_Bics%}b(w64H(^%M?jZN>+Ep+yj25c{t$yK!>csf@{bTiMh!J$-`)(czJ= z7HjcZrYUUMwna_ZP{^yxsjD?0<5tu<&XxGkJ*}x@)Q?rKea(rv>><*B#F*K;fd{EW z)^Mk>bQx>@R-$57yqYwCe{IC*?$eyww>Tu@6+Q9dcp>@5P!&4 zWGz>{QoX5-7t)o|BC|zp2XRPzkbx(zxIjYZPFLE9@Cq-ZF6%f89#FPtx3tXi+`T*5 zOjsjq=9^^mve4DAFE1~Hx`SY^pJ~py;NnmM9bq5@dbH&z!sQy=C70Fh6%~L$SK-MJ zGtT_^_NhqMdm=(^x6oi$H#cBf@XH6De9wA`P1KKBKw0RSo)5^yA6JsDySyYZed!$i z_dQid+};#Ib@5GEd>1pzWnsaOTD#elmcxO^#owa%#=w4Up!CXjmR`$3C#+>Sf!ifhB_EmnO_E@MLnwGR%BZ#*=L+`|sc6-Uc_fV`==KR;T74 zi-uke`1Vy8DfoA;c^hO7W43b{ox^TRk%K}CcpUFLkO_b=L>O#aa&7xzC@nQD*{cJ- zFB%S?spYpldCwi^_5i*7_tw}b`AEMf&yC*J_37J}!uk)U-?-6s$<67*QOQ_h6GR9h zhdC^##70vU(wY!_es6+ zGBhty=sB!>BG3I+GBhL1w-dAg=gf>Dub5r7%Trq~X7)5dVQaw9{?krr8}&?^I&x_O z3>-~PPFhY3n=TKO|LpEM_)!JkSVH4ir+kpI$XYXO>|*@QKr7iRr~B%7H)r-i2y7az zQ`f7{yNSz+-^5~jxj{#~s$XkYeSvy=LH^}->cHcLae!+uNWexZf2Phs?j|45;4p9? z&Top2)2pjv9{}R}m!w?sz8?Xl@L*!9F0*XDFcqr@ms!*-JRk|W|Hj_VO;}J8>zhoN zWpzfw0};rG3v(&F=1Y&M){Az2gn8U!5YfaX9QDm~1L()%e||dp-4T4%A(xe1CD?4a z^ZRJv)|5r%m#%d=(~$P5m--|BEPV$65klbM;Sma)m@(5Yap(Ck(Oe>HJ21{}2D!sE zGAIR(%J>9(d7VJEFrwXa80JbK^5wB7P0DtXV(1^>Wi;%~z!G9_#`Q$QBTgJ|l3X*by zodp;Ot{z8E*PAK49xeE+cKh?e?TbCBb@uU7*QnRiA6~F@wDut%Y|uiUkLTM1$kFLQ zgO%Ixk_!ruftMB)DP8Xk2Bcko;|riq7bzDi;C|-~Yz{Vgson+Ps^l@&4Uds$r&s`) ziOuUx-PWY0zhnA@K%wf)LR=<8*SODP8g-w48vBBGgNf#ri{1MPsf7&L^vwrY=LfIj zavv=GCe9>lt5j^zuW`@@qCNi9^(W0R86I&d?a@FnzQn+?6Ny48OSseLH(9etbNkHus$I)QLI++HiQR#(5=R}}EM z&l_hYAk$o%mB#C21Bz%O7o;WWKUN%n{>)4B*OsgbXX@i_R;^bjL<*VD?;2l8LKtG~ zVBF`wJ*?jhq=H*-`=@@*vMTo*lgC|DhP$2Ow}Z>f3VWA~E1@QWSH|v@$=~3cZ|$Yb zw{}CKuc;zk(UjqG!)s|CMi&5STlgM@9err}ltLHEo;w9bsDNb*Kxej7c2TH;wYAr*j$~BO^&$ zUau$>Hy8gb>_-G~la(BAFl#sBGu~SE*m&!7CW_ChPpc*CfZL(Ba05HNgITtcyKnnZ4S6xN`I%Mq-6G0t6;7=yP)6=U_ig-N_G86O&FX_^|Yhb1jr~g;MGaH zbFa1O1gtl{Q)6$NUF;}|Hi8DRL9lu!a)})~Ne)_TLL=Vbp#!9z`Y$K$-7SE4=Tm6U zT(8I$QoZnI>YTGGY&6Bq8ZDi>8gnRRR(S}2){-|-XJSPw&lwYm>i!!UH$4lR{!nap zT$K2uYx8xgM$(Ckpz8-(+HO(j<{OyF)>!da2+7th5EM{pX3RYE$Wxa-V23V97Atcb z6|d-6v1=&XQ&s2!hdvttyZQd01*>g8f-ra+d+p~|!`W(baUb{$5&XGpukY8|GXhFo zM&*E*^5fI_Ac0RB8-(l&HuI(2gthilUPr$a8t*k8QM3SCM-KRztHN;s{%{IqCoo66 zZ?B0g=#iXHnG2cL8K&s$%z%FQeT4ZDKPT9iGn$;9&MuxSGXh#{Q~Jp^Qpu($r&>H+ zITw{nvsQ6NESsS7G%E}(-2mYp^#@t=CVgCbW)33T->Immh%=6!)|J?Z(S+xps&9N0 z781(*`c?67G@X#Fh}r4JwZJpEYg)&Ln}fuZ<>u!0 zr+UmLHY^=a$$(K@R(Xv|*hbN~?9&4Z>1HcT^!+to$1uXhjRWzcTlg~Ob;4~+1rJc> z_-no-T~#L&PNh~;LLf5rd2(nd44`1PnQwktMo=qsWZa_j4UA~Su~07Y#FZIff(@bK z--B+iKBNBaPFE4jkYImWrEY#%zQ`W(c}Ca0uqPwHMLT6-W&&o{g0*&&1>UYENA|>p zz6}c0h+WCa@9(gmr}+}g$X7DM>@_$rU~H|37|!nFcU2zA;1f}G;Z!T?HE{k$op?AP zDM|DG8<373uDi8~hUVIA5)jdDviP57oB@vTApH#LL*j@qMjT7OSBrl< zEf2C^nyxo2mbLd(GeJ3@XuGpgG|u4xka$LnY(hh{nqS3hC<4^z`$C_|`yBIJZFP2cHrt;w&;U zGg)1`6RjTEj+gZM?ziLr=gjn^Wq{%g@8*-n?8wNH*v+&{FQY>f%*}S3W}-%%!PW*J z{S9&42)qz+zxu-A0fKcCt>k}%E-`=MCPdxNgEDG z0148N*iJk#9UdAoah$G-0fe*}f|#q5CanR{C~1}J)P$9j7TksXLkhW0BZTw}$sbR+ z_@5ZMGftj70R6d~UXzG#0a=17@B;+fPw9xTc>7@NrxJu6)0{VL@;GYjCSMB)31Q5U zto-Zoff`N&sUQ)RL?(V}vI=CEQ3giFPP#Myhm376;qM0@liT&GN4WT-8>3PP6K!sh zFu}htuu|?;FS|7}nhgG3jIg$~9R-b&HD|0Mb5xIqvGRq?QN~~5M`2o{%u_YBQd&Qh zDt(LkIbbZTbq@@{X^tfew!+!CW-6a#L4KD*JeIw3)O<(I10X%}9 znYn6b-|N4HOyv=2PxSLw|$bK`r>5VSrTnaO5%M2B;AhO6MI! z_o@cPwu+qe^b%i93DK+V@0D;^C*`Ic1;-wNwDc5MhLjzvl(Q_s#y|8J_T+hVuX)N! zpQOr+---BuGG7Z~cXM-C;jNTAwkJMd^6=q|--n>vqHF26`xbD;Y?SUu{+Dj?SP6iF z$h&vapwJ#fSZRT3%>d18}2cU-Ddh9L&s+KI~TQ$U) zun97(Az|bW+VHwnP_B9b29aS+Fb_?zKpiQEK`;Xx)EN)`!rb7}pE|GgSD_a7n)X?l z0Qji<8Fx16lY>|i@YFYW2RiT8uT5@^mkt)aC=YxT$#$!Uy<%ID3Ow~=W0uVzxseF$ zjFc?JFUYHTZ3PGNilH>j_sdcOr~OhHt?)tjShyzA@JgPN5aZmLC4qo;DbUQGY~f+E~`5P#IcXJ?%GFeD*Ioahj+lbxL%ip7Zd7dg3GP zyWo7*rz|YE&Bx1a(#T8`ZvJu9u^aBfKZ)?obc~H1B@XsGSAqDG?=V&K5JT?X_9iGb3YuQwq<9&`a7ih% zm~!}!mdq6;Lm{0R(C>nM#K|%?pal0SAL5SA=r7|3g|c!5r?sSr;)YuhTQsCh3H)a7-d`#Ig%@1evZeBkH!c$<42p5)ONy3qaZFIx@nU>WO1Up$}i&EM_H*N;-;E;N2gzZdX_euzAFzr~i`yXzVY2mC;CsRwN*MekhvKQdmj5HFP9BPUh~jjp!~Ec@`6 zLLXV6=`t_9m|vbDJy&DjXs<^z?&ep!^lp3YI?^}$(aya;7*0bcT>7x815YGGd30rWG5Mo_` zyNio}Tu^oKhlhefh<|L*N*H5P3qNT0U5vKzF4aM0*noA(y9B8PMqe_)q*ylS3Ntew z`~#RISf%>aG@Q&{V5ov`vrV!rh>(hJm*FmewLQw=j3uqoJL}dgA9B^gp$=!J1}_&s z>%&#=z+Yg|**e~JRAamHvmJsgkaaP?lH9`6plI(xA%td)l%I>rrR(2TK&O&{>W_|~ zQyPNMkfNNlmarqIVQMP(ohz-mUE%>RP;%LK&gZCb0)3pV=j1(<&>=dgYXor6ZOl)g zH>$N3PlAJWcxn3dHH&W(hGS%Zl)PD{qbEbJ-x5a17BIZZ(xC9>kYZ7eq)p8p?z^V zM&277-vm|b_nx-Rw91>^b(JALY9M0OXIy5N5$!_czXyK%kFfB0Q_1)*4=<#+$ECEe zZj0B&0q;}x(J^No2FCt4iP+t$ncqLk0Zuql$;*8wL55NJNBeWsHTGtvKf8uF;*DOH z80yDQHq{#~r1afJcUhY-qC-)5I3+SpcL}Hg3;i3yy2LzM_ z)t*fzLww|GOerojB#btIF#l)I4X`=^y-Vv3dfin) z?39m}S1CQ99ru3=KQ{@uQV{ez^R13odU2{zB8%VG>8(;;J;{2m3^HVFKV@OOxcfd3 z*p499)*b+t*}7_lN-$31vF$e#Xe@rJ{wV)A(i6MC<~Mc@4iA!&lc}e!|2va{qXY<0 zXC?J {>X@)YQKDTy!>>7cTk2rF@V8B}FnY9359 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 Date: Mon, 31 Aug 2026 17:35:00 -0400 Subject: [PATCH 16/29] fix(store): gate the git-pull update path (#508) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(store): gate the git-pull update path `install_plugin` gates every route that re-downloads, `_reinstall_with_rollback` included. `update_plugin` has one branch that re-downloads nothing: a git checkout pulls in place, installs dependencies, and returns True. A pull could therefore deliver a manifest flooring above this core and nothing would notice until the plugin failed to load — which surfaces as one line in the journal and a display that silently stopped appearing. Checked after the pull rather than before it, for the same reason `_install_plugin_impl` checks after the download: the registry carries no compatibility field, so the incoming floor is only knowable once the new commit is on disk. Undone with `git reset --hard` to the pre-pull commit rather than by removing the directory. This is a live checkout, the old commit is still in the object store, and the reset leaves the user on the exact version they were already running — the same promise `_reinstall_with_rollback` makes, reached by the means this path actually has, with no window where the plugin directory does not exist. An unreadable manifest allows: it is not evidence of a floor. Scope, stated plainly: monorepo plugins install as archives and update through `_reinstall_with_rollback`, so they were already gated. Only registry entries with no `plugin_path` reach this branch. It is closed anyway because the sunset rule in the plugins repo's `08-shared-sports-code.md` names, as condition 3, that the core enforces the floor "at install/update time" — and B6 rests on that being true rather than merely written down. `install_from_url` is still ungated; the tests say so rather than letting the next reader assume otherwise. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014RRtqXDCnvnY6EQwhT5CV9 * fix(store): do not pull what the gate cannot un-pull Review of the gate found a data-loss path it had introduced, plus two smaller scope errors. All three from CodeRabbit on #508. **The stash failure was load-bearing and was not treated as one.** update_plugin stashes local changes before pulling; when that stash failed or timed out it logged a warning and pulled anyway. That was harmless while nothing ever undid a pull. It is not harmless now: the gate's rollback is `git reset --hard`, which discards uncommitted tracked edits -- exactly the edits the stash existed to protect. A pull does not refuse on a dirty tree as long as the incoming commit touches other files, so the sequence completed silently: pull succeeds, gate refuses, reset takes the user's work with it. update_plugin now returns before pulling unless the tree was already clean or was successfully stashed. Refusing costs an update in a case that had already gone wrong; the alternative costs data. That also makes `--hard` safe by construction in _gate_pulled_commit, and its comment now says so rather than observing it in passing. Pinned by test_a_failed_stash_stops_the_update_before_pulling, which writes a local edit, forces the stash to fail, and asserts both that HEAD did not move and that the edit is still on disk. Verified it bites: with the new guard removed the file comes back as `class P: pass`, the edit gone. **_HAS_GIT could take the module down instead of skipping it.** With no git on PATH, subprocess.run raises FileNotFoundError, and this runs at import time -- before skipif can act, so the whole file errors rather than skipping. Now catches OSError. **The doc overclaimed the gate's reach.** It said the floor is enforced on "every route that installs or updates" while the same passage notes install_from_url is ungated. Both spots now scope the claim to registry-managed installs and the two supported update paths, and name the sideload exception. Full suite 3720 passed, 6 skipped. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014RRtqXDCnvnY6EQwhT5CV9 --------- Co-authored-by: Claude Opus 5 (1M context) --- docs/SPORTS_UNIFICATION.md | 16 +- src/plugin_system/store_manager.py | 114 ++++++++++++ test/test_plugin_compatibility_gate.py | 233 ++++++++++++++++++++++++- 3 files changed, 359 insertions(+), 4 deletions(-) diff --git a/docs/SPORTS_UNIFICATION.md b/docs/SPORTS_UNIFICATION.md index 4e731278..4ef30e94 100644 --- a/docs/SPORTS_UNIFICATION.md +++ b/docs/SPORTS_UNIFICATION.md @@ -27,7 +27,7 @@ These are independent concerns. Conflating them is what produces god classes. | Plugin loads on a core that predates a module | Guarded import with a bundled fallback (`try: from src.X import Y / except ModuleNotFoundError: from y import Y`) | | Plugin loads on a core that predates a *method* | Capability probing — `hasattr(SportsCore, "_detect_stale_games")` — never a version comparison. The loader's compat check is advisory-only (it logs and continues), so probing is the real protection. | | Core changes never break a plugin's rendering | The **view-model contract**: `_extract_game_details_common` returns a dict whose `GUARANTEED_KEYS` are frozen by `test/test_skin_system.py::TestViewModelContract`. Keys may be added, never renamed or removed. | -| A plugin can drop its bundled copy safely | The **sunset rule**: its manifest must floor `ledmatrix_min_version` at the first core release shipping the module (recorded in `CHANGELOG.md`) — *necessary but not sufficient*. Nothing enforces that floor today, so the copy also waits for the B6 gate below. | +| A plugin can drop its bundled copy safely | The **sunset rule**: its manifest must floor `ledmatrix_min_version` at the first core release shipping the module (recorded in `CHANGELOG.md`) — *necessary but not sufficient*. The store enforces that floor on every registry-managed install and on both supported update paths (sideloading via `install_from_url` is not gated), but a floor cannot reach a user who never updates, so the copy also waits for the B6 gate below. | The core API is **additive-only**. A method the plugins call is never removed or given a new required parameter; new behavior arrives as new methods with @@ -234,6 +234,20 @@ a floor can be trusted against, and today it is not: compares the plugin's manifest version against the registry's `latest_version` and nothing else. + *Fixed, in two parts.* `install_plugin` gained the gate in #431/#433, which + covers every registry-managed install and, through `_reinstall_with_rollback`, + the update path that re-downloads. + `update_plugin`'s git branch pulls in place and re-downloads nothing, so it + stayed ungated until `_gate_pulled_commit` closed it — checked after the pull + (the registry carries no floor field, so the incoming floor is unknowable + before it) and undone with `git reset --hard` to the pre-pull commit. That + route is rare in practice, since monorepo plugins install as archives; it was + closed because the sunset rule in the plugins repo's + `08-shared-sports-code.md` states as **condition 3** that the core enforces + the floor "at install/update time", and B6 rests on that being true rather + than merely written down. `install_from_url` — sideloading a plugin from a + URL — is still ungated. + So B4 is: tag and release 3.2.0; make the tag, the release, and `__version__` agree, and keep them agreeing; reconsider the `< 2.0.0` skip; migrate manifests from `ledmatrix_min` to `ledmatrix_min_version`; and add the install/update diff --git a/src/plugin_system/store_manager.py b/src/plugin_system/store_manager.py index 5cf31cf8..623270e3 100644 --- a/src/plugin_system/store_manager.py +++ b/src/plugin_system/store_manager.py @@ -2583,6 +2583,85 @@ class PluginStoreManager: self.logger.error(f"Error uninstalling plugin {plugin_id}: {e}") return False + def _gate_pulled_commit(self, plugin_id: str, plugin_path: Path, + previous_sha: Optional[str]) -> bool: + """Apply the compatibility gate to a commit that arrived via git pull. + + Every other route into an installed plugin goes through + ``install_plugin``, which gates in ``_install_plugin_impl``. This one + did not: a ``git pull`` could deliver a manifest flooring above this + core and nothing would notice until the plugin failed to load, which + surfaces as one line in the journal and a scoreboard that silently + stopped appearing. + + Checked after the pull rather than before it, for the same reason + ``_install_plugin_impl`` checks after the download: the registry + carries no compatibility field, so the incoming floor is only knowable + once the new commit is on disk. + + Undone with ``git reset --hard`` rather than by removing the directory. + This is a live checkout, the previous commit is still in the object + store, and the reset leaves the user on the exact version they were + already running -- the same promise ``_reinstall_with_rollback`` makes, + reached by the means this path actually has. It is also the gentler + option: no window in which the plugin directory does not exist, and no + ``.standalone-backup-`` debris if the process dies mid-way. + + A manifest that cannot be read is not evidence of incompatibility, so + it allows. ``compatibility.check`` refuses only on evidence for the + same reason: a wrong refusal breaks a working install, while a wrong + allowance degrades to exactly the behaviour this path had before the + gate existed. + """ + manifest_path = plugin_path / "manifest.json" + try: + with open(manifest_path, 'r', encoding='utf-8') as mf: + manifest = json.load(mf) + except (OSError, ValueError) as e: + self.logger.warning( + "Could not read %s after updating %s (%s); allowing the " + "update, as an unreadable manifest declares no floor", + manifest_path, plugin_id, e) + return True + + from src import __version__ as core_version + from src.plugin_system import compatibility + + compatible, reason = compatibility.check(manifest, core_version) + if compatible: + return True + + self.logger.error("Refusing the update to %s: %s", plugin_id, reason) + + if not previous_sha: + self.logger.error( + "Cannot roll %s back: the commit it was on before the pull is " + "unknown. It is now on a version this core cannot run — " + "reinstall it from the plugin store.", plugin_id) + return False + + # Safe by construction: update_plugin returns before pulling unless the + # tree was clean or successfully stashed, so there are no uncommitted + # tracked edits for --hard to discard. The stash is not popped on the + # success path either, so the reset leaves the working tree exactly + # where a successful pull would have. Say "commit", not "changes". + reset = subprocess.run( + ['git', '-C', str(plugin_path), 'reset', '--hard', previous_sha], + capture_output=True, text=True, timeout=60, check=False) + if reset.returncode != 0: + self.logger.error( + "CRITICAL: could not roll %s back to commit %s: %s. It is left " + "on a version this core cannot run; " + "`git -C %s reset --hard %s` restores it.", + plugin_id, previous_sha[:7], + (reset.stderr or reset.stdout or '').strip(), + plugin_path, previous_sha) + else: + self.logger.info( + "Rolled %s back to commit %s; it stays on the version it was " + "already running.", plugin_id, previous_sha[:7]) + return False + def _reinstall_with_rollback(self, plugin_id: str, plugin_path: Path) -> bool: """Replace an installed plugin with a fresh install, atomically. @@ -2860,6 +2939,8 @@ class PluginStoreManager: status_result = type('obj', (object,), {'stdout': '', 'stderr': 'Status check timed out'})() stash_info = "" + # Whether the pull can be undone without destroying work. + tree_is_recoverable = not has_changes if has_changes: self.logger.info(f"Stashing local changes in {plugin_id} before update") try: @@ -2873,12 +2954,37 @@ class PluginStoreManager: ) if stash_result.returncode == 0: stash_info = " (local changes were stashed)" + tree_is_recoverable = True self.logger.info(f"Stashed local changes (including untracked files) for {plugin_id}") else: self.logger.warning(f"Failed to stash local changes for {plugin_id}: {stash_result.stderr}") except subprocess.TimeoutExpired: self.logger.warning(f"Stash operation timed out for {plugin_id}, proceeding with pull") + # Do not pull what cannot be un-pulled. + # + # The compatibility gate below can refuse the commit this + # pull brings down, and its only way back is `git reset + # --hard`, which discards uncommitted tracked edits. Those + # edits are exactly what the stash above exists to protect, + # so a stash that failed or timed out leaves the rollback + # unable to run without destroying them. + # + # A pull does not necessarily refuse on a dirty tree -- git + # merges happily as long as the incoming commit touches + # different files -- so without this the update would + # succeed, the gate would refuse, and the reset would take + # the user's work with it. Refusing here costs an update in + # a case that already went wrong; the alternative costs + # data. + if not tree_is_recoverable: + self.logger.error( + "Refusing to update %s: it has local changes that could " + "not be stashed, and an incompatible update could then " + "only be rolled back by discarding them. Commit or stash " + "them by hand, then update.", plugin_id) + return False + # Pull from the determined remote branch self.logger.info(f"Pulling from origin/{remote_pull_branch} for {plugin_id}...") pull_result = subprocess.run( @@ -2901,6 +3007,14 @@ class PluginStoreManager: elif updated_sha: self.logger.info(f"Plugin {plugin_id} updated to commit {updated_sha[:7]}{stash_info}") + # The install gate, at the only point on this path where + # it can be answered. Every other route in goes through + # install_plugin, which gates in _install_plugin_impl; this + # one did not, so a pull could deliver a manifest flooring + # above this core and nothing would notice. + if not self._gate_pulled_commit(plugin_id, plugin_path, local_sha): + return False + self._install_dependencies(plugin_path) return True diff --git a/test/test_plugin_compatibility_gate.py b/test/test_plugin_compatibility_gate.py index 26834ffa..91a84b8e 100644 --- a/test/test_plugin_compatibility_gate.py +++ b/test/test_plugin_compatibility_gate.py @@ -19,6 +19,7 @@ The rules being pinned here, in priority order: """ import json +import subprocess from pathlib import Path from unittest.mock import MagicMock @@ -146,9 +147,17 @@ def store(tmp_path, monkeypatch): class TestInstallGate: - """`install_plugin` is the chokepoint: `_reinstall_with_rollback` calls it, - so gating there covers updates too, and a refused update restores the - version the user already had.""" + """`install_plugin` is the chokepoint for every route that re-downloads: + `_reinstall_with_rollback` calls it, so a refused update restores the + version the user already had. + + It is not the *only* route in, and saying so here once is cheaper than + rediscovering it. `update_plugin` also has a git branch that pulls in + place and never re-downloads; that one is gated separately by + `_gate_pulled_commit` and pinned in `TestGitPullGate` below. A third + route, `install_from_url`, is still ungated — sideloading from a URL + checks required fields but not the floor. + """ def _install_with_manifest(self, store, manifest, core_version, monkeypatch): mgr, plugins_dir = store @@ -208,6 +217,224 @@ class TestInstallGate: assert (path / "manifest.json").exists() +# -------------------------------------------------------------------------- +# The git-pull update path — the one route that does not re-download +# -------------------------------------------------------------------------- + +def _git_available() -> bool: + # OSError, not just a non-zero exit: with no git on PATH subprocess raises + # FileNotFoundError, and this runs at import time -- before skipif can act, + # so the whole module would error out instead of skipping. + try: + return subprocess.run( + ['git', '--version'], capture_output=True).returncode == 0 + except OSError: + return False + + +_HAS_GIT = _git_available() + + +def _git(*args, cwd): + return subprocess.run(['git', *args], cwd=str(cwd), + capture_output=True, text=True, check=True) + + +@pytest.mark.skipif(not _HAS_GIT, reason='git not available') +class TestGitPullGate: + """A plugin installed as a git checkout updates by pulling in place, so it + never passes through `install_plugin` and was never gated. + + Real git repositories rather than mocks, because the claim under test is + that `git reset --hard` puts the checkout back — a mock of git would only + prove the call was made, which is the easy half. + + Only the handful of registry entries with no `plugin_path` reach this path + in practice; monorepo plugins install as archives and update through + `_reinstall_with_rollback`. It is gated anyway because the sunset rule in + `docs/plugin-development/08-shared-sports-code.md` states the core enforces + the floor "at install/update time", and a precondition that is documented + but not true is worse than one that is merely missing. + """ + + @pytest.fixture + def checkout(self, tmp_path, monkeypatch): + """An origin repo holding a plugin, and a clone of it installed as + `plugin-repos/gitplug`, with the store pointed at it.""" + from src.plugin_system.store_manager import PluginStoreManager + + origin = tmp_path / 'origin' + origin.mkdir() + _git('init', '--initial-branch=main', '--bare', cwd=origin) + + seed = tmp_path / 'seed' + _git('clone', str(origin), str(seed), cwd=tmp_path) + _git('config', 'user.email', 'test@example.com', cwd=seed) + _git('config', 'user.name', 'Test', cwd=seed) + (seed / 'manifest.json').write_text(json.dumps({ + "id": "gitplug", "name": "Git Plug", "class_name": "P", + "display_modes": ["a"], "min_ledmatrix_version": "2.0.0", + }), encoding='utf-8') + (seed / 'manager.py').write_text("class P: pass\n", encoding='utf-8') + _git('add', '-A', cwd=seed) + _git('commit', '-m', 'initial', cwd=seed) + _git('push', '-u', 'origin', 'main', cwd=seed) + + plugins_dir = tmp_path / 'plugin-repos' + plugins_dir.mkdir() + work = plugins_dir / 'gitplug' + _git('clone', str(origin), str(work), cwd=tmp_path) + _git('config', 'user.email', 'test@example.com', cwd=work) + _git('config', 'user.name', 'Test', cwd=work) + + mgr = PluginStoreManager(plugins_dir=str(plugins_dir)) + mgr.logger = MagicMock() + # No registry entry, so update_plugin takes the plain-pull branch + # rather than the remote-mismatch or already-current shortcuts. + monkeypatch.setattr(mgr, 'fetch_registry', lambda *a, **k: None) + monkeypatch.setattr(mgr, 'get_plugin_info', lambda *a, **k: None) + monkeypatch.setattr(mgr, '_install_dependencies', lambda *a, **k: True) + return mgr, seed, work + + def _push(self, seed, manifest_text): + (seed / 'manifest.json').write_text(manifest_text, encoding='utf-8') + _git('add', '-A', cwd=seed) + _git('commit', '-m', 'update', cwd=seed) + _git('push', 'origin', 'main', cwd=seed) + + def _head(self, work): + return _git('rev-parse', 'HEAD', cwd=work).stdout.strip() + + def test_refuses_a_pulled_commit_that_needs_a_newer_core( + self, checkout, monkeypatch): + mgr, seed, work = checkout + before = self._head(work) + self._push(seed, json.dumps({ + "id": "gitplug", "name": "Git Plug", "class_name": "P", + "display_modes": ["a"], "min_ledmatrix_version": "9.9.9", + })) + + import src + monkeypatch.setattr(src, '__version__', '3.2.0') + assert mgr.update_plugin('gitplug') is False + + assert self._head(work) == before, ( + "a refused update must leave the checkout on the commit it was " + "already running, not on the one it cannot load") + floor = json.loads((work / 'manifest.json').read_text()) + assert floor['min_ledmatrix_version'] == '2.0.0', ( + "the reset must restore the working tree, not just the ref") + + def test_allows_a_pulled_commit_the_core_can_run( + self, checkout, monkeypatch): + """The guard against over-refusing. A gate that refuses everything + passes the test above and breaks every update.""" + mgr, seed, work = checkout + before = self._head(work) + self._push(seed, json.dumps({ + "id": "gitplug", "name": "Git Plug", "class_name": "P", + "display_modes": ["a"], "min_ledmatrix_version": "3.0.0", + })) + + import src + monkeypatch.setattr(src, '__version__', '3.2.0') + assert mgr.update_plugin('gitplug') is True + assert self._head(work) != before + + def test_an_unreadable_manifest_after_pull_is_not_a_refusal( + self, checkout, monkeypatch): + """Rule 1 of this file — refuse only on evidence. A manifest that + will not parse declares no floor, so it is not evidence of anything.""" + mgr, seed, work = checkout + self._push(seed, '{ this is not json') + + import src + monkeypatch.setattr(src, '__version__', '3.2.0') + assert mgr.update_plugin('gitplug') is True + + def test_an_untrustworthy_core_does_not_block_a_2_0_0_floor_on_pull( + self, checkout, monkeypatch): + """The same regression guard as + `test_untrustworthy_core_does_not_block_installs`, because this path + now shares that rule: a v3.1.0 release reports 1.0.0, and nearly every + published manifest floors at 2.0.0.""" + mgr, seed, work = checkout + before = self._head(work) + self._push(seed, json.dumps({ + "id": "gitplug", "name": "Git Plug", "class_name": "P", + "display_modes": ["a"], "min_ledmatrix_version": "2.0.0", + "description": "changed", + })) + + import src + monkeypatch.setattr(src, '__version__', '1.0.0') + assert mgr.update_plugin('gitplug') is True + assert self._head(work) != before + + def test_a_failed_stash_stops_the_update_before_pulling( + self, checkout, monkeypatch): + """Uncommitted work is not collateral for the gate. + + The gate's only rollback is `git reset --hard`, which discards + uncommitted tracked edits. update_plugin stashes them first -- but when + that stash fails it used to pull anyway, and a pull touching different + files succeeds on a dirty tree. Refuse, refuse the rollback's rollback, + and the user's edits go with it. + """ + mgr, seed, work = checkout + before = self._head(work) + (work / 'manager.py').write_text( + "class P:\n MINE = 'do not lose this'\n", encoding='utf-8') + self._push(seed, json.dumps({ + "id": "gitplug", "name": "Git Plug", "class_name": "P", + "display_modes": ["a"], "min_ledmatrix_version": "9.9.9", + })) + + real_run = subprocess.run + + def fail_stash(cmd, *a, **k): + if isinstance(cmd, list) and 'stash' in cmd: + return subprocess.CompletedProcess(cmd, 1, '', 'stash boom') + return real_run(cmd, *a, **k) + + import src + monkeypatch.setattr(src, '__version__', '3.2.0') + monkeypatch.setattr( + 'src.plugin_system.store_manager.subprocess.run', fail_stash) + assert mgr.update_plugin('gitplug') is False + + assert self._head(work) == before, "must not pull what it cannot undo" + assert 'do not lose this' in (work / 'manager.py').read_text(), ( + "the local edit the stash failed to save must still be there") + + def test_rollback_reports_the_recovery_command_when_git_fails( + self, checkout, monkeypatch): + """If the reset itself fails the user is left on a version that cannot + load, so the log line has to carry the command that fixes it — + it is the only thing standing between them and a manual reinstall.""" + mgr, seed, work = checkout + self._push(seed, json.dumps({ + "id": "gitplug", "name": "Git Plug", "class_name": "P", + "display_modes": ["a"], "min_ledmatrix_version": "9.9.9", + })) + + real_run = subprocess.run + + def fail_reset(cmd, *a, **k): + if isinstance(cmd, list) and 'reset' in cmd: + return subprocess.CompletedProcess(cmd, 1, '', 'reset boom') + return real_run(cmd, *a, **k) + + import src + monkeypatch.setattr(src, '__version__', '3.2.0') + monkeypatch.setattr( + 'src.plugin_system.store_manager.subprocess.run', fail_reset) + assert mgr.update_plugin('gitplug') is False + + logged = ' '.join(str(c) for c in mgr.logger.error.call_args_list) + assert 'reset --hard' in logged, logged + + class TestLoaderAndStoreAgree: """Both read the same manifests; a disagreement means one of them is lying to the user.""" From 154525beb8f7d182768a148c6ddf37cd47b8c31f Mon Sep 17 00:00:00 2001 From: Chuck <33324927+ChuckBuilds@users.noreply.github.com> Date: Tue, 1 Sep 2026 09:54:04 -0400 Subject: [PATCH 17/29] fix(background): release the payload after every callback, not after each one (#509) Callers that join an in-flight fetch share one FetchResult. #499 released the payload inside the delivery loop, so the first callback got the data and every joiner got `result.data is None`. That is not a quiet degradation. Consumers read `result.data.get('events')`, so they raise AttributeError -- which the delivery loop catches and logs. The entire failure surfaced as one line: ERROR - src.background_data_service - Error in callback for request nhl_2026_...: 'NoneType' object has no attribute 'get' and a manager that silently never received its schedule. Seen on hardware: NHLRecentManager logs "Background fetch completed for 2026: 1000 events" and the very next line is the error, from NHLUpcomingManager's callback on the same request -- which had already logged "No events found in shared data." Deduplication is the normal case, not a corner. A sport's recent, upcoming and live managers all want the same season schedule, so the second and third are joiners on almost every cycle. _release_payload's own docstring said "once A callback has been handed the data", singular, which is the assumption that broke: the loop above it was written for many, and says so. Moved after the loop, and guarded on `callbacks` being non-empty. The guard matters: a request submitted without a callback must keep its payload, because polling get_result() is then the only way to collect it. The per-delivery release got that right by accident -- an empty list never entered the loop body -- and the existing test for it caught the omission. test_background_payload_release.py gains TestJoinersAllGetTheData: two submitters on one in-flight cache_key, asserting both are handed a populated payload, plus that the memory fix still happens once they have all had it. test_background_fetch_dedupe.py already proved the joiner's callback FIRES; it never checked what the callback received, which is the gap that let this through. Verified the new test bites: restoring the release inside the loop fails it with "'second' was handed a released payload". Full suite 3716 passed, 6 skipped. Claude-Session: https://claude.ai/code/session_014RRtqXDCnvnY6EQwhT5CV9 Co-authored-by: Claude Opus 5 (1M context) --- src/background_data_service.py | 25 +++++-- test/test_background_payload_release.py | 93 +++++++++++++++++++++++++ 2 files changed, 114 insertions(+), 4 deletions(-) diff --git a/src/background_data_service.py b/src/background_data_service.py index b3838322..fa20783c 100644 --- a/src/background_data_service.py +++ b/src/background_data_service.py @@ -478,8 +478,22 @@ class BackgroundDataService: cb(result) except Exception as e: logger.error(f"Error in callback for request {request.id}: {e}") - # Delivered. Drop both references -- they point at the same - # object, so one survivor keeps the whole payload resident. + + # Released AFTER the loop, not inside it. Every callback here holds + # the same FetchResult, so releasing per-delivery handed the first + # one the data and every joiner `result.data is None` -- which is + # not a quiet degradation: they read `result.data.get('events')` and + # raise AttributeError, which this very loop catches and logs, so + # the symptom was one ERROR line and a manager that silently never + # got its schedule. Deduplication is the normal case, not a corner: + # a sport's recent, upcoming and live managers all ride one season + # fetch. + # + # Guarded on `callbacks`, because a request submitted without one + # has no other way to collect its payload than polling get_result(). + # The old per-delivery release got that right by accident: an empty + # list never entered the loop body. + if callbacks: self._release_payload(result) request.result = None @@ -489,8 +503,11 @@ class BackgroundDataService: def _release_payload(result: FetchResult) -> None: """Drop a delivered payload, keeping the result's status and timings. - Only called once a callback has been handed the data. Consumers read - fetched data back from the cache under ``cache_key``; the copy carried + Only called once EVERY callback has been handed the data -- callers + that joined an in-flight fetch share this object, so releasing between + deliveries strips the payload out from under the ones still queued. + Consumers read fetched data back from the cache under ``cache_key``; + the copy carried here was pinning a parsed season schedule -- 946 games for NCAA football, roughly a tenth of total RAM on a 1GB Pi -- in memory until the hourly sweep. diff --git a/test/test_background_payload_release.py b/test/test_background_payload_release.py index 3f6a72c1..2c90a8da 100644 --- a/test/test_background_payload_release.py +++ b/test/test_background_payload_release.py @@ -23,8 +23,14 @@ it only in passing before reading the cache back. Requests submitted *without* a callback keep their payload: polling get_result() is then the only way to collect it, so releasing would break that contract. + +And the release must happen after EVERY callback, not after each one. Callers +that joined an in-flight fetch share a single FetchResult, so releasing per +delivery strips the payload out from under everyone still queued -- see +TestJoinersAllGetTheData. """ +import threading import time import pytest from unittest.mock import MagicMock, Mock, patch @@ -185,3 +191,90 @@ class TestCacheHitPath: cache_key="nfl_2026", ) assert service.get_result(req_id).data == PAYLOAD + + +class TestJoinersAllGetTheData: + """Deduplicated callers share one FetchResult; releasing between them + empties it for the rest. + + Not a corner case. A sport's recent, upcoming and live managers all ask for + the same season schedule, so the second and third are joiners on almost + every cycle. Releasing inside the delivery loop handed the payload to + whichever ran first and gave the others `result.data is None`. + + The consequence was worse than a quiet degradation, because consumers do + `result.data.get('events')`: they raised AttributeError, the delivery loop + caught it, and the whole failure surfaced as a single + "Error in callback for request ..." line while that manager silently never + received its schedule. + """ + + class _BlockingSession: + """Holds the fetch open so a second submit lands while in flight.""" + + def __init__(self): + self.release = threading.Event() + self.started = threading.Event() + + def get(self, *a, **k): + self.started.set() + self.release.wait(timeout=5) + return _resp() + + def test_every_joiner_is_handed_the_payload(self, service): + session = self._BlockingSession() + seen = {} + + def record(name): + # Read it the way the sport managers do. `result.data['events']` + # would raise TypeError on None; `.get` raises AttributeError, + # which is the error actually seen in the field. + def cb(result): + seen[name] = result.data.get('events') if result.data else None + return cb + + with patch.object(service, "session", session): + first = service.submit_fetch_request( + sport="nhl", year=2026, url="https://x/s", cache_key="nhl_2026", + callback=record("first"), max_retries=0) + assert session.started.wait(timeout=5) + + joined = service.submit_fetch_request( + sport="nhl", year=2026, url="https://x/s", cache_key="nhl_2026", + callback=record("second"), max_retries=0) + # Without coalescing these are two independent fetches that each + # own their result, and the test proves nothing about sharing. + assert joined == first, "the joiner should share the in-flight id" + + session.release.set() + _wait(service, first) + + deadline = time.time() + 5 + while len(seen) < 2 and time.time() < deadline: + time.sleep(0.02) + + assert set(seen) == {"first", "second"}, f"both must be called, got {seen}" + for name, events in seen.items(): + assert events is not None, ( + f"{name!r} was handed a released payload: the result was " + f"emptied before every callback had been delivered") + assert len(events) == 50, f"{name!r} got {events!r}" + + def test_the_payload_is_still_released_once_they_have_all_had_it(self, service): + """The memory fix must survive the ordering fix.""" + session = self._BlockingSession() + + with patch.object(service, "session", session): + first = service.submit_fetch_request( + sport="nhl", year=2026, url="https://x/s", cache_key="nhl_2026", + callback=lambda r: None, max_retries=0) + assert session.started.wait(timeout=5) + service.submit_fetch_request( + sport="nhl", year=2026, url="https://x/s", cache_key="nhl_2026", + callback=lambda r: None, max_retries=0) + session.release.set() + _wait_for_release(service, first) + + stored = service.get_result(first) + assert stored is not None and stored.data is None, ( + "the payload must still be dropped once every callback has run") From a686932c7e2f68cfb799da85bcaa559ee70ae04e Mon Sep 17 00:00:00 2001 From: Chuck <33324927+ChuckBuilds@users.noreply.github.com> Date: Wed, 2 Sep 2026 09:51:27 -0400 Subject: [PATCH 18/29] fix(store): land the install_from_url gate, which never reached main (#511) #510 shows as merged, but into fix/gate-git-pull-updates -- #508's branch -- rather than main. #508 reached main first, so the sideload gate was left behind on a branch. Same failure as plugins #350/#351, which merged into each other's bases; worth knowing the pattern, because GitHub reports these as MERGED and `gh pr list` shows nothing outstanding. main today has two of the three routes gated: install_plugin (#431/#433) and update_plugin's git branch (#508). install_from_url validates required manifest fields and then installs whatever it found, never comparing the core version. Cherry-picked unchanged from the orphaned branch -- it applies to main with no conflict. TestSideloadGate pins the three cases the other routes pin: refuses a floor above this core leaving nothing behind, still allows a compatible plugin (the guard against a gate that refuses everything), and does not block a 2.0.0 floor on a core reporting an untrustworthy version. Full suite 3725 passed, 6 skipped. Claude-Session: https://claude.ai/code/session_014RRtqXDCnvnY6EQwhT5CV9 Co-authored-by: Claude Opus 5 (1M context) --- src/plugin_system/store_manager.py | 20 +++++++ test/test_plugin_compatibility_gate.py | 72 +++++++++++++++++++++++++- 2 files changed, 90 insertions(+), 2 deletions(-) diff --git a/src/plugin_system/store_manager.py b/src/plugin_system/store_manager.py index 623270e3..80a454fc 100644 --- a/src/plugin_system/store_manager.py +++ b/src/plugin_system/store_manager.py @@ -1602,6 +1602,26 @@ class PluginStoreManager: 'error': f'Manifest missing required fields: {", ".join(missing_fields)}' } + # Refuse a plugin that needs a newer core than this one, exactly as + # _install_plugin_impl does after its download. Sideloading is an + # explicit act rather than an automatic store update, but the floor + # is not advice about intent -- it is a statement that the plugin + # cannot run here, and letting it through produces the same silent + # PluginState.ERROR at load. This was the last of the three routes + # in that skipped the check. + # + # Before the move, so the `finally` below removes the temp tree and + # nothing half-installed is left behind. + from src import __version__ as core_version + from src.plugin_system import compatibility + + compatible, reason = compatibility.check(manifest, core_version) + if not compatible: + self.logger.error( + "Refusing to install %s from %s: %s", + plugin_id, repo_url, reason) + return {'success': False, 'error': reason} + # Validate version fields consistency (warnings only, not required) validation_errors = self._validate_manifest_version_fields(manifest) if validation_errors: diff --git a/test/test_plugin_compatibility_gate.py b/test/test_plugin_compatibility_gate.py index 91a84b8e..9ab858a9 100644 --- a/test/test_plugin_compatibility_gate.py +++ b/test/test_plugin_compatibility_gate.py @@ -155,8 +155,9 @@ class TestInstallGate: rediscovering it. `update_plugin` also has a git branch that pulls in place and never re-downloads; that one is gated separately by `_gate_pulled_commit` and pinned in `TestGitPullGate` below. A third - route, `install_from_url`, is still ungated — sideloading from a URL - checks required fields but not the floor. + route, `install_from_url` (sideloading from a URL), is gated in that + function and pinned in `TestSideloadGate`. All three refuse on the same + rule. """ def _install_with_manifest(self, store, manifest, core_version, monkeypatch): @@ -217,6 +218,73 @@ class TestInstallGate: assert (path / "manifest.json").exists() +class TestSideloadGate: + """`install_from_url` never looked at the core version. + + Sideloading is an explicit act rather than an automatic store update, so + the argument for gating it is different: not "the user did not choose + this", but that the floor states the plugin *cannot run here*. Letting it + through produces the same silent PluginState.ERROR at load that the store + gate exists to prevent, and the user who typed the URL is no better placed + to diagnose it than one who pressed Update. + """ + + def _sideload(self, store, manifest, core_version, monkeypatch): + mgr, plugins_dir = store + plugin_id = manifest["id"] + + def fake_clone(repo_url, target_path, branches=None): + target_path.mkdir(parents=True, exist_ok=True) + (target_path / "manifest.json").write_text( + json.dumps(manifest), encoding="utf-8") + (target_path / "manager.py").write_text( + "class P: pass\n", encoding="utf-8") + return "main" + + monkeypatch.setattr(mgr, "_install_via_git", fake_clone) + monkeypatch.setattr(mgr, "_install_dependencies", lambda *a, **k: True) + + import src + monkeypatch.setattr(src, "__version__", core_version) + return mgr.install_from_url("https://example.invalid/plugin"), \ + plugins_dir / plugin_id + + def test_refuses_a_plugin_that_needs_a_newer_core(self, store, monkeypatch): + manifest = { + "id": "sideload-newer", "name": "Sideload", "class_name": "P", + "display_modes": ["a"], "min_ledmatrix_version": "9.9.9", + } + result, path = self._sideload(store, manifest, "3.2.0", monkeypatch) + + assert result["success"] is False + assert "9.9.9" in result["error"], result["error"] + assert not path.exists(), ( + "a refused sideload must not leave the plugin installed") + + def test_allows_a_compatible_plugin(self, store, monkeypatch): + """The guard against over-refusing: a gate that blocks everything + passes the test above and breaks sideloading entirely.""" + manifest = { + "id": "sideload-fine", "name": "Sideload", "class_name": "P", + "display_modes": ["a"], "min_ledmatrix_version": "3.0.0", + } + result, path = self._sideload(store, manifest, "3.2.0", monkeypatch) + + assert result["success"] is True, result.get("error") + assert (path / "manifest.json").exists() + + def test_untrustworthy_core_does_not_block_a_2_0_0_floor( + self, store, monkeypatch): + """Same rule as the other two routes: a v3.1.0 release reports 1.0.0, + and nearly every published manifest floors at 2.0.0.""" + manifest = { + "id": "sideload-floored", "name": "Sideload", "class_name": "P", + "display_modes": ["a"], "versions": [{"ledmatrix_min": "2.0.0"}], + } + result, _ = self._sideload(store, manifest, "1.0.0", monkeypatch) + + assert result["success"] is True, result.get("error") + # -------------------------------------------------------------------------- # The git-pull update path — the one route that does not re-download # -------------------------------------------------------------------------- From 6e361e05ccbca931d53c1d353bc47a821bce6768 Mon Sep 17 00:00:00 2001 From: Chuck <33324927+ChuckBuilds@users.noreply.github.com> Date: Wed, 2 Sep 2026 11:34:18 -0400 Subject: [PATCH 19/29] docs(sports): record B6 as done, and what running it found (#435) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs(sports): record where B6 stands, and why it is waiting The phase table had B4 as "next" and B5 as "after B4" while both had shipped, and described B6 as blocked on B4's gate — which is now merged and released. A plan that misreports which phase it is in is worse than no plan: the next person reads it and repeats finished work. Corrected, and three things that were only ever decided in conversation are now written down: * **B6 is deliberately held.** 3.2.0 published 2026-08-03; 3.1.0 ran nine months before it. B6's premise is that cores without the module are gone, and there is no release-asset count or install telemetry to show that. Running it now strands users on their current plugin versions. The gate that makes it safe is already built and tested — it is the calendar that is missing, and no amount of further code changes that. * **Stop adopting further shared modules** (data_sources, game_renderer, base_odds_manager) until B6 closes. Each adoption adds a copy to keep in step against a payoff contingent on B6. * **A B5 retrospective**, because "the adoption went fine" is not what happened: four of eight shipped with scroll mode broken on a 3.2.0 core. The bundled fallback did not protect against it — the break was on the modern path — which is an argument for the sunset, not against it. Records the ledger too: net negative on disk until B6 runs. Also replaces the "what's next" list, whose first five items were all done, with what actually remains. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Udr6MfaFLUPhX5Fgo67Jf5 * fix: apply CodeRabbit auto-fixes Fixed 1 file(s) based on 2 unresolved review comments. Co-authored-by: CodeRabbit * docs(sports): stop a wrapped PR reference reading as a heading A line wrapped onto "#433), the newest manifest entry ...", which markdownlint reads as a malformed ATX heading (MD018). Reflowed so the line starts with "(#431, #433)" instead. Not the suggested fix: adding a space after the hash would have turned the PR reference into "# 433". The B5 safety claim raised alongside this was already corrected in ac44b5a. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Udr6MfaFLUPhX5Fgo67Jf5 * docs(sports): scope the B5 safety claim to the fallback The heading read "B5 — adoption is safe by construction", which this same document disproves two sections later: four of the eight adopted plugins shipped with scroll mode broken on a 3.2.0 core and were repaired in plugins #251. The body was already careful -- it says fallback compatibility is what is guaranteed, and that correctness on a core which *does* ship the module needs object-level and scroll-mode validation. The heading was not, and a heading is what a reader scanning the plan actually takes away. Retitled to name both halves, with a sentence up front saying why the unqualified claim is false and pointing at the retrospective that shows it. The phase intro said "one of them is safe by construction and the other is not"; that now says what it actually means -- one cannot break a user on an old core, the other can. The second review point, MD018 on the ATX heading at line 409, does not reproduce: that line now begins "(#431, #433)" rather than "#433)", so there is no bare-hash heading. `grep -cE '^#+[^ #]'` returns 0 for the whole file. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01STMbQE4YctTacQXfbYqKuW * docs(sports): re-check the hold, and close two items that are already done The remaining-work list had two entries that finished without the doc noticing, which is the failure mode this file exists to prevent. - The stale plugin-test tranche is gone. run_plugin_tests.py --all now reports 174 passed, 2 skipped, 0 failed across the whole fleet. Recorded how to re-check it too: these are standalone scripts, not a pytest suite, and one calls sys.exit(1) at import, so pointing pytest at a plugin directory collapses into an INTERNALERROR that looks nothing like the real state. - CLAUDE.md already says eight panel sizes. That leaves the hardware soaks as the only open item needing work rather than calendar time. B6's prerequisite is now built -- core test/test_sports_sunset_matrix.py (#505) -- so the phase table and the regression-test section say so, and the two modelling traps it had to work through are recorded for whoever touches it next: the copy-removed shape must be an unguarded import or the failure names scroll_display_legacy instead of the core module, and only the leaf module may be hidden because a pre-3.2.0 core still ships src/common/. The hold itself is re-checked and unchanged: v3.2.0 is still latest, __version__ is still 3.2.0, no 3.3.0, 23 days rather than the few months the gate asks for. Also worth stating plainly -- the core updates by git pull, not by downloading a release, so release-asset counts would not measure uptake even if we had them. Whatever unblocks this has to come from the store side. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01STMbQE4YctTacQXfbYqKuW --------- Co-authored-by: Claude Opus 5 (1M context) Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> Co-authored-by: CodeRabbit --- docs/SPORTS_UNIFICATION.md | 190 ++++++++++++++++++++++++++++++------- 1 file changed, 154 insertions(+), 36 deletions(-) diff --git a/docs/SPORTS_UNIFICATION.md b/docs/SPORTS_UNIFICATION.md index 4ef30e94..d783e15b 100644 --- a/docs/SPORTS_UNIFICATION.md +++ b/docs/SPORTS_UNIFICATION.md @@ -204,7 +204,7 @@ legacy compatibility rather than the mechanism. B0–B3 are merged and shipping in core 3.2.0. Everything that remains is **rollout**, and it splits into three phases with very different risk profiles. The original plan folded the last two together; they are separated here because -one of them is safe by construction and the other is not. +one of them cannot break a user on an old core and the other can. | Phase | Scope | Status | Gate | |---|---|---|---| @@ -212,9 +212,9 @@ one of them is safe by construction and the other is not. | **B1** | Promote the nine universal methods; convert `sports.py` → package | ✅ | Characterization suite green; no behavior change intended | | **B2** | `CelebrationMixin` + rotation strategies as opt-in capabilities | ✅ | Non-adopters have zero new code in their MRO; strategies checked against verbatim plugin transcriptions | | **B3** | Upstream the scroll **orchestration** layer as `src/common/sports_scroll.py`, reading `global_config['target_fps']` natively | ✅ | Content building stays per-sport | -| **B4** | Ship 3.2.0 *and* make version reporting trustworthy | ⏳ **next** | Tag, release, and `src.__version__` agree; compatibility gate merged | -| **B5** | Adoption — guarded core imports: three pilots, then the remaining six. **Bundled copies stay.** | after B4 | Per plugin: harness + goldens byte-identical, then a device soak | -| **B6** | Sunset — delete the bundled copies | **blocked** | B4's gate shipped *and* in users' hands (see below) | +| **B4** | Ship 3.2.0 *and* make version reporting trustworthy | ✅ | Released 2026-08-03; tag, release and `src.__version__` agree; compatibility gate merged (#428, #431, #433) | +| **B5** | Adoption — guarded core imports, all eight. **Bundled copies stay.** | ✅ | All eight adopted; harness byte-identical; see the B5 retrospective below — four shipped broken and were repaired in plugins #251 | +| **B6** | Sunset — delete the bundled copies | ✅ | Ran 2026-09-01, all eight. Floors at 3.2.0; the store refuses on all three routes in (#431/#433, #508, #510). See "B6 — what actually happened" | ### B4 — what "ship 3.2.0" actually requires @@ -276,13 +276,21 @@ default is the more restrictive. `compatible_versions`. No manifest still carries it, so there is nothing to migrate there.) -### B5 — adoption is safe by construction +### B5 — the *fallback* is safe by construction; the modern path is not + +The heading matters, because the unqualified version of this claim is false and +this document proves it two sections down: four of the eight adopted plugins +shipped with scroll mode broken on a 3.2.0 core. What is safe by construction is +narrower than "adoption". A plugin adopting core imports keeps its bundled copy and reaches it through the -guarded import (see the Upgradability table above). On a core that ships the -module the plugin uses core code; on one that doesn't it falls back and behaves -exactly as it does today. There is no version of this step that breaks a user, -which is why it does not wait for B6's gate. +guarded import (see the Upgradability table above). On a core that doesn't ship +the module the plugin falls back and behaves exactly as it does today. That +fallback compatibility — and only that — is safe by construction. On a core that +*does* ship the module, correctness is not automatic — object-level and scroll-mode validation +(building both classes and comparing, per the retrospective below) is required +to prove full behavior. There is no version of this step that breaks a user *on +an old core*, which is why it does not wait for B6's gate. The hockey scroll-display pilot is **already validated**: adopted against a core carrying 3.2.0, `scroll_display.py` went from 691 to 289 lines and all 16 harness @@ -311,7 +319,8 @@ been in users' hands long enough that the population running a core without it is small.** The bundled copies cost disk space; deleting them early costs scoreboards, silently. That trade is not close. -Before the first sunset, add a **compatibility regression test**. It has to +Before the first sunset, add a **compatibility regression test**. **Built:** +core `test/test_sports_sunset_matrix.py` (#505). It has to cover four cases, not one — B5's safety claim and B6's failure mode are different propositions and only the second is obvious: @@ -335,35 +344,144 @@ gate rather than trusting the failure to be noticed. The same suite should exercise the install/update gate, since it is the other half of the guarantee. +### B6 — what actually happened + +**Ran 2026-09-01, across all eight scoreboards.** Held from 2026-08-05 to +2026-09-01 on the argument below, which is kept because the reasoning applies to +the next module, not because it is still in force. + +**The hold, and why it lifted.** The stated gate was evidence of 3.2.0 uptake — +"a few months of it being the default download, or store-side install data". +That evidence never arrived and could not: the core updates by +`git pull --rebase`, so release-asset counts cannot measure uptake, and no +store-side telemetry exists. What changed instead is that the *risk* the gate +protected against was closed directly. The store now refuses a plugin whose +floor exceeds the running core on **all three** routes in: + +| route | gated by | +|---|---| +| `install_plugin` — every path that re-downloads, `_reinstall_with_rollback` included | #431, #433 | +| `update_plugin`'s git branch — pulls in place, re-downloads nothing | #508 | +| `install_from_url` — sideloading | #510 | + +With all three closed a pre-3.2.0 user cannot receive a sunset plugin at all; +they keep the version they already run. The population the hold existed to +protect is protected by refusal rather than by a bundled copy — which is what +the copy was standing in for. + +**What shipped.** Eight plugins, ~5,800 lines of frozen fallback deleted. Each: +copy removed, guarded import collapsed to a plain one, floor raised to 3.2.0, +`test_core_fallback.py` rewritten as `test_core_scroll.py` asserting the sunset +rather than the fallback. `scripts/check_scroll_adoption.py` gained +`sunset_violations` and a `SUNSET_PLUGINS` set naming all eight, so a +resurrected copy or a returned guard fails CI. + +Delivered as plugins #346 (hockey, later folded into #351), #349 (football), +#350 (baseball), #351 (the remaining six). + +**Two things found by doing it, both worth carrying forward:** + +- **Only one fallback held orchestration logic the core lacked.** baseball's + `_configure_scroll_helper` reinterpreted `scroll_speed` as pixels-per-*frame* + when `speed × delay` fell outside the 0.1–5.0 window — measured, 10–20× + faster than configured for a speed between 1.0 and 5.0. Standardised onto the + core's behaviour (honour the documented px/sec, clamp) rather than preserved. + Every other difference across the eight was a docstring, an unreachable + `scroll_helper is None` guard, or an equivalent diagnostic. +- **Two tests had been leaning on the guard without anyone knowing.** + `soccer/test_live_screens.py` stubbed `src` in a way that shadowed the core, + so its guarded import fell back and the test had been exercising the *frozen + copy* rather than the shipping class since B5. Before sunsetting anything else + that carries a guarded core import, grep for tests that stub `src`. + +**The floor-raising traps still apply** to any future sunset: four plugins +declare their floor top-level, where editing `versions[0]` is a silent no-op, +and the floor has three live spellings (`min_ledmatrix_version`, +`requires.min_ledmatrix_version`, `versions[].ledmatrix_min_version`, plus +deprecated `ledmatrix_min`). See +`src/plugin_system/compatibility.py:declared_min_version` for the resolution +order any floor-raising tool must reproduce — and note the name is **inverted** +between the top level and `versions[]`. + +**Still not adopted, deliberately:** `data_sources.py`, `game_renderer.py` and +`base_odds_manager.py`. The standing decision held them until B6 closed; it now +has, so they can be reconsidered — with B5's lesson applied, which is to build +the object and diff rendered output rather than trust a static check. + +### B5 retrospective — what the adoption actually cost + +Recorded because it is the evidence behind the two decisions above, and because +"the adoption went fine" is not what happened. + +**Four of the eight shipped with scroll mode broken** on a 3.2.0 core, and were +repaired in plugins-repo #251. The restructure lifted the content methods +verbatim but left the state they read off `self` behind: separator-icon +constants (hockey, basketball, lacrosse) and the game-renderer cache (afl). +hockey/basketball/lacrosse could not construct the scroll display at all; afl +raised inside `prepare_scroll_content`, which the core base *catches*, so its +only symptom was scroll mode silently drawing nothing. + +Three things are worth carrying forward: + +- **The bundled fallback did not protect anyone from this.** The break was on + the modern path, which the fallback never touches. Carrying the second copy + bought nothing against the actual defect while creating the divergence that + produced it. That is an argument *for* B6, not against it. +- **Every gate was green.** The safety harness renders the scoreboard screens, + not scroll mode; `test_core_fallback.py` checked that methods existed and that + their *globals* resolved, and `self.NHL_SEPARATOR_ICON` is an attribute read, + invisible to an AST scan for `Name` loads. The fix was to stop reasoning about + source and **build the object**: construct both classes on both paths, compare + the separator icons they end up with, and assert the adopted class ends up + with every instance attribute the bundled one sets. +- **Test what the change touches, not what is convenient to render.** Scroll + mode had no coverage because the harness could not reach it. A comparison + harness that renders the same games through both paths and diffs the pixels + needs no per-sport knowledge of the right answer, only that adopting core code + did not change it. + +**The ledger.** Before adoption, eight duplicated copies totalled 5,685 lines. +After adoption plus the frozen legacy copies it was 10,610; removing the dead +inline duplication (plugins #252) brought it to roughly 8,620. B6 would take it +to about 3,300 including the shared core module — some 2,400 fewer than before +this project started. **Until B6 runs, the adoption is net negative on disk**, +and its one delivered user-visible gain is that adopted plugins honour the +global `target_fps` instead of hardcoding ~100 FPS. + +### Decision: stop adopting further modules until B6 closes + +`data_sources.py` (9 copies), `game_renderer.py` (8) and `base_odds_manager.py` +are the obvious next candidates. **Do not adopt them yet.** Each adoption adds +carrying cost — a second copy to keep in step — against a payoff that is +contingent on B6, and B6 is gated on an installed base we cannot currently +measure. Consolidate what is already committed; revisit when B6 does. + ## What's next -In order. Each step is independently useful and independently revertible. +Steps 1–5 of the original plan are **done**: 3.2.0 is tagged and published with +a version number CI now asserts (#428), the compatibility gate is in +`install_plugin` and reads `compatible_versions` as well as the floor +(#431, #433), the newest manifest entry is required to use `ledmatrix_min_version` +(plugins #244), and all eight plugins have adopted the scroll orchestration +(plugins #245–#249, repaired in #251, tidied in #252). -1. **Tag and publish v3.2.0.** The code is already on `main` (`21825cbf`). - Nothing else blocks this, and it is what makes `ledmatrix_min_version: - "3.2.0"` refer to something real. -2. **Make the version number honest.** Have the release process assert that the - tag, the GitHub release, and `src.__version__` agree — a check in CI is - cheaper than the confusion of the last two releases. Then revisit the - `< 2.0.0` skip in `_warn_if_incompatible`, which currently silences the - warning for the users who most need it. -3. **Add the compatibility gate** to `StoreManager.install_plugin` and - `.update_plugin`: refuse a plugin whose declared floor exceeds - `src.__version__`, and surface the reason in the store UI rather than only - the log. This is the single change that turns the floor from documentation - into a guarantee, and B6 depends on it. -4. **Migrate the manifests** to `ledmatrix_min_version`, and reconcile them with - `compatible_versions` (see above — that field is the required, canonical one, - and the gate does not read it yet). Currently 28 plugins spell the floor both - ways across their `versions[]` entries, 12 use only the old spelling, and 2 - only the new. Scope the sweep to the nine sports plugins if a 42-plugin - version-bump wave isn't worth it — but the `compatible_versions` half has to - cover every manifest the gate can refuse, or define explicit legacy handling, - before the gate is allowed to block anything. -5. **Run B5 adoption** — hockey, soccer, football, then the remaining six. - Bundled copies stay. Byte-identical harness output per plugin, then a soak. -6. **Only then plan B6**, with the compatibility regression test described above - in CI first. +What actually remains, smallest first: + +1. **Soak the adoptions on hardware.** football and hockey have been run on a + live rig through real games; baseball was watched through one earlier. The + rest are proven by harness, unit tests and pixel comparison. Out-of-season + sports cannot be soaked until their season starts. When you do, **check the + rig's `*_display_mode` first** — a board in `switch` mode will happily load a + sunset plugin and tell you nothing about the scroll code the sunset changed. +2. **Cut 3.3.0.** Not required by B6 — its floors are 3.2.0, which is released — + but `calendar` 1.2.3 floors at 3.3.0 for the device-authorization endpoints + that landed after 3.2.0, so it is un-installable until the release exists. +3. **Reconsider the held modules** (`data_sources.py`, `game_renderer.py`, + `base_odds_manager.py`) now that the sunset has closed. `game_renderer.py` is + the largest single duplication left: ~11,500 lines across eight plugins, with + ~36,500 more in the eight `sports.py`. Note that core already ships + `src/base_classes/sports/` (~143KB, promoted in B1/B2) that **no plugin + imports** — check whether it has drifted before treating it as the target. ## How to keep this project healthy From 92f9d06af955ea68038cb6489c892d17561e3043 Mon Sep 17 00:00:00 2001 From: Chuck <33324927+ChuckBuilds@users.noreply.github.com> Date: Wed, 2 Sep 2026 13:21:07 -0400 Subject: [PATCH 20/29] fix(logos): stop a failed download pinning a team to a grey box forever (#512) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(logos): stop a failed download pinning a team to a grey box forever When a logo download fails, create_placeholder_logo writes a 64x64 grey PNG under the *real* logo's filename. Every later call then hits `if filepath.exists(): return True` and reports success, so the real logo is never attempted again. One transient failure -- no network at boot, ESPN blipping -- permanently costs that team its logo. This is not hypothetical. Five of the eleven cached AFL logos in my checkout were 384-byte stubs written in a single bad minute, and they had stayed that way ever since; the scoreboard rendered COLL, FRE, NMFC, PORT and SYD as grey text boxes on every card. Placeholders are now stamped with a `ledmatrix_placeholder` PNG text chunk carrying their creation time, and `is_placeholder_logo` recognises them. It also matches on the placeholder's exact geometry and background colour, so the stubs already sitting on users' disks are picked up too -- without that, this fix would only help teams whose logos break in future. Verified against the real stubs: all five detected, all six real logos untouched. `download_missing_logo` now treats an existing placeholder as the failed download it is and retries, rather than as a satisfied request. The retry is rate-limited to PLACEHOLDER_RETRY_SECONDS (6h) so this does not trade a permanent grey box for an ESPN request every frame; a failed retry rewrites the placeholder, restarting the clock. The age comes from the stamp rather than mtime, so a backup restore, an rsync, or a permissions script cannot silently reset it. `download_missing_logos_for_league` gets the same treatment -- a bulk pass is exactly where a previously failed logo should get another chance -- and `LogoHelper.load_logo_with_download` no longer accepts a stale placeholder as a cache hit. That import is lazy and guarded so the module still works against a core build predating the marker. `LogoHelper._create_placeholder_logo` needs no change: it returns an in-memory image and never writes it to disk, which is the behaviour this bug argues for. Tests cover marked and legacy-unmarked detection, the two false-positive cases (a real 500x500 logo, and a 64x64 image that is merely the same size), the retry, the rate limit, and that the age survives an mtime touch. Co-Authored-By: Claude Opus 5 * fix(logos): address review — unify eligibility, invalidate cache, restart back-off Three findings from the review on #512, all confirmed against the code: 1. The three download sites each had their own idea of "already have it". download_missing_logos_for_league() retried *any* placeholder, ignoring the back-off entirely, while download_all_ncaa_football_logos() was never updated and still skipped placeholders forever. They now share one should_attempt_download(), which also covers force_download, so the sites cannot drift apart again. download_missing_logo() reads through the same helper. 2. LogoHelper.load_logo_with_download() answered from the in-memory cache before touching the disk, so after a stale placeholder was successfully replaced the *cached placeholder image* was still returned -- the real logo would not have appeared until the process restarted. The cache entry for that file (every size of it) is now dropped after a successful download. 3. A failed retry left the stale placeholder on disk with its old timestamp, so the next call saw it as stale again and retried immediately: a download attempt per call, which is precisely what the back-off exists to prevent. refresh_placeholder_timestamp() restamps it, and the helper calls that on the failure path. It refuses to touch anything that is not a placeholder. Tests cover both bulk loops in both directions (fresh placeholder skipped, stale one retried), the eligibility rule including force_download, the timestamp refresh, and the two LogoHelper paths -- including that a freshly-downloaded logo is actually what comes back rather than the cached placeholder. Two of the new bulk-loop tests initially passed for the wrong reason: the fetch_teams_data stub returned {}, which is falsy, so the loops bailed before reaching the eligibility check at all. Fixed to return a truthy payload. Re-verified end to end: with both halves in place, rendering the AFL scoreboard took FRE.png from a 362-byte stub to a 12,928-byte logo. Co-Authored-By: Claude Opus 5 --------- Co-authored-by: Claude Opus 5 --- src/common/logo_helper.py | 54 ++++++++- src/logo_downloader.py | 124 +++++++++++++++++-- test/test_logo_downloader.py | 226 ++++++++++++++++++++++++++++++++++- test/test_logo_helper.py | 73 +++++++++++ 4 files changed, 466 insertions(+), 11 deletions(-) diff --git a/src/common/logo_helper.py b/src/common/logo_helper.py index 743e7b7b..a64ca340 100644 --- a/src/common/logo_helper.py +++ b/src/common/logo_helper.py @@ -143,8 +143,10 @@ class LogoHelper: """ logo_path = Path(logo_path) - # Try to load existing logo first - if logo_path.exists(): + # Try to load existing logo first. A placeholder written by a previous + # failed download does not count: it wears the real logo's filename, so + # trusting the file's existence is what left teams as grey boxes. + if logo_path.exists() and not self._is_stale_placeholder(logo_path): return self.load_logo(team_abbr, logo_path, max_width, max_height) # Download if URL provided and file doesn't exist @@ -152,13 +154,61 @@ class LogoHelper: try: self.logger.info(f"Downloading logo for {team_abbr} from {logo_url}") self._download_logo(logo_url, logo_path) + # The file on disk just changed. Any cached image for it is the + # placeholder we came here to replace, and load_logo() answers + # from the cache before touching the disk -- so without this the + # real logo would not appear until the process restarted. + self._invalidate_cached_logo(team_abbr, logo_path) return self.load_logo(team_abbr, logo_path, max_width, max_height) except Exception as e: self.logger.error(f"Failed to download logo for {team_abbr}: {e}") + # The retry failed, so restart the back-off. The stale + # placeholder is still on disk with its old timestamp, and + # leaving it there means the next call retries immediately -- + # a download attempt per call, which is what the back-off + # exists to prevent. + self._refresh_stale_placeholder(logo_path) # Create placeholder if all else fails return self._create_placeholder_logo(team_abbr, max_width, max_height) + def _invalidate_cached_logo(self, team_abbr: str, logo_path: Path) -> None: + """Drop every cached size of one logo after its file changed on disk.""" + prefix = f"{team_abbr}_{logo_path}_" + for key in [k for k in self._logo_cache if k.startswith(prefix)]: + self._logo_cache.pop(key, None) + if key in self._cache_order: + self._cache_order.remove(key) + + @staticmethod + def _refresh_stale_placeholder(logo_path: Path) -> None: + """Restart the retry back-off after a failed download attempt.""" + try: + from src.logo_downloader import refresh_placeholder_timestamp + except ImportError: + return + refresh_placeholder_timestamp(logo_path) + + @staticmethod + def _is_stale_placeholder(logo_path: Path) -> bool: + """True if the file is a placeholder old enough to be worth retrying. + + Imported lazily so this module keeps working against a core build whose + logo_downloader predates placeholder marking. + """ + try: + from src.logo_downloader import ( + PLACEHOLDER_RETRY_SECONDS, + is_placeholder_logo, + placeholder_age_seconds, + ) + except ImportError: + return False + if not is_placeholder_logo(logo_path): + return False + age = placeholder_age_seconds(logo_path) + return age is None or age >= PLACEHOLDER_RETRY_SECONDS + def get_logo_variations(self, team_abbr: str) -> List[str]: """ Get possible filename variations for a team abbreviation. diff --git a/src/logo_downloader.py b/src/logo_downloader.py index b799b7c1..d6fbd7bc 100644 --- a/src/logo_downloader.py +++ b/src/logo_downloader.py @@ -14,6 +14,7 @@ import json from typing import Dict, List, Optional, Tuple from pathlib import Path from PIL import Image, ImageDraw, ImageFont +from PIL.PngImagePlugin import PngInfo from requests.adapters import HTTPAdapter from urllib3.util.retry import Retry from src.common.permission_utils import ( @@ -25,6 +26,96 @@ from src.common.permission_utils import ( logger = logging.getLogger(__name__) +#: PNG text key stamped into a generated placeholder so a later run can tell it +#: apart from a real logo that happens to be small. +PLACEHOLDER_MARKER = "ledmatrix_placeholder" + +#: Geometry of a generated placeholder, used to recognise ones written before +#: the marker existed. Those are already on users' disks and would otherwise +#: never be retried. +PLACEHOLDER_SIZE = (64, 64) +PLACEHOLDER_BG = (100, 100, 100, 255) + +#: How long a placeholder is trusted before the real logo is attempted again. +#: A placeholder means the download failed, and download failures are usually +#: transient (no network at boot, ESPN blipping). Retrying every frame would +#: hammer the API from a Pi that is also driving a panel; never retrying leaves +#: the team a grey box forever, which is the bug this exists to avoid. +PLACEHOLDER_RETRY_SECONDS = 6 * 60 * 60 + + +def is_placeholder_logo(filepath: Path) -> bool: + """True if the file at ``filepath`` is a generated placeholder, not a logo. + + Checks the marker first, then falls back to matching the placeholder's + exact geometry and background colour so files written before the marker was + introduced are still recognised. + """ + try: + with Image.open(filepath) as img: + if img.info.get(PLACEHOLDER_MARKER): + return True + if img.size != PLACEHOLDER_SIZE: + return False + return img.convert("RGBA").getpixel((0, 0)) == PLACEHOLDER_BG + except Exception: + # Unreadable file: not provably a placeholder, and the caller's own + # error handling is better placed to deal with it. + return False + + +def should_attempt_download(filepath: Path, force_download: bool = False) -> bool: + """Whether a real logo is worth (re)fetching for ``filepath``. + + True when nothing is there, when the caller forced it, or when what is + there is a placeholder old enough to retry. A *fresh* placeholder says a + download just failed, so retrying it immediately would hammer the API for + a result that is very unlikely to have changed. + """ + if force_download or not filepath.exists(): + return True + if not is_placeholder_logo(filepath): + return False + age = placeholder_age_seconds(filepath) + return age is None or age >= PLACEHOLDER_RETRY_SECONDS + + +def refresh_placeholder_timestamp(filepath: Path) -> bool: + """Restamp a placeholder so a failed retry restarts the back-off clock. + + Without this a stale placeholder stays stale: every later call sees an + expired timestamp, retries, fails, and leaves the timestamp untouched -- + which is a download attempt per call, the opposite of what the back-off is + for. + """ + try: + if not is_placeholder_logo(filepath): + return False + metadata = PngInfo() + metadata.add_text(PLACEHOLDER_MARKER, str(time.time())) + with Image.open(filepath) as img: + img.copy().save(filepath, "PNG", pnginfo=metadata) + return True + except Exception: + logger.debug("Could not refresh placeholder timestamp for %s", filepath, + exc_info=True) + return False + + +def placeholder_age_seconds(filepath: Path) -> Optional[float]: + """Seconds since a placeholder was written, or None if unknown.""" + try: + with Image.open(filepath) as img: + stamped = img.info.get(PLACEHOLDER_MARKER) + if stamped and stamped != "1": + return max(0.0, time.time() - float(stamped)) + except Exception: + pass + try: + return max(0.0, time.time() - filepath.stat().st_mtime) + except OSError: + return None + class LogoDownloader: """Centralized logo downloader for team logos from ESPN API.""" @@ -499,8 +590,10 @@ class LogoDownloader: filename = f"{self.normalize_abbreviation(abbreviation)}.png" filepath = Path(logo_dir) / filename - # Skip if already exists and not forcing download - if filepath.exists() and not force_download: + # A placeholder does not count as existing -- it is a previous + # failure, and a bulk pass is exactly where it should get another + # chance, subject to the same back-off as everywhere else. + if not should_attempt_download(filepath, force_download): logger.debug(f"Skipping {display_name}: {filename} already exists") continue @@ -559,8 +652,9 @@ class LogoDownloader: filename = f"{self.normalize_abbreviation(abbreviation)}.png" filepath = Path(logo_dir) / filename - # Skip if already exists and not forcing download - if filepath.exists() and not force_download: + # Same eligibility rule as every other download site: a stale + # placeholder is a failed download, not a logo. + if not should_attempt_download(filepath, force_download): logger.debug(f"Skipping {display_name} ({category}, {conference}): {filename} already exists") continue @@ -674,11 +768,16 @@ class LogoDownloader: # Fallback without font draw.text((16, 24), text, fill=(255, 255, 255, 255)) - logo.save(filepath) - + # Stamp it so a later run can tell this apart from a real logo and + # retry the download, instead of treating the file's existence as + # proof the logo was fetched. + metadata = PngInfo() + metadata.add_text(PLACEHOLDER_MARKER, str(time.time())) + logo.save(filepath, "PNG", pnginfo=metadata) + # Set proper file permissions after saving ensure_file_permissions(filepath, get_assets_file_mode()) - + logger.info(f"Created placeholder logo for {team_abbreviation} at {filepath}") return True @@ -770,9 +869,18 @@ def download_missing_logo(league: str, team_id: str, team_abbreviation: str, log # Use the exact filepath that was passed in (respects config settings) filepath = logo_path - if filepath.exists(): + if filepath.exists() and not should_attempt_download(filepath): + # Either a real logo, or a placeholder too fresh to be worth retrying. logger.debug(f"Logo already exists for {team_abbreviation} ({league})") return True + if filepath.exists(): + # A placeholder is a *failed* download wearing the real logo's + # filename. Treating it as "already exists" is what pinned a team to a + # grey box permanently after one transient failure. + logger.info( + "Logo for %s (%s) is a placeholder from a failed download; " + "retrying the real logo", team_abbreviation, league, + ) # Try to download the real logo first logger.info(f"Attempting to download logo for {team_abbreviation} from {league}") diff --git a/test/test_logo_downloader.py b/test/test_logo_downloader.py index 9b79d45b..c14b3391 100644 --- a/test/test_logo_downloader.py +++ b/test/test_logo_downloader.py @@ -8,11 +8,27 @@ ensure_logo_directory, and the download_missing_logo function path """ import os +import time + import pytest from pathlib import Path from unittest.mock import patch, Mock, MagicMock -from src.logo_downloader import LogoDownloader +from PIL import Image +from PIL.PngImagePlugin import PngInfo + +from src.logo_downloader import ( + PLACEHOLDER_BG, + PLACEHOLDER_MARKER, + PLACEHOLDER_RETRY_SECONDS, + PLACEHOLDER_SIZE, + LogoDownloader, + download_missing_logo, + is_placeholder_logo, + placeholder_age_seconds, + refresh_placeholder_timestamp, + should_attempt_download, +) # --------------------------------------------------------------------------- @@ -127,3 +143,211 @@ class TestEnsureLogoDirectory: with patch("builtins.open", side_effect=mock_open): result = downloader.ensure_logo_directory(test_dir) assert result is False + + +# --------------------------------------------------------------------------- +# Placeholder detection and retry +# +# A failed download used to be cached as a placeholder wearing the real logo's +# filename, and download_missing_logo returned early on "the file exists". One +# transient failure therefore pinned a team to a grey box permanently. +# --------------------------------------------------------------------------- + +class TestPlaceholderLogos: + def _placeholder(self, tmp_path, abbrev="COLL"): + downloader = LogoDownloader() + assert downloader.create_placeholder_logo(abbrev, str(tmp_path)) is True + return tmp_path / f"{abbrev}.png" + + def test_generated_placeholder_is_recognised(self, tmp_path): + assert is_placeholder_logo(self._placeholder(tmp_path)) is True + + def test_real_logo_is_not_a_placeholder(self, tmp_path): + real = tmp_path / "REAL.png" + Image.new("RGBA", (500, 500), (12, 34, 56, 255)).save(real) + assert is_placeholder_logo(real) is False + + def test_legacy_unmarked_placeholder_is_recognised(self, tmp_path): + """Placeholders written before the marker existed must still be caught. + + They are already sitting on users' disks; if they were not recognised + those teams would stay grey boxes forever even after this fix. + """ + legacy = tmp_path / "LEGACY.png" + Image.new("RGBA", PLACEHOLDER_SIZE, PLACEHOLDER_BG).save(legacy) + assert is_placeholder_logo(legacy) is True + + def test_same_size_but_different_colour_is_not_a_placeholder(self, tmp_path): + real = tmp_path / "SMALL.png" + Image.new("RGBA", PLACEHOLDER_SIZE, (10, 200, 10, 255)).save(real) + assert is_placeholder_logo(real) is False + + def test_missing_file_is_not_a_placeholder(self, tmp_path): + assert is_placeholder_logo(tmp_path / "nope.png") is False + + def test_existing_real_logo_short_circuits_without_downloading(self, tmp_path): + real = tmp_path / "REAL.png" + Image.new("RGBA", (500, 500), (1, 2, 3, 255)).save(real) + with patch.object(LogoDownloader, "download_logo") as download: + assert download_missing_logo( + "afl", "1", "REAL", real, logo_url="http://example/x.png") is True + download.assert_not_called() + + def _age_placeholder(self, path, seconds): + """Rewrite a placeholder's marker so it reads as `seconds` old.""" + metadata = PngInfo() + metadata.add_text(PLACEHOLDER_MARKER, str(time.time() - seconds)) + with Image.open(path) as img: + img.copy().save(path, "PNG", pnginfo=metadata) + + def test_stale_placeholder_triggers_a_retry(self, tmp_path): + path = self._placeholder(tmp_path) + self._age_placeholder(path, PLACEHOLDER_RETRY_SECONDS + 60) + assert placeholder_age_seconds(path) > PLACEHOLDER_RETRY_SECONDS + + with patch.object(LogoDownloader, "download_logo", return_value=True) as download: + assert download_missing_logo( + "afl", "1", "COLL", path, + logo_url="http://example/coll.png") is True + download.assert_called_once() + + def test_placeholder_age_survives_an_mtime_touch(self, tmp_path): + """The age comes from the stamp, not the filesystem. + + Anything that rewrites file times -- a backup restore, an rsync, a + permissions fix script -- would otherwise reset the retry clock. + """ + path = self._placeholder(tmp_path) + self._age_placeholder(path, PLACEHOLDER_RETRY_SECONDS + 60) + now = time.time() + os.utime(path, (now, now)) + assert placeholder_age_seconds(path) > PLACEHOLDER_RETRY_SECONDS + + def test_fresh_placeholder_does_not_retry(self, tmp_path): + """Rate limiting: a placeholder written seconds ago must not re-download. + + Without this the fix would trade a permanent grey box for an ESPN + request on every frame. + """ + path = self._placeholder(tmp_path) + with patch.object(LogoDownloader, "download_logo") as download: + assert download_missing_logo( + "afl", "1", "COLL", path, + logo_url="http://example/coll.png") is True + download.assert_not_called() + + +class TestDownloadEligibility: + """One rule, shared by every download site. + + The two bulk loops and the single-logo path each had their own idea of what + counted as "already have it", which is how one of them ended up retrying + fresh placeholders and the other skipping stale ones forever. + """ + + def _placeholder(self, tmp_path, abbrev="COLL"): + assert LogoDownloader().create_placeholder_logo(abbrev, str(tmp_path)) + return tmp_path / f"{abbrev}.png" + + def _age(self, path, seconds): + metadata = PngInfo() + metadata.add_text(PLACEHOLDER_MARKER, str(time.time() - seconds)) + with Image.open(path) as img: + img.copy().save(path, "PNG", pnginfo=metadata) + + def test_missing_file_is_eligible(self, tmp_path): + assert should_attempt_download(tmp_path / "nope.png") is True + + def test_real_logo_is_not_eligible(self, tmp_path): + real = tmp_path / "REAL.png" + Image.new("RGBA", (500, 500), (1, 2, 3, 255)).save(real) + assert should_attempt_download(real) is False + + def test_force_download_beats_a_real_logo(self, tmp_path): + real = tmp_path / "REAL.png" + Image.new("RGBA", (500, 500), (1, 2, 3, 255)).save(real) + assert should_attempt_download(real, force_download=True) is True + + def test_fresh_placeholder_is_not_eligible(self, tmp_path): + assert should_attempt_download(self._placeholder(tmp_path)) is False + + def test_stale_placeholder_is_eligible(self, tmp_path): + path = self._placeholder(tmp_path) + self._age(path, PLACEHOLDER_RETRY_SECONDS + 60) + assert should_attempt_download(path) is True + + def test_league_bulk_loop_skips_a_fresh_placeholder(self, tmp_path): + """A bulk pass honours the same back-off as everything else.""" + self._placeholder(tmp_path, "AAA") + downloader = LogoDownloader() + teams = [{"abbreviation": "AAA", "display_name": "A", "logo_url": "http://x/a.png"}] + with patch.object(LogoDownloader, "get_logo_directory", return_value=str(tmp_path)): + with patch.object(LogoDownloader, "fetch_teams_data", return_value={"sports": [{}]}): + with patch.object(LogoDownloader, "extract_teams_from_data", return_value=teams): + with patch.object(LogoDownloader, "download_logo") as download: + downloader.download_missing_logos_for_league("nfl") + download.assert_not_called() + + def test_league_bulk_loop_retries_a_stale_placeholder(self, tmp_path): + path = self._placeholder(tmp_path, "AAA") + self._age(path, PLACEHOLDER_RETRY_SECONDS + 60) + downloader = LogoDownloader() + teams = [{"abbreviation": "AAA", "display_name": "A", "logo_url": "http://x/a.png"}] + with patch.object(LogoDownloader, "get_logo_directory", return_value=str(tmp_path)): + with patch.object(LogoDownloader, "fetch_teams_data", return_value={"sports": [{}]}): + with patch.object(LogoDownloader, "extract_teams_from_data", return_value=teams): + with patch.object(LogoDownloader, "download_logo", return_value=True) as download: + downloader.download_missing_logos_for_league("nfl") + download.assert_called_once() + + def test_ncaa_bulk_loop_retries_a_stale_placeholder(self, tmp_path): + """This loop skipped placeholders forever; it now shares the rule.""" + path = self._placeholder(tmp_path, "AAA") + self._age(path, PLACEHOLDER_RETRY_SECONDS + 60) + downloader = LogoDownloader() + teams = [{"abbreviation": "AAA", "display_name": "A", + "logo_url": "http://x/a.png", "category": "FBS", + "conference": "SEC"}] + with patch.object(LogoDownloader, "get_logo_directory", return_value=str(tmp_path)): + with patch.object(LogoDownloader, "fetch_teams_data", return_value={"sports": [{}]}): + with patch.object(LogoDownloader, "extract_teams_from_data", return_value=teams): + with patch.object(LogoDownloader, "download_logo", return_value=True) as download: + downloader.download_all_ncaa_football_logos() + download.assert_called_once() + + def test_ncaa_bulk_loop_skips_a_fresh_placeholder(self, tmp_path): + self._placeholder(tmp_path, "AAA") + downloader = LogoDownloader() + teams = [{"abbreviation": "AAA", "display_name": "A", + "logo_url": "http://x/a.png", "category": "FBS", + "conference": "SEC"}] + with patch.object(LogoDownloader, "get_logo_directory", return_value=str(tmp_path)): + with patch.object(LogoDownloader, "fetch_teams_data", return_value={"sports": [{}]}): + with patch.object(LogoDownloader, "extract_teams_from_data", return_value=teams): + with patch.object(LogoDownloader, "download_logo") as download: + downloader.download_all_ncaa_football_logos() + download.assert_not_called() + + +class TestRefreshPlaceholderTimestamp: + def test_restarts_the_back_off(self, tmp_path): + assert LogoDownloader().create_placeholder_logo("COLL", str(tmp_path)) + path = tmp_path / "COLL.png" + metadata = PngInfo() + metadata.add_text(PLACEHOLDER_MARKER, str(time.time() - (PLACEHOLDER_RETRY_SECONDS + 60))) + with Image.open(path) as img: + img.copy().save(path, "PNG", pnginfo=metadata) + assert should_attempt_download(path) is True + + assert refresh_placeholder_timestamp(path) is True + assert should_attempt_download(path) is False + + def test_refuses_to_touch_a_real_logo(self, tmp_path): + real = tmp_path / "REAL.png" + Image.new("RGBA", (500, 500), (1, 2, 3, 255)).save(real) + before = real.read_bytes() + assert refresh_placeholder_timestamp(real) is False + assert real.read_bytes() == before + + def test_missing_file_is_not_an_error(self, tmp_path): + assert refresh_placeholder_timestamp(tmp_path / "nope.png") is False diff --git a/test/test_logo_helper.py b/test/test_logo_helper.py index 0b02af7d..cb5824b2 100644 --- a/test/test_logo_helper.py +++ b/test/test_logo_helper.py @@ -421,3 +421,76 @@ class TestSessionConfiguration: def test_user_agent_and_accept_headers(self, helper): assert helper.session.headers["User-Agent"] == "LEDMatrix-Common/1.0" assert helper.session.headers["Accept"] == "image/*" + + +class TestStalePlaceholderHandling: + """load_logo_with_download must not be fooled by a cached placeholder. + + A placeholder wears the real logo's filename, so both the file cache and + the in-memory cache can hold one and look like a hit. + """ + + def _placeholder(self, tmp_path, abbrev="COLL"): + from src.logo_downloader import LogoDownloader + assert LogoDownloader().create_placeholder_logo(abbrev, str(tmp_path)) + return tmp_path / f"{abbrev}.png" + + def _make_stale(self, path): + import time + from PIL.PngImagePlugin import PngInfo + from src.logo_downloader import PLACEHOLDER_MARKER, PLACEHOLDER_RETRY_SECONDS + metadata = PngInfo() + metadata.add_text(PLACEHOLDER_MARKER, str(time.time() - (PLACEHOLDER_RETRY_SECONDS + 60))) + with Image.open(path) as img: + img.copy().save(path, "PNG", pnginfo=metadata) + + def test_fresh_placeholder_is_served_without_a_download(self, helper, tmp_path): + path = self._placeholder(tmp_path) + with patch.object(LogoHelper, "_download_logo") as download: + assert helper.load_logo_with_download("COLL", path, "http://x/c.png") is not None + download.assert_not_called() + + def test_stale_placeholder_triggers_a_download(self, helper, tmp_path): + path = self._placeholder(tmp_path) + self._make_stale(path) + with patch.object(LogoHelper, "_download_logo") as download: + helper.load_logo_with_download("COLL", path, "http://x/c.png") + download.assert_called_once() + + def test_replacement_logo_is_not_masked_by_the_cached_placeholder(self, helper, tmp_path): + """The bug this guards: load_logo answers from cache before the disk. + + Without invalidation the freshly downloaded logo would not appear until + the process restarted. + """ + path = self._placeholder(tmp_path) + first = helper.load_logo_with_download("COLL", path, "http://x/c.png") + assert first is not None + + self._make_stale(path) + + def fake_download(_self, _url, file_path): + Image.new("RGB", (500, 500), (7, 8, 9)).save(file_path, format="PNG") + + with patch.object(LogoHelper, "_download_logo", fake_download): + second = helper.load_logo_with_download("COLL", path, "http://x/c.png") + + assert second is not None + from src.logo_downloader import is_placeholder_logo + assert is_placeholder_logo(path) is False + assert second.getpixel((0, 0))[:3] == (7, 8, 9) + + def test_failed_retry_restarts_the_back_off(self, helper, tmp_path): + """Otherwise a stale placeholder means a download attempt per call.""" + from src.logo_downloader import should_attempt_download + path = self._placeholder(tmp_path) + self._make_stale(path) + assert should_attempt_download(path) is True + + def boom(_self, _url, _file_path): + raise OSError("network down") + + with patch.object(LogoHelper, "_download_logo", boom): + helper.load_logo_with_download("COLL", path, "http://x/c.png") + + assert should_attempt_download(path) is False From 10da2f97fd8fc067c464b87af9a606e306684d50 Mon Sep 17 00:00:00 2001 From: Chuck <33324927+ChuckBuilds@users.noreply.github.com> Date: Wed, 2 Sep 2026 16:22:09 -0400 Subject: [PATCH 21/29] feat(sports): share the card helpers the eight scoreboards each carried (#513) Twenty methods were byte-identical in all eight scoreboards' game_renderer.py: the colour pickers, the scroll_card settings lookup, the date and time formatting, the favourite-team rules and the font-size grid snapping. Every fix to any of it had to be made eight times, and a new scoreboard began by copying them a ninth. src/common/sports_card.py holds them once. 245 lines leave each plugin. **Free functions, not a base class.** Every helper takes config/logger/fonts as arguments rather than reading them off an instance, so a plugin keeps its method and delegates the body -- call sites, signatures and override points are all untouched. Adoption is therefore per-function and reversible, which is what let all eight move with byte-identical renders. The bodies are the plugins' code moved, not rewritten. Two deliberate differences, both verified: - crisp_size takes the seven-plugin guard (`not desired`) rather than football's. They agree on every real input; the extra guard only stops a None size raising TypeError, so adopting it is a no-op for seven plugins and removes a crash path for the eighth. - schema_font_size caches per schema PATH. The plugins cached on their own class, which is the same distinction expressed without a class to hang it on; two plugins never share an entry. The path has to be passed in because the plugins derived it from __file__, and __file__ here is the core's. Verified before any plugin was touched: 534 differential comparisons of the helpers against afl's originals and 704 more of the font-sizing chain against all eight plugins' originals -- 1,238 comparisons, zero differences. Writing the constant tables by hand introduced two errors that check caught: the tie colour was (255,255,0) instead of the plugins' (255,200,0), and a "five_by_seven" alias that does not exist. Both are now taken from the plugins verbatim. 43 tests pin the contract, including the cases the plugins' own comments record as having bitten: a three-character string must not iterate into a colour, a shared font face must give up rather than guess an element, a font_size equal to the schema default carries no intent, and a bad timezone falls back to UTC rather than blanking the card. Full suite 3768 passed, 6 skipped. Claude-Session: https://claude.ai/code/session_014RRtqXDCnvnY6EQwhT5CV9 Co-authored-by: Claude Opus 5 (1M context) --- src/common/sports_card.py | 460 ++++++++++++++++++++++++++++++++++++++ test/test_sports_card.py | 195 ++++++++++++++++ 2 files changed, 655 insertions(+) create mode 100644 src/common/sports_card.py create mode 100644 test/test_sports_card.py diff --git a/src/common/sports_card.py b/src/common/sports_card.py new file mode 100644 index 00000000..5a1f8ebb --- /dev/null +++ b/src/common/sports_card.py @@ -0,0 +1,460 @@ +"""Card-drawing helpers shared by every sports scoreboard plugin. + +The eight scoreboards each carried byte-identical copies of the functions +below: the colour pickers, the settings lookup, the date and time formatting, +the favourite-team rules and the font-size grid snapping. One fix had to be +made eight times, and a new scoreboard started by copying them a ninth. + +Everything here is a **free function taking explicit arguments**, not a base +class. Adoption is therefore per-function and reversible: a plugin keeps its +method and delegates the body, so the call sites and the override points are +untouched. That is also why `config`, `logger` and `fonts` are parameters +rather than attributes -- the helper never reaches back into the caller. + +The bodies are the plugins' own code, moved rather than rewritten. The one +deliberate difference is `crisp_size`, which takes the seven-plugin guard +(`not desired`) instead of football's: they agree on every real input, and +the extra guard only stops a None size raising TypeError. +""" + +from datetime import datetime, timezone +from typing import Any, Dict, Optional, Tuple +from zoneinfo import ZoneInfo + +__all__ = [ + "ELEMENT_FOR_FONT", "FAVORITE_RESULT_COLOR_DEFAULTS", "FONT_NAME_ALIASES", + "FONT_PIXEL_GRID", "MONTH_ABBR", "WEEKDAY_ABBR", + "scroll_card_option", "element_color", "font_color", "coerce_rgb", + "score_color_for", "recent_score_color", "favorite_teams_for", + "side_is_favorite", "side_score", "favorite_result", + "card_tzinfo", "weekday_for", "format_game_date", "format_game_time", + "vs_text", "upcoming_center_mode", "crisp_size", "schema_font_size", + "resolve_font_size", "unshare_element_fonts", +] + +#: Which customization element owns each font key, for colour resolution. +ELEMENT_FOR_FONT: Dict[str, str] = { + "score": "score_text", + "time": "period_text", + "team": "team_name", + "status": "status_text", + "detail": "detail_text", + "rank": "rank_text", +} + +#: Fallback colours when favourite_result_colors is on but a slot is unset. +FAVORITE_RESULT_COLOR_DEFAULTS: Dict[str, Tuple[int, int, int]] = { + "win": (0, 255, 0), + "loss": (255, 0, 0), + "tie": (255, 200, 0), +} + +#: Family aliases the web UI may write, mapped to the shipped filename. +FONT_NAME_ALIASES: Dict[str, str] = { + "press_start": "PressStart2P-Regular.ttf", + "four_by_six": "4x6-font.ttf", +} + +#: Pixel grid each face renders crisply on. Off-grid sizes anti-alias, which +#: on an LED matrix is a dim lamp rather than a soft edge. +FONT_PIXEL_GRID: Dict[str, int] = { + "PressStart2P-Regular.ttf": 8, + "4x6-font.ttf": 7, +} + +MONTH_ABBR = ("Jan", "Feb", "Mar", "Apr", "May", "Jun", + "Jul", "Aug", "Sep", "Oct", "Nov", "Dec") +WEEKDAY_ABBR = ("Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun") + + +# --------------------------------------------------------------------------- +# Settings lookup +# --------------------------------------------------------------------------- + +def scroll_card_option(config: Optional[Dict[str, Any]], key: str, + default: Any = None) -> Any: + """Read one key from the scroll_card config block.""" + block = (config or {}).get("scroll_card") + if isinstance(block, dict) and block.get(key) is not None: + return block.get(key) + return default + + +def vs_text(config: Optional[Dict[str, Any]]) -> str: + """Separator drawn between the teams -- "VS", "@", "at", anything.""" + return str(scroll_card_option(config, "vs_text", "VS")) + + +def upcoming_center_mode(config: Optional[Dict[str, Any]]) -> str: + """Middle of an upcoming card: 'vs', 'date_time' or 'none'.""" + mode = str(scroll_card_option(config, "upcoming_center", "vs") or "vs").lower() + return mode if mode in ("vs", "date_time", "none") else "vs" + + +# --------------------------------------------------------------------------- +# Colour +# --------------------------------------------------------------------------- + +def element_color(config: Optional[Dict[str, Any]], element: str, + default: Tuple[int, int, int] = (255, 255, 255)): + """Per-element text colour from customization..text_color.""" + try: + cfg = (config or {}).get("customization", {}).get(element, {}) + value = cfg.get("text_color") + if isinstance(value, (list, tuple)) and len(value) == 3: + return tuple(max(0, min(255, int(c))) for c in value) + if isinstance(value, str) and value.startswith("#") and len(value) == 7: + return tuple(int(value[i:i + 2], 16) for i in (1, 3, 5)) + except (TypeError, ValueError): + pass + return default + + +def font_color(config: Optional[Dict[str, Any]], fonts: Optional[Dict[str, Any]], + font, default: Tuple[int, int, int] = (255, 255, 255)): + """Colour for whichever element owns this face. + + Matched on identity, and deliberately gives up when one object is + shared: the last-resort font path can hand the same face to several + keys, and there is no right answer for which element's colour that is. + White is what those draws used before, so ambiguity costs nothing. + """ + try: + fonts = fonts or {} + matches = [element for key, element in ELEMENT_FOR_FONT.items() + if fonts.get(key) is font] + if len(matches) == 1: + return element_color(config, matches[0], default) + except (AttributeError, TypeError): + pass + return default + + +def coerce_rgb(value, fallback): + """Turn a configured [R, G, B] list into a clamped (r, g, b) tuple.""" + # Checked before unpacking: a 3-character string ("123") would otherwise + # iterate into three digits and yield a colour rather than the fallback. + if not isinstance(value, (list, tuple)) or len(value) != 3: + return fallback + try: + r, g, b = (max(0, min(255, int(channel))) for channel in value) + except (TypeError, ValueError): + return fallback + return (r, g, b) + + +# --------------------------------------------------------------------------- +# Favourite teams +# --------------------------------------------------------------------------- + +def favorite_teams_for(config: Dict[str, Any], game: Dict[str, Any]) -> list: + """Favorite teams that apply to this game. + + Both sources are used. Games carry the league manager's *resolved* + favorites, which is the only place dynamic groups such as AP_TOP_25 + appear expanded; the config is read as well so an edit takes effect on + already-fetched games, and so hand-built game dicts (tests, other + callers) still work. + """ + favorites = list(game.get("favorite_teams") or []) + league_config = config.get(str(game.get("league", "") or "")) + if isinstance(league_config, dict): + favorites += list(league_config.get("favorite_teams") or []) + else: + favorites += list(config.get("favorite_teams") or []) + return favorites + + +def side_is_favorite(game: Dict[str, Any], side: str, favorites: set) -> bool: + """Is the home/away side of this game a favorite team? + + Reads both the flat (``home_abbr``) and nested (``home_team.abbrev``) + payload shapes, and matches on the ESPN id too, because a couple of + leagues (NRL) key favorites by id where abbreviations collide. + """ + candidates = [game.get(f"{side}_abbr"), game.get(f"{side}_id")] + team = game.get(f"{side}_team") + if isinstance(team, dict): + candidates += [team.get("abbrev"), team.get("abbreviation"), team.get("id")] + for value in candidates: + if value is not None and str(value).strip().upper() in favorites: + return True + return False + + +def side_score(game: Dict[str, Any], side: str) -> Optional[int]: + """Numeric score for one side, from either payload shape.""" + raw = None + team = game.get(f"{side}_team") + if isinstance(team, dict) and team.get("score") is not None: + raw = team.get("score") + if raw is None: + raw = game.get(f"{side}_score") + try: + return int(float(str(raw).strip())) + except (TypeError, ValueError): + return None + + +def favorite_result(config: Dict[str, Any], game: Dict[str, Any]) -> Optional[str]: + """Say how the favorite team did in a finished game. + + Returns 'win', 'loss' or 'tie', or None when there is no single team + to root for: no favorites configured, neither side is a favorite, or + *both* are -- a favorite-vs-favorite game has no losing side worth + flagging in red. Also None when the scores are not usable numbers. + """ + favorites = { + str(team).strip().upper() + for team in favorite_teams_for(config, game) + if str(team).strip() + } + if not favorites: + return None + + home_fav = side_is_favorite(game, "home", favorites) + away_fav = side_is_favorite(game, "away", favorites) + if home_fav == away_fav: + return None + + home_score = side_score(game, "home") + away_score = side_score(game, "away") + if home_score is None or away_score is None: + return None + + if home_score == away_score: + return "tie" + favorite_score, other_score = ( + (home_score, away_score) if home_fav else (away_score, home_score) + ) + return "win" if favorite_score > other_score else "loss" + + +def recent_score_color(config: Dict[str, Any], logger, game: Dict[str, Any], default): + """Fill color for a finished game's score, per favorite_result_colors.""" + try: + settings = (config.get("customization") or {}).get( + "favorite_result_colors" + ) or {} + if not settings.get("enabled", False): + return default + result = favorite_result(config, game) + if result is None: + return default + return coerce_rgb( + settings.get(f"{result}_color"), + FAVORITE_RESULT_COLOR_DEFAULTS[result], + ) + except Exception: + logger.debug("Could not resolve favorite result color", exc_info=True) + return default + + +def score_color_for(config: Dict[str, Any], logger, game: Dict[str, Any], + game_type: str, default=None): + """Fill color for a game card's score. Only finished games are tinted. + + The default is the configured score colour rather than a flat white, + so customization.score_text.text_color shows on games the favourite + tint does not apply to. The tint still wins where it applies. + """ + if default is None: + default = element_color(config, 'score_text') + if game_type != "recent": + return default + return recent_score_color(config, logger, game, default) + + +# --------------------------------------------------------------------------- +# Date and time +# --------------------------------------------------------------------------- + +def card_tzinfo(config: Optional[Dict[str, Any]], logger): + """Timezone for weekday/24h conversions; falls back to UTC.""" + configured = (config or {}).get("timezone") + if configured: + try: + return ZoneInfo(configured) + except (KeyError, ValueError, TypeError, OSError) as exc: + # KeyError covers ZoneInfoNotFoundError. A bad zone name in + # config should fall back to UTC, not blank the card. + logger.debug("Unusable timezone %r: %s", configured, exc) + return timezone.utc + + +def weekday_for(config: Optional[Dict[str, Any]], logger, + game: Optional[Dict]) -> str: + """Weekday abbreviation from the game's start time, or ''.""" + if not game: + return "" + raw = game.get("start_time_utc") or game.get("start_time") + if not raw: + return "" + try: + start = raw if isinstance(raw, datetime) else datetime.fromisoformat( + str(raw).replace("Z", "+00:00")) + return WEEKDAY_ABBR[start.astimezone(card_tzinfo(config, logger)).weekday()] + except (ValueError, TypeError): + return "" + + +def format_game_date(config: Optional[Dict[str, Any]], logger, date_text: str, + game: Optional[Dict] = None) -> str: + """Format an upcoming card's date per scroll_card.date_format.""" + raw = str(date_text or "").strip() + if not raw: + return "" + fmt = str(scroll_card_option(config, "date_format", "abbrev") or "abbrev") + if fmt == "numeric": + return raw + parts = raw.replace("-", "/").split("/") + if not (len(parts) >= 2 and parts[0].strip().isdigit() and parts[1].strip().isdigit()): + return raw + month, day = int(parts[0]), int(parts[1]) + if not 1 <= month <= 12: + return raw + name = MONTH_ABBR[month - 1] + if fmt == "numeric_day_first": + return f"{day}/{month}" + if fmt == "day_first": + return f"{day} {name}" + if fmt == "weekday": + weekday = weekday_for(config, logger, game) + return f"{weekday} {name} {day}" if weekday else f"{name} {day}" + return f"{name} {day}" + + +def format_game_time(config: Optional[Dict[str, Any]], time_text: str) -> str: + """Return the time as-is (12h) or converted to 24h.""" + raw = str(time_text or "").strip() + if not raw or str(scroll_card_option(config, "time_format", "12h")) != "24h": + return raw + cleaned = raw.upper().replace(" ", "") + meridiem = "AM" if cleaned.endswith("AM") else "PM" if cleaned.endswith("PM") else "" + if not meridiem: + return raw + try: + hh, _, mm = cleaned[:-2].partition(":") + hour, minute = int(hh), int(mm or 0) + except ValueError: + return raw + if not (0 <= hour <= 12 and 0 <= minute <= 59): + return raw + hour = hour % 12 + (12 if meridiem == "PM" else 0) + return f"{hour:02d}:{minute:02d}" + + +# --------------------------------------------------------------------------- +# Font sizing +# --------------------------------------------------------------------------- + +#: Per-schema caches, keyed by the schema's absolute path. Keyed rather than +#: global because each plugin declares its own defaults; keyed rather than +#: per-class because the helper has no class to hang it on. +_SCHEMA_FONT_SIZE_CACHE: Dict[str, Dict[str, int]] = {} + + +def crisp_size(font_file, desired, aliases=None, grid_table=None): + """Snap *desired* to the nearest size *font_file* renders crisply at. + + A face with no known grid is returned unchanged, so a user-supplied + font is never second-guessed. + + ``aliases`` and ``grid_table`` default to the shared tables; a plugin + that ships an extra face can pass its own without forking this. + """ + aliases = FONT_NAME_ALIASES if aliases is None else aliases + grid_table = FONT_PIXEL_GRID if grid_table is None else grid_table + font_file = aliases.get(font_file, font_file) + grid = grid_table.get(font_file) + if not grid or not desired or desired <= 0: + return desired + return max(grid, int(round(float(desired) / grid)) * grid) + + +def schema_font_size(schema_path: str, element_key) -> Optional[int]: + """The font_size this plugin's config_schema.json declares, or None. + + Cached per schema path. The plugins cached this on their own class; the + path is the same distinction expressed without one, so two plugins never + share an entry. + """ + if not element_key: + return None + cache = _SCHEMA_FONT_SIZE_CACHE.get(schema_path) + if cache is None: + cache = {} + try: + import json + with open(schema_path) as fh: + schema = json.load(fh) + props = (schema.get('properties', {}) + .get('customization', {}) + .get('properties', {})) + for key, spec in props.items(): + size = spec.get('properties', {}).get('font_size', {}).get('default') + if size is not None: + cache[key] = int(size) + except Exception: + cache = {} + _SCHEMA_FONT_SIZE_CACHE[schema_path] = cache + return cache.get(element_key) + + +def resolve_font_size(schema_path: str, element_config, element_key, + default_size, font_name, aliases=None, grid_table=None): + """Size to render at: the user's choice, or a grid-snapped default. + + A configured size counts as a real choice only when it differs from + the schema default. The web UI writes the whole schema default block + on every save, so "font_size == schema default" carries no intent and + would otherwise pin every install to an anti-aliased size forever. + """ + configured = (element_config or {}).get('font_size') + if configured is not None: + try: + configured = int(configured) + if configured != schema_font_size(schema_path, element_key): + return configured + except (TypeError, ValueError): + pass + return crisp_size(font_name, default_size, aliases, grid_table) + + +def unshare_element_fonts(logger, fonts): + """Give each colourable element its own face object. + + The colour a draw gets is resolved from the face it was handed, and + several of these loaders legitimately hand one object to more than one + element -- a size resolver that lands two elements on the same face, a + fallback that fills every key from one default, football's narrowing + step that deliberately shrinks the clock along with the score. Sharing + the object makes the element ambiguous and the colour unresolvable. + + Re-instantiating from the same path and size gives a distinct object + with identical metrics, so nothing about the rendering changes; only + the ability to tell two elements apart does. Faces that cannot be + rebuilt (a BDF loaded through freetype.Face, anything without a usable + path) are left shared, and their draws stay white as before. + """ + try: + from PIL import ImageFont as _IF + except ImportError: # pragma: no cover + return fonts + seen = {} + for key in ELEMENT_FOR_FONT: + font = fonts.get(key) + if font is None: + continue + if id(font) not in seen: + seen[id(font)] = key + continue + path, size = getattr(font, "path", None), getattr(font, "size", None) + if not path or not size: + continue + try: + fonts[key] = _IF.truetype(path, size) + except (OSError, ValueError, TypeError): + logger.debug( + "Could not un-share the %s face; it keeps the default colour", key) + return fonts diff --git a/test/test_sports_card.py b/test/test_sports_card.py new file mode 100644 index 00000000..8de04e47 --- /dev/null +++ b/test/test_sports_card.py @@ -0,0 +1,195 @@ +"""The card helpers the eight scoreboards now share. + +These bodies lived in eight byte-identical copies. Moving them here means one +fix reaches every scoreboard — and that a mistake does too, which is what this +file guards. Each case below is one the plugins' own code already handled; the +point is that it keeps handling it. + +The functions take ``config``/``logger``/``fonts`` as arguments rather than +reading them off an instance, so a plugin keeps its method and delegates the +body. That is what let all eight adopt this with byte-identical renders. +""" + +import logging +import json +import os + +import pytest + +from src.common import sports_card as C + + +@pytest.fixture +def log(): + return logging.getLogger("test_sports_card") + + +class TestSettingsLookup: + def test_reads_the_scroll_card_block(self): + cfg = {"scroll_card": {"vs_text": "@"}} + assert C.scroll_card_option(cfg, "vs_text", "VS") == "@" + + @pytest.mark.parametrize("cfg", [None, {}, {"scroll_card": None}, + {"scroll_card": {"vs_text": None}}]) + def test_missing_or_null_falls_back(self, cfg): + """A null in config means "unset", not "empty string".""" + assert C.scroll_card_option(cfg, "vs_text", "VS") == "VS" + + def test_upcoming_center_rejects_unknown_modes(self): + for bad in ("sideways", "", None, 7): + assert C.upcoming_center_mode({"scroll_card": {"upcoming_center": bad}}) == "vs" + assert C.upcoming_center_mode({"scroll_card": {"upcoming_center": "DATE_TIME"}}) == "date_time" + + +class TestColour: + def test_rgb_list_and_hex_both_work(self): + assert C.element_color({"customization": {"score_text": {"text_color": [1, 2, 3]}}}, + "score_text") == (1, 2, 3) + assert C.element_color({"customization": {"score_text": {"text_color": "#ff8000"}}}, + "score_text") == (255, 128, 0) + + @pytest.mark.parametrize("value", ["nope", "#fff", [1, 2], None, ["a", "b", "c"]]) + def test_unusable_colour_falls_back(self, value): + cfg = {"customization": {"score_text": {"text_color": value}}} + assert C.element_color(cfg, "score_text", (9, 9, 9)) == (9, 9, 9) + + def test_coerce_rgb_clamps_rather_than_rejecting(self): + assert C.coerce_rgb([300, -5, 20], (0, 0, 0)) == (255, 0, 20) + + def test_coerce_rgb_refuses_a_three_character_string(self): + """"123" would otherwise iterate into three digits and yield a colour.""" + assert C.coerce_rgb("123", (7, 7, 7)) == (7, 7, 7) + + def test_font_colour_is_resolved_by_identity(self): + a, b = object(), object() + cfg = {"customization": {"score_text": {"text_color": [4, 5, 6]}}} + assert C.font_color(cfg, {"score": a, "team": b}, a) == (4, 5, 6) + + def test_a_shared_face_gives_up_rather_than_guessing(self): + """One object used for two elements has no single right colour.""" + shared = object() + cfg = {"customization": {"score_text": {"text_color": [4, 5, 6]}}} + assert C.font_color(cfg, {"score": shared, "team": shared}, shared) == (255, 255, 255) + + +class TestFavourites: + GAME = {"home_abbr": "TB", "away_abbr": "NO", "home_score": "21", "away_score": "17"} + + def test_win_loss_and_tie(self): + cfg = {"favorite_teams": ["TB"]} + assert C.favorite_result(cfg, self.GAME) == "win" + assert C.favorite_result({"favorite_teams": ["NO"]}, self.GAME) == "loss" + tied = dict(self.GAME, home_score="3", away_score="3") + assert C.favorite_result(cfg, tied) == "tie" + + def test_no_verdict_without_exactly_one_favourite_side(self): + assert C.favorite_result({}, self.GAME) is None + assert C.favorite_result({"favorite_teams": ["TB", "NO"]}, self.GAME) is None + assert C.favorite_result({"favorite_teams": ["SEA"]}, self.GAME) is None + + def test_unusable_scores_give_no_verdict(self): + bad = dict(self.GAME, home_score="x") + assert C.favorite_result({"favorite_teams": ["TB"]}, bad) is None + + def test_nested_payload_shape_is_read_too(self): + game = {"home_team": {"abbrev": "TB", "score": 9}, + "away_team": {"abbrev": "NO", "score": 2}} + assert C.side_score(game, "home") == 9 + assert C.side_is_favorite(game, "home", {"TB"}) is True + + def test_matches_on_id_where_abbreviations_collide(self): + """NRL keys favourites by ESPN id; abbreviations are not unique there.""" + game = {"home_abbr": "SYD", "home_id": "4321"} + assert C.side_is_favorite(game, "home", {"4321"}) is True + + def test_game_and_config_favourites_are_both_used(self): + """Games carry resolved dynamic groups; config catches later edits.""" + game = dict(self.GAME, favorite_teams=["NO"], league="nfl") + assert set(C.favorite_teams_for({"nfl": {"favorite_teams": ["TB"]}}, game)) == {"NO", "TB"} + + +class TestDateAndTime: + def test_date_formats(self, log): + for fmt, want in [("abbrev", "Sep 5"), ("numeric", "9/5"), + ("day_first", "5 Sep"), ("numeric_day_first", "5/9")]: + cfg = {"scroll_card": {"date_format": fmt}} + assert C.format_game_date(cfg, log, "9/5") == want + + @pytest.mark.parametrize("raw", ["", "garbage", "13/40", "no/slash/here"]) + def test_unparseable_dates_pass_through(self, log, raw): + assert C.format_game_date({}, log, raw) == raw.strip() + + def test_24h_conversion(self): + cfg = {"scroll_card": {"time_format": "24h"}} + assert C.format_game_time(cfg, "7:30 PM") == "19:30" + assert C.format_game_time(cfg, "12:00 AM") == "00:00" + assert C.format_game_time(cfg, "12:15 PM") == "12:15" + + def test_12h_is_left_alone_and_junk_survives(self): + assert C.format_game_time({}, "7:30 PM") == "7:30 PM" + assert C.format_game_time({"scroll_card": {"time_format": "24h"}}, "soon") == "soon" + + def test_a_bad_timezone_falls_back_to_utc(self, log): + """A typo in config should not blank the card.""" + from datetime import timezone + assert C.card_tzinfo({"timezone": "Not/AZone"}, log) is timezone.utc + + +class TestFontSizing: + def test_snaps_to_the_faces_pixel_grid(self): + assert C.crisp_size("4x6-font.ttf", 6) == 7 # 7px grid + assert C.crisp_size("PressStart2P-Regular.ttf", 10) == 8 + assert C.crisp_size("PressStart2P-Regular.ttf", 13) == 16 + + def test_an_unknown_face_is_never_second_guessed(self): + assert C.crisp_size("SomeUserFont.ttf", 11) == 11 + + def test_aliases_resolve_before_the_grid_lookup(self): + assert C.crisp_size("four_by_six", 6) == C.crisp_size("4x6-font.ttf", 6) + + @pytest.mark.parametrize("desired", [0, -3, None]) + def test_unusable_sizes_pass_through_without_raising(self, desired): + """None reached this in the field; football's variant raised TypeError.""" + assert C.crisp_size("4x6-font.ttf", desired) == desired + + def test_schema_cache_is_keyed_per_schema_not_globally(self, tmp_path): + """Two plugins declaring different defaults must not share an answer.""" + a, b = tmp_path / "a.json", tmp_path / "b.json" + for path, size in ((a, 11), (b, 22)): + path.write_text(json.dumps({"properties": {"customization": {"properties": { + "score_text": {"properties": {"font_size": {"default": size}}}}}}})) + assert C.schema_font_size(str(a), "score_text") == 11 + assert C.schema_font_size(str(b), "score_text") == 22 + + def test_a_missing_schema_is_not_an_error(self, tmp_path): + assert C.schema_font_size(str(tmp_path / "nope.json"), "score_text") is None + + def test_a_configured_size_matching_the_schema_default_is_not_a_choice(self, tmp_path): + """The web UI writes the whole default block on every save, so + font_size == schema default carries no intent and must not pin the + install to an off-grid size forever.""" + schema = tmp_path / "s.json" + schema.write_text(json.dumps({"properties": {"customization": {"properties": { + "score_text": {"properties": {"font_size": {"default": 10}}}}}}})) + got = C.resolve_font_size(str(schema), {"font_size": 10}, "score_text", 10, + "PressStart2P-Regular.ttf") + assert got == 8, "a default-valued size should snap to the grid" + + def test_a_real_choice_wins(self, tmp_path): + schema = tmp_path / "s.json" + schema.write_text(json.dumps({"properties": {"customization": {"properties": { + "score_text": {"properties": {"font_size": {"default": 10}}}}}}})) + got = C.resolve_font_size(str(schema), {"font_size": 13}, "score_text", 10, + "PressStart2P-Regular.ttf") + assert got == 13, "an explicit size the user chose is not second-guessed" + + +class TestTables: + def test_every_font_key_maps_to_an_element(self): + assert set(C.ELEMENT_FOR_FONT) == {"score", "time", "team", "status", "detail", "rank"} + + def test_result_colours_cover_every_verdict(self): + assert set(C.FAVORITE_RESULT_COLOR_DEFAULTS) == {"win", "loss", "tie"} + + def test_month_and_weekday_tables_are_complete(self): + assert len(C.MONTH_ABBR) == 12 and len(C.WEEKDAY_ABBR) == 7 From 300cdaa250382fa69817dd0eae94f2f07a9abb30 Mon Sep 17 00:00:00 2001 From: Chuck <33324927+ChuckBuilds@users.noreply.github.com> Date: Wed, 2 Sep 2026 16:22:25 -0400 Subject: [PATCH 22/29] feat(sports): share the scroll-card geometry the scoreboards all duplicate (#514) * feat(sports): share the scroll-card geometry the scoreboards all duplicate The card helpers moved to src/common/sports_card.py, which shared the eight scoreboards' settings lookups. Their *geometry* stayed duplicated: nine methods deciding how wide the centre strip is, how much room each logo gets, and where an upcoming card's date and time land. Five were byte-identical in all eight plugins; the other four were identical in seven, each with a different single outlier. That shape is why this is a mixin and not free functions. Comparing executable ASTs against the eight plugins, 67 of the 70 method bodies are inherited unchanged and 3 become ordinary overrides -- baseball keeps its own _logo_slot_width and _draw_upcoming_game_status, hockey its own _upcoming_date_and_time. No per-sport branching goes inside the base. It deliberately has no __init__ and no state. The plugins' constructors differ six ways and none of it is worth unifying, so adoption is one line on the class statement plus deleting what now comes from here. Placed in src/common/ rather than src/base_classes/sports/ on purpose: importing that package pulls core.py -> DisplayManager -> rgbmatrix, and this is pure geometry that must not drag a hardware import into every plugin that uses it. It sits next to sports_card.py, which the same plugins already use. Only _SCORE_PROBE varies between plugins, so leagues that reach three digits a side override that one ClassVar; the four gap constants are identical everywhere. The tests drive the mixin through a host that provides exactly the surface the module docstring names and nothing else, so the mixin growing a new self.* dependency the plugins do not have fails the contract test rather than shipping. * fix(sports): reject non-finite card settings before they abort the render A center_gap of inf passes `isinstance(x, (int, float)) and x >= 0` unharmed and then raises OverflowError out of int(). The surrounding guards caught only (TypeError, ValueError), so it escaped and took the whole card render with it. The same holds for center_gap_ratio, the two clamp bounds, and layout offsets, where "inf" arrives as a string and float() is happy to produce it. Four of the five paths crashed; only a NaN ratio happened to survive, by accident of min/max rather than by design. This is pre-existing behaviour -- the bodies moved here verbatim from the eight plugins and every one of them has it today. Fixing it in the mixin fixes it in all eight at once, which is the argument for the mixin. Guarded with math.isfinite() before any int()/round(), falling back to the same defaults the finite paths already use, plus OverflowError added to the except clauses as a backstop. Ordinary settings are untouched: all 192 scroll-card renders (8 plugins x 8 panel sizes x 3 game types) stay byte-identical to pristine main. Found by CodeRabbit on #514 and confirmed by running it before fixing. --- src/common/sports_game_renderer.py | 261 +++++++++++++++++++++++++++++ test/test_sports_game_renderer.py | 258 ++++++++++++++++++++++++++++ 2 files changed, 519 insertions(+) create mode 100644 src/common/sports_game_renderer.py create mode 100644 test/test_sports_game_renderer.py diff --git a/src/common/sports_game_renderer.py b/src/common/sports_game_renderer.py new file mode 100644 index 00000000..d42faf3f --- /dev/null +++ b/src/common/sports_game_renderer.py @@ -0,0 +1,261 @@ +"""The scroll/Vegas card geometry the sports scoreboards all share. + +Eight scoreboards -- afl, baseball, basketball, football, hockey, lacrosse, +nrl and soccer -- each carried their own ``game_renderer.py``. After the card +helpers moved to ``src/common/sports_card.py`` the settings lookups were +shared, but the *geometry* was not: nine methods that decide how wide the +centre gap is, how much room a logo gets, and where an upcoming card's date +and time land were still eight separate copies. Five were byte-identical +across all eight plugins; the other four were identical in seven, with a +different single outlier each time. + +That last detail is why this is a mixin rather than free functions. There is +no per-sport branching to write -- baseball needs its own +``_draw_upcoming_game_status`` and ``_logo_slot_width``, hockey its own +``_upcoming_date_and_time``, football its own ``_score_reserve_width``, and +every one of those is an ordinary override. The seven that agree inherit and +say nothing. + +It is deliberately *only* a mixin: no ``__init__``, no state of its own. The +plugins' constructors differ in six ways and none of that difference is worth +unifying, so adoption is one line on the class statement plus deleting the +methods that now come from here. + +What a host class must provide +------------------------------ +Attributes: ``display_width``, ``display_height``, ``config``, ``fonts``, +``logger``, ``_team_rankings_cache``. + +Methods: ``_draw_text_with_outline(draw, text, position, font, fill=None, +outline_color=(0, 0, 0))`` -- the one hook whose body genuinely varies -- plus +the ``sports_card`` delegations ``_scroll_card_option``, +``_upcoming_center_mode``, ``_vs_text``, ``_element_color``, +``_format_game_date`` and ``_format_game_time``. +""" + +import math +from typing import ClassVar, Dict, Tuple + +from PIL import Image, ImageDraw + + +class SportsGameRendererMixin: + """Shared card geometry for the sports scoreboards. See module docstring.""" + + #: Centre strip as a fraction of card width, before clamping. + CENTER_GAP_RATIO: ClassVar[float] = 0.28 + #: Clamp floor for the derived centre gap. + CENTER_GAP_MIN_PX: ClassVar[int] = 22 + #: Clamp ceiling for the derived centre gap. + CENTER_GAP_MAX_PX: ClassVar[int] = 40 + #: Breathing room kept between the score and each logo. + _SCORE_LOGO_GUTTER_PX: ClassVar[int] = 4 + #: Widest score the centre strip must fit. Leagues that can reach three + #: digits a side override this with "000-000". + _SCORE_PROBE: ClassVar[str] = "00-00" + + # ---- geometry ------------------------------------------------------ + + # Non-finite settings are rejected before any int()/round(): "inf" reaches + # these from config as a float or a string, passes an `isinstance` plus + # `>= 0` check unharmed, and then raises OverflowError out of int() -- + # which the old `except (TypeError, ValueError)` did not catch, so it + # aborted the whole card render. Present in all eight plugins before this + # moved to the core; fixing it here fixes it in all eight. + + def _score_reserve_width(self) -> int: + """Centre strip the score actually needs, measured rather than assumed. + + The gap was derived from the card width alone (width x + CENTER_GAP_RATIO, clamped to CENTER_GAP_MAX_PX) while the score's size + comes from config and the element-style resolver. Nothing compared the + two, so any score wider than the clamp was drawn over the logos. + Measuring it keeps the strip wide enough for whatever font is in play. + """ + try: + probe = ImageDraw.Draw(Image.new("RGB", (4, 4))) + width = probe.textlength(self._SCORE_PROBE, font=self.fonts['score']) + return int(width) + 2 * self._SCORE_LOGO_GUTTER_PX + except Exception: + self.logger.debug("Score reserve measurement failed", exc_info=True) + return 0 + + def _center_gap_width(self) -> int: + """Width of the middle strip kept clear of logos. + + ``scroll_card.center_gap`` pins it outright; otherwise it scales with + the card width between the configurable min and max. 0 restores + edge-to-edge logos. + """ + configured = self._scroll_card_option("center_gap") + if (isinstance(configured, (int, float)) + and math.isfinite(configured) and configured >= 0): + return int(configured) + ratio = self._scroll_card_option("center_gap_ratio", self.CENTER_GAP_RATIO) + low = self._scroll_card_option("center_gap_min", self.CENTER_GAP_MIN_PX) + high = self._scroll_card_option("center_gap_max", self.CENTER_GAP_MAX_PX) + try: + ratio, low, high = float(ratio), float(low), float(high) + if not (math.isfinite(ratio) and math.isfinite(low) + and math.isfinite(high)): + return self.CENTER_GAP_MIN_PX + scaled = round(self.display_width * ratio) + derived = int(max(int(low), min(int(high), scaled))) + # A strip narrower than the score is the bug, not a style choice. + # An explicit ``center_gap`` is still honoured above, including 0. + return max(derived, self._score_reserve_width()) + except (TypeError, ValueError, OverflowError): + return self.CENTER_GAP_MIN_PX + + def _logo_slot_width(self) -> int: + """Per-side logo slot, leaving the center gap clear. + + No longer capped at display_height: the card is sized as two + full-height logos plus the measured gap, so what is left after the gap + is exactly the logo's share. The cap was what froze the logos at 46px + on the old flat 128px card. + """ + available = (self.display_width - self._center_gap_width()) // 2 + return max(8, available) + + def _logo_cache_key(self, name: str) -> str: + """Cache key scoped to the logo slot. + + One cache dict is shared by renderers built for different card widths, + so a logo sized for a wide slot must not be handed to a narrow one. + """ + return f"{name}@{self._logo_slot_width()}x{self.display_height}" + + def _layout_offset(self, element: str, axis: str, default: int = 0) -> int: + """X/Y nudge for one element, from customization.layout. + + Same block the full-screen scorebug reads (sports.py + _get_layout_offset), so a nudge configured in the web UI now moves + the element on the scroll/Vegas card too -- previously the schema + advertised these offsets but this renderer ignored them. + """ + try: + layout = (self.config or {}).get("customization", {}).get("layout", {}) + value = (layout.get(element) or {}).get(axis, default) + if isinstance(value, bool): + return default + if isinstance(value, (int, float)): + return int(value) if math.isfinite(value) else default + if isinstance(value, str): + parsed = float(value) + return int(parsed) if math.isfinite(parsed) else default + except (TypeError, ValueError, OverflowError): + pass + return default + + # ---- upcoming cards ------------------------------------------------ + + def _upcoming_date_and_time(self, game: Dict) -> Tuple[str, str]: + """(date, time) for an upcoming card, from the extractor's flat keys.""" + return ( + str(game.get("game_date", "") or ""), + str(game.get("game_time", "") or ""), + ) + + def _draw_upcoming_center(self, draw: "ImageDraw.ImageDraw", game: Dict) -> None: + """Draw the middle of an upcoming card. + + Never a score: an upcoming game has not started, so the extractor's + 0-0 is noise. Either the VS text (default), the date and time stacked, + or nothing at all. + """ + mode = self._upcoming_center_mode() + if mode == "none": + return + + if mode == "vs": + vs_text = self._vs_text() + if not vs_text: + return + vs_width = draw.textlength(vs_text, font=self.fonts['score']) + vs_x = (self.display_width - vs_width) // 2 + self._layout_offset('score', 'x_offset') + vs_y = (self.display_height // 2) - 3 + self._layout_offset('score', 'y_offset') + self._draw_text_with_outline( + draw, vs_text, (vs_x, vs_y), self.fonts['score'], + fill=self._element_color('score_text') + ) + return + + date_text, time_text = self._upcoming_date_and_time(game) + lines = [] + if self._scroll_card_option("show_date", True): + lines.append(self._format_game_date(date_text, game)) + if self._scroll_card_option("show_time", True): + lines.append(self._format_game_time(time_text)) + lines = [t for t in lines if t] + if not lines: + return + font = self.fonts.get('detail') or self.fonts['time'] + line_h = 7 + top = (self.display_height // 2) - (len(lines) * line_h) // 2 + top += self._layout_offset('score', 'y_offset') + for i, line in enumerate(lines): + width = draw.textlength(line, font=font) + x = (self.display_width - width) // 2 + self._layout_offset('score', 'x_offset') + self._draw_text_with_outline( + draw, line, (x, top + i * line_h), font, + fill=self._element_color('detail_text') + ) + + def _draw_upcoming_game_status(self, draw: "ImageDraw.ImageDraw", game: Dict) -> None: + """Draw the date and time around an upcoming card. + + Time top and date bottom by default; scroll_card.swap_date_time puts + the date on top instead. Skipped when the pair is stacked in the + middle, which would otherwise print them twice. + """ + if self._upcoming_center_mode() == "date_time": + return + + date_raw, time_raw = self._upcoming_date_and_time(game) + date_text = (self._format_game_date(date_raw, game) + if self._scroll_card_option("show_date", True) else "") + time_text = (self._format_game_time(time_raw) + if self._scroll_card_option("show_time", True) else "") + + if self._scroll_card_option("swap_date_time", False): + top_text, top_el, bottom_text, bottom_el = ( + date_text, 'date', time_text, 'time') + top_font = self.fonts.get('detail') or self.fonts['time'] + bottom_font = self.fonts['time'] + top_color, bottom_color = 'detail_text', 'period_text' + else: + top_text, top_el, bottom_text, bottom_el = ( + time_text, 'time', date_text, 'date') + top_font = self.fonts['time'] + bottom_font = self.fonts.get('detail') or self.fonts['time'] + top_color, bottom_color = 'period_text', 'detail_text' + + if top_text: + top_width = draw.textlength(top_text, font=top_font) + top_x = (self.display_width - top_width) // 2 + self._layout_offset(top_el, 'x_offset') + top_y = 1 + self._layout_offset(top_el, 'y_offset') + self._draw_text_with_outline( + draw, top_text, (top_x, top_y), top_font, + fill=self._element_color(top_color) + ) + + if bottom_text: + bottom_width = draw.textlength(bottom_text, font=bottom_font) + bottom_x = ((self.display_width - bottom_width) // 2 + + self._layout_offset(bottom_el, 'x_offset')) + # Measured, not a fixed -7: the detail font is 6px in most plugins + # but 10px in soccer and nrl, where "Sep 19" ran past the card. + ink_bottom = draw.textbbox((0, 0), bottom_text, font=bottom_font)[3] + bottom_y = (max(0, self.display_height - ink_bottom - 1) + + self._layout_offset(bottom_el, 'y_offset')) + self._draw_text_with_outline( + draw, bottom_text, (bottom_x, bottom_y), bottom_font, + fill=self._element_color(bottom_color) + ) + + # ---- rankings ------------------------------------------------------ + + def set_rankings_cache(self, rankings: Dict[str, int]) -> None: + """Set the team rankings cache for display.""" + self._team_rankings_cache = rankings diff --git a/test/test_sports_game_renderer.py b/test/test_sports_game_renderer.py new file mode 100644 index 00000000..50590005 --- /dev/null +++ b/test/test_sports_game_renderer.py @@ -0,0 +1,258 @@ +"""The shared card geometry, exercised against a host that supplies only what +the mixin's contract names. + +The point of these is the contract, not the arithmetic. The mixin reaches for +``display_width``, ``fonts``, ``config`` and six ``sports_card`` delegations +through ``self``, and the eight plugins are what actually provide them. A +stub host that provides exactly the documented surface and nothing else is +what catches the mixin quietly growing a dependency the plugins do not have. +""" + +import pytest +from PIL import Image, ImageDraw, ImageFont + +from src.common.sports_game_renderer import SportsGameRendererMixin + + +class Host(SportsGameRendererMixin): + """The documented contract, and not one attribute more.""" + + def __init__(self, width=128, height=32, config=None, scroll=None): + self.display_width = width + self.display_height = height + self.config = config or {} + self.logger = _Logger() + self._team_rankings_cache = {} + self._scroll = scroll or {} + font = ImageFont.load_default() + self.fonts = {'score': font, 'time': font, 'detail': font} + self.drawn = [] + + # -- the six sports_card delegations the mixin calls -- + def _scroll_card_option(self, key, default=None): + return self._scroll.get(key, default) + + def _upcoming_center_mode(self): + return self._scroll.get('upcoming_center', 'vs') + + def _vs_text(self): + return self._scroll.get('vs_text', 'VS') + + def _element_color(self, element): + return (255, 255, 255) + + def _format_game_date(self, raw, game): + return raw + + def _format_game_time(self, raw): + return raw + + # -- the one hook whose body genuinely varies per plugin -- + def _draw_text_with_outline(self, draw, text, position, font, + fill=None, outline_color=(0, 0, 0)): + self.drawn.append((text, position)) + + +class _Logger: + def debug(self, *a, **k): + pass + + +def _draw(): + return ImageDraw.Draw(Image.new("RGB", (256, 64))) + + +class TestCenterGap: + def test_explicit_center_gap_wins_outright(self): + assert Host(scroll={'center_gap': 31})._center_gap_width() == 31 + + def test_explicit_zero_restores_edge_to_edge_logos(self): + # 0 is a real setting, not a falsy miss -- the guard is `>= 0`. + assert Host(scroll={'center_gap': 0})._center_gap_width() == 0 + + def test_otherwise_it_scales_with_card_width_within_the_clamp(self): + h = Host(width=512) + # 512 * 0.28 = 143, clamped to the 40px ceiling. + assert h._center_gap_width() >= h.CENTER_GAP_MIN_PX + + def test_the_gap_never_ends_up_narrower_than_the_score(self): + # This is the bug the measurement exists to prevent: a derived gap + # smaller than the rendered score drew the score over the logos. + h = Host(width=64) + assert h._center_gap_width() >= h._score_reserve_width() + + def test_a_junk_ratio_falls_back_to_the_floor(self): + h = Host(scroll={'center_gap_ratio': 'wide'}) + assert h._center_gap_width() == h.CENTER_GAP_MIN_PX + + +class TestNonFiniteSettings: + """inf reaches int() and raises OverflowError, which the old + `except (TypeError, ValueError)` did not catch -- so one bad config value + aborted the entire card render rather than falling back.""" + + @pytest.mark.parametrize("bad", [float("inf"), float("-inf")]) + def test_a_non_finite_center_gap_falls_back(self, bad): + h = Host(scroll={'center_gap': bad}) + assert h._center_gap_width() >= h.CENTER_GAP_MIN_PX + + @pytest.mark.parametrize("bad", [float("inf"), float("-inf"), float("nan")]) + def test_a_non_finite_ratio_falls_back_to_the_floor(self, bad): + h = Host(scroll={'center_gap_ratio': bad}) + assert h._center_gap_width() == h.CENTER_GAP_MIN_PX + + @pytest.mark.parametrize("bad", [float("inf"), float("-inf")]) + def test_non_finite_clamp_bounds_fall_back(self, bad): + h = Host(scroll={'center_gap_min': bad, 'center_gap_max': bad}) + assert h._center_gap_width() == h.CENTER_GAP_MIN_PX + + @pytest.mark.parametrize("bad", [float("inf"), float("-inf"), "inf", "-inf", "nan"]) + def test_a_non_finite_layout_offset_gives_the_default(self, bad): + cfg = {'customization': {'layout': {'score': {'x_offset': bad}}}} + assert Host(config=cfg)._layout_offset('score', 'x_offset', 7) == 7 + + def test_a_finite_value_is_still_honoured(self): + # The guard must not swallow ordinary settings. + assert Host(scroll={'center_gap': 31})._center_gap_width() == 31 + cfg = {'customization': {'layout': {'score': {'x_offset': -3}}}} + assert Host(config=cfg)._layout_offset('score', 'x_offset', 7) == -3 + + +class TestScoreReserve: + def test_it_measures_the_probe_plus_both_gutters(self): + h = Host() + assert h._score_reserve_width() > 2 * h._SCORE_LOGO_GUTTER_PX + + def test_a_wider_probe_reserves_more(self): + class Wide(Host): + _SCORE_PROBE = "000-000" + assert Wide()._score_reserve_width() > Host()._score_reserve_width() + + def test_an_unmeasurable_font_reserves_nothing_rather_than_raising(self): + h = Host() + h.fonts = {'score': object()} + assert h._score_reserve_width() == 0 + + +class TestLogoSlot: + def test_the_slot_is_what_is_left_after_the_gap(self): + h = Host(width=128, scroll={'center_gap': 40}) + assert h._logo_slot_width() == 44 + + def test_it_is_not_capped_at_the_card_height(self): + # The height cap is what froze logos at 46px on a 128px card. + h = Host(width=512, height=32, scroll={'center_gap': 40}) + assert h._logo_slot_width() > h.display_height + + def test_a_gap_wider_than_the_card_still_leaves_a_usable_slot(self): + assert Host(width=64, scroll={'center_gap': 200})._logo_slot_width() == 8 + + def test_the_cache_key_is_scoped_to_the_slot_not_just_the_name(self): + # One cache dict is shared by renderers of different card widths. + narrow = Host(width=64, scroll={'center_gap': 20})._logo_cache_key("NYY") + wide = Host(width=256, scroll={'center_gap': 20})._logo_cache_key("NYY") + assert narrow != wide + + +class TestLayoutOffset: + def _host(self, value): + return Host(config={'customization': {'layout': {'score': {'x_offset': value}}}}) + + def test_it_reads_the_same_block_as_the_full_screen_scorebug(self): + assert self._host(5)._layout_offset('score', 'x_offset') == 5 + + def test_a_string_offset_from_the_web_ui_is_coerced(self): + assert self._host("-3")._layout_offset('score', 'x_offset') == -3 + + def test_a_bool_is_not_silently_an_offset_of_one(self): + assert self._host(True)._layout_offset('score', 'x_offset', 9) == 9 + + @pytest.mark.parametrize("cfg", [{}, {'customization': {}}, + {'customization': {'layout': {}}}]) + def test_a_missing_block_gives_the_default(self, cfg): + assert Host(config=cfg)._layout_offset('score', 'x_offset', 7) == 7 + + def test_an_unparseable_offset_gives_the_default(self): + assert self._host("left")._layout_offset('score', 'x_offset', 4) == 4 + + +class TestUpcomingCenter: + def test_none_draws_nothing(self): + h = Host(scroll={'upcoming_center': 'none'}) + h._draw_upcoming_center(_draw(), {}) + assert h.drawn == [] + + def test_vs_is_the_default_and_never_a_score(self): + # An upcoming game has not started; the extractor's 0-0 is noise. + h = Host() + h._draw_upcoming_center(_draw(), {'home_score': 0, 'away_score': 0}) + assert [t for t, _ in h.drawn] == ['VS'] + + def test_an_empty_vs_string_draws_nothing(self): + h = Host(scroll={'vs_text': ''}) + h._draw_upcoming_center(_draw(), {}) + assert h.drawn == [] + + def test_date_time_stacks_both_lines(self): + h = Host(scroll={'upcoming_center': 'date_time'}) + h._draw_upcoming_center(_draw(), {'game_date': 'Sep 19', 'game_time': '7:00 PM'}) + assert [t for t, _ in h.drawn] == ['Sep 19', '7:00 PM'] + + def test_hiding_both_lines_draws_nothing(self): + h = Host(scroll={'upcoming_center': 'date_time', + 'show_date': False, 'show_time': False}) + h._draw_upcoming_center(_draw(), {'game_date': 'Sep 19', 'game_time': '7:00 PM'}) + assert h.drawn == [] + + +class TestUpcomingStatus: + def test_time_on_top_and_date_below_by_default(self): + h = Host() + h._draw_upcoming_game_status(_draw(), {'game_date': 'Sep 19', 'game_time': '7:00 PM'}) + assert [t for t, _ in h.drawn] == ['7:00 PM', 'Sep 19'] + + def test_swap_date_time_reverses_them(self): + h = Host(scroll={'swap_date_time': True}) + h._draw_upcoming_game_status(_draw(), {'game_date': 'Sep 19', 'game_time': '7:00 PM'}) + assert [t for t, _ in h.drawn] == ['Sep 19', '7:00 PM'] + + def test_it_stays_out_of_the_way_when_the_centre_already_has_them(self): + # Otherwise the date and time print twice on the same card. + h = Host(scroll={'upcoming_center': 'date_time'}) + h._draw_upcoming_game_status(_draw(), {'game_date': 'Sep 19', 'game_time': '7:00 PM'}) + assert h.drawn == [] + + def test_the_bottom_line_is_measured_not_a_fixed_offset(self): + # A fixed -7 ran "Sep 19" past the card wherever the detail font is + # 10px rather than 6px. + h = Host(height=64) + h._draw_upcoming_game_status(_draw(), {'game_date': 'Sep 19', 'game_time': '7:00 PM'}) + bottom_y = h.drawn[1][1][1] + assert 0 <= bottom_y < h.display_height + + +class TestRankings: + def test_set_rankings_cache_replaces_the_cache(self): + h = Host() + h.set_rankings_cache({'UGA': 1}) + assert h._team_rankings_cache == {'UGA': 1} + + +class TestContract: + def test_the_mixin_carries_no_state_of_its_own(self): + # Adoption must be one line on the class statement; a mixin with an + # __init__ would force eight constructors to cooperate. + assert '__init__' not in SportsGameRendererMixin.__dict__ + + def test_a_host_providing_the_documented_surface_needs_nothing_more(self): + # Host defines exactly what the module docstring names. If the mixin + # grows a new self.* dependency, this is what fails. + h = Host() + h._center_gap_width() + h._logo_slot_width() + h._logo_cache_key("X") + h._layout_offset('score', 'x_offset') + h._upcoming_date_and_time({}) + h._draw_upcoming_center(_draw(), {}) + h._draw_upcoming_game_status(_draw(), {}) + h.set_rankings_cache({}) From cb0545ecb3dff6b4cf2a015ce013ccbe74ade324 Mon Sep 17 00:00:00 2001 From: Chuck <33324927+ChuckBuilds@users.noreply.github.com> Date: Thu, 3 Sep 2026 10:32:08 -0400 Subject: [PATCH 23/29] release: report 3.3.0, so the version the gate reads matches the tag (#516) v3.3.0 is tagged, but src/__init__.py still says "3.2.0" -- and that string, not the git tag, is what the compatibility gate compares (store_manager.py: `from src import __version__ as core_version`). The effect is that every plugin flooring at 3.3.0 is refused on a device running 3.3.0. Checked against the real gate and the real manifest: core __version__ reported to the gate : 3.2.0 hockey floor : 3.3.0 verdict : REFUSE "supports LEDMatrix >=3.3.0, but this system is running 3.2.0" That is all eight sports scoreboards plus calendar 1.2.3, and it would read as a broken plugin store rather than a stale constant. The TRUSTWORTHY_FLOOR escape hatch does not cover this: it exempts cores reporting below 2.0.0 as "unknown rather than old", and 3.2.0 is above it, so the number is trusted and compared. This is the same slip as v3.1.0, which was tagged six weeks before its version string was bumped and shipped __version__ = "1.0.0" -- the reason that escape hatch exists at all. With the bump, the same gate call returns ALLOW for hockey at both the sports_card and sports_shared floors, and for calendar 1.2.3. CHANGELOG.md gains the 3.3.0 section. That file is what plugin authors read to decide which release to floor on, so it records the three new modules -- src/common/sports_card.py, sports_game_renderer.py and sports_shared.py -- against this version, along with the three traps in adopting the mixins. No test pinned the old literal; the ones that care monkeypatch __version__. 135 compatibility tests pass. --- CHANGELOG.md | 75 +++++++++++++++++++++++++++++++++++++++++++++++++ src/__init__.py | 2 +- 2 files changed, 76 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index db5ea778..ab90b8e6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,81 @@ release that ships it. accepts both, but the store flags the old spelling as deprecated (`store_manager.py`) and only the new one is in `schema/manifest_schema.json`. +## 3.3.0 + +**The release the sports scoreboards floor on to delete their bundled copies.** +3.2.0 shipped the unified sports library and made `ledmatrix_min_version` +enforceable; this ships the last three shared modules and completes the store +gate, so a scoreboard can now floor here and carry no fallback at all. + +New modules a plugin may import via `src.*` and floor on 3.3.0 for: + +- `src/common/sports_card.py` — settings, colour, font and date helpers for a + scoreboard's `game_renderer.py`. Free functions taking `config`/`fonts` + explicitly, so nothing about the caller's class is assumed. +- `src/common/sports_game_renderer.py` — `SportsGameRendererMixin`: scroll/Vegas + card geometry (centre gap, logo slot and cache key, layout offsets, the + upcoming-card date and time layout). No `__init__` and no state, so adoption + is one line on the class statement. +- `src/common/sports_shared.py` — `SportsCoreSharedMixin`, + `SportsLiveSharedMixin`, `SportsRecentSharedMixin`: the `sports.py` bodies + byte-identical in all eight lineage-sharing scoreboards. + +All three sit under `src/common/` rather than `src/base_classes/sports/`, +deliberately: importing that package pulls `core.py` → `DisplayManager` → +`rgbmatrix`, and these are pure logic. Plugins importing them must not acquire a +hardware dependency. + +Three notes for anyone adopting `sports_shared`: + +- `SportsRecentSharedMixin` defines `__init__`. Its bare `super()` binds to the + mixin, so it reaches the host only when the mixin is listed **first** in the + bases. Reversing that order silently skips the host constructor. +- Methods that resolve the plugin's `config_schema.json` use `_plugin_dir()`, + which walks the MRO rather than reading `__file__` — `__file__` is now + `src/common/`. It walks because `SportsCore` is an ABC: a subclass built with + `type(name, bases, ns)` reports `__module__` as `"abc"`. +- `_get_timezone`, `_extract_game_details` and `_fetch_data` are byte-identical + across the eight but stay in the plugins. The first binds a per-plugin + timezone module whose contents differ; the other two are the abstract stubs + that define the sport. + +**The store's compatibility gate is now on every registry-managed route.** 3.2.0 +gated `install_plugin`. This release gates the git-pull update path and +`install_from_url`, so a plugin whose floor the core cannot meet is refused +after download with no partial directory left behind. + +### Fixed + +- **ESPN 403s.** `site.api` began rejecting the User-Agent strings this repo + sent on 2026-08-04; every shared-data-source scoreboard returned + `403 Forbidden`. Requests now send an identifying token with a project URL — + browser-style strings and bare custom tokens are both refused. +- **Low-memory boards becoming unreachable under load** while still answering + pings and serving the web UI. Fetched payloads are released after delivery + rather than pinned on the completed request for up to an hour, malloc arenas + are capped, and log volume and SD writes are reduced. Available memory is now + reported in Tools diagnostics. +- **A failed logo download pinning a team to a grey box**: the placeholder was + written under the real logo's filename, so later attempts found a file and + reported success without retrying. +- **A plugin enabled but never loaded is retried** rather than staying absent + with `error = null`. +- `ttl` now controls cache expiry; abandoned cache writes no longer leave temp + files; one cache-cleanup thread per directory rather than per manager. +- Odds are fetched for the games displayed, not the whole schedule window, and a + stalled ESPN no longer stalls the whole plugin update. +- Array-item secrets are no longer wiped or logged. +- A restored backup matches the device it was taken from. +- The web UI reports the real error instead of "unknown", rejects non-finite + JSON numbers, and stops checkbox groups posting back hidden options. + +### Added + +- `display.hardware.orientation` for panels mounted upside down. +- Vegas keeps live content in the ticker rather than being preempted by it. +- Schemas can label enum dropdown options. + ## 3.2.0 **The first release shipping the unified sports library.** This is the version diff --git a/src/__init__.py b/src/__init__.py index f8fa0daa..c73f8102 100644 --- a/src/__init__.py +++ b/src/__init__.py @@ -4,5 +4,5 @@ LEDMatrix Display System Core source package for the LED Matrix Display project. """ -__version__ = "3.2.0" +__version__ = "3.3.0" From bc2dbf382488f99d8298dde5d5a3fd5403c43363 Mon Sep 17 00:00:00 2001 From: Chuck <33324927+ChuckBuilds@users.noreply.github.com> Date: Thu, 3 Sep 2026 13:29:37 -0400 Subject: [PATCH 24/29] feat(sports): share the sports.py surface that is identical in all eight scoreboards (#515) * feat(sports): share the sports.py surface that is identical in all eight Nine scoreboards ship their own sports.py -- 41,326 lines. Comparing executable ASTs across the eight that share a lineage, 48 method bodies are byte-identical in every one: 1,007 lines carried eight times, so 8,056 lines that must be edited eight times to fix once. They are the parts with no sport in them: the selection and rotation engine (_round_robin_favorites, _favorites_first, _compose_selection, _check_ranking_coverage, _game_divisions, _normalise_quality), the font/colour/ date subsystem (_scale_headline_fonts, _scorebug_font, _resolve_font_size, _format_game_date, _font_color), and the switch-mode upcoming card (_draw_upcoming_center_switch). Nothing here knows what an inning is. Mixins rather than free functions: every one of these reads host state, so rewriting 48 bodies into free functions would be a rewrite rather than a move, and it is the move that keeps the renders identical. Three of the 48 are deliberately left in the plugins, because a byte-identical body is not automatically safe to move: - _get_timezone calls resolve_timezone, imported from a per-plugin module (hockey_timezone, soccer_timezone, ...). All eight of those differ -- each carries its own _WRITEBACK_FIXED_IN -- so hoisting the caller would silently bind every scoreboard to one plugin's copy. - _extract_game_details and _fetch_data are @abstractmethod stubs. They are the sport contract; satisfying them from a mixin would let a plugin instantiate without implementing its own sport. _resolve_font_path went the other way: a module-level function, identical in all eight, that _scale_headline_fonts needs -- so it is inlined here. _schema_font_size needed a real change rather than a move. It located the plugin's config_schema.json with __file__, which here is src/common/, so the load failed silently, the cache stayed empty and every element fell back to an unsnapped size -- measured at 81% anti-aliased edges on a panel that should be pixel-crisp. It now recovers the plugin directory from the instance. Note that type(self).__module__ alone is not enough: SportsCore is an ABC, so a subclass built with type(name, bases, ns) -- which the plugins' own tests do -- reports its module as "abc". _plugin_dir walks the MRO past those synthetic classes to the first module sitting beside a config_schema.json. Worth recording: the 176 harness renders did NOT catch that regression. The plugin's own test_fonts_are_crisp.py did. Renders alone were not a sufficient gate here. Not merged with src/common/sports_card.py despite fourteen same-named twins. Only five are provably equivalent by source comparison; the other nine differ in ways inspection cannot settle, and a wrong guess silently changes what every scoreboard draws. That merge needs differential testing and is its own change. * fix(sports): declare the constants the mixins read, and test the contract CodeRabbit found _QUALITY_CHOICES and _RANKING_COVERAGE_SECONDS read by _normalise_quality and _check_ranking_coverage but never defined on a mixin. Confirmed: both are declared by all eight scoreboards, so nothing fails today -- it would only have bitten the ninth plugin to adopt this, at runtime, mid-render. Both are identical everywhere, so they get defaults here; each plugin's own copy still shadows them. Auditing for others showed those two were the only ones, but also that the host-contract docstring was substantially incomplete: it listed 21 attributes where the mixins actually read about 40, and omitted five hooks (_is_favorite_game, _is_game_really_over, _is_ranked_game, _passes_other_filters, _get_timezone). The section is now derived from that audit rather than remembered. test_sports_shared.py covers what is genuinely new, not the moved bodies: - The contract itself. It parses the module for every ALL-CAPS `self.X` the mixins read and asserts each is defined, so the next omission fails here rather than in the field. - _plugin_dir, the only new logic in the move. Including the case that made it necessary: SportsCore is an ABC, so a subclass built with type(name, bases, ns) -- which the plugins' own tests build -- reports __module__ as "abc". The test asserts that precondition before asserting the walk steps past it. - The three SportsLive bodies. Hockey and lacrosse disable live mode in their harness fixtures, so the 176 renders never reach this path; testing the mixin directly means coverage no longer depends on which plugin happens to have a unit test. Two of those tests pin things that would otherwise be silently undone. SportsRecentSharedMixin does carry an __init__ -- SportsRecent.__init__ was one of the 48 byte-identical bodies. Its bare super() binds to where it is defined, now the mixin, so it only reaches the host because the mixin is listed first in the bases. One test proves the chain runs; the next proves that reversing the order silently skips the host constructor. * fix(sports): drop three unused imports and let the matcher narrow Codacy flagged five issues on this file. Three are unused imports: math, abc.abstractmethod and zoneinfo.ZoneInfo. Nothing in the module references any of them -- the timezone work goes through pytz, and the @abstractmethod mention in the module docstring describes the two stubs that deliberately stayed behind in each plugin, not anything declared here. pyflakes agrees; all three are removed. The other two are "team_in is not callable" on the round-robin favourite matcher. That call is already guarded by callable(), so it cannot raise at runtime, but callable() is not a narrowing construct a static analyser follows: the name still carries the None from getattr's default. Normalising a non-callable to None and branching on `is None` gives the analyser a test it does understand, and keeps the guard. Behaviour is unchanged. _round_robin_favorites has no test coverage, so I exercised it directly on both paths -- a host with _team_in (id matching, the NRL case) and one without (abbreviation matching) -- across limits 1 to 4, and the selections are identical before and after. A host whose _team_in is present but not callable still falls back to abbreviation matching rather than raising. test/test_sports_shared.py: 27 passed. The 9 collection errors under `pytest test/ -k sport` reproduce identically on the unmodified branch and are not from this change. Co-Authored-By: Claude Opus 5 --------- Co-authored-by: Claude Opus 5 --- src/common/sports_shared.py | 1253 +++++++++++++++++++++++++++++++++++ test/test_sports_shared.py | 321 +++++++++ 2 files changed, 1574 insertions(+) create mode 100644 src/common/sports_shared.py create mode 100644 test/test_sports_shared.py diff --git a/src/common/sports_shared.py b/src/common/sports_shared.py new file mode 100644 index 00000000..f427817d --- /dev/null +++ b/src/common/sports_shared.py @@ -0,0 +1,1253 @@ +"""The sports.py surface that is byte-identical in every scoreboard. + +Nine plugins ship their own ``sports.py`` -- 41,326 lines in total. Comparing +executable ASTs across the eight that share a lineage, 48 method bodies are +byte-identical in all eight: 1,007 lines carried in eight copies, so 8,056 +duplicated lines that must be edited eight times to fix once. + +They are the parts with no sport in them. The selection and rotation engine +(``_round_robin_favorites``, ``_favorites_first``, ``_compose_selection``, +``_check_ranking_coverage``, ``_game_divisions``, ``_normalise_quality``), the +font/colour/date subsystem (``_scale_headline_fonts``, ``_scorebug_font``, +``_resolve_font_size``, ``_format_game_date``, ``_font_color``), and the +switch-mode upcoming card (``_draw_upcoming_center_switch``). Nothing here knows +what an inning or a possession is. + +Mixins rather than free functions, because every one of these reads host state +-- ``self.config``, ``self.fonts``, ``self.logger``, ``self.display_width``. +Rewriting 48 bodies into free functions would be a rewrite, not a move; as +mixins the bodies move verbatim, which is what keeps the renders identical. + +THREE OF THE 48 ARE DELIBERATELY LEFT BEHIND +-------------------------------------------- +Byte-identical bodies are not automatically safe to move: a body can bind a +module-level name that differs per plugin, and then it only *looks* the same. + +- ``_get_timezone`` calls ``resolve_timezone``, imported from a per-plugin + module (``hockey_timezone``, ``soccer_timezone``, ...). All eight of those + differ -- each carries its own ``_WRITEBACK_FIXED_IN`` version -- so moving + the caller here would silently bind every scoreboard to one plugin's copy. +- ``_extract_game_details`` and ``_fetch_data`` are ``@abstractmethod`` stubs. + They are the sport-specific contract; satisfying them from a mixin would let a + plugin instantiate without implementing its own sport. + +``_resolve_font_path`` went the other way: it is a module-level function in +sports.py rather than a method, identical in all eight, and ``_scale_headline_fonts`` +needs it -- so it is inlined below rather than left behind. + +WHAT A HOST MUST PROVIDE +------------------------ +Enumerated by walking every ``self.`` the mixins read and subtracting what +they define, so this list is derived rather than remembered. Everything below is +supplied by all eight scoreboards today. + +State: ``config``, ``fonts``, ``logger``, ``display_width``, ``display_height``, +``display_manager``, ``league``, ``sport``, ``mode_config``, ``session``, +``headers``, ``favorite_teams``, ``games_list``, ``current_game_index``, +``last_game_switch``, ``last_update``, ``update_interval``, +``no_data_interval``, ``game_display_duration``, ``stale_game_timeout``, +``other_games_min_quality``, ``schedule_lookback_days``, +``schedule_lookahead_days``, ``game_update_timestamps``, +``_zero_clock_timestamps``, ``_logo_cache``, ``_selection_pools``, +``_ranking_coverage_logged_at``, ``_empty_live_streak``, ``_last_warning_time``, +``_score_grew``. + +Methods that stay per-plugin, because they are not identical across the eight +(or, for ``_get_timezone``, because they bind per-plugin modules): +``_get_layout_offset``, ``_by_importance``, ``_other_games_window``, +``_upcoming_date_and_time_text``, ``_extract_game_details_common``, +``_load_division_team_ids``, ``_get_timezone``, ``_is_favorite_game``, +``_is_game_really_over``, ``_is_ranked_game``, ``_passes_other_filters``. + +Of the fourteen shared class constants, thirteen are identical everywhere and +live here. Only ``_SCORE_PROBE_TEXT`` varies -- afl and basketball reach three digits +a side and override it, the same two that override ``_SCORE_PROBE`` on +``SportsGameRendererMixin``. + +DELIBERATELY NOT MERGED WITH sports_card +---------------------------------------- +Fourteen of these have same-named twins in ``src/common/sports_card.py``, which +the scoreboards' ``game_renderer.py`` already uses. They are NOT wired together +here. Only five are provably equivalent by source comparison; the other nine +differ in ways inspection cannot settle, and a wrong guess silently changes what +every scoreboard draws. Merging them needs differential testing against both +implementations, and is left for its own change. +""" + +from __future__ import annotations + +import logging +import os +import sys +import time +from datetime import datetime, timedelta, timezone +from typing import Any, ClassVar, Dict, List, Optional, Tuple + +import pytz +import requests +from PIL import Image, ImageDraw, ImageFont + +logger = logging.getLogger(__name__) + +# How long a live mode may stretch its poll interval when nothing is happening, +# and the streak lengths that earn each stretch. Identical in all eight plugins. +_IDLE_SHORT_STREAK = 6 +_IDLE_SHORT_FACTOR = 2 +_IDLE_LONG_STREAK = 24 +_IDLE_LONG_FACTOR = 6 +_DEFAULT_LIVE_IDLE_MAX_SECONDS = 900 + + +def _resolve_font_path(path: str) -> str: + """Resolve a bundled font path without depending on the process cwd. + + These fonts ship with the LEDMatrix core, and every call site here named + them relative to the working directory. That holds under the packaged + systemd unit, whose WorkingDirectory is the install root, and breaks + everywhere else -- the plugin safety harness, a manual run from $HOME, a + unit file written without WorkingDirectory. The failure is quiet: the + load raises, the caller falls back, and the scoreboard renders in PIL's + default face instead of the pixel font it was laid out for. + + Resolution order matches the core's own resolver: the path as given + first, so behaviour is unchanged wherever it already worked and a + configured absolute path is returned untouched, then the core install + root, then the original string so callers still raise and fall back + exactly as they do today. + """ + if os.path.exists(path): + return path + try: + import src.font_manager as _core_fonts + + # The core grew this resolver in ChuckBuilds/LEDMatrix#425. Use it + # when it is there so both repos stay on one definition of "install + # root"; older cores fall through to the equivalent derivation below. + manager = getattr(_core_fonts, "FontManager", None) + resolver = getattr(manager, "_resolve_asset_path", None) + if resolver is not None: + resolved = resolver(path) + if resolved and os.path.exists(resolved): + return resolved + root = os.path.dirname(os.path.dirname(os.path.abspath(_core_fonts.__file__))) + candidate = os.path.join(root, path) + if os.path.exists(candidate): + return candidate + except (ImportError, AttributeError, OSError): + # No core on the path (standalone tooling), a core laid out + # differently, or an unreadable install. Returning the original keeps + # the caller's existing fallback intact. + return path + return path + + +class SportsCoreSharedMixin: + """The ``SportsCore`` bodies identical in all eight scoreboards.""" + + #: Design height the font scale is expressed against. + _FONT_DESIGN_HEIGHT: ClassVar[int] = 32 + #: Fraction of the centre strip a score may grow into. + _SCORE_GROWTH_BUDGET: ClassVar[float] = 0.65 + #: Widest score the scorebug sizes itself to hold. Leagues that reach three + #: digits a side override this with "000-000". + _SCORE_PROBE_TEXT: ClassVar[str] = "00-00" + #: Whether this sport's scorebug draws a score at all. + _DRAWS_SCORE: ClassVar[bool] = False + #: Fallback (font, size) rungs for a score that will not fit. + _NARROW_SCORE_RUNGS: ClassVar[Tuple[Tuple[str, int], ...]] = ( + ("4x6-font.ttf", 14), ("4x6-font.ttf", 7)) + #: Hard ceiling on score growth, in multiples of the configured size. + _SCORE_MAX_GROWTH: ClassVar[int] = 2 + #: Which colour setting owns each font slot. + _ELEMENT_FOR_FONT: ClassVar[Dict[str, str]] = { + "score": "score_text", "time": "period_text", "team": "team_text", + "detail": "detail_text", "status": "status_text"} + #: Default tint for a favourite team's finished game. + FAVORITE_RESULT_COLOR_DEFAULTS: ClassVar[Dict[str, Tuple[int, int, int]]] = { + "win": (0, 255, 0), "loss": (255, 0, 0), "tie": (255, 200, 0)} + _MONTH_ABBR: ClassVar[Tuple[str, ...]] = ( + "Jan", "Feb", "Mar", "Apr", "May", "Jun", + "Jul", "Aug", "Sep", "Oct", "Nov", "Dec") + _WEEKDAY_ABBR: ClassVar[Tuple[str, ...]] = ( + "Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun") + #: Bitmap fonts snap to their native pixel grid. + _FONT_PIXEL_GRID: ClassVar[Dict[str, int]] = { + "PressStart2P-Regular.ttf": 8, "4x6-font.ttf": 7} + _FONT_NAME_ALIASES: ClassVar[Dict[str, str]] = { + "press_start": "PressStart2P-Regular.ttf", "four_by_six": "4x6-font.ttf"} + #: Accepted values for the other-games quality filter. + _QUALITY_CHOICES: ClassVar[frozenset] = frozenset({"any", "ranked"}) + #: How long to stay quiet between ranking-coverage warnings. + _RANKING_COVERAGE_SECONDS: ClassVar[int] = 60 * 60 + + def _get_season_schedule_dates(self) -> tuple[str, str]: + return "", "" + + def _draw_scorebug_layout(self, game: Dict, force_clear: bool = False) -> None: + """Placeholder draw method - subclasses should override.""" + # This base method will be simple, subclasses provide specifics + try: + img = Image.new("RGB", (self.display_width, self.display_height), (0, 0, 0)) + draw = ImageDraw.Draw(img) + status = game.get("status_text", "N/A") + self._draw_text_with_outline(draw, status, (2, 2), self.fonts["status"]) + self.display_manager.image.paste(img, (0, 0)) + # Don't call update_display here, let subclasses handle it after drawing + except Exception as e: + self.logger.error( + f"Error in base _draw_scorebug_layout: {e}", exc_info=True + ) + + @classmethod + def _crisp_size(cls, font_file, desired): + """Snap *desired* to the nearest size *font_file* renders crisply at. + + A face with no known grid is returned unchanged, so a user-supplied + font is never second-guessed. + """ + font_file = cls._FONT_NAME_ALIASES.get(font_file, font_file) + grid = cls._FONT_PIXEL_GRID.get(font_file) + if not grid or not desired or desired <= 0: + return desired + return max(grid, int(round(float(desired) / grid)) * grid) + + def _plugin_dir(self) -> Optional[str]: + """Directory of the plugin that defines this class. + + In sports.py these methods could just use ``__file__``. Here that is + src/common/, so the plugin's own directory has to be recovered from the + instance. ``type(self).__module__`` alone is not enough: SportsCore is + an ABC, so a subclass built with ``type(name, bases, ns)`` -- which the + plugins' own tests do -- reports its module as "abc". Walking the MRO + steps past those synthetic classes to the first one whose module sits + next to a config_schema.json, which is the real plugin. + """ + for cls in type(self).__mro__: + module = sys.modules.get(getattr(cls, "__module__", ""), None) + path = getattr(module, "__file__", None) + if not path: + continue + directory = os.path.dirname(os.path.abspath(path)) + if os.path.isfile(os.path.join(directory, "config_schema.json")): + return directory + return None + + def _schema_font_size(self, element_key): + """The font_size this plugin's config_schema.json declares, or None.""" + if not element_key: + return None + cache = getattr(self.__class__, '_SCHEMA_FONT_SIZES', None) + if cache is None: + cache = {} + try: + import json + directory = self._plugin_dir() + if directory is None: + raise FileNotFoundError("no config_schema.json on the MRO") + with open(os.path.join(directory, 'config_schema.json')) as fh: + schema = json.load(fh) + props = (schema.get('properties', {}) + .get('customization', {}) + .get('properties', {})) + for key, spec in props.items(): + size = spec.get('properties', {}).get('font_size', {}).get('default') + if size is not None: + cache[key] = int(size) + except Exception: + cache = {} + self.__class__._SCHEMA_FONT_SIZES = cache + return cache.get(element_key) + + def _resolve_font_size(self, element_config, element_key, default_size, font_name): + """Size to render at: the user's choice, or a grid-snapped default. + + A configured size counts as a real choice only when it differs from + the schema default. The web UI writes the whole schema default block + on every save, so "font_size == schema default" carries no intent and + would otherwise pin every install to an anti-aliased size forever. + """ + configured = (element_config or {}).get('font_size') + if configured is not None: + try: + configured = int(configured) + if configured != self._schema_font_size(element_key): + return configured + except (TypeError, ValueError): + pass + return self._crisp_size(font_name, default_size) + + def _card_option(self, key: str, default: Any = None) -> Any: + """Read one key from the scroll_card config block.""" + block = (self.config or {}).get("scroll_card") + if isinstance(block, dict) and block.get(key) is not None: + return block.get(key) + return default + + def _switch_upcoming_center(self) -> str: + """Middle of the full-screen upcoming scorebug: 'vs', 'date_time' or 'none'.""" + mode = str(self._card_option("switch_upcoming_center", "date_time") + or "date_time").lower() + if mode == "inherit": + mode = str(self._card_option("upcoming_center", "vs") or "vs").lower() + return mode if mode in ("vs", "date_time", "none") else "date_time" + + def _vs_text(self) -> str: + """Separator drawn between the teams -- "VS", "@", "at", anything.""" + return str(self._card_option("vs_text", "VS")) + + def _switch_date_format(self) -> str: + """Date style for the full-screen scorebug. + + Its own key rather than the shared ``date_format`` because the two + displays disagree about the default: the scroll card renders "Sep 19" + while _extract_game_details_common emits "9/19", the "numeric" style, + and this scorebug has always drawn it. Reading the shared key here + would restyle every existing panel on update -- and "leave it alone + when unset" is not available, because the core merges schema defaults + into the config on every load, so the key is never actually unset. + "inherit" opts into the scroll and Vegas setting. + """ + fmt = str(self._card_option("switch_date_format", "numeric") or "numeric").lower() + if fmt == "inherit": + fmt = str(self._card_option("date_format", "abbrev") or "abbrev").lower() + return fmt + + def _format_game_date(self, date_text: str, game: Optional[Dict] = None) -> str: + """Format an upcoming date per scroll_card.switch_date_format.""" + raw = str(date_text or "").strip() + if not raw: + return raw + fmt = self._switch_date_format() + if fmt == "numeric": + return raw + parts = raw.replace("-", "/").split("/") + if not (len(parts) >= 2 and parts[0].strip().isdigit() and parts[1].strip().isdigit()): + return raw + month, day = int(parts[0]), int(parts[1]) + if not 1 <= month <= 12: + return raw + name = self._MONTH_ABBR[month - 1] + if fmt == "numeric_day_first": + return f"{day}/{month}" + if fmt == "day_first": + return f"{day} {name}" + if fmt == "weekday": + weekday = self._weekday_for(game) + return f"{weekday} {name} {day}" if weekday else f"{name} {day}" + return f"{name} {day}" + + def _weekday_for(self, game: Optional[Dict]) -> str: + """Weekday abbreviation from the game's start time, or ''.""" + if not game: + return "" + raw = game.get("start_time_utc") or game.get("start_time") + if not raw: + return "" + try: + start = raw if isinstance(raw, datetime) else datetime.fromisoformat( + str(raw).replace("Z", "+00:00")) + return self._WEEKDAY_ABBR[start.astimezone(self._get_timezone()).weekday()] + except (ValueError, TypeError, OverflowError): + return "" + + def _format_game_time(self, time_text: str) -> str: + """Return the time as-is (12h) or converted to 24h.""" + raw = str(time_text or "").strip() + if not raw or str(self._card_option("time_format", "12h")) != "24h": + return raw + cleaned = raw.upper().replace(" ", "") + meridiem = "AM" if cleaned.endswith("AM") else "PM" if cleaned.endswith("PM") else "" + if not meridiem: + return raw + try: + hh, _, mm = cleaned[:-2].partition(":") + hour, minute = int(hh), int(mm or 0) + except ValueError: + return raw + if not (0 <= hour <= 12 and 0 <= minute <= 59): + return raw + hour = hour % 12 + (12 if meridiem == "PM" else 0) + return f"{hour:02d}:{minute:02d}" + + def _scorebug_font(self, draw, text: str, width: int): + """The face this scorebug draws its date and time in. + + Always the "time" face, which is what this display has used for both + rows for as long as it has existed: changing switch_upcoming_center + moves the two lines around, it is not meant to restyle them, so the + type stays put while the placement changes. + + The single exception is text that cannot fit the panel at all. Only + the "weekday" date can do that -- "Fri Sep 19" measures 80px in an + 8px face, on a board 64px wide -- and the smaller "detail" face is a + better answer there than running off both edges. Every other date and + time this display can produce fits, so in practice the face never + changes; it is a floor, not a style rule. + """ + font = self.fonts["time"] + if not text: + return font + try: + if draw.textlength(text, font=font) + 2 <= width: + return font + except (TypeError, ValueError): + return font + return self.fonts.get("detail") or font + + def _draw_upcoming_center_switch(self, draw, game: Dict, center_y: int, + game_date: str, game_time: str, + display_width: Optional[int] = None, + display_height: Optional[int] = None, + date_element: str = 'date', + time_element: str = 'time', + second_row_y_offset: bool = True) -> bool: + """Draw the middle of the full-screen upcoming scorebug. + + Returns True when the header above it ("Next Game", or the league + name) should still be drawn. In "vs" and "none" the date and time move + out of the middle and into the top and bottom slots, mirroring the + scroll card -- and the top slot is where the header used to be, so the + caller drops it. + + ``date_element``/``time_element``/``second_row_y_offset`` exist only so + the layout-offset keys stay exactly what each plugin's schema + advertises; this sport's defaults are the common case. + """ + width = self.display_width if display_width is None else display_width + height = self.display_height if display_height is None else display_height + mode = self._switch_upcoming_center() + date_text, time_text = self._upcoming_date_and_time_text( + game_date, game_time, game) + swapped = bool(self._card_option("swap_date_time", False)) + + if mode == "date_time": + # Historically the date sat at center_y - 7 with the time 9px + # under it, and the time's row was derived from the date's, so a + # date y_offset moved the pair. Both still hold; the slots only + # trade places when swap_date_time is set, and hiding one line + # leaves the other where it was rather than re-centering the stack. + slots = [(time_element, time_text), (date_element, date_text)] if swapped \ + else [(date_element, date_text), (time_element, time_text)] + row_y = center_y - 7 + for index, (element, text) in enumerate(slots): + if index: + row_y += 9 + if second_row_y_offset: + row_y += self._get_layout_offset(element, 'y_offset') + else: + row_y += self._get_layout_offset(element, 'y_offset') + if not text: + continue + font = self._scorebug_font(draw, text, width) + text_width = draw.textlength(text, font=font) + text_x = ((width - text_width) // 2 + + self._get_layout_offset(element, 'x_offset')) + self._draw_text_with_outline( + draw, text, (text_x, row_y), font + ) + return True + + if mode == "vs": + vs_text = self._vs_text() + if vs_text: + vs_width = draw.textlength(vs_text, font=self.fonts["score"]) + vs_x = ((width - vs_width) // 2 + + self._get_layout_offset('score', 'x_offset')) + vs_y = (center_y - 3 + + self._get_layout_offset('score', 'y_offset')) + self._draw_text_with_outline( + draw, vs_text, (vs_x, vs_y), self.fonts["score"] + ) + + # "vs" and "none" both push the date and time out to the edges, time + # on top unless swap_date_time says otherwise -- the same order the + # scroll card uses. + if swapped: + top_element, top_text = date_element, date_text + bottom_element, bottom_text = time_element, time_text + else: + top_element, top_text = time_element, time_text + bottom_element, bottom_text = date_element, date_text + + if top_text: + top_font = self._scorebug_font(draw, top_text, width) + top_width = draw.textlength(top_text, font=top_font) + top_x = ((width - top_width) // 2 + + self._get_layout_offset(top_element, 'x_offset')) + top_y = 1 + self._get_layout_offset(top_element, 'y_offset') + self._draw_text_with_outline( + draw, top_text, (top_x, top_y), top_font + ) + if bottom_text: + bottom_font = self._scorebug_font(draw, bottom_text, width) + bottom_width = draw.textlength(bottom_text, font=bottom_font) + bottom_x = ((width - bottom_width) // 2 + + self._get_layout_offset(bottom_element, 'x_offset')) + # Measured, not a fixed offset: the detail font is 6px in most + # plugins and 10px in soccer and nrl, where a fixed -7 ran the + # date off the panel. + ink_bottom = draw.textbbox((0, 0), bottom_text, font=bottom_font)[3] + bottom_y = (max(0, height - ink_bottom - 1) + + self._get_layout_offset(bottom_element, 'y_offset')) + self._draw_text_with_outline( + draw, bottom_text, (bottom_x, bottom_y), bottom_font + ) + return False + + @staticmethod + def _coerce_rgb(value, fallback): + """Turn a configured [R, G, B] list into a clamped (r, g, b) tuple.""" + # Checked before unpacking: a 3-character string ("123") would otherwise + # iterate into three digits and yield a colour rather than the fallback. + if not isinstance(value, (list, tuple)) or len(value) != 3: + return fallback + try: + r, g, b = (max(0, min(255, int(channel))) for channel in value) + except (TypeError, ValueError): + return fallback + return (r, g, b) + + @staticmethod + def _side_is_favorite(game: Dict, side: str, favorites: set) -> bool: + """Is the home/away side of this game a favorite team? + + Both the abbreviation and the ESPN id are checked, because a couple of + leagues (NRL) match favorites by id where abbreviations collide. + """ + for key in (f"{side}_abbr", f"{side}_id"): + value = game.get(key) + if value is not None and str(value).strip().upper() in favorites: + return True + return False + + def _favorite_result(self, game: Dict) -> Optional[str]: + """Say how the favorite team did in a finished game. + + Returns 'win', 'loss' or 'tie', or None when there is no single team + to root for: no favorites configured, neither side is a favorite, or + *both* are -- a favorite-vs-favorite game has no losing side worth + flagging in red. Also None when the scores are not usable numbers. + """ + favorites = getattr(self, "favorite_teams", None) or [] + favorites = {str(team).strip().upper() for team in favorites if str(team).strip()} + if not favorites: + return None + + home_fav = self._side_is_favorite(game, "home", favorites) + away_fav = self._side_is_favorite(game, "away", favorites) + if home_fav == away_fav: + return None + + try: + # int(float(...)) to match GameRenderer._side_score exactly -- the + # two paths must agree on what counts as a usable score. + home_score = int(float(str(game.get("home_score", "")).strip())) + away_score = int(float(str(game.get("away_score", "")).strip())) + except (TypeError, ValueError): + return None + + if home_score == away_score: + return "tie" + favorite_score, other_score = ( + (home_score, away_score) if home_fav else (away_score, home_score) + ) + return "win" if favorite_score > other_score else "loss" + + def _recent_score_color(self, game: Dict, default): + """Fill color for a finished game's score, per favorite_result_colors.""" + try: + settings = (self.config.get("customization") or {}).get( + "favorite_result_colors" + ) or {} + if not settings.get("enabled", False): + return default + result = self._favorite_result(game) + if result is None: + return default + return self._coerce_rgb( + settings.get(f"{result}_color"), + self.FAVORITE_RESULT_COLOR_DEFAULTS[result], + ) + except Exception: + self.logger.debug( + "Could not resolve favorite result color", exc_info=True + ) + return default + + def _score_font_size(self) -> int: + """Pixel size the score is currently drawn at.""" + return getattr(self.fonts.get("score"), "size", 8) or 8 + + def _time_font_size(self) -> int: + """Pixel size the clock/date face is currently drawn at.""" + return getattr(self.fonts.get("time"), "size", 8) or 8 + + def _user_chose_size(self, element_key: str) -> bool: + """True when customization..font_size is a real choice. + + The web UI's save flow writes the whole schema default block into + config.json on every save, whether or not the user touched that + section, so a size merely being PRESENT carries no intent. Only one + that differs from the schema default does. + """ + element = (self.config.get('customization', {}) or {}).get(element_key) or {} + configured = element.get('font_size') + if configured is None: + return False + try: + return int(configured) != self._schema_font_size(element_key) + except (TypeError, ValueError): + return False + + def _grid_scaled_size(self, font): + """(path, grid, size) for *font* regrown to this panel's height. + + None when the panel is at or below the design height (nothing to do), + or when the face has no known pixel grid -- a user-supplied font is + never second-guessed, because we do not know what it renders crisply + at. + """ + path = getattr(font, 'path', None) + base = getattr(font, 'size', None) + if not base or not isinstance(path, str): + return None + face = os.path.basename(path) + grid = self._FONT_PIXEL_GRID.get(self._FONT_NAME_ALIASES.get(face, face)) + if not grid: + return None + scale = float(self.display_height) / (self._FONT_DESIGN_HEIGHT or 32) + if scale <= 1.0: + return None + return path, grid, max(int(base), int(self._crisp_size(face, base * scale))) + + def _scale_headline_fonts(self, fonts): + """Grow the score with the panel, and hold the clock/date below it. + + The score is the one number the card exists to show, and it was the + only element not sized from the panel. Worse, it was not even bigger + than its neighbours: PressStart2P renders crisply on an 8px grid, so + the 10px default snapped to 8 -- the same 8 the period/clock above it + and the game date below it are drawn at. Three lines of identical + type, none of them the headline, which is what makes the score read as + lower priority than the time and the date rather than the point of the + card. + + So the score is sized from display_height and snapped to its face's + pixel grid (off the grid FreeType anti-aliases the strokes, and on an + LED matrix a part-lit pixel is a dim lamp rather than a soft edge), + then stepped back down that grid until it fits its share of the width. + The clock/date face is regrown the same way but held at least one grid + step below the score, so the ranking between them is visible rather + than implied. + + A 32-tall panel scales by exactly 1.0 and is left byte-identical; a + size the user set explicitly is never overridden. + """ + self._score_grew = False + if not self._DRAWS_SCORE: + # No score on this screen, so none of the sizing below is for it. + return fonts + try: + scaled = None if self._user_chose_size('score_text') else \ + self._grid_scaled_size(fonts.get('score')) + if scaled is not None: + path, grid, size = scaled + base = getattr(fonts['score'], 'size', size) or size + size = min(size, base * self._SCORE_MAX_GROWTH) + probe = ImageDraw.Draw(Image.new('RGB', (4, 4))) + budget = self.display_width * self._SCORE_GROWTH_BUDGET + # Measured from a fixed five-character score rather than the + # live one, so the card does not resize when a side passes 9. + while size > grid: + if probe.textlength( + self._SCORE_PROBE_TEXT, + font=ImageFont.truetype(path, size)) <= budget: + break + size -= grid + if size != getattr(fonts['score'], 'size', size): + fonts['score'] = ImageFont.truetype(path, size) + self._score_grew = True + + if not self._score_grew and not self._user_chose_size('score_text') \ + and self.display_height > self._FONT_DESIGN_HEIGHT: + # PressStart2P could not grow inside the budget -- its next crisp + # size is simply too wide for this panel. A narrower face still + # can: 4x6-font at 14px is nearly as tall as PressStart2P at 16 + # and about half as wide. This matters beyond the score itself, + # because a card whose score never grows never reserves the + # centre either, so its logos stay at the uncapped 1.5x and are + # drawn straight over the score -- which is what a three-digit + # basketball score does on a 128x64 board. + probe = ImageDraw.Draw(Image.new('RGB', (4, 4))) + budget = self.display_width * self._SCORE_GROWTH_BUDGET + current = getattr(fonts.get('score'), 'size', 0) or 0 + for _name, _size in self._NARROW_SCORE_RUNGS: + if _size <= current: + continue + _path = _resolve_font_path(f"assets/fonts/{_name}") + _candidate = ImageFont.truetype(_path, _size) + if probe.textlength(self._SCORE_PROBE_TEXT, + font=_candidate) <= budget: + fonts['score'] = _candidate + self._score_grew = True + break + + scaled = None if self._user_chose_size('period_text') else \ + self._grid_scaled_size(fonts.get('time')) + if scaled is not None: + path, grid, size = scaled + ceiling = getattr(fonts.get('score'), 'size', 0) or 0 + if ceiling and size >= ceiling: + size = max(grid, ceiling - grid) + if size != getattr(fonts['time'], 'size', size): + fonts['time'] = ImageFont.truetype(path, size) + except Exception: + self.logger.debug("Headline font scaling skipped", exc_info=True) + return fonts + + def _element_color(self, element: str, default: Tuple[int, int, int] = (255, 255, 255)): + """Per-element text colour from customization..text_color.""" + try: + cfg = (self.config or {}).get("customization", {}).get(element, {}) + value = cfg.get("text_color") + if isinstance(value, (list, tuple)) and len(value) == 3: + return tuple(max(0, min(255, int(c))) for c in value) + if isinstance(value, str) and value.startswith("#") and len(value) == 7: + return tuple(int(value[i:i + 2], 16) for i in (1, 3, 5)) + except (TypeError, ValueError): + pass + return default + + def _unshare_element_fonts(self, fonts): + """Give each colourable element its own face object. + + The colour a draw gets is resolved from the face it was handed, and + several of these loaders legitimately hand one object to more than one + element -- a size resolver that lands two elements on the same face, a + fallback that fills every key from one default, football's narrowing + step that deliberately shrinks the clock along with the score. Sharing + the object makes the element ambiguous and the colour unresolvable. + + Re-instantiating from the same path and size gives a distinct object + with identical metrics, so nothing about the rendering changes; only + the ability to tell two elements apart does. Faces that cannot be + rebuilt (a BDF loaded through freetype.Face, anything without a usable + path) are left shared, and their draws stay white as before. + """ + try: + from PIL import ImageFont as _IF + except ImportError: # pragma: no cover + return fonts + seen = {} + for key in self._ELEMENT_FOR_FONT: + font = fonts.get(key) + if font is None: + continue + if id(font) not in seen: + seen[id(font)] = key + continue + path, size = getattr(font, "path", None), getattr(font, "size", None) + if not path or not size: + continue + try: + fonts[key] = _IF.truetype(path, size) + except (OSError, ValueError, TypeError): + self.logger.debug( + "Could not un-share the %s face; it keeps the default colour", key) + return fonts + + def _font_color(self, font, default: Tuple[int, int, int] = (255, 255, 255)): + """Colour for whichever element owns this face. + + Matched on identity, and deliberately gives up when one object is + shared: the last-resort font path can hand the same face to several + keys, and there is no right answer for which element's colour that is. + White is what those draws used before, so ambiguity costs nothing. + """ + try: + fonts = getattr(self, "fonts", None) or {} + matches = [element for key, element in self._ELEMENT_FOR_FONT.items() + if fonts.get(key) is font] + if len(matches) == 1: + return self._element_color(matches[0], default) + except (AttributeError, TypeError): + pass + return default + + def _draw_text_with_outline( + self, draw, text, position, font, fill=None, outline_color=(0, 0, 0) + ): + """Draw text with a black outline for better readability.""" + # Disable anti-aliasing: pixel/bitmap fonts (e.g. PressStart2P) get + # anti-aliased into dim partial-lit pixels on a 1:1 LED matrix, muddying + # glyphs. 1-bit mode keeps strokes crisp. + # Defaults to the configured colour for whichever element owns + # this face rather than to white, so customization..text_color + # reaches every draw. The schema has offered those pickers all along + # and they only ever changed the font. An explicit fill still wins: + # the odds colours and the favourite-result score tint mean something + # the palette does not. + if fill is None: + fill = self._font_color(font) + draw.fontmode = "1" + x, y = position + for dx, dy in [ + (-1, -1), + (-1, 0), + (-1, 1), + (0, -1), + (0, 1), + (1, -1), + (1, 0), + (1, 1), + ]: + draw.text((x + dx, y + dy), text, font=font, fill=outline_color) + draw.text((x, y), text, font=font, fill=fill) + + def _should_log(self, warning_type: str, cooldown: int = 60) -> bool: + """Check if we should log a warning based on cooldown period.""" + current_time = time.time() + if current_time - self._last_warning_time > cooldown: + self._last_warning_time = current_time + return True + return False + + def _get_weeks_data(self) -> Optional[Dict]: + """ + Get partial data for immediate display while background fetch is in progress. + This fetches current/recent games only for quick response. + """ + try: + # Fetch current week and next few days for immediate display + now = datetime.now(pytz.utc) + immediate_events = [] + + start_date = now - timedelta(days=self.schedule_lookback_days) + end_date = now + timedelta(days=self.schedule_lookahead_days) + date_str = f"{start_date.strftime('%Y%m%d')}-{end_date.strftime('%Y%m%d')}" + url = f"https://site.api.espn.com/apis/site/v2/sports/{self.sport}/{self.league}/scoreboard" + response = self.session.get( + url, + params={"dates": date_str, "limit": 1000}, + headers=self.headers, + timeout=10, + ) + response.raise_for_status() + data = response.json() + immediate_events = data.get("events", []) + + if immediate_events: + self.logger.info(f"Fetched {len(immediate_events)} events {date_str}") + return {"events": immediate_events} + + except requests.exceptions.RequestException as e: + self.logger.warning( + f"Error fetching this weeks games for {self.sport} - {self.league} - {date_str}: {e}" + ) + return None + + def _custom_scorebug_layout(self, game: dict, draw_overlay: ImageDraw.ImageDraw): + pass + + def cleanup(self): + """Clean up resources when plugin is unloaded.""" + # Close HTTP session + if hasattr(self, 'session') and self.session: + try: + self.session.close() + except Exception as e: + self.logger.warning(f"Error closing session: {e}") + + # Clear caches + if hasattr(self, '_logo_cache'): + self._logo_cache.clear() + + self.logger.info(f"{self.__class__.__name__} cleanup completed") + + def _game_divisions(self, game: Dict) -> Optional[set]: + """Divisions of BOTH sides, or None when they cannot be told. + + Both sides are collected, but the caller only needs ONE of them to sit + in a checked division. Requiring every participant read as "FBS games + only" and removed a ranked side hosting an FCS school -- which is still + a game involving a team the viewer checked the box for, and on a real + Week 2 slate it silently dropped five of the twenty ranked matchups. + What the checkbox is for is keeping FCS-versus-FCS out of a board + configured for FBS, and that still holds: a game with no checked + division on either side is dropped. + """ + divisions = self._load_division_team_ids() + if not any(divisions.values()): + return None + try: + ids = [int(game.get("home_id")), int(game.get("away_id"))] + except (TypeError, ValueError): + return None + present = set() + for team_id in ids: + for name in ("fbs", "fcs"): + if team_id in divisions.get(name, set()): + present.add(name) + break + else: + present.add("other") + return present + + def _league_has_rankings(self) -> bool: + """Only college leagues publish a poll; everyone else 404s. + + This gate matters more than it looks. _fetch_team_rankings only + short-circuits when the cache is non-empty, so a failed fetch leaves it + empty and the next update tries again -- at a 30s interval that is + ~2,900 pointless requests a day, per league, all of them 404s. + """ + league = (self.league or "").lower() + return "college" in league or "ncaa" in league + + @staticmethod + def _normalise_divisions(raw) -> List[str]: + """Division names from config, in the shape the filter expects. + + A hand-edited config can hold "fbs" where the schema says ["fbs"], and + list("fbs") is ['f', 'b', 's'] -- three names that match no division, so + every non-favourite game is rejected by a setting the user believes says + the opposite. An empty list is left empty: that means "no division + filter" and is a legitimate choice, not a mistake to correct. + """ + if isinstance(raw, str): + raw = [raw] + try: + items = list(raw or []) + except TypeError: + return [] + return [str(d).strip().lower() for d in items if str(d).strip()] + + def _round_robin_favorites(self, games: List[Dict], limit: int) -> List[Dict]: + """Each favourite team's next game before any team's second one. + + Taking the soonest N favourite games spends the slots on whoever plays + most often. Walked across a real season with two favourites and a limit + of 2, nine days of it showed Auburn twice and Georgia not at all -- + Auburn played either side of a Georgia bye, so both slots went to + Auburn. The other-games pool already refuses to do this; favourites + were still doing it. + + Depth is kept where there is room: one favourite with three slots still + gets its next three games, because the round-robin only comes back for + a team's second game once every team has had a first. + + A game between two favourites is picked once and counts for both. + """ + if limit <= 0 or not games: + return [] + wanted = [t for t in (self.favorite_teams or []) if t] + if len(wanted) < 2: + return games[:limit] # nothing to share the slots between + + # Which side of a game belongs to which favourite is a per-lineage + # question: NRL matches on ESPN team IDs because its abbreviations are + # not unique ("NEW" is both Newcastle and New Zealand), while the rest + # match on abbreviation. Ask for the lineage's own matcher rather than + # assuming, or this silently groups nothing and every slot goes empty. + matcher = getattr(self, "_team_in", None) + if not callable(matcher): + matcher = None # an is-None test narrows for static analysis + if matcher is None: + def belongs(game, team): + return team in (game.get("home_abbr"), game.get("away_abbr")) + else: + def belongs(game, team): + return bool(matcher(game.get("home_id"), [team]) + or matcher(game.get("away_id"), [team])) + + queues = {team: [] for team in wanted} + for game in games: # already in kickoff order + for team in wanted: + if belongs(game, team): + queues[team].append(game) + + picked, taken = [], set() + while len(picked) < limit: + progressed = False + for team in wanted: + queue = queues[team] + while queue and queue[0].get("id") in taken: + queue.pop(0) + if queue and len(picked) < limit: + game = queue.pop(0) + taken.add(game.get("id")) + picked.append(game) + progressed = True + if not progressed: + break # every queue is empty + return picked + + def _normalise_quality(self, raw) -> str: + """other_games_min_quality, as one of the values the code implements. + + An unusable value used to fall through every branch of + _passes_other_filters and silently mean "any" -- a quality bar the + board believes it has and does not. + """ + value = str(raw or "").strip().lower() + if value in self._QUALITY_CHOICES: + return value + if value == "broadcast": + # Retired in football-scoreboard 3.0.0 and now here. Measured + # against a real Week 1 and Week 2 college slate it passed 174 of + # 175 games: ESPN publishes a broadcaster for nearly everything + # now, ESPN+ included, so the tier read as a quality bar and + # behaved as "any". Boards holding it get the bar they thought + # they were getting. + self.logger.warning( + "%s: other_games_min_quality 'broadcast' has been retired -- " + "it let through nearly every game -- using 'ranked'. Change " + "the setting to clear this.", getattr(self, "sport_key", "?"), + ) + return "ranked" + self.logger.warning( + "%s: ignoring unusable other_games_min_quality=%r, using 'ranked'", + getattr(self, "sport_key", "?"), raw, + ) + return "ranked" + + def _check_ranking_coverage(self, games: List[Dict]) -> None: + """Say so when a loaded poll matches nothing on the schedule. + + The table is keyed by the abbreviation the RANKINGS endpoint returns and + matched against the one the SCOREBOARD endpoint returns. Nothing + guarantees the two agree, and if they ever stop agreeing the filter + quietly removes every non-favourite game -- no exception, no log line, + just a shorter board. That is the same shape as the bug where rankings + were never loading at all, which survived until someone went looking. + + Throttled to once an hour: selection runs on every update. + """ + if self.other_games_min_quality != "ranked": + return + rankings = getattr(self, "_team_rankings_cache", None) or {} + if not rankings or not games: + return + if any(self._is_ranked_game(g) for g in games): + return + now = time.monotonic() + # Zero means never logged, not "logged at the epoch". monotonic() counts + # from an arbitrary origin -- on a freshly booted board it is a few + # hundred seconds -- so comparing against 0 swallowed the first warning + # for the first hour of uptime, which is exactly when a misconfigured + # board is being watched. CI caught this; a machine with days of uptime + # cannot. + if (self._ranking_coverage_logged_at + and now - self._ranking_coverage_logged_at < self._RANKING_COVERAGE_SECONDS): + return + self._ranking_coverage_logged_at = now + self.logger.warning( + "%s: %d ranked teams loaded, but none of the %d other games match " + "one -- the quality filter is removing every non-favourite game. " + "Ranked abbreviations look like: %s", + self.league, len(rankings), len(games), + ", ".join(sorted(rankings)[:8]), + ) + + def _favorites_first( + self, + processed_games: List[Dict], + favorite_limit: int, + other_limit: int, + newest_first: bool = False, + ) -> List[Dict]: + """Favourite games first, then a bounded number of everything else. + + This is the middle setting the plugin was missing. `show_favorite_teams_only` + used to be the whole story: on, and you saw nothing but your teams; off, + and your teams were ignored entirely -- the selection just took the next + N games league-wide, so a UGA fan with 946 upcoming college games in the + window saw UGA about as often as chance allowed. + + Both counts are TOTALS here, not per-team. In favourites-only mode + `upcoming_games_to_show` is a per-team budget, which is reasonable when + the list is your own teams; applied to a dynamic group it is not. With + AP_TOP_10 resolving to a dozen teams, three games each is 28 distinct + cards before a single non-favourite is added. A total keeps the rotation + the length the user asked for. + """ + if newest_first: + def key(g): + return g.get("start_time_utc") or datetime.min.replace(tzinfo=timezone.utc) + ordered = sorted(processed_games, key=key, reverse=True) + else: + def key(g): + return g.get("start_time_utc") or datetime.max.replace(tzinfo=timezone.utc) + ordered = sorted(processed_games, key=key) + + favorites, others, unfiltered = [], [], [] + for game in ordered: + if self._is_favorite_game(game): + favorites.append(game) # never filtered: your team is your team + continue + unfiltered.append(game) + if self._passes_other_filters(game): + others.append(game) + self._check_ranking_coverage(unfiltered) + + self._selection_pools = { + "favorites": favorites, + "others": self._by_importance(others, newest_first), + "unfiltered": self._by_importance(unfiltered, newest_first), + "favorite_limit": favorite_limit, + "other_limit": other_limit, + "newest_first": newest_first, + } + return self._compose_selection() + + def _compose_selection(self) -> List[Dict]: + """Favourites plus the current slice of others, in schedule order. + + Split out of _favorites_first so the slice can be re-cut between + fetches. The pools are settled -- which games exist, and which of them + are worth a slot -- while WHICH of the others is on screen is a display + decision, and gating it on the fetch made the rotation interval a lie: + update() returns early until upcoming_update_interval has passed, so a + four-minute rotation actually stepped fifteen windows once an hour. + Same lesson as _advance_live_game_if_due further down this file. + """ + pools = self._selection_pools + favorites, others = pools["favorites"], pools["others"] + favorite_limit, other_limit = pools["favorite_limit"], pools["other_limit"] + newest_first = pools["newest_first"] + if newest_first: + def key(g): + return g.get("start_time_utc") or datetime.min.replace(tzinfo=timezone.utc) + else: + def key(g): + return g.get("start_time_utc") or datetime.max.replace(tzinfo=timezone.utc) + + selected = self._round_robin_favorites(favorites, max(0, favorite_limit)) + selected.extend(self._other_games_window(others, max(0, other_limit))) + if not selected and other_limit > 0: + # Nothing survived at all: your teams are not playing inside the + # schedule window AND the filters removed every other game. Each + # check fails open on missing data, but a filter working exactly as + # asked can still match nothing on a given day, and with no + # favourite game left there is nothing to carry the mode -- an empty + # list is a blank panel, not a short one. Same whole-list fallback + # `_filtered_or_all` makes for a board with no favourites at all. + # `other_limit` of 0 is an explicit "favourites only", so that one + # is left to go quiet as asked. + selected = self._other_games_window(pools["unfiltered"], max(0, other_limit)) + # Re-sort so the card order still reads as a schedule. Selection decides + # WHICH games; it should not reorder them into favourites-then-others, + # which would show next week's UGA game before tonight's. + selected.sort(key=key, reverse=newest_first) + return selected + + +class SportsLiveSharedMixin: + """The ``SportsLive`` bodies identical in all eight scoreboards.""" + + def _detect_stale_games(self, games: List[Dict]) -> None: + """Remove games that appear stale or haven't updated.""" + current_time = time.time() + + for game in games[:]: # Copy list to iterate safely + game_id = game.get("id") + if not game_id: + continue + + # Check if game data is stale + timestamps = self.game_update_timestamps.get(game_id, {}) + last_seen = timestamps.get("last_seen", 0) + + if last_seen > 0 and current_time - last_seen > self.stale_game_timeout: + self.logger.warning( + f"Removing stale game {game.get('away_abbr')}@{game.get('home_abbr')} " + f"(last seen {int(current_time - last_seen)}s ago)" + ) + games.remove(game) + if game_id in self.game_update_timestamps: + del self.game_update_timestamps[game_id] + continue + + # Also check if game appears to be over + if self._is_game_really_over(game): + self.logger.debug( + f"Removing game that appears over: {game.get('away_abbr')}@{game.get('home_abbr')} " + f"(clock={game.get('clock')}, period={game.get('period')}, period_text={game.get('period_text')})" + ) + games.remove(game) + if game_id in self.game_update_timestamps: + del self.game_update_timestamps[game_id] + + def _idle_live_interval(self) -> int: + """How long to wait before looking for live games again, when there are none. + + Escalates the longer nothing turns up, and any live game resets it, so + an in-season gap between games costs at most one escalated wait while + an out-of-season league stops polling on a live cadence entirely. + + Capped rather than unbounded: the cost of backing off is how late the + first game after a quiet spell is noticed, and past the cap the saving + stops being worth that. + """ + streak = getattr(self, "_empty_live_streak", 0) + base = self.no_data_interval + ceiling = getattr(self, "live_idle_max_interval", + _DEFAULT_LIVE_IDLE_MAX_SECONDS) + # The ceiling bounds the un-escalated interval too. The two settings are + # independent integers with no cross-validation, so base > ceiling is a + # reachable config -- and returning base unclamped there made the wait + # *shrink* as the streak grew (3600s at streak 0, 900s at streak 24), + # the opposite of what the setting named "maximum" promises. + if streak >= _IDLE_LONG_STREAK: + return min(int(base * _IDLE_LONG_FACTOR), ceiling) + if streak >= _IDLE_SHORT_STREAK: + return min(int(base * _IDLE_SHORT_FACTOR), ceiling) + return min(base, ceiling) + + def _note_live_fetch(self, found_live: bool) -> None: + """Record whether a look for live games found any.""" + if found_live: + if getattr(self, "_empty_live_streak", 0): + self.logger.info( + "Live games found after %d empty check(s); back to the " + "live update interval", self._empty_live_streak) + self._empty_live_streak = 0 + else: + self._empty_live_streak = getattr(self, "_empty_live_streak", 0) + 1 + + +class SportsRecentSharedMixin: + """The ``SportsRecent`` bodies identical in all eight scoreboards.""" + + def __init__( + self, + config: Dict[str, Any], + display_manager, + cache_manager, + logger: logging.Logger, + sport_key: str, + ): + super().__init__(config, display_manager, cache_manager, logger, sport_key) + self.games_list = [] # Filtered list for display (favorite teams) + self.current_game_index = 0 + self.last_update = 0 + self.update_interval = self.mode_config.get( + "recent_update_interval", 3600 + ) # Check for recent games every hour + self.last_game_switch = 0 + self.game_display_duration = self.mode_config.get("recent_game_duration", 15) + self._zero_clock_timestamps: Dict[str, float] = {} # Track games at 0:00 + + def _get_zero_clock_duration(self, game_id: str) -> float: + """Track how long a game has been at 0:00 clock.""" + current_time = time.time() + if game_id not in self._zero_clock_timestamps: + self._zero_clock_timestamps[game_id] = current_time + return 0.0 + return current_time - self._zero_clock_timestamps[game_id] + + def _clear_zero_clock_tracking(self, game_id: str) -> None: + """Clear tracking when game clock moves away from 0:00 or game ends.""" + if game_id in self._zero_clock_timestamps: + del self._zero_clock_timestamps[game_id] + diff --git a/test/test_sports_shared.py b/test/test_sports_shared.py new file mode 100644 index 00000000..f18fe64d --- /dev/null +++ b/test/test_sports_shared.py @@ -0,0 +1,321 @@ +"""The shared sports.py mixins: their host contract, and _plugin_dir. + +Two things are worth testing here and the rest is not. The 45 method bodies +moved verbatim from the plugins, so they are covered by the plugins' own tests +and by 176 byte-identical safety-harness renders. What is genuinely new is: + +1. The contract. Every ``self.`` a mixin reads must be defined on the + mixin, or a host that does not happen to declare it raises AttributeError at + runtime. Two were missed on the first pass (_QUALITY_CHOICES and + _RANKING_COVERAGE_SECONDS); the eight plugins all declare them, so nothing + failed -- it would only have bitten a ninth. The test derives the list rather + than restating it, so the next omission fails here instead of in the field. + +2. ``_plugin_dir``. This is the only line of genuinely new logic in the move. In + sports.py these methods found config_schema.json with ``__file__``; here that + is src/common/, so the plugin directory has to be recovered from the + instance -- and getting it wrong is silent, costing grid-snapped font sizes + (measured at 81% anti-aliased edges) rather than raising. +""" + +import ast +import os +import sys +import types +from abc import ABC + +import pytest + +from src.common import sports_shared +from src.common.sports_shared import ( + SportsCoreSharedMixin, SportsLiveSharedMixin, SportsRecentSharedMixin) + +MIXINS = (SportsCoreSharedMixin, SportsLiveSharedMixin, SportsRecentSharedMixin) + + +def _constants_read_by_mixins(): + """Every ALL-CAPS ``self.X`` the mixin bodies read, found by parsing them.""" + tree = ast.parse(open(sports_shared.__file__).read()) + names = set() + for node in ast.walk(tree): + if (isinstance(node, ast.Attribute) + and isinstance(node.value, ast.Name) + and node.value.id == "self" + and node.attr.upper() == node.attr): + names.add(node.attr) + return names + + +class TestHostContract: + def test_every_constant_read_is_also_defined(self): + # Otherwise a host that does not declare it raises AttributeError the + # first time the code path runs -- which for these is mid-render. + missing = sorted( + name for name in _constants_read_by_mixins() + if not any(hasattr(m, name) for m in MIXINS)) + assert missing == [], ( + f"read but never defined on a mixin: {missing}. Give each a default " + f"on SportsCoreSharedMixin and document it in the module docstring.") + + @pytest.mark.parametrize("name,expected", [ + ("_QUALITY_CHOICES", frozenset({"any", "ranked"})), + ("_RANKING_COVERAGE_SECONDS", 3600), + ("_SCORE_PROBE_TEXT", "00-00"), + ("_FONT_DESIGN_HEIGHT", 32), + ]) + def test_defaults_match_what_the_plugins_ship(self, name, expected): + # The eight plugins declare their own copies, which shadow these. The + # values must still agree, or a ninth plugin inheriting the default + # behaves differently from the eight. + assert getattr(SportsCoreSharedMixin, name) == expected + + def test_only_the_recent_mixin_carries_a_constructor(self): + # SportsCore and SportsLive keep their own __init__ -- those differ per + # plugin. SportsRecent.__init__ was one of the 48 byte-identical bodies, + # so it moved with the rest; that is deliberate, not an oversight. + assert "__init__" not in SportsCoreSharedMixin.__dict__ + assert "__init__" not in SportsLiveSharedMixin.__dict__ + assert "__init__" in SportsRecentSharedMixin.__dict__ + + def test_the_recent_constructor_still_chains_to_the_host(self): + """Its zero-arg super() binds to where it is DEFINED, not where it is used. + + Moving a body containing bare ``super()`` is the one move that can + change meaning: the compiler closes over __class__ = the defining class, + so after the move that is SportsRecentSharedMixin rather than the + plugin's SportsRecent. It still works only because the mixin is listed + first, leaving the host class next in the MRO -- adopt it in the other + order and the chain silently skips the host's __init__. + """ + calls = [] + + class Host: + def __init__(self, config, display_manager, cache_manager, logger, sport_key): + calls.append(sport_key) + self.mode_config = {} + + class Recent(SportsRecentSharedMixin, Host): + pass + + inst = Recent({}, None, None, None, "nhl") + assert calls == ["nhl"], "the host constructor must still run" + assert inst.current_game_index == 0 + assert inst.update_interval == 3600 + assert inst._zero_clock_timestamps == {} + + def test_adopting_the_recent_mixin_second_would_skip_the_host(self): + # The failure mode the ordering above prevents, pinned so nobody + # "tidies" the base list. + calls = [] + + class Host: + def __init__(self, *a): + calls.append(a) + self.mode_config = {} + + class Wrong(Host, SportsRecentSharedMixin): + pass + + Wrong({}, None, None, None, "nhl") + # Host.__init__ wins and the mixin's setup never runs at all. + assert not hasattr(Wrong({}, None, None, None, "nhl"), "current_game_index") + + +class _Host(SportsCoreSharedMixin): + pass + + +def _write_plugin(tmp_path, name="fakeplug", schema=True): + """A throwaway package on sys.path, with or without a config_schema.json.""" + d = tmp_path / name + d.mkdir() + (d / "__init__.py").write_text("") + (d / "mod.py").write_text("class Leaf:\n pass\n") + if schema: + (d / "config_schema.json").write_text( + '{"properties": {"customization": {"properties": ' + '{"score": {"properties": {"font_size": {"default": 16}}}}}}}') + return d + + +class TestPluginDir: + def test_it_finds_the_directory_holding_config_schema_json(self, tmp_path, monkeypatch): + d = _write_plugin(tmp_path) + monkeypatch.syspath_prepend(str(tmp_path)) + mod = __import__("fakeplug.mod", fromlist=["Leaf"]) + host = type("H", (mod.Leaf, SportsCoreSharedMixin), {})() + assert host._plugin_dir() == str(d) + + def test_a_class_built_by_type_still_resolves(self, tmp_path, monkeypatch): + # SportsCore is an ABC, so type(name, bases, ns) reports __module__ as + # "abc" rather than the plugin -- which is exactly what the plugins' + # own tests build. Walking the MRO is what steps past it. + d = _write_plugin(tmp_path, "abcplug") + monkeypatch.syspath_prepend(str(tmp_path)) + mod = __import__("abcplug.mod", fromlist=["Leaf"]) + + class Base(SportsCoreSharedMixin, mod.Leaf, ABC): + pass + + synthetic = type("Probe", (Base,), {}) + assert synthetic.__module__ == "abc", "precondition: the trap this guards" + assert synthetic.__new__(synthetic)._plugin_dir() == str(d) + + def test_it_returns_none_when_no_schema_is_anywhere_on_the_mro(self, tmp_path, monkeypatch): + d = _write_plugin(tmp_path, "noschema", schema=False) + monkeypatch.syspath_prepend(str(tmp_path)) + mod = __import__("noschema.mod", fromlist=["Leaf"]) + host = type("H", (mod.Leaf, SportsCoreSharedMixin), {})() + # None rather than a wrong guess: _schema_font_size then caches empty + # and every element keeps its own default. + assert host._plugin_dir() is None + + def test_it_never_returns_the_core_module_directory(self): + # The bug this replaced: __file__ pointed at src/common/, so the schema + # was never found and font sizes silently stopped snapping to the grid. + host = _Host() + core_common = os.path.dirname(os.path.abspath(sports_shared.__file__)) + assert host._plugin_dir() != core_common + + def test_a_module_with_no_file_is_skipped_not_crashed_on(self, monkeypatch): + # Namespace packages and some frozen/dynamic modules have no __file__. + ghost = types.ModuleType("ghost_no_file") + if hasattr(ghost, "__file__"): + del ghost.__file__ + monkeypatch.setitem(sys.modules, "ghost_no_file", ghost) + cls = type("H", (SportsCoreSharedMixin,), {"__module__": "ghost_no_file"}) + assert cls.__new__(cls)._plugin_dir() is None + + +class TestSchemaFontSize: + def test_it_reads_the_plugin_schema_not_the_cores(self, tmp_path, monkeypatch): + d = _write_plugin(tmp_path, "sizeplug") + monkeypatch.syspath_prepend(str(tmp_path)) + mod = __import__("sizeplug.mod", fromlist=["Leaf"]) + host = type("H", (mod.Leaf, SportsCoreSharedMixin), {})() + assert host._schema_font_size("score") == 16 + + def test_an_unknown_element_is_none(self, tmp_path, monkeypatch): + _write_plugin(tmp_path, "unkplug") + monkeypatch.syspath_prepend(str(tmp_path)) + mod = __import__("unkplug.mod", fromlist=["Leaf"]) + host = type("H", (mod.Leaf, SportsCoreSharedMixin), {})() + assert host._schema_font_size("nonesuch") is None + + def test_an_empty_key_is_none_without_touching_the_disk(self): + assert _Host()._schema_font_size("") is None + + def test_a_missing_schema_degrades_to_none_rather_than_raising(self, tmp_path, monkeypatch): + _write_plugin(tmp_path, "bareplug", schema=False) + monkeypatch.syspath_prepend(str(tmp_path)) + mod = __import__("bareplug.mod", fromlist=["Leaf"]) + host = type("H", (mod.Leaf, SportsCoreSharedMixin), {})() + assert host._schema_font_size("score") is None + + +class _LiveHost(SportsLiveSharedMixin): + """The documented contract for the live mixin, and nothing else.""" + + def __init__(self, no_data_interval=300, stale_game_timeout=600, over=()): + self.no_data_interval = no_data_interval + self.stale_game_timeout = stale_game_timeout + self.game_update_timestamps = {} + self._over = set(over) + + class _L: + def __getattr__(self, _n): + return lambda *a, **k: None + self.logger = _L() + + def _is_game_really_over(self, game): + return game.get("id") in self._over + + +class TestLiveMixin: + """These three moved to the core, so they are tested here. + + They were already covered by hockey's and lacrosse's own tests, but those + two plugins disable live mode in their safety-harness fixtures, so the 176 + renders never exercise this path. Testing the mixin directly means the + coverage no longer depends on which plugin happens to have a unit test. + """ + + def test_a_stale_game_is_dropped_and_forgotten(self): + h = _LiveHost(stale_game_timeout=600) + import time as _t + h.game_update_timestamps["g1"] = {"last_seen": _t.time() - 5000} + games = [{"id": "g1", "home_abbr": "H", "away_abbr": "A"}] + h._detect_stale_games(games) + assert games == [] + assert "g1" not in h.game_update_timestamps, "its timestamp must go too" + + def test_a_fresh_game_survives(self): + h = _LiveHost(stale_game_timeout=600) + import time as _t + h.game_update_timestamps["g1"] = {"last_seen": _t.time() - 5} + games = [{"id": "g1"}] + h._detect_stale_games(games) + assert len(games) == 1 + + def test_a_game_never_seen_is_not_treated_as_stale(self): + # last_seen 0 means "no reading", not "seen at the epoch". + h = _LiveHost() + games = [{"id": "g1"}] + h._detect_stale_games(games) + assert len(games) == 1 + + def test_a_game_with_no_id_is_left_alone(self): + h = _LiveHost() + games = [{"home_abbr": "H"}] + h._detect_stale_games(games) + assert len(games) == 1 + + def test_a_finished_game_is_dropped_even_when_fresh(self): + h = _LiveHost(over=("g2",)) + games = [{"id": "g1"}, {"id": "g2"}] + h._detect_stale_games(games) + assert [g["id"] for g in games] == ["g1"] + + def test_removing_several_does_not_skip_any(self): + # It iterates a copy for exactly this reason; mutating the live list + # while looping would step over the element after each removal. + h = _LiveHost(over=("g1", "g2", "g3")) + games = [{"id": "g1"}, {"id": "g2"}, {"id": "g3"}] + h._detect_stale_games(games) + assert games == [] + + def test_the_idle_interval_escalates_with_the_empty_streak(self): + h = _LiveHost(no_data_interval=60) + h.live_idle_max_interval = 100000 + base = h._idle_live_interval() + h._empty_live_streak = 6 + short = h._idle_live_interval() + h._empty_live_streak = 24 + long = h._idle_live_interval() + assert base < short < long + + def test_the_ceiling_bounds_even_the_unescalated_interval(self): + # base > ceiling is a reachable config: the two settings are + # independent integers with no cross-validation. Returning base + # unclamped made the wait SHRINK as the streak grew. + h = _LiveHost(no_data_interval=3600) + h.live_idle_max_interval = 900 + h._empty_live_streak = 0 + assert h._idle_live_interval() == 900 + h._empty_live_streak = 24 + assert h._idle_live_interval() == 900 + + def test_finding_a_live_game_resets_the_streak(self): + h = _LiveHost() + h._note_live_fetch(False) + h._note_live_fetch(False) + assert h._empty_live_streak == 2 + h._note_live_fetch(True) + assert h._empty_live_streak == 0 + + def test_the_streak_starts_from_absent_state(self): + # The host is not required to pre-declare _empty_live_streak. + h = _LiveHost() + assert not hasattr(h, "_empty_live_streak") + h._note_live_fetch(False) + assert h._empty_live_streak == 1 From 32d637a446dbaccf5f77815cbfb7a64d91e17797 Mon Sep 17 00:00:00 2001 From: Chuck <33324927+ChuckBuilds@users.noreply.github.com> Date: Thu, 3 Sep 2026 16:06:40 -0400 Subject: [PATCH 25/29] fix(store): read the core version from disk, not from a stale import (#518) * fix(store): read the core version from disk, not from a stale import Updating the core to 3.3.0 and then updating plugins refused all eight sports scoreboards: Refusing to install nrl-scoreboard: NRL Scoreboard supports LEDMatrix >=3.3.0, but this system is running 3.2.0. while src/__init__.py on that machine read 3.3.0. Observed on hardware, not theorised. The gate ran `from src import __version__ as core_version`, which binds whatever the process loaded at start. The plugin store's gate lives in the web UI, a long-lived service of its own, and the update route deliberately restarts nothing -- it replaces files on disk and asks the user to restart. Its prompt named only the *display* service, so a user who followed it left the web process holding the previous number. Stale by exactly one release is the case that bites: every plugin flooring on the release you just installed is refused, blaming a core version that is already correct on disk. It reads as a broken plugin store. 3.3.0 is the first release where this hits a whole family at once, since all eight scoreboards floor there. compatibility.current_core_version() reads the version from the file instead, falling back to the imported value on any failure -- so it can only ever be as correct as before, never worse. All four gate call sites use it: three in store_manager (install, the git-pull update path, install_from_url) and one in plugin_loader's advisory warning. The restart prompt now names both services. Twelve tests, including the hardware failure itself: a process holding 3.2.0 while disk says 3.3.0 refuses hockey, and reading fresh allows it. The inverse is asserted too -- a genuinely old core still refuses, so the gate has not become permissive. One test greps both modules for the old import-bound read; reintroducing that line fails it, which is what stops this coming back. Not changed: web_interface/__init__.py also imports __version__, but for display rather than gating, and the API endpoint already reports a fresh git describe. * fix: drop the unused os import Left over from a first draft that joined paths by hand before this used pathlib. Flagged by CodeRabbit on #518; confirmed dead -- no os. reference remains in the module. --- src/plugin_system/compatibility.py | 40 +++++++++ src/plugin_system/plugin_loader.py | 2 +- src/plugin_system/store_manager.py | 9 +- test/test_core_version_freshness.py | 122 +++++++++++++++++++++++++++ web_interface/templates/v3/base.html | 9 +- 5 files changed, 177 insertions(+), 5 deletions(-) create mode 100644 test/test_core_version_freshness.py diff --git a/src/plugin_system/compatibility.py b/src/plugin_system/compatibility.py index 9b676cd2..4537be85 100644 --- a/src/plugin_system/compatibility.py +++ b/src/plugin_system/compatibility.py @@ -28,6 +28,7 @@ fixes their version string. See `docs/SPORTS_UNIFICATION.md`, phase B4. from __future__ import annotations import re +from pathlib import Path from typing import Any, Dict, Optional, Tuple # Below this, the core's self-reported version is not evidence of anything. @@ -35,6 +36,45 @@ from typing import Any, Dict, Optional, Tuple TRUSTWORTHY_FLOOR: Tuple[int, int, int] = (2, 0, 0) +# ``src/__init__.py`` relative to this file: src/plugin_system/ -> src/ +_VERSION_FILE = Path(__file__).resolve().parent.parent / "__init__.py" +_VERSION_RE = re.compile(r'^__version__\s*=\s*["\']([^"\']+)["\']', re.M) + + +def current_core_version() -> str: + """The core version as it is on disk right now, not as it was at import. + + ``from src import __version__`` binds whatever the process loaded at start. + The web UI runs as its own long-lived service (``ledmatrix-web.service``), + and updating the core replaces files on disk without restarting it -- the + update route says so explicitly and asks the user to restart. Its prompt + names the *display* service, so a user who follows it leaves the web + process holding the old number. + + The plugin store's gate lives in that web process. Stale by one release is + exactly the case that matters: every plugin flooring on the release you + just installed gets refused, with a message blaming a core version that is + already correct on disk. 3.3.0 is the first release where that hits a whole + plugin family at once -- all eight sports scoreboards floor there. + + Reading the file costs one stat and a small read per call, and only on the + install/update path. Any failure falls back to the imported value, so this + can only ever be as wrong as before, never worse. + """ + try: + text = _VERSION_FILE.read_text(encoding="utf-8") + match = _VERSION_RE.search(text) + if match: + return match.group(1) + except (OSError, UnicodeDecodeError): + pass + try: + from src import __version__ as imported + return imported + except Exception: # noqa: BLE001 - never let this raise + return "0.0.0" + + def parse_semver(value: Any) -> Optional[Tuple[int, int, int]]: """Parse ``X.Y.Z`` (extra parts and suffixes ignored) into a comparable 3-tuple, or ``None`` when unparseable. A leading ``v`` is tolerated.""" diff --git a/src/plugin_system/plugin_loader.py b/src/plugin_system/plugin_loader.py index d8ae0e3d..7a4969be 100644 --- a/src/plugin_system/plugin_loader.py +++ b/src/plugin_system/plugin_loader.py @@ -772,8 +772,8 @@ class PluginLoader: newer than the running core. Advisory only — never raises — so a plugin that guards optional features with try/except keeps working. """ - from src import __version__ as core_version from src.plugin_system import compatibility + core_version = compatibility.current_core_version() compatible, _reason = compatibility.check(manifest, core_version) if compatible: diff --git a/src/plugin_system/store_manager.py b/src/plugin_system/store_manager.py index 80a454fc..122cdf9c 100644 --- a/src/plugin_system/store_manager.py +++ b/src/plugin_system/store_manager.py @@ -1467,8 +1467,11 @@ class PluginStoreManager: # already had. Allowing it costs them a plugin that raises # ModuleNotFoundError at load and is reported only as one line # in the journal. See docs/SPORTS_UNIFICATION.md (phase B4/B6). - from src import __version__ as core_version from src.plugin_system import compatibility + # On disk, not as imported: this process may predate the core + # update that made the plugin compatible. See + # compatibility.current_core_version. + core_version = compatibility.current_core_version() compatible, reason = compatibility.check(manifest, core_version) if not compatible: @@ -1612,8 +1615,8 @@ class PluginStoreManager: # # Before the move, so the `finally` below removes the temp tree and # nothing half-installed is left behind. - from src import __version__ as core_version from src.plugin_system import compatibility + core_version = compatibility.current_core_version() compatible, reason = compatibility.check(manifest, core_version) if not compatible: @@ -2644,8 +2647,8 @@ class PluginStoreManager: manifest_path, plugin_id, e) return True - from src import __version__ as core_version from src.plugin_system import compatibility + core_version = compatibility.current_core_version() compatible, reason = compatibility.check(manifest, core_version) if compatible: diff --git a/test/test_core_version_freshness.py b/test/test_core_version_freshness.py new file mode 100644 index 00000000..55cdaef9 --- /dev/null +++ b/test/test_core_version_freshness.py @@ -0,0 +1,122 @@ +"""The gate must read the core version from disk, not from its own import. + +`from src import __version__` binds whatever the process loaded at start. The +web UI is a long-lived service of its own (ledmatrix-web.service), and updating +the core replaces files on disk without restarting it -- the update route says +so and asks the user to restart, but its prompt named only the *display* +service, so a user who followed it left the web process holding the old number. + +The plugin store's gate lives in that web process, so being stale by exactly +one release is the case that bites: every plugin flooring on the release you +just installed is refused, with a message blaming a core version that is +already correct on disk. + +Observed on hardware: after updating a rig to 3.3.0 and restarting only the +display service, all eight sports scoreboards were refused with +"supports LEDMatrix >=3.3.0, but this system is running 3.2.0" while +src/__init__.py on that machine read 3.3.0. +""" + +import importlib +import sys + +import pytest + +from src.plugin_system import compatibility + + +class TestCurrentCoreVersion: + def test_it_reads_the_file_rather_than_the_imported_value(self, monkeypatch, tmp_path): + # Simulate a process whose import predates the update: the module + # object says 3.2.0 while the file on disk says 3.3.0. + import src + monkeypatch.setattr(src, "__version__", "3.2.0") + fake = tmp_path / "__init__.py" + fake.write_text('__version__ = "3.3.0"\n', encoding="utf-8") + monkeypatch.setattr(compatibility, "_VERSION_FILE", fake) + assert compatibility.current_core_version() == "3.3.0" + + def test_it_matches_the_real_file_by_default(self): + import src + assert compatibility.current_core_version() == src.__version__ + + @pytest.mark.parametrize("body", [ + "__version__ = '3.4.1'\n", + '__version__="3.4.1"\n', + '"""doc"""\n\n__version__ = "3.4.1" # trailing comment\n', + ]) + def test_it_tolerates_the_ways_that_line_gets_written(self, monkeypatch, tmp_path, body): + fake = tmp_path / "__init__.py" + fake.write_text(body, encoding="utf-8") + monkeypatch.setattr(compatibility, "_VERSION_FILE", fake) + assert compatibility.current_core_version() == "3.4.1" + + def test_a_missing_file_falls_back_to_the_import(self, monkeypatch, tmp_path): + # Never worse than before: an unreadable file returns what the old + # code would have returned. + import src + monkeypatch.setattr(src, "__version__", "3.2.0") + monkeypatch.setattr(compatibility, "_VERSION_FILE", tmp_path / "gone.py") + assert compatibility.current_core_version() == "3.2.0" + + def test_a_file_without_the_line_falls_back(self, monkeypatch, tmp_path): + import src + monkeypatch.setattr(src, "__version__", "3.2.0") + fake = tmp_path / "__init__.py" + fake.write_text("# no version here\n", encoding="utf-8") + monkeypatch.setattr(compatibility, "_VERSION_FILE", fake) + assert compatibility.current_core_version() == "3.2.0" + + def test_it_never_raises(self, monkeypatch, tmp_path): + # This runs on the install path; an exception here would surface as a + # failed update rather than a version mismatch. + bad = tmp_path / "__init__.py" + bad.write_bytes(b"\xff\xfe\x00 not utf-8 \xff") + monkeypatch.setattr(compatibility, "_VERSION_FILE", bad) + assert isinstance(compatibility.current_core_version(), str) + + +class TestTheBugItFixes: + def test_a_stale_import_no_longer_refuses_a_compatible_plugin(self, monkeypatch, tmp_path): + """The exact hardware failure, as a test.""" + import src + monkeypatch.setattr(src, "__version__", "3.2.0") # what the process holds + fake = tmp_path / "__init__.py" + fake.write_text('__version__ = "3.3.0"\n', encoding="utf-8") # what is on disk + monkeypatch.setattr(compatibility, "_VERSION_FILE", fake) + + manifest = {"name": "Hockey Scoreboard", "min_ledmatrix_version": "3.3.0"} + + stale_ok, _ = compatibility.check(manifest, src.__version__) + assert stale_ok is False, "precondition: the stale value is what refused it" + + fresh_ok, reason = compatibility.check( + manifest, compatibility.current_core_version()) + assert fresh_ok is True, f"the disk version must allow it, got: {reason}" + + def test_it_still_refuses_when_the_core_really_is_too_old(self, monkeypatch, tmp_path): + # The gate must not become permissive: a genuinely old core still says no. + fake = tmp_path / "__init__.py" + fake.write_text('__version__ = "3.2.0"\n', encoding="utf-8") + monkeypatch.setattr(compatibility, "_VERSION_FILE", fake) + ok, reason = compatibility.check( + {"name": "Hockey", "min_ledmatrix_version": "3.3.0"}, + compatibility.current_core_version()) + assert ok is False + assert "3.2.0" in (reason or "") + + +class TestCallSites: + @pytest.mark.parametrize("module", [ + "src.plugin_system.store_manager", + "src.plugin_system.plugin_loader", + ]) + def test_no_gate_binds_the_version_at_import(self, module): + """Catch a future call site reintroducing the stale read.""" + import inspect + mod = importlib.import_module(module) + source = inspect.getsource(mod) + assert "from src import __version__ as core_version" not in source, ( + f"{module} binds __version__ at import; use " + f"compatibility.current_core_version() so a long-lived process " + f"sees a core update.") diff --git a/web_interface/templates/v3/base.html b/web_interface/templates/v3/base.html index e739dd8c..053724b4 100644 --- a/web_interface/templates/v3/base.html +++ b/web_interface/templates/v3/base.html @@ -1164,9 +1164,16 @@ // The pull replaced files on disk; the running services still // hold the code they loaded at boot. Ask for the restart that // makes the update actually take effect. + // + // Both services, not just the display. The plugin store's + // compatibility gate runs in the web process, so a web service + // still holding the previous version refuses every plugin that + // floors on the release just installed -- blaming a core + // version that is already correct on disk. if (data.restart_required && typeof window.showRestartPending === 'function') { window.showRestartPending( - 'Update installed \u2014 restart the display to run the new code'); + 'Update installed \u2014 restart the display and web ' + + 'services to run the new code'); } } if (typeof showNotification === 'function') { From 0730d952006b181556386014127306aaf15dbe6e Mon Sep 17 00:00:00 2001 From: Chuck <33324927+ChuckBuilds@users.noreply.github.com> Date: Thu, 3 Sep 2026 17:01:30 -0400 Subject: [PATCH 26/29] fix(sports): let the plugin declare its own directory, don't deduce it (#519) _plugin_dir() returned None on every device. The consequence was silent and reached the panel: _plugin_dir() -> None _schema_font_size() -> None for every element -> a configured size equal to the schema default stops looking like a default and is treated as a deliberate user choice -> the snap to the font's pixel grid is skipped -> 4x6-font.ttf renders at 6 instead of 7: 3px-wide glyphs, not 4px On a 256x64 panel that made the odds, the team records and the date row hard to read. Both `odds` and `detail` were affected -- anything resolving a grid-snapped schema default was a pixel narrow. Why it was invisible here. PluginLoader._namespace_plugin_modules renames every bare module a plugin brought in (sports, game_renderer, ...) to "_plg__" and REMOVES the bare sys.modules entry, so two plugins owning a module of the same name cannot collide. A class defined in sports.py still reports __module__ == "sports", but sys.modules["sports"] is gone, so walking the MRO for a module with a __file__ finds nothing. Every test here imported plugins directly, which leaves the bare entry in place, so the walk succeeded. The safety harness loads plugins its own way and never reproduced it either. It was found by a user counting pixels on the panel. The directory is now declared by the plugin (_PLUGIN_DIR) and only deduced as a fallback, for hosts that declare nothing -- the plugins' own probe harnesses build classes with type(). Verified on hardware, which is the only place the original failure appeared: before, the live service logged plugin_dir=None and 4x6-font.ttf@6 for all six football managers; after, plugin_dir resolves and both odds and detail are @7. Five regression tests, including the production shape: a class whose __module__ is absent from sys.modules still resolves via its declared directory, and the precondition that the MRO walk alone returns None is pinned so the test keeps meaning something if the fallback changes. --- src/common/sports_shared.py | 49 ++++++++++++++++++++++++++++----- test/test_sports_shared.py | 55 +++++++++++++++++++++++++++++++++++++ 2 files changed, 97 insertions(+), 7 deletions(-) diff --git a/src/common/sports_shared.py b/src/common/sports_shared.py index f427817d..1187b846 100644 --- a/src/common/sports_shared.py +++ b/src/common/sports_shared.py @@ -211,17 +211,52 @@ class SportsCoreSharedMixin: return desired return max(grid, int(round(float(desired) / grid)) * grid) + #: Absolute path of this plugin's directory, declared by the plugin + #: itself. The mixin cannot work it out -- see _plugin_dir. + _PLUGIN_DIR: ClassVar[Optional[str]] = None + def _plugin_dir(self) -> Optional[str]: - """Directory of the plugin that defines this class. + """Directory of the plugin that owns this instance. In sports.py these methods could just use ``__file__``. Here that is - src/common/, so the plugin's own directory has to be recovered from the - instance. ``type(self).__module__`` alone is not enough: SportsCore is - an ABC, so a subclass built with ``type(name, bases, ns)`` -- which the - plugins' own tests do -- reports its module as "abc". Walking the MRO - steps past those synthetic classes to the first one whose module sits - next to a config_schema.json, which is the real plugin. + src/common/, so the directory has to come from the plugin. + + It is TOLD, not deduced. The first version walked the MRO for a class + whose module sits beside a config_schema.json. That works when a test + imports the plugin itself, and returns None under the real loader, for + a specific reason worth recording: + + PluginLoader._namespace_plugin_modules renames every bare module a + plugin brought in (sports, game_renderer, ...) to + "_plg__" and REMOVES the bare entry, so two + plugins owning a module of the same name cannot collide. + + The class still reports ``__module__ == "sports"``, but + ``sys.modules["sports"]`` no longer exists, so the walk finds no + __file__ and falls off the end. The failure was silent and expensive: + + _plugin_dir() -> None + _schema_font_size() -> None for every element + -> a configured size equal to the schema default stops looking + like a default and is treated as a deliberate user choice + -> the snap to the font's pixel grid is skipped + -> 4x6-font.ttf renders at 6 instead of 7: 3px-wide glyphs + instead of 4px + + On a 256x64 panel that made the odds, the team records and the date row + hard to read. It was found by a user counting pixels on the panel. No + gate here caught it: the tests imported plugins directly and the + safety harness loads them its own way, so neither reproduced the + loader's renaming. + + The MRO walk stays as a fallback for hosts that declare no + _PLUGIN_DIR -- the plugins' own probe harnesses build classes with + ``type()`` -- but it is no longer the primary answer. """ + declared = getattr(self, "_PLUGIN_DIR", None) + if declared and os.path.isfile(os.path.join(declared, "config_schema.json")): + return declared + for cls in type(self).__mro__: module = sys.modules.get(getattr(cls, "__module__", ""), None) path = getattr(module, "__file__", None) diff --git a/test/test_sports_shared.py b/test/test_sports_shared.py index f18fe64d..bd402ae3 100644 --- a/test/test_sports_shared.py +++ b/test/test_sports_shared.py @@ -319,3 +319,58 @@ class TestLiveMixin: assert not hasattr(h, "_empty_live_streak") h._note_live_fetch(False) assert h._empty_live_streak == 1 + + +class TestPluginDirIsToldNotDeduced: + """The regression that shipped: _plugin_dir returned None in production. + + The first version walked the MRO for a class whose module sits beside a + config_schema.json. That passes when a test imports the plugin directly -- + which is how it was verified -- and returns None under the real plugin + loader, which imports modules by a path that leaves no such entry on the + MRO. + + Silent, and expensive: no schema means _schema_font_size returns None for + every element, so a configured size equal to the schema default stops + looking like a default, is treated as a deliberate choice, and skips the + grid snap. 4x6-font.ttf then renders at 6 rather than 7 -- 3px-wide glyphs + instead of 4px. On a 256x64 panel that made the odds, the records and the + date row illegible. A user counted the pixels; no gate here caught it. + """ + + def test_a_declared_directory_is_used(self, tmp_path): + d = _write_plugin(tmp_path, "declared") + host = type("H", (SportsCoreSharedMixin,), {"_PLUGIN_DIR": str(d)})() + assert host._plugin_dir() == str(d) + + def test_it_works_when_no_module_on_the_mro_helps(self, tmp_path, monkeypatch): + """The production case: nothing on the MRO sits beside a schema.""" + d = _write_plugin(tmp_path, "loaderstyle") + # A class whose module is not importable by name, as the loader produces. + cls = type("Loaded", (SportsCoreSharedMixin,), {"_PLUGIN_DIR": str(d)}) + cls.__module__ = "a.module.name.that.is.not.in.sys.modules" + assert cls.__new__(cls)._plugin_dir() == str(d), ( + "the declared directory must win when the MRO walk cannot help") + + def test_without_it_the_mro_walk_would_have_failed(self): + # Pin the precondition, so this test still means something if the + # fallback is ever changed. + cls = type("Orphan", (SportsCoreSharedMixin,), {}) + cls.__module__ = "not.a.real.module" + assert cls.__new__(cls)._plugin_dir() is None + + def test_a_declared_directory_without_a_schema_is_not_trusted(self, tmp_path): + # A stale or wrong path must not shadow the fallback. + empty = tmp_path / "noschema" + empty.mkdir() + d = _write_plugin(tmp_path, "realone") + monkey = type("H", (SportsCoreSharedMixin,), {"_PLUGIN_DIR": str(empty)}) + assert monkey.__new__(monkey)._plugin_dir() != str(empty) + + def test_the_font_size_consequence(self, tmp_path): + """End to end: a declared directory restores the schema lookup.""" + d = _write_plugin(tmp_path, "sizeconseq") + host = type("H", (SportsCoreSharedMixin,), {"_PLUGIN_DIR": str(d)})() + assert host._schema_font_size("score") == 16, ( + "without the schema this is None, which is what made a configured " + "size look user-chosen and skipped the grid snap") From 6bea1a7c21b14d2b359f9c54f4ceeaecd2b31cbb Mon Sep 17 00:00:00 2001 From: Chuck <33324927+ChuckBuilds@users.noreply.github.com> Date: Fri, 4 Sep 2026 16:01:50 -0400 Subject: [PATCH 27/29] fix(sports): say when the schema cannot be read, instead of failing silently (#520) _schema_font_size swallowed every exception and cached an empty dict. That is not cosmetic. With no schema, a configured font size can no longer be compared against the schema default, so every size is treated as a deliberate user choice and skips the snap to the font's pixel grid -- which renders 4x6-font.ttf at 6 instead of 7: a 3px-wide glyph instead of 4px. That shipped. On a 256x64 panel it made the odds, the team records and the date row hard to read, and it was found by a user counting pixels on a photo of the panel rather than by anything here. The cause (_plugin_dir returning None under the real plugin loader) is fixed in #519; this makes the same class of failure audible next time: Orphan: could not read config_schema.json (FileNotFoundError: ...); every font size will be treated as user-chosen and will skip its pixel grid snap. Font sizes may render a pixel narrow. The message names the consequence, not just the error, because the error alone does not suggest "your fonts are a pixel narrow". Logged rather than raised: an unreadable schema must not stop a plugin rendering. The cache is built once per class (per schema path in sports_card), so this cannot repeat per frame. Scope deliberately small. An audit of the three shared modules found 23 handlers that swallow and return a default, but all 23 catch specific types -- TypeError, ValueError, ImportError -- turning bad config values into defaults, which is what they are for. Of 77 broad handlers across the font and odds paths, 74 already log. Only these two were both broad and silent. --- src/common/sports_card.py | 12 +++++++++++- src/common/sports_shared.py | 18 +++++++++++++++++- 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/src/common/sports_card.py b/src/common/sports_card.py index 5a1f8ebb..6b8bd7ea 100644 --- a/src/common/sports_card.py +++ b/src/common/sports_card.py @@ -17,10 +17,13 @@ deliberate difference is `crisp_size`, which takes the seven-plugin guard the extra guard only stops a None size raising TypeError. """ +import logging from datetime import datetime, timezone from typing import Any, Dict, Optional, Tuple from zoneinfo import ZoneInfo +logger = logging.getLogger(__name__) + __all__ = [ "ELEMENT_FOR_FONT", "FAVORITE_RESULT_COLOR_DEFAULTS", "FONT_NAME_ALIASES", "FONT_PIXEL_GRID", "MONTH_ABBR", "WEEKDAY_ABBR", @@ -395,7 +398,14 @@ def schema_font_size(schema_path: str, element_key) -> Optional[int]: size = spec.get('properties', {}).get('font_size', {}).get('default') if size is not None: cache[key] = int(size) - except Exception: + except Exception as exc: + # See sports_shared._schema_font_size: an unreadable schema + # silently disables the pixel-grid snap for every element. + # Built once per schema path, so this cannot repeat per frame. + logger.warning( + "could not read %s (%s: %s); font sizes will skip their " + "pixel grid snap and may render a pixel narrow", + schema_path, type(exc).__name__, exc) cache = {} _SCHEMA_FONT_SIZE_CACHE[schema_path] = cache return cache.get(element_key) diff --git a/src/common/sports_shared.py b/src/common/sports_shared.py index 1187b846..b120e532 100644 --- a/src/common/sports_shared.py +++ b/src/common/sports_shared.py @@ -288,7 +288,23 @@ class SportsCoreSharedMixin: size = spec.get('properties', {}).get('font_size', {}).get('default') if size is not None: cache[key] = int(size) - except Exception: + except Exception as exc: + # Say so. An unreadable schema is not cosmetic: every element's + # configured size then stops matching "the schema default", is + # treated as a deliberate user choice, and skips the snap to the + # font's pixel grid -- which renders 4x6-font.ttf at 6 instead + # of 7, a 3px-wide glyph instead of 4px. That shipped once, + # silently, and was found by a user counting pixels on a photo + # of the panel. + # + # Logged, not raised: a missing schema must not stop a plugin + # rendering. The cache is built once per class, so this cannot + # repeat per frame. + logger.warning( + "%s: could not read config_schema.json (%s: %s); every font " + "size will be treated as user-chosen and will skip its pixel " + "grid snap. Font sizes may render a pixel narrow.", + type(self).__name__, type(exc).__name__, exc) cache = {} self.__class__._SCHEMA_FONT_SIZES = cache return cache.get(element_key) From 91d15a8943b409c3ed10f0a127ebf651b78b4213 Mon Sep 17 00:00:00 2001 From: Chuck <33324927+ChuckBuilds@users.noreply.github.com> Date: Fri, 4 Sep 2026 16:02:04 -0400 Subject: [PATCH 28/29] fix(display): draw text 1-bit, so glyphs stay crisp on the LED grid (#521) An LED panel has no partial brightness. PIL defaults ImageDraw's fontmode to "L", which anti-aliases TrueType glyphs into a grey fringe the panel can only round off -- a 4px glyph arrives smeared into 3px. DisplayManager creates its shared `draw` in six places and set fontmode at none of them, while _load_fonts loads extra_small_font as 4x6-font.ttf at size 6. Measured at draw time, that face at that size puts 74% of its lit pixels at partial coverage. Every plugin drawing small text through the shared draw inherited the blur; geochron was the case that surfaced it. The harness's VisualDisplayManager had the same gap, which mattered more than it looks: goldens were recording anti-aliased text that production would not produce, so the harness could not have caught this. Fixing only production left geochron still blurry under the harness -- that is how the second site was found. Both are set to "1" so the harness renders what the panel renders. Claude-Session: https://claude.ai/code/session_014RRtqXDCnvnY6EQwhT5CV9 Co-authored-by: Claude Opus 5 (1M context) --- src/display_manager.py | 6 ++++++ src/plugin_system/testing/visual_display_manager.py | 5 +++++ 2 files changed, 11 insertions(+) diff --git a/src/display_manager.py b/src/display_manager.py index 9cc7f622..e578ad7c 100644 --- a/src/display_manager.py +++ b/src/display_manager.py @@ -364,6 +364,7 @@ class DisplayManager: # Create image with the (logical) display dimensions self.image = Image.new('RGB', (self.matrix.width, self.matrix.height)) self.draw = ImageDraw.Draw(self.image) + self.draw.fontmode = "1" # 1-bit text: the panel has no partial brightness, so AA only smears glyphs. logger.info(f"Image canvas created with dimensions: {self.matrix.width}x{self.matrix.height}") # Initialize font with Press Start 2P @@ -403,6 +404,7 @@ class DisplayManager: self.image = Image.new('RGB', (fallback_width, fallback_height)) self.draw = ImageDraw.Draw(self.image) + self.draw.fontmode = "1" # 1-bit text: the panel has no partial brightness, so AA only smears glyphs. # Simple fallback visualization so web UI shows a realistic canvas try: self.draw.rectangle([0, 0, fallback_width - 1, fallback_height - 1], outline=(255, 0, 0)) @@ -705,6 +707,7 @@ class DisplayManager: # self.image, so swapping the buffer below is enough on its own. self.image = Image.new('RGB', (target_w, target_h)) self.draw = ImageDraw.Draw(self.image) + self.draw.fontmode = "1" # 1-bit text: the panel has no partial brightness, so AA only smears glyphs. yield finally: self.matrix = real_matrix @@ -814,6 +817,7 @@ class DisplayManager: self.image = Image.new('RGB', (width, height)) self.draw = ImageDraw.Draw(self.image) + self.draw.fontmode = "1" # 1-bit text: the panel has no partial brightness, so AA only smears glyphs. logger.debug("Cleared display in fallback mode") return @@ -825,6 +829,7 @@ class DisplayManager: # Create a new black image self.image = Image.new('RGB', (self.matrix.width, self.matrix.height)) self.draw = ImageDraw.Draw(self.image) + self.draw.fontmode = "1" # 1-bit text: the panel has no partial brightness, so AA only smears glyphs. if not self._capture_mode_active: # Clear both canvases and the underlying matrix to ensure no artifacts. @@ -1258,6 +1263,7 @@ class DisplayManager: try: self.image = Image.new('RGB', (self.width, self.height)) self.draw = ImageDraw.Draw(self.image) + self.draw.fontmode = "1" # 1-bit text: the panel has no partial brightness, so AA only smears glyphs. except (OSError, RuntimeError, ValueError, MemoryError): logger.debug("Canvas reset during cleanup failed", exc_info=True) # Reset the singleton state when cleaning up diff --git a/src/plugin_system/testing/visual_display_manager.py b/src/plugin_system/testing/visual_display_manager.py index 5211a226..e33d2309 100644 --- a/src/plugin_system/testing/visual_display_manager.py +++ b/src/plugin_system/testing/visual_display_manager.py @@ -70,6 +70,7 @@ class VisualTestDisplayManager: # Canvas self.image = Image.new('RGB', (width, height), (0, 0, 0)) self.draw = ImageDraw.Draw(self.image) + self.draw.fontmode = "1" # Match production: 1-bit text, so goldens show what the panel shows. # Matrix proxy (plugins access display_manager.matrix.width/height) self.matrix = _MatrixProxy(width, height) @@ -184,6 +185,7 @@ class VisualTestDisplayManager: self.clear_called = True self.image = Image.new('RGB', (self._width, self._height), (0, 0, 0)) self.draw = ImageDraw.Draw(self.image) + self.draw.fontmode = "1" # Match production: 1-bit text, so goldens show what the panel shows. def update_display(self): """No-op for hardware; marks that display was updated.""" @@ -211,6 +213,7 @@ class VisualTestDisplayManager: self.matrix = _MatrixProxy(target_w, target_h) self.image = Image.new('RGB', (target_w, target_h), (0, 0, 0)) self.draw = ImageDraw.Draw(self.image) + self.draw.fontmode = "1" # Match production: 1-bit text, so goldens show what the panel shows. yield finally: self._width, self._height = prev_w, prev_h @@ -569,6 +572,7 @@ class VisualTestDisplayManager: self.draw_calls = [] self.image = Image.new('RGB', (self._width, self._height), (0, 0, 0)) self.draw = ImageDraw.Draw(self.image) + self.draw.fontmode = "1" # Match production: 1-bit text, so goldens show what the panel shows. self._scrolling_state = { 'is_scrolling': False, 'last_scroll_activity': 0, @@ -582,3 +586,4 @@ class VisualTestDisplayManager: """Clean up resources.""" self.image = Image.new('RGB', (self._width, self._height), (0, 0, 0)) self.draw = ImageDraw.Draw(self.image) + self.draw.fontmode = "1" # Match production: 1-bit text, so goldens show what the panel shows. From 696acdbc7bfad7b9aa3e092496d2086d5ea734a9 Mon Sep 17 00:00:00 2001 From: Chuck <33324927+ChuckBuilds@users.noreply.github.com> Date: Sun, 6 Sep 2026 17:28:22 -0400 Subject: [PATCH 29/29] feat(render_plugin): add --display-mode so multi-mode plugins can be rendered (#522) * feat(render_plugin): add --display-mode so multi-mode plugins can be rendered render_plugin.py always called plugin.display(force_clear=True) with no mode. A plugin that declares one display mode is fine, but the sports scoreboards declare three or more and keep their per-mode state on sub-managers; their no-argument path selects nothing and returns False, so the render came out blank with nothing to say why. Measured on nrl-scoreboard with identical seeded state: live.display() directly True, 1892 lit pixels plugin.display(display_mode="nrl_live") True, 1892 lit pixels plugin.display() False, 0 lit pixels --display-mode passes the requested mode through. It is only passed when asked for, so the many plugins whose display() takes no display_mode keep working untouched, and a plugin that declares modes but does not accept the argument degrades to its default screen with a warning rather than a TypeError. This is what lets the plugin READMEs show a scoreboard at all, and it also unblocks screens like birdnet_stats and the weather plugin's hourly, daily and almanac modes, which could previously only be described in prose. Co-Authored-By: Claude Opus 5 * Only fall back when plugin display rejects display_mode --------- Co-authored-by: Claude Opus 5 Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- scripts/render_plugin.py | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/scripts/render_plugin.py b/scripts/render_plugin.py index 39dee61f..6db85c1b 100644 --- a/scripts/render_plugin.py +++ b/scripts/render_plugin.py @@ -52,6 +52,10 @@ def main() -> int: parser.add_argument('--height', type=int, default=32, help='Display height (default: 32)') parser.add_argument('--skip-update', action='store_true', help='Skip calling update() (render display only)') + parser.add_argument('--display-mode', default=None, + help='Display mode to render, for plugins that declare ' + 'more than one in their manifest (e.g. nrl_live). ' + 'Omitted, the plugin picks its own default.') args = parser.parse_args() @@ -141,8 +145,26 @@ def main() -> int: except Exception as e: logger.warning("update() raised: %s — continuing to display()", e) + # A plugin that declares several display modes usually renders nothing + # useful without being told which one to draw: the scoreboards keep their + # state on per-mode sub-managers and their no-argument path returns False. + # Only pass the argument when asked for, so the many plugins whose display() + # takes no display_mode keep working untouched. try: - plugin_instance.display(force_clear=True) + if args.display_mode: + try: + plugin_instance.display(display_mode=args.display_mode, + force_clear=True) + except TypeError as error: + if ("unexpected keyword argument" not in str(error) + or "display_mode" not in str(error)): + raise + logger.warning( + "%s.display() does not accept display_mode; rendering its " + "default screen instead", args.plugin) + plugin_instance.display(force_clear=True) + else: + plugin_instance.display(force_clear=True) logger.debug("display() completed") except Exception as e: logger.error("Error in display(): %s", e)