From 502c1daa43e740bbe953d45369892b22668ff8da Mon Sep 17 00:00:00 2001 From: Zanie Blue Date: Mon, 24 Jun 2024 12:35:54 -0500 Subject: [PATCH] Add `uv tool install` --- Cargo.lock | 24 ++ Cargo.toml | 1 + crates/install-wheel-rs/src/lib.rs | 2 + crates/install-wheel-rs/src/script.rs | 10 +- crates/install-wheel-rs/src/wheel.rs | 4 +- crates/uv-cli/src/lib.rs | 42 ++++ crates/uv-state/src/lib.rs | 5 +- crates/uv-tool/Cargo.toml | 33 +++ crates/uv-tool/src/lib.rs | 306 +++++++++++++++++++++++++ crates/uv-tool/src/tools_toml.rs | 119 ++++++++++ crates/uv/Cargo.toml | 1 + crates/uv/src/commands/mod.rs | 1 + crates/uv/src/commands/tool/install.rs | 139 +++++++++++ crates/uv/src/commands/tool/mod.rs | 1 + crates/uv/src/main.rs | 27 +++ crates/uv/src/settings.rs | 44 +++- 16 files changed, 749 insertions(+), 10 deletions(-) create mode 100644 crates/uv-tool/Cargo.toml create mode 100644 crates/uv-tool/src/lib.rs create mode 100644 crates/uv-tool/src/tools_toml.rs create mode 100644 crates/uv/src/commands/tool/install.rs diff --git a/Cargo.lock b/Cargo.lock index 18313bd9b9526..b93ba802cbe9f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4488,6 +4488,7 @@ dependencies = [ "uv-requirements", "uv-resolver", "uv-settings", + "uv-tool", "uv-toolchain", "uv-types", "uv-virtualenv", @@ -5009,6 +5010,29 @@ dependencies = [ "tempfile", ] +[[package]] +name = "uv-tool" +version = "0.0.1" +dependencies = [ + "directories", + "dirs-sys", + "fs-err", + "install-wheel-rs", + "pep440_rs", + "pep508_rs", + "pypi-types", + "serde", + "thiserror", + "toml", + "toml_edit", + "tracing", + "uv-extract", + "uv-fs", + "uv-state", + "uv-toolchain", + "uv-virtualenv", +] + [[package]] name = "uv-toolchain" version = "0.0.1" diff --git a/Cargo.toml b/Cargo.toml index ba9261621cc7a..701b7e2df1c4a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" } diff --git a/crates/install-wheel-rs/src/lib.rs b/crates/install-wheel-rs/src/lib.rs index 9c748e8006eda..0202ddf18e103 100644 --- a/crates/install-wheel-rs/src/lib.rs +++ b/crates/install-wheel-rs/src/lib.rs @@ -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; diff --git a/crates/install-wheel-rs/src/script.rs b/crates/install-wheel-rs/src/script.rs index eabe49363788b..5a29e0d1f17f3 100644 --- a/crates/install-wheel-rs/src/script.rs +++ b/crates/install-wheel-rs/src/script.rs @@ -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, diff --git a/crates/install-wheel-rs/src/wheel.rs b/crates/install-wheel-rs/src/wheel.rs index 0440023929cc7..cc7e2bd2e71b2 100644 --- a/crates/install-wheel-rs/src/wheel.rs +++ b/crates/install-wheel-rs/src/wheel.rs @@ -320,7 +320,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 +331,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 { +pub fn parse_wheel_file(wheel_text: &str) -> Result { // {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")?; diff --git a/crates/uv-cli/src/lib.rs b/crates/uv-cli/src/lib.rs index 402d9869ca8b6..15cad6342789f 100644 --- a/crates/uv-cli/src/lib.rs +++ b/crates/uv-cli/src/lib.rs @@ -1819,6 +1819,8 @@ pub struct ToolNamespace { pub enum ToolCommand { /// Run a tool Run(ToolRunArgs), + /// Install a tool + Install(ToolInstallArgs), } #[derive(Args)] @@ -1862,6 +1864,46 @@ pub struct ToolRunArgs { pub python: Option, } +#[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, + + /// Include the following extra requirements. + #[arg(long)] + pub with: Vec, + + #[command(flatten)] + pub installer: ResolverInstallerArgs, + + #[command(flatten)] + pub build: BuildArgs, + + #[command(flatten)] + pub refresh: RefreshArgs, + + /// The Python interpreter to use to build the run environment. + /// + /// By default, `uv` uses the virtual environment in the current working directory or any parent + /// directory, falling back to searching for a Python executable in `PATH`. 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, +} + #[derive(Args)] #[allow(clippy::struct_excessive_bools)] pub struct ToolchainNamespace { diff --git a/crates/uv-state/src/lib.rs b/crates/uv-state/src/lib.rs index dc3ba55a7d523..22f9bd0edc2e2 100644 --- a/crates/uv-state/src/lib.rs +++ b/crates/uv-state/src/lib.rs @@ -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", } } } diff --git a/crates/uv-tool/Cargo.toml b/crates/uv-tool/Cargo.toml new file mode 100644 index 0000000000000..c461f6fabe08a --- /dev/null +++ b/crates/uv-tool/Cargo.toml @@ -0,0 +1,33 @@ +[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-extract = { workspace = true } +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 } +directories = { workspace = true } +tracing = { workspace = true } +fs-err = { workspace = true } +serde = { workspace = true } +toml = { workspace = true } +toml_edit = { workspace = true } +dirs-sys = { workspace = true } diff --git a/crates/uv-tool/src/lib.rs b/crates/uv-tool/src/lib.rs new file mode 100644 index 0000000000000..6ce7baab9246c --- /dev/null +++ b/crates/uv-tool/src/lib.rs @@ -0,0 +1,306 @@ +use core::fmt; +use fs_err as fs; +use install_wheel_rs::{scripts_from_ini, Layout, 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::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] 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 installed tools. + root: PathBuf, +} + +impl InstalledTools { + /// A directory for installed tools at `root`. + fn from_path(root: impl Into) -> 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 { + 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 installed tools. + pub fn toml(&self) -> Result { + 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(), err))?), + Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(ToolsToml::default()), + Err(err) => Err(err.into()), + } + } + + pub fn toml_mut(&self) -> Result { + 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, Error> { + let toml = self.toml()?; + Ok(toml.tools.and_then(|tools| tools.get(name).cloned())) + } + + pub fn add_tool_entry(&self, name: &str, tool: &Tool) -> Result<(), Error> { + 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 { + let environment_path = self.root.join(name); + + debug!( + "Creating environment for tool `{name}` at {}.", + environment_path.user_display() + ); + + // Discover an interpreter. + // Note we force preview on during `uv tool run` for now since the entire interface is in preview + + // Create a virtual environment. + let venv = uv_virtualenv::create_venv( + &environment_path, + interpreter, + uv_virtualenv::Prompt::None, + false, + false, + )?; + + Ok(venv) + } + + /// Create a temporary installed tool directory. + pub fn temp() -> Result { + Ok(Self::from_path( + StateStore::temp()?.bucket(StateBucket::Tools), + )) + } + + /// Initialize the installed tool directory. + /// + /// Ensures the directory is created. + pub fn init(self) -> Result { + let root = &self.root; + + // Create the cache 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 installed tool. + path: PathBuf, +} + +impl InstalledTool { + pub fn new(path: PathBuf) -> Result { + 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 { + 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(|| dirs_sys::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 { + 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) +/// +/// Extras are supposed to be ignored, which happens if you pass None for extras. +fn parse_scripts( + dist_info_path: &Path, + extras: Option<&[String]>, + python_minor: u8, +) -> Result<(Vec