led-ticker-core 2.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- led_ticker/__init__.py +3 -0
- led_ticker/_coerce.py +125 -0
- led_ticker/_compat.py +42 -0
- led_ticker/_plugin_hint.py +37 -0
- led_ticker/_plugin_loader.py +393 -0
- led_ticker/_rgbmatrix_stub.py +132 -0
- led_ticker/_types.py +41 -0
- led_ticker/animations.py +80 -0
- led_ticker/app/__init__.py +83 -0
- led_ticker/app/cli.py +477 -0
- led_ticker/app/coercion.py +800 -0
- led_ticker/app/factories.py +1318 -0
- led_ticker/app/plugin_cmd.py +633 -0
- led_ticker/app/run.py +890 -0
- led_ticker/borders.py +679 -0
- led_ticker/busy_http.py +84 -0
- led_ticker/busy_light.py +87 -0
- led_ticker/color_lut.py +40 -0
- led_ticker/color_providers.py +281 -0
- led_ticker/colors.py +81 -0
- led_ticker/config.py +693 -0
- led_ticker/drawing.py +214 -0
- led_ticker/fonts/5x8.bdf +21422 -0
- led_ticker/fonts/6x10.bdf +31042 -0
- led_ticker/fonts/6x12.bdf +86121 -0
- led_ticker/fonts/7x13.bdf +64553 -0
- led_ticker/fonts/__init__.py +227 -0
- led_ticker/fonts/bdf_parser.py +124 -0
- led_ticker/fonts/hires/Inter-Bold.otf +0 -0
- led_ticker/fonts/hires/Inter-Regular.otf +0 -0
- led_ticker/fonts/hires_loader.py +268 -0
- led_ticker/frame.py +146 -0
- led_ticker/pixel_emoji.py +2904 -0
- led_ticker/plugin.py +427 -0
- led_ticker/plugins_catalog.json +112 -0
- led_ticker/plugins_catalog.py +257 -0
- led_ticker/preview.py +263 -0
- led_ticker/reload.py +140 -0
- led_ticker/render_breaker.py +99 -0
- led_ticker/scaled_canvas.py +178 -0
- led_ticker/schedule.py +172 -0
- led_ticker/status_board.py +354 -0
- led_ticker/text_render.py +189 -0
- led_ticker/ticker.py +1279 -0
- led_ticker/transitions/__init__.py +416 -0
- led_ticker/transitions/_hires_loader.py +305 -0
- led_ticker/transitions/effects.py +217 -0
- led_ticker/transitions/push.py +271 -0
- led_ticker/transitions/wipe.py +240 -0
- led_ticker/validate.py +2120 -0
- led_ticker/webui/__init__.py +415 -0
- led_ticker/webui/_paths.py +45 -0
- led_ticker/webui/inventory.py +80 -0
- led_ticker/webui/redact.py +88 -0
- led_ticker/webui/static/index.html +596 -0
- led_ticker/widget.py +177 -0
- led_ticker/widgets/__init__.py +46 -0
- led_ticker/widgets/_frame_aware.py +156 -0
- led_ticker/widgets/_gif_decode.py +82 -0
- led_ticker/widgets/_image_base.py +1691 -0
- led_ticker/widgets/_image_fit.py +202 -0
- led_ticker/widgets/_row_layout.py +119 -0
- led_ticker/widgets/clock.py +172 -0
- led_ticker/widgets/gif.py +346 -0
- led_ticker/widgets/message.py +383 -0
- led_ticker/widgets/still.py +313 -0
- led_ticker/widgets/two_row.py +641 -0
- led_ticker/widgets/weather_icons.py +262 -0
- led_ticker_core-2.0.0.dist-info/METADATA +134 -0
- led_ticker_core-2.0.0.dist-info/RECORD +73 -0
- led_ticker_core-2.0.0.dist-info/WHEEL +4 -0
- led_ticker_core-2.0.0.dist-info/entry_points.txt +2 -0
- led_ticker_core-2.0.0.dist-info/licenses/LICENSE +21 -0
led_ticker/__init__.py
ADDED
led_ticker/_coerce.py
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""Pure coercion helpers for config load.
|
|
2
|
+
|
|
3
|
+
Each helper returns `(coerced_value, warning_or_None)`. The caller
|
|
4
|
+
decides whether to surface the warning via `led-ticker validate`
|
|
5
|
+
output, runtime `logging.warning`, or both.
|
|
6
|
+
|
|
7
|
+
Bool is rejected explicitly in `coerce_int` / `coerce_float` because
|
|
8
|
+
bool is a subclass of `int` in Python. Silently coercing `true → 1`
|
|
9
|
+
would reopen the hole that the existing `bottom_text_loops` and
|
|
10
|
+
`font_threshold` validators close.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import attrs
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@attrs.frozen
|
|
17
|
+
class CoercionWarning:
|
|
18
|
+
field: str
|
|
19
|
+
original: object
|
|
20
|
+
coerced: object
|
|
21
|
+
message: str
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def coerce_int(value: object, *, field: str) -> tuple[int, CoercionWarning | None]:
|
|
25
|
+
"""Coerce string-of-digits → int. Raise ValueError otherwise.
|
|
26
|
+
|
|
27
|
+
Rejects: bool, float, non-numeric strings, None.
|
|
28
|
+
"""
|
|
29
|
+
if isinstance(value, bool):
|
|
30
|
+
raise ValueError(
|
|
31
|
+
f"{field} must be an int; got bool ({value!r}). "
|
|
32
|
+
f"TOML has native true/false — if you meant a number, drop the "
|
|
33
|
+
f"true/false and write 0 or 1 explicitly."
|
|
34
|
+
)
|
|
35
|
+
if isinstance(value, int):
|
|
36
|
+
return value, None
|
|
37
|
+
if isinstance(value, str):
|
|
38
|
+
try:
|
|
39
|
+
coerced = int(value)
|
|
40
|
+
except ValueError:
|
|
41
|
+
raise ValueError(
|
|
42
|
+
f'{field} must be an int; got str ("{value}"). '
|
|
43
|
+
f"Drop the quotes around the number (e.g. {field} = 25 "
|
|
44
|
+
f'instead of {field} = "25").'
|
|
45
|
+
) from None
|
|
46
|
+
return coerced, CoercionWarning(
|
|
47
|
+
field=field,
|
|
48
|
+
original=value,
|
|
49
|
+
coerced=coerced,
|
|
50
|
+
message=(
|
|
51
|
+
f'{field} was a string ("{value}"); coerced to int {coerced}. '
|
|
52
|
+
f"Drop the quotes around the number to silence this warning."
|
|
53
|
+
),
|
|
54
|
+
)
|
|
55
|
+
raise ValueError(f"{field} must be an int; got {type(value).__name__} ({value!r}).")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def coerce_float(value: object, *, field: str) -> tuple[float, CoercionWarning | None]:
|
|
59
|
+
"""Coerce string-of-number → float. Accept int passthrough.
|
|
60
|
+
|
|
61
|
+
Rejects: bool, non-numeric strings, None.
|
|
62
|
+
"""
|
|
63
|
+
if isinstance(value, bool):
|
|
64
|
+
raise ValueError(
|
|
65
|
+
f"{field} must be a float; got bool ({value!r}). "
|
|
66
|
+
f"Use a number (e.g. {field} = 3.0)."
|
|
67
|
+
)
|
|
68
|
+
if isinstance(value, int):
|
|
69
|
+
return float(value), None
|
|
70
|
+
if isinstance(value, float):
|
|
71
|
+
return value, None
|
|
72
|
+
if isinstance(value, str):
|
|
73
|
+
try:
|
|
74
|
+
coerced = float(value)
|
|
75
|
+
except ValueError:
|
|
76
|
+
raise ValueError(
|
|
77
|
+
f'{field} must be a float; got str ("{value}"). '
|
|
78
|
+
f"Drop the quotes around the number (e.g. {field} = 3.0 "
|
|
79
|
+
f'instead of {field} = "3.0").'
|
|
80
|
+
) from None
|
|
81
|
+
return coerced, CoercionWarning(
|
|
82
|
+
field=field,
|
|
83
|
+
original=value,
|
|
84
|
+
coerced=coerced,
|
|
85
|
+
message=(
|
|
86
|
+
f'{field} was a string ("{value}"); coerced to float {coerced}. '
|
|
87
|
+
f"Drop the quotes around the number to silence this warning."
|
|
88
|
+
),
|
|
89
|
+
)
|
|
90
|
+
raise ValueError(
|
|
91
|
+
f"{field} must be a float; got {type(value).__name__} ({value!r})."
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def coerce_choice(
|
|
96
|
+
value: object, *, field: str, valid: frozenset[str]
|
|
97
|
+
) -> tuple[str, CoercionWarning | None]:
|
|
98
|
+
"""Normalize a closed-set enum string (lowercase + strip).
|
|
99
|
+
|
|
100
|
+
Raise ValueError if the input isn't a string, or if the normalized
|
|
101
|
+
value still isn't in `valid`.
|
|
102
|
+
"""
|
|
103
|
+
if not isinstance(value, str):
|
|
104
|
+
raise ValueError(
|
|
105
|
+
f"{field} must be a string; got {type(value).__name__} "
|
|
106
|
+
f"({value!r}). Expected one of {sorted(valid)}."
|
|
107
|
+
)
|
|
108
|
+
normalized = value.strip().lower()
|
|
109
|
+
if normalized not in valid:
|
|
110
|
+
raise ValueError(
|
|
111
|
+
f'{field}="{value}" is not a valid choice; expected one of {sorted(valid)}.'
|
|
112
|
+
)
|
|
113
|
+
if normalized == value:
|
|
114
|
+
return normalized, None
|
|
115
|
+
return normalized, CoercionWarning(
|
|
116
|
+
field=field,
|
|
117
|
+
original=value,
|
|
118
|
+
coerced=normalized,
|
|
119
|
+
message=(
|
|
120
|
+
f'{field} was "{value}"; coerced to "{normalized}". Enum '
|
|
121
|
+
f"values are case-insensitive but the canonical form is "
|
|
122
|
+
f'lowercase — write {field} = "{normalized}" to silence '
|
|
123
|
+
f"this warning."
|
|
124
|
+
),
|
|
125
|
+
)
|
led_ticker/_compat.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Compatibility shim for rgbmatrix.
|
|
2
|
+
|
|
3
|
+
Attempts to import the real rgbmatrix C library (available on Raspberry Pi).
|
|
4
|
+
Falls back to a bundled pure-Python stub of `graphics.*` (Color, Font,
|
|
5
|
+
DrawText) when it isn't installed, so `led-ticker validate` and other
|
|
6
|
+
non-drawing operations work on any machine without PYTHONPATH tricks.
|
|
7
|
+
|
|
8
|
+
`RGBMatrix` and `RGBMatrixOptions` are NOT stubbed — running the actual
|
|
9
|
+
display requires real hardware. `require_matrix()` raises a clear error
|
|
10
|
+
if you try to construct a matrix without rgbmatrix installed.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
try:
|
|
16
|
+
from rgbmatrix import RGBMatrix, RGBMatrixOptions, graphics
|
|
17
|
+
except ImportError:
|
|
18
|
+
from led_ticker import _rgbmatrix_stub as graphics # type: ignore[assignment]
|
|
19
|
+
|
|
20
|
+
RGBMatrix = None # type: ignore[assignment]
|
|
21
|
+
RGBMatrixOptions = None # type: ignore[assignment]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def require_graphics() -> Any:
|
|
25
|
+
"""Return the graphics module (real rgbmatrix or bundled stub)."""
|
|
26
|
+
return graphics
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def require_matrix() -> Any:
|
|
30
|
+
"""Return the RGBMatrix class. Raises if rgbmatrix is not installed.
|
|
31
|
+
|
|
32
|
+
Use this when actually constructing the display — graphics-only
|
|
33
|
+
operations (Color, Font, DrawText) should use `require_graphics()`
|
|
34
|
+
instead, which always returns something usable.
|
|
35
|
+
"""
|
|
36
|
+
if RGBMatrix is None:
|
|
37
|
+
raise RuntimeError(
|
|
38
|
+
"rgbmatrix hardware library not installed. "
|
|
39
|
+
"Run on a Raspberry Pi with the LED matrix library, "
|
|
40
|
+
"or use `PYTHONPATH=tests/stubs` for test fixtures."
|
|
41
|
+
)
|
|
42
|
+
return RGBMatrix
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Shared helper for "this name didn't resolve" errors across every
|
|
2
|
+
registry (transitions, widgets, borders, color providers, animations).
|
|
3
|
+
|
|
4
|
+
A namespaced name (`<plugin>.<name>`) that fails to resolve almost
|
|
5
|
+
always means the owning plugin isn't installed. This helper turns that
|
|
6
|
+
into an actionable hint. It is pure and context-free — it does NOT
|
|
7
|
+
consult the loaded-plugin set, so it works from the bare runtime
|
|
8
|
+
registry lookups that have no `LoadedPlugins` handle.
|
|
9
|
+
|
|
10
|
+
The fix text suggests `led-ticker plugin install <namespace>` (the plugin
|
|
11
|
+
catalog CLI). This is the one place to update if that command changes.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def plugin_hint(name: str, kind: str) -> str | None:
|
|
16
|
+
"""Return an install hint if `name` looks like a reference to an
|
|
17
|
+
uninstalled plugin component, else None.
|
|
18
|
+
|
|
19
|
+
A hint is returned only when the namespace (segment before the first
|
|
20
|
+
dot) is a valid Python identifier — real plugin namespaces always are
|
|
21
|
+
(e.g. ``baseball``, ``pool``, ``crypto``), while dotted non-plugin
|
|
22
|
+
values like ``"1.5"`` or ``"."`` are not and fall through to None.
|
|
23
|
+
|
|
24
|
+
`kind` is the human word for the registry — "transition", "widget",
|
|
25
|
+
"border", "color provider", "animation".
|
|
26
|
+
"""
|
|
27
|
+
if "." not in name:
|
|
28
|
+
return None
|
|
29
|
+
namespace = name.split(".", 1)[0]
|
|
30
|
+
if not namespace.isidentifier():
|
|
31
|
+
return None
|
|
32
|
+
return (
|
|
33
|
+
f"{name!r} looks like a plugin {kind}, but no {namespace!r} plugin "
|
|
34
|
+
f"is loaded. Install it with `led-ticker plugin install {namespace}` "
|
|
35
|
+
f"(or check the namespace). "
|
|
36
|
+
f"See https://docs.ledticker.dev/plugins/."
|
|
37
|
+
)
|
|
@@ -0,0 +1,393 @@
|
|
|
1
|
+
"""Plugin discovery and loading (internal). Plugins never import this."""
|
|
2
|
+
|
|
3
|
+
import contextlib
|
|
4
|
+
import importlib.metadata
|
|
5
|
+
import importlib.util
|
|
6
|
+
import inspect
|
|
7
|
+
import logging
|
|
8
|
+
from collections.abc import Callable
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from types import ModuleType
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from led_ticker.animations import _ANIMATION_REGISTRY
|
|
15
|
+
from led_ticker.borders import _BORDER_REGISTRY
|
|
16
|
+
from led_ticker.color_providers import _PROVIDER_REGISTRY
|
|
17
|
+
from led_ticker.config import PluginsConfig, _parse_plugins_block
|
|
18
|
+
from led_ticker.fonts.hires_loader import _PLUGIN_FONTS
|
|
19
|
+
from led_ticker.pixel_emoji import EMOJI_REGISTRY, HIRES_REGISTRY
|
|
20
|
+
from led_ticker.plugin import API_VERSION, PluginAPI
|
|
21
|
+
from led_ticker.transitions import _TRANSITION_REGISTRY, EASING
|
|
22
|
+
from led_ticker.widgets import _WIDGET_REGISTRY
|
|
23
|
+
|
|
24
|
+
logger = logging.getLogger(__name__)
|
|
25
|
+
|
|
26
|
+
ENTRY_POINT_GROUP = "led_ticker.plugins"
|
|
27
|
+
|
|
28
|
+
# surface name (matches PluginAPI._buffers keys) -> the registry it commits into.
|
|
29
|
+
# Later phases extend this map; the commit loop never changes.
|
|
30
|
+
_REGISTRY_MAP: dict[str, dict[str, Any]] = {
|
|
31
|
+
"widgets": _WIDGET_REGISTRY,
|
|
32
|
+
"transitions": _TRANSITION_REGISTRY,
|
|
33
|
+
"color_providers": _PROVIDER_REGISTRY,
|
|
34
|
+
"animations": _ANIMATION_REGISTRY,
|
|
35
|
+
"borders": _BORDER_REGISTRY,
|
|
36
|
+
"easing": EASING,
|
|
37
|
+
"emojis": EMOJI_REGISTRY,
|
|
38
|
+
"hires_emojis": HIRES_REGISTRY,
|
|
39
|
+
"fonts": _PLUGIN_FONTS,
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass
|
|
44
|
+
class PluginInfo:
|
|
45
|
+
namespace: str
|
|
46
|
+
source: str
|
|
47
|
+
counts: dict[str, int] = field(default_factory=dict)
|
|
48
|
+
# Per-surface qualified contribution names (e.g. {"widgets": ["acme.clock"]})
|
|
49
|
+
# — what an operator references in TOML.
|
|
50
|
+
names: dict[str, list[str]] = field(default_factory=dict)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass
|
|
54
|
+
class LoadedPlugins:
|
|
55
|
+
loaded: list[PluginInfo] = field(default_factory=list)
|
|
56
|
+
failed: list[tuple[str, str]] = field(default_factory=list)
|
|
57
|
+
# Lifecycle hooks, each tagged with the contributing namespace (for logging
|
|
58
|
+
# and the overlay guard). Collected only from successfully-loaded plugins.
|
|
59
|
+
overlays: list[tuple[str, Callable[[Any], None]]] = field(default_factory=list)
|
|
60
|
+
startup_hooks: list[tuple[str, Callable[..., Any]]] = field(default_factory=list)
|
|
61
|
+
shutdown_hooks: list[tuple[str, Callable[..., Any]]] = field(default_factory=list)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
# Load-once guard; assigned by load_plugins() (added in Task A3).
|
|
65
|
+
_LOADED: LoadedPlugins | None = None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def reset_plugins() -> None:
|
|
69
|
+
"""Test helper: drop all namespaced (dotted) registry entries + load guard.
|
|
70
|
+
|
|
71
|
+
Note: ``pixel_emoji._EMOJI_BUILTINS_LOADED`` is intentionally NOT reset
|
|
72
|
+
here. Built-in emoji entries are bare slugs (no dot), so they survive the
|
|
73
|
+
dotted-key deletion and the sentinel stays True — ``_get_registry()`` keeps
|
|
74
|
+
returning them. A test that needs a pristine un-built emoji registry must
|
|
75
|
+
also set ``pe._EMOJI_BUILTINS_LOADED = False`` and clear ``pe.EMOJI_REGISTRY``
|
|
76
|
+
itself.
|
|
77
|
+
"""
|
|
78
|
+
global _LOADED # noqa: PLW0603
|
|
79
|
+
for registry in _REGISTRY_MAP.values():
|
|
80
|
+
for key in [k for k in registry if "." in k]:
|
|
81
|
+
del registry[key]
|
|
82
|
+
_LOADED = None
|
|
83
|
+
# A plugin font name may have been looked up (and cached as a miss) before
|
|
84
|
+
# its plugin registered. Drop the hi-res font cache so the next resolve
|
|
85
|
+
# re-reads _PLUGIN_FONTS instead of returning a stale None.
|
|
86
|
+
from led_ticker.fonts import hires_loader
|
|
87
|
+
|
|
88
|
+
hires_loader.load_hires_font.cache_clear()
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _commit(api: PluginAPI, info: PluginInfo) -> None:
|
|
92
|
+
"""Write a cleanly-registered plugin's buffers into the registries.
|
|
93
|
+
|
|
94
|
+
Two-pass (validate all, then write all) so a mid-commit collision can't
|
|
95
|
+
leave a partial registration. Only buffers that map to a registry are
|
|
96
|
+
committed here (hook surfaces are collected separately — see _load_one).
|
|
97
|
+
"""
|
|
98
|
+
for surface, buf in api._buffers.items():
|
|
99
|
+
registry = _REGISTRY_MAP.get(surface)
|
|
100
|
+
if registry is None:
|
|
101
|
+
logger.debug(
|
|
102
|
+
"surface %r not in _REGISTRY_MAP; skipping (hook surface?)", surface
|
|
103
|
+
)
|
|
104
|
+
continue
|
|
105
|
+
for name in buf:
|
|
106
|
+
if name in registry:
|
|
107
|
+
raise ValueError(f"{surface} entry {name!r} already registered")
|
|
108
|
+
for surface, buf in api._buffers.items():
|
|
109
|
+
registry = _REGISTRY_MAP.get(surface)
|
|
110
|
+
if registry is None:
|
|
111
|
+
continue
|
|
112
|
+
for name, obj in buf.items():
|
|
113
|
+
registry[name] = obj
|
|
114
|
+
if buf:
|
|
115
|
+
info.counts[surface] = len(buf)
|
|
116
|
+
info.names[surface] = sorted(buf)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _resolve_root(source: str, register: Callable[[PluginAPI], None]) -> Path | None:
|
|
120
|
+
"""Best-effort plugin root for resolving ``api.font()`` relative paths.
|
|
121
|
+
|
|
122
|
+
Local plugins: the dir containing the plugin file — a single-file plugin's
|
|
123
|
+
parent (the plugins dir), or the package dir itself. Entry-point plugins:
|
|
124
|
+
the dir of the register callable's module. Returns ``None`` when it cannot
|
|
125
|
+
be determined (e.g. a zip-imported package); ``api.font`` then raises a
|
|
126
|
+
clear error rather than guessing.
|
|
127
|
+
|
|
128
|
+
Uses ``.py`` suffix rather than ``path.is_file()`` to discriminate between
|
|
129
|
+
a single-file plugin and a package dir, so the check is existence-independent
|
|
130
|
+
(works even when the source path is hypothetical or in a tmp dir in tests).
|
|
131
|
+
|
|
132
|
+
For entry-point plugins, ``inspect.getmodule`` is tried first; if it
|
|
133
|
+
returns ``None`` (e.g. the module was loaded via ``spec_from_file_location``
|
|
134
|
+
without being registered in ``sys.modules``), we fall back to
|
|
135
|
+
``register.__globals__.get('__file__')`` which Py guarantees is set for
|
|
136
|
+
any function defined in a source file.
|
|
137
|
+
"""
|
|
138
|
+
if source.startswith("entry-point:"):
|
|
139
|
+
module = inspect.getmodule(register)
|
|
140
|
+
module_file = getattr(module, "__file__", None)
|
|
141
|
+
if module_file is None:
|
|
142
|
+
# Fallback: function's own globals dict always has __file__ for
|
|
143
|
+
# source-file functions, even when the module isn't in sys.modules.
|
|
144
|
+
module_file = getattr(register, "__globals__", {}).get("__file__")
|
|
145
|
+
return Path(module_file).parent if module_file else None
|
|
146
|
+
path = Path(source)
|
|
147
|
+
return path.parent if path.suffix == ".py" else path
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _warn_unpaired_hires(namespace: str, api: PluginAPI) -> None:
|
|
151
|
+
"""Warn when a plugin registers a hi-res emoji with no low-res counterpart.
|
|
152
|
+
|
|
153
|
+
Inline ``:ns.slug:`` parsing and unscaled canvases resolve only through the
|
|
154
|
+
low-res emoji registry, so a hi-res-only slug silently won't render there.
|
|
155
|
+
Built-in emojis always pair the two; plugins must too for inline use.
|
|
156
|
+
"""
|
|
157
|
+
lowres = set(api._buffers["emojis"])
|
|
158
|
+
for slug in api._buffers["hires_emojis"]:
|
|
159
|
+
if slug not in lowres:
|
|
160
|
+
bare = slug.split(".", 1)[-1]
|
|
161
|
+
logger.warning(
|
|
162
|
+
"plugin %r: hi-res emoji %r has no low-res counterpart; it will "
|
|
163
|
+
"not render inline (:%s:) or on unscaled canvases. Also register "
|
|
164
|
+
"api.emoji(%r, ...).",
|
|
165
|
+
namespace,
|
|
166
|
+
slug,
|
|
167
|
+
slug,
|
|
168
|
+
bare,
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _guarded_overlay(
|
|
173
|
+
namespace: str, paint: Callable[[Any], None]
|
|
174
|
+
) -> Callable[[Any], None]:
|
|
175
|
+
"""Wrap a plugin overlay so a raise disables it (and logs once) instead of
|
|
176
|
+
propagating out of ``LedFrame.swap()``.
|
|
177
|
+
|
|
178
|
+
Core overlays intentionally have no per-hook try/except (a raising core hook
|
|
179
|
+
freezes the panel — the documented invariant). Plugin code is less trusted,
|
|
180
|
+
so its overlays must never be able to freeze the panel.
|
|
181
|
+
"""
|
|
182
|
+
state = {"disabled": False}
|
|
183
|
+
|
|
184
|
+
def wrapped(canvas: Any) -> None:
|
|
185
|
+
if state["disabled"]:
|
|
186
|
+
return
|
|
187
|
+
try:
|
|
188
|
+
paint(canvas)
|
|
189
|
+
except Exception:
|
|
190
|
+
state["disabled"] = True
|
|
191
|
+
# Never let a logging failure propagate into swap() and freeze
|
|
192
|
+
# the panel — disabling the overlay is what matters.
|
|
193
|
+
with contextlib.suppress(Exception):
|
|
194
|
+
logger.exception(
|
|
195
|
+
"plugin %r overlay raised; disabling it for this run", namespace
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
return wrapped
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
async def _run_startup_hooks(
|
|
202
|
+
hooks: list[tuple[str, Callable[..., Any]]], ctx: Any
|
|
203
|
+
) -> None:
|
|
204
|
+
"""Run each on_startup hook once, isolating failures. Awaits a hook that
|
|
205
|
+
returns a coroutine."""
|
|
206
|
+
for namespace, fn in hooks:
|
|
207
|
+
try:
|
|
208
|
+
result = fn(ctx)
|
|
209
|
+
if inspect.isawaitable(result):
|
|
210
|
+
await result
|
|
211
|
+
except Exception:
|
|
212
|
+
logger.exception("plugin %r on_startup hook failed", namespace)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
async def _run_shutdown_hooks(
|
|
216
|
+
hooks: list[tuple[str, Callable[..., Any]]],
|
|
217
|
+
) -> None:
|
|
218
|
+
"""Run each on_shutdown hook best-effort, isolating failures. Awaits a hook
|
|
219
|
+
that returns a coroutine.
|
|
220
|
+
|
|
221
|
+
Failure isolation covers ``Exception`` only. A hook that raises or
|
|
222
|
+
propagates ``CancelledError``/``KeyboardInterrupt`` (both ``BaseException``)
|
|
223
|
+
interrupts the remaining shutdown sequence — intentional: plugin code must
|
|
224
|
+
not be able to suppress external cancellation, and this runner is invoked
|
|
225
|
+
from the run-loop ``finally`` which is itself reached via cancellation.
|
|
226
|
+
"""
|
|
227
|
+
for namespace, fn in hooks:
|
|
228
|
+
try:
|
|
229
|
+
result = fn()
|
|
230
|
+
if inspect.isawaitable(result):
|
|
231
|
+
await result
|
|
232
|
+
except Exception:
|
|
233
|
+
logger.exception("plugin %r on_shutdown hook failed", namespace)
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _load_one(
|
|
237
|
+
namespace: str,
|
|
238
|
+
source: str,
|
|
239
|
+
register: Callable[[PluginAPI], None] | None,
|
|
240
|
+
requires_api: int | None,
|
|
241
|
+
loaded_namespaces: set[str],
|
|
242
|
+
result: LoadedPlugins,
|
|
243
|
+
) -> None:
|
|
244
|
+
"""Run + commit one plugin's register(), isolating all failures."""
|
|
245
|
+
if namespace in loaded_namespaces:
|
|
246
|
+
result.failed.append((namespace, "namespace already claimed by another plugin"))
|
|
247
|
+
logger.error(
|
|
248
|
+
"plugin namespace %r already claimed; skipping %s", namespace, source
|
|
249
|
+
)
|
|
250
|
+
return
|
|
251
|
+
if requires_api is not None and requires_api != API_VERSION[0]:
|
|
252
|
+
msg = f"requires API v{requires_api}, core is v{API_VERSION[0]}"
|
|
253
|
+
result.failed.append((namespace, msg))
|
|
254
|
+
logger.error("plugin %r %s; skipping", namespace, msg)
|
|
255
|
+
return
|
|
256
|
+
if register is None or not callable(register):
|
|
257
|
+
result.failed.append((namespace, "no callable register(api) found"))
|
|
258
|
+
logger.error("plugin %r has no register(api); skipping %s", namespace, source)
|
|
259
|
+
return
|
|
260
|
+
root = _resolve_root(source, register)
|
|
261
|
+
api = PluginAPI(namespace, root=root)
|
|
262
|
+
info = PluginInfo(namespace=namespace, source=source)
|
|
263
|
+
try:
|
|
264
|
+
register(api)
|
|
265
|
+
_commit(api, info)
|
|
266
|
+
except Exception as e: # isolation: a plugin must never crash the app
|
|
267
|
+
logger.exception("plugin %r (%s) failed to load", namespace, source)
|
|
268
|
+
result.failed.append((namespace, str(e)))
|
|
269
|
+
return
|
|
270
|
+
loaded_namespaces.add(namespace)
|
|
271
|
+
result.loaded.append(info)
|
|
272
|
+
_warn_unpaired_hires(namespace, api)
|
|
273
|
+
for paint in api._overlays:
|
|
274
|
+
result.overlays.append((namespace, paint))
|
|
275
|
+
for fn in api._startup_hooks:
|
|
276
|
+
result.startup_hooks.append((namespace, fn))
|
|
277
|
+
for fn in api._shutdown_hooks:
|
|
278
|
+
result.shutdown_hooks.append((namespace, fn))
|
|
279
|
+
logger.info("plugin %r loaded from %s (%s)", namespace, source, info.counts)
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def _import_from_path(mod_name: str, init: Path) -> ModuleType:
|
|
283
|
+
spec = importlib.util.spec_from_file_location(mod_name, init)
|
|
284
|
+
if spec is None or spec.loader is None:
|
|
285
|
+
raise ImportError(f"cannot load plugin module from {init}")
|
|
286
|
+
module = importlib.util.module_from_spec(spec)
|
|
287
|
+
spec.loader.exec_module(module)
|
|
288
|
+
return module
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _discover_local(plugin_dir: Path):
|
|
292
|
+
"""Yield (namespace, source, thunk) for each local plugin. The thunk imports
|
|
293
|
+
the module lazily and returns (register, requires_api)."""
|
|
294
|
+
if not plugin_dir.is_dir():
|
|
295
|
+
return
|
|
296
|
+
for entry in sorted(plugin_dir.iterdir()):
|
|
297
|
+
if entry.name.startswith("_"):
|
|
298
|
+
continue
|
|
299
|
+
if entry.suffix == ".py" and entry.is_file():
|
|
300
|
+
ns, init = entry.stem, entry
|
|
301
|
+
elif entry.is_dir() and (entry / "__init__.py").exists():
|
|
302
|
+
ns, init = entry.name, entry / "__init__.py"
|
|
303
|
+
else:
|
|
304
|
+
continue
|
|
305
|
+
|
|
306
|
+
def thunk(ns=ns, init=init):
|
|
307
|
+
mod = _import_from_path(f"led_ticker_plugin_{ns}", init)
|
|
308
|
+
return getattr(mod, "register", None), getattr(mod, "requires_api", None)
|
|
309
|
+
|
|
310
|
+
yield ns, str(entry), thunk
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def _discover_entry_points():
|
|
314
|
+
"""Yield (namespace, source, thunk) for installed entry-point plugins.
|
|
315
|
+
Namespace = the entry-point name; the thunk loads the entry point and
|
|
316
|
+
resolves its register callable."""
|
|
317
|
+
try:
|
|
318
|
+
eps = importlib.metadata.entry_points(group=ENTRY_POINT_GROUP)
|
|
319
|
+
except Exception: # pragma: no cover - defensive across importlib versions
|
|
320
|
+
return
|
|
321
|
+
for ep in eps:
|
|
322
|
+
|
|
323
|
+
def thunk(ep=ep):
|
|
324
|
+
obj = ep.load()
|
|
325
|
+
if callable(obj) and not isinstance(obj, type):
|
|
326
|
+
return obj, getattr(obj, "requires_api", None)
|
|
327
|
+
register = getattr(obj, "register", None)
|
|
328
|
+
return register, getattr(obj, "requires_api", None)
|
|
329
|
+
|
|
330
|
+
yield ep.name, f"entry-point:{ep.value}", thunk
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def load_plugins(
|
|
334
|
+
plugin_dir: Path | None,
|
|
335
|
+
*,
|
|
336
|
+
entry_points_enabled: bool = True,
|
|
337
|
+
disable: set[str] | None = None,
|
|
338
|
+
) -> LoadedPlugins:
|
|
339
|
+
"""Discover + load all plugins once. Idempotent (call reset_plugins() in
|
|
340
|
+
tests to reload). ``disable`` is a set of namespaces to skip + log."""
|
|
341
|
+
global _LOADED # noqa: PLW0603
|
|
342
|
+
if _LOADED is not None:
|
|
343
|
+
return _LOADED
|
|
344
|
+
disabled = disable or set()
|
|
345
|
+
result = LoadedPlugins()
|
|
346
|
+
loaded_ns: set[str] = set()
|
|
347
|
+
sources = []
|
|
348
|
+
if plugin_dir is not None:
|
|
349
|
+
sources.extend(_discover_local(plugin_dir))
|
|
350
|
+
if entry_points_enabled:
|
|
351
|
+
sources.extend(_discover_entry_points())
|
|
352
|
+
for ns, source, thunk in sources:
|
|
353
|
+
if ns in disabled:
|
|
354
|
+
logger.info("plugin %r disabled via [plugins].disable; skipping", ns)
|
|
355
|
+
continue
|
|
356
|
+
try:
|
|
357
|
+
register, requires = thunk()
|
|
358
|
+
except Exception as e:
|
|
359
|
+
logger.exception("plugin %r (%s) failed to import", ns, source)
|
|
360
|
+
result.failed.append((ns, str(e)))
|
|
361
|
+
continue
|
|
362
|
+
_load_one(ns, source, register, requires, loaded_ns, result)
|
|
363
|
+
_LOADED = result
|
|
364
|
+
return result
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def read_plugins_config(config_path: Path) -> PluginsConfig:
|
|
368
|
+
"""Lightweight read of just the ``[plugins]`` block, so plugin discovery can
|
|
369
|
+
run BEFORE full config validation (plugin-provided easings etc. must be
|
|
370
|
+
registered before load_config validates them). Returns defaults only if the
|
|
371
|
+
file is missing; a TOML syntax error or a structural ``[plugins]`` error
|
|
372
|
+
propagates so the caller can report it.
|
|
373
|
+
"""
|
|
374
|
+
import tomllib
|
|
375
|
+
|
|
376
|
+
try:
|
|
377
|
+
with open(config_path, "rb") as f:
|
|
378
|
+
raw = tomllib.load(f)
|
|
379
|
+
except FileNotFoundError:
|
|
380
|
+
return PluginsConfig()
|
|
381
|
+
return _parse_plugins_block(raw)
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def load_plugins_for_config(config_path: Path) -> LoadedPlugins:
|
|
385
|
+
"""Config-driven plugin load: read the ``[plugins]`` block, then load from
|
|
386
|
+
``<config dir>/<dir>`` honoring enable/disable. Used by the run loop, the
|
|
387
|
+
``validate`` path, and the ``plugins`` CLI command."""
|
|
388
|
+
pc = read_plugins_config(config_path)
|
|
389
|
+
if not pc.enabled:
|
|
390
|
+
logger.info("plugins disabled via [plugins].enabled=false; skipping")
|
|
391
|
+
return load_plugins(None, entry_points_enabled=False)
|
|
392
|
+
plugin_dir = config_path.parent / pc.dir
|
|
393
|
+
return load_plugins(plugin_dir, disable=set(pc.disable))
|