Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 69 additions & 1 deletion crates/uv-cache-info/src/cache_info.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,11 @@ pub struct CacheInfo {
/// The Git tags present at the time of the build.
tags: Option<Tags>,
/// Environment variables to include in the cache key.
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
#[serde(default)]
env: BTreeMap<String, Option<String>>,
/// The timestamp or inode of any directories that should be considered in the cache key.
#[serde(default)]
directories: BTreeMap<String, Option<DirectoryTimestamp>>,
}

impl CacheInfo {
Expand All @@ -59,6 +62,7 @@ impl CacheInfo {
let mut commit = None;
let mut tags = None;
let mut timestamp = None;
let mut directories = BTreeMap::new();
let mut env = BTreeMap::new();

// Read the cache keys.
Expand All @@ -82,6 +86,9 @@ impl CacheInfo {
CacheKey::Path("pyproject.toml".to_string()),
CacheKey::Path("setup.py".to_string()),
CacheKey::Path("setup.cfg".to_string()),
CacheKey::Directory {
dir: "src".to_string(),
},
]
});

Expand Down Expand Up @@ -117,6 +124,51 @@ impl CacheInfo {
}
timestamp = max(timestamp, Some(Timestamp::from_metadata(&metadata)));
}
CacheKey::Directory { dir } => {
// Treat the path as a directory.
let path = directory.join(&dir);
let metadata = match path.metadata() {
Ok(metadata) => metadata,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
directories.insert(dir, None);
continue;
}
Err(err) => {
warn!("Failed to read metadata for directory: {err}");
continue;
}
};
if !metadata.is_dir() {
warn!(
"Expected directory for cache key, but found file: `{}`",
path.display()
);
continue;
}

if let Ok(created) = metadata.created() {
// Prefer the creation time.
directories.insert(
dir,
Some(DirectoryTimestamp::Timestamp(Timestamp::from(created))),
);
} else {
// Fall back to the inode.
#[cfg(unix)]
{
use std::os::unix::fs::MetadataExt;
directories
.insert(dir, Some(DirectoryTimestamp::Inode(metadata.ino())));
}
#[cfg(not(unix))]
{
warn!(
"Failed to read creation time for directory: `{}`",
path.display()
);
}
}
}
CacheKey::Git {
git: GitPattern::Bool(true),
} => match Commit::from_repository(directory) {
Expand Down Expand Up @@ -186,11 +238,16 @@ impl CacheInfo {
}
}

debug!(
"Computed cache info: {timestamp:?}, {commit:?}, {tags:?}, {env:?}, {directories:?}"
);

Ok(Self {
timestamp,
commit,
tags,
env,
directories,
})
}

Expand All @@ -211,6 +268,7 @@ impl CacheInfo {
&& self.commit.is_none()
&& self.tags.is_none()
&& self.env.is_empty()
&& self.directories.is_empty()
}
}

Expand Down Expand Up @@ -241,6 +299,8 @@ pub enum CacheKey {
Path(String),
/// Ex) `{ file = "Cargo.lock" }` or `{ file = "**/*.toml" }`
File { file: String },
/// Ex) `{ dir = "src" }`
Directory { dir: String },
/// Ex) `{ git = true }` or `{ git = { commit = true, tags = false } }`
Git { git: GitPattern },
/// Ex) `{ env = "UV_CACHE_INFO" }`
Expand All @@ -267,3 +327,11 @@ pub enum FilePattern {
Glob(String),
Path(PathBuf),
}

/// A timestamp used to measure changes to a directory.
#[derive(Debug, Clone, Hash, PartialEq, Eq, serde::Deserialize, serde::Serialize)]
#[serde(untagged, rename_all = "kebab-case", deny_unknown_fields)]
enum DirectoryTimestamp {
Timestamp(Timestamp),
Inode(u64),
}
6 changes: 6 additions & 0 deletions crates/uv-cache-info/src/timestamp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -44,3 +44,9 @@ impl Timestamp {
Self(std::time::SystemTime::now())
}
}

