mirror of
https://github.com/astral-sh/uv.git
synced 2025-11-03 05:03:46 +00:00
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:
parent
b57ad179b6
commit
7606f1ad3c
6 changed files with 187 additions and 25 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<PublishPrepareError>),
|
||||
#[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<impl Reporter>,
|
||||
) -> 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 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<Metadata23, Pu
|
|||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct FormMetadata(Vec<(&'static str, String)>);
|
||||
pub struct FormMetadata(Vec<(&'static str, String)>);
|
||||
|
||||
impl FormMetadata {
|
||||
/// 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>
|
||||
async fn read_from_file(
|
||||
pub async fn read_from_file(
|
||||
file: &Path,
|
||||
filename: &DistFilename,
|
||||
) -> Result<Self, PublishPrepareError> {
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<String>,
|
||||
check_url: Option<IndexUrl>,
|
||||
index_locations: IndexLocations,
|
||||
dry_run: bool,
|
||||
cache: &Cache,
|
||||
printer: Printer,
|
||||
) -> Result<ExitStatus> {
|
||||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -1645,6 +1645,7 @@ async fn run(mut cli: Cli) -> Result<ExitStatus> {
|
|||
files,
|
||||
username,
|
||||
password,
|
||||
dry_run,
|
||||
publish_url,
|
||||
trusted_publishing,
|
||||
keyring_provider,
|
||||
|
|
@ -1699,6 +1700,7 @@ async fn run(mut cli: Cli) -> Result<ExitStatus> {
|
|||
password,
|
||||
check_url,
|
||||
index_locations,
|
||||
dry_run,
|
||||
&cache,
|
||||
printer,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -3414,6 +3414,7 @@ pub(crate) struct PublishSettings {
|
|||
pub(crate) username: Option<String>,
|
||||
pub(crate) password: Option<String>,
|
||||
pub(crate) index: Option<String>,
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -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>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>
|
||||
</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--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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue