summaryrefslogtreecommitdiff
path: root/lib/python/qmk
diff options
context:
space:
mode:
authorNick Brassel <nick@tzarc.org>2025-11-28 00:36:49 +1100
committerGitHub <noreply@github.com>2025-11-28 00:36:49 +1100
commit9c2ca00074784dbee27b459d71cfc8e75f47b976 (patch)
treea59576289fd024bf35b0573db70eb8862ed44568 /lib/python/qmk
parent594558ec7b9ac1963870447778426682065e0d20 (diff)
QMK CLI Environment bootstrapper (#25038)
Co-authored-by: Joel Challis <git@zvecr.com> Co-authored-by: Pascal Getreuer <getreuer@google.com>
Diffstat (limited to 'lib/python/qmk')
-rw-r--r--lib/python/qmk/cli/__init__.py24
-rw-r--r--lib/python/qmk/cli/doctor/check.py74
-rwxr-xr-xlib/python/qmk/cli/doctor/main.py69
-rw-r--r--lib/python/qmk/flashers.py6
-rw-r--r--lib/python/qmk/info.py2
-rw-r--r--lib/python/qmk/keyboard.py6
-rw-r--r--lib/python/qmk/math_ops.py (renamed from lib/python/qmk/math.py)4
7 files changed, 154 insertions, 31 deletions
diff --git a/lib/python/qmk/cli/__init__.py b/lib/python/qmk/cli/__init__.py
index 26905ec134..dc2e4726a5 100644
--- a/lib/python/qmk/cli/__init__.py
+++ b/lib/python/qmk/cli/__init__.py
@@ -3,6 +3,8 @@
3We list each subcommand here explicitly because all the reliable ways of searching for modules are slow and delay startup. 3We list each subcommand here explicitly because all the reliable ways of searching for modules are slow and delay startup.
4""" 4"""
5import os 5import os
6import platform
7import platformdirs
6import shlex 8import shlex
7import sys 9import sys
8from importlib.util import find_spec 10from importlib.util import find_spec
@@ -12,6 +14,28 @@ from subprocess import run
12from milc import cli, __VERSION__ 14from milc import cli, __VERSION__
13from milc.questions import yesno 15from milc.questions import yesno
14 16
17
18def _get_default_distrib_path():
19 if 'windows' in platform.platform().lower():
20 try:
21 result = cli.run(['cygpath', '-w', '/opt/qmk'])
22 if result.returncode == 0:
23 return result.stdout.strip()
24 except Exception:
25 pass
26
27 return platformdirs.user_data_dir('qmk')
28
29
30# Ensure the QMK distribution is on the `$PATH` if present. This must be kept in sync with qmk/qmk_cli.
31QMK_DISTRIB_DIR = Path(os.environ.get('QMK_DISTRIB_DIR', _get_default_distrib_path()))
32if QMK_DISTRIB_DIR.exists():
33 os.environ['PATH'] = str(QMK_DISTRIB_DIR / 'bin') + os.pathsep + os.environ['PATH']
34
35# Prepend any user-defined path prefix
36if 'QMK_PATH_PREFIX' in os.environ:
37 os.environ['PATH'] = os.environ['QMK_PATH_PREFIX'] + os.pathsep + os.environ['PATH']
38
15import_names = { 39import_names = {
16 # A mapping of package name to importable name 40 # A mapping of package name to importable name
17 'pep8-naming': 'pep8ext_naming', 41 'pep8-naming': 'pep8ext_naming',
diff --git a/lib/python/qmk/cli/doctor/check.py b/lib/python/qmk/cli/doctor/check.py
index 51b0f0c80a..8a13cb0832 100644
--- a/lib/python/qmk/cli/doctor/check.py
+++ b/lib/python/qmk/cli/doctor/check.py
@@ -1,7 +1,6 @@
1"""Check for specific programs. 1"""Check for specific programs.
2""" 2"""
3from enum import Enum 3from enum import Enum
4import re
5import shutil 4import shutil
6from subprocess import DEVNULL, TimeoutExpired 5from subprocess import DEVNULL, TimeoutExpired
7from tempfile import TemporaryDirectory 6from tempfile import TemporaryDirectory
@@ -9,6 +8,7 @@ from pathlib import Path
9 8
10from milc import cli 9from milc import cli
11from qmk import submodules 10from qmk import submodules
11from qmk.commands import find_make
12 12
13 13
14class CheckStatus(Enum): 14class CheckStatus(Enum):
@@ -17,7 +17,13 @@ class CheckStatus(Enum):
17 ERROR = 3 17 ERROR = 3
18 18
19 19
20WHICH_MAKE = Path(find_make()).name
21
20ESSENTIAL_BINARIES = { 22ESSENTIAL_BINARIES = {
23 WHICH_MAKE: {},
24 'git': {},
25 'dos2unix': {},
26 'diff': {},
21 'dfu-programmer': {}, 27 'dfu-programmer': {},
22 'avrdude': {}, 28 'avrdude': {},
23 'dfu-util': {}, 29 'dfu-util': {},
@@ -30,14 +36,39 @@ ESSENTIAL_BINARIES = {
30} 36}
31 37
32 38
33def _parse_gcc_version(version): 39def _check_make_version():
34 m = re.match(r"(\d+)(?:\.(\d+))?(?:\.(\d+))?", version) 40 last_line = ESSENTIAL_BINARIES[WHICH_MAKE]['output'].split('\n')[0]
41 version_number = last_line.split()[2]
42 cli.log.info('Found %s version %s', WHICH_MAKE, version_number)
35 43
36 return { 44 return CheckStatus.OK
37 'major': int(m.group(1)), 45
38 'minor': int(m.group(2)) if m.group(2) else 0, 46
39 'patch': int(m.group(3)) if m.group(3) else 0, 47def _check_git_version():
40 } 48 last_line = ESSENTIAL_BINARIES['git']['output'].split('\n')[0]
49 version_number = last_line.split()[2]
50 cli.log.info('Found git version %s', version_number)
51
52 return CheckStatus.OK
53
54
55def _check_dos2unix_version():
56 last_line = ESSENTIAL_BINARIES['dos2unix']['output'].split('\n')[0]
57 version_number = last_line.split()[1]
58 cli.log.info('Found dos2unix version %s', version_number)
59
60 return CheckStatus.OK
61
62
63def _check_diff_version():
64 last_line = ESSENTIAL_BINARIES['diff']['output'].split('\n')[0]
65 if 'Apple diff' in last_line:
66 version_number = last_line
67 else:
68 version_number = last_line.split()[3]
69 cli.log.info('Found diff version %s', version_number)
70
71 return CheckStatus.OK
41 72
42 73
43def _check_arm_gcc_version(): 74def _check_arm_gcc_version():
@@ -148,16 +179,24 @@ def check_binaries():
148 """Iterates through ESSENTIAL_BINARIES and tests them. 179 """Iterates through ESSENTIAL_BINARIES and tests them.
149 """ 180 """
150 ok = CheckStatus.OK 181 ok = CheckStatus.OK
182 missing_from_path = []
151 183
152 for binary in sorted(ESSENTIAL_BINARIES): 184 for binary in sorted(ESSENTIAL_BINARIES):
153 try: 185 try:
154 if not is_executable(binary): 186 if not is_in_path(binary):
187 ok = CheckStatus.ERROR
188 missing_from_path.append(binary)
189 elif not is_executable(binary):
155 ok = CheckStatus.ERROR 190 ok = CheckStatus.ERROR
156 except TimeoutExpired: 191 except TimeoutExpired:
157 cli.log.debug('Timeout checking %s', binary) 192 cli.log.debug('Timeout checking %s', binary)
158 if ok != CheckStatus.ERROR: 193 if ok != CheckStatus.ERROR:
159 ok = CheckStatus.WARNING 194 ok = CheckStatus.WARNING
160 195
196 if missing_from_path:
197 location_noun = 'its location' if len(missing_from_path) == 1 else 'their locations'
198 cli.log.error('{fg_red}' + ', '.join(missing_from_path) + f' may need to be installed, or {location_noun} added to your path.')
199
161 return ok 200 return ok
162 201
163 202
@@ -165,6 +204,10 @@ def check_binary_versions():
165 """Check the versions of ESSENTIAL_BINARIES 204 """Check the versions of ESSENTIAL_BINARIES
166 """ 205 """
167 checks = { 206 checks = {
207 WHICH_MAKE: _check_make_version,
208 'git': _check_git_version,
209 'dos2unix': _check_dos2unix_version,
210 'diff': _check_diff_version,
168 'arm-none-eabi-gcc': _check_arm_gcc_version, 211 'arm-none-eabi-gcc': _check_arm_gcc_version,
169 'avr-gcc': _check_avr_gcc_version, 212 'avr-gcc': _check_avr_gcc_version,
170 'avrdude': _check_avrdude_version, 213 'avrdude': _check_avrdude_version,
@@ -196,15 +239,18 @@ def check_submodules():
196 return CheckStatus.OK 239 return CheckStatus.OK
197 240
198 241
199def is_executable(command): 242def is_in_path(command):
200 """Returns True if command exists and can be executed. 243 """Returns True if command is found in the path.
201 """ 244 """
202 # Make sure the command is in the path. 245 if shutil.which(command) is None:
203 res = shutil.which(command)
204 if res is None:
205 cli.log.error("{fg_red}Can't find %s in your path.", command) 246 cli.log.error("{fg_red}Can't find %s in your path.", command)
206 return False 247 return False
248 return True
249
207 250
251def is_executable(command):
252 """Returns True if command can be executed.
253 """
208 # Make sure the command can be executed 254 # Make sure the command can be executed
209 version_arg = ESSENTIAL_BINARIES[command].get('version_arg', '--version') 255 version_arg = ESSENTIAL_BINARIES[command].get('version_arg', '--version')
210 check = cli.run([command, version_arg], combined_output=True, stdin=DEVNULL, timeout=5) 256 check = cli.run([command, version_arg], combined_output=True, stdin=DEVNULL, timeout=5)
diff --git a/lib/python/qmk/cli/doctor/main.py b/lib/python/qmk/cli/doctor/main.py
index 391353ebbf..45667e8ce2 100755
--- a/lib/python/qmk/cli/doctor/main.py
+++ b/lib/python/qmk/cli/doctor/main.py
@@ -3,7 +3,6 @@
3Check out the user's QMK environment and make sure it's ready to compile. 3Check out the user's QMK environment and make sure it's ready to compile.
4""" 4"""
5import platform 5import platform
6from subprocess import DEVNULL
7 6
8from milc import cli 7from milc import cli
9from milc.questions import yesno 8from milc.questions import yesno
@@ -16,6 +15,60 @@ from qmk.commands import in_virtualenv
16from qmk.userspace import qmk_userspace_paths, qmk_userspace_validate, UserspaceValidationError 15from qmk.userspace import qmk_userspace_paths, qmk_userspace_validate, UserspaceValidationError
17 16
18 17
18def distrib_tests():
19 def _load_kvp_file(file):
20 """Load a simple key=value file into a dictionary
21 """
22 vars = {}
23 with open(file, 'r') as f:
24 for line in f:
25 if '=' in line:
26 key, value = line.split('=', 1)
27 vars[key.strip()] = value.strip()
28 return vars
29
30 def _parse_toolchain_release_file(file):
31 """Parse the QMK toolchain release info file
32 """
33 try:
34 vars = _load_kvp_file(file)
35 return f'{vars.get("TOOLCHAIN_HOST", "unknown")}:{vars.get("TOOLCHAIN_TARGET", "unknown")}:{vars.get("COMMIT_HASH", "unknown")}'
36 except Exception as e:
37 cli.log.warning('Error reading QMK toolchain release info file: %s', e)
38 return f'Unknown toolchain release info file: {file}'
39
40 def _parse_flashutils_release_file(file):
41 """Parse the QMK flashutils release info file
42 """
43 try:
44 vars = _load_kvp_file(file)
45 return f'{vars.get("FLASHUTILS_HOST", "unknown")}:{vars.get("COMMIT_HASH", "unknown")}'
46 except Exception as e:
47 cli.log.warning('Error reading QMK flashutils release info file: %s', e)
48 return f'Unknown flashutils release info file: {file}'
49
50 try:
51 from qmk.cli import QMK_DISTRIB_DIR
52 if (QMK_DISTRIB_DIR / 'etc').exists():
53 cli.log.info('Found QMK tools distribution directory: {fg_cyan}%s', QMK_DISTRIB_DIR)
54
55 toolchains = [_parse_toolchain_release_file(file) for file in (QMK_DISTRIB_DIR / 'etc').glob('toolchain_release_*')]
56 if len(toolchains) > 0:
57 cli.log.info('Found QMK toolchains: {fg_cyan}%s', ', '.join(toolchains))
58 else:
59 cli.log.warning('No QMK toolchains manifest found.')
60
61 flashutils = [_parse_flashutils_release_file(file) for file in (QMK_DISTRIB_DIR / 'etc').glob('flashutils_release_*')]
62 if len(flashutils) > 0:
63 cli.log.info('Found QMK flashutils: {fg_cyan}%s', ', '.join(flashutils))
64 else:
65 cli.log.warning('No QMK flashutils manifest found.')
66 except ImportError:
67 cli.log.info('QMK tools distribution not found.')
68
69 return CheckStatus.OK
70
71
19def os_tests(): 72def os_tests():
20 """Determine our OS and run platform specific tests 73 """Determine our OS and run platform specific tests
21 """ 74 """
@@ -124,10 +177,12 @@ def doctor(cli):
124 * [ ] Compile a trivial program with each compiler 177 * [ ] Compile a trivial program with each compiler
125 """ 178 """
126 cli.log.info('QMK Doctor is checking your environment.') 179 cli.log.info('QMK Doctor is checking your environment.')
180 cli.log.info('Python version: %s', platform.python_version())
127 cli.log.info('CLI version: %s', cli.version) 181 cli.log.info('CLI version: %s', cli.version)
128 cli.log.info('QMK home: {fg_cyan}%s', QMK_FIRMWARE) 182 cli.log.info('QMK home: {fg_cyan}%s', QMK_FIRMWARE)
129 183
130 status = os_status = os_tests() 184 status = os_status = os_tests()
185 distrib_tests()
131 186
132 userspace_tests(None) 187 userspace_tests(None)
133 188
@@ -141,12 +196,6 @@ def doctor(cli):
141 196
142 # Make sure the basic CLI tools we need are available and can be executed. 197 # Make sure the basic CLI tools we need are available and can be executed.
143 bin_ok = check_binaries() 198 bin_ok = check_binaries()
144
145 if bin_ok == CheckStatus.ERROR:
146 if yesno('Would you like to install dependencies?', default=True):
147 cli.run(['util/qmk_install.sh', '-y'], stdin=DEVNULL, capture_output=False)
148 bin_ok = check_binaries()
149
150 if bin_ok == CheckStatus.OK: 199 if bin_ok == CheckStatus.OK:
151 cli.log.info('All dependencies are installed.') 200 cli.log.info('All dependencies are installed.')
152 elif bin_ok == CheckStatus.WARNING: 201 elif bin_ok == CheckStatus.WARNING:
@@ -163,7 +212,6 @@ def doctor(cli):
163 212
164 # Check out the QMK submodules 213 # Check out the QMK submodules
165 sub_ok = check_submodules() 214 sub_ok = check_submodules()
166
167 if sub_ok == CheckStatus.OK: 215 if sub_ok == CheckStatus.OK:
168 cli.log.info('Submodules are up to date.') 216 cli.log.info('Submodules are up to date.')
169 else: 217 else:
@@ -186,6 +234,7 @@ def doctor(cli):
186 cli.log.info('{fg_yellow}QMK is ready to go, but minor problems were found') 234 cli.log.info('{fg_yellow}QMK is ready to go, but minor problems were found')
187 return 1 235 return 1
188 else: 236 else:
189 cli.log.info('{fg_red}Major problems detected, please fix these problems before proceeding.') 237 cli.log.info('{fg_red}Major problems detected, please fix these problems before proceeding.{fg_reset}')
190 cli.log.info('{fg_blue}Check out the FAQ (https://docs.qmk.fm/#/faq_build) or join the QMK Discord (https://discord.gg/qmk) for help.') 238 cli.log.info('{fg_blue}If you\'re missing dependencies, try following the instructions on: https://docs.qmk.fm/newbs_getting_started{fg_reset}')
239 cli.log.info('{fg_blue}Additionally, check out the FAQ (https://docs.qmk.fm/#/faq_build) or join the QMK Discord (https://discord.gg/qmk) for help.{fg_reset}')
191 return 2 240 return 2
diff --git a/lib/python/qmk/flashers.py b/lib/python/qmk/flashers.py
index b70b5fb035..6b52f4d35a 100644
--- a/lib/python/qmk/flashers.py
+++ b/lib/python/qmk/flashers.py
@@ -155,10 +155,10 @@ def _flash_atmel_dfu(mcu, file):
155def _flash_hid_bootloader(mcu, details, file): 155def _flash_hid_bootloader(mcu, details, file):
156 cmd = None 156 cmd = None
157 if details == 'halfkay': 157 if details == 'halfkay':
158 if shutil.which('teensy-loader-cli'): 158 if shutil.which('teensy_loader_cli'):
159 cmd = 'teensy-loader-cli'
160 elif shutil.which('teensy_loader_cli'):
161 cmd = 'teensy_loader_cli' 159 cmd = 'teensy_loader_cli'
160 elif shutil.which('teensy-loader-cli'):
161 cmd = 'teensy-loader-cli'
162 162
163 # Use 'hid_bootloader_cli' for QMK HID and as a fallback for HalfKay 163 # Use 'hid_bootloader_cli' for QMK HID and as a fallback for HalfKay
164 if not cmd: 164 if not cmd:
diff --git a/lib/python/qmk/info.py b/lib/python/qmk/info.py
index f63228b2bc..e8aad760de 100644
--- a/lib/python/qmk/info.py
+++ b/lib/python/qmk/info.py
@@ -14,7 +14,7 @@ from qmk.json_schema import deep_update, json_load, validate
14from qmk.keyboard import config_h, rules_mk 14from qmk.keyboard import config_h, rules_mk
15from qmk.commands import parse_configurator_json 15from qmk.commands import parse_configurator_json
16from qmk.makefile import parse_rules_mk_file 16from qmk.makefile import parse_rules_mk_file
17from qmk.math import compute 17from qmk.math_ops import compute
18from qmk.util import maybe_exit, truthy 18from qmk.util import maybe_exit, truthy
19 19
20true_values = ['1', 'on', 'yes'] 20true_values = ['1', 'on', 'yes']
diff --git a/lib/python/qmk/keyboard.py b/lib/python/qmk/keyboard.py
index 254dc62309..e8534492c9 100644
--- a/lib/python/qmk/keyboard.py
+++ b/lib/python/qmk/keyboard.py
@@ -175,8 +175,9 @@ def keyboard_completer(prefix, action, parser, parsed_args):
175 return list_keyboards() 175 return list_keyboards()
176 176
177 177
178@lru_cache(maxsize=None)
178def list_keyboards(): 179def list_keyboards():
179 """Returns a list of all keyboards 180 """Returns a list of all keyboards.
180 """ 181 """
181 # We avoid pathlib here because this is performance critical code. 182 # We avoid pathlib here because this is performance critical code.
182 kb_wildcard = os.path.join(base_path, "**", 'keyboard.json') 183 kb_wildcard = os.path.join(base_path, "**", 'keyboard.json')
@@ -184,6 +185,9 @@ def list_keyboards():
184 185
185 found = map(_find_name, paths) 186 found = map(_find_name, paths)
186 187
188 # Convert to posix paths for consistency
189 found = map(lambda x: str(Path(x).as_posix()), found)
190
187 return sorted(set(found)) 191 return sorted(set(found))
188 192
189 193
diff --git a/lib/python/qmk/math.py b/lib/python/qmk/math_ops.py
index 88dc4a300c..1f14b18f4e 100644
--- a/lib/python/qmk/math.py
+++ b/lib/python/qmk/math_ops.py
@@ -23,8 +23,8 @@ def compute(expr):
23 23
24 24
25def _eval(node): 25def _eval(node):
26 if isinstance(node, ast.Num): # <number> 26 if isinstance(node, ast.Constant): # <number>
27 return node.n 27 return node.value
28 elif isinstance(node, ast.BinOp): # <left> <operator> <right> 28 elif isinstance(node, ast.BinOp): # <left> <operator> <right>
29 return operators[type(node.op)](_eval(node.left), _eval(node.right)) 29 return operators[type(node.op)](_eval(node.left), _eval(node.right))
30 elif isinstance(node, ast.UnaryOp): # <operator> <operand> e.g., -1 30 elif isinstance(node, ast.UnaryOp): # <operator> <operand> e.g., -1