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