|
9 | 9 | from contextlib import contextmanager
|
10 | 10 | from dataclasses import dataclass, field
|
11 | 11 | from pathlib import Path
|
12 |
| -from typing import Dict, Iterable, Iterator, List, Optional, Set, Tuple |
| 12 | +from typing import AbstractSet, Dict, Iterable, Iterator, List, Optional, Set, Tuple |
13 | 13 |
|
14 | 14 | # importlib_metadata is gradually graduating into the importlib.metadata stdlib
|
15 | 15 | # module, however we rely on internal functions and recent (and upcoming)
|
@@ -181,24 +181,29 @@ class LocalPackageResolver(BasePackageResolver):
|
181 | 181 |
|
182 | 182 | def __init__(
|
183 | 183 | self,
|
184 |
| - pyenv_path: Optional[Path] = None, |
| 184 | + pyenv_paths: AbstractSet[Path] = frozenset(), |
185 | 185 | description_template: str = "Python env at {path}",
|
186 | 186 | ) -> None:
|
187 | 187 | """Lookup packages installed in the given virtualenv.
|
188 | 188 |
|
189 |
| - Default to the current python environment if `pyenv_path` is not given |
190 |
| - (or None). |
| 189 | + Default to the current python environment if `pyenv_paths` is empty |
| 190 | + (the default). |
191 | 191 |
|
192 | 192 | Use importlib_metadata to look up the mapping between packages and their
|
193 | 193 | provided import names.
|
194 | 194 | """
|
195 | 195 | self.description_template = description_template
|
196 |
| - if pyenv_path is not None: |
197 |
| - self.pyenv_path = self.determine_package_dir(pyenv_path) |
198 |
| - if self.pyenv_path is None: |
199 |
| - raise ValueError(f"Could not find a Python env at {pyenv_path}!") |
200 |
| - else: |
201 |
| - self.pyenv_path = None |
| 196 | + self.package_dirs: Set[Path] = set() # empty => use sys.path instead |
| 197 | + if pyenv_paths: |
| 198 | + package_dirs = { |
| 199 | + (path, self.determine_package_dir(path)) for path in pyenv_paths |
| 200 | + } |
| 201 | + for pyenv_path, package_dir in package_dirs: |
| 202 | + if package_dir is None: |
| 203 | + logger.warning(f"Could not find a Python env at {pyenv_path}!") |
| 204 | + self.package_dirs = {dir for _, dir in package_dirs if dir is not None} |
| 205 | + if not self.package_dirs: |
| 206 | + raise ValueError(f"Could not find any Python env in {pyenv_paths}!") |
202 | 207 | # We enumerate packages for pyenv_path _once_ and cache the result here:
|
203 | 208 | self._packages: Optional[Dict[str, Package]] = None
|
204 | 209 |
|
@@ -233,49 +238,61 @@ def determine_package_dir(cls, path: Path) -> Optional[Path]:
|
233 | 238 | # Try again with parent directory
|
234 | 239 | return None if path.parent == path else cls.determine_package_dir(path.parent)
|
235 | 240 |
|
236 |
| - @property |
237 |
| - @calculated_once |
238 |
| - def packages(self) -> Dict[str, Package]: |
239 |
| - """Return mapping of package names to Package objects. |
| 241 | + def _from_one_env( |
| 242 | + self, env_paths: List[str] |
| 243 | + ) -> Iterator[Tuple[CustomMapping, str]]: |
| 244 | + """Return package-name-to-import-names mapping from one Python env. |
240 | 245 |
|
241 |
| - This enumerates the available packages in the given Python environment |
242 |
| - (or the current Python environment) _once_, and caches the result for |
243 |
| - the remainder of this object's life. |
| 246 | + This is roughly equivalent to calling importlib_metadata's |
| 247 | + packages_distributions(), except that instead of implicitly querying |
| 248 | + sys.path, we query env_paths instead. |
| 249 | +
|
| 250 | + Also, we are able to return packages that map to zero import names, |
| 251 | + whereas packages_distributions() cannot. |
244 | 252 | """
|
245 |
| - if self.pyenv_path is None: |
246 |
| - paths = sys.path # use current Python environment |
247 |
| - else: |
248 |
| - paths = [str(self.pyenv_path)] |
249 |
| - |
250 |
| - ret = {} |
251 |
| - # We're reaching into the internals of importlib_metadata here, |
252 |
| - # which Mypy is not overly fond of. Roughly what we're doing here |
253 |
| - # is calling packages_distributions(), but on a possibly different |
254 |
| - # environment than the current one (i.e. sys.path). |
255 |
| - # Note that packages_distributions() is not able to return packages |
256 |
| - # that map to zero import names. |
257 |
| - context = DistributionFinder.Context(path=paths) # type: ignore |
| 253 | + seen = set() # Package names (normalized) seen earlier in env_paths |
| 254 | + |
| 255 | + # We're reaching into the internals of importlib_metadata here, which |
| 256 | + # Mypy is not overly fond of, hence lots of "type: ignore"... |
| 257 | + context = DistributionFinder.Context(path=env_paths) # type: ignore |
258 | 258 | for dist in MetadataPathFinder().find_distributions(context): # type: ignore
|
259 | 259 | normalized_name = Package.normalize_name(dist.name)
|
260 | 260 | parent_dir = dist.locate_file("")
|
261 |
| - if normalized_name in ret: |
| 261 | + if normalized_name in seen: |
262 | 262 | # We already found another instance of this package earlier in
|
263 |
| - # the given paths. Assume that the earlier package is what |
264 |
| - # Python's import machinery will choose, and that this later |
265 |
| - # package is not interesting |
| 263 | + # env_paths. Assume that the earlier package is what Python's |
| 264 | + # import machinery will choose, and that this later package is |
| 265 | + # not interesting. |
266 | 266 | logger.debug(f"Skip {dist.name} {dist.version} under {parent_dir}")
|
267 | 267 | continue
|
268 | 268 |
|
269 | 269 | logger.debug(f"Found {dist.name} {dist.version} under {parent_dir}")
|
270 |
| - imports = set( |
| 270 | + seen.add(normalized_name) |
| 271 | + imports = list( |
271 | 272 | _top_level_declared(dist) # type: ignore
|
272 | 273 | or _top_level_inferred(dist) # type: ignore
|
273 | 274 | )
|
274 | 275 | description = self.description_template.format(path=parent_dir)
|
275 |
| - package = Package(dist.name, {description: imports}) |
276 |
| - ret[normalized_name] = package |
| 276 | + yield {dist.name: imports}, description |
277 | 277 |
|
278 |
| - return ret |
| 278 | + @property |
| 279 | + @calculated_once |
| 280 | + def packages(self) -> Dict[str, Package]: |
| 281 | + """Return mapping of package names to Package objects. |
| 282 | +
|
| 283 | + This enumerates the available packages in the given Python environment |
| 284 | + (or the current Python environment) _once_, and caches the result for |
| 285 | + the remainder of this object's life. |
| 286 | + """ |
| 287 | + |
| 288 | + def _pyenvs() -> Iterator[Tuple[CustomMapping, str]]: |
| 289 | + if not self.package_dirs: # No pyenvs given, fall back to sys.path |
| 290 | + yield from self._from_one_env(sys.path) |
| 291 | + else: |
| 292 | + for package_dir in self.package_dirs: |
| 293 | + yield from self._from_one_env([str(package_dir)]) |
| 294 | + |
| 295 | + return accumulate_mappings(_pyenvs()) |
279 | 296 |
|
280 | 297 | def lookup_packages(self, package_names: Set[str]) -> Dict[str, Package]:
|
281 | 298 | """Convert package names to locally available Package objects.
|
@@ -339,7 +356,7 @@ def lookup_packages(self, package_names: Set[str]) -> Dict[str, Package]:
|
339 | 356 | logger.info("Installing dependencies into a new temporary Python environment.")
|
340 | 357 | with self.temp_installed_requirements(sorted(package_names)) as venv_dir:
|
341 | 358 | description_template = "Temporarily pip-installed"
|
342 |
| - local_resolver = LocalPackageResolver(venv_dir, description_template) |
| 359 | + local_resolver = LocalPackageResolver({venv_dir}, description_template) |
343 | 360 | return local_resolver.lookup_packages(package_names)
|
344 | 361 |
|
345 | 362 |
|
@@ -396,7 +413,9 @@ def resolve_dependencies(
|
396 | 413 | )
|
397 | 414 | )
|
398 | 415 |
|
399 |
| - resolvers.append(LocalPackageResolver(pyenv_path)) |
| 416 | + resolvers.append( |
| 417 | + LocalPackageResolver(set() if pyenv_path is None else {pyenv_path}) |
| 418 | + ) |
400 | 419 | if install_deps:
|
401 | 420 | resolvers += [TemporaryPipInstallResolver()]
|
402 | 421 | # Identity mapping being at the bottom of the resolvers stack ensures that
|
|
0 commit comments