Skip to content

fix(python-uv): take into account dependencies on other workspace members - #887

Open
noritada wants to merge 6 commits into
aws:developfrom
noritada:working
Open

noritada wants to merge 6 commits into
aws:developfrom
noritada:working

Conversation

@noritada

@noritada noritada commented Jul 9, 2026 •

Copy link
Copy Markdown

This PR 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.
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.

Closes #892.

Commands to reproduce the build failure

uv init --bare
uv init --lib lib
sam init --name sam-app --runtime python3.14 --architecture arm64 \
    --dependency-manager pip --package-type Zip \
    --app-template hello-world
cd sam-app
uv init
uv add lib@../lib
# remove requirements.txt and edit the app
# edit template.yaml to configure `BuildMethod: python-uv` and `CodeUri: .`
sam build --beta-features

Fixes #892

@noritada
noritada requested a review from a team as a code owner July 9, 2026 17:03
@github-actions github-actions Bot added pr/external stage/needs-triage Automatically applied to new issues and PRs, indicating they haven't been looked at. labels Jul 9, 2026

@aws-sam-tooling-bot aws-sam-tooling-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Code Review Results

Reviewed: 37cd1d1..e96d649
Files: 1
Comments: 1

Comment thread aws_lambda_builders/workflows/python_uv/packager.py Outdated
@roger-zhangg

Copy link
Copy Markdown
Member

Thanks for the fix and the unusually clear write-up, @noritada — and apologies for the long silence on this one.

I reproduced this end to end and your diagnosis is correct. Details below, including one thing I'd like changed before merge.

Yes, this fixes #892

I built the exact workspace from your repro (bare root + lib + an app member depending on lib@../lib) and drove the real PythonUvDependencyBuilder.build_dependencies() against it:

So #892 is closable when this merges.

I also confirmed that both halves of the change are load-bearing, which wasn't obvious to me from the diff alone. With only the cwd change (packager.py:365) and without --no-editable (packager.py:335), uv pip install succeeds but writes only lib.pth + lib-0.1.0.dist-info into the target — a Lambda zip that would ModuleNotFoundError at runtime. Worth stating that in the comment on line 335, since a future reader might otherwise take --no-editable for a cosmetic tidy-up and drop it.

