Allow multiple source entries for each package in `tool.uv.sources` (#7745)

## Summary

This PR enables users to provide multiple source entries in
`tool.uv.sources`, e.g.:

```toml
[tool.uv.sources]
httpx = [
  { git = "https://github.com/encode/httpx", tag = "0.27.2", marker = "sys_platform == 'darwin'" },
  { git = "https://github.com/encode/httpx", tag = "0.24.1", marker = "sys_platform == 'linux'" },
]
```

The implementation is relatively straightforward: when we lower the
requirement, we now return an iterator rather than a single requirement.
In other words, the above is transformed into two requirements:

```txt
httpx @ git+https://github.com/encode/httpx@0.27.2 ; sys_platform == 'darwin'
httpx @ git+https://github.com/encode/httpx@0.24.1 ; sys_platform == 'linux'
```

We verify (at deserialization time) that the markers are
non-overlapping.

Closes https://github.com/astral-sh/uv/issues/3397.
This commit is contained in:
Charlie Marsh 2024-09-30 17:16:44 -04:00 committed by GitHub
parent 71d5661bd8
commit f67347e72c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 1063 additions and 206 deletions

2
Cargo.lock generated
View File

@ -4789,6 +4789,7 @@ dependencies = [
"cache-key", "cache-key",
"distribution-filename", "distribution-filename",
"distribution-types", "distribution-types",
"either",
"fs-err", "fs-err",
"futures", "futures",
"indoc", "indoc",
@ -5319,6 +5320,7 @@ dependencies = [
"glob", "glob",
"insta", "insta",
"itertools 0.13.0", "itertools 0.13.0",
"owo-colors",
"pep440_rs", "pep440_rs",
"pep508_rs", "pep508_rs",
"pypi-types", "pypi-types",

View File

@ -5,12 +5,11 @@ use std::ops::{Bound, Deref};
use std::str::FromStr; use std::str::FromStr;
use itertools::Itertools; use itertools::Itertools;
use pep440_rs::{Version, VersionParseError, VersionSpecifier};
use pubgrub::Range; use pubgrub::Range;
#[cfg(feature = "pyo3")] #[cfg(feature = "pyo3")]
use pyo3::{basic::CompareOp, pyclass, pymethods}; use pyo3::{basic::CompareOp, pyclass, pymethods};
use serde::{de, Deserialize, Deserializer, Serialize, Serializer}; use serde::{de, Deserialize, Deserializer, Serialize, Serializer};
use pep440_rs::{Version, VersionParseError, VersionSpecifier};
use uv_normalize::ExtraName; use uv_normalize::ExtraName;
use crate::cursor::Cursor; use crate::cursor::Cursor;
@ -1667,6 +1666,28 @@ impl Display for MarkerTreeContents {
} }
} }
#[cfg(feature = "schemars")]
impl schemars::JsonSchema for MarkerTree {
fn schema_name() -> String {
"MarkerTree".to_string()
}
fn json_schema(_gen: &mut schemars::gen::SchemaGenerator) -> schemars::schema::Schema {
schemars::schema::SchemaObject {
instance_type: Some(schemars::schema::InstanceType::String.into()),
metadata: Some(Box::new(schemars::schema::Metadata {
description: Some(
"A PEP 508-compliant marker expression, e.g., `sys_platform == 'Darwin'`"
.to_string(),
),
..schemars::schema::Metadata::default()
})),
..schemars::schema::SchemaObject::default()
}
.into()
}
}
#[cfg(test)] #[cfg(test)]
mod test { mod test {
use std::ops::Bound; use std::ops::Bound;

View File

@ -34,6 +34,7 @@ uv-warnings = { workspace = true }
uv-workspace = { workspace = true } uv-workspace = { workspace = true }
anyhow = { workspace = true } anyhow = { workspace = true }
either = { workspace = true }
fs-err = { workspace = true } fs-err = { workspace = true }
futures = { workspace = true } futures = { workspace = true }
nanoid = { workspace = true } nanoid = { workspace = true }

View File

@ -1,18 +1,18 @@
use either::Either;
use std::collections::BTreeMap; use std::collections::BTreeMap;
use std::io; use std::io;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use thiserror::Error; use thiserror::Error;
use url::Url; use url::Url;
use distribution_filename::DistExtension; use distribution_filename::DistExtension;
use pep440_rs::VersionSpecifiers; use pep440_rs::VersionSpecifiers;
use pep508_rs::{VerbatimUrl, VersionOrUrl}; use pep508_rs::{MarkerTree, VerbatimUrl, VersionOrUrl};
use pypi_types::{ParsedUrlError, Requirement, RequirementSource, VerbatimParsedUrl}; use pypi_types::{ParsedUrlError, Requirement, RequirementSource, VerbatimParsedUrl};
use uv_git::GitReference; use uv_git::GitReference;
use uv_normalize::PackageName; use uv_normalize::PackageName;
use uv_warnings::warn_user_once; use uv_warnings::warn_user_once;
use uv_workspace::pyproject::{PyProjectToml, Source}; use uv_workspace::pyproject::{PyProjectToml, Source, Sources};
use uv_workspace::Workspace; use uv_workspace::Workspace;
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
@ -28,13 +28,13 @@ enum Origin {
impl LoweredRequirement { impl LoweredRequirement {
/// Combine `project.dependencies` or `project.optional-dependencies` with `tool.uv.sources`. /// Combine `project.dependencies` or `project.optional-dependencies` with `tool.uv.sources`.
pub(crate) fn from_requirement( pub(crate) fn from_requirement<'data>(
requirement: pep508_rs::Requirement<VerbatimParsedUrl>, requirement: pep508_rs::Requirement<VerbatimParsedUrl>,
project_name: &PackageName, project_name: &'data PackageName,
project_dir: &Path, project_dir: &'data Path,
project_sources: &BTreeMap<PackageName, Source>, project_sources: &'data BTreeMap<PackageName, Sources>,
workspace: &Workspace, workspace: &'data Workspace,
) -> Result<Self, LoweringError> { ) -> impl Iterator<Item = Result<LoweredRequirement, LoweringError>> + 'data {
let (source, origin) = if let Some(source) = project_sources.get(&requirement.name) { let (source, origin) = if let Some(source) = project_sources.get(&requirement.name) {
(Some(source), Origin::Project) (Some(source), Origin::Project)
} else if let Some(source) = workspace.sources().get(&requirement.name) { } else if let Some(source) = workspace.sources().get(&requirement.name) {
@ -48,18 +48,16 @@ impl LoweredRequirement {
// We require that when you use a package that's part of the workspace, ... // We require that when you use a package that's part of the workspace, ...
!workspace.packages().contains_key(&requirement.name) !workspace.packages().contains_key(&requirement.name)
// ... it must be declared as a workspace dependency (`workspace = true`), ... // ... it must be declared as a workspace dependency (`workspace = true`), ...
|| matches!( || source.as_ref().filter(|sources| !sources.is_empty()).is_some_and(|source| source.iter().all(|source| {
source, matches!(source, Source::Workspace { workspace: true, .. })
Some(Source::Workspace { }))
// By using toml, we technically support `workspace = false`.
workspace: true
})
)
// ... except for recursive self-inclusion (extras that activate other extras), e.g. // ... except for recursive self-inclusion (extras that activate other extras), e.g.
// `framework[machine_learning]` depends on `framework[cuda]`. // `framework[machine_learning]` depends on `framework[cuda]`.
|| &requirement.name == project_name; || &requirement.name == project_name;
if !workspace_package_declared { if !workspace_package_declared {
return Err(LoweringError::UndeclaredWorkspacePackage); return Either::Left(std::iter::once(Err(
LoweringError::UndeclaredWorkspacePackage,
)));
} }
let Some(source) = source else { let Some(source) = source else {
@ -74,172 +72,282 @@ impl LoweredRequirement {
requirement.name requirement.name
); );
} }
return Ok(Self(Requirement::from(requirement))); return Either::Left(std::iter::once(Ok(Self(Requirement::from(requirement)))));
}; };
let source = match source { // Determine whether the markers cover the full space for the requirement. If not, fill the
Source::Git { // remaining space with the negation of the sources.
git, let remaining = {
subdirectory, // Determine the space covered by the sources.
rev, let mut total = MarkerTree::FALSE;
tag, for source in source.iter() {
branch, total.or(source.marker());
} => {
if matches!(requirement.version_or_url, Some(VersionOrUrl::Url(_))) {
return Err(LoweringError::ConflictingUrls);
}
git_source(&git, subdirectory.map(PathBuf::from), rev, tag, branch)?
} }
Source::Url { url, subdirectory } => {
if matches!(requirement.version_or_url, Some(VersionOrUrl::Url(_))) {
return Err(LoweringError::ConflictingUrls);
}
url_source(url, subdirectory.map(PathBuf::from))?
}
Source::Path { path, editable } => {
if matches!(requirement.version_or_url, Some(VersionOrUrl::Url(_))) {
return Err(LoweringError::ConflictingUrls);
}
path_source(
PathBuf::from(path),
origin,
project_dir,
workspace.install_path(),
editable.unwrap_or(false),
)?
}
Source::Registry { index } => registry_source(&requirement, index)?,
Source::Workspace {
workspace: is_workspace,
} => {
if !is_workspace {
return Err(LoweringError::WorkspaceFalse);
}
if matches!(requirement.version_or_url, Some(VersionOrUrl::Url(_))) {
return Err(LoweringError::ConflictingUrls);
}
let member = workspace
.packages()
.get(&requirement.name)
.ok_or(LoweringError::UndeclaredWorkspacePackage)?
.clone();
// Say we have: // Determine the space covered by the requirement.
// ``` let mut remaining = total.negate();
// root remaining.and(requirement.marker.clone());
// ├── main_workspace <- We want to the path from here ...
// │ ├── pyproject.toml
// │ └── uv.lock
// └──current_workspace
// └── packages
// └── current_package <- ... to here.
// └── pyproject.toml
// ```
// The path we need in the lockfile: `../current_workspace/packages/current_project`
// member root: `/root/current_workspace/packages/current_project`
// workspace install root: `/root/current_workspace`
// relative to workspace: `packages/current_project`
// workspace lock root: `../current_workspace`
// relative to main workspace: `../current_workspace/packages/current_project`
let url = VerbatimUrl::from_absolute_path(member.root())?;
let install_path = url.to_file_path().map_err(|()| {
LoweringError::RelativeTo(io::Error::new(
io::ErrorKind::Other,
"Invalid path in file URL",
))
})?;
if member.pyproject_toml().is_package() { LoweredRequirement(Requirement {
RequirementSource::Directory { marker: remaining,
install_path, ..Requirement::from(requirement.clone())
url, })
editable: true,
r#virtual: false,
}
} else {
RequirementSource::Directory {
install_path,
url,
editable: false,
r#virtual: true,
}
}
}
Source::CatchAll { .. } => {
// Emit a dedicated error message, which is an improvement over Serde's default error.
return Err(LoweringError::InvalidEntry);
}
}; };
Ok(Self(Requirement {
name: requirement.name, Either::Right(
extras: requirement.extras, source
marker: requirement.marker, .into_iter()
source, .map(move |source| {
origin: requirement.origin, let (source, mut marker) = match source {
})) Source::Git {
git,
subdirectory,
rev,
tag,
branch,
marker,
} => {
if matches!(requirement.version_or_url, Some(VersionOrUrl::Url(_))) {
return Err(LoweringError::ConflictingUrls);
}
let source = git_source(
&git,
subdirectory.map(PathBuf::from),
rev,
tag,
branch,
)?;
(source, marker)
}
Source::Url {
url,
subdirectory,
marker,
} => {
if matches!(requirement.version_or_url, Some(VersionOrUrl::Url(_))) {
return Err(LoweringError::ConflictingUrls);
}
let source = url_source(url, subdirectory.map(PathBuf::from))?;
(source, marker)
}
Source::Path {
path,
editable,
marker,
} => {
if matches!(requirement.version_or_url, Some(VersionOrUrl::Url(_))) {
return Err(LoweringError::ConflictingUrls);
}
let source = path_source(
PathBuf::from(path),
origin,
project_dir,
workspace.install_path(),
editable.unwrap_or(false),
)?;
(source, marker)
}
Source::Registry { index, marker } => {
let source = registry_source(&requirement, index)?;
(source, marker)
}
Source::Workspace {
workspace: is_workspace,
marker,
} => {
if !is_workspace {
return Err(LoweringError::WorkspaceFalse);
}
if matches!(requirement.version_or_url, Some(VersionOrUrl::Url(_))) {
return Err(LoweringError::ConflictingUrls);
}
let member = workspace
.packages()
.get(&requirement.name)
.ok_or(LoweringError::UndeclaredWorkspacePackage)?
.clone();
// Say we have:
// ```
// root
// ├── main_workspace <- We want to the path from here ...
// │ ├── pyproject.toml
// │ └── uv.lock
// └──current_workspace
// └── packages
// └── current_package <- ... to here.
// └── pyproject.toml
// ```
// The path we need in the lockfile: `../current_workspace/packages/current_project`
// member root: `/root/current_workspace/packages/current_project`
// workspace install root: `/root/current_workspace`
// relative to workspace: `packages/current_project`
// workspace lock root: `../current_workspace`
// relative to main workspace: `../current_workspace/packages/current_project`
let url = VerbatimUrl::from_absolute_path(member.root())?;
let install_path = url.to_file_path().map_err(|()| {
LoweringError::RelativeTo(io::Error::new(
io::ErrorKind::Other,
"Invalid path in file URL",
))
})?;
let source = if member.pyproject_toml().is_package() {
RequirementSource::Directory {
install_path,
url,
editable: true,
r#virtual: false,
}
} else {
RequirementSource::Directory {
install_path,
url,
editable: false,
r#virtual: true,
}
};
(source, marker)
}
Source::CatchAll { .. } => {
// Emit a dedicated error message, which is an improvement over Serde's default error.
return Err(LoweringError::InvalidEntry);
}
};
marker.and(requirement.marker.clone());
Ok(Self(Requirement {
name: requirement.name.clone(),
extras: requirement.extras.clone(),
marker,
source,
origin: requirement.origin.clone(),
}))
})
.chain(std::iter::once(Ok(remaining)))
.filter(|requirement| match requirement {
Ok(requirement) => !requirement.0.marker.is_false(),
Err(_) => true,
}),
)
} }
/// Lower a [`pep508_rs::Requirement`] in a non-workspace setting (for example, in a PEP 723 /// Lower a [`pep508_rs::Requirement`] in a non-workspace setting (for example, in a PEP 723
/// script, which runs in an isolated context). /// script, which runs in an isolated context).
pub fn from_non_workspace_requirement( pub fn from_non_workspace_requirement<'data>(
requirement: pep508_rs::Requirement<VerbatimParsedUrl>, requirement: pep508_rs::Requirement<VerbatimParsedUrl>,
dir: &Path, dir: &'data Path,
sources: &BTreeMap<PackageName, Source>, sources: &'data BTreeMap<PackageName, Sources>,
) -> Result<Self, LoweringError> { ) -> impl Iterator<Item = Result<LoweredRequirement, LoweringError>> + 'data {
let source = sources.get(&requirement.name).cloned(); let source = sources.get(&requirement.name).cloned();
let Some(source) = source else { let Some(source) = source else {
return Ok(Self(Requirement::from(requirement))); return Either::Left(std::iter::once(Ok(Self(Requirement::from(requirement)))));
}; };
let source = match source { // Determine whether the markers cover the full space for the requirement. If not, fill the
Source::Git { // remaining space with the negation of the sources.
git, let remaining = {
subdirectory, // Determine the space covered by the sources.
rev, let mut total = MarkerTree::FALSE;
tag, for source in source.iter() {
branch, total.or(source.marker());
} => {
if matches!(requirement.version_or_url, Some(VersionOrUrl::Url(_))) {
return Err(LoweringError::ConflictingUrls);
}
git_source(&git, subdirectory.map(PathBuf::from), rev, tag, branch)?
}
Source::Url { url, subdirectory } => {
if matches!(requirement.version_or_url, Some(VersionOrUrl::Url(_))) {
return Err(LoweringError::ConflictingUrls);
}
url_source(url, subdirectory.map(PathBuf::from))?
}
Source::Path { path, editable } => {
if matches!(requirement.version_or_url, Some(VersionOrUrl::Url(_))) {
return Err(LoweringError::ConflictingUrls);
}
path_source(
PathBuf::from(path),
Origin::Project,
dir,
dir,
editable.unwrap_or(false),
)?
}
Source::Registry { index } => registry_source(&requirement, index)?,
Source::Workspace { .. } => {
return Err(LoweringError::WorkspaceMember);
}
Source::CatchAll { .. } => {
// Emit a dedicated error message, which is an improvement over Serde's default
// error.
return Err(LoweringError::InvalidEntry);
} }
// Determine the space covered by the requirement.
let mut remaining = total.negate();
remaining.and(requirement.marker.clone());
LoweredRequirement(Requirement {
marker: remaining,
..Requirement::from(requirement.clone())
})
}; };
Ok(Self(Requirement {
name: requirement.name, Either::Right(
extras: requirement.extras, source
marker: requirement.marker, .into_iter()
source, .map(move |source| {
origin: requirement.origin, let (source, mut marker) = match source {
})) Source::Git {
git,
subdirectory,
rev,
tag,
branch,
marker,
} => {
if matches!(requirement.version_or_url, Some(VersionOrUrl::Url(_))) {
return Err(LoweringError::ConflictingUrls);
}
let source = git_source(
&git,
subdirectory.map(PathBuf::from),
rev,
tag,
branch,
)?;
(source, marker)
}
Source::Url {
url,
subdirectory,
marker,
} => {
if matches!(requirement.version_or_url, Some(VersionOrUrl::Url(_))) {
return Err(LoweringError::ConflictingUrls);
}
let source = url_source(url, subdirectory.map(PathBuf::from))?;
(source, marker)
}
Source::Path {
path,
editable,
marker,
} => {
if matches!(requirement.version_or_url, Some(VersionOrUrl::Url(_))) {
return Err(LoweringError::ConflictingUrls);
}
let source = path_source(
PathBuf::from(path),
Origin::Project,
dir,
dir,
editable.unwrap_or(false),
)?;
(source, marker)
}
Source::Registry { index, marker } => {
let source = registry_source(&requirement, index)?;
(source, marker)
}
Source::Workspace { .. } => {
return Err(LoweringError::WorkspaceMember);
}
Source::CatchAll { .. } => {
// Emit a dedicated error message, which is an improvement over Serde's default
// error.
return Err(LoweringError::InvalidEntry);
}
};
marker.and(requirement.marker.clone());
Ok(Self(Requirement {
name: requirement.name.clone(),
extras: requirement.extras.clone(),
marker,
source,
origin: requirement.origin.clone(),
}))
})
.chain(std::iter::once(Ok(remaining)))
.filter(|requirement| match requirement {
Ok(requirement) => !requirement.0.marker.is_false(),
Err(_) => true,
}),
)
} }
/// Convert back into a [`Requirement`]. /// Convert back into a [`Requirement`].

View File

@ -22,6 +22,8 @@ pub enum MetadataError {
Workspace(#[from] WorkspaceError), Workspace(#[from] WorkspaceError),
#[error("Failed to parse entry for: `{0}`")] #[error("Failed to parse entry for: `{0}`")]
LoweringError(PackageName, #[source] LoweringError), LoweringError(PackageName, #[source] LoweringError),
#[error(transparent)]
Lower(#[from] LoweringError),
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone)]

View File

@ -1,6 +1,6 @@
use crate::metadata::{LoweredRequirement, MetadataError}; use crate::metadata::{LoweredRequirement, MetadataError};
use crate::Metadata; use crate::Metadata;
use pypi_types::Requirement;
use std::collections::BTreeMap; use std::collections::BTreeMap;
use std::path::Path; use std::path::Path;
use uv_configuration::SourceStrategy; use uv_configuration::SourceStrategy;
@ -84,7 +84,7 @@ impl RequiresDist {
.cloned(); .cloned();
let dev_dependencies = match source_strategy { let dev_dependencies = match source_strategy {
SourceStrategy::Enabled => dev_dependencies SourceStrategy::Enabled => dev_dependencies
.map(|requirement| { .flat_map(|requirement| {
let requirement_name = requirement.name.clone(); let requirement_name = requirement.name.clone();
LoweredRequirement::from_requirement( LoweredRequirement::from_requirement(
requirement, requirement,
@ -93,13 +93,17 @@ impl RequiresDist {
sources, sources,
project_workspace.workspace(), project_workspace.workspace(),
) )
.map(LoweredRequirement::into_inner) .map(move |requirement| match requirement {
.map_err(|err| MetadataError::LoweringError(requirement_name.clone(), err)) Ok(requirement) => Ok(requirement.into_inner()),
Err(err) => {
Err(MetadataError::LoweringError(requirement_name.clone(), err))
}
})
}) })
.collect::<Result<Vec<_>, _>>()?, .collect::<Result<Vec<_>, _>>()?,
SourceStrategy::Disabled => dev_dependencies SourceStrategy::Disabled => dev_dependencies
.into_iter() .into_iter()
.map(Requirement::from) .map(pypi_types::Requirement::from)
.collect(), .collect(),
}; };
if dev_dependencies.is_empty() { if dev_dependencies.is_empty() {
@ -112,7 +116,7 @@ impl RequiresDist {
let requires_dist = metadata.requires_dist.into_iter(); let requires_dist = metadata.requires_dist.into_iter();
let requires_dist = match source_strategy { let requires_dist = match source_strategy {
SourceStrategy::Enabled => requires_dist SourceStrategy::Enabled => requires_dist
.map(|requirement| { .flat_map(|requirement| {
let requirement_name = requirement.name.clone(); let requirement_name = requirement.name.clone();
LoweredRequirement::from_requirement( LoweredRequirement::from_requirement(
requirement, requirement,
@ -121,11 +125,18 @@ impl RequiresDist {
sources, sources,
project_workspace.workspace(), project_workspace.workspace(),
) )
.map(LoweredRequirement::into_inner) .map(move |requirement| match requirement {
.map_err(|err| MetadataError::LoweringError(requirement_name.clone(), err)) Ok(requirement) => Ok(requirement.into_inner()),
Err(err) => {
Err(MetadataError::LoweringError(requirement_name.clone(), err))
}
})
}) })
.collect::<Result<_, _>>()?, .collect::<Result<Vec<_>, _>>()?,
SourceStrategy::Disabled => requires_dist.into_iter().map(Requirement::from).collect(), SourceStrategy::Disabled => requires_dist
.into_iter()
.map(pypi_types::Requirement::from)
.collect(),
}; };
Ok(Self { Ok(Self {
@ -253,7 +264,7 @@ mod test {
| |
8 | tqdm = { git = "https://github.com/tqdm/tqdm", ref = "baaaaaab" } 8 | tqdm = { git = "https://github.com/tqdm/tqdm", ref = "baaaaaab" }
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
data did not match any variant of untagged enum Source data did not match any variant of untagged enum SourcesWire
"###); "###);
} }
@ -277,7 +288,7 @@ mod test {
| |
8 | tqdm = { path = "tqdm", index = "torch" } 8 | tqdm = { path = "tqdm", index = "torch" }
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
data did not match any variant of untagged enum Source data did not match any variant of untagged enum SourcesWire
"###); "###);
} }
@ -337,7 +348,7 @@ mod test {
| |
8 | tqdm = { url = "§invalid#+#*Ä" } 8 | tqdm = { url = "§invalid#+#*Ä" }
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^ | ^^^^^^^^^^^^^^^^^^^^^^^^^^^
data did not match any variant of untagged enum Source data did not match any variant of untagged enum SourcesWire
"###); "###);
} }

View File

@ -12,7 +12,7 @@ use pep440_rs::VersionSpecifiers;
use pep508_rs::PackageName; use pep508_rs::PackageName;
use pypi_types::VerbatimParsedUrl; use pypi_types::VerbatimParsedUrl;
use uv_settings::{GlobalOptions, ResolverInstallerOptions}; use uv_settings::{GlobalOptions, ResolverInstallerOptions};
use uv_workspace::pyproject::Source; use uv_workspace::pyproject::Sources;
static FINDER: LazyLock<Finder> = LazyLock::new(|| Finder::new(b"# /// script")); static FINDER: LazyLock<Finder> = LazyLock::new(|| Finder::new(b"# /// script"));
@ -193,7 +193,7 @@ pub struct ToolUv {
pub globals: GlobalOptions, pub globals: GlobalOptions,
#[serde(flatten)] #[serde(flatten)]
pub top_level: ResolverInstallerOptions, pub top_level: ResolverInstallerOptions,
pub sources: Option<BTreeMap<PackageName, Source>>, pub sources: Option<BTreeMap<PackageName, Sources>>,
} }
#[derive(Debug, Error)] #[derive(Debug, Error)]

View File

@ -26,6 +26,8 @@ uv-options-metadata = { workspace = true }
either = { workspace = true } either = { workspace = true }
fs-err = { workspace = true } fs-err = { workspace = true }
glob = { workspace = true } glob = { workspace = true }
itertools = { workspace = true }
owo-colors = { workspace = true }
rustc-hash = { workspace = true } rustc-hash = { workspace = true }
same-file = { workspace = true } same-file = { workspace = true }
schemars = { workspace = true, optional = true } schemars = { workspace = true, optional = true }
@ -36,7 +38,6 @@ toml = { workspace = true }
toml_edit = { workspace = true } toml_edit = { workspace = true }
tracing = { workspace = true } tracing = { workspace = true }
url = { workspace = true } url = { workspace = true }
itertools = { workspace = true }
[dev-dependencies] [dev-dependencies]
anyhow = { workspace = true } anyhow = { workspace = true }

View File

@ -7,6 +7,7 @@
//! Then lowers them into a dependency specification. //! Then lowers them into a dependency specification.
use glob::Pattern; use glob::Pattern;
use owo_colors::OwoColorize;
use serde::{de::IntoDeserializer, Deserialize, Serialize}; use serde::{de::IntoDeserializer, Deserialize, Serialize};
use std::ops::Deref; use std::ops::Deref;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
@ -16,6 +17,7 @@ use thiserror::Error;
use url::Url; use url::Url;
use pep440_rs::{Version, VersionSpecifiers}; use pep440_rs::{Version, VersionSpecifiers};
use pep508_rs::MarkerTree;
use pypi_types::{RequirementSource, SupportedEnvironments, VerbatimParsedUrl}; use pypi_types::{RequirementSource, SupportedEnvironments, VerbatimParsedUrl};
use uv_fs::{relative_to, PortablePathBuf}; use uv_fs::{relative_to, PortablePathBuf};
use uv_git::GitReference; use uv_git::GitReference;
@ -294,17 +296,17 @@ pub struct ToolUv {
#[derive(Default, Debug, Clone, PartialEq, Eq)] #[derive(Default, Debug, Clone, PartialEq, Eq)]
#[cfg_attr(test, derive(Serialize))] #[cfg_attr(test, derive(Serialize))]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub struct ToolUvSources(BTreeMap<PackageName, Source>); pub struct ToolUvSources(BTreeMap<PackageName, Sources>);
impl ToolUvSources { impl ToolUvSources {
/// Returns the underlying `BTreeMap` of package names to sources. /// Returns the underlying `BTreeMap` of package names to sources.
pub fn inner(&self) -> &BTreeMap<PackageName, Source> { pub fn inner(&self) -> &BTreeMap<PackageName, Sources> {
&self.0 &self.0
} }
/// Convert the [`ToolUvSources`] into its inner `BTreeMap`. /// Convert the [`ToolUvSources`] into its inner `BTreeMap`.
#[must_use] #[must_use]
pub fn into_inner(self) -> BTreeMap<PackageName, Source> { pub fn into_inner(self) -> BTreeMap<PackageName, Sources> {
self.0 self.0
} }
} }
@ -329,7 +331,7 @@ impl<'de> serde::de::Deserialize<'de> for ToolUvSources {
M: serde::de::MapAccess<'de>, M: serde::de::MapAccess<'de>,
{ {
let mut sources = BTreeMap::new(); let mut sources = BTreeMap::new();
while let Some((key, value)) = access.next_entry::<PackageName, Source>()? { while let Some((key, value)) = access.next_entry::<PackageName, Sources>()? {
match sources.entry(key) { match sources.entry(key) {
std::collections::btree_map::Entry::Occupied(entry) => { std::collections::btree_map::Entry::Occupied(entry) => {
return Err(serde::de::Error::custom(format!( return Err(serde::de::Error::custom(format!(
@ -407,6 +409,105 @@ impl Deref for SerdePattern {
} }
} }
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[serde(rename_all = "kebab-case", try_from = "SourcesWire")]
pub struct Sources(#[cfg_attr(feature = "schemars", schemars(with = "SourcesWire"))] Vec<Source>);
impl Sources {
/// Return an [`Iterator`] over the sources.
///
/// If the iterator contains multiple entries, they will always use disjoint markers.
///
/// The iterator will contain at most one registry source.
pub fn iter(&self) -> impl Iterator<Item = &Source> {
self.0.iter()
}
/// Returns `true` if the sources list is empty.
pub fn is_empty(&self) -> bool {
self.0.is_empty()
}
/// Returns the number of sources in the list.
pub fn len(&self) -> usize {
self.0.len()
}
}
impl IntoIterator for Sources {
type Item = Source;
type IntoIter = std::vec::IntoIter<Source>;
fn into_iter(self) -> Self::IntoIter {
self.0.into_iter()
}
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
#[serde(rename_all = "kebab-case", untagged)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[allow(clippy::large_enum_variant)]
enum SourcesWire {
One(Source),
Many(Vec<Source>),
}
impl TryFrom<SourcesWire> for Sources {
type Error = SourceError;
fn try_from(wire: SourcesWire) -> Result<Self, Self::Error> {
match wire {
SourcesWire::One(source) => Ok(Self(vec![source])),
SourcesWire::Many(sources) => {
// Ensure that the markers are disjoint.
for (lhs, rhs) in sources
.iter()
.map(Source::marker)
.zip(sources.iter().skip(1).map(Source::marker))
{
if !lhs.is_disjoint(&rhs) {
let mut hint = lhs.negate();
hint.and(rhs.clone());
let lhs = lhs
.contents()
.map(|contents| contents.to_string())
.unwrap_or_else(|| "true".to_string());
let rhs = rhs
.contents()
.map(|contents| contents.to_string())
.unwrap_or_else(|| "true".to_string());
let hint = hint
.contents()
.map(|contents| contents.to_string())
.unwrap_or_else(|| "true".to_string());
return Err(SourceError::OverlappingMarkers(lhs, rhs, hint));
}
}
// Ensure that there is at least one source.
if sources.is_empty() {
return Err(SourceError::EmptySources);
}
// Ensure that there is at most one registry source.
if sources
.iter()
.filter(|source| matches!(source, Source::Registry { .. }))
.nth(1)
.is_some()
{
return Err(SourceError::MultipleIndexes);
}
Ok(Self(sources))
}
}
}
}
/// A `tool.uv.sources` value. /// A `tool.uv.sources` value.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
@ -427,6 +528,12 @@ pub enum Source {
rev: Option<String>, rev: Option<String>,
tag: Option<String>, tag: Option<String>,
branch: Option<String>, branch: Option<String>,
#[serde(
skip_serializing_if = "pep508_rs::marker::ser::is_empty",
serialize_with = "pep508_rs::marker::ser::serialize",
default
)]
marker: MarkerTree,
}, },
/// A remote `http://` or `https://` URL, either a wheel (`.whl`) or a source distribution /// A remote `http://` or `https://` URL, either a wheel (`.whl`) or a source distribution
/// (`.zip`, `.tar.gz`). /// (`.zip`, `.tar.gz`).
@ -440,6 +547,12 @@ pub enum Source {
/// For source distributions, the path to the directory with the `pyproject.toml`, if it's /// For source distributions, the path to the directory with the `pyproject.toml`, if it's
/// not in the archive root. /// not in the archive root.
subdirectory: Option<PortablePathBuf>, subdirectory: Option<PortablePathBuf>,
#[serde(
skip_serializing_if = "pep508_rs::marker::ser::is_empty",
serialize_with = "pep508_rs::marker::ser::serialize",
default
)]
marker: MarkerTree,
}, },
/// The path to a dependency, either a wheel (a `.whl` file), source distribution (a `.zip` or /// The path to a dependency, either a wheel (a `.whl` file), source distribution (a `.zip` or
/// `.tar.gz` file), or source tree (i.e., a directory containing a `pyproject.toml` or /// `.tar.gz` file), or source tree (i.e., a directory containing a `pyproject.toml` or
@ -448,17 +561,35 @@ pub enum Source {
path: PortablePathBuf, path: PortablePathBuf,
/// `false` by default. /// `false` by default.
editable: Option<bool>, editable: Option<bool>,
#[serde(
skip_serializing_if = "pep508_rs::marker::ser::is_empty",
serialize_with = "pep508_rs::marker::ser::serialize",
default
)]
marker: MarkerTree,
}, },
/// A dependency pinned to a specific index, e.g., `torch` after setting `torch` to `https://download.pytorch.org/whl/cu118`. /// A dependency pinned to a specific index, e.g., `torch` after setting `torch` to `https://download.pytorch.org/whl/cu118`.
Registry { Registry {
// TODO(konstin): The string is more-or-less a placeholder // TODO(konstin): The string is more-or-less a placeholder
index: String, index: String,
#[serde(
skip_serializing_if = "pep508_rs::marker::ser::is_empty",
serialize_with = "pep508_rs::marker::ser::serialize",
default
)]
marker: MarkerTree,
}, },
/// A dependency on another package in the workspace. /// A dependency on another package in the workspace.
Workspace { Workspace {
/// When set to `false`, the package will be fetched from the remote index, rather than /// When set to `false`, the package will be fetched from the remote index, rather than
/// included as a workspace package. /// included as a workspace package.
workspace: bool, workspace: bool,
#[serde(
skip_serializing_if = "pep508_rs::marker::ser::is_empty",
serialize_with = "pep508_rs::marker::ser::serialize",
default
)]
marker: MarkerTree,
}, },
/// A catch-all variant used to emit precise error messages when deserializing. /// A catch-all variant used to emit precise error messages when deserializing.
CatchAll { CatchAll {
@ -471,6 +602,12 @@ pub enum Source {
path: PortablePathBuf, path: PortablePathBuf,
index: String, index: String,
workspace: bool, workspace: bool,
#[serde(
skip_serializing_if = "pep508_rs::marker::ser::is_empty",
serialize_with = "pep508_rs::marker::ser::serialize",
default
)]
marker: MarkerTree,
}, },
} }
@ -494,6 +631,12 @@ pub enum SourceError {
Absolute(#[from] std::io::Error), Absolute(#[from] std::io::Error),
#[error("Path contains invalid characters: `{}`", _0.display())] #[error("Path contains invalid characters: `{}`", _0.display())]
NonUtf8Path(PathBuf), NonUtf8Path(PathBuf),
#[error("Source markers must be disjoint, but the following markers overlap: `{0}` and `{1}`.\n\n{hint}{colon} replace `{1}` with `{2}`.", hint = "hint".bold().cyan(), colon = ":".bold())]
OverlappingMarkers(String, String, String),
#[error("Must provide at least one source")]
EmptySources,
#[error("Sources can only include a single index source")]
MultipleIndexes,
} }
impl Source { impl Source {
@ -524,7 +667,10 @@ impl Source {
if workspace { if workspace {
return match source { return match source {
RequirementSource::Registry { .. } | RequirementSource::Directory { .. } => { RequirementSource::Registry { .. } | RequirementSource::Directory { .. } => {
Ok(Some(Source::Workspace { workspace: true })) Ok(Some(Source::Workspace {
workspace: true,
marker: MarkerTree::TRUE,
}))
} }
RequirementSource::Url { .. } => { RequirementSource::Url { .. } => {
Err(SourceError::WorkspacePackageUrl(name.to_string())) Err(SourceError::WorkspacePackageUrl(name.to_string()))
@ -548,12 +694,14 @@ impl Source {
.or_else(|_| std::path::absolute(&install_path)) .or_else(|_| std::path::absolute(&install_path))
.map_err(SourceError::Absolute)?, .map_err(SourceError::Absolute)?,
), ),
marker: MarkerTree::TRUE,
}, },
RequirementSource::Url { RequirementSource::Url {
subdirectory, url, .. subdirectory, url, ..
} => Source::Url { } => Source::Url {
url: url.to_url(), url: url.to_url(),
subdirectory: subdirectory.map(PortablePathBuf::from), subdirectory: subdirectory.map(PortablePathBuf::from),
marker: MarkerTree::TRUE,
}, },
RequirementSource::Git { RequirementSource::Git {
repository, repository,
@ -578,6 +726,7 @@ impl Source {
branch, branch,
git: repository, git: repository,
subdirectory: subdirectory.map(PortablePathBuf::from), subdirectory: subdirectory.map(PortablePathBuf::from),
marker: MarkerTree::TRUE,
} }
} else { } else {
Source::Git { Source::Git {
@ -586,6 +735,7 @@ impl Source {
branch, branch,
git: repository, git: repository,
subdirectory: subdirectory.map(PortablePathBuf::from), subdirectory: subdirectory.map(PortablePathBuf::from),
marker: MarkerTree::TRUE,
} }
} }
} }
@ -593,6 +743,18 @@ impl Source {
Ok(Some(source)) Ok(Some(source))
} }
/// Return the [`MarkerTree`] for the source.
pub fn marker(&self) -> MarkerTree {
match self {
Source::Git { marker, .. } => marker.clone(),
Source::Url { marker, .. } => marker.clone(),
Source::Path { marker, .. } => marker.clone(),
Source::Registry { marker, .. } => marker.clone(),
Source::Workspace { marker, .. } => marker.clone(),
Source::CatchAll { marker, .. } => marker.clone(),
}
}
} }
/// The type of a dependency in a `pyproject.toml`. /// The type of a dependency in a `pyproject.toml`.

View File

@ -14,7 +14,7 @@ use uv_normalize::{GroupName, PackageName, DEV_DEPENDENCIES};
use uv_warnings::{warn_user, warn_user_once}; use uv_warnings::{warn_user, warn_user_once};
use crate::pyproject::{ use crate::pyproject::{
Project, PyProjectToml, PyprojectTomlError, Source, ToolUvSources, ToolUvWorkspace, Project, PyProjectToml, PyprojectTomlError, Source, Sources, ToolUvSources, ToolUvWorkspace,
}; };
#[derive(thiserror::Error, Debug)] #[derive(thiserror::Error, Debug)]
@ -78,7 +78,7 @@ pub struct Workspace {
/// The sources table from the workspace `pyproject.toml`. /// The sources table from the workspace `pyproject.toml`.
/// ///
/// This table is overridden by the project sources. /// This table is overridden by the project sources.
sources: BTreeMap<PackageName, Source>, sources: BTreeMap<PackageName, Sources>,
/// The `pyproject.toml` of the workspace root. /// The `pyproject.toml` of the workspace root.
pyproject_toml: PyProjectToml, pyproject_toml: PyProjectToml,
} }
@ -517,7 +517,7 @@ impl Workspace {
} }
/// The sources table from the workspace `pyproject.toml`. /// The sources table from the workspace `pyproject.toml`.
pub fn sources(&self) -> &BTreeMap<PackageName, Source> { pub fn sources(&self) -> &BTreeMap<PackageName, Sources> {
&self.sources &self.sources
} }
@ -531,7 +531,7 @@ impl Workspace {
.as_ref() .as_ref()
.and_then(|uv| uv.sources.as_ref()) .and_then(|uv| uv.sources.as_ref())
.map(ToolUvSources::inner) .map(ToolUvSources::inner)
.map(|sources| sources.values()) .map(|sources| sources.values().flat_map(Sources::iter))
}) })
}) })
.flatten() .flatten()
@ -1755,9 +1755,11 @@ mod tests {
} }
}, },
"sources": { "sources": {
"bird-feeder": { "bird-feeder": [
"workspace": true {
} "workspace": true
}
]
}, },
"pyproject_toml": { "pyproject_toml": {
"project": { "project": {
@ -1773,9 +1775,11 @@ mod tests {
"tool": { "tool": {
"uv": { "uv": {
"sources": { "sources": {
"bird-feeder": { "bird-feeder": [
"workspace": true {
} "workspace": true
}
]
}, },
"workspace": { "workspace": {
"members": [ "members": [

View File

@ -453,6 +453,7 @@ pub(crate) async fn add(
rev, rev,
tag, tag,
branch, branch,
marker,
}) => { }) => {
let credentials = Credentials::from_url(&git); let credentials = Credentials::from_url(&git);
if let Some(credentials) = credentials { if let Some(credentials) = credentials {
@ -468,6 +469,7 @@ pub(crate) async fn add(
rev, rev,
tag, tag,
branch, branch,
marker,
}) })
} }
_ => source, _ => source,

View File

@ -293,15 +293,15 @@ async fn do_lock(
let lhs = lhs let lhs = lhs
.contents() .contents()
.map(|contents| contents.to_string()) .map(|contents| contents.to_string())
.unwrap_or("true".to_string()); .unwrap_or_else(|| "true".to_string());
let rhs = rhs let rhs = rhs
.contents() .contents()
.map(|contents| contents.to_string()) .map(|contents| contents.to_string())
.unwrap_or("true".to_string()); .unwrap_or_else(|| "true".to_string());
let hint = hint let hint = hint
.contents() .contents()
.map(|contents| contents.to_string()) .map(|contents| contents.to_string())
.unwrap_or("true".to_string()); .unwrap_or_else(|| "true".to_string());
return Err(ProjectError::OverlappingMarkers(lhs, rhs, hint)); return Err(ProjectError::OverlappingMarkers(lhs, rhs, hint));
} }

View File

@ -196,13 +196,13 @@ pub(crate) async fn run(
let requirements = dependencies let requirements = dependencies
.into_iter() .into_iter()
.map(|requirement| { .flat_map(|requirement| {
LoweredRequirement::from_non_workspace_requirement( LoweredRequirement::from_non_workspace_requirement(
requirement, requirement,
script_dir, script_dir,
script_sources, script_sources,
) )
.map(LoweredRequirement::into_inner) .map_ok(uv_distribution::LoweredRequirement::into_inner)
}) })
.collect::<Result<_, _>>()?; .collect::<Result<_, _>>()?;
let spec = RequirementsSpecification::from_requirements(requirements); let spec = RequirementsSpecification::from_requirements(requirements);

View File

@ -28,7 +28,7 @@ use uv_python::{PythonDownloads, PythonEnvironment, PythonPreference, PythonRequ
use uv_resolver::{FlatIndex, Lock}; use uv_resolver::{FlatIndex, Lock};
use uv_types::{BuildIsolation, HashStrategy}; use uv_types::{BuildIsolation, HashStrategy};
use uv_warnings::warn_user; use uv_warnings::warn_user;
use uv_workspace::pyproject::{Source, ToolUvSources}; use uv_workspace::pyproject::{Source, Sources, ToolUvSources};
use uv_workspace::{DiscoveryOptions, InstallTarget, MemberDiscovery, VirtualProject, Workspace}; use uv_workspace::{DiscoveryOptions, InstallTarget, MemberDiscovery, VirtualProject, Workspace};
/// Sync the project environment. /// Sync the project environment.
@ -425,7 +425,7 @@ fn store_credentials_from_workspace(workspace: &Workspace) {
.and_then(|uv| uv.sources.as_ref()) .and_then(|uv| uv.sources.as_ref())
.map(ToolUvSources::inner) .map(ToolUvSources::inner)
.iter() .iter()
.flat_map(|sources| sources.values()) .flat_map(|sources| sources.values().flat_map(Sources::iter))
{ {
match source { match source {
Source::Git { git, .. } => { Source::Git { git, .. } => {

View File

@ -13323,3 +13323,468 @@ fn lock_change_requires_python() -> Result<()> {
Ok(()) Ok(())
} }
#[test]
fn lock_multiple_sources() -> Result<()> {
let context = TestContext::new("3.12");
let pyproject_toml = context.temp_dir.child("pyproject.toml");
pyproject_toml.write_str(
r#"
[project]
name = "project"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = ["iniconfig"]
[tool.uv.sources]
iniconfig = [
{ url = "https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl", marker = "sys_platform != 'win32'" },
{ url = "https://files.pythonhosted.org/packages/d7/4b/cbd8e699e64a6f16ca3a8220661b5f83792b3017d0f79807cb8708d33913/iniconfig-2.0.0.tar.gz", marker = "sys_platform == 'win32'" },
]
"#,
)?;
uv_snapshot!(context.filters(), context.lock(), @r###"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Resolved 3 packages in [TIME]
"###);
let lock = fs_err::read_to_string(context.temp_dir.join("uv.lock")).unwrap();
insta::with_settings!({
filters => context.filters(),
}, {
assert_snapshot!(
lock, @r###"
version = 1
requires-python = ">=3.12"
resolution-markers = [
"sys_platform != 'win32'",
"sys_platform == 'win32'",
]
[options]
exclude-newer = "2024-03-25T00:00:00Z"
[[package]]
name = "iniconfig"
version = "2.0.0"
source = { url = "https://files.pythonhosted.org/packages/d7/4b/cbd8e699e64a6f16ca3a8220661b5f83792b3017d0f79807cb8708d33913/iniconfig-2.0.0.tar.gz" }
resolution-markers = [
"sys_platform == 'win32'",
]
sdist = { url = "https://files.pythonhosted.org/packages/d7/4b/cbd8e699e64a6f16ca3a8220661b5f83792b3017d0f79807cb8708d33913/iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3" }
[[package]]
name = "iniconfig"
version = "2.0.0"
source = { url = "https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl" }
resolution-markers = [
"sys_platform != 'win32'",
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374" },
]
[[package]]
name = "project"
version = "0.1.0"
source = { virtual = "." }
dependencies = [
{ name = "iniconfig", version = "2.0.0", source = { url = "https://files.pythonhosted.org/packages/d7/4b/cbd8e699e64a6f16ca3a8220661b5f83792b3017d0f79807cb8708d33913/iniconfig-2.0.0.tar.gz" }, marker = "sys_platform == 'win32'" },
{ name = "iniconfig", version = "2.0.0", source = { url = "https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl" }, marker = "sys_platform != 'win32'" },
]
[package.metadata]
requires-dist = [
{ name = "iniconfig", marker = "sys_platform != 'win32'", url = "https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl" },
{ name = "iniconfig", marker = "sys_platform == 'win32'", url = "https://files.pythonhosted.org/packages/d7/4b/cbd8e699e64a6f16ca3a8220661b5f83792b3017d0f79807cb8708d33913/iniconfig-2.0.0.tar.gz" },
]
"###
);
});
// Re-run with `--locked`.
uv_snapshot!(context.filters(), context.lock().arg("--locked"), @r###"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Resolved 3 packages in [TIME]
"###);
Ok(())
}
#[test]
fn lock_multiple_sources_conflict() -> Result<()> {
let context = TestContext::new("3.12");
let pyproject_toml = context.temp_dir.child("pyproject.toml");
pyproject_toml.write_str(
r#"
[project]
name = "project"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = ["iniconfig"]
[tool.uv.sources]
iniconfig = [
{ url = "https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl", marker = "sys_platform == 'win32' and python_version == '3.12'" },
{ url = "https://files.pythonhosted.org/packages/d7/4b/cbd8e699e64a6f16ca3a8220661b5f83792b3017d0f79807cb8708d33913/iniconfig-2.0.0.tar.gz", marker = "sys_platform == 'win32'" },
]
"#,
)?;
uv_snapshot!(context.filters(), context.lock(), @r###"
success: false
exit_code: 2
----- stdout -----
----- stderr -----
error: Failed to parse: `pyproject.toml`
Caused by: TOML parse error at line 9, column 21
|
9 | iniconfig = [
| ^
Source markers must be disjoint, but the following markers overlap: `python_full_version == '3.12.*' and sys_platform == 'win32'` and `sys_platform == 'win32'`.
hint: replace `sys_platform == 'win32'` with `python_full_version != '3.12.*' and sys_platform == 'win32'`.
"###);
Ok(())
}
/// Multiple `index` entries is not yet supported.
#[test]
fn lock_multiple_sources_index() -> Result<()> {
let context = TestContext::new("3.12");
let pyproject_toml = context.temp_dir.child("pyproject.toml");
pyproject_toml.write_str(
r#"
[project]
name = "project"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = ["iniconfig"]
[tool.uv.sources]
iniconfig = [
{ index = "pytorch", marker = "sys_platform != 'win32'" },
{ index = "internal", marker = "sys_platform == 'win32'" },
]
"#,
)?;
uv_snapshot!(context.filters(), context.lock(), @r###"
success: false
exit_code: 2
----- stdout -----
----- stderr -----
error: Failed to parse: `pyproject.toml`
Caused by: TOML parse error at line 9, column 21
|
9 | iniconfig = [
| ^
Sources can only include a single index source
"###);
Ok(())
}
#[test]
fn lock_multiple_sources_non_total() -> Result<()> {
let context = TestContext::new("3.12");
let pyproject_toml = context.temp_dir.child("pyproject.toml");
pyproject_toml.write_str(
r#"
[project]
name = "project"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = ["iniconfig"]
[tool.uv.sources]
iniconfig = [
{ url = "https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl", marker = "sys_platform == 'darwin'" },
]
"#,
)?;
uv_snapshot!(context.filters(), context.lock(), @r###"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Resolved 3 packages in [TIME]
"###);
let lock = fs_err::read_to_string(context.temp_dir.join("uv.lock")).unwrap();
insta::with_settings!({
filters => context.filters(),
}, {
assert_snapshot!(
lock, @r###"
version = 1
requires-python = ">=3.12"
resolution-markers = [
"sys_platform == 'darwin'",
"sys_platform != 'darwin'",
]
[options]
exclude-newer = "2024-03-25T00:00:00Z"
[[package]]
name = "iniconfig"
version = "2.0.0"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
"sys_platform != 'darwin'",
]
sdist = { url = "https://files.pythonhosted.org/packages/d7/4b/cbd8e699e64a6f16ca3a8220661b5f83792b3017d0f79807cb8708d33913/iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3", size = 4646 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374", size = 5892 },
]
[[package]]
name = "iniconfig"
version = "2.0.0"
source = { url = "https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl" }
resolution-markers = [
"sys_platform == 'darwin'",
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374" },
]
[[package]]
name = "project"
version = "0.1.0"
source = { virtual = "." }
dependencies = [
{ name = "iniconfig", version = "2.0.0", source = { registry = "https://pypi.org/simple" }, marker = "sys_platform != 'darwin'" },
{ name = "iniconfig", version = "2.0.0", source = { url = "https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl" }, marker = "sys_platform == 'darwin'" },
]
[package.metadata]
requires-dist = [
{ name = "iniconfig", marker = "sys_platform != 'darwin'" },
{ name = "iniconfig", marker = "sys_platform == 'darwin'", url = "https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl" },
]
"###
);
});
// Re-run with `--locked`.
uv_snapshot!(context.filters(), context.lock().arg("--locked"), @r###"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Resolved 3 packages in [TIME]
"###);
Ok(())
}
#[test]
fn lock_multiple_sources_respect_marker() -> Result<()> {
let context = TestContext::new("3.12");
let pyproject_toml = context.temp_dir.child("pyproject.toml");
pyproject_toml.write_str(
r#"
[project]
name = "project"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = ["iniconfig ; platform_system == 'Windows'"]
[tool.uv.sources]
iniconfig = [
{ url = "https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl", marker = "sys_platform == 'darwin'" },
]
"#,
)?;
uv_snapshot!(context.filters(), context.lock(), @r###"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Resolved 3 packages in [TIME]
"###);
let lock = fs_err::read_to_string(context.temp_dir.join("uv.lock")).unwrap();
insta::with_settings!({
filters => context.filters(),
}, {
assert_snapshot!(
lock, @r###"
version = 1
requires-python = ">=3.12"
resolution-markers = [
"platform_system == 'Windows' and sys_platform == 'darwin'",
"platform_system == 'Windows' and sys_platform != 'darwin'",
"platform_system != 'Windows'",
]
[options]
exclude-newer = "2024-03-25T00:00:00Z"
[[package]]
name = "iniconfig"
version = "2.0.0"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
"platform_system == 'Windows' and sys_platform != 'darwin'",
]
sdist = { url = "https://files.pythonhosted.org/packages/d7/4b/cbd8e699e64a6f16ca3a8220661b5f83792b3017d0f79807cb8708d33913/iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3", size = 4646 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374", size = 5892 },
]
[[package]]
name = "iniconfig"
version = "2.0.0"
source = { url = "https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl" }
resolution-markers = [
"platform_system == 'Windows' and sys_platform == 'darwin'",
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374" },
]
[[package]]
name = "project"
version = "0.1.0"
source = { virtual = "." }
dependencies = [
{ name = "iniconfig", version = "2.0.0", source = { registry = "https://pypi.org/simple" }, marker = "platform_system == 'Windows' and sys_platform != 'darwin'" },
{ name = "iniconfig", version = "2.0.0", source = { url = "https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl" }, marker = "platform_system == 'Windows' and sys_platform == 'darwin'" },
]
[package.metadata]
requires-dist = [
{ name = "iniconfig", marker = "platform_system == 'Windows' and sys_platform != 'darwin'" },
{ name = "iniconfig", marker = "platform_system == 'Windows' and sys_platform == 'darwin'", url = "https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl" },
]
"###
);
});
// Re-run with `--locked`.
uv_snapshot!(context.filters(), context.lock().arg("--locked"), @r###"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Resolved 3 packages in [TIME]
"###);
Ok(())
}
#[test]
fn lock_multiple_sources_extra() -> Result<()> {
let context = TestContext::new("3.12");
let pyproject_toml = context.temp_dir.child("pyproject.toml");
pyproject_toml.write_str(
r#"
[project]
name = "project"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = ["iniconfig"]
[project.optional-dependencies]
cpu = []
[tool.uv.sources]
iniconfig = [
{ url = "https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl", marker = "extra == 'cpu'" },
]
"#,
)?;
uv_snapshot!(context.filters(), context.lock(), @r###"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Resolved 2 packages in [TIME]
"###);
let lock = fs_err::read_to_string(context.temp_dir.join("uv.lock")).unwrap();
insta::with_settings!({
filters => context.filters(),
}, {
assert_snapshot!(
lock, @r###"
version = 1
requires-python = ">=3.12"
[options]
exclude-newer = "2024-03-25T00:00:00Z"
[[package]]
name = "iniconfig"
version = "2.0.0"
source = { url = "https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374" },
]
[[package]]
name = "project"
version = "0.1.0"
source = { virtual = "." }
[package.optional-dependencies]
cpu = [
{ name = "iniconfig" },
]
[package.metadata]
requires-dist = [
{ name = "iniconfig", marker = "extra == 'cpu'", url = "https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl" },
{ name = "iniconfig", marker = "extra != 'cpu'" },
]
"###
);
});
// Re-run with `--locked`.
uv_snapshot!(context.filters(), context.lock().arg("--locked"), @r###"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Resolved 2 packages in [TIME]
"###);
Ok(())
}

View File

@ -223,6 +223,46 @@ members = [
] ]
``` ```
### Platform-specific sources
You can limit a source to a given platform or Python version by providing
[PEP 508](https://peps.python.org/pep-0508/#environment-markers)-compatible environment markers for
the source.
For example, to pull `httpx` from GitHub, but only on macOS, use the following:
```toml title="pyproject.toml"
[project]
dependencies = [
"httpx",
]
[tool.uv.sources]
httpx = { git = "https://github.com/encode/httpx", tag = "0.27.2", marker = "sys_platform == 'darwin'" }
```
By specifying the marker on the source, uv will still include `httpx` on all platforms, but will
download the source from GitHub on macOS, and fall back to PyPI on all other platforms.
### Multiple sources
You can specify multiple sources for a single dependency by providing a list of sources,
disambiguated by [PEP 508](https://peps.python.org/pep-0508/#environment-markers)-compatible
environment markers. For example, to pull in different `httpx` commits on macOS vs. Linux:
```toml title="pyproject.toml"
[project]
dependencies = [
"httpx",
]
[tool.uv.sources]
httpx = [
{ git = "https://github.com/encode/httpx", tag = "0.27.2", marker = "sys_platform == 'darwin'" },
{ git = "https://github.com/encode/httpx", tag = "0.24.1", marker = "sys_platform == 'linux'" },
]
```
## Optional dependencies ## Optional dependencies
It is common for projects that are published as libraries to make some features optional to reduce It is common for projects that are published as libraries to make some features optional to reduce

40
uv.schema.json generated
View File

@ -625,6 +625,10 @@
} }
] ]
}, },
"MarkerTree": {
"description": "A PEP 508-compliant marker expression, e.g., `sys_platform == 'Darwin'`",
"type": "string"
},
"PackageName": { "PackageName": {
"description": "The normalized name of a package.\n\nConverts the name to lowercase and collapses runs of `-`, `_`, and `.` down to a single `-`. For example, `---`, `.`, and `__` are all converted to a single `-`.\n\nSee: <https://packaging.python.org/en/latest/specifications/name-normalization/>", "description": "The normalized name of a package.\n\nConverts the name to lowercase and collapses runs of `-`, `_`, and `.` down to a single `-`. For example, `---`, `.`, and `__` are all converted to a single `-`.\n\nSee: <https://packaging.python.org/en/latest/specifications/name-normalization/>",
"type": "string" "type": "string"
@ -1240,6 +1244,9 @@
"type": "string", "type": "string",
"format": "uri" "format": "uri"
}, },
"marker": {
"$ref": "#/definitions/MarkerTree"
},
"rev": { "rev": {
"type": [ "type": [
"string", "string",
@ -1273,6 +1280,9 @@
"url" "url"
], ],
"properties": { "properties": {
"marker": {
"$ref": "#/definitions/MarkerTree"
},
"subdirectory": { "subdirectory": {
"description": "For source distributions, the path to the directory with the `pyproject.toml`, if it's not in the archive root.", "description": "For source distributions, the path to the directory with the `pyproject.toml`, if it's not in the archive root.",
"anyOf": [ "anyOf": [
@ -1305,6 +1315,9 @@
"null" "null"
] ]
}, },
"marker": {
"$ref": "#/definitions/MarkerTree"
},
"path": { "path": {
"$ref": "#/definitions/String" "$ref": "#/definitions/String"
} }
@ -1320,6 +1333,9 @@
"properties": { "properties": {
"index": { "index": {
"type": "string" "type": "string"
},
"marker": {
"$ref": "#/definitions/MarkerTree"
} }
}, },
"additionalProperties": false "additionalProperties": false
@ -1331,6 +1347,9 @@
"workspace" "workspace"
], ],
"properties": { "properties": {
"marker": {
"$ref": "#/definitions/MarkerTree"
},
"workspace": { "workspace": {
"description": "When set to `false`, the package will be fetched from the remote index, rather than included as a workspace package.", "description": "When set to `false`, the package will be fetched from the remote index, rather than included as a workspace package.",
"type": "boolean" "type": "boolean"
@ -1361,6 +1380,9 @@
"index": { "index": {
"type": "string" "type": "string"
}, },
"marker": {
"$ref": "#/definitions/MarkerTree"
},
"path": { "path": {
"$ref": "#/definitions/String" "$ref": "#/definitions/String"
}, },
@ -1397,6 +1419,22 @@
} }
] ]
}, },
"Sources": {
"$ref": "#/definitions/SourcesWire"
},
"SourcesWire": {
"anyOf": [
{
"$ref": "#/definitions/Source"
},
{
"type": "array",
"items": {
"$ref": "#/definitions/Source"
}
}
]
},
"StaticMetadata": { "StaticMetadata": {
"description": "A subset of the Python Package Metadata 2.3 standard as specified in <https://packaging.python.org/specifications/core-metadata/>.", "description": "A subset of the Python Package Metadata 2.3 standard as specified in <https://packaging.python.org/specifications/core-metadata/>.",
"type": "object", "type": "object",
@ -1565,7 +1603,7 @@
"ToolUvSources": { "ToolUvSources": {
"type": "object", "type": "object",
"additionalProperties": { "additionalProperties": {
"$ref": "#/definitions/Source" "$ref": "#/definitions/Sources"
} }
}, },
"ToolUvWorkspace": { "ToolUvWorkspace": {