xkcdkit 0.2.0__tar.gz → 0.3.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.
- {xkcdkit-0.2.0 → xkcdkit-0.3.0}/PKG-INFO +20 -6
- {xkcdkit-0.2.0 → xkcdkit-0.3.0}/README.md +19 -5
- {xkcdkit-0.2.0 → xkcdkit-0.3.0}/pyproject.toml +1 -1
- {xkcdkit-0.2.0 → xkcdkit-0.3.0}/pyproject.toml.orig +1 -1
- {xkcdkit-0.2.0 → xkcdkit-0.3.0}/src/xkcdkit/cli.py +13 -1
- {xkcdkit-0.2.0 → xkcdkit-0.3.0}/src/xkcdkit/client.py +51 -19
- {xkcdkit-0.2.0 → xkcdkit-0.3.0}/src/xkcdkit/store.py +2 -2
- {xkcdkit-0.2.0 → xkcdkit-0.3.0}/src/xkcdkit/__init__.py +0 -0
- {xkcdkit-0.2.0 → xkcdkit-0.3.0}/src/xkcdkit/config.py +0 -0
- {xkcdkit-0.2.0 → xkcdkit-0.3.0}/src/xkcdkit/models.py +0 -0
- {xkcdkit-0.2.0 → xkcdkit-0.3.0}/src/xkcdkit/py.typed +0 -0
- {xkcdkit-0.2.0 → xkcdkit-0.3.0}/src/xkcdkit/render.py +0 -0
- {xkcdkit-0.2.0 → xkcdkit-0.3.0}/src/xkcdkit/sources.py +0 -0
- {xkcdkit-0.2.0 → xkcdkit-0.3.0}/src/xkcdkit/tui.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: xkcdkit
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.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 (
|
|
116
|
+
xkcd sync --full # download every comic and explanation (under an hour, see below)
|
|
117
|
+
xkcd sync --full --no-explain # comics only, a minute or two; 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,23 @@ 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
|
|
275
|
-
User-Agent that names this project and its repository.
|
|
276
|
-
|
|
277
|
-
|
|
275
|
+
per second, so a full sync sends at most one explainxkcd request per second
|
|
276
|
+
and a User-Agent that names this project and its repository. xkcd.com has no
|
|
277
|
+
such limit and is fetched by several workers at once, so explanations are the
|
|
278
|
+
pacing item: a complete first `sync --full` takes a little under an hour for
|
|
279
|
+
the whole archive.
|
|
280
|
+
|
|
281
|
+
Two ways to shorten the wait:
|
|
282
|
+
|
|
283
|
+
- `xkcd sync --full --no-explain` fetches only the comics, which takes a
|
|
284
|
+
minute or two. Explanations are then filled in on demand when you open a
|
|
285
|
+
comic, or in bulk with `xkcd resync`, which is paced the same way.
|
|
286
|
+
- Ctrl-C at any point is safe. Every comic is committed to the database the
|
|
287
|
+
moment it is fetched, and the next `sync --full` skips everything already
|
|
288
|
+
cached, so you can sync in several sittings.
|
|
289
|
+
|
|
290
|
+
The sync walks every number from 1 to the latest comic, so it does not depend
|
|
291
|
+
on the archive page. On-demand lookups are never delayed.
|
|
278
292
|
|
|
279
293
|
## Terminal image support
|
|
280
294
|
|
|
@@ -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 (
|
|
84
|
+
xkcd sync --full # download every comic and explanation (under an hour, see below)
|
|
85
|
+
xkcd sync --full --no-explain # comics only, a minute or two; 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,23 @@ 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
|
|
243
|
-
User-Agent that names this project and its repository.
|
|
244
|
-
|
|
245
|
-
|
|
243
|
+
per second, so a full sync sends at most one explainxkcd request per second
|
|
244
|
+
and a User-Agent that names this project and its repository. xkcd.com has no
|
|
245
|
+
such limit and is fetched by several workers at once, so explanations are the
|
|
246
|
+
pacing item: a complete first `sync --full` takes a little under an hour for
|
|
247
|
+
the whole archive.
|
|
248
|
+
|
|
249
|
+
Two ways to shorten the wait:
|
|
250
|
+
|
|
251
|
+
- `xkcd sync --full --no-explain` fetches only the comics, which takes a
|
|
252
|
+
minute or two. Explanations are then filled in on demand when you open a
|
|
253
|
+
comic, or in bulk with `xkcd resync`, which is paced the same way.
|
|
254
|
+
- Ctrl-C at any point is safe. Every comic is committed to the database the
|
|
255
|
+
moment it is fetched, and the next `sync --full` skips everything already
|
|
256
|
+
cached, so you can sync in several sittings.
|
|
257
|
+
|
|
258
|
+
The sync walks every number from 1 to the latest comic, so it does not depend
|
|
259
|
+
on the archive page. On-demand lookups are never delayed.
|
|
246
260
|
|
|
247
261
|
## Terminal image support
|
|
248
262
|
|
|
@@ -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,
|
|
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
|
|
@@ -17,6 +18,7 @@ from xkcdkit.store import Store, utc_now
|
|
|
17
18
|
|
|
18
19
|
Progress = Callable[[int, int], None]
|
|
19
20
|
RETRY_MISSING_AFTER = timedelta(days=1)
|
|
21
|
+
WORKERS = 8
|
|
20
22
|
|
|
21
23
|
|
|
22
24
|
class AsyncXKCD:
|
|
@@ -32,7 +34,7 @@ class AsyncXKCD:
|
|
|
32
34
|
db: Database path. Defaults to the platform user data directory or ``$XKCDKIT_DB``.
|
|
33
35
|
client: An ``httpx2.AsyncClient`` to use instead of the built-in one.
|
|
34
36
|
timeout: Request timeout in seconds for the built-in client.
|
|
35
|
-
explain_delay:
|
|
37
|
+
explain_delay: Minimum seconds between explainxkcd requests during bulk syncs.
|
|
36
38
|
"""
|
|
37
39
|
|
|
38
40
|
def __init__(
|
|
@@ -48,6 +50,8 @@ class AsyncXKCD:
|
|
|
48
50
|
timeout=timeout, headers={"User-Agent": user_agent()}, follow_redirects=True
|
|
49
51
|
)
|
|
50
52
|
self.explain_delay = explain_delay
|
|
53
|
+
self.explain_lock = asyncio.Lock()
|
|
54
|
+
self.last_explain = 0.0
|
|
51
55
|
|
|
52
56
|
async def __aenter__(self) -> Self:
|
|
53
57
|
"""Enter the async context manager."""
|
|
@@ -67,6 +71,7 @@ class AsyncXKCD:
|
|
|
67
71
|
async def complete(self, comic: Comic) -> Comic:
|
|
68
72
|
"""Attach the explainxkcd page when it exists and stamp the sync time."""
|
|
69
73
|
found = await fetch_explanation(self.http, comic.num)
|
|
74
|
+
self.last_explain = time.monotonic()
|
|
70
75
|
wikitext, revid = found if found else (None, None)
|
|
71
76
|
return comic.model_copy(
|
|
72
77
|
update={
|
|
@@ -126,39 +131,66 @@ class AsyncXKCD:
|
|
|
126
131
|
self.store.put_archive(entries)
|
|
127
132
|
return entries
|
|
128
133
|
|
|
129
|
-
async def sync(self, full: bool = False, progress: Progress | None = None) -> int:
|
|
134
|
+
async def sync(self, full: bool = False, progress: Progress | None = None, explain: bool = True) -> int:
|
|
130
135
|
"""Refresh the archive index and the latest comic; with ``full`` fetch every comic.
|
|
131
136
|
|
|
132
137
|
A full sync walks every number from 1 to the latest, so it does not depend
|
|
133
|
-
on the archive page.
|
|
138
|
+
on the archive page. With ``explain=False`` it skips explainxkcd, which is
|
|
139
|
+
much faster; ``resync`` or a later ``get`` fills the explanations in.
|
|
140
|
+
Returns the number of comics newly added to the cache.
|
|
134
141
|
"""
|
|
135
142
|
before = self.store.count()
|
|
136
143
|
await self.refresh_archive()
|
|
137
144
|
latest = await self.latest()
|
|
138
145
|
if full:
|
|
139
146
|
cached = set(self.store.nums())
|
|
140
|
-
|
|
147
|
+
nums = [n for n in range(1, latest.num + 1) if n not in cached]
|
|
148
|
+
await self.fetch_many(nums, progress, explain)
|
|
141
149
|
self.store.set_meta("last_full_sync_at", utc_now().isoformat())
|
|
142
150
|
self.store.set_meta("last_sync_at", utc_now().isoformat())
|
|
143
151
|
return self.store.count() - before
|
|
144
152
|
|
|
145
153
|
async def resync(self, max_age: timedelta = timedelta(days=30), progress: Progress | None = None) -> int:
|
|
146
|
-
"""Re-fetch comics older than ``max_age`` or
|
|
154
|
+
"""Re-fetch comics older than ``max_age`` or without explanation data. Returns the count."""
|
|
147
155
|
return await self.fetch_many(self.store.stale(max_age), progress)
|
|
148
156
|
|
|
149
|
-
async def fetch_many(self, nums: list[int], progress: Progress | None) -> int:
|
|
150
|
-
"""Fetch and store
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
157
|
+
async def fetch_many(self, nums: list[int], progress: Progress | None, explain: bool = True) -> int:
|
|
158
|
+
"""Fetch and store comics with several workers; explainxkcd is paced to one request at a time.
|
|
159
|
+
|
|
160
|
+
xkcd.com requests overlap freely. Each stored comic is committed at once, so an
|
|
161
|
+
interrupted run keeps everything finished so far. Numbers xkcd lacks are skipped.
|
|
162
|
+
"""
|
|
163
|
+
pending, done, fetched = list(reversed(nums)), 0, 0
|
|
164
|
+
|
|
165
|
+
async def worker() -> None:
|
|
166
|
+
nonlocal done, fetched
|
|
167
|
+
while pending:
|
|
168
|
+
num = pending.pop()
|
|
169
|
+
try:
|
|
170
|
+
comic = await fetch_comic(self.http, num)
|
|
171
|
+
if explain:
|
|
172
|
+
await self.pace()
|
|
173
|
+
comic = await self.complete(comic)
|
|
174
|
+
self.store.replace(comic)
|
|
175
|
+
fetched += 1
|
|
176
|
+
except ComicNotFound:
|
|
177
|
+
pass
|
|
155
178
|
done += 1
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
179
|
+
if progress:
|
|
180
|
+
progress(done, len(nums))
|
|
181
|
+
|
|
182
|
+
async with asyncio.TaskGroup() as group:
|
|
183
|
+
for _ in range(min(WORKERS, len(nums))):
|
|
184
|
+
group.create_task(worker())
|
|
185
|
+
return fetched
|
|
186
|
+
|
|
187
|
+
async def pace(self) -> None:
|
|
188
|
+
"""Wait until ``explain_delay`` has passed since the previous explainxkcd request."""
|
|
189
|
+
async with self.explain_lock:
|
|
190
|
+
wait = self.last_explain + self.explain_delay - time.monotonic()
|
|
191
|
+
if wait > 0:
|
|
192
|
+
await asyncio.sleep(wait)
|
|
193
|
+
self.last_explain = time.monotonic()
|
|
162
194
|
|
|
163
195
|
|
|
164
196
|
class XKCD:
|
|
@@ -224,9 +256,9 @@ class XKCD:
|
|
|
224
256
|
"""See ``AsyncXKCD.explain``."""
|
|
225
257
|
return self.run("explain", num) # type: ignore[no-any-return]
|
|
226
258
|
|
|
227
|
-
def sync(self, full: bool = False, progress: Progress | None = None) -> int:
|
|
259
|
+
def sync(self, full: bool = False, progress: Progress | None = None, explain: bool = True) -> int:
|
|
228
260
|
"""See ``AsyncXKCD.sync``."""
|
|
229
|
-
return self.run("sync", full, progress) # type: ignore[no-any-return]
|
|
261
|
+
return self.run("sync", full, progress, explain) # type: ignore[no-any-return]
|
|
230
262
|
|
|
231
263
|
def resync(self, max_age: timedelta = timedelta(days=30), progress: Progress | None = None) -> int:
|
|
232
264
|
"""See ``AsyncXKCD.resync``."""
|
|
@@ -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
|
|
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
|
|
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
|
|
File without changes
|