diff --git a/crates/uv-cli/src/lib.rs b/crates/uv-cli/src/lib.rs index bf605198f..3845c6d6a 100644 --- a/crates/uv-cli/src/lib.rs +++ b/crates/uv-cli/src/lib.rs @@ -58,6 +58,15 @@ pub enum ListFormat { Json, } +#[derive(Debug, Default, Clone, Copy, clap::ValueEnum)] +pub enum SelfUpdateFormat { + /// Output plain text messages. + #[default] + Text, + /// Output result as JSON. + Json, +} + fn extra_name_with_clap_error(arg: &str) -> Result { ExtraName::from_str(arg).map_err(|_err| { anyhow!( @@ -651,6 +660,10 @@ pub struct SelfUpdateArgs { /// Run without performing the update. #[arg(long)] pub dry_run: bool, + + /// The format in which the result would be displayed. + #[arg(long, value_enum, default_value_t = SelfUpdateFormat::default())] + pub output_format: SelfUpdateFormat, } #[derive(Args)] diff --git a/crates/uv/src/commands/self_update.rs b/crates/uv/src/commands/self_update.rs index 4b4fd4830..e5517f695 100644 --- a/crates/uv/src/commands/self_update.rs +++ b/crates/uv/src/commands/self_update.rs @@ -1,10 +1,12 @@ -use std::fmt::Write; +use std::fmt::{Display, Write}; use anyhow::Result; -use axoupdater::{AxoUpdater, AxoupdateError, UpdateRequest}; +use axoupdater::{AxoUpdater, AxoupdateError, UpdateRequest, Version}; use owo_colors::OwoColorize; +use serde::{Serialize, Serializer}; use tracing::debug; +use uv_cli::SelfUpdateFormat; use uv_client::WrappedReqwestError; use uv_fs::Simplified; @@ -12,27 +14,204 @@ use crate::commands::ExitStatus; use crate::printer::Printer; use crate::settings::NetworkSettings; +#[derive(Clone, Eq, PartialEq, Ord, PartialOrd, Hash)] +struct VersionWrapper(Version); + +impl Display for VersionWrapper { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + +impl From for VersionWrapper { + fn from(value: Version) -> VersionWrapper { + VersionWrapper(value) + } +} + +impl Serialize for VersionWrapper { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + serializer.serialize_str(&self.0.to_string()) + } +} + +#[derive(Serialize)] +#[serde(tag = "result", rename_all = "kebab-case")] +enum SelfUpdateOutput { + Offline, + ExternallyInstalled, + MultipleInstallations { + current: String, + other: String, + }, + #[serde(rename = "github-rate-limit-exceeded")] + GitHubRateLimitExceeded, + OnLatest { + version: String, + #[serde(skip_serializing)] + dry_run: bool, + }, + WouldUpdate { + from: String, + to: String, + }, + Updated { + from: Option, + to: VersionWrapper, + tag: String, + }, +} + +impl SelfUpdateOutput { + fn exit_status(&self) -> ExitStatus { + match self { + Self::Offline => ExitStatus::Failure, + Self::ExternallyInstalled => ExitStatus::Error, + Self::MultipleInstallations { .. } => ExitStatus::Error, + Self::GitHubRateLimitExceeded => ExitStatus::Error, + Self::WouldUpdate { .. } => ExitStatus::Success, + Self::Updated { .. } => ExitStatus::Success, + Self::OnLatest { .. } => ExitStatus::Success, + } + } +} + /// Attempt to update the uv binary. pub(crate) async fn self_update( version: Option, token: Option, dry_run: bool, + output_format: SelfUpdateFormat, printer: Printer, network_settings: NetworkSettings, ) -> Result { - if network_settings.connectivity.is_offline() { - writeln!( - printer.stderr(), - "{}", - format_args!( - concat!( - "{}{} Self-update is not possible because network connectivity is disabled (i.e., with `--offline`)" - ), - "error".red().bold(), - ":".bold() + let output = self_update_impl( + version, + token, + dry_run, + output_format, + printer, + network_settings, + ) + .await?; + let exit_status = output.exit_status(); + + if matches!(output_format, SelfUpdateFormat::Json) { + writeln!(printer.stdout(), "{}", serde_json::to_string(&output)?)?; + return Ok(exit_status); + } + + let message = match output { + SelfUpdateOutput::Offline => format!( + concat!( + "{}{} Self-update is not possible because network connectivity is disabled (i.e., with `--offline`)" + ), + "error".red().bold(), + ":".bold() + ), + SelfUpdateOutput::ExternallyInstalled => format!( + concat!( + "{}{} Self-update is only available for uv binaries installed via the standalone installation scripts.", + "\n", + "\n", + "If you installed uv with pip, brew, or another package manager, update uv with `pip install --upgrade`, `brew upgrade`, or similar." + ), + "error".red().bold(), + ":".bold() + ), + SelfUpdateOutput::MultipleInstallations { current, other } => format!( + concat!( + "{}{} Self-update is only available for uv binaries installed via the standalone installation scripts.", + "\n", + "\n", + "The current executable is at `{}` but the standalone installer was used to install uv to `{}`. Are multiple copies of uv installed?" + ), + "error".red().bold(), + ":".bold(), + current.bold().cyan(), + other.bold().cyan() + ), + SelfUpdateOutput::GitHubRateLimitExceeded => format!( + "{}{} GitHub API rate limit exceeded. Please provide a GitHub token via the {} option.", + "error".red().bold(), + ":".bold(), + "`--token`".green().bold() + ), + + SelfUpdateOutput::OnLatest { version, dry_run } => { + if dry_run { + format!( + "You're on the latest version of uv ({})", + format!("v{version}").bold().white() + ) + } else { + format!( + "{}{} You're on the latest version of uv ({})", + "success".green().bold(), + ":".bold(), + format!("v{version}").bold().cyan() + ) + } + } + + SelfUpdateOutput::WouldUpdate { from, to } => { + let to = if to == "latest" { + "the latest version".to_string() + } else { + format!("v{to}") + }; + + format!( + "Would update uv from {} to {}", + format!("v{from}").bold().white(), + to.bold().white(), ) - )?; - return Ok(ExitStatus::Failure); + } + + SelfUpdateOutput::Updated { from, to, tag } => { + let direction = if from.as_ref().is_some_and(|from| *from > to) { + "Downgraded" + } else { + "Upgraded" + }; + + let version_information = if let Some(from) = from { + format!( + "from {} to {}", + format!("v{from}").bold().cyan(), + format!("v{to}").bold().cyan(), + ) + } else { + format!("to {}", format!("v{to}").bold().cyan()) + }; + + format!( + "{}{} {direction} uv {}! {}", + "success".green().bold(), + ":".bold(), + version_information, + format!("https://github.com/astral-sh/uv/releases/tag/{tag}").cyan() + ) + } + }; + + writeln!(printer.stderr(), "{message}")?; + Ok(exit_status) +} + +async fn self_update_impl( + version: Option, + token: Option, + dry_run: bool, + output_format: SelfUpdateFormat, + printer: Printer, + network_settings: NetworkSettings, +) -> Result { + if network_settings.connectivity.is_offline() { + return Ok(SelfUpdateOutput::Offline); } let mut updater = AxoUpdater::new_for("uv"); @@ -46,21 +225,7 @@ pub(crate) async fn self_update( // uv was likely installed via a package manager. let Ok(updater) = updater.load_receipt() else { debug!("No receipt found; assuming uv was installed via a package manager"); - writeln!( - printer.stderr(), - "{}", - format_args!( - concat!( - "{}{} Self-update is only available for uv binaries installed via the standalone installation scripts.", - "\n", - "\n", - "If you installed uv with pip, brew, or another package manager, update uv with `pip install --upgrade`, `brew upgrade`, or similar." - ), - "error".red().bold(), - ":".bold() - ) - )?; - return Ok(ExitStatus::Error); + return Ok(SelfUpdateOutput::ExternallyInstalled); }; // If we know what our version is, ignore whatever the receipt thinks it is! @@ -78,35 +243,24 @@ pub(crate) async fn self_update( let current_exe = std::env::current_exe()?; let receipt_prefix = updater.install_prefix_root()?; + return Ok(SelfUpdateOutput::MultipleInstallations { + current: current_exe.simplified_display().to_string(), + other: receipt_prefix.simplified_display().to_string(), + }); + } + + if matches!(output_format, SelfUpdateFormat::Text) { writeln!( printer.stderr(), "{}", format_args!( - concat!( - "{}{} Self-update is only available for uv binaries installed via the standalone installation scripts.", - "\n", - "\n", - "The current executable is at `{}` but the standalone installer was used to install uv to `{}`. Are multiple copies of uv installed?" - ), - "error".red().bold(), - ":".bold(), - current_exe.simplified_display().bold().cyan(), - receipt_prefix.simplified_display().bold().cyan() + "{}{} Checking for updates...", + "info".cyan().bold(), + ":".bold() ) )?; - return Ok(ExitStatus::Error); } - writeln!( - printer.stderr(), - "{}", - format_args!( - "{}{} Checking for updates...", - "info".cyan().bold(), - ":".bold() - ) - )?; - let update_request = if let Some(version) = version { UpdateRequest::SpecificTag(version) } else { @@ -118,108 +272,49 @@ pub(crate) async fn self_update( if dry_run { // TODO(charlie): `updater.fetch_release` isn't public, so we can't say what the latest // version is. - if updater.is_update_needed().await? { + return if updater.is_update_needed().await? { let version = match update_request { UpdateRequest::Latest | UpdateRequest::LatestMaybePrerelease => { - "the latest version".to_string() + "latest".to_string() } UpdateRequest::SpecificTag(version) | UpdateRequest::SpecificVersion(version) => { - format!("v{version}") + version } }; - writeln!( - printer.stderr(), - "Would update uv from {} to {}", - format!("v{}", env!("CARGO_PKG_VERSION")).bold().white(), - version.bold().white(), - )?; + + Ok(SelfUpdateOutput::WouldUpdate { + from: env!("CARGO_PKG_VERSION").to_string(), + to: version, + }) } else { - writeln!( - printer.stderr(), - "{}", - format_args!( - "You're on the latest version of uv ({})", - format!("v{}", env!("CARGO_PKG_VERSION")).bold().white() - ) - )?; - } - return Ok(ExitStatus::Success); + Ok(SelfUpdateOutput::OnLatest { + version: env!("CARGO_PKG_VERSION").to_string(), + dry_run: true, + }) + }; } // Run the updater. This involves a network request, since we need to determine the latest // available version of uv. match updater.run().await { - Ok(Some(result)) => { - let direction = if result - .old_version - .as_ref() - .is_some_and(|old_version| *old_version > result.new_version) - { - "Downgraded" - } else { - "Upgraded" - }; - - let version_information = if let Some(old_version) = result.old_version { - format!( - "from {} to {}", - format!("v{old_version}").bold().cyan(), - format!("v{}", result.new_version).bold().cyan(), - ) - } else { - format!("to {}", format!("v{}", result.new_version).bold().cyan()) - }; - - writeln!( - printer.stderr(), - "{}", - format_args!( - "{}{} {direction} uv {}! {}", - "success".green().bold(), - ":".bold(), - version_information, - format!( - "https://github.com/astral-sh/uv/releases/tag/{}", - result.new_version_tag - ) - .cyan() - ) - )?; - } - Ok(None) => { - writeln!( - printer.stderr(), - "{}", - format_args!( - "{}{} You're on the latest version of uv ({})", - "success".green().bold(), - ":".bold(), - format!("v{}", env!("CARGO_PKG_VERSION")).bold().cyan() - ) - )?; - } - Err(err) => { - return if let AxoupdateError::Reqwest(err) = err { + Ok(Some(result)) => Ok(SelfUpdateOutput::Updated { + from: result.old_version.map(VersionWrapper), + to: result.new_version.into(), + tag: result.new_version_tag, + }), + Ok(None) => Ok(SelfUpdateOutput::OnLatest { + version: env!("CARGO_PKG_VERSION").to_string(), + dry_run: false, + }), + Err(err) => match err { + AxoupdateError::Reqwest(err) => { if err.status() == Some(http::StatusCode::FORBIDDEN) && token.is_none() { - writeln!( - printer.stderr(), - "{}", - format_args!( - "{}{} GitHub API rate limit exceeded. Please provide a GitHub token via the {} option.", - "error".red().bold(), - ":".bold(), - "`--token`".green().bold() - ) - )?; - Ok(ExitStatus::Error) + Ok(SelfUpdateOutput::GitHubRateLimitExceeded) } else { Err(WrappedReqwestError::from(err).into()) } - } else { - Err(err.into()) - }; - } + } + _ => Err(err.into()), + }, } - - Ok(ExitStatus::Success) } diff --git a/crates/uv/src/lib.rs b/crates/uv/src/lib.rs index ab4aee9e9..8e02e236e 100644 --- a/crates/uv/src/lib.rs +++ b/crates/uv/src/lib.rs @@ -1076,12 +1076,14 @@ async fn run(mut cli: Cli) -> Result { target_version, token, dry_run, + output_format, }), }) => { commands::self_update( target_version, token, dry_run, + output_format, printer, globals.network_settings, ) diff --git a/crates/uv/tests/it/common/mod.rs b/crates/uv/tests/it/common/mod.rs index 7b13c49b5..dec5f384a 100644 --- a/crates/uv/tests/it/common/mod.rs +++ b/crates/uv/tests/it/common/mod.rs @@ -400,6 +400,15 @@ impl TestContext { self } + /// Add a filter that filters out version fields in `self version`'s JSON output. + pub fn with_filtered_version_fields(mut self) -> Self { + self.filters.push(( + r#""(version|from|to)":"[^"]+""#.to_string(), + r#""$1":"""#.to_string(), + )); + self + } + /// Clear filters on `TestContext`. pub fn clear_filters(mut self) -> Self { self.filters.clear(); diff --git a/crates/uv/tests/it/self_update.rs b/crates/uv/tests/it/self_update.rs index 169c7e48e..3f364a435 100644 --- a/crates/uv/tests/it/self_update.rs +++ b/crates/uv/tests/it/self_update.rs @@ -57,3 +57,18 @@ fn test_self_update_offline_error() { error: Self-update is not possible because network connectivity is disabled (i.e., with `--offline`) "); } + +#[test] +fn test_self_update_offline_json() { + let context = TestContext::new("3.12"); + + uv_snapshot!(context.self_update().arg("--offline").arg("--output-format=json"), + @r#" + success: false + exit_code: 1 + ----- stdout ----- + {"result":"offline"} + + ----- stderr ----- + "#); +} diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 82fe0fa3d..aae252b78 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -5226,7 +5226,12 @@ uv self update [OPTIONS] [TARGET_VERSION]

May also be set with the UV_NO_PROGRESS environment variable.

--no-python-downloads

Disable automatic downloads of Python.

--offline

Disable network access.

When disabled, uv will only use locally cached data and locally available files.

-

May also be set with the UV_OFFLINE environment variable.

--project project

Run the command within the given project directory.

+

May also be set with the UV_OFFLINE environment variable.

--output-format output-format

The format in which the result would be displayed

+

[default: text]

Possible values:

+
    +
  • text: Output plain text messages
  • +
  • json: Output result as JSON
  • +
--project project

Run the command within the given project directory.

All pyproject.toml, uv.toml, and .python-version files will be discovered by walking up the directory tree from the project root, as will the project's virtual environment (.venv).

Other command-line arguments (such as relative paths) will be resolved relative to the current working directory.

See --directory to change the working directory entirely.