And your claim about export paths (astral-sh/uv#20238) checks out: exporting from the member dir emits -e ./lib, i.e. relative to the workspace root, not ../lib relative to the member.

Your approach also looks like the only reliable one here. Because uv writes uv.lock at the workspace root, a workspace member never takes the os.path.exists(uv_lock_path) branch at packager.py:274-276; it goes through _build_from_pyproject, and the lock_path assembled at packager.py:398 does not actually exist on disk. That path works today only because _build_from_lock_file uses it purely for os.path.dirname (packager.py:324). So there is no local uv.lock to walk up from, and asking uv directly is the right call.

One change requested: fall back when uv workspace dir is unavailable

uv workspace dir (packager.py:351-355) is recent — added in astral-sh/uv#16678, first released in uv 0.9.9 (2025-11-12), stabilized in 0.10.0. (I tested 0.9.9 specifically: it works there with no --preview flag and no stderr warning, despite the workspace-dir preview gate mentioned in that PR.)

The issue is that rc != 0 is fatal, and every pyproject.toml build funnels through _build_from_lock_file — workspace or not. On uv 0.8.17 against a plain single-project pyproject.toml, with no workspace involved at all:

  • base 37cd1d1: succeeds
  • this PR: LockFileError: Lock file operation failed: Failed to get workspace root: error: unrecognized subcommand 'workspace'

aws-lambda-builders neither pins nor checks a minimum uv version (the only reference is DESIGN.md:298, "Minimum UV version: 0.1.0 (to be determined)"), and SAM CLI doesn't vendor uv — users bring their own, frequently pinned in CI images. As written, this breaks builds that work today for users who have no workspace and no need for the fix.

Since uv workspace dir returns the project directory itself for a non-workspace project (I verified this), degrading to the previous behaviour is exact rather than approximate:

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

Workspace users on uv < 0.9.9 then get today's error instead of a confusing new one, and everyone on 0.9.9+ gets the fix. A version check would also work, but a fallback seems lower-risk than parsing version strings.

Test results

  • pytest tests/unit/workflows/python_uv/ on this PR: 65 passed. Base 37cd1d1 is also 65, so nothing was dropped.
  • The PR merges cleanly into current develop (587257c); on the merge result: 68 passed (develop has added 3 tests in the meantime).
  • Full pytest tests/unit: 831 passed, 6 subtests passed.
  • black --check clean on both changed files.

Minor, non-blocking

  1. The unit tests mock run_uv_command, so they cannot catch the class of bug this PR fixes — the new assertions would pass just as happily against a wrong-but-consistent cwd string. Since tests/integration/workflows/python_uv/testdata/ already exists, a small workspace fixture there is where this behaviour could actually be pinned down. Noting it more as follow-up work for us than as a request to you.
  2. test_packager.py:277 — call_args_list[-2][0][0] will silently point at the wrong call if a third uv invocation is ever added ahead of the install. Matching on the call whose first arg starts with "export" would survive that.
  3. The comment at packager.py:139-141 states "UV runs with cwd set to the project directory", which is no longer strictly true. The os.path.abspath(target_dir) on line 142 remains correct (it resolves against the builder's cwd, not uv's), but the stated rationale is now slightly off and could mislead later.
  4. One thing I checked and did not find a problem with: moving cwd up to the workspace root does not appear to change uv's config discovery in a way that matters. A member-level [tool.uv.pip] index-url was not applied from either directory, so I observed no behavioural difference.

Overall the approach and the analysis behind it are right, and the end-to-end result is verified working. With the fallback above added, this looks good to me.

@aws-sam-tooling-bot aws-sam-tooling-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Code Review Results

Reviewed: 37cd1d1..b813ed8
Files: 9
Comments: 2


Comments on lines outside the diff:

[aws_lambda_builders/workflows/python_uv/packager.py:372] [BUG] uv export still runs with cwd=project_dir while uv pip install now runs with cwd=workspace_dir, so any relative path shared by the two commands no longer resolves to the same location.

temp_requirements is os.path.join(scratch_dir, "lock_requirements.txt"), and scratch_dir is never absolutized on the way in — PythonUvBuildAction forwards whatever the workflow was given. With a relative scratch dir, --output-file is written under project_dir but -r is looked up under the workspace root, so the install fails with a missing requirements file for exactly the workspace layout this PR fixes. install_requirements already guards --target against this (and its comment notes the incremental-build dependencies dir does arrive relative in practice), so the requirements path deserves the same treatment:

args.extend(["-r", os.path.abspath(requirements_path)])

config.cache_dir is derived from scratch_dir too and is passed to uv as --cache-dir from the new cwd, so it has the same exposure.

Comment thread tests/unit/workflows/python_uv/test_packager.py Outdated

@aws-sam-tooling-bot aws-sam-tooling-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Code Review Results

Reviewed: 37cd1d1..85b30bd
Files: 9
Comments: 1

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.

@aws-sam-tooling-bot aws-sam-tooling-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Code Review Results

Reviewed: 37cd1d1..45b8529
Files: 9
Comments: 3


Comments on lines outside the diff:

[tests/unit/workflows/python_uv/test_packager.py:126] [BUG] This existing assertion was not updated for the new abspath behavior and will fail on Windows.

install_requirements now passes os.path.abspath(requirements_path) (packager.py:137), and on Windows os.path.abspath("/path/to/requirements.txt") returns C:\path\to\requirements.txt (drive letter added, separators flipped). The literal "/path/to/requirements.txt" is therefore no longer an element of args_called, so assertIn raises. Unit tests run on windows-latest across the whole Python matrix in .github/workflows/build.yml, so this breaks half of CI.

The neighbouring test_install_requirements_resolves_relative_target_to_absolute already shows the correct pattern — compare against the absolutized value:

self.assertEqual(
   args_called[args_called.index("-r") + 1],
   os.path.abspath("/path/to/requirements.txt"),
)

Comment thread tests/integration/workflows/python_uv/test_python_uv.py Outdated
# 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"]

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[GENERAL] Now that the workspace root is discoverable, _handle_pyproject_build's uv.lock probe is wrong for the exact scenario this PR adds support for. It checks os.path.join(manifest_dir, "uv.lock"), but a uv workspace keeps a single lockfile at the workspace root, not next to each member's pyproject.toml. The new integration test confirms this: it asserts uv.lock exists at workspace_dir and explicitly asserts it does not exist in source_dir.

Two consequences for workspace members:

  • The "use lock-based build for precise dependencies" branch is unreachable — a committed root uv.lock is ignored and _build_from_pyproject re-runs uv lock --python <version> on every build, which can re-resolve and rewrite the user's lockfile. That defeats the reproducibility the lock path exists to provide.
  • uv lock writes into the workspace root, i.e. a directory above the function's source dir. The build mutates files outside the CodeUri, and fails outright if that parent is read-only.

Resolving the workspace root before the lock lookup (rather than only between export and install) would let both the lock discovery and the install use it consistently.

…bers

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]: astral-sh/uv#20238

