xkcdkit 0.1.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.1.0/PKG-INFO +293 -0
- xkcdkit-0.1.0/README.md +261 -0
- xkcdkit-0.1.0/pyproject.toml +158 -0
- xkcdkit-0.1.0/pyproject.toml.orig +113 -0
- xkcdkit-0.1.0/src/xkcdkit/__init__.py +56 -0
- xkcdkit-0.1.0/src/xkcdkit/cli.py +179 -0
- xkcdkit-0.1.0/src/xkcdkit/client.py +233 -0
- xkcdkit-0.1.0/src/xkcdkit/models.py +108 -0
- xkcdkit-0.1.0/src/xkcdkit/py.typed +0 -0
- xkcdkit-0.1.0/src/xkcdkit/render.py +43 -0
- xkcdkit-0.1.0/src/xkcdkit/sources.py +89 -0
- xkcdkit-0.1.0/src/xkcdkit/store.py +156 -0
- xkcdkit-0.1.0/src/xkcdkit/tui.py +188 -0
xkcdkit-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: xkcdkit
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: xkcd comics as a Python library, a CLI and a TUI, with a local cache and explainxkcd explanations.
|
|
5
|
+
Keywords: xkcd,comics,cli,tui,textual
|
|
6
|
+
Author: Eivind Teig
|
|
7
|
+
Author-email: Eivind Teig <eivind@dchub.no>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
Classifier: Development Status :: 3 - Alpha
|
|
10
|
+
Classifier: Environment :: Console
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Operating System :: OS Independent
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.15
|
|
18
|
+
Classifier: Topic :: Terminals
|
|
19
|
+
Classifier: Typing :: Typed
|
|
20
|
+
Requires-Dist: httpx2>=2.16
|
|
21
|
+
Requires-Dist: pydantic>=2.11
|
|
22
|
+
Requires-Dist: typer>=0.17
|
|
23
|
+
Requires-Dist: textual>=6.0
|
|
24
|
+
Requires-Dist: textual-image>=0.14
|
|
25
|
+
Requires-Dist: rapidfuzz>=3.13
|
|
26
|
+
Requires-Dist: platformdirs>=4.3
|
|
27
|
+
Requires-Python: >=3.12
|
|
28
|
+
Project-URL: Homepage, https://github.com/eivl/xkcdkit
|
|
29
|
+
Project-URL: Repository, https://github.com/eivl/xkcdkit
|
|
30
|
+
Project-URL: Changelog, https://github.com/eivl/xkcdkit/blob/main/CHANGELOG.md
|
|
31
|
+
Description-Content-Type: text/markdown
|
|
32
|
+
|
|
33
|
+
# xkcdkit
|
|
34
|
+
|
|
35
|
+
xkcd comics as a Python library, a command line tool and a terminal browser.
|
|
36
|
+
Everything you look at is cached in a local SQLite database, together with the
|
|
37
|
+
explanation from [explainxkcd](https://www.explainxkcd.com/), so repeat lookups
|
|
38
|
+
and searches never touch the network.
|
|
39
|
+
|
|
40
|
+
```
|
|
41
|
+
$ xkcd show 614
|
|
42
|
+
╭─ #614 Woodpecker ───────────────────────────────────────────────╮
|
|
43
|
+
│ If you don't have an extension cord I can get that too. Because │
|
|
44
|
+
│ we're friends! Right? │
|
|
45
|
+
│ https://xkcd.com/614/ │
|
|
46
|
+
╰──────────────────────────────────────────────────── 2009-07-24 ──╯
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
On a terminal that supports the kitty graphics protocol or sixel, the comic
|
|
50
|
+
image is drawn above the card.
|
|
51
|
+
|
|
52
|
+
## Install
|
|
53
|
+
|
|
54
|
+
### As a library
|
|
55
|
+
|
|
56
|
+
```sh
|
|
57
|
+
pip install xkcdkit
|
|
58
|
+
# or
|
|
59
|
+
uv add xkcdkit
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
### As a command line tool and TUI
|
|
63
|
+
|
|
64
|
+
Install it in its own isolated environment so it never conflicts with your
|
|
65
|
+
projects:
|
|
66
|
+
|
|
67
|
+
```sh
|
|
68
|
+
pipx install xkcdkit
|
|
69
|
+
# or
|
|
70
|
+
uv tool install xkcdkit
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Either way you get the `xkcd` command.
|
|
74
|
+
|
|
75
|
+
### Linux and macOS
|
|
76
|
+
|
|
77
|
+
The commands above work as written. If you prefer not to install anything
|
|
78
|
+
permanently:
|
|
79
|
+
|
|
80
|
+
```sh
|
|
81
|
+
uvx xkcdkit show 614
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
### Windows
|
|
85
|
+
|
|
86
|
+
On Windows the Python launcher is called `py`, and `python` is often not on
|
|
87
|
+
your PATH (or opens the Microsoft Store). Use the launcher for everything that
|
|
88
|
+
starts with `python`:
|
|
89
|
+
|
|
90
|
+
```powershell
|
|
91
|
+
py -m pip install --user pipx
|
|
92
|
+
py -m pipx ensurepath
|
|
93
|
+
pipx install xkcdkit
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
or, with uv installed:
|
|
97
|
+
|
|
98
|
+
```powershell
|
|
99
|
+
uv tool install xkcdkit
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
Images need Windows Terminal 1.22 or newer, which supports sixel. The old
|
|
103
|
+
console host (`conhost.exe`) cannot draw images, and xkcdkit falls back to a
|
|
104
|
+
text-only card there.
|
|
105
|
+
|
|
106
|
+
## Command line
|
|
107
|
+
|
|
108
|
+
```
|
|
109
|
+
xkcd show 614 # one comic by number
|
|
110
|
+
xkcd show 614 --explain # with the explainxkcd explanation
|
|
111
|
+
xkcd latest # the newest comic
|
|
112
|
+
xkcd random # a random comic
|
|
113
|
+
xkcd search "woodpecker" # fuzzy search over cached titles, alt text, transcripts and explanations
|
|
114
|
+
xkcd explain 614 # just the explanation, as plain text
|
|
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)
|
|
117
|
+
xkcd resync # re-fetch comics older than 30 days or still missing an explanation
|
|
118
|
+
xkcd tui # open the browser
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
Add `--json` to `show`, `latest`, `random`, `search` or `explain` for
|
|
122
|
+
machine-readable output. Nothing but the JSON goes to stdout; errors go to
|
|
123
|
+
stderr with exit code 1, so pipelines stay clean:
|
|
124
|
+
|
|
125
|
+
```sh
|
|
126
|
+
xkcd show 614 --json | jq .alt
|
|
127
|
+
xkcd search "sandwich" --json | jq '.[].num'
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
`--no-image` skips the picture. Images are also skipped automatically when
|
|
131
|
+
stdout is not a terminal, so pipes always get clean text.
|
|
132
|
+
|
|
133
|
+
### The browser
|
|
134
|
+
|
|
135
|
+
`xkcd tui` opens a two-pane browser in the spirit of lazygit: the archive on
|
|
136
|
+
the left, the comic on the right with tabs for alt text, transcript and
|
|
137
|
+
explanation.
|
|
138
|
+
|
|
139
|
+
| Key | Action |
|
|
140
|
+
| ------- | -------------------------------- |
|
|
141
|
+
| `j`/`k` | move through the archive |
|
|
142
|
+
| `/` | filter the archive by title |
|
|
143
|
+
| `Enter` | open the selected comic |
|
|
144
|
+
| `r` | random comic |
|
|
145
|
+
| `l` | latest comic |
|
|
146
|
+
| `i` | show or hide the image pane |
|
|
147
|
+
| `s` | sync the archive index |
|
|
148
|
+
| `?` | key help |
|
|
149
|
+
| `q` | quit |
|
|
150
|
+
|
|
151
|
+
## Library
|
|
152
|
+
|
|
153
|
+
The async client is the primary API. It is built for I/O bound programs such
|
|
154
|
+
as bots and web services.
|
|
155
|
+
|
|
156
|
+
```python
|
|
157
|
+
from xkcdkit import AsyncXKCD
|
|
158
|
+
|
|
159
|
+
async with AsyncXKCD() as xk:
|
|
160
|
+
comic = await xk.get(614)
|
|
161
|
+
print(comic.title, comic.alt, comic.img)
|
|
162
|
+
print(comic.explanation_text[:200])
|
|
163
|
+
|
|
164
|
+
latest = await xk.latest()
|
|
165
|
+
hits = await xk.search("sandwich", limit=5)
|
|
166
|
+
await xk.sync() # archive index + latest comic
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
A blocking twin exists for scripts and the REPL. It runs each call on its own
|
|
170
|
+
event loop, so it cannot be used from inside async code, and it opens and
|
|
171
|
+
closes its connections per call (pass `client_factory=` rather than a client
|
|
172
|
+
if you need to customise HTTP).
|
|
173
|
+
|
|
174
|
+
```python
|
|
175
|
+
from xkcdkit import XKCD
|
|
176
|
+
|
|
177
|
+
with XKCD() as xk:
|
|
178
|
+
print(xk.random().title)
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
In a long-running program such as a Discord bot, keep one `AsyncXKCD` open
|
|
182
|
+
for the lifetime of the process: it holds the HTTP connection pool and the
|
|
183
|
+
database connection.
|
|
184
|
+
|
|
185
|
+
```python
|
|
186
|
+
from xkcdkit import AsyncXKCD, ComicNotFound
|
|
187
|
+
|
|
188
|
+
xk = AsyncXKCD() # created once; call `await xk.close()` on shutdown
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
@bot.command()
|
|
192
|
+
async def xkcd(ctx, num: int):
|
|
193
|
+
try:
|
|
194
|
+
comic = await xk.get(num)
|
|
195
|
+
except ComicNotFound:
|
|
196
|
+
await ctx.send(f"There is no xkcd {num}.")
|
|
197
|
+
return
|
|
198
|
+
await ctx.send(f"**{comic.title}**\n{comic.img}\n*{comic.alt}*")
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
### The `Comic` model
|
|
202
|
+
|
|
203
|
+
`Comic` is a pydantic model with every field from the xkcd JSON (`num`,
|
|
204
|
+
`title`, `safe_title`, `alt`, `img`, `transcript`, `link`, `news`, `year`,
|
|
205
|
+
`month`, `day`) plus:
|
|
206
|
+
|
|
207
|
+
| Field | Meaning |
|
|
208
|
+
| --------------------- | ----------------------------------------------------------- |
|
|
209
|
+
| `explanation` | full explainxkcd page wikitext, or `None` |
|
|
210
|
+
| `explanation_revid` | the wiki revision that was fetched |
|
|
211
|
+
| `explanation_missing` | `True` when explainxkcd had no page at sync time |
|
|
212
|
+
| `synced_at` | when the row was written (UTC) |
|
|
213
|
+
| `explanation_text` | property: the Explanation section as plain text |
|
|
214
|
+
| `date`, `url` | properties: publication date and the comic's page |
|
|
215
|
+
|
|
216
|
+
`comic.model_dump_json()` gives you the same JSON the CLI prints.
|
|
217
|
+
|
|
218
|
+
## Data, caching and freshness
|
|
219
|
+
|
|
220
|
+
- The database lives in your platform's user data directory
|
|
221
|
+
(`~/.local/share/xkcdkit/xkcd.sqlite3` on Linux, `~/Library/Application
|
|
222
|
+
Support/xkcdkit/` on macOS, `%LOCALAPPDATA%\xkcdkit\` on Windows). Set
|
|
223
|
+
`XKCDKIT_DB` or pass `--db` / `db=` to put it elsewhere.
|
|
224
|
+
- Comics are immutable once stored. A cached comic is served as is, except
|
|
225
|
+
when its explanation was missing more than a day ago; then explainxkcd is
|
|
226
|
+
asked again on the next access.
|
|
227
|
+
- `resync` re-fetches rows older than `--max-age` days (default 30) and every
|
|
228
|
+
row still flagged `explanation_missing`. Recently synced data is trusted.
|
|
229
|
+
- Roughly 1 to 2 percent of comics have no explainxkcd page yet. Those rows
|
|
230
|
+
carry `explanation_missing = True` so your code can decide what to do.
|
|
231
|
+
|
|
232
|
+
### Being polite to explainxkcd
|
|
233
|
+
|
|
234
|
+
explainxkcd is a volunteer-run wiki. Its robots policy asks for one request
|
|
235
|
+
per second, so a full sync fetches one explanation per second and sends a
|
|
236
|
+
User-Agent that names this project and its repository. A complete first
|
|
237
|
+
`sync --full` therefore takes about an hour. On-demand lookups are not
|
|
238
|
+
delayed.
|
|
239
|
+
|
|
240
|
+
## Terminal image support
|
|
241
|
+
|
|
242
|
+
| Terminal | Protocol | Status |
|
|
243
|
+
| ---------------------------- | -------- | ------------------------- |
|
|
244
|
+
| kitty, Ghostty | kitty | works |
|
|
245
|
+
| WezTerm, foot, iTerm2 | sixel | works |
|
|
246
|
+
| Windows Terminal 1.22+ | sixel | works |
|
|
247
|
+
| VS Code terminal | sixel | enable `terminal.integrated.enableImages` |
|
|
248
|
+
| tmux | – | falls back to half-block unicode |
|
|
249
|
+
| GNOME Terminal, conhost | – | falls back to half-block unicode |
|
|
250
|
+
|
|
251
|
+
Detection and rendering are done by [textual-image](https://github.com/lnqs/textual-image).
|
|
252
|
+
|
|
253
|
+
## Development
|
|
254
|
+
|
|
255
|
+
```sh
|
|
256
|
+
git clone https://github.com/eivl/xkcdkit
|
|
257
|
+
cd xkcdkit
|
|
258
|
+
uv sync --all-groups
|
|
259
|
+
uv run pre-commit install
|
|
260
|
+
uv run pytest
|
|
261
|
+
uv run tox # all supported Python versions
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
The code base is deliberately small: seven modules, short functions, a
|
|
265
|
+
docstring on everything. ruff (with pydocstyle), mypy strict, flake8 and
|
|
266
|
+
zizmor run in pre-commit and in CI.
|
|
267
|
+
|
|
268
|
+
### Releasing
|
|
269
|
+
|
|
270
|
+
Commits follow [Conventional Commits](https://www.conventionalcommits.org/).
|
|
271
|
+
On every push to `main`, python-semantic-release reads the commit messages,
|
|
272
|
+
bumps the version in `pyproject.toml`, updates `CHANGELOG.md`, tags the
|
|
273
|
+
release and publishes to PyPI through trusted publishing. `feat:` bumps the
|
|
274
|
+
minor version, `fix:` the patch version, and a `BREAKING CHANGE:` footer the
|
|
275
|
+
major version. The release commit also re-locks `uv.lock` so that
|
|
276
|
+
`uv sync --locked` keeps working after the bump.
|
|
277
|
+
|
|
278
|
+
Trusted publishing has to be registered once on PyPI under
|
|
279
|
+
*Publishing → Add a new pending publisher* with these values:
|
|
280
|
+
|
|
281
|
+
| Field | Value |
|
|
282
|
+
| ---------------- | -------------- |
|
|
283
|
+
| PyPI project | `xkcdkit` |
|
|
284
|
+
| Owner | `eivl` |
|
|
285
|
+
| Repository | `xkcdkit` |
|
|
286
|
+
| Workflow | `release.yml` |
|
|
287
|
+
| Environment | `pypi` |
|
|
288
|
+
|
|
289
|
+
and a GitHub environment named `pypi` must exist in the repository settings.
|
|
290
|
+
|
|
291
|
+
## License
|
|
292
|
+
|
|
293
|
+
MIT
|
xkcdkit-0.1.0/README.md
ADDED
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
# xkcdkit
|
|
2
|
+
|
|
3
|
+
xkcd comics as a Python library, a command line tool and a terminal browser.
|
|
4
|
+
Everything you look at is cached in a local SQLite database, together with the
|
|
5
|
+
explanation from [explainxkcd](https://www.explainxkcd.com/), so repeat lookups
|
|
6
|
+
and searches never touch the network.
|
|
7
|
+
|
|
8
|
+
```
|
|
9
|
+
$ xkcd show 614
|
|
10
|
+
╭─ #614 Woodpecker ───────────────────────────────────────────────╮
|
|
11
|
+
│ If you don't have an extension cord I can get that too. Because │
|
|
12
|
+
│ we're friends! Right? │
|
|
13
|
+
│ https://xkcd.com/614/ │
|
|
14
|
+
╰──────────────────────────────────────────────────── 2009-07-24 ──╯
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
On a terminal that supports the kitty graphics protocol or sixel, the comic
|
|
18
|
+
image is drawn above the card.
|
|
19
|
+
|
|
20
|
+
## Install
|
|
21
|
+
|
|
22
|
+
### As a library
|
|
23
|
+
|
|
24
|
+
```sh
|
|
25
|
+
pip install xkcdkit
|
|
26
|
+
# or
|
|
27
|
+
uv add xkcdkit
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
### As a command line tool and TUI
|
|
31
|
+
|
|
32
|
+
Install it in its own isolated environment so it never conflicts with your
|
|
33
|
+
projects:
|
|
34
|
+
|
|
35
|
+
```sh
|
|
36
|
+
pipx install xkcdkit
|
|
37
|
+
# or
|
|
38
|
+
uv tool install xkcdkit
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Either way you get the `xkcd` command.
|
|
42
|
+
|
|
43
|
+
### Linux and macOS
|
|
44
|
+
|
|
45
|
+
The commands above work as written. If you prefer not to install anything
|
|
46
|
+
permanently:
|
|
47
|
+
|
|
48
|
+
```sh
|
|
49
|
+
uvx xkcdkit show 614
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
### Windows
|
|
53
|
+
|
|
54
|
+
On Windows the Python launcher is called `py`, and `python` is often not on
|
|
55
|
+
your PATH (or opens the Microsoft Store). Use the launcher for everything that
|
|
56
|
+
starts with `python`:
|
|
57
|
+
|
|
58
|
+
```powershell
|
|
59
|
+
py -m pip install --user pipx
|
|
60
|
+
py -m pipx ensurepath
|
|
61
|
+
pipx install xkcdkit
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
or, with uv installed:
|
|
65
|
+
|
|
66
|
+
```powershell
|
|
67
|
+
uv tool install xkcdkit
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Images need Windows Terminal 1.22 or newer, which supports sixel. The old
|
|
71
|
+
console host (`conhost.exe`) cannot draw images, and xkcdkit falls back to a
|
|
72
|
+
text-only card there.
|
|
73
|
+
|
|
74
|
+
## Command line
|
|
75
|
+
|
|
76
|
+
```
|
|
77
|
+
xkcd show 614 # one comic by number
|
|
78
|
+
xkcd show 614 --explain # with the explainxkcd explanation
|
|
79
|
+
xkcd latest # the newest comic
|
|
80
|
+
xkcd random # a random comic
|
|
81
|
+
xkcd search "woodpecker" # fuzzy search over cached titles, alt text, transcripts and explanations
|
|
82
|
+
xkcd explain 614 # just the explanation, as plain text
|
|
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)
|
|
85
|
+
xkcd resync # re-fetch comics older than 30 days or still missing an explanation
|
|
86
|
+
xkcd tui # open the browser
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Add `--json` to `show`, `latest`, `random`, `search` or `explain` for
|
|
90
|
+
machine-readable output. Nothing but the JSON goes to stdout; errors go to
|
|
91
|
+
stderr with exit code 1, so pipelines stay clean:
|
|
92
|
+
|
|
93
|
+
```sh
|
|
94
|
+
xkcd show 614 --json | jq .alt
|
|
95
|
+
xkcd search "sandwich" --json | jq '.[].num'
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
`--no-image` skips the picture. Images are also skipped automatically when
|
|
99
|
+
stdout is not a terminal, so pipes always get clean text.
|
|
100
|
+
|
|
101
|
+
### The browser
|
|
102
|
+
|
|
103
|
+
`xkcd tui` opens a two-pane browser in the spirit of lazygit: the archive on
|
|
104
|
+
the left, the comic on the right with tabs for alt text, transcript and
|
|
105
|
+
explanation.
|
|
106
|
+
|
|
107
|
+
| Key | Action |
|
|
108
|
+
| ------- | -------------------------------- |
|
|
109
|
+
| `j`/`k` | move through the archive |
|
|
110
|
+
| `/` | filter the archive by title |
|
|
111
|
+
| `Enter` | open the selected comic |
|
|
112
|
+
| `r` | random comic |
|
|
113
|
+
| `l` | latest comic |
|
|
114
|
+
| `i` | show or hide the image pane |
|
|
115
|
+
| `s` | sync the archive index |
|
|
116
|
+
| `?` | key help |
|
|
117
|
+
| `q` | quit |
|
|
118
|
+
|
|
119
|
+
## Library
|
|
120
|
+
|
|
121
|
+
The async client is the primary API. It is built for I/O bound programs such
|
|
122
|
+
as bots and web services.
|
|
123
|
+
|
|
124
|
+
```python
|
|
125
|
+
from xkcdkit import AsyncXKCD
|
|
126
|
+
|
|
127
|
+
async with AsyncXKCD() as xk:
|
|
128
|
+
comic = await xk.get(614)
|
|
129
|
+
print(comic.title, comic.alt, comic.img)
|
|
130
|
+
print(comic.explanation_text[:200])
|
|
131
|
+
|
|
132
|
+
latest = await xk.latest()
|
|
133
|
+
hits = await xk.search("sandwich", limit=5)
|
|
134
|
+
await xk.sync() # archive index + latest comic
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
A blocking twin exists for scripts and the REPL. It runs each call on its own
|
|
138
|
+
event loop, so it cannot be used from inside async code, and it opens and
|
|
139
|
+
closes its connections per call (pass `client_factory=` rather than a client
|
|
140
|
+
if you need to customise HTTP).
|
|
141
|
+
|
|
142
|
+
```python
|
|
143
|
+
from xkcdkit import XKCD
|
|
144
|
+
|
|
145
|
+
with XKCD() as xk:
|
|
146
|
+
print(xk.random().title)
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
In a long-running program such as a Discord bot, keep one `AsyncXKCD` open
|
|
150
|
+
for the lifetime of the process: it holds the HTTP connection pool and the
|
|
151
|
+
database connection.
|
|
152
|
+
|
|
153
|
+
```python
|
|
154
|
+
from xkcdkit import AsyncXKCD, ComicNotFound
|
|
155
|
+
|
|
156
|
+
xk = AsyncXKCD() # created once; call `await xk.close()` on shutdown
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
@bot.command()
|
|
160
|
+
async def xkcd(ctx, num: int):
|
|
161
|
+
try:
|
|
162
|
+
comic = await xk.get(num)
|
|
163
|
+
except ComicNotFound:
|
|
164
|
+
await ctx.send(f"There is no xkcd {num}.")
|
|
165
|
+
return
|
|
166
|
+
await ctx.send(f"**{comic.title}**\n{comic.img}\n*{comic.alt}*")
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
### The `Comic` model
|
|
170
|
+
|
|
171
|
+
`Comic` is a pydantic model with every field from the xkcd JSON (`num`,
|
|
172
|
+
`title`, `safe_title`, `alt`, `img`, `transcript`, `link`, `news`, `year`,
|
|
173
|
+
`month`, `day`) plus:
|
|
174
|
+
|
|
175
|
+
| Field | Meaning |
|
|
176
|
+
| --------------------- | ----------------------------------------------------------- |
|
|
177
|
+
| `explanation` | full explainxkcd page wikitext, or `None` |
|
|
178
|
+
| `explanation_revid` | the wiki revision that was fetched |
|
|
179
|
+
| `explanation_missing` | `True` when explainxkcd had no page at sync time |
|
|
180
|
+
| `synced_at` | when the row was written (UTC) |
|
|
181
|
+
| `explanation_text` | property: the Explanation section as plain text |
|
|
182
|
+
| `date`, `url` | properties: publication date and the comic's page |
|
|
183
|
+
|
|
184
|
+
`comic.model_dump_json()` gives you the same JSON the CLI prints.
|
|
185
|
+
|
|
186
|
+
## Data, caching and freshness
|
|
187
|
+
|
|
188
|
+
- The database lives in your platform's user data directory
|
|
189
|
+
(`~/.local/share/xkcdkit/xkcd.sqlite3` on Linux, `~/Library/Application
|
|
190
|
+
Support/xkcdkit/` on macOS, `%LOCALAPPDATA%\xkcdkit\` on Windows). Set
|
|
191
|
+
`XKCDKIT_DB` or pass `--db` / `db=` to put it elsewhere.
|
|
192
|
+
- Comics are immutable once stored. A cached comic is served as is, except
|
|
193
|
+
when its explanation was missing more than a day ago; then explainxkcd is
|
|
194
|
+
asked again on the next access.
|
|
195
|
+
- `resync` re-fetches rows older than `--max-age` days (default 30) and every
|
|
196
|
+
row still flagged `explanation_missing`. Recently synced data is trusted.
|
|
197
|
+
- Roughly 1 to 2 percent of comics have no explainxkcd page yet. Those rows
|
|
198
|
+
carry `explanation_missing = True` so your code can decide what to do.
|
|
199
|
+
|
|
200
|
+
### Being polite to explainxkcd
|
|
201
|
+
|
|
202
|
+
explainxkcd is a volunteer-run wiki. Its robots policy asks for one request
|
|
203
|
+
per second, so a full sync fetches one explanation per second and sends a
|
|
204
|
+
User-Agent that names this project and its repository. A complete first
|
|
205
|
+
`sync --full` therefore takes about an hour. On-demand lookups are not
|
|
206
|
+
delayed.
|
|
207
|
+
|
|
208
|
+
## Terminal image support
|
|
209
|
+
|
|
210
|
+
| Terminal | Protocol | Status |
|
|
211
|
+
| ---------------------------- | -------- | ------------------------- |
|
|
212
|
+
| kitty, Ghostty | kitty | works |
|
|
213
|
+
| WezTerm, foot, iTerm2 | sixel | works |
|
|
214
|
+
| Windows Terminal 1.22+ | sixel | works |
|
|
215
|
+
| VS Code terminal | sixel | enable `terminal.integrated.enableImages` |
|
|
216
|
+
| tmux | – | falls back to half-block unicode |
|
|
217
|
+
| GNOME Terminal, conhost | – | falls back to half-block unicode |
|
|
218
|
+
|
|
219
|
+
Detection and rendering are done by [textual-image](https://github.com/lnqs/textual-image).
|
|
220
|
+
|
|
221
|
+
## Development
|
|
222
|
+
|
|
223
|
+
```sh
|
|
224
|
+
git clone https://github.com/eivl/xkcdkit
|
|
225
|
+
cd xkcdkit
|
|
226
|
+
uv sync --all-groups
|
|
227
|
+
uv run pre-commit install
|
|
228
|
+
uv run pytest
|
|
229
|
+
uv run tox # all supported Python versions
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
The code base is deliberately small: seven modules, short functions, a
|
|
233
|
+
docstring on everything. ruff (with pydocstyle), mypy strict, flake8 and
|
|
234
|
+
zizmor run in pre-commit and in CI.
|
|
235
|
+
|
|
236
|
+
### Releasing
|
|
237
|
+
|
|
238
|
+
Commits follow [Conventional Commits](https://www.conventionalcommits.org/).
|
|
239
|
+
On every push to `main`, python-semantic-release reads the commit messages,
|
|
240
|
+
bumps the version in `pyproject.toml`, updates `CHANGELOG.md`, tags the
|
|
241
|
+
release and publishes to PyPI through trusted publishing. `feat:` bumps the
|
|
242
|
+
minor version, `fix:` the patch version, and a `BREAKING CHANGE:` footer the
|
|
243
|
+
major version. The release commit also re-locks `uv.lock` so that
|
|
244
|
+
`uv sync --locked` keeps working after the bump.
|
|
245
|
+
|
|
246
|
+
Trusted publishing has to be registered once on PyPI under
|
|
247
|
+
*Publishing → Add a new pending publisher* with these values:
|
|
248
|
+
|
|
249
|
+
| Field | Value |
|
|
250
|
+
| ---------------- | -------------- |
|
|
251
|
+
| PyPI project | `xkcdkit` |
|
|
252
|
+
| Owner | `eivl` |
|
|
253
|
+
| Repository | `xkcdkit` |
|
|
254
|
+
| Workflow | `release.yml` |
|
|
255
|
+
| Environment | `pypi` |
|
|
256
|
+
|
|
257
|
+
and a GitHub environment named `pypi` must exist in the repository settings.
|
|
258
|
+
|
|
259
|
+
## License
|
|
260
|
+
|
|
261
|
+
MIT
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "xkcdkit"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "xkcd comics as a Python library, a CLI and a TUI, with a local cache and explainxkcd explanations."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
requires-python = ">=3.12"
|
|
8
|
+
keywords = [
|
|
9
|
+
"xkcd",
|
|
10
|
+
"comics",
|
|
11
|
+
"cli",
|
|
12
|
+
"tui",
|
|
13
|
+
"textual",
|
|
14
|
+
]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 3 - Alpha",
|
|
17
|
+
"Environment :: Console",
|
|
18
|
+
"Intended Audience :: Developers",
|
|
19
|
+
"Operating System :: OS Independent",
|
|
20
|
+
"Programming Language :: Python :: 3",
|
|
21
|
+
"Programming Language :: Python :: 3.12",
|
|
22
|
+
"Programming Language :: Python :: 3.13",
|
|
23
|
+
"Programming Language :: Python :: 3.14",
|
|
24
|
+
"Programming Language :: Python :: 3.15",
|
|
25
|
+
"Topic :: Terminals",
|
|
26
|
+
"Typing :: Typed",
|
|
27
|
+
]
|
|
28
|
+
dependencies = [
|
|
29
|
+
"httpx2>=2.16",
|
|
30
|
+
"pydantic>=2.11",
|
|
31
|
+
"typer>=0.17",
|
|
32
|
+
"textual>=6.0",
|
|
33
|
+
"textual-image>=0.14",
|
|
34
|
+
"rapidfuzz>=3.13",
|
|
35
|
+
"platformdirs>=4.3",
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
[[project.authors]]
|
|
39
|
+
name = "Eivind Teig"
|
|
40
|
+
email = "eivind@dchub.no"
|
|
41
|
+
|
|
42
|
+
[project.urls]
|
|
43
|
+
Homepage = "https://github.com/eivl/xkcdkit"
|
|
44
|
+
Repository = "https://github.com/eivl/xkcdkit"
|
|
45
|
+
Changelog = "https://github.com/eivl/xkcdkit/blob/main/CHANGELOG.md"
|
|
46
|
+
|
|
47
|
+
[project.scripts]
|
|
48
|
+
xkcd = "xkcdkit.cli:app"
|
|
49
|
+
|
|
50
|
+
[dependency-groups]
|
|
51
|
+
dev = [
|
|
52
|
+
"pytest>=9.0",
|
|
53
|
+
"pytest-asyncio>=1.2",
|
|
54
|
+
"coverage[toml]>=7.10",
|
|
55
|
+
"ruff>=0.14",
|
|
56
|
+
"mypy>=1.18",
|
|
57
|
+
"flake8>=7.3",
|
|
58
|
+
"tox>=4.30",
|
|
59
|
+
"tox-uv>=1.28",
|
|
60
|
+
"pre-commit>=4.3",
|
|
61
|
+
"zizmor>=1.20",
|
|
62
|
+
]
|
|
63
|
+
|
|
64
|
+
[build-system]
|
|
65
|
+
requires = ["uv_build>=0.13.4,<0.14.0"]
|
|
66
|
+
build-backend = "uv_build"
|
|
67
|
+
|
|
68
|
+
[tool.pytest.ini_options]
|
|
69
|
+
asyncio_mode = "auto"
|
|
70
|
+
asyncio_default_fixture_loop_scope = "function"
|
|
71
|
+
testpaths = ["tests"]
|
|
72
|
+
|
|
73
|
+
[tool.ruff]
|
|
74
|
+
line-length = 110
|
|
75
|
+
target-version = "py312"
|
|
76
|
+
src = [
|
|
77
|
+
"src",
|
|
78
|
+
"tests",
|
|
79
|
+
]
|
|
80
|
+
|
|
81
|
+
[tool.ruff.lint]
|
|
82
|
+
select = [
|
|
83
|
+
"E",
|
|
84
|
+
"F",
|
|
85
|
+
"W",
|
|
86
|
+
"I",
|
|
87
|
+
"UP",
|
|
88
|
+
"B",
|
|
89
|
+
"D",
|
|
90
|
+
"N",
|
|
91
|
+
"SIM",
|
|
92
|
+
"RUF",
|
|
93
|
+
"ASYNC",
|
|
94
|
+
"PT",
|
|
95
|
+
"S",
|
|
96
|
+
]
|
|
97
|
+
ignore = [
|
|
98
|
+
"D203",
|
|
99
|
+
"D213",
|
|
100
|
+
"S101",
|
|
101
|
+
"S311",
|
|
102
|
+
]
|
|
103
|
+
|
|
104
|
+
[tool.ruff.lint.pydocstyle]
|
|
105
|
+
convention = "google"
|
|
106
|
+
|
|
107
|
+
[tool.ruff.lint.per-file-ignores]
|
|
108
|
+
"tests/*" = [
|
|
109
|
+
"S105",
|
|
110
|
+
"S106",
|
|
111
|
+
]
|
|
112
|
+
|
|
113
|
+
[tool.mypy]
|
|
114
|
+
python_version = "3.12"
|
|
115
|
+
strict = true
|
|
116
|
+
files = [
|
|
117
|
+
"src",
|
|
118
|
+
"tests",
|
|
119
|
+
]
|
|
120
|
+
plugins = ["pydantic.mypy"]
|
|
121
|
+
|
|
122
|
+
[[tool.mypy.overrides]]
|
|
123
|
+
module = [
|
|
124
|
+
"textual_image.*",
|
|
125
|
+
"rapidfuzz.*",
|
|
126
|
+
]
|
|
127
|
+
ignore_missing_imports = true
|
|
128
|
+
|
|
129
|
+
[tool.coverage.run]
|
|
130
|
+
source = ["xkcdkit"]
|
|
131
|
+
branch = true
|
|
132
|
+
|
|
133
|
+
[tool.coverage.report]
|
|
134
|
+
fail_under = 95
|
|
135
|
+
show_missing = true
|
|
136
|
+
exclude_also = [
|
|
137
|
+
"if TYPE_CHECKING:",
|
|
138
|
+
"pragma: no cover",
|
|
139
|
+
]
|
|
140
|
+
|
|
141
|
+
[tool.semantic_release]
|
|
142
|
+
version_toml = ["pyproject.toml:project.version"]
|
|
143
|
+
commit_parser = "conventional"
|
|
144
|
+
tag_format = "v{version}"
|
|
145
|
+
allow_zero_version = true
|
|
146
|
+
major_on_zero = false
|
|
147
|
+
build_command = "pip install uv && uv lock"
|
|
148
|
+
assets = ["uv.lock"]
|
|
149
|
+
commit_message = "chore(release): {version} [skip ci]"
|
|
150
|
+
|
|
151
|
+
[tool.semantic_release.changelog]
|
|
152
|
+
mode = "update"
|
|
153
|
+
|
|
154
|
+
[tool.semantic_release.changelog.default_templates]
|
|
155
|
+
changelog_file = "CHANGELOG.md"
|
|
156
|
+
|
|
157
|
+
[tool.semantic_release.branches.main]
|
|
158
|
+
match = "main"
|