Skip to content
Merged
Show file tree
Hide file tree
Changes from 10 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 21 additions & 10 deletions crates/uv-cli/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -906,20 +906,27 @@ fn parse_find_links(input: &str) -> Result<Maybe<PipFindLinks>, String> {
}
}

/// Parse an `--index` argument into an [`Index`], mapping the empty string to `None`.
fn parse_index(input: &str) -> Result<Maybe<Index>, String> {
if input.is_empty() {
Ok(Maybe::None)
} else {
match Index::from_str(input) {
Ok(index) => Ok(Maybe::Some(Index {
/// Parse an `--index` argument into a [`Vec<Index>`], mapping the empty string to an empty Vec.
///
/// This function splits the input on all whitespace characters rather than a single delimiter,
/// which is necessary to parse environment variables like `PIP_EXTRA_INDEX_URL`.
/// The standard `clap::Args` value_delimiter only supports single-character delimiters.
fn parse_indices(input: &str) -> Result<Vec<Maybe<Index>>, String> {
if input.trim().is_empty() {
return Ok(Vec::new());
}
let mut indices = Vec::new();
for token in input.split_whitespace() {
match Index::from_str(token) {
Ok(index) => indices.push(Maybe::Some(Index {
default: false,
origin: Some(Origin::Cli),
..index
})),
Err(err) => Err(err.to_string()),
Err(e) => return Err(e.to_string()),
}
}
Ok(indices)
}

/// Parse a `--default-index` argument into an [`Index`], mapping the empty string to `None`.
Expand Down Expand Up @@ -4897,8 +4904,12 @@ pub struct IndexArgs {
/// All indexes provided via this flag take priority over the index specified by
/// `--default-index` (which defaults to PyPI). When multiple `--index` flags are provided,
/// earlier values take priority.
#[arg(long, env = EnvVars::UV_INDEX, value_delimiter = ' ', value_parser = parse_index, help_heading = "Index options")]
pub index: Option<Vec<Maybe<Index>>>,
///
/// The nested Vec structure (`Vec<Vec<Maybe<Index>>>`) is required for clap's
/// value parsing mechanism, which processes one value at a time, in order to handle
/// `UV_INDEX` the same way pip handles `PIP_EXTRA_INDEX_URL`.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
///
/// The nested Vec structure (`Vec<Vec<Maybe<Index>>>`) is required for clap's
/// value parsing mechanism, which processes one value at a time, in order to handle
/// `UV_INDEX` the same way pip handles `PIP_EXTRA_INDEX_URL`.
//
// The nested Vec structure (`Vec<Vec<Maybe<Index>>>`) is required for clap's
// value parsing mechanism, which processes one value at a time, in order to handle
// `UV_INDEX` the same way pip handles `PIP_EXTRA_INDEX_URL`.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(This kind of detail shouldn't be part of --help)

#[arg(long, env = EnvVars::UV_INDEX, value_parser = parse_indices, help_heading = "Index options")]
pub index: Option<Vec<Vec<Maybe<Index>>>>,

/// The URL of the default package index (by default: <https://pypi.org/simple>).
///
Expand Down
32 changes: 21 additions & 11 deletions crates/uv-cli/src/options.rs
Original file line number Diff line number Diff line change
Expand Up @@ -199,9 +199,13 @@ impl From<IndexArgs> for PipOptions {
index: default_index
.and_then(Maybe::into_option)
.map(|default_index| vec![default_index])
.combine(
index.map(|index| index.into_iter().filter_map(Maybe::into_option).collect()),
),
.combine(index.map(|index| {
index
.iter()
.flat_map(std::clone::Clone::clone)
.filter_map(Maybe::into_option)
.collect()
})),
index_url: index_url.and_then(Maybe::into_option),
extra_index_url: extra_index_url.map(|extra_index_urls| {
extra_index_urls
Expand Down Expand Up @@ -260,11 +264,13 @@ pub fn resolver_options(
.default_index
.and_then(Maybe::into_option)
.map(|default_index| vec![default_index])
.combine(
index_args
.index
.map(|index| index.into_iter().filter_map(Maybe::into_option).collect()),
),
.combine(index_args.index.map(|index| {
index
.into_iter()
.flat_map(|v| v.clone())
.filter_map(Maybe::into_option)
.collect()
})),
index_url: index_args.index_url.and_then(Maybe::into_option),
extra_index_url: index_args.extra_index_url.map(|extra_index_url| {
extra_index_url
Expand Down Expand Up @@ -352,9 +358,13 @@ pub fn resolver_installer_options(
.default_index
.and_then(Maybe::into_option)
.map(|default_index| vec![default_index]);
let index = index_args
.index
.map(|index| index.into_iter().filter_map(Maybe::into_option).collect());
let index = index_args.index.map(|index| {
index
.into_iter()
.flat_map(|v| v.clone())
.filter_map(Maybe::into_option)
.collect()
});

ResolverInstallerOptions {
index: default_index.combine(index),
Expand Down
1 change: 1 addition & 0 deletions crates/uv/src/settings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1279,6 +1279,7 @@ impl AddSettings {
.index
.clone()
.into_iter()
.flat_map(|v| v.clone())
.flatten()
.filter_map(Maybe::into_option),
)
Expand Down
106 changes: 106 additions & 0 deletions crates/uv/tests/it/edit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9945,6 +9945,112 @@ fn repeated_index_cli_environment_variable() -> Result<()> {
Ok(())
}

/// If an index is repeated on the CLI, the last-provided index should take precedence.
/// Newlines in `UV_INDEX` should be treated as separators.
///
/// The index that appears in the `pyproject.toml` should also be consistent with the index that
/// appears in the `uv.lock`.
///
/// See: <https://github.com/astral-sh/uv/issues/11312>
#[test]
fn repeated_index_cli_environment_variable_newline() -> Result<()> {
let context = TestContext::new("3.12");

let pyproject_toml = context.temp_dir.child("pyproject.toml");
pyproject_toml.write_str(indoc! {r#"
[project]
name = "project"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = []
"#})?;

uv_snapshot!(context.filters(), context
.add()
.env(EnvVars::UV_INDEX, "https://test.pypi.org/simple\nhttps://test.pypi.org/simple/")
.arg("iniconfig"), @r###"
success: true
exit_code: 0
----- stdout -----

----- stderr -----
Resolved 2 packages in [TIME]
Prepared 1 package in [TIME]
Installed 1 package in [TIME]
+ iniconfig==2.0.0
"###);

let pyproject_toml = context.read("pyproject.toml");

insta::with_settings!({
filters => context.filters(),
}, {
assert_snapshot!(
pyproject_toml, @r###"
[project]
name = "project"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = [
"iniconfig>=2.0.0",
]

[[tool.uv.index]]
url = "https://test.pypi.org/simple"
"###
);
});

let lock = context.read("uv.lock");

insta::with_settings!({
filters => context.filters(),
}, {
assert_snapshot!(
lock, @r#"
version = 1
revision = 1
requires-python = ">=3.12"

[options]
exclude-newer = "2024-03-25T00:00:00Z"

[[package]]
name = "iniconfig"
version = "2.0.0"
source = { registry = "https://test.pypi.org/simple" }
sdist = { url = "https://test-files.pythonhosted.org/packages/d7/4b/cbd8e699e64a6f16ca3a8220661b5f83792b3017d0f79807cb8708d33913/iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3", size = 4646 }
wheels = [
{ url = "https://test-files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374", size = 5892 },
]

[[package]]
name = "project"
version = "0.1.0"
source = { virtual = "." }
dependencies = [
{ name = "iniconfig" },
]

[package.metadata]
requires-dist = [{ name = "iniconfig", specifier = ">=2.0.0" }]
"#
);
});

// Install from the lockfile.
uv_snapshot!(context.filters(), context.sync().arg("--frozen"), @r###"
success: true
exit_code: 0
----- stdout -----

----- stderr -----
Audited 1 package in [TIME]
"###);

Ok(())
}

/// If an index is repeated on the CLI, the last-provided index should take precedence.
///
/// The index that appears in the `pyproject.toml` should also be consistent with the index that
Expand Down
Loading