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,
}
#[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> {
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)]

View file

@ -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<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.
pub(crate) async fn self_update(
version: Option<String>,
token: Option<String>,
dry_run: bool,
output_format: SelfUpdateFormat,
printer: Printer,
network_settings: NetworkSettings,
) -> Result<ExitStatus> {
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<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");
@ -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)
}

View file

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

View file

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