From 32ae0dbd2120c03967732bcf9cd5041f23aea5f8 Mon Sep 17 00:00:00 2001 From: Zanie Blue Date: Tue, 21 May 2024 10:17:43 -0500 Subject: [PATCH] Add initial implementation of `uv tool run` # Conflicts: # crates/uv/src/commands/mod.rs # crates/uv/src/commands/project/run.rs --- crates/uv/src/cli.rs | 35 +++++++ crates/uv/src/commands/mod.rs | 3 + crates/uv/src/commands/project/mod.rs | 4 +- crates/uv/src/commands/tool/mod.rs | 1 + crates/uv/src/commands/tool/run.rs | 127 ++++++++++++++++++++++++++ crates/uv/src/main.rs | 15 +++ 6 files changed, 183 insertions(+), 2 deletions(-) create mode 100644 crates/uv/src/commands/tool/mod.rs create mode 100644 crates/uv/src/commands/tool/run.rs diff --git a/crates/uv/src/cli.rs b/crates/uv/src/cli.rs index 18f7a417aa67..2f47172fef12 100644 --- a/crates/uv/src/cli.rs +++ b/crates/uv/src/cli.rs @@ -121,6 +121,8 @@ impl From for anstream::ColorChoice { pub(crate) enum Commands { /// Resolve and install Python packages. Pip(PipNamespace), + /// Run and manage executable Python packages. + Tool(ToolNamespace), /// Create a virtual environment. #[command(alias = "virtualenv", alias = "v")] Venv(VenvArgs), @@ -1920,3 +1922,36 @@ struct RemoveArgs { /// The name of the package to remove (e.g., `Django`). name: PackageName, } + +#[derive(Args)] +pub(crate) struct ToolNamespace { + #[command(subcommand)] + pub(crate) command: ToolCommand, +} + +#[derive(Subcommand)] +pub(crate) enum ToolCommand { + /// Run a tool + Run(ToolRunArgs), +} + +#[derive(Args)] +#[allow(clippy::struct_excessive_bools)] +pub(crate) struct ToolRunArgs { + /// The command to run. + pub(crate) target: String, + + /// The arguments to the command. + #[arg(allow_hyphen_values = true)] + pub(crate) args: Vec, + + /// The Python interpreter to use to build the run environment. + #[arg( + long, + short, + env = "UV_PYTHON", + verbatim_doc_comment, + group = "discovery" + )] + pub(crate) python: Option, +} diff --git a/crates/uv/src/commands/mod.rs b/crates/uv/src/commands/mod.rs index ede4ff5172e8..eb5b515c9bab 100644 --- a/crates/uv/src/commands/mod.rs +++ b/crates/uv/src/commands/mod.rs @@ -21,6 +21,7 @@ pub(crate) use project::run::run; pub(crate) use project::sync::sync; #[cfg(feature = "self-update")] pub(crate) use self_update::self_update; +pub(crate) use tool::run::run as run_tool; use uv_cache::Cache; use uv_fs::Simplified; use uv_installer::compile_tree; @@ -37,6 +38,8 @@ mod cache_prune; mod pip; mod project; pub(crate) mod reporters; +mod tool; + #[cfg(feature = "self-update")] mod self_update; mod venv; diff --git a/crates/uv/src/commands/project/mod.rs b/crates/uv/src/commands/project/mod.rs index c173c35f3ca7..bf2514f4282d 100644 --- a/crates/uv/src/commands/project/mod.rs +++ b/crates/uv/src/commands/project/mod.rs @@ -37,7 +37,7 @@ use crate::commands::{elapsed, ChangeEvent, ChangeEventKind}; use crate::editables::ResolvedEditables; use crate::printer::Printer; -mod discovery; +pub(crate) mod discovery; pub(crate) mod lock; pub(crate) mod run; pub(crate) mod sync; @@ -463,7 +463,7 @@ pub(crate) async fn install( } /// Update a [`PythonEnvironment`] to satisfy a set of [`RequirementsSource`]s. -async fn update_environment( +pub(crate) async fn update_environment( venv: PythonEnvironment, requirements: &[RequirementsSource], preview: PreviewMode, diff --git a/crates/uv/src/commands/tool/mod.rs b/crates/uv/src/commands/tool/mod.rs new file mode 100644 index 000000000000..bcb3b3839ac3 --- /dev/null +++ b/crates/uv/src/commands/tool/mod.rs @@ -0,0 +1 @@ +pub(crate) mod run; diff --git a/crates/uv/src/commands/tool/run.rs b/crates/uv/src/commands/tool/run.rs new file mode 100644 index 000000000000..02d4a6dd45e9 --- /dev/null +++ b/crates/uv/src/commands/tool/run.rs @@ -0,0 +1,127 @@ +use std::ffi::OsString; +use std::path::PathBuf; + +use anyhow::Result; +use itertools::Itertools; +use tempfile::tempdir_in; +use tokio::process::Command; +use tracing::debug; + +use uv_cache::Cache; +use uv_configuration::PreviewMode; +use uv_interpreter::PythonEnvironment; +use uv_requirements::RequirementsSource; +use uv_warnings::warn_user; + +use crate::commands::project::update_environment; +use crate::commands::ExitStatus; +use crate::printer::Printer; + +/// Run a command. +#[allow(clippy::too_many_arguments)] +pub(crate) async fn run( + target: String, + args: Vec, + python: Option, + _isolated: bool, + preview: PreviewMode, + cache: &Cache, + printer: Printer, +) -> Result { + if preview.is_disabled() { + warn_user!("`uv tool run` is experimental and may change without warning."); + } + + // TODO(zanieb): Allow users to pass an explicit package name different than the target + // as well as additional requirements + let requirements = [RequirementsSource::from_package(target.clone())]; + + // TODO(zanieb): When implementing project-level tools, discover the project and check if it has the tool + // TOOD(zanieb): Determine if we sould layer on top of the project environment if it is present + + // If necessary, create an environment for the ephemeral requirements. + debug!("Syncing ephemeral environment."); + + // Discover an interpreter. + let interpreter = if let Some(python) = python.as_ref() { + PythonEnvironment::from_requested_python(python, cache)?.into_interpreter() + } else { + PythonEnvironment::from_default_python(cache)?.into_interpreter() + }; + + // Create a virtual environment + // TODO(zanieb): Move this path derivation elsewhere + let uv_state_path = std::env::current_dir()?.join(".uv"); + fs_err::create_dir_all(&uv_state_path)?; + let tmpdir = tempdir_in(uv_state_path)?; + let venv = uv_virtualenv::create_venv( + tmpdir.path(), + interpreter, + uv_virtualenv::Prompt::None, + false, + false, + )?; + + // Install the ephemeral requirements. + let ephemeral_env = + Some(update_environment(venv, &requirements, preview, cache, printer).await?); + + // TODO(zanieb): Determine the command via the package entry points + let command = target; + + // Construct the command + let mut process = Command::new(&command); + process.args(&args); + + // Construct the `PATH` environment variable. + let new_path = std::env::join_paths( + ephemeral_env + .as_ref() + .map(PythonEnvironment::scripts) + .into_iter() + .map(PathBuf::from) + .chain( + std::env::var_os("PATH") + .as_ref() + .iter() + .flat_map(std::env::split_paths), + ), + )?; + process.env("PATH", new_path); + + // Construct the `PYTHONPATH` environment variable. + let new_python_path = std::env::join_paths( + ephemeral_env + .as_ref() + .map(PythonEnvironment::site_packages) + .into_iter() + .flatten() + .map(PathBuf::from) + .chain( + std::env::var_os("PYTHONPATH") + .as_ref() + .iter() + .flat_map(std::env::split_paths), + ), + )?; + process.env("PYTHONPATH", new_python_path); + + // Spawn and wait for completion + // Standard input, output, and error streams are all inherited + // TODO(zanieb): Throw a nicer error message if the command is not found + let space = if args.is_empty() { "" } else { " " }; + debug!( + "Running `{command}{space}{}`", + args.iter().map(|arg| arg.to_string_lossy()).join(" ") + ); + let mut handle = process.spawn()?; + let status = handle.wait().await?; + + // Exit based on the result of the command + // TODO(zanieb): Do we want to exit with the code of the child process? Probably. + if status.success() { + Ok(ExitStatus::Success) + } else { + Ok(ExitStatus::Failure) + } +} diff --git a/crates/uv/src/main.rs b/crates/uv/src/main.rs index 4a8ebe129013..24b31701a565 100644 --- a/crates/uv/src/main.rs +++ b/crates/uv/src/main.rs @@ -7,6 +7,7 @@ use anstream::eprintln; use anyhow::Result; use clap::error::{ContextKind, ContextValue}; use clap::{CommandFactory, Parser}; +use cli::{ToolCommand, ToolNamespace}; use owo_colors::OwoColorize; use tracing::instrument; @@ -599,6 +600,20 @@ async fn run() -> Result { shell.generate(&mut Cli::command(), &mut stdout()); Ok(ExitStatus::Success) } + Commands::Tool(ToolNamespace { + command: ToolCommand::Run(args), + }) => { + commands::run_tool( + args.target, + args.args, + args.python, + globals.isolated, + globals.preview, + &cache, + printer, + ) + .await + } } }