Skip to main content

uv_cache_info/
cache_info.rs

1use std::borrow::Cow;
2use std::collections::BTreeMap;
3use std::path::{Path, PathBuf};
4
5use serde::Deserialize;
6use tracing::{debug, info_span, warn};
7
8use uv_fs::Simplified;
9
10use crate::git_info::{Commit, Tags};
11use crate::glob::cluster_globs;
12use crate::timestamp::Timestamp;
13
14#[derive(Debug, thiserror::Error)]
15pub enum CacheInfoError {
16    #[error("Failed to parse glob patterns for `cache-keys`: {0}")]
17    Glob(#[from] globwalk::GlobError),
18    #[error(transparent)]
19    Io(#[from] std::io::Error),
20}
21
22/// The information used to determine whether a built distribution is up-to-date, based on the
23/// timestamps of relevant files, the current commit of a repository, etc.
24#[derive(Default, Debug, Clone, Hash, PartialEq, Eq, serde::Deserialize, serde::Serialize)]
25#[serde(rename_all = "kebab-case")]
26pub struct CacheInfo {
27    /// The timestamp of the most recent `ctime` of any relevant files, at the time of the build.
28    /// The timestamp will typically be the maximum of the `ctime` values of the `pyproject.toml`,
29    /// `setup.py`, and `setup.cfg` files, if they exist; however, users can provide additional
30    /// files to timestamp via the `cache-keys` field.
31    timestamp: Option<Timestamp>,
32    /// The commit at which the distribution was built.
33    commit: Option<Commit>,
34    /// The Git tags present at the time of the build.
35    tags: Option<Tags>,
36    /// Environment variables to include in the cache key.
37    #[serde(default)]
38    env: BTreeMap<String, Option<String>>,
39    /// The timestamp or inode of any directories that should be considered in the cache key.
40    #[serde(default)]
41    directories: BTreeMap<Cow<'static, str>, Option<DirectoryTimestamp>>,
42}
43
44impl CacheInfo {
45    /// Return the [`CacheInfo`] for a given timestamp.
46    pub fn from_timestamp(timestamp: Timestamp) -> Self {
47        Self {
48            timestamp: Some(timestamp),
49            ..Self::default()
50        }
51    }
52
53    /// Compute the cache info for a given path, which may be a file or a directory.
54    pub fn from_path(path: &Path) -> Result<Self, CacheInfoError> {
55        let metadata = fs_err::metadata(path)?;
56        if metadata.is_file() {
57            Ok(Self::from_file(path)?)
58        } else {
59            Self::from_directory(path)
60        }
61    }
62
63    /// Compute the cache info for a given directory.
64    pub fn from_directory(directory: &Path) -> Result<Self, CacheInfoError> {
65        let mut commit = None;
66        let mut tags = None;
67        let mut last_changed: Option<(PathBuf, Timestamp)> = None;
68        let mut directories = BTreeMap::new();
69        let mut env = BTreeMap::new();
70
71        // Read the cache keys.
72        let pyproject_path = directory.join("pyproject.toml");
73        let cache_keys = if let Ok(contents) = fs_err::read_to_string(&pyproject_path) {
74            let result = info_span!("toml::from_str cache keys", path = %pyproject_path.display())
75                .in_scope(|| toml::from_str::<PyProjectToml>(&contents));
76            if let Ok(pyproject_toml) = result {
77                pyproject_toml
78                    .tool
79                    .and_then(|tool| tool.uv)
80                    .and_then(|tool_uv| tool_uv.cache_keys)
81            } else {
82                None
83            }
84        } else {
85            None
86        };
87
88        // If no cache keys were defined, use the defaults.
89        let cache_keys = cache_keys.unwrap_or_else(|| {
90            vec![
91                CacheKey::Path(Cow::Borrowed("pyproject.toml")),
92                CacheKey::Path(Cow::Borrowed("setup.py")),
93                CacheKey::Path(Cow::Borrowed("setup.cfg")),
94                CacheKey::Directory {
95                    dir: Cow::Borrowed("src"),
96                },
97            ]
98        });
99
100        // Incorporate timestamps from any direct filepaths.
101        let mut globs = vec![];
102        for cache_key in cache_keys {
103            match cache_key {
104                CacheKey::Path(file) | CacheKey::File { file } => {
105                    if file
106                        .as_ref()
107                        .chars()
108                        .any(|c| matches!(c, '*' | '?' | '[' | '{'))
109                    {
110                        // Defer globs to a separate pass.
111                        globs.push(file);
112                        continue;
113                    }
114
115                    // Treat the path as a file.
116                    let path = directory.join(file.as_ref());
117                    let metadata = match path.metadata() {
118                        Ok(metadata) => metadata,
119                        Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
120                            continue;
121                        }
122                        Err(err) => {
123                            warn!("Failed to read metadata for file: {err}");
124                            continue;
125                        }
126                    };
127                    if !metadata.is_file() {
128                        warn!(
129                            "Expected file for cache key, but found directory: `{}`",
130                            path.display()
131                        );
132                        continue;
133                    }
134                    let timestamp = Timestamp::from_metadata(&metadata);
135                    if last_changed.as_ref().is_none_or(|(_, prev_timestamp)| {
136                        *prev_timestamp < Timestamp::from_metadata(&metadata)
137                    }) {
138                        last_changed = Some((path, timestamp));
139                    }
140                }
141                CacheKey::Directory { dir } => {
142                    // Treat the path as a directory.
143                    let path = directory.join(dir.as_ref());
144                    let metadata = match path.metadata() {
145                        Ok(metadata) => metadata,
146                        Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
147                            directories.insert(dir, None);
148                            continue;
149                        }
150                        Err(err) => {
151                            warn!("Failed to read metadata for directory: {err}");
152                            continue;
153                        }
154                    };
155                    if !metadata.is_dir() {
156                        warn!(
157                            "Expected directory for cache key, but found file: `{}`",
158                            path.display()
159                        );
160                        continue;
161                    }
162
163                    if let Ok(created) = metadata.created() {
164                        // Prefer the creation time.
165                        directories.insert(
166                            dir,
167                            Some(DirectoryTimestamp::Timestamp(Timestamp::from(created))),
168                        );
169                    } else {
170                        // Fall back to the inode.
171                        cfg_select! {
172                            unix => {
173                                use std::os::unix::fs::MetadataExt;
174                                directories.insert(
175                                    dir,
176                                    Some(DirectoryTimestamp::Inode(metadata.ino())),
177                                );
178                            },
179                            _ => {
180                                warn!(
181                                    "Failed to read creation time for directory: `{}`",
182                                    path.display()
183                                );
184                            },
185                        }
186                    }
187                }
188                CacheKey::Git {
189                    git: GitPattern::Bool(true),
190                } => match Commit::from_repository(directory) {
191                    Ok(commit_info) => commit = Some(commit_info),
192                    Err(err) => {
193                        debug!("Failed to read the current commit: {err}");
194                    }
195                },
196                CacheKey::Git {
197                    git: GitPattern::Set(set),
198                } => {
199                    if set.commit.unwrap_or(false) {
200                        match Commit::from_repository(directory) {
201                            Ok(commit_info) => commit = Some(commit_info),
202                            Err(err) => {
203                                debug!("Failed to read the current commit: {err}");
204                            }
205                        }
206                    }
207                    if set.tags.unwrap_or(false) {
208                        match Tags::from_repository(directory) {
209                            Ok(tags_info) => tags = Some(tags_info),
210                            Err(err) => {
211                                debug!("Failed to read the current tags: {err}");
212                            }
213                        }
214                    }
215                }
216                CacheKey::Git {
217                    git: GitPattern::Bool(false),
218                } => {}
219                CacheKey::Environment { env: var } => {
220                    let value = std::env::var(&var).ok();
221                    env.insert(var, value);
222                }
223            }
224        }
225
226        // If we have any globs, first cluster them using LCP and then do a single pass on each group.
227        if !globs.is_empty() {
228            for (glob_base, glob_patterns) in cluster_globs(&globs) {
229                let walker = globwalk::GlobWalkerBuilder::from_patterns(
230                    directory.join(glob_base),
231                    &glob_patterns,
232                )
233                .file_type(globwalk::FileType::FILE | globwalk::FileType::SYMLINK)
234                .build()?;
235                for entry in walker {
236                    let entry = match entry {
237                        Ok(entry) => entry,
238                        Err(err) => {
239                            warn!("Failed to read glob entry: {err}");
240                            continue;
241                        }
242                    };
243                    let metadata = if entry.path_is_symlink() {
244                        // resolve symlinks for leaf entries without following symlinks while globbing
245                        match fs_err::metadata(entry.path()) {
246                            Ok(metadata) => metadata,
247                            Err(err) => {
248                                warn!("Failed to resolve symlink for glob entry: {err}");
249                                continue;
250                            }
251                        }
252                    } else {
253                        match entry.metadata() {
254                            Ok(metadata) => metadata,
255                            Err(err) => {
256                                warn!("Failed to read metadata for glob entry: {err}");
257                                continue;
258                            }
259                        }
260                    };
261                    if !metadata.is_file() {
262                        if !entry.path_is_symlink() {
263                            // don't warn if it was a symlink - it may legitimately resolve to a directory
264                            warn!(
265                                "Expected file for cache key, but found directory: `{}`",
266                                entry.path().display()
267                            );
268                        }
269                        continue;
270                    }
271                    let timestamp = Timestamp::from_metadata(&metadata);
272                    if last_changed.as_ref().is_none_or(|(_, prev_timestamp)| {
273                        *prev_timestamp < Timestamp::from_metadata(&metadata)
274                    }) {
275                        last_changed = Some((entry.into_path(), timestamp));
276                    }
277                }
278            }
279        }
280
281        let timestamp = if let Some((path, timestamp)) = last_changed {
282            debug!(
283                "Computed cache info: {timestamp:?}, {commit:?}, {tags:?}, {env:?}, {directories:?}. Most recently modified: {}",
284                path.user_display()
285            );
286            Some(timestamp)
287        } else {
288            None
289        };
290
291        Ok(Self {
292            timestamp,
293            commit,
294            tags,
295            env,
296            directories,
297        })
298    }
299
300    /// Compute the cache info for a given file, assumed to be a binary or source distribution
301    /// represented as (e.g.) a `.whl` or `.tar.gz` archive.
302    pub fn from_file(path: impl AsRef<Path>) -> std::io::Result<Self> {
303        let metadata = fs_err::metadata(path.as_ref())?;
304        let timestamp = Timestamp::from_metadata(&metadata);
305        Ok(Self {
306            timestamp: Some(timestamp),
307            ..Self::default()
308        })
309    }
310
311    /// Returns `true` if the cache info is empty.
312    pub fn is_empty(&self) -> bool {
313        self.timestamp.is_none()
314            && self.commit.is_none()
315            && self.tags.is_none()
316            && self.env.is_empty()
317            && self.directories.is_empty()
318    }
319}
320
321/// A `pyproject.toml` with an (optional) `[tool.uv]` section.
322#[derive(Debug, Deserialize)]
323#[serde(rename_all = "kebab-case")]
324struct PyProjectToml {
325    tool: Option<Tool>,
326}
327
328#[derive(Debug, Deserialize)]
329#[serde(rename_all = "kebab-case")]
330struct Tool {
331    uv: Option<ToolUv>,
332}
333
334#[derive(Debug, Deserialize)]
335#[serde(rename_all = "kebab-case")]
336struct ToolUv {
337    cache_keys: Option<Vec<CacheKey>>,
338}
339
340#[derive(Debug, Clone, serde::Deserialize)]
341#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
342#[serde(untagged, rename_all = "kebab-case", deny_unknown_fields)]
343pub enum CacheKey {
344    /// Ex) `"Cargo.lock"` or `"**/*.toml"`
345    Path(Cow<'static, str>),
346    /// Ex) `{ file = "Cargo.lock" }` or `{ file = "**/*.toml" }`
347    File { file: Cow<'static, str> },
348    /// Ex) `{ dir = "src" }`
349    Directory { dir: Cow<'static, str> },
350    /// Ex) `{ git = true }` or `{ git = { commit = true, tags = false } }`
351    Git { git: GitPattern },
352    /// Ex) `{ env = "UV_CACHE_INFO" }`
353    Environment { env: String },
354}
355
356#[derive(Debug, Clone, serde::Deserialize)]
357#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
358#[serde(untagged, rename_all = "kebab-case", deny_unknown_fields)]
359pub enum GitPattern {
360    Bool(bool),
361    Set(GitSet),
362}
363
364#[derive(Debug, Clone, serde::Deserialize)]
365#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
366#[serde(rename_all = "kebab-case", deny_unknown_fields)]
367pub struct GitSet {
368    commit: Option<bool>,
369    tags: Option<bool>,
370}
371
372/// A timestamp used to measure changes to a directory.
373#[derive(Debug, Clone, Hash, PartialEq, Eq, serde::Deserialize, serde::Serialize)]
374#[serde(untagged, rename_all = "kebab-case", deny_unknown_fields)]
375enum DirectoryTimestamp {
376    Timestamp(Timestamp),
377    Inode(u64),
378}
379
380#[cfg(all(test, unix))]
381mod tests_unix {
382    use anyhow::Result;
383
384    use super::{CacheInfo, Timestamp};
385
386    #[test]
387    fn test_cache_info_symlink_resolve() -> Result<()> {
388        let dir = tempfile::tempdir()?;
389        let dir = dir.path().join("dir");
390        fs_err::create_dir_all(&dir)?;
391
392        let write_manifest = |cache_key: &str| {
393            fs_err::write(
394                dir.join("pyproject.toml"),
395                format!(
396                    r#"
397                [tool.uv]
398                cache-keys = [
399                    "{cache_key}"
400                ]
401                "#
402                ),
403            )
404        };
405
406        let touch = |path: &str| -> Result<_> {
407            let path = dir.join(path);
408            fs_err::create_dir_all(path.parent().unwrap())?;
409            fs_err::write(&path, "")?;
410            Ok(Timestamp::from_metadata(&path.metadata()?))
411        };
412
413        let cache_timestamp = || -> Result<_> { Ok(CacheInfo::from_directory(&dir)?.timestamp) };
414
415        write_manifest("x/**")?;
416        assert_eq!(cache_timestamp()?, None);
417        let y = touch("x/y")?;
418        assert_eq!(cache_timestamp()?, Some(y));
419        let z = touch("x/z")?;
420        assert_eq!(cache_timestamp()?, Some(z));
421
422        // leaf entry symlink should be resolved
423        let a = touch("../a")?;
424        fs_err::os::unix::fs::symlink(dir.join("../a"), dir.join("x/a"))?;
425        assert_eq!(cache_timestamp()?, Some(a));
426
427        // symlink directories should not be followed while globbing
428        let c = touch("../b/c")?;
429        fs_err::os::unix::fs::symlink(dir.join("../b"), dir.join("x/b"))?;
430        assert_eq!(cache_timestamp()?, Some(a));
431
432        // no globs, should work as expected
433        write_manifest("x/y")?;
434        assert_eq!(cache_timestamp()?, Some(y));
435        write_manifest("x/a")?;
436        assert_eq!(cache_timestamp()?, Some(a));
437        write_manifest("x/b/c")?;
438        assert_eq!(cache_timestamp()?, Some(c));
439
440        // symlink pointing to a directory
441        write_manifest("x/*b*")?;
442        assert_eq!(cache_timestamp()?, None);
443
444        Ok(())
445    }
446}