diff --git a/crates/uv-cli/src/lib.rs b/crates/uv-cli/src/lib.rs index cc2bd21e5d2e4..18c5bbc7a2155 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/src/commands/project/lock.rs b/crates/uv/src/commands/project/lock.rs index c48f650c21be6..3eae393f310ea 100644 --- a/crates/uv/src/commands/project/lock.rs +++ b/crates/uv/src/commands/project/lock.rs @@ -8,13 +8,6 @@ use owo_colors::OwoColorize; use rustc_hash::{FxBuildHasher, FxHashMap}; use tracing::debug; -use crate::commands::pip::loggers::{DefaultResolveLogger, ResolveLogger, SummaryResolveLogger}; -use crate::commands::project::lock_target::LockTarget; -use crate::commands::project::{ProjectError, ProjectInterpreter}; -use crate::commands::reporters::ResolverReporter; -use crate::commands::{diagnostics, pip, ExitStatus}; -use crate::printer::Printer; -use crate::settings::{ResolverSettings, ResolverSettingsRef}; use uv_cache::Cache; use uv_client::{Connectivity, FlatIndexClient, RegistryClientBuilder}; use uv_configuration::{ @@ -38,11 +31,20 @@ use uv_resolver::{ 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, WorkspaceMember}; +use crate::commands::pip::loggers::{DefaultResolveLogger, ResolveLogger, SummaryResolveLogger}; +use crate::commands::project::lock_target::LockTarget; +use crate::commands::project::{ProjectError, ProjectInterpreter, ScriptInterpreter}; +use crate::commands::reporters::ResolverReporter; +use crate::commands::{diagnostics, pip, ExitStatus}; +use crate::printer::Printer; +use crate::settings::{ResolverSettings, ResolverSettingsRef}; + /// The result of running a lock operation. #[derive(Debug, Clone)] pub(crate) enum LockResult { @@ -78,6 +80,7 @@ pub(crate) async fn lock( python: Option, install_mirrors: PythonInstallMirrors, settings: ResolverSettings, + script: Option, python_preference: PythonPreference, python_downloads: PythonDownloads, connectivity: Connectivity, @@ -90,29 +93,52 @@ pub(crate) async fn lock( preview: PreviewMode, ) -> anyhow::Result { // 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 { - 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) => ScriptInterpreter::discover( + &Pep723Item::Script(script.clone()), + 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(), + }; if locked { LockMode::Locked(&interpreter) @@ -129,7 +155,7 @@ pub(crate) async fn lock( // Perform the lock operation. match do_safe_lock( mode, - (&workspace).into(), + target, settings.as_ref(), LowerBound::Warn, &state, diff --git a/crates/uv/src/commands/project/lock_target.rs b/crates/uv/src/commands/project/lock_target.rs index 6124cb79925e7..991b43f195dbd 100644 --- a/crates/uv/src/commands/project/lock_target.rs +++ b/crates/uv/src/commands/project/lock_target.rs @@ -1,12 +1,16 @@ use std::collections::BTreeMap; use std::path::{Path, PathBuf}; +use itertools::Either; + 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}; @@ -16,6 +20,7 @@ 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> { @@ -24,6 +29,12 @@ impl<'lock> From<&'lock Workspace> for LockTarget<'lock> { } } +impl<'lock> From<&'lock Pep723Script> for LockTarget<'lock> { + fn from(script: &'lock Pep723Script) -> Self { + LockTarget::Script(script) + } +} + impl<'lock> LockTarget<'lock> { /// Returns any requirements that are exclusive to the workspace root, i.e., not included in /// any of the workspace members. @@ -32,6 +43,7 @@ impl<'lock> LockTarget<'lock> { ) -> Result>, DependencyGroupError> { match self { Self::Workspace(workspace) => workspace.non_project_requirements(), + Self::Script(script) => Ok(script.metadata.dependencies.clone().unwrap_or_default()), } } @@ -39,6 +51,16 @@ impl<'lock> LockTarget<'lock> { pub(crate) fn overrides(self) -> Vec> { match self { Self::Workspace(workspace) => workspace.overrides(), + Self::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(), } } @@ -46,6 +68,16 @@ impl<'lock> LockTarget<'lock> { pub(crate) fn constraints(self) -> Vec> { match self { Self::Workspace(workspace) => workspace.constraints(), + Self::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(), } } @@ -83,6 +115,55 @@ impl<'lock> LockTarget<'lock> { .map(|requirement| requirement.with_origin(RequirementOrigin::Workspace)) .collect::>()) } + Self::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, + 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::>()?) + } } } @@ -102,6 +183,7 @@ impl<'lock> LockTarget<'lock> { members } + Self::Script(_) => Vec::new(), } } @@ -109,6 +191,10 @@ impl<'lock> LockTarget<'lock> { pub(crate) fn packages(self) -> &'lock BTreeMap { match self { Self::Workspace(workspace) => workspace.packages(), + Self::Script(_) => { + static EMPTY: BTreeMap = BTreeMap::new(); + &EMPTY + } } } @@ -116,6 +202,10 @@ impl<'lock> LockTarget<'lock> { pub(crate) fn environments(self) -> Option<&'lock SupportedEnvironments> { match self { Self::Workspace(workspace) => workspace.environments(), + Self::Script(_) => { + // TODO(charlie): Add support for environments in scripts. + None + } } } @@ -123,6 +213,7 @@ impl<'lock> LockTarget<'lock> { pub(crate) fn conflicts(self) -> Conflicts { match self { Self::Workspace(workspace) => workspace.conflicts(), + Self::Script(_) => Conflicts::empty(), } } @@ -130,20 +221,27 @@ impl<'lock> LockTarget<'lock> { pub(crate) fn requires_python(self) -> Option { match self { Self::Workspace(workspace) => find_requires_python(workspace), + Self::Script(script) => script + .metadata + .requires_python + .as_ref() + .map(RequiresPython::from_specifiers), } } /// Returns the set of requirements that include all packages in the workspace. pub(crate) fn members_requirements(self) -> impl Iterator + 'lock { match self { - Self::Workspace(workspace) => workspace.members_requirements(), + Self::Workspace(workspace) => Either::Left(workspace.members_requirements()), + Self::Script(_) => Either::Right(std::iter::empty()), } } /// Returns the set of requirements that include all packages in the workspace. pub(crate) fn group_requirements(self) -> impl Iterator + 'lock { match self { - Self::Workspace(workspace) => workspace.group_requirements(), + Self::Workspace(workspace) => Either::Left(workspace.group_requirements()), + Self::Script(_) => Either::Right(std::iter::empty()), } } @@ -151,13 +249,24 @@ impl<'lock> LockTarget<'lock> { pub(crate) fn install_path(self) -> &'lock Path { match self { Self::Workspace(workspace) => workspace.install_path(), + Self::Script(script) => script.path.parent().unwrap(), } } /// Return the path to the lockfile. fn lock_path(self) -> PathBuf { match self { + // `uv.lock` Self::Workspace(workspace) => workspace.install_path().join("uv.lock"), + // `script.py.lock` + Self::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) + } } } diff --git a/crates/uv/src/lib.rs b/crates/uv/src/lib.rs index f34ef7a03a151..7a90324fefd85 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,7 +1489,14 @@ async fn run_project( .combine(Refresh::from(args.settings.upgrade.clone())), ); - commands::lock( + // Unwrap the script. + let script = script.map(|script| match script { + Pep723Item::Script(script) => script, + Pep723Item::Stdin(_) => unreachable!("`uv remove` does not support stdin"), + Pep723Item::Remote(_) => unreachable!("`uv remove` does not support remote files"), + }); + + Box::pin(commands::lock( project_dir, args.locked, args.frozen, @@ -1491,6 +1504,7 @@ async fn run_project( args.python, args.install_mirrors, args.settings, + script, globals.python_preference, globals.python_downloads, globals.connectivity, @@ -1501,7 +1515,7 @@ async fn run_project( &cache, printer, globals.preview, - ) + )) .await } ProjectCommand::Add(args) => { diff --git a/crates/uv/src/settings.rs b/crates/uv/src/settings.rs index 2e677eeabf4ae..761deae0c8474 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 cf8110eec7f80..5090d483a9f4a 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