From 448ff57455cb595a614daa48c7a2056ba58a9a2e Mon Sep 17 00:00:00 2001 From: makan Date: Sat, 1 Aug 2026 03:00:57 +0300 Subject: [PATCH] fix(cli): keep --version and --json free of terminal styling rich highlights whatever it prints, so --version emitted the version with escape codes spliced through it and --json emitted syntax-highlighted JSON that did not parse. Both exist to be consumed by other programs, so both now go through plain print. Caught by CI: rich enables colour when it detects a CI terminal, which the local test run never does. The help assertions were reading rendered bytes, which made them depend on colour and width, so they now strip styling before matching. conftest also pins NO_COLOR and a wide COLUMNS so a developer sees the same output the tests do. Also fixes an epilog line still referring to the pre-rename command name. --- instadata/cli/app.py | 25 ++++++++++++++++++++----- tests/conftest.py | 16 ++++++++++++++++ tests/test_cli.py | 32 ++++++++++++++++++++++---------- 3 files changed, 58 insertions(+), 15 deletions(-) diff --git a/instadata/cli/app.py b/instadata/cli/app.py index 70a6374..af16fb0 100644 --- a/instadata/cli/app.py +++ b/instadata/cli/app.py @@ -88,7 +88,7 @@ [cyan]instadata whoami nasa --json[/cyan] -Run [cyan]instagram COMMAND --help[/cyan] for a command's own options. +Run [cyan]instadata COMMAND --help[/cyan] for a command's own options. """ app = typer.Typer( @@ -113,9 +113,14 @@ def _examples(*lines: str) -> str: def _version(value: bool) -> None: - """Print the version and exit, as an eager ``--version`` callback.""" + """Print the version and exit, as an eager ``--version`` callback. + + Plain ``print``, not the rich console: rich's highlighter colourises a + version string into ``\x1b[1;36m0.1\x1b[0m.\x1b[1;36m0\x1b[0m``, so + ``VERSION=$(instadata --version)`` would capture escape codes. + """ if value: - console.print(__version__) + print(__version__) raise typer.Exit() @@ -250,6 +255,16 @@ def execute(coro: Any) -> Any: _fail(exc) +def emit_json(payload: Any) -> None: + """Write a machine-readable JSON document to stdout. + + Plain ``print``, not ``console.print_json``: rich syntax-highlights JSON, + and under a colour-forcing terminal the escape codes make the output fail + to parse. ``--json`` exists to be piped, so it stays bytes-exact. + """ + print(orjson.dumps(payload, option=orjson.OPT_INDENT_2).decode()) + + def run(coro: Any, *, as_json: bool) -> None: """Run a scrape coroutine and render its report.""" render(execute(coro), as_json=as_json) @@ -269,7 +284,7 @@ def render(report: ScrapeReport, *, as_json: bool) -> None: "completed": report.completed, "failures": report.failures, } - console.print_json(orjson.dumps(payload).decode()) + emit_json(payload) return table = Table(title=f"@{report.username}", show_header=False, box=None) @@ -518,7 +533,7 @@ async def job() -> dict[str, Any]: payload = execute(job()) if as_json: - console.print_json(orjson.dumps(payload).decode()) + emit_json(payload) return table = Table(show_header=False, box=None) for key in ("user_id", "username", "full_name", "is_private", "media_count", "follower_count"): diff --git a/tests/conftest.py b/tests/conftest.py index 4158097..e63270c 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -173,6 +173,22 @@ async def aclose(self) -> None: """No-op.""" +@pytest.fixture(autouse=True) +def _deterministic_rich_output(monkeypatch: pytest.MonkeyPatch) -> None: + """Render help text plainly, whatever the surrounding environment. + + rich enables colour when it detects CI, and typer styles option names, so + ``--proxy`` stops being a contiguous substring of the rendered help — the + assertions pass on a developer's machine and fail in GitHub Actions. Pin + colour off and the width wide so what the tests read is what the code + produced, not what the terminal happened to do to it. + """ + monkeypatch.setenv("NO_COLOR", "1") + monkeypatch.setenv("TERM", "dumb") + monkeypatch.setenv("COLUMNS", "200") + monkeypatch.delenv("FORCE_COLOR", raising=False) + + @pytest.fixture(autouse=True) def _no_leaked_log_sinks() -> Any: """Drop any sink a test installed via ``configure_logging``. diff --git a/tests/test_cli.py b/tests/test_cli.py index 05aef0f..b1c447d 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -6,6 +6,7 @@ from __future__ import annotations +import re from pathlib import Path from typing import Any @@ -20,6 +21,18 @@ runner = CliRunner() +_ANSI = re.compile(r"\x1b\[[0-9;]*m") + + +def help_text(*args: str) -> str: + """Rendered help with styling removed. + + rich colours option names when it detects a CI terminal, which splits + ``--proxy`` with escape sequences and breaks a plain substring check. The + tests care what the help says, not how the terminal painted it. + """ + return _ANSI.sub("", runner.invoke(app, [*args, "--help"]).stdout) + class FakeScraper: """Stand-in for :class:`InstagramScraper` recording how it was called.""" @@ -180,12 +193,12 @@ def test_highlights_command(self) -> None: assert FakeScraper.calls[0][1]["highlights"] is True def test_help_lists_every_command(self) -> None: - result = runner.invoke(app, ["--help"]) + out = help_text() for command in ("profile", "post", "reel", "story", "highlights", "whoami"): - assert command in result.stdout + assert command in out def test_root_help_covers_capabilities_auth_and_examples(self) -> None: - out = runner.invoke(app, ["--help"]).stdout + out = help_text() for expected in ( "What it downloads", "Session requirements", @@ -199,20 +212,20 @@ def test_root_help_covers_capabilities_auth_and_examples(self) -> None: def test_help_documents_incremental_behaviour_and_its_escape_hatches(self) -> None: # This help went stale once already: it still described the pre-archive # design after the walk had been made incremental. - root = runner.invoke(app, ["--help"]).stdout + root = help_text() assert "Incremental" in root assert "metadata.jsonl" in root assert "--full" in root assert "--no-metadata" in root - profile_help = runner.invoke(app, ["profile", "--help"]).stdout + profile_help = help_text("profile") assert "--full" in profile_help assert "nothing new" in profile_help def test_no_metadata_flag_warns_that_it_disables_the_archive(self) -> None: # --no-metadata silently turns a 2-request re-check into a full walk; # that coupling has to be visible where the flag is documented. - out = runner.invoke(app, ["profile", "--help"]).stdout + out = help_text("profile") assert "archive" in out def test_help_text_carries_no_restructuredtext_markup(self) -> None: @@ -220,20 +233,19 @@ def test_help_text_carries_no_restructuredtext_markup(self) -> None: # separate so ``literal`` markup never reaches a terminal. commands = ["profile", "post", "reel", "story", "highlights", "whoami"] for name in [None, *commands]: - args = ["--help"] if name is None else [name, "--help"] - assert "``" not in runner.invoke(app, args).stdout, name + assert "``" not in help_text(*([] if name is None else [name])), name @pytest.mark.parametrize( "command", ["profile", "post", "reel", "story", "highlights", "whoami"] ) def test_every_command_help_shows_examples(self, command: str) -> None: - out = runner.invoke(app, [command, "--help"]).stdout + out = help_text(command) assert "Examples" in out assert "instadata " in out def test_whoami_accepts_a_proxy_like_every_other_command(self) -> None: # It was the one command that hardcoded proxy=None. - assert "--proxy" in runner.invoke(app, ["whoami", "--help"]).stdout + assert "--proxy" in help_text("whoami") def test_version_flag_prints_the_version(self) -> None: from instadata import __version__