Skip to content

Commit c8e5c19

Browse files
committed
Add src to default cache keys
1 parent f427164 commit c8e5c19

File tree

11 files changed

+264
-75
lines changed

11 files changed

+264
-75
lines changed

crates/uv-cache-info/src/cache_info.rs

+58
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,9 @@ pub struct CacheInfo {
3030
commit: Option<Commit>,
3131
/// The Git tags present at the time of the build.
3232
tags: Option<Tags>,
33+
/// The timestamp or inode of any directories that should be considered in the cache key.
34+
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
35+
directories: BTreeMap<String, DirectoryTimestamp>,
3336
/// Environment variables to include in the cache key.
3437
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
3538
env: BTreeMap<String, Option<String>>,
@@ -59,6 +62,7 @@ impl CacheInfo {
5962
let mut commit = None;
6063
let mut tags = None;
6164
let mut timestamp = None;
65+
let mut directories = BTreeMap::new();
6266
let mut env = BTreeMap::new();
6367

6468
// Read the cache keys.
@@ -82,6 +86,9 @@ impl CacheInfo {
8286
CacheKey::Path("pyproject.toml".to_string()),
8387
CacheKey::Path("setup.py".to_string()),
8488
CacheKey::Path("setup.cfg".to_string()),
89+
CacheKey::Directory {
90+
dir: "src".to_string(),
91+
},
8592
]
8693
});
8794

@@ -117,6 +124,47 @@ impl CacheInfo {
117124
}
118125
timestamp = max(timestamp, Some(Timestamp::from_metadata(&metadata)));
119126
}
127+
CacheKey::Directory { dir } => {
128+
// Treat the path as a directory.
129+
let path = directory.join(&dir);
130+
let metadata = match path.metadata() {
131+
Ok(metadata) => metadata,
132+
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
133+
continue;
134+
}
135+
Err(err) => {
136+
warn!("Failed to read metadata for directory: {err}");
137+
continue;
138+
}
139+
};
140+
if !metadata.is_dir() {
141+
warn!(
142+
"Expected directory for cache key, but found file: `{}`",
143+
path.display()
144+
);
145+
continue;
146+
}
147+
148+
if let Ok(created) = metadata.created() {
149+
// Prefer the creation time.
150+
directories
151+
.insert(dir, DirectoryTimestamp::Timestamp(Timestamp::from(created)));
152+
} else {
153+
// Fall back to the inode.
154+
#[cfg(unix)]
155+
{
156+
use std::os::unix::fs::MetadataExt;
157+
directories.insert(dir, DirectoryTimestamp::Inode(metadata.ino()));
158+
}
159+
#[cfg(not(unix))]
160+
{
161+
warn!(
162+
"Failed to read creation time for directory: `{}`",
163+
path.display()
164+
);
165+
}
166+
}
167+
}
120168
CacheKey::Git {
121169
git: GitPattern::Bool(true),
122170
} => match Commit::from_repository(directory) {
@@ -190,6 +238,7 @@ impl CacheInfo {
190238
timestamp,
191239
commit,
192240
tags,
241+
directories,
193242
env,
194243
})
195244
}
@@ -241,6 +290,8 @@ pub enum CacheKey {
241290
Path(String),
242291
/// Ex) `{ file = "Cargo.lock" }` or `{ file = "**/*.toml" }`
243292
File { file: String },
293+
/// Ex) `{ dir = "src" }`
294+
Directory { dir: String },
244295
/// Ex) `{ git = true }` or `{ git = { commit = true, tags = false } }`
245296
Git { git: GitPattern },
246297
/// Ex) `{ env = "UV_CACHE_INFO" }`
@@ -267,3 +318,10 @@ pub enum FilePattern {
267318
Glob(String),
268319
Path(PathBuf),
269320
}
321+
322+
/// A timestamp used to measure changes to a directory.
323+
#[derive(Debug, Clone, Hash, PartialEq, Eq, serde::Deserialize, serde::Serialize)]
324+
enum DirectoryTimestamp {
325+
Timestamp(Timestamp),
326+
Inode(u64),
327+
}

crates/uv-cache-info/src/timestamp.rs

+6
Original file line numberDiff line numberDiff line change
@@ -44,3 +44,9 @@ impl Timestamp {
4444
Self(std::time::SystemTime::now())
4545
}
4646
}
47+
48+
impl From<std::time::SystemTime> for Timestamp {
49+
fn from(system_time: std::time::SystemTime) -> Self {
50+
Self(system_time)
51+
}
52+
}

crates/uv-settings/src/settings.rs

