Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Refactor] qmk find #21096

Merged
merged 7 commits into from
Feb 17, 2024
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions lib/python/qmk/cli/find.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
"""Command to search through all keyboards and keymaps for a given search criteria.
"""
from milc import cli
from qmk.search import search_keymap_targets
from qmk.search import filter_help, search_keymap_targets


@cli.argument(
Expand All @@ -11,7 +11,7 @@
action='append',
default=[],
help= # noqa: `format-python` and `pytest` don't agree here.
"Filter the list of keyboards based on their info.json data. Accepts the formats key=value, function(key), or function(key,value), eg. 'features.rgblight=true'. Valid functions are 'absent', 'contains', 'exists' and 'length'. May be passed multiple times; all filters need to match. Value may include wildcards such as '*' and '?'." # noqa: `format-python` and `pytest` don't agree here.
f"Filter the list of keyboards based on their info.json data. Accepts the formats key=value, function(key), or function(key,value), eg. 'features.rgblight=true'. Valid functions are {filter_help()}. May be passed multiple times; all filters need to match. Value may include wildcards such as '*' and '?'." # noqa: `format-python` and `pytest` don't agree here.
)
@cli.argument('-p', '--print', arg_only=True, action='append', default=[], help="For each matched target, print the value of the supplied info.json key. May be passed multiple times.")
@cli.argument('-km', '--keymap', type=str, default='default', help="The keymap name to build. Default is 'default'.")
Expand Down
109 changes: 86 additions & 23 deletions lib/python/qmk/search.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
"""Functions for searching through QMK keyboards and keymaps.

Check failure on line 1 in lib/python/qmk/search.py

View workflow job for this annotation

GitHub Actions / lint

Requires Formatting
"""
import contextlib
import functools
import fnmatch
import logging
import re
from typing import List, Tuple
from typing import Callable, List, Optional, Tuple
from dotty_dict import dotty, Dotty
from milc import cli

Expand All @@ -15,6 +15,80 @@
from qmk.keymap import list_keymaps, locate_keymap
from qmk.build_targets import KeyboardKeymapBuildTarget, BuildTarget

TargetInfo = Tuple[str, str, dict]


# by using a class for filters, we dont need to worry about capturing values
# see details <https://github.com/qmk/qmk_firmware/pull/21090>
class FilterFunction:
"""Base class for filters.
It provides:
- __init__: capture key and value

Each subclass should provide:
- func_name: how it will be specified on CLI
>>> qmk find -f <func_name>...
- apply: function that actually applies the filter
ie: return whether the input kb/km satisfies the condition
"""

key: str
value: Optional[str]

func_name: str
apply: Callable[[TargetInfo], bool]

def __init__(self, key, value):
self.key = key
self.value = value


class Exists(FilterFunction):
func_name = "exists"

def apply(self, target_info: TargetInfo) -> bool:
_kb, _km, info = target_info
return self.key in info


class Absent(FilterFunction):
func_name = "absent"

def apply(self, target_info: TargetInfo) -> bool:
_kb, _km, info = target_info
return self.key not in info


class Length(FilterFunction):
func_name = "length"

def apply(self, target_info: TargetInfo) -> bool:
_kb, _km, info = target_info
return (self.key in info and len(info[self.key]) == int(self.value))

class Contains(FilterFunction):
func_name = "contains"

def apply(self, target_info: TargetInfo) -> bool:
_kb, _km, info = target_info
return (self.key in info and self.value in info[self.key])


def _get_filter_class(func_name: str, key: str, value: str) -> Optional[FilterFunction]:
"""Initialize a filter subclass based on regex findings and return it.
None if no there's no filter with the name queried.
"""

for subclass in FilterFunction.__subclasses__():
if func_name == subclass.func_name:
return subclass(key, value)

return None


def filter_help() -> str:
names = [f"'{f.func_name}'" for f in FilterFunction.__subclasses__()]
return ", ".join(names[:-1]) + f" and {names[-1]}"

def _set_log_level(level):
cli.acquire_lock()
Expand Down Expand Up @@ -48,11 +122,12 @@
return keyboard if locate_keymap(keyboard, keymap) is not None else None


def _load_keymap_info(kb_km):
def _load_keymap_info(target: Tuple[str, str]) -> TargetInfo:
"""Returns a tuple of (keyboard, keymap, info.json) for the given keyboard/keymap combination.
"""
kb, km = target
with ignore_logging():
return (kb_km[0], kb_km[1], keymap_json(kb_km[0], kb_km[1]))
return (kb, km, keymap_json(kb, km))


def expand_make_targets(targets: List[str]) -> List[Tuple[str, str]]:
Expand Down Expand Up @@ -139,26 +214,14 @@
key = function_match.group('key')
value = function_match.group('value')

if value is not None:
if func_name == 'length':
valid_keymaps = filter(lambda e, key=key, value=value: key in e[2] and len(e[2].get(key)) == int(value), valid_keymaps)
elif func_name == 'contains':
valid_keymaps = filter(lambda e, key=key, value=value: key in e[2] and value in e[2].get(key), valid_keymaps)
else:
cli.log.warning(f'Unrecognized filter expression: {function_match.group(0)}')
continue

cli.log.info(f'Filtering on condition: {{fg_green}}{func_name}{{fg_reset}}({{fg_cyan}}{key}{{fg_reset}}, {{fg_cyan}}{value}{{fg_reset}})...')
else:
if func_name == 'exists':
valid_keymaps = filter(lambda e, key=key: key in e[2], valid_keymaps)
elif func_name == 'absent':
valid_keymaps = filter(lambda e, key=key: key not in e[2], valid_keymaps)
else:
cli.log.warning(f'Unrecognized filter expression: {function_match.group(0)}')
continue

cli.log.info(f'Filtering on condition: {{fg_green}}{func_name}{{fg_reset}}({{fg_cyan}}{key}{{fg_reset}})...')
filter_class = _get_filter_class(func_name, key, value)
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This code is no longer checking that the binary (key and value) filters receive value != None. Shall perhaps add it back

if filter_class is None:
cli.log.warning(f'Unrecognized filter expression: {function_match.group(0)}')
continue
valid_keymaps = filter(filter_class.apply, valid_keymaps)

value_str = f", {{fg_cyan}}{value}{{fg_reset}})" if value is not None else ""
cli.log.info(f'Filtering on condition: {{fg_green}}{func_name}{{fg_reset}}({{fg_cyan}}{key}{{fg_reset}}{value_str}...')

elif equals_match is not None:
key = equals_match.group('key')
Expand Down
Loading