__init__.py 8.4 KB

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