diff --git a/Cargo.lock b/Cargo.lock index 8a09d4aa25503..04970da9a459f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -23,6 +23,18 @@ version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "512761e0bb2578dd7380c6baaa0f4ce03e84f95e960231d1dec8bf4d7d6e2627" +[[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" @@ -32,6 +44,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" @@ -581,6 +599,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", + "stacker", +] + [[package]] name = "ciborium" version = "0.2.2" @@ -1453,6 +1481,10 @@ name = "hashbrown" version = "0.14.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", + "allocator-api2", +] [[package]] name = "heck" @@ -2131,6 +2163,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" @@ -2677,6 +2719,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.3.0" @@ -2808,6 +2859,21 @@ dependencies = [ "uv-normalize", ] +[[package]] +name = "python-pkginfo" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba3f3f0d552c7efdde2b6898bf21b49c4e76b3e6071ff196dfe52109804db896" +dependencies = [ + "flate2", + "fs-err", + "mailparse", + "rfc2047-decoder", + "tar", + "thiserror", + "zip", +] + [[package]] name = "quinn" version = "0.11.5" @@ -3090,6 +3156,7 @@ dependencies = [ "js-sys", "log", "mime", + "mime_guess", "once_cell", "percent-encoding", "pin-project-lite", @@ -3179,6 +3246,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" @@ -3685,6 +3766,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" @@ -3799,6 +3893,17 @@ version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eddb6b06d20fba9ed21fca3d696ee1b6e870bca0bcf9fa2971f6ae2436de576a" +[[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" @@ -4290,6 +4395,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" @@ -4511,6 +4625,7 @@ dependencies = [ "uv-git", "uv-installer", "uv-normalize", + "uv-publish", "uv-python", "uv-requirements", "uv-resolver", @@ -4983,6 +5098,34 @@ 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", +] + [[package]] name = "uv-python" version = "0.0.1" diff --git a/Cargo.toml b/Cargo.toml index 08aa3ee388910..58b8d408537ca 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" } @@ -119,12 +120,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.3" } 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", "socks"] } -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", "socks", "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.8.8", features = ["bytecheck"] } rmp-serde = { version = "1.3.0" } diff --git a/crates/distribution-filename/src/lib.rs b/crates/distribution-filename/src/lib.rs index dd06a89363b83..9c79895586d0f 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-publish/Cargo.toml b/crates/uv-publish/Cargo.toml new file mode 100644 index 0000000000000..4f762d65ef1c4 --- /dev/null +++ b/crates/uv-publish/Cargo.toml @@ -0,0 +1,40 @@ +[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 } + +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 index ea5d0df860608..4b92e73ac5e96 100644 --- a/crates/uv-publish/src/lib.rs +++ b/crates/uv-publish/src/lib.rs @@ -23,6 +23,7 @@ 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 { diff --git a/crates/uv/Cargo.toml b/crates/uv/Cargo.toml index d418a1ce5b6d4..97be2244f5b4f 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 e9caaa99c4159..48d72ab2de35a 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 0000000000000..39073ff092258 --- /dev/null +++ b/crates/uv/src/commands/publish.rs @@ -0,0 +1,74 @@ +use crate::commands::{human_readable_bytes, 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(), "Publishing 1 file")?, + n => writeln!(printer.stderr(), "Publishing {n} files")?, + } + + 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 { + let size = file.metadata()?.len(); + let (bytes, unit) = human_readable_bytes(size); + writeln!( + printer.stderr(), + "{} {filename} {}", + "Uploading".bold().green(), + format!("({bytes:.1}{unit})").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".dimmed() + )?; + } + } + + Ok(ExitStatus::Success) +} diff --git a/crates/uv/src/lib.rs b/crates/uv/src/lib.rs index acd6bb9d7436e..fc36bf494ccd0 100644 --- a/crates/uv/src/lib.rs +++ b/crates/uv/src/lib.rs @@ -1077,20 +1077,18 @@ async fn run(cli: Cli) -> Result { allow_insecure_host, } = PublishSettings::resolve(args, filesystem); - todo!( - "{:?}", - ( - files, - publish_url, - keyring_provider, - allow_insecure_host, - username, - password, - globals.connectivity, - globals.native_tls, - printer, - ) + commands::publish( + files, + publish_url, + keyring_provider, + allow_insecure_host, + username, + password, + globals.connectivity, + globals.native_tls, + printer, ) + .await } } } diff --git a/crates/uv/tests/common/mod.rs b/crates/uv/tests/common/mod.rs index 22339af658eaa..4da45471c810b 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", (4 * 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/publish.rs b/crates/uv/tests/publish.rs new file mode 100644 index 0000000000000..d06221c410418 --- /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 ([SIZE]) + 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 ([SIZE]) + 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. + "### + ); +}