@aws-sam-tooling-bot aws-sam-tooling-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Code Review Results

Reviewed: 587257c..8fae211
Files: 9
Comments: 2

Comment thread tests/integration/workflows/python_uv/test_python_uv.py Outdated
# 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"]

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[GENERAL] Now that the workspace root is discoverable here, the uv.lock probe in _handle_pyproject_build is wrong for exactly the scenario this PR adds support for. It checks os.path.join(manifest_dir, "uv.lock"), but a uv workspace keeps a single lockfile at the workspace root, not next to each member's pyproject.toml. The new integration test confirms this — it asserts uv.lock exists at workspace_dir and explicitly does not exist at source_dir.

Consequences for a workspace member that already has a committed lockfile at the root:

  • os.path.exists returns False, so the build always falls through to _build_from_pyproject and re-runs uv lock --python &lt;version&gt;, defeating the "use lock-based build for precise dependencies" behavior.
  • That uv lock writes to the workspace root, i.e. it mutates a file outside the function's source directory, and can change pins the user deliberately committed.
  • Re-resolving requires network access, so a build that would have succeeded from a complete lockfile can now fail.

lock_path then points at a file that does not exist (os.path.join(project_dir, "uv.lock") in _build_from_pyproject); it happens to work only because _build_from_lock_file uses it solely for os.path.dirname. Worth resolving the workspace root before probing for the lockfile (the same uv workspace dir lookup added here), and adding an integration case with a pre-existing workspace-root uv.lock — the new test only covers the no-lockfile path.

@aws-sam-tooling-bot aws-sam-tooling-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Code Review Results

Reviewed: 587257c..ec8ae83
Files: 9
Comments: 1

# 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"]

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[GENERAL] The workspace root is now discoverable here, but the uv.lock probe in _handle_pyproject_build (packager.py:267-270) still assumes the lockfile sits next to pyproject.toml — which is never true for the layout this PR adds support for, since uv keeps a single lockfile at the workspace root. The new integration test documents this directly: it asserts uv.lock exists at workspace_dir and asserts it does not exist in source_dir.

Consequences when building a workspace member:

  • os.path.exists(os.path.join(manifest_dir, "uv.lock")) is always False, so the build takes _build_from_pyproject and re-runs uv lock instead of the "lock-based build for precise dependencies" path — even when the user has a committed lockfile at the workspace root.
  • The python_uv workflow runs uv against the real source tree (it passes source_dir/manifest_path straight through; CopySourceAction runs last), so that uv lock writes or refreshes uv.lock in the user's workspace root — a directory outside the function's source dir.
  • lock_path = os.path.join(project_dir, "uv.lock") in _build_from_pyproject (packager.py:381) then names a file that does not exist. It is harmless only because _build_from_lock_file uses it solely to derive project_dir, which makes it an easy trap for the next change.

Since this hunk already resolves the workspace root, consider extracting it into a helper and using it for the lock probe as well, so a member with a root lockfile takes the lock-based path:

def _resolve_workspace_dir(self, project_dir: str) -> str:
    rc, stdout, stderr = self._uv_runner._uv.run_uv_command(["workspace", "dir"], cwd=project_dir)
    if rc == 0:
        return stdout.strip()
    LOG.debug("Could not determine workspace root, assuming no workspace: %s", stderr)
    return project_dir

_handle_pyproject_build can then check manifest_dir first and fall back to os.path.join(self._resolve_workspace_dir(manifest_dir), "uv.lock").

The new integration fixture only covers the lock-less case, so this path stays untested — a fixture with a committed workspace-root uv.lock would cover it.

@aws-sam-tooling-bot aws-sam-tooling-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Code Review Results

Reviewed: 587257c..503d09f
Files: 10
Comments: 2

"""Find the workspace root, falling back for UV versions without this command."""
rc, stdout, stderr = self._uv_runner._uv.run_uv_command(["workspace", "dir"], cwd=project_dir)
if rc == 0:
return stdout.strip()

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] stdout is returned and used directly as a filesystem path without normalizing its type, and the existing functional tests feed it bytes. Three tests in tests/functional/workflows/python_uv/test_packager.py will fail after this change, and make pr runs tests/functional:

  • test_can_build_pyproject_dependencies — fake_uv.set_return_tuple(0, b"Resolved 2 packages", b"")
  • test_can_build_with_lock_file_optimization — fake_uv.set_return_tuple(0, b"Using existing lock file", b"")
  • test_can_handle_large_dependency_trees — set_return_tuple(0, f"Resolved {len(reqs)} packages".encode(), b"")

