diff --git a/scripts/uv-pip-compat.sh b/scripts/uv-pip-compat.sh index f80303c4..a0964a2f 100644 --- a/scripts/uv-pip-compat.sh +++ b/scripts/uv-pip-compat.sh @@ -27,7 +27,8 @@ fi has_environment_option() { while [ "$#" -gt 0 ]; do case "$1" in - -p|--python|--python=*|-p*|--system) + -p|--python|--python=*|-p*|--system|--user|\ + -t|--target|--target=*|-t*|--prefix|--prefix=*) return 0 ;; --) @@ -39,6 +40,18 @@ has_environment_option() { return 1 } +uv_pip_with_venv_python() { + command_name="$1" + shift + + if [ -x "${SCRIPT_DIR}/python" ] && ! has_environment_option "$@"; then + # uv 不会仅凭 pip 软链接位置锁定 venv,本地安装也不会激活 venv。 + # 因此需要在会读取或改写环境的 pip 子命令上显式绑定当前 venv 解释器。 + exec "${UV_BIN}" pip "${command_name}" --python "${SCRIPT_DIR}/python" "$@" + fi + exec "${UV_BIN}" pip "${command_name}" "$@" +} + case "${COMMAND_NAME}" in pip|pip3|pip3.*) if [ "$#" -eq 0 ]; then @@ -53,13 +66,10 @@ case "${COMMAND_NAME}" in shift exec "${UV_BIN}" help pip "$@" ;; - check) - if [ -x "${SCRIPT_DIR}/python" ] && ! has_environment_option "$@"; then - # uv 不会仅凭 pip 软链接位置锁定 venv,显式绑定当前运行态解释器。 - shift - exec "${UV_BIN}" pip check --python "${SCRIPT_DIR}/python" "$@" - fi - exec "${UV_BIN}" pip "$@" + check|freeze|install|list|show|sync|tree|uninstall) + pip_command="$1" + shift + uv_pip_with_venv_python "${pip_command}" "$@" ;; *) exec "${UV_BIN}" pip "$@" @@ -70,7 +80,7 @@ case "${COMMAND_NAME}" in exec "${UV_BIN}" pip compile "$@" ;; pip-sync) - exec "${UV_BIN}" pip sync "$@" + uv_pip_with_venv_python sync "$@" ;; *) echo "不支持的 pip 兼容命令入口:${COMMAND_NAME}" >&2 diff --git a/tests/test_uv_pip_compat.py b/tests/test_uv_pip_compat.py new file mode 100644 index 00000000..4a6b329c --- /dev/null +++ b/tests/test_uv_pip_compat.py @@ -0,0 +1,79 @@ +from __future__ import annotations + +import os +import shutil +import subprocess +import tempfile +import unittest +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +WRAPPER = ROOT / "scripts" / "uv-pip-compat.sh" + + +class UvPipCompatTests(unittest.TestCase): + def run_wrapper(self, link_name: str, *args: str) -> list[str]: + with tempfile.TemporaryDirectory() as temp_dir: + venv_bin = Path(temp_dir) / "venv" / "bin" + venv_bin.mkdir(parents=True) + (venv_bin / "python").write_text("#!/bin/sh\nexit 0\n", encoding="utf-8") + (venv_bin / "python").chmod(0o755) + + argv_file = Path(temp_dir) / "argv.txt" + uv_bin = venv_bin / "uv" + uv_bin.write_text( + "#!/bin/sh\n" + # 测试只关心兼容层传给 uv 的参数,逐行记录可以避免 shell 转义差异干扰断言。 + f"for arg in \"$@\"; do printf '%s\\n' \"$arg\" >> '{argv_file}'; done\n", + encoding="utf-8", + ) + uv_bin.chmod(0o755) + + wrapper_path = venv_bin / "uv-pip-compat" + shutil.copy2(WRAPPER, wrapper_path) + wrapper_path.chmod(0o755) + + link_path = venv_bin / link_name + link_path.symlink_to(wrapper_path.name) + + subprocess.run( + [str(link_path), *args], + check=True, + env={ + **os.environ, + "PATH": f"{venv_bin}{os.pathsep}{os.environ.get('PATH', '')}", + }, + ) + return argv_file.read_text(encoding="utf-8").splitlines() + + def test_pip_install_binds_venv_python(self): + argv = self.run_wrapper("pip", "install", "-r", "requirements.txt") + + self.assertEqual( + [ + "pip", + "install", + "--python", + argv[3], + "-r", + "requirements.txt", + ], + argv, + ) + self.assertTrue(argv[3].endswith("/venv/bin/python")) + + def test_pip_install_keeps_explicit_environment(self): + argv = self.run_wrapper("pip", "install", "--system", "demo-package") + + self.assertEqual(["pip", "install", "--system", "demo-package"], argv) + + def test_pip_sync_binds_venv_python(self): + argv = self.run_wrapper("pip-sync", "requirements.txt") + + self.assertEqual(["pip", "sync", "--python", argv[3], "requirements.txt"], argv) + self.assertTrue(argv[3].endswith("/venv/bin/python")) + + +if __name__ == "__main__": + unittest.main()