aboutsummaryrefslogtreecommitdiff
path: root/lib/python/qmk
diff options
context:
space:
mode:
Diffstat (limited to 'lib/python/qmk')
-rw-r--r--lib/python/qmk/cli/__init__.py2
-rw-r--r--lib/python/qmk/cli/console.py303
-rw-r--r--lib/python/qmk/cli/doctor/check.py1
-rwxr-xr-xlib/python/qmk/cli/format/python.py6
-rwxr-xr-xlib/python/qmk/cli/generate/compilation_database.py133
-rw-r--r--lib/python/qmk/cli/lint.py145
-rw-r--r--lib/python/qmk/cli/pytest.py2
-rw-r--r--lib/python/qmk/commands.py16
-rw-r--r--lib/python/qmk/tests/test_cli_commands.py2
9 files changed, 251 insertions, 359 deletions
diff --git a/lib/python/qmk/cli/__init__.py b/lib/python/qmk/cli/__init__.py
index f45e33240..944938824 100644
--- a/lib/python/qmk/cli/__init__.py
+++ b/lib/python/qmk/cli/__init__.py
@@ -35,7 +35,6 @@ subcommands = [
35 'qmk.cli.chibios.confmigrate', 35 'qmk.cli.chibios.confmigrate',
36 'qmk.cli.clean', 36 'qmk.cli.clean',
37 'qmk.cli.compile', 37 'qmk.cli.compile',
38 'qmk.cli.console',
39 'qmk.cli.docs', 38 'qmk.cli.docs',
40 'qmk.cli.doctor', 39 'qmk.cli.doctor',
41 'qmk.cli.fileformat', 40 'qmk.cli.fileformat',
@@ -45,6 +44,7 @@ subcommands = [
45 'qmk.cli.format.python', 44 'qmk.cli.format.python',
46 'qmk.cli.format.text', 45 'qmk.cli.format.text',
47 'qmk.cli.generate.api', 46 'qmk.cli.generate.api',
47 'qmk.cli.generate.compilation_database',
48 'qmk.cli.generate.config_h', 48 'qmk.cli.generate.config_h',
49 'qmk.cli.generate.dfu_header', 49 'qmk.cli.generate.dfu_header',
50 'qmk.cli.generate.docs', 50 'qmk.cli.generate.docs',
diff --git a/lib/python/qmk/cli/console.py b/lib/python/qmk/cli/console.py
deleted file mode 100644
index 98c6bc0dc..000000000
--- a/lib/python/qmk/cli/console.py
+++ /dev/null
@@ -1,303 +0,0 @@
1"""Acquire debugging information from usb hid devices
2
3cli implementation of https://www.pjrc.com/teensy/hid_listen.html
4"""
5from pathlib import Path
6from threading import Thread
7from time import sleep, strftime
8
9import hid
10import usb.core
11
12from milc import cli
13
14LOG_COLOR = {
15 'next': 0,
16 'colors': [
17 '{fg_blue}',
18 '{fg_cyan}',
19 '{fg_green}',
20 '{fg_magenta}',
21 '{fg_red}',
22 '{fg_yellow}',
23 ],
24}
25
26KNOWN_BOOTLOADERS = {
27 # VID , PID
28 ('03EB', '2FEF'): 'atmel-dfu: ATmega16U2',
29 ('03EB', '2FF0'): 'atmel-dfu: ATmega32U2',
30 ('03EB', '2FF3'): 'atmel-dfu: ATmega16U4',
31 ('03EB', '2FF4'): 'atmel-dfu: ATmega32U4',
32 ('03EB', '2FF9'): 'atmel-dfu: AT90USB64',
33 ('03EB', '2FFA'): 'atmel-dfu: AT90USB162',
34 ('03EB', '2FFB'): 'atmel-dfu: AT90USB128',
35 ('03EB', '6124'): 'Microchip SAM-BA',
36 ('0483', 'DF11'): 'stm32-dfu: STM32 BOOTLOADER',
37 ('16C0', '05DC'): 'usbasploader: USBaspLoader',
38 ('16C0', '05DF'): 'bootloadhid: HIDBoot',
39 ('16C0', '0478'): 'halfkay: Teensy Halfkay',
40 ('1B4F', '9203'): 'caterina: Pro Micro 3.3V',
41 ('1B4F', '9205'): 'caterina: Pro Micro 5V',
42 ('1B4F', '9207'): 'caterina: LilyPadUSB',
43 ('1C11', 'B007'): 'kiibohd: Kiibohd DFU Bootloader',
44 ('1EAF', '0003'): 'stm32duino: Maple 003',
45 ('1FFB', '0101'): 'caterina: Polou A-Star 32U4 Bootloader',
46 ('2341', '0036'): 'caterina: Arduino Leonardo',
47 ('2341', '0037'): 'caterina: Arduino Micro',
48 ('239A', '000C'): 'caterina: Adafruit Feather 32U4',
49 ('239A', '000D'): 'caterina: Adafruit ItsyBitsy 32U4 3v',
50 ('239A', '000E'): 'caterina: Adafruit ItsyBitsy 32U4 5v',
51 ('2A03', '0036'): 'caterina: Arduino Leonardo',
52 ('2A03', '0037'): 'caterina: Arduino Micro',
53 ('314B', '0106'): 'apm32-dfu: APM32 DFU ISP Mode',
54 ('03EB', '2067'): 'qmk-hid: HID Bootloader',
55 ('03EB', '2045'): 'lufa-ms: LUFA Mass Storage Bootloader'
56}
57
58
59class MonitorDevice(object):
60 def __init__(self, hid_device, numeric):
61 self.hid_device = hid_device
62 self.numeric = numeric
63 self.device = hid.Device(path=hid_device['path'])
64 self.current_line = ''
65
66 cli.log.info('Console Connected: %(color)s%(manufacturer_string)s %(product_string)s{style_reset_all} (%(color)s%(vendor_id)04X:%(product_id)04X:%(index)d{style_reset_all})', hid_device)
67
68 def read(self, size, encoding='ascii', timeout=1):
69 """Read size bytes from the device.
70 """
71 return self.device.read(size, timeout).decode(encoding)
72
73 def read_line(self):
74 """Read from the device's console until we get a \n.
75 """
76 while '\n' not in self.current_line:
77 self.current_line += self.read(32).replace('\x00', '')
78
79 lines = self.current_line.split('\n', 1)
80 self.current_line = lines[1]
81
82 return lines[0]
83
84 def run_forever(self):
85 while True:
86 try:
87 message = {**self.hid_device, 'text': self.read_line()}
88 identifier = (int2hex(message['vendor_id']), int2hex(message['product_id'])) if self.numeric else (message['manufacturer_string'], message['product_string'])
89 message['identifier'] = ':'.join(identifier)
90 message['ts'] = '{style_dim}{fg_green}%s{style_reset_all} ' % (strftime(cli.config.general.datetime_fmt),) if cli.args.timestamp else ''
91
92 cli.echo('%(ts)s%(color)s%(identifier)s:%(index)d{style_reset_all}: %(text)s' % message)
93
94 except hid.HIDException:
95 break
96
97
98class FindDevices(object):
99 def __init__(self, vid, pid, index, numeric):
100 self.vid = vid
101 self.pid = pid
102 self.index = index
103 self.numeric = numeric
104
105 def run_forever(self):
106 """Process messages from our queue in a loop.
107 """
108 live_devices = {}
109 live_bootloaders = {}
110
111 while True:
112 try:
113 for device in list(live_devices):
114 if not live_devices[device]['thread'].is_alive():
115 cli.log.info('Console Disconnected: %(color)s%(manufacturer_string)s %(product_string)s{style_reset_all} (%(color)s%(vendor_id)04X:%(product_id)04X:%(index)d{style_reset_all})', live_devices[device])
116 del live_devices[device]
117
118 for device in self.find_devices():
119 if device['path'] not in live_devices:
120 device['color'] = LOG_COLOR['colors'][LOG_COLOR['next']]
121 LOG_COLOR['next'] = (LOG_COLOR['next'] + 1) % len(LOG_COLOR['colors'])
122 live_devices[device['path']] = device
123
124 try:
125 monitor = MonitorDevice(device, self.numeric)
126 device['thread'] = Thread(target=monitor.run_forever, daemon=True)
127
128 device['thread'].start()
129 except Exception as e:
130 device['e'] = e
131 device['e_name'] = e.__class__.__name__
132 cli.log.error("Could not connect to %(color)s%(manufacturer_string)s %(product_string)s{style_reset_all} (%(color)s:%(vendor_id)04X:%(product_id)04X:%(index)d): %(e_name)s: %(e)s", device)
133 if cli.config.general.verbose:
134 cli.log.exception(e)
135 del live_devices[device['path']]
136
137 if cli.args.bootloaders:
138 for device in self.find_bootloaders():
139 if device.address in live_bootloaders:
140 live_bootloaders[device.address]._qmk_found = True
141 else:
142 name = KNOWN_BOOTLOADERS[(int2hex(device.idVendor), int2hex(device.idProduct))]
143 cli.log.info('Bootloader Connected: {style_bright}{fg_magenta}%s', name)
144 device._qmk_found = True
145 live_bootloaders[device.address] = device
146
147 for device in list(live_bootloaders):
148 if live_bootloaders[device]._qmk_found:
149 live_bootloaders[device]._qmk_found = False
150 else:
151 name = KNOWN_BOOTLOADERS[(int2hex(live_bootloaders[device].idVendor), int2hex(live_bootloaders[device].idProduct))]
152 cli.log.info('Bootloader Disconnected: {style_bright}{fg_magenta}%s', name)
153 del live_bootloaders[device]
154
155 sleep(.1)
156
157 except KeyboardInterrupt:
158 break
159
160 def is_bootloader(self, hid_device):
161 """Returns true if the device in question matches a known bootloader vid/pid.
162 """
163 return (int2hex(hid_device.idVendor), int2hex(hid_device.idProduct)) in KNOWN_BOOTLOADERS
164
165 def is_console_hid(self, hid_device):
166 """Returns true when the usage page indicates it's a teensy-style console.
167 """
168 return hid_device['usage_page'] == 0xFF31 and hid_device['usage'] == 0x0074
169
170 def is_filtered_device(self, hid_device):
171 """Returns True if the device should be included in the list of available consoles.
172 """
173 return int2hex(hid_device['vendor_id']) == self.vid and int2hex(hid_device['product_id']) == self.pid
174
175 def find_devices_by_report(self, hid_devices):
176 """Returns a list of available teensy-style consoles by doing a brute-force search.
177
178 Some versions of linux don't report usage and usage_page. In that case we fallback to reading the report (possibly inaccurately) ourselves.
179 """
180 devices = []
181
182 for device in hid_devices:
183 path = device['path'].decode('utf-8')
184
185 if path.startswith('/dev/hidraw'):
186 number = path[11:]
187 report = Path(f'/sys/class/hidraw/hidraw{number}/device/report_descriptor')
188
189 if report.exists():
190 rp = report.read_bytes()
191
192 if rp[1] == 0x31 and rp[3] == 0x09:
193 devices.append(device)
194
195 return devices
196
197 def find_bootloaders(self):
198 """Returns a list of available bootloader devices.
199 """
200 return list(filter(self.is_bootloader, usb.core.find(find_all=True)))
201
202 def find_devices(self):
203 """Returns a list of available teensy-style consoles.
204 """
205 hid_devices = hid.enumerate()
206 devices = list(filter(self.is_console_hid, hid_devices))
207
208 if not devices:
209 devices = self.find_devices_by_report(hid_devices)
210
211 if self.vid and self.pid:
212 devices = list(filter(self.is_filtered_device, devices))
213
214 # Add index numbers
215 device_index = {}
216 for device in devices:
217 id = ':'.join((int2hex(device['vendor_id']), int2hex(device['product_id'])))
218
219 if id not in device_index:
220 device_index[id] = 0
221
222 device_index[id] += 1
223 device['index'] = device_index[id]
224
225 return devices
226
227
228def int2hex(number):
229 """Returns a string representation of the number as hex.
230 """
231 return "%04X" % number
232
233
234def list_devices(device_finder):
235 """Show the user a nicely formatted list of devices.
236 """
237 devices = device_finder.find_devices()
238
239 if devices:
240 cli.log.info('Available devices:')
241 for dev in devices:
242 color = LOG_COLOR['colors'][LOG_COLOR['next']]
243 LOG_COLOR['next'] = (LOG_COLOR['next'] + 1) % len(LOG_COLOR['colors'])
244 cli.log.info("\t%s%s:%s:%d{style_reset_all}\t%s %s", color, int2hex(dev['vendor_id']), int2hex(dev['product_id']), dev['index'], dev['manufacturer_string'], dev['product_string'])
245
246 if cli.args.bootloaders:
247 bootloaders = device_finder.find_bootloaders()
248
249 if bootloaders:
250 cli.log.info('Available Bootloaders:')
251
252 for dev in bootloaders:
253 cli.log.info("\t%s:%s\t%s", int2hex(dev.idVendor), int2hex(dev.idProduct), KNOWN_BOOTLOADERS[(int2hex(dev.idVendor), int2hex(dev.idProduct))])
254
255
256@cli.argument('--bootloaders', arg_only=True, default=True, action='store_boolean', help='displaying bootloaders.')
257@cli.argument('-d', '--device', help='Device to select - uses format <pid>:<vid>[:<index>].')
258@cli.argument('-l', '--list', arg_only=True, action='store_true', help='List available hid_listen devices.')
259@cli.argument('-n', '--numeric', arg_only=True, action='store_true', help='Show VID/PID instead of names.')
260@cli.argument('-t', '--timestamp', arg_only=True, action='store_true', help='Print the timestamp for received messages as well.')
261@cli.argument('-w', '--wait', type=int, default=1, help="How many seconds to wait between checks (Default: 1)")
262@cli.subcommand('Acquire debugging information from usb hid devices.', hidden=False if cli.config.user.developer else True)
263def console(cli):
264 """Acquire debugging information from usb hid devices
265 """
266 vid = None
267 pid = None
268 index = 1
269
270 if cli.config.console.device:
271 device = cli.config.console.device.split(':')
272
273 if len(device) == 2:
274 vid, pid = device
275
276 elif len(device) == 3:
277 vid, pid, index = device
278
279 if not index.isdigit():
280 cli.log.error('Device index must be a number! Got "%s" instead.', index)
281 exit(1)
282
283 index = int(index)
284
285 if index < 1:
286 cli.log.error('Device index must be greater than 0! Got %s', index)
287 exit(1)
288
289 else:
290 cli.log.error('Invalid format for device, expected "<pid>:<vid>[:<index>]" but got "%s".', cli.config.console.device)
291 cli.print_help()
292 exit(1)
293
294 vid = vid.upper()
295 pid = pid.upper()
296
297 device_finder = FindDevices(vid, pid, index, cli.args.numeric)
298
299 if cli.args.list:
300 return list_devices(device_finder)
301
302 print('Looking for devices...', flush=True)
303 device_finder.run_forever()
diff --git a/lib/python/qmk/cli/doctor/check.py b/lib/python/qmk/cli/doctor/check.py
index 0807f4151..2d691b64b 100644
--- a/lib/python/qmk/cli/doctor/check.py
+++ b/lib/python/qmk/cli/doctor/check.py
@@ -26,7 +26,6 @@ ESSENTIAL_BINARIES = {
26 'arm-none-eabi-gcc': { 26 'arm-none-eabi-gcc': {
27 'version_arg': '-dumpversion' 27 'version_arg': '-dumpversion'
28 }, 28 },
29 'bin/qmk': {},
30} 29}
31 30
32 31
diff --git a/lib/python/qmk/cli/format/python.py b/lib/python/qmk/cli/format/python.py
index 00612f97e..b32a72640 100755
--- a/lib/python/qmk/cli/format/python.py
+++ b/lib/python/qmk/cli/format/python.py
@@ -11,15 +11,15 @@ def format_python(cli):
11 """Format python code according to QMK's style. 11 """Format python code according to QMK's style.
12 """ 12 """
13 edit = '--diff' if cli.args.dry_run else '--in-place' 13 edit = '--diff' if cli.args.dry_run else '--in-place'
14 yapf_cmd = ['yapf', '-vv', '--recursive', edit, 'bin/qmk', 'lib/python'] 14 yapf_cmd = ['yapf', '-vv', '--recursive', edit, 'lib/python']
15 try: 15 try:
16 cli.run(yapf_cmd, check=True, capture_output=False, stdin=DEVNULL) 16 cli.run(yapf_cmd, check=True, capture_output=False, stdin=DEVNULL)
17 cli.log.info('Python code in `bin/qmk` and `lib/python` is correctly formatted.') 17 cli.log.info('Python code in `lib/python` is correctly formatted.')
18 return True 18 return True
19 19
20 except CalledProcessError: 20 except CalledProcessError:
21 if cli.args.dry_run: 21 if cli.args.dry_run:
22 cli.log.error('Python code in `bin/qmk` and `lib/python` incorrectly formatted!') 22 cli.log.error('Python code in `lib/python` is incorrectly formatted!')
23 else: 23 else:
24 cli.log.error('Error formatting python code!') 24 cli.log.error('Error formatting python code!')
25 25
diff --git a/lib/python/qmk/cli/generate/compilation_database.py b/lib/python/qmk/cli/generate/compilation_database.py
new file mode 100755
index 000000000..602635270
--- /dev/null
+++ b/lib/python/qmk/cli/generate/compilation_database.py
@@ -0,0 +1,133 @@
1"""Creates a compilation database for the given keyboard build.
2"""
3
4import json
5import os
6import re
7import shlex
8import shutil
9from functools import lru_cache
10from pathlib import Path
11from typing import Dict, Iterator, List, Union
12
13from milc import cli, MILC
14
15from qmk.commands import create_make_command
16from qmk.constants import QMK_FIRMWARE
17from qmk.decorators import automagic_keyboard, automagic_keymap
18
19
20@lru_cache(maxsize=10)
21def system_libs(binary: str) -> List[Path]:
22 """Find the system include directory that the given build tool uses.
23 """
24 cli.log.debug("searching for system library directory for binary: %s", binary)
25 bin_path = shutil.which(binary)
26
27 # Actually query xxxxxx-gcc to find its include paths.
28 if binary.endswith("gcc") or binary.endswith("g++"):
29 result = cli.run([binary, '-E', '-Wp,-v', '-'], capture_output=True, check=True, input='\n')
30 paths = []
31 for line in result.stderr.splitlines():
32 if line.startswith(" "):
33 paths.append(Path(line.strip()).resolve())
34 return paths
35
36 return list(Path(bin_path).resolve().parent.parent.glob("*/include")) if bin_path else []
37
38
39file_re = re.compile(r'printf "Compiling: ([^"]+)')
40cmd_re = re.compile(r'LOG=\$\((.+?)&&')
41
42
43def parse_make_n(f: Iterator[str]) -> List[Dict[str, str]]:
44 """parse the output of `make -n <target>`
45
46 This function makes many assumptions about the format of your build log.
47 This happens to work right now for qmk.
48 """
49
50 state = 'start'
51 this_file = None
52 records = []
53 for line in f:
54 if state == 'start':
55 m = file_re.search(line)
56 if m:
57 this_file = m.group(1)
58 state = 'cmd'
59
60 if state == 'cmd':
61 assert this_file
62 m = cmd_re.search(line)
63 if m:
64 # we have a hit!
65 this_cmd = m.group(1)
66 args = shlex.split(this_cmd)
67 for s in system_libs(args[0]):
68 args += ['-isystem', '%s' % s]
69 new_cmd = ' '.join(shlex.quote(s) for s in args if s != '-mno-thumb-interwork')
70 records.append({"directory": str(QMK_FIRMWARE.resolve()), "command": new_cmd, "file": this_file})
71 state = 'start'
72
73 return records
74
75
76@cli.argument('-kb', '--keyboard', help='The keyboard to build a firmware for. Ignored when a configurator export is supplied.')
77@cli.argument('-km', '--keymap', help='The keymap to build a firmware for. Ignored when a configurator export is supplied.')
78@cli.subcommand('Create a compilation database.')
79@automagic_keyboard
80@automagic_keymap
81def generate_compilation_database(cli: MILC) -> Union[bool, int]:
82 """Creates a compilation database for the given keyboard build.
83
84 Does a make clean, then a make -n for this target and uses the dry-run output to create
85 a compilation database (compile_commands.json). This file can help some IDEs and
86 IDE-like editors work better. For more information about this:
87
88 https://clang.llvm.org/docs/JSONCompilationDatabase.html
89 """
90 command = None
91 # check both config domains: the magic decorator fills in `generate_compilation_database` but the user is
92 # more likely to have set `compile` in their config file.
93 current_keyboard = cli.config.generate_compilation_database.keyboard or cli.config.user.keyboard
94 current_keymap = cli.config.generate_compilation_database.keymap or cli.config.user.keymap
95
96 if current_keyboard and current_keymap:
97 # Generate the make command for a specific keyboard/keymap.
98 command = create_make_command(current_keyboard, current_keymap, dry_run=True)
99 elif not current_keyboard:
100 cli.log.error('Could not determine keyboard!')
101 elif not current_keymap:
102 cli.log.error('Could not determine keymap!')
103
104 if not command:
105 cli.log.error('You must supply both `--keyboard` and `--keymap`, or be in a directory for a keyboard or keymap.')
106 cli.echo('usage: qmk compiledb [-kb KEYBOARD] [-km KEYMAP]')
107 return False
108
109 # remove any environment variable overrides which could trip us up
110 env = os.environ.copy()
111 env.pop("MAKEFLAGS", None)
112
113 # re-use same executable as the main make invocation (might be gmake)
114 clean_command = [command[0], 'clean']
115 cli.log.info('Making clean with {fg_cyan}%s', ' '.join(clean_command))
116 cli.run(clean_command, capture_output=False, check=True, env=env)
117
118 cli.log.info('Gathering build instructions from {fg_cyan}%s', ' '.join(command))
119
120 result = cli.run(command, capture_output=True, check=True, env=env)
121 db = parse_make_n(result.stdout.splitlines())
122 if not db:
123 cli.log.error("Failed to parse output from make output:\n%s", result.stdout)
124 return False
125
126 cli.log.info("Found %s compile commands", len(db))
127
128 dbpath = QMK_FIRMWARE / 'compile_commands.json'
129
130 cli.log.info(f"Writing build database to {dbpath}")
131 dbpath.write_text(json.dumps(db, indent=4))
132
133 return True
diff --git a/lib/python/qmk/cli/lint.py b/lib/python/qmk/cli/lint.py
index 02b31fbc4..96593ed69 100644
--- a/lib/python/qmk/cli/lint.py
+++ b/lib/python/qmk/cli/lint.py
@@ -1,72 +1,129 @@
1"""Command to look over a keyboard/keymap and check for common mistakes. 1"""Command to look over a keyboard/keymap and check for common mistakes.
2""" 2"""
3from pathlib import Path
4
3from milc import cli 5from milc import cli
4 6
5from qmk.decorators import automagic_keyboard, automagic_keymap 7from qmk.decorators import automagic_keyboard, automagic_keymap
6from qmk.info import info_json 8from qmk.info import info_json
7from qmk.keyboard import find_readme, keyboard_completer 9from qmk.keyboard import keyboard_completer, list_keyboards
8from qmk.keymap import locate_keymap 10from qmk.keymap import locate_keymap
9from qmk.path import is_keyboard, keyboard 11from qmk.path import is_keyboard, keyboard
10 12
11 13
12@cli.argument('--strict', action='store_true', help='Treat warnings as errors.') 14def keymap_check(kb, km):
13@cli.argument('-kb', '--keyboard', completer=keyboard_completer, help='The keyboard to check.') 15 """Perform the keymap level checks.
14@cli.argument('-km', '--keymap', help='The keymap to check.') 16 """
17 ok = True
18 keymap_path = locate_keymap(kb, km)
19
20 if not keymap_path:
21 ok = False
22 cli.log.error("%s: Can't find %s keymap.", kb, km)
23
24 return ok
25
26
27def rules_mk_assignment_only(keyboard_path):
28 """Check the keyboard-level rules.mk to ensure it only has assignments.
29 """
30 current_path = Path()
31 errors = []
32
33 for path_part in keyboard_path.parts:
34 current_path = current_path / path_part
35 rules_mk = current_path / 'rules.mk'
36
37 if rules_mk.exists():
38 continuation = None
39
40 for i, line in enumerate(rules_mk.open()):
41 line = line.strip()
42
43 if '#' in line:
44 line = line[:line.index('#')]
45
46 if continuation:
47 line = continuation + line
48 continuation = None
49
50 if line:
51 if line[-1] == '\\':
52 continuation = line[:-1]
53 continue
54
55 if line and '=' not in line:
56 errors.append(f'Non-assignment code on line +{i} {rules_mk}: {line}')
57
58 return errors
59
60
61@cli.argument('--strict', action='store_true', help='Treat warnings as errors')
62@cli.argument('-kb', '--keyboard', completer=keyboard_completer, help='Comma separated list of keyboards to check')
63@cli.argument('-km', '--keymap', help='The keymap to check')
64@cli.argument('--all-kb', action='store_true', arg_only=True, help='Check all keyboards')
15@cli.subcommand('Check keyboard and keymap for common mistakes.') 65@cli.subcommand('Check keyboard and keymap for common mistakes.')
16@automagic_keyboard 66@automagic_keyboard
17@automagic_keymap 67@automagic_keymap
18def lint(cli): 68def lint(cli):
19 """Check keyboard and keymap for common mistakes. 69 """Check keyboard and keymap for common mistakes.
20 """ 70 """
21 if not cli.config.lint.keyboard: 71 failed = []
22 cli.log.error('Missing required argument: --keyboard')
23 cli.print_help()
24 return False
25 72
26 if not is_keyboard(cli.config.lint.keyboard): 73 # Determine our keyboard list
27 cli.log.error('No such keyboard: %s', cli.config.lint.keyboard) 74 if cli.args.all_kb:
28 return False 75 if cli.args.keyboard:
76 cli.log.warning('Both --all-kb and --keyboard passed, --all-kb takes presidence.')
29 77
30 # Gather data about the keyboard. 78 keyboard_list = list_keyboards()
31 ok = True 79 elif not cli.config.lint.keyboard:
32 keyboard_path = keyboard(cli.config.lint.keyboard) 80 cli.log.error('Missing required arguments: --keyboard or --all-kb')
33 keyboard_info = info_json(cli.config.lint.keyboard) 81 cli.print_help()
34 readme_path = find_readme(cli.config.lint.keyboard) 82 return False
35 missing_readme_path = keyboard_path / 'readme.md' 83 else:
84 keyboard_list = cli.config.lint.keyboard.split(',')
36 85
37 # Check for errors in the info.json 86 # Lint each keyboard
38 if keyboard_info['parse_errors']: 87 for kb in keyboard_list:
39 ok = False 88 if not is_keyboard(kb):
40 cli.log.error('Errors found when generating info.json.') 89 cli.log.error('No such keyboard: %s', kb)
90 continue
41 91
42 if cli.config.lint.strict and keyboard_info['parse_warnings']: 92 # Gather data about the keyboard.
43 ok = False 93 ok = True
44 cli.log.error('Warnings found when generating info.json (Strict mode enabled.)') 94 keyboard_path = keyboard(kb)
95 keyboard_info = info_json(kb)
45 96
46 # Check for a readme.md and warn if it doesn't exist 97 # Check for errors in the info.json
47 if not readme_path: 98 if keyboard_info['parse_errors']:
48 ok = False 99 ok = False
49 cli.log.error('Missing %s', missing_readme_path) 100 cli.log.error('%s: Errors found when generating info.json.', kb)
50 101
51 # Keymap specific checks 102 if cli.config.lint.strict and keyboard_info['parse_warnings']:
52 if cli.config.lint.keymap: 103 ok = False
53 keymap_path = locate_keymap(cli.config.lint.keyboard, cli.config.lint.keymap) 104 cli.log.error('%s: Warnings found when generating info.json (Strict mode enabled.)', kb)
54 105
55 if not keymap_path: 106 # Check the rules.mk file(s)
107 rules_mk_assignment_errors = rules_mk_assignment_only(keyboard_path)
108 if rules_mk_assignment_errors:
56 ok = False 109 ok = False
57 cli.log.error("Can't find %s keymap for %s keyboard.", cli.config.lint.keymap, cli.config.lint.keyboard) 110 cli.log.error('%s: Non-assignment code found in rules.mk. Move it to post_rules.mk instead.', kb)
58 else: 111 for assignment_error in rules_mk_assignment_errors:
59 keymap_readme = keymap_path.parent / 'readme.md' 112 cli.log.error(assignment_error)
60 if not keymap_readme.exists():
61 cli.log.warning('Missing %s', keymap_readme)
62 113
63 if cli.config.lint.strict: 114 # Keymap specific checks
64 ok = False 115 if cli.config.lint.keymap:
116 if not keymap_check(kb, cli.config.lint.keymap):
117 ok = False
118
119 # Report status
120 if not ok:
121 failed.append(kb)
65 122
66 # Check and report the overall status 123 # Check and report the overall status
67 if ok: 124 if failed:
68 cli.log.info('Lint check passed!') 125 cli.log.error('Lint check failed for: %s', ', '.join(failed))
69 return True 126 return False
70 127
71 cli.log.error('Lint check failed!') 128 cli.log.info('Lint check passed!')
72 return False 129 return True
diff --git a/lib/python/qmk/cli/pytest.py b/lib/python/qmk/cli/pytest.py
index bdb336b9a..a7f01a872 100644
--- a/lib/python/qmk/cli/pytest.py
+++ b/lib/python/qmk/cli/pytest.py
@@ -12,6 +12,6 @@ def pytest(cli):
12 """Run several linting/testing commands. 12 """Run several linting/testing commands.
13 """ 13 """
14 nose2 = cli.run(['nose2', '-v'], capture_output=False, stdin=DEVNULL) 14 nose2 = cli.run(['nose2', '-v'], capture_output=False, stdin=DEVNULL)
15 flake8 = cli.run(['flake8', 'lib/python', 'bin/qmk'], capture_output=False, stdin=DEVNULL) 15 flake8 = cli.run(['flake8', 'lib/python'], capture_output=False, stdin=DEVNULL)
16 16
17 return flake8.returncode | nose2.returncode 17 return flake8.returncode | nose2.returncode
diff --git a/lib/python/qmk/commands.py b/lib/python/qmk/commands.py
index 421453d83..2995a5fda 100644
--- a/lib/python/qmk/commands.py
+++ b/lib/python/qmk/commands.py
@@ -28,7 +28,7 @@ def _find_make():
28 return make_cmd 28 return make_cmd
29 29
30 30
31def create_make_target(target, parallel=1, **env_vars): 31def create_make_target(target, dry_run=False, parallel=1, **env_vars):
32 """Create a make command 32 """Create a make command
33 33
34 Args: 34 Args:
@@ -36,6 +36,9 @@ def create_make_target(target, parallel=1, **env_vars):
36 target 36 target
37 Usually a make rule, such as 'clean' or 'all'. 37 Usually a make rule, such as 'clean' or 'all'.
38 38
39 dry_run
40 make -n -- don't actually build
41
39 parallel 42 parallel
40 The number of make jobs to run in parallel 43 The number of make jobs to run in parallel
41 44
@@ -52,10 +55,10 @@ def create_make_target(target, parallel=1, **env_vars):
52 for key, value in env_vars.items(): 55 for key, value in env_vars.items():
53 env.append(f'{key}={value}') 56 env.append(f'{key}={value}')
54 57
55 return [make_cmd, *get_make_parallel_args(parallel), *env, target] 58 return [make_cmd, *(['-n'] if dry_run else []), *get_make_parallel_args(parallel), *env, target]
56 59
57 60
58def create_make_command(keyboard, keymap, target=None, parallel=1, **env_vars): 61def create_make_command(keyboard, keymap, target=None, dry_run=False, parallel=1, **env_vars):
59 """Create a make compile command 62 """Create a make compile command
60 63
61 Args: 64 Args:
@@ -69,6 +72,9 @@ def create_make_command(keyboard, keymap, target=None, parallel=1, **env_vars):
69 target 72 target
70 Usually a bootloader. 73 Usually a bootloader.
71 74
75 dry_run
76 make -n -- don't actually build
77
72 parallel 78 parallel
73 The number of make jobs to run in parallel 79 The number of make jobs to run in parallel
74 80
@@ -84,7 +90,7 @@ def create_make_command(keyboard, keymap, target=None, parallel=1, **env_vars):
84 if target: 90 if target:
85 make_args.append(target) 91 make_args.append(target)
86 92
87 return create_make_target(':'.join(make_args), parallel, **env_vars) 93 return create_make_target(':'.join(make_args), dry_run=dry_run, parallel=parallel, **env_vars)
88 94
89 95
90def get_git_version(current_time, repo_dir='.', check_dir='.'): 96def get_git_version(current_time, repo_dir='.', check_dir='.'):
@@ -233,7 +239,7 @@ def compile_configurator_json(user_keymap, bootloader=None, parallel=1, **env_va
233 f'VERBOSE={verbose}', 239 f'VERBOSE={verbose}',
234 f'COLOR={color}', 240 f'COLOR={color}',
235 'SILENT=false', 241 'SILENT=false',
236 f'QMK_BIN={"bin/qmk" if "DEPRECATED_BIN_QMK" in os.environ else "qmk"}', 242 'QMK_BIN="qmk"',
237 ]) 243 ])
238 244
239 return make_command 245 return make_command
diff --git a/lib/python/qmk/tests/test_cli_commands.py b/lib/python/qmk/tests/test_cli_commands.py
index b39fe5e46..e4eaef899 100644
--- a/lib/python/qmk/tests/test_cli_commands.py
+++ b/lib/python/qmk/tests/test_cli_commands.py
@@ -83,7 +83,7 @@ def test_hello():
83def test_format_python(): 83def test_format_python():
84 result = check_subcommand('format-python', '--dry-run') 84 result = check_subcommand('format-python', '--dry-run')
85 check_returncode(result) 85 check_returncode(result)
86 assert 'Python code in `bin/qmk` and `lib/python` is correctly formatted.' in result.stdout 86 assert 'Python code in `lib/python` is correctly formatted.' in result.stdout
87 87
88 88
89def test_list_keyboards(): 89def test_list_keyboards():