+3-2
Original file line numberDiff line numberDiff line change
@@ -59,10 +59,11 @@ pub struct Options {
5959
///
6060
/// Cache keys enable you to specify the files or directories that should trigger a rebuild when
6161
/// modified. By default, uv will rebuild a project whenever the `pyproject.toml`, `setup.py`,
62-
/// or `setup.cfg` files in the project directory are modified, i.e.:
62+
/// or `setup.cfg` files in the project directory are modified, or if a `src` directory is
63+
/// added or removed, i.e.:
6364
///
6465
/// ```toml
65-
/// cache-keys = [{ file = "pyproject.toml" }, { file = "setup.py" }, { file = "setup.cfg" }]
66+
/// cache-keys = [{ file = "pyproject.toml" }, { file = "setup.py" }, { file = "setup.cfg" }, { dir = "src" }]
6667
/// ```
6768
///
6869
/// As an example: if a project uses dynamic metadata to read its dependencies from a

crates/uv/src/commands/pip/operations.rs

+2
Original file line numberDiff line numberDiff line change
@@ -261,6 +261,8 @@ pub(crate) async fn resolve<InstalledPackages: InstalledPackagesProvider>(
261261
let overrides = Overrides::from_requirements(overrides);
262262
let preferences = Preferences::from_iter(preferences, &resolver_env);
263263

264+
// Treat any source paths provided on the CLI as requiring `--reinstall`.
265+
264266
// Determine any lookahead requirements.
265267
let lookaheads = match options.dependency_mode {
266268
DependencyMode::Transitive => {

crates/uv/tests/it/lock.rs

+14-14
Original file line numberDiff line numberDiff line change
@@ -10607,7 +10607,7 @@ fn lock_mixed_extras() -> Result<()> {
1060710607
[tool.uv.workspace]
1060810608
members = ["packages/*"]
1060910609
"#})?;
10610-
workspace1.child("src/__init__.py").touch()?;
10610+
workspace1.child("src/workspace1/__init__.py").touch()?;
1061110611

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

1062310623
[build-system]
10624-
requires = ["setuptools>=42"]
10625-
build-backend = "setuptools.build_meta"
10624+
requires = ["hatchling"]
10625+
build-backend = "hatchling.build"
1062610626
"#})?;
10627-
leaf1.child("src/__init__.py").touch()?;
10627+
leaf1.child("src/leaf1/__init__.py").touch()?;
1062810628

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

1063810638
[build-system]
10639-
requires = ["setuptools>=42"]
10640-
build-backend = "setuptools.build_meta"
10639+
requires = ["hatchling"]
10640+
build-backend = "hatchling.build"
1064110641

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

1064510645
[tool.uv.workspace]
1064610646
members = ["packages/*"]
1064710647
"#})?;
10648-
workspace2.child("src/__init__.py").touch()?;
10648+
workspace2.child("src/workspace2/__init__.py").touch()?;
1064910649

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

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

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

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

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

1086410864
// Lock the workspace.
1086510865
uv_snapshot!(context.filters(), context.lock().current_dir(&workspace), @r###"

crates/uv/tests/it/pip_install.rs

+91
Original file line numberDiff line numberDiff line change
@@ -9183,3 +9183,94 @@ fn unsupported_git_scheme() {
91839183
"###
91849184
);
91859185
}
9186+
9187+
/// Modify a project to use a `src` layout.
9188+
#[test]
9189+
fn change_layout() -> Result<()> {
9190+
let context = TestContext::new("3.12");
9191+
9192+
let pyproject_toml = context.temp_dir.child("pyproject.toml");
9193+
pyproject_toml.write_str(
9194+
r#"
9195+
[project]
9196+
name = "project"
9197+
version = "0.1.0"
9198+
requires-python = ">=3.12"
9199+
dependencies = ["iniconfig"]
9200+
9201+
[build-system]
9202+
requires = ["hatchling"]
9203+
build-backend = "hatchling.build"
9204+
"#,
9205+
)?;
9206+
9207+
context
9208+
.temp_dir
9209+
.child("src")
9210+
.child("project")
9211+
.child("__init__.py")
9212+
.touch()?;
9213+
9214+
// Installing should build the package.
9215+
uv_snapshot!(context.filters(), context.pip_install().arg("-e").arg("."), @r###"
9216+
success: true
9217+
exit_code: 0
9218+
----- stdout -----
9219+
9220+
----- stderr -----
9221+
Resolved 2 packages in [TIME]
9222+
Prepared 2 packages in [TIME]
9223+
Installed 2 packages in [TIME]
9224+
+ iniconfig==2.0.0
9225+
+ project==0.1.0 (from file://[TEMP_DIR]/)
9226+
"###
9227+
);
9228+
9229+
// Reinstalling should have no effect.
9230+
uv_snapshot!(context.filters(), context.pip_install().arg("-e").arg("."), @r###"
9231+
success: true
9232+
exit_code: 0
9233+
----- stdout -----
9234+
9235+
----- stderr -----
9236+
Audited 1 package in [TIME]
9237+
"###
9238+
);
9239+
9240+
// Replace the `src` layout with a flat layout.
9241+
fs_err::remove_dir_all(context.temp_dir.child("src").path())?;
9242+
9243+
context
9244+
.temp_dir
9245+
.child("project")
9246+
.child("__init__.py")
9247+
.touch()?;
9248+
9249+
// Installing should rebuild the package.
9250+
uv_snapshot!(context.filters(), context.pip_install().arg("-e").arg("."), @r###"
9251+
success: true
9252+
exit_code: 0
9253+
----- stdout -----
9254+
9255+
----- stderr -----
9256+
Resolved 2 packages in [TIME]
9257+
Prepared 1 package in [TIME]
9258+
Uninstalled 1 package in [TIME]
9259+
Installed 1 package in [TIME]
9260+
~ project==0.1.0 (from file://[TEMP_DIR]/)
9261+
"###
9262+
);
9263+
9264+
// Reinstalling should have no effect.
9265+
uv_snapshot!(context.filters(), context.pip_install().arg("-e").arg("."), @r###"
9266+
success: true
9267+
exit_code: 0
9268+
----- stdout -----
9269+
9270+
----- stderr -----
9271+
Audited 1 package in [TIME]
9272+
"###
9273+
);
9274+
9275+
Ok(())
9276+
}

0 commit comments

Comments
 (0)