first commit

This commit is contained in:
Will McGugan
2023-08-15 14:21:06 +01:00
commit 0576678d63
28 changed files with 4028 additions and 0 deletions

131
.gitignore vendored Normal file
View File

@@ -0,0 +1,131 @@
.DS_Store
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
pip-wheel-metadata/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
.python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies.
#Pipfile.lock
# PEP 582; used by e.g. github.com/David-OConnor/pyflow
__pypackages__/
# Celery stuff
celerybeat-schedule
celerybeat.pid
# SageMath parsed files
*.sage.py
# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
.vscode
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2023 Textualize
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

2
README.md Normal file
View File

@@ -0,0 +1,2 @@
# textual-web
Serve textual apps

33
examples/calculator.css Normal file
View File

@@ -0,0 +1,33 @@
Screen {
overflow: auto;
}
#calculator {
layout: grid;
grid-size: 4;
grid-gutter: 1 2;
grid-columns: 1fr;
grid-rows: 2fr 1fr 1fr 1fr 1fr 1fr;
margin: 1 2;
min-height: 25;
min-width: 26;
height: 100%;
}
Button {
width: 100%;
height: 100%;
}
#numbers {
column-span: 4;
content-align: right middle;
padding: 0 1;
height: 100%;
background: $primary-lighten-2;
color: $text;
}
#number-0 {
column-span: 2;
}

145
examples/calculator.py Normal file
View File

@@ -0,0 +1,145 @@
from decimal import Decimal
from textual import events
from textual.app import App, ComposeResult
from textual.containers import Container
from textual.css.query import NoMatches
from textual.reactive import var
from textual.widgets import Button, Static
class CalculatorApp(App):
"""A working 'desktop' calculator."""
CSS_PATH = "calculator.css"
numbers = var("0")
show_ac = var(True)
left = var(Decimal("0"))
right = var(Decimal("0"))
value = var("")
operator = var("plus")
NAME_MAP = {
"asterisk": "multiply",
"slash": "divide",
"underscore": "plus-minus",
"full_stop": "point",
"plus_minus_sign": "plus-minus",
"percent_sign": "percent",
"equals_sign": "equals",
"minus": "minus",
"plus": "plus",
}
def watch_numbers(self, value: str) -> None:
"""Called when numbers is updated."""
# Update the Numbers widget
self.query_one("#numbers", Static).update(value)
def compute_show_ac(self) -> bool:
"""Compute switch to show AC or C button"""
return self.value in ("", "0") and self.numbers == "0"
def watch_show_ac(self, show_ac: bool) -> None:
"""Called when show_ac changes."""
self.query_one("#c").display = not show_ac
self.query_one("#ac").display = show_ac
def compose(self) -> ComposeResult:
"""Add our buttons."""
with Container(id="calculator"):
yield Static(id="numbers")
yield Button("AC", id="ac", variant="primary")
yield Button("C", id="c", variant="primary")
yield Button("+/-", id="plus-minus", variant="primary")
yield Button("%", id="percent", variant="primary")
yield Button("÷", id="divide", variant="warning")
yield Button("7", id="number-7")
yield Button("8", id="number-8")
yield Button("9", id="number-9")
yield Button("×", id="multiply", variant="warning")
yield Button("4", id="number-4")
yield Button("5", id="number-5")
yield Button("6", id="number-6")
yield Button("-", id="minus", variant="warning")
yield Button("1", id="number-1")
yield Button("2", id="number-2")
yield Button("3", id="number-3")
yield Button("+", id="plus", variant="warning")
yield Button("0", id="number-0")
yield Button(".", id="point")
yield Button("=", id="equals", variant="warning")
def on_key(self, event: events.Key) -> None:
"""Called when the user presses a key."""
def press(button_id: str) -> None:
try:
self.query_one(f"#{button_id}", Button).press()
except NoMatches:
pass
key = event.key
if key.isdecimal():
press(f"number-{key}")
elif key == "c":
press("c")
press("ac")
else:
button_id = self.NAME_MAP.get(key)
if button_id is not None:
press(self.NAME_MAP.get(key, key))
def on_button_pressed(self, event: Button.Pressed) -> None:
"""Called when a button is pressed."""
button_id = event.button.id
assert button_id is not None
def do_math() -> None:
"""Does the math: LEFT OPERATOR RIGHT"""
try:
if self.operator == "plus":
self.left += self.right
elif self.operator == "minus":
self.left -= self.right
elif self.operator == "divide":
self.left /= self.right
elif self.operator == "multiply":
self.left *= self.right
self.numbers = str(self.left)
self.value = ""
except Exception:
self.numbers = "Error"
if button_id.startswith("number-"):
number = button_id.partition("-")[-1]
self.numbers = self.value = self.value.lstrip("0") + number
elif button_id == "plus-minus":
self.numbers = self.value = str(Decimal(self.value or "0") * -1)
elif button_id == "percent":
self.numbers = self.value = str(Decimal(self.value or "0") / Decimal(100))
elif button_id == "point":
if "." not in self.value:
self.numbers = self.value = (self.value or "0") + "."
elif button_id == "ac":
self.value = ""
self.left = self.right = Decimal(0)
self.operator = "plus"
self.numbers = "0"
elif button_id == "c":
self.value = ""
self.numbers = "0"
elif button_id in ("plus", "minus", "divide", "multiply"):
self.right = Decimal(self.value or "0")
do_math()
self.operator = button_id
elif button_id == "equals":
if self.value:
self.right = Decimal(self.value)
do_math()
if __name__ == "__main__":
CalculatorApp().run()

47
examples/ganglion.toml Normal file
View File

@@ -0,0 +1,47 @@
[account]
domain = "willmcgugan"
[[apps]]
name = "Calculator"
slug = "calculator"
color = "#00ff00"
path = "./"
command = "python calculator.py"
[[apps]]
name = "Easing"
slug = "easing"
color = "#2222ff"
path = "./"
command = "textual easing"
[[apps]]
name = "Keys"
slug = "keys"
color = "#2222ff"
path = "./"
command = "textual keys"
[[apps]]
name = "Borders"
slug = "borders"
color = "#2222ff"
path = "./"
command = "textual borders"
[[apps]]
name = "Demo"
slug = "demo"
color = "#2222ff"
path = "./"
command = "python -m textual"
#[[apps]]
#name = "Terminal"
#slug = "term"
#path = "./"
#terminal = true

1135
poetry.lock generated Normal file

File diff suppressed because it is too large Load Diff

29
pyproject.toml Normal file
View File

@@ -0,0 +1,29 @@
[tool.poetry]
name = "textual_web"
version = "0.1.0"
description = "Serve Textual apps"
authors = ["Will McGugan <will@textualize.io>"]
license = "MIT"
readme = "README.md"
[tool.poetry.dependencies]
python = "^3.7"
textual = { version = ">=0.13.0", extras = ["dev"] }
aiohttp = "^3.8.4"
uvloop = "^0.17.0"
click = "^8.1.3"
aiohttp-jinja2 = "^1.5.1"
pydantic = "^2.1.1"
xdg = "^6.0.0"
msgpack = "^1.0.5"
[build-system]
requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"
[tool.black]
includes = "src"
[tool.poetry.scripts]
textual-web = "textual_web.cli:app"

BIN
src/textual_web/.DS_Store vendored Normal file

Binary file not shown.

View File

View File

@@ -0,0 +1,75 @@
from __future__ import annotations
from typing import Generic, TypeVar
Key = TypeVar("Key")
Value = TypeVar("Value")
class TwoWayDict(Generic[Key, Value]):
"""
A two-way mapping offering O(1) access in both directions.
Wraps two dictionaries and uses them to provide efficient access to
both values (given keys) and keys (given values).
"""
def __init__(self, initial: dict[Key, Value] | None = None) -> None:
initial_data = {} if initial is None else initial
self._forward: dict[Key, Value] = initial_data
self._reverse: dict[Value, Key] = {
value: key for key, value in initial_data.items()
}
def __setitem__(self, key: Key, value: Value) -> None:
# TODO: Duplicate values need to be managed to ensure consistency,
# decide on best approach.
self._forward.__setitem__(key, value)
self._reverse.__setitem__(value, key)
def __delitem__(self, key: Key) -> None:
value = self._forward[key]
self._forward.__delitem__(key)
self._reverse.__delitem__(value)
def __iter__(self):
return iter(self._forward)
def get(self, key: Key) -> Value | None:
"""Given a key, efficiently lookup and return the associated value.
Args:
key: The key
Returns:
The value
"""
return self._forward.get(key)
def get_key(self, value: Value) -> Key | None:
"""Given a value, efficiently lookup and return the associated key.
Args:
value: The value
Returns:
The key
"""
return self._reverse.get(value)
def contains_value(self, value: Value) -> bool:
"""Check if `value` is a value within this TwoWayDict.
Args:
value: The value to check.
Returns:
True if the value is within the values of this dict.
"""
return value in self._reverse
def __len__(self):
return len(self._forward)
def __contains__(self, item: Key) -> bool:
return item in self._forward

