Publish: Support --index <name> (#9694)

When publishing, we currently ask the user to set `--publish-url` to the
upload URL and `--check-url` to the simple index URL, or the equivalent
configuration keys. But that's redundant with the `[[tool.uv.index]]`
declaration. Instead, we extend `[[tool.uv.index]]` with a `publish-url`
entry and allow passing `uv publish --index <name>`.

`uv publish --index <name>` requires the `pyproject.toml` to be present
when publishing, unlike using `--publish-url ... --check-url ...` which
can be used e.g. in CI without a checkout step. `--index` also always
uses the check URL feature to aid upload consistency.

The documentation tries to explain both approaches together, which
overlap for the check URL feature.

Fixes #8864

---------

Co-authored-by: Zanie Blue <contact@zanie.dev>
This commit is contained in:
konsti 2024-12-10 22:17:47 +01:00 committed by GitHub
parent a090cf1f12
commit 321101d340
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 300 additions and 30 deletions

View File

@ -5180,14 +5180,32 @@ pub struct PublishArgs {
#[arg(default_value = "dist/*")]
pub files: Vec<String>,
/// 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 (<https://upload.pypi.org/legacy/>).
#[arg(long, env = EnvVars::UV_PUBLISH_URL)]
pub publish_url: Option<Url>,
/// ```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<String>,
/// The username for the upload.
#[arg(short, long, env = EnvVars::UV_PUBLISH_USERNAME)]
@ -5227,6 +5245,15 @@ pub struct PublishArgs {
#[arg(long, value_enum, env = EnvVars::UV_KEYRING_PROVIDER)]
pub keyring_provider: Option<KeyringProviderType>,
/// 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 (<https://upload.pypi.org/legacy/>).
#[arg(long, env = EnvVars::UV_PUBLISH_URL)]
pub publish_url: Option<Url>,
/// 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

View File

@ -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 <name>`, 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<Url>,
}
// #[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,
})
}
}

View File

@ -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";

View File

@ -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;
@ -1210,8 +1210,46 @@ async fn run(mut cli: Cli) -> Result<ExitStatus> {
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<String> = 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,

View File

@ -2854,12 +2854,16 @@ pub(crate) struct PublishSettings {
pub(crate) files: Vec<String>,
pub(crate) username: Option<String>,
pub(crate) password: Option<String>,
pub(crate) index: Option<String>,
// Both CLI and configuration.
pub(crate) publish_url: Url,
pub(crate) trusted_publishing: TrustedPublishing,
pub(crate) keyring_provider: KeyringProviderType,
pub(crate) check_url: Option<IndexUrl>,
// Configuration only
pub(crate) index_locations: IndexLocations,
}
impl PublishSettings {
@ -2877,7 +2881,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
@ -2903,6 +2911,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,
),
}
}
}

View File

@ -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`
"###
);
}

View File

@ -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: [],

View File

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

View File

@ -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 <name>`. 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 <name>`, 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 <index 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 <index 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

View File

@ -8152,6 +8152,15 @@ uv publish [OPTIONS] [FILES]...
</dd><dt><code>--help</code>, <code>-h</code></dt><dd><p>Display the concise help for this command</p>
</dd><dt><code>--index</code> <i>index</i></dt><dd><p>The name of an index in the configuration to use for publishing.</p>
<p>The index must have a <code>publish-url</code> setting, for example:</p>
<pre><code class="language-toml [[tool.uv.index]] name = &quot;pypi&quot; url = &quot;https://pypi.org/simple&quot; publish-url = &quot;https://upload.pypi.org/legacy/&quot; ```">The index `url` will be used to check for existing files to skip duplicate uploads.
With these settings, the following two calls are equivalent:</code></pre>
<p>May also be set with the <code>UV_PUBLISH_INDEX</code> environment variable.</p>
</dd><dt><code>--keyring-provider</code> <i>keyring-provider</i></dt><dd><p>Attempt to use <code>keyring</code> for authentication for remote requirements files.</p>
<p>At present, only <code>--keyring-provider subprocess</code> is supported, which configures uv to use the <code>keyring</code> CLI to handle authentication.</p>

View File

@ -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 @@ local_targets: dict[str, 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} "

8
uv.schema.json generated
View File

@ -685,6 +685,14 @@
}
]
},
"publish-url": {
"description": "The URL of the upload endpoint.\n\nWhen using `uv publish --index <name>`, 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": [