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.
- textflowkit/__init__.py +8 -0
- textflowkit/adapters/__init__.py +11 -0
- textflowkit/adapters/http_server.py +465 -0
- textflowkit/adapters/mcp_server.py +554 -0
- textflowkit/cli.py +473 -0
- textflowkit/core/__init__.py +5 -0
- textflowkit/core/batch.py +186 -0
- textflowkit/core/bind.py +66 -0
- textflowkit/core/cancel.py +19 -0
- textflowkit/core/checkpoint.py +389 -0
- textflowkit/core/diarize.py +229 -0
- textflowkit/core/engine.py +127 -0
- textflowkit/core/executor.py +301 -0
- textflowkit/core/jobs.py +241 -0
- textflowkit/core/model.py +98 -0
- textflowkit/core/paths.py +226 -0
- textflowkit/core/pipeline.py +368 -0
- textflowkit/core/retrieval.py +148 -0
- textflowkit/core/runner.py +146 -0
- textflowkit/core/service.py +146 -0
- textflowkit/core/sqlite_store.py +233 -0
- textflowkit/core/submission.py +220 -0
- textflowkit/core/timeutil.py +20 -0
- textflowkit/core/translate.py +244 -0
- textflowkit/render/__init__.py +191 -0
- textflowkit/render/docx.py +71 -0
- textflowkit/render/fonts/NotoSans.ttf +0 -0
- textflowkit/render/fonts/NotoSansArabic.ttf +0 -0
- textflowkit/render/fonts/NotoSansSC.ttf +0 -0
- textflowkit/render/fonts/OFL-NotoSans.txt +94 -0
- textflowkit/render/fonts/OFL-NotoSansSC.txt +93 -0
- textflowkit/render/fonts/README.md +19 -0
- textflowkit/render/markdown.py +33 -0
- textflowkit/render/pdf.py +136 -0
- textflowkit/render/srt.py +22 -0
- textflowkit/render/txt.py +19 -0
- textflowkit/render/vtt.py +20 -0
- textflowkit/sources/__init__.py +16 -0
- textflowkit/sources/acquire.py +437 -0
- textflowkit/sources/detect.py +200 -0
- textflowkit/sources/scratch.py +32 -0
- textflowkit-0.1.3.dist-info/METADATA +266 -0
- textflowkit-0.1.3.dist-info/RECORD +46 -0
- textflowkit-0.1.3.dist-info/WHEEL +4 -0
- textflowkit-0.1.3.dist-info/entry_points.txt +4 -0
- textflowkit-0.1.3.dist-info/licenses/LICENSE +203 -0
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""Opt-in production profile; localhost owner mode stays unconfined."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
import os
|
|
7
|
+
import subprocess
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
ENV_PROFILE = "TEXTFLOWKIT_PROFILE"
|
|
11
|
+
ENV_API_TOKEN = "TEXTFLOWKIT_API_TOKEN"
|
|
12
|
+
ENV_WORK_ROOT = "TEXTFLOWKIT_WORK_ROOT"
|
|
13
|
+
ENV_MAX_REQUEST_BYTES = "TEXTFLOWKIT_MAX_REQUEST_BYTES"
|
|
14
|
+
ENV_RATE_PER_MINUTE = "TEXTFLOWKIT_RATE_PER_MINUTE"
|
|
15
|
+
ENV_MAX_DURATION_SECONDS = "TEXTFLOWKIT_MAX_DURATION_SECONDS"
|
|
16
|
+
ENV_MAX_OUTPUT_BYTES = "TEXTFLOWKIT_MAX_OUTPUT_BYTES"
|
|
17
|
+
ENV_MAX_MEDIA_BYTES = "TEXTFLOWKIT_MAX_MEDIA_BYTES"
|
|
18
|
+
ENV_EGRESS_PROXY = "TEXTFLOWKIT_EGRESS_PROXY"
|
|
19
|
+
ENV_FFMPEG_TIMEOUT_SECONDS = "TEXTFLOWKIT_FFMPEG_TIMEOUT_SECONDS"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class ServiceConfigurationError(ValueError):
|
|
23
|
+
"""The production profile cannot safely start with this configuration."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def production_enabled() -> bool:
|
|
27
|
+
value = os.environ.get(ENV_PROFILE, "developer").strip().lower()
|
|
28
|
+
if value not in {"developer", "production"}:
|
|
29
|
+
raise ServiceConfigurationError(
|
|
30
|
+
f"{ENV_PROFILE} must be 'developer' or 'production', not '{value}'"
|
|
31
|
+
)
|
|
32
|
+
return value == "production"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def positive_limit(name: str, default: int) -> int:
|
|
36
|
+
raw = os.environ.get(name)
|
|
37
|
+
try:
|
|
38
|
+
value = int(raw) if raw else default
|
|
39
|
+
except ValueError as exc:
|
|
40
|
+
raise ServiceConfigurationError(f"{name} must be a positive integer") from exc
|
|
41
|
+
if value <= 0:
|
|
42
|
+
raise ServiceConfigurationError(f"{name} must be a positive integer")
|
|
43
|
+
return value
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def validate_production_config() -> None:
|
|
47
|
+
"""Fail closed before handling any production HTTP request."""
|
|
48
|
+
if not production_enabled():
|
|
49
|
+
return
|
|
50
|
+
from textflowkit.core.paths import ENV_INPUT_ROOT, ENV_OUTPUT_ROOT
|
|
51
|
+
|
|
52
|
+
required = (ENV_API_TOKEN, ENV_INPUT_ROOT, ENV_OUTPUT_ROOT, "TEXTFLOWKIT_DB", ENV_WORK_ROOT)
|
|
53
|
+
missing = [name for name in required if not os.environ.get(name)]
|
|
54
|
+
if missing:
|
|
55
|
+
raise ServiceConfigurationError(
|
|
56
|
+
f"production profile requires: {', '.join(missing)}"
|
|
57
|
+
)
|
|
58
|
+
if len(os.environ[ENV_API_TOKEN]) < 16:
|
|
59
|
+
raise ServiceConfigurationError(f"{ENV_API_TOKEN} must have at least 16 characters")
|
|
60
|
+
if os.environ["TEXTFLOWKIT_DB"] == ":memory:":
|
|
61
|
+
raise ServiceConfigurationError("production requires an on-disk TEXTFLOWKIT_DB")
|
|
62
|
+
from textflowkit.core.executor import get_default_executor
|
|
63
|
+
from textflowkit.core.jobs import get_default_store
|
|
64
|
+
from textflowkit.core.sqlite_store import SqliteJobStore
|
|
65
|
+
|
|
66
|
+
store = get_default_store()
|
|
67
|
+
db_path = Path(os.environ["TEXTFLOWKIT_DB"]).expanduser().resolve()
|
|
68
|
+
if not isinstance(store, SqliteJobStore) or Path(store.path).resolve() != db_path:
|
|
69
|
+
raise ServiceConfigurationError("production requires the active store to use TEXTFLOWKIT_DB")
|
|
70
|
+
if get_default_executor().store is not store:
|
|
71
|
+
raise ServiceConfigurationError("production executor and durable store must match")
|
|
72
|
+
input_root = Path(os.environ[ENV_INPUT_ROOT]).expanduser().resolve()
|
|
73
|
+
if not input_root.is_dir():
|
|
74
|
+
raise ServiceConfigurationError(f"{ENV_INPUT_ROOT} must name an existing directory")
|
|
75
|
+
for name in (ENV_OUTPUT_ROOT, ENV_WORK_ROOT):
|
|
76
|
+
path = Path(os.environ[name]).expanduser().resolve()
|
|
77
|
+
path.mkdir(parents=True, exist_ok=True)
|
|
78
|
+
if not path.is_dir():
|
|
79
|
+
raise ServiceConfigurationError(f"{name} must name a directory")
|
|
80
|
+
positive_limit(ENV_MAX_REQUEST_BYTES, 64 * 1024)
|
|
81
|
+
positive_limit(ENV_RATE_PER_MINUTE, 60)
|
|
82
|
+
positive_limit(ENV_MAX_DURATION_SECONDS, 4 * 3600)
|
|
83
|
+
positive_limit(ENV_MAX_OUTPUT_BYTES, 50 * 1024 * 1024)
|
|
84
|
+
positive_limit(ENV_MAX_MEDIA_BYTES, 1024 * 1024 * 1024)
|
|
85
|
+
positive_limit(ENV_FFMPEG_TIMEOUT_SECONDS, 600)
|
|
86
|
+
positive_limit("TEXTFLOWKIT_MAX_PENDING_JOBS", 100)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def service_work_root() -> str | None:
|
|
90
|
+
if not production_enabled():
|
|
91
|
+
return None
|
|
92
|
+
validate_production_config()
|
|
93
|
+
return str(Path(os.environ[ENV_WORK_ROOT]).expanduser().resolve())
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _probe_duration(media: Path) -> float | None:
|
|
97
|
+
"""Return known duration using a bounded probe; unknown is handled at decode."""
|
|
98
|
+
from textflowkit.sources.acquire import require_tool
|
|
99
|
+
|
|
100
|
+
ffprobe = require_tool("ffprobe")
|
|
101
|
+
try:
|
|
102
|
+
proc = subprocess.run(
|
|
103
|
+
[ffprobe, "-v", "error", "-show_entries", "format=duration",
|
|
104
|
+
"-of", "default=noprint_wrappers=1:nokey=1", str(media)],
|
|
105
|
+
capture_output=True, text=True, timeout=30, check=False,
|
|
106
|
+
)
|
|
107
|
+
except subprocess.TimeoutExpired as exc:
|
|
108
|
+
raise ServiceConfigurationError("ffprobe timed out before decode") from exc
|
|
109
|
+
if proc.returncode != 0:
|
|
110
|
+
return None
|
|
111
|
+
try:
|
|
112
|
+
duration = float(proc.stdout.strip())
|
|
113
|
+
except ValueError:
|
|
114
|
+
return None
|
|
115
|
+
return duration if math.isfinite(duration) and duration >= 0 else None
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def enforce_predecode_limits(media: Path) -> None:
|
|
119
|
+
"""Reject known oversize/overlong media before launching full extraction."""
|
|
120
|
+
if not production_enabled():
|
|
121
|
+
return
|
|
122
|
+
maximum = positive_limit(ENV_MAX_MEDIA_BYTES, 1024 * 1024 * 1024)
|
|
123
|
+
if media.stat().st_size > maximum:
|
|
124
|
+
raise ServiceConfigurationError("media exceeds the configured size limit")
|
|
125
|
+
duration = _probe_duration(media)
|
|
126
|
+
if duration is not None and duration > positive_limit(ENV_MAX_DURATION_SECONDS, 4 * 3600):
|
|
127
|
+
raise ServiceConfigurationError("source duration exceeds the configured limit")
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def enforce_media_limits(media: Path, audio: Path) -> None:
|
|
131
|
+
"""Defense in depth after bounded acquisition and decode."""
|
|
132
|
+
if not production_enabled():
|
|
133
|
+
return
|
|
134
|
+
maximum = positive_limit(ENV_MAX_MEDIA_BYTES, 1024 * 1024 * 1024)
|
|
135
|
+
if media.stat().st_size > maximum or audio.stat().st_size > maximum:
|
|
136
|
+
raise ServiceConfigurationError("media exceeds the configured size limit")
|
|
137
|
+
duration = _probe_duration(audio)
|
|
138
|
+
if duration is None:
|
|
139
|
+
raise ServiceConfigurationError("cannot verify source duration with ffprobe")
|
|
140
|
+
if duration > positive_limit(ENV_MAX_DURATION_SECONDS, 4 * 3600):
|
|
141
|
+
raise ServiceConfigurationError("source duration exceeds the configured limit")
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def enforce_output_limit(size: int) -> None:
|
|
145
|
+
if production_enabled() and size > positive_limit(ENV_MAX_OUTPUT_BYTES, 50 * 1024 * 1024):
|
|
146
|
+
raise ServiceConfigurationError("rendered output exceeds the configured size limit")
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
"""Durable job store backed by SQLite.
|
|
2
|
+
|
|
3
|
+
Chosen over a hand-rolled file format because SQLite is stdlib, transactional,
|
|
4
|
+
and already safe for concurrent access. One connection guarded by a lock is
|
|
5
|
+
sufficient at this scale and keeps the implementation obvious.
|
|
6
|
+
|
|
7
|
+
Ordering uses the table's AUTOINCREMENT `seq`, not `created_at` - same reasoning
|
|
8
|
+
as the in-memory store: wall-clock ordering is not portable across platforms.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import sqlite3
|
|
15
|
+
import threading
|
|
16
|
+
import time
|
|
17
|
+
import uuid
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
from textflowkit.core.jobs import Job, JobState, JobStore
|
|
22
|
+
|
|
23
|
+
_SCHEMA = """
|
|
24
|
+
CREATE TABLE IF NOT EXISTS jobs (
|
|
25
|
+
seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
26
|
+
id TEXT NOT NULL UNIQUE,
|
|
27
|
+
source TEXT NOT NULL,
|
|
28
|
+
state TEXT NOT NULL,
|
|
29
|
+
created_at REAL NOT NULL,
|
|
30
|
+
updated_at REAL NOT NULL,
|
|
31
|
+
progress TEXT NOT NULL DEFAULT '',
|
|
32
|
+
error TEXT,
|
|
33
|
+
transcript TEXT,
|
|
34
|
+
outputs TEXT NOT NULL DEFAULT '[]',
|
|
35
|
+
cancel_requested INTEGER NOT NULL DEFAULT 0,
|
|
36
|
+
checkpoint TEXT,
|
|
37
|
+
request TEXT
|
|
38
|
+
);
|
|
39
|
+
CREATE INDEX IF NOT EXISTS idx_jobs_state ON jobs(state);
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
# Columns a caller may patch through update().
|
|
43
|
+
_MUTABLE = frozenset(
|
|
44
|
+
{
|
|
45
|
+
"state",
|
|
46
|
+
"progress",
|
|
47
|
+
"error",
|
|
48
|
+
"transcript",
|
|
49
|
+
"outputs",
|
|
50
|
+
"cancel_requested",
|
|
51
|
+
"checkpoint",
|
|
52
|
+
"request",
|
|
53
|
+
}
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
_CHECKPOINT_COLUMN = "checkpoint"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _row_to_job(row: sqlite3.Row) -> Job:
|
|
60
|
+
transcript = json.loads(row["transcript"]) if row["transcript"] else None
|
|
61
|
+
outputs = json.loads(row["outputs"]) if row["outputs"] else []
|
|
62
|
+
keys = set(row.keys())
|
|
63
|
+
raw_checkpoint = row["checkpoint"] if "checkpoint" in keys else None
|
|
64
|
+
checkpoint = json.loads(raw_checkpoint) if raw_checkpoint else None
|
|
65
|
+
raw_request = row["request"] if "request" in keys else None
|
|
66
|
+
request = json.loads(raw_request) if raw_request else None
|
|
67
|
+
return Job(
|
|
68
|
+
id=row["id"],
|
|
69
|
+
source=row["source"],
|
|
70
|
+
state=JobState(row["state"]),
|
|
71
|
+
created_at=row["created_at"],
|
|
72
|
+
updated_at=row["updated_at"],
|
|
73
|
+
progress=row["progress"] or "",
|
|
74
|
+
error=row["error"],
|
|
75
|
+
transcript=transcript,
|
|
76
|
+
outputs=list(outputs),
|
|
77
|
+
cancel_requested=bool(row["cancel_requested"]),
|
|
78
|
+
checkpoint=checkpoint,
|
|
79
|
+
request=request,
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class SqliteJobStore(JobStore):
|
|
84
|
+
"""Durable job store. Jobs survive process restart."""
|
|
85
|
+
|
|
86
|
+
def __init__(self, path: str | Path, *, max_jobs: int = 200) -> None:
|
|
87
|
+
self.path = str(path)
|
|
88
|
+
# ":memory:" is allowed and useful for tests.
|
|
89
|
+
if self.path != ":memory:":
|
|
90
|
+
Path(self.path).expanduser().parent.mkdir(parents=True, exist_ok=True)
|
|
91
|
+
self._lock = threading.RLock()
|
|
92
|
+
self._conn = sqlite3.connect(self.path, check_same_thread=False)
|
|
93
|
+
self._conn.row_factory = sqlite3.Row
|
|
94
|
+
self._conn.execute("PRAGMA journal_mode=WAL")
|
|
95
|
+
self._conn.execute("PRAGMA foreign_keys=ON")
|
|
96
|
+
with self._lock:
|
|
97
|
+
self._conn.executescript(_SCHEMA)
|
|
98
|
+
self._migrate_locked()
|
|
99
|
+
self._conn.commit()
|
|
100
|
+
self._max_jobs = max_jobs
|
|
101
|
+
|
|
102
|
+
# -- interface ---------------------------------------------------------
|
|
103
|
+
|
|
104
|
+
def create(self, source: str, *, request: dict[str, Any] | None = None) -> Job:
|
|
105
|
+
now = time.time()
|
|
106
|
+
job = Job(
|
|
107
|
+
id=uuid.uuid4().hex[:12],
|
|
108
|
+
source=source,
|
|
109
|
+
state=JobState.PENDING,
|
|
110
|
+
created_at=now,
|
|
111
|
+
updated_at=now,
|
|
112
|
+
request=request,
|
|
113
|
+
)
|
|
114
|
+
with self._lock:
|
|
115
|
+
self._conn.execute(
|
|
116
|
+
"INSERT INTO jobs (id, source, state, created_at, updated_at,"
|
|
117
|
+
" progress, error, transcript, outputs, cancel_requested, checkpoint, request)"
|
|
118
|
+
" VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
|
119
|
+
(
|
|
120
|
+
job.id,
|
|
121
|
+
job.source,
|
|
122
|
+
job.state.value,
|
|
123
|
+
job.created_at,
|
|
124
|
+
job.updated_at,
|
|
125
|
+
job.progress,
|
|
126
|
+
job.error,
|
|
127
|
+
None,
|
|
128
|
+
json.dumps([]),
|
|
129
|
+
int(job.cancel_requested),
|
|
130
|
+
None,
|
|
131
|
+
json.dumps(request) if request is not None else None,
|
|
132
|
+
),
|
|
133
|
+
)
|
|
134
|
+
self._conn.commit()
|
|
135
|
+
self._prune_locked()
|
|
136
|
+
return job
|
|
137
|
+
|
|
138
|
+
def get(self, job_id: str) -> Job | None:
|
|
139
|
+
with self._lock:
|
|
140
|
+
row = self._conn.execute(
|
|
141
|
+
"SELECT * FROM jobs WHERE id = ?", (job_id,)
|
|
142
|
+
).fetchone()
|
|
143
|
+
return _row_to_job(row) if row else None
|
|
144
|
+
|
|
145
|
+
def update(self, job_id: str, **fields: Any) -> Job | None:
|
|
146
|
+
patch = {k: v for k, v in fields.items() if k in _MUTABLE}
|
|
147
|
+
if not patch:
|
|
148
|
+
return self.get(job_id)
|
|
149
|
+
|
|
150
|
+
sets: list[str] = []
|
|
151
|
+
values: list[Any] = []
|
|
152
|
+
for key, value in patch.items():
|
|
153
|
+
sets.append(f"{key} = ?")
|
|
154
|
+
if key == "state" and isinstance(value, JobState):
|
|
155
|
+
values.append(value.value)
|
|
156
|
+
elif key == "transcript":
|
|
157
|
+
values.append(json.dumps(value) if value is not None else None)
|
|
158
|
+
elif key == "outputs":
|
|
159
|
+
values.append(json.dumps(list(value or [])))
|
|
160
|
+
elif key in {"checkpoint", "request"}:
|
|
161
|
+
values.append(json.dumps(value) if value is not None else None)
|
|
162
|
+
elif key == "cancel_requested":
|
|
163
|
+
values.append(int(bool(value)))
|
|
164
|
+
else:
|
|
165
|
+
values.append(value)
|
|
166
|
+
|
|
167
|
+
sets.append("updated_at = ?")
|
|
168
|
+
values.append(time.time())
|
|
169
|
+
values.append(job_id)
|
|
170
|
+
|
|
171
|
+
with self._lock:
|
|
172
|
+
cur = self._conn.execute(
|
|
173
|
+
f"UPDATE jobs SET {', '.join(sets)} WHERE id = ?",
|
|
174
|
+
values,
|
|
175
|
+
)
|
|
176
|
+
self._conn.commit()
|
|
177
|
+
if cur.rowcount == 0:
|
|
178
|
+
return None
|
|
179
|
+
return self.get(job_id)
|
|
180
|
+
|
|
181
|
+
def list(self, *, limit: int = 50, state: JobState | None = None) -> list[Job]:
|
|
182
|
+
if limit < 0:
|
|
183
|
+
raise ValueError("limit must be >= 0")
|
|
184
|
+
with self._lock:
|
|
185
|
+
if state is None:
|
|
186
|
+
rows = self._conn.execute(
|
|
187
|
+
"SELECT * FROM jobs ORDER BY seq DESC LIMIT ?", (limit,)
|
|
188
|
+
).fetchall()
|
|
189
|
+
else:
|
|
190
|
+
rows = self._conn.execute(
|
|
191
|
+
"SELECT * FROM jobs WHERE state = ? ORDER BY seq DESC LIMIT ?",
|
|
192
|
+
(state.value, limit),
|
|
193
|
+
).fetchall()
|
|
194
|
+
return [_row_to_job(r) for r in rows]
|
|
195
|
+
|
|
196
|
+
def clear(self) -> None:
|
|
197
|
+
with self._lock:
|
|
198
|
+
self._conn.execute("DELETE FROM jobs")
|
|
199
|
+
self._conn.commit()
|
|
200
|
+
|
|
201
|
+
# -- internals ---------------------------------------------------------
|
|
202
|
+
|
|
203
|
+
def _migrate_locked(self) -> None:
|
|
204
|
+
"""Add columns introduced after the original schema."""
|
|
205
|
+
existing = {
|
|
206
|
+
row["name"] for row in self._conn.execute("PRAGMA table_info(jobs)").fetchall()
|
|
207
|
+
}
|
|
208
|
+
if _CHECKPOINT_COLUMN not in existing:
|
|
209
|
+
self._conn.execute("ALTER TABLE jobs ADD COLUMN checkpoint TEXT")
|
|
210
|
+
if "request" not in existing:
|
|
211
|
+
self._conn.execute("ALTER TABLE jobs ADD COLUMN request TEXT")
|
|
212
|
+
|
|
213
|
+
def _prune_locked(self) -> None:
|
|
214
|
+
"""Drop the oldest terminal jobs once over capacity."""
|
|
215
|
+
rows = self._conn.execute(
|
|
216
|
+
"SELECT seq FROM jobs WHERE state IN (?, ?, ?) ORDER BY seq ASC",
|
|
217
|
+
(JobState.DONE.value, JobState.ERROR.value, JobState.CANCELLED.value),
|
|
218
|
+
).fetchall()
|
|
219
|
+
excess = self._count_locked() - self._max_jobs
|
|
220
|
+
if excess <= 0 or not rows:
|
|
221
|
+
return
|
|
222
|
+
doomed = [r["seq"] for r in rows[:excess]]
|
|
223
|
+
if doomed:
|
|
224
|
+
self._conn.executemany("DELETE FROM jobs WHERE seq = ?", [(s,) for s in doomed])
|
|
225
|
+
self._conn.commit()
|
|
226
|
+
|
|
227
|
+
def _count_locked(self) -> int:
|
|
228
|
+
return int(self._conn.execute("SELECT COUNT(*) AS n FROM jobs").fetchone()["n"])
|
|
229
|
+
|
|
230
|
+
def close(self) -> None:
|
|
231
|
+
with self._lock:
|
|
232
|
+
self._conn.close()
|
|
233
|
+
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
"""One submission contract for CLI, MCP, HTTP, and batch callers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import asdict, dataclass, field
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
from urllib.parse import urlparse
|
|
9
|
+
|
|
10
|
+
from textflowkit.core.checkpoint import (
|
|
11
|
+
find_resumable_checkpoint,
|
|
12
|
+
is_local_source,
|
|
13
|
+
load_checkpoint,
|
|
14
|
+
matches,
|
|
15
|
+
prepare_resume,
|
|
16
|
+
reusable_done_result,
|
|
17
|
+
validate_local_resume,
|
|
18
|
+
)
|
|
19
|
+
from textflowkit.core.executor import QueueFullError, get_default_executor
|
|
20
|
+
from textflowkit.core.jobs import Job, JobState, JobStore
|
|
21
|
+
from textflowkit.core.paths import default_input_root
|
|
22
|
+
from textflowkit.core.runner import run_job
|
|
23
|
+
from textflowkit.render import SUPPORTED_FORMATS
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(slots=True)
|
|
27
|
+
class SubmissionRequest:
|
|
28
|
+
source: str
|
|
29
|
+
language: str | None = None
|
|
30
|
+
formats: list[str] = field(default_factory=lambda: ["json", "srt", "txt"])
|
|
31
|
+
output_dir: str | None = None
|
|
32
|
+
model: str = "small"
|
|
33
|
+
engine: str = "whisper"
|
|
34
|
+
device: str | None = None
|
|
35
|
+
cookies_from_browser: str | None = None
|
|
36
|
+
input_root: str | None = None
|
|
37
|
+
work_dir: str | None = None
|
|
38
|
+
diarize: bool = False
|
|
39
|
+
diarizer_backend: str = "pyannote"
|
|
40
|
+
translate_to: str | None = None
|
|
41
|
+
translator_backend: str = "ollama"
|
|
42
|
+
|
|
43
|
+
def __post_init__(self) -> None:
|
|
44
|
+
if not self.source:
|
|
45
|
+
raise ValueError("source is required")
|
|
46
|
+
# Adapter path helpers return Path objects, but a durable request must
|
|
47
|
+
# be JSON-serializable before it is inserted into SQLite.
|
|
48
|
+
if isinstance(self.input_root, Path):
|
|
49
|
+
self.input_root = str(self.input_root)
|
|
50
|
+
if isinstance(self.work_dir, Path):
|
|
51
|
+
self.work_dir = str(self.work_dir)
|
|
52
|
+
self.formats = [str(fmt).lower().lstrip(".") for fmt in self.formats]
|
|
53
|
+
bad = [fmt for fmt in self.formats if fmt not in SUPPORTED_FORMATS]
|
|
54
|
+
if bad:
|
|
55
|
+
raise ValueError(f"unsupported format(s): {', '.join(bad)}")
|
|
56
|
+
if len(self.formats) != len(set(self.formats)):
|
|
57
|
+
raise ValueError("duplicate output format")
|
|
58
|
+
|
|
59
|
+
def to_dict(self) -> dict[str, Any]:
|
|
60
|
+
return asdict(self)
|
|
61
|
+
|
|
62
|
+
@classmethod
|
|
63
|
+
def from_dict(cls, data: dict[str, Any]) -> SubmissionRequest:
|
|
64
|
+
return cls(**data)
|
|
65
|
+
|
|
66
|
+
def options(self) -> dict[str, Any]:
|
|
67
|
+
return {
|
|
68
|
+
"formats": list(self.formats),
|
|
69
|
+
"diarize": self.diarize,
|
|
70
|
+
"diarizer_backend": self.diarizer_backend,
|
|
71
|
+
"translate_to": self.translate_to,
|
|
72
|
+
"translator_backend": self.translator_backend,
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
def run_kwargs(self) -> dict[str, Any]:
|
|
76
|
+
return self.to_dict()
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _matching_checkpoint(store: JobStore, request: SubmissionRequest, job_id: str | None):
|
|
80
|
+
if job_id is not None:
|
|
81
|
+
job = store.get(job_id)
|
|
82
|
+
if job is None:
|
|
83
|
+
raise ValueError(f"no job with id '{job_id}'")
|
|
84
|
+
if job.request is not None:
|
|
85
|
+
saved = dict(job.request)
|
|
86
|
+
incoming = request.to_dict()
|
|
87
|
+
for key in ("input_root", "work_dir"):
|
|
88
|
+
saved.pop(key, None)
|
|
89
|
+
incoming.pop(key, None)
|
|
90
|
+
if saved != incoming:
|
|
91
|
+
raise ValueError("resume request does not match the saved request")
|
|
92
|
+
checkpoint = load_checkpoint(job)
|
|
93
|
+
if checkpoint is None:
|
|
94
|
+
return job, None
|
|
95
|
+
if not matches(
|
|
96
|
+
checkpoint, source=request.source, model=request.model,
|
|
97
|
+
language=request.language, engine=request.engine, device=request.device,
|
|
98
|
+
options=request.options(),
|
|
99
|
+
):
|
|
100
|
+
raise ValueError("resume request does not match the saved checkpoint")
|
|
101
|
+
return job, checkpoint
|
|
102
|
+
return find_resumable_checkpoint(
|
|
103
|
+
store, source=request.source, model=request.model,
|
|
104
|
+
language=request.language, engine=request.engine, device=request.device,
|
|
105
|
+
options=request.options(),
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def submit_request(
|
|
110
|
+
store: JobStore,
|
|
111
|
+
request: SubmissionRequest,
|
|
112
|
+
*,
|
|
113
|
+
background: bool = True,
|
|
114
|
+
resume: bool = False,
|
|
115
|
+
resume_job_id: str | None = None,
|
|
116
|
+
) -> Job:
|
|
117
|
+
"""Submit or resume via the same durable job lifecycle on every surface."""
|
|
118
|
+
executor = get_default_executor() if background else None
|
|
119
|
+
if executor is not None and executor.store is store:
|
|
120
|
+
# Reap old PENDING/RUNNING records before selecting one to resume.
|
|
121
|
+
executor.start()
|
|
122
|
+
found = _matching_checkpoint(store, request, resume_job_id) if resume or resume_job_id else None
|
|
123
|
+
if found is not None:
|
|
124
|
+
prior, checkpoint = found
|
|
125
|
+
if is_local_source(request.source):
|
|
126
|
+
if checkpoint is None:
|
|
127
|
+
if prior.state is JobState.DONE:
|
|
128
|
+
raise ValueError("local job has no reusable checkpoint; resubmit without resume")
|
|
129
|
+
else:
|
|
130
|
+
validate_local_resume(
|
|
131
|
+
checkpoint, request.source,
|
|
132
|
+
input_root=request.input_root if request.input_root is not None
|
|
133
|
+
else default_input_root(),
|
|
134
|
+
)
|
|
135
|
+
if prior.state is JobState.DONE:
|
|
136
|
+
if request.output_dir is None:
|
|
137
|
+
return prior
|
|
138
|
+
result = reusable_done_result(store, prior, formats=request.formats,
|
|
139
|
+
output_dir=request.output_dir,
|
|
140
|
+
stem=_output_stem(prior))
|
|
141
|
+
if result is not None:
|
|
142
|
+
_transcript, outputs = result
|
|
143
|
+
updated = store.update(prior.id, outputs=[str(p) for p in outputs])
|
|
144
|
+
return updated or prior
|
|
145
|
+
if prior.state in {JobState.PENDING, JobState.RUNNING}:
|
|
146
|
+
raise ValueError(f"job '{prior.id}' is already active")
|
|
147
|
+
prepared = prepare_resume(store, prior, checkpoint) if checkpoint else None
|
|
148
|
+
if checkpoint is None:
|
|
149
|
+
reopened = store.update(
|
|
150
|
+
prior.id, state=JobState.PENDING, progress="resuming from start",
|
|
151
|
+
error=None, cancel_requested=False,
|
|
152
|
+
)
|
|
153
|
+
if reopened is None:
|
|
154
|
+
raise ValueError(f"job '{prior.id}' disappeared before resume")
|
|
155
|
+
prepared = reopened, None
|
|
156
|
+
if prepared is not None:
|
|
157
|
+
job, checkpoint_payload = prepared
|
|
158
|
+
kwargs = request.run_kwargs()
|
|
159
|
+
store.update(job.id, request=request.to_dict())
|
|
160
|
+
if checkpoint_payload is not None:
|
|
161
|
+
kwargs["resume_checkpoint"] = checkpoint_payload
|
|
162
|
+
if executor is not None and executor.store is store:
|
|
163
|
+
try:
|
|
164
|
+
return executor.enqueue(job, **kwargs)
|
|
165
|
+
except QueueFullError:
|
|
166
|
+
store.update(job.id, state=JobState.ERROR, progress="queue full",
|
|
167
|
+
error="resume not queued; job queue is full")
|
|
168
|
+
raise
|
|
169
|
+
run_job(job, store, **kwargs)
|
|
170
|
+
return store.get(job.id) or job
|
|
171
|
+
|
|
172
|
+
kwargs = request.run_kwargs()
|
|
173
|
+
if executor is not None and executor.store is store:
|
|
174
|
+
return executor.submit(request=request.to_dict(), **kwargs)
|
|
175
|
+
job = store.create(request.source, request=request.to_dict())
|
|
176
|
+
run_job(job, store, **kwargs)
|
|
177
|
+
return store.get(job.id) or job
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def resume_job(
|
|
181
|
+
store: JobStore, job_id: str, *, background: bool = True,
|
|
182
|
+
input_root: str | None = None, work_dir: str | None = None,
|
|
183
|
+
) -> Job:
|
|
184
|
+
"""Resume an interrupted durable job using its original saved request."""
|
|
185
|
+
job = store.get(job_id)
|
|
186
|
+
if job is None:
|
|
187
|
+
raise ValueError(f"no job with id '{job_id}'")
|
|
188
|
+
if job.request is None:
|
|
189
|
+
raise ValueError("job predates saved requests; resubmit its original options")
|
|
190
|
+
request_data = dict(job.request)
|
|
191
|
+
if input_root is not None:
|
|
192
|
+
request_data["input_root"] = input_root
|
|
193
|
+
if work_dir is not None:
|
|
194
|
+
request_data["work_dir"] = work_dir
|
|
195
|
+
return submit_request(
|
|
196
|
+
store, SubmissionRequest.from_dict(request_data),
|
|
197
|
+
background=background, resume=True, resume_job_id=job_id,
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def submit_batch(
|
|
202
|
+
store: JobStore, requests: list[SubmissionRequest], *, resume: bool = False
|
|
203
|
+
) -> list[dict[str, Any]]:
|
|
204
|
+
"""Accept each source independently; a bad item never hides later items."""
|
|
205
|
+
results: list[dict[str, Any]] = []
|
|
206
|
+
for request in requests:
|
|
207
|
+
try:
|
|
208
|
+
job = submit_request(store, request, resume=resume)
|
|
209
|
+
results.append({"source": request.source, "job_id": job.id, "state": job.state.value})
|
|
210
|
+
except Exception as exc: # noqa: BLE001 - one bad item must not stop the batch
|
|
211
|
+
results.append({"source": request.source, "error": f"{type(exc).__name__}: {exc}"})
|
|
212
|
+
return results
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _output_stem(job: Job) -> str:
|
|
216
|
+
if job.outputs:
|
|
217
|
+
return Path(job.outputs[0]).stem
|
|
218
|
+
parsed = urlparse(job.source)
|
|
219
|
+
name = Path(parsed.path).stem if parsed.scheme in {"http", "https"} else Path(job.source).stem
|
|
220
|
+
return f"{name or 'transcript'}-{job.id}"
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""Timestamp formatting for subtitle formats."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def srt_timestamp(seconds: float) -> str:
|
|
7
|
+
"""HH:MM:SS,mmm (SRT uses a comma)."""
|
|
8
|
+
if seconds < 0:
|
|
9
|
+
seconds = 0.0
|
|
10
|
+
ms_total = round(seconds * 1000)
|
|
11
|
+
h, rem = divmod(ms_total, 3_600_000)
|
|
12
|
+
m, rem = divmod(rem, 60_000)
|
|
13
|
+
s, ms = divmod(rem, 1000)
|
|
14
|
+
return f"{h:02d}:{m:02d}:{s:02d},{ms:03d}"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def vtt_timestamp(seconds: float) -> str:
|
|
18
|
+
"""HH:MM:SS.mmm (WebVTT uses a period)."""
|
|
19
|
+
return srt_timestamp(seconds).replace(",", ".")
|
|
20
|
+
|