Infer check URL from publish URL when known (#15886)

## Summary

If we know the publish URL-to-check URL mapping, we can just infer it.
This commit is contained in:
Charlie Marsh 2025-09-16 10:03:03 -04:00 committed by GitHub
parent ac52201626
commit 422863ffde
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 128 additions and 44 deletions

View File

@ -6677,14 +6677,15 @@ pub struct PublishArgs {
/// Check an index URL for existing files to skip duplicate uploads.
///
/// This option allows retrying publishing that failed after only some, but not all files have
/// been uploaded, and handles error due to parallel uploads of the same file.
/// been uploaded, and handles errors due to parallel uploads of the same file.
///
/// Before uploading, the index is checked. If the exact same file already exists in the index,
/// the file will not be uploaded. If an error occurred during the upload, the index is checked
/// again, to handle cases where the identical file was uploaded twice in parallel.
///
/// The exact behavior will vary based on the index. When uploading to PyPI, uploading the same
/// file succeeds even without `--check-url`, while most other indexes error.
/// file succeeds even without `--check-url`, while most other indexes error. When uploading to
/// pyx, the index URL can be inferred automatically from the publish URL.
///
/// The index must provide one of the supported hashes (SHA-256, SHA-384, or SHA-512).
#[arg(long, env = EnvVars::UV_PUBLISH_CHECK_URL)]

View File

@ -12,6 +12,7 @@ use uv_cache::Cache;
use uv_client::{AuthIntegration, BaseClient, BaseClientBuilder, RegistryClientBuilder};
use uv_configuration::{KeyringProviderType, TrustedPublishing};
use uv_distribution_types::{IndexCapabilities, IndexLocations, IndexUrl};
use uv_pep508::VerbatimUrl;
use uv_publish::{
CheckUrlClient, FormMetadata, PublishError, TrustedPublishResult, check_trusted_publishing,
files_for_publishing, upload,
@ -32,6 +33,7 @@ pub(crate) async fn publish(
username: Option<String>,
password: Option<String>,
check_url: Option<IndexUrl>,
index: Option<String>,
index_locations: IndexLocations,
dry_run: bool,
cache: &Cache,
@ -41,6 +43,51 @@ pub(crate) async fn publish(
bail!("Unable to publish files in offline mode");
}
let token_store = PyxTokenStore::from_settings()?;
let (publish_url, check_url) = if let Some(index_name) = index {
// If the user provided an index by name, look it up.
debug!("Publishing with index {index_name}");
let index = index_locations
.simple_indexes()
.find(|index| {
index
.name
.as_ref()
.is_some_and(|name| name.as_ref() == index_name)
})
.with_context(|| {
let mut index_names: Vec<String> = index_locations
.simple_indexes()
.filter_map(|index| index.name.as_ref())
.map(ToString::to_string)
.collect();
index_names.sort();
if index_names.is_empty() {
format!("No indexes were found, can't use index: `{index_name}`")
} else {
let index_names = index_names.join("`, `");
format!("Index not found: `{index_name}`. Found indexes: `{index_names}`")
}
})?;
let publish_url = index
.publish_url
.clone()
.with_context(|| format!("Index is missing a publish URL: `{index_name}`"))?;
let check_url = index.url.clone();
(publish_url, Some(check_url))
} else if token_store.is_known_url(&publish_url) {
// If the user is publishing to a known index, construct the check URL from the publish
// URL.
let check_url = check_url.or_else(|| {
infer_check_url(&publish_url)
.inspect(|check_url| debug!("Inferred check URL: {check_url}"))
});
(publish_url, check_url)
} else {
(publish_url, check_url)
};
let files = files_for_publishing(paths)?;
match files.len() {
0 => bail!("No files found to publish"),
@ -88,9 +135,7 @@ pub(crate) async fn publish(
// We're only checking a single URL and one at a time, so 1 permit is sufficient
let download_concurrency = Arc::new(Semaphore::new(1));
// Load credentials from the token store.
let token_store = PyxTokenStore::from_settings()?;
// Load credentials.
let (publish_url, credentials) = gather_credentials(
publish_url,
username,
@ -395,6 +440,50 @@ fn prompt_username_and_password() -> Result<(Option<String>, Option<String>)> {
Ok((Some(username), Some(password)))
}
/// Construct a Simple Index URL from a publish URL, if possible.
///
/// Matches against a publish URL of the form `/v1/upload/{workspace}/{registry}` and returns
/// `/simple/{workspace}/{registry}`.
fn infer_check_url(publish_url: &DisplaySafeUrl) -> Option<IndexUrl> {
let mut segments = publish_url.path_segments()?;
let v1 = segments.next()?;
if v1 != "v1" {
return None;
}
let upload = segments.next()?;
if upload != "upload" {
return None;
}
let workspace = segments.next()?;
if workspace.is_empty() {
return None;
}
let registry = segments.next()?;
if registry.is_empty() {
return None;
}
// Skip any empty segments (trailing slash handling)
for remaining in segments {
if !remaining.is_empty() {
return None;
}
}
// Reconstruct the URL with `/simple/{workspace}/{registry}`.
let mut check_url = publish_url.clone();
{
let mut segments = check_url.path_segments_mut().ok()?;
segments.clear();
segments.push("simple").push(workspace).push(registry);
}
Some(IndexUrl::from(VerbatimUrl::from(check_url)))
}
#[cfg(test)]
mod tests {
use super::*;
@ -507,4 +596,33 @@ mod tests {
@"The password can't be set both in the publish URL and in the CLI"
);
}
#[test]
fn test_infer_check_url() {
let url =
DisplaySafeUrl::from_str("https://example.com/v1/upload/workspace/registry").unwrap();
let check_url = infer_check_url(&url);
assert_eq!(
check_url,
Some(IndexUrl::from_str("https://example.com/simple/workspace/registry").unwrap())
);
let url =
DisplaySafeUrl::from_str("https://example.com/v1/upload/workspace/registry/").unwrap();
let check_url = infer_check_url(&url);
assert_eq!(
check_url,
Some(IndexUrl::from_str("https://example.com/simple/workspace/registry").unwrap())
);
let url =
DisplaySafeUrl::from_str("https://example.com/upload/workspace/registry").unwrap();
let check_url = infer_check_url(&url);
assert_eq!(check_url, None);
let url = DisplaySafeUrl::from_str("https://example.com/upload/workspace/registry/package")
.unwrap();
let check_url = infer_check_url(&url);
assert_eq!(check_url, None);
}
}

View File

@ -10,7 +10,7 @@ use std::str::FromStr;
use std::sync::atomic::Ordering;
use anstream::eprintln;
use anyhow::{Context, Result, bail};
use anyhow::{Result, bail};
use clap::error::{ContextKind, ContextValue};
use clap::{CommandFactory, Parser};
use futures::FutureExt;
@ -1663,42 +1663,6 @@ async fn run(mut cli: Cli) -> Result<ExitStatus> {
index_locations,
} = PublishSettings::resolve(args, filesystem);
let (publish_url, check_url) = if let Some(index_name) = index {
debug!("Publishing with index {index_name}");
let index = index_locations
.simple_indexes()
.find(|index| {
index
.name
.as_ref()
.is_some_and(|name| name.as_ref() == index_name)
})
.with_context(|| {
let mut index_names: Vec<String> = index_locations
.simple_indexes()
.filter_map(|index| index.name.as_ref())
.map(ToString::to_string)
.collect();
index_names.sort();
if index_names.is_empty() {
format!("No indexes were found, can't use index: `{index_name}`")
} else {
let index_names = index_names.join("`, `");
format!(
"Index not found: `{index_name}`. Found indexes: `{index_names}`"
)
}
})?;
let publish_url = index
.publish_url
.clone()
.with_context(|| format!("Index is missing a publish URL: `{index_name}`"))?;
let check_url = index.url.clone();
(publish_url, Some(check_url))
} else {
(publish_url, check_url)
};
commands::publish(
files,
publish_url,
@ -1708,6 +1672,7 @@ async fn run(mut cli: Cli) -> Result<ExitStatus> {
username,
password,
check_url,
index,
index_locations,
dry_run,
&cache,

View File

@ -5810,9 +5810,9 @@ uv publish [OPTIONS] [FILES]...
<p>Defaults to <code>$XDG_CACHE_HOME/uv</code> or <code>$HOME/.cache/uv</code> on macOS and Linux, and <code>%LOCALAPPDATA%\uv\cache</code> on Windows.</p>
<p>To view the location of the cache directory, run <code>uv cache dir</code>.</p>
<p>May also be set with the <code>UV_CACHE_DIR</code> environment variable.</p></dd><dt id="uv-publish--check-url"><a href="#uv-publish--check-url"><code>--check-url</code></a> <i>check-url</i></dt><dd><p>Check an index URL for existing files to skip duplicate uploads.</p>
<p>This option allows retrying publishing that failed after only some, but not all files have been uploaded, and handles error due to parallel uploads of the same file.</p>
<p>This option allows retrying publishing that failed after only some, but not all files have been uploaded, and handles errors due to parallel uploads of the same file.</p>
<p>Before uploading, the index is checked. If the exact same file already exists in the index, the file will not be uploaded. If an error occurred during the upload, the index is checked again, to handle cases where the identical file was uploaded twice in parallel.</p>
<p>The exact behavior will vary based on the index. When uploading to PyPI, uploading the same file succeeds even without <code>--check-url</code>, while most other indexes error.</p>
<p>The exact behavior will vary based on the index. When uploading to PyPI, uploading the same file succeeds even without <code>--check-url</code>, while most other indexes error. When uploading to pyx, the index URL can be inferred automatically from the publish URL.</p>
<p>The index must provide one of the supported hashes (SHA-256, SHA-384, or SHA-512).</p>
<p>May also be set with the <code>UV_PUBLISH_CHECK_URL</code> environment variable.</p></dd><dt id="uv-publish--color"><a href="#uv-publish--color"><code>--color</code></a> <i>color-choice</i></dt><dd><p>Control the use of color in output.</p>
<p>By default, uv will automatically detect support for colors when writing to a terminal.</p>