diff --git a/crates/uv-cli/src/lib.rs b/crates/uv-cli/src/lib.rs index ea82e4dfd..4e4d67694 100644 --- a/crates/uv-cli/src/lib.rs +++ b/crates/uv-cli/src/lib.rs @@ -4899,6 +4899,10 @@ pub struct ToolListArgs { #[arg(long)] pub show_extras: bool, + /// Whether to display the Python version associated with run each tool. + #[arg(long)] + pub show_python: bool, + // Hide unused global Python options. #[arg(long, hide = true)] pub python_preference: Option, diff --git a/crates/uv-tool/src/lib.rs b/crates/uv-tool/src/lib.rs index d1330efe3..684ce0024 100644 --- a/crates/uv-tool/src/lib.rs +++ b/crates/uv-tool/src/lib.rs @@ -26,6 +26,41 @@ pub use tool::{Tool, ToolEntrypoint}; mod receipt; mod tool; +/// A wrapper around [`PythonEnvironment`] for tools that provides additional functionality. +#[derive(Debug, Clone)] +pub struct ToolEnvironment { + environment: PythonEnvironment, + name: PackageName, +} + +impl ToolEnvironment { + pub fn new(environment: PythonEnvironment, name: PackageName) -> Self { + Self { environment, name } + } + + /// Return the [`Version`] of the tool package in this environment. + pub fn version(&self) -> Result { + let site_packages = SitePackages::from_environment(&self.environment).map_err(|err| { + Error::EnvironmentRead(self.environment.root().to_path_buf(), err.to_string()) + })?; + let packages = site_packages.get_packages(&self.name); + let package = packages + .first() + .ok_or_else(|| Error::MissingToolPackage(self.name.clone()))?; + Ok(package.version().clone()) + } + + /// Get the underlying [`PythonEnvironment`]. + pub fn into_environment(self) -> PythonEnvironment { + self.environment + } + + /// Get a reference to the underlying [`PythonEnvironment`]. + pub fn environment(&self) -> &PythonEnvironment { + &self.environment + } +} + #[derive(Error, Debug)] pub enum Error { #[error(transparent)] @@ -50,6 +85,8 @@ pub enum Error { EnvironmentRead(PathBuf, String), #[error("Failed find package `{0}` in tool environment")] MissingToolPackage(PackageName), + #[error("Tool `{0}` environment not found at `{1}`")] + ToolEnvironmentNotFound(PackageName, PathBuf), } /// A collection of uv-managed tools installed on the current system. @@ -201,7 +238,7 @@ impl InstalledTools { &self, name: &PackageName, cache: &Cache, - ) -> Result, Error> { + ) -> Result, Error> { let environment_path = self.tool_dir(name); match PythonEnvironment::from_root(&environment_path, cache) { @@ -210,7 +247,7 @@ impl InstalledTools { "Found existing environment for tool `{name}`: {}", environment_path.user_display() ); - Ok(Some(venv)) + Ok(Some(ToolEnvironment::new(venv, name.clone()))) } Err(uv_python::Error::MissingEnvironment(_)) => Ok(None), Err(uv_python::Error::Query(uv_python::InterpreterError::NotFound( @@ -290,19 +327,6 @@ impl InstalledTools { )) } - /// Return the [`Version`] of an installed tool. - pub fn version(&self, name: &PackageName, cache: &Cache) -> Result { - let environment_path = self.tool_dir(name); - let environment = PythonEnvironment::from_root(&environment_path, cache)?; - let site_packages = SitePackages::from_environment(&environment) - .map_err(|err| Error::EnvironmentRead(environment_path.clone(), err.to_string()))?; - let packages = site_packages.get_packages(name); - let package = packages - .first() - .ok_or_else(|| Error::MissingToolPackage(name.clone()))?; - Ok(package.version().clone()) - } - /// Initialize the tools directory. /// /// Ensures the directory is created. diff --git a/crates/uv/src/commands/tool/install.rs b/crates/uv/src/commands/tool/install.rs index 04b5ef884..0f35ad70c 100644 --- a/crates/uv/src/commands/tool/install.rs +++ b/crates/uv/src/commands/tool/install.rs @@ -348,11 +348,11 @@ pub(crate) async fn install( installed_tools .get_environment(package_name, &cache)? .filter(|environment| { - if environment.uses(&interpreter) { + if environment.environment().uses(&interpreter) { trace!( "Existing interpreter matches the requested interpreter for `{}`: {}", package_name, - environment.interpreter().sys_executable().display() + environment.environment().interpreter().sys_executable().display() ); true } else { @@ -399,7 +399,7 @@ pub(crate) async fn install( let tags = resolution_tags(None, python_platform.as_ref(), &interpreter)?; // Check if the installed packages meet the requirements. - let site_packages = SitePackages::from_environment(environment)?; + let site_packages = SitePackages::from_environment(environment.environment())?; if matches!( site_packages.satisfies_requirements( requirements.iter(), @@ -461,7 +461,7 @@ pub(crate) async fn install( // be invalidated by moving the environment. let environment = if let Some(environment) = existing_environment { let environment = match update_environment( - environment, + environment.into_environment(), spec, Modifications::Exact, python_platform.as_ref(), diff --git a/crates/uv/src/commands/tool/list.rs b/crates/uv/src/commands/tool/list.rs index 21b16b020..e6e66a4a7 100644 --- a/crates/uv/src/commands/tool/list.rs +++ b/crates/uv/src/commands/tool/list.rs @@ -6,6 +6,7 @@ use owo_colors::OwoColorize; use uv_cache::Cache; use uv_fs::Simplified; +use uv_python::LenientImplementationName; use uv_tool::InstalledTools; use uv_warnings::warn_user; @@ -19,6 +20,7 @@ pub(crate) async fn list( show_version_specifiers: bool, show_with: bool, show_extras: bool, + show_python: bool, cache: &Cache, printer: Printer, ) -> Result { @@ -50,8 +52,27 @@ pub(crate) async fn list( continue; }; - // Output tool name and version - let version = match installed_tools.version(&name, cache) { + // Get the tool environment + let tool_env = match installed_tools.get_environment(&name, cache) { + Ok(Some(env)) => env, + Ok(None) => { + warn_user!( + "Tool `{name}` environment not found (run `{}` to reinstall)", + format!("uv tool install {name} --reinstall").green() + ); + continue; + } + Err(e) => { + warn_user!( + "{e} (run `{}` to reinstall)", + format!("uv tool install {name} --reinstall").green() + ); + continue; + } + }; + + // Get the tool version + let version = match tool_env.version() { Ok(version) => version, Err(e) => { if let uv_tool::Error::EnvironmentError(e) = e { @@ -97,6 +118,18 @@ pub(crate) async fn list( }) .unwrap_or_default(); + let python_version = if show_python { + let interpreter = tool_env.environment().interpreter(); + let implementation = LenientImplementationName::from(interpreter.implementation_name()); + format!( + " [{} {}]", + implementation.pretty(), + interpreter.python_full_version() + ) + } else { + String::new() + }; + let with_requirements = show_with .then(|| { tool.requirements() @@ -118,7 +151,7 @@ pub(crate) async fn list( printer.stdout(), "{} ({})", format!( - "{name} v{version}{version_specifier}{extra_requirements}{with_requirements}" + "{name} v{version}{version_specifier}{extra_requirements}{with_requirements}{python_version}" ) .bold(), installed_tools.tool_dir(&name).simplified_display().cyan(), @@ -128,7 +161,7 @@ pub(crate) async fn list( printer.stdout(), "{}", format!( - "{name} v{version}{version_specifier}{extra_requirements}{with_requirements}" + "{name} v{version}{version_specifier}{extra_requirements}{with_requirements}{python_version}" ) .bold() )?; diff --git a/crates/uv/src/commands/tool/run.rs b/crates/uv/src/commands/tool/run.rs index e28557089..918d014f9 100644 --- a/crates/uv/src/commands/tool/run.rs +++ b/crates/uv/src/commands/tool/run.rs @@ -460,8 +460,10 @@ async fn show_help( .filter_map(|(name, tool)| { tool.ok().and_then(|_| { installed_tools - .version(&name, cache) + .get_environment(&name, cache) .ok() + .flatten() + .and_then(|tool_env| tool_env.version().ok()) .map(|version| (name, version)) }) }) @@ -931,7 +933,7 @@ async fn get_or_create_environment( .get_environment(&requirement.name, cache)? .filter(|environment| { python_request.as_ref().is_none_or(|python_request| { - python_request.satisfied(environment.interpreter(), cache) + python_request.satisfied(environment.environment().interpreter(), cache) }) }); @@ -967,7 +969,7 @@ async fn get_or_create_environment( let tags = pip::resolution_tags(None, python_platform.as_ref(), &interpreter)?; // Check if the installed packages meet the requirements. - let site_packages = SitePackages::from_environment(&environment)?; + let site_packages = SitePackages::from_environment(environment.environment())?; if matches!( site_packages.satisfies_requirements( requirements.iter(), @@ -984,7 +986,7 @@ async fn get_or_create_environment( Ok(SatisfiesResult::Fresh { .. }) ) { debug!("Using existing tool `{}`", requirement.name); - return Ok((from, environment)); + return Ok((from, environment.into_environment())); } } } diff --git a/crates/uv/src/commands/tool/upgrade.rs b/crates/uv/src/commands/tool/upgrade.rs index fe1cfa09e..330673c24 100644 --- a/crates/uv/src/commands/tool/upgrade.rs +++ b/crates/uv/src/commands/tool/upgrade.rs @@ -341,7 +341,7 @@ async fn upgrade_tool( // Check if we need to create a new environment — if so, resolve it first, then // install the requested tool let (environment, outcome) = if let Some(interpreter) = - interpreter.filter(|interpreter| !environment.uses(interpreter)) + interpreter.filter(|interpreter| !environment.environment().uses(interpreter)) { // If we're using a new interpreter, re-create the environment for each tool. let resolution = resolve_environment( @@ -388,7 +388,7 @@ async fn upgrade_tool( environment, changelog, } = update_environment( - environment, + environment.into_environment(), spec, Modifications::Exact, python_platform, diff --git a/crates/uv/src/lib.rs b/crates/uv/src/lib.rs index b97a22bd6..b370d6eac 100644 --- a/crates/uv/src/lib.rs +++ b/crates/uv/src/lib.rs @@ -1421,6 +1421,7 @@ async fn run(mut cli: Cli) -> Result { args.show_version_specifiers, args.show_with, args.show_extras, + args.show_python, &cache, printer, ) diff --git a/crates/uv/src/settings.rs b/crates/uv/src/settings.rs index 3f5972bc9..e3f255e3b 100644 --- a/crates/uv/src/settings.rs +++ b/crates/uv/src/settings.rs @@ -859,6 +859,7 @@ pub(crate) struct ToolListSettings { pub(crate) show_version_specifiers: bool, pub(crate) show_with: bool, pub(crate) show_extras: bool, + pub(crate) show_python: bool, } impl ToolListSettings { @@ -870,6 +871,7 @@ impl ToolListSettings { show_version_specifiers, show_with, show_extras, + show_python, python_preference: _, no_python_downloads: _, } = args; @@ -879,6 +881,7 @@ impl ToolListSettings { show_version_specifiers, show_with, show_extras, + show_python, } } } diff --git a/crates/uv/tests/it/tool_list.rs b/crates/uv/tests/it/tool_list.rs index cb767d457..3d77f0178 100644 --- a/crates/uv/tests/it/tool_list.rs +++ b/crates/uv/tests/it/tool_list.rs @@ -563,3 +563,81 @@ fn tool_list_show_extras() { ----- stderr ----- "###); } + +#[test] +fn tool_list_show_python() { + let context = TestContext::new("3.12").with_filtered_exe_suffix(); + let tool_dir = context.temp_dir.child("tools"); + let bin_dir = context.temp_dir.child("bin"); + + // Install `black` with python 3.12 + context + .tool_install() + .arg("black==24.2.0") + .env(EnvVars::UV_TOOL_DIR, tool_dir.as_os_str()) + .env(EnvVars::XDG_BIN_HOME, bin_dir.as_os_str()) + .assert() + .success(); + + // Test with --show-python + uv_snapshot!(context.filters(), context.tool_list().arg("--show-python") + .env(EnvVars::UV_TOOL_DIR, tool_dir.as_os_str()) + .env(EnvVars::XDG_BIN_HOME, bin_dir.as_os_str()), @r###" + success: true + exit_code: 0 + ----- stdout ----- + black v24.2.0 [CPython 3.12.[X]] + - black + - blackd + + ----- stderr ----- + "###); +} + +#[test] +fn tool_list_show_all() { + let context = TestContext::new("3.12").with_filtered_exe_suffix(); + let tool_dir = context.temp_dir.child("tools"); + let bin_dir = context.temp_dir.child("bin"); + + // Install `black` without extras + context + .tool_install() + .arg("black==24.2.0") + .env(EnvVars::UV_TOOL_DIR, tool_dir.as_os_str()) + .env(EnvVars::XDG_BIN_HOME, bin_dir.as_os_str()) + .assert() + .success(); + + // Install `flask` with extras and additional requirements + context + .tool_install() + .arg("flask[async,dotenv]") + .arg("--with") + .arg("requests") + .env(EnvVars::UV_TOOL_DIR, tool_dir.as_os_str()) + .env(EnvVars::XDG_BIN_HOME, bin_dir.as_os_str()) + .assert() + .success(); + + // Test with all flags + uv_snapshot!(context.filters(), context.tool_list() + .arg("--show-extras") + .arg("--show-with") + .arg("--show-version-specifiers") + .arg("--show-paths") + .arg("--show-python") + .env(EnvVars::UV_TOOL_DIR, tool_dir.as_os_str()) + .env(EnvVars::XDG_BIN_HOME, bin_dir.as_os_str()), @r###" + success: true + exit_code: 0 + ----- stdout ----- + black v24.2.0 [required: ==24.2.0] [CPython 3.12.[X]] ([TEMP_DIR]/tools/black) + - black ([TEMP_DIR]/bin/black) + - blackd ([TEMP_DIR]/bin/blackd) + flask v3.0.2 [extras: async, dotenv] [with: requests] [CPython 3.12.[X]] ([TEMP_DIR]/tools/flask) + - flask ([TEMP_DIR]/bin/flask) + + ----- stderr ----- + "###); +} diff --git a/docs/reference/cli.md b/docs/reference/cli.md index f130ca0d4..eff9f3214 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -3047,6 +3047,7 @@ uv tool list [OPTIONS]

Repeating this option, e.g., -qq, will enable a silent mode in which uv will write no output to stdout.

--show-extras

Whether to display the extra requirements installed with each tool

--show-paths

Whether to display the path to each tool environment and installed executable

+
--show-python

Whether to display the Python version associated with run each tool

--show-version-specifiers

Whether to display the version specifier(s) used to install each tool

--show-with

Whether to display the additional requirements installed with each tool

--verbose, -v

Use verbose output.