From a5c8efddb4d81fcd43a2f22aad8668455d6c04bc Mon Sep 17 00:00:00 2001 From: skullY Date: Sun, 8 Sep 2019 19:58:56 -0700 Subject: [PATCH] Rework how the CLI calls subcommands --- qmk_cli/doctor.py | 96 ----- qmk_cli/git.py | 2 +- qmk_cli/helpers.py | 4 +- qmk_cli/milc.py | 716 ---------------------------------- qmk_cli/script_qmk.py | 104 +++-- qmk_cli/subcommands/clone.py | 12 +- qmk_cli/subcommands/doctor.py | 29 -- qmk_cli/subcommands/setup.py | 37 +- setup.py | 4 +- 9 files changed, 94 insertions(+), 910 deletions(-) delete mode 100755 qmk_cli/doctor.py delete mode 100644 qmk_cli/milc.py delete mode 100755 qmk_cli/subcommands/doctor.py diff --git a/qmk_cli/doctor.py b/qmk_cli/doctor.py deleted file mode 100755 index ce64403..0000000 --- a/qmk_cli/doctor.py +++ /dev/null @@ -1,96 +0,0 @@ -"""QMK Python Doctor - -Check up for QMK environment. -""" -import shutil -import platform -import os -from pathlib import Path - -from qmk_cli.milc import cli - - -def check_qmk_firmware(): - """Make sure qmk_firmware and the qmk cli are there. - """ - qmk_firmware = Path(os.environ['QMK_HOME']) - qmk_cli = qmk_firmware / 'bin' / 'qmk' - - if qmk_firmware.exists() and not qmk_cli.exists(): - cli.log.error("{fg_red}Can't find %s/bin/qmk! You need to `git pull`.", os.environ['QMK_HOME']) - return False - - elif not qmk_firmware.exists(): - cli.log.error("{fg_red}Can't find the qmk_firmware checkout! %s does not exist!", os.environ['QMK_HOME']) - return False - - cli.log.info('Found qmk_firmware checkout in {fg_cyan}%s', str(qmk_firmware)) - return True - - -def check_vital_programs(): - """Make sure the software we need has been installed. - - TODO(unclaimed): - * [ ] Run the binaries to make sure they work - * [ ] Compile a trivial program with each compiler - """ - ok = True - binaries = ['dfu-programmer', 'avrdude', 'dfu-util', 'avr-gcc', 'arm-none-eabi-gcc'] - - for binary in binaries: - res = shutil.which(binary) - if res is None: - cli.log.error("{fg_red}QMK can't find %s in your path", binary) - ok = False - - if ok: - cli.log.info("All necessary software is installed.") - - return ok - - -def check_platform_tests(): - """Dispatch to platform specific tests. - """ - OS = platform.system() - - if OS == "Darwin": - cli.log.info("Detected {fg_cyan}macOS") - return check_mac_os() - - elif OS == "Linux": - cli.log.info("Detected {fg_cyan}linux") - return check_linux() - - else: - cli.log.info("Assuming {fg_cyan}Windows") - return check_windows() - - -def check_mac_os(): - """Run macOS specific tests. - - There aren't any yet. - """ - return True - - -def check_linux(): - """Run Linux specific tests. - - TODO(unclaimed): - * [ ] Check for udev entries on linux - """ - test = 'systemctl list-unit-files | grep enabled | grep -i ModemManager' - if os.system(test) == 0: - cli.log.warn("{bg_yellow}Detected modem manager. Please disable it if you are using Pro Micros") - - -def check_windows(): - """Run Windows specific tests. - - TODO(unclaimed): - * [ ] Check out the driver situation - """ - return True diff --git a/qmk_cli/git.py b/qmk_cli/git.py index 877be2b..269241b 100644 --- a/qmk_cli/git.py +++ b/qmk_cli/git.py @@ -2,7 +2,7 @@ """ import subprocess -from qmk_cli.milc import cli +from milc import cli default_repo = 'qmk_firmware' default_fork = 'qmk/' + default_repo diff --git a/qmk_cli/helpers.py b/qmk_cli/helpers.py index 237c2c0..e9f3b8c 100644 --- a/qmk_cli/helpers.py +++ b/qmk_cli/helpers.py @@ -1,6 +1,6 @@ """Useful helper functions. """ -from qmk_cli.milc import cli +from milc import cli def question(question, boolean=True, default=''): @@ -8,7 +8,7 @@ def question(question, boolean=True, default=''): This keeps re-asking until it gets acceptible input. """ - if cli.args.general_yes: + if cli.args.yes: return True if default and default.lower() == 'y': diff --git a/qmk_cli/milc.py b/qmk_cli/milc.py deleted file mode 100644 index 6e82edf..0000000 --- a/qmk_cli/milc.py +++ /dev/null @@ -1,716 +0,0 @@ -#!/usr/bin/env python3 -# coding=utf-8 -"""MILC - A CLI Framework - -PYTHON_ARGCOMPLETE_OK - -MILC is an opinionated framework for writing CLI apps. It optimizes for the -most common unix tool pattern- small tools that are run from the command -line but generally do not feature any user interaction while they run. - -For more details see the MILC documentation: - - -""" -from __future__ import division, print_function, unicode_literals -import argparse -import logging -import os -import re -import sys -from decimal import Decimal -from tempfile import NamedTemporaryFile -from time import sleep - -try: - from ConfigParser import RawConfigParser -except ImportError: - from configparser import RawConfigParser - -try: - import thread - import threading -except ImportError: - thread = None - -import argcomplete -import colorama - -# Log Level Representations -EMOJI_LOGLEVELS = { - 'CRITICAL': '{bg_red}{fg_white}¬_¬{style_reset_all}', - 'ERROR': '{fg_red}☒{style_reset_all}', - 'WARNING': '{fg_yellow}⚠{style_reset_all}', - 'INFO': '{fg_blue}ℹ{style_reset_all}', - 'DEBUG': '{fg_cyan}☐{style_reset_all}', - 'NOTSET': '{style_reset_all}¯\\_(o_o)_/¯' -} -EMOJI_LOGLEVELS['FATAL'] = EMOJI_LOGLEVELS['CRITICAL'] -EMOJI_LOGLEVELS['WARN'] = EMOJI_LOGLEVELS['WARNING'] - -# ANSI Color setup -# Regex was gratefully borrowed from kfir on stackoverflow: -# https://stackoverflow.com/a/45448194 -ansi_regex = r'\x1b(' \ - r'(\[\??\d+[hl])|' \ - r'([=<>a-kzNM78])|' \ - r'([\(\)][a-b0-2])|' \ - r'(\[\d{0,2}[ma-dgkjqi])|' \ - r'(\[\d+;\d+[hfy]?)|' \ - r'(\[;?[hf])|' \ - r'(#[3-68])|' \ - r'([01356]n)|' \ - r'(O[mlnp-z]?)|' \ - r'(/Z)|' \ - r'(\d+)|' \ - r'(\[\?\d;\d0c)|' \ - r'(\d;\dR))' -ansi_escape = re.compile(ansi_regex, flags=re.IGNORECASE) -ansi_styles = ( - ('fg', colorama.ansi.AnsiFore()), - ('bg', colorama.ansi.AnsiBack()), - ('style', colorama.ansi.AnsiStyle()), -) -ansi_colors = {} - -for prefix, obj in ansi_styles: - for color in [x for x in obj.__dict__ if not x.startswith('_')]: - ansi_colors[prefix + '_' + color.lower()] = getattr(obj, color) - - -def format_ansi(text): - """Return a copy of text with certain strings replaced with ansi. - """ - # Avoid .format() so we don't have to worry about the log content - for color in ansi_colors: - text = text.replace('{%s}' % color, ansi_colors[color]) - return text + ansi_colors['style_reset_all'] - - -class ANSIFormatter(logging.Formatter): - """A log formatter that inserts ANSI color. - """ - - def format(self, record): - msg = super(ANSIFormatter, self).format(record) - return format_ansi(msg) - - -class ANSIEmojiLoglevelFormatter(ANSIFormatter): - """A log formatter that makes the loglevel an emoji. - """ - - def format(self, record): - record.levelname = EMOJI_LOGLEVELS[record.levelname].format(**ansi_colors) - return super(ANSIEmojiLoglevelFormatter, self).format(record) - - -class ANSIStrippingFormatter(ANSIFormatter): - """A log formatter that strips ANSI. - """ - - def format(self, record): - msg = super(ANSIStrippingFormatter, self).format(record) - return ansi_escape.sub('', msg) - - -class Configuration(object): - """Represents the running configuration. - - This class never raises IndexError, instead it will return None if a - section or option does not yet exist. - """ - - def __contains__(self, key): - return self._config.__contains__(key) - - def __iter__(self): - return self._config.__iter__() - - def __len__(self): - return self._config.__len__() - - def __repr__(self): - return self._config.__repr__() - - def keys(self): - return self._config.keys() - - def items(self): - return self._config.items() - - def values(self): - return self._config.values() - - def __init__(self, *args, **kwargs): - self._config = {} - self.default_container = ConfigurationOption - - def __getitem__(self, key): - """Returns a config section, creating it if it doesn't exist yet. - """ - if key not in self._config: - self.__dict__[key] = self._config[key] = ConfigurationOption() - - return self._config[key] - - def __setitem__(self, key, value): - self.__dict__[key] = value - self._config[key] = value - - def __delitem__(self, key): - if key in self.__dict__ and key[0] != '_': - del self.__dict__[key] - del self._config[key] - - -class ConfigurationOption(Configuration): - def __init__(self, *args, **kwargs): - super(ConfigurationOption, self).__init__(*args, **kwargs) - self.default_container = dict - - def __getitem__(self, key): - """Returns a config section, creating it if it doesn't exist yet. - """ - if key not in self._config: - self.__dict__[key] = self._config[key] = None - - return self._config[key] - - -def handle_store_boolean(self, *args, **kwargs): - """Does the add_argument for action='store_boolean'. - """ - kwargs['add_dest'] = False - disabled_args = None - disabled_kwargs = kwargs.copy() - disabled_kwargs['action'] = 'store_false' - disabled_kwargs['help'] = 'Disable ' + kwargs['help'] - kwargs['action'] = 'store_true' - kwargs['help'] = 'Enable ' + kwargs['help'] - - for flag in args: - if flag[:2] == '--': - disabled_args = ('--no-' + flag[2:],) - break - - self.add_argument(*args, **kwargs) - self.add_argument(*disabled_args, **disabled_kwargs) - - return (args, kwargs, disabled_args, disabled_kwargs) - - -class SubparserWrapper(object): - """Wrap subparsers so we can populate the normal and the shadow parser. - """ - - def __init__(self, cli, submodule, subparser): - self.cli = cli - self.submodule = submodule - self.subparser = subparser - - for attr in dir(subparser): - if not hasattr(self, attr): - setattr(self, attr, getattr(subparser, attr)) - - def completer(self, completer): - """Add an arpcomplete completer to this subcommand. - """ - self.subparser.completer = completer - - def add_argument(self, *args, **kwargs): - if kwargs.get('add_dest', True): - kwargs['dest'] = self.submodule + '_' + self.cli.get_argument_name(*args, **kwargs) - if 'add_dest' in kwargs: - del kwargs['add_dest'] - - if 'action' in kwargs and kwargs['action'] == 'store_boolean': - return handle_store_boolean(self, *args, **kwargs) - - self.cli.acquire_lock() - self.subparser.add_argument(*args, **kwargs) - - if 'default' in kwargs: - del kwargs['default'] - if 'action' in kwargs and kwargs['action'] == 'store_false': - kwargs['action'] == 'store_true' - self.cli.subcommands_default[self.submodule].add_argument(*args, **kwargs) - self.cli.release_lock() - - -class MILC(object): - """MILC - An Opinionated Batteries Included Framework - """ - - def __init__(self): - """Initialize the MILC object. - """ - # Setup a lock for thread safety - self._lock = threading.RLock() if thread else None - - # Define some basic info - self.acquire_lock() - self._description = None - self._entrypoint = None - self._inside_context_manager = False - self.ansi = ansi_colors - self.config = Configuration() - self.config_file = None - self.prog_name = sys.argv[0][:-3] if sys.argv[0].endswith('.py') else sys.argv[0] - self.version = os.environ.get('QMK_VERSION', 'unknown') - self.release_lock() - - # Initialize all the things - self.initialize_argparse() - self.initialize_logging() - - @property - def description(self): - return self._description - - @description.setter - def description(self, value): - self._description = self._arg_parser.description = self._arg_defaults.description = value - - def echo(self, text, *args, **kwargs): - """Print colorized text to stdout, as long as stdout is a tty. - - ANSI color strings (such as {fg-blue}) will be converted into ANSI - escape sequences, and the ANSI reset sequence will be added to all - strings. - - If *args or **kwargs are passed they will be used to %-format the strings. - """ - if args and kwargs: - raise RuntimeError('You can only specify *args or **kwargs, not both!') - - if sys.stdout.isatty(): - args = args or kwargs - text = format_ansi(text) - - print(text % args) - - def initialize_argparse(self): - """Prepare to process arguments from sys.argv. - """ - kwargs = { - 'fromfile_prefix_chars': '@', - 'conflict_handler': 'resolve', - } - - self.acquire_lock() - self.subcommands = {} - self.subcommands_default = {} - self._subparsers = None - self._subparsers_default = None - self.argwarn = argcomplete.warn - self.args = None - self._arg_defaults = argparse.ArgumentParser(**kwargs) - self._arg_parser = argparse.ArgumentParser(**kwargs) - self.set_defaults = self._arg_parser.set_defaults - self.print_usage = self._arg_parser.print_usage - self.print_help = self._arg_parser.print_help - self.release_lock() - - def completer(self, completer): - """Add an arpcomplete completer to this subcommand. - """ - self._arg_parser.completer = completer - - def add_argument(self, *args, **kwargs): - """Wrapper to add arguments to both the main and the shadow argparser. - """ - if kwargs.get('add_dest', True) and args[0][0] == '-': - kwargs['dest'] = 'general_' + self.get_argument_name(*args, **kwargs) - if 'add_dest' in kwargs: - del kwargs['add_dest'] - - if 'action' in kwargs and kwargs['action'] == 'store_boolean': - return handle_store_boolean(self, *args, **kwargs) - - self.acquire_lock() - self._arg_parser.add_argument(*args, **kwargs) - - # Populate the shadow parser - if 'default' in kwargs: - del kwargs['default'] - if 'action' in kwargs and kwargs['action'] == 'store_false': - kwargs['action'] == 'store_true' - self._arg_defaults.add_argument(*args, **kwargs) - self.release_lock() - - def initialize_logging(self): - """Prepare the defaults for the logging infrastructure. - """ - self.acquire_lock() - self.log_file = None - self.log_file_mode = 'a' - self.log_file_handler = None - self.log_print = True - self.log_print_to = sys.stderr - self.log_print_level = logging.INFO - self.log_file_level = logging.DEBUG - self.log_level = logging.INFO - self.log = logging.getLogger(self.__class__.__name__) - self.log.setLevel(logging.DEBUG) - logging.root.setLevel(logging.DEBUG) - self.release_lock() - - self.add_argument('-V', '--version', version=self.version, action='version', help='Display the version and exit') - self.add_argument('-v', '--verbose', action='store_true', help='Make the logging more verbose') - self.add_argument('--datetime-fmt', default='%Y-%m-%d %H:%M:%S', help='Format string for datetimes') - self.add_argument('--log-fmt', default='%(levelname)s %(message)s', help='Format string for printed log output') - self.add_argument('--log-file-fmt', default='[%(levelname)s] [%(asctime)s] [file:%(pathname)s] [line:%(lineno)d] %(message)s', help='Format string for log file.') - self.add_argument('--log-file', help='File to write log messages to') - self.add_argument('--color', action='store_boolean', default=True, help='color in output') - self.add_argument('-c', '--config-file', help='The config file to read and/or write') - self.add_argument('--save-config', action='store_true', help='Save the running configuration to the config file') - - def add_subparsers(self, title='Sub-commands', **kwargs): - if self._inside_context_manager: - raise RuntimeError('You must run this before the with statement!') - - self.acquire_lock() - self._subparsers_default = self._arg_defaults.add_subparsers(title=title, dest='subparsers', **kwargs) - self._subparsers = self._arg_parser.add_subparsers(title=title, dest='subparsers', **kwargs) - self.release_lock() - - def acquire_lock(self): - """Acquire the MILC lock for exclusive access to properties. - """ - if self._lock: - self._lock.acquire() - - def release_lock(self): - """Release the MILC lock. - """ - if self._lock: - self._lock.release() - - def find_config_file(self): - """Locate the config file. - """ - if self.config_file: - return self.config_file - - if self.args and self.args.general_config_file: - return self.args.general_config_file - - return os.path.abspath(os.path.expanduser('~/.%s.ini' % self.prog_name)) - - def get_argument_name(self, *args, **kwargs): - """Takes argparse arguments and returns the dest name. - """ - try: - return self._arg_parser._get_optional_kwargs(*args, **kwargs)['dest'] - except ValueError: - return self._arg_parser._get_positional_kwargs(*args, **kwargs)['dest'] - - def argument(self, *args, **kwargs): - """Decorator to call self.add_argument or self..add_argument. - """ - if self._inside_context_manager: - raise RuntimeError('You must run this before the with statement!') - - def argument_function(handler): - if handler is self._entrypoint: - self.add_argument(*args, **kwargs) - - elif handler.__name__ in self.subcommands: - self.subcommands[handler.__name__].add_argument(*args, **kwargs) - - else: - raise RuntimeError('Decorated function is not entrypoint or subcommand!') - - return handler - - return argument_function - - def arg_passed(self, arg): - """Returns True if arg was passed on the command line. - """ - return self.args_passed[arg] in (None, False) - - def parse_args(self): - """Parse the CLI args. - """ - if self.args: - self.log.debug('Warning: Arguments have already been parsed, ignoring duplicate attempt!') - return - - argcomplete.autocomplete(self._arg_parser) - - self.acquire_lock() - self.args = self._arg_parser.parse_args() - self.args_passed = self._arg_defaults.parse_args() - - if 'entrypoint' in self.args: - self._entrypoint = self.args.entrypoint - - if self.args.general_config_file: - self.config_file = self.args.general_config_file - - self.release_lock() - - def read_config(self): - """Parse the configuration file and determine the runtime configuration. - """ - self.acquire_lock() - self.config_file = self.find_config_file() - - if self.config_file and os.path.exists(self.config_file): - config = RawConfigParser(self.config) - config.read(self.config_file) - - # Iterate over the config file options and write them into self.config - for section in config.sections(): - for option in config.options(section): - value = config.get(section, option) - - # Coerce values into useful datatypes - if value.lower() in ['1', 'yes', 'true', 'on']: - value = True - elif value.lower() in ['0', 'no', 'false', 'none', 'off']: - value = False - elif value.replace('.', '').isdigit(): - if '.' in value: - value = Decimal(value) - else: - value = int(value) - - self.config[section][option] = value - - # Fold the CLI args into self.config - for argument in vars(self.args): - if argument in ('subparsers', 'entrypoint'): - continue - - if '_' not in argument: - continue - - section, option = argument.split('_', 1) - if hasattr(self.args_passed, argument): - self.config[section][option] = getattr(self.args, argument) - else: - if option not in self.config[section]: - self.config[section][option] = getattr(self.args, argument) - - self.release_lock() - - def save_config(self): - """Save the current configuration to the config file. - """ - self.log.debug("Saving config file to '%s'", self.config_file) - - if not self.config_file: - self.log.warning('%s.config_file file not set, not saving config!', self.__class__.__name__) - return - - self.acquire_lock() - - config = RawConfigParser() - for section_name, section in self.config._config.items(): - config.add_section(section_name) - for option_name, value in section.items(): - if section_name == 'general': - if option_name in ['save_config']: - continue - config.set(section_name, option_name, str(value)) - - with NamedTemporaryFile(mode='w', dir=os.path.dirname(self.config_file), delete=False) as tmpfile: - config.write(tmpfile) - - # Move the new config file into place atomically - if os.path.getsize(tmpfile.name) > 0: - os.rename(tmpfile.name, self.config_file) - else: - self.log.warning('Config file saving failed, not replacing %s with %s.', self.config_file, tmpfile.name) - - self.release_lock() - - def __call__(self): - """Execute the entrypoint function. - """ - if not self._inside_context_manager: - # If they didn't use the context manager use it ourselves - with self: - self.__call__() - return - - if not self._entrypoint: - raise RuntimeError('No entrypoint provided!') - - return self._entrypoint(self) - - def entrypoint(self, description): - """Set the entrypoint for when no subcommand is provided. - """ - if self._inside_context_manager: - raise RuntimeError('You must run this before cli()!') - - self.acquire_lock() - self.description = description - self.release_lock() - - def entrypoint_func(handler): - self.acquire_lock() - self._entrypoint = handler - self.release_lock() - - return handler - - return entrypoint_func - - def add_subcommand(self, handler, description, name=None, **kwargs): - """Register a subcommand. - - If name is not provided we use `handler.__name__`. - """ - if self._inside_context_manager: - raise RuntimeError('You must run this before the with statement!') - - if self._subparsers is None: - self.add_subparsers() - - if not name: - name = handler.__name__ - - self.acquire_lock() - kwargs['help'] = description - self.subcommands_default[name] = self._subparsers_default.add_parser(name, **kwargs) - self.subcommands[name] = SubparserWrapper(self, name, self._subparsers.add_parser(name, **kwargs)) - self.subcommands[name].set_defaults(entrypoint=handler) - - if name not in self.__dict__: - self.__dict__[name] = self.subcommands[name] - else: - self.log.debug("Could not add subcommand '%s' to attributes, key already exists!", name) - - self.release_lock() - - return handler - - def subcommand(self, description, **kwargs): - """Decorator to register a subcommand. - """ - - def subcommand_function(handler): - return self.add_subcommand(handler, description, **kwargs) - - return subcommand_function - - def setup_logging(self): - """Called by __enter__() to setup the logging configuration. - """ - if len(logging.root.handlers) != 0: - # This is not a design decision. This is what I'm doing for now until I can examine and think about this situation in more detail. - raise RuntimeError('MILC should be the only system installing root log handlers!') - - self.acquire_lock() - - if self.config['general']['verbose']: - self.log_print_level = logging.DEBUG - - self.log_file = self.config['general']['log_file'] or self.log_file - self.log_file_format = self.config['general']['log_file_fmt'] - self.log_file_format = ANSIStrippingFormatter(self.config['general']['log_file_fmt'], self.config['general']['datetime_fmt']) - self.log_format = self.config['general']['log_fmt'] - - if self.config.general.color: - self.log_format = ANSIEmojiLoglevelFormatter(self.args.general_log_fmt, self.config.general.datetime_fmt) - else: - self.log_format = ANSIStrippingFormatter(self.args.general_log_fmt, self.config.general.datetime_fmt) - - if self.log_file: - self.log_file_handler = logging.FileHandler(self.log_file, self.log_file_mode) - self.log_file_handler.setLevel(self.log_file_level) - self.log_file_handler.setFormatter(self.log_file_format) - logging.root.addHandler(self.log_file_handler) - - if self.log_print: - self.log_print_handler = logging.StreamHandler(self.log_print_to) - self.log_print_handler.setLevel(self.log_print_level) - self.log_print_handler.setFormatter(self.log_format) - logging.root.addHandler(self.log_print_handler) - - self.release_lock() - - def __enter__(self): - if self._inside_context_manager: - self.log.debug('Warning: context manager was entered again. This usually means that self.__call__() was called before the with statement. You probably do not want to do that.') - return - - self.acquire_lock() - self._inside_context_manager = True - self.release_lock() - - colorama.init() - self.parse_args() - self.read_config() - self.setup_logging() - - if self.config.general.save_config: - self.save_config() - - return self - - def __exit__(self, exc_type, exc_val, exc_tb): - self.acquire_lock() - self._inside_context_manager = False - self.release_lock() - - if exc_type is not None and not isinstance(SystemExit(), exc_type): - print(exc_type) - logging.exception(exc_val) - exit(255) - - -cli = MILC() - -if __name__ == '__main__': - - @cli.argument('-c', '--comma', help='comma in output', default=True, action='store_boolean') - @cli.entrypoint('My useful CLI tool with subcommands.') - def main(cli): - comma = ',' if cli.config.general.comma else '' - cli.log.info('{bg_green}{fg_red}Hello%s World!', comma) - - @cli.argument('-n', '--name', help='Name to greet', default='World') - @cli.subcommand('Description of hello subcommand here.') - def hello(cli): - comma = ',' if cli.config.general.comma else '' - cli.log.info('{fg_blue}Hello%s %s!', comma, cli.config.hello.name) - - def goodbye(cli): - comma = ',' if cli.config.general.comma else '' - cli.log.info('{bg_red}Goodbye%s %s!', comma, cli.config.goodbye.name) - - @cli.argument('-n', '--name', help='Name to greet', default='World') - @cli.subcommand('Think a bit before greeting the user.') - def thinking(cli): - comma = ',' if cli.config.general.comma else '' - spinner = cli.spinner(text='Just a moment...', spinner='earth') - spinner.start() - sleep(2) - spinner.stop() - - with cli.spinner(text='Almost there!', spinner='moon'): - sleep(2) - - cli.log.info('{fg_cyan}Hello%s %s!', comma, cli.config.thinking.name) - - @cli.subcommand('Show off our ANSI colors.') - def pride(cli): - cli.echo('{bg_red} ') - cli.echo('{bg_lightred_ex} ') - cli.echo('{bg_lightyellow_ex} ') - cli.echo('{bg_green} ') - cli.echo('{bg_blue} ') - cli.echo('{bg_magenta} ') - - # You can register subcommands using decorators as seen above, or using functions like like this: - cli.add_subcommand(goodbye, 'This will show up in --help output.') - cli.goodbye.add_argument('-n', '--name', help='Name to bid farewell to', default='World') - - cli() # Automatically picks between main(), hello() and goodbye() - print(sorted(ansi_colors.keys())) diff --git a/qmk_cli/script_qmk.py b/qmk_cli/script_qmk.py index 07247ff..7e96aea 100644 --- a/qmk_cli/script_qmk.py +++ b/qmk_cli/script_qmk.py @@ -2,6 +2,8 @@ """CLI wrapper for running QMK commands. This program can be run from anywhere, with or without a qmk_firmware checkout. It provides a small set of subcommands for working with QMK and otherwise dispatches to the `qmk_firmware/bin/qmk` script for the repo you are currently in, or your default repo if you are not currently in a qmk_firmware checkout. + +FIXME(skullydazed): --help shows underscores where we want dashes in subcommands (EG json_keymap instead of json-keymap) """ import argparse import os @@ -10,6 +12,28 @@ import sys from functools import lru_cache from importlib import import_module from pathlib import Path +from pkgutil import walk_packages + +import milc + + +SUBCOMMAND_BLACKLIST = ['qmk.cli.subcommands'] +milc.EMOJI_LOGLEVELS['INFO'] = '{fg_blue}Ψ{style_reset_all}' + + +@milc.cli.entrypoint('CLI wrapper for running QMK commands.') +def qmk_main(cli): + """The function that gets run when there's no subcommand. + """ + cli.print_help() + + +def subcommand_modules(): + """Returns a list of subcommands + """ + for pkg in walk_packages(): + if 'qmk_cli.subcommands.' in pkg.name or 'qmk.cli.' in pkg.name: + yield pkg.name @lru_cache(maxsize=2) @@ -21,7 +45,7 @@ def in_qmk_firmware(): found_bin = cur_dir / 'bin' / 'qmk' if found_bin.is_file(): command = [found_bin, '--version'] - result = subprocess.run(command) + result = subprocess.run(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE) if result.returncode == 0: return cur_dir @@ -47,21 +71,6 @@ def find_qmk_firmware(): return Path.home() / 'qmk_firmware' -def parse_args(): - """Process arguments outside milc. - """ - parser = argparse.ArgumentParser(description='CLI wrapper for running QMK commands.') - parser.add_argument('-H', '--home', help='Path to the qmk_firmware directory.') - parser.add_argument('subcommand', help='Subcommand to run') - parser.add_argument('subcommand_args', nargs=argparse.REMAINDER, help='Arguments to pass to the subcommand.') - args = parser.parse_args() - - if args.home: - os.environ['QMK_HOME'] = args.home - - return (args.subcommand, args.subcommand_args) - - def main(): """Dispatch the CLI subcommand to the proper place. @@ -69,33 +78,52 @@ def main(): All other subcommands are dispatched to the local `qmk`, either the one we are currently in or whatever the user's default qmk_firmware is. """ - subcommand, subcommand_args = parse_args() - subcommand_module = 'qmk_cli.subcommands.' + subcommand - sys.argv = ['qmk-'+subcommand] + subcommand_args + # Environment setup qmk_firmware = find_qmk_firmware() - qmk_bin = qmk_firmware / 'bin' / 'qmk' os.environ['QMK_HOME'] = str(qmk_firmware) + qmk_bin = qmk_firmware / 'bin' / 'qmk' + qmk_lib = qmk_firmware / 'lib' / 'python' + sys.path.append(str(qmk_lib)) - try: - # Attempt to import the subcommand from qmk_cli first - import qmk_cli.milc - import_module(subcommand_module) - qmk_cli.milc.cli() + subcommand = None - except ImportError as e: - # Check to make sure there's not a bad import statement in qmk_cli - if e.name != subcommand_module: - raise + for count, arg in enumerate(sys.argv[1:]): + if arg and arg[0] != '-': + sys.argv[count+1] = subcommand = arg.replace('-', '_') + subcommand = subcommand.replace('_', '.') + break - # Dispatch to the underlying `qmk_firmware/bin/qmk` - if qmk_bin.is_file() and os.access(str(qmk_bin), os.X_OK): - argv = ['python3', str(qmk_bin), subcommand] + subcommand_args - os.execvp('python3', argv) + if not subcommand: + # Import all the subcommand modules so --help works correctly + for subcommand_module in subcommand_modules(): + if subcommand_module in SUBCOMMAND_BLACKLIST: + continue - # Tell the user we can't continue - print('*** Could not locate qmk_firmware directory!') - exit(255) + try: + import_module(subcommand_module) + except ModuleNotFoundError as e: + if e.name != subcommand_module: + raise -if __name__ == '__main__': - main() + else: + subcommand_module = None + + for module in subcommand_modules(): + if subcommand in module: + subcommand_module = module + break # First match wins + + if subcommand_module: + if subcommand_module.startswith('qmk.cli.'): + os.environ['ORIG_CWD'] = os.getcwd() + os.chdir(str(qmk_firmware)) + + import_module(subcommand_module) + + else: + print("Ψ Can't find subcommand %s!" % (subcommand,)) # milc.cli.log is not available at this point in execution + exit(255) + + # Call the entrypoint + milc.cli() diff --git a/qmk_cli/subcommands/clone.py b/qmk_cli/subcommands/clone.py index 4595e4e..8a3f0ed 100644 --- a/qmk_cli/subcommands/clone.py +++ b/qmk_cli/subcommands/clone.py @@ -3,8 +3,8 @@ import os from pathlib import Path -from qmk_cli.milc import cli -from qmk.cli.git import clone +from milc import cli +from qmk_cli.git import clone default_repo = 'qmk_firmware' default_fork = 'qmk/' + default_repo @@ -15,14 +15,14 @@ default_branch = 'master' @cli.argument('-b', '--branch', default=default_branch, help='The branch to clone.') @cli.argument('destination', default=os.environ['QMK_HOME'], nargs='?', help='The directory to clone to.') @cli.argument('fork', default=default_fork, nargs='?', help='The qmk_firmware fork to clone') -@cli.entrypoint('Clone a qmk_firmware fork.') -def main(cli): +@cli.subcommand('Clone a qmk_firmware fork.') +def clone(cli): qmk_firmware = Path(cli.args.destination) - git_url = '/'.join((cli.config.general.baseurl, cli.args.fork)) + git_url = '/'.join((cli.config.clone.baseurl, cli.args.fork)) if qmk_firmware.exists(): cli.log.error('Destination already exists: %s', cli.args.destination) exit(1) - success = clone(git_url, cli.args.destination, cli.config.general.branch) + success = clone(git_url, cli.args.destination, cli.config.clone.branch) exit(0 if success else 1) diff --git a/qmk_cli/subcommands/doctor.py b/qmk_cli/subcommands/doctor.py deleted file mode 100755 index 110e5a4..0000000 --- a/qmk_cli/subcommands/doctor.py +++ /dev/null @@ -1,29 +0,0 @@ -"""QMK Python Doctor - -Check up for QMK environment. -""" -import qmk_cli.doctor -from qmk_cli.milc import cli - - -@cli.entrypoint('Basic QMK environment checks') -def main(cli): - """Basic QMK environment checks. - - This is currently very simple, it just checks that all the expected binaries are on your system. - """ - cli.log.info('QMK Doctor is checking your environment') - - funcs = ( - qmk_cli.doctor.check_qmk_firmware, - qmk_cli.doctor.check_platform_tests, - qmk_cli.doctor.check_vital_programs, - ) - - ok = True - for func in funcs: - if not func(): - ok = False - - if ok: - cli.log.info('{fg_green}QMK is ready to go') diff --git a/qmk_cli/subcommands/setup.py b/qmk_cli/subcommands/setup.py index 322007e..5a1f6be 100644 --- a/qmk_cli/subcommands/setup.py +++ b/qmk_cli/subcommands/setup.py @@ -2,12 +2,12 @@ """ import os import subprocess +import sys from pathlib import Path -from qmk_cli.doctor import check_vital_programs +from milc import cli from qmk_cli.git import clone from qmk_cli.helpers import question -from qmk_cli.milc import cli default_repo = 'qmk_firmware' default_fork = 'qmk/' + default_repo @@ -19,9 +19,8 @@ default_branch = 'master' @cli.argument('-b', '--branch', default=default_branch, help='The branch to clone') @cli.argument('destination', default=os.environ['QMK_HOME'], nargs='?', help='The directory to clone to') @cli.argument('fork', default=default_fork, nargs='?', help='The qmk_firmware fork to clone') -@cli.entrypoint('Setup your computer for qmk_firmware.') -def main(cli): - setup_successful = False +@cli.subcommand('Setup your computer for qmk_firmware.') +def setup(cli): qmk_firmware = Path(cli.args.destination) # Check on qmk_firmware, and if it doesn't exist offer to check it out. @@ -33,19 +32,17 @@ def main(cli): git_url = '/'.join((cli.config.general.baseurl, cli.args.fork)) clone(git_url, cli.args.destination, cli.config.general.branch) - # Check if the build environment is setup, and if not offer to set it up - if check_vital_programs(): - cli.log.info('Your build environment is ready!') - else: - cli.log.error('Your build environment is not setup completely.') - if qmk_firmware.exists() and question('Would you like to run util/qmk_install?'): - curdir = os.getcwd() - os.chdir(str(qmk_firmware)) - process = subprocess.run(['util/qmk_install.sh']) - os.chdir(curdir) - if process.returncode == 0: - setup_successful = True + # Run `qmk_firmware/bin/qmk doctor` to check the rest of the environment out + if qmk_firmware.exists(): + qmk_bin = qmk_firmware / 'bin' / 'qmk' + doctor = subprocess.run([sys.executable, qmk_bin, 'doctor']) + if doctor.returncode != 0: + cli.log.error('Your build environment is not setup completely.') - # fin - if setup_successful: - cli.log.info('QMK setup complete!') + if question('Would you like to run util/qmk_install?'): + curdir = os.getcwd() + os.chdir(str(qmk_firmware)) + process = subprocess.run(['util/qmk_install.sh']) + os.chdir(curdir) + if process.returncode == 0: + cli.log.info('QMK setup complete!') diff --git a/setup.py b/setup.py index 075e18e..a0fdc19 100644 --- a/setup.py +++ b/setup.py @@ -38,8 +38,8 @@ if __name__ == "__main__": requires_python=metadata['requires-python'], install_requires=[ #"milc", #FIXME(skullydazed): Included in the repo for now. + "appdirs", "argcomplete", - "colorama", - #"halo" + "colorama" ], )