1mod trusted_publishing;
2
3use std::collections::BTreeSet;
4use std::path::{Path, PathBuf};
5use std::sync::Arc;
6use std::{fmt, io};
7
8use fs_err::tokio::File;
9use futures::TryStreamExt;
10use glob::{GlobError, PatternError, glob};
11use itertools::Itertools;
12use reqwest::header::{AUTHORIZATION, LOCATION, ToStrError};
13use reqwest::multipart::Part;
14use reqwest::{Body, Response, StatusCode};
15use reqwest_retry::RetryError;
16use reqwest_retry::policies::ExponentialBackoff;
17use rustc_hash::FxHashMap;
18use serde::Deserialize;
19use thiserror::Error;
20use tokio::io::{AsyncReadExt, BufReader};
21use tokio::sync::Semaphore;
22use tokio_util::io::ReaderStream;
23use tracing::{Level, debug, enabled, trace, warn};
24use url::Url;
25
26use uv_auth::{Credentials, PyxTokenStore, Realm};
27use uv_cache::{Cache, Refresh};
28use uv_client::{
29 BaseClient, ClientBuildError, DEFAULT_MAX_REDIRECTS, MetadataFormat, OwnedArchive,
30 RegistryClientBuilder, RequestBuilder, RetryParsingError, RetryState,
31};
32use uv_configuration::{KeyringProviderType, TrustedPublishing};
33use uv_distribution_filename::{DistFilename, SourceDistExtension, SourceDistFilename};
34use uv_distribution_types::{IndexCapabilities, IndexUrl};
35use uv_extract::hash::{HashReader, Hasher};
36use uv_fs::{ProgressReader, Simplified};
37use uv_metadata::read_metadata_async_seek;
38use uv_pypi_types::{HashAlgorithm, HashDigest, Metadata23, MetadataError};
39use uv_redacted::{DisplaySafeUrl, DisplaySafeUrlError};
40use uv_warnings::warn_user;
41
42use crate::trusted_publishing::pypi::PyPIPublishingService;
43use crate::trusted_publishing::pyx::PyxPublishingService;
44use crate::trusted_publishing::{
45 TrustedPublishingError, TrustedPublishingService, TrustedPublishingToken,
46};
47
48#[derive(Error, Debug)]
49pub enum PublishError {
50 #[error("The publish path is not a valid glob pattern: `{0}`")]
51 Pattern(String, #[source] PatternError),
52 #[error(transparent)]
54 Glob(#[from] GlobError),
55 #[error("Path patterns didn't match any wheels or source distributions")]
56 NoFiles,
57 #[error(transparent)]
58 Fmt(#[from] fmt::Error),
59 #[error("File is neither a wheel nor a source distribution: `{}`", _0.user_display())]
60 InvalidFilename(PathBuf),
61 #[error("Failed to publish: `{}`", _0.user_display())]
62 PublishPrepare(PathBuf, #[source] Box<PublishPrepareError>),
63 #[error("Failed to publish `{}` to {}", _0.user_display(), _1)]
64 PublishSend(
65 PathBuf,
66 Box<DisplaySafeUrl>,
67 #[source] Box<PublishSendError>,
68 ),
69 #[error("Validation failed for `{}` on {}", _0.user_display(), _1)]
70 Validate(
71 PathBuf,
72 Box<DisplaySafeUrl>,
73 #[source] Box<PublishSendError>,
74 ),
75 #[error("Failed to obtain token for trusted publishing")]
76 TrustedPublishing(#[from] Box<TrustedPublishingError>),
77 #[error("{0} are not allowed when using trusted publishing")]
78 MixedCredentials(String),
79 #[error("Failed to query check URL")]
80 CheckUrlIndex(#[source] uv_client::Error),
81 #[error(transparent)]
82 ClientBuild(#[from] ClientBuildError),
83 #[error(
84 "Local file and index file do not match for {filename}. \
85 Local: {hash_algorithm}={local}, Remote: {hash_algorithm}={remote}"
86 )]
87 HashMismatch {
88 filename: Box<DistFilename>,
89 hash_algorithm: HashAlgorithm,
90 local: String,
91 remote: String,
92 },
93 #[error("Hash is missing in index for {0}")]
94 MissingHash(Box<DistFilename>),
95 #[error(transparent)]
96 RetryParsing(#[from] RetryParsingError),
97 #[error("Failed to reserve upload slot for `{}`", _0.user_display())]
98 Reserve(PathBuf, #[source] Box<PublishSendError>),
99 #[error("Failed to upload to S3 for `{}`", _0.user_display())]
100 S3Upload(PathBuf, #[source] Box<PublishSendError>),
101 #[error("Failed to finalize upload for `{}`", _0.user_display())]
102 Finalize(PathBuf, #[source] Box<PublishSendError>),
103}
104
105#[derive(Error, Debug)]
107pub enum PublishPrepareError {
108 #[error(transparent)]
109 Io(#[from] io::Error),
110 #[error("Failed to read metadata")]
111 Metadata(#[from] uv_metadata::Error),
112 #[error("Failed to read metadata")]
113 Metadata23(#[from] MetadataError),
114 #[error("Only files ending in `.tar.gz` are valid source distributions: `{0}`")]
115 InvalidExtension(SourceDistFilename),
116 #[error("No PKG-INFO file found")]
117 MissingPkgInfo,
118 #[error("Multiple PKG-INFO files found: `{0}`")]
119 MultiplePkgInfo(String),
120 #[error("Failed to read: `{0}`")]
121 Read(String, #[source] io::Error),
122 #[error("Invalid PEP 740 attestation (not JSON): `{0}`")]
123 InvalidAttestation(PathBuf, #[source] serde_json::Error),
124}
125
126#[derive(Error, Debug)]
128pub enum PublishSendError {
129 #[error("Failed to send POST request")]
130 ReqwestMiddleware(#[source] reqwest_middleware::Error),
131 #[error("Server returned status code {0}")]
132 StatusNoBody(StatusCode, #[source] reqwest::Error),
133 #[error("Server returned status code {0}. Server says: {1}")]
134 Status(StatusCode, String),
135 #[error("Server returned status code {0}. {1}")]
136 StatusProblemDetails(StatusCode, String),
137 #[error(
138 "POST requests are not supported by the endpoint, are you using the simple index URL instead of the upload URL?"
139 )]
140 MethodNotAllowedNoBody,
141 #[error(
142 "POST requests are not supported by the endpoint, are you using the simple index URL instead of the upload URL? Server says: {0}"
143 )]
144 MethodNotAllowed(String),
145 #[error("Permission denied (status code {0}): {1}")]
147 PermissionDenied(StatusCode, String),
148 #[error("Too many redirects, only {0} redirects are allowed")]
149 TooManyRedirects(u32),
150 #[error("Redirected URL is not in the same realm. Redirected to: {0}")]
151 RedirectRealmMismatch(String),
152 #[error("Request was redirected, but no location header was provided")]
153 RedirectNoLocation,
154 #[error("Request was redirected, but location header is not a UTF-8 string")]
155 RedirectLocationInvalidStr(#[source] ToStrError),
156 #[error("Request was redirected, but location header is not a URL")]
157 RedirectInvalidLocation(#[source] DisplaySafeUrlError),
158}
159
160pub trait Reporter: Send + Sync + 'static {
161 fn on_progress(&self, name: &str, id: usize);
162 fn on_upload_start(&self, name: &str, size: Option<u64>) -> usize;
163 fn on_upload_progress(&self, id: usize, inc: u64);
164 fn on_upload_complete(&self, id: usize);
165 fn on_hash_start(&self, name: &DistFilename, size: Option<u64>) -> usize;
166 fn on_hash_progress(&self, id: usize, inc: u64);
167 fn on_hash_complete(&self, id: usize);
168}
169
170pub struct CheckUrlClient<'a> {
172 pub index_url: IndexUrl,
173 pub registry_client_builder: RegistryClientBuilder<'a>,
174 pub client: &'a BaseClient,
175 pub index_capabilities: IndexCapabilities,
176 pub cache: &'a Cache,
177}
178
179impl PublishSendError {
180 fn extract_error_message(body: String, content_type: Option<&str>) -> String {
257 if content_type == Some("application/json") {
258 #[derive(Deserialize)]
259 struct ErrorBody {
260 code: String,
261 }
262
263 if let Ok(structured) = serde_json::from_str::<ErrorBody>(&body) {
264 structured.code
265 } else {
266 body
267 }
268 } else {
269 body
270 }
271 }
272}
273
274#[derive(Debug)]
277pub struct UploadDistribution {
278 pub file: PathBuf,
280 pub raw_filename: String,
282 pub filename: DistFilename,
284 pub attestations: Vec<PathBuf>,
286}
287
288fn unroll_paths(paths: Vec<String>) -> Result<Vec<PathBuf>, PublishError> {
292 let mut files = BTreeSet::default();
293 for path in paths {
294 for file in glob(&path).map_err(|err| PublishError::Pattern(path.clone(), err))? {
295 let file = file?;
296 if !file.is_file() {
297 continue;
298 }
299
300 files.insert(file);
301 }
302 }
303
304 Ok(files.into_iter().collect())
305}
306
307fn group_files(files: Vec<PathBuf>, no_attestations: bool) -> Vec<UploadDistribution> {
309 let mut groups = FxHashMap::default();
310 let mut attestations_by_dist = FxHashMap::default();
311 for file in files {
312 let Some(filename) = file
313 .file_name()
314 .and_then(|filename| filename.to_str())
315 .map(ToString::to_string)
316 else {
317 continue;
318 };
319
320 let mut filename_parts = filename.rsplitn(3, '.');
325 if filename_parts.next() == Some("attestation")
326 && let Some(_) = filename_parts.next()
327 && let Some(dist_name) = filename_parts.next()
328 {
329 debug!(
330 "Found attestation for distribution: `{}` -> `{}`",
331 file.user_display(),
332 dist_name
333 );
334
335 attestations_by_dist
336 .entry(dist_name.to_string())
337 .or_insert_with(Vec::new)
338 .push(file);
339 } else {
340 let Some(dist_filename) = DistFilename::try_from_normalized_filename(&filename) else {
341 debug!("Not a distribution filename: `{filename}`");
342 #[expect(clippy::case_sensitive_file_extension_comparisons)]
344 if filename.ends_with(".whl")
345 || filename.ends_with(".zip")
346 || filename
348 .split_once(".tar.")
349 .is_some_and(|(_, ext)| ext.chars().all(char::is_alphanumeric))
350 {
351 warn_user!(
352 "Skipping file that looks like a distribution, \
353 but is not a valid distribution filename: `{}`",
354 file.user_display()
355 );
356 }
357 continue;
358 };
359
360 groups.insert(
361 filename.clone(),
362 UploadDistribution {
363 file,
364 raw_filename: filename,
365 filename: dist_filename,
366 attestations: Vec::new(),
367 },
368 );
369 }
370 }
371
372 if no_attestations {
373 debug!("Not merging attestations with distributions per user request");
374 } else {
375 for (dist_name, attestations) in attestations_by_dist {
377 if let Some(group) = groups.get_mut(&dist_name) {
378 group.attestations = attestations;
379 group.attestations.sort();
380 }
381 }
382 }
383
384 groups.into_values().collect()
385}
386
387pub fn group_files_for_publishing(
395 paths: Vec<String>,
396 no_attestations: bool,
397) -> Result<Vec<UploadDistribution>, PublishError> {
398 Ok(group_files(unroll_paths(paths)?, no_attestations))
399}
400
401pub enum TrustedPublishResult {
402 Skipped,
404 Configured(TrustedPublishingToken),
406 Ignored(TrustedPublishingError),
408}
409
410pub async fn check_trusted_publishing(
412 username: Option<&str>,
413 password: Option<&str>,
414 keyring_provider: KeyringProviderType,
415 token_store: &PyxTokenStore,
416 trusted_publishing: TrustedPublishing,
417 registry: &DisplaySafeUrl,
418 client: &BaseClient,
419) -> Result<TrustedPublishResult, PublishError> {
420 match trusted_publishing {
421 TrustedPublishing::Automatic => {
422 if username.is_some()
424 || password.is_some()
425 || keyring_provider != KeyringProviderType::Disabled
426 {
427 return Ok(TrustedPublishResult::Skipped);
428 }
429
430 debug!("Attempting to get a token for trusted publishing");
431
432 let token = if token_store.is_known_url(registry) {
434 debug!("Using trusted publishing flow for pyx");
435 PyxPublishingService::new(registry, client)
436 .get_token()
437 .await
438 } else {
439 debug!("Using trusted publishing flow for PyPI");
440 PyPIPublishingService::new(registry, client)
441 .get_token()
442 .await
443 };
444
445 match token {
446 Ok(Some(token)) => Ok(TrustedPublishResult::Configured(token)),
448 Ok(None) => Ok(TrustedPublishResult::Ignored(
450 TrustedPublishingError::NoToken,
451 )),
452 Err(err) => Ok(TrustedPublishResult::Ignored(err)),
454 }
455 }
456 TrustedPublishing::Always => {
457 debug!("Using trusted publishing for GitHub Actions");
458
459 let mut conflicts = Vec::new();
460 if username.is_some() {
461 conflicts.push("a username");
462 }
463 if password.is_some() {
464 conflicts.push("a password");
465 }
466 if keyring_provider != KeyringProviderType::Disabled {
467 conflicts.push("the keyring");
468 }
469 if !conflicts.is_empty() {
470 return Err(PublishError::MixedCredentials(conflicts.join(" and ")));
471 }
472
473 let token = if token_store.is_known_url(registry) {
475 debug!("Using trusted publishing flow for pyx");
476 PyxPublishingService::new(registry, client)
477 .get_token()
478 .await
479 .map_err(Box::new)?
480 } else {
481 debug!("Using trusted publishing flow for PyPI");
482 PyPIPublishingService::new(registry, client)
483 .get_token()
484 .await
485 .map_err(Box::new)?
486 };
487
488 let Some(token) = token else {
489 return Err(PublishError::TrustedPublishing(
490 TrustedPublishingError::NoToken.into(),
491 ));
492 };
493
494 Ok(TrustedPublishResult::Configured(token))
495 }
496 TrustedPublishing::Never => Ok(TrustedPublishResult::Skipped),
497 }
498}
499
500pub async fn upload(
506 group: &UploadDistribution,
507 form_metadata: &FormMetadata,
508 registry: &DisplaySafeUrl,
509 client: &BaseClient,
510 retry_policy: ExponentialBackoff,
511 credentials: &Credentials,
512 check_url_client: Option<&CheckUrlClient<'_>>,
513 download_concurrency: &Semaphore,
514 reporter: Arc<impl Reporter>,
515) -> Result<bool, PublishError> {
516 let mut n_past_redirections = 0;
517 let max_redirects = DEFAULT_MAX_REDIRECTS;
518 let mut current_registry = registry.clone();
519 let mut retry_state = RetryState::start(retry_policy, registry.clone());
520
521 loop {
522 let (request, idx) = build_upload_request(
523 group,
524 ¤t_registry,
525 client,
526 credentials,
527 form_metadata,
528 reporter.clone(),
529 )
530 .await
531 .map_err(|err| PublishError::PublishPrepare(group.file.clone(), Box::new(err)))?;
532
533 let result = request.send().await;
534 let response = match result {
535 Ok(response) => {
536 if response.status().is_redirection() {
546 if n_past_redirections >= max_redirects {
547 return Err(PublishError::PublishSend(
548 group.file.clone(),
549 current_registry.clone().into(),
550 PublishSendError::TooManyRedirects(n_past_redirections).into(),
551 ));
552 }
553 let location = response
554 .headers()
555 .get(LOCATION)
556 .ok_or_else(|| {
557 PublishError::PublishSend(
558 group.file.clone(),
559 current_registry.clone().into(),
560 PublishSendError::RedirectNoLocation.into(),
561 )
562 })?
563 .to_str()
564 .map_err(|err| {
565 PublishError::PublishSend(
566 group.file.clone(),
567 current_registry.clone().into(),
568 PublishSendError::RedirectLocationInvalidStr(err).into(),
569 )
570 })?;
571 current_registry = DisplaySafeUrl::parse(location).map_err(|err| {
572 PublishError::PublishSend(
573 group.file.clone(),
574 current_registry.clone().into(),
575 PublishSendError::RedirectInvalidLocation(err).into(),
576 )
577 })?;
578 if Realm::from(¤t_registry) != Realm::from(registry) {
579 return Err(PublishError::PublishSend(
580 group.file.clone(),
581 current_registry.clone().into(),
582 PublishSendError::RedirectRealmMismatch(current_registry.to_string())
583 .into(),
584 ));
585 }
586 debug!("Redirecting the request to: {}", current_registry);
587 n_past_redirections += 1;
588 continue;
589 }
590 reporter.on_upload_complete(idx);
591 response
592 }
593 Err(err) => {
594 let middleware_retries = if let Some(RetryError::WithRetries { retries, .. }) =
595 (&err as &dyn std::error::Error).downcast_ref::<RetryError>()
596 {
597 *retries
598 } else {
599 0
600 };
601 if let Some(backoff) = retry_state.should_retry(&err, middleware_retries) {
602 retry_state.sleep_backoff(backoff).await;
603 continue;
604 }
605 return Err(PublishError::PublishSend(
606 group.file.clone(),
607 current_registry.clone().into(),
608 PublishSendError::ReqwestMiddleware(err).into(),
609 ));
610 }
611 };
612
613 return match handle_response(¤t_registry, response).await {
614 Ok(()) => {
615 Ok(true)
618 }
619 Err(err) => {
620 if matches!(
621 err,
622 PublishSendError::Status(..) | PublishSendError::StatusNoBody(..)
623 ) {
624 if let Some(check_url_client) = &check_url_client {
625 if check_url(
626 check_url_client,
627 &group.file,
628 &group.filename,
629 download_concurrency,
630 reporter.clone(),
631 )
632 .await?
633 {
634 return Ok(false);
637 }
638 }
639 }
640 Err(PublishError::PublishSend(
641 group.file.clone(),
642 current_registry.clone().into(),
643 err.into(),
644 ))
645 }
646 };
647 }
648}
649
650pub async fn validate(
654 file: &Path,
655 form_metadata: &FormMetadata,
656 raw_filename: &str,
657 registry: &DisplaySafeUrl,
658 store: &PyxTokenStore,
659 client: &BaseClient,
660 credentials: &Credentials,
661) -> Result<bool, PublishError> {
662 if store.is_known_url(registry) {
663 debug!("Performing validation request for {registry}");
664
665 let mut validation_url = registry.clone();
666 validation_url
667 .path_segments_mut()
668 .expect("URL must have path segments")
669 .push("validate");
670
671 let request = build_metadata_request(
672 raw_filename,
673 &validation_url,
674 client,
675 credentials,
676 form_metadata,
677 );
678
679 let response = request.send().await.map_err(|err| {
680 PublishError::Validate(
681 file.to_path_buf(),
682 registry.clone().into(),
683 PublishSendError::ReqwestMiddleware(err).into(),
684 )
685 })?;
686
687 let status_code = response.status();
688 debug!("Response code for {validation_url}: {status_code}");
689
690 if status_code.is_success() {
691 #[derive(Deserialize)]
692 struct ValidateResponse {
693 exists: bool,
694 }
695
696 match response.text().await {
698 Ok(body) => {
699 trace!("Response content for {validation_url}: {body}");
700 if let Ok(response) = serde_json::from_str::<ValidateResponse>(&body) {
701 if response.exists {
702 debug!("File already uploaded: {raw_filename}");
703 return Ok(false);
704 }
705 }
706 }
707 Err(err) => {
708 trace!("Failed to read response content for {validation_url}: {err}");
709 }
710 }
711 return Ok(true);
712 }
713
714 handle_response(&validation_url, response)
716 .await
717 .map_err(|err| {
718 PublishError::Validate(file.to_path_buf(), registry.clone().into(), err.into())
719 })?;
720
721 Ok(true)
722 } else {
723 debug!("Skipping validation request for unsupported publish URL: {registry}");
724 Ok(true)
725 }
726}
727
728pub async fn upload_two_phase(
737 group: &UploadDistribution,
738 form_metadata: &FormMetadata,
739 registry: &DisplaySafeUrl,
740 client: &BaseClient,
741 s3_client: &BaseClient,
742 retry_policy: ExponentialBackoff,
743 credentials: &Credentials,
744 reporter: Arc<impl Reporter>,
745) -> Result<bool, PublishError> {
746 #[derive(Debug, Deserialize)]
747 struct ReserveResponse {
748 upload_url: Option<String>,
749 upload_headers: Option<FxHashMap<String, String>>,
750 }
751
752 let mut reserve_url = registry.clone();
754 reserve_url
755 .path_segments_mut()
756 .expect("URL must have path segments")
757 .push("reserve");
758
759 debug!("Reserving upload slot at {reserve_url}");
760
761 let reserve_request = build_metadata_request(
762 &group.raw_filename,
763 &reserve_url,
764 client,
765 credentials,
766 form_metadata,
767 );
768
769 let response = reserve_request.send().await.map_err(|err| {
770 PublishError::Reserve(
771 group.file.clone(),
772 PublishSendError::ReqwestMiddleware(err).into(),
773 )
774 })?;
775
776 let status = response.status();
777
778 let reserve_response: ReserveResponse = match status {
779 StatusCode::OK => {
780 debug!("File already uploaded: {}", group.raw_filename);
781 return Ok(false);
782 }
783 StatusCode::CREATED => {
784 let body = response.text().await.map_err(|err| {
785 PublishError::Reserve(
786 group.file.clone(),
787 PublishSendError::StatusNoBody(status, err).into(),
788 )
789 })?;
790 serde_json::from_str(&body).map_err(|_| {
791 PublishError::Reserve(
792 group.file.clone(),
793 PublishSendError::Status(status, format!("Invalid JSON response: {body}"))
794 .into(),
795 )
796 })?
797 }
798 _ => {
799 let body = response.text().await.unwrap_or_default();
800 return Err(PublishError::Reserve(
801 group.file.clone(),
802 PublishSendError::Status(status, body).into(),
803 ));
804 }
805 };
806
807 if let Some(upload_url) = reserve_response.upload_url {
810 let s3_url = DisplaySafeUrl::parse(&upload_url).map_err(|_| {
811 PublishError::S3Upload(
812 group.file.clone(),
813 PublishSendError::Status(
814 StatusCode::BAD_REQUEST,
815 "Invalid S3 URL in reserve response".to_string(),
816 )
817 .into(),
818 )
819 })?;
820
821 debug!("Got pre-signed URL for upload: {s3_url}");
822
823 let file_size = fs_err::tokio::metadata(&group.file)
825 .await
826 .map_err(|err| {
827 PublishError::PublishPrepare(
828 group.file.clone(),
829 Box::new(PublishPrepareError::Io(err)),
830 )
831 })?
832 .len();
833
834 let mut retry_state = RetryState::start(retry_policy, s3_url.clone());
835 loop {
836 let file = File::open(&group.file).await.map_err(|err| {
837 PublishError::PublishPrepare(
838 group.file.clone(),
839 Box::new(PublishPrepareError::Io(err)),
840 )
841 })?;
842
843 let idx = reporter.on_upload_start(&group.filename.to_string(), Some(file_size));
844 let reporter_clone = reporter.clone();
845 let reader = ProgressReader::new(file, move |read| {
846 reporter_clone.on_upload_progress(idx, read as u64);
847 });
848 let file_reader = Body::wrap_stream(ReaderStream::new(reader));
849
850 let mut request = s3_client
851 .for_host(&s3_url)
852 .raw_client()
853 .put(Url::from(s3_url.clone()))
854 .header(reqwest::header::CONTENT_TYPE, "application/octet-stream")
855 .header(reqwest::header::CONTENT_LENGTH, file_size);
856
857 if let Some(headers) = &reserve_response.upload_headers {
859 for (key, value) in headers {
860 request = request.header(key, value);
861 }
862 }
863
864 let result = request.body(file_reader).send().await;
865
866 let response = match result {
867 Ok(response) => {
868 reporter.on_upload_complete(idx);
869 response
870 }
871 Err(err) => {
872 let middleware_retries =
873 if let Some(RetryError::WithRetries { retries, .. }) =
874 (&err as &dyn std::error::Error).downcast_ref::<RetryError>()
875 {
876 *retries
877 } else {
878 0
879 };
880 if let Some(backoff) = retry_state.should_retry(&err, middleware_retries) {
881 retry_state.sleep_backoff(backoff).await;
882 continue;
883 }
884 return Err(PublishError::S3Upload(
885 group.file.clone(),
886 PublishSendError::ReqwestMiddleware(err).into(),
887 ));
888 }
889 };
890
891 if response.status().is_success() {
892 break;
893 }
894
895 let status = response.status();
896 let body = response.text().await.unwrap_or_default();
897 return Err(PublishError::S3Upload(
898 group.file.clone(),
899 PublishSendError::Status(status, format!("S3 upload failed: {body}")).into(),
900 ));
901 }
902
903 debug!("S3 upload complete for {}", group.raw_filename);
904 } else {
905 debug!(
906 "File already exists on S3, skipping upload: {}",
907 group.raw_filename
908 );
909 }
910
911 let mut finalize_url = registry.clone();
913 finalize_url
914 .path_segments_mut()
915 .expect("URL must have path segments")
916 .push("finalize");
917
918 debug!("Finalizing upload at {finalize_url}");
919
920 let finalize_request = build_metadata_request(
921 &group.raw_filename,
922 &finalize_url,
923 client,
924 credentials,
925 form_metadata,
926 );
927
928 let response = finalize_request.send().await.map_err(|err| {
929 PublishError::Finalize(
930 group.file.clone(),
931 PublishSendError::ReqwestMiddleware(err).into(),
932 )
933 })?;
934
935 handle_response(&finalize_url, response)
936 .await
937 .map_err(|err| PublishError::Finalize(group.file.clone(), err.into()))?;
938
939 debug!("Upload finalized for {}", group.raw_filename);
940
941 Ok(true)
942}
943
944pub async fn check_url(
946 check_url_client: &CheckUrlClient<'_>,
947 file: &Path,
948 filename: &DistFilename,
949 download_concurrency: &Semaphore,
950 reporter: Arc<impl Reporter>,
951) -> Result<bool, PublishError> {
952 let CheckUrlClient {
953 index_url,
954 registry_client_builder,
955 client,
956 index_capabilities,
957 cache,
958 } = check_url_client;
959
960 let cache_refresh = (*cache)
962 .clone()
963 .with_refresh(Refresh::from_args(None, vec![filename.name().clone()]));
964 let registry_client = registry_client_builder
965 .clone()
966 .cache(cache_refresh)
967 .wrap_existing(client)?;
968
969 debug!("Checking for {filename} in the registry");
970 let response = match registry_client
971 .simple_detail(
972 filename.name(),
973 Some(index_url.into()),
974 index_capabilities,
975 download_concurrency,
976 )
977 .await
978 {
979 Ok(response) => response,
980 Err(err) => {
981 return match err.kind() {
982 uv_client::ErrorKind::RemotePackageNotFound(_) => {
983 warn!(
985 "Package not found in the registry; skipping upload check for {filename}"
986 );
987 Ok(false)
988 }
989 _ => Err(PublishError::CheckUrlIndex(err)),
990 };
991 }
992 };
993 let [(_, MetadataFormat::Simple(simple_metadata))] = response.as_slice() else {
994 unreachable!("We queried a single index, we must get a single response");
995 };
996 let simple_metadata = OwnedArchive::deserialize(simple_metadata);
997 let Some(metadatum) = simple_metadata
998 .iter()
999 .find(|metadatum| &metadatum.version == filename.version())
1000 else {
1001 return Ok(false);
1002 };
1003
1004 let archived_file = match filename {
1005 DistFilename::SourceDistFilename(source_dist) => metadatum
1006 .files
1007 .source_dists
1008 .iter()
1009 .find(|entry| &entry.name == source_dist)
1010 .map(|entry| &entry.file),
1011 DistFilename::WheelFilename(wheel) => metadatum
1012 .files
1013 .wheels
1014 .iter()
1015 .find(|entry| &entry.name == wheel)
1016 .map(|entry| &entry.file),
1017 };
1018 let Some(archived_file) = archived_file else {
1019 return Ok(false);
1020 };
1021
1022 if let Some(remote_hash) = archived_file.hashes.first() {
1024 let local_hash = &hash_file(
1027 file,
1028 filename,
1029 vec![Hasher::from(remote_hash.algorithm)],
1030 reporter,
1031 )
1032 .await
1033 .map_err(|err| {
1034 PublishError::PublishPrepare(file.to_path_buf(), Box::new(PublishPrepareError::Io(err)))
1035 })?[0];
1036 if local_hash.digest == remote_hash.digest {
1037 debug!(
1038 "Found {filename} in the registry with matching hash {}",
1039 remote_hash.digest
1040 );
1041 Ok(true)
1042 } else {
1043 Err(PublishError::HashMismatch {
1044 filename: Box::new(filename.clone()),
1045 hash_algorithm: remote_hash.algorithm,
1046 local: local_hash.digest.to_string(),
1047 remote: remote_hash.digest.to_string(),
1048 })
1049 }
1050 } else {
1051 Err(PublishError::MissingHash(Box::new(filename.clone())))
1052 }
1053}
1054
1055async fn hash_file(
1057 path: impl AsRef<Path>,
1058 filename: &DistFilename,
1059 hashers: Vec<Hasher>,
1060 reporter: Arc<impl Reporter>,
1061) -> Result<Vec<HashDigest>, io::Error> {
1062 let path = path.as_ref();
1063 debug!("Hashing {}", path.user_display());
1064
1065 let file = File::open(path).await?;
1066 let file_size = file.metadata().await?.len();
1067 let idx = reporter.on_hash_start(filename, Some(file_size));
1068
1069 let reader = BufReader::new(file);
1070 let mut hashers = hashers;
1071 let reporter_clone = reporter.clone();
1072 let mut reader = HashReader::new(
1073 ProgressReader::new(reader, move |read| {
1074 reporter_clone.on_hash_progress(idx, read as u64);
1075 }),
1076 &mut hashers,
1077 );
1078
1079 let result = reader.finish().await;
1080 reporter.on_hash_complete(idx);
1081 result?;
1082
1083 Ok(hashers
1084 .into_iter()
1085 .map(HashDigest::from)
1086 .collect::<Vec<_>>())
1087}
1088
1089async fn source_dist_pkg_info(file: &Path) -> Result<Vec<u8>, PublishPrepareError> {
1091 let reader = BufReader::new(File::open(&file).await?);
1092 let decoded = async_compression::tokio::bufread::GzipDecoder::new(reader);
1093 let mut archive = tokio_tar::Archive::new(decoded);
1094 let mut pkg_infos: Vec<(PathBuf, Vec<u8>)> = archive
1095 .entries()?
1096 .map_err(PublishPrepareError::from)
1097 .try_filter_map(async |mut entry| {
1098 let path = entry
1099 .path()
1100 .map_err(PublishPrepareError::from)?
1101 .to_path_buf();
1102 let mut components = path.components();
1103 let Some(_top_level) = components.next() else {
1104 return Ok(None);
1105 };
1106 let Some(pkg_info) = components.next() else {
1107 return Ok(None);
1108 };
1109 if components.next().is_some() || pkg_info.as_os_str() != "PKG-INFO" {
1110 return Ok(None);
1111 }
1112 let mut buffer = Vec::new();
1113 entry.read_to_end(&mut buffer).await.map_err(|err| {
1115 PublishPrepareError::Read(path.to_string_lossy().to_string(), err)
1116 })?;
1117 Ok(Some((path, buffer)))
1118 })
1119 .try_collect()
1120 .await?;
1121 match pkg_infos.len() {
1122 0 => Err(PublishPrepareError::MissingPkgInfo),
1123 1 => Ok(pkg_infos.remove(0).1),
1124 _ => Err(PublishPrepareError::MultiplePkgInfo(
1125 pkg_infos
1126 .iter()
1127 .map(|(path, _buffer)| path.to_string_lossy())
1128 .join(", "),
1129 )),
1130 }
1131}
1132
1133async fn metadata(file: &Path, filename: &DistFilename) -> Result<Metadata23, PublishPrepareError> {
1134 let contents = match filename {
1135 DistFilename::SourceDistFilename(source_dist) => {
1136 if source_dist.extension != SourceDistExtension::TarGz {
1137 return Err(PublishPrepareError::InvalidExtension(source_dist.clone()));
1140 }
1141 source_dist_pkg_info(file).await?
1142 }
1143 DistFilename::WheelFilename(wheel) => {
1144 let reader = BufReader::new(File::open(&file).await?);
1145 read_metadata_async_seek(wheel, reader).await?
1146 }
1147 };
1148 Ok(Metadata23::parse(&contents)?)
1149}
1150
1151#[derive(Debug, Clone)]
1152pub struct FormMetadata(Vec<(&'static str, String)>);
1153
1154impl FormMetadata {
1155 pub async fn read_from_file(
1159 file: &Path,
1160 filename: &DistFilename,
1161 reporter: Arc<impl Reporter>,
1162 ) -> Result<Self, PublishPrepareError> {
1163 let hashes = hash_file(
1164 file,
1165 filename,
1166 vec![
1167 Hasher::from(HashAlgorithm::Sha256),
1168 Hasher::from(HashAlgorithm::Blake2b),
1169 ],
1170 reporter,
1171 )
1172 .await?;
1173
1174 let sha256_hash = hashes
1175 .iter()
1176 .find(|hash| hash.algorithm == HashAlgorithm::Sha256)
1177 .unwrap();
1178
1179 let blake2b_hash = hashes
1180 .iter()
1181 .find(|hash| hash.algorithm == HashAlgorithm::Blake2b)
1182 .unwrap();
1183
1184 let metadata = metadata(file, filename).await?;
1185
1186 Ok(Self::from_metadata(
1187 metadata,
1188 filename,
1189 sha256_hash,
1190 blake2b_hash,
1191 ))
1192 }
1193
1194 fn from_metadata(
1195 metadata: Metadata23,
1196 filename: &DistFilename,
1197 sha256_hash: &HashDigest,
1198 blake2b_hash: &HashDigest,
1199 ) -> Self {
1200 let Metadata23 {
1201 metadata_version,
1202 name,
1203 version,
1204 platforms,
1205 supported_platforms: _,
1207 summary,
1208 description,
1209 description_content_type,
1210 keywords,
1211 home_page,
1212 download_url,
1213 author,
1214 author_email,
1215 maintainer,
1216 maintainer_email,
1217 license,
1218 license_expression,
1219 license_files,
1220 classifiers,
1221 requires_dist,
1222 provides_dist,
1223 obsoletes_dist,
1224 requires_python,
1225 requires_external,
1226 project_urls,
1227 provides_extra,
1228 import_names,
1229 import_namespaces,
1230 dynamic,
1231 } = metadata;
1232
1233 let mut form_metadata = vec![
1234 (":action", "file_upload".to_string()),
1235 ("sha256_digest", sha256_hash.digest.to_string()),
1236 ("blake2_256_digest", blake2b_hash.digest.to_string()),
1237 ("protocol_version", "1".to_string()),
1238 ("metadata_version", metadata_version.clone()),
1239 ("name", name.clone()),
1245 ("version", version.clone()),
1246 ("filetype", filename.filetype().to_string()),
1247 ];
1248
1249 if let DistFilename::WheelFilename(wheel) = filename {
1250 form_metadata.push(("pyversion", wheel.python_tags().iter().join(".")));
1251 } else {
1252 form_metadata.push(("pyversion", "source".to_string()));
1253 }
1254
1255 let mut add_option = |name, value: Option<String>| {
1256 if let Some(some) = value.clone() {
1257 form_metadata.push((name, some));
1258 }
1259 };
1260
1261 add_option("author", author);
1262 add_option("author_email", author_email);
1263 add_option("description", description);
1264 add_option("description_content_type", description_content_type);
1265 add_option("download_url", download_url);
1266 add_option("home_page", home_page);
1267 add_option("keywords", keywords.map(|keywords| keywords.as_metadata()));
1268 add_option("license", license);
1269 add_option("license_expression", license_expression);
1270 add_option("maintainer", maintainer);
1271 add_option("maintainer_email", maintainer_email);
1272 add_option("summary", summary);
1273
1274 form_metadata.push(("requires_python", requires_python.unwrap_or(String::new())));
1277
1278 let mut add_vec = |name, values: Vec<String>| {
1279 for i in values {
1280 form_metadata.push((name, i.clone()));
1281 }
1282 };
1283
1284 add_vec("classifiers", classifiers);
1285 add_vec("dynamic", dynamic);
1286 add_vec("license_file", license_files);
1287 add_vec("obsoletes_dist", obsoletes_dist);
1288 add_vec("platform", platforms);
1289 add_vec("project_urls", project_urls.to_vec_str());
1290 add_vec("provides_dist", provides_dist);
1291 add_vec("provides_extra", provides_extra);
1292 add_vec("import_names", import_names);
1293 add_vec("import_namespaces", import_namespaces);
1294 add_vec("requires_dist", requires_dist);
1295 add_vec("requires_external", requires_external);
1296
1297 Self(form_metadata)
1298 }
1299
1300 fn iter(&self) -> std::slice::Iter<'_, (&'static str, String)> {
1302 self.0.iter()
1303 }
1304}
1305
1306impl<'a> IntoIterator for &'a FormMetadata {
1307 type Item = &'a (&'a str, String);
1308 type IntoIter = std::slice::Iter<'a, (&'a str, String)>;
1309 fn into_iter(self) -> Self::IntoIter {
1310 self.iter()
1311 }
1312}
1313
1314async fn build_upload_request<'a>(
1318 group: &UploadDistribution,
1319 registry: &DisplaySafeUrl,
1320 client: &'a BaseClient,
1321 credentials: &Credentials,
1322 form_metadata: &FormMetadata,
1323 reporter: Arc<impl Reporter>,
1324) -> Result<(RequestBuilder<'a>, usize), PublishPrepareError> {
1325 let mut form = reqwest::multipart::Form::new();
1326 for (key, value) in form_metadata.iter() {
1327 form = form.text(*key, value.clone());
1328 }
1329
1330 let file = File::open(&group.file).await?;
1331 let file_size = file.metadata().await?.len();
1332 let idx = reporter.on_upload_start(&group.filename.to_string(), Some(file_size));
1333 let reader = ProgressReader::new(file, move |read| {
1334 reporter.on_upload_progress(idx, read as u64);
1335 });
1336 let file_reader = Body::wrap_stream(ReaderStream::new(reader));
1339 let part =
1341 Part::stream_with_length(file_reader, file_size).file_name(group.raw_filename.clone());
1342 form = form.part("content", part);
1343
1344 let mut attestations = vec![];
1345 for attestation_path in &group.attestations {
1346 let contents = fs_err::read_to_string(attestation_path)?;
1347 let raw_attestation = serde_json::from_str::<serde_json::Value>(&contents)
1350 .map_err(|err| PublishPrepareError::InvalidAttestation(attestation_path.into(), err))?;
1351 attestations.push(raw_attestation);
1352 }
1353
1354 if !attestations.is_empty() {
1355 let attestations_json =
1357 serde_json::to_string(&attestations).expect("Round-trip of PEP 740 attestation failed");
1358 form = form.text("attestations", attestations_json);
1359 }
1360
1361 let url = if let Some(username) = credentials
1364 .username()
1365 .filter(|_| credentials.password().is_none())
1366 {
1367 let mut url = registry.clone();
1368 let _ = url.set_username(username);
1369 url
1370 } else {
1371 registry.clone()
1372 };
1373
1374 let mut request = client
1375 .for_host(&url)
1376 .post(Url::from(url))
1377 .multipart(form)
1378 .header(
1382 reqwest::header::ACCEPT,
1383 "application/json;q=0.9, text/plain;q=0.8, text/html;q=0.7",
1384 );
1385
1386 match credentials {
1387 Credentials::Basic { password, .. } => {
1388 if password.is_some() {
1389 debug!("Using HTTP Basic authentication");
1390 request = request.header(AUTHORIZATION, credentials.to_header_value());
1391 }
1392 }
1393 Credentials::Bearer { .. } => {
1394 debug!("Using Bearer token authentication");
1395 request = request.header(AUTHORIZATION, credentials.to_header_value());
1396 }
1397 }
1398
1399 Ok((request, idx))
1400}
1401
1402fn build_metadata_request<'a>(
1404 raw_filename: &str,
1405 registry: &DisplaySafeUrl,
1406 client: &'a BaseClient,
1407 credentials: &Credentials,
1408 form_metadata: &FormMetadata,
1409) -> RequestBuilder<'a> {
1410 let mut form = reqwest::multipart::Form::new();
1411 for (key, value) in form_metadata.iter() {
1412 form = form.text(*key, value.clone());
1413 }
1414 form = form.text("filename", raw_filename.to_owned());
1415
1416 let url = if let Some(username) = credentials
1419 .username()
1420 .filter(|_| credentials.password().is_none())
1421 {
1422 let mut url = registry.clone();
1423 let _ = url.set_username(username);
1424 url
1425 } else {
1426 registry.clone()
1427 };
1428
1429 let mut request = client
1430 .for_host(&url)
1431 .post(Url::from(url))
1432 .multipart(form)
1433 .header(
1437 reqwest::header::ACCEPT,
1438 "application/json;q=0.9, text/plain;q=0.8, text/html;q=0.7",
1439 );
1440
1441 match credentials {
1442 Credentials::Basic { password, .. } => {
1443 if password.is_some() {
1444 debug!("Using HTTP Basic authentication");
1445 request = request.header(AUTHORIZATION, credentials.to_header_value());
1446 }
1447 }
1448 Credentials::Bearer { .. } => {
1449 debug!("Using Bearer token authentication");
1450 request = request.header(AUTHORIZATION, credentials.to_header_value());
1451 }
1452 }
1453
1454 request
1455}
1456
1457async fn handle_response(
1459 registry: &DisplaySafeUrl,
1460 response: Response,
1461) -> Result<(), PublishSendError> {
1462 let status_code = response.status();
1463 debug!("Response code for {registry}: {status_code}");
1464 trace!("Response headers for {registry}: {response:?}");
1465
1466 if status_code.is_success() {
1467 if enabled!(Level::TRACE) {
1468 match response.text().await {
1469 Ok(response_content) => {
1470 trace!("Response content for {registry}: {response_content}");
1471 }
1472 Err(err) => {
1473 trace!("Failed to read response content for {registry}: {err}");
1474 }
1475 }
1476 }
1477 return Ok(());
1478 }
1479
1480 let content_type = response
1481 .headers()
1482 .get(reqwest::header::CONTENT_TYPE)
1483 .and_then(|content_type| content_type.to_str().ok())
1484 .map(ToString::to_string);
1485 let upload_error = response.bytes().await.map_err(|err| {
1486 if status_code == StatusCode::METHOD_NOT_ALLOWED {
1487 PublishSendError::MethodNotAllowedNoBody
1488 } else {
1489 PublishSendError::StatusNoBody(status_code, err)
1490 }
1491 })?;
1492 let upload_error = String::from_utf8_lossy(&upload_error);
1493
1494 trace!("Response content for non-200 response for {registry}: {upload_error}");
1495
1496 debug!("Upload error response: {upload_error}");
1497
1498 if status_code == StatusCode::METHOD_NOT_ALLOWED {
1500 return Err(PublishSendError::MethodNotAllowed(
1501 PublishSendError::extract_error_message(
1502 upload_error.to_string(),
1503 content_type.as_deref(),
1504 ),
1505 ));
1506 }
1507
1508 if content_type.as_deref() == Some(uv_client::ProblemDetails::CONTENT_TYPE)
1510 && let Some(problem) =
1511 uv_client::ProblemDetails::try_from_response_body(upload_error.as_bytes())
1512 && let Some(description) = problem.description()
1513 {
1514 return Err(PublishSendError::StatusProblemDetails(
1515 status_code,
1516 description,
1517 ));
1518 }
1519
1520 Err(PublishSendError::Status(
1522 status_code,
1523 PublishSendError::extract_error_message(upload_error.to_string(), content_type.as_deref()),
1524 ))
1525}
1526
1527#[cfg(test)]
1528mod tests {
1529 use std::path::PathBuf;
1530 use std::sync::Arc;
1531
1532 use insta::{allow_duplicates, assert_debug_snapshot, assert_snapshot};
1533 use itertools::Itertools;
1534 use uv_auth::Credentials;
1535 use uv_client::{AuthIntegration, BaseClientBuilder, RedirectPolicy};
1536 use uv_distribution_filename::DistFilename;
1537 use uv_pypi_types::{HashDigest, Metadata23};
1538 use uv_redacted::DisplaySafeUrl;
1539
1540 use crate::{
1541 FormMetadata, PublishError, Reporter, UploadDistribution, build_upload_request,
1542 group_files, upload,
1543 };
1544 use tokio::sync::Semaphore;
1545 use uv_errors::{ErrorOptions, write_error_chain_with_options};
1546 use wiremock::matchers::{method, path};
1547 use wiremock::{Mock, MockServer, ResponseTemplate};
1548
1549 struct DummyReporter;
1550
1551 impl Reporter for DummyReporter {
1552 fn on_progress(&self, _name: &str, _id: usize) {}
1553 fn on_upload_start(&self, _name: &str, _size: Option<u64>) -> usize {
1554 0
1555 }
1556 fn on_upload_progress(&self, _id: usize, _inc: u64) {}
1557 fn on_upload_complete(&self, _id: usize) {}
1558 fn on_hash_start(&self, _name: &DistFilename, _size: Option<u64>) -> usize {
1559 0
1560 }
1561 fn on_hash_progress(&self, _id: usize, _inc: u64) {}
1562 fn on_hash_complete(&self, _id: usize) {}
1563 }
1564
1565 async fn mock_server_upload(mock_server: &MockServer) -> Result<bool, PublishError> {
1566 let raw_filename = "tqdm-4.66.1-py3-none-manylinux_2_12_x86_64.manylinux2010_x86_64.musllinux_1_1_x86_64.whl";
1567 let file = PathBuf::from("../../test/links/").join(raw_filename);
1568 let filename = DistFilename::try_from_normalized_filename(raw_filename).unwrap();
1569
1570 let group = UploadDistribution {
1571 file,
1572 raw_filename: raw_filename.to_string(),
1573 filename,
1574 attestations: vec![],
1575 };
1576
1577 let form_metadata =
1578 FormMetadata::read_from_file(&group.file, &group.filename, Arc::new(DummyReporter))
1579 .await
1580 .unwrap();
1581
1582 let client = BaseClientBuilder::default()
1583 .redirect(RedirectPolicy::NoRedirect)
1584 .retries(0)
1585 .auth_integration(AuthIntegration::NoAuthMiddleware)
1586 .build()
1587 .expect("failed to build base client");
1588
1589 let download_concurrency = Arc::new(Semaphore::new(1));
1590 let registry = DisplaySafeUrl::parse(&format!("{}/final", mock_server.uri())).unwrap();
1591 upload(
1592 &group,
1593 &form_metadata,
1594 ®istry,
1595 &client,
1596 client.retry_policy(),
1597 &Credentials::basic(Some("ferris".to_string()), Some("F3RR!S".to_string())),
1598 None,
1599 &download_concurrency,
1600 Arc::new(DummyReporter),
1601 )
1602 .await
1603 }
1604
1605 #[test]
1606 fn test_group_files() {
1607 fn shuffle<T>(vec: &mut [T]) {
1609 let n: usize = vec.len();
1610 for i in 0..(n - 1) {
1611 let j = (fastrand::usize(..)) % (n - i) + i;
1612 vec.swap(i, j);
1613 }
1614 }
1615
1616 let valid_sdist = "dist/acme-1.2.3.tar.gz";
1617 let valid_sdist_publish_attestation = format!("{valid_sdist}.publish.attestation");
1618 let valid_sdist_build_attestation = format!("{valid_sdist}.build.attestation");
1619 let valid_sdist_frob_attestation = format!("{valid_sdist}.frob.attestation");
1620
1621 let valid_wheel = "dist/acme-1.2.3-py3-none-any.whl";
1622 let valid_wheel_publish_attestation = format!("{valid_wheel}.publish.attestation");
1623 let valid_wheel_build_attestation = format!("{valid_wheel}.build.attestation");
1624 let valid_wheel_frob_attestation = format!("{valid_wheel}.frob.attestation");
1625
1626 let invalid_sdist = "dist/nudnik.tar.gz";
1627 let invalid_wheel = "dist/nudnik.whl";
1628 let valid_sdist_invalid_attestation = format!("{valid_sdist}.attestation");
1629 let invalid_attestation = "dist/nudnik.attestation";
1630
1631 {
1633 let dists = [valid_sdist, valid_wheel];
1634
1635 let mut groups = group_files(dists.iter().map(PathBuf::from).collect(), false);
1636 groups.sort_by_key(|group| group.raw_filename.clone());
1637
1638 assert_debug_snapshot!(groups, @r#"
1639 [
1640 UploadDistribution {
1641 file: "dist/acme-1.2.3-py3-none-any.whl",
1642 raw_filename: "acme-1.2.3-py3-none-any.whl",
1643 filename: WheelFilename(
1644 WheelFilename {
1645 name: PackageName(
1646 "acme",
1647 ),
1648 version: "1.2.3",
1649 tags: Small {
1650 small: WheelTagSmall {
1651 python_tag: Python {
1652 major: 3,
1653 minor: None,
1654 },
1655 abi_tag: None,
1656 platform_tag: Any,
1657 },
1658 },
1659 },
1660 ),
1661 attestations: [],
1662 },
1663 UploadDistribution {
1664 file: "dist/acme-1.2.3.tar.gz",
1665 raw_filename: "acme-1.2.3.tar.gz",
1666 filename: SourceDistFilename(
1667 SourceDistFilename {
1668 name: PackageName(
1669 "acme",
1670 ),
1671 version: "1.2.3",
1672 extension: TarGz,
1673 },
1674 ),
1675 attestations: [],
1676 },
1677 ]
1678 "#);
1679 }
1680
1681 {
1683 let mut dists = vec![
1684 valid_sdist,
1685 &valid_sdist_publish_attestation,
1686 &valid_sdist_build_attestation,
1687 &valid_sdist_frob_attestation,
1688 valid_wheel,
1689 &valid_wheel_build_attestation,
1690 &valid_wheel_publish_attestation,
1691 &valid_wheel_frob_attestation,
1692 ];
1693
1694 allow_duplicates! {
1695 for _ in 0..5 {
1696 shuffle(&mut dists);
1697
1698 let mut groups =
1699 group_files(dists.iter().map(PathBuf::from).collect(), false);
1700 groups.sort_by_key(|group| group.raw_filename.clone());
1701
1702 assert_debug_snapshot!(groups, @r#"
1703 [
1704 UploadDistribution {
1705 file: "dist/acme-1.2.3-py3-none-any.whl",
1706 raw_filename: "acme-1.2.3-py3-none-any.whl",
1707 filename: WheelFilename(
1708 WheelFilename {
1709 name: PackageName(
1710 "acme",
1711 ),
1712 version: "1.2.3",
1713 tags: Small {
1714 small: WheelTagSmall {
1715 python_tag: Python {
1716 major: 3,
1717 minor: None,
1718 },
1719 abi_tag: None,
1720 platform_tag: Any,
1721 },
1722 },
1723 },
1724 ),
1725 attestations: [
1726 "dist/acme-1.2.3-py3-none-any.whl.build.attestation",
1727 "dist/acme-1.2.3-py3-none-any.whl.frob.attestation",
1728 "dist/acme-1.2.3-py3-none-any.whl.publish.attestation",
1729 ],
1730 },
1731 UploadDistribution {
1732 file: "dist/acme-1.2.3.tar.gz",
1733 raw_filename: "acme-1.2.3.tar.gz",
1734 filename: SourceDistFilename(
1735 SourceDistFilename {
1736 name: PackageName(
1737 "acme",
1738 ),
1739 version: "1.2.3",
1740 extension: TarGz,
1741 },
1742 ),
1743 attestations: [
1744 "dist/acme-1.2.3.tar.gz.build.attestation",
1745 "dist/acme-1.2.3.tar.gz.frob.attestation",
1746 "dist/acme-1.2.3.tar.gz.publish.attestation",
1747 ],
1748 },
1749 ]
1750 "#);
1751 }
1752 }
1753 }
1754
1755 {
1758 let mut dists = vec![
1759 valid_sdist,
1760 &valid_sdist_publish_attestation,
1761 &valid_sdist_build_attestation,
1762 &valid_sdist_frob_attestation,
1763 valid_wheel,
1764 &valid_wheel_build_attestation,
1765 &valid_wheel_publish_attestation,
1766 &valid_wheel_frob_attestation,
1767 ];
1768
1769 allow_duplicates! {
1770 for _ in 0..5 {
1771 shuffle(&mut dists);
1772
1773 let mut groups =
1774 group_files(dists.iter().map(PathBuf::from).collect(), true);
1775 groups.sort_by_key(|group| group.raw_filename.clone());
1776
1777 assert_debug_snapshot!(groups, @r#"
1778 [
1779 UploadDistribution {
1780 file: "dist/acme-1.2.3-py3-none-any.whl",
1781 raw_filename: "acme-1.2.3-py3-none-any.whl",
1782 filename: WheelFilename(
1783 WheelFilename {
1784 name: PackageName(
1785 "acme",
1786 ),
1787 version: "1.2.3",
1788 tags: Small {
1789 small: WheelTagSmall {
1790 python_tag: Python {
1791 major: 3,
1792 minor: None,
1793 },
1794 abi_tag: None,
1795 platform_tag: Any,
1796 },
1797 },
1798 },
1799 ),
1800 attestations: [],
1801 },
1802 UploadDistribution {
1803 file: "dist/acme-1.2.3.tar.gz",
1804 raw_filename: "acme-1.2.3.tar.gz",
1805 filename: SourceDistFilename(
1806 SourceDistFilename {
1807 name: PackageName(
1808 "acme",
1809 ),
1810 version: "1.2.3",
1811 extension: TarGz,
1812 },
1813 ),
1814 attestations: [],
1815 },
1816 ]
1817 "#);
1818 }
1819 }
1820 }
1821
1822 {
1824 let dists = [
1825 valid_sdist,
1826 &valid_sdist_frob_attestation,
1827 valid_wheel,
1828 &valid_wheel_build_attestation,
1829 invalid_sdist,
1830 invalid_wheel,
1831 &valid_sdist_invalid_attestation,
1832 invalid_attestation,
1833 ];
1834
1835 let groups = group_files(dists.iter().map(PathBuf::from).collect(), false);
1836 assert_debug_snapshot!(groups, @r#"
1837 [
1838 UploadDistribution {
1839 file: "dist/acme-1.2.3-py3-none-any.whl",
1840 raw_filename: "acme-1.2.3-py3-none-any.whl",
1841 filename: WheelFilename(
1842 WheelFilename {
1843 name: PackageName(
1844 "acme",
1845 ),
1846 version: "1.2.3",
1847 tags: Small {
1848 small: WheelTagSmall {
1849 python_tag: Python {
1850 major: 3,
1851 minor: None,
1852 },
1853 abi_tag: None,
1854 platform_tag: Any,
1855 },
1856 },
1857 },
1858 ),
1859 attestations: [
1860 "dist/acme-1.2.3-py3-none-any.whl.build.attestation",
1861 ],
1862 },
1863 UploadDistribution {
1864 file: "dist/acme-1.2.3.tar.gz",
1865 raw_filename: "acme-1.2.3.tar.gz",
1866 filename: SourceDistFilename(
1867 SourceDistFilename {
1868 name: PackageName(
1869 "acme",
1870 ),
1871 version: "1.2.3",
1872 extension: TarGz,
1873 },
1874 ),
1875 attestations: [
1876 "dist/acme-1.2.3.tar.gz.frob.attestation",
1877 ],
1878 },
1879 ]
1880 "#);
1881 }
1882 }
1883
1884 #[test]
1885 fn form_metadata_import_names() {
1886 let filename = DistFilename::try_from_normalized_filename("pkg-1.0.0.tar.gz").unwrap();
1887 let sha256_hash: HashDigest = "sha256:0123".parse().unwrap();
1888 let blake2b_hash: HashDigest = "blake2b:4567".parse().unwrap();
1889 let metadata = Metadata23 {
1890 metadata_version: "2.5".to_string(),
1891 name: "pkg".to_string(),
1892 version: "1.0.0".to_string(),
1893 requires_python: Some(">=3.12".to_string()),
1894 import_names: vec!["spam".to_string(), "spam.eggs; private".to_string()],
1895 import_namespaces: vec!["zope".to_string()],
1896 ..Default::default()
1897 };
1898
1899 let form_metadata =
1900 FormMetadata::from_metadata(metadata, &filename, &sha256_hash, &blake2b_hash);
1901 let formatted_metadata = form_metadata
1902 .iter()
1903 .map(|(key, value)| format!("{key}: {value}"))
1904 .join("\n");
1905
1906 assert_snapshot!(formatted_metadata, @r###"
1907 :action: file_upload
1908 sha256_digest: 0123
1909 blake2_256_digest: 4567
1910 protocol_version: 1
1911 metadata_version: 2.5
1912 name: pkg
1913 version: 1.0.0
1914 filetype: sdist
1915 pyversion: source
1916 requires_python: >=3.12
1917 import_names: spam
1918 import_names: spam.eggs; private
1919 import_namespaces: zope
1920 "###);
1921 }
1922
1923 #[tokio::test]
1925 async fn upload_request_source_dist() {
1926 let group = {
1927 let raw_filename = "tqdm-999.0.0.tar.gz";
1928 let file = PathBuf::from("../../test/links/").join(raw_filename);
1929 let filename = DistFilename::try_from_normalized_filename(raw_filename).unwrap();
1930
1931 UploadDistribution {
1932 file,
1933 raw_filename: raw_filename.to_string(),
1934 filename,
1935 attestations: vec![],
1936 }
1937 };
1938
1939 let form_metadata =
1940 FormMetadata::read_from_file(&group.file, &group.filename, Arc::new(DummyReporter))
1941 .await
1942 .unwrap();
1943
1944 let formatted_metadata = form_metadata
1945 .iter()
1946 .map(|(k, v)| format!("{k}: {v}"))
1947 .join("\n");
1948 assert_snapshot!(&formatted_metadata, @"
1949 :action: file_upload
1950 sha256_digest: 89fa05cffa7f457658373b85de302d24d0c205ceda2819a8739e324b75e9430b
1951 blake2_256_digest: 40ab79b48c4e289e4990f7e689177adae4096c07a634034eb1d10c0b6700e4d2
1952 protocol_version: 1
1953 metadata_version: 2.3
1954 name: tqdm
1955 version: 999.0.0
1956 filetype: sdist
1957 pyversion: source
1958 author_email: Charlie Marsh <charlie.r.marsh@gmail.com>
1959 description: # tqdm
1960
1961 [](https://pypi.org/project/tqdm)
1962 [](https://pypi.org/project/tqdm)
1963
1964 -----
1965
1966 **Table of Contents**
1967
1968 - [Installation](#installation)
1969 - [License](#license)
1970
1971 ## Installation
1972
1973 ```console
1974 pip install tqdm
1975 ```
1976
1977 ## License
1978
1979 `tqdm` is distributed under the terms of the [MIT](https://spdx.org/licenses/MIT.html) license.
1980
1981 description_content_type: text/markdown
1982 license_expression: MIT
1983 requires_python: >=3.8
1984 classifiers: Development Status :: 4 - Beta
1985 classifiers: Programming Language :: Python
1986 classifiers: Programming Language :: Python :: 3.8
1987 classifiers: Programming Language :: Python :: 3.9
1988 classifiers: Programming Language :: Python :: 3.10
1989 classifiers: Programming Language :: Python :: 3.11
1990 classifiers: Programming Language :: Python :: 3.12
1991 classifiers: Programming Language :: Python :: Implementation :: CPython
1992 classifiers: Programming Language :: Python :: Implementation :: PyPy
1993 license_file: LICENSE.txt
1994 project_urls: Documentation, https://github.com/unknown/tqdm#readme
1995 project_urls: Issues, https://github.com/unknown/tqdm/issues
1996 project_urls: Source, https://github.com/unknown/tqdm
1997 ");
1998
1999 let client = BaseClientBuilder::default()
2000 .build()
2001 .expect("failed to build base client");
2002 let (request, _) = build_upload_request(
2003 &group,
2004 &DisplaySafeUrl::parse("https://example.org/upload").unwrap(),
2005 &client,
2006 &Credentials::basic(Some("ferris".to_string()), Some("F3RR!S".to_string())),
2007 &form_metadata,
2008 Arc::new(DummyReporter),
2009 )
2010 .await
2011 .unwrap();
2012
2013 insta::with_settings!({
2014 filters => [("boundary=[0-9a-f-]+", "boundary=[...]")],
2015 }, {
2016 assert_debug_snapshot!(&request.raw_builder(), @r#"
2017 RequestBuilder {
2018 inner: RequestBuilder {
2019 method: POST,
2020 url: Url {
2021 scheme: "https",
2022 cannot_be_a_base: false,
2023 username: "",
2024 password: None,
2025 host: Some(
2026 Domain(
2027 "example.org",
2028 ),
2029 ),
2030 port: None,
2031 path: "/upload",
2032 query: None,
2033 fragment: None,
2034 },
2035 headers: {
2036 "content-type": "multipart/form-data; boundary=[...]",
2037 "content-length": "7000",
2038 "accept": "application/json;q=0.9, text/plain;q=0.8, text/html;q=0.7",
2039 "authorization": Sensitive,
2040 },
2041 },
2042 ..
2043 }
2044 "#);
2045 });
2046 }
2047
2048 #[tokio::test]
2050 async fn upload_request_wheel() {
2051 let group = {
2052 let raw_filename = "tqdm-4.66.1-py3-none-manylinux_2_12_x86_64.manylinux2010_x86_64.musllinux_1_1_x86_64.whl";
2053 let file = PathBuf::from("../../test/links/").join(raw_filename);
2054 let filename = DistFilename::try_from_normalized_filename(raw_filename).unwrap();
2055
2056 UploadDistribution {
2057 file,
2058 raw_filename: raw_filename.to_string(),
2059 filename,
2060 attestations: vec![],
2061 }
2062 };
2063
2064 let form_metadata =
2065 FormMetadata::read_from_file(&group.file, &group.filename, Arc::new(DummyReporter))
2066 .await
2067 .unwrap();
2068
2069 let formatted_metadata = form_metadata
2070 .iter()
2071 .map(|(k, v)| format!("{k}: {v}"))
2072 .join("\n");
2073 assert_snapshot!(&formatted_metadata, @r#"
2074 :action: file_upload
2075 sha256_digest: 0d88ca657bc6b64995ca416e0c59c71af85cc10015d940fa446c42a8b485ee1c
2076 blake2_256_digest: 33d4e92517a16e3fa0c0893de0c7e4d46a2c38adab148dd2ff66eb47481d19cd
2077 protocol_version: 1
2078 metadata_version: 2.1
2079 name: tqdm
2080 version: 4.66.1
2081 filetype: bdist_wheel
2082 pyversion: py3
2083 description_content_type: text/x-rst
2084 keywords: progressbar,progressmeter,progress,bar,meter,rate,eta,console,terminal,time
2085 license: MPL-2.0 AND MIT
2086 maintainer_email: tqdm developers <devs@tqdm.ml>
2087 summary: Fast, Extensible Progress Meter
2088 requires_python: >=3.7
2089 classifiers: Development Status :: 5 - Production/Stable
2090 classifiers: Environment :: Console
2091 classifiers: Environment :: MacOS X
2092 classifiers: Environment :: Other Environment
2093 classifiers: Environment :: Win32 (MS Windows)
2094 classifiers: Environment :: X11 Applications
2095 classifiers: Framework :: IPython
2096 classifiers: Framework :: Jupyter
2097 classifiers: Intended Audience :: Developers
2098 classifiers: Intended Audience :: Education
2099 classifiers: Intended Audience :: End Users/Desktop
2100 classifiers: Intended Audience :: Other Audience
2101 classifiers: Intended Audience :: System Administrators
2102 classifiers: License :: OSI Approved :: MIT License
2103 classifiers: License :: OSI Approved :: Mozilla Public License 2.0 (MPL 2.0)
2104 classifiers: Operating System :: MacOS
2105 classifiers: Operating System :: MacOS :: MacOS X
2106 classifiers: Operating System :: Microsoft
2107 classifiers: Operating System :: Microsoft :: MS-DOS
2108 classifiers: Operating System :: Microsoft :: Windows
2109 classifiers: Operating System :: POSIX
2110 classifiers: Operating System :: POSIX :: BSD
2111 classifiers: Operating System :: POSIX :: BSD :: FreeBSD
2112 classifiers: Operating System :: POSIX :: Linux
2113 classifiers: Operating System :: POSIX :: SunOS/Solaris
2114 classifiers: Operating System :: Unix
2115 classifiers: Programming Language :: Python
2116 classifiers: Programming Language :: Python :: 3
2117 classifiers: Programming Language :: Python :: 3.7
2118 classifiers: Programming Language :: Python :: 3.8
2119 classifiers: Programming Language :: Python :: 3.9
2120 classifiers: Programming Language :: Python :: 3.10
2121 classifiers: Programming Language :: Python :: 3.11
2122 classifiers: Programming Language :: Python :: 3 :: Only
2123 classifiers: Programming Language :: Python :: Implementation
2124 classifiers: Programming Language :: Python :: Implementation :: IronPython
2125 classifiers: Programming Language :: Python :: Implementation :: PyPy
2126 classifiers: Programming Language :: Unix Shell
2127 classifiers: Topic :: Desktop Environment
2128 classifiers: Topic :: Education :: Computer Aided Instruction (CAI)
2129 classifiers: Topic :: Education :: Testing
2130 classifiers: Topic :: Office/Business
2131 classifiers: Topic :: Other/Nonlisted Topic
2132 classifiers: Topic :: Software Development :: Build Tools
2133 classifiers: Topic :: Software Development :: Libraries
2134 classifiers: Topic :: Software Development :: Libraries :: Python Modules
2135 classifiers: Topic :: Software Development :: Pre-processors
2136 classifiers: Topic :: Software Development :: User Interfaces
2137 classifiers: Topic :: System :: Installation/Setup
2138 classifiers: Topic :: System :: Logging
2139 classifiers: Topic :: System :: Monitoring
2140 classifiers: Topic :: System :: Shells
2141 classifiers: Topic :: Terminals
2142 classifiers: Topic :: Utilities
2143 license_file: LICENCE
2144 project_urls: homepage, https://tqdm.github.io
2145 project_urls: repository, https://github.com/tqdm/tqdm
2146 project_urls: changelog, https://tqdm.github.io/releases
2147 project_urls: wiki, https://github.com/tqdm/tqdm/wiki
2148 provides_extra: dev
2149 provides_extra: notebook
2150 provides_extra: slack
2151 provides_extra: telegram
2152 requires_dist: colorama ; platform_system == "Windows"
2153 requires_dist: pytest >=6 ; extra == 'dev'
2154 requires_dist: pytest-cov ; extra == 'dev'
2155 requires_dist: pytest-timeout ; extra == 'dev'
2156 requires_dist: pytest-xdist ; extra == 'dev'
2157 requires_dist: ipywidgets >=6 ; extra == 'notebook'
2158 requires_dist: slack-sdk ; extra == 'slack'
2159 requires_dist: requests ; extra == 'telegram'
2160 "#);
2161
2162 let client = BaseClientBuilder::default()
2163 .build()
2164 .expect("failed to build base client");
2165 let (request, _) = build_upload_request(
2166 &group,
2167 &DisplaySafeUrl::parse("https://example.org/upload").unwrap(),
2168 &client,
2169 &Credentials::basic(Some("ferris".to_string()), Some("F3RR!S".to_string())),
2170 &form_metadata,
2171 Arc::new(DummyReporter),
2172 )
2173 .await
2174 .unwrap();
2175
2176 insta::with_settings!({
2177 filters => [("boundary=[0-9a-f-]+", "boundary=[...]")],
2178 }, {
2179 assert_debug_snapshot!(&request.raw_builder(), @r#"
2180 RequestBuilder {
2181 inner: RequestBuilder {
2182 method: POST,
2183 url: Url {
2184 scheme: "https",
2185 cannot_be_a_base: false,
2186 username: "",
2187 password: None,
2188 host: Some(
2189 Domain(
2190 "example.org",
2191 ),
2192 ),
2193 port: None,
2194 path: "/upload",
2195 query: None,
2196 fragment: None,
2197 },
2198 headers: {
2199 "content-type": "multipart/form-data; boundary=[...]",
2200 "content-length": "19527",
2201 "accept": "application/json;q=0.9, text/plain;q=0.8, text/html;q=0.7",
2202 "authorization": Sensitive,
2203 },
2204 },
2205 ..
2206 }
2207 "#);
2208 });
2209 }
2210
2211 #[tokio::test]
2212 async fn upload_redirect_308() {
2213 let mock_server = MockServer::start().await;
2214 Mock::given(method("POST"))
2215 .and(path("/final"))
2216 .respond_with(
2217 ResponseTemplate::new(308)
2218 .insert_header("Location", format!("{}/final/", mock_server.uri())),
2219 )
2220 .mount(&mock_server)
2221 .await;
2222 Mock::given(method("POST"))
2223 .and(path("/final/"))
2224 .respond_with(ResponseTemplate::new(200))
2225 .mount(&mock_server)
2226 .await;
2227
2228 assert!(mock_server_upload(&mock_server).await.unwrap());
2229 }
2230
2231 #[tokio::test]
2232 async fn upload_infinite_redirects() {
2233 let mock_server = MockServer::start().await;
2234 Mock::given(method("POST"))
2235 .and(path("/final"))
2236 .respond_with(
2237 ResponseTemplate::new(308)
2238 .insert_header("Location", format!("{}/final/", mock_server.uri())),
2239 )
2240 .mount(&mock_server)
2241 .await;
2242 Mock::given(method("POST"))
2243 .and(path("/final/"))
2244 .respond_with(
2245 ResponseTemplate::new(308)
2246 .insert_header("Location", format!("{}/final", mock_server.uri())),
2247 )
2248 .mount(&mock_server)
2249 .await;
2250
2251 let err = mock_server_upload(&mock_server).await.unwrap_err();
2252
2253 let mut capture = String::new();
2254 write_error_chain_with_options(&err, ErrorOptions::default().with_stream(&mut capture))
2255 .unwrap();
2256
2257 let capture = capture.replace(&mock_server.uri(), "[SERVER]");
2258 let capture = anstream::adapter::strip_str(&capture);
2259 assert_snapshot!(
2260 &capture,
2261 @"
2262 error: Failed to publish `../../test/links/tqdm-4.66.1-py3-none-manylinux_2_12_x86_64.manylinux2010_x86_64.musllinux_1_1_x86_64.whl` to [SERVER]/final
2263 Caused by: Too many redirects, only 10 redirects are allowed
2264 "
2265 );
2266 }
2267
2268 #[tokio::test]
2269 async fn upload_redirect_different_realm() {
2270 let mock_server = MockServer::start().await;
2271 Mock::given(method("POST"))
2272 .and(path("/final"))
2273 .respond_with(
2274 ResponseTemplate::new(308)
2275 .insert_header("Location", "https://different.auth.tld/final/"),
2276 )
2277 .mount(&mock_server)
2278 .await;
2279
2280 let err = mock_server_upload(&mock_server).await.unwrap_err();
2281
2282 let mut capture = String::new();
2283 write_error_chain_with_options(&err, ErrorOptions::default().with_stream(&mut capture))
2284 .unwrap();
2285
2286 let capture = capture.replace(&mock_server.uri(), "[SERVER]");
2287 let capture = anstream::adapter::strip_str(&capture);
2288 assert_snapshot!(
2289 &capture,
2290 @"
2291 error: Failed to publish `../../test/links/tqdm-4.66.1-py3-none-manylinux_2_12_x86_64.manylinux2010_x86_64.musllinux_1_1_x86_64.whl` to https://different.auth.tld/final/
2292 Caused by: Redirected URL is not in the same realm. Redirected to: https://different.auth.tld/final/
2293 "
2294 );
2295 }
2296
2297 #[tokio::test]
2299 async fn upload_error_pypi_json() {
2300 let mock_server = MockServer::start().await;
2301 Mock::given(method("POST"))
2302 .and(path("/final"))
2303 .respond_with(
2304 ResponseTemplate::new(400)
2305 .insert_header("content-type", "application/json")
2306 .set_body_raw(
2307 r#"{"message": "The server could not comply with the request since it is either malformed or otherwise incorrect.\n\n\nError: Use 'source' as Python version for an sdist.\n\n", "code": "400 Error: Use 'source' as Python version for an sdist.", "title": "Bad Request"}"#,
2308 "application/json",
2309 ),
2310 )
2311 .mount(&mock_server)
2312 .await;
2313
2314 let err = mock_server_upload(&mock_server).await.unwrap_err();
2315
2316 let mut capture = String::new();
2317 write_error_chain_with_options(&err, ErrorOptions::default().with_stream(&mut capture))
2318 .unwrap();
2319
2320 let capture = capture.replace(&mock_server.uri(), "[SERVER]");
2321 let capture = anstream::adapter::strip_str(&capture);
2322 assert_snapshot!(
2323 &capture,
2324 @"
2325 error: Failed to publish `../../test/links/tqdm-4.66.1-py3-none-manylinux_2_12_x86_64.manylinux2010_x86_64.musllinux_1_1_x86_64.whl` to [SERVER]/final
2326 Caused by: Server returned status code 400 Bad Request. Server says: 400 Error: Use 'source' as Python version for an sdist.
2327 "
2328 );
2329 }
2330
2331 #[tokio::test]
2333 async fn upload_error_problem_details() {
2334 let mock_server = MockServer::start().await;
2335 Mock::given(method("POST"))
2336 .and(path("/final"))
2337 .respond_with(
2338 ResponseTemplate::new(400)
2339 .insert_header(
2340 "content-type",
2341 uv_client::ProblemDetails::CONTENT_TYPE,
2342 )
2343 .set_body_raw(
2344 r#"{"type": "about:blank", "status": 400, "title": "Bad Request", "detail": "Missing required field `name`"}"#,
2345 uv_client::ProblemDetails::CONTENT_TYPE,
2346 ),
2347 )
2348 .mount(&mock_server)
2349 .await;
2350
2351 let err = mock_server_upload(&mock_server).await.unwrap_err();
2352
2353 let mut capture = String::new();
2354 write_error_chain_with_options(&err, ErrorOptions::default().with_stream(&mut capture))
2355 .unwrap();
2356
2357 let capture = capture.replace(&mock_server.uri(), "[SERVER]");
2358 let capture = anstream::adapter::strip_str(&capture);
2359 assert_snapshot!(
2360 &capture,
2361 @"
2362 error: Failed to publish `../../test/links/tqdm-4.66.1-py3-none-manylinux_2_12_x86_64.manylinux2010_x86_64.musllinux_1_1_x86_64.whl` to [SERVER]/final
2363 Caused by: Server returned status code 400 Bad Request. Server message: Bad Request, Missing required field `name`
2364 "
2365 );
2366 }
2367}