Skip to content

Commit ced61d5

Browse files
committed
Infer check URL from publish URL when known
1 parent 663053b commit ced61d5

File tree

4 files changed

+128
-44
lines changed

4 files changed

+128
-44
lines changed

crates/uv-cli/src/lib.rs

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

crates/uv/src/commands/publish.rs

Lines changed: 121 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ use uv_cache::Cache;
1212
use uv_client::{AuthIntegration, BaseClient, BaseClientBuilder, RegistryClientBuilder};
1313
use uv_configuration::{KeyringProviderType, TrustedPublishing};
1414
use uv_distribution_types::{IndexCapabilities, IndexLocations, IndexUrl};
15+
use uv_pep508::VerbatimUrl;
1516
use uv_publish::{
1617
CheckUrlClient, FormMetadata, PublishError, TrustedPublishResult, check_trusted_publishing,
1718
files_for_publishing, upload,
@@ -32,6 +33,7 @@ pub(crate) async fn publish(
3233
username: Option<String>,
3334
password: Option<String>,
3435
check_url: Option<IndexUrl>,
36+
index: Option<String>,
3537
index_locations: IndexLocations,
3638
dry_run: bool,
3739
cache: &Cache,
@@ -41,6 +43,51 @@ pub(crate) async fn publish(
4143
bail!("Unable to publish files in offline mode");
4244
}
4345

46+
let token_store = PyxTokenStore::from_settings()?;
47+
48+
let (publish_url, check_url) = if let Some(index_name) = index {
49+
// If the user provided an index by name, look it up.
50+
debug!("Publishing with index {index_name}");
51+
let index = index_locations
52+
.simple_indexes()
53+
.find(|index| {
54+
index
55+
.name
56+
.as_ref()
57+
.is_some_and(|name| name.as_ref() == index_name)
58+
})
59+
.with_context(|| {
60+
let mut index_names: Vec<String> = index_locations
61+
.simple_indexes()
62+
.filter_map(|index| index.name.as_ref())
63+
.map(ToString::to_string)
64+
.collect();
65+
index_names.sort();
66+
if index_names.is_empty() {
67+
format!("No indexes were found, can't use index: `{index_name}`")
68+
} else {
69+
let index_names = index_names.join("`, `");
70+
format!("Index not found: `{index_name}`. Found indexes: `{index_names}`")
71+
}
72+
})?;
73+
let publish_url = index
74+
.publish_url
75+
.clone()
76+
.with_context(|| format!("Index is missing a publish URL: `{index_name}`"))?;
77+
let check_url = index.url.clone();
78+
(publish_url, Some(check_url))
79+
} else if token_store.is_known_url(&publish_url) {
80+
// If the user is publishing to a known index, construct the check URL from the publish
81+
// URL.
82+
let check_url = check_url.or_else(|| {
83+
infer_check_url(&publish_url)
84+
.inspect(|check_url| debug!("Inferred check URL: {check_url}"))
85+
});
86+
(publish_url, check_url)
87+
} else {
88+
(publish_url, check_url)
89+
};
90+
4491
let files = files_for_publishing(paths)?;
4592
match files.len() {
4693
0 => bail!("No files found to publish"),
@@ -88,9 +135,7 @@ pub(crate) async fn publish(
88135
// We're only checking a single URL and one at a time, so 1 permit is sufficient
89136
let download_concurrency = Arc::new(Semaphore::new(1));
90137

91-
// Load credentials from the token store.
92-
let token_store = PyxTokenStore::from_settings()?;
93-
138+
// Load credentials.
94139
let (publish_url, credentials) = gather_credentials(
95140
publish_url,
96141
username,
@@ -395,6 +440,50 @@ fn prompt_username_and_password() -> Result<(Option<String>, Option<String>)> {
395440
Ok((Some(username), Some(password)))
396441
}
397442

443+
/// Construct a Simple Index URL from a publish URL, if possible.
444+
///
445+
/// Matches against a publish URL of the form `/v1/upload/{workspace}/{registry}` and returns
446+
/// `/simple/{workspace}/{registry}`.
447+
fn infer_check_url(publish_url: &DisplaySafeUrl) -> Option<IndexUrl> {
448+
let mut segments = publish_url.path_segments()?;
449+
450+
let v1 = segments.next()?;
451+
if v1 != "v1" {
452+
return None;
453+
}
454+
455+
let upload = segments.next()?;
456+
if upload != "upload" {
457+
return None;
458+
}
459+
460+
let workspace = segments.next()?;
461+
if workspace.is_empty() {
462+
return None;
463+
}
464+
465+
let registry = segments.next()?;
466+
if registry.is_empty() {
467+
return None;
468+
}
469+
470+
// Skip any empty segments (trailing slash handling)
471+
for remaining in segments {
472+
if !remaining.is_empty() {
473+
return None;
474+
}
475+
}
476+
477+
// Reconstruct the URL with `/simple/{workspace}/{registry}`.
478+
let mut check_url = publish_url.clone();
479+
{
480+
let mut segments = check_url.path_segments_mut().ok()?;
481+
segments.clear();
482+
segments.push("simple").push(workspace).push(registry);
483+
}
484+
Some(IndexUrl::from(VerbatimUrl::from(check_url)))
485+
}
486+
398487
#[cfg(test)]
399488
mod tests {
400489
use super::*;
@@ -507,4 +596,33 @@ mod tests {
507596
@"The password can't be set both in the publish URL and in the CLI"
508597
);
509598
}
599+
600+
#[test]
601+
fn test_infer_check_url() {
602+
let url =
603+
DisplaySafeUrl::from_str("https://example.com/v1/upload/workspace/registry").unwrap();
604+
let check_url = infer_check_url(&url);
605+
assert_eq!(
606+
check_url,
607+
Some(IndexUrl::from_str("https://example.com/simple/workspace/registry").unwrap())
608+
);
609+
610+
let url =
611+
DisplaySafeUrl::from_str("https://example.com/v1/upload/workspace/registry/").unwrap();
612+
let check_url = infer_check_url(&url);
613+
assert_eq!(
614+
check_url,
615+
Some(IndexUrl::from_str("https://example.com/simple/workspace/registry").unwrap())
616+
);
617+
618+
let url =
619+
DisplaySafeUrl::from_str("https://example.com/upload/workspace/registry").unwrap();
620+
let check_url = infer_check_url(&url);
621+
assert_eq!(check_url, None);
622+
623+
let url = DisplaySafeUrl::from_str("https://example.com/upload/workspace/registry/package")
624+
.unwrap();
625+
let check_url = infer_check_url(&url);
626+
assert_eq!(check_url, None);
627+
}
510628
}

crates/uv/src/lib.rs

Lines changed: 2 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ use std::str::FromStr;
1010
use std::sync::atomic::Ordering;
1111

1212
use anstream::eprintln;
13-
use anyhow::{Context, Result, bail};
13+
use anyhow::{Result, bail};
1414
use clap::error::{ContextKind, ContextValue};
1515
use clap::{CommandFactory, Parser};
1616
use futures::FutureExt;
@@ -1663,42 +1663,6 @@ async fn run(mut cli: Cli) -> Result<ExitStatus> {
16631663
index_locations,
16641664
} = PublishSettings::resolve(args, filesystem);
16651665

1666-
let (publish_url, check_url) = if let Some(index_name) = index {
1667-
debug!("Publishing with index {index_name}");
1668-
let index = index_locations
1669-
.simple_indexes()
1670-
.find(|index| {
1671-
index
1672-
.name
1673-
.as_ref()
1674-
.is_some_and(|name| name.as_ref() == index_name)
1675-
})
1676-
.with_context(|| {
1677-
let mut index_names: Vec<String> = index_locations
1678-
.simple_indexes()
1679-
.filter_map(|index| index.name.as_ref())
1680-
.map(ToString::to_string)
1681-
.collect();
1682-
index_names.sort();
1683-
if index_names.is_empty() {
1684-
format!("No indexes were found, can't use index: `{index_name}`")
1685-
} else {
1686-
let index_names = index_names.join("`, `");
1687-
format!(
1688-
"Index not found: `{index_name}`. Found indexes: `{index_names}`"
1689-
)
1690-
}
1691-
})?;
1692-
let publish_url = index
1693-
.publish_url
1694-
.clone()
1695-
.with_context(|| format!("Index is missing a publish URL: `{index_name}`"))?;
1696-
let check_url = index.url.clone();
1697-
(publish_url, Some(check_url))
1698-
} else {
1699-
(publish_url, check_url)
1700-
};
1701-
17021666
commands::publish(
17031667
files,
17041668
publish_url,
@@ -1708,6 +1672,7 @@ async fn run(mut cli: Cli) -> Result<ExitStatus> {
17081672
username,
17091673
password,
17101674
check_url,
1675+
index,
17111676
index_locations,
17121677
dry_run,
17131678
&cache,

docs/reference/cli.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5810,9 +5810,9 @@ uv publish [OPTIONS] [FILES]...
58105810
<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>
58115811
<p>To view the location of the cache directory, run <code>uv cache dir</code>.</p>
58125812
<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>
5813-
<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>
5813+
<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>
58145814
<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>
5815-
<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>
5815+
<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>
58165816
<p>The index must provide one of the supported hashes (SHA-256, SHA-384, or SHA-512).</p>
58175817
<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>
58185818
<p>By default, uv will automatically detect support for colors when writing to a terminal.</p>

0 commit comments

Comments
 (0)