Skip to content

Vendored vendor --dry-run previews success on a uv project with an inline [tool.uv] / sources table, but the real run refuses pypi_uv_lock_parse_failed (exit 1) #979

Description

[agent] Found by the scheduled uv bug-hunt routine (ledger #310).

Summary

On a uv project where the user wrote [tool.uv] or its sources as an inline table (all valid TOML that uv accepts and locks), socket-patch vendor --dry-run reports success / verified with exit 0. The real vendor then fails apply_failed with pypi_uv_lock_parse_failed: pyproject.toml [tool.uv.sources] is not a standard table (or [tool.uv] is not a standard table for a transitive package) and exits 1. Spellings that hit it:

  • [tool.uv] + sources = { idna = { index = "pypi" } }
  • [tool] + uv = { sources = { … }, index = [ … ] }
  • [tool] + dotted uv.sources = { … }
  • [tool] + uv = { constraint-dependencies = ["six==1.16.0"] } with six transitive (the refusal then names [tool.uv])

The refusal itself is intentional: it's unit-tested in pypi_uv.rs, and the ledger lists it as a known non-bug. What's wrong is the preview. The refusal is raised only inside wire_uv (ensure_table), which runs after the dry run has already returned. It's not in check_target_guards, the preflight that pypi.rs runs "so a refusal happens before the wheel artifact is built". So the wet run also downloads the prebuilt wheel from the service before refusing. Other uv vendored refusals do preview correctly, for example pypi_uv_source_already_exists for an existing { index = … } source or a user override-dependencies (exit 1 in both the dry and the wet run).

The PEP 723 script-lock lane accepts the same inline # [tool.uv] sources = { … } and wires it: uv lock --script --check passes, the patched module imports, and the revert is byte-identical. So the dry run's "verified" is right for scripts and wrong for projects.

Impact

Repro

Real uv, plus the repo's own vendoring-service fixture (tests/prebuilt_common::Server::project_with_env, serving the wheel built from the installed six with an SRI sha512). Any service that grants pkg:pypi/six@1.16.0 works.

mkdir p && cd p
cat > pyproject.toml <<'EOF'
[project]
name = "app"
version = "0.1.0"
requires-python = ">=3.9"
dependencies = ["six==1.16.0", "idna==3.7"]

[tool.uv]
sources = { idna = { index = "pypi" } }

[[tool.uv.index]]
name = "pypi"
url = "https://pypi.org/simple"
EOF
uv lock && uv sync
# stage .socket/manifest.json + blob for pkg:pypi/six@1.16.0 (six.py patched)
export SOCKET_VENDOR_URL=<fixture uri>
socket-patch vendor --dry-run --json; echo $?   # status success, events: verified six; exit 0
socket-patch vendor --json; echo $?             # failed apply_failed "pypi_uv_lock_parse_failed: pyproject.toml [tool.uv.sources] is not a standard table"; exit 1

Nothing is written by either run: pyproject.toml and uv.lock stay byte-identical, and there's no .socket/vendor/.

Expected vs actual

  • Expected: the dry run previews the same failed code as the real run, with exit-code parity. CLI_CONTRACT documents this for every other vendored refusal: "Refused before any write; a dry run previews the same refusal", "Refused before any download or write, dry runs included (would_refuse)". The refusal should also come before the prebuilt download, as the check_target_guards doc comment says.
  • Actual: dry run success / verified (exit 0), real run partialFailure (exit 1), after downloading the wheel.

Matrix (main 9c43dfc, CLI 4.0.0)

OS uv [tool.uv] sources inline [tool] uv = {…} dotted uv.sources = {…} transitive + uv = {constraint-dependencies} script lock inline (control)
Linux 0.5.31 ❌ ×2 – – – –
Linux 0.8.17 ❌ ×2 – – – –
Linux 0.12.23 ❌ ×2 ❌ ×2 ❌ ❌ ✅ wires, --locked ok, revert byte-identical
macOS / Windows – not probed (pure TOML logic, no OS-specific path)

Not a regression bisect: the refusal has been in wire_uv since the uv vendored backend landed. I found no release where the dry run previews it.

Suspect code

  • crates/socket-patch-core/src/vendor/pypi_uv.rs:313 check_target_guards: the preflight doesn't check that [tool.uv] / [tool.uv.sources] are standard tables.
  • crates/socket-patch-core/src/vendor/pypi_uv.rs:521 and :590: ensure_table(&mut doc, …) inside wire_uv is the only place the refusal is raised (ensure_table at :1429).
  • crates/socket-patch-core/src/vendor/pypi.rs:757 runs the preflight. The dry-run return path at pypi.rs:961 exits before wire_uv.

Related: #944 (the same refusal reached through the hosted takeover), #928 / #891 (other vendored uv dry-run parity gaps).

No activity

Activity on this issue will appear here.

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions