Skip to content

Commit 7e14476

Browse files
committed
Add uv python install --default
1 parent 00873e2 commit 7e14476

File tree

9 files changed

+218
-41
lines changed

9 files changed

+218
-41
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/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

crates/uv/tests/it/python_install.rs

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

docs/reference/cli.md

+8
Original file line numberDiff line numberDiff line change
@@ -4427,6 +4427,14 @@ 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 as the default Python version.</p>
4431+
4432+
<p>By default, only a <code>python{major}.{minor}</code> executable is installed, e.g., <code>python3.10</code>. When the <code>--default</code> flag is used, <code>python{major}</code>, e.g., <code>python3</code>, and <code>python</code> executables are also installed.</p>
4433+
4434+
<p>Alternative Python variants will still include their tag. For example, installing 3.13+freethreaded with <code>--default</code> will include in <code>python3t</code> and <code>pythont</code>, not <code>python3</code> and <code>python</code>.</p>
4435+
4436+
<p>If multiple Python versions are requested during the installation, the first request will be the default.</p>
4437+
44304438
</dd><dt><code>--directory</code> <i>directory</i></dt><dd><p>Change to the given directory prior to running the command.</p>
44314439

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

0 commit comments

Comments
 (0)