led-ticker-weather 0.2.0__tar.gz

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.
@@ -0,0 +1,12 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.egg-info/
5
+ dist/
6
+ build/
7
+ .pytest_cache/
8
+ .ruff_cache/
9
+ .coverage
10
+ .coverage.*
11
+ htmlcov/
12
+ .env
@@ -0,0 +1,65 @@
1
+ # CLAUDE.md
2
+
3
+ Guidance for Claude Code when working in **led-ticker-weather**, an external plugin for
4
+ [led-ticker](https://github.com/JamesAwesome/led-ticker).
5
+
6
+ `README.md` is the source of truth for the user-facing surface (config options, install).
7
+ This file keeps the **load-bearing invariants** a contributor must respect.
8
+
9
+ ## Overview
10
+
11
+ This plugin contributes, via the `led_ticker.plugins` entry point, a single widget:
12
+
13
+ - `weather.current` — a current-conditions widget backed by `WeatherWidget`
14
+ (`src/led_ticker_weather/weather.py`). It fetches current conditions from
15
+ [WeatherAPI.com](https://www.weatherapi.com/) (key via the `WEATHERAPI_KEY` env var) and
16
+ draws the location label, temperature, and a condition icon.
17
+
18
+ The entry-point name `weather` is the plugin namespace, so the config `type` is
19
+ `weather.current`. `register()` in `__init__.py` calls `api.widget("current")(WeatherWidget)`.
20
+
21
+ This package split out of `led-ticker-feeds` (was `feeds.weather`); history is preserved.
22
+
23
+ ## Load-bearing invariants
24
+
25
+ - **Public surface only:** `weather.py` imports ONLY from `led_ticker.plugin` (plus stdlib +
26
+ `aiohttp` + `attrs`). Never reach into `led_ticker.<internal>`. Enforced by
27
+ `tests/test_import_purity.py` (AST scan of `src/led_ticker_weather`).
28
+ - **Deps:** `aiohttp` only (no `feedparser` — that is an rss-only dep).
29
+ - **Condition → icon mapping:** `_match_condition(condition) -> slug` is the icon resolver
30
+ (sun / cloud / rain / snow / thunder / fog; unknown defaults to sun). `test_weather_icons.py`
31
+ is the per-branch tripwire; `test_weather.py` asserts every slug it can return exists in both
32
+ the lowres and hires emoji registries.
33
+ - **`WEATHERAPI_KEY`:** `test_weather.py` provides it via an autouse `monkeypatch.setenv`
34
+ fixture — never hardcode a real key. `test_weather_icons.py` calls the pure resolver and
35
+ needs no key.
36
+ - **No `from __future__ import annotations`** (Python 3.14 / PEP 649 rule, same as core).
37
+
38
+ ## Commands
39
+
40
+ led-ticker is **not on PyPI**; it resolves from a sibling checkout via the monorepo root
41
+ `[tool.uv.sources]`. The rgbmatrix stub is vendored at the monorepo root and put on the
42
+ import path by the **root** `pyproject.toml`. Run tooling from the repo root:
43
+
44
+ ```bash
45
+ uv sync --extra dev
46
+ uv run pytest plugins/weather
47
+ uv run ruff check plugins/weather
48
+ uv run pyright plugins/weather/src
49
+ ```
50
+
51
+ Python **3.14+** only.
52
+
53
+ ## Package layout
54
+
55
+ ```
56
+ src/led_ticker_weather/
57
+ __init__.py # register(api) → api.widget("current")(WeatherWidget)
58
+ weather.py # WeatherWidget + _match_condition icon resolver
59
+ tests/
60
+ test_weather.py # widget behavior (autouse WEATHERAPI_KEY fixture)
61
+ test_weather_icons.py # _match_condition per-branch tripwire
62
+ test_import_purity.py # AST: only led_ticker.plugin imports
63
+ test_smoke.py # entry-point registers weather.current
64
+ conftest.py # shared canvas / make_widget fixtures
65
+ ```
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 James Awesome
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,90 @@
1
+ Metadata-Version: 2.4
2
+ Name: led-ticker-weather
3
+ Version: 0.2.0
4
+ Summary: Current-conditions weather widget for led-ticker (weather.current).
5
+ Project-URL: Homepage, https://docs.ledticker.dev
6
+ Project-URL: Repository, https://github.com/JamesAwesome/led-ticker-plugins
7
+ Project-URL: Issues, https://github.com/JamesAwesome/led-ticker-plugins/issues
8
+ Author-email: James Awesome <james@morelli.nyc>
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Operating System :: POSIX :: Linux
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.14
15
+ Classifier: Topic :: Multimedia :: Graphics
16
+ Requires-Python: >=3.14
17
+ Requires-Dist: aiohttp
18
+ Requires-Dist: led-ticker-core>=2.0
19
+ Provides-Extra: dev
20
+ Requires-Dist: pre-commit>=4.0; extra == 'dev'
21
+ Requires-Dist: pyright>=1.1; extra == 'dev'
22
+ Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
23
+ Requires-Dist: pytest-cov>=5.0; extra == 'dev'
24
+ Requires-Dist: pytest>=8.0; extra == 'dev'
25
+ Requires-Dist: ruff>=0.4; extra == 'dev'
26
+ Description-Content-Type: text/markdown
27
+
28
+ # led-ticker-weather
29
+
30
+ A current-conditions weather **plugin** for [led-ticker](https://github.com/JamesAwesome/led-ticker), backed by [WeatherAPI.com](https://www.weatherapi.com/). It contributes a single `weather.current` widget that shows the location label, current temperature, and a condition icon.
31
+
32
+ This package split out of `led-ticker-feeds` (its `feeds.weather` widget); the type is now `weather.current`.
33
+
34
+ ## Prerequisites
35
+
36
+ - A working [led-ticker](https://github.com/JamesAwesome/led-ticker) install.
37
+ - A free [WeatherAPI.com](https://www.weatherapi.com/) API key, exported as `WEATHERAPI_KEY`.
38
+ - Internet access on the Pi (the widget calls the WeatherAPI.com API).
39
+
40
+ ## Install
41
+
42
+ The widget auto-registers via the `led_ticker.plugins` entry point — once the package is installed, no `[plugins]` config change is needed.
43
+
44
+ **Into a containerized led-ticker (recommended):** add this package to `config/requirements-plugins.txt` (copy it from `config/requirements-plugins.example.txt`), then rebuild:
45
+
46
+ ```text
47
+ git+https://github.com/JamesAwesome/led-ticker-plugins.git@weather-v0.2.0#subdirectory=plugins/weather
48
+ ```
49
+
50
+ ```bash
51
+ # in your led-ticker checkout
52
+ docker compose up -d --build
53
+ ```
54
+
55
+ **Standalone (a venv that already has led-ticker):**
56
+
57
+ ```bash
58
+ pip install "git+https://github.com/JamesAwesome/led-ticker-plugins.git@weather-v0.2.0#subdirectory=plugins/weather"
59
+ ```
60
+
61
+ led-ticker isn't on PyPI, so this path only works where led-ticker is already installed. See the led-ticker [Plugins docs](https://docs.ledticker.dev/plugins/) for the constraint-based install the Docker image uses.
62
+
63
+ ## Configuration
64
+
65
+ Set the API key in your `.env`:
66
+
67
+ ```text
68
+ WEATHERAPI_KEY=your-key-here
69
+ ```
70
+
71
+ Then add the widget:
72
+
73
+ ```toml
74
+ [[sections]]
75
+ [[sections.widgets]]
76
+ type = "weather.current"
77
+ location = "London"
78
+ ```
79
+
80
+ The widget polls WeatherAPI.com in the background and renders the label, temperature, and a condition icon. Conditions map to icon slugs via `_match_condition` (sun / cloud / rain / snow / thunder / fog).
81
+
82
+ ## Development
83
+
84
+ This package lives in the [led-ticker-plugins](https://github.com/JamesAwesome/led-ticker-plugins) monorepo. Run tooling from the repo root:
85
+
86
+ ```bash
87
+ uv sync --extra dev
88
+ uv run pytest plugins/weather
89
+ uv run ruff check plugins/weather
90
+ ```
@@ -0,0 +1,63 @@
1
+ # led-ticker-weather
2
+
3
+ A current-conditions weather **plugin** for [led-ticker](https://github.com/JamesAwesome/led-ticker), backed by [WeatherAPI.com](https://www.weatherapi.com/). It contributes a single `weather.current` widget that shows the location label, current temperature, and a condition icon.
4
+
5
+ This package split out of `led-ticker-feeds` (its `feeds.weather` widget); the type is now `weather.current`.
6
+
7
+ ## Prerequisites
8
+
9
+ - A working [led-ticker](https://github.com/JamesAwesome/led-ticker) install.
10
+ - A free [WeatherAPI.com](https://www.weatherapi.com/) API key, exported as `WEATHERAPI_KEY`.
11
+ - Internet access on the Pi (the widget calls the WeatherAPI.com API).
12
+
13
+ ## Install
14
+
15
+ The widget auto-registers via the `led_ticker.plugins` entry point — once the package is installed, no `[plugins]` config change is needed.
16
+
17
+ **Into a containerized led-ticker (recommended):** add this package to `config/requirements-plugins.txt` (copy it from `config/requirements-plugins.example.txt`), then rebuild:
18
+
19
+ ```text
20
+ git+https://github.com/JamesAwesome/led-ticker-plugins.git@weather-v0.2.0#subdirectory=plugins/weather
21
+ ```
22
+
23
+ ```bash
24
+ # in your led-ticker checkout
25
+ docker compose up -d --build
26
+ ```
27
+
28
+ **Standalone (a venv that already has led-ticker):**
29
+
30
+ ```bash
31
+ pip install "git+https://github.com/JamesAwesome/led-ticker-plugins.git@weather-v0.2.0#subdirectory=plugins/weather"
32
+ ```
33
+
34
+ led-ticker isn't on PyPI, so this path only works where led-ticker is already installed. See the led-ticker [Plugins docs](https://docs.ledticker.dev/plugins/) for the constraint-based install the Docker image uses.
35
+
36
+ ## Configuration
37
+
38
+ Set the API key in your `.env`:
39
+
40
+ ```text
41
+ WEATHERAPI_KEY=your-key-here
42
+ ```
43
+
44
+ Then add the widget:
45
+
46
+ ```toml
47
+ [[sections]]
48
+ [[sections.widgets]]
49
+ type = "weather.current"
50
+ location = "London"
51
+ ```
52
+
53
+ The widget polls WeatherAPI.com in the background and renders the label, temperature, and a condition icon. Conditions map to icon slugs via `_match_condition` (sun / cloud / rain / snow / thunder / fog).
54
+
55
+ ## Development
56
+
57
+ This package lives in the [led-ticker-plugins](https://github.com/JamesAwesome/led-ticker-plugins) monorepo. Run tooling from the repo root:
58
+
59
+ ```bash
60
+ uv sync --extra dev
61
+ uv run pytest plugins/weather
62
+ uv run ruff check plugins/weather
63
+ ```
@@ -0,0 +1,56 @@
1
+ [project]
2
+ name = "led-ticker-weather"
3
+ version = "0.2.0"
4
+ description = "Current-conditions weather widget for led-ticker (weather.current)."
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ license-files = ["LICENSE"]
8
+ requires-python = ">=3.14"
9
+ authors = [{ name = "James Awesome", email = "james@morelli.nyc" }]
10
+ classifiers = [
11
+ "Development Status :: 4 - Beta",
12
+ "Programming Language :: Python :: 3",
13
+ "Programming Language :: Python :: 3.14",
14
+ "Operating System :: POSIX :: Linux",
15
+ "Topic :: Multimedia :: Graphics",
16
+ ]
17
+ dependencies = [
18
+ "led-ticker-core>=2.0",
19
+ "aiohttp",
20
+ ]
21
+
22
+ # Entry-point NAME ("weather") is the plugin namespace -> TOML `type = "weather.current"`.
23
+ [project.entry-points."led_ticker.plugins"]
24
+ weather = "led_ticker_weather:register"
25
+
26
+ [project.optional-dependencies]
27
+ dev = [
28
+ "pytest>=8.0",
29
+ "pytest-asyncio>=0.23",
30
+ "pytest-cov>=5.0",
31
+ "pre-commit>=4.0",
32
+ "ruff>=0.4",
33
+ "pyright>=1.1",
34
+ ]
35
+
36
+ [project.urls]
37
+ Homepage = "https://docs.ledticker.dev"
38
+ Repository = "https://github.com/JamesAwesome/led-ticker-plugins"
39
+ Issues = "https://github.com/JamesAwesome/led-ticker-plugins/issues"
40
+
41
+ [build-system]
42
+ requires = ["hatchling"]
43
+ build-backend = "hatchling.build"
44
+
45
+ [tool.hatch.build.targets.wheel]
46
+ packages = ["src/led_ticker_weather"]
47
+
48
+ [tool.ruff]
49
+ target-version = "py314"
50
+ src = ["src"]
51
+
52
+ [tool.ruff.lint]
53
+ select = ["E", "F", "I", "UP", "B", "SIM"]
54
+
55
+ [tool.coverage.report]
56
+ fail_under = 90
@@ -0,0 +1,12 @@
1
+ """led-ticker-weather: current-conditions widget (weather.current) contributed
2
+ via the ``led_ticker.plugins`` entry point.
3
+
4
+ The entry-point name ``weather`` is the plugin namespace, so the widget is
5
+ referenced in config.toml as ``type = "weather.current"``.
6
+ """
7
+
8
+ from led_ticker_weather.weather import WeatherWidget
9
+
10
+
11
+ def register(api):
12
+ api.widget("current")(WeatherWidget)
@@ -0,0 +1,281 @@
1
+ """Weather widget using WeatherAPI.com (weather.current)."""
2
+
3
+ import logging
4
+ import os
5
+ from typing import Any, Self
6
+
7
+ import aiohttp
8
+ import attrs
9
+ from led_ticker.plugin import (
10
+ FONT_DEFAULT,
11
+ Canvas,
12
+ Color,
13
+ ColorProvider,
14
+ DrawResult,
15
+ Font,
16
+ FrameAwareBase,
17
+ as_color_provider,
18
+ compute_baseline,
19
+ compute_cursor,
20
+ draw_emoji_at,
21
+ draw_text,
22
+ draw_text_per_char,
23
+ get_text_width,
24
+ make_color,
25
+ measure_emoji_at,
26
+ run_monitor_loop,
27
+ spawn_tracked,
28
+ )
29
+ from led_ticker.plugin import (
30
+ colors as _colors,
31
+ )
32
+
33
+ WEATHERAPI_URL: str = "https://api.weatherapi.com/v1/current.json"
34
+
35
+
36
+ def _coerce_provider(value: Any) -> ColorProvider:
37
+ """Coerce a color field to a ColorProvider.
38
+
39
+ None -> default; an existing provider (has ``color_for``) -> as-is; a raw
40
+ [r, g, b] list/tuple -> ``as_color_provider(make_color(...))``; an existing
41
+ ``graphics.Color`` -> ``as_color_provider``. Config strings/tables (e.g.
42
+ "rainbow", {style=...}) are coerced into providers by the loader BEFORE
43
+ construction, so they arrive here already as providers and pass through.
44
+ """
45
+ if value is None:
46
+ return as_color_provider(_colors.DEFAULT_COLOR)
47
+ if hasattr(value, "color_for"):
48
+ return value
49
+ if isinstance(value, (list, tuple)):
50
+ return as_color_provider(make_color(*value))
51
+ return as_color_provider(value) # already a graphics.Color
52
+
53
+
54
+ def _match_condition(condition: str) -> str:
55
+ """Map a WeatherAPI condition string to an emoji slug."""
56
+ c = condition.lower()
57
+ if "thunder" in c:
58
+ return "thunder"
59
+ if "snow" in c or "blizzard" in c or "ice" in c or "sleet" in c:
60
+ return "snow"
61
+ if "rain" in c or "drizzle" in c or "shower" in c:
62
+ return "rain"
63
+ if "fog" in c or "mist" in c:
64
+ return "fog"
65
+ if "partly" in c:
66
+ return "partly_cloudy"
67
+ if "cloud" in c or "overcast" in c:
68
+ return "cloud"
69
+ # Sunny, Clear, or anything else
70
+ return "sun"
71
+
72
+
73
+ @attrs.define
74
+ class WeatherWidget(FrameAwareBase):
75
+ """Current weather display widget."""
76
+
77
+ session: aiohttp.ClientSession
78
+ location: str # query string: "New York", "10001", "40.71,-74.01"
79
+ text: str
80
+ units: str = "imperial"
81
+ font: Font = attrs.Factory(lambda: FONT_DEFAULT)
82
+ font_color: Color | ColorProvider = attrs.Factory(lambda: _colors.DEFAULT_COLOR)
83
+ # WeatherWidget keeps two color knobs: `font_color` for the label
84
+ # (e.g. "Brooklyn:") and `font_color_temp` for the temperature
85
+ # value (e.g. "64°F"). They're separate so a config can color the
86
+ # label with an effect (`font_color = "rainbow"`) while keeping
87
+ # the temp value in a steady high-contrast color (default white).
88
+ # If you want the temp to also use the effect, set them both:
89
+ # font_color = "rainbow"
90
+ # font_color_temp = "rainbow"
91
+ font_color_temp: Color | ColorProvider = attrs.Factory(lambda: _colors.RGB_WHITE)
92
+ bg_color: Color | None = attrs.field(default=None, kw_only=True)
93
+ center: bool = True
94
+ padding: int = 6
95
+ hold_time: float = 0.0
96
+ show_icon: bool = True
97
+ unit_symbol: str = attrs.field(init=False, default="")
98
+ current_temp: int = attrs.field(init=False, default=0)
99
+ weather: str = attrs.field(init=False, default="")
100
+
101
+ def __attrs_post_init__(self) -> None:
102
+ # Normalize both color fields to ColorProvider instances. The loader
103
+ # pre-coerces rich config forms ("rainbow", {style=...}) into providers
104
+ # before construction, so _coerce_provider is an idempotent pass-through
105
+ # for those; raw graphics.Color / [r, g, b] values (e.g. from defaults or
106
+ # direct construction) get wrapped here.
107
+ self.font_color = _coerce_provider(self.font_color)
108
+ self.font_color_temp = _coerce_provider(self.font_color_temp)
109
+
110
+ # Support dict location from TOML: {lat = 40.71, lon = -74.01}
111
+ if isinstance(self.location, dict):
112
+ lat = self.location.get("lat", 0)
113
+ lon = self.location.get("lon", 0)
114
+ self.location = f"{lat},{lon}"
115
+
116
+ if self.units == "imperial":
117
+ self.unit_symbol = "F"
118
+ elif self.units == "metric":
119
+ self.unit_symbol = "C"
120
+
121
+ @classmethod
122
+ async def start(
123
+ cls, *args: Any, update_interval: int = 10800, **kwargs: Any
124
+ ) -> Self:
125
+ widget = cls(*args, **kwargs)
126
+ try:
127
+ await widget.update()
128
+ except Exception:
129
+ logging.exception(
130
+ "Weather initial update failed for %s, will retry in background",
131
+ widget.location,
132
+ )
133
+ spawn_tracked(run_monitor_loop(widget, update_interval))
134
+ return widget
135
+
136
+ async def update(self) -> None:
137
+ logging.info("Updating weather for: %s", self.location)
138
+ api_key = os.getenv("WEATHERAPI_KEY", "")
139
+ if not api_key:
140
+ raise ValueError("WEATHERAPI_KEY not set. Add it to your .env file.")
141
+ params = {
142
+ "key": api_key,
143
+ "q": self.location,
144
+ }
145
+ async with self.session.get(
146
+ WEATHERAPI_URL,
147
+ params=params,
148
+ ) as response:
149
+ data = await response.json()
150
+
151
+ # WeatherAPI returns {"error": {...}} on failure
152
+ if "error" in data:
153
+ code = data["error"].get("code", "?")
154
+ msg = data["error"].get("message", "Unknown error")
155
+ raise ValueError(f"WeatherAPI error {code}: {msg}")
156
+
157
+ current = data["current"]
158
+ if self.units == "imperial":
159
+ self.current_temp = int(current["temp_f"])
160
+ else:
161
+ self.current_temp = int(current["temp_c"])
162
+ self.weather = current["condition"]["text"]
163
+
164
+ def draw(
165
+ self,
166
+ canvas: Canvas,
167
+ cursor_pos: int = 0,
168
+ *,
169
+ y_offset: int = 0,
170
+ font_color: Any = None,
171
+ ) -> DrawResult:
172
+ temp_text = f"{self.current_temp}{self.unit_symbol}"
173
+ label_text = f"{self.text}: "
174
+
175
+ # Resolve the icon slug once and read its actual rendered footprint
176
+ # via `measure_emoji_at` — keeps layout in sync with whichever
177
+ # variant `draw_emoji_at` will pick (lowres on plain canvas,
178
+ # hires-when-available on a ScaledCanvas, falling back to lowres
179
+ # for slugs without a HIRES_REGISTRY entry like `partly_cloudy`).
180
+ # Reading the footprint dynamically scales correctly across
181
+ # per-section `scale` overrides — at scale=2 a hires sprite is 16
182
+ # logical wide, and a hardcoded `8` here would let the temperature
183
+ # text overlap the icon.
184
+ if self.show_icon:
185
+ content_width = (
186
+ get_text_width(self.font, label_text, padding=0, canvas=canvas)
187
+ + measure_emoji_at(canvas, _match_condition(self.weather))
188
+ + get_text_width(self.font, temp_text, padding=0, canvas=canvas)
189
+ )
190
+ else:
191
+ condition_text = f"{self.weather} "
192
+ content_width = get_text_width(
193
+ self.font,
194
+ f"{label_text}{condition_text}{temp_text}",
195
+ padding=0,
196
+ canvas=canvas,
197
+ )
198
+
199
+ cursor_pos, end_padding = compute_cursor(
200
+ canvas.width,
201
+ content_width,
202
+ cursor_pos,
203
+ self.padding,
204
+ center=self.center,
205
+ )
206
+
207
+ baseline_y = compute_baseline(self.font, canvas, valign="center") + y_offset
208
+
209
+ cursor_pos += self._draw_segment(
210
+ canvas,
211
+ cursor_pos,
212
+ baseline_y,
213
+ self.font_color,
214
+ label_text,
215
+ frame_count=self.frame_for("font_color"),
216
+ )
217
+
218
+ if self.show_icon:
219
+ # Bottom-anchor the condition icon at the text baseline (exact at
220
+ # any scale via draw_emoji_at's real-pixel bottom-anchor).
221
+ cursor_pos += draw_emoji_at(
222
+ canvas,
223
+ _match_condition(self.weather),
224
+ int(cursor_pos),
225
+ bottom_baseline=baseline_y,
226
+ )
227
+ else:
228
+ cursor_pos += self._draw_segment(
229
+ canvas,
230
+ cursor_pos,
231
+ baseline_y,
232
+ self.font_color,
233
+ f"{self.weather} ",
234
+ frame_count=self.frame_for("font_color"),
235
+ )
236
+
237
+ cursor_pos += self._draw_segment(
238
+ canvas,
239
+ cursor_pos,
240
+ baseline_y,
241
+ self.font_color_temp,
242
+ temp_text,
243
+ frame_count=self.frame_for("font_color_temp"),
244
+ )
245
+ cursor_pos += end_padding
246
+
247
+ return canvas, cursor_pos
248
+
249
+ def _draw_segment(
250
+ self,
251
+ canvas: Canvas,
252
+ x: int,
253
+ baseline_y: int,
254
+ provider: ColorProvider,
255
+ text: str,
256
+ frame_count: int,
257
+ ) -> int:
258
+ """Render one weather text segment (label / condition / temp).
259
+
260
+ Per-char providers (rainbow / gradient) iterate chars via
261
+ `draw_text_per_char` so each char renders with its own hue.
262
+ Whole-string providers (constant / color_cycle / random)
263
+ materialize once and use `draw_text`. Mirrors the per-char
264
+ dispatch in `TickerCountdown.draw` and image widgets'
265
+ `_draw_text` — without it, `font_color = "rainbow"` on
266
+ weather collapsed the label / condition / temp to a single
267
+ sweeping hue.
268
+ """
269
+ if provider.per_char:
270
+ return draw_text_per_char(
271
+ canvas,
272
+ self.font,
273
+ x,
274
+ baseline_y,
275
+ text,
276
+ lambda idx, total: provider.color_for(frame_count, idx, total),
277
+ )
278
+ color = provider.color_for(frame_count, 0, len(text) if text else 1)
279
+ # plugin draw_text signature: (canvas, font, text, x, y, color) → absolute x.
280
+ # Subtract starting x to return relative advance width.
281
+ return draw_text(canvas, self.font, text, x, baseline_y, color) - x
@@ -0,0 +1,35 @@
1
+ """Shared test fixtures for the led-ticker-weather plugin test suite.
2
+
3
+ The rgbmatrix stub is on the pytest path via ``pythonpath`` in
4
+ ``pyproject.toml`` (``../led-ticker/tests/stubs``). The plugin doesn't ship
5
+ core's conftest, so re-provide the small fixtures the ported tests use.
6
+ """
7
+
8
+ import unittest.mock as mock
9
+
10
+ import pytest
11
+
12
+
13
+ @pytest.fixture
14
+ def canvas():
15
+ """Mock LED canvas with standard width and height."""
16
+ c = mock.Mock()
17
+ c.width = 160
18
+ c.height = 16
19
+ return c
20
+
21
+
22
+ @pytest.fixture
23
+ def make_widget():
24
+ """Factory for mock widgets with configurable draw width."""
25
+
26
+ def _factory(content_width=40):
27
+ widget = mock.Mock()
28
+ widget.hold_time = 0.0
29
+ widget.draw.side_effect = lambda c, cursor_pos=0, **kw: (
30
+ c,
31
+ cursor_pos + content_width,
32
+ )
33
+ return widget
34
+
35
+ return _factory
@@ -0,0 +1,29 @@
1
+ import ast
2
+ import pathlib
3
+
4
+ SRC = pathlib.Path(__file__).resolve().parents[1] / "src" / "led_ticker_weather"
5
+
6
+
7
+ def _led_ticker_imports(path):
8
+ tree = ast.parse(path.read_text(), filename=str(path))
9
+ names = []
10
+ for node in ast.walk(tree):
11
+ if isinstance(node, ast.ImportFrom) and node.module:
12
+ if node.module.split(".")[0] == "led_ticker":
13
+ names.append(node.module)
14
+ elif isinstance(node, ast.Import):
15
+ for alias in node.names:
16
+ if alias.name.split(".")[0] == "led_ticker":
17
+ names.append(alias.name)
18
+ return names
19
+
20
+
21
+ def test_plugin_imports_only_public_surface():
22
+ offenders = {}
23
+ for py in SRC.rglob("*.py"):
24
+ bad = [m for m in _led_ticker_imports(py) if m != "led_ticker.plugin"]
25
+ if bad:
26
+ offenders[py.name] = bad
27
+ assert not offenders, (
28
+ f"modules import led_ticker internals instead of led_ticker.plugin: {offenders}"
29
+ )
@@ -0,0 +1,15 @@
1
+ from led_ticker import _plugin_loader as L
2
+
3
+
4
+ def test_entry_point_registers_weather_namespace():
5
+ L.reset_plugins()
6
+ try:
7
+ result = L.load_plugins(None, entry_points_enabled=True)
8
+ loaded = {info.namespace for info in result.loaded}
9
+ assert "weather" in loaded, f"weather plugin not discovered: {result}"
10
+
11
+ from led_ticker.widgets import get_widget_class
12
+
13
+ assert get_widget_class("weather.current") is not None
14
+ finally:
15
+ L.reset_plugins()