Skip to content

Commit 69fa904

Browse files
committed
Add initial implementation of uv tool run
# Conflicts: # crates/uv/src/commands/mod.rs # crates/uv/src/commands/project/run.rs # Conflicts: # crates/uv/src/commands/project/mod.rs
1 parent dfd6ccf commit 69fa904

File tree

6 files changed

+182
-1
lines changed

6 files changed

+182
-1
lines changed

crates/uv/src/cli.rs

+35
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,8 @@ impl From<ColorChoice> for anstream::ColorChoice {
121121
pub(crate) enum Commands {
122122
/// Resolve and install Python packages.
123123
Pip(PipNamespace),
124+
/// Run and manage executable Python packages.
125+
Tool(ToolNamespace),
124126
/// Create a virtual environment.
125127
#[command(alias = "virtualenv", alias = "v")]
126128
Venv(VenvArgs),
@@ -1920,3 +1922,36 @@ struct RemoveArgs {
19201922
/// The name of the package to remove (e.g., `Django`).
19211923
name: PackageName,
19221924
}
1925+
1926+
#[derive(Args)]
1927+
pub(crate) struct ToolNamespace {
1928+
#[command(subcommand)]
1929+
pub(crate) command: ToolCommand,
1930+
}
1931+
1932+
#[derive(Subcommand)]
1933+
pub(crate) enum ToolCommand {
1934+
/// Run a tool
1935+
Run(ToolRunArgs),
1936+
}
1937+
1938+
#[derive(Args)]
1939+
#[allow(clippy::struct_excessive_bools)]
1940+
pub(crate) struct ToolRunArgs {
1941+
/// The command to run.
1942+
pub(crate) target: String,
1943+
1944+
/// The arguments to the command.
1945+
#[arg(allow_hyphen_values = true)]
1946+
pub(crate) args: Vec<OsString>,
1947+
1948+
/// The Python interpreter to use to build the run environment.
1949+
#[arg(
1950+
long,
1951+
short,
1952+
env = "UV_PYTHON",
1953+
verbatim_doc_comment,
1954+
group = "discovery"
1955+
)]
1956+
pub(crate) python: Option<String>,
1957+
}

crates/uv/src/commands/mod.rs

+3
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ pub(crate) use project::run::run;
2121
pub(crate) use project::sync::sync;
2222
#[cfg(feature = "self-update")]
2323
pub(crate) use self_update::self_update;
24+
pub(crate) use tool::run::run as run_tool;
2425
use uv_cache::Cache;
2526
use uv_fs::Simplified;
2627
use uv_installer::compile_tree;
@@ -37,6 +38,8 @@ mod cache_prune;
3738
mod pip;
3839
mod project;
3940
pub(crate) mod reporters;
41+
mod tool;
42+
4043
#[cfg(feature = "self-update")]
4144
mod self_update;
4245
mod venv;

crates/uv/src/commands/project/mod.rs

+1-1
Original file line numberDiff line numberDiff line change
@@ -461,7 +461,7 @@ pub(crate) async fn install(
461461
}
462462

