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,8 @@
1
+ """textflowkit - cross-platform media transcription toolkit."""
2
+
3
+ from textflowkit.core.model import Segment, Transcript
4
+ from textflowkit.core.pipeline import PipelineError, transcribe
5
+
6
+ __version__ = "0.1.3"
7
+
8
+ __all__ = ["PipelineError", "Segment", "Transcript", "__version__", "transcribe"]
@@ -0,0 +1,11 @@
1
+ """Interface adapters over the textflowkit core.
2
+
3
+ Adapters are deliberately thin: they translate a transport into a call on
4
+ `textflowkit.core.runner` and translate the result back. No pipeline logic lives
5
+ here, so the MCP server, the HTTP API, and any future frontend cannot drift.
6
+
7
+ - `mcp_server` - Model Context Protocol (stdio or Streamable HTTP)
8
+ - `http_server` - JSON HTTP API for software products and web frontends
9
+ """
10
+
11
+ __all__ = ["http_server", "mcp_server"]
@@ -0,0 +1,465 @@
1
+ """HTTP adapter.
2
+
3
+ A small JSON API over the same core and job store the MCP adapter uses. This is
4
+ the door for software products and for the eventual website: it is deliberately
5
+ job-based so a long video never blocks a request.
6
+
7
+ Not started by default. Developer mode is unauthenticated and loopback-only by
8
+ default. The opt-in JSON HTTP production profile requires Bearer authentication
9
+ and a trusted egress proxy for URL jobs; Streamable-HTTP MCP is separate.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import hmac
15
+ import json
16
+ import os
17
+ import sys
18
+ import threading
19
+ import time
20
+ from typing import Annotated, Any
21
+
22
+ from textflowkit import __version__
23
+ from textflowkit.core.bind import ENV_ALLOW_REMOTE, UnsafeBindError, check_bind_safety
24
+ from textflowkit.core.executor import QueueFullError, get_default_executor
25
+ from textflowkit.core.jobs import JobState, get_default_store, validate_list_limit
26
+ from textflowkit.core.model import Transcript
27
+ from textflowkit.core.paths import (
28
+ UnsafeOutputPathError,
29
+ ensure_output_dir,
30
+ server_input_root,
31
+ )
32
+ from textflowkit.core.retrieval import page_segments, search_segments
33
+ from textflowkit.core.runner import transcript_for
34
+ from textflowkit.core.service import (
35
+ ENV_API_TOKEN,
36
+ ENV_MAX_REQUEST_BYTES,
37
+ ENV_RATE_PER_MINUTE,
38
+ ServiceConfigurationError,
39
+ enforce_output_limit,
40
+ positive_limit,
41
+ production_enabled,
42
+ service_work_root,
43
+ validate_production_config,
44
+ )
45
+ from textflowkit.core.submission import (
46
+ SubmissionRequest,
47
+ submit_batch,
48
+ submit_request,
49
+ )
50
+ from textflowkit.core.submission import (
51
+ resume_job as core_resume_job,
52
+ )
53
+ from textflowkit.render import (
54
+ SUPPORTED_FORMATS,
55
+ TEXT_FORMATS,
56
+ atomic_write_bytes,
57
+ render,
58
+ render_bytes,
59
+ )
60
+
61
+ try: # optional extra
62
+ from fastapi import FastAPI, HTTPException, Query, Request
63
+ from fastapi.responses import JSONResponse, PlainTextResponse
64
+ from pydantic import BaseModel, Field
65
+ except ImportError as exc: # pragma: no cover
66
+ raise ImportError(
67
+ "The HTTP adapter requires the 'http' extra. Install with: pip install 'textflowkit[http]'"
68
+ ) from exc
69
+
70
+ app = FastAPI(
71
+ title="textflowkit",
72
+ version=__version__,
73
+ description="Cross-platform media transcription API. Job-based: submit, poll, fetch.",
74
+ )
75
+
76
+ _RATE_LOCK = threading.Lock()
77
+ _RATE_BUCKETS: dict[str, tuple[float, int]] = {}
78
+
79
+
80
+ @app.middleware("http")
81
+ async def production_guard(request: Request, call_next):
82
+ try:
83
+ if not production_enabled():
84
+ return await call_next(request)
85
+ validate_production_config()
86
+ except ServiceConfigurationError as exc:
87
+ return JSONResponse({"error": str(exc)}, status_code=503)
88
+ supplied = request.headers.get("authorization", "")
89
+ expected = f"Bearer {os.environ[ENV_API_TOKEN]}"
90
+ if not hmac.compare_digest(supplied, expected):
91
+ return JSONResponse({"error": "unauthorized"}, status_code=401)
92
+
93
+ max_bytes = positive_limit(ENV_MAX_REQUEST_BYTES, 64 * 1024)
94
+ length = request.headers.get("content-length")
95
+ if length is not None and (not length.isdecimal() or int(length) > max_bytes):
96
+ return JSONResponse({"error": "request body too large"}, status_code=413)
97
+ # Do not call request.body() first: absent Content-Length, it buffers an
98
+ # arbitrarily large chunked body before the check can run. Cache only after
99
+ # incrementally enforcing the limit so call_next can replay it to FastAPI.
100
+ body = bytearray()
101
+ async for chunk in request.stream():
102
+ if len(body) + len(chunk) > max_bytes:
103
+ return JSONResponse({"error": "request body too large"}, status_code=413)
104
+ body.extend(chunk)
105
+ request._body = bytes(body)
106
+
107
+ rate = positive_limit(ENV_RATE_PER_MINUTE, 60)
108
+ peer = request.client.host if request.client else "unknown"
109
+ now = time.monotonic()
110
+ with _RATE_LOCK:
111
+ started, count = _RATE_BUCKETS.get(peer, (now, 0))
112
+ if now - started >= 60:
113
+ started, count = now, 0
114
+ if count >= rate:
115
+ return JSONResponse({"error": "rate limit exceeded"}, status_code=429)
116
+ _RATE_BUCKETS[peer] = started, count + 1
117
+ if len(_RATE_BUCKETS) > 10000:
118
+ _RATE_BUCKETS.clear()
119
+ return await call_next(request)
120
+
121
+
122
+ class TranscribeRequest(BaseModel):
123
+ source: str = Field(..., description="Media URL or local file path")
124
+ language: str | None = Field(None, description="ISO language code; auto-detected if omitted")
125
+ formats: list[str] = Field(default_factory=lambda: ["json", "srt", "txt"])
126
+ output_dir: str | None = Field(None, description="Directory for rendered files; omit for none")
127
+ model: str = Field("small", description="Whisper model size")
128
+ device: str | None = Field(None, description="cuda or cpu; auto-detected if omitted")
129
+ cookies_from_browser: str | None = None
130
+ diarize: bool = False
131
+ translate_to: str | None = None
132
+
133
+
134
+ class BatchRequest(BaseModel):
135
+ jobs: list[TranscribeRequest]
136
+ resume: bool = False
137
+
138
+
139
+ def _submission_request(req: TranscribeRequest) -> SubmissionRequest:
140
+ return SubmissionRequest(
141
+ **req.model_dump(), input_root=server_input_root(), work_dir=service_work_root()
142
+ )
143
+
144
+
145
+ @app.get("/health")
146
+ def health() -> dict[str, Any]:
147
+ """Liveness probe."""
148
+ return {"status": "ok", "version": __version__}
149
+
150
+
151
+ @app.get("/sources")
152
+ def sources() -> dict[str, Any]:
153
+ """Capabilities: platforms, input kinds, output formats."""
154
+ from textflowkit.sources.detect import PLATFORMS
155
+
156
+ return {
157
+ "platforms": sorted(PLATFORMS),
158
+ "input_kinds": ["local", "direct"],
159
+ "formats": list(SUPPORTED_FORMATS),
160
+ }
161
+
162
+
163
+ @app.post("/jobs", status_code=202)
164
+ def create_job(req: TranscribeRequest) -> dict[str, Any]:
165
+ """Submit a transcription job. Returns 202 with a job id immediately."""
166
+ store = get_default_store()
167
+ try:
168
+ job = submit_request(store, _submission_request(req))
169
+ except ValueError as exc:
170
+ raise HTTPException(status_code=422, detail=str(exc)) from exc
171
+ except QueueFullError as exc:
172
+ raise HTTPException(status_code=429, detail=str(exc)) from exc
173
+ return job.to_dict()
174
+
175
+
176
+ @app.post("/jobs/batch", status_code=202)
177
+ def create_batch(req: BatchRequest) -> dict[str, Any]:
178
+ """Queue multiple independent jobs through the same core contract."""
179
+ try:
180
+ requests = [_submission_request(item) for item in req.jobs]
181
+ except ValueError as exc:
182
+ raise HTTPException(status_code=422, detail=str(exc)) from exc
183
+ results = submit_batch(get_default_store(), requests, resume=req.resume)
184
+ return {"count": len(results), "jobs": results}
185
+
186
+
187
+ @app.post("/jobs/{job_id}/resume", status_code=202)
188
+ def resume_job(job_id: str) -> dict[str, Any]:
189
+ """Resume a durable interrupted job by its saved request and checkpoint."""
190
+ try:
191
+ job = core_resume_job(
192
+ get_default_store(), job_id,
193
+ input_root=str(server_input_root()) if server_input_root() else None,
194
+ work_dir=service_work_root(),
195
+ )
196
+ except QueueFullError as exc:
197
+ raise HTTPException(status_code=429, detail=str(exc)) from exc
198
+ except ValueError as exc:
199
+ raise HTTPException(status_code=409, detail=str(exc)) from exc
200
+ return job.to_dict()
201
+
202
+
203
+ @app.get("/jobs")
204
+ def list_jobs(limit: int = 20, state: str | None = None) -> dict[str, Any]:
205
+ """List recent jobs, newest first."""
206
+ try:
207
+ validate_list_limit(limit)
208
+ except ValueError as exc:
209
+ raise HTTPException(status_code=422, detail=str(exc)) from exc
210
+ store = get_default_store()
211
+ filter_state = None
212
+ if state:
213
+ try:
214
+ filter_state = JobState(state.lower())
215
+ except ValueError:
216
+ raise HTTPException(
217
+ status_code=422,
218
+ detail={"error": f"unknown state '{state}'",
219
+ "available_states": [s.value for s in JobState]},
220
+ ) from None
221
+ jobs = store.list(limit=limit, state=filter_state)
222
+ return {"count": len(jobs), "jobs": [j.to_dict() for j in jobs]}
223
+
224
+
225
+ @app.get("/jobs/{job_id}")
226
+ def get_job(job_id: str) -> dict[str, Any]:
227
+ """Lean job status; use the paged transcript endpoint for content."""
228
+ job = get_default_store().get(job_id)
229
+ if job is None:
230
+ raise HTTPException(status_code=404, detail=f"no job with id '{job_id}'")
231
+ return job.to_dict()
232
+
233
+
234
+ @app.post("/jobs/{job_id}/cancel")
235
+ def cancel_job(job_id: str) -> dict[str, Any]:
236
+ """Request cancellation. 202-style: accepted, then poll for state.
237
+
238
+ A queued job is cancelled immediately. A running job stops at its next stage
239
+ boundary and reports state 'cancelled' once it does.
240
+ """
241
+ store = get_default_store()
242
+ job = store.get(job_id)
243
+ if job is None:
244
+ raise HTTPException(status_code=404, detail=f"no job with id '{job_id}'")
245
+ if job.is_terminal:
246
+ return {
247
+ "job_id": job_id,
248
+ "cancelled": False,
249
+ "state": job.state.value,
250
+ "reason": f"job is already {job.state.value}",
251
+ }
252
+
253
+ accepted = get_default_executor().cancel(job_id)
254
+ latest = store.get(job_id)
255
+ return {
256
+ "job_id": job_id,
257
+ "cancelled": accepted,
258
+ "state": latest.state.value if latest is not None else job.state.value,
259
+ }
260
+
261
+
262
+ def _finished_transcript(job_id: str):
263
+ """Shared guard: 404/409/500 and return (job, transcript)."""
264
+ job = get_default_store().get(job_id)
265
+ if job is None:
266
+ raise HTTPException(status_code=404, detail=f"no job with id '{job_id}'")
267
+ if job.state is not JobState.DONE:
268
+ raise HTTPException(
269
+ status_code=409,
270
+ detail={"error": f"job not finished (state: {job.state.value})", "state": job.state.value},
271
+ )
272
+ tr = transcript_for(job)
273
+ if tr is None:
274
+ raise HTTPException(status_code=500, detail="job contains no transcript")
275
+ return job, tr
276
+
277
+
278
+ @app.get("/jobs/{job_id}/transcript")
279
+ def get_transcript(
280
+ job_id: str,
281
+ format: str = "json",
282
+ offset: int = 0,
283
+ limit: int | None = None,
284
+ start: float | None = None,
285
+ end: float | None = None,
286
+ ):
287
+ """Transcript for a completed job, optionally a slice.
288
+
289
+ `offset`/`limit` page through segments; `start`/`end` select a time range in
290
+ seconds. The JSON form reports total_segments and has_more.
291
+ """
292
+ job, tr = _finished_transcript(job_id)
293
+ if production_enabled():
294
+ limit = 100 if limit is None else limit
295
+ if limit > 500:
296
+ raise HTTPException(status_code=422, detail="transcript page limit must be <= 500")
297
+
298
+ fmt = format.lower().lstrip(".")
299
+ if fmt not in TEXT_FORMATS:
300
+ raise HTTPException(
301
+ status_code=422,
302
+ detail={
303
+ "error": f"'{format}' cannot be returned inline",
304
+ "available_formats": list(TEXT_FORMATS),
305
+ "hint": "binary formats (docx, pdf) are written to disk via /export",
306
+ },
307
+ )
308
+
309
+ try:
310
+ page = page_segments(tr, offset=offset, limit=limit, start=start, end=end)
311
+ except ValueError as exc:
312
+ raise HTTPException(status_code=422, detail=str(exc)) from exc
313
+
314
+ sliced = Transcript(
315
+ source=tr.source,
316
+ language=tr.language,
317
+ platform=tr.platform,
318
+ duration=tr.duration,
319
+ engine=tr.engine,
320
+ metadata=tr.metadata,
321
+ segments=page.segments,
322
+ )
323
+
324
+ if fmt == "json":
325
+ response = {
326
+ "job_id": job.id,
327
+ **page.as_dict(),
328
+ "transcript": sliced.to_dict(),
329
+ }
330
+ try:
331
+ enforce_output_limit(len(json.dumps(response, ensure_ascii=False).encode("utf-8")))
332
+ except ServiceConfigurationError as exc:
333
+ raise HTTPException(status_code=413, detail=str(exc)) from exc
334
+ return response
335
+ content = render(sliced, fmt)
336
+ try:
337
+ enforce_output_limit(len(content.encode("utf-8")))
338
+ except ServiceConfigurationError as exc:
339
+ raise HTTPException(status_code=413, detail=str(exc)) from exc
340
+ return PlainTextResponse(content)
341
+
342
+
343
+ @app.get("/jobs/{job_id}/search")
344
+ def search(job_id: str, q: str, limit: int = 20, context: int = 1,
345
+ case_sensitive: bool = False) -> dict[str, Any]:
346
+ """Search a completed transcript for a phrase."""
347
+ job, tr = _finished_transcript(job_id)
348
+ if production_enabled() and (limit > 500 or context > 20):
349
+ raise HTTPException(status_code=422, detail="search limit/context exceeds production cap")
350
+ try:
351
+ matches = search_segments(
352
+ tr, q, limit=limit, context=context, case_sensitive=case_sensitive
353
+ )
354
+ except ValueError as exc:
355
+ raise HTTPException(status_code=422, detail=str(exc)) from exc
356
+
357
+ response = {
358
+ "job_id": job.id,
359
+ "query": q,
360
+ "match_count": len(matches),
361
+ "matches": [
362
+ {
363
+ "index": m.index,
364
+ "start": m.segment.start,
365
+ "end": m.segment.end,
366
+ "text": m.segment.display_text(),
367
+ "context_before": [c.display_text() for c in m.context_before],
368
+ "context_after": [c.display_text() for c in m.context_after],
369
+ }
370
+ for m in matches
371
+ ],
372
+ }
373
+ try:
374
+ enforce_output_limit(len(json.dumps(response, ensure_ascii=False).encode("utf-8")))
375
+ except ServiceConfigurationError as exc:
376
+ raise HTTPException(status_code=413, detail=str(exc)) from exc
377
+ return response
378
+
379
+
380
+ @app.post("/jobs/{job_id}/export")
381
+ def export(
382
+ job_id: str,
383
+ formats: Annotated[
384
+ list[str] | None,
385
+ Query(description="Repeat for each format, e.g. ?formats=docx&formats=pdf"),
386
+ ] = None,
387
+ output_dir: str = ".",
388
+ ) -> dict[str, Any]:
389
+ """Write a completed transcript to disk.
390
+
391
+ `formats` is declared as an explicit query parameter: a bare `list[str]` on a
392
+ POST is treated by FastAPI as a request *body* field, which meant the argument
393
+ was silently ignored and the defaults were always used.
394
+ """
395
+ job = get_default_store().get(job_id)
396
+ if job is None:
397
+ raise HTTPException(status_code=404, detail=f"no job with id '{job_id}'")
398
+ if job.state is not JobState.DONE:
399
+ raise HTTPException(status_code=409, detail={"error": "job not finished"})
400
+ tr = transcript_for(job)
401
+ if tr is None:
402
+ raise HTTPException(status_code=500, detail="job contains no transcript")
403
+
404
+ fmt_list = formats or ["srt", "vtt", "txt", "json"]
405
+ normalized = [f.lower().lstrip(".") for f in fmt_list]
406
+ bad = [f for f in normalized if f not in SUPPORTED_FORMATS]
407
+ if bad:
408
+ raise HTTPException(status_code=422, detail=f"unsupported format(s): {', '.join(bad)}")
409
+ if len(normalized) != len(set(normalized)):
410
+ raise HTTPException(status_code=422, detail="duplicate output format")
411
+ try:
412
+ rendered = [(f, render_bytes(tr, f, title=job.id)) for f in normalized]
413
+ except (ValueError, ImportError) as exc:
414
+ raise HTTPException(status_code=422, detail=str(exc)) from exc
415
+ try:
416
+ out = ensure_output_dir(output_dir)
417
+ except UnsafeOutputPathError as exc:
418
+ raise HTTPException(status_code=422, detail=str(exc)) from exc
419
+
420
+ written = []
421
+ for norm, content in rendered:
422
+ path = out / f"{job.id}.{norm}"
423
+ atomic_write_bytes(path, content, replace=True)
424
+ written.append(str(path))
425
+ return {"job_id": job.id, "written": written}
426
+
427
+
428
+ def main(argv: list[str] | None = None) -> int:
429
+ import argparse
430
+
431
+ import uvicorn
432
+
433
+ parser = argparse.ArgumentParser(
434
+ prog="textflowkit-http", description="textflowkit HTTP API"
435
+ )
436
+ parser.add_argument("--host", default="127.0.0.1", help="Bind host (default 127.0.0.1)")
437
+ parser.add_argument("--port", type=int, default=8767, help="Bind port (default 8767)")
438
+ parser.add_argument(
439
+ "--allow-remote",
440
+ action="store_true",
441
+ help=(
442
+ "permit binding to a non-loopback address. Developer mode has no "
443
+ "authentication; use a gateway or the production profile. "
444
+ f"({ENV_ALLOW_REMOTE}=1 also works)"
445
+ ),
446
+ )
447
+ parser.add_argument("--version", action="version", version=f"textflowkit-http {__version__}")
448
+ args = parser.parse_args(argv)
449
+
450
+ try:
451
+ validate_production_config()
452
+ check_bind_safety(args.host, allow_remote=args.allow_remote or None)
453
+ except (UnsafeBindError, ServiceConfigurationError) as exc:
454
+ print(f"error: {exc}", file=sys.stderr)
455
+ return 2
456
+
457
+ uvicorn.run(app, host=args.host, port=args.port)
458
+ return 0
459
+
460
+
461
+ if __name__ == "__main__":
462
+ raise SystemExit(main())
463
+
464
+
465
+