diff --git a/aws_lambda_builders/workflows/python_uv/packager.py b/aws_lambda_builders/workflows/python_uv/packager.py index 291c9c191..cfc22b800 100644 --- a/aws_lambda_builders/workflows/python_uv/packager.py +++ b/aws_lambda_builders/workflows/python_uv/packager.py @@ -136,9 +136,9 @@ def install_requirements( # Add requirements file args.extend(["-r", requirements_path]) - # Resolve --target to an absolute path: UV runs with cwd set to the project directory, so a - # relative target (e.g. the incremental-build dependencies dir) would otherwise be created - # under the source directory instead of the build root. + # Resolve --target to an absolute path: UV runs from the project or workspace directory, + # so a relative target (e.g. the incremental-build dependencies dir) would otherwise be + # created under the UV's cwd instead of the build root. args.extend(["--target", os.path.abspath(target_dir)]) # Add configuration arguments @@ -332,6 +332,8 @@ 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 + # Install package bodies instead of editable .pth links, which break in Lambda zips. + "--no-editable", "--output-file", temp_requirements, # We want to specify the version because `uv export` might default to using a different one @@ -344,6 +346,19 @@ 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: + workspace_dir = stdout.strip() + else: + # `uv workspace dir` requires uv >= 0.9.9. Fall back to the project directory, + # which is what that command returns for any non-workspace project anyway. + LOG.debug("Could not determine workspace root, assuming no workspace: %s", stderr) + workspace_dir = project_dir + # Install with platform targeting self._uv_runner.install_requirements( requirements_path=temp_requirements, @@ -352,7 +367,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/integration/workflows/python_uv/test_python_uv.py b/tests/integration/workflows/python_uv/test_python_uv.py index 81af2adfc..e1bc20f98 100644 --- a/tests/integration/workflows/python_uv/test_python_uv.py +++ b/tests/integration/workflows/python_uv/test_python_uv.py @@ -297,3 +297,27 @@ def test_workflow_builds_numpy_with_pyproject(self): finally: shutil.rmtree(temp_source_dir) + + @skipIf(which("uv") is None, "uv not available") + def test_workflow_builds_with_dependencies_within_workspace(self): + with tempfile.TemporaryDirectory() as workspace_dir: + shutil.copytree(os.path.join(self.TEST_DATA_FOLDER, "workspace"), workspace_dir, dirs_exist_ok=True) + source_dir = os.path.join(workspace_dir, "app") + builder = LambdaBuilder(language="python", dependency_manager="uv", application_framework=None) + builder.build( + source_dir, + self.artifacts_dir, + self.scratch_dir, + os.path.join(source_dir, "pyproject.toml"), + runtime=f"python{sys.version_info.major}.{sys.version_info.minor}", + experimental_flags=self.experimental_flags, + ) + + self.assertTrue(os.path.isfile(os.path.join(workspace_dir, "uv.lock"))) + self.assertFalse(os.path.exists(os.path.join(source_dir, "uv.lock"))) + for filename in ("__init__.py", "py.typed"): + installed = pathlib.Path(self.artifacts_dir, "workspace_lib", filename) + original = pathlib.Path(workspace_dir, "lib", "src", "workspace_lib", filename) + self.assertEqual(installed.read_bytes(), original.read_bytes()) + self.assertTrue(os.path.isdir(os.path.join(self.artifacts_dir, "workspace_lib-0.1.0.dist-info"))) + self.assertEqual(list(pathlib.Path(self.artifacts_dir).rglob("*.pth")), []) diff --git a/tests/integration/workflows/python_uv/testdata/workspace/app/main.py b/tests/integration/workflows/python_uv/testdata/workspace/app/main.py new file mode 100644 index 000000000..16486e1d4 --- /dev/null +++ b/tests/integration/workflows/python_uv/testdata/workspace/app/main.py @@ -0,0 +1,5 @@ +from workspace_lib import message + + +def handler(event, context): + return message() diff --git a/tests/integration/workflows/python_uv/testdata/workspace/app/pyproject.toml b/tests/integration/workflows/python_uv/testdata/workspace/app/pyproject.toml new file mode 100644 index 000000000..37a04f45c --- /dev/null +++ b/tests/integration/workflows/python_uv/testdata/workspace/app/pyproject.toml @@ -0,0 +1,8 @@ +[project] +name = "workspace-app" +version = "0.1.0" +requires-python = ">=3.9" +dependencies = ["workspace-lib"] + +[tool.uv.sources] +workspace-lib = { workspace = true } diff --git a/tests/integration/workflows/python_uv/testdata/workspace/lib/pyproject.toml b/tests/integration/workflows/python_uv/testdata/workspace/lib/pyproject.toml new file mode 100644 index 000000000..aa0e23e02 --- /dev/null +++ b/tests/integration/workflows/python_uv/testdata/workspace/lib/pyproject.toml @@ -0,0 +1,8 @@ +[project] +name = "workspace-lib" +version = "0.1.0" +requires-python = ">=3.9" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" diff --git a/tests/integration/workflows/python_uv/testdata/workspace/lib/src/workspace_lib/__init__.py b/tests/integration/workflows/python_uv/testdata/workspace/lib/src/workspace_lib/__init__.py new file mode 100644 index 000000000..2d4f6400a --- /dev/null +++ b/tests/integration/workflows/python_uv/testdata/workspace/lib/src/workspace_lib/__init__.py @@ -0,0 +1,2 @@ +def message(): + return "Hello from the workspace dependency" diff --git a/tests/integration/workflows/python_uv/testdata/workspace/lib/src/workspace_lib/py.typed b/tests/integration/workflows/python_uv/testdata/workspace/lib/src/workspace_lib/py.typed new file mode 100644 index 000000000..e69de29bb diff --git a/tests/integration/workflows/python_uv/testdata/workspace/pyproject.toml b/tests/integration/workflows/python_uv/testdata/workspace/pyproject.toml new file mode 100644 index 000000000..d51ceca70 --- /dev/null +++ b/tests/integration/workflows/python_uv/testdata/workspace/pyproject.toml @@ -0,0 +1,2 @@ +[tool.uv.workspace] +members = ["app", "lib"] diff --git a/tests/unit/workflows/python_uv/test_packager.py b/tests/unit/workflows/python_uv/test_packager.py index 5f9b235e6..dbdc76866 100644 --- a/tests/unit/workflows/python_uv/test_packager.py +++ b/tests/unit/workflows/python_uv/test_packager.py @@ -126,8 +126,9 @@ def test_install_requirements_success(self): self.assertIn("/path/to/requirements.txt", args_called) def test_install_requirements_resolves_relative_target_to_absolute(self): - # UV runs with cwd=project_dir, so a relative --target must be resolved to an absolute path - # first, otherwise dependencies land under the source dir instead of the build root. + # UV runs from project_dir or workspace_dir, + # so a relative --target must be resolved to an absolute path first, + # otherwise dependencies land under the source dir instead of the build root. self.mock_subprocess_uv.run_uv_command.return_value = (0, "success", "") self.uv_runner.install_requirements( @@ -174,22 +175,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 +241,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,17 +262,26 @@ 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"] == os.path.join("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 = next( + call.args[0] + for call in self.mock_uv_runner._uv.run_uv_command.call_args_list + if call.args[0][0] == "export" + ) self.assertIn("--no-default-groups", export_args) + self.assertIn("--no-editable", export_args) def test_build_dependencies_pyproject_without_uv_lock(self): """Test that pyproject.toml without uv.lock uses standard pyproject build."""