mirror of
https://github.com/astral-sh/uv.git
synced 2025-07-07 13:25:00 +00:00
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:
parent
b677a06aba
commit
c9657b0015
18 changed files with 744 additions and 26 deletions
22
Cargo.lock
generated
22
Cargo.lock
generated
|
@ -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"
|
||||
|
|
|
@ -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" }
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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")?;
|
||||
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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
31
crates/uv-tool/Cargo.toml
Normal 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
290
crates/uv-tool/src/lib.rs
Normal 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())
|
||||
}
|
119
crates/uv-tool/src/tools_toml.rs
Normal file
119
crates/uv-tool/src/tools_toml.rs
Normal 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)
|
||||
}
|
||||
}
|
|
@ -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.
|
||||
|
|
|
@ -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 }
|
||||
|
|
|
@ -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;
|
||||
|
|
133
crates/uv/src/commands/tool/install.rs
Normal file
133
crates/uv/src/commands/tool/install.rs
Normal 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)
|
||||
}
|
|
@ -1 +1,2 @@
|
|||
pub(crate) mod install;
|
||||
pub(crate) mod run;
|
||||
|
|
|
@ -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),
|
||||
}) => {
|
||||
|
|
|
@ -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]
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue