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
textflowkit/core/jobs.py
ADDED
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
"""Job model and the store interface.
|
|
2
|
+
|
|
3
|
+
Long-running transcription is modelled as a job so the same interface works
|
|
4
|
+
everywhere: stdio MCP polls in-process, an HTTP adapter polls over the wire, and
|
|
5
|
+
a website can queue work.
|
|
6
|
+
|
|
7
|
+
`JobStore` is the abstraction. Two implementations ship:
|
|
8
|
+
|
|
9
|
+
- `MemoryJobStore` - fast, process-local, used for tests and ephemeral runs.
|
|
10
|
+
- `SqliteJobStore` - durable, survives restart. Selected by setting
|
|
11
|
+
``TEXTFLOWKIT_DB``.
|
|
12
|
+
|
|
13
|
+
Ordering is always by insertion sequence, never by wall-clock time: on Windows
|
|
14
|
+
with Python < 3.13 ``time.time()`` is coarse enough that jobs created in a tight
|
|
15
|
+
loop share a timestamp, and tie-breaking by insertion order silently inverts
|
|
16
|
+
"newest first".
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import os
|
|
22
|
+
import threading
|
|
23
|
+
import time
|
|
24
|
+
import uuid
|
|
25
|
+
from abc import ABC, abstractmethod
|
|
26
|
+
from dataclasses import dataclass, field
|
|
27
|
+
from enum import Enum
|
|
28
|
+
from typing import Any
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class JobState(str, Enum):
|
|
32
|
+
PENDING = "pending"
|
|
33
|
+
RUNNING = "running"
|
|
34
|
+
DONE = "done"
|
|
35
|
+
ERROR = "error"
|
|
36
|
+
CANCELLED = "cancelled"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
TERMINAL_STATES = frozenset({JobState.DONE, JobState.ERROR, JobState.CANCELLED})
|
|
40
|
+
MAX_LIST_LIMIT = 1000
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def validate_list_limit(limit: int) -> int:
|
|
44
|
+
if limit < 0 or limit > MAX_LIST_LIMIT:
|
|
45
|
+
raise ValueError(f"limit must be between 0 and {MAX_LIST_LIMIT}")
|
|
46
|
+
return limit
|
|
47
|
+
|
|
48
|
+
# A job in one of these states expects a worker to be running it. Across a
|
|
49
|
+
# restart there is no worker, so any such job is orphaned and must be reaped -
|
|
50
|
+
# otherwise it sits in "running" forever.
|
|
51
|
+
INCOMPLETE_STATES = frozenset({JobState.PENDING, JobState.RUNNING})
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass
|
|
55
|
+
class Job:
|
|
56
|
+
"""A single transcription job."""
|
|
57
|
+
|
|
58
|
+
id: str
|
|
59
|
+
source: str
|
|
60
|
+
state: JobState = JobState.PENDING
|
|
61
|
+
created_at: float = field(default_factory=time.time)
|
|
62
|
+
updated_at: float = field(default_factory=time.time)
|
|
63
|
+
progress: str = ""
|
|
64
|
+
error: str | None = None
|
|
65
|
+
transcript: dict[str, Any] | None = None
|
|
66
|
+
outputs: list[str] = field(default_factory=list)
|
|
67
|
+
cancel_requested: bool = False
|
|
68
|
+
checkpoint: dict[str, Any] | None = None
|
|
69
|
+
request: dict[str, Any] | None = None
|
|
70
|
+
|
|
71
|
+
@property
|
|
72
|
+
def is_terminal(self) -> bool:
|
|
73
|
+
return self.state in TERMINAL_STATES
|
|
74
|
+
|
|
75
|
+
def to_dict(
|
|
76
|
+
self, *, include_transcript: bool = False, include_checkpoint: bool = False
|
|
77
|
+
) -> dict[str, Any]:
|
|
78
|
+
data = {
|
|
79
|
+
"id": self.id,
|
|
80
|
+
"source": self.source,
|
|
81
|
+
"state": self.state.value,
|
|
82
|
+
"created_at": self.created_at,
|
|
83
|
+
"updated_at": self.updated_at,
|
|
84
|
+
"progress": self.progress,
|
|
85
|
+
"error": self.error,
|
|
86
|
+
"outputs": list(self.outputs),
|
|
87
|
+
"cancel_requested": self.cancel_requested,
|
|
88
|
+
}
|
|
89
|
+
if include_checkpoint and self.checkpoint is not None:
|
|
90
|
+
data["checkpoint"] = self.checkpoint
|
|
91
|
+
if include_transcript and self.transcript is not None:
|
|
92
|
+
data["transcript"] = self.transcript
|
|
93
|
+
return data
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class JobStore(ABC):
|
|
97
|
+
"""Storage for jobs.
|
|
98
|
+
|
|
99
|
+
Implementations must be safe for concurrent use from multiple threads, and
|
|
100
|
+
must return jobs ordered newest-first by insertion sequence.
|
|
101
|
+
"""
|
|
102
|
+
|
|
103
|
+
@abstractmethod
|
|
104
|
+
def create(self, source: str, *, request: dict[str, Any] | None = None) -> Job:
|
|
105
|
+
"""Create a pending job and return it."""
|
|
106
|
+
|
|
107
|
+
@abstractmethod
|
|
108
|
+
def get(self, job_id: str) -> Job | None:
|
|
109
|
+
"""Fetch one job, or None."""
|
|
110
|
+
|
|
111
|
+
@abstractmethod
|
|
112
|
+
def update(self, job_id: str, **fields: Any) -> Job | None:
|
|
113
|
+
"""Patch fields on a job and bump updated_at. None if absent."""
|
|
114
|
+
|
|
115
|
+
@abstractmethod
|
|
116
|
+
def list(self, *, limit: int = 50, state: JobState | None = None) -> list[Job]:
|
|
117
|
+
"""Recent jobs, newest first, optionally filtered by state."""
|
|
118
|
+
|
|
119
|
+
@abstractmethod
|
|
120
|
+
def clear(self) -> None:
|
|
121
|
+
"""Remove every job."""
|
|
122
|
+
|
|
123
|
+
def reap_incomplete(self, *, reason: str) -> int:
|
|
124
|
+
"""Fail any job left mid-flight, returning how many were reaped.
|
|
125
|
+
|
|
126
|
+
Called at startup. A job in PENDING/RUNNING has no worker after a
|
|
127
|
+
restart, so leaving it alone would report a job that will never finish.
|
|
128
|
+
"""
|
|
129
|
+
reaped = 0
|
|
130
|
+
for job in self.list(limit=10_000):
|
|
131
|
+
if job.state in INCOMPLETE_STATES:
|
|
132
|
+
self.update(
|
|
133
|
+
job.id,
|
|
134
|
+
state=JobState.ERROR,
|
|
135
|
+
error=reason,
|
|
136
|
+
progress="interrupted",
|
|
137
|
+
)
|
|
138
|
+
reaped += 1
|
|
139
|
+
return reaped
|
|
140
|
+
|
|
141
|
+
def close(self) -> None:
|
|
142
|
+
"""Release resources. No-op by default."""
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
class MemoryJobStore(JobStore):
|
|
146
|
+
"""In-memory, thread-safe job store. Fast; not durable."""
|
|
147
|
+
|
|
148
|
+
def __init__(self, *, max_jobs: int = 200) -> None:
|
|
149
|
+
self._jobs: dict[str, Job] = {}
|
|
150
|
+
self._order: list[str] = []
|
|
151
|
+
self._lock = threading.RLock()
|
|
152
|
+
self._max_jobs = max_jobs
|
|
153
|
+
|
|
154
|
+
def create(self, source: str, *, request: dict[str, Any] | None = None) -> Job:
|
|
155
|
+
job = Job(id=uuid.uuid4().hex[:12], source=source, request=request)
|
|
156
|
+
with self._lock:
|
|
157
|
+
self._jobs[job.id] = job
|
|
158
|
+
self._order.append(job.id)
|
|
159
|
+
self._evict_locked()
|
|
160
|
+
return job
|
|
161
|
+
|
|
162
|
+
def get(self, job_id: str) -> Job | None:
|
|
163
|
+
with self._lock:
|
|
164
|
+
return self._jobs.get(job_id)
|
|
165
|
+
|
|
166
|
+
def update(self, job_id: str, **fields: Any) -> Job | None:
|
|
167
|
+
with self._lock:
|
|
168
|
+
job = self._jobs.get(job_id)
|
|
169
|
+
if job is None:
|
|
170
|
+
return None
|
|
171
|
+
for key, value in fields.items():
|
|
172
|
+
setattr(job, key, value)
|
|
173
|
+
job.updated_at = time.time()
|
|
174
|
+
return job
|
|
175
|
+
|
|
176
|
+
def list(self, *, limit: int = 50, state: JobState | None = None) -> list[Job]:
|
|
177
|
+
if limit < 0:
|
|
178
|
+
raise ValueError("limit must be >= 0")
|
|
179
|
+
with self._lock:
|
|
180
|
+
jobs = [self._jobs[i] for i in reversed(self._order) if i in self._jobs]
|
|
181
|
+
if state is not None:
|
|
182
|
+
jobs = [j for j in jobs if j.state == state]
|
|
183
|
+
return jobs[:limit]
|
|
184
|
+
|
|
185
|
+
def _evict_locked(self) -> None:
|
|
186
|
+
"""Drop oldest terminal jobs once over capacity."""
|
|
187
|
+
while len(self._order) > self._max_jobs:
|
|
188
|
+
for idx, job_id in enumerate(self._order):
|
|
189
|
+
job = self._jobs.get(job_id)
|
|
190
|
+
if job is None:
|
|
191
|
+
self._order.pop(idx)
|
|
192
|
+
break
|
|
193
|
+
if job.is_terminal:
|
|
194
|
+
self._order.pop(idx)
|
|
195
|
+
self._jobs.pop(job_id, None)
|
|
196
|
+
break
|
|
197
|
+
else:
|
|
198
|
+
return # everything still running; keep them all
|
|
199
|
+
|
|
200
|
+
def clear(self) -> None:
|
|
201
|
+
with self._lock:
|
|
202
|
+
self._jobs.clear()
|
|
203
|
+
self._order.clear()
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
ENV_DB = "TEXTFLOWKIT_DB"
|
|
207
|
+
|
|
208
|
+
_default_store: JobStore | None = None
|
|
209
|
+
_store_lock = threading.Lock()
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _make_store() -> JobStore:
|
|
213
|
+
"""Choose a store from the environment. Durable when TEXTFLOWKIT_DB is set."""
|
|
214
|
+
db_path = os.environ.get(ENV_DB)
|
|
215
|
+
if db_path:
|
|
216
|
+
from textflowkit.core.sqlite_store import SqliteJobStore
|
|
217
|
+
|
|
218
|
+
return SqliteJobStore(db_path)
|
|
219
|
+
return MemoryJobStore()
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def get_default_store() -> JobStore:
|
|
223
|
+
"""Process-wide store shared by the MCP and HTTP adapters."""
|
|
224
|
+
global _default_store
|
|
225
|
+
with _store_lock:
|
|
226
|
+
if _default_store is None:
|
|
227
|
+
_default_store = _make_store()
|
|
228
|
+
return _default_store
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def set_default_store(store: JobStore | None) -> None:
|
|
232
|
+
"""Override the process-wide store (tests, embedding)."""
|
|
233
|
+
global _default_store
|
|
234
|
+
with _store_lock:
|
|
235
|
+
_default_store = store
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def reset_default_store() -> None:
|
|
239
|
+
"""Drop the cached store so the next call re-reads the environment."""
|
|
240
|
+
set_default_store(None)
|
|
241
|
+
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Canonical transcript data model.
|
|
2
|
+
|
|
3
|
+
Every source, engine, and renderer speaks this shape. Keeping one canonical
|
|
4
|
+
object is what lets the pipeline stay shared while platforms multiply.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
from dataclasses import asdict, dataclass, field
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(slots=True)
|
|
16
|
+
class Segment:
|
|
17
|
+
"""One timestamped span of speech."""
|
|
18
|
+
|
|
19
|
+
start: float
|
|
20
|
+
end: float
|
|
21
|
+
text: str
|
|
22
|
+
speaker: str | None = None
|
|
23
|
+
translated_text: str | None = None
|
|
24
|
+
hidden: bool = False
|
|
25
|
+
|
|
26
|
+
def display_text(self) -> str:
|
|
27
|
+
"""Text to render: translated if present, else source."""
|
|
28
|
+
return self.translated_text or self.text
|
|
29
|
+
|
|
30
|
+
def to_dict(self) -> dict[str, Any]:
|
|
31
|
+
return asdict(self)
|
|
32
|
+
|
|
33
|
+
@classmethod
|
|
34
|
+
def from_dict(cls, data: dict[str, Any]) -> Segment:
|
|
35
|
+
return cls(
|
|
36
|
+
start=float(data["start"]),
|
|
37
|
+
end=float(data["end"]),
|
|
38
|
+
text=str(data["text"]),
|
|
39
|
+
speaker=data.get("speaker"),
|
|
40
|
+
translated_text=data.get("translated_text"),
|
|
41
|
+
hidden=bool(data.get("hidden", False)),
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass(slots=True)
|
|
46
|
+
class Transcript:
|
|
47
|
+
"""A complete transcription result."""
|
|
48
|
+
|
|
49
|
+
source: str
|
|
50
|
+
language: str | None = None
|
|
51
|
+
segments: list[Segment] = field(default_factory=list)
|
|
52
|
+
platform: str | None = None
|
|
53
|
+
duration: float | None = None
|
|
54
|
+
engine: str | None = None
|
|
55
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
56
|
+
|
|
57
|
+
@property
|
|
58
|
+
def text(self) -> str:
|
|
59
|
+
return "\n".join(s.display_text().strip() for s in self.segments if not s.hidden)
|
|
60
|
+
|
|
61
|
+
def to_dict(self) -> dict[str, Any]:
|
|
62
|
+
return {
|
|
63
|
+
"source": self.source,
|
|
64
|
+
"language": self.language,
|
|
65
|
+
"platform": self.platform,
|
|
66
|
+
"duration": self.duration,
|
|
67
|
+
"engine": self.engine,
|
|
68
|
+
"metadata": self.metadata,
|
|
69
|
+
"segments": [s.to_dict() for s in self.segments],
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
@classmethod
|
|
73
|
+
def from_dict(cls, data: dict[str, Any]) -> Transcript:
|
|
74
|
+
return cls(
|
|
75
|
+
source=data.get("source", ""),
|
|
76
|
+
language=data.get("language"),
|
|
77
|
+
platform=data.get("platform"),
|
|
78
|
+
duration=data.get("duration"),
|
|
79
|
+
engine=data.get("engine"),
|
|
80
|
+
metadata=data.get("metadata", {}) or {},
|
|
81
|
+
segments=[Segment.from_dict(s) for s in data.get("segments", [])],
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
def to_json(self, *, indent: int = 2) -> str:
|
|
85
|
+
return json.dumps(self.to_dict(), ensure_ascii=False, indent=indent)
|
|
86
|
+
|
|
87
|
+
@classmethod
|
|
88
|
+
def from_json(cls, raw: str) -> Transcript:
|
|
89
|
+
return cls.from_dict(json.loads(raw))
|
|
90
|
+
|
|
91
|
+
def save_json(self, path: str | Path) -> Path:
|
|
92
|
+
p = Path(path)
|
|
93
|
+
p.write_text(self.to_json(), encoding="utf-8")
|
|
94
|
+
return p
|
|
95
|
+
|
|
96
|
+
@classmethod
|
|
97
|
+
def load_json(cls, path: str | Path) -> Transcript:
|
|
98
|
+
return cls.from_json(Path(path).read_text(encoding="utf-8"))
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
"""Output path resolution.
|
|
2
|
+
|
|
3
|
+
Adapters accept a destination directory from a caller - a CLI user, an HTTP
|
|
4
|
+
client, or a model. That value must not be able to name an arbitrary location on
|
|
5
|
+
the host.
|
|
6
|
+
|
|
7
|
+
Policy:
|
|
8
|
+
|
|
9
|
+
- The process chooses an allowed root. `TEXTFLOWKIT_OUTPUT_ROOT` sets it; when
|
|
10
|
+
unset the root is the current working directory. This keeps the CLI's default
|
|
11
|
+
("write where I ran it") working while giving a server operator one switch to
|
|
12
|
+
confine every write.
|
|
13
|
+
- A requested directory must resolve *inside* that root. `..` segments, absolute
|
|
14
|
+
paths elsewhere, and symlinks that escape are all rejected.
|
|
15
|
+
- The returned path is the resolved absolute path, so callers never re-interpret
|
|
16
|
+
the raw string.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import os
|
|
22
|
+
import sys
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
|
|
25
|
+
ENV_OUTPUT_ROOT = "TEXTFLOWKIT_OUTPUT_ROOT"
|
|
26
|
+
ENV_INPUT_ROOT = "TEXTFLOWKIT_INPUT_ROOT"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class UnsafeOutputPathError(ValueError):
|
|
30
|
+
"""Raised when a requested output directory escapes the allowed root."""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class UnsafeInputPathError(ValueError):
|
|
34
|
+
"""Raised when a local input path escapes the allowed input root."""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def output_root() -> Path:
|
|
38
|
+
"""The directory all rendered output must live under.
|
|
39
|
+
|
|
40
|
+
Default: the current working directory, which is the least surprising answer
|
|
41
|
+
for a CLI ("write where I ran it"). A caller that asks for somewhere else
|
|
42
|
+
inside the same machine is not doing anything the operator could not, so
|
|
43
|
+
`TEXTFLOWKIT_OUTPUT_ROOT` is the switch that imposes a real boundary when a
|
|
44
|
+
deployment needs one.
|
|
45
|
+
"""
|
|
46
|
+
raw = os.environ.get(ENV_OUTPUT_ROOT)
|
|
47
|
+
base = Path(raw) if raw else Path.cwd()
|
|
48
|
+
return base.expanduser().resolve()
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def output_is_confined() -> bool:
|
|
52
|
+
"""Whether an output boundary was explicitly requested.
|
|
53
|
+
|
|
54
|
+
Distinguishes "the operator chose a root" from "we fell back to cwd". The
|
|
55
|
+
CLI writes wherever asked when no root is set; an explicitly configured root
|
|
56
|
+
is still enforced exactly as before.
|
|
57
|
+
"""
|
|
58
|
+
return bool(os.environ.get(ENV_OUTPUT_ROOT))
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def resolve_output_dir(requested: str | None) -> Path:
|
|
62
|
+
"""Resolve a caller-supplied output directory.
|
|
63
|
+
|
|
64
|
+
Confinement applies only when `TEXTFLOWKIT_OUTPUT_ROOT` is set. Without it
|
|
65
|
+
there is no boundary to violate: the caller is the operator and already has
|
|
66
|
+
whatever access the machine gives them, so refusing a path they own would be
|
|
67
|
+
the tool inventing a restriction rather than enforcing one.
|
|
68
|
+
|
|
69
|
+
Raises `UnsafeOutputPathError` when an explicit root is set and the path
|
|
70
|
+
escapes it.
|
|
71
|
+
"""
|
|
72
|
+
root = output_root()
|
|
73
|
+
|
|
74
|
+
if requested is None or requested == "":
|
|
75
|
+
target = root
|
|
76
|
+
else:
|
|
77
|
+
candidate = Path(requested).expanduser()
|
|
78
|
+
target = candidate if candidate.is_absolute() else (root / candidate)
|
|
79
|
+
|
|
80
|
+
# strict=False: the directory may not exist yet.
|
|
81
|
+
resolved = target.resolve()
|
|
82
|
+
|
|
83
|
+
if output_is_confined() and resolved != root and root not in resolved.parents:
|
|
84
|
+
raise UnsafeOutputPathError(
|
|
85
|
+
f"output directory '{requested}' is outside the allowed root "
|
|
86
|
+
f"'{root}'. Set {ENV_OUTPUT_ROOT} to widen the root, or choose a "
|
|
87
|
+
"path inside it."
|
|
88
|
+
)
|
|
89
|
+
return resolved
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def ensure_output_dir(requested: str | None) -> Path:
|
|
93
|
+
"""Resolve and create the output directory."""
|
|
94
|
+
resolved = resolve_output_dir(requested)
|
|
95
|
+
resolved.mkdir(parents=True, exist_ok=True)
|
|
96
|
+
# Recheck after creation: a symlink/junction may have changed since the
|
|
97
|
+
# first resolve. File publishing repeats the check at point of use.
|
|
98
|
+
return resolve_output_dir(str(resolved))
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def verify_output_file_target(path: Path) -> None:
|
|
102
|
+
"""Check the destination parent immediately before publishing a file."""
|
|
103
|
+
if not output_is_confined():
|
|
104
|
+
return
|
|
105
|
+
root = output_root()
|
|
106
|
+
parent = path.parent.resolve(strict=True)
|
|
107
|
+
if parent != root and root not in parent.parents:
|
|
108
|
+
raise UnsafeOutputPathError(
|
|
109
|
+
f"output file '{path}' is outside the allowed root '{root}'"
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
# --- input confinement ----------------------------------------------------
|
|
114
|
+
|
|
115
|
+
def resolve_input_path(requested: str | Path, *, root: str | Path | None) -> Path:
|
|
116
|
+
"""Resolve a local input path, optionally confined to `root`.
|
|
117
|
+
|
|
118
|
+
Confinement is opt-in. When `root` is None the path is returned resolved but
|
|
119
|
+
unrestricted, because the caller is the principal - a person who typed the
|
|
120
|
+
path, or an agent acting with their authority. Adapters also pass None by
|
|
121
|
+
default; set TEXTFLOWKIT_INPUT_ROOT when the caller is *not* the machine's
|
|
122
|
+
owner (a shared or network-reachable deployment).
|
|
123
|
+
|
|
124
|
+
Raises `UnsafeInputPathError` when the path is outside the root, or when it
|
|
125
|
+
does not name readable regular file.
|
|
126
|
+
"""
|
|
127
|
+
candidate = Path(requested).expanduser()
|
|
128
|
+
|
|
129
|
+
if root is None:
|
|
130
|
+
resolved = candidate.resolve()
|
|
131
|
+
if not resolved.exists():
|
|
132
|
+
raise FileNotFoundError(f"no such file: {requested}")
|
|
133
|
+
if not resolved.is_file():
|
|
134
|
+
raise ValueError(f"not a file: {requested}")
|
|
135
|
+
return resolved
|
|
136
|
+
|
|
137
|
+
base = Path(root).expanduser().resolve()
|
|
138
|
+
target = candidate if candidate.is_absolute() else (base / candidate)
|
|
139
|
+
resolved = target.resolve()
|
|
140
|
+
|
|
141
|
+
if resolved != base and base not in resolved.parents:
|
|
142
|
+
raise UnsafeInputPathError(
|
|
143
|
+
f"input path '{requested}' is outside the allowed input root "
|
|
144
|
+
f"'{base}'. Set {ENV_INPUT_ROOT} to widen the root, or use a path "
|
|
145
|
+
"inside it."
|
|
146
|
+
)
|
|
147
|
+
if not resolved.exists():
|
|
148
|
+
raise FileNotFoundError(f"no such file: {requested}")
|
|
149
|
+
if not resolved.is_file():
|
|
150
|
+
raise ValueError(f"not a file: {requested}")
|
|
151
|
+
return resolved
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def opened_file_path(fd: int, fallback: Path) -> Path:
|
|
155
|
+
"""Resolve the path of the *opened handle*, not a name checked earlier."""
|
|
156
|
+
if os.name == "nt":
|
|
157
|
+
import ctypes
|
|
158
|
+
import msvcrt
|
|
159
|
+
from ctypes import wintypes
|
|
160
|
+
|
|
161
|
+
kernel = ctypes.WinDLL("kernel32", use_last_error=True)
|
|
162
|
+
fn = kernel.GetFinalPathNameByHandleW
|
|
163
|
+
fn.argtypes = [wintypes.HANDLE, wintypes.LPWSTR, wintypes.DWORD, wintypes.DWORD]
|
|
164
|
+
fn.restype = wintypes.DWORD
|
|
165
|
+
buffer = ctypes.create_unicode_buffer(32768)
|
|
166
|
+
length = fn(msvcrt.get_osfhandle(fd), buffer, len(buffer), 0)
|
|
167
|
+
if not length or length >= len(buffer):
|
|
168
|
+
raise UnsafeInputPathError("could not verify the opened input file path")
|
|
169
|
+
raw = buffer.value
|
|
170
|
+
if raw.startswith("\\\\?\\UNC\\"):
|
|
171
|
+
raw = "\\\\" + raw[8:]
|
|
172
|
+
elif raw.startswith("\\\\?\\"):
|
|
173
|
+
raw = raw[4:]
|
|
174
|
+
return Path(raw).resolve()
|
|
175
|
+
if os.path.exists(f"/proc/self/fd/{fd}"):
|
|
176
|
+
return Path(os.readlink(f"/proc/self/fd/{fd}")).resolve()
|
|
177
|
+
if sys.platform == "darwin":
|
|
178
|
+
try:
|
|
179
|
+
return _darwin_opened_file_path(fd)
|
|
180
|
+
except (OSError, ValueError) as exc:
|
|
181
|
+
raise UnsafeInputPathError(
|
|
182
|
+
f"cannot verify opened input file path: {fallback}: {exc}"
|
|
183
|
+
) from exc
|
|
184
|
+
raise UnsafeInputPathError(f"cannot verify opened input file path: {fallback}")
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _darwin_opened_file_path(fd: int) -> Path:
|
|
188
|
+
"""Use Apple's F_GETPATH even when Python omits the symbolic constant."""
|
|
189
|
+
import fcntl
|
|
190
|
+
|
|
191
|
+
# Apple bsd/sys/fcntl.h defines F_GETPATH as 50. MAXPATHLEN is 1024;
|
|
192
|
+
# Python 3.10-3.13 fcntl() also caps the argument buffer at 1024 bytes.
|
|
193
|
+
# A larger buffer raises ValueError before the OS call.
|
|
194
|
+
command = getattr(fcntl, "F_GETPATH", 50)
|
|
195
|
+
raw = fcntl.fcntl(fd, command, b"\0" * 1024)
|
|
196
|
+
path = os.fsdecode(raw.split(b"\0", 1)[0])
|
|
197
|
+
if not path:
|
|
198
|
+
raise ValueError("F_GETPATH returned an empty path")
|
|
199
|
+
return Path(path).resolve()
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def default_input_root() -> Path | None:
|
|
203
|
+
"""The configured input root, or None when confinement is not requested."""
|
|
204
|
+
raw = os.environ.get(ENV_INPUT_ROOT)
|
|
205
|
+
return Path(raw).expanduser().resolve() if raw else None
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def server_input_root() -> Path | None:
|
|
209
|
+
"""The input root a long-running adapter should enforce, or None for no limit.
|
|
210
|
+
|
|
211
|
+
Default: **no confinement.** An adapter runs as the person who started it and
|
|
212
|
+
inherits their access to the machine. When that person is the operator - a
|
|
213
|
+
developer running this for themselves, or an agent acting on their behalf -
|
|
214
|
+
confining it to the working directory only refuses paths they are already
|
|
215
|
+
entitled to use, which reads as the tool being broken.
|
|
216
|
+
|
|
217
|
+
Confinement is still one environment variable away for the case it was
|
|
218
|
+
actually designed for: exposing a server to callers who are *not* the owner.
|
|
219
|
+
A shared or network-reachable deployment should set:
|
|
220
|
+
|
|
221
|
+
TEXTFLOWKIT_INPUT_ROOT=/srv/media # one directory
|
|
222
|
+
TEXTFLOWKIT_INPUT_ROOT=C:\\media # one tree on Windows
|
|
223
|
+
|
|
224
|
+
Setting it to a filesystem root is equivalent to no confinement.
|
|
225
|
+
"""
|
|
226
|
+
return default_input_root()
|