diff --git a/crates/uv-cli/src/lib.rs b/crates/uv-cli/src/lib.rs index 074da2589dd2e..134abe46933f6 100644 --- a/crates/uv-cli/src/lib.rs +++ b/crates/uv-cli/src/lib.rs @@ -765,6 +765,10 @@ impl Maybe { Maybe::None => None, } } + + pub fn is_some(&self) -> bool { + matches!(self, Maybe::Some(_)) + } } /// Parse a string into an [`IndexUrl`], mapping the empty string to `None`. diff --git a/crates/uv-workspace/src/pyproject_mut.rs b/crates/uv-workspace/src/pyproject_mut.rs index de29526482844..0f0eb4164ed25 100644 --- a/crates/uv-workspace/src/pyproject_mut.rs +++ b/crates/uv-workspace/src/pyproject_mut.rs @@ -1,3 +1,4 @@ +use distribution_types::Index; use itertools::Itertools; use pep440_rs::{Version, VersionSpecifier, VersionSpecifiers}; use pep508_rs::{ExtraName, MarkerTree, PackageName, Requirement, VersionOrUrl}; @@ -5,7 +6,7 @@ use std::path::Path; use std::str::FromStr; use std::{fmt, mem}; use thiserror::Error; -use toml_edit::{Array, DocumentMut, Item, RawString, Table, TomlError, Value}; +use toml_edit::{Array, ArrayOfTables, DocumentMut, Item, RawString, Table, TomlError, Value}; use uv_fs::PortablePath; use crate::pyproject::{DependencyType, Source}; @@ -190,6 +191,77 @@ impl PyProjectTomlMut { Ok(edit) } + /// Add an [`Index`] to `tool.uv.index`. + pub fn add_index(&mut self, index: &Index) -> Result<(), Error> { + let existing = self + .doc + .entry("tool") + .or_insert(implicit()) + .as_table_mut() + .ok_or(Error::MalformedSources)? + .entry("uv") + .or_insert(implicit()) + .as_table_mut() + .ok_or(Error::MalformedSources)? + .entry("index") + .or_insert(Item::ArrayOfTables(ArrayOfTables::new())) + .as_array_of_tables() + .ok_or(Error::MalformedSources)?; + + let mut table = Table::new(); + if let Some(name) = index.name.as_ref() { + table.insert("name", toml_edit::value(name.to_string())); + } + table.insert("url", toml_edit::value(index.url.to_string())); + if index.default { + table.insert("default", toml_edit::value(true)); + } + + // Push the item to the table. + let mut updated = ArrayOfTables::new(); + updated.push(table); + for table in existing { + // If there's another index with the same name, replace it. + if table + .get("name") + .is_some_and(|name| name.as_str() == index.name.as_deref()) + { + continue; + } + + // If there's another default index, remove it. + if index.default + && table + .get("default") + .is_some_and(|default| default.as_bool() == Some(true)) + { + continue; + } + + // If there's another index with the same URL, replace it. + if table + .get("url") + .is_some_and(|url| url.as_str() == Some(index.url.url().as_str())) + { + continue; + } + + updated.push(table.clone()); + } + self.doc + .entry("tool") + .or_insert(implicit()) + .as_table_mut() + .ok_or(Error::MalformedSources)? + .entry("uv") + .or_insert(implicit()) + .as_table_mut() + .ok_or(Error::MalformedSources)? + .insert("index", Item::ArrayOfTables(updated)); + + Ok(()) + } + /// Adds a dependency to `project.optional-dependencies`. /// /// Returns `true` if the dependency was added, `false` if it was updated. diff --git a/crates/uv/src/commands/project/add.rs b/crates/uv/src/commands/project/add.rs index 17db35922c395..b17a822721567 100644 --- a/crates/uv/src/commands/project/add.rs +++ b/crates/uv/src/commands/project/add.rs @@ -9,7 +9,7 @@ use rustc_hash::{FxBuildHasher, FxHashMap}; use tracing::debug; use cache_key::RepositoryUrl; -use distribution_types::UnresolvedRequirement; +use distribution_types::{Index, UnresolvedRequirement}; use pep508_rs::{ExtraName, Requirement, UnnamedRequirement, VersionOrUrl}; use pypi_types::{redact_git_credentials, ParsedUrl, RequirementSource, VerbatimParsedUrl}; use uv_auth::{store_credentials, store_credentials_from_url, Credentials}; @@ -59,6 +59,7 @@ pub(crate) async fn add( editable: Option, dependency_type: DependencyType, raw_sources: bool, + indexes: Vec, rev: Option, tag: Option, branch: Option, @@ -465,6 +466,13 @@ pub(crate) async fn add( }); } + // Add any indexes that were provided on the command-line. + if !raw_sources { + for index in indexes { + toml.add_index(&index)?; + } + } + let content = toml.to_string(); // Save the modified `pyproject.toml` or script. diff --git a/crates/uv/src/lib.rs b/crates/uv/src/lib.rs index 7eb87fcae77c3..58b56ed5ac59e 100644 --- a/crates/uv/src/lib.rs +++ b/crates/uv/src/lib.rs @@ -1356,6 +1356,7 @@ async fn run_project( args.editable, args.dependency_type, args.raw_sources, + args.indexes, args.rev, args.tag, args.branch, diff --git a/crates/uv/src/settings.rs b/crates/uv/src/settings.rs index 49e649e722680..e761f8ae7fa0e 100644 --- a/crates/uv/src/settings.rs +++ b/crates/uv/src/settings.rs @@ -804,6 +804,7 @@ pub(crate) struct AddSettings { pub(crate) script: Option, pub(crate) python: Option, pub(crate) refresh: Refresh, + pub(crate) indexes: Vec, pub(crate) settings: ResolverInstallerSettings, } @@ -842,6 +843,51 @@ impl AddSettings { DependencyType::Production }; + // Track the `--index` and `--default-index` arguments from the command-line. + let indexes = installer + .index_args + .default_index + .clone() + .and_then(Maybe::into_option) + .into_iter() + .chain( + installer + .index_args + .index + .clone() + .into_iter() + .flatten() + .filter_map(Maybe::into_option), + ) + .collect::>(); + + // If the user passed an `--index-url` or `--extra-index-url`, warn. + if installer + .index_args + .index_url + .as_ref() + .is_some_and(Maybe::is_some) + { + if script.is_some() { + warn_user_once!("Indexes specified via `--index-url` will not be persisted to the script; use `--default-index` instead."); + } else { + warn_user_once!("Indexes specified via `--index-url` will not be persisted to the `pyproject.toml` file; use `--default-index` instead."); + } + } + + if installer + .index_args + .extra_index_url + .as_ref() + .is_some_and(|extra_index_url| extra_index_url.iter().any(Maybe::is_some)) + { + if script.is_some() { + warn_user_once!("Indexes specified via `--extra-index-url` will not be persisted to the script; use `--index` instead."); + } else { + warn_user_once!("Indexes specified via `--extra-index-url` will not be persisted to the `pyproject.toml` file; use `--index` instead."); + } + } + Self { locked, frozen, @@ -856,6 +902,7 @@ impl AddSettings { package, script, python, + indexes, editable: flag(editable, no_editable), extras: extra.unwrap_or_default(), refresh: Refresh::from(refresh), diff --git a/crates/uv/tests/edit.rs b/crates/uv/tests/edit.rs index 68042e69120cd..7e17852a6f9e5 100644 --- a/crates/uv/tests/edit.rs +++ b/crates/uv/tests/edit.rs @@ -3707,7 +3707,7 @@ fn add_lower_bound_local() -> Result<()> { "#})?; // Adding `torch` should include a lower-bound, but no local segment. - uv_snapshot!(context.filters(), context.add().arg("local-simple-a").arg("--extra-index-url").arg(packse_index_url()).env_remove("UV_EXCLUDE_NEWER"), @r###" + uv_snapshot!(context.filters(), context.add().arg("local-simple-a").arg("--index").arg(packse_index_url()).env_remove("UV_EXCLUDE_NEWER"), @r###" success: true exit_code: 0 ----- stdout ----- @@ -3738,6 +3738,9 @@ fn add_lower_bound_local() -> Result<()> { [build-system] requires = ["setuptools>=42"] build-backend = "setuptools.build_meta" + + [[tool.uv.index]] + url = "https://astral-sh.github.io/packse/PACKSE_VERSION/simple-html/" "### ); }); @@ -4865,3 +4868,659 @@ fn add_shadowed_name() -> Result<()> { Ok(()) } + +/// Warn when a user provides an index via `--index-url` or `--extra-index-url`. +#[test] +fn add_warn_index_url() -> 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 = [] + + [build-system] + requires = ["setuptools>=42"] + build-backend = "setuptools.build_meta" + "#})?; + + uv_snapshot!(context.filters(), context.add().arg("idna").arg("--index-url").arg("https://pypi.org/simple"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + warning: Indexes specified via `--index-url` will not be persisted to the `pyproject.toml` file; use `--default-index` instead. + Resolved 2 packages in [TIME] + Prepared 2 packages in [TIME] + Installed 2 packages in [TIME] + + idna==3.6 + + project==0.1.0 (from file://[TEMP_DIR]/) + "###); + + let pyproject_toml = fs_err::read_to_string(context.temp_dir.join("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 = [ + "idna>=3.6", + ] + + [build-system] + requires = ["setuptools>=42"] + build-backend = "setuptools.build_meta" + "### + ); + }); + + let lock = fs_err::read_to_string(context.temp_dir.join("uv.lock"))?; + + insta::with_settings!({ + filters => context.filters(), + }, { + assert_snapshot!( + lock, @r###" + version = 1 + requires-python = ">=3.12" + + [options] + exclude-newer = "2024-03-25T00:00:00Z" + + [[package]] + name = "idna" + version = "3.6" + source = { registry = "https://pypi.org/simple" } + sdist = { url = "https://files.pythonhosted.org/packages/bf/3f/ea4b9117521a1e9c50344b909be7886dd00a519552724809bb1f486986c2/idna-3.6.tar.gz", hash = "sha256:9ecdbbd083b06798ae1e86adcbfe8ab1479cf864e4ee30fe4e46a003d12491ca", size = 175426 } + wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/e7/a82b05cf63a603df6e68d59ae6a68bf5064484a0718ea5033660af4b54a9/idna-3.6-py3-none-any.whl", hash = "sha256:c05567e9c24a6b9faaa835c4821bad0590fbb9d5779e7caa6e1cc4978e7eb24f", size = 61567 }, + ] + + [[package]] + name = "project" + version = "0.1.0" + source = { editable = "." } + dependencies = [ + { name = "idna" }, + ] + + [package.metadata] + requires-dist = [{ name = "idna", specifier = ">=3.6" }] + "### + ); + }); + + uv_snapshot!(context.filters(), context.add().arg("iniconfig").arg("--extra-index-url").arg("https://test.pypi.org/simple"), @r###" + success: false + exit_code: 1 + ----- stdout ----- + + ----- stderr ----- + warning: Indexes specified via `--extra-index-url` will not be persisted to the `pyproject.toml` file; use `--index` instead. + × No solution found when resolving dependencies: + ╰─▶ Because only idna==2.7 is available and your project depends on idna>=3.6, we can conclude that your project's requirements are unsatisfiable. + + hint: `idna` was found on https://test.pypi.org/simple, but not at the requested version (idna>=3.6). A compatible version may be available on a subsequent index (e.g., https://pypi.org/simple). By default, uv will only consider versions that are published on the first index that contains a given package, to avoid dependency confusion attacks. If all indexes are equally trusted, use `--index-strategy unsafe-best-match` to consider all versions from all indexes, regardless of the order in which they were defined. + help: If you want to add the package regardless of the failed resolution, provide the `--frozen` flag to skip locking and syncing. + "###); + + Ok(()) +} + +/// Don't warn if the user provides an index via `index-url` in `pyproject.toml`. +#[test] +fn add_no_warn_index_url() -> 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 = [] + + [tool.uv] + index-url = "https://test.pypi.org/simple" + "#})?; + + uv_snapshot!(context.filters(), context.add().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 = fs_err::read_to_string(context.temp_dir.join("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 = fs_err::read_to_string(context.temp_dir.join("uv.lock"))?; + + insta::with_settings!({ + filters => context.filters(), + }, { + assert_snapshot!( + lock, @r###" + version = 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" }] + "### + ); + }); + + Ok(()) +} + +/// Add an index provided via `--index`. +#[test] +fn add_index_url() -> 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().arg("iniconfig==2.0.0").arg("--index").arg("https://pypi.org/simple").env_remove("UV_EXCLUDE_NEWER"), @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 = fs_err::read_to_string(context.temp_dir.join("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://pypi.org/simple" + "### + ); + }); + + let lock = fs_err::read_to_string(context.temp_dir.join("uv.lock"))?; + + insta::with_settings!({ + filters => context.filters(), + }, { + assert_snapshot!( + lock, @r###" + version = 1 + requires-python = ">=3.12" + + [[package]] + name = "iniconfig" + version = "2.0.0" + source = { registry = "https://pypi.org/simple" } + sdist = { url = "https://files.pythonhosted.org/packages/d7/4b/cbd8e699e64a6f16ca3a8220661b5f83792b3017d0f79807cb8708d33913/iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3", size = 4646 } + wheels = [ + { url = "https://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" }] + "### + ); + }); + + // Adding a subsequent index should put it _below_ the existing index. + uv_snapshot!(context.filters(), context.add().arg("jinja2").arg("--index").arg("pytorch=https://download.pytorch.org/whl/cu121").env_remove("UV_EXCLUDE_NEWER"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 4 packages in [TIME] + Prepared 2 packages in [TIME] + Installed 2 packages in [TIME] + + jinja2==3.1.3 + + markupsafe==2.1.5 + "###); + + let pyproject_toml = fs_err::read_to_string(context.temp_dir.join("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", + "jinja2>=3.1.3", + ] + + [[tool.uv.index]] + name = "pytorch" + url = "https://download.pytorch.org/whl/cu121" + + [[tool.uv.index]] + url = "https://pypi.org/simple" + "### + ); + }); + + let lock = fs_err::read_to_string(context.temp_dir.join("uv.lock"))?; + + insta::with_settings!({ + filters => context.filters(), + }, { + assert_snapshot!( + lock, @r###" + version = 1 + requires-python = ">=3.12" + + [[package]] + name = "iniconfig" + version = "2.0.0" + source = { registry = "https://pypi.org/simple" } + sdist = { url = "https://files.pythonhosted.org/packages/d7/4b/cbd8e699e64a6f16ca3a8220661b5f83792b3017d0f79807cb8708d33913/iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3", size = 4646 } + wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374", size = 5892 }, + ] + + [[package]] + name = "jinja2" + version = "3.1.3" + source = { registry = "https://download.pytorch.org/whl/cu121" } + dependencies = [ + { name = "markupsafe" }, + ] + wheels = [ + { url = "https://download.pytorch.org/whl/Jinja2-3.1.3-py3-none-any.whl", hash = "sha256:7d6d50dd97d52cbc355597bd845fabfbac3f551e1f99619e39a35ce8c370b5fa" }, + ] + + [[package]] + name = "markupsafe" + version = "2.1.5" + source = { registry = "https://download.pytorch.org/whl/cu121" } + wheels = [ + { url = "https://download.pytorch.org/whl/MarkupSafe-2.1.5-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:8dec4936e9c3100156f8a2dc89c4b88d5c435175ff03413b443469c7c8c5f4d1" }, + { url = "https://download.pytorch.org/whl/MarkupSafe-2.1.5-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:3c6b973f22eb18a789b1460b4b91bf04ae3f0c4234a0a6aa6b0a92f6f7b951d4" }, + { url = "https://download.pytorch.org/whl/MarkupSafe-2.1.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ac07bad82163452a6884fe8fa0963fb98c2346ba78d779ec06bd7a6262132aee" }, + { url = "https://download.pytorch.org/whl/MarkupSafe-2.1.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f5dfb42c4604dddc8e4305050aa6deb084540643ed5804d7455b5df8fe16f5e5" }, + { url = "https://download.pytorch.org/whl/MarkupSafe-2.1.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ea3d8a3d18833cf4304cd2fc9cbb1efe188ca9b5efef2bdac7adc20594a0e46b" }, + { url = "https://download.pytorch.org/whl/MarkupSafe-2.1.5-cp312-cp312-win_amd64.whl", hash = "sha256:823b65d8706e32ad2df51ed89496147a42a2a6e01c13cfb6ffb8b1e92bc910bb" }, + ] + + [[package]] + name = "project" + version = "0.1.0" + source = { virtual = "." } + dependencies = [ + { name = "iniconfig" }, + { name = "jinja2" }, + ] + + [package.metadata] + requires-dist = [ + { name = "iniconfig", specifier = "==2.0.0" }, + { name = "jinja2", specifier = ">=3.1.3" }, + ] + "### + ); + }); + + // Adding a subsequent index with the same name should replace it. + uv_snapshot!(context.filters(), context.add().arg("jinja2").arg("--index").arg("pytorch=https://test.pypi.org/simple").env_remove("UV_EXCLUDE_NEWER"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 4 packages in [TIME] + Audited 3 packages in [TIME] + "###); + + let pyproject_toml = fs_err::read_to_string(context.temp_dir.join("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", + "jinja2>=3.1.3", + ] + + [[tool.uv.index]] + name = "pytorch" + url = "https://test.pypi.org/simple" + + [[tool.uv.index]] + url = "https://pypi.org/simple" + "### + ); + }); + + let lock = fs_err::read_to_string(context.temp_dir.join("uv.lock"))?; + + insta::with_settings!({ + filters => context.filters(), + }, { + assert_snapshot!( + lock, @r###" + version = 1 + requires-python = ">=3.12" + + [[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 = "jinja2" + version = "3.1.3" + source = { registry = "https://test.pypi.org/simple" } + dependencies = [ + { name = "markupsafe" }, + ] + sdist = { url = "https://test-files.pythonhosted.org/packages/3e/f0/69ae37cced6b277dc0419dbb1c6e4fb259e5e319a1a971061a2776316bec/Jinja2-3.1.3.tar.gz", hash = "sha256:27fb536952e578492fa66d8681d8967d8bdf1eb36368b1f842b53251c9f0bfe1", size = 268254 } + wheels = [ + { url = "https://test-files.pythonhosted.org/packages/47/dc/9d1c0f1ddbedb1e67f7d00e91819b5a9157056ad83bfa64c12ecef8a4f4e/Jinja2-3.1.3-py3-none-any.whl", hash = "sha256:ddd11470e8a1dc4c30e3146400f0130fed7d85886c5f8082f309355b4b0c1128", size = 133236 }, + ] + + [[package]] + name = "markupsafe" + version = "2.1.5" + source = { registry = "https://test.pypi.org/simple" } + sdist = { url = "https://test-files.pythonhosted.org/packages/87/5b/aae44c6655f3801e81aa3eef09dbbf012431987ba564d7231722f68df02d/MarkupSafe-2.1.5.tar.gz", hash = "sha256:d283d37a890ba4c1ae73ffadf8046435c76e7bc2247bbb63c00bd1a709c6544b", size = 19384 } + wheels = [ + { url = "https://test-files.pythonhosted.org/packages/53/bd/583bf3e4c8d6a321938c13f49d44024dbe5ed63e0a7ba127e454a66da974/MarkupSafe-2.1.5-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:8dec4936e9c3100156f8a2dc89c4b88d5c435175ff03413b443469c7c8c5f4d1", size = 18215 }, + { url = "https://test-files.pythonhosted.org/packages/48/d6/e7cd795fc710292c3af3a06d80868ce4b02bfbbf370b7cee11d282815a2a/MarkupSafe-2.1.5-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:3c6b973f22eb18a789b1460b4b91bf04ae3f0c4234a0a6aa6b0a92f6f7b951d4", size = 14069 }, + { url = "https://test-files.pythonhosted.org/packages/51/b5/5d8ec796e2a08fc814a2c7d2584b55f889a55cf17dd1a90f2beb70744e5c/MarkupSafe-2.1.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ac07bad82163452a6884fe8fa0963fb98c2346ba78d779ec06bd7a6262132aee", size = 29452 }, + { url = "https://test-files.pythonhosted.org/packages/0a/0d/2454f072fae3b5a137c119abf15465d1771319dfe9e4acbb31722a0fff91/MarkupSafe-2.1.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f5dfb42c4604dddc8e4305050aa6deb084540643ed5804d7455b5df8fe16f5e5", size = 28462 }, + { url = "https://test-files.pythonhosted.org/packages/2d/75/fd6cb2e68780f72d47e6671840ca517bda5ef663d30ada7616b0462ad1e3/MarkupSafe-2.1.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ea3d8a3d18833cf4304cd2fc9cbb1efe188ca9b5efef2bdac7adc20594a0e46b", size = 27869 }, + { url = "https://test-files.pythonhosted.org/packages/b0/81/147c477391c2750e8fc7705829f7351cf1cd3be64406edcf900dc633feb2/MarkupSafe-2.1.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:d050b3361367a06d752db6ead6e7edeb0009be66bc3bae0ee9d97fb326badc2a", size = 33906 }, + { url = "https://test-files.pythonhosted.org/packages/8b/ff/9a52b71839d7a256b563e85d11050e307121000dcebc97df120176b3ad93/MarkupSafe-2.1.5-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:bec0a414d016ac1a18862a519e54b2fd0fc8bbfd6890376898a6c0891dd82e9f", size = 32296 }, + { url = "https://test-files.pythonhosted.org/packages/88/07/2dc76aa51b481eb96a4c3198894f38b480490e834479611a4053fbf08623/MarkupSafe-2.1.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:58c98fee265677f63a4385256a6d7683ab1832f3ddd1e66fe948d5880c21a169", size = 33038 }, + { url = "https://test-files.pythonhosted.org/packages/96/0c/620c1fb3661858c0e37eb3cbffd8c6f732a67cd97296f725789679801b31/MarkupSafe-2.1.5-cp312-cp312-win32.whl", hash = "sha256:8590b4ae07a35970728874632fed7bd57b26b0102df2d2b233b6d9d82f6c62ad", size = 16572 }, + { url = "https://test-files.pythonhosted.org/packages/3f/14/c3554d512d5f9100a95e737502f4a2323a1959f6d0d01e0d0997b35f7b10/MarkupSafe-2.1.5-cp312-cp312-win_amd64.whl", hash = "sha256:823b65d8706e32ad2df51ed89496147a42a2a6e01c13cfb6ffb8b1e92bc910bb", size = 17127 }, + ] + + [[package]] + name = "project" + version = "0.1.0" + source = { virtual = "." } + dependencies = [ + { name = "iniconfig" }, + { name = "jinja2" }, + ] + + [package.metadata] + requires-dist = [ + { name = "iniconfig", specifier = "==2.0.0" }, + { name = "jinja2", specifier = ">=3.1.3" }, + ] + "### + ); + }); + + Ok(()) +} + +/// Add an index provided via `--default-index`. +#[test] +fn add_default_index_url() -> 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().arg("iniconfig").arg("--default-index").arg("https://test.pypi.org/simple"), @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 = fs_err::read_to_string(context.temp_dir.join("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" + default = true + "### + ); + }); + + let lock = fs_err::read_to_string(context.temp_dir.join("uv.lock"))?; + + insta::with_settings!({ + filters => context.filters(), + }, { + assert_snapshot!( + lock, @r###" + version = 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" }] + "### + ); + }); + + // Adding another `--default-index` replaces the current default. + uv_snapshot!(context.filters(), context.add().arg("typing-extensions").arg("--default-index").arg("https://pypi.org/simple"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 3 packages in [TIME] + Prepared 1 package in [TIME] + Installed 1 package in [TIME] + + typing-extensions==4.10.0 + "###); + + let pyproject_toml = fs_err::read_to_string(context.temp_dir.join("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", + "typing-extensions>=4.10.0", + ] + + [[tool.uv.index]] + url = "https://pypi.org/simple" + default = true + "### + ); + }); + + let lock = fs_err::read_to_string(context.temp_dir.join("uv.lock"))?; + + insta::with_settings!({ + filters => context.filters(), + }, { + assert_snapshot!( + lock, @r###" + version = 1 + requires-python = ">=3.12" + + [options] + exclude-newer = "2024-03-25T00:00:00Z" + + [[package]] + name = "iniconfig" + version = "2.0.0" + source = { registry = "https://pypi.org/simple" } + sdist = { url = "https://files.pythonhosted.org/packages/d7/4b/cbd8e699e64a6f16ca3a8220661b5f83792b3017d0f79807cb8708d33913/iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3", size = 4646 } + wheels = [ + { url = "https://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" }, + { name = "typing-extensions" }, + ] + + [package.metadata] + requires-dist = [ + { name = "iniconfig", specifier = ">=2.0.0" }, + { name = "typing-extensions", specifier = ">=4.10.0" }, + ] + + [[package]] + name = "typing-extensions" + version = "4.10.0" + source = { registry = "https://pypi.org/simple" } + sdist = { url = "https://files.pythonhosted.org/packages/16/3a/0d26ce356c7465a19c9ea8814b960f8a36c3b0d07c323176620b7b483e44/typing_extensions-4.10.0.tar.gz", hash = "sha256:b0abd7c89e8fb96f98db18d86106ff1d90ab692004eb746cf6eda2682f91b3cb", size = 77558 } + wheels = [ + { url = "https://files.pythonhosted.org/packages/f9/de/dc04a3ea60b22624b51c703a84bbe0184abcd1d0b9bc8074b5d6b7ab90bb/typing_extensions-4.10.0-py3-none-any.whl", hash = "sha256:69b1a937c3a517342112fb4c6df7e72fc39a38e7891a5730ed4985b5214b5475", size = 33926 }, + ] + "### + ); + }); + + Ok(()) +}