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