2 import importlib.metadata
9 from typing import Iterator, List, Optional, Sequence, Set, Tuple
11 from pip._vendor.packaging.utils import NormalizedName, canonicalize_name
13 from pip._internal.metadata.base import BaseDistribution, BaseEnvironment
14 from pip._internal.models.wheel import Wheel
15 from pip._internal.utils.deprecation import deprecated
16 from pip._internal.utils.filetypes import WHEEL_EXTENSION
18 from ._compat import BadMetadata, BasePath, get_dist_name, get_info_location
19 from ._dists import Distribution
21 logger = logging.getLogger(__name__)
24 def _looks_like_wheel(location: str) -> bool:
25 if not location.endswith(WHEEL_EXTENSION):
27 if not os.path.isfile(location):
29 if not Wheel.wheel_file_re.match(os.path.basename(location)):
31 return zipfile.is_zipfile(location)
34 class _DistributionFinder:
35 """Finder to locate distributions.
37 The main purpose of this class is to memoize found distributions' names, so
38 only one distribution is returned for each package name. At lot of pip code
39 assumes this (because it is setuptools's behavior), and not doing the same
40 can potentially cause a distribution in lower precedence path to override a
41 higher precedence one if the caller is not careful.
43 Eventually we probably want to make it possible to see lower precedence
44 installations as well. It's useful feature, after all.
47 FoundResult = Tuple[importlib.metadata.Distribution, Optional[BasePath]]
49 def __init__(self) -> None:
50 self._found_names: Set[NormalizedName] = set()
52 def _find_impl(self, location: str) -> Iterator[FoundResult]:
53 """Find distributions in a location."""
54 # Skip looking inside a wheel. Since a package inside a wheel is not
55 # always valid (due to .data directories etc.), its .dist-info entry
56 # should not be considered an installed distribution.
57 if _looks_like_wheel(location):
59 # To know exactly where we find a distribution, we have to feed in the
60 # paths one by one, instead of dumping the list to importlib.metadata.
61 for dist in importlib.metadata.distributions(path=[location]):
62 info_location = get_info_location(dist)
64 raw_name = get_dist_name(dist)
65 except BadMetadata as e:
66 logger.warning("Skipping %s due to %s", info_location, e.reason)
68 normalized_name = canonicalize_name(raw_name)
69 if normalized_name in self._found_names:
71 self._found_names.add(normalized_name)
72 yield dist, info_location
74 def find(self, location: str) -> Iterator[BaseDistribution]:
75 """Find distributions in a location.
77 The path can be either a directory, or a ZIP archive.
79 for dist, info_location in self._find_impl(location):
80 if info_location is None:
81 installed_location: Optional[BasePath] = None
83 installed_location = info_location.parent
84 yield Distribution(dist, info_location, installed_location)
86 def find_linked(self, location: str) -> Iterator[BaseDistribution]:
87 """Read location in egg-link files and return distributions in there.
89 The path should be a directory; otherwise this returns nothing. This
90 follows how setuptools does this for compatibility. The first non-empty
91 line in the egg-link is read as a path (resolved against the egg-link's
92 containing directory if relative). Distributions found at that linked
93 location are returned.
95 path = pathlib.Path(location)
98 for child in path.iterdir():
99 if child.suffix != ".egg-link":
101 with child.open() as f:
102 lines = (line.strip() for line in f)
103 target_rel = next((line for line in lines if line), "")
106 target_location = str(path.joinpath(target_rel))
107 for dist, info_location in self._find_impl(target_location):
108 yield Distribution(dist, info_location, path)
110 def _find_eggs_in_dir(self, location: str) -> Iterator[BaseDistribution]:
111 from pip._vendor.pkg_resources import find_distributions
113 from pip._internal.metadata import pkg_resources as legacy
115 with os.scandir(location) as it:
117 if not entry.name.endswith(".egg"):
119 for dist in find_distributions(entry.path):
120 yield legacy.Distribution(dist)
122 def _find_eggs_in_zip(self, location: str) -> Iterator[BaseDistribution]:
123 from pip._vendor.pkg_resources import find_eggs_in_zip
125 from pip._internal.metadata import pkg_resources as legacy
128 importer = zipimport.zipimporter(location)
129 except zipimport.ZipImportError:
131 for dist in find_eggs_in_zip(importer, location):
132 yield legacy.Distribution(dist)
134 def find_eggs(self, location: str) -> Iterator[BaseDistribution]:
135 """Find eggs in a location.
137 This actually uses the old *pkg_resources* backend. We likely want to
138 deprecate this so we can eventually remove the *pkg_resources*
139 dependency entirely. Before that, this should first emit a deprecation
140 warning for some versions when using the fallback since importing
141 *pkg_resources* is slow for those who don't need it.
143 if os.path.isdir(location):
144 yield from self._find_eggs_in_dir(location)
145 if zipfile.is_zipfile(location):
146 yield from self._find_eggs_in_zip(location)
149 @functools.lru_cache(maxsize=None) # Warn a distribution exactly once.
150 def _emit_egg_deprecation(location: Optional[str]) -> None:
152 reason=f"Loading egg at {location} is deprecated.",
153 replacement="to use pip for package installation.",
158 class Environment(BaseEnvironment):
159 def __init__(self, paths: Sequence[str]) -> None:
163 def default(cls) -> BaseEnvironment:
167 def from_paths(cls, paths: Optional[List[str]]) -> BaseEnvironment:
172 def _iter_distributions(self) -> Iterator[BaseDistribution]:
173 finder = _DistributionFinder()
174 for location in self._paths:
175 yield from finder.find(location)
176 for dist in finder.find_eggs(location):
177 # _emit_egg_deprecation(dist.location) # TODO: Enable this.
179 # This must go last because that's how pkg_resources tie-breaks.
180 yield from finder.find_linked(location)
182 def get_distribution(self, name: str) -> Optional[BaseDistribution]:
185 for distribution in self.iter_all_distributions()
186 if distribution.canonical_name == canonicalize_name(name)
188 return next(matches, None)