led-ticker-rss 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,58 @@
1
+ # CLAUDE.md
2
+
3
+ Guidance for Claude Code when working in **led-ticker-rss**, 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
+ - `rss.feed` — an RSS/Atom headline Container backed by `RSSFeedMonitor`
14
+ (`src/led_ticker_rss/rss.py`). It polls a feed URL in the background and expands each
15
+ story into its own scrolling `TickerMessage`; the engine re-reads `feed_stories` on every
16
+ pass so live updates surface within one cycle.
17
+
18
+ The entry-point name `rss` is the plugin namespace, so the config `type` is `rss.feed`.
19
+ `register()` in `__init__.py` calls `api.widget("feed")(RSSFeedMonitor)`.
20
+
21
+ This package split out of `led-ticker-feeds` (was `feeds.rss`); history is preserved.
22
+
23
+ ## Load-bearing invariants
24
+
25
+ - **Public surface only:** `rss.py` imports ONLY from `led_ticker.plugin` (plus stdlib +
26
+ `aiohttp` + `attrs` + `feedparser`). Never reach into `led_ticker.<internal>`. Enforced by
27
+ `tests/test_import_purity.py` (AST scan of `src/led_ticker_rss`).
28
+ - **Deps:** `aiohttp` (fetch) and `feedparser>=6.0` (parse). `feedparser` is rss-only — it is
29
+ NOT a weather dep.
30
+ - **No `from __future__ import annotations`** (Python 3.14 / PEP 649 rule, same as core).
31
+
32
+ ## Commands
33
+
34
+ led-ticker is **not on PyPI**; it resolves from a sibling checkout via the monorepo root
35
+ `[tool.uv.sources]`. The rgbmatrix stub is vendored at the monorepo root and put on the
36
+ import path by the **root** `pyproject.toml`. Run tooling from the repo root:
37
+
38
+ ```bash
39
+ uv sync --extra dev
40
+ uv run pytest plugins/rss
41
+ uv run ruff check plugins/rss
42
+ uv run pyright plugins/rss/src
43
+ ```
44
+
45
+ Python **3.14+** only.
46
+
47
+ ## Package layout
48
+
49
+ ```
50
+ src/led_ticker_rss/
51
+ __init__.py # register(api) → api.widget("feed")(RSSFeedMonitor)
52
+ rss.py # RSSFeedMonitor (Container): update() pulls + parses the feed
53
+ tests/
54
+ test_rss.py # widget behavior
55
+ test_import_purity.py # AST: only led_ticker.plugin imports
56
+ test_smoke.py # entry-point registers rss.feed
57
+ conftest.py # shared canvas / make_widget fixtures
58
+ ```
@@ -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,82 @@
1
+ Metadata-Version: 2.4
2
+ Name: led-ticker-rss
3
+ Version: 0.2.0
4
+ Summary: RSS/Atom headline widget for led-ticker (rss.feed).
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: feedparser>=6.0
19
+ Requires-Dist: led-ticker-core>=2.0
20
+ Provides-Extra: dev
21
+ Requires-Dist: pre-commit>=4.0; extra == 'dev'
22
+ Requires-Dist: pyright>=1.1; extra == 'dev'
23
+ Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
24
+ Requires-Dist: pytest-cov>=5.0; extra == 'dev'
25
+ Requires-Dist: pytest>=8.0; extra == 'dev'
26
+ Requires-Dist: ruff>=0.4; extra == 'dev'
27
+ Description-Content-Type: text/markdown
28
+
29
+ # led-ticker-rss
30
+
31
+ An RSS/Atom headline **plugin** for [led-ticker](https://github.com/JamesAwesome/led-ticker). It contributes a single `rss.feed` widget that polls a feed URL and scrolls each story headline across the panel as its own ticker message.
32
+
33
+ This package split out of `led-ticker-feeds` (its `feeds.rss` widget); the type is now `rss.feed`.
34
+
35
+ ## Prerequisites
36
+
37
+ - A working [led-ticker](https://github.com/JamesAwesome/led-ticker) install.
38
+ - Internet access on the Pi (the widget fetches the feed URL).
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@rss-v0.2.0#subdirectory=plugins/rss
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@rss-v0.2.0#subdirectory=plugins/rss"
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
+ ```toml
66
+ [[sections]]
67
+ [[sections.widgets]]
68
+ type = "rss.feed"
69
+ url = "https://feeds.bbci.co.uk/news/rss.xml"
70
+ ```
71
+
72
+ The widget is a Container: it pulls the feed in the background and expands each story into its own scrolling `TickerMessage`, so live updates surface within one display cycle.
73
+
74
+ ## Development
75
+
76
+ This package lives in the [led-ticker-plugins](https://github.com/JamesAwesome/led-ticker-plugins) monorepo. Run tooling from the repo root:
77
+
78
+ ```bash
79
+ uv sync --extra dev
80
+ uv run pytest plugins/rss
81
+ uv run ruff check plugins/rss
82
+ ```
@@ -0,0 +1,54 @@
1
+ # led-ticker-rss
2
+
3
+ An RSS/Atom headline **plugin** for [led-ticker](https://github.com/JamesAwesome/led-ticker). It contributes a single `rss.feed` widget that polls a feed URL and scrolls each story headline across the panel as its own ticker message.
4
+
5
+ This package split out of `led-ticker-feeds` (its `feeds.rss` widget); the type is now `rss.feed`.
6
+
7
+ ## Prerequisites
8
+
9
+ - A working [led-ticker](https://github.com/JamesAwesome/led-ticker) install.
10
+ - Internet access on the Pi (the widget fetches the feed URL).
11
+
12
+ ## Install
13
+
14
+ The widget auto-registers via the `led_ticker.plugins` entry point — once the package is installed, no `[plugins]` config change is needed.
15
+
16
+ **Into a containerized led-ticker (recommended):** add this package to `config/requirements-plugins.txt` (copy it from `config/requirements-plugins.example.txt`), then rebuild:
17
+
18
+ ```text
19
+ git+https://github.com/JamesAwesome/led-ticker-plugins.git@rss-v0.2.0#subdirectory=plugins/rss
20
+ ```
21
+
22
+ ```bash
23
+ # in your led-ticker checkout
24
+ docker compose up -d --build
25
+ ```
26
+
27
+ **Standalone (a venv that already has led-ticker):**
28
+
29
+ ```bash
30
+ pip install "git+https://github.com/JamesAwesome/led-ticker-plugins.git@rss-v0.2.0#subdirectory=plugins/rss"
31
+ ```
32
+
33
+ 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.
34
+
35
+ ## Configuration
36
+
37
+ ```toml
38
+ [[sections]]
39
+ [[sections.widgets]]
40
+ type = "rss.feed"
41
+ url = "https://feeds.bbci.co.uk/news/rss.xml"
42
+ ```
43
+
44
+ The widget is a Container: it pulls the feed in the background and expands each story into its own scrolling `TickerMessage`, so live updates surface within one display cycle.
45
+
46
+ ## Development
47
+
48
+ This package lives in the [led-ticker-plugins](https://github.com/JamesAwesome/led-ticker-plugins) monorepo. Run tooling from the repo root:
49
+
50
+ ```bash
51
+ uv sync --extra dev
52
+ uv run pytest plugins/rss
53
+ uv run ruff check plugins/rss
54
+ ```
@@ -0,0 +1,57 @@
1
+ [project]
2
+ name = "led-ticker-rss"
3
+ version = "0.2.0"
4
+ description = "RSS/Atom headline widget for led-ticker (rss.feed)."
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
+ "feedparser>=6.0",
21
+ ]
22
+
23
+ # Entry-point NAME ("rss") is the plugin namespace -> TOML `type = "rss.feed"`.
24
+ [project.entry-points."led_ticker.plugins"]
25
+ rss = "led_ticker_rss:register"
26
+
27
+ [project.optional-dependencies]
28
+ dev = [
29
+ "pytest>=8.0",
30
+ "pytest-asyncio>=0.23",
31
+ "pytest-cov>=5.0",
32
+ "pre-commit>=4.0",
33
+ "ruff>=0.4",
34
+ "pyright>=1.1",
35
+ ]
36
+
37
+ [project.urls]
38
+ Homepage = "https://docs.ledticker.dev"
39
+ Repository = "https://github.com/JamesAwesome/led-ticker-plugins"
40
+ Issues = "https://github.com/JamesAwesome/led-ticker-plugins/issues"
41
+
42
+ [build-system]
43
+ requires = ["hatchling"]
44
+ build-backend = "hatchling.build"
45
+
46
+ [tool.hatch.build.targets.wheel]
47
+ packages = ["src/led_ticker_rss"]
48
+
49
+ [tool.ruff]
50
+ target-version = "py314"
51
+ src = ["src"]
52
+
53
+ [tool.ruff.lint]
54
+ select = ["E", "F", "I", "UP", "B", "SIM"]
55
+
56
+ [tool.coverage.report]
57
+ fail_under = 90
@@ -0,0 +1,12 @@
1
+ """led-ticker-rss: RSS/Atom headline widget (rss.feed) contributed via the
2
+ ``led_ticker.plugins`` entry point.
3
+
4
+ The entry-point name ``rss`` is the plugin namespace, so the widget is
5
+ referenced in config.toml as ``type = "rss.feed"``.
6
+ """
7
+
8
+ from led_ticker_rss.rss import RSSFeedMonitor
9
+
10
+
11
+ def register(api):
12
+ api.widget("feed")(RSSFeedMonitor)
@@ -0,0 +1,91 @@
1
+ """RSS feed monitor widget."""
2
+
3
+ import asyncio
4
+ import itertools
5
+ import logging
6
+ from typing import Any, Self
7
+
8
+ import aiohttp
9
+ import attrs
10
+ import feedparser
11
+ from led_ticker.plugin import (
12
+ FONT_DEFAULT,
13
+ Color,
14
+ Font,
15
+ TickerMessage,
16
+ run_monitor_loop,
17
+ spawn_tracked,
18
+ )
19
+ from led_ticker.plugin import (
20
+ colors as _colors,
21
+ )
22
+
23
+ logger: logging.Logger = logging.getLogger(__name__)
24
+
25
+
26
+ @attrs.define
27
+ class RSSFeedMonitor:
28
+ """Fetches and displays headlines from an RSS feed."""
29
+
30
+ session: aiohttp.ClientSession
31
+ feed_url: str
32
+ padding: int = 6
33
+ colors: itertools.cycle[Color] = attrs.Factory(
34
+ lambda: itertools.cycle([_colors.DEFAULT_COLOR, _colors.RED, _colors.GREEN])
35
+ )
36
+ max_stories: int = 5
37
+ # When set, every story TickerMessage gets this color/provider
38
+ # (e.g. `font_color = "rainbow"` paints all stories rainbow).
39
+ # When unset (None), fall back to the legacy 3-color rotation
40
+ # (DEFAULT_COLOR / RED / GREEN) so existing configs keep working.
41
+ font_color: Any = attrs.field(default=None, kw_only=True)
42
+ bg_color: Color | None = attrs.field(default=None, kw_only=True)
43
+ font: Font = attrs.field(default=attrs.Factory(lambda: FONT_DEFAULT), kw_only=True)
44
+ feed_title: TickerMessage | None = attrs.field(init=False, default=None)
45
+ feed_stories: list[TickerMessage] = attrs.field(init=False, factory=list)
46
+
47
+ def _story_color(self) -> Any:
48
+ """Per-story color: `font_color` if set, else next from the
49
+ legacy cycle. Called once per story in `update()`."""
50
+ if self.font_color is not None:
51
+ return self.font_color
52
+ return next(self.colors)
53
+
54
+ @classmethod
55
+ async def start(
56
+ cls,
57
+ session: aiohttp.ClientSession,
58
+ feed_url: str,
59
+ update_interval: int = 1800,
60
+ **kwargs: Any,
61
+ ) -> Self:
62
+ widget = cls(session=session, feed_url=feed_url, **kwargs)
63
+ await widget.update()
64
+ spawn_tracked(run_monitor_loop(widget, update_interval))
65
+ return widget
66
+
67
+ async def update(self) -> None:
68
+ logger.info("Updating RSS Feed from: %s", self.feed_url)
69
+ async with self.session.get(self.feed_url) as response:
70
+ feed_data = await response.text()
71
+ feed = await asyncio.to_thread(feedparser.parse, feed_data)
72
+ self.feed_title = TickerMessage(
73
+ feed["channel"]["title"], # type: ignore[index]
74
+ font=self.font,
75
+ font_color=self._story_color(),
76
+ bg_color=self.bg_color,
77
+ )
78
+ self.feed_stories = [
79
+ TickerMessage(
80
+ item["title"], # type: ignore[index]
81
+ font=self.font,
82
+ font_color=self._story_color(),
83
+ bg_color=self.bg_color,
84
+ )
85
+ for item in itertools.islice(feed["items"], self.max_stories) # type: ignore[index]
86
+ ]
87
+ logger.info(
88
+ "RSS %s updated: %d stories",
89
+ self.feed_url,
90
+ len(self.feed_stories),
91
+ )
@@ -0,0 +1,35 @@
1
+ """Shared test fixtures for the led-ticker-rss 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_rss"
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,215 @@
1
+ """Tests for led_ticker_rss.rss."""
2
+
3
+ import unittest.mock as mock
4
+
5
+ import feedparser as _feedparser
6
+ import pytest
7
+ from led_ticker.widgets.message import TickerMessage
8
+
9
+ from led_ticker_rss.rss import RSSFeedMonitor
10
+
11
+ SAMPLE_RSS = """<?xml version="1.0" encoding="UTF-8"?>
12
+ <rss version="2.0">
13
+ <channel>
14
+ <title>Test Feed</title>
15
+ <item><title>Story One</title></item>
16
+ <item><title>Story Two</title></item>
17
+ <item><title>Story Three</title></item>
18
+ </channel>
19
+ </rss>
20
+ """
21
+
22
+
23
+ @pytest.fixture
24
+ def mock_session():
25
+ session = mock.MagicMock()
26
+ response = mock.AsyncMock()
27
+ response.text.return_value = SAMPLE_RSS
28
+
29
+ # Create a proper async context manager
30
+ ctx = mock.AsyncMock()
31
+ ctx.__aenter__.return_value = response
32
+ session.get.return_value = ctx
33
+ return session
34
+
35
+
36
+ class TestRSSFeedMonitor:
37
+ async def test_update_parses_feed(self, mock_session):
38
+ monitor = RSSFeedMonitor(
39
+ session=mock_session, feed_url="http://example.com/rss"
40
+ )
41
+ await monitor.update()
42
+
43
+ assert isinstance(monitor.feed_title, TickerMessage)
44
+ assert monitor.feed_title.text == "Test Feed"
45
+ assert len(monitor.feed_stories) == 3
46
+ assert monitor.feed_stories[0].text == "Story One"
47
+
48
+ async def test_update_respects_max_stories(self, mock_session):
49
+ monitor = RSSFeedMonitor(
50
+ session=mock_session, feed_url="http://example.com/rss", max_stories=2
51
+ )
52
+ await monitor.update()
53
+ assert len(monitor.feed_stories) == 2
54
+
55
+ async def test_stories_are_ticker_messages(self, mock_session):
56
+ monitor = RSSFeedMonitor(
57
+ session=mock_session, feed_url="http://example.com/rss"
58
+ )
59
+ await monitor.update()
60
+ for story in monitor.feed_stories:
61
+ assert isinstance(story, TickerMessage)
62
+
63
+ async def test_font_propagates_to_stories(self, mock_session):
64
+ """font= on RSSFeedMonitor must flow to every generated TickerMessage."""
65
+ from led_ticker.fonts import resolve_font
66
+
67
+ custom_font = resolve_font("Inter-Regular", 16, threshold=80)
68
+ monitor = RSSFeedMonitor(
69
+ session=mock_session,
70
+ feed_url="http://example.com/rss",
71
+ font=custom_font,
72
+ )
73
+ await monitor.update()
74
+
75
+ assert monitor.feed_title.font is custom_font
76
+ assert all(s.font is custom_font for s in monitor.feed_stories)
77
+
78
+ async def test_font_defaults_to_font_default(self, mock_session):
79
+ """No font= specified → stories use FONT_DEFAULT (back-compat)."""
80
+ from led_ticker.fonts import FONT_DEFAULT
81
+
82
+ monitor = RSSFeedMonitor(
83
+ session=mock_session, feed_url="http://example.com/rss"
84
+ )
85
+ await monitor.update()
86
+
87
+ assert monitor.feed_title.font is FONT_DEFAULT
88
+ assert all(s.font is FONT_DEFAULT for s in monitor.feed_stories)
89
+
90
+
91
+ class TestRssBgColor:
92
+ def test_field_exists(self):
93
+ names = {a.name for a in RSSFeedMonitor.__attrs_attrs__}
94
+ assert "bg_color" in names
95
+
96
+ def test_bg_color_propagates_to_stories(self, mock_session):
97
+ """bg_color set on the container propagates to every story
98
+ TickerMessage in feed_stories."""
99
+ from rgbmatrix.graphics import Color
100
+
101
+ bg = Color(40, 50, 60)
102
+ feed = RSSFeedMonitor(
103
+ session=mock_session, feed_url="https://example.com/feed", bg_color=bg
104
+ )
105
+ # Manually populate stories the way update() would. Bypass network.
106
+ feed.feed_title = TickerMessage(text="Title", bg_color=bg)
107
+ feed.feed_stories = [
108
+ TickerMessage(text=item, bg_color=bg) for item in ("a", "b", "c")
109
+ ]
110
+
111
+ assert feed.bg_color is bg
112
+ assert feed.feed_title.bg_color is bg
113
+ assert all(s.bg_color is bg for s in feed.feed_stories)
114
+
115
+ async def test_update_threads_bg_color(self, mock_session):
116
+ """After update(), every story and the title carry bg_color."""
117
+ from rgbmatrix.graphics import Color
118
+
119
+ bg = Color(40, 50, 60)
120
+ feed = RSSFeedMonitor(
121
+ session=mock_session, feed_url="https://example.com/feed", bg_color=bg
122
+ )
123
+ await feed.update()
124
+
125
+ assert feed.feed_title is not None
126
+ assert feed.feed_title.bg_color is bg
127
+ assert all(s.bg_color is bg for s in feed.feed_stories)
128
+
129
+
130
+ class TestRssFontColor:
131
+ """`font_color` overrides the legacy 3-color cycle. When set, every
132
+ story TickerMessage gets the same color/provider; when unset
133
+ (None), fall back to the legacy rotation."""
134
+
135
+ async def test_font_color_unset_uses_legacy_cycle(self, mock_session):
136
+ """Default behavior: stories cycle through the 3 legacy colors."""
137
+ feed = RSSFeedMonitor(session=mock_session, feed_url="https://example.com/feed")
138
+ await feed.update()
139
+
140
+ # Three distinct stories → three distinct cycle colors. The
141
+ # exact values come from DEFAULT_COLOR / DOWN / UP cycling.
142
+ colors = [s.font_color for s in feed.feed_stories]
143
+ # All three should be distinct (cycle has 3 entries, 3 stories).
144
+ assert len({(c._color.red, c._color.green, c._color.blue) for c in colors}) == 3
145
+
146
+ async def test_font_color_set_applies_to_all_stories(self, mock_session):
147
+ """`font_color = Rainbow()` → every story gets the same provider."""
148
+ from led_ticker.color_providers import Rainbow
149
+
150
+ rainbow = Rainbow()
151
+ feed = RSSFeedMonitor(
152
+ session=mock_session,
153
+ feed_url="https://example.com/feed",
154
+ font_color=rainbow,
155
+ )
156
+ await feed.update()
157
+
158
+ assert feed.feed_title is not None
159
+ # Title + every story shares the same provider instance.
160
+ assert feed.feed_title.font_color is rainbow
161
+ assert all(s.font_color is rainbow for s in feed.feed_stories)
162
+
163
+
164
+ class TestFeedparserOffEventLoop:
165
+ """feedparser.parse is CPU-bound XML parsing. Calling it directly on
166
+ the event loop blocks all other coroutines for the full parse duration.
167
+ It must be offloaded via asyncio.to_thread (C2)."""
168
+
169
+ async def test_feedparser_called_via_to_thread(self, mock_session, monkeypatch):
170
+ """asyncio.to_thread must be called with feedparser.parse and the
171
+ raw feed text — not feedparser.parse called directly."""
172
+ calls: list[tuple] = []
173
+
174
+ async def _fake_to_thread(func, *args, **kwargs):
175
+ calls.append((func, args, kwargs))
176
+ return func(*args, **kwargs)
177
+
178
+ monkeypatch.setattr("led_ticker_rss.rss.asyncio.to_thread", _fake_to_thread)
179
+
180
+ monitor = RSSFeedMonitor(
181
+ session=mock_session, feed_url="http://example.com/rss"
182
+ )
183
+ await monitor.update()
184
+
185
+ assert len(calls) == 1, f"expected 1 to_thread call, got {len(calls)}: {calls}"
186
+ func, args, kwargs = calls[0]
187
+ assert func is _feedparser.parse, f"expected feedparser.parse, got {func}"
188
+ assert args == (SAMPLE_RSS,), f"expected (SAMPLE_RSS,), got {args}"
189
+
190
+
191
+ class TestRSSFeedUpdateLogging:
192
+ """Periodic update() must log INFO so users can tell the background
193
+ task is firing.
194
+ """
195
+
196
+ async def test_rss_update_logs_info(self, mock_session, caplog) -> None:
197
+ import logging
198
+
199
+ from led_ticker_rss.rss import RSSFeedMonitor
200
+
201
+ widget = RSSFeedMonitor(
202
+ session=mock_session, feed_url="http://example.com/feed"
203
+ )
204
+
205
+ with caplog.at_level(logging.INFO, logger="led_ticker_rss.rss"):
206
+ await widget.update()
207
+
208
+ matching = [
209
+ r
210
+ for r in caplog.records
211
+ if r.levelno == logging.INFO
212
+ and "updated" in r.message
213
+ and str(len(widget.feed_stories)) in r.message
214
+ ]
215
+ assert matching, f"expected INFO log; got {[r.message for r in caplog.records]}"
@@ -0,0 +1,15 @@
1
+ from led_ticker import _plugin_loader as L
2
+
3
+
4
+ def test_entry_point_registers_rss_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 "rss" in loaded, f"rss plugin not discovered: {result}"
10
+
11
+ from led_ticker.widgets import get_widget_class
12
+
13
+ assert get_widget_class("rss.feed") is not None
14
+ finally:
15
+ L.reset_plugins()