forked from lektor/lektor
-
-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
backport(lektor#1065): an alternative fix for installing local plugins.
Here we create a "nested" virtual environment in which to install our plugins. By "nested" we mean that it keeps the `site-packages` directory of the "containing" virtual environment (or the system, if there is no containing virtual environment on sys.path. (It does this via some `.pth` file magic.) This allows us to use plain old `pip install` to install plugins, rather than having to resort to `pip install --target <dir>` which was causing all sorts of grief. This also allows us to generalize our plugin installation machinery to support any PEP517 / PEP660 compatible distribution. We've also cleaned up detection of changed requirements. Previously, the package cache was created whenever: - Any new plugin (local or remote) was added to the project - Any plugin (local or remote) was removed from the project - The specified version for any remote plugin was changed Now that we are using a real virtualenv to manage installation, we could probably loosen these requirements, and let pip deal with, e.g, reconciling changes in requested package version. For now, we keep the existing behavior, but clean up the logic for detecting when requirements have changed.
- Loading branch information
Showing
8 changed files
with
483 additions
and
231 deletions.
There are no files selected for viewing
Large diffs are not rendered by default.
Oops, something went wrong.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,9 @@ | ||
"""Dummy test plugin""" | ||
from lektor.pluginsystem import Plugin | ||
|
||
|
||
class DummyPlugin(Plugin): | ||
"""Dummy test plugin.""" | ||
|
||
# pylint: disable=too-few-public-methods | ||
name = "dummy" |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,13 @@ | ||
from setuptools import setup | ||
|
||
setup( | ||
name="lektor-dummy-plugin", | ||
description="setup.py test plugin", | ||
version="0.1a42", | ||
py_modules=["lektor_dummy_plugin"], | ||
entry_points={ | ||
"lektor.plugins": [ | ||
"dummy = lektor_dummy_plugin:DummyPlugin", | ||
] | ||
}, | ||
) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,201 @@ | ||
import inspect | ||
import os | ||
import re | ||
import sys | ||
import sysconfig | ||
from pathlib import Path | ||
from subprocess import PIPE | ||
from subprocess import run | ||
|
||
import pytest | ||
from pytest_mock import MockerFixture | ||
|
||
from lektor.environment import Environment | ||
from lektor.packages import load_packages | ||
from lektor.packages import Requirements | ||
from lektor.packages import update_cache | ||
from lektor.packages import VirtualEnv | ||
from lektor.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.requiresinternet | ||
def test_VirtualEnv_run_pip_install(tmp_path: Path) -> None: | ||
# XXX: slow test | ||
venv = VirtualEnv(tmp_path) | ||
venv.create() | ||
|
||
# install a dummy plugin | ||
plugin_path = Path(__file__).parent / "setup_py-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 | ||
if sys.version_info < (3, 10): | ||
# need "selectable" entry_points | ||
import importlib_metadata as metadata | ||
else: | ||
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, encoding="utf-8", 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 = VirtualEnv(tmp_path).executable | ||
relpath = os.fspath(Path(executable).relative_to(tmp_path)) | ||
assert re.match(r"(?i)(?:bin|Scripts)[/\\]python(?:\.\w*)?\Z", relpath) | ||
|
||
|
||
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 / "setup_py-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.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.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() |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters