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,229 @@
|
|
|
1
|
+
"""Speaker diarization.
|
|
2
|
+
|
|
3
|
+
The `Segment.speaker` field existed before anything could fill it, which is the
|
|
4
|
+
same "surface with no implementation" defect the audit caught on
|
|
5
|
+
`--speaker-labels`. This module fills it - and refuses to pretend:
|
|
6
|
+
|
|
7
|
+
- diarization is **opt-in**
|
|
8
|
+
- if the backend is missing or unconfigured, the job **fails with an actionable
|
|
9
|
+
error**; it never silently returns a transcript with empty speakers
|
|
10
|
+
- the real backend (`pyannote.audio`) is an optional extra and its pretrained
|
|
11
|
+
pipeline is gated behind a Hugging Face token, so the live path is not covered
|
|
12
|
+
by CI. The *assignment* logic is fully tested with a stub.
|
|
13
|
+
|
|
14
|
+
Design: a diarizer answers "who spoke when" as a list of turns over the audio.
|
|
15
|
+
Assigning speakers to transcript segments is a separate, pure step, so it can be
|
|
16
|
+
tested without any model at all.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import os
|
|
22
|
+
import threading
|
|
23
|
+
from dataclasses import dataclass
|
|
24
|
+
from functools import lru_cache
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
from typing import Protocol
|
|
27
|
+
|
|
28
|
+
from textflowkit.core.engine import _pick_device
|
|
29
|
+
from textflowkit.core.model import Segment
|
|
30
|
+
|
|
31
|
+
ENV_HF_TOKEN = "HF_TOKEN"
|
|
32
|
+
ENV_PYANNOTE_MODEL = "TEXTFLOWKIT_PYANNOTE_MODEL"
|
|
33
|
+
ENV_DIARIZE_DEVICE = "TEXTFLOWKIT_DIARIZE_DEVICE"
|
|
34
|
+
DEFAULT_PYANNOTE_MODEL = "pyannote/speaker-diarization-3.1"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class DiarizationError(RuntimeError):
|
|
38
|
+
"""Raised when diarization was requested but could not be performed."""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(slots=True)
|
|
42
|
+
class SpeakerTurn:
|
|
43
|
+
"""A span of audio attributed to one speaker."""
|
|
44
|
+
|
|
45
|
+
start: float
|
|
46
|
+
end: float
|
|
47
|
+
speaker: str
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def duration(self) -> float:
|
|
51
|
+
return max(0.0, self.end - self.start)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class Diarizer(Protocol):
|
|
55
|
+
"""Identifies who spoke when."""
|
|
56
|
+
|
|
57
|
+
name: str
|
|
58
|
+
|
|
59
|
+
def diarize(self, audio_path: str | Path) -> list[SpeakerTurn]: ...
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def overlap(a_start: float, a_end: float, b_start: float, b_end: float) -> float:
|
|
63
|
+
"""Seconds of overlap between two spans. Zero when they do not intersect."""
|
|
64
|
+
return max(0.0, min(a_end, b_end) - max(a_start, b_start))
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def assign_speakers(
|
|
68
|
+
segments: list[Segment],
|
|
69
|
+
turns: list[SpeakerTurn],
|
|
70
|
+
*,
|
|
71
|
+
min_overlap: float = 0.0,
|
|
72
|
+
) -> int:
|
|
73
|
+
"""Label each segment with the speaker who overlaps it most.
|
|
74
|
+
|
|
75
|
+
A segment is assigned to the turn with the greatest time overlap. Ties go to
|
|
76
|
+
the earlier turn, so the result is deterministic rather than dependent on
|
|
77
|
+
iteration order. A segment with no overlapping turn is left unlabelled
|
|
78
|
+
rather than guessed at.
|
|
79
|
+
|
|
80
|
+
Returns the number of segments that received a label.
|
|
81
|
+
"""
|
|
82
|
+
if not turns:
|
|
83
|
+
return 0
|
|
84
|
+
|
|
85
|
+
labelled = 0
|
|
86
|
+
for segment in segments:
|
|
87
|
+
best: SpeakerTurn | None = None
|
|
88
|
+
best_overlap = 0.0
|
|
89
|
+
for turn in turns:
|
|
90
|
+
amount = overlap(segment.start, segment.end, turn.start, turn.end)
|
|
91
|
+
# Strictly greater wins. On an exact tie the earlier turn wins, so
|
|
92
|
+
# the result depends on the turns themselves and not on the order
|
|
93
|
+
# they happen to arrive in.
|
|
94
|
+
if amount > best_overlap or (
|
|
95
|
+
amount == best_overlap and best is not None and turn.start < best.start
|
|
96
|
+
):
|
|
97
|
+
best = turn
|
|
98
|
+
best_overlap = amount
|
|
99
|
+
if best is not None and best_overlap > min_overlap:
|
|
100
|
+
segment.speaker = best.speaker
|
|
101
|
+
labelled += 1
|
|
102
|
+
return labelled
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class PyannoteDiarizer:
|
|
106
|
+
"""Diarization via `pyannote.audio`.
|
|
107
|
+
|
|
108
|
+
Optional dependency, and the pretrained pipeline is gated: it needs a Hugging
|
|
109
|
+
Face token with access granted to the model. Both conditions are checked up
|
|
110
|
+
front so the failure names what is missing instead of surfacing as an opaque
|
|
111
|
+
load error.
|
|
112
|
+
"""
|
|
113
|
+
|
|
114
|
+
name = "pyannote"
|
|
115
|
+
|
|
116
|
+
def __init__(
|
|
117
|
+
self, model: str | None = None, token: str | None = None,
|
|
118
|
+
device: str | None = None,
|
|
119
|
+
) -> None:
|
|
120
|
+
self.model_name = model or os.environ.get(ENV_PYANNOTE_MODEL, DEFAULT_PYANNOTE_MODEL)
|
|
121
|
+
self._token = token or os.environ.get(ENV_HF_TOKEN)
|
|
122
|
+
self.device = _pick_device(device or os.environ.get(ENV_DIARIZE_DEVICE))
|
|
123
|
+
self._pipeline = None
|
|
124
|
+
self._lock = threading.RLock()
|
|
125
|
+
|
|
126
|
+
def _load(self):
|
|
127
|
+
if self._pipeline is not None:
|
|
128
|
+
return self._pipeline
|
|
129
|
+
try:
|
|
130
|
+
from pyannote.audio import Pipeline
|
|
131
|
+
except ImportError as exc:
|
|
132
|
+
raise DiarizationError(
|
|
133
|
+
"diarization requires the optional 'diarize' extra. "
|
|
134
|
+
"Install with: pip install 'textflowkit[diarize]'"
|
|
135
|
+
) from exc
|
|
136
|
+
if not self._token:
|
|
137
|
+
raise DiarizationError(
|
|
138
|
+
f"diarization requires a Hugging Face token with access to "
|
|
139
|
+
f"'{self.model_name}'. Set {ENV_HF_TOKEN}, or pass token=... . "
|
|
140
|
+
"The model is gated, so access must also be granted on Hugging Face."
|
|
141
|
+
)
|
|
142
|
+
# pyannote.audio renamed `use_auth_token` to `token` in 4.0. Pass the
|
|
143
|
+
# keyword the installed version actually accepts rather than pinning the
|
|
144
|
+
# code to one release.
|
|
145
|
+
try:
|
|
146
|
+
import inspect
|
|
147
|
+
|
|
148
|
+
params = inspect.signature(Pipeline.from_pretrained).parameters
|
|
149
|
+
kwargs = {"token": self._token} if "token" in params else {"use_auth_token": self._token}
|
|
150
|
+
loaded = Pipeline.from_pretrained(self.model_name, **kwargs)
|
|
151
|
+
if hasattr(loaded, "to"):
|
|
152
|
+
import torch
|
|
153
|
+
|
|
154
|
+
loaded.to(torch.device(self.device))
|
|
155
|
+
self._pipeline = loaded
|
|
156
|
+
except Exception as exc: # provider errors vary
|
|
157
|
+
raise DiarizationError(
|
|
158
|
+
f"could not load diarization model '{self.model_name}': {exc}"
|
|
159
|
+
) from exc
|
|
160
|
+
return self._pipeline
|
|
161
|
+
|
|
162
|
+
@staticmethod
|
|
163
|
+
def _load_waveform(audio_path: str | Path) -> dict:
|
|
164
|
+
"""Read audio ourselves instead of letting pyannote decode it.
|
|
165
|
+
|
|
166
|
+
pyannote.audio 4.x decodes through `torchcodec`, whose bundled DLLs are
|
|
167
|
+
built against specific torch releases and fail to load against the ROCm
|
|
168
|
+
torch build this project targets. Its own error message names the way
|
|
169
|
+
out: "provide audio as a waveform dictionary". Reading with `soundfile`
|
|
170
|
+
(already a pyannote dependency) avoids that native-extension coupling
|
|
171
|
+
entirely and skips a decode step.
|
|
172
|
+
"""
|
|
173
|
+
try:
|
|
174
|
+
import soundfile as sf
|
|
175
|
+
import torch
|
|
176
|
+
except ImportError as exc: # pragma: no cover - both are hard deps of pyannote
|
|
177
|
+
raise DiarizationError(f"diarization requires soundfile and torch: {exc}") from exc
|
|
178
|
+
|
|
179
|
+
try:
|
|
180
|
+
data, sample_rate = sf.read(str(audio_path), dtype="float32", always_2d=True)
|
|
181
|
+
except Exception as exc:
|
|
182
|
+
raise DiarizationError(f"could not read audio '{audio_path}': {exc}") from exc
|
|
183
|
+
|
|
184
|
+
waveform = torch.from_numpy(data.T) # (channels, samples)
|
|
185
|
+
if waveform.shape[0] > 1:
|
|
186
|
+
# pyannote expects mono; averaging is what it documents for
|
|
187
|
+
# multi-channel input.
|
|
188
|
+
waveform = waveform.mean(dim=0, keepdim=True)
|
|
189
|
+
return {"waveform": waveform, "sample_rate": sample_rate}
|
|
190
|
+
|
|
191
|
+
def diarize(self, audio_path: str | Path) -> list[SpeakerTurn]:
|
|
192
|
+
try:
|
|
193
|
+
with self._lock:
|
|
194
|
+
pipeline = self._load()
|
|
195
|
+
annotation = pipeline(self._load_waveform(audio_path))
|
|
196
|
+
except DiarizationError:
|
|
197
|
+
raise
|
|
198
|
+
except Exception as exc:
|
|
199
|
+
raise DiarizationError(f"diarization failed: {exc}") from exc
|
|
200
|
+
|
|
201
|
+
# pyannote.audio 4.x returns a DiarizeOutput wrapper exposing the
|
|
202
|
+
# annotation as `.speaker_diarization`; earlier releases returned the
|
|
203
|
+
# Annotation directly. Accept either rather than pinning to one version.
|
|
204
|
+
annotation = getattr(annotation, "speaker_diarization", annotation)
|
|
205
|
+
|
|
206
|
+
turns: list[SpeakerTurn] = []
|
|
207
|
+
for turn, _, speaker in annotation.itertracks(yield_label=True):
|
|
208
|
+
turns.append(SpeakerTurn(start=turn.start, end=turn.end, speaker=str(speaker)))
|
|
209
|
+
return turns
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
_DIARIZER_CACHE_LOCK = threading.Lock()
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
@lru_cache(maxsize=4)
|
|
216
|
+
def _cached_diarizer(model: str, token: str | None, device: str) -> PyannoteDiarizer:
|
|
217
|
+
return PyannoteDiarizer(model=model, token=token, device=device)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def get_diarizer(backend: str = "pyannote", **kwargs) -> Diarizer:
|
|
221
|
+
if backend in ("pyannote", "default"):
|
|
222
|
+
model = kwargs.pop("model", None) or os.environ.get(ENV_PYANNOTE_MODEL, DEFAULT_PYANNOTE_MODEL)
|
|
223
|
+
token = kwargs.pop("token", None) or os.environ.get(ENV_HF_TOKEN)
|
|
224
|
+
device = _pick_device(kwargs.pop("device", None) or os.environ.get(ENV_DIARIZE_DEVICE))
|
|
225
|
+
if kwargs:
|
|
226
|
+
raise TypeError(f"unknown diarizer options: {', '.join(kwargs)}")
|
|
227
|
+
with _DIARIZER_CACHE_LOCK:
|
|
228
|
+
return _cached_diarizer(model, token, device)
|
|
229
|
+
raise DiarizationError(f"unknown diarization backend: {backend}")
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""Speech-to-text engines.
|
|
2
|
+
|
|
3
|
+
The default engine is openai-whisper on PyTorch. On this project's reference
|
|
4
|
+
hardware (AMD Strix Halo) that means ROCm; on NVIDIA it means CUDA; with neither
|
|
5
|
+
it falls back to CPU. The engine interface is intentionally tiny so alternatives
|
|
6
|
+
can be added without touching the pipeline.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import threading
|
|
12
|
+
from functools import lru_cache
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any, Protocol
|
|
15
|
+
|
|
16
|
+
from textflowkit.core.model import Segment, Transcript
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class Engine(Protocol):
|
|
20
|
+
name: str
|
|
21
|
+
|
|
22
|
+
def transcribe(
|
|
23
|
+
self,
|
|
24
|
+
audio_path: str | Path,
|
|
25
|
+
*,
|
|
26
|
+
language: str | None = None,
|
|
27
|
+
) -> Transcript: ...
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _pick_device(prefer: str | None = None) -> str:
|
|
31
|
+
if prefer:
|
|
32
|
+
return prefer
|
|
33
|
+
try:
|
|
34
|
+
import torch
|
|
35
|
+
except ImportError:
|
|
36
|
+
return "cpu"
|
|
37
|
+
if torch.cuda.is_available():
|
|
38
|
+
return "cuda"
|
|
39
|
+
return "cpu"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class WhisperEngine:
|
|
43
|
+
"""openai-whisper backed engine."""
|
|
44
|
+
|
|
45
|
+
name = "openai-whisper"
|
|
46
|
+
|
|
47
|
+
def __init__(self, model: str = "small", device: str | None = None, fp16: bool | None = None):
|
|
48
|
+
self.model_name = model
|
|
49
|
+
self.device = _pick_device(device)
|
|
50
|
+
if fp16 is None:
|
|
51
|
+
fp16 = self.device != "cpu"
|
|
52
|
+
self.fp16 = fp16
|
|
53
|
+
self._model: Any = None
|
|
54
|
+
self._lock = threading.RLock()
|
|
55
|
+
|
|
56
|
+
def _load(self):
|
|
57
|
+
with self._lock:
|
|
58
|
+
if self._model is None:
|
|
59
|
+
try:
|
|
60
|
+
import whisper
|
|
61
|
+
except ImportError as exc:
|
|
62
|
+
raise RuntimeError(
|
|
63
|
+
"openai-whisper is not installed. Install with: pip install openai-whisper"
|
|
64
|
+
) from exc
|
|
65
|
+
self._model = whisper.load_model(self.model_name, device=self.device)
|
|
66
|
+
return self._model
|
|
67
|
+
|
|
68
|
+
def transcribe(
|
|
69
|
+
self,
|
|
70
|
+
audio_path: str | Path,
|
|
71
|
+
*,
|
|
72
|
+
language: str | None = None,
|
|
73
|
+
) -> Transcript:
|
|
74
|
+
with self._lock:
|
|
75
|
+
model = self._load()
|
|
76
|
+
result = model.transcribe(
|
|
77
|
+
str(audio_path),
|
|
78
|
+
language=language,
|
|
79
|
+
fp16=self.fp16,
|
|
80
|
+
verbose=False,
|
|
81
|
+
word_timestamps=True,
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
segments: list[Segment] = []
|
|
85
|
+
for raw in result.get("segments", []) or []:
|
|
86
|
+
text = str(raw.get("text", "")).strip()
|
|
87
|
+
if not text:
|
|
88
|
+
continue
|
|
89
|
+
segments.append(
|
|
90
|
+
Segment(
|
|
91
|
+
start=float(raw.get("start", 0.0)),
|
|
92
|
+
end=float(raw.get("end", 0.0)),
|
|
93
|
+
text=text,
|
|
94
|
+
speaker=None,
|
|
95
|
+
)
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
duration = segments[-1].end if segments else None
|
|
99
|
+
return Transcript(
|
|
100
|
+
source=str(audio_path),
|
|
101
|
+
language=result.get("language"),
|
|
102
|
+
segments=segments,
|
|
103
|
+
duration=duration,
|
|
104
|
+
engine=self.name,
|
|
105
|
+
metadata={"model": self.model_name, "device": self.device},
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
_ENGINE_CACHE_LOCK = threading.Lock()
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
@lru_cache(maxsize=8)
|
|
113
|
+
def _cached_whisper(model: str, device: str | None, fp16: bool | None) -> WhisperEngine:
|
|
114
|
+
return WhisperEngine(model=model, device=device, fp16=fp16)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def get_engine(name: str = "whisper", **kwargs: Any) -> Engine:
|
|
118
|
+
if name in ("whisper", "openai-whisper", "default"):
|
|
119
|
+
model = kwargs.pop("model", "small")
|
|
120
|
+
device = kwargs.pop("device", None)
|
|
121
|
+
fp16 = kwargs.pop("fp16", None)
|
|
122
|
+
if kwargs:
|
|
123
|
+
raise TypeError(f"unknown Whisper options: {', '.join(kwargs)}")
|
|
124
|
+
with _ENGINE_CACHE_LOCK:
|
|
125
|
+
return _cached_whisper(model, device, fp16)
|
|
126
|
+
raise ValueError(f"unknown engine: {name}")
|
|
127
|
+
|
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
"""Bounded job executor.
|
|
2
|
+
|
|
3
|
+
Before this, `submit()` spawned one raw `threading.Thread` per job and forgot it.
|
|
4
|
+
That had two consequences the review called out:
|
|
5
|
+
|
|
6
|
+
- **No concurrency bound.** N submissions meant N simultaneous Whisper runs
|
|
7
|
+
competing for the same GPU. On a device Whisper already saturates, parallel
|
|
8
|
+
jobs do not go faster - they thrash memory.
|
|
9
|
+
- **No handle on running work.** Nothing held a reference to a running job, so
|
|
10
|
+
"cancel" had nothing to act on.
|
|
11
|
+
|
|
12
|
+
This module replaces the fire-and-forget thread with a fixed worker pool that
|
|
13
|
+
owns job lifecycle. Cancellation is cooperative: a job checks a token at stage
|
|
14
|
+
boundaries and stops cleanly. A job inside a single long model call cannot be
|
|
15
|
+
interrupted mid-call - that call finishes, then the job stops. Stated plainly
|
|
16
|
+
here because it is a real limit, not an implementation detail.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import os
|
|
22
|
+
import queue
|
|
23
|
+
import threading
|
|
24
|
+
from typing import Any
|
|
25
|
+
|
|
26
|
+
from textflowkit.core.cancel import CancelledError
|
|
27
|
+
from textflowkit.core.jobs import Job, JobState, JobStore, get_default_store
|
|
28
|
+
|
|
29
|
+
ENV_CONCURRENCY = "TEXTFLOWKIT_MAX_CONCURRENCY"
|
|
30
|
+
DEFAULT_CONCURRENCY = 1
|
|
31
|
+
ENV_MAX_PENDING = "TEXTFLOWKIT_MAX_PENDING_JOBS"
|
|
32
|
+
DEFAULT_MAX_PENDING = 100
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class QueueFullError(RuntimeError):
|
|
36
|
+
"""The executor cannot accept another pending job right now."""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
# The signal itself lives in a leaf module so the source layer can re-raise it
|
|
40
|
+
# around broad exception handling. This alias keeps the name used everywhere
|
|
41
|
+
# else in the codebase and in tests.
|
|
42
|
+
JobCancelled = CancelledError
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class CancelToken:
|
|
46
|
+
"""A cooperative cancellation flag, checked at stage boundaries."""
|
|
47
|
+
|
|
48
|
+
__slots__ = ("_cancelled", "_lock")
|
|
49
|
+
|
|
50
|
+
def __init__(self) -> None:
|
|
51
|
+
self._cancelled = False
|
|
52
|
+
self._lock = threading.Lock()
|
|
53
|
+
|
|
54
|
+
def cancel(self) -> None:
|
|
55
|
+
with self._lock:
|
|
56
|
+
self._cancelled = True
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def cancelled(self) -> bool:
|
|
60
|
+
with self._lock:
|
|
61
|
+
return self._cancelled
|
|
62
|
+
|
|
63
|
+
def checkpoint(self) -> None:
|
|
64
|
+
"""Raise `JobCancelled` if cancellation was requested."""
|
|
65
|
+
if self.cancelled:
|
|
66
|
+
raise JobCancelled()
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class JobExecutor:
|
|
70
|
+
"""A fixed pool of workers that run jobs from a queue.
|
|
71
|
+
|
|
72
|
+
The pool size is the concurrency bound. Defaults to 1 because Whisper
|
|
73
|
+
saturates a GPU on its own; override with `TEXTFLOWKIT_MAX_CONCURRENCY`.
|
|
74
|
+
"""
|
|
75
|
+
|
|
76
|
+
def __init__(
|
|
77
|
+
self,
|
|
78
|
+
store: JobStore,
|
|
79
|
+
*,
|
|
80
|
+
max_concurrency: int | None = None,
|
|
81
|
+
max_pending: int | None = None,
|
|
82
|
+
) -> None:
|
|
83
|
+
self._store = store
|
|
84
|
+
if max_concurrency is None:
|
|
85
|
+
raw = os.environ.get(ENV_CONCURRENCY)
|
|
86
|
+
try:
|
|
87
|
+
max_concurrency = int(raw) if raw else DEFAULT_CONCURRENCY
|
|
88
|
+
except ValueError:
|
|
89
|
+
max_concurrency = DEFAULT_CONCURRENCY
|
|
90
|
+
self._max_concurrency = max(1, max_concurrency)
|
|
91
|
+
|
|
92
|
+
if max_pending is None:
|
|
93
|
+
raw = os.environ.get(ENV_MAX_PENDING)
|
|
94
|
+
try:
|
|
95
|
+
max_pending = int(raw) if raw else DEFAULT_MAX_PENDING
|
|
96
|
+
except ValueError:
|
|
97
|
+
max_pending = DEFAULT_MAX_PENDING
|
|
98
|
+
self._max_pending = max(1, max_pending)
|
|
99
|
+
# This semaphore counts queued jobs, not running workers. It also lets
|
|
100
|
+
# shutdown enqueue sentinels without deadlocking on a full queue.
|
|
101
|
+
self._pending_slots = threading.BoundedSemaphore(self._max_pending)
|
|
102
|
+
|
|
103
|
+
self._queue: queue.Queue[tuple[str, dict[str, Any]] | None] = queue.Queue()
|
|
104
|
+
self._workers: list[threading.Thread] = []
|
|
105
|
+
self._tokens: dict[str, CancelToken] = {}
|
|
106
|
+
self._lock = threading.RLock()
|
|
107
|
+
self._started = False
|
|
108
|
+
self._shutdown = False
|
|
109
|
+
|
|
110
|
+
# -- lifecycle ---------------------------------------------------------
|
|
111
|
+
|
|
112
|
+
@property
|
|
113
|
+
def store(self) -> JobStore:
|
|
114
|
+
"""The store this executor writes to."""
|
|
115
|
+
return self._store
|
|
116
|
+
|
|
117
|
+
@property
|
|
118
|
+
def max_concurrency(self) -> int:
|
|
119
|
+
return self._max_concurrency
|
|
120
|
+
|
|
121
|
+
@property
|
|
122
|
+
def max_pending(self) -> int:
|
|
123
|
+
return self._max_pending
|
|
124
|
+
|
|
125
|
+
def start(self) -> None:
|
|
126
|
+
"""Start worker threads. Idempotent; called lazily by submit()."""
|
|
127
|
+
|
|
128
|
+
def _start_locked() -> None:
|
|
129
|
+
for i in range(self._max_concurrency):
|
|
130
|
+
t = threading.Thread(
|
|
131
|
+
target=self._worker,
|
|
132
|
+
name=f"textflowkit-worker-{i}",
|
|
133
|
+
daemon=True,
|
|
134
|
+
)
|
|
135
|
+
t.start()
|
|
136
|
+
self._workers.append(t)
|
|
137
|
+
self._started = True
|
|
138
|
+
|
|
139
|
+
with self._lock:
|
|
140
|
+
if self._started or self._shutdown:
|
|
141
|
+
return
|
|
142
|
+
# A durable store may hold jobs left mid-flight by a previous
|
|
143
|
+
# process. They have no worker now, so fail them rather than
|
|
144
|
+
# reporting jobs that can never finish. This assumes one owning
|
|
145
|
+
# process per store, which is the documented deployment model.
|
|
146
|
+
self._store.reap_incomplete(
|
|
147
|
+
reason="interrupted by restart; no worker is running this job"
|
|
148
|
+
)
|
|
149
|
+
_start_locked()
|
|
150
|
+
|
|
151
|
+
def shutdown(self, *, wait: bool = True, timeout: float = 5.0) -> None:
|
|
152
|
+
"""Stop accepting work and drain the pool."""
|
|
153
|
+
with self._lock:
|
|
154
|
+
if self._shutdown:
|
|
155
|
+
return
|
|
156
|
+
self._shutdown = True
|
|
157
|
+
for _ in self._workers:
|
|
158
|
+
self._queue.put(None) # sentinel: one per worker
|
|
159
|
+
if wait:
|
|
160
|
+
for t in self._workers:
|
|
161
|
+
t.join(timeout=timeout)
|
|
162
|
+
|
|
163
|
+
# -- work --------------------------------------------------------------
|
|
164
|
+
|
|
165
|
+
def submit(
|
|
166
|
+
self, *, source: str, request: dict[str, Any] | None = None, **kwargs: Any
|
|
167
|
+
) -> Job:
|
|
168
|
+
"""Queue a job and return it immediately."""
|
|
169
|
+
self.start()
|
|
170
|
+
with self._lock:
|
|
171
|
+
if self._shutdown:
|
|
172
|
+
raise RuntimeError("job executor is shut down")
|
|
173
|
+
if not self._pending_slots.acquire(blocking=False):
|
|
174
|
+
raise QueueFullError(
|
|
175
|
+
f"job queue is full ({self._max_pending} pending); retry later"
|
|
176
|
+
)
|
|
177
|
+
try:
|
|
178
|
+
job = self._store.create(source, request=request)
|
|
179
|
+
self._queue.put_nowait((job.id, {"source": source, **kwargs}))
|
|
180
|
+
except Exception:
|
|
181
|
+
self._pending_slots.release()
|
|
182
|
+
raise
|
|
183
|
+
return job
|
|
184
|
+
|
|
185
|
+
def enqueue(self, job: Job, *, source: str, **kwargs: Any) -> Job:
|
|
186
|
+
"""Queue an existing prepared job (the durable resume path)."""
|
|
187
|
+
self.start()
|
|
188
|
+
with self._lock:
|
|
189
|
+
if self._shutdown:
|
|
190
|
+
raise RuntimeError("job executor is shut down")
|
|
191
|
+
if not self._pending_slots.acquire(blocking=False):
|
|
192
|
+
raise QueueFullError(
|
|
193
|
+
f"job queue is full ({self._max_pending} pending); retry later"
|
|
194
|
+
)
|
|
195
|
+
try:
|
|
196
|
+
self._queue.put_nowait((job.id, {"source": source, **kwargs}))
|
|
197
|
+
except Exception:
|
|
198
|
+
self._pending_slots.release()
|
|
199
|
+
raise
|
|
200
|
+
return job
|
|
201
|
+
|
|
202
|
+
def cancel(self, job_id: str) -> bool:
|
|
203
|
+
"""Request cancellation. True if the job was live.
|
|
204
|
+
|
|
205
|
+
Two cases, because they are genuinely different:
|
|
206
|
+
|
|
207
|
+
- **Not yet started** (queued, or the worker has not reached it): the job
|
|
208
|
+
is marked CANCELLED immediately and the worker skips it.
|
|
209
|
+
- **Running**: `cancel_requested` is set and the token is tripped. The
|
|
210
|
+
job stops at its next stage boundary and is then marked CANCELLED.
|
|
211
|
+
Until that boundary is reached it stays RUNNING with
|
|
212
|
+
`cancel_requested: true` - honest about work still in flight rather
|
|
213
|
+
than claiming an instant stop we cannot deliver.
|
|
214
|
+
"""
|
|
215
|
+
job = self._store.get(job_id)
|
|
216
|
+
if job is None or job.is_terminal:
|
|
217
|
+
return False
|
|
218
|
+
|
|
219
|
+
with self._lock:
|
|
220
|
+
token = self._tokens.get(job_id)
|
|
221
|
+
|
|
222
|
+
if token is None:
|
|
223
|
+
self._store.update(
|
|
224
|
+
job_id,
|
|
225
|
+
state=JobState.CANCELLED,
|
|
226
|
+
progress="cancelled",
|
|
227
|
+
cancel_requested=True,
|
|
228
|
+
)
|
|
229
|
+
else:
|
|
230
|
+
token.cancel()
|
|
231
|
+
self._store.update(job_id, cancel_requested=True, progress="cancelling")
|
|
232
|
+
return True
|
|
233
|
+
|
|
234
|
+
def running_jobs(self) -> list[str]:
|
|
235
|
+
with self._lock:
|
|
236
|
+
return list(self._tokens)
|
|
237
|
+
|
|
238
|
+
@property
|
|
239
|
+
def pending_count(self) -> int:
|
|
240
|
+
return self._queue.qsize()
|
|
241
|
+
|
|
242
|
+
def _worker(self) -> None:
|
|
243
|
+
while True:
|
|
244
|
+
item = self._queue.get()
|
|
245
|
+
try:
|
|
246
|
+
if item is None:
|
|
247
|
+
return
|
|
248
|
+
self._pending_slots.release()
|
|
249
|
+
job_id, kwargs = item
|
|
250
|
+
|
|
251
|
+
# Register the token BEFORE inspecting state, so a concurrent
|
|
252
|
+
# cancel() always finds a token to trip rather than racing us
|
|
253
|
+
# into marking the job cancelled while we start it anyway.
|
|
254
|
+
token = CancelToken()
|
|
255
|
+
with self._lock:
|
|
256
|
+
self._tokens[job_id] = token
|
|
257
|
+
try:
|
|
258
|
+
try:
|
|
259
|
+
self._run_one(job_id, kwargs, token)
|
|
260
|
+
except Exception as exc: # noqa: BLE001 - keep the worker alive
|
|
261
|
+
self._store.update(
|
|
262
|
+
job_id,
|
|
263
|
+
state=JobState.ERROR,
|
|
264
|
+
error=f"{type(exc).__name__}: {exc}",
|
|
265
|
+
progress="failed",
|
|
266
|
+
)
|
|
267
|
+
finally:
|
|
268
|
+
with self._lock:
|
|
269
|
+
self._tokens.pop(job_id, None)
|
|
270
|
+
finally:
|
|
271
|
+
self._queue.task_done()
|
|
272
|
+
|
|
273
|
+
def _run_one(self, job_id: str, kwargs: dict[str, Any], token: CancelToken) -> None:
|
|
274
|
+
from textflowkit.core.runner import run_job
|
|
275
|
+
|
|
276
|
+
job = self._store.get(job_id)
|
|
277
|
+
if job is None or job.is_terminal:
|
|
278
|
+
return # cancelled or reaped before we got to it
|
|
279
|
+
run_job(job, self._store, check_cancel=token.checkpoint, **kwargs)
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
# Process-wide default executor, shared by the adapters.
|
|
283
|
+
_default_executor: JobExecutor | None = None
|
|
284
|
+
_executor_lock = threading.Lock()
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def get_default_executor() -> JobExecutor:
|
|
288
|
+
global _default_executor
|
|
289
|
+
with _executor_lock:
|
|
290
|
+
if _default_executor is None:
|
|
291
|
+
_default_executor = JobExecutor(get_default_store())
|
|
292
|
+
return _default_executor
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def reset_default_executor() -> None:
|
|
296
|
+
"""Drop the cached executor (tests, embedding)."""
|
|
297
|
+
global _default_executor
|
|
298
|
+
with _executor_lock:
|
|
299
|
+
if _default_executor is not None:
|
|
300
|
+
_default_executor.shutdown(wait=False)
|
|
301
|
+
_default_executor = None
|