From e797e3d9ff79da24a22286abd6a7a23891454e0d Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Mon, 4 May 2026 13:04:36 -0500 Subject: [PATCH] Update the CLI to use a safe, best-effort console emitter so logging issues cannot fail the run --- audita | 26 ++++++++--- src/audita/cli.py | 16 ++++++- tests/test_new_cli.py | 88 ++++++++++++++++++++++++++++++++++++++ tests/test_new_launcher.py | 42 ++++++++++++++++++ 4 files changed, 163 insertions(+), 9 deletions(-) diff --git a/audita b/audita index 0b226fd..658472e 100755 --- a/audita +++ b/audita @@ -93,6 +93,18 @@ def _write_launcher_error_log( ) +def _emit_console_line(message: str) -> None: + for stream in (sys.stderr, sys.stdout): + if stream is None: + continue + try: + stream.write(f"{message}\n") + stream.flush() + return + except (OSError, ValueError): + continue + + def main() -> int: argv = list(sys.argv[1:]) work_root = _resolve_work_root(argv) @@ -103,10 +115,10 @@ def main() -> int: error_log = run_dir / "error.log" message = "uv is required to run this launcher. Install uv and run `uv sync` in the Audita project." _write_launcher_error_log(error_log, message=message, exit_code=1, argv=redacted_argv, command=None) - print(f"audita: error: {message}", file=sys.stderr) - print(f"audita: exit code: 1", file=sys.stderr) - print(f"audita: run directory: {run_dir}", file=sys.stderr) - print(f"audita: error log: {error_log}", file=sys.stderr) + _emit_console_line(f"audita: error: {message}") + _emit_console_line("audita: exit code: 1") + _emit_console_line(f"audita: run directory: {run_dir}") + _emit_console_line(f"audita: error log: {error_log}") return 1 project_root = Path(__file__).resolve().parent @@ -125,9 +137,9 @@ def main() -> int: argv=redacted_argv, command=_redact_argv(command), ) - print(f"audita: subprocess exited with status {result.returncode}", file=sys.stderr) - print(f"audita: run directory: {run_dir}", file=sys.stderr) - print(f"audita: error log: {error_log}", file=sys.stderr) + _emit_console_line(f"audita: subprocess exited with status {result.returncode}") + _emit_console_line(f"audita: run directory: {run_dir}") + _emit_console_line(f"audita: error log: {error_log}") return result.returncode diff --git a/src/audita/cli.py b/src/audita/cli.py index cc37bb9..824c207 100644 --- a/src/audita/cli.py +++ b/src/audita/cli.py @@ -182,7 +182,7 @@ def _process(args: argparse.Namespace, raw_argv: Optional[Sequence[str]] = None) transcript, glossary, config, - progress=lambda message: print(message, file=sys.stderr), + progress=_emit_console_line, run_dir=run_dir, invocation_details={"argv": redacted_argv, "cwd": os.getcwd()}, ) @@ -231,7 +231,7 @@ def _process(args: argparse.Namespace, raw_argv: Optional[Sequence[str]] = None) write_report(report_path, report) if args.report_json is not None and report_path.exists(): args.report_json.write_text(report_path.read_text(encoding="utf-8"), encoding="utf-8") - print(format_stderr_summary(error_details=error_details), file=sys.stderr) + _emit_console_line(format_stderr_summary(error_details=error_details)) return 1 @@ -239,3 +239,15 @@ def _resolve_work_root(args: argparse.Namespace) -> Path: if args.work_dir is not None: return args.work_dir return Path(os.environ.get("AUDITA_WORK_DIR") or "/tmp/audita") + + +def _emit_console_line(message: str) -> None: + for stream in (sys.stderr, sys.stdout): + if stream is None: + continue + try: + stream.write(f"{message}\n") + stream.flush() + return + except (OSError, ValueError): + continue diff --git a/tests/test_new_cli.py b/tests/test_new_cli.py index 242abaa..8fd464e 100644 --- a/tests/test_new_cli.py +++ b/tests/test_new_cli.py @@ -1,4 +1,5 @@ import json +import io import pytest from audita.cli import main @@ -569,3 +570,90 @@ def test_cli_process_writes_failure_diagnostics_for_unexpected_exceptions(monkey assert report["error_details"]["phase"] == "transcript_load" assert report["error_details"]["type"] == "RuntimeError" assert "RuntimeError: boom" in (run_dir / "error.log").read_text(encoding="utf-8") + + +class _BrokenWriter: + def write(self, _message): + raise OSError(9, "Bad file descriptor") + + def flush(self): + raise OSError(9, "Bad file descriptor") + + +def test_cli_process_progress_falls_back_to_stdout_when_stderr_is_invalid(monkeypatch, tmp_path): + transcript = parse_transcript_json( + """ + [ + {"id": 1, "speaker": "Eric", "start": 0.0, "end": 1.0, "text": "Fixed."} + ] + """ + ) + report = RunReport( + status="success", + config={"model": "m", "base_url": "b"}, + normalization={"source_segment_count": 1, "normalized_segment_count": 1, "merge_count": 0}, + pipeline=["grammar"], + modules=[], + applied_changes=[], + skipped_corrections=[], + totals={"output_segment_count": 1, "applied_change_count": 0, "skipped_correction_count": 0}, + work_dir_retention="auto", + work_dir_retained=False, + work_dir=None, + error=None, + ) + result = ProcessResult( + transcript=transcript, + report=report, + run_dir=tmp_path / "run", + work_dir_retained=False, + ) + + def _fake_pipeline(*args, **kwargs): + kwargs["progress"]("progress-line") + return result + + stdout_capture = io.StringIO() + monkeypatch.setattr("audita.cli.AuditaConfig.from_sources", lambda overrides=None: object()) + monkeypatch.setattr("audita.cli.load_transcript", lambda path: []) + monkeypatch.setattr("audita.cli.load_glossary", lambda path: object()) + monkeypatch.setattr("audita.cli.process_transcript_result", _fake_pipeline) + monkeypatch.setattr("audita.cli.sys.stderr", _BrokenWriter()) + monkeypatch.setattr("audita.cli.sys.stdout", stdout_capture) + + output_path = tmp_path / "out.json" + exit_code = main( + [ + "process", + "transcript.json", + "--glossary", + "glossary.yaml", + "--output", + str(output_path), + ] + ) + + assert exit_code == 0 + assert "progress-line" in stdout_capture.getvalue() + + +def test_cli_process_failure_summary_falls_back_to_stdout_when_stderr_is_invalid(monkeypatch, tmp_path): + stdout_capture = io.StringIO() + monkeypatch.setattr("audita.cli.AuditaConfig.from_sources", lambda overrides=None: object()) + monkeypatch.setattr("audita.cli.load_transcript", lambda path: (_ for _ in ()).throw(RuntimeError("boom"))) + monkeypatch.setattr("audita.cli.sys.stderr", _BrokenWriter()) + monkeypatch.setattr("audita.cli.sys.stdout", stdout_capture) + + exit_code = main( + [ + "process", + "transcript.json", + "--glossary", + "glossary.yaml", + "--work-dir", + str(tmp_path / "work"), + ] + ) + + assert exit_code == 1 + assert "audita: error: boom" in stdout_capture.getvalue() diff --git a/tests/test_new_launcher.py b/tests/test_new_launcher.py index 5b91c8d..c9b46b5 100644 --- a/tests/test_new_launcher.py +++ b/tests/test_new_launcher.py @@ -1,3 +1,6 @@ +import importlib.machinery +import importlib.util +import io import os import shutil import subprocess @@ -11,6 +14,15 @@ ROOT = Path(__file__).resolve().parents[1] LAUNCHER = ROOT / "audita" +def _load_launcher_module(): + loader = importlib.machinery.SourceFileLoader("audita_launcher", str(LAUNCHER)) + spec = importlib.util.spec_from_file_location("audita_launcher", LAUNCHER, loader=loader) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + def test_root_launcher_exists_and_is_executable(): assert LAUNCHER.is_file() assert os.access(LAUNCHER, os.X_OK) @@ -73,3 +85,33 @@ def test_root_launcher_preserves_child_exit_code_and_writes_fallback_error_log(t assert "audita: subprocess exited with status 120" in result.stderr run_dir = next(work_dir.iterdir()) assert (run_dir / "error.log").exists() + + +class _BrokenWriter: + def write(self, _message): + raise OSError(9, "Bad file descriptor") + + def flush(self): + raise OSError(9, "Bad file descriptor") + + +def test_root_launcher_falls_back_to_stdout_when_stderr_is_invalid(monkeypatch, tmp_path): + launcher = _load_launcher_module() + work_dir = tmp_path / "work" + stdout_capture = io.StringIO() + + monkeypatch.setattr(launcher.shutil, "which", lambda _name: None) + monkeypatch.setattr( + launcher.sys, + "argv", + ["audita", "process", "transcript.json", "--glossary", "glossary.yaml", "--work-dir", str(work_dir)], + ) + monkeypatch.setattr(launcher.sys, "stderr", _BrokenWriter()) + monkeypatch.setattr(launcher.sys, "stdout", stdout_capture) + + exit_code = launcher.main() + + assert exit_code == 1 + assert "audita: error: uv is required to run this launcher" in stdout_capture.getvalue() + run_dir = next(work_dir.iterdir()) + assert (run_dir / "error.log").exists()