This commit is contained in:
InSync 2025-07-07 00:32:28 +00:00 committed by GitHub
commit c85c31d735
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 277 additions and 138 deletions

View file

@ -58,6 +58,15 @@ pub enum ListFormat {
Json, 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> { fn extra_name_with_clap_error(arg: &str) -> Result<ExtraName> {
ExtraName::from_str(arg).map_err(|_err| { ExtraName::from_str(arg).map_err(|_err| {
anyhow!( anyhow!(
@ -651,6 +660,10 @@ pub struct SelfUpdateArgs {
/// Run without performing the update. /// Run without performing the update.
#[arg(long)] #[arg(long)]
pub dry_run: bool, 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)] #[derive(Args)]

View file

@ -1,10 +1,12 @@
use std::fmt::Write; use std::fmt::{Display, Write};
use anyhow::Result; use anyhow::Result;
use axoupdater::{AxoUpdater, AxoupdateError, UpdateRequest}; use axoupdater::{AxoUpdater, AxoupdateError, UpdateRequest, Version};
use owo_colors::OwoColorize; use owo_colors::OwoColorize;
use serde::{Serialize, Serializer};
use tracing::debug; use tracing::debug;
use uv_cli::SelfUpdateFormat;
use uv_client::WrappedReqwestError; use uv_client::WrappedReqwestError;
use uv_fs::Simplified; use uv_fs::Simplified;
@ -12,27 +14,204 @@ use crate::commands::ExitStatus;
use crate::printer::Printer; use crate::printer::Printer;
use crate::settings::NetworkSettings; 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<Version> for VersionWrapper {
fn from(value: Version) -> VersionWrapper {
VersionWrapper(value)
}
}
impl Serialize for VersionWrapper {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
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<VersionWrapper>,
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. /// Attempt to update the uv binary.
pub(crate) async fn self_update( pub(crate) async fn self_update(
version: Option<String>, version: Option<String>,
token: Option<String>, token: Option<String>,
dry_run: bool, dry_run: bool,
output_format: SelfUpdateFormat,
printer: Printer, printer: Printer,
network_settings: NetworkSettings, network_settings: NetworkSettings,
) -> Result<ExitStatus> { ) -> Result<ExitStatus> {
if network_settings.connectivity.is_offline() { let output = self_update_impl(
writeln!( version,
printer.stderr(), token,
"{}", dry_run,
format_args!( output_format,
concat!( printer,
"{}{} Self-update is not possible because network connectivity is disabled (i.e., with `--offline`)" network_settings,
), )
"error".red().bold(), .await?;
":".bold() 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<String>,
token: Option<String>,
dry_run: bool,
output_format: SelfUpdateFormat,
printer: Printer,
network_settings: NetworkSettings,
) -> Result<SelfUpdateOutput> {
if network_settings.connectivity.is_offline() {
return Ok(SelfUpdateOutput::Offline);
} }
let mut updater = AxoUpdater::new_for("uv"); 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. // uv was likely installed via a package manager.
let Ok(updater) = updater.load_receipt() else { let Ok(updater) = updater.load_receipt() else {
debug!("No receipt found; assuming uv was installed via a package manager"); debug!("No receipt found; assuming uv was installed via a package manager");
writeln!( return Ok(SelfUpdateOutput::ExternallyInstalled);
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);
}; };
// If we know what our version is, ignore whatever the receipt thinks it is! // 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 current_exe = std::env::current_exe()?;
let receipt_prefix = updater.install_prefix_root()?; 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!( writeln!(
printer.stderr(), printer.stderr(),
"{}", "{}",
format_args!( format_args!(
concat!( "{}{} Checking for updates...",
"{}{} Self-update is only available for uv binaries installed via the standalone installation scripts.", "info".cyan().bold(),
"\n", ":".bold()
"\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()
) )
)?; )?;
return Ok(ExitStatus::Error);
} }
writeln!(
printer.stderr(),
"{}",
format_args!(
"{}{} Checking for updates...",
"info".cyan().bold(),
":".bold()
)
)?;
let update_request = if let Some(version) = version { let update_request = if let Some(version) = version {
UpdateRequest::SpecificTag(version) UpdateRequest::SpecificTag(version)
} else { } else {
@ -118,108 +272,49 @@ pub(crate) async fn self_update(
if dry_run { if dry_run {
// TODO(charlie): `updater.fetch_release` isn't public, so we can't say what the latest // TODO(charlie): `updater.fetch_release` isn't public, so we can't say what the latest
// version is. // version is.
if updater.is_update_needed().await? { return if updater.is_update_needed().await? {
let version = match update_request { let version = match update_request {
UpdateRequest::Latest | UpdateRequest::LatestMaybePrerelease => { UpdateRequest::Latest | UpdateRequest::LatestMaybePrerelease => {
"the latest version".to_string() "latest".to_string()
} }
UpdateRequest::SpecificTag(version) | UpdateRequest::SpecificVersion(version) => { UpdateRequest::SpecificTag(version) | UpdateRequest::SpecificVersion(version) => {
format!("v{version}") version
} }
}; };
writeln!(
printer.stderr(), Ok(SelfUpdateOutput::WouldUpdate {
"Would update uv from {} to {}", from: env!("CARGO_PKG_VERSION").to_string(),
format!("v{}", env!("CARGO_PKG_VERSION")).bold().white(), to: version,
version.bold().white(), })
)?;
} else { } else {
writeln!( Ok(SelfUpdateOutput::OnLatest {
printer.stderr(), version: env!("CARGO_PKG_VERSION").to_string(),
"{}", dry_run: true,
format_args!( })
"You're on the latest version of uv ({})", };
format!("v{}", env!("CARGO_PKG_VERSION")).bold().white()
)
)?;
}
return Ok(ExitStatus::Success);
} }
// Run the updater. This involves a network request, since we need to determine the latest // Run the updater. This involves a network request, since we need to determine the latest
// available version of uv. // available version of uv.
match updater.run().await { match updater.run().await {
Ok(Some(result)) => { Ok(Some(result)) => Ok(SelfUpdateOutput::Updated {
let direction = if result from: result.old_version.map(VersionWrapper),
.old_version to: result.new_version.into(),
.as_ref() tag: result.new_version_tag,
.is_some_and(|old_version| *old_version > result.new_version) }),
{ Ok(None) => Ok(SelfUpdateOutput::OnLatest {
"Downgraded" version: env!("CARGO_PKG_VERSION").to_string(),
} else { dry_run: false,
"Upgraded" }),
}; Err(err) => match err {
AxoupdateError::Reqwest(err) => {
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 {
if err.status() == Some(http::StatusCode::FORBIDDEN) && token.is_none() { if err.status() == Some(http::StatusCode::FORBIDDEN) && token.is_none() {
writeln!( Ok(SelfUpdateOutput::GitHubRateLimitExceeded)
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)
} else { } else {
Err(WrappedReqwestError::from(err).into()) Err(WrappedReqwestError::from(err).into())
} }
} else { }
Err(err.into()) _ => Err(err.into()),
}; },
}
} }
Ok(ExitStatus::Success)
} }

View file

@ -1076,12 +1076,14 @@ async fn run(mut cli: Cli) -> Result<ExitStatus> {
target_version, target_version,
token, token,
dry_run, dry_run,
output_format,
}), }),
}) => { }) => {
commands::self_update( commands::self_update(
target_version, target_version,
token, token,
dry_run, dry_run,
output_format,
printer, printer,
globals.network_settings, globals.network_settings,
) )

View file

@ -400,6 +400,15 @@ impl TestContext {
self 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":"<version>""#.to_string(),
));
self
}
/// Clear filters on `TestContext`. /// Clear filters on `TestContext`.
pub fn clear_filters(mut self) -> Self { pub fn clear_filters(mut self) -> Self {
self.filters.clear(); self.filters.clear();

View file

@ -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`) 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 -----
"#);
}

View file

@ -5226,7 +5226,12 @@ uv self update [OPTIONS] [TARGET_VERSION]
<p>May also be set with the <code>UV_NO_PROGRESS</code> environment variable.</p></dd><dt id="uv-self-update--no-python-downloads"><a href="#uv-self-update--no-python-downloads"><code>--no-python-downloads</code></a></dt><dd><p>Disable automatic downloads of Python.</p> <p>May also be set with the <code>UV_NO_PROGRESS</code> environment variable.</p></dd><dt id="uv-self-update--no-python-downloads"><a href="#uv-self-update--no-python-downloads"><code>--no-python-downloads</code></a></dt><dd><p>Disable automatic downloads of Python.</p>
</dd><dt id="uv-self-update--offline"><a href="#uv-self-update--offline"><code>--offline</code></a></dt><dd><p>Disable network access.</p> </dd><dt id="uv-self-update--offline"><a href="#uv-self-update--offline"><code>--offline</code></a></dt><dd><p>Disable network access.</p>
<p>When disabled, uv will only use locally cached data and locally available files.</p> <p>When disabled, uv will only use locally cached data and locally available files.</p>
<p>May also be set with the <code>UV_OFFLINE</code> environment variable.</p></dd><dt id="uv-self-update--project"><a href="#uv-self-update--project"><code>--project</code></a> <i>project</i></dt><dd><p>Run the command within the given project directory.</p> <p>May also be set with the <code>UV_OFFLINE</code> environment variable.</p></dd><dt id="uv-self-update--output-format"><a href="#uv-self-update--output-format"><code>--output-format</code></a> <i>output-format</i></dt><dd><p>The format in which the result would be displayed</p>
<p>[default: text]</p><p>Possible values:</p>
<ul>
<li><code>text</code>: Output plain text messages</li>
<li><code>json</code>: Output result as JSON</li>
</ul></dd><dt id="uv-self-update--project"><a href="#uv-self-update--project"><code>--project</code></a> <i>project</i></dt><dd><p>Run the command within the given project directory.</p>
<p>All <code>pyproject.toml</code>, <code>uv.toml</code>, and <code>.python-version</code> files will be discovered by walking up the directory tree from the project root, as will the project's virtual environment (<code>.venv</code>).</p> <p>All <code>pyproject.toml</code>, <code>uv.toml</code>, and <code>.python-version</code> files will be discovered by walking up the directory tree from the project root, as will the project's virtual environment (<code>.venv</code>).</p>
<p>Other command-line arguments (such as relative paths) will be resolved relative to the current working directory.</p> <p>Other command-line arguments (such as relative paths) will be resolved relative to the current working directory.</p>
<p>See <code>--directory</code> to change the working directory entirely.</p> <p>See <code>--directory</code> to change the working directory entirely.</p>