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