xkcdkit 0.2.0__tar.gz → 0.4.0__tar.gz

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: xkcdkit
3
- Version: 0.2.0
3
+ Version: 0.4.0
4
4
  Summary: xkcd comics as a Python library, a CLI and a TUI, with a local cache and explainxkcd explanations.
5
5
  Keywords: xkcd,comics,cli,tui,textual
6
6
  Author: Eivind Teig
@@ -113,7 +113,8 @@ xkcd random # a random comic
113
113
  xkcd search "woodpecker" # fuzzy search over cached titles, alt text, transcripts and explanations
114
114
  xkcd explain 614 # just the explanation, as plain text
115
115
  xkcd sync # refresh the archive index and the latest comic
116
- xkcd sync --full # download every comic and explanation (about an hour, see below)
116
+ xkcd sync --full # download every comic and explanation (a couple of minutes)
117
+ xkcd sync --full --no-explain # comics only, under a minute; explanations come later
117
118
  xkcd resync # re-fetch comics older than 30 days or still missing an explanation
118
119
  xkcd tui # open the browser
119
120
  ```
@@ -271,10 +272,22 @@ async def xkcd(ctx, num: int):
271
272
  ### Being polite to explainxkcd
272
273
 
273
274
  explainxkcd is a volunteer-run wiki. Its robots policy asks for one request
274
- per second, so a full sync fetches one explanation per second and sends a
275
- User-Agent that names this project and its repository. A complete first
276
- `sync --full` therefore takes about an hour. On-demand lookups are not
277
- delayed.
275
+ per second, so bulk syncs send at most one explainxkcd request per second and
276
+ a User-Agent that names this project and its repository. To stay fast anyway,
277
+ each request asks the MediaWiki API for fifty pages at once, the maximum it
278
+ allows, so the whole archive's explanations arrive in about seventy requests.
279
+ xkcd.com has no such limit and is fetched by several workers at once. A
280
+ complete first `sync --full` takes a couple of minutes.
281
+
282
+ - `xkcd sync --full --no-explain` fetches only the comics, in under a minute.
283
+ Explanations are then filled in on demand when you open a comic, or in bulk
284
+ with `xkcd resync`, paced the same way.
285
+ - Ctrl-C at any point is safe. Each batch of fifty is committed as soon as it
286
+ is fetched, and the next `sync --full` or `resync` skips everything already
287
+ cached, so you can sync in several sittings.
288
+
289
+ The sync walks every number from 1 to the latest comic, so it does not depend
290
+ on the archive page. On-demand lookups are never delayed.
278
291
 
279
292
  ## Terminal image support
280
293
 
@@ -81,7 +81,8 @@ xkcd random # a random comic
81
81
  xkcd search "woodpecker" # fuzzy search over cached titles, alt text, transcripts and explanations
82
82
  xkcd explain 614 # just the explanation, as plain text
83
83
  xkcd sync # refresh the archive index and the latest comic
84
- xkcd sync --full # download every comic and explanation (about an hour, see below)
84
+ xkcd sync --full # download every comic and explanation (a couple of minutes)
85
+ xkcd sync --full --no-explain # comics only, under a minute; explanations come later
85
86
  xkcd resync # re-fetch comics older than 30 days or still missing an explanation
86
87
  xkcd tui # open the browser
87
88
  ```
@@ -239,10 +240,22 @@ async def xkcd(ctx, num: int):
239
240
  ### Being polite to explainxkcd
240
241
 
241
242
  explainxkcd is a volunteer-run wiki. Its robots policy asks for one request
242
- per second, so a full sync fetches one explanation per second and sends a
243
- User-Agent that names this project and its repository. A complete first
244
- `sync --full` therefore takes about an hour. On-demand lookups are not
245
- delayed.
243
+ per second, so bulk syncs send at most one explainxkcd request per second and
244
+ a User-Agent that names this project and its repository. To stay fast anyway,
245
+ each request asks the MediaWiki API for fifty pages at once, the maximum it
246
+ allows, so the whole archive's explanations arrive in about seventy requests.
247
+ xkcd.com has no such limit and is fetched by several workers at once. A
248
+ complete first `sync --full` takes a couple of minutes.
249
+
250
+ - `xkcd sync --full --no-explain` fetches only the comics, in under a minute.
251
+ Explanations are then filled in on demand when you open a comic, or in bulk
252
+ with `xkcd resync`, paced the same way.
253
+ - Ctrl-C at any point is safe. Each batch of fifty is committed as soon as it
254
+ is fetched, and the next `sync --full` or `resync` skips everything already
255
+ cached, so you can sync in several sittings.
256
+
257
+ The sync walks every number from 1 to the latest comic, so it does not depend
258
+ on the archive page. On-demand lookups are never delayed.
246
259
 
247
260
  ## Terminal image support
