aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorskullydazed <skullydazed@users.noreply.github.com>2019-09-22 13:25:33 -0700
committerGitHub <noreply@github.com>2019-09-22 13:25:33 -0700
commitd569f0877155efc752994f8a21f5cf001f9d6ae6 (patch)
treeeb58a3e3f916d6d938d8f05742d48919c053a579
parent2f49cae9bcbdd94431659727ef75cfd30f557da8 (diff)
downloadqmk_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
-rwxr-xr-xbin/qmk92
l---------bin/qmk-compile-json1
l---------bin/qmk-doctor1
l---------bin/qmk-hello1
l---------bin/qmk-json-keymap1
-rw-r--r--build_json.mk2
-rw-r--r--docs/_summary.md3
-rw-r--r--docs/cli.md114
-rw-r--r--docs/cli_configuration.md121
-rw-r--r--docs/cli_development.md175
-rw-r--r--docs/python_development.md45
-rw-r--r--docs/redirects.json4
-rw-r--r--lib/python/milc.py113
-rw-r--r--lib/python/qmk/cli/__init__.py13
-rw-r--r--lib/python/qmk/cli/cformat.py6
-rwxr-xr-xlib/python/qmk/cli/compile.py10
-rw-r--r--lib/python/qmk/cli/config.py96
-rwxr-xr-xlib/python/qmk/cli/doctor.py5
-rwxr-xr-xlib/python/qmk/cli/hello.py6
-rw-r--r--lib/python/qmk/cli/json/__init__.py5
-rwxr-xr-xlib/python/qmk/cli/json/keymap.py20
-rw-r--r--lib/python/qmk/cli/new/__init__.py1
-rwxr-xr-xlib/python/qmk/cli/new/keymap.py17
-rwxr-xr-xlib/python/qmk/cli/pyformat.py5
-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.py1
-rw-r--r--lib/python/qmk/tests/test_cli_commands.py39
-rw-r--r--requirements.txt2
-rwxr-xr-xutil/travis_build.sh7
29 files changed, 715 insertions, 199 deletions
diff --git a/bin/qmk b/bin/qmk
index 1aa16e17d..876473fa4 100755
--- a/bin/qmk
+++ b/bin/qmk
@@ -4,10 +4,8 @@
4import os 4import os
5import subprocess 5import subprocess
6import sys 6import sys
7from glob import glob
8from time import strftime
9from importlib import import_module
10from importlib.util import find_spec 7from importlib.util import find_spec
8from time import strftime
11 9
12# Add the QMK python libs to our path 10# Add the QMK python libs to our path
13script_dir = os.path.dirname(os.path.realpath(__file__)) 11script_dir = os.path.dirname(os.path.realpath(__file__))
@@ -15,12 +13,8 @@ qmk_dir = os.path.abspath(os.path.join(script_dir, '..'))
15python_lib_dir = os.path.abspath(os.path.join(qmk_dir, 'lib', 'python')) 13python_lib_dir = os.path.abspath(os.path.join(qmk_dir, 'lib', 'python'))
16sys.path.append(python_lib_dir) 14sys.path.append(python_lib_dir)
17 15
18# Change to the root of our checkout
19os.environ['ORIG_CWD'] = os.getcwd()
20os.chdir(qmk_dir)
21
22# Make sure our modules have been setup 16# Make sure our modules have been setup
23with open('requirements.txt', 'r') as fd: 17with open(os.path.join(qmk_dir, 'requirements.txt'), 'r') as fd:
24 for line in fd.readlines(): 18 for line in fd.readlines():
25 line = line.strip().replace('<', '=').replace('>', '=') 19 line = line.strip().replace('<', '=').replace('>', '=')
26 20
@@ -32,72 +26,58 @@ with open('requirements.txt', 'r') as fd:
32 26
33 module = line.split('=')[0] if '=' in line else line 27 module = line.split('=')[0] if '=' in line else line
34 if not find_spec(module): 28 if not find_spec(module):
35 print('Your QMK build environment is not fully setup!\n') 29 print('Could not find module %s!', module)
36 print('Please run `./util/qmk_install.sh` to setup QMK.') 30 print('Please run `pip3 install -r requirements.txt` to install the python dependencies.')
37 exit(255) 31 exit(255)
38 32
39# Figure out our version 33# Figure out our version
34# TODO(skullydazed/anyone): Find a method that doesn't involve git. This is slow in docker and on windows.
40command = ['git', 'describe', '--abbrev=6', '--dirty', '--always', '--tags'] 35command = ['git', 'describe', '--abbrev=6', '--dirty', '--always', '--tags']
41result = subprocess.run(command, universal_newlines=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE) 36result = subprocess.run(command, universal_newlines=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
42 37
43if result.returncode == 0: 38if result.returncode == 0:
44 os.environ['QMK_VERSION'] = 'QMK ' + result.stdout.strip() 39 os.environ['QMK_VERSION'] = result.stdout.strip()
45else: 40else:
46 os.environ['QMK_VERSION'] = 'QMK ' + strftime('%Y-%m-%d-%H:%M:%S') 41 os.environ['QMK_VERSION'] = 'nogit-' + strftime('%Y-%m-%d-%H:%M:%S') + '-dirty'
47 42
48# Setup the CLI 43# Setup the CLI
49import milc 44import milc
50milc.EMOJI_LOGLEVELS['INFO'] = '{fg_blue}Ψ{style_reset_all}'
51 45
52# If we were invoked as `qmk <cmd>` massage sys.argv into `qmk-<cmd>`. 46milc.EMOJI_LOGLEVELS['INFO'] = '{fg_blue}Ψ{style_reset_all}'
53# This means we can't accept arguments to the qmk script itself.
54script_name = os.path.basename(sys.argv[0])
55if script_name == 'qmk':
56 if len(sys.argv) == 1:
57 milc.cli.log.error('No subcommand specified!\n')
58
59 if len(sys.argv) == 1 or sys.argv[1] in ['-h', '--help']:
60 milc.cli.echo('usage: qmk <subcommand> [...]')
61 milc.cli.echo('\nsubcommands:')
62 subcommands = glob(os.path.join(qmk_dir, 'bin', 'qmk-*'))
63 for subcommand in sorted(subcommands):
64 subcommand = os.path.basename(subcommand).split('-', 1)[1]
65 milc.cli.echo('\t%s', subcommand)
66 milc.cli.echo('\nqmk <subcommand> --help for more information')
67 exit(1)
68 47
69 if sys.argv[1] in ['-V', '--version']:
70 milc.cli.echo(os.environ['QMK_VERSION'])
71 exit(0)
72 48
73 sys.argv[0] = script_name = '-'.join((script_name, sys.argv[1])) 49@milc.cli.entrypoint('QMK Helper Script')
74 del sys.argv[1] 50def qmk_main(cli):
51 """The function that gets run when no subcommand is provided.
52 """
53 cli.print_help()
75 54
76# Look for which module to import
77if script_name == 'qmk':
78 milc.cli.print_help()
79 exit(0)
80elif not script_name.startswith('qmk-'):
81 milc.cli.log.error('Invalid symlink, must start with "qmk-": %s', script_name)
82else:
83 subcommand = script_name.replace('-', '.').replace('_', '.').split('.')
84 subcommand.insert(1, 'cli')
85 subcommand = '.'.join(subcommand)
86 55
87 try: 56def main():
88 import_module(subcommand) 57 """Setup our environment and then call the CLI entrypoint.
89 except ModuleNotFoundError as e: 58 """
90 if e.__class__.__name__ != subcommand: 59 # Change to the root of our checkout
91 raise 60 os.environ['ORIG_CWD'] = os.getcwd()
61 os.chdir(qmk_dir)
92 62
93 milc.cli.log.error('Invalid subcommand! Could not import %s.', subcommand) 63 # Import the subcommands
94 exit(1) 64 import qmk.cli
95 65
96if __name__ == '__main__': 66 # Execute
97 return_code = milc.cli() 67 return_code = milc.cli()
68
98 if return_code is False: 69 if return_code is False:
99 exit(1) 70 exit(1)
100 elif return_code is not True and isinstance(return_code, int) and return_code < 256: 71
72 elif return_code is not True and isinstance(return_code, int):
73 if return_code < 0 or return_code > 255:
74 milc.cli.log.error('Invalid return_code: %d', return_code)
75 exit(255)
76
101 exit(return_code) 77 exit(return_code)
102 else: 78
103 exit(0) 79 exit(0)
80
81
82if __name__ == '__main__':
83 main()
diff --git a/bin/qmk-compile-json b/bin/qmk-compile-json
deleted file mode 120000
index c92dce8a1..000000000
--- a/bin/qmk-compile-json
+++ /dev/null
@@ -1 +0,0 @@
1qmk \ No newline at end of file
diff --git a/bin/qmk-doctor b/bin/qmk-doctor
deleted file mode 120000
index c92dce8a1..000000000
--- a/bin/qmk-doctor
+++ /dev/null
@@ -1 +0,0 @@
1qmk \ No newline at end of file
diff --git a/bin/qmk-hello b/bin/qmk-hello
deleted file mode 120000
index c92dce8a1..000000000
--- a/bin/qmk-hello
+++ /dev/null
@@ -1 +0,0 @@
1qmk \ No newline at end of file
diff --git a/bin/qmk-json-keymap b/bin/qmk-json-keymap
deleted file mode 120000
index c92dce8a1..000000000
--- a/bin/qmk-json-keymap
+++ /dev/null
@@ -1 +0,0 @@
1qmk \ No newline at end of file
diff --git a/build_json.mk b/build_json.mk
index 8820a8f4a..e2a33e3b6 100644
--- a/build_json.mk
+++ b/build_json.mk
@@ -23,5 +23,5 @@ endif
23 23
24# Generate the keymap.c 24# Generate the keymap.c
25ifneq ("$(KEYMAP_JSON)","") 25ifneq ("$(KEYMAP_JSON)","")
26 _ = $(shell test -e $(KEYMAP_C) || bin/qmk-json-keymap $(KEYMAP_JSON) -o $(KEYMAP_C)) 26 _ = $(shell test -e $(KEYMAP_C) || bin/qmk json-keymap $(KEYMAP_JSON) -o $(KEYMAP_C))
27endif 27endif
diff --git a/docs/_summary.md b/docs/_summary.md
index 4e87d8f1f..233b2cdaa 100644
--- a/docs/_summary.md
+++ b/docs/_summary.md
@@ -9,6 +9,7 @@
9* [QMK Basics](README.md) 9* [QMK Basics](README.md)
10 * [QMK Introduction](getting_started_introduction.md) 10 * [QMK Introduction](getting_started_introduction.md)
11 * [QMK CLI](cli.md) 11 * [QMK CLI](cli.md)
12 * [QMK CLI Config](cli_configuration.md)
12 * [Contributing to QMK](contributing.md) 13 * [Contributing to QMK](contributing.md)
13 * [How to Use Github](getting_started_github.md) 14 * [How to Use Github](getting_started_github.md)
14 * [Getting Help](getting_started_getting_help.md) 15 * [Getting Help](getting_started_getting_help.md)
@@ -48,7 +49,7 @@
48 * [Useful Functions](ref_functions.md) 49 * [Useful Functions](ref_functions.md)
49 * [Configurator Support](reference_configurator_support.md) 50 * [Configurator Support](reference_configurator_support.md)
50 * [info.json Format](reference_info_json.md) 51 * [info.json Format](reference_info_json.md)
51 * [Python Development](python_development.md) 52 * [Python CLI Development](cli_development.md)
52 53
53* [Features](features.md) 54* [Features](features.md)
54 * [Basic Keycodes](keycodes_basic.md) 55 * [Basic Keycodes](keycodes_basic.md)
diff --git a/docs/cli.md b/docs/cli.md
index 4b8472b19..cb609e2a9 100644
--- a/docs/cli.md
+++ b/docs/cli.md
@@ -4,22 +4,70 @@ This page describes how to setup and use the QMK CLI.
4 4
5# Overview 5# Overview
6 6
7The QMK CLI makes building and working with QMK keyboards easier. We have provided a number of commands to help you work with QMK: 7The QMK CLI makes building and working with QMK keyboards easier. We have provided a number of commands to simplify and streamline tasks such as obtaining and compiling the QMK firmware, creating keymaps, and more.
8 8
9* `qmk compile` 9* [Global CLI](#global-cli)
10* `qmk doctor` 10* [Local CLI](#local-cli)
11* [CLI Commands](#cli-commands)
11 12
12# Setup 13# Requirements
13 14
14Simply add the `qmk_firmware/bin` directory to your `PATH`. You can run the `qmk` commands from any directory. 15The CLI requires Python 3.5 or greater. We try to keep the number of requirements small but you will also need to install the packages listed in [`requirements.txt`](https://github.com/qmk/qmk_firmware/blob/master/requirements.txt).
16
17# Global CLI
18
19QMK provides an installable CLI that can be used to setup your QMK build environment, work with QMK, and which makes working with multiple copies of `qmk_firmware` easier. We recommend installing and updating this periodically.
20
21## Install Using Homebrew (macOS, some Linux)
22
23If you have installed [Homebrew](https://brew.sh) you can tap and install QMK:
24
25```
26brew tap qmk/qmk
27brew install qmk
28export QMK_HOME='~/qmk_firmware' # Optional, set the location for `qmk_firmware`
29qmk setup # This will clone `qmk/qmk_firmware` and optionally set up your build environment
30```
31
32## Install Using easy_install or pip
33
34If your system is not listed above you can install QMK manually. First ensure that you have python 3.5 (or later) installed and have installed pip. Then install QMK with this command:
35
36```
37pip3 install qmk
38export QMK_HOME='~/qmk_firmware' # Optional, set the location for `qmk_firmware`
39qmk setup # This will clone `qmk/qmk_firmware` and optionally set up your build environment
40```
41
42## Packaging For Other Operating Systems
43
44We are looking for people to create and maintain a `qmk` package for more operating systems. If you would like to create a package for your OS please follow these guidelines:
45
46* Follow best practices for your OS when they conflict with these guidelines
47 * Documment why in a comment when you do deviate
48* Install using a virtualenv
49* Instruct the user to set the environment variable `QMK_HOME` to have the firmware source checked out somewhere other than `~/qmk_firmware`.
50
51# Local CLI
52
53If you do not want to use the global CLI there is a local CLI bundled with `qmk_firmware`. You can find it in `qmk_firmware/bin/qmk`. You can run the `qmk` command from any directory and it will always operate on that copy of `qmk_firmware`.
54
55**Example**:
15 56
16``` 57```
17export PATH=$PATH:$HOME/qmk_firmware/bin 58$ ~/qmk_firmware/bin/qmk hello
59Ψ Hello, World!
18``` 60```
19 61
20You may want to add this to your `.profile`, `.bash_profile`, `.zsh_profile`, or other shell startup scripts. 62## Local CLI Limitations
21 63
22# Commands 64There are some limitations to the local CLI compared to the global CLI:
65
66* The local CLI does not support `qmk setup` or `qmk clone`
67* The local CLI always operates on the same `qmk_firmware` tree, even if you have multiple repositories cloned.
68* The local CLI does not run in a virtualenv, so it's possible that dependencies will conflict
69
70# CLI Commands
23 71
24## `qmk compile` 72## `qmk compile`
25 73
@@ -46,3 +94,53 @@ This command formats C code using clang-format. Run it with no arguments to form
46``` 94```
47qmk cformat [file1] [file2] [...] [fileN] 95qmk cformat [file1] [file2] [...] [fileN]
48``` 96```
97
98## `qmk config`
99
100This command lets you configure the behavior of QMK. For the full `qmk config` documentation see [CLI Configuration](cli_configuration.md).
101
102**Usage**:
103
104```
105qmk config [-ro] [config_token1] [config_token2] [...] [config_tokenN]
106```
107
108## `qmk doctor`
109
110This command examines your environment and alerts you to potential build or flash problems.
111
112**Usage**:
113
114```
115qmk doctor
116```
117
118## `qmk new-keymap`
119
120This command creates a new keymap based on a keyboard's existing default keymap.
121
122**Usage**:
123
124```
125qmk new-keymap [-kb KEYBOARD] [-km KEYMAP]
126```
127
128## `qmk pyformat`
129
130This command formats python code in `qmk_firmware`.
131
132**Usage**:
133
134```
135qmk pyformat
136```
137
138## `qmk pytest`
139
140This command runs the python test suite. If you make changes to python code you should ensure this runs successfully.
141
142**Usage**:
143
144```
145qmk pytest
146```
diff --git a/docs/cli_configuration.md b/docs/cli_configuration.md
new file mode 100644
index 000000000..ad9ff291c
--- /dev/null
+++ b/docs/cli_configuration.md
@@ -0,0 +1,121 @@
1# QMK CLI Configuration
2
3This document explains how `qmk config` works.
4
5# Introduction
6
7Configuration for QMK CLI is a key/value system. Each key consists of a subcommand and an argument name separated by a period. This allows for a straightforward and direct translation between config keys and the arguments they set.
8
9## Simple Example
10
11As an example let's look at the command `qmk compile --keyboard clueboard/66/rev4 --keymap default`.
12
13There are two command line arguments that could be read from configuration instead:
14
15* `compile.keyboard`
16* `compile.keymap`
17
18Let's set these now:
19
20```
21$ qmk config compile.keyboard=clueboard/66/rev4 compile.keymap=default
22compile.keyboard: None -> clueboard/66/rev4
23compile.keymap: None -> default
24Ψ Wrote configuration to '/Users/example/Library/Application Support/qmk/qmk.ini'
25```
26
27Now I can run `qmk compile` without specifying my keyboard and keymap each time.
28
29## Setting User Defaults
30
31Sometimes you want to share a setting between multiple commands. For example, multiple commands take the argument `--keyboard`. Rather than setting this value for every command you can set a user value which will be used by any command that takes that argument.
32
33Example:
34
35```
36$ qmk config user.keyboard=clueboard/66/rev4 user.keymap=default
37user.keyboard: None -> clueboard/66/rev4
38user.keymap: None -> default
39Ψ Wrote configuration to '/Users/example/Library/Application Support/qmk/qmk.ini'
40```
41
42# CLI Documentation (`qmk config`)
43
44The `qmk config` command is used to interact with the underlying configuration. When run with no argument it shows the current configuration. When arguments are supplied they are assumed to be configuration tokens, which are strings containing no spaces with the following form:
45
46 <subcommand|general|default>[.<key>][=<value>]
47
48## Setting Configuration Values
49
50You can set configuration values by putting an equal sign (=) into your config key. The key must always be the full `<section>.<key>` form.
51
52Example:
53
54```
55$ qmk config default.keymap=default
56default.keymap: None -> default
57Ψ Wrote configuration to '/Users/example/Library/Application Support/qmk/qmk.ini'
58```
59
60## Reading Configuration Values
61
62You can read configuration values for the entire configuration, a single key, or for an entire section. You can also specify multiple keys to display more than one value.
63
64### Entire Configuration Example
65
66 qmk config
67
68### Whole Section Example
69
70 qmk config compile
71
72### Single Key Example
73
74 qmk config compile.keyboard
75
76### Multiple Keys Example
77
78 qmk config user compile.keyboard compile.keymap
79
80## Deleting Configuration Values
81
82You can delete a configuration value by setting it to the special string `None`.
83
84Example:
85
86```
87$ qmk config default.keymap=None
88default.keymap: default -> None
89Ψ Wrote configuration to '/Users/example/Library/Application Support/qmk/qmk.ini'
90```
91
92## Multiple Operations
93
94You can combine multiple read and write operations into a single command. They will be executed and displayed in order:
95
96```
97$ qmk config compile default.keymap=default compile.keymap=None
98compile.keymap=skully
99compile.keyboard=clueboard/66_hotswap/gen1
100default.keymap: None -> default
101compile.keymap: skully -> None
102Ψ Wrote configuration to '/Users/example/Library/Application Support/qmk/qmk.ini'
103```
104
105# User Configuration Options
106
107| Key | Default Value | Description |
108|-----|---------------|-------------|
109| user.keyboard | None | The keyboard path (Example: `clueboard/66/rev4`) |
110| user.keymap | None | The keymap name (Example: `default`) |
111| user.name | None | The user's github username. |
112
113# All Configuration Options
114
115| Key | Default Value | Description |
116|-----|---------------|-------------|
117| compile.keyboard | None | The keyboard path (Example: `clueboard/66/rev4`) |
118| compile.keymap | None | The keymap name (Example: `default`) |
119| hello.name | None | The name to greet when run. |
120| new_keyboard.keyboard | None | The keyboard path (Example: `clueboard/66/rev4`) |
121| new_keyboard.keymap | None | The keymap name (Example: `default`) |
diff --git a/docs/cli_development.md b/docs/cli_development.md
new file mode 100644
index 000000000..f5c7ad139
--- /dev/null
+++ b/docs/cli_development.md
@@ -0,0 +1,175 @@
1# QMK CLI Development
2
3This document has useful information for developers wishing to write new `qmk` subcommands.
4
5# Overview
6
7The QMK CLI operates using the subcommand pattern made famous by git. The main `qmk` script is simply there to setup the environment and pick the correct entrypoint to run. Each subcommand is a self-contained module with an entrypoint (decorated by `@cli.subcommand()`) that performs some action and returns a shell returncode, or None.
8
9# Subcommands
10
11[MILC](https://github.com/clueboard/milc) is the CLI framework `qmk` uses to handle argument parsing, configuration, logging, and many other features. It lets you focus on writing your tool without wasting your time writing glue code.
12
13Subcommands in the local CLI are always found in `qmk_firmware/lib/python/qmk/cli`.
14
15Let's start by looking at an example subcommand. This is `lib/python/qmk/cli/hello.py`:
16
17```python
18"""QMK Python Hello World
19
20This is an example QMK CLI script.
21"""
22from milc import cli
23
24
25@cli.argument('-n', '--name', default='World', help='Name to greet.')
26@cli.subcommand('QMK Hello World.')
27def hello(cli):
28 """Log a friendly greeting.
29 """
30 cli.log.info('Hello, %s!', cli.config.hello.name)
31```
32
33First we import the `cli` object from `milc`. This is how we interact with the user and control the script's behavior. We use `@cli.argument()` to define a command line flag, `--name`. This also creates a configuration variable named `hello.name` (and the corresponding `user.name`) which the user can set so they don't have to specify the argument. The `cli.subcommand()` decorator designates this function as a subcommand. The name of the subcommand will be taken from the name of the function.
34
35Once inside our function we find a typical "Hello, World!" program. We use `cli.log` to access the underlying [Logger Object](https://docs.python.org/3.5/library/logging.html#logger-objects), whose behavior is user controllable. We also access the value for name supplied by the user as `cli.config.hello.name`. The value for `cli.config.hello.name` will be determined by looking at the `--name` argument supplied by the user, if not provided it will use the value in the `qmk.ini` config file, and if neither of those is provided it will fall back to the default supplied in the `cli.argument()` decorator.
36
37# User Interaction
38
39MILC and the QMK CLI have several nice tools for interacting with the user. Using these standard tools will allow you to colorize your text for easier interactions, and allow the user to control when and how that information is displayed and stored.
40
41## Printing Text
42
43There are two main methods for outputting text in a subcommand- `cli.log` and `cli.echo()`. They operate in similar ways but you should prefer to use `cli.log.info()` for most general purpose printing.
44
45You can use special tokens to colorize your text, to make it easier to understand the output of your program. See [Colorizing Text](#colorizing-text) below.
46
47Both of these methods support built-in string formatting using python's [printf style string format operations](https://docs.python.org/3.5/library/stdtypes.html#old-string-formatting). You can use tokens such as `%s` and `%d` within your text strings then pass the values as arguments. See our Hello, World program above for an example.
48
49You should never use the format operator (`%`) directly, always pass values as arguments.
50
51### Logging (`cli.log`)
52
53The `cli.log` object gives you access to a [Logger Object](https://docs.python.org/3.5/library/logging.html#logger-objects). We have configured our log output to show the user a nice emoji for each log level (or the log level name if their terminal does not support unicode.) This way the user can tell at a glance which messages are most important when something goes wrong.
54
55The default log level is `INFO`. If the user runs `qmk -v <subcommand>` the default log level will be set to `DEBUG`.
56
57| Function | Emoji |
58|----------|-------|
59| cli.log.critical | `{bg_red}{fg_white}¬_¬{style_reset_all}` |
60| cli.log.error | `{fg_red}☒{style_reset_all}` |
61| cli.log.warning | `{fg_yellow}⚠{style_reset_all}` |
62| cli.log.info | `{fg_blue}Ψ{style_reset_all}` |
63| cli.log.debug | `{fg_cyan}☐{style_reset_all}` |
64| cli.log.notset | `{style_reset_all}¯\\_(o_o)_/¯` |
65
66### Printing (`cli.echo`)
67
68Sometimes you simply need to print text outside of the log system. This is appropriate if you are outputting fixed data or writing out something that should never be logged. Most of the time you should prefer `cli.log.info()` over `cli.echo`.
69
70### Colorizing Text
71
72You can colorize the output of your text by including color tokens within text. Use color to highlight, not to convey information. Remember that the user can disable color, and your subcommand should still be usable if they do.
73
74You should generally avoid setting the background color, unless it's integral to what you are doing. Remember that users have a lot of preferences when it comes to their terminal color, so you should pick colors that work well against both black and white backgrounds.
75
76Colors prefixed with 'fg' will affect the foreground (text) color. Colors prefixed with 'bg' will affect the background color.
77
78| Color | Background | Extended Background | Foreground | Extended Foreground|
79|-------|------------|---------------------|------------|--------------------|
80| Black | {bg_black} | {bg_lightblack_ex} | {fg_black} | {fg_lightblack_ex} |
81| Blue | {bg_blue} | {bg_lightblue_ex} | {fg_blue} | {fg_lightblue_ex} |
82| Cyan | {bg_cyan} | {bg_lightcyan_ex} | {fg_cyan} | {fg_lightcyan_ex} |
83| Green | {bg_green} | {bg_lightgreen_ex} | {fg_green} | {fg_lightgreen_ex} |
84| Magenta | {bg_magenta} | {bg_lightmagenta_ex} | {fg_magenta} | {fg_lightmagenta_ex} |
85| Red | {bg_red} | {bg_lightred_ex} | {fg_red} | {fg_lightred_ex} |
86| White | {bg_white} | {bg_lightwhite_ex} | {fg_white} | {fg_lightwhite_ex} |
87| Yellow | {bg_yellow} | {bg_lightyellow_ex} | {fg_yellow} | {fg_lightyellow_ex} |
88
89There are also control sequences that can be used to change the behavior of
90ANSI output:
91
92| Control Sequences | Description |
93|-------------------|-------------|
94| {style_bright} | Make the text brighter |
95| {style_dim} | Make the text dimmer |
96| {style_normal} | Make the text normal (neither `{style_bright}` nor `{style_dim}`) |
97| {style_reset_all} | Reset all text attributes to default. (This is automatically added to the end of every string.) |
98| {bg_reset} | Reset the background color to the user's default |
99| {fg_reset} | Reset the foreground color to the user's default |
100
101# Arguments and Configuration
102
103QMK handles the details of argument parsing and configuration for you. When you add a new argument it is automatically incorporated into the config tree based on your subcommand's name and the long name of the argument. You can access this configuration in `cli.config`, using either attribute-style access (`cli.config.<subcommand>.<argument>`) or dictionary-style access (`cli.config['<subcommand>']['<argument>']`).
104
105Under the hood QMK uses [ConfigParser](https://docs.python.org/3/library/configparser.html) to store configurations. This gives us an easy and straightforward way to represent the configuration in a human-editable way. We have wrapped access to this configuration to provide some nicities that ConfigParser does not normally have.
106
107## Reading Configuration Values
108
109You can interact with `cli.config` in all the ways you'd normally expect. For example the `qmk compile` command gets the keyboard name from `cli.config.compile.keyboard`. It does not need to know whether that value came from the command line, an environment variable, or the configuration file.
110
111Iteration is also supported:
112
113```
114for section in cli.config:
115 for key in cli.config[section]:
116 cli.log.info('%s.%s: %s', section, key, cli.config[section][key])
117```
118
119## Setting Configuration Values
120
121You can set configuration values in the usual ways.
122
123Dictionary style:
124
125```
126cli.config['<section>']['<key>'] = <value>
127```
128
129Attribute style:
130
131```
132cli.config.<section>.<key> = <value>
133```
134
135## Deleting Configuration Values
136
137You can delete configuration values in the usual ways.
138
139Dictionary style:
140
141```
142del(cli.config['<section>']['<key>'])
143```
144
145Attribute style:
146
147```
148del(cli.config.<section>.<key>)
149```
150
151## Writing The Configuration File
152
153The configuration is not written out when it is changed. Most commands do not need to do this. We prefer to have the user change their configuration deliberitely using `qmk config`.
154
155You can use `cli.save_config()` to write out the configuration.
156
157## Excluding Arguments From Configuration
158
159Some arguments should not be propagated to the configuration file. These can be excluded by adding `arg_only=True` when creating the argument.
160
161Example:
162
163```
164@cli.argument('-o', '--output', arg_only=True, help='File to write to')
165@cli.argument('filename', arg_only=True, help='Configurator JSON file')
166@cli.subcommand('Create a keymap.c from a QMK Configurator export.')
167def json_keymap(cli):
168 pass
169```
170
171You will only be able to access these arguments using `cli.args`. For example:
172
173```
174cli.log.info('Reading from %s and writing to %s', cli.args.filename, cli.args.output)
175```
diff --git a/docs/python_development.md b/docs/python_development.md
deleted file mode 100644
index b976a7c0e..000000000
--- a/docs/python_development.md
+++ /dev/null
@@ -1,45 +0,0 @@
1# Python Development in QMK
2
3This document gives an overview of how QMK has structured its python code. You should read this before working on any of the python code.
4
5## Script directories
6
7There are two places scripts live in QMK: `qmk_firmware/bin` and `qmk_firmware/util`. You should use `bin` for any python scripts that utilize the `qmk` wrapper. Scripts that are standalone and not run very often live in `util`.
8
9We discourage putting anything into `bin` that does not utilize the `qmk` wrapper. If you think you have a good reason for doing so please talk to us about your use case.
10
11## Python Modules
12
13Most of the QMK python modules can be found in `qmk_firmware/lib/python`. This is the path that we append to `sys.path`.
14
15We have a module hierarchy under that path:
16
17* `qmk_firmware/lib/python`
18 * `milc.py` - The CLI library we use. Will be pulled out into its own module in the future.
19 * `qmk` - Code associated with QMK
20 * `cli` - Modules that will be imported for CLI commands.
21 * `errors.py` - Errors that can be raised within QMK apps
22 * `keymap.py` - Functions for working with keymaps
23
24## CLI Scripts
25
26We have a CLI wrapper that you should utilize for any user facing scripts. We think it's pretty easy to use and it gives you a lot of nice things for free.
27
28To use the wrapper simply place a module into `qmk_firmware/lib/python/qmk/cli`, and create a symlink to `bin/qmk` named after your module. Dashes in command names will be converted into dots so you can use hierarchy to manage commands.
29
30When `qmk` is run it checks to see how it was invoked. If it was invoked as `qmk` the module name is take from `sys.argv[1]`. If it was invoked as `qmk-<module-name>` then everything after the first dash is taken as the module name. Dashes and underscores are converted to dots, and then `qmk.cli` is prepended before the module is imported.
31
32The module uses `@cli.entrypoint()` and `@cli.argument()` decorators to define an entrypoint, which is where execution starts.
33
34## Example CLI Script
35
36We have provided a QMK Hello World script you can use as an example. To run it simply run `qmk hello` or `qmk-hello`. The source code is listed below.
37
38```
39from milc import cli
40
41@cli.argument('-n', '--name', default='World', help='Name to greet.')
42@cli.entrypoint('QMK Python Hello World.')
43def main(cli):
44 cli.echo('Hello, %s!', cli.config.general.name)
45```
diff --git a/docs/redirects.json b/docs/redirects.json
index 814518f40..651148c2c 100644
--- a/docs/redirects.json
+++ b/docs/redirects.json
@@ -43,6 +43,10 @@
43 { 43 {
44 "from": "unicode.html", 44 "from": "unicode.html",
45 "to": "feature_unicode.html" 45 "to": "feature_unicode.html"
46 },
47 {
48 "from": "python_development.html",
49 "to": "cli_development.html"
46 } 50 }
47 ] 51 ]
48} 52}
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
17import logging 17import logging
18import os 18import os
19import re 19import re
20import shlex
20import sys 21import sys
21from decimal import Decimal 22from decimal import Decimal
22from tempfile import NamedTemporaryFile 23from tempfile import NamedTemporaryFile
@@ -35,6 +36,10 @@ except ImportError:
35 36
36import argcomplete 37import argcomplete
37import colorama 38import colorama
39from appdirs import user_config_dir
40
41# Disable logging until we can configure it how the user wants
42logging.basicConfig(filename='/dev/null')
38 43
39# Log Level Representations 44# Log Level Representations
40EMOJI_LOGLEVELS = { 45EMOJI_LOGLEVELS = {
@@ -47,6 +52,7 @@ EMOJI_LOGLEVELS = {
47} 52}
48EMOJI_LOGLEVELS['FATAL'] = EMOJI_LOGLEVELS['CRITICAL'] 53EMOJI_LOGLEVELS['FATAL'] = EMOJI_LOGLEVELS['CRITICAL']
49EMOJI_LOGLEVELS['WARN'] = EMOJI_LOGLEVELS['WARNING'] 54EMOJI_LOGLEVELS['WARN'] = EMOJI_LOGLEVELS['WARNING']
55UNICODE_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
99class ANSIEmojiLoglevelFormatter(ANSIFormatter): 105class 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
167class ConfigurationOption(Configuration): 177class 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
181def handle_store_boolean(self, *args, **kwargs): 194def 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
3We list each subcommand here explicitly because all the reliable ways of searching for modules are slow and delay startup.
4"""
5from . import cformat
6from . import compile
7from . import config
8from . import doctor
9from . import hello
10from . import json
11from . import new
12from . import pyformat
13from . 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
6from milc import cli 6from 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.")
11def main(cli): 11def 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
14import qmk.path 14import 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.')
21def main(cli): 21def 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"""
3import os
4import subprocess
5
6from milc import cli
7
8
9def 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.")
18def 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
11from milc import cli 11from milc import cli
12 12
13 13
14@cli.entrypoint('Basic QMK environment checks') 14@cli.subcommand('Basic QMK environment checks')
15def main(cli): 15def 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.')
10def main(cli): 10def 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
3We list each subcommand here explicitly because all the reliable ways of searching for modules are slow and delay startup.
4"""
5from . 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
9import qmk.keymap 9import 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.')
15def main(cli): 15def 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
6from milc import cli 6from 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')
12def main(cli): 12def 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
5import subprocess 5import 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.")
9def main(cli): 9def 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
3QMK script to run unit and integration tests against our python code. 3QMK script to run unit and integration tests against our python code.
4""" 4"""
5import sys
5from milc import cli 6from milc import cli
6 7
7 8
8@cli.entrypoint('QMK Python Unit Tests') 9@cli.subcommand('QMK Python Unit Tests')
9def main(cli): 10def 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"""
3import logging 3import logging
4import os 4import os
5from pkgutil import walk_packages
5 6
6from qmk.errors import NoSuchKeyboardError 7from 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 @@
1import subprocess
2
3
4def 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
9def test_cformat():
10 assert check_subcommand('cformat', 'tmk_core/common/backlight.c').returncode == 0
11
12
13def test_compile():
14 assert check_subcommand('compile', '-kb', 'handwired/onekey/pytest', '-km', 'default').returncode == 0
15
16
17def test_config():
18 result = check_subcommand('config')
19 assert result.returncode == 0
20 assert 'general.color' in result.stdout
21
22
23def 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
30def test_hello():
31 result = check_subcommand('hello')
32 assert result.returncode == 0
33 assert 'Hello,' in result.stderr
34
35
36def test_pyformat():
37 result = check_subcommand('pyformat')
38 assert result.returncode == 0
39 assert 'Successfully formatted the python code' in result.stderr
diff --git a/requirements.txt b/requirements.txt
index 351dc2524..f6257e399 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,5 +1,5 @@
1# Python requirements 1# Python requirements
2# milc FIXME(skullydazed): Included in the repo for now. 2# milc FIXME(skullydazed): Included in the repo for now.
3appdirs
3argcomplete 4argcomplete
4colorama 5colorama
5#halo
diff --git a/util/travis_build.sh b/util/travis_build.sh
index 605b6d5f0..2bc1ccd62 100755
--- a/util/travis_build.sh
+++ b/util/travis_build.sh
@@ -18,11 +18,12 @@ if [[ "$TRAVIS_COMMIT_MESSAGE" != *"[skip build]"* ]] ; then
18 exit_code=0 18 exit_code=0
19 git diff --name-only -n 1 ${TRAVIS_COMMIT_RANGE} 19 git diff --name-only -n 1 ${TRAVIS_COMMIT_RANGE}
20 if [ $? -eq 128 ]; then 20 if [ $? -eq 128 ]; then
21 echo "Making default keymaps for all keyboards" 21 # We don't know what changed so just build the default keymaps
22 echo "Making default keymaps for all keyboards (fallback)"
22 eval $MAKE_ALL 23 eval $MAKE_ALL
23 : $((exit_code = $exit_code + $?)) 24 : $((exit_code = $exit_code + $?))
24 else 25 else
25 NEFM=$(git diff --name-only -n 1 ${TRAVIS_COMMIT_RANGE} | grep -Ev '^(keyboards/)' | grep -Ev '^(docs/)' | grep -Ev '^(lib/python/)' | grep -Ev '(^bin/qmk)' | wc -l) 26 NEFM=$(git diff --name-only -n 1 ${TRAVIS_COMMIT_RANGE} | grep -Ev '^(keyboards/)' | grep -Ev '^(docs/)' | grep -Ev '^(lib/python/)' | grep -Ev '^(bin/qmk)' | grep -Ev '^(requirements.txt)' | grep -Ev '^(util/)' | wc -l)
26 BRANCH=$(git rev-parse --abbrev-ref HEAD) 27 BRANCH=$(git rev-parse --abbrev-ref HEAD)
27 # is this branch master or a "non docs, non keyboards" change 28 # is this branch master or a "non docs, non keyboards" change
28 if [ $NEFM -gt 0 -o "$BRANCH" = "master" ]; then 29 if [ $NEFM -gt 0 -o "$BRANCH" = "master" ]; then
@@ -56,7 +57,7 @@ if [[ "$TRAVIS_COMMIT_MESSAGE" != *"[skip build]"* ]] ; then
56 if [ $PFM -gt 0 -o "$BRANCH" = "master" ]; then 57 if [ $PFM -gt 0 -o "$BRANCH" = "master" ]; then
57 echo 58 echo
58 echo "Running python tests." 59 echo "Running python tests."
59 docker run --rm -w /qmk_firmware/ -v "$PWD":/qmk_firmware --user $(id -u):$(id -g) qmkfm/base_container bin/qmk nose2 60 docker run --rm -w /qmk_firmware/ -v "$PWD":/qmk_firmware --user $(id -u):$(id -g) qmkfm/base_container 'bin/qmk pytest'
60 : $((exit_code = $exit_code + $?)) 61 : $((exit_code = $exit_code + $?))
61 fi 62 fi
62 fi 63 fi