Skip to content

Commit

Permalink
Merge pull request #719 from anaconda/feat/anaconda-cli-base-plugin
Browse files Browse the repository at this point in the history
Migrate `anaconda-client` to become a plugin of `anaconda-cli-base`
  • Loading branch information
mattkram authored Aug 28, 2024
2 parents c87913d + 48cf285 commit a319064
Show file tree
Hide file tree
Showing 10 changed files with 341 additions and 7 deletions.
4 changes: 2 additions & 2 deletions .github/workflows/check-master.yml
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ jobs:
- name: Install dependencies
run: |
conda install python=${{ matrix.python-version }} pip --file requirements.txt --file requirements-extra.txt
conda install -c defaults -c anaconda-cloud python=${{ matrix.python-version }} pip --file requirements.txt --file requirements-extra.txt
pip install -r requirements-dev.txt
python setup.py develop --no-deps
Expand Down Expand Up @@ -116,7 +116,7 @@ jobs:
- name: Install dependencies
run: |
conda install python=${{ matrix.python-version }} pip --file requirements.txt --file requirements-extra.txt
conda install -c defaults -c anaconda-cloud python=${{ matrix.python-version }} pip --file requirements.txt --file requirements-extra.txt
pip install -r requirements-dev.txt
python setup.py develop --no-deps
Expand Down
1 change: 1 addition & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ init:
@if [ -z "$${CONDA_SHLVL:+x}" ]; then echo "Conda is not installed." && exit 1; fi
@conda create \
--channel defaults \
--channel anaconda-cloud \
--yes \
--prefix $(conda_env_dir) \
python=3.11 \
Expand Down
202 changes: 202 additions & 0 deletions binstar_client/plugins.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
"""Defines the subcommand plugins for the new CLI defined in anaconda-cli-base.
We define a new subcommand called `anaconda org`, which nests all existing
anaconda-client subcommands beneath it. Additionally, we mount all of the
existing subcommands, with the exception of "login" and "logout" at the top
level of the CLI, although some of these are mounted silently. This is done to
maintain backwards compatibility while we work to deprecate some of them.
Rather than re-write all the CLI code in anaconda-client, we opt to dynamically
register each subcommand in the `load_legacy_subcommands` function.
Note: This module should not be imported, except as defined as a plugin
entrypoint in setup.py.
"""

import logging
import warnings
from argparse import ArgumentParser
from typing import Any
from typing import Callable

import typer
import typer.colors
from anaconda_cli_base.cli import app as main_app
from typer import Context, Typer

from binstar_client import commands as command_module
from binstar_client.scripts.cli import (
_add_subparser_modules as add_subparser_modules, main as binstar_main,
)

# All subcommands in anaconda-client
ALL_SUBCOMMANDS = {
"auth",
"channel",
"config",
"copy",
"download",
"groups",
"label",
"login",
"logout",
"move",
"notebook",
"package",
"remove",
"search",
"show",
"update",
"upload",
"whoami",
}
# These subcommands will be shown in the top-level help
NON_HIDDEN_SUBCOMMANDS = {
"upload",
}
# Any subcommands that should emit deprecation warnings, and show as deprecated in the help
DEPRECATED_SUBCOMMANDS = {
"notebook",
}

# The logger
log = logging.getLogger(__name__)
warnings.simplefilter("always")

app = Typer(
add_completion=False,
name="org",
help="Interact with anaconda.org",
no_args_is_help=True,
)


def _get_help_text(parser: ArgumentParser, name: str) -> str:
"""Extract the help text from the anaconda-client CLI Argument Parser."""
if parser._subparsers is None: # pylint: disable=protected-access
return ""
# MyPy says this was unreachable
# if parser._subparsers._actions is None: # pylint: disable=protected-access
# return ""
if parser._subparsers._actions[1].choices is None: # pylint: disable=protected-access
return ""
subcommand_parser = dict(parser._subparsers._actions[1].choices).get(name) # pylint: disable=protected-access
if subcommand_parser is None:
return ""
description = subcommand_parser.description
if description is None:
return ""
return description.strip()


def _deprecate(name: str, func: Callable) -> Callable:
"""Mark a named subcommand as deprecated.
Args:
name: The name of the subcommand.
f: The subcommand callable.
"""
def new_func(ctx: Context) -> 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 new_func


