From 72be5ffb254f5457ab34b3b3c32c68c3a4c408fd Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Mon, 17 Mar 2025 14:12:21 -0700 Subject: [PATCH] Always reinstall local source trees passed to `uv pip install` (#12176) ## Summary This ended up being more involved than expected. The gist is that we setup all the packages we want to reinstall upfront (they're passed in on the command-line); but at that point, we don't have names for all the packages that the user has specified. (Consider, e.g., `uv pip install .` -- we don't have a name for `.`, so we can't add it to the list of `Reinstall` packages.) Now, `Reinstall` also accepts paths, so we can augment `Reinstall` based on the user-provided paths. Closes #12038. --- Cargo.lock | 3 + crates/uv-cache/Cargo.toml | 1 + crates/uv-cache/src/lib.rs | 55 ++-- crates/uv-client/src/flat_index.rs | 2 +- crates/uv-client/src/registry_client.rs | 6 +- crates/uv-configuration/Cargo.toml | 1 + .../uv-configuration/src/package_options.rs | 71 ++++-- crates/uv-distribution-types/src/buildable.rs | 16 ++ crates/uv-distribution-types/src/installed.rs | 6 +- crates/uv-distribution-types/src/lib.rs | 20 +- crates/uv-distribution-types/src/resolved.rs | 9 + .../src/distribution_database.rs | 4 +- crates/uv-distribution/src/source/mod.rs | 6 +- crates/uv-installer/src/plan.rs | 22 +- crates/uv-installer/src/site_packages.rs | 8 +- crates/uv-installer/src/uninstall.rs | 6 +- crates/uv-python/src/interpreter.rs | 2 +- crates/uv-requirements/src/sources.rs | 43 +++- crates/uv-requirements/src/specification.rs | 30 +-- crates/uv-resolver/src/exclusions.rs | 2 +- crates/uv-tool/src/lib.rs | 2 +- crates/uv/Cargo.toml | 3 +- crates/uv/src/commands/pip/operations.rs | 4 +- crates/uv/src/commands/pip/show.rs | 4 +- crates/uv/src/commands/pip/uninstall.rs | 4 +- crates/uv/src/commands/tool/install.rs | 4 +- crates/uv/src/lib.rs | 109 +++++--- crates/uv/tests/it/cache_prune.rs | 1 + crates/uv/tests/it/pip_install.rs | 238 +++++++++++------- docs/concepts/cache.md | 3 + 30 files changed, 471 insertions(+), 214 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b8952d438..ad5a3fb6e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4625,6 +4625,7 @@ dependencies = [ "uv-pypi-types", "uv-python", "uv-requirements", + "uv-requirements-txt", "uv-resolver", "uv-scripts", "uv-settings", @@ -4785,6 +4786,7 @@ dependencies = [ "nanoid", "rmp-serde", "rustc-hash", + "same-file", "serde", "tempfile", "tracing", @@ -4916,6 +4918,7 @@ dependencies = [ "fs-err 3.1.0", "rayon", "rustc-hash", + "same-file", "schemars", "serde", "serde-untagged", diff --git a/crates/uv-cache/Cargo.toml b/crates/uv-cache/Cargo.toml index 658681d95..dc304da98 100644 --- a/crates/uv-cache/Cargo.toml +++ b/crates/uv-cache/Cargo.toml @@ -32,6 +32,7 @@ fs-err = { workspace = true, features = ["tokio"] } nanoid = { workspace = true } rmp-serde = { workspace = true } rustc-hash = { workspace = true } +same-file = { workspace = true } serde = { workspace = true, features = ["derive"] } tempfile = { workspace = true } tracing = { workspace = true } diff --git a/crates/uv-cache/src/lib.rs b/crates/uv-cache/src/lib.rs index d32bc18a1..4e3ff1f95 100644 --- a/crates/uv-cache/src/lib.rs +++ b/crates/uv-cache/src/lib.rs @@ -223,11 +223,22 @@ impl Cache { } /// Returns `true` if a cache entry must be revalidated given the [`Refresh`] policy. - pub fn must_revalidate(&self, package: &PackageName) -> bool { + pub fn must_revalidate_package(&self, package: &PackageName) -> bool { match &self.refresh { Refresh::None(_) => false, Refresh::All(_) => true, - Refresh::Packages(packages, _) => packages.contains(package), + Refresh::Packages(packages, _, _) => packages.contains(package), + } + } + + /// Returns `true` if a cache entry must be revalidated given the [`Refresh`] policy. + pub fn must_revalidate_path(&self, path: &Path) -> bool { + match &self.refresh { + Refresh::None(_) => false, + Refresh::All(_) => true, + Refresh::Packages(_, paths, _) => paths + .iter() + .any(|target| same_file::is_same_file(path, target).unwrap_or(false)), } } @@ -239,13 +250,20 @@ impl Cache { &self, entry: &CacheEntry, package: Option<&PackageName>, + path: Option<&Path>, ) -> io::Result { // Grab the cutoff timestamp, if it's relevant. let timestamp = match &self.refresh { Refresh::None(_) => return Ok(Freshness::Fresh), Refresh::All(timestamp) => timestamp, - Refresh::Packages(packages, timestamp) => { - if package.is_none_or(|package| packages.contains(package)) { + Refresh::Packages(packages, paths, timestamp) => { + if package.is_none_or(|package| packages.contains(package)) + || path.is_some_and(|path| { + paths + .iter() + .any(|target| same_file::is_same_file(path, target).unwrap_or(false)) + }) + { timestamp } else { return Ok(Freshness::Fresh); @@ -1167,7 +1185,7 @@ pub enum Refresh { /// Don't refresh any entries. None(Timestamp), /// Refresh entries linked to the given packages, if created before the given timestamp. - Packages(Vec, Timestamp), + Packages(Vec, Vec, Timestamp), /// Refresh all entries created before the given timestamp. All(Timestamp), } @@ -1183,7 +1201,7 @@ impl Refresh { if refresh_package.is_empty() { Self::None(timestamp) } else { - Self::Packages(refresh_package, timestamp) + Self::Packages(refresh_package, vec![], timestamp) } } } @@ -1193,7 +1211,7 @@ impl Refresh { pub fn timestamp(&self) -> Timestamp { match self { Self::None(timestamp) => *timestamp, - Self::Packages(_, timestamp) => *timestamp, + Self::Packages(.., timestamp) => *timestamp, Self::All(timestamp) => *timestamp, } } @@ -1220,24 +1238,27 @@ impl Refresh { // Take the `max` of the two timestamps. (Self::None(t1), Refresh::None(t2)) => Refresh::None(max(t1, t2)), (Self::None(t1), Refresh::All(t2)) => Refresh::All(max(t1, t2)), - (Self::None(t1), Refresh::Packages(packages, t2)) => { - Refresh::Packages(packages, max(t1, t2)) + (Self::None(t1), Refresh::Packages(packages, paths, t2)) => { + Refresh::Packages(packages, paths, max(t1, t2)) } // If the policy is `All`, refresh all packages. (Self::All(t1), Refresh::None(t2)) => Refresh::All(max(t1, t2)), (Self::All(t1), Refresh::All(t2)) => Refresh::All(max(t1, t2)), - (Self::All(t1), Refresh::Packages(_packages, t2)) => Refresh::All(max(t1, t2)), + (Self::All(t1), Refresh::Packages(.., t2)) => Refresh::All(max(t1, t2)), // If the policy is `Packages`, take the "max" of the two policies. - (Self::Packages(packages, t1), Refresh::None(t2)) => { - Refresh::Packages(packages, max(t1, t2)) + (Self::Packages(packages, paths, t1), Refresh::None(t2)) => { + Refresh::Packages(packages, paths, max(t1, t2)) + } + (Self::Packages(.., t1), Refresh::All(t2)) => Refresh::All(max(t1, t2)), + (Self::Packages(packages1, paths1, t1), Refresh::Packages(packages2, paths2, t2)) => { + Refresh::Packages( + packages1.into_iter().chain(packages2).collect(), + paths1.into_iter().chain(paths2).collect(), + max(t1, t2), + ) } - (Self::Packages(_packages, t1), Refresh::All(t2)) => Refresh::All(max(t1, t2)), - (Self::Packages(packages1, t1), Refresh::Packages(packages2, t2)) => Refresh::Packages( - packages1.into_iter().chain(packages2).collect(), - max(t1, t2), - ), } } } diff --git a/crates/uv-client/src/flat_index.rs b/crates/uv-client/src/flat_index.rs index bc923f354..1bf9800ce 100644 --- a/crates/uv-client/src/flat_index.rs +++ b/crates/uv-client/src/flat_index.rs @@ -160,7 +160,7 @@ impl<'a> FlatIndexClient<'a> { let cache_control = match self.client.connectivity() { Connectivity::Online => CacheControl::from( self.cache - .freshness(&cache_entry, None) + .freshness(&cache_entry, None, None) .map_err(ErrorKind::Io)?, ), Connectivity::Offline => CacheControl::AllowStale, diff --git a/crates/uv-client/src/registry_client.rs b/crates/uv-client/src/registry_client.rs index ea476e1d7..dd5ed89d4 100644 --- a/crates/uv-client/src/registry_client.rs +++ b/crates/uv-client/src/registry_client.rs @@ -347,7 +347,7 @@ impl RegistryClient { let cache_control = match self.connectivity { Connectivity::Online => CacheControl::from( self.cache - .freshness(&cache_entry, Some(package_name)) + .freshness(&cache_entry, Some(package_name), None) .map_err(ErrorKind::Io)?, ), Connectivity::Offline => CacheControl::AllowStale, @@ -632,7 +632,7 @@ impl RegistryClient { let cache_control = match self.connectivity { Connectivity::Online => CacheControl::from( self.cache - .freshness(&cache_entry, Some(&filename.name)) + .freshness(&cache_entry, Some(&filename.name), None) .map_err(ErrorKind::Io)?, ), Connectivity::Offline => CacheControl::AllowStale, @@ -702,7 +702,7 @@ impl RegistryClient { let cache_control = match self.connectivity { Connectivity::Online => CacheControl::from( self.cache - .freshness(&cache_entry, Some(&filename.name)) + .freshness(&cache_entry, Some(&filename.name), None) .map_err(ErrorKind::Io)?, ), Connectivity::Offline => CacheControl::AllowStale, diff --git a/crates/uv-configuration/Cargo.toml b/crates/uv-configuration/Cargo.toml index cc8b00757..493698ba5 100644 --- a/crates/uv-configuration/Cargo.toml +++ b/crates/uv-configuration/Cargo.toml @@ -32,6 +32,7 @@ either = { workspace = true } fs-err = { workspace = true } rayon = { workspace = true } rustc-hash = { workspace = true } +same-file = { workspace = true } schemars = { workspace = true, optional = true } serde = { workspace = true } serde-untagged = { workspace = true } diff --git a/crates/uv-configuration/src/package_options.rs b/crates/uv-configuration/src/package_options.rs index ac3938386..5c23baf86 100644 --- a/crates/uv-configuration/src/package_options.rs +++ b/crates/uv-configuration/src/package_options.rs @@ -1,4 +1,5 @@ use either::Either; +use std::path::{Path, PathBuf}; use uv_pep508::PackageName; use rustc_hash::FxHashMap; @@ -18,7 +19,7 @@ pub enum Reinstall { All, /// Reinstall only the specified packages. - Packages(Vec), + Packages(Vec, Vec), } impl Reinstall { @@ -31,7 +32,7 @@ impl Reinstall { if reinstall_package.is_empty() { Self::None } else { - Self::Packages(reinstall_package) + Self::Packages(reinstall_package, Vec::new()) } } } @@ -48,11 +49,22 @@ impl Reinstall { } /// Returns `true` if the specified package should be reinstalled. - pub fn contains(&self, package_name: &PackageName) -> bool { + pub fn contains_package(&self, package_name: &PackageName) -> bool { match &self { Self::None => false, Self::All => true, - Self::Packages(packages) => packages.contains(package_name), + Self::Packages(packages, ..) => packages.contains(package_name), + } + } + + /// Returns `true` if the specified path should be reinstalled. + pub fn contains_path(&self, path: &Path) -> bool { + match &self { + Self::None => false, + Self::All => true, + Self::Packages(.., paths) => paths + .iter() + .any(|target| same_file::is_same_file(path, target).unwrap_or(false)), } } @@ -65,19 +77,46 @@ impl Reinstall { // If either is `All`, the result is `All`. (Self::All, _) | (_, Self::All) => Self::All, // If one is `None`, the result is the other. - (Self::Packages(a), Self::None) => Self::Packages(a), - (Self::None, Self::Packages(b)) => Self::Packages(b), + (Self::Packages(a1, a2), Self::None) => Self::Packages(a1, a2), + (Self::None, Self::Packages(b1, b2)) => Self::Packages(b1, b2), // If both are `Packages`, the result is the union of the two. - (Self::Packages(mut a), Self::Packages(b)) => { - a.extend(b); - Self::Packages(a) + (Self::Packages(mut a1, mut a2), Self::Packages(b1, b2)) => { + a1.extend(b1); + a2.extend(b2); + Self::Packages(a1, a2) + } + } + } + + /// Add a [`PathBuf`] to the [`Reinstall`] policy. + #[must_use] + pub fn with_path(self, path: PathBuf) -> Self { + match self { + Self::None => Self::Packages(vec![], vec![path]), + Self::All => Self::All, + Self::Packages(packages, mut paths) => { + paths.push(path); + Self::Packages(packages, paths) + } + } + } + + /// Add a [`Package`] to the [`Reinstall`] policy. + #[must_use] + pub fn with_package(self, package_name: PackageName) -> Self { + match self { + Self::None => Self::Packages(vec![package_name], vec![]), + Self::All => Self::All, + Self::Packages(mut packages, paths) => { + packages.push(package_name); + Self::Packages(packages, paths) } } } /// Create a [`Reinstall`] strategy to reinstall a single package. pub fn package(package_name: PackageName) -> Self { - Self::Packages(vec![package_name]) + Self::Packages(vec![package_name], vec![]) } } @@ -87,7 +126,9 @@ impl From for Refresh { match value { Reinstall::None => Self::None(Timestamp::now()), Reinstall::All => Self::All(Timestamp::now()), - Reinstall::Packages(packages) => Self::Packages(packages, Timestamp::now()), + Reinstall::Packages(packages, paths) => { + Self::Packages(packages, paths, Timestamp::now()) + } } } } @@ -200,9 +241,11 @@ impl From for Refresh { match value { Upgrade::None => Self::None(Timestamp::now()), Upgrade::All => Self::All(Timestamp::now()), - Upgrade::Packages(packages) => { - Self::Packages(packages.into_keys().collect::>(), Timestamp::now()) - } + Upgrade::Packages(packages) => Self::Packages( + packages.into_keys().collect::>(), + Vec::new(), + Timestamp::now(), + ), } } } diff --git a/crates/uv-distribution-types/src/buildable.rs b/crates/uv-distribution-types/src/buildable.rs index e633c81ad..3fe6fe8db 100644 --- a/crates/uv-distribution-types/src/buildable.rs +++ b/crates/uv-distribution-types/src/buildable.rs @@ -32,6 +32,14 @@ impl BuildableSource<'_> { } } + /// Return the source tree of the source, if available. + pub fn source_tree(&self) -> Option<&Path> { + match self { + Self::Dist(dist) => dist.source_tree(), + Self::Url(url) => url.source_tree(), + } + } + /// Return the [`Version`] of the source, if available. pub fn version(&self) -> Option<&Version> { match self { @@ -104,6 +112,14 @@ impl SourceUrl<'_> { } } + /// Return the source tree of the source, if available. + pub fn source_tree(&self) -> Option<&Path> { + match self { + Self::Directory(dist) => Some(&dist.install_path), + _ => None, + } + } + /// Returns `true` if the source is editable. pub fn is_editable(&self) -> bool { matches!( diff --git a/crates/uv-distribution-types/src/installed.rs b/crates/uv-distribution-types/src/installed.rs index 4bd528643..a5b902a21 100644 --- a/crates/uv-distribution-types/src/installed.rs +++ b/crates/uv-distribution-types/src/installed.rs @@ -281,7 +281,7 @@ impl InstalledDist { } /// Return the [`Path`] at which the distribution is stored on-disk. - pub fn path(&self) -> &Path { + pub fn install_path(&self) -> &Path { match self { Self::Registry(dist) => &dist.path, Self::Url(dist) => &dist.path, @@ -332,7 +332,7 @@ impl InstalledDist { pub fn metadata(&self) -> Result { match self { Self::Registry(_) | Self::Url(_) => { - let path = self.path().join("METADATA"); + let path = self.install_path().join("METADATA"); let contents = fs::read(&path)?; // TODO(zanieb): Update this to use thiserror so we can unpack parse errors downstream uv_pypi_types::ResolutionMetadata::parse_metadata(&contents).map_err(|err| { @@ -362,7 +362,7 @@ impl InstalledDist { /// Return the `INSTALLER` of the distribution. pub fn installer(&self) -> Result, InstalledDistError> { - let path = self.path().join("INSTALLER"); + let path = self.install_path().join("INSTALLER"); match fs::read_to_string(path) { Ok(installer) => Ok(Some(installer)), Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None), diff --git a/crates/uv-distribution-types/src/lib.rs b/crates/uv-distribution-types/src/lib.rs index ba8f2b6d6..506f8f114 100644 --- a/crates/uv-distribution-types/src/lib.rs +++ b/crates/uv-distribution-types/src/lib.rs @@ -542,6 +542,14 @@ impl Dist { } } + /// Return the source tree of the distribution, if available. + pub fn source_tree(&self) -> Option<&Path> { + match self { + Self::Built { .. } => None, + Self::Source(source) => source.source_tree(), + } + } + /// Returns the version of the distribution, if it is known. pub fn version(&self) -> Option<&Version> { match self { @@ -657,6 +665,14 @@ impl SourceDist { _ => None, } } + + /// Return the source tree of the distribution, if available. + pub fn source_tree(&self) -> Option<&Path> { + match self { + Self::Directory(dist) => Some(&dist.install_path), + _ => None, + } + } } impl RegistryBuiltDist { @@ -1305,11 +1321,11 @@ impl Identifier for BuiltDist { impl Identifier for InstalledDist { fn distribution_id(&self) -> DistributionId { - self.path().distribution_id() + self.install_path().distribution_id() } fn resource_id(&self) -> ResourceId { - self.path().resource_id() + self.install_path().resource_id() } } diff --git a/crates/uv-distribution-types/src/resolved.rs b/crates/uv-distribution-types/src/resolved.rs index a1eaac3f2..9567f6936 100644 --- a/crates/uv-distribution-types/src/resolved.rs +++ b/crates/uv-distribution-types/src/resolved.rs @@ -1,4 +1,5 @@ use std::fmt::{Display, Formatter}; +use std::path::Path; use std::sync::Arc; use uv_pep440::Version; @@ -92,6 +93,14 @@ impl ResolvedDist { Self::Installed { dist } => Some(dist.version()), } } + + /// Return the source tree of the distribution, if available. + pub fn source_tree(&self) -> Option<&Path> { + match self { + Self::Installable { dist, .. } => dist.source_tree(), + Self::Installed { .. } => None, + } + } } impl ResolvedDistRef<'_> { diff --git a/crates/uv-distribution/src/distribution_database.rs b/crates/uv-distribution/src/distribution_database.rs index ee32d28bf..341a7e5f3 100644 --- a/crates/uv-distribution/src/distribution_database.rs +++ b/crates/uv-distribution/src/distribution_database.rs @@ -594,7 +594,7 @@ impl<'a, Context: BuildContext> DistributionDatabase<'a, Context> { Connectivity::Online => CacheControl::from( self.build_context .cache() - .freshness(&http_entry, Some(&filename.name)) + .freshness(&http_entry, Some(&filename.name), None) .map_err(Error::CacheRead)?, ), Connectivity::Offline => CacheControl::AllowStale, @@ -758,7 +758,7 @@ impl<'a, Context: BuildContext> DistributionDatabase<'a, Context> { Connectivity::Online => CacheControl::from( self.build_context .cache() - .freshness(&http_entry, Some(&filename.name)) + .freshness(&http_entry, Some(&filename.name), None) .map_err(Error::CacheRead)?, ), Connectivity::Offline => CacheControl::AllowStale, diff --git a/crates/uv-distribution/src/source/mod.rs b/crates/uv-distribution/src/source/mod.rs index d7af5d331..a09b7a8e3 100644 --- a/crates/uv-distribution/src/source/mod.rs +++ b/crates/uv-distribution/src/source/mod.rs @@ -690,7 +690,7 @@ impl<'a, T: BuildContext> SourceDistributionBuilder<'a, T> { Connectivity::Online => CacheControl::from( self.build_context .cache() - .freshness(&cache_entry, source.name()) + .freshness(&cache_entry, source.name(), source.source_tree()) .map_err(Error::CacheRead)?, ), Connectivity::Offline => CacheControl::AllowStale, @@ -1359,7 +1359,7 @@ impl<'a, T: BuildContext> SourceDistributionBuilder<'a, T> { if self .build_context .cache() - .freshness(&entry, source.name()) + .freshness(&entry, source.name(), source.source_tree()) .map_err(Error::CacheRead)? .is_fresh() { @@ -1671,7 +1671,7 @@ impl<'a, T: BuildContext> SourceDistributionBuilder<'a, T> { if self .build_context .cache() - .freshness(&metadata_entry, source.name()) + .freshness(&metadata_entry, source.name(), source.source_tree()) .map_err(Error::CacheRead)? .is_fresh() { diff --git a/crates/uv-installer/src/plan.rs b/crates/uv-installer/src/plan.rs index 157e2c21c..c6bbdb530 100644 --- a/crates/uv-installer/src/plan.rs +++ b/crates/uv-installer/src/plan.rs @@ -66,9 +66,23 @@ impl<'a> Planner<'a> { let mut reinstalls = vec![]; let mut extraneous = vec![]; + // TODO(charlie): There are a few assumptions here that are hard to spot: + // + // 1. Apparently, we never return direct URL distributions as [`ResolvedDist::Installed`]. + // If you trace the resolver, we only ever return [`ResolvedDist::Installed`] if you go + // through the [`CandidateSelector`], and we only go through the [`CandidateSelector`] + // for registry distributions. + // + // 2. We expect any distribution returned as [`ResolvedDist::Installed`] to hit the + // "Requirement already installed" path (hence the `unreachable!`) a few lines below it. + // So, e.g., if a package is marked as `--reinstall`, we _expect_ that it's not passed in + // as [`ResolvedDist::Installed`] here. for dist in self.resolution.distributions() { // Check if the package should be reinstalled. - let reinstall = reinstall.contains(dist.name()); + let reinstall = reinstall.contains_package(dist.name()) + || dist + .source_tree() + .is_some_and(|source_tree| reinstall.contains_path(source_tree)); // Check if installation of a binary version of the package should be allowed. let no_binary = build_options.no_binary_package(dist.name()); @@ -108,7 +122,11 @@ impl<'a> Planner<'a> { unreachable!("Installed distribution could not be found in site-packages: {dist}"); }; - if cache.must_revalidate(dist.name()) { + if cache.must_revalidate_package(dist.name()) + || dist + .source_tree() + .is_some_and(|source_tree| cache.must_revalidate_path(source_tree)) + { debug!("Must revalidate requirement: {}", dist.name()); remote.push(dist.clone()); continue; diff --git a/crates/uv-installer/src/site_packages.rs b/crates/uv-installer/src/site_packages.rs index efd7e5dab..7fbb12f31 100644 --- a/crates/uv-installer/src/site_packages.rs +++ b/crates/uv-installer/src/site_packages.rs @@ -202,9 +202,9 @@ impl SitePackages { // There are multiple installed distributions for the same package. diagnostics.push(SitePackagesDiagnostic::DuplicatePackage { package: package.clone(), - paths: std::iter::once(distribution.path().to_owned()) - .chain(std::iter::once(conflict.path().to_owned())) - .chain(distributions.map(|dist| dist.path().to_owned())) + paths: std::iter::once(distribution.install_path().to_owned()) + .chain(std::iter::once(conflict.install_path().to_owned())) + .chain(distributions.map(|dist| dist.install_path().to_owned())) .collect(), }); continue; @@ -219,7 +219,7 @@ impl SitePackages { let Ok(metadata) = distribution.metadata() else { diagnostics.push(SitePackagesDiagnostic::MetadataUnavailable { package: package.clone(), - path: distribution.path().to_owned(), + path: distribution.install_path().to_owned(), }); continue; }; diff --git a/crates/uv-installer/src/uninstall.rs b/crates/uv-installer/src/uninstall.rs index 9379cb43f..42100e84f 100644 --- a/crates/uv-installer/src/uninstall.rs +++ b/crates/uv-installer/src/uninstall.rs @@ -8,9 +8,11 @@ pub async fn uninstall( let dist = dist.clone(); move || match dist { InstalledDist::Registry(_) | InstalledDist::Url(_) => { - Ok(uv_install_wheel::uninstall_wheel(dist.path())?) + Ok(uv_install_wheel::uninstall_wheel(dist.install_path())?) + } + InstalledDist::EggInfoDirectory(_) => { + Ok(uv_install_wheel::uninstall_egg(dist.install_path())?) } - InstalledDist::EggInfoDirectory(_) => Ok(uv_install_wheel::uninstall_egg(dist.path())?), InstalledDist::LegacyEditable(dist) => { Ok(uv_install_wheel::uninstall_legacy_editable(&dist.egg_link)?) } diff --git a/crates/uv-python/src/interpreter.rs b/crates/uv-python/src/interpreter.rs index 228bad505..2c4cb653b 100644 --- a/crates/uv-python/src/interpreter.rs +++ b/crates/uv-python/src/interpreter.rs @@ -874,7 +874,7 @@ impl InterpreterInfo { // Read from the cache. if cache - .freshness(&cache_entry, None) + .freshness(&cache_entry, None, None) .is_ok_and(Freshness::is_fresh) { if let Ok(data) = fs::read(cache_entry.path()) { diff --git a/crates/uv-requirements/src/sources.rs b/crates/uv-requirements/src/sources.rs index ea0bd2768..92aa97b42 100644 --- a/crates/uv-requirements/src/sources.rs +++ b/crates/uv-requirements/src/sources.rs @@ -1,17 +1,18 @@ use std::path::{Path, PathBuf}; -use anyhow::Result; +use anyhow::{Context, Result}; use console::Term; -use uv_fs::Simplified; +use uv_fs::{Simplified, CWD}; +use uv_requirements_txt::RequirementsTxtRequirement; use uv_warnings::warn_user; #[derive(Debug, Clone)] pub enum RequirementsSource { /// A package was provided on the command line (e.g., `pip install flask`). - Package(String), + Package(RequirementsTxtRequirement), /// An editable path was provided on the command line (e.g., `pip install -e ../flask`). - Editable(String), + Editable(RequirementsTxtRequirement), /// Dependencies were provided via a `requirements.txt` file (e.g., `pip install -r requirements.txt`). RequirementsTxt(PathBuf), /// Dependencies were provided via a `pyproject.toml` file (e.g., `pip-compile pyproject.toml`). @@ -86,7 +87,7 @@ impl RequirementsSource { /// /// If the user provided a value that appears to be a `requirements.txt` file or a local /// directory, prompt them to correct it (if the terminal is interactive). - pub fn from_package(name: String) -> Result { + pub fn from_package_argument(name: &str) -> Result { // If the user provided a `requirements.txt` file without `-r` (as in // `uv pip install requirements.txt`), prompt them to correct it. #[allow(clippy::case_sensitive_file_extension_comparisons)] @@ -120,7 +121,10 @@ impl RequirementsSource { } } - Ok(Self::Package(name)) + let requirement = RequirementsTxtRequirement::parse(name, &*CWD, false) + .with_context(|| format!("Failed to parse: `{name}`"))?; + + Ok(Self::Package(requirement)) } /// Parse a [`RequirementsSource`] from a user-provided string, assumed to be a `--with` @@ -128,7 +132,7 @@ impl RequirementsSource { /// /// If the user provided a value that appears to be a `requirements.txt` file or a local /// directory, prompt them to correct it (if the terminal is interactive). - pub fn from_with_package(name: String) -> Result { + pub fn from_with_package_argument(name: &str) -> Result { // If the user provided a `requirements.txt` file without `--with-requirements` (as in // `uvx --with requirements.txt ruff`), prompt them to correct it. #[allow(clippy::case_sensitive_file_extension_comparisons)] @@ -162,7 +166,26 @@ impl RequirementsSource { } } - Ok(Self::Package(name)) + let requirement = RequirementsTxtRequirement::parse(name, &*CWD, false) + .with_context(|| format!("Failed to parse: `{name}`"))?; + + Ok(Self::Package(requirement)) + } + + /// Parse an editable [`RequirementsSource`] (e.g., `uv pip install -e .`). + pub fn from_editable(name: &str) -> Result { + let requirement = RequirementsTxtRequirement::parse(name, &*CWD, true) + .with_context(|| format!("Failed to parse: `{name}`"))?; + + Ok(Self::Editable(requirement)) + } + + /// Parse a package [`RequirementsSource`] (e.g., `uv pip install ruff`). + pub fn from_package(name: &str) -> Result { + let requirement = RequirementsTxtRequirement::parse(name, &*CWD, false) + .with_context(|| format!("Failed to parse: `{name}`"))?; + + Ok(Self::Package(requirement)) } /// Parse a [`RequirementsSource`] from a user-provided string, assumed to be a path to a source @@ -188,8 +211,8 @@ impl RequirementsSource { impl std::fmt::Display for RequirementsSource { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { - Self::Package(package) => write!(f, "{package}"), - Self::Editable(path) => write!(f, "-e {path}"), + Self::Package(package) => write!(f, "{package:?}"), + Self::Editable(path) => write!(f, "-e {path:?}"), Self::RequirementsTxt(path) | Self::PyprojectToml(path) | Self::SetupPy(path) diff --git a/crates/uv-requirements/src/specification.rs b/crates/uv-requirements/src/specification.rs index f4729d83a..002f07486 100644 --- a/crates/uv-requirements/src/specification.rs +++ b/crates/uv-requirements/src/specification.rs @@ -89,24 +89,18 @@ impl RequirementsSpecification { client_builder: &BaseClientBuilder<'_>, ) -> Result { Ok(match source { - RequirementsSource::Package(name) => { - let requirement = RequirementsTxtRequirement::parse(name, &*CWD, false) - .with_context(|| format!("Failed to parse: `{name}`"))?; - Self { - requirements: vec![UnresolvedRequirementSpecification::from(requirement)], - ..Self::default() - } - } - RequirementsSource::Editable(name) => { - let requirement = RequirementsTxtRequirement::parse(name, &*CWD, true) - .with_context(|| format!("Failed to parse: `{name}`"))?; - Self { - requirements: vec![UnresolvedRequirementSpecification::from( - requirement.into_editable()?, - )], - ..Self::default() - } - } + RequirementsSource::Package(requirement) => Self { + requirements: vec![UnresolvedRequirementSpecification::from( + requirement.clone(), + )], + ..Self::default() + }, + RequirementsSource::Editable(requirement) => Self { + requirements: vec![UnresolvedRequirementSpecification::from( + requirement.clone().into_editable()?, + )], + ..Self::default() + }, RequirementsSource::RequirementsTxt(path) => { if !(path == Path::new("-") || path.starts_with("http://") diff --git a/crates/uv-resolver/src/exclusions.rs b/crates/uv-resolver/src/exclusions.rs index 24cb7da42..5f4efb4f9 100644 --- a/crates/uv-resolver/src/exclusions.rs +++ b/crates/uv-resolver/src/exclusions.rs @@ -14,7 +14,7 @@ impl Exclusions { } pub fn reinstall(&self, package: &PackageName) -> bool { - self.reinstall.contains(package) + self.reinstall.contains_package(package) } pub fn upgrade(&self, package: &PackageName) -> bool { diff --git a/crates/uv-tool/src/lib.rs b/crates/uv-tool/src/lib.rs index 793483597..70aeaf32a 100644 --- a/crates/uv-tool/src/lib.rs +++ b/crates/uv-tool/src/lib.rs @@ -382,7 +382,7 @@ fn find_dist_info<'a>( .get_packages(package_name) .iter() .find(|package| package.version() == package_version) - .map(|dist| dist.path()) + .map(|dist| dist.install_path()) .ok_or_else(|| Error::MissingToolPackage(package_name.clone())) } diff --git a/crates/uv/Cargo.toml b/crates/uv/Cargo.toml index 730eba454..cc60c4ac5 100644 --- a/crates/uv/Cargo.toml +++ b/crates/uv/Cargo.toml @@ -44,6 +44,7 @@ uv-publish = { workspace = true } uv-pypi-types = { workspace = true } uv-python = { workspace = true, features = ["schemars"] } uv-requirements = { workspace = true } +uv-requirements-txt = { workspace = true } uv-resolver = { workspace = true } uv-scripts = { workspace = true } uv-settings = { workspace = true, features = ["schemars"] } @@ -52,8 +53,8 @@ uv-static = { workspace = true } uv-tool = { workspace = true } uv-trampoline-builder = { workspace = true } uv-types = { workspace = true } -uv-virtualenv = { workspace = true } uv-version = { workspace = true } +uv-virtualenv = { workspace = true } uv-warnings = { workspace = true } uv-workspace = { workspace = true } diff --git a/crates/uv/src/commands/pip/operations.rs b/crates/uv/src/commands/pip/operations.rs index e77444dee..60f1cc4fb 100644 --- a/crates/uv/src/commands/pip/operations.rs +++ b/crates/uv/src/commands/pip/operations.rs @@ -514,7 +514,7 @@ pub(crate) async fn install( )) => { warn_user!( "Failed to uninstall package at {} due to missing `RECORD` file. Installation may result in an incomplete environment.", - dist_info.path().user_display().cyan(), + dist_info.install_path().user_display().cyan(), ); } Err(uv_installer::UninstallError::Uninstall( @@ -522,7 +522,7 @@ pub(crate) async fn install( )) => { warn_user!( "Failed to uninstall package at {} due to missing `top-level.txt` file. Installation may result in an incomplete environment.", - dist_info.path().user_display().cyan(), + dist_info.install_path().user_display().cyan(), ); } Err(err) => return Err(err.into()), diff --git a/crates/uv/src/commands/pip/show.rs b/crates/uv/src/commands/pip/show.rs index 5c03f6d04..065331d2d 100644 --- a/crates/uv/src/commands/pip/show.rs +++ b/crates/uv/src/commands/pip/show.rs @@ -142,7 +142,7 @@ pub(crate) fn pip_show( printer.stdout(), "Location: {}", distribution - .path() + .install_path() .parent() .expect("package path is not root") .simplified_display() @@ -190,7 +190,7 @@ pub(crate) fn pip_show( // If requests, show the list of installed files. if files { - let path = distribution.path().join("RECORD"); + let path = distribution.install_path().join("RECORD"); let record = read_record_file(&mut File::open(path)?)?; writeln!(printer.stdout(), "Files:")?; for entry in record { diff --git a/crates/uv/src/commands/pip/uninstall.rs b/crates/uv/src/commands/pip/uninstall.rs index 28f85c659..7b4ea866b 100644 --- a/crates/uv/src/commands/pip/uninstall.rs +++ b/crates/uv/src/commands/pip/uninstall.rs @@ -176,8 +176,8 @@ pub(crate) async fn pip_uninstall( } // Deduplicate, since a package could be listed both by name and editable URL. - distributions.sort_unstable_by_key(|dist| dist.path()); - distributions.dedup_by_key(|dist| dist.path()); + distributions.sort_unstable_by_key(|dist| dist.install_path()); + distributions.dedup_by_key(|dist| dist.install_path()); distributions }; diff --git a/crates/uv/src/commands/tool/install.rs b/crates/uv/src/commands/tool/install.rs index 93e27884d..795f36ee8 100644 --- a/crates/uv/src/commands/tool/install.rs +++ b/crates/uv/src/commands/tool/install.rs @@ -109,9 +109,9 @@ pub(crate) async fn install( // Ex) `ruff` Target::Unspecified(from) => { let source = if editable { - RequirementsSource::Editable((*from).to_string()) + RequirementsSource::from_editable(from)? } else { - RequirementsSource::Package((*from).to_string()) + RequirementsSource::from_package(from)? }; let requirement = RequirementsSpecification::from_source(&source, &client_builder) .await? diff --git a/crates/uv/src/lib.rs b/crates/uv/src/lib.rs index 267b5629c..4097db5b9 100644 --- a/crates/uv/src/lib.rs +++ b/crates/uv/src/lib.rs @@ -27,7 +27,10 @@ use uv_cli::{PythonCommand, PythonNamespace, ToolCommand, ToolNamespace, TopLeve #[cfg(feature = "self-update")] use uv_cli::{SelfCommand, SelfNamespace, SelfUpdateArgs}; use uv_fs::{Simplified, CWD}; +use uv_pep508::VersionOrUrl; +use uv_pypi_types::{ParsedDirectoryUrl, ParsedUrl}; use uv_requirements::RequirementsSource; +use uv_requirements_txt::RequirementsTxtRequirement; use uv_scripts::{Pep723Error, Pep723Item, Pep723Metadata, Pep723Script}; use uv_settings::{Combine, FilesystemOptions, Options}; use uv_static::EnvVars; @@ -538,23 +541,18 @@ async fn run(mut cli: Cli) -> Result { args.compat_args.validate()?; // Resolve the settings from the command-line arguments and workspace configuration. - let args = PipInstallSettings::resolve(args, filesystem); + let mut args = PipInstallSettings::resolve(args, filesystem); show_settings!(args); - // Initialize the cache. - let cache = cache.init()?.with_refresh( - args.refresh - .combine(Refresh::from(args.settings.reinstall.clone())) - .combine(Refresh::from(args.settings.upgrade.clone())), - ); - let mut requirements = Vec::with_capacity( args.package.len() + args.editables.len() + args.requirements.len(), ); for package in args.package { - requirements.push(RequirementsSource::from_package(package)?); + requirements.push(RequirementsSource::from_package_argument(&package)?); + } + for package in args.editables { + requirements.push(RequirementsSource::from_editable(&package)?); } - requirements.extend(args.editables.into_iter().map(RequirementsSource::Editable)); requirements.extend( args.requirements .into_iter() @@ -590,6 +588,55 @@ async fn run(mut cli: Cli) -> Result { .push(group.name.clone()); } + // Special-case: any source trees specified on the command-line are automatically + // reinstalled. This matches user expectations: `uv pip install .` should always + // re-build and re-install the package in the current working directory. + for requirement in &requirements { + let requirement = match requirement { + RequirementsSource::Package(requirement) => requirement, + RequirementsSource::Editable(requirement) => requirement, + _ => continue, + }; + match requirement { + RequirementsTxtRequirement::Named(requirement) => { + if let Some(VersionOrUrl::Url(url)) = requirement.version_or_url.as_ref() { + if let ParsedUrl::Directory(ParsedDirectoryUrl { + install_path, .. + }) = &url.parsed_url + { + debug!( + "Marking explicit source tree for reinstall: `{}`", + install_path.display() + ); + args.settings.reinstall = args + .settings + .reinstall + .with_package(requirement.name.clone()); + } + } + } + RequirementsTxtRequirement::Unnamed(requirement) => { + if let ParsedUrl::Directory(ParsedDirectoryUrl { install_path, .. }) = + &requirement.url.parsed_url + { + debug!( + "Marking explicit source tree for reinstall: `{}`", + install_path.display() + ); + args.settings.reinstall = + args.settings.reinstall.with_path(install_path.clone()); + } + } + } + } + + // Initialize the cache. + let cache = cache.init()?.with_refresh( + args.refresh + .combine(Refresh::from(args.settings.reinstall.clone())) + .combine(Refresh::from(args.settings.upgrade.clone())), + ); + commands::pip_install( &requirements, &constraints, @@ -650,7 +697,7 @@ async fn run(mut cli: Cli) -> Result { let mut sources = Vec::with_capacity(args.package.len() + args.requirements.len()); for package in args.package { - sources.push(RequirementsSource::from_package(package)?); + sources.push(RequirementsSource::from_package_argument(&package)?); } sources.extend( args.requirements @@ -1003,13 +1050,11 @@ async fn run(mut cli: Cli) -> Result { args.with.len() + args.with_editable.len() + args.with_requirements.len(), ); for package in args.with { - requirements.push(RequirementsSource::from_with_package(package)?); + requirements.push(RequirementsSource::from_with_package_argument(&package)?); + } + for package in args.with_editable { + requirements.push(RequirementsSource::from_editable(&package)?); } - requirements.extend( - args.with_editable - .into_iter() - .map(RequirementsSource::Editable), - ); requirements.extend( args.with_requirements .into_iter() @@ -1070,13 +1115,11 @@ async fn run(mut cli: Cli) -> Result { args.with.len() + args.with_editable.len() + args.with_requirements.len(), ); for package in args.with { - requirements.push(RequirementsSource::from_with_package(package)?); + requirements.push(RequirementsSource::from_with_package_argument(&package)?); + } + for package in args.with_editable { + requirements.push(RequirementsSource::from_editable(&package)?); } - requirements.extend( - args.with_editable - .into_iter() - .map(RequirementsSource::Editable), - ); requirements.extend( args.with_requirements .into_iter() @@ -1493,13 +1536,11 @@ async fn run_project( args.with.len() + args.with_editable.len() + args.with_requirements.len(), ); for package in args.with { - requirements.push(RequirementsSource::from_with_package(package)?); + requirements.push(RequirementsSource::from_with_package_argument(&package)?); + } + for package in args.with_editable { + requirements.push(RequirementsSource::from_editable(&package)?); } - requirements.extend( - args.with_editable - .into_iter() - .map(RequirementsSource::Editable), - ); requirements.extend( args.with_requirements .into_iter() @@ -1661,14 +1702,16 @@ async fn run_project( let requirements = args .packages - .into_iter() - .map(RequirementsSource::Package) + .iter() + .map(String::as_str) + .map(RequirementsSource::from_package_argument) .chain( args.requirements .into_iter() - .map(RequirementsSource::from_requirements_file), + .map(RequirementsSource::from_requirements_file) + .map(Ok), ) - .collect::>(); + .collect::>>()?; Box::pin(commands::add( project_dir, diff --git a/crates/uv/tests/it/cache_prune.rs b/crates/uv/tests/it/cache_prune.rs index 43e52d1d3..68749aaff 100644 --- a/crates/uv/tests/it/cache_prune.rs +++ b/crates/uv/tests/it/cache_prune.rs @@ -375,6 +375,7 @@ fn prune_stale_revision() -> Result<()> { ----- stderr ----- Resolved 1 package in [TIME] + Prepared 1 package in [TIME] Installed 1 package in [TIME] + project==0.1.0 (from file://[TEMP_DIR]/) "###); diff --git a/crates/uv/tests/it/pip_install.rs b/crates/uv/tests/it/pip_install.rs index dc2b977e0..0e98df628 100644 --- a/crates/uv/tests/it/pip_install.rs +++ b/crates/uv/tests/it/pip_install.rs @@ -1137,7 +1137,7 @@ fn install_editable() { "### ); - // Install it again (no-op). + // Install it again. uv_snapshot!(context.filters(), context.pip_install() .arg("-e") .arg(context.workspace_root.join("scripts/packages/poetry_editable")), @r###" @@ -1146,7 +1146,11 @@ fn install_editable() { ----- stdout ----- ----- stderr ----- - Audited 1 package in [TIME] + Resolved 4 packages in [TIME] + Prepared 1 package in [TIME] + Uninstalled 1 package in [TIME] + Installed 1 package in [TIME] + ~ poetry-editable==0.1.0 (from file://[WORKSPACE]/scripts/packages/poetry_editable) "### ); @@ -1161,14 +1165,16 @@ fn install_editable() { ----- stderr ----- Resolved 10 packages in [TIME] - Prepared 6 packages in [TIME] - Installed 6 packages in [TIME] + Prepared 7 packages in [TIME] + Uninstalled 1 package in [TIME] + Installed 7 packages in [TIME] + black==24.3.0 + click==8.1.7 + mypy-extensions==1.0.0 + packaging==24.0 + pathspec==0.12.1 + platformdirs==4.2.0 + ~ poetry-editable==0.1.0 (from file://[WORKSPACE]/scripts/packages/poetry_editable) "### ); } @@ -3631,13 +3637,22 @@ fn config_settings_registry() { } #[test] -fn config_settings_path() { +fn config_settings_path() -> Result<()> { let context = TestContext::new("3.12"); + let requirements_txt = context.temp_dir.child("requirements.txt"); + requirements_txt.write_str(&format!( + "-e {}", + context + .workspace_root + .join("scripts/packages/setuptools_editable") + .display() + ))?; + // Install the editable package. uv_snapshot!(context.filters(), context.pip_install() - .arg("-e") - .arg(context.workspace_root.join("scripts/packages/setuptools_editable")), @r###" + .arg("-r") + .arg("requirements.txt"), @r###" success: true exit_code: 0 ----- stdout ----- @@ -3660,8 +3675,8 @@ fn config_settings_path() { // Reinstalling with `--editable_mode=compat` should be a no-op; changes in build configuration // don't invalidate the environment. uv_snapshot!(context.filters(), context.pip_install() - .arg("-e") - .arg(context.workspace_root.join("scripts/packages/setuptools_editable")) + .arg("-r") + .arg("requirements.txt") .arg("-C") .arg("editable_mode=compat") , @r###" @@ -3689,8 +3704,8 @@ fn config_settings_path() { // Install the editable package with `--editable_mode=compat`. We should ignore the cached // build configuration and rebuild. uv_snapshot!(context.filters(), context.pip_install() - .arg("-e") - .arg(context.workspace_root.join("scripts/packages/setuptools_editable")) + .arg("-r") + .arg("requirements.txt") .arg("-C") .arg("editable_mode=compat") , @r###" @@ -3711,6 +3726,8 @@ fn config_settings_path() { .site_packages() .join("__editable___setuptools_editable_0_1_0_finder.py"); assert!(!finder.exists()); + + Ok(()) } /// Reinstall a duplicate package in a virtual environment. @@ -3813,6 +3830,9 @@ fn install_symlink() { fn invalidate_editable_on_change() -> Result<()> { let context = TestContext::new("3.12"); + let requirements_txt = context.temp_dir.child("requirements.txt"); + requirements_txt.write_str("-e ./editable")?; + // Create an editable package. let editable_dir = context.temp_dir.child("editable"); editable_dir.create_dir_all()?; @@ -3829,8 +3849,8 @@ requires-python = ">=3.8" )?; uv_snapshot!(context.filters(), context.pip_install() - .arg("--editable") - .arg(editable_dir.path()), @r###" + .arg("-r") + .arg("requirements.txt"), @r###" success: true exit_code: 0 ----- stdout ----- @@ -3848,8 +3868,8 @@ requires-python = ">=3.8" // Installing again should be a no-op. uv_snapshot!(context.filters(), context.pip_install() - .arg("--editable") - .arg(editable_dir.path()), @r###" + .arg("-r") + .arg("requirements.txt"), @r###" success: true exit_code: 0 ----- stdout ----- @@ -3873,8 +3893,8 @@ requires-python = ">=3.8" // Installing again should update the package. uv_snapshot!(context.filters(), context.pip_install() - .arg("--editable") - .arg(editable_dir.path()), @r###" + .arg("-r") + .arg("requirements.txt"), @r###" success: true exit_code: 0 ----- stdout ----- @@ -3897,6 +3917,9 @@ requires-python = ">=3.8" fn editable_dynamic() -> Result<()> { let context = TestContext::new("3.12"); + let requirements_txt = context.temp_dir.child("requirements.txt"); + requirements_txt.write_str("-e ./editable")?; + // Create an editable package with dynamic metadata. let editable_dir = context.temp_dir.child("editable"); editable_dir.create_dir_all()?; @@ -3910,16 +3933,16 @@ dynamic = ["dependencies"] requires-python = ">=3.11,<3.13" [tool.setuptools.dynamic] -dependencies = {file = ["requirements.txt"]} +dependencies = {file = ["dependencies.txt"]} "#, )?; - let requirements_txt = editable_dir.child("requirements.txt"); - requirements_txt.write_str("anyio==4.0.0")?; + let dependencies_txt = editable_dir.child("dependencies.txt"); + dependencies_txt.write_str("anyio==4.0.0")?; uv_snapshot!(context.filters(), context.pip_install() - .arg("--editable") - .arg(editable_dir.path()), @r###" + .arg("-r") + .arg("requirements.txt"), @r###" success: true exit_code: 0 ----- stdout ----- @@ -3937,8 +3960,8 @@ dependencies = {file = ["requirements.txt"]} // Installing again should not re-install, as we don't special-case dynamic metadata. uv_snapshot!(context.filters(), context.pip_install() - .arg("--editable") - .arg(editable_dir.path()), @r###" + .arg("-r") + .arg("requirements.txt"), @r###" success: true exit_code: 0 ----- stdout ----- @@ -3955,6 +3978,9 @@ dependencies = {file = ["requirements.txt"]} fn invalidate_path_on_change() -> Result<()> { let context = TestContext::new("3.12"); + let requirements_txt = context.temp_dir.child("requirements.txt"); + requirements_txt.write_str("example @ ./editable")?; + // Create a local package. let editable_dir = context.temp_dir.child("editable"); editable_dir.create_dir_all()?; @@ -3971,14 +3997,13 @@ requires-python = ">=3.8" )?; uv_snapshot!(context.filters(), context.pip_install() - .arg("example @ .") - .current_dir(editable_dir.path()), @r###" + .arg("-r") + .arg("requirements.txt"), @r###" success: true exit_code: 0 ----- stdout ----- ----- stderr ----- - Using Python 3.12.[X] environment at: [VENV]/ Resolved 4 packages in [TIME] Prepared 4 packages in [TIME] Installed 4 packages in [TIME] @@ -3991,14 +4016,13 @@ requires-python = ">=3.8" // Installing again should be a no-op. uv_snapshot!(context.filters(), context.pip_install() - .arg("example @ .") - .current_dir(editable_dir.path()), @r###" + .arg("-r") + .arg("requirements.txt"), @r###" success: true exit_code: 0 ----- stdout ----- ----- stderr ----- - Using Python 3.12.[X] environment at: [VENV]/ Audited 1 package in [TIME] "### ); @@ -4017,14 +4041,13 @@ requires-python = ">=3.8" // Installing again should update the package. uv_snapshot!(context.filters(), context.pip_install() - .arg("example @ .") - .current_dir(editable_dir.path()), @r###" + .arg("-r") + .arg("requirements.txt"), @r###" success: true exit_code: 0 ----- stdout ----- ----- stderr ----- - Using Python 3.12.[X] environment at: [VENV]/ Resolved 4 packages in [TIME] Prepared 2 packages in [TIME] Uninstalled 2 packages in [TIME] @@ -4042,6 +4065,9 @@ requires-python = ">=3.8" fn invalidate_path_on_cache_key() -> Result<()> { let context = TestContext::new("3.12"); + let requirements_txt = context.temp_dir.child("requirements.txt"); + requirements_txt.write_str("example @ ./editable")?; + // Create a local package. let editable_dir = context.temp_dir.child("editable"); editable_dir.create_dir_all()?; @@ -4054,25 +4080,24 @@ fn invalidate_path_on_cache_key() -> Result<()> { requires-python = ">=3.8" [tool.uv] - cache-keys = ["constraints.txt", { file = "requirements.txt" }] + cache-keys = ["constraints.txt", { file = "overrides.txt" }] "#, )?; - let requirements_txt = editable_dir.child("requirements.txt"); - requirements_txt.write_str("idna")?; + let overrides_txt = editable_dir.child("overrides.txt"); + overrides_txt.write_str("idna")?; let constraints_txt = editable_dir.child("constraints.txt"); constraints_txt.write_str("idna<3.4")?; uv_snapshot!(context.filters(), context.pip_install() - .arg("example @ .") - .current_dir(editable_dir.path()), @r###" + .arg("-r") + .arg("requirements.txt"), @r###" success: true exit_code: 0 ----- stdout ----- ----- stderr ----- - Using Python 3.12.[X] environment at: [VENV]/ Resolved 4 packages in [TIME] Prepared 4 packages in [TIME] Installed 4 packages in [TIME] @@ -4085,14 +4110,13 @@ fn invalidate_path_on_cache_key() -> Result<()> { // Installing again should be a no-op. uv_snapshot!(context.filters(), context.pip_install() - .arg("example @ .") - .current_dir(editable_dir.path()), @r###" + .arg("-r") + .arg("requirements.txt"), @r###" success: true exit_code: 0 ----- stdout ----- ----- stderr ----- - Using Python 3.12.[X] environment at: [VENV]/ Audited 1 package in [TIME] "### ); @@ -4102,14 +4126,13 @@ fn invalidate_path_on_cache_key() -> Result<()> { // Installing again should update the package. uv_snapshot!(context.filters(), context.pip_install() - .arg("example @ .") - .current_dir(editable_dir.path()), @r###" + .arg("-r") + .arg("requirements.txt"), @r###" success: true exit_code: 0 ----- stdout ----- ----- stderr ----- - Using Python 3.12.[X] environment at: [VENV]/ Resolved 4 packages in [TIME] Prepared 1 package in [TIME] Uninstalled 1 package in [TIME] @@ -4119,18 +4142,17 @@ fn invalidate_path_on_cache_key() -> Result<()> { ); // Modify the requirements file. - requirements_txt.write_str("flask")?; + overrides_txt.write_str("flask")?; // Installing again should update the package. uv_snapshot!(context.filters(), context.pip_install() - .arg("example @ .") - .current_dir(editable_dir.path()), @r###" + .arg("-r") + .arg("requirements.txt"), @r###" success: true exit_code: 0 ----- stdout ----- ----- stderr ----- - Using Python 3.12.[X] environment at: [VENV]/ Resolved 4 packages in [TIME] Prepared 1 package in [TIME] Uninstalled 1 package in [TIME] @@ -4148,20 +4170,19 @@ fn invalidate_path_on_cache_key() -> Result<()> { requires-python = ">=3.8" [tool.uv] - cache-keys = [{ file = "requirements.txt" }, "constraints.txt"] + cache-keys = [{ file = "overrides.txt" }, "constraints.txt"] "#, )?; // Installing again should be a no-op, since `pyproject.toml` was not included as a cache key. uv_snapshot!(context.filters(), context.pip_install() - .arg("example @ .") - .current_dir(editable_dir.path()), @r###" + .arg("-r") + .arg("requirements.txt"), @r###" success: true exit_code: 0 ----- stdout ----- ----- stderr ----- - Using Python 3.12.[X] environment at: [VENV]/ Audited 1 package in [TIME] "### ); @@ -4187,14 +4208,13 @@ fn invalidate_path_on_cache_key() -> Result<()> { // Installing again should update the package. uv_snapshot!(context.filters(), context.pip_install() - .arg("example @ .") - .current_dir(editable_dir.path()), @r###" + .arg("-r") + .arg("requirements.txt"), @r###" success: true exit_code: 0 ----- stdout ----- ----- stderr ----- - Using Python 3.12.[X] environment at: [VENV]/ Resolved 4 packages in [TIME] Prepared 1 package in [TIME] Uninstalled 1 package in [TIME] @@ -4208,14 +4228,13 @@ fn invalidate_path_on_cache_key() -> Result<()> { // Installing again should update the package. uv_snapshot!(context.filters(), context.pip_install() - .arg("example @ .") - .current_dir(editable_dir.path()), @r###" + .arg("-r") + .arg("requirements.txt"), @r###" success: true exit_code: 0 ----- stdout ----- ----- stderr ----- - Using Python 3.12.[X] environment at: [VENV]/ Resolved 4 packages in [TIME] Prepared 1 package in [TIME] Uninstalled 1 package in [TIME] @@ -4231,6 +4250,9 @@ fn invalidate_path_on_cache_key() -> Result<()> { fn invalidate_path_on_commit() -> Result<()> { let context = TestContext::new("3.12"); + let requirements_txt = context.temp_dir.child("requirements.txt"); + requirements_txt.write_str("example @ ./editable")?; + // Create a local package. let editable_dir = context.temp_dir.child("editable"); editable_dir.create_dir_all()?; @@ -4264,14 +4286,13 @@ fn invalidate_path_on_commit() -> Result<()> { .write_str("1b6638fdb424e993d8354e75c55a3e524050c857")?; uv_snapshot!(context.filters(), context.pip_install() - .arg("example @ .") - .current_dir(editable_dir.path()), @r###" + .arg("-r") + .arg("requirements.txt"), @r###" success: true exit_code: 0 ----- stdout ----- ----- stderr ----- - Using Python 3.12.[X] environment at: [VENV]/ Resolved 4 packages in [TIME] Prepared 4 packages in [TIME] Installed 4 packages in [TIME] @@ -4284,14 +4305,13 @@ fn invalidate_path_on_commit() -> Result<()> { // Installing again should be a no-op. uv_snapshot!(context.filters(), context.pip_install() - .arg("example @ .") - .current_dir(editable_dir.path()), @r###" + .arg("-r") + .arg("requirements.txt"), @r###" success: true exit_code: 0 ----- stdout ----- ----- stderr ----- - Using Python 3.12.[X] environment at: [VENV]/ Audited 1 package in [TIME] "### ); @@ -4307,14 +4327,13 @@ fn invalidate_path_on_commit() -> Result<()> { // Installing again should update the package. uv_snapshot!(context.filters(), context.pip_install() - .arg("example @ .") - .current_dir(editable_dir.path()), @r###" + .arg("-r") + .arg("requirements.txt"), @r###" success: true exit_code: 0 ----- stdout ----- ----- stderr ----- - Using Python 3.12.[X] environment at: [VENV]/ Resolved 4 packages in [TIME] Prepared 1 package in [TIME] Uninstalled 1 package in [TIME] @@ -4330,6 +4349,9 @@ fn invalidate_path_on_commit() -> Result<()> { fn invalidate_path_on_env_var() -> Result<()> { let context = TestContext::new("3.12"); + let requirements_txt = context.temp_dir.child("requirements.txt"); + requirements_txt.write_str(".")?; + // Create a local package. context.temp_dir.child("pyproject.toml").write_str( r#"[project] @@ -4345,7 +4367,8 @@ fn invalidate_path_on_env_var() -> Result<()> { // Install the package. uv_snapshot!(context.filters(), context.pip_install() - .arg(".") + .arg("-r") + .arg("requirements.txt") .env_remove("FOO"), @r###" success: true exit_code: 0 @@ -4364,7 +4387,8 @@ fn invalidate_path_on_env_var() -> Result<()> { // Installing again should be a no-op. uv_snapshot!(context.filters(), context.pip_install() - .arg(".") + .arg("-r") + .arg("requirements.txt") .env_remove("FOO"), @r###" success: true exit_code: 0 @@ -4377,7 +4401,8 @@ fn invalidate_path_on_env_var() -> Result<()> { // Installing again should update the package. uv_snapshot!(context.filters(), context.pip_install() - .arg(".") + .arg("-r") + .arg("requirements.txt") .env("FOO", "BAR"), @r###" success: true exit_code: 0 @@ -5646,7 +5671,7 @@ fn already_installed_dependent_editable() { ); // Request install of the first editable by full path again - // We should audit the installed package + // We should reinstall the package because it was explicitly requested uv_snapshot!(context.filters(), context.pip_install() .arg("-e") .arg(root_path.join("first_local")), @r###" @@ -5655,7 +5680,11 @@ fn already_installed_dependent_editable() { ----- stdout ----- ----- stderr ----- - Audited 1 package in [TIME] + Resolved 1 package in [TIME] + Prepared 1 package in [TIME] + Uninstalled 1 package in [TIME] + Installed 1 package in [TIME] + ~ first-local==0.1.0 (from file://[WORKSPACE]/scripts/packages/dependent_locals/first_local) "### ); @@ -5745,8 +5774,8 @@ fn already_installed_local_path_dependent() { "### ); - // Request install of the first local by full path again - // We should audit the installed package + // Request install of the first local by full path again. + // We should rebuild and reinstall it. uv_snapshot!(context.filters(), context.pip_install() .arg(root_path.join("first_local")), @r###" success: true @@ -5754,7 +5783,28 @@ fn already_installed_local_path_dependent() { ----- stdout ----- ----- stderr ----- - Audited 1 package in [TIME] + Resolved 1 package in [TIME] + Prepared 1 package in [TIME] + Uninstalled 1 package in [TIME] + Installed 1 package in [TIME] + ~ first-local==0.1.0 (from file://[WORKSPACE]/scripts/packages/dependent_locals/first_local) + "### + ); + + // Request install of the first local by full path again, along with its name. + // We should rebuild and reinstall it. + uv_snapshot!(context.filters(), context.pip_install() + .arg(format!("first-local @ {}", root_path.join("first_local").display())), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 1 package in [TIME] + Prepared 1 package in [TIME] + Uninstalled 1 package in [TIME] + Installed 1 package in [TIME] + ~ first-local==0.1.0 (from file://[WORKSPACE]/scripts/packages/dependent_locals/first_local) "### ); @@ -5792,10 +5842,11 @@ fn already_installed_local_path_dependent() { ----- stderr ----- Resolved 2 packages in [TIME] - Prepared 1 package in [TIME] - Uninstalled 1 package in [TIME] - Installed 1 package in [TIME] + Prepared 2 packages in [TIME] + Uninstalled 2 packages in [TIME] + Installed 2 packages in [TIME] ~ first-local==0.1.0 (from file://[WORKSPACE]/scripts/packages/dependent_locals/first_local) + ~ second-local==0.1.0 (from file://[WORKSPACE]/scripts/packages/dependent_locals/second_local) "### ); @@ -5815,12 +5866,16 @@ fn already_installed_local_path_dependent() { ----- stderr ----- Resolved 2 packages in [TIME] - Audited 2 packages in [TIME] + Prepared 1 package in [TIME] + Uninstalled 1 package in [TIME] + Installed 1 package in [TIME] + ~ second-local==0.1.0 (from file://[WORKSPACE]/scripts/packages/dependent_locals/second_local) "### ); // Request upgrade of the first package - // A full path is specified and there's nothing to upgrade to so we should just audit + // A full path is specified and there's nothing to upgrade, but because it was passed + // explicitly, we reinstall uv_snapshot!(context.filters(), context.pip_install() .arg(root_path.join("first_local")) .arg(root_path.join("second_local")) @@ -5836,7 +5891,11 @@ fn already_installed_local_path_dependent() { ----- stderr ----- Resolved 2 packages in [TIME] - Audited 2 packages in [TIME] + Prepared 2 packages in [TIME] + Uninstalled 2 packages in [TIME] + Installed 2 packages in [TIME] + ~ first-local==0.1.0 (from file://[WORKSPACE]/scripts/packages/dependent_locals/first_local) + ~ second-local==0.1.0 (from file://[WORKSPACE]/scripts/packages/dependent_locals/second_local) "### ); } @@ -5957,6 +6016,7 @@ fn already_installed_local_version_of_remote_package() { ----- stderr ----- Resolved 1 package in [TIME] + Prepared 1 package in [TIME] Uninstalled 1 package in [TIME] Installed 1 package in [TIME] - anyio==4.3.0 @@ -9878,11 +9938,12 @@ fn no_sources_workspace_discovery() -> Result<()> { ----- stderr ----- Resolved 4 packages in [TIME] - Prepared 3 packages in [TIME] - Uninstalled 1 package in [TIME] - Installed 3 packages in [TIME] + Prepared 4 packages in [TIME] + Uninstalled 2 packages in [TIME] + Installed 4 packages in [TIME] - anyio==2.0.0 (from file://[TEMP_DIR]/anyio) + anyio==4.3.0 + ~ foo==1.0.0 (from file://[TEMP_DIR]/) + idna==3.6 + sniffio==1.3.1 "### @@ -9898,11 +9959,12 @@ fn no_sources_workspace_discovery() -> Result<()> { ----- stderr ----- Resolved 2 packages in [TIME] - Prepared 1 package in [TIME] - Uninstalled 1 package in [TIME] - Installed 1 package in [TIME] + Prepared 2 packages in [TIME] + Uninstalled 2 packages in [TIME] + Installed 2 packages in [TIME] - anyio==4.3.0 + anyio==2.0.0 (from file://[TEMP_DIR]/anyio) + ~ foo==1.0.0 (from file://[TEMP_DIR]/) "### ); diff --git a/docs/concepts/cache.md b/docs/concepts/cache.md index 3b84e31cf..b7ad42326 100644 --- a/docs/concepts/cache.md +++ b/docs/concepts/cache.md @@ -26,6 +26,9 @@ If you're running into caching issues, uv includes a few escape hatches: - To force uv to ignore existing installed versions, pass `--reinstall` to any installation command (e.g., `uv sync --reinstall` or `uv pip install --reinstall ...`). +As a special case, uv will always rebuild and reinstall any local directory dependencies passed +explicitly on the command-line (e.g., `uv pip install .`). + ## Dynamic metadata By default, uv will _only_ rebuild and reinstall local directory dependencies (e.g., editables) if