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.
- led_ticker_rss-0.2.0/.gitignore +12 -0
- led_ticker_rss-0.2.0/CLAUDE.md +58 -0
- led_ticker_rss-0.2.0/LICENSE +21 -0
- led_ticker_rss-0.2.0/PKG-INFO +82 -0
- led_ticker_rss-0.2.0/README.md +54 -0
- led_ticker_rss-0.2.0/pyproject.toml +57 -0
- led_ticker_rss-0.2.0/src/led_ticker_rss/__init__.py +12 -0
- led_ticker_rss-0.2.0/src/led_ticker_rss/rss.py +91 -0
- led_ticker_rss-0.2.0/tests/conftest.py +35 -0
- led_ticker_rss-0.2.0/tests/test_import_purity.py +29 -0
- led_ticker_rss-0.2.0/tests/test_rss.py +215 -0
- led_ticker_rss-0.2.0/tests/test_smoke.py +15 -0
|
@@ -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()
|