Runtime

Launching the User’s Editor from a Python CLI

Open $VISUAL or $EDITOR from a Python CLI the way git commit does: editor lookup, temp files with a suffix, comment lines, aborting on empty input, and tests.

Updated

Some input is too long or too structured for a command-line option: a commit message, release notes, a ticket description, a YAML manifest to tweak before applying. git commit, crontab -e, kubectl edit and gh pr create all solve this the same way — they open the user’s own editor on a temporary file, wait for it to close, and read the result. Users get their familiar keybindings, syntax highlighting and spell checker; the CLI gets well-formed text. Doing it well takes more than subprocess.run(["vim", path]): the editor must come from the right environment variables and may include arguments, the temporary file should have a suffix that triggers highlighting, instructions can live in comment lines that are stripped afterwards, an unchanged or empty buffer should abort rather than submit, and the whole thing must fail clearly when there is no terminal. This guide builds that helper and a command that uses it, and tests it without ever opening a real editor. It belongs to the running subprocesses from Python CLIs topic.

Prerequisites

The editing round trip

The editing round trip Sequence of a command line tool writing a template to a temporary file, handing the terminal to the user’s editor, and reading back the result. The editing round trip mytool temp file editor user write template + # comments run [*shlex.split($VISUAL), path] type, save, quit exit status read back, strip comments Unchanged, empty or failed edits abort; nothing half-written is used.

The flow is the one users know from git: the command writes a template — usually empty space for the answer plus a few commented instructions — to a temporary file, hands control of the terminal to the editor, and blocks. When the editor exits, the command reads the file back, drops the comment lines and decides: real content continues, an unchanged or empty buffer aborts, a failed editor saves nothing.

The recipe

# src/mytool/editor.py
from __future__ import annotations

import os
import shlex
import shutil
import subprocess
import sys
import tempfile
from pathlib import Path


class EditorError(Exception):
    pass


def editor_command() -> list[str]:
    """$MYTOOL_EDITOR, then $VISUAL, then $EDITOR, then a platform default."""
    for var in ("MYTOOL_EDITOR", "VISUAL", "EDITOR"):
        if value := os.environ.get(var, "").strip():
            return shlex.split(value, posix=os.name != "nt")
    for fallback in (["notepad"] if os.name == "nt" else ["nano"], ["vi"]):
        if shutil.which(fallback[0]):
            return fallback
    raise EditorError("no editor found; set $EDITOR (for example: export EDITOR=nano)")


def edit_text(initial: str, *, suffix: str = ".txt", comment: str = "#") -> str | None:
    """Open INITIAL in the user's editor and return the result without comment lines.

    Returns None when the user saved nothing new (unchanged or empty), which callers treat as "abort".
    """
    if not sys.stdin.isatty() and "MYTOOL_EDITOR" not in os.environ:
        raise EditorError("cannot open an editor without a terminal; pass the text with --message")
    command = editor_command()
    with tempfile.TemporaryDirectory(prefix="mytool-") as tmp:
        path = Path(tmp) / f"EDIT{suffix}"                 # suffix gives the editor syntax highlighting
        path.write_text(initial, encoding="utf-8")
        try:
            result = subprocess.run([*command, str(path)])
        except FileNotFoundError:
            raise EditorError(f"editor not found: {command[0]!r}") from None
        if result.returncode != 0:
            raise EditorError(f"editor exited with status {result.returncode}; nothing saved")
        edited = path.read_text(encoding="utf-8")
    if edited == initial:
        return None
    kept = [line for line in edited.splitlines() if not line.startswith(comment)]
    text = "\n".join(kept).strip()
    return text + "\n" if text else None
# src/mytool/cli.py
from typing import Annotated, Optional

import typer

from mytool.editor import EditorError, edit_text

app = typer.Typer()

TEMPLATE = """\

# Describe the release. Lines starting with '#' are ignored.
# An empty message aborts.
"""


@app.callback()
def main() -> None:
    """Release tool."""


