Skip to content

Commit

Permalink
Improve interactions with existing Python executables during install
Browse files Browse the repository at this point in the history
  • Loading branch information
zanieb committed Oct 31, 2024
1 parent 8d3408f commit be7e3f2
Show file tree
Hide file tree
Showing 10 changed files with 353 additions and 37 deletions.
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

10 changes: 9 additions & 1 deletion crates/uv-cli/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3905,8 +3905,16 @@ pub struct PythonInstallArgs {
///
/// By default, uv will exit successfully if the version is already
/// installed.
#[arg(long, short, alias = "force")]
#[arg(long, short)]
pub reinstall: bool,

/// Replace existing Python executables during installation.
///
/// By default, uv will refuse to replace executables that it does not manage.
///
/// Implies `--reinstall`.
#[arg(long, short)]
pub force: bool,
}

#[derive(Args)]
Expand Down
31 changes: 30 additions & 1 deletion crates/uv-python/src/managed.rs
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ pub enum Error {
LibcDetection(#[from] LibcDetectionError),
}
/// A collection of uv-managed Python installations installed on the current system.
#[derive(Debug, Clone)]
#[derive(Debug, Clone, Eq, PartialEq)]
pub struct ManagedPythonInstallations {
/// The path to the top-level directory of the installed Python versions.
root: PathBuf,
Expand Down Expand Up @@ -542,6 +542,35 @@ impl ManagedPythonInstallation {
unreachable!("Only Windows and Unix are supported")
}
}

/// Returns `true` if self is a suitable upgrade of other.
pub fn is_upgrade_of(&self, other: &ManagedPythonInstallation) -> bool {
// Require matching implementation
if self.key.implementation != other.key.implementation {
return false;
}
// Require a default variant
if self.key.variant != PythonVariant::Default {
return false;
}
// Require matching minor version
if (self.key.major, self.key.minor) != (other.key.major, other.key.minor) {
return false;
}
// Require a newer, or equal patch version (for pre-release upgrades)
if self.key.patch <= other.key.patch {
return false;
}
if let Some(other_pre) = other.key.prerelease {
if let Some(self_pre) = self.key.prerelease {
return self_pre > other_pre;
}
// Do not upgrade from non-prerelease to prerelease
return false;
}
// Do not upgrade if the patch versions are the same
self.key.patch != other.key.patch
}
}

/// Generate a platform portion of a key from the environment.
Expand Down
2 changes: 1 addition & 1 deletion crates/uv/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ uv-settings = { workspace = true, features = ["schemars"] }
uv-shell = { workspace = true }
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 }
Expand Down Expand Up @@ -78,7 +79,6 @@ rayon = { workspace = true }
regex = { workspace = true }
reqwest = { workspace = true }
rustc-hash = { workspace = true }
same-file = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
tempfile = { workspace = true }
Expand Down
146 changes: 118 additions & 28 deletions crates/uv/src/commands/python/install.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ use futures::StreamExt;
use itertools::{Either, Itertools};
use owo_colors::OwoColorize;
use rustc_hash::{FxHashMap, FxHashSet};
use same_file::is_same_file;
use tracing::{debug, trace};

use uv_client::Connectivity;
Expand All @@ -20,6 +19,7 @@ use uv_python::managed::{
};
use uv_python::{PythonDownloads, PythonInstallationKey, PythonRequest, PythonVersionFile};
use uv_shell::Shell;
use uv_trampoline_builder::{Launcher, LauncherKind};
use uv_warnings::warn_user;

use crate::commands::python::{ChangeEvent, ChangeEventKind};
Expand Down Expand Up @@ -73,7 +73,6 @@ struct Changelog {
installed: FxHashSet<PythonInstallationKey>,
uninstalled: FxHashSet<PythonInstallationKey>,
installed_executables: FxHashMap<PythonInstallationKey, Vec<PathBuf>>,
uninstalled_executables: FxHashSet<PathBuf>,
}

