Skip to content

Commit 0a42da6

Browse files
committed
packages: Introduce a list of package resolvers
This does not change current behavior, but does a preliminary refactoring on the way towards having multiple package-to-import resolvers as discussed in #256 and associated issues. Introduce a BasePackageResolver abstract base class to define the interface for package resolvers, and provide, for now, two subclasses: - LocalPackageResolver (renamed from LocalPackageLookup) - IdentityMapping (refactored from resolve_dependencies()) Also refactor resolved_dependencies() to operate on a list of BasePackageResolver objects: For each dependency name we go through the list of resolvers until a resolver returns a Package object for this name. We then add this Package to the returned mapping. For now this list of resolvers is hardcoded to exactly two entries, the LocalPackageResolver we were already using, and the IdentityMapping that we were effectively falling back on for packages not found in the former. Ideas for future work that build on this: - Introduce more/different BasePackageResolver subclasses to do different kinds of package resolving. E.g. user-defined mapping from a config file. Resolving by looking up packages remotely on PyPI, etc. - Make the list of resolvers user-configurable instead of hardcoded. - Properly handle the case where none of the resolver find a package mapping, i.e. _unmapped_ depenedencies.
1 parent 8056dc6 commit 0a42da6

File tree

5 files changed

+69
-33
lines changed

5 files changed

+69
-33
lines changed

fawltydeps/packages.py

+57-21
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import sys
66
import tempfile
77
import venv
8+
from abc import ABC, abstractmethod
89
from contextlib import contextmanager, nullcontext
910
from dataclasses import dataclass, field
1011
from enum import Enum
@@ -105,7 +106,22 @@ def is_used(self, imported_names: Iterable[str]) -> bool:
105106
return bool(self.import_names.intersection(imported_names))
106107

107108

108-
class LocalPackageLookup:
109+
class BasePackageResolver(ABC):
110+
"""Define the interface for doing package -> import names lookup."""
111+
112+
@abstractmethod
113+
def lookup_package(self, package_name: str) -> Optional[Package]:
114+
"""Convert a package name into a Package object with available imports.
115+
116+
Return a Package object that encapsulates the package-name-to-import-
117+
names mapping for the given package name.
118+
119+
Return None if this PackageResolver is unable to resolve the package.
120+
"""
121+
raise NotImplementedError
122+
123+
124+
class LocalPackageResolver(BasePackageResolver):
109125
"""Lookup imports exposed by packages installed in a Python environment."""
110126

