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.
This commit is contained in:
2026-03-15 16:05:41 -07:00
parent 7193e7487b
commit fba183526a
4 changed files with 166 additions and 4 deletions

View File

@@ -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"})

60
engine/types.py Normal file
View File

@@ -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)

View File

@@ -22,6 +22,7 @@ classifiers = [
dependencies = [
"feedparser>=6.0.0",
"Pillow>=10.0.0",
"pyright>=1.1.408",
]
[project.optional-dependencies]

95
tests/test_types.py Normal file
View File

@@ -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)