diff --git a/changelog/55097.fixed.md b/changelog/55097.fixed.md new file mode 100644 index 00000000000..06e5e16d826 --- /dev/null +++ b/changelog/55097.fixed.md @@ -0,0 +1 @@ +Fixed pip.install so a local editable install with a Windows drive-letter path (e.g. C:/path or C:\path) is treated as local and no longer spuriously requires an #egg= fragment. diff --git a/salt/modules/pip.py b/salt/modules/pip.py index 6c91910895b..c2f90f983fa 100644 --- a/salt/modules/pip.py +++ b/salt/modules/pip.py @@ -953,8 +953,14 @@ def install( editable = [e.strip() for e in editable.split(",")] for entry in editable: - # Is the editable local? - if not (entry == "." or entry.startswith(("file://", "/"))): + # Is the editable local? Accept "." , file:// URLs, POSIX + # absolute paths, and Windows drive-letter paths (e.g. C:/foo + # or C:\foo). + if not ( + entry == "." + or entry.startswith(("file://", "/")) + or re.match(r"^[A-Za-z]:[\\/]", entry) + ): match = egg_match.search(entry) if not match or not match.group(1): diff --git a/tests/pytests/unit/modules/test_pip.py b/tests/pytests/unit/modules/test_pip.py index 9873102c231..7f88ab28560 100644 --- a/tests/pytests/unit/modules/test_pip.py +++ b/tests/pytests/unit/modules/test_pip.py @@ -200,6 +200,74 @@ def test_install_multiple_editable(python_binary): ) +@pytest.mark.parametrize( + "editable", + [ + "C:\\local-file-path", + "C:/local-file-path", + ], +) +def test_install_editable_local_windows_path_55097(python_binary, editable): + """ + A local Windows editable path (drive letter followed by \\ or /) must be + treated as local and must NOT require an #egg= fragment. + + Regression test for issue #55097. install() is the exec-module caller that + performs the editable validation; the state module delegates to it. The + call shape mirrors test_install_multiple_editable (no egg, default flags). + """ + expected = [*python_binary, "install", *TARGET, "--editable", editable] + + mock = MagicMock(return_value={"retcode": 0, "stdout": ""}) + with patch.dict(pip.__salt__, {"cmd.run_all": mock}): + pip.install(editable=editable) + mock.assert_called_with( + expected, + saltenv="base", + runas=None, + use_vt=False, + python_shell=False, + ) + + +def test_install_editable_without_egg_still_fails_55097(): + """ + Inverse / must-not-regress sibling for issue #55097. + + A remote VCS editable with no #egg= fragment must still raise. A multi-char + URL scheme (git+https:) cannot match the single-letter drive prefix, so the + Windows-path allowance does not weaken this validation. Passes with and + without the fix. + """ + mock = MagicMock(return_value={"retcode": 0, "stdout": ""}) + with patch.dict(pip.__salt__, {"cmd.run_all": mock}): + pytest.raises( + CommandExecutionError, + pip.install, + editable="git+https://github.com/saltstack/salt-testing.git", + ) + + +def test_install_editable_posix_local_path_55097(python_binary): + """ + Peripheral coverage for issue #55097: a POSIX absolute editable path was + already accepted without an #egg= fragment and must remain so. + """ + editable = "/home/user/local-file-path" + expected = [*python_binary, "install", *TARGET, "--editable", editable] + + mock = MagicMock(return_value={"retcode": 0, "stdout": ""}) + with patch.dict(pip.__salt__, {"cmd.run_all": mock}): + pip.install(editable=editable) + mock.assert_called_with( + expected, + saltenv="base", + runas=None, + use_vt=False, + python_shell=False, + ) + + def test_install_multiple_pkgs_and_editables(python_binary): pkgs = ["pep8", "salt"] editables = [