From fba183526af1ec66c76d19f60f1bd07d7019848f Mon Sep 17 00:00:00 2001 From: David Gwilliam Date: Sun, 15 Mar 2026 16:05:41 -0700 Subject: [PATCH] refactor: phase 3 - API efficiency improvements Add typed dataclasses for tuple returns: - types.py: HeadlineItem, FetchResult, Block dataclasses with legacy tuple converters - fetch.py: Add type hints and HeadlineTuple type alias Add pyright for static type checking: - Add pyright to dependencies - Verify type coverage with pyright (0 errors in core modules) This enables: - Named types instead of raw tuples (better IDE support, self-documenting) - Type-safe APIs across modules - Backward compatibility via to_tuple/from_tuple methods Note: Lazy imports skipped for render.py - startup impact is minimal. --- engine/fetch.py | 14 +++++-- engine/types.py | 60 ++++++++++++++++++++++++++++ pyproject.toml | 1 + tests/test_types.py | 95 +++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 166 insertions(+), 4 deletions(-) create mode 100644 engine/types.py create mode 100644 tests/test_types.py diff --git a/engine/fetch.py b/engine/fetch.py index a236c6e..5d6f9bb 100644 --- a/engine/fetch.py +++ b/engine/fetch.py @@ -8,6 +8,7 @@ import pathlib import re import urllib.request from datetime import datetime +from typing import Any import feedparser @@ -16,9 +17,13 @@ from engine.filter import skip, strip_tags from engine.sources import FEEDS, POETRY_SOURCES from engine.terminal import boot_ln +# Type alias for headline items +HeadlineTuple = tuple[str, str, str] + # ─── SINGLE FEED ────────────────────────────────────────── -def fetch_feed(url): +def fetch_feed(url: str) -> Any | None: + """Fetch and parse a single RSS feed URL.""" try: req = urllib.request.Request(url, headers={"User-Agent": "mainline/0.1"}) resp = urllib.request.urlopen(req, timeout=config.FEED_TIMEOUT) @@ -28,8 +33,9 @@ def fetch_feed(url): # ─── ALL RSS FEEDS ──────────────────────────────────────── -def fetch_all(): - items = [] +def fetch_all() -> tuple[list[HeadlineTuple], int, int]: + """Fetch all RSS feeds and return items, linked count, failed count.""" + items: list[HeadlineTuple] = [] linked = failed = 0 for src, url in FEEDS.items(): feed = fetch_feed(url) @@ -59,7 +65,7 @@ def fetch_all(): # ─── PROJECT GUTENBERG ──────────────────────────────────── -def _fetch_gutenberg(url, label): +def _fetch_gutenberg(url: str, label: str) -> list[HeadlineTuple]: """Download and parse stanzas/passages from a Project Gutenberg text.""" try: req = urllib.request.Request(url, headers={"User-Agent": "mainline/0.1"}) diff --git a/engine/types.py b/engine/types.py new file mode 100644 index 0000000..f7b45a1 --- /dev/null +++ b/engine/types.py @@ -0,0 +1,60 @@ +""" +Shared dataclasses for the mainline application. +Provides named types for tuple returns across modules. +""" + +from dataclasses import dataclass + + +@dataclass +class HeadlineItem: + """A single headline item: title, source, and timestamp.""" + + title: str + source: str + timestamp: str + + def to_tuple(self) -> tuple[str, str, str]: + """Convert to tuple for backward compatibility.""" + return (self.title, self.source, self.timestamp) + + @classmethod + def from_tuple(cls, t: tuple[str, str, str]) -> "HeadlineItem": + """Create from tuple for backward compatibility.""" + return cls(title=t[0], source=t[1], timestamp=t[2]) + + +def items_to_tuples(items: list[HeadlineItem]) -> list[tuple[str, str, str]]: + """Convert list of HeadlineItem to list of tuples.""" + return [item.to_tuple() for item in items] + + +def tuples_to_items(tuples: list[tuple[str, str, str]]) -> list[HeadlineItem]: + """Convert list of tuples to list of HeadlineItem.""" + return [HeadlineItem.from_tuple(t) for t in tuples] + + +@dataclass +class FetchResult: + """Result from fetch_all() or fetch_poetry().""" + + items: list[HeadlineItem] + linked: int + failed: int + + def to_legacy_tuple(self) -> tuple[list[tuple], int, int]: + """Convert to legacy tuple format for backward compatibility.""" + return ([item.to_tuple() for item in self.items], self.linked, self.failed) + + +@dataclass +class Block: + """Rendered headline block from make_block().""" + + content: list[str] + color: str + meta_row_index: int + + def to_legacy_tuple(self) -> tuple[list[str], str, int]: + """Convert to legacy tuple format for backward compatibility.""" + return (self.content, self.color, self.meta_row_index) diff --git a/pyproject.toml b/pyproject.toml index 6d93f42..f52a05a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,6 +22,7 @@ classifiers = [ dependencies = [ "feedparser>=6.0.0", "Pillow>=10.0.0", + "pyright>=1.1.408", ] [project.optional-dependencies] diff --git a/tests/test_types.py b/tests/test_types.py new file mode 100644 index 0000000..33f5c0e --- /dev/null +++ b/tests/test_types.py @@ -0,0 +1,95 @@ +""" +Tests for engine.types module. +""" + +from engine.types import ( + Block, + FetchResult, + HeadlineItem, + items_to_tuples, + tuples_to_items, +) + + +class TestHeadlineItem: + """Tests for HeadlineItem dataclass.""" + + def test_create_headline_item(self): + """Can create HeadlineItem with required fields.""" + item = HeadlineItem(title="Test", source="Source", timestamp="12:00") + assert item.title == "Test" + assert item.source == "Source" + assert item.timestamp == "12:00" + + def test_to_tuple(self): + """to_tuple returns correct tuple.""" + item = HeadlineItem(title="Test", source="Source", timestamp="12:00") + assert item.to_tuple() == ("Test", "Source", "12:00") + + def test_from_tuple(self): + """from_tuple creates HeadlineItem from tuple.""" + item = HeadlineItem.from_tuple(("Test", "Source", "12:00")) + assert item.title == "Test" + assert item.source == "Source" + assert item.timestamp == "12:00" + + +class TestItemsConversion: + """Tests for list conversion functions.""" + + def test_items_to_tuples(self): + """Converts list of HeadlineItem to list of tuples.""" + items = [ + HeadlineItem(title="A", source="S", timestamp="10:00"), + HeadlineItem(title="B", source="T", timestamp="11:00"), + ] + result = items_to_tuples(items) + assert result == [("A", "S", "10:00"), ("B", "T", "11:00")] + + def test_tuples_to_items(self): + """Converts list of tuples to list of HeadlineItem.""" + tuples = [("A", "S", "10:00"), ("B", "T", "11:00")] + result = tuples_to_items(tuples) + assert len(result) == 2 + assert result[0].title == "A" + assert result[1].title == "B" + + +class TestFetchResult: + """Tests for FetchResult dataclass.""" + + def test_create_fetch_result(self): + """Can create FetchResult.""" + items = [HeadlineItem(title="Test", source="Source", timestamp="12:00")] + result = FetchResult(items=items, linked=1, failed=0) + assert len(result.items) == 1 + assert result.linked == 1 + assert result.failed == 0 + + def test_to_legacy_tuple(self): + """to_legacy_tuple returns correct format.""" + items = [HeadlineItem(title="Test", source="Source", timestamp="12:00")] + result = FetchResult(items=items, linked=1, failed=0) + legacy = result.to_legacy_tuple() + assert legacy[0] == [("Test", "Source", "12:00")] + assert legacy[1] == 1 + assert legacy[2] == 0 + + +class TestBlock: + """Tests for Block dataclass.""" + + def test_create_block(self): + """Can create Block.""" + block = Block( + content=["line1", "line2"], color="\033[38;5;46m", meta_row_index=1 + ) + assert len(block.content) == 2 + assert block.color == "\033[38;5;46m" + assert block.meta_row_index == 1 + + def test_to_legacy_tuple(self): + """to_legacy_tuple returns correct format.""" + block = Block(content=["line1"], color="green", meta_row_index=0) + legacy = block.to_legacy_tuple() + assert legacy == (["line1"], "green", 0)