343 lines
12 KiB
Python
343 lines
12 KiB
Python
# SPDX-License-Identifier: GPL-2.0-or-later
|
|
|
|
# ./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".
|
|
- 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.
|
|
|
|
NOTE:
|
|
|
|
Append ' -- --relaxed' not to fail on small issues,
|
|
this is so minor inconsistencies don't break the tests.
|
|
"""
|
|
|
|
import types
|
|
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 # 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 = "" # "/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
|
|
|
|
|
|
@contextlib.contextmanager
|
|
def temp_fn_argument_extractor(
|
|
mod: types.ModuleType,
|
|
mod_attr: str,
|
|
) -> 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
|
|
arguments & keywords, stored each time the function was called.
|
|
"""
|
|
args_collected = []
|
|
real_fn = getattr(mod, mod_attr)
|
|
|
|
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)
|
|
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] # type: ignore
|
|
|
|
|
|
def report_humanly_readable_difference(a: Any, b: Any) -> 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() -> 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: Any) -> 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(),
|
|
# 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: KeyConfigData, *, 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.
|
|
# Writing `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_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 # 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]
|
|
# 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
|
|
import pprint
|
|
import tempfile
|
|
|
|
argv = (sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else [])
|
|
|
|
# Use `argparse` for full arg parsing, for now this is enough.
|
|
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]
|
|
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)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|