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,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