Skip to main content

dev_prune/adapters/
uv.rs

1// Copyright 2026 VKrishna04
2// SPDX-License-Identifier: Apache-2.0
3
4// uv package manager adapter for Python projects.
5
6use super::venv::{BASELINE_DISTRIBUTIONS, installed_distributions, normalize_package_name};
7use super::{
8    BloatDir, EnforcePolicy, PackageManager, dir_size, enforce_two_tier, run_command_with_timeout,
9};
10use crate::declared::{Gap, RebuildCheck, label_of, on_path, path_of, relative_parts};
11use anyhow::{Result, anyhow};
12use std::collections::HashSet;
13use std::fs;
14use std::path::Path;
15
16/// Adapter for uv-based Python projects.
17pub struct Uv;
18
19/// Whether `.venv` was created by uv itself — uv stamps a `uv = <version>` key into
20/// `pyvenv.cfg`. A venv without the stamp was built by some other tool, and uv can make
21/// no claims about what is inside it.
22fn venv_is_uv_managed(path: &Path) -> bool {
23    fs::read_to_string(path.join(".venv").join("pyvenv.cfg"))
24        .map(|content| {
25            content.lines().any(|line| {
26                line.trim_start()
27                    .strip_prefix("uv")
28                    .is_some_and(|rest| rest.trim_start().starts_with('='))
29            })
30        })
31        .unwrap_or(false)
32}
33
34/// Every package name `uv.lock` pins, normalised.
35///
36/// `uv.lock` records the full transitive closure — including the project itself — so a
37/// plain name scan is enough; no dependency graph needed. `None` when no names could be
38/// read at all, in which case the caller skips the drift comparison rather than refusing
39/// on a lockfile format this scan does not understand.
40pub(super) fn lockfile_package_names(lockfile: &Path) -> Option<HashSet<String>> {
41    let content = fs::read_to_string(lockfile).ok()?;
42    let mut names = HashSet::new();
43    let mut in_package = false;
44    for raw in content.lines() {
45        let line = raw.trim();
46        if line.starts_with('[') {
47            in_package = line == "[[package]]";
48            continue;
49        }
50        if !in_package {
51            continue;
52        }
53        if let Some(rest) = line.strip_prefix("name")
54            && let Some(value) = rest.trim_start().strip_prefix('=')
55        {
56            let value = value.trim().trim_matches('"');
57            if !value.is_empty() {
58                names.insert(normalize_package_name(value));
59            }
60            // Only the first `name` in each `[[package]]` entry is the package's own.
61            in_package = false;
62        }
63    }
64    (!names.is_empty()).then_some(names)
65}
66
67/// Installed distributions in `.venv` that `uv.lock` does not record.
68///
69/// Those were installed ad hoc (`uv pip install …`) and `uv sync` after deletion would
70/// not bring them back — exactly what this tool promises never to lose.
71pub(super) fn unlocked_packages(path: &Path, locked: &HashSet<String>) -> Vec<String> {
72    let Some(installed) = installed_distributions(&path.join(".venv")) else {
73        return Vec::new();
74    };
75    let mut extras: Vec<String> = installed
76        .keys()
77        .filter(|name| !locked.contains(*name) && !BASELINE_DISTRIBUTIONS.contains(&name.as_str()))
78        .cloned()
79        .collect();
80    extras.sort();
81    extras
82}
83
84impl PackageManager for Uv {
85    fn name(&self) -> &'static str {
86        "uv"
87    }
88
89    fn detect(&self, path: &Path) -> bool {
90        let uv_lock = path.join("uv.lock");
91        if uv_lock.exists() {
92            return true;
93        }
94
95        let pyproject = path.join("pyproject.toml");
96        if pyproject.exists()
97            && let Ok(content) = fs::read_to_string(&pyproject)
98            && content.contains("[tool.uv]")
99        {
100            return true;
101        }
102
103        false
104    }
105
106    fn bloat_dirs(&self, path: &Path) -> Vec<BloatDir> {
107        let mut dirs = Vec::new();
108        let venv_path = path.join(".venv");
109        if venv_path.exists() {
110            dirs.push(BloatDir {
111                name: ".venv".to_string(),
112                path: venv_path.clone(),
113                size_bytes: dir_size(&venv_path),
114                shared_bytes: 0,
115            });
116        }
117        dirs
118    }
119
120    /// Enforces the lockfile without writing it.
121    ///
122    /// `uv lock --locked` asserts that `uv.lock` is already up to date with
123    /// `pyproject.toml` and exits non-zero instead of rewriting it when it is not.
124    /// Plain `uv lock` is the writing form, for the case where no lockfile exists yet.
125    fn enforce_lockfile(&self, path: &Path, policy: EnforcePolicy) -> Result<()> {
126        let lockfile = path.join("uv.lock");
127
128        // Generating a lockfile from `pyproject.toml` only proves the *declared*
129        // dependencies resolve — it says nothing about what is actually installed in
130        // `.venv`. When the venv was not even created by uv, `uv sync` against that
131        // fresh lock could rebuild a different environment than the one deleted, so
132        // refuse instead of manufacturing proof.
133        if !lockfile.exists() && path.join(".venv").exists() && !venv_is_uv_managed(path) {
134            return Err(anyhow!(
135                "`pyproject.toml` declares `[tool.uv]` but there is no `uv.lock`, and \
136                 `.venv` was not created by uv — a generated lockfile could not prove the \
137                 environment's contents are recoverable. Rebuild the environment under uv \
138                 first: `uv lock` then `uv sync`."
139            ));
140        }
141
142        enforce_two_tier(
143            &lockfile,
144            "uv",
145            &["lock", "--locked"],
146            &["lock"],
147            path,
148            policy,
149        )?;
150
151        // The environment can hold packages the lockfile never recorded — a
152        // `uv pip install foo` nobody wrote back. `uv.lock` pins the full transitive
153        // closure, so anything installed but absent from it is recoverable from
154        // nowhere, which is exactly what this tool promises never to delete.
155        if let Some(locked) = lockfile_package_names(&lockfile) {
156            let extras = unlocked_packages(path, &locked);
157            if !extras.is_empty() {
158                let shown = extras
159                    .iter()
160                    .take(10)
161                    .cloned()
162                    .collect::<Vec<_>>()
163                    .join(", ");
164                let suffix = if extras.len() > 10 {
165                    format!(", … and {} more", extras.len() - 10)
166                } else {
167                    String::new()
168                };
169                return Err(anyhow!(
170                    "`.venv` holds {} package(s) that uv.lock does not record \
171                     ({shown}{suffix}). They were installed ad hoc and `uv sync` would \
172                     not bring them back. Record them first: `uv add <package>`.",
173                    extras.len()
174                ));
175            }
176        }
177        Ok(())
178    }
179
180    fn restore(&self, path: &Path, timeout: std::time::Duration) -> Result<()> {
181        self.restore_named(path, ".venv", None, timeout)
182    }
183
184    /// `uv sync --python 3.12` rebuilds on that interpreter and, unlike every other
185    /// route here, *downloads* it when the machine does not have it — which is why the
186    /// tag is passed straight through without an availability check first.
187    fn restore_named(
188        &self,
189        path: &Path,
190        _dir_name: &str,
191        runtime: Option<&str>,
192        timeout: std::time::Duration,
193    ) -> Result<()> {
194        match runtime.filter(|tag| super::is_valid_runtime_tag(tag)) {
195            Some(tag) => run_command_with_timeout("uv", &["sync", "--python", tag], path, timeout),
196            None => run_command_with_timeout("uv", &["sync"], path, timeout),
197        }
198    }
199
200    /// The interpreter `.venv` was built with, so a restore can rebuild on it.
201    fn runtime_tag(&self, path: &Path, dir_name: &str) -> Option<String> {
202        super::venv_runtime_tag(&path.join(dir_name))
203    }
204
205    fn lockfiles(&self) -> &'static [&'static str] {
206        &["uv.lock"]
207    }
208
209    /// The comparison `enforce_lockfile` refuses on, as data: distributions in `.venv`
210    /// that `uv.lock` does not pin.
211    fn drift(&self, path: &Path) -> Vec<super::DriftReport> {
212        let Some(locked) = lockfile_package_names(&path.join("uv.lock")) else {
213            return Vec::new();
214        };
215        let extras = unlocked_packages(path, &locked);
216        if extras.is_empty() {
217            return Vec::new();
218        }
219        vec![super::DriftReport {
220            directory: ".venv".to_string(),
221            unrecorded: extras,
222            record_command: "uv add <package>",
223        }]
224    }
225}
226
227/// The rebuild check for `uv` declarations.
228pub(crate) struct UvScripts;
229
230impl RebuildCheck for UvScripts {
231    fn tools(&self) -> &'static [&'static str] {
232        &["uv"]
233    }
234
235    fn gap(&self, repo_path: &Path, _tool: &str, args: &[&str]) -> Option<Gap> {
236        uv_script_gap(repo_path, args)
237    }
238}
239
240/// What `pyproject.toml` had to say about the name `uv run` was given.
241struct PyprojectLookup {
242    /// `[project.scripts]` — the one table uv resolves entry points from.
243    defined: bool,
244    /// The name appears as a requirement, so a dependency's console script provides it.
245    from_dependency: bool,
246    /// `[tool.uv.scripts]` names it. That table is not one uv reads.
247    in_tool_uv_scripts: bool,
248}
249
250/// A `uv run` target that `pyproject.toml` does not provide.
251fn uv_script_gap(repo_path: &Path, args: &[&str]) -> Option<Gap> {
252    let mut dir = None;
253    let mut saw_run = false;
254    let mut script = None;
255    let mut i = 0;
256    while i < args.len() {
257        let arg = args[i];
258        if arg == "--" {
259            return None;
260        }
261        if let Some(value) = arg
262            .strip_prefix("--directory=")
263            .or_else(|| arg.strip_prefix("--project="))
264        {
265            dir = Some(value.to_string());
266        } else if arg == "--directory" || arg == "--project" {
267            dir = Some((*args.get(i + 1)?).to_string());
268            i += 1;
269        } else if arg == "run" {
270            saw_run = true;
271        } else if arg.starts_with('-') {
272            return None;
273        } else if saw_run {
274            script = Some(arg.to_string());
275            break;
276        } else {
277            // `uv sync`, `uv pip install …` — not a script invocation at all.
278            return None;
279        }
280        i += 1;
281    }
282    let script = script?;
283    // `uv run ./tools/gen.py` runs a file. Whether that file is there is not a question
284    // `pyproject.toml` answers.
285    if script.contains(['/', '\\']) || script.ends_with(".py") {
286        return None;
287    }
288    // `uv run` also falls back to anything already on `PATH`.
289    if on_path(&script) {
290        return None;
291    }
292    let parts = relative_parts(dir.as_deref())?;
293    let manifest = label_of(&parts, "pyproject.toml");
294    let content = fs::read_to_string(path_of(repo_path, &parts, "pyproject.toml")).ok()?;
295    let found = pyproject_lookup(&content, &script)?;
296    if found.defined || found.from_dependency {
297        return None;
298    }
299    // A dependency's console script is not named in `pyproject.toml` at all when the
300    // requirement is only pinned in the lockfile.
301    if lockfile_records(&path_of(repo_path, &parts, "uv.lock"), &script) {
302        return None;
303    }
304    let fix = if found.in_tool_uv_scripts {
305        format!(
306            "`[tool.uv.scripts]` is not a table uv reads — it is silently ignored. Move \
307             `{script}` to `[project.scripts]` in `{manifest}`, or fix the command."
308        )
309    } else {
310        format!("Add `{script}` to `[project.scripts]` in `{manifest}`, or fix the command.")
311    };
312    Some(Gap {
313        what: format!("`{manifest}` defines no `{script}` entry point"),
314        fix,
315    })
316}
317
318/// Where `pyproject.toml` does and does not mention one name.
319///
320/// Line-based for the same reason the poetry adapter's read of this file is: there is no
321/// TOML dependency in this crate, and table headers only ever start a line. Any shape
322/// the scan cannot see through returns `None`, which allows the prune.
323fn pyproject_lookup(content: &str, wanted: &str) -> Option<PyprojectLookup> {
324    let mut found = PyprojectLookup {
325        defined: false,
326        from_dependency: false,
327        in_tool_uv_scripts: false,
328    };
329    let mut table = "";
330    for raw in content.lines() {
331        let line = raw.trim();
332        if line.is_empty() || line.starts_with('#') {
333            continue;
334        }
335        if line.starts_with('[') {
336            table = line;
337            continue;
338        }
339        // A dotted key puts entry points somewhere this scan does not look.
340        if line.starts_with("project.scripts") {
341            return None;
342        }
343        // Any quoted requirement anywhere in the file — `"pytest>=8"` in a dependency
344        // array, wherever that array happens to be written. `uv run pytest` runs a
345        // console script that a dependency installed, and no table here lists it.
346        if quoted_strings(line).any(|value| same_name(requirement_head(value), wanted)) {
347            found.from_dependency = true;
348        }
349        let Some((key, _)) = line.split_once('=') else {
350            continue;
351        };
352        let key = key.trim().trim_matches(['"', '\'']);
353        match table {
354            // An inline `scripts = { regen = "…" }` is a shape this cannot read.
355            "[project]" if key == "scripts" => return None,
356            "[project.scripts]" | "[project.gui-scripts]" if same_name(key, wanted) => {
357                found.defined = true;
358            }
359            "[tool.uv.scripts]" if same_name(key, wanted) => found.in_tool_uv_scripts = true,
360            _ => {}
361        }
362    }
363    Some(found)
364}
365
366/// The double-quoted runs of a line.
367fn quoted_strings(line: &str) -> impl Iterator<Item = &str> {
368    line.split('"').skip(1).step_by(2)
369}
370
371/// The distribution name at the front of a requirement string like `pytest>=8,<9`.
372fn requirement_head(raw: &str) -> &str {
373    raw.trim()
374        .split(|c: char| !(c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.'))
375        .next()
376        .unwrap_or("")
377}
378
379/// Python treats `-` and `_` in a distribution or entry-point name as the same character.
380fn same_name(a: &str, b: &str) -> bool {
381    a.replace('_', "-")
382        .eq_ignore_ascii_case(&b.replace('_', "-"))
383}
384
385/// Does a `uv.lock` record a package under this name?
386fn lockfile_records(lockfile: &Path, wanted: &str) -> bool {
387    let Ok(content) = fs::read_to_string(lockfile) else {
388        return false;
389    };
390    content.lines().any(|raw| {
391        raw.trim()
392            .strip_prefix("name")
393            .and_then(|rest| rest.trim_start().strip_prefix('='))
394            .is_some_and(|value| same_name(value.trim().trim_matches('"'), wanted))
395    })
396}
397
398#[cfg(test)]
399mod tests {
400    use super::*;
401    use std::fs::File;
402    use std::io::Write;
403    use tempfile::tempdir;
404
405    #[test]
406    fn test_name() {
407        let adapter = Uv;
408        assert_eq!(adapter.name(), "uv");
409    }
410
411    #[test]
412    fn test_detect_positive_lock() {
413        let dir = tempdir().unwrap();
414        File::create(dir.path().join("uv.lock")).unwrap();
415
416        let adapter = Uv;
417        assert!(adapter.detect(dir.path()));
418    }
419
420    #[test]
421    fn test_detect_positive_toml() {
422        let dir = tempdir().unwrap();
423        let mut file = File::create(dir.path().join("pyproject.toml")).unwrap();
424        writeln!(file, "[tool.uv]").unwrap();
425
426        let adapter = Uv;
427        assert!(adapter.detect(dir.path()));
428    }
429
430    #[test]
431    fn test_detect_negative() {
432        let dir = tempdir().unwrap();
433
434        let adapter = Uv;
435        assert!(!adapter.detect(dir.path()));
436    }
437
438    #[test]
439    fn test_bloat_dirs_present() {
440        let dir = tempdir().unwrap();
441        fs::create_dir(dir.path().join(".venv")).unwrap();
442
443        let adapter = Uv;
444        let dirs = adapter.bloat_dirs(dir.path());
445        assert_eq!(dirs.len(), 1);
446        assert_eq!(dirs[0].name, ".venv");
447    }
448
449    #[test]
450    fn test_bloat_dirs_absent() {
451        let dir = tempdir().unwrap();
452
453        let adapter = Uv;
454        let dirs = adapter.bloat_dirs(dir.path());
455        assert!(dirs.is_empty());
456    }
457
458    #[test]
459    fn a_uv_stamped_pyvenv_cfg_marks_the_venv_as_uv_managed() {
460        let dir = tempdir().unwrap();
461        let venv = dir.path().join(".venv");
462        fs::create_dir(&venv).unwrap();
463        let mut cfg = File::create(venv.join("pyvenv.cfg")).unwrap();
464        writeln!(cfg, "home = /usr/bin").unwrap();
465        writeln!(cfg, "uv = 0.5.9").unwrap();
466        assert!(venv_is_uv_managed(dir.path()));
467    }
468
469    #[test]
470    fn a_pip_built_venv_is_not_mistaken_for_a_uv_one() {
471        let dir = tempdir().unwrap();
472        let venv = dir.path().join(".venv");
473        fs::create_dir(&venv).unwrap();
474        // `uvloop` starts with "uv" but is not the uv stamp.
475        let mut cfg = File::create(venv.join("pyvenv.cfg")).unwrap();
476        writeln!(cfg, "home = /usr/bin").unwrap();
477        writeln!(cfg, "uvloop = 1.0").unwrap();
478        assert!(!venv_is_uv_managed(dir.path()));
479    }
480
481    #[test]
482    fn lockfile_names_come_from_package_entries_only() {
483        let dir = tempdir().unwrap();
484        let lock = dir.path().join("uv.lock");
485        fs::write(
486            &lock,
487            "version = 1\n\n[[package]]\nname = \"Requests\"\nversion = \"2.32.3\"\n\n\
488             [package.metadata]\nname = \"not-a-package\"\n\n[[package]]\nname = \"my-proj\"\n",
489        )
490        .unwrap();
491        let names = lockfile_package_names(&lock).unwrap();
492        assert!(names.contains("requests"));
493        assert!(names.contains("my-proj"));
494        assert!(!names.contains("not-a-package"));
495        assert_eq!(names.len(), 2);
496    }
497
498    #[test]
499    fn an_ad_hoc_install_missing_from_the_lockfile_is_flagged() {
500        let dir = tempdir().unwrap();
501        let sp = dir.path().join(".venv").join("Lib").join("site-packages");
502        fs::create_dir_all(sp.join("requests-2.32.3.dist-info")).unwrap();
503        fs::create_dir_all(sp.join("sneaky_pkg-1.0.dist-info")).unwrap();
504        fs::create_dir_all(sp.join("pip-24.0.dist-info")).unwrap();
505
506        let locked: HashSet<String> = ["requests".to_string()].into();
507        assert_eq!(unlocked_packages(dir.path(), &locked), vec!["sneaky-pkg"]);
508    }
509
510    #[test]
511    fn a_foreign_venv_next_to_tool_uv_without_a_lock_is_refused() {
512        let dir = tempdir().unwrap();
513        let mut file = File::create(dir.path().join("pyproject.toml")).unwrap();
514        writeln!(file, "[tool.uv]").unwrap();
515        let venv = dir.path().join(".venv");
516        fs::create_dir(&venv).unwrap();
517        File::create(venv.join("pyvenv.cfg")).unwrap();
518
519        let err = Uv
520            .enforce_lockfile(dir.path(), EnforcePolicy::default())
521            .unwrap_err();
522        assert!(err.to_string().contains("not created by uv"));
523    }
524
525    #[test]
526    fn drift_reports_the_ad_hoc_install_as_data() {
527        let dir = tempdir().unwrap();
528        fs::write(
529            dir.path().join("uv.lock"),
530            "[[package]]\nname = \"requests\"\nversion = \"2.32.3\"\n",
531        )
532        .unwrap();
533        let sp = dir.path().join(".venv").join("Lib").join("site-packages");
534        fs::create_dir_all(sp.join("requests-2.32.3.dist-info")).unwrap();
535        fs::create_dir_all(sp.join("sneaky_pkg-1.0.dist-info")).unwrap();
536
537        let reports = Uv.drift(dir.path());
538        assert_eq!(reports.len(), 1);
539        assert_eq!(reports[0].directory, ".venv");
540        assert_eq!(reports[0].unrecorded, vec!["sneaky-pkg"]);
541        assert_eq!(reports[0].record_command, "uv add <package>");
542    }
543
544    #[test]
545    fn drift_is_silent_when_the_lockfile_records_everything() {
546        let dir = tempdir().unwrap();
547        fs::write(
548            dir.path().join("uv.lock"),
549            "[[package]]\nname = \"requests\"\nversion = \"2.32.3\"\n",
550        )
551        .unwrap();
552        let sp = dir.path().join(".venv").join("Lib").join("site-packages");
553        fs::create_dir_all(sp.join("requests-2.32.3.dist-info")).unwrap();
554
555        assert!(Uv.drift(dir.path()).is_empty());
556    }
557}