Skip to content

Commit 62d6220

Browse files
committed
Improve interactions with existing Python executables during install
1 parent 8d3408f commit 62d6220

File tree

10 files changed

+354
-38
lines changed

10 files changed

+354
-38
lines changed

Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/uv-cli/src/lib.rs

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3905,8 +3905,16 @@ pub struct PythonInstallArgs {
39053905
///
39063906
/// By default, uv will exit successfully if the version is already
39073907
/// installed.
3908-
#[arg(long, short, alias = "force")]
3908+
#[arg(long, short)]
39093909
pub reinstall: bool,
3910+
3911+
/// Replace existing Python executables during installation.
3912+
///
3913+
/// By default, uv will refuse to replace executables that it does not manage.
3914+
///
3915+
/// Implies `--reinstall`.
3916+
#[arg(long, short)]
3917+
pub force: bool,
39103918
}
39113919

39123920
#[derive(Args)]

crates/uv-python/src/managed.rs

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,7 @@ pub enum Error {
8888
LibcDetection(#[from] LibcDetectionError),
8989
}
9090
/// A collection of uv-managed Python installations installed on the current system.
91-
#[derive(Debug, Clone)]
91+
#[derive(Debug, Clone, Eq, PartialEq)]
9292
pub struct ManagedPythonInstallations {
9393
/// The path to the top-level directory of the installed Python versions.
9494
root: PathBuf,
@@ -542,6 +542,35 @@ impl ManagedPythonInstallation {
542542
unreachable!("Only Windows and Unix are supported")
543543
}
544544
}
545+
546+
/// Returns `true` if self is a suitable upgrade of other.
547+
pub fn is_upgrade_of(&self, other: &ManagedPythonInstallation) -> bool {
548+
// Require matching implementation
549+
if self.key.implementation != other.key.implementation {
550+
return false;
551+
}
552+
// Require a matching variant
553+
if self.key.variant != other.key.variant {
554+
return false;
555+
}
556+
// Require matching minor version
557+
if (self.key.major, self.key.minor) != (other.key.major, other.key.minor) {
558+
return false;
559+
}
560+
// Require a newer, or equal patch version (for pre-release upgrades)
561+
if self.key.patch <= other.key.patch {
562+
return false;
563+
}
564+
if let Some(other_pre) = other.key.prerelease {
565+
if let Some(self_pre) = self.key.prerelease {
566+
return self_pre > other_pre;
567+
}
568+
// Do not upgrade from non-prerelease to prerelease
569+
return false;
570+
}
571+
// Do not upgrade if the patch versions are the same
572+
self.key.patch != other.key.patch
573+
}
545574
}
546575

547576
/// Generate a platform portion of a key from the environment.

crates/uv/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ uv-settings = { workspace = true, features = ["schemars"] }
4848
uv-shell = { workspace = true }
4949
uv-static = { workspace = true }
5050
uv-tool = { workspace = true }
51+
uv-trampoline-builder = { workspace = true }
5152
uv-types = { workspace = true }
5253
uv-virtualenv = { workspace = true }
5354
uv-version = { workspace = true }
@@ -78,7 +79,6 @@ rayon = { workspace = true }
7879
regex = { workspace = true }
7980
reqwest = { workspace = true }
8081
rustc-hash = { workspace = true }
81-
same-file = { workspace = true }
8282
serde = { workspace = true }
8383
serde_json = { workspace = true }
8484
tempfile = { workspace = true }

crates/uv/src/commands/python/install.rs

Lines changed: 119 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@ use futures::StreamExt;
88
use itertools::{Either, Itertools};
99
use owo_colors::OwoColorize;
1010
use rustc_hash::{FxHashMap, FxHashSet};
11-
use same_file::is_same_file;
1211
use tracing::{debug, trace};
1312

1413
use uv_client::Connectivity;
@@ -20,6 +19,7 @@ use uv_python::managed::{
2019
};
2120
use uv_python::{PythonDownloads, PythonInstallationKey, PythonRequest, PythonVersionFile};
2221
use uv_shell::Shell;
22+
use uv_trampoline_builder::{Launcher, LauncherKind};
2323
use uv_warnings::warn_user;
2424