All three use a pyproject.toml manifest, so they now reach _resolve_workspace_dir. FakeUv.run_uv_command returns its canned tuple for every subcommand, so rc == 0 and stdout.strip() yields a bytes object. The next statement then does:

uv_lock_path = os.path.join(workspace_dir, "uv.lock")  # workspace_dir is bytes

which raises TypeError: Can't mix strings and bytes in path components. _handle_pyproject_build has no try, so the error propagates out of build_dependencies and the tests fail rather than exercising the intended paths.

The fake is the unrealistic part here — OSUtils.run_subprocess uses text=True, so real stdout is always str. The minimal fix is to update those three fixtures to return str (they should arguably not have been bytes to begin with). Separately, consider guarding against a success exit with no usable output, since an empty stdout would silently produce workspace_dir = "" and then cwd="" for uv pip install:

rc, stdout, stderr = self._uv_runner._uv.run_uv_command(["workspace", "dir"], cwd=project_dir)
workspace_dir = stdout.strip() if rc == 0 else ""
if workspace_dir:
    return workspace_dir

return stdout.strip()
# `uv workspace dir` requires uv >= 0.9.9. For a non-workspace project,
# the project directory is also what the command would return.
LOG.debug("Could not determine workspace root, assuming no workspace: %s", stderr)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[ERROR_HANDLING] Any non-zero exit from uv workspace dir is swallowed at DEBUG level and treated as "no workspace". The comment frames this as version compatibility, but the branch cannot distinguish an old uv from a real failure (unreadable/malformed pyproject.toml, permission error, uv unable to spawn — recall OSUtils.run_subprocess also converts exceptions into (1, "", str(e))). Two consequences, both silent:

  1. For a genuine workspace member, the fallback restores exactly the behavior this PR fixes: install_requirements runs with cwd=project_dir and the build dies with the cryptic error: Distribution not found at: file:///.../app/lib from Bug: PythonUvBuilder fails to build app with dependencies on editable installs in the workspace #892. Since sam build does not surface DEBUG logs, the user gets no hint that upgrading uv resolves it.
  2. _handle_pyproject_build then probes uv.lock at the member directory, misses the workspace-root lockfile, and routes to _build_from_pyproject, which runs uv lock and re-resolves dependencies. A transient uv failure therefore silently downgrades a pinned, reproducible build into a fresh resolution that can install different versions than the lockfile pins.

Raising the log level and making it actionable keeps the graceful degradation while leaving a trail:

LOG.warning(
    "Could not determine the UV workspace root (uv workspace dir requires uv >= 0.9.9); "
    "assuming %s is not part of a workspace. If this project is a workspace member, "
    "upgrade uv. stderr: %s",
    project_dir,
    stderr,
)
return project_dir

@aws-sam-tooling-bot aws-sam-tooling-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Code Review Results

Reviewed: 587257c..3ea9c16
Files: 11
Comments: 1

def _resolve_workspace_dir(self, project_dir: str) -> str:
"""Find the workspace root, falling back for UV versions without this command."""
rc, stdout, stderr = self._uv_runner._uv.run_uv_command(["workspace", "dir"], cwd=project_dir)
workspace_dir = stdout.strip() if rc == 0 else ""

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[ERROR_HANDLING] _resolve_workspace_dir treats every non-zero exit from uv workspace dir as "old uv / not a workspace" and continues with project_dir. The branch cannot actually distinguish the cases it claims to, and OSUtils.run_subprocess (utils.py) also converts exceptions into (1, "", str(e)), so a failure to spawn uv at all lands here too:

rc, stdout, stderr = self._uv_runner._uv.run_uv_command(["workspace", "dir"], cwd=project_dir)
workspace_dir = stdout.strip() if rc == 0 else ""
if workspace_dir:
   return workspace_dir
LOG.warning(
   "Could not determine the UV workspace root (uv workspace dir requires uv >= 0.9.9); "
   ...
)
return project_dir

Consequences for a real failure (unreadable/malformed pyproject.toml, permission error, spawn failure) on a modern uv:

  • The warning asserts a cause that is wrong and tells the user to upgrade uv, which will not help.
  • The build proceeds with workspace_dir == project_dir, so _handle_pyproject_build probes project_dir/uv.lock and uv pip install runs from project_dir — reproducing exactly the Distribution not found at: .../sam-app/lib failure this PR fixes, now masked behind a misleading message.