View File

@@ -0,0 +1,268 @@
from __future__ import annotations
import asyncio
from asyncio import StreamReader, StreamWriter, IncompleteReadError
from asyncio.subprocess import Process
from enum import StrEnum, auto
import io
import logging
import json
import logging
import os
import signal
from time import monotonic
from datetime import timedelta
from pathlib import Path
import rich.repr
from . import constants
from .session import Session, SessionConnector
from .types import Meta, SessionID
log = logging.getLogger("textual-web")
class ProcessState(StrEnum):
"""The state of a process."""
PENDING = auto()
RUNNING = auto()
CLOSING = auto()
CLOSED = auto()
def __repr__(self) -> str:
return self.name
@rich.repr.auto(angular=True)
class AppSession(Session):
"""Runs a single app process."""
def __init__(
self,
working_directory: Path,
command: str,
session_id: SessionID,
) -> None:
self.working_directory = working_directory
self.command = command
self.session_id = session_id
self.start_time: float | None = None
self.end_time: float | None = None
self._process: Process | None = None
self._task: asyncio.Task | None = None
super().__init__()
self._state = ProcessState.PENDING
@property
def process(self) -> Process:
assert self._process is not None
return self._process
@property
def stdin(self) -> StreamWriter:
assert self._process is not None
assert self._process.stdin is not None
return self._process.stdin
@property
def stdout(self) -> StreamReader:
assert self._process is not None
assert self._process.stdout is not None
return self._process.stdout
@property
def stderr(self) -> StreamReader:
assert self._process is not None
assert self._process.stderr is not None
return self._process.stderr
@property
def task(self) -> asyncio.Task:
assert self._task is not None
return self._task
@property
def state(self) -> ProcessState:
"""Current running state."""
return self._state
@state.setter
def state(self, state: ProcessState) -> None:
self._state = state
run_time = self.run_time
log.debug(
"%r state=%r run_time=%s",
self,
self.state,
"0" if run_time is None else timedelta(seconds=int(run_time)),
)
@property
def run_time(self) -> float | None:
"""Time process was running, or `None` if it hasn't started."""
if self.end_time is not None:
assert self.start_time is not None
return self.end_time - self.start_time
elif self.start_time is not None:
return monotonic() - self.start_time
else:
return None
def __rich_repr__(self) -> rich.repr.Result:
yield self.command
yield "id", self.session_id
if self._process is not None:
yield "returncode", self._process.returncode, None
async def open(self) -> None:
"""Open the process."""
environment = dict(os.environ.copy())
environment["TEXTUAL_DRIVER"] = "textual.drivers.web_driver:WebDriver"
environment["TEXTUAL_FILTERS"] = "dim"
environment["TEXTUAL_FPS"] = "60"
cwd = os.getcwd()
os.chdir(str(self.working_directory))
try:
self._process = await asyncio.create_subprocess_shell(
self.command,
stdin=asyncio.subprocess.PIPE,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
env=environment,
)
finally:
os.chdir(cwd)
log.debug("opened %r; %r", self.command, self._process)
self.start_time = monotonic()
async def start(self, connector: SessionConnector) -> asyncio.Task:
"""Start a task to run the process."""
self._connector = connector
assert self._task is None
self._task = asyncio.create_task(self.run())
return self._task
async def close(self) -> None:
"""Close the process."""
self.state = ProcessState.CLOSING
try:
self.process.send_signal(signal=signal.SIGINT)
except Exception:
pass
if self._task:
await self._task
async def set_terminal_size(self, width: int, height: int) -> None:
"""Set the terminal size for the process.
Args:
width: Width in cells.
height: Height in cells.
"""
await self.send_meta({"type": "resize", "width": width, "height": height})
async def run(self) -> None:
"""This loop reads the processes standard output, and relays it to the websocket."""
self.state = ProcessState.RUNNING
META = b"M"
DATA = b"D"
stderr_data = io.BytesIO()
async def read_stderr() -> None:
"""Task to read stderr."""
try:
while True:
data = await self.stderr.read(1024 * 4)
if not data:
break
stderr_data.write(data)
except asyncio.CancelledError:
pass
stderr_task = asyncio.create_task(read_stderr())
readexactly = self.stdout.readexactly
from_bytes = int.from_bytes
on_data = self._connector.on_data
on_meta = self._connector.on_meta
try:
while True:
type_bytes = await readexactly(1)
size_bytes = await readexactly(4)
size = from_bytes(size_bytes, "big")
data = await readexactly(size)
if type_bytes == DATA:
await on_data(data)
elif type_bytes == META:
await on_meta(json.loads(data))
except IncompleteReadError:
# Incomplete read means that the stream was closed
pass
except asyncio.CancelledError:
pass
finally:
stderr_task.cancel()
await stderr_task
self.end_time = monotonic()
self.state = ProcessState.CLOSED
stderr_message = stderr_data.getvalue().decode("utf-8", errors="replace")
if self._process is not None and self._process.returncode != 0:
log.info("%s reported errors", self)
if constants.DEBUG and stderr_message:
log.warning(stderr_message)
await self._connector.on_close()
@classmethod
def encode_packet(cls, packet_type: bytes, payload: bytes) -> bytes:
"""Encode a packet.
Args:
packet_type: The packet type (b"D" for data or b"M" for meta)
payload: The payload.
Returns:
Data as bytes.
"""
return b"%s%s%s" % (packet_type, len(payload).to_bytes(4, "big"), payload)
async def send_bytes(self, data: bytes) -> bool:
"""Send bytes to process.
Args:
data: Data to send.
Returns:
True if the data was sent, otherwise False.
"""
stdin = self.stdin
stdin.write(self.encode_packet(b"D", data))
await stdin.drain()
return True
async def send_meta(self, data: Meta) -> bool:
"""Send meta information to process.
Args:
data: Meta dict to send.
Returns:
True if the data was sent, otherwise False.
"""
stdin = self.stdin
data_bytes = json.dumps(data).encode("utf-8")
stdin.write(self.encode_packet(b"M", data_bytes))
await stdin.drain()
return True

View File

@@ -0,0 +1,230 @@
import re
import unicodedata
from rich.console import RenderableType
from textual import on
from textual.app import App, ComposeResult
from textual import events
from textual.containers import Vertical, Container
from textual.renderables.bar import Bar
from textual.reactive import reactive
from textual.widget import Widget
from textual.widgets import Label, Input, Button, LoadingIndicator
from textual.screen import Screen
class Form(Container):
DEFAULT_CSS = """
Form {
color: $text;
width: auto;
height: auto;
background: $boost;
padding: 1 2;
}
Form .title {
color: $text;
text-align: center;
text-style: bold;
margin-bottom: 2;
width: 100%;
}
Form Button {
width: 100%;
margin: 2 1 0 1;
}
LoadingIndicator {
width: 100%;
height: 3 !important;
margin: 2 1 0 1;
display: none;
}
Form:disabled Button {
display: none;
}
Form:disabled LoadingIndicator {
display: block;
}
"""
class FormField(Container):
DEFAULT_CSS = """
FormField {
layout: horizontal;
height: auto;
width: 80;
margin: 1 0;
}
FormField Input {
border: tall transparent;
}
Form:disabled FormField Label{
opacity:0.5;
}
FormField:focus-within > Label {
color: $text;
}
FormField > Label {
width: 1fr;
margin: 1 2;
text-align: right;
color: $text-muted;
}
FormField .group PasswordStrength {
margin: 0 1 0 1;
color: $text-muted;
}
FormField > Input, FormField > Select {
width: 2fr;
}
FormField Vertical.group {
height: auto;
width: 2fr;
}
FormField Vertical.group Input {
width: 100%;
}
"""
class PasswordStrength(Widget):
DEFAULT_CSS = """
PasswordStrength {
margin: 1;
height: 1;
}
PasswordStrength > .password-strength--highlight {
color: $error;
}
PasswordStrength > .password-strength--back {
color: $foreground 10%;
}
PasswordStrength > .password-strength--success {
color: $success;
}
"""
COMPONENT_CLASSES = {
"password-strength--highlight",
"password-strength--back",
"password-strength--success",
}
password = reactive("")
def render(self) -> RenderableType:
if self.password:
steps = 8
progress = len(self.password) / steps
if progress >= 1:
highlight_style = self.get_component_rich_style(
"password-strength--success"
)
else:
highlight_style = self.get_component_rich_style(
"password-strength--highlight"
)
back_style = self.get_component_rich_style("password-strength--back")
return Bar(
highlight_range=(0, progress * self.size.width),
width=None,
background_style=back_style,
highlight_style=highlight_style,
)
else:
return "Minimum 8 character, no common words"
def slugify(value: str, allow_unicode=False) -> str:
"""
Convert to ASCII if 'allow_unicode' is False. Convert spaces or repeated
dashes to single dashes. Remove characters that aren't alphanumerics,
underscores, or hyphens. Convert to lowercase. Also strip leading and
trailing whitespace, dashes, and underscores.
"""
value = str(value)
if allow_unicode:
value = unicodedata.normalize("NFKC", value)
else:
value = (
unicodedata.normalize("NFKD", value)
.encode("ascii", "ignore")
.decode("ascii")
)
value = re.sub(r"[^\w\s-]", "", value.lower())
return re.sub(r"[-\s]+", "-", value).strip("-_")
class SignupScreen(Screen):
def compose(self) -> ComposeResult:
with Form():
yield Label("Textual-web Signup", classes="title")
with FormField():
yield Label("Your name*")
yield Input(id="name")
with FormField():
yield Label("Account slug*")
yield Input(id="org-name", placeholder="Identifier used in URLs")
with FormField():
yield Label("Email*")
yield Input(id="email")
with FormField():
yield Label("Password*")
with Vertical(classes="group"):
yield Input(password=True, id="password")
yield PasswordStrength()
with FormField():
yield Label("Password (again)")
yield Input(password=True, id="password-check")
yield Button("Signup", variant="primary", id="signup")
yield LoadingIndicator()
@on(Button.Pressed, "#signup")
def signup(self):
self.disabled = True
@on(Input.Changed, "#password")
def input_changed(self, event: Input.Changed):
self.query_one(PasswordStrength).password = event.input.value
@on(events.DescendantBlur, "#password-check")
def password_check(self, event: Input.Changed) -> None:
password = self.query_one("#password", Input).value
if password:
password_check = self.query_one("#password-check", Input).value
if password != password_check:
self.notify("Passwords do not match", severity="error")
@on(events.DescendantFocus, "#org-name")
def update_org_name(self) -> None:
org_name = self.query_one("#org-name", Input).value
if not org_name:
name = self.query_one("#name", Input).value
self.query_one("#org-name", Input).insert_text_at_cursor(slugify(name))
class SignUpApp(App):
CSS_PATH = "signup.tcss"
def on_ready(self) -> None:
self.push_screen(SignupScreen())
if __name__ == "__main__":
app = SignUpApp()
app.run()

