diff --git a/crates/uv-resolver/src/lock/mod.rs b/crates/uv-resolver/src/lock/mod.rs index cac36838ff7a..752d997aea6f 100644 --- a/crates/uv-resolver/src/lock/mod.rs +++ b/crates/uv-resolver/src/lock/mod.rs @@ -49,7 +49,7 @@ use uv_pypi_types::{ }; use uv_types::{BuildContext, HashStrategy}; use uv_workspace::dependency_groups::DependencyGroupError; -use uv_workspace::Workspace; +use uv_workspace::WorkspaceMember; mod installable; mod map; @@ -879,7 +879,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], @@ -906,7 +907,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) @@ -919,7 +920,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 @@ -948,14 +949,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)); @@ -967,14 +968,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)); @@ -986,14 +987,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)); @@ -1034,7 +1035,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) @@ -1044,7 +1045,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"); @@ -1093,7 +1094,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`. @@ -1156,14 +1157,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 { @@ -1187,7 +1188,7 @@ impl Lock { group, requirements .into_iter() - .map(|requirement| normalize_requirement(requirement, workspace)) + .map(|requirement| normalize_requirement(requirement, root)) .collect::>()?, )) }) @@ -1203,7 +1204,7 @@ impl Lock { requirements .iter() .cloned() - .map(|requirement| normalize_requirement(requirement, workspace)) + .map(|requirement| normalize_requirement(requirement, root)) .collect::>()?, )) }) @@ -1368,23 +1369,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_metadata: self.dependency_metadata, }) @@ -3764,10 +3765,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, @@ -3809,8 +3807,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)?; @@ -3833,8 +3830,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/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 59c73e59241c..c48f650c21be 100644 --- a/crates/uv/src/commands/project/lock.rs +++ b/crates/uv/src/commands/project/lock.rs @@ -1,6 +1,6 @@ #![allow(clippy::single_match_else)] -use std::collections::BTreeSet; +use std::collections::{BTreeMap, BTreeSet}; use std::fmt::Write; use std::path::Path; @@ -8,11 +8,18 @@ use owo_colors::OwoColorize; use rustc_hash::{FxBuildHasher, FxHashMap}; use tracing::debug; +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}; 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; @@ -23,27 +30,18 @@ use uv_distribution_types::{ use uv_git::ResolvedRepositoryReference; use uv_normalize::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 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_workspace::{DiscoveryOptions, Workspace, WorkspaceMember}; /// The result of running a lock operation. #[derive(Debug, Clone)] @@ -99,7 +97,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, @@ -132,7 +129,7 @@ pub(crate) async fn lock( // Perform the lock operation. match do_safe_lock( mode, - &workspace, + (&workspace).into(), settings.as_ref(), LowerBound::Warn, &state, @@ -192,7 +189,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, @@ -208,20 +205,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, @@ -247,7 +246,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)) => { @@ -261,7 +260,7 @@ pub(super) async fn do_safe_lock( // Perform the lock operation. let result = do_lock( - workspace, + target, interpreter, existing, settings, @@ -281,7 +280,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?; } } @@ -292,7 +291,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<'_>, @@ -329,34 +328,24 @@ async fn do_lock( } = settings; // Collect the requirements, etc. - let requirements = workspace.non_project_requirements()?; - let overrides = workspace.overrides(); - let constraints = workspace.constraints(); + let members = target.members(); + let packages = target.packages(); + let requirements = target.requirements()?; + let overrides = target.overrides(); + let constraints = target.constraints(); 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)?; - - // 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(); - } + let requirements = target.lower(requirements, index_locations, sources)?; + let overrides = target.lower(overrides, index_locations, sources)?; + let constraints = target.lower(constraints, index_locations, sources)?; - 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 { @@ -392,7 +381,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() { @@ -520,11 +509,13 @@ 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, &constraints, &overrides, + &conflicts, environments, dependency_metadata, interpreter, @@ -576,7 +567,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(); @@ -625,11 +616,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()) .map(UnresolvedRequirementSpecification::from) .collect(), @@ -646,7 +637,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, @@ -656,7 +647,7 @@ async fn do_lock( None, resolver_env, python_requirement, - workspace.conflicts(), + conflicts, &client, &flat_index, state.index(), @@ -681,12 +672,12 @@ async fn do_lock( overrides, 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() @@ -717,11 +708,13 @@ 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], constraints: &[Requirement], overrides: &[Requirement], + conflicts: &Conflicts, environments: Option<&SupportedEnvironments>, dependency_metadata: &DependencyMetadata, interpreter: &Interpreter, @@ -840,10 +833,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)); @@ -865,7 +858,8 @@ impl ValidatedLock { // Determine whether the lockfile satisfies the workspace requirements. match lock .satisfies( - workspace, + install_path, + packages, members, requirements, constraints, @@ -987,62 +981,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. @@ -1137,36 +1075,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..6124cb79925e --- /dev/null +++ b/crates/uv/src/commands/project/lock_target.rs @@ -0,0 +1,217 @@ +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; + +use uv_configuration::{LowerBound, SourceStrategy}; +use uv_distribution_types::IndexLocations; +use uv_normalize::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> { + /// Returns any requirements that are exclusive to the workspace root, i.e., not included in + /// any of the workspace members. + pub(crate) fn requirements( + self, + ) -> Result>, DependencyGroupError> { + match self { + Self::Workspace(workspace) => workspace.non_project_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(), + } + } + + /// 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::>()) + } + } + } + + /// 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), + } + } + + /// Returns the set of requirements that include all packages in the workspace. + pub(crate) fn members_requirements(self) -> impl Iterator + 'lock { + match self { + Self::Workspace(workspace) => workspace.members_requirements(), + } + } + + /// Returns the set of requirements that include all packages in the workspace. + pub(crate) fn group_requirements(self) -> impl Iterator + 'lock { + match self { + Self::Workspace(workspace) => workspace.group_requirements(), + } + } + + /// 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. + 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(()) + } +} 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 c3eb80a03001..da546235b317 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,