546 lines
18 KiB
Python
546 lines
18 KiB
Python
from __future__ import annotations
|
|
|
|
import contextlib
|
|
import os
|
|
import re
|
|
import site
|
|
import subprocess
|
|
import sys
|
|
|
|
from pathlib import Path
|
|
from threading import Thread
|
|
from typing import TYPE_CHECKING
|
|
|
|
import pytest
|
|
|
|
from poetry.factory import Factory
|
|
from poetry.repositories.installed_repository import InstalledRepository
|
|
from poetry.utils._compat import WINDOWS
|
|
from poetry.utils._compat import metadata
|
|
from poetry.utils.env import EnvCommandError
|
|
from poetry.utils.env import EnvManager
|
|
from poetry.utils.env import GenericEnv
|
|
from poetry.utils.env import MockEnv
|
|
from poetry.utils.env import SystemEnv
|
|
from poetry.utils.env import VirtualEnv
|
|
from poetry.utils.env import build_environment
|
|
|
|
|
|
if TYPE_CHECKING:
|
|
|
|
from pytest_mock import MockerFixture
|
|
|
|
from poetry.poetry import Poetry
|
|
from tests.types import FixtureDirGetter
|
|
|
|
MINIMAL_SCRIPT = """\
|
|
|
|
print("Minimal Output"),
|
|
"""
|
|
|
|
# Script expected to fail.
|
|
ERRORING_SCRIPT = """\
|
|
import nullpackage
|
|
|
|
print("nullpackage loaded"),
|
|
"""
|
|
|
|
|
|
class MockVirtualEnv(VirtualEnv):
|
|
def __init__(
|
|
self,
|
|
path: Path,
|
|
base: Path | None = None,
|
|
sys_path: list[str] | None = None,
|
|
) -> None:
|
|
super().__init__(path, base=base)
|
|
|
|
self._sys_path = sys_path
|
|
|
|
@property
|
|
def sys_path(self) -> list[str]:
|
|
if self._sys_path is not None:
|
|
return self._sys_path
|
|
|
|
return super().sys_path
|
|
|
|
|
|
def test_virtualenvs_with_spaces_in_their_path_work_as_expected(
|
|
tmp_path: Path, manager: EnvManager
|
|
) -> None:
|
|
venv_path = tmp_path / "Virtual Env"
|
|
|
|
manager.build_venv(venv_path)
|
|
|
|
venv = VirtualEnv(venv_path)
|
|
|
|
assert venv.run("python", "-V").startswith("Python")
|
|
|
|
|
|
def test_env_commands_with_spaces_in_their_arg_work_as_expected(
|
|
tmp_path: Path, manager: EnvManager
|
|
) -> None:
|
|
venv_path = tmp_path / "Virtual Env"
|
|
manager.build_venv(venv_path)
|
|
venv = VirtualEnv(venv_path)
|
|
output = venv.run("python", str(venv.pip), "--version")
|
|
assert re.match(r"pip \S+ from", output)
|
|
|
|
|
|
def test_env_get_supported_tags_matches_inside_virtualenv(
|
|
tmp_path: Path, manager: EnvManager
|
|
) -> None:
|
|
venv_path = tmp_path / "Virtual Env"
|
|
manager.build_venv(venv_path)
|
|
venv = VirtualEnv(venv_path)
|
|
|
|
import packaging.tags
|
|
|
|
assert venv.get_supported_tags() == list(packaging.tags.sys_tags())
|
|
|
|
|
|
@pytest.mark.skipif(os.name == "nt", reason="Symlinks are not support for Windows")
|
|
def test_env_has_symlinks_on_nix(tmp_path: Path, tmp_venv: VirtualEnv) -> None:
|
|
assert os.path.islink(tmp_venv.python)
|
|
|
|
|
|
def test_run_with_keyboard_interrupt(
|
|
tmp_path: Path, tmp_venv: VirtualEnv, mocker: MockerFixture
|
|
) -> None:
|
|
mocker.patch("subprocess.check_output", side_effect=KeyboardInterrupt())
|
|
with pytest.raises(KeyboardInterrupt):
|
|
tmp_venv.run("python", "-c", MINIMAL_SCRIPT)
|
|
subprocess.check_output.assert_called_once() # type: ignore[attr-defined]
|
|
|
|
|
|
def test_call_with_keyboard_interrupt(
|
|
tmp_path: Path, tmp_venv: VirtualEnv, mocker: MockerFixture
|
|
) -> None:
|
|
mocker.patch("subprocess.check_call", side_effect=KeyboardInterrupt())
|
|
kwargs = {"call": True}
|
|
with pytest.raises(KeyboardInterrupt):
|
|
tmp_venv.run("python", "-", **kwargs)
|
|
subprocess.check_call.assert_called_once() # type: ignore[attr-defined]
|
|
|
|
|
|
def test_run_with_called_process_error(
|
|
tmp_path: Path, tmp_venv: VirtualEnv, mocker: MockerFixture
|
|
) -> None:
|
|
mocker.patch(
|
|
"subprocess.check_output",
|
|
side_effect=subprocess.CalledProcessError(
|
|
42, "some_command", "some output", "some error"
|
|
),
|
|
)
|
|
with pytest.raises(EnvCommandError) as error:
|
|
tmp_venv.run("python", "-c", MINIMAL_SCRIPT)
|
|
subprocess.check_output.assert_called_once() # type: ignore[attr-defined]
|
|
assert "some output" in str(error.value)
|
|
assert "some error" in str(error.value)
|
|
|
|
|
|
def test_call_no_input_with_called_process_error(
|
|
tmp_path: Path, tmp_venv: VirtualEnv, mocker: MockerFixture
|
|
) -> None:
|
|
mocker.patch(
|
|
"subprocess.check_call",
|
|
side_effect=subprocess.CalledProcessError(
|
|
42, "some_command", "some output", "some error"
|
|
),
|
|
)
|
|
kwargs = {"call": True}
|
|
with pytest.raises(EnvCommandError) as error:
|
|
tmp_venv.run("python", "-", **kwargs)
|
|
subprocess.check_call.assert_called_once() # type: ignore[attr-defined]
|
|
assert "some output" in str(error.value)
|
|
assert "some error" in str(error.value)
|
|
|
|
|
|
def test_check_output_with_called_process_error(
|
|
tmp_path: Path, tmp_venv: VirtualEnv, mocker: MockerFixture
|
|
) -> None:
|
|
mocker.patch(
|
|
"subprocess.check_output",
|
|
side_effect=subprocess.CalledProcessError(
|
|
42, "some_command", "some output", "some error"
|
|
),
|
|
)
|
|
with pytest.raises(EnvCommandError) as error:
|
|
tmp_venv.run("python", "-")
|
|
subprocess.check_output.assert_called_once() # type: ignore[attr-defined]
|
|
assert "some output" in str(error.value)
|
|
assert "some error" in str(error.value)
|
|
|
|
|
|
@pytest.mark.parametrize("out", ["sys.stdout", "sys.stderr"])
|
|
def test_call_does_not_block_on_full_pipe(
|
|
tmp_path: Path, tmp_venv: VirtualEnv, out: str
|
|
) -> None:
|
|
"""see https://github.com/python-poetry/poetry/issues/7698"""
|
|
script = tmp_path / "script.py"
|
|
script.write_text(
|
|
f"""\
|
|
import sys
|
|
for i in range(10000):
|
|
print('just print a lot of text to fill the buffer', file={out})
|
|
"""
|
|
)
|
|
|
|
def target(result: list[int]) -> None:
|
|
tmp_venv.run("python", str(script), call=True)
|
|
result.append(0)
|
|
|
|
results: list[int] = []
|
|
# use a separate thread, so that the test does not block in case of error
|
|
thread = Thread(target=target, args=(results,))
|
|
thread.start()
|
|
thread.join(1) # must not block
|
|
assert results and results[0] == 0
|
|
|
|
|
|
def test_run_python_script_called_process_error(
|
|
tmp_path: Path, tmp_venv: VirtualEnv, mocker: MockerFixture
|
|
) -> None:
|
|
mocker.patch(
|
|
"subprocess.run",
|
|
side_effect=subprocess.CalledProcessError(
|
|
42, "some_command", "some output", "some error"
|
|
),
|
|
)
|
|
with pytest.raises(EnvCommandError) as error:
|
|
tmp_venv.run_python_script(MINIMAL_SCRIPT)
|
|
assert "some output" in str(error.value)
|
|
assert "some error" in str(error.value)
|
|
|
|
|
|
def test_run_python_script_only_stdout(tmp_path: Path, tmp_venv: VirtualEnv) -> None:
|
|
output = tmp_venv.run_python_script(
|
|
"import sys; print('some warning', file=sys.stderr); print('some output')"
|
|
)
|
|
assert "some output" in output
|
|
assert "some warning" not in output
|
|
|
|
|
|
def test_system_env_has_correct_paths() -> None:
|
|
env = SystemEnv(Path(sys.prefix))
|
|
|
|
paths = env.paths
|
|
|
|
assert paths.get("purelib") is not None
|
|
assert paths.get("platlib") is not None
|
|
assert paths.get("scripts") is not None
|
|
assert env.site_packages.path == Path(paths["purelib"])
|
|
assert paths["include"] is not None
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"enabled",
|
|
[True, False],
|
|
)
|
|
def test_system_env_usersite(mocker: MockerFixture, enabled: bool) -> None:
|
|
mocker.patch("site.check_enableusersite", return_value=enabled)
|
|
env = SystemEnv(Path(sys.prefix))
|
|
assert (enabled and env.usersite is not None) or (
|
|
not enabled and env.usersite is None
|
|
)
|
|
|
|
|
|
def test_venv_has_correct_paths(tmp_venv: VirtualEnv) -> None:
|
|
paths = tmp_venv.paths
|
|
|
|
assert paths.get("purelib") is not None
|
|
assert paths.get("platlib") is not None
|
|
assert paths.get("scripts") is not None
|
|
assert tmp_venv.site_packages.path == Path(paths["purelib"])
|
|
assert paths["include"] == str(
|
|
tmp_venv.path.joinpath(
|
|
f"include/site/python{tmp_venv.version_info[0]}.{tmp_venv.version_info[1]}"
|
|
)
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize("with_system_site_packages", [True, False])
|
|
def test_env_system_packages(
|
|
tmp_path: Path, poetry: Poetry, with_system_site_packages: bool
|
|
) -> None:
|
|
venv_path = tmp_path / "venv"
|
|
pyvenv_cfg = venv_path / "pyvenv.cfg"
|
|
|
|
EnvManager(poetry).build_venv(
|
|
path=venv_path, flags={"system-site-packages": with_system_site_packages}
|
|
)
|
|
env = VirtualEnv(venv_path)
|
|
|
|
assert (
|
|
f"include-system-site-packages = {str(with_system_site_packages).lower()}"
|
|
in pyvenv_cfg.read_text()
|
|
)
|
|
assert env.includes_system_site_packages is with_system_site_packages
|
|
|
|
|
|
def test_generic_env_system_packages(poetry: Poetry) -> None:
|
|
"""https://github.com/python-poetry/poetry/issues/8646"""
|
|
env = GenericEnv(Path(sys.base_prefix))
|
|
assert not env.includes_system_site_packages
|
|
|
|
|
|
@pytest.mark.parametrize("with_system_site_packages", [True, False])
|
|
def test_env_system_packages_are_relative_to_lib(
|
|
tmp_path: Path, poetry: Poetry, with_system_site_packages: bool
|
|
) -> None:
|
|
venv_path = tmp_path / "venv"
|
|
EnvManager(poetry).build_venv(
|
|
path=venv_path, flags={"system-site-packages": with_system_site_packages}
|
|
)
|
|
env = VirtualEnv(venv_path)
|
|
site_dir = Path(site.getsitepackages()[-1])
|
|
for dist in metadata.distributions():
|
|
# Emulate is_relative_to, only available in 3.9+
|
|
with contextlib.suppress(ValueError):
|
|
dist._path.relative_to(site_dir) # type: ignore[attr-defined]
|
|
break
|
|
assert (
|
|
env.is_path_relative_to_lib(dist._path) # type: ignore[attr-defined]
|
|
is with_system_site_packages
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("flags", "packages"),
|
|
[
|
|
({"no-pip": False}, {"pip"}),
|
|
({"no-pip": False, "no-wheel": True}, {"pip"}),
|
|
({"no-pip": False, "no-wheel": False}, {"pip", "wheel"}),
|
|
({"no-pip": True}, set()),
|
|
({"no-setuptools": False}, {"setuptools"}),
|
|
({"no-setuptools": True}, set()),
|
|
({"setuptools": "bundle"}, {"setuptools"}),
|
|
({"no-pip": True, "no-setuptools": False}, {"setuptools"}),
|
|
({"no-wheel": False}, {"wheel"}),
|
|
({"wheel": "bundle"}, {"wheel"}),
|
|
({}, set()),
|
|
],
|
|
)
|
|
def test_env_no_pip(
|
|
tmp_path: Path, poetry: Poetry, flags: dict[str, str | bool], packages: set[str]
|
|
) -> None:
|
|
venv_path = tmp_path / "venv"
|
|
EnvManager(poetry).build_venv(path=venv_path, flags=flags)
|
|
env = VirtualEnv(venv_path)
|
|
installed_repository = InstalledRepository.load(env=env, with_dependencies=True)
|
|
installed_packages = {
|
|
package.name
|
|
for package in installed_repository.packages
|
|
# workaround for BSD test environments
|
|
if package.name != "sqlite3"
|
|
}
|
|
|
|
# For python >= 3.12, virtualenv defaults to "--no-setuptools" and "--no-wheel"
|
|
# behaviour, so setting these values to False becomes meaningless.
|
|
if sys.version_info >= (3, 12):
|
|
if not flags.get("no-setuptools", True):
|
|
packages.discard("setuptools")
|
|
if not flags.get("no-wheel", True):
|
|
packages.discard("wheel")
|
|
|
|
assert installed_packages == packages
|
|
|
|
|
|
def test_env_finds_the_correct_executables(tmp_path: Path, manager: EnvManager) -> None:
|
|
venv_path = tmp_path / "Virtual Env"
|
|
manager.build_venv(venv_path, with_pip=True)
|
|
venv = VirtualEnv(venv_path)
|
|
|
|
default_executable = expected_executable = f"python{'.exe' if WINDOWS else ''}"
|
|
default_pip_executable = expected_pip_executable = f"pip{'.exe' if WINDOWS else ''}"
|
|
major_executable = f"python{sys.version_info[0]}{'.exe' if WINDOWS else ''}"
|
|
major_pip_executable = f"pip{sys.version_info[0]}{'.exe' if WINDOWS else ''}"
|
|
|
|
if (
|
|
venv._bin_dir.joinpath(default_executable).exists()
|
|
and venv._bin_dir.joinpath(major_executable).exists()
|
|
):
|
|
venv._bin_dir.joinpath(default_executable).unlink()
|
|
expected_executable = major_executable
|
|
|
|
if (
|
|
venv._bin_dir.joinpath(default_pip_executable).exists()
|
|
and venv._bin_dir.joinpath(major_pip_executable).exists()
|
|
):
|
|
venv._bin_dir.joinpath(default_pip_executable).unlink()
|
|
expected_pip_executable = major_pip_executable
|
|
|
|
venv = VirtualEnv(venv_path)
|
|
|
|
assert Path(venv.python).name == expected_executable
|
|
assert Path(venv.pip).name.startswith(expected_pip_executable.split(".")[0])
|
|
|
|
|
|
def test_env_finds_the_correct_executables_for_generic_env(
|
|
tmp_path: Path, manager: EnvManager
|
|
) -> None:
|
|
venv_path = tmp_path / "Virtual Env"
|
|
child_venv_path = tmp_path / "Child Virtual Env"
|
|
manager.build_venv(venv_path, with_pip=True)
|
|
parent_venv = VirtualEnv(venv_path)
|
|
manager.build_venv(child_venv_path, executable=parent_venv.python, with_pip=True)
|
|
venv = GenericEnv(parent_venv.path, child_env=VirtualEnv(child_venv_path))
|
|
|
|
expected_executable = (
|
|
f"python{sys.version_info[0]}.{sys.version_info[1]}{'.exe' if WINDOWS else ''}"
|
|
)
|
|
expected_pip_executable = (
|
|
f"pip{sys.version_info[0]}.{sys.version_info[1]}{'.exe' if WINDOWS else ''}"
|
|
)
|
|
|
|
if WINDOWS:
|
|
expected_executable = "python.exe"
|
|
expected_pip_executable = "pip.exe"
|
|
|
|
assert Path(venv.python).name == expected_executable
|
|
assert Path(venv.pip).name == expected_pip_executable
|
|
|
|
|
|
def test_env_finds_fallback_executables_for_generic_env(
|
|
tmp_path: Path, manager: EnvManager
|
|
) -> None:
|
|
venv_path = tmp_path / "Virtual Env"
|
|
child_venv_path = tmp_path / "Child Virtual Env"
|
|
manager.build_venv(venv_path, with_pip=True)
|
|
parent_venv = VirtualEnv(venv_path)
|
|
manager.build_venv(child_venv_path, executable=parent_venv.python, with_pip=True)
|
|
venv = GenericEnv(parent_venv.path, child_env=VirtualEnv(child_venv_path))
|
|
|
|
default_executable = f"python{'.exe' if WINDOWS else ''}"
|
|
major_executable = f"python{sys.version_info[0]}{'.exe' if WINDOWS else ''}"
|
|
minor_executable = (
|
|
f"python{sys.version_info[0]}.{sys.version_info[1]}{'.exe' if WINDOWS else ''}"
|
|
)
|
|
expected_executable = minor_executable
|
|
if (
|
|
venv._bin_dir.joinpath(expected_executable).exists()
|
|
and venv._bin_dir.joinpath(major_executable).exists()
|
|
):
|
|
venv._bin_dir.joinpath(expected_executable).unlink()
|
|
expected_executable = major_executable
|
|
|
|
if (
|
|
venv._bin_dir.joinpath(expected_executable).exists()
|
|
and venv._bin_dir.joinpath(default_executable).exists()
|
|
):
|
|
venv._bin_dir.joinpath(expected_executable).unlink()
|
|
expected_executable = default_executable
|
|
|
|
default_pip_executable = f"pip{'.exe' if WINDOWS else ''}"
|
|
major_pip_executable = f"pip{sys.version_info[0]}{'.exe' if WINDOWS else ''}"
|
|
minor_pip_executable = (
|
|
f"pip{sys.version_info[0]}.{sys.version_info[1]}{'.exe' if WINDOWS else ''}"
|
|
)
|
|
expected_pip_executable = minor_pip_executable
|
|
if (
|
|
venv._bin_dir.joinpath(expected_pip_executable).exists()
|
|
and venv._bin_dir.joinpath(major_pip_executable).exists()
|
|
):
|
|
venv._bin_dir.joinpath(expected_pip_executable).unlink()
|
|
expected_pip_executable = major_pip_executable
|
|
|
|
if (
|
|
venv._bin_dir.joinpath(expected_pip_executable).exists()
|
|
and venv._bin_dir.joinpath(default_pip_executable).exists()
|
|
):
|
|
venv._bin_dir.joinpath(expected_pip_executable).unlink()
|
|
expected_pip_executable = default_pip_executable
|
|
|
|
if not venv._bin_dir.joinpath(expected_executable).exists():
|
|
expected_executable = default_executable
|
|
|
|
if not venv._bin_dir.joinpath(expected_pip_executable).exists():
|
|
expected_pip_executable = default_pip_executable
|
|
|
|
venv = GenericEnv(parent_venv.path, child_env=VirtualEnv(child_venv_path))
|
|
|
|
assert Path(venv.python).name == expected_executable
|
|
assert Path(venv.pip).name == expected_pip_executable
|
|
|
|
|
|
@pytest.fixture
|
|
def extended_without_setup_poetry(fixture_dir: FixtureDirGetter) -> Poetry:
|
|
poetry = Factory().create_poetry(fixture_dir("extended_project_without_setup"))
|
|
|
|
return poetry
|
|
|
|
|
|
def test_build_environment_called_build_script_specified(
|
|
mocker: MockerFixture, extended_without_setup_poetry: Poetry, tmp_path: Path
|
|
) -> None:
|
|
project_env = MockEnv(path=tmp_path / "project")
|
|
ephemeral_env = MockEnv(path=tmp_path / "ephemeral")
|
|
|
|
mocker.patch(
|
|
"poetry.utils.env.ephemeral_environment"
|
|
).return_value.__enter__.return_value = ephemeral_env
|
|
|
|
with build_environment(extended_without_setup_poetry, project_env) as env:
|
|
assert env == ephemeral_env
|
|
assert env.executed == [ # type: ignore[attr-defined]
|
|
[
|
|
str(sys.executable),
|
|
str(env.pip_embedded),
|
|
"install",
|
|
"--disable-pip-version-check",
|
|
"--ignore-installed",
|
|
"--no-input",
|
|
*extended_without_setup_poetry.pyproject.build_system.requires,
|
|
]
|
|
]
|
|
|
|
|
|
def test_build_environment_not_called_without_build_script_specified(
|
|
mocker: MockerFixture, poetry: Poetry, tmp_path: Path
|
|
) -> None:
|
|
project_env = MockEnv(path=tmp_path / "project")
|
|
ephemeral_env = MockEnv(path=tmp_path / "ephemeral")
|
|
|
|
mocker.patch(
|
|
"poetry.utils.env.ephemeral_environment"
|
|
).return_value.__enter__.return_value = ephemeral_env
|
|
|
|
with build_environment(poetry, project_env) as env:
|
|
assert env == project_env
|
|
assert not env.executed # type: ignore[attr-defined]
|
|
|
|
|
|
def test_fallback_on_detect_active_python(
|
|
poetry: Poetry, mocker: MockerFixture
|
|
) -> None:
|
|
m = mocker.patch(
|
|
"subprocess.check_output",
|
|
side_effect=subprocess.CalledProcessError(1, "some command"),
|
|
)
|
|
env_manager = EnvManager(poetry)
|
|
active_python = env_manager._detect_active_python()
|
|
|
|
assert active_python is None
|
|
assert m.call_count == 1
|
|
|
|
|
|
@pytest.mark.skipif(sys.platform != "win32", reason="Windows only")
|
|
def test_detect_active_python_with_bat(poetry: Poetry, tmp_path: Path) -> None:
|
|
"""On Windows pyenv uses batch files for python management."""
|
|
python_wrapper = tmp_path / "python.bat"
|
|
wrapped_python = Path(r"C:\SpecialPython\python.exe")
|
|
with python_wrapper.open("w") as f:
|
|
f.write(f"@echo {wrapped_python}")
|
|
os.environ["PATH"] = str(python_wrapper.parent) + os.pathsep + os.environ["PATH"]
|
|
|
|
active_python = EnvManager(poetry)._detect_active_python()
|
|
|
|
assert active_python == wrapped_python
|
|
|
|
|
|
def test_command_from_bin_preserves_relative_path(manager: EnvManager) -> None:
|
|
# https://github.com/python-poetry/poetry/issues/7959
|
|
env = manager.get()
|
|
command = env.get_command_from_bin("./foo.py")
|
|
assert command == ["./foo.py"]
|