Skip to main content

dev_prune/adapters/
poetry.rs

1// Copyright 2026 VKrishna04
2// SPDX-License-Identifier: Apache-2.0
3
4// Poetry package manager adapter for Python projects.
5//
6// Poetry only leaves something to prune when the virtualenv is *in-project*
7// (`virtualenvs.in-project = true`, the `.venv` directory). Environments kept in
8// poetry's own cache live outside the repository and are not this tool's business, so
9// `bloat_dirs` simply finds nothing there.
10//
11// Priority: `uv` wins when both detect (see `resolve_python_conflict`); the plain
12// `venv` adapter already refuses poetry projects at `detect`.
13
14use super::uv::{lockfile_package_names, unlocked_packages};
15use super::{
16    BloatDir, EnforcePolicy, PackageManager, dir_size, enforce_two_tier, run_command_with_timeout,
17};
18use anyhow::{Result, anyhow};
19use std::collections::HashSet;
20use std::fs;
21use std::path::Path;
22
23/// Adapter for poetry-based Python projects.
24pub struct Poetry;
25
26/// Whether `pyproject.toml` declares a `[tool.poetry]` table.
27///
28/// A textual check rather than a TOML parse, exactly like the venv adapter's: the table
29/// header only ever appears at the start of a line, and this needs a yes/no.
30fn declares_poetry(path: &Path) -> bool {
31    fs::read_to_string(path.join("pyproject.toml"))
32        .map(|c| {
33            c.lines()
34                .any(|l| l.trim_start().starts_with("[tool.poetry"))
35        })
36        .unwrap_or(false)
37}
38
39/// The project's own package name from `pyproject.toml`, normalised.
40///
41/// `poetry.lock` records the dependency closure but — unlike `uv.lock` — never the
42/// project itself, while `poetry install` does install the project into `.venv`. Without
43/// this the drift check would flag every poetry project as holding an unrecorded copy of
44/// itself.
45fn project_name(path: &Path) -> Option<String> {
46    let content = fs::read_to_string(path.join("pyproject.toml")).ok()?;
47    let mut in_name_table = false;
48    for raw in content.lines() {
49        let line = raw.trim();
50        if line.starts_with('[') {
51            // Both spellings carry the name: `[tool.poetry]` (poetry's own table) and
52            // `[project]` (PEP 621, which poetry 2.x also reads).
53            in_name_table = line == "[tool.poetry]" || line == "[project]";
54            continue;
55        }
56        if !in_name_table {
57            continue;
58        }
59        if let Some(rest) = line.strip_prefix("name")
60            && let Some(value) = rest.trim_start().strip_prefix('=')
61        {
62            let value = value.trim().trim_matches('"');
63            if !value.is_empty() {
64                return Some(super::venv::normalize_package_name(value));
65            }
66        }
67    }
68    None
69}
70
71impl PackageManager for Poetry {
72    fn name(&self) -> &'static str {
73        "poetry"
74    }
75
76    fn detect(&self, path: &Path) -> bool {
77        path.join("poetry.lock").exists() || declares_poetry(path)
78    }
79
80    fn bloat_dirs(&self, path: &Path) -> Vec<BloatDir> {
81        let mut dirs = Vec::new();
82        let venv_path = path.join(".venv");
83        if venv_path.exists() {
84            dirs.push(BloatDir {
85                name: ".venv".to_string(),
86                path: venv_path.clone(),
87                size_bytes: dir_size(&venv_path),
88                shared_bytes: 0,
89            });
90        }
91        dirs
92    }
93
94    /// Enforces the lockfile without writing it.
95    ///
96    /// `poetry check --lock` asserts that `poetry.lock` is consistent with
97    /// `pyproject.toml` and exits non-zero instead of rewriting anything when it is not.
98    /// Plain `poetry lock` is the writing form, for the case where no lockfile exists
99    /// yet — but never over an existing environment, below.
100    fn enforce_lockfile(&self, path: &Path, policy: EnforcePolicy) -> Result<()> {
101        let lockfile = path.join("poetry.lock");
102
103        // Generating a lockfile from `pyproject.toml` only proves the *declared*
104        // dependencies resolve — it says nothing about what is actually installed in
105        // `.venv`. Unlike uv, poetry leaves no stamp in `pyvenv.cfg`, so there is no way
106        // to tell whether this environment even matches the manifest. Refuse instead of
107        // manufacturing proof.
108        if !lockfile.exists() && path.join(".venv").exists() {
109            return Err(anyhow!(
110                "`pyproject.toml` declares `[tool.poetry]` but there is no `poetry.lock` \
111                 — a generated lockfile could not prove the environment's contents are \
112                 recoverable. Lock and rebuild it first: `poetry lock` then \
113                 `poetry install`."
114            ));
115        }
116
117        enforce_two_tier(
118            &lockfile,
119            "poetry",
120            &["check", "--lock"],
121            &["lock"],
122            path,
123            policy,
124        )?;
125
126        // The environment can hold packages the lockfile never recorded — a
127        // `poetry run pip install foo` nobody wrote back. Anything installed but absent
128        // from `poetry.lock` is recoverable from nowhere, which is exactly what this
129        // tool promises never to delete.
130        if let Some(locked) = locked_names_with_project(path, &lockfile) {
131            let extras = unlocked_packages(path, &locked);
132            if !extras.is_empty() {
133                let shown = extras
134                    .iter()
135                    .take(10)
136                    .cloned()
137                    .collect::<Vec<_>>()
138                    .join(", ");
139                let suffix = if extras.len() > 10 {
140                    format!(", … and {} more", extras.len() - 10)
141                } else {
142                    String::new()
143                };
144                return Err(anyhow!(
145                    "`.venv` holds {} package(s) that poetry.lock does not record \
146                     ({shown}{suffix}). They were installed ad hoc and `poetry install` \
147                     would not bring them back. Record them first: `poetry add <package>`.",
148                    extras.len()
149                ));
150            }
151        }
152        Ok(())
153    }
154
155    fn restore(&self, path: &Path, timeout: std::time::Duration) -> Result<()> {
156        run_command_with_timeout("poetry", &["install"], path, timeout)
157    }
158
159    fn lockfiles(&self) -> &'static [&'static str] {
160        &["poetry.lock"]
161    }
162
163    /// The comparison `enforce_lockfile` refuses on, as data: distributions in `.venv`
164    /// that `poetry.lock` does not pin.
165    fn drift(&self, path: &Path) -> Vec<super::DriftReport> {
166        let Some(locked) = locked_names_with_project(path, &path.join("poetry.lock")) else {
167            return Vec::new();
168        };
169        let extras = unlocked_packages(path, &locked);
170        if extras.is_empty() {
171            return Vec::new();
172        }
173        vec![super::DriftReport {
174            directory: ".venv".to_string(),
175            unrecorded: extras,
176            record_command: "poetry add <package>",
177        }]
178    }
179}
180
181/// The lockfile's package names plus the project's own, since `poetry install` installs
182/// the project but `poetry.lock` never lists it.
183fn locked_names_with_project(path: &Path, lockfile: &Path) -> Option<HashSet<String>> {
184    let mut locked = lockfile_package_names(lockfile)?;
185    if let Some(own) = project_name(path) {
186        locked.insert(own);
187    }
188    Some(locked)
189}
190
191#[cfg(test)]
192mod tests {
193    use super::*;
194    use std::fs::File;
195    use std::io::Write;
196    use tempfile::tempdir;
197
198    #[test]
199    fn detect_positive_lock() {
200        let dir = tempdir().unwrap();
201        File::create(dir.path().join("poetry.lock")).unwrap();
202        assert!(Poetry.detect(dir.path()));
203    }
204
205    #[test]
206    fn detect_positive_toml() {
207        let dir = tempdir().unwrap();
208        let mut file = File::create(dir.path().join("pyproject.toml")).unwrap();
209        writeln!(file, "[tool.poetry]").unwrap();
210        assert!(Poetry.detect(dir.path()));
211    }
212
213    #[test]
214    fn detect_negative_on_a_plain_pep621_project() {
215        let dir = tempdir().unwrap();
216        let mut file = File::create(dir.path().join("pyproject.toml")).unwrap();
217        writeln!(file, "[project]\nname = \"plain\"").unwrap();
218        assert!(!Poetry.detect(dir.path()));
219    }
220
221    #[test]
222    fn bloat_dirs_only_claims_an_in_project_venv() {
223        let dir = tempdir().unwrap();
224        assert!(Poetry.bloat_dirs(dir.path()).is_empty());
225        fs::create_dir(dir.path().join(".venv")).unwrap();
226        let dirs = Poetry.bloat_dirs(dir.path());
227        assert_eq!(dirs.len(), 1);
228        assert_eq!(dirs[0].name, ".venv");
229    }
230
231    #[test]
232    fn a_venv_without_a_lockfile_is_refused() {
233        let dir = tempdir().unwrap();
234        let mut file = File::create(dir.path().join("pyproject.toml")).unwrap();
235        writeln!(file, "[tool.poetry]\nname = \"proj\"").unwrap();
236        fs::create_dir(dir.path().join(".venv")).unwrap();
237
238        let err = Poetry
239            .enforce_lockfile(dir.path(), EnforcePolicy::default())
240            .unwrap_err();
241        assert!(err.to_string().contains("poetry lock"));
242    }
243
244    #[test]
245    fn the_projects_own_name_is_not_drift() {
246        let dir = tempdir().unwrap();
247        fs::write(
248            dir.path().join("pyproject.toml"),
249            "[tool.poetry]\nname = \"My_Proj\"\n",
250        )
251        .unwrap();
252        fs::write(
253            dir.path().join("poetry.lock"),
254            "[[package]]\nname = \"requests\"\nversion = \"2.32.3\"\n",
255        )
256        .unwrap();
257        let sp = dir.path().join(".venv").join("Lib").join("site-packages");
258        fs::create_dir_all(sp.join("requests-2.32.3.dist-info")).unwrap();
259        fs::create_dir_all(sp.join("my_proj-0.1.0.dist-info")).unwrap();
260
261        assert!(Poetry.drift(dir.path()).is_empty());
262    }
263
264    #[test]
265    fn an_ad_hoc_install_is_reported_as_drift() {
266        let dir = tempdir().unwrap();
267        fs::write(
268            dir.path().join("pyproject.toml"),
269            "[tool.poetry]\nname = \"proj\"\n",
270        )
271        .unwrap();
272        fs::write(
273            dir.path().join("poetry.lock"),
274            "[[package]]\nname = \"requests\"\nversion = \"2.32.3\"\n",
275        )
276        .unwrap();
277        let sp = dir.path().join(".venv").join("Lib").join("site-packages");
278        fs::create_dir_all(sp.join("requests-2.32.3.dist-info")).unwrap();
279        fs::create_dir_all(sp.join("sneaky_pkg-1.0.dist-info")).unwrap();
280
281        let reports = Poetry.drift(dir.path());
282        assert_eq!(reports.len(), 1);
283        assert_eq!(reports[0].unrecorded, vec!["sneaky-pkg"]);
284        assert_eq!(reports[0].record_command, "poetry add <package>");
285    }
286
287    #[test]
288    fn the_project_name_reads_from_either_table() {
289        let dir = tempdir().unwrap();
290        fs::write(
291            dir.path().join("pyproject.toml"),
292            "[build-system]\nname = \"not-this\"\n\n[project]\nname = \"pep621-name\"\n",
293        )
294        .unwrap();
295        assert_eq!(project_name(dir.path()).as_deref(), Some("pep621-name"));
296    }
297}