111127
def __init__(self, pyenv_path: Optional[Path] = None) -> None:
@@ -201,10 +217,10 @@ def lookup_package(self, package_name: str) -> Optional[Package]:
201217
@contextmanager
202218
def temp_installed_requirements(
203219
requirements: List[str],
204-
) -> Iterator[LocalPackageLookup]:
220+
) -> Iterator[LocalPackageResolver]:
205221
"""Install packages into a temporary venv and provide lookup.
206222
207-
Provide a `LocalPackageLookup` object into the caller's context which
223+
Provide a `LocalPackageResolver` object into the caller's context which
208224
provide dependency-to-imports mapping for the given requirements.
209225
The requirements are downloaded and installed into a temporary virtualenv,
210226
which is automatically deleted at the end of this context.
@@ -217,7 +233,24 @@ def temp_installed_requirements(
217233
check=True, # fail if any of the commands fail
218234
)
219235
(venv_dir / ".installed").touch()
220-
yield LocalPackageLookup(venv_dir)
236+
yield LocalPackageResolver(venv_dir)
237+
238+
239+
class IdentityMapping(BasePackageResolver):
240+
"""An imperfect package resolver that assumes package name == import name.
241+
242+
This will resolve _any_ package name into a corresponding identical import
243+
name (modulo normalization, see Package.normalize_name() for details).
244+
"""
245+
246+
def lookup_package(self, package_name: str) -> Optional[Package]:
247+
"""Convert a package name into a Package with the same import name."""
248+
ret = Package.identity_mapping(package_name)
249+
logger.info(
250+
f"Could not find {package_name!r} in the current environment. "
251+
f"Assuming it can be imported as {', '.join(sorted(ret.import_names))}"
252+
)
253+
return ret
221254

222255

223256
def resolve_dependencies(
@@ -227,19 +260,13 @@ def resolve_dependencies(
227260
) -> Dict[str, Package]:
228261
"""Associate dependencies with corresponding Package objects.
229262
230-
Use LocalPackageLookup to find Package objects for each of the given
263+
Use LocalPackageResolver to find Package objects for each of the given
231264
dependencies inside the virtualenv given by 'pyenv_path'. When 'pyenv_path'
232265
is None (the default), look for packages in the current Python environment
233266
(i.e. equivalent to sys.path).
234267
235-
For dependencies that cannot be found with LocalPackageLookup,
236-
fabricate an identity mapping (a pseudo-package making available an import
237-
of the same name as the package, modulo normalization).
238-
239268
Return a dict mapping dependency names to the resolved Package objects.
240269
"""
241-
ret = {}
242-
243270
# consume the iterable once
244271
deps = list(dep_names)
245272

@@ -251,17 +278,26 @@ def resolve_dependencies(
251278
)
252279
local_packages_context = temp_installed_requirements(deps)
253280
else:
254-
local_packages_context = nullcontext(LocalPackageLookup(pyenv_path)) # type: ignore
281+
local_packages_context = nullcontext(LocalPackageResolver(pyenv_path)) # type: ignore
255282

256283
with local_packages_context as local_packages:
284+
# This defines the "stack" of resolvers that we will use to convert
285+
# dependencies into provided import names. We call .lookup_package() on
286+
# each resolver in order until one of them returns a Package object. At
287+
# that point we are happy, and don't consult any of the later resolvers.
288+
resolvers = [
289+
local_packages,
290+
IdentityMapping(),
291+
]
292+
ret = {}
293+
257294
for name in deps:
258295
if name not in ret:
259-
package = local_packages.lookup_package(name)
260-
if package is None: # fall back to identity mapping
261-
package = Package.identity_mapping(name)
262-
logger.info(
263-
f"Could not find {name!r} in the current environment. Assuming "
264-
f"it can be imported as {', '.join(sorted(package.import_names))}"
265-
)
266-
ret[name] = package
267-
return ret
296+
for resolver in resolvers:
297+
package = resolver.lookup_package(name)
298+
if package is None: # skip to next resolver
299+
continue
300+
ret[name] = package
301+
break # skip to next dependency name
302+
303+
return ret

tests/test_local_env.py

+6-6
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66

77
from fawltydeps.packages import (
88
DependenciesMapping,
9-
LocalPackageLookup,
9+
LocalPackageResolver,
1010
Package,
1111
resolve_dependencies,
1212
)
@@ -31,7 +31,7 @@ def test_determine_package_dir__various_paths_in_venv(tmp_path, subdir):
3131
venv.create(tmp_path, with_pip=False)
3232
path = tmp_path / subdir
3333
expect = tmp_path / f"lib/python{major}.{minor}/site-packages"
34-
assert LocalPackageLookup.determine_package_dir(path) == expect
34+
assert LocalPackageResolver.determine_package_dir(path) == expect
3535

3636

3737
@pytest.mark.parametrize(
@@ -49,18 +49,18 @@ def test_determine_package_dir__various_paths_in_poetry2nix_env(
4949
)
5050
path = tmp_path / subdir
5151
expect = tmp_path / f"lib/python{major}.{minor}/site-packages"
52-
assert LocalPackageLookup.determine_package_dir(path) == expect
52+
assert LocalPackageResolver.determine_package_dir(path) == expect
5353

5454

5555
def test_local_env__empty_venv__has_no_packages(tmp_path):
5656
venv.create(tmp_path, with_pip=False)
57-
lpl = LocalPackageLookup(tmp_path)
57+
lpl = LocalPackageResolver(tmp_path)
5858
assert lpl.packages == {}
5959

6060

6161
def test_local_env__default_venv__contains_pip_and_setuptools(tmp_path):
6262
venv.create(tmp_path, with_pip=True)
63-
lpl = LocalPackageLookup(tmp_path)
63+
lpl = LocalPackageResolver(tmp_path)
6464
# We cannot do a direct comparison, as different Python/pip/setuptools
6565
# versions differ in exactly which packages are provided. The following
6666
# is a subset that we can expect across all of our supported versions.
@@ -78,7 +78,7 @@ def test_local_env__default_venv__contains_pip_and_setuptools(tmp_path):
7878

7979

8080
def test_local_env__current_venv__contains_our_test_dependencies():
81-
lpl = LocalPackageLookup()
81+
lpl = LocalPackageResolver()
8282
expect_package_names = [
8383
# Present in ~all venvs:
8484
"pip",

tests/test_packages.py

+2-2
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66

77
from fawltydeps.packages import (
88
DependenciesMapping,
9-
LocalPackageLookup,
9+
LocalPackageResolver,
1010
Package,
1111
resolve_dependencies,
1212
)
@@ -184,7 +184,7 @@ def test_package__both_mappings():
184184
],
185185
)
186186
def test_LocalPackageLookup_lookup_package(dep_name, expect_import_names):
187-
lpl = LocalPackageLookup()
187+
lpl = LocalPackageResolver()
188188
actual = lpl.lookup_package(dep_name)
189189
if expect_import_names is None:
190190
assert actual is None

tests/test_real_projects.py

+2-2
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
import pytest
2020
from pkg_resources import Requirement
2121

22-
from fawltydeps.packages import LocalPackageLookup
22+
from fawltydeps.packages import LocalPackageResolver
2323
from fawltydeps.types import TomlData
2424

2525
from .project_helpers import BaseExperiment, BaseProject, JsonData, parse_toml
@@ -36,7 +36,7 @@
3636

3737

3838
def verify_requirements(venv_path: Path, requirements: List[str]) -> None:
39-
lpl = LocalPackageLookup(venv_path)
39+
lpl = LocalPackageResolver(venv_path)
4040
for req in requirements:
4141
if "python_version" in req: # we don't know how to parse these (yet)
4242
continue # skip checking this requirement

tests/utils.py

+2-2
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
from pathlib import Path
77
from typing import Any, Dict, Iterable, List, Optional, Tuple
88

9-
from fawltydeps.packages import DependenciesMapping, LocalPackageLookup, Package
9+
from fawltydeps.packages import DependenciesMapping, LocalPackageResolver, Package
1010
from fawltydeps.types import (
1111
DeclaredDependency,
1212
Location,
@@ -35,7 +35,7 @@ def collect_dep_names(deps: Iterable[DeclaredDependency]) -> Iterable[str]:
3535
# - pip (exposes a single import name: pip)
3636
# - isort (exposes no top_level.txt, but 'isort' import name can be inferred)
3737

38-
local_env = LocalPackageLookup()
38+
local_env = LocalPackageResolver()
3939

4040

4141
def imports_factory(*imports: str) -> List[ParsedImport]:

0 commit comments

Comments
 (0)