diff --git a/packages/cli/pyproject.toml b/packages/cli/pyproject.toml index 7c663e1..c0f5403 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..7cd9874 --- /dev/null +++ b/packages/cli/src/pywrangler/resolve.py @@ -0,0 +1,101 @@ +import logging +import tomllib +from pathlib import Path + +from .utils import ( + get_lockfile_path, + get_project_root, + get_pyodide_index, + get_uv_pyodide_interp_name, + read_pyproject_toml, + run_command, + temp_requirements_file, +) + +logger = logging.getLogger(__name__) + +MANAGED_SDK_PACKAGE = "workers-runtime-sdk" + + +class InstallPlan: + def __init__(self, lockfile: Path) -> None: + self.lockfile = lockfile + self.requirements: list[tuple[str, str]] = [] + + 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]: + pyproject_data = read_pyproject_toml() + + # Extract dependencies from [project.dependencies] + return pyproject_data.get("project", {}).get("dependencies", []) + + +def _compile_lockfile( + requirements: list[str], + lockfile_path: Path, + *, + upgrade: 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). + """ + with temp_requirements_file(requirements) as req_in_path: + 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") + + run_command(cmd, cwd=get_project_root(), capture_output=True) + + +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) + + _compile_lockfile(deps, lockfile, upgrade=upgrade) + plan = InstallPlan(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 df65ec2..ce9a14b 100644 --- a/packages/cli/src/pywrangler/sync.py +++ b/packages/cli/src/pywrangler/sync.py @@ -1,24 +1,25 @@ import logging import os import shutil -import tempfile -from collections.abc import Iterator -from contextlib import contextmanager from pathlib import Path import click +from .resolve import ( + InstallPlan, + 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, get_pywrangler_version, get_uv_pyodide_interp_name, - read_pyproject_toml, run_command, + temp_requirements_file, ) logger = logging.getLogger(__name__) @@ -144,30 +145,8 @@ 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. - 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(requirements: list[str]) -> str | None: - """Install packages to the Pyodide vendor directory. +def _install_requirements_to_vendor(plan: InstallPlan) -> str | None: + """Install packages to the Pyodide vendor directory from pylock.toml. Returns: Error message string if installation failed, None if successful. @@ -175,7 +154,7 @@ def _install_requirements_to_vendor(requirements: list[str]) -> str | None: 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}." ) @@ -200,29 +179,27 @@ 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: - 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())}, - ) - if result.returncode != 0: - return result.stdout.strip() + 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() - shutil.rmtree(vendor_path) - shutil.copytree(pyodide_site_packages, vendor_path) + 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() @@ -313,18 +290,19 @@ 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.to_requirement_strings() + if pyodide_error + else _get_vendor_package_versions() ) native_error = _install_requirements_to_venv(host_requirements) @@ -395,8 +373,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 @@ -406,13 +384,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() @@ -437,10 +424,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/src/pywrangler/utils.py b/packages/cli/src/pywrangler/utils.py index 85a57e2..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 @@ -38,6 +40,8 @@ "error": logging.ERROR, } +LOCKFILE_NAME = "pylock.toml" + def setup_logging() -> int: """ @@ -404,3 +408,16 @@ 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 + + +@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_cli.py b/packages/cli/tests/test_cli.py index 280a77f..dd65f5f 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 @@ -366,7 +428,9 @@ def test_sync_command_with_unchanged_timestamps( @patch.object(pywrangler_sync, "is_sync_needed", lambda: True) @patch.object(pywrangler_sync, "install_requirements") def test_sync_command_with_changed_timestamps( - mock_install_requirements, test_dir, caplog + mock_install_requirements, + test_dir, + caplog, ): """Test that the sync command runs when timestamps indicate changes.""" # Create the pyproject.toml file @@ -388,7 +452,13 @@ def test_sync_command_with_changed_timestamps( @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") +def test_sync_command_with_force_flag( + mock_resolve, + mock_install_requirements, + test_dir, + caplog, +): """Test that the sync command runs when the --force flag is used, regardless of timestamps.""" 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..293f831 100644 --- a/packages/cli/tests/test_version_sync.py +++ b/packages/cli/tests/test_version_sync.py @@ -3,7 +3,19 @@ import pytest +import pywrangler.resolve as pywrangler_resolve import pywrangler.sync as pywrangler_sync +import pywrangler.utils as pywrangler_utils +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(): @@ -31,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 @@ -42,20 +54,28 @@ def test_native_error_shown_before_pyodide_error( import click import pytest + 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(["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] == [ - "nonexistent-package", - "workers-runtime-sdk", + passed_plan = mock_vendor.call_args_list[0][0][0] + assert passed_plan.requirements == [ + ("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] @@ -75,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 @@ -85,23 +105,31 @@ def test_only_pyodide_error_shown_when_native_succeeds( import click import pytest + 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(["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] == [ - "some-package", - "workers-runtime-sdk", + passed_plan = mock_vendor.call_args_list[0][0][0] + assert passed_plan.requirements == [ + ("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] @@ -116,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 @@ -129,16 +157,24 @@ def test_pyodide_install_succeeds_but_native_installation_fail( import click import pytest + 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(["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] == [ - "some-package", - "workers-runtime-sdk", + passed_plan = mock_vendor.call_args_list[0][0][0] + assert passed_plan.requirements == [ + ("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", @@ -157,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?", @@ -173,8 +209,15 @@ def test_known_pyodide_errors( import click import pytest + 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(["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) @@ -187,8 +230,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( @@ -250,3 +293,87 @@ def test_sync_needed_when_token_missing_version( venv_token.write_text("") assert pywrangler_sync.is_sync_needed() is True + + +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' + ) + 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') + 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_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 + ): + 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 ("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() + + +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.chdir(tmp_path) + pywrangler_utils.find_pyproject_toml.cache_clear() + 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