Suggest keying the fallback on evidence of an unsupported subcommand and propagating anything else, rather than assuming the cause. For example, only fall back when stderr indicates the subcommand is unrecognized (or gate on the already-available self._uv_runner.uv_version), and raise UvBuildError otherwise so the real stderr surfaces as the build error instead of a warning. At minimum, drop the "requires uv >= 0.9.9 / upgrade uv" claim from the message when the cause is unknown.

This was raised on an earlier revision; the log level was raised from DEBUG to WARNING and stderr was added, which helps, but the conflation of causes and the silent wrong-root fallback remain.

@aws-sam-tooling-bot aws-sam-tooling-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Code Review Results

Reviewed: 587257c..f8c8c30
Files: 11
Comments: 1

workspace_dir = stdout.strip() if rc == 0 else ""
if workspace_dir:
return workspace_dir
unsupported_command = rc != 0 and "unrecognized subcommand 'workspace'" in stderr

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[ERROR_HANDLING] The graceful-fallback path is gated on an exact substring of UV's stderr, while every other non-zero exit is now a hard build failure:

unsupported_command = rc != 0 and "unrecognized subcommand 'workspace'" in stderr
if not unsupported_command:
   reason = stderr.strip() or f"uv workspace dir returned no workspace path (exit code {rc})"
   raise UvBuildError(reason=f"Could not determine the UV workspace root for {project_dir}: {reason}")

This makes an external tool's error wording load-bearing for build success. If the message differs at all for the older UV releases this branch exists to support — different phrasing, a quoted-argument variant, or a localized/wrapped message — users on those versions get Could not determine the UV workspace root instead of the intended fallback, turning a previously working build into a hard failure. Note also that OSUtils.run_subprocess (utils.py:30-31) funnels any exception into (1, "", str(e)), so spawn failures land in this same non-matching branch.

The condition you actually want to express is already available in the codebase and is even named in the warning text (requires uv >= 0.9.9): SubprocessUv.get_uv_version() / UvRunner.uv_version. Gating the fallback on the parsed version rather than on stderr text makes the "old UV" case deterministic, and leaves the hard failure for genuine errors only:

# fall back when uv predates uv workspace dir, otherwise surface the real error
if not self._supports_workspace_dir():
   LOG.warning(...)
   return project_dir
raise UvBuildError(...)

get_uv_version() already returns None on failure, so an unparseable version can keep whichever default you prefer.

