diff --git a/crates/uv/src/commands/project/environment.rs b/crates/uv/src/commands/project/environment.rs index c80bac37cb76f..5c2d83c1c7a2c 100644 --- a/crates/uv/src/commands/project/environment.rs +++ b/crates/uv/src/commands/project/environment.rs @@ -11,10 +11,13 @@ use crate::printer::Printer; use crate::settings::ResolverInstallerSettings; use uv_cache::{Cache, CacheBucket}; +use uv_cache_info::CacheInfo; use uv_cache_key::{cache_digest, hash_digest}; use uv_client::BaseClientBuilder; use uv_configuration::{Concurrency, Constraints, TargetTriple}; -use uv_distribution_types::{Name, Resolution}; +use uv_distribution_types::{ + BuiltDist, Dist, Identifier, Node, Resolution, ResolvedDist, SourceDist, +}; use uv_fs::PythonExt; use uv_preview::Preview; use uv_python::{Interpreter, PythonEnvironment, canonicalize_executable}; @@ -107,6 +110,13 @@ impl From for PythonEnvironment { } } +#[derive(Debug, Clone, Hash)] +struct CachedEnvironmentDist { + dist: ResolvedDist, + hashes: uv_pypi_types::HashDigests, + cache_info: Option, +} + impl CachedEnvironment { /// Get or create an [`CachedEnvironment`] based on a given set of requirements. pub(crate) async fn from_spec( @@ -149,11 +159,31 @@ impl CachedEnvironment { ); // Hash the resolution by hashing the generated lockfile. - // TODO(charlie): If the resolution contains any mutable metadata (like a path or URL - // dependency), skip this step. let resolution_hash = { - let mut distributions = resolution.distributions().collect::>(); - distributions.sort_unstable_by_key(|dist| dist.name()); + let mut distributions = resolution + .graph() + .node_weights() + .filter_map(|node| match node { + Node::Dist { + dist, + hashes, + install: true, + } => Some((dist, hashes)), + Node::Dist { install: false, .. } | Node::Root => None, + }) + .map(|(dist, hashes)| { + Ok(CachedEnvironmentDist { + dist: dist.clone(), + hashes: hashes.clone(), + cache_info: Self::cache_info(dist).map_err(ProjectError::from)?, + }) + }) + .collect::, ProjectError>>()?; + distributions.sort_unstable_by(|left, right| { + left.dist + .distribution_id() + .cmp(&right.dist.distribution_id()) + }); hash_digest(&distributions) }; @@ -220,6 +250,22 @@ impl CachedEnvironment { Ok(Self(PythonEnvironment::from_root(root, cache)?)) } + /// Return any mutable cache info that should invalidate a cached environment for a given + /// distribution. + fn cache_info(dist: &ResolvedDist) -> Result, uv_cache_info::CacheInfoError> { + let path = match dist { + ResolvedDist::Installed { .. } => return Ok(None), + ResolvedDist::Installable { dist, .. } => match dist.as_ref() { + Dist::Built(BuiltDist::Path(wheel)) => wheel.install_path.as_ref(), + Dist::Source(SourceDist::Path(sdist)) => sdist.install_path.as_ref(), + Dist::Source(SourceDist::Directory(directory)) => directory.install_path.as_ref(), + _ => return Ok(None), + }, + }; + + Ok(Some(CacheInfo::from_path(path)?)) + } + /// Return the [`Interpreter`] to use for the cached environment, based on a given /// [`Interpreter`]. /// diff --git a/crates/uv/src/commands/project/mod.rs b/crates/uv/src/commands/project/mod.rs index b7c2256426dc2..9cbabb35c540f 100644 --- a/crates/uv/src/commands/project/mod.rs +++ b/crates/uv/src/commands/project/mod.rs @@ -330,6 +330,9 @@ pub(crate) enum ProjectError { #[error(transparent)] Fmt(#[from] std::fmt::Error), + #[error(transparent)] + CacheInfo(#[from] uv_cache_info::CacheInfoError), + #[error(transparent)] Io(#[from] std::io::Error), diff --git a/crates/uv/tests/it/run.rs b/crates/uv/tests/it/run.rs index b40c9bf0fc408..cff25f2754341 100644 --- a/crates/uv/tests/it/run.rs +++ b/crates/uv/tests/it/run.rs @@ -1396,6 +1396,134 @@ fn run_with() -> Result<()> { Ok(()) } +#[test] +fn run_with_local_wheel_refreshes_rebuilt_wheel() -> Result<()> { + let context = uv_test::test_context_with_versions!(&["3.12"]); + + let package = context.temp_dir.child("foo"); + package.child("pyproject.toml").write_str(indoc! { r#" + [project] + name = "foo" + version = "0.1.0" + requires-python = ">=3.12" + + [build-system] + requires = ["uv_build>=0.7,<10000"] + build-backend = "uv_build" + "# + })?; + let init = package.child("src").child("foo").child("__init__.py"); + init.write_str(indoc! { r#" + def hello() -> str: + return "Hello from foo!" + "# + })?; + + context + .build() + .arg("--wheel") + .current_dir(package.path()) + .assert() + .success(); + + let wheel = package.child("dist").child("foo-0.1.0-py3-none-any.whl"); + filetime::set_file_mtime( + wheel.path(), + filetime::FileTime::from_unix_time(1_700_000_000, 0), + ) + .unwrap(); + + // First run: install the original wheel. + uv_snapshot!(context.filters(), context.run() + .arg("--isolated") + .arg("--refresh") + .arg("--with") + .arg(wheel.as_os_str()) + .arg("python") + .arg("-c") + .arg("import foo; print(foo.hello())") + .env_remove(EnvVars::VIRTUAL_ENV), @r" + success: true + exit_code: 0 + ----- stdout ----- + Hello from foo! + + ----- stderr ----- + Resolved 1 package in [TIME] + Prepared 1 package in [TIME] + Installed 1 package in [TIME] + + foo==0.1.0 (from file://[TEMP_DIR]/foo/dist/foo-0.1.0-py3-none-any.whl) + "); + + init.write_str(indoc! { r#" + def hello() -> str: + return "Updated code!" + "# + })?; + fs_err::remove_file(wheel.path())?; + + context + .build() + .arg("--wheel") + .current_dir(package.path()) + .assert() + .success(); + + filetime::set_file_mtime( + wheel.path(), + filetime::FileTime::from_unix_time(1_700_000_001, 0), + ) + .unwrap(); + + // Second run: should pick up the rebuilt wheel due to `--refresh`. + uv_snapshot!(context.filters(), context.run() + .arg("--isolated") + .arg("--refresh") + .arg("--with") + .arg(wheel.as_os_str()) + .arg("python") + .arg("-c") + .arg("import foo; print(foo.hello())") + .env_remove(EnvVars::VIRTUAL_ENV), @r" + success: true + exit_code: 0 + ----- stdout ----- + Updated code! + + ----- stderr ----- + Resolved 1 package in [TIME] + Prepared 1 package in [TIME] + Installed 1 package in [TIME] + + foo==0.1.0 (from file://[TEMP_DIR]/foo/dist/foo-0.1.0-py3-none-any.whl) + "); + + context.prune().assert().success(); + + // Third run: after cache prune, should still see the updated code. + uv_snapshot!(context.filters(), context.run() + .arg("--isolated") + .arg("--refresh") + .arg("--with") + .arg(wheel.as_os_str()) + .arg("python") + .arg("-c") + .arg("import foo; print(foo.hello())") + .env_remove(EnvVars::VIRTUAL_ENV), @r" + success: true + exit_code: 0 + ----- stdout ----- + Updated code! + + ----- stderr ----- + Resolved 1 package in [TIME] + Prepared 1 package in [TIME] + Installed 1 package in [TIME] + + foo==0.1.0 (from file://[TEMP_DIR]/foo/dist/foo-0.1.0-py3-none-any.whl) + "); + + Ok(()) +} + /// Test that an ephemeral environment writes the path of its parent environment to the `extends-environment` key /// of its `pyvenv.cfg` file. This feature makes it easier for static-analysis tools like ty to resolve which import /// search paths are available in these ephemeral environments.