Skip to content

Commit ce72e93

Browse files
committed
package: 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 eb1812a commit ce72e93

File tree

5 files changed

+64
-26
lines changed

5 files changed

+64
-26
lines changed

fawltydeps/packages.py

+52-14
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import logging
44
import sys
5+
from abc import ABC, abstractmethod
56
from dataclasses import dataclass, field
67
from enum import Enum
78
from pathlib import Path
@@ -101,7 +102,22 @@ def is_used(self, imported_names: Iterable[str]) -> bool:
101102
return bool(self.import_names.intersection(imported_names))
102103

103104

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

107123
def __init__(self, pyenv_path: Optional[Path] = None) -> None:
@@ -194,32 +210,54 @@ def lookup_package(self, package_name: str) -> Optional[Package]:
194210
return self.packages.get(Package.normalize_name(package_name))
195211

196212

213+
class IdentityMapping(BasePackageResolver):
214+
"""An imperfect package resolver that assmues package name == import name.
215+
216+
This will resolve _any_ package name into a corresponding identical import
217+
name (modulo normalization, see Package.normalize_name() for details).
218+
"""
219+
220+
def lookup_package(self, package_name: str) -> Optional[Package]:
221+
"""Convert a package name into a Package with the same import name."""
222+
ret = Package.identity_mapping(package_name)
223+
logger.info(
224+
f"Could not find {package_name!r} in the current environment. "
225+
f"Assuming it can be imported as {', '.join(sorted(ret.import_names))}"
226+
)
227+
return ret
228+
229+
197230
def resolve_dependencies(
198231
dep_names: Iterable[str], pyenv_path: Optional[Path] = None
199232
) -> Dict[str, Package]:
200233
"""Associate dependencies with corresponding Package objects.
201234
202-
Use LocalPackageLookup to find Package objects for each of the given
235+
Use LocalPackageResolver to find Package objects for each of the given
203236
dependencies inside the virtualenv given by 'pyenv_path'. When 'pyenv_path'
204237
is None (the default), look for packages in the current Python environment
205238
(i.e. equivalent to sys.path).
206239
207-
For dependencies that cannot be found with LocalPackageLookup,
208-
fabricate an identity mapping (a pseudo-package making available an import
209-
of the same name as the package, modulo normalization).
240+
For dependencies that cannot be found with LocalPackageResolver, fall back
241+
an identity mapping (a pseudo-package making available an import of the same
242+
name as the package, modulo normalization).
210243
211244
Return a dict mapping dependency names to the resolved Package objects.
212245
"""
246+
# This defines the "stack" of resolvers that we will use to convert
247+
# dependencies into provided import names. We call .lookup() on each
248+
# resolver in order until one of them returns a Package object. At that
249+
# point we are happy, and do not consult any of the later resolvers.
250+
resolvers = [
251+
LocalPackageResolver(pyenv_path),
252+
IdentityMapping(),
253+
]
213254
ret = {}
214-
local_packages = LocalPackageLookup(pyenv_path)
215255
for name in dep_names:
216256
if name not in ret:
217-
package = local_packages.lookup_package(name)
218-
if package is None: # fall back to identity mapping
219-
package = Package.identity_mapping(name)
220-
logger.info(
221-
f"Could not find {name!r} in the current environment. Assuming "
222-
f"it can be imported as {', '.join(sorted(package.import_names))}"
223-
)
224-
ret[name] = package
257+
for resolver in resolvers:
258+
package = resolver.lookup_package(name)
259+
if package is None: # skip to next resolver
260+
continue
261+
ret[name] = package
262+
break # skip to next dependency name
225263
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,
@@ -33,7 +33,7 @@ def collect_dep_names(deps: Iterable[DeclaredDependency]) -> Iterable[str]:
3333
# - pip (exposes a single import name: pip)
3434
# - isort (exposes no top_level.txt, but 'isort' import name can be inferred)
3535

36-
local_env = LocalPackageLookup()
36+
local_env = LocalPackageResolver()
3737

3838

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

0 commit comments

Comments
 (0)