Merge pull request #449 from Textualize/add-e2e-smoke-test-to-CI

[CI] Check that the "basic.py" sandbox script can be run for a few seconds without crashing
This commit is contained in:
Will McGugan
2022-05-04 10:50:37 +01:00
committed by GitHub
11 changed files with 489 additions and 26 deletions

View File

@@ -39,6 +39,10 @@ jobs:
run: | run: |
source $VENV source $VENV
pytest tests -v --cov=./src/textual --cov-report=xml:./coverage.xml --cov-report term-missing pytest tests -v --cov=./src/textual --cov-report=xml:./coverage.xml --cov-report term-missing
- name: Quick e2e smoke test
run: |
source $VENV
python e2e_tests/sandbox_basic_test.py basic 2.0
- name: Upload code coverage - name: Upload code coverage
uses: codecov/codecov-action@v1.0.10 uses: codecov/codecov-action@v1.0.10
with: with:

View File

@@ -0,0 +1,55 @@
from __future__ import annotations
import shlex
import sys
import subprocess
import threading
from pathlib import 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(python_file_name: str) -> None:
global completed_process
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
thread = threading.Thread(
target=launch_sandbox_script, args=(target_script_name,), daemon=True
)
thread.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_still_running = completed_process is None
process_was_able_to_run_without_errors = process_still_running
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)

View File

@@ -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
}

View File

@@ -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()

View File

@@ -1,3 +1,5 @@
from pathlib import Path
from rich.align import Align from rich.align import Align
from rich.console import RenderableType from rich.console import RenderableType
from rich.syntax import Syntax from rich.syntax import Syntax
@@ -139,7 +141,8 @@ class BasicApp(App):
self.panic(self.tree) 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__": if __name__ == "__main__":
app.run() app.run()

View File

@@ -448,7 +448,7 @@ class Compositor:
segment_lines: list[list[Segment]] = [ segment_lines: list[list[Segment]] = [
sum( sum(
[line for line in bucket.values() if line is not None], [line for line in bucket.values() if line is not None],
start=[], [],
) )
for bucket in chops for bucket in chops
] ]

View File

@@ -1,8 +1,8 @@
from __future__ import annotations from __future__ import annotations
import asyncio
import weakref import weakref
from asyncio import ( from asyncio import (
get_event_loop,
CancelledError, CancelledError,
Event, Event,
sleep, sleep,
@@ -84,7 +84,7 @@ class Timer:
Returns: Returns:
Task: A Task instance for the timer. 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 return self._task
async def stop(self) -> None: async def stop(self) -> None:

View File

@@ -6,7 +6,6 @@ import os
import platform import platform
import sys import sys
import warnings import warnings
from asyncio import AbstractEventLoop
from contextlib import redirect_stdout from contextlib import redirect_stdout
from time import perf_counter from time import perf_counter
from typing import Any, Generic, Iterable, TextIO, Type, TypeVar, TYPE_CHECKING from typing import Any, Generic, Iterable, TextIO, Type, TypeVar, TYPE_CHECKING
@@ -28,9 +27,8 @@ from ._animator import Animator
from ._callback import invoke from ._callback import invoke
from ._context import active_app from ._context import active_app
from ._event_broker import extract_handler_actions, NoHandler from ._event_broker import extract_handler_actions, NoHandler
from ._profile import timer
from .binding import Bindings, NoBinding from .binding import Bindings, NoBinding
from .css.stylesheet import Stylesheet, StylesheetError from .css.stylesheet import Stylesheet
from .design import ColorSystem from .design import ColorSystem
from .devtools.client import DevtoolsClient, DevtoolsConnectionError, DevtoolsLog from .devtools.client import DevtoolsClient, DevtoolsConnectionError, DevtoolsLog
from .devtools.redirect_output import StdoutRedirector from .devtools.redirect_output import StdoutRedirector
@@ -54,6 +52,9 @@ WINDOWS = PLATFORM == "Windows"
# asyncio will warn against resources not being cleared # asyncio will warn against resources not being cleared
warnings.simplefilter("always", ResourceWarning) 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]" LayoutDefinition = "dict[str, Any]"
@@ -109,6 +110,11 @@ class App(Generic[ReturnType], DOMNode):
css (str | None, optional): CSS code to parse, or ``None`` for no literal CSS. Defaults to None. 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. watch_css (bool, optional): Watch CSS for changes. Defaults to True.
""" """
# 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( self.console = Console(
file=sys.__stdout__, markup=False, highlight=False, emoji=False file=sys.__stdout__, markup=False, highlight=False, emoji=False
) )
@@ -319,25 +325,19 @@ class App(Generic[ReturnType], DOMNode):
keys, action, description, show=show, key_display=key_display keys, action, description, show=show, key_display=key_display
) )
def run(self, loop: AbstractEventLoop | None = None) -> ReturnType | None: def run(self) -> ReturnType | None:
"""The entry point to run a Textual app."""
async def run_app() -> None: async def run_app() -> None:
await self.process_messages() await self.process_messages()
if loop: if _ASYNCIO_GET_EVENT_LOOP_IS_DEPRECATED:
asyncio.set_event_loop(loop) # N.B. This doesn't work with Python<3.10, as we end up with 2 event loops:
else:
try:
import uvloop
except ImportError:
pass
else:
asyncio.set_event_loop(uvloop.new_event_loop())
event_loop = asyncio.get_event_loop()
try:
asyncio.run(run_app()) asyncio.run(run_app())
finally: else:
event_loop.close() # However, this works with Python<3.10:
event_loop = asyncio.get_event_loop()
event_loop.run_until_complete(run_app())
return self._return_value return self._return_value
@@ -508,6 +508,7 @@ class App(Generic[ReturnType], DOMNode):
active_app.set(self) active_app.set(self)
log("---") log("---")
log(f"driver={self.driver_class}") log(f"driver={self.driver_class}")
log(f"asyncio running loop={asyncio.get_running_loop()!r}")
if self.devtools_enabled: if self.devtools_enabled:
try: try:
@@ -877,3 +878,26 @@ class App(Generic[ReturnType], DOMNode):
async def handle_styles_updated(self, message: messages.StylesUpdated) -> None: async def handle_styles_updated(self, message: messages.StylesUpdated) -> None:
self.stylesheet.update(self, animate=True) 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

View File

@@ -21,7 +21,7 @@ class Driver(ABC):
self.console = console self.console = console
self._target = target self._target = target
self._debug = debug self._debug = debug
self._loop = asyncio.get_event_loop() self._loop = asyncio.get_running_loop()
self._mouse_down_time = time() self._mouse_down_time = time()
def send_event(self, event: events.Event) -> None: def send_event(self, event: events.Event) -> None:

View File

@@ -73,7 +73,7 @@ class LinuxDriver(Driver):
def start_application_mode(self): def start_application_mode(self):
loop = asyncio.get_event_loop() loop = asyncio.get_running_loop()
def send_size_event(): def send_size_event():
terminal_size = self._get_terminal_size() terminal_size = self._get_terminal_size()
@@ -121,7 +121,7 @@ class LinuxDriver(Driver):
self.console.file.write("\033[?1003h\n") self.console.file.write("\033[?1003h\n")
self.console.file.flush() self.console.file.flush()
self._key_thread = Thread( 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() send_size_event()
self._key_thread.start() self._key_thread.start()

View File

@@ -46,7 +46,7 @@ class WindowsDriver(Driver):
def start_application_mode(self) -> None: def start_application_mode(self) -> None:
loop = asyncio.get_event_loop() loop = asyncio.get_running_loop()
self._restore_console = win32.enable_application_mode() self._restore_console = win32.enable_application_mode()