xkcdkit 0.1.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.1.0 → xkcdkit-0.3.0}/PKG-INFO +60 -7
- {xkcdkit-0.1.0 → xkcdkit-0.3.0}/README.md +59 -6
- {xkcdkit-0.1.0 → xkcdkit-0.3.0}/pyproject.toml +1 -1
- {xkcdkit-0.1.0 → xkcdkit-0.3.0}/pyproject.toml.orig +1 -1
- {xkcdkit-0.1.0 → xkcdkit-0.3.0}/src/xkcdkit/cli.py +63 -9
- {xkcdkit-0.1.0 → xkcdkit-0.3.0}/src/xkcdkit/client.py +51 -19
- xkcdkit-0.3.0/src/xkcdkit/config.py +76 -0
- {xkcdkit-0.1.0 → xkcdkit-0.3.0}/src/xkcdkit/render.py +20 -4
- {xkcdkit-0.1.0 → xkcdkit-0.3.0}/src/xkcdkit/store.py +2 -2
- {xkcdkit-0.1.0 → xkcdkit-0.3.0}/src/xkcdkit/tui.py +27 -7
- {xkcdkit-0.1.0 → xkcdkit-0.3.0}/src/xkcdkit/__init__.py +0 -0
- {xkcdkit-0.1.0 → xkcdkit-0.3.0}/src/xkcdkit/models.py +0 -0
- {xkcdkit-0.1.0 → xkcdkit-0.3.0}/src/xkcdkit/py.typed +0 -0
- {xkcdkit-0.1.0 → xkcdkit-0.3.0}/src/xkcdkit/sources.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
|
```
|
|
@@ -130,6 +131,40 @@ xkcd search "sandwich" --json | jq '.[].num'
|
|
|
130
131
|
`--no-image` skips the picture. Images are also skipped automatically when
|
|
131
132
|
stdout is not a terminal, so pipes always get clean text.
|
|
132
133
|
|
|
134
|
+
Images are drawn at the size the terminal reports for them, which can be
|
|
135
|
+
small on a high-resolution screen. `--scale 2` (or `-s 3`, any factor) makes
|
|
136
|
+
them bigger, and `--fit` fills the terminal while keeping the aspect ratio:
|
|
137
|
+
|
|
138
|
+
```sh
|
|
139
|
+
xkcd show 614 --scale 2
|
|
140
|
+
xkcd latest --fit
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
### Settings file
|
|
144
|
+
|
|
145
|
+
Defaults for these live in a small TOML file. `xkcd config` prints its
|
|
146
|
+
location and the current values; `xkcd config --init` writes a commented
|
|
147
|
+
file with every key:
|
|
148
|
+
|
|
149
|
+
| Platform | Location |
|
|
150
|
+
| -------- | -------- |
|
|
151
|
+
| Linux | `~/.config/xkcdkit/config.toml` |
|
|
152
|
+
| macOS | `~/Library/Application Support/xkcdkit/config.toml` |
|
|
153
|
+
| Windows | `%APPDATA%\xkcdkit\config.toml` |
|
|
154
|
+
|
|
155
|
+
Set `XKCDKIT_CONFIG` to use another path.
|
|
156
|
+
|
|
157
|
+
```toml
|
|
158
|
+
[image]
|
|
159
|
+
scale = 2.0 # CLI image size multiplier
|
|
160
|
+
fit = false # CLI: fill the terminal instead
|
|
161
|
+
|
|
162
|
+
[tui]
|
|
163
|
+
image_pane = 3 # browser: image pane height relative to the text pane (1 to 8)
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
Command line flags always win over the file.
|
|
167
|
+
|
|
133
168
|
### The browser
|
|
134
169
|
|
|
135
170
|
`xkcd tui` opens a two-pane browser in the spirit of lazygit: the archive on
|
|
@@ -139,15 +174,20 @@ explanation.
|
|
|
139
174
|
| Key | Action |
|
|
140
175
|
| ------- | -------------------------------- |
|
|
141
176
|
| `j`/`k` | move through the archive |
|
|
142
|
-
| `/` | filter the archive by title
|
|
177
|
+
| `/` | filter the archive by title; up/down still move the list, Esc returns to it |
|
|
143
178
|
| `Enter` | open the selected comic |
|
|
144
179
|
| `r` | random comic |
|
|
145
180
|
| `l` | latest comic |
|
|
146
181
|
| `i` | show or hide the image pane |
|
|
182
|
+
| `+`/`-` | grow or shrink the image pane |
|
|
147
183
|
| `s` | sync the archive index |
|
|
148
184
|
| `?` | key help |
|
|
149
185
|
| `q` | quit |
|
|
150
186
|
|
|
187
|
+
The image always fills its pane while keeping its aspect ratio, so the pane
|
|
188
|
+
height is the zoom. The starting height comes from `image_pane` in the
|
|
189
|
+
settings file.
|
|
190
|
+
|
|
151
191
|
## Library
|
|
152
192
|
|
|
153
193
|
The async client is the primary API. It is built for I/O bound programs such
|
|
@@ -232,10 +272,23 @@ async def xkcd(ctx, num: int):
|
|
|
232
272
|
### Being polite to explainxkcd
|
|
233
273
|
|
|
234
274
|
explainxkcd is a volunteer-run wiki. Its robots policy asks for one request
|
|
235
|
-
per second, so a full sync
|
|
236
|
-
User-Agent that names this project and its repository.
|
|
237
|
-
|
|
238
|
-
|
|
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.
|
|
239
292
|
|
|
240
293
|
## Terminal image support
|
|
241
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
|
```
|
|
@@ -98,6 +99,40 @@ xkcd search "sandwich" --json | jq '.[].num'
|
|
|
98
99
|
`--no-image` skips the picture. Images are also skipped automatically when
|
|
99
100
|
stdout is not a terminal, so pipes always get clean text.
|
|
100
101
|
|
|
102
|
+
Images are drawn at the size the terminal reports for them, which can be
|
|
103
|
+
small on a high-resolution screen. `--scale 2` (or `-s 3`, any factor) makes
|
|
104
|
+
them bigger, and `--fit` fills the terminal while keeping the aspect ratio:
|
|
105
|
+
|
|
106
|
+
```sh
|
|
107
|
+
xkcd show 614 --scale 2
|
|
108
|
+
xkcd latest --fit
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
### Settings file
|
|
112
|
+
|
|
113
|
+
Defaults for these live in a small TOML file. `xkcd config` prints its
|
|
114
|
+
location and the current values; `xkcd config --init` writes a commented
|
|
115
|
+
file with every key:
|
|
116
|
+
|
|
117
|
+
| Platform | Location |
|
|
118
|
+
| -------- | -------- |
|
|
119
|
+
| Linux | `~/.config/xkcdkit/config.toml` |
|
|
120
|
+
| macOS | `~/Library/Application Support/xkcdkit/config.toml` |
|
|
121
|
+
| Windows | `%APPDATA%\xkcdkit\config.toml` |
|
|
122
|
+
|
|
123
|
+
Set `XKCDKIT_CONFIG` to use another path.
|
|
124
|
+
|
|
125
|
+
```toml
|
|
126
|
+
[image]
|
|
127
|
+
scale = 2.0 # CLI image size multiplier
|
|
128
|
+
fit = false # CLI: fill the terminal instead
|
|
129
|
+
|
|
130
|
+
[tui]
|
|
131
|
+
image_pane = 3 # browser: image pane height relative to the text pane (1 to 8)
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
Command line flags always win over the file.
|
|
135
|
+
|
|
101
136
|
### The browser
|
|
102
137
|
|
|
103
138
|
`xkcd tui` opens a two-pane browser in the spirit of lazygit: the archive on
|
|
@@ -107,15 +142,20 @@ explanation.
|
|
|
107
142
|
| Key | Action |
|
|
108
143
|
| ------- | -------------------------------- |
|
|
109
144
|
| `j`/`k` | move through the archive |
|
|
110
|
-
| `/` | filter the archive by title
|
|
145
|
+
| `/` | filter the archive by title; up/down still move the list, Esc returns to it |
|
|
111
146
|
| `Enter` | open the selected comic |
|
|
112
147
|
| `r` | random comic |
|
|
113
148
|
| `l` | latest comic |
|
|
114
149
|
| `i` | show or hide the image pane |
|
|
150
|
+
| `+`/`-` | grow or shrink the image pane |
|
|
115
151
|
| `s` | sync the archive index |
|
|
116
152
|
| `?` | key help |
|
|
117
153
|
| `q` | quit |
|
|
118
154
|
|
|
155
|
+
The image always fills its pane while keeping its aspect ratio, so the pane
|
|
156
|
+
height is the zoom. The starting height comes from `image_pane` in the
|
|
157
|
+
settings file.
|
|
158
|
+
|
|
119
159
|
## Library
|
|
120
160
|
|
|
121
161
|
The async client is the primary API. It is built for I/O bound programs such
|
|
@@ -200,10 +240,23 @@ async def xkcd(ctx, num: int):
|
|
|
200
240
|
### Being polite to explainxkcd
|
|
201
241
|
|
|
202
242
|
explainxkcd is a volunteer-run wiki. Its robots policy asks for one request
|
|
203
|
-
per second, so a full sync
|
|
204
|
-
User-Agent that names this project and its repository.
|
|
205
|
-
|
|
206
|
-
|
|
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.
|
|
207
260
|
|
|
208
261
|
## Terminal image support
|
|
209
262
|
|
|
@@ -12,6 +12,7 @@ from rich.text import Text
|
|
|
12
12
|
|
|
13
13
|
from xkcdkit import XKCDError, __version__
|
|
14
14
|
from xkcdkit.client import AsyncXKCD
|
|
15
|
+
from xkcdkit.config import config_path, load_settings, write_default
|
|
15
16
|
from xkcdkit.models import Comic
|
|
16
17
|
from xkcdkit.render import can_draw, comic_image, comic_text
|
|
17
18
|
from xkcdkit.sources import fetch_image
|
|
@@ -21,6 +22,8 @@ console = Console()
|
|
|
21
22
|
errors = Console(stderr=True)
|
|
22
23
|
Num = Annotated[int, typer.Argument(help="Comic number.")]
|
|
23
24
|
Json = Annotated[bool, typer.Option("--json", help="Print JSON for piping.")]
|
|
25
|
+
Scale = Annotated[float | None, typer.Option("--scale", "-s", help="Image size multiplier.")]
|
|
26
|
+
Fit = Annotated[bool, typer.Option("--fit", help="Fill the terminal with the image.")]
|
|
24
27
|
NoImage = Annotated[bool, typer.Option("--no-image", help="Do not draw the image.")]
|
|
25
28
|
Explain = Annotated[bool, typer.Option("--explain", "-e", help="Include the explanation.")]
|
|
26
29
|
|
|
@@ -66,6 +69,12 @@ def run(ctx: typer.Context, action: Callable[[AsyncXKCD], Awaitable[Any]]) -> An
|
|
|
66
69
|
except XKCDError as exc:
|
|
67
70
|
errors.print(f"error: {exc}", markup=False, highlight=False)
|
|
68
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
|
|
69
78
|
|
|
70
79
|
|
|
71
80
|
@dataclass
|
|
@@ -75,6 +84,14 @@ class View:
|
|
|
75
84
|
json: bool = False
|
|
76
85
|
image: bool = True
|
|
77
86
|
explain: bool = False
|
|
87
|
+
scale: float = 1.0
|
|
88
|
+
fit: bool = False
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def view(json: bool, no_image: bool, explain: bool, scale: float | None, fit: bool) -> View:
|
|
92
|
+
"""Combine command flags with the settings file; flags win."""
|
|
93
|
+
image = load_settings().image
|
|
94
|
+
return View(json, not no_image, explain, scale or image.scale, fit or image.fit)
|
|
78
95
|
|
|
79
96
|
|
|
80
97
|
async def present(xk: AsyncXKCD, pending: Awaitable[Comic], view: View) -> None:
|
|
@@ -84,32 +101,48 @@ async def present(xk: AsyncXKCD, pending: Awaitable[Comic], view: View) -> None:
|
|
|
84
101
|
console.print_json(comic.model_dump_json())
|
|
85
102
|
return
|
|
86
103
|
if view.image and can_draw():
|
|
87
|
-
console.print(comic_image(await fetch_image(xk.http, comic)))
|
|
104
|
+
console.print(comic_image(await fetch_image(xk.http, comic), view.scale, view.fit))
|
|
88
105
|
console.print(comic_text(comic, explain=view.explain))
|
|
89
106
|
|
|
90
107
|
|
|
91
108
|
@app.command()
|
|
92
109
|
def show(
|
|
93
|
-
ctx: typer.Context,
|
|
110
|
+
ctx: typer.Context,
|
|
111
|
+
num: Num,
|
|
112
|
+
no_image: NoImage = False,
|
|
113
|
+
explain: Explain = False,
|
|
114
|
+
json: Json = False,
|
|
115
|
+
scale: Scale = None,
|
|
116
|
+
fit: Fit = False,
|
|
94
117
|
) -> None:
|
|
95
118
|
"""Show one comic by number."""
|
|
96
|
-
run(ctx, lambda xk: present(xk, xk.get(num),
|
|
119
|
+
run(ctx, lambda xk: present(xk, xk.get(num), view(json, no_image, explain, scale, fit)))
|
|
97
120
|
|
|
98
121
|
|
|
99
122
|
@app.command()
|
|
100
123
|
def latest(
|
|
101
|
-
ctx: typer.Context,
|
|
124
|
+
ctx: typer.Context,
|
|
125
|
+
no_image: NoImage = False,
|
|
126
|
+
explain: Explain = False,
|
|
127
|
+
json: Json = False,
|
|
128
|
+
scale: Scale = None,
|
|
129
|
+
fit: Fit = False,
|
|
102
130
|
) -> None:
|
|
103
131
|
"""Show the newest comic."""
|
|
104
|
-
run(ctx, lambda xk: present(xk, xk.latest(),
|
|
132
|
+
run(ctx, lambda xk: present(xk, xk.latest(), view(json, no_image, explain, scale, fit)))
|
|
105
133
|
|
|
106
134
|
|
|
107
135
|
@app.command()
|
|
108
136
|
def random(
|
|
109
|
-
ctx: typer.Context,
|
|
137
|
+
ctx: typer.Context,
|
|
138
|
+
no_image: NoImage = False,
|
|
139
|
+
explain: Explain = False,
|
|
140
|
+
json: Json = False,
|
|
141
|
+
scale: Scale = None,
|
|
142
|
+
fit: Fit = False,
|
|
110
143
|
) -> None:
|
|
111
144
|
"""Show a random comic."""
|
|
112
|
-
run(ctx, lambda xk: present(xk, xk.random(),
|
|
145
|
+
run(ctx, lambda xk: present(xk, xk.random(), view(json, no_image, explain, scale, fit)))
|
|
113
146
|
|
|
114
147
|
|
|
115
148
|
@app.command()
|
|
@@ -146,11 +179,17 @@ def explain(ctx: typer.Context, num: Num, json: Json = False) -> None:
|
|
|
146
179
|
def sync(
|
|
147
180
|
ctx: typer.Context,
|
|
148
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,
|
|
149
185
|
) -> None:
|
|
150
186
|
"""Refresh the archive index and latest comic; with --full, download everything."""
|
|
151
187
|
with console.status("Syncing") as status:
|
|
152
188
|
added: int = run(
|
|
153
|
-
ctx,
|
|
189
|
+
ctx,
|
|
190
|
+
lambda xk: xk.sync(
|
|
191
|
+
full, lambda done, total: status.update(f"Syncing {done}/{total}"), explain=not no_explain
|
|
192
|
+
),
|
|
154
193
|
)
|
|
155
194
|
console.print(f"{added} new comic{'s' if added != 1 else ''} cached.")
|
|
156
195
|
|
|
@@ -176,4 +215,19 @@ def tui(ctx: typer.Context) -> None:
|
|
|
176
215
|
"""Open the interactive browser."""
|
|
177
216
|
from xkcdkit.tui import run_tui
|
|
178
217
|
|
|
179
|
-
run_tui(open_client(ctx.obj.db))
|
|
218
|
+
run_tui(open_client(ctx.obj.db), load_settings())
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
@app.command()
|
|
222
|
+
def config(
|
|
223
|
+
init: Annotated[bool, typer.Option("--init", help="Create the file with defaults.")] = False,
|
|
224
|
+
) -> None:
|
|
225
|
+
"""Show where settings live and what they are; --init writes a commented default file."""
|
|
226
|
+
path = config_path()
|
|
227
|
+
if init:
|
|
228
|
+
console.print(
|
|
229
|
+
f"{'created' if write_default(path) else 'exists'}: {path}", markup=False, soft_wrap=True
|
|
230
|
+
)
|
|
231
|
+
return
|
|
232
|
+
console.print(f"settings file: {path}", markup=False, soft_wrap=True)
|
|
233
|
+
console.print_json(load_settings(path).model_dump_json())
|
|
@@ -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``."""
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""User settings from a small TOML file in the platform config directory."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import tomllib
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from platformdirs import user_config_path
|
|
8
|
+
from pydantic import BaseModel, Field, ValidationError
|
|
9
|
+
|
|
10
|
+
from xkcdkit import XKCDError
|
|
11
|
+
|
|
12
|
+
DEFAULT_TOML = """# xkcdkit settings. Every key is optional; these are the defaults.
|
|
13
|
+
|
|
14
|
+
[image]
|
|
15
|
+
# CLI: multiply the image's natural terminal size (1 = as the terminal sizes it, 2 = twice as big).
|
|
16
|
+
scale = 1.0
|
|
17
|
+
# CLI: ignore scale and fill the terminal, keeping the aspect ratio.
|
|
18
|
+
fit = false
|
|
19
|
+
|
|
20
|
+
[tui]
|
|
21
|
+
# Height of the image pane relative to the text pane (1 to 8). Change live with + and -.
|
|
22
|
+
image_pane = 2
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class ImageSettings(BaseModel):
|
|
27
|
+
"""How the CLI draws images."""
|
|
28
|
+
|
|
29
|
+
scale: float = Field(default=1.0, gt=0, le=16)
|
|
30
|
+
fit: bool = False
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class TuiSettings(BaseModel):
|
|
34
|
+
"""How the browser lays itself out."""
|
|
35
|
+
|
|
36
|
+
image_pane: int = Field(default=2, ge=1, le=8)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class Settings(BaseModel):
|
|
40
|
+
"""All settings, mirroring the sections of the TOML file."""
|
|
41
|
+
|
|
42
|
+
image: ImageSettings = ImageSettings()
|
|
43
|
+
tui: TuiSettings = TuiSettings()
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def config_path() -> Path:
|
|
47
|
+
"""Settings file location: ``$XKCDKIT_CONFIG`` or the platform user config directory."""
|
|
48
|
+
override = os.environ.get("XKCDKIT_CONFIG")
|
|
49
|
+
if override:
|
|
50
|
+
return Path(override)
|
|
51
|
+
return user_config_path("xkcdkit") / "config.toml"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def load_settings(path: Path | None = None) -> Settings:
|
|
55
|
+
"""Read the settings file, falling back to defaults when it does not exist.
|
|
56
|
+
|
|
57
|
+
Raises:
|
|
58
|
+
XKCDError: when the file is not valid TOML or holds an invalid value.
|
|
59
|
+
"""
|
|
60
|
+
path = path or config_path()
|
|
61
|
+
if not path.exists():
|
|
62
|
+
return Settings()
|
|
63
|
+
try:
|
|
64
|
+
return Settings.model_validate(tomllib.loads(path.read_text(encoding="utf-8")))
|
|
65
|
+
except (tomllib.TOMLDecodeError, ValidationError) as exc:
|
|
66
|
+
raise XKCDError(f"{path}: {exc}") from exc
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def write_default(path: Path | None = None) -> bool:
|
|
70
|
+
"""Create the settings file with documented defaults. Returns False if it already exists."""
|
|
71
|
+
path = path or config_path()
|
|
72
|
+
if path.exists():
|
|
73
|
+
return False
|
|
74
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
75
|
+
path.write_text(DEFAULT_TOML, encoding="utf-8")
|
|
76
|
+
return True
|
|
@@ -3,10 +3,12 @@
|
|
|
3
3
|
import io
|
|
4
4
|
import sys
|
|
5
5
|
|
|
6
|
+
from PIL import Image as PILImage
|
|
6
7
|
from rich.console import Group, RenderableType
|
|
7
8
|
from rich.panel import Panel
|
|
8
9
|
from rich.text import Text
|
|
9
10
|
|
|
11
|
+
from xkcdkit import XKCDError
|
|
10
12
|
from xkcdkit.models import Comic
|
|
11
13
|
|
|
12
14
|
|
|
@@ -26,15 +28,29 @@ def explanation_text(comic: Comic) -> Text:
|
|
|
26
28
|
return Text(comic.explanation_text)
|
|
27
29
|
|
|
28
30
|
|
|
29
|
-
def comic_image(data: bytes) -> RenderableType:
|
|
31
|
+
def comic_image(data: bytes, scale: float = 1.0, fit: bool = False) -> RenderableType:
|
|
30
32
|
"""A Rich renderable for the image bytes using the best protocol the terminal supports.
|
|
31
33
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
+
``scale`` multiplies the image's natural terminal size; ``fit`` instead fills the
|
|
35
|
+
terminal while keeping the aspect ratio. textual-image probes the terminal on
|
|
36
|
+
import, so it is imported here rather than at module load.
|
|
37
|
+
|
|
38
|
+
Raises:
|
|
39
|
+
XKCDError: when the bytes are not an image.
|
|
34
40
|
"""
|
|
35
41
|
from textual_image.renderable import Image
|
|
36
42
|
|
|
37
|
-
|
|
43
|
+
try:
|
|
44
|
+
picture: PILImage.Image = PILImage.open(io.BytesIO(data))
|
|
45
|
+
picture.load()
|
|
46
|
+
except OSError as exc:
|
|
47
|
+
raise XKCDError(f"not an image: {exc}") from exc
|
|
48
|
+
if fit:
|
|
49
|
+
renderable: RenderableType = Image(picture, width="auto", height="auto")
|
|
50
|
+
return renderable
|
|
51
|
+
if scale != 1:
|
|
52
|
+
picture = picture.resize((round(picture.width * scale), round(picture.height * scale)))
|
|
53
|
+
renderable = Image(picture)
|
|
38
54
|
return renderable
|
|
39
55
|
|
|
40
56
|
|
|
@@ -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]
|
|
@@ -16,10 +16,12 @@ from textual_image.widget import Image
|
|
|
16
16
|
|
|
17
17
|
from xkcdkit import XKCDError
|
|
18
18
|
from xkcdkit.client import AsyncXKCD
|
|
19
|
+
from xkcdkit.config import Settings
|
|
19
20
|
from xkcdkit.models import ArchiveEntry, Comic
|
|
20
21
|
from xkcdkit.sources import fetch_image
|
|
21
22
|
|
|
22
|
-
HELP = "j/k move / filter enter open r random l latest i image s sync q quit"
|
|
23
|
+
HELP = "j/k move / filter enter open r random l latest i image +/- zoom s sync q quit"
|
|
24
|
+
MAX_PANE = 8
|
|
23
25
|
|
|
24
26
|
|
|
25
27
|
def option(entry: ArchiveEntry) -> Option:
|
|
@@ -35,6 +37,7 @@ class XKCDApp(App[None]):
|
|
|
35
37
|
#archive-pane { width: 40; border: round $primary; }
|
|
36
38
|
#archive { height: 1fr; }
|
|
37
39
|
#image-pane { height: 2fr; border: round $secondary; align: center middle; }
|
|
40
|
+
#image { width: auto; height: auto; }
|
|
38
41
|
TabbedContent { height: 1fr; }
|
|
39
42
|
.text { padding: 0 1; }
|
|
40
43
|
"""
|
|
@@ -45,15 +48,21 @@ class XKCDApp(App[None]):
|
|
|
45
48
|
Binding("r", "random", "Random"),
|
|
46
49
|
Binding("l", "latest", "Latest"),
|
|
47
50
|
Binding("i", "toggle_image", "Image"),
|
|
51
|
+
Binding("plus,equals_sign", "zoom(1)", "Zoom", key_display="+/-"),
|
|
52
|
+
Binding("minus", "zoom(-1)", "Zoom out", show=False),
|
|
48
53
|
Binding("s", "sync", "Sync"),
|
|
54
|
+
Binding("down", "cursor('down')", "Down", show=False),
|
|
55
|
+
Binding("up", "cursor('up')", "Up", show=False),
|
|
56
|
+
Binding("escape", "focus_list", "List", show=False),
|
|
49
57
|
Binding("question_mark", "help", "Help", key_display="?"),
|
|
50
58
|
Binding("q", "quit", "Quit"),
|
|
51
59
|
]
|
|
52
60
|
|
|
53
|
-
def __init__(self, client: AsyncXKCD) -> None:
|
|
54
|
-
"""Keep the client
|
|
61
|
+
def __init__(self, client: AsyncXKCD, settings: Settings | None = None) -> None:
|
|
62
|
+
"""Keep the client (its store feeds the archive list) and the user's settings."""
|
|
55
63
|
super().__init__()
|
|
56
64
|
self.client = client
|
|
65
|
+
self.settings = settings or Settings()
|
|
57
66
|
self.entries: list[ArchiveEntry] = []
|
|
58
67
|
|
|
59
68
|
def compose(self) -> ComposeResult:
|
|
@@ -77,6 +86,7 @@ class XKCDApp(App[None]):
|
|
|
77
86
|
def on_mount(self) -> None:
|
|
78
87
|
"""Show the cached archive (syncing it on first run) and open the latest comic."""
|
|
79
88
|
self.query_one(OptionList).focus()
|
|
89
|
+
self.query_one("#image-pane").styles.height = f"{self.settings.tui.image_pane}fr"
|
|
80
90
|
self.entries = self.client.store.archive()
|
|
81
91
|
self.show_entries(self.entries)
|
|
82
92
|
if not self.entries:
|
|
@@ -100,10 +110,10 @@ class XKCDApp(App[None]):
|
|
|
100
110
|
self.show_entries([e for score, e in scored if score >= 70])
|
|
101
111
|
|
|
102
112
|
def on_input_submitted(self, event: Input.Submitted) -> None:
|
|
103
|
-
"""Enter in the filter opens the first
|
|
113
|
+
"""Enter in the filter opens the highlighted match (the first unless moved)."""
|
|
104
114
|
archive = self.query_one(OptionList)
|
|
105
115
|
if archive.option_count:
|
|
106
|
-
self.open(int(str(archive.get_option_at_index(0).id)))
|
|
116
|
+
self.open(int(str(archive.get_option_at_index(archive.highlighted or 0).id)))
|
|
107
117
|
|
|
108
118
|
def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None:
|
|
109
119
|
"""Enter on a row opens that comic."""
|
|
@@ -150,6 +160,16 @@ class XKCDApp(App[None]):
|
|
|
150
160
|
"""Jump to the filter box."""
|
|
151
161
|
self.query_one("#filter", Input).focus()
|
|
152
162
|
|
|
163
|
+
def action_focus_list(self) -> None:
|
|
164
|
+
"""Return to the archive list."""
|
|
165
|
+
self.query_one(OptionList).focus()
|
|
166
|
+
|
|
167
|
+
def action_zoom(self, step: int) -> None:
|
|
168
|
+
"""Grow or shrink the image pane relative to the text pane."""
|
|
169
|
+
pane = self.query_one("#image-pane")
|
|
170
|
+
self.settings.tui.image_pane = min(MAX_PANE, max(1, self.settings.tui.image_pane + step))
|
|
171
|
+
pane.styles.height = f"{self.settings.tui.image_pane}fr"
|
|
172
|
+
|
|
153
173
|
def action_random(self) -> None:
|
|
154
174
|
"""Open a random comic."""
|
|
155
175
|
self.open(-1)
|
|
@@ -180,9 +200,9 @@ class XKCDApp(App[None]):
|
|
|
180
200
|
self.notify(HELP, title="Keys", timeout=8)
|
|
181
201
|
|
|
182
202
|
|
|
183
|
-
def run_tui(client: AsyncXKCD) -> None:
|
|
203
|
+
def run_tui(client: AsyncXKCD, settings: Settings | None = None) -> None:
|
|
184
204
|
"""Start the browser and close the client when it exits."""
|
|
185
205
|
try:
|
|
186
|
-
XKCDApp(client).run()
|
|
206
|
+
XKCDApp(client, settings).run()
|
|
187
207
|
finally:
|
|
188
208
|
client.store.close()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|