# Copyright 2023-2024 Nick Brassel (@tzarc) # SPDX-License-Identifier: GPL-2.0-or-later import json import shutil from typing import Dict, List, Union from pathlib import Path from dotty_dict import dotty, Dotty from milc import cli from qmk.constants import QMK_FIRMWARE, INTERMEDIATE_OUTPUT_PREFIX, HAS_QMK_USERSPACE, QMK_USERSPACE from qmk.commands import find_make, get_make_parallel_args, parse_configurator_json from qmk.keyboard import keyboard_folder from qmk.info import keymap_json from qmk.keymap import locate_keymap from qmk.path import is_under_qmk_firmware, is_under_qmk_userspace # These must be kept in the order in which they're applied to $(TARGET) in the makefiles in order to ensure consistency. TARGET_FILENAME_MODIFIERS = ['FORCE_LAYOUT', 'CONVERT_TO'] class BuildTarget: def __init__(self, keyboard: str, keymap: str, json: Union[dict, Dotty] = None): self._keyboard = keyboard_folder(keyboard) self._keyboard_safe = self._keyboard.replace('/', '_') self._keymap = keymap self._parallel = 1 self._clean = False self._compiledb = False self._extra_args = {} self._json = json.to_dict() if isinstance(json, Dotty) else json def __str__(self): return f'{self.keyboard}:{self.keymap}' def __repr__(self): if len(self._extra_args.items()) > 0: return f'BuildTarget(keyboard={self.keyboard}, keymap={self.keymap}, extra_args={json.dumps(self._extra_args, sort_keys=True)})' return f'BuildTarget(keyboard={self.keyboard}, keymap={self.keymap})' def __lt__(self, __value: object) -> bool: return self.__repr__() < __value.__repr__() def __eq__(self, __value: object) -> bool: if not isinstance(__value, BuildTarget): return False return self.__repr__() == __value.__repr__() def __hash__(self) -> int: return self.__repr__().__hash__() def configure(self, parallel: int = None, clean: bool = None, compiledb: bool = None) -> None: if parallel is not None: self._parallel = parallel if clean is not None: self._clean = clean if compiledb is not None: self._compiledb = compiledb @property def keyboard(self) -> str: return self._keyboard @property def keymap(self) -> str: return self._keymap @property def json(self) -> dict: if not self._json: self._load_json() if not self._json: return {} return self._json @property def dotty(self) -> Dotty: return dotty(self.json) @property def extra_args(self) -> Dict[str, str]: return {k: v for k, v in self._extra_args.items()} @extra_args.setter def extra_args(self, ex_args: Dict[str, str]): if ex_args is not None and isinstance(ex_args, dict): self._extra_args = {k: v for k, v in ex_args.items()} def target_name(self, **env_vars) -> str: # Work out the intended target name target = f'{self._keyboard_safe}_{self.keymap}' vars = self._all_vars(**env_vars) for modifier in TARGET_FILENAME_MODIFIERS: if modifier in vars: target += f"_{vars[modifier]}" return target def _all_vars(self, **env_vars) -> Dict[str, str]: vars = {k: v for k, v in env_vars.items()} for k, v in self._extra_args.items(): vars[k] = v return vars def _intermediate_output(self, **env_vars) -> Path: return Path(f'{INTERMEDIATE_OUTPUT_PREFIX}{self.target_name(**env_vars)}') def _common_make_args(self, dry_run: bool = False, build_target: str = None, **env_vars): compile_args = [ find_make(), *get_make_parallel_args(self._parallel), '-r', '-R', '-f', 'builddefs/build_keyboard.mk', ] if not cli.config.general.verbose: compile_args.append('-s') verbose = 'true' if cli.config.general.verbose else 'false' color = 'true' if cli.config.general.color else 'false' if dry_run: compile_args.append('-n') if build_target: compile_args.append(build_target) compile_args.extend([ f'KEYBOARD={self.keyboard}', f'KEYMAP={self.keymap}', f'KEYBOARD_FILESAFE={self._keyboard_safe}', f'TARGET={self._keyboard_safe}_{self.keymap}', # don't use self.target_name() here, it's rebuilt on the makefile side f'VERBOSE={verbose}', f'COLOR={color}', 'SILENT=false', 'QMK_BIN="qmk"', ]) vars = self._all_vars(**env_vars) for k, v in vars.items(): compile_args.append(f'{k}={v}') return compile_args def prepare_build(self, build_target: str = None, dry_run: bool = False, **env_vars) -> None: raise NotImplementedError("prepare_build() not implemented in base class") def compile_command(self, build_target: str = None, dry_run: bool = False, **env_vars) -> List[str]: raise NotImplementedError("compile_command() not implemented in base class") def generate_compilation_database(self, build_target: str = None, skip_clean: bool = False, **env_vars) -> None: self.prepare_build(build_target=build_target, **env_vars) command = self.compile_command(build_target=build_target, dry_run=True, **env_vars) from qmk.cli.generate.compilation_database import write_compilation_database # Lazy load due to circular references output_path = QMK_FIRMWARE / 'compile_commands.json' ret = write_compilation_database(command=command, output_path=output_path, skip_clean=skip_clean, **env_vars) if ret and output_path.exists() and HAS_QMK_USERSPACE: shutil.copy(str(output_path), str(QMK_USERSPACE / 'compile_commands.json')) return ret def compile(self, build_target: str = None, dry_run: bool = False, **env_vars) -> None: if self._clean or self._compiledb: command = [find_make(), "clean"] if dry_run: command.append('-n') cli.log.info('Cleaning with {fg_cyan}%s', ' '.join(command)) cli.run(command, capture_output=False) if self._compiledb and not dry_run: self.generate_compilation_database(build_target=build_target, skip_clean=True, **env_vars) self.prepare_build(build_target=build_target, dry_run=dry_run, **env_vars) command = self.compile_command(build_target=build_target, **env_vars) cli.log.info('Compiling keymap with {fg_cyan}%s', ' '.join(command)) if not dry_run: cli.echo('\n') ret = cli.run(command, capture_output=False) if ret.returncode: return ret.returncode class KeyboardKeymapBuildTarget(BuildTarget): def __init__(self, keyboard: str, keymap: str, json: dict = None): super().__init__(keyboard=keyboard, keymap=keymap, json=json) def __repr__(self): if len(self._extra_args.items()) > 0: return f'KeyboardKeymapTarget(keyboard={self.keyboard}, keymap={self.keymap}, extra_args={self._extra_args})' return f'KeyboardKeymapTarget(keyboard={self.keyboard}, keymap={self.keymap})' def _load_json(self): self._json = keymap_json(self.keyboard, self.keymap) def prepare_build(self, build_target: str = None, dry_run: bool = False, **env_vars) -> None: pass def compile_command(self, build_target: str = None, dry_run: bool = False, **env_vars) -> List[str]: compile_args = self._common_make_args(dry_run=dry_run, build_target=build_target, **env_vars) # Need to override the keymap path if the keymap is a userspace directory. # This also ensures keyboard aliases as per `keyboard_aliases.hjson` still work if the userspace has the keymap # in an equivalent historical location. vars = self._all_vars(**env_vars) keymap_location = locate_keymap(self.keyboard, self.keymap, force_layout=vars.get('FORCE_LAYOUT')) if is_under_qmk_userspace(keymap_location) and not is_under_qmk_firmware(keymap_location): keymap_directory = keymap_location.parent compile_args.extend([ f'MAIN_KEYMAP_PATH_1={keymap_directory}', f'MAIN_KEYMAP_PATH_2={keymap_directory}', f'MAIN_KEYMAP_PATH_3={keymap_directory}', f'MAIN_KEYMAP_PATH_4={keymap_directory}', f'MAIN_KEYMAP_PATH_5={keymap_directory}', ]) return compile_args class JsonKeymapBuildTarget(BuildTarget): def __init__(self, json_path): if isinstance(json_path, Path): self.json_path = json_path else: self.json_path = None json = parse_configurator_json(json_path) # Will load from stdin if provided # In case the user passes a keymap.json from a keymap directory directly to the CLI. # e.g.: qmk compile - < keyboards/clueboard/california/keymaps/default/keymap.json json["keymap"] = json.get("keymap", "default_json") super().__init__(keyboard=json['keyboard'], keymap=json['keymap'], json=json) def __repr__(self): if len(self._extra_args.items()) > 0: return f'JsonKeymapTarget(keyboard={self.keyboard}, keymap={self.keymap}, path={self.json_path}, extra_args={self._extra_args})' return f'JsonKeymapTarget(keyboard={self.keyboard}, keymap={self.keymap}, path={self.json_path})' def _load_json(self): pass # Already loaded in constructor def prepare_build(self, build_target: str = None, dry_run: bool = False, **env_vars) -> None: intermediate_output = self._intermediate_output(**env_vars) generated_files_path = intermediate_output / 'src' keymap_json = generated_files_path / 'keymap.json' if self._clean: if intermediate_output.exists(): shutil.rmtree(intermediate_output) # begin with making the deepest folder in the tree generated_files_path.mkdir(exist_ok=True, parents=True) # Compare minified to ensure consistent comparison new_content = json.dumps(self.json, separators=(',', ':')) if keymap_json.exists(): old_content = json.dumps(json.loads(keymap_json.read_text(encoding='utf-8')), separators=(',', ':')) if old_content == new_content: new_content = None # Write the keymap.json file if different so timestamps are only updated # if the content changes -- running `make` won't treat it as modified. if new_content: keymap_json.write_text(new_content, encoding='utf-8') def compile_command(self, build_target: str = None, dry_run: bool = False, **env_vars) -> List[str]: compile_args = self._common_make_args(dry_run=dry_run, build_target=build_target, **env_vars) intermediate_output = self._intermediate_output(**env_vars) generated_files_path = intermediate_output / 'src' keymap_json = generated_files_path / 'keymap.json' compile_args.extend([ f'MAIN_KEYMAP_PATH_1={intermediate_output}', f'MAIN_KEYMAP_PATH_2={intermediate_output}', f'MAIN_KEYMAP_PATH_3={intermediate_output}', f'MAIN_KEYMAP_PATH_4={intermediate_output}', f'MAIN_KEYMAP_PATH_5={intermediate_output}', f'KEYMAP_JSON={keymap_json}', f'KEYMAP_PATH={generated_files_path}', ]) return compile_args