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: |
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 e2e_tests/sandbox_basic_test.py basic 2.0
- name: Upload code coverage
uses: codecov/codecov-action@v1.0.10
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.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()

View File

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

View File

@@ -1,8 +1,8 @@
from __future__ import annotations
import asyncio
import weakref
from asyncio import (
get_event_loop,
CancelledError,
Event,
sleep,
@@ -84,7 +84,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:

View File

@@ -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
@@ -54,6 +52,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 +110,11 @@ 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.
"""
# 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
)
@@ -319,25 +325,19 @@ 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:
"""The entry point to run a Textual app."""
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
@@ -508,6 +508,7 @@ class App(Generic[ReturnType], DOMNode):
active_app.set(self)
log("---")
log(f"driver={self.driver_class}")
log(f"asyncio running loop={asyncio.get_running_loop()!r}")
if self.devtools_enabled:
try:
@@ -877,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

View File

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

View File

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

View File

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