Skip to main content

uv_tool/
lib.rs

1use std::io::{self, Write};
2use std::path::{Path, PathBuf};
3use std::str::FromStr;
4
5use fs_err as fs;
6use fs_err::File;
7use owo_colors::OwoColorize;
8use thiserror::Error;
9use tracing::{debug, warn};
10
11use uv_cache::Cache;
12use uv_dirs::user_executable_directory;
13use uv_fs::{LockedFile, LockedFileError, LockedFileMode, Simplified};
14use uv_install_wheel::read_record;
15use uv_installer::SitePackages;
16use uv_normalize::{InvalidNameError, PackageName};
17use uv_pep440::Version;
18use uv_python::{BrokenLink, Interpreter, PythonEnvironment};
19use uv_state::{StateBucket, StateStore};
20use uv_static::EnvVars;
21
22pub use receipt::ToolReceipt;
23pub use tool::{Tool, ToolEntrypoint};
24
25mod receipt;
26mod tool;
27
28/// A wrapper around [`PythonEnvironment`] for tools that provides additional functionality.
29#[derive(Debug, Clone)]
30pub struct ToolEnvironment {
31    environment: PythonEnvironment,
32    name: PackageName,
33}
34
35impl ToolEnvironment {
36    pub fn new(environment: PythonEnvironment, name: PackageName) -> Self {
37        Self { environment, name }
38    }
39
40    /// Return the [`Version`] of the tool package in this environment.
41    pub fn version(&self) -> Result<Version, Error> {
42        let site_packages = SitePackages::from_environment(&self.environment).map_err(|err| {
43            Error::EnvironmentRead(self.environment.root().to_path_buf(), err.to_string())
44        })?;
45        let packages = site_packages.get_packages(&self.name);
46        let package = packages
47            .first()
48            .ok_or_else(|| Error::MissingToolPackage(self.name.clone()))?;
49        Ok(package.version().clone())
50    }
51
52    /// Get the underlying [`PythonEnvironment`].
53    pub fn into_environment(self) -> PythonEnvironment {
54        self.environment
55    }
56
57    /// Get a reference to the underlying [`PythonEnvironment`].
58    pub fn environment(&self) -> &PythonEnvironment {
59        &self.environment
60    }
61}
62
63#[derive(Error, Debug)]
64pub enum Error {
65    #[error(transparent)]
66    Io(#[from] io::Error),
67    #[error(transparent)]
68    LockedFile(#[from] LockedFileError),
69    #[error("Failed to update `uv-receipt.toml` at {0}")]
70    ReceiptWrite(PathBuf, #[source] Box<toml_edit::ser::Error>),
71    #[error("Failed to read `uv-receipt.toml` at {0}")]
72    ReceiptRead(PathBuf, #[source] Box<toml::de::Error>),
73    #[error(transparent)]
74    VirtualEnvError(#[from] uv_virtualenv::Error),
75    #[error("Failed to read package entry points {0}")]
76    EntrypointRead(#[from] uv_install_wheel::Error),
77    #[error("Failed to find a directory to install executables into")]
78    NoExecutableDirectory,
79    #[error(transparent)]
80    ToolName(#[from] InvalidNameError),
81    #[error(transparent)]
82    EnvironmentError(#[from] uv_python::Error),
83    #[error("Failed to find a receipt for tool `{0}` at {1}")]
84    MissingToolReceipt(String, PathBuf),
85    #[error("Failed to read tool environment packages at `{0}`: {1}")]
86    EnvironmentRead(PathBuf, String),
87    #[error("Failed find package `{0}` in tool environment")]
88    MissingToolPackage(PackageName),
89    #[error("Tool `{0}` environment not found at `{1}`")]
90    ToolEnvironmentNotFound(PackageName, PathBuf),
91}
92
93impl Error {
94    pub fn as_io_error(&self) -> Option<&io::Error> {
95        match self {
96            Self::Io(err) => Some(err),
97            Self::LockedFile(err) => err.as_io_error(),
98            Self::VirtualEnvError(uv_virtualenv::Error::Io(err)) => Some(err),
99            Self::ReceiptWrite(_, _)
100            | Self::ReceiptRead(_, _)
101            | Self::VirtualEnvError(_)
102            | Self::EntrypointRead(_)
103            | Self::NoExecutableDirectory
104            | Self::ToolName(_)
105            | Self::EnvironmentError(_)
106            | Self::MissingToolReceipt(_, _)
107            | Self::EnvironmentRead(_, _)
108            | Self::MissingToolPackage(_)
109            | Self::ToolEnvironmentNotFound(_, _) => None,
110        }
111    }
112}
113
114/// A collection of uv-managed tools installed on the current system.
115#[derive(Debug, Clone)]
116pub struct InstalledTools {
117    /// The path to the top-level directory of the tools.
118    root: PathBuf,
119}
120
121impl InstalledTools {
122    /// A directory for tools at `root`.
123    fn from_path(root: impl Into<PathBuf>) -> Self {
124        Self { root: root.into() }
125    }
126
127    /// Create a new [`InstalledTools`] from settings.
128    ///
129    /// Prefer, in order:
130    ///
131    /// 1. The specific tool directory specified by the user, i.e., `UV_TOOL_DIR`
132    /// 2. A directory in the system-appropriate user-level data directory, e.g., `~/.local/uv/tools`
133    /// 3. A directory in the local data directory, e.g., `./.uv/tools`
134    pub fn from_settings() -> Result<Self, Error> {
135        if let Some(tool_dir) = std::env::var_os(EnvVars::UV_TOOL_DIR).filter(|s| !s.is_empty()) {
136            Ok(Self::from_path(std::path::absolute(tool_dir)?))
137        } else {
138            Ok(Self::from_path(
139                StateStore::from_settings(None)?.bucket(StateBucket::Tools),
140            ))
141        }
142    }
143
144    /// Return the expected directory for a tool with the given [`PackageName`].
145    pub fn tool_dir(&self, name: &PackageName) -> PathBuf {
146        self.root.join(name.to_string())
147    }
148
149    /// Return the metadata for all installed tools.
150    ///
151    /// If a tool is present, but is missing a receipt or the receipt is invalid, the tool will be
152    /// included with an error.
153    ///
154    /// Note it is generally incorrect to use this without [`Self::acquire_lock`].
155    #[expect(clippy::type_complexity)]
156    pub fn tools(&self) -> Result<Vec<(PackageName, Result<Tool, Error>)>, Error> {
157        let mut tools = Vec::new();
158        for directory in uv_fs::directories(self.root())? {
159            let Some(name) = directory
160                .file_name()
161                .and_then(|file_name| file_name.to_str())
162            else {
163                continue;
164            };
165            let name = PackageName::from_str(name)?;
166            let path = directory.join("uv-receipt.toml");
167            let contents = match fs_err::read_to_string(&path) {
168                Ok(contents) => contents,
169                Err(err) if err.kind() == io::ErrorKind::NotFound => {
170                    let err = Error::MissingToolReceipt(name.to_string(), path);
171                    tools.push((name, Err(err)));
172                    continue;
173                }
174                Err(err) => return Err(err.into()),
175            };
176            match ToolReceipt::from_string(contents) {
177                Ok(tool_receipt) => tools.push((name, Ok(tool_receipt.tool))),
178                Err(err) => {
179                    let err = Error::ReceiptRead(path, Box::new(err));
180                    tools.push((name, Err(err)));
181                }
182            }
183        }
184        Ok(tools)
185    }
186
187    /// Get the receipt for the given tool.
188    ///
189    /// If the tool is not installed, returns `Ok(None)`. If the receipt is invalid, returns an
190    /// error.
191    ///
192    /// Note it is generally incorrect to use this without [`Self::acquire_lock`].
193    pub fn get_tool_receipt(&self, name: &PackageName) -> Result<Option<Tool>, Error> {
194        let path = self.tool_dir(name).join("uv-receipt.toml");
195        match ToolReceipt::from_path(&path) {
196            Ok(tool_receipt) => Ok(Some(tool_receipt.tool)),
197            Err(Error::Io(err)) if err.kind() == io::ErrorKind::NotFound => Ok(None),
198            Err(err) => Err(err),
199        }
200    }
201
202    /// Grab a file lock for the tools directory to prevent concurrent access across processes.
203    pub async fn lock(&self) -> Result<LockedFile, Error> {
204        Ok(LockedFile::acquire(
205            self.root.join(".lock"),
206            LockedFileMode::Exclusive,
207            self.root.user_display(),
208        )
209        .await?)
210    }
211
212    /// Add a receipt for a tool.
213    ///
214    /// Any existing receipt will be replaced.
215    ///
216    /// Note it is generally incorrect to use this without [`Self::acquire_lock`].
217    pub fn add_tool_receipt(&self, name: &PackageName, tool: Tool) -> Result<(), Error> {
218        let tool_receipt = ToolReceipt::from(tool);
219        let path = self.tool_dir(name).join("uv-receipt.toml");
220
221        debug!(
222            "Adding metadata entry for tool `{name}` at {}",
223            path.user_display()
224        );
225
226        let doc = tool_receipt
227            .to_toml()
228            .map_err(|err| Error::ReceiptWrite(path.clone(), Box::new(err)))?;
229
230        // Save the modified `uv-receipt.toml`.
231        fs_err::write(&path, doc)?;
232
233        Ok(())
234    }
235
236    /// Remove the environment for a tool.
237    ///
238    /// Does not remove the tool's entrypoints.
239    ///
240    /// Note it is generally incorrect to use this without [`Self::acquire_lock`].
241    ///
242    /// # Errors
243    ///
244    /// If no such environment exists for the tool.
245    pub fn remove_environment(&self, name: &PackageName) -> Result<(), Error> {
246        let environment_path = self.tool_dir(name);
247
248        debug!(
249            "Deleting environment for tool `{name}` at {}",
250            environment_path.user_display()
251        );
252
253        uv_fs::remove_virtualenv(environment_path.as_path()).map_err(uv_virtualenv::Error::from)?;
254
255        Ok(())
256    }
257
258    /// Return the [`PythonEnvironment`] for a given tool, if it exists.
259    ///
260    /// Returns `Ok(None)` if the environment does not exist or is linked to a non-existent
261    /// interpreter.
262    ///
263    /// Note it is generally incorrect to use this without [`Self::acquire_lock`].
264    pub fn get_environment(
265        &self,
266        name: &PackageName,
267        cache: &Cache,
268    ) -> Result<Option<ToolEnvironment>, Error> {
269        let environment_path = self.tool_dir(name);
270
271        match PythonEnvironment::from_root(&environment_path, cache) {
272            Ok(venv) => {
273                debug!(
274                    "Found existing environment for tool `{name}`: {}",
275                    environment_path.user_display()
276                );
277                Ok(Some(ToolEnvironment::new(venv, name.clone())))
278            }
279            Err(uv_python::Error::MissingEnvironment(_)) => Ok(None),
280            Err(uv_python::Error::Query(uv_python::InterpreterError::NotFound(
281                interpreter_path,
282            ))) => {
283                warn!(
284                    "Ignoring existing virtual environment with missing Python interpreter: {}",
285                    interpreter_path.user_display()
286                );
287
288                Ok(None)
289            }
290            Err(uv_python::Error::Query(uv_python::InterpreterError::BrokenLink(BrokenLink {
291                path,
292                unix,
293                venv: _,
294            }))) => {
295                if unix {
296                    let target_path = fs_err::read_link(&path)?;
297                    warn!(
298                        "Ignoring existing virtual environment linked to non-existent Python interpreter: {} -> {}",
299                        path.user_display().cyan(),
300                        target_path.user_display().cyan(),
301                    );
302                } else {
303                    warn!(
304                        "Ignoring existing virtual environment linked to non-existent Python interpreter: {}",
305                        path.user_display().cyan(),
306                    );
307                }
308
309                Ok(None)
310            }
311            Err(err) => Err(err.into()),
312        }
313    }
314
315    /// Create the [`PythonEnvironment`] for a given tool, removing any existing environments.
316    ///
317    /// Note it is generally incorrect to use this without [`Self::acquire_lock`].
318    pub fn create_environment(
319        &self,
320        name: &PackageName,
321        interpreter: Interpreter,
322    ) -> Result<PythonEnvironment, Error> {
323        let environment_path = self.tool_dir(name);
324
325        // Remove any existing environment.
326        match uv_fs::remove_virtualenv(&environment_path) {
327            Ok(()) => {
328                debug!(
329                    "Removed existing environment for tool `{name}`: {}",
330                    environment_path.user_display()
331                );
332            }
333            Err(err) if err.kind() == io::ErrorKind::NotFound => (),
334            Err(err) => return Err(uv_virtualenv::Error::from(err).into()),
335        }
336
337        debug!(
338            "Creating environment for tool `{name}`: {}",
339            environment_path.user_display()
340        );
341
342        // Create a virtual environment.
343        let venv = uv_virtualenv::create_venv(
344            &environment_path,
345            interpreter,
346            uv_virtualenv::Prompt::None,
347            false,
348            uv_virtualenv::OnExisting::Remove(uv_virtualenv::RemovalReason::ManagedEnvironment),
349            false,
350            false,
351            false,
352        )?;
353
354        Ok(venv)
355    }
356
357    /// Create a temporary tools directory.
358    pub fn temp() -> Result<Self, Error> {
359        Ok(Self::from_path(
360            StateStore::temp()?.bucket(StateBucket::Tools),
361        ))
362    }
363
364    /// Initialize the tools directory.
365    ///
366    /// Ensures the directory is created.
367    pub fn init(self) -> Result<Self, Error> {
368        let root = &self.root;
369
370        // Create the tools directory, if it doesn't exist.
371        fs::create_dir_all(root)?;
372
373        // Add a .gitignore.
374        match fs::OpenOptions::new()
375            .write(true)
376            .create_new(true)
377            .open(root.join(".gitignore"))
378        {
379            Ok(mut file) => file.write_all(b"*")?,
380            Err(err) if err.kind() == io::ErrorKind::AlreadyExists => (),
381            Err(err) => return Err(err.into()),
382        }
383
384        Ok(self)
385    }
386
387    /// Return the path of the tools directory.
388    pub fn root(&self) -> &Path {
389        &self.root
390    }
391}
392
393/// A uv-managed tool installed on the current system..
394#[derive(Debug, Clone)]
395pub struct InstalledTool {
396    /// The path to the top-level directory of the tools.
397    path: PathBuf,
398}
399
400impl InstalledTool {
401    pub fn new(path: PathBuf) -> Result<Self, Error> {
402        Ok(Self { path })
403    }
404
405    pub fn path(&self) -> &Path {
406        &self.path
407    }
408}
409
410impl std::fmt::Display for InstalledTool {
411    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
412        write!(
413            f,
414            "{}",
415            self.path
416                .file_name()
417                .unwrap_or(self.path.as_os_str())
418                .to_string_lossy()
419        )
420    }
421}
422
423/// Find the tool executable directory.
424pub fn tool_executable_dir() -> Result<PathBuf, Error> {
425    user_executable_directory(Some(EnvVars::UV_TOOL_BIN_DIR)).ok_or(Error::NoExecutableDirectory)
426}
427
428/// Find the `.dist-info` directory for a package in an environment.
429fn find_dist_info<'a>(
430    site_packages: &'a SitePackages,
431    package_name: &PackageName,
432    package_version: &Version,
433) -> Result<&'a Path, Error> {
434    site_packages
435        .get_packages(package_name)
436        .iter()
437        .find(|package| package.version() == package_version)
438        .map(|dist| dist.install_path())
439        .ok_or_else(|| Error::MissingToolPackage(package_name.clone()))
440}
441
442/// Find the paths to the entry points provided by a package in an environment.
443///
444/// Entry points can either be true Python entrypoints (defined in `entrypoints.txt`) or scripts in
445/// the `.data` directory.
446///
447/// Returns a list of `(name, path)` tuples.
448pub fn entrypoint_paths(
449    site_packages: &SitePackages,
450    package_name: &PackageName,
451    package_version: &Version,
452) -> Result<Vec<(String, PathBuf)>, Error> {
453    // Find the `.dist-info` directory in the installed environment.
454    let dist_info_path = find_dist_info(site_packages, package_name, package_version)?;
455    debug!(
456        "Looking at `.dist-info` at: {}",
457        dist_info_path.user_display()
458    );
459
460    // Read the RECORD file.
461    let record = read_record(File::open(dist_info_path.join("RECORD"))?)?;
462
463    // The RECORD file uses relative paths, so we're looking for the relative path to be a prefix.
464    let layout = site_packages.interpreter().layout();
465    let script_relative = pathdiff::diff_paths(&layout.scheme.scripts, &layout.scheme.purelib)
466        .ok_or_else(|| {
467            io::Error::other(format!(
468                "Could not find relative path for: {}",
469                layout.scheme.scripts.simplified_display()
470            ))
471        })?;
472
473    // Identify any installed binaries (both entrypoints and scripts from the `.data` directory).
474    let mut entrypoints = vec![];
475    for entry in record {
476        let relative_path = PathBuf::from(&entry.path);
477        let Ok(path_in_scripts) = relative_path.strip_prefix(&script_relative) else {
478            continue;
479        };
480
481        let absolute_path = layout.scheme.scripts.join(path_in_scripts);
482        let script_name = relative_path
483            .file_name()
484            .and_then(|filename| filename.to_str())
485            .map(ToString::to_string)
486            .unwrap_or(entry.path);
487        entrypoints.push((script_name, absolute_path));
488    }
489
490    Ok(entrypoints)
491}