Facts about Python packaging in Nix, and where each one comes from¶
docs/NIX-FACTS.md records what this lab proved about Nix. This file records
what it read in the two projects that already solve Python packaging in Nix,
and it is the reference for the work that remains.
Each fact names the file that proves it. A claim with no file is a guess, and it does not belong here. Two checkouts are the source:
~/Code/pyproject.nix, the builders and the pure Nix implementations of the packaging PEPs.~/Code/uv2nix, which readsuv.lockand renders a package set on top of those builders.
Sizes, measured with wc -l over each tree:
tree |
lines |
|---|---|
|
3602 |
|
4179 |
|
1578 |
|
921 |
|
1947 |
|
990 |
|
394 |
The two halves of pyproject.nix¶
lib/ is the packaging PEPs, written in the Nix language. pep440.nix is
402 lines of version comparison, pep508.nix is 826 lines of marker and
requirement parsing, and pypa.nix is 526 lines of tag selection. poetry.nix,
pip.nix, eggs.nix, pep599.nix, pep600.nix, pep621.nix, pep656.nix,
pep723.nix, project.nix, scripts.nix and validators.nix complete it.
This lab needs none of that. ddrn/examples/venv-graph/plan.py imports
packaging.markers, packaging.requirements, packaging.tags and
packaging.utils, and the reference implementation answers each question.
build/ is the builders, and this lab does need the equivalent of that.
The two halves are separable, and the work that remains is all in the second
half.
The hook pipeline of pyproject.nix¶
A package build is stdenv.mkDerivation with pyprojectHook in
nativeBuildInputs. build/hooks/default.nix:191 composes it from four hooks,
and a fifth when the build is cross:
hook |
phase |
what it does |
|---|---|---|
|
configure |
overwrites |
|
build |
PEP 517 |
|
install |
installs each wheel of |
|
— |
writes the setup hook of the output |
|
— |
patches a shebang, when cross |
Three variants replace one hook of that set:
pyprojectWheelHookreplaces the build hook withpyprojectWheelDistHook, for a wheel that needs no build (build/hooks/default.nix:236).pyprojectEditableHookreplaces it withpyprojectBuildEditableHook, and appendspyprojectFixupEditableHook(build/hooks/default.nix:245).pyprojectDistHookproduces adistoutput.
The default installer of pyproject.nix is uv, and not pypa/installer.
build/hooks/pyproject-install-hook.sh runs:
uv pip --offline --no-cache install --no-deps --link-mode=copy \
--python=@pythonInterpreter@ --system --prefix "$out" "$wheel"
pypa/installer is the cross path only. The comment above
pyprojectPypaInstallHook says "Used instead of pyprojectInstallHook for
cross compilation support" (build/hooks/default.nix:286), and
build/hooks/pypa-install-hook/wrapper.py says "Ideally uv should also be used
for cross installs, but for now it's not compatible."
The wrapper exists because the two installers take different options: it turns
UV_COMPILE_BYTECODE into --compile-bytecode 1 for installer.
This lab uses pypa/installer for every node. That is the cross path of
pyproject.nix, and not its default path. The reason is that installer is pure
Python and is one wheel of the same lock file, so it becomes a node of the
graph. uv is a compiled program, and a node that installs with uv would
take it from outside the plan.
Both install from a dist directory. pushd dist in each install hook.
That is the same reason this lab copies a wheel under its own name first, which
docs/NIX-FACTS.md records.
Composing a virtual environment¶
build/hooks/make-venv/make_venv.py is the reference for
ddrn/examples/venv-graph/scripts/make-venv.py, and it is 350 lines against
130. Five differences matter.
merge_inputs recurses into a directory, and this lab does not. A
directory that several members provide is merged entry by entry, and only a
leaf regular file can collide (make_venv.py:120). link_tree of this lab
links a whole top level entry, and raises when a second member provides the
same name. A namespace package therefore does not work in this lab, because
two distributions that both ship google/ or zope/ collide.
Bytecode always collides, and the comparison has to skip it.
is_bytecode at make_venv.py:84: "Bytecode files contain fully resolved file
names, meaning that the byte code will always collide even if the original
sources are identical." pyproject.nix compares the sources instead. This lab
compares file content and would raise.
venvSkip and venvIgnoreCollisions are the escape. Both take an
fnmatch pattern, and mkVirtualEnv turns them into --skip and
--ignore-collisions (build/packages.nix:54). This lab has no equivalent.
pyvenv.cfg names the interpreter that ran venv, which is the build
interpreter. fixup_pyvenv at make_venv.py:250 rewrites sys.prefix and
sys.base_prefix to the target Python. This lab does not, and the file is
correct only because the build interpreter and the target interpreter are the
same derivation. A cross build would write the wrong path.
venv symlinks bin/python, and a symlink to the environment then breaks
it. CPython derives sys.prefix from argv[0], so a second symlink loses
the environment. wrap_python_bin at make_venv.py:266 compiles a three line C
program that sets argv[0] and calls execv, and puts that in place of the
symlink. This lab meets the same trap from the other side: default.nix of the
planner enters the environment through its full path, and says so in a comment.
The C wrapper is the fix, and the full path is the workaround.
The shebang test differs. write_bin at make_venv.py:185 compares the
whole shebang against the path of the build interpreter. This lab compares the
name of the interpreter, because installer writes the shebang from
sys.executable, which CPython reports after it resolves the symlink.
Resolution is a walk, and not a solve¶
resolveNonCyclic and resolveCyclic walk passthru.dependencies.
build/lib/resolvers.nix:26 and :76. Neither compares a version, and neither
reads a specifier. The edges come in already resolved, from uv.lock through
uv2nix, or by hand.
resolveCyclic exists because Python packages have real cycles. It uses
builtins.genericClosure. resolveNonCyclic is the faster memoised form, and
the comment says it "is intended to resolve build-system dependencies".
So a lock file is the answer to resolution, and the Nix side only reads it. This is the fact that decides the shape of the work that remains here.
Overrides in pyproject.nix¶
The package set is a lib.makeScope (build/packages.nix:96), so it
carries overrideScope, and each package carries overrideAttrs. An override
is an overlay of final: prev: { name = prev.name.overrideAttrs ...; }.
nixpkgsPrebuilt adapts a nixpkgs Python package into that set.
build/hacks/default.nix:54. It does three things, and
build/hacks/write-nixpkgs-prebuilt.py performs them:
It drops
nix-support/propagated-build-inputs, so that the resolver of pyproject.nix owns dependencies rather than nixpkgs propagation (write_nix_support).It undoes
wrapPythonPrograms. Measured onpkgs.python3Packages.pygments:bin/holdspygmentize, a bash wrapper that prependsPATHand execs, and.pygmentize-wrapped, the real script.write_binrenames the second over the first, and drops any line matchingimport sys.+sys.argv\[0\](.+)/nix/store. That line is nixpkgs dependency injection:The block below is the line as nixpkgs writes it, so the fence says
text. Apythonfence makesruff formatrewrite the quote, and a rewritten quote is no longer evidence.import sys;import site;import functools;sys.argv[0] = '...';functools.reduce( lambda k, p: site.addsitedir(p, k), ['/nix/store/1j8cch7q…-pygments-2.20.0/lib/python3.14/site-packages'], site._init_pathinfo());Left in place, it puts the nixpkgs
site-packagesonsys.pathand shadows every dependency that the environment resolved.It checks the interpreter. It throws when
pythonVersiondiffers, and warns when the Python derivation differs, because a compiled extension is built against one ABI.
Everything else is a symlink. main of write-nixpkgs-prebuilt.py
symlinks each top level entry of the input into the output.
For this lab, only the second of the three is work. Nothing here reads
nix-support, because make-venv.py reads site-packages and bin and
nothing else. The shebang is already rewritten. The interpreter check is a
comparison the planner can make.
Two more hacks exist: importCargoLock (build/hacks/default.nix:155), which
fetches Rust dependencies and uses import from derivation, and toNixpkgs
(:240), which renders a pyproject.nix set back into buildPythonPackage, and
adds autoPatchelfHook while it does so.
build/util/mkApplication builds an application from an environment by
symlinking only the files of one package, and dropping the interpreter, the
activation scripts and pyvenv.cfg.
How much of the ecosystem pyproject.nix exercises¶
build/checks/build-systems.nix bootstraps 27 build systems, and builds
each one from the source that nixpkgs names:
build calver cffi editables flit-core hatchling hatch-vcs installer libcst
maturin packaging pathspec pip pluggy pycparser pyproject-hooks pyyaml
semantic-version setuptools setuptools-rust setuptools-scm tomli tomli-w
trove-classifiers typing-extensions urllib3 wheel
This lab has run one, flit_core. Nothing in the design blocks the others.
uv.lock, and what it does and does not hold¶
Measured over the 39 lock files in ~/Code/uv2nix/lib/fixtures.
A package entry carries the artefacts, and they are exactly the menu that
this lab already builds. sdist = { url, hash, size } and wheels = [ { url, hash, size } ], with hash in the sha256:<hex> form that fetchurl takes.
The dependency edges are in the lock, and they are already resolved. A
[[package]] entry holds dependencies = [ { name = "packaging" } ], with no
version and no specifier, because uv resolved them.
[package.metadata].requires-dist is the declaration, not the resolution.
That table holds [{ name = "packaging", specifier = ">=24.1" }]. It is what
the project asked for. The dependencies list is what it got.
Six kinds of source appear, and this is how often:
|
count |
this lab |
|---|---|---|
|
59 |
covered, wheel and sdist nodes |
|
36 |
covered, since the PEP 660 node |
|
5 |
covered; a member with edges and no install |
|
1 |
covered; an sdist that carries a subdirectory |
|
1 |
covered; a checkout, fetched during evaluation |
|
1 |
covered; a tree, built as a wheel and not as an editable |
All six are covered now. The two that name a place get a section of their own below, because each one costs something that a registry source does not.
uv.lock records no build system. No build-system, build-backend or
requires key appears in any of the 39 fixtures. uv2nix says so in its own
documentation, doc/src/overriding/overrides-sdist.nix:
uv.lock does not contain build-system metadata. Meaning that for source builds, this needs to be provided by overriding.
The workaround it names has three parts: use
pyproject-nix/build-system-pkgs, add the build system to your own uv project,
or write an overlay by hand. The example adds six packages by hand to build
pyzmq.
This is the gap that the lock file cannot close, because the answer lives
in the pyproject.toml inside the source distribution, which nothing has
downloaded when the Nix evaluation runs.
The whole override culture of Python in Nix comes from that one shape of
problem. doc/src/FAQ.md of uv2nix states it plainly:
uv2nixcan only work with what it has, anduv.lockmetadata is notably absent of important metadata.
Overrides are required because Python tooling is lacking important metadata, complexity which surfaces when using Nix. Uv2nix focuses on getting the translation from
pyproject.toml&uv.lockto Nix right, without trying to taper over deficiencies in metadata.
In poetry2nix much of the requirement of overriding came from it's choice to build sdist's by default. […] Maintaining overrides was the biggest source of maintainer burnout for poetry2nix.
The reference is nix-community/poetry2nix#1865. So the reason uv2nix prefers
a wheel is not that a wheel is better. It is that a wheel carries its
metadata on the outside, and a source distribution carries it on the inside,
and a Nix evaluation cannot open the inside.
A planner can open the inside. That is the one structural difference this lab has, and it is the difference that the burnout above measures.
A lock can hold several resolutions at once. resolution-markers appears
at the top level and again on a package, and two entries can then share a name
and differ in version:
resolution-markers = [
"platform_release == '5.10.65'",
"platform_release == '5.10.104'",
"platform_release != '5.10.104' and platform_release != '5.10.65'",
]
A dependency of a forked resolution pins the whole coordinate:
dependencies = [
{ name = "arpeggio", version = "2.0.1", source = { registry = "..." }, marker = "platform_release == '5.10.65'" },
{ name = "arpeggio", version = "2.0.2", source = { registry = "..." }, marker = "platform_release == '5.10.104'" },
]
Forked resolution is most of what lock1.nix costs. That file is 568
lines, and the marker handling runs from :62 to :234.
uv has an open bug that makes a conflict marker unreadable.
lib/lock1.nix:85 names astral-sh/uv#11513: internal conflict markers are
not stripped, so "This makes it impossible to correctly consume a lock with
conflict markers", and the fallback parse costs the top level marker cache.
A lock from an older uv can be ambiguous. lib/lock1.nix:166 throws "Non
disjoint install time resolution for package ... You are most likely using an
older version of uv to produce uv.lock which contains marker bugs."
sourcePreference is mandatory in uv2nix. lib/workspace.nix:143 throws
unless tool.uv.no-binary or tool.uv.no-build implies one, and
lib/build.nix:344 lets no-binary-package and no-build-package override it
for one package. This lab hardcodes the wheel preference, and has no per package
override.
A lock also carries optional-dependencies, dev-dependencies and
conflicts, which are extras, PEP 735 dependency groups, and declared
conflicts between them. lib/build.nix:277 maps the first two onto
passthru.optional-dependencies and passthru.dependency-groups.
A workspace gives four dependency presets, and each one is an attribute set
of package name to a list of extras and groups (lib/workspace.nix:253):
all, optionals, groups, and default, where default reads
tool.uv.default-groups of the project. mkVirtualEnv takes one of them, or a
set written by hand.
uv.lock in the wild, measured¶
The section above measured the 39 lock files of ~/Code/uv2nix/lib/fixtures,
which a test suite wrote. This section measures 22 lock files of real projects,
under ~/Code/*/uv.lock.
what |
count |
|---|---|
lock files |
22 |
packages |
1911 |
artefacts (wheel entries and sdist entries) |
15223 |
The largest is anthropic-sdk-python: 103 packages and 1838 artefacts. The
ratio holds across the sample, and it is the number that sizes a menu: a lock
of 100 packages carries about 1800 artefacts, because a package publishes one
wheel for each interpreter and each platform.
field |
count |
note |
|---|---|---|
|
1858 |
the common case |
|
49 |
a workspace member |
|
3 |
edges, and nothing to install |
|
1 |
needs a fetcher |
package with |
52 |
extras |
package with |
40 |
PEP 735 groups |
package with |
60 |
a forked resolution |
package with no |
6 |
a dynamic version |
|
2 |
over two lock files |
version is 1 in every one. revision is 3 in 18 of them, 2 in two, 1 in
one, and absent in one. So a reader asserts version == 1, and it accepts any
revision and no revision.
What the whole planner costs, measured and reproducible¶
The table above measures a writeText file of 1838 fetchurl derivations, and
that file is not the planner that this lab builds now. The planner also carries
one planner for each source distribution, at each level of the recursion, and
those did not exist when that measurement ran.
ddrn/examples/venv-graph/bench/measure.sh measures the planner itself. It
needs no network and it builds nothing, and bench/README.md gives the method.
The project is anthropic, boto3, jupyterlab and pandas, which uv
resolved to 111 packages:
$ ddrn/examples/venv-graph/bench/measure.sh --cold
what |
value |
|---|---|
packages in |
111 |
|
244918 bytes |
artefact rows |
715 (605 wheel, 110 sdist) |
the menu, |
253860 bytes |
instantiation into an empty store |
8.97 s |
instantiation, warm |
1.74 s |
paths in the closure of the |
2043 |
of which |
1811 |
closure size |
158730056 bytes |
bytes of |
119169562 |
|
331, and 114646605 bytes of the above |
96% of the derivation text is the same menu, written 331 times. There is
one planner for each of the 110 source distributions at each of the three
levels, and one outer planner, so 331 derivations each hold the whole 253860
byte menu. That is what a menu of every artefact costs, and the cost is a
property of the design and not a defect: a planner names an artefact through
the string context of that text, so the text is what makes each .drv an input
source.
A planner node spends 97% of its time on one import¶
The measurements above are the cost of the evaluation, which writes the .drv
files. This one is the cost of a planner node while it runs.
projects/workspace builds in 4.320 s, and 0.441 s of that is the outer
evaluation. Temporary time.monotonic() prints inside plan.py gave the
breakdown of one planner node:
phase |
seconds |
|---|---|
the imports of the module |
0.877 |
open the restricted store |
0.003 |
parse |
0.000 |
resolve the lock file |
0.006 |
build the plan |
0.005 |
submit the graph |
0.011 |
everything after the imports |
0.025 |
The decision work is 25 ms, and the import is 35 times that. The instrumentation is gone now, and the import half needs none. It reproduces from the environment of the planner alone, as the best of five runs:
$ env=/nix/store/...-ddrn-planner-env
$ PYTHONPATH=$packaging:planner "$env/bin/python" -c pass # 17 ms
$ PYTHONPATH=$packaging:planner "$env/bin/python" -c 'import packaging.tags' # 65 ms
$ PYTHONPATH=$packaging:planner "$env/bin/python" -c 'import nanopynix' # 848 ms
$ PYTHONPATH=$packaging:planner "$env/bin/python" -c 'import pydd.python' # 880 ms
So the interpreter starts in 17 ms, and import nanopynix is 848 ms of the
880. python -X importtime gives 778 ms of self time over 555 modules, by
top-level package:
package |
modules |
self |
|---|---|---|
|
12 |
274.2 ms |
|
53 |
122.5 ms |
|
41 |
44.9 ms |
|
31 |
29.3 ms |
|
15 |
20.8 ms |
|
10 |
20.2 ms |
The planner uses one name of that package, and the name is
init_libstore. Every other name it needs comes from nanopynix_bindings,
and from nanopynix_bindings import expr, store, util costs 46 ms. The 12
generated modules of nanopynix_proto alone are 274 ms, and a planner opens no
RPC session at all.
A submodule does not help, because Python runs the __init__ of the package
first:
import |
seconds |
|---|---|
|
0.785 |
|
0.819 |
|
0.840 |
|
0.848 |
The cost is the public surface of nanopynix/__init__.py, and every consumer
pays it. That file re-exports inproc, rpc, stores, the settings models
and the generated protocol, so one import loads the whole library. The number
above is therefore not a fact about this lab. Issue #122 holds the measurement.
The surface became lazy, and the planner asks for the store alone¶
nanopynix/__init__.py now maps each public name to the module that defines
it, and PEP 562 gives a module a __getattr__, so the first read of a name
imports that one module. nanopynix.libstore holds init_libstore and the
store names, and it imports the compiled bindings and no other module of
nanopynix.
import |
before |
after |
|---|---|---|
|
0.017 s |
0.019 s |
|
0.848 s |
0.030 s |
|
0.848 s |
0.053 s |
|
0.848 s |
0.801 s |
The planner reads init_libstore from nanopynix.libstore, so it pays 53 ms
in place of 848 ms. Its decision work is still 25 ms, and the import is no
longer 35 times that.
Measured again from the environment of the planner, with the same command as above and the best of five runs:
import |
before |
after |
|---|---|---|
|
17 ms |
14 ms |
|
65 ms |
64 ms |
|
848 ms |
23 ms |
|
848 ms |
49 ms |
|
880 ms |
140 ms |
import pydd.python loads 176 modules, against 555 for import nanopynix
alone before. nanopynix_proto, pydantic, grpclib, anyio and
betterproto2 are absent from sys.modules, and the planner holds
nanopynix, nanopynix.libstore and the compiled bindings.
packaging is now the largest import of a planner, at 64 ms against 49 ms
for the store surface. The decision work is 25 ms, so the import is 5.6 times
the work rather than 35 times it. A planner runs one time for each source
distribution of a project, so this is a cost of each node and not of the
build.
A consumer that reads a name from every module pays for every module.
pynix is one, and its start did not change: 2.09 s, of which lsprotocol is
394 ms. Issue #107 covers what pynix itself asks for. The change moves the
cost to the caller that asks for the name, and it removes no cost that a
caller asked for.
tests/nanopynix/test_import_surface.py gates this by what loads and not by
how long it takes, because a ceiling in milliseconds measures the machine.
One planner runs for each source distribution of a project, at each level of
the recursion, so the cost multiplies with the count of source distributions
and not with the count of packages. run-uv.sh builds two projects, and four
planner nodes run in it.
How uv2nix resolves, and what each step is for¶
lib/lock1.nix:50, resolveDependencies. It takes the lock, a PEP 508
environment, and the names to start from. Five steps:
Drop each package that this environment cannot reach. A package carries
resolution-markers, and the package survives when one of those markers holds.lock1.nix:79.Drop each dependency edge that this environment cannot reach. An edge carries a
marker.filterPackage,lock1.nix:231.Walk the edges from the start set.
genericClosure,lock1.nix:111. An edge names a version only when the resolution is ambiguous, so an edge with no version admits every candidate of that name.lock1.nix:127.Reduce each name to one package.
reduceDependencies,lock1.nix:144. A name with more than one candidate is narrowed by the versions that the other reached packages pin for it, and the step repeats until each name has one. It gives up after 100 rounds.Assert.
requires-pythonagainst the interpreter, andsupported-markersandrequired-markersagainst the environment.overlays.nix:126.
filterConflicts (lock1.nix:250) runs before all of this. A conflicts
declaration names a set of extras and groups that may not appear together, and
the caller passes the one it wants. The function removes the others from the
lock, so the resolver sees a lock with no conflict.
Step 4 is the part that a lock of a current uv rarely needs. uv writes
resolution-markers on each package of a fork, so step 1 already leaves one
candidate. Step 4 answers an older lock, where the edges carry the
disambiguation and the packages do not. lock1.nix:166 says so in the message
that it throws.
How uv resolves the same file, and why it needs no step 4¶
Installable::to_resolution, in crates/uv-resolver/src/lock/installable.rs.
It takes a marker environment, the tags, the extras and the groups, and the
roots. Three steps:
Find the entry of each root.
Lock::find_by_name, which fails when more than one entry carries the name.Lock::find_by_markersanswers the same question for a requirement of the manifest, and it usesresolution-markersto choose.Walk the edges from the roots. The queue holds a package and one extra of it, and the marker of each edge decides whether uv follows that edge.
Follow each edge to the entry that it names.
Lock::find_by_id, which is a lookup and not a search.
Step 3 is a lookup because an edge names one entry. uv identifies a package
by a PackageId, which is the name, the version and the source together. An
edge of the lock file carries all three whenever the name is ambiguous, and it
carries none of them when one entry holds the name. TryFrom<LockWire> for Lock
counts the entries of each name first, and its comment gives the reason: the
two fields are "strictly redundant" for a name that one entry carries.
uv refuses a lock file whose edge is short for an ambiguous name. The two
messages are Dependency ahas missingsource field but has more than one matching package, and the same sentence for version.
PackageIdForDependency::unwire raises them. Only a directory may omit the
version, because the version of such a project may be dynamic;
Source::is_source_tree names the three kinds that count, and they are
directory, editable and virtual.
uv applies no fork filter before the walk. A package of a fork that this
environment does not take drops out because no live edge reaches it, and not
because a step removed it. resolution-markers decides a root, and it sets the
preferences of the next resolution. The field of Package says the second part
itself.
pydd.python._uvlock follows these three steps, and it therefore has no step 4.
uv2nix needs one because its resolver reads the version of an edge and not the
source (lock1.nix:127), so a name that two entries carry stays a choice for it.
A marker of a lock file is simplified, and two facts make it readable¶
uv writes each marker in a simplified form, and the type says what that form
needs. SimplifiedMarkerTree
(crates/uv-distribution-types/src/requires_python.rs) carries the rule:
A simplified marker should only exist in contexts where a
requires-pythonsetting can be assumed. In order to get a "normal" marker out of a simplified marker, one must re-contextualize it by adding therequires-pythonconstraint back to the marker.
uv adds the constraint back with into_marker, at the moment it reads the file.
This lab asserts the constraint instead, in Lock.check_environment, and it
reads each marker as written. The two are the same answer for an environment
that satisfies requires-python, and the assertion is what makes them the same.
So the order in resolve is load bearing: assert first, evaluate second.
A second simplification applies to an edge, and the walk answers it.
simplify_dependency_marker removes from an edge "the PEP 508 conditions that
must already hold to reach its parent node". An edge marker read alone is
therefore weaker than the condition it stands for. A walk evaluates the marker
of an edge only after it reaches the parent of that edge, so the removed
conditions already hold, and the two agree again. A reader that filtered every
edge of the file up front would still agree, and a reader that answered "does
this edge hold" for one edge in isolation would not.
The source is a part of the identity, and dropping it takes the wrong file¶
Two entries of one lock file may carry the same name and the same version.
lock_multiple_sources_index_disjoint_markers, a test of uv
(crates/uv/tests/lock/lock.rs), writes jinja2 3.1.3 twice: once from the
cu118 index of PyTorch and once from cu124. The two entries differ in the
source and in nothing else, and the two edges of the root differ in the source
and the marker:
dependencies = [
{ name = "jinja2", version = "3.1.3", source = { registry = ".../cu118" }, marker = "sys_platform == 'win32'" },
{ name = "jinja2", version = "3.1.3", source = { registry = ".../cu124" }, marker = "sys_platform != 'win32'" },
]
A reader that identifies a package by the name and the version finds the two
entries equal, and it installs whichever one it met first. Both wheels are
named Jinja2-3.1.3-py3-none-any.whl, so nothing downstream says that the
answer is wrong. 13 lock files of the tests of uv hold such a pair. The common
one is torch from the CPU index beside torch from the CUDA index, which is
the usual reason to hold two indexes at all.
tests/fixtures/locks/two-indexes.lock is that file, copied from the test of
uv that produced it. TestThePackageIdentity resolves it for two environments,
and asserts the index of each answer.
What a lock file pins, and what it leaves open¶
uv.lock pins the hash of every artefact, and a reader trusts every one of
them. It also pins the version of every package, and the edges between them,
because uv resolved them. Nothing in this lab solves a version or queries an
index. [package.metadata].requires-dist is the question that the project
asked, and the reader takes dependencies, which is the answer that uv gave.
The section below is the one place that reads the question, and it resolves
nothing there either: it only asks whether the project still asks the same
question.
What a lock file does not pin is which of those apply to one machine,
because the file covers many machines at once. Measured over
anthropic-sdk-python/uv.lock, which has 103 packages:
what is open |
the measurement |
|---|---|
which wheel of a package |
|
which packages at all |
96 dependency edges carry a PEP 508 marker. |
which fork |
20 packages carry |
which extras and groups |
the caller chooses, and a |
how to build a source distribution |
98 of the 103 packages ship one, and the lock records no build system for any of them. |
So a reader takes every fact of the lock file, and it answers the five questions above from the target. That is selection, and not resolution.
A lock file records the declaration, and a project can grow past it¶
A user who edits pyproject.toml and does not run uv lock has a lock file
that resolves the old declaration. uv refuses such a file. Lock::satisfies
(crates/uv-resolver/src/lock/mod.rs) compares the two and gives one of 21
SatisfiesResult values, and MismatchedPackageRequirements is the one for
this case. A reader that skipped the comparison would build the old
environment and say nothing, which is the worst of the three answers.
pydd.python._freshness makes the part of that comparison that is exact.
uv.nix puts the pyproject.toml of each member into the store,
default.nix passes each path through as uvDeclarations, and
UvProject.selection compares before it resolves.
It compares the name, the extras and the specifier, and nothing else.
normalize_requirement of the same file says what uv rewrites before it
compares: it sorts the extras and the groups, it rebuilds a git or a path
source from its fields, and it calls requires_python.simplify_markers on the
marker. It does not touch the specifier. So those three fields are exactly
comparable, and the marker and the source are not. A marker-only edit
therefore passes this check, and uv lock --check is the command for the whole
answer.
Four rules decide what each side holds, and none of them is the table of the same name. Each rule came from the source of uv, and not from the output of a command:
rule |
where uv states it |
|---|---|
An extra is a row of |
|
|
|
|
|
A dynamic |
|
A fifth rule costs nothing to follow: uv drops a group that states no
requirement from both sides, with
self.includes_empty_groups() || !requirements.is_empty(). An empty group
compares equal to an absent one, so this reader agrees without a special case.
A lock file of an older uv writes no [package.metadata] at all. uv has the
same case, and it calls it allow_missing_package_metadata. A reader cannot
tell such a file from a member that declares nothing, so the check compares
neither.
A marker-only edit passes this check, because uv simplifies every marker
before it compares. That is the one gap that stays, and uv lock --check is
the command for it.
ddrn/examples/venv-graph/tests/test_freshness.py holds one test for each rule
above, against the lock files that uv wrote in tests/fixtures/locks. The seam
was proved end to end by adding typing-extensions to
projects/pure-app/pyproject.toml and building uv.nix for that project:
pydd.python._uvlock.LockError: uv.lock is stale: 'pure-app' declares different
dependencies than the lock file resolved. pyproject.toml adds
['typing-extensions']. Run `uv lock` and build again.
A workspace can grow a member, and no declaration reaches that one¶
The comparison above reads one declaration for each package of the lock
file, so a member directory that uv.lock never saw reaches no comparison at
all. uv finds that one, and it calls the difference
SatisfiesResult::MismatchedMembers. The two sides of it are
[tool.uv.workspace] members of the file system, and [manifest] members of
the lock file.
Only the evaluator reads a directory that is not in the store, so the walk is
in Nix and every rule is in Python.
ddrn/examples/venv-graph/workspace.nix lists the pyproject.toml of each
directory under the workspace and decides nothing: it reads no glob, it opens
no file, and it holds no rule of uv. pydd.python._workspace takes that listing
and answers which directories are members, with fnmatch of the standard
library for each glob component. pydd.python._freshness.check_members compares
the answer with [manifest] members.
That split is the argument of the lab applied once more, and it is the reason
**, [a-z] and [!x] all work: each one is a case of fnmatch, and not a
new branch of the Nix language.
uv writes an empty [manifest] members for a workspace of one member that is
a project. LockTarget::members
(crates/uv/src/commands/project/lock_target.rs) clears the list in that one
case, and the comment there gives the reason: such a workspace mismatches on
the member itself. So every single project has an empty list, and a reader that
did not clear the same list would call every single project stale.
Five rules decide what counts as a member, and each one comes from
collect_members_only of crates/uv-workspace/src/workspace.rs:
rule |
what this reader does |
|---|---|
The root is a member when it declares |
adds the root name; a root with no |
An |
expands |
|
skips it |
A directory with no |
it is not in the listing, so it is skipped |
A duplicate directory is read once. |
takes the unique list |
Two cases turn the comparison off, and each one gives None. A false alarm
stops a build that is correct, which is worse than the gap that the comparison
closes:
the case |
why this reader cannot answer it |
|---|---|
a glob that holds |
the |
a |
uv raises |
What each glob of [tool.uv.workspace] members means¶
Pattern::new of glob-0.3.4/src/lib.rs gives the whole token set, and uv
calls that crate with MatchOptions::new():
the form |
what it is |
|---|---|
|
|
|
|
|
|
|
|
|
|
|
not alternation. No token holds it, so both braces are literal characters |
** forms a whole component or the crate refuses the pattern: a/**b,
a/bc**, a/***** and a**b each give
recursive wildcards must form a single path component, and the tests of the
crate name all four. It matches zero or more levels, it matches directories
only, and Paths::next returns the base directory as well when the pattern
ends in it, so packages/** names packages.
packages/** is a pattern that uv refuses for most trees, and that is a
measured fact and not an opinion. ** names every directory at every depth,
so the src of a member becomes a member with no pyproject.toml. Over
projects/workspace with that one edit:
$ uv lock --check --offline --python <the interpreter>
error: Workspace member `.../packages/ws-cli/src` is missing a `pyproject.toml`
(matches: `packages/**`)
This reader accepts that workspace, because such a directory is not in the
listing at all. It is a lock file that uv cannot write, so a build of it fails
for its own reason. packages/* is the form that a workspace writes, and
the reader answers it exactly.
Two boundaries of the walk, and what each one costs¶
workspace.nix does not descend into .git, .jj, node_modules,
__pycache__, .venv and eight more names, and it follows no symbolic link.
uv reaches both, so a member behind one is not found here.
The reason is the cost of the walk, measured: this repository holds 1902
directories, and a complete recursive builtins.readDir of all of them adds
0.3 s to an evaluation that costs 5.0 s to import lib at all.
$ nix eval --impure --json --expr 'let walk = dir: ...; in walk /path'
The seam was proved end to end, and uv agreed on each tree. A copy of
projects/workspace took a fourth member directory and a fourth entry in
[tool.uv.workspace] members, with no uv lock after it:
pydd.python._uvlock.LockError: uv.lock is stale: it resolved a different set of
workspace members. the workspace adds ['ws-extra']. Run `uv lock` and build
again.
$ uv lock --check --offline --python <the interpreter>
error: The lockfile at `uv.lock` needs to be updated, but `--check` was provided.
The same copy with members = ["packages/*"] in place of the two literal paths
built the same derivation, workspace-root.drv, and uv lock --check over it
resolved 14 packages and said nothing. The unchanged fixture did the same.
uv itself is the backstop, and a sandbox can run it¶
The two comparisons above are the ones that a build reaches, and
Lock::satisfies holds 21. The uvCheck argument of uv.nix runs
uv lock --check --offline over the whole project, in a runCommand that the
planner waits for, so the other nineteen stop the build as well.
A refusal with no network is still a refusal. uv reaches the network only
when Lock::satisfies gave it a reason to resolve again, so a lock file that
satisfies the project downloads nothing. Measured over projects/workspace
with an empty cache directory:
the tree |
what uv did |
how long |
|---|---|---|
unchanged |
|
27 ms |
one member added to |
|
— |
The message differs with the cache, and the verdict does not. With a warm
cache uv resolves again and says The lockfile at uv.lock needs to be updated.
With the empty cache of a sandbox it cannot resolve at all, so it reports the
package it could not read. uv.nix prints its own paragraph after the failure,
because the uv message names the network and the cause is the lock file.
--check is the one mode that a derivation can read¶
The verdict of a derivation is the exit code of its builder, so a mode that
exits 0 over a stale project is no gate. LockMode
(crates/uv/src/commands/project/lock.rs:289) holds four, and three of them
do:
the mode |
what it does |
over a project that grew a member |
|---|---|---|
|
resolves, and writes |
writes a new file, exits 0 |
|
resolves, and writes nothing |
prints the difference, exits 0 |
|
reads the lock file, and resolves nothing |
exits 2 here, and 0 for a marker |
|
asserts the file would not change |
exits 1 |
The --dry-run and --check rows are measured, over a copy of
projects/workspace with a fourth member and with the network up:
$ uv lock --dry-run # exit 0, uv.lock untouched
$ uv lock --check # exit 1, uv.lock untouched
uv lock is the worst of the three in a sandbox: it rewrites the copy of the
lock file, so the build plans from the file that the graph came from and never
learns of the difference. The doc comment of --check
(crates/uv-cli/src/lib.rs:3934) states the behaviour that a gate needs:
Asserts that the
uv.lockwould remain unchanged after a resolution. If the lockfile is missing or needs to be updated, uv will exit with an error. Equivalent to--locked.
--frozen is a diagnostic, and uv says so itself. It compares the lock
file with the project and resolves nothing, so it names a structural difference
exactly, and it needs no index:
error: The lockfile at `uv.lock` needs to be updated, but `--frozen` was
provided: Missing workspace member `ws-extra`.
Over the marker edit it exits 0, with this warning:
warning: The lockfile at `uv.lock` was only checked for validity, not whether
it is up-to-date, because `--frozen` was provided; use `--check` instead
uv.nix runs --frozen after --check refused, and never before. The reason
is that warning: a successful check would otherwise print it on every build.
Two measurements say what the argument costs:
The check changes no output.
projects/pure-appsubmitted the same root derivations4k36ain6j2h1iz11q1615b4aykf2ibm-pure-app.drvwith the argument on and with it off, and built/nix/store/2ppg8xs82055lxw8vg22a1zbbvjnmfjc-pure-appeither way.It raises no false alarm. All seven projects of
ddrn/examples/venv-graph/projectspass it, and each of the seven holds a source kind that the ones before it do not.
The price is the input. The rest of uv.nix reads pyproject.toml and
uv.lock, so an edit of any other file changes no derivation. The check takes
the whole tree, so every edit makes a new store path and a new check. The
argument is off by default for that reason.
It closes the marker gap, which is measured too. A copy of
projects/workspace took ; sys_platform == 'darwin' on the rich dependency
of ws-cli, with no uv lock after it. The planner accepted that tree and
built an environment of 14 nodes that still holds rich on Linux.
uv lock --check refused the same tree.
A marker of an edge is not always a PEP 508 marker¶
uv encodes a conflict into the extra of a marker, and it says so. The doc
comment of UniversalMarker (crates/uv-resolver/src/universal_marker.rs)
gives the design and the reason for it:
we technically preserve PEP 508 compatibility but abuse the
extraattribute to encode conflicts
encode_package_extra of the same file writes the value, and it takes one of
three shapes:
the value |
what it means |
|---|---|
|
the extra |
|
the group |
|
the production dependencies of |
The number is the length of the package name in bytes. uv puts it there so that
a reader can find where the package name ends, because an extra name may hold a
hyphen as well. uv evaluates such a marker against the extras and the groups
that the caller enabled, and Installable::to_resolution passes all three sets
to UniversalMarker::evaluate.
A reader that gives extra an empty value answers a different question. An
edge of aiohttp in anthropic-sdk-python/uv.lock reads:
python_full_version < '3.11' or (extra == 'extra-9-anthropic-mcp' and extra
== 'group-9-anthropic-pydantic-v1') or (extra == 'group-9-anthropic-pydantic-v1'
and extra == 'group-9-anthropic-pydantic-v2')
An extra cannot hold two values at once, so each of those two clauses is
false for any environment. They are conflict markers that uv did not simplify
away, which is astral-sh/uv#11513 and which lib/lock1.nix:85 of uv2nix
names. An empty extra gives the right answer for this one marker, because the
marker then reduces to the version test that it already carried.
It gives the wrong answer for a satisfiable one. uv writes
marker = "extra == 'project-7-example'" on an edge that a workspace member
reaches, and 213 edges of the lock files in the tests of uv carry such a marker.
An empty extra makes that edge false, and the package leaves the environment
with no message. One extra != '...' fails the other way, and keeps a package
that the selection did not ask for.
pydd.python._uvlock therefore refuses an edge whose marker holds this encoding,
and the message decodes each value. TestAConflictMarkerIsNotPep508 covers the
three shapes. The alternative is to implement UniversalMarker::evaluate, and
that needs the activated extras, groups and projects of the whole walk. uv
itself records two soundness gaps in how it accumulates those sets, in a comment
in to_resolution_from_packages.
The marker environment, and the two keys that no build can know¶
pep508.mkEnviron of pyproject.nix (lib/pep508.nix:683) builds the PEP 508
environment from an interpreter derivation. It sets platform_release to ""
and platform_version to an empty version, and the comment gives the reason:
platform_release maps to platform.release() which returns the running kernel version on Linux. Because this field is not reproducible it's left empty.
A build cannot know the kernel of the machine that will run the environment. So a marker that reads either key evaluates against an empty value, and uv2nix lets the caller override the environment for that case.
A key that a planner does not pass takes the value of the build machine¶
Marker.evaluate(environment) of packaging does not use environment alone.
It merges environment over default_environment(), and
default_environment() reads the interpreter that is running. So a key that
a planner does not pass silently describes the machine that plans, and not the
target.
Measured with packaging 26.2, on a host whose kernel is 7.1.4:
host platform_release = 7.1.4
marker 'platform_release != ""' with {} -> True
same with an explicit '' -> False
marker 'python_version >= "3.14"' with {} -> True
same with an explicit '3.11' -> False
A planner plans for a target that is not the machine it runs on, so it must pass every key of PEP 508:
implementation_name implementation_version os_name
platform_machine platform_python_implementation
platform_release platform_system platform_version
python_full_version python_version sys_platform
The present lab passes six of the eleven, so five of them describe the
builder. packaging raises no error for this, because it has a value for
each one.
What a virtual environment needs from the edges, and what it does not¶
uv2nix builds a package set: one derivation for each package, and each one
carries its dependencies, because the builder of pyproject.nix composes a
PYTHONPATH for each build.
This lab builds one flat environment. A wheel installs on its own, and the order does not matter, so the edges answer one question only: which packages are members. The closure of the roots is the member list, and nothing else reads an edge.
The one exception is a source distribution, and its build dependencies do not come from the lock at all. They come from the archive, which is the gap that this lab closes.
How uv2nix layers onto pyproject.nix¶
The base is a scope, and the layer is an overlay over that scope.
build/packages.nix:96 of pyproject.nix builds the package set with
lib.makeScope, so the set carries overrideScope. lib/overlays.nix:71 of
uv2nix returns final: prev: mapAttrs (name: package: callPackage ...), which
is one such overlay.
The contract between the two halves is small. A package of the layer is a
stdenv.mkDerivation that carries pyprojectHook in nativeBuildInputs, and
that sets three passthru attributes: dependencies,
optional-dependencies and dependency-groups (uv2nix/lib/build.nix:272).
Everything else, the resolvers and mkVirtualEnv and the hooks, belongs to the
base and never reads the lock file.
The layer decides membership, and the base decides how one package builds.
lock1.resolveDependencies walks the lock, and build.remote or build.local
turns each chosen package into a derivation.
The canonical stack has four layers, and the third one exists only to patch
the second. From doc/src/usage/getting-started.md of uv2nix:
pythonBase = pkgs.callPackage pyproject-nix.build.packages { inherit python; };
overlay = workspace.mkPyprojectOverlay { sourcePreference = "wheel"; };
pythonSet = pythonBase.overrideScope (lib.composeManyExtensions [
pyproject-build-systems.overlays.wheel
overlay
]);
venv = pythonSet.mkVirtualEnv "hello-world-env" workspace.deps.default;
pythonBase holds the hooks and the resolvers and no package at all.
pyproject-build-systems is a separate repository, and it is there because
uv does not lock a build system; uv2nix names the upstream issue,
astral-sh/uv#5190, and says "uv2nix doesn't try to hide this deficiency".
An editable needs its own overlay and its own package set, and the root it takes is a reference to a variable:
editableOverlay = workspace.mkEditablePyprojectOverlay { root = "$REPO_ROOT"; };
editablePythonSet = pythonSet.overrideScope editableOverlay;
with export REPO_ROOT=$(git rev-parse --show-toplevel) in the shell hook.
This lab arrived at the same interface, root = "$PYDD_EDITABLE_ROOT", and
for the same reason: a literal root would put the tree into what the
environment builds.
pyproject.nix does not merely prefer that form. It refuses the other one.
build/lib/renderers.nix, in mkDerivationEditable:
assert isString root;
assert assertMsg (!hasPrefix storeDir root) ''
Editable root was passed as a Nix store path string.
…
Pass editable root either as a string pointing to an absolute path non-store
path, or use environment variables for relative paths.
'';
The same message names the trap that catches everyone: a flake copies its
source into the store, so an inferred projectRoot is a store path, and
--impure is the workaround it offers. This lab bakes $PYDD_EDITABLE_ROOT
and never a store path, so it obeys the same rule. check.nix then points that
variable at a store path at run time, which is a different thing and is
what makes the two check derivations read one environment.
uv2nix picks an interpreter from requires-python.
pyproject-nix.lib.util.filterPythonInterpreters filters
pkgs.pythonInterpreters by the range that the workspace declares. This lab
takes PYTHON_VERSION from default.nix and asserts nothing.
uv encodes a conflict as a synthetic marker. lib/overlays.nix:26 names
the encoding, from crates/uv-resolver/src/universal_marker.rs of uv:
extra-{package_len}-{package}-{extra} and
group-{package_len}-{package}-{group}, where the length disambiguates a name
that holds a hyphen. uv2nix has to synthesise those extras into the marker
environment for the resolution markers of a conflicted lock to evaluate.
Poetry has no layer. pyproject.nix/lib/poetry.nix is 323 lines, and it
translates [tool.poetry] metadata into the PEP 621 shape
(translatePoetryProject) and parses the caret and tilde version syntax of
poetry into PEP 440 (parseVersionCond). It never reads poetry.lock, no
fixture of that name exists in the tree, and nothing under build/ mentions
poetry. So the pyproject.nix ecosystem stops at poetry metadata, and
poetry2nix is the separate project that consumes the lock.
poetry.lock, and what it holds¶
Two checkouts on this machine give the two formats. ~/Code/pynixutil is lock
version 1.1, with 15 packages. ~/Code/alpaca-py is lock version 2.1, written
by Poetry 2.2.1, with 84 packages.
Both hold the dependency edges, and a uv.lock reader and a poetry.lock
reader therefore walk the same way. Each [[package]] carries name,
version, python-versions, a [package.dependencies] table of PEP 508
constraints with optional markers, and a [package.extras] table:
[package.dependencies]
click = ">=8.0.0"
tomli = {version = ">=1.1.0", markers = "python_full_version < \"3.11.0a7\""}
The two versions differ in two places, and a reader must handle both.
Version 1.1 puts the artefacts in one
[metadata.files]table at the end of the file, keyed by package name. Version 2.1 puts afiles = [ … ]list inside each[[package]]. The measured counts are 1[metadata.files]section against 84 inlinefiles = [lists.Version 1.1 marks a package with
category = "dev". Version 2.1 replaces that withgroups = ["main"], which holds a list.
Neither version records a URL. Every artefact is a {file, hash} pair:
{file = "pytest-7.1.3-py3-none-any.whl", hash = "sha256:1377bda3466d…"},
A reader must therefore build the URL from the file name and an index, which
is what lock.json of this lab already holds directly. This is the one place
where poetry.lock gives less than uv.lock, which records the URL.
Neither version records a build system, exactly as uv.lock does not. So
the source distribution problem is identical under both, and step 4 below
serves both readers.
[metadata] carries lock-version, python-versions and content-hash.
The content-hash is the hash of the relevant part of pyproject.toml, and it
is how a reader tells that a lock file is stale.
The same layering suits this lab, and the interface already exists.
plan.py builds one document and hands it to graph.nix:
{ "system": …, "pythonVersion": …, "tools": …, "scripts": …,
"nodes": [ … ], "installer": …, "editables": …, "venv": { … } }
graph.nix is the base: it says how a node of each kind is built, and it reads
no lock file. A lock reader is anything that produces nodes. So a uv.lock
reader and a poetry.lock reader are two readers over one base, and the base
does not change when a second one arrives.
Can the builders of pyproject.nix be passed into the planner?¶
Three tiers exist, and the probe of docs/NIX-FACTS.md measured the first two.
Tier 1, the lib of nixpkgs: yes, and it is free. import (nixpkgs + "/lib") inside the sandbox costs 0.0 s and no store operation, because lib
is pure Nix. Any pure Nix library travels the same way, and that includes the
whole of pyproject.nix/lib, the 3602 lines of PEPs. This lab needs none of
it, but the mechanism is proved.
Tier 2, nixpkgs itself: it imports, and it cannot yet instantiate.
import nixpkgs { ... } returns a set in 0.1 s. hello.drvPath then fails on
QueryPathInfo, which the allowlist refuses. So the answer to "pass in
${nixpkgs} and get the complete shebang" is not yet, and the gap is one
more allowlist entry or a few.
Tier 3, the built store paths: this is what the lab does now, and it needs no
nixpkgs inside the sandbox at all. default.nix evaluates outside, and hands
the planner the output path of each tool and each script. graph.nix calls the
derivation primitive over those paths.
The builders of pyproject.nix do not travel by tier 3 on their own, because
they are stdenv.mkDerivation and a set of setup hooks. A setup hook is a
bash mechanism of stdenv, and mkDerivation is a Nix function of about a
thousand lines in pkgs/stdenv/generic/make-derivation.nix, with dozens of
optional arguments. Its result is a plain derivation call whose builder is
bash and whose args are [ "-e" ./default-builder.sh ], and
default-builder.sh holds one word, genericBuild, which $stdenv/setup
defines. So the runtime half of mkDerivation is one store path, and the
evaluation half is the thousand lines.
What is expensive there and cheap here¶
Markers, tags, specifiers and name normalisation. 3602 lines of Nix in
pyproject.nix/lib, against four import lines in plan.py.
Forked resolution. Most of uv2nix/lib/lock1.nix, against a filter over
the Target that plan.py already builds: a package is a candidate when it
carries no resolution-markers, or when one of them evaluates true.
The build system of a source distribution. uv2nix cannot close this, and
tells the user to write an overlay. A planner can close it, and the way is
already tested: tests/functional/dyn-drv/eval-submit.sh of the patched Nix
runs a three level chain, a planner that submits a planner that submits a
graph. So the outer planner can give each source distribution its own planner
node, with the source archive as a real input. That inner planner reads the
pyproject.toml of the archive, resolves the backend against the same lock,
and submits the build node.
This is the strongest case for the whole feature that this lab has found,
and it is stronger than the idna case, because uv2nix does not merely find it
awkward. uv2nix cannot express it at all: the answer is inside a file that no
evaluation has downloaded.
What the environment costs at run time, measured¶
The measurements above are the cost of the evaluation. This one is the cost of what the user runs. It is a different number, and the whole of it is the interpreter.
The subject is projects/pure-app, whose environment holds rich and its two
dependencies. tests/test_projects.py builds it into a private store, and the
method is one command:
$ nix --store "$store" path-info -r --json <the environment>
path |
NAR bytes |
share |
|---|---|---|
|
141459368 |
59.4% |
|
35073096 |
14.7% |
|
13441424 |
5.6% |
the 24 other paths |
48225088 |
20.2% |
the environment itself |
21656 |
0.0% |
the closure |
238220632 |
28 paths |
The environment holds nothing that it should not. It is 21656 bytes,
because a virtual environment is a tree of symbolic links. The three
distributions that the user asked for are 19 MB of the 238 MB. Nothing that
built the environment is in it: neither packaging nor installer appears in
the closure, because each one is an input of a build step and not a member.
So the question is the interpreter, and one file of it is 28% of everything.
du -sb over the store path of python3 gives the breakdown:
inside |
bytes |
|---|---|
|
66397194 |
|
15449098 |
|
7474640 |
|
7040073 |
|
6884096 |
|
2743124 |
|
2411476 |
|
1458792 |
libpython3.14.a is the static archive that builds an extension module. No
file of the environment names it, and grep -rl config-3.14 over the
environment finds nothing.
The two interpreters that nixpkgs builds, and what each one costs¶
nixpkgs builds python3 and python3Minimal, and a binary cache holds both.
Any other combination is an .override, which is a local build of CPython.
$ nix path-info --store https://cache.nixos.org -S <the interpreter>
interpreter |
closure |
paths |
|---|---|---|
|
219013232 |
23 |
|
77948792 |
8 |
python3Minimal is 64% smaller, and cpython/default.nix says why: it takes
withMinimalDeps = true, and stripConfig, stripIdlelib, stripTests and
stripTkinter each default to that same value. So it drops the static archive
and the optional dependencies together.
python3Minimal cannot open a wheel, so this lab cannot use it. Every
wheel is a zip with deflate members, and the module that reads deflate is
absent. Measured by running each import against the store path:
$ /nix/store/...-python3-minimal-3.14.6/bin/python3 -c 'import zlib'
module |
|
|---|---|
|
missing |
|
present |
zipfile then raises
RuntimeError: Compression requires the (missing) zlib module. ssl and
ctypes rule the interpreter out for most projects as well.
The third interpreter, which no cache holds¶
The two knobs are separate, so a third combination exists: keep every optional dependency, and strip the dead weight only.
$ nix build --expr '(import <nixpkgs> {}).python3.override {
stripConfig = true; stripIdlelib = true; stripTests = true; stripTkinter = true;
}' --impure --no-link --print-out-paths
interpreter |
closure |
paths |
on disk |
a wheel opens |
|---|---|---|---|---|
|
219013232 |
23 |
141459368 |
yes |
|
77948792 |
8 |
27659480 |
no |
the stripped override |
140479456 |
23 |
62357942 |
yes |
The override keeps zlib, ssl, ctypes, sqlite3, readline, curses,
bz2, lzma, decimal and hashlib, and its closure is 36% below the
closure of python3. That comparison holds for an empty target only. The
section below measures what each one adds to a target that already holds
pkgs.python3, and the order reverses there.
The environment was built with it, and it runs. default.nix takes a
python argument, so the whole graph builds against whichever interpreter the
caller names:
$ nix build --file uv.nix --arg project ./projects/pure-app \
--arg python '(import <nixpkgs> {}).python3.override {
stripConfig = true; stripIdlelib = true; stripTests = true; stripTkinter = true;
}' --argstr venvName pure-app-slim
the environment of |
closure |
|---|---|
with |
238220632 |
with the stripped override |
159686920 |
That is 33% less, for an empty target. A derivation that runs the second
environment reports
sys.prefix inside it, rich from its own site-packages, and an import of
zlib, ssl, ctypes and sqlite3. A deflate zip opens.
It costs a build of CPython, and a cache does not answer for it.
nix path-info --store https://cache.nixos.org gives null for the store
path, because an override is a derivation that nixpkgs never built. The build
above took 357 s on this machine, and that number holds the substitution of
each build input as well. Every package that fromNixpkgs supplies would also
need a build against the same interpreter.
The total closure is the wrong number, and the marginal closure is right¶
A total closure is what an empty target pays, and almost no target is
empty. The store is shared. A machine that runs NixOS holds pkgs.python3
already, and so does every other package of nixpkgs that names an interpreter.
What an environment costs such a machine is what it adds: the paths of its
closure that the target does not hold yet.
The method subtracts one closure from the other, over the path sets and not over the totals:
$ nix --store "$store" path-info -r --json <the environment> > env.json
$ nix path-info -r --json <the interpreter of the target> > base.json
Read the path key of each entry into a set, subtract the second set from the
first, and add the narSize of what remains.
the two interpreters |
paths |
NAR bytes |
|---|---|---|
the closure of |
23 |
219013232 |
the closure of the stripped override |
23 |
140479456 |
shared by both |
22 |
77553864 |
only in |
1 |
141459368 |
only in the stripped override |
1 |
62925592 |
The two interpreters differ in one path, and share the other 22. glibc,
ncurses, sqlite, openssl and every other dependency is the same store
path in both, because the override changes the build of CPython only.
a target that already holds |
it adds |
new paths |
|---|---|---|
the environment, with |
19207400 |
5 |
the environment, with the stripped override |
82133056 |
6 |
So the stripped override makes the deployment larger by 62925592 bytes. An
override is a new store path, so the target holds both interpreters: the
141459368 bytes of pkgs.python3 stay, because everything else on that machine
still names them, and the 62925592 bytes of the override arrive beside them.
The 33% saving above is real for an empty target and for nothing else.
So the three options are a choice, and the target decides it:
an empty target |
a target with |
the price |
|
|---|---|---|---|
|
238220632 |
19207400 |
none; this is the default of |
|
not usable |
not usable |
the interpreter cannot open a wheel |
the stripped override |
159686920 |
82133056 |
one CPython build, and no binary cache |
Two deployments make the total closure the right number. A container image
that carries this closure and nothing else pays the whole 238220632 bytes. A
nix copy to a machine that runs no other Nix package pays it as well. Every
other deployment shares the interpreter, and the override then costs bytes
rather than saving them.
The lab takes neither decision for the user. python is an argument, and
its default is pkgs.python3, which is the correct default for a shared store.
A caller that builds a single-purpose image passes one expression and pays one
build of CPython.
The same environment from nixpkgs, measured beside it¶
python3.withPackages is the ordinary way to get this environment, so it is
the number that says what this lab costs. Measured on 2026-08-12, over
projects/pure-app, which resolves to rich and its three dependencies. Both
sides read the same <nixpkgs> and the same pkgs.python3:
the closure |
paths |
NAR bytes |
|---|---|---|
|
23 |
219013232 |
|
28 |
238473264 |
|
28 |
238220632 |
The interpreter is 91.9% of each total, and each environment adds five paths: the four packages, and the environment itself. The 23 paths of the interpreter are a subset of both closures, and no path of it is missing from either, so the subtraction is exact:
what each one adds to a target that holds |
bytes |
|---|---|
|
19460032 |
|
19207400 |
The difference is 252632 bytes, and 236488 of it is the environment path:
the path |
nixpkgs |
|
|---|---|---|
pygments |
13450712 |
13441424 |
rich |
4778984 |
4776248 |
markdown-it-py |
902600 |
899248 |
mdurl |
69592 |
68824 |
the environment |
258144 |
21656 |
python3.withPackages writes a compiled wrapper for each program, and bin
holds twelve of them at about 16000 bytes each. A virtual environment writes a
symbolic link for the interpreter, and a Python shim of about 265 bytes for
each console script. The four packages differ by less than 0.1% each.
So the runtime closure is no reason to choose either one. The two agree to
0.1%, because both install the same code beside the same interpreter. The
reason to choose is the resolution: uv.lock decides the versions here, and
nixpkgs decides them there.
A proof that reaches no index¶
ddrn/examples/venv-graph/run.sh is the proof over the real lock file, and
every artefact of that lock comes from PyPI. So that proof needs a network, and
it also needs the versions that PyPI serves today. A gate of this repository
can have neither.
ddrn/examples/venv-graph/tests is the same graph over artefacts that this
repository builds. The run reads:
plan: installer installer-1.0.1-py3-none-any.whl (unpacked to bootstrap)
plan: demo-lib: demo_lib-2.1.0-py3-none-any.whl (wheel)
plan: demo-tool: demo_tool-3.0.0.tar.gz (sdist)
plan: backend demo_backend-1.0.0-py3-none-any.whl for demo-backend>=1.0
plan: demo-app: editable through demoback, reading $PYDD_EDITABLE_ROOT
==> 6 nodes, 3 of them in the environment, from 4 artefacts
and then, from the environment that the graph built:
console script
hello from demo-lib
from a source distribution
demo-tool was built by demoback
editable
hello from the tree that the sealed check named
The second line is the one that matters. demo-tool is a source distribution,
its pyproject.toml names demoback, and no row of the lock file records
that name. The line exists only because a planner opened the archive.
The seam between a reader and the graph¶
default.nix takes an artifacts argument, and each entry holds package,
version, filename, kind and drv. drv is a derivation, and not a
path. null means "read lock.json, and make a fetchurl for each row",
which is what run.sh gets.
A reader of a different lock format fills that list, and so does a test that
builds its artefacts. The refactor changed no decision: the environment of
run.sh is the same store path as before,
rfy15b0q3y3nmrld2wbw3j9arvjrf4wl-demo-venv.
What a fixture artefact costs¶
A wheel and a source distribution each record a time, in three places. A
derivation that writes one is not reproducible until all three are fixed, and
ddrn/examples/venv-graph/tests/fixtures fixes them:
format |
field |
value |
|---|---|---|
zip |
the date of each entry |
1980-01-01, the floor of the format |
tar |
|
a fixed epoch, and |
gzip |
the |
|
A .pyc records a fourth one, and SOURCE_DATE_EPOCH is the fix for it.
docs/NIX-FACTS.md gives that measurement.
nixpkgs ships the installed tree of installer, and not the wheel of it.
The graph bootstraps by unzipping a wheel, because nothing can install the
installer, so the fixture repacks python3Packages.installer into a wheel
again.
A member name is not a path, and two archives spell the same member two
ways. tar accepts ./demo-1.0/pyproject.toml, and zip writes
demo-1.0/pyproject.toml. A count of the separators answers one and not the
other, so pydd.python.is_pyproject drops the leading . and each empty part,
and then asks for exactly two parts.
A real uv project, built end to end¶
ddrn/examples/venv-graph/uv.nix reads a uv.lock and hands it to the same
graph that lock.json uses. projects/pure-app is the first project, and
uv itself wrote every file of it. projects/README.md gives each command.
Two projects hold the comparison below. pure-app has a virtual root and every
artefact of it is a pure wheel. packaged-app installs itself through
hatchling, and one of its dependencies builds from a source distribution.
The graph of pure-app holds one node for each package and one for the
environment:
==> the planner submitted /nix/store/yic8v7qq...-pure-app.drv
==> the root of the graph reads:
"7wz8dcib...-pygments-2.20.0.drv"
"dxvcwc56...-markdown-it-py-4.2.0.drv"
"vf58lwq9...-mdurl-0.1.2.drv"
"w9kllfhc...-rich-15.0.0.drv"
"yic8v7qq...-pure-app.drv"
The Nix half of this reads the lock file and resolves nothing. uv.nix
walks package[].wheels[] and package[].sdist and makes one fetchurl for
each, with no filter and no preference. The planner resolves inside the
sandbox, with packaging.
Three decisions that uv.lock leaves open¶
uv already chose every version and every hash, and this lab trusts both
absolutely: the planner asks choose for rich==15.0.0, an exact pin, and
never for the range that the project wrote. A lock file is a union over every
environment that the project supports, so three decisions remain:
decision |
who answers it |
why the lock cannot |
|---|---|---|
which packages apply |
|
an edge carries a PEP 508 marker, and a fork carries |
which extras and groups |
the caller, through |
|
which artefact of a package |
|
|
uv export --format requirements.txt moves the first decision into uv itself.
It still writes markers, so PEP 508 evaluation stays, and it costs a second
generated file to commit. This lab did not adopt it.
The wheel installer comes out of the same lock file¶
The graph installs every wheel with pypa/installer, and that is a tool of
this build system and not a dependency of the project. PEP 735 dependency
groups are what uv offers for exactly that:
$ uv add --no-sync --group ddrn installer
The selection of uv.nix enables no group, so the lock file pins the tool and
the environment does not carry it. Both directions are proved, by the manifest
of the environment that each selection builds:
selection |
the environment holds |
|---|---|
|
|
|
the same four, and |
A distribution does not spell its name the way the index does¶
uv.lock calls the package pygments. The Name of the metadata that the
installer wrote is Pygments:
distributions
Pygments==2.20.0
markdown-it-py==4.2.0
mdurl==0.1.2
rich==15.0.0
PEP 503 normalisation is what makes the two agree, and pydd.python applies it
at every join. A check that compares the two spellings directly fails, and this
one did until the run above.
uv writes a hash that fetchurl reads¶
uv.lock spells every hash sha256:<hex>, which is a format that the hash
argument of fetchurl accepts. So uv.nix passes it through with no
translation, and a mismatch is a mismatch of the lock file itself.
The build system is the one thing that uv.lock does not hold¶
projects/packaged-app is the measurement. uv init --package --build-backend hatch writes requires = ["hatchling"] into pyproject.toml, and uv lock
copies nothing of that table into uv.lock. The lock file holds
hatchling only after uv add --group ddrn hatchling puts it there as an
ordinary package.
The same hole covers every source distribution that the lock file names.
mdurl builds through flit_core, and no entry of uv.lock says so: the
declaration is inside the archive, which is why a source distribution becomes a
planner node here at all.
So a dependency group is where a backend goes. PEP 735 groups are what uv offers for a tool that the project needs and does not ship, the selection does not enable the group, and one lock file then pins the whole build. The cost is one entry for each backend that any build reaches, and nothing checks that the list is complete until a build fails:
package |
what needs it |
|---|---|
|
the |
|
|
|
the |
|
every node of the graph, to install a wheel |
uv2nix pays the same cost differently. pyproject.nix ships a set of build
systems of its own, so the version of a backend comes from nixpkgs and not from
the lock file of the project.
A backend needs its own closure, and requires names the top of it¶
PEP 517 says the frontend installs requires and what those need. The
lock.json example never met this rule, because flit_core depends on
nothing. hatchling depends on four packages, and a build that installs the
declaration alone fails at import.
uv.lock holds the edges of every package that it lists, so
pydd.python.build_requirements walks them from each name of requires. For
packaged-app that turns two declared names into six pinned ones:
plan: backend hatchling-1.31.0-py3-none-any.whl for hatchling==1.31.0
plan: backend packaging-26.3-py3-none-any.whl for packaging==26.3
plan: backend pathspec-1.1.1-py3-none-any.whl for pathspec==1.1.1
plan: backend pluggy-1.6.0-py3-none-any.whl for pluggy==1.6.0
plan: backend trove_classifiers-2026.6.1.19-py3-none-any.whl for trove-classifiers==2026.6.1.19
hatchling needs editables and declares four other packages¶
Measured, and not assumed. The metadata of hatchling 1.31.0 reads:
Requires-Dist: packaging>=24.2
Requires-Dist: pathspec>=0.10.1
Requires-Dist: pluggy>=1.0.0
Requires-Dist: tomli>=1.2.2; python_version < '3.11'
Requires-Dist: trove-classifiers
hatchling/builders/wheel.py reads from editables import EditableProject in
build_editable_detection, which is the default path of build_editable. So a
PEP 660 build of any hatchling project fails with ModuleNotFoundError: No module named 'editables' until something adds it. This lab adds it to
build-system.requires of the project.
hatchling does declare it, through the hook that this lab does not call.
hatchling/build.py defines get_requires_for_build_editable, and that
function returns [*builder.config.dependencies, EDITABLES_REQUIREMENT]. PEP
517 and PEP 660 give a backend three such hooks, one for each of build_sdist,
build_wheel and build_editable, and a frontend calls the hook, resolves
what comes back, and adds it to the build environment. The next section says
why this lab cannot, and what it pays instead.
get_requires_for_build_* is the hook that this lab does not call¶
A frontend of PEP 517 builds the environment of a backend in two steps: it
installs build-system.requires, and it then asks the backend what more it
needs. ddrn performs the first step only.
The reason is where each step would run. The plan is decided in the sandbox of the planner, and the backend is not installed there. A hook could run in the node that builds, which is where the backend does exist, but that node has no index, no store connection and no way to add a member to a graph that is already registered. So a requirement that arrives from the hook arrives after the only moment that could act on it.
What the lab pays instead is one line for each such package in the ddrn
dependency group of the project, and editables is the one case that a project
here has met. What it costs is that nothing checks the list: a backend that
asks for a package through the hook, and a group that does not name it, fails
in the build with ModuleNotFoundError.
A dependency group cannot pay it for a git source, and hatchling is that
case. The group works because the [build-system] of the consumer project
names the backend, and the edges of uv.lock then expand it. A git source
brings its own [build-system], out of the checkout, and hatchling writes
this one:
[build-system]
requires = []
build-backend = 'hatchling.ouroboros'
backend-path = ['src']
requires is empty and it is not honest. hatchling/ouroboros.py reads the
dependencies list out of its own pyproject.toml with a regular expression,
and returns it from get_requires_for_build_wheel. That list is packaging,
pathspec, pluggy and trove-classifiers, and hatchling imports all four.
So the plan holds no backend node, nothing puts those four on the path, and the
import of the backend fails.
flit_core has the same in-tree shape with an honest empty requires, because
it declares dependencies = []. projects/git-app therefore takes flit_core
from pypa/flit, and projects/README.md records why it is not hatchling.
editables wrote a bare path, and not an import finder. For a project of
one package under src/, EditableProject takes add_to_path, so the install
holds one .pth and no generated module:
_editable_impl_packaged_app.pth
import sys; import os.path; sys.path.append(os.path.expandvars('$PYDD_EDITABLE_ROOT/src'))
scripts/build-local.py rewrites that line, and it raises for the other
form. The other form needs the syntax tree rewrite that pyproject.nix does
with libcst, and no project of this lab has reached it yet.
tool.uv.no-binary-package stays in pyproject.toml¶
uv writes this setting into no lock file, and the lock file still carries the
wheel of the package that the setting names. It is a decision of the consumer,
so uv.nix reads it from pyproject.toml, where uv keeps it, and passes it to
pydd.python.choose. That planner then takes the source distribution although a
wheel fits:
plan: mdurl-0.1.2-py3-none-any.whl: no-binary-package names mdurl
plan: mdurl==0.1.2: mdurl-0.1.2.tar.gz (sdist)
uv.lock records no extra and no group, so uv.nix takes both¶
uv sync decides on its command line which extras and which groups to install,
and it writes none of that decision into the lock file. uv.nix takes the same
decision as two arguments, and each one takes null, the literal "all", or a
list of names. The rules come from uv, and each one has a source:
what |
uv says |
|
|---|---|---|
no extra |
|
|
every extra |
|
|
a default group |
|
|
every group |
|
|
a named group |
|
|
There is no tool.uv.default-extras for a project. DefaultExtras exists,
in crates/uv-normalize/src/extra_name.rs, and a PEP 723 script is what reads
it. So a project that wants an extra by default has no place to write that, and
extras of uv.nix is the only place.
uv validates the default group list, and not the fallback.
default_dependency_groups raises MissingDefaultGroup when
tool.uv.default-groups names a group that [dependency-groups] does not
declare. The ["dev"] that it falls back to reaches no such check, so a project
with no dependency group at all still locks. pydd.python._workspace.groups_of
copies both halves of that behaviour.
A workspace names its members twice, in two spellings¶
uv.lock and pyproject.toml each hold a list called members, and the two do
not agree entry for entry. The lock file names the packages, and the project
file names the directories:
# uv.lock
[manifest]
members = ["workspace-root", "ws-cli", "ws-core"]
# pyproject.toml
[tool.uv.workspace]
members = ["packages/ws-core", "packages/ws-cli"]
A selection names packages, so uv.nix reads the first list. uv writes
[manifest] for a workspace only, so the lock file of a single project holds
none of it, and pure-app and packaged-app have no such table. uv.nix
falls back to the name of the project, which keeps the default of a single
project the same.
pydd.python._uvlock.Lock.members parsed that table from the start and nothing
read it. tests/test_workspace.py reads it now.
One lock file resolves to more than one environment¶
uv.lock records no selection, which is the first of the three open
decisions above, and a workspace is where that matters. Two selections of the
one lock file of projects/workspace:
selection |
what the environment holds |
|---|---|
every member |
|
|
|
ws-core depends on nothing, so the second environment reaches no artefact of
the index. The two graphs share the ws-core-editable node, because that node
is the same derivation in both.
The default of uv.nix is every member, which is what uv sync --all-packages
takes. A default of "the root project" builds an empty environment for a
workspace, because the virtual root of a workspace usually depends on nothing.
A PEP 660 redirect names the directory of the member¶
scripts/build-local.py rewrites the build directory of a backend to the
value of editableRoot, and the build directory is the tree of the package.
For a project whose source is ., that tree is the project, so one value
serves. For a member of a workspace it is a directory under the project, and
the redirect has to carry that directory too.
uv.nix gave every local package the same editableRoot, so the .pth of
ws-core read $PYDD_EDITABLE_ROOT/src and the environment raised
ModuleNotFoundError: No module named 'ws_core'. It now joins the relative
directory of source = { editable = "packages/ws-core" } to the redirect.
Two measurements come with the correction. The environment of packaged-app
is still a851fa0dqrjd1bshd2x6v02ck3mmr1n1-packaged-app, so the . case did
not move. A second copy of the whole workspace, with one edit to ws-core,
changes both reports of the same environment, because ws-cli reads
ws-core through the same redirect.
A wheel of nixpkgs, in place of a wheel of the index¶
Tier 4 answers the override question, and projects/binary-app is the proof.
uv.nix takes a fromNixpkgs argument, as
{ pyzmq = pkgs.python3Packages.pyzmq; }. The planner then makes no wheel node
for that name, and Plan.nixpkgs_node names the store path instead.
That argument is not called overrides, and the name is a decision. uv
gives override-dependencies to a table that forces a version, and uv2nix
gives overrides to a build fixup of a package that does not build. Neither
one is this, which replaces the artefact of a package with a store path that
nixpkgs already built. One word carries one meaning in this repository.
Both environments run the same code, and they differ in one line:
the wheel of the index |
|
|
|---|---|---|
extension |
|
|
links against |
|
|
|
runs |
does not run |
The substitution is the measurement that matters. The build log of the second one reads:
copying path '/nix/store/…-zeromq-4.3.5' from 'https://cache.nixos.org'...
copying path '/nix/store/…-python3.14-pyzmq-27.1.0' from 'https://cache.nixos.org'...
The derivation kept the identity that nixpkgs gave it, so the override cost a download and not a compile. Tier 2 could never offer this, and the section "Tier 4, the pre-instantiated menu" above gives the reason.
Nothing reconciles the two versions. nixpkgs holds what it holds, and the lock file pins what uv resolved. The planner prints both on one line, and that line is the whole of the warning:
plan: pyzmq==27.1.0: nixpkgs supplies pyzmq==27.1.0, and the lock file asked for ==27.1.0
A local project installs in two forms, and the lock file records neither¶
uv.lock says that a member installs from its own directory. It does not say
which form that install takes, and uv itself writes two:
command |
hook |
what the environment holds |
|---|---|---|
|
PEP 660 |
a redirect at the tree of the user |
|
PEP 517 |
nothing yet; the archive that an index serves |
The second one is what reaches a user who does not have the tree. That user
runs build_wheel over the archive, and the wheel that comes out holds the
code. So uv.nix takes the form as an argument, fromSdist, and the graph
makes each hook a node:
demo-app-sdist build_sdist over the tree -> one .tar.gz
demo-app-from-sdist build_wheel over that archive -> a wheel, installed
The two nodes are the same two nodes that an artefact of the index uses.
The second one is of kind sdist-build, which is the kind that mdurl takes,
and the same scripts/build-sdist.sh builds both. An archive that a node of
the graph wrote and an archive that PyPI served are one thing to the build that
reads it.
The proof of the difference is the path that the module comes from:
editable loaded from $PYDD_EDITABLE_ROOT/src/packaged_app/__init__.py
sdist loaded from <the environment>/lib/python3.14/site-packages/packaged_app/__init__.py
The two sources that name a place, and what each one costs¶
Four of the seven source kinds name a registry or a directory of the workspace,
and one names a file of this machine. The other two name a place on the
network, and uv.lock writes them like this:
# tests/fixtures/locks/git-subdirectory.lock
source = { git = "https://github.com/pypa/hatch.git?subdirectory=backend&rev=hatchling-v1.27.0#cbf6598e5cbce3ba9097023c5bf783001ebbcbcb" }
# no wheels, and no sdist
# tests/fixtures/locks/url-subdirectory.lock
source = { url = "https://github.com/pypa/hatch/archive/hatchling-v1.27.0.tar.gz", subdirectory = "backend" }
sdist = { hash = "sha256:c5d576a04bbc3940c71dc899da35a9cfe644044a6e4e70549f41d964d6331625" }
Both files name one project, hatchling, and that project lives in backend
of pypa/hatch. So the two kinds are the same project through two doors, and
each door needs a different route:
kind |
what arrives |
route |
nodes |
|---|---|---|---|
|
an archive, with the hash on |
join the two, and it is an ordinary source distribution |
a planner, then |
|
a checkout |
a tree, like a |
one |
A URL source splits its artefact across two tables, and a reader has to join
them. The sdist table carries the hash alone. uv.nix reads the address
from source.url, and what reaches the planner is a row of the menu that no
node treats specially.
A git source carries no artefact at all. The package has no wheels table
and no sdist table, so nothing of it reaches the menu. The checkout is the
whole input, and one PEP 517 build_wheel call over it gives the install.
The commit is in the fragment, and the query holds what the user asked
for. Measured against locked_git_url and GitSource::from_url, both in
crates/uv-resolver/src/lock/mod.rs of astral-sh/uv. That pair writes the
string and reads it back, so it is the whole grammar:
key |
what it means |
this reader |
|---|---|---|
|
where the project lives in the repository |
takes it |
|
a file of the repository, a wheel or an archive |
refuses |
|
the checkout needs Git LFS |
refuses |
|
what the user asked for |
drops it |
|
the commit that uv resolved |
fetches it |
A tag moves and a commit does not, so uv-git.nix takes the commit and drops
the name that the user gave it. uv also removes the credentials from the URL
before it writes the string, so no reference of a lock file carries one.
The two refusals are refusals because each one changes what the package
is. path comes from from_git_path_source_dist, and the package is then
an archive or a wheel that lives in the checkout, so a reader that ignored the
key would build the root of the repository and install another project.
builtins.fetchGit fetches no Git LFS object, so a checkout of an lfs=true
source holds a pointer file of a hundred bytes where each large file belongs,
and the build reads that pointer as if it were the file. Both fail with a
message rather than build the wrong thing.
uv writes an artefact with no address in three shapes, and this reader takes
all three. WheelWireSource gives Url, Path — "used for wheels that come
from local registries (like --find-links)" — and Filename, for a wheel that
a path source names. SourceDist gives the same three shapes, and its
Metadata variant is the hash-only row that a URL source and a path source
both write.
A path source keeps the location on the source, and the row of the artefact
carries the file name or nothing at all. So the reader takes the name from
the row when the row gives one, and from the path of the source when it does
not:
[[package]]
name = "vendored"
source = { path = "vendor/vendored-1.0.0-py3-none-any.whl" }
wheels = [ { filename = "vendored-1.0.0-py3-none-any.whl", hash = "sha256:7353…" } ]
[[package]]
name = "archived"
source = { path = "vendor/archived-1.0.0.tar.gz" }
sdist = { hash = "sha256:9207…" }
uv.nix joins the path to the directory of the project and calls
builtins.path, so the file reaches the store with no fetch and with no hash
to state twice. Everything below that point is unchanged: the menu holds a row
for each file, the planner picks one out of the menu, and graph.nix names the
chosen file with builtins.storePath where a fetch would need
builtins.appendContext. ddrn/examples/venv-graph/projects/vendored-app is
the project that uv wrote for these two shapes.
A registry may be a directory, and the location of an artefact then has three parts. The source names the directory, and the row of the artefact names the file under it:
[[package]]
name = "stocked"
version = "1.0.0"
source = { registry = "index" }
wheels = [ { path = "stocked-1.0.0-py3-none-any.whl" } ]
uv joins the root of the workspace, the path of the registry and the path of
the row. to_registry_wheel writes root.join(index_path).join(file_path),
and to_source_dist writes workspace_root.join(path).join(file_path), both
in crates/uv-resolver/src/lock/mod.rs. A reader that took the row as a path
of the project names a file that does not exist, so this divergence fails
loudly. ddrn/examples/venv-graph/projects/local-index is the project, and
tests/test_local_index.py asserts that the file is under the registry and
nowhere else.
Three more facts of the same shape follow, and this reader takes each one:
A value with no scheme is a directory.
RegistrySourceWirereads the value ofregistryas a URL whensplit_schemefinds a scheme thatScheme::parseknows, and as a path otherwise.Either path may be absolute. uv makes the row relative only when the user gave the index as a relative path (
try_relative_to_if(…, !path.was_given_absolute())).Only a registry of a directory writes a
pathon a row.from_registry_wheelwrites it underIndexUrl::Pathalone, andto_registry_wheelrefuses the same row for a registry of the network withMissingUrl.uv.nixraises there rather than guess a directory.
A row of such a registry carries no hash. A flat registry serves the file
itself, so uv records no digest for it. That costs this reader nothing, because
builtins.path copies the file and needs none. A pkgs.fetchurl would have
had nothing to state.
A git source is the one input of this lab that is not lazy, and uv.lock is
the reason. Every other input is a pkgs.fetchurl, which is a fixed-output
derivation: instantiating one downloads nothing, so the menu holds every
artefact of the lock file and the build downloads the ones that the planner
chose. A git source records the commit and no hash of the tree that the
commit gives, so there is no fixed-output derivation to instantiate.
builtins.fetchGit therefore runs during evaluation, and a lock file that
holds a git source pays that fetch whether or not the resolution installs the
package.
One line of uv.lock would remove the cost. A NAR hash of the checkout, or
of the archive that the commit gives, would make the fetch a fixed-output
derivation like every other one. uv records the commit because the commit is
what pip and uv need, and neither of them defers a download.
uv now holds the machinery for such a line, and no lock file uses it yet.
crates/uv-extract/src/dirhash.rs hashes a directory tree with BLAKE3, and it
gives the property that a Nix consumer needs: "the dirhash of an archive is the
same as the dirhash of its unpacked files". Nothing in uv-resolver reads that
module, so a git source of a lock file written today still records the commit
alone.
uv installs an editable source as an editable, and a directory source as
a wheel. The Source enum of the lock says so itself: Directory is "a path
to a local directory", and Editable is "a path to a local directory that
should be installed as editable". So the two source kinds are the decision, and
uv.nix follows it rather than install every directory the same way.
A subdirectory is what both kinds add, and one archive may hold two
projects. pypa/hatch declares a project at its root and another in
backend. A reader that took the first pyproject.toml it found would build
the wrong project with the wrong backend, and nothing downstream would say so.
So is_pyproject moves the answer down by exactly the parts of the
subdirectory, and the sealed test carries a decoy above each project to prove
it: the top of the tree and the top of the archive both declare
the_backend_of_the_wrong_project, which no artefact of that menu supplies.
The section above records where uv states each of these, and this lab read
astral-sh/uv at e39e0b21 to write it.
An archive holds what the backend put in it, and not the tree¶
Measured with uv build --sdist, over the projects of
ddrn/examples/venv-graph/projects:
packaged_app-0.1.0.tar.gz ws_cli-0.1.0.tar.gz
PKG-INFO PKG-INFO
pyproject.toml pyproject.toml
README.md README.md
src/packaged_app/__init__.py src/ws_cli/__init__.py
uv.lock src/ws_cli/py.typed
.python-version
Three things follow, and each one is a difference from the tree:
The name is not the name of the project. PEP 625 normalises it, so
packaged-appwritespackaged_app-0.1.0.tar.gz. A consumer therefore finds the archive by its extension, andscripts/build-sdist.shdoes that.The archive holds one directory, named after itself, so a consumer strips one level.
A file that the backend leaves out is absent for every user of the index.
ws-clidepends onws-core, and its archive carries neither the other member noruv.lock.[tool.uv.sources]is a table of uv and no index reads it, so theRequires-Dist: ws-coreof that wheel resolves against PyPI and against nothing else.
The archive is the same bytes, given the same SOURCE_DATE_EPOCH¶
The graph writes packaged_app-0.1.0.tar.gz with
sha256:7cf7d79c5da32c1753de9717685799633745315284186a21972c0a4d3beaecb6, and
so does uv build --sdist on the same tree. Change one variable, and the
archive changes:
$ env -u SOURCE_DATE_EPOCH uv build --sdist
$ sha256sum dist/packaged_app-0.1.0.tar.gz
e4a131963bdbdf3cee4a22a951191a2ea788ad0a960bfc24f814d0a9860a1c1c
hatchling/builders/utils.py reads that variable, and it takes 1580601600
when the variable is absent. graph.nix sets 315532800 for every node, for
the reason that the file gives at SOURCE_DATE_EPOCH: a node output floats, so
a build that recorded the time of the day would take a new path on every run.
The agreement with uv build is a result of that setting, and not of anything
that this lab does to the archive.
uv downloads an interpreter that NixOS cannot start¶
uv fetches its own CPython by default, and that build is a dynamically linked
executable for a generic Linux. Name an interpreter of nixpkgs instead:
$ py=$(nix build --file . pkgs.python313 --no-link --print-out-paths)
$ export UV_PYTHON_DOWNLOADS=never UV_PYTHON="$py/bin/python3.13"
Two ways to seed the private store, and what each one costs¶
The build runs in a private chroot store, and that store starts empty. Two
forms fill it, and README.md and run-uv.sh do not use the same one.
nix copy --to "$store" --no-check-sigsputs the closure of the patched Nix and of the planner environment in, before the first build.--extra-substituters daemon --option require-sigs falsenames the system daemon as a substituter, and copies nothing first.
Measured on 2026-08-12 over projects/pure-app, with one empty store for each
form and the store removed between the two runs:
form |
seeded from this machine |
fetched from |
built |
store |
total |
|---|---|---|---|---|---|
|
142 paths |
78 paths |
13 |
825 MB |
8 s |
|
214 paths |
0 paths |
13 |
822 MB |
9 s |
Both gave /nix/store/90mx650ayjnhh0ivf92kmjgpbymskkzm-pure-app.
The substituter form removes the network, and it does not save time or
space. nix copy seeds the two closures that the command names, so each
other path of the build still comes from the binary cache. The daemon serves
all of them. The wall-clock is the same here, because the cache is fast and the
78 paths are 20.7 MiB.
The daemon is the only source that works. --extra-substituters local and
--extra-substituters 'local?root=/' both fail with path '...' is required, but there is no substituter that can build it. A client cannot read the
database of the local store; the daemon owns it.
The substituter form prints one warning for each realisation. The patched
Nix is 2.36-pre and the daemon of this machine is older, so each build prints
warning: the daemon is missing the 'realisation-with-path-not-hash' protocol feature, needed to support content-addressing derivations. One build of
pure-app printed it 16 times, and a whole run of run-uv.sh printed it 145
times in 1088 lines.
So run-uv.sh keeps nix copy. That script is the demonstration, and its
output is what a reader reads. It also cannot become an offline script whatever
it does, because the graph downloads each artefact from PyPI. The one-command
form in README.md keeps the substituter, because a reader of that form wants
the environment and not the transcript.
A package that compiles, and what its compiler costs¶
A node of the graph holds no compiler, and one argument gives it one¶
graph.nix gives every node coreutils, unzip, tar, gzip and the
interpreter, and no more. A source distribution that holds C code therefore
fails inside its backend, where the interpreter looks for cc:
> demoback: cc -shared -fPIC -I/nix/store/…-python3-3.14.6/include/python3.14 …
> FileNotFoundError: [Errno 2] No such file or directory: 'cc'
Measured on 2026-08-12, with pytest ddrn/examples/venv-graph/tests/test_sealed_graph.py.
tests/fixtures/demo-native is the archive that produces it, and
TestAProjectThatHoldsCCode asserts the message.
The list stays short because a compiler in it would reach every node. A
wheel is already built, so the node that installs one needs no compiler, and
the index serves a wheel for almost every package. buildTools of
default.nix names one for the package that needs it:
buildTools = {
psutil = [ pkgs.stdenv.cc ];
};
The planner attaches the list to the node that builds that package, and to no
other node. The canonicalisation of the name happens in Python, with
packaging.utils.canonicalize_name, so graph.nix looks nothing up and
matches no spelling.
The compiler is a build input, and it reaches no environment¶
store object |
closure |
paths |
|---|---|---|
|
219013232 |
23 |
|
219054112 |
25 |
The environment costs 40880 bytes and two paths over its interpreter, and those two are the install of the package and the environment itself. The compiled module is 15824 bytes of that.
By name, the closure of the environment holds glibc, gcc-lib, libgcc and
xgcc-libgcc, and the closure of python3 holds the same four. It holds no
gcc-wrapper and no binutils. So the argument buys a build step and adds
nothing that a user of the environment carries.
Measured on 2026-08-12, with nix path-info --closure-size over the private
store of the test, and a comparison of the two path lists by name. The two
stores hold two revisions of nixpkgs, so a comparison by store path answers
nothing here and a comparison by name answers the question.
The argument moved every planner, and no environment¶
buildTools changed graph.nix and the planner, so the .drv of every
planner moved. The environment did not:
$ nix build ... --expr '(import <before>/uv.nix { ... project = <before>/projects/pure-app; }).env'
$ nix build ... --expr '(import <after>/uv.nix { ... project = <after>/projects/pure-app; }).env'
/nix/store/2ppg8xs82055lxw8vg22a1zbbvjnmfjc-pure-app
/nix/store/2ppg8xs82055lxw8vg22a1zbbvjnmfjc-pure-app
Measured on 2026-08-12, over a copy of the example at the commit before the
argument. A floating output takes its path from what the build wrote, so
one path means one content, and this is the same proof that the change to the
menu used. Under input addressing the path would have moved with the .drv of
the planner, and a reader could not tell a rewritten plan from a rewritten
environment.
A caller that names no tool gets no buildTools key in the document of the
planner, and a package that names none gets no key on its node. The rule is
one shape for one meaning, and projects/native-app is the one project of the
eight that carries either.
NIX_CFLAGS_COMPILE reaches nothing here, and CPPFLAGS does¶
The first version of buildTools set NIX_CFLAGS_COMPILE and NIX_LDFLAGS,
which is what a mkDerivation sets. lxml then failed at the link step:
lxml> ld.bfd: cannot find -lz: No such file or directory
zlib was in the list, so the variable reached nothing. add-flags.sh of the
wrapper says why. It reads
NIX_CFLAGS_COMPILE_x86_64_unknown_linux_gnu, and mangleVarList copies the
plain name into that one for each role that accumulateRoles found:
accumulateRoles() {
declare -ga role_suffixes=()
if [ "${NIX_CC_WRAPPER_TARGET_HOST_x86_64_unknown_linux_gnu:-}" ]; then
role_suffixes+=('')
fi
...
}
The setup hook of the wrapper exports that variable, and a node of this
graph runs no setup hook. So role_suffixes is empty, nothing is copied, and
the salted variable stays empty.
CPPFLAGS and LDFLAGS are what customize_compiler of
setuptools._distutils reads, and it appends each one to the command that
sysconfig gives. It reads CFLAGS as well and replaces that value, so
graph.nix does not set CFLAGS.
Read the source, and do not infer the rule from the name of a variable. This is the second time in this lab that a guess about a tool cost a build.
-g0 is 58% of the closure of a package that compiles¶
projects holds no package that compiles, so this measurement used a project
of its own: psutil and lxml, both under tool.uv.no-binary-package, with
buildTools naming stdenv.cc for the first and stdenv.cc, libxml2,
libxslt, zlib and pkg-config for the second. Both built, and both import.
the environment |
closure |
paths |
|---|---|---|
as first built |
545822056 |
41 |
with |
228729808 |
28 |
Measured on 2026-08-12, with
nix path-info --closure-size over the private store of the test.
A .so with debug information names every include directory of its build.
strings over _psutil_linux.abi3.so of the first row finds these, and the
scan of the output makes each one a reference:
/nix/store/…-gcc-15.3.0/lib/gcc/x86_64-unknown-linux-gnu/15.3.0/include
/nix/store/…-glibc-2.42-67-dev/include
/nix/store/…-python3-3.14.6/include/python3.14
So the first row carried gcc, glibc-dev, linux-headers, the dev output
of each library, and the four libraries that gcc itself needs: gmp, isl,
mpfr and libmpc. stdenv of nixpkgs strips a binary and removes the same
strings, and a node of this graph runs no stdenv.
With -g0, the environment adds exactly five paths over its interpreter:
libxml2-2.15.3 libxslt-1.1.45 lxml-6.1.1 native-real psutil-7.2.2
libxml2 and libxslt are real: the .so of lxml links against both.
Nothing of the compiler is there.
cffi asks pkg-config and does not need the answer¶
projects/native-app is the gate over two packages of PyPI, and
check-native.nix names pkg-config for cffi because cffi asks it first.
The build succeeds with no pkg-config at all. _ask_pkg_config of
cffi/setup.py fails, cffi falls back to a hand-written list of directories,
and the -isystem of CPPFLAGS answers before that list does:
'-g0', '-isystem', '/nix/store/…-gcc-wrapper-15.3.0/include',
'-DFFI_BUILDING=1', '-I/usr/include/ffi', '-I/usr/include/libffi',
'-I/nix/store/…-python3-3.14.6/include/python3.14'
The header is what the build needs. Without libffi.dev in the list the same
build stops:
cffi> src/c/_cffi_backend.c:15:10: fatal error: ffi.h: No such file or directory
Measured on 2026-08-12, by building check-native.nix three times: with the
argument as written, with pkg-config removed, and with the two outputs of
libffi removed.
So no test of this repository covers PKG_CONFIG_PATH. The variable is
there because a build that reads it is ordinary, and the two gates cover
PATH, CPPFLAGS and LDFLAGS alone.
The wheel says which interpreter it is for¶
The fixture backend reads sysconfig, and it writes
demo_native-1.0.0-cp314-cp314-linux_x86_64.whl with Root-Is-Purelib: false
when the wheel holds a .so, and py3-none-any with Root-Is-Purelib: true
when it does not. The module arrives as
_speedup.cpython-314-x86_64-linux-gnu.so, which is EXT_SUFFIX of the
interpreter that built it.
A wheel that carries a binary may not say py3-none-any. That tag means
"any interpreter, any platform", and a compiled module runs on neither.
The project declares its build tools, and the declaration is the argument¶
[tool.uv] no-binary-package is a setting that uv keeps in pyproject.toml
and writes into no lock file, and uv.nix reads it there. A build tool is the
same kind of setting, and PEP 518 gives [tool.*] to exactly this:
[tool.uv]
no-binary-package = ["cffi", "psutil"]
[tool.ddrn.build-tools]
psutil = ["stdenv.cc"]
cffi = ["stdenv.cc", "pkg-config", "libffi.dev", "libffi"]
Each value names an attribute of nixpkgs, and a dot walks into it. This is
a lookup and not a rule: libffi.dev names the dev output of one package,
and nothing globs, compares a version or evaluates a marker. So the boundary
that docs/NIX-FACTS.md records holds. lib.hasAttrByPath refuses a name that
nixpkgs does not answer, and the message names the package that asked:
error: uv.nix: [tool.ddrn.build-tools] of native-app gives 'psutil' the build
tool 'stdenv.cxx', and nixpkgs holds no attribute of that name.
The two routes end at one derivation. projects/native-app declares the
table, and the same two entries as a buildTools argument give the same
planner:
$ nix eval --impure --raw --expr \
'(import ./uv.nix { project = ./projects/native-app; nanopynixEnv = "…"; }).drvPath'
/nix/store/b4bin047ms8yybwrpglbcrhnq2p4s7bk-venv-planner.drv
$ nix eval --impure --raw --expr \
'let pkgs = import <nixpkgs> {}; in (import ./uv.nix { inherit pkgs; … buildTools = { … }; }).drvPath'
/nix/store/b4bin047ms8yybwrpglbcrhnq2p4s7bk-venv-planner.drv
Measured on 2026-08-12. The argument replaces the entry of each package that it names, and an empty list takes the tools of one package away.
No check reads the key here. A package name that this target does not install is not a mistake: one lock file resolves to more than one environment, so a table that covers a workspace names packages that a single member never builds.
What this buys is one file. uv2nix asks for an overlay in the Nix language
for the same decision, so a user of it describes the project in two places and
in two languages. nix build --file uv.nix env --arg project ./. now needs no
second argument for a project that compiles.
Three decisions belong to the project, and three belong to the build¶
[tool.ddrn] holds one key for each decision that uv.lock does not record
and that the project answers the same way every time:
key |
what it decides |
how the argument overrides it |
|---|---|---|
|
what one package needs to build |
per package |
|
which package nixpkgs supplies rather than the index |
per package |
|
which member ships an archive rather than a redirect |
the whole list |
from-sdist takes a different shape because it is a list. A table merges
per key, and a list cannot. So fromSdist of uv.nix defaults to null,
which reads the project, and a list replaces the table. select, extras and
groups already take that shape, for the same reason.
Three others stay an argument, and the reason is that they change for each
build of one project. select, extras and groups differ for each
environment of one lock file, and projects/workspace builds two environments
out of one file. application decides the shape of one output, and the same
project ships as a program in one place and as an environment in another.
Measured on 2026-08-12, over projects/binary-app and projects/packaged-app
with the table written into a copy of each. tests/test_projects.py, in
TestTheProjectDeclaresTheOtherTwoDecisions, holds the comparison: the table
and the argument give one .drv, and a different value gives another one.
PEP 751, and what packaging already does with it¶
PEP 751 gives one lock file format that more than one tool writes. uv 0.12.1
exports it, and packaging 26.2 reads it. Measured on 2026-08-12.
$ uv export --format pylock.toml --frozen --all-packages --all-groups \
--python <interpreter> -o pylock.toml
The output name must be pylock.toml, or pylock.<name>.toml with a name that
holds no dot. uv refuses any other name. --python is necessary here, because
uv downloads an interpreter that NixOS cannot start.
Over projects/workspace, the export is 3274 bytes and uv.lock is 8874.
packaging.pylock does the work that _uvlock does¶
packaging/pylock.py is 33890 bytes, and it holds Pylock, Package,
PackageWheel, PackageSdist, PackageDirectory, PackageVcs and
PackageArchive. Pylock holds from_dict, validate, to_dict and
select:
Pylock.select(*, environment=None, tags=None, extras=None, dependency_groups=None)
-> Iterator[tuple[Package, PackageVcs | PackageDirectory | PackageArchive | PackageWheel | PackageSdist]]
That one method is the marker evaluation, the extras and the groups, and the choice of a wheel by its PEP 425 tags. It gives the chosen artefact beside each package. Over the export above it gave 13 packages of 13: a wheel for each package of an index, and a directory for each member of the workspace.
Each awkward source kind survives, in a standard spelling¶
the source |
what |
what |
|---|---|---|
a member of the workspace |
|
|
a tarball of the project |
|
|
a registry that is a directory |
|
|
a git source |
one url, as |
|
The git row is the one that would delete code. ./uv-git.nix parses that
url, and PEP 751 gives the same three parts as fields.
The local registry loses no hash. uv.lock writes sdist = { path = "crated-1.0.0.tar.gz" } with no hash of its own, and the export writes
hashes = {}.
builtins.fromTOML needs one more experimental feature, and nothing else¶
PEP 751 writes upload-time as a TOML datetime, and uv writes one for each
artefact. The default reader refuses it:
$ nix eval --impure --expr 'builtins.fromTOML (builtins.readFile ./pylock.toml)'
error: while parsing TOML: Dates and times are not supported
parse-toml-timestamps is the whole answer.
src/libexpr/primops/fromTOML.cc:131-149 handles local_datetime,
offset_datetime, local_date and local_time, and it raises that message
only when Xp::ParseTomlTimestamps is disabled. A timestamp arrives as an
attribute set:
$ nix eval --impure --extra-experimental-features parse-toml-timestamps --expr '...'
{ name = "editables"; packages = 13; uploadTime = { _type = "timestamp"; value = "2026-04-14T10:39:03Z"; }; }
Measured on 2026-08-12, on both bands: Nix 2.34.8 of this machine and the
2.36.0pre-ddnix of the lab. The exports of projects/vendored-app,
projects/local-index and projects/git-app each read as well.
So the cost is one more entry beside the four features that this example needs already. No patch of Nix, and no derivation that rewrites the file. A derivation would be the worse answer twice over: it would rewrite a lock file before a reader saw it, and reading its output during evaluation is an import from derivation, which this example does not do today.
upload-time is the only datetime that PEP 751 defines. packaging.pylock
gives it to PackageWheel, PackageSdist and PackageArchive, and to no
other class.
What the export does not carry¶
The export of projects/workspace holds no extras key, no
dependency-groups key, no default-groups key, and no dependencies on any
package. uv resolves the selection while it exports, and it writes the result.
So one pylock.toml is one selection. uv.nix takes select, extras
and groups as arguments, and tests/test_projects.py builds three
environments from the one uv.lock of projects/workspace. Each of those
would need an exported file of its own.
The format itself does not force this. Pylock holds extras,
dependency_groups and default_groups, and select takes the first two. The
exporter of uv is what flattens them.
The export also carries no [package.metadata]. pydd.python._freshness
compares that table with the pyproject.toml of each member, and it is how
this lab refuses a stale lock file. A pylock.toml records no declaration to
compare against, so a checked-in export goes stale with no reader to notice.
A reader of the standard format is half the size, and it parses nothing¶
examples/venv-graph/pylock.nix is that reader, written as a spike.
examples/venv-graph/tests/test_pylock.py runs it over an export of each of the
seven projects, and 18 tests pass in 5.9 s. Measured on 2026-08-12, with
pytest ddrn/examples/venv-graph/tests/test_pylock.py.
file |
lines |
what it reads |
|---|---|---|
|
846 |
the whole of |
|
298 |
the source of each package, and its artefacts |
|
94 |
one |
|
188 |
the whole of |
So the comparable read is 392 lines against 188. The 204 lines that go are
the ones that decide what a source is. uv.nix reads package.source and
asks which of five keys it holds, then joins a registry against a path, then
splits a url into a repository, a subdirectory and a revision. PEP 751 gives
each shape a key of its own, so pylock.nix asks which key is present and
reads it.
uv-git.nix disappears whole: vcs.url, vcs.commit-id and
vcs.subdirectory are three fields, and no reader parses them apart.
One translation arrives in return. uv writes sha256:<hex> as a string, and
PEP 751 writes a table of one algorithm to one digest. That is hashOf, and it
is 12 lines.
What the spike does not answer¶
The spike reads a lock file, and it builds nothing. default.nix takes the
menu that pylock.nix gives, so a planner over it is a small change and not a
proven one. Three things stay open, and each one is a fact of the section
above and not of the reader:
One
pylock.tomlis one selection, soselect,extrasandgroupsneed an exported file of each.The export carries no
[package.metadata], sopydd.python._freshnesshas nothing to compare.packaging.pylockreads the same file in the planner, and the spike calls it nowhere.
The order that these facts imply¶
Keep the two halves apart, the way pyproject.nix and uv2nix keep them
apart. graph.nix is the base and reads no lock file. plan.py produces the
plan document. A lock reader is anything that fills nodes. Then a uv.lock
reader and a poetry.lock reader are two readers over one base, and the base
does not change when the second one arrives.
Split
plan.pyinto a base and a reader, with the plan document as the interface it already is. Nothing else changes, and the shape is the one that the next four steps need.Write the
uv.lockreader, and drop the hand writtenlock.json. The lock already holds the dependency edges, so a closure walk replaces the flatrootslist.lock.jsonof this lab holdspackage,version,filename,kind,url,sha256andmarker, and no edge at all, which is why the walk has nothing to walk today.Then forked resolution, which the same reader gets almost free, and which costs uv2nix most of
lock1.nix.A planner node for each source distribution: done.
ddrn/examples/venv-graph/scripts/plan-sdist.pyreadspyproject.tomlout of the archive, resolves the backend against the same menu, and submits the graph that builds the distribution.lock.jsonno longer carries abackendor abackend-requiresforidna, and the environment that comes out is the same store path as before,rfy15b0q3y3nmrld2wbw3j9arvjrf4wl-demo-venv.The run reads:
plan-sdist: idna-3.18.tar.gz builds through flit_core.buildapi plan-sdist: backend flit_core-4.0.2-py3-none-any.whl for flit_core<5,>=3.11
The planner is instantiated outside the sandbox, and it has to be. A planner cannot read a store path that the planner itself added, so
builtins.outputOfon a derivation written inside fails.docs/NIX-FACTS.mdgives the measurement.default.nixtherefore instantiates one planner for each source distribution of the lock file, to a fixed depth, and hands each.drvin the way the menu hands in a fetch.This closes the gap that uv2nix documents as an override and that poetry2nix documents as burnout. It is the step that no other tool can take.
Then the nixpkgs override, by tier 4. A pre-processing step outside the sandbox instantiates the candidates and passes a menu of
{ name, version, drv, out }. The planner names the chosen.drvin the node that it writes. This also needs thewrite_binunwrapping plus an interpreter check, because a package that is in the lock takes its edges from the lock, and the nixpkgs package takes its edges from nixpkgs.Then the merge semantics of
make_venv.py: recursion into a directory for a namespace package, bytecode collision skipping, and the skip and ignore patterns.A
poetry.lockreader is then a new reader over the same base, and it is new capability rather than a port, because the pyproject.nix ecosystem has no such layer.
Passing the builders of pyproject.nix into the planner is a separate
question, and tier 4 answers the part of it that matters. Tier 1 works today
and buys the pure Nix libraries. Tier 2 needs QueryPathInfo in the allowlist,
and it would still hand the planner derivations whose sources the sandbox
cannot fetch, so this lab does not need it. Tier 3 is what the lab does now.
Tier 4 is the step 5 above, and it needs no patched Nix. None of the steps
waits on tier 2.