Support for strings in macros

xkb
sezanzeb 3 years ago
parent 79f11b4e34
commit b233e93eae

@ -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:

@ -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)

@ -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)`.

@ -17,7 +17,7 @@
<text x="22.0" y="14">pylint</text>
</g>
<g fill="#fff" text-anchor="middle" font-family="DejaVu Sans,Verdana,Geneva,sans-serif" font-size="11">
<text x="63.0" y="15" fill="#010101" fill-opacity=".3">9.65</text>
<text x="62.0" y="14">9.65</text>
<text x="63.0" y="15" fill="#010101" fill-opacity=".3">9.64</text>
<text x="62.0" y="14">9.64</text>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 1.0 KiB

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

Loading…
Cancel
Save