diff --git a/crates/uv-cli/src/lib.rs b/crates/uv-cli/src/lib.rs index e4f1f04df084..b5cc4824bca4 100644 --- a/crates/uv-cli/src/lib.rs +++ b/crates/uv-cli/src/lib.rs @@ -5119,14 +5119,32 @@ pub struct PublishArgs { #[arg(default_value = "dist/*")] pub files: Vec, - /// The URL of the upload endpoint (not the index URL). + /// The name of an index in the configuration to use for publishing. /// - /// Note that there are typically different URLs for index access (e.g., `https:://.../simple`) - /// and index upload. + /// The index must have a `publish-url` setting, for example: /// - /// Defaults to PyPI's publish URL (). - #[arg(long, env = EnvVars::UV_PUBLISH_URL)] - pub publish_url: Option, + /// ```toml + /// [[tool.uv.index]] + /// name = "pypi" + /// url = "https://pypi.org/simple" + /// publish-url = "https://upload.pypi.org/legacy/" + /// ``` + /// + /// The index `url` will be used to check for existing files to skip duplicate uploads. + /// + /// With these settings, the following two calls are equivalent: + /// + /// ``` + /// uv publish --index pypi + /// uv publish --publish-url https://upload.pypi.org/legacy/ --check-url https://pypi.org/simple + /// ``` + #[arg( + long, + env = EnvVars::UV_PUBLISH_INDEX, + conflicts_with = "publish_url", + conflicts_with = "check_url" + )] + pub index: Option, /// The username for the upload. #[arg(short, long, env = EnvVars::UV_PUBLISH_USERNAME)] @@ -5166,6 +5184,15 @@ pub struct PublishArgs { #[arg(long, value_enum, env = EnvVars::UV_KEYRING_PROVIDER)] pub keyring_provider: Option, + /// The URL of the upload endpoint (not the index URL). + /// + /// Note that there are typically different URLs for index access (e.g., `https:://.../simple`) + /// and index upload. + /// + /// Defaults to PyPI's publish URL (). + #[arg(long, env = EnvVars::UV_PUBLISH_URL)] + pub publish_url: Option, + /// Check an index URL for existing files to skip duplicate uploads. /// /// This option allows retrying publishing that failed after only some, but not all files have diff --git a/crates/uv-distribution-types/src/index.rs b/crates/uv-distribution-types/src/index.rs index 3450757ab828..7660072b31de 100644 --- a/crates/uv-distribution-types/src/index.rs +++ b/crates/uv-distribution-types/src/index.rs @@ -11,6 +11,7 @@ use crate::{IndexUrl, IndexUrlError}; #[derive(Debug, Clone, Hash, Eq, PartialEq, serde::Serialize, serde::Deserialize)] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[serde(rename_all = "kebab-case")] pub struct Index { /// The name of the index. /// @@ -67,6 +68,19 @@ pub struct Index { // /// can point to either local or remote resources. // #[serde(default)] // pub r#type: IndexKind, + /// The URL of the upload endpoint. + /// + /// When using `uv publish --index `, this URL is used for publishing. + /// + /// A configuration for the default index PyPI would look as follows: + /// + /// ```toml + /// [[tool.uv.index]] + /// name = "pypi" + /// url = "https://pypi.org/simple" + /// publish-url = "https://upload.pypi.org/legacy/" + /// ``` + pub publish_url: Option, } // #[derive( @@ -90,6 +104,7 @@ impl Index { explicit: false, default: true, origin: None, + publish_url: None, } } @@ -101,6 +116,7 @@ impl Index { explicit: false, default: false, origin: None, + publish_url: None, } } @@ -112,6 +128,7 @@ impl Index { explicit: false, default: false, origin: None, + publish_url: None, } } @@ -166,6 +183,7 @@ impl FromStr for Index { explicit: false, default: false, origin: None, + publish_url: None, }); } } @@ -178,6 +196,7 @@ impl FromStr for Index { explicit: false, default: false, origin: None, + publish_url: None, }) } } diff --git a/crates/uv-static/src/env_vars.rs b/crates/uv-static/src/env_vars.rs index 271a9ce7554d..ec58be134096 100644 --- a/crates/uv-static/src/env_vars.rs +++ b/crates/uv-static/src/env_vars.rs @@ -138,6 +138,10 @@ impl EnvVars { /// will use this token (with the username `__token__`) for publishing. pub const UV_PUBLISH_TOKEN: &'static str = "UV_PUBLISH_TOKEN"; + /// Equivalent to the `--index` command-line argument in `uv publish`. If + /// set, uv the index with this name in the configuration for publishing. + pub const UV_PUBLISH_INDEX: &'static str = "UV_PUBLISH_INDEX"; + /// Equivalent to the `--username` command-line argument in `uv publish`. If /// set, uv will use this username for publishing. pub const UV_PUBLISH_USERNAME: &'static str = "UV_PUBLISH_USERNAME"; diff --git a/crates/uv/src/lib.rs b/crates/uv/src/lib.rs index 03fa5b823d86..454ef6683f44 100644 --- a/crates/uv/src/lib.rs +++ b/crates/uv/src/lib.rs @@ -8,7 +8,7 @@ use std::process::ExitCode; use std::sync::atomic::Ordering; use anstream::eprintln; -use anyhow::{bail, Result}; +use anyhow::{bail, Context, Result}; use clap::error::{ContextKind, ContextValue}; use clap::{CommandFactory, Parser}; use owo_colors::OwoColorize; @@ -1203,8 +1203,46 @@ async fn run(mut cli: Cli) -> Result { trusted_publishing, keyring_provider, check_url, + index, + index_locations, } = PublishSettings::resolve(args, filesystem); + let (publish_url, check_url) = if let Some(index_name) = index { + debug!("Publishing with index {index_name}"); + let index = index_locations + .indexes() + .find(|index| { + index + .name + .as_ref() + .is_some_and(|name| name.as_ref() == index_name) + }) + .with_context(|| { + let mut index_names: Vec = index_locations + .indexes() + .filter_map(|index| index.name.as_ref()) + .map(ToString::to_string) + .collect(); + index_names.sort(); + if index_names.is_empty() { + format!("No indexes were found, can't use index: `{index_name}`") + } else { + let index_names = index_names.join("`, `"); + format!( + "Index not found: `{index_name}`. Found indexes: `{index_names}`" + ) + } + })?; + let publish_url = index + .publish_url + .clone() + .with_context(|| format!("Index is missing a publish URL: `{index_name}`"))?; + let check_url = index.url.clone(); + (publish_url, Some(check_url)) + } else { + (publish_url, check_url) + }; + commands::publish( files, publish_url, diff --git a/crates/uv/src/settings.rs b/crates/uv/src/settings.rs index b2ddf1376f65..78a14f55f31f 100644 --- a/crates/uv/src/settings.rs +++ b/crates/uv/src/settings.rs @@ -2829,12 +2829,16 @@ pub(crate) struct PublishSettings { pub(crate) files: Vec, pub(crate) username: Option, pub(crate) password: Option, + pub(crate) index: Option, // Both CLI and configuration. pub(crate) publish_url: Url, pub(crate) trusted_publishing: TrustedPublishing, pub(crate) keyring_provider: KeyringProviderType, pub(crate) check_url: Option, + + // Configuration only + pub(crate) index_locations: IndexLocations, } impl PublishSettings { @@ -2852,7 +2856,11 @@ impl PublishSettings { check_url, } = publish; let ResolverInstallerOptions { - keyring_provider, .. + keyring_provider, + index, + extra_index_url, + index_url, + .. } = top_level; // Tokens are encoded in the same way as username/password @@ -2878,6 +2886,17 @@ impl PublishSettings { .combine(keyring_provider) .unwrap_or_default(), check_url: args.check_url.combine(check_url), + index: args.index, + index_locations: IndexLocations::new( + index + .into_iter() + .flatten() + .chain(extra_index_url.into_iter().flatten().map(Index::from)) + .chain(index_url.into_iter().map(Index::from)) + .collect(), + Vec::new(), + false, + ), } } } diff --git a/crates/uv/tests/it/publish.rs b/crates/uv/tests/it/publish.rs index 46a5b463a6b3..1cd40e15c80f 100644 --- a/crates/uv/tests/it/publish.rs +++ b/crates/uv/tests/it/publish.rs @@ -1,7 +1,9 @@ use crate::common::{uv_snapshot, venv_bin_path, TestContext}; use assert_cmd::assert::OutputAssertExt; -use assert_fs::fixture::{FileTouch, PathChild}; +use assert_fs::fixture::{FileTouch, FileWriteStr, PathChild}; +use indoc::indoc; use std::env; +use std::env::current_dir; use uv_static::EnvVars; #[test] @@ -324,3 +326,71 @@ fn check_keyring_behaviours() { "### ); } + +#[test] +fn invalid_index() { + let context = TestContext::new("3.12"); + + let pyproject_toml = indoc! {r#" + [project] + name = "foo" + version = "0.1.0" + + [[tool.uv.index]] + name = "foo" + url = "https://example.com" + + [[tool.uv.index]] + name = "internal" + url = "https://internal.example.org" + "#}; + context + .temp_dir + .child("pyproject.toml") + .write_str(pyproject_toml) + .unwrap(); + + let ok_wheel = current_dir() + .unwrap() + .join("../../scripts/links/ok-1.0.0-py3-none-any.whl"); + + // No such index + uv_snapshot!(context.filters(), context.publish() + .arg("-u") + .arg("__token__") + .arg("-p") + .arg("dummy") + .arg("--index") + .arg("bar") + .arg(&ok_wheel) + .current_dir(context.temp_dir.path()), @r###" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + warning: `uv publish` is experimental and may change without warning + error: Index not found: `bar`. Found indexes: `foo`, `internal` + "### + ); + + // Index does not have a publish URL + uv_snapshot!(context.filters(), context.publish() + .arg("-u") + .arg("__token__") + .arg("-p") + .arg("dummy") + .arg("--index") + .arg("foo") + .arg(&ok_wheel) + .current_dir(context.temp_dir.path()), @r###" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + warning: `uv publish` is experimental and may change without warning + error: Index is missing a publish URL: `foo` + "### + ); +} diff --git a/crates/uv/tests/it/show_settings.rs b/crates/uv/tests/it/show_settings.rs index 0835e2326a2e..8d50670d0660 100644 --- a/crates/uv/tests/it/show_settings.rs +++ b/crates/uv/tests/it/show_settings.rs @@ -118,6 +118,7 @@ fn resolve_uv_toml() -> anyhow::Result<()> { explicit: false, default: true, origin: None, + publish_url: None, }, ], flat_index: [], @@ -272,6 +273,7 @@ fn resolve_uv_toml() -> anyhow::Result<()> { explicit: false, default: true, origin: None, + publish_url: None, }, ], flat_index: [], @@ -427,6 +429,7 @@ fn resolve_uv_toml() -> anyhow::Result<()> { explicit: false, default: true, origin: None, + publish_url: None, }, ], flat_index: [], @@ -614,6 +617,7 @@ fn resolve_pyproject_toml() -> anyhow::Result<()> { explicit: false, default: true, origin: None, + publish_url: None, }, ], flat_index: [], @@ -906,6 +910,7 @@ fn resolve_pyproject_toml() -> anyhow::Result<()> { explicit: false, default: true, origin: None, + publish_url: None, }, ], flat_index: [], @@ -1085,6 +1090,7 @@ fn resolve_index_url() -> anyhow::Result<()> { explicit: false, default: false, origin: None, + publish_url: None, }, Index { name: None, @@ -1113,6 +1119,7 @@ fn resolve_index_url() -> anyhow::Result<()> { explicit: false, default: true, origin: None, + publish_url: None, }, ], flat_index: [], @@ -1271,6 +1278,7 @@ fn resolve_index_url() -> anyhow::Result<()> { origin: Some( Cli, ), + publish_url: None, }, Index { name: None, @@ -1299,6 +1307,7 @@ fn resolve_index_url() -> anyhow::Result<()> { explicit: false, default: false, origin: None, + publish_url: None, }, Index { name: None, @@ -1327,6 +1336,7 @@ fn resolve_index_url() -> anyhow::Result<()> { explicit: false, default: true, origin: None, + publish_url: None, }, ], flat_index: [], @@ -1507,6 +1517,7 @@ fn resolve_find_links() -> anyhow::Result<()> { explicit: false, default: false, origin: None, + publish_url: None, }, ], no_index: true, @@ -1826,6 +1837,7 @@ fn resolve_top_level() -> anyhow::Result<()> { explicit: false, default: false, origin: None, + publish_url: None, }, Index { name: None, @@ -1854,6 +1866,7 @@ fn resolve_top_level() -> anyhow::Result<()> { explicit: false, default: false, origin: None, + publish_url: None, }, ], flat_index: [], @@ -2008,6 +2021,7 @@ fn resolve_top_level() -> anyhow::Result<()> { explicit: false, default: false, origin: None, + publish_url: None, }, Index { name: None, @@ -2036,6 +2050,7 @@ fn resolve_top_level() -> anyhow::Result<()> { explicit: false, default: false, origin: None, + publish_url: None, }, ], flat_index: [], @@ -3089,6 +3104,7 @@ fn resolve_both() -> anyhow::Result<()> { explicit: false, default: true, origin: None, + publish_url: None, }, ], flat_index: [], @@ -3366,6 +3382,7 @@ fn resolve_config_file() -> anyhow::Result<()> { explicit: false, default: true, origin: None, + publish_url: None, }, ], flat_index: [], @@ -4058,6 +4075,7 @@ fn index_priority() -> anyhow::Result<()> { origin: Some( Cli, ), + publish_url: None, }, Index { name: None, @@ -4086,6 +4104,7 @@ fn index_priority() -> anyhow::Result<()> { explicit: false, default: false, origin: None, + publish_url: None, }, ], flat_index: [], @@ -4242,6 +4261,7 @@ fn index_priority() -> anyhow::Result<()> { origin: Some( Cli, ), + publish_url: None, }, Index { name: None, @@ -4270,6 +4290,7 @@ fn index_priority() -> anyhow::Result<()> { explicit: false, default: false, origin: None, + publish_url: None, }, ], flat_index: [], @@ -4432,6 +4453,7 @@ fn index_priority() -> anyhow::Result<()> { origin: Some( Cli, ), + publish_url: None, }, Index { name: None, @@ -4460,6 +4482,7 @@ fn index_priority() -> anyhow::Result<()> { explicit: false, default: true, origin: None, + publish_url: None, }, ], flat_index: [], @@ -4617,6 +4640,7 @@ fn index_priority() -> anyhow::Result<()> { origin: Some( Cli, ), + publish_url: None, }, Index { name: None, @@ -4645,6 +4669,7 @@ fn index_priority() -> anyhow::Result<()> { explicit: false, default: true, origin: None, + publish_url: None, }, ], flat_index: [], @@ -4809,6 +4834,7 @@ fn index_priority() -> anyhow::Result<()> { origin: Some( Cli, ), + publish_url: None, }, Index { name: None, @@ -4837,6 +4863,7 @@ fn index_priority() -> anyhow::Result<()> { explicit: false, default: true, origin: None, + publish_url: None, }, ], flat_index: [], @@ -4994,6 +5021,7 @@ fn index_priority() -> anyhow::Result<()> { origin: Some( Cli, ), + publish_url: None, }, Index { name: None, @@ -5022,6 +5050,7 @@ fn index_priority() -> anyhow::Result<()> { explicit: false, default: true, origin: None, + publish_url: None, }, ], flat_index: [], diff --git a/docs/configuration/environment.md b/docs/configuration/environment.md index db6e07279c55..064a406ec3f5 100644 --- a/docs/configuration/environment.md +++ b/docs/configuration/environment.md @@ -226,6 +226,11 @@ for more details. Don't upload a file if it already exists on the index. The value is the URL of the index. +### `UV_PUBLISH_INDEX` + +Equivalent to the `--index` command-line argument in `uv publish`. If +set, uv the index with this name in the configuration for publishing. + ### `UV_PUBLISH_PASSWORD` Equivalent to the `--password` command-line argument in `uv publish`. If diff --git a/docs/guides/publish.md b/docs/guides/publish.md index f3c1a6ffbd1c..d429ea26ba4a 100644 --- a/docs/guides/publish.md +++ b/docs/guides/publish.md @@ -69,14 +69,29 @@ PyPI from GitHub Actions, you don't need to set any credentials. Instead, generate a token. Using a token is equivalent to setting `--username __token__` and using the token as password. +If you're using a custom index through `[[tool.uv.index]]`, add `publish-url` and use +`uv publish --index `. For example: + +```toml +[[tool.uv.index]] +name = "testpypi" +url = "https://test.pypi.org/simple/" +publish-url = "https://test.pypi.org/legacy/" +``` + +!!! note + + When using `uv publish --index `, the `pyproject.toml` must be present, i.e. you need to + have a checkout step in a publish CI job. + Even though `uv publish` retries failed uploads, it can happen that publishing fails in the middle, with some files uploaded and some files still missing. With PyPI, you can retry the exact same command, existing identical files will be ignored. With other registries, use -`--check-url ` with the index URL (not the publish URL) the packages belong to. uv will -skip uploading files that are identical to files in the registry, and it will also handle raced -parallel uploads. Note that existing files need to match exactly with those previously uploaded to -the registry, this avoids accidentally publishing source distribution and wheels with different -contents for the same version. +`--check-url ` with the index URL (not the publish URL) the packages belong to. When +using `--index`, the index URL is used as check URL. uv will skip uploading files that are identical +to files in the registry, and it will also handle raced parallel uploads. Note that existing files +need to match exactly with those previously uploaded to the registry, this avoids accidentally +publishing source distribution and wheels with different contents for the same version. ## Installing your package diff --git a/docs/reference/cli.md b/docs/reference/cli.md index afd36fd647ed..1396e4617687 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -8122,6 +8122,15 @@ uv publish [OPTIONS] [FILES]...
--help, -h

Display the concise help for this command

+
--index index

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

+ +

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

+ +
The index `url` will be used to check for existing files to skip duplicate uploads.
+
+With these settings, the following two calls are equivalent:
+ +

May also be set with the UV_PUBLISH_INDEX environment variable.

--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.

diff --git a/scripts/publish/test_publish.py b/scripts/publish/test_publish.py index caf8ea308c4c..55666274c1c7 100644 --- a/scripts/publish/test_publish.py +++ b/scripts/publish/test_publish.py @@ -104,6 +104,17 @@ class TargetConfiguration: project_name: str publish_url: str index_url: str + index: str | None = None + + def index_declaration(self) -> str | None: + if not self.index: + return None + return ( + "[[tool.uv.index]]\n" + + f'name = "{self.index}"\n' + + f'url = "{self.index_url}"\n' + + f'publish-url = "{self.publish_url}"\n' + ) # Map CLI target name to package name and index url. @@ -114,6 +125,7 @@ class TargetConfiguration: "astral-test-token", TEST_PYPI_PUBLISH_URL, "https://test.pypi.org/simple/", + "test-pypi", ), "pypi-password-env": TargetConfiguration( "astral-test-password", @@ -209,10 +221,12 @@ def get_filenames(url: str, client: httpx.Client) -> list[str]: def build_project_at_version( - project_name: str, version: Version, uv: Path, modified: bool = False + target: str, version: Version, uv: Path, modified: bool = False ) -> Path: """Build a source dist and a wheel with the project name and an unclaimed version.""" + project_name = all_targets[target].project_name + if modified: dir_name = f"{project_name}-modified" else: @@ -225,7 +239,7 @@ def build_project_at_version( [uv, "init", "-p", PYTHON_VERSION, "--lib", "--name", project_name, dir_name], cwd=cwd, ) - project_root.joinpath("pyproject.toml").write_text( + toml = ( "[project]\n" + f'name = "{project_name}"\n' # Set to an unclaimed version @@ -233,6 +247,10 @@ def build_project_at_version( # Add all supported metadata + PYPROJECT_TAIL ) + if index_declaration := all_targets[target].index_declaration(): + toml += index_declaration + + project_root.joinpath("pyproject.toml").write_text(toml) shutil.copy( cwd.parent.parent.joinpath("LICENSE-APACHE"), cwd.joinpath(dir_name).joinpath("LICENSE-APACHE"), @@ -320,7 +338,7 @@ def publish_project(target: str, uv: Path, client: httpx.Client): # The distributions are build to the dist directory of the project. previous_version = get_latest_version(project_name, client) version = get_new_version(previous_version) - project_dir = build_project_at_version(project_name, version, uv) + project_dir = build_project_at_version(target, version, uv) # Upload configuration publish_url = all_targets[target].publish_url @@ -358,17 +376,28 @@ def publish_project(target: str, uv: Path, client: httpx.Client): f"---\n{output}\n---" ) - print(f"\n=== 3. Publishing {project_name} {version} again with check URL ===") + mode = "index" if all_targets[target].index else "check URL" + print(f"\n=== 3. Publishing {project_name} {version} again with {mode} ===") wait_for_index(index_url, project_name, version, uv) - args = [ - uv, - "publish", - "--publish-url", - publish_url, - "--check-url", - index_url, - *extra_args, - ] + # Test twine-style and index-style uploads for different packages. + if index := all_targets[target].index: + args = [ + uv, + "publish", + "--index", + index, + *extra_args, + ] + else: + args = [ + uv, + "publish", + "--publish-url", + publish_url, + "--check-url", + index_url, + *extra_args, + ] output = run( args, cwd=project_dir, env=env, text=True, check=True, stderr=PIPE ).stderr @@ -385,9 +414,7 @@ def publish_project(target: str, uv: Path, client: httpx.Client): # Build a different source dist and wheel at the same version, so the upload fails del project_dir - modified_project_dir = build_project_at_version( - project_name, version, uv, modified=True - ) + modified_project_dir = build_project_at_version(target, version, uv, modified=True) print( f"\n=== 4. Publishing modified {project_name} {version} " diff --git a/uv.schema.json b/uv.schema.json index 6b6c613a612f..d49eaeb56b61 100644 --- a/uv.schema.json +++ b/uv.schema.json @@ -685,6 +685,14 @@ } ] }, + "publish-url": { + "description": "The URL of the upload endpoint.\n\nWhen using `uv publish --index `, this URL is used for publishing.\n\nA configuration for the default index PyPI would look as follows:\n\n```toml [[tool.uv.index]] name = \"pypi\" url = \"https://pypi.org/simple\" publish-url = \"https://upload.pypi.org/legacy/\" ```", + "type": [ + "string", + "null" + ], + "format": "uri" + }, "url": { "description": "The URL of the index.\n\nExpects to receive a URL (e.g., `https://pypi.org/simple`) or a local path.", "allOf": [