Skip to content

Commit

Permalink
feat: Add optional typer subcommands (#723)
Browse files Browse the repository at this point in the history
* Bump version number
* Update the CHANGELOG.md
* 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
* Exclude conda environment and coverage report
* Implement way to optionally define a new subcommand using typer
* Pass the common args via ctx.obj
* Remove commented context_settings from main callback
* This allows token to be passed either before or after "org" subcommand
* Depend on upcoming changes to anaconda-cli-base
* Remove debug statement
* Start work on upload subcommand
* Start mapping arguments for upload subcommand, enhance tests
* Copy in defaults for Namespace and tests
* Refactor the test to be parametrized
* Add more tests around label and no-progress args
* Extend testing for multiple label options
* Add test and handling for deprecated --channel option
* Add -u/--user option
* Add parametrization to test with and without org prefix
* Add keep_basename option
* Add package option
* Add support for package version option
* Add support for package summary option
* Add support for package_type option
* Add support for description option
* Add support for thumbnail
* Add support for private
* Add support for register/auto_register
* Fix interactive flag
* Add fail option
* Add force option
* Add handling of mutually-exclusive options
* Fix the exclusivity callback to handle False values
* Add skip-existing option
* Add force-metadata-update option
* Add build-id option
* Add note about --json-help option
* Add testing around top-level options too
* Show help if no args provided
* Clean up help text
* Files are required
* Don't exit on first failed test
* Move test up
* Start adding copy command
* Add to_owner option
* Add from-label and to-label options
* Add replace and update options
* Parse spec instead of using raw string
* Add new subcommand for move
* Support token and site options in copy
* Start creating channel and label subcommands
* Add test for organization option
* Add handling of --copy option
* Add handling of --list option
* Add handling of --show option
* Add handling of --lock and --unlock options
* Add handling of --remove option
* Start adding exclusivity to the actions
* Finish adding exclusivity to the actions
* Add handling of required exclusive option group
* Parametrize command name
* Renamed from ctx.obj to ctx.obj.params
* Start adding update subcommand
* Add tests for --token and --site
* Add package_type option
* Add --release option
* Add search subcommand
* Select --platform from a list of choices
* Add boilerplate for new groups subcommand definition
* Add handling of action argument
* Add group spec argument
* Add --perms option
* Clean up imports
* Add new show subcommand
* Start implementing remove subcommand
* Add --force option
* Start adding auth subcommand
* Add default handling of token name, and tests
* Add --organization option
* Add flag-like options
* Add --remove option
* Add handling of required and mutually exclusive action options
* Add handling of token strength options
* Add --url option
* Add --max-age option
* Add --scopes option
* Add the --out option
* Add basic boilerplate for config subcommand
* Improve handling of type option
* Add --set option
* Add --get option
* Add --remove option
* Add flag options --show, --files, --show-sources
* Add --user and --system options
* Add boilerplate for package command
* Add required spec argument
* Add default values
* Add actions flags
* Add required exclusive option callback
* Fix test
* Add options for --summary, --license, --license-url
* Add options for --private and --personal
* Move all mount_subcommand functions to bottom of modules
* Alphabetize import
* Add a new test and fixture that runs each CLI entrypoint combination the same and checks the arg parsing
* Move sys.argv monkeypatch to fixture
* Extend fixture to cover with and without "org" prefix
* Refactor mocking into an encapsulating class
* Move fixture to top and refactor package command test
* Add fixture IDs and use assertion methods for better comparison output
* Use new fixture and fix a bug!
* Refactor copy and move commands
* Migrate update command and get parity
* Migrate search command and get parity
* Migrate channel/label command and get parity
* Migrate groups command and get parity
* Migrate show command and get parity
* Migrate remove command and get parity
* Migrate auth command and get parity with TODO
* Migrate config command and get parity with TODO
* Fix whitespace and line-too-long issues
* Ignore bandit alerts for test TOKEN values
* Update imports to use typing_extensions
* Fix type hints
* Resolve type errors in config.py
* Fix types in channel.py
* Fix tests by moving class definition above usage as type
* Fix types in authorizations.py
* Format code
* Fix pylint errors
* Remove intermediate callback function (we get args from parent)
* Remove unused imports
* Fix comment syntax
* Bump dependency to anaconda-cli-base>=0.4.0
* Try to set _TYPER_FORCE_DISABLE_TERMINAL
* Fix python 3.8 type to use typing.List
  • Loading branch information
mattkram authored Sep 30, 2024
1 parent 8dda75f commit d734167
Show file tree
Hide file tree
Showing 20 changed files with 2,038 additions and 55 deletions.
2 changes: 2 additions & 0 deletions .github/workflows/check-master.yml
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,8 @@ jobs:
conda list --show-channel-urls
- name: Run tests
env:
_TYPER_FORCE_DISABLE_TERMINAL: 1
run: |
mkdir -p .artifacts/reports
python scripts/refresh_coveragerc.py
Expand Down
154 changes: 154 additions & 0 deletions binstar_client/commands/authorizations.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,10 @@
import socket
import sys
import typing
from enum import Enum

import pytz
import typer
from dateutil.parser import parse as parse_date

from binstar_client import errors
Expand Down Expand Up @@ -271,3 +273,155 @@ def add_parser(subparsers):
action='store_true', help='Show information about the current authentication token')