impl Changelog {
Expand Down Expand Up @@ -104,10 +103,12 @@ impl Changelog {
}

/// Download and install Python versions.
#[allow(clippy::fn_params_excessive_bools)]
pub(crate) async fn install(
project_dir: &Path,
targets: Vec<String>,
reinstall: bool,
force: bool,
python_downloads: PythonDownloads,
native_tls: bool,
connectivity: Connectivity,
Expand Down Expand Up @@ -291,42 +292,102 @@ pub(crate) async fn install(
.or_default()
.push(target.clone());
}
Err(uv_python::managed::Error::LinkExecutable { from, to, err })
Err(uv_python::managed::Error::LinkExecutable { from: _, to, err })
if err.kind() == ErrorKind::AlreadyExists =>
{
// TODO(zanieb): Add `--force`
if reinstall {
fs_err::remove_file(&to)?;
installation.create_bin_link(&target)?;
debug!(
"Updated executable at {} to {}",
target.user_display(),
installation.key(),
);
changelog.installed.insert(installation.key().clone());
changelog
.installed_executables
.entry(installation.key().clone())
.or_default()
.push(target.clone());
changelog.uninstalled_executables.insert(target);
} else {
if !is_same_file(&to, &from).unwrap_or_default() {
errors.push((
debug!(
"Inspecting existing executable at {}",
target.user_display()
);

// Figure out what installation it references, if any
let existing = find_matching_bin_link(&existing_installations, &target);

match existing {
None => {
// There's an existing executable we don't manage, require `--force`
if !force {
errors.push((
installation.key(),
anyhow::anyhow!(
"Executable already exists at `{}` but is not managed by uv; use `--force` to replace it.",
to.user_display()
),
));
continue;
}
debug!(
"Replacing existing executable at {} due to `--force`",
target.user_display()
);
}
Some(existing) if existing == installation => {
// The existing link points to the same installation, so we're done unless
// they requested we reinstall
if !(reinstall || force) {
debug!(
"Executable at {} is already for {}",
target.user_display(),
installation.key(),
);
continue;
}
debug!(
"Replacing existing executable for {} at {}",
installation.key(),
anyhow::anyhow!(
"Executable already exists at `{}`. Use `--reinstall` to force replacement.",
to.user_display()
),
));
target.user_display(),
);
}
Some(existing) => {
// The existing link points to a different installation, check if it
// is reasonable to replace
if force {
debug!(
"Replacing existing executable for {} at {} with executable for {} due to `--force` flag",
existing.key(),
target.user_display(),
installation.key(),
);
} else {
if installation.is_upgrade_of(existing) {
debug!(
"Replacing existing executable for {} at {} with executable for {} since it is an upgrade",
existing.key(),
target.user_display(),
installation.key(),
);
} else {
debug!(
"Executable already exists at `{}` for `{}`. Use `--force` to replace.",
existing.key(),
to.user_display()
);
continue;
}
}
}
}

// Replace the existing link
fs_err::remove_file(&to)?;
installation.create_bin_link(&target)?;
debug!(
"Updated executable at {} to {}",
target.user_display(),
installation.key(),
);
changelog.installed.insert(installation.key().clone());
changelog
.installed_executables
.entry(installation.key().clone())
.or_default()
.push(target.clone());
}
Err(err) => return Err(err.into()),
}
}

if changelog.installed.is_empty() {
if changelog.installed.is_empty() && errors.is_empty() {
if is_default_install {
writeln!(
printer.stderr(),
Expand Down Expand Up @@ -483,3 +544,32 @@ fn warn_if_not_on_path(bin: &Path) {
}
}
}

/// Find the [`ManagedPythonInstallation`] corresponding to an executable link installed at the
/// given path, if any.
///
/// Like [`ManagedPythonInstallation::is_bin_link`], but this method will only resolve the
/// given path one time.
fn find_matching_bin_link<'a>(
installations: &'a [ManagedPythonInstallation],
path: &Path,
) -> Option<&'a ManagedPythonInstallation> {
let target = if cfg!(unix) {
if !path.is_symlink() {
return None;
}
path.read_link().ok()?
} else if cfg!(windows) {
let launcher = Launcher::try_from_path(path).ok()??;
if !matches!(launcher.kind, LauncherKind::Python) {
return None;
}
launcher.python_path
} else {
unreachable!("Only Windows and Unix are supported")
};

installations
.iter()
.find(|installation| installation.executable() == target)
}
1 change: 1 addition & 0 deletions crates/uv/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1053,6 +1053,7 @@ async fn run(mut cli: Cli) -> Result<ExitStatus> {
&project_dir,
args.targets,
args.reinstall,
args.force,
globals.python_downloads,
globals.native_tls,
globals.connectivity,
Expand Down
13 changes: 11 additions & 2 deletions crates/uv/src/settings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -620,15 +620,24 @@ impl PythonDirSettings {
pub(crate) struct PythonInstallSettings {
pub(crate) targets: Vec<String>,
pub(crate) reinstall: bool,
pub(crate) force: bool,
}

impl PythonInstallSettings {
/// Resolve the [`PythonInstallSettings`] from the CLI and filesystem configuration.
#[allow(clippy::needless_pass_by_value)]
pub(crate) fn resolve(args: PythonInstallArgs, _filesystem: Option<FilesystemOptions>) -> Self {
let PythonInstallArgs { targets, reinstall } = args;
let PythonInstallArgs {
targets,
reinstall,
force,
} = args;

Self { targets, reinstall }
Self {
targets,
reinstall,
force,
}
}
}

Expand Down
8 changes: 8 additions & 0 deletions crates/uv/tests/it/help.rs
Original file line number Diff line number Diff line change
Expand Up @@ -441,6 +441,13 @@ fn help_subsubcommand() {
By default, uv will exit successfully if the version is already installed.
-f, --force
Replace existing Python executables during installation.
By default, uv will refuse to replace executables that it does not manage.
Implies `--reinstall`.
Cache options:
-n, --no-cache
Avoid reading from or writing to the cache, instead using a temporary directory for the
Expand Down Expand Up @@ -646,6 +653,7 @@ fn help_flag_subsubcommand() {
Options:
-r, --reinstall Reinstall the requested Python version, if it's already installed
-f, --force Replace existing Python executables during installation
Cache options:
-n, --no-cache Avoid reading from or writing to the cache, instead using a temporary
Expand Down
Loading

0 comments on commit be7e3f2

Please sign in to comment.