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
This commit is contained in:
skullydazed 2019-09-22 13:25:33 -07:00 committed by GitHub
parent 2f49cae9bc
commit d569f08771
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
29 changed files with 715 additions and 199 deletions

92
bin/qmk
View file

@ -4,10 +4,8 @@
import os
import subprocess
import sys
from glob import glob
from time import strftime
from importlib import import_module
from importlib.util import find_spec
from time import strftime
# Add the QMK python libs to our path
script_dir = os.path.dirname(os.path.realpath(__file__))
@ -15,12 +13,8 @@ qmk_dir = os.path.abspath(os.path.join(script_dir, '..'))
python_lib_dir = os.path.abspath(os.path.join(qmk_dir, 'lib', 'python'))
sys.path.append(python_lib_dir)
# Change to the root of our checkout
os.environ['ORIG_CWD'] = os.getcwd()
os.chdir(qmk_dir)
# Make sure our modules have been setup
with open('requirements.txt', 'r') as fd:
with open(os.path.join(qmk_dir, 'requirements.txt'), 'r') as fd:
for line in fd.readlines():
line = line.strip().replace('<', '=').replace('>', '=')
@ -32,72 +26,58 @@ with open('requirements.txt', 'r') as fd:
module = line.split('=')[0] if '=' in line else line
if not find_spec(module):
print('Your QMK build environment is not fully setup!\n')
print('Please run `./util/qmk_install.sh` to setup QMK.')
print('Could not find module %s!', module)
print('Please run `pip3 install -r requirements.txt` to install the python dependencies.')
exit(255)
# Figure out our version
# TODO(skullydazed/anyone): Find a method that doesn't involve git. This is slow in docker and on windows.
command = ['git', 'describe', '--abbrev=6', '--dirty', '--always', '--tags']
result = subprocess.run(command, universal_newlines=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
result = subprocess.run(command, universal_newlines=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
if result.returncode == 0:
os.environ['QMK_VERSION'] = 'QMK ' + result.stdout.strip()
os.environ['QMK_VERSION'] = result.stdout.strip()
else:
os.environ['QMK_VERSION'] = 'QMK ' + strftime('%Y-%m-%d-%H:%M:%S')
os.environ['QMK_VERSION'] = 'nogit-' + strftime('%Y-%m-%d-%H:%M:%S') + '-dirty'
# Setup the CLI
import milc
milc.EMOJI_LOGLEVELS['INFO'] = '{fg_blue}Ψ{style_reset_all}'
# If we were invoked as `qmk <cmd>` massage sys.argv into `qmk-<cmd>`.
# This means we can't accept arguments to the qmk script itself.
script_name = os.path.basename(sys.argv[0])
if script_name == 'qmk':
if len(sys.argv) == 1:
milc.cli.log.error('No subcommand specified!\n')
if len(sys.argv) == 1 or sys.argv[1] in ['-h', '--help']:
milc.cli.echo('usage: qmk <subcommand> [...]')
milc.cli.echo('\nsubcommands:')
subcommands = glob(os.path.join(qmk_dir, 'bin', 'qmk-*'))
for subcommand in sorted(subcommands):
subcommand = os.path.basename(subcommand).split('-', 1)[1]
milc.cli.echo('\t%s', subcommand)
milc.cli.echo('\nqmk <subcommand> --help for more information')
exit(1)
@milc.cli.entrypoint('QMK Helper Script')
def qmk_main(cli):
"""The function that gets run when no subcommand is provided.
"""
cli.print_help()
if sys.argv[1] in ['-V', '--version']:
milc.cli.echo(os.environ['QMK_VERSION'])
exit(0)
sys.argv[0] = script_name = '-'.join((script_name, sys.argv[1]))
del sys.argv[1]
def main():
"""Setup our environment and then call the CLI entrypoint.
"""
# Change to the root of our checkout
os.environ['ORIG_CWD'] = os.getcwd()
os.chdir(qmk_dir)
# Look for which module to import
if script_name == 'qmk':
milc.cli.print_help()
exit(0)
elif not script_name.startswith('qmk-'):
milc.cli.log.error('Invalid symlink, must start with "qmk-": %s', script_name)
else:
subcommand = script_name.replace('-', '.').replace('_', '.').split('.')
subcommand.insert(1, 'cli')
subcommand = '.'.join(subcommand)
# Import the subcommands
import qmk.cli
try:
import_module(subcommand)
except ModuleNotFoundError as e:
if e.__class__.__name__ != subcommand:
raise
milc.cli.log.error('Invalid subcommand! Could not import %s.', subcommand)
exit(1)
if __name__ == '__main__':
# Execute
return_code = milc.cli()
if return_code is False:
exit(1)
elif return_code is not True and isinstance(return_code, int) and return_code < 256:
elif return_code is not True and isinstance(return_code, int):
if return_code < 0 or return_code > 255:
milc.cli.log.error('Invalid return_code: %d', return_code)
exit(255)
exit(return_code)
else:
exit(0)
exit(0)
if __name__ == '__main__':
main()

View file

@ -1 +0,0 @@
qmk

View file

@ -1 +0,0 @@
qmk

View file

@ -1 +0,0 @@
qmk

View file

@ -1 +0,0 @@
qmk

View file

@ -23,5 +23,5 @@ endif
# Generate the keymap.c
ifneq ("$(KEYMAP_JSON)","")
_ = $(shell test -e $(KEYMAP_C) || bin/qmk-json-keymap $(KEYMAP_JSON) -o $(KEYMAP_C))
_ = $(shell test -e $(KEYMAP_C) || bin/qmk json-keymap $(KEYMAP_JSON) -o $(KEYMAP_C))
endif

View file

@ -9,6 +9,7 @@
* [QMK Basics](README.md)
* [QMK Introduction](getting_started_introduction.md)
* [QMK CLI](cli.md)
* [QMK CLI Config](cli_configuration.md)
* [Contributing to QMK](contributing.md)
* [How to Use Github](getting_started_github.md)
* [Getting Help](getting_started_getting_help.md)
@ -48,7 +49,7 @@
* [Useful Functions](ref_functions.md)
* [Configurator Support](reference_configurator_support.md)
* [info.json Format](reference_info_json.md)
* [Python Development](python_development.md)
* [Python CLI Development](cli_development.md)
* [Features](features.md)
* [Basic Keycodes](keycodes_basic.md)

View file

@ -4,22 +4,70 @@ This page describes how to setup and use the QMK CLI.
# Overview
The QMK CLI makes building and working with QMK keyboards easier. We have provided a number of commands to help you work with QMK:
The 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.
* `qmk compile`
* `qmk doctor`
* [Global CLI](#global-cli)
* [Local CLI](#local-cli)
* [CLI Commands](#cli-commands)
# Setup
# Requirements
Simply add the `qmk_firmware/bin` directory to your `PATH`. You can run the `qmk` commands from any directory.
The 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).
# Global CLI
QMK 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.
## Install Using Homebrew (macOS, some Linux)
If you have installed [Homebrew](https://brew.sh) you can tap and install QMK:
```
export PATH=$PATH:$HOME/qmk_firmware/bin
brew tap qmk/qmk
brew install qmk
export QMK_HOME='~/qmk_firmware' # Optional, set the location for `qmk_firmware`
qmk setup # This will clone `qmk/qmk_firmware` and optionally set up your build environment
```
You may want to add this to your `.profile`, `.bash_profile`, `.zsh_profile`, or other shell startup scripts.
## Install Using easy_install or pip
# Commands
If 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:
```
pip3 install qmk
export QMK_HOME='~/qmk_firmware' # Optional, set the location for `qmk_firmware`
qmk setup # This will clone `qmk/qmk_firmware` and optionally set up your build environment
```
## Packaging For Other Operating Systems
We 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:
* Follow best practices for your OS when they conflict with these guidelines
* Documment why in a comment when you do deviate
* Install using a virtualenv
* Instruct the user to set the environment variable `QMK_HOME` to have the firmware source checked out somewhere other than `~/qmk_firmware`.
# Local CLI
If 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`.
**Example**:
```
$ ~/qmk_firmware/bin/qmk hello
Ψ Hello, World!
```
## Local CLI Limitations
There are some limitations to the local CLI compared to the global CLI:
* The local CLI does not support `qmk setup` or `qmk clone`
* The local CLI always operates on the same `qmk_firmware` tree, even if you have multiple repositories cloned.
* The local CLI does not run in a virtualenv, so it's possible that dependencies will conflict
# CLI Commands
## `qmk compile`
@ -46,3 +94,53 @@ This command formats C code using clang-format. Run it with no arguments to form
```
qmk cformat [file1] [file2] [...] [fileN]
```
## `qmk config`
This command lets you configure the behavior of QMK. For the full `qmk config` documentation see [CLI Configuration](cli_configuration.md).
**Usage**:
```
qmk config [-ro] [config_token1] [config_token2] [...] [config_tokenN]
```
## `qmk doctor`
This command examines your environment and alerts you to potential build or flash problems.
**Usage**:
```
qmk doctor
```
## `qmk new-keymap`
This command creates a new keymap based on a keyboard's existing default keymap.
**Usage**:
```
qmk new-keymap [-kb KEYBOARD] [-km KEYMAP]
```
## `qmk pyformat`
This command formats python code in `qmk_firmware`.
**Usage**:
```
qmk pyformat
```
## `qmk pytest`
This command runs the python test suite. If you make changes to python code you should ensure this runs successfully.
**Usage**:
```
qmk pytest
```

121
docs/cli_configuration.md Normal file
View file

@ -0,0 +1,121 @@
# QMK CLI Configuration
This document explains how `qmk config` works.
# Introduction
Configuration 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.
## Simple Example
As an example let's look at the command `qmk compile --keyboard clueboard/66/rev4 --keymap default`.
There are two command line arguments that could be read from configuration instead:
* `compile.keyboard`
* `compile.keymap`
Let's set these now:
```
$ qmk config compile.keyboard=clueboard/66/rev4 compile.keymap=default
compile.keyboard: None -> clueboard/66/rev4
compile.keymap: None -> default
Ψ Wrote configuration to '/Users/example/Library/Application Support/qmk/qmk.ini'
```
Now I can run `qmk compile` without specifying my keyboard and keymap each time.
## Setting User Defaults
Sometimes 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.
Example:
```
$ qmk config user.keyboard=clueboard/66/rev4 user.keymap=default
user.keyboard: None -> clueboard/66/rev4
user.keymap: None -> default
Ψ Wrote configuration to '/Users/example/Library/Application Support/qmk/qmk.ini'
```
# CLI Documentation (`qmk config`)
The `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:
<subcommand|general|default>[.<key>][=<value>]
## Setting Configuration Values
You can set configuration values by putting an equal sign (=) into your config key. The key must always be the full `<section>.<key>` form.
Example:
```
$ qmk config default.keymap=default
default.keymap: None -> default
Ψ Wrote configuration to '/Users/example/Library/Application Support/qmk/qmk.ini'
```
## Reading Configuration Values
You 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.
### Entire Configuration Example
qmk config
### Whole Section Example
qmk config compile
### Single Key Example
qmk config compile.keyboard
### Multiple Keys Example
qmk config user compile.keyboard compile.keymap
## Deleting Configuration Values
You can delete a configuration value by setting it to the special string `None`.
Example:
```
$ qmk config default.keymap=None
default.keymap: default -> None
Ψ Wrote configuration to '/Users/example/Library/Application Support/qmk/qmk.ini'
```
## Multiple Operations
You can combine multiple read and write operations into a single command. They will be executed and displayed in order:
```
$ qmk config compile default.keymap=default compile.keymap=None
compile.keymap=skully
compile.keyboard=clueboard/66_hotswap/gen1
default.keymap: None -> default
compile.keymap: skully -> None
Ψ Wrote configuration to '/Users/example/Library/Application Support/qmk/qmk.ini'
```
# User Configuration Options
| Key | Default Value | Description |
|-----|---------------|-------------|
| user.keyboard | None | The keyboard path (Example: `clueboard/66/rev4`) |
| user.keymap | None | The keymap name (Example: `default`) |
| user.name | None | The user's github username. |
# All Configuration Options
| Key | Default Value | Description |
|-----|---------------|-------------|
| compile.keyboard | None | The keyboard path (Example: `clueboard/66/rev4`) |
| compile.keymap | None | The keymap name (Example: `default`) |
| hello.name | None | The name to greet when run. |
| new_keyboard.keyboard | None | The keyboard path (Example: `clueboard/66/rev4`) |
| new_keyboard.keymap | None | The keymap name (Example: `default`) |

175
docs/cli_development.md Normal file
View file

@ -0,0 +1,175 @@
# QMK CLI Development
This document has useful information for developers wishing to write new `qmk` subcommands.
# Overview
The 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.
# Subcommands
[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.
Subcommands in the local CLI are always found in `qmk_firmware/lib/python/qmk/cli`.
Let's start by looking at an example subcommand. This is `lib/python/qmk/cli/hello.py`:
```python
"""QMK Python Hello World
This is an example QMK CLI script.
"""
from milc import cli
@cli.argument('-n', '--name', default='World', help='Name to greet.')
@cli.subcommand('QMK Hello World.')
def hello(cli):
"""Log a friendly greeting.
"""
cli.log.info('Hello, %s!', cli.config.hello.name)
```
First 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.
Once 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.
# User Interaction
MILC 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.
## Printing Text
There 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.
You 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.
Both 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.
You should never use the format operator (`%`) directly, always pass values as arguments.
### Logging (`cli.log`)
The `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.
The default log level is `INFO`. If the user runs `qmk -v <subcommand>` the default log level will be set to `DEBUG`.
| Function | Emoji |
|----------|-------|
| cli.log.critical | `{bg_red}{fg_white}¬_¬{style_reset_all}` |
| cli.log.error | `{fg_red}☒{style_reset_all}` |
| cli.log.warning | `{fg_yellow}⚠{style_reset_all}` |
| cli.log.info | `{fg_blue}Ψ{style_reset_all}` |
| cli.log.debug | `{fg_cyan}☐{style_reset_all}` |
| cli.log.notset | `{style_reset_all}¯\\_(o_o)_/¯` |
### Printing (`cli.echo`)
Sometimes 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`.
### Colorizing Text
You 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.
You 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.
Colors prefixed with 'fg' will affect the foreground (text) color. Colors prefixed with 'bg' will affect the background color.
| Color | Background | Extended Background | Foreground | Extended Foreground|
|-------|------------|---------------------|------------|--------------------|
| Black | {bg_black} | {bg_lightblack_ex} | {fg_black} | {fg_lightblack_ex} |
| Blue | {bg_blue} | {bg_lightblue_ex} | {fg_blue} | {fg_lightblue_ex} |
| Cyan | {bg_cyan} | {bg_lightcyan_ex} | {fg_cyan} | {fg_lightcyan_ex} |
| Green | {bg_green} | {bg_lightgreen_ex} | {fg_green} | {fg_lightgreen_ex} |
| Magenta | {bg_magenta} | {bg_lightmagenta_ex} | {fg_magenta} | {fg_lightmagenta_ex} |
| Red | {bg_red} | {bg_lightred_ex} | {fg_red} | {fg_lightred_ex} |
| White | {bg_white} | {bg_lightwhite_ex} | {fg_white} | {fg_lightwhite_ex} |
| Yellow | {bg_yellow} | {bg_lightyellow_ex} | {fg_yellow} | {fg_lightyellow_ex} |
There are also control sequences that can be used to change the behavior of
ANSI output:
| Control Sequences | Description |
|-------------------|-------------|
| {style_bright} | Make the text brighter |
| {style_dim} | Make the text dimmer |
| {style_normal} | Make the text normal (neither `{style_bright}` nor `{style_dim}`) |
| {style_reset_all} | Reset all text attributes to default. (This is automatically added to the end of every string.) |
| {bg_reset} | Reset the background color to the user's default |
| {fg_reset} | Reset the foreground color to the user's default |
# Arguments and Configuration
QMK 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>']`).
Under 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.
## Reading Configuration Values
You 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.
Iteration is also supported:
```
for section in cli.config:
for key in cli.config[section]:
cli.log.info('%s.%s: %s', section, key, cli.config[section][key])
```
## Setting Configuration Values
You can set configuration values in the usual ways.
Dictionary style:
```
cli.config['<section>']['<key>'] = <value>
```
Attribute style:
```
cli.config.<section>.<key> = <value>
```
## Deleting Configuration Values
You can delete configuration values in the usual ways.
Dictionary style:
```
del(cli.config['<section>']['<key>'])
```
Attribute style:
```
del(cli.config.<section>.<key>)
```
## Writing The Configuration File
The 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`.
You can use `cli.save_config()` to write out the configuration.
## Excluding Arguments From Configuration
Some arguments should not be propagated to the configuration file. These can be excluded by adding `arg_only=True` when creating the argument.
Example:
```
@cli.argument('-o', '--output', arg_only=True, help='File to write to')
@cli.argument('filename', arg_only=True, help='Configurator JSON file')
@cli.subcommand('Create a keymap.c from a QMK Configurator export.')
def json_keymap(cli):
pass
```
You will only be able to access these arguments using `cli.args`. For example:
```
cli.log.info('Reading from %s and writing to %s', cli.args.filename, cli.args.output)
```

View file

@ -1,45 +0,0 @@
# Python Development in QMK
This document gives an overview of how QMK has structured its python code. You should read this before working on any of the python code.
## Script directories
There 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`.
We 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.
## Python Modules
Most of the QMK python modules can be found in `qmk_firmware/lib/python`. This is the path that we append to `sys.path`.
We have a module hierarchy under that path:
* `qmk_firmware/lib/python`
* `milc.py` - The CLI library we use. Will be pulled out into its own module in the future.
* `qmk` - Code associated with QMK
* `cli` - Modules that will be imported for CLI commands.
* `errors.py` - Errors that can be raised within QMK apps
* `keymap.py` - Functions for working with keymaps
## CLI Scripts
We 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.
To 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.
When `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.
The module uses `@cli.entrypoint()` and `@cli.argument()` decorators to define an entrypoint, which is where execution starts.
## Example CLI Script
We 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.
```
from milc import cli
@cli.argument('-n', '--name', default='World', help='Name to greet.')
@cli.entrypoint('QMK Python Hello World.')
def main(cli):
cli.echo('Hello, %s!', cli.config.general.name)
```

View file

@ -43,6 +43,10 @@
{
"from": "unicode.html",
"to": "feature_unicode.html"
},
{
"from": "python_development.html",
"to": "cli_development.html"
}
]
}

View file

@ -17,6 +17,7 @@ import argparse
import logging
import os
import re
import shlex
import sys
from decimal import Decimal
from tempfile import NamedTemporaryFile
@ -35,6 +36,10 @@ except ImportError:
import argcomplete
import colorama
from appdirs import user_config_dir
# Disable logging until we can configure it how the user wants
logging.basicConfig(filename='/dev/null')
# Log Level Representations
EMOJI_LOGLEVELS = {
@ -47,6 +52,7 @@ EMOJI_LOGLEVELS = {
}
EMOJI_LOGLEVELS['FATAL'] = EMOJI_LOGLEVELS['CRITICAL']
EMOJI_LOGLEVELS['WARN'] = EMOJI_LOGLEVELS['WARNING']
UNICODE_SUPPORT = sys.stdout.encoding.lower().startswith('utf')
# ANSI Color setup
# Regex was gratefully borrowed from kfir on stackoverflow:
@ -97,11 +103,12 @@ class ANSIFormatter(logging.Formatter):
class ANSIEmojiLoglevelFormatter(ANSIFormatter):
"""A log formatter that makes the loglevel an emoji.
"""A log formatter that makes the loglevel an emoji on UTF capable terminals.
"""
def format(self, record):
record.levelname = EMOJI_LOGLEVELS[record.levelname].format(**ansi_colors)
if UNICODE_SUPPORT:
record.levelname = EMOJI_LOGLEVELS[record.levelname].format(**ansi_colors)
return super(ANSIEmojiLoglevelFormatter, self).format(record)
@ -144,13 +151,15 @@ class Configuration(object):
def __init__(self, *args, **kwargs):
self._config = {}
self.default_container = ConfigurationOption
def __getattr__(self, key):
return self.__getitem__(key)
def __getitem__(self, key):
"""Returns a config section, creating it if it doesn't exist yet.
"""
if key not in self._config:
self.__dict__[key] = self._config[key] = ConfigurationOption()
self.__dict__[key] = self._config[key] = ConfigurationSection(self)
return self._config[key]
@ -161,30 +170,34 @@ class Configuration(object):
def __delitem__(self, key):
if key in self.__dict__ and key[0] != '_':
del self.__dict__[key]
del self._config[key]
if key in self._config:
del self._config[key]
class ConfigurationOption(Configuration):
def __init__(self, *args, **kwargs):
super(ConfigurationOption, self).__init__(*args, **kwargs)
self.default_container = dict
class ConfigurationSection(Configuration):
def __init__(self, parent, *args, **kwargs):
super(ConfigurationSection, self).__init__(*args, **kwargs)
self.parent = parent
def __getitem__(self, key):
"""Returns a config section, creating it if it doesn't exist yet.
"""Returns a config value, pulling from the `user` section as a fallback.
"""
if key not in self._config:
self.__dict__[key] = self._config[key] = None
if key in self._config:
return self._config[key]
return self._config[key]
elif key in self.parent.user:
return self.parent.user[key]
return None
def handle_store_boolean(self, *args, **kwargs):
"""Does the add_argument for action='store_boolean'.
"""
kwargs['add_dest'] = False
disabled_args = None
disabled_kwargs = kwargs.copy()
disabled_kwargs['action'] = 'store_false'
disabled_kwargs['dest'] = self.get_argument_name(*args, **kwargs)
disabled_kwargs['help'] = 'Disable ' + kwargs['help']
kwargs['action'] = 'store_true'
kwargs['help'] = 'Enable ' + kwargs['help']
@ -219,11 +232,6 @@ class SubparserWrapper(object):
self.subparser.completer = completer
def add_argument(self, *args, **kwargs):
if kwargs.get('add_dest', True):
kwargs['dest'] = self.submodule + '_' + self.cli.get_argument_name(*args, **kwargs)
if 'add_dest' in kwargs:
del kwargs['add_dest']
if 'action' in kwargs and kwargs['action'] == 'store_boolean':
return handle_store_boolean(self, *args, **kwargs)
@ -254,12 +262,16 @@ class MILC(object):
self._entrypoint = None
self._inside_context_manager = False
self.ansi = ansi_colors
self.arg_only = []
self.config = Configuration()
self.config_file = None
self.prog_name = sys.argv[0][:-3] if sys.argv[0].endswith('.py') else sys.argv[0]
self.version = os.environ.get('QMK_VERSION', 'unknown')
self.release_lock()
# Figure out our program name
self.prog_name = sys.argv[0][:-3] if sys.argv[0].endswith('.py') else sys.argv[0]
self.prog_name = self.prog_name.split('/')[-1]
# Initialize all the things
self.initialize_argparse()
self.initialize_logging()
@ -273,7 +285,7 @@ class MILC(object):
self._description = self._arg_parser.description = self._arg_defaults.description = value
def echo(self, text, *args, **kwargs):
"""Print colorized text to stdout, as long as stdout is a tty.
"""Print colorized text to stdout.
ANSI color strings (such as {fg-blue}) will be converted into ANSI
escape sequences, and the ANSI reset sequence will be added to all
@ -284,11 +296,10 @@ class MILC(object):
if args and kwargs:
raise RuntimeError('You can only specify *args or **kwargs, not both!')
if sys.stdout.isatty():
args = args or kwargs
text = format_ansi(text)
args = args or kwargs
text = format_ansi(text)
print(text % args)
print(text % args)
def initialize_argparse(self):
"""Prepare to process arguments from sys.argv.
@ -313,21 +324,21 @@ class MILC(object):
self.release_lock()
def completer(self, completer):
"""Add an arpcomplete completer to this subcommand.
"""Add an argcomplete completer to this subcommand.
"""
self._arg_parser.completer = completer
def add_argument(self, *args, **kwargs):
"""Wrapper to add arguments to both the main and the shadow argparser.
"""
if 'action' in kwargs and kwargs['action'] == 'store_boolean':
return handle_store_boolean(self, *args, **kwargs)
if kwargs.get('add_dest', True) and args[0][0] == '-':
kwargs['dest'] = 'general_' + self.get_argument_name(*args, **kwargs)
if 'add_dest' in kwargs:
del kwargs['add_dest']
if 'action' in kwargs and kwargs['action'] == 'store_boolean':
return handle_store_boolean(self, *args, **kwargs)
self.acquire_lock()
self._arg_parser.add_argument(*args, **kwargs)
@ -396,7 +407,7 @@ class MILC(object):
if self.args and self.args.general_config_file:
return self.args.general_config_file
return os.path.abspath(os.path.expanduser('~/.%s.ini' % self.prog_name))
return os.path.join(user_config_dir(appname='qmk', appauthor='QMK'), '%s.ini' % self.prog_name)
def get_argument_name(self, *args, **kwargs):
"""Takes argparse arguments and returns the dest name.
@ -413,6 +424,11 @@ class MILC(object):
raise RuntimeError('You must run this before the with statement!')
def argument_function(handler):
if 'arg_only' in kwargs and kwargs['arg_only']:
arg_name = self.get_argument_name(*args, **kwargs)
self.arg_only.append(arg_name)
del kwargs['arg_only']
if handler is self._entrypoint:
self.add_argument(*args, **kwargs)
@ -485,15 +501,20 @@ class MILC(object):
if argument in ('subparsers', 'entrypoint'):
continue
if '_' not in argument:
continue
section, option = argument.split('_', 1)
if hasattr(self.args_passed, argument):
self.config[section][option] = getattr(self.args, argument)
if '_' in argument:
section, option = argument.split('_', 1)
else:
if option not in self.config[section]:
self.config[section][option] = getattr(self.args, argument)
section = self._entrypoint.__name__
option = argument
if option not in self.arg_only:
if hasattr(self.args_passed, argument):
arg_value = getattr(self.args, argument)
if arg_value:
self.config[section][option] = arg_value
else:
if option not in self.config[section]:
self.config[section][option] = getattr(self.args, argument)
self.release_lock()
@ -509,6 +530,8 @@ class MILC(object):
self.acquire_lock()
config = RawConfigParser()
config_dir = os.path.dirname(self.config_file)
for section_name, section in self.config._config.items():
config.add_section(section_name)
for option_name, value in section.items():
@ -517,7 +540,10 @@ class MILC(object):
continue
config.set(section_name, option_name, str(value))
with NamedTemporaryFile(mode='w', dir=os.path.dirname(self.config_file), delete=False) as tmpfile:
if not os.path.exists(config_dir):
os.makedirs(config_dir)
with NamedTemporaryFile(mode='w', dir=config_dir, delete=False) as tmpfile:
config.write(tmpfile)
# Move the new config file into place atomically
@ -527,6 +553,7 @@ class MILC(object):
self.log.warning('Config file saving failed, not replacing %s with %s.', self.config_file, tmpfile.name)
self.release_lock()
cli.log.info('Wrote configuration to %s', shlex.quote(self.config_file))
def __call__(self):
"""Execute the entrypoint function.
@ -602,8 +629,8 @@ class MILC(object):
"""Called by __enter__() to setup the logging configuration.
"""
if len(logging.root.handlers) != 0:
# This is not a design decision. This is what I'm doing for now until I can examine and think about this situation in more detail.
raise RuntimeError('MILC should be the only system installing root log handlers!')
# MILC is the only thing that should have root log handlers
logging.root.handlers = []
self.acquire_lock()
@ -648,8 +675,9 @@ class MILC(object):
self.read_config()
self.setup_logging()
if self.config.general.save_config:
if 'save_config' in self.config.general and self.config.general.save_config:
self.save_config()
exit(0)
return self
@ -712,4 +740,3 @@ if __name__ == '__main__':
cli.goodbye.add_argument('-n', '--name', help='Name to bid farewell to', default='World')
cli() # Automatically picks between main(), hello() and goodbye()
print(sorted(ansi_colors.keys()))

View file

@ -0,0 +1,13 @@
"""QMK CLI Subcommands
We list each subcommand here explicitly because all the reliable ways of searching for modules are slow and delay startup.
"""
from . import cformat
from . import compile
from . import config
from . import doctor
from . import hello
from . import json
from . import new
from . import pyformat
from . import pytest

View file

@ -6,9 +6,9 @@ import subprocess
from milc import cli
@cli.argument('files', nargs='*', help='Filename(s) to format.')
@cli.entrypoint("Format C code according to QMK's style.")
def main(cli):
@cli.argument('files', nargs='*', arg_only=True, help='Filename(s) to format.')
@cli.subcommand("Format C code according to QMK's style.")
def cformat(cli):
"""Format C code according to QMK's style.
"""
clang_format = ['clang-format', '-i']

View file

@ -14,11 +14,11 @@ import qmk.keymap
import qmk.path
@cli.argument('filename', nargs='?', type=FileType('r'), help='The configurator export to compile')
@cli.argument('filename', nargs='?', arg_only=True, type=FileType('r'), help='The configurator export to compile')
@cli.argument('-kb', '--keyboard', help='The keyboard to build a firmware for. Ignored when a configurator export is supplied.')
@cli.argument('-km', '--keymap', help='The keymap to build a firmware for. Ignored when a configurator export is supplied.')
@cli.entrypoint('Compile a QMK Firmware.')
def main(cli):
@cli.subcommand('Compile a QMK Firmware.')
def compile(cli):
"""Compile a QMK Firmware.
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):
# Compile the keymap
command = ['make', ':'.join((user_keymap['keyboard'], user_keymap['keymap']))]
elif cli.config.general.keyboard and cli.config.general.keymap:
elif cli.config.compile.keyboard and cli.config.compile.keymap:
# Generate the make command for a specific keyboard/keymap.
command = ['make', ':'.join((cli.config.general.keyboard, cli.config.general.keymap))]
command = ['make', ':'.join((cli.config.compile.keyboard, cli.config.compile.keymap))]
else:
cli.log.error('You must supply a configurator export or both `--keyboard` and `--keymap`.')

View file

@ -0,0 +1,96 @@
"""Read and write configuration settings
"""
import os
import subprocess
from milc import cli
def print_config(section, key):
"""Print a single config setting to stdout.
"""
cli.echo('%s.%s{fg_cyan}={fg_reset}%s', section, key, cli.config[section][key])
@cli.argument('-ro', '--read-only', action='store_true', help='Operate in read-only mode.')
@cli.argument('configs', nargs='*', arg_only=True, help='Configuration options to read or write.')
@cli.subcommand("Read and write configuration settings.")
def config(cli):
"""Read and write config settings.
This script iterates over the config_tokens supplied as argument. Each config_token has the following form:
section[.key][=value]
If only a section (EG 'compile') is supplied all keys for that section will be displayed.
If section.key is supplied the value for that single key will be displayed.
If section.key=value is supplied the value for that single key will be set.
If section.key=None is supplied the key will be deleted.
No validation is done to ensure that the supplied section.key is actually used by qmk scripts.
"""
if not cli.args.configs:
# Walk the config tree
for section in cli.config:
for key in cli.config[section]:
print_config(section, key)
return True
# Process config_tokens
save_config = False
for argument in cli.args.configs:
# Split on space in case they quoted multiple config tokens
for config_token in argument.split(' '):
# Extract the section, config_key, and value to write from the supplied config_token.
if '=' in config_token:
key, value = config_token.split('=')
else:
key = config_token
value = None
if '.' in key:
section, config_key = key.split('.', 1)
else:
section = key
config_key = None
# Validation
if config_key and '.' in config_key:
cli.log.error('Config keys may not have more than one period! "%s" is not valid.', key)
return False
# Do what the user wants
if section and config_key and value:
# Write a config key
log_string = '%s.%s{fg_cyan}:{fg_reset} %s {fg_cyan}->{fg_reset} %s'
if cli.args.read_only:
log_string += ' {fg_red}(change not written)'
cli.echo(log_string, section, config_key, cli.config[section][config_key], value)
if not cli.args.read_only:
if value == 'None':
del cli.config[section][config_key]
else:
cli.config[section][config_key] = value
save_config = True
elif section and config_key:
# Display a single key
print_config(section, config_key)
elif section:
# Display an entire section
for key in cli.config[section]:
print_config(section, key)
# Ending actions
if save_config:
cli.save_config()
return True

View file

@ -11,8 +11,8 @@ from glob import glob
from milc import cli
@cli.entrypoint('Basic QMK environment checks')
def main(cli):
@cli.subcommand('Basic QMK environment checks')
def doctor(cli):
"""Basic QMK environment checks.
This is currently very simple, it just checks that all the expected binaries are on your system.
@ -36,6 +36,7 @@ def main(cli):
else:
try:
subprocess.run([binary, '--version'], stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=5, check=True)
cli.log.info('Found {fg_cyan}%s', binary)
except subprocess.CalledProcessError:
cli.log.error("{fg_red}Can't run `%s --version`", binary)
ok = False

View file

@ -6,8 +6,8 @@ from milc import cli
@cli.argument('-n', '--name', default='World', help='Name to greet.')
@cli.entrypoint('QMK Hello World.')
def main(cli):
@cli.subcommand('QMK Hello World.')
def hello(cli):
"""Log a friendly greeting.
"""
cli.log.info('Hello, %s!', cli.config.general.name)
cli.log.info('Hello, %s!', cli.config.hello.name)

View file

@ -0,0 +1,5 @@
"""QMK CLI JSON Subcommands
We list each subcommand here explicitly because all the reliable ways of searching for modules are slow and delay startup.
"""
from . import keymap

View file

@ -9,10 +9,10 @@ from milc import cli
import qmk.keymap
@cli.argument('-o', '--output', help='File to write to')
@cli.argument('filename', help='Configurator JSON file')
@cli.entrypoint('Create a keymap.c from a QMK Configurator export.')
def main(cli):
@cli.argument('-o', '--output', arg_only=True, help='File to write to')
@cli.argument('filename', arg_only=True, help='Configurator JSON file')
@cli.subcommand('Create a keymap.c from a QMK Configurator export.')
def json_keymap(cli):
"""Generate a keymap.c from a configurator export.
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):
exit(1)
# Environment processing
if cli.config.general.output == ('-'):
cli.config.general.output = None
if cli.args.output == ('-'):
cli.args.output = None
# Parse the configurator json
with open(qmk.path.normpath(cli.args.filename), 'r') as fd:
@ -38,17 +38,17 @@ def main(cli):
# Generate the keymap
keymap_c = qmk.keymap.generate(user_keymap['keyboard'], user_keymap['layout'], user_keymap['layers'])
if cli.config.general.output:
output_dir = os.path.dirname(cli.config.general.output)
if cli.args.output:
output_dir = os.path.dirname(cli.args.output)
if not os.path.exists(output_dir):
os.makedirs(output_dir)
output_file = qmk.path.normpath(cli.config.general.output)
output_file = qmk.path.normpath(cli.args.output)
with open(output_file, 'w') as keymap_fd:
keymap_fd.write(keymap_c)
cli.log.info('Wrote keymap to %s.', cli.config.general.output)
cli.log.info('Wrote keymap to %s.', cli.args.output)
else:
print(keymap_c)

View file

@ -0,0 +1 @@
from . import keymap

View file

@ -6,15 +6,15 @@ import shutil
from milc import cli
@cli.argument('-k', '--keyboard', help='Specify keyboard name. Example: 1upkeyboards/1up60hse')
@cli.argument('-u', '--username', help='Specify any name for the new keymap directory')
@cli.entrypoint('Creates a new keymap for the keyboard of your choosing')
def main(cli):
@cli.argument('-kb', '--keyboard', help='Specify keyboard name. Example: 1upkeyboards/1up60hse')
@cli.argument('-km', '--keymap', help='Specify the name for the new keymap directory')
@cli.subcommand('Creates a new keymap for the keyboard of your choosing')
def new_keymap(cli):
"""Creates a new keymap for the keyboard of your choosing.
"""
# ask for user input if keyboard or username was not provided in the command line
keyboard = cli.config.general.keyboard if cli.config.general.keyboard else input("Keyboard Name: ")
username = cli.config.general.username if cli.config.general.username else input("Username: ")
keyboard = cli.config.new_keymap.keyboard if cli.config.new_keymap.keyboard else input("Keyboard Name: ")
keymap = cli.config.new_keymap.keymap if cli.config.new_keymap.keymap else input("Keymap Name: ")
# generate keymap paths
kb_path = os.path.join(os.getcwd(), "keyboards", keyboard)
@ -36,6 +36,5 @@ def main(cli):
shutil.copytree(keymap_path_default, keymap_path, symlinks=True)
# end message to user
cli.log.info("%s keymap directory created in: %s\n" +
"Compile a firmware file with your new keymap by typing: \n" +
"qmk compile -kb %s -km %s", username, keymap_path, keyboard, username)
cli.log.info("%s keymap directory created in: %s", username, keymap_path)
cli.log.info("Compile a firmware with your new keymap by typing: \n" + "qmk compile -kb %s -km %s", keyboard, username)

View file

@ -5,12 +5,13 @@ from milc import cli
import subprocess
@cli.entrypoint("Format python code according to QMK's style.")
def main(cli):
@cli.subcommand("Format python code according to QMK's style.")
def pyformat(cli):
"""Format python code according to QMK's style.
"""
try:
subprocess.run(['yapf', '-vv', '-ri', 'bin/qmk', 'lib/python'], check=True)
cli.log.info('Successfully formatted the python code in `bin/qmk` and `lib/python`.')
except subprocess.CalledProcessError:
cli.log.error('Error formatting python code!')

View file

@ -2,17 +2,19 @@
QMK script to run unit and integration tests against our python code.
"""
import sys
from milc import cli
@cli.entrypoint('QMK Python Unit Tests')
def main(cli):
@cli.subcommand('QMK Python Unit Tests')
def pytest(cli):
"""Use nose2 to run unittests
"""
try:
import nose2
except ImportError:
cli.log.error('Could not import nose2! Please install it with {fg_cyan}pip3 install nose2')
return False
nose2.discover()
nose2.discover(argv=['nose2', '-v'])

View file

@ -2,6 +2,7 @@
"""
import logging
import os
from pkgutil import walk_packages
from qmk.errors import NoSuchKeyboardError

View file

@ -0,0 +1,39 @@
import subprocess
def check_subcommand(command, *args):
cmd = ['bin/qmk', command] + list(args)
return subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, universal_newlines=True)
def test_cformat():
assert check_subcommand('cformat', 'tmk_core/common/backlight.c').returncode == 0
def test_compile():
assert check_subcommand('compile', '-kb', 'handwired/onekey/pytest', '-km', 'default').returncode == 0
def test_config():
result = check_subcommand('config')
assert result.returncode == 0
assert 'general.color' in result.stdout
def test_doctor():
result = check_subcommand('doctor')
assert result.returncode == 0
assert 'QMK Doctor is checking your environment.' in result.stderr
assert 'QMK is ready to go' in result.stderr
def test_hello():
result = check_subcommand('hello')
assert result.returncode == 0
assert 'Hello,' in result.stderr
def test_pyformat():
result = check_subcommand('pyformat')
assert result.returncode == 0
assert 'Successfully formatted the python code' in result.stderr

View file

@ -1,5 +1,5 @@
# Python requirements
# milc FIXME(skullydazed): Included in the repo for now.
appdirs
argcomplete
colorama
#halo

View file

@ -18,11 +18,12 @@ if [[ "$TRAVIS_COMMIT_MESSAGE" != *"[skip build]"* ]] ; then
exit_code=0
git diff --name-only -n 1 ${TRAVIS_COMMIT_RANGE}
if [ $? -eq 128 ]; then
echo "Making default keymaps for all keyboards"
# We don't know what changed so just build the default keymaps
echo "Making default keymaps for all keyboards (fallback)"
eval $MAKE_ALL
: $((exit_code = $exit_code + $?))
else
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)
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)
BRANCH=$(git rev-parse --abbrev-ref HEAD)
# is this branch master or a "non docs, non keyboards" change
if [ $NEFM -gt 0 -o "$BRANCH" = "master" ]; then
@ -56,7 +57,7 @@ if [[ "$TRAVIS_COMMIT_MESSAGE" != *"[skip build]"* ]] ; then
if [ $PFM -gt 0 -o "$BRANCH" = "master" ]; then
echo
echo "Running python tests."
docker run --rm -w /qmk_firmware/ -v "$PWD":/qmk_firmware --user $(id -u):$(id -g) qmkfm/base_container bin/qmk nose2
docker run --rm -w /qmk_firmware/ -v "$PWD":/qmk_firmware --user $(id -u):$(id -g) qmkfm/base_container 'bin/qmk pytest'
: $((exit_code = $exit_code + $?))
fi
fi