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.
This commit is contained in:
Charlie Marsh 2025-03-17 14:12:21 -07:00 committed by GitHub
parent 05352882ea
commit 72be5ffb25
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
30 changed files with 471 additions and 214 deletions

3
Cargo.lock generated
View file

@ -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",

View file

@ -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 }

View file

@ -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<Freshness> {
// 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<PackageName>, Timestamp),
Packages(Vec<PackageName>, Vec<PathBuf>, 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(_packages, t1), Refresh::All(t2)) => Refresh::All(max(t1, t2)),
(Self::Packages(packages1, t1), Refresh::Packages(packages2, t2)) => Refresh::Packages(
(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),
),
)
}
}
}
}

View file

@ -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,

View file

@ -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,

View file

@ -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 }

View file

@ -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<PackageName>),
Packages(Vec<PackageName>, Vec<PathBuf>),
}
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<Reinstall> 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<Upgrade> 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::<Vec<_>>(), Timestamp::now())
}
Upgrade::Packages(packages) => Self::Packages(
packages.into_keys().collect::<Vec<_>>(),
Vec::new(),
Timestamp::now(),
),
}
}
}

View file

@ -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!(

View file

@ -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<uv_pypi_types::ResolutionMetadata, InstalledDistError> {
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<Option<String>, 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),

View file

@ -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()
}
}

View file

@ -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<'_> {

View file

@ -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,

View file

@ -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()
{

View file

@ -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;

View file

@ -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;
};

View file

@ -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)?)
}

View file

@ -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()) {

View file

@ -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<Self> {
pub fn from_package_argument(name: &str) -> Result<Self> {
// 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<Self> {
pub fn from_with_package_argument(name: &str) -> Result<Self> {
// 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<Self> {
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<Self> {
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)

View file

@ -89,24 +89,18 @@ impl RequirementsSpecification {
client_builder: &BaseClientBuilder<'_>,
) -> Result<Self> {
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 {
RequirementsSource::Package(requirement) => Self {
requirements: vec![UnresolvedRequirementSpecification::from(
requirement.into_editable()?,
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://")

View file

@ -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 {

View file

@ -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()))
}

View file

@ -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 }

View file

@ -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()),

View file

@ -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 {

View file

@ -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
};

View file

@ -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?

View file

@ -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<ExitStatus> {
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<ExitStatus> {
.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<ExitStatus> {
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<ExitStatus> {
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<ExitStatus> {
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::<Vec<_>>();
.collect::<Result<Vec<_>>>()?;
Box::pin(commands::add(
project_dir,

View file

@ -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]/)
"###);

View file

@ -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]/)
"###
);

View file

@ -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