xkcdkit 0.1.0__py3-none-any.whl

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.
xkcdkit/sources.py ADDED
@@ -0,0 +1,89 @@
1
+ """Fetchers for xkcd.com and explainxkcd.com.
2
+
3
+ Every function takes an ``httpx2.AsyncClient`` so callers control pooling,
4
+ timeouts and, in tests, the transport.
5
+ """
6
+
7
+ import html
8
+ import re
9
+ from typing import Any
10
+
11
+ import httpx2
12
+
13
+ from xkcdkit import ComicNotFound, XKCDError, __version__
14
+ from xkcdkit.models import ArchiveEntry, Comic
15
+
16
+ XKCD = "https://xkcd.com"
17
+ EXPLAIN_API = "https://www.explainxkcd.com/wiki/api.php"
18
+ ARCHIVE_LINK = re.compile(r'<a href="/(\d+)/" title="([^"]+)">(.*?)</a>', re.DOTALL)
19
+ HTML_TAG = re.compile(r"</?[a-z][a-z0-9]*(?:\s[^>]*)?>")
20
+
21
+
22
+ def user_agent() -> str:
23
+ """A User-Agent that names the project, as both sites ask of bots."""
24
+ return f"xkcdkit/{__version__} (+https://github.com/eivl/xkcdkit)"
25
+
26
+
27
+ async def request(client: httpx2.AsyncClient, url: str, **params: Any) -> httpx2.Response:
28
+ """GET ``url`` and wrap transport failures in ``XKCDError``."""
29
+ try:
30
+ return await client.get(url, params=params or None)
31
+ except httpx2.HTTPError as exc:
32
+ raise XKCDError(f"{url}: {exc}") from exc
33
+
34
+
35
+ async def fetch_comic(client: httpx2.AsyncClient, num: int | None) -> Comic:
36
+ """Fetch comic ``num`` from xkcd.com, or the latest comic when ``num`` is None."""
37
+ url = f"{XKCD}/info.0.json" if num is None else f"{XKCD}/{num}/info.0.json"
38
+ response = await request(client, url)
39
+ if response.status_code == 404:
40
+ raise ComicNotFound(f"xkcd has no comic {num}")
41
+ if response.status_code != 200:
42
+ raise XKCDError(f"{url}: HTTP {response.status_code}")
43
+ return Comic.model_validate(response.json())
44
+
45
+
46
+ def parse_archive(page: str) -> list[ArchiveEntry]:
47
+ """Turn the archive page's anchors into entries, in page order (newest first)."""
48
+ return [
49
+ ArchiveEntry(num=int(num), date=date, title=html.unescape(HTML_TAG.sub("", title)))
50
+ for num, date, title in ARCHIVE_LINK.findall(page)
51
+ ]
52
+
53
+
54
+ async def fetch_archive(client: httpx2.AsyncClient) -> list[ArchiveEntry]:
55
+ """Fetch and parse https://xkcd.com/archive/."""
56
+ response = await request(client, f"{XKCD}/archive/")
57
+ if response.status_code != 200:
58
+ raise XKCDError(f"archive: HTTP {response.status_code}")
59
+ return parse_archive(response.text)
60
+
61
+
62
+ async def fetch_explanation(client: httpx2.AsyncClient, num: int) -> tuple[str, int] | None:
63
+ """Fetch the explainxkcd page for ``num`` as ``(wikitext, revid)``, or None if absent."""
64
+ response = await request(
65
+ client,
66
+ EXPLAIN_API,
67
+ action="parse",
68
+ page=str(num),
69
+ redirects=1,
70
+ prop="wikitext|revid",
71
+ format="json",
72
+ formatversion=2,
73
+ )
74
+ if response.status_code != 200:
75
+ raise XKCDError(f"explainxkcd: HTTP {response.status_code}")
76
+ payload = response.json()
77
+ if "error" in payload:
78
+ if payload["error"].get("code") == "missingtitle":
79
+ return None
80
+ raise XKCDError(f"explainxkcd: {payload['error'].get('info', 'unknown error')}")
81
+ return payload["parse"]["wikitext"], int(payload["parse"]["revid"])
82
+
83
+
84
+ async def fetch_image(client: httpx2.AsyncClient, comic: Comic) -> bytes:
85
+ """Download the comic's image bytes."""
86
+ response = await request(client, comic.img)
87
+ if response.status_code != 200:
88
+ raise XKCDError(f"{comic.img}: HTTP {response.status_code}")
89
+ return response.content
xkcdkit/store.py ADDED
@@ -0,0 +1,156 @@
1
+ """Local SQLite cache of comics, the archive index and sync metadata."""
2
+
3
+ import os
4
+ import sqlite3
5
+ from datetime import UTC, datetime, timedelta
6
+ from pathlib import Path
7
+ from types import TracebackType
8
+ from typing import Self
9
+
10
+ from platformdirs import user_data_path
11
+ from rapidfuzz import fuzz
12
+
13
+ from xkcdkit.models import ArchiveEntry, Comic
14
+
15
+ COLUMNS = list(Comic.model_fields)
16
+ SCHEMA = f"""
17
+ CREATE TABLE IF NOT EXISTS comics ({", ".join(COLUMNS)}, PRIMARY KEY (num));
18
+ CREATE TABLE IF NOT EXISTS archive (num INTEGER PRIMARY KEY, date TEXT, title TEXT);
19
+ CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT);
20
+ """
21
+ SEARCH_CUTOFF = 60.0
22
+
23
+
24
+ def default_path() -> Path:
25
+ """Database location: ``$XKCDKIT_DB`` or the platform user data directory."""
26
+ override = os.environ.get("XKCDKIT_DB")
27
+ if override:
28
+ return Path(override)
29
+ return user_data_path("xkcdkit") / "xkcd.sqlite3"
30
+
31
+
32
+ def utc_now() -> datetime:
33
+ """Current time as a timezone-aware UTC datetime."""
34
+ return datetime.now(UTC)
35
+
36
+
37
+ class Store:
38
+ """Insert-only cache of comics with fuzzy search.
39
+
40
+ Use as a context manager so the connection is closed::
41
+
42
+ with Store() as store:
43
+ comic = store.get(614)
44
+ """
45
+
46
+ def __init__(self, path: Path | str | None = None) -> None:
47
+ """Open (and create if needed) the database at ``path``."""
48
+ self.path = Path(path) if path else default_path()
49
+ self.path.parent.mkdir(parents=True, exist_ok=True)
50
+ self.db = sqlite3.connect(self.path)
51
+ self.db.row_factory = sqlite3.Row
52
+ self.db.executescript(SCHEMA)
53
+
54
+ def __enter__(self) -> Self:
55
+ """Enter the context manager."""
56
+ return self
57
+
58
+ def __exit__(
59
+ self, *exc: tuple[type[BaseException] | None, BaseException | None, TracebackType | None]
60
+ ) -> None:
61
+ """Close the connection on exit."""
62
+ self.close()
63
+
64
+ def close(self) -> None:
65
+ """Close the database connection."""
66
+ self.db.close()
67
+
68
+ def write(self, comic: Comic, verb: str) -> None:
69
+ """Insert a comic using ``verb`` (``OR IGNORE`` or ``OR REPLACE``)."""
70
+ stamped = comic.model_copy(update={"synced_at": comic.synced_at or utc_now()})
71
+ row = stamped.model_dump(mode="json")
72
+ columns, marks = ", ".join(COLUMNS), ", ".join("?" for _ in COLUMNS)
73
+ sql = f"INSERT {verb} INTO comics ({columns}) VALUES ({marks})" # noqa: S608 - all constants
74
+ with self.db:
75
+ self.db.execute(sql, [row[column] for column in COLUMNS])
76
+
77
+ def put(self, comic: Comic) -> None:
78
+ """Store a comic unless one with the same number already exists."""
79
+ self.write(comic, "OR IGNORE")
80
+
81
+ def replace(self, comic: Comic) -> None:
82
+ """Store a comic, overwriting any existing row with the same number."""
83
+ self.write(comic, "OR REPLACE")
84
+
85
+ def get(self, num: int) -> Comic | None:
86
+ """Return the cached comic ``num`` or None."""
87
+ row = self.db.execute("SELECT * FROM comics WHERE num = ?", (num,)).fetchone()
88
+ return Comic.model_validate(dict(row)) if row else None
89
+
90
+ def has(self, num: int) -> bool:
91
+ """Whether comic ``num`` is cached."""
92
+ return self.get(num) is not None
93
+
94
+ def nums(self) -> list[int]:
95
+ """All cached comic numbers, ascending."""
96
+ return [row[0] for row in self.db.execute("SELECT num FROM comics ORDER BY num")]
97
+
98
+ def count(self) -> int:
99
+ """Number of cached comics."""
100
+ return int(self.db.execute("SELECT COUNT(*) FROM comics").fetchone()[0])
101
+
102
+ def all(self) -> list[Comic]:
103
+ """Every cached comic, ascending by number."""
104
+ rows = self.db.execute("SELECT * FROM comics ORDER BY num")
105
+ return [Comic.model_validate(dict(row)) for row in rows]
106
+
107
+ def put_archive(self, entries: list[ArchiveEntry]) -> None:
108
+ """Replace the archive index with ``entries``."""
109
+ with self.db:
110
+ self.db.execute("DELETE FROM archive")
111
+ self.db.executemany(
112
+ "INSERT OR REPLACE INTO archive VALUES (?, ?, ?)",
113
+ [(e.num, e.date.isoformat(), e.title) for e in entries],
114
+ )
115
+
116
+ def archive(self) -> list[ArchiveEntry]:
117
+ """The archive index, newest first."""
118
+ rows = self.db.execute("SELECT * FROM archive ORDER BY num DESC")
119
+ return [ArchiveEntry.model_validate(dict(row)) for row in rows]
120
+
121
+ def set_meta(self, key: str, value: str) -> None:
122
+ """Store a metadata value such as ``last_sync_at``."""
123
+ with self.db:
124
+ self.db.execute("INSERT OR REPLACE INTO meta VALUES (?, ?)", (key, value))
125
+
126
+ def get_meta(self, key: str) -> str | None:
127
+ """Read a metadata value, or None when unset."""
128
+ row = self.db.execute("SELECT value FROM meta WHERE key = ?", (key,)).fetchone()
129
+ return str(row[0]) if row else None
130
+
131
+ def stale(self, max_age: timedelta) -> list[int]:
132
+ """Numbers of comics older than ``max_age`` or still missing an explanation."""
133
+ cutoff = (utc_now() - max_age).isoformat()
134
+ rows = self.db.execute(
135
+ "SELECT num FROM comics WHERE synced_at < ? OR explanation_missing ORDER BY num",
136
+ (cutoff,),
137
+ )
138
+ return [row[0] for row in rows]
139
+
140
+ def search(self, query: str, limit: int = 10) -> list[tuple[Comic, float]]:
141
+ """Fuzzy-search titles, alt text, transcripts and explanations.
142
+
143
+ Returns ``(comic, score)`` pairs, best first, scores from 0 to 100.
144
+ """
145
+ scored = [(comic, score_comic(comic, query)) for comic in self.all()]
146
+ hits = [(comic, score) for comic, score in scored if score >= SEARCH_CUTOFF]
147
+ hits.sort(key=lambda hit: (-hit[1], hit[0].num))
148
+ return hits[:limit]
149
+
150
+
151
+ def score_comic(comic: Comic, query: str) -> float:
152
+ """Score a comic against a query: exact-ish titles win, long text counts a bit less."""
153
+ title = float(fuzz.WRatio(query, comic.title, processor=str.lower))
154
+ body = " ".join([comic.alt, comic.transcript, comic.explanation_text])
155
+ text = float(fuzz.token_set_ratio(query, body, processor=str.lower))
156
+ return max(title, 0.9 * text)
xkcdkit/tui.py ADDED
@@ -0,0 +1,188 @@
1
+ """A lazygit-style browser: archive on the left, image and text panes on the right."""
2
+
3
+ import io
4
+ from typing import ClassVar
5
+
6
+ from PIL import Image as PILImage
7
+ from rapidfuzz import fuzz
8
+ from rich.text import Text
9
+ from textual import work
10
+ from textual.app import App, ComposeResult
11
+ from textual.binding import Binding, BindingType
12
+ from textual.containers import Horizontal, Vertical, VerticalScroll
13
+ from textual.widgets import Footer, Header, Input, OptionList, Static, TabbedContent, TabPane
14
+ from textual.widgets.option_list import Option
15
+ from textual_image.widget import Image
16
+
17
+ from xkcdkit import XKCDError
18
+ from xkcdkit.client import AsyncXKCD
19
+ from xkcdkit.models import ArchiveEntry, Comic
20
+ from xkcdkit.sources import fetch_image
21
+
22
+ HELP = "j/k move / filter enter open r random l latest i image s sync q quit"
23
+
24
+
25
+ def option(entry: ArchiveEntry) -> Option:
26
+ """An archive row: bold number, then the title, keyed by the comic number."""
27
+ return Option(Text.assemble((f"{entry.num:>5}", "bold"), " ", entry.title), id=str(entry.num))
28
+
29
+
30
+ class XKCDApp(App[None]):
31
+ """Browse the xkcd archive with images, alt text, transcripts and explanations."""
32
+
33
+ TITLE = "xkcd"
34
+ CSS = """
35
+ #archive-pane { width: 40; border: round $primary; }
36
+ #archive { height: 1fr; }
37
+ #image-pane { height: 2fr; border: round $secondary; align: center middle; }
38
+ TabbedContent { height: 1fr; }
39
+ .text { padding: 0 1; }
40
+ """
41
+ BINDINGS: ClassVar[list[BindingType]] = [
42
+ Binding("j", "cursor('down')", "Down"),
43
+ Binding("k", "cursor('up')", "Up"),
44
+ Binding("slash", "focus_filter", "Filter", key_display="/"),
45
+ Binding("r", "random", "Random"),
46
+ Binding("l", "latest", "Latest"),
47
+ Binding("i", "toggle_image", "Image"),
48
+ Binding("s", "sync", "Sync"),
49
+ Binding("question_mark", "help", "Help", key_display="?"),
50
+ Binding("q", "quit", "Quit"),
51
+ ]
52
+
53
+ def __init__(self, client: AsyncXKCD) -> None:
54
+ """Keep the client; the store it holds feeds the archive list."""
55
+ super().__init__()
56
+ self.client = client
57
+ self.entries: list[ArchiveEntry] = []
58
+
59
+ def compose(self) -> ComposeResult:
60
+ """Build the two-column layout."""
61
+ yield Header()
62
+ with Horizontal():
63
+ with Vertical(id="archive-pane"):
64
+ yield Input(placeholder="filter titles", id="filter")
65
+ yield OptionList(id="archive")
66
+ with Vertical():
67
+ yield Vertical(Image(id="image"), id="image-pane")
68
+ with TabbedContent():
69
+ with TabPane("Alt text", id="tab-alt"):
70
+ yield VerticalScroll(Static(id="alt", classes="text", markup=False))
71
+ with TabPane("Transcript", id="tab-transcript"):
72
+ yield VerticalScroll(Static(id="transcript", classes="text", markup=False))
73
+ with TabPane("Explanation", id="tab-explanation"):
74
+ yield VerticalScroll(Static(id="explanation", classes="text", markup=False))
75
+ yield Footer()
76
+
77
+ def on_mount(self) -> None:
78
+ """Show the cached archive (syncing it on first run) and open the latest comic."""
79
+ self.query_one(OptionList).focus()
80
+ self.entries = self.client.store.archive()
81
+ self.show_entries(self.entries)
82
+ if not self.entries:
83
+ self.action_sync()
84
+ self.open(None)
85
+
86
+ def show_entries(self, entries: list[ArchiveEntry]) -> None:
87
+ """Replace the list contents."""
88
+ archive = self.query_one(OptionList)
89
+ archive.clear_options()
90
+ archive.add_options(option(entry) for entry in entries)
91
+ archive.highlighted = 0 if entries else None
92
+
93
+ def on_input_changed(self, event: Input.Changed) -> None:
94
+ """Fuzzy-filter the archive list as the user types."""
95
+ query = event.value.strip().lower()
96
+ if not query:
97
+ self.show_entries(self.entries)
98
+ return
99
+ scored = [(fuzz.WRatio(query, e.title.lower()), e) for e in self.entries]
100
+ self.show_entries([e for score, e in scored if score >= 70])
101
+
102
+ def on_input_submitted(self, event: Input.Submitted) -> None:
103
+ """Enter in the filter opens the first match."""
104
+ archive = self.query_one(OptionList)
105
+ if archive.option_count:
106
+ self.open(int(str(archive.get_option_at_index(0).id)))
107
+
108
+ def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None:
109
+ """Enter on a row opens that comic."""
110
+ self.open(int(str(event.option.id)))
111
+
112
+ @work(exclusive=True, group="open")
113
+ async def open(self, num: int | None) -> None:
114
+ """Fetch comic ``num`` (or the latest, or a random one for -1) and show it."""
115
+ try:
116
+ if num is None:
117
+ comic = await self.client.latest()
118
+ elif num < 0:
119
+ comic = await self.client.random()
120
+ else:
121
+ comic = await self.client.get(num)
122
+ await self.show_comic(comic)
123
+ except XKCDError as exc:
124
+ self.notify(str(exc), severity="error")
125
+
126
+ async def show_comic(self, comic: Comic) -> None:
127
+ """Fill the panes from a comic."""
128
+ self.sub_title = f"#{comic.num} {comic.title} ({comic.date.isoformat()})"
129
+ self.query_one("#alt", Static).update(comic.alt)
130
+ self.query_one("#transcript", Static).update(comic.transcript or "No transcript.")
131
+ explanation = comic.explanation_text or "No explanation on explainxkcd yet."
132
+ self.query_one("#explanation", Static).update(explanation)
133
+ if self.query_one("#image-pane").display:
134
+ await self.show_image(comic)
135
+
136
+ async def show_image(self, comic: Comic) -> None:
137
+ """Download and display the comic image, reporting rather than crashing on bad data."""
138
+ try:
139
+ data = await fetch_image(self.client.http, comic)
140
+ self.query_one("#image", Image).image = PILImage.open(io.BytesIO(data))
141
+ except (XKCDError, OSError) as exc:
142
+ self.notify(f"Image unavailable: {exc}", severity="warning")
143
+
144
+ def action_cursor(self, direction: str) -> None:
145
+ """Move the archive cursor with j/k."""
146
+ archive = self.query_one(OptionList)
147
+ archive.action_cursor_down() if direction == "down" else archive.action_cursor_up()
148
+
149
+ def action_focus_filter(self) -> None:
150
+ """Jump to the filter box."""
151
+ self.query_one("#filter", Input).focus()
152
+
153
+ def action_random(self) -> None:
154
+ """Open a random comic."""
155
+ self.open(-1)
156
+
157
+ def action_latest(self) -> None:
158
+ """Open the newest comic."""
159
+ self.open(None)
160
+
161
+ def action_toggle_image(self) -> None:
162
+ """Show or hide the image pane."""
163
+ pane = self.query_one("#image-pane")
164
+ pane.display = not pane.display
165
+
166
+ @work(exclusive=True, group="sync")
167
+ async def action_sync(self) -> None:
168
+ """Refresh the archive and latest comic, then report."""
169
+ try:
170
+ added = await self.client.sync()
171
+ except XKCDError as exc:
172
+ self.notify(str(exc), severity="error")
173
+ return
174
+ self.entries = self.client.store.archive()
175
+ self.show_entries(self.entries)
176
+ self.notify(f"Synced: {added} new comic(s).")
177
+
178
+ def action_help(self) -> None:
179
+ """Show the key bindings."""
180
+ self.notify(HELP, title="Keys", timeout=8)
181
+
182
+
183
+ def run_tui(client: AsyncXKCD) -> None:
184
+ """Start the browser and close the client when it exits."""
185
+ try:
186
+ XKCDApp(client).run()
187
+ finally:
188
+ client.store.close()