diff --git a/Cargo.lock b/Cargo.lock index 9c8b78e75eed2..221eb1fafc9bd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5334,6 +5334,7 @@ dependencies = [ "glob", "insta", "itertools 0.13.0", + "owo-colors", "pep440_rs", "pep508_rs", "pypi-types", diff --git a/crates/pep508-rs/src/marker/tree.rs b/crates/pep508-rs/src/marker/tree.rs index 550c85acb0c09..bb54c64a6d9f6 100644 --- a/crates/pep508-rs/src/marker/tree.rs +++ b/crates/pep508-rs/src/marker/tree.rs @@ -5,12 +5,11 @@ use std::ops::{Bound, Deref}; use std::str::FromStr; use itertools::Itertools; +use pep440_rs::{Version, VersionParseError, VersionSpecifier}; use pubgrub::Range; #[cfg(feature = "pyo3")] use pyo3::{basic::CompareOp, pyclass, pymethods}; use serde::{de, Deserialize, Deserializer, Serialize, Serializer}; - -use pep440_rs::{Version, VersionParseError, VersionSpecifier}; use uv_normalize::ExtraName; use crate::cursor::Cursor; @@ -1631,6 +1630,20 @@ impl Serialize for MarkerTreeContents { } } +impl<'de> serde::Deserialize<'de> for MarkerTreeContents { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let s = String::deserialize(deserializer)?; + let marker = MarkerTree::from_str(&s).map_err(de::Error::custom)?; + let marker = marker + .contents() + .ok_or_else(|| de::Error::custom("expected at least one marker expression"))?; + Ok(marker) + } +} + impl Display for MarkerTreeContents { fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { // Normalize all `false` expressions to the same trivially false expression. @@ -1667,6 +1680,28 @@ impl Display for MarkerTreeContents { } } +#[cfg(feature = "schemars")] +impl schemars::JsonSchema for MarkerTreeContents { + fn schema_name() -> String { + "MarkerTreeContents".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)] mod test { use std::ops::Bound; diff --git a/crates/uv-distribution/src/metadata/lowering.rs b/crates/uv-distribution/src/metadata/lowering.rs index a7f581ec0a20b..7e949ec8b652c 100644 --- a/crates/uv-distribution/src/metadata/lowering.rs +++ b/crates/uv-distribution/src/metadata/lowering.rs @@ -7,7 +7,7 @@ use url::Url; use distribution_filename::DistExtension; use pep440_rs::VersionSpecifiers; -use pep508_rs::{VerbatimUrl, VersionOrUrl}; +use pep508_rs::{MarkerTree, VerbatimUrl, VersionOrUrl}; use pypi_types::{ParsedUrlError, Requirement, RequirementSource, VerbatimParsedUrl}; use uv_git::GitReference; use uv_normalize::PackageName; @@ -48,8 +48,8 @@ impl LoweredRequirement { // We require that when you use a package that's part of the workspace, ... !workspace.packages().contains_key(&requirement.name) // ... it must be declared as a workspace dependency (`workspace = true`), ... - || source.as_ref().is_some_and(|source| source.inner().iter().all(|source| { - matches!(source, Source::Workspace { workspace: true }) + || source.as_ref().is_some_and(|source| source.iter().all(|source| { + matches!(source, Source::Workspace { workspace: true, .. }) })) // ... except for recursive self-inclusion (extras that activate other extras), e.g. // `framework[machine_learning]` depends on `framework[cuda]`. @@ -75,41 +75,62 @@ impl LoweredRequirement { return Either::Left(std::iter::once(Ok(Self(Requirement::from(requirement))))); }; - Either::Right(source.into_inner().into_iter().map(move |source| { - let source = match source { + Either::Right(source.into_iter().map(move |source| { + 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); } - git_source(&git, subdirectory.map(PathBuf::from), rev, tag, branch)? + let source = + git_source(&git, subdirectory.map(PathBuf::from), rev, tag, branch)?; + let marker = marker.map(MarkerTree::from).unwrap_or_default(); + (source, marker) } - Source::Url { url, subdirectory } => { + Source::Url { + url, + subdirectory, + marker, + } => { if matches!(requirement.version_or_url, Some(VersionOrUrl::Url(_))) { return Err(LoweringError::ConflictingUrls); } - url_source(url, subdirectory.map(PathBuf::from))? + let source = url_source(url, subdirectory.map(PathBuf::from))?; + let marker = marker.map(MarkerTree::from).unwrap_or_default(); + (source, marker) } - Source::Path { path, editable } => { + Source::Path { + path, + editable, + marker, + } => { if matches!(requirement.version_or_url, Some(VersionOrUrl::Url(_))) { return Err(LoweringError::ConflictingUrls); } - path_source( + let source = path_source( PathBuf::from(path), origin, project_dir, workspace.install_path(), editable.unwrap_or(false), - )? + )?; + let marker = marker.map(MarkerTree::from).unwrap_or_default(); + (source, marker) + } + Source::Registry { index, marker } => { + let source = registry_source(&requirement, index)?; + let marker = marker.map(MarkerTree::from).unwrap_or_default(); + (source, marker) } - Source::Registry { index } => registry_source(&requirement, index)?, Source::Workspace { workspace: is_workspace, + marker, } => { if !is_workspace { return Err(LoweringError::WorkspaceFalse); @@ -148,7 +169,7 @@ impl LoweredRequirement { )) })?; - if member.pyproject_toml().is_package() { + let source = if member.pyproject_toml().is_package() { RequirementSource::Directory { install_path, url, @@ -162,17 +183,22 @@ impl LoweredRequirement { editable: false, r#virtual: true, } - } + }; + let marker = marker.map(MarkerTree::from).unwrap_or_default(); + (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: requirement.marker.clone(), + marker, source, origin: requirement.origin.clone(), })) @@ -192,39 +218,59 @@ impl LoweredRequirement { return Either::Left(std::iter::once(Ok(Self(Requirement::from(requirement))))); }; - Either::Right(source.into_inner().into_iter().map(move |source| { - let source = match source { + Either::Right(source.into_iter().map(move |source| { + 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); } - git_source(&git, subdirectory.map(PathBuf::from), rev, tag, branch)? + let source = + git_source(&git, subdirectory.map(PathBuf::from), rev, tag, branch)?; + let marker = marker.map(MarkerTree::from).unwrap_or_default(); + (source, marker) } - Source::Url { url, subdirectory } => { + Source::Url { + url, + subdirectory, + marker, + } => { if matches!(requirement.version_or_url, Some(VersionOrUrl::Url(_))) { return Err(LoweringError::ConflictingUrls); } - url_source(url, subdirectory.map(PathBuf::from))? + let source = url_source(url, subdirectory.map(PathBuf::from))?; + let marker = marker.map(MarkerTree::from).unwrap_or_default(); + (source, marker) } - Source::Path { path, editable } => { + Source::Path { + path, + editable, + marker, + } => { if matches!(requirement.version_or_url, Some(VersionOrUrl::Url(_))) { return Err(LoweringError::ConflictingUrls); } - path_source( + let source = path_source( PathBuf::from(path), Origin::Project, dir, dir, editable.unwrap_or(false), - )? + )?; + let marker = marker.map(MarkerTree::from).unwrap_or_default(); + (source, marker) + } + Source::Registry { index, marker } => { + let source = registry_source(&requirement, index)?; + let marker = marker.map(MarkerTree::from).unwrap_or_default(); + (source, marker) } - Source::Registry { index } => registry_source(&requirement, index)?, Source::Workspace { .. } => { return Err(LoweringError::WorkspaceMember); } @@ -235,10 +281,12 @@ impl LoweredRequirement { } }; + marker.and(requirement.marker.clone()); + Ok(Self(Requirement { name: requirement.name.clone(), extras: requirement.extras.clone(), - marker: requirement.marker.clone(), + marker, source, origin: requirement.origin.clone(), })) diff --git a/crates/uv-workspace/Cargo.toml b/crates/uv-workspace/Cargo.toml index d77df0f98e614..b04eb185cd9a1 100644 --- a/crates/uv-workspace/Cargo.toml +++ b/crates/uv-workspace/Cargo.toml @@ -26,6 +26,8 @@ uv-options-metadata = { workspace = true } either = { workspace = true } fs-err = { workspace = true } glob = { workspace = true } +itertools = { workspace = true } +owo-colors = { workspace = true } rustc-hash = { workspace = true } same-file = { workspace = true } schemars = { workspace = true, optional = true } @@ -36,7 +38,6 @@ toml = { workspace = true } toml_edit = { workspace = true } tracing = { workspace = true } url = { workspace = true } -itertools = { workspace = true } [dev-dependencies] anyhow = { workspace = true } diff --git a/crates/uv-workspace/src/pyproject.rs b/crates/uv-workspace/src/pyproject.rs index 514355cf14fe4..a7c525d708b34 100644 --- a/crates/uv-workspace/src/pyproject.rs +++ b/crates/uv-workspace/src/pyproject.rs @@ -7,6 +7,7 @@ //! Then lowers them into a dependency specification. use glob::Pattern; +use owo_colors::OwoColorize; use serde::{de::IntoDeserializer, Deserialize, Serialize}; use std::ops::Deref; use std::path::{Path, PathBuf}; @@ -16,6 +17,7 @@ use thiserror::Error; use url::Url; use pep440_rs::{Version, VersionSpecifiers}; +use pep508_rs::{MarkerTree, MarkerTreeContents}; use pypi_types::{RequirementSource, SupportedEnvironments, VerbatimParsedUrl}; use uv_fs::{relative_to, PortablePathBuf}; use uv_git::GitReference; @@ -409,31 +411,85 @@ impl Deref for SerdePattern { #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -#[serde(rename_all = "kebab-case", from = "SourcesWire")] +#[serde(rename_all = "kebab-case", try_from = "SourcesWire")] pub struct Sources(Vec); impl Sources { - pub fn inner(&self) -> &[Source] { - &self.0 + /// Return an [`Iterator`] over the sources. + pub fn iter(&self) -> impl Iterator { + self.0.iter() } +} - pub fn into_inner(self) -> Vec { - self.0 +impl IntoIterator for Sources { + type Item = Source; + type IntoIter = std::vec::IntoIter; + + 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), } -impl From for Sources { - fn from(wire: SourcesWire) -> Self { +impl TryFrom for Sources { + type Error = SourceError; + + fn try_from(wire: SourcesWire) -> Result { match wire { - SourcesWire::One(source) => Sources(vec![source]), - SourcesWire::Many(sources) => Sources(sources), + 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("true".to_string()); + let rhs = rhs + .contents() + .map(|contents| contents.to_string()) + .unwrap_or("true".to_string()); + let hint = hint + .contents() + .map(|contents| contents.to_string()) + .unwrap_or("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)) + } } } } @@ -458,6 +514,7 @@ pub enum Source { rev: Option, tag: Option, branch: Option, + marker: Option, }, /// A remote `http://` or `https://` URL, either a wheel (`.whl`) or a source distribution /// (`.zip`, `.tar.gz`). @@ -471,6 +528,7 @@ pub enum Source { /// For source distributions, the path to the directory with the `pyproject.toml`, if it's /// not in the archive root. subdirectory: Option, + marker: Option, }, /// 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 @@ -479,17 +537,20 @@ pub enum Source { path: PortablePathBuf, /// `false` by default. editable: Option, + marker: Option, }, /// A dependency pinned to a specific index, e.g., `torch` after setting `torch` to `https://download.pytorch.org/whl/cu118`. Registry { // TODO(konstin): The string is more-or-less a placeholder index: String, + marker: Option, }, /// A dependency on another package in the workspace. Workspace { /// When set to `false`, the package will be fetched from the remote index, rather than /// included as a workspace package. workspace: bool, + marker: Option, }, /// A catch-all variant used to emit precise error messages when deserializing. CatchAll { @@ -502,6 +563,7 @@ pub enum Source { path: PortablePathBuf, index: String, workspace: bool, + marker: Option, }, } @@ -525,6 +587,12 @@ pub enum SourceError { Absolute(#[from] std::io::Error), #[error("Path contains invalid characters: `{}`", _0.display())] 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 { @@ -555,7 +623,10 @@ impl Source { if workspace { return match source { RequirementSource::Registry { .. } | RequirementSource::Directory { .. } => { - Ok(Some(Source::Workspace { workspace: true })) + Ok(Some(Source::Workspace { + workspace: true, + marker: None, + })) } RequirementSource::Url { .. } => { Err(SourceError::WorkspacePackageUrl(name.to_string())) @@ -579,12 +650,14 @@ impl Source { .or_else(|_| std::path::absolute(&install_path)) .map_err(SourceError::Absolute)?, ), + marker: None, }, RequirementSource::Url { subdirectory, url, .. } => Source::Url { url: url.to_url(), subdirectory: subdirectory.map(PortablePathBuf::from), + marker: None, }, RequirementSource::Git { repository, @@ -609,6 +682,7 @@ impl Source { branch, git: repository, subdirectory: subdirectory.map(PortablePathBuf::from), + marker: None, } } else { Source::Git { @@ -617,6 +691,7 @@ impl Source { branch, git: repository, subdirectory: subdirectory.map(PortablePathBuf::from), + marker: None, } } } @@ -624,6 +699,18 @@ impl Source { Ok(Some(source)) } + + /// Return the [`MarkerTree`] for the source. + pub fn marker(&self) -> MarkerTree { + match self { + Source::Git { marker, .. } => MarkerTree::from(marker.clone()), + Source::Url { marker, .. } => MarkerTree::from(marker.clone()), + Source::Path { marker, .. } => MarkerTree::from(marker.clone()), + Source::Registry { marker, .. } => MarkerTree::from(marker.clone()), + Source::Workspace { marker, .. } => MarkerTree::from(marker.clone()), + Source::CatchAll { marker, .. } => MarkerTree::from(marker.clone()), + } + } } /// The type of a dependency in a `pyproject.toml`. diff --git a/crates/uv-workspace/src/workspace.rs b/crates/uv-workspace/src/workspace.rs index 2451abce4d0c4..390ad1c91c1fe 100644 --- a/crates/uv-workspace/src/workspace.rs +++ b/crates/uv-workspace/src/workspace.rs @@ -541,7 +541,7 @@ impl Workspace { .as_ref() .and_then(|uv| uv.sources.as_ref()) .map(ToolUvSources::inner) - .map(|sources| sources.values().flat_map(Sources::inner)) + .map(|sources| sources.values().flat_map(Sources::iter)) }) }) .flatten() diff --git a/crates/uv/src/commands/project/add.rs b/crates/uv/src/commands/project/add.rs index 89f3bd0c198bc..498b9b7adbaaf 100644 --- a/crates/uv/src/commands/project/add.rs +++ b/crates/uv/src/commands/project/add.rs @@ -401,6 +401,7 @@ pub(crate) async fn add( rev, tag, branch, + marker, }) => { let credentials = Credentials::from_url(&git); if let Some(credentials) = credentials { @@ -416,6 +417,7 @@ pub(crate) async fn add( rev, tag, branch, + marker, }) } _ => source, diff --git a/crates/uv/src/commands/project/sync.rs b/crates/uv/src/commands/project/sync.rs index e580d1b0b9b36..94c7c8ddefca2 100644 --- a/crates/uv/src/commands/project/sync.rs +++ b/crates/uv/src/commands/project/sync.rs @@ -426,7 +426,7 @@ fn store_credentials_from_workspace(workspace: &Workspace) { .and_then(|uv| uv.sources.as_ref()) .map(ToolUvSources::inner) .iter() - .flat_map(|sources| sources.values().flat_map(Sources::inner)) + .flat_map(|sources| sources.values().flat_map(Sources::iter)) { match source { Source::Git { git, .. } => { diff --git a/crates/uv/tests/lock.rs b/crates/uv/tests/lock.rs index 4343628287e52..a5344955180f8 100644 --- a/crates/uv/tests/lock.rs +++ b/crates/uv/tests/lock.rs @@ -13323,3 +13323,182 @@ fn lock_change_requires_python() -> Result<()> { 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(()) +} diff --git a/docs/concepts/dependencies.md b/docs/concepts/dependencies.md index e45ea9dcdbeb1..20da9f23d9263 100644 --- a/docs/concepts/dependencies.md +++ b/docs/concepts/dependencies.md @@ -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 It is common for projects that are published as libraries to make some features optional to reduce diff --git a/uv.schema.json b/uv.schema.json index 645ffc4582fbf..aea18da1b24f3 100644 --- a/uv.schema.json +++ b/uv.schema.json @@ -625,6 +625,10 @@ } ] }, + "MarkerTreeContents": { + "description": "A PEP 508-compliant marker expression, e.g., `sys_platform == 'Darwin'`", + "type": "string" + }, "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: ", "type": "string" @@ -1240,6 +1244,16 @@ "type": "string", "format": "uri" }, + "marker": { + "anyOf": [ + { + "$ref": "#/definitions/MarkerTreeContents" + }, + { + "type": "null" + } + ] + }, "rev": { "type": [ "string", @@ -1273,6 +1287,16 @@ "url" ], "properties": { + "marker": { + "anyOf": [ + { + "$ref": "#/definitions/MarkerTreeContents" + }, + { + "type": "null" + } + ] + }, "subdirectory": { "description": "For source distributions, the path to the directory with the `pyproject.toml`, if it's not in the archive root.", "anyOf": [ @@ -1305,6 +1329,16 @@ "null" ] }, + "marker": { + "anyOf": [ + { + "$ref": "#/definitions/MarkerTreeContents" + }, + { + "type": "null" + } + ] + }, "path": { "$ref": "#/definitions/String" } @@ -1320,6 +1354,16 @@ "properties": { "index": { "type": "string" + }, + "marker": { + "anyOf": [ + { + "$ref": "#/definitions/MarkerTreeContents" + }, + { + "type": "null" + } + ] } }, "additionalProperties": false @@ -1331,6 +1375,16 @@ "workspace" ], "properties": { + "marker": { + "anyOf": [ + { + "$ref": "#/definitions/MarkerTreeContents" + }, + { + "type": "null" + } + ] + }, "workspace": { "description": "When set to `false`, the package will be fetched from the remote index, rather than included as a workspace package.", "type": "boolean" @@ -1361,6 +1415,16 @@ "index": { "type": "string" }, + "marker": { + "anyOf": [ + { + "$ref": "#/definitions/MarkerTreeContents" + }, + { + "type": "null" + } + ] + }, "path": { "$ref": "#/definitions/String" }, @@ -1397,6 +1461,12 @@ } ] }, + "Sources": { + "type": "array", + "items": { + "$ref": "#/definitions/Source" + } + }, "StaticMetadata": { "description": "A subset of the Python Package Metadata 2.3 standard as specified in .", "type": "object", @@ -1565,7 +1635,7 @@ "ToolUvSources": { "type": "object", "additionalProperties": { - "$ref": "#/definitions/Source" + "$ref": "#/definitions/Sources" } }, "ToolUvWorkspace": {