The rest of the change holds up. The path fix is coherent: temp_requirements is absolutized at creation, install_requirements absolutizes both -r and --target, and cwd is split correctly (export from project_dir to pick the member's dependency set, pip install from workspace_dir to resolve the relative local paths UV emits). Moving the uv.lock probe to the workspace root and threading project_dir/workspace_dir through _build_from_lock_file and _build_from_pyproject is consistent across both entry paths, including the old-UV fallback where the two collapse to the same directory. The earlier review rounds' findings — the relative temp_requirements shared across two working directories, the workspace-root lockfile probe, the bytes stdout in the functional fixtures, the os.path.relpath cross-drive ValueError, and the hardcoded forward-slash assertions — are all resolved in this revision.

@aws-sam-tooling-bot aws-sam-tooling-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Code Review Results

Reviewed: 587257c..78e738e
Files: 11
Comments: 1

version = self._uv_runner.uv_version
# Only interpret plain release versions. Unknown or prerelease versions
# must prove support by successfully executing the command.
if version and re.fullmatch(r"[0-9]+\.[0-9]+\.[0-9]+", version):

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[ERROR_HANDLING] The old-uv fallback only triggers when uv --version yields a plain X.Y.Z string. When it does not, any failure of uv workspace dir becomes a hard UvBuildError, which turns previously-working builds into failures for projects that are not workspace members at all.

get_uv_version() (packager.py:37-53) swallows every exception and returns None — on a spawn failure, a non-zero uv --version, or any output format it cannot split. It also returns the raw token, so a dev/prerelease build such as 0.9.8+dev or 0.9.0-alpha.1 fails the re.fullmatch. In all of those cases the code falls through to the probe, and uv replies unrecognized subcommand 'workspace' (rc != 0), so:

workspace_dir = stdout.strip() if rc == 0 else ""
if workspace_dir:
    return workspace_dir
reason = stderr.strip() or f"uv workspace dir returned no workspace path (exit code {rc})"
raise UvBuildError(reason=f"Could not determine the UV workspace root for {project_dir}: {reason}")

tests/unit/.../test_packager.py encodes this: test_resolve_workspace_dir_probes_supported_or_unknown_versions asserts that version None / "" / "0.9.9rc1" plus unrecognized subcommand 'workspace' raises. A single-project build that has nothing to do with workspaces then fails outright, even though project_dir is the correct answer for it — the pre-PR behavior.

Treating that specific stderr as the same signal as the version gate keeps the hard failure for genuine errors (unreadable pyproject.toml, permission denied) while preserving the documented degraded path:

rc, stdout, stderr = self._uv_runner._uv.run_uv_command(["workspace", "dir"], cwd=project_dir)
if rc != 0 and "unrecognized subcommand 'workspace'" in stderr:
    LOG.warning(
        "This UV build does not support `uv workspace dir` (requires uv >= 0.9.9); "
        "falling back to project directory %s. If this project is a workspace member, upgrade uv.",
        project_dir,
    )
    return project_dir

A workspace member on such a uv still fails loudly at uv pip install with the original #892 error, so nothing is silently mis-built.

@aws-sam-tooling-bot aws-sam-tooling-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Code Review Results

Reviewed: 587257c..ec04ca7
Files: 11
Comments: 1

workspace_dir = stdout.strip() if rc == 0 else ""
if workspace_dir:
return workspace_dir
if rc != 0 and "unrecognized subcommand 'workspace'" in stderr:

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[ERROR_HANDLING] Re-raising a previously flagged, still-unresolved concern (the narrowing helps but does not close it).

The graceful fallback now has exactly two triggers: a uv --version string that parses as plain X.Y.Z and is < 0.9.9, or a stderr containing the literal substring unrecognized subcommand 'workspace'. Every other non-zero exit becomes a hard UvBuildError that aborts the build before the uv.lock probe ever runs:

rc, stdout, stderr = self._uv_runner._uv.run_uv_command(["workspace", "dir"], cwd=project_dir)
workspace_dir = stdout.strip() if rc == 0 else ""
if workspace_dir:
    return workspace_dir
if rc != 0 and "unrecognized subcommand 'workspace'" in stderr:
    ...
    return project_dir
reason = stderr.strip() or f"uv workspace dir returned no workspace path (exit code {rc})"
raise UvBuildError(reason=f"Could not determine the UV workspace root for {project_dir}: {reason}")

Two paths reach the raise for a project that is not a workspace member at all and previously built fine, because _handle_pyproject_build never invoked uv for workspace resolution before this change:

  • get_uv_version() (packager.py:37-53) swallows every exception and returns None, and it only returns parts[1] of the uv --version output. When the version is unparseable, or non-plain (0.9.8+dev, 0.9.0-alpha.1), the version gate is skipped by design, so the fallback rests entirely on the stderr wording. OSUtils.run_subprocess (utils.py) also converts spawn exceptions into (1, "", str(e)), so those land here too.
  • rc == 0 with empty or whitespace-only stdout also raises, even though the command reported success.

_resolve_workspace_dir is called for every pyproject.toml build, so this failure mode is not scoped to workspace users. Consider inverting the gate: fall back to project_dir on any failure the code cannot positively attribute to a real workspace-resolution error, and reserve the hard error for rc == 0 with output that is not a usable directory (or verify with os.path.isdir). I recognize the tests encode the current behavior as intentional; if that is the deliberate tradeoff, a note saying so would let this be closed out rather than re-raised.

@noritada
noritada force-pushed the working branch 2 times, most recently from 7a81667 to 5678c10 Compare September 28, 2026 19:43

@aws-sam-tooling-bot aws-sam-tooling-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Code Review Results

Reviewed: 587257c..5678c10
Files: 12
Comments: 1

scratch_dir,
python_version,
architecture,
config,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[GENERAL] The old-uv fallback logs at WARNING on every pyproject.toml build, even when no workspace is involved.

_resolve_workspace_dir is now called unconditionally from _handle_pyproject_build, so any user on uv < 0.9.9 building a plain standalone project (no workspace root anywhere above it) will see this on every sam build:

UV 0.8.17 does not support `uv workspace dir` (requires uv >= 0.9.9); falling back to project directory /src. If this project is a workspace member, upgrade uv.

For the standalone case the fallback is exactly the pre-PR behavior and nothing is degraded, so the message is pure noise plus an upgrade recommendation the user does not need. The second fallback branch (stderr contains unrecognized subcommand 'workspace') has the same property.

Two cheap options: log at DEBUG and only escalate to WARNING when the project actually looks like it could be a member (e.g. a pyproject.toml with [tool.uv.workspace] exists in an ancestor directory), or keep the WARNING but defer it until a workspace-relative install is about to happen. The corresponding assertLogs(..., level="WARNING") expectations in tests/unit/workflows/python_uv/test_packager.py would need to follow.


Everything else I checked looks correct. Specifically, the concerns raised in earlier rounds appear addressed in this revision: temp_requirements and the -r argument are now absolutized so export (cwd=project_dir) and install (cwd=workspace_dir) agree on the path; the uv.lock probe moved to the resolved workspace root; the unit tests account for the second run_uv_command call and use os.path.join for Windows-safe comparisons; the functional fake now returns str rather than bytes; and os.path.relpath in the new integration test is guarded against the cross-drive ValueError. The hard-failure policy for unexplained uv workspace dir errors is explicitly documented as an accepted tradeoff in DESIGN.md, so I am not re-raising it.

@aws-sam-tooling-bot aws-sam-tooling-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Code Review Results

Reviewed: 587257c..b3c776a
Files: 13
Comments: 1

try:
with open(manifest, "rb") as file:
config = tomllib.load(file)
has_uv_workspace_table = "workspace" in config.get("tool", {}).get("uv", {})

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[ERROR_HANDLING] The exception guard in _log_workspace_fallback is narrower than the operations it protects, so a diagnostic-only helper can abort the build.

config = tomllib.load(file)
has_uv_workspace_table = "workspace" in config.get("tool", {}).get("uv", {})

except (OSError, ValueError) covers I/O errors and TOMLDecodeError, but not the type errors this duck-typed access can raise on syntactically valid TOML:

  • tool = "text" at top level → config.get("tool", {}) returns a str, then .get("uv", {}) raises AttributeError.
  • [tool] with uv = 5 → "workspace" in 5 raises TypeError: argument of type 'int' is not iterable.

The unit test added in test_workspace_hint_ignores_comments_and_invalid_or_unreadable_toml covers [tool]\nuv = "text", which survives only incidentally ("workspace" in "text" is a valid substring check), so neither shape above is exercised.

This matters more than a typical malformed-manifest case because the loop walks every ancestor directory up to the filesystem root and parses any pyproject.toml it finds — including files outside the project that the user did not author and that are never otherwise consumed by the build. An unrelated file several levels up can therefore fail a build that would otherwise succeed, and the only thing lost by skipping it is a warning hint.

Suggest either broadening the guard or type-checking the lookup:

tool = config.get("tool")
uv_config = tool.get("uv") if isinstance(tool, dict) else None
has_uv_workspace_table = isinstance(uv_config, dict) and "workspace" in uv_config

Other previously raised concerns appear addressed in this revision: the export/install relative-path split is fixed by absolutizing temp_requirements and requirements_path; the uv.lock probe now uses the resolved workspace root; the functional-test fake returns str instead of bytes; the integration test guards os.path.relpath against cross-drive ValueError; and the old-uv fallback now logs at DEBUG with the WARNING gated on an actual tool.uv.workspace table. The hard-failure policy for unexplained uv workspace dir failures is explicitly documented as intentional in both the code comments and DESIGN.md, so I am not re-raising it.

@aws-sam-tooling-bot aws-sam-tooling-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Code Review Results

Reviewed: 587257c..078d459
Files: 13
Comments: 1


try:
import tomllib
except ModuleNotFoundError: # Python 3.10

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[GENERAL] ```python
try:
import tomllib
except ModuleNotFoundError: # Python 3.10
import tomli as tomllib


This module-level import, plus the new `tomli>=1.1.0; python_version < "3.11"` entry in `requirements/base.txt`, makes `tomli` a hard install dependency of the whole package for every consumer — but the only use of `tomllib` in this PR is `_log_workspace_fallback`, a diagnostics-only helper that emits a DEBUG/WARNING hint on uv < 0.9.9. It never influences the resolved root or the build path (as DESIGN.md states).

Two concrete consequences:

- `setup.py` builds `install_requires` from `read_requirements("base.txt") + read_requirements("python_pip.txt")`, so this lands in base rather than a workflow-scoped file. The existing `requirements/python_pip.txt` already carries a TODO about moving workflow deps out of shared requirements; this adds a new one in the opposite direction.
- The fallback `import tomli` is unguarded, so if it is ever unavailable on 3.10 the `ModuleNotFoundError` propagates out of the `except` block and `packager.py` becomes unimportable — taking down every python-uv build, including plain `requirements.txt` ones that never parse TOML.

Both go away by importing lazily inside the helper and degrading to no hint:

```python
def _log_workspace_fallback(self, project_dir: str) -> None:
    LOG.debug(...)
    try:
        import tomllib
    except ModuleNotFoundError:
        try:
            import tomli as tomllib
        except ModuleNotFoundError:
            return  # hint unavailable; not worth failing the build
    ...

That also lets tomli be dropped from base.txt, or moved to a uv-workflow-specific requirements file if the hint is considered important enough to guarantee.


Other previously raised items check out as addressed: the export/install relative-path mismatch is fixed by absolutizing temp_requirements and requirements_path; the uv.lock probe now uses the resolved workspace root; the _log_workspace_fallback TOML access is isinstance-guarded; the functional-test fakes return str instead of bytes; and the integration test guards os.path.relpath against cross-drive ValueError. The hard-fail-on-uncertain-discovery policy is explicitly documented as accepted in DESIGN.md and in the inline comment, so I did not re-raise it.

@noritada
noritada force-pushed the working branch 3 times, most recently from b43ad6e to d7a4121 Compare September 29, 2026 07:12

@aws-sam-tooling-bot aws-sam-tooling-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Code Review Results

Reviewed: 587257c..d7a4121
Files: 14
Comments: 2

self.mock_uv_runner._uv.run_uv_command.assert_not_called()
self.assertIn(project_dir, logs.output[0])
self.assertIn("uv >= 0.9.9", logs.output[0])
self.assertTrue(all(record.levelname == "DEBUG" for record in logs.records))

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[GENERAL] These two fallback tests (test_resolve_workspace_dir_logs_debug_for_old_uv and test_resolve_workspace_dir_falls_back_for_explicitly_unsupported_command) pass project_dir = os.path.join("path", "to"), a relative path. _log_workspace_fallback then does os.path.abspath(project_dir) and walks the real filesystem all the way to the root, reading every ancestor pyproject.toml. If any ancestor of the test process cwd contains a [tool.uv.workspace] table, the helper emits a WARNING record and these assertions fail:

self.assertIn("uv >= 0.9.9", logs.output[0])
self.assertTrue(all(record.levelname == "DEBUG" for record in logs.records))

This repo's root pyproject.toml has no [tool.uv] section, so CI passes today, but the outcome depends on directories outside the checkout (e.g. a developer's ~/pyproject.toml or a parent workspace containing the clone).

