build_targets.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276
  1. # Copyright 2023-2024 Nick Brassel (@tzarc)
  2. # SPDX-License-Identifier: GPL-2.0-or-later
  3. import json
  4. import shutil
  5. from typing import Dict, List, Union
  6. from pathlib import Path
  7. from dotty_dict import dotty, Dotty
  8. from milc import cli
  9. from qmk.constants import QMK_FIRMWARE, INTERMEDIATE_OUTPUT_PREFIX, HAS_QMK_USERSPACE, QMK_USERSPACE
  10. from qmk.commands import find_make, get_make_parallel_args, parse_configurator_json
  11. from qmk.keyboard import keyboard_folder
  12. from qmk.info import keymap_json
  13. from qmk.keymap import locate_keymap
  14. from qmk.path import is_under_qmk_firmware, is_under_qmk_userspace, unix_style_path
  15. from qmk.compilation_database import write_compilation_database
  16. # These must be kept in the order in which they're applied to $(TARGET) in the makefiles in order to ensure consistency.
  17. TARGET_FILENAME_MODIFIERS = ['FORCE_LAYOUT', 'CONVERT_TO']
  18. class BuildTarget:
  19. def __init__(self, keyboard: str, keymap: str, json: Union[dict, Dotty] = None):
  20. self._keyboard = keyboard_folder(keyboard)
  21. self._keyboard_safe = self._keyboard.replace('/', '_')
  22. self._keymap = keymap
  23. self._parallel = 1
  24. self._clean = False
  25. self._compiledb = False
  26. self._extra_args = {}
  27. self._json = json.to_dict() if isinstance(json, Dotty) else json
  28. def __str__(self):
  29. return f'{self.keyboard}:{self.keymap}'
  30. def __repr__(self):
  31. if len(self._extra_args.items()) > 0:
  32. return f'BuildTarget(keyboard={self.keyboard}, keymap={self.keymap}, extra_args={json.dumps(self._extra_args, sort_keys=True)})'
  33. return f'BuildTarget(keyboard={self.keyboard}, keymap={self.keymap})'
  34. def __lt__(self, __value: object) -> bool:
  35. return self.__repr__() < __value.__repr__()
  36. def __eq__(self, __value: object) -> bool:
  37. if not isinstance(__value, BuildTarget):
  38. return False
  39. return self.__repr__() == __value.__repr__()
  40. def __hash__(self) -> int:
  41. return self.__repr__().__hash__()
  42. def configure(self, parallel: int = None, clean: bool = None, compiledb: bool = None) -> None:
  43. if parallel is not None:
  44. self._parallel = parallel
  45. if clean is not None:
  46. self._clean = clean
  47. if compiledb is not None:
  48. self._compiledb = compiledb
  49. @property
  50. def keyboard(self) -> str:
  51. return self._keyboard
  52. @property
  53. def keymap(self) -> str:
  54. return self._keymap
  55. @property
  56. def json(self) -> dict:
  57. if not self._json:
  58. self._load_json()
  59. if not self._json:
  60. return {}
  61. return self._json
  62. @property
  63. def dotty(self) -> Dotty:
  64. return dotty(self.json)
  65. @property
  66. def extra_args(self) -> Dict[str, str]:
  67. return {k: v for k, v in self._extra_args.items()}
  68. @extra_args.setter
  69. def extra_args(self, ex_args: Dict[str, str]):
  70. if ex_args is not None and isinstance(ex_args, dict):
  71. self._extra_args = {k: v for k, v in ex_args.items()}
  72. def target_name(self, **env_vars) -> str:
  73. # Work out the intended target name
  74. target = f'{self._keyboard_safe}_{self.keymap}'
  75. vars = self._all_vars(**env_vars)
  76. for modifier in TARGET_FILENAME_MODIFIERS:
  77. if modifier in vars:
  78. target += f"_{vars[modifier]}"
  79. return target
  80. def _all_vars(self, **env_vars) -> Dict[str, str]:
  81. vars = {k: v for k, v in env_vars.items()}
  82. for k, v in self._extra_args.items():
  83. vars[k] = v
  84. return vars
  85. def _intermediate_output(self, **env_vars) -> Path:
  86. return Path(f'{INTERMEDIATE_OUTPUT_PREFIX}{self.target_name(**env_vars)}')
  87. def _common_make_args(self, dry_run: bool = False, build_target: str = None, **env_vars):
  88. compile_args = [
  89. find_make(),
  90. *get_make_parallel_args(self._parallel),
  91. '-r',
  92. '-R',
  93. '-f',
  94. 'builddefs/build_keyboard.mk',
  95. ]
  96. verbose = 'true' if cli.config.general.verbose else 'false'
  97. color = 'true' if cli.config.general.color else 'false'
  98. if dry_run:
  99. compile_args.append('-n')
  100. if build_target:
  101. compile_args.append(build_target)
  102. compile_args.extend([
  103. f'KEYBOARD={self.keyboard}',
  104. f'KEYMAP={self.keymap}',
  105. f'KEYBOARD_FILESAFE={self._keyboard_safe}',
  106. f'TARGET={self._keyboard_safe}_{self.keymap}', # don't use self.target_name() here, it's rebuilt on the makefile side
  107. f'VERBOSE={verbose}',
  108. f'COLOR={color}',
  109. 'SILENT=false',
  110. 'QMK_BIN="qmk"',
  111. ])
  112. vars = self._all_vars(**env_vars)
  113. for k, v in vars.items():
  114. compile_args.append(f'{k}={v}')
  115. return compile_args
  116. def prepare_build(self, build_target: str = None, dry_run: bool = False, **env_vars) -> None:
  117. raise NotImplementedError("prepare_build() not implemented in base class")
  118. def compile_command(self, build_target: str = None, dry_run: bool = False, **env_vars) -> List[str]:
  119. raise NotImplementedError("compile_command() not implemented in base class")
  120. def generate_compilation_database(self, build_target: str = None, skip_clean: bool = False, **env_vars) -> None:
  121. self.prepare_build(build_target=build_target, **env_vars)
  122. command = self.compile_command(build_target=build_target, dry_run=True, **env_vars)
  123. output_path = QMK_FIRMWARE / 'compile_commands.json'
  124. ret = write_compilation_database(command=command, output_path=output_path, skip_clean=skip_clean, **env_vars)
  125. if ret and output_path.exists() and HAS_QMK_USERSPACE:
  126. shutil.copy(str(output_path), str(QMK_USERSPACE / 'compile_commands.json'))
  127. return ret
  128. def compile(self, build_target: str = None, dry_run: bool = False, **env_vars) -> None:
  129. if self._clean or self._compiledb:
  130. command = [find_make(), "clean"]
  131. if dry_run:
  132. command.append('-n')
  133. cli.log.info('Cleaning with {fg_cyan}%s', ' '.join(command))
  134. cli.run(command, capture_output=False)
  135. if self._compiledb and not dry_run:
  136. self.generate_compilation_database(build_target=build_target, skip_clean=True, **env_vars)
  137. self.prepare_build(build_target=build_target, dry_run=dry_run, **env_vars)
  138. command = self.compile_command(build_target=build_target, **env_vars)
  139. cli.log.info('Compiling keymap with {fg_cyan}%s', ' '.join(command))
  140. if not dry_run:
  141. cli.echo('\n')
  142. ret = cli.run(command, capture_output=False)
  143. if ret.returncode:
  144. return ret.returncode
  145. class KeyboardKeymapBuildTarget(BuildTarget):
  146. def __init__(self, keyboard: str, keymap: str, json: dict = None):
  147. super().__init__(keyboard=keyboard, keymap=keymap, json=json)
  148. def __repr__(self):
  149. if len(self._extra_args.items()) > 0:
  150. return f'KeyboardKeymapTarget(keyboard={self.keyboard}, keymap={self.keymap}, extra_args={self._extra_args})'
  151. return f'KeyboardKeymapTarget(keyboard={self.keyboard}, keymap={self.keymap})'
  152. def _load_json(self):
  153. self._json = keymap_json(self.keyboard, self.keymap)
  154. def prepare_build(self, build_target: str = None, dry_run: bool = False, **env_vars) -> None:
  155. pass
  156. def compile_command(self, build_target: str = None, dry_run: bool = False, **env_vars) -> List[str]:
  157. compile_args = self._common_make_args(dry_run=dry_run, build_target=build_target, **env_vars)
  158. # Need to override the keymap path if the keymap is a userspace directory.
  159. # This also ensures keyboard aliases as per `keyboard_aliases.hjson` still work if the userspace has the keymap
  160. # in an equivalent historical location.
  161. vars = self._all_vars(**env_vars)
  162. keymap_location = locate_keymap(self.keyboard, self.keymap, force_layout=vars.get('FORCE_LAYOUT'))
  163. if is_under_qmk_userspace(keymap_location) and not is_under_qmk_firmware(keymap_location):
  164. keymap_directory = keymap_location.parent
  165. compile_args.extend([
  166. f'MAIN_KEYMAP_PATH_1={unix_style_path(keymap_directory)}',
  167. f'MAIN_KEYMAP_PATH_2={unix_style_path(keymap_directory)}',
  168. f'MAIN_KEYMAP_PATH_3={unix_style_path(keymap_directory)}',
  169. f'MAIN_KEYMAP_PATH_4={unix_style_path(keymap_directory)}',
  170. f'MAIN_KEYMAP_PATH_5={unix_style_path(keymap_directory)}',
  171. ])
  172. return compile_args
  173. class JsonKeymapBuildTarget(BuildTarget):
  174. def __init__(self, json_path):
  175. if isinstance(json_path, Path):
  176. self.json_path = json_path
  177. else:
  178. self.json_path = None
  179. json = parse_configurator_json(json_path) # Will load from stdin if provided
  180. # In case the user passes a keymap.json from a keymap directory directly to the CLI.
  181. # e.g.: qmk compile - < keyboards/clueboard/california/keymaps/default/keymap.json
  182. json["keymap"] = json.get("keymap", "default_json")
  183. super().__init__(keyboard=json['keyboard'], keymap=json['keymap'], json=json)
  184. def __repr__(self):
  185. if len(self._extra_args.items()) > 0:
  186. return f'JsonKeymapTarget(keyboard={self.keyboard}, keymap={self.keymap}, path={self.json_path}, extra_args={self._extra_args})'
  187. return f'JsonKeymapTarget(keyboard={self.keyboard}, keymap={self.keymap}, path={self.json_path})'
  188. def _load_json(self):
  189. pass # Already loaded in constructor
  190. def prepare_build(self, build_target: str = None, dry_run: bool = False, **env_vars) -> None:
  191. intermediate_output = self._intermediate_output(**env_vars)
  192. generated_files_path = intermediate_output / 'src'
  193. keymap_json = generated_files_path / 'keymap.json'
  194. if self._clean:
  195. if intermediate_output.exists():
  196. shutil.rmtree(intermediate_output)
  197. # begin with making the deepest folder in the tree
  198. generated_files_path.mkdir(exist_ok=True, parents=True)
  199. # Compare minified to ensure consistent comparison
  200. new_content = json.dumps(self.json, separators=(',', ':'))
  201. if keymap_json.exists():
  202. old_content = json.dumps(json.loads(keymap_json.read_text(encoding='utf-8')), separators=(',', ':'))
  203. if old_content == new_content:
  204. new_content = None
  205. # Write the keymap.json file if different so timestamps are only updated
  206. # if the content changes -- running `make` won't treat it as modified.
  207. if new_content:
  208. keymap_json.write_text(new_content, encoding='utf-8')
  209. def compile_command(self, build_target: str = None, dry_run: bool = False, **env_vars) -> List[str]:
  210. compile_args = self._common_make_args(dry_run=dry_run, build_target=build_target, **env_vars)
  211. intermediate_output = self._intermediate_output(**env_vars)
  212. generated_files_path = intermediate_output / 'src'
  213. keymap_json = generated_files_path / 'keymap.json'
  214. compile_args.extend([
  215. f'MAIN_KEYMAP_PATH_1={unix_style_path(intermediate_output)}',
  216. f'MAIN_KEYMAP_PATH_2={unix_style_path(intermediate_output)}',
  217. f'MAIN_KEYMAP_PATH_3={unix_style_path(intermediate_output)}',
  218. f'MAIN_KEYMAP_PATH_4={unix_style_path(intermediate_output)}',
  219. f'MAIN_KEYMAP_PATH_5={unix_style_path(intermediate_output)}',
  220. f'KEYMAP_JSON={keymap_json}',
  221. f'KEYMAP_PATH={generated_files_path}',
  222. ])
  223. return compile_args