From b6e27f1e8cc53409b02f78d3105b47e3e1e97f7d Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 6 Jun 2026 03:14:00 +0000 Subject: [PATCH 1/2] test: cover human-render, init launch, and error-path gaps Raise branch coverage from 97% to 99% by filling the largest gaps: - init: interactive picker (TTY/Ctrl-C/missing questionary), install failure reporting, and the launch path (port, browser open, exit code) - stdio: silence_stdout, piped/binary stdin fallbacks - keys/login/transcribe: human-readable (non-JSON) render paths - llm --follow: missing-prompt and non-piped-stdin guards - auth flow/loopback: no-projects, wrong token type, 404 path, _capture - scaffold: registered-but-missing template - audio-transcription template: 502 on missing id / gateway error - login: env-override warning emitted by the root callback --- tests/test_agent_render.py | 19 ++++ tests/test_auth_flow.py | 27 ++++++ tests/test_auth_loopback.py | 18 ++++ tests/test_init_command.py | 129 ++++++++++++++++++++++++- tests/test_init_scaffold.py | 8 ++ tests/test_init_template_transcribe.py | 25 +++++ tests/test_keys.py | 52 ++++++++++ tests/test_llm_command.py | 19 ++++ tests/test_login.py | 44 +++++++++ tests/test_stdio.py | 58 +++++++++++ tests/test_transcribe.py | 20 ++++ tests/test_youtube.py | 25 +++++ 12 files changed, 443 insertions(+), 1 deletion(-) diff --git a/tests/test_agent_render.py b/tests/test_agent_render.py index 7bbff818..c697d8c9 100644 --- a/tests/test_agent_render.py +++ b/tests/test_agent_render.py @@ -88,6 +88,25 @@ def flush(self): AgentRenderer(json_mode=True, out=FlakyOut()).connected() # non-pipe errors are non-fatal +# --- text mode (plain stdout + stderr status) ------------------------------ +def test_text_connected_status_goes_to_stderr(): + out, err = io.StringIO(), io.StringIO() + r = AgentRenderer(json_mode=False, text_mode=True, out=out, err=err) + r.connected() + # The "start talking" prompt is a status notice -> stderr, keeping stdout clean + # for the piped transcript. + assert "start talking" in err.getvalue().lower() + assert out.getvalue() == "" + + +def test_text_notice_goes_to_stderr(): + out, err = io.StringIO(), io.StringIO() + r = AgentRenderer(json_mode=False, text_mode=True, out=out, err=err) + r.notice("Half-duplex note.\n") + assert "Half-duplex note." in err.getvalue() + assert out.getvalue() == "" + + # --- human mode (Rich) ----------------------------------------------------- def test_human_partial_then_final(): r, buf = _human() diff --git a/tests/test_auth_flow.py b/tests/test_auth_flow.py index a9af240f..f4e96c51 100644 --- a/tests/test_auth_flow.py +++ b/tests/test_auth_flow.py @@ -63,6 +63,33 @@ def test_find_or_create_creates_when_existing_cli_token_disabled(monkeypatch): assert flow.find_or_create_cli_key(1, "jwt") == "sk_fresh" +def test_find_or_create_raises_when_no_projects(monkeypatch): + # An account with zero projects can't hold a key; surface a clean APIError. + monkeypatch.setattr(flow.ams, "list_projects", lambda acct, jwt: []) + monkeypatch.setattr(flow.ams, "create_token", lambda *a, **k: pytest.fail("should not create")) + with pytest.raises(APIError) as exc: + flow.find_or_create_cli_key(1, "jwt") + assert "no project" in exc.value.message + + +def test_capture_delegates_to_loopback(monkeypatch): + sentinel = loopback.CallbackResult(token="tok", token_type="discovery_oauth") + monkeypatch.setattr(flow.loopback, "capture_callback", lambda: sentinel) + assert flow._capture() is sentinel + + +def test_run_login_flow_rejects_wrong_token_type(monkeypatch): + monkeypatch.setattr(flow, "_open_browser", lambda url: None) + monkeypatch.setattr( + flow, + "_capture", + lambda: loopback.CallbackResult(token="tok", token_type="something_else"), + ) + with pytest.raises(APIError) as exc: + flow.run_login_flow() + assert "valid OAuth token" in exc.value.message + + def test_run_login_flow_happy_path(monkeypatch): opened = {} monkeypatch.setattr(flow, "_open_browser", lambda url: opened.setdefault("url", url)) diff --git a/tests/test_auth_loopback.py b/tests/test_auth_loopback.py index ebca7fed..d5cde6e3 100644 --- a/tests/test_auth_loopback.py +++ b/tests/test_auth_loopback.py @@ -37,6 +37,24 @@ def run(): assert result.error is None +def test_capture_ignores_unknown_paths(): + # A request to a non-callback path gets a 404 and the server keeps waiting; the + # real callback that follows still completes the capture. + result_box = {} + + def run(): + result_box["result"] = loopback.capture_callback(timeout=5.0) + + t = threading.Thread(target=run) + t.start() + _hit("/favicon.ico") # unknown path -> 404, capture stays open + _hit("/callback?stytch_token_type=discovery_oauth&token=tok_late") + t.join(timeout=5) + + result = result_box["result"] + assert result.token == "tok_late" + + def test_capture_times_out_without_callback(): result = loopback.capture_callback(timeout=0.3) assert result.error == "timeout" diff --git a/tests/test_init_command.py b/tests/test_init_command.py index a33b6cf7..85482f1a 100644 --- a/tests/test_init_command.py +++ b/tests/test_init_command.py @@ -1,11 +1,25 @@ +import io +import subprocess +import sys +import types + +import pytest +import typer from typer.testing import CliRunner +from aai_cli.commands import init as init_cmd +from aai_cli.errors import CLIError from aai_cli.main import app runner = CliRunner() TEMPLATE = "audio-transcription" +class _Tty(io.StringIO): + def isatty(self) -> bool: + return True + + def test_init_scaffold_only_creates_project(tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) result = runner.invoke(app, ["init", TEMPLATE, "myapp", "--no-install"]) @@ -24,7 +38,6 @@ def test_init_writes_key_from_env(tmp_path, monkeypatch): def test_init_logged_out_installs_but_skips_launch_with_hint(tmp_path, monkeypatch): # Logged out + install: deps install, but the server can't start without a key — # the report must say so (and must not launch) rather than exiting silently. - import subprocess monkeypatch.chdir(tmp_path) monkeypatch.setattr( @@ -141,3 +154,117 @@ def test_init_unregistered_template_errors_cleanly(tmp_path, monkeypatch): result = runner.invoke(app, ["init", "llm", "x", "--no-install"]) assert result.exit_code == 1 assert "llm" in result.output + + +def _fake_questionary(choice): + """A minimal stand-in for the questionary module's select(...).ask() chain.""" + + class _Choice: + def __init__(self, title, value): + self.value = value + + class _Select: + def ask(self): + return choice + + return types.SimpleNamespace(Choice=_Choice, select=lambda *a, **k: _Select()) + + +def test_pick_template_interactive_returns_choice(monkeypatch): + monkeypatch.setattr("sys.stdin", _Tty()) + monkeypatch.setattr("sys.stdout", _Tty()) + monkeypatch.setitem(sys.modules, "questionary", _fake_questionary(TEMPLATE)) + assert init_cmd._pick_template() == TEMPLATE + + +def test_pick_template_ctrl_c_exits_130(monkeypatch): + # questionary returns None when the user presses Ctrl-C at the prompt. + monkeypatch.setattr("sys.stdin", _Tty()) + monkeypatch.setattr("sys.stdout", _Tty()) + monkeypatch.setitem(sys.modules, "questionary", _fake_questionary(None)) + with pytest.raises(typer.Exit) as exc: + init_cmd._pick_template() + assert exc.value.exit_code == 130 + + +def test_pick_template_missing_questionary_errors(monkeypatch): + # A broken/stale install missing the declared 'questionary' dep. + monkeypatch.setattr("sys.stdin", _Tty()) + monkeypatch.setattr("sys.stdout", _Tty()) + monkeypatch.setitem(sys.modules, "questionary", None) # makes `import questionary` raise + with pytest.raises(CLIError) as exc: + init_cmd._pick_template() + assert exc.value.error_type == "missing_dependency" + + +def test_init_install_failure_reports_and_exits(tmp_path, monkeypatch): + # A failing dependency install is reported and exits non-zero (no launch). + monkeypatch.chdir(tmp_path) + monkeypatch.setattr( + "aai_cli.init.runner.run_setup", + lambda *a, **k: subprocess.CompletedProcess([], 1, "", "pip exploded"), + ) + launched = {"v": False} + monkeypatch.setattr( + "aai_cli.init.runner.launch_and_open", + lambda *a, **k: launched.__setitem__("v", True) or 0, + ) + result = runner.invoke(app, ["init", TEMPLATE, "app", "--json"]) + assert result.exit_code == 1 + assert launched["v"] is False + assert "pip exploded" in result.output + + +def test_init_launches_when_key_present(tmp_path, monkeypatch): + # Key present + install succeeds -> the server is launched and the browser opens. + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("ASSEMBLYAI_API_KEY", "sk-real-key") + monkeypatch.setattr("aai_cli.output._is_agentic", lambda: False) # exercise human banner + monkeypatch.setattr( + "aai_cli.init.runner.run_setup", + lambda *a, **k: subprocess.CompletedProcess([], 0, "ok", ""), + ) + monkeypatch.setattr("aai_cli.init.runner.find_free_port", lambda preferred: 4321) + captured = {} + monkeypatch.setattr( + "aai_cli.init.runner.launch_and_open", + lambda target, **k: captured.update(k) or 0, + ) + result = runner.invoke(app, ["init", TEMPLATE, "app"]) + assert result.exit_code == 0, result.output + assert captured["port"] == 4321 + assert captured["open_browser"] is True + assert "Starting" in result.output + assert "4321" in result.output + + +def test_init_no_open_keeps_browser_closed(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("ASSEMBLYAI_API_KEY", "sk-real-key") + monkeypatch.setattr( + "aai_cli.init.runner.run_setup", + lambda *a, **k: subprocess.CompletedProcess([], 0, "ok", ""), + ) + monkeypatch.setattr("aai_cli.init.runner.find_free_port", lambda preferred: 4321) + captured = {} + monkeypatch.setattr( + "aai_cli.init.runner.launch_and_open", + lambda target, **k: captured.update(k) or 0, + ) + result = runner.invoke(app, ["init", TEMPLATE, "app", "--no-open"]) + assert result.exit_code == 0, result.output + assert captured["open_browser"] is False + + +def test_init_launch_failure_propagates_exit_code(tmp_path, monkeypatch): + # A non-zero server exit code is surfaced as the command's exit code. + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("ASSEMBLYAI_API_KEY", "sk-real-key") + monkeypatch.setattr( + "aai_cli.init.runner.run_setup", + lambda *a, **k: subprocess.CompletedProcess([], 0, "ok", ""), + ) + monkeypatch.setattr("aai_cli.init.runner.find_free_port", lambda preferred: 4321) + monkeypatch.setattr("aai_cli.init.runner.launch_and_open", lambda *a, **k: 7) + result = runner.invoke(app, ["init", TEMPLATE, "app"]) + assert result.exit_code == 7 diff --git a/tests/test_init_scaffold.py b/tests/test_init_scaffold.py index ee1b9ef4..c3515793 100644 --- a/tests/test_init_scaffold.py +++ b/tests/test_init_scaffold.py @@ -70,6 +70,14 @@ def test_scaffold_unknown_template_raises(tmp_path): scaffold.scaffold("nope", tmp_path / "app", api_key=None) +def test_scaffold_registered_but_missing_files_raises(tmp_path, monkeypatch): + # Defense in depth: the registry lists a template whose on-disk dir is gone. + monkeypatch.setattr("aai_cli.init.templates.is_template", lambda _t: True) + with pytest.raises(CLIError) as exc: + scaffold.scaffold("ghost-template", tmp_path / "app", api_key=None) + assert exc.value.error_type == "template_missing" + + def test_target_conflict_detects_nonempty_dir(tmp_path): empty = tmp_path / "empty" empty.mkdir() diff --git a/tests/test_init_template_transcribe.py b/tests/test_init_template_transcribe.py index 3049436e..ef22004b 100644 --- a/tests/test_init_template_transcribe.py +++ b/tests/test_init_template_transcribe.py @@ -185,3 +185,28 @@ def test_status_surfaces_sdk_exception_as_502_not_500(monkeypatch): client = TestClient(app) resp = client.get("/api/status/t-x") assert resp.status_code == 502 + + +def test_submit_returns_502_when_no_id(monkeypatch): + # The SDK returned a transcript object without an id — surface a clean 502. + app, fake, _api = _load_app(monkeypatch) + fake.Transcriber.return_value.submit.return_value = MagicMock(id=None) + client = TestClient(app) + resp = client.post("/api/transcribe-url", json={"url": "https://example.com/a.mp3"}) + assert resp.status_code == 502 + assert "did not return an id" in resp.json()["detail"] + + +def test_ask_surfaces_gateway_error_as_502(monkeypatch): + # A gateway failure (e.g. no plan access) becomes a clean 502, not a 500 traceback. + fake_openai = MagicMock() + fake_openai.OpenAI.return_value.chat.completions.create.side_effect = RuntimeError( + "no plan access" + ) + monkeypatch.setitem(sys.modules, "openai", fake_openai) + + app, _aai, _api = _load_app(monkeypatch) + client = TestClient(app) + resp = client.post("/api/ask", json={"transcript_id": "t-1", "question": "what?"}) + assert resp.status_code == 502 + assert "LLM Gateway error" in resp.json()["detail"] diff --git a/tests/test_keys.py b/tests/test_keys.py index 28b39173..5bf18cb0 100644 --- a/tests/test_keys.py +++ b/tests/test_keys.py @@ -126,3 +126,55 @@ def test_keys_rename_calls_ams(): result = runner.invoke(app, ["keys", "rename", "10", "prod"]) assert result.exit_code == 0 rename.assert_called_once_with(42, 10, "prod", "jwt") + + +def test_keys_list_renders_human_table(): + # Human-readable (non-JSON) render path: the table includes names, project, and + # the disabled flag rendered as yes/no. + _auth() + projects = [ + { + "project": {"id": 1, "name": "Default"}, + "tokens": [ + {"id": 10, "name": "ci", "api_key": "sk_abcdef1234", "is_disabled": False}, + {"id": 11, "name": "old", "api_key": "sk_zzzzzz9999", "is_disabled": True}, + ], + } + ] + with ( + patch("aai_cli.output._is_agentic", return_value=False), + patch("aai_cli.commands.keys.ams.list_projects", return_value=projects), + ): + result = runner.invoke(app, ["keys", "list"]) + assert result.exit_code == 0 + assert "ci" in result.output + assert "Default" in result.output + assert "yes" in result.output # the disabled key + assert "no" in result.output # the enabled key + assert "sk_abcdef1234" not in result.output # masked + + +def test_keys_create_rejects_empty_project_list(): + # No projects at all -> clean APIError, create_token never called. + _auth() + with ( + patch("aai_cli.commands.keys.ams.list_projects", return_value=[]), + patch("aai_cli.commands.keys.ams.create_token") as create, + ): + result = runner.invoke(app, ["keys", "create", "--name", "ci"]) + assert result.exit_code == 1 + create.assert_not_called() + + +def test_keys_create_with_explicit_project_skips_lookup(): + # Passing --project bypasses the default-project lookup entirely. + _auth() + created = {"id": 5, "name": "ci", "api_key": "sk_explicit999", "is_disabled": False} + with ( + patch("aai_cli.commands.keys.ams.list_projects") as list_projects, + patch("aai_cli.commands.keys.ams.create_token", return_value=created) as create, + ): + result = runner.invoke(app, ["keys", "create", "--name", "ci", "--project", "9"]) + assert result.exit_code == 0 + list_projects.assert_not_called() + create.assert_called_once_with(42, 9, "ci", "jwt") diff --git a/tests/test_llm_command.py b/tests/test_llm_command.py index 76d576a2..7230fd57 100644 --- a/tests/test_llm_command.py +++ b/tests/test_llm_command.py @@ -252,6 +252,25 @@ def test_llm_output_with_follow_is_rejected(monkeypatch): assert "one-shot" in result.output +def test_llm_follow_requires_a_prompt(monkeypatch): + # --follow re-runs a prompt over each turn; with no prompt there's nothing to run. + _auth() + monkeypatch.setattr("aai_cli.commands.llm.gateway.complete", lambda *a, **k: _payload()) + result = runner.invoke(app, ["llm", "--follow", "--json"], input="x\n") + assert result.exit_code == 2 + assert "prompt" in result.output.lower() + + +def test_llm_follow_requires_piped_stdin(monkeypatch): + # Interactively (no pipe) --follow would block forever; reject it with guidance. + _auth() + monkeypatch.setattr("aai_cli.commands.llm.stdio.stdin_is_piped", lambda: False) + monkeypatch.setattr("aai_cli.commands.llm.gateway.complete", lambda *a, **k: _payload()) + result = runner.invoke(app, ["llm", "summarize", "--follow", "--json"]) + assert result.exit_code == 2 + assert "stdin" in result.output.lower() + + def test_llm_follow_stops_cleanly_on_interrupt(monkeypatch): _auth() calls = [] diff --git a/tests/test_login.py b/tests/test_login.py index 8ae0a4b3..1b01b853 100644 --- a/tests/test_login.py +++ b/tests/test_login.py @@ -160,6 +160,17 @@ def test_unknown_env_exits_2(): assert result.exit_code == 2 +def test_env_override_prints_warning_to_stderr(): + # The root callback warns when an explicit --env contradicts the profile's stored + # env (the stored key was minted for a different environment). + config.set_api_key("default", "sk_1234567890") + config.set_profile_env("default", "production") + with patch("aai_cli.commands.login.client.validate_key", return_value=True): + result = runner.invoke(app, ["--env", "sandbox000", "whoami", "--json"]) + assert result.exit_code == 0 + assert "may be rejected by sandbox000" in result.output + + def test_rejected_api_key_has_suggestion(monkeypatch): from aai_cli import client @@ -192,3 +203,36 @@ def test_whoami_session_none_without_browser_login(): data = json.loads(result.output) assert data["session"] == "none" assert data["account_id"] is None + + +def test_whoami_renders_human_table_reachable(): + # Human-readable (non-JSON) render path: the grid lists profile, env, masked key, + # a reachable status, and the account/session rows. + config.set_api_key("default", "sk_1234567890") + config.set_session("default", session_jwt="j", session_token="t", account_id=77) + with ( + patch("aai_cli.output._is_agentic", return_value=False), + patch("aai_cli.commands.login.client.validate_key", return_value=True), + ): + result = runner.invoke(app, ["whoami"]) + assert result.exit_code == 0 + assert "Profile" in result.output + assert "default" in result.output + assert "reachable" in result.output + assert "77" in result.output + assert "stored" in result.output + assert "sk_1234567890" not in result.output # masked + + +def test_whoami_renders_human_table_rejected_key(): + # The non-JSON render path also covers the "key rejected" branch and the + # account/session "none" fallbacks (the em-dash placeholder). + config.set_api_key("default", "sk_1234567890") + with ( + patch("aai_cli.output._is_agentic", return_value=False), + patch("aai_cli.commands.login.client.validate_key", return_value=False), + ): + result = runner.invoke(app, ["whoami"]) + assert result.exit_code == 0 + assert "key rejected" in result.output + assert "none" in result.output diff --git a/tests/test_stdio.py b/tests/test_stdio.py index 3f4c6173..e0e6b23f 100644 --- a/tests/test_stdio.py +++ b/tests/test_stdio.py @@ -28,3 +28,61 @@ def test_stdin_is_piped(monkeypatch): assert stdio.stdin_is_piped() is True monkeypatch.setattr("sys.stdin", _Tty("")) assert stdio.stdin_is_piped() is False + + +def test_piped_stdin_text_returns_none_on_tty(monkeypatch): + monkeypatch.setattr("sys.stdin", _Tty("ignored\n")) + assert stdio.piped_stdin_text() is None + + +def test_piped_stdin_text_returns_none_when_blank(monkeypatch): + monkeypatch.setattr("sys.stdin", _Pipe(" \n")) + assert stdio.piped_stdin_text() is None + + +def test_piped_stdin_text_returns_text(monkeypatch): + monkeypatch.setattr("sys.stdin", _Pipe("hello world\n")) + assert stdio.piped_stdin_text() == "hello world\n" + + +def test_read_binary_stdin_uses_buffer(monkeypatch): + class _Bin(io.BytesIO): + pass + + class _WithBuffer(io.StringIO): + buffer = _Bin(b"\x00\x01\x02") + + monkeypatch.setattr("sys.stdin", _WithBuffer()) + assert stdio.read_binary_stdin() == b"\x00\x01\x02" + + +def test_read_binary_stdin_falls_back_for_text_only_stub(monkeypatch): + # A text-only stub (no .buffer) — e.g. CliRunner's StringIO in tests. + monkeypatch.setattr("sys.stdin", _Pipe("abc")) + assert stdio.read_binary_stdin() == b"abc" + + +def test_silence_stdout_redirects_to_devnull(monkeypatch): + calls = {} + + def fake_open(path, flags): + calls["path"] = path + return 99 + + def fake_dup2(fd_src, fd_dst): + calls["dup2"] = (fd_src, fd_dst) + + monkeypatch.setattr("os.open", fake_open) + monkeypatch.setattr("os.dup2", fake_dup2) + stdio.silence_stdout() + assert calls["path"] == __import__("os").devnull + assert calls["dup2"][0] == 99 + + +def test_silence_stdout_suppresses_oserror(monkeypatch): + def boom(*_a, **_k): + raise OSError("no fd") + + # Raising inside the suppressed block must not propagate. + monkeypatch.setattr("os.open", boom) + stdio.silence_stdout() diff --git a/tests/test_transcribe.py b/tests/test_transcribe.py index 80616a3b..24a02558 100644 --- a/tests/test_transcribe.py +++ b/tests/test_transcribe.py @@ -235,6 +235,26 @@ def test_transcribe_prompt_human_shows_only_transform(monkeypatch): assert "hello world" not in result.output # human mode shows the transform only +def test_transcribe_chained_prompts_human_labels_each_step(monkeypatch): + # Human render of a multi-step chain labels each step (the single-step path + # prints only the lone output; this one enumerates "Step N"). + _auth() + monkeypatch.setattr("aai_cli.output.resolve_json", lambda *, explicit: False) + with patch("aai_cli.commands.transcribe.client.transcribe", return_value=_fake_transcript()): + monkeypatch.setattr( + "aai_cli.commands.transcribe.llm.transform_transcript", + lambda *a, **k: f"out({k['prompt']})", + ) + result = runner.invoke( + app, + ["transcribe", "audio.mp3", "--llm", "summarize", "--llm", "translate"], + ) + assert result.exit_code == 0 + assert "Step 1 — summarize:" in result.output + assert "Step 2 — translate:" in result.output + assert "out(summarize)" in result.output + + def test_transcribe_prompt_biases_speech_model(): _auth() with patch( diff --git a/tests/test_youtube.py b/tests/test_youtube.py index 13156132..21006790 100644 --- a/tests/test_youtube.py +++ b/tests/test_youtube.py @@ -73,6 +73,31 @@ def prepare_filename(self, info): assert youtube.download_audio("https://youtu.be/x", tmp_path) == landed +def test_download_audio_no_file_produced_raises(tmp_path, monkeypatch): + # prepare_filename points at a missing file and nothing landed in dest_dir. + class FakeYDL: + def __init__(self, opts): + pass + + def __enter__(self): + return self + + def __exit__(self, *exc): + return False + + def extract_info(self, url, download): + return {"id": "x"} # writes no file + + def prepare_filename(self, info): + return str(tmp_path / "guessed.m4a") # doesn't exist + + _fake_ytdlp(monkeypatch, FakeYDL) + with pytest.raises(CLIError) as exc: + youtube.download_audio("https://youtu.be/x", tmp_path) + assert exc.value.error_type == "youtube_error" + assert "no audio file" in exc.value.message + + def test_download_audio_error_raises_cli_error(tmp_path, monkeypatch): class FakeYDL: def __init__(self, opts): From 07a8fce2b013e17428d4b8ac485c6dab21a3d549 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 6 Jun 2026 04:53:18 +0000 Subject: [PATCH 2/2] test: fix pyright error in stdin buffer stub MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit io.StringIO declares buffer as a property, so assigning a BytesIO class attribute tripped reportIncompatibleMethodOverride under pyright (tests standard). Use a plain stub object carrying a .buffer attribute instead — read_binary_stdin only does getattr(sys.stdin, "buffer", None). --- tests/test_stdio.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/tests/test_stdio.py b/tests/test_stdio.py index e0e6b23f..deaf520c 100644 --- a/tests/test_stdio.py +++ b/tests/test_stdio.py @@ -46,13 +46,12 @@ def test_piped_stdin_text_returns_text(monkeypatch): def test_read_binary_stdin_uses_buffer(monkeypatch): - class _Bin(io.BytesIO): - pass + # A real stdin exposes a binary `.buffer`; read_binary_stdin reads from it. + class _WithBuffer: + def __init__(self, data: bytes): + self.buffer = io.BytesIO(data) - class _WithBuffer(io.StringIO): - buffer = _Bin(b"\x00\x01\x02") - - monkeypatch.setattr("sys.stdin", _WithBuffer()) + monkeypatch.setattr("sys.stdin", _WithBuffer(b"\x00\x01\x02")) assert stdio.read_binary_stdin() == b"\x00\x01\x02"