diff --git a/src/click/_termui_impl.py b/src/click/_termui_impl.py index 70aabf8f1..63831ede1 100644 --- a/src/click/_termui_impl.py +++ b/src/click/_termui_impl.py @@ -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 @@ -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() @@ -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() diff --git a/tests/test_termui.py b/tests/test_termui.py index 1825b1987..5d0c20add 100644 --- a/tests/test_termui.py +++ b/tests/test_termui.py @@ -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