at master 12 kB view raw
1"""Functions for searching through QMK keyboards and keymaps. 2""" 3from dataclasses import dataclass 4import contextlib 5import functools 6import fnmatch 7import json 8import logging 9import re 10from typing import Callable, Dict, List, Optional, Tuple, Union 11from dotty_dict import dotty, Dotty 12from milc import cli 13 14from qmk.util import parallel_map 15from qmk.info import keymap_json 16from qmk.keyboard import list_keyboards, keyboard_folder 17from qmk.keymap import list_keymaps, locate_keymap 18from qmk.build_targets import KeyboardKeymapBuildTarget, BuildTarget 19 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 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> 50class 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 73class 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 80class 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 87class 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 95class 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 103def _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 115def 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 120def _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 134def ignore_logging(): 135 old = _set_log_level(logging.CRITICAL) 136 yield 137 _set_log_level(old) 138 139 140def _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 148def _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 155def _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 163def 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 183def _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 215def 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 225def _construct_build_target(e: KeyboardKeymapDesc): 226 return e.to_build_target() 227 228 229def _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 309def 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 324def 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)