Coverage for src/lektor_ng/packages.py: 73%
142 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-08-03 21:08 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-08-03 21:08 +0000
1from __future__ import annotations
3import hashlib
4import os
5import shutil
6import site
7import subprocess
8import sys
9import sysconfig
10from collections.abc import Iterable, Iterator, Sized
11from pathlib import Path
12from typing import TYPE_CHECKING
13from venv import EnvBuilder
15import click
16import requests
18if TYPE_CHECKING:
19 from _typeshed import StrPath
20 from lektor.environment import Environment # circ dependency
21else:
22 StrPath = object
25def _get_package_version_from_project(cfg, name):
26 choices = (name.lower(), "lektor-" + name.lower())
27 for pkg, version in cfg.section_as_dict("packages").items():
28 if pkg.lower() in choices:
29 return {"name": pkg, "version": version}
30 return None
33def add_package_to_project(project, req):
34 """Given a package requirement this returns the information about this
35 plugin.
36 """
37 if "@" in req:
38 name, version = req.split("@", 1)
39 version_hint = version
40 else:
41 name = req
42 version = None
43 version_hint = "latest release"
45 cfg = project.open_config()
46 info = _get_package_version_from_project(cfg, name)
47 if info is not None:
48 raise RuntimeError("The package was already added to the project.")
50 for choice in name, "lektor-" + name:
51 rv = requests.get(f"https://pypi.python.org/pypi/{choice}/json", timeout=10)
52 if rv.status_code != 200:
53 continue
55 data = rv.json()
56 canonical_name = data["info"]["name"]
57 if version is None:
58 version = data["info"]["version"]
59 version_info = data["releases"].get(version)
60 if version_info is None:
61 raise RuntimeError(f"Latest requested version ({version_hint}) could not be found")
63 cfg[f"packages.{canonical_name}"] = version
64 cfg.save()
65 return {"name": canonical_name, "version": version}
67 raise RuntimeError("The package could not be found on PyPI")
70def remove_package_from_project(project, name):
71 cfg = project.open_config()
72 choices = (name.lower(), "lektor-" + name.lower())
73 for pkg, version in cfg.section_as_dict("packages").items():
74 if pkg.lower() in choices:
75 del cfg[f"packages.{pkg}"]
76 cfg.save()
77 return {"name": pkg, "version": version}
78 return None
81if os.name == "nt":
82 _default_venv_symlinks = False
83else:
84 _default_venv_symlinks = True
87class VirtualEnv:
88 """A helper for manipulating our private package cache virtual environment.
90 Parameters:
92 path — The path to the virtual environment to manage. This can be an existing
93 environment or not.
95 """
97 def __init__(self, path: StrPath):
98 self.path = Path(path)
100 def create(
101 self,
102 with_pip: bool = True,
103 upgrade_deps: bool = True,
104 symlinks: bool = _default_venv_symlinks,
105 ) -> None:
106 """(Re-)Create a new virtual environment.
108 This will remove any existing virtual environment and create a new one.
110 The parameters ``with_pip`` and ``upgrade_deps`` should probably be left at
111 their default values in normal usage. They are provided here mostly for use in
112 tests. They work as described for ``venv.EnvBuilder`` from the standard library.
113 (Though ``upgrade_deps`` is only supported by EnvBuilder`` in py39+, here we
114 emulate it's behavior if running under older pythons.)
116 """
117 # Right now, by default, we always install and upgrade pip to
118 # the latest available version.
119 #
120 # We could optimize by not installing (and not upgrading) pip if
121 # the system pip is sufficient to our needs.
122 #
123 # Note that, e.g., pip>=21.3 is required to support PEP660 editable
124 # installs.
125 env_builder = EnvBuilder(
126 clear=True,
127 with_pip=with_pip,
128 upgrade_deps=upgrade_deps,
129 symlinks=symlinks,
130 )
131 env_builder.create(self.path)
133 def addsitedir(self, sitedir: str) -> None:
134 """Add an additional sitedir to sys.path for virtual environment.
136 Packages installed in ``sitedir`` will be made available to any invocations
137 of python running within the virtual environment.
138 """
139 with Path(self.site_packages, "_lektor.pth").open("a", encoding="utf-8") as fp:
140 fp.write(f"import site; site.addsitedir({sitedir!r})\n")
142 def run_pip_install(self, *args: str) -> None:
143 """Run `pip install` in the virtual environment.
145 ``Args`` are appended to the command line (following ``pip install``). They
146 should specify how and which packages to install.
148 """
149 try:
150 subprocess.run((self.executable, "-m", "pip", "install", *args), check=True)
151 except subprocess.CalledProcessError as exc:
152 raise RuntimeError("Failed to install dependency package.") from exc
154 @property
155 def site_packages(self) -> str:
156 """The path to the virtual environments ``site-packages`` directory."""
157 return self._get_path("purelib")
159 @property
160 def executable(self) -> str:
161 """The path to the python interpreter for the virtual environment."""
162 script_path = Path(self._get_path("scripts"))
163 executable_name = Path(sys.executable).name
164 return os.fspath(script_path / executable_name)
166 def _get_path(self, name: str) -> str:
167 vars = {"base": os.fspath(self.path)}
168 return sysconfig.get_path(name, vars=vars)
171class Requirements(Iterable[str], Sized):
172 """Manage package requirements."""
174 requirements: set[str]
176 def __init__(self) -> None:
177 self.requirements = set()
179 def __len__(self) -> int:
180 return len(self.requirements)
182 def __iter__(self) -> Iterator[str]:
183 """The requirements.
185 These requirements are in the form of arguments that can be passed to ``pip
186 install``.
187 """
188 return iter(self.requirements)
190 def add_requirement(self, package: str, version: str | None = None) -> None:
191 """Add a (remote) distribution to the requirements."""
192 self.requirements.add(f"{package}=={version}" if version else f"{package}")
194 def add_local_requirement(self, path: StrPath) -> None:
195 """Add a local distribution source directory to the requirements.
197 The distribution source at ``path`` (which should be a legacy `setup.py` or
198 modern PEP660-compatible project) will be installed in editable mode.
200 """
201 srcdir = os.fspath(Path(path).resolve())
202 self.requirements.add(f"--editable={srcdir}")
204 _DIST_FILES = ("setup.py", "pyproject.toml")
206 def add_local_requirements_from(self, packages_path: StrPath) -> None:
207 """Add sub-directories of path that look like local distribution sources.
209 Any direct sub-directories of ``packages_path`` which appear to be distribution
210 source code will be added to the requirements in local (editable) mode.
212 """
213 try:
214 for path in Path(packages_path).iterdir():
215 if any(path.joinpath(fn).is_file() for fn in self._DIST_FILES):
216 self.add_local_requirement(path)
217 except OSError:
218 pass
220 def hash(self) -> str:
221 """Compute a hash of the requirement set."""
222 hash = hashlib.sha1()
223 for requirement in sorted(self.requirements):
224 hash.update(requirement.encode("utf-8"))
225 hash.update(b"\0")
226 return hash.hexdigest()
229def update_cache(
230 venv_path: Path,
231 remote_packages: dict[str, str],
232 local_package_path: Path,
233) -> None:
234 """Ensure the package cache at venv_path is up-to-date.
236 ``Remote_packages`` is a dictionary (mapping package names to required versions)
237 that specifies remote packages (to be installed from PyPI).
239 ``Local_package_page`` is a path to a directory whose sub-directories may contain
240 local plugin source. Any such source directories will be installed in "editable"
241 mode.
243 """
244 requirements = Requirements()
245 for package, version in remote_packages.items():
246 requirements.add_requirement(package, version)
247 requirements.add_local_requirements_from(local_package_path)
249 if len(requirements) == 0:
250 shutil.rmtree(venv_path, ignore_errors=True)
251 else:
252 hash_file = venv_path / "lektor-requirements-hash.txt"
253 try:
254 is_stale = hash_file.read_text().strip() != requirements.hash()
255 except FileNotFoundError:
256 is_stale = True
258 if is_stale:
259 venv = VirtualEnv(venv_path)
260 venv.create()
261 # Add our site-packages to venv's sys.path
262 our_site_packages = sysconfig.get_path("purelib")
263 venv.addsitedir(our_site_packages)
265 venv.run_pip_install(*requirements)
266 hash_file.write_text(f"{requirements.hash()}\n", encoding="ascii")
269def load_packages(env: Environment, reinstall: bool = False) -> None:
270 """Import all of our managed plugins into our ``sys.path``
272 This first ensures that our private package cache is up-to-date, then
273 adds it to ``sys.path``.
275 After ``load_packages`` is called, the entry points defined in by
276 plugins that we manage will be available.
277 """
278 if reinstall:
279 click.echo("Force package cache refresh.")
280 wipe_package_cache(env)
282 config = env.load_config()
283 venv_path = env.project.get_package_cache_path()
284 update_cache(venv_path, config["PACKAGES"], Path(env.root_path, "packages"))
285 site.addsitedir(VirtualEnv(venv_path).site_packages)
288def wipe_package_cache(env: Environment) -> None:
289 """Remove the entire package cache."""
290 project = env.project
291 # Remove the legacy flat package cache, too
292 for cache_type in project.PackageCacheType:
293 shutil.rmtree(project.get_package_cache_path(cache_type), ignore_errors=True)