watchby 0.0.1__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.
watchby/__init__.py ADDED
@@ -0,0 +1,11 @@
1
+ # SPDX-FileCopyrightText: 2026 the watchby authors
2
+ # SPDX-License-Identifier: Apache-2.0
3
+
4
+ from importlib.metadata import PackageNotFoundError, version
5
+
6
+ try:
7
+ __version__ = version("watchby")
8
+ except PackageNotFoundError:
9
+ __version__ = "0.0.0+unknown"
10
+
11
+ __all__ = ["__version__"]
watchby/__main__.py ADDED
@@ -0,0 +1,7 @@
1
+ # SPDX-FileCopyrightText: 2026 the watchby authors
2
+ # SPDX-License-Identifier: Apache-2.0
3
+
4
+ from watchby.cli import main
5
+
6
+ if __name__ == "__main__":
7
+ main()
@@ -0,0 +1,103 @@
1
+ # SPDX-FileCopyrightText: 2026 the watchby authors
2
+ # SPDX-License-Identifier: Apache-2.0
3
+
4
+ import os
5
+ import sys
6
+ from typing import Annotated
7
+
8
+ import typer
9
+
10
+ from watchby import __version__
11
+ from watchby.cli.doctor import doctor
12
+ from watchby.cli.init import init
13
+ from watchby.cli.process import EPILOG as PROCESS_EPILOG
14
+ from watchby.cli.process import HELP as PROCESS_HELP
15
+ from watchby.cli.process import process
16
+ from watchby.cli.term import prepare_streams
17
+ from watchby.errors import WatchbyError
18
+ from watchby.i18n import _, set_language
19
+ from watchby.runtime import host
20
+
21
+ UNSUPPORTED_ENV = "WATCHBY_UNSUPPORTED_OS"
22
+ WATCH = "Watch"
23
+ SETUP = "Set up"
24
+
25
+ EPILOG = """\b
26
+ Start here:
27
+ watchby doctor check this machine
28
+ watchby init write watchby.toml so runs need no flags
29
+ watchby process screen.mp4 read a screen recording
30
+
31
+ Every command has its own help: watchby process --help
32
+ Documentation: https://github.com/khvostpola/watchby/tree/main/docs"""
33
+
34
+ app = typer.Typer(
35
+ name="watchby",
36
+ help=(
37
+ "What a screen recording showed, next to what was said: stable screens, their text, "
38
+ "and a transcript that points at them. Runs on your machine."
39
+ ),
40
+ epilog=EPILOG,
41
+ no_args_is_help=True,
42
+ pretty_exceptions_enable=False,
43
+ context_settings={"help_option_names": ["-h", "--help"]},
44
+ )
45
+ app.command("process", rich_help_panel=WATCH, epilog=PROCESS_EPILOG, help=PROCESS_HELP)(process)
46
+ app.command("init", rich_help_panel=SETUP)(init)
47
+ app.command("doctor", rich_help_panel=SETUP)(doctor)
48
+
49
+
50
+ def _version(value: bool) -> None:
51
+ if value:
52
+ typer.echo(__version__)
53
+ raise typer.Exit()
54
+
55
+
56
+ def _ui_language(value: str | None) -> str | None:
57
+ if value is not None:
58
+ set_language(value)
59
+ return value
60
+
61
+
62
+ @app.callback()
63
+ def root(
64
+ version: Annotated[
65
+ bool,
66
+ typer.Option(
67
+ "--version", callback=_version, is_eager=True, help="Show the version and exit."
68
+ ),
69
+ ] = False,
70
+ ui_lang: Annotated[
71
+ str | None,
72
+ typer.Option(
73
+ "--ui-lang",
74
+ callback=_ui_language,
75
+ is_eager=True,
76
+ help="Language of messages, such as es (default: your system language).",
77
+ ),
78
+ ] = None,
79
+ ) -> None:
80
+ pass
81
+
82
+
83
+ def warn_unsupported_system() -> None:
84
+ if host.is_supported() or os.environ.get(UNSUPPORTED_ENV):
85
+ return
86
+ message = _(
87
+ "watchby supports Linux and Windows; {system} is untested (set {env}=1 to hide this)"
88
+ )
89
+ print(message.format(system=host.platform_label(), env=UNSUPPORTED_ENV), file=sys.stderr)
90
+
91
+
92
+ def main() -> None:
93
+ prepare_streams()
94
+ set_language()
95
+ warn_unsupported_system()
96
+ try:
97
+ app()
98
+ except KeyboardInterrupt:
99
+ sys.exit(130)
100
+ except WatchbyError as exc:
101
+ message = _("error: {message}").format(message=exc)
102
+ print(f"{message} [{exc.error_id} · exit {exc.exit_code}]", file=sys.stderr)
103
+ sys.exit(exc.exit_code)
watchby/cli/doctor.py ADDED
@@ -0,0 +1,71 @@
1
+ # SPDX-FileCopyrightText: 2026 the watchby authors
2
+ # SPDX-License-Identifier: Apache-2.0
3
+
4
+ import json
5
+ import platform
6
+ import sys
7
+ from typing import Annotated
8
+
9
+ import typer
10
+
11
+ from watchby import __version__
12
+ from watchby.i18n import _, ngettext, set_language
13
+ from watchby.runtime.diagnose import Check, run_checks
14
+
15
+ SYMBOLS = {"ok": "ok ", "warn": "warn", "fail": "FAIL"}
16
+
17
+
18
+ def _as_dict(check: Check) -> dict[str, str | None]:
19
+ return {
20
+ "name": check.name,
21
+ "status": check.status,
22
+ "detail": check.detail,
23
+ "hint": check.hint,
24
+ }
25
+
26
+
27
+ def _print(checks: list[Check]) -> None:
28
+ width = max(len(c.name) for c in checks)
29
+ for check in checks:
30
+ typer.echo(f"{SYMBOLS[check.status]} {check.name.ljust(width)} {check.detail}")
31
+ if check.hint and check.status != "ok":
32
+ typer.echo(f" {' ' * width} -> {check.hint}")
33
+
34
+
35
+ def doctor(
36
+ as_json: Annotated[bool, typer.Option("--json", help="Machine-readable output")] = False,
37
+ ) -> None:
38
+ """Check that this machine can run watchby and say how to fix what is missing."""
39
+ if as_json:
40
+ set_language("en")
41
+ checks = run_checks()
42
+ failures = [c for c in checks if c.status == "fail"]
43
+ if as_json:
44
+ typer.echo(
45
+ json.dumps(
46
+ {
47
+ "watchby": __version__,
48
+ "python": platform.python_version(),
49
+ "platform": sys.platform,
50
+ "ffmpeg": next((c.detail for c in checks if c.name == "ffmpeg"), None),
51
+ "saidby": next((c.detail for c in checks if c.name == "saidby"), None),
52
+ "checks": [_as_dict(c) for c in checks],
53
+ "problems": [f"{c.name}: {c.detail}" for c in failures],
54
+ },
55
+ indent=2,
56
+ )
57
+ )
58
+ else:
59
+ typer.echo(_("watchby {version}").format(version=__version__))
60
+ _print(checks)
61
+ typer.echo("")
62
+ if failures:
63
+ problems = ngettext(
64
+ "{n} problem to fix before processing",
65
+ "{n} problems to fix before processing",
66
+ len(failures),
67
+ )
68
+ typer.echo(problems.format(n=len(failures)))
69
+ else:
70
+ typer.echo(_("ready"))
71
+ raise typer.Exit(1 if failures else 0)
watchby/cli/init.py ADDED
@@ -0,0 +1,68 @@
1
+ # SPDX-FileCopyrightText: 2026 the watchby authors
2
+ # SPDX-License-Identifier: Apache-2.0
3
+
4
+ import sys
5
+ from pathlib import Path
6
+ from typing import Annotated
7
+
8
+ import typer
9
+
10
+ from watchby.config.settings import CONFIG_NAME, global_config_path
11
+ from watchby.config.writer import check_language, default_language, render_config
12
+ from watchby.errors import InputError
13
+ from watchby.i18n import _
14
+
15
+
16
+ def _interactive() -> bool:
17
+ return sys.stdin.isatty() and sys.stderr.isatty()
18
+
19
+
20
+ def _write_new(path: Path, text: str, note: str) -> None:
21
+ if path.exists():
22
+ typer.echo(_(" kept {name:<16} already there, not touched").format(name=path.name))
23
+ return
24
+ try:
25
+ path.write_text(text, encoding="utf-8", newline="\n")
26
+ except OSError as exc:
27
+ raise InputError(_("cannot write {path}: {error}").format(path=path, error=exc)) from exc
28
+ typer.echo(_(" wrote {name:<16} {note}").format(name=path.name, note=note))
29
+
30
+
31
+ def init(
32
+ language: Annotated[
33
+ str | None,
34
+ typer.Option(help="Language of your recordings, such as es or en (default: asks)."),
35
+ ] = None,
36
+ yes: Annotated[
37
+ bool, typer.Option("--yes", help="Do not ask: take the language option or the default.")
38
+ ] = False,
39
+ global_: Annotated[
40
+ bool,
41
+ typer.Option(
42
+ "--global",
43
+ help="Set up the user config folder for every folder, not this one "
44
+ "(~/.config/watchby on Linux, %APPDATA%\\watchby on Windows).",
45
+ ),
46
+ ] = False,
47
+ ) -> None:
48
+ """Create watchby.toml so the next run needs no flags."""
49
+ ask = _interactive() and not yes
50
+ chosen = language or default_language()
51
+ if ask and language is None:
52
+ chosen = typer.prompt(_("Language of your recordings"), default=chosen)
53
+ chosen = check_language(chosen)
54
+ folder = global_config_path().parent if global_ else Path.cwd()
55
+ try:
56
+ folder.mkdir(parents=True, exist_ok=True)
57
+ except OSError as exc:
58
+ raise InputError(_("cannot create {path}: {error}").format(path=folder, error=exc)) from exc
59
+ config = folder / ("config.toml" if global_ else CONFIG_NAME)
60
+ typer.echo(_("Setting up {folder}").format(folder=folder))
61
+ note = _("language {language}").format(language=chosen)
62
+ if not global_:
63
+ note += " · " + _("output ./watchby/")
64
+ _write_new(config, render_config(language=chosen, out=None if global_ else "watchby"), note)
65
+ where = _("every folder") if global_ else _("this folder")
66
+ typer.echo(
67
+ _("Done. In {where}, `watchby process screen.mp4` needs no flags now.").format(where=where)
68
+ )
watchby/cli/process.py ADDED
@@ -0,0 +1,320 @@
1
+ # SPDX-FileCopyrightText: 2026 the watchby authors
2
+ # SPDX-License-Identifier: Apache-2.0
3
+
4
+ from pathlib import Path
5
+ from typing import Annotated
6
+
7
+ import typer
8
+
9
+ from watchby import __version__
10
+ from watchby.config.settings import Settings
11
+ from watchby.errors import WatchbyError
12
+ from watchby.i18n import _
13
+
14
+ IO = "Input and output"
15
+ SCREENS = "Screens"
16
+ TRANSCRIPT = "Transcript"
17
+ TEXT = "Text"
18
+ MODELS = "Models"
19
+ PRIVACY = "Privacy"
20
+ BEHAVIOR = "Run behavior"
21
+
22
+ HELP = (
23
+ "Turn screen recordings into stable screens, their text, and a transcript that points at "
24
+ "them. Not available yet: this version only checks the options."
25
+ )
26
+ EPILOG = """\b
27
+ Examples:
28
+ watchby process screen.mp4 --language es
29
+ watchby process call.mp4 --source call --out results/
30
+ watchby process app.mp4 --source screen --ocr none
31
+ watchby process call.mp4 --transcript saidby/call
32
+ watchby process call.mp4 --describe --llm-backend anthropic --allow-remote-llm
33
+
34
+ Each input gets its own folder under the output folder (./watchby unless you pass --out or set
35
+ `out` in watchby.toml). A failed run keeps its work and the next run resumes from the last
36
+ finished stage."""
37
+
38
+
39
+ def process(
40
+ inputs: Annotated[list[Path], typer.Argument(help="Videos (anything ffmpeg reads).")],
41
+ out: Annotated[
42
+ Path | None,
43
+ typer.Option(
44
+ "--out",
45
+ help="Folder for the results, one subfolder per input (default: ./watchby).",
46
+ rich_help_panel=IO,
47
+ ),
48
+ ] = None,
49
+ config: Annotated[
50
+ Path | None,
51
+ typer.Option(
52
+ help="A watchby.toml with defaults for the options below.", rich_help_panel=IO
53
+ ),
54
+ ] = None,
55
+ source: Annotated[
56
+ str | None,
57
+ typer.Option(
58
+ help=(
59
+ "auto (default): look for a shared screen inside a video call and fall back to "
60
+ "the whole frame. call: only the shared screen. screen: always the whole frame."
61
+ ),
62
+ rich_help_panel=SCREENS,
63
+ ),
64
+ ] = None,
65
+ crop: Annotated[
66
+ str | None,
67
+ typer.Option(
68
+ help="A fixed screen rectangle WxH+X+Y in pixels, such as 894x504+94+906; "
69
+ "skips the detection.",
70
+ rich_help_panel=SCREENS,
71
+ ),
72
+ ] = None,
73
+ fps: Annotated[
74
+ float | None,
75
+ typer.Option(
76
+ help="Frames per second looked at to find changes (default 1).",
77
+ rich_help_panel=SCREENS,
78
+ ),
79
+ ] = None,
80
+ min_screen: Annotated[
81
+ float | None,
82
+ typer.Option(
83
+ help="Seconds a screen must stay to count as stable (default 2).",
84
+ rich_help_panel=SCREENS,
85
+ ),
86
+ ] = None,
87
+ change_threshold: Annotated[
88
+ float | None,
89
+ typer.Option(
90
+ help="Share of a grid cell's pixels that must change to count it (default 0.02).",
91
+ rich_help_panel=SCREENS,
92
+ ),
93
+ ] = None,
94
+ mask: Annotated[
95
+ list[str] | None,
96
+ typer.Option(
97
+ "--mask",
98
+ help="A rectangle WxH+X+Y of the screen to ignore when comparing frames "
99
+ "(% works too); repeat it.",
100
+ rich_help_panel=SCREENS,
101
+ ),
102
+ ] = None,
103
+ transcript: Annotated[
104
+ Path | None,
105
+ typer.Option(
106
+ help="Use this finished saidby run of the same video instead of running saidby "
107
+ "(one input only).",
108
+ rich_help_panel=TRANSCRIPT,
109
+ ),
110
+ ] = None,
111
+ no_transcript: Annotated[
112
+ bool,
113
+ typer.Option(
114
+ "--no-transcript",
115
+ help="Do not run saidby: screens only. Automatic when the video has no audio.",
116
+ rich_help_panel=TRANSCRIPT,
117
+ ),
118
+ ] = False,
119
+ language: Annotated[
120
+ str | None,
121
+ typer.Option(
122
+ help="Language code of the speech and the on-screen text (es, en, pt...). "
123
+ "Passed to saidby.",
124
+ rich_help_panel=TRANSCRIPT,
125
+ ),
126
+ ] = None,
127
+ saidby_arg: Annotated[
128
+ list[str] | None,
129
+ typer.Option(
130
+ "--saidby-arg",
131
+ help="One extra argument for saidby; repeat it. Options watchby sets itself are "
132
+ "refused.",
133
+ rich_help_panel=TRANSCRIPT,
134
+ ),
135
+ ] = None,
136
+ ocr: Annotated[
137
+ str | None,
138
+ typer.Option(
139
+ help="Text reader: rapidocr (default), tesseract (extra) or none.",
140
+ rich_help_panel=TEXT,
141
+ ),
142
+ ] = None,
143
+ ocr_lang: Annotated[
144
+ list[str] | None,
145
+ typer.Option(
146
+ "--ocr-lang",
147
+ help="Language of the on-screen text; repeat it (default: the transcript language "
148
+ "plus en).",
149
+ rich_help_panel=TEXT,
150
+ ),
151
+ ] = None,
152
+ describe: Annotated[
153
+ bool,
154
+ typer.Option(
155
+ "--describe",
156
+ help="Have a model describe each stable screen.",
157
+ rich_help_panel=MODELS,
158
+ ),
159
+ ] = False,
160
+ summarize: Annotated[
161
+ bool,
162
+ typer.Option(
163
+ "--summarize",
164
+ help="Have a model sum up each stretch of talk next to what was on screen. "
165
+ "Implies --describe.",
166
+ rich_help_panel=MODELS,
167
+ ),
168
+ ] = False,
169
+ llm_backend: Annotated[
170
+ str | None,
171
+ typer.Option(
172
+ help="openai (default): a llama-server or any OpenAI-compatible server. "
173
+ "anthropic: Claude through the official SDK (extra).",
174
+ rich_help_panel=MODELS,
175
+ ),
176
+ ] = None,
177
+ llm_url: Annotated[
178
+ str | None,
179
+ typer.Option(
180
+ help="Use an OpenAI-compatible server you already run, not llama-server.",
181
+ rich_help_panel=MODELS,
182
+ ),
183
+ ] = None,
184
+ llm_model: Annotated[
185
+ str | None,
186
+ typer.Option(help="Model to ask (default depends on the backend).", rich_help_panel=MODELS),
187
+ ] = None,
188
+ llm_binary: Annotated[
189
+ str | None,
190
+ typer.Option(
191
+ help="Path to llama-server (default: PATH or WATCHBY_LLAMA_SERVER).",
192
+ rich_help_panel=MODELS,
193
+ ),
194
+ ] = None,
195
+ allow_remote_llm: Annotated[
196
+ bool,
197
+ typer.Option(
198
+ "--allow-remote-llm",
199
+ help="Allow a model endpoint that is not on this machine.",
200
+ rich_help_panel=MODELS,
201
+ ),
202
+ ] = False,
203
+ llm_max_screens: Annotated[
204
+ int | None,
205
+ typer.Option(
206
+ min=1,
207
+ help="Most screens sent to the model in one run (default 150).",
208
+ rich_help_panel=MODELS,
209
+ ),
210
+ ] = None,
211
+ no_pii: Annotated[
212
+ bool,
213
+ typer.Option(
214
+ "--no-pii",
215
+ help="Do not flag screens that show email addresses or phone numbers.",
216
+ rich_help_panel=PRIVACY,
217
+ ),
218
+ ] = False,
219
+ keep_all_screens: Annotated[
220
+ bool,
221
+ typer.Option(
222
+ "--keep-all-screens",
223
+ help="Also keep captures from stretches with no shared screen.",
224
+ rich_help_panel=PRIVACY,
225
+ ),
226
+ ] = False,
227
+ strict_offline: Annotated[
228
+ bool,
229
+ typer.Option(
230
+ "--strict-offline",
231
+ help="Fail with exit code 8 if anything tries to leave this machine.",
232
+ rich_help_panel=PRIVACY,
233
+ ),
234
+ ] = False,
235
+ device: Annotated[
236
+ str | None,
237
+ typer.Option(
238
+ help="auto (default): GPU if usable, else CPU. Or force cuda or cpu. "
239
+ "Applies to saidby and to a local model server.",
240
+ rich_help_panel=BEHAVIOR,
241
+ ),
242
+ ] = None,
243
+ keep_intermediates: Annotated[
244
+ bool,
245
+ typer.Option(
246
+ "--keep-intermediates",
247
+ help="Keep the .work folder after success (it is kept on failure).",
248
+ rich_help_panel=BEHAVIOR,
249
+ ),
250
+ ] = False,
251
+ force: Annotated[
252
+ bool,
253
+ typer.Option(
254
+ "--force",
255
+ help="Redo a stage whose results are already in the output folder.",
256
+ rich_help_panel=BEHAVIOR,
257
+ ),
258
+ ] = False,
259
+ fail_fast: Annotated[
260
+ bool,
261
+ typer.Option(
262
+ "--fail-fast", help="Stop at the first input that fails.", rich_help_panel=BEHAVIOR
263
+ ),
264
+ ] = False,
265
+ progress: Annotated[
266
+ str | None,
267
+ typer.Option(
268
+ help="human (default, on stderr), json (one event per line on stdout) or none.",
269
+ rich_help_panel=BEHAVIOR,
270
+ ),
271
+ ] = None,
272
+ strict: Annotated[
273
+ bool,
274
+ typer.Option(
275
+ "--strict",
276
+ help="Exit with code 1 when a run finishes degraded (screens without a transcript, "
277
+ "for example).",
278
+ rich_help_panel=BEHAVIOR,
279
+ ),
280
+ ] = False,
281
+ ) -> None:
282
+ Settings.load(
283
+ config,
284
+ {
285
+ "out": out,
286
+ "source": source,
287
+ "crop": crop,
288
+ "fps": fps,
289
+ "min_screen": min_screen,
290
+ "change_threshold": change_threshold,
291
+ "masks": mask or None,
292
+ "run_saidby": False if no_transcript else None,
293
+ "language": language,
294
+ "saidby_args": saidby_arg or None,
295
+ "ocr": ocr,
296
+ "ocr_lang": ocr_lang or None,
297
+ "describe": describe or None,
298
+ "summarize": summarize or None,
299
+ "llm_backend": llm_backend,
300
+ "llm_url": llm_url,
301
+ "llm_model": llm_model,
302
+ "llm_binary": llm_binary,
303
+ "allow_remote_llm": allow_remote_llm or None,
304
+ "llm_max_screens": llm_max_screens,
305
+ "pii": False if no_pii else None,
306
+ "keep_all_screens": keep_all_screens or None,
307
+ "strict_offline": strict_offline or None,
308
+ "device": device,
309
+ "keep_intermediates": keep_intermediates or None,
310
+ "force": force or None,
311
+ "fail_fast": fail_fast or None,
312
+ "progress": progress,
313
+ "strict": strict or None,
314
+ },
315
+ )
316
+ raise WatchbyError(
317
+ _("watchby {version} cannot process recordings yet: the pipeline arrives in 0.1.0").format(
318
+ version=__version__
319
+ )
320
+ )
watchby/cli/term.py ADDED
@@ -0,0 +1,106 @@
1
+ # SPDX-FileCopyrightText: 2026 the watchby authors
2
+ # SPDX-License-Identifier: Apache-2.0
3
+
4
+ import contextlib
5
+ import io
6
+ import os
7
+ import shutil
8
+ import sys
9
+ from collections.abc import Callable, Mapping
10
+ from dataclasses import dataclass
11
+ from typing import TextIO
12
+
13
+ from watchby.runtime import host
14
+
15
+ RESET = "\x1b[0m"
16
+ CODES = {
17
+ "bold": "1",
18
+ "dim": "2",
19
+ "red": "31",
20
+ "green": "32",
21
+ "yellow": "33",
22
+ "blue": "34",
23
+ "cyan": "36",
24
+ }
25
+ TRUECOLOR_VALUES = {"truecolor", "24bit"}
26
+ VT_PROCESSING = 0x0004
27
+
28
+
29
+ @dataclass(frozen=True)
30
+ class Terminal:
31
+ tty: bool
32
+ color: bool
33
+ truecolor: bool
34
+ columns: int
35
+
36
+ def paint(self, text: str, *styles: str) -> str:
37
+ if not self.color or not styles or not text:
38
+ return text
39
+ return f"\x1b[{';'.join(CODES[s] for s in styles)}m{text}{RESET}"
40
+
41
+ def fit(self, text: str) -> str:
42
+ limit = max(self.columns - 1, 10)
43
+ return text if len(text) <= limit else text[: limit - 1] + "…"
44
+
45
+
46
+ def detect(
47
+ stream: TextIO,
48
+ environ: Mapping[str, str] | None = None,
49
+ *,
50
+ vt: Callable[[TextIO], bool] | None = None,
51
+ ) -> Terminal:
52
+ env = os.environ if environ is None else environ
53
+ if host.is_windows():
54
+ return _detect_windows(stream, env, vt or enable_vt)
55
+ tty = stream.isatty() and env.get("TERM", "") not in {"", "dumb"}
56
+ color = tty and not env.get("NO_COLOR")
57
+ columns = shutil.get_terminal_size(fallback=(80, 24)).columns if tty else 80
58
+ return Terminal(
59
+ tty=tty,
60
+ color=color,
61
+ truecolor=color and env.get("COLORTERM", "").lower() in TRUECOLOR_VALUES,
62
+ columns=columns,
63
+ )
64
+
65
+
66
+ def _detect_windows(
67
+ stream: TextIO, env: Mapping[str, str], vt: Callable[[TextIO], bool]
68
+ ) -> Terminal:
69
+ """Windows consoles set no TERM; a console is a terminal once it accepts ANSI escapes."""
70
+ tty = stream.isatty() and env.get("TERM", "") != "dumb"
71
+ color = tty and not env.get("NO_COLOR") and vt(stream)
72
+ columns = shutil.get_terminal_size(fallback=(80, 24)).columns if tty else 80
73
+ return Terminal(tty=tty, color=color, truecolor=color, columns=columns)
74
+
75
+
76
+ def enable_vt(stream: TextIO) -> bool:
77
+ """Switch the console behind `stream` to ANSI escape processing; False when it cannot."""
78
+ if sys.platform != "win32":
79
+ return True
80
+ import ctypes
81
+ import msvcrt
82
+
83
+ try:
84
+ handle = msvcrt.get_osfhandle(stream.fileno())
85
+ except (OSError, ValueError, AttributeError):
86
+ return False
87
+ kernel32 = ctypes.windll.kernel32
88
+ kernel32.GetConsoleMode.argtypes = [ctypes.c_void_p, ctypes.POINTER(ctypes.c_uint32)]
89
+ kernel32.SetConsoleMode.argtypes = [ctypes.c_void_p, ctypes.c_uint32]
90
+ mode = ctypes.c_uint32()
91
+ if not kernel32.GetConsoleMode(handle, ctypes.byref(mode)):
92
+ return False
93
+ if mode.value & VT_PROCESSING:
94
+ return True
95
+ return bool(kernel32.SetConsoleMode(handle, mode.value | VT_PROCESSING))
96
+
97
+
98
+ def prepare_streams() -> None:
99
+ """On Windows a redirected stream uses the ANSI code page: make it UTF-8 and never fail."""
100
+ if not host.is_windows():
101
+ return
102
+ for stream in (sys.stdin, sys.stdout, sys.stderr):
103
+ if not isinstance(stream, io.TextIOWrapper) or stream.isatty():
104
+ continue
105
+ with contextlib.suppress(OSError, ValueError):
106
+ stream.reconfigure(encoding="utf-8", errors="replace")
@@ -0,0 +1,2 @@
1
+ # SPDX-FileCopyrightText: 2026 the watchby authors
2
+ # SPDX-License-Identifier: Apache-2.0