parser.set_defaults(main=main)


def _exclusive_action(ctx: typer.Context, param: typer.CallbackParam, value: str) -> str:
"""Check for exclusivity of action options.
To do this, we attach a new special attribute onto the typer Context the first time
one of the options in the group is used.
"""
# pylint: disable=protected-access,invalid-name
if getattr(ctx, '_actions', None) is None:
ctx._actions = set() # type: ignore
if value:
if ctx._actions: # type: ignore
used_action, = ctx._actions # type: ignore
raise typer.BadParameter(f'mutually exclusive with {used_action}')
ctx._actions.add(' / '.join(f'\'{o}\'' for o in param.opts)) # type: ignore
return value


class TokenStrength(Enum):
"""Available options for strength when creating a token."""
STRONG = 'strong'
WEAK = 'weak'


def mount_subcommand(app: typer.Typer, name: str, hidden: bool, help_text: str, context_settings: dict) -> None:
@app.command(
name=name,
hidden=hidden,
help=help_text,
context_settings=context_settings,
# no_args_is_help=True,
)
def auth_subcommand(
ctx: typer.Context,
name_: str = typer.Option(
...,
'-n',
'--name',
default_factory=lambda: f'binstar_token:{socket.gethostname()}',
help='A unique name so you can identify this token later. View your tokens at anaconda.org/settings/access'
),
organization: typing.Optional[str] = typer.Option(
None,
'-o',
'--org',
'--organization',
help='Set the token owner (must be an organization)',
),
strength: TokenStrength = typer.Option(
default='strong',
help='Specify the strength of the token',
),
strong: typing.Optional[bool] = typer.Option(
None,
help='Create a longer token (default)',
),
weak: typing.Optional[bool] = typer.Option(
None,
'-w',
'--weak',
help='Create a shorter token',
),
url: str = typer.Option(
'http://anaconda.org',
help='The url of the application that will use this token',
),
max_age: typing.Optional[int] = typer.Option(
None,
help='The maximum age in seconds that this token will be valid for',
),
scopes: typing.Optional[typing.List[str]] = typer.Option(
[],
'-s',
'--scopes',
help=(
'Scopes for token. ' +
'For example if you want to limit this token to conda downloads only you would use ' +
'--scopes "repo conda:download". You can also provide multiple scopes by providing ' +
'this option multiple times, e.g. --scopes repo --scopes conda:download.'
),
),
out: typing.Optional[typer.FileTextWrite] = typer.Option(
sys.stdout,
),
list_scopes: typing.Optional[bool] = typer.Option(
False,
'-x',
'--list-scopes',
help='List all authentication scopes',
callback=_exclusive_action,
),
list_: typing.Optional[bool] = typer.Option(
False,
'-l',
'--list',
help='List all user authentication tokens',
callback=_exclusive_action,
),
create: typing.Optional[bool] = typer.Option(
False,
'-c',
'--create',
help='Create an authentication token',
callback=_exclusive_action,
),
info: typing.Optional[bool] = typer.Option(
False,
'-i',
'--info',
'--current-info',
help='Show information about the current authentication token',
callback=_exclusive_action,
),
remove: typing.List[str] = typer.Option(
[],
'-r',
'--remove',
help='Remove authentication tokens. Multiple token names can be provided',
callback=_exclusive_action,
),
) -> None:
# pylint: disable=too-many-arguments,too-many-locals,fixme
if not any([list_scopes, list_, create, info, remove]):
raise typer.BadParameter('one of --list-scopes, --list, --list, --info, or --remove must be provided')

