Skip to content

Commit 8e382ef

Browse files
committed
Add uv python install --default
1 parent 85f9a0d commit 8e382ef

File tree

11 files changed

+254
-57
lines changed

11 files changed

+254
-57
lines changed

crates/uv-cli/src/lib.rs

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

39123927
#[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

+52-32
Original file line numberDiff line numberDiff line change
@@ -104,10 +104,12 @@ impl Changelog {
104104
}
105105

106106
/// Download and install Python versions.
107+
#[allow(clippy::fn_params_excessive_bools)]
107108
pub(crate) async fn install(
108109
project_dir: &Path,
109110
targets: Vec<String>,
110111
reinstall: bool,
112+
default: bool,
111113
python_downloads: PythonDownloads,
112114
native_tls: bool,
113115
connectivity: Connectivity,
@@ -117,6 +119,11 @@ pub(crate) async fn install(
117119
) -> Result<ExitStatus> {
118120
let start = std::time::Instant::now();
119121

122+
if default && !preview.is_enabled() {
123+
writeln!(printer.stderr(), "The `--default` flag is only available in preview mode; add the `--preview` flag to use `--default.")?;
124+
return Ok(ExitStatus::Failure);
125+
}
126+
120127
// Resolve the requests
121128
let mut is_default_install = false;
122129
let requests: Vec<_> = if targets.is_empty() {
@@ -269,34 +276,27 @@ pub(crate) async fn install(
269276
continue;
270277
}
271278

279+
let targets = if default {
280+
vec![
281+
installation.key().executable_name_minor(),
282+
installation.key().executable_name_major(),
283+
installation.key().executable_name(),
284+
]
285+
} else {
286+
vec![installation.key().executable_name_minor()]
287+
};
288+
272289
let bin = bin
273290
.as_ref()
274291
.expect("We should have a bin directory with preview enabled")
275292
.as_path();
276293

277-
match installation.create_bin_link(bin) {
278-
Ok(target) => {
279-
debug!(
280-
"Installed executable at {} for {}",
281-
target.user_display(),
282-
installation.key(),
283-
);
284-
changelog.installed.insert(installation.key().clone());
285-
changelog
286-
.installed_executables
287-
.entry(installation.key().clone())
288-
.or_default()
289-
.push(target.clone());
290-
}
291-
Err(uv_python::managed::Error::LinkExecutable { from, to, err })
292-
if err.kind() == ErrorKind::AlreadyExists =>
293-
{
294-
// TODO(zanieb): Add `--force`
295-
if reinstall {
296-
fs_err::remove_file(&to)?;
297-
let target = installation.create_bin_link(bin)?;
294+
for target in targets {
295+
let target = bin.join(target);
296+
match installation.create_bin_link(&target) {
297+
Ok(()) => {
298298
debug!(
299-
"Updated executable at {} to {}",
299+
"Installed executable at {} for {}",
300300
target.user_display(),
301301
installation.key(),
302302
);
@@ -306,20 +306,40 @@ pub(crate) async fn install(
306306
.entry(installation.key().clone())
307307
.or_default()
308308
.push(target.clone());
309-
changelog.uninstalled_executables.insert(target);
310-
} else {
311-
if !is_same_file(&to, &from).unwrap_or_default() {
312-
errors.push((
309+
}
310+
Err(uv_python::managed::Error::LinkExecutable { from, to, err })
311+
if err.kind() == ErrorKind::AlreadyExists =>
312+
{
313+
// TODO(zanieb): Add `--force`
314+
if reinstall {
315+
fs_err::remove_file(&to)?;
316+
installation.create_bin_link(&target)?;
317+
debug!(
318+
"Updated executable at {} to {}",
319+
target.user_display(),
313320
installation.key(),
314-
anyhow::anyhow!(
315-
"Executable already exists at `{}`. Use `--reinstall` to force replacement.",
316-
to.user_display()
317-
),
318-
));
321+
);
322+
changelog.installed.insert(installation.key().clone());
323+
changelog
324+
.installed_executables
325+
.entry(installation.key().clone())
326+
.or_default()
327+
.push(target.clone());
328+
changelog.uninstalled_executables.insert(target);
329+
} else {
330+
if !is_same_file(&to, &from).unwrap_or_default() {
331+
errors.push((
332+
installation.key(),
333+
anyhow::anyhow!(
334+
"Executable already exists at `{}`. Use `--reinstall` to force replacement.",
335+
to.user_display()
336+
),
337+
));
338+
}
319339
}
320340
}
341+
Err(err) => return Err(err.into()),
321342
}
322-
Err(err) => return Err(err.into()),
323343
}
324344
}
325345

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
@@ -1053,6 +1053,7 @@ async fn run(mut cli: Cli) -> Result<ExitStatus> {
10531053
&project_dir,
10541054
args.targets,
10551055
args.reinstall,
1056+
args.default,
10561057
globals.python_downloads,
10571058
globals.native_tls,
10581059
globals.connectivity,

crates/uv/src/settings.rs

+11-2
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) default: 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+
default,
634+
} = args;
630635

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

crates/uv/tests/it/common/mod.rs

+7-1
Original file line numberDiff line numberDiff line change
@@ -215,10 +215,16 @@ impl TestContext {
215215

216216
/// Adds a filter that ignores platform information in a Python installation key.
217217
pub fn with_filtered_python_keys(mut self) -> Self {
218+
// Filter platform keys
218219
self.filters.push((
219-
r"((?:cpython|pypy)-\d+\.\d+(:?\.\d+)?[a-z]?(:?\+[a-z]+)?)-.*".to_string(),
220+
r"((?:cpython|pypy)-\d+\.\d+(?:\.(?:\[X\]|\d+))?[a-z]?(?:\+[a-z]+)?)-.*".to_string(),
220221
"$1-[PLATFORM]".to_string(),
221222
));
223+
// Filter patch versions
224+
self.filters.push((
225+
r"((?:cpython|pypy)-\d+\.\d+)\.\d+([a-z])?".to_string(),
226+
"$1.[X]$2".to_string(),
227+
));
222228
self
223229
}
224230

crates/uv/tests/it/help.rs

+15
Original file line numberDiff line numberDiff line change
@@ -441,6 +441,20 @@ fn help_subsubcommand() {
441441
442442
By default, uv will exit successfully if the version is already installed.
443443
444+
--default
445+
Use as the default Python version.
446+
447+
By default, only a `python{major}.{minor}` executable is installed, e.g., `python3.10`.
448+
When the `--default` flag is used, `python{major}`, e.g., `python3`, and `python`
449+
executables are also installed.
450+
451+
Alternative Python variants will still include their tag. For example, installing
452+
3.13+freethreaded with `--default` will include in `python3t` and `pythont`, not `python3`
453+
and `python`.
454+
455+
If multiple Python versions are requested during the installation, the first request will
456+
be the default.
457+
444458
Cache options:
445459
-n, --no-cache
446460
Avoid reading from or writing to the cache, instead using a temporary directory for the
@@ -646,6 +660,7 @@ fn help_flag_subsubcommand() {
646660
647661
Options:
648662
-r, --reinstall Reinstall the requested Python version, if it's already installed
663+
--default Use as the default Python version
649664
650665
Cache options:
651666
-n, --no-cache Avoid reading from or writing to the cache, instead using a temporary

0 commit comments

Comments
 (0)