Repository: Textualize/textual

Issue:
`)` cannot appear as part of the parameter passed to an action
Reproduction:

```python
from textual.app import App


class ActionBugApp(App):
    BINDINGS = [("a", "test(')')", "Test")]

    def action_test(self, _: str) -> None:
        pass


if __name__ == '__main__':
    app = ActionBugApp()
    app.run()
```

Omitting the full stack trace (since it's fairly easy to reproduce), the key error message is:

```
ActionError: unable to parse "(')" in action "test(')')"
```

Seems that [this regex](https://github.com/Textualize/textual/blob/2a6368754a8b3a11f1772b52298b5d3b50ceebaa/src/textual/actions.py#L20) is not general enough.


Discussion:
My real-world use case is a [NetHack price identification](https://nethackwiki.com/wiki/Price_identification) tool.

In [NetHack](https://nethackwiki.com/wiki/Main_Page), different categories of items are represented using different characters, and `)` represents weapons.

I built the following app for manual and auto price ID, and I bound those item-category characters to an action to select the corresponding `ListItem` in the "Item category" `ListView` (with a `layout: horizontal`).

Hitting `)` to identify a weapon triggers this issue.

![nethack_price_id_2023-03-19T00_10_05_075154](https://user-images.githubusercontent.com/230655/226159835-e06026ea-7a4e-490a-bea2-f261a7f8ce68.svg)

Yup, at first glance I feel `r"([\w\.]+)(\(.*\))"` might be a better regexp here (greedy match of `)` rather than non-greedy), although I'd need to check the unit tests and also with Will to see if it was non-greedy for a reason.
I think @davep is correct. Will fix that in the next release. @liancheng your app looks great.

Resolving patch:
diff --git a/src/textual/actions.py b/src/textual/actions.py
--- a/src/textual/actions.py
+++ b/src/textual/actions.py
@@ -6,7 +6,7 @@
 from typing_extensions import Any, TypeAlias
 
 ActionParseResult: TypeAlias = "tuple[str, tuple[Any, ...]]"
-"""An action is its name and the arbitrary tuple of its parameters."""
+"""An action is its name and the arbitrary tuple of its arguments."""
 
 
 class SkipAction(Exception):
@@ -17,7 +17,7 @@ class ActionError(Exception):
     pass
 
 
-re_action_params = re.compile(r"([\w\.]+)(\(.*?\))")
+re_action_args = re.compile(r"([\w\.]+)\((.*)\)")
 
 
 def parse(action: str) -> ActionParseResult:
@@ -30,22 +30,25 @@ def parse(action: str) -> ActionParseResult:
         ActionError: If the action has invalid syntax.
 
     Returns:
-        Action name and parameters
+        Action name and arguments.
     """
-    params_match = re_action_params.match(action)
-    if params_match is not None:
-        action_name, action_params_str = params_match.groups()
-        try:
-            action_params = ast.literal_eval(action_params_str)
-        except Exception:
-            raise ActionError(
-                f"unable to parse {action_params_str!r} in action {action!r}"
-            )
+    args_match = re_action_args.match(action)
+    if args_match is not None:
+        action_name, action_args_str = args_match.groups()
+        if action_args_str:
+            try:
+                # We wrap `action_args_str` to be able to disambiguate the cases where
+                # the list of arguments is a comma-separated list of values from the
+                # case where the argument is a single tuple.
+                action_args: tuple[Any, ...] = ast.literal_eval(f"({action_args_str},)")
+            except Exception:
+                raise ActionError(
+                    f"unable to parse {action_args_str!r} in action {action!r}"
+                )
+        else:
+            action_args = ()
     else:
         action_name = action
-        action_params = ()
+        action_args = ()
 
-    return (
-        action_name,
-        action_params if isinstance(action_params, tuple) else (action_params,),
-    )
+    return action_name, action_args


Test patch:
diff --git a/tests/test_actions.py b/tests/test_actions.py
new file mode 100644
--- /dev/null
+++ b/tests/test_actions.py
@@ -0,0 +1,86 @@
+from __future__ import annotations
+
+from typing import Any
+
+import pytest
+
+from textual.actions import ActionError, parse
+
+
+@pytest.mark.parametrize(
+    ("action_string", "expected_name", "expected_arguments"),
+    [
+        ("spam", "spam", ()),
+        ("hypothetical_action()", "hypothetical_action", ()),
+        ("another_action(1)", "another_action", (1,)),
+        ("foo(True, False)", "foo", (True, False)),
+        ("foo.bar.baz(3, 3.15, 'Python')", "foo.bar.baz", (3, 3.15, "Python")),
+        ("m1234.n5678(None, [1, 2])", "m1234.n5678", (None, [1, 2])),
+    ],
+)
+def test_parse_action(
+    action_string: str, expected_name: str, expected_arguments: tuple[Any]
+) -> None:
+    action_name, action_arguments = parse(action_string)
+    assert action_name == expected_name
+    assert action_arguments == expected_arguments
+
+
+@pytest.mark.parametrize(
+    ("action_string", "expected_arguments"),
+    [
+        ("f()", ()),
+        ("f(())", ((),)),
+        ("f((1, 2, 3))", ((1, 2, 3),)),
+        ("f((1, 2, 3), (1, 2, 3))", ((1, 2, 3), (1, 2, 3))),
+        ("f(((1, 2), (), None), 0)", (((1, 2), (), None), 0)),
+        ("f((((((1))))))", (1,)),
+        ("f(((((((((1, 2)))))))))", ((1, 2),)),
+        ("f((1, 2), (3, 4))", ((1, 2), (3, 4))),
+        ("f((((((1, 2), (3, 4))))))", (((1, 2), (3, 4)),)),
+    ],
+)
+def test_nested_and_convoluted_tuple_arguments(
+    action_string: str, expected_arguments: tuple[Any]
+) -> None:
+    """Test that tuple arguments are parsed correctly."""
+    _, args = parse(action_string)
+    assert args == expected_arguments
+
+
+@pytest.mark.parametrize(
+    ["action_string", "expected_arguments"],
+    [
+        ("f('')", ("",)),
+        ('f("")', ("",)),
+        ("f('''''')", ("",)),
+        ('f("""""")', ("",)),
+        ("f('(')", ("(",)),
+        ("f(')')", (")",)),  # Regression test for #2088
+        ("f('f()')", ("f()",)),
+    ],
+)
+def test_parse_action_nested_special_character_arguments(
+    action_string: str, expected_arguments: tuple[Any]
+) -> None:
+    """Test that special characters nested in strings are handled correctly.
+
+    See also: https://github.com/Textualize/textual/issues/2088
+    """
+    _, args = parse(action_string)
+    assert args == expected_arguments
+
+
+@pytest.mark.parametrize(
+    "action_string",
+    [
+        "foo(,,,,,)",
+        "bar(1 2 3 4 5)",
+        "baz.spam(Tru, Fals, in)",
+        "ham(not)",
+        "cheese((((()",
+    ],
+)
+def test_parse_action_raises_error(action_string: str) -> None:
+    with pytest.raises(ActionError):
+        parse(action_string)