From 4bc48d37a1f6f070d35efc649f3c661ba6f7f0e2 Mon Sep 17 00:00:00 2001 From: Olivier Philippon Date: Fri, 29 Apr 2022 14:48:40 +0100 Subject: [PATCH 1/7] [CI] Check that the "basic.py" sandbox script can be run for a few seconds without crashing --- .github/workflows/pythonpackage.yml | 4 ++++ sandbox/basic.py | 5 ++++- tools/sandbox_e2e_smoke_test.py | 27 +++++++++++++++++++++++++++ 3 files changed, 35 insertions(+), 1 deletion(-) create mode 100644 tools/sandbox_e2e_smoke_test.py diff --git a/.github/workflows/pythonpackage.yml b/.github/workflows/pythonpackage.yml index 3096e1a20..302575e8a 100644 --- a/.github/workflows/pythonpackage.yml +++ b/.github/workflows/pythonpackage.yml @@ -39,6 +39,10 @@ jobs: run: | source $VENV pytest tests -v --cov=./src/textual --cov-report=xml:./coverage.xml --cov-report term-missing + - name: Quick e2e smoke test + run: | + source $VENV + python tools/sandbox_e2e_smoke_test.py - name: Upload code coverage uses: codecov/codecov-action@v1.0.10 with: diff --git a/sandbox/basic.py b/sandbox/basic.py index 0778a9eed..c7ba72ba8 100644 --- a/sandbox/basic.py +++ b/sandbox/basic.py @@ -1,3 +1,5 @@ +from pathlib import Path + from rich.align import Align from rich.console import RenderableType from rich.syntax import Syntax @@ -139,7 +141,8 @@ class BasicApp(App): self.panic(self.tree) -app = BasicApp(css_file="basic.css", watch_css=True, log="textual.log") +css_file = Path(__file__).parent / "basic.css" +app = BasicApp(css_file=str(css_file), watch_css=True, log="textual.log") if __name__ == "__main__": app.run() diff --git a/tools/sandbox_e2e_smoke_test.py b/tools/sandbox_e2e_smoke_test.py new file mode 100644 index 000000000..adc95f3d0 --- /dev/null +++ b/tools/sandbox_e2e_smoke_test.py @@ -0,0 +1,27 @@ +from __future__ import annotations +import sys +import multiprocessing +from pathlib import Path + +project_root = Path(__file__).parent.parent +for python_path in [str(project_root), str(project_root / "src")]: + sys.path.append(python_path) + + +def launch_sandbox_script(): + from sandbox.basic import app as basic_app + + basic_app.run() + + +process = multiprocessing.Process(target=launch_sandbox_script, daemon=True) +process.start() + +process.join(timeout=2) + +process_still_running = process.exitcode is None +process_was_able_to_run_without_errors = process_still_running +if process_still_running: + process.kill() + +sys.exit(0 if process_was_able_to_run_without_errors else 1) From c78158296ad80043396f0ef087937f9c9f82d121 Mon Sep 17 00:00:00 2001 From: Olivier Philippon Date: Tue, 3 May 2022 12:24:14 +0100 Subject: [PATCH 2/7] [Python compatibility] Make the code work with Python 3.7+ --- src/textual/_compositor.py | 2 +- src/textual/_timer.py | 10 ++++++++-- src/textual/app.py | 41 ++++++++++++++++++++++++-------------- 3 files changed, 35 insertions(+), 18 deletions(-) diff --git a/src/textual/_compositor.py b/src/textual/_compositor.py index 5603eca11..5e4652a59 100644 --- a/src/textual/_compositor.py +++ b/src/textual/_compositor.py @@ -448,7 +448,7 @@ class Compositor: segment_lines: list[list[Segment]] = [ sum( [line for line in bucket.values() if line is not None], - start=[], + [], ) for bucket in chops ] diff --git a/src/textual/_timer.py b/src/textual/_timer.py index e6ba5165c..c07d815fc 100644 --- a/src/textual/_timer.py +++ b/src/textual/_timer.py @@ -1,5 +1,6 @@ from __future__ import annotations +import asyncio import weakref from asyncio import ( get_event_loop, @@ -84,7 +85,7 @@ class Timer: Returns: Task: A Task instance for the timer. """ - self._task = get_event_loop().create_task(self._run()) + self._task = asyncio.create_task(self._run()) return self._task async def stop(self) -> None: @@ -114,7 +115,12 @@ class Timer: continue wait_time = max(0, next_timer - monotonic()) if wait_time: - await sleep(wait_time) + try: + await sleep(wait_time) + except asyncio.CancelledError: + # Likely our program terminating: this is fine, we just have to + # shut down out asyncio Task properly: + await self.stop() event = events.Timer( self.sender, timer=self, diff --git a/src/textual/app.py b/src/textual/app.py index 0d900822b..c35cf9120 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -54,6 +54,9 @@ WINDOWS = PLATFORM == "Windows" # asyncio will warn against resources not being cleared warnings.simplefilter("always", ResourceWarning) +# `asyncio.get_event_loop()` is deprecated since Python 3.10: +_ASYNCIO_GET_EVENT_LOOP_IS_DEPRECATED = sys.version_info >= (3, 10, 0) + LayoutDefinition = "dict[str, Any]" @@ -109,6 +112,8 @@ class App(Generic[ReturnType], DOMNode): css (str | None, optional): CSS code to parse, or ``None`` for no literal CSS. Defaults to None. watch_css (bool, optional): Watch CSS for changes. Defaults to True. """ + App._init_uvloop() + self.console = Console( file=sys.__stdout__, markup=False, highlight=False, emoji=False ) @@ -319,28 +324,33 @@ class App(Generic[ReturnType], DOMNode): keys, action, description, show=show, key_display=key_display ) - def run(self, loop: AbstractEventLoop | None = None) -> ReturnType | None: + def run(self) -> ReturnType | None: async def run_app() -> None: await self.process_messages() - if loop: - asyncio.set_event_loop(loop) - else: - try: - import uvloop - except ImportError: - pass - else: - asyncio.set_event_loop(uvloop.new_event_loop()) - - event_loop = asyncio.get_event_loop() - try: + if _ASYNCIO_GET_EVENT_LOOP_IS_DEPRECATED: + # N.B. This doesn't work with Python<3.10, as we end up with 2 event loops: asyncio.run(run_app()) - finally: - event_loop.close() + else: + # However, this works with Python<3.10: + event_loop = asyncio.get_event_loop() + event_loop.run_until_complete(run_app()) return self._return_value + @classmethod + def _init_uvloop(cls) -> None: + if hasattr(cls, "__uvloop_installed"): + return + cls.__uvloop_installed = False + try: + import uvloop + except ImportError: + pass + else: + uvloop.install() + cls.__uvloop_installed = True + async def _on_css_change(self) -> None: """Called when the CSS changes (if watch_css is True).""" if self.css_file is not None: @@ -508,6 +518,7 @@ class App(Generic[ReturnType], DOMNode): active_app.set(self) log("---") log(f"driver={self.driver_class}") + log(f"uvloop installed: {self.__class__.__uvloop_installed!r}") if self.devtools_enabled: try: From 85db9263a88dfec32b8d05345ce3fde83d39298b Mon Sep 17 00:00:00 2001 From: Olivier Philippon Date: Tue, 3 May 2022 12:32:42 +0100 Subject: [PATCH 3/7] [e2e] Move the smoke tests to a new root folder "e2e_tests/" --- .github/workflows/pythonpackage.yml | 2 +- .../sandbox_basic_test.py | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) rename tools/sandbox_e2e_smoke_test.py => e2e_tests/sandbox_basic_test.py (71%) diff --git a/.github/workflows/pythonpackage.yml b/.github/workflows/pythonpackage.yml index 302575e8a..94ef31217 100644 --- a/.github/workflows/pythonpackage.yml +++ b/.github/workflows/pythonpackage.yml @@ -42,7 +42,7 @@ jobs: - name: Quick e2e smoke test run: | source $VENV - python tools/sandbox_e2e_smoke_test.py + python e2e_tests/sandbox_basic_test.py - name: Upload code coverage uses: codecov/codecov-action@v1.0.10 with: diff --git a/tools/sandbox_e2e_smoke_test.py b/e2e_tests/sandbox_basic_test.py similarity index 71% rename from tools/sandbox_e2e_smoke_test.py rename to e2e_tests/sandbox_basic_test.py index adc95f3d0..bde7bdfd7 100644 --- a/tools/sandbox_e2e_smoke_test.py +++ b/e2e_tests/sandbox_basic_test.py @@ -14,6 +14,11 @@ def launch_sandbox_script(): basic_app.run() +# The following line seems to be required in order to have this work in the MacOS part +# of our CI - despite the fact that Python docs say that this function +# "has no effect when invoked on any operating system other than Windows" 🤔 +multiprocessing.freeze_support() + process = multiprocessing.Process(target=launch_sandbox_script, daemon=True) process.start() From 078b7c151b39381586c6f430a4a30fe83be8709a Mon Sep 17 00:00:00 2001 From: Olivier Philippon Date: Tue, 3 May 2022 13:51:33 +0100 Subject: [PATCH 4/7] [e2e] The smoke tests no longer rely on the `multiprocessing` package MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Running the scripts with this package leads to the following cryptic error in our CI, when run in macOS: ``` RuntimeError: An attempt has been made to start a new process before the current process has finished its bootstrapping phase. This probably means that you are not using fork to start your child processes and you have forgotten to use the proper idiom in the main module: if __name__ == '__main__': freeze_support() ... The "freeze_support()" line can be omitted if the program is not going to be frozen to produce an executable. ``` (and Python's docs clearly state that this `freeze_support` function has no effect when invoked on any operating system other than Windows ¯\_(ツ)_/¯ ) --- .github/workflows/pythonpackage.yml | 2 +- e2e_tests/sandbox_basic_test.py | 59 +++++--- e2e_tests/test_apps/basic.css | 227 ++++++++++++++++++++++++++++ e2e_tests/test_apps/basic.py | 150 ++++++++++++++++++ 4 files changed, 419 insertions(+), 19 deletions(-) create mode 100644 e2e_tests/test_apps/basic.css create mode 100644 e2e_tests/test_apps/basic.py diff --git a/.github/workflows/pythonpackage.yml b/.github/workflows/pythonpackage.yml index 94ef31217..d9bda9509 100644 --- a/.github/workflows/pythonpackage.yml +++ b/.github/workflows/pythonpackage.yml @@ -42,7 +42,7 @@ jobs: - name: Quick e2e smoke test run: | source $VENV - python e2e_tests/sandbox_basic_test.py + python e2e_tests/sandbox_basic_test.py basic 2.0 - name: Upload code coverage uses: codecov/codecov-action@v1.0.10 with: diff --git a/e2e_tests/sandbox_basic_test.py b/e2e_tests/sandbox_basic_test.py index bde7bdfd7..3cf1ced9d 100644 --- a/e2e_tests/sandbox_basic_test.py +++ b/e2e_tests/sandbox_basic_test.py @@ -1,32 +1,55 @@ from __future__ import annotations + +import shlex import sys -import multiprocessing +import subprocess +import threading from pathlib import Path -project_root = Path(__file__).parent.parent -for python_path in [str(project_root), str(project_root / "src")]: - sys.path.append(python_path) +target_script_name = "basic" +script_time_to_live = 2.0 # in seconds + +if len(sys.argv) > 1: + target_script_name = sys.argv[1] +if len(sys.argv) > 2: + script_time_to_live = float(sys.argv[2]) + +e2e_root = Path(__file__).parent + +completed_process = None -def launch_sandbox_script(): - from sandbox.basic import app as basic_app +def launch_sandbox_script(python_file_name: str) -> None: + global completed_process - basic_app.run() + command = f"{sys.executable} ./test_apps/{shlex.quote(python_file_name)}.py" + print(f"Launching command '{command}'...") + try: + completed_process = subprocess.run( + command, shell=True, check=True, capture_output=True, cwd=str(e2e_root) + ) + except subprocess.CalledProcessError as err: + print(f"Process error: {err.stderr}") + raise -# The following line seems to be required in order to have this work in the MacOS part -# of our CI - despite the fact that Python docs say that this function -# "has no effect when invoked on any operating system other than Windows" 🤔 -multiprocessing.freeze_support() +thread = threading.Thread( + target=launch_sandbox_script, args=(target_script_name,), daemon=True +) +thread.start() -process = multiprocessing.Process(target=launch_sandbox_script, daemon=True) -process.start() +print( + f"Launching Python script in a sub-thread; we'll wait for it for {script_time_to_live} seconds..." +) +thread.join(timeout=script_time_to_live) +print("The wait is over.") -process.join(timeout=2) - -process_still_running = process.exitcode is None +process_still_running = completed_process is None process_was_able_to_run_without_errors = process_still_running -if process_still_running: - process.kill() + +if process_was_able_to_run_without_errors: + print("Python script is still running :-)") +else: + print("Python script is no longer running :-/") sys.exit(0 if process_was_able_to_run_without_errors else 1) diff --git a/e2e_tests/test_apps/basic.css b/e2e_tests/test_apps/basic.css new file mode 100644 index 000000000..f9bc0b96d --- /dev/null +++ b/e2e_tests/test_apps/basic.css @@ -0,0 +1,227 @@ +/* CSS file for basic.py */ + + + +* { + transition: color 300ms linear, background 300ms linear; +} + + +* { + scrollbar-background: $panel-darken-2; + scrollbar-background-hover: $panel-darken-3; + scrollbar-color: $system; + scrollbar-color-active: $accent-darken-1; +} + +App > Screen { + layout: dock; + docks: side=left/1; + background: $surface; + color: $text-surface; +} + + +#sidebar { + color: $text-primary; + background: $primary; + dock: side; + width: 30; + offset-x: -100%; + layout: dock; + transition: offset 500ms in_out_cubic; +} + +#sidebar.-active { + offset-x: 0; +} + +#sidebar .title { + height: 3; + background: $primary-darken-2; + color: $text-primary-darken-2 ; + border-right: outer $primary-darken-3; + content-align: center middle; +} + +#sidebar .user { + height: 8; + background: $primary-darken-1; + color: $text-primary-darken-1; + border-right: outer $primary-darken-3; + content-align: center middle; +} + +#sidebar .content { + background: $primary; + color: $text-primary; + border-right: outer $primary-darken-3; + content-align: center middle; +} + +#header { + color: $text-primary-darken-1; + background: $primary-darken-1; + height: 3; + content-align: center middle; +} + +#content { + color: $text-background; + background: $background; + layout: vertical; + overflow-y: scroll; +} + + +Tweet { + height: 12; + width: 80; + + margin: 1 3; + background: $panel; + color: $text-panel; + layout: vertical; + /* border: outer $primary; */ + padding: 1; + border: wide $panel-darken-2; + overflow-y: scroll; + align-horizontal: center; + +} + +.scrollable { + width: 80; + overflow-y: scroll; + max-width:80; + height: 20; + align-horizontal: center; + layout: vertical; +} + +.code { + + height: 34; + width: 100%; + +} + + +TweetHeader { + height:1; + background: $accent; + color: $text-accent +} + +TweetBody { + width: 100%; + background: $panel; + color: $text-panel; + height:20; + padding: 0 1 0 0; + +} + +.button { + background: $accent; + color: $text-accent; + width:20; + height: 3; + /* border-top: hidden $accent-darken-3; */ + border: tall $accent-darken-2; + /* border-left: tall $accent-darken-1; */ + + + /* padding: 1 0 0 0 ; */ + + transition: background 200ms in_out_cubic, color 300ms in_out_cubic; + +} + +.button:hover { + background: $accent-lighten-1; + color: $text-accent-lighten-1; + width: 20; + height: 3; + border: tall $accent-darken-1; + /* border-left: tall $accent-darken-3; */ + + + + +} + +#footer { + color: $text-accent; + background: $accent; + height: 1; + border-top: hkey $accent-darken-2; + content-align: center middle; +} + + +#sidebar .content { + layout: vertical +} + +OptionItem { + height: 3; + background: $primary; + transition: background 100ms linear; + border-right: outer $primary-darken-2; + border-left: hidden; + content-align: center middle; +} + +OptionItem:hover { + height: 3; + color: $accent; + background: $primary-darken-1; + /* border-top: hkey $accent2-darken-3; + border-bottom: hkey $accent2-darken-3; */ + text-style: bold; + border-left: outer $accent-darken-2; +} + +Error { + width: 80; + height:3; + background: $error; + color: $text-error; + border-top: hkey $error-darken-2; + border-bottom: hkey $error-darken-2; + margin: 1 3; + + text-style: bold; + align-horizontal: center; +} + +Warning { + width: 80; + height:3; + background: $warning; + color: $text-warning-fade-1; + border-top: hkey $warning-darken-2; + border-bottom: hkey $warning-darken-2; + margin: 1 2; + text-style: bold; + align-horizontal: center; +} + +Success { + width: 80; + height:3; + box-sizing: border-box; + background: $success-lighten-3; + color: $text-success-lighten-3-fade-1; + border-top: hkey $success; + border-bottom: hkey $success; + margin: 1 2; + text-style: bold; + align-horizontal: center; +} + + +.horizontal { + layout: horizontal +} diff --git a/e2e_tests/test_apps/basic.py b/e2e_tests/test_apps/basic.py new file mode 100644 index 000000000..a4816fe1c --- /dev/null +++ b/e2e_tests/test_apps/basic.py @@ -0,0 +1,150 @@ +from pathlib import Path + +from rich.align import Align +from rich.console import RenderableType +from rich.syntax import Syntax +from rich.text import Text + +from textual.app import App +from textual.widget import Widget +from textual.widgets import Static + +CODE = ''' +class Offset(NamedTuple): + """A point defined by x and y coordinates.""" + + x: int = 0 + y: int = 0 + + @property + def is_origin(self) -> bool: + """Check if the point is at the origin (0, 0)""" + return self == (0, 0) + + def __bool__(self) -> bool: + return self != (0, 0) + + def __add__(self, other: object) -> Offset: + if isinstance(other, tuple): + _x, _y = self + x, y = other + return Offset(_x + x, _y + y) + return NotImplemented + + def __sub__(self, other: object) -> Offset: + if isinstance(other, tuple): + _x, _y = self + x, y = other + return Offset(_x - x, _y - y) + return NotImplemented + + def __mul__(self, other: object) -> Offset: + if isinstance(other, (float, int)): + x, y = self + return Offset(int(x * other), int(y * other)) + return NotImplemented +''' + + +lorem = Text.from_markup( + """Lorem ipsum dolor sit amet, consectetur adipiscing elit. In velit libero, volutpat nec hendrerit at, faucibus in odio. Aliquam hendrerit nibh sed quam volutpat maximus. Nullam suscipit convallis lorem quis sodales. In tristique lobortis ante et dictum. Ut at finibus ipsum. In urna dolor, placerat et mi facilisis, congue sollicitudin massa. Phasellus felis turpis, cursus eu lectus et, porttitor malesuada augue. Sed feugiat volutpat velit, sollicitudin fringilla velit bibendum faucibus. Lorem ipsum dolor sit amet, consectetur adipiscing elit. In velit libero, volutpat nec hendrerit at, faucibus in odio. Aliquam hendrerit nibh sed quam volutpat maximus. Nullam suscipit convallis lorem quis sodales. In tristique lobortis ante et dictum. Ut at finibus ipsum. In urna dolor, placerat et mi facilisis, congue sollicitudin massa. Phasellus felis turpis, cursus eu lectus et, porttitor malesuada augue. Sed feugiat volutpat velit, sollicitudin fringilla velit bibendum faucibus. """ + """Lorem ipsum dolor sit amet, consectetur adipiscing elit. In velit libero, volutpat nec hendrerit at, faucibus in odio. Aliquam hendrerit nibh sed quam volutpat maximus. Nullam suscipit convallis lorem quis sodales. In tristique lobortis ante et dictum. Ut at finibus ipsum. In urna dolor, placerat et mi facilisis, congue sollicitudin massa. Phasellus felis turpis, cursus eu lectus et, porttitor malesuada augue. Sed feugiat volutpat velit, sollicitudin fringilla velit bibendum faucibus. Lorem ipsum dolor sit amet, consectetur adipiscing elit. In velit libero, volutpat nec hendrerit at, faucibus in odio. Aliquam hendrerit nibh sed quam volutpat maximus. Nullam suscipit convallis lorem quis sodales. In tristique lobortis ante et dictum. Ut at finibus ipsum. In urna dolor, placerat et mi facilisis, congue sollicitudin massa. Phasellus felis turpis, cursus eu lectus et, porttitor malesuada augue. Sed feugiat volutpat velit, sollicitudin fringilla velit bibendum faucibus. """ +) + + +class TweetHeader(Widget): + def render(self) -> RenderableType: + return Text("Lorem Impsum", justify="center") + + +class TweetBody(Widget): + def render(self) -> Text: + return lorem + + +class Tweet(Widget): + pass + + +class OptionItem(Widget): + def render(self) -> Text: + return Text("Option") + + +class Error(Widget): + def render(self) -> Text: + return Text("This is an error message", justify="center") + + +class Warning(Widget): + def render(self) -> Text: + return Text("This is a warning message", justify="center") + + +class Success(Widget): + def render(self) -> Text: + return Text("This is a success message", justify="center") + + +class BasicApp(App): + """A basic app demonstrating CSS""" + + def on_load(self): + """Bind keys here.""" + self.bind("tab", "toggle_class('#sidebar', '-active')") + + def on_mount(self): + """Build layout here.""" + self.mount( + header=Static( + Text.from_markup( + "[b]This is a [u]Textual[/u] app, running in the terminal" + ), + ), + content=Widget( + Tweet( + TweetBody(), + # Widget( + # Widget(classes={"button"}), + # Widget(classes={"button"}), + # classes={"horizontal"}, + # ), + ), + Widget( + Static(Syntax(CODE, "python"), classes="code"), + classes="scrollable", + ), + Error(), + Tweet(TweetBody()), + Warning(), + Tweet(TweetBody()), + Success(), + ), + footer=Widget(), + sidebar=Widget( + Widget(classes="title"), + Widget(classes="user"), + OptionItem(), + OptionItem(), + OptionItem(), + Widget(classes="content"), + ), + ) + + async def on_key(self, event) -> None: + await self.dispatch_key(event) + + def key_d(self): + self.dark = not self.dark + + def key_x(self): + self.panic(self.tree) + + +css_file = Path(__file__).parent / "basic.css" +app = BasicApp( + css_file=str(css_file), watch_css=True, log="textual.log", log_verbosity=0 +) + +if __name__ == "__main__": + app.run() From 3f7ab9ce5bf39fe81f41e602eb448e091e8f55a0 Mon Sep 17 00:00:00 2001 From: Olivier Philippon Date: Tue, 3 May 2022 14:39:22 +0100 Subject: [PATCH 5/7] [asyncio] Replace calls to asyncio's `get_event_loop()` with `get_running_loop()` where possible --- src/textual/_timer.py | 1 - src/textual/driver.py | 2 +- src/textual/drivers/linux_driver.py | 4 ++-- src/textual/drivers/windows_driver.py | 2 +- 4 files changed, 4 insertions(+), 5 deletions(-) diff --git a/src/textual/_timer.py b/src/textual/_timer.py index c07d815fc..aba34aa69 100644 --- a/src/textual/_timer.py +++ b/src/textual/_timer.py @@ -3,7 +3,6 @@ from __future__ import annotations import asyncio import weakref from asyncio import ( - get_event_loop, CancelledError, Event, sleep, diff --git a/src/textual/driver.py b/src/textual/driver.py index 11108e037..f9c44af57 100644 --- a/src/textual/driver.py +++ b/src/textual/driver.py @@ -21,7 +21,7 @@ class Driver(ABC): self.console = console self._target = target self._debug = debug - self._loop = asyncio.get_event_loop() + self._loop = asyncio.get_running_loop() self._mouse_down_time = time() def send_event(self, event: events.Event) -> None: diff --git a/src/textual/drivers/linux_driver.py b/src/textual/drivers/linux_driver.py index 9a1a4c6de..194ed1f2b 100644 --- a/src/textual/drivers/linux_driver.py +++ b/src/textual/drivers/linux_driver.py @@ -73,7 +73,7 @@ class LinuxDriver(Driver): def start_application_mode(self): - loop = asyncio.get_event_loop() + loop = asyncio.get_running_loop() def send_size_event(): terminal_size = self._get_terminal_size() @@ -121,7 +121,7 @@ class LinuxDriver(Driver): self.console.file.write("\033[?1003h\n") self.console.file.flush() self._key_thread = Thread( - target=self.run_input_thread, args=(asyncio.get_event_loop(),) + target=self.run_input_thread, args=(asyncio.get_running_loop(),) ) send_size_event() self._key_thread.start() diff --git a/src/textual/drivers/windows_driver.py b/src/textual/drivers/windows_driver.py index 0cbac1ac0..e63aad542 100644 --- a/src/textual/drivers/windows_driver.py +++ b/src/textual/drivers/windows_driver.py @@ -46,7 +46,7 @@ class WindowsDriver(Driver): def start_application_mode(self) -> None: - loop = asyncio.get_event_loop() + loop = asyncio.get_running_loop() self._restore_console = win32.enable_application_mode() From 3198d28588a591ff7262f77a1a756cadf598503b Mon Sep 17 00:00:00 2001 From: Olivier Philippon Date: Tue, 3 May 2022 16:33:03 +0100 Subject: [PATCH 6/7] [asyncio] Address "asyncio bugfixes for Python < 3.10" PR feedback --- src/textual/_timer.py | 7 +------ src/textual/app.py | 8 +++++++- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/src/textual/_timer.py b/src/textual/_timer.py index aba34aa69..377cdf8a5 100644 --- a/src/textual/_timer.py +++ b/src/textual/_timer.py @@ -114,12 +114,7 @@ class Timer: continue wait_time = max(0, next_timer - monotonic()) if wait_time: - try: - await sleep(wait_time) - except asyncio.CancelledError: - # Likely our program terminating: this is fine, we just have to - # shut down out asyncio Task properly: - await self.stop() + await sleep(wait_time) event = events.Timer( self.sender, timer=self, diff --git a/src/textual/app.py b/src/textual/app.py index c35cf9120..c490ef945 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -325,6 +325,8 @@ class App(Generic[ReturnType], DOMNode): ) def run(self) -> ReturnType | None: + """The entry point to run a Textual app.""" + async def run_app() -> None: await self.process_messages() @@ -340,6 +342,10 @@ class App(Generic[ReturnType], DOMNode): @classmethod def _init_uvloop(cls) -> None: + """ + Import and install the `uvloop` asyncio policy, if available. + This is done only once, even if the method is called multiple times. + """ if hasattr(cls, "__uvloop_installed"): return cls.__uvloop_installed = False @@ -518,7 +524,7 @@ class App(Generic[ReturnType], DOMNode): active_app.set(self) log("---") log(f"driver={self.driver_class}") - log(f"uvloop installed: {self.__class__.__uvloop_installed!r}") + log(f"asyncio running loop={asyncio.get_running_loop()!r}") if self.devtools_enabled: try: From e324de761306eed5252b9189aa8a132ac7378dac Mon Sep 17 00:00:00 2001 From: Olivier Philippon Date: Wed, 4 May 2022 09:29:05 +0100 Subject: [PATCH 7/7] [app] Move `uvloop` init logic to a private module function --- src/textual/app.py | 49 ++++++++++++++++++++++++++-------------------- 1 file changed, 28 insertions(+), 21 deletions(-) diff --git a/src/textual/app.py b/src/textual/app.py index c490ef945..0ba39f271 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -6,7 +6,6 @@ import os import platform import sys import warnings -from asyncio import AbstractEventLoop from contextlib import redirect_stdout from time import perf_counter from typing import Any, Generic, Iterable, TextIO, Type, TypeVar, TYPE_CHECKING @@ -28,9 +27,8 @@ from ._animator import Animator from ._callback import invoke from ._context import active_app from ._event_broker import extract_handler_actions, NoHandler -from ._profile import timer from .binding import Bindings, NoBinding -from .css.stylesheet import Stylesheet, StylesheetError +from .css.stylesheet import Stylesheet from .design import ColorSystem from .devtools.client import DevtoolsClient, DevtoolsConnectionError, DevtoolsLog from .devtools.redirect_output import StdoutRedirector @@ -112,7 +110,10 @@ class App(Generic[ReturnType], DOMNode): css (str | None, optional): CSS code to parse, or ``None`` for no literal CSS. Defaults to None. watch_css (bool, optional): Watch CSS for changes. Defaults to True. """ - App._init_uvloop() + # N.B. This must be done *before* we call the parent constructor, because MessagePump's + # constructor instantiates a `asyncio.PriorityQueue` and in Python versions older than 3.10 + # this will create some first references to an asyncio loop. + _init_uvloop() self.console = Console( file=sys.__stdout__, markup=False, highlight=False, emoji=False @@ -340,23 +341,6 @@ class App(Generic[ReturnType], DOMNode): return self._return_value - @classmethod - def _init_uvloop(cls) -> None: - """ - Import and install the `uvloop` asyncio policy, if available. - This is done only once, even if the method is called multiple times. - """ - if hasattr(cls, "__uvloop_installed"): - return - cls.__uvloop_installed = False - try: - import uvloop - except ImportError: - pass - else: - uvloop.install() - cls.__uvloop_installed = True - async def _on_css_change(self) -> None: """Called when the CSS changes (if watch_css is True).""" if self.css_file is not None: @@ -894,3 +878,26 @@ class App(Generic[ReturnType], DOMNode): async def handle_styles_updated(self, message: messages.StylesUpdated) -> None: self.stylesheet.update(self, animate=True) + + +_uvloop_init_done: bool = False + + +def _init_uvloop() -> None: + """ + Import and install the `uvloop` asyncio policy, if available. + This is done only once, even if the function is called multiple times. + """ + global _uvloop_init_done + + if _uvloop_init_done: + return + + try: + import uvloop + except ImportError: + pass + else: + uvloop.install() + + _uvloop_init_done = True