diff --git a/CHANGELOG.md b/CHANGELOG.md index 1643a05850..84b2740a7d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,18 +5,30 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/) and this project adheres to [Semantic Versioning](http://semver.org/). + ## Unreleased +- Fixed `Button` not rendering correctly with console markup https://github.com/Textualize/textual/issues/4328 + + +## [0.54.0] - 2023-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 -- Fixed `Button` not rendering correctly with console markup https://github.com/Textualize/textual/issues/4328 +- Exceptions inside `Widget.compose` or workers weren't bubbling up in tests https://github.com/Textualize/textual/issues/4282 ### Changed - ProgressBar won't show ETA until there is at least one second of samples https://github.com/Textualize/textual/pull/4316 +- `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 @@ -1800,6 +1812,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/unmount.md b/docs/events/unmount.md new file mode 100644 index 0000000000..8e17c76924 --- /dev/null +++ b/docs/events/unmount.md @@ -0,0 +1,3 @@ +::: textual.events.Unmount + options: + heading_level: 1 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/mkdocs-nav.yml b/mkdocs-nav.yml index 1ba90e4ee6..7dd0863b43 100644 --- a/mkdocs-nav.yml +++ b/mkdocs-nav.yml @@ -69,6 +69,7 @@ nav: - "events/screen_resume.md" - "events/screen_suspend.md" - "events/show.md" + - "events/unmount.md" - Styles: - "styles/index.md" - "styles/align.md" 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 aaf799a66c..15811232c9 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 79c7c2366a..7e5c158bc5 100644 --- a/src/textual/events.py +++ b/src/textual/events.py @@ -143,7 +143,7 @@ class Mount(Event, bubble=False, verbose=False): class Unmount(Event, bubble=False, verbose=False): - """Sent when a widget is unmounted and may not longer receive messages. + """Sent when a widget is unmounted and may no longer receive messages. - [ ] Bubbles - [ ] Verbose 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/_markdown.py b/src/textual/widgets/_markdown.py index c37c7770e0..849e291bd4 100644 --- a/src/textual/widgets/_markdown.py +++ b/src/textual/widgets/_markdown.py @@ -1,5 +1,6 @@ from __future__ import annotations +import re from pathlib import Path, PurePath from typing import Callable, Iterable, Optional @@ -149,7 +150,12 @@ def build_from_token(self, token: Token) -> None: if token.children: for child in token.children: if child.type == "text": - content.append(child.content, style_stack[-1]) + content.append( + # Ensure repeating spaces and/or tabs get squashed + # down to a single space. + re.sub(r"\s+", " ", child.content), + style_stack[-1], + ) if child.type == "hardbreak": content.append("\n") if child.type == "softbreak": diff --git a/src/textual/widgets/_text_area.py b/src/textual/widgets/_text_area.py index 501eef8f40..4ad2023aad 100644 --- a/src/textual/widgets/_text_area.py +++ b/src/textual/widgets/_text_area.py @@ -688,7 +688,8 @@ def _watch_indent_width(self) -> None: self.scroll_cursor_visible() def _watch_show_vertical_scrollbar(self) -> None: - self._rewrap_and_refresh_virtual_size() + if self.wrap_width: + self._rewrap_and_refresh_virtual_size() self.scroll_cursor_visible() def _watch_theme(self, theme: str) -> None: @@ -2025,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 4754af204b..29c658bf55 100644 --- a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr +++ b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr @@ -20792,137 +20792,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 + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + + + + + + + @@ -23812,6 +23812,174 @@ ''' # --- +# name: test_markdown_space_squashing + ''' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + MarkdownSpaceApp + + + + + + + + + + X XX XX X X X X X + + X XX XX X X X X X + + X XX X X X X X + + X XX X X X X X + + ─────────────────────────────────────────────────────────────────────────────── + + + # Two spaces:  see? + classFoo: + │   '''This is    a doc    string.''' + │   some_code(1,2,3,4) + + + + + + + + + + + + + + ''' +# --- # name: test_markdown_theme_switching ''' diff --git a/tests/snapshot_tests/snapshot_apps/markdown_whitespace.py b/tests/snapshot_tests/snapshot_apps/markdown_whitespace.py new file mode 100644 index 0000000000..71c8fd8bbb --- /dev/null +++ b/tests/snapshot_tests/snapshot_apps/markdown_whitespace.py @@ -0,0 +1,59 @@ +from textual.app import App, ComposeResult +from textual.containers import Horizontal +from textual.widgets import Markdown + +MARKDOWN = ( + """\ +X X + +X X + +X\tX + +X\t\tX +""", + """\ +X \tX + +X \t \tX +""", + """\ +[X X X\tX\t\tX \t \tX](https://example.com/) + +_X X X\tX\t\tX \t \tX_ + +**X X X\tX\t\tX \t \tX** + +~~X X X\tX\t\tX \t \tX~~ +""" +) + +class MarkdownSpaceApp(App[None]): + + CSS = """ + Markdown { + margin-left: 0; + border-left: solid red; + width: 1fr; + height: 1fr; + } + .code { + height: 2fr; + border-top: solid red; + } + """ + + def compose(self) -> ComposeResult: + with Horizontal(): + for document in MARKDOWN: + yield Markdown(document) + yield Markdown("""```python +# Two spaces: see? +class Foo: + '''This is a doc string.''' + some_code(1, 2, 3, 4) +``` +""", classes="code") + +if __name__ == "__main__": + MarkdownSpaceApp().run() diff --git a/tests/snapshot_tests/test_snapshots.py b/tests/snapshot_tests/test_snapshots.py index 12d1ca94d2..f75257fe6f 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 ) @@ -686,6 +686,9 @@ async def run_before(pilot): run_before=run_before, ) +def test_markdown_space_squashing(snap_compare): + assert snap_compare(SNAPSHOT_APPS_DIR / "markdown_whitespace.py") + def test_layer_fix(snap_compare): # Check https://github.com/Textualize/textual/issues/1358 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():