Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 20 additions & 5 deletions instadata/cli/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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()


Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -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"):
Expand Down
16 changes: 16 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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``.
Expand Down
32 changes: 22 additions & 10 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

from __future__ import annotations

import re
from pathlib import Path
from typing import Any

Expand All @@ -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."""
Expand Down Expand Up @@ -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",
Expand All @@ -199,41 +212,40 @@ 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:
# Command docstrings are RST for the API docs; the CLI help strings are
# 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__
Expand Down
Loading