Skip to main content

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;
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    // Cache data that must be read from the `.dist-info` directory. These are safe to cache as
75    // the `InstalledDist` is immutable after creation.
76    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/// A distribution installed in a Python environment.
105#[derive(Debug, Clone, Hash, PartialEq, Eq)]
106pub enum InstalledDistKind {
107    /// The distribution was derived from a registry, like `PyPI`.
108    Registry(InstalledRegistryDist),
109    /// The distribution was derived from an arbitrary URL.
110    Url(InstalledDirectUrlDist),
111    /// The distribution was derived from pre-existing `.egg-info` file (as installed by distutils).
112    EggInfoFile(InstalledEggInfoFile),
113    /// The distribution was derived from pre-existing `.egg-info` directory.
114    EggInfoDirectory(InstalledEggInfoDirectory),
115    /// The distribution was derived from an `.egg-link` pointer.
116    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    /// Try to parse a distribution from a `.dist-info` directory name (like `django-5.0a1.dist-info`).
166    ///
167    /// See: <https://packaging.python.org/en/latest/specifications/recording-installed-packages/#recording-installed-packages>
168    pub fn try_from_path(path: &Path) -> Result<Option<Self>, InstalledDistError> {
169        // Ex) `cffi-1.16.0.dist-info`
170        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        // Ex) `zstandard-0.22.0-py3.12.egg-info` or `vtk-9.2.6.egg-info`
213        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        // Ex) `zstandard.egg-link`
280        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            // https://setuptools.pypa.io/en/latest/deprecated/python_eggs.html#egg-links
289            // https://github.com/pypa/pip/blob/946f95d17431f645da8e2e0bf4054a72db5be766/src/pip/_internal/metadata/importlib/_envs.py#L86-L108
290            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            // Match pip, but note setuptools only puts absolute paths in `.egg-link` files.
304            let target = path
305                .parent()
306                .ok_or_else(|| InstalledDistError::InvalidEggLinkPath(path.to_path_buf()))?
307                .join(target);
308
309            // Normalisation comes from `pkg_resources.to_filename`.
310            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            // Mildly unfortunate that we must read metadata to get the version.
315            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    /// Return the [`Path`] at which the distribution is stored on-disk.
335    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    /// Return the [`Version`] of the distribution.
346    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    /// Return the [`BuildInfo`] of the distribution, if any.
357    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    /// Read the `direct_url.json` file from a `.dist-info` directory.
368    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    /// Read the `uv_cache.json` file from a `.dist-info` directory.
386    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    /// Read the `uv_build.json` file from a `.dist-info` directory.
399    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    /// Read the `METADATA` file from a `.dist-info` directory.
412    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                // TODO(zanieb): Update this to use thiserror so we can unpack parse errors downstream
422                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    /// Return the supported wheel tags for the distribution from the `WHEEL` file, if available.
457    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        // Read the `WHEEL` file.
471        let contents = fs_err::read_to_string(path.join("WHEEL"))?;
472        let wheel_file = WheelFile::parse(&contents)?;
473
474        // Parse the tags.
475        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    /// Return true if the distribution is editable.
486    pub fn is_editable(&self) -> bool {
487        matches!(
488            &self.kind,
489            InstalledDistKind::LegacyEditable(_)
490                | InstalledDistKind::Url(InstalledDirectUrlDist { editable: true, .. })
491        )
492    }
493
494    /// Return the [`SafeUrl`] of the distribution, if it is editable.
495    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    /// Return true if the distribution refers to a local file or directory.
506    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}