diff options
Diffstat (limited to 'tests/python/bl_keymap_validate.py')
-rw-r--r-- | tests/python/bl_keymap_validate.py | 212 |
1 files changed, 165 insertions, 47 deletions
diff --git a/tests/python/bl_keymap_validate.py b/tests/python/bl_keymap_validate.py index ce2022f6ae7..1743893dc8a 100644 --- a/tests/python/bl_keymap_validate.py +++ b/tests/python/bl_keymap_validate.py @@ -16,6 +16,7 @@ This catches the following kinds of issues: - Unused keymaps (keymaps which are defined but not used anywhere). - Event values that don't make sense for the event type, e.g. An escape key could have the value "NORTH" instead of "PRESS". +- Identical key-map items. This works by taking the keymap data (before it's loaded into Blender), then comparing it with that same keymap after exporting and importing. @@ -27,16 +28,46 @@ NOTE: """ import types -import typing - +from typing import ( + Any, + Dict, + Generator, + List, + Optional, + Sequence, + Tuple, +) + +KeyConfigData = List[Tuple[str, Tuple[Any], Dict[str, Any]]] + +import os import contextlib -import bpy +import bpy # type: ignore # Useful for diffing the output to see what changed in context. # this writes keymaps into the current directory with `.orig.py` & `.rewrite.py` extensions. -WRITE_OUTPUT_DIR = None # "/tmp", defaults to the systems temp directory. +WRITE_OUTPUT_DIR = "" # "/tmp", defaults to the systems temp directory. +# For each preset, test all of these options. +# The key is the preset name, containing a sequence of (attribute, value) pairs to test. +# +# NOTE(@campbellbarton): only add these for preferences which impact multiple keys as exposing all preferences +# this way would create too many combinations making the tests take too long to complete. +PRESET_PREFS = { + "Blender": ( + (("select_mouse", 'LEFT'), ("use_alt_tool", False)), + (("select_mouse", 'LEFT'), ("use_alt_tool", True)), + (("select_mouse", 'RIGHT'), ("rmb_action", 'TWEAK')), + (("select_mouse", 'RIGHT'), ("rmb_action", 'FALLBACK_TOOL')), + ), +} + +# Don't report duplicates for these presets. +ALLOW_DUPLICATES = { + # This key-map manipulates the default key-map, making it difficult to avoid duplicates entirely. + "Industry_Compatible" +} # ----------------------------------------------------------------------------- # Generic Utilities @@ -45,7 +76,7 @@ WRITE_OUTPUT_DIR = None # "/tmp", defaults to the systems temp directory. def temp_fn_argument_extractor( mod: types.ModuleType, mod_attr: str, -) -> typing.Iterator[typing.List[typing.Tuple[list, dict]]]: +) -> Generator[List[Tuple[Tuple[Tuple[Any], ...], Dict[str, Dict[str, Any]]]], None, None]: """ Temporarily intercept a function, so it's arguments can be extracted. The context manager gives us a list where each item is a tuple of @@ -54,7 +85,7 @@ def temp_fn_argument_extractor( args_collected = [] real_fn = getattr(mod, mod_attr) - def wrap_fn(*args, **kw): + def wrap_fn(*args: Tuple[Any], **kw: Dict[str, Any]) -> Any: args_collected.append((args, kw)) return real_fn(*args, **kw) setattr(mod, mod_attr, wrap_fn) @@ -66,10 +97,10 @@ def temp_fn_argument_extractor( def round_float_32(f: float) -> float: from struct import pack, unpack - return unpack("f", pack("f", f))[0] + return unpack("f", pack("f", f))[0] # type: ignore -def report_humanly_readable_difference(a: typing.Any, b: typing.Any) -> typing.Optional[str]: +def report_humanly_readable_difference(a: Any, b: Any) -> Optional[str]: """ Compare strings, return None whrn they match, otherwise a humanly readable difference message. @@ -86,7 +117,7 @@ def report_humanly_readable_difference(a: typing.Any, b: typing.Any) -> typing.O # ----------------------------------------------------------------------------- # Keymap Utilities. -def keyconfig_preset_scan() -> typing.List[str]: +def keyconfig_preset_scan() -> List[str]: """ Return all bundled presets (keymaps), not user presets. """ @@ -104,7 +135,7 @@ def keyconfig_preset_scan() -> typing.List[str]: ] -def keymap_item_property_clean(value: typing.Any) -> typing.Any: +def keymap_item_property_clean(value: Any) -> Any: """ Recursive property sanitize. @@ -118,12 +149,13 @@ def keymap_item_property_clean(value: typing.Any) -> typing.Any: return sorted( # Convert to `dict` to de-duplicate. dict([(k, keymap_item_property_clean(v)) for k, v in value]).items(), - key=lambda item: item[0], + # Ignore type checking, these are strings which we know can be sorted. + key=lambda item: item[0], # type: ignore ) return value -def keymap_data_clean(keyconfig_data: typing.List, *, relaxed: bool) -> None: +def keymap_data_clean(keyconfig_data: KeyConfigData, *, relaxed: bool) -> None: """ Order & sanitize keymap data so the result from the hand written Python script is comparable with data exported & imported. @@ -153,22 +185,82 @@ def keymap_data_clean(keyconfig_data: typing.List, *, relaxed: bool) -> None: items[i] = item_op, item_event, None -def keyconfig_activate_and_extract_data(filepath: str, *, relaxed: bool) -> typing.List: +def keyconfig_config_as_filename_component(values: Sequence[Tuple[str, Any]]) -> str: + """ + Takes a configuration, eg: + + [("select_mouse", 'LEFT'), ("rmb_action", 'TWEAK')] + + And returns a filename compatible path: + """ + from urllib.parse import quote + if not values: + return "" + + return "(" + quote( + ".".join([ + "-".join((str(key), str(val))) + for key, val in values + ]), + # Needed so forward slashes aren't included in the resulting name. + safe="", + ) + ")" + + +def keyconfig_activate_and_extract_data( + filepath: str, + *, + relaxed: bool, + config: Sequence[Tuple[str, Any]], +) -> KeyConfigData: """ Activate the key-map by filepath, return the key-config data (cleaned for comparison). """ - import bl_keymap_utils.io + import bl_keymap_utils.io # type: ignore + + if config: + bpy.ops.preferences.keyconfig_activate(filepath=filepath) + km_prefs = bpy.context.window_manager.keyconfigs.active.preferences + for attr, value in config: + setattr(km_prefs, attr, value) + with temp_fn_argument_extractor(bl_keymap_utils.io, "keyconfig_init_from_data") as args_collected: bpy.ops.preferences.keyconfig_activate(filepath=filepath) + # If called multiple times, something strange is happening. assert(len(args_collected) == 1) args, _kw = args_collected[0] - keyconfig_data = args[1] + # Ignore the type check as `temp_fn_argument_extractor` is a generic function + # which doesn't contain type information of the function being wrapped. + keyconfig_data: KeyConfigData = args[1] # type: ignore keymap_data_clean(keyconfig_data, relaxed=relaxed) return keyconfig_data +def keyconfig_report_duplicates(keyconfig_data: KeyConfigData) -> str: + """ + Return true if any of the key-maps have duplicate items. + + Duplicate items are reported so they can be resolved. + """ + error_text = [] + for km_idname, km_args, km_items_data in keyconfig_data: + items = tuple(km_items_data["items"]) + unique: Dict[str, List[int]] = {} + for i, (item_op, item_event, item_prop) in enumerate(items): + # Ensure stable order as `repr` will use order of definition. + item_event = {key: item_event[key] for key in sorted(item_event.keys())} + if item_prop is not None: + item_prop = {key: item_prop[key] for key in sorted(item_prop.keys())} + item_repr = repr((item_op, item_event, item_prop)) + unique.setdefault(item_repr, []).append(i) + for key, value in unique.items(): + if len(value) > 1: + error_text.append("\"%s\" %r indices %r for item %r" % (km_idname, km_args, value, key)) + return "\n".join(error_text) + + def main() -> None: import os import sys @@ -185,38 +277,64 @@ def main() -> None: presets = keyconfig_preset_scan() for filepath in presets: name_only = os.path.splitext(os.path.basename(filepath))[0] - - print("KeyMap Validate:", name_only, end=" ... ") - - data_orig = keyconfig_activate_and_extract_data(filepath, relaxed=relaxed) - - with tempfile.TemporaryDirectory() as dir_temp: - filepath_temp = os.path.join(dir_temp, name_only + ".test.py") - bpy.ops.preferences.keyconfig_export(filepath=filepath_temp, all=True) - data_reimport = keyconfig_activate_and_extract_data(filepath_temp, relaxed=relaxed) - - # Comparing a pretty printed string tends to give more useful - # text output compared to the data-structure. Both will work. - if (cmp_message := report_humanly_readable_difference( - pprint.pformat(data_orig, indent=0, width=120), - pprint.pformat(data_reimport, indent=0, width=120), - )): - print("FAILED!") - sys.stdout.write(( - "Keymap %s has inconsistency on re-importing:\n" - " %r" - ) % (filepath, cmp_message)) - has_error = True - else: - print("OK!") - - if WRITE_OUTPUT_DIR: - name_only_temp = os.path.join(WRITE_OUTPUT_DIR, name_only) - print("Writing data to:", name_only_temp + ".*.py") - with open(name_only_temp + ".orig.py", 'w') as fh: - fh.write(pprint.pformat(data_orig, indent=0, width=120)) - with open(name_only_temp + ".rewrite.py", 'w') as fh: - fh.write(pprint.pformat(data_reimport, indent=0, width=120)) + for config in PRESET_PREFS.get(name_only, ((),)): + name_only_with_config = name_only + keyconfig_config_as_filename_component(config) + print("KeyMap Validate:", name_only_with_config, end=" ... ") + data_orig = keyconfig_activate_and_extract_data( + filepath, + relaxed=relaxed, + config=config, + ) + + with tempfile.TemporaryDirectory() as dir_temp: + filepath_temp = os.path.join( + dir_temp, + name_only_with_config + ".test" + ".py", + ) + + bpy.ops.preferences.keyconfig_export(filepath=filepath_temp, all=True) + data_reimport = keyconfig_activate_and_extract_data( + filepath_temp, + relaxed=relaxed, + # No configuration supported when loading exported key-maps. + config=(), + ) + + # Comparing a pretty printed string tends to give more useful + # text output compared to the data-structure. Both will work. + if (cmp_message := report_humanly_readable_difference( + pprint.pformat(data_orig, indent=0, width=120), + pprint.pformat(data_reimport, indent=0, width=120), + )): + error_text_consistency = "Keymap %s has inconsistency on re-importing." % cmp_message + else: + error_text_consistency = "" + + # Perform an additional sanity check: + # That there are no identical key-map items. + if name_only not in ALLOW_DUPLICATES: + error_text_duplicates = keyconfig_report_duplicates(data_orig) + else: + error_text_duplicates = "" + + if error_text_consistency or error_text_duplicates: + print("FAILED!") + print("%r has errors!" % filepath) + if error_text_consistency: + print(error_text_consistency) + if error_text_duplicates: + print(error_text_duplicates) + else: + print("OK!") + + if WRITE_OUTPUT_DIR: + os.makedirs(WRITE_OUTPUT_DIR, exist_ok=True) + name_only_temp = os.path.join(WRITE_OUTPUT_DIR, name_only_with_config) + print("Writing data to:", name_only_temp + ".*.py") + with open(name_only_temp + ".orig.py", 'w') as fh: + fh.write(pprint.pformat(data_orig, indent=0, width=120)) + with open(name_only_temp + ".rewrite.py", 'w') as fh: + fh.write(pprint.pformat(data_reimport, indent=0, width=120)) if has_error: sys.exit(1) |