impl From<std::time::SystemTime> for Timestamp {
fn from(system_time: std::time::SystemTime) -> Self {
Self(system_time)
}
}
5 changes: 3 additions & 2 deletions crates/uv-settings/src/settings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -59,10 +59,11 @@ pub struct Options {
///
/// Cache keys enable you to specify the files or directories that should trigger a rebuild when
/// modified. By default, uv will rebuild a project whenever the `pyproject.toml`, `setup.py`,
/// or `setup.cfg` files in the project directory are modified, i.e.:
/// or `setup.cfg` files in the project directory are modified, or if a `src` directory is
/// added or removed, i.e.:
///
/// ```toml
/// cache-keys = [{ file = "pyproject.toml" }, { file = "setup.py" }, { file = "setup.cfg" }]
/// cache-keys = [{ file = "pyproject.toml" }, { file = "setup.py" }, { file = "setup.cfg" }, { dir = "src" }]
/// ```
///
/// As an example: if a project uses dynamic metadata to read its dependencies from a
Expand Down
28 changes: 14 additions & 14 deletions crates/uv/tests/it/lock.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10607,7 +10607,7 @@ fn lock_mixed_extras() -> Result<()> {
[tool.uv.workspace]
members = ["packages/*"]
"#})?;
workspace1.child("src/__init__.py").touch()?;
workspace1.child("src/workspace1/__init__.py").touch()?;

let leaf1 = workspace1.child("packages").child("leaf1");
leaf1.child("pyproject.toml").write_str(indoc! {r#"
Expand All @@ -10621,10 +10621,10 @@ fn lock_mixed_extras() -> Result<()> {
async = ["iniconfig>=2"]

[build-system]
requires = ["setuptools>=42"]
build-backend = "setuptools.build_meta"
requires = ["hatchling"]
build-backend = "hatchling.build"
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ugh... setuptools puts {name}.egg-info inside src, so it bumps the cache key here (since creating a directory within a directory bumps the parent's mtime and ctime).

I could just record directory existence and have the same effect? There doesn't seem to be reliable way to get directory creation time (ignoring child contents) cross-platform.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually, I can probably use the inode number instead of mtime.

"#})?;
leaf1.child("src/__init__.py").touch()?;
leaf1.child("src/leaf1/__init__.py").touch()?;

// Create a second workspace (`workspace2`) with an extra of the same name.
let workspace2 = context.temp_dir.child("workspace2");
Expand All @@ -10636,16 +10636,16 @@ fn lock_mixed_extras() -> Result<()> {
dependencies = ["leaf2"]

[build-system]
requires = ["setuptools>=42"]
build-backend = "setuptools.build_meta"
requires = ["hatchling"]
build-backend = "hatchling.build"

[tool.uv.sources]
leaf2 = { workspace = true }

[tool.uv.workspace]
members = ["packages/*"]
"#})?;
workspace2.child("src/__init__.py").touch()?;
workspace2.child("src/workspace2/__init__.py").touch()?;

let leaf2 = workspace2.child("packages").child("leaf2");
leaf2.child("pyproject.toml").write_str(indoc! {r#"
Expand All @@ -10659,10 +10659,10 @@ fn lock_mixed_extras() -> Result<()> {
async = ["packaging>=24"]

[build-system]
requires = ["setuptools>=42"]
build-backend = "setuptools.build_meta"
requires = ["hatchling"]
build-backend = "hatchling.build"
"#})?;
leaf2.child("src/__init__.py").touch()?;
leaf2.child("src/leaf2/__init__.py").touch()?;

// Lock the first workspace.
uv_snapshot!(context.filters(), context.lock().current_dir(&workspace1), @r###"
Expand Down Expand Up @@ -10842,7 +10842,7 @@ fn lock_transitive_extra() -> Result<()> {
[tool.uv.workspace]
members = ["packages/*"]
"#})?;
workspace.child("src/__init__.py").touch()?;
workspace.child("src/workspace/__init__.py").touch()?;

let leaf = workspace.child("packages").child("leaf");
leaf.child("pyproject.toml").write_str(indoc! {r#"
Expand All @@ -10856,10 +10856,10 @@ fn lock_transitive_extra() -> Result<()> {
async = ["iniconfig>=2"]

[build-system]
requires = ["setuptools>=42"]
build-backend = "setuptools.build_meta"
requires = ["hatchling"]
build-backend = "hatchling.build"
"#})?;
leaf.child("src/__init__.py").touch()?;
leaf.child("src/leaf/__init__.py").touch()?;

// Lock the workspace.
uv_snapshot!(context.filters(), context.lock().current_dir(&workspace), @r###"
Expand Down
Loading
Loading