textflowkit 0.1.3__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.
Files changed (46) hide show
  1. textflowkit/__init__.py +8 -0
  2. textflowkit/adapters/__init__.py +11 -0
  3. textflowkit/adapters/http_server.py +465 -0
  4. textflowkit/adapters/mcp_server.py +554 -0
  5. textflowkit/cli.py +473 -0
  6. textflowkit/core/__init__.py +5 -0
  7. textflowkit/core/batch.py +186 -0
  8. textflowkit/core/bind.py +66 -0
  9. textflowkit/core/cancel.py +19 -0
  10. textflowkit/core/checkpoint.py +389 -0
  11. textflowkit/core/diarize.py +229 -0
  12. textflowkit/core/engine.py +127 -0
  13. textflowkit/core/executor.py +301 -0
  14. textflowkit/core/jobs.py +241 -0
  15. textflowkit/core/model.py +98 -0
  16. textflowkit/core/paths.py +226 -0
  17. textflowkit/core/pipeline.py +368 -0
  18. textflowkit/core/retrieval.py +148 -0
  19. textflowkit/core/runner.py +146 -0
  20. textflowkit/core/service.py +146 -0
  21. textflowkit/core/sqlite_store.py +233 -0
  22. textflowkit/core/submission.py +220 -0
  23. textflowkit/core/timeutil.py +20 -0
  24. textflowkit/core/translate.py +244 -0
  25. textflowkit/render/__init__.py +191 -0
  26. textflowkit/render/docx.py +71 -0
  27. textflowkit/render/fonts/NotoSans.ttf +0 -0
  28. textflowkit/render/fonts/NotoSansArabic.ttf +0 -0
  29. textflowkit/render/fonts/NotoSansSC.ttf +0 -0
  30. textflowkit/render/fonts/OFL-NotoSans.txt +94 -0
  31. textflowkit/render/fonts/OFL-NotoSansSC.txt +93 -0
  32. textflowkit/render/fonts/README.md +19 -0
  33. textflowkit/render/markdown.py +33 -0
  34. textflowkit/render/pdf.py +136 -0
  35. textflowkit/render/srt.py +22 -0
  36. textflowkit/render/txt.py +19 -0
  37. textflowkit/render/vtt.py +20 -0
  38. textflowkit/sources/__init__.py +16 -0
  39. textflowkit/sources/acquire.py +437 -0
  40. textflowkit/sources/detect.py +200 -0
  41. textflowkit/sources/scratch.py +32 -0
  42. textflowkit-0.1.3.dist-info/METADATA +266 -0
  43. textflowkit-0.1.3.dist-info/RECORD +46 -0
  44. textflowkit-0.1.3.dist-info/WHEEL +4 -0
  45. textflowkit-0.1.3.dist-info/entry_points.txt +4 -0
  46. textflowkit-0.1.3.dist-info/licenses/LICENSE +203 -0
