Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 19 additions & 4 deletions aws_lambda_builders/workflows/python_uv/packager.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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)
Comment thread
noritada marked this conversation as resolved.
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,
Expand All @@ -352,7 +367,7 @@ def _build_from_lock_file(
config=config,
python_version=python_version,
platform="linux",
cwd=project_dir,
cwd=workspace_dir,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[BUG] Splitting the two UV invocations across different working directories breaks the relative path they share.

temp_requirements is built as os.path.join(scratch_dir, "lock_requirements.txt") and is never absolutized. It is written by uv export --output-file (which still runs with cwd=project_dir) and then read by uv pip install -r (which now runs with cwd=workspace_dir). Before this change both commands shared the same cwd, so a relative scratch_dir resolved consistently. Now, when workspace_dir != project_dir, export writes to project_dir/<scratch_dir>/lock_requirements.txt while install looks for workspace_dir/<scratch_dir>/lock_requirements.txt and fails. config.cache_dir (set to os.path.join(scratch_dir, "uv-cache") in _ensure_cache_dir) has the same problem and would silently create a second cache directory.

scratch_dir is not normalized anywhere in the call chain — builder.py only does os.makedirs(scratch_dir) on it. This codebase already treats these paths as possibly relative: install_requirements deliberately wraps the target in os.path.abspath() (line 142, with a comment about UV's cwd, covered by test_install_requirements_resolves_relative_target_to_absolute), and java_gradle/actions.py:55 applies os.path.abspath to scratch_dir for the same reason.

Anchor the shared path so it is independent of which cwd UV runs in:

temp_requirements = os.path.abspath(os.path.join(scratch_dir, "lock_requirements.txt"))

Absolutizing requirements_path inside install_requirements (mirroring the existing --target handling) would also protect the _build_from_requirements path, which passes a caller-supplied manifest path.

Note: this was raised in the previous review round at packager.py:372 and does not appear to have been addressed or explicitly dismissed, so re-raising.

architecture=architecture,
)
except LockFileError:
Expand Down
24 changes: 24 additions & 0 deletions tests/integration/workflows/python_uv/test_python_uv.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")), [])
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
from workspace_lib import message


def handler(event, context):
return message()
Original file line number Diff line number Diff line change
@@ -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 }
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
[project]
name = "workspace-lib"
version = "0.1.0"
requires-python = ">=3.9"

[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
def message():
return "Hello from the workspace dependency"
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
[tool.uv.workspace]
members = ["app", "lib"]
62 changes: 41 additions & 21 deletions tests/unit/workflows/python_uv/test_packager.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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

Expand All @@ -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."""
Expand Down