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        self.restore_named(path, ".venv", None, timeout)
157    }
158
159    /// Poetry picks its own interpreter, so the recorded one is pointed at it first with
160    /// `poetry env use` — the same command a user would type.
161    fn restore_named(
162        &self,
163        path: &Path,
164        _dir_name: &str,
165        runtime: Option<&str>,
166        timeout: std::time::Duration,
167    ) -> Result<()> {
168        if let Some(exe) = runtime.and_then(super::python_executable) {
169            // Best effort, deliberately. If poetry will not adopt that interpreter the
170            // right answer is still to install the dependencies into the environment it
171            // *will* use — that is what this command did before the version was recorded
172            // at all, and a restore that refuses to run is worse than one that runs on
173            // 3.14. `run_last_run` has already said out loud which interpreter it asked
174            // for, so a mismatch is not silent.
175            let _ = run_command_with_timeout("poetry", &["env", "use", &exe], path, timeout);
176        }
177        run_command_with_timeout("poetry", &["install"], path, timeout)
178    }
179
180    /// The interpreter the in-project `.venv` was built with.
181    fn runtime_tag(&self, path: &Path, dir_name: &str) -> Option<String> {
182        super::venv_runtime_tag(&path.join(dir_name))
183    }
184
185    fn lockfiles(&self) -> &'static [&'static str] {
186        &["poetry.lock"]
187    }
188
189    /// The comparison `enforce_lockfile` refuses on, as data: distributions in `.venv`
190    /// that `poetry.lock` does not pin.
191    fn drift(&self, path: &Path) -> Vec<super::DriftReport> {
192        let Some(locked) = locked_names_with_project(path, &path.join("poetry.lock")) else {
193            return Vec::new();
194        };
195        let extras = unlocked_packages(path, &locked);
196        if extras.is_empty() {
197            return Vec::new();
198        }
199        vec![super::DriftReport {
200            directory: ".venv".to_string(),
201            unrecorded: extras,
202            record_command: "poetry add <package>",
203        }]
204    }
205}
206
207/// The lockfile's package names plus the project's own, since `poetry install` installs
208/// the project but `poetry.lock` never lists it.
209fn locked_names_with_project(path: &Path, lockfile: &Path) -> Option<HashSet<String>> {
210    let mut locked = lockfile_package_names(lockfile)?;
211    if let Some(own) = project_name(path) {
212        locked.insert(own);
213    }
214    Some(locked)
215}
216
217#[cfg(test)]
218mod tests {
219    use super::*;
220    use std::fs::File;
221    use std::io::Write;
222    use tempfile::tempdir;
223
224    #[test]
225    fn detect_positive_lock() {
226        let dir = tempdir().unwrap();
227        File::create(dir.path().join("poetry.lock")).unwrap();
228        assert!(Poetry.detect(dir.path()));
229    }
230
231    #[test]
232    fn detect_positive_toml() {
233        let dir = tempdir().unwrap();
234        let mut file = File::create(dir.path().join("pyproject.toml")).unwrap();
235        writeln!(file, "[tool.poetry]").unwrap();
236        assert!(Poetry.detect(dir.path()));
237    }
238
239    #[test]
240    fn detect_negative_on_a_plain_pep621_project() {
241        let dir = tempdir().unwrap();
242        let mut file = File::create(dir.path().join("pyproject.toml")).unwrap();
243        writeln!(file, "[project]\nname = \"plain\"").unwrap();
244        assert!(!Poetry.detect(dir.path()));
245    }
246
247    #[test]
248    fn bloat_dirs_only_claims_an_in_project_venv() {
249        let dir = tempdir().unwrap();
250        assert!(Poetry.bloat_dirs(dir.path()).is_empty());
251        fs::create_dir(dir.path().join(".venv")).unwrap();
252        let dirs = Poetry.bloat_dirs(dir.path());
253        assert_eq!(dirs.len(), 1);
254        assert_eq!(dirs[0].name, ".venv");
255    }
256
257    #[test]
258    fn a_venv_without_a_lockfile_is_refused() {
259        let dir = tempdir().unwrap();
260        let mut file = File::create(dir.path().join("pyproject.toml")).unwrap();
261        writeln!(file, "[tool.poetry]\nname = \"proj\"").unwrap();
262        fs::create_dir(dir.path().join(".venv")).unwrap();
263
264        let err = Poetry
265            .enforce_lockfile(dir.path(), EnforcePolicy::default())
266            .unwrap_err();
267        assert!(err.to_string().contains("poetry lock"));
268    }
269
270    #[test]
271    fn the_projects_own_name_is_not_drift() {
272        let dir = tempdir().unwrap();
273        fs::write(
274            dir.path().join("pyproject.toml"),
275            "[tool.poetry]\nname = \"My_Proj\"\n",
276        )
277        .unwrap();
278        fs::write(
279            dir.path().join("poetry.lock"),
280            "[[package]]\nname = \"requests\"\nversion = \"2.32.3\"\n",
281        )
282        .unwrap();
283        let sp = dir.path().join(".venv").join("Lib").join("site-packages");
284        fs::create_dir_all(sp.join("requests-2.32.3.dist-info")).unwrap();
285        fs::create_dir_all(sp.join("my_proj-0.1.0.dist-info")).unwrap();
286
287        assert!(Poetry.drift(dir.path()).is_empty());
288    }
289
290    #[test]
291    fn an_ad_hoc_install_is_reported_as_drift() {
292        let dir = tempdir().unwrap();
293        fs::write(
294            dir.path().join("pyproject.toml"),
295            "[tool.poetry]\nname = \"proj\"\n",
296        )
297        .unwrap();
298        fs::write(
299            dir.path().join("poetry.lock"),
300            "[[package]]\nname = \"requests\"\nversion = \"2.32.3\"\n",
301        )
302        .unwrap();
303        let sp = dir.path().join(".venv").join("Lib").join("site-packages");
304        fs::create_dir_all(sp.join("requests-2.32.3.dist-info")).unwrap();
305        fs::create_dir_all(sp.join("sneaky_pkg-1.0.dist-info")).unwrap();
306
307        let reports = Poetry.drift(dir.path());
308        assert_eq!(reports.len(), 1);
309        assert_eq!(reports[0].unrecorded, vec!["sneaky-pkg"]);
310        assert_eq!(reports[0].record_command, "poetry add <package>");
311    }
312
313    #[test]
314    fn the_project_name_reads_from_either_table() {
315        let dir = tempdir().unwrap();
316        fs::write(
317            dir.path().join("pyproject.toml"),
318            "[build-system]\nname = \"not-this\"\n\n[project]\nname = \"pep621-name\"\n",
319        )
320        .unwrap();
321        assert_eq!(project_name(dir.path()).as_deref(), Some("pep621-name"));
322    }
323}