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