keyboard stuff
1"""QMK CLI Subcommands
2
3We list each subcommand here explicitly because all the reliable ways of searching for modules are slow and delay startup.
4"""
5import os
6import platform
7import platformdirs
8import shlex
9import sys
10from importlib.util import find_spec
11from pathlib import Path
12from subprocess import run
13
14from milc import cli, __VERSION__
15from milc.questions import yesno
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
39import_names = {
40 # A mapping of package name to importable name
41 'pep8-naming': 'pep8ext_naming',
42 'pyserial': 'serial',
43 'pyusb': 'usb.core',
44 'qmk-dotty-dict': 'dotty_dict',
45 'pillow': 'PIL'
46}
47
48safe_commands = [
49 # A list of subcommands we always run, even when the module imports fail
50 'clone',
51 'config',
52 'doctor',
53 'env',
54 'setup',
55]
56
57subcommands = [
58 'qmk.cli.ci.validate_aliases',
59 'qmk.cli.bux',
60 'qmk.cli.c2json',
61 'qmk.cli.cd',
62 'qmk.cli.chibios.confmigrate',
63 'qmk.cli.clean',
64 'qmk.cli.compile',
65 'qmk.cli.docs',
66 'qmk.cli.doctor',
67 'qmk.cli.find',
68 'qmk.cli.flash',
69 'qmk.cli.format.c',
70 'qmk.cli.format.json',
71 'qmk.cli.format.python',
72 'qmk.cli.format.text',
73 'qmk.cli.generate.api',
74 'qmk.cli.generate.autocorrect_data',
75 'qmk.cli.generate.compilation_database',
76 'qmk.cli.generate.community_modules',
77 'qmk.cli.generate.config_h',
78 'qmk.cli.generate.develop_pr_list',
79 'qmk.cli.generate.dfu_header',
80 'qmk.cli.generate.docs',
81 'qmk.cli.generate.info_json',
82 'qmk.cli.generate.keyboard_c',
83 'qmk.cli.generate.keyboard_h',
84 'qmk.cli.generate.keycodes',
85 'qmk.cli.generate.keymap_h',
86 'qmk.cli.generate.make_dependencies',
87 'qmk.cli.generate.rgb_breathe_table',
88 'qmk.cli.generate.rules_mk',
89 'qmk.cli.generate.version_h',
90 'qmk.cli.git.submodule',
91 'qmk.cli.hello',
92 'qmk.cli.import.kbfirmware',
93 'qmk.cli.import.keyboard',
94 'qmk.cli.import.keymap',
95 'qmk.cli.info',
96 'qmk.cli.json2c',
97 'qmk.cli.license_check',
98 'qmk.cli.lint',
99 'qmk.cli.kle2json',
100 'qmk.cli.list.keyboards',
101 'qmk.cli.list.keymaps',
102 'qmk.cli.list.layouts',
103 'qmk.cli.mass_compile',
104 'qmk.cli.migrate',
105 'qmk.cli.new.keyboard',
106 'qmk.cli.new.keymap',
107 'qmk.cli.painter',
108 'qmk.cli.pytest',
109 'qmk.cli.resolve_alias',
110 'qmk.cli.test.c',
111 'qmk.cli.userspace.add',
112 'qmk.cli.userspace.compile',
113 'qmk.cli.userspace.doctor',
114 'qmk.cli.userspace.list',
115 'qmk.cli.userspace.path',
116 'qmk.cli.userspace.remove',
117 'qmk.cli.via2json',
118]
119
120
121def _install_deps(requirements):
122 """Perform the installation of missing requirements.
123
124 If we detect that we are running in a virtualenv we can't write into we'll use sudo to perform the pip install.
125 """
126 command = [sys.executable, '-m', 'pip', 'install']
127
128 if sys.prefix != sys.base_prefix:
129 # We are in a virtualenv, check to see if we need to use sudo to write to it
130 if not os.access(sys.prefix, os.W_OK):
131 print('Notice: Using sudo to install modules to location owned by root:', sys.prefix)
132 command.insert(0, 'sudo')
133
134 elif not os.access(sys.prefix, os.W_OK):
135 # We can't write to sys.prefix, attempt to install locally
136 command.append('--user')
137
138 return _run_cmd(*command, '-r', requirements)
139
140
141def _run_cmd(*command):
142 """Run a command in a subshell.
143 """
144 if 'windows' in cli.platform.lower():
145 safecmd = map(shlex.quote, command)
146 safecmd = ' '.join(safecmd)
147 command = [os.environ['SHELL'], '-c', safecmd]
148
149 return run(command)
150
151
152def _find_broken_requirements(requirements):
153 """ Check if the modules in the given requirements.txt are available.
154
155 Args:
156
157 requirements
158 The path to a requirements.txt file
159
160 Returns a list of modules that couldn't be imported
161 """
162 with Path(requirements).open() as fd:
163 broken_modules = []
164
165 for line in fd.readlines():
166 line = line.strip().replace('<', '=').replace('>', '=')
167
168 if len(line) == 0 or line[0] == '#' or line.startswith('-r'):
169 continue
170
171 if '#' in line:
172 line = line.split('#')[0]
173
174 module_name = line.split('=')[0] if '=' in line else line
175 module_import = module_name.replace('-', '_')
176
177 # Not every module is importable by its own name.
178 if module_name in import_names:
179 module_import = import_names[module_name]
180
181 if not find_spec(module_import):
182 broken_modules.append(module_name)
183
184 return broken_modules
185
186
187def _broken_module_imports(requirements):
188 """Make sure we can import all the python modules.
189 """
190 broken_modules = _find_broken_requirements(requirements)
191
192 for module in broken_modules:
193 print('Could not find module %s!' % module)
194
195 if broken_modules:
196 return True
197
198 return False
199
200
201def _yesno(*args):
202 """Wrapper to only prompt if interactive
203 """
204 return sys.stdout.isatty() and yesno(*args)
205
206
207def _eprint(errmsg):
208 """Wrapper to print to stderr
209 """
210 print(errmsg, file=sys.stderr)
211
212
213# Make sure our python is new enough
214#
215# Supported version information
216#
217# Based on the OSes we support these are the minimum python version available by default.
218# Last update: 2024 Jun 24
219#
220# Arch: 3.12
221# Debian 11: 3.9
222# Debian 12: 3.11
223# Fedora 39: 3.12
224# Fedora 40: 3.12
225# FreeBSD: 3.11
226# Gentoo: 3.12
227# macOS: 3.12 (from homebrew)
228# msys2: 3.11
229# Slackware: 3.9
230# solus: 3.10
231# Ubuntu 22.04: 3.10
232# Ubuntu 24.04: 3.12
233# void: 3.12
234
235if sys.version_info[0] != 3 or sys.version_info[1] < 9:
236 _eprint('Error: Your Python is too old! Please upgrade to Python 3.9 or later.')
237 exit(127)
238
239milc_version = __VERSION__.split('.')
240
241if int(milc_version[0]) < 2 and int(milc_version[1]) < 9:
242 requirements = Path('requirements.txt').resolve()
243
244 _eprint(f'Your MILC library is too old! Please upgrade: python3 -m pip install -U -r {str(requirements)}')
245 exit(127)
246
247# Make sure we can run binaries in the same directory as our Python interpreter
248python_dir = os.path.dirname(sys.executable)
249
250if python_dir not in os.environ['PATH'].split(os.pathsep):
251 os.environ['PATH'] = os.pathsep.join((python_dir, os.environ['PATH']))
252
253# Check to make sure we have all our dependencies
254msg_install = f'\nPlease run `{sys.executable} -m pip install -r %s` to install required python dependencies.'
255args = sys.argv[1:]
256while args and args[0][0] == '-':
257 del args[0]
258
259safe_command = args and args[0] in safe_commands
260
261if not safe_command:
262 if _broken_module_imports('requirements.txt'):
263 if _yesno('Would you like to install the required Python modules?'):
264 _install_deps('requirements.txt')
265 else:
266 _eprint(msg_install % (str(Path('requirements.txt').resolve()),))
267 exit(1)
268
269 if cli.config.user.developer and _broken_module_imports('requirements-dev.txt'):
270 if _yesno('Would you like to install the required developer Python modules?'):
271 _install_deps('requirements-dev.txt')
272 elif _yesno('Would you like to disable developer mode?'):
273 _run_cmd(sys.argv[0], 'config', 'user.developer=None')
274 else:
275 _eprint(msg_install % (str(Path('requirements-dev.txt').resolve()),))
276 _eprint('You can also turn off developer mode: qmk config user.developer=None')
277 exit(1)
278
279# Import our subcommands
280for subcommand in subcommands:
281 try:
282 __import__(subcommand)
283
284 except (ImportError, ModuleNotFoundError) as e:
285 if safe_command:
286 _eprint(f'Warning: Could not import {subcommand}: {e.__class__.__name__}, {e}')
287 else:
288 raise