Skip to content
Open
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
2 changes: 1 addition & 1 deletion packages/cli/src/pywrangler/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,7 @@ def types_command(outdir: str | None, config: str | None) -> Never:
"--allow-build/--no-allow-build",
default=None,
help=(
"Allow building source distributions and local directory sources. "
"Allow building any source distribution, not just local path sources. "
"Defaults to the [tool.pywrangler] allow-build setting in pyproject.toml."
),
)
Expand Down
25 changes: 18 additions & 7 deletions packages/cli/src/pywrangler/resolve.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@ def __init__(self, lockfile: Path) -> None:
# Names of packages sourced from a local path. They need refreshing when
# rebuilt.
self.local_packages: list[str] = []
# Subset of `local_packages` that have no wheel and so must be built
# (directories, sdists). Local `.whl` files are excluded.
self.local_build_packages: list[str] = []

with open(lockfile, "rb") as f:
data = tomllib.load(f)
Expand All @@ -39,18 +42,26 @@ def __init__(self, lockfile: Path) -> None:
if not name:
logger.warning("Skipping malformed lockfile entry: %s", pkg)
continue
if any(self._is_local_source(pkg, key) for key in self._LOCAL_SOURCE_KEYS):
local_paths = [
path
for key in self._LOCAL_SOURCE_KEYS
if (path := self._local_source_path(pkg, key)) is not None
]
if local_paths:
self.local_packages.append(name)
if not all(path.endswith(".whl") for path in local_paths):
self.local_build_packages.append(name)

self.requirements.append((name, pkg.get("version")))

@staticmethod
def _is_local_source(pkg: dict, key: str) -> bool:
def _local_source_path(pkg: dict, key: str) -> str | None:
source = pkg.get(key)
if not isinstance(source, dict):
return False
return None
# A local reference has a `path`
return "path" in source
path = source.get("path")
return path if isinstance(path, str) else None


