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 thiserror::Error;
8use tracing::{debug, warn};
9
10use uv_cache::Cache;
11use uv_dirs::user_executable_directory;
12use uv_fs::{LockedFile, LockedFileError, LockedFileMode, Simplified};
13use uv_install_wheel::read_record_file;
14use uv_installer::SitePackages;
15use uv_normalize::{InvalidNameError, PackageName};
16use uv_pep440::Version;
17use uv_python::{Interpreter, PythonEnvironment};
18use uv_state::{StateBucket, StateStore};
19use uv_static::EnvVars;
20use uv_virtualenv::remove_virtualenv;
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        remove_virtualenv(environment_path.as_path())?;
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::BrokenSymlink(
291                broken_symlink,
292            ))) => {
293                let target_path = fs_err::read_link(&broken_symlink.path)?;
294                warn!(
295                    "Ignoring existing virtual environment linked to non-existent Python interpreter: {} -> {}",
296                    broken_symlink.path.user_display(),
297                    target_path.user_display()
298                );
299
300                Ok(None)
301            }
302            Err(err) => Err(err.into()),
303        }
304    }
305
306    /// Create the [`PythonEnvironment`] for a given tool, removing any existing environments.
307    ///
308    /// Note it is generally incorrect to use this without [`Self::acquire_lock`].
309    pub fn create_environment(
310        &self,
311        name: &PackageName,
312        interpreter: Interpreter,
313    ) -> Result<PythonEnvironment, Error> {
314        let environment_path = self.tool_dir(name);
315
316        // Remove any existing environment.
317        match fs_err::remove_dir_all(&environment_path) {
318            Ok(()) => {
319                debug!(
320                    "Removed existing environment for tool `{name}`: {}",
321                    environment_path.user_display()
322                );
323            }
324            Err(err) if err.kind() == io::ErrorKind::NotFound => (),
325            Err(err) => return Err(err.into()),
326        }
327
328        debug!(
329            "Creating environment for tool `{name}`: {}",
330            environment_path.user_display()
331        );
332
333        // Create a virtual environment.
334        let venv = uv_virtualenv::create_venv(
335            &environment_path,
336            interpreter,
337            uv_virtualenv::Prompt::None,
338            false,
339            uv_virtualenv::OnExisting::Remove(uv_virtualenv::RemovalReason::ManagedEnvironment),
340            false,
341            false,
342            false,
343        )?;
344
345        Ok(venv)
346    }
347
348    /// Create a temporary tools directory.
349    pub fn temp() -> Result<Self, Error> {
350        Ok(Self::from_path(
351            StateStore::temp()?.bucket(StateBucket::Tools),
352        ))
353    }
354
355    /// Initialize the tools directory.
356    ///
357    /// Ensures the directory is created.
358    pub fn init(self) -> Result<Self, Error> {
359        let root = &self.root;
360
361        // Create the tools directory, if it doesn't exist.
362        fs::create_dir_all(root)?;
363
364        // Add a .gitignore.
365        match fs::OpenOptions::new()
366            .write(true)
367            .create_new(true)
368            .open(root.join(".gitignore"))
369        {
370            Ok(mut file) => file.write_all(b"*")?,
371            Err(err) if err.kind() == io::ErrorKind::AlreadyExists => (),
372            Err(err) => return Err(err.into()),
373        }
374
375        Ok(self)
376    }
377
378    /// Return the path of the tools directory.
379    pub fn root(&self) -> &Path {
380        &self.root
381    }
382}
383
384/// A uv-managed tool installed on the current system..
385#[derive(Debug, Clone)]
386pub struct InstalledTool {
387    /// The path to the top-level directory of the tools.
388    path: PathBuf,
389}
390
391impl InstalledTool {
392    pub fn new(path: PathBuf) -> Result<Self, Error> {
393        Ok(Self { path })
394    }
395
396    pub fn path(&self) -> &Path {
397        &self.path
398    }
399}
400
401impl std::fmt::Display for InstalledTool {
402    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
403        write!(
404            f,
405            "{}",
406            self.path
407                .file_name()
408                .unwrap_or(self.path.as_os_str())
409                .to_string_lossy()
410        )
411    }
412}
413
414/// Find the tool executable directory.
415pub fn tool_executable_dir() -> Result<PathBuf, Error> {
416    user_executable_directory(Some(EnvVars::UV_TOOL_BIN_DIR)).ok_or(Error::NoExecutableDirectory)
417}
418
419/// Find the `.dist-info` directory for a package in an environment.
420fn find_dist_info<'a>(
421    site_packages: &'a SitePackages,
422    package_name: &PackageName,
423    package_version: &Version,
424) -> Result<&'a Path, Error> {
425    site_packages
426        .get_packages(package_name)
427        .iter()
428        .find(|package| package.version() == package_version)
429        .map(|dist| dist.install_path())
430        .ok_or_else(|| Error::MissingToolPackage(package_name.clone()))
431}
432
433/// Find the paths to the entry points provided by a package in an environment.
434///
435/// Entry points can either be true Python entrypoints (defined in `entrypoints.txt`) or scripts in
436/// the `.data` directory.
437///
438/// Returns a list of `(name, path)` tuples.
439pub fn entrypoint_paths(
440    site_packages: &SitePackages,
441    package_name: &PackageName,
442    package_version: &Version,
443) -> Result<Vec<(String, PathBuf)>, Error> {
444    // Find the `.dist-info` directory in the installed environment.
445    let dist_info_path = find_dist_info(site_packages, package_name, package_version)?;
446    debug!(
447        "Looking at `.dist-info` at: {}",
448        dist_info_path.user_display()
449    );
450
451    // Read the RECORD file.
452    let record = read_record_file(&mut File::open(dist_info_path.join("RECORD"))?)?;
453
454    // The RECORD file uses relative paths, so we're looking for the relative path to be a prefix.
455    let layout = site_packages.interpreter().layout();
456    let script_relative = pathdiff::diff_paths(&layout.scheme.scripts, &layout.scheme.purelib)
457        .ok_or_else(|| {
458            io::Error::other(format!(
459                "Could not find relative path for: {}",
460                layout.scheme.scripts.simplified_display()
461            ))
462        })?;
463
464    // Identify any installed binaries (both entrypoints and scripts from the `.data` directory).
465    let mut entrypoints = vec![];
466    for entry in record {
467        let relative_path = PathBuf::from(&entry.path);
468        let Ok(path_in_scripts) = relative_path.strip_prefix(&script_relative) else {
469            continue;
470        };
471
472        let absolute_path = layout.scheme.scripts.join(path_in_scripts);
473        let script_name = relative_path
474            .file_name()
475            .and_then(|filename| filename.to_str())
476            .map(ToString::to_string)
477            .unwrap_or(entry.path);
478        entrypoints.push((script_name, absolute_path));
479    }
480
481    Ok(entrypoints)
482}