diff --git a/CHANGELOG.md b/CHANGELOG.md index e04f4bdbb7..03cfc63017 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,24 +9,38 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ### Fixed +- Fix priority bindings not appearing in footer when key clashes with focused widget https://github.com/Textualize/textual/pull/4342 + +### Changed + +- App.namespace_bindings renamed to App.active_bindings and now returns a list instead of a dict https://github.com/Textualize/textual/pull/4342 + +## [0.54.0] - 2024-03-26 + +### Fixed + - Fixed a crash in `TextArea` when undoing an edit to a selection the selection was made backwards https://github.com/Textualize/textual/issues/4301 - Fixed issue with flickering scrollbars https://github.com/Textualize/textual/pull/4315 - Fixed issue where narrow TextArea would repeatedly wrap due to scrollbar appearing/disappearing https://github.com/Textualize/textual/pull/4334 - Fix progress bar ETA not updating when setting `total` reactive https://github.com/Textualize/textual/pull/4316 -- Fix priority bindings not appearing in footer when key clashes with focused widget https://github.com/Textualize/textual/pull/4342 +- Exceptions inside `Widget.compose` or workers weren't bubbling up in tests https://github.com/Textualize/textual/issues/4282 -### Changed +### Changed - ProgressBar won't show ETA until there is at least one second of samples https://github.com/Textualize/textual/pull/4316 -- App.namespace_bindings renamed to App.active_bindings and now returns a list instead of a dict https://github.com/Textualize/textual/pull/4342 +- `Input` waits until an edit has been made, after entry to the widget, before offering a suggestion https://github.com/Textualize/textual/pull/4335 + +### Added + +- Added `Document.start` and `end` location properties for convenience https://github.com/Textualize/textual/pull/4267 -## [0.53.1] - 2023-03-18 +## [0.53.1] - 2024-03-18 ### Fixed - Fixed issue with data binding https://github.com/Textualize/textual/pull/4308 -## [0.53.0] - 2023-03-18 +## [0.53.0] - 2024-03-18 ### Added @@ -1802,6 +1816,7 @@ https://textual.textualize.io/blog/2022/11/08/version-040/#version-040 - New handler system for messages that doesn't require inheritance - Improved traceback handling +[0.54.0]: https://github.com/Textualize/textual/compare/v0.53.1...v0.54.0 [0.53.1]: https://github.com/Textualize/textual/compare/v0.53.0...v0.53.1 [0.53.0]: https://github.com/Textualize/textual/compare/v0.52.1...v0.53.0 [0.52.1]: https://github.com/Textualize/textual/compare/v0.52.0...v0.52.1 diff --git a/docs/events/load.md b/docs/events/load.md index 16f5e3d153..f1142f3546 100644 --- a/docs/events/load.md +++ b/docs/events/load.md @@ -1,3 +1,7 @@ ::: textual.events.Load options: heading_level: 1 + +## See also + +- [Mount](mount.md) diff --git a/docs/events/mount.md b/docs/events/mount.md index 885b8f4e9e..955391f056 100644 --- a/docs/events/mount.md +++ b/docs/events/mount.md @@ -1,3 +1,8 @@ ::: textual.events.Mount options: heading_level: 1 + +## See also + +- [Load](load.md) +- [Unmount](unmount.md) diff --git a/docs/events/mouse_capture.md b/docs/events/mouse_capture.md index 945da83085..953a086bf5 100644 --- a/docs/events/mouse_capture.md +++ b/docs/events/mouse_capture.md @@ -8,4 +8,6 @@ title: MouseCapture ## See also +- [capture_mouse][textual.widget.Widget.capture_mouse] +- [release_mouse][textual.widget.Widget.release_mouse] - [MouseRelease](mouse_release.md) diff --git a/docs/events/mouse_release.md b/docs/events/mouse_release.md index 438e03569b..a6e0a91cbe 100644 --- a/docs/events/mouse_release.md +++ b/docs/events/mouse_release.md @@ -8,4 +8,6 @@ title: MouseRelease ## See also +- [capture_mouse][textual.widget.Widget.capture_mouse] +- [release_mouse][textual.widget.Widget.release_mouse] - [MouseCapture](mouse_capture.md) diff --git a/docs/events/unmount.md b/docs/events/unmount.md index 8e17c76924..2df6dccdd5 100644 --- a/docs/events/unmount.md +++ b/docs/events/unmount.md @@ -1,3 +1,7 @@ ::: textual.events.Unmount options: heading_level: 1 + +## See also + +- [Mount](mount.md) diff --git a/docs/widgets/text_area.md b/docs/widgets/text_area.md index c0a95c265a..9e24442d30 100644 --- a/docs/widgets/text_area.md +++ b/docs/widgets/text_area.md @@ -84,6 +84,10 @@ This method is the programmatic equivalent of selecting some text and then pasti Some other convenient methods are available, such as [`insert`][textual.widgets._text_area.TextArea.insert], [`delete`][textual.widgets._text_area.TextArea.delete], and [`clear`][textual.widgets._text_area.TextArea.clear]. +!!! tip + The `TextArea.document.end` property returns the location at the end of the + document, which might be convenient when editing programmatically. + ### Working with the cursor #### Moving the cursor diff --git a/pyproject.toml b/pyproject.toml index 5d8ebf16de..630cc27599 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "textual" -version = "0.53.1" +version = "0.54.0" homepage = "https://github.com/Textualize/textual" repository = "https://github.com/Textualize/textual" documentation = "https://textual.textualize.io/" diff --git a/src/textual/app.py b/src/textual/app.py index 372cf4b278..91d67cc78a 100644 --- 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 diff --git a/src/textual/document/_document.py b/src/textual/document/_document.py index 3a4e5729b2..ca850285b5 100644 --- a/src/textual/document/_document.py +++ b/src/textual/document/_document.py @@ -171,6 +171,17 @@ def prepare_query(self, query: str) -> Query | None: def line_count(self) -> int: """Returns the number of lines in the document.""" + @property + @abstractmethod + def start(self) -> Location: + """Returns the location of the start of the document (0, 0).""" + return (0, 0) + + @property + @abstractmethod + def end(self) -> Location: + """Returns the location of the end of the document.""" + @overload def __getitem__(self, line_index: int) -> str: ... @@ -331,6 +342,17 @@ def line_count(self) -> int: """Returns the number of lines in the document.""" return len(self._lines) + @property + def start(self) -> Location: + """Returns the location of the start of the document (0, 0).""" + return super().start + + @property + def end(self) -> Location: + """Returns the location of the end of the document.""" + last_line = self._lines[-1] + return (self.line_count, len(last_line)) + def get_index_from_location(self, location: Location) -> int: """Given a location, returns the index from the document's text. diff --git a/src/textual/events.py b/src/textual/events.py index 7e5c158bc5..68c3ae8f3d 100644 --- a/src/textual/events.py +++ b/src/textual/events.py @@ -59,7 +59,7 @@ class Load(Event, bubble=False): """ Sent when the App is running but *before* the terminal is in application mode. - Use this event to run any set up that doesn't require any visuals such as loading + Use this event to run any setup that doesn't require any visuals such as loading configuration and binding keys. - [ ] Bubbles @@ -129,6 +129,9 @@ def __rich_repr__(self) -> rich.repr.Result: class Compose(Event, bubble=False, verbose=True): """Sent to a widget to request it to compose and mount children. + This event is used internally by Textual. + You won't typically need to explicitly handle it, + - [ ] Bubbles - [X] Verbose """ @@ -151,7 +154,7 @@ class Unmount(Event, bubble=False, verbose=False): class Show(Event, bubble=False): - """Sent when a widget has become visible. + """Sent when a widget is first displayed. - [ ] Bubbles - [ ] Verbose @@ -164,13 +167,17 @@ class Hide(Event, bubble=False): - [ ] Bubbles - [ ] Verbose - A widget may be hidden by setting its `visible` flag to `False`, if it is no longer in a layout, - or if it has been offset beyond the edges of the terminal. + Sent when any of the following conditions apply: + + - The widget is removed from the DOM. + - The widget is no longer displayed because it has been scrolled or clipped from the terminal or its container. + - The widget has its `display` attribute set to `False`. + - The widget's `display` style is set to `"none"`. """ class Ready(Event, bubble=False): - """Sent to the app when the DOM is ready. + """Sent to the `App` when the DOM is ready and the first frame has been displayed. - [ ] Bubbles - [ ] Verbose @@ -232,7 +239,7 @@ class Key(InputEvent): Args: key: The key that was pressed. - character: A printable character or ``None`` if it is not printable. + character: A printable character or `None` if it is not printable. """ __slots__ = ["key", "character", "aliases"] diff --git a/src/textual/widget.py b/src/textual/widget.py index aac76e8778..4bfaabdee0 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -3618,10 +3618,8 @@ async def _compose(self) -> None: raise TypeError( f"{self!r} compose() method returned an invalid result; {error}" ) from error - except Exception: - from rich.traceback import Traceback - - self.app.panic(Traceback()) + except Exception as error: + self.app._handle_exception(error) else: self._extend_compose(widgets) await self.mount_composed_widgets(widgets) diff --git a/src/textual/widgets/_input.py b/src/textual/widgets/_input.py index 0d3149f2d3..0b3ae57ee1 100644 --- a/src/textual/widgets/_input.py +++ b/src/textual/widgets/_input.py @@ -519,6 +519,7 @@ def _on_focus(self, _: Focus) -> None: if self.cursor_blink: self._blink_timer.resume() self.app.cursor_position = self.cursor_screen_offset + self._suggestion = "" async def _on_key(self, event: events.Key) -> None: self._cursor_visible = True diff --git a/src/textual/widgets/_text_area.py b/src/textual/widgets/_text_area.py index 71e1b3fd7c..4ad2023aad 100644 --- a/src/textual/widgets/_text_area.py +++ b/src/textual/widgets/_text_area.py @@ -2026,10 +2026,7 @@ def clear(self) -> EditResult: Returns: An EditResult relating to the deletion of all content. """ - document = self.document - last_line = document[-1] - document_end = (document.line_count, len(last_line)) - return self.delete((0, 0), document_end, maintain_selection_offset=False) + return self.delete((0, 0), self.document.end, maintain_selection_offset=False) def _delete_via_keyboard( self, diff --git a/src/textual/worker.py b/src/textual/worker.py index 7719cd4dca..f7f10b60ae 100644 --- a/src/textual/worker.py +++ b/src/textual/worker.py @@ -375,7 +375,8 @@ async def _run(self, app: App) -> None: app.log.worker(Traceback()) if self.exit_on_error: - app._fatal_error() + worker_failed = WorkerFailed(self._error) + app._handle_exception(worker_failed) else: self.state = WorkerState.SUCCESS app.log.worker(self) diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr index 83eaf4a490..c375135d8b 100644 --- a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr +++ b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr @@ -20627,137 +20627,137 @@ font-weight: 700; } - .terminal-2073605363-matrix { + .terminal-2577839347-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-2073605363-title { + .terminal-2577839347-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-2073605363-r1 { fill: #1e1e1e } - .terminal-2073605363-r2 { fill: #0178d4 } - .terminal-2073605363-r3 { fill: #c5c8c6 } - .terminal-2073605363-r4 { fill: #e2e2e2 } - .terminal-2073605363-r5 { fill: #1e1e1e;font-style: italic; } - .terminal-2073605363-r6 { fill: #ff0000;font-style: italic; } - .terminal-2073605363-r7 { fill: #121212 } - .terminal-2073605363-r8 { fill: #e1e1e1 } + .terminal-2577839347-r1 { fill: #1e1e1e } + .terminal-2577839347-r2 { fill: #0178d4 } + .terminal-2577839347-r3 { fill: #c5c8c6 } + .terminal-2577839347-r4 { fill: #e2e2e2 } + .terminal-2577839347-r5 { fill: #1e1e1e;font-style: italic; } + .terminal-2577839347-r6 { fill: #ff0000;font-style: italic; } + .terminal-2577839347-r7 { fill: #121212 } + .terminal-2577839347-r8 { fill: #e1e1e1 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - FruitsApp + FruitsApp - - - - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ - strawberry - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ - straw - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ - p - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ - b - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ - a - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - - - - - - - - + + + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + strawberry + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + straw + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + p + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + b + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + a + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + + + + + + + diff --git a/tests/snapshot_tests/test_snapshots.py b/tests/snapshot_tests/test_snapshots.py index 228ad28608..cf6ccb2fa3 100644 --- a/tests/snapshot_tests/test_snapshots.py +++ b/tests/snapshot_tests/test_snapshots.py @@ -105,7 +105,7 @@ async def run_before(pilot): pilot.app.query(Input).first().cursor_blink = False assert snap_compare( - SNAPSHOT_APPS_DIR / "input_suggestions.py", press=[], run_before=run_before + SNAPSHOT_APPS_DIR / "input_suggestions.py", press=["b"], run_before=run_before ) diff --git a/tests/test_issue_4248.py b/tests/test_issue_4248.py new file mode 100644 index 0000000000..de3eee0928 --- /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 diff --git a/tests/test_pilot.py b/tests/test_pilot.py index d436f8b5fc..9451237fab 100644 --- a/tests/test_pilot.py +++ b/tests/test_pilot.py @@ -2,12 +2,14 @@ import pytest -from textual import events +from textual import events, work from textual.app import App, ComposeResult from textual.binding import Binding from textual.containers import Center, Middle from textual.pilot import OutOfBounds +from textual.screen import Screen from textual.widgets import Button, Label +from textual.worker import WorkerFailed KEY_CHARACTERS_TO_TEST = "akTW03" + punctuation """Test some "simple" characters (letters + digits) and all punctuation.""" @@ -56,7 +58,7 @@ def on_key(self, event: events.Key) -> None: assert keys_pressed[-1] == char -async def test_pilot_exception_catching_compose(): +async def test_pilot_exception_catching_app_compose(): """Ensuring that test frameworks are aware of exceptions inside compose methods when running via Pilot run_test().""" @@ -70,6 +72,21 @@ def compose(self) -> ComposeResult: pass +async def test_pilot_exception_catching_widget_compose(): + class SomeScreen(Screen[None]): + def compose(self) -> ComposeResult: + 1 / 0 + yield Label("Beep") + + class FailingApp(App[None]): + def on_mount(self) -> None: + self.push_screen(SomeScreen()) + + with pytest.raises(ZeroDivisionError): + async with FailingApp().run_test(): + pass + + async def test_pilot_exception_catching_action(): """Ensure that exceptions inside action handlers are presented to the test framework when running via Pilot run_test().""" @@ -85,6 +102,21 @@ def action_beep(self) -> None: await pilot.press("b") +async def test_pilot_exception_catching_worker(): + class SimpleAppThatCrashes(App[None]): + def on_mount(self) -> None: + self.crash() + + @work(name="crash") + async def crash(self) -> None: + 1 / 0 + + with pytest.raises(WorkerFailed) as exc: + async with SimpleAppThatCrashes().run_test(): + pass + assert exc.type is ZeroDivisionError + + async def test_pilot_click_screen(): """Regression test for https://github.com/Textualize/textual/issues/3395. diff --git a/tests/workers/test_worker.py b/tests/workers/test_worker.py index 7b553d122f..61af91f638 100644 --- a/tests/workers/test_worker.py +++ b/tests/workers/test_worker.py @@ -113,10 +113,10 @@ class ErrorApp(App): pass app = ErrorApp() - async with app.run_test(): - worker: Worker[str] = Worker(app, run_error) - worker._start(app) - with pytest.raises(WorkerFailed): + with pytest.raises(WorkerFailed): + async with app.run_test(): + worker: Worker[str] = Worker(app, run_error) + worker._start(app) await worker.wait() @@ -218,12 +218,12 @@ async def self_referential_work(): await get_current_worker().wait() app = App() - async with app.run_test(): - worker = Worker(app, self_referential_work) - worker._start(app) - with pytest.raises(WorkerFailed) as exc: + with pytest.raises(WorkerFailed) as exc: + async with app.run_test(): + worker = Worker(app, self_referential_work) + worker._start(app) await worker.wait() - assert exc.type is DeadlockError + assert exc.type is DeadlockError async def test_wait_without_start():