Skip to content
Draft
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
1 change: 1 addition & 0 deletions packages/cli/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ dependencies = [
"pyjson5>=1.6.0",
"pyodide-py",
"workers-runtime-sdk>=0.1.0",
"packaging>=23.0",
]

[dependency-groups]
Expand Down
55 changes: 55 additions & 0 deletions packages/cli/src/pywrangler/local_deps.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import logging
from pathlib import Path

from packaging.requirements import Requirement

from .utils import get_project_root, run_command

logger = logging.getLogger(__name__)


def is_local_path_dep(dep: str) -> bool:
req = Requirement(dep)
if req.url is None:
return False
url = req.url.strip()
return not url.startswith(("http://", "https://"))


def parse_local_dep(dep: str) -> tuple[str, str, Path]:
"""Returns (name, extras_str, resolved_path).

``extras_str`` includes the brackets, e.g. ``"[extra1,extra2]"`` or ``""``.
"""
req = Requirement(dep)
if req.url is None:
raise ValueError(f"Not a URL/path dependency: {dep}")

url = req.url.strip()
if url.startswith("file://"):
url = url[len("file://") :]

resolved = (get_project_root() / url).resolve()
extras_str = f"[{','.join(sorted(req.extras))}]" if req.extras else ""
return req.name, extras_str, resolved


def build_wheels(local_deps: list[str], output_dir: Path) -> list[str]:
results: list[str] = []
for dep in local_deps:
name, extras, path = parse_local_dep(dep)

logger.info(f"Building wheel for local package '{name}' from {path}")
if not path.exists():
raise RuntimeError(f"Local package path does not exist: {path}")

dep_out = output_dir / name
dep_out.mkdir(parents=True, exist_ok=True)
run_command(["uv", "build", "--wheel", str(path), "--out-dir", str(dep_out)])
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we have a way to make sure these aren't platformed?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not really... maybe we can check the output name but I wasn't able to think of a very robust way to do it.


wheels = list(dep_out.glob("*.whl"))
if not wheels:
raise RuntimeError(f"No wheel produced for '{name}' from {path}")

results.append(f"{wheels[0]}{extras}")
return results
95 changes: 76 additions & 19 deletions packages/cli/src/pywrangler/sync.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,13 @@
from pathlib import Path

import click
from packaging.utils import canonicalize_name

from .local_deps import (
build_wheels,
is_local_path_dep,
parse_local_dep,
)
from .utils import (
check_uv_version,
check_wrangler_config,
Expand Down Expand Up @@ -154,6 +160,7 @@ def parse_requirements() -> list[str]:
if dependencies:
for dep in dependencies:
logger.debug(f" - {dep}")

return dependencies


Expand Down Expand Up @@ -306,21 +313,56 @@ def _get_vendor_package_versions() -> list[str]:
return _parse_pip_freeze(result.stdout)


def install_requirements(requirements: list[str]) -> None:
requirements.append("workers-runtime-sdk")
# First, install to the Pyodide vendor directory. This determines the exact package
# versions that will run in production.
pyodide_error = _install_requirements_to_vendor(requirements)

# Then install to .venv-workers using the pinned versions from vendor.
# This ensures host packages accurately reflect what will run in production.
# If the installation to the Pyodide vendor directory fails, use the original requirements
# to see if it fails in the native venv as well.
host_requirements = (
requirements if pyodide_error else _get_vendor_package_versions()
)
native_error = _install_requirements_to_venv(host_requirements)
@contextmanager
def _prebuild_local_deps(local_deps: list[str]) -> Iterator[list[str]]:
if not local_deps:
yield []
return
wheel_dir = Path(tempfile.mkdtemp(prefix="pywrangler-wheels-"))
try:
yield build_wheels(local_deps, wheel_dir)
finally:
shutil.rmtree(wheel_dir, ignore_errors=True)


def _host_requirements_for(
original: list[str],
local_deps: list[str],
pyodide_failed: bool,
) -> list[str]:
"""Build the requirements list for the native .venv-workers install.

Normally the native venv mirrors the exact versions that were installed in
the Pyodide vendor directory (via ``pip freeze``), so both environments stay
in sync. Two situations require special handling:

1. **Pyodide install failed** — fall back to the original user requirements
so the native install can surface its own, often more actionable, error.

2. **Local path deps are present** — ``pip freeze`` records them as
``pkg==version`` which would then resolve against PyPI where the package
likely doesn't exist. We strip those entries from the pinned list and
re-add the original local path references (resolved to absolute paths so
they work from the temp requirements file).
"""
if pyodide_failed:
return original

pinned = _get_vendor_package_versions()
if not local_deps:
return pinned

local_names = {canonicalize_name(parse_local_dep(d)[0]) for d in local_deps}
pinned_remote = [
p for p in pinned if canonicalize_name(p.split("==")[0]) not in local_names
]
return pinned_remote + local_deps


def _raise_on_install_error(
native_error: str | None,
pyodide_error: str | None,
) -> None:
# Show the native error first (more likely to be actionable), then the Pyodide error.
if native_error:
logger.warning(native_error)
Expand All @@ -331,17 +373,16 @@ def install_requirements(requirements: list[str]) -> None:

if pyodide_error:
logger.warning(pyodide_error)
# Handle some common failures and give nicer error messages for them.
lowered_error = pyodide_error.lower()
if "invalid peer certificate" in lowered_error:
lowered = pyodide_error.lower()
if "invalid peer certificate" in lowered:
logger.error(
"Installation failed because of an invalid peer certificate. Are your systems certificates correctly installed? Do you have an Enterprise VPN enabled?"
)
elif "failed to fetch" in lowered_error:
elif "failed to fetch" in lowered:
logger.error(
"Installation failed because of a failed fetch. Is your network connection working?"
)
elif "no solution found when resolving dependencies" in lowered_error:
elif "no solution found when resolving dependencies" in lowered:
logger.error(
"Installation failed because the packages you requested are not supported by Python Workers. See above for details."
)
Expand All @@ -351,6 +392,22 @@ def install_requirements(requirements: list[str]) -> None:
)
raise click.exceptions.Exit(code=1)


