From 7606f1ad3c41f8ee8c9e559f96026efa5d581bc7 Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Tue, 2 Sep 2025 21:24:31 -0400 Subject: [PATCH] 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. --- crates/uv-cli/src/lib.rs | 7 ++ crates/uv-publish/src/lib.rs | 131 ++++++++++++++++++++++++++---- crates/uv/src/commands/publish.rs | 68 +++++++++++++--- crates/uv/src/lib.rs | 2 + crates/uv/src/settings.rs | 2 + docs/reference/cli.md | 2 + 6 files changed, 187 insertions(+), 25 deletions(-) diff --git a/crates/uv-cli/src/lib.rs b/crates/uv-cli/src/lib.rs index 5159b611a..84a29cb5c 100644 --- a/crates/uv-cli/src/lib.rs +++ b/crates/uv-cli/src/lib.rs @@ -6572,6 +6572,13 @@ pub struct PublishArgs { #[arg(long, hide = true)] 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 diff --git a/crates/uv-publish/src/lib.rs b/crates/uv-publish/src/lib.rs index 03140502a..2792b0cb5 100644 --- a/crates/uv-publish/src/lib.rs +++ b/crates/uv-publish/src/lib.rs @@ -24,7 +24,7 @@ use tracing::{Level, debug, enabled, trace, warn}; use trusted_publishing::TrustedPublishingToken; use url::Url; -use uv_auth::Credentials; +use uv_auth::{Credentials, PyxTokenStore}; use uv_cache::{Cache, Refresh}; use uv_client::{ BaseClient, MetadataFormat, OwnedArchive, RegistryClientBuilder, RequestBuilder, @@ -60,6 +60,8 @@ pub enum PublishError { PublishPrepare(PathBuf, #[source] Box), #[error("Failed to publish `{}` to {}", _0.user_display(), _1)] 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")] TrustedPublishing(#[from] TrustedPublishingError), #[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. pub async fn upload( file: &Path, + form_metadata: &FormMetadata, raw_filename: &str, filename: &DistFilename, registry: &DisplaySafeUrl, @@ -392,23 +395,19 @@ pub async fn upload( download_concurrency: &Semaphore, reporter: Arc, ) -> Result { - 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 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()?); loop { - let (request, idx) = build_request( + let (request, idx) = build_upload_request( file, raw_filename, filename, registry, client, credentials, - &form_metadata, + form_metadata, reporter.clone(), ) .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. pub async fn check_url( check_url_client: &CheckUrlClient<'_>, @@ -647,13 +691,13 @@ async fn metadata(file: &Path, filename: &DistFilename) -> Result); +pub struct FormMetadata(Vec<(&'static str, String)>); impl FormMetadata { /// Collect the non-file fields for the multipart request from the package METADATA. /// /// Reference implementation: - async fn read_from_file( + pub async fn read_from_file( file: &Path, filename: &DistFilename, ) -> Result { @@ -770,8 +814,8 @@ impl<'a> IntoIterator for &'a FormMetadata { /// Build the upload request. /// -/// Returns the request and the reporter progress bar id. -async fn build_request<'a>( +/// Returns the [`RequestBuilder`] and the reporter progress bar ID. +async fn build_upload_request<'a>( file: &Path, raw_filename: &str, filename: &DistFilename, @@ -840,6 +884,63 @@ async fn build_request<'a>( 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. async fn handle_response(registry: &Url, response: Response) -> Result<(), PublishSendError> { let status_code = response.status(); @@ -919,7 +1020,7 @@ mod tests { use uv_distribution_filename::DistFilename; use uv_redacted::DisplaySafeUrl; - use crate::{FormMetadata, Reporter, build_request}; + use crate::{FormMetadata, Reporter, build_upload_request}; struct DummyReporter; @@ -998,7 +1099,7 @@ mod tests { "###); let client = BaseClientBuilder::default().build(); - let (request, _) = build_request( + let (request, _) = build_upload_request( &file, raw_filename, &filename, @@ -1150,7 +1251,7 @@ mod tests { "###); let client = BaseClientBuilder::default().build(); - let (request, _) = build_request( + let (request, _) = build_upload_request( &file, raw_filename, &filename, diff --git a/crates/uv/src/commands/publish.rs b/crates/uv/src/commands/publish.rs index f9de28408..2ef7863a2 100644 --- a/crates/uv/src/commands/publish.rs +++ b/crates/uv/src/commands/publish.rs @@ -7,13 +7,14 @@ use console::Term; use owo_colors::{AnsiColors, OwoColorize}; use tokio::sync::Semaphore; use tracing::{debug, info, trace}; -use uv_auth::Credentials; +use uv_auth::{Credentials, PyxTokenStore}; 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_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_warnings::{warn_user_once, write_error_chain}; @@ -32,6 +33,7 @@ pub(crate) async fn publish( password: Option, check_url: Option, index_locations: IndexLocations, + dry_run: bool, cache: &Cache, printer: Printer, ) -> Result { @@ -42,8 +44,20 @@ pub(crate) async fn publish( let files = files_for_publishing(paths)?; match files.len() { 0 => bail!("No files found to publish"), - 1 => writeln!(printer.stderr(), "Publishing 1 file to {publish_url}")?, - n => writeln!(printer.stderr(), "Publishing {n} files {publish_url}")?, + 1 => { + 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 @@ -115,15 +129,48 @@ pub(crate) async fn publish( let size = fs_err::metadata(&file)?.len(); let (bytes, unit) = human_readable_bytes(size); - writeln!( - printer.stderr(), - "{} {filename} {}", - "Uploading".bold().green(), - format!("({bytes:.1}{unit})").dimmed() - )?; + if dry_run { + writeln!( + printer.stderr(), + "{} {filename} {}", + "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 uploaded = upload( &file, + &form_metadata, &raw_filename, &filename, &publish_url, @@ -136,6 +183,7 @@ pub(crate) async fn publish( ) .await?; // Filename and/or URL are already attached, if applicable. info!("Upload succeeded"); + if !uploaded { writeln!( printer.stderr(), diff --git a/crates/uv/src/lib.rs b/crates/uv/src/lib.rs index 538f44538..11cbf4699 100644 --- a/crates/uv/src/lib.rs +++ b/crates/uv/src/lib.rs @@ -1645,6 +1645,7 @@ async fn run(mut cli: Cli) -> Result { files, username, password, + dry_run, publish_url, trusted_publishing, keyring_provider, @@ -1699,6 +1700,7 @@ async fn run(mut cli: Cli) -> Result { password, check_url, index_locations, + dry_run, &cache, printer, ) diff --git a/crates/uv/src/settings.rs b/crates/uv/src/settings.rs index 9a742321a..d5a9145ea 100644 --- a/crates/uv/src/settings.rs +++ b/crates/uv/src/settings.rs @@ -3414,6 +3414,7 @@ pub(crate) struct PublishSettings { pub(crate) username: Option, pub(crate) password: Option, pub(crate) index: Option, + pub(crate) dry_run: bool, // Both CLI and configuration. pub(crate) publish_url: DisplaySafeUrl, @@ -3458,6 +3459,7 @@ impl PublishSettings { files: args.files, username, password, + dry_run: args.dry_run, publish_url: args .publish_url .combine(publish_url) diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 8fbbbeb7c..e83d85576 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -5743,6 +5743,8 @@ uv publish [OPTIONS] [FILES]...

May also be set with the UV_CONFIG_FILE environment variable.

--directory directory

Change to the given directory prior to running the command.

Relative paths are resolved with the given directory as the base.

See --project to only change the project root directory.

+
--dry-run

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.

--help, -h

Display the concise help for this command

--index index

The name of an index in the configuration to use for publishing.

The index must have a publish-url setting, for example: