diff --git a/Cargo.lock b/Cargo.lock index 1ef938990a88..7d919582677a 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", @@ -5010,6 +5011,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" diff --git a/Cargo.toml b/Cargo.toml index ba9261621cc7..701b7e2df1c4 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 9c748e8006ed..0202ddf18e10 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/linker.rs b/crates/install-wheel-rs/src/linker.rs index a71b8a32de4f..c2be59f3283e 100644 --- a/crates/install-wheel-rs/src/linker.rs +++ b/crates/install-wheel-rs/src/linker.rs @@ -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: diff --git a/crates/install-wheel-rs/src/script.rs b/crates/install-wheel-rs/src/script.rs index eabe49363788..5a29e0d1f17f 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 0440023929cc..33c53bf685f2 100644 --- a/crates/install-wheel-rs/src/wheel.rs +++ b/crates/install-wheel-rs/src/wheel.rs @@ -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 { +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 402d9869ca8b..9396aa905b38 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 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, +} + #[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 dc3ba55a7d52..22f9bd0edc2e 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 000000000000..e565b63990f5 --- /dev/null +++ b/crates/uv-tool/Cargo.toml @@ -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 } diff --git a/crates/uv-tool/src/lib.rs b/crates/uv-tool/src/lib.rs new file mode 100644 index 000000000000..72e3631ee850 --- /dev/null +++ b/crates/uv-tool/src/lib.rs @@ -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), + #[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) -> 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 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(), 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 { + 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 acquire_lock(&self) -> Result { + 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 { + 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 { + Ok(Self::from_path( + StateStore::temp()?.bucket(StateBucket::Tools), + )) + } + + /// Initialize the tools directory. + /// + /// Ensures the directory is created. + pub fn init(self) -> Result { + 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 { + 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(|| { + // 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 { + 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