Skip to content

Commit 017cbc9

Browse files
andrian-sevastyanovAndrian Sevastyanov
and
Andrian Sevastyanov
authored
Fix for PIP Inspector not working when pkg_resources package is not available in newer Python versions (#1287)
* Fix for PIP Inspector not working when pkg_resources package is not available in newer Python versions * Strip whitespace from package name before attempting lookup * Uniquify requirement list per package * Use pip internal search_packages_info to look up packages when possible * Minor refactor * Update docs to reflect the updated pip inspector implementation * Update comments and minor refactor * Simplify decision around which method of package lookup to use * Update date --------- Co-authored-by: Andrian Sevastyanov <[email protected]>
1 parent df6b875 commit 017cbc9

File tree

2 files changed

+52
-33
lines changed

2 files changed

+52
-33
lines changed

documentation/src/main/markdown/packagemgrs/python.md

+4-4
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121

2222
Setuptools detector attempts to run on your project if a pyproject.toml file containing a build section with `requires = ["setuptools"]` or equivalent line is located, and a pip installation is found. (Setuptools scans can be run in both build, if a pip installation is available, and buildless mode, if not.)
2323

24-
<note type="note">Setuptools build detector should be run in a virtual environment, or environment with a clean global pip cache, where a pip install has only been performed for the project being scanned. If you are on Python v3.12 or later, run `pip install setuptools` (before executing [detect_product_short]) to ensure required tools are installed.</note>
24+
<note type="note">Setuptools build detector should be run in a virtual environment, or environment with a clean global pip cache, where a pip install has only been performed for the project being scanned.</note>
2525

2626
[detect_product_short] parses the pyproject.toml file determining if the `[build-system]` section has been configured for Setuptools Pip via the `requries= ["setuptools"]` setting. If the setting is located and pip is installed in the environment, either in the default location or specified via the `--detect.pip.path` property, the detector will execute in a virtual environment, if configured as suggested, and analyze the pyproject.toml, setup.cfg, or setup.py files for dependencies. If the detector discovers a configured pyproject.toml file but not a pip executible, it will execute in buildless mode where it will parse dependencies from the pyproject.toml, setup.cfg, or setup.py files but may not be able to specify exact package versions. If no dependencies are located in the pyproject.toml, setup.cfg, or setup.py files, or if the detector fails, the BDIO file output will not be generated in build or buildless mode. [detect_product_short] will also attempt to run additional detectors if their execution requirements are met.
2727

@@ -69,10 +69,10 @@ Pip Native Inspector requires Python and pip executables.
6969

7070
Pip Native Inspector runs the [pip-inspector.py script](https://github.com/blackducksoftware/detect/blob/master/src/main/resources/pip-inspector.py), which uses Python/pip libraries to query the pip cache for the project, which may or may not be a virtual environment, for dependency information:
7171

72-
1. pip-inspector.py queries for the project dependencies by project name which can be discovered using setup.py, or provided using the detect.pip.project.name property, using the [pkg_resources library](https://setuptools.readthedocs.io/en/latest/pkg_resources.html). If your project is installed into the pip cache, this discovers dependencies specified in setup.py.
73-
1. If one or more requirements files are found or provided, pip-inspector.py uses the Python API called parse_requirements to query each requirements file for possible additional dependencies, and uses the pkg_resources library to query for the details of each.
72+
1. pip-inspector.py queries for the project dependencies by project name which can be discovered using setup.py, or provided using the detect.pip.project.name property. If your project is installed into the pip cache, this discovers dependencies specified in setup.py.
73+
1. If one or more requirements files are found or provided, pip-inspector.py queries each requirements file for possible additional dependencies and details of each.
7474

75-
<note type="tip">pip-inspector.py uses the pkg_resources library to discover dependencies, only those packages which have been installed; using, for example, `pip install`, into the pip cache and appearing in the output of `pip list`, are included in the output. There must be a match between the package version on which your project depends and the package version installed in the pip cache. Additional details are available in the [pkg_resources library documentation](https://setuptools.readthedocs.io/en/latest/pkg_resources.html).</note>
75+
<note type="tip">Only those packages which have been installed; using, for example, `pip install`, into the pip cache and appearing in the output of `pip list`, are included in the output of pip-inspector.py. There must be a match between the package version on which your project depends and the package version installed in the pip cache.</note>
7676
<note type="note">If the packages are installed into a virtual environment for your project, you must run [detect_product_short] from within that virtual environment.</note>
7777

7878
### Recommendations for Pip Detector

src/main/resources/pip-inspector.py

+48-29
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# pylint: disable=fixme, line-too-long, import-error, no-name-in-module
22
#
3-
# Copyright (c) 2024 Black Duck Software, Inc.
3+
# Copyright (c) 2025 Black Duck Software Inc.
44
#
55
# Licensed to the Apache Software Foundation (ASF) under one
66
# or more contributor license agreements. See the NOTICE file
@@ -35,7 +35,6 @@
3535
from os import path
3636
import sys
3737
from re import split
38-
from pkg_resources import working_set, Requirement
3938

4039
import pip
4140
pip_major_version = int(pip.__version__.split(".")[0])
@@ -97,9 +96,6 @@ def resolve_project_node(project_name):
9796
def populate_dependency_tree(project_root_node, requirements_path):
9897
"""Resolves the dependencies of the user-provided requirements.txt and appends them to the dependency tree"""
9998
try:
100-
# This line is pretty much the only reason why we call the internal pip APIs anymore. We should consider if we
101-
# can do this with a more generalized approach.
102-
# --rotte DEC 2020
10399
parsed_requirements = parse_requirements(requirements_path, session=PipSession())
104100
for parsed_requirement in parsed_requirements:
105101
package_name = None
@@ -128,43 +124,66 @@ def populate_dependency_tree(project_root_node, requirements_path):
128124

129125
def recursively_resolve_dependencies(package_name, history):
130126
"""Forms a DependencyNode by recursively resolving its dependencies. Tracks history for cyclic dependencies."""
131-
package = get_package_by_name(package_name)
127+
dependency_node, child_names = get_package_by_name(package_name)
132128

133-
if package is None:
129+
if dependency_node is None:
134130
return None
135131

136-
dependency_node = DependencyNode(package.project_name, package.version)
137-
138-
if package_name.lower() not in history:
139-
history.append(package_name.lower())
140-
for package_dependency in package.requires():
141-
child_node = recursively_resolve_dependencies(package_dependency.key, history)
132+
if dependency_node.name not in history:
133+
history.append(dependency_node.name)
134+
for child_name in child_names:
135+
child_node = recursively_resolve_dependencies(child_name, history)
142136
if child_node is not None:
143137
dependency_node.children = dependency_node.children + [child_node]
144138

145139
return dependency_node
146140

141+
use_pip_internal_to_search_packages = False
142+
if sys.version_info.major > 3 or sys.version_info.major == 3 and sys.version_info.minor >= 12:
143+
# Python version 3.12 is used as a cutoff for the following reasons:
144+
# * it is the version in which pkg_resources is no longer included by default
145+
# * a test confirmed that this version of python is incompatible with PIP versions below 21.2,
146+
# which was the most recent version in which PIP search_packages_info interface changed
147+
use_pip_internal_to_search_packages = True
147148

148-
def get_package_by_name(package_name):
149-
"""Looks up a package from the pip cache"""
150-
if package_name is None:
151-
return None
149+
if use_pip_internal_to_search_packages:
150+
from pip._internal.commands.show import search_packages_info
152151

153-
package_dict = working_set.by_key
154-
try:
155-
# TODO: By using pkg_resources.Requirement.parse to get the correct key, we may not need to attempt the other
156-
# methods. Robust tests are needed to confirm.
157-
return package_dict[Requirement.parse(package_name).key]
158-
except:
159-
pass
152+
def get_package_by_name(package_name):
153+
"""Looks up a package from the pip cache using internal pip API.
154+
This should not be used when PIP is older than 21.2 since the internal API was slightly different then.
155+
"""
156+
if package_name is None:
157+
return None, None
160158

161-
name_variants = (package_name, package_name.lower(), package_name.replace('-', '_'), package_name.replace('_', '-'))
162-
for name_variant in name_variants:
163-
if name_variant in package_dict:
164-
return package_dict[name_variant]
159+
package_info = next(search_packages_info([package_name.strip()]), None)
165160

166-
return None
161+
if package_info is None:
162+
return None, None
167163

164+
return DependencyNode(package_info.name, package_info.version), package_info.requires
165+
else:
166+
from pkg_resources import working_set, Requirement
167+
168+
def get_package_by_name(package_name):
169+
"""Looks up a package from the pip cache using pkg_resouces"""
170+
if package_name is None:
171+
return None, None
172+
173+
package = None
174+
175+
package_dict = working_set.by_key
176+
try:
177+
package = package_dict[Requirement.parse(package_name).key]
178+
except:
179+
name_variants = (package_name, package_name.lower(), package_name.replace('-', '_'), package_name.replace('_', '-'))
180+
for name_variant in name_variants:
181+
if name_variant in package_dict:
182+
return package_dict[name_variant]
183+
184+
if package is None:
185+
return None, None
186+
return DependencyNode(package.project_name, package.version), [requirement.key for requirement in package.requires()]
168187

169188
class DependencyNode(object):
170189
"""Represents a python dependency in a tree graph with a name, version, and array of children DependencyNodes"""

0 commit comments

Comments
 (0)