Skip to content

Commit

Permalink
fix: Manually handle sys.argv to allow proper delegation to `binsta…
Browse files Browse the repository at this point in the history
…r_main` (#721)

* Fix bug: Manually handle sys.argv to allow proper handling of all CLI options and args

* Use pytest-mock to spy calls to binstar_main and ensure arguments are processed correctly

* Add test to ensure top-level CLI options are passed through to binstar_main.

* Fix whitespace linting errors

* Satisfy pycodestyle, pylint, and mypy
  • Loading branch information
mattkram authored Aug 30, 2024
1 parent c6ccc88 commit b3c43b2
Show file tree
Hide file tree
Showing 3 changed files with 75 additions and 24 deletions.
15 changes: 6 additions & 9 deletions binstar_client/plugins.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
"""

import logging
import sys
import warnings
from argparse import ArgumentParser
from typing import Any
Expand All @@ -23,7 +24,7 @@
import typer
import typer.colors
from anaconda_cli_base.cli import app as main_app
from typer import Context, Typer
from typer import Typer

from binstar_client import commands as command_module
from binstar_client.scripts.cli import (
Expand Down Expand Up @@ -98,19 +99,19 @@ def _deprecate(name: str, func: Callable) -> Callable:
f: The subcommand callable.
"""
def new_func(ctx: Context) -> Any:
def new_func() -> Any:
msg = (
f"The existing anaconda-client commands will be deprecated. To maintain compatibility, "
f"please either pin `anaconda-client<2` or update your system call with the `org` prefix, "
f'e.g. "anaconda org {name} ..."'
)
log.warning(msg)
return func(ctx)
return func()

return new_func


def _subcommand(ctx: Context) -> None:
def _subcommand() -> None:
"""A common function to use for all subcommands.
In a proper typer/click app, this is the function that is decorated.
Expand All @@ -119,11 +120,7 @@ def _subcommand(ctx: Context) -> None:
to the binstar_main function.
"""
args = []
# Ensure we capture the subcommand name if there is one
if ctx.info_name is not None:
args.append(ctx.info_name)
args.extend(ctx.args)
args = [arg for arg in sys.argv[1:] if arg != "org"]
binstar_main(args, allow_plugin_main=False)


Expand Down
1 change: 1 addition & 0 deletions requirements-dev.txt
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ pylint~=2.17.4
pytest~=7.3.1
pytest-cov~=4.0.0
pytest-html~=3.2.0
pytest-mock
setuptools~=67.8.0
types-python-dateutil~=2.8.19.13
types-pytz~=2023.3.0.0
Expand Down
83 changes: 68 additions & 15 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,18 @@
"""Test entrypoint to anaconda-cli-base"""

# pylint: disable=redefined-outer-name
import sys
from importlib import reload
import logging
from typing import Generator
from typing import Any, Generator

import pytest
from pytest import LogCaptureFixture
from pytest import MonkeyPatch
from typer.testing import CliRunner
import anaconda_cli_base.cli
import binstar_client.plugins
import binstar_client.scripts.cli
from binstar_client import commands
from binstar_client.plugins import ALL_SUBCOMMANDS, NON_HIDDEN_SUBCOMMANDS, DEPRECATED_SUBCOMMANDS

BASE_COMMANDS = {"login", "logout", "whoami"}
Expand All @@ -34,10 +37,26 @@ def test_entrypoint() -> None:
assert "org" in groups


@pytest.mark.parametrize("cmd", ALL_SUBCOMMANDS)
def test_org_subcommands(cmd: str) -> None:
@pytest.fixture()
def assert_binstar_args(mocker):
"""
Returns a closure that can be used to assert that binstar_main was called with a specific list of args.
"""
mock = mocker.spy(binstar_client.scripts.cli, "binstar_main")

def check_args(args):
mock.assert_called_once_with(commands, args, True)

return check_args


@pytest.mark.parametrize("cmd", sorted(ALL_SUBCOMMANDS))
def test_org_subcommands(cmd: str, monkeypatch: MonkeyPatch, assert_binstar_args: Any) -> None:
"""anaconda org <cmd>"""

args = ["org", cmd, "-h"]
monkeypatch.setattr(sys, "argv", ["/path/to/anaconda"] + args)

org = next((group for group in anaconda_cli_base.cli.app.registered_groups if group.name == "org"), None)
assert org is not None

Expand All @@ -47,49 +66,83 @@ def test_org_subcommands(cmd: str) -> None:
assert subcmd.hidden is False

runner = CliRunner()
result = runner.invoke(anaconda_cli_base.cli.app, ["org", cmd, "-h"])
result = runner.invoke(anaconda_cli_base.cli.app, args)
assert result.exit_code == 0
assert result.stdout.startswith("usage")

assert_binstar_args([cmd, "-h"])

@pytest.mark.parametrize("cmd", HIDDEN_SUBCOMMANDS)
def test_hidden_commands(cmd: str) -> None:

@pytest.mark.parametrize("cmd", list(HIDDEN_SUBCOMMANDS))
def test_hidden_commands(cmd: str, monkeypatch: MonkeyPatch, assert_binstar_args: Any) -> None:
"""anaconda <cmd>"""

args = [cmd, "-h"]
monkeypatch.setattr(sys, "argv", ["/path/to/anaconda"] + args)

subcmd = next((subcmd for subcmd in anaconda_cli_base.cli.app.registered_commands if subcmd.name == cmd), None)
assert subcmd is not None
assert subcmd.hidden is True
assert subcmd.help is not None
assert subcmd.help.startswith("anaconda.org")

runner = CliRunner()
result = runner.invoke(anaconda_cli_base.cli.app, [cmd, "-h"])
assert result.exit_code == 0
result = runner.invoke(anaconda_cli_base.cli.app, args)
assert result.exit_code == 0, result.stdout
assert result.stdout.startswith("usage")

assert_binstar_args([cmd, "-h"])

@pytest.mark.parametrize("cmd", NON_HIDDEN_SUBCOMMANDS)
def test_non_hidden_commands(cmd: str) -> None:

@pytest.mark.parametrize("cmd", list(NON_HIDDEN_SUBCOMMANDS))
def test_non_hidden_commands(cmd: str, monkeypatch: MonkeyPatch, assert_binstar_args: Any) -> None:
"""anaconda login"""

args = [cmd, "-h"]
monkeypatch.setattr(sys, "argv", ["/path/to/anaconda"] + args)

subcmd = next((subcmd for subcmd in anaconda_cli_base.cli.app.registered_commands if subcmd.name == cmd), None)
assert subcmd is not None
assert subcmd.hidden is False
assert subcmd.help is not None
assert subcmd.help.startswith("anaconda.org")

runner = CliRunner()
result = runner.invoke(anaconda_cli_base.cli.app, [cmd, "-h"])
result = runner.invoke(anaconda_cli_base.cli.app, args)
assert result.exit_code == 0
assert result.stdout.startswith("usage")

assert_binstar_args([cmd, "-h"])

@pytest.mark.parametrize("cmd", DEPRECATED_SUBCOMMANDS)
def test_deprecated_message(cmd: str, caplog: LogCaptureFixture) -> None:

@pytest.mark.parametrize("cmd", list(DEPRECATED_SUBCOMMANDS))
def test_deprecated_message(
cmd: str, caplog: LogCaptureFixture, monkeypatch: MonkeyPatch, assert_binstar_args: Any
) -> None:
"""anaconda <cmd> warning"""

args = [cmd, "-h"]
monkeypatch.setattr(sys, "argv", ["/path/to/anaconda"] + args)

with caplog.at_level(logging.WARNING):
runner = CliRunner()
result = runner.invoke(anaconda_cli_base.cli.app, [cmd, "-h"])
result = runner.invoke(anaconda_cli_base.cli.app, args)
assert result.exit_code == 0
assert "commands will be deprecated" in caplog.records[0].msg

assert_binstar_args([cmd, "-h"])


@pytest.mark.parametrize("cmd", list(NON_HIDDEN_SUBCOMMANDS))
def test_top_level_options_passed_through(cmd: str, monkeypatch: MonkeyPatch, assert_binstar_args: Any) -> None:
"""Ensure top-level CLI options are passed through to binstar_main."""

args = ["-t", "TOKEN", "-s", "some-site.com", cmd, "-h"]
monkeypatch.setattr(sys, "argv", ["/path/to/anaconda"] + args)

runner = CliRunner()
result = runner.invoke(anaconda_cli_base.cli.app, args)
assert result.exit_code == 0
assert result.stdout.startswith("usage")

assert_binstar_args(["-t", "TOKEN", "-s", "some-site.com", cmd, "-h"])

0 comments on commit b3c43b2

Please sign in to comment.