Skip to content

Commit 4579f3f

Browse files
committed
Add uv python install --default
1 parent 28b6613 commit 4579f3f

File tree

8 files changed

+197
-41
lines changed

8 files changed

+197
-41
lines changed

crates/uv-cli/src/lib.rs

+15
Original file line numberDiff line numberDiff line change
@@ -3883,6 +3883,21 @@ pub struct PythonInstallArgs {
38833883
/// installed.
38843884
#[arg(long, short, alias = "force")]
38853885
pub reinstall: bool,
3886+
3887+
/// Use as the default Python version.
3888+
///
3889+
/// By default, only a `python{major}.{minor}` executable is installed, e.g., `python3.10`. When
3890+
/// the `--default` flag is used, `python{major}`, e.g., `python3`, and `python` executables are
3891+
/// also installed.
3892+
///
3893+
/// Alternative Python variants will still include their tag. For example, installing
3894+
/// 3.13+freethreaded with `--default` will include in `python3t` and `pythont`, not `python3`
3895+
/// and `python`.
3896+
///
3897+
/// If multiple Python versions are requested during the installation, the first request will be
3898+
/// the default.
3899+
#[arg(long)]
3900+
pub default: bool,
38863901
}
38873902

38883903
#[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/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
@@ -629,15 +629,24 @@ impl PythonListSettings {
629629
pub(crate) struct PythonInstallSettings {
630630
pub(crate) targets: Vec<String>,
631631
pub(crate) reinstall: bool,
632+
pub(crate) default: bool,
632633
}
633634

634635
impl PythonInstallSettings {
635636
/// Resolve the [`PythonInstallSettings`] from the CLI and filesystem configuration.
636637
#[allow(clippy::needless_pass_by_value)]
637638
pub(crate) fn resolve(args: PythonInstallArgs, _filesystem: Option<FilesystemOptions>) -> Self {
638-
let PythonInstallArgs { targets, reinstall } = args;
639+
let PythonInstallArgs {
640+
targets,
641+
reinstall,
642+
default,
643+
} = args;
639644

640-
Self { targets, reinstall }
645+
Self {
646+
targets,
647+
reinstall,
648+
default,
649+
}
641650
}
642651
}
643652

crates/uv/tests/it/python_install.rs

+98
Original file line numberDiff line numberDiff line change
@@ -188,3 +188,101 @@ fn python_install_freethreaded() {
188188
- cpython-3.13.0+freethreaded-[PLATFORM]
189189
"###);
190190
}
191+
192+
#[test]
193+
fn python_install_default() {
194+
let context: TestContext = TestContext::new_with_versions(&[]).with_filtered_python_keys();
195+
196+
// Install the latest version
197+
uv_snapshot!(context.filters(), context.python_install(), @r###"
198+
success: true
199+
exit_code: 0
200+
----- stdout -----
201+
202+
----- stderr -----
203+
Searching for Python installations
204+
Installed Python 3.13.0 in [TIME]
205+
+ cpython-3.13.0-[PLATFORM]
206+
warning: `[TEMP_DIR]/bin` is not on your PATH. To use the installed Python executable, run `export PATH="[TEMP_DIR]/bin:$PATH"`.
207+
"###);
208+
209+
let bin_python_minor = context
210+
.temp_dir
211+
.child("bin")
212+
.child(format!("python3.13{}", std::env::consts::EXE_SUFFIX));
213+
214+
let bin_python_major = context
215+
.temp_dir
216+
.child("bin")
217+
.child(format!("python3{}", std::env::consts::EXE_SUFFIX));
218+
219+
let bin_python_default = context
220+
.temp_dir
221+
.child("bin")
222+
.child(format!("python{}", std::env::consts::EXE_SUFFIX));
223+
224+
// The minor-versioend executable should be installed in the bin directory
225+
bin_python_minor.assert(predicate::path::exists());
226+
// But the others should not
227+
bin_python_major.assert(predicate::path::missing());
228+
bin_python_default.assert(predicate::path::missing());
229+
230+
// TODO(zanieb): We should add the executables without the `--reinstall` flag
231+
uv_snapshot!(context.filters(), context.python_install().arg("--default").arg("--reinstall"), @r###"
232+
success: true
233+
exit_code: 0
234+
----- stdout -----
235+
236+
----- stderr -----
237+
Searching for Python installations
238+
Found: cpython-3.13.0-[PLATFORM]
239+
Installed Python 3.13.0 in [TIME]
240+
~ cpython-3.13.0-[PLATFORM]
241+
warning: `[TEMP_DIR]/bin` is not on your PATH. To use the installed Python executable, run `export PATH="[TEMP_DIR]/bin:$PATH"`.
242+
"###);
243+
244+
// Now we should have an unversioned and major-versioned executable
245+
bin_python_minor.assert(predicate::path::exists());
246+
bin_python_major.assert(predicate::path::exists());
247+
bin_python_default.assert(predicate::path::exists());
248+
249+
uv_snapshot!(context.filters(), context.python_uninstall().arg("3.13"), @r###"
250+
success: true
251+
exit_code: 0
252+
----- stdout -----
253+
254+
----- stderr -----
255+
Searching for Python versions matching: Python 3.13
256+
Uninstalled Python 3.13.0 in [TIME]
257+
- cpython-3.13.0-[PLATFORM]
258+
"###);
259+
260+
// We should remove all the executables
261+
bin_python_minor.assert(predicate::path::missing());
262+
bin_python_major.assert(predicate::path::missing());
263+
bin_python_default.assert(predicate::path::missing());
264+
265+
// Install multiple versions
266+
uv_snapshot!(context.filters(), context.python_install().arg("3.12").arg("3.13").arg("--default"), @r###"
267+
success: false
268+
exit_code: 1
269+
----- stdout -----
270+
271+
----- stderr -----
272+
Searching for Python versions matching: Python 3.12
273+
Searching for Python versions matching: Python 3.13
274+
Installed 2 versions in [TIME]
275+
+ cpython-3.12.7-[PLATFORM]
276+
+ cpython-3.13.0-[PLATFORM]
277+
warning: `[TEMP_DIR]/bin` is not on your PATH. To use the installed Python executable, run `export PATH="[TEMP_DIR]/bin:$PATH"`.
278+
error: Failed to install cpython-3.12.7-[PLATFORM]
279+
Caused by: Executable already exists at `bin/python3`. Use `--reinstall` to force replacement.
280+
error: Failed to install cpython-3.12.7-[PLATFORM]
281+
Caused by: Executable already exists at `bin/python`. Use `--reinstall` to force replacement.
282+
"###);
283+
284+
bin_python_minor.assert(predicate::path::exists());
285+
bin_python_major.assert(predicate::path::exists());
286+
bin_python_default.assert(predicate::path::exists());
287+
// TODO(zanieb): Assert that 3.12 is the default version
288+
}

docs/reference/cli.md

+2
Original file line numberDiff line numberDiff line change
@@ -4427,6 +4427,8 @@ uv python install [OPTIONS] [TARGETS]...
44274427
<p>While uv configuration can be included in a <code>pyproject.toml</code> file, it is not allowed in this context.</p>
44284428

44294429
<p>May also be set with the <code>UV_CONFIG_FILE</code> environment variable.</p>
4430+
</dd><dt><code>--default</code></dt><dd><p>Use the installed version as the default Python version</p>
4431+
44304432
</dd><dt><code>--directory</code> <i>directory</i></dt><dd><p>Change to the given directory prior to running the command.</p>
44314433

44324434
<p>Relative paths are resolved with the given directory as the base.</p>

0 commit comments

Comments
 (0)