Files
LEDMatrix/src/plugin_system/testing/loading.py
T
ChuckandClaude Opus 5.5 b8c01c69fb ci: mypy ratchet -- keep type-clean modules clean (71 modules, 536 -> 442 errors) (#661)
* ci: mypy ratchet -- keep type-clean modules clean

mypy-clean.txt lists the 71 modules under src/ that type-check clean;
scripts/check_types.py runs mypy (--follow-imports=silent) on exactly
those files and fails on any error or a missing/unsorted/duplicate entry.
A new "Type check (mypy ratchet)" CI job runs it with mypy 1.20.2 and
pinned stubs; the manual pre-commit mypy hook now runs the same script
(a local hook, so mypy sees the installed requirements like CI does).

35 modules were made clean with annotation-only fixes: hints, typing.cast,
TYPE_CHECKING imports, implicit-Optional defaults made explicit, and
annotations widened (never guards removed) where mypy called a defensive
isinstance check unreachable. No runtime behaviour change.

mypy.ini: numpy and orjson are treated as Any (follow_imports=skip, also
for stubs). numpy 2.3+ stubs use 3.12 `type` statements that mypy won't
parse at python_version 3.10, and orjson is optional, so seeing its stubs
made the result depend on whether it was installed.

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

* chore: annotate check_types.py's list-form mypy subprocess

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 15:01:35 -04:00

165 lines
7.1 KiB
Python

"""
Shared helpers for loading a plugin headlessly.
Used by scripts/render_plugin.py, scripts/check_plugin.py, and the harness so
plugin discovery / manifest / config-default logic lives in exactly one place.
"""
import json
from pathlib import Path
from typing import Any, Dict, Optional, Sequence, Union, cast
def find_plugin_dir(plugin_id: str, search_dirs: Sequence[Union[str, Path]]) -> Optional[Path]:
"""Find a plugin directory by searching multiple paths."""
from src.plugin_system.plugin_loader import PluginLoader
loader = PluginLoader()
for search_dir in search_dirs:
search_path = Path(search_dir)
if not search_path.exists():
continue
result = loader.find_plugin_directory(plugin_id, search_path)
if result:
return Path(result)
return None
def load_manifest(plugin_dir: Union[str, Path]) -> Dict[str, Any]:
"""Load and return manifest.json from a plugin directory.
Read as UTF-8 explicitly, not in the platform default encoding: JSON is
UTF-8 by RFC 8259, but `open()` honours the locale, which is cp1252 on
Windows. A manifest carrying any non-ASCII byte (an em dash in a
description, a degree sign in a mode name) therefore raised
UnicodeDecodeError and aborted the whole `check_plugin.py --all` run on the
byte rather than failing just that plugin. The three sibling loaders below
read JSON from the same plugin trees and had the same bug.
"""
manifest_path = Path(plugin_dir) / 'manifest.json'
if not manifest_path.exists():
raise FileNotFoundError(f"No manifest.json in {plugin_dir}")
with open(manifest_path, 'r', encoding='utf-8') as f:
return cast(Dict[str, Any], json.load(f))
def merge_config(base: Dict[str, Any], override: Dict[str, Any]) -> Dict[str, Any]:
"""Deep-merge override onto base, without dropping sibling defaults.
A shallow merge would let `-c '{"nhl": {"enabled": true}}'` replace the whole
nhl subtree and silently discard every other nhl default -- the same class of
bug this function exists to fix.
"""
merged = dict(base)
for key, value in (override or {}).items():
if isinstance(value, dict) and isinstance(merged.get(key), dict):
merged[key] = merge_config(merged[key], value)
else:
merged[key] = value
return merged
def load_schema(plugin_dir: Union[str, Path]) -> Optional[Dict[str, Any]]:
"""A plugin's config_schema.json, or None when it has none."""
schema_path = Path(plugin_dir) / 'config_schema.json'
if not schema_path.exists():
return None
with open(schema_path, 'r', encoding='utf-8') as f:
return cast(Optional[Dict[str, Any]], json.load(f))
def load_config_defaults(plugin_dir: Union[str, Path]) -> Dict[str, Any]:
"""Default values from a plugin's config_schema.json (empty if none).
The device's own extraction (schema_manager.extract_schema_defaults), so a
harness run starts from the config an install would have: nested objects
contribute their children's defaults (organised-by-league configs lost
thousands of them when only the top level was read), and arrays without a
default start as [].
"""
from src.plugin_system.schema_manager import extract_schema_defaults
schema = load_schema(plugin_dir)
return extract_schema_defaults(schema) if schema else {}
def build_config(plugin_dir: Union[str, Path],
overrides: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
"""The config a device would give this plugin, with ``overrides`` applied.
Starts from a forced ``enabled: True``, deep-merges ``overrides`` onto it
(merge_config), then prepares the result exactly as the device does when it
loads a plugin: legacy booleans read as objects, and schema plus core
defaults filled in (schema_manager.prepare_plugin_config). Used by the
harness, check_plugin, render_plugin and the dev preview server.
"""
from src.plugin_system.schema_manager import (
plugin_config_defaults, prepare_plugin_config,
)
schema = load_schema(plugin_dir)
requested = merge_config({"enabled": True}, overrides or {})
return prepare_plugin_config(requested, schema, plugin_config_defaults(schema))
def load_harness_spec(plugin_dir: Union[str, Path]) -> Dict[str, Any]:
"""Optional per-plugin harness settings from <plugin>/test/harness.json.
Lets a plugin opt into golden-image testing by declaring how to render it
deterministically. All keys optional:
{
"config": {...}, # config overrides
"mock_data": "fixtures/mock.json", # path (relative to plugin dir) to cache fixtures
"freeze_time": "2025-08-01 15:25:00",
"skip_update": false,
"fill_check": "warn", # or "strict": underfilled big panels FAIL
"variants": [ # extra runs with config overlays and
{ # their own golden dirs — e.g. an
"name": "adaptive", # opt-in adaptive mode tested beside
"config": {"layout_mode": "adaptive"}, # the classic default
"golden_dir": "test/golden-adaptive"
}
]
}
Returns {} when no harness.json exists.
"""
spec_path = Path(plugin_dir) / 'test' / 'harness.json'
if not spec_path.exists():
return {}
with open(spec_path, 'r', encoding='utf-8') as f:
spec: Dict[str, Any] = json.load(f)
# Resolve mock_data path and inline its contents for convenience.
mock_rel = spec.get('mock_data')
if mock_rel:
mock_path = Path(plugin_dir) / mock_rel
if not mock_path.exists():
# A declared-but-missing fixture is a harness config error: failing
# loudly beats silently rendering the plugin with no mock data.
raise FileNotFoundError(
f"harness.json references mock_data '{mock_rel}' but "
f"{mock_path} does not exist"
)
with open(mock_path, 'r', encoding='utf-8') as mf:
spec['mock_data_contents'] = json.load(mf)
return spec
def build_full_config(
plugin_dir: Union[str, Path],
spec: Optional[Dict[str, Any]] = None,
cli_config: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]:
"""Build the config a plugin sees under test.
Merge order: config_schema.json defaults, then a forced ``enabled: True``,
then harness.json's config overlay, then the caller's explicit config --
most specific wins, and each layer deep-merges (a nested override such as
``{"nhl": {"enabled": true}}`` keeps the other nhl defaults). `enabled` is
asserted over the schema defaults so a plugin that reasonably ships
`enabled: false` (e.g. a seasonal or opt-in plugin) can't silently make
every harness run test "disabled, do nothing" by accident -- callers that
genuinely want to test the disabled path can still do so via
`cli_config={"enabled": False}`. See build_config.
"""
spec = spec or {}
overrides = merge_config(spec.get("config", {}) or {}, cli_config or {})
return build_config(plugin_dir, overrides)