fix(pm): MSIX plugin installs build on a writable copy of the bundled Python (#135236)
Enabling a plugin with Python dependencies, and `hermes pm install`, failed in the packaged Windows app: uv lock/sync/pip check exit 101 with "Unable to create process using ...\venv\Scripts\python.exe". uv queries a venv by running its Scripts\python.exe. That redirector lives outside the package and CreateProcess's the interpreter named by pyvenv.cfg `home`.6a031bf1b3moved generations onto the packaged interpreter, so `home` names a file under WindowsApps that a process without package identity may not start (exit 101 is the redirector's RC_CREATE_PROCESS). Restore the verified writable copy of the pinned Python in the user store as the build interpreter for sealed Windows payloads. venv_command (also from6a031bf1b3) stays: Hermes still enters those venvs on the packaged interpreter, so its own children keep package access to the bundled git, rg and PM worker.
This commit is contained in:
@@ -319,15 +319,17 @@ relaunch waiter is registered before the install request. Unknown checks,
|
||||
cancellation and request failures do not count as successful updates. Native
|
||||
acceptance requires a Store-acquired package or flight, not a sideloaded MSIX.
|
||||
|
||||
On Windows bundles, every dependency generation is built on the bundled Python
|
||||
and nothing runs a venv's own executables. A venv's `Scripts\python.exe` is a
|
||||
redirector outside the package that starts the packaged interpreter, which
|
||||
Windows refuses (WinError 5) to a process without package identity. The app and
|
||||
execution aliases keep their signed bundled launchers, and children that need a
|
||||
On Windows bundles, PM builds dependency generations on a verified writable
|
||||
copy of the bundled Python in its user store. A venv's `Scripts\python.exe` is a
|
||||
redirector outside the package. Windows refuses it (WinError 5) when it starts the
|
||||
packaged interpreter, and uv runs that redirector during every lock, sync and
|
||||
`pip check`. Hermes never runs a venv's own executables. The app and execution
|
||||
aliases keep their signed bundled launchers, and children that need a
|
||||
generation's packages start through `pm.environments.venv_command`: the bundled
|
||||
Python with `-S` plus `pm/_venv_entry.py`, which attaches the generation's
|
||||
site-packages. The new venv's generated console scripts must not replace the
|
||||
launchers, and are kept off `PATH`.
|
||||
site-packages. Processes started from the bundled Python keep package access to
|
||||
the bundled git, rg and the PM worker. The new venv's generated console scripts
|
||||
must not replace the launchers, and are kept off `PATH`.
|
||||
|
||||
Sideload stable versions are `X.Y.Z.0`. Canary revisions derive from elapsed
|
||||
minutes after the stable baseline. The release script rejects ambiguous or
|
||||
|
||||
34
pm/_uv.py
34
pm/_uv.py
@@ -3,20 +3,27 @@ from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from pm.filesystem import native
|
||||
from pm import paths
|
||||
from pm.filesystem import long_root, native
|
||||
from pm.lock import Facts
|
||||
from pm.package import InstallError
|
||||
from pm.registry import get_package
|
||||
from pm.store import current_target
|
||||
from pm.store import Store, current_target
|
||||
|
||||
|
||||
def _toolchain(*, realize: bool = True, explicit: bool = False) -> tuple[Path, Path] | None:
|
||||
"""Resolve uv and Python without host discovery or recursive worker dispatch.
|
||||
|
||||
A read-only probe never installs. A sealed payload builds on its own
|
||||
interpreter: a venv's redirectors then name a path inside the package, so
|
||||
nothing may run them (``pm.environments.venv_command`` enters such a venv).
|
||||
A read-only probe never installs. A sealed Windows payload builds on a verified writable
|
||||
copy of its Python: uv starts a venv's ``Scripts\\python.exe`` to query it, and that
|
||||
redirector runs outside the package, so its target must too. A redirector naming the
|
||||
packaged interpreter dies with exit 101 (#135236). Hermes itself still enters those venvs
|
||||
on the packaged interpreter (``pm.environments.venv_command``).
|
||||
"""
|
||||
from pm.install import _installed_location, _lockfile, ensure
|
||||
from pm.install import (
|
||||
_install, _installed_location, _lockfile, _refuse_lazy,
|
||||
ensure, lazy_installs_allowed, sealed,
|
||||
)
|
||||
|
||||
if realize:
|
||||
ensure("uv", explicit=explicit)
|
||||
@@ -31,6 +38,21 @@ def _toolchain(*, realize: bool = True, explicit: bool = False) -> tuple[Path, P
|
||||
if not realize:
|
||||
return None
|
||||
raise InstallError(name, "pinned tool is unavailable", "run `hermes pm install`")
|
||||
if name == "python" and target.startswith("win32") and sealed():
|
||||
# Spelled like Store spells its root, so the comparison and the copy's file calls agree.
|
||||
writable = long_root(paths.writable_store_root())
|
||||
if location[1].root != writable:
|
||||
copied = _installed_location(package, lockfile, target, verify=explicit, roots=(writable,))
|
||||
if copied is None:
|
||||
if not realize:
|
||||
return None
|
||||
if not explicit and not lazy_installs_allowed():
|
||||
raise _refuse_lazy(name, "a writable Python is required for bundled builds")
|
||||
copy_store = Store(writable)
|
||||
copy_facts = Facts(writable / "facts.json")
|
||||
_install(package, lockfile, copy_facts, copy_store, target, copy_from=location)
|
||||
copied = copy_facts, copy_store
|
||||
location = copied
|
||||
facts, store = location
|
||||
binary = package.binary(store.entry(facts.get(name)["entry"]), target)
|
||||
if binary is None or not binary.is_file():
|
||||
|
||||
@@ -352,12 +352,13 @@ def _payload_store_python(root: Path) -> Path | None:
|
||||
def venv_command(project_root: Path, venv: Path, options: tuple[str, ...] | list[str] = ()) -> list[str]:
|
||||
"""The argv prefix that runs Python inside *venv*; append a script, ``-c`` or ``-m``.
|
||||
|
||||
A sealed payload never enters a venv through ``Scripts\\python.exe``. That redirector is
|
||||
an executable outside the package that starts the package's interpreter, which an MSIX
|
||||
refuses (WinError 5) to a process without package identity. Instead the payload's own
|
||||
interpreter attaches the venv's site-packages (``pm/_venv_entry.py``): the same
|
||||
contract as the payload's launchers and its PM worker. Elsewhere the venv's
|
||||
interpreter runs as is. *options* are interpreter flags (``-I``, ``-u``, ``-X …``).
|
||||
A sealed payload never enters a venv through ``Scripts\\python.exe``. That redirector sits
|
||||
outside the package and starts PM's writable copy of the Python (``pm._uv._toolchain``),
|
||||
and a process on that copy has no package identity, so Windows refuses it the package's
|
||||
git, rg and PM worker (WinError 5). Instead the payload's own interpreter attaches the
|
||||
venv's site-packages (``pm/_venv_entry.py``): the same contract as the payload's
|
||||
launchers and its PM worker. Elsewhere the venv's interpreter runs as is. *options* are
|
||||
interpreter flags (``-I``, ``-u``, ``-X …``).
|
||||
"""
|
||||
root = Path(project_root).resolve()
|
||||
store_python = _payload_store_python(root)
|
||||
@@ -502,8 +503,8 @@ def activate_dependencies(project_root: Path) -> None:
|
||||
# A sealed payload's launchers live in <payload>/bin instead.
|
||||
directories = [payload_command_dir(project_root) or project_root.resolve() / ".hermes" / "bin"]
|
||||
# A sealed payload's Windows venv redirectors never go on PATH: a shipped venv's still
|
||||
# names the build machine, and a generation built since names the payload's interpreter,
|
||||
# which an MSIX refuses to an outside executable (see venv_command).
|
||||
# names the build machine, and a generation built since names PM's writable Python copy,
|
||||
# which cannot start the package's executables (see venv_command).
|
||||
if not (os.name == "nt" and _payload_manifest(project_root.resolve()) is not None):
|
||||
directories.append(venv_bin_dir(environment))
|
||||
prefix = [str(path) for path in directories if path.is_dir()]
|
||||
|
||||
@@ -180,42 +180,74 @@ def test_uv_refuses_discovery_when_pm_python_is_missing(installed_uv, monkeypatc
|
||||
assert not list(entry.iterdir())
|
||||
|
||||
|
||||
def test_bundled_uv_builds_on_the_shipped_python_and_writes_nothing(installed_uv, monkeypatch):
|
||||
"""A sealed payload's venvs are built on its own interpreter, never a writable copy:
|
||||
the venvs are entered through ``venv_command``, so no redirector needs an outside target."""
|
||||
@pytest.mark.platforms("windows")
|
||||
def test_bundled_uv_uses_a_verified_writable_python_without_changing_runtime(installed_uv, monkeypatch):
|
||||
from pm import paths
|
||||
from pm import registry
|
||||
from pm.store import Store, tree_digest
|
||||
|
||||
root, uv_binary, facts, target, digest = installed_uv
|
||||
ensure = importlib.import_module("pm.install")
|
||||
shipped = uv_binary.parent.parent
|
||||
writable = root / "writable-tools"
|
||||
monkeypatch.setattr(paths, "writable_store_root", lambda: writable)
|
||||
(shipped.parent / "manifest.json").write_text("{}", encoding="utf-8")
|
||||
|
||||
class FixturePython(Python):
|
||||
def verify(self, entry, target):
|
||||
return "" if self.binary(entry, target).read_bytes() == b"pinned interpreter" else "damaged Python"
|
||||
|
||||
class FixtureUv(Uv):
|
||||
emulated_arch_targets = {target}
|
||||
|
||||
monkeypatch.setitem(registry._packages, "uv", FixtureUv())
|
||||
facts.record("uv", "test", uv_binary.parent.name, {}, shipped,
|
||||
target=target, artifacts=[digest], digest=tree_digest(uv_binary.parent))
|
||||
python = Python()
|
||||
python = FixturePython()
|
||||
monkeypatch.setitem(registry._packages, "python", python)
|
||||
entry = shipped / python.store_entry("test", target)
|
||||
binary = python.binary(entry, target)
|
||||
binary.parent.mkdir(parents=True)
|
||||
entry.mkdir()
|
||||
binary = entry / "python.exe"
|
||||
binary.write_bytes(b"pinned interpreter")
|
||||
binary.chmod(0o755)
|
||||
(entry / "python.dll").write_bytes(b"pinned runtime")
|
||||
facts.record("python", "test", entry.name, python.env(entry, target), shipped,
|
||||
target=target, artifacts=[digest], digest=tree_digest(entry))
|
||||
before = facts.path.read_bytes()
|
||||
monkeypatch.setattr(Store, "fetch_many", lambda *a, **k: pytest.fail("the shipped Python must not be fetched"))
|
||||
monkeypatch.setattr(shutil, "copytree", lambda *a, **k: pytest.fail("the shipped Python must not be copied"))
|
||||
shipped_digest = tree_digest(entry)
|
||||
monkeypatch.setattr(ensure, "lazy_installs_allowed", lambda: False)
|
||||
|
||||
assert _toolchain(realize=False) == (uv_binary, binary)
|
||||
assert _toolchain(explicit=True) == (uv_binary, binary)
|
||||
def no_download(*args, **kwargs):
|
||||
raise AssertionError("the verified Python must be copied without downloading")
|
||||
|
||||
monkeypatch.setattr(Store, "fetch_many", no_download)
|
||||
assert _toolchain(realize=False) is None
|
||||
assert not writable.exists()
|
||||
with pytest.raises(InstallError, match="lazy installs are disabled"):
|
||||
_toolchain()
|
||||
assert not writable.exists()
|
||||
|
||||
resolved_uv, python = _toolchain(explicit=True)
|
||||
copied = writable / entry.name
|
||||
assert resolved_uv == uv_binary
|
||||
assert python == copied / "python.exe"
|
||||
assert tree_digest(copied) == shipped_digest
|
||||
copied_fact = Facts(writable / "facts.json").get("python")
|
||||
assert copied_fact["digest"] == shipped_digest
|
||||
assert copied_fact["artifacts"] == [digest]
|
||||
assert copied_fact["target"] == target
|
||||
assert ensure.installed_package("python").binary == binary
|
||||
assert str(copied) not in ensure.env_for("python")["PATH"]
|
||||
|
||||
def no_copy(*args, **kwargs):
|
||||
raise AssertionError("a matching copy must be reused")
|
||||
|
||||
with monkeypatch.context() as reuse:
|
||||
reuse.setattr(shutil, "copytree", no_copy)
|
||||
assert _toolchain(realize=False)[1] == python
|
||||
assert _toolchain()[1] == python
|
||||
|
||||
assert facts.path.read_bytes() == before
|
||||
assert tree_digest(entry) == shipped_digest
|
||||
|
||||
|
||||
@pytest.mark.parametrize("damage", [None, "source", "copy", "publication"])
|
||||
|
||||
Reference in New Issue
Block a user