View File

@@ -0,0 +1,4 @@
SignupScreen {
align: center middle;
}

122
src/textual_web/cli.py Normal file
View File

@@ -0,0 +1,122 @@
import asyncio
import click
from pathlib import Path
import logging
import os
from rich.panel import Panel
import sys
from . import constants
from . import identity
from .environment import ENVIRONMENTS
from .ganglion_client import GanglionClient
from rich.console import Console
from rich.logging import RichHandler
from rich.text import Text
import uvloop
if constants.DEBUG:
FORMAT = "%(message)s"
logging.basicConfig(
level="DEBUG",
format=FORMAT,
datefmt="[%X]",
handlers=[RichHandler(show_path=False)],
)
else:
FORMAT = "%(message)s"
logging.basicConfig(
level="INFO",
format=FORMAT,
datefmt="[%X]",
handlers=[RichHandler(show_path=False)],
)
log = logging.getLogger("textual-web")
def print_disclaimer() -> None:
"""Print a disclaimer message."""
from rich import print
from rich import box
panel = Panel.fit(
Text.from_markup(
"[b]textual-web is currently under active development, and not suitable for production use.[/b]\n\n"
"For support, please join the [blue][link=https://discord.gg/Enf6Z3qhVr]Discord server[/link]",
),
border_style="red",
box=box.HEAVY,
title="[b]Disclaimer",
padding=(1, 2),
)
print(panel)
@click.command()
@click.option("--config", help="Location of config file")
@click.option(
"-e",
"--environment",
help="Environment (prod, dev, or local)",
type=click.Choice(list(ENVIRONMENTS)),
default=constants.ENVIRONMENT,
)
@click.option("-a", "--api-key", help="API key", default=constants.API_KEY)
@click.option(
"-t", "--terminal", is_flag=True, help="Publish a remote terminal on a random URL"
)
def app(
config: str | None,
environment: str,
terminal: bool,
api_key: str,
) -> None:
error_console = Console(stderr=True)
from .config import load_config, default_config
from .environment import get_environment
_environment = get_environment(environment)
print_disclaimer()
log.info(f"environment={_environment!r}")
if constants.DEBUG:
log.warning("DEBUG env var is set; logs may be verbose!")
if config is not None:
path = Path(config).absolute()
log.info(f"loading config from {str(path)!r}")
try:
_config = load_config(path)
except FileNotFoundError:
log.critical("Config not found")
return
except Exception as error:
error_console.print(f"Failed to load config from {str(path)!r}; {error!r}")
return
else:
log.info("No --config specified, using defaults.")
_config = default_config()
if constants.DEBUG:
from rich import print
print(_config)
ganglion_client = GanglionClient(
config or "./", _config, _environment, api_key=api_key or None
)
if terminal:
ganglion_client.add_terminal(
"Terminal", os.environ.get("SHELL", "bin/sh"), identity.generate().lower()
)
if sys.version_info >= (3, 11):
with asyncio.Runner(loop_factory=uvloop.new_event_loop) as runner:
runner.run(ganglion_client.run())
else:
uvloop.install()
asyncio.run(ganglion_client.run())

45
src/textual_web/config.py Normal file
View File

@@ -0,0 +1,45 @@
from os.path import expandvars
from typing_extensions import Annotated
from pathlib import Path
import tomllib
from pydantic import BaseModel, Field
from pydantic.functional_validators import AfterValidator
ExpandVarsStr = Annotated[str, AfterValidator(expandvars)]
class Account(BaseModel):
domain: str | None = None
api_key: str | None = None
class App(BaseModel):
name: str
slug: str
color: str = ""
path: ExpandVarsStr
command: ExpandVarsStr = ""
terminal: bool = False
class Config(BaseModel):
account: Account
apps: list[App] = Field(default_factory=list)
def default_config() -> Config:
"""Get a default config."""
return Config(account=Account())
def load_config(config_path: Path) -> Config:
"""Load config from path."""
with Path(config_path).open("rb") as config_file:
config_data = tomllib.load(config_file)
config = Config(**config_data)
return config

View File

@@ -0,0 +1,53 @@
"""
Constants that we might want to expose via the public API.
"""
from __future__ import annotations
import os
from typing_extensions import Final
get_environ = os.environ.get
def get_environ_bool(name: str) -> bool:
"""Check an environment variable switch.
Args:
name: Name of environment variable.
Returns:
`True` if the env var is "1", otherwise `False`.
"""
has_environ = get_environ(name) == "1"
return has_environ
def get_environ_int(name: str, default: int) -> int:
"""Retrieves an integer environment variable.
Args:
name: Name of environment variable.
default: The value to use if the value is not set, or set to something other
than a valid integer.
Returns:
The integer associated with the environment variable if it's set to a valid int
or the default value otherwise.
"""
try:
return int(os.environ[name])
except KeyError:
return default
except ValueError:
return default
DEBUG: Final = get_environ_bool("DEBUG")
"""Enable debug mode."""
ENVIRONMENT: Final[str] = get_environ("GANGLION_ENVIRONMENT", "dev")
"""Select alternative environment."""
API_KEY: Final[str] = get_environ("GANGLION_API_KEY", "")

View File

@@ -0,0 +1,36 @@
from __future__ import annotations
from dataclasses import dataclass
@dataclass
class Environment:
"""Data structure to describe the environment (dev, prod, local)."""
name: str
url: str
ENVIRONMENTS = {
# "prod": Environment(
# name="prod",
# url="wss://textualize.io/app-service/",
# ),
"local": Environment(
name="local",
url="ws://127.0.0.1:8080/app-service/",
),
"dev": Environment(
name="dev",
url="wss://textualize-dev.io/app-service/",
),
}
def get_environment(environment: str) -> Environment:
"""Get an Environment instance for the given environment name."""
try:
run_environment = ENVIRONMENTS[environment]
except KeyError:
raise RuntimeError(f"Invalid environment {environment!r}")
return run_environment

View File

