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#[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 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 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#[derive(Debug, Clone)]
114pub struct InstalledTools {
115 root: PathBuf,
117}
118
119impl InstalledTools {
120 fn from_path(root: impl Into<PathBuf>) -> Self {
122 Self { root: root.into() }
123 }
124
125 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 pub fn tool_dir(&self, name: &PackageName) -> PathBuf {
144 self.root.join(name.to_string())
145 }
146
147 #[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 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 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 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 fs_err::write(&path, doc)?;
238
239 Ok(())
240 }
241
242 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 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 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 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 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 pub fn init(self) -> Result<Self, Error> {
367 let root = &self.root;
368
369 fs::create_dir_all(root)?;
371
372 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 pub fn root(&self) -> &Path {
388 &self.root
389 }
390}
391
392pub fn tool_executable_dir() -> Result<PathBuf, Error> {
394 user_executable_directory(Some(EnvVars::UV_TOOL_BIN_DIR)).ok_or(Error::NoExecutableDirectory)
395}
396
397fn 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
411pub fn entrypoint_paths(
418 site_packages: &SitePackages,
419 package_name: &PackageName,
420 package_version: &Version,
421) -> Result<Vec<(String, PathBuf)>, Error> {
422 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 let record = read_record(File::open(dist_info_path.join("RECORD"))?)?;
431
432 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 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}