def _subcommand(ctx: Context) -> None:
"""A common function to use for all subcommands.
In a proper typer/click app, this is the function that is decorated.
We use the typer.Context object to extract the args passed into the CLI, and then delegate
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)
binstar_main(args, allow_plugin_main=False)


def _mount_subcommand(
*,
name: str,
help_text: str,
is_deprecated: bool,
mount_to_main: bool,
is_hidden_on_main: bool,
) -> None:
"""Mount an existing subcommand to the `anaconda org` typer application.
Args:
name: The name of the subcommand.
help_text: The help text for the subcommand
is_deprecated: If True, mark the subcommand as deprecated. This will cause a warning to be
emitted, and also add "(deprecated)" to the help text.
mount_to_main: If True, also mount the subcommand to the main typer app.
is_hidden_on_main: If True, the subcommand is registered as a hidden subcommand of the main CLI
for backwards-compatibility
"""
if is_deprecated:
deprecated_text = typer.style("(deprecated)", fg=typer.colors.RED, bold=True)
help_text = f"{deprecated_text} {help_text}"
func = _deprecate(name, _subcommand)
else:
func = _subcommand

# Mount the subcommand to the `anaconda org` application.
app.command(
name=name,
help=help_text,
context_settings={"allow_extra_args": True, "ignore_unknown_options": True},
)(func)

# Exit early if we are not mounting to the main `anaconda` app
if not mount_to_main:
return

# Mount some CLI subcommands at the top-level, but optionally emit a deprecation warning
help_text = f"anaconda.org: {help_text + ' ' if help_text else ''}(alias for 'anaconda org {name}')"

main_app.command(
name=name,
help=help_text,
hidden=is_hidden_on_main,
context_settings={
"allow_extra_args": True,
"ignore_unknown_options": True,
},
)(func)


def load_legacy_subcommands() -> None:
"""Load each of the legacy subcommands into its own typer subcommand.
This allows them to be called from the new CLI, without having to manually migrate.
"""
parser = ArgumentParser()
add_subparser_modules(parser, command_module)

for name in ALL_SUBCOMMANDS:
# TODO: Can we load the arguments, or at least the docstring to make the help nicer? # pylint: disable=fixme
_mount_subcommand(
name=name,
help_text=_get_help_text(parser, name),
is_deprecated=(name in DEPRECATED_SUBCOMMANDS),
mount_to_main=(name not in {"login", "logout", "whoami"}),
is_hidden_on_main=(name not in NON_HIDDEN_SUBCOMMANDS),
)


load_legacy_subcommands()
18 changes: 18 additions & 0 deletions binstar_client/utils/logging_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,13 +50,31 @@ def format(self, record: logging.LogRecord) -> str:
return super().format(record)


try:
from rich.logging import RichHandler
except (ImportError, ModuleNotFoundError):
RichHandler = None # type: ignore


def _purge_rich_handler_from_logging_root() -> None:
# Remove all handlers associated with the root logger object.
# We do this since anaconda-cli-base defines the RichHandler, which conflicts with anaconda-client's logging
# We can remove this once we clean up logging.
for handler in logging.root.handlers[:]:
# Only remove the root RichHandler, and only if rich is installed
# This should always happen, but just being super careful here.
if RichHandler is not None and isinstance(handler, RichHandler):
logging.root.removeHandler(handler)


def setup_logging(
logger: logging.Logger,
log_level: int = logging.INFO,
show_traceback: bool = False,
disable_ssl_warnings: bool = False
) -> None:
"""Configure logging for the application."""
_purge_rich_handler_from_logging_root()
logger.setLevel(logging.DEBUG)

os.makedirs(config.USER_LOGDIR, exist_ok=True)
Expand Down
4 changes: 2 additions & 2 deletions conda.recipe/meta.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,12 @@ package:
version: {{ data.get('version') }}

source:
git_url: ../
path: ..

build:
number: 0
script: {{ PYTHON }} -m pip install --no-build-isolation --no-deps .
entry_points:
- anaconda = binstar_client.scripts.cli:main
- binstar = binstar_client.scripts.cli:main
- conda-server = binstar_client.scripts.cli:main

Expand All @@ -35,6 +34,7 @@ requirements:
- setuptools >=58.0.4
- tqdm >=4.56.0
- urllib3 >=1.26.4
- anaconda-cli-base >=0.3.0

test:
requires:
Expand Down
8 changes: 6 additions & 2 deletions requirements-extra.txt
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
# Additional requirements for complete experience

anaconda-project>=0.9.1
ruamel.yaml # Required by anaconda-project
# Disabling these extras since they break CI and the server doesn't support
# projects anyway. The problem is that anaconda-project has a circular
# dependency back onto anaconda-client.

# anaconda-project>=0.9.1
# ruamel.yaml # Required by anaconda-project
pillow>=8.2
1 change: 1 addition & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,4 @@ requests-toolbelt>=0.9.1
setuptools>=58.0.4
tqdm>=4.56.0
urllib3>=1.26.4
anaconda-cli-base>=0.3.0
9 changes: 8 additions & 1 deletion setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,11 @@
requirement.split('#', 1)[0].strip() for requirement in stream
)))

# This is temporarily here so we don't pull in the incompatible dependency in CI
# and during local development as we move to 1.13.0. But to not change the behavior
# around the "full" extra at all. We will soon explicitly drop this dependency.
extras_require.append("anaconda-project>=0.9.1")

__about__ = {}
with open(os.path.join(root, 'binstar_client', '__about__.py'), 'rt', encoding='utf-8') as stream:
exec(stream.read(), __about__)
Expand Down Expand Up @@ -46,9 +51,11 @@
packages=setuptools.find_packages(include=['binstar_client', 'binstar_client.*']),
entry_points={
'console_scripts': [
'anaconda = binstar_client.scripts.cli:main',
'binstar = binstar_client.scripts.cli:main',
'conda-server = binstar_client.scripts.cli:main',
],
'anaconda_cli.subcommand': [
'org = binstar_client.plugins:app',
]
},
)
Loading

0 comments on commit a319064

Please sign in to comment.