@@ -0,0 +1,339 @@
from __future__ import annotations
import asyncio
from typing import TYPE_CHECKING, cast
from functools import partial
import aiohttp
import logging
import msgpack
import signal
from pathlib import Path
from .packets import (
NotifyTerminalSize,
SessionClose,
SessionData,
RoutePing,
RoutePong,
)
from .environment import Environment
from .session import SessionConnector
from .session_manager import SessionManager
from .types import RouteKey, SessionID, Meta
from . import packets
from . import constants
from .packets import Packet, Handlers, PACKET_MAP
from .retry import Retry
from .terminal_session import Poller
if TYPE_CHECKING:
from .config import Config
log = logging.getLogger("textual-web")
PacketDataType = int | bytes | str | None
class PacketError(Exception):
"""A packet error."""
class _ClientConnector(SessionConnector):
def __init__(
self, client: GanglionClient, session_id: SessionID, route_key: RouteKey
) -> None:
self.client = client
self.session_id = session_id
self.route_key = route_key
async def on_data(self, data: bytes) -> None:
"""Data received from the process."""
await self.client.send(packets.SessionData(self.route_key, data))
async def on_meta(self, meta: Meta) -> None:
pass
async def on_close(self) -> None:
await self.client.send(packets.SessionClose(self.session_id, self.route_key))
self.client.session_manager.on_session_end(self.session_id)
class GanglionClient(Handlers):
"""Manages a connection to a ganglion server."""
def __init__(
self,
config_path: str,
config: Config,
environment: Environment,
api_key: str | None,
) -> None:
self.environment = environment
self.websocket_url = environment.url
path = Path(config_path).absolute().parent
self.config = config
self.api_key = api_key
self._websocket: aiohttp.ClientWebSocketResponse | None = None
self._poll_reader = Poller()
self.session_manager = SessionManager(self._poll_reader, path, config.apps)
self.exit_event = asyncio.Event()
self._task: asyncio.Task | None = None
def add_app(self, name: str, command: str, slug: str = "") -> None:
"""Add a new app
Args:
name: Name of the app.
command: Command to run the app.
slug: Slug used in URL, or blank to auto-generate on server.
"""
self.session_manager.add_app(name, command, slug=slug)
def add_terminal(self, name: str, command: str, slug: str = "") -> None:
"""Add a new terminal.
Args:
name: Name of the app.
command: Command to run the app.
slug: Slug used in URL, or blank to auto-generate on server.
"""
self.session_manager.add_app(name, command, slug=slug, terminal=True)
@classmethod
def decode_envelope(
cls, packet_envelope: tuple[PacketDataType, ...]
) -> Packet | None:
"""Decode a packet envelope.
Packet envelopes are a list where the first value is an integer denoting the type.
The type is used to look up the appropriate Packet class which is instantiated with
the rest of the data.
If the envelope contains *more* data than required, then that data is silently dropped.
This is to provide an extension mechanism.
Raises:
PacketError: If the packet_envelope is empty.
PacketError: If the packet type is not an int.
Returns:
One of the Packet classes defined in packets.py or None if the packet was of an unknown type.
"""
if not packet_envelope:
raise PacketError("Packet data is empty")
packet_data: list[PacketDataType]
packet_type, *packet_data = packet_envelope
if not isinstance(packet_type, int):
raise PacketError(f"Packet id expected int, found {packet_type!r}")
packet_class = PACKET_MAP.get(packet_type, None)
if packet_class is None:
return None
try:
packet = packet_class.build(*packet_data[: len(packet_class._attributes)])
except TypeError as error:
raise PacketError(f"Packet failed to validate; {error}")
return packet
async def run(self) -> None:
"""Run the connection loop."""
try:
await self._run()
finally:
# Shut down the poller thread
self._poll_reader.exit()
def on_keyboard_interrupt(self) -> None:
"""Signal handler to respond to keyboard interrupt."""
print(
"\r\033[F"
) # Move to start of line, to overwrite "^C" written by the shell (?)
log.info("Exit requested")
self.exit_event.set()
if self._task is not None:
self._task.cancel()
async def _run(self) -> None:
loop = asyncio.get_event_loop()
loop.add_signal_handler(signal.SIGINT, self.on_keyboard_interrupt)
self._poll_reader.set_loop(loop)
self._poll_reader.start()
self._task = asyncio.create_task(self.connect())
await self._task
async def connect(self) -> None:
"""Connect to the Ganglion server."""
try:
await self._connect()
except asyncio.CancelledError:
pass
async def _connect(self) -> None:
"""Internal connect."""
api_key = self.config.account.api_key or self.api_key or None
if api_key:
headers = {"GANGLIONAPIKEY": api_key}
else:
headers = {}
retry = Retry()
async for retry_count in retry:
if self.exit_event.is_set():
break
try:
if retry_count == 1:
log.info("connecting to Ganglion")
async with aiohttp.ClientSession() as session:
async with session.ws_connect(
self.websocket_url, headers=headers
) as websocket:
self._websocket = websocket
retry.success()
await self.post_connect()
try:
await self.run_websocket(websocket, retry)
finally:
self._websocket = None
log.info("Disconnected from Ganglion")
if self.exit_event.is_set():
break
except asyncio.CancelledError:
raise
except Exception as error:
if retry_count == 1:
log.warning(
"Unable to connect to Ganglion server. Will reattempt connection soon."
)
if constants.DEBUG:
log.error("Unable to connect; %s", error)
async def run_websocket(
self, websocket: aiohttp.ClientWebSocketResponse, retry: Retry
) -> None:
"""Run the websocket loop.
Args:
websocket: Websocket.
"""
unpackb = partial(msgpack.unpackb, use_list=True, raw=False)
BINARY = aiohttp.WSMsgType.BINARY
async def run_messages() -> None:
"""Read, decode, and dispatch websocket messages."""
async for message in websocket:
if message.type == BINARY:
try:
envelope = unpackb(message.data)
except Exception:
log.error(f"Unable to decode {message.data!r}")
else:
packet = self.decode_envelope(envelope)
log.debug("<RECV> %r", packet)
if packet is not None:
try:
await self.dispatch_packet(packet)
except Exception:
log.exception("error processing %r", packet)
elif message.type == aiohttp.WSMsgType.ERROR:
break
try:
await run_messages()
except asyncio.CancelledError:
retry.done()
await self.session_manager.close_all()
await websocket.close(message=b"Close requested")
try:
await run_messages()
except asyncio.CancelledError:
pass
except ConnectionResetError:
log.info("connection reset")
except Exception as error:
log.exception(str(error))
async def post_connect(self) -> None:
"""Called immediately after connecting to server."""
# Inform the server about our apps
apps = [
app.model_dump(include={"name", "slug", "color", "terminal"})
for app in self.config.apps
]
await self.send(packets.DeclareApps(apps))
async def send(self, packet: Packet) -> bool:
"""Send a packet.
Args:
packet: Packet to send.
Returns:
bool: `True` if the packet was sent, otherwise `False`.
"""
if self._websocket is None:
log.warning("Failed to send %r", packet)
return False
packet_bytes = msgpack.packb(packet, use_bin_type=True)
try:
await self._websocket.send_bytes(packet_bytes)
except Exception as error:
log.warning("Failed to send %r; %s", packet, error)
return False
else:
log.debug("<SEND> %r", packet)
return True
async def on_ping(self, packet: packets.Ping) -> None:
"""Sent by the server."""
# Reply to a Ping with an immediate Pong.
await self.send(packets.Pong(packet.data))
async def on_log(self, packet: packets.Log) -> None:
"""A log message sent by the server."""
log.debug(f"<ganglion> {packet.message}")
async def on_info(self, packet: packets.Info) -> None:
"""An info message (higher priority log) sent by the server."""
log.info(f"<ganglion> {packet.message}")
async def on_session_open(self, packet: packets.SessionOpen) -> None:
route_key = packet.route_key
session_process = await self.session_manager.new_session(
packet.application_slug,
SessionID(packet.session_id),
RouteKey(packet.route_key),
)
assert session_process is not None # TODO: handle session open failed
connector = _ClientConnector(
self, cast(SessionID, packet.session_id), cast(RouteKey, route_key)
)
await session_process.start(connector)
async def on_session_close(self, packet: SessionClose) -> None:
session_id = SessionID(packet.session_id)
session_process = self.session_manager.get_session(session_id)
await self.session_manager.close_session(session_id)
async def on_session_data(self, packet: SessionData) -> None:
session_process = self.session_manager.get_session_by_route_key(
RouteKey(packet.route_key)
)
if session_process is not None:
await session_process.send_bytes(packet.data)
async def on_notify_terminal_size(self, packet: NotifyTerminalSize) -> None:
session_process = self.session_manager.get_session(SessionID(packet.session_id))
if session_process is not None:
await session_process.set_terminal_size(packet.width, packet.height)
async def on_route_ping(self, packet: RoutePing) -> None:
await self.send(RoutePong(packet.route_key, packet.data))

View File

@@ -0,0 +1,11 @@
import os
SEPARATOR = "-"
IDENTITY_ALPHABET = "0123456789ABCDEFGHJKMNPQRSTUVWYZ"
IDENTITY_SIZE = 12
def generate(size: int = IDENTITY_SIZE) -> str:
"""Generate a random identifier."""
alphabet = IDENTITY_ALPHABET
return "".join(alphabet[byte % 31] for byte in os.urandom(size))

821
src/textual_web/packets.py Normal file
View File

