Skip to main content

Serve Textual apps over SSH

Project description

textish

Python License Built with asyncssh Powered by Textual

textish demo

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

textish-0.3.0.tar.gz (15.7 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

textish-0.3.0-py3-none-any.whl (16.8 kB view details)

Uploaded Python 3

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

Hashes for textish-0.3.0.tar.gz
Algorithm Hash digest
SHA256 170a79fb0c1124713de63d2267a342c58b5fd740c2e099308998e0be193be16f
MD5 2cebd63710739a1ccca4f9cc121c2245
BLAKE2b-256 e698c51bca03533e1f214c634cad053fb52963916c54a5cd5a7f71998a00e282

See more details on using hashes here.

Provenance

The following attestation bundles were made for textish-0.3.0.tar.gz:

Publisher: release.yml on Aisha630/textish

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

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

Hashes for textish-0.3.0-py3-none-any.whl
Algorithm Hash digest
SHA256 d1931c2469376a892978145bd478de8f2352394f194d52b87418040881408eb3
MD5 8b5fe9b53257e8e9cd9f6b1136d036c8
BLAKE2b-256 5bf6c92231ab07a9ecf83dbd76853b873eff42bc78c009d1d3c282f0b366c53a

See more details on using hashes here.

Provenance

The following attestation bundles were made for textish-0.3.0-py3-none-any.whl:

Publisher: release.yml on Aisha630/textish

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page