search.py 9.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265
  1. """Functions for searching through QMK keyboards and keymaps.
  2. """
  3. import contextlib
  4. import functools
  5. import fnmatch
  6. import logging
  7. import re
  8. from typing import Callable, List, Optional, Tuple
  9. from dotty_dict import dotty, Dotty
  10. from milc import cli
  11. from qmk.util import parallel_map
  12. from qmk.info import keymap_json
  13. from qmk.keyboard import list_keyboards, keyboard_folder
  14. from qmk.keymap import list_keymaps, locate_keymap
  15. from qmk.build_targets import KeyboardKeymapBuildTarget, BuildTarget
  16. TargetInfo = Tuple[str, str, dict]
  17. # by using a class for filters, we dont need to worry about capturing values
  18. # see details <https://github.com/qmk/qmk_firmware/pull/21090>
  19. class FilterFunction:
  20. """Base class for filters.
  21. It provides:
  22. - __init__: capture key and value
  23. Each subclass should provide:
  24. - func_name: how it will be specified on CLI
  25. >>> qmk find -f <func_name>...
  26. - apply: function that actually applies the filter
  27. ie: return whether the input kb/km satisfies the condition
  28. """
  29. key: str
  30. value: Optional[str]
  31. func_name: str
  32. apply: Callable[[TargetInfo], bool]
  33. def __init__(self, key, value):
  34. self.key = key
  35. self.value = value
  36. class Exists(FilterFunction):
  37. func_name = "exists"
  38. def apply(self, target_info: TargetInfo) -> bool:
  39. _kb, _km, info = target_info
  40. return self.key in info
  41. class Absent(FilterFunction):
  42. func_name = "absent"
  43. def apply(self, target_info: TargetInfo) -> bool:
  44. _kb, _km, info = target_info
  45. return self.key not in info
  46. class Length(FilterFunction):
  47. func_name = "length"
  48. def apply(self, target_info: TargetInfo) -> bool:
  49. _kb, _km, info = target_info
  50. return (self.key in info and len(info[self.key]) == int(self.value))
  51. class Contains(FilterFunction):
  52. func_name = "contains"
  53. def apply(self, target_info: TargetInfo) -> bool:
  54. _kb, _km, info = target_info
  55. return (self.key in info and self.value in info[self.key])
  56. def _get_filter_class(func_name: str, key: str, value: str) -> Optional[FilterFunction]:
  57. """Initialize a filter subclass based on regex findings and return it.
  58. None if no there's no filter with the name queried.
  59. """
  60. for subclass in FilterFunction.__subclasses__():
  61. if func_name == subclass.func_name:
  62. return subclass(key, value)
  63. return None
  64. def filter_help() -> str:
  65. names = [f"'{f.func_name}'" for f in FilterFunction.__subclasses__()]
  66. return ", ".join(names[:-1]) + f" and {names[-1]}"
  67. def _set_log_level(level):
  68. cli.acquire_lock()
  69. old = cli.log_level
  70. cli.log_level = level
  71. cli.log.setLevel(level)
  72. logging.root.setLevel(level)
  73. cli.release_lock()
  74. return old
  75. @contextlib.contextmanager
  76. def ignore_logging():
  77. old = _set_log_level(logging.CRITICAL)
  78. yield
  79. _set_log_level(old)
  80. def _all_keymaps(keyboard):
  81. """Returns a list of tuples of (keyboard, keymap) for all keymaps for the given keyboard.
  82. """
  83. with ignore_logging():
  84. keyboard = keyboard_folder(keyboard)
  85. return [(keyboard, keymap) for keymap in list_keymaps(keyboard)]
  86. def _keymap_exists(keyboard, keymap):
  87. """Returns the keyboard name if the keyboard+keymap combination exists, otherwise None.
  88. """
  89. with ignore_logging():
  90. return keyboard if locate_keymap(keyboard, keymap) is not None else None
  91. def _load_keymap_info(target: Tuple[str, str]) -> TargetInfo:
  92. """Returns a tuple of (keyboard, keymap, info.json) for the given keyboard/keymap combination.
  93. """
  94. kb, km = target
  95. with ignore_logging():
  96. return (kb, km, keymap_json(kb, km))
  97. def expand_make_targets(targets: List[str]) -> List[Tuple[str, str]]:
  98. """Expand a list of make targets into a list of (keyboard, keymap) tuples.
  99. Caters for 'all' in either keyboard or keymap, or both.
  100. """
  101. split_targets = []
  102. for target in targets:
  103. split_target = target.split(':')
  104. if len(split_target) != 2:
  105. cli.log.error(f"Invalid build target: {target}")
  106. return []
  107. split_targets.append((split_target[0], split_target[1]))
  108. return expand_keymap_targets(split_targets)
  109. def _expand_keymap_target(keyboard: str, keymap: str, all_keyboards: List[str] = None) -> List[Tuple[str, str]]:
  110. """Expand a keyboard input and keymap input into a list of (keyboard, keymap) tuples.
  111. Caters for 'all' in either keyboard or keymap, or both.
  112. """
  113. if all_keyboards is None:
  114. all_keyboards = list_keyboards()
  115. if keyboard == 'all':
  116. if keymap == 'all':
  117. cli.log.info('Retrieving list of all keyboards and keymaps...')
  118. targets = []
  119. for kb in parallel_map(_all_keymaps, all_keyboards):
  120. targets.extend(kb)
  121. return targets
  122. else:
  123. cli.log.info(f'Retrieving list of keyboards with keymap "{keymap}"...')
  124. keyboard_filter = functools.partial(_keymap_exists, keymap=keymap)
  125. return [(kb, keymap) for kb in filter(lambda e: e is not None, parallel_map(keyboard_filter, all_keyboards))]
  126. else:
  127. if keymap == 'all':
  128. cli.log.info(f'Retrieving list of keymaps for keyboard "{keyboard}"...')
  129. return _all_keymaps(keyboard)
  130. else:
  131. return [(keyboard, keymap)]
  132. def expand_keymap_targets(targets: List[Tuple[str, str]]) -> List[Tuple[str, str]]:
  133. """Expand a list of (keyboard, keymap) tuples inclusive of 'all', into a list of explicit (keyboard, keymap) tuples.
  134. """
  135. overall_targets = []
  136. all_keyboards = list_keyboards()
  137. for target in targets:
  138. overall_targets.extend(_expand_keymap_target(target[0], target[1], all_keyboards))
  139. return list(sorted(set(overall_targets)))
  140. def _construct_build_target_kb_km(e):
  141. return KeyboardKeymapBuildTarget(keyboard=e[0], keymap=e[1])
  142. def _construct_build_target_kb_km_json(e):
  143. return KeyboardKeymapBuildTarget(keyboard=e[0], keymap=e[1], json=e[2])
  144. def _filter_keymap_targets(target_list: List[Tuple[str, str]], filters: List[str] = []) -> List[BuildTarget]:
  145. """Filter a list of (keyboard, keymap) tuples based on the supplied filters.
  146. Optionally includes the values of the queried info.json keys.
  147. """
  148. if len(filters) == 0:
  149. cli.log.info('Preparing target list...')
  150. targets = list(set(parallel_map(_construct_build_target_kb_km, target_list)))
  151. else:
  152. cli.log.info('Parsing data for all matching keyboard/keymap combinations...')
  153. valid_keymaps = [(e[0], e[1], dotty(e[2])) for e in parallel_map(_load_keymap_info, target_list)]
  154. function_re = re.compile(r'^(?P<function>[a-zA-Z]+)\((?P<key>[a-zA-Z0-9_\.]+)(,\s*(?P<value>[^#]+))?\)$')
  155. equals_re = re.compile(r'^(?P<key>[a-zA-Z0-9_\.]+)\s*=\s*(?P<value>[^#]+)$')
  156. for filter_expr in filters:
  157. function_match = function_re.match(filter_expr)
  158. equals_match = equals_re.match(filter_expr)
  159. if function_match is not None:
  160. func_name = function_match.group('function').lower()
  161. key = function_match.group('key')
  162. value = function_match.group('value')
  163. filter_class = _get_filter_class(func_name, key, value)
  164. if filter_class is None:
  165. cli.log.warning(f'Unrecognized filter expression: {function_match.group(0)}')
  166. continue
  167. valid_keymaps = filter(filter_class.apply, valid_keymaps)
  168. value_str = f", {{fg_cyan}}{value}{{fg_reset}}" if value is not None else ""
  169. cli.log.info(f'Filtering on condition: {{fg_green}}{func_name}{{fg_reset}}({{fg_cyan}}{key}{{fg_reset}}{value_str})...')
  170. elif equals_match is not None:
  171. key = equals_match.group('key')
  172. value = equals_match.group('value')
  173. cli.log.info(f'Filtering on condition: {{fg_cyan}}{key}{{fg_reset}} == {{fg_cyan}}{value}{{fg_reset}}...')
  174. def _make_filter(k, v):
  175. expr = fnmatch.translate(v)
  176. rule = re.compile(f'^{expr}$', re.IGNORECASE)
  177. def f(e):
  178. lhs = e[2].get(k)
  179. lhs = str(False if lhs is None else lhs)
  180. return rule.search(lhs) is not None
  181. return f
  182. valid_keymaps = filter(_make_filter(key, value), valid_keymaps)
  183. else:
  184. cli.log.warning(f'Unrecognized filter expression: {filter_expr}')
  185. continue
  186. cli.log.info('Preparing target list...')
  187. 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
  188. targets = list(set(parallel_map(_construct_build_target_kb_km_json, list(valid_keymaps))))
  189. return targets
  190. def search_keymap_targets(targets: List[Tuple[str, str]] = [('all', 'default')], filters: List[str] = []) -> List[BuildTarget]:
  191. """Search for build targets matching the supplied criteria.
  192. """
  193. return _filter_keymap_targets(expand_keymap_targets(targets), filters)
  194. def search_make_targets(targets: List[str], filters: List[str] = []) -> List[BuildTarget]:
  195. """Search for build targets matching the supplied criteria.
  196. """
  197. return _filter_keymap_targets(expand_make_targets(targets), filters)