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 build_info(&self) -> Option<&BuildInfo> {
373 match &self.kind {
374 InstalledDistKind::Registry(dist) => dist.build_info.as_ref(),
375 InstalledDistKind::Url(dist) => dist.build_info.as_ref(),
376 InstalledDistKind::EggInfoDirectory(..) => None,
377 InstalledDistKind::EggInfoFile(..) => None,
378 InstalledDistKind::LegacyEditable(..) => None,
379 }
380 }
381
382 fn read_direct_url(path: &Path) -> Result<Option<DirectUrl>, InstalledDistError> {
384 let path = path.join("direct_url.json");
385 let file = match fs_err::File::open(&path) {
386 Ok(file) => file,
387 Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None),
388 Err(err) => return Err(err.into()),
389 };
390 let direct_url =
391 serde_json::from_reader::<BufReader<fs_err::File>, DirectUrl>(BufReader::new(file))?;
392 Ok(Some(direct_url))
393 }
394
395 fn read_cache_info(path: &Path) -> Result<Option<CacheInfo>, InstalledDistError> {
397 let path = path.join("uv_cache.json");
398 let file = match fs_err::File::open(&path) {
399 Ok(file) => file,
400 Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None),
401 Err(err) => return Err(err.into()),
402 };
403 let cache_info =
404 serde_json::from_reader::<BufReader<fs_err::File>, CacheInfo>(BufReader::new(file))?;
405 Ok(Some(cache_info))
406 }
407
408 fn read_build_info(path: &Path) -> Result<Option<BuildInfo>, InstalledDistError> {
410 let path = path.join("uv_build.json");
411 let file = match fs_err::File::open(&path) {
412 Ok(file) => file,
413 Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None),
414 Err(err) => return Err(err.into()),
415 };
416 let build_info =
417 serde_json::from_reader::<BufReader<fs_err::File>, BuildInfo>(BufReader::new(file))?;
418 Ok(Some(build_info))
419 }
420
421 pub fn read_metadata(&self) -> Result<&uv_pypi_types::ResolutionMetadata, InstalledDistError> {
423 if let Some(metadata) = self.metadata_cache.get() {
424 return Ok(metadata);
425 }
426
427 let metadata = match &self.kind {
428 InstalledDistKind::Registry(_) | InstalledDistKind::Url(_) => {
429 let path = self.install_path().join("METADATA");
430 let contents = fs::read(&path)?;
431 uv_pypi_types::ResolutionMetadata::parse_metadata(&contents).map_err(|err| {
433 InstalledDistError::MetadataParse {
434 path: path.clone(),
435 err: Box::new(err),
436 }
437 })?
438 }
439 InstalledDistKind::EggInfoFile(_)
440 | InstalledDistKind::EggInfoDirectory(_)
441 | InstalledDistKind::LegacyEditable(_) => {
442 let path = match &self.kind {
443 InstalledDistKind::EggInfoFile(dist) => Cow::Borrowed(&*dist.path),
444 InstalledDistKind::EggInfoDirectory(dist) => {
445 Cow::Owned(dist.path.join("PKG-INFO"))
446 }
447 InstalledDistKind::LegacyEditable(dist) => {
448 Cow::Owned(dist.egg_info.join("PKG-INFO"))
449 }
450 _ => unreachable!(),
451 };
452 let contents = fs::read(path.as_ref())?;
453 uv_pypi_types::ResolutionMetadata::parse_metadata(&contents).map_err(|err| {
454 InstalledDistError::PkgInfoParse {
455 path: path.to_path_buf(),
456 err: Box::new(err),
457 }
458 })?
459 }
460 };
461
462 let _ = self.metadata_cache.set(metadata);
463 Ok(self.metadata_cache.get().expect("metadata should be set"))
464 }
465
466 pub fn read_tags(&self) -> Result<Option<&ExpandedTags>, InstalledDistError> {
468 if let Some(tags) = self.tags_cache.get() {
469 return Ok(tags.as_ref());
470 }
471
472 let path = match &self.kind {
473 InstalledDistKind::Registry(dist) => &dist.path,
474 InstalledDistKind::Url(dist) => &dist.path,
475 InstalledDistKind::EggInfoFile(_) => return Ok(None),
476 InstalledDistKind::EggInfoDirectory(_) => return Ok(None),
477 InstalledDistKind::LegacyEditable(_) => return Ok(None),
478 };
479
480 let contents = fs_err::read_to_string(path.join("WHEEL"))?;
482 let wheel_file = WheelFile::parse(&contents)?;
483
484 let tags = if let Some(tags) = wheel_file.tags() {
486 Some(ExpandedTags::parse(tags.iter().map(String::as_str))?)
487 } else {
488 None
489 };
490
491 let _ = self.tags_cache.set(tags);
492 Ok(self.tags_cache.get().expect("tags should be set").as_ref())
493 }
494
495 pub fn is_editable(&self) -> bool {
497 matches!(
498 &self.kind,
499 InstalledDistKind::LegacyEditable(_)
500 | InstalledDistKind::Url(InstalledDirectUrlDist { editable: true, .. })
501 )
502 }
503
504 pub fn as_editable(&self) -> Option<&Url> {
506 match &self.kind {
507 InstalledDistKind::Registry(_) => None,
508 InstalledDistKind::Url(dist) => dist.editable.then_some(&dist.url),
509 InstalledDistKind::EggInfoFile(_) => None,
510 InstalledDistKind::EggInfoDirectory(_) => None,
511 InstalledDistKind::LegacyEditable(dist) => Some(&dist.target_url),
512 }
513 }
514
515 pub(crate) fn is_local(&self) -> bool {
517 match &self.kind {
518 InstalledDistKind::Registry(_) => false,
519 InstalledDistKind::Url(dist) => {
520 matches!(&*dist.direct_url, DirectUrl::LocalDirectory { .. })
521 }
522 InstalledDistKind::EggInfoFile(_) => false,
523 InstalledDistKind::EggInfoDirectory(_) => false,
524 InstalledDistKind::LegacyEditable(_) => true,
525 }
526 }
527}
528
529impl DistributionMetadata for InstalledDist {
530 fn version_or_url(&self) -> VersionOrUrlRef<'_> {
531 VersionOrUrlRef::Version(self.version())
532 }
533}
534
535impl Name for InstalledRegistryDist {
536 fn name(&self) -> &PackageName {
537 &self.name
538 }
539}
540
541impl Name for InstalledDirectUrlDist {
542 fn name(&self) -> &PackageName {
543 &self.name
544 }
545}
546
547impl Name for InstalledEggInfoFile {
548 fn name(&self) -> &PackageName {
549 &self.name
550 }
551}
552
553impl Name for InstalledEggInfoDirectory {
554 fn name(&self) -> &PackageName {
555 &self.name
556 }
557}
558
559impl Name for InstalledLegacyEditable {
560 fn name(&self) -> &PackageName {
561 &self.name
562 }
563}
564
565impl Name for InstalledDist {
566 fn name(&self) -> &PackageName {
567 match &self.kind {
568 InstalledDistKind::Registry(dist) => dist.name(),
569 InstalledDistKind::Url(dist) => dist.name(),
570 InstalledDistKind::EggInfoDirectory(dist) => dist.name(),
571 InstalledDistKind::EggInfoFile(dist) => dist.name(),
572 InstalledDistKind::LegacyEditable(dist) => dist.name(),
573 }
574 }
575}
576
577impl InstalledMetadata for InstalledRegistryDist {
578 fn installed_version(&self) -> InstalledVersion<'_> {
579 InstalledVersion::Version(&self.version)
580 }
581}
582
583impl InstalledMetadata for InstalledDirectUrlDist {
584 fn installed_version(&self) -> InstalledVersion<'_> {
585 InstalledVersion::Url(&self.url, &self.version)
586 }
587}
588
589impl InstalledMetadata for InstalledEggInfoFile {
590 fn installed_version(&self) -> InstalledVersion<'_> {
591 InstalledVersion::Version(&self.version)
592 }
593}
594
595impl InstalledMetadata for InstalledEggInfoDirectory {
596 fn installed_version(&self) -> InstalledVersion<'_> {
597 InstalledVersion::Version(&self.version)
598 }
599}
600
601impl InstalledMetadata for InstalledLegacyEditable {
602 fn installed_version(&self) -> InstalledVersion<'_> {
603 InstalledVersion::Version(&self.version)
604 }
605}
606
607impl InstalledMetadata for InstalledDist {
608 fn installed_version(&self) -> InstalledVersion<'_> {
609 match &self.kind {
610 InstalledDistKind::Registry(dist) => dist.installed_version(),
611 InstalledDistKind::Url(dist) => dist.installed_version(),
612 InstalledDistKind::EggInfoFile(dist) => dist.installed_version(),
613 InstalledDistKind::EggInfoDirectory(dist) => dist.installed_version(),
614 InstalledDistKind::LegacyEditable(dist) => dist.installed_version(),
615 }
616 }
617}
618
619fn read_metadata(path: &Path) -> Option<uv_pypi_types::Metadata10> {
620 let content = match fs::read(path) {
621 Ok(content) => content,
622 Err(err) => {
623 warn!("Failed to read metadata for {path:?}: {err}");
624 return None;
625 }
626 };
627 let metadata = match uv_pypi_types::Metadata10::parse_pkg_info(&content) {
628 Ok(metadata) => metadata,
629 Err(err) => {
630 warn!("Failed to parse metadata for {path:?}: {err}");
631 return None;
632 }
633 };
634
635 Some(metadata)
636}