From d720f2fb331a07b16b36fc43ec79772313e78e25 Mon Sep 17 00:00:00 2001 From: Zanie Blue Date: Mon, 23 Feb 2026 15:25:40 -0600 Subject: [PATCH] Add hint when named index is found in a parent config file (#18087) See https://github.com/astral-sh/uv/issues/18053 --------- Co-authored-by: Claude --- crates/uv-distribution-types/src/origin.rs | 2 + crates/uv-distribution-types/src/pip_index.rs | 7 +- .../uv-distribution/src/metadata/lowering.rs | 48 ++++++-- crates/uv-settings/src/lib.rs | 13 ++- crates/uv-settings/src/settings.rs | 38 ++++++- crates/uv/tests/it/lock.rs | 103 ++++++++++++++++++ crates/uv/tests/it/show_settings.rs | 48 ++++++-- 7 files changed, 233 insertions(+), 26 deletions(-) diff --git a/crates/uv-distribution-types/src/origin.rs b/crates/uv-distribution-types/src/origin.rs index f5f3cd7cb..8d0404c0a 100644 --- a/crates/uv-distribution-types/src/origin.rs +++ b/crates/uv-distribution-types/src/origin.rs @@ -5,6 +5,8 @@ pub enum Origin { Cli, /// The setting was provided via a user-level configuration file. User, + /// The setting was provided via a system-level configuration file. + System, /// The setting was provided via a project-level configuration file. Project, /// The setting was provided via a `requirements.txt` file. diff --git a/crates/uv-distribution-types/src/pip_index.rs b/crates/uv-distribution-types/src/pip_index.rs index 18671e42f..e26c0ac03 100644 --- a/crates/uv-distribution-types/src/pip_index.rs +++ b/crates/uv-distribution-types/src/pip_index.rs @@ -7,7 +7,7 @@ use serde::{Deserialize, Deserializer, Serialize}; use std::borrow::Cow; use std::path::Path; -use crate::{Index, IndexUrl}; +use crate::{Index, IndexUrl, Origin}; macro_rules! impl_index { ($name:ident, $from:expr) => { @@ -18,6 +18,11 @@ macro_rules! impl_index { pub fn relative_to(self, root_dir: &Path) -> Result { Ok(Self(self.0.relative_to(root_dir)?)) } + + /// Set the [`Origin`] if not already set. + pub fn try_set_origin(&mut self, origin: Origin) { + self.0.origin.get_or_insert(origin); + } } impl From<$name> for Index { diff --git a/crates/uv-distribution/src/metadata/lowering.rs b/crates/uv-distribution/src/metadata/lowering.rs index 8133c925d..b333260b6 100644 --- a/crates/uv-distribution/src/metadata/lowering.rs +++ b/crates/uv-distribution/src/metadata/lowering.rs @@ -3,6 +3,7 @@ use std::io; use std::path::{Path, PathBuf}; use either::Either; +use owo_colors::OwoColorize; use thiserror::Error; use uv_auth::CredentialsCache; use uv_distribution_filename::DistExtension; @@ -226,10 +227,12 @@ impl LoweredRequirement { name.as_ref().is_some_and(|name| *name == index) }) else { - return Err(LoweringError::MissingIndex( - requirement.name.clone(), + let hint = missing_index_hint(locations, &index); + return Err(LoweringError::MissingIndex { + package: requirement.name.clone(), index, - )); + hint, + }); }; if let Some(credentials) = index.credentials() { credentials_cache.store_credentials(index.raw_url(), credentials); @@ -462,10 +465,12 @@ impl LoweredRequirement { name.as_ref().is_some_and(|name| *name == index) }) else { - return Err(LoweringError::MissingIndex( - requirement.name.clone(), + let hint = missing_index_hint(locations, &index); + return Err(LoweringError::MissingIndex { + package: requirement.name.clone(), index, - )); + hint, + }); }; if let Some(credentials) = index.credentials() { credentials_cache.store_credentials(index.raw_url(), credentials); @@ -528,8 +533,12 @@ pub enum LoweringError { MoreThanOneGitRef, #[error(transparent)] GitUrlParse(#[from] GitUrlParseError), - #[error("Package `{0}` references an undeclared index: `{1}`")] - MissingIndex(PackageName, IndexName), + #[error("Package `{package}` references an undeclared index: `{index}`{}", if let Some(hint) = hint { format!("\n\n{}{} {hint}", "hint".bold().cyan(), ":".bold()) } else { String::new() })] + MissingIndex { + package: PackageName, + index: IndexName, + hint: Option, + }, #[error("Workspace members are not allowed in non-workspace contexts")] WorkspaceMember, #[error(transparent)] @@ -579,6 +588,29 @@ impl std::fmt::Display for SourceKind { } } +/// Generate a hint for a missing index if the index name is found in a configuration file +/// (e.g., `uv.toml`) rather than in the project's `pyproject.toml`. +fn missing_index_hint(locations: &IndexLocations, index: &IndexName) -> Option { + let config_index = locations + .simple_indexes() + .filter(|idx| !matches!(idx.origin, Some(Origin::Cli))) + .find(|idx| idx.name.as_ref().is_some_and(|name| *name == *index)); + + config_index.and_then(|idx| { + let source = match idx.origin { + Some(Origin::User) => "a user-level `uv.toml`", + Some(Origin::System) => "a system-level `uv.toml`", + Some(Origin::Project) => "a project-level `uv.toml`", + Some(Origin::Cli | Origin::RequirementsTxt) | None => return None, + }; + Some(format!( + "Index `{index}` was found in {source}, but indexes \ + referenced via `tool.uv.sources` must be defined in the project's \ + `pyproject.toml`" + )) + }) +} + /// Convert a Git source into a [`RequirementSource`]. fn git_source( git: &DisplaySafeUrl, diff --git a/crates/uv-settings/src/lib.rs b/crates/uv-settings/src/lib.rs index c3a1736ff..8d7897250 100644 --- a/crates/uv-settings/src/lib.rs +++ b/crates/uv-settings/src/lib.rs @@ -4,6 +4,7 @@ use std::path::{Path, PathBuf}; use std::time::Duration; use uv_client::{DEFAULT_CONNECT_TIMEOUT, DEFAULT_READ_TIMEOUT, DEFAULT_READ_TIMEOUT_UPLOAD}; use uv_dirs::{system_config_file, user_config_dir}; +use uv_distribution_types::Origin; use uv_flags::EnvironmentFlags; use uv_fs::Simplified; use uv_static::{EnvVars, InvalidEnvironmentVariable, parse_boolish_environment_variable}; @@ -24,6 +25,12 @@ impl FilesystemOptions { pub fn into_options(self) -> Options { self.0 } + + /// Set the [`Origin`] on all indexes without an existing origin. + #[must_use] + pub fn with_origin(self, origin: Origin) -> Self { + Self(self.0.with_origin(origin)) + } } impl Deref for FilesystemOptions { @@ -48,7 +55,7 @@ impl FilesystemOptions { Ok(options) => { tracing::debug!("Found user configuration in: `{}`", file.display()); validate_uv_toml(&file, &options)?; - Ok(Some(Self(options))) + Ok(Some(Self(options.with_origin(Origin::User)))) } Err(Error::Io(err)) if matches!( @@ -72,7 +79,7 @@ impl FilesystemOptions { tracing::debug!("Found system configuration in: `{}`", file.display()); let options = read_file(&file)?; validate_uv_toml(&file, &options)?; - Ok(Some(Self(options))) + Ok(Some(Self(options.with_origin(Origin::System)))) } /// Find the [`FilesystemOptions`] for the given path. @@ -131,7 +138,7 @@ impl FilesystemOptions { tracing::debug!("Found workspace configuration at `{}`", path.display()); validate_uv_toml(&path, &options)?; - return Ok(Some(Self(options))); + return Ok(Some(Self(options.with_origin(Origin::Project)))); } Err(err) if err.kind() == std::io::ErrorKind::NotFound => {} Err(err) => return Err(err.into()), diff --git a/crates/uv-settings/src/settings.rs b/crates/uv-settings/src/settings.rs index 11a5d88f7..672deb8d7 100644 --- a/crates/uv-settings/src/settings.rs +++ b/crates/uv-settings/src/settings.rs @@ -8,8 +8,8 @@ use uv_configuration::{ RequiredVersion, TargetTriple, TrustedHost, TrustedPublishing, Upgrade, }; use uv_distribution_types::{ - ConfigSettings, ExtraBuildVariables, Index, IndexUrl, IndexUrlError, PackageConfigSettings, - PipExtraIndex, PipFindLinks, PipIndex, StaticMetadata, + ConfigSettings, ExtraBuildVariables, Index, IndexUrl, IndexUrlError, Origin, + PackageConfigSettings, PipExtraIndex, PipFindLinks, PipIndex, StaticMetadata, }; use uv_install_wheel::LinkMode; use uv_macros::{CombineOptions, OptionsMetadata}; @@ -171,6 +171,40 @@ impl Options { } } + /// Set the [`Origin`] on all indexes without an existing origin. + #[must_use] + pub fn with_origin(mut self, origin: Origin) -> Self { + if let Some(indexes) = &mut self.top_level.index { + for index in indexes { + index.origin.get_or_insert(origin); + } + } + if let Some(index_url) = &mut self.top_level.index_url { + index_url.try_set_origin(origin); + } + if let Some(extra_index_urls) = &mut self.top_level.extra_index_url { + for index_url in extra_index_urls { + index_url.try_set_origin(origin); + } + } + if let Some(pip) = &mut self.pip { + if let Some(indexes) = &mut pip.index { + for index in indexes { + index.origin.get_or_insert(origin); + } + } + if let Some(index_url) = &mut pip.index_url { + index_url.try_set_origin(origin); + } + if let Some(extra_index_urls) = &mut pip.extra_index_url { + for index_url in extra_index_urls { + index_url.try_set_origin(origin); + } + } + } + self + } + /// Resolve the [`Options`] relative to the given root directory. pub fn relative_to(self, root_dir: &Path) -> Result { Ok(Self { diff --git a/crates/uv/tests/it/lock.rs b/crates/uv/tests/it/lock.rs index 3c72cdf95..1f8a09d02 100644 --- a/crates/uv/tests/it/lock.rs +++ b/crates/uv/tests/it/lock.rs @@ -19293,6 +19293,109 @@ fn lock_named_index_cli() -> Result<()> { Ok(()) } +/// If a named index is referenced in `tool.uv.sources` but only defined in `uv.toml`, we should +/// provide a hint that the index was found in a configuration file. +#[test] +fn lock_named_index_config_file_hint() -> Result<()> { + let context = uv_test::test_context!("3.12"); + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str( + r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["jinja2==3.1.2"] + + [tool.uv.sources] + jinja2 = { index = "pytorch" } + "#, + )?; + + let uv_toml = context.temp_dir.child("uv.toml"); + uv_toml.write_str( + r#" + [[index]] + name = "pytorch" + url = "https://astral-sh.github.io/pytorch-mirror/whl/cu121" + explicit = true + "#, + )?; + + // The index is defined in `uv.toml`, which should produce a hint. + uv_snapshot!(context.filters(), context.lock(), @" + success: false + exit_code: 1 + ----- stdout ----- + + ----- stderr ----- + × Failed to build `project @ file://[TEMP_DIR]/` + ├─▶ Failed to parse entry: `jinja2` + ╰─▶ Package `jinja2` references an undeclared index: `pytorch` + + hint: Index `pytorch` was found in a project-level `uv.toml`, but indexes referenced via `tool.uv.sources` must be defined in the project's `pyproject.toml` + "); + + Ok(()) +} + +/// If a named index is referenced in `tool.uv.sources` but only defined in a user-level `uv.toml` +/// (e.g., `~/.config/uv/uv.toml`), we should provide a hint that the index was found in a +/// configuration file. +#[test] +#[cfg_attr( + windows, + ignore = "Configuration tests are not yet supported on Windows" +)] +fn lock_named_index_user_config_file_hint() -> Result<()> { + let context = uv_test::test_context!("3.12"); + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str( + r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["jinja2==3.1.2"] + + [tool.uv.sources] + jinja2 = { index = "pytorch" } + "#, + )?; + + // Define the index in a user-level configuration file. + let xdg = assert_fs::TempDir::new().expect("Failed to create temp dir"); + let uv_config = xdg.child("uv"); + let uv_toml = uv_config.child("uv.toml"); + uv_toml.write_str( + r#" + [[index]] + name = "pytorch" + url = "https://astral-sh.github.io/pytorch-mirror/whl/cu121" + explicit = true + "#, + )?; + + // The index is defined in a user-level `uv.toml`, which should produce a hint. + uv_snapshot!(context.filters(), context.lock() + .env(EnvVars::XDG_CONFIG_HOME, xdg.path()), @" + success: false + exit_code: 1 + ----- stdout ----- + + ----- stderr ----- + × Failed to build `project @ file://[TEMP_DIR]/` + ├─▶ Failed to parse entry: `jinja2` + ╰─▶ Package `jinja2` references an undeclared index: `pytorch` + + hint: Index `pytorch` was found in a user-level `uv.toml`, but indexes referenced via `tool.uv.sources` must be defined in the project's `pyproject.toml` + "); + + Ok(()) +} + /// If a name is reused, within a single file, we should raise an error. #[test] fn lock_repeat_named_index() -> Result<()> { diff --git a/crates/uv/tests/it/show_settings.rs b/crates/uv/tests/it/show_settings.rs index 1e63d9fd6..735073c82 100644 --- a/crates/uv/tests/it/show_settings.rs +++ b/crates/uv/tests/it/show_settings.rs @@ -143,7 +143,9 @@ fn resolve_uv_toml() -> anyhow::Result<()> { ), explicit: false, default: true, - origin: None, + origin: Some( + Project, + ), format: Simple, publish_url: None, authenticate: Auto, @@ -349,7 +351,9 @@ fn resolve_uv_toml() -> anyhow::Result<()> { ), explicit: false, default: true, - origin: None, + origin: Some( + Project, + ), format: Simple, publish_url: None, authenticate: Auto, @@ -556,7 +560,9 @@ fn resolve_uv_toml() -> anyhow::Result<()> { ), explicit: false, default: true, - origin: None, + origin: Some( + Project, + ), format: Simple, publish_url: None, authenticate: Auto, @@ -795,7 +801,9 @@ fn resolve_pyproject_toml() -> anyhow::Result<()> { ), explicit: false, default: true, - origin: None, + origin: Some( + Project, + ), format: Simple, publish_url: None, authenticate: Auto, @@ -4027,7 +4035,9 @@ fn resolve_both() -> anyhow::Result<()> { ), explicit: false, default: true, - origin: None, + origin: Some( + Project, + ), format: Simple, publish_url: None, authenticate: Auto, @@ -4279,7 +4289,9 @@ fn resolve_both_special_fields() -> anyhow::Result<()> { ), explicit: false, default: true, - origin: None, + origin: Some( + Project, + ), format: Simple, publish_url: None, authenticate: Auto, @@ -5527,7 +5539,9 @@ fn index_priority() -> anyhow::Result<()> { ), explicit: false, default: false, - origin: None, + origin: Some( + Project, + ), format: Simple, publish_url: None, authenticate: Auto, @@ -5768,7 +5782,9 @@ fn index_priority() -> anyhow::Result<()> { ), explicit: false, default: false, - origin: None, + origin: Some( + Project, + ), format: Simple, publish_url: None, authenticate: Auto, @@ -6015,7 +6031,9 @@ fn index_priority() -> anyhow::Result<()> { ), explicit: false, default: true, - origin: None, + origin: Some( + Project, + ), format: Simple, publish_url: None, authenticate: Auto, @@ -6257,7 +6275,9 @@ fn index_priority() -> anyhow::Result<()> { ), explicit: false, default: true, - origin: None, + origin: Some( + Project, + ), format: Simple, publish_url: None, authenticate: Auto, @@ -6506,7 +6526,9 @@ fn index_priority() -> anyhow::Result<()> { ), explicit: false, default: true, - origin: None, + origin: Some( + Project, + ), format: Simple, publish_url: None, authenticate: Auto, @@ -6748,7 +6770,9 @@ fn index_priority() -> anyhow::Result<()> { ), explicit: false, default: true, - origin: None, + origin: Some( + Project, + ), format: Simple, publish_url: None, authenticate: Auto,