test_workspace_hint_skips_non_table_values_and_continues_to_parent already guards against exactly this, with a comment saying so:

# Stop at the temporary root so unrelated manifests outside the test cannot warn.
real_dirname = os.path.dirname
with patch("os.path.dirname", side_effect=lambda path: root if path == root else real_dirname(path)):

Applying the same containment here (or using a tempfile.TemporaryDirectory as project_dir with the traversal bounded) would make these two tests independent of the machine they run on.

experimental_flags=self.experimental_flags,
)
self.assertEqual(lock_path.read_bytes(), lock_contents)
self.assertEqual(lock_path.stat().st_mtime_ns, lock_mtime)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[GENERAL] test_workflow_reuses_existing_workspace_lock asserts the workspace lockfile is byte-identical and has an unchanged st_mtime_ns:

self.assertEqual(lock_path.read_bytes(), lock_contents)
self.assertEqual(lock_path.stat().st_mtime_ns, lock_mtime)

Patching _build_from_pyproject prevents the builder from running uv lock, but _build_from_lock_file still runs uv export without --frozen/--locked, and export performs its own lock maintenance. The checked-in fixture pins version = 1 / revision = 3; whenever uv bumps the lock revision or changes serialization, export rewrites uv.lock in place and both assertions fail even though the behavior under test (reusing the workspace-root lock instead of regenerating one under app/) is still correct.

