[Bf-blender-cvs] [85b209e02e0] master: Tests: add script_validate_keymap

Campbell Barton noreply at git.blender.org
Sun Mar 14 13:32:53 CET 2021


Commit: 85b209e02e07ca0918237b655a94d1214c72deb1
Author: Campbell Barton
Date:   Sun Mar 14 19:06:48 2021 +1100
Branches: master
https://developer.blender.org/rB85b209e02e07ca0918237b655a94d1214c72deb1

Tests: add script_validate_keymap

This checks the generated key-map data matches the result of
re-exporting and re-importing.

This shows up various inconsistencies, including:

- Unused keymaps.
- Unknown/unused data in the keymap.
- Event arguments that don't make sense.
- Event values that don't match the event type
  (tweak direction on keyboard event for example).

===================================================================

M	tests/python/CMakeLists.txt
A	tests/python/bl_keymap_validate.py

===================================================================

diff --git a/tests/python/CMakeLists.txt b/tests/python/CMakeLists.txt
index d8bc5be40d7..260eb75d64f 100644
--- a/tests/python/CMakeLists.txt
+++ b/tests/python/CMakeLists.txt
@@ -70,6 +70,12 @@ add_blender_test(
   --python ${CMAKE_CURRENT_LIST_DIR}/bl_keymap_completeness.py
 )
 
