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 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
@@ -0,0 +1,6 @@
1
+ """Support ``python -m tranpro``."""
2
+
3
+ from tranpro.cli import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
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
@@ -0,0 +1,6 @@
1
+ """Persistent transcript library primitives."""
2
+
3
+ from tranpro.library.database import Database
4
+ from tranpro.library.repository import LibraryRepository
5
+
6
+ __all__ = ["Database", "LibraryRepository"]