Skip to content

Commit dc3f628

Browse files
Respect dynamic extras in uv lock and uv sync (#8091)
## Summary We can't rely on reading these from the `pyproject.toml`; instead, we resolve the project metadata (which will typically just require reading the `pyproject.toml`, but will go through our standard metadata paths). Closes #8071.
1 parent 7b80b18 commit dc3f628

11 files changed

Lines changed: 358 additions & 103 deletions

File tree

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
use std::sync::Arc;
2+
3+
use futures::{stream::FuturesOrdered, TryStreamExt};
4+
use thiserror::Error;
5+
6+
use uv_distribution::{DistributionDatabase, Reporter};
7+
use uv_distribution_types::{BuiltDist, Dist, DistributionMetadata, SourceDist};
8+
use uv_pypi_types::Requirement;
9+
use uv_resolver::{InMemoryIndex, MetadataResponse};
10+
use uv_types::{BuildContext, HashStrategy};
11+
12+
use crate::required_dist;
13+
14+
#[derive(Debug, Error)]
15+
pub enum ExtrasError {
16+
#[error("Failed to download: `{0}`")]
17+
Download(BuiltDist, #[source] uv_distribution::Error),
18+
#[error("Failed to download and build: `{0}`")]
19+
DownloadAndBuild(SourceDist, #[source] uv_distribution::Error),
20+
#[error("Failed to build: `{0}`")]
21+
Build(SourceDist, #[source] uv_distribution::Error),
22+
#[error(transparent)]
23+
UnsupportedUrl(#[from] uv_distribution_types::Error),
24+
}
25+
26+
/// A resolver to expand the requested extras for a set of requirements to include all defined
27+
/// extras.
28+
pub struct ExtrasResolver<'a, Context: BuildContext> {
29+
/// Whether to check hashes for distributions.
30+
hasher: &'a HashStrategy,
31+
/// The in-memory index for resolving dependencies.
32+
index: &'a InMemoryIndex,
33+
/// The database for fetching and building distributions.
34+
database: DistributionDatabase<'a, Context>,
35+
}
36+
37+
impl<'a, Context: BuildContext> ExtrasResolver<'a, Context> {
38+
/// Instantiate a new [`ExtrasResolver`] for a given set of requirements.
39+
pub fn new(
40+
hasher: &'a HashStrategy,
41+
index: &'a InMemoryIndex,
42+
database: DistributionDatabase<'a, Context>,
43+
) -> Self {
44+
Self {
45+
hasher,
46+
index,
47+
database,
48+
}
49+
}
50+
51+
/// Set the [`Reporter`] to use for this resolver.
52+
#[must_use]
53+
pub fn with_reporter(self, reporter: impl Reporter + 'static) -> Self {
54+
Self {
55+
database: self.database.with_reporter(reporter),
56+
..self
57+
}
58+
}
59+
60+
/// Expand the set of available extras for a given set of requirements.
61+
pub async fn resolve(
62+
self,
63+
requirements: impl Iterator<Item = Requirement>,
64+
) -> Result<Vec<Requirement>, ExtrasError> {
65+
let Self {
66+
hasher,
67+
index,
68+
database,
69+
} = self;
70+
requirements
71+
.map(|requirement| async {
72+
Self::resolve_requirement(requirement, hasher, index, &database)
73+
.await
74+
.map(Requirement::from)
75+
})
76+
.collect::<FuturesOrdered<_>>()
77+
.try_collect()
78+
.await
79+
}
80+
81+
/// Expand the set of available extras for a given [`Requirement`].
82+
async fn resolve_requirement(
83+
requirement: Requirement,
84+
hasher: &HashStrategy,
85+
index: &InMemoryIndex,
86+
database: &DistributionDatabase<'a, Context>,
87+
) -> Result<Requirement, ExtrasError> {
88+
// Determine whether the requirement represents a local distribution and convert to a
89+
// buildable distribution.
90+
let Some(dist) = required_dist(&requirement)? else {
91+
return Ok(requirement);
92+
};
93+
94+
// Fetch the metadata for the distribution.
95+
let metadata = {
96+
let id = dist.version_id();
97+
if let Some(archive) = index
98+
.distributions()
99+
.get(&id)
100+
.as_deref()
101+
.and_then(|response| {
102+
if let MetadataResponse::Found(archive, ..) = response {
103+
Some(archive)
104+
} else {
105+
None
106+
}
107+
})
108+
{
109+
// If the metadata is already in the index, return it.
110+
archive.metadata.clone()
111+
} else {
112+
// Run the PEP 517 build process to extract metadata from the source distribution.
113+
let archive = database
114+
.get_or_build_wheel_metadata(&dist, hasher.get(&dist))
115+
.await
116+
.map_err(|err| match &dist {
117+
Dist::Built(built) => ExtrasError::Download(built.clone(), err),
118+
Dist::Source(source) => {
119+
if source.is_local() {
120+
ExtrasError::Build(source.clone(), err)
121+
} else {
122+
ExtrasError::DownloadAndBuild(source.clone(), err)
123+
}
124+
}
125+
})?;
126+
127+
let metadata = archive.metadata.clone();
128+
129+
// Insert the metadata into the index.
130+
index
131+
.distributions()
132+
.done(id, Arc::new(MetadataResponse::Found(archive)));
133+
134+
metadata
135+
}
136+
};
137+
138+
// Sort extras for consistency.
139+
let extras = {
140+
let mut extras = metadata.provides_extras;
141+
extras.sort_unstable();
142+
extras
143+
};
144+
145+
Ok(Requirement {
146+
extras,
147+
..requirement
148+
})
149+
}
150+
}

crates/uv-requirements/src/lib.rs

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,75 @@
1+
use uv_distribution_types::{Dist, GitSourceDist, SourceDist};
2+
use uv_git::GitUrl;
3+
use uv_pypi_types::{Requirement, RequirementSource};
4+
5+
pub use crate::extras::*;
16
pub use crate::lookahead::*;
27
pub use crate::source_tree::*;
38
pub use crate::sources::*;
49
pub use crate::specification::*;
510
pub use crate::unnamed::*;
611

12+
mod extras;
713
mod lookahead;
814
mod source_tree;
915
mod sources;
1016
mod specification;
1117
mod unnamed;
1218
pub mod upgrade;
19+
20+
/// Convert a [`Requirement`] into a [`Dist`], if it is a direct URL.
21+
pub(crate) fn required_dist(
22+
requirement: &Requirement,
23+
) -> Result<Option<Dist>, uv_distribution_types::Error> {
24+
Ok(Some(match &requirement.source {
25+
RequirementSource::Registry { .. } => return Ok(None),
26+
RequirementSource::Url {
27+
subdirectory,
28+
location,
29+
ext,
30+
url,
31+
} => Dist::from_http_url(
32+
requirement.name.clone(),
33+
url.clone(),
34+
location.clone(),
35+
subdirectory.clone(),
36+
*ext,
37+
)?,
38+
RequirementSource::Git {
39+
repository,
40+
reference,
41+
precise,
42+
subdirectory,
43+
url,
44+
} => {
45+
let git_url = if let Some(precise) = precise {
46+
GitUrl::from_commit(repository.clone(), reference.clone(), *precise)
47+
} else {
48+
GitUrl::from_reference(repository.clone(), reference.clone())
49+
};
50+
Dist::Source(SourceDist::Git(GitSourceDist {
51+
name: requirement.name.clone(),
52+
git: Box::new(git_url),
53+
subdirectory: subdirectory.clone(),
54+
url: url.clone(),
55+
}))
56+
}
57+
RequirementSource::Path {
58+
install_path,
59+
ext,
60+
url,
61+
} => Dist::from_file_url(requirement.name.clone(), url.clone(), install_path, *ext)?,
62+
RequirementSource::Directory {
63+
install_path,
64+
r#virtual,
65+
url,
66+
editable,
67+
} => Dist::from_directory_url(
68+
requirement.name.clone(),
69+
url.clone(),
70+
install_path,
71+
*editable,
72+
*r#virtual,
73+
)?,
74+
}))
75+
}

crates/uv-requirements/src/lookahead.rs

Lines changed: 2 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,10 @@ use rustc_hash::FxHashSet;
66
use thiserror::Error;
77
use tracing::trace;
88

9+
use crate::required_dist;
910
use uv_configuration::{Constraints, Overrides};
1011
use uv_distribution::{DistributionDatabase, Reporter};
11-
use uv_distribution_types::{BuiltDist, Dist, DistributionMetadata, GitSourceDist, SourceDist};
12-
use uv_git::GitUrl;
12+
use uv_distribution_types::{BuiltDist, Dist, DistributionMetadata, SourceDist};
1313
use uv_normalize::GroupName;
1414
use uv_pypi_types::{Requirement, RequirementSource};
1515
use uv_resolver::{InMemoryIndex, MetadataResponse, ResolverMarkers};
@@ -245,58 +245,3 @@ impl<'a, Context: BuildContext> LookaheadResolver<'a, Context> {
245245
)))
246246
}
247247
}
248-
249-
/// Convert a [`Requirement`] into a [`Dist`], if it is a direct URL.
250-
fn required_dist(requirement: &Requirement) -> Result<Option<Dist>, uv_distribution_types::Error> {
251-
Ok(Some(match &requirement.source {
252-
RequirementSource::Registry { .. } => return Ok(None),
253-
RequirementSource::Url {
254-
subdirectory,
255-
location,
256-
ext,
257-
url,
258-
} => Dist::from_http_url(
259-
requirement.name.clone(),
260-
url.clone(),
261-
location.clone(),
262-
subdirectory.clone(),
263-
*ext,
264-
)?,
265-
RequirementSource::Git {
266-
repository,
267-
reference,
268-
precise,
269-
subdirectory,
270-
url,
271-
} => {
272-
let git_url = if let Some(precise) = precise {
273-
GitUrl::from_commit(repository.clone(), reference.clone(), *precise)
274-
} else {
275-
GitUrl::from_reference(repository.clone(), reference.clone())
276-
};
277-
Dist::Source(SourceDist::Git(GitSourceDist {
278-
name: requirement.name.clone(),
279-
git: Box::new(git_url),
280-
subdirectory: subdirectory.clone(),
281-
url: url.clone(),
282-
}))
283-
}
284-
RequirementSource::Path {
285-
install_path,
286-
ext,
287-
url,
288-
} => Dist::from_file_url(requirement.name.clone(), url.clone(), install_path, *ext)?,
289-
RequirementSource::Directory {
290-
install_path,
291-
r#virtual,
292-
url,
293-
editable,
294-
} => Dist::from_directory_url(
295-
requirement.name.clone(),
296-
url.clone(),
297-
install_path,
298-
*editable,
299-
*r#virtual,
300-
)?,
301-
}))
302-
}

