commands.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518
  1. """Helper functions for commands.
  2. """
  3. from functools import lru_cache
  4. import json
  5. import os
  6. import sys
  7. import shutil
  8. import threading
  9. from pathlib import Path
  10. from subprocess import DEVNULL
  11. from time import sleep, strftime
  12. from dotty_dict import dotty
  13. from milc import cli
  14. import qmk.keymap
  15. from qmk.constants import QMK_FIRMWARE, KEYBOARD_OUTPUT_PREFIX
  16. from qmk.info import info_json
  17. from qmk.json_schema import json_load
  18. from qmk.keyboard import list_keyboards
  19. time_fmt = '%Y-%m-%d-%H:%M:%S'
  20. @lru_cache(maxsize=0)
  21. def _find_make():
  22. """Returns the correct make command for this environment.
  23. """
  24. make_cmd = os.environ.get('MAKE')
  25. if not make_cmd:
  26. make_cmd = 'gmake' if shutil.which('gmake') else 'make'
  27. return make_cmd
  28. def create_make_target(target, parallel=1, **env_vars):
  29. """Create a make command
  30. Args:
  31. target
  32. Usually a make rule, such as 'clean' or 'all'.
  33. parallel
  34. The number of make jobs to run in parallel
  35. **env_vars
  36. Environment variables to be passed to make.
  37. Returns:
  38. A command that can be run to make the specified keyboard and keymap
  39. """
  40. env = []
  41. make_cmd = _find_make()
  42. for key, value in env_vars.items():
  43. env.append(f'{key}={value}')
  44. return [make_cmd, *get_make_parallel_args(parallel), *env, target]
  45. def create_make_command(keyboard, keymap, target=None, parallel=1, silent=False, **env_vars):
  46. """Create a make compile command
  47. Args:
  48. keyboard
  49. The path of the keyboard, for example 'plank'
  50. keymap
  51. The name of the keymap, for example 'algernon'
  52. target
  53. Usually a bootloader.
  54. parallel
  55. The number of make jobs to run in parallel
  56. **env_vars
  57. Environment variables to be passed to make.
  58. Returns:
  59. A command that can be run to make the specified keyboard and keymap
  60. """
  61. make_cmd = [_find_make(), '--no-print-directory', '-r', '-R', '-C', './', '-f', 'build_keyboard.mk']
  62. env_vars['KEYBOARD'] = keyboard
  63. env_vars['KEYMAP'] = keymap
  64. env_vars['QMK_BIN'] = 'bin/qmk' if 'DEPRECATED_BIN_QMK' in os.environ else 'qmk'
  65. env_vars['VERBOSE'] = 'true' if cli.config.general.verbose else ''
  66. env_vars['SILENT'] = 'true' if silent else 'false'
  67. env_vars['COLOR'] = 'true' if cli.config.general.color else ''
  68. if parallel > 1:
  69. make_cmd.append('-j')
  70. make_cmd.append(str(parallel))
  71. if target:
  72. make_cmd.append(target)
  73. for key, value in env_vars.items():
  74. make_cmd.append(f'{key}={value}')
  75. return make_cmd
  76. @lru_cache(maxsize=0)
  77. def get_git_version(current_time, repo_dir='.', check_dir='.'):
  78. """Returns the current git version for a repo, or the current time.
  79. """
  80. git_describe_cmd = ['git', 'describe', '--abbrev=6', '--dirty', '--always', '--tags']
  81. if repo_dir != '.':
  82. repo_dir = Path('lib') / repo_dir
  83. if check_dir != '.':
  84. check_dir = repo_dir / check_dir
  85. if Path(check_dir).exists():
  86. git_describe = cli.run(git_describe_cmd, stdin=DEVNULL, cwd=repo_dir)
  87. if git_describe.returncode == 0:
  88. return git_describe.stdout.strip()
  89. else:
  90. cli.log.warn(f'"{" ".join(git_describe_cmd)}" returned error code {git_describe.returncode}')
  91. print(git_describe.stderr)
  92. return current_time
  93. return current_time
  94. def get_make_parallel_args(parallel=1):
  95. """Returns the arguments for running the specified number of parallel jobs.
  96. """
  97. parallel_args = []
  98. if int(parallel) <= 0:
  99. # 0 or -1 means -j without argument (unlimited jobs)
  100. parallel_args.append('--jobs')
  101. else:
  102. parallel_args.append('--jobs=' + str(parallel))
  103. if int(parallel) != 1:
  104. # If more than 1 job is used, synchronize parallel output by target
  105. parallel_args.append('--output-sync=target')
  106. return parallel_args
  107. def create_version_h(skip_git=False, skip_all=False):
  108. """Generate version.h contents
  109. """
  110. if skip_all:
  111. current_time = "1970-01-01-00:00:00"
  112. else:
  113. current_time = strftime(time_fmt)
  114. if skip_git:
  115. git_version = "NA"
  116. chibios_version = "NA"
  117. chibios_contrib_version = "NA"
  118. else:
  119. git_version = get_git_version(current_time)
  120. chibios_version = get_git_version(current_time, "chibios", "os")
  121. chibios_contrib_version = get_git_version(current_time, "chibios-contrib", "os")
  122. version_h_lines = f"""/* This file was automatically generated. Do not edit or copy.
  123. */
  124. #pragma once
  125. #define QMK_VERSION "{git_version}"
  126. #define QMK_BUILDDATE "{current_time}"
  127. #define CHIBIOS_VERSION "{chibios_version}"
  128. #define CHIBIOS_CONTRIB_VERSION "{chibios_contrib_version}"
  129. """
  130. return version_h_lines
  131. def compile_configurator_json(user_keymap, bootloader=None, parallel=1, **env_vars):
  132. """Convert a configurator export JSON file into a C file and then compile it.
  133. Args:
  134. user_keymap
  135. A deserialized keymap export
  136. bootloader
  137. A bootloader to flash
  138. parallel
  139. The number of make jobs to run in parallel
  140. Returns:
  141. A command to run to compile and flash the C file.
  142. """
  143. # Write the keymap.c file
  144. keyboard_filesafe = user_keymap['keyboard'].replace('/', '_')
  145. target = f'{keyboard_filesafe}_{user_keymap["keymap"]}'
  146. keyboard_output = Path(f'{KEYBOARD_OUTPUT_PREFIX}{keyboard_filesafe}')
  147. keymap_output = Path(f'{keyboard_output}_{user_keymap["keymap"]}')
  148. c_text = qmk.keymap.generate_c(user_keymap['keyboard'], user_keymap['layout'], user_keymap['layers'])
  149. keymap_dir = keymap_output / 'src'
  150. keymap_c = keymap_dir / 'keymap.c'
  151. keymap_dir.mkdir(exist_ok=True, parents=True)
  152. keymap_c.write_text(c_text)
  153. version_h = Path('quantum/version.h')
  154. version_h.write_text(create_version_h())
  155. # Return a command that can be run to make the keymap and flash if given
  156. verbose = 'true' if cli.config.general.verbose else 'false'
  157. color = 'true' if cli.config.general.color else 'false'
  158. make_command = [_find_make()]
  159. if not cli.config.general.verbose:
  160. make_command.append('-s')
  161. make_command.extend([
  162. *get_make_parallel_args(parallel),
  163. '-r',
  164. '-R',
  165. '-f',
  166. 'build_keyboard.mk',
  167. ])
  168. if bootloader:
  169. make_command.append(bootloader)
  170. for key, value in env_vars.items():
  171. make_command.append(f'{key}={value}')
  172. make_command.extend([
  173. f'KEYBOARD={user_keymap["keyboard"]}',
  174. f'KEYMAP={user_keymap["keymap"]}',
  175. f'KEYBOARD_FILESAFE={keyboard_filesafe}',
  176. f'TARGET={target}',
  177. f'KEYBOARD_OUTPUT={keyboard_output}',
  178. f'KEYMAP_OUTPUT={keymap_output}',
  179. f'MAIN_KEYMAP_PATH_1={keymap_output}',
  180. f'MAIN_KEYMAP_PATH_2={keymap_output}',
  181. f'MAIN_KEYMAP_PATH_3={keymap_output}',
  182. f'MAIN_KEYMAP_PATH_4={keymap_output}',
  183. f'MAIN_KEYMAP_PATH_5={keymap_output}',
  184. f'KEYMAP_C={keymap_c}',
  185. f'KEYMAP_PATH={keymap_dir}',
  186. f'VERBOSE={verbose}',
  187. f'COLOR={color}',
  188. 'SILENT=false',
  189. 'QMK_BIN="qmk"',
  190. ])
  191. return user_keymap['keyboard'], user_keymap['keymap'], make_command
  192. @lru_cache(maxsize=0)
  193. def parse_configurator_json(configurator_file):
  194. """Open and parse a configurator json export
  195. """
  196. # FIXME(skullydazed/anyone): Add validation here
  197. user_keymap = json.load(configurator_file)
  198. orig_keyboard = user_keymap['keyboard']
  199. aliases = json_load(Path('data/mappings/keyboard_aliases.json'))
  200. if orig_keyboard in aliases:
  201. if 'target' in aliases[orig_keyboard]:
  202. user_keymap['keyboard'] = aliases[orig_keyboard]['target']
  203. if 'layouts' in aliases[orig_keyboard] and user_keymap['layout'] in aliases[orig_keyboard]['layouts']:
  204. user_keymap['layout'] = aliases[orig_keyboard]['layouts'][user_keymap['layout']]
  205. return user_keymap
  206. def git_get_username():
  207. """Retrieves user's username from Git config, if set.
  208. """
  209. git_username = cli.run(['git', 'config', '--get', 'user.name'])
  210. if git_username.returncode == 0 and git_username.stdout:
  211. return git_username.stdout.strip()
  212. def git_check_repo():
  213. """Checks that the .git directory exists inside QMK_HOME.
  214. This is a decent enough indicator that the qmk_firmware directory is a
  215. proper Git repository, rather than a .zip download from GitHub.
  216. """
  217. dot_git_dir = QMK_FIRMWARE / '.git'
  218. return dot_git_dir.is_dir()
  219. def git_get_branch():
  220. """Returns the current branch for a repo, or None.
  221. """
  222. git_branch = cli.run(['git', 'branch', '--show-current'])
  223. if not git_branch.returncode != 0 or not git_branch.stdout:
  224. # Workaround for Git pre-2.22
  225. git_branch = cli.run(['git', 'rev-parse', '--abbrev-ref', 'HEAD'])
  226. if git_branch.returncode == 0:
  227. return git_branch.stdout.strip()
  228. def git_is_dirty():
  229. """Returns 1 if repo is dirty, or 0 if clean
  230. """
  231. git_diff_staged_cmd = ['git', 'diff', '--quiet']
  232. git_diff_unstaged_cmd = [*git_diff_staged_cmd, '--cached']
  233. unstaged = cli.run(git_diff_staged_cmd)
  234. staged = cli.run(git_diff_unstaged_cmd)
  235. return unstaged.returncode != 0 or staged.returncode != 0
  236. def git_get_remotes():
  237. """Returns the current remotes for a repo.
  238. """
  239. remotes = {}
  240. git_remote_show_cmd = ['git', 'remote', 'show']
  241. git_remote_get_cmd = ['git', 'remote', 'get-url']
  242. git_remote_show = cli.run(git_remote_show_cmd)
  243. if git_remote_show.returncode == 0:
  244. for name in git_remote_show.stdout.splitlines():
  245. git_remote_name = cli.run([*git_remote_get_cmd, name])
  246. remotes[name.strip()] = {"url": git_remote_name.stdout.strip()}
  247. return remotes
  248. def git_check_deviation(active_branch):
  249. """Return True if branch has custom commits
  250. """
  251. cli.run(['git', 'fetch', 'upstream', active_branch])
  252. deviations = cli.run(['git', '--no-pager', 'log', f'upstream/{active_branch}...{active_branch}'])
  253. return bool(deviations.returncode)
  254. def in_virtualenv():
  255. """Check if running inside a virtualenv.
  256. Based on https://stackoverflow.com/a/1883251
  257. """
  258. active_prefix = getattr(sys, "base_prefix", None) or getattr(sys, "real_prefix", None) or sys.prefix
  259. return active_prefix != sys.prefix
  260. def do_compile(keyboard, keymap, parallel, target=None, filters=None, environment=None):
  261. """Shared code between `qmk compile` and `qmk flash`.
  262. """
  263. if keyboard is None:
  264. keyboard = ''
  265. if environment is None:
  266. environment = {}
  267. all_keyboards = keyboard == 'all' or keyboard.startswith('all-')
  268. all_keymaps = keymap == 'all'
  269. multiple_compiles = all_keyboards or all_keymaps
  270. # Setup the environment
  271. envs = {'REQUIRE_PLATFORM_KEY': ''}
  272. for env in environment:
  273. if '=' in env:
  274. key, value = env.split('=', 1)
  275. if key in envs:
  276. cli.log.warning('Overwriting existing environment variable %s=%s with %s=%s!', key, envs[key], key, value)
  277. envs[key] = value
  278. else:
  279. cli.log.warning('Invalid environment variable: %s', env)
  280. if keyboard.startswith('all-'):
  281. envs['REQUIRE_PLATFORM_KEY'] = keyboard[4:]
  282. # Run clean if necessary
  283. if cli.args.clean and not cli.args.filename and not cli.args.dry_run:
  284. for kb, km in keyboard_keymap_iter(keyboard, keymap, {}):
  285. cli.log.info('Cleaning previous build files for keyboard {fg_cyan}%s{fg_reset} keymap {fg_cyan}%s', kb, km)
  286. make_cmd = create_make_command(kb, km, 'clean', 1, multiple_compiles, **envs)
  287. cli.run(make_cmd, capture_output=False, stdin=DEVNULL)
  288. # Determine the compile command(s)
  289. command = None
  290. if cli.args.filename:
  291. if cli.args.keyboard:
  292. cli.log.warning('Ignoring --keyboard because a keymap.json was provided.')
  293. if cli.args.keymap:
  294. cli.log.warning('Ignoring --keymap because a keymap.json was provided.')
  295. # If a configurator JSON was provided generate a keymap and compile it
  296. user_keymap = parse_configurator_json(cli.args.filename)
  297. command = compile_configurator_json(user_keymap, parallel=parallel, **envs)
  298. elif keyboard and keymap:
  299. if multiple_compiles:
  300. command = 'multiple'
  301. else:
  302. command = create_make_command(keyboard, keymap, target=target, parallel=parallel, silent=multiple_compiles, **envs)
  303. elif not keyboard:
  304. cli.log.error('Could not determine keyboard!')
  305. elif not keymap:
  306. cli.log.error('Could not determine keymap!')
  307. # Compile the firmware, if we're able to
  308. if command == 'multiple':
  309. cli.log.info('Building {fg_cyan}%s{fg_reset} with keymap {fg_cyan}%s', keyboard, keymap)
  310. returncodes = []
  311. for keyboard, keymap in keyboard_keymap_iter(keyboard, keymap, filters):
  312. command = create_make_command(keyboard, keymap, target=target, parallel=1, silent=multiple_compiles, **envs)
  313. while threading.active_count() >= parallel + 1:
  314. sleep(1)
  315. threading.Thread(target=_execute_compile, args=(keyboard, keymap, command, target, returncodes)).start()
  316. while threading.active_count() > 1:
  317. sleep(1)
  318. if any(returncodes):
  319. print()
  320. cli.log.error('Could not compile all targets, look above this message for more details. Failing target(s):')
  321. for i, returncode in enumerate(returncodes):
  322. if returncode != 0:
  323. keyboard, keymap, command = returncodes[i]
  324. cli.echo('\tkeyboard: {fg_cyan}%s{fg_reset} keymap: {fg_cyan}%s', keyboard, keymap)
  325. elif command:
  326. if target:
  327. cli.log.info('Building {fg_cyan}%s{fg_reset} with keymap {fg_cyan}%s{fg_reset} and target {fg_cyan}%s', keyboard, keymap, target)
  328. else:
  329. cli.log.info('Building {fg_cyan}%s{fg_reset} with keymap {fg_cyan}%s', keyboard, keymap)
  330. if _execute_compile(keyboard, keymap, command, target) != 0:
  331. print()
  332. cli.log.error('Could not compile all targets, look above this message for more details. Failing target(s):')
  333. cli.echo('\tkeyboard: {fg_cyan}%s{fg_reset} keymap: {fg_cyan}%s', keyboard, keymap)
  334. elif filters:
  335. cli.log.error('No keyboards found after applying filter(s)!')
  336. return False
  337. else:
  338. cli.log.error('You must supply a configurator export, both `--keyboard` and `--keymap`, or be in a directory for a keyboard or keymap.')
  339. cli.print_help()
  340. return False
  341. def _execute_compile(keyboard, keymap, command, target, returncodes=None):
  342. if not returncodes:
  343. returncodes = []
  344. if keymap not in qmk.keymap.list_keymaps(keyboard):
  345. cli.log.debug('Skipping keyboard %s, no %s keymap found.', keyboard, keymap)
  346. return 0
  347. cli.log.debug('Running make command: {fg_blue}%s', ' '.join(command))
  348. if not cli.args.dry_run:
  349. compile = cli.run(command, combined_output=True)
  350. cli.acquire_lock()
  351. returncodes.append(compile.returncode)
  352. cli.release_lock()
  353. if compile.returncode != 0:
  354. cli.log.info('Could not build firmware for {fg_cyan}%s{fg_reset} with keymap {fg_cyan}%s', keyboard, keymap)
  355. print(compile.stdout)
  356. @lru_cache()
  357. def _keyboard_list(keyboard):
  358. """Returns a list of keyboards matching keyboard.
  359. """
  360. if keyboard == 'all' or keyboard.startswith('all-'):
  361. return list_keyboards()
  362. return [keyboard]
  363. def keyboard_keymap_iter(cli_keyboard, cli_keymap, filters):
  364. """Iterates over the keyboard/keymap for this command and yields a pairing of each.
  365. """
  366. for keyboard in _keyboard_list(cli_keyboard):
  367. continue_flag = False
  368. if filters:
  369. info_data = dotty(info_json(keyboard))
  370. for key, value in filters.items():
  371. if info_data.get(key) != value:
  372. continue_flag = True
  373. break
  374. if continue_flag:
  375. continue
  376. if cli_keymap == 'all':
  377. for keymap in qmk.keymap.list_keymaps(keyboard):
  378. yield keyboard, keymap
  379. else:
  380. yield keyboard, cli_keymap