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."""