def parse_requirements() -> list[str]:
Expand Down Expand Up @@ -79,9 +90,9 @@ def _compile_lockfile(
are preserved across re-runs (no silent upgrades).

By default ``--no-build`` is passed so only prebuilt wheels are used. This
is because building a Pyodide platformed wheel will fail. Set *allow_build*
to permit building source distributions / local directory sources. This is
useful for testing against local checkouts of pure Python packages.
is because building a Pyodide platformed wheel will fail. Local directory
sources still resolve because uv reads their static metadata without
building. Set *allow_build* to permit building source distributions.
"""
project_root = get_project_root()
with temp_requirements_file(supplemental_requirements) as req_in_path:
Expand Down
22 changes: 12 additions & 10 deletions packages/cli/src/pywrangler/sync.py
Original file line number Diff line number Diff line change
Expand Up @@ -169,9 +169,10 @@ def _install_requirements_to_vendor(
) -> str | None:
"""Install packages to the Pyodide vendor directory from pylock.toml.

By default ``--no-build`` is passed so only prebuilt wheels install. When
*allow_build* is True, source distributions / local directory sources are
allowed to build.
By default only prebuilt wheels install, except for local path sources such
as directories and sdists, which have no wheel and are explicitly requested
by the project, so they are always built. When *allow_build* is True, every
source distribution is allowed to build.

Returns:
Error message string if installation failed, None if successful.
Expand Down Expand Up @@ -206,13 +207,14 @@ def _install_requirements_to_vendor(

install_cmd = ["uv", "pip", "install"]
if not allow_build:
install_cmd.append("--no-build")
else:
# uv caches built wheels for local sources keyed on their path, so edits
# to local checkouts wouldn't be picked up. Refresh the build cache for
# those packages so `sync` always rebuilds them.
for name in plan.local_packages:
install_cmd += ["--refresh-package", name]
install_cmd += ["--only-binary", ":all:"]
for name in plan.local_build_packages:
install_cmd += ["--no-binary", name]
# uv caches built wheels for local sources keyed on their path, so edits to
# local checkouts wouldn't be picked up. Refresh the build cache for those
# packages so `sync` always rebuilds them.
for name in plan.local_packages:
install_cmd += ["--refresh-package", name]
install_cmd += ["-r", str(plan.lockfile), "--preview-features", "pylock"]
result = run_command(
install_cmd,
Expand Down
90 changes: 30 additions & 60 deletions packages/cli/tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -392,9 +392,9 @@ def create_dummy_build_dep(parent_dir: Path, name: str = "dummy-build-dep") -> P
"""Create a tiny pure-Python dependency that must be built from source.

The package only exists as a local directory (no prebuilt wheel on any
index), so installing it requires ``uv`` to run its build backend. This is
exactly what ``--allow-build`` gates: with ``--no-build`` (the default) the
resolver refuses to build it, and with ``--allow-build`` it succeeds.
index), so installing it requires ``uv`` to run its build backend. Local
path sources are the one kind of source ``sync`` builds even without
``--allow-build``.

Returns the path to the created dependency directory.
"""
Expand Down Expand Up @@ -428,8 +428,8 @@ def create_worker_pyproject_with_local_dep(

The dependency is expressed as a PEP 508 direct reference to the local
directory (``name @ file://...``) so the resolver treats it as a
``directory`` source that must be built from source. ``allow_build_config``
toggles the ``[tool.pywrangler] allow-build`` key.
``directory`` source that must be built. ``allow_build_config`` toggles the
``[tool.pywrangler] allow-build`` key.
"""
pywrangler_table = (
"[tool.pywrangler]\nallow-build = true\n" if allow_build_config else ""
Expand All @@ -455,87 +455,57 @@ def create_worker_pyproject_with_local_dep(
sys.platform == "win32",
reason="FIXME Pyodide WASM interpreter cannot run setuptools build backends on Windows",
)
def test_sync_allow_build_local_dependency(test_dir):
"""End-to-end test for --allow-build with a local source dependency.
@pytest.mark.parametrize(
"extra_args", [[], ["--no-allow-build"]], ids=["default", "explicit"]
)
def test_sync_builds_local_directory_dependency(test_dir, extra_args):
"""End-to-end test: local directory sources are built without --allow-build.

A tiny dummy package that only exists as a local directory (and therefore
must be built from source) is added to the worker's pyproject.toml. Syncing
without --allow-build must fail (default is --no-build), while syncing with
--allow-build must succeed and vendor the built package.
must be built from source) is added to the worker's pyproject.toml. Unlike
source distributions from an index, which stay blocked so uv can't pick a
newer sdist over an older Pyodide wheel, a local path source is something
the project explicitly asked for, so `sync` builds it.
"""
dep_name = "dummy-build-dep"
dep_dir = create_dummy_build_dep(test_dir, dep_name)
create_worker_pyproject_with_local_dep(test_dir, dep_dir, dep_name)
create_test_wrangler_jsonc(test_dir, "src/worker.py")

vendor_path = test_dir / "python_modules"
sync_cmd = ["uv", "run", "pywrangler", "sync"]

# Without --allow-build: the default --no-build rejects the local source.
result = subprocess.run(
[*sync_cmd, "--no-allow-build"],
capture_output=True,
text=True,
cwd=test_dir,
check=False,
)
assert result.returncode != 0, (
"sync should fail without --allow-build because the local dependency "
"must be built from source"
)
assert not is_package_installed(vendor_path, dep_name), (
"dummy build dep should not be vendored when the build was rejected"
)

# With --allow-build: uv is allowed to build the local source.
result = subprocess.run(
[*sync_cmd, "--force", "--allow-build"],
["uv", "run", "pywrangler", "sync", *extra_args],
capture_output=True,
text=True,
cwd=test_dir,
check=False,
)
assert result.returncode == 0, (
f"sync --allow-build failed: {result.stdout}\n{result.stderr}"
)
assert result.returncode == 0, f"sync failed: {result.stdout}\n{result.stderr}"
assert is_package_installed(vendor_path, dep_name), (
f"{dep_name} should be built and vendored into python_modules "
"when --allow-build is passed"
f"{dep_name} should be built and vendored into python_modules"
)


@pytest.mark.skipif(
sys.platform == "win32",
reason="FIXME Pyodide WASM interpreter cannot run setuptools build backends on Windows",
)
def test_sync_allow_build_via_pyproject_config(test_dir):
"""End-to-end test for the [tool.pywrangler] allow-build config fallback.

When no CLI flag is passed, sync should honor `allow-build = true` in the
[tool.pywrangler] table of pyproject.toml.
"""
def test_sync_reads_allow_build_from_pyproject_config(test_dir):
"""`sync()` falls back to `[tool.pywrangler] allow-build` when no flag is passed."""
dep_name = "dummy-build-dep"
dep_dir = create_dummy_build_dep(test_dir, dep_name)
create_worker_pyproject_with_local_dep(
test_dir, dep_dir, dep_name, allow_build_config=True
)
create_test_wrangler_jsonc(test_dir, "src/worker.py")

vendor_path = test_dir / "python_modules"
result = subprocess.run(
["uv", "run", "pywrangler", "sync"],
capture_output=True,
text=True,
cwd=test_dir,
check=False,
)
assert result.returncode == 0, (
f"sync failed with [tool.pywrangler] allow-build = true: "
f"{result.stdout}\n{result.stderr}"
)
assert is_package_installed(vendor_path, dep_name), (
f"{dep_name} should be vendored when allow-build is enabled via config"
)
with (
patch.object(pywrangler_sync, "check_requirements_txt"),
patch.object(pywrangler_sync, "create_workers_venv"),
patch.object(pywrangler_sync, "create_pyodide_venv"),
patch.object(pywrangler_sync, "resolve_requirements") as mock_resolve,
patch.object(pywrangler_sync, "install_requirements") as mock_install,
):
pywrangler_sync.sync(force=True)

assert mock_resolve.call_args.kwargs["allow_build"] is True
assert mock_install.call_args.kwargs["allow_build"] is True


def test_sync_command_handles_missing_pyproject():
Expand Down
80 changes: 80 additions & 0 deletions packages/cli/tests/test_version_sync.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,66 @@ def test_get_vendor_package_versions_disables_color():
assert env.get("VIRTUAL_ENV") == str(Path("pyodide-venv"))


class TestInstallToVendorCommand:
"""Check the `uv pip install` flags built by `_install_requirements_to_vendor`."""

@pytest.fixture
def plan(self, tmp_path: Path) -> InstallPlan:
lockfile = tmp_path / "pylock.toml"
lockfile.write_text(
'lock-version = "1.0"\n'
'[[packages]]\nname = "click"\nversion = "8.1.7"\n'
'[[packages]]\nname = "local-dir"\n'
'directory = { path = "../local-dir", editable = false }\n'
'[[packages]]\nname = "local-wheel"\nversion = "3.0"\n'
'archive = { path = "dist/local_wheel-3.0-py3-none-any.whl" }\n'
)
return InstallPlan(lockfile)

def _run(self, plan: InstallPlan, tmp_path: Path, *, allow_build: bool) -> list:
with (
patch.object(pywrangler_sync, "run_command") as mock_run,
patch.object(pywrangler_sync, "get_project_root", return_value=tmp_path),
patch.object(
pywrangler_sync,
"get_vendor_modules_path",
return_value=tmp_path / "python_modules",
),
patch.object(
pywrangler_sync,
"get_pyodide_venv_path",
return_value=tmp_path / "pyodide-venv",
),
patch.object(pywrangler_sync, "get_python_version", return_value="3.12"),
patch.object(pywrangler_sync, "_write_sync_token"),
patch.object(pywrangler_sync, "get_vendor_token_path"),
):
mock_run.return_value.returncode = 1
mock_run.return_value.stdout = "boom"
pywrangler_sync._install_requirements_to_vendor(
plan, allow_build=allow_build
)
return mock_run.call_args[0][0]

def test_default_builds_only_local_sources(self, plan, tmp_path):
command = self._run(plan, tmp_path, allow_build=False)
assert command[:3] == ["uv", "pip", "install"]
assert "--no-build" not in command
assert command[3:5] == ["--only-binary", ":all:"]
assert ["--no-binary", "local-dir"] == command[5:7]
# Local wheels must not be marked --no-binary or uv refuses them.
assert "local-wheel" not in command[: command.index("--refresh-package")]
assert command.count("--refresh-package") == 2
assert "-r" in command

def test_allow_build_lifts_binary_restriction(self, plan, tmp_path):
command = self._run(plan, tmp_path, allow_build=True)
assert "--only-binary" not in command
assert "--no-binary" not in command
assert "--no-build" not in command
assert command.count("--refresh-package") == 2


class TestInstallRequirements:
@patch.object(pywrangler_sync, "_install_requirements_to_vendor")
@patch.object(pywrangler_sync, "_get_vendor_package_versions")
Expand Down Expand Up @@ -337,6 +397,26 @@ def test_empty_packages(self, tmp_path):
plan = InstallPlan(lockfile)
assert plan.requirements == []

def test_classifies_local_sources(self, tmp_path):
"""Local directories and sdists must be built; local wheels must not."""
lockfile = tmp_path / "pylock.toml"
lockfile.write_text(
'lock-version = "1.0"\n'
'[[packages]]\nname = "from-pypi"\nversion = "1.0"\n'
'wheels = [{ url = "https://example.invalid/from_pypi-1.0-py3-none-any.whl" }]\n'
'[[packages]]\nname = "local-dir"\n'
'directory = { path = "../local-dir", editable = false }\n'
'[[packages]]\nname = "local-sdist"\nversion = "2.0"\n'
'sdist = { path = "dist/local_sdist-2.0.tar.gz" }\n'
'[[packages]]\nname = "local-wheel"\nversion = "3.0"\n'
'archive = { path = "dist/local_wheel-3.0-py3-none-any.whl" }\n'
'[[packages]]\nname = "remote-archive"\nversion = "4.0"\n'
'archive = { url = "https://example.invalid/remote_archive-4.0.tar.gz" }\n'
)
plan = InstallPlan(lockfile)
assert plan.local_packages == ["local-dir", "local-sdist", "local-wheel"]
assert plan.local_build_packages == ["local-dir", "local-sdist"]


class TestResolveRequirements:
@patch.object(pywrangler_resolve, "_compile_lockfile")
Expand Down
Loading
Loading