From dab396cac89480fc947ee2d7a10dd29a4486f4eb Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Mon, 19 Aug 2024 12:21:43 -0400 Subject: [PATCH 1/2] Allow user to constrain supported lock environments --- crates/uv-resolver/src/lock.rs | 32 ++ crates/uv-resolver/src/resolution/graph.rs | 13 +- ...r__lock__tests__hash_optional_missing.snap | 1 + ...r__lock__tests__hash_optional_present.snap | 1 + ...r__lock__tests__hash_required_present.snap | 1 + ...missing_dependency_source_unambiguous.snap | 1 + ...dependency_source_version_unambiguous.snap | 1 + ...issing_dependency_version_unambiguous.snap | 1 + ...lock__tests__source_direct_has_subdir.snap | 1 + ..._lock__tests__source_direct_no_subdir.snap | 1 + ...solver__lock__tests__source_directory.snap | 1 + ...esolver__lock__tests__source_editable.snap | 1 + crates/uv-settings/src/settings.rs | 4 + crates/uv-workspace/src/environments.rs | 87 +++++ crates/uv-workspace/src/lib.rs | 2 + crates/uv-workspace/src/pyproject.rs | 33 +- crates/uv-workspace/src/workspace.rs | 18 ++ crates/uv/src/commands/project/lock.rs | 92 +++--- crates/uv/src/commands/project/mod.rs | 3 + crates/uv/src/commands/project/sync.rs | 18 +- crates/uv/tests/lock.rs | 296 ++++++++++++++++++ crates/uv/tests/sync.rs | 35 +++ docs/reference/settings.md | 57 ++++ uv.schema.json | 12 +- 24 files changed, 673 insertions(+), 39 deletions(-) create mode 100644 crates/uv-workspace/src/environments.rs diff --git a/crates/uv-resolver/src/lock.rs b/crates/uv-resolver/src/lock.rs index beaa25bd3896..812ed47cb07b 100644 --- a/crates/uv-resolver/src/lock.rs +++ b/crates/uv-resolver/src/lock.rs @@ -47,6 +47,8 @@ pub struct Lock { /// If this lockfile was built from a forking resolution with non-identical forks, store the /// forks in the lockfile so we can recreate them in subsequent resolutions. fork_markers: Vec, + /// The list of supported environments specified by the user. + supported_environments: Vec, /// The range of supported Python versions. requires_python: Option, /// We discard the lockfile if these options don't match. @@ -161,6 +163,7 @@ impl Lock { requires_python, options, ResolverManifest::default(), + vec![], graph.fork_markers.clone(), )?; Ok(lock) @@ -173,6 +176,7 @@ impl Lock { requires_python: Option, options: ResolverOptions, manifest: ResolverManifest, + supported_environments: Vec, fork_markers: Vec, ) -> Result { // Put all dependencies for each package in a canonical order and @@ -329,6 +333,7 @@ impl Lock { Ok(Self { version, fork_markers, + supported_environments, requires_python, options, packages, @@ -344,6 +349,13 @@ impl Lock { self } + /// Record the supported environments that were used to generate this lock. + #[must_use] + pub fn with_supported_environments(mut self, supported_environments: Vec) -> Self { + self.supported_environments = supported_environments; + self + } + /// Returns the number of packages in the lockfile. pub fn len(&self) -> usize { self.packages.len() @@ -384,6 +396,11 @@ impl Lock { self.options.exclude_newer } + /// Returns the supported environments that were used to generate this lock. + pub fn supported_environments(&self) -> &[MarkerTree] { + &self.supported_environments + } + /// If this lockfile was built from a forking resolution with non-identical forks, return the /// markers of those forks, otherwise `None`. pub fn fork_markers(&self) -> &[MarkerTree] { @@ -486,6 +503,7 @@ impl Lock { if let Some(ref requires_python) = self.requires_python { doc.insert("requires-python", value(requires_python.to_string())); } + if !self.fork_markers.is_empty() { let fork_markers = each_element_on_its_line_array( self.fork_markers @@ -496,6 +514,16 @@ impl Lock { doc.insert("resolution-markers", value(fork_markers)); } + if !self.supported_environments.is_empty() { + let supported_environments = each_element_on_its_line_array( + self.supported_environments + .iter() + .filter_map(MarkerTree::contents) + .map(|marker| marker.to_string()), + ); + doc.insert("supported-markers", value(supported_environments)); + } + // Write the settings that were used to generate the resolution. // This enables us to invalidate the lockfile if the user changes // their settings. @@ -951,6 +979,8 @@ struct LockWire { /// forks in the lockfile so we can recreate them in subsequent resolutions. #[serde(rename = "resolution-markers", default)] fork_markers: Vec, + #[serde(rename = "supported-markers", default)] + supported_environments: Vec, /// We discard the lockfile if these options match. #[serde(default)] options: ResolverOptions, @@ -966,6 +996,7 @@ impl From for LockWire { version: lock.version, requires_python: lock.requires_python, fork_markers: lock.fork_markers, + supported_environments: lock.supported_environments, options: lock.options, manifest: lock.manifest, packages: lock.packages.into_iter().map(PackageWire::from).collect(), @@ -1005,6 +1036,7 @@ impl TryFrom for Lock { wire.requires_python, wire.options, wire.manifest, + wire.supported_environments, wire.fork_markers, ) } diff --git a/crates/uv-resolver/src/resolution/graph.rs b/crates/uv-resolver/src/resolution/graph.rs index 216d3e43c9a1..a4d2859323bb 100644 --- a/crates/uv-resolver/src/resolution/graph.rs +++ b/crates/uv-resolver/src/resolution/graph.rs @@ -174,7 +174,18 @@ impl ResolutionGraph { vec![] } ResolverMarkers::Fork(_) => { - panic!("A single fork must be universal"); + resolutions + .iter() + .map(|resolution| { + resolution + .markers + .fork_markers() + .expect("A non-forking resolution exists in forking mode") + .clone() + }) + // Any unsatisfiable forks were skipped. + .filter(|fork| !fork.is_false()) + .collect() } } } else { diff --git a/crates/uv-resolver/src/snapshots/uv_resolver__lock__tests__hash_optional_missing.snap b/crates/uv-resolver/src/snapshots/uv_resolver__lock__tests__hash_optional_missing.snap index 6ddf93bf70ed..dc681ebe3405 100644 --- a/crates/uv-resolver/src/snapshots/uv_resolver__lock__tests__hash_optional_missing.snap +++ b/crates/uv-resolver/src/snapshots/uv_resolver__lock__tests__hash_optional_missing.snap @@ -6,6 +6,7 @@ Ok( Lock { version: 1, fork_markers: [], + supported_environments: [], requires_python: None, options: ResolverOptions { resolution_mode: Highest, diff --git a/crates/uv-resolver/src/snapshots/uv_resolver__lock__tests__hash_optional_present.snap b/crates/uv-resolver/src/snapshots/uv_resolver__lock__tests__hash_optional_present.snap index e02dae555179..1adc09b7480f 100644 --- a/crates/uv-resolver/src/snapshots/uv_resolver__lock__tests__hash_optional_present.snap +++ b/crates/uv-resolver/src/snapshots/uv_resolver__lock__tests__hash_optional_present.snap @@ -6,6 +6,7 @@ Ok( Lock { version: 1, fork_markers: [], + supported_environments: [], requires_python: None, options: ResolverOptions { resolution_mode: Highest, diff --git a/crates/uv-resolver/src/snapshots/uv_resolver__lock__tests__hash_required_present.snap b/crates/uv-resolver/src/snapshots/uv_resolver__lock__tests__hash_required_present.snap index 22cf4126c168..d2a909d67c30 100644 --- a/crates/uv-resolver/src/snapshots/uv_resolver__lock__tests__hash_required_present.snap +++ b/crates/uv-resolver/src/snapshots/uv_resolver__lock__tests__hash_required_present.snap @@ -6,6 +6,7 @@ Ok( Lock { version: 1, fork_markers: [], + supported_environments: [], requires_python: None, options: ResolverOptions { resolution_mode: Highest, diff --git a/crates/uv-resolver/src/snapshots/uv_resolver__lock__tests__missing_dependency_source_unambiguous.snap b/crates/uv-resolver/src/snapshots/uv_resolver__lock__tests__missing_dependency_source_unambiguous.snap index c70f7f8d9c11..3d98ecdff8ea 100644 --- a/crates/uv-resolver/src/snapshots/uv_resolver__lock__tests__missing_dependency_source_unambiguous.snap +++ b/crates/uv-resolver/src/snapshots/uv_resolver__lock__tests__missing_dependency_source_unambiguous.snap @@ -6,6 +6,7 @@ Ok( Lock { version: 1, fork_markers: [], + supported_environments: [], requires_python: None, options: ResolverOptions { resolution_mode: Highest, diff --git a/crates/uv-resolver/src/snapshots/uv_resolver__lock__tests__missing_dependency_source_version_unambiguous.snap b/crates/uv-resolver/src/snapshots/uv_resolver__lock__tests__missing_dependency_source_version_unambiguous.snap index c70f7f8d9c11..3d98ecdff8ea 100644 --- a/crates/uv-resolver/src/snapshots/uv_resolver__lock__tests__missing_dependency_source_version_unambiguous.snap +++ b/crates/uv-resolver/src/snapshots/uv_resolver__lock__tests__missing_dependency_source_version_unambiguous.snap @@ -6,6 +6,7 @@ Ok( Lock { version: 1, fork_markers: [], + supported_environments: [], requires_python: None, options: ResolverOptions { resolution_mode: Highest, diff --git a/crates/uv-resolver/src/snapshots/uv_resolver__lock__tests__missing_dependency_version_unambiguous.snap b/crates/uv-resolver/src/snapshots/uv_resolver__lock__tests__missing_dependency_version_unambiguous.snap index c70f7f8d9c11..3d98ecdff8ea 100644 --- a/crates/uv-resolver/src/snapshots/uv_resolver__lock__tests__missing_dependency_version_unambiguous.snap +++ b/crates/uv-resolver/src/snapshots/uv_resolver__lock__tests__missing_dependency_version_unambiguous.snap @@ -6,6 +6,7 @@ Ok( Lock { version: 1, fork_markers: [], + supported_environments: [], requires_python: None, options: ResolverOptions { resolution_mode: Highest, diff --git a/crates/uv-resolver/src/snapshots/uv_resolver__lock__tests__source_direct_has_subdir.snap b/crates/uv-resolver/src/snapshots/uv_resolver__lock__tests__source_direct_has_subdir.snap index 3ba47df9faee..1c15e7a0f2f8 100644 --- a/crates/uv-resolver/src/snapshots/uv_resolver__lock__tests__source_direct_has_subdir.snap +++ b/crates/uv-resolver/src/snapshots/uv_resolver__lock__tests__source_direct_has_subdir.snap @@ -6,6 +6,7 @@ Ok( Lock { version: 1, fork_markers: [], + supported_environments: [], requires_python: None, options: ResolverOptions { resolution_mode: Highest, diff --git a/crates/uv-resolver/src/snapshots/uv_resolver__lock__tests__source_direct_no_subdir.snap b/crates/uv-resolver/src/snapshots/uv_resolver__lock__tests__source_direct_no_subdir.snap index 01c5cfd82d80..520f1d927576 100644 --- a/crates/uv-resolver/src/snapshots/uv_resolver__lock__tests__source_direct_no_subdir.snap +++ b/crates/uv-resolver/src/snapshots/uv_resolver__lock__tests__source_direct_no_subdir.snap @@ -6,6 +6,7 @@ Ok( Lock { version: 1, fork_markers: [], + supported_environments: [], requires_python: None, options: ResolverOptions { resolution_mode: Highest, diff --git a/crates/uv-resolver/src/snapshots/uv_resolver__lock__tests__source_directory.snap b/crates/uv-resolver/src/snapshots/uv_resolver__lock__tests__source_directory.snap index 57c5a9687d4e..8fd5bda01232 100644 --- a/crates/uv-resolver/src/snapshots/uv_resolver__lock__tests__source_directory.snap +++ b/crates/uv-resolver/src/snapshots/uv_resolver__lock__tests__source_directory.snap @@ -6,6 +6,7 @@ Ok( Lock { version: 1, fork_markers: [], + supported_environments: [], requires_python: None, options: ResolverOptions { resolution_mode: Highest, diff --git a/crates/uv-resolver/src/snapshots/uv_resolver__lock__tests__source_editable.snap b/crates/uv-resolver/src/snapshots/uv_resolver__lock__tests__source_editable.snap index 05371b457220..468c047661c1 100644 --- a/crates/uv-resolver/src/snapshots/uv_resolver__lock__tests__source_editable.snap +++ b/crates/uv-resolver/src/snapshots/uv_resolver__lock__tests__source_editable.snap @@ -6,6 +6,7 @@ Ok( Lock { version: 1, fork_markers: [], + supported_environments: [], requires_python: None, options: ResolverOptions { resolution_mode: Highest, diff --git a/crates/uv-settings/src/settings.rs b/crates/uv-settings/src/settings.rs index 6803c8ff5b93..41c0aa6a9d71 100644 --- a/crates/uv-settings/src/settings.rs +++ b/crates/uv-settings/src/settings.rs @@ -64,6 +64,10 @@ pub struct Options { #[cfg_attr(feature = "schemars", schemars(skip))] dev_dependencies: serde::de::IgnoredAny, + #[serde(default, skip_serializing)] + #[cfg_attr(feature = "schemars", schemars(skip))] + environments: serde::de::IgnoredAny, + #[serde(default, skip_serializing)] #[cfg_attr(feature = "schemars", schemars(skip))] managed: serde::de::IgnoredAny, diff --git a/crates/uv-workspace/src/environments.rs b/crates/uv-workspace/src/environments.rs new file mode 100644 index 000000000000..5a27a14b07eb --- /dev/null +++ b/crates/uv-workspace/src/environments.rs @@ -0,0 +1,87 @@ +use std::str::FromStr; + +use serde::ser::SerializeSeq; + +use pep508_rs::{MarkerTree, Pep508Error}; + +#[derive(Debug, Default, Clone, Eq, PartialEq)] +pub struct SupportedEnvironments(Vec); + +impl SupportedEnvironments { + /// Return the list of marker trees. + pub fn as_markers(&self) -> &[MarkerTree] { + &self.0 + } + + /// Convert the [`SupportedEnvironments`] struct into a list of marker trees. + pub fn into_markers(self) -> Vec { + self.0 + } +} + +/// Serialize a [`SupportedEnvironments`] struct into a list of marker strings. +impl serde::Serialize for SupportedEnvironments { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + let mut seq = serializer.serialize_seq(Some(self.0.len()))?; + for element in &self.0 { + if let Some(contents) = element.contents() { + seq.serialize_element(&contents)?; + } + } + seq.end() + } +} + +/// Deserialize a marker string or list of marker strings into a [`SupportedEnvironments`] struct. +impl<'de> serde::Deserialize<'de> for SupportedEnvironments { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + struct StringOrVecVisitor; + + impl<'de> serde::de::Visitor<'de> for StringOrVecVisitor { + type Value = SupportedEnvironments; + + fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { + formatter.write_str("a string or a list of strings") + } + + fn visit_str(self, value: &str) -> Result + where + E: serde::de::Error, + { + let marker = MarkerTree::from_str(value).map_err(serde::de::Error::custom)?; + Ok(SupportedEnvironments(vec![marker])) + } + + fn visit_seq(self, mut seq: A) -> Result + where + A: serde::de::SeqAccess<'de>, + { + let mut markers = Vec::new(); + + while let Some(elem) = seq.next_element::()? { + let marker = MarkerTree::from_str(&elem).map_err(serde::de::Error::custom)?; + markers.push(marker); + } + + Ok(SupportedEnvironments(markers)) + } + } + + deserializer.deserialize_any(StringOrVecVisitor) + } +} + +/// Parse a marker string into a [`SupportedEnvironments`] struct. +impl FromStr for SupportedEnvironments { + type Err = Pep508Error; + + fn from_str(s: &str) -> Result { + MarkerTree::parse_str(s).map(|markers| SupportedEnvironments(vec![markers])) + } +} diff --git a/crates/uv-workspace/src/lib.rs b/crates/uv-workspace/src/lib.rs index f9bc90906388..17113d54c34a 100644 --- a/crates/uv-workspace/src/lib.rs +++ b/crates/uv-workspace/src/lib.rs @@ -1,8 +1,10 @@ +pub use environments::SupportedEnvironments; pub use workspace::{ check_nested_workspaces, DiscoveryOptions, ProjectWorkspace, VirtualProject, Workspace, WorkspaceError, WorkspaceMember, }; +mod environments; pub mod pyproject; pub mod pyproject_mut; mod workspace; diff --git a/crates/uv-workspace/src/pyproject.rs b/crates/uv-workspace/src/pyproject.rs index c2f2badd7de9..3309deedd9f9 100644 --- a/crates/uv-workspace/src/pyproject.rs +++ b/crates/uv-workspace/src/pyproject.rs @@ -14,6 +14,7 @@ use serde::{Deserialize, Serialize}; use thiserror::Error; use url::Url; +use crate::environments::SupportedEnvironments; use pep440_rs::VersionSpecifiers; use pypi_types::{RequirementSource, VerbatimParsedUrl}; use uv_git::GitReference; @@ -98,6 +99,8 @@ pub struct ToolUv { "# )] pub managed: Option, + /// The project's development dependencies. Development dependencies will be installed by + /// default in `uv run` and `uv sync`, but will not appear in the project's published metadata. #[cfg_attr( feature = "schemars", schemars( @@ -105,12 +108,40 @@ pub struct ToolUv { description = "PEP 508-style requirements, e.g., `ruff==0.5.0`, or `ruff @ https://...`." ) )] + #[option( + default = r#"[]"#, + value_type = "list[str]", + example = r#" + dev_dependencies = ["ruff==0.5.0"] + "# + )] pub dev_dependencies: Option>>, + /// A list of supported environments against which to resolve dependencies. + /// + /// By default, uv will resolve for all possible environments during a `uv lock` operation. + /// However, you can restrict the set of supported environments to improve performance and avoid + /// unsatisfiable branches in the solution space. + #[cfg_attr( + feature = "schemars", + schemars( + with = "Option>", + description = "A list of environment markers, e.g. `python_version >= '3.6'`." + ) + )] + #[option( + default = r#"[]"#, + value_type = "str | list[str]", + example = r#" + # Resolve for macOS, but not for Linux or Windows. + environments = ["sys_platform == 'darwin'"] + "# + )] + pub environments: Option, #[cfg_attr( feature = "schemars", schemars( with = "Option>", - description = "PEP 508 style requirements, e.g. `ruff==0.5.0`, or `ruff @ https://...`." + description = "PEP 508-style requirements, e.g. `ruff==0.5.0`, or `ruff @ https://...`." ) )] pub override_dependencies: Option>>, diff --git a/crates/uv-workspace/src/workspace.rs b/crates/uv-workspace/src/workspace.rs index 7e4097df9500..204eefbfbc93 100644 --- a/crates/uv-workspace/src/workspace.rs +++ b/crates/uv-workspace/src/workspace.rs @@ -14,6 +14,7 @@ use uv_fs::{absolutize_path, normalize_path, relative_to, Simplified}; use uv_normalize::{GroupName, PackageName, DEV_DEPENDENCIES}; use uv_warnings::warn_user; +use crate::environments::SupportedEnvironments; use crate::pyproject::{Project, PyProjectToml, Source, ToolUvWorkspace}; #[derive(thiserror::Error, Debug)] @@ -367,6 +368,21 @@ impl Workspace { .collect() } + /// Returns the set of supported environments for the workspace. + pub fn environments(&self) -> Option<&SupportedEnvironments> { + let workspace_package = self + .packages + .values() + .find(|workspace_package| workspace_package.root() == self.install_path())?; + + workspace_package + .pyproject_toml() + .tool + .as_ref() + .and_then(|tool| tool.uv.as_ref()) + .and_then(|uv| uv.environments.as_ref()) + } + /// Returns the set of constraints for the workspace. pub fn constraints(&self) -> Vec { let Some(workspace_package) = self @@ -1579,6 +1595,7 @@ mod tests { }, "managed": null, "dev-dependencies": null, + "environments": null, "override-dependencies": null, "constraint-dependencies": null } @@ -1651,6 +1668,7 @@ mod tests { }, "managed": null, "dev-dependencies": null, + "environments": null, "override-dependencies": null, "constraint-dependencies": null } diff --git a/crates/uv/src/commands/project/lock.rs b/crates/uv/src/commands/project/lock.rs index c4778586fef2..38d1853ed1e5 100644 --- a/crates/uv/src/commands/project/lock.rs +++ b/crates/uv/src/commands/project/lock.rs @@ -31,7 +31,7 @@ use uv_resolver::{ }; use uv_types::{BuildContext, BuildIsolation, EmptyInstalledPackages, HashStrategy}; use uv_warnings::{warn_user, warn_user_once}; -use uv_workspace::{DiscoveryOptions, Workspace}; +use uv_workspace::{DiscoveryOptions, SupportedEnvironments, Workspace}; use crate::commands::pip::loggers::{DefaultResolveLogger, ResolveLogger, SummaryResolveLogger}; use crate::commands::project::{find_requires_python, FoundInterpreter, ProjectError, SharedState}; @@ -274,6 +274,7 @@ async fn do_lock( let constraints = workspace.constraints(); let dev = vec![DEV_DEPENDENCIES.clone()]; let source_trees = vec![]; + let environments = workspace.environments(); // Collect the list of members. let members = { @@ -410,6 +411,7 @@ async fn do_lock( &members, &constraints, &overrides, + environments, interpreter, &requires_python, index_locations, @@ -444,14 +446,15 @@ async fn do_lock( _ => { debug!("Starting clean resolution"); + // Determine whether we can reuse the existing package versions. + let reusable_lock = existing_lock.as_ref().and_then(|lock| match &lock { + ValidatedLock::Preferable(lock) => Some(lock), + ValidatedLock::Satisfies(lock) => Some(lock), + ValidatedLock::Unusable(_) => None, + }); + // If an existing lockfile exists, build up a set of preferences. - let LockedRequirements { preferences, git } = existing_lock - .as_ref() - .and_then(|lock| match &lock { - ValidatedLock::Preferable(lock) => Some(lock), - ValidatedLock::Satisfies(lock) => Some(lock), - ValidatedLock::Unusable(_) => None, - }) + let LockedRequirements { preferences, git } = reusable_lock .map(|lock| read_lock_requirements(lock, upgrade)) .unwrap_or_default(); @@ -462,19 +465,20 @@ async fn do_lock( } // When we run the same resolution from the lockfile again, we could get a different result the - // second time due to the preferences causing us to skip a fork point (see - // "preferences-dependent-forking" packse scenario). To avoid this, we store the forks in the + // second time due to the preferences causing us to skip a fork point (see the + // `preferences-dependent-forking` packse scenario). To avoid this, we store the forks in the // lockfile. We read those after all the lockfile filters, to allow the forks to change when // the environment changed, e.g. the python bound check above can lead to different forking. - let resolver_markers = ResolverMarkers::universal(if upgrade.is_all() { - // We're discarding all preferences, so we're also discarding the existing forks. - vec![] - } else { - existing_lock - .as_ref() - .map(|existing_lock| existing_lock.lock().fork_markers().to_vec()) - .unwrap_or_default() - }); + let resolver_markers = ResolverMarkers::universal( + reusable_lock + .map(|lock| lock.fork_markers().to_vec()) + .unwrap_or_else(|| { + environments + .cloned() + .map(SupportedEnvironments::into_markers) + .unwrap_or_default() + }), + ); // Resolve the requirements. let resolution = pip::operations::resolve( @@ -518,7 +522,13 @@ async fn do_lock( let previous = existing_lock.map(ValidatedLock::into_lock); let lock = Lock::from_resolution_graph(&resolution)? - .with_manifest(ResolverManifest::new(members, constraints, overrides)); + .with_manifest(ResolverManifest::new(members, constraints, overrides)) + .with_supported_environments( + environments + .cloned() + .map(SupportedEnvironments::into_markers) + .unwrap_or_default(), + ); Ok(LockResult::Changed(previous, lock)) } @@ -544,6 +554,7 @@ impl ValidatedLock { members: &[PackageName], constraints: &[Requirement], overrides: &[Requirement], + environments: Option<&SupportedEnvironments>, interpreter: &Interpreter, requires_python: &RequiresPython, index_locations: &IndexLocations, @@ -601,11 +612,31 @@ impl ValidatedLock { } } - // If the user specified `--upgrade`, then at best we can prefer some of the existing - // versions. - if !upgrade.is_none() { - debug!("Ignoring existing lockfile due to `--upgrade`"); - return Ok(Self::Preferable(lock)); + // If the set of supported environments has changed, we have to perform a clean resolution. + if lock.supported_environments() + != environments + .map(SupportedEnvironments::as_markers) + .unwrap_or_default() + { + let _ = writeln!( + printer.stderr(), + "Ignoring existing lockfile due to change in supported environments" + ); + return Ok(Self::Unusable(lock)); + } + + match upgrade { + Upgrade::None => {} + Upgrade::All => { + // If the user specified `--upgrade`, then we can't use the existing lockfile. + debug!("Ignoring existing lockfile due to `--upgrade`"); + return Ok(Self::Unusable(lock)); + } + Upgrade::Packages(_) => { + // If the user specified `--upgrade-package`, then at best we can prefer some of + // the existing versions. + return Ok(Self::Preferable(lock)); + } } // If the Requires-Python bound in the lockfile is weaker or equivalent to the @@ -627,7 +658,7 @@ impl ValidatedLock { // file), don't use the existing lockfile if it references any registries that are no longer // included in the current configuration. // - // However, iIf _no_ indexes were provided, we assume that the user wants to reuse the existing + // However, if _no_ indexes were provided, we assume that the user wants to reuse the existing // distributions, even though a failure to reuse the lockfile will result in re-resolving // against PyPI by default. let indexes = if index_locations.is_none() { @@ -707,15 +738,6 @@ impl ValidatedLock { } } - /// Return the inner [`Lock`]. - fn lock(&self) -> &Lock { - match self { - ValidatedLock::Unusable(lock) => lock, - ValidatedLock::Satisfies(lock) => lock, - ValidatedLock::Preferable(lock) => lock, - } - } - /// Convert the [`ValidatedLock`] into a [`Lock`]. #[must_use] fn into_lock(self) -> Lock { diff --git a/crates/uv/src/commands/project/mod.rs b/crates/uv/src/commands/project/mod.rs index 4837687e73a6..78e5e01a1054 100644 --- a/crates/uv/src/commands/project/mod.rs +++ b/crates/uv/src/commands/project/mod.rs @@ -60,6 +60,9 @@ pub(crate) enum ProjectError { #[error("The current Python version ({0}) is not compatible with the locked Python requirement: `{1}`")] LockedPythonIncompatibility(Version, RequiresPython), + #[error("The current Python platform is not compatible with the lockfile's supported environments: {0}")] + LockedPlatformIncompatibility(String), + #[error("The requested Python interpreter ({0}) is incompatible with the project Python requirement: `{1}`")] RequestedPythonIncompatibility(Version, RequiresPython), diff --git a/crates/uv/src/commands/project/sync.rs b/crates/uv/src/commands/project/sync.rs index e8c017f611e5..ca533db25ef3 100644 --- a/crates/uv/src/commands/project/sync.rs +++ b/crates/uv/src/commands/project/sync.rs @@ -1,5 +1,6 @@ use anyhow::{Context, Result}; - +use itertools::Itertools; +use pep508_rs::MarkerTree; use uv_auth::store_credentials_from_url; use uv_cache::Cache; use uv_client::{Connectivity, FlatIndexClient, RegistryClientBuilder}; @@ -169,6 +170,21 @@ pub(super) async fn do_sync( } } + // Validate that the platform is supported by the lockfile. + let environments = lock.supported_environments(); + if !environments.is_empty() { + let platform = venv.interpreter().markers(); + if !environments.iter().any(|env| env.evaluate(platform, &[])) { + return Err(ProjectError::LockedPlatformIncompatibility( + environments + .iter() + .filter_map(MarkerTree::contents) + .map(|env| format!("`{env}`")) + .join(", "), + )); + } + } + // Include development dependencies, if requested. let dev = if dev { vec![DEV_DEPENDENCIES.clone()] diff --git a/crates/uv/tests/lock.rs b/crates/uv/tests/lock.rs index 69c179769e5b..ecb103916ad6 100644 --- a/crates/uv/tests/lock.rs +++ b/crates/uv/tests/lock.rs @@ -9918,3 +9918,299 @@ fn lock_exclude_unnecessary_python_forks() -> Result<()> { Ok(()) } + +/// Lock a requirement from PyPI. +#[test] +fn lock_constrained_environment() -> 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 = ["black"] + + [tool.uv] + environments = ["platform_system != 'Windows'"] + "#, + )?; + + uv_snapshot!(context.filters(), context.lock(), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + warning: `uv lock` is experimental and may change without warning + Resolved 7 packages in [TIME] + "###); + + let lock = fs_err::read_to_string(context.temp_dir.join("uv.lock")).unwrap(); + + // Because we're _not_ locking for Windows, `colorama` should not be included. + insta::with_settings!({ + filters => context.filters(), + }, { + assert_snapshot!( + lock, @r###" + version = 1 + requires-python = ">=3.12" + environment-markers = [ + "platform_system != 'Windows'", + ] + supported-markers = [ + "platform_system != 'Windows'", + ] + + [options] + exclude-newer = "2024-03-25 00:00:00 UTC" + + [[package]] + name = "black" + version = "24.3.0" + source = { registry = "https://pypi.org/simple" } + dependencies = [ + { name = "click", marker = "platform_system != 'Windows'" }, + { name = "mypy-extensions", marker = "platform_system != 'Windows'" }, + { name = "packaging", marker = "platform_system != 'Windows'" }, + { name = "pathspec", marker = "platform_system != 'Windows'" }, + { name = "platformdirs", marker = "platform_system != 'Windows'" }, + ] + sdist = { url = "https://files.pythonhosted.org/packages/8f/5f/bac24a952668c7482cfdb4ebf91ba57a796c9da8829363a772040c1a3312/black-24.3.0.tar.gz", hash = "sha256:a0c9c4a0771afc6919578cec71ce82a3e31e054904e7197deacbc9382671c41f", size = 634292 } + wheels = [ + { url = "https://files.pythonhosted.org/packages/b6/c6/1d174efa9ff02b22d0124c73fc5f4d4fb006d0d9a081aadc354d05754a13/black-24.3.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:2818cf72dfd5d289e48f37ccfa08b460bf469e67fb7c4abb07edc2e9f16fb63f", size = 1600822 }, + { url = "https://files.pythonhosted.org/packages/d9/ed/704731afffe460b8ff0672623b40fce9fe569f2ee617c15857e4d4440a3a/black-24.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4acf672def7eb1725f41f38bf6bf425c8237248bb0804faa3965c036f7672d11", size = 1429987 }, + { url = "https://files.pythonhosted.org/packages/a8/05/8dd038e30caadab7120176d4bc109b7ca2f4457f12eef746b0560a583458/black-24.3.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c7ed6668cbbfcd231fa0dc1b137d3e40c04c7f786e626b405c62bcd5db5857e4", size = 1755319 }, + { url = "https://files.pythonhosted.org/packages/71/9d/e5fa1ff4ef1940be15a64883c0bb8d2fcf626efec996eab4ae5a8c691d2c/black-24.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:56f52cfbd3dabe2798d76dbdd299faa046a901041faf2cf33288bc4e6dae57b5", size = 1385180 }, + { url = "https://files.pythonhosted.org/packages/4d/ea/31770a7e49f3eedfd8cd7b35e78b3a3aaad860400f8673994bc988318135/black-24.3.0-py3-none-any.whl", hash = "sha256:41622020d7120e01d377f74249e677039d20e6344ff5851de8a10f11f513bf93", size = 201493 }, + ] + + [[package]] + name = "click" + version = "8.1.7" + source = { registry = "https://pypi.org/simple" } + sdist = { url = "https://files.pythonhosted.org/packages/96/d3/f04c7bfcf5c1862a2a5b845c6b2b360488cf47af55dfa79c98f6a6bf98b5/click-8.1.7.tar.gz", hash = "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de", size = 336121 } + wheels = [ + { url = "https://files.pythonhosted.org/packages/00/2e/d53fa4befbf2cfa713304affc7ca780ce4fc1fd8710527771b58311a3229/click-8.1.7-py3-none-any.whl", hash = "sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28", size = 97941 }, + ] + + [[package]] + name = "mypy-extensions" + version = "1.0.0" + source = { registry = "https://pypi.org/simple" } + sdist = { url = "https://files.pythonhosted.org/packages/98/a4/1ab47638b92648243faf97a5aeb6ea83059cc3624972ab6b8d2316078d3f/mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782", size = 4433 } + wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/e2/5d3f6ada4297caebe1a2add3b126fe800c96f56dbe5d1988a2cbe0b267aa/mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d", size = 4695 }, + ] + + [[package]] + name = "packaging" + version = "24.0" + source = { registry = "https://pypi.org/simple" } + sdist = { url = "https://files.pythonhosted.org/packages/ee/b5/b43a27ac7472e1818c4bafd44430e69605baefe1f34440593e0332ec8b4d/packaging-24.0.tar.gz", hash = "sha256:eb82c5e3e56209074766e6885bb04b8c38a0c015d0a30036ebe7ece34c9989e9", size = 147882 } + wheels = [ + { url = "https://files.pythonhosted.org/packages/49/df/1fceb2f8900f8639e278b056416d49134fb8d84c5942ffaa01ad34782422/packaging-24.0-py3-none-any.whl", hash = "sha256:2ddfb553fdf02fb784c234c7ba6ccc288296ceabec964ad2eae3777778130bc5", size = 53488 }, + ] + + [[package]] + name = "pathspec" + version = "0.12.1" + source = { registry = "https://pypi.org/simple" } + sdist = { url = "https://files.pythonhosted.org/packages/ca/bc/f35b8446f4531a7cb215605d100cd88b7ac6f44ab3fc94870c120ab3adbf/pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712", size = 51043 } + wheels = [ + { url = "https://files.pythonhosted.org/packages/cc/20/ff623b09d963f88bfde16306a54e12ee5ea43e9b597108672ff3a408aad6/pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08", size = 31191 }, + ] + + [[package]] + name = "platformdirs" + version = "4.2.0" + source = { registry = "https://pypi.org/simple" } + sdist = { url = "https://files.pythonhosted.org/packages/96/dc/c1d911bf5bb0fdc58cc05010e9f3efe3b67970cef779ba7fbc3183b987a8/platformdirs-4.2.0.tar.gz", hash = "sha256:ef0cc731df711022c174543cb70a9b5bd22e5a9337c8624ef2c2ceb8ddad8768", size = 20055 } + wheels = [ + { url = "https://files.pythonhosted.org/packages/55/72/4898c44ee9ea6f43396fbc23d9bfaf3d06e01b83698bdf2e4c919deceb7c/platformdirs-4.2.0-py3-none-any.whl", hash = "sha256:0614df2a2f37e1a662acbd8e2b25b92ccf8632929bc6d43467e17fe89c75e068", size = 17717 }, + ] + + [[package]] + name = "project" + version = "0.1.0" + source = { editable = "." } + dependencies = [ + { name = "black", marker = "platform_system != 'Windows'" }, + ] + + [package.metadata] + requires-dist = [{ name = "black" }] + "### + ); + }); + + // Re-run with `--locked`. + uv_snapshot!(context.filters(), context.lock().arg("--locked"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + warning: `uv lock` is experimental and may change without warning + Resolved 7 packages in [TIME] + "###); + + // Re-run with `--offline`. We shouldn't need a network connection to validate an + // already-correct lockfile with immutable metadata. + uv_snapshot!(context.filters(), context.lock().arg("--locked").arg("--offline").arg("--no-cache"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + warning: `uv lock` is experimental and may change without warning + Resolved 7 packages in [TIME] + "###); + + // Re-lock without the environment constraint. + 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 = ["black"] + "#, + )?; + + // Re-run with `--locked`. This should fail. + uv_snapshot!(context.filters(), context.lock().arg("--locked"), @r###" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + warning: `uv lock` is experimental and may change without warning + Ignoring existing lockfile due to change in supported environments + Resolved 8 packages in [TIME] + error: The lockfile at `uv.lock` needs to be updated, but `--locked` was provided. To update the lockfile, run `uv lock`. + "###); + + uv_snapshot!(context.filters(), context.lock(), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + warning: `uv lock` is experimental and may change without warning + Ignoring existing lockfile due to change in supported environments + Resolved 8 packages in [TIME] + Added colorama v0.4.6 + "###); + + let lock = fs_err::read_to_string(context.temp_dir.join("uv.lock")).unwrap(); + + // Because we're locking for Windows, `colorama` should be included. + insta::with_settings!({ + filters => context.filters(), + }, { + assert_snapshot!( + lock, @r###" + version = 1 + requires-python = ">=3.12" + + [options] + exclude-newer = "2024-03-25 00:00:00 UTC" + + [[package]] + name = "black" + version = "24.3.0" + source = { registry = "https://pypi.org/simple" } + dependencies = [ + { name = "click" }, + { name = "mypy-extensions" }, + { name = "packaging" }, + { name = "pathspec" }, + { name = "platformdirs" }, + ] + sdist = { url = "https://files.pythonhosted.org/packages/8f/5f/bac24a952668c7482cfdb4ebf91ba57a796c9da8829363a772040c1a3312/black-24.3.0.tar.gz", hash = "sha256:a0c9c4a0771afc6919578cec71ce82a3e31e054904e7197deacbc9382671c41f", size = 634292 } + wheels = [ + { url = "https://files.pythonhosted.org/packages/b6/c6/1d174efa9ff02b22d0124c73fc5f4d4fb006d0d9a081aadc354d05754a13/black-24.3.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:2818cf72dfd5d289e48f37ccfa08b460bf469e67fb7c4abb07edc2e9f16fb63f", size = 1600822 }, + { url = "https://files.pythonhosted.org/packages/d9/ed/704731afffe460b8ff0672623b40fce9fe569f2ee617c15857e4d4440a3a/black-24.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4acf672def7eb1725f41f38bf6bf425c8237248bb0804faa3965c036f7672d11", size = 1429987 }, + { url = "https://files.pythonhosted.org/packages/a8/05/8dd038e30caadab7120176d4bc109b7ca2f4457f12eef746b0560a583458/black-24.3.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c7ed6668cbbfcd231fa0dc1b137d3e40c04c7f786e626b405c62bcd5db5857e4", size = 1755319 }, + { url = "https://files.pythonhosted.org/packages/71/9d/e5fa1ff4ef1940be15a64883c0bb8d2fcf626efec996eab4ae5a8c691d2c/black-24.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:56f52cfbd3dabe2798d76dbdd299faa046a901041faf2cf33288bc4e6dae57b5", size = 1385180 }, + { url = "https://files.pythonhosted.org/packages/4d/ea/31770a7e49f3eedfd8cd7b35e78b3a3aaad860400f8673994bc988318135/black-24.3.0-py3-none-any.whl", hash = "sha256:41622020d7120e01d377f74249e677039d20e6344ff5851de8a10f11f513bf93", size = 201493 }, + ] + + [[package]] + name = "click" + version = "8.1.7" + source = { registry = "https://pypi.org/simple" } + dependencies = [ + { name = "colorama", marker = "platform_system == 'Windows'" }, + ] + sdist = { url = "https://files.pythonhosted.org/packages/96/d3/f04c7bfcf5c1862a2a5b845c6b2b360488cf47af55dfa79c98f6a6bf98b5/click-8.1.7.tar.gz", hash = "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de", size = 336121 } + wheels = [ + { url = "https://files.pythonhosted.org/packages/00/2e/d53fa4befbf2cfa713304affc7ca780ce4fc1fd8710527771b58311a3229/click-8.1.7-py3-none-any.whl", hash = "sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28", size = 97941 }, + ] + + [[package]] + name = "colorama" + version = "0.4.6" + source = { registry = "https://pypi.org/simple" } + sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697 } + wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 }, + ] + + [[package]] + name = "mypy-extensions" + version = "1.0.0" + source = { registry = "https://pypi.org/simple" } + sdist = { url = "https://files.pythonhosted.org/packages/98/a4/1ab47638b92648243faf97a5aeb6ea83059cc3624972ab6b8d2316078d3f/mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782", size = 4433 } + wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/e2/5d3f6ada4297caebe1a2add3b126fe800c96f56dbe5d1988a2cbe0b267aa/mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d", size = 4695 }, + ] + + [[package]] + name = "packaging" + version = "24.0" + source = { registry = "https://pypi.org/simple" } + sdist = { url = "https://files.pythonhosted.org/packages/ee/b5/b43a27ac7472e1818c4bafd44430e69605baefe1f34440593e0332ec8b4d/packaging-24.0.tar.gz", hash = "sha256:eb82c5e3e56209074766e6885bb04b8c38a0c015d0a30036ebe7ece34c9989e9", size = 147882 } + wheels = [ + { url = "https://files.pythonhosted.org/packages/49/df/1fceb2f8900f8639e278b056416d49134fb8d84c5942ffaa01ad34782422/packaging-24.0-py3-none-any.whl", hash = "sha256:2ddfb553fdf02fb784c234c7ba6ccc288296ceabec964ad2eae3777778130bc5", size = 53488 }, + ] + + [[package]] + name = "pathspec" + version = "0.12.1" + source = { registry = "https://pypi.org/simple" } + sdist = { url = "https://files.pythonhosted.org/packages/ca/bc/f35b8446f4531a7cb215605d100cd88b7ac6f44ab3fc94870c120ab3adbf/pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712", size = 51043 } + wheels = [ + { url = "https://files.pythonhosted.org/packages/cc/20/ff623b09d963f88bfde16306a54e12ee5ea43e9b597108672ff3a408aad6/pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08", size = 31191 }, + ] + + [[package]] + name = "platformdirs" + version = "4.2.0" + source = { registry = "https://pypi.org/simple" } + sdist = { url = "https://files.pythonhosted.org/packages/96/dc/c1d911bf5bb0fdc58cc05010e9f3efe3b67970cef779ba7fbc3183b987a8/platformdirs-4.2.0.tar.gz", hash = "sha256:ef0cc731df711022c174543cb70a9b5bd22e5a9337c8624ef2c2ceb8ddad8768", size = 20055 } + wheels = [ + { url = "https://files.pythonhosted.org/packages/55/72/4898c44ee9ea6f43396fbc23d9bfaf3d06e01b83698bdf2e4c919deceb7c/platformdirs-4.2.0-py3-none-any.whl", hash = "sha256:0614df2a2f37e1a662acbd8e2b25b92ccf8632929bc6d43467e17fe89c75e068", size = 17717 }, + ] + + [[package]] + name = "project" + version = "0.1.0" + source = { editable = "." } + dependencies = [ + { name = "black" }, + ] + + [package.metadata] + requires-dist = [{ name = "black" }] + "### + ); + }); + + Ok(()) +} diff --git a/crates/uv/tests/sync.rs b/crates/uv/tests/sync.rs index a4717e0ae06c..4c1fb18b3f38 100644 --- a/crates/uv/tests/sync.rs +++ b/crates/uv/tests/sync.rs @@ -780,3 +780,38 @@ fn sync_relative_wheel() -> Result<()> { Ok(()) } + +/// Syncing against an unstable environment should fail (but locking should succeed). +#[test] +fn sync_environment() -> 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.10" + dependencies = ["iniconfig"] + + [tool.uv] + environments = ["python_version < '3.11'"] + "#, + )?; + + uv_snapshot!(context.filters(), context.sync(), @r###" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + warning: `uv sync` is experimental and may change without warning + Resolved 2 packages in [TIME] + error: The current Python platform is not compatible with the lockfile's supported environments: `python_full_version < '3.11'` + "###); + + assert!(context.temp_dir.child("uv.lock").exists()); + + Ok(()) +} diff --git a/docs/reference/settings.md b/docs/reference/settings.md index a62edbda875a..f5f46af25835 100644 --- a/docs/reference/settings.md +++ b/docs/reference/settings.md @@ -87,6 +87,63 @@ specified as `KEY=VALUE` pairs. --- +#### [`dev-dependencies`](#dev-dependencies) {: #dev-dependencies } + +The project's development dependencies. Development dependencies will be installed by +default in `uv run` and `uv sync`, but will not appear in the project's published metadata. + +**Default value**: `[]` + +**Type**: `list[str]` + +**Example usage**: + +=== "pyproject.toml" + + ```toml + [tool.uv] + dev_dependencies = ["ruff==0.5.0"] + ``` +=== "uv.toml" + + ```toml + + dev_dependencies = ["ruff==0.5.0"] + ``` + +--- + +#### [`environments`](#environments) {: #environments } + +A list of supported environments against which to resolve dependencies. + +By default, uv will resolve for all possible environments during a `uv lock` operation. +However, you can restrict the set of supported environments to improve performance and avoid +unsatisfiable branches in the solution space. + +**Default value**: `[]` + +**Type**: `str | list[str]` + +**Example usage**: + +=== "pyproject.toml" + + ```toml + [tool.uv] + # Resolve for macOS, but not for Linux or Windows. + environments = ["sys_platform == 'darwin'"] + ``` +=== "uv.toml" + + ```toml + + # Resolve for macOS, but not for Linux or Windows. + environments = ["sys_platform == 'darwin'"] + ``` + +--- + #### [`exclude-newer`](#exclude-newer) {: #exclude-newer } Limit candidate packages to those that were uploaded prior to the given date. diff --git a/uv.schema.json b/uv.schema.json index 0a86e538b8c3..46067da3f0dc 100644 --- a/uv.schema.json +++ b/uv.schema.json @@ -48,6 +48,16 @@ "type": "string" } }, + "environments": { + "description": "A list of environment markers, e.g. `python_version >= '3.6'`.", + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + }, "exclude-newer": { "description": "Limit candidate packages to those that were uploaded prior to the given date.\n\nAccepts both [RFC 3339](https://www.rfc-editor.org/rfc/rfc3339.html) timestamps (e.g., `2006-12-02T02:07:43Z`) and UTC dates in the same format (e.g., `2006-12-02`).", "anyOf": [ @@ -217,7 +227,7 @@ ] }, "override-dependencies": { - "description": "PEP 508 style requirements, e.g. `ruff==0.5.0`, or `ruff @ https://...`.", + "description": "PEP 508-style requirements, e.g. `ruff==0.5.0`, or `ruff @ https://...`.", "type": [ "array", "null" From ded478a7478d8d4b1aedb2e29aba47b96bc18339 Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Tue, 20 Aug 2024 00:12:29 -0400 Subject: [PATCH 2/2] Require disjoint --- STYLE.md | 2 +- crates/uv-client/src/httpcache/mod.rs | 2 +- crates/uv-git/src/git.rs | 4 +- crates/uv-workspace/src/environments.rs | 11 +---- crates/uv/src/commands/project/lock.rs | 37 +++++++++++++- crates/uv/src/commands/project/mod.rs | 3 ++ crates/uv/tests/lock.rs | 66 +++++++++++++++++++++++-- docs/concepts/projects.md | 30 ++++++++--- 8 files changed, 131 insertions(+), 24 deletions(-) diff --git a/STYLE.md b/STYLE.md index 259f7071226c..6786ea6871a7 100644 --- a/STYLE.md +++ b/STYLE.md @@ -86,7 +86,7 @@ The documentation is divided into: 1. When using `console` syntax, use `$` to indicate commands — everything else is output. 1. Never use the `bash` syntax when displaying command output. 1. Prefer `console` with `$` prefixed commands over `bash`. -1. Command output should rarely be included — it's hard to keep up to date. +1. Command output should rarely be included — it's hard to keep up-to-date. 1. Use `title` for example files, e.g., `pyproject.toml`, `Dockerfile`, or `example.py`. ## CLI diff --git a/crates/uv-client/src/httpcache/mod.rs b/crates/uv-client/src/httpcache/mod.rs index e4917183dbbd..5bf4320d0697 100644 --- a/crates/uv-client/src/httpcache/mod.rs +++ b/crates/uv-client/src/httpcache/mod.rs @@ -51,7 +51,7 @@ called a re-validation request. A re-validation request includes with it some metadata (usually an "entity tag" or `etag` for short) that was on the cached response (which is now stale). -When we send this request, the server can compare it with its most up to date +When we send this request, the server can compare it with its most up-to-date version of the resource. If its entity tag matches the one we gave it (among other possible criteria), then the server can skip returning the body and instead just return a small HTTP 304 NOT MODIFIED response. When we get this diff --git a/crates/uv-git/src/git.rs b/crates/uv-git/src/git.rs index 0397722d6a8a..9794dccc831c 100644 --- a/crates/uv-git/src/git.rs +++ b/crates/uv-git/src/git.rs @@ -626,9 +626,9 @@ enum FastPathRev { /// date with what this rev resolves to on GitHub's server. UpToDate, /// The following SHA must be fetched in order for the local rev to become - /// up to date. + /// up-to-date. NeedsFetch(GitOid), - /// Don't know whether local rev is up to date. We'll fetch _all_ branches + /// Don't know whether local rev is up-to-date. We'll fetch _all_ branches /// and tags from the server and see what happens. Indeterminate, } diff --git a/crates/uv-workspace/src/environments.rs b/crates/uv-workspace/src/environments.rs index 5a27a14b07eb..9c640dab200b 100644 --- a/crates/uv-workspace/src/environments.rs +++ b/crates/uv-workspace/src/environments.rs @@ -2,7 +2,7 @@ use std::str::FromStr; use serde::ser::SerializeSeq; -use pep508_rs::{MarkerTree, Pep508Error}; +use pep508_rs::MarkerTree; #[derive(Debug, Default, Clone, Eq, PartialEq)] pub struct SupportedEnvironments(Vec); @@ -76,12 +76,3 @@ impl<'de> serde::Deserialize<'de> for SupportedEnvironments { deserializer.deserialize_any(StringOrVecVisitor) } } - -/// Parse a marker string into a [`SupportedEnvironments`] struct. -impl FromStr for SupportedEnvironments { - type Err = Pep508Error; - - fn from_str(s: &str) -> Result { - MarkerTree::parse_str(s).map(|markers| SupportedEnvironments(vec![markers])) - } -} diff --git a/crates/uv/src/commands/project/lock.rs b/crates/uv/src/commands/project/lock.rs index 38d1853ed1e5..d37919ccffbd 100644 --- a/crates/uv/src/commands/project/lock.rs +++ b/crates/uv/src/commands/project/lock.rs @@ -274,7 +274,6 @@ async fn do_lock( let constraints = workspace.constraints(); let dev = vec![DEV_DEPENDENCIES.clone()]; let source_trees = vec![]; - let environments = workspace.environments(); // Collect the list of members. let members = { @@ -291,6 +290,42 @@ async fn do_lock( members }; + // Collect the list of supported environments. + let environments = { + let environments = workspace.environments(); + + // Ensure that the environments are disjoint. + if let Some(environments) = &environments { + for (lhs, rhs) in environments + .as_markers() + .iter() + .zip(environments.as_markers().iter().skip(1)) + { + 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(ProjectError::OverlappingMarkers(lhs, rhs, hint)); + } + } + } + + environments + }; + // Determine the supported Python range. If no range is defined, and warn and default to the // current minor version. let requires_python = find_requires_python(workspace)?; diff --git a/crates/uv/src/commands/project/mod.rs b/crates/uv/src/commands/project/mod.rs index 78e5e01a1054..acc79dbe9a1e 100644 --- a/crates/uv/src/commands/project/mod.rs +++ b/crates/uv/src/commands/project/mod.rs @@ -75,6 +75,9 @@ pub(crate) enum ProjectError { PathBuf, ), + #[error("Supported environments 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(transparent)] Python(#[from] uv_python::Error), diff --git a/crates/uv/tests/lock.rs b/crates/uv/tests/lock.rs index ecb103916ad6..72e22f6fb629 100644 --- a/crates/uv/tests/lock.rs +++ b/crates/uv/tests/lock.rs @@ -9919,7 +9919,7 @@ fn lock_exclude_unnecessary_python_forks() -> Result<()> { Ok(()) } -/// Lock a requirement from PyPI. +/// Lock with a user-provided constraint on the space of supported environments. #[test] fn lock_constrained_environment() -> Result<()> { let context = TestContext::new("3.12"); @@ -9934,7 +9934,7 @@ fn lock_constrained_environment() -> Result<()> { dependencies = ["black"] [tool.uv] - environments = ["platform_system != 'Windows'"] + environments = "platform_system != 'Windows'" "#, )?; @@ -9958,7 +9958,7 @@ fn lock_constrained_environment() -> Result<()> { lock, @r###" version = 1 requires-python = ">=3.12" - environment-markers = [ + resolution-markers = [ "platform_system != 'Windows'", ] supported-markers = [ @@ -10070,6 +10070,32 @@ fn lock_constrained_environment() -> Result<()> { Resolved 7 packages in [TIME] "###); + // Rewrite with a list, rather than a string. + 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 = ["black"] + + [tool.uv] + environments = ["platform_system != 'Windows'"] + "#, + )?; + + // Re-run with `--locked`. + uv_snapshot!(context.filters(), context.lock().arg("--locked"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + warning: `uv lock` is experimental and may change without warning + Resolved 7 packages in [TIME] + "###); + // Re-lock without the environment constraint. let pyproject_toml = context.temp_dir.child("pyproject.toml"); pyproject_toml.write_str( @@ -10214,3 +10240,37 @@ fn lock_constrained_environment() -> Result<()> { Ok(()) } + +/// User-provided constraints must be disjoint. +#[test] +fn lock_overlapping_environment() -> 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.8" + dependencies = ["black"] + + [tool.uv] + environments = ["platform_system != 'Windows'", "python_version > '3.10'"] + "#, + )?; + + uv_snapshot!(context.filters(), context.lock(), @r###" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + warning: `uv lock` is experimental and may change without warning + error: Supported environments must be disjoint, but the following markers overlap: `platform_system != 'Windows'` and `python_full_version >= '3.11'`. + + hint: replace `python_full_version >= '3.11'` with `python_full_version >= '3.11' and platform_system == 'Windows'`. + "###); + + Ok(()) +} diff --git a/docs/concepts/projects.md b/docs/concepts/projects.md index 4cdee6f23e56..ad94bfd243ea 100644 --- a/docs/concepts/projects.md +++ b/docs/concepts/projects.md @@ -51,7 +51,7 @@ To run a command in the project environment, use `uv run`. Alternatively the pro be activated as normal for a virtual environment. When `uv run` is invoked, it will create the project environment if it does not exist yet or ensure -it is up to date if it exists. The project environment can also be explicitly created with +it is up-to-date if it exists. The project environment can also be explicitly created with `uv sync`. It is _not_ recommended to modify the project environment manually, e.g., with `uv pip install`. For @@ -63,8 +63,26 @@ use [`uvx`](../guides/tools.md) or uv creates a `uv.lock` file next to the `pyproject.toml`. -`uv.lock` is a _universal_ lockfile that captures the packages that would be installed across all -possible Python markers such as operating system, architecture, and Python version. +`uv.lock` is a _universal_ or _cross-platform_ lockfile that captures the packages that would be +installed across all possible Python markers such as operating system, architecture, and Python +version. + +If your project supports a more limited set of platforms or Python versions, you can constrain the +set of solved platforms via the `environments` setting, which accepts a list of PEP 508 environment +markers. For example, to constrain the lockfile to macOS and Linux, and exclude Windows: + +```toml title="pyproject.toml" +[tool.uv] +environments = [ + "sys_platform == 'darwin'", + "sys_platform == 'linux'", +] +``` + +Entries in the `environments` setting must be disjoint (i.e., they must not overlap). For example, +`sys_platform == 'darwin'` and `sys_platform == 'linux'` are disjoint, but +`sys_platform == 'darwin'` and `python_version >= '3.9'` are not, since both could be true at the +same time. Unlike the `pyproject.toml`, which is used to specify the broad requirements of your project, the lockfile contains the exact resolved versions that are installed in the project environment. This @@ -80,11 +98,11 @@ The lockfile is created and updated during uv invocations that use the project e `uv.lock` is a human-readable TOML file but is managed by uv and should not be edited manually. There is no Python standard for lockfiles at this time, so the format of this file is specific to uv -and not generally usable by other tools. +and not usable by other tools. To avoid updating the lockfile during `uv sync` and `uv run` invocations, use the `--frozen` flag. -To assert the lockfile is up to date, use the `--locked` flag. If the lockfile is not up to date, an +To assert the lockfile is up-to-date, use the `--locked` flag. If the lockfile is not up-to-date, an error will be raised instead of updating the lockfile. ## Managing dependencies @@ -146,7 +164,7 @@ environment: $ uv run python -c "import example" ``` -When using `run`, uv will ensure that the project environment is up to date before running the given +When using `run`, uv will ensure that the project environment is up-to-date before running the given command. The given command can be provided by the project environment or exist outside of it, e.g.: