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::{InvalidNameError, PackageName};
17use uv_pep440::Version;
18use uv_python::{BrokenLink, Interpreter, PythonEnvironment};
19use uv_state::{StateBucket, StateStore};
20use uv_static::EnvVars;
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 uv_fs::remove_virtualenv(environment_path.as_path()).map_err(uv_virtualenv::Error::from)?;
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::BrokenLink(BrokenLink {
291 path,
292 unix,
293 venv: _,
294 }))) => {
295 if unix {
296 let target_path = fs_err::read_link(&path)?;
297 warn!(
298 "Ignoring existing virtual environment linked to non-existent Python interpreter: {} -> {}",
299 path.user_display().cyan(),
300 target_path.user_display().cyan(),
301 );
302 } else {
303 warn!(
304 "Ignoring existing virtual environment linked to non-existent Python interpreter: {}",
305 path.user_display().cyan(),
306 );
307 }
308
309 Ok(None)
310 }
311 Err(err) => Err(err.into()),
312 }
313 }
314
315 pub fn create_environment(
319 &self,
320 name: &PackageName,
321 interpreter: Interpreter,
322 ) -> Result<PythonEnvironment, Error> {
323 let environment_path = self.tool_dir(name);
324
325 match uv_fs::remove_virtualenv(&environment_path) {
327 Ok(()) => {
328 debug!(
329 "Removed existing environment for tool `{name}`: {}",
330 environment_path.user_display()
331 );
332 }
333 Err(err) if err.kind() == io::ErrorKind::NotFound => (),
334 Err(err) => return Err(uv_virtualenv::Error::from(err).into()),
335 }
336
337 debug!(
338 "Creating environment for tool `{name}`: {}",
339 environment_path.user_display()
340 );
341
342 let venv = uv_virtualenv::create_venv(
344 &environment_path,
345 interpreter,
346 uv_virtualenv::Prompt::None,
347 false,
348 uv_virtualenv::OnExisting::Remove(uv_virtualenv::RemovalReason::ManagedEnvironment),
349 false,
350 false,
351 false,
352 )?;
353
354 Ok(venv)
355 }
356
357 pub fn temp() -> Result<Self, Error> {
359 Ok(Self::from_path(
360 StateStore::temp()?.bucket(StateBucket::Tools),
361 ))
362 }
363
364 pub fn init(self) -> Result<Self, Error> {
368 let root = &self.root;
369
370 fs::create_dir_all(root)?;
372
373 match fs::OpenOptions::new()
375 .write(true)
376 .create_new(true)
377 .open(root.join(".gitignore"))
378 {
379 Ok(mut file) => file.write_all(b"*")?,
380 Err(err) if err.kind() == io::ErrorKind::AlreadyExists => (),
381 Err(err) => return Err(err.into()),
382 }
383
384 Ok(self)
385 }
386
387 pub fn root(&self) -> &Path {
389 &self.root
390 }
391}
392
393#[derive(Debug, Clone)]
395pub struct InstalledTool {
396 path: PathBuf,
398}
399
400impl InstalledTool {
401 pub fn new(path: PathBuf) -> Result<Self, Error> {
402 Ok(Self { path })
403 }
404
405 pub fn path(&self) -> &Path {
406 &self.path
407 }
408}
409
410impl std::fmt::Display for InstalledTool {
411 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
412 write!(
413 f,
414 "{}",
415 self.path
416 .file_name()
417 .unwrap_or(self.path.as_os_str())
418 .to_string_lossy()
419 )
420 }
421}
422
423pub fn tool_executable_dir() -> Result<PathBuf, Error> {
425 user_executable_directory(Some(EnvVars::UV_TOOL_BIN_DIR)).ok_or(Error::NoExecutableDirectory)
426}
427
428fn find_dist_info<'a>(
430 site_packages: &'a SitePackages,
431 package_name: &PackageName,
432 package_version: &Version,
433) -> Result<&'a Path, Error> {
434 site_packages
435 .get_packages(package_name)
436 .iter()
437 .find(|package| package.version() == package_version)
438 .map(|dist| dist.install_path())
439 .ok_or_else(|| Error::MissingToolPackage(package_name.clone()))
440}
441
442pub fn entrypoint_paths(
449 site_packages: &SitePackages,
450 package_name: &PackageName,
451 package_version: &Version,
452) -> Result<Vec<(String, PathBuf)>, Error> {
453 let dist_info_path = find_dist_info(site_packages, package_name, package_version)?;
455 debug!(
456 "Looking at `.dist-info` at: {}",
457 dist_info_path.user_display()
458 );
459
460 let record = read_record(File::open(dist_info_path.join("RECORD"))?)?;
462
463 let layout = site_packages.interpreter().layout();
465 let script_relative = pathdiff::diff_paths(&layout.scheme.scripts, &layout.scheme.purelib)
466 .ok_or_else(|| {
467 io::Error::other(format!(
468 "Could not find relative path for: {}",
469 layout.scheme.scripts.simplified_display()
470 ))
471 })?;
472
473 let mut entrypoints = vec![];
475 for entry in record {
476 let relative_path = PathBuf::from(&entry.path);
477 let Ok(path_in_scripts) = relative_path.strip_prefix(&script_relative) else {
478 continue;
479 };
480
481 let absolute_path = layout.scheme.scripts.join(path_in_scripts);
482 let script_name = relative_path
483 .file_name()
484 .and_then(|filename| filename.to_str())
485 .map(ToString::to_string)
486 .unwrap_or(entry.path);
487 entrypoints.push((script_name, absolute_path));
488 }
489
490 Ok(entrypoints)
491}