Skip to content

Commit 461d555

Browse files
committed
Improve error messages for mismatches in tool.uv.sources
1 parent 95cd8b8 commit 461d555

File tree

6 files changed

+202
-26
lines changed

6 files changed

+202
-26
lines changed

crates/uv-distribution/src/metadata/lowering.rs

Lines changed: 89 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ impl LoweredRequirement {
4949
git_member: Option<&'data GitWorkspaceMember<'data>>,
5050
) -> impl Iterator<Item = Result<Self, LoweringError>> + 'data {
5151
// Identify the source from the `tool.uv.sources` table.
52-
let (source, origin) = if let Some(source) = project_sources.get(&requirement.name) {
52+
let (sources, origin) = if let Some(source) = project_sources.get(&requirement.name) {
5353
(Some(source), RequirementOrigin::Project)
5454
} else if let Some(source) = workspace.sources().get(&requirement.name) {
5555
(Some(source), RequirementOrigin::Workspace)
@@ -58,8 +58,8 @@ impl LoweredRequirement {
5858
};
5959

6060
// If the source only applies to a given extra or dependency group, filter it out.
61-
let source = source.map(|source| {
62-
source
61+
let sources = sources.map(|sources| {
62+
sources
6363
.iter()
6464
.filter(|source| {
6565
if let Some(target) = source.extra() {
@@ -80,23 +80,62 @@ impl LoweredRequirement {
8080
.collect::<Sources>()
8181
});
8282

83-
let workspace_package_declared =
84-
// We require that when you use a package that's part of the workspace, ...
85-
!workspace.packages().contains_key(&requirement.name)
86-
// ... it must be declared as a workspace dependency (`workspace = true`), ...
87-
|| source.as_ref().filter(|sources| !sources.is_empty()).is_some_and(|source| source.iter().all(|source| {
88-
matches!(source, Source::Workspace { workspace: true, .. })
89-
}))
90-
// ... except for recursive self-inclusion (extras that activate other extras), e.g.
91-
// `framework[machine_learning]` depends on `framework[cuda]`.
92-
|| project_name.is_some_and(|project_name| *project_name == requirement.name);
93-
if !workspace_package_declared {
94-
return Either::Left(std::iter::once(Err(
95-
LoweringError::UndeclaredWorkspacePackage,
96-
)));
83+
// If you use a package that's part of the workspace...
84+
if workspace.packages().contains_key(&requirement.name) {
85+
// And it's not a recursive self-inclusion (extras that activate other extras), e.g.
86+
// `framework[machine_learning]` depends on `framework[cuda]`.
87+
if !project_name.is_some_and(|project_name| *project_name == requirement.name) {
88+
// It must be declared as a workspace source.
89+
let Some(sources) = sources.as_ref() else {
90+
// No sources were declared for the workspace package.
91+
return Either::Left(std::iter::once(Err(
92+
LoweringError::MissingWorkspaceSource(requirement.name.clone()),
93+
)));
94+
};
95+
96+
for source in sources.iter() {
97+
match source {
98+
Source::Git { .. } => {
99+
return Either::Left(std::iter::once(Err(
100+
LoweringError::NonWorkspaceSource(
101+
requirement.name.clone(),
102+
SourceKind::Git,
103+
),
104+
)));
105+
}
106+
Source::Url { .. } => {
107+
return Either::Left(std::iter::once(Err(
108+
LoweringError::NonWorkspaceSource(
109+
requirement.name.clone(),
110+
SourceKind::Url,
111+
),
112+
)));
113+
}
114+
Source::Path { .. } => {
115+
return Either::Left(std::iter::once(Err(
116+
LoweringError::NonWorkspaceSource(
117+
requirement.name.clone(),
118+
SourceKind::Path,
119+
),
120+
)));
121+
}
122+
Source::Registry { .. } => {
123+
return Either::Left(std::iter::once(Err(
124+
LoweringError::NonWorkspaceSource(
125+
requirement.name.clone(),
126+
SourceKind::Registry,
127+
),
128+
)));
129+
}
130+
Source::Workspace { .. } => {
131+
// OK
132+
}
133+
}
134+
}
135+
}
97136
}
98137

99-
let Some(source) = source else {
138+
let Some(sources) = sources else {
100139
let has_sources = !project_sources.is_empty() || !workspace.sources().is_empty();
101140
if matches!(lower_bound, LowerBound::Warn) {
102141
// Support recursive editable inclusions.
@@ -118,7 +157,7 @@ impl LoweredRequirement {
118157
let remaining = {
119158
// Determine the space covered by the sources.
120159
let mut total = MarkerTree::FALSE;
121-
for source in source.iter() {
160+
for source in sources.iter() {
122161
total.or(source.marker().clone());
123162
}
124163

@@ -133,7 +172,7 @@ impl LoweredRequirement {
133172
};
134173

135174
Either::Right(
136-
source
175+
sources
137176
.into_iter()
138177
.map(move |source| {
139178
let (source, mut marker) = match source {
@@ -242,7 +281,11 @@ impl LoweredRequirement {
242281
let member = workspace
243282
.packages()
244283
.get(&requirement.name)
245-
.ok_or(LoweringError::UndeclaredWorkspacePackage)?
284+
.ok_or_else(|| {
285+
LoweringError::UndeclaredWorkspacePackage(
286+
requirement.name.clone(),
287+
)
288+
})?
246289
.clone();
247290

248291
// Say we have:
@@ -486,8 +529,12 @@ impl LoweredRequirement {
486529
/// `project.{dependencies,optional-dependencies}`.
487530
#[derive(Debug, Error)]
488531
pub enum LoweringError {
489-
#[error("Package is not included as workspace package in `tool.uv.workspace`")]
490-
UndeclaredWorkspacePackage,
532+
#[error("`{0}` is included as a workspace member, but is missing an entry in `tool.uv.sources` (e.g., `{0} = {{ workspace = true }}`)")]
533+
MissingWorkspaceSource(PackageName),
534+
#[error("`{0}` is included as a workspace member, but references a {1} in `tool.uv.sources`. Workspace members must be declared as workspace sources (e.g., `{0} = {{ workspace = true }}`).")]
535+
NonWorkspaceSource(PackageName, SourceKind),
536+
#[error("`{0}` references a workspace in `tool.uv.sources` (e.g., `{0} = {{ workspace = true }}`), but is not a workspace member")]
537+
UndeclaredWorkspacePackage(PackageName),
491538
#[error("Can only specify one of: `rev`, `tag`, or `branch`")]
492539
MoreThanOneGitRef,
493540
#[error("Package `{0}` references an undeclared index: `{1}`")]
@@ -514,6 +561,25 @@ pub enum LoweringError {
514561
RelativeTo(io::Error),
515562
}
516563

564+
#[derive(Debug, Copy, Clone)]
565+
pub enum SourceKind {
566+
Path,
567+
Url,
568+
Git,
569+
Registry,
570+
}
571+
572+
impl std::fmt::Display for SourceKind {
573+
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
574+
match self {
575+
SourceKind::Path => write!(f, "path"),
576+
SourceKind::Url => write!(f, "URL"),
577+
SourceKind::Git => write!(f, "Git"),
578+
SourceKind::Registry => write!(f, "registry"),
579+
}
580+
}
581+
}
582+
517583
/// Convert a Git source into a [`RequirementSource`].
518584
fn git_source(
519585
git: &Url,

crates/uv-distribution/src/metadata/requires_dist.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -587,7 +587,7 @@ mod test {
587587

588588
assert_snapshot!(format_err(input).await, @r###"
589589
error: Failed to parse entry: `tqdm`
590-
Caused by: Package is not included as workspace package in `tool.uv.workspace`
590+
Caused by: `tqdm` references a workspace in `tool.uv.sources` (e.g., `tqdm = { workspace = true }`), but is not a workspace member
591591
"###);
592592
}
593593

crates/uv-workspace/src/pyproject.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -727,6 +727,11 @@ impl Sources {
727727
pub fn len(&self) -> usize {
728728
self.0.len()
729729
}
730+
731+
/// Returns the first source in the list, if any.
732+
pub fn first(&self) -> Option<&Source> {
733+
self.0.first()
734+
}
730735
}
731736

732737
impl FromIterator<Source> for Sources {

crates/uv/tests/it/export.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -731,7 +731,7 @@ fn frozen() -> Result<()> {
731731
----- stderr -----
732732
× Failed to build `project @ file://[TEMP_DIR]/`
733733
├─▶ Failed to parse entry: `child`
734-
╰─▶ Package is not included as workspace package in `tool.uv.workspace`
734+
╰─▶ `child` references a workspace in `tool.uv.sources` (e.g., `child = { workspace = true }`), but is not a workspace member
735735
"###);
736736

737737
uv_snapshot!(context.filters(), context.export().arg("--all-packages").arg("--frozen"), @r###"

crates/uv/tests/it/lock.rs

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6252,6 +6252,111 @@ fn lock_exclusion() -> Result<()> {
62526252
Ok(())
62536253
}
62546254

6255+
/// Lock a workspace member with a non-workspace source.
6256+
#[test]
6257+
fn lock_non_workspace_source() -> Result<()> {
6258+
let context = TestContext::new("3.12");
6259+
6260+
let pyproject_toml = context.temp_dir.child("pyproject.toml");
6261+
pyproject_toml.write_str(
6262+
r#"
6263+
[project]
6264+
name = "project"
6265+
version = "0.1.0"
6266+
requires-python = ">=3.12"
6267+
dependencies = ["child"]
6268+
6269+
[tool.uv.workspace]
6270+
members = ["child"]
6271+
6272+
[tool.uv.sources]
6273+
child = { path = "child" }
6274+
"#,
6275+
)?;
6276+
6277+
let child = context.temp_dir.child("child");
6278+
fs_err::create_dir_all(&child)?;
6279+
6280+
let pyproject_toml = child.child("pyproject.toml");
6281+
pyproject_toml.write_str(
6282+
r#"
6283+
[project]
6284+
name = "child"
6285+
version = "0.1.0"
6286+
requires-python = ">=3.12"
6287+
dependencies = []
6288+
6289+
[build-system]
6290+
requires = ["setuptools>=42"]
6291+
build-backend = "setuptools.build_meta"
6292+
"#,
6293+
)?;
6294+
6295+
uv_snapshot!(context.filters(), context.lock().current_dir(&child), @r###"
6296+
success: false
6297+
exit_code: 1
6298+
----- stdout -----
6299+
6300+
----- stderr -----
6301+
× Failed to build `project @ file://[TEMP_DIR]/`
6302+
├─▶ Failed to parse entry: `child`
6303+
╰─▶ `child` is included as a workspace member, but references a path in `tool.uv.sources`. Workspace members must be declared as workspace sources (e.g., `child = { workspace = true }`).
6304+
"###);
6305+
6306+
Ok(())
6307+
}
6308+
6309+
/// Lock a workspace member with a non-workspace source.
6310+
#[test]
6311+
fn lock_no_workspace_source() -> Result<()> {
6312+
let context = TestContext::new("3.12");
6313+
6314+
let pyproject_toml = context.temp_dir.child("pyproject.toml");
6315+
pyproject_toml.write_str(
6316+
r#"
6317+
[project]
6318+
name = "project"
6319+
version = "0.1.0"
6320+
requires-python = ">=3.12"
6321+
dependencies = ["child"]
6322+
6323+
[tool.uv.workspace]
6324+
members = ["child"]
6325+
"#,
6326+
)?;
6327+
6328+
let child = context.temp_dir.child("child");
6329+
fs_err::create_dir_all(&child)?;
6330+
6331+
let pyproject_toml = child.child("pyproject.toml");
6332+
pyproject_toml.write_str(
6333+
r#"
6334+
[project]
6335+
name = "child"
6336+
version = "0.1.0"
6337+
requires-python = ">=3.12"
6338+
dependencies = []
6339+
6340+
[build-system]
6341+
requires = ["setuptools>=42"]
6342+
build-backend = "setuptools.build_meta"
6343+
"#,
6344+
)?;
6345+
6346+
uv_snapshot!(context.filters(), context.lock().current_dir(&child), @r###"
6347+
success: false
6348+
exit_code: 1
6349+
----- stdout -----
6350+
6351+
----- stderr -----
6352+
× Failed to build `project @ file://[TEMP_DIR]/`
6353+
├─▶ Failed to parse entry: `child`
6354+
╰─▶ `child` is included as a workspace member, but is missing an entry in `tool.uv.sources` (e.g., `child = { workspace = true }`)
6355+
"###);
6356+
6357+
Ok(())
6358+
}
6359+
62556360
/// Ensure that development dependencies are omitted for non-workspace members. Below, `bar` depends
62566361
/// on `foo`, but `bar/uv.lock` should omit `anyio`, but should include `typing-extensions`.
62576362
#[test]

crates/uv/tests/it/workspace.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1697,7 +1697,7 @@ fn workspace_member_name_shadows_dependencies() -> Result<()> {
16971697
Using CPython 3.12.[X] interpreter at: [PYTHON-3.12]
16981698
× Failed to build `foo @ file://[TEMP_DIR]/workspace/packages/foo`
16991699
├─▶ Failed to parse entry: `anyio`
1700-
╰─▶ Package is not included as workspace package in `tool.uv.workspace`
1700+
╰─▶ `anyio` is included as a workspace member, but is missing an entry in `tool.uv.sources` (e.g., `anyio = { workspace = true }`)
17011701
"###
17021702
);
17031703

0 commit comments

Comments
 (0)