From ea8fc611dc2d2dcc63cf13575680bc556a4c6f73 Mon Sep 17 00:00:00 2001 From: konstin Date: Tue, 17 Sep 2024 16:09:02 +0200 Subject: [PATCH] Add `uv publish`: Basic upload with username/password or keyring What works: * Basic upload to pypi with username/password (username must be `__token__`, but it uses the same HTTP headers) * Error messages What's missing: * Testing on CI. I have a test script for testpypi for both username/password and keyring (next PR), but we need to run it in CI in a secure way. * Trusted publishing from GitHub actions with OIDC, and testing for it. * Other registries? --- Cargo.lock | 189 +++++- Cargo.toml | 6 +- crates/distribution-filename/src/lib.rs | 8 + crates/uv-auth/src/middleware.rs | 86 ++- crates/uv-cli/src/lib.rs | 61 +- crates/uv-client/src/base_client.rs | 38 +- crates/uv-publish/Cargo.toml | 41 ++ crates/uv-publish/src/lib.rs | 789 ++++++++++++++++++++++++ crates/uv-settings/Cargo.toml | 1 + crates/uv-settings/src/combine.rs | 7 +- crates/uv-settings/src/settings.rs | 24 +- crates/uv/Cargo.toml | 1 + crates/uv/src/commands/mod.rs | 2 + crates/uv/src/commands/publish.rs | 79 +++ crates/uv/src/lib.rs | 26 + crates/uv/src/settings.rs | 68 +- crates/uv/tests/common/mod.rs | 17 +- crates/uv/tests/help.rs | 7 + crates/uv/tests/publish.rs | 51 ++ docs/configuration/environment.md | 6 + docs/guides/publish.md | 13 +- docs/reference/cli.md | 129 ++++ docs/reference/settings.md | 26 + uv.schema.json | 8 + 24 files changed, 1620 insertions(+), 63 deletions(-) create mode 100644 crates/uv-publish/Cargo.toml create mode 100644 crates/uv-publish/src/lib.rs create mode 100644 crates/uv/src/commands/publish.rs create mode 100644 crates/uv/tests/publish.rs diff --git a/Cargo.lock b/Cargo.lock index 569ebca0c44a..1e9cf7d7216e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -34,6 +34,18 @@ dependencies = [ "version_check", ] +[[package]] +name = "ahash" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e89da841a80418a9b391ebaea17f5c112ffaaa96f621d2c285b5174da76b9011" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + [[package]] name = "aho-corasick" version = "1.1.3" @@ -43,6 +55,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "allocator-api2" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c6cb57a04249c6480766f7f7cef5467412af1490f8d1e243141daddada3264f" + [[package]] name = "anes" version = "0.1.6" @@ -603,6 +621,16 @@ dependencies = [ "encoding_rs", ] +[[package]] +name = "chumsky" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8eebd66744a15ded14960ab4ccdbfb51ad3b81f51f3f04a80adac98c985396c9" +dependencies = [ + "hashbrown 0.14.5", + "stacker", +] + [[package]] name = "ciborium" version = "0.2.2" @@ -968,6 +996,15 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "092966b41edc516079bdf31ec78a2e0588d1d0c08f78b91d8307215928642b2b" +[[package]] +name = "deranged" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b42b6fa04a440b495c8b04d0e71b707c585f83cb9cb28cf8cd0d976c315e31b4" +dependencies = [ + "powerfmt", +] + [[package]] name = "diff" version = "0.1.13" @@ -1482,7 +1519,7 @@ version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" dependencies = [ - "ahash", + "ahash 0.7.8", ] [[package]] @@ -1490,6 +1527,10 @@ name = "hashbrown" version = "0.14.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash 0.8.11", + "allocator-api2", +] [[package]] name = "heck" @@ -2168,6 +2209,16 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + [[package]] name = "miniz_oxide" version = "0.7.4" @@ -2267,6 +2318,12 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "num-conv" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" + [[package]] name = "num-traits" version = "0.2.19" @@ -2625,6 +2682,12 @@ version = "1.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "da544ee218f0d287a911e9c99a39a8c9bc8fcad3cb8db5959940044ecfc67265" +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + [[package]] name = "ppv-lite86" version = "0.2.20" @@ -2694,6 +2757,15 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "psm" +version = "0.1.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa37f80ca58604976033fae9515a8a2989fc13797d953f7c04fb8fa36a11f205" +dependencies = [ + "cc", +] + [[package]] name = "ptr_meta" version = "0.1.4" @@ -2825,6 +2897,21 @@ dependencies = [ "uv-normalize", ] +[[package]] +name = "python-pkginfo" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4320ca452fe003f8a07afb8e30c315bbd813ae8105f454ddefebf15a24021e1f" +dependencies = [ + "flate2", + "fs-err", + "mailparse", + "rfc2047-decoder", + "tar", + "thiserror", + "zip", +] + [[package]] name = "quinn" version = "0.11.5" @@ -3104,6 +3191,7 @@ dependencies = [ "js-sys", "log", "mime", + "mime_guess", "once_cell", "percent-encoding", "pin-project-lite", @@ -3192,6 +3280,20 @@ dependencies = [ "rand", ] +[[package]] +name = "rfc2047-decoder" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e90a668c463c412c3118ae1883e18b53d812c349f5af7a06de3ba4bb0c17cc73" +dependencies = [ + "base64 0.21.7", + "charset", + "chumsky", + "memchr", + "quoted_printable", + "thiserror", +] + [[package]] name = "rgb" version = "0.8.50" @@ -3697,6 +3799,19 @@ version = "0.9.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +[[package]] +name = "stacker" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799c883d55abdb5e98af1a7b3f23b9b6de8ecada0ecac058672d7635eb48ca7b" +dependencies = [ + "cc", + "cfg-if", + "libc", + "psm", + "windows-sys 0.59.0", +] + [[package]] name = "strict-num" version = "0.1.1" @@ -3828,6 +3943,17 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" +[[package]] +name = "tar" +version = "0.4.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb797dad5fb5b76fcf519e702f4a589483b5ef06567f160c392832c1f5e44909" +dependencies = [ + "filetime", + "libc", + "xattr", +] + [[package]] name = "target-lexicon" version = "0.12.16" @@ -3996,6 +4122,25 @@ dependencies = [ "tikv-jemalloc-sys", ] +[[package]] +name = "time" +version = "0.3.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dfd88e563464686c916c7e46e623e520ddc6d79fa6641390f2e3fa86e83e885" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde", + "time-core", +] + +[[package]] +name = "time-core" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef927ca75afb808a4d64dd374f00a2adf8d0fcff8e7b184af886c3c87ec4a3f3" + [[package]] name = "tiny-skia" version = "0.8.4" @@ -4307,6 +4452,15 @@ version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed646292ffc8188ef8ea4d1e0e0150fb15a5c2e12ad9b8fc191ae7a8a7f3c4b9" +[[package]] +name = "unicase" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7d2d4dafb69621809a81864c9c1b864479e1235c0dd4e199924b9742439ed89" +dependencies = [ + "version_check", +] + [[package]] name = "unicode-bidi" version = "0.3.15" @@ -4527,6 +4681,7 @@ dependencies = [ "uv-git", "uv-installer", "uv-normalize", + "uv-publish", "uv-python", "uv-requirements", "uv-resolver", @@ -4997,6 +5152,35 @@ dependencies = [ "thiserror", ] +[[package]] +name = "uv-publish" +version = "0.1.0" +dependencies = [ + "async-compression", + "base64 0.22.1", + "distribution-filename", + "fs-err", + "futures", + "glob", + "insta", + "itertools 0.13.0", + "krata-tokio-tar", + "python-pkginfo", + "reqwest", + "reqwest-middleware", + "serde", + "serde_json", + "sha2", + "thiserror", + "tokio", + "tracing", + "url", + "uv-client", + "uv-fs", + "uv-metadata", + "uv-warnings", +] + [[package]] name = "uv-python" version = "0.0.1" @@ -5170,6 +5354,7 @@ dependencies = [ "thiserror", "toml", "tracing", + "url", "uv-cache-info", "uv-configuration", "uv-fs", @@ -5933,9 +6118,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "760394e246e4c28189f19d488c058bf16f564016aefac5d32bb1f3b51d5e9261" dependencies = [ "byteorder", + "bzip2", "crc32fast", "crossbeam-utils", "flate2", + "time", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index ad6da60c5fa3..e8a74b58a53c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -45,6 +45,7 @@ uv-metadata = { path = "crates/uv-metadata" } uv-normalize = { path = "crates/uv-normalize" } uv-options-metadata = { path = "crates/uv-options-metadata" } uv-pubgrub = { path = "crates/uv-pubgrub" } +uv-publish = { path = "crates/uv-publish" } uv-python = { path = "crates/uv-python" } uv-requirements = { path = "crates/uv-requirements" } uv-resolver = { path = "crates/uv-resolver" } @@ -118,12 +119,13 @@ proc-macro2 = { version = "1.0.86" } pubgrub = { git = "https://github.com/astral-sh/pubgrub", rev = "388685a8711092971930986644cfed152d1a1f6c" } pyo3 = { version = "0.21.2" } pyo3-log = { version = "0.10.0" } +python-pkginfo = { version = "0.6.2" } quote = { version = "1.0.37" } rayon = { version = "1.10.0" } reflink-copy = { version = "0.1.19" } regex = { version = "1.10.6" } -reqwest = { version = "0.12.7", default-features = false, features = ["json", "gzip", "stream", "rustls-tls", "rustls-tls-native-roots"] } -reqwest-middleware = { git = "https://github.com/astral-sh/reqwest-middleware", rev = "5e3eaf254b5bd481c75d2710eed055f95b756913" } +reqwest = { version = "0.12.7", default-features = false, features = ["json", "gzip", "stream", "rustls-tls", "rustls-tls-native-roots", "multipart"] } +reqwest-middleware = { git = "https://github.com/astral-sh/reqwest-middleware", rev = "5e3eaf254b5bd481c75d2710eed055f95b756913", features = ["multipart"] } reqwest-retry = { git = "https://github.com/astral-sh/reqwest-middleware", rev = "5e3eaf254b5bd481c75d2710eed055f95b756913" } rkyv = { version = "0.7.45", features = ["strict", "validation"] } rmp-serde = { version = "1.3.0" } diff --git a/crates/distribution-filename/src/lib.rs b/crates/distribution-filename/src/lib.rs index dd06a89363b8..9c79895586d0 100644 --- a/crates/distribution-filename/src/lib.rs +++ b/crates/distribution-filename/src/lib.rs @@ -67,6 +67,14 @@ impl DistFilename { Self::WheelFilename(filename) => &filename.version, } } + + /// Whether the file is a `bdist_wheel` or an `sdist`. + pub fn filetype(&self) -> &'static str { + match self { + Self::SourceDistFilename(_) => "sdist", + Self::WheelFilename(_) => "bdist_wheel", + } + } } impl Display for DistFilename { diff --git a/crates/uv-auth/src/middleware.rs b/crates/uv-auth/src/middleware.rs index 1c938d5cba8e..d593c69c010e 100644 --- a/crates/uv-auth/src/middleware.rs +++ b/crates/uv-auth/src/middleware.rs @@ -8,7 +8,7 @@ use crate::{ realm::Realm, CredentialsCache, KeyringProvider, CREDENTIALS_CACHE, }; -use anyhow::anyhow; +use anyhow::{anyhow, format_err}; use netrc::Netrc; use reqwest::{Request, Response}; use reqwest_middleware::{Error, Middleware, Next}; @@ -22,6 +22,11 @@ pub struct AuthMiddleware { netrc: Option, keyring: Option, cache: Option, + /// We know that the endpoint needs authentication, so we don't try to send an unauthenticated + /// request. + /// + /// This is also useful since it avoids cloning an unclonable request. + only_authenticated: bool, } impl AuthMiddleware { @@ -30,6 +35,7 @@ impl AuthMiddleware { netrc: Netrc::new().ok(), keyring: None, cache: None, + only_authenticated: false, } } @@ -56,6 +62,16 @@ impl AuthMiddleware { self } + /// We know that the endpoint needs authentication, so we don't try to send an unauthenticated + /// request. + /// + /// This is also useful since it avoids cloning an unclonable request. + #[must_use] + pub fn with_only_authenticated(mut self, only_authenticated: bool) -> Self { + self.only_authenticated = only_authenticated; + self + } + /// Get the configured authentication store. /// /// If not set, the global store is used. @@ -198,32 +214,42 @@ impl Middleware for AuthMiddleware { .as_ref() .is_some_and(|credentials| credentials.username().is_some()); - // Otherwise, attempt an anonymous request - trace!("Attempting unauthenticated request for {url}"); - - // - // Clone the request so we can retry it on authentication failure - let mut retry_request = request.try_clone().ok_or_else(|| { - Error::Middleware(anyhow!( - "Request object is not cloneable. Are you passing a streaming body?".to_string() - )) - })?; - - let response = next.clone().run(request, extensions).await?; - - // If we don't fail with authorization related codes, return the response - if !matches!( - response.status(), - StatusCode::FORBIDDEN | StatusCode::NOT_FOUND | StatusCode::UNAUTHORIZED - ) { - return Ok(response); - } + let (mut retry_request, response) = if self.only_authenticated { + // For endpoints where we require the user to provide credentials, we don't try the + // unauthenticated request first. + trace!("Checking for credentials for {url}"); + (request, None) + } else { + // Otherwise, attempt an anonymous request + trace!("Attempting unauthenticated request for {url}"); + + // + // Clone the request so we can retry it on authentication failure + let retry_request = request.try_clone().ok_or_else(|| { + Error::Middleware(anyhow!( + "Request object is not cloneable. Are you passing a streaming body?" + .to_string() + )) + })?; + + let response = next.clone().run(request, extensions).await?; + + // If we don't fail with authorization related codes, return the response + if !matches!( + response.status(), + StatusCode::FORBIDDEN | StatusCode::NOT_FOUND | StatusCode::UNAUTHORIZED + ) { + return Ok(response); + } - // Otherwise, search for credentials - trace!( - "Request for {url} failed with {}, checking for credentials", - response.status() - ); + // Otherwise, search for credentials + trace!( + "Request for {url} failed with {}, checking for credentials", + response.status() + ); + + (retry_request, Some(response)) + }; // Check in the cache first let credentials = self.cache().get_realm( @@ -265,7 +291,13 @@ impl Middleware for AuthMiddleware { } } - Ok(response) + if let Some(response) = response { + Ok(response) + } else { + Err(Error::Middleware(format_err!( + "Missing credentials for {url}" + ))) + } } } diff --git a/crates/uv-cli/src/lib.rs b/crates/uv-cli/src/lib.rs index e9515a9e1262..46868f0a6265 100644 --- a/crates/uv-cli/src/lib.rs +++ b/crates/uv-cli/src/lib.rs @@ -1,8 +1,3 @@ -use std::ffi::OsString; -use std::ops::Deref; -use std::path::PathBuf; -use std::str::FromStr; - use anyhow::{anyhow, Result}; use clap::builder::styling::{AnsiColor, Effects, Style}; use clap::builder::Styles; @@ -10,6 +5,11 @@ use clap::{Args, Parser, Subcommand}; use distribution_types::{FlatIndexLocation, IndexUrl}; use pep508_rs::Requirement; use pypi_types::VerbatimParsedUrl; +use std::ffi::OsString; +use std::ops::Deref; +use std::path::PathBuf; +use std::str::FromStr; +use url::Url; use uv_cache::CacheArgs; use uv_configuration::{ ConfigSettingEntry, ExportFormat, IndexStrategy, KeyringProviderType, PackageNameSpecifier, @@ -367,6 +367,8 @@ pub enum Commands { after_long_help = "" )] Build(BuildArgs), + /// Upload distributions to an index. + Publish(PublishArgs), /// Manage uv's cache. #[command( after_help = "Use `uv help cache` for more details.", @@ -4287,3 +4289,52 @@ pub struct DisplayTreeArgs { #[arg(long, alias = "reverse")] pub invert: bool, } + +#[derive(Args, Debug)] +pub struct PublishArgs { + /// The paths to the files to uploads, as glob expressions. + #[arg(default_value = "dist/*")] + pub files: Vec, + + /// The URL to the upload endpoint. Note: This is usually not the same as the index URL. + /// + /// The default value is publish URL for PyPI (). + #[arg(long, env = "UV_PUBLISH_URL")] + pub publish_url: Option, + + /// The username for the upload. + #[arg(short, long, env = "UV_PUBLISH_USERNAME")] + pub username: Option, + + /// The password for the upload. + #[arg(short, long, env = "UV_PUBLISH_PASSWORD")] + pub password: Option, + + /// Attempt to use `keyring` for authentication for remote requirements files. + /// + /// At present, only `--keyring-provider subprocess` is supported, which configures uv to + /// use the `keyring` CLI to handle authentication. + /// + /// Defaults to `disabled`. + #[arg(long, value_enum, env = "UV_KEYRING_PROVIDER")] + pub keyring_provider: Option, + + /// Allow insecure connections to a host. + /// + /// Can be provided multiple times. + /// + /// Expects to receive either a hostname (e.g., `localhost`), a host-port pair (e.g., + /// `localhost:8080`), or a URL (e.g., `https://localhost`). + /// + /// WARNING: Hosts included in this list will not be verified against the system's certificate + /// store. Only use `--allow-insecure-host` in a secure network with verified sources, as it + /// bypasses SSL verification and could expose you to MITM attacks. + #[arg( + long, + alias = "trusted-host", + env = "UV_INSECURE_HOST", + value_delimiter = ' ', + value_parser = parse_insecure_host, + )] + pub allow_insecure_host: Option>>, +} diff --git a/crates/uv-client/src/base_client.rs b/crates/uv-client/src/base_client.rs index 189d960ce7ea..6dab30880dc8 100644 --- a/crates/uv-client/src/base_client.rs +++ b/crates/uv-client/src/base_client.rs @@ -36,6 +36,7 @@ pub struct BaseClientBuilder<'a> { client: Option, markers: Option<&'a MarkerEnvironment>, platform: Option<&'a Platform>, + only_authenticated: bool, } impl Default for BaseClientBuilder<'_> { @@ -55,6 +56,7 @@ impl BaseClientBuilder<'_> { client: None, markers: None, platform: None, + only_authenticated: false, } } } @@ -108,6 +110,12 @@ impl<'a> BaseClientBuilder<'a> { self } + #[must_use] + pub fn only_authenticated(mut self, only_authenticated: bool) -> Self { + self.only_authenticated = only_authenticated; + self + } + pub fn is_offline(&self) -> bool { matches!(self.connectivity, Connectivity::Offline) } @@ -230,20 +238,26 @@ impl<'a> BaseClientBuilder<'a> { fn apply_middleware(&self, client: Client) -> ClientWithMiddleware { match self.connectivity { Connectivity::Online => { - let client = reqwest_middleware::ClientBuilder::new(client); - - // Initialize the retry strategy. - let retry_policy = - ExponentialBackoff::builder().build_with_max_retries(self.retries); - let retry_strategy = RetryTransientMiddleware::new_with_policy_and_strategy( - retry_policy, - UvRetryableStrategy, - ); - let client = client.with(retry_strategy); + let mut client = reqwest_middleware::ClientBuilder::new(client); + + // Avoid non-cloneable errors with a streaming body during publish. + if self.retries > 0 { + // Initialize the retry strategy. + let retry_policy = + ExponentialBackoff::builder().build_with_max_retries(self.retries); + let retry_strategy = RetryTransientMiddleware::new_with_policy_and_strategy( + retry_policy, + UvRetryableStrategy, + ); + client = client.with(retry_strategy); + } // Initialize the authentication middleware to set headers. - let client = - client.with(AuthMiddleware::new().with_keyring(self.keyring.to_provider())); + client = client.with( + AuthMiddleware::new() + .with_keyring(self.keyring.to_provider()) + .with_only_authenticated(self.only_authenticated), + ); client.build() } diff --git a/crates/uv-publish/Cargo.toml b/crates/uv-publish/Cargo.toml new file mode 100644 index 000000000000..eb2ea28dd4e8 --- /dev/null +++ b/crates/uv-publish/Cargo.toml @@ -0,0 +1,41 @@ +[package] +name = "uv-publish" +version = "0.1.0" +edition.workspace = true +rust-version.workspace = true +homepage.workspace = true +documentation.workspace = true +repository.workspace = true +authors.workspace = true +license.workspace = true + +[dependencies] +distribution-filename = { workspace = true } +uv-client = { workspace = true } +uv-fs = { workspace = true } +uv-metadata = { workspace = true } +uv-warnings = { workspace = true } + +async-compression = { workspace = true } +base64 = { workspace = true } +fs-err = { workspace = true } +futures = { workspace = true } +glob = { workspace = true } +itertools = { workspace = true } +krata-tokio-tar = { workspace = true } +python-pkginfo = { workspace = true } +reqwest = { workspace = true } +reqwest-middleware = { workspace = true } +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } +sha2 = { workspace = true } +thiserror = { workspace = true } +tokio = { workspace = true } +tracing = { workspace = true } +url = { workspace = true } + +[dev-dependencies] +insta = { version = "1.36.1", features = ["json", "filters"] } + +[lints] +workspace = true diff --git a/crates/uv-publish/src/lib.rs b/crates/uv-publish/src/lib.rs new file mode 100644 index 000000000000..013d1f7d84dd --- /dev/null +++ b/crates/uv-publish/src/lib.rs @@ -0,0 +1,789 @@ +use base64::prelude::BASE64_STANDARD; +use base64::Engine; +use distribution_filename::{DistFilename, SourceDistExtension, SourceDistFilename}; +use fs_err::File; +use futures::TryStreamExt; +use glob::{glob, GlobError, PatternError}; +use itertools::Itertools; +use python_pkginfo::Metadata; +use reqwest::header::AUTHORIZATION; +use reqwest::multipart::Part; +use reqwest::{Body, Response, StatusCode}; +use reqwest_middleware::RequestBuilder; +use serde::Deserialize; +use sha2::{Digest, Sha256}; +use std::collections::HashSet; +use std::io::BufReader; +use std::path::{Path, PathBuf}; +use std::{fmt, io}; +use thiserror::Error; +use tokio::io::AsyncReadExt; +use tracing::{debug, enabled, trace, Level}; +use url::Url; +use uv_client::BaseClient; +use uv_fs::Simplified; +use uv_metadata::read_metadata_async_seek; +use uv_warnings::warn_user_once; + +#[derive(Error, Debug)] +pub enum PublishError { + #[error("Invalid publish paths")] + Pattern(#[from] PatternError), + /// [`GlobError`] is a wrapped io error. + #[error(transparent)] + Glob(#[from] GlobError), + #[error("Path patterns didn't match any wheels or source distributions")] + NoFiles, + #[error(transparent)] + Fmt(#[from] fmt::Error), + #[error("File is neither a wheel nor a source distribution: `{}`", _0.user_display())] + InvalidFilename(PathBuf), + #[error("Failed to publish: `{}`", _0.user_display())] + PublishPrepare(PathBuf, #[source] PublishPrepareError), + #[error("Failed to publish `{}` to `{}`", _0.user_display(), _1)] + PublishSend(PathBuf, Url, #[source] PublishSendError), +} + +/// Failure to get the metadata for a specific file. +#[derive(Error, Debug)] +pub enum PublishPrepareError { + #[error(transparent)] + PkgInfoError(#[from] python_pkginfo::Error), + #[error(transparent)] + Io(#[from] io::Error), + #[error("Failed to read metadata")] + Metadata(#[from] uv_metadata::Error), + #[error("Only files ending in `.tar.gz` are valid source distributions: `{0}`")] + InvalidExtension(SourceDistFilename), + #[error("No PKG-INFO file found")] + MissingPkgInfo, + #[error("Multiple PKG-INFO files found: `{0}`")] + MultiplePkgInfo(String), + #[error("Failed to read: `{0}`")] + Read(String, #[source] io::Error), +} + +/// Failure in or after (HTTP) transport for a specific file. +#[derive(Error, Debug)] +pub enum PublishSendError { + #[error("Failed to send POST request: `{0}`")] + ReqwestMiddleware(Url, #[source] reqwest_middleware::Error), + #[error("Upload failed with status {0}")] + StatusNoBody(StatusCode, #[source] reqwest::Error), + #[error("Upload failed with status code {0}: {1}")] + Status(StatusCode, String), + /// The registry returned a "403 Forbidden" + #[error("Incorrect credentials (status code {0}): {1}")] + IncorrectCredentials(StatusCode, String), +} + +impl PublishSendError { + /// Extract `code` from the PyPI json error response, if any. + /// + /// The error response from PyPI contains crucial context, such as the difference between + /// "Invalid or non-existent authentication information" and "The user 'konstin' isn't allowed + /// to upload to project 'dummy'". + /// + /// Twine uses the HTTP status reason for its error messages. In HTTP 2.0 and onward this field + /// is abolished, so reqwest doesn't expose it, see + /// . + /// PyPI does respect the content type for error responses and can return an error display as + /// HTML, JSON and plain. Since HTML and plain text are both overly verbose, we show the JSON + /// response. Examples are shown below, line breaks were inserted for readability. Of those, + /// the `code` seems to be the most helpful message, so we return it. If the response isn't a + /// JSON document with `code` we return the regular body + /// + /// ```json + /// {"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"} + /// ``` + /// + /// ```json + /// {"message": "Access was denied to this resource.\n\n\nInvalid or non-existent authentication + /// information. See https://test.pypi.org/help/#invalid-auth for more information.\n\n", + /// "code": "403 Invalid or non-existent authentication information. See + /// https://test.pypi.org/help/#invalid-auth for more information.", + /// "title": "Forbidden"} + /// ``` + /// ```json + /// {"message": "Access was denied to this resource.\n\n\n\n\n", + /// "code": "403 Username/Password authentication is no longer supported. Migrate to API + /// Tokens or Trusted Publishers instead. See https://test.pypi.org/help/#apitoken and + /// https://test.pypi.org/help/#trusted-publishers", + /// "title": "Forbidden"} + /// ``` + /// + /// For context, for the last case twine shows: + /// ```text + /// WARNING Error during upload. Retry with the --verbose option for more details. + /// ERROR HTTPError: 403 Forbidden from https://test.pypi.org/legacy/ + /// Username/Password authentication is no longer supported. Migrate to API + /// Tokens or Trusted Publishers instead. See + /// https://test.pypi.org/help/#apitoken and + /// https://test.pypi.org/help/#trusted-publishers + /// ``` + /// + /// ```text + /// INFO Response from https://test.pypi.org/legacy/: + /// 403 Username/Password authentication is no longer supported. Migrate to + /// API Tokens or Trusted Publishers instead. See + /// https://test.pypi.org/help/#apitoken and + /// https://test.pypi.org/help/#trusted-publishers + /// INFO + /// + /// 403 Username/Password authentication is no longer supported. + /// Migrate to API Tokens or Trusted Publishers instead. See + /// https://test.pypi.org/help/#apitoken and + /// https://test.pypi.org/help/#trusted-publishers + /// + /// + ///

403 Username/Password authentication is no longer supported. + /// Migrate to API Tokens or Trusted Publishers instead. See + /// https://test.pypi.org/help/#apitoken and + /// https://test.pypi.org/help/#trusted-publishers

+ /// Access was denied to this resource.

+ /// ``` + /// + /// In comparison, we now show (line-wrapped for readability): + /// + /// ```text + /// error: Failed to publish `dist/astral_test_1-0.1.0-py3-none-any.whl` to `https://test.pypi.org/legacy/` + /// Caused by: Incorrect credentials (status code 403 Forbidden): 403 Username/Password + /// authentication is no longer supported. Migrate to API Tokens or Trusted Publishers + /// instead. See https://test.pypi.org/help/#apitoken and https://test.pypi.org/help/#trusted-publishers + /// ``` + fn extract_error_message(body: String, content_type: Option<&str>) -> String { + if content_type == Some("application/json") { + #[derive(Deserialize)] + struct ErrorBody { + code: String, + } + + if let Ok(structured) = serde_json::from_str::(&body) { + structured.code + } else { + body + } + } else { + body + } + } +} + +pub fn files_for_publishing( + paths: Vec, +) -> Result, PublishError> { + let mut seen = HashSet::new(); + let mut files = Vec::new(); + for path in paths { + for entry in glob(&path)? { + let entry = entry?; + if !seen.insert(entry.clone()) { + continue; + } + if !entry.is_file() { + continue; + } + let Some(filename) = entry.file_name().and_then(|filename| filename.to_str()) else { + continue; + }; + let filename = DistFilename::try_from_normalized_filename(filename) + .ok_or_else(|| PublishError::InvalidFilename(entry.clone()))?; + files.push((entry, filename)); + } + } + // TODO(konsti): Should we sort those files, e.g. wheels before sdists because they are more + // certain to have reliable metadata, even though the metadata in the upload API is unreliable + // in general? + Ok(files) +} + +/// Calculate the SHA256 of a file. +fn hash_file(path: impl AsRef) -> Result { + // Ideally, this would be async, but in case we actually want to make parallel uploads we should + // use `spawn_blocking` since sha256 is cpu intensive. + let mut file = BufReader::new(File::open(path.as_ref())?); + let mut hasher = Sha256::new(); + io::copy(&mut file, &mut hasher)?; + Ok(format!("{:x}", hasher.finalize())) +} + +// Not in `uv-metadata` because we only support tar files here. +async fn source_dist_pkg_info(file: &Path) -> Result, PublishPrepareError> { + let file = fs_err::tokio::File::open(&file).await?; + let reader = tokio::io::BufReader::new(file); + let decoded = async_compression::tokio::bufread::GzipDecoder::new(reader); + let mut archive = tokio_tar::Archive::new(decoded); + let mut pkg_infos: Vec<(PathBuf, Vec)> = archive + .entries()? + .map_err(PublishPrepareError::from) + .try_filter_map(|mut entry| async move { + let path = entry + .path() + .map_err(PublishPrepareError::from)? + .to_path_buf(); + let mut components = path.components(); + let Some(_top_level) = components.next() else { + return Ok(None); + }; + let Some(pkg_info) = components.next() else { + return Ok(None); + }; + if components.next().is_some() || pkg_info.as_os_str() != "PKG-INFO" { + return Ok(None); + } + let mut buffer = Vec::new(); + entry.read_to_end(&mut buffer).await.map_err(|err| { + PublishPrepareError::Read(path.to_string_lossy().to_string(), err) + })?; + Ok(Some((path, buffer))) + }) + .try_collect() + .await?; + match pkg_infos.len() { + 0 => Err(PublishPrepareError::MissingPkgInfo), + 1 => Ok(pkg_infos.remove(0).1), + _ => Err(PublishPrepareError::MultiplePkgInfo( + pkg_infos + .iter() + .map(|(path, _buffer)| path.to_string_lossy()) + .join(", "), + )), + } +} + +async fn metadata(file: &Path, filename: &DistFilename) -> Result { + let contents = match filename { + DistFilename::SourceDistFilename(source_dist) => { + if source_dist.extension != SourceDistExtension::TarGz { + // See PEP 625. While we support installing legacy source distributions, we don't + // support creating and uploading them. + return Err(PublishPrepareError::InvalidExtension(source_dist.clone())); + } + source_dist_pkg_info(file).await? + } + DistFilename::WheelFilename(wheel) => { + let file = fs_err::tokio::File::open(&file).await?; + let reader = tokio::io::BufReader::new(file); + read_metadata_async_seek(wheel, reader).await? + } + }; + Ok(Metadata::parse(&contents)?) +} + +/// Upload a file to a registry. +/// +/// Returns `true` if the file was newly uploaded and `false` if it already existed. +pub async fn upload( + file: &Path, + filename: &DistFilename, + registry: &Url, + client: &BaseClient, + username: Option<&str>, + password: Option<&str>, +) -> Result { + let form_metadata = form_metadata(file, filename) + .await + .map_err(|err| PublishError::PublishPrepare(file.to_path_buf(), err))?; + let request = build_request( + file, + filename, + registry, + client, + username, + password, + form_metadata, + ) + .await + .map_err(|err| PublishError::PublishPrepare(file.to_path_buf(), err))?; + + let response = request.send().await.map_err(|err| { + let send_err = PublishSendError::ReqwestMiddleware(registry.clone(), err); + PublishError::PublishSend(file.to_path_buf(), registry.clone(), send_err) + })?; + + handle_response(registry, response) + .await + .map_err(|err| PublishError::PublishSend(file.to_path_buf(), registry.clone(), err)) +} + +/// Collect the non-file field for the multipart request from the package METADATA. +async fn form_metadata( + file: &Path, + filename: &DistFilename, +) -> Result, PublishPrepareError> { + let hash_hex = hash_file(file)?; + + let metadata = metadata(file, filename).await?; + + let mut form_metadata = vec![ + (":action", "file_upload".to_string()), + ("sha256_digest", hash_hex), + ("protocol_version", "1".to_string()), + ("metadata_version", metadata.metadata_version.clone()), + // Twine transforms the name with `re.sub("[^A-Za-z0-9.]+", "-", name)` + // * + // * + // warehouse seems to call `packaging.utils.canonicalize_name` nowadays and has a separate + // `normalized_name`, so we'll start with this and we'll readjust if there are user reports. + ("name", metadata.name.clone()), + ("version", metadata.version.clone()), + ("filetype", filename.filetype().to_string()), + ]; + + if let DistFilename::WheelFilename(wheel) = filename { + form_metadata.push(("pyversion", wheel.python_tag.join("."))); + } else { + form_metadata.push(("pyversion", "source".to_string())); + } + + let mut add_option = |name, value: Option| { + if let Some(some) = value.clone() { + form_metadata.push((name, some)); + } + }; + + // https://github.com/pypi/warehouse/blob/d2c36d992cf9168e0518201d998b2707a3ef1e72/warehouse/forklift/legacy.py#L1376-L1430 + add_option("summary", metadata.summary); + add_option("description", metadata.description); + add_option( + "description_content_type", + metadata.description_content_type, + ); + add_option("author", metadata.author); + add_option("author_email", metadata.author_email); + add_option("maintainer", metadata.maintainer); + add_option("maintainer_email", metadata.maintainer_email); + add_option("license", metadata.license); + add_option("keywords", metadata.keywords); + add_option("home_page", metadata.home_page); + add_option("download_url", metadata.download_url); + + // GitLab PyPI repository API implementation requires this metadata field + // and twine always includes it in the request, even when it's empty. + form_metadata.push(( + "requires_python", + metadata.requires_python.unwrap_or(String::new()), + )); + + let mut add_vec = |name, values: Vec| { + for i in values { + form_metadata.push((name, i.clone())); + } + }; + + add_vec("classifiers", metadata.classifiers); + add_vec("platform", metadata.platforms); + add_vec("requires_dist", metadata.requires_dist); + add_vec("provides_dist", metadata.provides_dist); + add_vec("obsoletes_dist", metadata.obsoletes_dist); + add_vec("requires_external", metadata.requires_external); + add_vec("project_urls", metadata.project_urls); + + Ok(form_metadata) +} + +async fn build_request( + file: &Path, + filename: &DistFilename, + registry: &Url, + client: &BaseClient, + username: Option<&str>, + password: Option<&str>, + form_metadata: Vec<(&'static str, String)>, +) -> Result { + let mut form = reqwest::multipart::Form::new(); + for (key, value) in form_metadata { + form = form.text(key, value); + } + + let file: tokio::fs::File = fs_err::tokio::File::open(file).await?.into(); + let file_reader = Body::from(file); + form = form.part( + "content", + Part::stream(file_reader).file_name(filename.to_string()), + ); + + let url = if let Some(username) = username { + if password.is_none() { + // Attach the username to the URL so the authentication middleware can find the matching + // password. + let mut url = registry.clone(); + let _ = url.set_username(username); + url + } else { + // We set the authorization header below. + registry.clone() + } + } else { + registry.clone() + }; + + let mut request = client + .client() + .post(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", + ); + if let (Some(username), Some(password)) = (username, password) { + debug!("Using username/password basic auth"); + let credentials = BASE64_STANDARD.encode(format!("{username}:{password}")); + request = request.header(AUTHORIZATION, format!("Basic {credentials}")); + } + Ok(request) +} + +/// Returns `true` if the file was newly uploaded and `false` if it already existed. +async fn handle_response(registry: &Url, response: Response) -> Result { + let status_code = response.status(); + debug!("Response code for {registry}: {status_code}"); + trace!("Response headers for {registry}: {response:?}"); + + // TODO(konsti): There's some strange behavior here we're not handling yet: When I POST to + // https://test.pypi.org/simple/, I get a method not allowed error (as it should be), but when + // I post to https://test.pypi.org/simple (no slash) it returns a 200 with the content of the + // index that you should only GET. Logs for the latter case: + // ```text + // DEBUG redirecting 'https://test.pypi.org/simple' to 'https://test.pypi.org/simple/' + // DEBUG reuse idle connection for ("https", test.pypi.org) + // DEBUG Response code for https://test.pypi.org/simple: 200 OK + // TRACE Response headers for https://test.pypi.org/simple: Response { url: "https://test.pypi.org/simple/", [...] } + // ``` + // twine always errors on redirects (), + // which does not seem desirable either. + if response.url() != registry { + warn_user_once!( + "The request was redirected, please use the new URL for future requests: {}", + response.url() + ); + } + + if status_code.is_success() { + if enabled!(Level::TRACE) { + match response.text().await { + Ok(response_content) => { + trace!("Response content for {registry}: {response_content}"); + } + Err(err) => { + trace!("Failed to read response content for {registry}: {err}"); + } + } + } + return Ok(true); + } + + let content_type = response + .headers() + .get(reqwest::header::CONTENT_TYPE) + .and_then(|content_type| content_type.to_str().ok()) + .map(ToString::to_string); + let upload_error = response + .bytes() + .await + .map_err(|err| PublishSendError::StatusNoBody(status_code, err))?; + let upload_error = String::from_utf8_lossy(&upload_error); + + trace!("Response content for non-200 for {registry}: {upload_error}"); + + debug!("Upload error response: {upload_error}"); + // Detect existing file errors the way twine does. + // https://github.com/pypa/twine/blob/c512bbf166ac38239e58545a39155285f8747a7b/twine/commands/upload.py#L34-L72 + if status_code == 403 { + if upload_error.contains("overwrite artifact") { + // Artifactory (https://jfrog.com/artifactory/) + Ok(false) + } else { + Err(PublishSendError::IncorrectCredentials( + status_code, + PublishSendError::extract_error_message( + upload_error.to_string(), + content_type.as_deref(), + ), + )) + } + } else if status_code == 409 { + // conflict, pypiserver (https://pypi.org/project/pypiserver) + Ok(false) + } else if status_code == 400 + && (upload_error.contains("updating asset") || upload_error.contains("already been taken")) + { + // Nexus Repository OSS (https://www.sonatype.com/nexus-repository-oss) + // and Gitlab Enterprise Edition (https://about.gitlab.com) + Ok(false) + } else { + Err(PublishSendError::Status( + status_code, + PublishSendError::extract_error_message( + upload_error.to_string(), + content_type.as_deref(), + ), + )) + } +} + +#[cfg(test)] +mod tests { + use crate::{build_request, form_metadata}; + use distribution_filename::DistFilename; + use insta::{assert_debug_snapshot, assert_snapshot}; + use itertools::Itertools; + use std::path::PathBuf; + use url::Url; + use uv_client::BaseClientBuilder; + + /// Snapshot the data we send for an upload request for a source distribution. + #[tokio::test] + async fn upload_request_source_dist() { + let filename = "tqdm-999.0.0.tar.gz"; + let file = PathBuf::from("../../scripts/links/").join(filename); + let filename = DistFilename::try_from_normalized_filename(filename).unwrap(); + + let form_metadata = form_metadata(&file, &filename).await.unwrap(); + + let formatted_metadata = form_metadata + .iter() + .map(|(k, v)| format!("{k}: {v}")) + .join("\n"); + assert_snapshot!(&formatted_metadata, @r###" + :action: file_upload + sha256_digest: 89fa05cffa7f457658373b85de302d24d0c205ceda2819a8739e324b75e9430b + protocol_version: 1 + metadata_version: 2.3 + name: tqdm + version: 999.0.0 + filetype: sdist + pyversion: source + description: # tqdm + + [![PyPI - Version](https://img.shields.io/pypi/v/tqdm.svg)](https://pypi.org/project/tqdm) + [![PyPI - Python Version](https://img.shields.io/pypi/pyversions/tqdm.svg)](https://pypi.org/project/tqdm) + + ----- + + **Table of Contents** + + - [Installation](#installation) + - [License](#license) + + ## Installation + + ```console + pip install tqdm + ``` + + ## License + + `tqdm` is distributed under the terms of the [MIT](https://spdx.org/licenses/MIT.html) license. + + description_content_type: text/markdown + author_email: Charlie Marsh + requires_python: >=3.8 + classifiers: Development Status :: 4 - Beta + classifiers: Programming Language :: Python + classifiers: Programming Language :: Python :: 3.8 + classifiers: Programming Language :: Python :: 3.9 + classifiers: Programming Language :: Python :: 3.10 + classifiers: Programming Language :: Python :: 3.11 + classifiers: Programming Language :: Python :: 3.12 + classifiers: Programming Language :: Python :: Implementation :: CPython + classifiers: Programming Language :: Python :: Implementation :: PyPy + project_urls: Documentation, https://github.com/unknown/tqdm#readme + project_urls: Issues, https://github.com/unknown/tqdm/issues + project_urls: Source, https://github.com/unknown/tqdm + "###); + + let request = build_request( + &file, + &filename, + &Url::parse("https://example.org/upload").unwrap(), + &BaseClientBuilder::new().build(), + Some("ferris"), + Some("F3RR!S"), + form_metadata, + ) + .await + .unwrap(); + + insta::with_settings!({ + filters => [("boundary=[0-9a-f-]+", "boundary=[...]")], + }, { + assert_debug_snapshot!(&request, @r###" + RequestBuilder { + inner: RequestBuilder { + method: POST, + url: Url { + scheme: "https", + cannot_be_a_base: false, + username: "", + password: None, + host: Some( + Domain( + "example.org", + ), + ), + port: None, + path: "/upload", + query: None, + fragment: None, + }, + headers: { + "content-type": "multipart/form-data; boundary=[...]", + "accept": "application/json;q=0.9, text/plain;q=0.8, text/html;q=0.7", + "authorization": "Basic ZmVycmlzOkYzUlIhUw==", + }, + }, + .. + } + "###); + }); + } + + /// Snapshot the data we send for an upload request for a wheel. + #[tokio::test] + async fn upload_request_wheel() { + let filename = "tqdm-4.66.1-py3-none-manylinux_2_12_x86_64.manylinux2010_x86_64.musllinux_1_1_x86_64.whl"; + let file = PathBuf::from("../../scripts/links/").join(filename); + let filename = DistFilename::try_from_normalized_filename(filename).unwrap(); + + let form_metadata = form_metadata(&file, &filename).await.unwrap(); + + let formatted_metadata = form_metadata + .iter() + .map(|(k, v)| format!("{k}: {v}")) + .join("\n"); + assert_snapshot!(&formatted_metadata, @r###" + :action: file_upload + sha256_digest: 0d88ca657bc6b64995ca416e0c59c71af85cc10015d940fa446c42a8b485ee1c + protocol_version: 1 + metadata_version: 2.1 + name: tqdm + version: 4.66.1 + filetype: bdist_wheel + pyversion: py3 + summary: Fast, Extensible Progress Meter + description_content_type: text/x-rst + maintainer_email: tqdm developers + license: MPL-2.0 AND MIT + keywords: progressbar,progressmeter,progress,bar,meter,rate,eta,console,terminal,time + requires_python: >=3.7 + classifiers: Development Status :: 5 - Production/Stable + classifiers: Environment :: Console + classifiers: Environment :: MacOS X + classifiers: Environment :: Other Environment + classifiers: Environment :: Win32 (MS Windows) + classifiers: Environment :: X11 Applications + classifiers: Framework :: IPython + classifiers: Framework :: Jupyter + classifiers: Intended Audience :: Developers + classifiers: Intended Audience :: Education + classifiers: Intended Audience :: End Users/Desktop + classifiers: Intended Audience :: Other Audience + classifiers: Intended Audience :: System Administrators + classifiers: License :: OSI Approved :: MIT License + classifiers: License :: OSI Approved :: Mozilla Public License 2.0 (MPL 2.0) + classifiers: Operating System :: MacOS + classifiers: Operating System :: MacOS :: MacOS X + classifiers: Operating System :: Microsoft + classifiers: Operating System :: Microsoft :: MS-DOS + classifiers: Operating System :: Microsoft :: Windows + classifiers: Operating System :: POSIX + classifiers: Operating System :: POSIX :: BSD + classifiers: Operating System :: POSIX :: BSD :: FreeBSD + classifiers: Operating System :: POSIX :: Linux + classifiers: Operating System :: POSIX :: SunOS/Solaris + classifiers: Operating System :: Unix + classifiers: Programming Language :: Python + classifiers: Programming Language :: Python :: 3 + classifiers: Programming Language :: Python :: 3.7 + classifiers: Programming Language :: Python :: 3.8 + classifiers: Programming Language :: Python :: 3.9 + classifiers: Programming Language :: Python :: 3.10 + classifiers: Programming Language :: Python :: 3.11 + classifiers: Programming Language :: Python :: 3 :: Only + classifiers: Programming Language :: Python :: Implementation + classifiers: Programming Language :: Python :: Implementation :: IronPython + classifiers: Programming Language :: Python :: Implementation :: PyPy + classifiers: Programming Language :: Unix Shell + classifiers: Topic :: Desktop Environment + classifiers: Topic :: Education :: Computer Aided Instruction (CAI) + classifiers: Topic :: Education :: Testing + classifiers: Topic :: Office/Business + classifiers: Topic :: Other/Nonlisted Topic + classifiers: Topic :: Software Development :: Build Tools + classifiers: Topic :: Software Development :: Libraries + classifiers: Topic :: Software Development :: Libraries :: Python Modules + classifiers: Topic :: Software Development :: Pre-processors + classifiers: Topic :: Software Development :: User Interfaces + classifiers: Topic :: System :: Installation/Setup + classifiers: Topic :: System :: Logging + classifiers: Topic :: System :: Monitoring + classifiers: Topic :: System :: Shells + classifiers: Topic :: Terminals + classifiers: Topic :: Utilities + requires_dist: colorama ; platform_system == "Windows" + requires_dist: pytest >=6 ; extra == 'dev' + requires_dist: pytest-cov ; extra == 'dev' + requires_dist: pytest-timeout ; extra == 'dev' + requires_dist: pytest-xdist ; extra == 'dev' + requires_dist: ipywidgets >=6 ; extra == 'notebook' + requires_dist: slack-sdk ; extra == 'slack' + requires_dist: requests ; extra == 'telegram' + project_urls: homepage, https://tqdm.github.io + project_urls: repository, https://github.com/tqdm/tqdm + project_urls: changelog, https://tqdm.github.io/releases + project_urls: wiki, https://github.com/tqdm/tqdm/wiki + "###); + + let request = build_request( + &file, + &filename, + &Url::parse("https://example.org/upload").unwrap(), + &BaseClientBuilder::new().build(), + Some("ferris"), + Some("F3RR!S"), + form_metadata, + ) + .await + .unwrap(); + + insta::with_settings!({ + filters => [("boundary=[0-9a-f-]+", "boundary=[...]")], + }, { + assert_debug_snapshot!(&request, @r###" + RequestBuilder { + inner: RequestBuilder { + method: POST, + url: Url { + scheme: "https", + cannot_be_a_base: false, + username: "", + password: None, + host: Some( + Domain( + "example.org", + ), + ), + port: None, + path: "/upload", + query: None, + fragment: None, + }, + headers: { + "content-type": "multipart/form-data; boundary=[...]", + "accept": "application/json;q=0.9, text/plain;q=0.8, text/html;q=0.7", + "authorization": "Basic ZmVycmlzOkYzUlIhUw==", + }, + }, + .. + } + "###); + }); + } +} diff --git a/crates/uv-settings/Cargo.toml b/crates/uv-settings/Cargo.toml index 6be4b49b758f..1473eb0c75f0 100644 --- a/crates/uv-settings/Cargo.toml +++ b/crates/uv-settings/Cargo.toml @@ -36,6 +36,7 @@ textwrap = { workspace = true } thiserror = { workspace = true } toml = { workspace = true } tracing = { workspace = true } +url = { workspace = true } [package.metadata.cargo-shear] ignored = ["uv-options-metadata", "clap"] diff --git a/crates/uv-settings/src/combine.rs b/crates/uv-settings/src/combine.rs index f7c1e8af6581..59215cbd00cc 100644 --- a/crates/uv-settings/src/combine.rs +++ b/crates/uv-settings/src/combine.rs @@ -1,9 +1,9 @@ -use std::num::NonZeroUsize; -use std::path::PathBuf; - use distribution_types::IndexUrl; use install_wheel_rs::linker::LinkMode; use pypi_types::SupportedEnvironments; +use std::num::NonZeroUsize; +use std::path::PathBuf; +use url::Url; use uv_configuration::{ConfigSettings, IndexStrategy, KeyringProviderType, TargetTriple}; use uv_python::{PythonDownloads, PythonPreference, PythonVersion}; use uv_resolver::{AnnotationStyle, ExcludeNewer, PrereleaseMode, ResolutionMode}; @@ -71,6 +71,7 @@ impl_combine_or!(AnnotationStyle); impl_combine_or!(ExcludeNewer); impl_combine_or!(IndexStrategy); impl_combine_or!(IndexUrl); +impl_combine_or!(Url); impl_combine_or!(KeyringProviderType); impl_combine_or!(LinkMode); impl_combine_or!(NonZeroUsize); diff --git a/crates/uv-settings/src/settings.rs b/crates/uv-settings/src/settings.rs index 55b1f7a945d8..9719b1d4c4d3 100644 --- a/crates/uv-settings/src/settings.rs +++ b/crates/uv-settings/src/settings.rs @@ -1,11 +1,11 @@ use std::{fmt::Debug, num::NonZeroUsize, path::PathBuf}; -use serde::{Deserialize, Serialize}; - use distribution_types::{FlatIndexLocation, IndexUrl, StaticMetadata}; use install_wheel_rs::linker::LinkMode; use pep508_rs::Requirement; use pypi_types::{SupportedEnvironments, VerbatimParsedUrl}; +use serde::{Deserialize, Serialize}; +use url::Url; use uv_cache_info::CacheKey; use uv_configuration::{ ConfigSettings, IndexStrategy, KeyringProviderType, PackageNameSpecifier, TargetTriple, @@ -40,6 +40,8 @@ pub struct Options { pub globals: GlobalOptions, #[serde(flatten)] pub top_level: ResolverInstallerOptions, + #[serde(flatten)] + pub publish: PublishOptions, #[option_group] pub pip: Option, @@ -1472,3 +1474,21 @@ impl From for ResolverInstallerOptions { } } } + +#[derive( + Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, CombineOptions, OptionsMetadata, +)] +#[serde(rename_all = "kebab-case")] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +pub struct PublishOptions { + /// The URL for publishing packages to the Python package index (by default: + /// ). + #[option( + default = "\"https://upload.pypi.org/legacy\"", + value_type = "str", + example = r#" + index-url = "https://test.pypi.org/simple" + "# + )] + pub publish_url: Option, +} diff --git a/crates/uv/Cargo.toml b/crates/uv/Cargo.toml index 7bceee8a02ab..93a28a389f4d 100644 --- a/crates/uv/Cargo.toml +++ b/crates/uv/Cargo.toml @@ -35,6 +35,7 @@ uv-fs = { workspace = true } uv-git = { workspace = true } uv-installer = { workspace = true } uv-normalize = { workspace = true } +uv-publish = { workspace = true } uv-python = { workspace = true, features = ["schemars"]} uv-requirements = { workspace = true } uv-resolver = { workspace = true } diff --git a/crates/uv/src/commands/mod.rs b/crates/uv/src/commands/mod.rs index e9caaa99c415..48d72ab2de35 100644 --- a/crates/uv/src/commands/mod.rs +++ b/crates/uv/src/commands/mod.rs @@ -30,6 +30,7 @@ pub(crate) use project::remove::remove; pub(crate) use project::run::{run, RunCommand}; pub(crate) use project::sync::sync; pub(crate) use project::tree::tree; +pub(crate) use publish::publish; pub(crate) use python::dir::dir as python_dir; pub(crate) use python::find::find as python_find; pub(crate) use python::install::install as python_install; @@ -70,6 +71,7 @@ pub(crate) mod reporters; mod tool; mod build; +mod publish; #[cfg(feature = "self-update")] mod self_update; mod venv; diff --git a/crates/uv/src/commands/publish.rs b/crates/uv/src/commands/publish.rs new file mode 100644 index 000000000000..bf0843a0abdc --- /dev/null +++ b/crates/uv/src/commands/publish.rs @@ -0,0 +1,79 @@ +use crate::commands::ExitStatus; +use crate::printer::Printer; +use anyhow::{bail, Result}; +use owo_colors::OwoColorize; +use std::fmt::Write; +use tracing::info; +use url::Url; +use uv_client::{BaseClientBuilder, Connectivity}; +use uv_configuration::{KeyringProviderType, TrustedHost}; +use uv_publish::{files_for_publishing, upload}; + +pub(crate) async fn publish( + paths: Vec, + publish_url: Url, + keyring_provider: KeyringProviderType, + allow_insecure_host: Vec, + username: Option, + password: Option, + connectivity: Connectivity, + native_tls: bool, + printer: Printer, +) -> Result { + if connectivity.is_offline() { + bail!("You cannot publish files in offline mode"); + } + + let files = files_for_publishing(paths)?; + match files.len() { + 0 => bail!("No files found to publish"), + 1 => writeln!( + printer.stderr(), + "{}", + format!("Publishing {}", "1 file".bold()).dimmed() + )?, + n => writeln!( + printer.stderr(), + "{}", + format!("Publishing {}", format!("{n} files").bold()).dimmed() + )?, + } + + let client = BaseClientBuilder::new() + // https://github.com/seanmonstar/reqwest/issues/2416 + .retries(0) + .keyring(keyring_provider) + .native_tls(native_tls) + .allow_insecure_host(allow_insecure_host) + // Don't try cloning the request to make an unauthenticated request first. + // https://github.com/seanmonstar/reqwest/issues/2416 + .only_authenticated(true) + .build(); + + for (file, filename) in files { + writeln!( + printer.stderr(), + "{}", + format!("Uploading {}", filename.bold()).dimmed() + )?; + let uploaded = upload( + &file, + &filename, + &publish_url, + &client, + username.as_deref(), + password.as_deref(), + ) + .await?; // Filename and/or URL are already attached, if applicable. + info!("Upload succeeded"); + if !uploaded { + writeln!( + printer.stderr(), + "{}", + "File already existed, skipping".bold() + )?; + } + } + + Ok(ExitStatus::Success) +} diff --git a/crates/uv/src/lib.rs b/crates/uv/src/lib.rs index 80b788ec2578..4223e9e4ee51 100644 --- a/crates/uv/src/lib.rs +++ b/crates/uv/src/lib.rs @@ -31,6 +31,7 @@ use crate::printer::Printer; use crate::settings::{ CacheSettings, GlobalSettings, PipCheckSettings, PipCompileSettings, PipFreezeSettings, PipInstallSettings, PipListSettings, PipShowSettings, PipSyncSettings, PipUninstallSettings, + PublishSettings, }; #[cfg(target_os = "windows")] @@ -1063,6 +1064,31 @@ async fn run(cli: Cli) -> Result { commands::python_dir()?; Ok(ExitStatus::Success) } + Commands::Publish(args) => { + show_settings!(args); + // Resolve the settings from the command-line arguments and workspace configuration. + let PublishSettings { + files, + username, + password, + publish_url, + keyring_provider, + allow_insecure_host, + } = PublishSettings::resolve(args, filesystem); + + commands::publish( + files, + publish_url, + keyring_provider, + allow_insecure_host, + username, + password, + globals.connectivity, + globals.native_tls, + printer, + ) + .await + } } } diff --git a/crates/uv/src/settings.rs b/crates/uv/src/settings.rs index c7d1eb626825..bf0a76de4544 100644 --- a/crates/uv/src/settings.rs +++ b/crates/uv/src/settings.rs @@ -8,10 +8,11 @@ use distribution_types::{DependencyMetadata, IndexLocations}; use install_wheel_rs::linker::LinkMode; use pep508_rs::{ExtraName, RequirementOrigin}; use pypi_types::{Requirement, SupportedEnvironments}; +use url::Url; use uv_cache::{CacheArgs, Refresh}; use uv_cli::{ options::{flag, resolver_installer_options, resolver_options}, - BuildArgs, ExportArgs, ToolUpgradeArgs, + BuildArgs, ExportArgs, PublishArgs, ToolUpgradeArgs, }; use uv_cli::{ AddArgs, ColorChoice, ExternalCommand, GlobalArgs, InitArgs, ListFormat, LockArgs, Maybe, @@ -30,7 +31,8 @@ use uv_normalize::PackageName; use uv_python::{Prefix, PythonDownloads, PythonPreference, PythonVersion, Target}; use uv_resolver::{AnnotationStyle, DependencyMode, ExcludeNewer, PrereleaseMode, ResolutionMode}; use uv_settings::{ - Combine, FilesystemOptions, Options, PipOptions, ResolverInstallerOptions, ResolverOptions, + Combine, FilesystemOptions, Options, PipOptions, PublishOptions, ResolverInstallerOptions, + ResolverOptions, }; use uv_warnings::warn_user_once; use uv_workspace::pyproject::DependencyType; @@ -38,6 +40,9 @@ use uv_workspace::pyproject::DependencyType; use crate::commands::ToolRunCommand; use crate::commands::{pip::operations::Modifications, InitProjectKind}; +/// The default publish URL. +const PYPI_PUBLISH_URL: &str = "https://upload.pypi.org/legacy/"; + /// The resolved global settings to use for any invocation of the CLI. #[allow(clippy::struct_excessive_bools)] #[derive(Debug, Clone)] @@ -2419,6 +2424,65 @@ impl<'a> From> for InstallerSettingsRef<'a> { } } +/// The resolved settings to use for an invocation of the `uv publish` CLI. +#[allow(clippy::struct_excessive_bools)] +#[derive(Debug, Clone)] +pub(crate) struct PublishSettings { + // CLI only, see [`PublishArgs`] for docs. + pub(crate) files: Vec, + pub(crate) username: Option, + pub(crate) password: Option, + + // Both CLI and configuration. + pub(crate) publish_url: Url, + pub(crate) keyring_provider: KeyringProviderType, + pub(crate) allow_insecure_host: Vec, +} + +impl PublishSettings { + /// Resolve the [`crate::settings::PublishSettings`] from the CLI and filesystem configuration. + pub(crate) fn resolve(args: PublishArgs, filesystem: Option) -> Self { + let Options { + publish, top_level, .. + } = filesystem + .map(FilesystemOptions::into_options) + .unwrap_or_default(); + + let PublishOptions { publish_url } = publish; + let ResolverInstallerOptions { + keyring_provider, + allow_insecure_host, + .. + } = top_level; + + Self { + files: args.files, + username: args.username, + password: args.password, + publish_url: args + .publish_url + .combine(publish_url) + // TODO(reviewer): This is different from how it's done anywhere else, but i haven't + // figured out what the ergonomic pattern is? + .unwrap_or(Url::parse(PYPI_PUBLISH_URL).unwrap()), + keyring_provider: args + .keyring_provider + .combine(keyring_provider) + .unwrap_or_default(), + allow_insecure_host: args + .allow_insecure_host + .map(|allow_insecure_host| { + allow_insecure_host + .into_iter() + .filter_map(Maybe::into_option) + .collect() + }) + .combine(allow_insecure_host) + .unwrap_or_default(), + } + } +} + // Environment variables that are not exposed as CLI arguments. mod env { pub(super) const CONCURRENT_DOWNLOADS: (&str, &str) = diff --git a/crates/uv/tests/common/mod.rs b/crates/uv/tests/common/mod.rs index c1e273cc1807..6ef91177e539 100644 --- a/crates/uv/tests/common/mod.rs +++ b/crates/uv/tests/common/mod.rs @@ -52,7 +52,7 @@ pub const INSTA_FILTERS: &[(&str, &str)] = &[ (r"tv_sec: \d+", "tv_sec: [TIME]"), (r"tv_nsec: \d+", "tv_nsec: [TIME]"), // Rewrite Windows output to Unix output - (r"\\([\w\d])", "/$1"), + (r"\\([\w\d]|\.\.)", "/$1"), (r"uv.exe", "uv"), // uv version display ( @@ -579,6 +579,21 @@ impl TestContext { command } + /// Create a `uv publish` command with options shared across scenarios. + #[expect(clippy::unused_self)] // For consistency + pub fn publish(&self) -> Command { + let mut command = Command::new(get_bin()); + command.arg("publish"); + + if cfg!(all(windows, debug_assertions)) { + // TODO(konstin): Reduce stack usage in debug mode enough that the tests pass with the + // default windows stack of 1MB + command.env("UV_STACK_SIZE", (2 * 1024 * 1024).to_string()); + } + + command + } + /// Create a `uv python find` command with options shared across scenarios. pub fn python_find(&self) -> Command { let mut command = Command::new(get_bin()); diff --git a/crates/uv/tests/help.rs b/crates/uv/tests/help.rs index 516408dcdad5..c4b9369b721b 100644 --- a/crates/uv/tests/help.rs +++ b/crates/uv/tests/help.rs @@ -29,6 +29,7 @@ fn help() { pip Manage Python packages with a pip-compatible interface venv Create a virtual environment build Build Python packages into source distributions and wheels + publish Upload distributions to an index cache Manage uv's cache version Display uv's version generate-shell-completion Generate shell completion @@ -94,6 +95,7 @@ fn help_flag() { pip Manage Python packages with a pip-compatible interface venv Create a virtual environment build Build Python packages into source distributions and wheels + publish Upload distributions to an index cache Manage uv's cache version Display uv's version help Display documentation for a command @@ -157,6 +159,7 @@ fn help_short_flag() { pip Manage Python packages with a pip-compatible interface venv Create a virtual environment build Build Python packages into source distributions and wheels + publish Upload distributions to an index cache Manage uv's cache version Display uv's version help Display documentation for a command @@ -637,6 +640,7 @@ fn help_unknown_subcommand() { pip venv build + publish cache version generate-shell-completion @@ -662,6 +666,7 @@ fn help_unknown_subcommand() { pip venv build + publish cache version generate-shell-completion @@ -714,6 +719,7 @@ fn help_with_global_option() { pip Manage Python packages with a pip-compatible interface venv Create a virtual environment build Build Python packages into source distributions and wheels + publish Upload distributions to an index cache Manage uv's cache version Display uv's version generate-shell-completion Generate shell completion @@ -815,6 +821,7 @@ fn help_with_no_pager() { pip Manage Python packages with a pip-compatible interface venv Create a virtual environment build Build Python packages into source distributions and wheels + publish Upload distributions to an index cache Manage uv's cache version Display uv's version generate-shell-completion Generate shell completion diff --git a/crates/uv/tests/publish.rs b/crates/uv/tests/publish.rs new file mode 100644 index 000000000000..3599855687aa --- /dev/null +++ b/crates/uv/tests/publish.rs @@ -0,0 +1,51 @@ +#![cfg(feature = "pypi")] + +use common::{uv_snapshot, TestContext}; + +mod common; + +#[test] +fn username_password_no_longer_supported() { + let context = TestContext::new("3.12"); + + uv_snapshot!(context.filters(), context.publish() + .arg("-u") + .arg("dummy") + .arg("-p") + .arg("dummy") + .arg("../../scripts/links/ok-1.0.0-py3-none-any.whl"), @r###" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + Publishing 1 file + Uploading ok-1.0.0-py3-none-any.whl + error: Failed to publish `../../scripts/links/ok-1.0.0-py3-none-any.whl` to `https://upload.pypi.org/legacy/` + Caused by: Incorrect credentials (status code 403 Forbidden): 403 Username/Password authentication is no longer supported. Migrate to API Tokens or Trusted Publishers instead. See https://pypi.org/help/#apitoken and https://pypi.org/help/#trusted-publishers + "### + ); +} + +#[test] +fn invalid_token() { + let context = TestContext::new("3.12"); + + uv_snapshot!(context.filters(), context.publish() + .arg("-u") + .arg("__token__") + .arg("-p") + .arg("dummy") + .arg("../../scripts/links/ok-1.0.0-py3-none-any.whl"), @r###" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + Publishing 1 file + Uploading ok-1.0.0-py3-none-any.whl + error: Failed to publish `../../scripts/links/ok-1.0.0-py3-none-any.whl` to `https://upload.pypi.org/legacy/` + Caused by: Incorrect credentials (status code 403 Forbidden): 403 Invalid or non-existent authentication information. See https://pypi.org/help/#invalid-auth for more information. + "### + ); +} diff --git a/docs/configuration/environment.md b/docs/configuration/environment.md index 9cb1142db78c..8d1d9f0dfba0 100644 --- a/docs/configuration/environment.md +++ b/docs/configuration/environment.md @@ -63,6 +63,12 @@ uv accepts the following command-line arguments as environment variables: `--no-python-downloads` option. Whether uv should allow Python downloads. - `UV_COMPILE_BYTECODE`: Equivalent to the `--compile-bytecode` command-line argument. If set, uv will compile Python source files to bytecode after installation. +- `UV_PUBLISH_URL`: Equivalent to the `--publish-url` command-line argument. The URL of the upload + endpoint of the index to use with `uv publish`. +- `UV_PUBLISH_USERNAME`: Equivalent to the `--username` command-line argument in `uv publish`. If + set, uv will use this username for publishing. +- `UV_PUBLISH_PASSWORD`: Equivalent to the `--password` command-line argument in `uv publish`. If + set, uv will use this password for publishing. In each case, the corresponding command-line argument takes precedence over an environment variable. diff --git a/docs/guides/publish.md b/docs/guides/publish.md index 537d088eb4b0..16961ba831f6 100644 --- a/docs/guides/publish.md +++ b/docs/guides/publish.md @@ -1,10 +1,7 @@ # Publishing a package -uv supports building Python packages into source and binary distributions via `uv build`. - -As uv does not yet have a dedicated command for publishing packages, you can use the PyPA tool -[`twine`](https://github.com/pypa/twine) to upload your package to a package registry, which can be -invoked via `uvx`. +uv supports building Python packages into source and binary distributions via `uv build` and +uploading them to a registry with `uv publish`. ## Preparing your project for packaging @@ -32,15 +29,15 @@ Alternatively, `uv build ` will build the package in the specified director ## Publishing your package -Publish your package with `twine`: +Publish your package with `uv publish`: ```console -$ uvx twine upload dist/* +$ uv publish ``` !!! tip - To provide credentials, use the `TWINE_USERNAME` and `TWINE_PASSWORD` environment variables. + To provide credentials, use the `UV_PUBLISH_USERNAME` and `UV_PUBLISH_PASSWORD` environment variables. ## Installing your package diff --git a/docs/reference/cli.md b/docs/reference/cli.md index a7ee29fd4982..ae6155b0fc47 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -38,6 +38,8 @@ uv [OPTIONS]
uv build

Build Python packages into source distributions and wheels

+
uv publish

Upload distributions to an index

+
uv cache

Manage uv’s cache

uv version

Display uv’s version

@@ -6520,6 +6522,133 @@ uv build [OPTIONS] [SRC]
+## uv publish + +Upload distributions to an index + +

Usage

+ +``` +uv publish [OPTIONS] [FILES]... +``` + +

Arguments

+ +
FILES

The paths to the files to uploads, as glob expressions

+ +
+ +

Options

+ +
--allow-insecure-host allow-insecure-host

Allow insecure connections to a host.

+ +

Can be provided multiple times.

+ +

Expects to receive either a hostname (e.g., localhost), a host-port pair (e.g., localhost:8080), or a URL (e.g., https://localhost).

+ +

WARNING: Hosts included in this list will not be verified against the system’s certificate store. Only use --allow-insecure-host in a secure network with verified sources, as it bypasses SSL verification and could expose you to MITM attacks.

+ +

May also be set with the UV_INSECURE_HOST environment variable.

+
--cache-dir cache-dir

Path to the cache directory.

+ +

Defaults to $HOME/Library/Caches/uv on macOS, $XDG_CACHE_HOME/uv or $HOME/.cache/uv on Linux, and %LOCALAPPDATA%\uv\cache on Windows.

+ +

May also be set with the UV_CACHE_DIR environment variable.

+
--color color-choice

Control colors in output

+ +

[default: auto]

+

Possible values:

+ +
    +
  • auto: Enables colored output only when the output is going to a terminal or TTY with support
  • + +
  • always: Enables colored output regardless of the detected environment
  • + +
  • never: Disables colored output
  • +
+
--config-file config-file

The path to a uv.toml file to use for configuration.

+ +

While uv configuration can be included in a pyproject.toml file, it is not allowed in this context.

+ +

May also be set with the UV_CONFIG_FILE environment variable.

+
--help, -h

Display the concise help for this command

+ +
--keyring-provider keyring-provider

Attempt to use keyring for authentication for remote requirements files.

+ +

At present, only --keyring-provider subprocess is supported, which configures uv to use the keyring CLI to handle authentication.

+ +

Defaults to disabled.

+ +

May also be set with the UV_KEYRING_PROVIDER environment variable.

+

Possible values:

+ +
    +
  • disabled: Do not use keyring for credential lookup
  • + +
  • subprocess: Use the keyring command for credential lookup
  • +
+
--native-tls

Whether to load TLS certificates from the platform’s native certificate store.

+ +

By default, uv loads certificates from the bundled webpki-roots crate. The webpki-roots are a reliable set of trust roots from Mozilla, and including them in uv improves portability and performance (especially on macOS).

+ +

However, in some cases, you may want to use the platform’s native certificate store, especially if you’re relying on a corporate trust root (e.g., for a mandatory proxy) that’s included in your system’s certificate store.

+ +

May also be set with the UV_NATIVE_TLS environment variable.

+
--no-cache, -n

Avoid reading from or writing to the cache, instead using a temporary directory for the duration of the operation

+ +

May also be set with the UV_NO_CACHE environment variable.

+
--no-config

Avoid discovering configuration files (pyproject.toml, uv.toml).

+ +

Normally, configuration files are discovered in the current directory, parent directories, or user configuration directories.

+ +

May also be set with the UV_NO_CONFIG environment variable.

+
--no-progress

Hide all progress outputs.

+ +

For example, spinners or progress bars.

+ +
--no-python-downloads

Disable automatic downloads of Python.

+ +
--offline

Disable network access.

+ +

When disabled, uv will only use locally cached data and locally available files.

+ +
--password, -p password

The password for the upload

+ +

May also be set with the UV_PUBLISH_PASSWORD environment variable.

+
--publish-url publish-url

The URL to the upload endpoint. Note: This is usually not the same as the index URL.

+ +

The default value is publish URL for PyPI (<https://upload.pypi.org/legacy/>).

+ +

May also be set with the UV_PUBLISH_URL environment variable.

+
--python-preference python-preference

Whether to prefer uv-managed or system Python installations.

+ +

By default, uv prefers using Python versions it manages. However, it will use system Python installations if a uv-managed Python is not installed. This option allows prioritizing or ignoring system Python installations.

+ +

May also be set with the UV_PYTHON_PREFERENCE environment variable.

+

Possible values:

+ +
    +
  • only-managed: Only use managed Python installations; never use system Python installations
  • + +
  • managed: Prefer managed Python installations over system Python installations
  • + +
  • system: Prefer system Python installations over managed Python installations
  • + +
  • only-system: Only use system Python installations; never use managed Python installations
  • +
+
--quiet, -q

Do not print any output

+ +
--username, -u username

The username for the upload

+ +

May also be set with the UV_PUBLISH_USERNAME environment variable.

+
--verbose, -v

Use verbose output.

+ +

You can configure fine-grained logging using the RUST_LOG environment variable. (<https://docs.rs/tracing-subscriber/latest/tracing_subscriber/filter/struct.EnvFilter.html#directives>)

+ +
--version, -V

Display the uv version

+ +
+ ## uv cache Manage uv's cache diff --git a/docs/reference/settings.md b/docs/reference/settings.md index f71c47405b7c..fe76bfb5899c 100644 --- a/docs/reference/settings.md +++ b/docs/reference/settings.md @@ -1073,6 +1073,32 @@ Whether to enable experimental, preview features. --- +### [`publish-url`](#publish-url) {: #publish-url } + +The URL for publishing packages to the Python package index (by default: +). + +**Default value**: `"https://upload.pypi.org/legacy"` + +**Type**: `str` + +**Example usage**: + +=== "pyproject.toml" + + ```toml + [tool.uv] + index-url = "https://test.pypi.org/simple" + ``` +=== "uv.toml" + + ```toml + + index-url = "https://test.pypi.org/simple" + ``` + +--- + ### [`python-downloads`](#python-downloads) {: #python-downloads } Whether to allow Python downloads. diff --git a/uv.schema.json b/uv.schema.json index cafce3caea2a..2eb45af28731 100644 --- a/uv.schema.json +++ b/uv.schema.json @@ -330,6 +330,14 @@ "null" ] }, + "publish-url": { + "description": "The URL for publishing packages to the Python package index (by default: ).", + "type": [ + "string", + "null" + ], + "format": "uri" + }, "python-downloads": { "description": "Whether to allow Python downloads.", "anyOf": [