diff --git a/crates/pypi-types/src/metadata.rs b/crates/pypi-types/src/metadata.rs
index c1b11b33bbb1..1732c45de71b 100644
--- a/crates/pypi-types/src/metadata.rs
+++ b/crates/pypi-types/src/metadata.rs
@@ -327,6 +327,73 @@ fn parse_version(metadata_version: &str) -> Result<(u8, u8), MetadataError> {
Ok((major, minor))
}
+/// Python Package Metadata 2.3 as specified in
+/// .
+///
+/// This is a subset of [`Metadata23`]; specifically, it omits the `version` and `requires-python`
+/// fields, which aren't necessary when extracting the requirements of a package without installing
+/// the package itself.
+#[derive(Serialize, Deserialize, Debug, Clone)]
+#[serde(rename_all = "kebab-case")]
+pub struct RequiresDist {
+ pub name: PackageName,
+ pub requires_dist: Vec>,
+ pub provides_extras: Vec,
+}
+
+impl RequiresDist {
+ /// Extract the [`RequiresDist`] from a `pyproject.toml` file, as specified in PEP 621.
+ pub fn parse_pyproject_toml(contents: &str) -> Result {
+ let pyproject_toml: PyProjectToml = toml::from_str(contents)?;
+
+ let project = pyproject_toml
+ .project
+ .ok_or(MetadataError::FieldNotFound("project"))?;
+
+ // If any of the fields we need were declared as dynamic, we can't use the `pyproject.toml`
+ // file.
+ let dynamic = project.dynamic.unwrap_or_default();
+ for field in dynamic {
+ match field.as_str() {
+ "dependencies" => return Err(MetadataError::DynamicField("dependencies")),
+ "optional-dependencies" => {
+ return Err(MetadataError::DynamicField("optional-dependencies"))
+ }
+ _ => (),
+ }
+ }
+
+ let name = project.name;
+
+ // Extract the requirements.
+ let mut requires_dist = project
+ .dependencies
+ .unwrap_or_default()
+ .into_iter()
+ .map(Requirement::from)
+ .collect::>();
+
+ // Extract the optional dependencies.
+ let mut provides_extras: Vec = Vec::new();
+ for (extra, requirements) in project.optional_dependencies.unwrap_or_default() {
+ requires_dist.extend(
+ requirements
+ .into_iter()
+ .map(Requirement::from)
+ .map(|requirement| requirement.with_extra_marker(&extra))
+ .collect::>(),
+ );
+ provides_extras.push(extra);
+ }
+
+ Ok(Self {
+ name,
+ requires_dist,
+ provides_extras,
+ })
+ }
+}
+
/// The headers of a distribution metadata file.
#[derive(Debug)]
struct Headers<'a>(Vec>);
diff --git a/crates/uv-distribution/src/distribution_database.rs b/crates/uv-distribution/src/distribution_database.rs
index 1ca5af85b2d0..0369df32ca50 100644
--- a/crates/uv-distribution/src/distribution_database.rs
+++ b/crates/uv-distribution/src/distribution_database.rs
@@ -34,7 +34,7 @@ use crate::archive::Archive;
use crate::locks::Locks;
use crate::metadata::{ArchiveMetadata, Metadata};
use crate::source::SourceDistributionBuilder;
-use crate::{Error, LocalWheel, Reporter};
+use crate::{Error, LocalWheel, Reporter, RequiresDist};
/// A cached high-level interface to convert distributions (a requirement resolved to a location)
/// to a wheel or wheel metadata.
@@ -434,6 +434,11 @@ impl<'a, Context: BuildContext> DistributionDatabase<'a, Context> {
Ok(metadata)
}
+ /// Return the [`RequiresDist`] from a `pyproject.toml`, if it can be statically extracted.
+ pub async fn requires_dist(&self, project_root: &Path) -> Result {
+ self.builder.requires_dist(project_root).await
+ }
+
/// Stream a wheel from a URL, unzipping it into the cache as it's downloaded.
async fn stream_wheel(
&self,
diff --git a/crates/uv-distribution/src/lib.rs b/crates/uv-distribution/src/lib.rs
index 51261e98d9bd..7d8570465bb9 100644
--- a/crates/uv-distribution/src/lib.rs
+++ b/crates/uv-distribution/src/lib.rs
@@ -2,7 +2,7 @@ pub use distribution_database::{DistributionDatabase, HttpArchivePointer, LocalA
pub use download::LocalWheel;
pub use error::Error;
pub use index::{BuiltWheelIndex, RegistryWheelIndex};
-pub use metadata::{ArchiveMetadata, Metadata};
+pub use metadata::{ArchiveMetadata, Metadata, RequiresDist};
pub use reporter::Reporter;
pub use workspace::{ProjectWorkspace, Workspace, WorkspaceError, WorkspaceMember};
diff --git a/crates/uv-distribution/src/metadata/mod.rs b/crates/uv-distribution/src/metadata/mod.rs
index 52303e2c9e88..eab16103a843 100644
--- a/crates/uv-distribution/src/metadata/mod.rs
+++ b/crates/uv-distribution/src/metadata/mod.rs
@@ -1,17 +1,18 @@
-mod lowering;
-
-use std::collections::BTreeMap;
use std::path::Path;
use thiserror::Error;
use pep440_rs::{Version, VersionSpecifiers};
use pypi_types::{HashDigest, Metadata23};
+pub use requires_dist::RequiresDist;
use uv_configuration::PreviewMode;
use uv_normalize::{ExtraName, PackageName};
-use crate::metadata::lowering::{lower_requirement, LoweringError};
-use crate::{ProjectWorkspace, WorkspaceError};
+use crate::metadata::lowering::LoweringError;
+use crate::WorkspaceError;
+
+mod lowering;
+mod requires_dist;
#[derive(Debug, Error)]
pub enum MetadataLoweringError {
@@ -56,55 +57,29 @@ impl Metadata {
project_root: &Path,
preview_mode: PreviewMode,
) -> Result {
- // TODO(konsti): Limit discovery for Git checkouts to Git root.
- // TODO(konsti): Cache workspace discovery.
- let Some(project_workspace) =
- ProjectWorkspace::from_maybe_project_root(project_root, None).await?
- else {
- return Ok(Self::from_metadata23(metadata));
- };
-
- Self::from_project_workspace(metadata, &project_workspace, preview_mode)
- }
-
- fn from_project_workspace(
- metadata: Metadata23,
- project_workspace: &ProjectWorkspace,
- preview_mode: PreviewMode,
- ) -> Result {
- let empty = BTreeMap::default();
- let sources = project_workspace
- .current_project()
- .pyproject_toml()
- .tool
- .as_ref()
- .and_then(|tool| tool.uv.as_ref())
- .and_then(|uv| uv.sources.as_ref())
- .unwrap_or(&empty);
-
- let requires_dist = metadata
- .requires_dist
- .into_iter()
- .map(|requirement| {
- let requirement_name = requirement.name.clone();
- lower_requirement(
- requirement,
- &metadata.name,
- project_workspace.project_root(),
- sources,
- project_workspace.workspace(),
- preview_mode,
- )
- .map_err(|err| MetadataLoweringError::LoweringError(requirement_name.clone(), err))
- })
- .collect::>()?;
+ // Lower the requirements.
+ let RequiresDist {
+ name,
+ requires_dist,
+ provides_extras,
+ } = RequiresDist::from_workspace(
+ pypi_types::RequiresDist {
+ name: metadata.name,
+ requires_dist: metadata.requires_dist,
+ provides_extras: metadata.provides_extras,
+ },
+ project_root,
+ preview_mode,
+ )
+ .await?;
+ // Combine with the remaining metadata.
Ok(Self {
- name: metadata.name,
+ name,
version: metadata.version,
requires_dist,
requires_python: metadata.requires_python,
- provides_extras: metadata.provides_extras,
+ provides_extras,
})
}
}
@@ -137,265 +112,3 @@ impl From for ArchiveMetadata {
}
}
}
-
-#[cfg(test)]
-mod test {
- use anyhow::Context;
- use std::path::Path;
-
- use indoc::indoc;
- use insta::assert_snapshot;
-
- use pypi_types::Metadata23;
- use uv_configuration::PreviewMode;
-
- use crate::metadata::Metadata;
- use crate::pyproject::PyProjectToml;
- use crate::ProjectWorkspace;
-
- async fn metadata_from_pyproject_toml(contents: &str) -> anyhow::Result {
- let pyproject_toml: PyProjectToml = toml::from_str(contents)?;
- let path = Path::new("pyproject.toml");
- let project_workspace = ProjectWorkspace::from_project(
- path,
- pyproject_toml
- .project
- .as_ref()
- .context("metadata field project not found")?,
- &pyproject_toml,
- Some(path),
- )
- .await?;
- let metadata = Metadata23::parse_pyproject_toml(contents)?;
- Ok(Metadata::from_project_workspace(
- metadata,
- &project_workspace,
- PreviewMode::Enabled,
- )?)
- }
-
- async fn format_err(input: &str) -> String {
- let err = metadata_from_pyproject_toml(input).await.unwrap_err();
- let mut causes = err.chain();
- let mut message = String::new();
- message.push_str(&format!("error: {}\n", causes.next().unwrap()));
- for err in causes {
- message.push_str(&format!(" Caused by: {err}\n"));
- }
- message
- }
-
- #[tokio::test]
- async fn conflict_project_and_sources() {
- let input = indoc! {r#"
- [project]
- name = "foo"
- version = "0.0.0"
- dependencies = [
- "tqdm @ git+https://github.com/tqdm/tqdm",
- ]
- [tool.uv.sources]
- tqdm = { url = "https://files.pythonhosted.org/packages/a5/d6/502a859bac4ad5e274255576cd3e15ca273cdb91731bc39fb840dd422ee9/tqdm-4.66.0-py3-none-any.whl" }
- "#};
-
- assert_snapshot!(format_err(input).await, @r###"
- error: Failed to parse entry for: `tqdm`
- Caused by: Can't combine URLs from both `project.dependencies` and `tool.uv.sources`
- "###);
- }
-
- #[tokio::test]
- async fn too_many_git_specs() {
- let input = indoc! {r#"
- [project]
- name = "foo"
- version = "0.0.0"
- dependencies = [
- "tqdm",
- ]
- [tool.uv.sources]
- tqdm = { git = "https://github.com/tqdm/tqdm", rev = "baaaaaab", tag = "v1.0.0" }
- "#};
-
- assert_snapshot!(format_err(input).await, @r###"
- error: Failed to parse entry for: `tqdm`
- Caused by: Can only specify one of: `rev`, `tag`, or `branch`
- "###);
- }
-
- #[tokio::test]
- async fn too_many_git_typo() {
- let input = indoc! {r#"
- [project]
- name = "foo"
- version = "0.0.0"
- dependencies = [
- "tqdm",
- ]
- [tool.uv.sources]
- tqdm = { git = "https://github.com/tqdm/tqdm", ref = "baaaaaab" }
- "#};
-
- // TODO(konsti): This should tell you the set of valid fields
- assert_snapshot!(format_err(input).await, @r###"
- error: TOML parse error at line 8, column 8
- |
- 8 | tqdm = { git = "https://github.com/tqdm/tqdm", ref = "baaaaaab" }
- | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
- data did not match any variant of untagged enum Source
-
- "###);
- }
-
- #[tokio::test]
- async fn you_cant_mix_those() {
- let input = indoc! {r#"
- [project]
- name = "foo"
- version = "0.0.0"
- dependencies = [
- "tqdm",
- ]
- [tool.uv.sources]
- tqdm = { path = "tqdm", index = "torch" }
- "#};
-
- // TODO(konsti): This should tell you the set of valid fields
- assert_snapshot!(format_err(input).await, @r###"
- error: TOML parse error at line 8, column 8
- |
- 8 | tqdm = { path = "tqdm", index = "torch" }
- | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
- data did not match any variant of untagged enum Source
-
- "###);
- }
-
- #[tokio::test]
- async fn missing_constraint() {
- let input = indoc! {r#"
- [project]
- name = "foo"
- version = "0.0.0"
- dependencies = [
- "tqdm",
- ]
- "#};
- assert!(metadata_from_pyproject_toml(input).await.is_ok());
- }
-
- #[tokio::test]
- async fn invalid_syntax() {
- let input = indoc! {r#"
- [project]
- name = "foo"
- version = "0.0.0"
- dependencies = [
- "tqdm ==4.66.0",
- ]
- [tool.uv.sources]
- tqdm = { url = invalid url to tqdm-4.66.0-py3-none-any.whl" }
- "#};
-
- assert_snapshot!(format_err(input).await, @r###"
- error: TOML parse error at line 8, column 16
- |
- 8 | tqdm = { url = invalid url to tqdm-4.66.0-py3-none-any.whl" }
- | ^
- invalid string
- expected `"`, `'`
-
- "###);
- }
-
- #[tokio::test]
- async fn invalid_url() {
- let input = indoc! {r#"
- [project]
- name = "foo"
- version = "0.0.0"
- dependencies = [
- "tqdm ==4.66.0",
- ]
- [tool.uv.sources]
- tqdm = { url = "§invalid#+#*Ä" }
- "#};
-
- assert_snapshot!(format_err(input).await, @r###"
- error: TOML parse error at line 8, column 8
- |
- 8 | tqdm = { url = "§invalid#+#*Ä" }
- | ^^^^^^^^^^^^^^^^^^^^^^^^^^^
- data did not match any variant of untagged enum Source
-
- "###);
- }
-
- #[tokio::test]
- async fn workspace_and_url_spec() {
- let input = indoc! {r#"
- [project]
- name = "foo"
- version = "0.0.0"
- dependencies = [
- "tqdm @ git+https://github.com/tqdm/tqdm",
- ]
- [tool.uv.sources]
- tqdm = { workspace = true }
- "#};
-
- assert_snapshot!(format_err(input).await, @r###"
- error: Failed to parse entry for: `tqdm`
- Caused by: Can't combine URLs from both `project.dependencies` and `tool.uv.sources`
- "###);
- }
-
- #[tokio::test]
- async fn missing_workspace_package() {
- let input = indoc! {r#"
- [project]
- name = "foo"
- version = "0.0.0"
- dependencies = [
- "tqdm ==4.66.0",
- ]
- [tool.uv.sources]
- tqdm = { workspace = true }
- "#};
-
- assert_snapshot!(format_err(input).await, @r###"
- error: Failed to parse entry for: `tqdm`
- Caused by: Package is not included as workspace package in `tool.uv.workspace`
- "###);
- }
-
- #[tokio::test]
- async fn cant_be_dynamic() {
- let input = indoc! {r#"
- [project]
- name = "foo"
- version = "0.0.0"
- dynamic = [
- "dependencies"
- ]
- [tool.uv.sources]
- tqdm = { workspace = true }
- "#};
-
- assert_snapshot!(format_err(input).await, @r###"
- error: The following field was marked as dynamic: dependencies
- "###);
- }
-
- #[tokio::test]
- async fn missing_project_section() {
- let input = indoc! {"
- [tool.uv.sources]
- tqdm = { workspace = true }
- "};
-
- assert_snapshot!(format_err(input).await, @r###"
- error: metadata field project not found
- "###);
- }
-}
diff --git a/crates/uv-distribution/src/metadata/requires_dist.rs b/crates/uv-distribution/src/metadata/requires_dist.rs
new file mode 100644
index 000000000000..7010ff5329b2
--- /dev/null
+++ b/crates/uv-distribution/src/metadata/requires_dist.rs
@@ -0,0 +1,359 @@
+use std::collections::BTreeMap;
+use std::path::Path;
+
+use uv_configuration::PreviewMode;
+use uv_normalize::{ExtraName, PackageName};
+
+use crate::metadata::lowering::lower_requirement;
+use crate::metadata::MetadataLoweringError;
+use crate::{Metadata, ProjectWorkspace};
+
+#[derive(Debug, Clone)]
+pub struct RequiresDist {
+ pub name: PackageName,
+ pub requires_dist: Vec,
+ pub provides_extras: Vec,
+}
+
+impl RequiresDist {
+ /// Lower without considering `tool.uv` in `pyproject.toml`, used for index and other archive
+ /// dependencies.
+ pub fn from_metadata23(metadata: pypi_types::RequiresDist) -> Self {
+ Self {
+ name: metadata.name,
+ requires_dist: metadata
+ .requires_dist
+ .into_iter()
+ .map(pypi_types::Requirement::from)
+ .collect(),
+ provides_extras: metadata.provides_extras,
+ }
+ }
+
+ /// Lower by considering `tool.uv` in `pyproject.toml` if present, used for Git and directory
+ /// dependencies.
+ pub async fn from_workspace(
+ metadata: pypi_types::RequiresDist,
+ project_root: &Path,
+ preview_mode: PreviewMode,
+ ) -> Result {
+ // TODO(konsti): Limit discovery for Git checkouts to Git root.
+ // TODO(konsti): Cache workspace discovery.
+ let Some(project_workspace) =
+ ProjectWorkspace::from_maybe_project_root(project_root, None).await?
+ else {
+ return Ok(Self::from_metadata23(metadata));
+ };
+
+ Self::from_project_workspace(metadata, &project_workspace, preview_mode)
+ }
+
+ pub fn from_project_workspace(
+ metadata: pypi_types::RequiresDist,
+ project_workspace: &ProjectWorkspace,
+ preview_mode: PreviewMode,
+ ) -> Result {
+ let empty = BTreeMap::default();
+ let sources = project_workspace
+ .current_project()
+ .pyproject_toml()
+ .tool
+ .as_ref()
+ .and_then(|tool| tool.uv.as_ref())
+ .and_then(|uv| uv.sources.as_ref())
+ .unwrap_or(&empty);
+
+ let requires_dist = metadata
+ .requires_dist
+ .into_iter()
+ .map(|requirement| {
+ let requirement_name = requirement.name.clone();
+ lower_requirement(
+ requirement,
+ &metadata.name,
+ project_workspace.project_root(),
+ sources,
+ project_workspace.workspace(),
+ preview_mode,
+ )
+ .map_err(|err| MetadataLoweringError::LoweringError(requirement_name.clone(), err))
+ })
+ .collect::>()?;
+
+ Ok(Self {
+ name: metadata.name,
+ requires_dist,
+ provides_extras: metadata.provides_extras,
+ })
+ }
+}
+
+impl From for RequiresDist {
+ fn from(metadata: Metadata) -> Self {
+ Self {
+ name: metadata.name,
+ requires_dist: metadata.requires_dist,
+ provides_extras: metadata.provides_extras,
+ }
+ }
+}
+
+#[cfg(test)]
+mod test {
+ use std::path::Path;
+
+ use anyhow::Context;
+ use indoc::indoc;
+ use insta::assert_snapshot;
+
+ use uv_configuration::PreviewMode;
+
+ use crate::pyproject::PyProjectToml;
+ use crate::{ProjectWorkspace, RequiresDist};
+
+ async fn requires_dist_from_pyproject_toml(contents: &str) -> anyhow::Result {
+ let pyproject_toml: PyProjectToml = toml::from_str(contents)?;
+ let path = Path::new("pyproject.toml");
+ let project_workspace = ProjectWorkspace::from_project(
+ path,
+ pyproject_toml
+ .project
+ .as_ref()
+ .context("metadata field project not found")?,
+ &pyproject_toml,
+ Some(path),
+ )
+ .await?;
+ let requires_dist = pypi_types::RequiresDist::parse_pyproject_toml(contents)?;
+ Ok(RequiresDist::from_project_workspace(
+ requires_dist,
+ &project_workspace,
+ PreviewMode::Enabled,
+ )?)
+ }
+
+ async fn format_err(input: &str) -> String {
+ let err = requires_dist_from_pyproject_toml(input).await.unwrap_err();
+ let mut causes = err.chain();
+ let mut message = String::new();
+ message.push_str(&format!("error: {}\n", causes.next().unwrap()));
+ for err in causes {
+ message.push_str(&format!(" Caused by: {err}\n"));
+ }
+ message
+ }
+
+ #[tokio::test]
+ async fn conflict_project_and_sources() {
+ let input = indoc! {r#"
+ [project]
+ name = "foo"
+ version = "0.0.0"
+ dependencies = [
+ "tqdm @ git+https://github.com/tqdm/tqdm",
+ ]
+ [tool.uv.sources]
+ tqdm = { url = "https://files.pythonhosted.org/packages/a5/d6/502a859bac4ad5e274255576cd3e15ca273cdb91731bc39fb840dd422ee9/tqdm-4.66.0-py3-none-any.whl" }
+ "#};
+
+ assert_snapshot!(format_err(input).await, @r###"
+ error: Failed to parse entry for: `tqdm`
+ Caused by: Can't combine URLs from both `project.dependencies` and `tool.uv.sources`
+ "###);
+ }
+
+ #[tokio::test]
+ async fn too_many_git_specs() {
+ let input = indoc! {r#"
+ [project]
+ name = "foo"
+ version = "0.0.0"
+ dependencies = [
+ "tqdm",
+ ]
+ [tool.uv.sources]
+ tqdm = { git = "https://github.com/tqdm/tqdm", rev = "baaaaaab", tag = "v1.0.0" }
+ "#};
+
+ assert_snapshot!(format_err(input).await, @r###"
+ error: Failed to parse entry for: `tqdm`
+ Caused by: Can only specify one of: `rev`, `tag`, or `branch`
+ "###);
+ }
+
+ #[tokio::test]
+ async fn too_many_git_typo() {
+ let input = indoc! {r#"
+ [project]
+ name = "foo"
+ version = "0.0.0"
+ dependencies = [
+ "tqdm",
+ ]
+ [tool.uv.sources]
+ tqdm = { git = "https://github.com/tqdm/tqdm", ref = "baaaaaab" }
+ "#};
+
+ // TODO(konsti): This should tell you the set of valid fields
+ assert_snapshot!(format_err(input).await, @r###"
+ error: TOML parse error at line 8, column 8
+ |
+ 8 | tqdm = { git = "https://github.com/tqdm/tqdm", ref = "baaaaaab" }
+ | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+ data did not match any variant of untagged enum Source
+
+ "###);
+ }
+
+ #[tokio::test]
+ async fn you_cant_mix_those() {
+ let input = indoc! {r#"
+ [project]
+ name = "foo"
+ version = "0.0.0"
+ dependencies = [
+ "tqdm",
+ ]
+ [tool.uv.sources]
+ tqdm = { path = "tqdm", index = "torch" }
+ "#};
+
+ // TODO(konsti): This should tell you the set of valid fields
+ assert_snapshot!(format_err(input).await, @r###"
+ error: TOML parse error at line 8, column 8
+ |
+ 8 | tqdm = { path = "tqdm", index = "torch" }
+ | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+ data did not match any variant of untagged enum Source
+
+ "###);
+ }
+
+ #[tokio::test]
+ async fn missing_constraint() {
+ let input = indoc! {r#"
+ [project]
+ name = "foo"
+ version = "0.0.0"
+ dependencies = [
+ "tqdm",
+ ]
+ "#};
+ assert!(requires_dist_from_pyproject_toml(input).await.is_ok());
+ }
+
+ #[tokio::test]
+ async fn invalid_syntax() {
+ let input = indoc! {r#"
+ [project]
+ name = "foo"
+ version = "0.0.0"
+ dependencies = [
+ "tqdm ==4.66.0",
+ ]
+ [tool.uv.sources]
+ tqdm = { url = invalid url to tqdm-4.66.0-py3-none-any.whl" }
+ "#};
+
+ assert_snapshot!(format_err(input).await, @r###"
+ error: TOML parse error at line 8, column 16
+ |
+ 8 | tqdm = { url = invalid url to tqdm-4.66.0-py3-none-any.whl" }
+ | ^
+ invalid string
+ expected `"`, `'`
+
+ "###);
+ }
+
+ #[tokio::test]
+ async fn invalid_url() {
+ let input = indoc! {r#"
+ [project]
+ name = "foo"
+ version = "0.0.0"
+ dependencies = [
+ "tqdm ==4.66.0",
+ ]
+ [tool.uv.sources]
+ tqdm = { url = "§invalid#+#*Ä" }
+ "#};
+
+ assert_snapshot!(format_err(input).await, @r###"
+ error: TOML parse error at line 8, column 8
+ |
+ 8 | tqdm = { url = "§invalid#+#*Ä" }
+ | ^^^^^^^^^^^^^^^^^^^^^^^^^^^
+ data did not match any variant of untagged enum Source
+
+ "###);
+ }
+
+ #[tokio::test]
+ async fn workspace_and_url_spec() {
+ let input = indoc! {r#"
+ [project]
+ name = "foo"
+ version = "0.0.0"
+ dependencies = [
+ "tqdm @ git+https://github.com/tqdm/tqdm",
+ ]
+ [tool.uv.sources]
+ tqdm = { workspace = true }
+ "#};
+
+ assert_snapshot!(format_err(input).await, @r###"
+ error: Failed to parse entry for: `tqdm`
+ Caused by: Can't combine URLs from both `project.dependencies` and `tool.uv.sources`
+ "###);
+ }
+
+ #[tokio::test]
+ async fn missing_workspace_package() {
+ let input = indoc! {r#"
+ [project]
+ name = "foo"
+ version = "0.0.0"
+ dependencies = [
+ "tqdm ==4.66.0",
+ ]
+ [tool.uv.sources]
+ tqdm = { workspace = true }
+ "#};
+
+ assert_snapshot!(format_err(input).await, @r###"
+ error: Failed to parse entry for: `tqdm`
+ Caused by: Package is not included as workspace package in `tool.uv.workspace`
+ "###);
+ }
+
+ #[tokio::test]
+ async fn cant_be_dynamic() {
+ let input = indoc! {r#"
+ [project]
+ name = "foo"
+ version = "0.0.0"
+ dynamic = [
+ "dependencies"
+ ]
+ [tool.uv.sources]
+ tqdm = { workspace = true }
+ "#};
+
+ assert_snapshot!(format_err(input).await, @r###"
+ error: The following field was marked as dynamic: dependencies
+ "###);
+ }
+
+ #[tokio::test]
+ async fn missing_project_section() {
+ let input = indoc! {"
+ [tool.uv.sources]
+ tqdm = { workspace = true }
+ "};
+
+ assert_snapshot!(format_err(input).await, @r###"
+ error: metadata field project not found
+ "###);
+ }
+}
diff --git a/crates/uv-distribution/src/source/mod.rs b/crates/uv-distribution/src/source/mod.rs
index 74331cf8062c..9c0ae58923c9 100644
--- a/crates/uv-distribution/src/source/mod.rs
+++ b/crates/uv-distribution/src/source/mod.rs
@@ -39,7 +39,7 @@ use crate::metadata::{ArchiveMetadata, Metadata};
use crate::reporter::Facade;
use crate::source::built_wheel_metadata::BuiltWheelMetadata;
use crate::source::revision::Revision;
-use crate::Reporter;
+use crate::{Reporter, RequiresDist};
mod built_wheel_metadata;
mod revision;
@@ -385,6 +385,14 @@ impl<'a, T: BuildContext> SourceDistributionBuilder<'a, T> {
Ok(metadata)
}
+ /// Return the [`RequiresDist`] from a `pyproject.toml`, if it can be statically extracted.
+ pub(crate) async fn requires_dist(&self, project_root: &Path) -> Result {
+ let requires_dist = read_requires_dist(project_root).await?;
+ let requires_dist =
+ RequiresDist::from_workspace(requires_dist, project_root, self.preview_mode).await?;
+ Ok(requires_dist)
+ }
+
/// Build a source distribution from a remote URL.
#[allow(clippy::too_many_arguments)]
async fn url<'data>(
@@ -1625,6 +1633,25 @@ async fn read_pyproject_toml(
Ok(metadata)
}
+/// Return the [`pypi_types::RequiresDist`] from a `pyproject.toml`, if it can be statically extracted.
+async fn read_requires_dist(project_root: &Path) -> Result {
+ // Read the `pyproject.toml` file.
+ let pyproject_toml = project_root.join("pyproject.toml");
+ let content = match fs::read_to_string(pyproject_toml).await {
+ Ok(content) => content,
+ Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
+ return Err(Error::MissingPyprojectToml);
+ }
+ Err(err) => return Err(Error::CacheRead(err)),
+ };
+
+ // Parse the metadata.
+ let requires_dist = pypi_types::RequiresDist::parse_pyproject_toml(&content)
+ .map_err(Error::DynamicPyprojectToml)?;
+
+ Ok(requires_dist)
+}
+
/// Read an existing cached [`Metadata23`], if it exists.
async fn read_cached_metadata(cache_entry: &CacheEntry) -> Result