Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions crates/uv-cli/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5868,6 +5868,19 @@ pub struct PythonInstallArgs {
#[arg(long, short)]
pub force: bool,

/// Upgrade existing Python installations to the latest patch version.
///
/// By default, uv will not upgrade already-installed Python versions to newer patch releases.
/// With `--upgrade`, uv will upgrade to the latest available patch version for the specified
/// minor version(s).
///
/// If the requested versions are not yet installed, uv will install them.
///
/// This option is only supported for minor version requests, e.g., `3.12`; uv will exit with an
/// error if a patch version, e.g., `3.12.2`, is requested.
#[arg(long, short = 'U')]
pub upgrade: bool,

/// Use as the default Python version.
///
/// By default, only a `python{major}.{minor}` executable is installed, e.g., `python3.10`. When
Expand Down
1 change: 1 addition & 0 deletions crates/uv/src/commands/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ pub(crate) use python::dir::dir as python_dir;
pub(crate) use python::find::find as python_find;
pub(crate) use python::find::find_script as python_find_script;
pub(crate) use python::install::install as python_install;
pub(crate) use python::install::{PythonUpgrade, PythonUpgradeSource};
pub(crate) use python::list::list as python_list;
pub(crate) use python::pin::pin as python_pin;
pub(crate) use python::uninstall::uninstall as python_uninstall;
Expand Down
166 changes: 122 additions & 44 deletions crates/uv/src/commands/python/install.rs
Original file line number Diff line number Diff line change
Expand Up @@ -149,14 +149,39 @@ enum InstallErrorKind {
Registry,
}

#[derive(Debug, Clone, Copy)]
pub(crate) enum PythonUpgradeSource {
/// The user invoked `uv python install --upgrade`
Install,
/// The user invoked `uv python upgrade`
Upgrade,
}

impl std::fmt::Display for PythonUpgradeSource {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Install => write!(f, "uv python install --upgrade"),
Self::Upgrade => write!(f, "uv python upgrade"),
}
}
}

#[derive(Debug, Clone, Copy)]
pub(crate) enum PythonUpgrade {
/// Python upgrades are enabled.
Enabled(PythonUpgradeSource),
/// Python upgrades are disabled.
Disabled,
}

/// Download and install Python versions.
#[allow(clippy::fn_params_excessive_bools)]
pub(crate) async fn install(
project_dir: &Path,
install_dir: Option<PathBuf>,
targets: Vec<String>,
reinstall: bool,
upgrade: bool,
upgrade: PythonUpgrade,
bin: Option<bool>,
registry: Option<bool>,
force: bool,
Expand All @@ -183,11 +208,13 @@ pub(crate) async fn install(
);
}

