From c4267c9b51511578daf5bb60a100d6058dd2218a Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Mon, 19 Aug 2024 12:21:43 -0400 Subject: [PATCH] Allow user to constrain supported lock environments --- crates/uv-resolver/src/lock.rs | 32 +++ crates/uv-resolver/src/resolution/graph.rs | 13 +- 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 +- 14 files changed, 662 insertions(+), 40 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 e655e96130828..319b523bad30e 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("environment-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 = "environment-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 216d3e43c9a11..a4d2859323bb9 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-settings/src/settings.rs b/crates/uv-settings/src/settings.rs index 6803c8ff5b938..41c0aa6a9d71a 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 0000000000000..5a27a14b07ebd --- /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 f9bc909063886..17113d54c34a6 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 c2f2badd7de96..3309deedd9f9e 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 7e4097df95001..f4139cadef2af 100644 --- a/crates/uv-workspace/src/workspace.rs +++ b/crates/uv-workspace/src/workspace.rs @@ -11,9 +11,10 @@ use tracing::{debug, trace, warn}; use pep508_rs::{MarkerTree, RequirementOrigin, VerbatimUrl}; use pypi_types::{Requirement, RequirementSource}; use uv_fs::{absolutize_path, normalize_path, relative_to, Simplified}; -use uv_normalize::{GroupName, PackageName, DEV_DEPENDENCIES}; +use uv_normalize::{DEV_DEPENDENCIES, GroupName, PackageName}; 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 diff --git a/crates/uv/src/commands/project/lock.rs b/crates/uv/src/commands/project/lock.rs index c4778586fef26..38d1853ed1e57 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 71187ab514371..cfb79efa048e7 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 e8c017f611e5c..ca533db25ef3a 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 eb3d487788bfc..59d094864c8ad 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 a4717e0ae06c7..4c1fb18b3f388 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 a62edbda875ac..f5f46af25835d 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 0a86e538b8c34..46067da3f0dcd 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"