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
- Familiarity with calling external commands safely with subprocess.
- Typer for the example command (examples checked with Typer 0.27 and Python 3.13).
The editing round trip
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.
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,--fileor 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_EDITMSGfor 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.
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.