mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-18 04:49:13 +00:00
load_config_defaults read only top-level properties. An object property carries
its defaults on its children, not on itself, so everything nested was dropped --
2,386 defaults across 37 of 44 plugins, soccer-scoreboard alone losing 539 of
565. render_plugin_matrix's comment says the plugin then "behaves like a real
install", which for most of the fleet it did not.
_defaults_from_properties now recurses. merge_config deep-merges the caller's
config onto the result so an override lands at the leaf: a shallow merge would
let -c '{"nhl": {"enabled": true}}' replace the whole nhl subtree and discard
every other nhl default, which is the same class of bug being fixed here.
Measured before/after across all 49 installed plugins on the rig: **no render
changed** -- identical PASS/FAIL counts, byte-identical output, goldens intact.
Plugins already fall back to the same values internally via config.get(key,
default), so supplying them explicitly agrees with what they were doing. The
defaults really are arriving now:
ufc-scoreboard 9 -> 87 defaults
ledmatrix-flights 51 -> 95
masters-tournament 10 -> 51
cricket-scoreboard 22 -> 50
tide-display 12 -> 18
and hockey-scoreboard, which used to load nhl.enabled=None, now gets
nhl.enabled=True with its full display_modes block.
Caveat worth carrying: the eight plugins with the most nested config
(soccer, baseball, basketball, hockey, lacrosse, football, afl, nrl -- 1,634 of
the 2,386 dropped defaults, 68%) could not be measured. They import
src.common.sports_shared, which the test rig's core branch predates, so they
fail to load there identically before and after. Re-run this comparison against
a core that has that module before trusting the "nothing changed" result for
them; those are exactly the plugins whose renders should change most.
Closes #531.
150 lines
6.0 KiB
Python
150 lines
6.0 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
|
|
|
|
|
|
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."""
|
|
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') as f:
|
|
return json.load(f)
|
|
|
|
|
|
def _defaults_from_properties(properties: Dict[str, Any]) -> Dict[str, Any]:
|
|
"""Defaults for one `properties` block, recursing into nested objects.
|
|
|
|
An object property carries its defaults on its children, not on itself, so
|
|
reading only the top level dropped everything nested. That is most of the
|
|
fleet: config organised by league, or under customization/display_options,
|
|
lost 2,386 defaults across 37 of 44 plugins -- soccer-scoreboard alone lost
|
|
539 of 565 -- and the harness rendered them with a config no install would
|
|
ever have.
|
|
"""
|
|
defaults: Dict[str, Any] = {}
|
|
for key, prop in (properties or {}).items():
|
|
if not isinstance(prop, dict):
|
|
continue
|
|
if prop.get('type') == 'object' and isinstance(prop.get('properties'), dict):
|
|
nested = _defaults_from_properties(prop['properties'])
|
|
if nested:
|
|
defaults[key] = nested
|
|
elif 'default' in prop:
|
|
defaults[key] = prop['default']
|
|
return defaults
|
|
|
|
|
|
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_config_defaults(plugin_dir: Union[str, Path]) -> Dict[str, Any]:
|
|
"""Extract default values from a plugin's config_schema.json (empty if none)."""
|
|
schema_path = Path(plugin_dir) / 'config_schema.json'
|
|
if not schema_path.exists():
|
|
return {}
|
|
with open(schema_path, 'r') as f:
|
|
schema = json.load(f)
|
|
return _defaults_from_properties(schema.get('properties', {}))
|
|
|
|
|
|
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') as f:
|
|
spec = 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') 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. `enabled` is re-asserted *after* 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}`.
|
|
"""
|
|
spec = spec or {}
|
|
config: Dict[str, Any] = {}
|
|
config.update(load_config_defaults(plugin_dir))
|
|
config["enabled"] = True
|
|
config.update(spec.get("config", {}))
|
|
config.update(cli_config or {})
|
|
return config
|