Infer check URL from publish URL when known (#15886)
Some checks are pending
CI / typos (push) Waiting to run
CI / Determine changes (push) Waiting to run
CI / lint (push) Waiting to run
CI / cargo clippy | ubuntu (push) Blocked by required conditions
CI / cargo clippy | windows (push) Blocked by required conditions
CI / cargo dev generate-all (push) Blocked by required conditions
CI / cargo shear (push) Waiting to run
CI / ecosystem test | pydantic/pydantic-core (push) Blocked by required conditions
CI / cargo test | ubuntu (push) Blocked by required conditions
CI / cargo test | macos (push) Blocked by required conditions
CI / cargo test | windows (push) Blocked by required conditions
CI / check windows trampoline | aarch64 (push) Blocked by required conditions
CI / check windows trampoline | i686 (push) Blocked by required conditions
CI / check windows trampoline | x86_64 (push) Blocked by required conditions
CI / test windows trampoline | aarch64 (push) Blocked by required conditions
CI / test windows trampoline | i686 (push) Blocked by required conditions
CI / test windows trampoline | x86_64 (push) Blocked by required conditions
CI / mkdocs (push) Waiting to run
CI / build binary | linux libc (push) Blocked by required conditions
CI / build binary | linux aarch64 (push) Blocked by required conditions
CI / build binary | linux musl (push) Blocked by required conditions
CI / build binary | freebsd (push) Blocked by required conditions
CI / build binary | macos aarch64 (push) Blocked by required conditions
CI / build binary | macos x86_64 (push) Blocked by required conditions
CI / build binary | windows x86_64 (push) Blocked by required conditions
CI / build binary | windows aarch64 (push) Blocked by required conditions
CI / build binary | msrv (push) Blocked by required conditions
CI / ecosystem test | prefecthq/prefect (push) Blocked by required conditions
CI / ecosystem test | pallets/flask (push) Blocked by required conditions
CI / smoke test | linux aarch64 (push) Blocked by required conditions
CI / check system | alpine (push) Blocked by required conditions
CI / smoke test | macos (push) Blocked by required conditions
CI / smoke test | windows x86_64 (push) Blocked by required conditions
CI / smoke test | windows aarch64 (push) Blocked by required conditions
CI / integration test | activate nushell venv (push) Blocked by required conditions
CI / integration test | conda on ubuntu (push) Blocked by required conditions
CI / integration test | deadsnakes python3.9 on ubuntu (push) Blocked by required conditions
CI / integration test | free-threaded on windows (push) Blocked by required conditions
CI / integration test | aarch64 windows implicit (push) Blocked by required conditions
CI / integration test | aarch64 windows explicit (push) Blocked by required conditions
CI / integration test | pypy on ubuntu (push) Blocked by required conditions
CI / integration test | pypy on windows (push) Blocked by required conditions
CI / integration test | graalpy on ubuntu (push) Blocked by required conditions
CI / integration test | graalpy on windows (push) Blocked by required conditions
CI / integration test | pyodide on ubuntu (push) Blocked by required conditions
CI / check cache | ubuntu (push) Blocked by required conditions
CI / check cache | macos aarch64 (push) Blocked by required conditions
CI / check system | python on debian (push) Blocked by required conditions
CI / check system | python on fedora (push) Blocked by required conditions
CI / check system | python on ubuntu (push) Blocked by required conditions
CI / check system | python on rocky linux 8 (push) Blocked by required conditions
CI / check system | python on rocky linux 9 (push) Blocked by required conditions
CI / check system | graalpy on ubuntu (push) Blocked by required conditions
CI / check system | pypy on ubuntu (push) Blocked by required conditions
CI / check system | pyston (push) Blocked by required conditions
CI / smoke test | linux (push) Blocked by required conditions
CI / integration test | github actions (push) Blocked by required conditions
CI / integration test | free-threaded python on github actions (push) Blocked by required conditions
CI / integration test | pyenv on wsl x86-64 (push) Blocked by required conditions
CI / integration test | determine publish changes (push) Blocked by required conditions
CI / integration test | registries (push) Blocked by required conditions
CI / integration test | uv publish (push) Blocked by required conditions
CI / integration test | uv_build (push) Blocked by required conditions
CI / check system | python on macos aarch64 (push) Blocked by required conditions
CI / check system | conda3.11 on linux x86-64 (push) Blocked by required conditions
CI / check system | conda3.8 on linux x86-64 (push) Blocked by required conditions
CI / check system | homebrew python on macos aarch64 (push) Blocked by required conditions
CI / check system | x86-64 python on macos aarch64 (push) Blocked by required conditions
CI / check system | python on macos x86-64 (push) Blocked by required conditions
CI / check system | python3.10 on windows x86-64 (push) Blocked by required conditions
CI / check system | python3.10 on windows x86 (push) Blocked by required conditions
CI / check system | python3.13 on windows x86-64 (push) Blocked by required conditions
CI / check system | x86-64 python3.13 on windows aarch64 (push) Blocked by required conditions
CI / check system | aarch64 python3.13 on windows aarch64 (push) Blocked by required conditions
CI / check system | windows registry (push) Blocked by required conditions
CI / check system | python3.12 via chocolatey (push) Blocked by required conditions
CI / check system | python3.9 via pyenv (push) Blocked by required conditions
CI / check system | python3.13 (push) Blocked by required conditions
CI / check system | conda3.11 on macos aarch64 (push) Blocked by required conditions
CI / check system | conda3.8 on macos aarch64 (push) Blocked by required conditions
CI / check system | conda3.11 on windows x86-64 (push) Blocked by required conditions
CI / check system | conda3.8 on windows x86-64 (push) Blocked by required conditions
CI / check system | amazonlinux (push) Blocked by required conditions
CI / check system | embedded python3.10 on windows x86-64 (push) Blocked by required conditions
CI / benchmarks | walltime aarch64 linux (push) Blocked by required conditions
CI / benchmarks | instrumented (push) Blocked by required conditions
zizmor / Run zizmor (push) Waiting to run

## 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,