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