if upgrade && !preview.is_enabled(PreviewFeatures::PYTHON_UPGRADE) {
warn_user!(
"`uv python upgrade` is experimental and may change without warning. Pass `--preview-features {}` to disable this warning",
PreviewFeatures::PYTHON_UPGRADE
);
if let PythonUpgrade::Enabled(source @ PythonUpgradeSource::Upgrade) = upgrade {
if !preview.is_enabled(PreviewFeatures::PYTHON_UPGRADE) {
warn_user!(
"`{source}` is experimental and may change without warning. Pass `--preview-features {}` to disable this warning",
PreviewFeatures::PYTHON_UPGRADE
);
}
}

if default && targets.len() > 1 {
Expand All @@ -207,8 +234,14 @@ pub(crate) async fn install(
// Resolve the requests
let mut is_default_install = false;
let mut is_unspecified_upgrade = false;
// TODO(zanieb): We use this variable to special-case .python-version files, but it'd be nice to
// have generalized request source tracking instead
let mut is_from_python_version_file = false;
let requests: Vec<_> = if targets.is_empty() {
if upgrade {
if matches!(
upgrade,
PythonUpgrade::Enabled(PythonUpgradeSource::Upgrade)
) {
is_unspecified_upgrade = true;
// On upgrade, derive requests for all of the existing installations
let mut minor_version_requests = IndexSet::<InstallRequest>::default();
Expand Down Expand Up @@ -240,6 +273,7 @@ pub(crate) async fn install(
);
})
.map(PythonVersionFile::into_versions)
.inspect(|_| is_from_python_version_file = true)
.unwrap_or_else(|| {
// If no version file is found and no requests were made
// TODO(zanieb): We should consider differentiating between a global Python version
Expand All @@ -265,11 +299,20 @@ pub(crate) async fn install(
};

if requests.is_empty() {
if upgrade {
writeln!(
printer.stderr(),
"There are no installed versions to upgrade"
)?;
match upgrade {
PythonUpgrade::Enabled(PythonUpgradeSource::Upgrade) => {
writeln!(
printer.stderr(),
"There are no installed versions to upgrade"
)?;
}
PythonUpgrade::Enabled(PythonUpgradeSource::Install) => {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review note: This is reachable by creating an empty .python_versions file (unlike the branch above, which by default looks at installed Pythons)

writeln!(
printer.stderr(),
"No Python versions specified for upgrade; did you mean `uv python upgrade`?"
)?;
}
PythonUpgrade::Disabled => {}
}
return Ok(ExitStatus::Success);
}
Expand All @@ -287,17 +330,25 @@ pub(crate) async fn install(
})
.collect::<IndexSet<_>>();

if upgrade
&& let Some(request) = requests.iter().find(|request| {
if let PythonUpgrade::Enabled(source) = upgrade {
if let Some(request) = requests.iter().find(|request| {
request.request.includes_patch() || request.request.includes_prerelease()
})
{
writeln!(
printer.stderr(),
"error: `uv python upgrade` only accepts minor versions, got: {}",
request.request.to_canonical_string()
)?;
return Ok(ExitStatus::Failure);
}) {
writeln!(
printer.stderr(),
"error: `{source}` only accepts minor versions, got: {}",
request.request.to_canonical_string()
)?;
if is_from_python_version_file {
writeln!(
printer.stderr(),
"\n{}{} The version request came from a `.python-version` file; change the patch version in the file to upgrade instead",
"hint".bold().cyan(),
":".bold(),
)?;
}
return Ok(ExitStatus::Failure);
}
}

// Find requests that are already satisfied
Expand Down Expand Up @@ -361,10 +412,10 @@ pub(crate) async fn install(
// If we can find one existing installation that matches the request, it is satisfied
requests.iter().partition_map(|request| {
if let Some(installation) = existing_installations.iter().find(|installation| {
if upgrade {
// If this is an upgrade, the requested version is a minor version
// but the requested download is the highest patch for that minor
// version. We need to install it unless an exact match is found.
if matches!(upgrade, PythonUpgrade::Enabled(_)) {
// If this is an upgrade, the requested version is a minor version but the
// requested download is the highest patch for that minor version. We need to
// install it unless an exact match is found.
request.download.key() == installation.key()
} else {
request.matches_installation(installation)
Expand Down Expand Up @@ -498,7 +549,10 @@ pub(crate) async fn install(
force,
default,
upgradeable,
upgrade,
matches!(
upgrade,
PythonUpgrade::Enabled(PythonUpgradeSource::Upgrade)
),
is_default_install,
&existing_installations,
&installations,
Expand Down Expand Up @@ -534,7 +588,10 @@ pub(crate) async fn install(
);

for installation in minor_versions.values() {
if upgrade {
if matches!(
upgrade,
PythonUpgrade::Enabled(PythonUpgradeSource::Upgrade)
) {
// During an upgrade, update existing symlinks but avoid
// creating new ones.
installation.update_minor_version_link(preview)?;
Expand All @@ -545,24 +602,38 @@ pub(crate) async fn install(

if changelog.installed.is_empty() && errors.is_empty() {
if is_default_install {
writeln!(
printer.stderr(),
"Python is already installed. Use `uv python install <request>` to install another version.",
)?;
} else if upgrade && requests.is_empty() {
if matches!(
upgrade,
PythonUpgrade::Enabled(PythonUpgradeSource::Install)
) {
writeln!(
printer.stderr(),
"The default Python installation is already on the latest supported patch release. Use `uv python install <request>` to install another version.",
)?;
} else {
writeln!(
printer.stderr(),
"Python is already installed. Use `uv python install <request>` to install another version.",
)?;
}
} else if matches!(
upgrade,
PythonUpgrade::Enabled(PythonUpgradeSource::Upgrade)
) && requests.is_empty()
{
writeln!(
printer.stderr(),
"There are no installed versions to upgrade"
)?;
} else if upgrade && is_unspecified_upgrade {
writeln!(
printer.stderr(),
"All versions already on latest supported patch release"
)?;
} else if let [request] = requests.as_slice() {
// Convert to the inner request
let request = &request.request;
if upgrade {
if is_unspecified_upgrade {
writeln!(
printer.stderr(),
"All versions already on latest supported patch release"
)?;
} else if matches!(upgrade, PythonUpgrade::Enabled(_)) {
writeln!(
printer.stderr(),
"{request} is already on the latest supported patch release"
Expand All @@ -571,11 +642,18 @@ pub(crate) async fn install(
writeln!(printer.stderr(), "{request} is already installed")?;
}
} else {
if upgrade {
writeln!(
printer.stderr(),
"All requested versions already on latest supported patch release"
)?;
if matches!(upgrade, PythonUpgrade::Enabled(_)) {
if is_unspecified_upgrade {
writeln!(
printer.stderr(),
"All versions already on latest supported patch release"
)?;
} else {
writeln!(
printer.stderr(),
"All requested versions already on latest supported patch release"
)?;
}
} else {
writeln!(printer.stderr(), "All requested versions already installed")?;
}
Expand Down
6 changes: 2 additions & 4 deletions crates/uv/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1543,15 +1543,13 @@ async fn run(mut cli: Cli) -> Result<ExitStatus> {
// Resolve the settings from the command-line arguments and workspace configuration.
let args = settings::PythonInstallSettings::resolve(args, filesystem, environment);
show_settings!(args);
// TODO(john): If we later want to support `--upgrade`, we need to replace this.
let upgrade = false;

commands::python_install(
&project_dir,
args.install_dir,
args.targets,
args.reinstall,
upgrade,
args.upgrade,
args.bin,
args.registry,
args.force,
Expand All @@ -1573,7 +1571,7 @@ async fn run(mut cli: Cli) -> Result<ExitStatus> {
// Resolve the settings from the command-line arguments and workspace configuration.
let args = settings::PythonUpgradeSettings::resolve(args, filesystem, environment);
show_settings!(args);
let upgrade = true;
let upgrade = commands::PythonUpgrade::Enabled(commands::PythonUpgradeSource::Upgrade);

commands::python_install(
&project_dir,
Expand Down
8 changes: 8 additions & 0 deletions crates/uv/src/settings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ use std::process;
use std::str::FromStr;
use std::time::Duration;

use crate::commands::{PythonUpgrade, PythonUpgradeSource};
use uv_auth::Service;
use uv_cache::{CacheArgs, Refresh};
use uv_cli::comma::CommaSeparatedRequirements;
Expand Down Expand Up @@ -1061,6 +1062,7 @@ pub(crate) struct PythonInstallSettings {
pub(crate) targets: Vec<String>,
pub(crate) reinstall: bool,
pub(crate) force: bool,
pub(crate) upgrade: PythonUpgrade,
pub(crate) bin: Option<bool>,
pub(crate) registry: Option<bool>,
pub(crate) python_install_mirror: Option<String>,
Expand Down Expand Up @@ -1101,6 +1103,7 @@ impl PythonInstallSettings {
registry,
no_registry,
force,
upgrade,
mirror: _,
pypy_mirror: _,
python_downloads_json_url: _,
Expand All @@ -1112,6 +1115,11 @@ impl PythonInstallSettings {
targets,
reinstall,
force,
upgrade: if upgrade {
PythonUpgrade::Enabled(PythonUpgradeSource::Install)
} else {
PythonUpgrade::Disabled
},
bin: flag(bin, no_bin, "bin").or(environment.python_install_bin),
registry: flag(registry, no_registry, "registry")
.or(environment.python_install_registry),
Expand Down
14 changes: 14 additions & 0 deletions crates/uv/tests/it/help.rs
Original file line number Diff line number Diff line change
Expand Up @@ -557,6 +557,18 @@ fn help_subsubcommand() {

Implies `--reinstall`.

-U, --upgrade
Upgrade existing Python installations to the latest patch version.

By default, uv will not upgrade already-installed Python versions to newer patch releases.
With `--upgrade`, uv will upgrade to the latest available patch version for the specified
minor version(s).

If the requested versions are not yet installed, uv will install them.

This option is only supported for minor version requests, e.g., `3.12`; uv will exit with
an error if a patch version, e.g., `3.12.2`, is requested.

--default
Use as the default Python version.

Expand Down Expand Up @@ -819,6 +831,8 @@ fn help_flag_subsubcommand() {
Reinstall the requested Python version, if it's already installed
-f, --force
Replace existing Python executables during installation
-U, --upgrade
Upgrade existing Python installations to the latest patch version
--default
Use as the default Python version

Expand Down
Loading
Loading