def install_requirements(requirements: list[str]) -> None:
requirements.append("workers-runtime-sdk")

local_deps = [r for r in requirements if is_local_path_dep(r)]
remote_deps = [r for r in requirements if not is_local_path_dep(r)]

with _prebuild_local_deps(local_deps) as wheel_reqs:
pyodide_error = _install_requirements_to_vendor(remote_deps + wheel_reqs)

host_reqs = _host_requirements_for(
requirements, local_deps, pyodide_error is not None
)
native_error = _install_requirements_to_venv(host_reqs)

_raise_on_install_error(native_error, pyodide_error)
_log_installed_packages(get_venv_workers_path())


Expand Down
69 changes: 69 additions & 0 deletions packages/cli/tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -270,6 +270,75 @@ def test_sync_command_integration(dependencies, test_dir): # noqa: C901 (test c
)


def create_test_local_package(base_dir: Path, name: str = "mylocalpkg") -> Path:
pkg_dir = base_dir / f"test_{name}"
pkg_dir.mkdir(parents=True, exist_ok=True)

(pkg_dir / "pyproject.toml").write_text(
dedent(f"""\
[build-system]
requires = ["setuptools>=61.0"]
build-backend = "setuptools.build_meta"

[project]
name = "{name}"
version = "0.1.0"
""")
)

module_dir = pkg_dir / name
module_dir.mkdir(exist_ok=True)
(module_dir / "__init__.py").write_text('__version__ = "0.1.0"\n')

return pkg_dir


def test_sync_command_with_local_dep(test_dir):
local_pkg_dir = create_test_local_package(test_dir.parent, "mylocalpkg")
try:
rel_path = os.path.relpath(local_pkg_dir, test_dir)
create_test_pyproject(test_dir, ["click", f"mylocalpkg @ {rel_path}"])
create_test_wrangler_jsonc(test_dir, "src/worker.py")

result = subprocess.run(
["uv", "run", "--no-project", "pywrangler", "sync"],
capture_output=True,
text=True,
cwd=test_dir,
check=False,
)
print(f"\nCommand output:\n{result.stdout}")
if result.stderr:
print(f"Command errors:\n{result.stderr}")

assert result.returncode == 0, (
f"Sync failed with output: {result.stdout}\nErrors: {result.stderr}"
)

vendor = test_dir / "python_modules"
assert vendor.exists(), "python_modules directory was not created"
assert is_package_installed(vendor, "mylocalpkg"), (
"Local package not found in python_modules"
)
assert is_package_installed(vendor, "click"), (
"Registry package 'click' not found in python_modules"
)

venv_workers = test_dir / ".venv-workers"
if os.name == "nt":
site_packages = venv_workers / "Lib" / "site-packages"
else:
site_packages = venv_workers / "lib" / "python3.12" / "site-packages"
assert is_package_installed(site_packages, "mylocalpkg"), (
"Local package not found in .venv-workers"
)
assert is_package_installed(site_packages, "click"), (
"Registry package 'click' not found in .venv-workers"
)
finally:
shutil.rmtree(local_pkg_dir, ignore_errors=True)


def test_sync_command_handles_missing_pyproject():
"""Test that the sync command correctly handles a missing pyproject.toml file."""
import tempfile
Expand Down
Loading
Loading