Serve Textual apps over SSH
Project description
textish
Serve Textual TUI apps over SSH. Point it at any command that runs a Textual app, give it a port, and anyone with an SSH client can connect and use the app in their terminal — no installation required on their end.
from textish import serve
serve("python my_app.py", port=2222)
ssh localhost -p 2222
How it works
Textual ships with a WebDriver that runs an app in a subprocess and communicates over pipes using a simple length-prefixed packet protocol. textish reuses that mechanism entirely. Each SSH connection spawns the Textual app as a fresh subprocess and bridges the SSH channel to the subprocess's stdin/stdout. Nothing inside Textual is patched or monkeypatched — the app has no idea it's being served over SSH.
This is the same basic idea as wish (Charmbracelet's SSH app framework for Go) and inkish, adapted to work with Textual's existing driver infrastructure.
Installation
Requires Python 3.12 or later.
pip install textish
Usage
Command line
textish "python my_app.py"
textish "python my_app.py" --port 3000
textish "python my_app.py" --host 127.0.0.1 --port 3000 --max-connections 10
$ textish --help
usage: textish [-h] [--host HOST] [--port PORT] [--host-key PATH]
[--max-connections N]
app_command
Serve a Textual app over SSH.
positional arguments:
app_command Shell command that launches your Textual app,
e.g. "python my_app.py".
options:
--host HOST Address to listen on. (default: 0.0.0.0)
--port PORT TCP port to listen on. (default: 2222)
--host-key PATH Path to the SSH host key file.
Defaults to ~/.ssh/ssh_host_key.
--max-connections N Maximum simultaneous SSH sessions.
0 means unlimited. (default: 0)
Python API
from textish import serve
# Note: requires a host key at ~/.ssh/ssh_host_key by default
serve("python my_app.py", port=2222)
Async
If you are already inside a running event loop (for example, embedding textish inside a larger async application):
from textish import serve_async
await serve_async("python my_app.py", port=2222)
Configuration object
from textish import serve_config, AppConfig
config = AppConfig(
app_command="python my_app.py",
port=2222,
max_connections=10,
)
serve_config(config)
Host keys
By default, textish looks for a host key at ~/.ssh/ssh_host_key. You can generate one with:
ssh-keygen -t ed25519 -f ssh_host_key -N ""
Or pass an explicit path:
serve("python my_app.py", port=2222, host_keys=["./ssh_host_key"])
Public-key authentication
By default, textish allows all connections without authentication — suitable for private networks. To restrict access, pass an auth_function:
ALLOWED_KEYS = {
"ssh-ed25519 AAAAC3Nza..."}
def auth(username: str, public_key: str) -> bool:
return public_key in ALLOWED_KEYS
serve("python my_app.py", port=2222, auth_function=auth)
The function receives the username and the client's public key in OpenSSH format. It may also be async.
Limitations
A few things worth knowing before you deploy this anywhere serious.
One process per connection. Every SSH connection spawns a completely independent subprocess running your app. There is no shared state between clients, and no concept of a persistent session. If a client disconnects and reconnects, they get a brand new app instance from scratch.
No reconnection support. Related to the above — if a client's connection drops mid-session, there is nothing to reconnect to. The subprocess is terminated and any in-progress state is gone.
PTY required. textish only supports interactive shell sessions with a pseudo-terminal. Clients that connect without a PTY (for example, ssh host -p 2222 some-command) will be rejected with an error message. This is a deliberate constraint, not something that is straightforward to lift.
The app must use Textual's WebDriver. textish sets TEXTUAL_DRIVER=textual.drivers.web_driver:WebDriver in the subprocess environment. If your app overrides the driver or uses something incompatible, the handshake will fail and the connection will be dropped.
Development
Install with dev dependencies:
poetry install --with dev
Run the tests:
poetry run pytest
Lint:
poetry run ruff check .
Type check:
poetry run mypy
Project details
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file textish-0.2.0.tar.gz.
File metadata
- Download URL: textish-0.2.0.tar.gz
- Upload date:
- Size: 15.3 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
e28824e15d707c3401c182f3aa1072aba7ba005cf3f087363879d157afb5e3d5
|
|
| MD5 |
3f8341c0472ca0f8477ea8fa3f28f30b
|
|
| BLAKE2b-256 |
5d9f5fbc9037260c9c9992bde220e37601e08818b7650098fac06789ecd32bdd
|
Provenance
The following attestation bundles were made for textish-0.2.0.tar.gz:
Publisher:
release.yml on Aisha630/textish
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
textish-0.2.0.tar.gz -
Subject digest:
e28824e15d707c3401c182f3aa1072aba7ba005cf3f087363879d157afb5e3d5 - Sigstore transparency entry: 1281053592
- Sigstore integration time:
-
Permalink:
Aisha630/textish@71059132d416dc3685d604bf44814ac3d9aec963 -
Branch / Tag:
refs/heads/main - Owner: https://github.com/Aisha630
-
Access:
private
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@71059132d416dc3685d604bf44814ac3d9aec963 -
Trigger Event:
push
-
Statement type:
File details
Details for the file textish-0.2.0-py3-none-any.whl.
File metadata
- Download URL: textish-0.2.0-py3-none-any.whl
- Upload date:
- Size: 16.8 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
da6272ebeaca7f70f73dddd77f10d1ee0ed837f08f7e3719aa6645529843efb9
|
|
| MD5 |
21f17aa18c986a4851ee3714dd5a763b
|
|
| BLAKE2b-256 |
ba6818ec6df13ba18489a6a83fb43d6f1103010926a90ffc2519216a0284dd72
|
Provenance
The following attestation bundles were made for textish-0.2.0-py3-none-any.whl:
Publisher:
release.yml on Aisha630/textish
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
textish-0.2.0-py3-none-any.whl -
Subject digest:
da6272ebeaca7f70f73dddd77f10d1ee0ed837f08f7e3719aa6645529843efb9 - Sigstore transparency entry: 1281053611
- Sigstore integration time:
-
Permalink:
Aisha630/textish@71059132d416dc3685d604bf44814ac3d9aec963 -
Branch / Tag:
refs/heads/main - Owner: https://github.com/Aisha630
-
Access:
private
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@71059132d416dc3685d604bf44814ac3d9aec963 -
Trigger Event:
push
-
Statement type: