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