mirror of
https://github.com/Textualize/textual-web.git
synced 2025-10-17 02:36:40 +03:00
first commit
This commit is contained in:
131
.gitignore
vendored
Normal file
131
.gitignore
vendored
Normal 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
21
LICENSE
Normal 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.
|
||||
33
examples/calculator.css
Normal file
33
examples/calculator.css
Normal 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
145
examples/calculator.py
Normal 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
47
examples/ganglion.toml
Normal 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
1135
poetry.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
29
pyproject.toml
Normal file
29
pyproject.toml
Normal 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
BIN
src/textual_web/.DS_Store
vendored
Normal file
Binary file not shown.
0
src/textual_web/__init__.py
Normal file
0
src/textual_web/__init__.py
Normal file
75
src/textual_web/_two_way_dict.py
Normal file
75
src/textual_web/_two_way_dict.py
Normal 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
|
||||
268
src/textual_web/app_session.py
Normal file
268
src/textual_web/app_session.py
Normal 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
|
||||
230
src/textual_web/apps/signup.py
Normal file
230
src/textual_web/apps/signup.py
Normal 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()
|
||||
4
src/textual_web/apps/signup.tcss
Normal file
4
src/textual_web/apps/signup.tcss
Normal file
@@ -0,0 +1,4 @@
|
||||
SignupScreen {
|
||||
align: center middle;
|
||||
|
||||
}
|
||||
122
src/textual_web/cli.py
Normal file
122
src/textual_web/cli.py
Normal 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
45
src/textual_web/config.py
Normal 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
|
||||
53
src/textual_web/constants.py
Normal file
53
src/textual_web/constants.py
Normal 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", "")
|
||||
36
src/textual_web/environment.py
Normal file
36
src/textual_web/environment.py
Normal 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
|
||||
339
src/textual_web/ganglion_client.py
Normal file
339
src/textual_web/ganglion_client.py
Normal 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))
|
||||
11
src/textual_web/identity.py
Normal file
11
src/textual_web/identity.py
Normal 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
821
src/textual_web/packets.py
Normal 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
53
src/textual_web/retry.py
Normal 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
|
||||
85
src/textual_web/session.py
Normal file
85
src/textual_web/session.py
Normal 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.
|
||||
"""
|
||||
...
|
||||
10
src/textual_web/session_connector.py
Normal file
10
src/textual_web/session_connector.py
Normal 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))
|
||||
18
src/textual_web/session_handler.py
Normal file
18
src/textual_web/session_handler.py
Normal 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
|
||||
109
src/textual_web/session_manager.py
Normal file
109
src/textual_web/session_manager.py
Normal 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
|
||||
199
src/textual_web/terminal_session.py
Normal file
199
src/textual_web/terminal_session.py
Normal 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
7
src/textual_web/types.py
Normal 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)
|
||||
Reference in New Issue
Block a user