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