"""Arch news.
Upstream uses the news feed to announce upgrades that will not complete on their
own, so a tool that applies updates for you should not let those pass unnoticed.
The feed is RSS 2.0 and parsed with the standard library; the interesting parts
are the judgement calls, not the parsing.
"""
from __future__ import annotations
import sys
import tempfile
import time
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from updater import news # noqa: E402
FEED = """<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
<channel>
<title>Arch Linux: Recent news updates</title>
<item>
<title>Mkinitcpio requires manual intervention for LUKS</title>
<link>https://archlinux.org/news/mkinitcpio/</link>
<description><p>You must run <code>mkinitcpio -P</code> before rebooting.</p></description>
<dc:creator>David Runge</dc:creator>
<pubDate>Tue, 22 Sep 2026 09:09:27 +0000</pubDate>
<guid>tag:archlinux.org,2026-09-22:/news/mkinitcpio/</guid>
</item>
<item>
<title>A routine package note</title>
<link>https://archlinux.org/news/routine/</link>
<description><p>Nothing to do here.</p></description>
<dc:creator>Someone</dc:creator>
<pubDate>Mon, 01 Sep 2026 09:00:00 +0000</pubDate>
<guid>tag:archlinux.org,2026-09-01:/news/routine/</guid>
</item>
<item>
<title>Missing date</title>
<link>https://archlinux.org/news/undated/</link>
<description>no date at all</description>
<guid>x</guid>
</item>
</channel>
</rss>
"""
def parsed():
return news._parse(FEED)
# --------------------------------------------------------------------------
# parsing
# --------------------------------------------------------------------------
def test_parses_titles_links_dates_and_authors():
items = parsed()
assert len(items) == 3, items
newest = items[0]
assert newest.title.startswith("Mkinitcpio")
assert newest.link.endswith("/news/mkinitcpio/")
assert newest.author == "David Runge", newest.author
# the namespaced <dc:creator> must be found whichever prefix it uses
assert newest.published > 0
def test_html_is_stripped_from_the_summary():
"""The description is markup; printing it raw would be a wall of tags."""
item = parsed()[0]
assert "<" not in item.summary and ">" not in item.summary
assert "mkinitcpio -P" in item.summary
def test_items_are_newest_first():
stamps = [i.published for i in parsed()]
assert stamps == sorted(stamps, reverse=True), stamps
def test_an_item_without_a_date_is_kept_not_dropped():
"""Losing an item is worse than showing one with an odd timestamp.
A stamp of zero is never greater than a positive cutoff, so an undated item
was invisible in `recent()` for good -- an Arch news item nobody would ever
see. It inherits the feed's newest stamp instead.
"""
items = parsed()
undated = [i for i in items if i.title == "Missing date"]
assert len(undated) == 1
assert undated[0].published > 0
# and it survives the filter that used to hide it
shown = news.recent(items, since=1.0)
assert "Missing date" in [i.title for i in shown]
def test_a_broken_feed_is_not_an_exception():
for bad in ("", "not xml at all", "<rss><channel>", "<html><body>x</body></html>"):
assert news._parse(bad) == [], bad
def test_an_item_without_a_title_is_skipped():
feed = (
"<rss><channel><item><link>x</link></item><item><title>real</title></item></channel></rss>"
)
got = news._parse(feed)
assert [i.title for i in got] == ["real"]
# --------------------------------------------------------------------------
# what needs a human
# --------------------------------------------------------------------------
def test_manual_intervention_is_recognised():
item = parsed()[0]
assert item.needs_action, item.title
def test_a_routine_item_is_not_flagged():
routine = next(i for i in parsed() if i.title == "A routine package note")
assert not routine.needs_action
def test_the_phrases_that_signal_required_action():
for phrase in (
"requires manual intervention",
"manual action needed",
"before upgrading, do this",
"you will need to reboot",
):
item = news.NewsItem(title=phrase, link="", published=time.time())
assert item.needs_action, phrase
# --------------------------------------------------------------------------
# recency
# --------------------------------------------------------------------------
def test_recent_uses_the_recorded_timestamp():
now = time.time()
items = [
news.NewsItem("new", "", now - 60),
news.NewsItem("old", "", now - 100000),
]
assert [i.title for i in news.recent(items, since=now - 3600)] == ["new"]
def test_recent_falls_back_to_a_window_when_nothing_is_recorded():
now = time.time()
items = [
news.NewsItem("fresh", "", now - 3600),
news.NewsItem("ancient", "", now - 400 * 24 * 3600),
]
got = news.recent(items, since=0.0)
assert [i.title for i in got] == ["fresh"]
# --------------------------------------------------------------------------
# presentation
# --------------------------------------------------------------------------
def test_the_summary_says_how_many_need_a_decision():
items = parsed()
line = news.summary_line(items)
assert "needing a decision" in line, line
assert news.summary_line([]) == "nothing new"
def test_the_explanation_leads_with_what_needs_action():
items = parsed()
text = "\n".join(news.explain(items))
assert "READ BEFORE UPDATING" in text, text
assert "ALSO RECENT" in text, text
# the item that needs a decision comes before the routine one
assert text.index("Mkinitcpio") < text.index("A routine package note"), text
assert "archlinux.org" in text, "the link should be there to follow"
def test_explaining_nothing_is_reassuring_not_empty():
lines = news.explain([])
assert lines and "nothing new" in lines[0]
# --------------------------------------------------------------------------
# the real feed, when it is reachable
# --------------------------------------------------------------------------
def test_fetching_the_real_feed_never_raises():
with tempfile.TemporaryDirectory() as td:
import os
os.environ["XDG_CACHE_HOME"] = td
items, error = news.fetch_news(timeout=15)
assert isinstance(items, list)
if error:
assert isinstance(error, str) and error
else:
assert items, "no error but no items either"
for item in items:
assert item.title
assert item.link.startswith("https://")
def main() -> int:
tests = [(n, f) for n, f in sorted(globals().items()) if n.startswith("test_") and callable(f)]
failed = 0
for name, fn in tests:
try:
fn()
except Exception as exc: # noqa: BLE001
failed += 1
print(f"FAIL {name}\n {type(exc).__name__}: {exc}")
else:
print(f"ok {name}")
print(f"\n{len(tests) - failed}/{len(tests)} passed")
return 1 if failed else 0
if __name__ == "__main__":
raise SystemExit(main())