Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 34 additions & 5 deletions packages/cli/src/pywrangler/sync.py
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,26 @@ def create_pyodide_venv() -> None:
run_command(["uv", "venv", str(pyodide_venv_path), "--python", interp_name])


def _find_pyodide_site_packages() -> Path:
"""Locate the pyodide venv's site-packages directory.

Discovered directly rather than assumed from an os.name check, since this
venv always targets a foreign (non-host) interpreter --
cpython-*-emscripten-wasm32-musl -- and there's no single formula that's
safe to assume holds for its internal layout on every host OS.
"""
pyodide_venv_path = get_pyodide_venv_path()
matches = sorted(
pyodide_venv_path.glob("**/site-packages"), key=lambda p: len(p.parts)
)
if not matches:
raise RuntimeError(
f"Could not find a site-packages directory under {pyodide_venv_path}. "
"Try deleting .venv-workers and running `pywrangler sync` again."
)
return matches[0]


def _install_requirements_to_vendor(
plan: InstallPlan, allow_build: bool = False
) -> str | None:
Expand Down Expand Up @@ -177,11 +197,20 @@ def _install_requirements_to_vendor(

# Clear pyodide venv site-packages so stale packages from previous syncs
# don't carry over into python_modules.
pyv = get_python_version()
site_packages_path = (
f"lib/python{pyv}/site-packages" if os.name != "nt" else "Lib/site-packages"
)
pyodide_site_packages = get_pyodide_venv_path() / site_packages_path
#
# This venv always targets cpython-*-emscripten-wasm32-musl (see
# get_uv_pyodide_interp_name()), never the host's own interpreter -- unlike
# venv_workers_path (create_workers_venv()), which is a real native venv
# and does follow the host OS's own site-packages convention. Whether this
# *foreign*-target venv's internal layout also follows the host OS's
# convention, or the target's, isn't something to assume either way; it's
# located directly instead of guessed from os.name, which pointed at the
# wrong directory for at least one platform in practice (reported: real
# packages installed correctly by `uv`, but never copied into
# python_modules, so the deployed Worker fell back to workerd's
# always-raising stub "workers" module and failed at deploy time with
# ModuleNotFoundError).
pyodide_site_packages = _find_pyodide_site_packages()
if pyodide_site_packages.is_dir():
shutil.rmtree(pyodide_site_packages)
pyodide_site_packages.mkdir()
Expand Down
33 changes: 33 additions & 0 deletions packages/cli/tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -309,6 +309,39 @@ def test_sync_removes_stale_packages(test_dir):
)


def test_find_pyodide_site_packages_discovers_nested_layout(test_dir):
"""`_find_pyodide_site_packages` must locate site-packages by discovery,
not by constructing a path from a guessed OS-conditional convention.

The pyodide venv always targets a foreign (non-host) interpreter, so its
on-disk layout isn't guaranteed to match either the POSIX or the Windows
native-venv convention on every host -- this was previously assumed via
an `os.name` check, which pointed at a nonexistent directory on at least
one real platform (freshly-installed packages were silently never copied
into python_modules, producing the reported deploy-time
ModuleNotFoundError). Using an unconventional depth here (rather than
exactly mirroring either real convention) proves the discovery is
layout-agnostic, not just accidentally matching one specific guess.
"""
pyodide_venv_path = pywrangler_sync.get_pyodide_venv_path()
real_site_packages = pyodide_venv_path / "unexpected" / "nested" / "site-packages"
real_site_packages.mkdir(parents=True)
(real_site_packages / "marker.txt").write_text("installed package marker")

found = pywrangler_sync._find_pyodide_site_packages()

assert found == real_site_packages
assert (found / "marker.txt").exists()


def test_find_pyodide_site_packages_raises_when_missing(test_dir):
"""A clear error, not a silent no-op or a copytree crash on a path that
doesn't exist, when the pyodide venv has no site-packages directory at
all (e.g. venv creation never completed)."""
with pytest.raises(RuntimeError, match="Could not find a site-packages"):
pywrangler_sync._find_pyodide_site_packages()


def test_sync_lockfile_lifecycle(test_dir):
"""Test that pylock.toml pins versions and --upgrade refreshes them."""
create_test_wrangler_jsonc(test_dir, "src/worker.py")
Expand Down
Loading