From 1f56a425b17771e070f529f2c664568fea56e8f2 Mon Sep 17 00:00:00 2001 From: Praveen Mittal Date: Tue, 18 Aug 2026 11:34:21 +0200 Subject: [PATCH] fix(cli): locate pyodide vendor site-packages by discovery, not a guessed OS-conditional path _install_requirements_to_vendor guessed the pyodide venv's site-packages location from os.name (Lib/site-packages on Windows, lib/pythonX.Y/site-packages elsewhere). That venv always targets a foreign interpreter (cpython-*-emscripten-wasm32-musl), never the host's own, so there's no OS-conditional formula guaranteed to hold for it. When it guessed wrong, uv still installed packages correctly, but they were 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. Adds _find_pyodide_site_packages(), which discovers the real directory by globbing for it instead of assuming a layout, regardless of host OS or uv version. Fixes cloudflare/workers-sdk#15208 --- packages/cli/src/pywrangler/sync.py | 39 +++++++++++++++++++++++++---- packages/cli/tests/test_cli.py | 33 ++++++++++++++++++++++++ 2 files changed, 67 insertions(+), 5 deletions(-) diff --git a/packages/cli/src/pywrangler/sync.py b/packages/cli/src/pywrangler/sync.py index a4245a21..322058d4 100644 --- a/packages/cli/src/pywrangler/sync.py +++ b/packages/cli/src/pywrangler/sync.py @@ -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: @@ -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() diff --git a/packages/cli/tests/test_cli.py b/packages/cli/tests/test_cli.py index 925b117a..380ec44f 100644 --- a/packages/cli/tests/test_cli.py +++ b/packages/cli/tests/test_cli.py @@ -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")