poetry and uv

poetry.lock and uv.lock — the two Python lockfiles that record a graph. One module reads both, because the piece worth writing exactly once is turning a dependency's name into an entry, and that is where the whole file can go quietly wrong.

$ ./target/release/stranger scan fixtures/poetry-m.poetry.lock

  poetry-m.poetry.lock     233 packages   (75 direct · 158 transitive)

  no findings
  ·  INSTALL SCRIPTS        — no signal in this format
  ·  UNPINNED               — no signal in this format

  risk 0/100    16ms    third-party deps used to compute this: 0
$ ./target/release/stranger scan fixtures/uv-m.uv.lock

  uv-m.uv.lock             249 packages   (91 direct · 158 transitive · 1 workspace)

  ⚠  VERSION DRIFT          1     same package at 2+ versions in one tree

  ·  INSTALL SCRIPTS        — no signal in this format
  ·  UNPINNED               — no signal in this format

  risk 27/100    20ms    third-party deps used to compute this: 0

A clean report on a 233-package tree is a real result and is published as one.

Why these exist when pip.rs already reads Python

requirements.txt records no edges, so every package in it has in-degree 0 and the detection rule's third clause eliminates nothing. That costs a real false positive on tensorflow-gpu.

Both formats here record the resolved graph, which is the entire reason to read them. A reader that produced packages and no edges would leave the rule exactly as weak as it already was — and it is measured rather than asserted: clause 3 removes 45.5% to 80.0% of candidates on poetry and uv, and structurally zero on requirements.txt, at every corpus size.

The source for those numbers is tests/pypi.rs, which prints the whole table under cargo test --test pypi -- --nocapture. It is a different measurement from the ablation, which is npm-only — this page used to cite the ablation for a PyPI number, which is the wrong table.

What each one records

poetry.lockuv.lock
entry[[package]][[package]]
edges[package.dependencies], keys are namesdependencies = [ { name = "…" } ]
optional edges[package.extras]not read[package.optional-dependencies] — read
hashesfiles array of {file, hash}sdist / wheels
dev splityes, per package, in poetry 2.x's groupsno
the root projectabsentsource = { editable = "." }

poetry-m has 112 dependency sub-tables, poetry-s has 20. uv-m has 141 dependencies arrays and 7 optional blocks, and no [[package.dependencies]] array-of-tables anywhere — that shape does not occur.

Why uv's optional block is read and poetry's extras are not

This looks inconsistent and is not.

uv's optional block holds resolved references. davey is in uv-m because somebody asked for discord-py[voice], so that is a real install edge.

poetry's extras block is the package's metadata copied verbatim whether anyone asked or not — 1,049 PEP 508 strings in poetry-m, of which 754 name packages that are not in the lock at all. Feeding the other 295 into edges would put non-install edges in the graph, and a package mentioned by somebody's unrequested docs extra would stop looking like a root when it is one.

That costs the detection rule 295 pieces of real "a maintainer has heard of this name" evidence, and losing evidence makes the rule fire more, not less. The upgrade is a separate mentioned set for clauses that want weaker evidence than an install edge — not a wider definition of edges.

poetry does not record the root project

There is no [[package]] for the thing being locked, and its direct dependencies live in pyproject.toml, which is not this file. uv does record it, and its dependency list is the answer poetry makes you infer — which is why the uv header prints 1 workspace and poetry's prints none.

What neither records

Install-time code. No field in either format says a package runs setup.py at install time, so install_script is false on every entry and install scripts never fires on a Python tree. There is no honest proxy — an sdist with no wheel is suggestive and is not the same claim — so nothing is invented. This is a real blind spot relative to npm.

A dev split, in uv's case. uv-m has no dev-dependencies at all, and group membership in uv attaches to the edge rather than the package, so dev is false on every uv entry.

What each one refuses

Both readers check a marker before they read anything, and the marker is the point. Cargo.lock, poetry.lock and uv.lock are all TOML with a [[package]] array, so the file name is the only thing that says which reader should get it — and a renamed file would otherwise be read by the wrong one and silently produce a tree with no edges in it, because cargo writes its dependencies as strings and these two write inline tables.

So poetry requires metadata.lock-version and uv requires requires-python, each of which appears in every file its own tool writes and in nothing else here:

$ mkdir -p /tmp/refuse-py
$ printf '[[package]]\nname = "a"\nversion = "1"\n' > /tmp/refuse-py/x.poetry.lock
$ ./target/release/stranger scan /tmp/refuse-py/x.poetry.lock
stranger: /tmp/refuse-py/x.poetry.lock: no `metadata.lock-version`; this is not the lockfile its name claims to be

An entry with no name is refused by ordinal, exactly as in cargo and for the same reason — the value tree carries no positions.

An absent package array is deliberately not an error. A project with no dependencies gets a lockfile that is a header and nothing else, and refusing that would be a false alarm about a file that is simply empty. The marker check above is what makes that safe: the file has already proved it is a poetry or uv lockfile before the empty array is accepted as meaning zero packages.

The TOML that makes this possible

Both files, plus Cargo.lock, go through one subset parser. It has no date type, and the only reason that is sufficient is that uv.lock stores timestamps as strings:

upload-time = "2026-03-26T01:21:00.379Z"

If uv wrote them as TOML datetimes the parser would refuse the file, loudly, with a line and column. The only bare integers across all six fixtures are version and revision.

poetry writes quoted keys containing dots — "jaraco.classes" = "*". That is one key whose name contains a dot, not a dotted key; quoting decides, not the dot. Conflating them silently invents a jaraco table.

$ ./target/release/stranger scan -v fixtures/uv-m.uv.lock