From b90829cb0cf4122348b28e6cd882f498d760e790 Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Mon, 23 Dec 2024 17:41:09 -0500 Subject: [PATCH] Add a lock target abstraction --- crates/uv-resolver/src/lock/mod.rs | 60 +++-- crates/uv-workspace/src/workspace.rs | 4 +- crates/uv/src/commands/project/add.rs | 9 +- crates/uv/src/commands/project/export.rs | 2 +- crates/uv/src/commands/project/lock.rs | 207 +++++----------- crates/uv/src/commands/project/lock_target.rs | 228 ++++++++++++++++++ crates/uv/src/commands/project/mod.rs | 1 + crates/uv/src/commands/project/remove.rs | 2 +- crates/uv/src/commands/project/run.rs | 6 +- crates/uv/src/commands/project/sync.rs | 2 +- crates/uv/src/commands/project/tree.rs | 2 +- 11 files changed, 329 insertions(+), 194 deletions(-) create mode 100644 crates/uv/src/commands/project/lock_target.rs diff --git a/crates/uv-resolver/src/lock/mod.rs b/crates/uv-resolver/src/lock/mod.rs index 10767a67bcd9..e9040a9bd924 100644 --- a/crates/uv-resolver/src/lock/mod.rs +++ b/crates/uv-resolver/src/lock/mod.rs @@ -37,7 +37,7 @@ use uv_pypi_types::{ Requirement, RequirementSource, }; use uv_types::{BuildContext, HashStrategy}; -use uv_workspace::Workspace; +use uv_workspace::WorkspaceMember; use crate::fork_strategy::ForkStrategy; pub use crate::lock::installable::Installable; @@ -916,7 +916,8 @@ impl Lock { /// Convert the [`Lock`] to a [`Resolution`] using the given marker environment, tags, and root. pub async fn satisfies( &self, - workspace: &Workspace, + root: &Path, + packages: &BTreeMap, members: &[PackageName], requirements: &[Requirement], constraints: &[Requirement], @@ -944,7 +945,7 @@ impl Lock { // Validate that the member sources have not changed. { // E.g., that they've switched from virtual to non-virtual or vice versa. - for (name, member) in workspace.packages() { + for (name, member) in packages { let expected = !member.pyproject_toml().is_package(); let actual = self .find_by_name(name) @@ -957,7 +958,7 @@ impl Lock { } // E.g., that the version has changed. - for (name, member) in workspace.packages() { + for (name, member) in packages { let Some(expected) = member .pyproject_toml() .project @@ -986,14 +987,14 @@ impl Lock { let expected: BTreeSet<_> = requirements .iter() .cloned() - .map(|requirement| normalize_requirement(requirement, workspace)) + .map(|requirement| normalize_requirement(requirement, root)) .collect::>()?; let actual: BTreeSet<_> = self .manifest .requirements .iter() .cloned() - .map(|requirement| normalize_requirement(requirement, workspace)) + .map(|requirement| normalize_requirement(requirement, root)) .collect::>()?; if expected != actual { return Ok(SatisfiesResult::MismatchedConstraints(expected, actual)); @@ -1005,14 +1006,14 @@ impl Lock { let expected: BTreeSet<_> = constraints .iter() .cloned() - .map(|requirement| normalize_requirement(requirement, workspace)) + .map(|requirement| normalize_requirement(requirement, root)) .collect::>()?; let actual: BTreeSet<_> = self .manifest .constraints .iter() .cloned() - .map(|requirement| normalize_requirement(requirement, workspace)) + .map(|requirement| normalize_requirement(requirement, root)) .collect::>()?; if expected != actual { return Ok(SatisfiesResult::MismatchedConstraints(expected, actual)); @@ -1024,14 +1025,14 @@ impl Lock { let expected: BTreeSet<_> = overrides .iter() .cloned() - .map(|requirement| normalize_requirement(requirement, workspace)) + .map(|requirement| normalize_requirement(requirement, root)) .collect::>()?; let actual: BTreeSet<_> = self .manifest .overrides .iter() .cloned() - .map(|requirement| normalize_requirement(requirement, workspace)) + .map(|requirement| normalize_requirement(requirement, root)) .collect::>()?; if expected != actual { return Ok(SatisfiesResult::MismatchedOverrides(expected, actual)); @@ -1049,7 +1050,7 @@ impl Lock { requirements .iter() .cloned() - .map(|requirement| normalize_requirement(requirement, workspace)) + .map(|requirement| normalize_requirement(requirement, root)) .collect::>()?, )) }) @@ -1065,7 +1066,7 @@ impl Lock { requirements .iter() .cloned() - .map(|requirement| normalize_requirement(requirement, workspace)) + .map(|requirement| normalize_requirement(requirement, root)) .collect::>()?, )) }) @@ -1111,7 +1112,7 @@ impl Lock { IndexUrl::Pypi(_) | IndexUrl::Url(_) => None, IndexUrl::Path(url) => { let path = url.to_file_path().ok()?; - let path = relative_to(&path, workspace.install_path()) + let path = relative_to(&path, root) .or_else(|_| std::path::absolute(path)) .ok()?; Some(path) @@ -1121,7 +1122,7 @@ impl Lock { }); // Add the workspace packages to the queue. - for root_name in workspace.packages().keys() { + for root_name in packages.keys() { let root = self .find_by_name(root_name) .expect("found too many packages matching root"); @@ -1170,7 +1171,7 @@ impl Lock { // Get the metadata for the distribution. let dist = package.to_dist( - workspace.install_path(), + root, // When validating, it's okay to use wheels that don't match the current platform. TagPolicy::Preferred(tags), // When validating, it's okay to use (e.g.) a source distribution with `--no-build`. @@ -1233,14 +1234,14 @@ impl Lock { let expected: BTreeSet<_> = metadata .requires_dist .into_iter() - .map(|requirement| normalize_requirement(requirement, workspace)) + .map(|requirement| normalize_requirement(requirement, root)) .collect::>()?; let actual: BTreeSet<_> = package .metadata .requires_dist .iter() .cloned() - .map(|requirement| normalize_requirement(requirement, workspace)) + .map(|requirement| normalize_requirement(requirement, root)) .collect::>()?; if expected != actual { @@ -1264,7 +1265,7 @@ impl Lock { group, requirements .into_iter() - .map(|requirement| normalize_requirement(requirement, workspace)) + .map(|requirement| normalize_requirement(requirement, root)) .collect::>()?, )) }) @@ -1280,7 +1281,7 @@ impl Lock { requirements .iter() .cloned() - .map(|requirement| normalize_requirement(requirement, workspace)) + .map(|requirement| normalize_requirement(requirement, root)) .collect::>()?, )) }) @@ -1465,23 +1466,23 @@ impl ResolverManifest { } /// Convert the manifest to a relative form using the given workspace. - pub fn relative_to(self, workspace: &Workspace) -> Result { + pub fn relative_to(self, root: &Path) -> Result { Ok(Self { members: self.members, requirements: self .requirements .into_iter() - .map(|requirement| requirement.relative_to(workspace.install_path())) + .map(|requirement| requirement.relative_to(root)) .collect::, _>>()?, constraints: self .constraints .into_iter() - .map(|requirement| requirement.relative_to(workspace.install_path())) + .map(|requirement| requirement.relative_to(root)) .collect::, _>>()?, overrides: self .overrides .into_iter() - .map(|requirement| requirement.relative_to(workspace.install_path())) + .map(|requirement| requirement.relative_to(root)) .collect::, _>>()?, dependency_groups: self .dependency_groups @@ -1491,7 +1492,7 @@ impl ResolverManifest { group, requirements .into_iter() - .map(|requirement| requirement.relative_to(workspace.install_path())) + .map(|requirement| requirement.relative_to(root)) .collect::, _>>()?, )) }) @@ -3874,10 +3875,7 @@ fn normalize_url(mut url: Url) -> UrlString { /// 2. Ensures that the lock and install paths are appropriately framed with respect to the /// current [`Workspace`]. /// 3. Removes the `origin` field, which is only used in `requirements.txt`. -fn normalize_requirement( - requirement: Requirement, - workspace: &Workspace, -) -> Result { +fn normalize_requirement(requirement: Requirement, root: &Path) -> Result { match requirement.source { RequirementSource::Git { mut repository, @@ -3919,8 +3917,7 @@ fn normalize_requirement( ext, url: _, } => { - let install_path = - uv_fs::normalize_path_buf(workspace.install_path().join(&install_path)); + let install_path = uv_fs::normalize_path_buf(root.join(&install_path)); let url = VerbatimUrl::from_absolute_path(&install_path) .map_err(LockErrorKind::RequirementVerbatimUrl)?; @@ -3943,8 +3940,7 @@ fn normalize_requirement( r#virtual, url: _, } => { - let install_path = - uv_fs::normalize_path_buf(workspace.install_path().join(&install_path)); + let install_path = uv_fs::normalize_path_buf(root.join(&install_path)); let url = VerbatimUrl::from_absolute_path(&install_path) .map_err(LockErrorKind::RequirementVerbatimUrl)?; diff --git a/crates/uv-workspace/src/workspace.rs b/crates/uv-workspace/src/workspace.rs index 180755c4396d..19b6d74bf7fd 100644 --- a/crates/uv-workspace/src/workspace.rs +++ b/crates/uv-workspace/src/workspace.rs @@ -278,7 +278,7 @@ impl Workspace { .any(|member| *member.root() == self.install_path) } - /// Returns the set of requirements that include all packages in the workspace. + /// Returns the set of all workspace members. pub fn members_requirements(&self) -> impl Iterator + '_ { self.packages.values().filter_map(|member| { let url = VerbatimUrl::from_absolute_path(&member.root) @@ -309,7 +309,7 @@ impl Workspace { }) } - /// Returns the set of requirements that include all packages in the workspace. + /// Returns the set of all workspace member dependency groups. pub fn group_requirements(&self) -> impl Iterator + '_ { self.packages.values().filter_map(|member| { let url = VerbatimUrl::from_absolute_path(&member.root) diff --git a/crates/uv/src/commands/project/add.rs b/crates/uv/src/commands/project/add.rs index 520d44c822f6..d06b98f1082c 100644 --- a/crates/uv/src/commands/project/add.rs +++ b/crates/uv/src/commands/project/add.rs @@ -43,8 +43,9 @@ use crate::commands::pip::loggers::{ use crate::commands::pip::operations::Modifications; use crate::commands::project::install_target::InstallTarget; use crate::commands::project::lock::LockMode; +use crate::commands::project::lock_target::LockTarget; use crate::commands::project::{ - init_script_python_requirement, lock, ProjectError, ProjectInterpreter, ScriptInterpreter, + init_script_python_requirement, ProjectError, ProjectInterpreter, ScriptInterpreter, }; use crate::commands::reporters::{PythonDownloadReporter, ResolverReporter}; use crate::commands::{diagnostics, project, ExitStatus}; @@ -611,7 +612,7 @@ pub(crate) async fn add( let project_root = project.root().to_path_buf(); let workspace_root = project.workspace().install_path().clone(); let existing_pyproject_toml = project.pyproject_toml().as_ref().to_vec(); - let existing_uv_lock = lock::read_bytes(project.workspace()).await?; + let existing_uv_lock = LockTarget::from(project.workspace()).read_bytes().await?; // Update the `pypackage.toml` in-memory. let project = project @@ -715,7 +716,7 @@ async fn lock_and_sync( let mut lock = project::lock::do_safe_lock( mode, - project.workspace(), + project.workspace().into(), settings.into(), bounds, &state, @@ -834,7 +835,7 @@ async fn lock_and_sync( // the addition of the minimum version specifiers. lock = project::lock::do_safe_lock( mode, - project.workspace(), + project.workspace().into(), settings.into(), bounds, &state, diff --git a/crates/uv/src/commands/project/export.rs b/crates/uv/src/commands/project/export.rs index 6700da4860a0..eb1d0ebe3bb8 100644 --- a/crates/uv/src/commands/project/export.rs +++ b/crates/uv/src/commands/project/export.rs @@ -137,7 +137,7 @@ pub(crate) async fn export( // Lock the project. let lock = match do_safe_lock( mode, - project.workspace(), + project.workspace().into(), settings.as_ref(), LowerBound::Warn, &state, diff --git a/crates/uv/src/commands/project/lock.rs b/crates/uv/src/commands/project/lock.rs index 044f82bdd006..5f8f579b2f82 100644 --- a/crates/uv/src/commands/project/lock.rs +++ b/crates/uv/src/commands/project/lock.rs @@ -8,17 +8,11 @@ use owo_colors::OwoColorize; use rustc_hash::{FxBuildHasher, FxHashMap}; use tracing::debug; -use crate::commands::pip::loggers::{DefaultResolveLogger, ResolveLogger, SummaryResolveLogger}; -use crate::commands::project::{find_requires_python, ProjectError, ProjectInterpreter}; -use crate::commands::reporters::ResolverReporter; -use crate::commands::{diagnostics, pip, ExitStatus}; -use crate::printer::Printer; -use crate::settings::{ResolverSettings, ResolverSettingsRef}; use uv_cache::Cache; use uv_client::{Connectivity, FlatIndexClient, RegistryClientBuilder}; use uv_configuration::{ - Concurrency, Constraints, ExtrasSpecification, LowerBound, PreviewMode, Reinstall, - SourceStrategy, TrustedHost, Upgrade, + Concurrency, Constraints, ExtrasSpecification, LowerBound, PreviewMode, Reinstall, TrustedHost, + Upgrade, }; use uv_dispatch::{BuildDispatch, SharedState}; use uv_distribution::DistributionDatabase; @@ -29,20 +23,26 @@ use uv_distribution_types::{ use uv_git::ResolvedRepositoryReference; use uv_normalize::{GroupName, PackageName}; use uv_pep440::Version; -use uv_pep508::RequirementOrigin; -use uv_pypi_types::{Requirement, SupportedEnvironments, VerbatimParsedUrl}; +use uv_pypi_types::{Conflicts, Requirement, SupportedEnvironments}; use uv_python::{Interpreter, PythonDownloads, PythonEnvironment, PythonPreference, PythonRequest}; use uv_requirements::upgrade::{read_lock_requirements, LockedRequirements}; use uv_requirements::ExtrasResolver; use uv_resolver::{ - FlatIndex, InMemoryIndex, Lock, LockVersion, Options, OptionsBuilder, PythonRequirement, - RequiresPython, ResolverEnvironment, ResolverManifest, SatisfiesResult, UniversalMarker, - VERSION, + FlatIndex, InMemoryIndex, Lock, Options, OptionsBuilder, PythonRequirement, RequiresPython, + ResolverEnvironment, ResolverManifest, SatisfiesResult, UniversalMarker, }; use uv_settings::PythonInstallMirrors; use uv_types::{BuildContext, BuildIsolation, EmptyInstalledPackages, HashStrategy}; use uv_warnings::{warn_user, warn_user_once}; -use uv_workspace::{DiscoveryOptions, Workspace}; +use uv_workspace::{DiscoveryOptions, Workspace, WorkspaceMember}; + +use crate::commands::pip::loggers::{DefaultResolveLogger, ResolveLogger, SummaryResolveLogger}; +use crate::commands::project::lock_target::LockTarget; +use crate::commands::project::{ProjectError, ProjectInterpreter}; +use crate::commands::reporters::ResolverReporter; +use crate::commands::{diagnostics, pip, ExitStatus}; +use crate::printer::Printer; +use crate::settings::{ResolverSettings, ResolverSettingsRef}; /// The result of running a lock operation. #[derive(Debug, Clone)] @@ -98,7 +98,6 @@ pub(crate) async fn lock( let mode = if frozen { LockMode::Frozen } else { - // Find an interpreter for the project interpreter = ProjectInterpreter::discover( &workspace, project_dir, @@ -131,7 +130,7 @@ pub(crate) async fn lock( // Perform the lock operation. match do_safe_lock( mode, - &workspace, + (&workspace).into(), settings.as_ref(), LowerBound::Warn, &state, @@ -191,7 +190,7 @@ pub(super) enum LockMode<'env> { #[allow(clippy::fn_params_excessive_bools)] pub(super) async fn do_safe_lock( mode: LockMode<'_>, - workspace: &Workspace, + target: LockTarget<'_>, settings: ResolverSettingsRef<'_>, bounds: LowerBound, state: &SharedState, @@ -207,20 +206,22 @@ pub(super) async fn do_safe_lock( match mode { LockMode::Frozen => { // Read the existing lockfile, but don't attempt to lock the project. - let existing = read(workspace) + let existing = target + .read() .await? .ok_or_else(|| ProjectError::MissingLockfile)?; Ok(LockResult::Unchanged(existing)) } LockMode::Locked(interpreter) => { // Read the existing lockfile. - let existing = read(workspace) + let existing = target + .read() .await? .ok_or_else(|| ProjectError::MissingLockfile)?; // Perform the lock operation, but don't write the lockfile to disk. let result = do_lock( - workspace, + target, interpreter, Some(existing), settings, @@ -246,7 +247,7 @@ pub(super) async fn do_safe_lock( } LockMode::Write(interpreter) | LockMode::DryRun(interpreter) => { // Read the existing lockfile. - let existing = match read(workspace).await { + let existing = match target.read().await { Ok(Some(existing)) => Some(existing), Ok(None) => None, Err(ProjectError::Lock(err)) => { @@ -260,7 +261,7 @@ pub(super) async fn do_safe_lock( // Perform the lock operation. let result = do_lock( - workspace, + target, interpreter, existing, settings, @@ -280,7 +281,7 @@ pub(super) async fn do_safe_lock( // If the lockfile changed, write it to disk. if !matches!(mode, LockMode::DryRun(_)) { if let LockResult::Changed(_, lock) = &result { - commit(lock, workspace).await?; + target.commit(lock).await?; } } @@ -291,7 +292,7 @@ pub(super) async fn do_safe_lock( /// Lock the project requirements into a lockfile. async fn do_lock( - workspace: &Workspace, + target: LockTarget<'_>, interpreter: &Interpreter, existing_lock: Option, settings: ResolverSettingsRef<'_>, @@ -328,42 +329,32 @@ async fn do_lock( } = settings; // Collect the requirements, etc. - let requirements = workspace.requirements(); - let overrides = workspace.overrides(); - let constraints = workspace.constraints(); - let dependency_groups = workspace.dependency_groups()?; + let members = target.members(); + let packages = target.packages(); + let requirements = target.requirements(); + let overrides = target.overrides(); + let constraints = target.constraints(); + let dependency_groups = target.dependency_groups()?; let source_trees = vec![]; // If necessary, lower the overrides and constraints. - let requirements = lower(requirements, workspace, index_locations, sources)?; - let overrides = lower(overrides, workspace, index_locations, sources)?; - let constraints = lower(constraints, workspace, index_locations, sources)?; + let requirements = target.lower(requirements, index_locations, sources)?; + let overrides = target.lower(overrides, index_locations, sources)?; + let constraints = target.lower(constraints, index_locations, sources)?; let dependency_groups = dependency_groups .into_iter() .map(|(name, requirements)| { - let requirements = lower(requirements, workspace, index_locations, sources)?; + let requirements = target.lower(requirements, index_locations, sources)?; Ok((name, requirements)) }) .collect::, ProjectError>>()?; - // Collect the list of members. - let members = { - let mut members = workspace.packages().keys().cloned().collect::>(); - members.sort(); - - // If this is a non-virtual project with a single member, we can omit it from the lockfile. - // If any members are added or removed, it will inherently mismatch. If the member is - // renamed, it will also mismatch. - if members.len() == 1 && !workspace.is_non_project() { - members.clear(); - } - - members - }; + // Collect the conflicts. + let conflicts = target.conflicts(); // Collect the list of supported environments. let environments = { - let environments = workspace.environments(); + let environments = target.environments(); // Ensure that the environments are disjoint. if let Some(environments) = &environments { @@ -399,7 +390,7 @@ async fn do_lock( // 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); + let requires_python = target.requires_python(); let requires_python = if let Some(requires_python) = requires_python { if requires_python.is_unbounded() { @@ -527,12 +518,14 @@ async fn do_lock( let existing_lock = if let Some(existing_lock) = existing_lock { match ValidatedLock::validate( existing_lock, - workspace, + target.install_path(), + packages, &members, &requirements, &dependency_groups, &constraints, &overrides, + &conflicts, environments, dependency_metadata, interpreter, @@ -584,7 +577,7 @@ async fn do_lock( // If an existing lockfile exists, build up a set of preferences. let LockedRequirements { preferences, git } = versions_lock - .map(|lock| read_lock_requirements(lock, workspace.install_path(), upgrade)) + .map(|lock| read_lock_requirements(lock, target.install_path(), upgrade)) .transpose()? .unwrap_or_default(); @@ -633,11 +626,11 @@ async fn do_lock( let resolution = pip::operations::resolve( ExtrasResolver::new(&hasher, state.index(), database) .with_reporter(ResolverReporter::from(printer)) - .resolve(workspace.members_requirements()) + .resolve(target.members_requirements()) .await .map_err(|err| ProjectError::Operation(err.into()))? .into_iter() - .chain(workspace.group_requirements()) + .chain(target.group_requirements()) .chain(requirements.iter().cloned()) .chain( dependency_groups @@ -659,7 +652,7 @@ async fn do_lock( source_trees, // The root is always null in workspaces, it "depends on" the projects None, - Some(workspace.packages().keys().cloned().collect()), + Some(packages.keys().cloned().collect()), &extras, preferences, EmptyInstalledPackages, @@ -669,7 +662,7 @@ async fn do_lock( None, resolver_env, python_requirement, - workspace.conflicts(), + conflicts, &client, &flat_index, state.index(), @@ -695,12 +688,12 @@ async fn do_lock( dependency_groups, dependency_metadata.values().cloned(), ) - .relative_to(workspace)?; + .relative_to(target.install_path())?; let previous = existing_lock.map(ValidatedLock::into_lock); - let lock = Lock::from_resolution(&resolution, workspace.install_path())? + let lock = Lock::from_resolution(&resolution, target.install_path())? .with_manifest(manifest) - .with_conflicts(workspace.conflicts()) + .with_conflicts(target.conflicts()) .with_supported_environments( environments .cloned() @@ -731,12 +724,14 @@ impl ValidatedLock { /// Validate a [`Lock`] against the workspace requirements. async fn validate( lock: Lock, - workspace: &Workspace, + install_path: &Path, + packages: &BTreeMap, members: &[PackageName], requirements: &[Requirement], dependency_groups: &BTreeMap>, constraints: &[Requirement], overrides: &[Requirement], + conflicts: &Conflicts, environments: Option<&SupportedEnvironments>, dependency_metadata: &DependencyMetadata, interpreter: &Interpreter, @@ -855,10 +850,10 @@ impl ValidatedLock { } // If the conflicting group config has changed, we have to perform a clean resolution. - if &workspace.conflicts() != lock.conflicts() { + if conflicts != lock.conflicts() { debug!( "Ignoring existing lockfile due to change in conflicting groups: `{:?}` vs. `{:?}`", - workspace.conflicts(), + conflicts, lock.conflicts(), ); return Ok(Self::Versions(lock)); @@ -880,7 +875,8 @@ impl ValidatedLock { // Determine whether the lockfile satisfies the workspace requirements. match lock .satisfies( - workspace, + install_path, + packages, members, requirements, constraints, @@ -1010,62 +1006,6 @@ impl ValidatedLock { } } -/// Write the lockfile to disk. -async fn commit(lock: &Lock, workspace: &Workspace) -> Result<(), ProjectError> { - let encoded = lock.to_toml()?; - fs_err::tokio::write(workspace.install_path().join("uv.lock"), encoded).await?; - Ok(()) -} - -/// Read the lockfile from the workspace. -/// -/// Returns `Ok(None)` if the lockfile does not exist. -pub(crate) async fn read(workspace: &Workspace) -> Result, ProjectError> { - match fs_err::tokio::read_to_string(&workspace.install_path().join("uv.lock")).await { - Ok(encoded) => { - match toml::from_str::(&encoded) { - Ok(lock) => { - // If the lockfile uses an unsupported version, raise an error. - if lock.version() != VERSION { - return Err(ProjectError::UnsupportedLockVersion( - VERSION, - lock.version(), - )); - } - Ok(Some(lock)) - } - Err(err) => { - // If we failed to parse the lockfile, determine whether it's a supported - // version. - if let Ok(lock) = toml::from_str::(&encoded) { - if lock.version() != VERSION { - return Err(ProjectError::UnparsableLockVersion( - VERSION, - lock.version(), - err, - )); - } - } - Err(ProjectError::UvLockParse(err)) - } - } - } - Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None), - Err(err) => Err(err.into()), - } -} - -/// Read the lockfile from the workspace as bytes. -/// -/// Returns `Ok(None)` if the lockfile does not exist. -pub(crate) async fn read_bytes(workspace: &Workspace) -> Result>, ProjectError> { - match fs_err::tokio::read(&workspace.install_path().join("uv.lock")).await { - Ok(encoded) => Ok(Some(encoded)), - Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None), - Err(err) => Err(err.into()), - } -} - /// Reports on the versions that were upgraded in the new lockfile. /// /// Returns `true` if any upgrades were reported. @@ -1160,36 +1100,3 @@ fn report_upgrades( Ok(updated) } - -/// Lower a set of requirements, relative to the workspace root. -fn lower( - requirements: Vec>, - workspace: &Workspace, - locations: &IndexLocations, - sources: SourceStrategy, -) -> Result, uv_distribution::MetadataError> { - let name = workspace - .pyproject_toml() - .project - .as_ref() - .map(|project| project.name.clone()); - - // We model these as `build-requires`, since, like build requirements, it doesn't define extras - // or dependency groups. - let metadata = uv_distribution::BuildRequires::from_workspace( - uv_pypi_types::BuildRequires { - name, - requires_dist: requirements, - }, - workspace, - locations, - sources, - LowerBound::Warn, - )?; - - Ok(metadata - .requires_dist - .into_iter() - .map(|requirement| requirement.with_origin(RequirementOrigin::Workspace)) - .collect::>()) -} diff --git a/crates/uv/src/commands/project/lock_target.rs b/crates/uv/src/commands/project/lock_target.rs new file mode 100644 index 000000000000..6b1dee8271e8 --- /dev/null +++ b/crates/uv/src/commands/project/lock_target.rs @@ -0,0 +1,228 @@ +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; + +use uv_configuration::{LowerBound, SourceStrategy}; +use uv_distribution_types::IndexLocations; +use uv_normalize::{GroupName, PackageName}; +use uv_pep508::RequirementOrigin; +use uv_pypi_types::{Conflicts, Requirement, SupportedEnvironments, VerbatimParsedUrl}; +use uv_resolver::{Lock, LockVersion, RequiresPython, VERSION}; +use uv_workspace::dependency_groups::DependencyGroupError; +use uv_workspace::{Workspace, WorkspaceMember}; + +use crate::commands::project::{find_requires_python, ProjectError}; + +/// A target that can be resolved into a lockfile. +#[derive(Debug, Copy, Clone)] +pub(crate) enum LockTarget<'lock> { + Workspace(&'lock Workspace), +} + +impl<'lock> From<&'lock Workspace> for LockTarget<'lock> { + fn from(workspace: &'lock Workspace) -> Self { + Self::Workspace(workspace) + } +} + +impl<'lock> LockTarget<'lock> { + /// Return the set of requirements that are attached to the target directly, as opposed to being + /// attached to any members within the target. + pub(crate) fn requirements(self) -> Vec> { + match self { + Self::Workspace(workspace) => workspace.requirements(), + } + } + + /// Returns the set of overrides for the [`LockTarget`]. + pub(crate) fn overrides(self) -> Vec> { + match self { + Self::Workspace(workspace) => workspace.overrides(), + } + } + + /// Returns the set of constraints for the [`LockTarget`]. + pub(crate) fn constraints(self) -> Vec> { + match self { + Self::Workspace(workspace) => workspace.constraints(), + } + } + + /// Return the dependency groups that are attached to the target directly, as opposed to being + /// attached to any members within the target. + pub(crate) fn dependency_groups( + self, + ) -> Result< + BTreeMap>>, + DependencyGroupError, + > { + match self { + Self::Workspace(workspace) => workspace.dependency_groups(), + } + } + + /// Returns the set of all members within the target. + pub(crate) fn members_requirements(self) -> impl Iterator + 'lock { + match self { + Self::Workspace(workspace) => workspace.members_requirements(), + } + } + + /// Returns the set of all dependency groups within the target. + pub(crate) fn group_requirements(self) -> impl Iterator + 'lock { + match self { + Self::Workspace(workspace) => workspace.group_requirements(), + } + } + + /// Return the list of members to include in the [`Lock`]. + pub(crate) fn members(self) -> Vec { + match self { + Self::Workspace(workspace) => { + let mut members = workspace.packages().keys().cloned().collect::>(); + members.sort(); + + // If this is a non-virtual project with a single member, we can omit it from the lockfile. + // If any members are added or removed, it will inherently mismatch. If the member is + // renamed, it will also mismatch. + if members.len() == 1 && !workspace.is_non_project() { + members.clear(); + } + + members + } + } + } + + /// Return the list of packages. + pub(crate) fn packages(self) -> &'lock BTreeMap { + match self { + Self::Workspace(workspace) => workspace.packages(), + } + } + + /// Returns the set of supported environments for the [`LockTarget`]. + pub(crate) fn environments(self) -> Option<&'lock SupportedEnvironments> { + match self { + Self::Workspace(workspace) => workspace.environments(), + } + } + + /// Returns the set of conflicts for the [`LockTarget`]. + pub(crate) fn conflicts(self) -> Conflicts { + match self { + Self::Workspace(workspace) => workspace.conflicts(), + } + } + + /// Return the `Requires-Python` bound for the [`LockTarget`]. + pub(crate) fn requires_python(self) -> Option { + match self { + Self::Workspace(workspace) => find_requires_python(workspace), + } + } + + /// Return the path to the lock root. + pub(crate) fn install_path(self) -> &'lock Path { + match self { + Self::Workspace(workspace) => workspace.install_path(), + } + } + + /// Return the path to the lockfile. + pub(crate) fn lock_path(self) -> PathBuf { + match self { + Self::Workspace(workspace) => workspace.install_path().join("uv.lock"), + } + } + + /// Read the lockfile from the workspace. + /// + /// Returns `Ok(None)` if the lockfile does not exist. + pub(crate) async fn read(self) -> Result, ProjectError> { + match fs_err::tokio::read_to_string(self.lock_path()).await { + Ok(encoded) => { + match toml::from_str::(&encoded) { + Ok(lock) => { + // If the lockfile uses an unsupported version, raise an error. + if lock.version() != VERSION { + return Err(ProjectError::UnsupportedLockVersion( + VERSION, + lock.version(), + )); + } + Ok(Some(lock)) + } + Err(err) => { + // If we failed to parse the lockfile, determine whether it's a supported + // version. + if let Ok(lock) = toml::from_str::(&encoded) { + if lock.version() != VERSION { + return Err(ProjectError::UnparsableLockVersion( + VERSION, + lock.version(), + err, + )); + } + } + Err(ProjectError::UvLockParse(err)) + } + } + } + Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(err) => Err(err.into()), + } + } + + /// Read the lockfile from the workspace as bytes. + pub(crate) async fn read_bytes(self) -> Result>, ProjectError> { + match fs_err::tokio::read(self.lock_path()).await { + Ok(encoded) => Ok(Some(encoded)), + Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(err) => Err(err.into()), + } + } + + /// Write the lockfile to disk. + pub(crate) async fn commit(self, lock: &Lock) -> Result<(), ProjectError> { + let encoded = lock.to_toml()?; + fs_err::tokio::write(self.lock_path(), encoded).await?; + Ok(()) + } + + /// Lower the requirements for the [`LockTarget`], relative to the target root. + pub(crate) fn lower( + self, + requirements: Vec>, + locations: &IndexLocations, + sources: SourceStrategy, + ) -> Result, uv_distribution::MetadataError> { + match self { + Self::Workspace(workspace) => { + let name = workspace + .pyproject_toml() + .project + .as_ref() + .map(|project| project.name.clone()); + + // We model these as `build-requires`, since, like build requirements, it doesn't define extras + // or dependency groups. + let metadata = uv_distribution::BuildRequires::from_workspace( + uv_pypi_types::BuildRequires { + name, + requires_dist: requirements, + }, + workspace, + locations, + sources, + LowerBound::Warn, + )?; + + Ok(metadata + .requires_dist + .into_iter() + .map(|requirement| requirement.with_origin(RequirementOrigin::Workspace)) + .collect::>()) + } + } + } +} diff --git a/crates/uv/src/commands/project/mod.rs b/crates/uv/src/commands/project/mod.rs index 4869aaf38c04..5cb85f3a452d 100644 --- a/crates/uv/src/commands/project/mod.rs +++ b/crates/uv/src/commands/project/mod.rs @@ -55,6 +55,7 @@ pub(crate) mod export; pub(crate) mod init; mod install_target; pub(crate) mod lock; +mod lock_target; pub(crate) mod remove; pub(crate) mod run; pub(crate) mod sync; diff --git a/crates/uv/src/commands/project/remove.rs b/crates/uv/src/commands/project/remove.rs index b032487c2b16..5a669b74c3cf 100644 --- a/crates/uv/src/commands/project/remove.rs +++ b/crates/uv/src/commands/project/remove.rs @@ -222,7 +222,7 @@ pub(crate) async fn remove( // Lock and sync the environment, if necessary. let lock = match project::lock::do_safe_lock( mode, - project.workspace(), + project.workspace().into(), settings.as_ref().into(), LowerBound::Allow, &state, diff --git a/crates/uv/src/commands/project/run.rs b/crates/uv/src/commands/project/run.rs index 9fb3ae476ba4..04f6cd9ac4b5 100644 --- a/crates/uv/src/commands/project/run.rs +++ b/crates/uv/src/commands/project/run.rs @@ -45,6 +45,7 @@ use crate::commands::pip::operations::Modifications; use crate::commands::project::environment::CachedEnvironment; use crate::commands::project::install_target::InstallTarget; use crate::commands::project::lock::LockMode; +use crate::commands::project::lock_target::LockTarget; use crate::commands::project::{ default_dependency_groups, validate_project_requires_python, DependencyGroupsTarget, EnvironmentSpecification, ProjectError, ScriptInterpreter, WorkspacePython, @@ -583,7 +584,8 @@ pub(crate) async fn run( // If we're not syncing, we should still attempt to respect the locked preferences // in any `--with` requirements. if !isolated && !requirements.is_empty() { - lock = project::lock::read(project.workspace()) + lock = LockTarget::from(project.workspace()) + .read() .await .ok() .flatten() @@ -621,7 +623,7 @@ pub(crate) async fn run( let result = match project::lock::do_safe_lock( mode, - project.workspace(), + project.workspace().into(), settings.as_ref().into(), LowerBound::Allow, &state, diff --git a/crates/uv/src/commands/project/sync.rs b/crates/uv/src/commands/project/sync.rs index a87860c1f142..78a3595c3ff6 100644 --- a/crates/uv/src/commands/project/sync.rs +++ b/crates/uv/src/commands/project/sync.rs @@ -147,7 +147,7 @@ pub(crate) async fn sync( let lock = match do_safe_lock( mode, - project.workspace(), + project.workspace().into(), settings.as_ref().into(), LowerBound::Warn, &state, diff --git a/crates/uv/src/commands/project/tree.rs b/crates/uv/src/commands/project/tree.rs index 34226bf93c07..1994862fa7fb 100644 --- a/crates/uv/src/commands/project/tree.rs +++ b/crates/uv/src/commands/project/tree.rs @@ -111,7 +111,7 @@ pub(crate) async fn tree( // Update the lockfile, if necessary. let lock = match do_safe_lock( mode, - &workspace, + (&workspace).into(), settings.as_ref(), LowerBound::Allow, &state,