@@ -0,0 +1,821 @@
"""
This file is auto-generated from packets.yml and packets.py.template
Time: Thu Jul 27 15:47:42 2023
Version: 1
To regenerate run `make packets.py` (in src directory)
**Do not hand edit.**
"""
from __future__ import annotations
from enum import IntEnum
from operator import attrgetter
from typing import ClassVar, Type
import rich.repr
MAX_STRING = 20
def abbreviate_repr(input: object) -> str:
"""Abbreviate any long strings."""
if isinstance(input, (bytes, str)) and len(input) > MAX_STRING:
cropped = len(input) - MAX_STRING
return f"{input[:MAX_STRING]!r}+{cropped}"
return repr(input)
class PacketType(IntEnum):
"""Enumeration of packet types."""
# A null packet (never sent).
NULL = 0
# Request packet data to be returned via a Pong.
PING = 1 # See Ping()
# Response to a Ping packet. The data from Ping should be sent back in the Pong.
PONG = 2 # See Pong()
# A message to be written to debug logs. This is a debugging aid, and will be disabled in production.
LOG = 3 # See Log()
# Info message to be written in to logs. Unlike Log, these messages will be used in production.
INFO = 4 # See Info()
# Declare the apps exposed.
DECLARE_APPS = 5 # See DeclareApps()
# Notification sent by a client when an app session was opened
SESSION_OPEN = 6 # See SessionOpen()
# Close an existing app session.
SESSION_CLOSE = 7 # See SessionClose()
# Data for a session.
SESSION_DATA = 8 # See SessionData()
# Session ping
ROUTE_PING = 9 # See RoutePing()
# Session pong
ROUTE_PONG = 10 # See RoutePong()
# Notify the client that the terminal has change dimensions.
NOTIFY_TERMINAL_SIZE = 11 # See NotifyTerminalSize()
class Packet(tuple):
"""Base class for a packet.
Should never be sent. Use one of the derived classes.
"""
sender: ClassVar[str] = "both"
handler_name: ClassVar[str] = ""
type: ClassVar[PacketType] = PacketType.NULL
_attributes: ClassVar[list[tuple[str, Type]]] = []
_attribute_count = 0
_get_handler = attrgetter("foo")
# PacketType.PING (1)
class Ping(Packet):
"""Request packet data to be returned via a Pong.
Args:
data (bytes): Opaque data.
"""
sender: ClassVar[str] = "both"
"""Permitted sender, should be "client", "server", or "both"."""
handler_name: ClassVar[str] = "on_ping"
"""Name of the method used to handle this packet."""
type: ClassVar[PacketType] = PacketType.PING
"""The packet type enumeration."""
_attributes: ClassVar[list[tuple[str, Type]]] = [
("data", bytes),
]
_attribute_count = 1
_get_handler = attrgetter("on_ping")
def __new__(cls, data: bytes) -> "Ping":
return tuple.__new__(cls, (PacketType.PING, data))
@classmethod
def build(cls, data: bytes) -> "Ping":
"""Build and validate a packet from its attributes."""
if not isinstance(data, bytes):
raise TypeError(
f'packets.Ping Type of "data" incorrect; expected bytes, found {type(data)}'
)
return tuple.__new__(cls, (PacketType.PING, data))
def __repr__(self) -> str:
_type, data = self
return f"Ping({abbreviate_repr(data)})"
def __rich_repr__(self) -> rich.repr.Result:
yield "data", self.data
@property
def data(self) -> bytes:
"""Opaque data."""
return self[1]
# PacketType.PONG (2)
class Pong(Packet):
"""Response to a Ping packet. The data from Ping should be sent back in the Pong.
Args:
data (bytes): Data received from PING
"""
sender: ClassVar[str] = "both"
"""Permitted sender, should be "client", "server", or "both"."""
handler_name: ClassVar[str] = "on_pong"
"""Name of the method used to handle this packet."""
type: ClassVar[PacketType] = PacketType.PONG
"""The packet type enumeration."""
_attributes: ClassVar[list[tuple[str, Type]]] = [
("data", bytes),
]
_attribute_count = 1
_get_handler = attrgetter("on_pong")
def __new__(cls, data: bytes) -> "Pong":
return tuple.__new__(cls, (PacketType.PONG, data))
@classmethod
def build(cls, data: bytes) -> "Pong":
"""Build and validate a packet from its attributes."""
if not isinstance(data, bytes):
raise TypeError(
f'packets.Pong Type of "data" incorrect; expected bytes, found {type(data)}'
)
return tuple.__new__(cls, (PacketType.PONG, data))
def __repr__(self) -> str:
_type, data = self
return f"Pong({abbreviate_repr(data)})"
def __rich_repr__(self) -> rich.repr.Result:
yield "data", self.data
@property
def data(self) -> bytes:
"""Data received from PING"""
return self[1]
# PacketType.LOG (3)
class Log(Packet):
"""A message to be written to debug logs. This is a debugging aid, and will be disabled in production.
Args:
message (str): Message to log.
"""
sender: ClassVar[str] = "both"
"""Permitted sender, should be "client", "server", or "both"."""
handler_name: ClassVar[str] = "on_log"
"""Name of the method used to handle this packet."""
type: ClassVar[PacketType] = PacketType.LOG
"""The packet type enumeration."""
_attributes: ClassVar[list[tuple[str, Type]]] = [
("message", str),
]
_attribute_count = 1
_get_handler = attrgetter("on_log")
def __new__(cls, message: str) -> "Log":
return tuple.__new__(cls, (PacketType.LOG, message))
@classmethod
def build(cls, message: str) -> "Log":
"""Build and validate a packet from its attributes."""
if not isinstance(message, str):
raise TypeError(
f'packets.Log Type of "message" incorrect; expected str, found {type(message)}'
)
return tuple.__new__(cls, (PacketType.LOG, message))
def __repr__(self) -> str:
_type, message = self
return f"Log({abbreviate_repr(message)})"
def __rich_repr__(self) -> rich.repr.Result:
yield "message", self.message
@property
def message(self) -> str:
"""Message to log."""
return self[1]
# PacketType.INFO (4)
class Info(Packet):
"""Info message to be written in to logs. Unlike Log, these messages will be used in production.
Args:
message (str): Info message
"""
sender: ClassVar[str] = "server"
"""Permitted sender, should be "client", "server", or "both"."""
handler_name: ClassVar[str] = "on_info"
"""Name of the method used to handle this packet."""
type: ClassVar[PacketType] = PacketType.INFO
"""The packet type enumeration."""
_attributes: ClassVar[list[tuple[str, Type]]] = [
("message", str),
]
_attribute_count = 1
_get_handler = attrgetter("on_info")
def __new__(cls, message: str) -> "Info":
return tuple.__new__(cls, (PacketType.INFO, message))
@classmethod
def build(cls, message: str) -> "Info":
"""Build and validate a packet from its attributes."""
if not isinstance(message, str):
raise TypeError(
f'packets.Info Type of "message" incorrect; expected str, found {type(message)}'
)
return tuple.__new__(cls, (PacketType.INFO, message))
def __repr__(self) -> str:
_type, message = self
return f"Info({abbreviate_repr(message)})"
def __rich_repr__(self) -> rich.repr.Result:
yield "message", self.message
@property
def message(self) -> str:
"""Info message"""
return self[1]
# PacketType.DECLARE_APPS (5)
class DeclareApps(Packet):
"""Declare the apps exposed.
Args:
apps (list): Apps served by this client.
"""
sender: ClassVar[str] = "client"
"""Permitted sender, should be "client", "server", or "both"."""
handler_name: ClassVar[str] = "on_declare_apps"
"""Name of the method used to handle this packet."""
type: ClassVar[PacketType] = PacketType.DECLARE_APPS
"""The packet type enumeration."""
_attributes: ClassVar[list[tuple[str, Type]]] = [
("apps", list),
]
_attribute_count = 1
_get_handler = attrgetter("on_declare_apps")
def __new__(cls, apps: list) -> "DeclareApps":
return tuple.__new__(cls, (PacketType.DECLARE_APPS, apps))
@classmethod
def build(cls, apps: list) -> "DeclareApps":
"""Build and validate a packet from its attributes."""
if not isinstance(apps, list):
raise TypeError(
f'packets.DeclareApps Type of "apps" incorrect; expected list, found {type(apps)}'
)
return tuple.__new__(cls, (PacketType.DECLARE_APPS, apps))
def __repr__(self) -> str:
_type, apps = self
return f"DeclareApps({abbreviate_repr(apps)})"
def __rich_repr__(self) -> rich.repr.Result:
yield "apps", self.apps
@property
def apps(self) -> list:
"""Apps served by this client."""
return self[1]
# PacketType.SESSION_OPEN (6)
class SessionOpen(Packet):
"""Notification sent by a client when an app session was opened
Args:
session_id (str): Session ID
app_id (str): Application identity.
application_slug (str): Application slug.
route_key (str): Route key
"""
sender: ClassVar[str] = "server"
"""Permitted sender, should be "client", "server", or "both"."""
handler_name: ClassVar[str] = "on_session_open"
"""Name of the method used to handle this packet."""
type: ClassVar[PacketType] = PacketType.SESSION_OPEN
"""The packet type enumeration."""
_attributes: ClassVar[list[tuple[str, Type]]] = [
("session_id", str),
("app_id", str),
("application_slug", str),
("route_key", str),
]
_attribute_count = 4
_get_handler = attrgetter("on_session_open")
def __new__(
cls, session_id: str, app_id: str, application_slug: str, route_key: str
) -> "SessionOpen":
return tuple.__new__(
cls,
(PacketType.SESSION_OPEN, session_id, app_id, application_slug, route_key),
)
@classmethod
def build(
cls, session_id: str, app_id: str, application_slug: str, route_key: str
) -> "SessionOpen":
"""Build and validate a packet from its attributes."""
if not isinstance(session_id, str):
raise TypeError(
f'packets.SessionOpen Type of "session_id" incorrect; expected str, found {type(session_id)}'
)
if not isinstance(app_id, str):
raise TypeError(
f'packets.SessionOpen Type of "app_id" incorrect; expected str, found {type(app_id)}'
)
if not isinstance(application_slug, str):
raise TypeError(
f'packets.SessionOpen Type of "application_slug" incorrect; expected str, found {type(application_slug)}'
)
if not isinstance(route_key, str):
raise TypeError(
f'packets.SessionOpen Type of "route_key" incorrect; expected str, found {type(route_key)}'
)
return tuple.__new__(
cls,
(PacketType.SESSION_OPEN, session_id, app_id, application_slug, route_key),
)
def __repr__(self) -> str:
_type, session_id, app_id, application_slug, route_key = self
return f"SessionOpen({abbreviate_repr(session_id)}, {abbreviate_repr(app_id)}, {abbreviate_repr(application_slug)}, {abbreviate_repr(route_key)})"
def __rich_repr__(self) -> rich.repr.Result:
yield "session_id", self.session_id
yield "app_id", self.app_id
yield "application_slug", self.application_slug
yield "route_key", self.route_key
@property
def session_id(self) -> str:
"""Session ID"""
return self[1]
@property
def app_id(self) -> str:
"""Application identity."""
return self[2]
@property
def application_slug(self) -> str:
"""Application slug."""
return self[3]
@property
def route_key(self) -> str:
"""Route key"""
return self[4]
# PacketType.SESSION_CLOSE (7)
class SessionClose(Packet):
"""Close an existing app session.
Args:
session_id (str): Session identity
route_key (str): Route key
"""
sender: ClassVar[str] = "server"
"""Permitted sender, should be "client", "server", or "both"."""
handler_name: ClassVar[str] = "on_session_close"
"""Name of the method used to handle this packet."""
type: ClassVar[PacketType] = PacketType.SESSION_CLOSE
"""The packet type enumeration."""
_attributes: ClassVar[list[tuple[str, Type]]] = [
("session_id", str),
("route_key", str),
]
_attribute_count = 2
_get_handler = attrgetter("on_session_close")
def __new__(cls, session_id: str, route_key: str) -> "SessionClose":
return tuple.__new__(cls, (PacketType.SESSION_CLOSE, session_id, route_key))
@classmethod
def build(cls, session_id: str, route_key: str) -> "SessionClose":
"""Build and validate a packet from its attributes."""
if not isinstance(session_id, str):
raise TypeError(
f'packets.SessionClose Type of "session_id" incorrect; expected str, found {type(session_id)}'
)
if not isinstance(route_key, str):
raise TypeError(
f'packets.SessionClose Type of "route_key" incorrect; expected str, found {type(route_key)}'
)
return tuple.__new__(cls, (PacketType.SESSION_CLOSE, session_id, route_key))
def __repr__(self) -> str:
_type, session_id, route_key = self
return (
f"SessionClose({abbreviate_repr(session_id)}, {abbreviate_repr(route_key)})"
)
def __rich_repr__(self) -> rich.repr.Result:
yield "session_id", self.session_id
yield "route_key", self.route_key
@property
def session_id(self) -> str:
"""Session identity"""
return self[1]
@property
def route_key(self) -> str:
"""Route key"""
return self[2]
# PacketType.SESSION_DATA (8)
class SessionData(Packet):
"""Data for a session.
Args:
route_key (str): Route index.
data (bytes): Data for a remote app
"""
sender: ClassVar[str] = "both"
"""Permitted sender, should be "client", "server", or "both"."""
handler_name: ClassVar[str] = "on_session_data"
"""Name of the method used to handle this packet."""
type: ClassVar[PacketType] = PacketType.SESSION_DATA
"""The packet type enumeration."""
_attributes: ClassVar[list[tuple[str, Type]]] = [
("route_key", str),
("data", bytes),
]
_attribute_count = 2
_get_handler = attrgetter("on_session_data")
def __new__(cls, route_key: str, data: bytes) -> "SessionData":
return tuple.__new__(cls, (PacketType.SESSION_DATA, route_key, data))
@classmethod
def build(cls, route_key: str, data: bytes) -> "SessionData":
"""Build and validate a packet from its attributes."""
if not isinstance(route_key, str):
raise TypeError(
f'packets.SessionData Type of "route_key" incorrect; expected str, found {type(route_key)}'
)
if not isinstance(data, bytes):
raise TypeError(
f'packets.SessionData Type of "data" incorrect; expected bytes, found {type(data)}'
)
return tuple.__new__(cls, (PacketType.SESSION_DATA, route_key, data))
def __repr__(self) -> str:
_type, route_key, data = self
return f"SessionData({abbreviate_repr(route_key)}, {abbreviate_repr(data)})"
def __rich_repr__(self) -> rich.repr.Result:
yield "route_key", self.route_key
yield "data", self.data
@property
def route_key(self) -> str:
"""Route index."""
return self[1]
@property
def data(self) -> bytes:
"""Data for a remote app"""
return self[2]
# PacketType.ROUTE_PING (9)
class RoutePing(Packet):
"""Session ping
Args:
route_key (str): Route index.
data (str): Opaque data.
"""
sender: ClassVar[str] = "server"
"""Permitted sender, should be "client", "server", or "both"."""
handler_name: ClassVar[str] = "on_route_ping"
"""Name of the method used to handle this packet."""
type: ClassVar[PacketType] = PacketType.ROUTE_PING
"""The packet type enumeration."""
_attributes: ClassVar[list[tuple[str, Type]]] = [
("route_key", str),
("data", str),
]
_attribute_count = 2
_get_handler = attrgetter("on_route_ping")
def __new__(cls, route_key: str, data: str) -> "RoutePing":
return tuple.__new__(cls, (PacketType.ROUTE_PING, route_key, data))
@classmethod
def build(cls, route_key: str, data: str) -> "RoutePing":
"""Build and validate a packet from its attributes."""
if not isinstance(route_key, str):
raise TypeError(
f'packets.RoutePing Type of "route_key" incorrect; expected str, found {type(route_key)}'
)
if not isinstance(data, str):
raise TypeError(
f'packets.RoutePing Type of "data" incorrect; expected str, found {type(data)}'
)
return tuple.__new__(cls, (PacketType.ROUTE_PING, route_key, data))
def __repr__(self) -> str:
_type, route_key, data = self
return f"RoutePing({abbreviate_repr(route_key)}, {abbreviate_repr(data)})"
def __rich_repr__(self) -> rich.repr.Result:
yield "route_key", self.route_key
yield "data", self.data
@property
def route_key(self) -> str:
"""Route index."""
return self[1]
@property
def data(self) -> str:
"""Opaque data."""
return self[2]
# PacketType.ROUTE_PONG (10)
class RoutePong(Packet):
"""Session pong
Args:
route_key (str): Route index.
data (str): Opaque data.
"""
sender: ClassVar[str] = "both"
"""Permitted sender, should be "client", "server", or "both"."""
handler_name: ClassVar[str] = "on_route_pong"
"""Name of the method used to handle this packet."""
type: ClassVar[PacketType] = PacketType.ROUTE_PONG
"""The packet type enumeration."""
_attributes: ClassVar[list[tuple[str, Type]]] = [
("route_key", str),
("data", str),
]
_attribute_count = 2
_get_handler = attrgetter("on_route_pong")
def __new__(cls, route_key: str, data: str) -> "RoutePong":
return tuple.__new__(cls, (PacketType.ROUTE_PONG, route_key, data))
@classmethod
def build(cls, route_key: str, data: str) -> "RoutePong":
"""Build and validate a packet from its attributes."""
if not isinstance(route_key, str):
raise TypeError(
f'packets.RoutePong Type of "route_key" incorrect; expected str, found {type(route_key)}'
)
if not isinstance(data, str):
raise TypeError(
f'packets.RoutePong Type of "data" incorrect; expected str, found {type(data)}'
)
return tuple.__new__(cls, (PacketType.ROUTE_PONG, route_key, data))
def __repr__(self) -> str:
_type, route_key, data = self
return f"RoutePong({abbreviate_repr(route_key)}, {abbreviate_repr(data)})"
def __rich_repr__(self) -> rich.repr.Result:
yield "route_key", self.route_key
yield "data", self.data
@property
def route_key(self) -> str:
"""Route index."""
return self[1]
@property
def data(self) -> str:
"""Opaque data."""
return self[2]
# PacketType.NOTIFY_TERMINAL_SIZE (11)
class NotifyTerminalSize(Packet):
"""Notify the client that the terminal has change dimensions.
Args:
session_id (str): Session identity.
width (int): Width of the terminal.
height (int): Height of the terminal.
"""
sender: ClassVar[str] = "server"
"""Permitted sender, should be "client", "server", or "both"."""
handler_name: ClassVar[str] = "on_notify_terminal_size"
"""Name of the method used to handle this packet."""
type: ClassVar[PacketType] = PacketType.NOTIFY_TERMINAL_SIZE
"""The packet type enumeration."""
_attributes: ClassVar[list[tuple[str, Type]]] = [
("session_id", str),
("width", int),
("height", int),
]
_attribute_count = 3
_get_handler = attrgetter("on_notify_terminal_size")
def __new__(cls, session_id: str, width: int, height: int) -> "NotifyTerminalSize":
return tuple.__new__(
cls, (PacketType.NOTIFY_TERMINAL_SIZE, session_id, width, height)
)
@classmethod
def build(cls, session_id: str, width: int, height: int) -> "NotifyTerminalSize":
"""Build and validate a packet from its attributes."""
if not isinstance(session_id, str):
raise TypeError(
f'packets.NotifyTerminalSize Type of "session_id" incorrect; expected str, found {type(session_id)}'
)
if not isinstance(width, int):
raise TypeError(
f'packets.NotifyTerminalSize Type of "width" incorrect; expected int, found {type(width)}'
)
if not isinstance(height, int):
raise TypeError(
f'packets.NotifyTerminalSize Type of "height" incorrect; expected int, found {type(height)}'
)
return tuple.__new__(
cls, (PacketType.NOTIFY_TERMINAL_SIZE, session_id, width, height)
)
def __repr__(self) -> str:
_type, session_id, width, height = self
return f"NotifyTerminalSize({abbreviate_repr(session_id)}, {abbreviate_repr(width)}, {abbreviate_repr(height)})"
def __rich_repr__(self) -> rich.repr.Result:
yield "session_id", self.session_id
yield "width", self.width
yield "height", self.height
@property
def session_id(self) -> str:
"""Session identity."""
return self[1]
@property
def width(self) -> int:
"""Width of the terminal."""
return self[2]
@property
def height(self) -> int:
"""Height of the terminal."""
return self[3]
# A mapping of the packet id on to the packet class
PACKET_MAP: dict[int, type[Packet]] = {
1: Ping,
2: Pong,
3: Log,
4: Info,
5: DeclareApps,
6: SessionOpen,
7: SessionClose,
8: SessionData,
9: RoutePing,
10: RoutePong,
11: NotifyTerminalSize,
}
# A mapping of the packet name on to the packet class
PACKET_NAME_MAP: dict[str, type[Packet]] = {
"ping": Ping,
"pong": Pong,
"log": Log,
"info": Info,
"declareapps": DeclareApps,
"sessionopen": SessionOpen,
"sessionclose": SessionClose,
"sessiondata": SessionData,
"routeping": RoutePing,
"routepong": RoutePong,
"notifyterminalsize": NotifyTerminalSize,
}
class Handlers:
"""Base class for handlers."""
async def dispatch_packet(self, packet: Packet) -> None:
"""Dispatch a packet to the appropriate handler.
Args:
packet (Packet): A packet object.
"""
await packet._get_handler(self)(packet)
async def on_ping(self, packet: Ping) -> None:
"""Request packet data to be returned via a Pong."""
await self.on_default(packet)
async def on_pong(self, packet: Pong) -> None:
"""Response to a Ping packet. The data from Ping should be sent back in the Pong."""
await self.on_default(packet)
async def on_log(self, packet: Log) -> None:
"""A message to be written to debug logs. This is a debugging aid, and will be disabled in production."""
await self.on_default(packet)
async def on_info(self, packet: Info) -> None:
"""Info message to be written in to logs. Unlike Log, these messages will be used in production."""
await self.on_default(packet)
async def on_declare_apps(self, packet: DeclareApps) -> None:
"""Declare the apps exposed."""
await self.on_default(packet)
async def on_session_open(self, packet: SessionOpen) -> None:
"""Notification sent by a client when an app session was opened"""
await self.on_default(packet)
async def on_session_close(self, packet: SessionClose) -> None:
"""Close an existing app session."""
await self.on_default(packet)
async def on_session_data(self, packet: SessionData) -> None:
"""Data for a session."""
await self.on_default(packet)
async def on_route_ping(self, packet: RoutePing) -> None:
"""Session ping"""
await self.on_default(packet)
async def on_route_pong(self, packet: RoutePong) -> None:
"""Session pong"""
await self.on_default(packet)
async def on_notify_terminal_size(self, packet: NotifyTerminalSize) -> None:
"""Notify the client that the terminal has change dimensions."""
await self.on_default(packet)
async def on_default(self, packet: Packet) -> None:
"""Called when a packet is not handled."""
if __name__ == "__main__":
print("packets.py imported successfully")

