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#[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 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 pub fn into_environment(self) -> PythonEnvironment {
54 self.environment
55 }
56
57 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#[derive(Debug, Clone)]
116pub struct InstalledTools {
117 root: PathBuf,
119}
120
121impl InstalledTools {
122 fn from_path(root: impl Into<PathBuf>) -> Self {
124 Self { root: root.into() }
125 }
126
127 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 pub fn tool_dir(&self, name: &PackageName) -> PathBuf {
146 self.root.join(name.to_string())
147 }
148
149 #[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 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 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 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 fs_err::write(&path, doc)?;
232
233 Ok(())
234 }
235
236 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 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 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 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 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 pub fn temp() -> Result<Self, Error> {
350 Ok(Self::from_path(
351 StateStore::temp()?.bucket(StateBucket::Tools),
352 ))
353 }
354
355 pub fn init(self) -> Result<Self, Error> {
359 let root = &self.root;
360
361 fs::create_dir_all(root)?;
363
364 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 pub fn root(&self) -> &Path {
380 &self.root
381 }
382}
383
384#[derive(Debug, Clone)]
386pub struct InstalledTool {
387 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
414pub fn tool_executable_dir() -> Result<PathBuf, Error> {
416 user_executable_directory(Some(EnvVars::UV_TOOL_BIN_DIR)).ok_or(Error::NoExecutableDirectory)
417}
418
419fn 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
433pub fn entrypoint_paths(
440 site_packages: &SitePackages,
441 package_name: &PackageName,
442 package_version: &Version,
443) -> Result<Vec<(String, PathBuf)>, Error> {
444 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 let record = read_record_file(&mut File::open(dist_info_path.join("RECORD"))?)?;
453
454 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 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}