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/__init__.py +56 -0
- xkcdkit/cli.py +179 -0
- xkcdkit/client.py +233 -0
- xkcdkit/models.py +108 -0
- xkcdkit/py.typed +0 -0
- xkcdkit/render.py +43 -0
- xkcdkit/sources.py +89 -0
- xkcdkit/store.py +156 -0
- xkcdkit/tui.py +188 -0
- xkcdkit-0.1.0.dist-info/METADATA +293 -0
- xkcdkit-0.1.0.dist-info/RECORD +13 -0
- xkcdkit-0.1.0.dist-info/WHEEL +4 -0
- xkcdkit-0.1.0.dist-info/entry_points.txt +3 -0
xkcdkit/__init__.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""xkcd comics as a library, a CLI and a TUI, with a local cache.
|
|
2
|
+
|
|
3
|
+
Quick start::
|
|
4
|
+
|
|
5
|
+
from xkcdkit import AsyncXKCD
|
|
6
|
+
|
|
7
|
+
async with AsyncXKCD() as xk:
|
|
8
|
+
comic = await xk.get(614)
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
12
|
+
from typing import TYPE_CHECKING
|
|
13
|
+
|
|
14
|
+
try:
|
|
15
|
+
__version__ = version("xkcdkit")
|
|
16
|
+
except PackageNotFoundError: # pragma: no cover - only when not installed
|
|
17
|
+
__version__ = "0.0.0"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class XKCDError(Exception):
|
|
21
|
+
"""Base class for every error raised by xkcdkit."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ComicNotFound(XKCDError): # noqa: N818 - the name reads better at call sites
|
|
25
|
+
"""Raised when xkcd has no comic with the requested number."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
if TYPE_CHECKING:
|
|
29
|
+
from xkcdkit.client import XKCD, AsyncXKCD
|
|
30
|
+
from xkcdkit.models import ArchiveEntry, Comic
|
|
31
|
+
|
|
32
|
+
LAZY = {
|
|
33
|
+
"AsyncXKCD": "xkcdkit.client",
|
|
34
|
+
"XKCD": "xkcdkit.client",
|
|
35
|
+
"ArchiveEntry": "xkcdkit.models",
|
|
36
|
+
"Comic": "xkcdkit.models",
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def __getattr__(name: str) -> object:
|
|
41
|
+
"""Import the heavier submodules only when their names are first used."""
|
|
42
|
+
if name in LAZY:
|
|
43
|
+
module = __import__(LAZY[name], fromlist=[name])
|
|
44
|
+
return getattr(module, name)
|
|
45
|
+
raise AttributeError(f"module 'xkcdkit' has no attribute {name!r}")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
__all__ = [
|
|
49
|
+
"XKCD",
|
|
50
|
+
"ArchiveEntry",
|
|
51
|
+
"AsyncXKCD",
|
|
52
|
+
"Comic",
|
|
53
|
+
"ComicNotFound",
|
|
54
|
+
"XKCDError",
|
|
55
|
+
"__version__",
|
|
56
|
+
]
|
xkcdkit/cli.py
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
"""The ``xkcd`` command line interface."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from collections.abc import Awaitable, Callable
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from datetime import timedelta
|
|
7
|
+
from typing import Annotated, Any
|
|
8
|
+
|
|
9
|
+
import typer
|
|
10
|
+
from rich.console import Console
|
|
11
|
+
from rich.text import Text
|
|
12
|
+
|
|
13
|
+
from xkcdkit import XKCDError, __version__
|
|
14
|
+
from xkcdkit.client import AsyncXKCD
|
|
15
|
+
from xkcdkit.models import Comic
|
|
16
|
+
from xkcdkit.render import can_draw, comic_image, comic_text
|
|
17
|
+
from xkcdkit.sources import fetch_image
|
|
18
|
+
|
|
19
|
+
app = typer.Typer(help="xkcd comics in your terminal.", no_args_is_help=True, add_completion=False)
|
|
20
|
+
console = Console()
|
|
21
|
+
errors = Console(stderr=True)
|
|
22
|
+
Num = Annotated[int, typer.Argument(help="Comic number.")]
|
|
23
|
+
Json = Annotated[bool, typer.Option("--json", help="Print JSON for piping.")]
|
|
24
|
+
NoImage = Annotated[bool, typer.Option("--no-image", help="Do not draw the image.")]
|
|
25
|
+
Explain = Annotated[bool, typer.Option("--explain", "-e", help="Include the explanation.")]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class Settings:
|
|
30
|
+
"""Options shared by every command."""
|
|
31
|
+
|
|
32
|
+
db: str | None = None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def open_client(db: str | None) -> AsyncXKCD:
|
|
36
|
+
"""Create the client; tests replace this to inject fakes."""
|
|
37
|
+
return AsyncXKCD(db=db)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def version(flag: bool) -> None:
|
|
41
|
+
"""Print the version and exit when ``--version`` is given."""
|
|
42
|
+
if flag:
|
|
43
|
+
console.print(__version__)
|
|
44
|
+
raise typer.Exit()
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@app.callback()
|
|
48
|
+
def main(
|
|
49
|
+
ctx: typer.Context,
|
|
50
|
+
db: Annotated[str | None, typer.Option(help="Database file (default: user data dir).")] = None,
|
|
51
|
+
_: Annotated[bool, typer.Option("--version", callback=version, is_eager=True)] = False,
|
|
52
|
+
) -> None:
|
|
53
|
+
"""Fetch, browse and search xkcd comics with explanations from explainxkcd."""
|
|
54
|
+
ctx.obj = Settings(db=db)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def run(ctx: typer.Context, action: Callable[[AsyncXKCD], Awaitable[Any]]) -> Any:
|
|
58
|
+
"""Run an async action against a client, turning XKCDError into exit code 1."""
|
|
59
|
+
|
|
60
|
+
async def go() -> Any:
|
|
61
|
+
async with open_client(ctx.obj.db) as xk:
|
|
62
|
+
return await action(xk)
|
|
63
|
+
|
|
64
|
+
try:
|
|
65
|
+
return asyncio.run(go())
|
|
66
|
+
except XKCDError as exc:
|
|
67
|
+
errors.print(f"error: {exc}", markup=False, highlight=False)
|
|
68
|
+
raise typer.Exit(1) from exc
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@dataclass
|
|
72
|
+
class View:
|
|
73
|
+
"""How a comic should be presented."""
|
|
74
|
+
|
|
75
|
+
json: bool = False
|
|
76
|
+
image: bool = True
|
|
77
|
+
explain: bool = False
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
async def present(xk: AsyncXKCD, pending: Awaitable[Comic], view: View) -> None:
|
|
81
|
+
"""Await a comic and print it as JSON or as a card with an optional image."""
|
|
82
|
+
comic = await pending
|
|
83
|
+
if view.json:
|
|
84
|
+
console.print_json(comic.model_dump_json())
|
|
85
|
+
return
|
|
86
|
+
if view.image and can_draw():
|
|
87
|
+
console.print(comic_image(await fetch_image(xk.http, comic)))
|
|
88
|
+
console.print(comic_text(comic, explain=view.explain))
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@app.command()
|
|
92
|
+
def show(
|
|
93
|
+
ctx: typer.Context, num: Num, no_image: NoImage = False, explain: Explain = False, json: Json = False
|
|
94
|
+
) -> None:
|
|
95
|
+
"""Show one comic by number."""
|
|
96
|
+
run(ctx, lambda xk: present(xk, xk.get(num), View(json, not no_image, explain)))
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@app.command()
|
|
100
|
+
def latest(
|
|
101
|
+
ctx: typer.Context, no_image: NoImage = False, explain: Explain = False, json: Json = False
|
|
102
|
+
) -> None:
|
|
103
|
+
"""Show the newest comic."""
|
|
104
|
+
run(ctx, lambda xk: present(xk, xk.latest(), View(json, not no_image, explain)))
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@app.command()
|
|
108
|
+
def random(
|
|
109
|
+
ctx: typer.Context, no_image: NoImage = False, explain: Explain = False, json: Json = False
|
|
110
|
+
) -> None:
|
|
111
|
+
"""Show a random comic."""
|
|
112
|
+
run(ctx, lambda xk: present(xk, xk.random(), View(json, not no_image, explain)))
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
@app.command()
|
|
116
|
+
def search(
|
|
117
|
+
ctx: typer.Context,
|
|
118
|
+
query: Annotated[str, typer.Argument(help="Words from the title, alt text or explanation.")],
|
|
119
|
+
limit: Annotated[int, typer.Option(help="Maximum number of hits.")] = 10,
|
|
120
|
+
json: Json = False,
|
|
121
|
+
) -> None:
|
|
122
|
+
"""Fuzzy-search cached comics. Run sync --full first for the whole archive."""
|
|
123
|
+
hits: list[Comic] = run(ctx, lambda xk: xk.search(query, limit))
|
|
124
|
+
if json:
|
|
125
|
+
console.print_json(data=[comic.model_dump(mode="json") for comic in hits])
|
|
126
|
+
return
|
|
127
|
+
for comic in hits:
|
|
128
|
+
console.print(
|
|
129
|
+
Text.assemble(
|
|
130
|
+
(f"{comic.num:>5}", "bold"), " ", comic.title, " ", (comic.date.isoformat(), "dim")
|
|
131
|
+
)
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
@app.command()
|
|
136
|
+
def explain(ctx: typer.Context, num: Num, json: Json = False) -> None:
|
|
137
|
+
"""Print the explanation for a comic."""
|
|
138
|
+
text: str = run(ctx, lambda xk: xk.explain(num))
|
|
139
|
+
if json:
|
|
140
|
+
console.print_json(data={"num": num, "explanation": text})
|
|
141
|
+
return
|
|
142
|
+
console.print(text or "No explanation on explainxkcd yet.", markup=False)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
@app.command()
|
|
146
|
+
def sync(
|
|
147
|
+
ctx: typer.Context,
|
|
148
|
+
full: Annotated[bool, typer.Option("--full", help="Fetch every comic and explanation.")] = False,
|
|
149
|
+
) -> None:
|
|
150
|
+
"""Refresh the archive index and latest comic; with --full, download everything."""
|
|
151
|
+
with console.status("Syncing") as status:
|
|
152
|
+
added: int = run(
|
|
153
|
+
ctx, lambda xk: xk.sync(full, lambda done, total: status.update(f"Syncing {done}/{total}"))
|
|
154
|
+
)
|
|
155
|
+
console.print(f"{added} new comic{'s' if added != 1 else ''} cached.")
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
@app.command()
|
|
159
|
+
def resync(
|
|
160
|
+
ctx: typer.Context,
|
|
161
|
+
max_age: Annotated[int, typer.Option(help="Refresh comics synced more than this many days ago.")] = 30,
|
|
162
|
+
) -> None:
|
|
163
|
+
"""Re-fetch stale comics and those still missing an explanation."""
|
|
164
|
+
with console.status("Resyncing") as status:
|
|
165
|
+
count: int = run(
|
|
166
|
+
ctx,
|
|
167
|
+
lambda xk: xk.resync(
|
|
168
|
+
timedelta(days=max_age), lambda done, total: status.update(f"Resyncing {done}/{total}")
|
|
169
|
+
),
|
|
170
|
+
)
|
|
171
|
+
console.print(f"{count} comic{'s' if count != 1 else ''} refreshed.")
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
@app.command()
|
|
175
|
+
def tui(ctx: typer.Context) -> None:
|
|
176
|
+
"""Open the interactive browser."""
|
|
177
|
+
from xkcdkit.tui import run_tui
|
|
178
|
+
|
|
179
|
+
run_tui(open_client(ctx.obj.db))
|
xkcdkit/client.py
ADDED
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
"""High-level clients: ``AsyncXKCD`` does the work, ``XKCD`` wraps it for sync code."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import random
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from datetime import timedelta
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from types import TracebackType
|
|
9
|
+
from typing import Any, Self
|
|
10
|
+
|
|
11
|
+
import httpx2
|
|
12
|
+
|
|
13
|
+
from xkcdkit import ComicNotFound, XKCDError
|
|
14
|
+
from xkcdkit.models import ArchiveEntry, Comic
|
|
15
|
+
from xkcdkit.sources import fetch_archive, fetch_comic, fetch_explanation, user_agent
|
|
16
|
+
from xkcdkit.store import Store, utc_now
|
|
17
|
+
|
|
18
|
+
Progress = Callable[[int, int], None]
|
|
19
|
+
RETRY_MISSING_AFTER = timedelta(days=1)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class AsyncXKCD:
|
|
23
|
+
"""Fetch, cache and search xkcd comics with explainxkcd explanations.
|
|
24
|
+
|
|
25
|
+
Example::
|
|
26
|
+
|
|
27
|
+
async with AsyncXKCD() as xk:
|
|
28
|
+
comic = await xk.get(614)
|
|
29
|
+
print(comic.title, comic.alt)
|
|
30
|
+
|
|
31
|
+
Args:
|
|
32
|
+
db: Database path. Defaults to the platform user data directory or ``$XKCDKIT_DB``.
|
|
33
|
+
client: An ``httpx2.AsyncClient`` to use instead of the built-in one.
|
|
34
|
+
timeout: Request timeout in seconds for the built-in client.
|
|
35
|
+
explain_delay: Seconds to wait between explainxkcd requests during bulk syncs.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
def __init__(
|
|
39
|
+
self,
|
|
40
|
+
db: Path | str | None = None,
|
|
41
|
+
client: httpx2.AsyncClient | None = None,
|
|
42
|
+
timeout: float = 20.0,
|
|
43
|
+
explain_delay: float = 1.0,
|
|
44
|
+
) -> None:
|
|
45
|
+
"""Open the store and prepare the HTTP client."""
|
|
46
|
+
self.store = Store(db)
|
|
47
|
+
self.http = client or httpx2.AsyncClient(
|
|
48
|
+
timeout=timeout, headers={"User-Agent": user_agent()}, follow_redirects=True
|
|
49
|
+
)
|
|
50
|
+
self.explain_delay = explain_delay
|
|
51
|
+
|
|
52
|
+
async def __aenter__(self) -> Self:
|
|
53
|
+
"""Enter the async context manager."""
|
|
54
|
+
return self
|
|
55
|
+
|
|
56
|
+
async def __aexit__(
|
|
57
|
+
self, exc_type: type[BaseException] | None, exc: BaseException | None, tb: TracebackType | None
|
|
58
|
+
) -> None:
|
|
59
|
+
"""Close connections on exit."""
|
|
60
|
+
await self.close()
|
|
61
|
+
|
|
62
|
+
async def close(self) -> None:
|
|
63
|
+
"""Close the HTTP client and the database."""
|
|
64
|
+
await self.http.aclose()
|
|
65
|
+
self.store.close()
|
|
66
|
+
|
|
67
|
+
async def complete(self, comic: Comic) -> Comic:
|
|
68
|
+
"""Attach the explainxkcd page when it exists and stamp the sync time."""
|
|
69
|
+
found = await fetch_explanation(self.http, comic.num)
|
|
70
|
+
wikitext, revid = found if found else (None, None)
|
|
71
|
+
return comic.model_copy(
|
|
72
|
+
update={
|
|
73
|
+
"explanation": wikitext,
|
|
74
|
+
"explanation_revid": revid,
|
|
75
|
+
"explanation_missing": wikitext is None,
|
|
76
|
+
"synced_at": utc_now(),
|
|
77
|
+
}
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
def fresh(self, comic: Comic) -> bool:
|
|
81
|
+
"""A cached comic is fresh unless its explanation is absent and that is a day old."""
|
|
82
|
+
if comic.explanation is not None:
|
|
83
|
+
return True
|
|
84
|
+
if not comic.explanation_missing or comic.synced_at is None:
|
|
85
|
+
return False
|
|
86
|
+
return utc_now() - comic.synced_at < RETRY_MISSING_AFTER
|
|
87
|
+
|
|
88
|
+
async def get(self, num: int) -> Comic:
|
|
89
|
+
"""Return comic ``num`` from the cache, fetching and caching it when needed.
|
|
90
|
+
|
|
91
|
+
Raises:
|
|
92
|
+
ComicNotFound: when xkcd has no such comic.
|
|
93
|
+
XKCDError: on network or server failures.
|
|
94
|
+
"""
|
|
95
|
+
cached = self.store.get(num)
|
|
96
|
+
if cached and self.fresh(cached):
|
|
97
|
+
return cached
|
|
98
|
+
comic = await self.complete(await fetch_comic(self.http, num))
|
|
99
|
+
self.store.replace(comic)
|
|
100
|
+
return comic
|
|
101
|
+
|
|
102
|
+
async def latest(self) -> Comic:
|
|
103
|
+
"""Return the newest comic, always checking xkcd.com for its number."""
|
|
104
|
+
comic = await fetch_comic(self.http, None)
|
|
105
|
+
self.store.set_meta("latest_num", str(comic.num))
|
|
106
|
+
if not self.store.has(comic.num):
|
|
107
|
+
self.store.put(await self.complete(comic))
|
|
108
|
+
return await self.get(comic.num)
|
|
109
|
+
|
|
110
|
+
async def random(self) -> Comic:
|
|
111
|
+
"""Return a random comic, using the archive index for the number range."""
|
|
112
|
+
entries = self.store.archive() or await self.refresh_archive()
|
|
113
|
+
return await self.get(random.choice([entry.num for entry in entries]))
|
|
114
|
+
|
|
115
|
+
async def search(self, query: str, limit: int = 10) -> list[Comic]:
|
|
116
|
+
"""Fuzzy-search cached comics by title, alt text, transcript and explanation."""
|
|
117
|
+
return [comic for comic, _ in self.store.search(query, limit)]
|
|
118
|
+
|
|
119
|
+
async def explain(self, num: int) -> str:
|
|
120
|
+
"""The plain-text explanation for comic ``num``; empty when explainxkcd has none."""
|
|
121
|
+
return (await self.get(num)).explanation_text
|
|
122
|
+
|
|
123
|
+
async def refresh_archive(self) -> list[ArchiveEntry]:
|
|
124
|
+
"""Fetch the archive page and store it as the index."""
|
|
125
|
+
entries = await fetch_archive(self.http)
|
|
126
|
+
self.store.put_archive(entries)
|
|
127
|
+
return entries
|
|
128
|
+
|
|
129
|
+
async def sync(self, full: bool = False, progress: Progress | None = None) -> int:
|
|
130
|
+
"""Refresh the archive index and the latest comic; with ``full`` fetch every comic.
|
|
131
|
+
|
|
132
|
+
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.
|
|
134
|
+
"""
|
|
135
|
+
before = self.store.count()
|
|
136
|
+
await self.refresh_archive()
|
|
137
|
+
latest = await self.latest()
|
|
138
|
+
if full:
|
|
139
|
+
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)
|
|
141
|
+
self.store.set_meta("last_full_sync_at", utc_now().isoformat())
|
|
142
|
+
self.store.set_meta("last_sync_at", utc_now().isoformat())
|
|
143
|
+
return self.store.count() - before
|
|
144
|
+
|
|
145
|
+
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."""
|
|
147
|
+
return await self.fetch_many(self.store.stale(max_age), progress)
|
|
148
|
+
|
|
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
|
|
158
|
+
if progress:
|
|
159
|
+
progress(index, len(nums))
|
|
160
|
+
await asyncio.sleep(self.explain_delay)
|
|
161
|
+
return done
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
class XKCD:
|
|
165
|
+
"""Blocking twin of ``AsyncXKCD`` for scripts and REPL use.
|
|
166
|
+
|
|
167
|
+
Each call runs on its own short-lived event loop, so it cannot be used
|
|
168
|
+
from code that is already inside one. Use ``AsyncXKCD`` there.
|
|
169
|
+
"""
|
|
170
|
+
|
|
171
|
+
def __init__(
|
|
172
|
+
self,
|
|
173
|
+
db: Path | str | None = None,
|
|
174
|
+
client_factory: Callable[[], httpx2.AsyncClient] | None = None,
|
|
175
|
+
timeout: float = 20.0,
|
|
176
|
+
explain_delay: float = 1.0,
|
|
177
|
+
) -> None:
|
|
178
|
+
"""Remember the settings; connections are opened per call."""
|
|
179
|
+
self.db = db
|
|
180
|
+
self.client_factory = client_factory
|
|
181
|
+
self.timeout = timeout
|
|
182
|
+
self.explain_delay = explain_delay
|
|
183
|
+
|
|
184
|
+
def __enter__(self) -> Self:
|
|
185
|
+
"""Enter the context manager."""
|
|
186
|
+
return self
|
|
187
|
+
|
|
188
|
+
def __exit__(
|
|
189
|
+
self, exc_type: type[BaseException] | None, exc: BaseException | None, tb: TracebackType | None
|
|
190
|
+
) -> None:
|
|
191
|
+
"""Nothing to close: each call closes its own resources."""
|
|
192
|
+
|
|
193
|
+
def run(self, method: str, *args: Any, **kwargs: Any) -> Any:
|
|
194
|
+
"""Run one ``AsyncXKCD`` method to completion on a fresh event loop."""
|
|
195
|
+
|
|
196
|
+
async def call() -> Any:
|
|
197
|
+
client = self.client_factory() if self.client_factory else None
|
|
198
|
+
async with AsyncXKCD(self.db, client, self.timeout, self.explain_delay) as xk:
|
|
199
|
+
return await getattr(xk, method)(*args, **kwargs)
|
|
200
|
+
|
|
201
|
+
try:
|
|
202
|
+
asyncio.get_running_loop()
|
|
203
|
+
except RuntimeError:
|
|
204
|
+
return asyncio.run(call())
|
|
205
|
+
raise XKCDError("XKCD cannot run inside an event loop; use AsyncXKCD")
|
|
206
|
+
|
|
207
|
+
def get(self, num: int) -> Comic:
|
|
208
|
+
"""See ``AsyncXKCD.get``."""
|
|
209
|
+
return self.run("get", num) # type: ignore[no-any-return]
|
|
210
|
+
|
|
211
|
+
def latest(self) -> Comic:
|
|
212
|
+
"""See ``AsyncXKCD.latest``."""
|
|
213
|
+
return self.run("latest") # type: ignore[no-any-return]
|
|
214
|
+
|
|
215
|
+
def random(self) -> Comic:
|
|
216
|
+
"""See ``AsyncXKCD.random``."""
|
|
217
|
+
return self.run("random") # type: ignore[no-any-return]
|
|
218
|
+
|
|
219
|
+
def search(self, query: str, limit: int = 10) -> list[Comic]:
|
|
220
|
+
"""See ``AsyncXKCD.search``."""
|
|
221
|
+
return self.run("search", query, limit) # type: ignore[no-any-return]
|
|
222
|
+
|
|
223
|
+
def explain(self, num: int) -> str:
|
|
224
|
+
"""See ``AsyncXKCD.explain``."""
|
|
225
|
+
return self.run("explain", num) # type: ignore[no-any-return]
|
|
226
|
+
|
|
227
|
+
def sync(self, full: bool = False, progress: Progress | None = None) -> int:
|
|
228
|
+
"""See ``AsyncXKCD.sync``."""
|
|
229
|
+
return self.run("sync", full, progress) # type: ignore[no-any-return]
|
|
230
|
+
|
|
231
|
+
def resync(self, max_age: timedelta = timedelta(days=30), progress: Progress | None = None) -> int:
|
|
232
|
+
"""See ``AsyncXKCD.resync``."""
|
|
233
|
+
return self.run("resync", max_age, progress) # type: ignore[no-any-return]
|
xkcdkit/models.py
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""Data models for comics and archive entries, plus a wikitext cleaner."""
|
|
2
|
+
|
|
3
|
+
import datetime as dt
|
|
4
|
+
import re
|
|
5
|
+
|
|
6
|
+
from pydantic import BaseModel, field_validator
|
|
7
|
+
|
|
8
|
+
TEMPLATE = re.compile(r"\{\{([^{}]*)\}\}")
|
|
9
|
+
LINK = re.compile(r"\[\[([^\]|]*)(?:\|([^\]]*))?\]\]")
|
|
10
|
+
EXTERNAL_LINK = re.compile(r"\[\w+://[^\s\]]+(?:\s+([^\]]*))?\]")
|
|
11
|
+
TAG = re.compile(r"<ref[^>]*/>|<ref[^>]*>.*?</ref>|<[^>]+>", re.DOTALL)
|
|
12
|
+
SECTION = re.compile(r"^==([^=].*?)==\s*$", re.MULTILINE)
|
|
13
|
+
SUBHEADING = re.compile(r"^=+\s*(.*?)\s*=+\s*$", re.MULTILINE)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Comic(BaseModel):
|
|
17
|
+
"""One xkcd comic, as served by xkcd.com, with explainxkcd data attached.
|
|
18
|
+
|
|
19
|
+
Field names match the xkcd JSON so the payload can be validated directly.
|
|
20
|
+
The explanation fields are filled from explainxkcd when available.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
num: int
|
|
24
|
+
title: str
|
|
25
|
+
safe_title: str = ""
|
|
26
|
+
alt: str = ""
|
|
27
|
+
img: str = ""
|
|
28
|
+
transcript: str = ""
|
|
29
|
+
link: str = ""
|
|
30
|
+
news: str = ""
|
|
31
|
+
year: int
|
|
32
|
+
month: int
|
|
33
|
+
day: int
|
|
34
|
+
explanation: str | None = None
|
|
35
|
+
explanation_revid: int | None = None
|
|
36
|
+
explanation_missing: bool = False
|
|
37
|
+
synced_at: dt.datetime | None = None
|
|
38
|
+
|
|
39
|
+
@property
|
|
40
|
+
def date(self) -> dt.date:
|
|
41
|
+
"""Publication date assembled from year, month and day."""
|
|
42
|
+
return dt.date(self.year, self.month, self.day)
|
|
43
|
+
|
|
44
|
+
@property
|
|
45
|
+
def url(self) -> str:
|
|
46
|
+
"""The comic's page on xkcd.com."""
|
|
47
|
+
return f"https://xkcd.com/{self.num}/"
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def explanation_text(self) -> str:
|
|
51
|
+
"""The explainxkcd Explanation section as plain text, or empty."""
|
|
52
|
+
return wikitext_to_text(self.explanation)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class ArchiveEntry(BaseModel):
|
|
56
|
+
"""One line of the xkcd archive page: number, date and title."""
|
|
57
|
+
|
|
58
|
+
num: int
|
|
59
|
+
date: dt.date
|
|
60
|
+
title: str
|
|
61
|
+
|
|
62
|
+
@field_validator("date", mode="before")
|
|
63
|
+
@classmethod
|
|
64
|
+
def parse_loose_date(cls, value: object) -> object:
|
|
65
|
+
"""Accept the archive's unpadded dates such as ``2009-7-24``."""
|
|
66
|
+
if isinstance(value, str):
|
|
67
|
+
year, month, day = (int(part) for part in value.split("-"))
|
|
68
|
+
return dt.date(year, month, day)
|
|
69
|
+
return value
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def explanation_section(wikitext: str) -> str:
|
|
73
|
+
"""Return the body of the Explanation section, or the whole text if unsectioned."""
|
|
74
|
+
headings = list(SECTION.finditer(wikitext))
|
|
75
|
+
if not headings:
|
|
76
|
+
return wikitext
|
|
77
|
+
for index, heading in enumerate(headings):
|
|
78
|
+
if heading.group(1).strip().lower() == "explanation":
|
|
79
|
+
end = headings[index + 1].start() if index + 1 < len(headings) else len(wikitext)
|
|
80
|
+
return wikitext[heading.end() : end]
|
|
81
|
+
return ""
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def expand_template(match: re.Match[str]) -> str:
|
|
85
|
+
"""Replace a template: ``{{w|page|label}}`` becomes its label, others vanish."""
|
|
86
|
+
parts = match.group(1).split("|")
|
|
87
|
+
if parts[0].strip().lower() == "w" and len(parts) > 1:
|
|
88
|
+
return parts[-1].strip()
|
|
89
|
+
return ""
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def wikitext_to_text(wikitext: str | None) -> str:
|
|
93
|
+
"""Reduce explainxkcd wikitext to readable plain text.
|
|
94
|
+
|
|
95
|
+
Keeps only the Explanation section, drops templates and tags, turns links
|
|
96
|
+
into their visible text and collapses whitespace into paragraphs.
|
|
97
|
+
"""
|
|
98
|
+
if not wikitext:
|
|
99
|
+
return ""
|
|
100
|
+
text = SUBHEADING.sub(r"\1", explanation_section(wikitext))
|
|
101
|
+
while TEMPLATE.search(text):
|
|
102
|
+
text = TEMPLATE.sub(expand_template, text)
|
|
103
|
+
text = TAG.sub("", text)
|
|
104
|
+
text = LINK.sub(lambda m: m.group(2) or m.group(1), text)
|
|
105
|
+
text = EXTERNAL_LINK.sub(lambda m: m.group(1) or "", text)
|
|
106
|
+
text = text.replace("'''", "").replace("''", "")
|
|
107
|
+
lines = [re.sub(r"[ \t]+", " ", line).strip() for line in text.splitlines()]
|
|
108
|
+
return "\n\n".join(line for line in lines if line)
|
xkcdkit/py.typed
ADDED
|
File without changes
|
xkcdkit/render.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Terminal rendering: a text card for a comic and an image when the terminal can draw one."""
|
|
2
|
+
|
|
3
|
+
import io
|
|
4
|
+
import sys
|
|
5
|
+
|
|
6
|
+
from rich.console import Group, RenderableType
|
|
7
|
+
from rich.panel import Panel
|
|
8
|
+
from rich.text import Text
|
|
9
|
+
|
|
10
|
+
from xkcdkit.models import Comic
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def comic_text(comic: Comic, explain: bool = False) -> RenderableType:
|
|
14
|
+
"""A panel with number, title, date, alt text and optionally the explanation."""
|
|
15
|
+
header = Text.assemble(("#", "dim"), (str(comic.num), "bold"), " ", (comic.title, "bold cyan"))
|
|
16
|
+
body: list[RenderableType] = [Text(comic.alt, style="italic"), Text(comic.url, style="dim")]
|
|
17
|
+
if explain:
|
|
18
|
+
body += [Text(""), Text("Explanation", style="bold underline"), explanation_text(comic)]
|
|
19
|
+
return Panel(Group(*body), title=header, subtitle=comic.date.isoformat(), title_align="left")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def explanation_text(comic: Comic) -> Text:
|
|
23
|
+
"""The explanation body, or a note when explainxkcd has no page."""
|
|
24
|
+
if comic.explanation_missing or not comic.explanation_text:
|
|
25
|
+
return Text("No explanation on explainxkcd yet.", style="dim")
|
|
26
|
+
return Text(comic.explanation_text)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def comic_image(data: bytes) -> RenderableType:
|
|
30
|
+
"""A Rich renderable for the image bytes using the best protocol the terminal supports.
|
|
31
|
+
|
|
32
|
+
textual-image probes the terminal on import, so it is imported here rather than
|
|
33
|
+
at module load.
|
|
34
|
+
"""
|
|
35
|
+
from textual_image.renderable import Image
|
|
36
|
+
|
|
37
|
+
renderable: RenderableType = Image(io.BytesIO(data))
|
|
38
|
+
return renderable
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def can_draw() -> bool:
|
|
42
|
+
"""Whether drawing an image makes sense: only when stdout is a terminal."""
|
|
43
|
+
return sys.stdout.isatty()
|