53
src/textual_web/retry.py Normal file
View File

@@ -0,0 +1,53 @@
from __future__ import annotations
from typing import AsyncGenerator
from asyncio import Event, timeout, TimeoutError
from random import random
import logging
log = logging.getLogger("textual-web")
class Retry:
"""Manage exponential backoff."""
def __init__(
self,
done_event: Event | None = None,
min_wait: float = 2.0,
max_wait: float = 16.0,
) -> None:
"""
Args:
done_event: An event to exit the retry loop.
min_wait: Minimum delay in seconds.
max_wait: Maximum delay in seconds.
"""
self.min_wait = min_wait
self.max_wait = max_wait
self._done_event = Event() if done_event is None else done_event
self.retry_count = 0
def success(self) -> None:
"""Called when connection was successful."""
self.retry_count = 0
def done(self) -> None:
"""Exit retry loop."""
self._done_event.set()
async def __aiter__(self) -> AsyncGenerator[int, object]:
while not self._done_event.is_set():
self.retry_count = self.retry_count + 1
yield self.retry_count
retry_squared = self.retry_count**2
sleep_for = random() * max(self.min_wait, min(self.max_wait, retry_squared))
log.debug("Retrying after %dms", int(sleep_for * 1000.0))
try:
async with timeout(sleep_for):
await self._done_event.wait()
except TimeoutError:
pass