@app.command()
def note(message: Annotated[Optional[str], typer.Option("--message", "-m")] = None) -> None:
    """Write a release note, in the editor unless --message is given."""
    if message is None:
        try:
            message = edit_text(TEMPLATE, suffix=".md")
        except EditorError as exc:
            typer.echo(f"error: {exc}", err=True)
            raise typer.Exit(1)
        if message is None:
            typer.echo("aborted: empty message", err=True)
            raise typer.Exit(1)
    typer.echo(message.rstrip())

Finding the editor

Unix convention distinguishes $VISUAL (a full-screen editor) from $EDITOR (historically a line editor for dumb terminals); in practice both name the same program, and tools check $VISUAL first. A tool-specific variable on top, like git’s GIT_EDITOR, lets users choose a different editor for your tool alone, and makes testing easy. If nothing is set, fall back to something that exists — nano is friendlier for newcomers than vi, which famously traps people who do not know :q; notepad on Windows.

Editor variables often contain arguments: code --wait, subl -n -w, emacsclient -t. shlex.split turns them into an argument list the way a shell would, so quoting works ("/opt/My Editor/bin/edit" --wait). Running the result without shell=True keeps the file path from being interpreted by a shell, which matters because the path is not under the user’s full control. On Windows, posix=False keeps backslashes in paths intact.

The temporary file

A private temporary directory holds the file, so the name is predictable for the editor and nothing collides between concurrent runs; it disappears with everything in it when the with block ends — see safe temporary files and directories. The suffix is chosen by the caller: .md for notes, .yaml for manifests, .toml for configuration. Editors pick syntax highlighting, indentation rules and linters from it, which makes structured input noticeably less error-prone.

Comments, empty buffers and failures

Comment lines carry instructions without becoming part of the answer. The helper strips lines starting with the comment marker, then trims whitespace. Two outcomes mean “the user changed their mind”: a buffer identical to the template (they quit without saving) and a buffer that is empty after stripping comments (they deleted everything). Both return None, and the command aborts with a clear message and a non-zero exit code, exactly like git commit with an empty message.

A non-zero exit from the editor is different: in vim, :cq exits with an error precisely to say “abort”. Treat it as an explicit cancel, and never use a partially written file.

Editor, abort and script paths Terminal session writing a release note in the editor, aborting with an empty buffer, and passing the message directly in a script. Editor, abort and script paths bash $ mytool note # editor opens on EDIT.md Fixed the frobnicator. $ mytool note # saved an empty buffer aborted: empty message $ mytool note -m "Fixed the frobnicator." < /dev/null Fixed the frobnicator. Scripts never depend on an editor being available.

Graphical editors and waiting

Terminal editors block until the user quits, so subprocess.run returns at the right moment. Many graphical editors do not: code path opens a window in an already-running instance and returns immediately, and your CLI reads the unchanged template and aborts. The fix is the editor’s wait flag — code --wait, subl -w, zed --wait, gedit -s, mate -w — set in the variable itself. Mention this in your documentation, and consider detecting the symptom: if the editor exits within a second and the file is unchanged, print a hint that the editor may need a wait flag.

UX considerations

  • Always offer a non-interactive path. --message, --file or stdin must work without an editor, for scripts and CI. The helper refuses to start without a terminal and points at --message.
  • Keep the template short and actionable. Two or three comment lines: what to write, what is ignored, how to abort.
  • Preserve work on failure. If the command fails after editing (a network error while submitting), save the text somewhere and print the path, so the user does not have to type it again — git keeps .git/COMMIT_EDITMSG for the same reason.
  • Pre-fill sensibly. When editing an existing object, put its current value in the buffer; when creating one, include useful defaults as comments.
  • Do not hold locks or spinners while the editor runs. The editor owns the terminal; stop Rich live displays and progress bars first.
Where the editor comes from Order in which a command line tool resolves which editor to launch, from a tool-specific variable down to a platform default. Where the editor comes from $MYTOOL_EDITOR 1st this tool only; also used by tests $VISUAL 2nd full-screen editor, e.g. code --wait $EDITOR 3rd general editor, e.g. nano Default last nano or vi; notepad on Windows The first one set wins; its value is split with shlex.

Testing the behaviour

A fake editor is a short Python script set as $MYTOOL_EDITOR: it receives the path just like a real editor, edits the file and exits with a chosen status. That exercises the whole helper — command resolution, subprocess, reading back, comment stripping — with no terminal:

