keyboard stuff
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)