View File

@@ -0,0 +1,85 @@
from __future__ import annotations
from abc import ABC, abstractmethod
import asyncio
from .types import Meta
class SessionConnector:
"""Connect a session with a client."""
async def on_data(self, data: bytes) -> None:
"""Handle data from session.
Args:
data: Bytes to handle.
"""
async def on_meta(self, meta: Meta) -> None:
"""Handle meta from session.
Args:
meta: Mapping of meta information.
"""
async def on_close(self) -> None:
"""Handle session close."""
class Session(ABC):
"""Virtual base class for a session."""
def __init__(self) -> None:
self._connector = SessionConnector()
@abstractmethod
async def open(self) -> None:
"""Open the session."""
...
@abstractmethod
async def start(self, connector: SessionConnector) -> asyncio.Task:
"""Start the session.
Returns:
Running task.
"""
...
@abstractmethod
async def close(self) -> None:
"""Close the session"""
@abstractmethod
async def set_terminal_size(self, width: int, height: int) -> None:
"""Set the terminal size.
Args:
width: New width.
height: New height.
"""
...
@abstractmethod
async def send_bytes(self, data: bytes) -> bool:
"""Send bytes to the process.
Args:
data: Bytes to send.
Returns:
True on success, or False if the data was not sent.
"""
...
@abstractmethod
async def send_meta(self, data: Meta) -> bool:
"""Send meta to the process.
Args:
meta: Meta information.
Returns:
True on success, or False if the data was not sent.
"""
...

View File

@@ -0,0 +1,10 @@
class SessionConnector:
async def on_data(self, data: bytes) -> None:
"""Data received from the process."""
await self.send(packets.SessionData(route_key, data))
async def on_meta(self, meta: dict) -> None:
pass
async def on_close(self) -> None:
await self.send(packets.SessionClose(packet.session_id, route_key))

View File

