scribeio 0.1.4__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.
scribeio/__init__.py ADDED
@@ -0,0 +1,8 @@
1
+ """scribe — transcripts of TTRPG sessions recorded with Craig (Discord)."""
2
+
3
+ from importlib.metadata import PackageNotFoundError, version
4
+
5
+ try:
6
+ __version__ = version("scribeio")
7
+ except PackageNotFoundError: # source tree without `pip install -e .`
8
+ __version__ = "0.0.0"
@@ -0,0 +1,298 @@
1
+ """The `scribe check` report: what scribe needs, what it finds, per use.
2
+
3
+ Offline: it reads the local caches only, unless asked to look up model
4
+ sizes and test the model sources (``--online``). The settings checked are the
5
+ ``scribe.yaml`` given with ``-c``, else the one in the current folder, else
6
+ the defaults.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import platform
12
+ import sys
13
+ from collections.abc import Callable
14
+ from contextlib import AbstractContextManager, nullcontext
15
+ from dataclasses import dataclass
16
+ from pathlib import Path
17
+ from typing import TYPE_CHECKING, Any
18
+
19
+ from scribeio import __version__, checks, init
20
+ from scribeio.checks import Item
21
+ from scribeio.config import load_config
22
+ from scribeio.errors import ScribeError
23
+ from scribeio.progress import FIRST_RUN
24
+
25
+ if TYPE_CHECKING:
26
+ from scribeio.transcribe.models import Model
27
+
28
+ SYMBOLS = {"ok": "✔", "missing": "✘", "warning": "⚠", "info": "ℹ"}
29
+ NOT_INSTALLED = "not installed (audio mastering only)"
30
+ NONE_YET = "none yet: downloaded on your first transcription"
31
+ # Shown while WhisperX and PyTorch load: the slow part of a first check.
32
+ LOADING = f"loading WhisperX and PyTorch {FIRST_RUN}"
33
+ # Model source -> the host its downloads come from (checks.SOURCES)
34
+ SOURCE_HOSTS = {"huggingface": "huggingface.co", "torchaudio": "download.pytorch.org"}
35
+
36
+
37
+ @dataclass
38
+ class Checked:
39
+ """The settings the report checks.
40
+
41
+ Attributes:
42
+ path: The config file, or None when there is none.
43
+ settings: Its settings; the defaults without a config or when it
44
+ does not load.
45
+ error: Why the config does not load, or ``""``.
46
+ """
47
+
48
+ path: Path | None
49
+ settings: dict[str, Any]
50
+ error: str = ""
51
+
52
+
53
+ @dataclass
54
+ class Group:
55
+ """One use (audio mastering, transcription): its verdict and items."""
56
+
57
+ title: str
58
+ verdict: str
59
+ items: list[Item]
60
+
61
+
62
+ def load_settings(config: Path | None, cwd: Path) -> Checked:
63
+ """The config from ``-c``, else ``scribe.yaml`` in `cwd`, else defaults."""
64
+ path = config or init.find_config(cwd)
65
+ if path is None:
66
+ return Checked(None, load_config().settings)
67
+ try:
68
+ return Checked(path, load_config(path).settings)
69
+ except ScribeError as e:
70
+ return Checked(path, load_config().settings, str(e))
71
+
72
+
73
+ def header() -> str:
74
+ """scribeio and Python versions, and how it was installed."""
75
+ route = "installed with uv" if checks.uv_install() else f"in {sys.prefix}"
76
+ return f"scribeio {__version__}, Python {platform.python_version()} ({route})"
77
+
78
+
79
+ def audio_group(settings: dict[str, Any]) -> Group:
80
+ """ffmpeg, and the encoder of the configured output format."""
81
+ from scribeio.master.config import BITRATES
82
+
83
+ found = checks.ffmpeg_item()
84
+ items = [found]
85
+ if found.status != "missing":
86
+ fmt = settings["master"]["format"]
87
+ bitrate = settings["master"]["bitrate"] or BITRATES[fmt]
88
+ items.append(checks.encoder_item(fmt, bitrate))
89
+ return Group("Audio mastering", _verdict(items), items)
90
+
91
+
92
+ def transcription_group(settings: dict[str, Any], ffmpeg: Item) -> Group:
93
+ """WhisperX, PyTorch and the GPU for the configured device.
94
+
95
+ Args:
96
+ settings: The checked settings (``transcribe.whisper.device``).
97
+ ffmpeg: The ffmpeg item: transcription decodes audio with ffmpeg.
98
+ """
99
+ wx = checks.whisperx_item()
100
+ if wx.message == checks.WHISPERX_NOT_INSTALLED:
101
+ return Group("Transcription", NOT_INSTALLED, [wx])
102
+ items = [wx]
103
+ if wx.status != "missing":
104
+ device = settings["transcribe"]["whisper"]["device"]
105
+ torch = checks.torch_item(device)
106
+ items.append(torch)
107
+ # with a CPU-only PyTorch the GPU's reason would repeat PyTorch's
108
+ if torch.status != "missing":
109
+ items.append(checks.gpu_item(device))
110
+ if ffmpeg.status != "missing":
111
+ # the detail starts with the version ("9.0.2 C:\...\ffmpeg.exe")
112
+ info = checks.audio_library_item(ffmpeg.detail.split(" ")[0])
113
+ if info is not None:
114
+ items.append(info)
115
+ verdict = "not ready" if ffmpeg.status == "missing" else _verdict(items)
116
+ return Group("Transcription", verdict, items)
117
+
118
+
119
+ def _verdict(items: list[Item]) -> str:
120
+ return "not ready" if any(i.status == "missing" for i in items) else "ready"
121
+
122
+
123
+ def group_lines(group: Group) -> list[str]:
124
+ """The title line with the verdict, then one line per item."""
125
+ lines = [f"{group.title:<53}{group.verdict}"]
126
+ if group.verdict == NOT_INSTALLED:
127
+ [wx] = group.items
128
+ return [
129
+ *lines,
130
+ " " + SYMBOLS["info"] + " " + checks.line(wx),
131
+ " see " + checks.guide_url("transcription"),
132
+ ]
133
+ for item in group.items:
134
+ lines.append(" " + SYMBOLS[item.status] + " " + checks.line(item))
135
+ if item.status == "missing":
136
+ lines.append(" see " + checks.guide_url(item.guide))
137
+ return lines
138
+
139
+
140
+ def needed_models(checked: Checked, whisperx: bool) -> tuple[list[Model], str]:
141
+ """The models the checked config needs, or why they cannot be named.
142
+
143
+ Returns:
144
+ The models and ``""``; no models without a config that loads or
145
+ without WhisperX; no models and the error for an unknown model.
146
+ """
147
+ from scribeio.transcribe import models
148
+
149
+ if checked.path is None or checked.error or not whisperx:
150
+ return [], ""
151
+ try:
152
+ return models.needed(checked.settings["transcribe"]["whisper"]), ""
153
+ except ScribeError as e:
154
+ return [], str(e)
155
+
156
+
157
+ def model_lines(
158
+ checked: Checked,
159
+ cwd: Path,
160
+ whisperx: bool,
161
+ needed: tuple[list[Model], str],
162
+ online: bool = False,
163
+ reachable: frozenset[str] = frozenset(),
164
+ ) -> list[str]:
165
+ """The models on disk (with WhisperX loaded) and the settings checked.
166
+
167
+ Args:
168
+ checked: The settings checked.
169
+ cwd: The current folder, named when it holds no config.
170
+ whisperx: True if WhisperX loaded; models are listed only then.
171
+ needed: `needed_models` for these settings.
172
+ online: Add the download size of the missing models.
173
+ reachable: Model sources that answered; sizes are looked up only
174
+ there (a dropped connection would stall each lookup).
175
+ """
176
+ from scribeio.transcribe import models
177
+
178
+ lines = []
179
+ if whisperx:
180
+ ready, damaged = models.local_alignment()
181
+ lines += [
182
+ f"Models ({models.cache_folder()})",
183
+ " Whisper: " + (", ".join(models.cached_whisper_models()) or NONE_YET),
184
+ " Alignment: " + (", ".join(ready) or NONE_YET),
185
+ ]
186
+ lines += [
187
+ f" ⚠ alignment {language}: damaged; replaced on the next transcription"
188
+ for language in damaged
189
+ ]
190
+ else:
191
+ lines.append("Settings") # no Models block to hold the settings lines
192
+ if checked.path is None:
193
+ return [
194
+ *lines,
195
+ f" No {init.CONFIG_NAME} in {cwd} (the folder this terminal is in).",
196
+ # with the recording: a bare `scribe init` writes example accounts
197
+ " To set up a session: go to the folder with your recording and run "
198
+ 'scribe init "<your recording>.zip"',
199
+ ]
200
+ # absolute(), not resolve(): a mapped drive (Z:\) would become \\server\...
201
+ path = checked.path.absolute()
202
+ found, error = needed
203
+ error = checked.error or error
204
+ if error:
205
+ # config errors start with the file name ("scribe.yaml:3: ..."):
206
+ # the full path takes its place, so the name is not said twice
207
+ if error.startswith(path.name):
208
+ text = str(path) + error.removeprefix(path.name)
209
+ else:
210
+ text = f"{path}: {error}"
211
+ return [*lines, f" ⚠ Settings checked: {text}"]
212
+ lines.append(f" Settings checked: {path}")
213
+ if not found: # without WhisperX the models cannot be named
214
+ return lines
215
+
216
+ def need(m: Model) -> str:
217
+ name = f"alignment {m.language}" if m.kind == "alignment model" else m.name
218
+ if m.cached:
219
+ return f"{name} ✔"
220
+ size = ""
221
+ if online:
222
+ nbytes = models.size(m) if SOURCE_HOSTS.get(m.source) in reachable else None
223
+ size = f"{models.amount(nbytes)}, "
224
+ return f"{name} ↓ ({size}downloaded on first transcription)"
225
+
226
+ return [*lines, " needs " + ", ".join(need(m) for m in found)]
227
+
228
+
229
+ def connection_items(needed: list[Model]) -> list[Item]:
230
+ """Whether the model sources answer: Hugging Face always, PyTorch's
231
+ download site when a needed alignment model comes from there."""
232
+ hosts = ["huggingface.co"]
233
+ if any(m.source == "torchaudio" for m in needed):
234
+ hosts.append("download.pytorch.org")
235
+ return [checks.reach(host, checks.SOURCES[host]) for host in hosts]
236
+
237
+
238
+ def connection_lines(items: list[Item]) -> list[str]:
239
+ """The Connection block, with a hint when a source does not answer."""
240
+ lines = ["Connection"]
241
+ lines += [" " + SYMBOLS[i.status] + " " + checks.line(i) for i in items]
242
+ if any(i.status != "ok" for i in items):
243
+ lines += [
244
+ " " + SYMBOLS["info"] + " " + checks.CONNECTION_INFO,
245
+ " see " + checks.guide_url("connection"),
246
+ ]
247
+ return lines
248
+
249
+
250
+ def report(
251
+ config: Path | None,
252
+ cwd: Path,
253
+ online: bool,
254
+ show: Callable[[list[str]], None] | None = None,
255
+ busy: Callable[[str], AbstractContextManager[None]] | None = None,
256
+ ) -> tuple[list[str], int]:
257
+ """The whole report and the exit code (1 if a use is not ready).
258
+
259
+ Args:
260
+ config: The ``-c`` config, or None.
261
+ cwd: The current folder (its ``scribe.yaml`` is checked without -c).
262
+ online: Also look up missing model sizes and test the model sources;
263
+ neither changes the exit code.
264
+ show: Called with each section's lines as soon as it is done, so the
265
+ instant parts show while the slow ones run.
266
+ busy: Wraps each slow part, given what it does (e.g. `LOADING`).
267
+ """
268
+ lines: list[str] = []
269
+
270
+ def add(part: list[str]) -> None:
271
+ lines.extend(part)
272
+ if show is not None:
273
+ show(part)
274
+
275
+ def wait(label: str) -> AbstractContextManager[None]:
276
+ return busy(label) if busy is not None else nullcontext()
277
+
278
+ checked = load_settings(config, cwd)
279
+ audio = audio_group(checked.settings)
280
+ add([header(), "", *group_lines(audio), ""])
281
+ with wait(LOADING):
282
+ transcription = transcription_group(checked.settings, audio.items[0])
283
+ add([*group_lines(transcription), ""])
284
+ whisperx = transcription.items[0].status != "missing"
285
+ with wait("checking the models"):
286
+ needed = needed_models(checked, whisperx)
287
+ # tested first, so the size lookups skip a source that does not answer
288
+ connection: list[Item] = []
289
+ if online:
290
+ with wait("testing the model sources"):
291
+ connection = connection_items(needed[0])
292
+ reachable = frozenset(i.name for i in connection if i.status == "ok")
293
+ with wait("checking the models"):
294
+ add(model_lines(checked, cwd, whisperx, needed, online, reachable))
295
+ if online:
296
+ add(["", *connection_lines(connection)])
297
+ code = 1 if "not ready" in (audio.verdict, transcription.verdict) else 0
298
+ return lines, code