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.
import asyncio
from textish import AppConfig, serve
asyncio.run(serve(AppConfig(app_command="python my_app.py", port=2222)))
ssh localhost -p 2222
How it works
Each SSH connection spawns the Textual app as a fresh subprocess attached to a server-side pseudo-terminal (PTY). textish bridges raw terminal bytes between the SSH channel and the PTY master file descriptor, so the app sees a normal terminal and can use Textual's standard terminal driver. Terminal resize events from the SSH client are applied to the PTY, causing the app to reflow just as it would in a regular shell.
This is the same basic idea as wish (Charmbracelet's SSH app framework for Go) and inkish, adapted for Python, asyncssh, and Textual apps.
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 "python my_app.py" --env APP_MODE=prod
$ textish --help
usage: textish [-h] [--host HOST] [--port PORT] [--host-key PATH]
[--max-connections N] [--env KEY=VALUE]
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)
--env KEY=VALUE Environment variable to pass to the app.
Can be repeated. (default: [])
Python API
import asyncio
from textish import AppConfig, serve
# Note: requires a host key at ~/.ssh/ssh_host_key by default
asyncio.run(serve(AppConfig(app_command="python my_app.py", port=2222)))
Existing Event Loop
If you are already inside a running event loop (for example, embedding textish inside a larger async application):
from textish import AppConfig, serve
await serve(AppConfig(app_command="python my_app.py", port=2222))
Configuration object
from textish import AppConfig, serve
config = AppConfig(
app_command="python my_app.py",
port=2222,
max_connections=10,
env={"APP_MODE": "prod"},
)
await serve(config)
App subprocesses receive only the variables in env plus terminal variables
managed by textish: TERM, COLUMNS, and ROWS.
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:
await serve(AppConfig(
app_command="python my_app.py",
port=2222,
host_key_path="./ssh_host_key",
))
Public-key authentication
By default, textish allows all connections without authentication — suitable for private networks. To restrict access, pass an auth callback:
ALLOWED_KEYS = {
"ssh-ed25519 AAAAC3Nza..."}
def auth(username: str, public_key: str) -> bool:
return public_key in ALLOWED_KEYS
await serve(AppConfig(
app_command="python my_app.py",
port=2222,
auth=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.
Unix-style PTYs required. The server-side app process is attached to a pseudo-terminal using the operating system PTY APIs. This is a natural fit on macOS and Linux; native Windows support would need a ConPTY-specific implementation.
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.3.0.tar.gz.
File metadata
- Download URL: textish-0.3.0.tar.gz
- Upload date:
- Size: 15.7 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
170a79fb0c1124713de63d2267a342c58b5fd740c2e099308998e0be193be16f
|
|
| MD5 |
2cebd63710739a1ccca4f9cc121c2245
|
|
| BLAKE2b-256 |
e698c51bca03533e1f214c634cad053fb52963916c54a5cd5a7f71998a00e282
|
Provenance
The following attestation bundles were made for textish-0.3.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.3.0.tar.gz -
Subject digest:
170a79fb0c1124713de63d2267a342c58b5fd740c2e099308998e0be193be16f - Sigstore transparency entry: 1395716332
- Sigstore integration time:
-
Permalink:
Aisha630/textish@4ec6c79f5ab6f5339a00824e8974fee491ad10ee -
Branch / Tag:
refs/heads/main - Owner: https://github.com/Aisha630
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@4ec6c79f5ab6f5339a00824e8974fee491ad10ee -
Trigger Event:
push
-
Statement type:
File details
Details for the file textish-0.3.0-py3-none-any.whl.
File metadata
- Download URL: textish-0.3.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 |
d1931c2469376a892978145bd478de8f2352394f194d52b87418040881408eb3
|
|
| MD5 |
8b5fe9b53257e8e9cd9f6b1136d036c8
|
|
| BLAKE2b-256 |
5bf6c92231ab07a9ecf83dbd76853b873eff42bc78c009d1d3c282f0b366c53a
|
Provenance
The following attestation bundles were made for textish-0.3.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.3.0-py3-none-any.whl -
Subject digest:
d1931c2469376a892978145bd478de8f2352394f194d52b87418040881408eb3 - Sigstore transparency entry: 1395716336
- Sigstore integration time:
-
Permalink:
Aisha630/textish@4ec6c79f5ab6f5339a00824e8974fee491ad10ee -
Branch / Tag:
refs/heads/main - Owner: https://github.com/Aisha630
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@4ec6c79f5ab6f5339a00824e8974fee491ad10ee -
Trigger Event:
push
-
Statement type: