This commit is contained in:
Rui Carmo
2026-01-21 23:53:57 +00:00
commit a0e31d43fd
52 changed files with 6312 additions and 0 deletions
+229
View File
@@ -0,0 +1,229 @@
from __future__ import annotations
import asyncio
import importlib
import importlib.util
import logging
import os
import sys
from pathlib import Path
import click
from importlib_metadata import version
from rich.logging import RichHandler
from . import constants
from .local_server import LocalServer
FORMAT = "%(message)s"
logging.basicConfig(
level="DEBUG" if constants.DEBUG else "INFO",
format=FORMAT,
datefmt="[%X]",
handlers=[RichHandler(show_path=False)],
)
log = logging.getLogger("textual-webterm")
def _is_file_path(path: str) -> bool:
"""Check if path looks like a file path (vs module path)."""
return path.endswith(".py") or "/" in path or "\\" in path
def parse_app_path(app_path: str) -> tuple[str, str]:
"""Parse an app path like 'module.path:ClassName' or 'path/to/file.py:ClassName'.
Returns:
Tuple of (module_or_file, class_name)
"""
if ":" not in app_path:
raise click.BadParameter(
f"Invalid app path '{app_path}'. Expected format: 'module.path:ClassName' or 'path/to/file.py:ClassName'"
)
module_part, class_name = app_path.rsplit(":", 1)
return module_part, class_name
def load_app_class(app_path: str):
"""Load a Textual App class from a module path.
Args:
app_path: Path like 'module.path:ClassName' or 'path/to/file.py:ClassName'
Returns:
The App class
"""
module_part, class_name = parse_app_path(app_path)
# Check if it's a file path or module path
if _is_file_path(module_part):
# File path - load from file
file_path = Path(module_part).resolve()
if not file_path.exists():
raise click.BadParameter(f"File not found: {file_path}")
# Add parent directory to sys.path for imports
parent_dir = str(file_path.parent)
if parent_dir not in sys.path:
sys.path.insert(0, parent_dir)
# Import the module
module_name = file_path.stem
spec = importlib.util.spec_from_file_location(module_name, file_path)
if spec is None or spec.loader is None:
raise click.BadParameter(f"Could not load module from {file_path}")
module = importlib.util.module_from_spec(spec)
sys.modules[module_name] = module
spec.loader.exec_module(module)
else:
# Module path - import normally
try:
module = importlib.import_module(module_part)
except ImportError as e:
raise click.BadParameter(f"Could not import module '{module_part}': {e}") from e
# Get the class
if not hasattr(module, class_name):
raise click.BadParameter(f"Module '{module_part}' has no attribute '{class_name}'")
app_class = getattr(module, class_name)
return app_class
@click.command()
@click.version_option(version("textual-webterm"))
@click.argument("command", required=False)
@click.option("--port", "-p", type=int, help="Port for server.", default=8080)
@click.option("--host", "-H", help="Host for server.", default="0.0.0.0")
@click.option(
"--app",
"-a",
"app_path",
help="Load a Textual app from module:ClassName (e.g., 'myapp:MyApp' or 'path/to/app.py:MyApp')",
)
@click.option(
"--landing-manifest",
"-L",
"landing_manifest",
type=click.Path(exists=True, dir_okay=False, readable=True, path_type=Path),
help="YAML manifest describing landing page tiles (slug/name/command).",
)
@click.option(
"--compose-manifest",
"-C",
"compose_manifest",
type=click.Path(exists=True, dir_okay=False, readable=True, path_type=Path),
help='Docker compose YAML; services with label "webterm-command" become landing tiles.',
)
def app(
command: str | None,
port: int,
host: str,
app_path: str | None,
landing_manifest: Path | None,
compose_manifest: Path | None,
) -> None:
"""Serve a terminal or Textual app over HTTP/WebSocket.
COMMAND: Shell command to run in terminal (default: $SHELL)
Examples:
\b
textual-webterm # Serve default shell
textual-webterm htop # Serve htop in terminal
textual-webterm --app mymodule:MyApp # Serve a Textual app from module
textual-webterm -a ./calculator.py:CalculatorApp # Serve from file
"""
VERSION = version("textual-webterm")
log.info(f"textual-webterm v{VERSION}")
if constants.DEBUG:
log.warning("DEBUG env var is set; logs may be verbose!")
from .config import default_config, load_compose_manifest, load_landing_yaml
_config = default_config()
landing_apps: list = []
if landing_manifest:
landing_apps = load_landing_yaml(landing_manifest)
elif compose_manifest:
landing_apps = load_compose_manifest(compose_manifest)
server = LocalServer(
"./",
_config,
host=host,
port=port,
landing_apps=landing_apps,
)
for app_entry in landing_apps:
server.add_terminal(app_entry.name, app_entry.command, slug=app_entry.slug)
if app_path:
# Load and run as Textual app from module:class
try:
app_class = load_app_class(app_path)
except click.BadParameter as e:
log.error(str(e))
sys.exit(1)
# Create a command that runs the app using python -m runpy for safety
module_part, class_name = parse_app_path(app_path)
if _is_file_path(module_part):
# File path - use absolute path and proper escaping
file_path = Path(module_part).resolve()
# Use runpy to safely run the file
escaped_path = str(file_path).replace("'", "'\"'\"'")
escaped_class = class_name.replace("'", "'\"'\"'")
run_command = f'python3 -c \'import sys; sys.path.insert(0, "{file_path.parent}"); exec(open("{escaped_path}").read()); {escaped_class}().run()\''
else:
# Module path - validate module and class names
if not module_part.replace(".", "").replace("_", "").isalnum():
log.error(f"Invalid module path: {module_part}")
sys.exit(1)
if not class_name.isidentifier():
log.error(f"Invalid class name: {class_name}")
sys.exit(1)
run_command = (
f'python3 -c "from {module_part} import {class_name}; {class_name}().run()"'
)
app_name = getattr(app_class, "TITLE", None) or class_name
server.add_app(app_name, run_command, "")
log.info(f"Serving Textual app: {app_path}")
elif command:
# Run command as terminal
server.add_terminal("Terminal", command, "")
log.info(f"Serving terminal: {command}")
elif not landing_apps:
# Run default shell
terminal_command = os.environ.get("SHELL", "/bin/sh")
server.add_terminal("Terminal", terminal_command, "")
log.info(f"Serving terminal: {terminal_command}")
def _run_async():
if constants.WINDOWS:
asyncio.run(server.run())
else:
try:
import uvloop
except ImportError:
asyncio.run(server.run())
else:
if sys.version_info >= (3, 11):
with asyncio.Runner(loop_factory=uvloop.new_event_loop) as runner:
runner.run(server.run())
else:
uvloop.install()
asyncio.run(server.run())
_run_async()
if __name__ == "__main__":
app()