diff options
Diffstat (limited to 'lib/python')
-rw-r--r-- | lib/python/kle2xy.py | 146 | ||||
-rw-r--r-- | lib/python/milc.py | 179 | ||||
-rw-r--r-- | lib/python/qmk/cli/__init__.py | 2 | ||||
-rw-r--r-- | lib/python/qmk/cli/cformat.py | 11 | ||||
-rwxr-xr-x | lib/python/qmk/cli/compile.py | 16 | ||||
-rw-r--r-- | lib/python/qmk/cli/config.py | 96 | ||||
-rw-r--r-- | lib/python/qmk/cli/docs.py | 10 | ||||
-rwxr-xr-x | lib/python/qmk/cli/doctor.py | 13 | ||||
-rw-r--r-- | lib/python/qmk/cli/flash.py | 83 | ||||
-rwxr-xr-x | lib/python/qmk/cli/json/keymap.py | 5 | ||||
-rwxr-xr-x | lib/python/qmk/cli/kle2json.py | 75 | ||||
-rw-r--r-- | lib/python/qmk/cli/list/keyboards.py | 27 | ||||
-rw-r--r-- | lib/python/qmk/cli/pytest.py | 16 | ||||
-rw-r--r-- | lib/python/qmk/commands.py | 54 | ||||
-rw-r--r-- | lib/python/qmk/converter.py | 33 | ||||
-rw-r--r-- | lib/python/qmk/errors.py | 1 | ||||
-rw-r--r-- | lib/python/qmk/keymap.py | 4 | ||||
-rw-r--r-- | lib/python/qmk/path.py | 1 | ||||
-rw-r--r-- | lib/python/qmk/tests/attrdict.py | 1 | ||||
-rw-r--r-- | lib/python/qmk/tests/kle.txt | 5 | ||||
-rw-r--r-- | lib/python/qmk/tests/test_cli_commands.py | 11 | ||||
-rw-r--r-- | lib/python/qmk/tests/test_qmk_errors.py | 2 | ||||
-rw-r--r-- | lib/python/qmk/tests/test_qmk_path.py | 2 |
23 files changed, 607 insertions, 186 deletions
diff --git a/lib/python/kle2xy.py b/lib/python/kle2xy.py new file mode 100644 index 000000000..bff1d025b --- /dev/null +++ b/lib/python/kle2xy.py @@ -0,0 +1,146 @@ +""" Original code from https://github.com/skullydazed/kle2xy +""" + +import hjson +from decimal import Decimal + + +class KLE2xy(list): + """Abstract interface for interacting with a KLE layout. + """ + def __init__(self, layout=None, name='', invert_y=True): + super(KLE2xy, self).__init__() + + self.name = name + self.invert_y = invert_y + self.key_width = Decimal('19.05') + self.key_skel = {'decal': False, 'border_color': 'none', 'keycap_profile': '', 'keycap_color': 'grey', 'label_color': 'black', 'label_size': 3, 'label_style': 4, 'width': Decimal('1'), 'height': Decimal('1'), 'x': Decimal('0'), 'y': Decimal('0')} + self.rows = Decimal(0) + self.columns = Decimal(0) + + if layout: + self.parse_layout(layout) + + @property + def width(self): + """Returns the width of the keyboard plate. + """ + return (Decimal(self.columns) * self.key_width) + self.key_width / 2 + + @property + def height(self): + """Returns the height of the keyboard plate. + """ + return (self.rows * self.key_width) + self.key_width / 2 + + @property + def size(self): + """Returns the size of the keyboard plate. + """ + return (self.width, self.height) + + def attrs(self, properties): + """Parse the keyboard properties dictionary. + """ + # FIXME: Store more than just the keyboard name. + if 'name' in properties: + self.name = properties['name'] + + def parse_layout(self, layout): # noqa FIXME(skullydazed): flake8 says this has a complexity of 25, it should be refactored. + # Wrap this in a dictionary so hjson will parse KLE raw data + layout = '{"layout": [' + layout + ']}' + layout = hjson.loads(layout)['layout'] + + # Initialize our state machine + current_key = self.key_skel.copy() + current_row = Decimal(0) + current_col = Decimal(0) + current_x = 0 + current_y = self.key_width / 2 + + if isinstance(layout[0], dict): + self.attrs(layout[0]) + layout = layout[1:] + + for row_num, row in enumerate(layout): + self.append([]) + + # Process the current row + for key in row: + if isinstance(key, dict): + if 'w' in key and key['w'] != Decimal(1): + current_key['width'] = Decimal(key['w']) + if 'w2' in key and 'h2' in key and key['w2'] == 1.5 and key['h2'] == 1: + # FIXME: ISO Key uses these params: {x:0.25,w:1.25,h:2,w2:1.5,h2:1,x2:-0.25} + current_key['isoenter'] = True + if 'h' in key and key['h'] != Decimal(1): + current_key['height'] = Decimal(key['h']) + if 'a' in key: + current_key['label_style'] = self.key_skel['label_style'] = int(key['a']) + if current_key['label_style'] < 0: + current_key['label_style'] = 0 + elif current_key['label_style'] > 9: + current_key['label_style'] = 9 + if 'f' in key: + font_size = int(key['f']) + if font_size > 9: + font_size = 9 + elif font_size < 1: + font_size = 1 + current_key['label_size'] = self.key_skel['label_size'] = font_size + if 'p' in key: + current_key['keycap_profile'] = self.key_skel['keycap_profile'] = key['p'] + if 'c' in key: + current_key['keycap_color'] = self.key_skel['keycap_color'] = key['c'] + if 't' in key: + # FIXME: Need to do better validation, plus figure out how to support multiple colors + if '\n' in key['t']: + key['t'] = key['t'].split('\n')[0] + if key['t'] == "0": + key['t'] = "#000000" + current_key['label_color'] = self.key_skel['label_color'] = key['t'] + if 'x' in key: + current_col += Decimal(key['x']) + current_x += Decimal(key['x']) * self.key_width + if 'y' in key: + current_row += Decimal(key['y']) + current_y += Decimal(key['y']) * self.key_width + if 'd' in key: + current_key['decal'] = True + + else: + current_key['name'] = key + current_key['row'] = current_row + current_key['column'] = current_col + + # Determine the X center + x_center = (current_key['width'] * self.key_width) / 2 + current_x += x_center + current_key['x'] = current_x + current_x += x_center + + # Determine the Y center + y_center = (current_key['height'] * self.key_width) / 2 + y_offset = y_center - (self.key_width / 2) + current_key['y'] = (current_y + y_offset) + + # Tend to our row/col count + current_col += current_key['width'] + if current_col > self.columns: + self.columns = current_col + + # Invert the y-axis if neccesary + if self.invert_y: + current_key['y'] = -current_key['y'] + + # Store this key + self[-1].append(current_key) + current_key = self.key_skel.copy() + + # Move to the next row + current_x = 0 + current_y += self.key_width + current_col = Decimal(0) + current_row += Decimal(1) + if current_row > self.rows: + self.rows = Decimal(current_row) diff --git a/lib/python/milc.py b/lib/python/milc.py index 7b130bdea..4392c8376 100644 --- a/lib/python/milc.py +++ b/lib/python/milc.py @@ -20,6 +20,7 @@ import re import shlex import sys from decimal import Decimal +from pathlib import Path from tempfile import NamedTemporaryFile from time import sleep @@ -39,7 +40,7 @@ import colorama from appdirs import user_config_dir # Disable logging until we can configure it how the user wants -logging.basicConfig(filename='/dev/null') +logging.basicConfig(stream=os.devnull) # Log Level Representations EMOJI_LOGLEVELS = { @@ -96,7 +97,6 @@ def format_ansi(text): 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) @@ -105,7 +105,6 @@ class ANSIFormatter(logging.Formatter): class ANSIEmojiLoglevelFormatter(ANSIFormatter): """A log formatter that makes the loglevel an emoji on UTF capable terminals. """ - def format(self, record): if UNICODE_SUPPORT: record.levelname = EMOJI_LOGLEVELS[record.levelname].format(**ansi_colors) @@ -115,7 +114,6 @@ class ANSIEmojiLoglevelFormatter(ANSIFormatter): class ANSIStrippingFormatter(ANSIFormatter): """A log formatter that strips ANSI. """ - def format(self, record): msg = super(ANSIStrippingFormatter, self).format(record) return ansi_escape.sub('', msg) @@ -127,7 +125,6 @@ class Configuration(object): 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) @@ -214,9 +211,8 @@ def handle_store_boolean(self, *args, **kwargs): class SubparserWrapper(object): - """Wrap subparsers so we can populate the normal and the shadow parser. + """Wrap subparsers so we can track what options the user passed. """ - def __init__(self, cli, submodule, subparser): self.cli = cli self.submodule = submodule @@ -232,26 +228,30 @@ class SubparserWrapper(object): self.subparser.completer = completer def add_argument(self, *args, **kwargs): + """Add an argument for this subcommand. + + This also stores the default for the argument in `self.cli.default_arguments`. + """ if 'action' in kwargs and kwargs['action'] == 'store_boolean': + # Store boolean will call us again with the enable/disable flag arguments 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) + if self.submodule not in self.cli.default_arguments: + self.cli.default_arguments[self.submodule] = {} + self.cli.default_arguments[self.submodule][self.cli.get_argument_name(*args, **kwargs)] = kwargs.get('default') self.cli.release_lock() class MILC(object): """MILC - An Opinionated Batteries Included Framework """ - def __init__(self): """Initialize the MILC object. + + version + The version string to associate with your CLI program """ # Setup a lock for thread safety self._lock = threading.RLock() if thread else None @@ -263,9 +263,10 @@ class MILC(object): self._inside_context_manager = False self.ansi = ansi_colors self.arg_only = [] - self.config = Configuration() + self.config = None self.config_file = None - self.version = os.environ.get('QMK_VERSION', 'unknown') + self.default_arguments = {} + self.version = 'unknown' self.release_lock() # Figure out our program name @@ -273,6 +274,7 @@ class MILC(object): self.prog_name = self.prog_name.split('/')[-1] # Initialize all the things + self.read_config_file() self.initialize_argparse() self.initialize_logging() @@ -282,7 +284,7 @@ class MILC(object): @description.setter def description(self, value): - self._description = self._arg_parser.description = self._arg_defaults.description = value + self._description = self._arg_parser.description = value def echo(self, text, *args, **kwargs): """Print colorized text to stdout. @@ -311,12 +313,9 @@ class MILC(object): 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 @@ -329,25 +328,18 @@ class MILC(object): self._arg_parser.completer = completer def add_argument(self, *args, **kwargs): - """Wrapper to add arguments to both the main and the shadow argparser. + """Wrapper to add arguments and track whether they were passed on the command line. """ if 'action' in kwargs and kwargs['action'] == 'store_boolean': return handle_store_boolean(self, *args, **kwargs) - 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'] - self.acquire_lock() + self._arg_parser.add_argument(*args, **kwargs) + if 'general' not in self.default_arguments: + self.default_arguments['general'] = {} + self.default_arguments['general'][self.get_argument_name(*args, **kwargs)] = kwargs.get('default') - # 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): @@ -374,15 +366,14 @@ class MILC(object): 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') + self.add_argument('--config-file', help='The location for the configuration file') + self.arg_only.append('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() @@ -404,10 +395,12 @@ class MILC(object): if self.config_file: return self.config_file - if self.args and self.args.general_config_file: - return self.args.general_config_file + if '--config-file' in sys.argv: + return Path(sys.argv[sys.argv.index('--config-file') + 1]).expanduser().resolve() - return os.path.join(user_config_dir(appname='qmk', appauthor='QMK'), '%s.ini' % self.prog_name) + filedir = user_config_dir(appname='qmk', appauthor='QMK') + filename = '%s.ini' % self.prog_name + return Path(filedir) / filename def get_argument_name(self, *args, **kwargs): """Takes argparse arguments and returns the dest name. @@ -446,7 +439,7 @@ class MILC(object): def arg_passed(self, arg): """Returns True if arg was passed on the command line. """ - return self.args_passed[arg] in (None, False) + return self.default_arguments.get(arg) != self.args[arg] def parse_args(self): """Parse the CLI args. @@ -459,25 +452,22 @@ class MILC(object): 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. + def read_config_file(self): + """Read in the configuration file and store it in self.config. """ self.acquire_lock() + self.config = Configuration() self.config_file = self.find_config_file() - if self.config_file and os.path.exists(self.config_file): + if self.config_file and self.config_file.exists(): config = RawConfigParser(self.config) - config.read(self.config_file) + config.read(str(self.config_file)) # Iterate over the config file options and write them into self.config for section in config.sections(): @@ -487,8 +477,10 @@ class MILC(object): # Coerce values into useful datatypes if value.lower() in ['1', 'yes', 'true', 'on']: value = True - elif value.lower() in ['0', 'no', 'false', 'none', 'off']: + elif value.lower() in ['0', 'no', 'false', 'off']: value = False + elif value.lower() in ['none']: + continue elif value.replace('.', '').isdigit(): if '.' in value: value = Decimal(value) @@ -497,32 +489,44 @@ class MILC(object): self.config[section][option] = value - # Fold the CLI args into self.config + self.release_lock() + + def merge_args_into_config(self): + """Merge CLI arguments into self.config to create the runtime configuration. + """ + self.acquire_lock() for argument in vars(self.args): if argument in ('subparsers', 'entrypoint'): continue - if '_' in argument: - section, option = argument.split('_', 1) - else: - section = self._entrypoint.__name__ - option = argument - - if option not in self.arg_only: - if hasattr(self.args_passed, argument): + if argument not in self.arg_only: + # Find the argument's section + if self._entrypoint.__name__ in self.default_arguments and argument in self.default_arguments[self._entrypoint.__name__]: + argument_found = True + section = self._entrypoint.__name__ + if argument in self.default_arguments['general']: + argument_found = True + section = 'general' + + if not argument_found: + raise RuntimeError('Could not find argument in `self.default_arguments`. This should be impossible!') + exit(1) + + # Merge this argument into self.config + if argument in self.default_arguments: arg_value = getattr(self.args, argument) if arg_value: - self.config[section][option] = arg_value + self.config[section][argument] = arg_value else: - if option not in self.config[section]: - self.config[section][option] = getattr(self.args, argument) + if argument not in self.config[section]: + self.config[section][argument] = 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) + self.log.debug("Saving config file to '%s'", str(self.config_file)) if not self.config_file: self.log.warning('%s.config_file file not set, not saving config!', self.__class__.__name__) @@ -530,31 +534,34 @@ class MILC(object): self.acquire_lock() + # Generate a sanitized version of our running configuration config = RawConfigParser() - config_dir = os.path.dirname(self.config_file) - 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']: + if option_name in ['config_file']: continue - config.set(section_name, option_name, str(value)) + if value is not None: + config.set(section_name, option_name, str(value)) - if not os.path.exists(config_dir): - os.makedirs(config_dir) + # Write out the config file + config_dir = self.config_file.parent + if not config_dir.exists(): + config_dir.mkdir(parents=True, exist_ok=True) - with NamedTemporaryFile(mode='w', dir=config_dir, delete=False) as tmpfile: + with NamedTemporaryFile(mode='w', dir=str(config_dir), 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) + os.rename(tmpfile.name, str(self.config_file)) else: - self.log.warning('Config file saving failed, not replacing %s with %s.', self.config_file, tmpfile.name) + self.log.warning('Config file saving failed, not replacing %s with %s.', str(self.config_file), tmpfile.name) + # Housekeeping self.release_lock() - cli.log.info('Wrote configuration to %s', shlex.quote(self.config_file)) + cli.log.info('Wrote configuration to %s', shlex.quote(str(self.config_file))) def __call__(self): """Execute the entrypoint function. @@ -588,41 +595,37 @@ class MILC(object): return entrypoint_func - def add_subcommand(self, handler, description, name=None, **kwargs): + def add_subcommand(self, handler, description, name=None, hidden=False, **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() + self.add_subparsers(metavar="") if not name: name = handler.__name__.replace("_", "-") self.acquire_lock() - kwargs['help'] = description - self.subcommands_default[name] = self._subparsers_default.add_parser(name, **kwargs) + if not hidden: + self._subparsers.metavar = "{%s,%s}" % (self._subparsers.metavar[1:-1], name) if self._subparsers.metavar else "{%s%s}" % (self._subparsers.metavar[1:-1], name) + kwargs['help'] = description 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): + def subcommand(self, description, hidden=False, **kwargs): """Decorator to register a subcommand. """ - def subcommand_function(handler): - return self.add_subcommand(handler, description, **kwargs) + return self.add_subcommand(handler, description, hidden=hidden, **kwargs) return subcommand_function @@ -644,9 +647,9 @@ class MILC(object): 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) + self.log_format = ANSIEmojiLoglevelFormatter(self.args.log_fmt, self.config.general.datetime_fmt) else: - self.log_format = ANSIStrippingFormatter(self.args.general_log_fmt, self.config.general.datetime_fmt) + self.log_format = ANSIStrippingFormatter(self.args.log_fmt, self.config.general.datetime_fmt) if self.log_file: self.log_file_handler = logging.FileHandler(self.log_file, self.log_file_mode) @@ -673,13 +676,9 @@ class MILC(object): colorama.init() self.parse_args() - self.read_config() + self.merge_args_into_config() self.setup_logging() - if 'save_config' in self.config.general and self.config.general.save_config: - self.save_config() - exit(0) - return self def __exit__(self, exc_type, exc_val, exc_tb): diff --git a/lib/python/qmk/cli/__init__.py b/lib/python/qmk/cli/__init__.py index e41cc3dcb..72ee38f56 100644 --- a/lib/python/qmk/cli/__init__.py +++ b/lib/python/qmk/cli/__init__.py @@ -7,9 +7,11 @@ from . import compile from . import config from . import docs from . import doctor +from . import flash from . import hello from . import json from . import list +from . import kle2json from . import new from . import pyformat from . import pytest diff --git a/lib/python/qmk/cli/cformat.py b/lib/python/qmk/cli/cformat.py index d2382bdbd..17ca91b3b 100644 --- a/lib/python/qmk/cli/cformat.py +++ b/lib/python/qmk/cli/cformat.py @@ -2,6 +2,7 @@ """ import os import subprocess +from shutil import which from milc import cli @@ -11,10 +12,18 @@ from milc import cli def cformat(cli): """Format C code according to QMK's style. """ + # Determine which version of clang-format to use clang_format = ['clang-format', '-i'] + for clang_version in [10, 9, 8, 7]: + binary = 'clang-format-%d' % clang_version + if which(binary): + clang_format[0] = binary + break # Find the list of files to format - if not cli.args.files: + if cli.args.files: + cli.args.files = [os.path.join(os.environ['ORIG_CWD'], file) for file in cli.args.files] + else: for dir in ['drivers', 'quantum', 'tests', 'tmk_core']: for dirpath, dirnames, filenames in os.walk(dir): if 'tmk_core/protocol/usb_hid' in dirpath: diff --git a/lib/python/qmk/cli/compile.py b/lib/python/qmk/cli/compile.py index 6646891b3..8e2d0cdbf 100755 --- a/lib/python/qmk/cli/compile.py +++ b/lib/python/qmk/cli/compile.py @@ -2,13 +2,13 @@ You can compile a keymap already in the repo or using a QMK Configurator export. """ -import json -import os -import sys import subprocess from argparse import FileType from milc import cli +from qmk.commands import create_make_command +from qmk.commands import parse_configurator_json +from qmk.commands import compile_configurator_json import qmk.keymap import qmk.path @@ -30,20 +30,20 @@ def compile(cli): """ if cli.args.filename: # Parse the configurator json - user_keymap = json.load(cli.args.filename) + user_keymap = parse_configurator_json(cli.args.filename) # Generate the keymap keymap_path = qmk.path.keymap(user_keymap['keyboard']) cli.log.info('Creating {fg_cyan}%s{style_reset_all} keymap in {fg_cyan}%s', user_keymap['keymap'], keymap_path) - qmk.keymap.write(user_keymap['keyboard'], user_keymap['keymap'], user_keymap['layout'], user_keymap['layers']) - cli.log.info('Wrote keymap to {fg_cyan}%s/%s/keymap.c', keymap_path, user_keymap['keymap']) # Compile the keymap - command = ['make', ':'.join((user_keymap['keyboard'], user_keymap['keymap']))] + command = compile_configurator_json(user_keymap) + + cli.log.info('Wrote keymap to {fg_cyan}%s/%s/keymap.c', keymap_path, user_keymap['keymap']) elif cli.config.compile.keyboard and cli.config.compile.keymap: # Generate the make command for a specific keyboard/keymap. - command = ['make', ':'.join((cli.config.compile.keyboard, cli.config.compile.keymap))] + command = create_make_command(cli.config.compile.keyboard, cli.config.compile.keymap) else: cli.log.error('You must supply a configurator export or both `--keyboard` and `--keymap`.') diff --git a/lib/python/qmk/cli/config.py b/lib/python/qmk/cli/config.py index d6c774e65..e17d8bb9b 100644 --- a/lib/python/qmk/cli/config.py +++ b/lib/python/qmk/cli/config.py @@ -1,8 +1,5 @@ """Read and write configuration settings """ -import os -import subprocess - from milc import cli @@ -12,7 +9,55 @@ def print_config(section, key): cli.echo('%s.%s{fg_cyan}={fg_reset}%s', section, key, cli.config[section][key]) -@cli.argument('-ro', '--read-only', action='store_true', help='Operate in read-only mode.') +def show_config(): + """Print the current configuration to stdout. + """ + for section in cli.config: + for key in cli.config[section]: + print_config(section, key) + + +def parse_config_token(config_token): + """Split a user-supplied configuration-token into its components. + """ + section = option = value = None + + if '=' in config_token and '.' not in config_token: + cli.log.error('Invalid configuration token, the key must be of the form <section>.<option>: %s', config_token) + return section, option, value + + # Separate the key (<section>.<option>) from the value + if '=' in config_token: + key, value = config_token.split('=') + else: + key = config_token + + # Extract the section and option from the key + if '.' in key: + section, option = key.split('.', 1) + else: + section = key + + return section, option, value + + +def set_config(section, option, value): + """Set a config key in the running config. + """ + log_string = '%s.%s{fg_cyan}:{fg_reset} %s {fg_cyan}->{fg_reset} %s' + if cli.args.read_only: + log_string += ' {fg_red}(change not written)' + + cli.echo(log_string, section, option, cli.config[section][option], value) + + if not cli.args.read_only: + if value == 'None': + del cli.config[section][option] + else: + cli.config[section][option] = value + + +@cli.argument('-ro', '--read-only', arg_only=True, action='store_true', help='Operate in read-only mode.') @cli.argument('configs', nargs='*', arg_only=True, help='Configuration options to read or write.') @cli.subcommand("Read and write configuration settings.") def config(cli): @@ -33,12 +78,7 @@ def config(cli): No validation is done to ensure that the supplied section.key is actually used by qmk scripts. """ if not cli.args.configs: - # Walk the config tree - for section in cli.config: - for key in cli.config[section]: - print_config(section, key) - - return True + return show_config() # Process config_tokens save_config = False @@ -46,43 +86,23 @@ def config(cli): for argument in cli.args.configs: # Split on space in case they quoted multiple config tokens for config_token in argument.split(' '): - # Extract the section, config_key, and value to write from the supplied config_token. - if '=' in config_token: - key, value = config_token.split('=') - else: - key = config_token - value = None - - if '.' in key: - section, config_key = key.split('.', 1) - else: - section = key - config_key = None + section, option, value = parse_config_token(config_token) # Validation - if config_key and '.' in config_key: - cli.log.error('Config keys may not have more than one period! "%s" is not valid.', key) + if option and '.' in option: + cli.log.error('Config keys may not have more than one period! "%s" is not valid.', config_token) return False # Do what the user wants - if section and config_key and value: - # Write a config key - log_string = '%s.%s{fg_cyan}:{fg_reset} %s {fg_cyan}->{fg_reset} %s' - if cli.args.read_only: - log_string += ' {fg_red}(change not written)' - - cli.echo(log_string, section, config_key, cli.config[section][config_key], value) - + if section and option and value: + # Write a configuration option + set_config(section, option, value) if not cli.args.read_only: - if value == 'None': - del cli.config[section][config_key] - else: - cli.config[section][config_key] = value save_config = True - elif section and config_key: + elif section and option: # Display a single key - print_config(section, config_key) + print_config(section, option) elif section: # Display an entire section diff --git a/lib/python/qmk/cli/docs.py b/lib/python/qmk/cli/docs.py index b41989139..163c8b801 100644 --- a/lib/python/qmk/cli/docs.py +++ b/lib/python/qmk/cli/docs.py @@ -1,21 +1,19 @@ """Serve QMK documentation locally """ import http.server +import os from milc import cli -class DocsHandler(http.server.SimpleHTTPRequestHandler): - def __init__(self, *args, **kwargs): - super().__init__(*args, directory='docs', **kwargs) - - @cli.argument('-p', '--port', default=8936, type=int, help='Port number to use.') @cli.subcommand('Run a local webserver for QMK documentation.') def docs(cli): """Spin up a local HTTPServer instance for the QMK docs. """ - with http.server.HTTPServer(('', cli.config.docs.port), DocsHandler) as httpd: + os.chdir('docs') + + with http.server.HTTPServer(('', cli.config.docs.port), http.server.SimpleHTTPRequestHandler) as httpd: cli.log.info("Serving QMK docs at http://localhost:%d/", cli.config.docs.port) cli.log.info("Press Control+C to exit.") diff --git a/lib/python/qmk/cli/doctor.py b/lib/python/qmk/cli/doctor.py index 309de0c67..2421e520e 100755 --- a/lib/python/qmk/cli/doctor.py +++ b/lib/python/qmk/cli/doctor.py @@ -2,11 +2,9 @@ Check up for QMK environment. """ -import os import platform import shutil import subprocess -from glob import glob from milc import cli @@ -24,8 +22,7 @@ def doctor(cli): cli.log.info('QMK Doctor is checking your environment.') # Make sure the basic CLI tools we need are available and can be executed. - binaries = ['dfu-programmer', 'avrdude', 'dfu-util', 'avr-gcc', 'arm-none-eabi-gcc'] - binaries += glob('bin/qmk-*') + binaries = ['dfu-programmer', 'avrdude', 'dfu-util', 'avr-gcc', 'arm-none-eabi-gcc', 'bin/qmk'] ok = True for binary in binaries: @@ -34,15 +31,15 @@ def doctor(cli): cli.log.error("{fg_red}QMK can't find %s in your path.", binary) ok = False else: - try: - subprocess.run([binary, '--version'], stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=5, check=True) + check = subprocess.run([binary, '--version'], stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=5) + if check.returncode in [0, 1]: cli.log.info('Found {fg_cyan}%s', binary) - except subprocess.CalledProcessError: + else: cli.log.error("{fg_red}Can't run `%s --version`", binary) ok = False # Determine our OS and run platform specific tests - OS = platform.system() + OS = platform.system() # noqa (N806), uppercase name is ok in this instance if OS == "Darwin": cli.log.info("Detected {fg_cyan}macOS.") diff --git a/lib/python/qmk/cli/flash.py b/lib/python/qmk/cli/flash.py new file mode 100644 index 000000000..e897174a2 --- /dev/null +++ b/lib/python/qmk/cli/flash.py @@ -0,0 +1,83 @@ +"""Compile and flash QMK Firmware + +You can compile a keymap already in the repo or using a QMK Configurator export. +A bootloader must be specified. +""" +import subprocess +from argparse import FileType + +import qmk.path +from milc import cli +from qmk.commands import compile_configurator_json, create_make_command, parse_configurator_json + + +def print_bootloader_help(): + """Prints the available bootloaders listed in docs.qmk.fm. + """ + cli.log.info('Here are the available bootloaders:') + cli.echo('\tdfu') + cli.echo('\tdfu-ee') + cli.echo('\tdfu-split-left') + cli.echo('\tdfu-split-right') + cli.echo('\tavrdude') + cli.echo('\tBootloadHID') + cli.echo('\tdfu-util') + cli.echo('\tdfu-util-split-left') + cli.echo('\tdfu-util-split-right') + cli.echo('\tst-link-cli') + cli.echo('For more info, visit https://docs.qmk.fm/#/flashing') + + +@cli.argument('-bl', '--bootloader', default='flash', help='The flash command, corresponding to qmk\'s make options of bootloaders.') +@cli.argument('filename', nargs='?', arg_only=True, type=FileType('r'), help='The configurator export JSON to compile.') +@cli.argument('-km', '--keymap', help='The keymap to build a firmware for. Use this if you dont have a configurator file. Ignored when a configurator file is supplied.') +@cli.argument('-kb', '--keyboard', help='The keyboard to build a firmware for. Use this if you dont have a configurator file. Ignored when a configurator file is supplied.') +@cli.argument('-b', '--bootloaders', action='store_true', help='List the available bootloaders.') +@cli.subcommand('QMK Flash.') +def flash(cli): + """Compile and or flash QMK Firmware or keyboard/layout + + If a Configurator JSON export is supplied this command will create a new keymap. Keymap and Keyboard arguments + will be ignored. + + If no file is supplied, keymap and keyboard are expected. + + If bootloader is omitted, the one according to the rules.mk will be used. + + """ + command = [] + if cli.args.bootloaders: + # Provide usage and list bootloaders + cli.echo('usage: qmk flash [-h] [-b] [-kb KEYBOARD] [-km KEYMAP] [-bl BOOTLOADER] [filename]') + print_bootloader_help() + return False + + elif cli.args.keymap and not cli.args.keyboard: + # If only a keymap was given but no keyboard, suggest listing keyboards + cli.echo('usage: qmk flash [-h] [-b] [-kb KEYBOARD] [-km KEYMAP] [-bl BOOTLOADER] [filename]') + cli.log.error('run \'qmk list_keyboards\' to find out the supported keyboards') + return False + + elif cli.args.filename: + # Get keymap path to log info + user_keymap = parse_configurator_json(cli.args.filename) + keymap_path = qmk.path.keymap(user_keymap['keyboard']) + + cli.log.info('Creating {fg_cyan}%s{style_reset_all} keymap in {fg_cyan}%s', user_keymap['keymap'], keymap_path) + + # Convert the JSON into a C file and write it to disk. + command = compile_configurator_json(user_keymap, cli.args.bootloader) + + cli.log.info('Wrote keymap to {fg_cyan}%s/%s/keymap.c', keymap_path, user_keymap['keymap']) + + elif cli.args.keyboard and cli.args.keymap: + # Generate the make command for a specific keyboard/keymap. + command = create_make_command(cli.config.flash.keyboard, cli.config.flash.keymap, cli.args.bootloader) + + else: + cli.echo('usage: qmk flash [-h] [-b] [-kb KEYBOARD] [-km KEYMAP] [-bl BOOTLOADER] [filename]') + cli.log.error('You must supply a configurator export or both `--keyboard` and `--keymap`. You can also specify a bootloader with --bootloader. Use --bootloaders to list the available bootloaders.') + return False + + cli.log.info('Flashing keymap with {fg_cyan}%s\n\n', ' '.join(command)) + subprocess.run(command) diff --git a/lib/python/qmk/cli/json/keymap.py b/lib/python/qmk/cli/json/keymap.py index 207ac278c..a030ab53d 100755 --- a/lib/python/qmk/cli/json/keymap.py +++ b/lib/python/qmk/cli/json/keymap.py @@ -2,7 +2,6 @@ """ import json import os -import sys from milc import cli @@ -10,6 +9,7 @@ import qmk.keymap @cli.argument('-o', '--output', arg_only=True, help='File to write to') +@cli.argument('-q', '--quiet', arg_only=True, action='store_true', help="Quiet mode, only output error messages") @cli.argument('filename', arg_only=True, help='Configurator JSON file') @cli.subcommand('Creates a keymap.c from a QMK Configurator export.') def json_keymap(cli): @@ -48,7 +48,8 @@ def json_keymap(cli): with open(output_file, 'w') as keymap_fd: keymap_fd.write(keymap_c) - cli.log.info('Wrote keymap to %s.', cli.args.output) + if not cli.args.quiet: + cli.log.info('Wrote keymap to %s.', cli.args.output) else: print(keymap_c) diff --git a/lib/python/qmk/cli/kle2json.py b/lib/python/qmk/cli/kle2json.py new file mode 100755 index 000000000..00f63d362 --- /dev/null +++ b/lib/python/qmk/cli/kle2json.py @@ -0,0 +1,75 @@ +"""Convert raw KLE to JSON +""" +import json +import os +from pathlib import Path +from decimal import Decimal +from collections import OrderedDict + +from milc import cli +from kle2xy import KLE2xy + +from qmk.converter import kle2qmk + + +class CustomJSONEncoder(json.JSONEncoder): + def default(self, obj): + try: + if isinstance(obj, Decimal): + if obj % 2 in (Decimal(0), Decimal(1)): + return int(obj) + return float(obj) + except TypeError: + pass + return json.JSONEncoder.default(self, obj) + + +@cli.argument('filename', help='The KLE raw txt to convert') +@cli.argument('-f', '--force', action='store_true', help='Flag to overwrite current info.json') +@cli.subcommand('Convert a KLE layout to a Configurator JSON') +def kle2json(cli): + """Convert a KLE layout to QMK's layout format. + """ # If filename is a path + if cli.args.filename.startswith("/") or cli.args.filename.startswith("./"): + file_path = Path(cli.args.filename) + # Otherwise assume it is a file name + else: + file_path = Path(os.environ['ORIG_CWD'], cli.args.filename) + # Check for valid file_path for more graceful failure + if not file_path.exists(): + return cli.log.error('File {fg_cyan}%s{style_reset_all} was not found.', str(file_path)) + out_path = file_path.parent + raw_code = file_path.open().read() + # Check if info.json exists, allow overwrite with force + if Path(out_path, "info.json").exists() and not cli.args.force: + cli.log.error('File {fg_cyan}%s/info.json{style_reset_all} already exists, use -f or --force to overwrite.', str(out_path)) + return False + try: + # Convert KLE raw to x/y coordinates (using kle2xy package from skullydazed) + kle = KLE2xy(raw_code) + except Exception as e: + cli.log.error('Could not parse KLE raw data: %s', raw_code) + cli.log.exception(e) + # FIXME: This should be better + return cli.log.error('Could not parse KLE raw data.') + keyboard = OrderedDict( + keyboard_name=kle.name, + url='', + maintainer='qmk', + width=kle.columns, + height=kle.rows, + layouts={'LAYOUT': { + 'layout': 'LAYOUT_JSON_HERE' + }}, + ) + # Initialize keyboard with json encoded from ordered dict + keyboard = json.dumps(keyboard, indent=4, separators=(', ', ': '), sort_keys=False, cls=CustomJSONEncoder) + # Initialize layout with kle2qmk from converter module + layout = json.dumps(kle2qmk(kle), separators=(', ', ':'), cls=CustomJSONEncoder) + # Replace layout in keyboard json + keyboard = keyboard.replace('"LAYOUT_JSON_HERE"', layout) + # Write our info.json + file = open(str(out_path) + "/info.json", "w") + file.write(keyboard) + file.close() + cli.log.info('Wrote out {fg_cyan}%s/info.json', str(out_path)) diff --git a/lib/python/qmk/cli/list/keyboards.py b/lib/python/qmk/cli/list/keyboards.py index 53a7af75c..76e7760e8 100644 --- a/lib/python/qmk/cli/list/keyboards.py +++ b/lib/python/qmk/cli/list/keyboards.py @@ -1,26 +1,27 @@ """List the keyboards currently defined within QMK """ import os -import re import glob from milc import cli +BASE_PATH = os.path.join(os.getcwd(), "keyboards") + os.path.sep +KB_WILDCARD = os.path.join(BASE_PATH, "**", "rules.mk") + + +def find_name(path): + """Determine the keyboard name by stripping off the base_path and rules.mk. + """ + return path.replace(BASE_PATH, "").replace(os.path.sep + "rules.mk", "") + + @cli.subcommand("List the keyboards currently defined within QMK") def list_keyboards(cli): """List the keyboards currently defined within QMK """ - - base_path = os.path.join(os.getcwd(), "keyboards") + os.path.sep - kb_path_wildcard = os.path.join(base_path, "**", "rules.mk") - # find everywhere we have rules.mk where keymaps isn't in the path - paths = [path for path in glob.iglob(kb_path_wildcard, recursive=True) if 'keymaps' not in path] - - # strip the keyboard directory path prefix and rules.mk suffix and alphabetize - find_name = lambda path: path.replace(base_path, "").replace(os.path.sep + "rules.mk", "") - names = sorted(map(find_name, paths)) + paths = [path for path in glob.iglob(KB_WILDCARD, recursive=True) if 'keymaps' not in path] - for name in names: - # We echo instead of cli.log.info to allow easier piping of this output - cli.echo(name) + # Extract the keyboard name from the path and print it + for keyboard_name in sorted(map(find_name, paths)): + print(keyboard_name) diff --git a/lib/python/qmk/cli/pytest.py b/lib/python/qmk/cli/pytest.py index 14613e1d9..09611d750 100644 --- a/lib/python/qmk/cli/pytest.py +++ b/lib/python/qmk/cli/pytest.py @@ -2,19 +2,15 @@ QMK script to run unit and integration tests against our python code. """ -import sys +import subprocess + from milc import cli @cli.subcommand('QMK Python Unit Tests') def pytest(cli): - """Use nose2 to run unittests + """Run several linting/testing commands. """ - try: - import nose2 - - except ImportError: - cli.log.error('Could not import nose2! Please install it with {fg_cyan}pip3 install nose2') - return False - - nose2.discover(argv=['nose2', '-v']) + flake8 = subprocess.run(['flake8', 'lib/python', 'bin/qmk']) + nose2 = subprocess.run(['nose2', '-v']) + return flake8.returncode | nose2.returncode diff --git a/lib/python/qmk/commands.py b/lib/python/qmk/commands.py new file mode 100644 index 000000000..6067d49ae --- /dev/null +++ b/lib/python/qmk/commands.py @@ -0,0 +1,54 @@ +"""Functions that build make commands +""" +import json +import qmk.keymap + + +def create_make_command(keyboard, keymap, target=None): + """Create a make compile command + + Args: + keyboard + The path of the keyboard, for example 'plank' + + keymap + The name of the keymap, for example 'algernon' + + target + Usually a bootloader. + + Returns: + A command that can be run to make the specified keyboard and keymap + """ + if target is None: + return ['make', ':'.join((keyboard, keymap))] + return ['make', ':'.join((keyboard, keymap, target))] + + +def parse_configurator_json(configurator_file): + """Open and parse a configurator json export + """ + user_keymap = json.load(configurator_file) + return user_keymap + + +def compile_configurator_json(user_keymap, bootloader=None): + """Convert a configurator export JSON file into a C file + + Args: + configurator_filename + The configurator JSON export file + + bootloader + A bootloader to flash + + Returns: + A command to run to compile and flash the C file. + """ + # Write the keymap C file + qmk.keymap.write(user_keymap['keyboard'], user_keymap['keymap'], user_keymap['layout'], user_keymap['layers']) + + # Return a command that can be run to make the keymap and flash if given + if bootloader is None: + return create_make_command(user_keymap['keyboard'], user_keymap['keymap']) + return create_make_command(user_keymap['keyboard'], user_keymap['keymap'], bootloader) diff --git a/lib/python/qmk/converter.py b/lib/python/qmk/converter.py new file mode 100644 index 000000000..bbd353131 --- /dev/null +++ b/lib/python/qmk/converter.py @@ -0,0 +1,33 @@ +"""Functions to convert to and from QMK formats +""" +from collections import OrderedDict + + +def kle2qmk(kle): + """Convert a KLE layout to QMK's layout format. + """ + layout = [] + + for row in kle: + for key in row: + if key['decal']: + continue + + qmk_key = OrderedDict( + label="", + x=key['column'], + y=key['row'], + ) + + if key['width'] != 1: + qmk_key['w'] = key['width'] + if key['height'] != 1: + qmk_key['h'] = key['height'] + if 'name' in key and key['name']: + qmk_key['label'] = key['name'].split('\n', 1)[0] + else: + del (qmk_key['label']) + + layout.append(qmk_key) + + return layout diff --git a/lib/python/qmk/errors.py b/lib/python/qmk/errors.py index f9bf5b9af..4a8a91556 100644 --- a/lib/python/qmk/errors.py +++ b/lib/python/qmk/errors.py @@ -1,6 +1,5 @@ class NoSuchKeyboardError(Exception): """Raised when we can't find a keyboard/keymap directory. """ - def __init__(self, message): self.message = message diff --git a/lib/python/qmk/keymap.py b/lib/python/qmk/keymap.py index 396b53a6f..f4a2893f3 100644 --- a/lib/python/qmk/keymap.py +++ b/lib/python/qmk/keymap.py @@ -1,12 +1,8 @@ """Functions that help you work with QMK keymaps. """ -import json -import logging import os -from traceback import format_exc import qmk.path -from qmk.errors import NoSuchKeyboardError # The `keymap.c` template to use when a keyboard doesn't have its own DEFAULT_KEYMAP_C = """#include QMK_KEYBOARD_H diff --git a/lib/python/qmk/path.py b/lib/python/qmk/path.py index 2149625cc..cf087265f 100644 --- a/lib/python/qmk/path.py +++ b/lib/python/qmk/path.py @@ -2,7 +2,6 @@ """ import logging import os -from pkgutil import walk_packages from qmk.errors import NoSuchKeyboardError diff --git a/lib/python/qmk/tests/attrdict.py b/lib/python/qmk/tests/attrdict.py index 391c75c4e..a2584b923 100644 --- a/lib/python/qmk/tests/attrdict.py +++ b/lib/python/qmk/tests/attrdict.py @@ -3,7 +3,6 @@ class AttrDict(dict): This should only be used to mock objects for unit testing. Please do not use this outside of qmk.tests. """ - def __init__(self, *args, **kwargs): super(AttrDict, self).__init__(*args, **kwargs) self.__dict__ = self diff --git a/lib/python/qmk/tests/kle.txt b/lib/python/qmk/tests/kle.txt new file mode 100644 index 000000000..862a899ab --- /dev/null +++ b/lib/python/qmk/tests/kle.txt @@ -0,0 +1,5 @@ +["¬\n`","!\n1","\"\n2","£\n3","$\n4","%\n5","^\n6","&\n7","*\n8","(\n9",")\n0","_\n-","+\n=",{w:2},"Backspace"], +[{w:1.5},"Tab","Q","W","E","R","T","Y","U","I","O","P","{\n[","}\n]",{x:0.25,w:1.25,h:2,w2:1.5,h2:1,x2:-0.25},"Enter"], +[{w:1.75},"Caps Lock","A","S","D","F","G","H","J","K","L",":\n;","@\n'","~\n#"], +[{w:1.25},"Shift","|\n\\","Z","X","C","V","B","N","M","<\n,",">\n.","?\n/",{w:2.75},"Shift"], +[{w:1.25},"Ctrl",{w:1.25},"Win",{w:1.25},"Alt",{a:7,w:6.25},"",{a:4,w:1.25},"AltGr",{w:1.25},"Win",{w:1.25},"Menu",{w:1.25},"Ctrl"] diff --git a/lib/python/qmk/tests/test_cli_commands.py b/lib/python/qmk/tests/test_cli_commands.py index 9a9dc4b95..3f75cef3e 100644 --- a/lib/python/qmk/tests/test_cli_commands.py +++ b/lib/python/qmk/tests/test_cli_commands.py @@ -7,19 +7,28 @@ def check_subcommand(command, *args): def test_cformat(): - assert check_subcommand('cformat', 'tmk_core/common/backlight.c').returncode == 0 + assert check_subcommand('cformat', 'tmk_core/common/keyboard.c').returncode == 0 def test_compile(): assert check_subcommand('compile', '-kb', 'handwired/onekey/pytest', '-km', 'default').returncode == 0 +def test_flash(): + assert check_subcommand('flash', '-b').returncode == 1 + assert check_subcommand('flash').returncode == 1 + + def test_config(): result = check_subcommand('config') assert result.returncode == 0 assert 'general.color' in result.stdout +def test_kle2json(): + assert check_subcommand('kle2json', 'kle.txt', '-f').returncode == 0 + + def test_doctor(): result = check_subcommand('doctor') assert result.returncode == 0 diff --git a/lib/python/qmk/tests/test_qmk_errors.py b/lib/python/qmk/tests/test_qmk_errors.py index 1d8690b7e..948e7ef74 100644 --- a/lib/python/qmk/tests/test_qmk_errors.py +++ b/lib/python/qmk/tests/test_qmk_errors.py @@ -1,7 +1,7 @@ from qmk.errors import NoSuchKeyboardError -def test_NoSuchKeyboardError(): +def test_nosuchkeyboarderror(): try: raise NoSuchKeyboardError("test message") except NoSuchKeyboardError as e: diff --git a/lib/python/qmk/tests/test_qmk_path.py b/lib/python/qmk/tests/test_qmk_path.py index 94dbf3a6a..d6961a0f6 100644 --- a/lib/python/qmk/tests/test_qmk_path.py +++ b/lib/python/qmk/tests/test_qmk_path.py @@ -10,4 +10,4 @@ def test_keymap_onekey_pytest(): def test_normpath(): path = qmk.path.normpath('lib/python') - assert path == os.environ['ORIG_CWD'] + '/lib/python' + assert path == os.path.join(os.environ['ORIG_CWD'], 'lib/python') |