summaryrefslogtreecommitdiff
path: root/lib/python
diff options
context:
space:
mode:
authorQMK Bot <hello@qmk.fm>2024-08-12 12:34:54 +0000
committerQMK Bot <hello@qmk.fm>2024-08-12 12:34:54 +0000
commit2c6409fdd8deed5b10410b5e9a74748499e0739c (patch)
tree240efdcdff73fa87a47c5cd9b87b63a7b9263291 /lib/python
parent783f97ff32de1d6febceb09f46dfa624e4fc56ec (diff)
parent380e0c9cad72ac29f858bef85c8b8eb35b6931f0 (diff)
Merge remote-tracking branch 'origin/master' into develop
Diffstat (limited to 'lib/python')
-rw-r--r--lib/python/qmk/build_targets.py107
-rwxr-xr-xlib/python/qmk/cli/format/json.py9
-rwxr-xr-xlib/python/qmk/cli/mass_compile.py24
-rw-r--r--lib/python/qmk/cli/userspace/add.py10
-rw-r--r--lib/python/qmk/cli/userspace/compile.py10
-rw-r--r--lib/python/qmk/cli/userspace/list.py23
-rw-r--r--lib/python/qmk/cli/userspace/remove.py10
-rw-r--r--lib/python/qmk/commands.py7
-rw-r--r--lib/python/qmk/info.py19
-rw-r--r--lib/python/qmk/keymap.py4
-rw-r--r--lib/python/qmk/search.py159
-rw-r--r--lib/python/qmk/userspace.py66
12 files changed, 304 insertions, 144 deletions
diff --git a/lib/python/qmk/build_targets.py b/lib/python/qmk/build_targets.py
index d974d04020..e2df029490 100644
--- a/lib/python/qmk/build_targets.py
+++ b/lib/python/qmk/build_targets.py
@@ -1,8 +1,8 @@
1# Copyright 2023 Nick Brassel (@tzarc) 1# Copyright 2023-2024 Nick Brassel (@tzarc)
2# SPDX-License-Identifier: GPL-2.0-or-later 2# SPDX-License-Identifier: GPL-2.0-or-later
3import json 3import json
4import shutil 4import shutil
5from typing import List, Union 5from typing import Dict, List, Union
6from pathlib import Path 6from pathlib import Path
7from dotty_dict import dotty, Dotty 7from dotty_dict import dotty, Dotty
8from milc import cli 8from milc import cli
@@ -13,6 +13,9 @@ from qmk.info import keymap_json
13from qmk.keymap import locate_keymap 13from qmk.keymap import locate_keymap
14from qmk.path import is_under_qmk_firmware, is_under_qmk_userspace 14from qmk.path import is_under_qmk_firmware, is_under_qmk_userspace
15 15
16# These must be kept in the order in which they're applied to $(TARGET) in the makefiles in order to ensure consistency.
17TARGET_FILENAME_MODIFIERS = ['FORCE_LAYOUT', 'CONVERT_TO']
18
16 19
17class BuildTarget: 20class BuildTarget:
18 def __init__(self, keyboard: str, keymap: str, json: Union[dict, Dotty] = None): 21 def __init__(self, keyboard: str, keymap: str, json: Union[dict, Dotty] = None):
@@ -22,25 +25,25 @@ class BuildTarget:
22 self._parallel = 1 25 self._parallel = 1
23 self._clean = False 26 self._clean = False
24 self._compiledb = False 27 self._compiledb = False
25 self._target = f'{self._keyboard_safe}_{self.keymap}' 28 self._extra_args = {}
26 self._intermediate_output = Path(f'{INTERMEDIATE_OUTPUT_PREFIX}{self._target}')
27 self._generated_files_path = self._intermediate_output / 'src'
28 self._json = json.to_dict() if isinstance(json, Dotty) else json 29 self._json = json.to_dict() if isinstance(json, Dotty) else json
29 30
30 def __str__(self): 31 def __str__(self):
31 return f'{self.keyboard}:{self.keymap}' 32 return f'{self.keyboard}:{self.keymap}'
32 33
33 def __repr__(self): 34 def __repr__(self):
35 if len(self._extra_args.items()) > 0:
36 return f'BuildTarget(keyboard={self.keyboard}, keymap={self.keymap}, extra_args={json.dumps(self._extra_args, sort_keys=True)})'
34 return f'BuildTarget(keyboard={self.keyboard}, keymap={self.keymap})' 37 return f'BuildTarget(keyboard={self.keyboard}, keymap={self.keymap})'
35 38
39 def __lt__(self, __value: object) -> bool:
40 return self.__repr__() < __value.__repr__()
41
36 def __eq__(self, __value: object) -> bool: 42 def __eq__(self, __value: object) -> bool:
37 if not isinstance(__value, BuildTarget): 43 if not isinstance(__value, BuildTarget):
38 return False 44 return False
39 return self.__repr__() == __value.__repr__() 45 return self.__repr__() == __value.__repr__()
40 46
41 def __ne__(self, __value: object) -> bool:
42 return not self.__eq__(__value)
43
44 def __hash__(self) -> int: 47 def __hash__(self) -> int:
45 return self.__repr__().__hash__() 48 return self.__repr__().__hash__()
46 49
@@ -72,7 +75,34 @@ class BuildTarget:
72 def dotty(self) -> Dotty: 75 def dotty(self) -> Dotty:
73 return dotty(self.json) 76 return dotty(self.json)
74 77
75 def _common_make_args(self, dry_run: bool = False, build_target: str = None): 78 @property
79 def extra_args(self) -> Dict[str, str]:
80 return {k: v for k, v in self._extra_args.items()}
81
82 @extra_args.setter
83 def extra_args(self, ex_args: Dict[str, str]):
84 if ex_args is not None and isinstance(ex_args, dict):
85 self._extra_args = {k: v for k, v in ex_args.items()}
86
87 def target_name(self, **env_vars) -> str:
88 # Work out the intended target name
89 target = f'{self._keyboard_safe}_{self.keymap}'
90 vars = self._all_vars(**env_vars)
91 for modifier in TARGET_FILENAME_MODIFIERS:
92 if modifier in vars:
93 target += f"_{vars[modifier]}"
94 return target
95
96 def _all_vars(self, **env_vars) -> Dict[str, str]:
97 vars = {k: v for k, v in env_vars.items()}
98 for k, v in self._extra_args.items():
99 vars[k] = v
100 return vars
101
102 def _intermediate_output(self, **env_vars) -> Path:
103 return Path(f'{INTERMEDIATE_OUTPUT_PREFIX}{self.target_name(**env_vars)}')
104
105 def _common_make_args(self, dry_run: bool = False, build_target: str = None, **env_vars):
76 compile_args = [ 106 compile_args = [
77 find_make(), 107 find_make(),
78 *get_make_parallel_args(self._parallel), 108 *get_make_parallel_args(self._parallel),
@@ -98,14 +128,17 @@ class BuildTarget:
98 f'KEYBOARD={self.keyboard}', 128 f'KEYBOARD={self.keyboard}',
99 f'KEYMAP={self.keymap}', 129 f'KEYMAP={self.keymap}',
100 f'KEYBOARD_FILESAFE={self._keyboard_safe}', 130 f'KEYBOARD_FILESAFE={self._keyboard_safe}',
101 f'TARGET={self._target}', 131 f'TARGET={self._keyboard_safe}_{self.keymap}', # don't use self.target_name() here, it's rebuilt on the makefile side
102 f'INTERMEDIATE_OUTPUT={self._intermediate_output}',
103 f'VERBOSE={verbose}', 132 f'VERBOSE={verbose}',
104 f'COLOR={color}', 133 f'COLOR={color}',
105 'SILENT=false', 134 'SILENT=false',
106 'QMK_BIN="qmk"', 135 'QMK_BIN="qmk"',
107 ]) 136 ])
108 137
138 vars = self._all_vars(**env_vars)
139 for k, v in vars.items():
140 compile_args.append(f'{k}={v}')
141
109 return compile_args 142 return compile_args
110 143
111 def prepare_build(self, build_target: str = None, dry_run: bool = False, **env_vars) -> None: 144 def prepare_build(self, build_target: str = None, dry_run: bool = False, **env_vars) -> None:
@@ -150,6 +183,8 @@ class KeyboardKeymapBuildTarget(BuildTarget):
150 super().__init__(keyboard=keyboard, keymap=keymap, json=json) 183 super().__init__(keyboard=keyboard, keymap=keymap, json=json)
151 184
152 def __repr__(self): 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})'
153 return f'KeyboardKeymapTarget(keyboard={self.keyboard}, keymap={self.keymap})' 188 return f'KeyboardKeymapTarget(keyboard={self.keyboard}, keymap={self.keymap})'
154 189
155 def _load_json(self): 190 def _load_json(self):
@@ -159,15 +194,13 @@ class KeyboardKeymapBuildTarget(BuildTarget):
159 pass 194 pass
160 195
161 def compile_command(self, build_target: str = None, dry_run: bool = False, **env_vars) -> List[str]: 196 def compile_command(self, build_target: str = None, dry_run: bool = False, **env_vars) -> List[str]:
162 compile_args = self._common_make_args(dry_run=dry_run, build_target=build_target) 197 compile_args = self._common_make_args(dry_run=dry_run, build_target=build_target, **env_vars)
163
164 for key, value in env_vars.items():
165 compile_args.append(f'{key}={value}')
166 198
167 # Need to override the keymap path if the keymap is a userspace directory. 199 # Need to override the keymap path if the keymap is a userspace directory.
168 # This also ensures keyboard aliases as per `keyboard_aliases.hjson` still work if the userspace has the keymap 200 # This also ensures keyboard aliases as per `keyboard_aliases.hjson` still work if the userspace has the keymap
169 # in an equivalent historical location. 201 # in an equivalent historical location.
170 keymap_location = locate_keymap(self.keyboard, self.keymap) 202 vars = self._all_vars(**env_vars)
203 keymap_location = locate_keymap(self.keyboard, self.keymap, force_layout=vars.get('FORCE_LAYOUT'))
171 if is_under_qmk_userspace(keymap_location) and not is_under_qmk_firmware(keymap_location): 204 if is_under_qmk_userspace(keymap_location) and not is_under_qmk_firmware(keymap_location):
172 keymap_directory = keymap_location.parent 205 keymap_directory = keymap_location.parent
173 compile_args.extend([ 206 compile_args.extend([
@@ -196,47 +229,51 @@ class JsonKeymapBuildTarget(BuildTarget):
196 229
197 super().__init__(keyboard=json['keyboard'], keymap=json['keymap'], json=json) 230 super().__init__(keyboard=json['keyboard'], keymap=json['keymap'], json=json)
198 231
199 self._keymap_json = self._generated_files_path / 'keymap.json'
200
201 def __repr__(self): 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})'
202 return f'JsonKeymapTarget(keyboard={self.keyboard}, keymap={self.keymap}, path={self.json_path})' 235 return f'JsonKeymapTarget(keyboard={self.keyboard}, keymap={self.keymap}, path={self.json_path})'
203 236
204 def _load_json(self): 237 def _load_json(self):
205 pass # Already loaded in constructor 238 pass # Already loaded in constructor
206 239
207 def prepare_build(self, build_target: str = None, dry_run: bool = False, **env_vars) -> None: 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
208 if self._clean: 245 if self._clean:
209 if self._intermediate_output.exists(): 246 if intermediate_output.exists():
210 shutil.rmtree(self._intermediate_output) 247 shutil.rmtree(intermediate_output)
211 248
212 # begin with making the deepest folder in the tree 249 # begin with making the deepest folder in the tree
213 self._generated_files_path.mkdir(exist_ok=True, parents=True) 250 generated_files_path.mkdir(exist_ok=True, parents=True)
214 251
215 # Compare minified to ensure consistent comparison 252 # Compare minified to ensure consistent comparison
216 new_content = json.dumps(self.json, separators=(',', ':')) 253 new_content = json.dumps(self.json, separators=(',', ':'))
217 if self._keymap_json.exists(): 254 if keymap_json.exists():
218 old_content = json.dumps(json.loads(self._keymap_json.read_text(encoding='utf-8')), separators=(',', ':')) 255 old_content = json.dumps(json.loads(keymap_json.read_text(encoding='utf-8')), separators=(',', ':'))
219 if old_content == new_content: 256 if old_content == new_content:
220 new_content = None 257 new_content = None
221 258
222 # Write the keymap.json file if different so timestamps are only updated 259 # Write the keymap.json file if different so timestamps are only updated
223 # if the content changes -- running `make` won't treat it as modified. 260 # if the content changes -- running `make` won't treat it as modified.
224 if new_content: 261 if new_content:
225 self._keymap_json.write_text(new_content, encoding='utf-8') 262 keymap_json.write_text(new_content, encoding='utf-8')
226 263
227 def compile_command(self, build_target: str = None, dry_run: bool = False, **env_vars) -> List[str]: 264 def compile_command(self, build_target: str = None, dry_run: bool = False, **env_vars) -> List[str]:
228 compile_args = self._common_make_args(dry_run=dry_run, build_target=build_target) 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'
229 compile_args.extend([ 269 compile_args.extend([
230 f'MAIN_KEYMAP_PATH_1={self._intermediate_output}', 270 f'MAIN_KEYMAP_PATH_1={intermediate_output}',
231 f'MAIN_KEYMAP_PATH_2={self._intermediate_output}', 271 f'MAIN_KEYMAP_PATH_2={intermediate_output}',
232 f'MAIN_KEYMAP_PATH_3={self._intermediate_output}', 272 f'MAIN_KEYMAP_PATH_3={intermediate_output}',
233 f'MAIN_KEYMAP_PATH_4={self._intermediate_output}', 273 f'MAIN_KEYMAP_PATH_4={intermediate_output}',
234 f'MAIN_KEYMAP_PATH_5={self._intermediate_output}', 274 f'MAIN_KEYMAP_PATH_5={intermediate_output}',
235 f'KEYMAP_JSON={self._keymap_json}', 275 f'KEYMAP_JSON={keymap_json}',
236 f'KEYMAP_PATH={self._generated_files_path}', 276 f'KEYMAP_PATH={generated_files_path}',
237 ]) 277 ])
238 278
239 for key, value in env_vars.items():
240 compile_args.append(f'{key}={value}')
241
242 return compile_args 279 return compile_args
diff --git a/lib/python/qmk/cli/format/json.py b/lib/python/qmk/cli/format/json.py
index 87a3837d10..3670294434 100755
--- a/lib/python/qmk/cli/format/json.py
+++ b/lib/python/qmk/cli/format/json.py
@@ -18,13 +18,20 @@ def _detect_json_format(file, json_data):
18 """ 18 """
19 json_encoder = None 19 json_encoder = None
20 try: 20 try:
21 validate(json_data, 'qmk.user_repo.v1') 21 validate(json_data, 'qmk.user_repo.v1_1')
22 json_encoder = UserspaceJSONEncoder 22 json_encoder = UserspaceJSONEncoder
23 except ValidationError: 23 except ValidationError:
24 pass 24 pass
25 25
26 if json_encoder is None: 26 if json_encoder is None:
27 try: 27 try:
28 validate(json_data, 'qmk.user_repo.v1')
29 json_encoder = UserspaceJSONEncoder
30 except ValidationError:
31 pass
32
33 if json_encoder is None:
34 try:
28 validate(json_data, 'qmk.keyboard.v1') 35 validate(json_data, 'qmk.keyboard.v1')
29 json_encoder = InfoJSONEncoder 36 json_encoder = InfoJSONEncoder
30 except ValidationError as e: 37 except ValidationError as e:
diff --git a/lib/python/qmk/cli/mass_compile.py b/lib/python/qmk/cli/mass_compile.py
index d13afc6143..cf9be0fd1e 100755
--- a/lib/python/qmk/cli/mass_compile.py
+++ b/lib/python/qmk/cli/mass_compile.py
@@ -7,6 +7,7 @@ from typing import List
7from pathlib import Path 7from pathlib import Path
8from subprocess import DEVNULL 8from subprocess import DEVNULL
9from milc import cli 9from milc import cli
10import shlex
10 11
11from qmk.constants import QMK_FIRMWARE 12from qmk.constants import QMK_FIRMWARE
12from qmk.commands import find_make, get_make_parallel_args, build_environment 13from qmk.commands import find_make, get_make_parallel_args, build_environment
@@ -26,7 +27,8 @@ def mass_compile_targets(targets: List[BuildTarget], clean: bool, dry_run: bool,
26 if dry_run: 27 if dry_run:
27 cli.log.info('Compilation targets:') 28 cli.log.info('Compilation targets:')
28 for target in sorted(targets, key=lambda t: (t.keyboard, t.keymap)): 29 for target in sorted(targets, key=lambda t: (t.keyboard, t.keymap)):
29 cli.log.info(f"{{fg_cyan}}qmk compile -kb {target.keyboard} -km {target.keymap}{{fg_reset}}") 30 extra_args = ' '.join([f"-e {shlex.quote(f'{k}={v}')}" for k, v in target.extra_args.items()])
31 cli.log.info(f"{{fg_cyan}}qmk compile -kb {target.keyboard} -km {target.keymap} {extra_args}{{fg_reset}}")
30 else: 32 else:
31 if clean: 33 if clean:
32 cli.run([make_cmd, 'clean'], capture_output=False, stdin=DEVNULL) 34 cli.run([make_cmd, 'clean'], capture_output=False, stdin=DEVNULL)
@@ -36,18 +38,26 @@ def mass_compile_targets(targets: List[BuildTarget], clean: bool, dry_run: bool,
36 for target in sorted(targets, key=lambda t: (t.keyboard, t.keymap)): 38 for target in sorted(targets, key=lambda t: (t.keyboard, t.keymap)):
37 keyboard_name = target.keyboard 39 keyboard_name = target.keyboard
38 keymap_name = target.keymap 40 keymap_name = target.keymap
41 keyboard_safe = keyboard_name.replace('/', '_')
42 target_filename = target.target_name(**env)
39 target.configure(parallel=1) # We ignore parallelism on a per-build basis as we defer to the parent make invocation 43 target.configure(parallel=1) # We ignore parallelism on a per-build basis as we defer to the parent make invocation
40 target.prepare_build(**env) # If we've got json targets, allow them to write out any extra info to .build before we kick off `make` 44 target.prepare_build(**env) # If we've got json targets, allow them to write out any extra info to .build before we kick off `make`
41 command = target.compile_command(**env) 45 command = target.compile_command(**env)
42 command[0] = '+@$(MAKE)' # Override the make so that we can use jobserver to handle parallelism 46 command[0] = '+@$(MAKE)' # Override the make so that we can use jobserver to handle parallelism
43 keyboard_safe = keyboard_name.replace('/', '_') 47 extra_args = '_'.join([f"{k}_{v}" for k, v in target.extra_args.items()])
44 build_log = f"{QMK_FIRMWARE}/.build/build.log.{os.getpid()}.{keyboard_safe}.{keymap_name}" 48 build_log = f"{QMK_FIRMWARE}/.build/build.log.{os.getpid()}.{keyboard_safe}.{keymap_name}"
45 failed_log = f"{QMK_FIRMWARE}/.build/failed.log.{os.getpid()}.{keyboard_safe}.{keymap_name}" 49 failed_log = f"{QMK_FIRMWARE}/.build/failed.log.{os.getpid()}.{keyboard_safe}.{keymap_name}"
50 target_suffix = ''
51 if len(extra_args) > 0:
52 build_log += f".{extra_args}"
53 failed_log += f".{extra_args}"
54 target_suffix = f"_{extra_args}"
46 # yapf: disable 55 # yapf: disable
47 f.write( 56 f.write(
48 f"""\ 57 f"""\
49all: {keyboard_safe}_{keymap_name}_binary 58.PHONY: {target_filename}{target_suffix}_binary
50{keyboard_safe}_{keymap_name}_binary: 59all: {target_filename}{target_suffix}_binary
60{target_filename}{target_suffix}_binary:
51 @rm -f "{build_log}" || true 61 @rm -f "{build_log}" || true
52 @echo "Compiling QMK Firmware for target: '{keyboard_name}:{keymap_name}'..." >>"{build_log}" 62 @echo "Compiling QMK Firmware for target: '{keyboard_name}:{keymap_name}'..." >>"{build_log}"
53 {' '.join(command)} \\ 63 {' '.join(command)} \\
@@ -65,9 +75,9 @@ all: {keyboard_safe}_{keymap_name}_binary
65 # yapf: disable 75 # yapf: disable
66 f.write( 76 f.write(
67 f"""\ 77 f"""\
68 @rm -rf "{QMK_FIRMWARE}/.build/{keyboard_safe}_{keymap_name}.elf" 2>/dev/null || true 78 @rm -rf "{QMK_FIRMWARE}/.build/{target_filename}.elf" 2>/dev/null || true
69 @rm -rf "{QMK_FIRMWARE}/.build/{keyboard_safe}_{keymap_name}.map" 2>/dev/null || true 79 @rm -rf "{QMK_FIRMWARE}/.build/{target_filename}.map" 2>/dev/null || true
70 @rm -rf "{QMK_FIRMWARE}/.build/obj_{keyboard_safe}_{keymap_name}" || true 80 @rm -rf "{QMK_FIRMWARE}/.build/obj_{target_filename}" || true
71"""# noqa 81"""# noqa
72 ) 82 )
73 # yapf: enable 83 # yapf: enable
diff --git a/lib/python/qmk/cli/userspace/add.py b/lib/python/qmk/cli/userspace/add.py
index 8993d54dba..0d6f32cd11 100644
--- a/lib/python/qmk/cli/userspace/add.py
+++ b/lib/python/qmk/cli/userspace/add.py
@@ -1,8 +1,9 @@
1# Copyright 2023 Nick Brassel (@tzarc) 1# Copyright 2023-2024 Nick Brassel (@tzarc)
2# SPDX-License-Identifier: GPL-2.0-or-later 2# SPDX-License-Identifier: GPL-2.0-or-later
3from pathlib import Path 3from pathlib import Path
4from milc import cli 4from milc import cli
5 5
6from qmk.commands import parse_env_vars
6from qmk.constants import QMK_USERSPACE, HAS_QMK_USERSPACE 7from qmk.constants import QMK_USERSPACE, HAS_QMK_USERSPACE
7from qmk.keyboard import keyboard_completer, keyboard_folder_or_all 8from qmk.keyboard import keyboard_completer, keyboard_folder_or_all
8from qmk.keymap import keymap_completer, is_keymap_target 9from qmk.keymap import keymap_completer, is_keymap_target
@@ -12,12 +13,15 @@ from qmk.userspace import UserspaceDefs
12@cli.argument('builds', nargs='*', arg_only=True, help="List of builds in form <keyboard>:<keymap>, or path to a keymap JSON file.") 13@cli.argument('builds', nargs='*', arg_only=True, help="List of builds in form <keyboard>:<keymap>, or path to a keymap JSON file.")
13@cli.argument('-kb', '--keyboard', type=keyboard_folder_or_all, completer=keyboard_completer, help='The keyboard to build a firmware for. Ignored when a configurator export is supplied.') 14@cli.argument('-kb', '--keyboard', type=keyboard_folder_or_all, completer=keyboard_completer, help='The keyboard to build a firmware for. Ignored when a configurator export is supplied.')
14@cli.argument('-km', '--keymap', completer=keymap_completer, help='The keymap to build a firmware for. Ignored when a configurator export is supplied.') 15@cli.argument('-km', '--keymap', completer=keymap_completer, help='The keymap to build a firmware for. Ignored when a configurator export is supplied.')
16@cli.argument('-e', '--env', arg_only=True, action='append', default=[], help="Extra variables to set during build. May be passed multiple times.")
15@cli.subcommand('Adds a build target to userspace `qmk.json`.') 17@cli.subcommand('Adds a build target to userspace `qmk.json`.')
16def userspace_add(cli): 18def userspace_add(cli):
17 if not HAS_QMK_USERSPACE: 19 if not HAS_QMK_USERSPACE:
18 cli.log.error('Could not determine QMK userspace location. Please run `qmk doctor` or `qmk userspace-doctor` to diagnose.') 20 cli.log.error('Could not determine QMK userspace location. Please run `qmk doctor` or `qmk userspace-doctor` to diagnose.')
19 return False 21 return False
20 22
23 build_env = None if len(cli.args.env) == 0 else parse_env_vars(cli.args.env)
24
21 userspace = UserspaceDefs(QMK_USERSPACE / 'qmk.json') 25 userspace = UserspaceDefs(QMK_USERSPACE / 'qmk.json')
22 26
23 if len(cli.args.builds) > 0: 27 if len(cli.args.builds) > 0:
@@ -44,8 +48,8 @@ def userspace_add(cli):
44 cli.config.new_keymap.keyboard = cli.args.keyboard 48 cli.config.new_keymap.keyboard = cli.args.keyboard
45 cli.config.new_keymap.keymap = cli.args.keymap 49 cli.config.new_keymap.keymap = cli.args.keymap
46 if new_keymap(cli) is not False: 50 if new_keymap(cli) is not False:
47 userspace.add_target(keyboard=cli.args.keyboard, keymap=cli.args.keymap) 51 userspace.add_target(keyboard=cli.args.keyboard, keymap=cli.args.keymap, build_env=build_env)
48 else: 52 else:
49 userspace.add_target(keyboard=cli.args.keyboard, keymap=cli.args.keymap) 53 userspace.add_target(keyboard=cli.args.keyboard, keymap=cli.args.keymap, build_env=build_env)
50 54
51 return userspace.save() 55 return userspace.save()
diff --git a/lib/python/qmk/cli/userspace/compile.py b/lib/python/qmk/cli/userspace/compile.py
index e8cdf6cd97..f164ca2ef1 100644
--- a/lib/python/qmk/cli/userspace/compile.py
+++ b/lib/python/qmk/cli/userspace/compile.py
@@ -1,4 +1,4 @@
1# Copyright 2023 Nick Brassel (@tzarc) 1# Copyright 2023-2024 Nick Brassel (@tzarc)
2# SPDX-License-Identifier: GPL-2.0-or-later 2# SPDX-License-Identifier: GPL-2.0-or-later
3from pathlib import Path 3from pathlib import Path
4from milc import cli 4from milc import cli
@@ -12,6 +12,10 @@ from qmk.cli.mass_compile import mass_compile_targets
12from qmk.util import maybe_exit_config 12from qmk.util import maybe_exit_config
13 13
14 14
15def _extra_arg_setter(target, extra_args):
16 target.extra_args = extra_args
17
18
15@cli.argument('-t', '--no-temp', arg_only=True, action='store_true', help="Remove temporary files during build.") 19@cli.argument('-t', '--no-temp', arg_only=True, action='store_true', help="Remove temporary files during build.")
16@cli.argument('-j', '--parallel', type=int, default=1, help="Set the number of parallel make jobs; 0 means unlimited.") 20@cli.argument('-j', '--parallel', type=int, default=1, help="Set the number of parallel make jobs; 0 means unlimited.")
17@cli.argument('-c', '--clean', arg_only=True, action='store_true', help="Remove object files before compiling.") 21@cli.argument('-c', '--clean', arg_only=True, action='store_true', help="Remove object files before compiling.")
@@ -33,8 +37,8 @@ def userspace_compile(cli):
33 if isinstance(e, Path): 37 if isinstance(e, Path):
34 build_targets.append(JsonKeymapBuildTarget(e)) 38 build_targets.append(JsonKeymapBuildTarget(e))
35 elif isinstance(e, dict): 39 elif isinstance(e, dict):
36 keyboard_keymap_targets.append((e['keyboard'], e['keymap'])) 40 f = e['env'] if 'env' in e else None
37 41 keyboard_keymap_targets.append((e['keyboard'], e['keymap'], f))
38 if len(keyboard_keymap_targets) > 0: 42 if len(keyboard_keymap_targets) > 0:
39 build_targets.extend(search_keymap_targets(keyboard_keymap_targets)) 43 build_targets.extend(search_keymap_targets(keyboard_keymap_targets))
40 44
diff --git a/lib/python/qmk/cli/userspace/list.py b/lib/python/qmk/cli/userspace/list.py
index 8689c80a76..9f83a14a2a 100644
--- a/lib/python/qmk/cli/userspace/list.py
+++ b/lib/python/qmk/cli/userspace/list.py
@@ -1,4 +1,4 @@
1# Copyright 2023 Nick Brassel (@tzarc) 1# Copyright 2023-2024 Nick Brassel (@tzarc)
2# SPDX-License-Identifier: GPL-2.0-or-later 2# SPDX-License-Identifier: GPL-2.0-or-later
3from pathlib import Path 3from pathlib import Path
4from dotty_dict import Dotty 4from dotty_dict import Dotty
@@ -13,6 +13,10 @@ from qmk.search import search_keymap_targets
13from qmk.util import maybe_exit_config 13from qmk.util import maybe_exit_config
14 14
15 15
16def _extra_arg_setter(target, extra_args):
17 target.extra_args = extra_args
18
19
16@cli.argument('-e', '--expand', arg_only=True, action='store_true', help="Expands any use of `all` for either keyboard or keymap.") 20@cli.argument('-e', '--expand', arg_only=True, action='store_true', help="Expands any use of `all` for either keyboard or keymap.")
17@cli.subcommand('Lists the build targets specified in userspace `qmk.json`.') 21@cli.subcommand('Lists the build targets specified in userspace `qmk.json`.')
18def userspace_list(cli): 22def userspace_list(cli):
@@ -26,11 +30,15 @@ def userspace_list(cli):
26 30
27 if cli.args.expand: 31 if cli.args.expand:
28 build_targets = [] 32 build_targets = []
33 keyboard_keymap_targets = []
29 for e in userspace.build_targets: 34 for e in userspace.build_targets:
30 if isinstance(e, Path): 35 if isinstance(e, Path):
31 build_targets.append(e) 36 build_targets.append(e)
32 elif isinstance(e, dict) or isinstance(e, Dotty): 37 elif isinstance(e, dict) or isinstance(e, Dotty):
33 build_targets.extend(search_keymap_targets([(e['keyboard'], e['keymap'])])) 38 f = e['env'] if 'env' in e else None
39 keyboard_keymap_targets.append((e['keyboard'], e['keymap'], f))
40 if len(keyboard_keymap_targets) > 0:
41 build_targets.extend(search_keymap_targets(keyboard_keymap_targets))
34 else: 42 else:
35 build_targets = userspace.build_targets 43 build_targets = userspace.build_targets
36 44
@@ -43,12 +51,19 @@ def userspace_list(cli):
43 # keyboard/keymap dict from userspace 51 # keyboard/keymap dict from userspace
44 keyboard = e['keyboard'] 52 keyboard = e['keyboard']
45 keymap = e['keymap'] 53 keymap = e['keymap']
54 extra_args = e.get('env')
46 elif isinstance(e, BuildTarget): 55 elif isinstance(e, BuildTarget):
47 # BuildTarget from search_keymap_targets() 56 # BuildTarget from search_keymap_targets()
48 keyboard = e.keyboard 57 keyboard = e.keyboard
49 keymap = e.keymap 58 keymap = e.keymap
59 extra_args = e.extra_args
60
61 extra_args_str = ''
62 if extra_args is not None and len(extra_args) > 0:
63 extra_args_str = ', '.join([f'{{fg_cyan}}{k}={v}{{fg_reset}}' for k, v in extra_args.items()])
64 extra_args_str = f' ({{fg_cyan}}{extra_args_str}{{fg_reset}})'
50 65
51 if is_all_keyboards(keyboard) or is_keymap_target(keyboard_folder(keyboard), keymap): 66 if is_all_keyboards(keyboard) or is_keymap_target(keyboard_folder(keyboard), keymap):
52 cli.log.info(f'Keyboard: {{fg_cyan}}{keyboard}{{fg_reset}}, keymap: {{fg_cyan}}{keymap}{{fg_reset}}') 67 cli.log.info(f'Keyboard: {{fg_cyan}}{keyboard}{{fg_reset}}, keymap: {{fg_cyan}}{keymap}{{fg_reset}}{extra_args_str}')
53 else: 68 else:
54 cli.log.warn(f'Keyboard: {{fg_cyan}}{keyboard}{{fg_reset}}, keymap: {{fg_cyan}}{keymap}{{fg_reset}} -- not found!') 69 cli.log.warn(f'Keyboard: {{fg_cyan}}{keyboard}{{fg_reset}}, keymap: {{fg_cyan}}{keymap}{{fg_reset}}{extra_args_str} -- not found!')
diff --git a/lib/python/qmk/cli/userspace/remove.py b/lib/python/qmk/cli/userspace/remove.py
index c7d180bfd1..b2da08a98e 100644
--- a/lib/python/qmk/cli/userspace/remove.py
+++ b/lib/python/qmk/cli/userspace/remove.py
@@ -1,8 +1,9 @@
1# Copyright 2023 Nick Brassel (@tzarc) 1# Copyright 2023-2024 Nick Brassel (@tzarc)
2# SPDX-License-Identifier: GPL-2.0-or-later 2# SPDX-License-Identifier: GPL-2.0-or-later
3from pathlib import Path 3from pathlib import Path
4from milc import cli 4from milc import cli
5 5
6from qmk.commands import parse_env_vars
6from qmk.constants import QMK_USERSPACE, HAS_QMK_USERSPACE 7from qmk.constants import QMK_USERSPACE, HAS_QMK_USERSPACE
7from qmk.keyboard import keyboard_completer, keyboard_folder_or_all 8from qmk.keyboard import keyboard_completer, keyboard_folder_or_all
8from qmk.keymap import keymap_completer 9from qmk.keymap import keymap_completer
@@ -12,12 +13,15 @@ from qmk.userspace import UserspaceDefs
12@cli.argument('builds', nargs='*', arg_only=True, help="List of builds in form <keyboard>:<keymap>, or path to a keymap JSON file.") 13@cli.argument('builds', nargs='*', arg_only=True, help="List of builds in form <keyboard>:<keymap>, or path to a keymap JSON file.")
13@cli.argument('-kb', '--keyboard', type=keyboard_folder_or_all, completer=keyboard_completer, help='The keyboard to build a firmware for. Ignored when a configurator export is supplied.') 14@cli.argument('-kb', '--keyboard', type=keyboard_folder_or_all, completer=keyboard_completer, help='The keyboard to build a firmware for. Ignored when a configurator export is supplied.')
14@cli.argument('-km', '--keymap', completer=keymap_completer, help='The keymap to build a firmware for. Ignored when a configurator export is supplied.') 15@cli.argument('-km', '--keymap', completer=keymap_completer, help='The keymap to build a firmware for. Ignored when a configurator export is supplied.')
16@cli.argument('-e', '--env', arg_only=True, action='append', default=[], help="Extra variables to set during build. May be passed multiple times.")
15@cli.subcommand('Removes a build target from userspace `qmk.json`.') 17@cli.subcommand('Removes a build target from userspace `qmk.json`.')
16def userspace_remove(cli): 18def userspace_remove(cli):
17 if not HAS_QMK_USERSPACE: 19 if not HAS_QMK_USERSPACE:
18 cli.log.error('Could not determine QMK userspace location. Please run `qmk doctor` or `qmk userspace-doctor` to diagnose.') 20 cli.log.error('Could not determine QMK userspace location. Please run `qmk doctor` or `qmk userspace-doctor` to diagnose.')
19 return False 21 return False
20 22
23 build_env = None if len(cli.args.env) == 0 else parse_env_vars(cli.args.env)
24
21 userspace = UserspaceDefs(QMK_USERSPACE / 'qmk.json') 25 userspace = UserspaceDefs(QMK_USERSPACE / 'qmk.json')
22 26
23 if len(cli.args.builds) > 0: 27 if len(cli.args.builds) > 0:
@@ -29,9 +33,9 @@ def userspace_remove(cli):
29 33
30 for e in make_like_targets: 34 for e in make_like_targets:
31 s = e.split(':') 35 s = e.split(':')
32 userspace.remove_target(keyboard=s[0], keymap=s[1]) 36 userspace.remove_target(keyboard=s[0], keymap=s[1], build_env=build_env)
33 37
34 else: 38 else:
35 userspace.remove_target(keyboard=cli.args.keyboard, keymap=cli.args.keymap) 39 userspace.remove_target(keyboard=cli.args.keyboard, keymap=cli.args.keymap, build_env=build_env)
36 40
37 return userspace.save() 41 return userspace.save()
diff --git a/lib/python/qmk/commands.py b/lib/python/qmk/commands.py
index b0b8ccb244..97d9c5032c 100644
--- a/lib/python/qmk/commands.py
+++ b/lib/python/qmk/commands.py
@@ -68,7 +68,7 @@ def parse_configurator_json(configurator_file):
68 return user_keymap 68 return user_keymap
69 69
70 70
71def build_environment(args): 71def parse_env_vars(args):
72 """Common processing for cli.args.env 72 """Common processing for cli.args.env
73 """ 73 """
74 envs = {} 74 envs = {}
@@ -78,6 +78,11 @@ def build_environment(args):
78 envs[key] = value 78 envs[key] = value
79 else: 79 else:
80 cli.log.warning('Invalid environment variable: %s', env) 80 cli.log.warning('Invalid environment variable: %s', env)
81 return envs
82
83
84def build_environment(args):
85 envs = parse_env_vars(args)
81 86
82 if HAS_QMK_USERSPACE: 87 if HAS_QMK_USERSPACE:
83 envs['QMK_USERSPACE'] = Path(QMK_USERSPACE).resolve() 88 envs['QMK_USERSPACE'] = Path(QMK_USERSPACE).resolve()
diff --git a/lib/python/qmk/info.py b/lib/python/qmk/info.py
index 5948b66b5e..72b19a9fec 100644
--- a/lib/python/qmk/info.py
+++ b/lib/python/qmk/info.py
@@ -212,7 +212,7 @@ def _validate(keyboard, info_data):
212 maybe_exit(1) 212 maybe_exit(1)
213 213
214 214
215def info_json(keyboard): 215def info_json(keyboard, force_layout=None):
216 """Generate the info.json data for a specific keyboard. 216 """Generate the info.json data for a specific keyboard.
217 """ 217 """
218 cur_dir = Path('keyboards') 218 cur_dir = Path('keyboards')
@@ -256,6 +256,11 @@ def info_json(keyboard):
256 # Merge in data from <keyboard.c> 256 # Merge in data from <keyboard.c>
257 info_data = _extract_led_config(info_data, str(keyboard)) 257 info_data = _extract_led_config(info_data, str(keyboard))
258 258
259 # Force a community layout if requested
260 community_layouts = info_data.get("community_layouts", [])
261 if force_layout in community_layouts:
262 info_data["community_layouts"] = [force_layout]
263
259 # Validate 264 # Validate
260 _validate(keyboard, info_data) 265 _validate(keyboard, info_data)
261 266
@@ -1008,25 +1013,25 @@ def find_info_json(keyboard):
1008 return [info_json for info_json in info_jsons if info_json.exists()] 1013 return [info_json for info_json in info_jsons if info_json.exists()]
1009 1014
1010 1015
1011def keymap_json_config(keyboard, keymap): 1016def keymap_json_config(keyboard, keymap, force_layout=None):
1012 """Extract keymap level config 1017 """Extract keymap level config
1013 """ 1018 """
1014 # TODO: resolve keymap.py and info.py circular dependencies 1019 # TODO: resolve keymap.py and info.py circular dependencies
1015 from qmk.keymap import locate_keymap 1020 from qmk.keymap import locate_keymap
1016 1021
1017 keymap_folder = locate_keymap(keyboard, keymap).parent 1022 keymap_folder = locate_keymap(keyboard, keymap, force_layout=force_layout).parent
1018 1023
1019 km_info_json = parse_configurator_json(keymap_folder / 'keymap.json') 1024 km_info_json = parse_configurator_json(keymap_folder / 'keymap.json')
1020 return km_info_json.get('config', {}) 1025 return km_info_json.get('config', {})
1021 1026
1022 1027
1023def keymap_json(keyboard, keymap): 1028def keymap_json(keyboard, keymap, force_layout=None):
1024 """Generate the info.json data for a specific keymap. 1029 """Generate the info.json data for a specific keymap.
1025 """ 1030 """
1026 # TODO: resolve keymap.py and info.py circular dependencies 1031 # TODO: resolve keymap.py and info.py circular dependencies
1027 from qmk.keymap import locate_keymap 1032 from qmk.keymap import locate_keymap
1028 1033
1029 keymap_folder = locate_keymap(keyboard, keymap).parent 1034 keymap_folder = locate_keymap(keyboard, keymap, force_layout=force_layout).parent
1030 1035
1031 # Files to scan 1036 # Files to scan
1032 keymap_config = keymap_folder / 'config.h' 1037 keymap_config = keymap_folder / 'config.h'
@@ -1034,10 +1039,10 @@ def keymap_json(keyboard, keymap):
1034 keymap_file = keymap_folder / 'keymap.json' 1039 keymap_file = keymap_folder / 'keymap.json'
1035 1040
1036 # Build the info.json file 1041 # Build the info.json file
1037 kb_info_json = info_json(keyboard) 1042 kb_info_json = info_json(keyboard, force_layout=force_layout)
1038 1043
1039 # Merge in the data from keymap.json 1044 # Merge in the data from keymap.json
1040 km_info_json = keymap_json_config(keyboard, keymap) if keymap_file.exists() else {} 1045 km_info_json = keymap_json_config(keyboard, keymap, force_layout=force_layout) if keymap_file.exists() else {}
1041 deep_update(kb_info_json, km_info_json) 1046 deep_update(kb_info_json, km_info_json)
1042 1047
1043 # Merge in the data from config.h, and rules.mk 1048 # Merge in the data from config.h, and rules.mk
diff --git a/lib/python/qmk/keymap.py b/lib/python/qmk/keymap.py
index f3505d324e..97c358788a 100644
--- a/lib/python/qmk/keymap.py
+++ b/lib/python/qmk/keymap.py
@@ -356,7 +356,7 @@ def write(keymap_json):
356 return write_file(keymap_file, keymap_content) 356 return write_file(keymap_file, keymap_content)
357 357
358 358
359def locate_keymap(keyboard, keymap): 359def locate_keymap(keyboard, keymap, force_layout=None):
360 """Returns the path to a keymap for a specific keyboard. 360 """Returns the path to a keymap for a specific keyboard.
361 """ 361 """
362 if not qmk.path.is_keyboard(keyboard): 362 if not qmk.path.is_keyboard(keyboard):
@@ -395,7 +395,7 @@ def locate_keymap(keyboard, keymap):
395 return keymap_path 395 return keymap_path
396 396
397 # Check community layouts as a fallback 397 # Check community layouts as a fallback
398 info = info_json(keyboard) 398 info = info_json(keyboard, force_layout=force_layout)
399 399
400 community_parents = list(Path('layouts').glob('*/')) 400 community_parents = list(Path('layouts').glob('*/'))
401 if HAS_QMK_USERSPACE and (Path(QMK_USERSPACE) / "layouts").exists(): 401 if HAS_QMK_USERSPACE and (Path(QMK_USERSPACE) / "layouts").exists():
diff --git a/lib/python/qmk/search.py b/lib/python/qmk/search.py
index 2afb3033fc..baaf11eb34 100644
--- a/lib/python/qmk/search.py
+++ b/lib/python/qmk/search.py
@@ -1,11 +1,13 @@
1"""Functions for searching through QMK keyboards and keymaps. 1"""Functions for searching through QMK keyboards and keymaps.
2""" 2"""
3from dataclasses import dataclass
3import contextlib 4import contextlib
4import functools 5import functools
5import fnmatch 6import fnmatch
7import json
6import logging 8import logging
7import re 9import re
8from typing import Callable, List, Optional, Tuple 10from typing import Callable, Dict, List, Optional, Tuple, Union
9from dotty_dict import dotty, Dotty 11from dotty_dict import dotty, Dotty
10from milc import cli 12from milc import cli
11 13
@@ -15,7 +17,32 @@ from qmk.keyboard import list_keyboards, keyboard_folder
15from qmk.keymap import list_keymaps, locate_keymap 17from qmk.keymap import list_keymaps, locate_keymap
16from qmk.build_targets import KeyboardKeymapBuildTarget, BuildTarget 18from qmk.build_targets import KeyboardKeymapBuildTarget, BuildTarget
17 19
18TargetInfo = Tuple[str, str, dict] 20
21@dataclass
22class KeyboardKeymapDesc:
23 keyboard: str
24 keymap: str
25 data: dict = None
26 extra_args: dict = None
27
28 def __hash__(self) -> int:
29 return self.keyboard.__hash__() ^ self.keymap.__hash__() ^ json.dumps(self.extra_args, sort_keys=True).__hash__()
30
31 def __lt__(self, other) -> bool:
32 return (self.keyboard, self.keymap, json.dumps(self.extra_args, sort_keys=True)) < (other.keyboard, other.keymap, json.dumps(other.extra_args, sort_keys=True))
33
34 def load_data(self):
35 data = keymap_json(self.keyboard, self.keymap)
36 self.data = data.to_dict() if isinstance(data, Dotty) else data
37
38 @property
39 def dotty(self) -> Dotty:
40 return dotty(self.data) if self.data is not None else None
41
42 def to_build_target(self) -> KeyboardKeymapBuildTarget:
43 target = KeyboardKeymapBuildTarget(keyboard=self.keyboard, keymap=self.keymap, json=self.data)
44 target.extra_args = self.extra_args
45 return target
19 46
20 47
21# by using a class for filters, we dont need to worry about capturing values 48# by using a class for filters, we dont need to worry about capturing values
@@ -36,7 +63,7 @@ class FilterFunction:
36 value: Optional[str] 63 value: Optional[str]
37 64
38 func_name: str 65 func_name: str
39 apply: Callable[[TargetInfo], bool] 66 apply: Callable[[KeyboardKeymapDesc], bool]
40 67
41 def __init__(self, key, value): 68 def __init__(self, key, value):
42 self.key = key 69 self.key = key
@@ -46,33 +73,29 @@ class FilterFunction:
46class Exists(FilterFunction): 73class Exists(FilterFunction):
47 func_name = "exists" 74 func_name = "exists"
48 75
49 def apply(self, target_info: TargetInfo) -> bool: 76 def apply(self, target_info: KeyboardKeymapDesc) -> bool:
50 _kb, _km, info = target_info 77 return self.key in target_info.data
51 return self.key in info
52 78
53 79
54class Absent(FilterFunction): 80class Absent(FilterFunction):
55 func_name = "absent" 81 func_name = "absent"
56 82
57 def apply(self, target_info: TargetInfo) -> bool: 83 def apply(self, target_info: KeyboardKeymapDesc) -> bool:
58 _kb, _km, info = target_info 84 return self.key not in target_info.data
59 return self.key not in info
60 85
61 86
62class Length(FilterFunction): 87class Length(FilterFunction):
63 func_name = "length" 88 func_name = "length"
64 89
65 def apply(self, target_info: TargetInfo) -> bool: 90 def apply(self, target_info: KeyboardKeymapDesc) -> bool:
66 _kb, _km, info = target_info 91 return (self.key in target_info.data and len(target_info.data[self.key]) == int(self.value))
67 return (self.key in info and len(info[self.key]) == int(self.value))
68 92
69 93
70class Contains(FilterFunction): 94class Contains(FilterFunction):
71 func_name = "contains" 95 func_name = "contains"
72 96
73 def apply(self, target_info: TargetInfo) -> bool: 97 def apply(self, target_info: KeyboardKeymapDesc) -> bool:
74 _kb, _km, info = target_info 98 return (self.key in target_info.data and self.value in target_info.data[self.key])
75 return (self.key in info and self.value in info[self.key])
76 99
77 100
78def _get_filter_class(func_name: str, key: str, value: str) -> Optional[FilterFunction]: 101def _get_filter_class(func_name: str, key: str, value: str) -> Optional[FilterFunction]:
@@ -109,12 +132,12 @@ def ignore_logging():
109 _set_log_level(old) 132 _set_log_level(old)
110 133
111 134
112def _all_keymaps(keyboard): 135def _all_keymaps(keyboard) -> List[KeyboardKeymapDesc]:
113 """Returns a list of tuples of (keyboard, keymap) for all keymaps for the given keyboard. 136 """Returns a list of KeyboardKeymapDesc for all keymaps for the given keyboard.
114 """ 137 """
115 with ignore_logging(): 138 with ignore_logging():
116 keyboard = keyboard_folder(keyboard) 139 keyboard = keyboard_folder(keyboard)
117 return [(keyboard, keymap) for keymap in list_keymaps(keyboard)] 140 return [KeyboardKeymapDesc(keyboard, keymap) for keymap in list_keymaps(keyboard)]
118 141
119 142
120def _keymap_exists(keyboard, keymap): 143def _keymap_exists(keyboard, keymap):
@@ -124,85 +147,91 @@ def _keymap_exists(keyboard, keymap):
124 return keyboard if locate_keymap(keyboard, keymap) is not None else None 147 return keyboard if locate_keymap(keyboard, keymap) is not None else None
125 148
126 149
127def _load_keymap_info(target: Tuple[str, str]) -> TargetInfo: 150def _load_keymap_info(target: KeyboardKeymapDesc) -> KeyboardKeymapDesc:
128 """Returns a tuple of (keyboard, keymap, info.json) for the given keyboard/keymap combination. 151 """Ensures a KeyboardKeymapDesc has its data loaded.
129 """ 152 """
130 kb, km = target
131 with ignore_logging(): 153 with ignore_logging():
132 return (kb, km, keymap_json(kb, km)) 154 target.load_data() # Ensure we load the data first
155 return target
133 156
134 157
135def expand_make_targets(targets: List[str]) -> List[Tuple[str, str]]: 158def expand_make_targets(targets: List[Union[str, Tuple[str, Dict[str, str]]]]) -> List[KeyboardKeymapDesc]:
136 """Expand a list of make targets into a list of (keyboard, keymap) tuples. 159 """Expand a list of make targets into a list of KeyboardKeymapDesc.
137 160
138 Caters for 'all' in either keyboard or keymap, or both. 161 Caters for 'all' in either keyboard or keymap, or both.
139 """ 162 """
140 split_targets = [] 163 split_targets = []
141 for target in targets: 164 for target in targets:
142 split_target = target.split(':') 165 extra_args = None
166 if isinstance(target, tuple):
167 split_target = target[0].split(':')
168 extra_args = target[1]
169 else:
170 split_target = target.split(':')
143 if len(split_target) != 2: 171 if len(split_target) != 2:
144 cli.log.error(f"Invalid build target: {target}") 172 cli.log.error(f"Invalid build target: {target}")
145 return [] 173 return []
146 split_targets.append((split_target[0], split_target[1])) 174 split_targets.append(KeyboardKeymapDesc(split_target[0], split_target[1], extra_args=extra_args))
147 return expand_keymap_targets(split_targets) 175 return expand_keymap_targets(split_targets)
148 176
149 177
150def _expand_keymap_target(keyboard: str, keymap: str, all_keyboards: List[str] = None) -> List[Tuple[str, str]]: 178def _expand_keymap_target(target: KeyboardKeymapDesc, all_keyboards: List[str] = None) -> List[KeyboardKeymapDesc]:
151 """Expand a keyboard input and keymap input into a list of (keyboard, keymap) tuples. 179 """Expand a keyboard input and keymap input into a list of KeyboardKeymapDesc.
152 180
153 Caters for 'all' in either keyboard or keymap, or both. 181 Caters for 'all' in either keyboard or keymap, or both.
154 """ 182 """
155 if all_keyboards is None: 183 if all_keyboards is None:
156 all_keyboards = list_keyboards() 184 all_keyboards = list_keyboards()
157 185
158 if keyboard == 'all': 186 if target.keyboard == 'all':
159 if keymap == 'all': 187 if target.keymap == 'all':
160 cli.log.info('Retrieving list of all keyboards and keymaps...') 188 cli.log.info('Retrieving list of all keyboards and keymaps...')
161 targets = [] 189 targets = []
162 for kb in parallel_map(_all_keymaps, all_keyboards): 190 for kb in parallel_map(_all_keymaps, all_keyboards):
163 targets.extend(kb) 191 targets.extend(kb)
192 for t in targets:
193 t.extra_args = target.extra_args
164 return targets 194 return targets
165 else: 195 else:
166 cli.log.info(f'Retrieving list of keyboards with keymap "{keymap}"...') 196 cli.log.info(f'Retrieving list of keyboards with keymap "{target.keymap}"...')
167 keyboard_filter = functools.partial(_keymap_exists, keymap=keymap) 197 keyboard_filter = functools.partial(_keymap_exists, keymap=target.keymap)
168 return [(kb, keymap) for kb in filter(lambda e: e is not None, parallel_map(keyboard_filter, all_keyboards))] 198 return [KeyboardKeymapDesc(kb, target.keymap, extra_args=target.extra_args) for kb in filter(lambda e: e is not None, parallel_map(keyboard_filter, all_keyboards))]
169 else: 199 else:
170 if keymap == 'all': 200 if target.keymap == 'all':
171 cli.log.info(f'Retrieving list of keymaps for keyboard "{keyboard}"...') 201 cli.log.info(f'Retrieving list of keymaps for keyboard "{target.keyboard}"...')
172 return _all_keymaps(keyboard) 202 targets = _all_keymaps(target.keyboard)
203 for t in targets:
204 t.extra_args = target.extra_args
205 return targets
173 else: 206 else:
174 return [(keyboard, keymap)] 207 return [target]
175 208
176 209
177def expand_keymap_targets(targets: List[Tuple[str, str]]) -> List[Tuple[str, str]]: 210def expand_keymap_targets(targets: List[KeyboardKeymapDesc]) -> List[KeyboardKeymapDesc]:
178 """Expand a list of (keyboard, keymap) tuples inclusive of 'all', into a list of explicit (keyboard, keymap) tuples. 211 """Expand a list of KeyboardKeymapDesc inclusive of 'all', into a list of explicit KeyboardKeymapDesc.
179 """ 212 """
180 overall_targets = [] 213 overall_targets = []
181 all_keyboards = list_keyboards() 214 all_keyboards = list_keyboards()
182 for target in targets: 215 for target in targets:
183 overall_targets.extend(_expand_keymap_target(target[0], target[1], all_keyboards)) 216 overall_targets.extend(_expand_keymap_target(target, all_keyboards))
184 return list(sorted(set(overall_targets))) 217 return list(sorted(set(overall_targets)))
185 218
186 219
187def _construct_build_target_kb_km(e): 220def _construct_build_target(e: KeyboardKeymapDesc):
188 return KeyboardKeymapBuildTarget(keyboard=e[0], keymap=e[1]) 221 return e.to_build_target()
189
190 222
191def _construct_build_target_kb_km_json(e):
192 return KeyboardKeymapBuildTarget(keyboard=e[0], keymap=e[1], json=e[2])
193 223
194 224def _filter_keymap_targets(target_list: List[KeyboardKeymapDesc], filters: List[str] = []) -> List[KeyboardKeymapDesc]:
195def _filter_keymap_targets(target_list: List[Tuple[str, str]], filters: List[str] = []) -> List[BuildTarget]: 225 """Filter a list of KeyboardKeymapDesc based on the supplied filters.
196 """Filter a list of (keyboard, keymap) tuples based on the supplied filters.
197 226
198 Optionally includes the values of the queried info.json keys. 227 Optionally includes the values of the queried info.json keys.
199 """ 228 """
200 if len(filters) == 0: 229 if len(filters) == 0:
201 cli.log.info('Preparing target list...') 230 cli.log.info('Preparing target list...')
202 targets = list(set(parallel_map(_construct_build_target_kb_km, target_list))) 231 targets = target_list
203 else: 232 else:
204 cli.log.info('Parsing data for all matching keyboard/keymap combinations...') 233 cli.log.info('Parsing data for all matching keyboard/keymap combinations...')
205 valid_keymaps = [(e[0], e[1], dotty(e[2])) for e in parallel_map(_load_keymap_info, target_list)] 234 valid_targets = parallel_map(_load_keymap_info, target_list)
206 235
207 function_re = re.compile(r'^(?P<function>[a-zA-Z]+)\((?P<key>[a-zA-Z0-9_\.]+)(,\s*(?P<value>[^#]+))?\)$') 236 function_re = re.compile(r'^(?P<function>[a-zA-Z]+)\((?P<key>[a-zA-Z0-9_\.]+)(,\s*(?P<value>[^#]+))?\)$')
208 equals_re = re.compile(r'^(?P<key>[a-zA-Z0-9_\.]+)\s*=\s*(?P<value>[^#]+)$') 237 equals_re = re.compile(r'^(?P<key>[a-zA-Z0-9_\.]+)\s*=\s*(?P<value>[^#]+)$')
@@ -220,7 +249,7 @@ def _filter_keymap_targets(target_list: List[Tuple[str, str]], filters: List[str
220 if filter_class is None: 249 if filter_class is None:
221 cli.log.warning(f'Unrecognized filter expression: {function_match.group(0)}') 250 cli.log.warning(f'Unrecognized filter expression: {function_match.group(0)}')
222 continue 251 continue
223 valid_keymaps = filter(filter_class.apply, valid_keymaps) 252 valid_targets = filter(filter_class.apply, valid_targets)
224 253
225 value_str = f", {{fg_cyan}}{value}{{fg_reset}}" if value is not None else "" 254 value_str = f", {{fg_cyan}}{value}{{fg_reset}}" if value is not None else ""
226 cli.log.info(f'Filtering on condition: {{fg_green}}{func_name}{{fg_reset}}({{fg_cyan}}{key}{{fg_reset}}{value_str})...') 255 cli.log.info(f'Filtering on condition: {{fg_green}}{func_name}{{fg_reset}}({{fg_cyan}}{key}{{fg_reset}}{value_str})...')
@@ -234,32 +263,42 @@ def _filter_keymap_targets(target_list: List[Tuple[str, str]], filters: List[str
234 expr = fnmatch.translate(v) 263 expr = fnmatch.translate(v)
235 rule = re.compile(f'^{expr}$', re.IGNORECASE) 264 rule = re.compile(f'^{expr}$', re.IGNORECASE)
236 265
237 def f(e): 266 def f(e: KeyboardKeymapDesc):
238 lhs = e[2].get(k) 267 lhs = e.dotty.get(k)
239 lhs = str(False if lhs is None else lhs) 268 lhs = str(False if lhs is None else lhs)
240 return rule.search(lhs) is not None 269 return rule.search(lhs) is not None
241 270
242 return f 271 return f
243 272
244 valid_keymaps = filter(_make_filter(key, value), valid_keymaps) 273 valid_targets = filter(_make_filter(key, value), valid_targets)
245 else: 274 else:
246 cli.log.warning(f'Unrecognized filter expression: {filter_expr}') 275 cli.log.warning(f'Unrecognized filter expression: {filter_expr}')
247 continue 276 continue
248 277
249 cli.log.info('Preparing target list...') 278 cli.log.info('Preparing target list...')
250 valid_keymaps = [(e[0], e[1], e[2].to_dict() if isinstance(e[2], Dotty) else e[2]) for e in valid_keymaps] # need to convert dotty_dict back to dict because it doesn't survive parallelisation 279 targets = list(sorted(set(valid_targets)))
251 targets = list(set(parallel_map(_construct_build_target_kb_km_json, list(valid_keymaps))))
252 280
253 return targets 281 return targets
254 282
255 283
256def search_keymap_targets(targets: List[Tuple[str, str]] = [('all', 'default')], filters: List[str] = []) -> List[BuildTarget]: 284def search_keymap_targets(targets: List[Union[Tuple[str, str], Tuple[str, str, Dict[str, str]]]] = [('all', 'default')], filters: List[str] = []) -> List[BuildTarget]:
257 """Search for build targets matching the supplied criteria. 285 """Search for build targets matching the supplied criteria.
258 """ 286 """
259 return _filter_keymap_targets(expand_keymap_targets(targets), filters) 287 def _make_desc(e):
288 if len(e) == 3:
289 return KeyboardKeymapDesc(keyboard=e[0], keymap=e[1], extra_args=e[2])
290 else:
291 return KeyboardKeymapDesc(keyboard=e[0], keymap=e[1])
292
293 targets = map(_make_desc, targets)
294 targets = _filter_keymap_targets(expand_keymap_targets(targets), filters)
295 targets = list(set(parallel_map(_construct_build_target, list(targets))))
296 return sorted(targets)
260 297
261 298
262def search_make_targets(targets: List[str], filters: List[str] = []) -> List[BuildTarget]: 299def search_make_targets(targets: List[Union[str, Tuple[str, Dict[str, str]]]], filters: List[str] = []) -> List[BuildTarget]:
263 """Search for build targets matching the supplied criteria. 300 """Search for build targets matching the supplied criteria.
264 """ 301 """
265 return _filter_keymap_targets(expand_make_targets(targets), filters) 302 targets = _filter_keymap_targets(expand_make_targets(targets), filters)
303 targets = list(set(parallel_map(_construct_build_target, list(targets))))
304 return sorted(targets)
diff --git a/lib/python/qmk/userspace.py b/lib/python/qmk/userspace.py
index 1e5823b229..1c2a97f9c1 100644
--- a/lib/python/qmk/userspace.py
+++ b/lib/python/qmk/userspace.py
@@ -1,4 +1,4 @@
1# Copyright 2023 Nick Brassel (@tzarc) 1# Copyright 2023-2024 Nick Brassel (@tzarc)
2# SPDX-License-Identifier: GPL-2.0-or-later 2# SPDX-License-Identifier: GPL-2.0-or-later
3from os import environ 3from os import environ
4from pathlib import Path 4from pathlib import Path
@@ -77,31 +77,43 @@ class UserspaceDefs:
77 raise exception 77 raise exception
78 78
79 # Iterate through each version of the schema, starting with the latest and decreasing to v1 79 # Iterate through each version of the schema, starting with the latest and decreasing to v1
80 try: 80 schema_versions = [
81 validate(json, 'qmk.user_repo.v1') 81 ('qmk.user_repo.v1_1', self.__load_v1_1), #
82 self.__load_v1(json) 82 ('qmk.user_repo.v1', self.__load_v1) #
83 success = True 83 ]
84 except jsonschema.ValidationError as err: 84
85 exception.add('qmk.user_repo.v1', err) 85 for v in schema_versions:
86 schema = v[0]
87 loader = v[1]
88 try:
89 validate(json, schema)
90 loader(json)
91 success = True
92 break
93 except jsonschema.ValidationError as err:
94 exception.add(schema, err)
86 95
87 if not success: 96 if not success:
88 raise exception 97 raise exception
89 98
90 def save(self): 99 def save(self):
91 target_json = { 100 target_json = {
92 "userspace_version": "1.0", # Needs to match latest version 101 "userspace_version": "1.1", # Needs to match latest version
93 "build_targets": [] 102 "build_targets": []
94 } 103 }
95 104
96 for e in self.build_targets: 105 for e in self.build_targets:
97 if isinstance(e, dict): 106 if isinstance(e, dict):
98 target_json['build_targets'].append([e['keyboard'], e['keymap']]) 107 entry = [e['keyboard'], e['keymap']]
108 if 'env' in e:
109 entry.append(e['env'])
110 target_json['build_targets'].append(entry)
99 elif isinstance(e, Path): 111 elif isinstance(e, Path):
100 target_json['build_targets'].append(str(e.relative_to(self.path.parent))) 112 target_json['build_targets'].append(str(e.relative_to(self.path.parent)))
101 113
102 try: 114 try:
103 # Ensure what we're writing validates against the latest version of the schema 115 # Ensure what we're writing validates against the latest version of the schema
104 validate(target_json, 'qmk.user_repo.v1') 116 validate(target_json, 'qmk.user_repo.v1_1')
105 except jsonschema.ValidationError as err: 117 except jsonschema.ValidationError as err:
106 cli.log.error(f'Could not save userspace file: {err}') 118 cli.log.error(f'Could not save userspace file: {err}')
107 return False 119 return False
@@ -114,7 +126,7 @@ class UserspaceDefs:
114 cli.log.info(f'Saved userspace file to {self.path}.') 126 cli.log.info(f'Saved userspace file to {self.path}.')
115 return True 127 return True
116 128
117 def add_target(self, keyboard=None, keymap=None, json_path=None, do_print=True): 129 def add_target(self, keyboard=None, keymap=None, build_env=None, json_path=None, do_print=True):
118 if json_path is not None: 130 if json_path is not None:
119 # Assume we're adding a json filename/path 131 # Assume we're adding a json filename/path
120 json_path = Path(json_path) 132 json_path = Path(json_path)
@@ -128,6 +140,8 @@ class UserspaceDefs:
128 elif keyboard is not None and keymap is not None: 140 elif keyboard is not None and keymap is not None:
129 # Both keyboard/keymap specified 141 # Both keyboard/keymap specified
130 e = {"keyboard": keyboard, "keymap": keymap} 142 e = {"keyboard": keyboard, "keymap": keymap}
143 if build_env is not None:
144 e['env'] = build_env
131 if e not in self.build_targets: 145 if e not in self.build_targets:
132 self.build_targets.append(e) 146 self.build_targets.append(e)
133 if do_print: 147 if do_print:
@@ -136,7 +150,7 @@ class UserspaceDefs:
136 if do_print: 150 if do_print:
137 cli.log.info(f'{keyboard}:{keymap} is already a userspace build target.') 151 cli.log.info(f'{keyboard}:{keymap} is already a userspace build target.')
138 152
139 def remove_target(self, keyboard=None, keymap=None, json_path=None, do_print=True): 153 def remove_target(self, keyboard=None, keymap=None, build_env=None, json_path=None, do_print=True):
140 if json_path is not None: 154 if json_path is not None:
141 # Assume we're removing a json filename/path 155 # Assume we're removing a json filename/path
142 json_path = Path(json_path) 156 json_path = Path(json_path)
@@ -150,6 +164,8 @@ class UserspaceDefs:
150 elif keyboard is not None and keymap is not None: 164 elif keyboard is not None and keymap is not None:
151 # Both keyboard/keymap specified 165 # Both keyboard/keymap specified
152 e = {"keyboard": keyboard, "keymap": keymap} 166 e = {"keyboard": keyboard, "keymap": keymap}
167 if build_env is not None:
168 e['env'] = build_env
153 if e in self.build_targets: 169 if e in self.build_targets:
154 self.build_targets.remove(e) 170 self.build_targets.remove(e)
155 if do_print: 171 if do_print:
@@ -160,12 +176,26 @@ class UserspaceDefs:
160 176
161 def __load_v1(self, json): 177 def __load_v1(self, json):
162 for e in json['build_targets']: 178 for e in json['build_targets']:
163 if isinstance(e, list) and len(e) == 2: 179 self.__load_v1_target(e)
164 self.add_target(keyboard=e[0], keymap=e[1], do_print=False) 180
165 if isinstance(e, str): 181 def __load_v1_1(self, json):
166 p = self.path.parent / e 182 for e in json['build_targets']:
167 if p.exists() and p.suffix == '.json': 183 self.__load_v1_1_target(e)
168 self.add_target(json_path=p, do_print=False) 184
185 def __load_v1_target(self, e):
186 if isinstance(e, list) and len(e) == 2:
187 self.add_target(keyboard=e[0], keymap=e[1], do_print=False)
188 if isinstance(e, str):
189 p = self.path.parent / e
190 if p.exists() and p.suffix == '.json':
191 self.add_target(json_path=p, do_print=False)
192
193 def __load_v1_1_target(self, e):
194 # v1.1 adds support for a third item in the build target tuple; kvp's for environment
195 if isinstance(e, list) and len(e) == 3:
196 self.add_target(keyboard=e[0], keymap=e[1], build_env=e[2], do_print=False)
197 else:
198 self.__load_v1_target(e)
169 199
170 200
171class UserspaceValidationError(Exception): 201class UserspaceValidationError(Exception):