"""Locker implementation ``jupyterlite-pyodide-lock-uv``."""
# Copyright (c) jupyterlite-pyodide-lock contributors.
# Distributed under the terms of the BSD-3-Clause License.
from __future__ import annotations
import json
import re
import subprocess # noqa: S404
import sys
import tempfile
import urllib.parse
from datetime import datetime, timezone
from pathlib import Path
from typing import TYPE_CHECKING, Any
from pyodide_lock import PyodideLockSpec
from pyodide_lock.utils import add_wheels_to_spec
if sys.version_info >= (3, 11):
import tomllib
else:
import tomli as tomllib
import pkginfo
from jupyterlite_core.constants import JSON_FMT, UTF8
from jupyterlite_core.trait_types import TypedTuple
from packaging.requirements import Requirement
from packaging.utils import canonicalize_name
from psutil import Popen
from traitlets import Unicode, default
from jupyterlite_pyodide_lock.constants import (
PYODIDE_LOCK,
PYODIDE_LOCK_STEM,
RE_REMOTE_URL,
)
from jupyterlite_pyodide_lock.lockers._base import BaseLocker # noqa: PLC2701
from jupyterlite_pyodide_lock.utils import find_binary
if TYPE_CHECKING:
from collections.abc import Iterator
from pyodide_lock import PackageSpec
[docs]
class UvLocker(BaseLocker):
"""A locker that uses ``uv pip compile``."""
uv_bin: str = Unicode(help="a custom executable for ``uv``").tag(config=True)
uv_platform: str = Unicode(
"wasm32-pyodide2024", help="the ``uv`` python platform"
).tag(config=True)
uv_pip_compile_args = TypedTuple(
Unicode(),
default_value=["--format=pylock.toml", "--no-build"],
help="arguments to ``uv pip compile``",
).tag(config=True)
extra_uv_pip_compile_args = TypedTuple(
Unicode(),
help=("extra arguments to ``uv pip compile``, such as ``--default-index``"),
).tag(config=True)
# trait defaults
@default("uv_bin")
def _default_uv_bin(self) -> str:
return find_binary(["uv"])[0]
# locker API
[docs]
async def resolve(self) -> bool:
"""Get the lock."""
self.cache_dir.mkdir(parents=True, exist_ok=True)
reqs = self.build_requirements_txt()
self.build_constraints_txt(reqs)
self.run_pip_compile()
self.build_pyodide_lock()
return True
[docs]
def build_requirements_txt(self) -> dict[str, str]:
"""Combine all requirements."""
lines: dict[str, str] = {}
for spec in sorted(self.specs):
req = Requirement(spec)
lines[canonicalize_name(req.name)] = spec
for wheel in sorted(self.packages):
lines.update(self.build_one_package_requirement(wheel))
self.requirements_in.write_text("\n".join(sorted(lines.values())), **UTF8)
return lines
[docs]
def build_constraints_txt(self, requirements: dict[str, str]) -> None:
"""Combine all constraints."""
out_dir = self.parent.pyodide_addon.output_pyodide
bootstrap_lock = PyodideLockSpec.from_json(out_dir / PYODIDE_LOCK)
package_specs: dict[str, str] = {}
for pkg in bootstrap_lock.packages.values():
package_specs.update(
self.build_one_constraint_from_pyodide_lock(pkg, requirements)
)
for constraint in self.constraints:
req = Requirement(constraint)
name = canonicalize_name(req.name)
package_specs[name] = constraint
self.constraints_txt.write_text(
"\n".join(sorted(package_specs.values())), **UTF8
)
[docs]
def build_one_constraint_from_pyodide_lock(
self, pkg: PackageSpec, requirements: dict[str, str]
) -> dict[str, str]:
"""Build a PEP-508 ``@`` constraint from a ``pyodide-lock.json`` package."""
if pkg.package_type != "package":
return {}
out_dir = self.parent.pyodide_addon.output_pyodide
cdn = self.parent.pyodide_cdn_url
name, file_name = canonicalize_name(pkg.name), pkg.file_name
wheel = out_dir / file_name
if wheel.exists():
url = wheel.as_uri()
elif re.match(RE_REMOTE_URL, file_name):
url = file_name
else:
url = f"""{cdn}/{file_name}"""
spec = f"{name} @ {url}"
required = requirements.get(name)
if required:
self.log.debug(
"[uv] [constraints] [%s] skipping required %s", name, required
)
spec = "\n".join([
f"# {name} already required by: {required}",
f"# {spec}",
])
return {name: spec}
[docs]
def run_pip_compile(self) -> None:
"""Run a constrained ``uv pip compile``."""
args = [*self.all_uv_pip_compile_args]
self.log.debug("[uv] [compile] %s", "\t".join(args))
proc = Popen(args, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
out = proc.communicate()
if proc.returncode != 0:
self.log.error("[uv] [compile] error %s: %s", proc.returncode, out)
[docs]
def build_pyodide_lock(self) -> None:
"""Update ``{out_dir}/pyodide-lock/pyodide-lock.json`` from wheels."""
lockfile = self.parent.lockfile
lock_dir = lockfile.parent
pylock = tomllib.loads(self.pylock.read_text(**UTF8))
wheels: list[Path] = []
for pkg in pylock["packages"]:
wheels += [*self.collect_pylock_wheel(pkg)]
self.log.debug("[uv] [lock] collected wheels: %s", [w.name for w in wheels])
lock_json = self.build_tmp_pyodide_lock(
self.parent.pyodide_addon.output_pyodide / PYODIDE_LOCK, wheels
)
lock_dir.mkdir(parents=True, exist_ok=True)
root_path = self.parent.manager.output_dir.as_posix()
found = {wheel.name: wheel for wheel in wheels}
for package in lock_json["packages"].values():
self.fix_one_tmp_pyodide_lock_package(root_path, lock_dir, package, found)
lockfile.write_text(json.dumps(lock_json, **JSON_FMT), **UTF8)
[docs]
def build_tmp_pyodide_lock(
self, old_lockfile: Path, wheels: list[Path]
) -> dict[str, Any]:
"""Use local wheels to make a patched ``pyodide-lock.json``."""
with tempfile.TemporaryDirectory() as td:
tdp = Path(td)
tmp_lock = tdp / PYODIDE_LOCK
self.parent.copy_one(old_lockfile, tdp / PYODIDE_LOCK)
[
self.parent.copy_one(path, tdp / path.name)
for path in sorted(set(wheels))
]
spec = PyodideLockSpec.from_json(tdp / PYODIDE_LOCK)
spec = add_wheels_to_spec(spec, [*tdp.glob("*.whl")])
spec.to_json(tmp_lock)
return {**json.loads(tmp_lock.read_text(**UTF8))}
[docs]
def fix_one_tmp_pyodide_lock_package(
self,
root_posix: str,
lock_dir: Path,
package: dict[str, Any],
found_wheels: dict[str, Path],
) -> None:
"""Update a ``pyodide-lock`` URL for deployment."""
file_name = package["file_name"]
just_file_name = file_name.rsplit("/")[-1]
new_file_name = file_name
found_path = found_wheels.get(just_file_name)
if found_path:
path_posix = found_path.as_posix()
if path_posix.startswith(root_posix):
# build relative path to existing file
new_file_name = found_path.as_posix().replace(root_posix, "../..")
else:
# copy to be sibling of lockfile, leaving name unchanged
dest = lock_dir / file_name
self.parent.copy_one(found_path, dest)
new_file_name = f"../../static/{PYODIDE_LOCK_STEM}/{file_name}"
else:
new_file_name = f"{self.parent.pyodide_cdn_url}/{just_file_name}"
package["file_name"] = new_file_name
[docs]
def collect_pylock_wheel(self, pkg: dict[str, Any]) -> Iterator[Path]:
"""Ensure a local wheel from a PEP-751 package."""
archive: dict[str, Any] | None = pkg.get("archive")
wheels: list[dict[str, Any]] | None = pkg.get("wheels")
raw_url: str | None = None
dest: Path | None = None
if archive:
raw_url = archive.get("url")
raw_path = archive.get("path")
if raw_url and raw_url.startswith(self.parent.pyodide_cdn_url):
return
if raw_path:
rel = (self.pylock.parent / raw_path).resolve()
if rel.exists():
self.log.debug("[uv] [%s] is local", pkg["name"])
dest = rel
elif wheels:
wheel = wheels[0]
raw_url = f"""{wheel["url"]}"""
if not dest and raw_url:
url = urllib.parse.urlparse(raw_url)
wheel_name = f"""{url.path.split("/")[-1]}"""
dest = self.parent.package_cache / wheel_name
if dest.exists():
self.log.debug("[uv] [%s] already cached: %s", pkg["name"], dest)
else:
self.log.debug("[uv] [%s] downloading: %s", pkg["name"], dest)
self.parent.fetch_one(raw_url, dest)
if dest and dest.exists():
self.log.debug("[uv] [%s] will be locked: %s", pkg["name"], dest.name)
yield dest
[docs]
def build_one_package_requirement(self, wheel: Path) -> dict[str, str]:
"""Build a ``package @ file://url`` spec for an on-disk wheel."""
info = pkginfo.get_metadata(f"{wheel}")
name = info and info.name
if not name: # pragma: no cover
self.log.error("[uv] failed to parse wheel metadata for %s", wheel)
return {}
name = canonicalize_name(name)
return {name: f"{name} @ {wheel.absolute().as_uri()}"}
# derived properties
@property
def cache_dir(self) -> Path:
"""The location of cached files discovered during the solve."""
return Path(self.parent.manager.cache_dir / "uv-locker")
@property
def requirements_in(self) -> Path:
"""The a temporary ``requirements.in`` to solve."""
return Path(self.cache_dir / "requirements.in")
@property
def pylock(self) -> Path:
"""A temporary ``pylock.toml``."""
return Path(self.cache_dir / "pylock.toml")
@property
def constraints_txt(self) -> Path:
"""A temporary ``constraints.txt``."""
return Path(self.cache_dir / "constraints.txt")
@property
def lockfile_cache(self) -> Path:
"""The location of the updated lockfile."""
return Path(self.cache_dir / PYODIDE_LOCK)
@property
def all_uv_pip_compile_args(self) -> list[str]:
"""All args for ``uv pip compile``."""
args = [
self.uv_bin,
"pip",
"compile",
f"--python-platform={self.uv_platform}",
f"--output-file={self.pylock}",
f"--constraints={self.constraints_txt}",
*self.uv_pip_compile_args,
*self.extra_uv_pip_compile_args,
]
if self.parent.lock_date_epoch:
rfc339 = datetime.fromtimestamp(
self.parent.lock_date_epoch, timezone.utc
).isoformat()
args += [f"--exclude-newer={rfc339}"]
return [
*args,
f"{self.requirements_in}",
]