socket-patch supports hosted and vendored Python patches in the pdm.lock
generations PDM has written since the lock gained per-file hashes, plus agent
mode (in-place patching of the project's environment) on every release that
records a supported lock. The tests use real PDM releases bootstrapped with uv,
real PyPI artifacts, and the public Socket patch service. Successful rewriting
alone is not an installation result: the backtest reinstalls from the rewritten
lock and compares the installed bytes with the published patch.
This supplements the hosted and vendored production suites and mirrors the uv and Poetry matrices. See the ecosystem matrix for other package managers.
A shared rewriter (utils/pdm_lock.rs) drives both modes: it validates the
[metadata] lock_version and strategy, rewrites the target [[package]]
unit's source (url for hosted, local path for vendored) and its files
hashes — including the PDM 0.x/1.x legacy [metadata.files] table and separate
extras entries — preserves line endings and non-canonical spacing through
span-based edits, and produces byte-exact fragments for replay. The
pyproject.toml and [metadata] content_hash are never touched.
| Lock generation (writer) | lock_version |
Hosted (scan --mode hosted) |
Vendored (scan --mode vendored) |
|---|---|---|---|
| PDM ≤ 0.11 | none | Refused (…lock_version missing): the lock records no per-file hashes to rewrite coherently. |
Refused for the same reason. |
| PDM 0.12 – 1.4 | 2 |
url + files = [{file, hash}] (or the legacy [metadata.files] entry). PDM 0.x/1.x has an upstream freshness bug — a freshly generated lock can fail its own hash check, so ordinary pdm install may regenerate it and discard the redirect. The CLI warns redirect_pdm_legacy_sync_required; use pdm sync. |
Local-file path + files hash; same freshness advisory (pypi_pdm_legacy_sync_required). |
| PDM 1.8 – 1.15 | 3.1 |
Refused (unsupported PDM lock_version): native dependency lookup loses URL/path candidate identity before install, so a rewrite would not resolve. The original registry lock still installs. |
Refused for the same reason. |
| PDM 2.0 – 2.7 | 4.0 / 4.1 / 4.2 |
Refused (same identity loss). | Refused. |
| PDM 2.8.0 | 4.3 |
Accepted but not installable. 2.8.0 writes the 4.3 grammar yet still loses url/path candidate identity (fixed in 2.8.1), so pdm sync on the rewritten lock crashes with KeyError. A 2.8.0 lock is byte-identical to a 2.8.1 lock and records no PDM version, so the rewriter cannot refuse it by format — upgrade to ≥ 2.8.1. |
Same crash. |
| PDM 2.8.1 – current | 4.3 / 4.4 / 4.4.1 / 4.5.0 / 4.5.1 |
url + single {file, hash}; static_urls locks keep their {url, hash} file shape. |
Local-file path + {file, hash}. |
Any future lock_version |
unknown | Refused until the format is tested (fail-closed). | Refused. |
Both modes retain the package version, extras, groups, markers and the
content_hash; no pyproject.toml edit is required. A repeated scan leaves the
lock unchanged (idempotent). Rollback restores the recorded original
fragments — one per patch, plus the legacy integrity-table entry — so a
pdm.lock the tool rewrote returns byte-for-byte to its pre-scan state.
Refused before any write: a [[package]] locked at several versions (a marker
fork), a user-authored url/path/VCS/editable source, an unsupported
lock_version or strategy, hash-less files, malformed hashes, and a wheel
whose filename does not match the locked package.
| PDM | lock_version |
Hosted | Vendored | Agent | Verifies the lock hash on install | Notes |
|---|---|---|---|---|---|---|
| 0.12 | 2 | supported | supported | supported | yes | pdm sync; pdm install may regenerate the lock (freshness bug). |
| 1.0 – 1.4 | 2 | supported | supported | supported | yes | as 0.12; 1.0 cannot self-install a dev-group project natively. |
| 1.8 – 1.15 | 3.1 | refused | refused | supported | — | native install of the original registry lock is unaffected. |
| 2.0 – 2.7 | 4.0–4.2 | refused | refused | supported | — | same identity-loss boundary. |
| 2.8.0 | 4.3 | crashes | crashes | supported | — | rewrite accepted but pdm sync/pdm install raise KeyError (identity loss, fixed in 2.8.1); agent mode is unaffected. Upgrade to ≥ 2.8.1. |
| 2.8.1 – current | 4.3–4.5.1 | supported | supported | supported | yes | pdm sync + pdm install --check; static_urls, extras, markers, multi-target and PEP 735 dependency-groups all pass. |
Measured details:
- A relock discards a lock-only patch on every release.
pdm lock,pdm lock --refreshandpdm update <pkg>re-resolve the entry back to the registry source.content_hashis unchanged by that, sopdm lock --checkcannot detect the loss: re-runsocket-patch scan --mode …after any of them, or gate CI onsocket-patch vex. A re-scan after a relock re-applies the patch and rebases the ledger's recorded edits onto the relocked text (pristine → current, never an appended chain), sorollbackstill lands on the pristine lock afterwards. This matters most for CRLF locks, which PDM re-renders to a different byte layout on relock. - Hosted mode verifies the lock's file hash on install for every supported
release (tamper the hash and
pdm syncfails closed). Vendored mode's protection is the committed wheel bytes, verified by the same hash. __pypackages__(PEP 582) projects are not covered. PDM 0.x/1.x default to__pypackages__, and 2.x does so underpython.use_venv = false; the installed-set crawler probes virtualenvs (VIRTUAL_ENV,./.venv), so agent and vendored mode need a virtualenv install. Run PDM withpython.use_venvon, or use hosted mode.- A non-default lock filename (
pdm lock -L custom.lock) is invisible to the scan, which only readspdm.lock. A package locked at two versions (a marker fork) is refused (pypi_pdm_lock_forked_package/ a version-mismatch refusal), leaving the lock unchanged. - Precedence when several Python lockfiles coexist:
uv.lockandpoetry.lockdrive hosted PyPI redirects ahead ofpdm.lock, so a leftoverpdm.lockbeside them neither blocks nor is attested; it is rewritten only when it is the project's PyPI install driver.
- Hosted (
scan --mode hosted) rewritespdm.lockin place and needs no install; it works from a lock-only checkout. - Vendored (
scan --mode vendored) commits a rebuilt wheel under.socket/vendor/pypi/<uuid>/and points the lock'spathat it; the package must be resolvable so its wheel can be rebuilt. - Agent mode patches the interpreter the crawler finds.
rollback(orremove <purl>) restores the pristine lock and drops the ledger/manifest state for all three modes.
scripts/backtest-pdm.py bootstraps each pinned PDM release with uv, generates a
project per shape (direct, transitive/override, dev and optional groups, extras,
markers, platform markers, CRLF, static_urls, PEP 735 dependency-groups,
multi-target, Unicode paths), runs each of hosted/vendored/agent, and asserts:
the patch applies exactly once, a re-scan is byte-idempotent, pdm sync installs
the patched urllib3/response.py (git-blob SHA-256 matches the ledger
afterHash), ordinary pdm install keeps the lock stable, a tampered hash is
rejected, a relock-then-rescan keeps rollback invertible, and rollback restores
the lock and pyproject.toml byte for byte. .github/workflows/pdm-compatibility.yml
runs it on Linux, Windows and macOS across every PDM major family. The matrix
needs no Socket API token (the urllib3@1.26.18 patch is a free tier).
Note (v5.0): the "refused vendored scan still writes a
.socket/manifest.jsonrecord" observation in the notes column below describes the 4.0.0 binary the run was captured with. Vendored mode is manifest-free since v5.0 —scan --mode vendorednever writes.socket/manifest.json— so the note disappears on the next regeneration.
Run captured 2026-09-18 on macOS-26.6.2-arm64-arm-64bit-Mach-O with socket-patch socket-patch 4.0.0 (source fixed2, binary sha256 b7ac2064ae2a466ab2d2bd74447ba1ae1f47b94943eab9a4310012fc253cac38), 25 PDM releases, shapes: direct, transitive, dev, optional, extras, marker, marker-excluded, platform-linux, platform-windows, crlf, static-urls, space-unicode, dependency-groups, multi-target, two-versions, custom-lockfile, pep582.
| PDM | Python | lock_version | hosted | vendored | agent | tamper rejected (H/V) | pdm install keeps lock (H/V) |
relock keeps patch (H/V) | notes |
|---|---|---|---|---|---|---|---|---|---|
| 0.8.7 | 3.8 | — | refused (11 shapes) | 10/11 (11 shapes) | 7/8 (8 shapes) | n/a / n/a | n/a / n/a | n/a / n/a | lock_version absent unsupported: refused before any write, native install intact; __pypackages__ layout: crawler does not see it (falls through to the PATH interpreter); agent mode on a __pypackages__ project patched the PATH interpreter's site-packages; refused vendored scan still writes a .socket/manifest.json record; lock-only checkout (nothing installed) is not redirected |
| 0.12.3 | 3.8 | 2 | pass (12 shapes) | 11/12 (12 shapes) | 8/9 (9 shapes) | yes / yes | yes / yes | false / false | __pypackages__ layout: crawler does not see it (falls through to the PATH interpreter); agent mode on a __pypackages__ project patched the PATH interpreter's site-packages; relock dropped urllib3 1.26.18 (transitive: pinned resolution, no overrides on this release); re-scan then has nothing to redirect and rollback of the stale ledger exits 0/1; pdm install re-locked (extras,marker,marker-excluded,platform-linux,platform-windows): PDM's own freshness check flags its freshly generated lock as stale; patched install afterwards=false/true; use pdm sync |
| 1.0.0 | 3.8 | 2 | pass (12 shapes) | 11/12 (12 shapes) | 8/9 (9 shapes) | yes / yes | yes / yes | false / false | __pypackages__ layout: crawler does not see it (falls through to the PATH interpreter); agent mode on a __pypackages__ project patched the PATH interpreter's site-packages; relock dropped urllib3 1.26.18 (transitive: pinned resolution, no overrides on this release); re-scan then has nothing to redirect and rollback of the stale ledger exits 0/1 |
| 1.4.5 | 3.8 | 2 | pass (12 shapes) | 11/12 (12 shapes) | 8/9 (9 shapes) | yes / yes | yes / yes | false / false | __pypackages__ layout: crawler does not see it (falls through to the PATH interpreter); agent mode on a __pypackages__ project patched the PATH interpreter's site-packages; relock dropped urllib3 1.26.18 (transitive: pinned resolution, no overrides on this release); re-scan then has nothing to redirect and rollback of the stale ledger exits 0/1 |
| 1.8.5 | 3.8 | 3.1 | refused (11 shapes) | 10/11 (11 shapes) | 7/8 (8 shapes) | n/a / n/a | n/a / n/a | n/a / n/a | lock_version 3.1 unsupported: refused before any write, native install intact; __pypackages__ layout: crawler does not see it (falls through to the PATH interpreter); agent mode on a __pypackages__ project patched the PATH interpreter's site-packages; refused vendored scan still writes a .socket/manifest.json record; lock-only checkout (nothing installed) is not redirected |
| 1.12.8 | 3.8 | 3.1 | refused (12 shapes) | 11/12 (12 shapes) | 8/9 (9 shapes) | n/a / n/a | n/a / n/a | n/a / n/a | lock_version 3.1 unsupported: refused before any write, native install intact; __pypackages__ layout: crawler does not see it (falls through to the PATH interpreter); agent mode on a __pypackages__ project patched the PATH interpreter's site-packages; refused vendored scan still writes a .socket/manifest.json record; lock-only checkout (nothing installed) is not redirected |
| 1.15.5 | 3.8 | 3.1 | refused (12 shapes) | 12/13 (13 shapes) | 8/9 (9 shapes) | n/a / n/a | n/a / n/a | n/a / n/a | lock_version 3.1 unsupported: refused before any write, native install intact; __pypackages__ layout: crawler does not see it (falls through to the PATH interpreter); refused vendored scan still writes a .socket/manifest.json record; lock-only checkout (nothing installed) is not redirected |
| 2.0.3 | 3.11 | 4.0 | refused (12 shapes) | 12/13 (13 shapes) | 8/9 (9 shapes) | n/a / n/a | n/a / n/a | n/a / n/a | lock_version 4.0 unsupported: refused before any write, native install intact; __pypackages__ layout: crawler does not see it (falls through to the PATH interpreter); refused vendored scan still writes a .socket/manifest.json record; lock-only checkout (nothing installed) is not redirected |
| 2.1.5 | 3.11 | 4.0 | refused (12 shapes) | 12/13 (13 shapes) | 8/9 (9 shapes) | n/a / n/a | n/a / n/a | n/a / n/a | lock_version 4.0 unsupported: refused before any write, native install intact; __pypackages__ layout: crawler does not see it (falls through to the PATH interpreter); refused vendored scan still writes a .socket/manifest.json record; lock-only checkout (nothing installed) is not redirected |
| 2.2.1 | 3.11 | 4.0 | refused (12 shapes) | 12/13 (13 shapes) | 8/9 (9 shapes) | n/a / n/a | n/a / n/a | n/a / n/a | lock_version 4.0 unsupported: refused before any write, native install intact; __pypackages__ layout: crawler does not see it (falls through to the PATH interpreter); refused vendored scan still writes a .socket/manifest.json record; lock-only checkout (nothing installed) is not redirected |
| 2.3.4 | 3.11 | 4.1 | refused (12 shapes) | 12/13 (13 shapes) | 8/9 (9 shapes) | n/a / n/a | n/a / n/a | n/a / n/a | lock_version 4.1 unsupported: refused before any write, native install intact; __pypackages__ layout: crawler does not see it (falls through to the PATH interpreter); refused vendored scan still writes a .socket/manifest.json record; lock-only checkout (nothing installed) is not redirected |
| 2.6.1 | 3.11 | 4.2 | refused (12 shapes) | 12/13 (13 shapes) | 8/9 (9 shapes) | n/a / n/a | n/a / n/a | n/a / n/a | lock_version 4.2 unsupported: refused before any write, native install intact; __pypackages__ layout: crawler does not see it (falls through to the PATH interpreter); refused vendored scan still writes a .socket/manifest.json record; lock-only checkout (nothing installed) is not redirected |
| 2.7.4 | 3.11 | 4.2 | refused (12 shapes) | 12/13 (13 shapes) | 8/9 (9 shapes) | n/a / n/a | n/a / n/a | n/a / n/a | lock_version 4.2 unsupported: refused before any write, native install intact; __pypackages__ layout: crawler does not see it (falls through to the PATH interpreter); refused vendored scan still writes a .socket/manifest.json record; lock-only checkout (nothing installed) is not redirected |
| 2.8.2 | 3.11 | 4.3 | pass (13 shapes) | 12/13 (13 shapes) | 8/9 (9 shapes) | yes / yes | yes / yes | false / false | __pypackages__ layout: crawler does not see it (falls through to the PATH interpreter); refused vendored scan still writes a .socket/manifest.json record |
| 2.9.3 | 3.11 | 4.3 | pass (13 shapes) | 12/13 (13 shapes) | 8/9 (9 shapes) | yes / yes | yes / yes | false / false | __pypackages__ layout: crawler does not see it (falls through to the PATH interpreter); agent mode on a __pypackages__ project patched the PATH interpreter's site-packages; refused vendored scan still writes a .socket/manifest.json record |
| 2.10.4 | 3.11 | 4.4 | pass (13 shapes) | 12/13 (13 shapes) | 8/9 (9 shapes) | yes / yes | yes / yes | false / false | __pypackages__ layout: crawler does not see it (falls through to the PATH interpreter); refused vendored scan still writes a .socket/manifest.json record |
| 2.11.2 | 3.11 | 4.4.1 | pass (14 shapes) | 13/14 (14 shapes) | 9/10 (10 shapes) | yes / yes | yes / yes | false / false | __pypackages__ layout: crawler does not see it (falls through to the PATH interpreter); refused vendored scan still writes a .socket/manifest.json record |
| 2.12.4 | 3.11 | 4.4.1 | pass (14 shapes) | 13/14 (14 shapes) | 9/10 (10 shapes) | yes / yes | yes / yes | false / false | __pypackages__ layout: crawler does not see it (falls through to the PATH interpreter); refused vendored scan still writes a .socket/manifest.json record |
| 2.15.4 | 3.11 | 4.4.1 | pass (14 shapes) | 13/14 (14 shapes) | 9/10 (10 shapes) | yes / yes | yes / yes | false / false | __pypackages__ layout: crawler does not see it (falls through to the PATH interpreter); refused vendored scan still writes a .socket/manifest.json record |
| 2.17.3 | 3.11 | 4.5.0 | pass (16 shapes) | 15/16 (16 shapes) | 10/11 (11 shapes) | yes / yes | yes / yes | false / false | __pypackages__ layout: crawler does not see it (falls through to the PATH interpreter); refused vendored scan still writes a .socket/manifest.json record |
| 2.20.1 | 3.11 | 4.5.0 | pass (17 shapes) | 16/17 (17 shapes) | 11/12 (12 shapes) | yes / yes | yes / yes | false / false | __pypackages__ layout: crawler does not see it (falls through to the PATH interpreter); refused vendored scan still writes a .socket/manifest.json record |
| 2.22.4 | 3.12 | — | n/a | n/a | n/a | n/a / n/a | n/a / n/a | n/a / n/a | lock_version absent |
| 2.25.9 | 3.12 | — | n/a | n/a | n/a | n/a / n/a | n/a / n/a | n/a / n/a | lock_version absent |
| 2.27.0 | 3.13 | 4.5.0 | pass (17 shapes) | 16/17 (17 shapes) | 11/12 (12 shapes) | yes / yes | yes / yes | false / false | __pypackages__ layout: crawler does not see it (falls through to the PATH interpreter); refused vendored scan still writes a .socket/manifest.json record |
| 2.29.2 | 3.13 | 4.5.1 | pass (17 shapes) | 16/17 (17 shapes) | 11/12 (12 shapes) | yes / yes | yes / yes | false / false | __pypackages__ layout: crawler does not see it (falls through to the PATH interpreter); refused vendored scan still writes a .socket/manifest.json record |