test_packages

tests/test_packages.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
import inspect
import os
import re
import sys
import sysconfig
from pathlib import Path
from subprocess import PIPE, run

import pytest
from pytest_mock import MockerFixture

from lektor_ng.environment import Environment
from lektor_ng.packages import Requirements, VirtualEnv, load_packages, update_cache
from lektor_ng.project import Project


@pytest.fixture(scope="module")
def nested_venv(tmp_path_factory: pytest.TempPathFactory) -> VirtualEnv:
    """Create a lighweight nested virtual environment.

    The created venv does not have anything installed in it — not even pip. It does,
    however, have our ``site-packages`` directory added to its ``sys.path``, so it
    should have access to a working ``pip`` that way.

    This lightweight venv is relative quick to create.  Creating a full independent
    venv involves running ``python -m ensurepip`` and potential network requests
    to PyPI.
    """
    tmp_path = tmp_path_factory.mktemp("nested_venv")
    venv = VirtualEnv(tmp_path)
    # venv creation is very quick without installing/upgrading pip
    venv.create(with_pip=False, upgrade_deps=False)

    # add our site-packages to the venv's sys.path
    venv.addsitedir(sysconfig.get_path("purelib"))
    return venv


def test_VirtualEnv_creates_site_packages(tmp_path: Path) -> None:
    venv = VirtualEnv(tmp_path)
    # venv creation is very quick without installing/upgrading pip
    venv.create(with_pip=False, upgrade_deps=False)
    assert Path(venv.site_packages).is_dir()


def test_VirtualEnv_addsitedir(nested_venv: VirtualEnv) -> None:
    # check that we can run pytest (presumably from our site-packages)
    proc = run((nested_venv.executable, "-m", "pytest", "--version"), check=False)
    assert proc.returncode == 0


@pytest.mark.skipif(os.name == "nt", reason="Windows")
def test_VirtualEnv_uses_symlinks(nested_venv: VirtualEnv) -> None:
    executable = Path(nested_venv.executable)
    assert executable.resolve(strict=True).parent != executable.parent


@pytest.mark.requiresinternet
@pytest.mark.slowtest
def test_VirtualEnv_run_pip_install(tmp_path: Path, data_path) -> None:
    # XXX: slow test
    venv = VirtualEnv(tmp_path)
    venv.create()

    # install a dummy plugin
    plugin_path = data_path / "pep660-dummy-plugin"
    dummy_plugin_path = os.fspath(plugin_path.resolve())
    venv.run_pip_install(f"--editable={dummy_plugin_path}")

    # Make our lektor available to the installed plugin
    venv.addsitedir(sysconfig.get_path("purelib"))

    # Check that we can load the plugin entry point
    prog = inspect.cleandoc(
        """
        import sys
        from importlib import metadata

        for ep in metadata.entry_points(group="lektor.plugins", name="dummy"):
            print(ep.load().__name__)
    """
    )
    proc = run((venv.executable, "-c", prog), stdout=PIPE, text=True, check=True)
    assert proc.stdout.strip() == "DummyPlugin"


def test_VirtualEnv_run_pip_install_raises_runtime_error(
    nested_venv: VirtualEnv, capfd: pytest.CaptureFixture[str]
) -> None:
    with pytest.raises(RuntimeError) as excinfo:
        nested_venv.run_pip_install("--unknown-option")
    assert excinfo.match("Failed to install")
    assert "no such option" in capfd.readouterr().err


def test_VirtualEnv_site_packages(tmp_path: Path) -> None:
    site_packages = VirtualEnv(tmp_path).site_packages
    relpath = os.fspath(Path(site_packages).relative_to(tmp_path))
    assert re.match(r"(?i)lib(?=[/\\]).*[/\\]site-packages\Z", relpath)


def test_VirtualEnv_executable(tmp_path: Path) -> None:
    executable = Path(VirtualEnv(tmp_path).executable)
    scripts_dir = os.fspath(executable.parent.relative_to(tmp_path))
    assert scripts_dir in {"bin", "Scripts"}
    assert executable.name == Path(sys.executable).name


