Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
2 changes: 2 additions & 0 deletions crates/uv-distribution-types/src/origin.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
7 changes: 6 additions & 1 deletion crates/uv-distribution-types/src/pip_index.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand All @@ -18,6 +18,11 @@ macro_rules! impl_index {
pub fn relative_to(self, root_dir: &Path) -> Result<Self, crate::IndexUrlError> {
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 {
Expand Down
48 changes: 40 additions & 8 deletions crates/uv-distribution/src/metadata/lowering.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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<String>,
},
#[error("Workspace members are not allowed in non-workspace contexts")]
WorkspaceMember,
#[error(transparent)]
Expand Down Expand Up @@ -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<String> {
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`",
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Shouldn't this be "a pyproject.toml"?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

No, this is when there's a uv.toml in the project alongside the pyproject.toml,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Ah, fair enough, you're right, I am pretty sure I realised this yesterday and then forgot about it when looking at it today :)

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,
Expand Down
13 changes: 10 additions & 3 deletions crates/uv-settings/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand All @@ -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 {
Expand All @@ -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!(
Expand All @@ -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.
Expand Down Expand Up @@ -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()),
Expand Down
38 changes: 36 additions & 2 deletions crates/uv-settings/src/settings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -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<Self, IndexUrlError> {
Ok(Self {
Expand Down
103 changes: 103 additions & 0 deletions crates/uv/tests/it/lock.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19252,6 +19252,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<()> {
Expand Down
Loading
Loading