248
261
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "xkcdkit"
3
- version = "0.2.0"
3
+ version = "0.4.0"
4
4
  description = "xkcd comics as a Python library, a CLI and a TUI, with a local cache and explainxkcd explanations."
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "xkcdkit"
3
- version = "0.2.0"
3
+ version = "0.4.0"
4
4
  description = "xkcd comics as a Python library, a CLI and a TUI, with a local cache and explainxkcd explanations."
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -69,6 +69,12 @@ def run(ctx: typer.Context, action: Callable[[AsyncXKCD], Awaitable[Any]]) -> An
69
69
  except XKCDError as exc:
70
70
  errors.print(f"error: {exc}", markup=False, highlight=False)
71
71
  raise typer.Exit(1) from exc
72
+ except KeyboardInterrupt:
73
+ errors.print(
74
+ "Interrupted. Everything fetched so far is saved; run the command again to continue.",
75
+ soft_wrap=True,
76
+ )
77
+ raise typer.Exit(130) from None
72
78
 
73
79
 
74
80
  @dataclass
@@ -173,11 +179,17 @@ def explain(ctx: typer.Context, num: Num, json: Json = False) -> None:
173
179
  def sync(
174
180
  ctx: typer.Context,
175
181
  full: Annotated[bool, typer.Option("--full", help="Fetch every comic and explanation.")] = False,
182
+ no_explain: Annotated[
183
+ bool, typer.Option("--no-explain", help="Skip explainxkcd for now (much faster); resync fills it in.")
184
+ ] = False,
176
185
  ) -> None:
177
186
  """Refresh the archive index and latest comic; with --full, download everything."""
178
187
  with console.status("Syncing") as status:
179
188
  added: int = run(
180
- ctx, lambda xk: xk.sync(full, lambda done, total: status.update(f"Syncing {done}/{total}"))
189
+ ctx,
190
+ lambda xk: xk.sync(
191
+ full, lambda done, total: status.update(f"Syncing {done}/{total}"), explain=not no_explain
192
+ ),
181
193
  )
182
194
  console.print(f"{added} new comic{'s' if added != 1 else ''} cached.")
183
195
 
@@ -2,6 +2,7 @@
2
2
 
3
3
  import asyncio
4
4
  import random
5
+ import time
5
6
  from collections.abc import Callable
6
7
  from datetime import timedelta
7
8
  from pathlib import Path
@@ -12,11 +13,19 @@ import httpx2
12
13
 
13
14
  from xkcdkit import ComicNotFound, XKCDError
14
15
  from xkcdkit.models import ArchiveEntry, Comic
15
- from xkcdkit.sources import fetch_archive, fetch_comic, fetch_explanation, user_agent
16
+ from xkcdkit.sources import (
17
+ EXPLAIN_BATCH,
18
+ fetch_archive,
19
+ fetch_comic,
20
+ fetch_explanation,
21
+ fetch_explanations,
22
+ user_agent,
23
+ )
16
24
  from xkcdkit.store import Store, utc_now
17
25
 
18
26
  Progress = Callable[[int, int], None]
19
27
  RETRY_MISSING_AFTER = timedelta(days=1)
28
+ WORKERS = 8
20
29
 
21
30
 
22
31
  class AsyncXKCD:
@@ -32,7 +41,7 @@ class AsyncXKCD:
32
41
  db: Database path. Defaults to the platform user data directory or ``$XKCDKIT_DB``.
33
42
  client: An ``httpx2.AsyncClient`` to use instead of the built-in one.
34
43
  timeout: Request timeout in seconds for the built-in client.
35
- explain_delay: Seconds to wait between explainxkcd requests during bulk syncs.
44
+ explain_delay: Minimum seconds between explainxkcd requests during bulk syncs.
36
45
  """
37
46
 
38
47
  def __init__(
@@ -48,6 +57,8 @@ class AsyncXKCD:
48
57
  timeout=timeout, headers={"User-Agent": user_agent()}, follow_redirects=True
49
58
  )
50
59
  self.explain_delay = explain_delay
60
+ self.explain_lock = asyncio.Lock()
61
+ self.last_explain = 0.0
51
62
 
52
63
  async def __aenter__(self) -> Self:
53
64
  """Enter the async context manager."""
@@ -67,6 +78,11 @@ class AsyncXKCD:
67
78
  async def complete(self, comic: Comic) -> Comic:
68
79
  """Attach the explainxkcd page when it exists and stamp the sync time."""
69
80
  found = await fetch_explanation(self.http, comic.num)
81
+ self.last_explain = time.monotonic()
82
+ return self.attach(comic, found)
83
+
84
+ def attach(self, comic: Comic, found: tuple[str, int] | None) -> Comic:
85
+ """Copy a comic with explanation data (or a note of its absence) and a fresh sync time."""
70
86
  wikitext, revid = found if found else (None, None)
71
87
  return comic.model_copy(
72
88
  update={
@@ -126,39 +142,70 @@ class AsyncXKCD:
126
142
  self.store.put_archive(entries)
127
143
  return entries
128
144
 
129
- async def sync(self, full: bool = False, progress: Progress | None = None) -> int:
145
+ async def sync(self, full: bool = False, progress: Progress | None = None, explain: bool = True) -> int:
130
146
  """Refresh the archive index and the latest comic; with ``full`` fetch every comic.
