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
14 changes: 12 additions & 2 deletions packages/cli/src/pywrangler/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -132,14 +132,24 @@ def types_command(outdir: str | None, config: str | None) -> Never:
is_flag=True,
help="Allow package upgrades, ignoring pinned versions in pylock.toml",
)
def sync_command(force: bool = False, upgrade: bool = False) -> None:
@click.option(
"--allow-build/--no-allow-build",
default=None,
help=(
"Allow building source distributions and local directory sources. "
"Defaults to the [tool.pywrangler] allow-build setting in pyproject.toml."
),
)
def sync_command(
force: bool = False, upgrade: bool = False, allow_build: bool | None = None
) -> None:
"""
Installs Python packages from pyproject.toml into src/vendor.

Also creates a virtual env for Workers that you can use for testing.
"""

sync(force, directly_requested=True, upgrade=upgrade)
sync(force, directly_requested=True, upgrade=upgrade, allow_build=allow_build)
write_success("Sync process completed successfully.")


Expand Down
46 changes: 39 additions & 7 deletions packages/cli/src/pywrangler/resolve.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,21 +18,40 @@


class InstallPlan:
# Lockfile keys that indicate a package is sourced locally
_LOCAL_SOURCE_KEYS = ("directory", "sdist", "archive")

def __init__(self, lockfile: Path) -> None:
self.lockfile = lockfile
self.requirements: list[tuple[str, str]] = []
# Names of packages sourced from a local path. They need refreshing when
# rebuilt.
self.local_packages: list[str] = []

with open(lockfile, "rb") as f:
data = tomllib.load(f)

for pkg in data.get("packages", []):
name = pkg.get("name")
if name and any(
self._is_local_source(pkg, key) for key in self._LOCAL_SOURCE_KEYS
):
self.local_packages.append(name)

version = pkg.get("version")
if not name or not version:
logger.warning("Skipping malformed lockfile entry: %s", pkg)
continue
self.requirements.append((name, version))

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

def to_requirement_strings(self) -> list[str]:
return [f"{name}=={version}" for name, version in self.requirements]

