blitzline 0.1.1__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.
blitzline/__init__.py ADDED
@@ -0,0 +1,9 @@
1
+ """Identify the Blitzline package without starting the application.
2
+
3
+ Scope statement: expose package metadata only.
4
+ Included: the installed application version.
5
+ Excluded: CLI startup (cli.py), pipeline execution (core.py), and I/O.
6
+ Start here: core.run_pipeline provides the application API.
7
+ """
8
+
9
+ __version__ = "0.1.1"
blitzline/__main__.py ADDED
@@ -0,0 +1,12 @@
1
+ """Start the CLI when Python runs the Blitzline package as a module.
2
+
3
+ Scope statement: forward module execution to Click.
4
+ Included: the guarded console entry.
5
+ Excluded: command definitions (cli.py) and pipeline logic (core.py).
6
+ Start here: run `python -m blitzline --help` to see available commands.
7
+ """
8
+
9
+ from blitzline.cli import cli
10
+
11
+ if __name__ == "__main__":
12
+ cli()
blitzline/cli.py ADDED
@@ -0,0 +1,324 @@
1
+ """Present the recording-to-Anki pipeline as a small command-line interface.
2
+
3
+ Scope statement: translate explicit terminal choices into application calls.
4
+ Included: run/resume/export/check commands, option validation, concise progress,
5
+ artifact locations, dependency diagnostics, and documented exit statuses.
6
+ Excluded: stage algorithms (core.py/transcript.py/vocabulary.py), provider calls
7
+ (integrations/), persistence (storage.py), and export construction (exports/).
8
+ Start here: cli is the console entry; run starts the main pipeline.
9
+ Exit statuses: 0 success/intentional stop, 1 failed stage, 2 invalid input,
10
+ 3 awaiting review. Commands never prompt during processing.
11
+ """
12
+
13
+ import os
14
+ import shutil
15
+ from contextlib import contextmanager
16
+ from importlib.util import find_spec
17
+ from pathlib import Path
18
+
19
+ import click
20
+
21
+ from blitzline import __version__
22
+ from blitzline.config import get_config, llm_profile, resolve_language
23
+ from blitzline.core import STAGES, export_run, resume_pipeline, run_pipeline
24
+ from blitzline.integrations.blitzer import preflight
25
+ from blitzline.records import NeedsReview, PipelineError
26
+ from blitzline.storage import read_json
27
+
28
+ CONTEXT = {"help_option_names": ["-h", "--help"]}
29
+
30
+
31
+ @contextmanager
32
+ def errors():
33
+ """Convert expected failures into concise CLI messages and stable exit codes."""
34
+ try:
35
+ yield
36
+ except NeedsReview as error:
37
+ click.echo(str(error), err=True)
38
+ raise click.exceptions.Exit(3) from None
39
+ except (ValueError, KeyError, TypeError) as error:
40
+ raise click.UsageError(str(error)) from None
41
+ except (PipelineError, OSError) as error:
42
+ raise click.ClickException(str(error)) from None
43
+
44
+
45
+ def common_options(function):
46
+ """Attach shared explicit configuration and delivery overrides to commands."""
47
+ options = [
48
+ click.option(
49
+ "--deck-profile", help="Configured language/deck/card-type profile."
50
+ ),
51
+ click.option(
52
+ "--config", type=click.Path(exists=True, dir_okay=False, path_type=Path)
53
+ ),
54
+ click.option(
55
+ "--format", "output_format", type=click.Choice(["tsv", "apkg", "anki"])
56
+ ),
57
+ click.option("--clips", type=click.Choice(["audio", "video"])),
58
+ click.option("--proofread/--no-proofread", default=None),
59
+ click.option("--translate/--no-translate", default=None),
60
+ click.option("--allow-partial/--no-allow-partial", default=None),
61
+ ]
62
+ for option in options:
63
+ function = option(function)
64
+ return function
65
+
66
+
67
+ def transcript_options(function):
68
+ """Attach import, review, and stage-stop choices to run/resume commands."""
69
+ options = [
70
+ click.option(
71
+ "--asr-backend",
72
+ type=click.Choice(["local", "modal", "http"]),
73
+ help="Transcription backend; remote endpoints are configured in TOML.",
74
+ ),
75
+ click.option(
76
+ "--srt", type=click.Path(exists=True, dir_okay=False, path_type=Path)
77
+ ),
78
+ click.option(
79
+ "--asr-json", type=click.Path(exists=True, dir_okay=False, path_type=Path)
80
+ ),
81
+ click.option(
82
+ "--review",
83
+ "review_file",
84
+ type=click.Path(exists=True, dir_okay=False, path_type=Path),
85
+ ),
86
+ click.option("--stop-after", type=click.Choice(STAGES)),
87
+ ]
88
+ for option in options:
89
+ function = option(function)
90
+ return function
91
+
92
+
93
+ def overrides(
94
+ output_format=None,
95
+ clips=None,
96
+ proofread=None,
97
+ translate=None,
98
+ allow_partial=None,
99
+ asr_backend=None,
100
+ deck_profile=None,
101
+ ):
102
+ """Build nested non-None CLI choices for the configuration/application APIs."""
103
+ return {
104
+ "export": {"format": output_format, "deck_profile": deck_profile},
105
+ "asr": {"backend": asr_backend},
106
+ "media": {"kind": clips},
107
+ "run": {
108
+ "proofread": proofread,
109
+ "translate": translate,
110
+ "allow_partial": allow_partial,
111
+ },
112
+ }
113
+
114
+
115
+ def progress(message: str) -> None:
116
+ """Show a brief stage message on stderr, leaving stdout for results."""
117
+ click.echo(message, err=True)
118
+
119
+
120
+ def show_result(directory: Path) -> None:
121
+ """Print the run directory, state, card count, and completed artifact locations."""
122
+ manifest = read_json(directory / "manifest.json")
123
+ click.echo(f"Run: {directory}")
124
+ click.echo(f"State: {manifest['state']}; cards: {manifest.get('cards', 0)}")
125
+ if (directory / "artifacts" / "source.srt").is_file():
126
+ click.echo(f"Subtitles: {directory / 'artifacts' / 'source.srt'}")
127
+ if manifest["state"] == "complete":
128
+ click.echo(
129
+ f"Exports: {directory / 'exports' / manifest['settings']['export']['format']}"
130
+ )
131
+
132
+
133
+ @click.group(context_settings=CONTEXT)
134
+ @click.version_option(__version__)
135
+ def cli():
136
+ """Turn a foreign-language recording into subtitles and Anki vocabulary."""
137
+
138
+
139
+ @cli.command()
140
+ @click.argument("media", type=click.Path(exists=True, dir_okay=False, path_type=Path))
141
+ @click.option(
142
+ "--language",
143
+ "-l",
144
+ help="Blitzer three-letter language code; inferred from --deck-profile when omitted.",
145
+ )
146
+ @click.option("--output", "directory", type=click.Path(file_okay=False, path_type=Path))
147
+ @common_options
148
+ @transcript_options
149
+ def run(
150
+ media,
151
+ language,
152
+ directory,
153
+ config,
154
+ deck_profile,
155
+ output_format,
156
+ clips,
157
+ proofread,
158
+ translate,
159
+ allow_partial,
160
+ asr_backend,
161
+ srt,
162
+ asr_json,
163
+ review_file,
164
+ stop_after,
165
+ ):
166
+ """Process MEDIA, optionally starting from an existing subtitle/transcript."""
167
+ with errors():
168
+ settings = get_config(
169
+ config,
170
+ overrides(
171
+ output_format,
172
+ clips,
173
+ proofread,
174
+ translate,
175
+ allow_partial,
176
+ asr_backend,
177
+ deck_profile,
178
+ ),
179
+ )
180
+ result = run_pipeline(
181
+ media,
182
+ resolve_language(settings, language),
183
+ settings,
184
+ directory=directory,
185
+ srt=srt,
186
+ asr_json=asr_json,
187
+ review_file=review_file,
188
+ stop_after=stop_after,
189
+ progress=progress,
190
+ )
191
+ show_result(result)
192
+
193
+
194
+ @cli.command()
195
+ @click.argument(
196
+ "run_dir", type=click.Path(exists=True, file_okay=False, path_type=Path)
197
+ )
198
+ @common_options
199
+ @transcript_options
200
+ def resume(
201
+ run_dir,
202
+ config,
203
+ deck_profile,
204
+ output_format,
205
+ clips,
206
+ proofread,
207
+ translate,
208
+ allow_partial,
209
+ asr_backend,
210
+ srt,
211
+ asr_json,
212
+ review_file,
213
+ stop_after,
214
+ ):
215
+ """Continue a saved run with optional edited review or transcript inputs."""
216
+ with errors():
217
+ selected = get_config(config) if config else None
218
+ result = resume_pipeline(
219
+ run_dir,
220
+ settings=selected,
221
+ overrides=overrides(
222
+ output_format,
223
+ clips,
224
+ proofread,
225
+ translate,
226
+ allow_partial,
227
+ asr_backend,
228
+ deck_profile,
229
+ ),
230
+ srt=srt,
231
+ asr_json=asr_json,
232
+ review_file=review_file,
233
+ stop_after=stop_after,
234
+ progress=progress,
235
+ )
236
+ show_result(result)
237
+
238
+
239
+ @cli.command("export")
240
+ @click.argument(
241
+ "run_dir", type=click.Path(exists=True, file_okay=False, path_type=Path)
242
+ )
243
+ @click.option("--config", type=click.Path(exists=True, dir_okay=False, path_type=Path))
244
+ @click.option(
245
+ "--format",
246
+ "output_format",
247
+ type=click.Choice(["tsv", "apkg", "anki"]),
248
+ required=True,
249
+ )
250
+ @click.option("--allow-partial/--no-allow-partial", default=None)
251
+ @click.option(
252
+ "--deck-profile",
253
+ help="Configured destination deck profile for this run's language.",
254
+ )
255
+ def export_command(run_dir, config, output_format, allow_partial, deck_profile):
256
+ """Deliver existing validated cards as TSV, APKG, or directly to Anki."""
257
+ with errors():
258
+ result = export_run(
259
+ run_dir,
260
+ settings=get_config(config) if config else None,
261
+ overrides=overrides(
262
+ output_format=output_format,
263
+ allow_partial=allow_partial,
264
+ deck_profile=deck_profile,
265
+ ),
266
+ )
267
+ for path in result["paths"]:
268
+ click.echo(path)
269
+
270
+
271
+ @cli.command("check")
272
+ @click.option(
273
+ "--language", "-l", help="Also verify this Blitzer language pack and config."
274
+ )
275
+ @click.option("--config", type=click.Path(exists=True, dir_okay=False, path_type=Path))
276
+ def check_command(config, language):
277
+ """Show configured tools/providers and missing prerequisites without requests."""
278
+ with errors():
279
+ settings = get_config(config)
280
+ missing = []
281
+ for label, executable in (
282
+ ("Blitzer", settings["blitzer"]["executable"]),
283
+ ("FFmpeg", settings["media"]["ffmpeg"]),
284
+ ("FFprobe", settings["media"]["ffprobe"]),
285
+ ):
286
+ path = shutil.which(executable)
287
+ click.echo(f"{label}: {path or 'missing'}")
288
+ if not path and not label.startswith("ASR"):
289
+ missing.append(label)
290
+ for name, profile in settings["decks"].items():
291
+ click.echo(
292
+ f"Deck profile {name}: {profile['language']} -> {profile['deck']}; card types: {', '.join(profile['card_types'])}"
293
+ )
294
+ asr = settings["asr"]
295
+ click.echo(f"ASR backend: {asr['backend']} (not needed for imported SRT/JSON)")
296
+ if asr["backend"] == "local":
297
+ click.echo(f"WhisperX: {shutil.which(asr['executable']) or 'missing'}")
298
+ elif asr["backend"] == "modal":
299
+ click.echo(
300
+ f"Modal: {asr['modal_app']}/{asr['modal_function']}; SDK: {'available' if find_spec('modal') else 'install blitzline[modal]'}"
301
+ )
302
+ else:
303
+ click.echo(f"Remote ASR endpoint: {asr['endpoint']}")
304
+ if asr["api_key_env"]:
305
+ click.echo(
306
+ f"{asr['api_key_env']}: {'set' if os.environ.get(asr['api_key_env']) else 'missing'}"
307
+ )
308
+ click.echo(
309
+ f"APKG writer: {'available' if find_spec('genanki') else 'install blitzline[apkg]'}"
310
+ )
311
+ for stage in ("proofread", "translate", "review"):
312
+ profile = llm_profile(settings, stage)
313
+ if not profile:
314
+ click.echo(f"{stage}: no LLM profile")
315
+ continue
316
+ present = bool(os.environ.get(profile["api_key_env"]))
317
+ click.echo(
318
+ f"{stage}: {profile['provider']} / {profile['model']}; {profile['api_key_env']}: {'set' if present else 'missing'}"
319
+ )
320
+ if missing:
321
+ raise PipelineError("Missing required tools: " + ", ".join(missing))
322
+ if language:
323
+ preflight(language, settings["blitzer"])
324
+ click.echo(f"Blitzer {language}: ready")