Adding a language
This page is for people changing Pythonator itself, in a fork or a pull request. Adding a language takes one entry in a table; the window, the sandbox, the container mode and the environment check pick it up from there. The rest of the page shows where the other common changes go.
Set up a development copy first: see Development and CI.
The runtime table
Every language is a Runtime in pythonator/core/runtimes.py:
@dataclass(frozen=True, slots=True)
class Runtime:
key: str # stored in services.json: never change it once released
label: str # shown in the Language list
programs: tuple[str, ...] # looked up on PATH, first found wins
extensions: tuple[str, ...] # entry files that mean this language
markers: tuple[str, ...] # files that mark a project of this language (for Detect)
image: str # the default container image
run: Callable[[str, str, Argv], Argv] # (program, entry, args) -> the command
install: Callable[[Path, str], list[Argv]] | None # (folder, program) -> the install steps
packages: str # where its packages go, shown in the window
Example: Elixir
Add an entry to RUNTIMES:
def _elixir_run(prog: str, entry: str, args: Argv) -> Argv:
# A script runs with elixir; a Mix project (no entry) with mix run --no-halt.
if entry:
return [prog, entry, *args]
return [which("mix") or "mix", "run", "--no-halt", *args]
RUNTIMES: dict[str, Runtime] = {r.key: r for r in (
...
Runtime("elixir", "Elixir", ("elixir",), (".exs", ".ex"), ("mix.exs",), "elixir:1.18-slim",
run=_elixir_run,
install=lambda folder, prog: [[which("mix") or "mix", "deps.get"]],
packages="deps and _build in the project"),
)}
That’s all it takes for:
- Language to list Elixir and Detect from folder to recognise a
mix.exs; - a dropped
.exsfile to become an Elixir service; - Install packages to run
mix deps.get, inside the service’s isolation; - the sandbox to let the service read Elixir’s installation folder (found from the program’s path);
- Container mode to use
elixir:1.18-slimwith the project at/app; - the environment check to say when
elixirisn’t installed.
If the language has no default way to run a project without an entry file, add its key to the needs_entry check
in service_argv in pythonator/isolation/__init__.py.
When the sandbox needs more
A sandboxed service can read the folder its program is installed in, and the system folders. Some languages read
more: a shared cache in the home folder, or a second program. In the sandbox the home folder is a private one, so a
cache there just works and stays with the service. If the language runs a second program that lives elsewhere (as
npm runs node), add it to programs in policy_for, as Node.js does:
if service.runtime in ("node", "bun") and (node := runtimes.which("node")):
programs.append(node)
Never grant the home folder or a secrets folder: forbidden_read and forbidden_write in
pythonator/isolation/base.py refuse them, on purpose.
Tests
Add the command to test_run_commands in tests/test_core.py, and a project to
test_detect if it has a marker file. Run the checks:
ruff check pythonator plugins tests && mypy && pytest
Other changes, and where they go
| You want to | Change | Notes |
|---|---|---|
| Run something before every start of a language | run of its Runtime |
Return a longer argv; never a shell string |
| A new field on services | Service in core/models.py, then the dialog in ui/service_dialog.py |
Give it a default, so old services.json files still load |
| A new check before starts | service_report in core/envcheck.py |
Return ("ok" \| "warn" \| "error", text) |
| A new request for the background service | A model in daemon/protocol.py, added to Request, and a case in _dispatch in daemon/server.py |
extra="forbid" stays on; validate every field |
| A new plugin permission | Permission and PERMISSIONS in plugins/manifest.py, then the check in plugins/host.py |
Describe it so a user understands it |
| A new panel element | Node in plugins/host.py, a builder in sdk.py, and render in ui/panels.py |
Text stays plain text |
| A new isolation mode or system | A module in isolation/ with wrap() and capabilities(), called from prepare() |
It must raise IsolationError rather than run with less |
| A new toolbar button | _build_toolbar in ui/main_window.py |
The window only sends requests; the work goes in the background service |
| A new colour theme | Colors in ui/theme.py |
Both themes use the same names |
Most features don’t need a change to Pythonator at all: write a plugin instead. It can be shared without a fork, and it runs contained.
Rules that keep a fork safe
- Programs start from argument lists, never through a shell.
- Everything from a user, a file or a plugin goes through
core/validate.pyor a pydantic model before it is used. - The window never does the work; the background service does, and it never blocks on a plugin.
- If isolation can’t be applied, the service doesn’t start.
- The sandbox launcher (
launcher.py) imports nothing but the standard library: it runs inside the sandbox, before the service, and must stay small enough to read in one sitting.