Writing Tools¶
A Tool is a Python class whose methods execute on the remote side. The class definition is serialized and transferred to the remote interpreter the first time any method on the class is called through a connection - not when the class is defined, and not when the connection is opened. This is called lazy sync.
Once a tool has been synced over a connection, every subsequent call skips the transfer entirely: only the RPC packet (method name + arguments) is sent. If the connection is closed and a new one is opened, the class is re-synced on its first call through the new connection.
Rules¶
Inherit from
Tool.No
__init__- the metaclass raisesTypeErrorif one is defined.Static or class methods only - instance state is not preserved across calls.
Stdlib imports only - the remote side has no installed packages.
Import inside methods - put
importstatements inside the method body so they are executed on the remote interpreter, not the local one.
Running Subprocesses¶
The remote Python process communicates with the local side over its own stdin / stdout as a binary packet stream. Any child process that inherits the default file descriptors will share those pipes, and anything the child writes to stdout - even a single byte - will corrupt the packet framing and break the connection permanently.
Danger
Never use subprocess.run, subprocess.Popen, or os.system directly inside a Tool
method. They inherit the parent’s stdin/stdout by default, which are the protocol pipes.
Use rmote.protocol.process() instead. It always sets stdin=DEVNULL and, unless
capture_output=True, also redirects stdout and stderr to DEVNULL:
from rmote.protocol import Tool, process
class DeployTool(Tool):
@staticmethod
def git_pull(repo: str) -> int:
"""Pull latest changes; return the exit code."""
result = process("git", "-C", repo, "pull", "--ff-only")
return result.returncode
@staticmethod
def capture(cmd: str) -> str:
"""Run a shell command and return its stdout."""
result = process(cmd, shell=True, capture_output=True, text=True, check=True)
return result.stdout
An inline tool is a Tool subclass defined in the same script or module that imports
Protocol. A file-level tool is defined in its own standalone module that is imported
separately.
process is injected into every tool namespace automatically - no import is needed for inline
tools. For file-level tools add from rmote.protocol import process at the top of the file.
What process does¶
Default behaviour |
Why |
|---|---|
|
Child cannot read protocol data |
|
Child cannot corrupt the protocol stream |
|
Remote stderr is also the protocol channel |
The signature mirrors subprocess.run for everything else: check, env, cwd, shell, and
an optional stdin argument (bytes or str) for data that should be piped into the child.
What not to do¶
import os
import subprocess
from rmote.protocol import process
if __name__ == "__main__":
# BAD - child inherits the protocol pipes
subprocess.run(["apt-get", "update"])
# BAD - os.system goes through the shell which inherits the same fds
os.system("apt-get update")
# GOOD
process("apt-get", "update")
Sync and Async Methods¶
Both sync and async methods are supported.
The protocol detects the method type via inspect.iscoroutinefunction and dispatches
accordingly:
import urllib.request
from rmote.protocol import Tool
class MyTool(Tool):
@staticmethod
def read_file(path: str) -> str:
"""Synchronous - runs in a thread on the remote."""
with open(path) as f:
return f.read()
@staticmethod
async def fetch(url: str) -> bytes:
"""Async - runs directly in the remote event loop."""
with urllib.request.urlopen(url) as resp:
return resp.read()
Class Variables¶
Class variables can hold configuration.
Annotate them as typing.ClassVar to signal that they are not instance attributes:
from typing import ClassVar
from rmote.protocol import Tool
class Config(Tool):
base_url: ClassVar[str] = "https://example.com"
@classmethod
def get_url(cls, path: str) -> str:
return cls.base_url + path
Returning Custom Types¶
Any picklable object can be returned - including dataclasses:
import os
import dataclasses
from rmote.protocol import Tool
@dataclasses.dataclass
class FileInfo:
path: str
size: int
class Inspector(Tool):
@staticmethod
def stat(path: str) -> "FileInfo":
s = os.stat(path)
return FileInfo(path=path, size=s.st_size)
Note
The dataclass (or any custom type you return) must be defined outside the Tool
class body so it is available both in the tool source sent to the remote side and in
the local namespace where the result is unpickled.
Complete Example¶
import asyncio
import dataclasses
import shutil
import socket
from rmote.protocol import Protocol, Tool
@dataclasses.dataclass
class DiskUsage:
path: str
total: int
used: int
free: int
class SystemInfo(Tool):
@staticmethod
def disk_usage(path: str = "/") -> "DiskUsage":
"""Return disk usage statistics for *path*."""
total, used, free = shutil.disk_usage(path)
return DiskUsage(path=path, total=total, used=used, free=free)
@staticmethod
def hostname() -> str:
"""Return the remote machine hostname."""
return socket.gethostname()
@classmethod
def name(cls) -> str:
return cls.__name__
async def main() -> None:
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:
host = await proto(SystemInfo.hostname)
du = await proto(SystemInfo.disk_usage, "/")
print(f"{host}: {du.free // 2**30} GB free on /")
if __name__ == "__main__":
asyncio.run(main())
Protocol.from_subprocess bootstraps the remote process, then each await proto(...) first
syncs the tool class (once) and then issues the RPC call. Because the two calls are sequential,
the second call reuses the already-synced SystemInfo class:
sequenceDiagram
participant L as Local
participant R as python3 subprocess
Note over L,R: Protocol.from_subprocess - bootstrap
L->>R: exec(decompress(b64decode(payload)))
R-->>L: PROTOCOL READY
Note over L,R: await proto(SystemInfo.hostname) - first call
L->>R: SYNC SystemInfo source
R-->>L: ACK
L->>R: REQUEST {hostname, id=1}
R-->>L: RESPONSE {"web01", id=1}
Note over L,R: await proto(SystemInfo.disk_usage, "/") - sync skipped
L->>R: REQUEST {disk_usage, "/", id=2}
R-->>L: RESPONSE {DiskUsage(path="/", ...), id=2}