From e71384d17c41ea8879217b35afeb56214d0ada2a Mon Sep 17 00:00:00 2001 From: Gyeongjae Choi Date: Thu, 21 May 2026 20:44:25 +0900 Subject: [PATCH 1/6] feat(cli): add lockfile support to pywrangler sync command `pywrangler sync` command now generates a `pylock.toml` file that locks the installed packages. Rerunning `pywrangler sync` now keep the existing installed versions and will not upgrade packages silently. Also added `--upgrade` flag to `pywrangler sync` which upgrades the packages in the lockfile. --- packages/cli/pyproject.toml | 1 + packages/cli/src/pywrangler/cli.py | 9 +- packages/cli/src/pywrangler/resolve.py | 139 ++++++++++++++++++++++++ packages/cli/src/pywrangler/sync.py | 100 ++++++++++------- packages/cli/tests/test_cli.py | 93 +++++++++++++++- packages/cli/tests/test_version_sync.py | 89 +++++++++++++-- 6 files changed, 380 insertions(+), 51 deletions(-) create mode 100644 packages/cli/src/pywrangler/resolve.py diff --git a/packages/cli/pyproject.toml b/packages/cli/pyproject.toml index b70987d..9f24fc7 100644 --- a/packages/cli/pyproject.toml +++ b/packages/cli/pyproject.toml @@ -62,6 +62,7 @@ lint.select = [ ] lint.ignore = ["E402", "E501", "E731", "E741", "PLW2901", "UP031"] lint.flake8-comprehensions.allow-dict-calls-with-keyword-arguments = true +lint.per-file-ignores."tests/**" = ["PLR2004", "PLR0913"] lint.per-file-ignores."tests/workerd-test/**" = ["C901", "PLR0911", "PLR0912", "PLR2004", "PLW0603"] lint.per-file-ignores."tests/bindings-test/**" = ["C901", "PLR0911", "PLR0912", "PLR2004", "PLW0603"] diff --git a/packages/cli/src/pywrangler/cli.py b/packages/cli/src/pywrangler/cli.py index 77afe34..73190bf 100644 --- a/packages/cli/src/pywrangler/cli.py +++ b/packages/cli/src/pywrangler/cli.py @@ -127,14 +127,19 @@ def types_command(outdir: str | None, config: str | None) -> Never: @app.command("sync") @click.option("--force", is_flag=True, help="Force sync even if no changes detected") -def sync_command(force: bool = False) -> None: +@click.option( + "--upgrade", + is_flag=True, + help="Allow package upgrades, ignoring pinned versions in pylock.toml", +) +def sync_command(force: bool = False, upgrade: bool = False) -> 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) + sync(force, directly_requested=True, upgrade=upgrade) write_success("Sync process completed successfully.") diff --git a/packages/cli/src/pywrangler/resolve.py b/packages/cli/src/pywrangler/resolve.py new file mode 100644 index 0000000..ad97c7b --- /dev/null +++ b/packages/cli/src/pywrangler/resolve.py @@ -0,0 +1,139 @@ +import logging +import subprocess as sp +import tempfile +import tomllib +from dataclasses import dataclass, field +from pathlib import Path + +import click + +from .utils import ( + get_project_root, + get_pyodide_index, + get_uv_pyodide_interp_name, + read_pyproject_toml, +) + +logger = logging.getLogger(__name__) + +MANAGED_SDK_PACKAGE = "workers-runtime-sdk" +LOCKFILE_NAME = "pylock.toml" + + +@dataclass +class InstallPlan: + """Requirements resolved for installation (requirements.txt-style strings).""" + + requirements: list[str] = field(default_factory=list) + lockfile: Path | None = None + + +def parse_requirements() -> list[str]: + pyproject_data = read_pyproject_toml() + + # Extract dependencies from [project.dependencies] + return pyproject_data.get("project", {}).get("dependencies", []) + + +def get_lockfile_path() -> Path: + return get_project_root() / LOCKFILE_NAME + + +def _compile_requirements( + requirements: list[str], + lockfile_path: Path, + *, + upgrade: bool = False, +) -> list[str]: + """Run ``uv pip compile`` targeting Pyodide and return pinned requirement strings. + + 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). + + Uses subprocess directly (instead of run_command) to keep stdout and stderr + separate — uv writes the compiled output to stdout but warnings to stderr. + """ + with tempfile.NamedTemporaryFile(mode="w", suffix=".in", delete=False) as req_file: + req_file.write("\n".join(requirements)) + req_file.flush() + req_in_path = req_file.name + + try: + cmd = [ + "uv", + "pip", + "compile", + req_in_path, + "--python", + get_uv_pyodide_interp_name(), + "--extra-index-url", + get_pyodide_index(), + "--index-strategy", + "unsafe-best-match", + "--no-build", + "--no-header", + "-o", + str(lockfile_path), + ] + if upgrade: + cmd.append("--upgrade") + + # TODO: use run_command function instead of sp.run + # like other functions. It requires + # updating the run_command function to handle stdout and stderr separately + logger.debug(f"Running: {' '.join(cmd)}") + result = sp.run( + cmd, + cwd=get_project_root(), + capture_output=True, + text=True, + encoding="utf-8", + check=False, + ) + finally: + Path(req_in_path).unlink(missing_ok=True) + + if result.returncode != 0: + error_output = (result.stderr or result.stdout or "").strip() + logger.error(f"uv pip compile failed:\n{error_output}") + raise click.exceptions.Exit(code=1) + + return _read_lockfile_requirements(lockfile_path) + + +def _read_lockfile_requirements(lockfile_path: Path) -> list[str]: + """Read pinned ``name==version`` pairs from a ``pylock.toml`` file.""" + with open(lockfile_path, "rb") as f: + data = tomllib.load(f) + + results = [] + for pkg in data.get("packages", []): + name = pkg.get("name") + version = pkg.get("version") + if not name or not version: + logger.warning("Skipping malformed lockfile entry: %s", pkg) + continue + results.append(f"{name}=={version}") + return results + + +def resolve_requirements(*, upgrade: 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. + """ + lockfile = get_lockfile_path() + + deps = parse_requirements() + deps.append(MANAGED_SDK_PACKAGE) + + requirements = _compile_requirements(deps, lockfile, upgrade=upgrade) + + logger.info("Resolved %d requirements from %s.", len(requirements), LOCKFILE_NAME) + for req in requirements: + logger.debug(" - %s", req) + return InstallPlan(requirements=requirements, lockfile=lockfile) diff --git a/packages/cli/src/pywrangler/sync.py b/packages/cli/src/pywrangler/sync.py index 0c4904f..21ec265 100644 --- a/packages/cli/src/pywrangler/sync.py +++ b/packages/cli/src/pywrangler/sync.py @@ -8,6 +8,11 @@ import click +from .resolve import ( + InstallPlan, + get_lockfile_path, + resolve_requirements, +) from .utils import ( check_uv_version, check_wrangler_config, @@ -17,7 +22,6 @@ get_python_version, get_pywrangler_version, get_uv_pyodide_interp_name, - read_pyproject_toml, run_command, ) @@ -145,19 +149,6 @@ def create_pyodide_venv() -> None: run_command(["uv", "venv", str(pyodide_venv_path), "--python", interp_name]) -def parse_requirements() -> list[str]: - pyproject_data = read_pyproject_toml() - - # Extract dependencies from [project.dependencies] - dependencies = pyproject_data.get("project", {}).get("dependencies", []) - - logger.info(f"Found {len(dependencies)} dependencies.") - if dependencies: - for dep in dependencies: - logger.debug(f" - {dep}") - return dependencies - - @contextmanager def temp_requirements_file(requirements: list[str]) -> Iterator[str]: # Write dependencies to a requirements.txt-style temp file. @@ -167,16 +158,19 @@ def temp_requirements_file(requirements: list[str]) -> Iterator[str]: yield temp_file.name -def _install_requirements_to_vendor(requirements: list[str]) -> str | None: +def _install_requirements_to_vendor(plan: InstallPlan) -> str | None: """Install packages to the Pyodide vendor directory. + When the plan has a lockfile (pylock.toml), installs directly from it. + Otherwise falls back to a temp requirements file with dynamic resolution. + Returns: Error message string if installation failed, None if successful. """ vendor_path = get_vendor_modules_path() logger.debug(f"Using vendor path: {vendor_path}") - if len(requirements) == 0: + if len(plan.requirements) == 0: logger.warning( f"Requirements list is empty. No dependencies to install in {vendor_path}." ) @@ -201,7 +195,7 @@ def _install_requirements_to_vendor(requirements: list[str]) -> str | None: shutil.rmtree(pyodide_site_packages) pyodide_site_packages.mkdir() - with temp_requirements_file(requirements) as requirements_file: + if plan.lockfile and plan.lockfile.is_file(): result = run_command( [ "uv", @@ -209,21 +203,39 @@ def _install_requirements_to_vendor(requirements: list[str]) -> str | None: "install", "--no-build", "-r", - requirements_file, - "--extra-index-url", - get_pyodide_index(), - "--index-strategy", - "unsafe-best-match", + str(plan.lockfile), + "--preview-features", + "pylock", ], capture_output=True, check=False, env=os.environ | {"VIRTUAL_ENV": str(get_pyodide_venv_path())}, ) - if result.returncode != 0: - return result.stdout.strip() + else: + with temp_requirements_file(plan.requirements) as requirements_file: + result = run_command( + [ + "uv", + "pip", + "install", + "--no-build", + "-r", + requirements_file, + "--extra-index-url", + get_pyodide_index(), + "--index-strategy", + "unsafe-best-match", + ], + capture_output=True, + check=False, + env=os.environ | {"VIRTUAL_ENV": str(get_pyodide_venv_path())}, + ) - shutil.rmtree(vendor_path) - shutil.copytree(pyodide_site_packages, vendor_path) + if result.returncode != 0: + return result.stdout.strip() + + shutil.rmtree(vendor_path) + shutil.copytree(pyodide_site_packages, vendor_path) # Create a pyvenv.cfg file in python_modules to mark it as a virtual environment (vendor_path / "pyvenv.cfg").touch() @@ -314,18 +326,17 @@ 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") +def install_requirements(plan: InstallPlan) -> 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(requirements) + pyodide_error = _install_requirements_to_vendor(plan) # 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() + plan.requirements if pyodide_error else _get_vendor_package_versions() ) native_error = _install_requirements_to_venv(host_requirements) @@ -396,8 +407,8 @@ def _is_out_of_date(token: Path, time: float) -> bool: def is_sync_needed() -> bool: """ - Checks if pyproject.toml has been modified since the last sync, or if the - workers-py version has changed since the last sync. + Checks if pyproject.toml or pylock.toml has been modified since the last + sync, or if the workers-py version has changed since the last sync. Returns: bool: True if sync is needed, False otherwise @@ -407,13 +418,22 @@ def is_sync_needed() -> bool: # If pyproject.toml doesn't exist, we need to abort anyway return True - pyproject_mtime = pyproject_toml_path.stat().st_mtime - return _is_out_of_date(get_vendor_token_path(), pyproject_mtime) or _is_out_of_date( - get_venv_workers_token_path(), pyproject_mtime + latest_mtime = pyproject_toml_path.stat().st_mtime + + lockfile = get_lockfile_path() + if lockfile.is_file(): + latest_mtime = max(latest_mtime, lockfile.stat().st_mtime) + + return _is_out_of_date(get_vendor_token_path(), latest_mtime) or _is_out_of_date( + get_venv_workers_token_path(), latest_mtime ) -def sync(force: bool = False, directly_requested: bool = False) -> None: +def sync( + force: bool = False, + directly_requested: bool = False, + upgrade: bool = False, +) -> None: # Check if requirements.txt does not exist. check_requirements_txt() @@ -438,10 +458,10 @@ def sync(force: bool = False, directly_requested: bool = False) -> None: # Set up Pyodide virtual env create_pyodide_venv() - # Generate requirements.txt from pyproject.toml by directly parsing the TOML file then install into vendor folder. - requirements = parse_requirements() - if not requirements: + # Resolve dependencies via uv pip compile targeting Pyodide, then install into vendor folder. + plan = resolve_requirements(upgrade=upgrade) + if not plan.requirements: logger.warning( "No dependencies found in [project.dependencies] section of pyproject.toml." ) - install_requirements(requirements) + install_requirements(plan) diff --git a/packages/cli/tests/test_cli.py b/packages/cli/tests/test_cli.py index bf491f1..377eb9b 100644 --- a/packages/cli/tests/test_cli.py +++ b/packages/cli/tests/test_cli.py @@ -305,6 +305,68 @@ def test_sync_removes_stale_packages(test_dir): ) +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") + sync_cmd = ["uv", "run", "pywrangler", "sync"] + lockfile = test_dir / "pylock.toml" + vendor_path = test_dir / "python_modules" + + old_six = "six==1.16.0" + latest_six = "six>=1.16.0" + + # Step 1: Initial sync with old six — creates pylock.toml pinned to 1.16.0 + create_test_pyproject(test_dir, [old_six]) + result = subprocess.run( + sync_cmd, capture_output=True, text=True, cwd=test_dir, check=False + ) + assert result.returncode == 0, f"Step 1 failed: {result.stdout}\n{result.stderr}" + assert lockfile.is_file(), "pylock.toml should be created after first sync" + assert is_package_installed(vendor_path, "six") + lockfile_content = lockfile.read_text() + assert 'version = "1.16.0"' in lockfile_content + + # Step 2: Add click to pyproject.toml, rerun sync — pylock.toml adds click, six stays 1.16.0 + create_test_pyproject(test_dir, [old_six, "click"]) + result = subprocess.run( + sync_cmd, capture_output=True, text=True, cwd=test_dir, check=False + ) + assert result.returncode == 0, f"Step 2 failed: {result.stdout}\n{result.stderr}" + lockfile_content = lockfile.read_text() + assert "click" in lockfile_content + assert 'version = "1.16.0"' in lockfile_content, ( + "six should remain pinned to 1.16.0 when adding a new dep" + ) + assert is_package_installed(vendor_path, "click") + assert is_package_installed(vendor_path, "six") + + # Step 3: Rerun sync without changes — no update (skipped by timestamp check) + result = subprocess.run( + sync_cmd, capture_output=True, text=True, cwd=test_dir, check=False + ) + assert result.returncode == 0, f"Step 3 failed: {result.stdout}\n{result.stderr}" + assert lockfile.read_text() == lockfile_content, ( + "pylock.toml should not change when rerunning sync without changes" + ) + + # Step 4: Loosen six constraint and sync with --upgrade — six should upgrade past 1.16.0 + create_test_pyproject(test_dir, [latest_six, "click"]) + result = subprocess.run( + [*sync_cmd, "--force", "--upgrade"], + capture_output=True, + text=True, + cwd=test_dir, + check=False, + ) + assert result.returncode == 0, f"Step 4 failed: {result.stdout}\n{result.stderr}" + lockfile_content = lockfile.read_text() + assert 'version = "1.16.0"' not in lockfile_content, ( + "six should have been upgraded past 1.16.0 with --upgrade" + ) + assert is_package_installed(vendor_path, "click") + assert is_package_installed(vendor_path, "six") + + def test_sync_command_handles_missing_pyproject(): """Test that the sync command correctly handles a missing pyproject.toml file.""" import tempfile @@ -365,10 +427,22 @@ def test_sync_command_with_unchanged_timestamps( @patch.object(pywrangler_sync, "is_sync_needed", lambda: True) @patch.object(pywrangler_sync, "install_requirements") +@patch.object(pywrangler_sync, "resolve_requirements") +@patch.object(pywrangler_sync, "create_pyodide_venv") +@patch.object(pywrangler_sync, "create_workers_venv") def test_sync_command_with_changed_timestamps( - mock_install_requirements, test_dir, caplog + mock_create_venv, + mock_create_pyodide, + mock_resolve, + mock_install_requirements, + test_dir, + caplog, ): """Test that the sync command runs when timestamps indicate changes.""" + from pywrangler.resolve import InstallPlan + + mock_resolve.return_value = InstallPlan(requirements=["click>=8.0"]) + # Create the pyproject.toml file create_test_pyproject(test_dir) @@ -383,13 +457,28 @@ def test_sync_command_with_changed_timestamps( assert result.exit_code == 0 # Verify that all the sync functions were called + mock_resolve.assert_called_once() mock_install_requirements.assert_called_once() @patch.object(pywrangler_sync, "is_sync_needed", lambda: False) @patch.object(pywrangler_sync, "install_requirements") -def test_sync_command_with_force_flag(mock_install_requirements, test_dir, caplog): +@patch.object(pywrangler_sync, "resolve_requirements") +@patch.object(pywrangler_sync, "create_pyodide_venv") +@patch.object(pywrangler_sync, "create_workers_venv") +def test_sync_command_with_force_flag( + mock_create_venv, + mock_create_pyodide, + mock_resolve, + mock_install_requirements, + test_dir, + caplog, +): """Test that the sync command runs when the --force flag is used, regardless of timestamps.""" + from pywrangler.resolve import InstallPlan + + mock_resolve.return_value = InstallPlan(requirements=["click>=8.0"]) + create_test_pyproject(test_dir) create_test_wrangler_jsonc(test_dir) diff --git a/packages/cli/tests/test_version_sync.py b/packages/cli/tests/test_version_sync.py index c05472a..5f292b3 100644 --- a/packages/cli/tests/test_version_sync.py +++ b/packages/cli/tests/test_version_sync.py @@ -3,7 +3,9 @@ import pytest +import pywrangler.resolve as pywrangler_resolve import pywrangler.sync as pywrangler_sync +from pywrangler.resolve import InstallPlan def test_parse_pip_freeze(): @@ -42,14 +44,16 @@ def test_native_error_shown_before_pyodide_error( import click import pytest + plan = InstallPlan(requirements=["nonexistent-package", "workers-runtime-sdk"]) with pytest.raises(click.exceptions.Exit): - pywrangler_sync.install_requirements(["nonexistent-package"]) + pywrangler_sync.install_requirements(plan) assert mock_vendor.call_count == 1 assert mock_venv.call_count == 1 assert mock_get_vendor.call_count == 0 - assert mock_vendor.call_args_list[0][0][0] == [ + passed_plan = mock_vendor.call_args_list[0][0][0] + assert passed_plan.requirements == [ "nonexistent-package", "workers-runtime-sdk", ] @@ -85,15 +89,17 @@ def test_only_pyodide_error_shown_when_native_succeeds( import click import pytest + plan = InstallPlan(requirements=["some-package", "workers-runtime-sdk"]) with pytest.raises(click.exceptions.Exit): - pywrangler_sync.install_requirements(["some-package"]) + pywrangler_sync.install_requirements(plan) assert mock_vendor.call_count == 1 assert mock_venv.call_count == 1 # Pyodide installation failed, so _get_vendor_package_versions should not be called assert mock_get_vendor.call_count == 0 - assert mock_vendor.call_args_list[0][0][0] == [ + passed_plan = mock_vendor.call_args_list[0][0][0] + assert passed_plan.requirements == [ "some-package", "workers-runtime-sdk", ] @@ -129,14 +135,16 @@ def test_pyodide_install_succeeds_but_native_installation_fail( import click import pytest + plan = InstallPlan(requirements=["some-package", "workers-runtime-sdk"]) with pytest.raises(click.exceptions.Exit): - pywrangler_sync.install_requirements(["some-package"]) + pywrangler_sync.install_requirements(plan) assert mock_vendor.call_count == 1 assert mock_venv.call_count == 1 assert mock_get_vendor.call_count == 1 - assert mock_vendor.call_args_list[0][0][0] == [ + passed_plan = mock_vendor.call_args_list[0][0][0] + assert passed_plan.requirements == [ "some-package", "workers-runtime-sdk", ] @@ -173,8 +181,9 @@ def test_known_pyodide_errors( import click import pytest + plan = InstallPlan(requirements=["some-package", "workers-runtime-sdk"]) with pytest.raises(click.exceptions.Exit): - pywrangler_sync.install_requirements(["some-package"]) + pywrangler_sync.install_requirements(plan) log_messages = [record.message for record in caplog.records] assert any(message in msg for msg in log_messages) @@ -250,3 +259,69 @@ def test_sync_needed_when_token_missing_version( venv_token.write_text("") assert pywrangler_sync.is_sync_needed() is True + + +class TestReadLockfileRequirements: + def test_reads_packages_from_pylock(self, tmp_path): + lockfile = tmp_path / "pylock.toml" + lockfile.write_text( + 'lock-version = "1.0"\n' + '[[packages]]\nname = "click"\nversion = "8.1.7"\n' + '[[packages]]\nname = "numpy"\nversion = "2.0.2"\n' + ) + result = pywrangler_resolve._read_lockfile_requirements(lockfile) + assert result == ["click==8.1.7", "numpy==2.0.2"] + + def test_empty_packages(self, tmp_path): + lockfile = tmp_path / "pylock.toml" + lockfile.write_text('lock-version = "1.0"\n') + result = pywrangler_resolve._read_lockfile_requirements(lockfile) + assert result == [] + + +class TestResolveRequirements: + @patch.object( + pywrangler_resolve, + "_compile_requirements", + return_value=["click==8.1.7"], + ) + @patch.object(pywrangler_resolve, "parse_requirements", return_value=["click>=8.0"]) + @patch.object(pywrangler_resolve, "get_lockfile_path") + def test_compiles_from_deps( + self, mock_lockpath, mock_parse, mock_compile, tmp_path + ): + mock_lockpath.return_value = tmp_path / "pylock.toml" + plan = pywrangler_resolve.resolve_requirements() + assert plan.requirements == ["click==8.1.7"] + mock_parse.assert_called_once() + mock_compile.assert_called_once() + + +class TestSyncNeededWithLockfile: + @pytest.fixture + def project_root(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + pyproject = tmp_path / "pyproject.toml" + pyproject.write_text("[project]\nname='x'\nversion='0.0.0'\n") + monkeypatch.setattr(pywrangler_sync, "find_pyproject_toml", lambda: pyproject) + monkeypatch.setattr(pywrangler_sync, "get_project_root", lambda: tmp_path) + monkeypatch.setattr(pywrangler_resolve, "get_project_root", lambda: tmp_path) + monkeypatch.setattr(pywrangler_sync, "get_pywrangler_version", lambda: "1.0.0") + return tmp_path + + def test_sync_needed_when_lockfile_newer_than_token( + self, project_root: Path + ) -> None: + pywrangler_sync._write_sync_token(pywrangler_sync.get_vendor_token_path()) + pywrangler_sync._write_sync_token(pywrangler_sync.get_venv_workers_token_path()) + + assert pywrangler_sync.is_sync_needed() is False + + lockfile = project_root / "pylock.toml" + lockfile.write_text("click==8.1.7\n") + import os + import time + + future = time.time() + 10 + os.utime(lockfile, (future, future)) + + assert pywrangler_sync.is_sync_needed() is True From 623b348b5c31d066cc0049ee1150d7d509f3ee1f Mon Sep 17 00:00:00 2001 From: Gyeongjae Choi Date: Fri, 5 Jun 2026 11:14:15 +0900 Subject: [PATCH 2/6] chore: move get_lockfile_path function to utils.py --- packages/cli/src/pywrangler/resolve.py | 8 ++------ packages/cli/src/pywrangler/sync.py | 2 +- packages/cli/src/pywrangler/utils.py | 6 ++++++ packages/cli/tests/test_version_sync.py | 3 ++- 4 files changed, 11 insertions(+), 8 deletions(-) diff --git a/packages/cli/src/pywrangler/resolve.py b/packages/cli/src/pywrangler/resolve.py index ad97c7b..6a95dd6 100644 --- a/packages/cli/src/pywrangler/resolve.py +++ b/packages/cli/src/pywrangler/resolve.py @@ -8,6 +8,7 @@ import click from .utils import ( + get_lockfile_path, get_project_root, get_pyodide_index, get_uv_pyodide_interp_name, @@ -17,7 +18,6 @@ logger = logging.getLogger(__name__) MANAGED_SDK_PACKAGE = "workers-runtime-sdk" -LOCKFILE_NAME = "pylock.toml" @dataclass @@ -35,10 +35,6 @@ def parse_requirements() -> list[str]: return pyproject_data.get("project", {}).get("dependencies", []) -def get_lockfile_path() -> Path: - return get_project_root() / LOCKFILE_NAME - - def _compile_requirements( requirements: list[str], lockfile_path: Path, @@ -133,7 +129,7 @@ def resolve_requirements(*, upgrade: bool = False) -> InstallPlan: requirements = _compile_requirements(deps, lockfile, upgrade=upgrade) - logger.info("Resolved %d requirements from %s.", len(requirements), LOCKFILE_NAME) + logger.info("Resolved %d requirements from %s.", len(requirements), lockfile) for req in requirements: logger.debug(" - %s", req) return InstallPlan(requirements=requirements, lockfile=lockfile) diff --git a/packages/cli/src/pywrangler/sync.py b/packages/cli/src/pywrangler/sync.py index d120d97..c4b31fe 100644 --- a/packages/cli/src/pywrangler/sync.py +++ b/packages/cli/src/pywrangler/sync.py @@ -10,13 +10,13 @@ from .resolve import ( InstallPlan, - get_lockfile_path, resolve_requirements, ) from .utils import ( check_uv_version, check_wrangler_config, find_pyproject_toml, + get_lockfile_path, get_project_root, get_pyodide_index, get_python_version, diff --git a/packages/cli/src/pywrangler/utils.py b/packages/cli/src/pywrangler/utils.py index 85a57e2..9dc51ca 100644 --- a/packages/cli/src/pywrangler/utils.py +++ b/packages/cli/src/pywrangler/utils.py @@ -38,6 +38,8 @@ "error": logging.ERROR, } +LOCKFILE_NAME = "pylock.toml" + def setup_logging() -> int: """ @@ -404,3 +406,7 @@ def get_pyodide_index() -> str: case "3.13": v = "0.28.3" return "https://index.pyodide.org/" + v + + +def get_lockfile_path() -> Path: + return get_project_root() / LOCKFILE_NAME diff --git a/packages/cli/tests/test_version_sync.py b/packages/cli/tests/test_version_sync.py index 5f292b3..b7a9634 100644 --- a/packages/cli/tests/test_version_sync.py +++ b/packages/cli/tests/test_version_sync.py @@ -2,6 +2,7 @@ from unittest.mock import patch import pytest +import pywrnagler.utils as pywrangler_utils import pywrangler.resolve as pywrangler_resolve import pywrangler.sync as pywrangler_sync @@ -286,7 +287,7 @@ class TestResolveRequirements: return_value=["click==8.1.7"], ) @patch.object(pywrangler_resolve, "parse_requirements", return_value=["click>=8.0"]) - @patch.object(pywrangler_resolve, "get_lockfile_path") + @patch.object(pywrangler_utils, "get_lockfile_path") def test_compiles_from_deps( self, mock_lockpath, mock_parse, mock_compile, tmp_path ): From c5d3b3f30a92cbb233599c99aefa4b52435bc9f4 Mon Sep 17 00:00:00 2001 From: Gyeongjae Choi Date: Fri, 5 Jun 2026 11:20:46 +0900 Subject: [PATCH 3/6] chore: replace sp.run with run_command --- packages/cli/src/pywrangler/resolve.py | 25 ++----------------------- packages/cli/tests/test_version_sync.py | 2 +- 2 files changed, 3 insertions(+), 24 deletions(-) diff --git a/packages/cli/src/pywrangler/resolve.py b/packages/cli/src/pywrangler/resolve.py index 6a95dd6..f73f9de 100644 --- a/packages/cli/src/pywrangler/resolve.py +++ b/packages/cli/src/pywrangler/resolve.py @@ -1,18 +1,16 @@ import logging -import subprocess as sp import tempfile import tomllib from dataclasses import dataclass, field from pathlib import Path -import click - from .utils import ( get_lockfile_path, get_project_root, get_pyodide_index, get_uv_pyodide_interp_name, read_pyproject_toml, + run_command, ) logger = logging.getLogger(__name__) @@ -46,9 +44,6 @@ def _compile_requirements( 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). - - Uses subprocess directly (instead of run_command) to keep stdout and stderr - separate — uv writes the compiled output to stdout but warnings to stderr. """ with tempfile.NamedTemporaryFile(mode="w", suffix=".in", delete=False) as req_file: req_file.write("\n".join(requirements)) @@ -75,26 +70,10 @@ def _compile_requirements( if upgrade: cmd.append("--upgrade") - # TODO: use run_command function instead of sp.run - # like other functions. It requires - # updating the run_command function to handle stdout and stderr separately - logger.debug(f"Running: {' '.join(cmd)}") - result = sp.run( - cmd, - cwd=get_project_root(), - capture_output=True, - text=True, - encoding="utf-8", - check=False, - ) + run_command(cmd, cwd=get_project_root(), capture_output=True) finally: Path(req_in_path).unlink(missing_ok=True) - if result.returncode != 0: - error_output = (result.stderr or result.stdout or "").strip() - logger.error(f"uv pip compile failed:\n{error_output}") - raise click.exceptions.Exit(code=1) - return _read_lockfile_requirements(lockfile_path) diff --git a/packages/cli/tests/test_version_sync.py b/packages/cli/tests/test_version_sync.py index b7a9634..aef555d 100644 --- a/packages/cli/tests/test_version_sync.py +++ b/packages/cli/tests/test_version_sync.py @@ -2,7 +2,7 @@ from unittest.mock import patch import pytest -import pywrnagler.utils as pywrangler_utils +import pywrangler.utils as pywrangler_utils import pywrangler.resolve as pywrangler_resolve import pywrangler.sync as pywrangler_sync From aae241397ccaff89df5edd032bb83a6384637618 Mon Sep 17 00:00:00 2001 From: Gyeongjae Choi Date: Fri, 5 Jun 2026 12:42:29 +0900 Subject: [PATCH 4/6] chore: Use temp_requirements_file fixture --- packages/cli/src/pywrangler/resolve.py | 11 ++--------- packages/cli/src/pywrangler/sync.py | 13 +------------ packages/cli/src/pywrangler/utils.py | 13 ++++++++++++- packages/cli/tests/test_version_sync.py | 11 +++++------ 4 files changed, 20 insertions(+), 28 deletions(-) diff --git a/packages/cli/src/pywrangler/resolve.py b/packages/cli/src/pywrangler/resolve.py index f73f9de..24ac1cc 100644 --- a/packages/cli/src/pywrangler/resolve.py +++ b/packages/cli/src/pywrangler/resolve.py @@ -1,5 +1,4 @@ import logging -import tempfile import tomllib from dataclasses import dataclass, field from pathlib import Path @@ -11,6 +10,7 @@ get_uv_pyodide_interp_name, read_pyproject_toml, run_command, + temp_requirements_file, ) logger = logging.getLogger(__name__) @@ -45,12 +45,7 @@ def _compile_requirements( exists, ``uv pip compile`` uses it as a constraint source so pinned versions are preserved across re-runs (no silent upgrades). """ - with tempfile.NamedTemporaryFile(mode="w", suffix=".in", delete=False) as req_file: - req_file.write("\n".join(requirements)) - req_file.flush() - req_in_path = req_file.name - - try: + with temp_requirements_file(requirements) as req_in_path: cmd = [ "uv", "pip", @@ -71,8 +66,6 @@ def _compile_requirements( cmd.append("--upgrade") run_command(cmd, cwd=get_project_root(), capture_output=True) - finally: - Path(req_in_path).unlink(missing_ok=True) return _read_lockfile_requirements(lockfile_path) diff --git a/packages/cli/src/pywrangler/sync.py b/packages/cli/src/pywrangler/sync.py index c4b31fe..cd7cc51 100644 --- a/packages/cli/src/pywrangler/sync.py +++ b/packages/cli/src/pywrangler/sync.py @@ -1,9 +1,6 @@ import logging import os import shutil -import tempfile -from collections.abc import Iterator -from contextlib import contextmanager from pathlib import Path import click @@ -23,6 +20,7 @@ get_pywrangler_version, get_uv_pyodide_interp_name, run_command, + temp_requirements_file, ) logger = logging.getLogger(__name__) @@ -148,15 +146,6 @@ def create_pyodide_venv() -> None: run_command(["uv", "venv", str(pyodide_venv_path), "--python", interp_name]) -@contextmanager -def temp_requirements_file(requirements: list[str]) -> Iterator[str]: - # Write dependencies to a requirements.txt-style temp file. - with tempfile.NamedTemporaryFile(mode="w", suffix=".txt") as temp_file: - temp_file.write("\n".join(requirements)) - temp_file.flush() - yield temp_file.name - - def _install_requirements_to_vendor(plan: InstallPlan) -> str | None: """Install packages to the Pyodide vendor directory. diff --git a/packages/cli/src/pywrangler/utils.py b/packages/cli/src/pywrangler/utils.py index 9dc51ca..79bb1ca 100644 --- a/packages/cli/src/pywrangler/utils.py +++ b/packages/cli/src/pywrangler/utils.py @@ -5,8 +5,10 @@ import shutil import subprocess import sys +import tempfile import tomllib -from collections.abc import Mapping +from collections.abc import Iterator, Mapping +from contextlib import contextmanager from datetime import datetime from functools import cache from pathlib import Path @@ -410,3 +412,12 @@ def get_pyodide_index() -> str: def get_lockfile_path() -> Path: return get_project_root() / LOCKFILE_NAME + + +@contextmanager +def temp_requirements_file(requirements: list[str]) -> Iterator[str]: + # Write dependencies to a requirements.txt-style temp file. + with tempfile.NamedTemporaryFile(mode="w", suffix=".txt") as temp_file: + temp_file.write("\n".join(requirements)) + temp_file.flush() + yield temp_file.name diff --git a/packages/cli/tests/test_version_sync.py b/packages/cli/tests/test_version_sync.py index aef555d..6f0eda4 100644 --- a/packages/cli/tests/test_version_sync.py +++ b/packages/cli/tests/test_version_sync.py @@ -2,10 +2,10 @@ from unittest.mock import patch import pytest -import pywrangler.utils as pywrangler_utils import pywrangler.resolve as pywrangler_resolve import pywrangler.sync as pywrangler_sync +import pywrangler.utils as pywrangler_utils from pywrangler.resolve import InstallPlan @@ -197,8 +197,8 @@ class TestSyncTokenVersion: def project_root(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: pyproject = tmp_path / "pyproject.toml" pyproject.write_text("[project]\nname='x'\nversion='0.0.0'\n") - monkeypatch.setattr(pywrangler_sync, "find_pyproject_toml", lambda: pyproject) - monkeypatch.setattr(pywrangler_sync, "get_project_root", lambda: tmp_path) + monkeypatch.chdir(tmp_path) + pywrangler_utils.find_pyproject_toml.cache_clear() return tmp_path def test_write_sync_token_records_current_version( @@ -303,9 +303,8 @@ class TestSyncNeededWithLockfile: def project_root(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: pyproject = tmp_path / "pyproject.toml" pyproject.write_text("[project]\nname='x'\nversion='0.0.0'\n") - monkeypatch.setattr(pywrangler_sync, "find_pyproject_toml", lambda: pyproject) - monkeypatch.setattr(pywrangler_sync, "get_project_root", lambda: tmp_path) - monkeypatch.setattr(pywrangler_resolve, "get_project_root", lambda: tmp_path) + monkeypatch.chdir(tmp_path) + pywrangler_utils.find_pyproject_toml.cache_clear() monkeypatch.setattr(pywrangler_sync, "get_pywrangler_version", lambda: "1.0.0") return tmp_path From 4f78767e753ae000ee6e838d3103ca6dc8f7c590 Mon Sep 17 00:00:00 2001 From: Gyeongjae Choi Date: Fri, 5 Jun 2026 13:04:32 +0900 Subject: [PATCH 5/6] chore: always use lockfile --- packages/cli/src/pywrangler/resolve.py | 56 ++++++------ packages/cli/src/pywrangler/sync.py | 60 ++++--------- packages/cli/tests/test_cli.py | 12 ++- packages/cli/tests/test_version_sync.py | 114 +++++++++++++++++------- 4 files changed, 137 insertions(+), 105 deletions(-) diff --git a/packages/cli/src/pywrangler/resolve.py b/packages/cli/src/pywrangler/resolve.py index 24ac1cc..7cd9874 100644 --- a/packages/cli/src/pywrangler/resolve.py +++ b/packages/cli/src/pywrangler/resolve.py @@ -1,6 +1,5 @@ import logging import tomllib -from dataclasses import dataclass, field from pathlib import Path from .utils import ( @@ -18,12 +17,24 @@ MANAGED_SDK_PACKAGE = "workers-runtime-sdk" -@dataclass class InstallPlan: - """Requirements resolved for installation (requirements.txt-style strings).""" + def __init__(self, lockfile: Path) -> None: + self.lockfile = lockfile + self.requirements: list[tuple[str, str]] = [] - requirements: list[str] = field(default_factory=list) - lockfile: Path | None = None + with open(lockfile, "rb") as f: + data = tomllib.load(f) + + for pkg in data.get("packages", []): + name = pkg.get("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)) + + def to_requirement_strings(self) -> list[str]: + return [f"{name}=={version}" for name, version in self.requirements] def parse_requirements() -> list[str]: @@ -33,13 +44,13 @@ def parse_requirements() -> list[str]: return pyproject_data.get("project", {}).get("dependencies", []) -def _compile_requirements( +def _compile_lockfile( requirements: list[str], lockfile_path: Path, *, upgrade: bool = False, -) -> list[str]: - """Run ``uv pip compile`` targeting Pyodide and return pinned requirement strings. +) -> 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 @@ -67,24 +78,6 @@ def _compile_requirements( run_command(cmd, cwd=get_project_root(), capture_output=True) - return _read_lockfile_requirements(lockfile_path) - - -def _read_lockfile_requirements(lockfile_path: Path) -> list[str]: - """Read pinned ``name==version`` pairs from a ``pylock.toml`` file.""" - with open(lockfile_path, "rb") as f: - data = tomllib.load(f) - - results = [] - for pkg in data.get("packages", []): - name = pkg.get("name") - version = pkg.get("version") - if not name or not version: - logger.warning("Skipping malformed lockfile entry: %s", pkg) - continue - results.append(f"{name}=={version}") - return results - def resolve_requirements(*, upgrade: bool = False) -> InstallPlan: """Build an InstallPlan by compiling dependencies for the Pyodide target. @@ -99,9 +92,10 @@ def resolve_requirements(*, upgrade: bool = False) -> InstallPlan: deps = parse_requirements() deps.append(MANAGED_SDK_PACKAGE) - requirements = _compile_requirements(deps, lockfile, upgrade=upgrade) + _compile_lockfile(deps, lockfile, upgrade=upgrade) + plan = InstallPlan(lockfile) - logger.info("Resolved %d requirements from %s.", len(requirements), lockfile) - for req in requirements: - logger.debug(" - %s", req) - return InstallPlan(requirements=requirements, lockfile=lockfile) + logger.info("Resolved %d requirements from %s.", len(plan.requirements), lockfile) + for name, version in plan.requirements: + logger.debug(" - %s==%s", name, version) + return plan diff --git a/packages/cli/src/pywrangler/sync.py b/packages/cli/src/pywrangler/sync.py index cd7cc51..ce9a14b 100644 --- a/packages/cli/src/pywrangler/sync.py +++ b/packages/cli/src/pywrangler/sync.py @@ -15,7 +15,6 @@ find_pyproject_toml, get_lockfile_path, get_project_root, - get_pyodide_index, get_python_version, get_pywrangler_version, get_uv_pyodide_interp_name, @@ -147,10 +146,7 @@ def create_pyodide_venv() -> None: def _install_requirements_to_vendor(plan: InstallPlan) -> str | None: - """Install packages to the Pyodide vendor directory. - - When the plan has a lockfile (pylock.toml), installs directly from it. - Otherwise falls back to a temp requirements file with dynamic resolution. + """Install packages to the Pyodide vendor directory from pylock.toml. Returns: Error message string if installation failed, None if successful. @@ -183,41 +179,21 @@ def _install_requirements_to_vendor(plan: InstallPlan) -> str | None: shutil.rmtree(pyodide_site_packages) pyodide_site_packages.mkdir() - if plan.lockfile and plan.lockfile.is_file(): - result = run_command( - [ - "uv", - "pip", - "install", - "--no-build", - "-r", - str(plan.lockfile), - "--preview-features", - "pylock", - ], - capture_output=True, - check=False, - env=os.environ | {"VIRTUAL_ENV": str(get_pyodide_venv_path())}, - ) - else: - with temp_requirements_file(plan.requirements) as requirements_file: - result = run_command( - [ - "uv", - "pip", - "install", - "--no-build", - "-r", - requirements_file, - "--extra-index-url", - get_pyodide_index(), - "--index-strategy", - "unsafe-best-match", - ], - capture_output=True, - check=False, - env=os.environ | {"VIRTUAL_ENV": str(get_pyodide_venv_path())}, - ) + result = run_command( + [ + "uv", + "pip", + "install", + "--no-build", + "-r", + str(plan.lockfile), + "--preview-features", + "pylock", + ], + capture_output=True, + check=False, + env=os.environ | {"VIRTUAL_ENV": str(get_pyodide_venv_path())}, + ) if result.returncode != 0: return result.stdout.strip() @@ -324,7 +300,9 @@ def install_requirements(plan: InstallPlan) -> None: # 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 = ( - plan.requirements if pyodide_error else _get_vendor_package_versions() + plan.to_requirement_strings() + if pyodide_error + else _get_vendor_package_versions() ) native_error = _install_requirements_to_venv(host_requirements) diff --git a/packages/cli/tests/test_cli.py b/packages/cli/tests/test_cli.py index 1b4d600..4596ade 100644 --- a/packages/cli/tests/test_cli.py +++ b/packages/cli/tests/test_cli.py @@ -441,7 +441,11 @@ def test_sync_command_with_changed_timestamps( """Test that the sync command runs when timestamps indicate changes.""" from pywrangler.resolve import InstallPlan - mock_resolve.return_value = InstallPlan(requirements=["click>=8.0"]) + lockfile = test_dir / "pylock.toml" + lockfile.write_text( + 'lock-version = "1.0"\n[[packages]]\nname = "click"\nversion = "8.1.7"\n' + ) + mock_resolve.return_value = InstallPlan(lockfile) # Create the pyproject.toml file create_test_pyproject(test_dir) @@ -477,7 +481,11 @@ def test_sync_command_with_force_flag( """Test that the sync command runs when the --force flag is used, regardless of timestamps.""" from pywrangler.resolve import InstallPlan - mock_resolve.return_value = InstallPlan(requirements=["click>=8.0"]) + lockfile = test_dir / "pylock.toml" + lockfile.write_text( + 'lock-version = "1.0"\n[[packages]]\nname = "click"\nversion = "8.1.7"\n' + ) + mock_resolve.return_value = InstallPlan(lockfile) create_test_pyproject(test_dir) create_test_wrangler_jsonc(test_dir) diff --git a/packages/cli/tests/test_version_sync.py b/packages/cli/tests/test_version_sync.py index 6f0eda4..293f831 100644 --- a/packages/cli/tests/test_version_sync.py +++ b/packages/cli/tests/test_version_sync.py @@ -9,6 +9,15 @@ from pywrangler.resolve import InstallPlan +def _make_plan(tmp_path: Path, packages: list[tuple[str, str]]) -> InstallPlan: + lockfile = tmp_path / "pylock.toml" + lines = ['lock-version = "1.0"'] + for name, version in packages: + lines.append(f'[[packages]]\nname = "{name}"\nversion = "{version}"') + lockfile.write_text("\n".join(lines) + "\n") + return InstallPlan(lockfile) + + def test_parse_pip_freeze(): result = pywrangler_sync._parse_pip_freeze( "shapely==2.0.7\nnumpy==1.26.4\nclick==8.1.7\n" @@ -34,7 +43,7 @@ class TestInstallRequirements: @patch.object(pywrangler_sync, "_get_vendor_package_versions") @patch.object(pywrangler_sync, "_install_requirements_to_venv") def test_native_error_shown_before_pyodide_error( - self, mock_venv, mock_get_vendor, mock_vendor, caplog + self, mock_venv, mock_get_vendor, mock_vendor, caplog, tmp_path ): mocked_pyodide_error = "Pyodide install failed: no solution found" mock_vendor.return_value = mocked_pyodide_error @@ -45,7 +54,13 @@ def test_native_error_shown_before_pyodide_error( import click import pytest - plan = InstallPlan(requirements=["nonexistent-package", "workers-runtime-sdk"]) + plan = _make_plan( + tmp_path, + [ + ("nonexistent-package", "1.0.0"), + ("workers-runtime-sdk", "1.0.0"), + ], + ) with pytest.raises(click.exceptions.Exit): pywrangler_sync.install_requirements(plan) @@ -55,12 +70,12 @@ def test_native_error_shown_before_pyodide_error( passed_plan = mock_vendor.call_args_list[0][0][0] assert passed_plan.requirements == [ - "nonexistent-package", - "workers-runtime-sdk", + ("nonexistent-package", "1.0.0"), + ("workers-runtime-sdk", "1.0.0"), ] assert mock_venv.call_args_list[0][0][0] == [ - "nonexistent-package", - "workers-runtime-sdk", + "nonexistent-package==1.0.0", + "workers-runtime-sdk==1.0.0", ] log_messages = [record.message for record in caplog.records] @@ -80,7 +95,7 @@ def test_native_error_shown_before_pyodide_error( @patch.object(pywrangler_sync, "_get_vendor_package_versions") @patch.object(pywrangler_sync, "_install_requirements_to_venv") def test_only_pyodide_error_shown_when_native_succeeds( - self, mock_venv, mock_get_vendor, mock_vendor, caplog + self, mock_venv, mock_get_vendor, mock_vendor, caplog, tmp_path ): mocked_pyodide_error = "Pyodide install failed: no solution found" mock_vendor.return_value = mocked_pyodide_error @@ -90,7 +105,13 @@ def test_only_pyodide_error_shown_when_native_succeeds( import click import pytest - plan = InstallPlan(requirements=["some-package", "workers-runtime-sdk"]) + plan = _make_plan( + tmp_path, + [ + ("some-package", "1.0.0"), + ("workers-runtime-sdk", "1.0.0"), + ], + ) with pytest.raises(click.exceptions.Exit): pywrangler_sync.install_requirements(plan) @@ -101,14 +122,14 @@ def test_only_pyodide_error_shown_when_native_succeeds( passed_plan = mock_vendor.call_args_list[0][0][0] assert passed_plan.requirements == [ - "some-package", - "workers-runtime-sdk", + ("some-package", "1.0.0"), + ("workers-runtime-sdk", "1.0.0"), ] # native installation should be called with the original requirements assert mock_venv.call_args_list[0][0][0] == [ - "some-package", - "workers-runtime-sdk", + "some-package==1.0.0", + "workers-runtime-sdk==1.0.0", ] log_messages = [record.message for record in caplog.records] @@ -123,7 +144,7 @@ def test_only_pyodide_error_shown_when_native_succeeds( @patch.object(pywrangler_sync, "_get_vendor_package_versions") @patch.object(pywrangler_sync, "_install_requirements_to_venv") def test_pyodide_install_succeeds_but_native_installation_fail( - self, mock_venv, mock_get_vendor, mock_vendor, caplog + self, mock_venv, mock_get_vendor, mock_vendor, caplog, tmp_path ): mocked_native_error = "Native install failed: package not found" mock_vendor.return_value = None @@ -136,7 +157,13 @@ def test_pyodide_install_succeeds_but_native_installation_fail( import click import pytest - plan = InstallPlan(requirements=["some-package", "workers-runtime-sdk"]) + plan = _make_plan( + tmp_path, + [ + ("some-package", "1.0.0"), + ("workers-runtime-sdk", "1.0.0"), + ], + ) with pytest.raises(click.exceptions.Exit): pywrangler_sync.install_requirements(plan) @@ -146,8 +173,8 @@ def test_pyodide_install_succeeds_but_native_installation_fail( passed_plan = mock_vendor.call_args_list[0][0][0] assert passed_plan.requirements == [ - "some-package", - "workers-runtime-sdk", + ("some-package", "1.0.0"), + ("workers-runtime-sdk", "1.0.0"), ] assert mock_venv.call_args_list[0][0][0] == [ "some-package==1.0.0", @@ -166,7 +193,7 @@ def test_pyodide_install_succeeds_but_native_installation_fail( @patch.object(pywrangler_sync, "_get_vendor_package_versions") @patch.object(pywrangler_sync, "_install_requirements_to_venv") def test_known_pyodide_errors( - self, mock_venv, mock_get_vendor, mock_vendor, caplog + self, mock_venv, mock_get_vendor, mock_vendor, caplog, tmp_path ): common_errors = { "invalid peer certificate": "Are your systems certificates correctly installed? Do you have an Enterprise VPN enabled?", @@ -182,7 +209,13 @@ def test_known_pyodide_errors( import click import pytest - plan = InstallPlan(requirements=["some-package", "workers-runtime-sdk"]) + plan = _make_plan( + tmp_path, + [ + ("some-package", "1.0.0"), + ("workers-runtime-sdk", "1.0.0"), + ], + ) with pytest.raises(click.exceptions.Exit): pywrangler_sync.install_requirements(plan) @@ -262,38 +295,57 @@ def test_sync_needed_when_token_missing_version( assert pywrangler_sync.is_sync_needed() is True -class TestReadLockfileRequirements: - def test_reads_packages_from_pylock(self, tmp_path): +class TestInstallPlan: + def test_parses_packages_from_pylock(self, tmp_path): lockfile = tmp_path / "pylock.toml" lockfile.write_text( 'lock-version = "1.0"\n' '[[packages]]\nname = "click"\nversion = "8.1.7"\n' '[[packages]]\nname = "numpy"\nversion = "2.0.2"\n' ) - result = pywrangler_resolve._read_lockfile_requirements(lockfile) - assert result == ["click==8.1.7", "numpy==2.0.2"] + plan = InstallPlan(lockfile) + assert plan.requirements == [("click", "8.1.7"), ("numpy", "2.0.2")] + assert plan.lockfile == lockfile def test_empty_packages(self, tmp_path): lockfile = tmp_path / "pylock.toml" lockfile.write_text('lock-version = "1.0"\n') - result = pywrangler_resolve._read_lockfile_requirements(lockfile) - assert result == [] + plan = InstallPlan(lockfile) + assert plan.requirements == [] + + def test_to_requirement_strings(self, tmp_path): + lockfile = tmp_path / "pylock.toml" + lockfile.write_text( + 'lock-version = "1.0"\n' + '[[packages]]\nname = "click"\nversion = "8.1.7"\n' + '[[packages]]\nname = "numpy"\nversion = "2.0.2"\n' + ) + plan = InstallPlan(lockfile) + assert plan.to_requirement_strings() == ["click==8.1.7", "numpy==2.0.2"] class TestResolveRequirements: - @patch.object( - pywrangler_resolve, - "_compile_requirements", - return_value=["click==8.1.7"], - ) + @patch.object(pywrangler_resolve, "_compile_lockfile") @patch.object(pywrangler_resolve, "parse_requirements", return_value=["click>=8.0"]) @patch.object(pywrangler_utils, "get_lockfile_path") def test_compiles_from_deps( self, mock_lockpath, mock_parse, mock_compile, tmp_path ): - mock_lockpath.return_value = tmp_path / "pylock.toml" + lockfile = tmp_path / "pylock.toml" + mock_lockpath.return_value = lockfile + + def write_lockfile(reqs, path, **kwargs): + path.write_text( + 'lock-version = "1.0"\n' + '[[packages]]\nname = "click"\nversion = "8.1.7"\n' + '[[packages]]\nname = "workers-runtime-sdk"\nversion = "1.1.5"\n' + ) + + mock_compile.side_effect = write_lockfile + plan = pywrangler_resolve.resolve_requirements() - assert plan.requirements == ["click==8.1.7"] + assert ("click", "8.1.7") in plan.requirements + assert ("workers-runtime-sdk", "1.1.5") in plan.requirements mock_parse.assert_called_once() mock_compile.assert_called_once() From 1a2649db6a93b975624a9d05f83bab33d9fbede2 Mon Sep 17 00:00:00 2001 From: Gyeongjae Choi Date: Mon, 8 Jun 2026 14:36:20 +0900 Subject: [PATCH 6/6] chore: remove unnecessary mocks in test_cli --- packages/cli/tests/test_cli.py | 27 --------------------------- 1 file changed, 27 deletions(-) diff --git a/packages/cli/tests/test_cli.py b/packages/cli/tests/test_cli.py index 4596ade..dd65f5f 100644 --- a/packages/cli/tests/test_cli.py +++ b/packages/cli/tests/test_cli.py @@ -427,26 +427,12 @@ def test_sync_command_with_unchanged_timestamps( @patch.object(pywrangler_sync, "is_sync_needed", lambda: True) @patch.object(pywrangler_sync, "install_requirements") -@patch.object(pywrangler_sync, "resolve_requirements") -@patch.object(pywrangler_sync, "create_pyodide_venv") -@patch.object(pywrangler_sync, "create_workers_venv") def test_sync_command_with_changed_timestamps( - mock_create_venv, - mock_create_pyodide, - mock_resolve, mock_install_requirements, test_dir, caplog, ): """Test that the sync command runs when timestamps indicate changes.""" - from pywrangler.resolve import InstallPlan - - lockfile = test_dir / "pylock.toml" - lockfile.write_text( - 'lock-version = "1.0"\n[[packages]]\nname = "click"\nversion = "8.1.7"\n' - ) - mock_resolve.return_value = InstallPlan(lockfile) - # Create the pyproject.toml file create_test_pyproject(test_dir) @@ -461,32 +447,19 @@ def test_sync_command_with_changed_timestamps( assert result.exit_code == 0 # Verify that all the sync functions were called - mock_resolve.assert_called_once() mock_install_requirements.assert_called_once() @patch.object(pywrangler_sync, "is_sync_needed", lambda: False) @patch.object(pywrangler_sync, "install_requirements") @patch.object(pywrangler_sync, "resolve_requirements") -@patch.object(pywrangler_sync, "create_pyodide_venv") -@patch.object(pywrangler_sync, "create_workers_venv") def test_sync_command_with_force_flag( - mock_create_venv, - mock_create_pyodide, mock_resolve, mock_install_requirements, test_dir, caplog, ): """Test that the sync command runs when the --force flag is used, regardless of timestamps.""" - from pywrangler.resolve import InstallPlan - - lockfile = test_dir / "pylock.toml" - lockfile.write_text( - 'lock-version = "1.0"\n[[packages]]\nname = "click"\nversion = "8.1.7"\n' - ) - mock_resolve.return_value = InstallPlan(lockfile) - create_test_pyproject(test_dir) create_test_wrangler_jsonc(test_dir)