diff --git a/helpers/_util.py b/helpers/_util.py new file mode 100644 index 00000000..277a07f8 --- /dev/null +++ b/helpers/_util.py @@ -0,0 +1,11 @@ +"""Shared helpers for command-line entry points.""" + +from __future__ import annotations + +import sys + + +def configure_stdout() -> None: + """Keep progress output safe when the locale encoding is not UTF-8.""" + if hasattr(sys.stdout, "reconfigure"): + sys.stdout.reconfigure(encoding="utf-8", errors="replace") diff --git a/helpers/grade.py b/helpers/grade.py index becf6fb4..a706df4f 100644 --- a/helpers/grade.py +++ b/helpers/grade.py @@ -34,6 +34,11 @@ import tempfile from pathlib import Path +try: + from _util import configure_stdout +except ModuleNotFoundError: + from helpers._util import configure_stdout + PRESETS: dict[str, str] = { # Subtle baseline — barely perceptible cleanup. No color shift. @@ -292,6 +297,7 @@ def apply_grade(input_path: Path, output_path: Path, filter_string: str) -> None def main() -> None: + configure_stdout() ap = argparse.ArgumentParser(description="Apply a color grade via ffmpeg filter chain") ap.add_argument("input", type=Path, nargs="?", help="Input video") ap.add_argument("-o", "--output", type=Path, help="Output video") diff --git a/helpers/pack_transcripts.py b/helpers/pack_transcripts.py index 036a1276..8ead8beb 100644 --- a/helpers/pack_transcripts.py +++ b/helpers/pack_transcripts.py @@ -20,6 +20,11 @@ import sys from pathlib import Path +try: + from _util import configure_stdout +except ModuleNotFoundError: + from helpers._util import configure_stdout + def format_time(seconds: float) -> str: """Format a time in seconds as "NNN.NN" with fixed 6-char width for alignment.""" @@ -163,6 +168,7 @@ def render_markdown(entries: list[tuple[str, float, list[dict]]], silence_thresh def main() -> None: + configure_stdout() ap = argparse.ArgumentParser(description="Pack Scribe transcripts into takes_packed.md") ap.add_argument("--edit-dir", type=Path, required=True, help="Edit directory containing transcripts/") ap.add_argument( diff --git a/helpers/render.py b/helpers/render.py index e464c476..d3bd694a 100644 --- a/helpers/render.py +++ b/helpers/render.py @@ -29,6 +29,12 @@ from fractions import Fraction from pathlib import Path +try: + from _util import configure_stdout +except ModuleNotFoundError: + from helpers._util import configure_stdout + + try: from grade import get_preset, auto_grade_for_clip # same directory except Exception: @@ -677,6 +683,7 @@ def build_final_composite( def main() -> None: + configure_stdout() ap = argparse.ArgumentParser(description="Render a video from an EDL") ap.add_argument("edl", type=Path, help="Path to edl.json") ap.add_argument("-o", "--output", type=Path, required=True, help="Output video path") diff --git a/helpers/transcribe_batch.py b/helpers/transcribe_batch.py index aa665260..a78dcd21 100644 --- a/helpers/transcribe_batch.py +++ b/helpers/transcribe_batch.py @@ -20,6 +20,10 @@ from concurrent.futures import ThreadPoolExecutor, as_completed from pathlib import Path +try: + from _util import configure_stdout +except ModuleNotFoundError: + from helpers._util import configure_stdout from transcribe import load_api_key, transcribe_one, transcript_path @@ -35,6 +39,7 @@ def find_videos(videos_dir: Path) -> list[Path]: def main() -> None: + configure_stdout() ap = argparse.ArgumentParser(description="Parallel batch transcription of a videos directory") ap.add_argument("videos_dir", type=Path, help="Directory containing source videos") ap.add_argument( diff --git a/tests/test_stdout_encoding.py b/tests/test_stdout_encoding.py new file mode 100644 index 00000000..00b41c20 --- /dev/null +++ b/tests/test_stdout_encoding.py @@ -0,0 +1,44 @@ +import os +import subprocess +import sys +import unittest +from pathlib import Path + + +REPO_ROOT = Path(__file__).parents[1] +HELPERS = ( + "pack_transcripts.py", + "render.py", + "grade.py", + "transcribe_batch.py", +) + + +class StdoutEncodingTests(unittest.TestCase): + def test_helpers_handle_ascii_redirected_stdout(self): + probe = ( + "import atexit, runpy, sys; " + "helper = sys.argv[1]; " + "atexit.register(lambda: print('→')); " + "sys.argv = [helper, '--help']; " + "runpy.run_path(helper, run_name='__main__')" + ) + for helper in HELPERS: + with self.subTest(helper=helper): + env = os.environ.copy() + env["PYTHONIOENCODING"] = "ascii" + result = subprocess.run( + [sys.executable, "-c", probe, helper], + cwd=REPO_ROOT / "helpers", + env=env, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + ) + + self.assertEqual(result.returncode, 0, result.stderr.decode("utf-8")) + self.assertTrue(result.stdout.decode("utf-8").endswith("→\n")) + + +if __name__ == "__main__": + unittest.main()