diff options
author | skullydazed <skullydazed@users.noreply.github.com> | 2019-09-22 13:25:33 -0700 |
---|---|---|
committer | GitHub <noreply@github.com> | 2019-09-22 13:25:33 -0700 |
commit | d569f0877155efc752994f8a21f5cf001f9d6ae6 (patch) | |
tree | eb58a3e3f916d6d938d8f05742d48919c053a579 /lib/python | |
parent | 2f49cae9bcbdd94431659727ef75cfd30f557da8 (diff) | |
download | qmk_firmware-d569f0877155efc752994f8a21f5cf001f9d6ae6.tar.gz qmk_firmware-d569f0877155efc752994f8a21f5cf001f9d6ae6.zip |
Configuration system for CLI (#6708)
* Rework how bin/qmk handles subcommands
* qmk config wip
* Code to show all configs
* Fully working `qmk config` command
* Mark some CLI arguments so they don't pollute the config file
* Fleshed out config support, nicer subcommand support
* sync with installable cli
* pyformat
* Add a test for subcommand_modules
* Documentation for the `qmk config` command
* split config_token on space so qmk config is more predictable
* Rework how subcommands are imported
* Document `arg_only`
* Document deleting from CLI
* Document how multiple operations work
* Add cli config to the doc index
* Add tests for the cli commands
* Make running the tests more reliable
* Be more selective about building all default keymaps
* Update new-keymap to fit the new subcommand style
* Add documentation about writing CLI scripts
* Document new-keyboard
* Update docs/cli_configuration.md
Co-Authored-By: noroadsleft <18669334+noroadsleft@users.noreply.github.com>
* Update docs/cli_development.md
Co-Authored-By: noroadsleft <18669334+noroadsleft@users.noreply.github.com>
* Update docs/cli_development.md
Co-Authored-By: noroadsleft <18669334+noroadsleft@users.noreply.github.com>
* Update docs/cli_development.md
Co-Authored-By: noroadsleft <18669334+noroadsleft@users.noreply.github.com>
* Address yan's comments.
* Apply suggestions from code review
suggestions from @noahfrederick
Co-Authored-By: Noah Frederick <code@noahfrederick.com>
* Apply suggestions from code review
Co-Authored-By: Noah Frederick <code@noahfrederick.com>
* Remove pip3 from the test runner
Diffstat (limited to 'lib/python')
-rw-r--r-- | lib/python/milc.py | 113 | ||||
-rw-r--r-- | lib/python/qmk/cli/__init__.py | 13 | ||||
-rw-r--r-- | lib/python/qmk/cli/cformat.py | 6 | ||||
-rwxr-xr-x | lib/python/qmk/cli/compile.py | 10 | ||||
-rw-r--r-- | lib/python/qmk/cli/config.py | 96 | ||||
-rwxr-xr-x | lib/python/qmk/cli/doctor.py | 5 | ||||
-rwxr-xr-x | lib/python/qmk/cli/hello.py | 6 | ||||
-rw-r--r-- | lib/python/qmk/cli/json/__init__.py | 5 | ||||
-rwxr-xr-x | lib/python/qmk/cli/json/keymap.py | 20 | ||||
-rw-r--r-- | lib/python/qmk/cli/new/__init__.py | 1 | ||||
-rwxr-xr-x | lib/python/qmk/cli/new/keymap.py | 17 | ||||
-rwxr-xr-x | lib/python/qmk/cli/pyformat.py | 5 | ||||
-rw-r--r-- | lib/python/qmk/cli/pytest.py (renamed from lib/python/qmk/cli/nose2.py) | 8 | ||||
-rw-r--r-- | lib/python/qmk/path.py | 1 | ||||
-rw-r--r-- | lib/python/qmk/tests/test_cli_commands.py | 39 |
15 files changed, 265 insertions, 80 deletions
diff --git a/lib/python/milc.py b/lib/python/milc.py index c62c1b166..1a29bb25c 100644 --- a/lib/python/milc.py +++ b/lib/python/milc.py | |||
@@ -17,6 +17,7 @@ import argparse | |||
17 | import logging | 17 | import logging |
18 | import os | 18 | import os |
19 | import re | 19 | import re |
20 | import shlex | ||
20 | import sys | 21 | import sys |
21 | from decimal import Decimal | 22 | from decimal import Decimal |
22 | from tempfile import NamedTemporaryFile | 23 | from tempfile import NamedTemporaryFile |
@@ -35,6 +36,10 @@ except ImportError: | |||
35 | 36 | ||
36 | import argcomplete | 37 | import argcomplete |
37 | import colorama | 38 | import colorama |
39 | from appdirs import user_config_dir | ||
40 | |||
41 | # Disable logging until we can configure it how the user wants | ||
42 | logging.basicConfig(filename='/dev/null') | ||
38 | 43 | ||
39 | # Log Level Representations | 44 | # Log Level Representations |
40 | EMOJI_LOGLEVELS = { | 45 | EMOJI_LOGLEVELS = { |
@@ -47,6 +52,7 @@ EMOJI_LOGLEVELS = { | |||
47 | } | 52 | } |
48 | EMOJI_LOGLEVELS['FATAL'] = EMOJI_LOGLEVELS['CRITICAL'] | 53 | EMOJI_LOGLEVELS['FATAL'] = EMOJI_LOGLEVELS['CRITICAL'] |
49 | EMOJI_LOGLEVELS['WARN'] = EMOJI_LOGLEVELS['WARNING'] | 54 | EMOJI_LOGLEVELS['WARN'] = EMOJI_LOGLEVELS['WARNING'] |
55 | UNICODE_SUPPORT = sys.stdout.encoding.lower().startswith('utf') | ||
50 | 56 | ||
51 | # ANSI Color setup | 57 | # ANSI Color setup |
52 | # Regex was gratefully borrowed from kfir on stackoverflow: | 58 | # Regex was gratefully borrowed from kfir on stackoverflow: |
@@ -97,11 +103,12 @@ class ANSIFormatter(logging.Formatter): | |||
97 | 103 | ||
98 | 104 | ||
99 | class ANSIEmojiLoglevelFormatter(ANSIFormatter): | 105 | class ANSIEmojiLoglevelFormatter(ANSIFormatter): |
100 | """A log formatter that makes the loglevel an emoji. | 106 | """A log formatter that makes the loglevel an emoji on UTF capable terminals. |
101 | """ | 107 | """ |
102 | 108 | ||
103 | def format(self, record): | 109 | def format(self, record): |
104 | record.levelname = EMOJI_LOGLEVELS[record.levelname].format(**ansi_colors) | 110 | if UNICODE_SUPPORT: |
111 | record.levelname = EMOJI_LOGLEVELS[record.levelname].format(**ansi_colors) | ||
105 | return super(ANSIEmojiLoglevelFormatter, self).format(record) | 112 | return super(ANSIEmojiLoglevelFormatter, self).format(record) |
106 | 113 | ||
107 | 114 | ||
@@ -144,13 +151,15 @@ class Configuration(object): | |||
144 | 151 | ||
145 | def __init__(self, *args, **kwargs): | 152 | def __init__(self, *args, **kwargs): |
146 | self._config = {} | 153 | self._config = {} |
147 | self.default_container = ConfigurationOption | 154 | |
155 | def __getattr__(self, key): | ||
156 | return self.__getitem__(key) | ||
148 | 157 | ||
149 | def __getitem__(self, key): | 158 | def __getitem__(self, key): |
150 | """Returns a config section, creating it if it doesn't exist yet. | 159 | """Returns a config section, creating it if it doesn't exist yet. |
151 | """ | 160 | """ |
152 | if key not in self._config: | 161 | if key not in self._config: |
153 | self.__dict__[key] = self._config[key] = ConfigurationOption() | 162 | self.__dict__[key] = self._config[key] = ConfigurationSection(self) |
154 | 163 | ||
155 | return self._config[key] | 164 | return self._config[key] |
156 | 165 | ||
@@ -161,30 +170,34 @@ class Configuration(object): | |||
161 | def __delitem__(self, key): | 170 | def __delitem__(self, key): |
162 | if key in self.__dict__ and key[0] != '_': | 171 | if key in self.__dict__ and key[0] != '_': |
163 | del self.__dict__[key] | 172 | del self.__dict__[key] |
164 | del self._config[key] | 173 | if key in self._config: |
174 | del self._config[key] | ||
165 | 175 | ||
166 | 176 | ||
167 | class ConfigurationOption(Configuration): | 177 | class ConfigurationSection(Configuration): |
168 | def __init__(self, *args, **kwargs): | 178 | def __init__(self, parent, *args, **kwargs): |
169 | super(ConfigurationOption, self).__init__(*args, **kwargs) | 179 | super(ConfigurationSection, self).__init__(*args, **kwargs) |
170 | self.default_container = dict | 180 | self.parent = parent |
171 | 181 | ||
172 | def __getitem__(self, key): | 182 | def __getitem__(self, key): |
173 | """Returns a config section, creating it if it doesn't exist yet. | 183 | """Returns a config value, pulling from the `user` section as a fallback. |
174 | """ | 184 | """ |
175 | if key not in self._config: | 185 | if key in self._config: |
176 | self.__dict__[key] = self._config[key] = None | 186 | return self._config[key] |
177 | 187 | ||
178 | return self._config[key] | 188 | elif key in self.parent.user: |
189 | return self.parent.user[key] | ||
190 | |||
191 | return None | ||
179 | 192 | ||
180 | 193 | ||
181 | def handle_store_boolean(self, *args, **kwargs): | 194 | def handle_store_boolean(self, *args, **kwargs): |
182 | """Does the add_argument for action='store_boolean'. | 195 | """Does the add_argument for action='store_boolean'. |
183 | """ | 196 | """ |
184 | kwargs['add_dest'] = False | ||
185 | disabled_args = None | 197 | disabled_args = None |
186 | disabled_kwargs = kwargs.copy() | 198 | disabled_kwargs = kwargs.copy() |
187 | disabled_kwargs['action'] = 'store_false' | 199 | disabled_kwargs['action'] = 'store_false' |
200 | disabled_kwargs['dest'] = self.get_argument_name(*args, **kwargs) | ||
188 | disabled_kwargs['help'] = 'Disable ' + kwargs['help'] | 201 | disabled_kwargs['help'] = 'Disable ' + kwargs['help'] |
189 | kwargs['action'] = 'store_true' | 202 | kwargs['action'] = 'store_true' |
190 | kwargs['help'] = 'Enable ' + kwargs['help'] | 203 | kwargs['help'] = 'Enable ' + kwargs['help'] |
@@ -219,11 +232,6 @@ class SubparserWrapper(object): | |||
219 | self.subparser.completer = completer | 232 | self.subparser.completer = completer |
220 | 233 | ||
221 | def add_argument(self, *args, **kwargs): | 234 | def add_argument(self, *args, **kwargs): |
222 | if kwargs.get('add_dest', True): | ||
223 | kwargs['dest'] = self.submodule + '_' + self.cli.get_argument_name(*args, **kwargs) | ||
224 | if 'add_dest' in kwargs: | ||
225 | del kwargs['add_dest'] | ||
226 | |||
227 | if 'action' in kwargs and kwargs['action'] == 'store_boolean': | 235 | if 'action' in kwargs and kwargs['action'] == 'store_boolean': |
228 | return handle_store_boolean(self, *args, **kwargs) | 236 | return handle_store_boolean(self, *args, **kwargs) |
229 | 237 | ||
@@ -254,12 +262,16 @@ class MILC(object): | |||
254 | self._entrypoint = None | 262 | self._entrypoint = None |
255 | self._inside_context_manager = False | 263 | self._inside_context_manager = False |
256 | self.ansi = ansi_colors | 264 | self.ansi = ansi_colors |
265 | self.arg_only = [] | ||
257 | self.config = Configuration() | 266 | self.config = Configuration() |
258 | self.config_file = None | 267 | self.config_file = None |
259 | self.prog_name = sys.argv[0][:-3] if sys.argv[0].endswith('.py') else sys.argv[0] | ||
260 | self.version = os.environ.get('QMK_VERSION', 'unknown') | 268 | self.version = os.environ.get('QMK_VERSION', 'unknown') |
261 | self.release_lock() | 269 | self.release_lock() |
262 | 270 | ||
271 | # Figure out our program name | ||
272 | self.prog_name = sys.argv[0][:-3] if sys.argv[0].endswith('.py') else sys.argv[0] | ||
273 | self.prog_name = self.prog_name.split('/')[-1] | ||
274 | |||
263 | # Initialize all the things | 275 | # Initialize all the things |
264 | self.initialize_argparse() | 276 | self.initialize_argparse() |
265 | self.initialize_logging() | 277 | self.initialize_logging() |
@@ -273,7 +285,7 @@ class MILC(object): | |||
273 | self._description = self._arg_parser.description = self._arg_defaults.description = value | 285 | self._description = self._arg_parser.description = self._arg_defaults.description = value |
274 | 286 | ||
275 | def echo(self, text, *args, **kwargs): | 287 | def echo(self, text, *args, **kwargs): |
276 | """Print colorized text to stdout, as long as stdout is a tty. | 288 | """Print colorized text to stdout. |
277 | 289 | ||
278 | ANSI color strings (such as {fg-blue}) will be converted into ANSI | 290 | ANSI color strings (such as {fg-blue}) will be converted into ANSI |
279 | escape sequences, and the ANSI reset sequence will be added to all | 291 | escape sequences, and the ANSI reset sequence will be added to all |
@@ -284,11 +296,10 @@ class MILC(object): | |||
284 | if args and kwargs: | 296 | if args and kwargs: |
285 | raise RuntimeError('You can only specify *args or **kwargs, not both!') | 297 | raise RuntimeError('You can only specify *args or **kwargs, not both!') |
286 | 298 | ||
287 | if sys.stdout.isatty(): | 299 | args = args or kwargs |
288 | args = args or kwargs | 300 | text = format_ansi(text) |
289 | text = format_ansi(text) | ||
290 | 301 | ||
291 | print(text % args) | 302 | print(text % args) |
292 | 303 | ||
293 | def initialize_argparse(self): | 304 | def initialize_argparse(self): |
294 | """Prepare to process arguments from sys.argv. | 305 | """Prepare to process arguments from sys.argv. |
@@ -313,21 +324,21 @@ class MILC(object): | |||
313 | self.release_lock() | 324 | self.release_lock() |
314 | 325 | ||
315 | def completer(self, completer): | 326 | def completer(self, completer): |
316 | """Add an arpcomplete completer to this subcommand. | 327 | """Add an argcomplete completer to this subcommand. |
317 | """ | 328 | """ |
318 | self._arg_parser.completer = completer | 329 | self._arg_parser.completer = completer |
319 | 330 | ||
320 | def add_argument(self, *args, **kwargs): | 331 | def add_argument(self, *args, **kwargs): |
321 | """Wrapper to add arguments to both the main and the shadow argparser. | 332 | """Wrapper to add arguments to both the main and the shadow argparser. |
322 | """ | 333 | """ |
334 | if 'action' in kwargs and kwargs['action'] == 'store_boolean': | ||
335 | return handle_store_boolean(self, *args, **kwargs) | ||
336 | |||
323 | if kwargs.get('add_dest', True) and args[0][0] == '-': | 337 | if kwargs.get('add_dest', True) and args[0][0] == '-': |
324 | kwargs['dest'] = 'general_' + self.get_argument_name(*args, **kwargs) | 338 | kwargs['dest'] = 'general_' + self.get_argument_name(*args, **kwargs) |
325 | if 'add_dest' in kwargs: | 339 | if 'add_dest' in kwargs: |
326 | del kwargs['add_dest'] | 340 | del kwargs['add_dest'] |
327 | 341 | ||
328 | if 'action' in kwargs and kwargs['action'] == 'store_boolean': | ||
329 | return handle_store_boolean(self, *args, **kwargs) | ||
330 | |||
331 | self.acquire_lock() | 342 | self.acquire_lock() |
332 | self._arg_parser.add_argument(*args, **kwargs) | 343 | self._arg_parser.add_argument(*args, **kwargs) |
333 | 344 | ||
@@ -396,7 +407,7 @@ class MILC(object): | |||
396 | if self.args and self.args.general_config_file: | 407 | if self.args and self.args.general_config_file: |
397 | return self.args.general_config_file | 408 | return self.args.general_config_file |
398 | 409 | ||
399 | return os.path.abspath(os.path.expanduser('~/.%s.ini' % self.prog_name)) | 410 | return os.path.join(user_config_dir(appname='qmk', appauthor='QMK'), '%s.ini' % self.prog_name) |
400 | 411 | ||
401 | def get_argument_name(self, *args, **kwargs): | 412 | def get_argument_name(self, *args, **kwargs): |
402 | """Takes argparse arguments and returns the dest name. | 413 | """Takes argparse arguments and returns the dest name. |
@@ -413,6 +424,11 @@ class MILC(object): | |||
413 | raise RuntimeError('You must run this before the with statement!') | 424 | raise RuntimeError('You must run this before the with statement!') |
414 | 425 | ||
415 | def argument_function(handler): | 426 | def argument_function(handler): |
427 | if 'arg_only' in kwargs and kwargs['arg_only']: | ||
428 | arg_name = self.get_argument_name(*args, **kwargs) | ||
429 | self.arg_only.append(arg_name) | ||
430 | del kwargs['arg_only'] | ||
431 | |||
416 | if handler is self._entrypoint: | 432 | if handler is self._entrypoint: |
417 | self.add_argument(*args, **kwargs) | 433 | self.add_argument(*args, **kwargs) |
418 | 434 | ||
@@ -485,15 +501,20 @@ class MILC(object): | |||
485 | if argument in ('subparsers', 'entrypoint'): | 501 | if argument in ('subparsers', 'entrypoint'): |
486 | continue | 502 | continue |
487 | 503 | ||
488 | if '_' not in argument: | 504 | if '_' in argument: |
489 | continue | 505 | section, option = argument.split('_', 1) |
490 | |||
491 | section, option = argument.split('_', 1) | ||
492 | if hasattr(self.args_passed, argument): | ||
493 | self.config[section][option] = getattr(self.args, argument) | ||
494 | else: | 506 | else: |
495 | if option not in self.config[section]: | 507 | section = self._entrypoint.__name__ |
496 | self.config[section][option] = getattr(self.args, argument) | 508 | option = argument |
509 | |||
510 | if option not in self.arg_only: | ||
511 | if hasattr(self.args_passed, argument): | ||
512 | arg_value = getattr(self.args, argument) | ||
513 | if arg_value: | ||
514 | self.config[section][option] = arg_value | ||
515 | else: | ||
516 | if option not in self.config[section]: | ||
517 | self.config[section][option] = getattr(self.args, argument) | ||
497 | 518 | ||
498 | self.release_lock() | 519 | self.release_lock() |
499 | 520 | ||
@@ -509,6 +530,8 @@ class MILC(object): | |||
509 | self.acquire_lock() | 530 | self.acquire_lock() |
510 | 531 | ||
511 | config = RawConfigParser() | 532 | config = RawConfigParser() |
533 | config_dir = os.path.dirname(self.config_file) | ||
534 | |||
512 | for section_name, section in self.config._config.items(): | 535 | for section_name, section in self.config._config.items(): |
513 | config.add_section(section_name) | 536 | config.add_section(section_name) |
514 | for option_name, value in section.items(): | 537 | for option_name, value in section.items(): |
@@ -517,7 +540,10 @@ class MILC(object): | |||
517 | continue | 540 | continue |
518 | config.set(section_name, option_name, str(value)) | 541 | config.set(section_name, option_name, str(value)) |
519 | 542 | ||
520 | with NamedTemporaryFile(mode='w', dir=os.path.dirname(self.config_file), delete=False) as tmpfile: | 543 | if not os.path.exists(config_dir): |
544 | os.makedirs(config_dir) | ||
545 | |||
546 | with NamedTemporaryFile(mode='w', dir=config_dir, delete=False) as tmpfile: | ||
521 | config.write(tmpfile) | 547 | config.write(tmpfile) |
522 | 548 | ||
523 | # Move the new config file into place atomically | 549 | # Move the new config file into place atomically |
@@ -527,6 +553,7 @@ class MILC(object): | |||
527 | self.log.warning('Config file saving failed, not replacing %s with %s.', self.config_file, tmpfile.name) | 553 | self.log.warning('Config file saving failed, not replacing %s with %s.', self.config_file, tmpfile.name) |
528 | 554 | ||
529 | self.release_lock() | 555 | self.release_lock() |
556 | cli.log.info('Wrote configuration to %s', shlex.quote(self.config_file)) | ||
530 | 557 | ||
531 | def __call__(self): | 558 | def __call__(self): |
532 | """Execute the entrypoint function. | 559 | """Execute the entrypoint function. |
@@ -602,8 +629,8 @@ class MILC(object): | |||
602 | """Called by __enter__() to setup the logging configuration. | 629 | """Called by __enter__() to setup the logging configuration. |
603 | """ | 630 | """ |
604 | if len(logging.root.handlers) != 0: | 631 | if len(logging.root.handlers) != 0: |
605 | # 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. | 632 | # MILC is the only thing that should have root log handlers |
606 | raise RuntimeError('MILC should be the only system installing root log handlers!') | 633 | logging.root.handlers = [] |
607 | 634 | ||
608 | self.acquire_lock() | 635 | self.acquire_lock() |
609 | 636 | ||
@@ -648,8 +675,9 @@ class MILC(object): | |||
648 | self.read_config() | 675 | self.read_config() |
649 | self.setup_logging() | 676 | self.setup_logging() |
650 | 677 | ||
651 | if self.config.general.save_config: | 678 | if 'save_config' in self.config.general and self.config.general.save_config: |
652 | self.save_config() | 679 | self.save_config() |
680 | exit(0) | ||
653 | 681 | ||
654 | return self | 682 | return self |
655 | 683 | ||
@@ -712,4 +740,3 @@ if __name__ == '__main__': | |||
712 | cli.goodbye.add_argument('-n', '--name', help='Name to bid farewell to', default='World') | 740 | cli.goodbye.add_argument('-n', '--name', help='Name to bid farewell to', default='World') |
713 | 741 | ||
714 | cli() # Automatically picks between main(), hello() and goodbye() | 742 | cli() # Automatically picks between main(), hello() and goodbye() |
715 | print(sorted(ansi_colors.keys())) | ||
diff --git a/lib/python/qmk/cli/__init__.py b/lib/python/qmk/cli/__init__.py index e69de29bb..fb4e0ecb4 100644 --- a/lib/python/qmk/cli/__init__.py +++ b/lib/python/qmk/cli/__init__.py | |||
@@ -0,0 +1,13 @@ | |||
1 | """QMK CLI Subcommands | ||
2 | |||
3 | We list each subcommand here explicitly because all the reliable ways of searching for modules are slow and delay startup. | ||
4 | """ | ||
5 | from . import cformat | ||
6 | from . import compile | ||
7 | from . import config | ||
8 | from . import doctor | ||
9 | from . import hello | ||
10 | from . import json | ||
11 | from . import new | ||
12 | from . import pyformat | ||
13 | from . import pytest | ||
diff --git a/lib/python/qmk/cli/cformat.py b/lib/python/qmk/cli/cformat.py index 91e650368..d2382bdbd 100644 --- a/lib/python/qmk/cli/cformat.py +++ b/lib/python/qmk/cli/cformat.py | |||
@@ -6,9 +6,9 @@ import subprocess | |||
6 | from milc import cli | 6 | from milc import cli |
7 | 7 | ||
8 | 8 | ||
9 | @cli.argument('files', nargs='*', help='Filename(s) to format.') | 9 | @cli.argument('files', nargs='*', arg_only=True, help='Filename(s) to format.') |
10 | @cli.entrypoint("Format C code according to QMK's style.") | 10 | @cli.subcommand("Format C code according to QMK's style.") |
11 | def main(cli): | 11 | def cformat(cli): |
12 | """Format C code according to QMK's style. | 12 | """Format C code according to QMK's style. |
13 | """ | 13 | """ |
14 | clang_format = ['clang-format', '-i'] | 14 | clang_format = ['clang-format', '-i'] |
diff --git a/lib/python/qmk/cli/compile.py b/lib/python/qmk/cli/compile.py index 7e14ad8fb..6646891b3 100755 --- a/lib/python/qmk/cli/compile.py +++ b/lib/python/qmk/cli/compile.py | |||
@@ -14,11 +14,11 @@ import qmk.keymap | |||
14 | import qmk.path | 14 | import qmk.path |
15 | 15 | ||
16 | 16 | ||
17 | @cli.argument('filename', nargs='?', type=FileType('r'), help='The configurator export to compile') | 17 | @cli.argument('filename', nargs='?', arg_only=True, type=FileType('r'), help='The configurator export to compile') |
18 | @cli.argument('-kb', '--keyboard', help='The keyboard to build a firmware for. Ignored when a configurator export is supplied.') | 18 | @cli.argument('-kb', '--keyboard', help='The keyboard to build a firmware for. Ignored when a configurator export is supplied.') |
19 | @cli.argument('-km', '--keymap', help='The keymap to build a firmware for. Ignored when a configurator export is supplied.') | 19 | @cli.argument('-km', '--keymap', help='The keymap to build a firmware for. Ignored when a configurator export is supplied.') |
20 | @cli.entrypoint('Compile a QMK Firmware.') | 20 | @cli.subcommand('Compile a QMK Firmware.') |
21 | def main(cli): | 21 | def compile(cli): |
22 | """Compile a QMK Firmware. | 22 | """Compile a QMK Firmware. |
23 | 23 | ||
24 | If a Configurator export is supplied this command will create a new keymap, overwriting an existing keymap if one exists. | 24 | If a Configurator export is supplied this command will create a new keymap, overwriting an existing keymap if one exists. |
@@ -41,9 +41,9 @@ def main(cli): | |||
41 | # Compile the keymap | 41 | # Compile the keymap |
42 | command = ['make', ':'.join((user_keymap['keyboard'], user_keymap['keymap']))] | 42 | command = ['make', ':'.join((user_keymap['keyboard'], user_keymap['keymap']))] |
43 | 43 | ||
44 | elif cli.config.general.keyboard and cli.config.general.keymap: | 44 | elif cli.config.compile.keyboard and cli.config.compile.keymap: |
45 | # Generate the make command for a specific keyboard/keymap. | 45 | # Generate the make command for a specific keyboard/keymap. |
46 | command = ['make', ':'.join((cli.config.general.keyboard, cli.config.general.keymap))] | 46 | command = ['make', ':'.join((cli.config.compile.keyboard, cli.config.compile.keymap))] |
47 | 47 | ||
48 | else: | 48 | else: |
49 | cli.log.error('You must supply a configurator export or both `--keyboard` and `--keymap`.') | 49 | 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 new file mode 100644 index 000000000..d6c774e65 --- /dev/null +++ b/lib/python/qmk/cli/config.py | |||
@@ -0,0 +1,96 @@ | |||
1 | """Read and write configuration settings | ||
2 | """ | ||
3 | import os | ||
4 | import subprocess | ||
5 | |||
6 | from milc import cli | ||
7 | |||
8 | |||
9 | def print_config(section, key): | ||
10 | """Print a single config setting to stdout. | ||
11 | """ | ||
12 | cli.echo('%s.%s{fg_cyan}={fg_reset}%s', section, key, cli.config[section][key]) | ||
13 | |||
14 | |||
15 | @cli.argument('-ro', '--read-only', action='store_true', help='Operate in read-only mode.') | ||
16 | @cli.argument('configs', nargs='*', arg_only=True, help='Configuration options to read or write.') | ||
17 | @cli.subcommand("Read and write configuration settings.") | ||
18 | def config(cli): | ||
19 | """Read and write config settings. | ||
20 | |||
21 | This script iterates over the config_tokens supplied as argument. Each config_token has the following form: | ||
22 | |||
23 | section[.key][=value] | ||
24 | |||
25 | If only a section (EG 'compile') is supplied all keys for that section will be displayed. | ||
26 | |||
27 | If section.key is supplied the value for that single key will be displayed. | ||
28 | |||
29 | If section.key=value is supplied the value for that single key will be set. | ||
30 | |||
31 | If section.key=None is supplied the key will be deleted. | ||
32 | |||
33 | No validation is done to ensure that the supplied section.key is actually used by qmk scripts. | ||
34 | """ | ||
35 | if not cli.args.configs: | ||
36 | # Walk the config tree | ||
37 | for section in cli.config: | ||
38 | for key in cli.config[section]: | ||
39 | print_config(section, key) | ||
40 | |||
41 | return True | ||
42 | |||
43 | # Process config_tokens | ||
44 | save_config = False | ||
45 | |||
46 | for argument in cli.args.configs: | ||
47 | # Split on space in case they quoted multiple config tokens | ||
48 | for config_token in argument.split(' '): | ||
49 | # Extract the section, config_key, and value to write from the supplied config_token. | ||
50 | if '=' in config_token: | ||
51 | key, value = config_token.split('=') | ||
52 | else: | ||
53 | key = config_token | ||
54 | value = None | ||
55 | |||
56 | if '.' in key: | ||
57 | section, config_key = key.split('.', 1) | ||
58 | else: | ||
59 | section = key | ||
60 | config_key = None | ||
61 | |||
62 | # Validation | ||
63 | if config_key and '.' in config_key: | ||
64 | cli.log.error('Config keys may not have more than one period! "%s" is not valid.', key) | ||
65 | return False | ||
66 | |||
67 | # Do what the user wants | ||
68 | if section and config_key and value: | ||
69 | # Write a config key | ||
70 | log_string = '%s.%s{fg_cyan}:{fg_reset} %s {fg_cyan}->{fg_reset} %s' | ||
71 | if cli.args.read_only: | ||
72 | log_string += ' {fg_red}(change not written)' | ||
73 | |||
74 | cli.echo(log_string, section, config_key, cli.config[section][config_key], value) | ||
75 | |||
76 | if not cli.args.read_only: | ||
77 | if value == 'None': | ||
78 | del cli.config[section][config_key] | ||
79 | else: | ||
80 | cli.config[section][config_key] = value | ||
81 | save_config = True | ||
82 | |||
83 | elif section and config_key: | ||
84 | # Display a single key | ||
85 | print_config(section, config_key) | ||
86 | |||
87 | elif section: | ||
88 | # Display an entire section | ||
89 | for key in cli.config[section]: | ||
90 | print_config(section, key) | ||
91 | |||
92 | # Ending actions | ||
93 | if save_config: | ||
94 | cli.save_config() | ||
95 | |||
96 | return True | ||
diff --git a/lib/python/qmk/cli/doctor.py b/lib/python/qmk/cli/doctor.py index 5a713b20f..3474422a8 100755 --- a/lib/python/qmk/cli/doctor.py +++ b/lib/python/qmk/cli/doctor.py | |||
@@ -11,8 +11,8 @@ from glob import glob | |||
11 | from milc import cli | 11 | from milc import cli |
12 | 12 | ||
13 | 13 | ||
14 | @cli.entrypoint('Basic QMK environment checks') | 14 | @cli.subcommand('Basic QMK environment checks') |
15 | def main(cli): | 15 | def doctor(cli): |
16 | """Basic QMK environment checks. | 16 | """Basic QMK environment checks. |
17 | 17 | ||
18 | This is currently very simple, it just checks that all the expected binaries are on your system. | 18 | This is currently very simple, it just checks that all the expected binaries are on your system. |
@@ -36,6 +36,7 @@ def main(cli): | |||
36 | else: | 36 | else: |
37 | try: | 37 | try: |
38 | subprocess.run([binary, '--version'], stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=5, check=True) | 38 | subprocess.run([binary, '--version'], stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=5, check=True) |
39 | cli.log.info('Found {fg_cyan}%s', binary) | ||
39 | except subprocess.CalledProcessError: | 40 | except subprocess.CalledProcessError: |
40 | cli.log.error("{fg_red}Can't run `%s --version`", binary) | 41 | cli.log.error("{fg_red}Can't run `%s --version`", binary) |
41 | ok = False | 42 | ok = False |
diff --git a/lib/python/qmk/cli/hello.py b/lib/python/qmk/cli/hello.py index bc0cb6de1..bee28c301 100755 --- a/lib/python/qmk/cli/hello.py +++ b/lib/python/qmk/cli/hello.py | |||
@@ -6,8 +6,8 @@ from milc import cli | |||
6 | 6 | ||
7 | 7 | ||
8 | @cli.argument('-n', '--name', default='World', help='Name to greet.') | 8 | @cli.argument('-n', '--name', default='World', help='Name to greet.') |
9 | @cli.entrypoint('QMK Hello World.') | 9 | @cli.subcommand('QMK Hello World.') |
10 | def main(cli): | 10 | def hello(cli): |
11 | """Log a friendly greeting. | 11 | """Log a friendly greeting. |
12 | """ | 12 | """ |
13 | cli.log.info('Hello, %s!', cli.config.general.name) | 13 | cli.log.info('Hello, %s!', cli.config.hello.name) |
diff --git a/lib/python/qmk/cli/json/__init__.py b/lib/python/qmk/cli/json/__init__.py index e69de29bb..f4ebfc45b 100644 --- a/lib/python/qmk/cli/json/__init__.py +++ b/lib/python/qmk/cli/json/__init__.py | |||
@@ -0,0 +1,5 @@ | |||
1 | """QMK CLI JSON Subcommands | ||
2 | |||
3 | We list each subcommand here explicitly because all the reliable ways of searching for modules are slow and delay startup. | ||
4 | """ | ||
5 | from . import keymap | ||
diff --git a/lib/python/qmk/cli/json/keymap.py b/lib/python/qmk/cli/json/keymap.py index e2d0b5809..a65acd619 100755 --- a/lib/python/qmk/cli/json/keymap.py +++ b/lib/python/qmk/cli/json/keymap.py | |||
@@ -9,10 +9,10 @@ from milc import cli | |||
9 | import qmk.keymap | 9 | import qmk.keymap |
10 | 10 | ||
11 | 11 | ||
12 | @cli.argument('-o', '--output', help='File to write to') | 12 | @cli.argument('-o', '--output', arg_only=True, help='File to write to') |
13 | @cli.argument('filename', help='Configurator JSON file') | 13 | @cli.argument('filename', arg_only=True, help='Configurator JSON file') |
14 | @cli.entrypoint('Create a keymap.c from a QMK Configurator export.') | 14 | @cli.subcommand('Create a keymap.c from a QMK Configurator export.') |
15 | def main(cli): | 15 | def json_keymap(cli): |
16 | """Generate a keymap.c from a configurator export. | 16 | """Generate a keymap.c from a configurator export. |
17 | 17 | ||
18 | This command uses the `qmk.keymap` module to generate a keymap.c from a configurator export. The generated keymap is written to stdout, or to a file if -o is provided. | 18 | This command uses the `qmk.keymap` module to generate a keymap.c from a configurator export. The generated keymap is written to stdout, or to a file if -o is provided. |
@@ -28,8 +28,8 @@ def main(cli): | |||
28 | exit(1) | 28 | exit(1) |
29 | 29 | ||
30 | # Environment processing | 30 | # Environment processing |
31 | if cli.config.general.output == ('-'): | 31 | if cli.args.output == ('-'): |
32 | cli.config.general.output = None | 32 | cli.args.output = None |
33 | 33 | ||
34 | # Parse the configurator json | 34 | # Parse the configurator json |
35 | with open(qmk.path.normpath(cli.args.filename), 'r') as fd: | 35 | with open(qmk.path.normpath(cli.args.filename), 'r') as fd: |
@@ -38,17 +38,17 @@ def main(cli): | |||
38 | # Generate the keymap | 38 | # Generate the keymap |
39 | keymap_c = qmk.keymap.generate(user_keymap['keyboard'], user_keymap['layout'], user_keymap['layers']) | 39 | keymap_c = qmk.keymap.generate(user_keymap['keyboard'], user_keymap['layout'], user_keymap['layers']) |
40 | 40 | ||
41 | if cli.config.general.output: | 41 | if cli.args.output: |
42 | output_dir = os.path.dirname(cli.config.general.output) | 42 | output_dir = os.path.dirname(cli.args.output) |
43 | 43 | ||
44 | if not os.path.exists(output_dir): | 44 | if not os.path.exists(output_dir): |
45 | os.makedirs(output_dir) | 45 | os.makedirs(output_dir) |
46 | 46 | ||
47 | output_file = qmk.path.normpath(cli.config.general.output) | 47 | output_file = qmk.path.normpath(cli.args.output) |
48 | with open(output_file, 'w') as keymap_fd: | 48 | with open(output_file, 'w') as keymap_fd: |
49 | keymap_fd.write(keymap_c) | 49 | keymap_fd.write(keymap_c) |
50 | 50 | ||
51 | cli.log.info('Wrote keymap to %s.', cli.config.general.output) | 51 | cli.log.info('Wrote keymap to %s.', cli.args.output) |
52 | 52 | ||
53 | else: | 53 | else: |
54 | print(keymap_c) | 54 | print(keymap_c) |
diff --git a/lib/python/qmk/cli/new/__init__.py b/lib/python/qmk/cli/new/__init__.py index e69de29bb..c6a26939b 100644 --- a/lib/python/qmk/cli/new/__init__.py +++ b/lib/python/qmk/cli/new/__init__.py | |||
@@ -0,0 +1 @@ | |||
from . import keymap | |||
diff --git a/lib/python/qmk/cli/new/keymap.py b/lib/python/qmk/cli/new/keymap.py index b378e5ab4..5efb81c93 100755 --- a/lib/python/qmk/cli/new/keymap.py +++ b/lib/python/qmk/cli/new/keymap.py | |||
@@ -6,15 +6,15 @@ import shutil | |||
6 | from milc import cli | 6 | from milc import cli |
7 | 7 | ||
8 | 8 | ||
9 | @cli.argument('-k', '--keyboard', help='Specify keyboard name. Example: 1upkeyboards/1up60hse') | 9 | @cli.argument('-kb', '--keyboard', help='Specify keyboard name. Example: 1upkeyboards/1up60hse') |
10 | @cli.argument('-u', '--username', help='Specify any name for the new keymap directory') | 10 | @cli.argument('-km', '--keymap', help='Specify the name for the new keymap directory') |
11 | @cli.entrypoint('Creates a new keymap for the keyboard of your choosing') | 11 | @cli.subcommand('Creates a new keymap for the keyboard of your choosing') |
12 | def main(cli): | 12 | def new_keymap(cli): |
13 | """Creates a new keymap for the keyboard of your choosing. | 13 | """Creates a new keymap for the keyboard of your choosing. |
14 | """ | 14 | """ |
15 | # ask for user input if keyboard or username was not provided in the command line | 15 | # ask for user input if keyboard or username was not provided in the command line |
16 | keyboard = cli.config.general.keyboard if cli.config.general.keyboard else input("Keyboard Name: ") | 16 | keyboard = cli.config.new_keymap.keyboard if cli.config.new_keymap.keyboard else input("Keyboard Name: ") |
17 | username = cli.config.general.username if cli.config.general.username else input("Username: ") | 17 | keymap = cli.config.new_keymap.keymap if cli.config.new_keymap.keymap else input("Keymap Name: ") |
18 | 18 | ||
19 | # generate keymap paths | 19 | # generate keymap paths |
20 | kb_path = os.path.join(os.getcwd(), "keyboards", keyboard) | 20 | kb_path = os.path.join(os.getcwd(), "keyboards", keyboard) |
@@ -36,6 +36,5 @@ def main(cli): | |||
36 | shutil.copytree(keymap_path_default, keymap_path, symlinks=True) | 36 | shutil.copytree(keymap_path_default, keymap_path, symlinks=True) |
37 | 37 | ||
38 | # end message to user | 38 | # end message to user |
39 | cli.log.info("%s keymap directory created in: %s\n" + | 39 | cli.log.info("%s keymap directory created in: %s", username, keymap_path) |
40 | "Compile a firmware file with your new keymap by typing: \n" + | 40 | cli.log.info("Compile a firmware with your new keymap by typing: \n" + "qmk compile -kb %s -km %s", keyboard, username) |
41 | "qmk compile -kb %s -km %s", username, keymap_path, keyboard, username) | ||
diff --git a/lib/python/qmk/cli/pyformat.py b/lib/python/qmk/cli/pyformat.py index b1f8c02b2..a53ba40c0 100755 --- a/lib/python/qmk/cli/pyformat.py +++ b/lib/python/qmk/cli/pyformat.py | |||
@@ -5,12 +5,13 @@ from milc import cli | |||
5 | import subprocess | 5 | import subprocess |
6 | 6 | ||
7 | 7 | ||
8 | @cli.entrypoint("Format python code according to QMK's style.") | 8 | @cli.subcommand("Format python code according to QMK's style.") |
9 | def main(cli): | 9 | def pyformat(cli): |
10 | """Format python code according to QMK's style. | 10 | """Format python code according to QMK's style. |
11 | """ | 11 | """ |
12 | try: | 12 | try: |
13 | subprocess.run(['yapf', '-vv', '-ri', 'bin/qmk', 'lib/python'], check=True) | 13 | subprocess.run(['yapf', '-vv', '-ri', 'bin/qmk', 'lib/python'], check=True) |
14 | cli.log.info('Successfully formatted the python code in `bin/qmk` and `lib/python`.') | 14 | cli.log.info('Successfully formatted the python code in `bin/qmk` and `lib/python`.') |
15 | |||
15 | except subprocess.CalledProcessError: | 16 | except subprocess.CalledProcessError: |
16 | cli.log.error('Error formatting python code!') | 17 | cli.log.error('Error formatting python code!') |
diff --git a/lib/python/qmk/cli/nose2.py b/lib/python/qmk/cli/pytest.py index c6c9c67b3..14613e1d9 100644 --- a/lib/python/qmk/cli/nose2.py +++ b/lib/python/qmk/cli/pytest.py | |||
@@ -2,17 +2,19 @@ | |||
2 | 2 | ||
3 | QMK script to run unit and integration tests against our python code. | 3 | QMK script to run unit and integration tests against our python code. |
4 | """ | 4 | """ |
5 | import sys | ||
5 | from milc import cli | 6 | from milc import cli |
6 | 7 | ||
7 | 8 | ||
8 | @cli.entrypoint('QMK Python Unit Tests') | 9 | @cli.subcommand('QMK Python Unit Tests') |
9 | def main(cli): | 10 | def pytest(cli): |
10 | """Use nose2 to run unittests | 11 | """Use nose2 to run unittests |
11 | """ | 12 | """ |
12 | try: | 13 | try: |
13 | import nose2 | 14 | import nose2 |
15 | |||
14 | except ImportError: | 16 | except ImportError: |
15 | cli.log.error('Could not import nose2! Please install it with {fg_cyan}pip3 install nose2') | 17 | cli.log.error('Could not import nose2! Please install it with {fg_cyan}pip3 install nose2') |
16 | return False | 18 | return False |
17 | 19 | ||
18 | nose2.discover() | 20 | nose2.discover(argv=['nose2', '-v']) |
diff --git a/lib/python/qmk/path.py b/lib/python/qmk/path.py index cf087265f..2149625cc 100644 --- a/lib/python/qmk/path.py +++ b/lib/python/qmk/path.py | |||
@@ -2,6 +2,7 @@ | |||
2 | """ | 2 | """ |
3 | import logging | 3 | import logging |
4 | import os | 4 | import os |
5 | from pkgutil import walk_packages | ||
5 | 6 | ||
6 | from qmk.errors import NoSuchKeyboardError | 7 | from qmk.errors import NoSuchKeyboardError |
7 | 8 | ||
diff --git a/lib/python/qmk/tests/test_cli_commands.py b/lib/python/qmk/tests/test_cli_commands.py new file mode 100644 index 000000000..2fc6e0f72 --- /dev/null +++ b/lib/python/qmk/tests/test_cli_commands.py | |||
@@ -0,0 +1,39 @@ | |||
1 | import subprocess | ||
2 | |||
3 | |||
4 | def check_subcommand(command, *args): | ||
5 | cmd = ['bin/qmk', command] + list(args) | ||
6 | return subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, universal_newlines=True) | ||
7 | |||
8 | |||
9 | def test_cformat(): | ||
10 | assert check_subcommand('cformat', 'tmk_core/common/backlight.c').returncode == 0 | ||
11 | |||
12 | |||
13 | def test_compile(): | ||
14 | assert check_subcommand('compile', '-kb', 'handwired/onekey/pytest', '-km', 'default').returncode == 0 | ||
15 | |||
16 | |||
17 | def test_config(): | ||
18 | result = check_subcommand('config') | ||
19 | assert result.returncode == 0 | ||
20 | assert 'general.color' in result.stdout | ||
21 | |||
22 | |||
23 | def test_doctor(): | ||
24 | result = check_subcommand('doctor') | ||
25 | assert result.returncode == 0 | ||
26 | assert 'QMK Doctor is checking your environment.' in result.stderr | ||
27 | assert 'QMK is ready to go' in result.stderr | ||
28 | |||
29 | |||
30 | def test_hello(): | ||
31 | result = check_subcommand('hello') | ||
32 | assert result.returncode == 0 | ||
33 | assert 'Hello,' in result.stderr | ||
34 | |||
35 | |||
36 | def test_pyformat(): | ||
37 | result = check_subcommand('pyformat') | ||
38 | assert result.returncode == 0 | ||
39 | assert 'Successfully formatted the python code' in result.stderr | ||