subthis 1.1.0__tar.gz

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.
subthis-1.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Bram
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
subthis-1.1.0/PKG-INFO ADDED
@@ -0,0 +1,77 @@
1
+ Metadata-Version: 2.4
2
+ Name: subthis
3
+ Version: 1.1.0
4
+ Summary: Create short, accurately worded SRT captions from a video
5
+ Author: Bram
6
+ License-Expression: MIT
7
+ Project-URL: Repository, https://github.com/ItayCohen-Prog/subthis
8
+ Keywords: subtitles,srt,captions,transcription,whisper
9
+ Requires-Python: >=3.10
10
+ Description-Content-Type: text/markdown
11
+ License-File: LICENSE
12
+ Dynamic: license-file
13
+
14
+ # subthis
15
+
16
+ Turns a video (or audio file) into an SRT subtitle file with short, accurately worded captions, at most three words per cue. Built for Hebrew with English tech terms mixed in: it transcribes twice through the OpenAI API (`gpt-transcribe` for wording, `whisper-1` for word timing), aligns the two, and canonicalizes product names like OpenAI, Claude and ChatGPT.
17
+
18
+ Works on Linux, macOS and Windows. No Python dependencies outside the standard library.
19
+
20
+ ## Requirements
21
+
22
+ - [ffmpeg](https://ffmpeg.org/) (`ffmpeg` and `ffprobe` on PATH). `subthis setup` tells you how to install it if it's missing.
23
+ - An OpenAI API key.
24
+
25
+ ## Install
26
+
27
+ With [uv](https://docs.astral.sh/uv/) (installs Python too if needed):
28
+
29
+ ```sh
30
+ uv tool install /path/to/subthis
31
+ ```
32
+
33
+ Or with pipx:
34
+
35
+ ```sh
36
+ pipx install /path/to/subthis
37
+ ```
38
+
39
+ Both put a `subthis` command on your PATH on any OS. To install from a git remote instead: `uv tool install git+<repo-url>`.
40
+
41
+ ## Setup
42
+
43
+ ```sh
44
+ subthis setup
45
+ ```
46
+
47
+ Checks for ffmpeg, asks for your OpenAI API key (input hidden), verifies it against the API, and stores it in the config directory:
48
+
49
+ - Linux/macOS: `~/.config/subthis/`
50
+ - Windows: `%APPDATA%\subthis\`
51
+
52
+ It also creates `terms.txt` there, where you can add professional terms and spelling corrections:
53
+
54
+ ```
55
+ MyCompany
56
+ DaVinci Resolve = דה וינצ'י ריזולב | Davinci Resolve
57
+ ```
58
+
59
+ `OPENAI_API_KEY` in the environment overrides the stored key.
60
+
61
+ ## Use
62
+
63
+ ```sh
64
+ subthis lecture.mp4 # writes lecture.srt next to the input
65
+ subthis talk.mp4 -o subs/talk.srt # explicit output path
66
+ subthis clip.mp4 --max-words 2 # shorter cues
67
+ subthis clip.mp4 --term "Omarchy" # one-off extra term
68
+ subthis clip.mp4 --language he --language en
69
+ ```
70
+
71
+ Long videos are chunked and merged automatically; uploads stay under OpenAI's 25 MB limit.
72
+
73
+ ## Tests
74
+
75
+ ```sh
76
+ python -m unittest discover -s tests
77
+ ```
@@ -0,0 +1,64 @@
1
+ # subthis
2
+
3
+ Turns a video (or audio file) into an SRT subtitle file with short, accurately worded captions, at most three words per cue. Built for Hebrew with English tech terms mixed in: it transcribes twice through the OpenAI API (`gpt-transcribe` for wording, `whisper-1` for word timing), aligns the two, and canonicalizes product names like OpenAI, Claude and ChatGPT.
4
+
5
+ Works on Linux, macOS and Windows. No Python dependencies outside the standard library.
6
+
7
+ ## Requirements
8
+
9
+ - [ffmpeg](https://ffmpeg.org/) (`ffmpeg` and `ffprobe` on PATH). `subthis setup` tells you how to install it if it's missing.
10
+ - An OpenAI API key.
11
+
12
+ ## Install
13
+
14
+ With [uv](https://docs.astral.sh/uv/) (installs Python too if needed):
15
+
16
+ ```sh
17
+ uv tool install /path/to/subthis
18
+ ```
19
+
20
+ Or with pipx:
21
+
22
+ ```sh
23
+ pipx install /path/to/subthis
24
+ ```
25
+
26
+ Both put a `subthis` command on your PATH on any OS. To install from a git remote instead: `uv tool install git+<repo-url>`.
27
+
28
+ ## Setup
29
+
30
+ ```sh
31
+ subthis setup
32
+ ```
33
+
34
+ Checks for ffmpeg, asks for your OpenAI API key (input hidden), verifies it against the API, and stores it in the config directory:
35
+
36
+ - Linux/macOS: `~/.config/subthis/`
37
+ - Windows: `%APPDATA%\subthis\`
38
+
39
+ It also creates `terms.txt` there, where you can add professional terms and spelling corrections:
40
+
41
+ ```
42
+ MyCompany
43
+ DaVinci Resolve = דה וינצ'י ריזולב | Davinci Resolve
44
+ ```
45
+
46
+ `OPENAI_API_KEY` in the environment overrides the stored key.
47
+
48
+ ## Use
49
+
50
+ ```sh
51
+ subthis lecture.mp4 # writes lecture.srt next to the input
52
+ subthis talk.mp4 -o subs/talk.srt # explicit output path
53
+ subthis clip.mp4 --max-words 2 # shorter cues
54
+ subthis clip.mp4 --term "Omarchy" # one-off extra term
55
+ subthis clip.mp4 --language he --language en
56
+ ```
57
+
58
+ Long videos are chunked and merged automatically; uploads stay under OpenAI's 25 MB limit.
59
+
60
+ ## Tests
61
+
62
+ ```sh
63
+ python -m unittest discover -s tests
64
+ ```
@@ -0,0 +1,23 @@
1
+ [build-system]
2
+ requires = ["setuptools>=77"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "subthis"
7
+ version = "1.1.0"
8
+ description = "Create short, accurately worded SRT captions from a video"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ authors = [{ name = "Bram" }]
12
+ license = "MIT"
13
+ license-files = ["LICENSE"]
14
+ keywords = ["subtitles", "srt", "captions", "transcription", "whisper"]
15
+
16
+ [project.urls]
17
+ Repository = "https://github.com/ItayCohen-Prog/subthis"
18
+
19
+ [project.scripts]
20
+ subthis = "subthis:main"
21
+
22
+ [tool.setuptools]
23
+ py-modules = ["subthis"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,77 @@
1
+ Metadata-Version: 2.4
2
+ Name: subthis
3
+ Version: 1.1.0
4
+ Summary: Create short, accurately worded SRT captions from a video
5
+ Author: Bram
6
+ License-Expression: MIT
7
+ Project-URL: Repository, https://github.com/ItayCohen-Prog/subthis
8
+ Keywords: subtitles,srt,captions,transcription,whisper
9
+ Requires-Python: >=3.10
10
+ Description-Content-Type: text/markdown
11
+ License-File: LICENSE
12
+ Dynamic: license-file
13
+
14
+ # subthis
15
+
16
+ Turns a video (or audio file) into an SRT subtitle file with short, accurately worded captions, at most three words per cue. Built for Hebrew with English tech terms mixed in: it transcribes twice through the OpenAI API (`gpt-transcribe` for wording, `whisper-1` for word timing), aligns the two, and canonicalizes product names like OpenAI, Claude and ChatGPT.
17
+
18
+ Works on Linux, macOS and Windows. No Python dependencies outside the standard library.
19
+
20
+ ## Requirements
21
+
22
+ - [ffmpeg](https://ffmpeg.org/) (`ffmpeg` and `ffprobe` on PATH). `subthis setup` tells you how to install it if it's missing.
23
+ - An OpenAI API key.
24
+
25
+ ## Install
26
+
27
+ With [uv](https://docs.astral.sh/uv/) (installs Python too if needed):
28
+
29
+ ```sh
30
+ uv tool install /path/to/subthis
31
+ ```
32
+
33
+ Or with pipx:
34
+
35
+ ```sh
36
+ pipx install /path/to/subthis
37
+ ```
38
+
39
+ Both put a `subthis` command on your PATH on any OS. To install from a git remote instead: `uv tool install git+<repo-url>`.
40
+
41
+ ## Setup
42
+
43
+ ```sh
44
+ subthis setup
45
+ ```
46
+
47
+ Checks for ffmpeg, asks for your OpenAI API key (input hidden), verifies it against the API, and stores it in the config directory:
48
+
49
+ - Linux/macOS: `~/.config/subthis/`
50
+ - Windows: `%APPDATA%\subthis\`
51
+
52
+ It also creates `terms.txt` there, where you can add professional terms and spelling corrections:
53
+
54
+ ```
55
+ MyCompany
56
+ DaVinci Resolve = דה וינצ'י ריזולב | Davinci Resolve
57
+ ```
58
+
59
+ `OPENAI_API_KEY` in the environment overrides the stored key.
60
+
61
+ ## Use
62
+
63
+ ```sh
64
+ subthis lecture.mp4 # writes lecture.srt next to the input
65
+ subthis talk.mp4 -o subs/talk.srt # explicit output path
66
+ subthis clip.mp4 --max-words 2 # shorter cues
67
+ subthis clip.mp4 --term "Omarchy" # one-off extra term
68
+ subthis clip.mp4 --language he --language en
69
+ ```
70
+
71
+ Long videos are chunked and merged automatically; uploads stay under OpenAI's 25 MB limit.
72
+
73
+ ## Tests
74
+
75
+ ```sh
76
+ python -m unittest discover -s tests
77
+ ```
@@ -0,0 +1,10 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ subthis.py
5
+ subthis.egg-info/PKG-INFO
6
+ subthis.egg-info/SOURCES.txt
7
+ subthis.egg-info/dependency_links.txt
8
+ subthis.egg-info/entry_points.txt
9
+ subthis.egg-info/top_level.txt
10
+ tests/test_subthis.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ subthis = subthis:main
@@ -0,0 +1 @@
1
+ subthis
@@ -0,0 +1,726 @@
1
+ #!/usr/bin/env python3
2
+ """Create short, accurately worded SRT captions from a video."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import contextlib
8
+ import dataclasses
9
+ import difflib
10
+ import getpass
11
+ import json
12
+ import os
13
+ import re
14
+ import shutil
15
+ import subprocess
16
+ import sys
17
+ import tempfile
18
+ import unicodedata
19
+ import urllib.error
20
+ import urllib.request
21
+ import uuid
22
+ from concurrent.futures import ThreadPoolExecutor
23
+ from pathlib import Path
24
+ from typing import Iterable, Sequence
25
+
26
+
27
+ __version__ = "1.1.0"
28
+
29
+ API_URL = "https://api.openai.com/v1/audio/transcriptions"
30
+ KEY_CHECK_URL = "https://api.openai.com/v1/models/whisper-1"
31
+
32
+
33
+ def _config_dir() -> Path:
34
+ if sys.platform == "win32":
35
+ appdata = os.environ.get("APPDATA", "").strip()
36
+ if appdata:
37
+ return Path(appdata) / "subthis"
38
+ xdg = os.environ.get("XDG_CONFIG_HOME", "").strip()
39
+ base = Path(xdg) if xdg else Path.home() / ".config"
40
+ return base / "subthis"
41
+
42
+
43
+ CONFIG_DIR = _config_dir()
44
+ ENV_FILE = CONFIG_DIR / ".env"
45
+ TERMS_FILE = CONFIG_DIR / "terms.txt"
46
+ ACCURATE_MODEL = "gpt-transcribe"
47
+ TIMING_MODEL = "whisper-1"
48
+ MAX_UPLOAD_BYTES = 24 * 1024 * 1024
49
+ CHUNK_SECONDS = 60 * 60
50
+ CHUNK_OVERLAP_SECONDS = 1.5
51
+
52
+
53
+ DEFAULT_ALIASES: dict[str, list[str]] = {
54
+ "OpenAI": ["OpenAI", "Open AI", "אופן איי איי", "אופן איי-איי", "אופן איי"],
55
+ "Claude": ["Claude", "Clod", "קלוד", "קלאוד"],
56
+ "ChatGPT": ["ChatGPT", "Chat GPT", "צ'אט ג'יפיטי", "צ׳אט ג׳יפיטי", "צ'ט ג'יפיטי"],
57
+ "Codex": ["Codex", "קודקס"],
58
+ "Anthropic": ["Anthropic", "אנתרופיק"],
59
+ "Gemini": ["Gemini", "ג'מיני", "ג׳מיני"],
60
+ "Cursor": ["Cursor", "קרסר", "קורסר"],
61
+ "GitHub": ["GitHub", "Git Hub", "גיטהאב", "גיט האב"],
62
+ "WordPress": ["WordPress", "Word Press", "וורדפרס"],
63
+ "JavaScript": ["JavaScript", "Java Script", "ג'אווה סקריפט", "ג׳אווה סקריפט"],
64
+ "TypeScript": ["TypeScript", "Type Script", "טייפסקריפט"],
65
+ "Python": ["Python", "פייתון", "פייטון"],
66
+ "Linux": ["Linux", "לינוקס"],
67
+ "API": ["API", "A P I", "איי פי איי"],
68
+ "CLI": ["CLI", "C L I", "סי אל איי"],
69
+ }
70
+
71
+ DEFAULT_KEYWORDS = [
72
+ "OpenAI",
73
+ "Claude",
74
+ "ChatGPT",
75
+ "Codex",
76
+ "Anthropic",
77
+ "Gemini",
78
+ "Cursor",
79
+ "GitHub",
80
+ "API",
81
+ "CLI",
82
+ ]
83
+
84
+
85
+ class SubthisError(RuntimeError):
86
+ """A safe, user-facing command error."""
87
+
88
+
89
+ @dataclasses.dataclass(frozen=True)
90
+ class TimedWord:
91
+ text: str
92
+ start: float
93
+ end: float
94
+
95
+
96
+ @dataclasses.dataclass(frozen=True)
97
+ class Cue:
98
+ start: float
99
+ end: float
100
+ text: str
101
+
102
+
103
+ @dataclasses.dataclass(frozen=True)
104
+ class AudioChunk:
105
+ path: Path
106
+ offset: float
107
+ duration: float
108
+
109
+
110
+ @dataclasses.dataclass(frozen=True)
111
+ class Config:
112
+ api_key: str
113
+ aliases: dict[str, list[str]]
114
+ terms: list[str]
115
+ languages: list[str]
116
+ max_words: int
117
+
118
+
119
+ def _normalized_token(text: str) -> str:
120
+ normalized = unicodedata.normalize("NFKD", text).casefold()
121
+ return "".join(
122
+ char
123
+ for char in normalized
124
+ if char.isalnum() and unicodedata.category(char) != "Mn"
125
+ )
126
+
127
+
128
+ def canonicalize_terms(text: str, aliases: dict[str, list[str]]) -> str:
129
+ replacements: list[tuple[str, str]] = []
130
+ for canonical, spellings in aliases.items():
131
+ for spelling in set([canonical, *spellings]):
132
+ cleaned = spelling.strip()
133
+ if cleaned:
134
+ replacements.append((cleaned, canonical))
135
+ replacements.sort(key=lambda item: len(item[0]), reverse=True)
136
+
137
+ result = text
138
+ for spelling, canonical in replacements:
139
+ pattern = rf"(?<!\w){re.escape(spelling)}(?!\w)"
140
+ result = re.sub(pattern, lambda _match: canonical, result, flags=re.IGNORECASE)
141
+ return result
142
+
143
+
144
+ def strip_caption_punctuation(text: str) -> str:
145
+ without_punctuation = "".join(
146
+ char for char in text if not unicodedata.category(char).startswith("P")
147
+ )
148
+ return " ".join(without_punctuation.split())
149
+
150
+
151
+ def _distribute_words(tokens: Sequence[str], start: float, end: float) -> list[TimedWord]:
152
+ if not tokens:
153
+ return []
154
+ start = max(0.0, start)
155
+ end = max(start, end)
156
+ width = (end - start) / len(tokens)
157
+ return [
158
+ TimedWord(token, start + index * width, start + (index + 1) * width)
159
+ for index, token in enumerate(tokens)
160
+ ]
161
+
162
+
163
+ def align_accurate_words(text: str, timed_words: Sequence[TimedWord]) -> list[TimedWord]:
164
+ accurate_tokens = text.split()
165
+ if not accurate_tokens:
166
+ return []
167
+ if not timed_words:
168
+ raise SubthisError("The timing pass returned no words for a non-empty transcript.")
169
+
170
+ accurate_normalized = [_normalized_token(token) for token in accurate_tokens]
171
+ timing_normalized = [_normalized_token(word.text) for word in timed_words]
172
+ matcher = difflib.SequenceMatcher(
173
+ None,
174
+ accurate_normalized,
175
+ timing_normalized,
176
+ autojunk=False,
177
+ )
178
+ aligned: list[TimedWord] = []
179
+
180
+ for tag, a_start, a_end, w_start, w_end in matcher.get_opcodes():
181
+ tokens = accurate_tokens[a_start:a_end]
182
+ if tag == "equal":
183
+ aligned.extend(
184
+ TimedWord(token, source.start, source.end)
185
+ for token, source in zip(tokens, timed_words[w_start:w_end])
186
+ )
187
+ continue
188
+ if tag == "delete":
189
+ continue
190
+ if w_start < w_end:
191
+ interval_start = timed_words[w_start].start
192
+ interval_end = timed_words[w_end - 1].end
193
+ else:
194
+ interval_start = aligned[-1].end if aligned else timed_words[0].start
195
+ interval_end = (
196
+ timed_words[w_start].start
197
+ if w_start < len(timed_words)
198
+ else max(interval_start, timed_words[-1].end)
199
+ )
200
+ aligned.extend(_distribute_words(tokens, interval_start, interval_end))
201
+
202
+ monotonic: list[TimedWord] = []
203
+ previous_end = 0.0
204
+ for word in aligned:
205
+ start = max(previous_end, word.start)
206
+ end = max(start, word.end)
207
+ monotonic.append(TimedWord(word.text, start, end))
208
+ previous_end = end
209
+ return monotonic
210
+
211
+
212
+ def make_cues(words: Sequence[TimedWord], media_end: float, max_words: int = 3) -> list[Cue]:
213
+ if not 1 <= max_words <= 3:
214
+ raise ValueError("max_words must be between 1 and 3")
215
+ clean_words = [
216
+ TimedWord(cleaned, word.start, word.end)
217
+ for word in words
218
+ if (cleaned := strip_caption_punctuation(word.text))
219
+ ]
220
+ if not clean_words:
221
+ return []
222
+
223
+ groups = [
224
+ list(clean_words[index : index + max_words])
225
+ for index in range(0, len(clean_words), max_words)
226
+ ]
227
+ cues: list[Cue] = []
228
+ for index, group in enumerate(groups):
229
+ start = group[0].start
230
+ if index + 1 < len(groups):
231
+ end = groups[index + 1][0].start
232
+ else:
233
+ end = min(media_end, group[-1].end + 0.5)
234
+ end = max(start + 0.001, end)
235
+ cues.append(Cue(start, end, " ".join(word.text for word in group)))
236
+ return cues
237
+
238
+
239
+ def _srt_time(seconds: float) -> str:
240
+ total_ms = max(0, int(seconds * 1000 + 0.5))
241
+ hours, remainder = divmod(total_ms, 3_600_000)
242
+ minutes, remainder = divmod(remainder, 60_000)
243
+ secs, milliseconds = divmod(remainder, 1_000)
244
+ return f"{hours:02d}:{minutes:02d}:{secs:02d},{milliseconds:03d}"
245
+
246
+
247
+ def render_srt(cues: Sequence[Cue]) -> str:
248
+ blocks = [
249
+ f"{index}\n{_srt_time(cue.start)} --> {_srt_time(cue.end)}\n"
250
+ f"{strip_caption_punctuation(cue.text)}"
251
+ for index, cue in enumerate(cues, start=1)
252
+ ]
253
+ return "\n\n".join(blocks) + ("\n" if blocks else "")
254
+
255
+
256
+ def merge_chunk_words(chunks: Sequence[Sequence[TimedWord]]) -> list[TimedWord]:
257
+ merged: list[TimedWord] = []
258
+ for chunk in chunks:
259
+ current = list(chunk)
260
+ if not current:
261
+ continue
262
+ overlap = 0
263
+ maximum = min(30, len(merged), len(current))
264
+ for size in range(maximum, 0, -1):
265
+ left = [_normalized_token(word.text) for word in merged[-size:]]
266
+ right = [_normalized_token(word.text) for word in current[:size]]
267
+ if left == right:
268
+ overlap = size
269
+ break
270
+ merged.extend(current[overlap:])
271
+ return merged
272
+
273
+
274
+ def _run(command: Sequence[str]) -> subprocess.CompletedProcess[str]:
275
+ try:
276
+ return subprocess.run(
277
+ command,
278
+ check=True,
279
+ stdout=subprocess.PIPE,
280
+ stderr=subprocess.PIPE,
281
+ text=True,
282
+ )
283
+ except FileNotFoundError as error:
284
+ raise SubthisError(f"Required command not found: {command[0]}") from error
285
+ except subprocess.CalledProcessError as error:
286
+ detail = error.stderr.strip().splitlines()
287
+ message = detail[-1] if detail else "unknown media error"
288
+ raise SubthisError(f"{command[0]} failed: {message}") from error
289
+
290
+
291
+ def probe_duration(path: Path) -> float:
292
+ result = _run(
293
+ [
294
+ "ffprobe",
295
+ "-v",
296
+ "error",
297
+ "-show_entries",
298
+ "format=duration",
299
+ "-of",
300
+ "json",
301
+ str(path),
302
+ ]
303
+ )
304
+ try:
305
+ duration = float(json.loads(result.stdout)["format"]["duration"])
306
+ except (KeyError, TypeError, ValueError, json.JSONDecodeError) as error:
307
+ raise SubthisError("Could not determine the video duration.") from error
308
+ if duration <= 0:
309
+ raise SubthisError("The video duration is zero.")
310
+ return duration
311
+
312
+
313
+ def extract_chunks(video: Path, directory: Path) -> tuple[list[AudioChunk], float]:
314
+ duration = probe_duration(video)
315
+ chunks: list[AudioChunk] = []
316
+ offset = 0.0
317
+ index = 0
318
+ while offset < duration:
319
+ chunk_duration = min(CHUNK_SECONDS + CHUNK_OVERLAP_SECONDS, duration - offset)
320
+ output = directory / f"chunk-{index:04d}.ogg"
321
+ command = ["ffmpeg", "-hide_banner", "-loglevel", "error", "-y"]
322
+ if offset:
323
+ command.extend(["-ss", f"{offset:.3f}"])
324
+ command.extend(
325
+ [
326
+ "-i",
327
+ str(video),
328
+ "-t",
329
+ f"{chunk_duration:.3f}",
330
+ "-vn",
331
+ "-ac",
332
+ "1",
333
+ "-ar",
334
+ "16000",
335
+ "-c:a",
336
+ "libopus",
337
+ "-b:a",
338
+ "32k",
339
+ "-application",
340
+ "voip",
341
+ str(output),
342
+ ]
343
+ )
344
+ _run(command)
345
+ if not output.exists() or output.stat().st_size == 0:
346
+ raise SubthisError("FFmpeg produced an empty audio file. The video may have no audio track.")
347
+ if output.stat().st_size > MAX_UPLOAD_BYTES:
348
+ raise SubthisError("An extracted audio chunk exceeds OpenAI's 25 MB upload limit.")
349
+ chunks.append(AudioChunk(output, offset, chunk_duration))
350
+ if offset + chunk_duration >= duration:
351
+ break
352
+ offset += CHUNK_SECONDS
353
+ index += 1
354
+ return chunks, duration
355
+
356
+
357
+ def _multipart(fields: Sequence[tuple[str, str]], file_path: Path) -> tuple[bytes, str]:
358
+ boundary = f"subthis-{uuid.uuid4().hex}"
359
+ body = bytearray()
360
+ for name, value in fields:
361
+ body.extend(f"--{boundary}\r\n".encode())
362
+ body.extend(f'Content-Disposition: form-data; name="{name}"\r\n\r\n'.encode())
363
+ body.extend(value.encode("utf-8"))
364
+ body.extend(b"\r\n")
365
+ body.extend(f"--{boundary}\r\n".encode())
366
+ body.extend(
367
+ (
368
+ f'Content-Disposition: form-data; name="file"; filename="{file_path.name}"\r\n'
369
+ "Content-Type: audio/ogg\r\n\r\n"
370
+ ).encode()
371
+ )
372
+ body.extend(file_path.read_bytes())
373
+ body.extend(b"\r\n")
374
+ body.extend(f"--{boundary}--\r\n".encode())
375
+ return bytes(body), f"multipart/form-data; boundary={boundary}"
376
+
377
+
378
+ def _api_error_message(payload: bytes, status: int | None = None) -> str:
379
+ prefix = f"OpenAI API error{f' {status}' if status else ''}"
380
+ try:
381
+ decoded = json.loads(payload.decode("utf-8", errors="replace"))
382
+ message = decoded.get("error", {}).get("message")
383
+ if isinstance(message, str) and message.strip():
384
+ return f"{prefix}: {message.strip()}"
385
+ except (json.JSONDecodeError, AttributeError):
386
+ pass
387
+ return prefix
388
+
389
+
390
+ def request_transcription(
391
+ api_key: str,
392
+ file_path: Path,
393
+ fields: Sequence[tuple[str, str]],
394
+ ) -> dict[str, object]:
395
+ body, content_type = _multipart(fields, file_path)
396
+ request = urllib.request.Request(
397
+ API_URL,
398
+ data=body,
399
+ headers={
400
+ "Authorization": f"Bearer {api_key}",
401
+ "Content-Type": content_type,
402
+ "User-Agent": "subthis/1.0",
403
+ },
404
+ method="POST",
405
+ )
406
+ try:
407
+ with urllib.request.urlopen(request, timeout=1800) as response:
408
+ payload = response.read()
409
+ except urllib.error.HTTPError as error:
410
+ raise SubthisError(_api_error_message(error.read(), error.code)) from error
411
+ except urllib.error.URLError as error:
412
+ reason = getattr(error, "reason", "connection failed")
413
+ raise SubthisError(f"Could not reach the OpenAI API: {reason}") from error
414
+ try:
415
+ decoded = json.loads(payload)
416
+ except json.JSONDecodeError as error:
417
+ raise SubthisError("OpenAI returned an invalid transcription response.") from error
418
+ if not isinstance(decoded, dict):
419
+ raise SubthisError("OpenAI returned an unexpected transcription response.")
420
+ return decoded
421
+
422
+
423
+ def transcribe_accurate(chunk: AudioChunk, config: Config) -> str:
424
+ prompt = (
425
+ "הקלטה בעברית עם מונחים מקצועיים באנגלית. יש לכתוב שמות חברות, "
426
+ "מוצרים וכלים באיות המקורי באנגלית כאשר כך הם נאמרים."
427
+ )
428
+ fields: list[tuple[str, str]] = [
429
+ ("model", ACCURATE_MODEL),
430
+ ("response_format", "json"),
431
+ ("prompt", prompt),
432
+ ]
433
+ fields.extend(("languages[]", language) for language in config.languages)
434
+ fields.extend(("keywords[]", term) for term in config.terms)
435
+ response = request_transcription(config.api_key, chunk.path, fields)
436
+ text = response.get("text")
437
+ if not isinstance(text, str):
438
+ raise SubthisError("The accurate transcription response did not contain text.")
439
+ canonical = canonicalize_terms(text.strip(), config.aliases)
440
+ return strip_caption_punctuation(canonical)
441
+
442
+
443
+ def transcribe_timing(chunk: AudioChunk, config: Config) -> list[TimedWord]:
444
+ timing_prompt = ", ".join(config.terms[:30])
445
+ fields: list[tuple[str, str]] = [
446
+ ("model", TIMING_MODEL),
447
+ ("response_format", "verbose_json"),
448
+ ("timestamp_granularities[]", "word"),
449
+ ("language", config.languages[0] if config.languages else "he"),
450
+ ]
451
+ if timing_prompt:
452
+ fields.append(("prompt", timing_prompt))
453
+ response = request_transcription(config.api_key, chunk.path, fields)
454
+ raw_words = response.get("words")
455
+ if not isinstance(raw_words, list):
456
+ raise SubthisError("The timing transcription response did not contain word timestamps.")
457
+ words: list[TimedWord] = []
458
+ for item in raw_words:
459
+ if not isinstance(item, dict):
460
+ continue
461
+ text = item.get("word")
462
+ start = item.get("start")
463
+ end = item.get("end")
464
+ if isinstance(text, str) and isinstance(start, (int, float)) and isinstance(end, (int, float)):
465
+ words.append(TimedWord(text.strip(), float(start), float(end)))
466
+ return words
467
+
468
+
469
+ def _load_api_key() -> str:
470
+ environment_key = os.environ.get("OPENAI_API_KEY", "").strip()
471
+ if environment_key:
472
+ return environment_key
473
+ if ENV_FILE.is_file():
474
+ with ENV_FILE.open(encoding="utf-8") as handle:
475
+ for line in handle:
476
+ match = re.match(r"\s*OPENAI_API_KEY\s*=\s*(.*?)\s*$", line)
477
+ if match:
478
+ value = match.group(1).strip().strip("\"'")
479
+ if value:
480
+ return value
481
+ raise SubthisError(
482
+ f"No OPENAI_API_KEY found in the environment or {ENV_FILE}. Run: subthis setup"
483
+ )
484
+
485
+
486
+ TERMS_TEMPLATE = """\
487
+ # Add one professional term per line so subthis sends it as a literal hint.
488
+ # Add spelling corrections with: Canonical name = alias one | alias two
489
+ #
490
+ # Examples:
491
+ # MyCompany
492
+ # DaVinci Resolve = דה וינצ'י ריזולב | Davinci Resolve
493
+ """
494
+
495
+
496
+ def _ffmpeg_install_hint() -> str:
497
+ if sys.platform == "win32":
498
+ return "winget install ffmpeg (or: choco install ffmpeg)"
499
+ if sys.platform == "darwin":
500
+ return "brew install ffmpeg"
501
+ return "install it with your package manager, e.g. sudo pacman -S ffmpeg / sudo apt install ffmpeg"
502
+
503
+
504
+ def _verify_api_key(api_key: str) -> str | None:
505
+ """Return a warning message when verification was inconclusive; raise on a bad key."""
506
+ request = urllib.request.Request(
507
+ KEY_CHECK_URL,
508
+ headers={"Authorization": f"Bearer {api_key}", "User-Agent": "subthis/1.1"},
509
+ )
510
+ try:
511
+ with urllib.request.urlopen(request, timeout=30):
512
+ return None
513
+ except urllib.error.HTTPError as error:
514
+ if error.code in (401, 403):
515
+ raise SubthisError(
516
+ "OpenAI rejected this API key. Check it at https://platform.openai.com/api-keys"
517
+ ) from error
518
+ return f"could not verify the key (HTTP {error.code}); saved it anyway"
519
+ except urllib.error.URLError as error:
520
+ reason = getattr(error, "reason", "connection failed")
521
+ return f"could not reach OpenAI to verify the key ({reason}); saved it anyway"
522
+
523
+
524
+ def _write_env_file(api_key: str) -> None:
525
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True)
526
+ descriptor = os.open(
527
+ ENV_FILE, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600
528
+ )
529
+ with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
530
+ handle.write(f"OPENAI_API_KEY={api_key}\n")
531
+ with contextlib.suppress(OSError):
532
+ os.chmod(ENV_FILE, 0o600)
533
+
534
+
535
+ def run_setup() -> int:
536
+ print(f"subthis {__version__} setup\n")
537
+
538
+ if shutil.which("ffmpeg") and shutil.which("ffprobe"):
539
+ print("ffmpeg: found")
540
+ else:
541
+ print("ffmpeg: NOT FOUND — subthis needs ffmpeg and ffprobe to read video.")
542
+ print(f" To install: {_ffmpeg_install_hint()}")
543
+
544
+ existing_key = ""
545
+ with contextlib.suppress(SubthisError):
546
+ existing_key = _load_api_key()
547
+ prompt = (
548
+ "OpenAI API key (Enter keeps the saved key): "
549
+ if existing_key
550
+ else "OpenAI API key: "
551
+ )
552
+ try:
553
+ if sys.stdin is None:
554
+ entered = ""
555
+ elif not sys.stdin.isatty():
556
+ # Piped input: Windows getpass reads the console device and would
557
+ # hang forever, so read stdin directly on every platform.
558
+ print(prompt, end="", flush=True)
559
+ entered = sys.stdin.readline().strip()
560
+ else:
561
+ entered = getpass.getpass(prompt).strip()
562
+ except EOFError:
563
+ entered = ""
564
+ api_key = entered or existing_key
565
+ if not api_key:
566
+ raise SubthisError("No API key entered. Get one at https://platform.openai.com/api-keys")
567
+
568
+ warning = _verify_api_key(api_key)
569
+ if warning:
570
+ print(f"Warning: {warning}", file=sys.stderr)
571
+ else:
572
+ print("API key verified with OpenAI.")
573
+
574
+ _write_env_file(api_key)
575
+ print(f"Saved API key to {ENV_FILE}")
576
+
577
+ if not TERMS_FILE.exists():
578
+ TERMS_FILE.write_text(TERMS_TEMPLATE, encoding="utf-8")
579
+ print(f"Created terms file at {TERMS_FILE}")
580
+ else:
581
+ print(f"Keeping existing terms file at {TERMS_FILE}")
582
+
583
+ print("\nSetup complete. Try: subthis video.mp4")
584
+ return 0
585
+
586
+
587
+ def load_terms(path: Path, additions: Iterable[str] = ()) -> tuple[dict[str, list[str]], list[str]]:
588
+ aliases = {canonical: list(spellings) for canonical, spellings in DEFAULT_ALIASES.items()}
589
+ keywords = list(DEFAULT_KEYWORDS)
590
+ if path.is_file():
591
+ with path.open(encoding="utf-8") as handle:
592
+ lines = list(handle)
593
+ else:
594
+ lines = []
595
+ lines.extend(additions)
596
+ for raw_line in lines:
597
+ line = raw_line.strip()
598
+ if not line or line.startswith("#"):
599
+ continue
600
+ canonical, separator, raw_aliases = line.partition("=")
601
+ canonical = canonical.strip()
602
+ if not canonical or any(char in canonical for char in "<>\r\n"):
603
+ continue
604
+ extra = [part.strip() for part in raw_aliases.split("|") if part.strip()] if separator else []
605
+ aliases.setdefault(canonical, [])
606
+ aliases[canonical].extend(extra)
607
+ if canonical not in keywords:
608
+ keywords.append(canonical)
609
+ return aliases, keywords
610
+
611
+
612
+ def transcribe_video(video: Path, config: Config) -> tuple[list[Cue], float]:
613
+ with tempfile.TemporaryDirectory(prefix="subthis-") as temporary:
614
+ chunks, duration = extract_chunks(video, Path(temporary))
615
+ all_words: list[list[TimedWord]] = []
616
+ for index, chunk in enumerate(chunks, start=1):
617
+ print(f"Transcribing chunk {index}/{len(chunks)}...", file=sys.stderr)
618
+ with ThreadPoolExecutor(max_workers=2) as pool:
619
+ accurate_future = pool.submit(transcribe_accurate, chunk, config)
620
+ timing_future = pool.submit(transcribe_timing, chunk, config)
621
+ accurate_text = accurate_future.result()
622
+ timing_words = timing_future.result()
623
+ if not accurate_text and not timing_words:
624
+ continue
625
+ if not accurate_text:
626
+ raise SubthisError("The accurate transcription was empty while speech timing was detected.")
627
+ aligned = align_accurate_words(accurate_text, timing_words)
628
+ all_words.append(
629
+ [
630
+ TimedWord(word.text, word.start + chunk.offset, word.end + chunk.offset)
631
+ for word in aligned
632
+ ]
633
+ )
634
+ merged = merge_chunk_words(all_words)
635
+ return make_cues(merged, duration, config.max_words), duration
636
+
637
+
638
+ def build_parser() -> argparse.ArgumentParser:
639
+ parser = argparse.ArgumentParser(
640
+ prog="subthis",
641
+ description="Create accurate Hebrew/English SRT captions with at most three words per cue.",
642
+ epilog="Run 'subthis setup' once after installing to store your OpenAI API key.",
643
+ )
644
+ parser.add_argument("--version", action="version", version=f"subthis {__version__}")
645
+ parser.add_argument("video", type=Path, help="video or audio file to transcribe")
646
+ parser.add_argument("-o", "--output", type=Path, help="output SRT path; defaults beside the input")
647
+ parser.add_argument(
648
+ "--max-words",
649
+ type=int,
650
+ choices=(1, 2, 3),
651
+ default=3,
652
+ help="maximum words per subtitle (default: 3)",
653
+ )
654
+ parser.add_argument("--term", action="append", default=[], help="extra canonical term; repeat as needed")
655
+ parser.add_argument(
656
+ "--terms-file",
657
+ type=Path,
658
+ default=TERMS_FILE,
659
+ help=f"term list (default: {TERMS_FILE})",
660
+ )
661
+ parser.add_argument(
662
+ "--language",
663
+ action="append",
664
+ dest="languages",
665
+ help="expected ISO-639-1 language; repeat for code-switching (default: he, en)",
666
+ )
667
+ parser.add_argument("--force", action="store_true", help="overwrite an existing subtitle file")
668
+ return parser
669
+
670
+
671
+ def run(argv: Sequence[str] | None = None) -> int:
672
+ arguments = list(sys.argv[1:] if argv is None else argv)
673
+ if arguments and arguments[0] == "setup":
674
+ if len(arguments) > 1:
675
+ raise SubthisError("setup takes no further arguments.")
676
+ return run_setup()
677
+ args = build_parser().parse_args(arguments)
678
+ video = args.video.expanduser().resolve()
679
+ if not video.is_file():
680
+ raise SubthisError(f"Input file not found: {video}")
681
+ if not shutil.which("ffmpeg") or not shutil.which("ffprobe"):
682
+ raise SubthisError("subthis requires both ffmpeg and ffprobe.")
683
+ output = (args.output or video.with_suffix(".srt")).expanduser().resolve()
684
+ if output == video:
685
+ raise SubthisError("The output path must differ from the input path.")
686
+ if output.exists() and not args.force:
687
+ raise SubthisError(f"Output already exists: {output}. Use --force to replace it.")
688
+ aliases, terms = load_terms(args.terms_file.expanduser(), args.term)
689
+ config = Config(
690
+ api_key=_load_api_key(),
691
+ aliases=aliases,
692
+ terms=terms,
693
+ languages=args.languages or ["he", "en"],
694
+ max_words=args.max_words,
695
+ )
696
+ cues, _duration = transcribe_video(video, config)
697
+ if not cues:
698
+ raise SubthisError("No speech was detected, so no subtitle file was written.")
699
+ output.parent.mkdir(parents=True, exist_ok=True)
700
+ temporary_output = output.with_name(f".{output.name}.subthis-{os.getpid()}.tmp")
701
+ try:
702
+ temporary_output.write_text(render_srt(cues), encoding="utf-8")
703
+ os.replace(temporary_output, output)
704
+ finally:
705
+ with contextlib.suppress(FileNotFoundError):
706
+ temporary_output.unlink()
707
+ print(f"Wrote {len(cues)} subtitles to {output}")
708
+ return 0
709
+
710
+
711
+ def main() -> int:
712
+ for stream in (sys.stdout, sys.stderr):
713
+ with contextlib.suppress(Exception):
714
+ stream.reconfigure(errors="replace")
715
+ try:
716
+ return run()
717
+ except SubthisError as error:
718
+ print(f"subthis: {error}", file=sys.stderr)
719
+ return 1
720
+ except KeyboardInterrupt:
721
+ print("subthis: cancelled", file=sys.stderr)
722
+ return 130
723
+
724
+
725
+ if __name__ == "__main__":
726
+ raise SystemExit(main())
@@ -0,0 +1,174 @@
1
+ from __future__ import annotations
2
+
3
+ import importlib.util
4
+ import os
5
+ import sys
6
+ import unittest
7
+ from pathlib import Path
8
+ from unittest import mock
9
+
10
+
11
+ MODULE_PATH = Path(__file__).parents[1] / "subthis.py"
12
+ SPEC = importlib.util.spec_from_file_location("subthis", MODULE_PATH)
13
+ subthis = importlib.util.module_from_spec(SPEC)
14
+ sys.modules["subthis"] = subthis
15
+ assert SPEC.loader is not None
16
+ SPEC.loader.exec_module(subthis)
17
+
18
+
19
+ class CanonicalTermsTests(unittest.TestCase):
20
+ def test_replaces_hebrew_and_english_aliases_with_canonical_terms(self) -> None:
21
+ text = "בדקתי אופן איי איי וגם קלוד ואז chat gpt"
22
+
23
+ actual = subthis.canonicalize_terms(text, subthis.DEFAULT_ALIASES)
24
+
25
+ self.assertEqual(actual, "בדקתי OpenAI וגם Claude ואז ChatGPT")
26
+
27
+ def test_does_not_replace_alias_inside_another_word(self) -> None:
28
+ actual = subthis.canonicalize_terms("הענן cloudiness", subthis.DEFAULT_ALIASES)
29
+
30
+ self.assertEqual(actual, "הענן cloudiness")
31
+
32
+ def test_does_not_turn_the_real_word_cloud_into_claude(self) -> None:
33
+ actual = subthis.canonicalize_terms("cloud computing", subthis.DEFAULT_ALIASES)
34
+
35
+ self.assertEqual(actual, "cloud computing")
36
+
37
+
38
+ class AlignmentTests(unittest.TestCase):
39
+ def test_transfers_timing_when_accurate_model_merges_phonetic_words(self) -> None:
40
+ timed = [
41
+ subthis.TimedWord("אנחנו", 0.0, 0.3),
42
+ subthis.TimedWord("עובדים", 0.3, 0.7),
43
+ subthis.TimedWord("עם", 0.7, 0.9),
44
+ subthis.TimedWord("אופן", 0.9, 1.1),
45
+ subthis.TimedWord("איי", 1.1, 1.3),
46
+ subthis.TimedWord("איי", 1.3, 1.5),
47
+ subthis.TimedWord("היום", 1.5, 1.9),
48
+ ]
49
+
50
+ actual = subthis.align_accurate_words("אנחנו עובדים עם OpenAI היום", timed)
51
+
52
+ self.assertEqual([word.text for word in actual], ["אנחנו", "עובדים", "עם", "OpenAI", "היום"])
53
+ self.assertAlmostEqual(actual[3].start, 0.9)
54
+ self.assertAlmostEqual(actual[3].end, 1.5)
55
+ self.assertAlmostEqual(actual[4].start, 1.5)
56
+
57
+ def test_alignment_remains_monotonic_when_no_tokens_match(self) -> None:
58
+ timed = [
59
+ subthis.TimedWord("one", 2.0, 2.4),
60
+ subthis.TimedWord("two", 2.5, 3.0),
61
+ ]
62
+
63
+ actual = subthis.align_accurate_words("שלום עולם חדש", timed)
64
+
65
+ self.assertEqual(len(actual), 3)
66
+ self.assertEqual(actual[0].start, 2.0)
67
+ self.assertEqual(actual[-1].end, 3.0)
68
+ self.assertTrue(all(a.end <= b.start for a, b in zip(actual, actual[1:])))
69
+
70
+
71
+ class CueTests(unittest.TestCase):
72
+ def test_removes_all_unicode_punctuation_from_caption_text(self) -> None:
73
+ actual = subthis.strip_caption_punctuation(
74
+ "שלום, OpenAI! מה נשמע? Next.js — כן. צ׳אט-בוט"
75
+ )
76
+
77
+ self.assertEqual(actual, "שלום OpenAI מה נשמע Nextjs כן צאטבוט")
78
+
79
+ def test_groups_at_most_three_words_and_holds_cue_across_pause(self) -> None:
80
+ words = [
81
+ subthis.TimedWord("אחד", 0.0, 0.2),
82
+ subthis.TimedWord("שתיים", 0.25, 0.5),
83
+ subthis.TimedWord("שלוש", 0.55, 0.8),
84
+ subthis.TimedWord("ארבע", 3.0, 3.2),
85
+ subthis.TimedWord("חמש", 3.25, 3.5),
86
+ subthis.TimedWord("שש", 3.55, 3.8),
87
+ subthis.TimedWord("שבע", 4.0, 4.3),
88
+ ]
89
+
90
+ cues = subthis.make_cues(words, media_end=10.0, max_words=3)
91
+
92
+ self.assertEqual([cue.text for cue in cues], ["אחד שתיים שלוש", "ארבע חמש שש", "שבע"])
93
+ self.assertEqual(cues[0].end, 3.0)
94
+ self.assertEqual(cues[1].end, 4.0)
95
+ self.assertEqual(cues[2].end, 4.8)
96
+ self.assertTrue(all(len(cue.text.split()) <= 3 for cue in cues))
97
+
98
+ def test_make_cues_never_emits_punctuation(self) -> None:
99
+ words = [
100
+ subthis.TimedWord("שלום,", 0.0, 0.3),
101
+ subthis.TimedWord("OpenAI!", 0.4, 0.8),
102
+ subthis.TimedWord("באמת?", 0.9, 1.2),
103
+ ]
104
+
105
+ cues = subthis.make_cues(words, media_end=2.0, max_words=3)
106
+
107
+ self.assertEqual(cues[0].text, "שלום OpenAI באמת")
108
+
109
+ def test_srt_rounds_milliseconds_without_overlapping(self) -> None:
110
+ cues = [
111
+ subthis.Cue(0.0, 1.2346, "שלום OpenAI"),
112
+ subthis.Cue(1.2346, 2.0, "מה נשמע"),
113
+ ]
114
+
115
+ actual = subthis.render_srt(cues)
116
+
117
+ self.assertEqual(
118
+ actual,
119
+ "1\n00:00:00,000 --> 00:00:01,235\nשלום OpenAI\n\n"
120
+ "2\n00:00:01,235 --> 00:00:02,000\nמה נשמע\n",
121
+ )
122
+
123
+
124
+ class ChunkMergeTests(unittest.TestCase):
125
+ def test_discards_duplicate_words_from_overlapping_chunks(self) -> None:
126
+ first = [
127
+ subthis.TimedWord("hello", 0.0, 0.4),
128
+ subthis.TimedWord("OpenAI", 0.5, 1.0),
129
+ ]
130
+ second = [
131
+ subthis.TimedWord("OpenAI", 0.5, 1.0),
132
+ subthis.TimedWord("again", 1.1, 1.5),
133
+ ]
134
+
135
+ actual = subthis.merge_chunk_words([first, second])
136
+
137
+ self.assertEqual([word.text for word in actual], ["hello", "OpenAI", "again"])
138
+
139
+
140
+ class ConfigDirTests(unittest.TestCase):
141
+ def test_uses_xdg_config_home_when_set(self) -> None:
142
+ with mock.patch.object(subthis.sys, "platform", "linux"), mock.patch.dict(
143
+ os.environ, {"XDG_CONFIG_HOME": "/custom/config"}
144
+ ):
145
+ self.assertEqual(subthis._config_dir(), Path("/custom/config/subthis"))
146
+
147
+ def test_defaults_to_dot_config_without_xdg(self) -> None:
148
+ with mock.patch.object(subthis.sys, "platform", "linux"), mock.patch.dict(
149
+ os.environ, {"XDG_CONFIG_HOME": ""}
150
+ ):
151
+ self.assertEqual(subthis._config_dir(), Path.home() / ".config" / "subthis")
152
+
153
+ def test_uses_appdata_on_windows(self) -> None:
154
+ with mock.patch.object(subthis.sys, "platform", "win32"), mock.patch.dict(
155
+ os.environ, {"APPDATA": r"C:\Users\bram\AppData\Roaming"}
156
+ ):
157
+ actual = subthis._config_dir()
158
+ self.assertEqual(actual.name, "subthis")
159
+ self.assertIn("AppData", str(actual))
160
+
161
+
162
+ class SetupDispatchTests(unittest.TestCase):
163
+ def test_setup_rejects_extra_arguments(self) -> None:
164
+ with self.assertRaises(subthis.SubthisError):
165
+ subthis.run(["setup", "extra"])
166
+
167
+ def test_setup_dispatches_to_run_setup(self) -> None:
168
+ with mock.patch.object(subthis, "run_setup", return_value=0) as run_setup:
169
+ self.assertEqual(subthis.run(["setup"]), 0)
170
+ run_setup.assert_called_once_with()
171
+
172
+
173
+ if __name__ == "__main__":
174
+ unittest.main()