The behavior you actually care about is already covered by the _build_from_pyproject patch plus assertFalse(os.path.exists(os.path.join(source_dir, "uv.lock"))). Consider dropping the st_mtime_ns comparison (and ideally the byte comparison) so a uv upgrade doesn't turn this into a CI failure that requires regenerating the fixture.

@noritada

noritada commented Sep 29, 2026 •

Copy link
Copy Markdown
Author

@roger-zhangg
I’ve finished making all the necessary changes.
Since some changes are slightly affected by recent updates to the develop branch, I’m performing a rebase based on develop/HEAD.

The bot review requested several additional fixes. The main changes are as follows:

  • Since the execution locations for uv export and uv pip install differ within the workspace, shared paths have been converted to absolute paths.
  • Improved detection of uv.lock files within the workspace layout.
  • Improved detection of older, workspace-incompatible versions of uv and the fallback mechanism.
  • Added detection of potential workspace environments when encountering older, workspace-incompatible versions of uv.

As a result, the overall changes became quite extensive, so I’ve split them into commits that are neither too small nor too large, making them easier to review.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

pr/external stage/needs-triage Automatically applied to new issues and PRs, indicating they haven't been looked at.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Bug: PythonUvBuilder fails to build app with dependencies on editable installs in the workspace

2 participants