From 1a67ec485367a8c31c9e114d61469817cd962f46 Mon Sep 17 00:00:00 2001 From: Noritada Kobayashi Date: Fri, 10 Jul 2026 01:55:11 +0900 Subject: [PATCH] fix(python-uv): take into account dependencies on other workspace members MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This commit fixes an issue where an application created as a member of a uv workspace would fail to build if they depended on other workspace members. The following shows the problematic workspace structure and the error message. ``` workspace root ├── lib │   ├── pyproject.toml │   └── src ├── pyproject.toml ├── sam-app │   ├── __init__.py │   ├── app.py │   ├── pyproject.toml │   ├── samconfig.toml │   └── template.yaml └── uv.lock ``` ``` Build Failed Error: PythonUvBuilder:ResolveDependencies - UV package build failed: Failed to build from pyproject.toml: Lock file operation failed: Failed to install dependencies using uv: UV pip install failed: Using CPython 3.13.0 interpreter at: /path/to/workspace/.venv/bin/python3 error: Distribution not found at: file:///path/to/workspace/sam-app/lib ``` Even though `lib` and `sam-app` are in the same directory level in the workspace, the workflow attempts to install `lib` under `sam-app`. In the workflow, `uv export` outputs a list of dependency packages, which is then passed to `uv pip install` for installation. When doing so, [`uv export` outputs relative paths from the workspace root][1]. Therefore, `uv pip install` must be run from the workspace root, not from the application directory. Additionally, dependencies on other packages within the workspace are exported as editable installations (e.g., `-e ./lib`) by default. When this is passed to `uv pip install`, only the `.pth` (path configuration) file for the package will be installed without the package body. To prevent this, the `--no-editable` option needs to be used. [1]: https://github.com/astral-sh/uv/issues/20238 --- .../workflows/python_uv/packager.py | 12 ++++- .../unit/workflows/python_uv/test_packager.py | 52 ++++++++++++------- 2 files changed, 44 insertions(+), 20 deletions(-) diff --git a/aws_lambda_builders/workflows/python_uv/packager.py b/aws_lambda_builders/workflows/python_uv/packager.py index 291c9c191..2b63f1841 100644 --- a/aws_lambda_builders/workflows/python_uv/packager.py +++ b/aws_lambda_builders/workflows/python_uv/packager.py @@ -332,6 +332,7 @@ def _build_from_lock_file( "--no-emit-project", # Don't include the project itself, only dependencies "--no-hashes", # Skip hashes for cleaner output (optional) "--no-default-groups", # Exclude PEP 735 default groups (e.g. dev/test) from Lambda zips + "--no-editable", # Export editable dependencies as non-editable "--output-file", temp_requirements, # We want to specify the version because `uv export` might default to using a different one @@ -344,6 +345,15 @@ def _build_from_lock_file( if rc != 0: raise LockFileError(reason=f"Failed to export lock file: {stderr}") + # Get the workspace root (or project directory if no workspace is used) + # For packages in the workspace, exported paths are relative to the workspace root, + # regardless of where in the workspace uv export is called + workspace_args = ["workspace", "dir"] + rc, stdout, stderr = self._uv_runner._uv.run_uv_command(workspace_args, cwd=project_dir) + if rc != 0: + raise LockFileError(reason=f"Failed to get workspace root: {stderr}") + workspace_dir = stdout.strip() + # Install with platform targeting self._uv_runner.install_requirements( requirements_path=temp_requirements, @@ -352,7 +362,7 @@ def _build_from_lock_file( config=config, python_version=python_version, platform="linux", - cwd=project_dir, + cwd=workspace_dir, architecture=architecture, ) except LockFileError: diff --git a/tests/unit/workflows/python_uv/test_packager.py b/tests/unit/workflows/python_uv/test_packager.py index 5f9b235e6..276fbaab3 100644 --- a/tests/unit/workflows/python_uv/test_packager.py +++ b/tests/unit/workflows/python_uv/test_packager.py @@ -174,22 +174,30 @@ def test_extract_python_version(self): self.assertIn("Runtime is required", str(context.exception)) def test_build_from_lock_file(self): - # Mock the uv command for export - self.mock_uv_runner._uv.run_uv_command.return_value = (0, b"", b"") - - self.builder._build_from_lock_file( - lock_path="/path/to/uv.lock", - target_dir="/target", - scratch_dir="/scratch", - python_version="3.9", - architecture=X86_64, - config=UvConfig(), - ) + # Mock the uv commands + self.mock_uv_runner._uv.run_uv_command.side_effect = [ + (0, b"", b""), # export + (0, "/workspace\n", b""), # workspace dir + ] + + with patch("os.path.dirname", return_value="/path/to"): + self.builder._build_from_lock_file( + lock_path="/path/to/uv.lock", + target_dir="/target", + scratch_dir="/scratch", + python_version="3.9", + architecture=X86_64, + config=UvConfig(), + ) - # Should call export then install_requirements - self.mock_uv_runner._uv.run_uv_command.assert_called_once() + # Should call export and workspace dir then install_requirements + assert self.mock_uv_runner._uv.run_uv_command.call_count == 2 self.mock_uv_runner.install_requirements.assert_called_once() + # Verify install_requirements is called from workspace root + assert self.mock_uv_runner._uv.run_uv_command.call_args.kwargs["cwd"] == "/path/to" + assert self.mock_uv_runner.install_requirements.call_args.kwargs["cwd"] == "/workspace" + def test_build_from_requirements(self): self.builder._build_from_requirements( requirements_path="/path/to/requirements.txt", @@ -232,15 +240,17 @@ def test_build_dependencies_with_uv_lock_standalone_fails(self): def test_build_dependencies_pyproject_with_uv_lock(self): """Test that pyproject.toml with uv.lock present uses lock-based build.""" - # Mock the uv export command - self.mock_uv_runner._uv.run_uv_command.return_value = (0, b"", b"") + # Mock the uv commands + self.mock_uv_runner._uv.run_uv_command.side_effect = [ + (0, b"", b""), # export + (0, "/workspace\n", b""), # workspace dir + ] with ( patch("os.path.basename", return_value="pyproject.toml"), patch("os.path.dirname", return_value=os.path.join("path", "to")), patch("os.path.exists") as mock_exists, ): - # Mock that uv.lock exists alongside pyproject.toml mock_exists.return_value = True @@ -251,16 +261,20 @@ def test_build_dependencies_pyproject_with_uv_lock(self): architecture=X86_64, ) - # Should use export + install_requirements (for cross-platform support) - self.mock_uv_runner._uv.run_uv_command.assert_called_once() # export + # Should use export + workspace dir + install_requirements (for cross-platform support) + assert self.mock_uv_runner._uv.run_uv_command.call_count == 2 self.mock_uv_runner.install_requirements.assert_called_once() + # Verify install_requirements is called from workspace root + assert self.mock_uv_runner._uv.run_uv_command.call_args.kwargs["cwd"] == "path/to" + assert self.mock_uv_runner.install_requirements.call_args.kwargs["cwd"] == "/workspace" + # Verify it checked for uv.lock in the right location mock_exists.assert_called_with(os.path.join("path", "to", "uv.lock")) # Verify export excludes PEP 735 default dependency-groups (dev/test deps # must not land in Lambda zips). - export_args = self.mock_uv_runner._uv.run_uv_command.call_args[0][0] + export_args = self.mock_uv_runner._uv.run_uv_command.call_args_list[-2][0][0] self.assertIn("--no-default-groups", export_args) def test_build_dependencies_pyproject_without_uv_lock(self):