From 86568cd37da0e855d5d9a3062e8171d367573e84 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 | 34 +++++++++++++++++- poetry.lock | 2 +- pyproject.toml | 2 +- tests/test_map_dep_name_to_import_names.py | 41 ++++++++++++++++++++++ 4 files changed, 76 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..0df32ea8 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,36 @@ 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. + + 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 None if not ret else ret + def compare_imports_to_dependencies( imports: List[ParsedImport], dependencies: List[DeclaredDependency] diff --git a/poetry.lock b/poetry.lock index 5cf433ab..370e4807 100644 --- a/poetry.lock +++ b/poetry.lock @@ -440,7 +440,7 @@ testing = ["flake8 (<5)", "func-timeout", "jaraco.functools", "jaraco.itertools" [metadata] lock-version = "1.1" python-versions = "^3.7.2" -content-hash = "6a7745b0960d287ae57d74728af60b1ef1d8acf9c4114b61f56199ef10949db8" +content-hash = "a19606ca1c7d6f4acaf90b6ca2353cc4e43ede8636978b27c87773f13cbf29cd" [metadata.files] argcomplete = [ diff --git a/pyproject.toml b/pyproject.toml index 8cde2b09..31fa19ed 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" tomli = {version = "^2.0.1", python = "<3.11"} typing-extensions = {version = "^4.4.0", python = "<3.8"} 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..dd53be78 --- /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 (provides multiple names, including pkg_resources) +# - pip (provides a single name: pip) +# - isort (provides 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="missing_top_level_txt__can_still_infer_import_name", + ), + pytest.param( + "pip", + ["pip"], + id="top_level_txt_w_one_entry__returns_entry", + ), + pytest.param( + "setuptools", + ["_distutils_hack", "pkg_resources", "setuptools"], + id="top_level_txt_w_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