Skip to content

Commit ec726e6

Browse files
committed
LocalPackageResolver: Accept multiple pyenvs
This adds the core logic for FawltyDeps to become able to handle multiple --pyenv arguments. Instead of taking an optional 'pyenv_path', we now take a 'pyenv_paths' set of paths; the new behavior is an extension of the old: - An empty set (corresponds to pyenv_path == None) implies that we use sys.path to look up packages. - A single-element set (corresponds to pyenv_path != None) will use the Python environment found at that path instead. - A multi-element set (new in this commit) causes packages to be looked up in _all_ the given Python environments. For packages that occur in more than one of these environments, the resulting Package object will _merge_ all the imports found among the various copies of this package (similar to how we deal with the same package occurring across multiple user-defined mappings).
1 parent 3322968 commit ec726e6

File tree

4 files changed

+95
-44
lines changed

4 files changed

+95
-44
lines changed

fawltydeps/packages.py

+59-40
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
from contextlib import contextmanager
1010
from dataclasses import dataclass, field
1111
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
1313

1414
# importlib_metadata is gradually graduating into the importlib.metadata stdlib
1515
# module, however we rely on internal functions and recent (and upcoming)
@@ -181,24 +181,29 @@ class LocalPackageResolver(BasePackageResolver):
181181

182182
def __init__(
183183
self,
184-
pyenv_path: Optional[Path] = None,
184+
pyenv_paths: AbstractSet[Path] = frozenset(),
185185
description_template: str = "Python env at {path}",
186186
) -> None:
187187
"""Lookup packages installed in the given virtualenv.
188188
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).
191191
192192
Use importlib_metadata to look up the mapping between packages and their
193193
provided import names.
194194
"""
195195
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}!")
202207
# We enumerate packages for pyenv_path _once_ and cache the result here:
203208
self._packages: Optional[Dict[str, Package]] = None
204209

@@ -233,49 +238,61 @@ def determine_package_dir(cls, path: Path) -> Optional[Path]:
233238
# Try again with parent directory
234239
return None if path.parent == path else cls.determine_package_dir(path.parent)
235240

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.
240245
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.
244252
"""
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
258258
for dist in MetadataPathFinder().find_distributions(context): # type: ignore
259259
normalized_name = Package.normalize_name(dist.name)
260260
parent_dir = dist.locate_file("")
261-
if normalized_name in ret:
261+
if normalized_name in seen:
262262
# 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.
266266
logger.debug(f"Skip {dist.name} {dist.version} under {parent_dir}")
267267
continue
268268

269269
logger.debug(f"Found {dist.name} {dist.version} under {parent_dir}")
270-
imports = set(
270+
seen.add(normalized_name)
271+
imports = list(
271272
_top_level_declared(dist) # type: ignore
272273
or _top_level_inferred(dist) # type: ignore
273274
)
274275
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
277277

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())
279296

280297
def lookup_packages(self, package_names: Set[str]) -> Dict[str, Package]:
281298
"""Convert package names to locally available Package objects.
@@ -339,7 +356,7 @@ def lookup_packages(self, package_names: Set[str]) -> Dict[str, Package]:
339356
logger.info("Installing dependencies into a new temporary Python environment.")
340357
with self.temp_installed_requirements(sorted(package_names)) as venv_dir:
341358
description_template = "Temporarily pip-installed"
342-
local_resolver = LocalPackageResolver(venv_dir, description_template)
359+
local_resolver = LocalPackageResolver({venv_dir}, description_template)
343360
return local_resolver.lookup_packages(package_names)
344361

345362

@@ -396,7 +413,9 @@ def resolve_dependencies(
396413
)
397414
)
398415

399-
resolvers.append(LocalPackageResolver(pyenv_path))
416+
resolvers.append(
417+
LocalPackageResolver(set() if pyenv_path is None else {pyenv_path})
418+
)
400419
if install_deps:
401420
resolvers += [TemporaryPipInstallResolver()]
402421
# Identity mapping being at the bottom of the resolvers stack ensures that

tests/test_local_env.py

+31-2
Original file line numberDiff line numberDiff line change
@@ -81,13 +81,13 @@ def test_determine_package_dir__various_paths_in_pypackages(write_tmp_files, sub
8181

8282
def test_local_env__empty_venv__has_no_packages(tmp_path):
8383
venv.create(tmp_path, with_pip=False)
84-
lpl = LocalPackageResolver(tmp_path)
84+
lpl = LocalPackageResolver({tmp_path})
8585
assert lpl.packages == {}
8686

8787

8888
def test_local_env__default_venv__contains_pip_and_setuptools(tmp_path):
8989
venv.create(tmp_path, with_pip=True)
90-
lpl = LocalPackageResolver(tmp_path)
90+
lpl = LocalPackageResolver({tmp_path})
9191
# We cannot do a direct comparison, as different Python/pip/setuptools
9292
# versions differ in exactly which packages are provided. The following
9393
# is a subset that we can expect across all of our supported versions.
@@ -136,6 +136,35 @@ def test_local_env__prefers_first_package_found_in_sys_path(isolate_default_reso
136136
}
137137

138138

139+
def test_local_env__multiple_pyenvs__can_find_packages_in_all(fake_venv):
140+
venv_dir1, site_dir1 = fake_venv({"some_module": {"some_module"}})
141+
venv_dir2, site_dir2 = fake_venv({"other-module": {"other_module"}})
142+
lpl = LocalPackageResolver({venv_dir1, venv_dir2})
143+
assert lpl.lookup_packages({"some_module", "other-module"}) == {
144+
"some_module": Package(
145+
"some_module", {f"Python env at {site_dir1}": {"some_module"}}
146+
),
147+
"other-module": Package(
148+
"other_module", {f"Python env at {site_dir2}": {"other_module"}}
149+
),
150+
}
151+
152+
153+
def test_local_env__multiple_pyenvs__merges_imports_for_same_package(fake_venv):
154+
venv_dir1, site_dir1 = fake_venv({"some_module": {"first_import"}})
155+
venv_dir2, site_dir2 = fake_venv({"some_module": {"second_import"}})
156+
lpl = LocalPackageResolver({venv_dir1, venv_dir2})
157+
assert lpl.lookup_packages({"some_module"}) == {
158+
"some_module": Package(
159+
"some_module",
160+
{
161+
f"Python env at {site_dir1}": {"first_import"},
162+
f"Python env at {site_dir2}": {"second_import"},
163+
},
164+
),
165+
}
166+
167+
139168
def test_resolve_dependencies__in_empty_venv__reverts_to_id_mapping(tmp_path):
140169
venv.create(tmp_path, with_pip=False)
141170
id_mapping = IdentityMapping()

tests/test_real_projects.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ def verify_requirements(venv_path: Path, requirements: List[str]) -> None:
4444
for req in requirements
4545
if "python_version" not in req # we don't know how to parse these (yet)
4646
}
47-
resolved = LocalPackageResolver(venv_path).lookup_packages(deps)
47+
resolved = LocalPackageResolver({venv_path}).lookup_packages(deps)
4848
assert all(dep in resolved for dep in deps)
4949

5050

tests/test_resolver.py

+4-1
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,10 @@ def generate_expected_resolved_deps(
7676
if locally_installed_deps:
7777
ret.update(
7878
{
79-
dep: Package(dep, {"Python env at {site_packages}": set(imports)})
79+
dep: Package(
80+
Package.normalize_name(dep),
81+
{"Python env at {site_packages}": set(imports)},
82+
)
8083
for dep, imports in locally_installed_deps.items()
8184
}
8285
)

0 commit comments

Comments
 (0)