Expand All @@ -49,12 +68,18 @@ def _compile_lockfile(
lockfile_path: Path,
*,
upgrade: bool = False,
allow_build: bool = False,
) -> None:
"""Run ``uv pip compile`` targeting Pyodide.

Writes the compiled output to *lockfile_path*. When *lockfile_path* already
exists, ``uv pip compile`` uses it as a constraint source so pinned versions
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.
"""
with temp_requirements_file(requirements) as req_in_path:
cmd = [
Expand All @@ -68,31 +93,38 @@ def _compile_lockfile(
get_pyodide_index(),
"--index-strategy",
"unsafe-best-match",
"--no-build",
"--no-header",
"-o",
str(lockfile_path),
]
if not allow_build:
cmd.append("--no-build")
if upgrade:
cmd.append("--upgrade")

run_command(cmd, cwd=get_project_root(), capture_output=True)


def resolve_requirements(*, upgrade: bool = False) -> InstallPlan:
def resolve_requirements(
*, upgrade: bool = False, allow_build: bool = False
) -> InstallPlan:
"""Build an InstallPlan by compiling dependencies for the Pyodide target.

Runs ``uv pip compile`` with the Pyodide interpreter and ``--no-build``
to resolve versions that have Pyodide wheels. The compiled output is
written to ``pylock.toml``; on subsequent runs the existing file
constrains versions so they don't drift.
Runs ``uv pip compile`` with the Pyodide interpreter and ``--no-build`` to
resolve versions that have Pyodide wheels. The compiled output is written
to ``pylock.toml``; on subsequent runs the existing file constrains versions
so they don't drift.

When *allow_build* is True, ``--no-build`` is omitted so source
distributions and local directory sources may be built. Building a binary
extension in this way will fail in a confusing way.
"""
lockfile = get_lockfile_path()

deps = parse_requirements()
deps.append(MANAGED_SDK_PACKAGE)

_compile_lockfile(deps, lockfile, upgrade=upgrade)
_compile_lockfile(deps, lockfile, upgrade=upgrade, allow_build=allow_build)
plan = InstallPlan(lockfile)

logger.info("Resolved %d requirements from %s.", len(plan.requirements), lockfile)
Expand Down
45 changes: 30 additions & 15 deletions packages/cli/src/pywrangler/sync.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
get_lockfile_path,
get_project_root,
get_python_version,
get_pywrangler_config,
get_pywrangler_version,
get_uv_pyodide_interp_name,
run_command,
Expand Down Expand Up @@ -145,9 +146,15 @@ def create_pyodide_venv() -> None:
run_command(["uv", "venv", str(pyodide_venv_path), "--python", interp_name])


def _install_requirements_to_vendor(plan: InstallPlan) -> str | None:
def _install_requirements_to_vendor(
plan: InstallPlan, allow_build: bool = False
) -> 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.

Returns:
Error message string if installation failed, None if successful.
"""
Expand Down Expand Up @@ -179,17 +186,18 @@ def _install_requirements_to_vendor(plan: InstallPlan) -> str | None:
shutil.rmtree(pyodide_site_packages)
pyodide_site_packages.mkdir()

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 += ["-r", str(plan.lockfile), "--preview-features", "pylock"]
result = run_command(
[
"uv",
"pip",
"install",
"--no-build",
"-r",
str(plan.lockfile),
"--preview-features",
"pylock",
],
install_cmd,
capture_output=True,
check=False,
env=os.environ | {"VIRTUAL_ENV": str(get_pyodide_venv_path())},
Expand Down Expand Up @@ -290,10 +298,10 @@ def _get_vendor_package_versions() -> list[str]:
return _parse_pip_freeze(result.stdout)


def install_requirements(plan: InstallPlan) -> None:
def install_requirements(plan: InstallPlan, allow_build: bool = False) -> None:
# First, install to the Pyodide vendor directory. This determines the exact package
# versions that will run in production.
pyodide_error = _install_requirements_to_vendor(plan)
pyodide_error = _install_requirements_to_vendor(plan, allow_build=allow_build)

# Then install to .venv-workers using the pinned versions from vendor.
# This ensures host packages accurately reflect what will run in production.
Expand Down Expand Up @@ -399,10 +407,17 @@ def sync(
force: bool = False,
directly_requested: bool = False,
upgrade: bool = False,
allow_build: bool | None = None,
) -> None:
# Check if requirements.txt does not exist.
check_requirements_txt()

# Resolve the build override: explicit CLI flag wins, otherwise fall back to
# `[tool.pywrangler] allow-build` in pyproject.toml so `dev`/`deploy` (which
# call sync() without flags) honor it too.
if allow_build is None:
allow_build = bool(get_pywrangler_config().get("allow-build", False))

# Check if sync is needed based on file timestamps
sync_needed = force or is_sync_needed()
if not sync_needed:
Expand All @@ -425,9 +440,9 @@ def sync(
create_pyodide_venv()

# Resolve dependencies via uv pip compile targeting Pyodide, then install into vendor folder.
plan = resolve_requirements(upgrade=upgrade)
plan = resolve_requirements(upgrade=upgrade, allow_build=allow_build)
if not plan.requirements:
logger.warning(
"No dependencies found in [project.dependencies] section of pyproject.toml."
)
install_requirements(plan)
install_requirements(plan, allow_build=allow_build)
10 changes: 10 additions & 0 deletions packages/cli/src/pywrangler/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,16 @@ def get_project_root() -> Path:
return find_pyproject_toml().parent


def get_pywrangler_config() -> dict:
"""Return the ``[tool.pywrangler]`` table from pyproject.toml (or ``{}``)."""
data = read_pyproject_toml()
tool = data.get("tool", {})
if not isinstance(tool, dict):
return {}
config = tool.get("pywrangler", {})
return config if isinstance(config, dict) else {}


MIN_UV_VERSION = (0, 8, 10)
MIN_WRANGLER_VERSION = (4, 42, 1)

Expand Down
Loading
Loading