mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-18 04:49:13 +00:00
Compare commits
12
Commits
v3.3.0
...
c9c258ac49
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c9c258ac49 | ||
|
|
a98bb4d866 | ||
|
|
431daa0b59 | ||
|
|
c7de8be6f5 | ||
|
|
2a92f1ab3b | ||
|
|
2538690435 | ||
|
|
76c5e367ef | ||
|
|
696acdbc7b | ||
|
|
91d15a8943 | ||
|
|
6bea1a7c21 | ||
|
|
0730d95200 | ||
|
|
32d637a446 |
@@ -52,6 +52,10 @@ def main() -> int:
|
|||||||
parser.add_argument('--height', type=int, default=32, help='Display height (default: 32)')
|
parser.add_argument('--height', type=int, default=32, help='Display height (default: 32)')
|
||||||
parser.add_argument('--skip-update', action='store_true',
|
parser.add_argument('--skip-update', action='store_true',
|
||||||
help='Skip calling update() (render display only)')
|
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()
|
args = parser.parse_args()
|
||||||
|
|
||||||
@@ -141,7 +145,25 @@ def main() -> int:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("update() raised: %s — continuing to display()", 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:
|
try:
|
||||||
|
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)
|
plugin_instance.display(force_clear=True)
|
||||||
logger.debug("display() completed")
|
logger.debug("display() completed")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@@ -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.
|
the extra guard only stops a None size raising TypeError.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import Any, Dict, Optional, Tuple
|
from typing import Any, Dict, Optional, Tuple
|
||||||
from zoneinfo import ZoneInfo
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"ELEMENT_FOR_FONT", "FAVORITE_RESULT_COLOR_DEFAULTS", "FONT_NAME_ALIASES",
|
"ELEMENT_FOR_FONT", "FAVORITE_RESULT_COLOR_DEFAULTS", "FONT_NAME_ALIASES",
|
||||||
"FONT_PIXEL_GRID", "MONTH_ABBR", "WEEKDAY_ABBR",
|
"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')
|
size = spec.get('properties', {}).get('font_size', {}).get('default')
|
||||||
if size is not None:
|
if size is not None:
|
||||||
cache[key] = int(size)
|
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 = {}
|
cache = {}
|
||||||
_SCHEMA_FONT_SIZE_CACHE[schema_path] = cache
|
_SCHEMA_FONT_SIZE_CACHE[schema_path] = cache
|
||||||
return cache.get(element_key)
|
return cache.get(element_key)
|
||||||
|
|||||||
@@ -211,17 +211,52 @@ class SportsCoreSharedMixin:
|
|||||||
return desired
|
return desired
|
||||||
return max(grid, int(round(float(desired) / grid)) * grid)
|
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]:
|
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
|
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
|
src/common/, so the directory has to come from the plugin.
|
||||||
instance. ``type(self).__module__`` alone is not enough: SportsCore is
|
|
||||||
an ABC, so a subclass built with ``type(name, bases, ns)`` -- which the
|
It is TOLD, not deduced. The first version walked the MRO for a class
|
||||||
plugins' own tests do -- reports its module as "abc". Walking the MRO
|
whose module sits beside a config_schema.json. That works when a test
|
||||||
steps past those synthetic classes to the first one whose module sits
|
imports the plugin itself, and returns None under the real loader, for
|
||||||
next to a config_schema.json, which is the real plugin.
|
a specific reason worth recording:
|
||||||
|
|
||||||
|
PluginLoader._namespace_plugin_modules renames every bare module a
|
||||||
|
plugin brought in (sports, game_renderer, ...) to
|
||||||
|
"_plg_<plugin_id>_<module>" 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__:
|
for cls in type(self).__mro__:
|
||||||
module = sys.modules.get(getattr(cls, "__module__", ""), None)
|
module = sys.modules.get(getattr(cls, "__module__", ""), None)
|
||||||
path = getattr(module, "__file__", None)
|
path = getattr(module, "__file__", None)
|
||||||
@@ -253,7 +288,23 @@ class SportsCoreSharedMixin:
|
|||||||
size = spec.get('properties', {}).get('font_size', {}).get('default')
|
size = spec.get('properties', {}).get('font_size', {}).get('default')
|
||||||
if size is not None:
|
if size is not None:
|
||||||
cache[key] = int(size)
|
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 = {}
|
cache = {}
|
||||||
self.__class__._SCHEMA_FONT_SIZES = cache
|
self.__class__._SCHEMA_FONT_SIZES = cache
|
||||||
return cache.get(element_key)
|
return cache.get(element_key)
|
||||||
|
|||||||
@@ -364,6 +364,7 @@ class DisplayManager:
|
|||||||
# Create image with the (logical) display dimensions
|
# Create image with the (logical) display dimensions
|
||||||
self.image = Image.new('RGB', (self.matrix.width, self.matrix.height))
|
self.image = Image.new('RGB', (self.matrix.width, self.matrix.height))
|
||||||
self.draw = ImageDraw.Draw(self.image)
|
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}")
|
logger.info(f"Image canvas created with dimensions: {self.matrix.width}x{self.matrix.height}")
|
||||||
|
|
||||||
# Initialize font with Press Start 2P
|
# Initialize font with Press Start 2P
|
||||||
@@ -403,6 +404,7 @@ class DisplayManager:
|
|||||||
|
|
||||||
self.image = Image.new('RGB', (fallback_width, fallback_height))
|
self.image = Image.new('RGB', (fallback_width, fallback_height))
|
||||||
self.draw = ImageDraw.Draw(self.image)
|
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
|
# Simple fallback visualization so web UI shows a realistic canvas
|
||||||
try:
|
try:
|
||||||
self.draw.rectangle([0, 0, fallback_width - 1, fallback_height - 1], outline=(255, 0, 0))
|
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, so swapping the buffer below is enough on its own.
|
||||||
self.image = Image.new('RGB', (target_w, target_h))
|
self.image = Image.new('RGB', (target_w, target_h))
|
||||||
self.draw = ImageDraw.Draw(self.image)
|
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
|
yield
|
||||||
finally:
|
finally:
|
||||||
self.matrix = real_matrix
|
self.matrix = real_matrix
|
||||||
@@ -814,6 +817,7 @@ class DisplayManager:
|
|||||||
|
|
||||||
self.image = Image.new('RGB', (width, height))
|
self.image = Image.new('RGB', (width, height))
|
||||||
self.draw = ImageDraw.Draw(self.image)
|
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")
|
logger.debug("Cleared display in fallback mode")
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -825,6 +829,7 @@ class DisplayManager:
|
|||||||
# Create a new black image
|
# Create a new black image
|
||||||
self.image = Image.new('RGB', (self.matrix.width, self.matrix.height))
|
self.image = Image.new('RGB', (self.matrix.width, self.matrix.height))
|
||||||
self.draw = ImageDraw.Draw(self.image)
|
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:
|
if not self._capture_mode_active:
|
||||||
# Clear both canvases and the underlying matrix to ensure no artifacts.
|
# Clear both canvases and the underlying matrix to ensure no artifacts.
|
||||||
@@ -1258,6 +1263,7 @@ class DisplayManager:
|
|||||||
try:
|
try:
|
||||||
self.image = Image.new('RGB', (self.width, self.height))
|
self.image = Image.new('RGB', (self.width, self.height))
|
||||||
self.draw = ImageDraw.Draw(self.image)
|
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):
|
except (OSError, RuntimeError, ValueError, MemoryError):
|
||||||
logger.debug("Canvas reset during cleanup failed", exc_info=True)
|
logger.debug("Canvas reset during cleanup failed", exc_info=True)
|
||||||
# Reset the singleton state when cleaning up
|
# Reset the singleton state when cleaning up
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ fixes their version string. See `docs/SPORTS_UNIFICATION.md`, phase B4.
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import re
|
import re
|
||||||
|
from pathlib import Path
|
||||||
from typing import Any, Dict, Optional, Tuple
|
from typing import Any, Dict, Optional, Tuple
|
||||||
|
|
||||||
# Below this, the core's self-reported version is not evidence of anything.
|
# 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)
|
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]]:
|
def parse_semver(value: Any) -> Optional[Tuple[int, int, int]]:
|
||||||
"""Parse ``X.Y.Z`` (extra parts and suffixes ignored) into a comparable
|
"""Parse ``X.Y.Z`` (extra parts and suffixes ignored) into a comparable
|
||||||
3-tuple, or ``None`` when unparseable. A leading ``v`` is tolerated."""
|
3-tuple, or ``None`` when unparseable. A leading ``v`` is tolerated."""
|
||||||
|
|||||||
@@ -772,8 +772,8 @@ class PluginLoader:
|
|||||||
newer than the running core. Advisory only — never raises — so a
|
newer than the running core. Advisory only — never raises — so a
|
||||||
plugin that guards optional features with try/except keeps working.
|
plugin that guards optional features with try/except keeps working.
|
||||||
"""
|
"""
|
||||||
from src import __version__ as core_version
|
|
||||||
from src.plugin_system import compatibility
|
from src.plugin_system import compatibility
|
||||||
|
core_version = compatibility.current_core_version()
|
||||||
|
|
||||||
compatible, _reason = compatibility.check(manifest, core_version)
|
compatible, _reason = compatibility.check(manifest, core_version)
|
||||||
if compatible:
|
if compatible:
|
||||||
|
|||||||
@@ -1467,8 +1467,11 @@ class PluginStoreManager:
|
|||||||
# already had. Allowing it costs them a plugin that raises
|
# already had. Allowing it costs them a plugin that raises
|
||||||
# ModuleNotFoundError at load and is reported only as one line
|
# ModuleNotFoundError at load and is reported only as one line
|
||||||
# in the journal. See docs/SPORTS_UNIFICATION.md (phase B4/B6).
|
# in the journal. See docs/SPORTS_UNIFICATION.md (phase B4/B6).
|
||||||
from src import __version__ as core_version
|
|
||||||
from src.plugin_system import compatibility
|
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)
|
compatible, reason = compatibility.check(manifest, core_version)
|
||||||
if not compatible:
|
if not compatible:
|
||||||
@@ -1612,8 +1615,8 @@ class PluginStoreManager:
|
|||||||
#
|
#
|
||||||
# Before the move, so the `finally` below removes the temp tree and
|
# Before the move, so the `finally` below removes the temp tree and
|
||||||
# nothing half-installed is left behind.
|
# nothing half-installed is left behind.
|
||||||
from src import __version__ as core_version
|
|
||||||
from src.plugin_system import compatibility
|
from src.plugin_system import compatibility
|
||||||
|
core_version = compatibility.current_core_version()
|
||||||
|
|
||||||
compatible, reason = compatibility.check(manifest, core_version)
|
compatible, reason = compatibility.check(manifest, core_version)
|
||||||
if not compatible:
|
if not compatible:
|
||||||
@@ -2644,8 +2647,8 @@ class PluginStoreManager:
|
|||||||
manifest_path, plugin_id, e)
|
manifest_path, plugin_id, e)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
from src import __version__ as core_version
|
|
||||||
from src.plugin_system import compatibility
|
from src.plugin_system import compatibility
|
||||||
|
core_version = compatibility.current_core_version()
|
||||||
|
|
||||||
compatible, reason = compatibility.check(manifest, core_version)
|
compatible, reason = compatibility.check(manifest, core_version)
|
||||||
if compatible:
|
if compatible:
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ class VisualTestDisplayManager:
|
|||||||
# Canvas
|
# Canvas
|
||||||
self.image = Image.new('RGB', (width, height), (0, 0, 0))
|
self.image = Image.new('RGB', (width, height), (0, 0, 0))
|
||||||
self.draw = ImageDraw.Draw(self.image)
|
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)
|
# Matrix proxy (plugins access display_manager.matrix.width/height)
|
||||||
self.matrix = _MatrixProxy(width, height)
|
self.matrix = _MatrixProxy(width, height)
|
||||||
@@ -184,6 +185,7 @@ class VisualTestDisplayManager:
|
|||||||
self.clear_called = True
|
self.clear_called = True
|
||||||
self.image = Image.new('RGB', (self._width, self._height), (0, 0, 0))
|
self.image = Image.new('RGB', (self._width, self._height), (0, 0, 0))
|
||||||
self.draw = ImageDraw.Draw(self.image)
|
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):
|
def update_display(self):
|
||||||
"""No-op for hardware; marks that display was updated."""
|
"""No-op for hardware; marks that display was updated."""
|
||||||
@@ -211,6 +213,7 @@ class VisualTestDisplayManager:
|
|||||||
self.matrix = _MatrixProxy(target_w, target_h)
|
self.matrix = _MatrixProxy(target_w, target_h)
|
||||||
self.image = Image.new('RGB', (target_w, target_h), (0, 0, 0))
|
self.image = Image.new('RGB', (target_w, target_h), (0, 0, 0))
|
||||||
self.draw = ImageDraw.Draw(self.image)
|
self.draw = ImageDraw.Draw(self.image)
|
||||||
|
self.draw.fontmode = "1" # Match production: 1-bit text, so goldens show what the panel shows.
|
||||||
yield
|
yield
|
||||||
finally:
|
finally:
|
||||||
self._width, self._height = prev_w, prev_h
|
self._width, self._height = prev_w, prev_h
|
||||||
@@ -569,6 +572,7 @@ class VisualTestDisplayManager:
|
|||||||
self.draw_calls = []
|
self.draw_calls = []
|
||||||
self.image = Image.new('RGB', (self._width, self._height), (0, 0, 0))
|
self.image = Image.new('RGB', (self._width, self._height), (0, 0, 0))
|
||||||
self.draw = ImageDraw.Draw(self.image)
|
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 = {
|
self._scrolling_state = {
|
||||||
'is_scrolling': False,
|
'is_scrolling': False,
|
||||||
'last_scroll_activity': 0,
|
'last_scroll_activity': 0,
|
||||||
@@ -582,3 +586,4 @@ class VisualTestDisplayManager:
|
|||||||
"""Clean up resources."""
|
"""Clean up resources."""
|
||||||
self.image = Image.new('RGB', (self._width, self._height), (0, 0, 0))
|
self.image = Image.new('RGB', (self._width, self._height), (0, 0, 0))
|
||||||
self.draw = ImageDraw.Draw(self.image)
|
self.draw = ImageDraw.Draw(self.image)
|
||||||
|
self.draw.fontmode = "1" # Match production: 1-bit text, so goldens show what the panel shows.
|
||||||
|
|||||||
@@ -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.")
|
||||||
@@ -319,3 +319,58 @@ class TestLiveMixin:
|
|||||||
assert not hasattr(h, "_empty_live_streak")
|
assert not hasattr(h, "_empty_live_streak")
|
||||||
h._note_live_fetch(False)
|
h._note_live_fetch(False)
|
||||||
assert h._empty_live_streak == 1
|
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")
|
||||||
|
|||||||
@@ -0,0 +1,507 @@
|
|||||||
|
"""The Starlark routes the frontend calls must exist.
|
||||||
|
|
||||||
|
`plugins_manager.js` posts to /api/v3/starlark/install-pixlet and then reloads
|
||||||
|
/api/v3/starlark/status. Neither route existed: #330 rewrote api_v3.py and
|
||||||
|
dropped all thirteen Starlark routes that #253 had added, so both calls fell
|
||||||
|
through to Flask's 404 handler, which answers
|
||||||
|
|
||||||
|
{"status": "error", "message": "Resource not found"}
|
||||||
|
|
||||||
|
and the button reported "Pixlet install failed: Resource not found" -- a
|
||||||
|
message that names neither the resource nor the cause.
|
||||||
|
|
||||||
|
These assert the routes are registered and answer in the shape the frontend
|
||||||
|
reads, so a future rewrite of this file cannot silently drop them again.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import threading
|
||||||
|
import types
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client():
|
||||||
|
from web_interface.app import app
|
||||||
|
app.config['TESTING'] = True
|
||||||
|
with app.test_client() as c:
|
||||||
|
yield c
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def offline_repository():
|
||||||
|
"""Stand in for TronbyteRepository so the store routes stay offline.
|
||||||
|
|
||||||
|
The browse and categories routes call list_all_apps_cached(), which on a
|
||||||
|
cold server-side cache fetches the app index from GitHub. That would make
|
||||||
|
the suite slow, network-dependent and rate-limitable -- and because these
|
||||||
|
tests only assert "not a 404", a rate-limited 500 would pass and hide it.
|
||||||
|
"""
|
||||||
|
repo = MagicMock()
|
||||||
|
repo.list_all_apps_cached.return_value = {
|
||||||
|
'apps': [], 'categories': [], 'authors': [], 'count': 0, 'cached': True,
|
||||||
|
}
|
||||||
|
with patch('web_interface.blueprints.api_v3._get_tronbyte_repository_class',
|
||||||
|
return_value=lambda *a, **kw: repo):
|
||||||
|
yield repo
|
||||||
|
|
||||||
|
|
||||||
|
class TestRoutesAreRegistered:
|
||||||
|
"""The failure was a missing route, so check the URL map directly.
|
||||||
|
|
||||||
|
All thirteen, not just the two the Pixlet button needs: #330 dropped the
|
||||||
|
lot, and the app store page is built on repository/browse,
|
||||||
|
repository/categories and repository/install, which 404 the same way.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("rule,method", [
|
||||||
|
("/api/v3/starlark/install-pixlet", "POST"),
|
||||||
|
("/api/v3/starlark/status", "GET"),
|
||||||
|
("/api/v3/starlark/apps", "GET"),
|
||||||
|
("/api/v3/starlark/upload", "POST"),
|
||||||
|
("/api/v3/starlark/repository/browse", "GET"),
|
||||||
|
("/api/v3/starlark/repository/categories", "GET"),
|
||||||
|
("/api/v3/starlark/repository/install", "POST"),
|
||||||
|
("/api/v3/starlark/apps/<app_id>", "GET"),
|
||||||
|
("/api/v3/starlark/apps/<app_id>", "DELETE"),
|
||||||
|
("/api/v3/starlark/apps/<app_id>/config", "GET"),
|
||||||
|
("/api/v3/starlark/apps/<app_id>/config", "PUT"),
|
||||||
|
("/api/v3/starlark/apps/<app_id>/toggle", "POST"),
|
||||||
|
("/api/v3/starlark/apps/<app_id>/render", "POST"),
|
||||||
|
])
|
||||||
|
def test_route_exists(self, rule, method):
|
||||||
|
from web_interface.app import app
|
||||||
|
matches = [r for r in app.url_map.iter_rules()
|
||||||
|
if r.rule == rule and method in r.methods]
|
||||||
|
assert matches, (
|
||||||
|
f"{method} {rule} is not registered; the frontend calls it and "
|
||||||
|
"would get Flask's generic 'Resource not found'")
|
||||||
|
|
||||||
|
|
||||||
|
class TestInstallPixlet:
|
||||||
|
def test_it_does_not_404(self, client):
|
||||||
|
with patch('web_interface.blueprints.api_v3.subprocess.run') as run:
|
||||||
|
run.return_value = MagicMock(returncode=0, stdout="ok", stderr="")
|
||||||
|
resp = client.post('/api/v3/starlark/install-pixlet')
|
||||||
|
assert resp.status_code != 404, "the route is still missing"
|
||||||
|
assert resp.get_json().get('message') != 'Resource not found'
|
||||||
|
|
||||||
|
def test_success_is_reported_in_the_shape_the_button_reads(self, client):
|
||||||
|
with patch('web_interface.blueprints.api_v3.subprocess.run') as run:
|
||||||
|
run.return_value = MagicMock(returncode=0, stdout="done", stderr="")
|
||||||
|
resp = client.post('/api/v3/starlark/install-pixlet')
|
||||||
|
body = resp.get_json()
|
||||||
|
assert body['status'] == 'success', body
|
||||||
|
assert 'message' in body, "the JS shows data.message on success"
|
||||||
|
|
||||||
|
def test_a_failed_download_says_why(self, client):
|
||||||
|
with patch('web_interface.blueprints.api_v3.subprocess.run') as run:
|
||||||
|
run.return_value = MagicMock(returncode=1, stdout="", stderr="no such release")
|
||||||
|
resp = client.post('/api/v3/starlark/install-pixlet')
|
||||||
|
body = resp.get_json()
|
||||||
|
assert body['status'] == 'error'
|
||||||
|
assert 'no such release' in body['message'], \
|
||||||
|
"the installer's own stderr is what tells the user what went wrong"
|
||||||
|
|
||||||
|
def test_a_timeout_is_reported_rather_than_hanging(self, client):
|
||||||
|
import subprocess as sp
|
||||||
|
with patch('web_interface.blueprints.api_v3.subprocess.run',
|
||||||
|
side_effect=sp.TimeoutExpired(cmd='x', timeout=300)):
|
||||||
|
resp = client.post('/api/v3/starlark/install-pixlet')
|
||||||
|
assert resp.get_json()['status'] == 'error'
|
||||||
|
assert 'timed out' in resp.get_json()['message'].lower()
|
||||||
|
|
||||||
|
|
||||||
|
class TestStarlarkStatus:
|
||||||
|
def test_it_does_not_404(self, client):
|
||||||
|
resp = client.get('/api/v3/starlark/status')
|
||||||
|
assert resp.status_code != 404, "the route is still missing"
|
||||||
|
assert resp.get_json().get('message') != 'Resource not found'
|
||||||
|
|
||||||
|
def test_it_reports_pixlet_availability_without_the_plugin_loaded(self, client):
|
||||||
|
# The status call runs before install too -- it must answer even when
|
||||||
|
# starlark-apps is not loaded, which is the state a user is in when
|
||||||
|
# they press the install button for the first time.
|
||||||
|
with patch('web_interface.blueprints.api_v3._get_starlark_plugin', return_value=None):
|
||||||
|
resp = client.get('/api/v3/starlark/status')
|
||||||
|
body = resp.get_json()
|
||||||
|
assert body['status'] == 'success', body
|
||||||
|
assert 'pixlet_available' in body
|
||||||
|
assert body['plugin_loaded'] is False
|
||||||
|
|
||||||
|
|
||||||
|
class TestTheInstallerScriptIsActuallyThere:
|
||||||
|
def test_download_pixlet_script_exists_and_is_executable(self):
|
||||||
|
# install_pixlet chmods and runs this; a missing file is the one error
|
||||||
|
# it reports as a 404 of its own, which would look identical to the
|
||||||
|
# bug being fixed here.
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
from web_interface.blueprints.api_v3 import PROJECT_ROOT
|
||||||
|
script = Path(PROJECT_ROOT) / 'scripts' / 'download_pixlet.sh'
|
||||||
|
assert script.is_file(), f"{script} is missing; install_pixlet would 404"
|
||||||
|
assert os.access(script, os.R_OK)
|
||||||
|
|
||||||
|
|
||||||
|
class TestTheAppStoreFlow:
|
||||||
|
"""Browsing and installing from the Tronbyte repository.
|
||||||
|
|
||||||
|
These are the calls the app store page makes. Each returned the generic
|
||||||
|
"Resource not found" before this change, which is indistinguishable from
|
||||||
|
an empty store.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def test_browse_does_not_404(self, client, offline_repository):
|
||||||
|
resp = client.get('/api/v3/starlark/repository/browse')
|
||||||
|
assert resp.status_code != 404, "the store cannot list anything"
|
||||||
|
assert resp.get_json().get('message') != 'Resource not found'
|
||||||
|
|
||||||
|
def test_categories_does_not_404(self, client, offline_repository):
|
||||||
|
resp = client.get('/api/v3/starlark/repository/categories')
|
||||||
|
assert resp.status_code != 404
|
||||||
|
assert resp.get_json().get('message') != 'Resource not found'
|
||||||
|
|
||||||
|
def test_installed_apps_list_does_not_404(self, client):
|
||||||
|
resp = client.get('/api/v3/starlark/apps')
|
||||||
|
assert resp.status_code != 404
|
||||||
|
assert resp.get_json().get('message') != 'Resource not found'
|
||||||
|
|
||||||
|
def test_repository_install_rejects_a_missing_body_rather_than_404ing(self, client):
|
||||||
|
# A 400/422 here is the route working: it received the call and said
|
||||||
|
# what was wrong. A 404 means it was never reached at all.
|
||||||
|
resp = client.post('/api/v3/starlark/repository/install',
|
||||||
|
json={}, content_type='application/json')
|
||||||
|
assert resp.status_code != 404, "the install route is still missing"
|
||||||
|
assert resp.get_json().get('message') != 'Resource not found'
|
||||||
|
|
||||||
|
def test_upload_rejects_an_empty_post_rather_than_404ing(self, client):
|
||||||
|
resp = client.post('/api/v3/starlark/upload')
|
||||||
|
assert resp.status_code != 404
|
||||||
|
assert resp.get_json().get('message') != 'Resource not found'
|
||||||
|
|
||||||
|
|
||||||
|
class TestNoStarlarkRouteIsMissing:
|
||||||
|
"""A single check that the whole set is present.
|
||||||
|
|
||||||
|
#330 removed all thirteen at once by rewriting this file. One assertion
|
||||||
|
over the frontend's own list is what would have caught that.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def test_every_endpoint_the_frontend_calls_is_registered(self):
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
from werkzeug.exceptions import MethodNotAllowed, NotFound
|
||||||
|
from web_interface.app import app
|
||||||
|
|
||||||
|
root = Path(__file__).resolve().parent.parent.parent
|
||||||
|
js = (root / 'web_interface' / 'static' / 'v3' / 'plugins_manager.js').read_text()
|
||||||
|
|
||||||
|
# The frontend builds some of these with template literals, e.g.
|
||||||
|
# `/api/v3/starlark/apps/${appId}/toggle`. Substitute a placeholder so
|
||||||
|
# the URL is concrete, then let Werkzeug match it the way a request
|
||||||
|
# would -- string comparison cannot see <app_id> rules.
|
||||||
|
raw = set(re.findall(r"[\'\"`](/api/v3/starlark/[^\'\"`\s]*)", js))
|
||||||
|
urls = set()
|
||||||
|
for u in raw:
|
||||||
|
u = re.sub(r"\$\{[^}]*\}", "probe", u)
|
||||||
|
urls.add(u.rstrip('/') or u)
|
||||||
|
assert urls, "found no starlark calls in the frontend -- did the file move?"
|
||||||
|
|
||||||
|
adapter = app.url_map.bind('localhost')
|
||||||
|
missing = []
|
||||||
|
for u in sorted(urls):
|
||||||
|
try:
|
||||||
|
adapter.match(u, method='GET')
|
||||||
|
except MethodNotAllowed:
|
||||||
|
pass # route exists, just not for GET -- fine
|
||||||
|
except NotFound:
|
||||||
|
missing.append(u)
|
||||||
|
assert not missing, f"the frontend calls these and they are not registered: {missing}"
|
||||||
|
|
||||||
|
|
||||||
|
class TestInstalledAppsAppearWithTheOtherPlugins:
|
||||||
|
"""An installed .star app must be manageable like any other plugin.
|
||||||
|
|
||||||
|
#253 surfaced installed apps in /plugins/installed as `starlark:<app_id>`
|
||||||
|
entries, so they could be seen and enabled/disabled from the same list as
|
||||||
|
everything else, and routed `starlark:` toggles to the Starlark manifest.
|
||||||
|
#330 removed both. The result was an app that installs successfully, then
|
||||||
|
appears nowhere and cannot be turned on or off.
|
||||||
|
"""
|
||||||
|
|
||||||
|
APPS = {'apps': {'quoteoftheday': {'name': 'A Quote A Day', 'enabled': True}}}
|
||||||
|
|
||||||
|
def test_an_installed_app_is_listed(self, client):
|
||||||
|
with patch('web_interface.blueprints.api_v3._get_starlark_plugin', return_value=None), \
|
||||||
|
patch('web_interface.blueprints.api_v3._read_starlark_manifest', return_value=self.APPS):
|
||||||
|
resp = client.get('/api/v3/plugins/installed')
|
||||||
|
ids = [p['id'] for p in resp.get_json()['data']['plugins']]
|
||||||
|
assert 'starlark:quoteoftheday' in ids, \
|
||||||
|
"an installed Starlark app does not appear among the plugins"
|
||||||
|
|
||||||
|
def test_the_entry_carries_what_the_ui_needs(self, client):
|
||||||
|
with patch('web_interface.blueprints.api_v3._get_starlark_plugin', return_value=None), \
|
||||||
|
patch('web_interface.blueprints.api_v3._read_starlark_manifest', return_value=self.APPS):
|
||||||
|
resp = client.get('/api/v3/plugins/installed')
|
||||||
|
entry = next(p for p in resp.get_json()['data']['plugins']
|
||||||
|
if p['id'] == 'starlark:quoteoftheday')
|
||||||
|
assert entry['name'] == 'A Quote A Day'
|
||||||
|
assert entry['enabled'] is True
|
||||||
|
assert entry['is_starlark_app'] is True, "the UI keys its Starlark handling off this"
|
||||||
|
assert entry['category'] == 'Starlark App'
|
||||||
|
|
||||||
|
def test_a_starlark_failure_does_not_empty_the_plugin_list(self, client):
|
||||||
|
# The virtual entries are appended to the real ones; a broken manifest
|
||||||
|
# must cost the Starlark rows, not everybody else's.
|
||||||
|
with patch('web_interface.blueprints.api_v3._get_starlark_plugin',
|
||||||
|
side_effect=RuntimeError('boom')):
|
||||||
|
resp = client.get('/api/v3/plugins/installed')
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.get_json()['status'] == 'success'
|
||||||
|
|
||||||
|
def test_toggling_an_app_does_not_report_plugin_not_found(self, client):
|
||||||
|
written = {}
|
||||||
|
with patch('web_interface.blueprints.api_v3._get_starlark_plugin', return_value=None), \
|
||||||
|
patch('web_interface.blueprints.api_v3._read_starlark_manifest',
|
||||||
|
return_value={'apps': {'quoteoftheday': {'enabled': True}}}), \
|
||||||
|
patch('web_interface.blueprints.api_v3._write_starlark_manifest',
|
||||||
|
side_effect=lambda m: written.update(m) or True):
|
||||||
|
resp = client.post('/api/v3/plugins/toggle',
|
||||||
|
json={'plugin_id': 'starlark:quoteoftheday', 'enabled': False})
|
||||||
|
body = resp.get_json()
|
||||||
|
assert body['status'] == 'success', body
|
||||||
|
assert body['enabled'] is False
|
||||||
|
assert written['apps']['quoteoftheday']['enabled'] is False, \
|
||||||
|
"the manifest was not actually updated"
|
||||||
|
|
||||||
|
def test_toggling_an_unknown_app_says_so(self, client):
|
||||||
|
with patch('web_interface.blueprints.api_v3._get_starlark_plugin', return_value=None), \
|
||||||
|
patch('web_interface.blueprints.api_v3._read_starlark_manifest',
|
||||||
|
return_value={'apps': {}}):
|
||||||
|
resp = client.post('/api/v3/plugins/toggle',
|
||||||
|
json={'plugin_id': 'starlark:nope', 'enabled': True})
|
||||||
|
assert resp.status_code == 404
|
||||||
|
assert 'nope' in resp.get_json()['message']
|
||||||
|
|
||||||
|
def test_a_traversal_app_id_is_rejected_before_touching_the_manifest(self, client):
|
||||||
|
resp = client.post('/api/v3/plugins/toggle',
|
||||||
|
json={'plugin_id': 'starlark:../../etc/passwd', 'enabled': True})
|
||||||
|
assert resp.status_code == 400
|
||||||
|
assert 'traversal' in resp.get_json()['message']
|
||||||
|
|
||||||
|
def test_an_app_id_the_listing_published_can_be_toggled(self, client):
|
||||||
|
"""The id here is the one _starlark_virtual_plugins publishes.
|
||||||
|
|
||||||
|
It used to be re-slugified on the way in -- lowercased, with every
|
||||||
|
character outside [a-z0-9_] replaced -- so 'My-App' was listed as
|
||||||
|
'starlark:My-App' and looked up as 'my_app', and toggling an app the
|
||||||
|
page had just drawn answered 404.
|
||||||
|
"""
|
||||||
|
manifest = {'apps': {'My-App': {'name': 'My App', 'enabled': False}}}
|
||||||
|
with patch('web_interface.blueprints.api_v3._get_starlark_plugin', return_value=None), \
|
||||||
|
patch('web_interface.blueprints.api_v3._read_starlark_manifest',
|
||||||
|
return_value=manifest), \
|
||||||
|
patch('web_interface.blueprints.api_v3._write_starlark_manifest',
|
||||||
|
return_value=True) as write:
|
||||||
|
resp = client.post('/api/v3/plugins/toggle',
|
||||||
|
json={'plugin_id': 'starlark:My-App', 'enabled': True})
|
||||||
|
assert resp.status_code == 200, resp.get_json()
|
||||||
|
assert write.called, "the toggle never reached the manifest"
|
||||||
|
assert manifest['apps']['My-App']['enabled'] is True
|
||||||
|
|
||||||
|
def test_a_failed_manifest_write_is_not_reported_as_success(self, client):
|
||||||
|
"""The toggle answered 200 while the change was never persisted."""
|
||||||
|
manifest = {'apps': {'demo': {'name': 'Demo', 'enabled': False}}}
|
||||||
|
with patch('web_interface.blueprints.api_v3._get_starlark_plugin', return_value=None), \
|
||||||
|
patch('web_interface.blueprints.api_v3._read_starlark_manifest',
|
||||||
|
return_value=manifest), \
|
||||||
|
patch('web_interface.blueprints.api_v3._write_starlark_manifest',
|
||||||
|
return_value=False):
|
||||||
|
resp = client.post('/api/v3/plugins/toggle',
|
||||||
|
json={'plugin_id': 'starlark:demo', 'enabled': True})
|
||||||
|
assert resp.status_code == 500
|
||||||
|
|
||||||
|
def test_a_loaded_app_is_not_flipped_when_the_manifest_write_fails(self, client):
|
||||||
|
"""Persist first, then update memory -- otherwise the UI shows a
|
||||||
|
toggle that silently reverts on the next restart."""
|
||||||
|
app = MagicMock()
|
||||||
|
app.manifest = {'enabled': False}
|
||||||
|
plugin = MagicMock()
|
||||||
|
plugin.apps = {'demo': app}
|
||||||
|
plugin._update_manifest_safe.return_value = False
|
||||||
|
|
||||||
|
with patch('web_interface.blueprints.api_v3._get_starlark_plugin', return_value=plugin):
|
||||||
|
resp = client.post('/api/v3/plugins/toggle',
|
||||||
|
json={'plugin_id': 'starlark:demo', 'enabled': True})
|
||||||
|
assert resp.status_code == 500
|
||||||
|
assert app.manifest['enabled'] is False, "in-memory state changed without being saved"
|
||||||
|
|
||||||
|
|
||||||
|
class TestTheManifestSurvivesConcurrentWriters:
|
||||||
|
"""Flask serves requests concurrently and five routes write this file.
|
||||||
|
|
||||||
|
The atomic-write pattern used one fixed temp name, `manifest.tmp`, shared
|
||||||
|
by every writer: two of them opened it, interleaved their json.dump output,
|
||||||
|
and both renamed. The rename is atomic; the content it published was the
|
||||||
|
mixture, which the next read could not parse.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def starlark_dir(self, tmp_path, monkeypatch):
|
||||||
|
from web_interface.blueprints import api_v3 as module
|
||||||
|
apps_dir = tmp_path / "starlark-apps"
|
||||||
|
apps_dir.mkdir()
|
||||||
|
monkeypatch.setattr(module, '_STARLARK_APPS_DIR', apps_dir)
|
||||||
|
monkeypatch.setattr(module, '_STARLARK_MANIFEST_FILE', apps_dir / 'manifest.json')
|
||||||
|
return apps_dir
|
||||||
|
|
||||||
|
def test_each_writer_gets_its_own_temp_file(self, starlark_dir):
|
||||||
|
from web_interface.blueprints import api_v3 as module
|
||||||
|
|
||||||
|
seen = []
|
||||||
|
real_mkstemp = tempfile.mkstemp
|
||||||
|
|
||||||
|
def recording_mkstemp(*args, **kwargs):
|
||||||
|
fd, name = real_mkstemp(*args, **kwargs)
|
||||||
|
seen.append(name)
|
||||||
|
return fd, name
|
||||||
|
|
||||||
|
with patch.object(module.tempfile, 'mkstemp', side_effect=recording_mkstemp):
|
||||||
|
for i in range(5):
|
||||||
|
assert module._write_starlark_manifest({'apps': {f'app{i}': {}}})
|
||||||
|
|
||||||
|
assert len(set(seen)) == 5, f"writers shared a temp file: {seen}"
|
||||||
|
|
||||||
|
def test_concurrent_writes_leave_readable_json(self, starlark_dir):
|
||||||
|
from web_interface.blueprints import api_v3 as module
|
||||||
|
|
||||||
|
manifests = [{'apps': {f'app{i}': {'name': 'x' * 400}}} for i in range(8)]
|
||||||
|
errors = []
|
||||||
|
|
||||||
|
def write(m):
|
||||||
|
try:
|
||||||
|
module._write_starlark_manifest(m)
|
||||||
|
except Exception as exc: # noqa: BLE001 - surfaced by the assert below
|
||||||
|
errors.append(exc)
|
||||||
|
|
||||||
|
threads = [threading.Thread(target=write, args=(m,)) for m in manifests]
|
||||||
|
for t in threads:
|
||||||
|
t.start()
|
||||||
|
for t in threads:
|
||||||
|
t.join()
|
||||||
|
|
||||||
|
assert not errors
|
||||||
|
loaded = json.loads((starlark_dir / 'manifest.json').read_text())
|
||||||
|
assert loaded in manifests, "the published manifest was a mix of two writers"
|
||||||
|
|
||||||
|
def test_no_temp_files_are_left_behind(self, starlark_dir):
|
||||||
|
from web_interface.blueprints import api_v3 as module
|
||||||
|
module._write_starlark_manifest({'apps': {}})
|
||||||
|
assert list(starlark_dir.glob('*.tmp')) == []
|
||||||
|
|
||||||
|
def test_the_installed_star_file_is_recorded_by_name(self, starlark_dir, tmp_path):
|
||||||
|
"""Readers join this to the app's own directory and default to a bare
|
||||||
|
'<app_id>.star'. An absolute value gave the key two meanings and, since
|
||||||
|
Path.__truediv__ discards the left side when the right is absolute,
|
||||||
|
pinned the manifest to the PROJECT_ROOT it was installed under."""
|
||||||
|
from web_interface.blueprints import api_v3 as module
|
||||||
|
|
||||||
|
source = tmp_path / "source.star"
|
||||||
|
source.write_text("# app")
|
||||||
|
with patch.object(module, '_get_pixlet_renderer_class',
|
||||||
|
side_effect=ImportError("no pixlet here")):
|
||||||
|
assert module._install_star_file('demo', str(source), {'name': 'Demo'})
|
||||||
|
|
||||||
|
entry = json.loads((starlark_dir / 'manifest.json').read_text())['apps']['demo']
|
||||||
|
assert entry['star_file'] == 'demo.star'
|
||||||
|
assert not os.path.isabs(entry['star_file'])
|
||||||
|
|
||||||
|
|
||||||
|
class TestATransientImportFailureIsNotPermanent:
|
||||||
|
"""Both importers insert into sys.modules before executing the module.
|
||||||
|
|
||||||
|
That order is required -- a module has to be findable while it runs -- but
|
||||||
|
a failure left the half-initialised object cached, so every later call took
|
||||||
|
the cache branch and raised AttributeError on the missing class instead of
|
||||||
|
retrying. One transient failure disabled the repository or the renderer for
|
||||||
|
the life of the process.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("getter,key", [
|
||||||
|
('_get_tronbyte_repository_class', 'tronbyte_repository'),
|
||||||
|
('_get_pixlet_renderer_class', 'pixlet_renderer'),
|
||||||
|
])
|
||||||
|
def test_a_failed_import_leaves_no_entry_behind(self, getter, key):
|
||||||
|
from web_interface.blueprints import api_v3 as module
|
||||||
|
|
||||||
|
original = sys.modules.pop(key, None)
|
||||||
|
try:
|
||||||
|
with patch('importlib.util.module_from_spec') as from_spec:
|
||||||
|
from_spec.return_value = types.ModuleType(key)
|
||||||
|
with patch('importlib.util.spec_from_file_location') as spec_from:
|
||||||
|
spec = MagicMock()
|
||||||
|
spec.loader.exec_module.side_effect = RuntimeError("network down")
|
||||||
|
spec_from.return_value = spec
|
||||||
|
|
||||||
|
with pytest.raises(RuntimeError):
|
||||||
|
getattr(module, getter)()
|
||||||
|
|
||||||
|
assert key not in sys.modules, "a half-initialised module stayed cached"
|
||||||
|
finally:
|
||||||
|
if original is not None:
|
||||||
|
sys.modules[key] = original
|
||||||
|
else:
|
||||||
|
sys.modules.pop(key, None)
|
||||||
|
|
||||||
|
|
||||||
|
class TestConfigIsNotAppliedUntilItIsSaved:
|
||||||
|
"""save_config() returning False answers 500, but the loaded app kept the
|
||||||
|
new values -- so GET config reported settings that were never written and
|
||||||
|
the plugin rendered with them until a restart silently reverted them."""
|
||||||
|
|
||||||
|
def _plugin_with_app(self, save_ok):
|
||||||
|
app = MagicMock()
|
||||||
|
app.config = {'city': 'Philadelphia'}
|
||||||
|
app.manifest = {'render_interval': 300, 'display_duration': 15}
|
||||||
|
app.save_config.return_value = save_ok
|
||||||
|
plugin = MagicMock()
|
||||||
|
plugin.apps = {'demo': app}
|
||||||
|
plugin._update_manifest_safe.return_value = True
|
||||||
|
return plugin, app
|
||||||
|
|
||||||
|
def test_a_failed_save_leaves_the_config_untouched(self, client):
|
||||||
|
plugin, app = self._plugin_with_app(save_ok=False)
|
||||||
|
with patch('web_interface.blueprints.api_v3._get_starlark_plugin', return_value=plugin):
|
||||||
|
resp = client.put('/api/v3/starlark/apps/demo/config',
|
||||||
|
json={'city': 'Pittsburgh'})
|
||||||
|
assert resp.status_code == 500
|
||||||
|
assert app.config == {'city': 'Philadelphia'}
|
||||||
|
|
||||||
|
def test_a_failed_save_leaves_the_timing_untouched(self, client):
|
||||||
|
plugin, app = self._plugin_with_app(save_ok=False)
|
||||||
|
with patch('web_interface.blueprints.api_v3._get_starlark_plugin', return_value=plugin):
|
||||||
|
resp = client.put('/api/v3/starlark/apps/demo/config',
|
||||||
|
json={'render_interval': 60})
|
||||||
|
assert resp.status_code == 500
|
||||||
|
assert app.manifest['render_interval'] == 300
|
||||||
|
|
||||||
|
def test_a_failed_save_does_not_re_render_with_values_it_did_not_keep(self, client):
|
||||||
|
plugin, _ = self._plugin_with_app(save_ok=False)
|
||||||
|
with patch('web_interface.blueprints.api_v3._get_starlark_plugin', return_value=plugin):
|
||||||
|
client.put('/api/v3/starlark/apps/demo/config', json={'city': 'Pittsburgh'})
|
||||||
|
plugin._render_app.assert_not_called()
|
||||||
|
|
||||||
|
def test_a_successful_save_still_applies(self, client):
|
||||||
|
plugin, app = self._plugin_with_app(save_ok=True)
|
||||||
|
with patch('web_interface.blueprints.api_v3._get_starlark_plugin', return_value=plugin):
|
||||||
|
resp = client.put('/api/v3/starlark/apps/demo/config',
|
||||||
|
json={'city': 'Pittsburgh', 'render_interval': 60})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert app.config['city'] == 'Pittsburgh'
|
||||||
|
assert app.manifest['render_interval'] == 60
|
||||||
|
plugin._render_app.assert_called_once()
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1164,9 +1164,16 @@
|
|||||||
// The pull replaced files on disk; the running services still
|
// The pull replaced files on disk; the running services still
|
||||||
// hold the code they loaded at boot. Ask for the restart that
|
// hold the code they loaded at boot. Ask for the restart that
|
||||||
// makes the update actually take effect.
|
// 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') {
|
if (data.restart_required && typeof window.showRestartPending === 'function') {
|
||||||
window.showRestartPending(
|
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') {
|
if (typeof showNotification === 'function') {
|
||||||
|
|||||||
Reference in New Issue
Block a user