Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions crates/uv-cli/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,15 @@ pub enum ListFormat {
Json,
}

#[derive(Debug, Default, Clone, Copy, clap::ValueEnum)]
pub enum ToolListFormat {
/// Display the list of tools as plain text.
#[default]
Text,
/// Display the list of tools as JSON.
Json,
}

fn extra_name_with_clap_error(arg: &str) -> Result<ExtraName> {
ExtraName::from_str(arg).map_err(|_err| {
anyhow!(
Expand Down Expand Up @@ -4501,6 +4510,10 @@ pub struct ToolListArgs {

#[arg(long, hide = true)]
pub no_python_downloads: bool,

/// The format in which the list of tools would be displayed.
#[arg(long, value_enum, default_value_t = ToolListFormat::default())]
pub output_format: ToolListFormat,
}

#[derive(Args)]
Expand Down
174 changes: 174 additions & 0 deletions crates/uv/src/commands/tool/list.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,11 @@ use anyhow::Result;
use itertools::Itertools;
use owo_colors::OwoColorize;

use serde::Serialize;
use uv_cache::Cache;
use uv_cli::ToolListFormat;
use uv_fs::Simplified;
use uv_pep440::Version;
use uv_tool::InstalledTools;
use uv_warnings::warn_user;

Expand All @@ -15,6 +18,32 @@ use crate::printer::Printer;
/// List installed tools.
#[allow(clippy::fn_params_excessive_bools)]
pub(crate) async fn list(
show_paths: bool,
show_version_specifiers: bool,
show_with: bool,
show_extras: bool,
output_format: ToolListFormat,
cache: &Cache,
printer: Printer,
) -> Result<ExitStatus> {
match output_format {
ToolListFormat::Text => {
list_text(
show_paths,
show_version_specifiers,
show_with,
show_extras,
cache,
printer,
)
.await
}
ToolListFormat::Json => list_json(cache, printer).await,
}
}

#[allow(clippy::fn_params_excessive_bools)]
async fn list_text(
show_paths: bool,
show_version_specifiers: bool,
show_with: bool,
Expand Down Expand Up @@ -146,3 +175,148 @@ pub(crate) async fn list(

Ok(ExitStatus::Success)
}

#[derive(Serialize)]
#[serde(untagged)]
enum ToolListEntry {
Tool {
name: String,
version: Version,
version_specifiers: Vec<String>,
extra_requirements: Vec<String>,
with_requirements: Vec<String>,
directory: String,
environment: EnvironmentInfo,
entrypoints: Vec<Entrypoint>,
},
MalformedTool {
name: String,
},
Error {
name: String,
error: String,
},
}

#[derive(Serialize)]
#[serde(untagged)]
enum EnvironmentInfo {
Environment { python: String, version: Version },
NoEnvironment,
Error { error: String },
}

#[derive(Serialize)]
struct Entrypoint {
name: String,
path: String,
}

async fn list_json(cache: &Cache, printer: Printer) -> Result<ExitStatus> {
let installed_tools = InstalledTools::from_settings()?;

match installed_tools.lock().await {
Ok(_lock) => (),
Err(uv_tool::Error::Io(err)) if err.kind() == std::io::ErrorKind::NotFound => {
writeln!(printer.stdout(), "[]")?;
return Ok(ExitStatus::Success);
}
Err(err) => return Err(err.into()),
}

let tools = installed_tools.tools()?;

if tools.is_empty() {
writeln!(printer.stdout(), "[]")?;
return Ok(ExitStatus::Success);
}

let tool_list = tools
.into_iter()
.sorted_by_cached_key(|(name, _)| name.clone())
.map(|(name, tool)| match tool {
Err(_) => ToolListEntry::MalformedTool {
name: name.to_string(),
},
Ok(tool) => {
let version = match installed_tools.version(&name, cache) {
Ok(version) => version,
Err(error) => {
return ToolListEntry::Error {
name: name.to_string(),
error: error.to_string(),
};
}
};

let mut version_specifiers = vec![];
let mut extra_requirements = vec![];
let mut with_requirements = vec![];

tool.requirements().iter().for_each(|req| {
if req.name == name {
let specifier = req.source.to_string();

if !specifier.is_empty() {
version_specifiers.push(specifier);
}

for extra in &req.extras {
extra_requirements.push(extra.to_string());
}
} else {
with_requirements.push(format!("{}{}", req.name, req.source));
}
});

let directory = installed_tools.tool_dir(&name).display().to_string();
let environment = match installed_tools.get_environment(&name, cache) {
Ok(None) => EnvironmentInfo::NoEnvironment,
Err(error) => EnvironmentInfo::Error {
error: error.to_string(),
},
Ok(Some(environment)) => {
let python_executable = environment.python_executable();
let interpreter = environment.interpreter();

EnvironmentInfo::Environment {
python: python_executable.display().to_string(),
version: interpreter.python_version().clone(),
}
}
};

let entrypoints = tool
.entrypoints()
.iter()
.map(|entrypoint| {
let name = entrypoint.name.to_string();
let path = entrypoint.install_path.display().to_string();

let path = if cfg!(windows) {
path.replace('/', "\\")
} else {
path
};

Entrypoint { name, path }
})
.collect::<Vec<_>>();

ToolListEntry::Tool {
name: name.to_string(),
version,
version_specifiers,
extra_requirements,
with_requirements,
directory,
environment,
entrypoints,
}
}
})
.collect::<Vec<_>>();

writeln!(printer.stdout(), "{}", serde_json::to_string(&tool_list)?)?;
Ok(ExitStatus::Success)
}
1 change: 1 addition & 0 deletions crates/uv/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1303,6 +1303,7 @@ async fn run(mut cli: Cli) -> Result<ExitStatus> {
args.show_version_specifiers,
args.show_with,
args.show_extras,
args.output_format,
&cache,
printer,
)
Expand Down
5 changes: 4 additions & 1 deletion crates/uv/src/settings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ use uv_cli::{
PipCheckArgs, PipCompileArgs, PipFreezeArgs, PipInstallArgs, PipListArgs, PipShowArgs,
PipSyncArgs, PipTreeArgs, PipUninstallArgs, PythonFindArgs, PythonInstallArgs, PythonListArgs,
PythonListFormat, PythonPinArgs, PythonUninstallArgs, PythonUpgradeArgs, RemoveArgs, RunArgs,
SyncArgs, SyncFormat, ToolDirArgs, ToolInstallArgs, ToolListArgs, ToolRunArgs,
SyncArgs, SyncFormat, ToolDirArgs, ToolInstallArgs, ToolListArgs, ToolListFormat, ToolRunArgs,
ToolUninstallArgs, TreeArgs, VenvArgs, VersionArgs, VersionBump, VersionFormat,
};
use uv_cli::{
Expand Down Expand Up @@ -783,6 +783,7 @@ pub(crate) struct ToolListSettings {
pub(crate) show_version_specifiers: bool,
pub(crate) show_with: bool,
pub(crate) show_extras: bool,
pub(crate) output_format: ToolListFormat,
}

impl ToolListSettings {
Expand All @@ -796,13 +797,15 @@ impl ToolListSettings {
show_extras,
python_preference: _,
no_python_downloads: _,
output_format,
} = args;

Self {
show_paths,
show_version_specifiers,
show_with,
show_extras,
output_format,
}
}
}
Expand Down
12 changes: 8 additions & 4 deletions crates/uv/tests/it/common/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ pub const INSTA_FILTERS: &[(&str, &str)] = &[
(r"tv_sec: \d+", "tv_sec: [TIME]"),
(r"tv_nsec: \d+", "tv_nsec: [TIME]"),
// Rewrite Windows output to Unix output
(r"\\([\w\d]|\.)", "/$1"),
(r"\\{1,2}([\w\d]|\.)", "/$1"),
(r"uv\.exe", "uv"),
// uv version display
(
Expand Down Expand Up @@ -225,7 +225,10 @@ impl TestContext {
#[must_use]
pub fn with_filtered_virtualenv_bin(mut self) -> Self {
self.filters.push((
format!(r"[\\/]{}", venv_bin_path(PathBuf::new()).to_string_lossy()),
format!(
r"(?:\\{{1,2}}|/){}",
venv_bin_path(PathBuf::new()).to_string_lossy()
),
"/[BIN]".to_string(),
));
self
Expand Down Expand Up @@ -1172,11 +1175,12 @@ impl TestContext {
fn path_pattern(path: impl AsRef<Path>) -> String {
format!(
// Trim the trailing separator for cross-platform directories filters
r"{}\\?/?",
r"{}\\{{0,2}}/?",
regex::escape(&path.as_ref().simplified_display().to_string())
// Make separators platform agnostic because on Windows we will display
// paths with Unix-style separators sometimes
.replace(r"\\", r"(\\|\/)")
// (Double-backslashes is for JSON-ified Windows paths.)
.replace(r"\\", r"(\\{1,2}|\/)")
)
}

Expand Down
1 change: 0 additions & 1 deletion crates/uv/tests/it/pip_list.rs
Original file line number Diff line number Diff line change
Expand Up @@ -483,7 +483,6 @@ fn list_exclude() {

#[test]
#[cfg(feature = "pypi")]
#[cfg(not(windows))]
fn list_format_json() {
let context = TestContext::new("3.12");

Expand Down
51 changes: 51 additions & 0 deletions crates/uv/tests/it/tool_list.rs
Original file line number Diff line number Diff line change
Expand Up @@ -563,3 +563,54 @@ fn tool_list_show_extras() {
----- stderr -----
"###);
}

#[test]
fn tool_list_output_format_json() {
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();

if cfg!(windows) {
uv_snapshot!(context.filters(), context.tool_list().arg("--output-format=json")
.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 -----
[{"name":"black","version":"24.2.0","version_specifiers":["==24.2.0"],"extra_requirements":[],"with_requirements":[],"directory":"[TEMP_DIR]/tools/black","environment":{"python":"[TEMP_DIR]/tools/black/Scripts/python","version":"3.12.[X]"},"entrypoints":[{"name":"black","path":"[TEMP_DIR]/bin/black"},{"name":"blackd","path":"[TEMP_DIR]/bin/blackd"}]},{"name":"flask","version":"3.0.2","version_specifiers":[],"extra_requirements":["async","dotenv"],"with_requirements":["requests"],"directory":"[TEMP_DIR]/tools/flask","environment":{"python":"[TEMP_DIR]/tools/flask/Scripts/python","version":"3.12.[X]"},"entrypoints":[{"name":"flask","path":"[TEMP_DIR]/bin/flask"}]}]

----- stderr -----
"###);
} else {
uv_snapshot!(context.filters(), context.tool_list().arg("--output-format=json")
.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 -----
[{"name":"black","version":"24.2.0","version_specifiers":["==24.2.0"],"extra_requirements":[],"with_requirements":[],"directory":"[TEMP_DIR]/tools/black","environment":{"python":"[TEMP_DIR]/tools/black/bin/python3","version":"3.12.[X]"},"entrypoints":[{"name":"black","path":"[TEMP_DIR]/bin/black"},{"name":"blackd","path":"[TEMP_DIR]/bin/blackd"}]},{"name":"flask","version":"3.0.2","version_specifiers":[],"extra_requirements":["async","dotenv"],"with_requirements":["requests"],"directory":"[TEMP_DIR]/tools/flask","environment":{"python":"[TEMP_DIR]/tools/flask/bin/python3","version":"3.12.[X]"},"entrypoints":[{"name":"flask","path":"[TEMP_DIR]/bin/flask"}]}]

----- stderr -----
"###);
}
}
7 changes: 6 additions & 1 deletion docs/reference/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -2356,7 +2356,12 @@ uv tool list [OPTIONS]
<p>For example, spinners or progress bars.</p>
<p>May also be set with the <code>UV_NO_PROGRESS</code> environment variable.</p></dd><dt id="uv-tool-list--offline"><a href="#uv-tool-list--offline"><code>--offline</code></a></dt><dd><p>Disable network access.</p>
<p>When disabled, uv will only use locally cached data and locally available files.</p>
<p>May also be set with the <code>UV_OFFLINE</code> environment variable.</p></dd><dt id="uv-tool-list--project"><a href="#uv-tool-list--project"><code>--project</code></a> <i>project</i></dt><dd><p>Run the command within the given project directory.</p>
<p>May also be set with the <code>UV_OFFLINE</code> environment variable.</p></dd><dt id="uv-tool-list--output-format"><a href="#uv-tool-list--output-format"><code>--output-format</code></a> <i>output-format</i></dt><dd><p>The format in which the list of tools would be displayed</p>
<p>[default: text]</p><p>Possible values:</p>
<ul>
<li><code>text</code>: Display the list of tools as plain text</li>
<li><code>json</code>: Display the list of tools as JSON</li>
</ul></dd><dt id="uv-tool-list--project"><a href="#uv-tool-list--project"><code>--project</code></a> <i>project</i></dt><dd><p>Run the command within the given project directory.</p>
<p>All <code>pyproject.toml</code>, <code>uv.toml</code>, and <code>.python-version</code> files will be discovered by walking up the directory tree from the project root, as will the project's virtual environment (<code>.venv</code>).</p>
<p>Other command-line arguments (such as relative paths) will be resolved relative to the current working directory.</p>
<p>See <code>--directory</code> to change the working directory entirely.</p>
Expand Down
Loading