uv_distribution_types/
installed.rs

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    // Cache data that must be read from the `.dist-info` directory. These are safe to cache as
76    // the `InstalledDist` is immutable after creation.
77    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/// A built distribution (wheel) that is installed in a virtual environment.
106#[derive(Debug, Clone, Hash, PartialEq, Eq)]
107pub enum InstalledDistKind {
108    /// The distribution was derived from a registry, like `PyPI`.
109    Registry(InstalledRegistryDist),
110    /// The distribution was derived from an arbitrary URL.
111    Url(InstalledDirectUrlDist),
112    /// The distribution was derived from pre-existing `.egg-info` file (as installed by distutils).
113    EggInfoFile(InstalledEggInfoFile),
114    /// The distribution was derived from pre-existing `.egg-info` directory.
115    EggInfoDirectory(InstalledEggInfoDirectory),
116    /// The distribution was derived from an `.egg-link` pointer.
117    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    /// Try to parse a distribution from a `.dist-info` directory name (like `django-5.0a1.dist-info`).
167    ///
168    /// See: <https://packaging.python.org/en/latest/specifications/recording-installed-packages/#recording-installed-packages>
169    pub fn try_from_path(path: &Path) -> Result<Option<Self>, InstalledDistError> {
170        // Ex) `cffi-1.16.0.dist-info`
171        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        // Ex) `zstandard-0.22.0-py3.12.egg-info` or `vtk-9.2.6.egg-info`
228        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        // Ex) `zstandard.egg-link`
295        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            // https://setuptools.pypa.io/en/latest/deprecated/python_eggs.html#egg-links
304            // https://github.com/pypa/pip/blob/946f95d17431f645da8e2e0bf4054a72db5be766/src/pip/_internal/metadata/importlib/_envs.py#L86-L108
305            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            // Match pip, but note setuptools only puts absolute paths in `.egg-link` files.
319            let target = path
320                .parent()
321                .ok_or_else(|| InstalledDistError::InvalidEggLinkPath(path.to_path_buf()))?
322                .join(target);
323
324            // Normalisation comes from `pkg_resources.to_filename`.
325            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            // Mildly unfortunate that we must read metadata to get the version.
330            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    /// Return the [`Path`] at which the distribution is stored on-disk.
350    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    /// Return the [`Version`] of the distribution.
361    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    /// Return the [`CacheInfo`] of the distribution, if any.
372    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    /// Return the [`BuildInfo`] of the distribution, if any.
383    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    /// Read the `direct_url.json` file from a `.dist-info` directory.
394    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    /// Read the `uv_cache.json` file from a `.dist-info` directory.
407    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    /// Read the `uv_build.json` file from a `.dist-info` directory.
420    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    /// Read the `METADATA` file from a `.dist-info` directory.
433    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                // TODO(zanieb): Update this to use thiserror so we can unpack parse errors downstream
443                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    /// Return the `INSTALLER` of the distribution.
478    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    /// Return the supported wheel tags for the distribution from the `WHEEL` file, if available.
488    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        // Read the `WHEEL` file.
502        let contents = fs_err::read_to_string(path.join("WHEEL"))?;
503        let wheel_file = WheelFile::parse(&contents)?;
504
505        // Parse the tags.
506        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    /// Return true if the distribution is editable.
517    pub fn is_editable(&self) -> bool {
518        matches!(
519            &self.kind,
520            InstalledDistKind::LegacyEditable(_)
521                | InstalledDistKind::Url(InstalledDirectUrlDist { editable: true, .. })
522        )
523    }
524
525    /// Return the [`Url`] of the distribution, if it is editable.
526    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    /// Return true if the distribution refers to a local file or directory.
537    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}