From 1a6d91632fcf826676a8be1a655c2b6eced007e0 Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Wed, 25 Dec 2024 14:13:07 -0500 Subject: [PATCH] Add export support for PEP 723 scripts --- crates/uv-cli/src/lib.rs | 8 ++ crates/uv/src/commands/project/add.rs | 4 +- crates/uv/src/commands/project/export.rs | 141 +++++++++++++++-------- crates/uv/src/lib.rs | 18 ++- crates/uv/src/settings.rs | 3 + crates/uv/tests/it/export.rs | 120 +++++++++++++++++++ docs/reference/cli.md | 4 + 7 files changed, 249 insertions(+), 49 deletions(-) diff --git a/crates/uv-cli/src/lib.rs b/crates/uv-cli/src/lib.rs index 48c5bdaadcc8..db21398ca682 100644 --- a/crates/uv-cli/src/lib.rs +++ b/crates/uv-cli/src/lib.rs @@ -3631,6 +3631,14 @@ pub struct ExportArgs { #[command(flatten)] pub refresh: RefreshArgs, + /// Export the dependencies for the specified PEP 723 Python script, rather than the current + /// project. + /// + /// If provided, uv will resolve the dependencies based on its inline metadata table, in + /// adherence with PEP 723. + #[arg(long, conflicts_with_all = ["all_packages", "package", "no_emit_project", "no_emit_workspace"])] + pub script: Option, + /// The Python interpreter to use during resolution. /// /// A Python interpreter is required for building source distributions to diff --git a/crates/uv/src/commands/project/add.rs b/crates/uv/src/commands/project/add.rs index 05baae6887fc..394592b17ba1 100644 --- a/crates/uv/src/commands/project/add.rs +++ b/crates/uv/src/commands/project/add.rs @@ -1005,8 +1005,8 @@ enum AddTarget { impl<'lock> From<&'lock AddTarget> for LockTarget<'lock> { fn from(value: &'lock AddTarget) -> Self { match value { - AddTarget::Script(script, _) => LockTarget::Script(script), - AddTarget::Project(project, _) => LockTarget::Workspace(project.workspace()), + AddTarget::Script(script, _) => Self::Script(script), + AddTarget::Project(project, _) => Self::Workspace(project.workspace()), } } } diff --git a/crates/uv/src/commands/project/export.rs b/crates/uv/src/commands/project/export.rs index eb1d0ebe3bb8..7a69beffa6ce 100644 --- a/crates/uv/src/commands/project/export.rs +++ b/crates/uv/src/commands/project/export.rs @@ -16,19 +16,39 @@ use uv_dispatch::SharedState; use uv_normalize::PackageName; use uv_python::{PythonDownloads, PythonPreference, PythonRequest}; use uv_resolver::RequirementsTxtExport; +use uv_scripts::{Pep723Item, Pep723Script}; use uv_workspace::{DiscoveryOptions, MemberDiscovery, VirtualProject, Workspace}; use crate::commands::pip::loggers::DefaultResolveLogger; use crate::commands::project::install_target::InstallTarget; use crate::commands::project::lock::{do_safe_lock, LockMode}; +use crate::commands::project::lock_target::LockTarget; use crate::commands::project::{ default_dependency_groups, detect_conflicts, DependencyGroupsTarget, ProjectError, - ProjectInterpreter, + ProjectInterpreter, ScriptInterpreter, }; use crate::commands::{diagnostics, ExitStatus, OutputWriter}; use crate::printer::Printer; use crate::settings::ResolverSettings; +#[derive(Debug, Clone)] +enum ExportTarget { + /// A PEP 723 script, with inline metadata. + Script(Pep723Script), + + /// A project with a `pyproject.toml`. + Project(VirtualProject), +} + +impl<'lock> From<&'lock ExportTarget> for LockTarget<'lock> { + fn from(value: &'lock ExportTarget) -> Self { + match value { + ExportTarget::Script(script) => Self::Script(script), + ExportTarget::Project(project) => Self::Workspace(project.workspace()), + } + } +} + /// Export the project's `uv.lock` in an alternate format. #[allow(clippy::fn_params_excessive_bools)] pub(crate) async fn export( @@ -46,6 +66,7 @@ pub(crate) async fn export( locked: bool, frozen: bool, include_header: bool, + script: Option, python: Option, install_mirrors: PythonInstallMirrors, settings: ResolverSettings, @@ -61,44 +82,55 @@ pub(crate) async fn export( 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 { + ExportTarget::Script(script) } else { - VirtualProject::discover(project_dir, &DiscoveryOptions::default()).await? + 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? + }; + ExportTarget::Project(project) }; // Validate that any referenced dependency groups are defined in the workspace. if !frozen { - let target = match &project { - VirtualProject::Project(project) => { + let target = match &target { + ExportTarget::Project(VirtualProject::Project(project)) => { if all_packages { DependencyGroupsTarget::Workspace(project.workspace()) } else { DependencyGroupsTarget::Project(project) } } - VirtualProject::NonProject(workspace) => DependencyGroupsTarget::Workspace(workspace), + ExportTarget::Project(VirtualProject::NonProject(workspace)) => { + DependencyGroupsTarget::Workspace(workspace) + } + ExportTarget::Script(_) => DependencyGroupsTarget::Script, }; target.validate(&dev)?; } // Determine the default groups to include. - let defaults = default_dependency_groups(project.pyproject_toml())?; + let defaults = match &target { + ExportTarget::Project(project) => default_dependency_groups(project.pyproject_toml())?, + ExportTarget::Script(_) => vec![], + }; let dev = dev.with_defaults(defaults); // Determine the lock mode. @@ -106,24 +138,39 @@ pub(crate) async fn export( let mode = if frozen { LockMode::Frozen } else { - // Find an interpreter for the project - interpreter = ProjectInterpreter::discover( - project.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 { + ExportTarget::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(), + ExportTarget::Project(project) => ProjectInterpreter::discover( + project.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(), + }; if locked { LockMode::Locked(&interpreter) } else { @@ -137,7 +184,7 @@ pub(crate) async fn export( // Lock the project. let lock = match do_safe_lock( mode, - project.workspace().into(), + (&target).into(), settings.as_ref(), LowerBound::Warn, &state, @@ -165,8 +212,8 @@ pub(crate) async fn export( detect_conflicts(&lock, &extras, &dev)?; // Identify the installation target. - let target = match &project { - VirtualProject::Project(project) => { + let target = match &target { + ExportTarget::Project(VirtualProject::Project(project)) => { if all_packages { InstallTarget::Workspace { workspace: project.workspace(), @@ -187,7 +234,7 @@ pub(crate) async fn export( } } } - VirtualProject::NonProject(workspace) => { + ExportTarget::Project(VirtualProject::NonProject(workspace)) => { if all_packages { InstallTarget::NonProjectWorkspace { workspace, @@ -207,6 +254,10 @@ pub(crate) async fn export( } } } + ExportTarget::Script(script) => InstallTarget::Script { + script, + lock: &lock, + }, }; // Write the resolved dependencies to the output channel. diff --git a/crates/uv/src/lib.rs b/crates/uv/src/lib.rs index ac34caf898d0..40b7f105d3f4 100644 --- a/crates/uv/src/lib.rs +++ b/crates/uv/src/lib.rs @@ -195,6 +195,12 @@ async fn run(mut cli: Cli) -> Result { script: Some(script), .. }) = &**command + { + Pep723Script::read(&script).await?.map(Pep723Item::Script) + } else if let ProjectCommand::Export(uv_cli::ExportArgs { + script: Some(script), + .. + }) = &**command { Pep723Script::read(&script).await?.map(Pep723Item::Script) } else { @@ -1679,7 +1685,14 @@ async fn run_project( // Initialize the cache. let cache = cache.init()?; - commands::export( + // Unwrap the script. + let script = script.map(|script| match script { + Pep723Item::Script(script) => script, + Pep723Item::Stdin(_) => unreachable!("`uv export` does not support stdin"), + Pep723Item::Remote(_) => unreachable!("`uv export` does not support remote files"), + }); + + Box::pin(commands::export( project_dir, args.format, args.all_packages, @@ -1694,6 +1707,7 @@ async fn run_project( args.locked, args.frozen, args.include_header, + script, args.python, args.install_mirrors, args.settings, @@ -1708,7 +1722,7 @@ async fn run_project( &cache, printer, globals.preview, - ) + )) .await } } diff --git a/crates/uv/src/settings.rs b/crates/uv/src/settings.rs index 71887cf772b3..3d2f68dc25a5 100644 --- a/crates/uv/src/settings.rs +++ b/crates/uv/src/settings.rs @@ -1349,6 +1349,7 @@ pub(crate) struct ExportSettings { pub(crate) locked: bool, pub(crate) frozen: bool, pub(crate) include_header: bool, + pub(crate) script: Option, pub(crate) python: Option, pub(crate) install_mirrors: PythonInstallMirrors, pub(crate) refresh: Refresh, @@ -1389,6 +1390,7 @@ impl ExportSettings { resolver, build, refresh, + script, python, } = args; let install_mirrors = filesystem @@ -1420,6 +1422,7 @@ impl ExportSettings { locked, frozen, include_header: flag(header, no_header).unwrap_or(true), + script, python: python.and_then(Maybe::into_option), refresh: Refresh::from(refresh), settings: ResolverSettings::combine(resolver_options(resolver, build), filesystem), diff --git a/crates/uv/tests/it/export.rs b/crates/uv/tests/it/export.rs index 19e7a388d937..aaf0d74670f4 100644 --- a/crates/uv/tests/it/export.rs +++ b/crates/uv/tests/it/export.rs @@ -4,6 +4,8 @@ use crate::common::{apply_filters, uv_snapshot, TestContext}; use anyhow::{Ok, Result}; use assert_cmd::assert::OutputAssertExt; use assert_fs::prelude::*; +use indoc::indoc; +use insta::assert_snapshot; use std::process::Stdio; #[test] @@ -2054,6 +2056,124 @@ fn export_group() -> Result<()> { Ok(()) } +#[test] +fn script() -> Result<()> { + let context = TestContext::new("3.12"); + + let script = context.temp_dir.child("script.py"); + script.write_str(indoc! {r#" + # /// script + # requires-python = ">=3.11" + # dependencies = [ + # "anyio==2.0.0 ; sys_platform == 'win32'", + # "anyio==3.0.0 ; sys_platform == 'linux'" + # ] + # /// + "#})?; + + uv_snapshot!(context.filters(), context.export().arg("--script").arg(script.path()), @r###" + success: true + exit_code: 0 + ----- stdout ----- + # This file was autogenerated by uv via the following command: + # uv export --cache-dir [CACHE_DIR] --script [TEMP_DIR]/script.py + anyio==2.0.0 ; sys_platform == 'win32' \ + --hash=sha256:0b8375c8fc665236cb4d143ea13e849eb9e074d727b1b5c27d88aba44ca8c547 \ + --hash=sha256:ceca4669ffa3f02bf20ef3d6c2a0c323b16cdc71d1ce0b0bc03c6f1f36054826 + anyio==3.0.0 ; sys_platform == 'linux' \ + --hash=sha256:b553598332c050af19f7d41f73a7790142f5bc3d5eb8bd82f5e515ec22019bd9 \ + --hash=sha256:e71c3d9d72291d12056c0265d07c6bbedf92332f78573e278aeb116f24f30395 + idna==3.6 ; sys_platform == 'linux' or sys_platform == 'win32' \ + --hash=sha256:9ecdbbd083b06798ae1e86adcbfe8ab1479cf864e4ee30fe4e46a003d12491ca \ + --hash=sha256:c05567e9c24a6b9faaa835c4821bad0590fbb9d5779e7caa6e1cc4978e7eb24f + sniffio==1.3.1 ; sys_platform == 'linux' or sys_platform == 'win32' \ + --hash=sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2 \ + --hash=sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc + + ----- stderr ----- + Resolved 4 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" + resolution-markers = [ + "sys_platform == 'win32'", + "sys_platform == 'linux'", + "sys_platform != 'linux' and sys_platform != 'win32'", + ] + + [options] + exclude-newer = "2024-03-25T00:00:00Z" + + [manifest] + requirements = [ + { name = "anyio", marker = "sys_platform == 'linux'", specifier = "==3.0.0" }, + { name = "anyio", marker = "sys_platform == 'win32'", specifier = "==2.0.0" }, + ] + + [[package]] + name = "anyio" + version = "2.0.0" + source = { registry = "https://pypi.org/simple" } + resolution-markers = [ + "sys_platform == 'win32'", + ] + dependencies = [ + { name = "idna", marker = "sys_platform == 'win32'" }, + { name = "sniffio", marker = "sys_platform == 'win32'" }, + ] + sdist = { url = "https://files.pythonhosted.org/packages/fe/dc/daeadb9b34093d3968afcc93946ee567cd6d2b402a96c608cb160f74d737/anyio-2.0.0.tar.gz", hash = "sha256:ceca4669ffa3f02bf20ef3d6c2a0c323b16cdc71d1ce0b0bc03c6f1f36054826", size = 91291 } + wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/19/10fe682e962efd1610aa41376399fc3f3e002425449b02d0fb04749bb712/anyio-2.0.0-py3-none-any.whl", hash = "sha256:0b8375c8fc665236cb4d143ea13e849eb9e074d727b1b5c27d88aba44ca8c547", size = 62675 }, + ] + + [[package]] + name = "anyio" + version = "3.0.0" + source = { registry = "https://pypi.org/simple" } + resolution-markers = [ + "sys_platform == 'linux'", + ] + dependencies = [ + { name = "idna", marker = "sys_platform == 'linux'" }, + { name = "sniffio", marker = "sys_platform == 'linux'" }, + ] + sdist = { url = "https://files.pythonhosted.org/packages/99/0d/65165f99e5f4f3b4c43a5ed9db0fb7aa655f5a58f290727a30528a87eb45/anyio-3.0.0.tar.gz", hash = "sha256:b553598332c050af19f7d41f73a7790142f5bc3d5eb8bd82f5e515ec22019bd9", size = 116952 } + wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/49/ebee263b69fe243bd1fd0a88bc6bb0f7732bf1794ba3273cb446351f9482/anyio-3.0.0-py3-none-any.whl", hash = "sha256:e71c3d9d72291d12056c0265d07c6bbedf92332f78573e278aeb116f24f30395", size = 72182 }, + ] + + [[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 }, + ] + "### + ); + }); + + Ok(()) +} + #[test] fn conflicts() -> Result<()> { let context = TestContext::new("3.12"); diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 69ef50269959..cf9111bbf577 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -2519,6 +2519,10 @@ uv export [OPTIONS]
  • lowest-direct: Resolve the lowest compatible version of any direct dependencies, and the highest compatible version of any transitive dependencies
  • +
    --script script

    Export the dependencies for the specified PEP 723 Python script, rather than the current project.

    + +

    If provided, uv will resolve the dependencies 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