diff --git a/crates/uv-cli/src/lib.rs b/crates/uv-cli/src/lib.rs index 3f9274bf1..1d5e4739a 100644 --- a/crates/uv-cli/src/lib.rs +++ b/crates/uv-cli/src/lib.rs @@ -3135,6 +3135,32 @@ pub struct SyncArgs { #[arg(long, conflicts_with = "all_packages")] pub package: Option, + /// Sync the environment for a Python script, rather than the current project. + /// + /// If provided, uv will sync the dependencies based on the script's inline metadata table, in + /// adherence with PEP 723. + #[arg( + long, + conflicts_with = "active", + conflicts_with = "all_packages", + conflicts_with = "package", + conflicts_with = "no_install_project", + conflicts_with = "no_install_workspace", + conflicts_with = "extra", + conflicts_with = "all_extras", + conflicts_with = "no_extra", + conflicts_with = "no_all_extras", + conflicts_with = "dev", + conflicts_with = "no_dev", + conflicts_with = "only_dev", + conflicts_with = "group", + conflicts_with = "no_group", + conflicts_with = "no_default_groups", + conflicts_with = "only_group", + conflicts_with = "all_groups" + )] + pub script: Option, + /// The Python interpreter to use for the project environment. /// /// By default, the first interpreter that meets the project's `requires-python` constraint is diff --git a/crates/uv-distribution/src/lib.rs b/crates/uv-distribution/src/lib.rs index 9cc5f0762..a91ed6ccf 100644 --- a/crates/uv-distribution/src/lib.rs +++ b/crates/uv-distribution/src/lib.rs @@ -3,8 +3,8 @@ pub use download::LocalWheel; pub use error::Error; pub use index::{BuiltWheelIndex, RegistryWheelIndex}; pub use metadata::{ - ArchiveMetadata, BuildRequires, FlatRequiresDist, LoweredRequirement, Metadata, MetadataError, - RequiresDist, + ArchiveMetadata, BuildRequires, FlatRequiresDist, LoweredRequirement, LoweringError, Metadata, + MetadataError, RequiresDist, }; pub use reporter::Reporter; pub use source::prune; diff --git a/crates/uv-distribution/src/metadata/mod.rs b/crates/uv-distribution/src/metadata/mod.rs index 1887c1e40..8585eff4f 100644 --- a/crates/uv-distribution/src/metadata/mod.rs +++ b/crates/uv-distribution/src/metadata/mod.rs @@ -13,7 +13,7 @@ use uv_workspace::WorkspaceError; pub use crate::metadata::build_requires::BuildRequires; pub use crate::metadata::lowering::LoweredRequirement; -use crate::metadata::lowering::LoweringError; +pub use crate::metadata::lowering::LoweringError; pub use crate::metadata::requires_dist::{FlatRequiresDist, RequiresDist}; mod build_requires; diff --git a/crates/uv/src/commands/project/mod.rs b/crates/uv/src/commands/project/mod.rs index 2074a7bab..1de0d82c8 100644 --- a/crates/uv/src/commands/project/mod.rs +++ b/crates/uv/src/commands/project/mod.rs @@ -1,7 +1,6 @@ use std::borrow::Cow; use std::collections::{BTreeMap, BTreeSet}; use std::fmt::Write; -use std::ops::Deref; use std::path::{Path, PathBuf}; use std::sync::Arc; @@ -14,10 +13,10 @@ use uv_cache_key::cache_digest; use uv_client::{BaseClientBuilder, Connectivity, FlatIndexClient, RegistryClientBuilder}; use uv_configuration::{ Concurrency, Constraints, DevGroupsManifest, DevGroupsSpecification, DryRun, - ExtrasSpecification, PreviewMode, Reinstall, TrustedHost, Upgrade, + ExtrasSpecification, PreviewMode, Reinstall, SourceStrategy, TrustedHost, Upgrade, }; use uv_dispatch::{BuildDispatch, SharedState}; -use uv_distribution::DistributionDatabase; +use uv_distribution::{DistributionDatabase, LoweredRequirement}; use uv_distribution_types::{ Index, Resolution, UnresolvedRequirement, UnresolvedRequirementSpecification, }; @@ -227,6 +226,9 @@ pub(crate) enum ProjectError { #[error(transparent)] Metadata(#[from] uv_distribution::MetadataError), + #[error(transparent)] + Lowering(#[from] uv_distribution::LoweringError), + #[error(transparent)] PyprojectMut(#[from] uv_workspace::pyproject_mut::Error), @@ -1224,7 +1226,7 @@ impl ProjectEnvironment { } } -impl Deref for ProjectEnvironment { +impl std::ops::Deref for ProjectEnvironment { type Target = PythonEnvironment; fn deref(&self) -> &Self::Target { @@ -1240,7 +1242,30 @@ impl Deref for ProjectEnvironment { /// The Python environment for a script. #[derive(Debug)] -struct ScriptEnvironment(PythonEnvironment); +enum ScriptEnvironment { + /// An existing [`PythonEnvironment`] was discovered, which satisfies the script's requirements. + Existing(PythonEnvironment), + /// An existing [`PythonEnvironment`] was discovered, but did not satisfy the script's + /// requirements, and so was replaced. + Replaced(PythonEnvironment), + /// A new [`PythonEnvironment`] was created for the script. + Created(PythonEnvironment), + /// An existing [`PythonEnvironment`] was discovered, but did not satisfy the script's + /// requirements. A new environment would've been created, but `--dry-run` mode is enabled; as + /// such, a temporary environment was created instead. + WouldReplace( + PathBuf, + PythonEnvironment, + #[allow(unused)] tempfile::TempDir, + ), + /// A new [`PythonEnvironment`] would've been created, but `--dry-run` mode is enabled; as such, + /// a temporary environment was created instead. + WouldCreate( + PathBuf, + PythonEnvironment, + #[allow(unused)] tempfile::TempDir, + ), +} impl ScriptEnvironment { /// Initialize a virtual environment for a PEP 723 script. @@ -1255,6 +1280,7 @@ impl ScriptEnvironment { install_mirrors: &PythonInstallMirrors, no_config: bool, cache: &Cache, + dry_run: DryRun, printer: Printer, ) -> Result { // Lock the script environment to avoid synchronization issues. @@ -1276,29 +1302,12 @@ impl ScriptEnvironment { .await? { // If we found an existing, compatible environment, use it. - ScriptInterpreter::Environment(environment) => Ok(Self(environment)), + ScriptInterpreter::Environment(environment) => Ok(Self::Existing(environment)), // Otherwise, create a virtual environment with the discovered interpreter. ScriptInterpreter::Interpreter(interpreter) => { let root = ScriptInterpreter::root(script, cache); - // Remove the existing virtual environment. - match fs_err::remove_dir_all(&root) { - Ok(()) => { - debug!( - "Removed virtual environment at: {}", - root.user_display().cyan() - ); - } - Err(err) if err.kind() == std::io::ErrorKind::NotFound => {} - Err(err) => return Err(err.into()), - }; - - debug!( - "Creating script environment at: {}", - root.user_display().cyan() - ); - // Determine a prompt for the environment, in order of preference: // // 1) The name of the script @@ -1310,6 +1319,43 @@ impl ScriptEnvironment { .map(uv_virtualenv::Prompt::Static) .unwrap_or(uv_virtualenv::Prompt::None); + // Under `--dry-run`, avoid modifying the environment. + if dry_run.enabled() { + let temp_dir = cache.venv_dir()?; + let environment = uv_virtualenv::create_venv( + temp_dir.path(), + interpreter, + prompt, + false, + false, + false, + false, + )?; + return Ok(if root.exists() { + Self::WouldReplace(root, environment, temp_dir) + } else { + Self::WouldCreate(root, environment, temp_dir) + }); + } + + // Remove the existing virtual environment. + let replaced = match fs_err::remove_dir_all(&root) { + Ok(()) => { + debug!( + "Removed virtual environment at: {}", + root.user_display().cyan() + ); + true + } + Err(err) if err.kind() == std::io::ErrorKind::NotFound => false, + Err(err) => return Err(err.into()), + }; + + debug!( + "Creating script environment at: {}", + root.user_display().cyan() + ); + let environment = uv_virtualenv::create_venv( &root, interpreter, @@ -1320,14 +1366,42 @@ impl ScriptEnvironment { false, )?; - Ok(Self(environment)) + Ok(if replaced { + Self::Replaced(environment) + } else { + Self::Created(environment) + }) } } } /// Convert the [`ScriptEnvironment`] into a [`PythonEnvironment`]. - pub(crate) fn into_environment(self) -> PythonEnvironment { - self.0 + /// + /// Returns an error if the environment was created in `--dry-run` mode, as dropping the + /// associated temporary directory could lead to errors downstream. + #[allow(clippy::result_large_err)] + pub(crate) fn into_environment(self) -> Result { + match self { + Self::Existing(environment) => Ok(environment), + Self::Replaced(environment) => Ok(environment), + Self::Created(environment) => Ok(environment), + Self::WouldReplace(..) => Err(ProjectError::DroppedEnvironment), + Self::WouldCreate(..) => Err(ProjectError::DroppedEnvironment), + } + } +} + +impl std::ops::Deref for ScriptEnvironment { + type Target = PythonEnvironment; + + fn deref(&self) -> &Self::Target { + match self { + Self::Existing(environment) => environment, + Self::Replaced(environment) => environment, + Self::Created(environment) => environment, + Self::WouldReplace(_, environment, _) => environment, + Self::WouldCreate(_, environment, _) => environment, + } } } @@ -1839,6 +1913,7 @@ pub(crate) async fn update_environment( native_tls: bool, allow_insecure_host: &[TrustedHost], cache: &Cache, + dry_run: DryRun, printer: Printer, preview: PreviewMode, ) -> Result { @@ -1954,7 +2029,6 @@ pub(crate) async fn update_environment( // optional on the downstream APIs. let build_constraints = Constraints::default(); let build_hasher = HashStrategy::default(); - let dry_run = DryRun::default(); let extras = ExtrasSpecification::default(); let groups = DevGroupsSpecification::default(); let hasher = HashStrategy::default(); @@ -2225,6 +2299,113 @@ pub(crate) fn detect_conflicts( Ok(()) } +/// Determine the [`RequirementsSpecification`] for a script. +#[allow(clippy::result_large_err)] +pub(crate) fn script_specification( + script: Pep723ItemRef<'_>, + settings: ResolverSettingsRef, +) -> Result, ProjectError> { + let Some(dependencies) = script.metadata().dependencies.as_ref() else { + return Ok(None); + }; + + // Determine the working directory for the script. + let script_dir = match &script { + Pep723ItemRef::Script(script) => std::path::absolute(&script.path)? + .parent() + .expect("script path has no parent") + .to_owned(), + Pep723ItemRef::Stdin(..) | Pep723ItemRef::Remote(..) => std::env::current_dir()?, + }; + + // Collect any `tool.uv.index` from the script. + let empty = Vec::default(); + let script_indexes = match settings.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 script_sources = match settings.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, + }; + + let requirements = dependencies + .iter() + .cloned() + .flat_map(|requirement| { + LoweredRequirement::from_non_workspace_requirement( + requirement, + script_dir.as_ref(), + script_sources, + script_indexes, + settings.index_locations, + ) + .map_ok(LoweredRequirement::into_inner) + }) + .collect::>()?; + let constraints = script + .metadata() + .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, + ) + .map_ok(LoweredRequirement::into_inner) + }) + .collect::, _>>()?; + let overrides = script + .metadata() + .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, + ) + .map_ok(LoweredRequirement::into_inner) + }) + .collect::, _>>()?; + + Ok(Some(RequirementsSpecification::from_overrides( + requirements, + constraints, + overrides, + ))) +} + /// Warn if the user provides (e.g.) an `--index-url` in a requirements file. fn warn_on_requirements_txt_setting( spec: &RequirementsSpecification, diff --git a/crates/uv/src/commands/project/run.rs b/crates/uv/src/commands/project/run.rs index 8ff7e96f0..46d3b6e3d 100644 --- a/crates/uv/src/commands/project/run.rs +++ b/crates/uv/src/commands/project/run.rs @@ -1,5 +1,4 @@ use std::borrow::Cow; -use std::collections::BTreeMap; use std::ffi::OsString; use std::fmt::Write; use std::io::Read; @@ -18,9 +17,8 @@ use uv_cli::ExternalCommand; use uv_client::{BaseClientBuilder, Connectivity}; use uv_configuration::{ Concurrency, DevGroupsSpecification, DryRun, EditableMode, ExtrasSpecification, InstallOptions, - PreviewMode, SourceStrategy, TrustedHost, + PreviewMode, TrustedHost, }; -use uv_distribution::LoweredRequirement; use uv_fs::which::is_executable; use uv_fs::{PythonExt, Simplified}; use uv_installer::{SatisfiesResult, SitePackages}; @@ -46,9 +44,10 @@ 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, update_environment, validate_project_requires_python, - DependencyGroupsTarget, EnvironmentSpecification, ProjectEnvironment, ProjectError, - ScriptEnvironment, ScriptInterpreter, UniversalState, WorkspacePython, + default_dependency_groups, script_specification, update_environment, + validate_project_requires_python, DependencyGroupsTarget, EnvironmentSpecification, + ProjectEnvironment, ProjectError, ScriptEnvironment, ScriptInterpreter, UniversalState, + WorkspacePython, }; use crate::commands::reporters::PythonDownloadReporter; use crate::commands::run::run_to_completion; @@ -211,10 +210,11 @@ pub(crate) async fn run( &install_mirrors, no_config, cache, + DryRun::Disabled, printer, ) .await? - .into_environment(); + .into_environment()?; // Determine the lock mode. let mode = if frozen { @@ -317,98 +317,8 @@ 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 metadata = script.metadata(); - // Install the script requirements, if necessary. Otherwise, use an isolated environment. - if let Some(dependencies) = metadata.dependencies.as_ref() { - // Collect any `tool.uv.index` from the script. - let empty = Vec::default(); - let script_indexes = match settings.sources { - SourceStrategy::Enabled => 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 script_sources = match settings.sources { - SourceStrategy::Enabled => metadata - .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 - .iter() - .cloned() - .flat_map(|requirement| { - LoweredRequirement::from_non_workspace_requirement( - requirement, - script_dir.as_ref(), - script_sources, - script_indexes, - &settings.index_locations, - ) - .map_ok(LoweredRequirement::into_inner) - }) - .collect::>()?; - let constraints = metadata - .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, - ) - .map_ok(LoweredRequirement::into_inner) - }) - .collect::, _>>()?; - let overrides = metadata - .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, - ) - .map_ok(LoweredRequirement::into_inner) - }) - .collect::, _>>()?; - - let spec = - RequirementsSpecification::from_overrides(requirements, constraints, overrides); - + if let Some(spec) = script_specification((&script).into(), settings.as_ref().into())? { let environment = ScriptEnvironment::get_or_init( (&script).into(), python.as_deref().map(PythonRequest::parse), @@ -420,10 +330,11 @@ pub(crate) async fn run( &install_mirrors, no_config, cache, + DryRun::Disabled, printer, ) .await? - .into_environment(); + .into_environment()?; match update_environment( environment, @@ -447,6 +358,7 @@ pub(crate) async fn run( native_tls, allow_insecure_host, cache, + DryRun::Disabled, printer, preview, ) diff --git a/crates/uv/src/commands/project/sync.rs b/crates/uv/src/commands/project/sync.rs index 47a4ded3c..4b1ec5043 100644 --- a/crates/uv/src/commands/project/sync.rs +++ b/crates/uv/src/commands/project/sync.rs @@ -1,4 +1,5 @@ use std::fmt::Write; +use std::ops::Deref; use std::path::Path; use std::sync::Arc; @@ -23,6 +24,7 @@ use uv_pep508::{MarkerTree, VersionOrUrl}; use uv_pypi_types::{ParsedArchiveUrl, ParsedGitUrl, ParsedUrl}; use uv_python::{PythonDownloads, PythonEnvironment, PythonPreference, PythonRequest}; use uv_resolver::{FlatIndex, Installable}; +use uv_scripts::{Pep723ItemRef, Pep723Script}; use uv_settings::PythonInstallMirrors; use uv_types::{BuildIsolation, HashStrategy}; use uv_warnings::warn_user; @@ -36,8 +38,9 @@ use crate::commands::project::install_target::InstallTarget; use crate::commands::project::lock::{do_safe_lock, LockMode, LockResult}; use crate::commands::project::lock_target::LockTarget; use crate::commands::project::{ - default_dependency_groups, detect_conflicts, DependencyGroupsTarget, PlatformState, - ProjectEnvironment, ProjectError, UniversalState, + default_dependency_groups, detect_conflicts, script_specification, update_environment, + DependencyGroupsTarget, PlatformState, ProjectEnvironment, ProjectError, ScriptEnvironment, + UniversalState, }; use crate::commands::{diagnostics, ExitStatus}; use crate::printer::Printer; @@ -63,6 +66,7 @@ pub(crate) async fn sync( python_preference: PythonPreference, python_downloads: PythonDownloads, settings: ResolverInstallerSettings, + script: Option, installer_metadata: bool, connectivity: Connectivity, concurrency: Concurrency, @@ -73,75 +77,152 @@ pub(crate) async fn sync( printer: Printer, preview: PreviewMode, ) -> Result { - // Identify the project. - let project = if frozen { - VirtualProject::discover( - project_dir, - &DiscoveryOptions { - members: MemberDiscovery::None, - ..DiscoveryOptions::default() - }, - ) - .await? - } else if let Some(package) = package.as_ref() { - VirtualProject::Project( - Workspace::discover(project_dir, &DiscoveryOptions::default()) - .await? - .with_current_project(package.clone()) - .with_context(|| format!("Package `{package}` not found in workspace"))?, - ) + // Identify the target. + let target = if let Some(script) = script { + SyncTarget::Script(script) } else { - VirtualProject::discover(project_dir, &DiscoveryOptions::default()).await? + // Identify the project. + let project = if frozen { + VirtualProject::discover( + project_dir, + &DiscoveryOptions { + members: MemberDiscovery::None, + ..DiscoveryOptions::default() + }, + ) + .await? + } else if let Some(package) = package.as_ref() { + VirtualProject::Project( + Workspace::discover(project_dir, &DiscoveryOptions::default()) + .await? + .with_current_project(package.clone()) + .with_context(|| format!("Package `{package}` not found in workspace"))?, + ) + } else { + VirtualProject::discover(project_dir, &DiscoveryOptions::default()).await? + }; + + // TODO(lucab): improve warning content + // + if project.workspace().pyproject_toml().has_scripts() + && !project.workspace().pyproject_toml().is_package() + { + warn_user!("Skipping installation of entry points (`project.scripts`) because this project is not packaged; to install entry points, set `tool.uv.package = true` or define a `build-system`"); + } + + SyncTarget::Project(project) }; // Validate that any referenced dependency groups are defined in the workspace. if !frozen { - let target = match &project { - VirtualProject::Project(project) => { - if all_packages { - DependencyGroupsTarget::Workspace(project.workspace()) - } else { - DependencyGroupsTarget::Project(project) - } + match &target { + SyncTarget::Project(project) => { + let target = match &project { + VirtualProject::Project(project) => { + if all_packages { + DependencyGroupsTarget::Workspace(project.workspace()) + } else { + DependencyGroupsTarget::Project(project) + } + } + VirtualProject::NonProject(workspace) => { + DependencyGroupsTarget::Workspace(workspace) + } + }; + target.validate(&dev)?; } - VirtualProject::NonProject(workspace) => DependencyGroupsTarget::Workspace(workspace), - }; - target.validate(&dev)?; + SyncTarget::Script(..) => {} + } } // Determine the default groups to include. - let defaults = default_dependency_groups(project.pyproject_toml())?; - - // TODO(lucab): improve warning content - // - if project.workspace().pyproject_toml().has_scripts() - && !project.workspace().pyproject_toml().is_package() - { - warn_user!("Skipping installation of entry points (`project.scripts`) because this project is not packaged; to install entry points, set `tool.uv.package = true` or define a `build-system`"); - } + let defaults = match &target { + SyncTarget::Project(project) => default_dependency_groups(project.pyproject_toml())?, + SyncTarget::Script(..) => Vec::new(), + }; // Discover or create the virtual environment. - let environment = ProjectEnvironment::get_or_init( - project.workspace(), - python.as_deref().map(PythonRequest::parse), - &install_mirrors, - python_preference, - python_downloads, - connectivity, - native_tls, - allow_insecure_host, - no_config, - active, - cache, - dry_run, - printer, - ) - .await?; + let environment = match &target { + SyncTarget::Project(project) => SyncEnvironment::Project( + ProjectEnvironment::get_or_init( + project.workspace(), + python.as_deref().map(PythonRequest::parse), + &install_mirrors, + python_preference, + python_downloads, + connectivity, + native_tls, + allow_insecure_host, + no_config, + active, + cache, + dry_run, + printer, + ) + .await?, + ), + SyncTarget::Script(script) => SyncEnvironment::Script( + ScriptEnvironment::get_or_init( + Pep723ItemRef::Script(script), + python.as_deref().map(PythonRequest::parse), + python_preference, + python_downloads, + connectivity, + native_tls, + allow_insecure_host, + &install_mirrors, + no_config, + cache, + dry_run, + printer, + ) + .await?, + ), + }; - // In `--dry-run` mode, print the environment discovery or creation. - if dry_run.enabled() { - match &environment { - ProjectEnvironment::Existing(environment) => { + // Notify the user of any environment changes. + match &environment { + SyncEnvironment::Project(ProjectEnvironment::Existing(environment)) + if dry_run.enabled() => + { + writeln!( + printer.stderr(), + "{}", + format!( + "Discovered existing environment at: {}", + environment.root().user_display().bold() + ) + .dimmed() + )?; + } + SyncEnvironment::Project(ProjectEnvironment::WouldReplace(root, ..)) + if dry_run.enabled() => + { + writeln!( + printer.stderr(), + "{}", + format!( + "Would replace existing virtual environment at: {}", + root.user_display().bold() + ) + .dimmed() + )?; + } + SyncEnvironment::Project(ProjectEnvironment::WouldCreate(root, ..)) + if dry_run.enabled() => + { + writeln!( + printer.stderr(), + "{}", + format!( + "Would create virtual environment at: {}", + root.user_display().bold() + ) + .dimmed() + )?; + } + SyncEnvironment::Script(ScriptEnvironment::Existing(environment)) => { + if dry_run.enabled() { writeln!( printer.stderr(), "{}", @@ -151,50 +232,102 @@ pub(crate) async fn sync( ) .dimmed() )?; - } - ProjectEnvironment::Replaced(environment) => { + } else { writeln!( printer.stderr(), - "{}", - format!( - "Replaced existing environment at: {}", - environment.root().user_display().bold() - ) - .dimmed() + "Using script environment at: {}", + environment.root().user_display().cyan() )?; } - ProjectEnvironment::Created(environment) => { - writeln!( - printer.stderr(), - "{}", - format!( - "Created new environment at: {}", - environment.root().user_display().bold() - ) - .dimmed() - )?; + } + SyncEnvironment::Script(ScriptEnvironment::Replaced(environment)) if !dry_run.enabled() => { + writeln!( + printer.stderr(), + "Recreating script environment at: {}", + environment.root().user_display().cyan() + )?; + } + SyncEnvironment::Script(ScriptEnvironment::Created(environment)) if !dry_run.enabled() => { + writeln!( + printer.stderr(), + "Creating script environment at: {}", + environment.root().user_display().cyan() + )?; + } + SyncEnvironment::Script(ScriptEnvironment::WouldReplace(root, ..)) if dry_run.enabled() => { + writeln!( + printer.stderr(), + "{}", + format!( + "Would replace existing script environment at: {}", + root.user_display().bold() + ) + .dimmed() + )?; + } + SyncEnvironment::Script(ScriptEnvironment::WouldCreate(root, ..)) if dry_run.enabled() => { + writeln!( + printer.stderr(), + "{}", + format!( + "Would create script environment at: {}", + root.user_display().bold() + ) + .dimmed() + )?; + } + _ => {} + } + + // Special-case: we're syncing a script that doesn't have an associated lockfile. In that case, + // we don't create a lockfile, so the resolve-and-install semantics are different. + if let SyncTarget::Script(script) = &target { + let lockfile = LockTarget::from(script).lock_path(); + if !lockfile.is_file() { + if frozen { + return Err(anyhow::anyhow!( + "`uv sync --frozen` requires a script lockfile; run `{}` to lock the script", + format!("uv lock --script {}", script.path.user_display()).green(), + )); } - ProjectEnvironment::WouldReplace(root, ..) => { - writeln!( - printer.stderr(), - "{}", - format!( - "Would replace existing virtual environment at: {}", - root.user_display().bold() - ) - .dimmed() - )?; + + if locked { + return Err(anyhow::anyhow!( + "`uv sync --locked` requires a script lockfile; run `{}` to lock the script", + format!("uv lock --script {}", script.path.user_display()).green(), + )); } - ProjectEnvironment::WouldCreate(root, ..) => { - writeln!( - printer.stderr(), - "{}", - format!( - "Would create virtual environment at: {}", - root.user_display().bold() - ) - .dimmed() - )?; + + let spec = + script_specification(Pep723ItemRef::Script(script), settings.as_ref().into())? + .unwrap_or_default(); + match update_environment( + Deref::deref(&environment).clone(), + spec, + modifications, + &settings, + &PlatformState::default(), + Box::new(DefaultResolveLogger), + Box::new(DefaultInstallLogger), + installer_metadata, + connectivity, + concurrency, + native_tls, + allow_insecure_host, + cache, + dry_run, + printer, + preview, + ) + .await + { + Ok(..) => return Ok(ExitStatus::Success), + Err(ProjectError::Operation(err)) => { + return diagnostics::OperationDiagnostic::native_tls(native_tls) + .report(err) + .map_or(Ok(ExitStatus::Failure), |err| Err(err.into())) + } + Err(err) => return Err(err.into()), } } } @@ -213,11 +346,14 @@ pub(crate) async fn sync( LockMode::Write(environment.interpreter()) }; - let target = LockTarget::from(project.workspace()); + let lock_target = match &target { + SyncTarget::Project(project) => LockTarget::from(project.workspace()), + SyncTarget::Script(script) => LockTarget::from(script), + }; let lock = match do_safe_lock( mode, - target, + lock_target, settings.as_ref().into(), &state, Box::new(DefaultResolveLogger), @@ -240,7 +376,7 @@ pub(crate) async fn sync( "{}", format!( "Found up-to-date lockfile at: {}", - target.lock_path().user_display().bold() + lock_target.lock_path().user_display().bold() ) .dimmed() )?; @@ -251,7 +387,7 @@ pub(crate) async fn sync( "{}", format!( "Would create lockfile at: {}", - target.lock_path().user_display().bold() + lock_target.lock_path().user_display().bold() ) .dimmed() )?; @@ -262,7 +398,7 @@ pub(crate) async fn sync( "{}", format!( "Would update lockfile at: {}", - target.lock_path().user_display().bold() + lock_target.lock_path().user_display().bold() ) .dimmed() )?; @@ -280,55 +416,63 @@ pub(crate) async fn sync( }; // Identify the installation target. - let target = match &project { - VirtualProject::Project(project) => { - if all_packages { - InstallTarget::Workspace { - workspace: project.workspace(), - lock: &lock, + let sync_target = match &target { + SyncTarget::Project(project) => { + match &project { + VirtualProject::Project(project) => { + if all_packages { + InstallTarget::Workspace { + workspace: project.workspace(), + lock: &lock, + } + } else if let Some(package) = package.as_ref() { + InstallTarget::Project { + workspace: project.workspace(), + name: package, + lock: &lock, + } + } else { + // By default, install the root package. + InstallTarget::Project { + workspace: project.workspace(), + name: project.project_name(), + lock: &lock, + } + } } - } else if let Some(package) = package.as_ref() { - InstallTarget::Project { - workspace: project.workspace(), - name: package, - lock: &lock, - } - } else { - // By default, install the root package. - InstallTarget::Project { - workspace: project.workspace(), - name: project.project_name(), - lock: &lock, - } - } - } - VirtualProject::NonProject(workspace) => { - if all_packages { - InstallTarget::NonProjectWorkspace { - workspace, - lock: &lock, - } - } else if let Some(package) = package.as_ref() { - InstallTarget::Project { - workspace, - name: package, - lock: &lock, - } - } else { - // By default, install the entire workspace. - InstallTarget::NonProjectWorkspace { - workspace, - lock: &lock, + VirtualProject::NonProject(workspace) => { + if all_packages { + InstallTarget::NonProjectWorkspace { + workspace, + lock: &lock, + } + } else if let Some(package) = package.as_ref() { + InstallTarget::Project { + workspace, + name: package, + lock: &lock, + } + } else { + // By default, install the entire workspace. + InstallTarget::NonProjectWorkspace { + workspace, + lock: &lock, + } + } } } } + SyncTarget::Script(script) => InstallTarget::Script { + script, + lock: &lock, + }, }; let state = state.fork(); // Perform the sync operation. match do_sync( - target, + sync_target, &environment, &extras, &dev.with_defaults(defaults), @@ -362,6 +506,33 @@ pub(crate) async fn sync( Ok(ExitStatus::Success) } +#[derive(Debug, Clone)] +enum SyncTarget { + /// Sync a project environment. + Project(VirtualProject), + /// Sync a PEP 723 script environment. + Script(Pep723Script), +} + +#[derive(Debug)] +enum SyncEnvironment { + /// A Python environment for a project. + Project(ProjectEnvironment), + /// A Python environment for a script. + Script(ScriptEnvironment), +} + +impl Deref for SyncEnvironment { + type Target = PythonEnvironment; + + fn deref(&self) -> &Self::Target { + match self { + Self::Project(environment) => Deref::deref(environment), + Self::Script(environment) => Deref::deref(environment), + } + } +} + /// Sync a lockfile with an environment. #[allow(clippy::fn_params_excessive_bools)] pub(super) async fn do_sync( diff --git a/crates/uv/src/commands/tool/install.rs b/crates/uv/src/commands/tool/install.rs index 06e07bed9..672d18ee2 100644 --- a/crates/uv/src/commands/tool/install.rs +++ b/crates/uv/src/commands/tool/install.rs @@ -8,7 +8,7 @@ use tracing::{debug, trace}; use uv_cache::{Cache, Refresh}; use uv_cache_info::Timestamp; use uv_client::{BaseClientBuilder, Connectivity}; -use uv_configuration::{Concurrency, PreviewMode, Reinstall, TrustedHost, Upgrade}; +use uv_configuration::{Concurrency, DryRun, PreviewMode, Reinstall, TrustedHost, Upgrade}; use uv_distribution_types::{NameRequirementSpecification, UnresolvedRequirementSpecification}; use uv_normalize::PackageName; use uv_pep440::{VersionSpecifier, VersionSpecifiers}; @@ -410,6 +410,7 @@ pub(crate) async fn install( native_tls, allow_insecure_host, &cache, + DryRun::Disabled, printer, preview, ) diff --git a/crates/uv/src/commands/tool/upgrade.rs b/crates/uv/src/commands/tool/upgrade.rs index b3a6ba668..bfd7ee194 100644 --- a/crates/uv/src/commands/tool/upgrade.rs +++ b/crates/uv/src/commands/tool/upgrade.rs @@ -7,7 +7,7 @@ use tracing::debug; use uv_cache::Cache; use uv_client::{BaseClientBuilder, Connectivity}; -use uv_configuration::{Concurrency, PreviewMode, TrustedHost}; +use uv_configuration::{Concurrency, DryRun, PreviewMode, TrustedHost}; use uv_fs::CWD; use uv_normalize::PackageName; use uv_pypi_types::Requirement; @@ -352,6 +352,7 @@ async fn upgrade_tool( native_tls, allow_insecure_host, cache, + DryRun::Disabled, printer, preview, ) diff --git a/crates/uv/src/lib.rs b/crates/uv/src/lib.rs index cb7f9d3dd..b3f735fcd 100644 --- a/crates/uv/src/lib.rs +++ b/crates/uv/src/lib.rs @@ -201,6 +201,10 @@ async fn run(mut cli: Cli) -> Result { script: Some(script), .. }) + | ProjectCommand::Sync(uv_cli::SyncArgs { + script: Some(script), + .. + }) | ProjectCommand::Tree(uv_cli::TreeArgs { script: Some(script), .. @@ -1527,7 +1531,14 @@ async fn run_project( .combine(Refresh::from(args.settings.upgrade.clone())), ); - commands::sync( + // 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"), + }); + + Box::pin(commands::sync( project_dir, args.locked, args.frozen, @@ -1545,6 +1556,7 @@ async fn run_project( globals.python_preference, globals.python_downloads, args.settings, + script, globals.installer_metadata, globals.connectivity, globals.concurrency, @@ -1554,7 +1566,7 @@ async fn run_project( &cache, printer, globals.preview, - ) + )) .await } ProjectCommand::Lock(args) => { diff --git a/crates/uv/src/settings.rs b/crates/uv/src/settings.rs index 6d108ee97..91ba1eba2 100644 --- a/crates/uv/src/settings.rs +++ b/crates/uv/src/settings.rs @@ -959,6 +959,7 @@ pub(crate) struct SyncSettings { pub(crate) locked: bool, pub(crate) frozen: bool, pub(crate) dry_run: DryRun, + pub(crate) script: Option, pub(crate) active: Option, pub(crate) extras: ExtrasSpecification, pub(crate) dev: DevGroupsSpecification, @@ -1006,6 +1007,7 @@ impl SyncSettings { refresh, all_packages, package, + script, python, } = args; let install_mirrors = filesystem @@ -1022,6 +1024,7 @@ impl SyncSettings { locked, frozen, dry_run: DryRun::from_args(dry_run), + script, active: flag(active, no_active), extras: ExtrasSpecification::from_args( flag(all_extras, no_all_extras).unwrap_or_default(), diff --git a/crates/uv/tests/it/sync.rs b/crates/uv/tests/it/sync.rs index b4c5fbe79..755ed3eeb 100644 --- a/crates/uv/tests/it/sync.rs +++ b/crates/uv/tests/it/sync.rs @@ -6684,3 +6684,379 @@ fn sync_dry_run() -> Result<()> { Ok(()) } + +#[test] +fn sync_script() -> Result<()> { + let context = TestContext::new_with_versions(&["3.8", "3.12"]); + + let script = context.temp_dir.child("script.py"); + script.write_str(indoc! { r#" + # /// script + # requires-python = ">=3.11" + # dependencies = [ + # "anyio", + # ] + # /// + + import anyio + "# + })?; + + let filters = context + .filters() + .into_iter() + .chain(vec![( + r"environments-v1/script-\w+", + "environments-v1/script-[HASH]", + )]) + .collect::>(); + + uv_snapshot!(&filters, context.sync().arg("--script").arg("script.py"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Creating script environment at: [CACHE_DIR]/environments-v1/script-[HASH] + Resolved 3 packages in [TIME] + Prepared 3 packages in [TIME] + Installed 3 packages in [TIME] + + anyio==4.3.0 + + idna==3.6 + + sniffio==1.3.1 + "###); + + // If a lockfile didn't exist already, `uv sync --script` shouldn't create one. + assert!(!context.temp_dir.child("uv.lock").exists()); + + // Modify the script's dependencies. + script.write_str(indoc! { r#" + # /// script + # requires-python = ">=3.11" + # dependencies = [ + # "anyio", + # "iniconfig", + # ] + # /// + + import anyio + "# + })?; + + uv_snapshot!(&filters, context.sync().arg("--script").arg("script.py"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Using script environment at: [CACHE_DIR]/environments-v1/script-[HASH] + Resolved 4 packages in [TIME] + Prepared 1 package in [TIME] + Installed 1 package in [TIME] + + iniconfig==2.0.0 + "###); + + // Modify the `requires-python`. + script.write_str(indoc! { r#" + # /// script + # requires-python = ">=3.8, <3.11" + # dependencies = [ + # "anyio", + # "iniconfig", + # ] + # /// + + import anyio + "# + })?; + + uv_snapshot!(&filters, context.sync().arg("--script").arg("script.py"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Recreating script environment at: [CACHE_DIR]/environments-v1/script-[HASH] + Resolved 6 packages in [TIME] + Prepared 2 packages in [TIME] + Installed 6 packages in [TIME] + + anyio==4.3.0 + + exceptiongroup==1.2.0 + + idna==3.6 + + iniconfig==2.0.0 + + sniffio==1.3.1 + + typing-extensions==4.10.0 + "###); + + // `--locked` and `--frozen` should fail with helpful error messages. + uv_snapshot!(&filters, context.sync().arg("--script").arg("script.py").arg("--locked"), @r###" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + Using script environment at: [CACHE_DIR]/environments-v1/script-[HASH] + error: `uv sync --locked` requires a script lockfile; run `uv lock --script script.py` to lock the script + "###); + + uv_snapshot!(&filters, context.sync().arg("--script").arg("script.py").arg("--frozen"), @r###" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + Using script environment at: [CACHE_DIR]/environments-v1/script-[HASH] + error: `uv sync --frozen` requires a script lockfile; run `uv lock --script script.py` to lock the script + "###); + + Ok(()) +} + +#[test] +fn sync_locked_script() -> Result<()> { + let context = TestContext::new_with_versions(&["3.8", "3.12"]); + + let script = context.temp_dir.child("script.py"); + script.write_str(indoc! { r#" + # /// script + # requires-python = ">=3.11" + # dependencies = [ + # "anyio", + # ] + # /// + + import anyio + "# + })?; + + let filters = context + .filters() + .into_iter() + .chain(vec![( + r"environments-v1/script-\w+", + "environments-v1/script-[HASH]", + )]) + .collect::>(); + + // Lock the script. + uv_snapshot!(&filters, context.lock().arg("--script").arg("script.py"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 3 packages in [TIME] + "###); + + let lock = context.read("script.py.lock"); + + insta::with_settings!({ + filters => context.filters(), + }, { + assert_snapshot!( + lock, @r###" + version = 1 + requires-python = ">=3.11" + + [options] + exclude-newer = "2024-03-25T00:00:00Z" + + [manifest] + requirements = [{ name = "anyio" }] + + [[package]] + name = "anyio" + version = "4.3.0" + source = { registry = "https://pypi.org/simple" } + dependencies = [ + { name = "idna" }, + { name = "sniffio" }, + ] + sdist = { url = "https://files.pythonhosted.org/packages/db/4d/3970183622f0330d3c23d9b8a5f52e365e50381fd484d08e3285104333d3/anyio-4.3.0.tar.gz", hash = "sha256:f75253795a87df48568485fd18cdd2a3fa5c4f7c5be8e5e36637733fce06fed6", size = 159642 } + wheels = [ + { url = "https://files.pythonhosted.org/packages/14/fd/2f20c40b45e4fb4324834aea24bd4afdf1143390242c0b33774da0e2e34f/anyio-4.3.0-py3-none-any.whl", hash = "sha256:048e05d0f6caeed70d731f3db756d35dcc1f35747c8c403364a8332c630441b8", size = 85584 }, + ] + + [[package]] + name = "idna" + version = "3.6" + source = { registry = "https://pypi.org/simple" } + sdist = { url = "https://files.pythonhosted.org/packages/bf/3f/ea4b9117521a1e9c50344b909be7886dd00a519552724809bb1f486986c2/idna-3.6.tar.gz", hash = "sha256:9ecdbbd083b06798ae1e86adcbfe8ab1479cf864e4ee30fe4e46a003d12491ca", size = 175426 } + wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/e7/a82b05cf63a603df6e68d59ae6a68bf5064484a0718ea5033660af4b54a9/idna-3.6-py3-none-any.whl", hash = "sha256:c05567e9c24a6b9faaa835c4821bad0590fbb9d5779e7caa6e1cc4978e7eb24f", size = 61567 }, + ] + + [[package]] + name = "sniffio" + version = "1.3.1" + source = { registry = "https://pypi.org/simple" } + sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372 } + wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235 }, + ] + "### + ); + }); + + uv_snapshot!(&filters, context.sync().arg("--script").arg("script.py"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Creating script environment at: [CACHE_DIR]/environments-v1/script-[HASH] + Resolved 3 packages in [TIME] + Prepared 3 packages in [TIME] + Installed 3 packages in [TIME] + + anyio==4.3.0 + + idna==3.6 + + sniffio==1.3.1 + "###); + + // Modify the script's dependencies. + script.write_str(indoc! { r#" + # /// script + # requires-python = ">=3.11" + # dependencies = [ + # "anyio", + # "iniconfig", + # ] + # /// + + import anyio + "# + })?; + + // Re-run with `--locked`. + uv_snapshot!(&filters, context.sync().arg("--script").arg("script.py").arg("--locked"), @r###" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + Using script environment at: [CACHE_DIR]/environments-v1/script-[HASH] + Resolved 4 packages in [TIME] + error: The lockfile at `uv.lock` needs to be updated, but `--locked` was provided. To update the lockfile, run `uv lock`. + "###); + + uv_snapshot!(&filters, context.sync().arg("--script").arg("script.py"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Using script environment at: [CACHE_DIR]/environments-v1/script-[HASH] + Resolved 4 packages in [TIME] + Prepared 1 package in [TIME] + Installed 1 package in [TIME] + + iniconfig==2.0.0 + "###); + + let lock = context.read("script.py.lock"); + + insta::with_settings!({ + filters => context.filters(), + }, { + assert_snapshot!( + lock, @r###" + version = 1 + requires-python = ">=3.11" + + [options] + exclude-newer = "2024-03-25T00:00:00Z" + + [manifest] + requirements = [ + { name = "anyio" }, + { name = "iniconfig" }, + ] + + [[package]] + name = "anyio" + version = "4.3.0" + source = { registry = "https://pypi.org/simple" } + dependencies = [ + { name = "idna" }, + { name = "sniffio" }, + ] + sdist = { url = "https://files.pythonhosted.org/packages/db/4d/3970183622f0330d3c23d9b8a5f52e365e50381fd484d08e3285104333d3/anyio-4.3.0.tar.gz", hash = "sha256:f75253795a87df48568485fd18cdd2a3fa5c4f7c5be8e5e36637733fce06fed6", size = 159642 } + wheels = [ + { url = "https://files.pythonhosted.org/packages/14/fd/2f20c40b45e4fb4324834aea24bd4afdf1143390242c0b33774da0e2e34f/anyio-4.3.0-py3-none-any.whl", hash = "sha256:048e05d0f6caeed70d731f3db756d35dcc1f35747c8c403364a8332c630441b8", size = 85584 }, + ] + + [[package]] + name = "idna" + version = "3.6" + source = { registry = "https://pypi.org/simple" } + sdist = { url = "https://files.pythonhosted.org/packages/bf/3f/ea4b9117521a1e9c50344b909be7886dd00a519552724809bb1f486986c2/idna-3.6.tar.gz", hash = "sha256:9ecdbbd083b06798ae1e86adcbfe8ab1479cf864e4ee30fe4e46a003d12491ca", size = 175426 } + wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/e7/a82b05cf63a603df6e68d59ae6a68bf5064484a0718ea5033660af4b54a9/idna-3.6-py3-none-any.whl", hash = "sha256:c05567e9c24a6b9faaa835c4821bad0590fbb9d5779e7caa6e1cc4978e7eb24f", size = 61567 }, + ] + + [[package]] + name = "iniconfig" + version = "2.0.0" + source = { registry = "https://pypi.org/simple" } + sdist = { url = "https://files.pythonhosted.org/packages/d7/4b/cbd8e699e64a6f16ca3a8220661b5f83792b3017d0f79807cb8708d33913/iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3", size = 4646 } + wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374", size = 5892 }, + ] + + [[package]] + name = "sniffio" + version = "1.3.1" + source = { registry = "https://pypi.org/simple" } + sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372 } + wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235 }, + ] + "### + ); + }); + + // Modify the `requires-python`. + script.write_str(indoc! { r#" + # /// script + # requires-python = ">=3.8, <3.11" + # dependencies = [ + # "anyio", + # "iniconfig", + # ] + # /// + + import anyio + "# + })?; + + // Re-run with `--locked`. + uv_snapshot!(&filters, context.sync().arg("--script").arg("script.py").arg("--locked"), @r###" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + Recreating script environment at: [CACHE_DIR]/environments-v1/script-[HASH] + Resolved 6 packages in [TIME] + error: The lockfile at `uv.lock` needs to be updated, but `--locked` was provided. To update the lockfile, run `uv lock`. + "###); + + uv_snapshot!(&filters, context.sync().arg("--script").arg("script.py"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Using script environment at: [CACHE_DIR]/environments-v1/script-[HASH] + Resolved 6 packages in [TIME] + Prepared 2 packages in [TIME] + Installed 6 packages in [TIME] + + anyio==4.3.0 + + exceptiongroup==1.2.0 + + idna==3.6 + + iniconfig==2.0.0 + + sniffio==1.3.1 + + typing-extensions==4.10.0 + "###); + + Ok(()) +} diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 46af8841b..8d6be84c4 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -1868,6 +1868,10 @@ uv sync [OPTIONS]
  • lowest-direct: Resolve the lowest compatible version of any direct dependencies, and the highest compatible version of any transitive dependencies
  • +
    --script script

    Sync the environment for a Python script, rather than the current project.

    + +

    If provided, uv will sync the dependencies based on the script’s 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