diff --git a/keymapper/injection/macros/macro.py b/keymapper/injection/macros/macro.py index b7b9cb3b..b9f21e10 100644 --- a/keymapper/injection/macros/macro.py +++ b/keymapper/injection/macros/macro.py @@ -30,7 +30,7 @@ all of the configured stuff. Examples -------- r(3, k(a).w(10)): a <10ms> a <10ms> a -r(2, k(a).k(-)).k(b): a - a - b +r(2, k(a).k(KEY_A)).k(b): a - a - b w(1000).m(Shift_L, r(2, k(a))).w(10).k(b): <1s> A A <10ms> b """ @@ -50,13 +50,30 @@ from keymapper.utils import PRESS, PRESS_NEGATIVE macro_variables = SharedDict() +class Variable: + """Can be used as function parameter in the various add_... functions. + + Parsed from strings like `$foo` in `repeat($foo, k(KEY_A))` + + Its value is unknown during construction and needs to be set using the `set` macro + during runtime. + """ + + def __init__(self, name): + self.name = name + + def resolve(self): + """Get the variables value from memory.""" + return macro_variables.get(self.name) + + def _type_check(value, allowed_types, display_name=None, position=None): """Validate a parameter used in a macro. - If the value starts with $, it will be returned and should be resolved + If the value is a Variable, it will be returned and should be resolved during runtime with _resolve. """ - if isinstance(value, str) and value.startswith("$"): + if isinstance(value, Variable): # it is a variable and will be read at runtime return value @@ -85,13 +102,13 @@ def _type_check(value, allowed_types, display_name=None, position=None): raise TypeError(f"Expected parameter to be one of {allowed_types}, but got {value}") -def _type_check_keyname(name): +def _type_check_keyname(keyname): """Same as _type_check, but checks if the key-name is valid.""" - if isinstance(name, str) and name.startswith("$"): + if isinstance(keyname, Variable): # it is a variable and will be read at runtime - return name + return keyname - symbol = str(name) + symbol = str(keyname) code = system_mapping.get(symbol) if code is None: @@ -114,14 +131,13 @@ def _type_check_variablename(name): def _resolve(argument, allowed_types=None): - """If the argument starts with a $, then figure out its value. + """If the argument is a variable, figure out its value and cast it. Use this just-in-time when you need the actual value of the variable during runtime. """ - if isinstance(argument, str) and argument.startswith("$"): - variable_name = argument.split("$", 1)[1] - value = macro_variables.get(variable_name) + if isinstance(argument, Variable): + value = argument.resolve() logger.debug('"%s" is "%s"', argument, value) if allowed_types: return _type_check(value, allowed_types) @@ -227,7 +243,7 @@ class Macro: return not self._holding_event.is_set() def get_capabilities(self): - """Resolve all capabilities of the macro and those of its children.""" + """Get the merged capabilities of the macro and its children.""" capabilities = copy.deepcopy(self.capabilities) for macro in self.child_macros: diff --git a/keymapper/injection/macros/parse.py b/keymapper/injection/macros/parse.py index fb7d4e02..67488d0a 100644 --- a/keymapper/injection/macros/parse.py +++ b/keymapper/injection/macros/parse.py @@ -27,7 +27,7 @@ import traceback import inspect from keymapper.logger import logger -from keymapper.injection.macros.macro import Macro +from keymapper.injection.macros.macro import Macro, Variable def is_this_a_macro(output): @@ -104,7 +104,15 @@ def _extract_args(inner): brackets = 0 params = [] start = 0 + string = False for position, char in enumerate(inner): + # ignore anything between string quotes + if char == '"': + string = not string + if string: + continue + + # ignore commas inside child macros if char == "(": brackets += 1 if char == ")": @@ -161,23 +169,33 @@ def _split_keyword_arg(param): def _parse_recurse(macro, context, macro_instance=None, depth=0): """Handle a subset of the macro, e.g. one parameter or function call. + Not using eval for security reasons. + Parameters ---------- macro : string - Just like parse + Just like parse. + A single parameter of a function or the complete macro as string. context : Context macro_instance : Macro or None A macro instance to add tasks to depth : int For logging porposes """ - # not using eval for security reasons assert isinstance(macro, str) assert isinstance(depth, int) if macro == "": return None + if macro.startswith('"'): + # a string, don't parse + return macro[1:-1] + + if macro.startswith("$"): + # will be resolved during the macros runtime + return Variable(macro.split("$", 1)[1]) + if macro_instance is None: macro_instance = Macro(macro, context) else: @@ -254,13 +272,22 @@ def _parse_recurse(macro, context, macro_instance=None, depth=0): return macro_instance - # probably a simple parameter + # at this point it is still a string since no previous rule matched + assert isinstance(macro, str) + try: - # if possible, parse as int + # If it can be parsed as number, then it's probably a parameter to a function. + # It isn't really needed to parse it here, but is nice for the logs. macro = int(macro) except ValueError: - # use as string instead - pass + try: + macro = float(macro) + except ValueError: + # use as string instead + # If a string, it is probably either a key name like KEY_A or a variable + # name as n `set(var, 1)`, both won't contain special characters that can + # break macro syntax so they don't have to be wrapped in quotes. + pass logger.spam("%s%s %s", space, type(macro), macro) return macro @@ -294,6 +321,20 @@ def handle_plus_syntax(macro): return output +def _remove_whitespaces(macro, delimiter='"'): + """Remove whitespaces, tabs, newlines and such outside of string quotes.""" + result = "" + for i, chunk in enumerate(macro.split(delimiter)): + if i % 2 == 0: + result += re.sub(r"\s", "", chunk) + else: + result += chunk + result += delimiter + + # one extra delimiter was added + return result[: -len(delimiter)] + + def parse(macro, context, return_errors=False): """parse and generate a Macro that can be run as often as you want. @@ -304,7 +345,7 @@ def parse(macro, context, return_errors=False): ---------- macro : string "r(3, k(a).w(10))" - "r(2, k(a).k(-)).k(b)" + "r(2, k(a).k(KEY_A)).k(b)" "w(1000).m(Shift_L, r(2, k(a))).w(10, 20).k(b)" context : Context return_errors : bool @@ -313,13 +354,7 @@ def parse(macro, context, return_errors=False): """ macro = handle_plus_syntax(macro) - # whitespaces, tabs, newlines and such don't serve a purpose. make - # the log output clearer and the parsing easier. - macro = re.sub(r"\s", "", macro) - - if '"' in macro or "'" in macro: - logger.info("Quotation marks in macros are not needed") - macro = macro.replace('"', "").replace("'", "") + macro = _remove_whitespaces(macro, '"') if return_errors: logger.spam("checking the syntax of %s", macro) diff --git a/readme/macros.md b/readme/macros.md index 6191e3c4..b802a0fd 100644 --- a/readme/macros.md +++ b/readme/macros.md @@ -232,8 +232,8 @@ Unlike other programming languages, `qux(bar())` would not run `bar` and then `qux`. Instead, `cux` can decide to run `bar` during runtime depending on various other factors. Like `repeat` is running its parameter multiple times. -Whitespaces, newlines and tabs don't have any meaning and are removed when the macro -gets compiled. +Whitespaces, newlines and tabs don't have any meaning and are removed when the macro +gets compiled, unless you wrap your strings in "quotes". Similar to python, arguments can be either positional or keyword arguments. `key(symbol=KEY_A)` is the same as `key(KEY_A)`. diff --git a/readme/pylint.svg b/readme/pylint.svg index 2733c52e..923dddf5 100644 --- a/readme/pylint.svg +++ b/readme/pylint.svg @@ -17,7 +17,7 @@ pylint - 9.65 - 9.65 + 9.64 + 9.64 \ No newline at end of file diff --git a/tests/testcases/test_macros.py b/tests/testcases/test_macros.py index 7f6a0a0d..9e6a961d 100644 --- a/tests/testcases/test_macros.py +++ b/tests/testcases/test_macros.py @@ -32,6 +32,8 @@ from keymapper.injection.macros.macro import ( _type_check, macro_variables, _type_check_variablename, + _resolve, + Variable, ) from keymapper.injection.macros.parse import ( parse, @@ -41,6 +43,7 @@ from keymapper.injection.macros.parse import ( handle_plus_syntax, _count_brackets, _split_keyword_arg, + _remove_whitespaces, ) from keymapper.injection.context import Context from keymapper.config import config @@ -89,6 +92,25 @@ class TestMacros(MacroTestBase): await parse("k(1, b=2, c=3)", self.context).run(self.handler) self.assertListEqual(result, [(1, 2, 3, 4), (1, 2, 3, 400)]) + def test_remove_whitespaces(self): + self.assertEqual(_remove_whitespaces('foo"bar"foo'), 'foo"bar"foo') + self.assertEqual(_remove_whitespaces('foo" bar"foo'), 'foo" bar"foo') + self.assertEqual(_remove_whitespaces('foo" bar"fo" "o'), 'foo" bar"fo" "o') + self.assertEqual(_remove_whitespaces(' fo o"\nba r "f\noo'), 'foo"\nba r "foo') + self.assertEqual(_remove_whitespaces(' a " b " c " '), 'a" b "c" ') + + self.assertEqual(_remove_whitespaces('"""""""""'), '"""""""""') + self.assertEqual(_remove_whitespaces('""""""""'), '""""""""') + + self.assertEqual(_remove_whitespaces(" "), "") + self.assertEqual(_remove_whitespaces(' " '), '" ') + self.assertEqual(_remove_whitespaces(' " " '), '" "') + + self.assertEqual(_remove_whitespaces("a# ##b", delimiter="##"), "a###b") + self.assertEqual(_remove_whitespaces("a###b", delimiter="##"), "a###b") + self.assertEqual(_remove_whitespaces("a## #b", delimiter="##"), "a## #b") + self.assertEqual(_remove_whitespaces("a## ##b", delimiter="##"), "a## ##b") + async def test_count_brackets(self): self.assertEqual(_count_brackets(""), 0) self.assertEqual(_count_brackets("()"), 2) @@ -99,7 +121,23 @@ class TestMacros(MacroTestBase): self.assertEqual(_count_brackets("a(b(c))d"), 7) self.assertEqual(_count_brackets("a(b(c))d()"), 7) - def test__type_check(self): + def test_resolve(self): + self.assertEqual(_resolve("a"), "a") + self.assertEqual(_resolve(1), 1) + self.assertEqual(_resolve(None), None) + + # $ is part of a custom string here + self.assertEqual(_resolve('"$a"'), '"$a"') + self.assertEqual(_resolve("'$a'"), "'$a'") + + # variables are expected to be of the Variable type here, not a $string + self.assertEqual(_resolve("$a"), "$a") + variable = Variable("a") + self.assertEqual(_resolve(variable), None) + macro_variables["a"] = 1 + self.assertEqual(_resolve(variable), 1) + + def test_type_check(self): # allows params that can be cast to the target type self.assertEqual(_type_check(1, [str, None], "foo", 0), "1") self.assertEqual(_type_check("1", [int, None], "foo", 1), 1) @@ -112,6 +150,11 @@ class TestMacros(MacroTestBase): self.assertRaises(TypeError, lambda: _type_check("a", [int, None], "foo", 3)) self.assertEqual(_type_check("a", [int, float, None, str], "foo", 4), "a") + # variables are expected to be of the Variable type here, not a $string + self.assertRaises(TypeError, lambda: _type_check("$a", [int], "foo", 4)) + variable = Variable("a") + self.assertEqual(_type_check(variable, [int], "foo", 4), variable) + self.assertRaises(TypeError, lambda: _type_check("a", [Macro], "foo", 0)) self.assertRaises(TypeError, lambda: _type_check(1, [Macro], "foo", 0)) self.assertEqual(_type_check("1", [Macro, int], "foo", 4), 1) @@ -213,6 +256,7 @@ class TestMacros(MacroTestBase): self.assertEqual(self.result[7], (EV_KEY, system_mapping.get("a"), 0)) async def test_extract_params(self): + # splits strings, doesn't try to understand their meaning yet def expect(raw, expectation): self.assertListEqual(_extract_args(raw), expectation) @@ -224,6 +268,11 @@ class TestMacros(MacroTestBase): expect("k(a).k(b), k(a)", ["k(a).k(b)", "k(a)"]) expect("k(a), k(a).k(b)", ["k(a)", "k(a).k(b)"]) + expect( + 'a("foo(1,2,3)", ",,,,,, "), , ""', + ['a("foo(1,2,3)", ",,,,,, ")', "", '""'], + ) + expect( ",1, ,b,x(,a(),).y().z(),,", ["", "1", "", "b", "x(,a(),).y().z()", "", ""], @@ -243,9 +292,24 @@ class TestMacros(MacroTestBase): async def test_parse_params(self): self.assertEqual(_parse_recurse("", self.context), None) - self.assertEqual(_parse_recurse("5", self.context), 5) + + # strings. If it is wrapped in quotes, don't parse the contents + self.assertEqual(_parse_recurse('"foo"', self.context), "foo") + self.assertEqual(_parse_recurse('"\tf o o\n"', self.context), "\tf o o\n") + self.assertEqual(_parse_recurse('"foo(a,b)"', self.context), "foo(a,b)") + self.assertEqual(_parse_recurse('",,,()"', self.context), ",,,()") + + # strings without quotes only work as long as there is no function call or + # anything. This is only really acceptable for constants like KEY_A and for + # variable names, which are not allowed to contain special characters that may + # have a meaning in the macro syntax. self.assertEqual(_parse_recurse("foo", self.context), "foo") + self.assertEqual(_parse_recurse("5", self.context), 5) + self.assertEqual(_parse_recurse("5.2", self.context), 5.2) + self.assertIsInstance(_parse_recurse("$foo", self.context), Variable) + self.assertEqual(_parse_recurse("$foo", self.context).name, "foo") + async def test_fails(self): self.assertIsNone(parse("r(1, a)", self.context)) self.assertIsNone(parse("r(a, k(b))", self.context)) @@ -264,8 +328,7 @@ class TestMacros(MacroTestBase): self.assertEqual(len(macro.child_macros), 0) async def test_1(self): - # quotation marks are removed automatically and don't do any harm - macro = parse('k(1).k("a").k(3)', self.context) + macro = parse('k(1).k("KEY_A").k(3)', self.context) self.assertSetEqual( macro.get_capabilities()[EV_KEY], {system_mapping.get("1"), system_mapping.get("a"), system_mapping.get("3")}, @@ -318,9 +381,7 @@ class TestMacros(MacroTestBase): self.assertIsNotNone(error) error = parse("r(k(1), 1)", self.context, True) self.assertIsNotNone(error) - error = parse("r(1.2, key(1))", self.context, True) - self.assertIsNotNone(error) - error = parse("r(repeats=1, macro=k(1))", self.context, True) + error = parse("r(1, macro=k(1))", self.context, True) self.assertIsNone(error) error = parse("r(a=1, b=k(1))", self.context, True) self.assertIsNotNone(error) @@ -824,6 +885,19 @@ class TestMacros(MacroTestBase): # k(KEY_B) is not executed, the macro stops self.assertListEqual(self.result, []) + async def test_set(self): + await parse('set(a, "foo")', self.context).run(self.handler) + self.assertEqual(macro_variables.get("a"), "foo") + + await parse('set( \t"b" \n, "1")', self.context).run(self.handler) + self.assertEqual(macro_variables.get("b"), "1") + + await parse("set(a, 1)", self.context).run(self.handler) + self.assertEqual(macro_variables.get("a"), 1) + + await parse("set(a, )", self.context).run(self.handler) + self.assertEqual(macro_variables.get("a"), None) + class TestIfEq(MacroTestBase): async def test_ifeq_runs(self): @@ -888,6 +962,7 @@ class TestIfEq(MacroTestBase): await test("if_eq(1, 1, k(a), k(b))", a_press) await test("if_eq(1, 2, k(a), k(b))", b_press) + await test("if_eq(value_1=1, value_2=1, then=k(a), else=k(b))", a_press) await test('set(a, "foo").if_eq($a, "foo", k(a), k(b))', a_press) await test('set(a, "foo").if_eq("foo", $a, k(a), k(b))', a_press) await test('set(a, "foo").if_eq("foo", $a, , k(b))', []) @@ -901,6 +976,19 @@ class TestIfEq(MacroTestBase): await test("if_eq($q, $w, k(a), else=k(b))", a_press) # both None await test("set(q, 1).if_eq($q, $w, k(a), else=k(b))", b_press) await test("set(q, 1).set(w, 1).if_eq($q, $w, k(a), else=k(b))", a_press) + await test('set(q, " a b ").if_eq($q, " a b ", k(a), k(b))', a_press) + await test('if_eq("\t", "\n", k(a), k(b))', b_press) + + # treats values in quotes as strings, not as code + await test('set(q, "$a").if_eq($q, "$a", k(a), k(b))', a_press) + await test('set(q, "a,b").if_eq("a,b", $q, k(a), k(b))', a_press) + await test('set(q, "c(1, 2)").if_eq("c(1, 2)", $q, k(a), k(b))', a_press) + await test('set(q, "c(1, 2)").if_eq("c(1, 2)", "$q", k(a), k(b))', b_press) + await test('if_eq("value_1=1", 1, k(a), k(b))', b_press) + + # won't compare strings and int, be similar to python + await test('set(a, "1").if_eq($a, 1, k(a), k(b))', b_press) + await test('set(a, 1).if_eq($a, "1", k(a), k(b))', b_press) async def test_if_eq_runs_multiprocessed(self): """ifeq on variables that have been set in other processes works."""