131
147
 
132
148
  A full sync walks every number from 1 to the latest, so it does not depend
133
- on the archive page. Returns the number of comics newly added to the cache.
149
+ on the archive page. With ``explain=False`` it skips explainxkcd, which is
150
+ much faster; ``resync`` or a later ``get`` fills the explanations in.
151
+ Returns the number of comics newly added to the cache.
134
152
  """
135
153
  before = self.store.count()
136
154
  await self.refresh_archive()
137
155
  latest = await self.latest()
138
156
  if full:
139
157
  cached = set(self.store.nums())
140
- await self.fetch_many([n for n in range(1, latest.num + 1) if n not in cached], progress)
158
+ nums = [n for n in range(1, latest.num + 1) if n not in cached]
159
+ await self.fetch_many(nums, progress, explain)
141
160
  self.store.set_meta("last_full_sync_at", utc_now().isoformat())
142
161
  self.store.set_meta("last_sync_at", utc_now().isoformat())
143
162
  return self.store.count() - before
144
163
 
145
164
  async def resync(self, max_age: timedelta = timedelta(days=30), progress: Progress | None = None) -> int:
146
- """Re-fetch comics older than ``max_age`` or missing an explanation. Returns the count."""
165
+ """Re-fetch comics older than ``max_age`` or without explanation data. Returns the count."""
147
166
  return await self.fetch_many(self.store.stale(max_age), progress)
148
167
 
149
- async def fetch_many(self, nums: list[int], progress: Progress | None) -> int:
150
- """Fetch and store each comic in turn, politely paced, skipping numbers xkcd lacks."""
151
- done = 0
152
- for index, num in enumerate(nums, start=1):
153
- try:
154
- self.store.replace(await self.complete(await fetch_comic(self.http, num)))
155
- done += 1
156
- except ComicNotFound:
157
- pass
168
+ async def fetch_many(self, nums: list[int], progress: Progress | None, explain: bool = True) -> int:
169
+ """Fetch and store comics in batches of ``EXPLAIN_BATCH``.
170
+
171
+ Within a batch xkcd.com is fetched concurrently and explainxkcd answers for the
172
+ whole batch in one paced request. Each batch is committed as a unit, so an
173
+ interrupted run keeps every finished batch. Numbers xkcd lacks are skipped.
174
+ """
175
+ fetched = 0
176
+ for start in range(0, len(nums), EXPLAIN_BATCH):
177
+ comics = await self.fetch_comics(nums[start : start + EXPLAIN_BATCH])
178
+ if explain and comics:
179
+ await self.pace()
180
+ found = await fetch_explanations(self.http, [comic.num for comic in comics])
181
+ comics = [self.attach(comic, found[comic.num]) for comic in comics]
182
+ for comic in comics:
183
+ self.store.replace(comic)
184
+ fetched += len(comics)
158
185
  if progress:
159
- progress(index, len(nums))
160
- await asyncio.sleep(self.explain_delay)
161
- return done
186
+ progress(min(start + EXPLAIN_BATCH, len(nums)), len(nums))
187
+ return fetched
188
+
189
+ async def fetch_comics(self, nums: list[int]) -> list[Comic]:
190
+ """Fetch several comics from xkcd.com, a few at a time, skipping numbers it lacks."""
191
+ limit = asyncio.Semaphore(WORKERS)
192
+
193
+ async def one(num: int) -> Comic | None:
194
+ async with limit:
195
+ try:
196
+ return await fetch_comic(self.http, num)
197
+ except ComicNotFound:
198
+ return None
199
+
200
+ return [comic for comic in await asyncio.gather(*(one(num) for num in nums)) if comic]
201
+
202
+ async def pace(self) -> None:
203
+ """Wait until ``explain_delay`` has passed since the previous explainxkcd request."""
204
+ async with self.explain_lock:
205
+ wait = self.last_explain + self.explain_delay - time.monotonic()
206
+ if wait > 0:
207
+ await asyncio.sleep(wait)
208
+ self.last_explain = time.monotonic()
162
209
 
163
210
 
164
211
  class XKCD:
@@ -224,9 +271,9 @@ class XKCD:
224
271
  """See ``AsyncXKCD.explain``."""
225
272
  return self.run("explain", num) # type: ignore[no-any-return]
226
273
 
227
- def sync(self, full: bool = False, progress: Progress | None = None) -> int:
274
+ def sync(self, full: bool = False, progress: Progress | None = None, explain: bool = True) -> int:
228
275
  """See ``AsyncXKCD.sync``."""
229
- return self.run("sync", full, progress) # type: ignore[no-any-return]
276
+ return self.run("sync", full, progress, explain) # type: ignore[no-any-return]
230
277
 
231
278
  def resync(self, max_age: timedelta = timedelta(days=30), progress: Progress | None = None) -> int:
232
279
  """See ``AsyncXKCD.resync``."""
@@ -15,6 +15,7 @@ from xkcdkit.models import ArchiveEntry, Comic
15
15
 
16
16
  XKCD = "https://xkcd.com"
17
17
  EXPLAIN_API = "https://www.explainxkcd.com/wiki/api.php"
18
+ EXPLAIN_BATCH = 50 # pages per API request allowed for ordinary users
18
19
  ARCHIVE_LINK = re.compile(r'<a href="/(\d+)/" title="([^"]+)">(.*?)</a>', re.DOTALL)
19
20
  HTML_TAG = re.compile(r"</?[a-z][a-z0-9]*(?:\s[^>]*)?>")
20
21
 
@@ -59,15 +60,23 @@ async def fetch_archive(client: httpx2.AsyncClient) -> list[ArchiveEntry]:
59
60
  return parse_archive(response.text)
60
61
 
61
62
 
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."""
63
+ async def fetch_explanations(
64
+ client: httpx2.AsyncClient, nums: list[int]
65
+ ) -> dict[int, tuple[str, int] | None]:
66
+ """Fetch up to ``EXPLAIN_BATCH`` explainxkcd pages in one request, keyed by comic number.
67
+
68
+ The wiki pages are named ``614: Woodpecker``; the bare number redirects there, and
69
+ the API resolves that for us. Numbers without a page map to None.
70
+ """
64
71
  response = await request(
65
72
  client,
66
73
  EXPLAIN_API,
67
- action="parse",
68
- page=str(num),
74
+ action="query",
75
+ titles="|".join(str(num) for num in nums),
69
76
  redirects=1,
70
- prop="wikitext|revid",
77
+ prop="revisions",
78
+ rvprop="ids|content",
79
+ rvslots="main",
71
80
  format="json",
72
81
  formatversion=2,
73
82
  )