if weak:
strength = TokenStrength.WEAK
if strong:
# Strong overrides everything
strength = TokenStrength.STRONG

args = argparse.Namespace(
token=ctx.obj.params.get('token'),
site=ctx.obj.params.get('site'),
name=name_,
organization=organization,
strength=strength.value,
list_scopes=list_scopes,
list=list_,
create=create,
info=info,
# TODO: The default of None here is to match existing argparse behavior
remove=remove or None,
url=url,
max_age=max_age,
scopes=scopes,
out=out,
)

main(args=args)
112 changes: 112 additions & 0 deletions binstar_client/commands/channel.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@
import argparse
import functools
import logging
from typing import List, Optional, Tuple, Union

import typer

from binstar_client.utils import get_server_api

Expand Down Expand Up @@ -103,3 +106,112 @@ def _add_parser(subparsers, name, deprecated=False):
def add_parser(subparsers):
_add_parser(subparsers, name='label')
_add_parser(subparsers, name='channel', deprecated=True)


def _parse_optional_tuple(value: Optional[Tuple[str, str]]) -> Optional[List[str]]:
# pylint: disable=fixme
# Convert a sentinel tuple of empty strings to None, since it is not possible with typer parser or callback
if value == ('', '') or value is None:
return None
# TODO: We only return a list because argparse does. Should really be a tuple.
return list(value)


def _exclusive_action(
ctx: typer.Context,
param: typer.CallbackParam,
value: Union[str, Tuple[str, str]],
) -> Union[str, Tuple[str, str]]:
"""Check for exclusivity of action options.
To do this, we attach a new special attribute onto the typer Context the first time
one of the options in the group is used.
"""
# pylint: disable=protected-access,invalid-name
parsed_value: Union[List[str], str, None]
if isinstance(value, tuple):
# This is here so we can treat the empty tuple as falsy, but I don't like it
parsed_value = _parse_optional_tuple(value)
else:
parsed_value = value

if getattr(ctx, '_actions', None) is None:
ctx._actions = set() # type: ignore
if parsed_value:
if ctx._actions: # type: ignore
used_action, = ctx._actions # type: ignore
raise typer.BadParameter(f'mutually exclusive with {used_action}')
ctx._actions.add(' / '.join(f'\'{o}\'' for o in param.opts)) # type: ignore
return value


def mount_subcommand(app: typer.Typer, name: str, hidden: bool, help_text: str, context_settings: dict) -> None:

@app.command(
name=name,
hidden=hidden,
help=help_text,
context_settings=context_settings,
# no_args_is_help=True,
)
def channel(
ctx: typer.Context,
organization: Optional[str] = typer.Option(
None,
'-o',
'--organization',
help='Manage an organizations {}s'.format(name),
),
copy: Optional[Tuple[str, str]] = typer.Option(
('', ''),
help=f'Copy a package from one {name} to another',
show_default=False,
callback=_exclusive_action,
),
list_: Optional[bool] = typer.Option(
False,
'--list',
is_flag=True,
help=f'List all {name}s for a user',
callback=_exclusive_action,
),
show: Optional[str] = typer.Option(
None,
help=f'Show all of the files in a {name}',
callback=_exclusive_action,
),
lock: Optional[str] = typer.Option(
None,
help=f'Lock a {name}',
callback=_exclusive_action,
),
unlock: Optional[str] = typer.Option(
None,
help=f'Unlock a {name}',
callback=_exclusive_action,
),
remove: Optional[str] = typer.Option(
None,
help=f'Remove a {name}',
callback=_exclusive_action,
),
) -> None:
# pylint: disable=too-many-arguments
parsed_copy = _parse_optional_tuple(copy)
if not any([parsed_copy, list_, show, lock, unlock, remove]):
raise typer.BadParameter('one of --copy, --list, --show, --lock, --unlock, or --remove must be provided')

args = argparse.Namespace(
token=ctx.obj.params.get('token'),
site=ctx.obj.params.get('site'),
organization=organization,
copy=parsed_copy,
list=list_,
show=show,
lock=lock,
unlock=unlock,
remove=remove,
)

main(args=args, name='channel', deprecated=True)
Loading

0 comments on commit d734167

Please sign in to comment.