New CLI runner (#2338)

* New CLI runner

* runner functionality

* Add port

* use env for port

* changelog

* test fix

* flush

* remove constant

* comment

* tidy docs

* docstrings

* punctuation

* docstring

* fix test

* snapshot

* Update src/textual/cli/cli.py

Co-authored-by: Rodrigo Girão Serrão <5621605+rodrigogiraoserrao@users.noreply.github.com>

* guard against bad imports

* guard againsts screenshot

* always print return

* docstrings

* docstrings

---------

Co-authored-by: Rodrigo Girão Serrão <5621605+rodrigogiraoserrao@users.noreply.github.com>
This commit is contained in:
Will McGugan
2023-04-20 17:09:39 +01:00
committed by GitHub
parent c8ecd26234
commit cab4925eaa
11 changed files with 358 additions and 160 deletions

View File

@@ -9,17 +9,20 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
### Changed
- Breaking change: standard keyboard scrollable navigation bindings have been moved off `Widget` and onto a new base class for scrollable containers (see also below addition) https://github.com/Textualize/textual/issues/2332
- `ScrollView` now inherits from `ScrollableContainer` rather than `Widget` https://github.com/Textualize/textual/issues/2332
- Containers no longer inherit any bindings from `Widget` https://github.com/Textualize/textual/issues/2331
- `textual run` execs apps in a new context.
### Added
- Added `-c` switch to `textual run` which runs commands in a Textual dev environment.
- Breaking change: standard keyboard scrollable navigation bindings have been moved off `Widget` and onto a new base class for scrollable containers (see also below addition) https://github.com/Textualize/textual/issues/2332
- `ScrollView` now inherits from `ScrollableContainer` rather than `Widget` https://github.com/Textualize/textual/issues/2332
- Containers no longer inherit any bindings from `Widget` https://github.com/Textualize/textual/issues/2331
- Added `ScrollableContainer`; a container class that binds the common navigation keys to scroll actions (see also above breaking change) https://github.com/Textualize/textual/issues/2332
### Fixed
- Fixed dark mode toggles in a "child" screen not updating a "parent" screen https://github.com/Textualize/textual/issues/1999
- Fixed "panel" border not exposed via CSS
## [0.20.1] - 2023-04-18

View File

@@ -1048,9 +1048,19 @@ class App(Generic[ReturnType], DOMNode):
auto_pilot_task: Task | None = None
if auto_pilot is None and constants.PRESS:
keys = constants.PRESS.split(",")
async def press_keys(pilot: Pilot) -> None:
"""Auto press keys."""
await pilot.press(*keys)
auto_pilot = press_keys
async def app_ready() -> None:
"""Called by the message loop when the app is ready."""
nonlocal auto_pilot_task
if auto_pilot is not None:
async def run_auto_pilot(
@@ -1762,23 +1772,16 @@ class App(Generic[ReturnType], DOMNode):
May be used as a hook for any operations that should run first.
"""
try:
screenshot_timer = float(os.environ.get("TEXTUAL_SCREENSHOT", "0"))
except ValueError:
return
screenshot_title = os.environ.get("TEXTUAL_SCREENSHOT_TITLE")
if not screenshot_timer:
return
async def on_screenshot():
"""Used by docs plugin."""
svg = self.export_screenshot(title=screenshot_title)
self._screenshot = svg # type: ignore
async def take_screenshot() -> None:
"""Take a screenshot and exit."""
self.save_screenshot()
self.exit()
self.set_timer(screenshot_timer, on_screenshot, name="screenshot timer")
if constants.SCREENSHOT_DELAY >= 0:
self.set_timer(
constants.SCREENSHOT_DELAY, take_screenshot, name="screenshot timer"
)
async def _on_compose(self) -> None:
try:
@@ -1966,6 +1969,14 @@ class App(Generic[ReturnType], DOMNode):
self._print_error_renderables()
if constants.SHOW_RETURN:
from rich.console import Console
from rich.pretty import Pretty
console = Console()
console.print("[b]The app returned:")
console.print(Pretty(self._return_value))
async def _on_exit_app(self) -> None:
self._begin_batch() # Prevent repaint / layout while shutting down
await self._message_queue.put(None)

163
src/textual/cli/_run.py Normal file
View File

@@ -0,0 +1,163 @@
"""
Functions to run Textual apps with an updated environment.
Note that these methods will execute apps in a new process, and abandon the current process.
This means that (if they succeed) they will never return.
"""
from __future__ import annotations
import importlib
import os
import platform
import shlex
import sys
from string import Template
from typing import NoReturn
WINDOWS = platform.system() == "Windows"
"""True if we're running on Windows."""
EXEC_SCRIPT = Template(
"""\
from textual.app import App
from $MODULE import $APP as app;
if isinstance(app, App):
# If we imported an app, run it
app.run()
else:
# Otherwise it is assumed to be a class
app().run()
"""
)
"""A template script to import and run an app."""
class ExecImportError(Exception):
"""Raised if a Python import is invalid."""
def run_app(command_args: str, environment: dict[str, str] | None = None) -> None:
"""Run a textual app.
Note:
The current process is abandoned.
Args:
command_args: Arguments to pass to the Textual app.
environment: Environment variables, or None to use current process.
"""
import_name, *args = shlex.split(command_args, posix=not WINDOWS)
if environment is None:
app_environment = dict(os.environ)
else:
app_environment = environment
if _is_python_path(import_name):
# If it is a Python path we can exec it now
exec_python([import_name, *args], app_environment)
else:
# Otherwise it is assumed to be a Python import
try:
exec_import(import_name, args, app_environment)
except (SyntaxError, ExecImportError):
print(f"Unable to import Textual app from {import_name!r}")
def _is_python_path(path: str) -> bool:
"""Does the given file look like it's run with Python?
Args:
path: The path to check.
Returns:
True if the path references Python code, False it it doesn't.
"""
if path.endswith(".py"):
return True
try:
with open(path, "r") as source:
first_line = source.readline()
except IOError:
return False
return first_line.startswith("#!") and "py" in first_line
def _flush() -> None:
"""Flush output before exec."""
sys.stderr.flush()
sys.stdout.flush()
def exec_python(args: list[str], environment: dict[str, str]) -> NoReturn:
"""Execute a Python script.
Args:
args: Additional arguments.
environment: Environment variables.
"""
_flush()
os.execvpe(sys.executable, ["python", *args], environment)
def exec_command(command: str, environment: dict[str, str]) -> NoReturn:
"""Execute a command with the given environment.
Args:
command: Command to execute.
environment: Environment variables.
"""
_flush()
command, *args = shlex.split(command, posix=not WINDOWS)
os.execvpe(command, [command, *args], environment)
def check_import(module_name: str, app_name: str) -> bool:
"""Check if a symbol can be imported.
Args:
module_name: Name of the module
app_name: Name of the app.
Returns:
True if the app may be imported from the module.
"""
try:
sys.path.insert(0, "")
module = importlib.import_module(module_name)
except ImportError as error:
return False
return hasattr(module, app_name)
def exec_import(
import_name: str, args: list[str], environment: dict[str, str]
) -> NoReturn:
"""Import and execute an app.
Raises:
SyntaxError: If any imports are not valid Python symbols.
ExecImportError: If the module could not be imported.
Args:
import_name: The Python import.
args: Command line arguments.
environment: Environment variables.
"""
module, _colon, app = import_name.partition(":")
app = app or "app"
if not check_import(module, app):
raise ExecImportError(f"Unable to import {app!r} from {import_name!r}")
script = EXEC_SCRIPT.substitute(MODULE=module, APP=app)
# Compiling the script will raise a SyntaxError if there are any invalid symbols
compile(script, "textual-exec", "exec")
_flush()
exec_python(["-c", script, *args], environment)

View File

@@ -2,7 +2,8 @@ from __future__ import annotations
import sys
from ..constants import DEFAULT_DEVTOOLS_PORT, DEVTOOLS_PORT_ENVIRON_VARIABLE
from ..constants import DEVTOOLS_PORT
from ._run import exec_command, run_app
try:
import click
@@ -12,9 +13,6 @@ except ImportError:
from importlib_metadata import version
from textual._import_app import AppFail, import_app
from textual.pilot import Pilot
@click.group()
@click.version_option(version("textual"))
@@ -29,31 +27,27 @@ def run():
type=int,
default=None,
metavar="PORT",
help=f"Port to use for the development mode console. Defaults to {DEFAULT_DEVTOOLS_PORT}.",
help=f"Port to use for the development mode console. Defaults to {DEVTOOLS_PORT}.",
)
@click.option("-v", "verbose", help="Enable verbose logs.", is_flag=True)
@click.option("-x", "--exclude", "exclude", help="Exclude log group(s)", multiple=True)
def console(port: int | None, verbose: bool, exclude: list[str]) -> None:
"""Launch the textual console."""
import os
from rich.console import Console
from textual.devtools.server import _run_devtools
if port is not None:
os.environ[DEVTOOLS_PORT_ENVIRON_VARIABLE] = str(port)
console = Console()
console.clear()
console.show_cursor(False)
try:
_run_devtools(verbose=verbose, exclude=exclude)
_run_devtools(verbose=verbose, exclude=exclude, port=port)
finally:
console.show_cursor(True)
def _post_run_warnings() -> None:
def _pre_run_warnings() -> None:
"""Look for and report any issues with the environment.
This is the right place to add code that looks at the terminal, or other
@@ -72,9 +66,11 @@ def _post_run_warnings() -> None:
# first item is `True` if a problem situation is detected, and the
# second item is a message to show the user on exit from `textual run`.
warnings = [
(
(
platform.system() == "Darwin"
and os.environ.get("TERM_PROGRAM") == "Apple_Terminal",
and os.environ.get("TERM_PROGRAM") == "Apple_Terminal"
),
"The default terminal app on macOS is limited to 256 colors. See our FAQ for more details:\n\n"
"https://github.com/Textualize/textual/blob/main/FAQ.md#why-doesn't-textual-look-good-on-macos",
)
@@ -82,7 +78,11 @@ def _post_run_warnings() -> None:
for concerning, concern in warnings:
if concerning:
console.print(Panel.fit(f"⚠️ [bold green] {concern}[/]", style="cyan"))
console.print(
Panel.fit(
f"⚠️ {concern}", style="yellow", border_style="red", padding=(1, 2)
)
)
@run.command(
@@ -92,25 +92,51 @@ def _post_run_warnings() -> None:
},
)
@click.argument("import_name", metavar="FILE or FILE:APP")
@click.option("--dev", "dev", help="Enable development mode", is_flag=True)
@click.option("--dev", "dev", help="Enable development mode.", is_flag=True)
@click.option(
"--port",
"port",
type=int,
default=None,
metavar="PORT",
help=f"Port to use for the development mode console. Defaults to {DEFAULT_DEVTOOLS_PORT}.",
help=f"Port to use for the development mode console. Defaults to {DEVTOOLS_PORT}.",
)
@click.option(
"--press", "press", default=None, help="Comma separated keys to simulate press."
)
@click.option("--press", "press", help="Comma separated keys to simulate press")
@click.option(
"--screenshot",
type=int,
default=None,
metavar="DELAY",
help="Take screenshot after DELAY seconds",
help="Take screenshot after DELAY seconds.",
)
def run_app(
import_name: str, dev: bool, port: int | None, press: str, screenshot: int | None
@click.option(
"-c",
"--command",
"command",
type=bool,
default=False,
help="Run as command rather that a file / module.",
is_flag=True,
)
@click.option(
"-r",
"--show-return",
"show_return",
type=bool,
default=False,
help="Show any return value on exit.",
is_flag=True,
)
def _run_app(
import_name: str,
dev: bool,
port: int | None,
press: str | None,
screenshot: int | None,
command: bool = False,
show_return: bool = False,
) -> None:
"""Run a Textual app.
@@ -132,53 +158,39 @@ def run_app(
in quotes:
textual run "foo.py arg --option"
Use the -c switch to run a command that launches a Textual app.
textual run -c "textual colors"
"""
import os
import sys
from asyncio import sleep
from textual.features import parse_features
if port is not None:
os.environ[DEVTOOLS_PORT_ENVIRON_VARIABLE] = str(port)
environment = dict(os.environ)
features = set(parse_features(os.environ.get("TEXTUAL", "")))
features = set(parse_features(environment.get("TEXTUAL", "")))
if dev:
features.add("debug")
features.add("devtools")
os.environ["TEXTUAL"] = ",".join(sorted(features))
try:
app = import_app(import_name)
except AppFail as error:
from rich.console import Console
console = Console(stderr=True)
console.print(str(error))
sys.exit(1)
press_keys = press.split(",") if press else None
async def run_press_keys(pilot: Pilot) -> None:
if press_keys is not None:
await pilot.press(*press_keys)
environment["TEXTUAL"] = ",".join(sorted(features))
if port is not None:
environment["TEXTUAL_DEVTOOLS_PORT"] = str(port)
if press is not None:
environment["TEXTUAL_PRESS"] = str(press)
if screenshot is not None:
await sleep(screenshot)
filename = pilot.app.save_screenshot()
pilot.app.exit(message=f"Saved {filename!r}")
environment["TEXTUAL_SCREENSHOT"] = str(screenshot)
if show_return:
environment["TEXTUAL_SHOW_RETURN"] = "1"
result = app.run(auto_pilot=run_press_keys)
_pre_run_warnings()
if result is not None:
from rich.console import Console
from rich.pretty import Pretty
console = Console()
console.print("[b]The app returned:")
console.print(Pretty(result))
_post_run_warnings()
if command:
exec_command(import_name, environment)
else:
run_app(import_name, environment)
@run.command("borders")
@@ -215,7 +227,7 @@ def keys():
@run.command("diagnose")
def run_diagnose():
"""Print information about the Textual environment"""
"""Print information about the Textual environment."""
from textual.cli.tools.diagnose import diagnose
diagnose()

View File

@@ -1,6 +1,6 @@
from textual.app import App, ComposeResult
from textual.constants import BORDERS
from textual.containers import VerticalScroll
from textual.css.constants import VALID_BORDER
from textual.widgets import Button, Label
TEXT = """I must not fear.
@@ -26,7 +26,7 @@ class BorderButtons(VerticalScroll):
"""
def compose(self) -> ComposeResult:
for border in BORDERS:
for border in sorted(VALID_BORDER):
if border:
yield Button(border, id=border)

5
src/textual/cli/run.py Normal file
View File

@@ -0,0 +1,5 @@
import sys
from ._run import run
run(sys.argv[1], sys.argv[1:])

View File

@@ -9,10 +9,6 @@ import os
from typing_extensions import Final
from ._border import BORDER_CHARS
__all__ = ["BORDERS"]
get_environ = os.environ.get
@@ -42,16 +38,18 @@ def get_environ_int(name: str, default: int) -> int:
or the default value otherwise.
"""
try:
return int(get_environ(name, default))
return int(os.environ[name])
except KeyError:
return default
except ValueError:
return default
BORDERS = list(BORDER_CHARS)
DEBUG: Final[bool] = get_environ_bool("TEXTUAL_DEBUG")
"""Enable debug mode."""
DRIVER: Final[str | None] = get_environ("TEXTUAL_DRIVER", None)
"""Import for replacement driver."""
FILTERS: Final[str] = get_environ("TEXTUAL_FILTERS", "")
"""A list of filters to apply to renderables."""
@@ -59,12 +57,14 @@ FILTERS: Final[str] = get_environ("TEXTUAL_FILTERS", "")
LOG_FILE: Final[str | None] = get_environ("TEXTUAL_LOG", None)
"""A last resort log file that appends all logs, when devtools isn't working."""
DEVTOOLS_PORT_ENVIRON_VARIABLE: Final[str] = "TEXTUAL_CONSOLE_PORT"
"""The name of the environment variable that sets the port for the devtools."""
DEFAULT_DEVTOOLS_PORT: Final[int] = 8081
"""The default port to use for the devtools."""
DEVTOOLS_PORT: Final[int] = get_environ_int(
DEVTOOLS_PORT_ENVIRON_VARIABLE, DEFAULT_DEVTOOLS_PORT
)
DEVTOOLS_PORT: Final[int] = get_environ_int("TEXTUAL_DEVTOOLS_PORT", 8081)
"""Constant with the port that the devtools will connect to."""
SCREENSHOT_DELAY: Final[int] = get_environ_int("TEXTUAL_SCREENSHOT", -1)
"""Seconds delay before taking screenshot."""
PRESS: Final[str] = get_environ("TEXTUAL_PRESS", "")
"""Keys to automatically press."""
SHOW_RETURN: Final[bool] = get_environ_bool("TEXTUAL_SHOW_RETURN")
"""Write the return value on exit."""

View File

@@ -20,6 +20,7 @@ VALID_BORDER: Final = {
"inner",
"none",
"outer",
"panel",
"round",
"solid",
"tall",

View File

@@ -8,8 +8,9 @@ from aiohttp.web_request import Request
from aiohttp.web_routedef import get
from aiohttp.web_ws import WebSocketResponse
from textual.devtools.client import DEVTOOLS_PORT
from textual.devtools.service import DevtoolsService
from ..constants import DEVTOOLS_PORT
from .client import DEVTOOLS_PORT
from .service import DevtoolsService
DEFAULT_SIZE_CHANGE_POLL_DELAY_SECONDS = 2
@@ -38,7 +39,9 @@ async def _on_startup(app: Application) -> None:
await service.start()
def _run_devtools(verbose: bool, exclude: list[str] | None = None) -> None:
def _run_devtools(
verbose: bool, exclude: list[str] | None = None, port: int | None = None
) -> None:
app = _make_devtools_aiohttp_app(verbose=verbose, exclude=exclude)
def noop_print(_: str) -> None:
@@ -47,7 +50,7 @@ def _run_devtools(verbose: bool, exclude: list[str] | None = None) -> None:
try:
run_app(
app,
port=DEVTOOLS_PORT,
port=DEVTOOLS_PORT if port is None else port,
print=noop_print,
loop=asyncio.get_event_loop(),
)

View File

@@ -10,7 +10,7 @@ from rich.console import ConsoleDimensions
from rich.panel import Panel
from tests.utilities.render import wait_for_predicate
from textual.constants import DEFAULT_DEVTOOLS_PORT
from textual.constants import DEVTOOLS_PORT
from textual.devtools.client import DevtoolsClient
from textual.devtools.redirect_output import DevtoolsLog
@@ -22,7 +22,7 @@ TIMESTAMP = 1649166819
def test_devtools_client_initialize_defaults():
devtools = DevtoolsClient()
assert devtools.url == f"ws://127.0.0.1:{DEFAULT_DEVTOOLS_PORT}"
assert devtools.url == f"ws://127.0.0.1:{DEVTOOLS_PORT}"
async def test_devtools_client_is_connected(devtools):

File diff suppressed because one or more lines are too long