Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 30 additions & 4 deletions src/click/_termui_impl.py
Original file line number Diff line number Diff line change
Expand Up @@ -701,7 +701,17 @@ def get_editor(self) -> str:
if rv:
return rv
if WIN:
return "notepad"
from shutil import which

notepad = which("notepad.exe") or which("notepad")
if notepad is not None:
return notepad
windir = (
os.environ.get("SystemRoot")
or os.environ.get("WINDIR")
or "C:\\Windows"
)
return str(Path(windir) / "System32" / "notepad.exe")

from shutil import which

Expand All @@ -712,7 +722,6 @@ def get_editor(self) -> str:

def edit_files(self, filenames: cabc.Iterable[str | os.PathLike[str]]) -> None:
"""Open files in the user's editor."""
import shlex
import subprocess

editor = self.get_editor()
Expand All @@ -725,9 +734,26 @@ def edit_files(self, filenames: cabc.Iterable[str | os.PathLike[str]]) -> None:
try:
# Split in POSIX mode (the default) for the same reasons as
# in pager(): strips quotes from tokens and preserves quoted
# Windows paths.
# Windows paths. On Windows, unquoted paths such as the
# default notepad.exe under System32 must use non-POSIX
# splitting — POSIX mode treats backslashes as escapes and
# CreateProcess can fail with WinError 87/14001.
editor_lower = editor.lower()
file_args = [os.fspath(name) for name in filenames]
if WIN and (
editor_lower in {"notepad", "notepad.exe"}
or editor_lower.endswith(("\\notepad.exe", "/notepad.exe"))
or (
len(editor) >= 3
and editor[1:3] == ":\\"
and editor[:1] not in "'\""
)
):
args = shlex.split(editor, posix=False) + file_args
else:
args = shlex.split(editor) + file_args
c = subprocess.Popen(
args=shlex.split(editor) + list(filenames),
args=args,
env=environ,
)
exit_code = c.wait()
Expand Down
49 changes: 49 additions & 0 deletions tests/test_termui.py
Original file line number Diff line number Diff line change
Expand Up @@ -594,6 +594,55 @@ def test_editor_windows_path_normalization(editor_cmd, expected_cmd):
assert mock_popen.call_args[1].get("shell") is None


def test_get_editor_windows_resolves_notepad(monkeypatch):
"""Issue #3840: default Windows editor must be a real notepad.exe path."""
monkeypatch.setattr(click._termui_impl, "WIN", True)
monkeypatch.delenv("VISUAL", raising=False)
monkeypatch.delenv("EDITOR", raising=False)
monkeypatch.setenv("SystemRoot", "C:\\Windows")

with patch("shutil.which", return_value=None):
editor = Editor().get_editor()

expected = str(pathlib.Path("C:\\Windows") / "System32" / "notepad.exe")
assert editor.lower().endswith("notepad.exe")
assert editor == expected


def test_edit_files_windows_notepad_full_path_argv(monkeypatch):
"""Issue #3840: unquoted System32 notepad path must not be POSIX-mangled."""
monkeypatch.setattr(click._termui_impl, "WIN", True)
notepad = "C:\\Windows\\System32\\notepad.exe"

with patch("subprocess.Popen") as mock_popen:
mock_popen.return_value.wait.return_value = 0
Editor(editor=notepad).edit_files(["f.txt"])

args = mock_popen.call_args[1].get("args") or mock_popen.call_args[0][0]
assert args == [notepad, "f.txt"]
assert mock_popen.call_args[1].get("shell") is None


def test_edit_files_windows_default_notepad_argv(monkeypatch):
"""Issue #3840: default editor launch uses argv[0] ending in notepad.exe."""
monkeypatch.setattr(click._termui_impl, "WIN", True)
monkeypatch.delenv("VISUAL", raising=False)
monkeypatch.delenv("EDITOR", raising=False)
monkeypatch.setenv("SystemRoot", "C:\\Windows")

with patch("shutil.which", return_value=None):
with patch("subprocess.Popen") as mock_popen:
mock_popen.return_value.wait.return_value = 0
Editor().edit_files(["f.txt"])

args = mock_popen.call_args[1].get("args") or mock_popen.call_args[0][0]
expected = str(pathlib.Path("C:\\Windows") / "System32" / "notepad.exe")
assert args[0].lower().endswith("notepad.exe")
assert args[0] == expected
assert args[1:] == ["f.txt"]
assert mock_popen.call_args[1].get("shell") is None


def test_editor_env_passed_through():
with patch("subprocess.Popen") as mock_popen:
mock_popen.return_value.wait.return_value = 0
Expand Down