@@ -75,10 +84,23 @@ async def fetch_explanation(client: httpx2.AsyncClient, num: int) -> tuple[str,
75
84
  raise XKCDError(f"explainxkcd: HTTP {response.status_code}")
76
85
  payload = response.json()
77
86
  if "error" in payload:
78
- if payload["error"].get("code") == "missingtitle":
79
- return None
80
87
  raise XKCDError(f"explainxkcd: {payload['error'].get('info', 'unknown error')}")
81
- return payload["parse"]["wikitext"], int(payload["parse"]["revid"])
88
+ target = {r["from"]: r["to"] for r in payload["query"].get("redirects", [])}
89
+ pages = {page["title"]: page for page in payload["query"]["pages"]}
90
+ return {num: page_content(pages.get(target.get(str(num), str(num)))) for num in nums}
91
+
92
+
93
+ def page_content(page: dict[str, Any] | None) -> tuple[str, int] | None:
94
+ """The latest revision's wikitext and id, or None for an absent page."""
95
+ if not page or page.get("missing"):
96
+ return None
97
+ revision = page["revisions"][0]
98
+ return revision["slots"]["main"]["content"], int(revision["revid"])
99
+
100
+
101
+ async def fetch_explanation(client: httpx2.AsyncClient, num: int) -> tuple[str, int] | None:
102
+ """Fetch the explainxkcd page for ``num`` as ``(wikitext, revid)``, or None if absent."""
103
+ return (await fetch_explanations(client, [num]))[num]
82
104
 
83
105
 
84
106
  async def fetch_image(client: httpx2.AsyncClient, comic: Comic) -> bytes:
@@ -129,10 +129,10 @@ class Store:
129
129
  return str(row[0]) if row else None
130
130
 
131
131
  def stale(self, max_age: timedelta) -> list[int]:
132
- """Numbers of comics older than ``max_age`` or still missing an explanation."""
132
+ """Numbers of comics older than ``max_age`` or without explanation data yet."""
133
133
  cutoff = (utc_now() - max_age).isoformat()
134
134
  rows = self.db.execute(
135
- "SELECT num FROM comics WHERE synced_at < ? OR explanation_missing ORDER BY num",
135
+ "SELECT num FROM comics WHERE synced_at < ? OR explanation IS NULL ORDER BY num",
136
136
  (cutoff,),
137
137
  )
138
138
  return [row[0] for row in rows]
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes