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.
Files changed (73) hide show
  1. led_ticker/__init__.py +3 -0
  2. led_ticker/_coerce.py +125 -0
  3. led_ticker/_compat.py +42 -0
  4. led_ticker/_plugin_hint.py +37 -0
  5. led_ticker/_plugin_loader.py +393 -0
  6. led_ticker/_rgbmatrix_stub.py +132 -0
  7. led_ticker/_types.py +41 -0
  8. led_ticker/animations.py +80 -0
  9. led_ticker/app/__init__.py +83 -0
  10. led_ticker/app/cli.py +477 -0
  11. led_ticker/app/coercion.py +800 -0
  12. led_ticker/app/factories.py +1318 -0
  13. led_ticker/app/plugin_cmd.py +633 -0
  14. led_ticker/app/run.py +890 -0
  15. led_ticker/borders.py +679 -0
  16. led_ticker/busy_http.py +84 -0
  17. led_ticker/busy_light.py +87 -0
  18. led_ticker/color_lut.py +40 -0
  19. led_ticker/color_providers.py +281 -0
  20. led_ticker/colors.py +81 -0
  21. led_ticker/config.py +693 -0
  22. led_ticker/drawing.py +214 -0
  23. led_ticker/fonts/5x8.bdf +21422 -0
  24. led_ticker/fonts/6x10.bdf +31042 -0
  25. led_ticker/fonts/6x12.bdf +86121 -0
  26. led_ticker/fonts/7x13.bdf +64553 -0
  27. led_ticker/fonts/__init__.py +227 -0
  28. led_ticker/fonts/bdf_parser.py +124 -0
  29. led_ticker/fonts/hires/Inter-Bold.otf +0 -0
  30. led_ticker/fonts/hires/Inter-Regular.otf +0 -0
  31. led_ticker/fonts/hires_loader.py +268 -0
  32. led_ticker/frame.py +146 -0
  33. led_ticker/pixel_emoji.py +2904 -0
  34. led_ticker/plugin.py +427 -0
  35. led_ticker/plugins_catalog.json +112 -0
  36. led_ticker/plugins_catalog.py +257 -0
  37. led_ticker/preview.py +263 -0
  38. led_ticker/reload.py +140 -0
  39. led_ticker/render_breaker.py +99 -0
  40. led_ticker/scaled_canvas.py +178 -0
  41. led_ticker/schedule.py +172 -0
  42. led_ticker/status_board.py +354 -0
  43. led_ticker/text_render.py +189 -0
  44. led_ticker/ticker.py +1279 -0
  45. led_ticker/transitions/__init__.py +416 -0
  46. led_ticker/transitions/_hires_loader.py +305 -0
  47. led_ticker/transitions/effects.py +217 -0
  48. led_ticker/transitions/push.py +271 -0
  49. led_ticker/transitions/wipe.py +240 -0
  50. led_ticker/validate.py +2120 -0
  51. led_ticker/webui/__init__.py +415 -0
  52. led_ticker/webui/_paths.py +45 -0
  53. led_ticker/webui/inventory.py +80 -0
  54. led_ticker/webui/redact.py +88 -0
  55. led_ticker/webui/static/index.html +596 -0
  56. led_ticker/widget.py +177 -0
  57. led_ticker/widgets/__init__.py +46 -0
  58. led_ticker/widgets/_frame_aware.py +156 -0
  59. led_ticker/widgets/_gif_decode.py +82 -0
  60. led_ticker/widgets/_image_base.py +1691 -0
  61. led_ticker/widgets/_image_fit.py +202 -0
  62. led_ticker/widgets/_row_layout.py +119 -0
  63. led_ticker/widgets/clock.py +172 -0
  64. led_ticker/widgets/gif.py +346 -0
  65. led_ticker/widgets/message.py +383 -0
  66. led_ticker/widgets/still.py +313 -0
  67. led_ticker/widgets/two_row.py +641 -0
  68. led_ticker/widgets/weather_icons.py +262 -0
  69. led_ticker_core-2.0.0.dist-info/METADATA +134 -0
  70. led_ticker_core-2.0.0.dist-info/RECORD +73 -0
  71. led_ticker_core-2.0.0.dist-info/WHEEL +4 -0
  72. led_ticker_core-2.0.0.dist-info/entry_points.txt +2 -0
  73. led_ticker_core-2.0.0.dist-info/licenses/LICENSE +21 -0
led_ticker/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """led-ticker: Asyncio LED matrix display for news, weather, crypto, and more."""
2
+
3
+ __version__ = "2.0.0"
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))