2525
use crate::commands::python::{ChangeEvent, ChangeEventKind};
@@ -73,7 +73,6 @@ struct Changelog {
7373
installed: FxHashSet<PythonInstallationKey>,
7474
uninstalled: FxHashSet<PythonInstallationKey>,
7575
installed_executables: FxHashMap<PythonInstallationKey, Vec<PathBuf>>,
76-
uninstalled_executables: FxHashSet<PathBuf>,
7776
}
7877

7978
impl Changelog {
@@ -104,10 +103,12 @@ impl Changelog {
104103
}
105104

106105
/// Download and install Python versions.
106+
#[allow(clippy::fn_params_excessive_bools)]
107107
pub(crate) async fn install(
108108
project_dir: &Path,
109109
targets: Vec<String>,
110110
reinstall: bool,
111+
force: bool,
111112
python_downloads: PythonDownloads,
112113
native_tls: bool,
113114
connectivity: Connectivity,
@@ -281,7 +282,7 @@ pub(crate) async fn install(
281282
Ok(()) => {
282283
debug!(
283284
"Installed executable at {} for {}",
284-
target.user_display(),
285+
target.simplified_display(),
285286
installation.key(),
286287
);
287288
changelog.installed.insert(installation.key().clone());
@@ -291,42 +292,102 @@ pub(crate) async fn install(
291292
.or_default()
292293
.push(target.clone());
293294
}
294-
Err(uv_python::managed::Error::LinkExecutable { from, to, err })
295+
Err(uv_python::managed::Error::LinkExecutable { from: _, to, err })
295296
if err.kind() == ErrorKind::AlreadyExists =>
296297
{
297-
// TODO(zanieb): Add `--force`
298-
if reinstall {
299-
fs_err::remove_file(&to)?;
300-
installation.create_bin_link(&target)?;
301-
debug!(
302-
"Updated executable at {} to {}",
303-
target.user_display(),
304-
installation.key(),
305-
);
306-
changelog.installed.insert(installation.key().clone());
307-
changelog
308-
.installed_executables
309-
.entry(installation.key().clone())
310-
.or_default()
311-
.push(target.clone());
312-
changelog.uninstalled_executables.insert(target);
313-
} else {
314-
if !is_same_file(&to, &from).unwrap_or_default() {
315-
errors.push((
298+
debug!(
299+
"Inspecting existing executable at {}",
300+
target.simplified_display()
301+
);
302+
303+
// Figure out what installation it references, if any
304+
let existing = find_matching_bin_link(&existing_installations, &target);
305+
306+
match existing {
307+
None => {
308+
// There's an existing executable we don't manage, require `--force`
309+
if !force {
310+
errors.push((
311+
installation.key(),
312+
anyhow::anyhow!(
313+
"Executable already exists at `{}` but is not managed by uv; use `--force` to replace it",
314+
to.simplified_display()
315+
),
316+
));
317+
continue;
318+
}
319+
debug!(
320+
"Replacing existing executable at `{}` due to `--force`",
321+
target.simplified_display()
322+
);
323+
}
324+
Some(existing) if existing == installation => {
325+
// The existing link points to the same installation, so we're done unless
326+
// they requested we reinstall
327+
if !(reinstall || force) {
328+
debug!(
329+
"Executable at `{}` is already for `{}`",
330+
target.simplified_display(),
331+
installation.key(),
332+
);
333+
continue;
334+
}
335+
debug!(
336+
"Replacing existing executable for `{}` at `{}`",
316337
installation.key(),
317-
anyhow::anyhow!(
318-
"Executable already exists at `{}`. Use `--reinstall` to force replacement.",
319-
to.user_display()
320-
),
321-
));
338+
target.simplified_display(),
339+
);
340+
}
341+
Some(existing) => {
342+
// The existing link points to a different installation, check if it
343+
// is reasonable to replace
344+
if force {
345+
debug!(
346+
"Replacing existing executable for `{}` at `{}` with executable for `{}` due to `--force` flag",
347+
existing.key(),
348+
target.simplified_display(),
349+
installation.key(),
350+
);
351+
} else {
352+
if installation.is_upgrade_of(existing) {
353+
debug!(
354+
"Replacing existing executable for `{}` at `{}` with executable for `{}` since it is an upgrade",
355+
existing.key(),
356+
target.simplified_display(),
357+
installation.key(),
358+
);
359+
} else {
360+
debug!(
361+
"Executable already exists at `{}` for `{}`. Use `--force` to replace it.",
362+
existing.key(),
363+
to.simplified_display()
364+
);
365+
continue;
366+
}
367+
}
322368
}
323369
}
370+
371+
// Replace the existing link
372+
fs_err::remove_file(&to)?;
373+
installation.create_bin_link(&target)?;
374+
debug!(
375+
"Updated executable at `{}` to `{}`",
376+
target.simplified_display(),
377+
installation.key(),
378+
);
379+
changelog.installed.insert(installation.key().clone());
380+
changelog
381+
.installed_executables
382+
.entry(installation.key().clone())
383+
.or_default()
384+
.push(target.clone());
324385
}
325386
Err(err) => return Err(err.into()),
326387
}
327388
}
328389

329-
if changelog.installed.is_empty() {
390+
if changelog.installed.is_empty() && errors.is_empty() {
330391
if is_default_install {
331392
writeln!(
332393
printer.stderr(),
@@ -483,3 +544,32 @@ fn warn_if_not_on_path(bin: &Path) {
483544
}
484545
}
485546
}
547+
548+
/// Find the [`ManagedPythonInstallation`] corresponding to an executable link installed at the
549+
/// given path, if any.
550+
///
551+
/// Like [`ManagedPythonInstallation::is_bin_link`], but this method will only resolve the
552+
/// given path one time.
553+
fn find_matching_bin_link<'a>(
554+
installations: &'a [ManagedPythonInstallation],
555+
path: &Path,
556+
) -> Option<&'a ManagedPythonInstallation> {
557+
let target = if cfg!(unix) {
558+
if !path.is_symlink() {
559+
return None;
560+
}
561+
path.read_link().ok()?
562+
} else if cfg!(windows) {
563+
let launcher = Launcher::try_from_path(path).ok()??;
564+
if !matches!(launcher.kind, LauncherKind::Python) {
565+
return None;
566+
}
567+
launcher.python_path
568+
} else {
569+
unreachable!("Only Windows and Unix are supported")
570+
};
571+
572+
installations
573+
.iter()
574+
.find(|installation| installation.executable() == target)
575+
}

crates/uv/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1053,6 +1053,7 @@ async fn run(mut cli: Cli) -> Result<ExitStatus> {
10531053
&project_dir,
10541054
args.targets,
10551055
args.reinstall,
1056+
args.force,
10561057
globals.python_downloads,
10571058
globals.native_tls,
10581059
globals.connectivity,

crates/uv/src/settings.rs

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -620,15 +620,24 @@ impl PythonDirSettings {
620620
pub(crate) struct PythonInstallSettings {
621621
pub(crate) targets: Vec<String>,
622622
pub(crate) reinstall: bool,
623+
pub(crate) force: bool,
623624
}
624625

625626
impl PythonInstallSettings {
626627
/// Resolve the [`PythonInstallSettings`] from the CLI and filesystem configuration.
627628
#[allow(clippy::needless_pass_by_value)]
628629
pub(crate) fn resolve(args: PythonInstallArgs, _filesystem: Option<FilesystemOptions>) -> Self {
629-
let PythonInstallArgs { targets, reinstall } = args;
630+
let PythonInstallArgs {
631+
targets,
632+
reinstall,
633+
force,
634+
} = args;
630635

631-
Self { targets, reinstall }
636+
Self {
637+
targets,
638+
reinstall,
639+
force,
640+
}
632641
}
633642
}
634643

crates/uv/tests/it/help.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -441,6 +441,13 @@ fn help_subsubcommand() {
441441
442442
By default, uv will exit successfully if the version is already installed.
443443
444+
-f, --force
445+
Replace existing Python executables during installation.
446+
447+
By default, uv will refuse to replace executables that it does not manage.
448+
449+
Implies `--reinstall`.
450+
444451
Cache options:
445452
-n, --no-cache
446453
Avoid reading from or writing to the cache, instead using a temporary directory for the
@@ -646,6 +653,7 @@ fn help_flag_subsubcommand() {
646653
647654
Options:
648655
-r, --reinstall Reinstall the requested Python version, if it's already installed
656+
-f, --force Replace existing Python executables during installation
649657
650658
Cache options:
651659
-n, --no-cache Avoid reading from or writing to the cache, instead using a temporary

0 commit comments

Comments
 (0)