From bfb7f5f9fa664ac55f6d8c55e472a8ac47d4c6c7 Mon Sep 17 00:00:00 2001 From: Johan Herland Date: Mon, 13 Feb 2023 14:07:49 +0100 Subject: [PATCH] check: Add function for finding import names provided by local packages This adds find_import_names_from_package_name() which will use importlib.metadata to look up a package name (e.g. a dependency name) in the current Python environment, and attempt to find the corresponding import names that this package provides. If the package does not exist in the local environment, or if importlib.metadata is not able to find any provided import names, we return `None`, and expect the caller to fall back to some other mechanism to map the package name into import name(s). --- fawltydeps/check.py | 37 ++++++++++++++++++- poetry.lock | 2 +- pyproject.toml | 2 +- tests/test_map_dep_name_to_import_names.py | 41 ++++++++++++++++++++++ 4 files changed, 79 insertions(+), 3 deletions(-) create mode 100644 tests/test_map_dep_name_to_import_names.py 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