Add uv tool install (#4492)

This is the minimal "working" implementation. In summary, we:

- Resolve the requested requirements
- Create an environment at `$UV_STATE_DIR/tools/$name`
- Inspect the `dist-info` for the main requirement to determine its
entry points scripts
- Link the entry points from a user-executable directory
(`$XDG_BIN_HOME`) to the environment bin
- Create an entry at `$UV_STATE_DIR/tools/tools.toml` tracking the
user's request

The idea with `tools.toml` is that it allows us to perform upgrades and
syncs, retaining the original user request (similar to declarations in a
`pyproject.toml`). I imagine using a similar schema in the
`pyproject.toml` in the future if/when we add project-levle tools. I'm
also considering exposing `tools.toml` in the standard uv configuration
directory instead of the state directory, but it seems nice to tuck it
away for now while we iterate on it. Installing a tool won't perform a
sync of other tool environments, we'll probably have an explicit `uv
tool sync` command for that?

I've split out todos into follow-up pull requests:

- #4509 (failing on Windows)
- #4501 
- #4504 

Closes #4485
This commit is contained in:
Zanie Blue 2024-06-26 11:24:29 -04:00 committed by GitHub
parent b677a06aba
commit c9657b0015
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 744 additions and 26 deletions

22
Cargo.lock generated
View file

@ -4488,6 +4488,7 @@ dependencies = [
"uv-requirements",
"uv-resolver",
"uv-settings",
"uv-tool",
"uv-toolchain",
"uv-types",
"uv-virtualenv",
@ -5011,6 +5012,27 @@ dependencies = [
"tempfile",
]
[[package]]
name = "uv-tool"
version = "0.0.1"
dependencies = [
"dirs-sys",
"fs-err",
"install-wheel-rs",
"pep440_rs",
"pep508_rs",
"pypi-types",
"serde",
"thiserror",
"toml",
"toml_edit",
"tracing",
"uv-fs",
"uv-state",
"uv-toolchain",
"uv-virtualenv",
]
[[package]]
name = "uv-toolchain"
version = "0.0.1"

View file

@ -45,6 +45,7 @@ uv-requirements = { path = "crates/uv-requirements" }
uv-resolver = { path = "crates/uv-resolver" }
uv-settings = { path = "crates/uv-settings" }
uv-state = { path = "crates/uv-state" }
uv-tool = { path = "crates/uv-tool" }
uv-toolchain = { path = "crates/uv-toolchain" }
uv-types = { path = "crates/uv-types" }
uv-version = { path = "crates/uv-version" }

View file

@ -11,9 +11,11 @@ use zip::result::ZipError;
use pep440_rs::Version;
use platform_tags::{Arch, Os};
use pypi_types::Scheme;
pub use script::{scripts_from_ini, Script};
pub use uninstall::{uninstall_egg, uninstall_legacy_editable, uninstall_wheel, Uninstall};
use uv_fs::Simplified;
use uv_normalize::PackageName;
pub use wheel::{parse_wheel_file, LibKind};
pub mod linker;
pub mod metadata;

View file

@ -1,7 +1,7 @@
//! Like `wheel.rs`, but for installing wheels that have already been unzipped, rather than
//! reading from a zip file.
use std::path::Path;
use std::path::{Path, PathBuf};
use std::str::FromStr;
use std::time::SystemTime;
@ -143,6 +143,24 @@ pub fn install_wheel(
Ok(())
}
/// Determine the absolute path to an entrypoint script.
pub fn entrypoint_path(entrypoint: &Script, layout: &Layout) -> PathBuf {
if cfg!(windows) {
// On windows we actually build an .exe wrapper
let script_name = entrypoint
.name
// FIXME: What are the in-reality rules here for names?
.strip_suffix(".py")
.unwrap_or(&entrypoint.name)
.to_string()
+ ".exe";
layout.scheme.scripts.join(script_name)
} else {
layout.scheme.scripts.join(&entrypoint.name)
}
}
/// Find the `dist-info` directory in an unzipped wheel.
///
/// See: <https://github.com/PyO3/python-pkginfo-rs>

View file

@ -9,10 +9,10 @@ use crate::{wheel, Error};
/// A script defining the name of the runnable entrypoint and the module and function that should be
/// run.
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
pub(crate) struct Script {
pub(crate) name: String,
pub(crate) module: String,
pub(crate) function: String,
pub struct Script {
pub name: String,
pub module: String,
pub function: String,
}
impl Script {
@ -64,7 +64,7 @@ impl Script {
}
}
pub(crate) fn scripts_from_ini(
pub fn scripts_from_ini(
extras: Option<&[String]>,
python_minor: u8,
ini: String,

View file

@ -17,6 +17,7 @@ use zip::ZipWriter;
use pypi_types::DirectUrl;
use uv_fs::{relative_to, Simplified};
use crate::linker::entrypoint_path;
use crate::record::RecordEntry;
use crate::script::Script;
use crate::{Error, Layout};
@ -255,20 +256,7 @@ pub(crate) fn write_script_entrypoints(
is_gui: bool,
) -> Result<(), Error> {
for entrypoint in entrypoints {
let entrypoint_absolute = if cfg!(windows) {
// On windows we actually build an .exe wrapper
let script_name = entrypoint
.name
// FIXME: What are the in-reality rules here for names?
.strip_suffix(".py")
.unwrap_or(&entrypoint.name)
.to_string()
+ ".exe";
layout.scheme.scripts.join(script_name)
} else {
layout.scheme.scripts.join(&entrypoint.name)
};
let entrypoint_absolute = entrypoint_path(entrypoint, layout);
let entrypoint_relative = pathdiff::diff_paths(&entrypoint_absolute, site_packages)
.ok_or_else(|| {
@ -320,7 +308,7 @@ pub(crate) fn write_script_entrypoints(
/// Whether the wheel should be installed into the `purelib` or `platlib` directory.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum LibKind {
pub enum LibKind {
/// Install into the `purelib` directory.
Pure,
/// Install into the `platlib` directory.
@ -331,7 +319,7 @@ pub(crate) enum LibKind {
///
/// > {distribution}-{version}.dist-info/WHEEL is metadata about the archive itself in the same
/// > basic key: value format:
pub(crate) fn parse_wheel_file(wheel_text: &str) -> Result<LibKind, Error> {
pub fn parse_wheel_file(wheel_text: &str) -> Result<LibKind, Error> {
// {distribution}-{version}.dist-info/WHEEL is metadata about the archive itself in the same basic key: value format:
let data = parse_key_value_file(&mut wheel_text.as_bytes(), "WHEEL")?;

View file

@ -1838,6 +1838,8 @@ pub struct ToolNamespace {
pub enum ToolCommand {
/// Run a tool
Run(ToolRunArgs),
/// Install a tool
Install(ToolInstallArgs),
}
#[derive(Args)]
@ -1881,6 +1883,46 @@ pub struct ToolRunArgs {
pub python: Option<String>,
}
#[derive(Args)]
#[allow(clippy::struct_excessive_bools)]
pub struct ToolInstallArgs {
/// The command to install.
pub name: String,
/// Use the given package to provide the command.
///
/// By default, the package name is assumed to match the command name.
#[arg(long)]
pub from: Option<String>,
/// Include the following extra requirements.
#[arg(long)]
pub with: Vec<String>,
#[command(flatten)]
pub installer: ResolverInstallerArgs,
#[command(flatten)]
pub build: BuildArgs,
#[command(flatten)]
pub refresh: RefreshArgs,
/// The Python interpreter to use to build the tool environment.
///
/// By default, uv will search for a Python executable in the `PATH`. uv ignores virtual
/// environments while looking for interpreter for tools. The `--python` option allows
/// you to specify a different interpreter.
///
/// Supported formats:
/// - `3.10` looks for an installed Python 3.10 using `py --list-paths` on Windows, or
/// `python3.10` on Linux and macOS.
/// - `python3.10` or `python.exe` looks for a binary with the given name in `PATH`.
/// - `/home/ferris/.local/bin/python3.10` uses the exact Python at the given path.
#[arg(long, short, env = "UV_PYTHON", verbatim_doc_comment)]
pub python: Option<String>,
}
#[derive(Args)]
#[allow(clippy::struct_excessive_bools)]
pub struct ToolchainNamespace {

View file

@ -95,14 +95,17 @@ impl StateStore {
/// are subdirectories of the state store root.
#[derive(Debug, Clone, Copy, Eq, PartialEq, Hash)]
pub enum StateBucket {
// Managed toolchain
// Managed toolchains
Toolchains,
// Installed tools
Tools,
}
impl StateBucket {
fn to_str(self) -> &'static str {
match self {
Self::Toolchains => "toolchains",
Self::Tools => "tools",
}
}
}

31
crates/uv-tool/Cargo.toml Normal file
View file

@ -0,0 +1,31 @@
[package]
name = "uv-tool"
version = "0.0.1"
edition = { workspace = true }
rust-version = { workspace = true }
homepage = { workspace = true }
documentation = { workspace = true }
repository = { workspace = true }
authors = { workspace = true }
license = { workspace = true }
[lints]
workspace = true
[dependencies]
uv-fs = { workspace = true }
uv-state = { workspace = true }
pep508_rs = { workspace = true }
pypi-types = { workspace = true }
uv-virtualenv = { workspace = true }
uv-toolchain = { workspace = true }
install-wheel-rs = { workspace = true }
pep440_rs = { workspace = true }
thiserror = { workspace = true }
tracing = { workspace = true }
fs-err = { workspace = true }
serde = { workspace = true }
toml = { workspace = true }
toml_edit = { workspace = true }
dirs-sys = { workspace = true }

290
crates/uv-tool/src/lib.rs Normal file
View file

@ -0,0 +1,290 @@
use core::fmt;
use fs_err as fs;
use install_wheel_rs::linker::entrypoint_path;
use install_wheel_rs::{scripts_from_ini, Script};
use pep440_rs::Version;
use pep508_rs::PackageName;
use std::io::{self, Write};
use std::path::{Path, PathBuf};
use thiserror::Error;
use tracing::debug;
use uv_fs::{LockedFile, Simplified};
use uv_toolchain::{Interpreter, PythonEnvironment};
pub use tools_toml::{Tool, ToolsToml, ToolsTomlMut};
use uv_state::{StateBucket, StateStore};
mod tools_toml;
#[derive(Error, Debug)]
pub enum Error {
#[error(transparent)]
IO(#[from] io::Error),
// TODO(zanieb): Improve the error handling here
#[error("Failed to update `tools.toml` at {0}")]
TomlEdit(PathBuf, #[source] tools_toml::Error),
#[error("Failed to read `tools.toml` at {0}")]
TomlRead(PathBuf, #[source] Box<toml::de::Error>),
#[error(transparent)]
VirtualEnvError(#[from] uv_virtualenv::Error),
#[error("Failed to read package entry points {0}")]
EntrypointRead(#[from] install_wheel_rs::Error),
#[error("Failed to find dist-info directory `{0}` in environment at {1}")]
DistInfoMissing(String, PathBuf),
#[error("Failed to find a directory for executables")]
NoExecutableDirectory,
}
/// A collection of uv-managed tools installed on the current system.
#[derive(Debug, Clone)]
pub struct InstalledTools {
/// The path to the top-level directory of the tools.
root: PathBuf,
}
impl InstalledTools {
/// A directory for tools at `root`.
fn from_path(root: impl Into<PathBuf>) -> Self {
Self { root: root.into() }
}
/// Prefer, in order:
/// 1. The specific tool directory specified by the user, i.e., `UV_TOOL_DIR`
/// 2. A directory in the system-appropriate user-level data directory, e.g., `~/.local/uv/tools`
/// 3. A directory in the local data directory, e.g., `./.uv/tools`
pub fn from_settings() -> Result<Self, Error> {
if let Some(tool_dir) = std::env::var_os("UV_TOOL_DIR") {
Ok(Self::from_path(tool_dir))
} else {
Ok(Self::from_path(
StateStore::from_settings(None)?.bucket(StateBucket::Tools),
))
}
}
pub fn tools_toml_path(&self) -> PathBuf {
self.root.join("tools.toml")
}
/// Return the toml tracking tools.
pub fn toml(&self) -> Result<ToolsToml, Error> {
match fs_err::read_to_string(self.tools_toml_path()) {
Ok(contents) => Ok(ToolsToml::from_string(contents)
.map_err(|err| Error::TomlRead(self.tools_toml_path(), Box::new(err)))?),
Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(ToolsToml::default()),
Err(err) => Err(err.into()),
}
}
pub fn toml_mut(&self) -> Result<ToolsTomlMut, Error> {
let toml = self.toml()?;
ToolsTomlMut::from_toml(&toml).map_err(|err| Error::TomlEdit(self.tools_toml_path(), err))
}
pub fn find_tool_entry(&self, name: &str) -> Result<Option<Tool>, Error> {
let toml = self.toml()?;
Ok(toml.tools.and_then(|tools| tools.get(name).cloned()))
}
pub fn acquire_lock(&self) -> Result<LockedFile, Error> {
Ok(LockedFile::acquire(
self.root.join(".lock"),
self.root.user_display(),
)?)
}
pub fn add_tool_entry(&self, name: &str, tool: &Tool) -> Result<(), Error> {
let _lock = self.acquire_lock();
let mut toml_mut = self.toml_mut()?;
toml_mut
.add_tool(name, tool)
.map_err(|err| Error::TomlEdit(self.tools_toml_path(), err))?;
// Save the modified `tools.toml`.
fs_err::write(self.tools_toml_path(), toml_mut.to_string())?;
Ok(())
}
pub fn create_environment(
&self,
name: &str,
interpreter: Interpreter,
) -> Result<PythonEnvironment, Error> {
let _lock = self.acquire_lock();
let environment_path = self.root.join(name);
debug!(
"Creating environment for tool `{name}` at {}.",
environment_path.user_display()
);
// Create a virtual environment.
let venv = uv_virtualenv::create_venv(
&environment_path,
interpreter,
uv_virtualenv::Prompt::None,
false,
false,
)?;
Ok(venv)
}
/// Create a temporary tools directory.
pub fn temp() -> Result<Self, Error> {
Ok(Self::from_path(
StateStore::temp()?.bucket(StateBucket::Tools),
))
}
/// Initialize the tools directory.
///
/// Ensures the directory is created.
pub fn init(self) -> Result<Self, Error> {
let root = &self.root;
// Create the tools directory, if it doesn't exist.
fs::create_dir_all(root)?;
// Add a .gitignore.
match fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(root.join(".gitignore"))
{
Ok(mut file) => file.write_all(b"*")?,
Err(err) if err.kind() == io::ErrorKind::AlreadyExists => (),
Err(err) => return Err(err.into()),
}
Ok(self)
}
pub fn root(&self) -> &Path {
&self.root
}
}
/// A uv-managed tool installed on the current system..
#[derive(Debug, Clone)]
pub struct InstalledTool {
/// The path to the top-level directory of the tools.
path: PathBuf,
}
impl InstalledTool {
pub fn new(path: PathBuf) -> Result<Self, Error> {
Ok(Self { path })
}
pub fn path(&self) -> &Path {
&self.path
}
}
impl fmt::Display for InstalledTool {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"{}",
self.path
.file_name()
.unwrap_or(self.path.as_os_str())
.to_string_lossy()
)
}
}
/// Find a directory to place executables in.
///
/// This follows, in order:
///
/// - `$XDG_BIN_HOME`
/// - `$XDG_DATA_HOME/../bin`
/// - `$HOME/.local/bin`
///
/// On all platforms.
///
/// Errors if a directory cannot be found.
pub fn find_executable_directory() -> Result<PathBuf, Error> {
std::env::var_os("XDG_BIN_HOME")
.and_then(dirs_sys::is_absolute_path)
.or_else(|| {
std::env::var_os("XDG_DATA_HOME")
.and_then(dirs_sys::is_absolute_path)
.map(|path| path.join("../bin"))
})
.or_else(|| {
// See https://github.com/dirs-dev/dirs-rs/blob/50b50f31f3363b7656e5e63b3fa1060217cbc844/src/win.rs#L5C58-L5C78
#[cfg(windows)]
let home_dir = dirs_sys::known_folder_profile();
#[cfg(not(windows))]
let home_dir = dirs_sys::home_dir();
home_dir.map(|path| path.join(".local").join("bin"))
})
.ok_or(Error::NoExecutableDirectory)
}
/// Find the dist-info directory for a package in an environment.
fn find_dist_info(
environment: &PythonEnvironment,
package_name: &PackageName,
package_version: &Version,
) -> Result<PathBuf, Error> {
let dist_info_prefix = format!("{package_name}-{package_version}.dist-info");
environment
.interpreter()
.site_packages()
.map(|path| path.join(&dist_info_prefix))
.find(|path| path.exists())
.ok_or_else(|| Error::DistInfoMissing(dist_info_prefix, environment.root().to_path_buf()))
}
/// Parses the `entry_points.txt` entry for console scripts
///
/// Returns (`script_name`, module, function)
fn parse_scripts(
dist_info_path: &Path,
python_minor: u8,
) -> Result<(Vec<Script>, Vec<Script>), Error> {
let entry_points_path = dist_info_path.join("entry_points.txt");
// Read the entry points mapping. If the file doesn't exist, we just return an empty mapping.
let Ok(ini) = fs::read_to_string(&entry_points_path) else {
debug!(
"Failed to read entry points at {}",
entry_points_path.user_display()
);
return Ok((Vec::new(), Vec::new()));
};
Ok(scripts_from_ini(None, python_minor, ini)?)
}
/// Find the paths to the entry points provided by a package in an environment.
///
/// Returns a list of `(name, path)` tuples.
pub fn entrypoint_paths(
environment: &PythonEnvironment,
package_name: &PackageName,
package_version: &Version,
) -> Result<Vec<(String, PathBuf)>, Error> {
let dist_info_path = find_dist_info(environment, package_name, package_version)?;
debug!("Looking at dist-info at {}", dist_info_path.user_display());
let (console_scripts, gui_scripts) =
parse_scripts(&dist_info_path, environment.interpreter().python_minor())?;
let layout = environment.interpreter().layout();
Ok(console_scripts
.into_iter()
.chain(gui_scripts)
.map(|entrypoint| {
let path = entrypoint_path(&entrypoint, &layout);
(entrypoint.name, path)
})
.collect())
}

View file

@ -0,0 +1,119 @@
use pypi_types::VerbatimParsedUrl;
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::{fmt, mem};
use thiserror::Error;
use toml_edit::{DocumentMut, Item, Table, TomlError, Value};
/// A `tools.toml` with an (optional) `[tools]` section.
#[allow(dead_code)]
#[derive(Debug, Clone, Default, Deserialize)]
pub struct ToolsToml {
pub(crate) tools: Option<BTreeMap<String, Tool>>,
/// The raw unserialized document.
#[serde(skip)]
pub(crate) raw: String,
}
impl ToolsToml {
/// Parse a `ToolsToml` from a raw TOML string.
pub(crate) fn from_string(raw: String) -> Result<Self, toml::de::Error> {
let tools = toml::from_str(&raw)?;
Ok(ToolsToml { raw, ..tools })
}
}
// Ignore raw document in comparison.
impl PartialEq for ToolsToml {
fn eq(&self, other: &Self) -> bool {
self.tools.eq(&other.tools)
}
}
impl Eq for ToolsToml {}
/// A `[[tools]]` entry.
#[allow(dead_code)]
#[derive(Debug, Clone, Serialize, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "kebab-case")]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub struct Tool {
requirements: Vec<pep508_rs::Requirement<VerbatimParsedUrl>>,
python: Option<String>,
}
impl Tool {
/// Create a new `Tool`.
pub fn new(
requirements: Vec<pep508_rs::Requirement<VerbatimParsedUrl>>,
python: Option<String>,
) -> Self {
Self {
requirements,
python,
}
}
}
/// Raw and mutable representation of a `tools.toml`.
///
/// This is useful for operations that require editing an existing `tools.toml` while
/// preserving comments and other structure.
pub struct ToolsTomlMut {
doc: DocumentMut,
}
#[derive(Error, Debug)]
pub enum Error {
#[error("Failed to parse `tools.toml`")]
Parse(#[from] Box<TomlError>),
#[error("Failed to serialize `tools.toml`")]
Serialize(#[from] Box<toml::ser::Error>),
#[error("`tools.toml` is malformed")]
MalformedTools,
}
impl ToolsTomlMut {
/// Initialize a `ToolsTomlMut` from a `ToolsToml`.
pub fn from_toml(tools: &ToolsToml) -> Result<Self, Error> {
Ok(Self {
doc: tools.raw.parse().map_err(Box::new)?,
})
}
/// Adds a tool to `tools`.
pub fn add_tool(&mut self, name: &str, tool: &Tool) -> Result<(), Error> {
// Get or create `tools`.
let tools = self
.doc
.entry("tools")
.or_insert(Item::Table(Table::new()))
.as_table_mut()
.ok_or(Error::MalformedTools)?;
add_tool(name, tool, tools)?;
Ok(())
}
}
/// Adds a tool to the given `tools` table.
pub(crate) fn add_tool(name: &str, tool: &Tool, tools: &mut Table) -> Result<(), Error> {
// Serialize as an inline table.
let mut doc = toml::to_string(tool)
.map_err(Box::new)?
.parse::<DocumentMut>()
.unwrap();
let table = mem::take(doc.as_table_mut()).into_inline_table();
tools.insert(name, Item::Value(Value::InlineTable(table)));
Ok(())
}
impl fmt::Display for ToolsTomlMut {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
self.doc.fmt(f)
}
}

View file

@ -92,7 +92,7 @@ impl InstalledToolchains {
pub fn init(self) -> Result<Self, Error> {
let root = &self.root;
// Create the cache directory, if it doesn't exist.
// Create the toolchain directory, if it doesn't exist.
fs::create_dir_all(root)?;
// Add a .gitignore.

View file

@ -35,6 +35,7 @@ uv-requirements = { workspace = true }
uv-resolver = { workspace = true }
uv-settings = { workspace = true, features = ["schemars"] }
uv-toolchain = { workspace = true, features = ["schemars"]}
uv-tool = { workspace = true }
uv-types = { workspace = true }
uv-virtualenv = { workspace = true }
uv-warnings = { workspace = true }

View file

@ -24,6 +24,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::install::install as tool_install;
pub(crate) use tool::run::run as run_tool;
pub(crate) use toolchain::find::find as toolchain_find;
pub(crate) use toolchain::install::install as toolchain_install;

View file

@ -0,0 +1,133 @@
use std::fmt::Write;
use std::str::FromStr;
use anyhow::{bail, Context, Result};
use distribution_types::Name;
use pep508_rs::Requirement;
use pypi_types::VerbatimParsedUrl;
use tracing::debug;
use uv_cache::Cache;
use uv_client::Connectivity;
use uv_configuration::{Concurrency, PreviewMode};
use uv_fs::{replace_symlink, Simplified};
use uv_installer::SitePackages;
use uv_requirements::RequirementsSource;
use uv_tool::{entrypoint_paths, find_executable_directory, InstalledTools, Tool};
use uv_toolchain::{EnvironmentPreference, Toolchain, ToolchainPreference, ToolchainRequest};
use uv_warnings::warn_user;
use crate::commands::project::update_environment;
use crate::commands::ExitStatus;
use crate::printer::Printer;
use crate::settings::ResolverInstallerSettings;
/// Install a tool.
#[allow(clippy::too_many_arguments)]
pub(crate) async fn install(
name: String,
python: Option<String>,
from: Option<String>,
with: Vec<String>,
settings: ResolverInstallerSettings,
preview: PreviewMode,
toolchain_preference: ToolchainPreference,
connectivity: Connectivity,
concurrency: Concurrency,
native_tls: bool,
cache: &Cache,
printer: Printer,
) -> Result<ExitStatus> {
if preview.is_disabled() {
warn_user!("`uv tool install` is experimental and may change without warning.");
}
let installed_tools = InstalledTools::from_settings()?;
// TODO(zanieb): Allow replacing an existing tool
if installed_tools.find_tool_entry(&name)?.is_some() {
writeln!(printer.stderr(), "Tool `{name}` is already installed.")?;
return Ok(ExitStatus::Failure);
}
// TODO(zanieb): Figure out the interface here, do we infer the name or do we match the `run --from` interface?
let from = from.unwrap_or(name.clone());
let requirements = [Requirement::from_str(&from)]
.into_iter()
.chain(with.iter().map(|name| Requirement::from_str(name)))
.collect::<Result<Vec<Requirement<VerbatimParsedUrl>>, _>>()?;
// TODO(zanieb): Duplicative with the above parsing but needed for `update_environment`
let requirements_sources = [RequirementsSource::from_package(from.clone())]
.into_iter()
.chain(with.into_iter().map(RequirementsSource::from_package))
.collect::<Vec<_>>();
let Some(from) = requirements.first().cloned() else {
bail!("Expected at least one requirement")
};
let tool = Tool::new(requirements, python.clone());
let path = installed_tools.tools_toml_path();
let interpreter = Toolchain::find(
&python
.as_deref()
.map(ToolchainRequest::parse)
.unwrap_or_default(),
EnvironmentPreference::OnlySystem,
toolchain_preference,
cache,
)?
.into_interpreter();
// TODO(zanieb): Build the environment in the cache directory then copy into the tool directory
// This lets us confirm the environment is valid before removing an existing install
let environment = installed_tools.create_environment(&name, interpreter)?;
// Install the ephemeral requirements.
let environment = update_environment(
environment,
&requirements_sources,
&settings,
preview,
connectivity,
concurrency,
native_tls,
cache,
printer,
)
.await?;
let site_packages = SitePackages::from_environment(&environment)?;
let installed = site_packages.get_packages(&from.name);
let Some(installed_dist) = installed.first().copied() else {
bail!("Expected at least one requirement")
};
// Find a suitable path to install into
// TODO(zanieb): Warn if this directory is not on the PATH
let executable_directory = find_executable_directory()?;
fs_err::create_dir_all(&executable_directory)
.context("Failed to create executable directory")?;
let entrypoints = entrypoint_paths(
&environment,
installed_dist.name(),
installed_dist.version(),
)?;
// TODO(zanieb): Handle the case where there are no entrypoints
// TODO(zanieb): Better error when an entry point exists, check if they all are don't exist first
for (name, path) in entrypoints {
let target = executable_directory.join(path.file_name().unwrap());
debug!("Installing {name} to {}", target.user_display());
replace_symlink(&path, &target).context("Failed to install entrypoint")?;
}
debug!("Adding `{name}` to {}", path.user_display());
let installed_tools = installed_tools.init()?;
installed_tools.add_tool_entry(&name, &tool)?;
Ok(ExitStatus::Success)
}

View file

@ -1 +1,2 @@
pub(crate) mod install;
pub(crate) mod run;

View file

@ -799,6 +799,32 @@ async fn run() -> Result<ExitStatus> {
)
.await
}
Commands::Tool(ToolNamespace {
command: ToolCommand::Install(args),
}) => {
// Resolve the settings from the command-line arguments and workspace configuration.
let args = settings::ToolInstallSettings::resolve(args, filesystem);
show_settings!(args);
// Initialize the cache.
let cache = cache.init()?.with_refresh(args.refresh);
commands::tool_install(
args.name,
args.python,
args.from,
args.with,
args.settings,
globals.preview,
globals.toolchain_preference,
globals.connectivity,
Concurrency::default(),
globals.native_tls,
&cache,
printer,
)
.await
}
Commands::Toolchain(ToolchainNamespace {
command: ToolchainCommand::List(args),
}) => {

View file

@ -13,8 +13,8 @@ use uv_cli::options::{flag, installer_options, resolver_installer_options, resol
use uv_cli::{
AddArgs, ColorChoice, Commands, ExternalCommand, GlobalArgs, ListFormat, LockArgs, Maybe,
PipCheckArgs, PipCompileArgs, PipFreezeArgs, PipInstallArgs, PipListArgs, PipShowArgs,
PipSyncArgs, PipTreeArgs, PipUninstallArgs, RemoveArgs, RunArgs, SyncArgs, ToolRunArgs,
ToolchainFindArgs, ToolchainInstallArgs, ToolchainListArgs, VenvArgs,
PipSyncArgs, PipTreeArgs, PipUninstallArgs, RemoveArgs, RunArgs, SyncArgs, ToolInstallArgs,
ToolRunArgs, ToolchainFindArgs, ToolchainInstallArgs, ToolchainListArgs, VenvArgs,
};
use uv_client::Connectivity;
use uv_configuration::{
@ -231,6 +231,46 @@ impl ToolRunSettings {
}
}
/// The resolved settings to use for a `tool install` invocation.
#[allow(clippy::struct_excessive_bools)]
#[derive(Debug, Clone)]
pub(crate) struct ToolInstallSettings {
pub(crate) name: String,
pub(crate) from: Option<String>,
pub(crate) with: Vec<String>,
pub(crate) python: Option<String>,
pub(crate) refresh: Refresh,
pub(crate) settings: ResolverInstallerSettings,
}
impl ToolInstallSettings {
/// Resolve the [`ToolInstallSettings`] from the CLI and filesystem configuration.
#[allow(clippy::needless_pass_by_value)]
pub(crate) fn resolve(args: ToolInstallArgs, filesystem: Option<FilesystemOptions>) -> Self {
let ToolInstallArgs {
name,
from,
with,
installer,
build,
refresh,
python,
} = args;
Self {
name,
from,
with,
python,
refresh: Refresh::from(refresh),
settings: ResolverInstallerSettings::combine(
resolver_installer_options(installer, build),
filesystem,
),
}
}
}
#[derive(Debug, Clone, Default)]
pub(crate) enum ToolchainListKinds {
#[default]