diff --git a/crates/uv-cli/src/lib.rs b/crates/uv-cli/src/lib.rs index 731c9074236c..ddaac9a9758b 100644 --- a/crates/uv-cli/src/lib.rs +++ b/crates/uv-cli/src/lib.rs @@ -3089,6 +3089,13 @@ pub struct LockArgs { #[arg(long, conflicts_with = "check_exists", conflicts_with = "check")] pub dry_run: bool, + /// Lock the specified Python script, rather than the current project. + /// + /// If provided, uv will lock the script based on its inline metadata table, in adherence + /// with PEP 723. + #[arg(long)] + pub script: Option, + #[command(flatten)] pub resolver: ResolverArgs, diff --git a/crates/uv-configuration/src/dev.rs b/crates/uv-configuration/src/dev.rs index 80ae4f0640e4..cce967ba1f89 100644 --- a/crates/uv-configuration/src/dev.rs +++ b/crates/uv-configuration/src/dev.rs @@ -316,7 +316,7 @@ impl From for DevGroupsSpecification { /// The manifest of `dependency-groups` to include, taking into account the user-provided /// [`DevGroupsSpecification`] and the project-specific default groups. -#[derive(Debug, Clone)] +#[derive(Debug, Default, Clone)] pub struct DevGroupsManifest { /// The specification for the development dependencies. pub(crate) spec: DevGroupsSpecification, @@ -347,7 +347,7 @@ impl DevGroupsManifest { } /// Returns `true` if the group was enabled by default. - pub fn default(&self, group: &GroupName) -> bool { + pub fn is_default(&self, group: &GroupName) -> bool { if self.spec.contains(group) { // If the group was explicitly requested, then it wasn't enabled by default. false diff --git a/crates/uv-resolver/src/lock/mod.rs b/crates/uv-resolver/src/lock/mod.rs index 91aefe78d4f4..84bfe81a9cd9 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 map; mod requirements_txt; @@ -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-resolver/src/lock/target.rs b/crates/uv-resolver/src/lock/target.rs index dcbae78e6df9..ca25281375f1 100644 --- a/crates/uv-resolver/src/lock/target.rs +++ b/crates/uv-resolver/src/lock/target.rs @@ -3,6 +3,7 @@ use petgraph::Graph; use rustc_hash::{FxBuildHasher, FxHashMap, FxHashSet}; use std::collections::hash_map::Entry; use std::collections::{BTreeMap, VecDeque}; +use std::path::Path; use uv_configuration::{BuildOptions, DevGroupsManifest, ExtrasSpecification, InstallOptions}; use uv_distribution_types::{Edge, Node, Resolution, ResolvedDist}; use uv_normalize::{ExtraName, GroupName, PackageName, DEV_DEPENDENCIES}; @@ -34,15 +35,22 @@ pub enum InstallTarget<'env> { workspace: &'env Workspace, lock: &'env Lock, }, + /// A PEP 723 script. + Script { + path: &'env Path, + dependencies: Option<&'env [uv_pep508::Requirement]>, + lock: &'env Lock, + }, } impl<'env> InstallTarget<'env> { /// Return the [`Workspace`] of the target. - pub fn workspace(&self) -> &'env Workspace { + pub fn install_path(&self) -> &'env Path { match self { - Self::Project { workspace, .. } => workspace, - Self::Workspace { workspace, .. } => workspace, - Self::NonProjectWorkspace { workspace, .. } => workspace, + Self::Project { workspace, .. } => workspace.install_path(), + Self::Workspace { workspace, .. } => workspace.install_path(), + Self::NonProjectWorkspace { workspace, .. } => workspace.install_path(), + Self::Script { path, .. } => path.parent().unwrap(), } } @@ -52,6 +60,7 @@ impl<'env> InstallTarget<'env> { Self::Project { lock, .. } => lock, Self::Workspace { lock, .. } => lock, Self::NonProjectWorkspace { lock, .. } => lock, + Self::Script { lock, .. } => lock, } } @@ -59,7 +68,9 @@ impl<'env> InstallTarget<'env> { pub fn packages(&self) -> impl Iterator { match self { Self::Project { name, .. } => Either::Right(Either::Left(std::iter::once(*name))), - Self::NonProjectWorkspace { lock, .. } => Either::Left(lock.members().iter()), + Self::NonProjectWorkspace { lock, .. } => { + Either::Left(Either::Left(lock.members().iter())) + } Self::Workspace { lock, .. } => { // Identify the workspace members. // @@ -70,9 +81,24 @@ impl<'env> InstallTarget<'env> { lock.root().into_iter().map(|package| &package.id.name), )) } else { - Either::Left(lock.members().iter()) + Either::Left(Either::Left(lock.members().iter())) } } + Self::Script { .. } => Either::Left(Either::Right(std::iter::empty())), + } + } + + /// Return the [`InstallTarget`] requirements. + /// + /// Returns dependencies that apply to the workspace root, but not any of its members. As such, + /// only returns a non-empty iterator for scripts, which include packages directly (unlike + /// workspaces, in which each member has its own dependencies). + pub fn requirements(&self) -> Option<&[uv_pep508::Requirement]> { + match self { + Self::Project { .. } => None, + Self::Workspace { .. } => None, + Self::NonProjectWorkspace { .. } => None, + Self::Script { dependencies, .. } => dependencies.as_deref(), } } @@ -135,6 +161,7 @@ impl<'env> InstallTarget<'env> { Ok(map) } + Self::Script { .. } => Ok(BTreeMap::default()), } } @@ -144,6 +171,7 @@ impl<'env> InstallTarget<'env> { Self::Project { name, .. } => Some(name), Self::Workspace { .. } => None, Self::NonProjectWorkspace { .. } => None, + Self::Script { .. } => None, } } @@ -273,6 +301,63 @@ impl<'env> InstallTarget<'env> { } } + // Add any dependencies that are exclusive to the workspace root (e.g., dependencies in + // scripts). + for dependency in self.requirements().into_iter().flatten() { + if !dependency.marker.evaluate(marker_env, &[]) { + continue; + } + + let root_name = &dependency.name; + let dist = self + .lock() + .find_by_markers(root_name, marker_env) + .map_err(|_| LockErrorKind::MultipleRootPackages { + name: root_name.clone(), + })? + .ok_or_else(|| LockErrorKind::MissingRootPackage { + name: root_name.clone(), + })?; + + // Add the package to the graph. + let index = match inverse.entry(&dist.id) { + Entry::Vacant(entry) => { + let index = petgraph.add_node(self.package_to_node( + dist, + tags, + build_options, + install_options, + )?); + entry.insert(index); + index + } + Entry::Occupied(entry) => { + // Critically, if the package is already in the graph, then it's a workspace + // member. If it was omitted due to, e.g., `--only-dev`, but is itself + // referenced as a development dependency, then we need to re-enable it. + let index = *entry.get(); + let node = &mut petgraph[index]; + if !dev.prod() { + *node = self.package_to_node(dist, tags, build_options, install_options)?; + } + index + } + }; + + // Add the edge. + petgraph.add_edge(root, index, Edge::Prod(dependency.marker)); + + // Push its dependencies on the queue. + if seen.insert((&dist.id, None)) { + queue.push_back((dist, None)); + } + for extra in &dependency.extras { + if seen.insert((&dist.id, Some(extra))) { + queue.push_back((dist, Some(extra))); + } + } + } + // Add any dependency groups that are exclusive to the workspace root (e.g., dev // dependencies in (legacy) non-project workspace roots). let groups = self @@ -419,7 +504,7 @@ impl<'env> InstallTarget<'env> { build_options: &BuildOptions, ) -> Result { let dist = package.to_dist( - self.workspace().install_path(), + self.install_path(), TagPolicy::Required(tags), build_options, )?; @@ -436,7 +521,7 @@ impl<'env> InstallTarget<'env> { /// Create a non-installable [`Node`] from a [`Package`]. fn non_installable_node(&self, package: &Package, tags: &Tags) -> Result { let dist = package.to_dist( - self.workspace().install_path(), + self.install_path(), TagPolicy::Preferred(tags), &BuildOptions::default(), )?; diff --git a/crates/uv-scripts/src/lib.rs b/crates/uv-scripts/src/lib.rs index 8b46007e0be8..f457e1e66c4c 100644 --- a/crates/uv-scripts/src/lib.rs +++ b/crates/uv-scripts/src/lib.rs @@ -54,6 +54,14 @@ impl Pep723Item { Self::Remote(_) => None, } } + + /// Return the PEP 723 script, if any. + pub fn as_script(&self) -> Option<&Pep723Script> { + match self { + Self::Script(script) => Some(script), + _ => None, + } + } } /// A PEP 723 script, including its [`Pep723Metadata`]. diff --git a/crates/uv/src/commands/project/add.rs b/crates/uv/src/commands/project/add.rs index c0bb80796880..e3b2611518bc 100644 --- a/crates/uv/src/commands/project/add.rs +++ b/crates/uv/src/commands/project/add.rs @@ -45,8 +45,9 @@ use crate::commands::pip::loggers::{ }; use crate::commands::pip::operations::Modifications; use crate::commands::project::lock::LockMode; +use crate::commands::project::target::LockTarget; use crate::commands::project::{ - init_script_python_requirement, lock, validate_script_requires_python, ProjectError, + init_script_python_requirement, validate_script_requires_python, ProjectError, ProjectInterpreter, ScriptPython, }; use crate::commands::reporters::{PythonDownloadReporter, ResolverReporter}; @@ -633,7 +634,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 @@ -737,7 +738,7 @@ async fn lock_and_sync( let mut lock = project::lock::do_safe_lock( mode, - project.workspace(), + project.workspace().into(), settings.into(), bounds, &state, @@ -856,7 +857,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/environment.rs b/crates/uv/src/commands/project/environment.rs index e4dd28ec2c01..70fe04ecc473 100644 --- a/crates/uv/src/commands/project/environment.rs +++ b/crates/uv/src/commands/project/environment.rs @@ -9,10 +9,13 @@ use crate::settings::ResolverInstallerSettings; use uv_cache::{Cache, CacheBucket}; use uv_cache_key::{cache_digest, hash_digest}; use uv_client::Connectivity; -use uv_configuration::{Concurrency, PreviewMode, TrustedHost}; +use uv_configuration::{ + Concurrency, DevGroupsManifest, ExtrasSpecification, InstallOptions, PreviewMode, TrustedHost, +}; use uv_dispatch::SharedState; use uv_distribution_types::{Name, Resolution}; use uv_python::{Interpreter, PythonEnvironment}; +use uv_resolver::InstallTarget; /// A [`PythonEnvironment`] stored in the cache. #[derive(Debug)] @@ -140,6 +143,138 @@ impl CachedEnvironment { Ok(Self(PythonEnvironment::from_root(root, cache)?)) } + /// Get or create an [`CachedEnvironment`] based on a given set of requirements and a base + /// interpreter. + pub(crate) async fn from_lock( + target: InstallTarget<'_>, + extras: &ExtrasSpecification, + dev: &DevGroupsManifest, + install_options: InstallOptions, + settings: &ResolverInstallerSettings, + interpreter: Interpreter, + state: &SharedState, + install: Box, + installer_metadata: bool, + connectivity: Connectivity, + concurrency: Concurrency, + native_tls: bool, + allow_insecure_host: &[TrustedHost], + cache: &Cache, + printer: Printer, + preview: PreviewMode, + ) -> Result { + let ResolverInstallerSettings { + index_locations: _, + index_strategy: _, + keyring_provider: _, + resolution: _, + prerelease: _, + fork_strategy: _, + dependency_metadata: _, + config_setting: _, + no_build_isolation: _, + no_build_isolation_package: _, + exclude_newer: _, + link_mode: _, + compile_bytecode: _, + sources: _, + upgrade: _, + reinstall: _, + build_options, + } = settings; + + // When caching, always use the base interpreter, rather than that of the virtual + // environment. + let interpreter = if let Some(interpreter) = interpreter.to_base_interpreter(cache)? { + debug!( + "Caching via base interpreter: `{}`", + interpreter.sys_executable().display() + ); + interpreter + } else { + debug!( + "Caching via interpreter: `{}`", + interpreter.sys_executable().display() + ); + interpreter + }; + + // Determine the tags, markers, and interpreter to use for resolution. + let tags = interpreter.tags()?; + let marker_env = interpreter.resolver_marker_environment(); + + // Read the lockfile. + let resolution = target.to_resolution( + &marker_env, + tags, + extras, + dev, + build_options, + &install_options, + )?; + + // Hash the resolution by hashing the generated lockfile. + // TODO(charlie): If the resolution contains any mutable metadata (like a path or URL + // dependency), skip this step. + let resolution_hash = { + let mut distributions = resolution.distributions().collect::>(); + distributions.sort_unstable_by_key(|dist| dist.name()); + hash_digest(&distributions) + }; + + // Hash the interpreter based on its path. + // TODO(charlie): Come up with a robust hash for the interpreter. + let interpreter_hash = cache_digest(&interpreter.sys_executable()); + + // Search in the content-addressed cache. + let cache_entry = cache.entry(CacheBucket::Environments, interpreter_hash, resolution_hash); + + if cache.refresh().is_none() { + if let Ok(root) = fs_err::read_link(cache_entry.path()) { + if let Ok(environment) = PythonEnvironment::from_root(root, cache) { + return Ok(Self(environment)); + } + } + } + + // Create the environment in the cache, then relocate it to its content-addressed location. + let temp_dir = cache.venv_dir()?; + let venv = uv_virtualenv::create_venv( + temp_dir.path(), + interpreter, + uv_virtualenv::Prompt::None, + false, + false, + true, + false, + )?; + + sync_environment( + venv, + &resolution, + settings.as_ref().into(), + state, + install, + installer_metadata, + connectivity, + concurrency, + native_tls, + allow_insecure_host, + cache, + printer, + preview, + ) + .await?; + + // Now that the environment is complete, sync it to its content-addressed location. + let id = cache + .persist(temp_dir.into_path(), cache_entry.path()) + .await?; + let root = cache.archive(&id); + + Ok(Self(PythonEnvironment::from_root(root, cache)?)) + } + /// Convert the [`CachedEnvironment`] into an [`Interpreter`]. pub(crate) fn into_interpreter(self) -> Interpreter { self.0.into_interpreter() diff --git a/crates/uv/src/commands/project/export.rs b/crates/uv/src/commands/project/export.rs index 4368af06cf84..0fbb5d53328a 100644 --- a/crates/uv/src/commands/project/export.rs +++ b/crates/uv/src/commands/project/export.rs @@ -135,7 +135,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/init.rs b/crates/uv/src/commands/project/init.rs index 8974dcc5214e..49198f25ce85 100644 --- a/crates/uv/src/commands/project/init.rs +++ b/crates/uv/src/commands/project/init.rs @@ -37,7 +37,7 @@ use crate::printer::Printer; #[allow(clippy::single_match_else, clippy::fn_params_excessive_bools)] pub(crate) async fn init( project_dir: &Path, - explicit_path: Option, + script: Option, name: Option, package: bool, init_kind: InitKind, @@ -64,7 +64,7 @@ pub(crate) async fn init( } match init_kind { InitKind::Script => { - let Some(path) = explicit_path.as_deref() else { + let Some(path) = script.as_deref() else { anyhow::bail!("Script initialization requires a file path") }; @@ -96,7 +96,7 @@ pub(crate) async fn init( } InitKind::Project(project_kind) => { // Default to the current directory if a path was not provided. - let path = match explicit_path { + let path = match script { None => project_dir.to_path_buf(), Some(ref path) => std::path::absolute(path)?, }; @@ -156,7 +156,7 @@ pub(crate) async fn init( } } - match explicit_path { + match script { // Initialized a project in the current directory. None => { writeln!(printer.stderr(), "Initialized project `{}`", name.cyan())?; diff --git a/crates/uv/src/commands/project/lock.rs b/crates/uv/src/commands/project/lock.rs index cf36c8e6349e..46bfc31e857f 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::target::LockTarget; +use crate::commands::project::{ProjectError, ProjectInterpreter, ScriptPython}; +use crate::commands::reporters::{PythonDownloadReporter, 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_client::{BaseClientBuilder, 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,22 @@ 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_python::{Interpreter, PythonDownloads, PythonEnvironment, PythonPreference, PythonRequest}; +use uv_pypi_types::{Conflicts, Requirement, SupportedEnvironments}; +use uv_python::{ + EnvironmentPreference, Interpreter, PythonDownloads, PythonEnvironment, PythonInstallation, + 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_scripts::{Pep723Item, Pep723Script}; 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)] @@ -80,6 +82,7 @@ pub(crate) async fn lock( python: Option, install_mirrors: PythonInstallMirrors, settings: ResolverSettings, + script: Option, python_preference: PythonPreference, python_downloads: PythonDownloads, connectivity: Connectivity, @@ -91,31 +94,73 @@ pub(crate) async fn lock( printer: Printer, preview: PreviewMode, ) -> anyhow::Result { + // Initialize any output reporters. + let download_reporter = PythonDownloadReporter::single(printer); + // Find the project requirements. - let workspace = Workspace::discover(project_dir, &DiscoveryOptions::default()).await?; + let workspace; + let target = if let Some(script) = script.as_ref() { + LockTarget::Script(script) + } else { + workspace = Workspace::discover(project_dir, &DiscoveryOptions::default()).await?; + LockTarget::Workspace(&workspace) + }; // Determine the lock mode. let interpreter; let mode = if frozen { LockMode::Frozen } else { - // Find an interpreter for the project - interpreter = ProjectInterpreter::discover( - &workspace, - project_dir, - python.as_deref().map(PythonRequest::parse), - python_preference, - python_downloads, - connectivity, - native_tls, - allow_insecure_host, - install_mirrors, - no_config, - cache, - printer, - ) - .await? - .into_interpreter(); + interpreter = match target { + LockTarget::Workspace(workspace) => ProjectInterpreter::discover( + workspace, + project_dir, + python.as_deref().map(PythonRequest::parse), + python_preference, + python_downloads, + connectivity, + native_tls, + allow_insecure_host, + install_mirrors, + no_config, + cache, + printer, + ) + .await? + .into_interpreter(), + LockTarget::Script(script) => { + let ScriptPython { + source, + python_request, + requires_python, + } = ScriptPython::from_request( + python.as_deref().map(PythonRequest::parse), + None, + &Pep723Item::Script(script.clone()), + no_config, + ) + .await?; + + let client_builder = BaseClientBuilder::new() + .connectivity(connectivity) + .native_tls(native_tls) + .allow_insecure_host(allow_insecure_host.to_vec()); + + PythonInstallation::find_or_download( + python_request.as_ref(), + EnvironmentPreference::Any, + python_preference, + python_downloads, + &client_builder, + cache, + Some(&download_reporter), + install_mirrors.python_install_mirror.as_deref(), + install_mirrors.pypy_install_mirror.as_deref(), + ) + .await? + .into_interpreter() + } + }; if locked { LockMode::Locked(&interpreter) @@ -132,7 +177,7 @@ pub(crate) async fn lock( // Perform the lock operation. match do_safe_lock( mode, - &workspace, + target, settings.as_ref(), LowerBound::Warn, &state, @@ -192,7 +237,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 +253,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 +294,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 +308,7 @@ pub(super) async fn do_safe_lock( // Perform the lock operation. let result = do_lock( - workspace, + target, interpreter, existing, settings, @@ -281,7 +328,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 +339,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 +376,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.non_project_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 +429,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 +557,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 +615,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 +664,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 +685,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 +695,7 @@ async fn do_lock( None, resolver_env, python_requirement, - workspace.conflicts(), + conflicts, &client, &flat_index, state.index(), @@ -681,12 +720,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 +756,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 +881,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 +906,8 @@ impl ValidatedLock { // Determine whether the lockfile satisfies the workspace requirements. match lock .satisfies( - workspace, + install_path, + packages, members, requirements, constraints, @@ -987,62 +1029,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 +1123,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/mod.rs b/crates/uv/src/commands/project/mod.rs index d77cee35e797..f84a7abe4269 100644 --- a/crates/uv/src/commands/project/mod.rs +++ b/crates/uv/src/commands/project/mod.rs @@ -57,6 +57,7 @@ pub(crate) mod lock; pub(crate) mod remove; pub(crate) mod run; pub(crate) mod sync; +mod target; pub(crate) mod tree; #[derive(thiserror::Error, Debug)] @@ -265,7 +266,7 @@ impl std::fmt::Display for ConflictError { self.conflicts .iter() .map(|conflict| match conflict { - ConflictPackage::Group(ref group) if self.dev.default(group) => + ConflictPackage::Group(ref group) if self.dev.is_default(group) => format!("`{group}` (enabled by default)"), ConflictPackage::Group(ref group) => format!("`{group}`"), ConflictPackage::Extra(..) => unreachable!(), @@ -284,7 +285,7 @@ impl std::fmt::Display for ConflictError { .map(|(i, conflict)| { let conflict = match conflict { ConflictPackage::Extra(ref extra) => format!("extra `{extra}`"), - ConflictPackage::Group(ref group) if self.dev.default(group) => { + ConflictPackage::Group(ref group) if self.dev.is_default(group) => { format!("group `{group}` (enabled by default)") } ConflictPackage::Group(ref group) => format!("group `{group}`"), diff --git a/crates/uv/src/commands/project/remove.rs b/crates/uv/src/commands/project/remove.rs index effa0752e205..cdec2b7c1e47 100644 --- a/crates/uv/src/commands/project/remove.rs +++ b/crates/uv/src/commands/project/remove.rs @@ -221,7 +221,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 cb51e7c8264f..0bdec693290c 100644 --- a/crates/uv/src/commands/project/run.rs +++ b/crates/uv/src/commands/project/run.rs @@ -17,8 +17,8 @@ use uv_cache::Cache; use uv_cli::ExternalCommand; use uv_client::{BaseClientBuilder, Connectivity}; use uv_configuration::{ - Concurrency, DevGroupsSpecification, EditableMode, ExtrasSpecification, GroupsSpecification, - InstallOptions, LowerBound, PreviewMode, SourceStrategy, TrustedHost, + Concurrency, DevGroupsManifest, DevGroupsSpecification, EditableMode, ExtrasSpecification, + GroupsSpecification, InstallOptions, LowerBound, PreviewMode, SourceStrategy, TrustedHost, }; use uv_dispatch::SharedState; use uv_distribution::LoweredRequirement; @@ -44,6 +44,7 @@ use crate::commands::pip::loggers::{ use crate::commands::pip::operations::Modifications; use crate::commands::project::environment::CachedEnvironment; use crate::commands::project::lock::LockMode; +use crate::commands::project::target::LockTarget; use crate::commands::project::{ default_dependency_groups, validate_requires_python, validate_script_requires_python, DependencyGroupsTarget, EnvironmentSpecification, ProjectError, ScriptPython, WorkspacePython, @@ -227,109 +228,65 @@ pub(crate) async fn run( } } - // Determine the working directory for the script. - let script_dir = match &script { - Pep723Item::Script(script) => std::path::absolute(&script.path)? - .parent() - .expect("script path has no parent") - .to_owned(), - Pep723Item::Stdin(..) | Pep723Item::Remote(..) => std::env::current_dir()?, - }; - let script = script.into_metadata(); - - // Install the script requirements, if necessary. Otherwise, use an isolated environment. - if let Some(dependencies) = script.dependencies { - // Collect any `tool.uv.index` from the script. - let empty = Vec::default(); - let script_indexes = match settings.sources { - SourceStrategy::Enabled => script - .tool - .as_ref() - .and_then(|tool| tool.uv.as_ref()) - .and_then(|uv| uv.top_level.index.as_deref()) - .unwrap_or(&empty), - SourceStrategy::Disabled => &empty, - }; + // If a lockfile already exists, lock the script. + if let Some(target) = script + .as_script() + .map(LockTarget::from) + .filter(LockTarget::exists) + { + debug!( + "Found existing lockfile for script at: {}", + target.lock_path().display() + ); - // Collect any `tool.uv.sources` from the script. - let empty = BTreeMap::default(); - let script_sources = match settings.sources { - SourceStrategy::Enabled => script - .tool - .as_ref() - .and_then(|tool| tool.uv.as_ref()) - .and_then(|uv| uv.sources.as_ref()) - .unwrap_or(&empty), - SourceStrategy::Disabled => &empty, + // Determine the lock mode. + let mode = if frozen { + LockMode::Frozen + } else if locked { + LockMode::Locked(&interpreter) + } else { + LockMode::Write(&interpreter) }; - let requirements = dependencies - .into_iter() - .flat_map(|requirement| { - LoweredRequirement::from_non_workspace_requirement( - requirement, - script_dir.as_ref(), - script_sources, - script_indexes, - &settings.index_locations, - LowerBound::Allow, - ) - .map_ok(LoweredRequirement::into_inner) - }) - .collect::>()?; - let constraints = script - .tool - .as_ref() - .and_then(|tool| tool.uv.as_ref()) - .and_then(|uv| uv.constraint_dependencies.as_ref()) - .into_iter() - .flatten() - .cloned() - .flat_map(|requirement| { - LoweredRequirement::from_non_workspace_requirement( - requirement, - script_dir.as_ref(), - script_sources, - script_indexes, - &settings.index_locations, - LowerBound::Allow, - ) - .map_ok(LoweredRequirement::into_inner) - }) - .collect::, _>>()?; - let overrides = script - .tool - .as_ref() - .and_then(|tool| tool.uv.as_ref()) - .and_then(|uv| uv.override_dependencies.as_ref()) - .into_iter() - .flatten() - .cloned() - .flat_map(|requirement| { - LoweredRequirement::from_non_workspace_requirement( - requirement, - script_dir.as_ref(), - script_sources, - script_indexes, - &settings.index_locations, - LowerBound::Allow, - ) - .map_ok(LoweredRequirement::into_inner) - }) - .collect::, _>>()?; - - let spec = - RequirementsSpecification::from_overrides(requirements, constraints, overrides); - let result = CachedEnvironment::get_or_create( - EnvironmentSpecification::from(spec), - interpreter, - &settings, + // Generate a lockfile. + let lock = project::lock::do_safe_lock( + mode, + target, + settings.as_ref().into(), + LowerBound::Allow, &state, if show_resolution { Box::new(DefaultResolveLogger) } else { Box::new(SummaryResolveLogger) }, + connectivity, + concurrency, + native_tls, + allow_insecure_host, + cache, + printer, + preview, + ) + .await? + .into_lock(); + + let dependencies = script.metadata().dependencies.as_deref(); + + let target = InstallTarget::Script { + path: target.install_path(), + dependencies, + lock: &lock, + }; + + let result = CachedEnvironment::from_lock( + target, + &ExtrasSpecification::default(), + &DevGroupsManifest::default(), + InstallOptions::default(), + &settings, + interpreter, + &state, if show_resolution { Box::new(DefaultInstallLogger) } else { @@ -358,19 +315,153 @@ pub(crate) async fn run( Some(environment.into_interpreter()) } else { - // Create a virtual environment. - temp_dir = cache.venv_dir()?; - let environment = uv_virtualenv::create_venv( - temp_dir.path(), - interpreter, - uv_virtualenv::Prompt::None, - false, - false, - false, - false, - )?; + // Determine the working directory for the script. + let script_dir = match &script { + Pep723Item::Script(script) => std::path::absolute(&script.path)? + .parent() + .expect("script path has no parent") + .to_owned(), + Pep723Item::Stdin(..) | Pep723Item::Remote(..) => std::env::current_dir()?, + }; - Some(environment.into_interpreter()) + let script = script.into_metadata(); + + // Install the script requirements, if necessary. Otherwise, use an isolated environment. + if let Some(dependencies) = script.dependencies { + // Collect any `tool.uv.index` from the script. + let empty = Vec::default(); + let script_indexes = match settings.sources { + SourceStrategy::Enabled => script + .tool + .as_ref() + .and_then(|tool| tool.uv.as_ref()) + .and_then(|uv| uv.top_level.index.as_deref()) + .unwrap_or(&empty), + SourceStrategy::Disabled => &empty, + }; + + // Collect any `tool.uv.sources` from the script. + let empty = BTreeMap::default(); + let script_sources = match settings.sources { + SourceStrategy::Enabled => script + .tool + .as_ref() + .and_then(|tool| tool.uv.as_ref()) + .and_then(|uv| uv.sources.as_ref()) + .unwrap_or(&empty), + SourceStrategy::Disabled => &empty, + }; + + let requirements = dependencies + .into_iter() + .flat_map(|requirement| { + LoweredRequirement::from_non_workspace_requirement( + requirement, + script_dir.as_ref(), + script_sources, + script_indexes, + &settings.index_locations, + LowerBound::Allow, + ) + .map_ok(LoweredRequirement::into_inner) + }) + .collect::>()?; + let constraints = script + .tool + .as_ref() + .and_then(|tool| tool.uv.as_ref()) + .and_then(|uv| uv.constraint_dependencies.as_ref()) + .into_iter() + .flatten() + .cloned() + .flat_map(|requirement| { + LoweredRequirement::from_non_workspace_requirement( + requirement, + script_dir.as_ref(), + script_sources, + script_indexes, + &settings.index_locations, + LowerBound::Allow, + ) + .map_ok(LoweredRequirement::into_inner) + }) + .collect::, _>>()?; + let overrides = script + .tool + .as_ref() + .and_then(|tool| tool.uv.as_ref()) + .and_then(|uv| uv.override_dependencies.as_ref()) + .into_iter() + .flatten() + .cloned() + .flat_map(|requirement| { + LoweredRequirement::from_non_workspace_requirement( + requirement, + script_dir.as_ref(), + script_sources, + script_indexes, + &settings.index_locations, + LowerBound::Allow, + ) + .map_ok(LoweredRequirement::into_inner) + }) + .collect::, _>>()?; + + let spec = + RequirementsSpecification::from_overrides(requirements, constraints, overrides); + + let result = CachedEnvironment::get_or_create( + EnvironmentSpecification::from(spec), + interpreter, + &settings, + &state, + if show_resolution { + Box::new(DefaultResolveLogger) + } else { + Box::new(SummaryResolveLogger) + }, + if show_resolution { + Box::new(DefaultInstallLogger) + } else { + Box::new(SummaryInstallLogger) + }, + installer_metadata, + connectivity, + concurrency, + native_tls, + allow_insecure_host, + cache, + printer, + preview, + ) + .await; + + let environment = match result { + Ok(resolution) => resolution, + Err(ProjectError::Operation(err)) => { + return diagnostics::OperationDiagnostic::with_context("script") + .report(err) + .map_or(Ok(ExitStatus::Failure), |err| Err(err.into())) + } + Err(err) => return Err(err.into()), + }; + + Some(environment.into_interpreter()) + } else { + // Create a virtual environment. + temp_dir = cache.venv_dir()?; + let environment = uv_virtualenv::create_venv( + temp_dir.path(), + interpreter, + uv_virtualenv::Prompt::None, + false, + false, + false, + false, + )?; + + Some(environment.into_interpreter()) + } } } else { None @@ -611,7 +702,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() @@ -649,7 +741,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, @@ -722,15 +814,13 @@ pub(crate) async fn run( } }; - let install_options = InstallOptions::default(); - match project::sync::do_sync( target, &venv, &extras, &dev.with_defaults(defaults), editable, - install_options, + InstallOptions::default(), Modifications::Sufficient, settings.as_ref().into(), if show_resolution { diff --git a/crates/uv/src/commands/project/sync.rs b/crates/uv/src/commands/project/sync.rs index f1e385dc7b26..6a1b85997086 100644 --- a/crates/uv/src/commands/project/sync.rs +++ b/crates/uv/src/commands/project/sync.rs @@ -34,6 +34,7 @@ use crate::commands::pip::loggers::{DefaultInstallLogger, DefaultResolveLogger, use crate::commands::pip::operations; use crate::commands::pip::operations::Modifications; use crate::commands::project::lock::{do_safe_lock, LockMode}; +use crate::commands::project::target::LockTarget; use crate::commands::project::{ default_dependency_groups, detect_conflicts, DependencyGroupsTarget, ProjectError, }; @@ -146,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, @@ -367,7 +368,7 @@ pub(super) async fn do_sync( } // Populate credentials from the workspace. - store_credentials_from_workspace(target.workspace()); + // store_credentials_from_workspace(target.into()); // Initialize the registry client. let client = RegistryClientBuilder::new(cache.clone()) @@ -525,8 +526,9 @@ fn apply_editable_mode(resolution: Resolution, editable: EditableMode) -> Resolu /// /// These credentials can come from any of `tool.uv.sources`, `tool.uv.dev-dependencies`, /// `project.dependencies`, and `project.optional-dependencies`. -fn store_credentials_from_workspace(workspace: &Workspace) { - for member in workspace.packages().values() { +fn store_credentials_from_workspace(target: LockTarget<'_>) { + // TODO(charlie): I think this misses dependencies in the non-project root. + for member in target.packages().values() { // Iterate over the `tool.uv.sources`. for source in member .pyproject_toml() diff --git a/crates/uv/src/commands/project/target.rs b/crates/uv/src/commands/project/target.rs new file mode 100644 index 000000000000..d97862a1ec1c --- /dev/null +++ b/crates/uv/src/commands/project/target.rs @@ -0,0 +1,335 @@ +use itertools::{Either, Itertools}; +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; +use uv_configuration::{LowerBound, SourceStrategy}; +use uv_distribution::LoweredRequirement; +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_scripts::Pep723Script; +use uv_workspace::dependency_groups::DependencyGroupError; +use uv_workspace::{Workspace, WorkspaceMember}; + +use crate::commands::project::{find_requires_python, ProjectError}; + +#[derive(Debug, Copy, Clone)] +pub(crate) enum LockTarget<'lock> { + Workspace(&'lock Workspace), + Script(&'lock Pep723Script), +} + +impl<'lock> From<&'lock Workspace> for LockTarget<'lock> { + fn from(workspace: &'lock Workspace) -> Self { + LockTarget::Workspace(workspace) + } +} + +impl<'lock> From<&'lock Pep723Script> for LockTarget<'lock> { + fn from(script: &'lock Pep723Script) -> Self { + LockTarget::Script(script) + } +} + +impl<'lock> LockTarget<'lock> { + /// Return the path to the lockfile. + pub(crate) fn lock_path(&self) -> PathBuf { + match self { + // `uv.lock` + LockTarget::Workspace(workspace) => workspace.install_path().join("uv.lock"), + // `script.py.lock` + LockTarget::Script(script) => { + let mut file_name = match script.path.file_name() { + Some(f) => f.to_os_string(), + None => panic!("Script path has no file name"), + }; + file_name.push(".lock"); + script.path.with_file_name(file_name) + } + } + } + + /// 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(()) + } + + /// Returns `true` if the lockfile exists. + pub(crate) fn exists(&self) -> bool { + self.lock_path().exists() + } + + pub(crate) fn non_project_requirements( + &self, + ) -> Result>, DependencyGroupError> { + match self { + LockTarget::Workspace(workspace) => workspace.non_project_requirements(), + LockTarget::Script(script) => { + Ok(script.metadata.dependencies.clone().unwrap_or_default()) + } + } + } + + pub(crate) fn overrides(&self) -> Vec> { + match self { + LockTarget::Workspace(workspace) => workspace.overrides(), + LockTarget::Script(script) => script + .metadata + .tool + .as_ref() + .and_then(|tool| tool.uv.as_ref()) + .and_then(|uv| uv.override_dependencies.as_ref()) + .into_iter() + .flatten() + .cloned() + .collect(), + } + } + + pub(crate) fn constraints(&self) -> Vec> { + match self { + LockTarget::Workspace(workspace) => workspace.constraints(), + LockTarget::Script(script) => script + .metadata + .tool + .as_ref() + .and_then(|tool| tool.uv.as_ref()) + .and_then(|uv| uv.constraint_dependencies.as_ref()) + .into_iter() + .flatten() + .cloned() + .collect(), + } + } + + pub(crate) fn lower( + &self, + requirements: Vec>, + index_locations: &IndexLocations, + sources: SourceStrategy, + ) -> Result, uv_distribution::MetadataError> { + match self { + LockTarget::Workspace(workspace) => { + lower(requirements, workspace, index_locations, sources) + } + LockTarget::Script(script) => { + // Collect any `tool.uv.index` from the script. + let empty = Vec::default(); + let indexes = match sources { + SourceStrategy::Enabled => script + .metadata + .tool + .as_ref() + .and_then(|tool| tool.uv.as_ref()) + .and_then(|uv| uv.top_level.index.as_deref()) + .unwrap_or(&empty), + SourceStrategy::Disabled => &empty, + }; + + // Collect any `tool.uv.sources` from the script. + let empty = BTreeMap::default(); + let sources = match sources { + SourceStrategy::Enabled => script + .metadata + .tool + .as_ref() + .and_then(|tool| tool.uv.as_ref()) + .and_then(|uv| uv.sources.as_ref()) + .unwrap_or(&empty), + SourceStrategy::Disabled => &empty, + }; + + Ok(requirements + .into_iter() + .flat_map(|requirement| { + let requirement_name = requirement.name.clone(); + LoweredRequirement::from_non_workspace_requirement( + requirement, + script.path.parent().unwrap(), + sources, + indexes, + index_locations, + LowerBound::Allow, + ) + .map(move |requirement| match requirement { + Ok(requirement) => Ok(requirement.into_inner()), + Err(err) => Err(uv_distribution::MetadataError::LoweringError( + requirement_name.clone(), + Box::new(err), + )), + }) + }) + .collect::>()?) + } + } + } + + pub(crate) fn members(&self) -> Vec { + match self { + LockTarget::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 + } + LockTarget::Script(script) => Vec::new(), + } + } + + pub(crate) fn packages(&self) -> &BTreeMap { + match self { + LockTarget::Workspace(workspace) => workspace.packages(), + LockTarget::Script(_) => { + static EMPTY: BTreeMap = BTreeMap::new(); + &EMPTY + } + } + } + + pub(crate) fn environments(&self) -> Option<&SupportedEnvironments> { + match self { + LockTarget::Workspace(workspace) => workspace.environments(), + LockTarget::Script(script) => { + // TODO(charlie): Add support for environments in scripts. + None + } + } + } + + pub(crate) fn conflicts(&self) -> Conflicts { + match self { + LockTarget::Workspace(workspace) => workspace.conflicts(), + LockTarget::Script(_) => Conflicts::empty(), + } + } + + pub(crate) fn requires_python(&self) -> Option { + match self { + LockTarget::Workspace(workspace) => find_requires_python(workspace), + LockTarget::Script(script) => script + .metadata + .requires_python + .as_ref() + .map(RequiresPython::from_specifiers), + } + } + + pub(crate) fn members_requirements(&self) -> impl Iterator + '_ { + match self { + LockTarget::Workspace(workspace) => Either::Left(workspace.members_requirements()), + LockTarget::Script(script) => Either::Right( + script + .metadata + .dependencies + .iter() + .flatten() + .cloned() + .map(Requirement::from), + ), + } + } + + pub(crate) fn group_requirements(&self) -> impl Iterator + '_ { + match self { + LockTarget::Workspace(workspace) => Either::Left(workspace.group_requirements()), + LockTarget::Script(_) => Either::Right(std::iter::empty()), + } + } + + pub(crate) fn install_path(&self) -> &Path { + match self { + LockTarget::Workspace(workspace) => workspace.install_path(), + LockTarget::Script(script) => script.path.parent().unwrap(), + } + } +} + +/// 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/tree.rs b/crates/uv/src/commands/project/tree.rs index b62ef2b54801..d38d22891a47 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, diff --git a/crates/uv/src/lib.rs b/crates/uv/src/lib.rs index f34ef7a03a15..15af02cececf 100644 --- a/crates/uv/src/lib.rs +++ b/crates/uv/src/lib.rs @@ -183,6 +183,12 @@ async fn run(mut cli: Cli) -> Result { script: Some(script), .. }) = &**command + { + Pep723Script::read(&script).await?.map(Pep723Item::Script) + } else if let ProjectCommand::Lock(uv_cli::LockArgs { + script: Some(script), + .. + }) = &**command { Pep723Script::read(&script).await?.map(Pep723Item::Script) } else { @@ -1483,6 +1489,13 @@ async fn run_project( .combine(Refresh::from(args.settings.upgrade.clone())), ); + // Unwrap the script. + let script = script.map(|script| match script { + Pep723Item::Script(script) => script, + Pep723Item::Stdin(_) => unreachable!("`uv lock` does not support stdin"), + Pep723Item::Remote(_) => unreachable!("`uv lock` does not support remote files"), + }); + commands::lock( project_dir, args.locked, @@ -1491,6 +1504,7 @@ async fn run_project( args.python, args.install_mirrors, args.settings, + script, globals.python_preference, globals.python_downloads, globals.connectivity, diff --git a/crates/uv/src/settings.rs b/crates/uv/src/settings.rs index 2e677eeabf4a..761deae0c847 100644 --- a/crates/uv/src/settings.rs +++ b/crates/uv/src/settings.rs @@ -1008,6 +1008,7 @@ pub(crate) struct LockSettings { pub(crate) locked: bool, pub(crate) frozen: bool, pub(crate) dry_run: bool, + pub(crate) script: Option, pub(crate) python: Option, pub(crate) install_mirrors: PythonInstallMirrors, pub(crate) refresh: Refresh, @@ -1022,6 +1023,7 @@ impl LockSettings { check, check_exists, dry_run, + script, resolver, build, refresh, @@ -1037,6 +1039,7 @@ impl LockSettings { locked: check, frozen: check_exists, dry_run, + script, python: python.and_then(Maybe::into_option), refresh: Refresh::from(refresh), settings: ResolverSettings::combine(resolver_options(resolver, build), filesystem), diff --git a/docs/reference/cli.md b/docs/reference/cli.md index cf8110eec7f8..5090d483a9f4 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -2123,6 +2123,10 @@ uv lock [OPTIONS]
  • lowest-direct: Resolve the lowest compatible version of any direct dependencies, and the highest compatible version of any transitive dependencies
  • +
    --script script

    Lock the specified Python script, rather than the current project.

    + +

    If provided, uv will lock the script based on its inline metadata table, in adherence with PEP 723.

    +
    --upgrade, -U

    Allow package upgrades, ignoring pinned versions in any existing output file. Implies --refresh

    --upgrade-package, -P upgrade-package

    Allow upgrades for a specific package, ignoring pinned versions in any existing output file. Implies --refresh-package