463463
/// Update a [`PythonEnvironment`] to satisfy a set of [`RequirementsSource`]s.
464-
async fn update_environment(
464+
pub(crate) async fn update_environment(
465465
venv: PythonEnvironment,
466466
requirements: &[RequirementsSource],
467467
preview: PreviewMode,

crates/uv/src/commands/tool/mod.rs

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
pub(crate) mod run;

crates/uv/src/commands/tool/run.rs

+127
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
use std::ffi::OsString;
2+
use std::path::PathBuf;
3+
4+
use anyhow::Result;
5+
use itertools::Itertools;
6+
use tempfile::tempdir_in;
7+
use tokio::process::Command;
8+
use tracing::debug;
9+
10+
use uv_cache::Cache;
11+
use uv_configuration::PreviewMode;
12+
use uv_interpreter::PythonEnvironment;
13+
use uv_requirements::RequirementsSource;
14+
use uv_warnings::warn_user;
15+
16+
use crate::commands::project::update_environment;
17+
use crate::commands::ExitStatus;
18+
use crate::printer::Printer;
19+
20+
/// Run a command.
21+
#[allow(clippy::too_many_arguments)]
22+
pub(crate) async fn run(
23+
target: String,
24+
args: Vec<OsString>,
25+
python: Option<String>,
26+
_isolated: bool,
27+
preview: PreviewMode,
28+
cache: &Cache,
29+
printer: Printer,
30+
) -> Result<ExitStatus> {
31+
if preview.is_disabled() {
32+
warn_user!("`uv tool run` is experimental and may change without warning.");
33+
}
34+
35+
// TODO(zanieb): Allow users to pass an explicit package name different than the target
36+
// as well as additional requirements
37+
let requirements = [RequirementsSource::from_package(target.clone())];
38+
39+
// TODO(zanieb): When implementing project-level tools, discover the project and check if it has the tool
40+
// TOOD(zanieb): Determine if we sould layer on top of the project environment if it is present
41+
42+
// If necessary, create an environment for the ephemeral requirements.
43+
debug!("Syncing ephemeral environment.");
44+
45+
// Discover an interpreter.
46+
let interpreter = if let Some(python) = python.as_ref() {
47+
PythonEnvironment::from_requested_python(python, cache)?.into_interpreter()
48+
} else {
49+
PythonEnvironment::from_default_python(cache)?.into_interpreter()
50+
};
51+
52+
// Create a virtual environment
53+
// TODO(zanieb): Move this path derivation elsewhere
54+
let uv_state_path = std::env::current_dir()?.join(".uv");
55+
fs_err::create_dir_all(&uv_state_path)?;
56+
let tmpdir = tempdir_in(uv_state_path)?;
57+
let venv = uv_virtualenv::create_venv(
58+
tmpdir.path(),
59+
interpreter,
60+
uv_virtualenv::Prompt::None,
61+
false,
62+
false,
63+
)?;
64+
65+
// Install the ephemeral requirements.
66+
let ephemeral_env =
67+
Some(update_environment(venv, &requirements, preview, cache, printer).await?);
68+
69+
// TODO(zanieb): Determine the command via the package entry points
70+
let command = target;
71+
72+
// Construct the command
73+
let mut process = Command::new(&command);
74+
process.args(&args);
75+
76+
// Construct the `PATH` environment variable.
77+
let new_path = std::env::join_paths(
78+
ephemeral_env
79+
.as_ref()
80+
.map(PythonEnvironment::scripts)
81+
.into_iter()
82+
.map(PathBuf::from)
83+
.chain(
84+
std::env::var_os("PATH")
85+
.as_ref()
86+
.iter()
87+
.flat_map(std::env::split_paths),
88+
),
89+
)?;
90+
process.env("PATH", new_path);
91+
92+
// Construct the `PYTHONPATH` environment variable.
93+
let new_python_path = std::env::join_paths(
94+
ephemeral_env
95+
.as_ref()
96+
.map(PythonEnvironment::site_packages)
97+
.into_iter()
98+
.flatten()
99+
.map(PathBuf::from)
100+
.chain(
101+
std::env::var_os("PYTHONPATH")
102+
.as_ref()
103+
.iter()
104+
.flat_map(std::env::split_paths),
105+
),
106+
)?;
107+
process.env("PYTHONPATH", new_python_path);
108+
109+
// Spawn and wait for completion
110+
// Standard input, output, and error streams are all inherited
111+
// TODO(zanieb): Throw a nicer error message if the command is not found
112+
let space = if args.is_empty() { "" } else { " " };
113+
debug!(
114+
"Running `{command}{space}{}`",
115+
args.iter().map(|arg| arg.to_string_lossy()).join(" ")
116+
);
117+
let mut handle = process.spawn()?;
118+
let status = handle.wait().await?;
119+
120+
// Exit based on the result of the command
121+
// TODO(zanieb): Do we want to exit with the code of the child process? Probably.
122+
if status.success() {
123+
Ok(ExitStatus::Success)
124+
} else {
125+
Ok(ExitStatus::Failure)
126+
}
127+
}

crates/uv/src/main.rs

+15
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ use anstream::eprintln;
77
use anyhow::Result;
88
use clap::error::{ContextKind, ContextValue};
99
use clap::{CommandFactory, Parser};
10+
use cli::{ToolCommand, ToolNamespace};
1011
use owo_colors::OwoColorize;
1112
use tracing::instrument;
1213

@@ -599,6 +600,20 @@ async fn run() -> Result<ExitStatus> {
599600
shell.generate(&mut Cli::command(), &mut stdout());
600601
Ok(ExitStatus::Success)
601602
}
603+
Commands::Tool(ToolNamespace {
604+
command: ToolCommand::Run(args),
605+
}) => {
606+
commands::run_tool(
607+
args.target,
608+
args.args,
609+
args.python,
610+
globals.isolated,
611+
globals.preview,
612+
&cache,
613+
printer,
614+
)
615+
.await
616+
}
602617
}
603618
}
604619

0 commit comments

Comments
 (0)