Skip to content

Commit 656c3b3

Browse files
committed
Add uv python install --default
1 parent 00873e2 commit 656c3b3

File tree

10 files changed

+223
-44
lines changed

10 files changed

+223
-44
lines changed

crates/uv-cli/src/lib.rs

+15
Original file line numberDiff line numberDiff line change
@@ -3904,6 +3904,21 @@ pub struct PythonInstallArgs {
39043904
/// installed.
39053905
#[arg(long, short, alias = "force")]
39063906
pub reinstall: bool,
3907+
3908+
/// Use as the default Python version.
3909+
///
3910+
/// By default, only a `python{major}.{minor}` executable is installed, e.g., `python3.10`. When
3911+
/// the `--default` flag is used, `python{major}`, e.g., `python3`, and `python` executables are
3912+
/// also installed.
3913+
///
3914+
/// Alternative Python variants will still include their tag. For example, installing
3915+
/// 3.13+freethreaded with `--default` will include in `python3t` and `pythont`, not `python3`
3916+
/// and `python`.
3917+
///
3918+
/// If multiple Python versions are requested during the installation, the first request will be
3919+
/// the default.
3920+
#[arg(long)]
3921+
pub default: bool,
39073922
}
39083923

39093924
#[derive(Args)]

crates/uv-python/src/installation.rs

+21-2
Original file line numberDiff line numberDiff line change
@@ -306,8 +306,8 @@ impl PythonInstallationKey {
306306
&self.libc
307307
}
308308

