Repository: Textualize/textual

Issue:
Malformed @click links crash Textual application
# Context

I was just trying out the `@click` syntax.

# Expected behaviour

If I create e.g. a Label with incorrect markup, I should get a MarkupError immediately.

# Current behaviour

Some `@click` syntax do not raise a MarkupError and make Textual applications crash when clicked, namely:
- `[@click]`
- `[@click=]`
- `[@click=()]`

MRE:
```python
#!/usr/bin/env python3
from textual.app import App, ComposeResult
from textual.widgets import Label

class ClickMRE(App):
    def compose(self) -> ComposeResult:
        yield Label("[@click]click me and crash[/]") # ValueError in run_action on click
        yield Label("[@click=]click me and crash[/]") # ValueError in run_action on click
        yield Label("[@click=()]click me and crash[/]") # ValueError in run_action on click
        #yield Label("[@click=(,)]click me and crash[/]") # MarkupError in render at startup
        yield Label("[@click=foobar]click me[/]") # ok, nothing happens
        yield Label("[@click=foobar()]click me[/]") # ok, nothing happens
        yield Label("[@click=toggle_dark]click me[/]") # ok, works fine
        yield Label("[@click=toggle_dark()]click me[/]") # ok, works fine

if __name__ == "__main__":
    app = ClickMRE()
    app.run()
```

<!-- This is valid Markdown, please paste the following directly in to a GitHub issue -->
# Textual Diagnostics

## Versions

| Name    | Value  |
|---------|--------|
| Textual | 0.52.1 |
| Rich    | 13.7.0 |

## Python

| Name           | Value                                              |
|----------------|----------------------------------------------------|
| Version        | 3.11.8                                             |
| Implementation | CPython                                            |
| Compiler       | GCC 13.2.0                                         |
| Executable     | ~/whatever/venv/bin/python3 |

## Operating System

| Name    | Value                                               |
|---------|-----------------------------------------------------|
| System  | Linux                                               |
| Release | 6.6.15-amd64                                        |
| Version | #1 SMP PREEMPT_DYNAMIC Debian 6.6.15-2 (2024-02-04) |

## Terminal

| Name                 | Value          |
|----------------------|----------------|
| Terminal Application | tmux (3.4)     |
| TERM                 | xterm-256color |
| COLORTERM            | truecolor      |
| FORCE_COLOR          | *Not set*      |
| NO_COLOR             | *Not set*      |

## Rich Console options

| Name           | Value                |
|----------------|----------------------|
| size           | width=212, height=34 |
| legacy_windows | False                |
| min_width      | 1                    |
| max_width      | 212                  |
| is_terminal    | False                |
| encoding       | utf-8                |
| max_height     | 34                   |
| justify        | None                 |
| overflow       | None                 |
| no_wrap        | False                |
| highlight      | None                 |
| markup         | None                 |
| height         | None                 |




Discussion:
Those aren't broken markup per se. But obviously it shouldn't crash Textual...
FWIW, my current workaround is:
```python
        text = self.render_str(value)
        # Extra check to work around https://github.com/Textualize/textual/issues/4248:
        for span in text.spans:
            if hasattr(span.style, 'meta') and span.style.meta.get('@click') == ():
                raise MarkupError('problematic @click tag')
```

Resolving patch:
diff --git a/src/textual/app.py b/src/textual/app.py
--- a/src/textual/app.py
+++ b/src/textual/app.py
@@ -3006,11 +3006,18 @@ async def _broker_event(
             return False
         else:
             event.stop()
-        if isinstance(action, (str, tuple)):
+        if isinstance(action, str) or (isinstance(action, tuple) and len(action) == 2):
             await self.run_action(action, default_namespace=default_namespace)  # type: ignore[arg-type]
         elif callable(action):
             await action()
         else:
+            if isinstance(action, tuple) and self.debug:
+                # It's a tuple and made it this far, which means it'll be a
+                # malformed action. This is a no-op, but let's log that
+                # anyway.
+                log.warning(
+                    f"Can't parse @{event_name} action from style meta; check your console markup syntax"
+                )
             return False
         return True
 


Test patch:
diff --git a/tests/test_issue_4248.py b/tests/test_issue_4248.py
new file mode 100644
--- /dev/null
+++ b/tests/test_issue_4248.py
@@ -0,0 +1,46 @@
+"""Test https://github.com/Textualize/textual/issues/4248"""
+
+from textual.app import App, ComposeResult
+from textual.widgets import Label
+
+
+async def test_issue_4248() -> None:
+    """Various forms of click parameters should be fine."""
+
+    bumps = 0
+
+    class ActionApp(App[None]):
+
+        def compose(self) -> ComposeResult:
+            yield Label("[@click]click me and crash[/]", id="nothing")
+            yield Label("[@click=]click me and crash[/]", id="no-params")
+            yield Label("[@click=()]click me and crash[/]", id="empty-params")
+            yield Label("[@click=foobar]click me[/]", id="unknown-sans-parens")
+            yield Label("[@click=foobar()]click me[/]", id="unknown-with-parens")
+            yield Label("[@click=bump]click me[/]", id="known-sans-parens")
+            yield Label("[@click=bump()]click me[/]", id="known-empty-parens")
+            yield Label("[@click=bump(100)]click me[/]", id="known-with-param")
+
+        def action_bump(self, by_value: int = 1) -> None:
+            nonlocal bumps
+            bumps += by_value
+
+    app = ActionApp()
+    async with app.run_test() as pilot:
+        assert bumps == 0
+        await pilot.click("#nothing")
+        assert bumps == 0
+        await pilot.click("#no-params")
+        assert bumps == 0
+        await pilot.click("#empty-params")
+        assert bumps == 0
+        await pilot.click("#unknown-sans-parens")
+        assert bumps == 0
+        await pilot.click("#unknown-with-parens")
+        assert bumps == 0
+        await pilot.click("#known-sans-parens")
+        assert bumps == 1
+        await pilot.click("#known-empty-parens")
+        assert bumps == 2
+        await pilot.click("#known-with-param")
+        assert bumps == 102