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__