309-
/// Return a canonical name for a versioned executable.
310-
pub fn versioned_executable_name(&self) -> String {
309+
/// Return a canonical name for a minor versioned executable.
310+
pub fn executable_name_minor(&self) -> String {
311311
format!(
312312
"python{maj}.{min}{var}{exe}",
313313
maj = self.major,
@@ -316,6 +316,25 @@ impl PythonInstallationKey {
316316
exe = std::env::consts::EXE_SUFFIX
317317
)
318318
}
319+
320+
/// Return a canonical name for a major versioned executable.
321+
pub fn executable_name_major(&self) -> String {
322+
format!(
323+
"python{maj}{var}{exe}",
324+
maj = self.major,
325+
var = self.variant.suffix(),
326+
exe = std::env::consts::EXE_SUFFIX
327+
)
328+
}
329+
330+
/// Return a canonical name for an un-versioned executable.
331+
pub fn executable_name(&self) -> String {
332+
format!(
333+
"python{var}{exe}",
334+
var = self.variant.suffix(),
335+
exe = std::env::consts::EXE_SUFFIX
336+
)
337+
}
319338
}
320339

321340
impl fmt::Display for PythonInstallationKey {

crates/uv-python/src/managed.rs

+6-8
Original file line numberDiff line numberDiff line change
@@ -475,26 +475,24 @@ impl ManagedPythonInstallation {
475475
Ok(())
476476
}
477477

478-
/// Create a link to the Python executable in the given `bin` directory.
479-
pub fn create_bin_link(&self, bin: &Path) -> Result<PathBuf, Error> {
478+
/// Create a link to the managed Python executable.
479+
pub fn create_bin_link(&self, target: &Path) -> Result<(), Error> {
480480
let python = self.executable();
481481

482+
let bin = target.parent().ok_or(Error::NoExecutableDirectory)?;
482483
fs_err::create_dir_all(bin).map_err(|err| Error::ExecutableDirectory {
483484
to: bin.to_path_buf(),
484485
err,
485486
})?;
486487

487-
// TODO(zanieb): Add support for a "default" which
488-
let python_in_bin = bin.join(self.key.versioned_executable_name());
489-
490-
match uv_fs::symlink_copy_fallback_file(&python, &python_in_bin) {
491-
Ok(()) => Ok(python_in_bin),
488+
match uv_fs::symlink_copy_fallback_file(&python, target) {
489+
Ok(()) => Ok(()),
492490
Err(err) if err.kind() == io::ErrorKind::NotFound => {
493491
Err(Error::MissingExecutable(python.clone()))
494492
}
495493
Err(err) => Err(Error::LinkExecutable {
496494
from: python,
497-
to: python_in_bin,
495+
to: target.to_path_buf(),
498496
err,
499497
}),
500498
}

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

+43-29
Original file line numberDiff line numberDiff line change
@@ -29,10 +29,12 @@ use crate::commands::{elapsed, ExitStatus};
2929
use crate::printer::Printer;
3030

3131
/// Download and install Python versions.
32+
#[allow(clippy::fn_params_excessive_bools)]
3233
pub(crate) async fn install(
3334
project_dir: &Path,
3435
targets: Vec<String>,
3536
reinstall: bool,
37+
default: bool,
3638
python_downloads: PythonDownloads,
3739
native_tls: bool,
3840
connectivity: Connectivity,
@@ -99,6 +101,7 @@ pub(crate) async fn install(
99101
installation.key().green(),
100102
)?;
101103
}
104+
// TODO(zanieb): Ensure executables are linked for already-installed versions
102105
if reinstall {
103106
uninstalled.insert(installation.key());
104107
unfilled_requests.push(download_request);
@@ -186,35 +189,46 @@ pub(crate) async fn install(
186189
let managed = ManagedPythonInstallation::new(path.clone())?;
187190
managed.ensure_externally_managed()?;
188191
managed.ensure_canonical_executables()?;
189-
match managed.create_bin_link(&bin) {
190-
Ok(executable) => {
191-
debug!("Installed {} executable to {}", key, executable.display());
192-
}
193-
Err(uv_python::managed::Error::LinkExecutable { from, to, err })
194-
if err.kind() == ErrorKind::AlreadyExists =>
195-
{
196-
// TODO(zanieb): Add `--force`
197-
if reinstall {
198-
fs_err::remove_file(&to)?;
199-
let executable = managed.create_bin_link(&bin)?;
200-
debug!(
201-
"Replaced {} executable at {}",
202-
key,
203-
executable.user_display()
204-
);
205-
} else {
206-
if !is_same_file(&to, &from).unwrap_or_default() {
207-
errors.push((
208-
key,
209-
anyhow::anyhow!(
210-
"Executable already exists at `{}`. Use `--reinstall` to force replacement.",
211-
to.user_display()
212-
),
213-
));
192+
193+
// TODO(zanieb): Only apply `default` for the _first_ requested version
194+
let targets = if default {
195+
vec![
196+
managed.key().executable_name_minor(),
197+
managed.key().executable_name_major(),
198+
managed.key().executable_name(),
199+
]
200+
} else {
201+
vec![managed.key().executable_name_minor()]
202+
};
203+
204+
for target in targets {
205+
let target = bin.join(target);
206+
match managed.create_bin_link(&target) {
207+
Ok(()) => {
208+
debug!("Installed {} executable to {}", key, target.display());
209+
}
210+
Err(uv_python::managed::Error::LinkExecutable { from, to, err })
211+
if err.kind() == ErrorKind::AlreadyExists =>
212+
{
213+
// TODO(zanieb): Add `--force`
214+
if reinstall {
215+
fs_err::remove_file(&to)?;
216+
managed.create_bin_link(&target)?;
217+
debug!("Replaced {} executable at {}", key, target.user_display());
218+
} else {
219+
if !is_same_file(&to, &from).unwrap_or_default() {
220+
errors.push((
221+
key,
222+
anyhow::anyhow!(
223+
"Executable already exists at `{}`. Use `--reinstall` to force replacement.",
224+
to.user_display()
225+
),
226+
));
227+
}
214228
}
215229
}
230+
Err(err) => return Err(err.into()),
216231
}
217-
Err(err) => return Err(err.into()),
218232
}
219233
}
220234
Err(err) => {
@@ -280,7 +294,7 @@ pub(crate) async fn install(
280294
" {} {} ({})",
281295
"+".green(),
282296
event.key.bold(),
283-
event.key.versioned_executable_name()
297+
event.key.executable_name_minor()
284298
)?;
285299
}
286300
ChangeEventKind::Removed => {
@@ -289,7 +303,7 @@ pub(crate) async fn install(
289303
" {} {} ({})",
290304
"-".red(),
291305
event.key.bold(),
292-
event.key.versioned_executable_name()
306+
event.key.executable_name_minor()
293307
)?;
294308
}
295309
ChangeEventKind::Reinstalled => {
@@ -298,7 +312,7 @@ pub(crate) async fn install(
298312
" {} {} ({})",
299313
"~".yellow(),
300314
event.key.bold(),
301-
event.key.versioned_executable_name()
315+
event.key.executable_name_minor()
302316
)?;
303317
}
304318
}

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

+5-3
Original file line numberDiff line numberDiff line change
@@ -141,8 +141,10 @@ async fn do_uninstall(
141141
// leave broken links behind, i.e., if the user created them.
142142
.filter(|path| {
143143
matching_installations.iter().any(|installation| {
144-
path.file_name().and_then(|name| name.to_str())
145-
== Some(&installation.key().versioned_executable_name())
144+
let name = path.file_name().and_then(|name| name.to_str());
145+
name == Some(&installation.key().executable_name_minor())
146+
|| name == Some(&installation.key().executable_name_major())
147+
|| name == Some(&installation.key().executable_name())
146148
})
147149
})
148150
// Only include Python executables that match the installations
@@ -222,7 +224,7 @@ async fn do_uninstall(
222224
" {} {} ({})",
223225
"-".red(),
224226
event.key.bold(),
225-
event.key.versioned_executable_name()
227+
event.key.executable_name_minor()
226228
)?;
227229
}
228230
_ => unreachable!(),

crates/uv/src/lib.rs

+1
Original file line numberDiff line numberDiff line change
@@ -1059,6 +1059,7 @@ async fn run(mut cli: Cli) -> Result<ExitStatus> {
10591059
&project_dir,
10601060
args.targets,
10611061
args.reinstall,
1062+
args.default,
10621063
globals.python_downloads,
10631064
globals.native_tls,
10641065
globals.connectivity,

crates/uv/src/settings.rs

+11-2
Original file line numberDiff line numberDiff line change
@@ -646,15 +646,24 @@ impl PythonDirSettings {
646646
pub(crate) struct PythonInstallSettings {
647647
pub(crate) targets: Vec<String>,
648648
pub(crate) reinstall: bool,
649+
pub(crate) default: bool,
649650
}
650651

651652
impl PythonInstallSettings {
652653
/// Resolve the [`PythonInstallSettings`] from the CLI and filesystem configuration.
653654
#[allow(clippy::needless_pass_by_value)]
654655
pub(crate) fn resolve(args: PythonInstallArgs, _filesystem: Option<FilesystemOptions>) -> Self {
655-
let PythonInstallArgs { targets, reinstall } = args;
656+
let PythonInstallArgs {
657+
targets,
658+
reinstall,
659+
default,
660+
} = args;
656661

657-
Self { targets, reinstall }
662+
Self {
663+
targets,
664+
reinstall,
665+
default,
666+
}
658667
}
659668
}
660669

crates/uv/tests/it/help.rs

+15
Original file line numberDiff line numberDiff line change
@@ -486,6 +486,20 @@ fn help_subsubcommand() {
486486
487487
By default, uv will exit successfully if the version is already installed.
488488
489+
--default
490+
Use as the default Python version.
491+
492+
By default, only a `python{major}.{minor}` executable is installed, e.g., `python3.10`.
493+
When the `--default` flag is used, `python{major}`, e.g., `python3`, and `python`
494+
executables are also installed.
495+
496+
Alternative Python variants will still include their tag. For example, installing
497+
3.13+freethreaded with `--default` will include in `python3t` and `pythont`, not `python3`
498+
and `python`.
499+
500+
If multiple Python versions are requested during the installation, the first request will
501+
be the default.
502+
489503
Cache options:
490504
-n, --no-cache
491505
Avoid reading from or writing to the cache, instead using a temporary directory for the
@@ -716,6 +730,7 @@ fn help_flag_subsubcommand() {
716730
717731
Options:
718732
-r, --reinstall Reinstall the requested Python version, if it's already installed
733+
--default Use as the default Python version
719734
720735
Cache options:
721736
-n, --no-cache Avoid reading from or writing to the cache, instead using a temporary

0 commit comments

Comments
 (0)