crates/uv-requirements/src/source_tree.rs

Lines changed: 6 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
use std::borrow::Cow;
2-
use std::path::{Path, PathBuf};
2+
use std::path::Path;
33
use std::sync::Arc;
44

55
use anyhow::{Context, Result};
@@ -34,8 +34,6 @@ pub struct SourceTreeResolution {
3434
/// Used, e.g., to determine the input requirements when a user specifies a `pyproject.toml`
3535
/// file, which may require running PEP 517 build hooks to extract metadata.
3636
pub struct SourceTreeResolver<'a, Context: BuildContext> {
37-
/// The requirements for the project.
38-
source_trees: Vec<PathBuf>,
3937
/// The extras to include when resolving requirements.
4038
extras: &'a ExtrasSpecification,
4139
/// The hash policy to enforce.
@@ -49,14 +47,12 @@ pub struct SourceTreeResolver<'a, Context: BuildContext> {
4947
impl<'a, Context: BuildContext> SourceTreeResolver<'a, Context> {
5048
/// Instantiate a new [`SourceTreeResolver`] for a given set of `source_trees`.
5149
pub fn new(
52-
source_trees: Vec<PathBuf>,
5350
extras: &'a ExtrasSpecification,
5451
hasher: &'a HashStrategy,
5552
index: &'a InMemoryIndex,
5653
database: DistributionDatabase<'a, Context>,
5754
) -> Self {
5855
Self {
59-
source_trees,
6056
extras,
6157
hasher,
6258
index,
@@ -74,10 +70,11 @@ impl<'a, Context: BuildContext> SourceTreeResolver<'a, Context> {
7470
}
7571

7672
/// Resolve the requirements from the provided source trees.
77-
pub async fn resolve(self) -> Result<Vec<SourceTreeResolution>> {
78-
let resolutions: Vec<_> = self
79-
.source_trees
80-
.iter()
73+
pub async fn resolve(
74+
self,
75+
source_trees: impl Iterator<Item = &Path>,
76+
) -> Result<Vec<SourceTreeResolution>> {
77+
let resolutions: Vec<_> = source_trees
8178
.map(|source_tree| async { self.resolve_source_tree(source_tree).await })
8279
.collect::<FuturesOrdered<_>>()
8380
.try_collect()

crates/uv-requirements/src/unnamed.rs

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -36,8 +36,6 @@ pub enum NamedRequirementsError {
3636

3737
/// Like [`RequirementsSpecification`], but with concrete names for all requirements.
3838
pub struct NamedRequirementsResolver<'a, Context: BuildContext> {
39-
/// The requirements for the project.
40-
requirements: Vec<UnnamedRequirement<VerbatimParsedUrl>>,
4139
/// Whether to check hashes for distributions.
4240
hasher: &'a HashStrategy,
4341
/// The in-memory index for resolving dependencies.
@@ -47,15 +45,13 @@ pub struct NamedRequirementsResolver<'a, Context: BuildContext> {
4745
}
4846

4947
impl<'a, Context: BuildContext> NamedRequirementsResolver<'a, Context> {
50-
/// Instantiate a new [`NamedRequirementsResolver`] for a given set of requirements.
48+
/// Instantiate a new [`NamedRequirementsResolver`].
5149
pub fn new(
52-
requirements: Vec<UnnamedRequirement<VerbatimParsedUrl>>,
5350
hasher: &'a HashStrategy,
5451
index: &'a InMemoryIndex,
5552
database: DistributionDatabase<'a, Context>,
5653
) -> Self {
5754
Self {
58-
requirements,
5955
hasher,
6056
index,
6157
database,
@@ -72,15 +68,16 @@ impl<'a, Context: BuildContext> NamedRequirementsResolver<'a, Context> {
7268
}
7369

7470
/// Resolve any unnamed requirements in the specification.
75-
pub async fn resolve(self) -> Result<Vec<Requirement>, NamedRequirementsError> {
71+
pub async fn resolve(
72+
self,
73+
requirements: impl Iterator<Item = UnnamedRequirement<VerbatimParsedUrl>>,
74+
) -> Result<Vec<Requirement>, NamedRequirementsError> {
7675
let Self {
77-
requirements,
7876
hasher,
7977
index,
8078
database,
8179
} = self;
8280
requirements
83-
.into_iter()
8481
.map(|requirement| async {
8582
Self::resolve_requirement(requirement, hasher, index, &database)
8683
.await

0 commit comments

Comments
 (0)