diff --git a/fawltydeps/check.py b/fawltydeps/check.py index db67ad9e..56d0c95e 100644 --- a/fawltydeps/check.py +++ b/fawltydeps/check.py @@ -1,7 +1,9 @@ "Compare imports and dependencies" +import logging +import sys from itertools import groupby -from typing import List, Tuple +from typing import List, Optional, Tuple from fawltydeps.types import ( DeclaredDependency, @@ -10,6 +12,39 @@ UnusedDependency, ) +# importlib.metadata.packages_distributions() was introduced in v3.10, but it +# is not able to infer import names for modules lacking a top_level.txt until +# v3.11. Hence we prefer importlib_metadata in v3.10 as well as pre-v3.10. +if sys.version_info >= (3, 11): + from importlib.metadata import packages_distributions +else: + from importlib_metadata import packages_distributions + +logger = logging.getLogger(__name__) + + +def find_import_names_from_package_name(package: str) -> Optional[List[str]]: + """Convert a package name to provided import names. + + (Although this function generally works with _all_ packages, we will apply + it only to the subset that is the dependencies of the current project.) + + Use importlib.metadata to look up the mapping between packages and their + provided import names, and return the import names associated with the given + package/distribution name in the current Python environment. This obviously + depends on which Python environment (e.g. virtualenv) we're calling from. + + Return None if we're unable to find any import names for the given package. + This is typically because the package is missing from the current + environment, or because it fails to declare its importable modules. + """ + ret = [ + import_name + for import_name, packages in packages_distributions().items() + if package in packages + ] + return ret or None + def compare_imports_to_dependencies( imports: List[ParsedImport], dependencies: List[DeclaredDependency] diff --git a/poetry.lock b/poetry.lock index 99be720d..49bff0e8 100644 --- a/poetry.lock +++ b/poetry.lock @@ -455,7 +455,7 @@ testing = ["flake8 (<5)", "func-timeout", "jaraco.functools", "jaraco.itertools" [metadata] lock-version = "1.1" python-versions = "^3.7.2" -content-hash = "b6162b2f7e92ef8f772cf9de22f2c9e5be07ca8b028bb8b5d1ae91fe33ab7ca6" +content-hash = "79367c7b65d588e38b7017c220a775bf37e16b524bbd9f9132bddc9adb059744" [metadata.files] argcomplete = [ diff --git a/pyproject.toml b/pyproject.toml index 0f1e86f7..372368c6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,7 +32,7 @@ fawltydeps = "fawltydeps.main:main" # These are the main dependencies for fawltydeps at runtime. # Do not add anything here that is only needed by CI/tests/linters/developers python = "^3.7.2" -importlib_metadata = {version = "^5.0.0", python = "<3.8"} +importlib_metadata = {version = "^5.0.0", python = "<3.11"} isort = "^5.10" pydantic = "^1.10.4" tomli = {version = "^2.0.1", python = "<3.11"} diff --git a/tests/test_map_dep_name_to_import_names.py b/tests/test_map_dep_name_to_import_names.py new file mode 100644 index 00000000..8dc7ac87 --- /dev/null +++ b/tests/test_map_dep_name_to_import_names.py @@ -0,0 +1,41 @@ +"""Test the mapping of dependency names to import names.""" + +import pytest + +from fawltydeps.check import find_import_names_from_package_name + +# TODO: These tests are not fully isolated, i.e. they do not control the +# virtualenv in which they run. For now, we assume that we are running in an +# environment where at least these packages are available: +# - setuptools (exposes multiple import names, including pkg_resources) +# - pip (exposes a single import name: pip) +# - isort (exposes no top_level.txt, but 'isort' import name can be inferred) + + +@pytest.mark.parametrize( + "dep_name,expect_import_names", + [ + pytest.param( + "NOT_A_PACKAGE", + None, + id="missing_package__returns_None", + ), + pytest.param( + "isort", + ["isort"], + id="package_exposes_nothing__can_still_infer_import_name", + ), + pytest.param( + "pip", + ["pip"], + id="package_exposes_one_entry__returns_entry", + ), + pytest.param( + "setuptools", + ["_distutils_hack", "pkg_resources", "setuptools"], + id="package_exposes_many_entries__returns_all_entries", + ), + ], +) +def test_find_import_names_from_package_name(dep_name, expect_import_names): + assert find_import_names_from_package_name(dep_name) == expect_import_names