Skip to content

env_version cannot see user- or system-level uv.toml, and uv has no "pyproject.toml only" mode #176

Description

@EiffL

env_version reads the project's uv settings correctly, but uv also merges user-level and system-level configuration underneath them, and lc cannot see that. So an install setting on the machine can change which artifacts a sync materializes while env_version — and therefore every manifest — reports the environment as unchanged.

This is a known, deliberate residue rather than a regression: hashing machine state would make one commit answer differently on two hosts, so a colleague's clone would report every output as behind. It is recorded as a limitation in CLAUDE.md; this issue is to decide how to close it.

What is already handled

identity._uv_config reads settings the way uv itself does, including uv's replacement precedence: a uv.toml beside pyproject.toml makes [tool.uv] ignored wholesale, so hashing both would report two environments where uv installs one. Measured on uv 0.12.5:

pyproject [tool.uv] no-binary = true       →  no_binary: All
  + uv.toml (no-build = true)              →  no_binary: None, no_build: All

uv warns about the pair itself, and project.tool_warnings() already lifts that into the report.

What is not

User-level (~/.config/uv/uv.toml, %APPDATA%\uv\uv.toml) and system-level (/etc/uv/uv.toml, %PROGRAMDATA%\uv\uv.toml) configuration is merged underneath the project's. Measured:

pyproject [tool.uv] no-binary = true  +  ~/.config/uv/uv.toml no-build = true
  →  no_binary: All,  no_build: All          # both applied; env_version sees only the first

Two properties make this narrower than it first looks, and both come from uv's docs:

  • Those levels can only be uv.toml, never pyproject.toml. So the surface is two known paths, and a check for it would be complete rather than a heuristic.
  • Arrays concatenate across levels, project entries first. A user-level no-binary-package = ["h5py"] therefore adds to the project's list rather than replacing it — so detecting the file's presence is not enough; the check has to look at which keys it sets.

Why there is no flag for this

The obvious fix — tell uv to consider only pyproject.toml — does not exist. Every lever treats [tool.uv] as one of the discovered configuration files. Measured, uv 0.12.5:

attempt result
--config-file pyproject.toml refused: "expects to receive a uv.toml file, not a pyproject.toml", then a parse error
--config-file <empty uv.toml> kills the user-level file and the project's [tool.uv] — identical to --no-config
--no-config / UV_NO_CONFIG=1 disables all persistent configuration, the project's own included

--no-config is finer-grained than the docs suggest, but not in a way that helps. It still reads pyproject.toml as the project definition — a lock built with it carries the declared dependencies, requires-python, [dependency-groups], [tool.uv] default-groups and [tool.uv.sources]. What it drops is the settings slice:

kept under --no-config dropped
[project] dependencies, requires-python no-binary, no-binary-package
[dependency-groups] no-build, no-build-package
[tool.uv] default-groups no-build-isolation, no-build-isolation-package
[tool.uv.sources] config-settings
[tool.uv.index]

So seven of the eight settings in _INSTALL_SETTINGS would vanish, which would shrink env_version to sha256(uv.lock ‖ .python-version ‖ default-groups) with no machine-level hole at all. Two things block adopting it, and neither is about identity:

  • [tool.uv.index] is dropped — the canonical GPU mechanism (pin download.pytorch.org). Worse, the failure is asymmetric: [tool.uv.sources] survives while the index does not, so lc init's uv lock resolves against PyPI and the project half-works rather than failing loudly.
  • no-build-isolation-package is dropped — some packages do not install without it, so lc would fail to sync an environment the researcher can sync by hand.

Both are lc breaking working projects, not lc disagreeing with them.

Options

  1. Refuse what we cannot honour. Refuse a project-level uv.toml (blocked in convergence, refused at run start), and refuse a user- or system-level uv.toml that sets one of the audited install settings — only those keys, so a cache-dir, link-mode or index-credentials config is untouched. The invariant "only pyproject.toml decides the environment" then holds by enforcement, env_version reads [tool.uv] alone, and _uv_config's precedence handling is deleted. Cost: the user-level half reaches outside the project and could block someone over a long-standing global setting.
  2. Report it. Add the user/system-level finding to scan_lock's advisory tier, beside sdist_built and non_default_groups. Cheap and honest; the hole stays open, annotated.
  3. --no-config on the sync/run hops only, never on uv lock. Both blockers above are resolution-time, so this could close the machine-level hole for everything except default-groups while leaving index pinning intact. Unmeasured: whether the settings a lock already resolved make the sync-side ones moot. That measurement is the prerequisite for taking this seriously.
  4. Leave it, as today: recorded in CLAUDE.md, unguarded.

Layer 3 is the natural home for anything stronger than (2) — scrubbing the ambient UV_* namespace is already the launcher's job, and this is the same class of fact: machine state steering an environment that is supposed to be a function of the repository.

Measurements in this issue are uv 0.12.5 on Linux, via uv sync --show-settings and by building locks and comparing installed packages.

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions