Only collect path dependency source indexes during lockfile validation

This commit is contained in:
John Mumm 2025-06-19 13:40:53 +02:00
parent 3ad4ba7935
commit 9e5c16d833
No known key found for this signature in database
GPG key ID: 73D2271AFDC26EA8
3 changed files with 95 additions and 133 deletions

View file

@ -114,10 +114,6 @@ pub struct Workspace {
///
/// This table is overridden by the project indexes.
indexes: Vec<Index>,
/// Indexes defined as sources in transitive path dependencies of this workspace.
///
/// Used for lockfile validation.
path_dependency_source_indexes: Vec<Index>,
/// The `pyproject.toml` of the workspace root.
pyproject_toml: PyProjectToml,
}
@ -639,11 +635,6 @@ impl Workspace {
&self.indexes
}
/// Indexes defined as sources in transitive path dependencies of this workspace.
pub fn path_dependency_source_indexes(&self) -> &[Index] {
&self.path_dependency_source_indexes
}
/// The `pyproject.toml` of the workspace.
pub fn pyproject_toml(&self) -> &PyProjectToml {
&self.pyproject_toml
@ -751,18 +742,11 @@ impl Workspace {
.and_then(|uv| uv.index)
.unwrap_or_default();
let path_dependency_source_indexes = collect_path_dependency_source_indexes(
&workspace_root,
&workspace_indexes,
&workspace_pyproject_toml,
);
Ok(Workspace {
install_path: workspace_root,
packages: workspace_members,
sources: workspace_sources,
indexes: workspace_indexes,
path_dependency_source_indexes,
pyproject_toml: workspace_pyproject_toml,
})
}
@ -945,6 +929,85 @@ impl Workspace {
}
Ok(workspace_members)
}
/// Collects indexes provided as sources in (transitive) path dependencies that
/// have not already been defined in the workspace.
pub fn collect_path_dependency_source_indexes(&self) -> Vec<Index> {
let mut dependency_indexes = FxHashSet::default();
let mut seen = FxHashSet::default();
// We will only add indexes if we have not already seen the URLs.
let known_urls: FxHashSet<_> = self.indexes.iter().map(Index::url).collect();
let mut pyprojects = std::collections::VecDeque::new();
pyprojects.push_back((self.install_path.clone(), self.pyproject_toml.clone()));
while let Some((base_path, pyproject)) = pyprojects.pop_front() {
if let Some(tool_uv_sources) = pyproject
.tool
.as_ref()
.and_then(|tool| tool.uv.as_ref())
.and_then(|uv| uv.sources.as_ref())
{
for sources in tool_uv_sources.inner().values() {
for source in sources.iter() {
if let Source::Path { path, .. } = source {
let dep_path = if path.as_ref().is_absolute() {
path.as_ref().to_path_buf()
} else {
base_path.join(path)
};
// Canonicalize path to compare symlinks and relative paths correctly
let Ok(canonical_path) = dep_path.canonicalize() else {
debug!(
"Failed to canonicalize path dependency path: {}",
dep_path.display()
);
continue;
};
// Prevent infinite loops from circular dependencies
if !seen.insert(canonical_path.clone()) {
continue;
}
let dep_pyproject_path = canonical_path.join("pyproject.toml");
match pyproject_toml_from_path(dep_pyproject_path.clone()) {
Ok(dep_pyproject) => {
if let Some(dep_indexes) = dep_pyproject
.tool
.as_ref()
.and_then(|tool| tool.uv.as_ref())
.and_then(|uv| uv.index.as_ref())
{
dependency_indexes.extend(
dep_indexes
.iter()
.filter(|idx| !known_urls.contains(idx.url()))
.cloned(),
);
}
pyprojects.push_back((canonical_path, dep_pyproject));
}
Err(e) => {
debug!(
"Failed to read `pyproject.toml` in path dependency `{}`: {}",
dep_pyproject_path.display(),
e
);
}
}
}
}
}
}
}
dependency_indexes.into_iter().collect::<Vec<_>>()
}
}
/// A project in a workspace.
@ -1252,7 +1315,6 @@ impl ProjectWorkspace {
// workspace sources.
sources: BTreeMap::default(),
indexes: Vec::default(),
path_dependency_source_indexes: Vec::default(),
pyproject_toml: project_pyproject_toml.clone(),
},
});
@ -1579,92 +1641,6 @@ fn pyproject_toml_from_path(pyproject_path: PathBuf) -> Result<PyProjectToml, Wo
.map_err(|err| WorkspaceError::Toml(pyproject_path, Box::new(err)))
}
/// Collects indexes provided as sources in (transitive) path dependencies that
/// have not already been defined in the workspace.
fn collect_path_dependency_source_indexes(
workspace_root: &Path,
workspace_indexes: &[Index],
workspace_pyproject_toml: &PyProjectToml,
) -> Vec<Index> {
let mut dependency_indexes = FxHashSet::default();
let mut seen = FxHashSet::default();
// We will only add indexes if we have not already seen the URLs.
let known_urls: FxHashSet<_> = workspace_indexes.iter().map(Index::url).collect();
let mut pyprojects = std::collections::VecDeque::new();
pyprojects.push_back((
workspace_root.to_path_buf(),
workspace_pyproject_toml.clone(),
));
while let Some((base_path, pyproject)) = pyprojects.pop_front() {
if let Some(tool_uv_sources) = pyproject
.tool
.as_ref()
.and_then(|tool| tool.uv.as_ref())
.and_then(|uv| uv.sources.as_ref())
{
for sources in tool_uv_sources.inner().values() {
for source in sources.iter() {
if let Source::Path { path, .. } = source {
let dep_path = if path.as_ref().is_absolute() {
path.as_ref().to_path_buf()
} else {
base_path.join(path)
};
// Canonicalize path to compare symlinks and relative paths correctly
let Ok(canonical_path) = dep_path.canonicalize() else {
debug!(
"Failed to canonicalize path dependency path: {}",
dep_path.display()
);
continue;
};
// Prevent infinite loops from circular dependencies
if !seen.insert(canonical_path.clone()) {
continue;
}
let dep_pyproject_path = canonical_path.join("pyproject.toml");
match pyproject_toml_from_path(dep_pyproject_path.clone()) {
Ok(dep_pyproject) => {
if let Some(dep_indexes) = dep_pyproject
.tool
.as_ref()
.and_then(|tool| tool.uv.as_ref())
.and_then(|uv| uv.index.as_ref())
{
dependency_indexes.extend(
dep_indexes
.iter()
.filter(|idx| !known_urls.contains(idx.url()))
.cloned(),
);
}
pyprojects.push_back((canonical_path, dep_pyproject));
}
Err(e) => {
debug!(
"Failed to read `pyproject.toml` in path dependency `{}`: {}",
dep_pyproject_path.display(),
e
);
}
}
}
}
}
}
}
dependency_indexes.into_iter().collect::<Vec<_>>()
}
#[cfg(test)]
#[cfg(unix)] // Avoid path escaping for the unit tests
mod tests {
@ -1753,7 +1729,6 @@ mod tests {
},
"sources": {},
"indexes": [],
"path_dependency_source_indexes": [],
"pyproject_toml": {
"project": {
"name": "bird-feeder",
@ -1807,7 +1782,6 @@ mod tests {
},
"sources": {},
"indexes": [],
"path_dependency_source_indexes": [],
"pyproject_toml": {
"project": {
"name": "bird-feeder",
@ -1896,7 +1870,6 @@ mod tests {
]
},
"indexes": [],
"path_dependency_source_indexes": [],
"pyproject_toml": {
"project": {
"name": "albatross",
@ -2009,7 +1982,6 @@ mod tests {
},
"sources": {},
"indexes": [],
"path_dependency_source_indexes": [],
"pyproject_toml": {
"project": null,
"tool": {
@ -2076,7 +2048,6 @@ mod tests {
},
"sources": {},
"indexes": [],
"path_dependency_source_indexes": [],
"pyproject_toml": {
"project": {
"name": "albatross",
@ -2211,7 +2182,6 @@ mod tests {
},
"sources": {},
"indexes": [],
"path_dependency_source_indexes": [],
"pyproject_toml": {
"project": {
"name": "albatross",
@ -2318,7 +2288,6 @@ mod tests {
},
"sources": {},
"indexes": [],
"path_dependency_source_indexes": [],
"pyproject_toml": {
"project": {
"name": "albatross",
@ -2439,7 +2408,6 @@ mod tests {
},
"sources": {},
"indexes": [],
"path_dependency_source_indexes": [],
"pyproject_toml": {
"project": {
"name": "albatross",
@ -2534,7 +2502,6 @@ mod tests {
},
"sources": {},
"indexes": [],
"path_dependency_source_indexes": [],
"pyproject_toml": {
"project": {
"name": "albatross",

View file

@ -680,7 +680,7 @@ async fn do_lock(
let existing_lock = if let Some(existing_lock) = existing_lock {
match ValidatedLock::validate(
existing_lock,
target.install_path(),
target,
packages,
&members,
&requirements,
@ -695,7 +695,6 @@ async fn do_lock(
interpreter,
&requires_python,
index_locations,
target.path_dependency_source_indexes(),
upgrade,
&options,
&hasher,
@ -894,7 +893,7 @@ impl ValidatedLock {
/// Validate a [`Lock`] against the workspace requirements.
async fn validate<Context: BuildContext>(
lock: Lock,
install_path: &Path,
target: LockTarget<'_>,
packages: &BTreeMap<PackageName, WorkspaceMember>,
members: &[PackageName],
requirements: &[Requirement],
@ -909,7 +908,6 @@ impl ValidatedLock {
interpreter: &Interpreter,
requires_python: &RequiresPython,
index_locations: &IndexLocations,
path_dependency_source_indexes: Option<&[Index]>,
upgrade: &Upgrade,
options: &Options,
hasher: &HashStrategy,
@ -1074,22 +1072,27 @@ impl ValidatedLock {
} else {
// If indexes were defined as sources in path dependencies, add them to the
// index locations to use for validation.
path_dependency_source_indexes
.filter(|source_indexes| !source_indexes.is_empty())
.map(|source_indexes| {
Cow::Owned(index_locations.clone().combine(
source_indexes.to_vec(),
if let LockTarget::Workspace(workspace) = target {
let path_dependency_source_indexes =
workspace.collect_path_dependency_source_indexes();
if path_dependency_source_indexes.is_empty() {
Some(Cow::Borrowed(index_locations))
} else {
Some(Cow::Owned(index_locations.clone().combine(
path_dependency_source_indexes,
Vec::new(),
false,
))
})
.or(Some(Cow::Borrowed(index_locations)))
)))
}
} else {
Some(Cow::Borrowed(index_locations))
}
};
// Determine whether the lockfile satisfies the workspace requirements.
match lock
.satisfies(
install_path,
target.install_path(),
packages,
members,
requirements,

View file

@ -215,14 +215,6 @@ impl<'lock> LockTarget<'lock> {
}
}
/// If this is a workspace, returns path dependency source indexes, otherwise return [`None`].
pub(crate) fn path_dependency_source_indexes(&self) -> Option<&[Index]> {
match self {
Self::Workspace(workspace) => Some(workspace.path_dependency_source_indexes()),
Self::Script(..) => None,
}
}
/// Return the `Requires-Python` bound for the [`LockTarget`].
#[allow(clippy::result_large_err)]
pub(crate) fn requires_python(self) -> Result<Option<RequiresPython>, ProjectError> {