"""Arch news, which occasionally tells you an update needs a human. The front page of archlinux.org is the one place upstream says "this upgrade requires manual intervention", and it is exactly the kind of thing a tool that applies updates for you should not let you miss. The feed is RSS 2.0, so this parses it with the standard library rather than adding a dependency. Never raises: a feed that cannot be fetched is a normal state, and the caller decides what to say about it. """ from __future__ import annotations import re import time import xml.etree.ElementTree as ET from dataclasses import dataclass, replace from email.utils import parsedate_to_datetime from . import fetch FEED = "https://archlinux.org/feeds/news/" #: the feed is a handful of items and changes rarely TTL = 6 * 3600 #: how far back to look when nothing has been read yet DEFAULT_WINDOW = 90 * 24 * 3600 #: the phrase upstream uses when an upgrade will not complete on its own _ACTION_PHRASES = ( "manual intervention", "requires manual", "manual action", "before upgrading", "you will need to", ) _TAGS = re.compile(r"<[^>]+>") _SPACE = re.compile(r"\s+") def plain_text(html: str) -> str: """The description is HTML; the terminal wants prose.""" return _SPACE.sub(" ", _TAGS.sub(" ", html or "")).strip() @dataclass(frozen=True) class NewsItem: title: str link: str published: float author: str = "" summary: str = "" @property def needs_action(self) -> bool: """Whether this reads like it will not apply cleanly on its own.""" haystack = f"{self.title} {self.summary}".lower() return any(phrase in haystack for phrase in _ACTION_PHRASES) @property def age(self) -> float: return max(0.0, time.time() - self.published) def _parse(text: str) -> list[NewsItem]: try: root = ET.fromstring(text) except ET.ParseError: return [] out: list[NewsItem] = [] for item in root.iter("item"): title = (item.findtext("title") or "").strip() if not title: continue link = (item.findtext("link") or "").strip() stamp = 0.0 raw_date = (item.findtext("pubDate") or "").strip() if raw_date: try: stamp = parsedate_to_datetime(raw_date).timestamp() except (TypeError, ValueError): stamp = 0.0 creator = "" for child in item: if child.tag.split("}")[-1] == "creator": creator = (child.text or "").strip() out.append( NewsItem( title=title, link=link, published=stamp, author=creator, summary=plain_text(item.findtext("description") or ""), ) ) # An item whose pubDate is missing or unparseable would keep a stamp of # zero, and `recent()` filters on a positive cutoff -- so it could never be # shown, however urgent it was. Falling back to the newest stamp in the # feed keeps it visible; being ordered slightly wrong is a smaller problem # than an Arch news item nobody ever sees. newest = max((n.published for n in out), default=0.0) fallback = newest if newest > 0 else time.time() out = [n if n.published > 0 else replace(n, published=fallback) for n in out] out.sort(key=lambda n: n.published, reverse=True) return out def fetch_news(*, timeout: float = 20) -> tuple[list[NewsItem], str]: """Recent news, newest first. Returns `(items, error)`; error is empty on success.""" path = fetch.cache_dir() / "news.xml" body, error = fetch.get(FEED, timeout=timeout, cache=path, ttl=TTL) if error: # a stale copy beats nothing, and the feed rarely changes if path.is_file(): try: body = path.read_text() except OSError: return [], error else: return [], error items = _parse(body) if not items: return [], "the news feed could not be read" return items, "" def recent( items: list[NewsItem], *, since: float = 0.0, window: float = DEFAULT_WINDOW ) -> list[NewsItem]: """Items published after `since`, or within `window` if nothing is recorded.""" cutoff = since if since > 0 else time.time() - window return [item for item in items if item.published > cutoff] def summary_line(items: list[NewsItem]) -> str: if not items: return "nothing new" urgent = [i for i in items if i.needs_action] if urgent: return f"{len(items)} recent item(s), {len(urgent)} needing a decision before updating" return f"{len(items)} recent item(s)" def explain(items: list[NewsItem], *, limit: int = 12) -> list[str]: """The news list, as lines, with anything needing action called out first.""" if not items: return ["nothing new since you last looked"] urgent = [i for i in items if i.needs_action] routine = [i for i in items if not i.needs_action] out = [ f"{len(items)} item(s) since you last looked, {len(urgent)} of which ask for a decision", "", ] for group, label in ((urgent, "READ BEFORE UPDATING"), (routine, "ALSO RECENT")): if not group: continue out.append(label) for item in group[:limit]: when = time.strftime("%Y-%m-%d", time.localtime(item.published)) out.append(f" {when} {item.title}") if item.summary: out.append(f" {item.summary[:150]}") if item.link: out.append(f" {item.link}") if len(group) > limit: out.append(f" + {len(group) - limit} more") out.append("") out.append("mark these as read with n") return out