+add_blender_test(
+  script_validate_keymap
+  --python ${CMAKE_CURRENT_LIST_DIR}/bl_keymap_validate.py
+  -- --relaxed  # Disable minor things that should not cause tests to break.
+)
+
 add_blender_test(
   script_load_addons
   --python ${CMAKE_CURRENT_LIST_DIR}/bl_load_addons.py
diff --git a/tests/python/bl_keymap_validate.py b/tests/python/bl_keymap_validate.py
new file mode 100644
index 00000000000..59b585b09d6
--- /dev/null
+++ b/tests/python/bl_keymap_validate.py
@@ -0,0 +1,241 @@
+# ##### BEGIN GPL LICENSE BLOCK #####
+#
+#  This program is free software; you can redistribute it and/or
+#  modify it under the terms of the GNU General Public License
+#  as published by the Free Software Foundation; either version 2
+#  of the License, or (at your option) any later version.
+#
+#  This program is distributed in the hope that it will be useful,
+#  but WITHOUT ANY WARRANTY; without even the implied warranty of
+#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+#  GNU General Public License for more details.
+#
+#  You should have received a copy of the GNU General Public License
+#  along with this program; if not, write to the Free Software Foundation,
+#  Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+#
+# ##### END GPL LICENSE BLOCK #####
+
+# <pep8 compliant>
+
+# ./blender.bin --background -noaudio --factory-startup --python tests/python/bl_keymap_validate.py
+#
+
+"""
+This catches the following kinds of issues:
+
+- Arguments that don't make sense, e.g.
+  - 'repeat' for non keyboard events.
+  - 'any' argument with other modifiers also enabled.
+  - Duplicate property entries.
+- Unknown/unused dictionary keys.
+- 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".
+
+This works by taking the keymap data (before it's loaded into Blender),
+then comparing it with that same keymap after exporting and imporing.
+
+NOTE:
+
+   Append ' -- --relaxed' not to fail on small issues,
+   this is so minor inconsistencies don't break the tests.
+"""
+
+import types
+import typing
+
+import contextlib
+
+import bpy
+
+# 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.
+
+
+# -----------------------------------------------------------------------------
+# Generic Utilities
+
+ at contextlib.contextmanager
+def temp_fn_argument_extractor(
+        mod: types.ModuleType,
+        mod_attr: str,
+) -> typing.Iterator[typing.List[typing.Tuple[list, dict]]]:
+    """
+    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
+    arguments & keywords, stored each time the function was called.
+    """
+    args_collected = []
+    real_fn = getattr(mod, mod_attr)
+
+    def wrap_fn(*args, **kw):
+        args_collected.append((args, kw))
+        return real_fn(*args, **kw)
+    setattr(mod, mod_attr, wrap_fn)
+    try:
+        yield args_collected
+    finally:
+        setattr(mod, mod_attr, real_fn)
+
+
+def round_float_32(f: float) -> float:
+    from struct import pack, unpack
+    return unpack("f", pack("f", f))[0]
+
+
+def report_humanly_readable_difference(a: typing.Any, b: typing.Any) -> typing.Optional[str]:
+    """
+    Compare strings, return None whrn they match,
+    otherwise a humanly readable difference message.
+    """
+    import unittest
+    cls = unittest.TestCase()
+    try:
+        cls.assertEqual(a, b)
+    except AssertionError as ex:
+        return str(ex)
+    return None
+
+
+# -----------------------------------------------------------------------------
+# Keymap Utilities.
+
+def keyconfig_preset_scan() -> typing.List[str]:
+    """
+    Return all bundled presets (keymaps), not user presets.
+    """
+    import os
+    from bpy import context
+    # This assumes running with `--factory-settings`.
+    default_keyconfig = context.window_manager.keyconfigs.active.name
+    default_preset_filepath = bpy.utils.preset_find(default_keyconfig, "keyconfig")
+    dirname, filename = os.path.split(default_preset_filepath)
+    return [
+        os.path.join(dirname, f)
+        for f in sorted(os.listdir(dirname))
+        if f.lower().endswith(".py")
+        if not f.startswith((".", "_"))
+    ]
+
+
+def keymap_item_property_clean(value: typing.Any) -> typing.Any:
+    """
+    Recursive property sanitize.
+
+    - Make all floats 32bit (since internally they are).
+    - Sort all properties (since the order isn't important).
+    """
+    if type(value) is float:
+        # Once the value is loaded back from Blender, it will be 32bit.
+        return round_float_32(value)
+    if type(value) is list:
+        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],
+        )
+    return value
+
+
+def keymap_data_clean(keyconfig_data: typing.List, *, relaxed: bool) -> None:
+    """
+    Order & sanitize keymap data so the result
+    from the hand written Python script is comparable with data exported & imported.
+    """
+    keyconfig_data.sort(key=lambda a: a[0])
+    for _, _, km_items_data in keyconfig_data:
+        items = km_items_data["items"]
+        for i, (item_op, item_event, item_prop) in enumerate(items):
+            if relaxed:
+                # Prevent "alt": False from raising an error.
+                defaults = {"alt": False, "ctrl": False, "shift": False, "any": False}
+                defaults.update(item_event)
+                item_event.update(defaults)
+
+            if item_prop:
+                properties = item_prop.get("properties")
+                if properties:
+                    properties[:] = keymap_item_property_clean(properties)
+                else:
+                    item_prop.pop("properties")
+
+            # Needed so: `{"properties": ()}` matches `None` as there is no meaningful difference.
+            # Wruting `None` makes the most sense when explicitly written, however generated properties
+            # might be empty and it's not worth adding checks in the generation logic to use `None`
+            # just to satisfy this check.
+            if not item_prop:
+                items[i] = item_op, item_event, None
+
+
+def keyconfig_activate_and_extract_data(filepath: str, *, relaxed: bool) -> typing.List:
+    """
+    Activate the key-map by filepath,
+    return the key-config data (cleaned for comparison).
+    """
+    import bl_keymap_utils.io
+    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]
+        keymap_data_clean(keyconfig_data, relaxed=relaxed)
+        return keyconfig_data
+
+
+def main() -> None:
+    import os
+    import sys
+    import pprint
+    import tempfile
+
+    argv = (sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else [])
+
+    # If we want full arg parsing.
+    relaxed = "--relaxed" not in argv
+
+    has_error = False
+
+    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))
+    if has_error:
+        sys.exit(1)
+
+
+if __name__ == "__main__":
+    main()



More information about the Bf-blender-cvs mailing list