@@ -8,7 +8,6 @@ use futures::StreamExt;
88use itertools:: { Either , Itertools } ;
99use owo_colors:: OwoColorize ;
1010use rustc_hash:: { FxHashMap , FxHashSet } ;
11- use same_file:: is_same_file;
1211use tracing:: { debug, trace} ;
1312
1413use uv_client:: Connectivity ;
@@ -20,6 +19,7 @@ use uv_python::managed::{
2019} ;
2120use uv_python:: { PythonDownloads , PythonInstallationKey , PythonRequest , PythonVersionFile } ;
2221use uv_shell:: Shell ;
22+ use uv_trampoline_builder:: { Launcher , LauncherKind } ;
2323use uv_warnings:: warn_user;
2424
2525use 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
7978impl Changelog {
@@ -104,10 +103,12 @@ impl Changelog {
104103}
105104
106105/// Download and install Python versions.
106+ #[ allow( clippy:: fn_params_excessive_bools) ]
107107pub ( 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+ }
0 commit comments