search.py (12114B)
1 """Functions for searching through QMK keyboards and keymaps. 2 """ 3 from dataclasses import dataclass 4 import contextlib 5 import functools 6 import fnmatch 7 import json 8 import logging 9 import re 10 from typing import Callable, Dict, List, Optional, Tuple, Union 11 from dotty_dict import dotty, Dotty 12 from milc import cli 13 14 from qmk.util import parallel_map 15 from qmk.info import keymap_json 16 from qmk.keyboard import list_keyboards, keyboard_folder 17 from qmk.keymap import list_keymaps, locate_keymap 18 from qmk.build_targets import KeyboardKeymapBuildTarget, BuildTarget 19 20 21 @dataclass 22 class 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 46 47 48 # by using a class for filters, we dont need to worry about capturing values 49 # see details <https://github.com/qmk/qmk_firmware/pull/21090> 50 class FilterFunction: 51 """Base class for filters. 52 It provides: 53 - __init__: capture key and value 54 55 Each subclass should provide: 56 - func_name: how it will be specified on CLI 57 >>> qmk find -f <func_name>... 58 - apply: function that actually applies the filter 59 ie: return whether the input kb/km satisfies the condition 60 """ 61 62 key: str 63 value: Optional[str] 64 65 func_name: str 66 apply: Callable[[KeyboardKeymapDesc], bool] 67 68 def __init__(self, key, value): 69 self.key = key 70 self.value = value 71 72 73 class Exists(FilterFunction): 74 func_name = "exists" 75 76 def apply(self, target_info: KeyboardKeymapDesc) -> bool: 77 return self.key in target_info.dotty 78 79 80 class Absent(FilterFunction): 81 func_name = "absent" 82 83 def apply(self, target_info: KeyboardKeymapDesc) -> bool: 84 return self.key not in target_info.dotty 85 86 87 class Length(FilterFunction): 88 func_name = "length" 89 90 def apply(self, target_info: KeyboardKeymapDesc) -> bool: 91 info_dotty = target_info.dotty 92 return (self.key in info_dotty and len(info_dotty[self.key]) == int(self.value)) 93 94 95 class Contains(FilterFunction): 96 func_name = "contains" 97 98 def apply(self, target_info: KeyboardKeymapDesc) -> bool: 99 info_dotty = target_info.dotty 100 return (self.key in info_dotty and self.value in info_dotty[self.key]) 101 102 103 def _get_filter_class(func_name: str, key: str, value: str) -> Optional[FilterFunction]: 104 """Initialize a filter subclass based on regex findings and return it. 105 None if no there's no filter with the name queried. 106 """ 107 108 for subclass in FilterFunction.__subclasses__(): 109 if func_name == subclass.func_name: 110 return subclass(key, value) 111 112 return None 113 114 115 def filter_help() -> str: 116 names = [f"'{f.func_name}'" for f in FilterFunction.__subclasses__()] 117 return ", ".join(names[:-1]) + f" and {names[-1]}" 118 119 120 def _set_log_level(level): 121 cli.acquire_lock() 122 try: 123 old = cli.log_level 124 cli.log_level = level 125 except AttributeError: 126 old = cli.log.level 127 cli.log.setLevel(level) 128 logging.root.setLevel(level) 129 cli.release_lock() 130 return old 131 132 133 @contextlib.contextmanager 134 def ignore_logging(): 135 old = _set_log_level(logging.CRITICAL) 136 yield 137 _set_log_level(old) 138 139 140 def _all_keymaps(keyboard) -> List[KeyboardKeymapDesc]: 141 """Returns a list of KeyboardKeymapDesc for all keymaps for the given keyboard. 142 """ 143 with ignore_logging(): 144 keyboard = keyboard_folder(keyboard) 145 return [KeyboardKeymapDesc(keyboard, keymap) for keymap in list_keymaps(keyboard)] 146 147 148 def _keymap_exists(keyboard, keymap): 149 """Returns the keyboard name if the keyboard+keymap combination exists, otherwise None. 150 """ 151 with ignore_logging(): 152 return keyboard if locate_keymap(keyboard, keymap) is not None else None 153 154 155 def _load_keymap_info(target: KeyboardKeymapDesc) -> KeyboardKeymapDesc: 156 """Ensures a KeyboardKeymapDesc has its data loaded. 157 """ 158 with ignore_logging(): 159 target.load_data() # Ensure we load the data first 160 return target 161 162 163 def expand_make_targets(targets: List[Union[str, Tuple[str, Dict[str, str]]]]) -> List[KeyboardKeymapDesc]: 164 """Expand a list of make targets into a list of KeyboardKeymapDesc. 165 166 Caters for 'all' in either keyboard or keymap, or both. 167 """ 168 split_targets = [] 169 for target in targets: 170 extra_args = None 171 if isinstance(target, tuple): 172 split_target = target[0].split(':') 173 extra_args = target[1] 174 else: 175 split_target = target.split(':') 176 if len(split_target) != 2: 177 cli.log.error(f"Invalid build target: {target}") 178 return [] 179 split_targets.append(KeyboardKeymapDesc(split_target[0], split_target[1], extra_args=extra_args)) 180 return expand_keymap_targets(split_targets) 181 182 183 def _expand_keymap_target(target: KeyboardKeymapDesc, all_keyboards: List[str] = None) -> List[KeyboardKeymapDesc]: 184 """Expand a keyboard input and keymap input into a list of KeyboardKeymapDesc. 185 186 Caters for 'all' in either keyboard or keymap, or both. 187 """ 188 if all_keyboards is None: 189 all_keyboards = list_keyboards() 190 191 if target.keyboard == 'all': 192 if target.keymap == 'all': 193 cli.log.info('Retrieving list of all keyboards and keymaps...') 194 targets = [] 195 for kb in parallel_map(_all_keymaps, all_keyboards): 196 targets.extend(kb) 197 for t in targets: 198 t.extra_args = target.extra_args 199 return targets 200 else: 201 cli.log.info(f'Retrieving list of keyboards with keymap "{target.keymap}"...') 202 keyboard_filter = functools.partial(_keymap_exists, keymap=target.keymap) 203 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))] 204 else: 205 if target.keymap == 'all': 206 cli.log.info(f'Retrieving list of keymaps for keyboard "{target.keyboard}"...') 207 targets = _all_keymaps(target.keyboard) 208 for t in targets: 209 t.extra_args = target.extra_args 210 return targets 211 else: 212 return [target] 213 214 215 def expand_keymap_targets(targets: List[KeyboardKeymapDesc]) -> List[KeyboardKeymapDesc]: 216 """Expand a list of KeyboardKeymapDesc inclusive of 'all', into a list of explicit KeyboardKeymapDesc. 217 """ 218 overall_targets = [] 219 all_keyboards = list_keyboards() 220 for target in targets: 221 overall_targets.extend(_expand_keymap_target(target, all_keyboards)) 222 return list(sorted(set(overall_targets))) 223 224 225 def _construct_build_target(e: KeyboardKeymapDesc): 226 return e.to_build_target() 227 228 229 def _filter_keymap_targets(target_list: List[KeyboardKeymapDesc], filters: List[str] = []) -> List[KeyboardKeymapDesc]: 230 """Filter a list of KeyboardKeymapDesc based on the supplied filters. 231 232 Optionally includes the values of the queried info.json keys. 233 """ 234 if len(filters) == 0: 235 cli.log.info('Preparing target list...') 236 targets = target_list 237 else: 238 cli.log.info('Parsing data for all matching keyboard/keymap combinations...') 239 valid_targets = parallel_map(_load_keymap_info, target_list) 240 241 function_re = re.compile(r'^(?P<function>[a-zA-Z]+)\((?P<key>[a-zA-Z0-9_\.]+)(,\s*(?P<value>[^#]+))?\)$') 242 comparison_re = re.compile(r'^(?P<key>[a-zA-Z0-9_\.]+)\s*(?P<op>[\<\>\!=]=|\<|\>)\s*(?P<value>[^#]+)$') 243 244 for filter_expr in filters: 245 function_match = function_re.match(filter_expr) 246 comparison_match = comparison_re.match(filter_expr) 247 248 if function_match is not None: 249 func_name = function_match.group('function').lower() 250 key = function_match.group('key') 251 value = function_match.group('value') 252 253 filter_class = _get_filter_class(func_name, key, value) 254 if filter_class is None: 255 cli.log.warning(f'Unrecognized filter expression: {function_match.group(0)}') 256 continue 257 valid_targets = filter(filter_class.apply, valid_targets) 258 259 value_str = f", {{fg_cyan}}{value}{{fg_reset}}" if value is not None else "" 260 cli.log.info(f'Filtering on condition: {{fg_green}}{func_name}{{fg_reset}}({{fg_cyan}}{key}{{fg_reset}}{value_str})...') 261 262 elif comparison_match is not None: 263 key = comparison_match.group('key') 264 op = comparison_match.group('op') 265 value = comparison_match.group('value') 266 cli.log.info(f'Filtering on condition: {{fg_cyan}}{key}{{fg_reset}} {op} {{fg_cyan}}{value}{{fg_reset}}...') 267 268 def _make_filter(k, o, v): 269 expr = fnmatch.translate(v) 270 rule = re.compile(f'^{expr}$', re.IGNORECASE) 271 272 def f(e: KeyboardKeymapDesc): 273 lhs = e.dotty.get(k) 274 rhs = v 275 276 if o in ['<', '>', '<=', '>=']: 277 lhs = int(False if lhs is None else lhs) 278 rhs = int(rhs) 279 280 if o == '<': 281 return lhs < rhs 282 elif o == '>': 283 return lhs > rhs 284 elif o == '<=': 285 return lhs <= rhs 286 elif o == '>=': 287 return lhs >= rhs 288 else: 289 lhs = str(False if lhs is None else lhs) 290 291 if o == '!=': 292 return rule.search(lhs) is None 293 elif o == '==': 294 return rule.search(lhs) is not None 295 296 return f 297 298 valid_targets = filter(_make_filter(key, op, value), valid_targets) 299 else: 300 cli.log.warning(f'Unrecognized filter expression: {filter_expr}') 301 continue 302 303 cli.log.info('Preparing target list...') 304 targets = list(sorted(set(valid_targets))) 305 306 return targets 307 308 309 def search_keymap_targets(targets: List[Union[Tuple[str, str], Tuple[str, str, Dict[str, str]]]] = [('all', 'default')], filters: List[str] = []) -> List[BuildTarget]: 310 """Search for build targets matching the supplied criteria. 311 """ 312 def _make_desc(e): 313 if len(e) == 3: 314 return KeyboardKeymapDesc(keyboard=e[0], keymap=e[1], extra_args=e[2]) 315 else: 316 return KeyboardKeymapDesc(keyboard=e[0], keymap=e[1]) 317 318 targets = map(_make_desc, targets) 319 targets = _filter_keymap_targets(expand_keymap_targets(targets), filters) 320 targets = list(set(parallel_map(_construct_build_target, list(targets)))) 321 return sorted(targets) 322 323 324 def search_make_targets(targets: List[Union[str, Tuple[str, Dict[str, str]]]], filters: List[str] = []) -> List[BuildTarget]: 325 """Search for build targets matching the supplied criteria. 326 """ 327 targets = _filter_keymap_targets(expand_make_targets(targets), filters) 328 targets = list(set(parallel_map(_construct_build_target, list(targets)))) 329 return sorted(targets)