def test_Requirements_add_requirement() -> None:
    requirements = Requirements()
    requirements.add_requirement("foo", "1.2")
    requirements.add_requirement("bar")
    assert len(requirements) == 2
    assert set(requirements) == {"foo==1.2", "bar"}


def test_Requirements_add_local_requirement() -> None:
    requirements = Requirements()
    plugin_path = Path(__file__).parent / "pep660-dummy-plugin"
    requirements.add_local_requirement(plugin_path)
    assert set(requirements) == {f"--editable={os.fspath(plugin_path.resolve())}"}


def test_Requirements_add_local_requirements_from(tmp_path: Path) -> None:
    for fn in ["plugin1/pyproject.toml", "plugin2/setup.py", "notaplugin/README.md"]:
        path = tmp_path / fn
        path.parent.mkdir(parents=True, exist_ok=True)
        path.touch()
    requirements = Requirements()
    requirements.add_local_requirements_from(tmp_path)
    assert len(requirements) == 2
    assert {req.rpartition(os.sep)[2] for req in requirements} == {"plugin1", "plugin2"}


def test_Requirements_add_local_requirements_from_missing_dir(tmp_path: Path) -> None:
    requirements = Requirements()
    requirements.add_local_requirements_from(tmp_path / "missing")
    assert len(requirements) == 0
    assert not requirements


def test_Requirements_hash() -> None:
    requirements = Requirements()
    assert requirements.hash() == "da39a3ee5e6b4b0d3255bfef95601890afd80709"
    requirements.add_requirement("foo", "42")
    assert requirements.hash() == "a44f078eab8bc1aa1ddfd111d63e24ff65131b4b"


def test_update_cache_installs_requirements(tmp_path: Path, mocker: MockerFixture) -> None:
    venv_path = tmp_path / "cache"
    venv_path.mkdir()
    VirtualEnv = mocker.patch("lektor_ng.packages.VirtualEnv")
    update_cache(venv_path, {"foo": "42"}, tmp_path / "packages")
    assert mocker.call().run_pip_install("foo==42") in VirtualEnv.mock_calls
    hash_file = venv_path / "lektor-requirements-hash.txt"
    assert hash_file.read_text().strip() == "a44f078eab8bc1aa1ddfd111d63e24ff65131b4b"


def test_update_cache_skips_install_if_up_to_date(tmp_path: Path, mocker: MockerFixture) -> None:
    venv_path = tmp_path / "cache"
    venv_path.mkdir()
    venv_path.joinpath("lektor-requirements-hash.txt").write_text("a44f078eab8bc1aa1ddfd111d63e24ff65131b4b\n")
    VirtualEnv = mocker.patch("lektor_ng.packages.VirtualEnv")
    update_cache(venv_path, {"foo": "42"}, tmp_path / "packages")
    assert VirtualEnv.mock_calls == []


def test_update_cache_removes_package_cache_if_no_requirements(tmp_path: Path) -> None:
    venv_path = tmp_path / "cache"
    venv_path.mkdir()

    update_cache(venv_path, {}, tmp_path / "missing")
    assert not venv_path.exists()


def test_load_packages_add_package_cache_to_sys_path(env: Environment) -> None:
    load_packages(env)
    venv_path = env.project.get_package_cache_path()
    site_packages = VirtualEnv(venv_path).site_packages
    assert site_packages in sys.path


PackageCacheType = Project.PackageCacheType


@pytest.mark.parametrize("cache_type", PackageCacheType)
def test_load_packages_reinstall_wipes_cache(env: Environment, cache_type: PackageCacheType) -> None:
    project = env.project
    cache_path = project.get_package_cache_path(cache_type)
    cache_path.mkdir(parents=True, exist_ok=False)

    load_packages(env, reinstall=True)
    assert not cache_path.exists()