Quickstart

Installation

pip install rmote

rmote requires Python 3.11 or newer on the local side. The remote side needs only a standard Python 3 interpreter - no extra packages.

Local Subprocess

The simplest usage is to spawn a local Python subprocess and communicate with it:

import asyncio
from rmote.protocol import Protocol
from rmote.tools.fs import FileSystem


async def main():
    process = await asyncio.create_subprocess_exec(
        "python3", "-qui",
        stdin=asyncio.subprocess.PIPE,
        stdout=asyncio.subprocess.PIPE,
    )

    proto = await Protocol.from_subprocess(process)

    async with proto:
        files = await proto(FileSystem.glob, "/etc/", "*.conf")
        print(files)

        content = await proto(FileSystem.read_str, "/etc/hostname")
        print(content)


if __name__ == "__main__":
    asyncio.run(main())

The -qui flags run Python in quiet, unbuffered, isolated mode - recommended for subprocess communication to avoid banner text and buffering issues.

SSH Remote Process

Use from_ssh() to connect to a remote host. The SSH subprocess is managed automatically - it is terminated when the context exits.

import asyncio
from rmote.protocol import Protocol
from rmote.tools import FileSystem


async def main():
    async with await Protocol.from_ssh("user@server") as proto:
        logs = await proto(FileSystem.glob, "/var/log/", "*.log")
        print(logs)


if __name__ == "__main__":
    asyncio.run(main())

All common SSH options are supported as keyword arguments:

import asyncio
from rmote.protocol import Protocol


async def main():
    async with await Protocol.from_ssh(
        "myserver",
        user="deploy",
        port=2222,
        identity="~/.ssh/id_ed25519",
        python="python3.11",
        ssh_options=["-o", "StrictHostKeyChecking=no"],
    ) as proto:
        pass  # use proto here


if __name__ == "__main__":
    asyncio.run(main())

Jump Hosts

Pass -J via ssh_options to route the connection through one or more bastion hosts.

Single jump host

from rmote.protocol import Protocol
from rmote.tools import FileSystem


async def main():
    async with await Protocol.from_ssh(
        "internal-host",
        ssh_options=["-J", "bastion.example.com"],
    ) as proto:
        hostname = await proto(FileSystem.read_str, "/etc/hostname")
        print(hostname)


if __name__ == "__main__":
    import asyncio
    asyncio.run(main())

SSH opens a connection to bastion.example.com first, then tunnels through it to internal-host. The Protocol context sees only the final destination.

Two jump hosts

Chain multiple bastions as a comma-separated list:

from rmote.protocol import Protocol
from rmote.tools import FileSystem


async def main():
    async with await Protocol.from_ssh(
        "deep-internal-host",
        ssh_options=["-J", "bastion1.example.com,bastion2.internal"],
    ) as proto:
        hostname = await proto(FileSystem.read_str, "/etc/hostname")
        print(hostname)


if __name__ == "__main__":
    import asyncio
    asyncio.run(main())

SSH hops local bastion1 bastion2 deep-internal-host transparently. Each jump host can specify its own user and port with the usual user@host:port syntax:

from rmote.protocol import Protocol


async def main():
    async with await Protocol.from_ssh(
        "10.0.2.5",
        ssh_options=["-J", "[email protected]:2222,[email protected]"],
    ) as proto:
        pass


if __name__ == "__main__":
    import asyncio
    asyncio.run(main())

For patterns that fan out to multiple hosts in parallel see Multi-Host Operations.

Concurrent Calls

Multiple RPC calls execute concurrently - use asyncio.gather to run them in parallel:

import asyncio
from rmote.protocol import Protocol, Tool
from rmote.tools import FileSystem


class MyTool(Tool):
    @staticmethod
    def shout(text: str) -> str:
        return text.upper()


async def main():
    process = await asyncio.create_subprocess_exec(
        "python3", "-qui",
        stdin=asyncio.subprocess.PIPE,
        stdout=asyncio.subprocess.PIPE,
    )
    proto = await Protocol.from_subprocess(process)

    async with proto:
        results = await asyncio.gather(
            proto(FileSystem.read_str, "/etc/hosts"),
            proto(FileSystem.read_str, "/etc/hostname"),
            proto(FileSystem.glob, "/tmp", "*.txt"),
            proto(MyTool.shout, "hello"),
        )
        print(results)


if __name__ == "__main__":
    asyncio.run(main())

Each call gets a unique packet_id; responses are matched back to their callers regardless of the order in which the remote side completes them.

Troubleshooting

ssh: connect to host port 22: Connection refused / Permission denied (publickey) Your SSH key is not loaded or the remote host requires a different key. Run ssh-add to load your key into the agent, or pass identity="~/.ssh/id_ed25519" (or whichever key file applies) to from_ssh. Verify with ssh user@host before using rmote.

/usr/bin/env: 'python3': No such file or directory The remote host does not have python3 on the default PATH, or the binary has a different name. Pass python="python3.12" (or the full path such as python="/usr/local/bin/python3") to from_ssh.

BrokenPipeError or the connection drops immediately The remote process exited before the protocol was established. Common causes: the remote shell prints a banner (disable with -q in ssh_options), the remote Python crashes during bootstrap, or a .bashrc / .profile writes to stdout. Add -v to ssh_options for verbose SSH diagnostics.

_pickle.UnpicklingError or AttributeError on the return value A custom type returned from a Tool method (e.g. a dataclass) must be defined outside the Tool class body and importable in both the local and remote namespaces. If it is defined inside the tool source it will exist remotely but not locally, so unpickling will fail. See Returning Custom Types for the correct pattern.

Two distinct tool classes appear in the gather above - FileSystem and MyTool. Each is synced exactly once, on its first call through this connection. The three FileSystem calls share a single sync; MyTool gets its own. All subsequent calls over the same connection go straight to RPC with no re-transfer.

Error Handling

Exceptions raised on the remote side are serialized and re-raised locally:

import asyncio
from rmote.protocol import Protocol
from rmote.tools import FileSystem


async def main():
    process = await asyncio.create_subprocess_exec(
        "python3", "-qui",
        stdin=asyncio.subprocess.PIPE,
        stdout=asyncio.subprocess.PIPE,
    )
    proto = await Protocol.from_subprocess(process)

    async with proto:
        try:
            content = await proto(FileSystem.read_str, "/nonexistent/file.txt")
        except FileNotFoundError as e:
            print(f"Remote error: {e}")


if __name__ == "__main__":
    asyncio.run(main())