core/cli: switch to using click library #155

everything is backwards-compatible with the previous
interface, the only minor changes were to the doctor cmd
which can now accept more than one item to run,
and the --skip-config-check to skip the config_ok
check if the user specifies to

added a test using click.testing.CliRunner (tests
the CLI in an isolated environment), though
additional tests which aren't testing the CLI
itself (parsing arguments or decorator behaviour)
can just call the functions themselves, as they
no longer accept a argparser.Namespace and instead
accept the direct arguments
This commit is contained in:
Sean Breckenridge 2021-04-04 02:06:59 -07:00 committed by GitHub
parent 5ef2775265
commit 349ab78fca
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
4 changed files with 157 additions and 131 deletions

View file

@ -1,15 +1,13 @@
import functools import functools
import importlib import importlib
import os import os
import sys
import traceback
from typing import Optional, Sequence, Iterable, List
from pathlib import Path from pathlib import Path
from subprocess import check_call, run, PIPE, CompletedProcess from subprocess import check_call, run, PIPE, CompletedProcess
import sys
from typing import Optional, Sequence, Iterable, List
import traceback
from . import LazyLogger import click
log = LazyLogger('HPI cli')
@functools.lru_cache() @functools.lru_cache()
@ -58,8 +56,12 @@ def run_mypy(pkg: ModuleType) -> Optional[CompletedProcess]:
return mres return mres
# use click.echo over print since it handles handles possible Unicode errors,
# strips colors if the output is a file
# https://click.palletsprojects.com/en/7.x/quickstart/#echoing
def eprint(x: str) -> None: def eprint(x: str) -> None:
print(x, file=sys.stderr) # err=True prints to stderr
click.echo(x, err=True)
def indent(x: str) -> str: def indent(x: str) -> str:
return ''.join(' ' + l for l in x.splitlines(keepends=True)) return ''.join(' ' + l for l in x.splitlines(keepends=True))
@ -81,23 +83,7 @@ def tb(e: Exception) -> None:
sys.stderr.write(indent(tb)) sys.stderr.write(indent(tb))
# todo not gonna work on Windows... perhaps make it optional and use colorama/termcolor? (similar to core.warnings) def config_create() -> None:
class color:
BLACK = '\033[30m'
RED = '\033[31m'
GREEN = '\033[32m'
YELLOW = '\033[33m'
BLUE = '\033[34m'
MAGENTA = '\033[35m'
CYAN = '\033[36m'
WHITE = '\033[37m'
UNDERLINE = '\033[4m'
RESET = '\033[0m'
from argparse import Namespace
def config_create(args: Namespace) -> None:
from .preinit import get_mycfg_dir from .preinit import get_mycfg_dir
mycfg_dir = get_mycfg_dir() mycfg_dir = get_mycfg_dir()
@ -136,23 +122,18 @@ class example:
else: else:
error(f"config directory '{mycfg_dir}' already exists, skipping creation") error(f"config directory '{mycfg_dir}' already exists, skipping creation")
check_passed = config_ok(args) check_passed = config_ok()
if not created or not check_passed: if not created or not check_passed:
sys.exit(1) sys.exit(1)
def config_check_cli(args: Namespace) -> None:
ok = config_ok(args)
sys.exit(0 if ok else False)
# TODO return the config as a result? # TODO return the config as a result?
def config_ok(args: Namespace) -> bool: def config_ok() -> bool:
errors: List[Exception] = [] errors: List[Exception] = []
import my import my
try: try:
paths: Sequence[str] = my.__path__._path # type: ignore[attr-defined] paths: List[str] = list(my.__path__) # type: ignore[attr-defined]
except Exception as e: except Exception as e:
errors.append(e) errors.append(e)
error('failed to determine module import path') error('failed to determine module import path')
@ -226,12 +207,11 @@ def _modules(*, all: bool=False) -> Iterable[HPIModule]:
warning(f'Skipped {len(skipped)} modules: {skipped}. Pass --all if you want to see them.') warning(f'Skipped {len(skipped)} modules: {skipped}. Pass --all if you want to see them.')
def modules_check(args: Namespace) -> None: def modules_check(*, verbose: bool, list_all: bool, quick: bool, for_modules: List[str]) -> None:
verbose: bool = args.verbose if len(for_modules) > 0:
quick: bool = args.quick # if you're checking specific modules, show errors
module: Optional[str] = args.module # hopefully makes sense?
if module is not None: verbose = True
verbose = True # hopefully makes sense?
vw = '' if verbose else '; pass --verbose to print more information' vw = '' if verbose else '; pass --verbose to print more information'
from . import common from . import common
@ -243,29 +223,29 @@ def modules_check(args: Namespace) -> None:
from .stats import guess_stats from .stats import guess_stats
mods: Iterable[HPIModule] mods: Iterable[HPIModule]
if module is None: if len(for_modules) == 0:
mods = _modules(all=args.all) mods = _modules(all=list_all)
else: else:
mods = [HPIModule(name=module, skip_reason=None)] mods = [HPIModule(name=m, skip_reason=None) for m in for_modules]
# todo add a --all argument to disregard is_active check? # todo add a --all argument to disregard is_active check?
for mr in mods: for mr in mods:
skip = mr.skip_reason skip = mr.skip_reason
m = mr.name m = mr.name
if skip is not None: if skip is not None:
eprint(OFF + f' {color.YELLOW}SKIP{color.RESET}: {m:<50} {skip}') eprint(f'{OFF} {click.style("SKIP", fg="yellow")}: {m:<50} {skip}')
continue continue
try: try:
mod = importlib.import_module(m) mod = importlib.import_module(m)
except Exception as e: except Exception as e:
# todo more specific command? # todo more specific command?
error(f'{color.RED}FAIL{color.RESET}: {m:<50} loading failed{vw}') error(f'{click.style("FAIL", fg="red")}: {m:<50} loading failed{vw}')
if verbose: if verbose:
tb(e) tb(e)
continue continue
info(f'{color.GREEN}OK{color.RESET} : {m:<50}') info(f'{click.style("OK", fg="green")} : {m:<50}')
# first try explicitly defined stats function: # first try explicitly defined stats function:
stats = get_stats(m) stats = get_stats(m)
if stats is None: if stats is None:
@ -281,19 +261,18 @@ def modules_check(args: Namespace) -> None:
res = stats() res = stats()
assert res is not None, 'stats() returned None' assert res is not None, 'stats() returned None'
except Exception as ee: except Exception as ee:
warning(f' - {color.RED}stats:{color.RESET} computing failed{vw}') warning(f' - {click.style("stats:", fg="red")} computing failed{vw}')
if verbose: if verbose:
tb(ee) tb(ee)
else: else:
info(f' - stats: {res}') info(f' - stats: {res}')
def list_modules(args: Namespace) -> None: def list_modules(*, list_all: bool) -> None:
# todo add a --sort argument? # todo add a --sort argument?
tabulate_warnings() tabulate_warnings()
all: bool = args.all for mr in _modules(all=list_all):
for mr in _modules(all=all):
m = mr.name m = mr.name
sr = mr.skip_reason sr = mr.skip_reason
if sr is None: if sr is None:
@ -301,9 +280,9 @@ def list_modules(args: Namespace) -> None:
suf = '' suf = ''
else: else:
pre = OFF pre = OFF
suf = f' {color.YELLOW}[disabled: {sr}]{color.RESET}' suf = f' {click.style(f"[disabled: {sr}]", fg="yellow")}'
print(f'{pre} {m:50}{suf}') click.echo(f'{pre} {m:50}{suf}')
def tabulate_warnings() -> None: def tabulate_warnings() -> None:
@ -319,13 +298,6 @@ def tabulate_warnings() -> None:
# TODO loggers as well? # TODO loggers as well?
# todo check that it finds private modules too?
def doctor(args: Namespace) -> None:
ok = config_ok(args)
# TODO propagate ok status up?
modules_check(args)
def _requires(module: str) -> Sequence[str]: def _requires(module: str) -> Sequence[str]:
from .discovery_pure import module_by_name from .discovery_pure import module_by_name
mod = module_by_name(module) mod = module_by_name(module)
@ -337,19 +309,16 @@ def _requires(module: str) -> Sequence[str]:
return r return r
def module_requires(args: Namespace) -> None: def module_requires(*, module: str) -> None:
module: str = args.module
rs = [f"'{x}'" for x in _requires(module)] rs = [f"'{x}'" for x in _requires(module)]
eprint(f'dependencies of {module}') eprint(f'dependencies of {module}')
for x in rs: for x in rs:
print(x) click.echo(x)
def module_install(args: Namespace) -> None: def module_install(*, user: bool, module: str) -> None:
# TODO hmm. not sure how it's gonna work -- presumably people use different means of installing... # TODO hmm. not sure how it's gonna work -- presumably people use different means of installing...
# how do I install into the 'same' environment?? # how do I install into the 'same' environment??
user: bool = args.user
module: str = args.module
import shlex import shlex
cmd = [ cmd = [
sys.executable, '-m', 'pip', 'install', sys.executable, '-m', 'pip', 'install',
@ -360,69 +329,126 @@ def module_install(args: Namespace) -> None:
check_call(cmd) check_call(cmd)
from argparse import ArgumentParser @click.group()
def parser() -> ArgumentParser:
p = ArgumentParser('Human Programming Interface', epilog='''
Tool for HPI.
Work in progress, will be used for config management, troubleshooting & introspection
''')
sp = p.add_subparsers(dest='mode')
dp = sp.add_parser('doctor', help='Run various checks')
dp.add_argument('--verbose', action='store_true', help='Print more diagnosic infomration')
dp.add_argument('--all' , action='store_true', help='List all modules, including disabled')
dp.add_argument('--quick' , action='store_true', help='Only run partial checks (first 100 items)')
dp.add_argument('module', nargs='?', type=str , help='Pass to check a specific module')
dp.set_defaults(func=doctor)
cp = sp.add_parser('config', help='Work with configuration')
scp = cp.add_subparsers(dest='config_mode')
if True:
ccp = scp.add_parser('check', help='Check config')
ccp.set_defaults(func=config_check_cli)
icp = scp.add_parser('create', help='Create user config')
icp.set_defaults(func=config_create)
mp = sp.add_parser('modules', help='List available modules')
mp.add_argument('--all' , action='store_true', help='List all modules, including disabled')
mp.set_defaults(func=list_modules)
op = sp.add_parser('module', help='Module management')
ops = op.add_subparsers(dest='module_mode')
if True:
add_module_arg = lambda x: x.add_argument('module', type=str, help='Module name (e.g. my.reddit)')
opsr = ops.add_parser('requires', help='Print module requirements')
# todo not sure, might be worth exposing outside...
add_module_arg(opsr)
opsr.set_defaults(func=module_requires)
# todo support multiple
opsi = ops.add_parser('install', help='Install module dependencies')
add_module_arg(opsi)
opsi.add_argument('--user', action='store_true', help='same as pip --user')
opsi.set_defaults(func=module_install)
# todo could add functions to check specific module etc..
return p
def main() -> None: def main() -> None:
p = parser() '''
args = p.parse_args() Human Programming Interface
func = getattr(args, 'func', None) Tool for HPI
if func is None: Work in progress, will be used for config management, troubleshooting & introspection
p.print_help() '''
sys.exit(1) # for potential future reference, if shared state needs to be added to groups
# https://click.palletsprojects.com/en/7.x/commands/#group-invocation-without-command
# https://click.palletsprojects.com/en/7.x/commands/#multi-command-chaining
# acts as a contextmanager of sorts - any subcommand will then run
# in something like /tmp/hpi_temp_dir
# to avoid importing relative modules by accident during development
# maybe can be removed later if theres more test coverage/confidence that nothing
# would happen?
import tempfile import tempfile
with tempfile.TemporaryDirectory() as td:
# cd into tmp dir to prevent accidental imports.. # use a particular directory instead of a random one, since
os.chdir(str(td)) # click being decorator based means its more complicated
func(args) # to run things at the end (would need to use a callback or pass context)
# https://click.palletsprojects.com/en/7.x/commands/#nested-handling-and-contexts
tdir: str = os.path.join(tempfile.gettempdir(), 'hpi_temp_dir')
if not os.path.exists(tdir):
os.makedirs(tdir)
os.chdir(tdir)
@main.command(name='doctor', short_help='run various checks')
@click.option('--verbose/--quiet', default=False, help='Print more diagnostic information')
@click.option('--all', 'list_all', is_flag=True, help='List all modules, including disabled')
@click.option('--quick', is_flag=True, help='Only run partial checks (first 100 items)')
@click.option('--skip-config-check', 'skip_conf', is_flag=True, help='Skip configuration check')
@click.argument('MODULE', nargs=-1, required=False)
def doctor_cmd(verbose: bool, list_all: bool, quick: bool, skip_conf: bool, module: Sequence[str]) -> None:
'''
Run various checks
MODULE is one or more specific module names to check (e.g. my.reddit)
Otherwise, checks all modules
'''
if not skip_conf:
config_ok()
# TODO check that it finds private modules too?
modules_check(verbose=verbose, list_all=list_all, quick=quick, for_modules=list(module))
@main.group(name='config', short_help='work with configuration')
def config_grp() -> None:
'''Act on your HPI configuration'''
pass
@config_grp.command(name='check', short_help='check config')
def config_check_cmd() -> None:
'''Check your HPI configuration file'''
ok = config_ok()
sys.exit(0 if ok else False)
@config_grp.command(name='create', short_help='create user config')
def config_create_cmd() -> None:
'''Create user configuration file for HPI'''
config_create()
@main.command(name='modules', short_help='list available modules')
@click.option('--all', 'list_all', is_flag=True, help='List all modules, including disabled')
def module_cmd(list_all: bool) -> None:
'''List available modules'''
list_modules(list_all=list_all)
@main.group(name='module', short_help='module management')
def module_grp() -> None:
'''Module management'''
pass
@module_grp.command(name='requires', short_help='print module reqs')
@click.argument('MODULE')
def module_requires_cmd(module: str) -> None:
'''
Print MODULE requirements
MODULE is a specific module name (e.g. my.reddit)
'''
module_requires(module=module)
@module_grp.command(name='install', short_help='install module deps')
@click.option('--user', is_flag=True, help='same as pip --user')
@click.argument('MODULE')
def module_install_cmd(user: bool, module: str) -> None:
'''
Install dependencies for a module using pip
MODULE is a specific module name (e.g. my.reddit)
'''
# todo could add functions to check specific module etc..
module_install(user=user, module=module)
# todo: add more tests?
# its standard click practice to have the function click calls be a separate
# function from the decorated function, as it allows the application-specific code to be
# more testable. also allows hpi commands to be imported and called manually from
# other python code
def test_requires() -> None:
from click.testing import CliRunner
result = CliRunner().invoke(main, ['module', 'requires', 'my.github.ghexport'])
assert result.exit_code == 0
assert "github.com/karlicoss/ghexport" in result.output
if __name__ == '__main__': if __name__ == '__main__':
main() # prog_name is so that if this is invoked with python -m my.core
# this still shows hpi in the help text
main(prog_name='hpi')

View file

@ -9,6 +9,9 @@ import sys
from typing import Optional from typing import Optional
import warnings import warnings
import click
# just bring in the scope of this module for convenience # just bring in the scope of this module for convenience
from warnings import warn from warnings import warn
@ -18,16 +21,11 @@ def _colorize(x: str, color: Optional[str]=None) -> str:
if not sys.stdout.isatty(): if not sys.stdout.isatty():
return x return x
# click handles importing/initializing colorama if necessary
# on windows it installs it if necessary
# on linux/mac, it manually handles ANSI without needing termcolor
return click.style(x, fg=color)
# I'm not sure about this approach yet, so don't want to introduce a hard dependency yet
try:
import termcolor # type: ignore[import]
import colorama # type: ignore[import]
colorama.init()
return termcolor.colored(x, color)
except:
# todo log something?
return x
def _warn(message: str, *args, color: Optional[str]=None, **kwargs) -> None: def _warn(message: str, *args, color: Optional[str]=None, **kwargs) -> None:
stacklevel = kwargs.get('stacklevel', 1) stacklevel = kwargs.get('stacklevel', 1)

View file

@ -8,6 +8,7 @@ INSTALL_REQUIRES = [
'appdirs', # very common, and makes it portable 'appdirs', # very common, and makes it portable
'more-itertools', # it's just too useful and very common anyway 'more-itertools', # it's just too useful and very common anyway
'decorator' , # less pain in writing correct decorators. very mature and stable, so worth keeping in core 'decorator' , # less pain in writing correct decorators. very mature and stable, so worth keeping in core
'click' , # for the CLI, printing colors, decorator-based - may allow extensions to CLI
] ]

View file

@ -20,3 +20,4 @@ from my.core.freezer import *
from my.core.stats import * from my.core.stats import *
from my.core.query import * from my.core.query import *
from my.core.serialize import test_serialize_fallback from my.core.serialize import test_serialize_fallback
from my.core.__main__ import *