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_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#[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 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 pub fn into_environment(self) -> PythonEnvironment {
55 self.environment
56 }
57
58 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#[derive(Debug, Clone)]
117pub struct InstalledTools {
118 root: PathBuf,
120}
121
122impl InstalledTools {
123 fn from_path(root: impl Into<PathBuf>) -> Self {
125 Self { root: root.into() }
126 }
127
128 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 pub fn tool_dir(&self, name: &PackageName) -> PathBuf {
147 self.root.join(name.to_string())
148 }
149
150 #[allow(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 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 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 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 fs_err::write(&path, doc)?;
233
234 Ok(())
235 }
236
237 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 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::BrokenSymlink(
292 broken_symlink,
293 ))) => {
294 let target_path = fs_err::read_link(&broken_symlink.path)?;
295 warn!(
296 "Ignoring existing virtual environment linked to non-existent Python interpreter: {} -> {}",
297 broken_symlink.path.user_display(),
298 target_path.user_display()
299 );
300
301 Ok(None)
302 }
303 Err(err) => Err(err.into()),
304 }
305 }
306
307 pub fn create_environment(
311 &self,
312 name: &PackageName,
313 interpreter: Interpreter,
314 preview: Preview,
315 ) -> Result<PythonEnvironment, Error> {
316 let environment_path = self.tool_dir(name);
317
318 match fs_err::remove_dir_all(&environment_path) {
320 Ok(()) => {
321 debug!(
322 "Removed existing environment for tool `{name}`: {}",
323 environment_path.user_display()
324 );
325 }
326 Err(err) if err.kind() == io::ErrorKind::NotFound => (),
327 Err(err) => return Err(err.into()),
328 }
329
330 debug!(
331 "Creating environment for tool `{name}`: {}",
332 environment_path.user_display()
333 );
334
335 let venv = uv_virtualenv::create_venv(
337 &environment_path,
338 interpreter,
339 uv_virtualenv::Prompt::None,
340 false,
341 uv_virtualenv::OnExisting::Remove(uv_virtualenv::RemovalReason::ManagedEnvironment),
342 false,
343 false,
344 false,
345 preview,
346 )?;
347
348 Ok(venv)
349 }
350
351 pub fn temp() -> Result<Self, Error> {
353 Ok(Self::from_path(
354 StateStore::temp()?.bucket(StateBucket::Tools),
355 ))
356 }
357
358 pub fn init(self) -> Result<Self, Error> {
362 let root = &self.root;
363
364 fs::create_dir_all(root)?;
366
367 match fs::OpenOptions::new()
369 .write(true)
370 .create_new(true)
371 .open(root.join(".gitignore"))
372 {
373 Ok(mut file) => file.write_all(b"*")?,
374 Err(err) if err.kind() == io::ErrorKind::AlreadyExists => (),
375 Err(err) => return Err(err.into()),
376 }
377
378 Ok(self)
379 }
380
381 pub fn root(&self) -> &Path {
383 &self.root
384 }
385}
386
387#[derive(Debug, Clone)]
389pub struct InstalledTool {
390 path: PathBuf,
392}
393
394impl InstalledTool {
395 pub fn new(path: PathBuf) -> Result<Self, Error> {
396 Ok(Self { path })
397 }
398
399 pub fn path(&self) -> &Path {
400 &self.path
401 }
402}
403
404impl std::fmt::Display for InstalledTool {
405 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
406 write!(
407 f,
408 "{}",
409 self.path
410 .file_name()
411 .unwrap_or(self.path.as_os_str())
412 .to_string_lossy()
413 )
414 }
415}
416
417pub fn tool_executable_dir() -> Result<PathBuf, Error> {
419 user_executable_directory(Some(EnvVars::UV_TOOL_BIN_DIR)).ok_or(Error::NoExecutableDirectory)
420}
421
422fn find_dist_info<'a>(
424 site_packages: &'a SitePackages,
425 package_name: &PackageName,
426 package_version: &Version,
427) -> Result<&'a Path, Error> {
428 site_packages
429 .get_packages(package_name)
430 .iter()
431 .find(|package| package.version() == package_version)
432 .map(|dist| dist.install_path())
433 .ok_or_else(|| Error::MissingToolPackage(package_name.clone()))
434}
435
436pub fn entrypoint_paths(
443 site_packages: &SitePackages,
444 package_name: &PackageName,
445 package_version: &Version,
446) -> Result<Vec<(String, PathBuf)>, Error> {
447 let dist_info_path = find_dist_info(site_packages, package_name, package_version)?;
449 debug!(
450 "Looking at `.dist-info` at: {}",
451 dist_info_path.user_display()
452 );
453
454 let record = read_record_file(&mut File::open(dist_info_path.join("RECORD"))?)?;
456
457 let layout = site_packages.interpreter().layout();
459 let script_relative = pathdiff::diff_paths(&layout.scheme.scripts, &layout.scheme.purelib)
460 .ok_or_else(|| {
461 io::Error::other(format!(
462 "Could not find relative path for: {}",
463 layout.scheme.scripts.simplified_display()
464 ))
465 })?;
466
467 let mut entrypoints = vec![];
469 for entry in record {
470 let relative_path = PathBuf::from(&entry.path);
471 let Ok(path_in_scripts) = relative_path.strip_prefix(&script_relative) else {
472 continue;
473 };
474
475 let absolute_path = layout.scheme.scripts.join(path_in_scripts);
476 let script_name = relative_path
477 .file_name()
478 .and_then(|filename| filename.to_str())
479 .map(ToString::to_string)
480 .unwrap_or(entry.path);
481 entrypoints.push((script_name, absolute_path));
482 }
483
484 Ok(entrypoints)
485}