@@ -0,0 +1,18 @@
from __future__ import annotations
from typing_extensions import Protocol
from .types import Meta, RouteKey
class SessionHandler:
def __init__(self, route_key: RouteKey) -> None:
self.route_key = route_key
async def on_data(self, data: bytes) -> None:
pass
async def on_meta(self, meta: Meta) -> None:
pass
async def on_close(self) -> None:
pass

View File

@@ -0,0 +1,109 @@
from __future__ import annotations
import asyncio
import logging
from pathlib import Path
from typing import TYPE_CHECKING
from . import config
from .session import Session
from .app_session import AppSession
from .terminal_session import TerminalSession, Poller
from .types import SessionID, RouteKey
from ._two_way_dict import TwoWayDict
log = logging.getLogger("textual-web")
class SessionManager:
def __init__(self, poll_reader: Poller, path: Path, apps: list[config.App]) -> None:
self.poll_reader = poll_reader
self.path = path
self.apps = apps
self.apps_by_slug = {app.slug: app for app in apps}
self.sessions: dict[SessionID, Session] = {}
self.routes: TwoWayDict[RouteKey, SessionID] = TwoWayDict()
def add_app(
self, name: str, command: str, slug: str, terminal: bool = False
) -> None:
"""Add a new app
Args:
name: Name of the app.
command: Command to run the app.
slug: Slug used in URL, or blank to auto-generate on server.
"""
new_app = config.App(
name=name, slug=slug, path="./", command=command, terminal=terminal
)
self.apps.append(new_app)
self.apps_by_slug[slug] = new_app
def on_session_end(self, session_id: SessionID) -> None:
"""Called by sessions."""
self.sessions.pop(session_id)
route_key = self.routes.get_key(session_id)
if route_key is not None:
del self.routes[route_key]
async def close_all(self, timeout: float = 3.0) -> None:
sessions = list(self.sessions.values())
if not sessions:
return
log.info("Closing %s session(s)", len(sessions))
async def do_close() -> int:
"""Close all sessions, return number unclosed after timeout
Returns:
Number of sessions not yet closed.
"""
_done, remaining = await asyncio.wait(
[asyncio.create_task(session.close()) for session in sessions],
timeout=timeout,
)
return len(remaining)
remaining = await do_close()
if remaining:
log.warning("%s session(s) didn't close after %s seconds", timeout)
async def new_session(
self, slug: str, session_id: SessionID, route_key: RouteKey
) -> Session | None:
app = self.apps_by_slug.get(slug)
if app is None:
return None
session_process: Session
if app.terminal:
log.debug(app)
session_process = TerminalSession(self.poll_reader, session_id, app.command)
else:
session_process = AppSession(self.path, app.command, session_id)
self.sessions[session_id] = session_process
self.routes[route_key] = session_id
await session_process.open()
return session_process
async def close_session(self, session_id: SessionID) -> None:
session_process = self.sessions.get(session_id, None)
if session_process is None:
return
await session_process.close()
def get_session(self, session_id: SessionID) -> Session | None:
return self.sessions.get(session_id)
def get_session_by_route_key(self, route_key: RouteKey) -> Session | None:
session_id = self.routes.get(route_key)
if session_id is not None:
return self.sessions.get(session_id)
else:
return None

View File

@@ -0,0 +1,199 @@
from __future__ import annotations
from dataclasses import dataclass, field
from collections import deque
import asyncio
import array
import fcntl
import logging
import os
import pty
import signal
import select
import termios
from threading import Thread, Event
import rich.repr
from .types import Meta
log = logging.getLogger("textual-web")
from .session import Session, SessionConnector
from .types import SessionID
@dataclass
class Write:
data: bytes
position: int = 0
done_event: asyncio.Event = field(default_factory=asyncio.Event)
READABLE_EVENTS = select.POLLIN | select.POLLPRI
WRITEABLE_EVENTS = select.POLLOUT
ERROR_EVENTS = select.POLLERR | select.POLLHUP
class Poller(Thread):
"""A thread which reads from file descriptors and posts read data to a queue."""
def __init__(self) -> None:
super().__init__()
self._loop: asyncio.AbstractEventLoop | None = None
self._poll = select.poll()
self._read_queues: dict[int, asyncio.Queue[bytes | None]] = {}
self._write_queues: dict[int, deque[Write]] = {}
self._exit_event = Event()
def add_file(self, file_descriptor: int) -> asyncio.Queue:
self._poll.register(
file_descriptor, READABLE_EVENTS | WRITEABLE_EVENTS | ERROR_EVENTS
)
queue = self._read_queues[file_descriptor] = asyncio.Queue()
return queue
def remove_file(self, file_descriptor: int) -> None:
self._read_queues.pop(file_descriptor, None)
self._write_queues.pop(file_descriptor, None)
async def write(self, file_descriptor: int, data: bytes) -> None:
"""Schedule write to file descriptor, return "done" event."""
if file_descriptor not in self._write_queues:
self._write_queues[file_descriptor] = deque()
new_write = Write(data)
self._write_queues[file_descriptor].append(new_write)
self._poll.register(
file_descriptor, READABLE_EVENTS | WRITEABLE_EVENTS | ERROR_EVENTS
)
await new_write.done_event.wait()
def set_loop(self, loop: asyncio.AbstractEventLoop) -> None:
self._loop = loop
def run(self) -> None:
readable_events = READABLE_EVENTS
writeable_events = WRITEABLE_EVENTS
error_events = ERROR_EVENTS
loop = self._loop
assert loop is not None
while not self._exit_event.is_set():
poll_result = self._poll.poll(1000)
for file_descriptor, event_mask in poll_result:
queue = self._read_queues.get(file_descriptor, None)
if queue is not None:
if event_mask & readable_events:
data = os.read(file_descriptor, 1024 * 32)
loop.call_soon_threadsafe(queue.put_nowait, data)
if event_mask & writeable_events:
write_queue = self._write_queues.get(file_descriptor, None)
if write_queue:
write = write_queue[0]
bytes_written = os.write(
file_descriptor, write.data[write.position :]
)
if bytes_written == len(write.data):
write_queue.popleft()
loop.call_soon_threadsafe(write.done_event.set)
else:
write.position += bytes_written
if event_mask & error_events:
loop.call_soon_threadsafe(queue.put_nowait, None)
self._read_queues.pop(file_descriptor, None)
def exit(self) -> None:
"""Exit and block until finished."""
for queue in self._read_queues.values():
queue.put_nowait(None)
self._exit_event.set()
self.join()
self._read_queues.clear()
self._write_queues.clear()
@rich.repr.auto
class TerminalSession(Session):
def __init__(
self,
poller: Poller,
session_id: SessionID,
command: str,
) -> None:
self.poller = poller
self.session_id = session_id
self.command = command or os.environ.get("SHELL", "sh")
self.master_fd: int | None = None
self.pid: int | None = None
self._task: asyncio.Task | None = None
super().__init__()
def __rich_repr__(self) -> rich.repr.Result:
yield "session_id", self.session_id
yield "command", self.command
async def open(self, argv=None) -> None:
pid, master_fd = pty.fork()
self.pid = pid
self.master_fd = master_fd
if pid == pty.CHILD:
if argv is None:
argv = [self.command]
try:
os.execlp(argv[0], *argv) ## Exits the app
except Exception:
os._exit(0)
def _set_terminal_size(self, width: int, height: int) -> None:
buf = array.array("h", [height, width, 0, 0])
assert self.master_fd is not None
fcntl.ioctl(self.master_fd, termios.TIOCSWINSZ, buf)
async def set_terminal_size(self, width: int, height: int) -> None:
self._set_terminal_size(width, height)
async def start(self, connector: SessionConnector) -> asyncio.Task:
self._connector = connector
assert self.master_fd is not None
assert self._task is None
self._task = self._task = asyncio.create_task(self.run())
return self._task
async def run(self) -> None:
assert self.master_fd is not None
queue = self.poller.add_file(self.master_fd)
on_data = self._connector.on_data
on_close = self._connector.on_close
try:
self._set_terminal_size(80, 24)
while True:
data = await queue.get() or None
if data is None:
break
await on_data(data)
except Exception:
log.exception("error in terminal.run")
finally:
await on_close()
os.close(self.master_fd)
self.master_fd = None
async def send_bytes(self, data: bytes) -> bool:
if self.master_fd is None:
return False
await self.poller.write(self.master_fd, data)
return True
async def send_meta(self, data: Meta) -> bool:
return True
async def close(self) -> None:
if self.pid is not None:
os.kill(self.pid, signal.SIGHUP)
if self._task is not None:
await self._task

7
src/textual_web/types.py Normal file
View File

@@ -0,0 +1,7 @@
from typing import NewType
AppID = NewType("AppID", str)
Meta = dict[str, str | None | int | bool]
RouteKey = NewType("RouteKey", str)
SessionID = NewType("SessionID", str)