Files
LEDMatrix/test/test_common_is_hardware_free.py
T
ChuckandClaude Opus 5.5 61e462c635 refactor: remove the skin system and the unused src/base_classes package (#615)
* refactor: remove the skin system

Skins never rendered with the current scoreboard plugins: the only hook was
SportsCore._render_game in src/base_classes, which no plugin builds on, so
the UI and store already treated them as unsupported. The owner decided on
2026-09-23 to remove them outright.

Removed src/skin_system/ (runtime, base class, fixtures), skins/,
scripts/validate_skin.py and their tests; the store's "type": "skin"
installer, uninstaller and hide/refuse filters (the official registry lists
no skins); SchemaManager.inject_skin_selector; and GET /api/v3/skins.

Stored skin/skin_options config values are handled in the next commit.

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

* fix(config): drop retired skin/skin_options keys instead of validating them

A config.json written while the skin system existed can carry skin and
skin_options in any plugin section, and most plugin schemas set
additionalProperties: false. They are no longer core plugin properties;
RETIRED_PLUGIN_KEYS in schema_manager lists them and
drop_retired_plugin_keys removes them (unless the plugin's own schema
declares the name) in prepare_plugin_config, which loading, hot reload,
GET /plugins/config and both web saves already share, and in
validate_config_against_schema for callers that validate a raw section.
POST /plugins/config and /config/main also drop them from the stored
section they merge into, so they leave config.json on the next save.

Tests cover the load path (real PluginManager.load_plugin: no schema
warning, not degraded), raw and prepared validation,
validate_all_plugin_configs, and the JSON, form and /config/main saves.

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

* refactor: remove the unused src/base_classes package

No scoreboard plugin builds on src.base_classes: the nine monorepo
scoreboards ship their own sports.py and share code through src/common
(docs/SPORTS_UNIFICATION.md), and none of the third-party registry plugins
imports it. The one import anywhere, baseball-scoreboard's
rankings_manager.py, is a lazy import of ESPNDataSource in a class nothing
instantiates.

Removed the package and the eight test files that only tested it
(test_api_extractors, test_data_sources, test_sports_base_characterization,
test_sports_capabilities, test_sports_core_promotions,
test_sports_logo_cache_bounded, test_sports_modes_promotions,
test_sports_odds_fanout). test_common_is_hardware_free no longer lists
src.base_classes as a forbidden import, and comments in sports_helpers.py
and base_odds_manager.py stop pointing at it.

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

* docs: drop the skin system and src/base_classes from the docs

Deletes docs/SKIN_SYSTEM.md and docs/CREATING_SKINS.md and every link to
them (docs/README.md, README.md, PLUGIN_DEVELOPMENT_GUIDE.md, the /skins
section of REST_API_REFERENCE.md), the skin section of CLAUDE.md and the
term in PRODUCT.md. SPORTS_UNIFICATION.md now says src/base_classes was
removed and shared code lives in src/common, in the Layering section and
the view-model-contract rule. Other docs stop pointing at the removed
package. CHANGELOG records both removals under Unreleased.

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

* fix(store): hide and refuse registry entries that aren't plugins

The skin filters went with the skin system, but a custom registry can still
list "type": "skin" entries, and installing one as a plugin would unpack it
into the plugins directory. PluginStoreManager.is_plugin_entry() (a missing
type means plugin) now hides non-plugin entries from the store and
custom-registry listings, and install refuses them, in the route with a
clear 400 and in _install_plugin_impl for any other caller.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 16:33:24 -04:00

163 lines
6.7 KiB
Python

"""src/common must stay importable without display hardware.
Plugins import src.common.sports_* in place of their bundled copies. If any of
those modules reaches src.display_manager (and through it rgbmatrix), a
scoreboard adopting it acquires a hardware dependency it never had, and the headless
tooling -- the web preview, check_plugin.py, these tests on a laptop -- stops
being able to load it.
Two checks, because each misses what the other catches:
1. A real import in a fresh interpreter with rgbmatrix made unimportable.
Catches indirect imports (common -> X -> display_manager) that no text scan
of src/common would see.
2. An AST scan of src/common/*.py for module-level imports of the hardware and
plugin-system packages. Catches a direct import even where the target
happens to import cleanly on this machine.
"""
import ast
import subprocess
import sys
import textwrap
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[1]
COMMON = REPO_ROOT / "src" / "common"
FORBIDDEN = ("src.display_manager", "src.plugin_system")
#: Existing module-level violations, by file name, each with the reason it is
#: tolerated. Empty when this test was added (core 3.4.0 + sports_helpers):
#: no src/common module imported any of FORBIDDEN at module level. Add to this
#: only with a comment saying why, never to get a new import past the test.
ALLOWLIST = {}
def test_sports_modules_import_without_hardware():
modules = sorted(p.stem for p in COMMON.glob("sports_*.py"))
assert "sports_helpers" in modules
script = textwrap.dedent(f"""
import importlib, json, sys
sys.modules["rgbmatrix"] = None # any import of it now raises
importlib.import_module("src.common")
for name in {modules!r}:
importlib.import_module("src.common." + name)
loaded = sorted(m for m in sys.modules
if m.startswith({FORBIDDEN!r}))
print(json.dumps(loaded))
""")
result = subprocess.run(
[sys.executable, "-c", script], cwd=str(REPO_ROOT),
capture_output=True, text=True, timeout=120)
assert result.returncode == 0, (
f"importing src.common without rgbmatrix failed:\n{result.stderr}")
loaded = result.stdout.strip().splitlines()[-1]
assert loaded == "[]", f"src.common pulled in hardware modules: {loaded}"
def _module_level_imports(tree):
"""Imports executed at import time: top level, including inside if/try
blocks, but not inside functions or classes (those are deferred)."""
stack = list(tree.body)
while stack:
node = stack.pop()
if isinstance(node, (ast.Import, ast.ImportFrom)):
yield node
elif isinstance(node, (ast.If, ast.Try, ast.With)):
stack.extend(node.body)
stack.extend(getattr(node, "orelse", []))
stack.extend(getattr(node, "finalbody", []))
for handler in getattr(node, "handlers", []):
stack.extend(handler.body)
def _targets(node, package="src.common"):
"""Absolute dotted names an import can load. Relative imports resolve
against ``package``, the scanned module's package."""
if isinstance(node, ast.Import):
return [alias.name for alias in node.names]
base = node.module
if node.level:
parts = package.split(".")
if node.level > len(parts):
return [] # beyond the top-level package; Python rejects it too
anchor = ".".join(parts[:len(parts) - node.level + 1])
base = f"{anchor}.{node.module}" if node.module else anchor
if base is None:
return []
# `from pkg import name` may load the submodule pkg.name.
return [base] + [f"{base}.{alias.name}" for alias in node.names
if alias.name != "*"]
def _violations():
found = {}
for path in sorted(COMMON.glob("*.py")):
tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
for node in _module_level_imports(tree):
for target in _targets(node, "src.common"):
if target == FORBIDDEN or any(
target == f or target.startswith(f + ".") for f in FORBIDDEN):
found.setdefault(path.name, []).append(f"line {node.lineno}: {target}")
return found
def test_no_module_level_hardware_or_plugin_system_imports():
found = {name: hits for name, hits in _violations().items() if name not in ALLOWLIST}
assert found == {}, (
"src/common must not import these at module level (defer the import "
f"into the function that needs it): {found}")
def test_allowlist_has_no_stale_entries():
stale = sorted(set(ALLOWLIST) - set(_violations()))
assert stale == [], f"allowlisted but no longer violating, remove: {stale}"
def test_the_scan_sees_a_direct_import(tmp_path):
# Guard the guard: the walker must find imports nested in try/if.
tree = ast.parse(textwrap.dedent("""
try:
from src.display_manager import DisplayManager
except ImportError:
pass
if True:
import src.plugin_system.plugin_manager
from src import plugin_system
def later():
from src.display_manager import DisplayManager
"""))
targets = {t for n in _module_level_imports(tree) for t in _targets(n)}
assert {"src.plugin_system.plugin_manager", "src.display_manager",
"src.plugin_system"} <= targets
def test_the_scan_resolves_relative_imports():
# A module in src/common reaches src.plugin_system through `..`.
tree = ast.parse(textwrap.dedent("""
from .. import plugin_system
from ..plugin_system import plugin_manager
from . import sports_shared
"""))
targets = {t for n in _module_level_imports(tree)
for t in _targets(n, "src.common")}
assert {"src.plugin_system", "src.plugin_system.plugin_manager",
"src.common.sports_shared"} <= targets
# The package decides where `..` points.
node = ast.parse("from .. import plugin_system").body[0]
assert _targets(node, "src.common") == ["src", "src.plugin_system"]
assert _targets(node, "a.b") == ["a", "a.plugin_system"]
def test_the_scan_flags_relative_imports_in_src_common(monkeypatch, tmp_path):
# End to end through _violations(): both relative forms are reported.
(tmp_path / "bad.py").write_text(
"from .. import plugin_system\nfrom ..plugin_system import x\n",
encoding="utf-8")
monkeypatch.setitem(globals(), "COMMON", tmp_path)
found = {name: sorted(hits) for name, hits in _violations().items()}
assert found == {"bad.py": ["line 1: src.plugin_system",
"line 2: src.plugin_system",
"line 2: src.plugin_system.x"]}