Skip to main content

dev_prune/adapters/
pycache.rs

1// Copyright 2026 VKrishna04
2// SPDX-License-Identifier: Apache-2.0
3
4// Python bytecode and tool-cache adapter.
5//
6// Not opt-in: unlike `target/` or `build/`, none of these come back by recompiling.
7// `__pycache__` refills the moment the module is imported again; `.pytest_cache`,
8// `.mypy_cache` and `.ruff_cache` refill on the next run of their tool. They are pure
9// caches in the same sense `node_modules` is, just far smaller individually: the space
10// is in how many of them a large tree accumulates, not in any one of them.
11//
12// What it claims, anywhere under the repository: `__pycache__` (name alone is proof,
13// since CPython reserves it), and `.pytest_cache` / `.mypy_cache` / `.ruff_cache` when
14// they carry the `CACHEDIR.TAG` the cachedir spec defines, so a same-named directory a
15// user created for something else is left alone. `.hypothesis` is deliberately not
16// claimed: it holds regression examples a rebuild cannot reconstruct. `.tox` and `.nox`
17// are not claimed either, since they are environments, not caches.
18//
19// A repository with no `.py` file anywhere has nothing to regenerate a bytecode or tool
20// cache from, so `enforce_lockfile` refuses exactly like Gradle and Maven do: no command
21// run, just the same structural proof the rest of the tree already relies on.
22
23use super::{BloatDir, EnforcePolicy, PackageManager, dir_size};
24use anyhow::{Result, anyhow};
25use std::path::{Path, PathBuf};
26use walkdir::WalkDir;
27
28/// The signature the [cachedir spec](https://bford.info/cachedir/) defines. A directory
29/// carrying a `CACHEDIR.TAG` that starts with these bytes is safe for a backup tool, an
30/// indexer, or this adapter to skip and delete without asking what's inside.
31const CACHEDIR_TAG_SIGNATURE: &[u8] = b"Signature: 8a477f597d28d172789f06886806bc55";
32
33/// Whether `dir` carries a `CACHEDIR.TAG` matching the spec's signature.
34fn has_cachedir_tag(dir: &Path) -> bool {
35    std::fs::read(dir.join("CACHEDIR.TAG"))
36        .is_ok_and(|bytes| bytes.starts_with(CACHEDIR_TAG_SIGNATURE))
37}
38
39/// Whether `entry` is one of the four caches this adapter claims.
40///
41/// `__pycache__` is proof by name alone: CPython reserves it for exactly one purpose.
42/// The three dot-named tool caches also need the `CACHEDIR.TAG` confirmation, because
43/// nothing stops a project from naming its own directory `.mypy_cache`.
44fn is_target_cache_dir(entry: &walkdir::DirEntry) -> bool {
45    let name = entry.file_name().to_string_lossy();
46    match name.as_ref() {
47        "__pycache__" => true,
48        ".pytest_cache" | ".mypy_cache" | ".ruff_cache" => has_cachedir_tag(entry.path()),
49        _ => false,
50    }
51}
52
53/// The result of walking a repository once for every cache this adapter claims.
54struct Scan {
55    /// Every matching cache directory found, in walk order.
56    caches: Vec<PathBuf>,
57    /// Whether at least one `.py` file exists anywhere under the repository.
58    has_py: bool,
59}
60
61/// Walk `repo_root` once, collecting every cache directory this adapter claims and
62/// noting whether any Python source exists to regenerate them from.
63///
64/// Deliberately not `filter_entry`: a matching cache directory must still be *yielded*
65/// (so it can be collected) while not being descended into, which `filter_entry` cannot
66/// express. A cache-name match is checked before consulting
67/// [`crate::workspace::is_scannable`], so this never descends into a `.venv`'s
68/// `site-packages`, `node_modules`, `target/`, or a nested repository, the same rule
69/// the general workspace walk already applies, reused rather than duplicated.
70fn scan(repo_root: &Path) -> Scan {
71    let mut caches = Vec::new();
72    let mut has_py = false;
73    let mut walk = WalkDir::new(repo_root).follow_links(false).into_iter();
74
75    while let Some(entry) = walk.next() {
76        let Ok(entry) = entry else { continue };
77
78        // The repository root itself is never a cache directory or a source file.
79        if entry.depth() == 0 {
80            continue;
81        }
82
83        if entry.file_type().is_dir() {
84            if is_target_cache_dir(&entry) {
85                caches.push(entry.path().to_path_buf());
86                walk.skip_current_dir();
87            } else if !crate::workspace::is_scannable(&entry) {
88                walk.skip_current_dir();
89            }
90            continue;
91        }
92
93        if !has_py
94            && entry
95                .path()
96                .extension()
97                .is_some_and(|ext| ext.eq_ignore_ascii_case("py"))
98        {
99            has_py = true;
100        }
101    }
102
103    Scan { caches, has_py }
104}
105
106/// Adapter for Python bytecode and tool caches. Always on; see the module comment.
107pub struct Pycache;
108
109impl PackageManager for Pycache {
110    fn name(&self) -> &'static str {
111        "pycache"
112    }
113
114    fn detect(&self, path: &Path) -> bool {
115        path.join(".git").exists() && !scan(path).caches.is_empty()
116    }
117
118    fn bloat_dirs(&self, path: &Path) -> Vec<BloatDir> {
119        scan(path)
120            .caches
121            .into_iter()
122            .map(|dir| {
123                let name = dir
124                    .file_name()
125                    .map(|n| n.to_string_lossy().into_owned())
126                    .unwrap_or_default();
127                BloatDir {
128                    name,
129                    size_bytes: dir_size(&dir),
130                    path: dir,
131                    shared_bytes: 0,
132                }
133            })
134            .collect()
135    }
136
137    /// Gradle's and Maven's proof, adapted: no source to compile from means no reason
138    /// these caches exist at all. Runs nothing: the walk `scan` already did is enough.
139    fn enforce_lockfile(&self, path: &Path, _policy: EnforcePolicy) -> Result<()> {
140        if !scan(path).has_py {
141            return Err(anyhow!(
142                "no `.py` file anywhere under the repository: nothing to regenerate a \
143                 Python bytecode or tool cache from."
144            ));
145        }
146        Ok(())
147    }
148
149    fn restore(&self, _path: &Path, _timeout: std::time::Duration) -> Result<()> {
150        println!(
151            "Python caches regenerate on their own: __pycache__ the next time these \
152             modules are imported, .pytest_cache/.mypy_cache/.ruff_cache the next time \
153             pytest, mypy or ruff runs"
154        );
155        Ok(())
156    }
157
158    fn restore_named(
159        &self,
160        _path: &Path,
161        dir_name: &str,
162        _runtime: Option<&str>,
163        _timeout: std::time::Duration,
164    ) -> Result<()> {
165        let hint = match Path::new(dir_name).file_name().and_then(|n| n.to_str()) {
166            Some("__pycache__") => "Python regenerates it the next time these modules are imported",
167            Some(".pytest_cache") => "pytest regenerates it on its next run",
168            Some(".mypy_cache") => "mypy regenerates it on its next run",
169            Some(".ruff_cache") => "ruff regenerates it on its next run",
170            _ => "it regenerates the next time the tool that made it runs again",
171        };
172        println!("{dir_name} will come back: {hint}");
173        Ok(())
174    }
175}
176
177#[cfg(test)]
178mod tests {
179    use super::*;
180    use std::fs;
181    use tempfile::tempdir;
182
183    fn tag(dir: &Path) {
184        fs::write(
185            dir.join("CACHEDIR.TAG"),
186            "Signature: 8a477f597d28d172789f06886806bc55\n",
187        )
188        .unwrap();
189    }
190
191    #[test]
192    fn test_name() {
193        assert_eq!(Pycache.name(), "pycache");
194    }
195
196    #[test]
197    fn pycache_is_not_opt_in() {
198        assert!(!Pycache.opt_in());
199    }
200
201    #[test]
202    fn test_detect_positive() {
203        let dir = tempdir().unwrap();
204        fs::create_dir(dir.path().join(".git")).unwrap();
205        fs::create_dir(dir.path().join("__pycache__")).unwrap();
206        assert!(Pycache.detect(dir.path()));
207    }
208
209    #[test]
210    fn detect_requires_the_repository_root_not_just_a_cache() {
211        // A nested directory that is not itself the repo root never detects, even if it
212        // holds a cache; only `bloat_dirs` called at the true root finds it.
213        let dir = tempdir().unwrap();
214        fs::create_dir(dir.path().join("__pycache__")).unwrap();
215        assert!(!Pycache.detect(dir.path()));
216    }
217
218    #[test]
219    fn test_detect_negative() {
220        let dir = tempdir().unwrap();
221        fs::create_dir(dir.path().join(".git")).unwrap();
222        assert!(!Pycache.detect(dir.path()));
223    }
224
225    #[test]
226    fn claims_pycache_by_name_alone() {
227        let dir = tempdir().unwrap();
228        fs::create_dir(dir.path().join(".git")).unwrap();
229        fs::create_dir_all(dir.path().join("pkg/__pycache__")).unwrap();
230
231        let dirs = Pycache.bloat_dirs(dir.path());
232        assert_eq!(dirs.len(), 1);
233        assert_eq!(dirs[0].name, "__pycache__");
234    }
235
236    #[test]
237    fn a_dot_mypy_cache_without_the_tag_is_left_alone() {
238        let dir = tempdir().unwrap();
239        fs::create_dir(dir.path().join(".git")).unwrap();
240        // A project's own directory that happens to share the name, with no tag inside.
241        fs::create_dir(dir.path().join(".mypy_cache")).unwrap();
242
243        assert!(Pycache.bloat_dirs(dir.path()).is_empty());
244    }
245
246    #[test]
247    fn a_tagged_dot_mypy_cache_is_claimed() {
248        let dir = tempdir().unwrap();
249        fs::create_dir(dir.path().join(".git")).unwrap();
250        let cache = dir.path().join(".mypy_cache");
251        fs::create_dir(&cache).unwrap();
252        tag(&cache);
253
254        let dirs = Pycache.bloat_dirs(dir.path());
255        assert_eq!(dirs.len(), 1);
256        assert_eq!(dirs[0].name, ".mypy_cache");
257    }
258
259    #[test]
260    fn finds_every_cache_kind_anywhere_in_the_tree() {
261        let dir = tempdir().unwrap();
262        fs::create_dir(dir.path().join(".git")).unwrap();
263        fs::create_dir_all(dir.path().join("src/pkg/__pycache__")).unwrap();
264        fs::create_dir_all(dir.path().join("tests/__pycache__")).unwrap();
265        let pytest = dir.path().join(".pytest_cache");
266        fs::create_dir(&pytest).unwrap();
267        tag(&pytest);
268        let ruff = dir.path().join(".ruff_cache");
269        fs::create_dir(&ruff).unwrap();
270        tag(&ruff);
271
272        let mut names: Vec<String> = Pycache
273            .bloat_dirs(dir.path())
274            .into_iter()
275            .map(|b| b.name)
276            .collect();
277        names.sort();
278        assert_eq!(
279            names,
280            vec![".pytest_cache", ".ruff_cache", "__pycache__", "__pycache__"]
281        );
282    }
283
284    #[test]
285    fn never_descends_into_a_virtual_environment() {
286        let dir = tempdir().unwrap();
287        fs::create_dir(dir.path().join(".git")).unwrap();
288        let venv = dir.path().join(".venv");
289        fs::create_dir_all(venv.join("lib/site-packages/dep/__pycache__")).unwrap();
290        fs::write(venv.join("pyvenv.cfg"), "").unwrap();
291
292        // `.venv` is hidden, so it is already excluded before the site-packages cache
293        // inside it is ever reached.
294        assert!(Pycache.bloat_dirs(dir.path()).is_empty());
295    }
296
297    #[test]
298    fn never_descends_into_node_modules_or_target() {
299        let dir = tempdir().unwrap();
300        fs::create_dir(dir.path().join(".git")).unwrap();
301        fs::create_dir_all(dir.path().join("node_modules/some-native-dep/__pycache__")).unwrap();
302        fs::create_dir_all(dir.path().join("target/debug/__pycache__")).unwrap();
303
304        assert!(Pycache.bloat_dirs(dir.path()).is_empty());
305    }
306
307    #[test]
308    fn never_descends_into_a_nested_repository() {
309        let dir = tempdir().unwrap();
310        fs::create_dir(dir.path().join(".git")).unwrap();
311        let nested = dir.path().join("vendored-tool");
312        fs::create_dir_all(nested.join("__pycache__")).unwrap();
313        fs::create_dir(nested.join(".git")).unwrap();
314
315        assert!(Pycache.bloat_dirs(dir.path()).is_empty());
316    }
317
318    #[test]
319    fn enforce_lockfile_refuses_with_no_python_source_anywhere() {
320        let dir = tempdir().unwrap();
321        fs::create_dir(dir.path().join(".git")).unwrap();
322        fs::create_dir(dir.path().join("__pycache__")).unwrap();
323
324        assert!(
325            Pycache
326                .enforce_lockfile(dir.path(), EnforcePolicy::default())
327                .is_err()
328        );
329
330        fs::write(dir.path().join("main.py"), "").unwrap();
331        assert!(
332            Pycache
333                .enforce_lockfile(dir.path(), EnforcePolicy::default())
334                .is_ok()
335        );
336    }
337
338    #[test]
339    fn restore_named_matches_by_basename_not_the_whole_relative_path() {
340        let dir = tempdir().unwrap();
341        // The recorded name is repo-relative and slash-separated, e.g.
342        // "src/pkg/__pycache__", so matching must key off the final component.
343        assert!(
344            Pycache
345                .restore_named(
346                    dir.path(),
347                    "src/pkg/__pycache__",
348                    None,
349                    std::time::Duration::from_secs(1),
350                )
351                .is_ok()
352        );
353    }
354}