@@ -0,0 +1,66 @@
1
+ """Bind-safety guard for the HTTP surfaces.
2
+
3
+ Developer-mode HTTP and Streamable-HTTP MCP are unauthenticated. The JSON HTTP
4
+ adapter has a separate opt-in production Bearer-token profile. The dangerous
5
+ configuration is an unauthenticated, file-writing, network-fetching API bound
6
+ beyond loopback.
7
+
8
+ This guard does not add auth. It refuses that specific configuration unless the
9
+ operator says so explicitly, so the failure mode is a clear startup error rather
10
+ than a silently exposed service.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import ipaddress
16
+ import os
17
+
18
+ ENV_ALLOW_REMOTE = "TEXTFLOWKIT_ALLOW_REMOTE"
19
+
20
+ _LOOPBACK_NAMES = frozenset({"localhost", "localhost.localdomain", "::1"})
21
+
22
+
23
+ class UnsafeBindError(ValueError):
24
+ """Raised when a bind address would expose a service without explicit opt-in."""
25
+
26
+
27
+ def is_loopback_host(host: str) -> bool:
28
+ """True if `host` is loopback (or a loopback name).
29
+
30
+ A bare hostname that is not `localhost` is treated as non-loopback: it may
31
+ resolve anywhere, and we cannot prove otherwise cheaply.
32
+ """
33
+ if not host:
34
+ return False
35
+ candidate = host.strip().strip("[]").lower()
36
+ if candidate in _LOOPBACK_NAMES:
37
+ return True
38
+ try:
39
+ return ipaddress.ip_address(candidate).is_loopback
40
+ except ValueError:
41
+ return False
42
+
43
+
44
+ def remote_allowed(explicit: bool | None = None) -> bool:
45
+ """Whether exposing the service beyond loopback was explicitly permitted."""
46
+ if explicit is not None:
47
+ return explicit
48
+ raw = os.environ.get(ENV_ALLOW_REMOTE, "")
49
+ return raw.strip().lower() in {"1", "true", "yes", "on"}
50
+
51
+
52
+ def check_bind_safety(host: str, *, allow_remote: bool | None = None) -> None:
53
+ """Refuse a non-loopback bind unless it was explicitly requested.
54
+
55
+ Raises `UnsafeBindError` with an actionable message.
56
+ """
57
+ if is_loopback_host(host):
58
+ return
59
+ if remote_allowed(allow_remote):
60
+ return
61
+ raise UnsafeBindError(
62
+ f"refusing to bind to '{host}': developer HTTP/MCP is unauthenticated, "
63
+ "so binding beyond loopback would expose it on the network. "
64
+ "Bind to 127.0.0.1, or pass --allow-remote (or set "
65
+ f"{ENV_ALLOW_REMOTE}=1) if you have a gateway in front of it."
66
+ )
@@ -0,0 +1,19 @@
1
+ """The cancellation signal.
2
+
3
+ Lives in its own leaf module so lower layers (the source layer, which must abort
4
+ a download mid-flight) can re-raise it without importing the executor. Sources
5
+ cannot import `core.executor` - `core.pipeline` imports sources, so that would
6
+ be a cycle.
7
+
8
+ Anything that catches broad exceptions while doing cancellable work MUST re-raise
9
+ this first, or a cancellation silently becomes a failure.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+
15
+ class CancelledError(Exception):
16
+ """Raised when cancellation has been requested.
17
+
18
+ Callers must not treat this as a failure - it is an orderly stop.
19
+ """
@@ -0,0 +1,389 @@
1
+ """Resumable transcription checkpoints.
2
+
3
+ A checkpoint is stored on the existing `Job` record, so there is no second
4
+ persistence mechanism to keep consistent. The store owns durability; this module
5
+ owns the shape of the record, safe reading, and matching a request against a
6
+ candidate.
7
+
8
+ Two rules matter for correctness:
9
+
10
+ - A malformed checkpoint is treated as absent. A corrupt record must never make
11
+ resume crash; it means "start clean".
12
+ - A checkpoint is only reusable when source, model, and language match. Anything
13
+ less can silently mix transcripts from different runs.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import hashlib
19
+ import json
20
+ import os
21
+ from dataclasses import dataclass, field
22
+ from pathlib import Path
23
+ from typing import Any
24
+
25
+ from textflowkit.core.jobs import Job, JobState, JobStore
26
+ from textflowkit.core.model import Transcript
27
+ from textflowkit.core.paths import opened_file_path, resolve_input_path
28
+ from textflowkit.render import ensure_outputs
29
+
30
+ CHECKPOINT_VERSION = 2
31
+ RESUMABLE_STATES = frozenset(
32
+ {JobState.PENDING, JobState.RUNNING, JobState.ERROR, JobState.CANCELLED, JobState.DONE}
33
+ )
34
+
35
+
36
+ class CheckpointError(ValueError):
37
+ """Raised when explicitly creating a malformed checkpoint."""
38
+
39
+
40
+ @dataclass(slots=True)
41
+ class CheckpointRecord:
42
+ """A durable snapshot of completed pipeline work for one source."""
43
+
44
+ source: str
45
+ model: str
46
+ language: str | None = None
47
+ engine: str = "whisper"
48
+ device: str | None = None
49
+ options: dict[str, Any] = field(default_factory=dict)
50
+ finished_stages: list[str] = field(default_factory=list)
51
+ transcript: dict[str, Any] | None = None
52
+ media_path: str | None = None
53
+ audio_path: str | None = None
54
+ local_identity: dict[str, Any] | None = None
55
+ version: int = CHECKPOINT_VERSION
56
+
57
+ def to_dict(self) -> dict[str, Any]:
58
+ return {
59
+ "version": self.version,
60
+ "source": self.source,
61
+ "model": self.model,
62
+ "language": self.language,
63
+ "engine": self.engine,
64
+ "device": self.device,
65
+ "options": dict(self.options),
66
+ "finished_stages": list(self.finished_stages),
67
+ "transcript": self.transcript,
68
+ "media_path": self.media_path,
69
+ "audio_path": self.audio_path,
70
+ "local_identity": self.local_identity,
71
+ }
72
+
73
+ @classmethod
74
+ def from_dict(cls, data: dict[str, Any]) -> CheckpointRecord:
75
+ if not isinstance(data, dict):
76
+ raise CheckpointError("checkpoint is not an object")
77
+ # Records written before versioning was explicit are legacy v1, not v2.
78
+ version = data.get("version", 1)
79
+ if version not in {1, CHECKPOINT_VERSION}:
80
+ raise CheckpointError(f"unsupported checkpoint version: {version!r}")
81
+ source = data.get("source")
82
+ model = data.get("model")
83
+ if not isinstance(source, str) or not source:
84
+ raise CheckpointError("checkpoint is missing source")
85
+ if not isinstance(model, str) or not model:
86
+ raise CheckpointError("checkpoint is missing model")
87
+ language = data.get("language")
88
+ if language is not None and not isinstance(language, str):
89
+ raise CheckpointError("checkpoint language is invalid")
90
+ options = data.get("options") or {}
91
+ if not isinstance(options, dict):
92
+ raise CheckpointError("checkpoint options are invalid")
93
+ stages = data.get("finished_stages") or []
94
+ if not isinstance(stages, list) or not all(isinstance(s, str) for s in stages):
95
+ raise CheckpointError("checkpoint finished_stages are invalid")
96
+ transcript = data.get("transcript")
97
+ if not isinstance(transcript, dict):
98
+ # A checkpoint without a validated transcript cannot be resumed;
99
+ # treat it as corrupt rather than "start from nothing".
100
+ raise CheckpointError("checkpoint is missing a transcript")
101
+ # Validate nested transcript structure now; callers should never
102
+ # discover corruption halfway through a resumed pipeline.
103
+ Transcript.from_dict(transcript)
104
+ if "transcribe" not in stages:
105
+ raise CheckpointError("checkpoint has not finished transcription")
106
+ identity = data.get("local_identity") if version == CHECKPOINT_VERSION else None
107
+ if identity is not None and not _valid_local_identity(identity):
108
+ raise CheckpointError("checkpoint local source identity is invalid")
109
+ return cls(
110
+ version=version,
111
+ source=source,
112
+ model=model,
113
+ language=language,
114
+ engine=str(data.get("engine") or "whisper"),
115
+ device=data.get("device") if isinstance(data.get("device"), str) else None,
116
+ options=dict(options),
117
+ finished_stages=list(stages),
118
+ transcript=transcript,
119
+ media_path=_optional_str(data.get("media_path")),
120
+ audio_path=_optional_str(data.get("audio_path")),
121
+ local_identity=identity,
122
+ )
123
+
124
+
125
+ def _optional_str(value: Any) -> str | None:
126
+ return value if isinstance(value, str) and value else None
127
+
128
+
129
+ def _valid_local_identity(identity: Any) -> bool:
130
+ return (
131
+ isinstance(identity, dict)
132
+ and isinstance(identity.get("path"), str)
133
+ and bool(identity["path"])
134
+ and isinstance(identity.get("size"), int)
135
+ and identity["size"] >= 0
136
+ and isinstance(identity.get("sha256"), str)
137
+ and len(identity["sha256"]) == 64
138
+ and all(c in "0123456789abcdef" for c in identity["sha256"])
139
+ )
140
+
141
+
142
+ def is_local_source(source: str) -> bool:
143
+ return not source.startswith(("http://", "https://"))
144
+
145
+
146
+ def local_source_identity(
147
+ source: str,
148
+ *,
149
+ input_root: str | Path | None = None,
150
+ content_path: str | Path | None = None,
151
+ ) -> dict[str, Any]:
152
+ """Fingerprint bytes and a normalized source path, with a stable open handle.
153
+
154
+ ``content_path`` is the handle-verified staged copy when input confinement is
155
+ active; the logical identity still names the original source. A changed
156
+ source is rechecked after transcription before a checkpoint is published.
157
+ """
158
+ logical = resolve_input_path(source, root=input_root)
159
+ path = Path(content_path) if content_path is not None else logical
160
+ digest = hashlib.sha256()
161
+ with path.open("rb") as opened:
162
+ actual = opened_file_path(opened.fileno(), path)
163
+ if content_path is None and actual != logical:
164
+ raise ValueError("local source changed while it was opened")
165
+ before = os.fstat(opened.fileno())
166
+ while chunk := opened.read(1024 * 1024):
167
+ digest.update(chunk)
168
+ after = os.fstat(opened.fileno())
169
+ if (before.st_size, before.st_mtime_ns) != (after.st_size, after.st_mtime_ns):
170
+ raise ValueError("local source changed while it was fingerprinted")
171
+ return {"path": str(logical), "size": after.st_size, "sha256": digest.hexdigest()}
172
+
173
+
174
+ def validate_local_resume(
175
+ record: CheckpointRecord,
176
+ source: str,
177
+ *,
178
+ input_root: str | Path | None = None,
179
+ ) -> None:
180
+ """Fail closed for missing, changed, or pre-v2 local-source checkpoints."""
181
+ if not is_local_source(source):
182
+ return # URL bytes can change; URL resume is a separate explicit policy.
183
+ if record.version < CHECKPOINT_VERSION or record.local_identity is None:
184
+ raise ValueError("legacy local checkpoint has no fingerprint; resubmit without resume")
185
+ try:
186
+ current = local_source_identity(source, input_root=input_root)
187
+ except (FileNotFoundError, OSError) as exc:
188
+ raise ValueError("local source is missing or unreadable; resubmit without resume") from exc
189
+ if current != record.local_identity:
190
+ raise ValueError("local source changed since checkpoint; resubmit without resume")
191
+
192
+
193
+ def load_checkpoint(job: Job | None) -> CheckpointRecord | None:
194
+ """Return a valid checkpoint from a job, or None on absent/corrupt data."""
195
+ if job is None or job.checkpoint is None:
196
+ return None
197
+ try:
198
+ return CheckpointRecord.from_dict(job.checkpoint)
199
+ except (CheckpointError, TypeError, ValueError, KeyError):
200
+ return None
201
+
202
+
203
+ def parse_checkpoint(raw: Any) -> CheckpointRecord | None:
204
+ """Parse an untrusted checkpoint value, returning None instead of raising."""
205
+ if raw is None:
206
+ return None
207
+ if isinstance(raw, str):
208
+ try:
209
+ raw = json.loads(raw)
210
+ except (TypeError, ValueError):
211
+ return None
212
+ try:
213
+ return CheckpointRecord.from_dict(raw)
214
+ except (CheckpointError, TypeError, ValueError, KeyError):
215
+ return None
216
+
217
+
218
+ def checkpoint_for_request(
219
+ *,
220
+ source: str,
221
+ model: str,
222
+ language: str | None = None,
223
+ engine: str = "whisper",
224
+ device: str | None = None,
225
+ options: dict[str, Any] | None = None,
226
+ ) -> CheckpointRecord:
227
+ """Build a checkpoint keyed by the options that must match on resume."""
228
+ return CheckpointRecord(
229
+ source=source,
230
+ model=model,
231
+ language=language,
232
+ engine=engine,
233
+ device=device,
234
+ options=dict(options or {}),
235
+ )
236
+
237
+
238
+ def matches(
239
+ checkpoint: CheckpointRecord,
240
+ *,
241
+ source: str,
242
+ model: str,
243
+ language: str | None = None,
244
+ engine: str = "whisper",
245
+ device: str | None = None,
246
+ options: dict[str, Any] | None = None,
247
+ ) -> bool:
248
+ """Whether a checkpoint can be safely reused for this request."""
249
+ if checkpoint.source != source:
250
+ return False
251
+ if checkpoint.model != model:
252
+ return False
253
+ if checkpoint.language != language:
254
+ return False
255
+ if checkpoint.engine != engine:
256
+ return False
257
+ if checkpoint.device != device:
258
+ return False
259
+ return options is None or checkpoint.options == dict(options)
260
+
261
+
262
+ def find_resumable_checkpoint(
263
+ store: JobStore,
264
+ *,
265
+ source: str,
266
+ model: str,
267
+ language: str | None = None,
268
+ engine: str = "whisper",
269
+ device: str | None = None,
270
+ options: dict[str, Any] | None = None,
271
+ limit: int = 10_000,
272
+ ) -> tuple[Job, CheckpointRecord] | None:
273
+ """Find the newest job with a matching, valid checkpoint.
274
+
275
+ Interrupted jobs are marked ERROR by startup reaping, so terminal jobs are
276
+ included deliberately. A DONE job may be reused too; that turns a repeated
277
+ invocation into a cheap no-op rather than recomputing identical work.
278
+ """
279
+ for job in store.list(limit=limit):
280
+ if job.state not in RESUMABLE_STATES:
281
+ continue
282
+ record = load_checkpoint(job)
283
+ if record is None:
284
+ continue
285
+ if matches(
286
+ record,
287
+ source=source,
288
+ model=model,
289
+ language=language,
290
+ engine=engine,
291
+ device=device,
292
+ options=options,
293
+ ):
294
+ return job, record
295
+ return None
296
+
297
+
298
+ def prepare_resume(
299
+ store: JobStore,
300
+ job: Job,
301
+ checkpoint: CheckpointRecord | dict[str, Any],
302
+ ) -> tuple[Job, dict[str, Any]] | None:
303
+ """Reopen a resumed job for another run, or return None if it is DONE.
304
+
305
+ A checkpoint from an ERROR/CANCELLED job is durable work, but the job row is
306
+ terminal, so `run_job` would refuse to start. Explicit resume is the one
307
+ place allowed to un-terminal that row: reset it to PENDING (clearing the
308
+ stale error/cancellation flag) while leaving the checkpoint byte-for-byte
309
+ intact. A DONE job is a different case: the transcript and outputs are the
310
+ real deliverable, so resuming it is a no-op and callers should render from
311
+ the stored transcript instead.
312
+ """
313
+ current = store.get(job.id)
314
+ if current is None:
315
+ return None
316
+ if current.state is JobState.DONE:
317
+ return None
318
+ payload = checkpoint.to_dict() if isinstance(checkpoint, CheckpointRecord) else dict(checkpoint)
319
+ updated = store.update(
320
+ job.id,
321
+ state=JobState.PENDING,
322
+ progress="resuming",
323
+ error=None,
324
+ cancel_requested=False,
325
+ checkpoint=payload,
326
+ )
327
+ if updated is None:
328
+ return None
329
+ return updated, payload
330
+
331
+
332
+ def write_checkpoint(
333
+ store: JobStore,
334
+ job_id: str,
335
+ checkpoint: CheckpointRecord | dict[str, Any],
336
+ ) -> Job | None:
337
+ """Persist a checkpoint through the existing store update path."""
338
+ payload = checkpoint.to_dict() if isinstance(checkpoint, CheckpointRecord) else dict(checkpoint)
339
+ return store.update(job_id, checkpoint=payload)
340
+
341
+
342
+ def transcript_for_job(job: Job | None) -> Transcript | None:
343
+ """Return the stored transcript for a terminal job, or None if absent/corrupt."""
344
+ if job is None or job.transcript is None:
345
+ return None
346
+ try:
347
+ return Transcript.from_dict(job.transcript)
348
+ except (KeyError, TypeError, ValueError):
349
+ return None
350
+
351
+
352
+ def reusable_done_result(
353
+ store: JobStore,
354
+ job: Job,
355
+ *,
356
+ transcript: Transcript | None = None,
357
+ formats: list[str] | None = None,
358
+ output_dir: str | Path | None = None,
359
+ stem: str | None = None,
360
+ ) -> tuple[Transcript, list[Path]] | None:
361
+ """Return stored transcript/outputs for a DONE job when it can be reused.
362
+
363
+ This is the no-op half of explicit resume: a job that already reached DONE
364
+ must not be reopened or re-run. Callers use this before falling back to the
365
+ normal pipeline, and render any missing requested formats from the stored
366
+ transcript without touching acquisition or the engine.
367
+
368
+ Returns None when the job is not DONE or its transcript is unreadable, which
369
+ makes the caller start clean rather than presenting corrupt output as a
370
+ successful resume.
371
+ """
372
+ current = store.get(job.id)
373
+ if current is None or current.state is not JobState.DONE:
374
+ return None
375
+ parsed = transcript or transcript_for_job(current)
376
+ if parsed is None:
377
+ return None
378
+ if formats:
379
+ if not stem:
380
+ raise CheckpointError("stem is required when rendering reused outputs")
381
+ outputs = ensure_outputs(
382
+ parsed,
383
+ formats=formats,
384
+ output_dir=output_dir,
385
+ stem=stem,
386
+ existing=current.outputs,
387
+ )
388
+ return parsed, outputs
389
+ return parsed, [Path(path) for path in current.outputs]