# tests/test_editor.py
import sys

import pytest
from typer.testing import CliRunner

from mytool import editor
from mytool.cli import app
from mytool.editor import EditorError, editor_command

runner = CliRunner()


def fake_editor(tmp_path, monkeypatch, body: str, exit_code: int = 0) -> None:
    script = tmp_path / "fake_editor.py"
    script.write_text(
        "import pathlib, sys\n"
        "p = pathlib.Path(sys.argv[1])\n"
        f"p.write_text({body!r} + p.read_text())\n"
        f"sys.exit({exit_code})\n")
    monkeypatch.setenv("MYTOOL_EDITOR", f"{sys.executable} {script}")


def test_editor_precedence(monkeypatch):
    monkeypatch.delenv("MYTOOL_EDITOR", raising=False)
    monkeypatch.setenv("EDITOR", "nano")
    monkeypatch.setenv("VISUAL", "code --wait")
    assert editor_command() == ["code", "--wait"]


def test_message_from_editor_strips_comments(tmp_path, monkeypatch):
    fake_editor(tmp_path, monkeypatch, "Fixed the frobnicator.\n")
    result = runner.invoke(app, ["note"])
    assert result.exit_code == 0
    assert result.stdout == "Fixed the frobnicator.\n"


def test_unchanged_buffer_aborts(tmp_path, monkeypatch):
    fake_editor(tmp_path, monkeypatch, "")
    result = runner.invoke(app, ["note"])
    assert result.exit_code == 1 and "aborted" in result.stderr


def test_failing_editor_saves_nothing(tmp_path, monkeypatch):
    fake_editor(tmp_path, monkeypatch, "half-written", exit_code=1)
    result = runner.invoke(app, ["note"])
    assert result.exit_code == 1 and "status 1" in result.stderr


def test_missing_editor_is_reported(monkeypatch):
    monkeypatch.setenv("MYTOOL_EDITOR", "no-such-editor-xyz")
    with pytest.raises(EditorError, match="not found"):
        editor.edit_text("x")


def test_no_terminal_needs_message(monkeypatch):
    monkeypatch.delenv("MYTOOL_EDITOR", raising=False)
    result = runner.invoke(app, ["note"])
    assert result.exit_code == 1 and "--message" in result.stderr
    assert runner.invoke(app, ["note", "-m", "done"]).stdout == "done\n"

The fake editor prepends text, so the template’s comment lines remain and the test proves they are removed. Setting MYTOOL_EDITOR also lifts the “no terminal” check — the variable is an explicit statement that an editor is available — while the last test confirms that, without it, the runner’s non-terminal stdin produces the helpful error and --message still works.

Conclusion

Launching the user’s editor gives long-form input the tooling people already use. Resolve the editor from a tool-specific variable, then $VISUAL, $EDITOR and a friendly default; split it with shlex; write a template with commented instructions to a private temporary directory with a meaningful suffix; run the editor without a shell and wait; strip comments; abort on an unchanged or empty buffer and on a failing editor; refuse to run without a terminal while offering --message; and test with a fake editor script.

Frequently asked questions

Why not use click.edit()?

click.edit() implements much of this — editor lookup, temporary file, extension — and is a fine choice for Click apps. A small helper of your own adds comment stripping, the abort rules and a tool-specific variable, and is easier to test; with Typer you would otherwise reach into its vendored copy of Click.

What if the user’s editor is a GUI app over SSH?

$VISUAL might name a graphical editor that cannot start without a display. Users typically set VISUAL and EDITOR differently per machine; your CLI just runs what they configured and reports the error if it fails to start.

How should Windows users configure an editor?

Set EDITOR to the editor’s command, quoting paths with spaces: setx EDITOR "code --wait". Notepad is always present as a fallback and blocks until closed.

Can the edited content be validated before accepting it?

Yes — and you should for structured input. Parse it, and on error reopen the editor with the error as a comment at the top, as kubectl edit does. The loop is shown in writing a config init and edit command.

Is it safe to put secrets in the edited file?

Avoid it. The text sits on disk while the editor runs and may end up in editor swap and backup files. For secrets, prompt without echo instead, as in prompting for passwords securely.