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