Skip to content

Commit 4f2b30c

Browse files
Improve error messages for mismatches in tool.uv.sources (#9482)
## Summary Closes #9479.
1 parent 0b0d0f4 commit 4f2b30c

File tree

5 files changed

+197
-26
lines changed

5 files changed

+197
-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/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
@@ -6243,6 +6243,111 @@ fn lock_exclusion() -> Result<()> {
62436243
Ok(())
62446244
}
62456245

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