Add uv publish --dry-run (#15638)

## Summary

`uv publish --dry-run` will perform the `--check-url` validation, and
hit the `/validate` endpoint if the registry is known to support
fast-path validation (like pyx). The `/validate` endpoint lets us
validate an upload without uploading the file _contents_, which lets you
skip the expensive step for common mistakes.

In the future, my hope is that the `/validate` step will deprecated in
favor of Upload API 2.0.
This commit is contained in:
Charlie Marsh 2025-09-02 21:24:31 -04:00 committed by GitHub
parent b57ad179b6
commit 7606f1ad3c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 187 additions and 25 deletions

View file

@ -6572,6 +6572,13 @@ pub struct PublishArgs {
#[arg(long, hide = true)] #[arg(long, hide = true)]
pub skip_existing: bool, pub skip_existing: bool,
/// Perform a dry run without uploading files.
///
/// When enabled, the command will check for existing files if `--check-url` is provided,
/// and will perform validation against the index if supported, but will not upload any files.
#[arg(long)]
pub dry_run: bool,
} }
/// See [PEP 517](https://peps.python.org/pep-0517/) and /// See [PEP 517](https://peps.python.org/pep-0517/) and

View file

@ -24,7 +24,7 @@ use tracing::{Level, debug, enabled, trace, warn};
use trusted_publishing::TrustedPublishingToken; use trusted_publishing::TrustedPublishingToken;
use url::Url; use url::Url;
use uv_auth::Credentials; use uv_auth::{Credentials, PyxTokenStore};
use uv_cache::{Cache, Refresh}; use uv_cache::{Cache, Refresh};
use uv_client::{ use uv_client::{
BaseClient, MetadataFormat, OwnedArchive, RegistryClientBuilder, RequestBuilder, BaseClient, MetadataFormat, OwnedArchive, RegistryClientBuilder, RequestBuilder,
@ -60,6 +60,8 @@ pub enum PublishError {
PublishPrepare(PathBuf, #[source] Box<PublishPrepareError>), PublishPrepare(PathBuf, #[source] Box<PublishPrepareError>),
#[error("Failed to publish `{}` to {}", _0.user_display(), _1)] #[error("Failed to publish `{}` to {}", _0.user_display(), _1)]
PublishSend(PathBuf, DisplaySafeUrl, #[source] PublishSendError), PublishSend(PathBuf, DisplaySafeUrl, #[source] PublishSendError),
#[error("Unable to publish `{}` to {}", _0.user_display(), _1)]
Validate(PathBuf, DisplaySafeUrl, #[source] PublishSendError),
#[error("Failed to obtain token for trusted publishing")] #[error("Failed to obtain token for trusted publishing")]
TrustedPublishing(#[from] TrustedPublishingError), TrustedPublishing(#[from] TrustedPublishingError),
#[error("{0} are not allowed when using trusted publishing")] #[error("{0} are not allowed when using trusted publishing")]
@ -383,6 +385,7 @@ pub async fn check_trusted_publishing(
/// Implements a custom retry flow since the request isn't cloneable. /// Implements a custom retry flow since the request isn't cloneable.
pub async fn upload( pub async fn upload(
file: &Path, file: &Path,
form_metadata: &FormMetadata,
raw_filename: &str, raw_filename: &str,
filename: &DistFilename, filename: &DistFilename,
registry: &DisplaySafeUrl, registry: &DisplaySafeUrl,
@ -392,23 +395,19 @@ pub async fn upload(
download_concurrency: &Semaphore, download_concurrency: &Semaphore,
reporter: Arc<impl Reporter>, reporter: Arc<impl Reporter>,
) -> Result<bool, PublishError> { ) -> Result<bool, PublishError> {
let form_metadata = FormMetadata::read_from_file(file, filename)
.await
.map_err(|err| PublishError::PublishPrepare(file.to_path_buf(), Box::new(err)))?;
let mut n_past_retries = 0; let mut n_past_retries = 0;
let start_time = SystemTime::now(); let start_time = SystemTime::now();
// N.B. We cannot use the client policy here because it is set to zero retries // N.B. We cannot use the client policy here because it is set to zero retries.
let retry_policy = ExponentialBackoff::builder().build_with_max_retries(retries_from_env()?); let retry_policy = ExponentialBackoff::builder().build_with_max_retries(retries_from_env()?);
loop { loop {
let (request, idx) = build_request( let (request, idx) = build_upload_request(
file, file,
raw_filename, raw_filename,
filename, filename,
registry, registry,
client, client,
credentials, credentials,
&form_metadata, form_metadata,
reporter.clone(), reporter.clone(),
) )
.await .await
@ -467,6 +466,51 @@ pub async fn upload(
} }
} }
/// Validate a file against a registry.
pub async fn validate(
file: &Path,
form_metadata: &FormMetadata,
raw_filename: &str,
registry: &DisplaySafeUrl,
store: &PyxTokenStore,
client: &BaseClient,
credentials: &Credentials,
) -> Result<(), PublishError> {
if store.is_known_url(registry) {
debug!("Performing validation request for {registry}");
let mut validation_url = registry.clone();
validation_url
.path_segments_mut()
.expect("URL must have path segments")
.push("validate");
let request = build_validation_request(
raw_filename,
&validation_url,
client,
credentials,
form_metadata,
);
let response = request.send().await.map_err(|err| {
PublishError::Validate(
file.to_path_buf(),
registry.clone(),
PublishSendError::ReqwestMiddleware(err),
)
})?;
handle_response(&validation_url, response)
.await
.map_err(|err| PublishError::Validate(file.to_path_buf(), registry.clone(), err))?;
} else {
debug!("Skipping validation request for unsupported publish URL: {registry}");
}
Ok(())
}
/// Check whether we should skip the upload of a file because it already exists on the index. /// Check whether we should skip the upload of a file because it already exists on the index.
pub async fn check_url( pub async fn check_url(
check_url_client: &CheckUrlClient<'_>, check_url_client: &CheckUrlClient<'_>,
@ -647,13 +691,13 @@ async fn metadata(file: &Path, filename: &DistFilename) -> Result<Metadata23, Pu
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
struct FormMetadata(Vec<(&'static str, String)>); pub struct FormMetadata(Vec<(&'static str, String)>);
impl FormMetadata { impl FormMetadata {
/// Collect the non-file fields for the multipart request from the package METADATA. /// Collect the non-file fields for the multipart request from the package METADATA.
/// ///
/// Reference implementation: <https://github.com/pypi/warehouse/blob/d2c36d992cf9168e0518201d998b2707a3ef1e72/warehouse/forklift/legacy.py#L1376-L1430> /// Reference implementation: <https://github.com/pypi/warehouse/blob/d2c36d992cf9168e0518201d998b2707a3ef1e72/warehouse/forklift/legacy.py#L1376-L1430>
async fn read_from_file( pub async fn read_from_file(
file: &Path, file: &Path,
filename: &DistFilename, filename: &DistFilename,
) -> Result<Self, PublishPrepareError> { ) -> Result<Self, PublishPrepareError> {
@ -770,8 +814,8 @@ impl<'a> IntoIterator for &'a FormMetadata {
/// Build the upload request. /// Build the upload request.
/// ///
/// Returns the request and the reporter progress bar id. /// Returns the [`RequestBuilder`] and the reporter progress bar ID.
async fn build_request<'a>( async fn build_upload_request<'a>(
file: &Path, file: &Path,
raw_filename: &str, raw_filename: &str,
filename: &DistFilename, filename: &DistFilename,
@ -840,6 +884,63 @@ async fn build_request<'a>(
Ok((request, idx)) Ok((request, idx))
} }
/// Build the validation request, to validate the upload without actually uploading the file.
///
/// Returns the [`RequestBuilder`].
fn build_validation_request<'a>(
raw_filename: &str,
registry: &DisplaySafeUrl,
client: &'a BaseClient,
credentials: &Credentials,
form_metadata: &FormMetadata,
) -> RequestBuilder<'a> {
let mut form = reqwest::multipart::Form::new();
for (key, value) in form_metadata.iter() {
form = form.text(*key, value.clone());
}
form = form.text("filename", raw_filename.to_owned());
// If we have a username but no password, attach the username to the URL so the authentication
// middleware can find the matching password.
let url = if let Some(username) = credentials
.username()
.filter(|_| credentials.password().is_none())
{
let mut url = registry.clone();
let _ = url.set_username(username);
url
} else {
registry.clone()
};
let mut request = client
.for_host(&url)
.post(Url::from(url))
.multipart(form)
// Ask PyPI for a structured error messages instead of HTML-markup error messages.
// For other registries, we ask them to return plain text over HTML. See
// [`PublishSendError::extract_remote_error`].
.header(
reqwest::header::ACCEPT,
"application/json;q=0.9, text/plain;q=0.8, text/html;q=0.7",
);
match credentials {
Credentials::Basic { password, .. } => {
if password.is_some() {
debug!("Using HTTP Basic authentication");
request = request.header(AUTHORIZATION, credentials.to_header_value());
}
}
Credentials::Bearer { .. } => {
debug!("Using Bearer token authentication");
request = request.header(AUTHORIZATION, credentials.to_header_value());
}
}
request
}
/// Log response information and map response to an error variant if not successful. /// Log response information and map response to an error variant if not successful.
async fn handle_response(registry: &Url, response: Response) -> Result<(), PublishSendError> { async fn handle_response(registry: &Url, response: Response) -> Result<(), PublishSendError> {
let status_code = response.status(); let status_code = response.status();
@ -919,7 +1020,7 @@ mod tests {
use uv_distribution_filename::DistFilename; use uv_distribution_filename::DistFilename;
use uv_redacted::DisplaySafeUrl; use uv_redacted::DisplaySafeUrl;
use crate::{FormMetadata, Reporter, build_request}; use crate::{FormMetadata, Reporter, build_upload_request};
struct DummyReporter; struct DummyReporter;
@ -998,7 +1099,7 @@ mod tests {
"###); "###);
let client = BaseClientBuilder::default().build(); let client = BaseClientBuilder::default().build();
let (request, _) = build_request( let (request, _) = build_upload_request(
&file, &file,
raw_filename, raw_filename,
&filename, &filename,
@ -1150,7 +1251,7 @@ mod tests {
"###); "###);
let client = BaseClientBuilder::default().build(); let client = BaseClientBuilder::default().build();
let (request, _) = build_request( let (request, _) = build_upload_request(
&file, &file,
raw_filename, raw_filename,
&filename, &filename,

View file

@ -7,13 +7,14 @@ use console::Term;
use owo_colors::{AnsiColors, OwoColorize}; use owo_colors::{AnsiColors, OwoColorize};
use tokio::sync::Semaphore; use tokio::sync::Semaphore;
use tracing::{debug, info, trace}; use tracing::{debug, info, trace};
use uv_auth::Credentials; use uv_auth::{Credentials, PyxTokenStore};
use uv_cache::Cache; use uv_cache::Cache;
use uv_client::{AuthIntegration, BaseClient, BaseClientBuilder, RegistryClientBuilder}; use uv_client::{AuthIntegration, BaseClient, BaseClientBuilder, RegistryClientBuilder};
use uv_configuration::{KeyringProviderType, TrustedPublishing}; use uv_configuration::{KeyringProviderType, TrustedPublishing};
use uv_distribution_types::{IndexCapabilities, IndexLocations, IndexUrl}; use uv_distribution_types::{IndexCapabilities, IndexLocations, IndexUrl};
use uv_publish::{ use uv_publish::{
CheckUrlClient, TrustedPublishResult, check_trusted_publishing, files_for_publishing, upload, CheckUrlClient, FormMetadata, PublishError, TrustedPublishResult, check_trusted_publishing,
files_for_publishing, upload,
}; };
use uv_redacted::DisplaySafeUrl; use uv_redacted::DisplaySafeUrl;
use uv_warnings::{warn_user_once, write_error_chain}; use uv_warnings::{warn_user_once, write_error_chain};
@ -32,6 +33,7 @@ pub(crate) async fn publish(
password: Option<String>, password: Option<String>,
check_url: Option<IndexUrl>, check_url: Option<IndexUrl>,
index_locations: IndexLocations, index_locations: IndexLocations,
dry_run: bool,
cache: &Cache, cache: &Cache,
printer: Printer, printer: Printer,
) -> Result<ExitStatus> { ) -> Result<ExitStatus> {
@ -42,8 +44,20 @@ pub(crate) async fn publish(
let files = files_for_publishing(paths)?; let files = files_for_publishing(paths)?;
match files.len() { match files.len() {
0 => bail!("No files found to publish"), 0 => bail!("No files found to publish"),
1 => writeln!(printer.stderr(), "Publishing 1 file to {publish_url}")?, 1 => {
n => writeln!(printer.stderr(), "Publishing {n} files {publish_url}")?, if dry_run {
writeln!(printer.stderr(), "Checking 1 file against {publish_url}")?;
} else {
writeln!(printer.stderr(), "Publishing 1 file to {publish_url}")?;
}
}
n => {
if dry_run {
writeln!(printer.stderr(), "Checking {n} files against {publish_url}")?;
} else {
writeln!(printer.stderr(), "Publishing {n} files to {publish_url}")?;
}
}
} }
// * For the uploads themselves, we roll our own retries due to // * For the uploads themselves, we roll our own retries due to
@ -115,15 +129,48 @@ pub(crate) async fn publish(
let size = fs_err::metadata(&file)?.len(); let size = fs_err::metadata(&file)?.len();
let (bytes, unit) = human_readable_bytes(size); let (bytes, unit) = human_readable_bytes(size);
writeln!( if dry_run {
printer.stderr(), writeln!(
"{} {filename} {}", printer.stderr(),
"Uploading".bold().green(), "{} {filename} {}",
format!("({bytes:.1}{unit})").dimmed() "Checking".bold().cyan(),
)?; format!("({bytes:.1}{unit})").dimmed()
)?;
} else {
writeln!(
printer.stderr(),
"{} {filename} {}",
"Uploading".bold().green(),
format!("({bytes:.1}{unit})").dimmed()
)?;
}
// Collect the metadata for the file.
let form_metadata = FormMetadata::read_from_file(&file, &filename)
.await
.map_err(|err| PublishError::PublishPrepare(file.clone(), Box::new(err)))?;
// Run validation checks on the file, but don't upload it (if possible).
let store = PyxTokenStore::from_settings()?;
uv_publish::validate(
&file,
&form_metadata,
&raw_filename,
&publish_url,
&store,
&upload_client,
&credentials,
)
.await?;
if dry_run {
continue;
}
let reporter = PublishReporter::single(printer); let reporter = PublishReporter::single(printer);
let uploaded = upload( let uploaded = upload(
&file, &file,
&form_metadata,
&raw_filename, &raw_filename,
&filename, &filename,
&publish_url, &publish_url,
@ -136,6 +183,7 @@ pub(crate) async fn publish(
) )
.await?; // Filename and/or URL are already attached, if applicable. .await?; // Filename and/or URL are already attached, if applicable.
info!("Upload succeeded"); info!("Upload succeeded");
if !uploaded { if !uploaded {
writeln!( writeln!(
printer.stderr(), printer.stderr(),

View file

@ -1645,6 +1645,7 @@ async fn run(mut cli: Cli) -> Result<ExitStatus> {
files, files,
username, username,
password, password,
dry_run,
publish_url, publish_url,
trusted_publishing, trusted_publishing,
keyring_provider, keyring_provider,
@ -1699,6 +1700,7 @@ async fn run(mut cli: Cli) -> Result<ExitStatus> {
password, password,
check_url, check_url,
index_locations, index_locations,
dry_run,
&cache, &cache,
printer, printer,
) )

View file

@ -3414,6 +3414,7 @@ pub(crate) struct PublishSettings {
pub(crate) username: Option<String>, pub(crate) username: Option<String>,
pub(crate) password: Option<String>, pub(crate) password: Option<String>,
pub(crate) index: Option<String>, pub(crate) index: Option<String>,
pub(crate) dry_run: bool,
// Both CLI and configuration. // Both CLI and configuration.
pub(crate) publish_url: DisplaySafeUrl, pub(crate) publish_url: DisplaySafeUrl,
@ -3458,6 +3459,7 @@ impl PublishSettings {
files: args.files, files: args.files,
username, username,
password, password,
dry_run: args.dry_run,
publish_url: args publish_url: args
.publish_url .publish_url
.combine(publish_url) .combine(publish_url)

View file

@ -5743,6 +5743,8 @@ uv publish [OPTIONS] [FILES]...
<p>May also be set with the <code>UV_CONFIG_FILE</code> environment variable.</p></dd><dt id="uv-publish--directory"><a href="#uv-publish--directory"><code>--directory</code></a> <i>directory</i></dt><dd><p>Change to the given directory prior to running the command.</p> <p>May also be set with the <code>UV_CONFIG_FILE</code> environment variable.</p></dd><dt id="uv-publish--directory"><a href="#uv-publish--directory"><code>--directory</code></a> <i>directory</i></dt><dd><p>Change to the given directory prior to running the command.</p>
<p>Relative paths are resolved with the given directory as the base.</p> <p>Relative paths are resolved with the given directory as the base.</p>
<p>See <code>--project</code> to only change the project root directory.</p> <p>See <code>--project</code> to only change the project root directory.</p>
</dd><dt id="uv-publish--dry-run"><a href="#uv-publish--dry-run"><code>--dry-run</code></a></dt><dd><p>Perform a dry run without uploading files.</p>
<p>When enabled, the command will check for existing files if <code>--check-url</code> is provided, and will perform validation against the index if supported, but will not upload any files.</p>
</dd><dt id="uv-publish--help"><a href="#uv-publish--help"><code>--help</code></a>, <code>-h</code></dt><dd><p>Display the concise help for this command</p> </dd><dt id="uv-publish--help"><a href="#uv-publish--help"><code>--help</code></a>, <code>-h</code></dt><dd><p>Display the concise help for this command</p>
</dd><dt id="uv-publish--index"><a href="#uv-publish--index"><code>--index</code></a> <i>index</i></dt><dd><p>The name of an index in the configuration to use for publishing.</p> </dd><dt id="uv-publish--index"><a href="#uv-publish--index"><code>--index</code></a> <i>index</i></dt><dd><p>The name of an index in the configuration to use for publishing.</p>
<p>The index must have a <code>publish-url</code> setting, for example:</p> <p>The index must have a <code>publish-url</code> setting, for example:</p>