1use std::borrow::Cow;
2use std::io::BufReader;
3use std::path::{Path, PathBuf};
4use std::str::FromStr;
5use std::sync::OnceLock;
6
7use fs_err as fs;
8use thiserror::Error;
9use tracing::warn;
10use url::Url;
11
12use uv_cache_info::CacheInfo;
13use uv_distribution_filename::{EggInfoFilename, ExpandedTags};
14use uv_fs::Simplified;
15use uv_install_wheel::WheelFile;
16use uv_normalize::PackageName;
17use uv_pep440::Version;
18use uv_pypi_types::{DirectUrl, MetadataError};
19use uv_redacted::DisplaySafeUrl;
20
21use crate::{
22 BuildInfo, DistributionMetadata, InstalledMetadata, InstalledVersion, Name, VersionOrUrlRef,
23};
24
25#[derive(Error, Debug)]
26pub enum InstalledDistError {
27 #[error(transparent)]
28 Io(#[from] std::io::Error),
29
30 #[error(transparent)]
31 UrlParse(#[from] url::ParseError),
32
33 #[error(transparent)]
34 Json(#[from] serde_json::Error),
35
36 #[error(transparent)]
37 EggInfoParse(#[from] uv_distribution_filename::EggInfoFilenameError),
38
39 #[error(transparent)]
40 VersionParse(#[from] uv_pep440::VersionParseError),
41
42 #[error(transparent)]
43 PackageNameParse(#[from] uv_normalize::InvalidNameError),
44
45 #[error(transparent)]
46 WheelFileParse(#[from] uv_install_wheel::Error),
47
48 #[error(transparent)]
49 ExpandedTagParse(#[from] uv_distribution_filename::ExpandedTagError),
50
51 #[error("Invalid .egg-link path: `{}`", _0.user_display())]
52 InvalidEggLinkPath(PathBuf),
53
54 #[error("Invalid .egg-link target: `{}`", _0.user_display())]
55 InvalidEggLinkTarget(PathBuf),
56
57 #[error("Failed to parse METADATA file: `{}`", path.user_display())]
58 MetadataParse {
59 path: PathBuf,
60 #[source]
61 err: Box<MetadataError>,
62 },
63
64 #[error("Failed to parse `PKG-INFO` file: `{}`", path.user_display())]
65 PkgInfoParse {
66 path: PathBuf,
67 #[source]
68 err: Box<MetadataError>,
69 },
70}
71
72#[derive(Debug, Clone)]
73pub struct InstalledDist {
74 pub kind: InstalledDistKind,
75 metadata_cache: OnceLock<uv_pypi_types::ResolutionMetadata>,
78 tags_cache: OnceLock<Option<ExpandedTags>>,
79}
80
81impl From<InstalledDistKind> for InstalledDist {
82 fn from(kind: InstalledDistKind) -> Self {
83 Self {
84 kind,
85 metadata_cache: OnceLock::new(),
86 tags_cache: OnceLock::new(),
87 }
88 }
89}
90
91impl std::hash::Hash for InstalledDist {
92 fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
93 self.kind.hash(state);
94 }
95}
96
97impl PartialEq for InstalledDist {
98 fn eq(&self, other: &Self) -> bool {
99 self.kind == other.kind
100 }
101}
102
103impl Eq for InstalledDist {}
104
105#[derive(Debug, Clone, Hash, PartialEq, Eq)]
107pub enum InstalledDistKind {
108 Registry(InstalledRegistryDist),
110 Url(InstalledDirectUrlDist),
112 EggInfoFile(InstalledEggInfoFile),
114 EggInfoDirectory(InstalledEggInfoDirectory),
116 LegacyEditable(InstalledLegacyEditable),
118}
119
120#[derive(Debug, Clone, Hash, PartialEq, Eq)]
121pub struct InstalledRegistryDist {
122 pub name: PackageName,
123 pub version: Version,
124 pub path: Box<Path>,
125 pub cache_info: Option<CacheInfo>,
126 pub build_info: Option<BuildInfo>,
127}
128
129#[derive(Debug, Clone, Hash, PartialEq, Eq)]
130pub struct InstalledDirectUrlDist {
131 pub name: PackageName,
132 pub version: Version,
133 pub direct_url: Box<DirectUrl>,
134 pub url: DisplaySafeUrl,
135 pub editable: bool,
136 pub path: Box<Path>,
137 pub cache_info: Option<CacheInfo>,
138 pub build_info: Option<BuildInfo>,
139}
140
141#[derive(Debug, Clone, Hash, PartialEq, Eq)]
142pub struct InstalledEggInfoFile {
143 pub name: PackageName,
144 pub version: Version,
145 pub path: Box<Path>,
146}
147
148#[derive(Debug, Clone, Hash, PartialEq, Eq)]
149pub struct InstalledEggInfoDirectory {
150 pub name: PackageName,
151 pub version: Version,
152 pub path: Box<Path>,
153}
154
155#[derive(Debug, Clone, Hash, PartialEq, Eq)]
156pub struct InstalledLegacyEditable {
157 pub name: PackageName,
158 pub version: Version,
159 pub egg_link: Box<Path>,
160 pub target: Box<Path>,
161 pub target_url: DisplaySafeUrl,
162 pub egg_info: Box<Path>,
163}
164
165impl InstalledDist {
166 pub fn try_from_path(path: &Path) -> Result<Option<Self>, InstalledDistError> {
170 if path.extension().is_some_and(|ext| ext == "dist-info") {
172 let Some(file_stem) = path.file_stem() else {
173 return Ok(None);
174 };
175 let Some(file_stem) = file_stem.to_str() else {
176 return Ok(None);
177 };
178 let Some((name, version)) = file_stem.split_once('-') else {
179 return Ok(None);
180 };
181
182 let name = PackageName::from_str(name)?;
183 let version = Version::from_str(version)?;
184 let cache_info = Self::read_cache_info(path)?;
185 let build_info = Self::read_build_info(path)?;
186
187 return if let Some(direct_url) = Self::read_direct_url(path)? {
188 match DisplaySafeUrl::try_from(&direct_url) {
189 Ok(url) => Ok(Some(Self::from(InstalledDistKind::Url(
190 InstalledDirectUrlDist {
191 name,
192 version,
193 editable: matches!(&direct_url, DirectUrl::LocalDirectory { dir_info, .. } if dir_info.editable == Some(true)),
194 direct_url: Box::new(direct_url),
195 url,
196 path: path.to_path_buf().into_boxed_path(),
197 cache_info,
198 build_info,
199 },
200 )))),
201 Err(err) => {
202 warn!("Failed to parse direct URL: {err}");
203 Ok(Some(Self::from(InstalledDistKind::Registry(
204 InstalledRegistryDist {
205 name,
206 version,
207 path: path.to_path_buf().into_boxed_path(),
208 cache_info,
209 build_info,
210 },
211 ))))
212 }
213 }
214 } else {
215 Ok(Some(Self::from(InstalledDistKind::Registry(
216 InstalledRegistryDist {
217 name,
218 version,
219 path: path.to_path_buf().into_boxed_path(),
220 cache_info,
221 build_info,
222 },
223 ))))
224 };
225 }
226
227 if path.extension().is_some_and(|ext| ext == "egg-info") {
229 let metadata = match fs_err::metadata(path) {
230 Ok(metadata) => metadata,
231 Err(err) => {
232 warn!("Invalid `.egg-info` path: {err}");
233 return Ok(None);
234 }
235 };
236
237 let Some(file_stem) = path.file_stem() else {
238 return Ok(None);
239 };
240 let Some(file_stem) = file_stem.to_str() else {
241 return Ok(None);
242 };
243 let file_name = EggInfoFilename::parse(file_stem)?;
244
245 if let Some(version) = file_name.version {
246 if metadata.is_dir() {
247 return Ok(Some(Self::from(InstalledDistKind::EggInfoDirectory(
248 InstalledEggInfoDirectory {
249 name: file_name.name,
250 version,
251 path: path.to_path_buf().into_boxed_path(),
252 },
253 ))));
254 }
255
256 if metadata.is_file() {
257 return Ok(Some(Self::from(InstalledDistKind::EggInfoFile(
258 InstalledEggInfoFile {
259 name: file_name.name,
260 version,
261 path: path.to_path_buf().into_boxed_path(),
262 },
263 ))));
264 }
265 }
266
267 if metadata.is_dir() {
268 let Some(egg_metadata) = read_metadata(&path.join("PKG-INFO")) else {
269 return Ok(None);
270 };
271 return Ok(Some(Self::from(InstalledDistKind::EggInfoDirectory(
272 InstalledEggInfoDirectory {
273 name: file_name.name,
274 version: Version::from_str(&egg_metadata.version)?,
275 path: path.to_path_buf().into_boxed_path(),
276 },
277 ))));
278 }
279
280 if metadata.is_file() {
281 let Some(egg_metadata) = read_metadata(path) else {
282 return Ok(None);
283 };
284 return Ok(Some(Self::from(InstalledDistKind::EggInfoDirectory(
285 InstalledEggInfoDirectory {
286 name: file_name.name,
287 version: Version::from_str(&egg_metadata.version)?,
288 path: path.to_path_buf().into_boxed_path(),
289 },
290 ))));
291 }
292 }
293
294 if path.extension().is_some_and(|ext| ext == "egg-link") {
296 let Some(file_stem) = path.file_stem() else {
297 return Ok(None);
298 };
299 let Some(file_stem) = file_stem.to_str() else {
300 return Ok(None);
301 };
302
303 let contents = fs::read_to_string(path)?;
306 let Some(target) = contents.lines().find_map(|line| {
307 let line = line.trim();
308 if line.is_empty() {
309 None
310 } else {
311 Some(PathBuf::from(line))
312 }
313 }) else {
314 warn!("Invalid `.egg-link` file: {path:?}");
315 return Ok(None);
316 };
317
318 let target = path
320 .parent()
321 .ok_or_else(|| InstalledDistError::InvalidEggLinkPath(path.to_path_buf()))?
322 .join(target);
323
324 let egg_info = target.join(file_stem.replace('-', "_") + ".egg-info");
326 let url = DisplaySafeUrl::from_file_path(&target)
327 .map_err(|()| InstalledDistError::InvalidEggLinkTarget(path.to_path_buf()))?;
328
329 let Some(egg_metadata) = read_metadata(&egg_info.join("PKG-INFO")) else {
331 return Ok(None);
332 };
333
334 return Ok(Some(Self::from(InstalledDistKind::LegacyEditable(
335 InstalledLegacyEditable {
336 name: egg_metadata.name,
337 version: Version::from_str(&egg_metadata.version)?,
338 egg_link: path.to_path_buf().into_boxed_path(),
339 target: target.into_boxed_path(),
340 target_url: url,
341 egg_info: egg_info.into_boxed_path(),
342 },
343 ))));
344 }
345
346 Ok(None)
347 }
348
349 pub fn install_path(&self) -> &Path {
351 match &self.kind {
352 InstalledDistKind::Registry(dist) => &dist.path,
353 InstalledDistKind::Url(dist) => &dist.path,
354 InstalledDistKind::EggInfoDirectory(dist) => &dist.path,
355 InstalledDistKind::EggInfoFile(dist) => &dist.path,
356 InstalledDistKind::LegacyEditable(dist) => &dist.egg_info,
357 }
358 }
359
360 pub fn version(&self) -> &Version {
362 match &self.kind {
363 InstalledDistKind::Registry(dist) => &dist.version,
364 InstalledDistKind::Url(dist) => &dist.version,
365 InstalledDistKind::EggInfoDirectory(dist) => &dist.version,
366 InstalledDistKind::EggInfoFile(dist) => &dist.version,
367 InstalledDistKind::LegacyEditable(dist) => &dist.version,
368 }
369 }
370
371 pub fn cache_info(&self) -> Option<&CacheInfo> {
373 match &self.kind {
374 InstalledDistKind::Registry(dist) => dist.cache_info.as_ref(),
375 InstalledDistKind::Url(dist) => dist.cache_info.as_ref(),
376 InstalledDistKind::EggInfoDirectory(..) => None,
377 InstalledDistKind::EggInfoFile(..) => None,
378 InstalledDistKind::LegacyEditable(..) => None,
379 }
380 }
381
382 pub fn build_info(&self) -> Option<&BuildInfo> {
384 match &self.kind {
385 InstalledDistKind::Registry(dist) => dist.build_info.as_ref(),
386 InstalledDistKind::Url(dist) => dist.build_info.as_ref(),
387 InstalledDistKind::EggInfoDirectory(..) => None,
388 InstalledDistKind::EggInfoFile(..) => None,
389 InstalledDistKind::LegacyEditable(..) => None,
390 }
391 }
392
393 pub fn read_direct_url(path: &Path) -> Result<Option<DirectUrl>, InstalledDistError> {
395 let path = path.join("direct_url.json");
396 let file = match fs_err::File::open(&path) {
397 Ok(file) => file,
398 Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None),
399 Err(err) => return Err(err.into()),
400 };
401 let direct_url =
402 serde_json::from_reader::<BufReader<fs_err::File>, DirectUrl>(BufReader::new(file))?;
403 Ok(Some(direct_url))
404 }
405
406 pub fn read_cache_info(path: &Path) -> Result<Option<CacheInfo>, InstalledDistError> {
408 let path = path.join("uv_cache.json");
409 let file = match fs_err::File::open(&path) {
410 Ok(file) => file,
411 Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None),
412 Err(err) => return Err(err.into()),
413 };
414 let cache_info =
415 serde_json::from_reader::<BufReader<fs_err::File>, CacheInfo>(BufReader::new(file))?;
416 Ok(Some(cache_info))
417 }
418
419 pub fn read_build_info(path: &Path) -> Result<Option<BuildInfo>, InstalledDistError> {
421 let path = path.join("uv_build.json");
422 let file = match fs_err::File::open(&path) {
423 Ok(file) => file,
424 Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None),
425 Err(err) => return Err(err.into()),
426 };
427 let build_info =
428 serde_json::from_reader::<BufReader<fs_err::File>, BuildInfo>(BufReader::new(file))?;
429 Ok(Some(build_info))
430 }
431
432 pub fn read_metadata(&self) -> Result<&uv_pypi_types::ResolutionMetadata, InstalledDistError> {
434 if let Some(metadata) = self.metadata_cache.get() {
435 return Ok(metadata);
436 }
437
438 let metadata = match &self.kind {
439 InstalledDistKind::Registry(_) | InstalledDistKind::Url(_) => {
440 let path = self.install_path().join("METADATA");
441 let contents = fs::read(&path)?;
442 uv_pypi_types::ResolutionMetadata::parse_metadata(&contents).map_err(|err| {
444 InstalledDistError::MetadataParse {
445 path: path.clone(),
446 err: Box::new(err),
447 }
448 })?
449 }
450 InstalledDistKind::EggInfoFile(_)
451 | InstalledDistKind::EggInfoDirectory(_)
452 | InstalledDistKind::LegacyEditable(_) => {
453 let path = match &self.kind {
454 InstalledDistKind::EggInfoFile(dist) => Cow::Borrowed(&*dist.path),
455 InstalledDistKind::EggInfoDirectory(dist) => {
456 Cow::Owned(dist.path.join("PKG-INFO"))
457 }
458 InstalledDistKind::LegacyEditable(dist) => {
459 Cow::Owned(dist.egg_info.join("PKG-INFO"))
460 }
461 _ => unreachable!(),
462 };
463 let contents = fs::read(path.as_ref())?;
464 uv_pypi_types::ResolutionMetadata::parse_metadata(&contents).map_err(|err| {
465 InstalledDistError::PkgInfoParse {
466 path: path.to_path_buf(),
467 err: Box::new(err),
468 }
469 })?
470 }
471 };
472
473 let _ = self.metadata_cache.set(metadata);
474 Ok(self.metadata_cache.get().expect("metadata should be set"))
475 }
476
477 pub fn read_installer(&self) -> Result<Option<String>, InstalledDistError> {
479 let path = self.install_path().join("INSTALLER");
480 match fs::read_to_string(path) {
481 Ok(installer) => Ok(Some(installer.trim().to_owned())),
482 Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None),
483 Err(err) => Err(err.into()),
484 }
485 }
486
487 pub fn read_tags(&self) -> Result<Option<&ExpandedTags>, InstalledDistError> {
489 if let Some(tags) = self.tags_cache.get() {
490 return Ok(tags.as_ref());
491 }
492
493 let path = match &self.kind {
494 InstalledDistKind::Registry(dist) => &dist.path,
495 InstalledDistKind::Url(dist) => &dist.path,
496 InstalledDistKind::EggInfoFile(_) => return Ok(None),
497 InstalledDistKind::EggInfoDirectory(_) => return Ok(None),
498 InstalledDistKind::LegacyEditable(_) => return Ok(None),
499 };
500
501 let contents = fs_err::read_to_string(path.join("WHEEL"))?;
503 let wheel_file = WheelFile::parse(&contents)?;
504
505 let tags = if let Some(tags) = wheel_file.tags() {
507 Some(ExpandedTags::parse(tags.iter().map(String::as_str))?)
508 } else {
509 None
510 };
511
512 let _ = self.tags_cache.set(tags);
513 Ok(self.tags_cache.get().expect("tags should be set").as_ref())
514 }
515
516 pub fn is_editable(&self) -> bool {
518 matches!(
519 &self.kind,
520 InstalledDistKind::LegacyEditable(_)
521 | InstalledDistKind::Url(InstalledDirectUrlDist { editable: true, .. })
522 )
523 }
524
525 pub fn as_editable(&self) -> Option<&Url> {
527 match &self.kind {
528 InstalledDistKind::Registry(_) => None,
529 InstalledDistKind::Url(dist) => dist.editable.then_some(&dist.url),
530 InstalledDistKind::EggInfoFile(_) => None,
531 InstalledDistKind::EggInfoDirectory(_) => None,
532 InstalledDistKind::LegacyEditable(dist) => Some(&dist.target_url),
533 }
534 }
535
536 pub fn is_local(&self) -> bool {
538 match &self.kind {
539 InstalledDistKind::Registry(_) => false,
540 InstalledDistKind::Url(dist) => {
541 matches!(&*dist.direct_url, DirectUrl::LocalDirectory { .. })
542 }
543 InstalledDistKind::EggInfoFile(_) => false,
544 InstalledDistKind::EggInfoDirectory(_) => false,
545 InstalledDistKind::LegacyEditable(_) => true,
546 }
547 }
548}
549
550impl DistributionMetadata for InstalledDist {
551 fn version_or_url(&self) -> VersionOrUrlRef<'_> {
552 VersionOrUrlRef::Version(self.version())
553 }
554}
555
556impl Name for InstalledRegistryDist {
557 fn name(&self) -> &PackageName {
558 &self.name
559 }
560}
561
562impl Name for InstalledDirectUrlDist {
563 fn name(&self) -> &PackageName {
564 &self.name
565 }
566}
567
568impl Name for InstalledEggInfoFile {
569 fn name(&self) -> &PackageName {
570 &self.name
571 }
572}
573
574impl Name for InstalledEggInfoDirectory {
575 fn name(&self) -> &PackageName {
576 &self.name
577 }
578}
579
580impl Name for InstalledLegacyEditable {
581 fn name(&self) -> &PackageName {
582 &self.name
583 }
584}
585
586impl Name for InstalledDist {
587 fn name(&self) -> &PackageName {
588 match &self.kind {
589 InstalledDistKind::Registry(dist) => dist.name(),
590 InstalledDistKind::Url(dist) => dist.name(),
591 InstalledDistKind::EggInfoDirectory(dist) => dist.name(),
592 InstalledDistKind::EggInfoFile(dist) => dist.name(),
593 InstalledDistKind::LegacyEditable(dist) => dist.name(),
594 }
595 }
596}
597
598impl InstalledMetadata for InstalledRegistryDist {
599 fn installed_version(&self) -> InstalledVersion<'_> {
600 InstalledVersion::Version(&self.version)
601 }
602}
603
604impl InstalledMetadata for InstalledDirectUrlDist {
605 fn installed_version(&self) -> InstalledVersion<'_> {
606 InstalledVersion::Url(&self.url, &self.version)
607 }
608}
609
610impl InstalledMetadata for InstalledEggInfoFile {
611 fn installed_version(&self) -> InstalledVersion<'_> {
612 InstalledVersion::Version(&self.version)
613 }
614}
615
616impl InstalledMetadata for InstalledEggInfoDirectory {
617 fn installed_version(&self) -> InstalledVersion<'_> {
618 InstalledVersion::Version(&self.version)
619 }
620}
621
622impl InstalledMetadata for InstalledLegacyEditable {
623 fn installed_version(&self) -> InstalledVersion<'_> {
624 InstalledVersion::Version(&self.version)
625 }
626}
627
628impl InstalledMetadata for InstalledDist {
629 fn installed_version(&self) -> InstalledVersion<'_> {
630 match &self.kind {
631 InstalledDistKind::Registry(dist) => dist.installed_version(),
632 InstalledDistKind::Url(dist) => dist.installed_version(),
633 InstalledDistKind::EggInfoFile(dist) => dist.installed_version(),
634 InstalledDistKind::EggInfoDirectory(dist) => dist.installed_version(),
635 InstalledDistKind::LegacyEditable(dist) => dist.installed_version(),
636 }
637 }
638}
639
640fn read_metadata(path: &Path) -> Option<uv_pypi_types::Metadata10> {
641 let content = match fs::read(path) {
642 Ok(content) => content,
643 Err(err) => {
644 warn!("Failed to read metadata for {path:?}: {err}");
645 return None;
646 }
647 };
648 let metadata = match uv_pypi_types::Metadata10::parse_pkg_info(&content) {
649 Ok(metadata) => metadata,
650 Err(err) => {
651 warn!("Failed to parse metadata for {path:?}: {err}");
652 return None;
653 }
654 };
655
656 Some(metadata)
657}