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::de::{IgnoredAny, MapAccess, Visitor};
6use serde::{Deserialize, Deserializer};
7use tracing::{debug, info_span, warn};
8
9use uv_fs::{Simplified, created_time};
10use uv_macros::DeserializeUntagged;
11
12use crate::git_info::{Commit, Tags};
13use crate::glob::cluster_globs;
14use crate::timestamp::Timestamp;
15
16#[derive(Debug, thiserror::Error)]
17pub enum CacheInfoError {
18    #[error("Failed to parse cache keys in `{}`", _0.user_display())]
19    PyprojectToml(PathBuf, #[source] Box<toml::de::Error>),
20    #[error("Failed to parse glob patterns for `cache-keys`: {0}")]
21    Glob(#[from] globwalk::GlobError),
22    #[error(transparent)]
23    Io(#[from] std::io::Error),
24}
25
26/// The information used to determine whether a built distribution is up-to-date, based on the
27/// timestamps of relevant files, the current commit of a repository, etc.
28#[derive(Default, Debug, Clone, Hash, PartialEq, Eq, serde::Deserialize, serde::Serialize)]
29#[serde(rename_all = "kebab-case")]
30pub struct CacheInfo {
31    /// The timestamp of the most recent `ctime` of any relevant files, at the time of the build.
32    /// The timestamp will typically be the maximum of the `ctime` values of the `pyproject.toml`,
33    /// `setup.py`, and `setup.cfg` files, if they exist; however, users can provide additional
34    /// files to timestamp via the `cache-keys` field.
35    timestamp: Option<Timestamp>,
36    /// The commit at which the distribution was built.
37    commit: Option<Commit>,
38    /// The Git tags present at the time of the build.
39    tags: Option<Tags>,
40    /// Environment variables to include in the cache key.
41    #[serde(default)]
42    env: BTreeMap<String, Option<String>>,
43    /// The timestamp or inode of any directories that should be considered in the cache key.
44    #[serde(default)]
45    directories: BTreeMap<Cow<'static, str>, Option<DirectoryTimestamp>>,
46}
47
48impl CacheInfo {
49    /// Return the [`CacheInfo`] for a given timestamp.
50    pub fn from_timestamp(timestamp: Timestamp) -> Self {
51        Self {
52            timestamp: Some(timestamp),
53            ..Self::default()
54        }
55    }
56
57    /// Compute the cache info for a given path, which may be a file or a directory.
58    pub fn from_path(path: &Path) -> Result<Self, CacheInfoError> {
59        let metadata = fs_err::metadata(path)?;
60        if metadata.is_file() {
61            Ok(Self::from_file(path)?)
62        } else {
63            Self::from_directory(path)
64        }
65    }
66
67    /// Compute the cache info for a given directory.
68    pub fn from_directory(directory: &Path) -> Result<Self, CacheInfoError> {
69        let mut commit = None;
70        let mut tags = None;
71        let mut last_changed: Option<(PathBuf, Timestamp)> = None;
72        let mut directories = BTreeMap::new();
73        let mut env = BTreeMap::new();
74
75        // Read the cache keys.
76        let pyproject_path = directory.join("pyproject.toml");
77        let cache_keys = if let Ok(contents) = fs_err::read_to_string(&pyproject_path) {
78            let pyproject_toml =
79                info_span!("toml::from_str cache keys", path = %pyproject_path.display())
80                    .in_scope(|| toml::from_str::<PyProjectToml>(&contents))
81                    .map_err(|err| CacheInfoError::PyprojectToml(pyproject_path, Box::new(err)))?;
82            pyproject_toml
83                .tool
84                .and_then(|tool| tool.uv)
85                .and_then(|tool_uv| tool_uv.cache_keys)
86        } else {
87            None
88        };
89
90        // If no cache keys were defined, use the defaults.
91        let cache_keys = cache_keys.unwrap_or_else(|| {
92            vec![
93                CacheKey::Path(Cow::Borrowed("pyproject.toml")),
94                CacheKey::Path(Cow::Borrowed("setup.py")),
95                CacheKey::Path(Cow::Borrowed("setup.cfg")),
96                CacheKey::Directory {
97                    dir: Cow::Borrowed("src"),
98                },
99            ]
100        });
101
102        // Incorporate timestamps from any direct filepaths.
103        let mut globs = vec![];
104        for cache_key in cache_keys {
105            match cache_key {
106                CacheKey::Path(file) | CacheKey::File { file } => {
107                    if file
108                        .as_ref()
109                        .chars()
110                        .any(|c| matches!(c, '*' | '?' | '[' | '{'))
111                    {
112                        // Defer globs to a separate pass.
113                        globs.push(file);
114                        continue;
115                    }
116
117                    // Treat the path as a file.
118                    let path = directory.join(file.as_ref());
119                    let metadata = match path.metadata() {
120                        Ok(metadata) => metadata,
121                        Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
122                            continue;
123                        }
124                        Err(err) => {
125                            warn!("Failed to read metadata for file: {err}");
126                            continue;
127                        }
128                    };
129                    if !metadata.is_file() {
130                        warn!(
131                            "Expected file for cache key, but found directory: {}",
132                            path.display()
133                        );
134                        continue;
135                    }
136                    let timestamp = Timestamp::from_metadata(&metadata);
137                    if last_changed.as_ref().is_none_or(|(_, prev_timestamp)| {
138                        *prev_timestamp < Timestamp::from_metadata(&metadata)
139                    }) {
140                        last_changed = Some((path, timestamp));
141                    }
142                }
143                CacheKey::Directory { dir } => {
144                    // Treat the path as a directory.
145                    let path = directory.join(dir.as_ref());
146                    let metadata = match path.metadata() {
147                        Ok(metadata) => metadata,
148                        Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
149                            directories.insert(dir, None);
150                            continue;
151                        }
152                        Err(err) => {
153                            warn!("Failed to read metadata for directory: {err}");
154                            continue;
155                        }
156                    };
157                    if !metadata.is_dir() {
158                        warn!(
159                            "Expected directory for cache key, but found file: {}",
160                            path.display()
161                        );
162                        continue;
163                    }
164
165                    if let Ok(created) = created_time(&path, &metadata) {
166                        // Prefer the creation time.
167                        directories.insert(
168                            dir,
169                            Some(DirectoryTimestamp::Timestamp(Timestamp::from(created))),
170                        );
171                    } else {
172                        // Fall back to the inode.
173                        cfg_select! {
174                            unix => {
175                                use std::os::unix::fs::MetadataExt;
176                                directories.insert(
177                                    dir,
178                                    Some(DirectoryTimestamp::Inode(metadata.ino())),
179                                );
180                            },
181                            _ => {
182                                warn!(
183                                    "Failed to read creation time for directory: `{}`",
184                                    path.display()
185                                );
186                            },
187                        }
188                    }
189                }
190                CacheKey::Git {
191                    git: GitPattern::Bool(true),
192                } => match Commit::from_repository(directory) {
193                    Ok(commit_info) => commit = Some(commit_info),
194                    Err(err) => {
195                        debug!("Failed to read the current commit: {err}");
196                    }
197                },
198                CacheKey::Git {
199                    git: GitPattern::Set(set),
200                } => {
201                    if set.commit.unwrap_or(false) {
202                        match Commit::from_repository(directory) {
203                            Ok(commit_info) => commit = Some(commit_info),
204                            Err(err) => {
205                                debug!("Failed to read the current commit: {err}");
206                            }
207                        }
208                    }
209                    if set.tags.unwrap_or(false) {
210                        match Tags::from_repository(directory) {
211                            Ok(tags_info) => tags = Some(tags_info),
212                            Err(err) => {
213                                debug!("Failed to read the current tags: {err}");
214                            }
215                        }
216                    }
217                }
218                CacheKey::Git {
219                    git: GitPattern::Bool(false),
220                } => {}
221                CacheKey::Environment { env: var } => {
222                    let value = std::env::var(&var).ok();
223                    env.insert(var, value);
224                }
225            }
226        }
227
228        // If we have any globs, first cluster them using LCP and then do a single pass on each group.
229        if !globs.is_empty() {
230            for (glob_base, glob_patterns) in cluster_globs(&globs) {
231                let walker = globwalk::GlobWalkerBuilder::from_patterns(
232                    directory.join(glob_base),
233                    &glob_patterns,
234                )
235                .file_type(globwalk::FileType::FILE | globwalk::FileType::SYMLINK)
236                .build()?;
237                for entry in walker {
238                    let entry = match entry {
239                        Ok(entry) => entry,
240                        Err(err) => {
241                            warn!("Failed to read glob entry: {err}");
242                            continue;
243                        }
244                    };
245                    let metadata = if entry.path_is_symlink() {
246                        // resolve symlinks for leaf entries without following symlinks while globbing
247                        match fs_err::metadata(entry.path()) {
248                            Ok(metadata) => metadata,
249                            Err(err) => {
250                                warn!("Failed to resolve symlink for glob entry: {err}");
251                                continue;
252                            }
253                        }
254                    } else {
255                        match entry.metadata() {
256                            Ok(metadata) => metadata,
257                            Err(err) => {
258                                warn!("Failed to read metadata for glob entry: {err}");
259                                continue;
260                            }
261                        }
262                    };
263                    if !metadata.is_file() {
264                        if !entry.path_is_symlink() {
265                            // don't warn if it was a symlink - it may legitimately resolve to a directory
266                            warn!(
267                                "Expected file for cache key, but found directory: {}",
268                                entry.path().display()
269                            );
270                        }
271                        continue;
272                    }
273                    let timestamp = Timestamp::from_metadata(&metadata);
274                    if last_changed.as_ref().is_none_or(|(_, prev_timestamp)| {
275                        *prev_timestamp < Timestamp::from_metadata(&metadata)
276                    }) {
277                        last_changed = Some((entry.into_path(), timestamp));
278                    }
279                }
280            }
281        }
282
283        let timestamp = if let Some((path, timestamp)) = last_changed {
284            debug!(
285                "Computed cache info: {timestamp:?}, {commit:?}, {tags:?}, {env:?}, {directories:?}. Most recently modified: {}",
286                path.user_display()
287            );
288            Some(timestamp)
289        } else {
290            None
291        };
292
293        Ok(Self {
294            timestamp,
295            commit,
296            tags,
297            env,
298            directories,
299        })
300    }
301
302    /// Compute the cache info for a given file, assumed to be a binary or source distribution
303    /// represented as (e.g.) a `.whl` or `.tar.gz` archive.
304    pub fn from_file(path: impl AsRef<Path>) -> std::io::Result<Self> {
305        let metadata = fs_err::metadata(path.as_ref())?;
306        let timestamp = Timestamp::from_metadata(&metadata);
307        Ok(Self {
308            timestamp: Some(timestamp),
309            ..Self::default()
310        })
311    }
312
313    /// Returns `true` if the cache info is empty.
314    pub fn is_empty(&self) -> bool {
315        self.timestamp.is_none()
316            && self.commit.is_none()
317            && self.tags.is_none()
318            && self.env.is_empty()
319            && self.directories.is_empty()
320    }
321}
322
323/// A `pyproject.toml` with an (optional) `[tool.uv]` section.
324#[derive(Debug, Deserialize)]
325#[serde(rename_all = "kebab-case")]
326struct PyProjectToml {
327    tool: Option<Tool>,
328}
329
330#[derive(Debug, Deserialize)]
331#[serde(rename_all = "kebab-case")]
332struct Tool {
333    uv: Option<ToolUv>,
334}
335
336#[derive(Debug, Deserialize)]
337#[serde(rename_all = "kebab-case")]
338struct ToolUv {
339    cache_keys: Option<Vec<CacheKey>>,
340}
341
342#[derive(Debug, Clone)]
343#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
344#[cfg_attr(
345    feature = "schemars",
346    schemars(untagged, rename_all = "kebab-case", deny_unknown_fields)
347)]
348pub enum CacheKey {
349    /// Ex) `"Cargo.lock"` or `"**/*.toml"`
350    Path(Cow<'static, str>),
351    /// Ex) `{ file = "Cargo.lock" }` or `{ file = "**/*.toml" }`
352    File { file: Cow<'static, str> },
353    /// Ex) `{ dir = "src" }`
354    Directory { dir: Cow<'static, str> },
355    /// Ex) `{ git = true }` or `{ git = { commit = true, tags = false } }`
356    Git { git: GitPattern },
357    /// Ex) `{ env = "UV_CACHE_INFO" }`
358    Environment { env: String },
359}
360
361impl<'de> Deserialize<'de> for CacheKey {
362    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
363    where
364        D: Deserializer<'de>,
365    {
366        #[derive(Deserialize)]
367        #[serde(field_identifier, rename_all = "lowercase")]
368        enum Field {
369            File,
370            Dir,
371            Git,
372            Env,
373        }
374
375        struct CacheKeyVisitor;
376
377        impl<'de> Visitor<'de> for CacheKeyVisitor {
378            type Value = CacheKey;
379
380            fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
381                formatter.write_str("a path or a cache key table")
382            }
383
384            fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
385            where
386                E: serde::de::Error,
387            {
388                Ok(CacheKey::Path(Cow::Owned(value.to_owned())))
389            }
390
391            fn visit_string<E>(self, value: String) -> Result<Self::Value, E>
392            where
393                E: serde::de::Error,
394            {
395                Ok(CacheKey::Path(Cow::Owned(value)))
396            }
397
398            fn visit_map<A>(self, mut map: A) -> Result<Self::Value, A::Error>
399            where
400                A: MapAccess<'de>,
401            {
402                let value = match map.next_key()? {
403                    Some(Field::File) => CacheKey::File {
404                        file: map.next_value()?,
405                    },
406                    Some(Field::Dir) => CacheKey::Directory {
407                        dir: map.next_value()?,
408                    },
409                    Some(Field::Git) => CacheKey::Git {
410                        git: map.next_value()?,
411                    },
412                    Some(Field::Env) => CacheKey::Environment {
413                        env: map.next_value()?,
414                    },
415                    None => return Err(serde::de::Error::custom("empty cache key table")),
416                };
417                if map.next_key::<IgnoredAny>()?.is_some() {
418                    return Err(serde::de::Error::custom("extra cache key field"));
419                }
420                Ok(value)
421            }
422        }
423
424        deserializer.deserialize_any(CacheKeyVisitor)
425    }
426}
427
428#[derive(Debug, Clone, DeserializeUntagged)]
429#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
430#[cfg_attr(
431    feature = "schemars",
432    schemars(untagged, rename_all = "kebab-case", deny_unknown_fields)
433)]
434#[serde(expecting = "a boolean or a Git cache key table")]
435pub enum GitPattern {
436    #[untagged(bool)]
437    Bool(bool),
438    #[untagged(map)]
439    Set(GitSet),
440}
441
442#[derive(Debug, Clone, serde::Deserialize)]
443#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
444#[serde(rename_all = "kebab-case", deny_unknown_fields)]
445pub struct GitSet {
446    commit: Option<bool>,
447    tags: Option<bool>,
448}
449
450/// A timestamp used to measure changes to a directory.
451#[derive(Debug, Clone, Hash, PartialEq, Eq, serde::Deserialize, serde::Serialize)]
452#[serde(untagged, rename_all = "kebab-case", deny_unknown_fields)]
453enum DirectoryTimestamp {
454    Timestamp(Timestamp),
455    Inode(u64),
456}
457
458#[cfg(test)]
459mod tests {
460    use super::{CacheKey, ToolUv};
461
462    #[test]
463    fn cache_key_forms() -> Result<(), toml::de::Error> {
464        let settings: ToolUv = toml::from_str(
465            r#"
466            cache-keys = [
467                "Cargo.lock",
468                { file = "**/*.toml" },
469                { dir = "src" },
470                { git = true },
471                { git = { commit = true, tags = false } },
472                { git = {} },
473                { env = "UV_CACHE_INFO" },
474            ]
475        "#,
476        )?;
477        insta::assert_debug_snapshot!(settings.cache_keys, @r#"
478        Some(
479            [
480                Path(
481                    "Cargo.lock",
482                ),
483                File {
484                    file: "**/*.toml",
485                },
486                Directory {
487                    dir: "src",
488                },
489                Git {
490                    git: Bool(
491                        true,
492                    ),
493                },
494                Git {
495                    git: Set(
496                        GitSet {
497                            commit: Some(
498                                true,
499                            ),
500                            tags: Some(
501                                false,
502                            ),
503                        },
504                    ),
505                },
506                Git {
507                    git: Set(
508                        GitSet {
509                            commit: None,
510                            tags: None,
511                        },
512                    ),
513                },
514                Environment {
515                    env: "UV_CACHE_INFO",
516                },
517            ],
518        )
519        "#);
520        Ok(())
521    }
522
523    #[test]
524    fn cache_key_extra_field() {
525        insta::assert_snapshot!(toml::from_str::<CacheKey>(r#"file = "Cargo.lock"
526            env = "UV_CACHE_INFO"
527        "#).unwrap_err(), @r#"
528        TOML parse error at line 1, column 1
529          |
530        1 | file = "Cargo.lock"
531          | ^
532        extra cache key field
533        "#);
534    }
535}
536
537#[cfg(all(test, unix))]
538mod tests_unix {
539    use anyhow::Result;
540
541    use super::{CacheInfo, Timestamp};
542
543    #[test]
544    fn test_cache_info_symlink_resolve() -> Result<()> {
545        let dir = tempfile::tempdir()?;
546        let dir = dir.path().join("dir");
547        fs_err::create_dir_all(&dir)?;
548
549        let write_manifest = |cache_key: &str| {
550            fs_err::write(
551                dir.join("pyproject.toml"),
552                format!(
553                    r#"
554                [tool.uv]
555                cache-keys = [
556                    "{cache_key}"
557                ]
558                "#
559                ),
560            )
561        };
562
563        let touch = |path: &str| -> Result<_> {
564            let path = dir.join(path);
565            fs_err::create_dir_all(path.parent().unwrap())?;
566            fs_err::write(&path, "")?;
567            Ok(Timestamp::from_metadata(&path.metadata()?))
568        };
569
570        let cache_timestamp = || -> Result<_> { Ok(CacheInfo::from_directory(&dir)?.timestamp) };
571
572        write_manifest("x/**")?;
573        assert_eq!(cache_timestamp()?, None);
574        let y = touch("x/y")?;
575        assert_eq!(cache_timestamp()?, Some(y));
576        let z = touch("x/z")?;
577        assert_eq!(cache_timestamp()?, Some(z));
578
579        // leaf entry symlink should be resolved
580        let a = touch("../a")?;
581        fs_err::os::unix::fs::symlink(dir.join("../a"), dir.join("x/a"))?;
582        assert_eq!(cache_timestamp()?, Some(a));
583
584        // symlink directories should not be followed while globbing
585        let c = touch("../b/c")?;
586        fs_err::os::unix::fs::symlink(dir.join("../b"), dir.join("x/b"))?;
587        assert_eq!(cache_timestamp()?, Some(a));
588
589        // no globs, should work as expected
590        write_manifest("x/y")?;
591        assert_eq!(cache_timestamp()?, Some(y));
592        write_manifest("x/a")?;
593        assert_eq!(cache_timestamp()?, Some(a));
594        write_manifest("x/b/c")?;
595        assert_eq!(cache_timestamp()?, Some(c));
596
597        // symlink pointing to a directory
598        write_manifest("x/*b*")?;
599        assert_eq!(cache_timestamp()?, None);
600
601        Ok(())
602    }
603}