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 ### 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 - `textual run` execs apps in a new context.
- `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 ### 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 - 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
- Fixed dark mode toggles in a "child" screen not updating a "parent" screen https://github.com/Textualize/textual/issues/1999 - 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 ## [0.20.1] - 2023-04-18

View File

@@ -1048,9 +1048,19 @@ class App(Generic[ReturnType], DOMNode):
auto_pilot_task: Task | None = None 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: async def app_ready() -> None:
"""Called by the message loop when the app is ready.""" """Called by the message loop when the app is ready."""
nonlocal auto_pilot_task nonlocal auto_pilot_task
if auto_pilot is not None: if auto_pilot is not None:
async def run_auto_pilot( 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. 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") async def take_screenshot() -> None:
"""Take a screenshot and exit."""
if not screenshot_timer: self.save_screenshot()
return
async def on_screenshot():
"""Used by docs plugin."""
svg = self.export_screenshot(title=screenshot_title)
self._screenshot = svg # type: ignore
self.exit() 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: async def _on_compose(self) -> None:
try: try:
@@ -1966,6 +1969,14 @@ class App(Generic[ReturnType], DOMNode):
self._print_error_renderables() 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: async def _on_exit_app(self) -> None:
self._begin_batch() # Prevent repaint / layout while shutting down self._begin_batch() # Prevent repaint / layout while shutting down
await self._message_queue.put(None) 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 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: try:
import click import click
@@ -12,9 +13,6 @@ except ImportError:
from importlib_metadata import version from importlib_metadata import version
from textual._import_app import AppFail, import_app
from textual.pilot import Pilot
@click.group() @click.group()
@click.version_option(version("textual")) @click.version_option(version("textual"))
@@ -29,31 +27,27 @@ def run():
type=int, type=int,
default=None, default=None,
metavar="PORT", 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("-v", "verbose", help="Enable verbose logs.", is_flag=True)
@click.option("-x", "--exclude", "exclude", help="Exclude log group(s)", multiple=True) @click.option("-x", "--exclude", "exclude", help="Exclude log group(s)", multiple=True)
def console(port: int | None, verbose: bool, exclude: list[str]) -> None: def console(port: int | None, verbose: bool, exclude: list[str]) -> None:
"""Launch the textual console.""" """Launch the textual console."""
import os
from rich.console import Console from rich.console import Console
from textual.devtools.server import _run_devtools from textual.devtools.server import _run_devtools
if port is not None:
os.environ[DEVTOOLS_PORT_ENVIRON_VARIABLE] = str(port)
console = Console() console = Console()
console.clear() console.clear()
console.show_cursor(False) console.show_cursor(False)
try: try:
_run_devtools(verbose=verbose, exclude=exclude) _run_devtools(verbose=verbose, exclude=exclude, port=port)
finally: finally:
console.show_cursor(True) console.show_cursor(True)
def _post_run_warnings() -> None: def _pre_run_warnings() -> None:
"""Look for and report any issues with the environment. """Look for and report any issues with the environment.
This is the right place to add code that looks at the terminal, or other This is the right place to add code that looks at the terminal, or other
@@ -73,8 +67,10 @@ def _post_run_warnings() -> None:
# second item is a message to show the user on exit from `textual run`. # second item is a message to show the user on exit from `textual run`.
warnings = [ warnings = [
( (
platform.system() == "Darwin" (
and os.environ.get("TERM_PROGRAM") == "Apple_Terminal", platform.system() == "Darwin"
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" "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", "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: for concerning, concern in warnings:
if concerning: 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( @run.command(
@@ -92,25 +92,51 @@ def _post_run_warnings() -> None:
}, },
) )
@click.argument("import_name", metavar="FILE or FILE:APP") @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( @click.option(
"--port", "--port",
"port", "port",
type=int, type=int,
default=None, default=None,
metavar="PORT", 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( @click.option(
"--screenshot", "--screenshot",
type=int, type=int,
default=None, default=None,
metavar="DELAY", metavar="DELAY",
help="Take screenshot after DELAY seconds", help="Take screenshot after DELAY seconds.",
) )
def run_app( @click.option(
import_name: str, dev: bool, port: int | None, press: str, screenshot: int | None "-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: ) -> None:
"""Run a Textual app. """Run a Textual app.
@@ -132,53 +158,39 @@ def run_app(
in quotes: in quotes:
textual run "foo.py arg --option" 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 os
import sys
from asyncio import sleep
from textual.features import parse_features from textual.features import parse_features
if port is not None: environment = dict(os.environ)
os.environ[DEVTOOLS_PORT_ENVIRON_VARIABLE] = str(port)
features = set(parse_features(os.environ.get("TEXTUAL", ""))) features = set(parse_features(environment.get("TEXTUAL", "")))
if dev: if dev:
features.add("debug") features.add("debug")
features.add("devtools") features.add("devtools")
os.environ["TEXTUAL"] = ",".join(sorted(features)) environment["TEXTUAL"] = ",".join(sorted(features))
try: if port is not None:
app = import_app(import_name) environment["TEXTUAL_DEVTOOLS_PORT"] = str(port)
except AppFail as error: if press is not None:
from rich.console import Console environment["TEXTUAL_PRESS"] = str(press)
if screenshot is not None:
environment["TEXTUAL_SCREENSHOT"] = str(screenshot)
if show_return:
environment["TEXTUAL_SHOW_RETURN"] = "1"
console = Console(stderr=True) _pre_run_warnings()
console.print(str(error))
sys.exit(1)
press_keys = press.split(",") if press else None if command:
exec_command(import_name, environment)
async def run_press_keys(pilot: Pilot) -> None: else:
if press_keys is not None: run_app(import_name, environment)
await pilot.press(*press_keys)
if screenshot is not None:
await sleep(screenshot)
filename = pilot.app.save_screenshot()
pilot.app.exit(message=f"Saved {filename!r}")
result = app.run(auto_pilot=run_press_keys)
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()
@run.command("borders") @run.command("borders")
@@ -215,7 +227,7 @@ def keys():
@run.command("diagnose") @run.command("diagnose")
def run_diagnose(): def run_diagnose():
"""Print information about the Textual environment""" """Print information about the Textual environment."""
from textual.cli.tools.diagnose import diagnose from textual.cli.tools.diagnose import diagnose
diagnose() diagnose()

View File

@@ -1,6 +1,6 @@
from textual.app import App, ComposeResult from textual.app import App, ComposeResult
from textual.constants import BORDERS
from textual.containers import VerticalScroll from textual.containers import VerticalScroll
from textual.css.constants import VALID_BORDER
from textual.widgets import Button, Label from textual.widgets import Button, Label
TEXT = """I must not fear. TEXT = """I must not fear.
@@ -26,7 +26,7 @@ class BorderButtons(VerticalScroll):
""" """
def compose(self) -> ComposeResult: def compose(self) -> ComposeResult:
for border in BORDERS: for border in sorted(VALID_BORDER):
if border: if border:
yield Button(border, id=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 typing_extensions import Final
from ._border import BORDER_CHARS
__all__ = ["BORDERS"]
get_environ = os.environ.get get_environ = os.environ.get
@@ -42,16 +38,18 @@ def get_environ_int(name: str, default: int) -> int:
or the default value otherwise. or the default value otherwise.
""" """
try: try:
return int(get_environ(name, default)) return int(os.environ[name])
except KeyError:
return default
except ValueError: except ValueError:
return default return default
BORDERS = list(BORDER_CHARS)
DEBUG: Final[bool] = get_environ_bool("TEXTUAL_DEBUG") DEBUG: Final[bool] = get_environ_bool("TEXTUAL_DEBUG")
"""Enable debug mode."""
DRIVER: Final[str | None] = get_environ("TEXTUAL_DRIVER", None) DRIVER: Final[str | None] = get_environ("TEXTUAL_DRIVER", None)
"""Import for replacement driver."""
FILTERS: Final[str] = get_environ("TEXTUAL_FILTERS", "") FILTERS: Final[str] = get_environ("TEXTUAL_FILTERS", "")
"""A list of filters to apply to renderables.""" """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) LOG_FILE: Final[str | None] = get_environ("TEXTUAL_LOG", None)
"""A last resort log file that appends all logs, when devtools isn't working.""" """A last resort log file that appends all logs, when devtools isn't working."""
DEVTOOLS_PORT: Final[int] = get_environ_int("TEXTUAL_DEVTOOLS_PORT", 8081)
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
)
"""Constant with the port that the devtools will connect to.""" """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", "inner",
"none", "none",
"outer", "outer",
"panel",
"round", "round",
"solid", "solid",
"tall", "tall",

View File

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

View File

@@ -10,7 +10,7 @@ from rich.console import ConsoleDimensions
from rich.panel import Panel from rich.panel import Panel
from tests.utilities.render import wait_for_predicate 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.client import DevtoolsClient
from textual.devtools.redirect_output import DevtoolsLog from textual.devtools.redirect_output import DevtoolsLog
@@ -22,7 +22,7 @@ TIMESTAMP = 1649166819
def test_devtools_client_initialize_defaults(): def test_devtools_client_initialize_defaults():
devtools = DevtoolsClient() 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): async def test_devtools_client_is_connected(devtools):

File diff suppressed because one or more lines are too long