tranpro 0.1.0a1__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.
- tranpro/__init__.py +10 -0
- tranpro/__main__.py +6 -0
- tranpro/cli.py +412 -0
- tranpro/config.py +106 -0
- tranpro/library/__init__.py +6 -0
- tranpro/library/cli.py +255 -0
- tranpro/library/database.py +169 -0
- tranpro/library/fingerprint.py +19 -0
- tranpro/library/models.py +97 -0
- tranpro/library/repository.py +680 -0
- tranpro/library/schema.py +228 -0
- tranpro/paths.py +41 -0
- tranpro/transcription/__init__.py +6 -0
- tranpro/transcription/factory.py +73 -0
- tranpro/transcription/models.py +89 -0
- tranpro/transcription/parakeet.py +261 -0
- tranpro/transcription/probe.py +29 -0
- tranpro/transcription/render.py +117 -0
- tranpro/transcription/service.py +174 -0
- tranpro/transcription/whisper_cpp.py +146 -0
- tranpro-0.1.0a1.dist-info/METADATA +171 -0
- tranpro-0.1.0a1.dist-info/RECORD +26 -0
- tranpro-0.1.0a1.dist-info/WHEEL +5 -0
- tranpro-0.1.0a1.dist-info/entry_points.txt +2 -0
- tranpro-0.1.0a1.dist-info/licenses/LICENSE +21 -0
- tranpro-0.1.0a1.dist-info/top_level.txt +1 -0
tranpro/__init__.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""tranpro: a local-first transcript library."""
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
4
|
+
|
|
5
|
+
try:
|
|
6
|
+
__version__ = version("tranpro")
|
|
7
|
+
except PackageNotFoundError: # pragma: no cover - only outside packaging
|
|
8
|
+
__version__ = "0+unknown"
|
|
9
|
+
|
|
10
|
+
__all__ = ["__version__"]
|
tranpro/__main__.py
ADDED
tranpro/cli.py
ADDED
|
@@ -0,0 +1,412 @@
|
|
|
1
|
+
"""Command-line entry point for tranpro."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import shlex
|
|
7
|
+
import subprocess
|
|
8
|
+
import sys
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Annotated, Any
|
|
11
|
+
|
|
12
|
+
import typer
|
|
13
|
+
from rich.console import Console
|
|
14
|
+
from rich.progress import (
|
|
15
|
+
BarColumn,
|
|
16
|
+
Progress,
|
|
17
|
+
SpinnerColumn,
|
|
18
|
+
TaskID,
|
|
19
|
+
TaskProgressColumn,
|
|
20
|
+
TextColumn,
|
|
21
|
+
TimeElapsedColumn,
|
|
22
|
+
TimeRemainingColumn,
|
|
23
|
+
)
|
|
24
|
+
from typer.core import TyperGroup
|
|
25
|
+
|
|
26
|
+
from tranpro import __version__
|
|
27
|
+
from tranpro.config import DEFAULT_CONFIG, Config, ConfigError, initialize_config
|
|
28
|
+
from tranpro.library.cli import (
|
|
29
|
+
forget_recording,
|
|
30
|
+
library_app,
|
|
31
|
+
list_recordings,
|
|
32
|
+
open_recording,
|
|
33
|
+
restore_recording,
|
|
34
|
+
search_library,
|
|
35
|
+
show_recording,
|
|
36
|
+
)
|
|
37
|
+
from tranpro.library.database import Database, DatabaseError
|
|
38
|
+
from tranpro.library.repository import LibraryError, LibraryRepository
|
|
39
|
+
from tranpro.paths import AppPaths
|
|
40
|
+
from tranpro.transcription.factory import build_provider
|
|
41
|
+
from tranpro.transcription.models import (
|
|
42
|
+
TranscriptionError,
|
|
43
|
+
TranscriptionEvent,
|
|
44
|
+
TranscriptionProgress,
|
|
45
|
+
TranscriptionRequest,
|
|
46
|
+
TranscriptReplayStarted,
|
|
47
|
+
TranscriptSegmentFinalized,
|
|
48
|
+
)
|
|
49
|
+
from tranpro.transcription.render import format_timestamp
|
|
50
|
+
from tranpro.transcription.service import TranscriptionService
|
|
51
|
+
|
|
52
|
+
_AUDIO_SUFFIXES = {
|
|
53
|
+
".aac",
|
|
54
|
+
".aiff",
|
|
55
|
+
".flac",
|
|
56
|
+
".m4a",
|
|
57
|
+
".m4b",
|
|
58
|
+
".mp3",
|
|
59
|
+
".mp4",
|
|
60
|
+
".ogg",
|
|
61
|
+
".opus",
|
|
62
|
+
".wav",
|
|
63
|
+
".webm",
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class DefaultCommandGroup(TyperGroup):
|
|
68
|
+
"""Route path-like first arguments to the transcribe command."""
|
|
69
|
+
|
|
70
|
+
def parse_args(self, ctx: Any, args: list[str]) -> list[str]:
|
|
71
|
+
if args and self._looks_like_source(args[0]):
|
|
72
|
+
args.insert(0, "transcribe")
|
|
73
|
+
return super().parse_args(ctx, args)
|
|
74
|
+
|
|
75
|
+
def _looks_like_source(self, value: str) -> bool:
|
|
76
|
+
if value.startswith("-") or value in self.commands:
|
|
77
|
+
return False
|
|
78
|
+
path = Path(value).expanduser()
|
|
79
|
+
return (
|
|
80
|
+
value.isdigit() or path.exists() or path.suffix.lower() in _AUDIO_SUFFIXES
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class TerminalProgress:
|
|
85
|
+
"""Render provider progress without contaminating stdout pipelines."""
|
|
86
|
+
|
|
87
|
+
def __init__(self, *, enabled: bool, console: Console | None = None) -> None:
|
|
88
|
+
self.enabled = enabled
|
|
89
|
+
self._progress = Progress(
|
|
90
|
+
SpinnerColumn("dots"),
|
|
91
|
+
TextColumn("[progress.description]{task.description}"),
|
|
92
|
+
BarColumn(),
|
|
93
|
+
TaskProgressColumn(),
|
|
94
|
+
TimeElapsedColumn(),
|
|
95
|
+
TimeRemainingColumn(),
|
|
96
|
+
console=console or Console(stderr=True),
|
|
97
|
+
transient=False,
|
|
98
|
+
)
|
|
99
|
+
self._task_id: TaskID | None = None
|
|
100
|
+
self._total: float | None = None
|
|
101
|
+
|
|
102
|
+
def __enter__(self) -> TerminalProgress:
|
|
103
|
+
if self.enabled:
|
|
104
|
+
self._progress.start()
|
|
105
|
+
self._task_id = self._progress.add_task("Transcribing", total=None)
|
|
106
|
+
return self
|
|
107
|
+
|
|
108
|
+
def __exit__(self, *args: object) -> None:
|
|
109
|
+
if self.enabled:
|
|
110
|
+
self._progress.stop()
|
|
111
|
+
|
|
112
|
+
def emit(self, event: TranscriptionEvent) -> None:
|
|
113
|
+
if not self.enabled or self._task_id is None:
|
|
114
|
+
return
|
|
115
|
+
if isinstance(event, TranscriptionProgress):
|
|
116
|
+
total = max(1, event.total_ms)
|
|
117
|
+
completed = min(total, max(0, event.completed_ms))
|
|
118
|
+
self._total = total
|
|
119
|
+
self._progress.update(self._task_id, total=total, completed=completed)
|
|
120
|
+
elif isinstance(event, TranscriptReplayStarted):
|
|
121
|
+
self._progress.update(
|
|
122
|
+
self._task_id,
|
|
123
|
+
description=f"Reusing transcript {event.transcript_id}",
|
|
124
|
+
total=1,
|
|
125
|
+
completed=1,
|
|
126
|
+
)
|
|
127
|
+
self._total = 1
|
|
128
|
+
|
|
129
|
+
def complete(self, *, reused: bool) -> None:
|
|
130
|
+
if not self.enabled or self._task_id is None:
|
|
131
|
+
return
|
|
132
|
+
total = self._total or 1
|
|
133
|
+
self._progress.update(
|
|
134
|
+
self._task_id,
|
|
135
|
+
description="Reused" if reused else "Transcribed",
|
|
136
|
+
total=total,
|
|
137
|
+
completed=total,
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _stderr_is_terminal() -> bool:
|
|
142
|
+
return sys.stderr.isatty()
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
app = typer.Typer(
|
|
146
|
+
name="tp",
|
|
147
|
+
help="Local-first transcription and transcript management.",
|
|
148
|
+
no_args_is_help=True,
|
|
149
|
+
pretty_exceptions_enable=False,
|
|
150
|
+
context_settings={"help_option_names": ["-h", "--help"]},
|
|
151
|
+
cls=DefaultCommandGroup,
|
|
152
|
+
)
|
|
153
|
+
config_app = typer.Typer(
|
|
154
|
+
help="Inspect and edit tranpro configuration.",
|
|
155
|
+
invoke_without_command=True,
|
|
156
|
+
context_settings={"help_option_names": ["-h", "--help"]},
|
|
157
|
+
)
|
|
158
|
+
app.add_typer(config_app, name="config")
|
|
159
|
+
app.add_typer(library_app, name="library")
|
|
160
|
+
app.command("list")(list_recordings)
|
|
161
|
+
app.command("show")(show_recording)
|
|
162
|
+
app.command("search")(search_library)
|
|
163
|
+
app.command("forget")(forget_recording)
|
|
164
|
+
app.command("restore")(restore_recording)
|
|
165
|
+
app.command("open")(open_recording)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
@app.command("transcribe")
|
|
169
|
+
def transcribe(
|
|
170
|
+
source: Annotated[
|
|
171
|
+
Path, typer.Argument(help="Audio/video path or library recording ID.")
|
|
172
|
+
],
|
|
173
|
+
output: Annotated[
|
|
174
|
+
str | None,
|
|
175
|
+
typer.Option("--output", "-o", help="Markdown path, or - for stdout."),
|
|
176
|
+
] = None,
|
|
177
|
+
profile: Annotated[
|
|
178
|
+
str | None,
|
|
179
|
+
typer.Option("--profile", help="Named provider profile from config.toml."),
|
|
180
|
+
] = None,
|
|
181
|
+
provider: Annotated[
|
|
182
|
+
str | None, typer.Option("--provider", help="Override the profile provider.")
|
|
183
|
+
] = None,
|
|
184
|
+
model: Annotated[
|
|
185
|
+
str | None,
|
|
186
|
+
typer.Option("--model", help="Override the provider model or model path."),
|
|
187
|
+
] = None,
|
|
188
|
+
whisper_cli: Annotated[
|
|
189
|
+
Path | None,
|
|
190
|
+
typer.Option(
|
|
191
|
+
"--whisper-cli", help="Path to whisper-cli when using whisper-cpp."
|
|
192
|
+
),
|
|
193
|
+
] = None,
|
|
194
|
+
language: Annotated[
|
|
195
|
+
str | None,
|
|
196
|
+
typer.Option("--language", help="Language override supported by the provider."),
|
|
197
|
+
] = None,
|
|
198
|
+
no_save: Annotated[
|
|
199
|
+
bool,
|
|
200
|
+
typer.Option("--no-save", help="Do not add this run to the SQLite library."),
|
|
201
|
+
] = False,
|
|
202
|
+
retranscribe: Annotated[
|
|
203
|
+
bool,
|
|
204
|
+
typer.Option(
|
|
205
|
+
"--retranscribe", help="Run the model even if an equivalent run exists."
|
|
206
|
+
),
|
|
207
|
+
] = False,
|
|
208
|
+
force: Annotated[
|
|
209
|
+
bool,
|
|
210
|
+
typer.Option("--force", help="Replace an explicitly selected output path."),
|
|
211
|
+
] = False,
|
|
212
|
+
live: Annotated[
|
|
213
|
+
bool,
|
|
214
|
+
typer.Option(
|
|
215
|
+
"--live", help="Print finalized timestamped segments during transcription."
|
|
216
|
+
),
|
|
217
|
+
] = False,
|
|
218
|
+
no_progress: Annotated[
|
|
219
|
+
bool,
|
|
220
|
+
typer.Option(
|
|
221
|
+
"--no-progress", help="Disable the interactive transcription progress bar."
|
|
222
|
+
),
|
|
223
|
+
] = False,
|
|
224
|
+
trash_source: Annotated[
|
|
225
|
+
bool,
|
|
226
|
+
typer.Option(
|
|
227
|
+
"--trash-source",
|
|
228
|
+
"-d",
|
|
229
|
+
help="Move source to OS Trash after every requested output succeeds.",
|
|
230
|
+
),
|
|
231
|
+
] = False,
|
|
232
|
+
) -> None:
|
|
233
|
+
"""Transcribe one recording; this is also the default tp action."""
|
|
234
|
+
|
|
235
|
+
paths = AppPaths.for_user()
|
|
236
|
+
try:
|
|
237
|
+
if live and output == "-":
|
|
238
|
+
raise TranscriptionError("--live cannot be combined with --output -")
|
|
239
|
+
config = Config.load(paths)
|
|
240
|
+
repository = LibraryRepository(Database(paths))
|
|
241
|
+
source = _resolve_transcription_source(source, repository)
|
|
242
|
+
profile_name = profile or config.default_transcription_profile
|
|
243
|
+
profile_values = config.profile(profile_name)
|
|
244
|
+
selected_provider = build_provider(
|
|
245
|
+
profile_values,
|
|
246
|
+
provider_override=provider,
|
|
247
|
+
model_override=model,
|
|
248
|
+
whisper_cli_override=whisper_cli,
|
|
249
|
+
language_override=language,
|
|
250
|
+
live=live,
|
|
251
|
+
)
|
|
252
|
+
service = TranscriptionService(
|
|
253
|
+
provider=selected_provider,
|
|
254
|
+
repository=repository,
|
|
255
|
+
)
|
|
256
|
+
output_path = None
|
|
257
|
+
if output is not None and output != "-":
|
|
258
|
+
output_path = Path(output)
|
|
259
|
+
request = TranscriptionRequest(
|
|
260
|
+
source=source,
|
|
261
|
+
output=output_path,
|
|
262
|
+
output_to_stdout=output == "-",
|
|
263
|
+
save=not no_save,
|
|
264
|
+
retranscribe=retranscribe,
|
|
265
|
+
force=force,
|
|
266
|
+
trash_source=trash_source,
|
|
267
|
+
)
|
|
268
|
+
progress_enabled = not live and not no_progress and _stderr_is_terminal()
|
|
269
|
+
with TerminalProgress(enabled=progress_enabled) as progress:
|
|
270
|
+
event_sink = (
|
|
271
|
+
_render_live_event
|
|
272
|
+
if live
|
|
273
|
+
else (progress.emit if progress_enabled else None)
|
|
274
|
+
)
|
|
275
|
+
outcome = service.run(request, emit=event_sink)
|
|
276
|
+
progress.complete(reused=outcome.reused)
|
|
277
|
+
if request.output_to_stdout:
|
|
278
|
+
typer.echo(outcome.markdown, nl=False)
|
|
279
|
+
elif outcome.output_path is not None:
|
|
280
|
+
action = "Reused" if outcome.reused else "Created"
|
|
281
|
+
typer.echo(f"{action} transcript {outcome.transcript_id or 'unsaved'}")
|
|
282
|
+
typer.echo(f"Markdown: {outcome.output_path}")
|
|
283
|
+
service.finalize_trash(outcome)
|
|
284
|
+
if trash_source:
|
|
285
|
+
typer.echo(f"Moved source to Trash: {outcome.source}", err=True)
|
|
286
|
+
except (
|
|
287
|
+
ConfigError,
|
|
288
|
+
DatabaseError,
|
|
289
|
+
LibraryError,
|
|
290
|
+
TranscriptionError,
|
|
291
|
+
OSError,
|
|
292
|
+
) as error:
|
|
293
|
+
typer.echo(f"Error: {error}", err=True)
|
|
294
|
+
raise typer.Exit(code=1) from error
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def _resolve_transcription_source(source: Path, repository: LibraryRepository) -> Path:
|
|
298
|
+
"""Resolve a numeric recording ID while preserving real numeric paths."""
|
|
299
|
+
|
|
300
|
+
expanded = source.expanduser()
|
|
301
|
+
if expanded.exists() or not str(source).isdigit():
|
|
302
|
+
return expanded
|
|
303
|
+
recording = repository.get_recording(int(str(source)))
|
|
304
|
+
if recording.current_path is None:
|
|
305
|
+
raise TranscriptionError(
|
|
306
|
+
f"Recording {recording.id} has no current source file; "
|
|
307
|
+
"restore it before retranscribing"
|
|
308
|
+
)
|
|
309
|
+
return recording.current_path
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def _render_live_event(event: TranscriptionEvent) -> None:
|
|
313
|
+
if isinstance(event, TranscriptReplayStarted):
|
|
314
|
+
typer.echo(f"Replaying saved transcript {event.transcript_id}", err=True)
|
|
315
|
+
elif isinstance(event, TranscriptSegmentFinalized):
|
|
316
|
+
segment = event.segment
|
|
317
|
+
typer.echo(f"[{format_timestamp(segment.start_ms)}] {segment.text}")
|
|
318
|
+
elif isinstance(event, TranscriptionProgress):
|
|
319
|
+
return
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def version_callback(value: bool) -> None:
|
|
323
|
+
if value:
|
|
324
|
+
typer.echo(f"tranpro {__version__}")
|
|
325
|
+
raise typer.Exit()
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
@app.callback()
|
|
329
|
+
def root(
|
|
330
|
+
version: bool = typer.Option(
|
|
331
|
+
False,
|
|
332
|
+
"--version",
|
|
333
|
+
callback=version_callback,
|
|
334
|
+
is_eager=True,
|
|
335
|
+
help="Show the installed version and exit.",
|
|
336
|
+
),
|
|
337
|
+
) -> None:
|
|
338
|
+
"""Local-first transcription and transcript management."""
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
@config_app.callback()
|
|
342
|
+
def config_root(context: typer.Context) -> None:
|
|
343
|
+
"""Inspect and edit tranpro configuration."""
|
|
344
|
+
|
|
345
|
+
if context.invoked_subcommand is None:
|
|
346
|
+
typer.echo(context.get_help())
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
@app.command("paths")
|
|
350
|
+
def show_paths() -> None:
|
|
351
|
+
"""Show where tranpro keeps private application state."""
|
|
352
|
+
|
|
353
|
+
paths = AppPaths.for_user()
|
|
354
|
+
rows = (
|
|
355
|
+
("Data", paths.data_dir),
|
|
356
|
+
("Config", paths.config_file),
|
|
357
|
+
("Database", paths.database_file),
|
|
358
|
+
("Backups", paths.backup_dir),
|
|
359
|
+
("Cache", paths.cache_dir),
|
|
360
|
+
)
|
|
361
|
+
for label, path in rows:
|
|
362
|
+
typer.echo(f"{label:<9} {path}")
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
@config_app.command("show")
|
|
366
|
+
def show_config() -> None:
|
|
367
|
+
"""Print the active config, or the defaults when no file exists."""
|
|
368
|
+
|
|
369
|
+
paths = AppPaths.for_user()
|
|
370
|
+
try:
|
|
371
|
+
Config.load(paths)
|
|
372
|
+
except ConfigError as error:
|
|
373
|
+
raise typer.BadParameter(str(error)) from error
|
|
374
|
+
|
|
375
|
+
if paths.config_file.exists():
|
|
376
|
+
typer.echo(paths.config_file.read_text(encoding="utf-8"), nl=False)
|
|
377
|
+
else:
|
|
378
|
+
typer.echo("# Built-in defaults; run `tp config init` to save them.\n")
|
|
379
|
+
typer.echo(DEFAULT_CONFIG, nl=False)
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
@config_app.command("init")
|
|
383
|
+
def init_config() -> None:
|
|
384
|
+
"""Write the default config without replacing an existing file."""
|
|
385
|
+
|
|
386
|
+
path, created = initialize_config()
|
|
387
|
+
if created:
|
|
388
|
+
typer.echo(f"Created {path}")
|
|
389
|
+
else:
|
|
390
|
+
typer.echo(f"Already exists: {path}")
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
@config_app.command("edit")
|
|
394
|
+
def edit_config() -> None:
|
|
395
|
+
"""Open the user config in $VISUAL or $EDITOR."""
|
|
396
|
+
|
|
397
|
+
path, _ = initialize_config()
|
|
398
|
+
editor = os.environ.get("VISUAL") or os.environ.get("EDITOR")
|
|
399
|
+
if not editor:
|
|
400
|
+
raise typer.BadParameter("Set $VISUAL or $EDITOR before using config edit")
|
|
401
|
+
try:
|
|
402
|
+
subprocess.run([*shlex.split(editor), str(path)], check=True)
|
|
403
|
+
except (OSError, subprocess.CalledProcessError) as error:
|
|
404
|
+
raise typer.BadParameter(f"Could not open editor: {error}") from error
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def main() -> None:
|
|
408
|
+
app()
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
if __name__ == "__main__":
|
|
412
|
+
main()
|
tranpro/config.py
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""User configuration loading and initialization."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import tomllib
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from tranpro.paths import AppPaths
|
|
11
|
+
|
|
12
|
+
DEFAULT_CONFIG = """\
|
|
13
|
+
version = 1
|
|
14
|
+
|
|
15
|
+
[defaults]
|
|
16
|
+
transcription_profile = "local"
|
|
17
|
+
summary_profile = "local"
|
|
18
|
+
|
|
19
|
+
[profiles.local]
|
|
20
|
+
transcription_provider = "parakeet-mlx"
|
|
21
|
+
transcription_model = "mlx-community/parakeet-tdt-0.6b-v3"
|
|
22
|
+
summary_provider = "ollama"
|
|
23
|
+
summary_model = "gemma4:12b"
|
|
24
|
+
|
|
25
|
+
[profiles.openai]
|
|
26
|
+
transcription_provider = "openai"
|
|
27
|
+
transcription_model = "gpt-transcribe"
|
|
28
|
+
summary_provider = "openai"
|
|
29
|
+
summary_model = "gpt-6.1-terra"
|
|
30
|
+
|
|
31
|
+
[profiles.whisper]
|
|
32
|
+
transcription_provider = "whisper-cpp"
|
|
33
|
+
transcription_model = "/path/to/ggml-model.bin"
|
|
34
|
+
whisper_cli = "/path/to/whisper-cli"
|
|
35
|
+
language = "auto"
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class ConfigError(ValueError):
|
|
40
|
+
"""Raised when user configuration cannot be parsed or validated."""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(frozen=True, slots=True)
|
|
44
|
+
class Config:
|
|
45
|
+
"""Parsed configuration plus its source path."""
|
|
46
|
+
|
|
47
|
+
path: Path
|
|
48
|
+
values: dict[str, Any]
|
|
49
|
+
|
|
50
|
+
@classmethod
|
|
51
|
+
def load(cls, paths: AppPaths | None = None) -> Config:
|
|
52
|
+
app_paths = paths or AppPaths.for_user()
|
|
53
|
+
path = app_paths.config_file
|
|
54
|
+
if not path.exists():
|
|
55
|
+
return cls(path=path, values=parse_config(DEFAULT_CONFIG, path=path))
|
|
56
|
+
try:
|
|
57
|
+
content = path.read_text(encoding="utf-8")
|
|
58
|
+
except OSError as error:
|
|
59
|
+
raise ConfigError(f"Could not read {path}: {error}") from error
|
|
60
|
+
return cls(path=path, values=parse_config(content, path=path))
|
|
61
|
+
|
|
62
|
+
def profile(self, name: str) -> dict[str, Any]:
|
|
63
|
+
profiles = self.values.get("profiles")
|
|
64
|
+
if not isinstance(profiles, dict):
|
|
65
|
+
raise ConfigError(f"Config has no [profiles] table: {self.path}")
|
|
66
|
+
profile = profiles.get(name)
|
|
67
|
+
if not isinstance(profile, dict):
|
|
68
|
+
available = ", ".join(sorted(str(key) for key in profiles))
|
|
69
|
+
raise ConfigError(
|
|
70
|
+
f"Unknown profile {name!r}; available profiles: {available or 'none'}"
|
|
71
|
+
)
|
|
72
|
+
return dict(profile)
|
|
73
|
+
|
|
74
|
+
@property
|
|
75
|
+
def default_transcription_profile(self) -> str:
|
|
76
|
+
defaults = self.values.get("defaults")
|
|
77
|
+
if not isinstance(defaults, dict):
|
|
78
|
+
raise ConfigError(f"Config has no [defaults] table: {self.path}")
|
|
79
|
+
profile = defaults.get("transcription_profile")
|
|
80
|
+
if not isinstance(profile, str) or not profile:
|
|
81
|
+
raise ConfigError("defaults.transcription_profile must be a profile name")
|
|
82
|
+
return profile
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def parse_config(content: str, *, path: Path) -> dict[str, Any]:
|
|
86
|
+
try:
|
|
87
|
+
values = tomllib.loads(content)
|
|
88
|
+
except tomllib.TOMLDecodeError as error:
|
|
89
|
+
raise ConfigError(f"Invalid TOML in {path}: {error}") from error
|
|
90
|
+
|
|
91
|
+
if values.get("version") != 1:
|
|
92
|
+
raise ConfigError(f"Unsupported config version in {path}; expected version = 1")
|
|
93
|
+
return values
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def initialize_config(paths: AppPaths | None = None) -> tuple[Path, bool]:
|
|
97
|
+
"""Create the default config without overwriting an existing file."""
|
|
98
|
+
|
|
99
|
+
app_paths = paths or AppPaths.for_user()
|
|
100
|
+
app_paths.create_private_directories()
|
|
101
|
+
path = app_paths.config_file
|
|
102
|
+
if path.exists():
|
|
103
|
+
return path, False
|
|
104
|
+
path.write_text(DEFAULT_CONFIG, encoding="utf-8")
|
|
105
|
+
path.chmod(0o600)
|
|
106
|
+
return path, True
|