Skip to content

Commit b69630e

Browse files
committed
Address review comment
Signed-off-by: Tushar Goel <tushar.goel.dav@gmail.com>
1 parent 6e7391e commit b69630e

11 files changed

Lines changed: 547 additions & 107 deletions

src/_packagedcode/pypi.py

Lines changed: 73 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
import os
1515
import re
1616
import sys
17+
from typing import NamedTuple
1718
import zipfile
1819
from configparser import ConfigParser
1920
from pathlib import Path
@@ -23,6 +24,7 @@
2324
import pip_requirements_parser
2425
import pkginfo2
2526
from commoncode import fileutils
27+
from packaging.specifiers import SpecifierSet
2628
from packageurl import PackageURL
2729
from packaging import markers
2830
from packaging.requirements import Requirement
@@ -453,8 +455,9 @@ def parse_metadata(location, datasource_id, package_type):
453455
type=package_type,
454456
primary_language='Python',
455457
name=name,
456-
version=version, #TODO: https://github.com/nexB/scancode-toolkit/issues/3014
457-
description=get_description(meta, str(location)),
458+
version=version,
459+
description=get_description( metainfo= meta, location= str(location)),
460+
#TODO: https://github.com/nexB/scancode-toolkit/issues/3014
458461
declared_license=get_declared_license(meta),
459462
keywords=get_keywords(meta),
460463
parties=get_parties(meta),
@@ -646,6 +649,14 @@ def parse(cls, location):
646649
)
647650

648651

652+
class ResolvedPurl(NamedTuple):
653+
"""
654+
A resolved PURL
655+
"""
656+
purl: PackageURL
657+
is_resolved: bool
658+
659+
649660
class BaseDependencyFileHandler(BasePypiHandler):
650661
"""
651662
Base class for a dependency files parsed with the same library
@@ -690,8 +701,35 @@ def parse(cls, location):
690701
parser.read_file(f)
691702
for section in parser.values():
692703
if section.name == 'options':
693-
reqs = list(get_requirement_from_section(section=section, sub_section="install_requires"))
694-
dependent_packages.extend(cls.parse_reqs(reqs, "install"))
704+
scope_by_sub_section = {
705+
"install_requires": "install",
706+
"tests_require": "test",
707+
"setup_requires": "setup",
708+
"python_requires": "python",
709+
}
710+
for sub_section in scope_by_sub_section:
711+
if sub_section not in section:
712+
continue
713+
scope = scope_by_sub_section[sub_section]
714+
if scope != "python":
715+
reqs = list(get_requirement_from_section(section=section, sub_section=sub_section))
716+
dependent_packages.extend(cls.parse_reqs(reqs, scope))
717+
continue
718+
python_requires = section[sub_section]
719+
purl = PackageURL(
720+
name="python",
721+
type="generic"
722+
)
723+
resolved_purl = is_purl_resolved(purl = purl, specifiers= SpecifierSet(python_requires))
724+
dependent_packages.append(models.DependentPackage(
725+
purl=str(resolved_purl.purl),
726+
scope=scope,
727+
is_runtime=True,
728+
is_optional=False,
729+
is_resolved=resolved_purl.is_resolved,
730+
extracted_requirement=f"python_requires{python_requires}",
731+
))
732+
695733
if section.name == "options.extras_require":
696734
for sub_section in section:
697735
reqs = list(get_requirement_from_section(section=section, sub_section=sub_section))
@@ -742,28 +780,39 @@ def parse_reqs(cls, reqs, scope):
742780
"""
743781
dependent_packages = []
744782
for req in reqs:
745-
is_resolved = False
746783
req_parsed = packaging.requirements.Requirement(str(req))
747784
name = canonicalize_name(req_parsed.name)
748785
purl = PackageURL(type="pypi", name=name)
749786
specifiers = req_parsed.specifier._specs
750-
if len(specifiers) == 1:
751-
specifier = list(specifiers)[0]
752-
if specifier.operator in ('==', '==='):
753-
is_resolved = True
754-
purl = purl._replace(version=specifier.version)
787+
resolved_purl = is_purl_resolved(purl = purl, specifiers= specifiers)
755788
dependent_packages.append(
756789
models.DependentPackage(
757-
purl=str(purl),
790+
purl=str(resolved_purl.purl),
758791
scope=scope,
759792
is_runtime=True,
760793
is_optional=False,
761-
is_resolved=is_resolved,
794+
is_resolved=resolved_purl.is_resolved,
762795
extracted_requirement=req
763796
)
764797
)
765798
return dependent_packages
766799

800+
801+
def is_purl_resolved(purl: PackageURL, specifiers: SpecifierSet):
802+
"""
803+
Check if the purl is resolved
804+
"""
805+
is_resolved = False
806+
if len(specifiers) == 1:
807+
specifier = list(specifiers)[0]
808+
if specifier.operator in ('==', '==='):
809+
is_resolved = True
810+
purl = purl._replace(version=specifier.version)
811+
return ResolvedPurl(
812+
purl=purl,
813+
is_resolved=is_resolved,
814+
)
815+
767816
class PipfileHandler(BaseDependencyFileHandler):
768817
datasource_id = 'pipfile'
769818
path_patterns = ('*Pipfile',)
@@ -1934,20 +1983,19 @@ def get_requirement_from_section(section, sub_section):
19341983
"""
19351984
content = section.get(sub_section) or ""
19361985
for req in content.splitlines():
1937-
if req:
1938-
#pytest-mypy >= 0.9.1; \
1939-
req = req.replace("; \\", "")
1940-
# pip>=19.1 # For proper file:// URLs support.
1941-
if "#" in req:
1942-
req , _ = req.rsplit("#")
1986+
if not req:
1987+
continue
1988+
#pytest-mypy >= 0.9.1; \
1989+
req = req.replace("; \\", "")
1990+
# pip>=19.1 # For proper file:// URLs support.
1991+
if "#" in req:
1992+
req , _ = req.rsplit("#")
1993+
try:
1994+
Requirement(req)
1995+
yield req
1996+
except:
19431997
#pure-eval; black; tox
19441998
req_split_by_semi_colon = req.split(";")
19451999
req_split_by_semi_colon = [req.strip() for req in req_split_by_semi_colon if req]
1946-
if len(req_split_by_semi_colon) >= 2 and not(req_split_by_semi_colon[1].startswith("python_version") # pip>=19.1 ;python_version > 3.7
1947-
or req_split_by_semi_colon[1].startswith("sys_platform") # pip>=19.1 ;sys_platform = "Windows"
1948-
or req_split_by_semi_colon[1].startswith("platform_system") # pip>=19.1 ;platform_system = "Windows"
1949-
or req_split_by_semi_colon[1].startswith("platform_python_implementation")):#pytest-black>=0.3.7; platform_python_implementation != "PyPy"
1950-
for temp_req in req_split_by_semi_colon:
1951-
yield temp_req
1952-
else:
2000+
for req in req_split_by_semi_colon:
19532001
yield req

src/python_inspector/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,3 +6,5 @@
66
# See https://github.com/nexB/scancode-toolkit for support or download.
77
# See https://aboutcode.org for more information about nexB OSS projects.
88
#
9+
10+
DEFAULT_PYTHON_VERSION = "3.8"

src/python_inspector/resolution.py

Lines changed: 47 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,11 @@
77
# See https://aboutcode.org for more information about nexB OSS projects.
88
#
99

10-
import collections
1110
import operator
1211
import os
1312
import tarfile
1413
from typing import List
14+
from typing import NamedTuple
1515
from typing import Sequence
1616
from zipfile import ZipFile
1717

@@ -32,7 +32,15 @@
3232
from _packagedcode.pypi import SetupCfgHandler
3333
from python_inspector import utils_pypi
3434

35-
Candidate = collections.namedtuple("Candidate", "name version extras")
35+
36+
class Candidate(NamedTuple):
37+
"""
38+
A candidate is a package that can be installed.
39+
"""
40+
41+
name: str
42+
version: str
43+
extras: str
3644

3745

3846
def get_response(url):
@@ -68,9 +76,16 @@ def get_python_version_from_env_tag(python_version: str):
6876
return python_version
6977

7078

71-
def get_sdist_file(repos, candidate, python_version):
79+
def fetch_and_extract_sdist(repos, candidate, python_version):
7280
"""
73-
Return the sdist file for a candidate.
81+
Fetch and extract the source distribution (sdist) for the ``candidate`` Candidate
82+
from the `repos` list of PyPiRepository
83+
and a required ``python_version`` Python version.
84+
Return the directory location string where the sdist has been extracted.
85+
Return None if the sdist was not fetched either
86+
because does not exist in any of the ``repos`` or it does not work with
87+
the required ``python_version``.
88+
Raise an Exception if extraction fails.
7489
"""
7590
sdist = utils_pypi.download_sdist(
7691
name=candidate.name,
@@ -82,25 +97,32 @@ def get_sdist_file(repos, candidate, python_version):
8297
if not sdist:
8398
return
8499

85-
sdist_file = None
86-
87100
if sdist.endswith(".tar.gz"):
88101
sdist_file = sdist.rstrip(".tar.gz")
89102
with tarfile.open(os.path.join(utils_pypi.CACHE_THIRDPARTY_DIR, sdist)) as file:
90103
file.extractall(
91104
os.path.join(utils_pypi.CACHE_THIRDPARTY_DIR, "extracted_sdists", sdist_file)
92105
)
93-
if sdist.endswith(".zip"):
106+
elif sdist.endswith(".zip"):
94107
sdist_file = sdist.rstrip(".zip")
95108
with ZipFile(os.path.join(utils_pypi.CACHE_THIRDPARTY_DIR, sdist)) as zip:
96109
zip.extractall(
97110
os.path.join(utils_pypi.CACHE_THIRDPARTY_DIR, "extracted_sdists", sdist_file)
98111
)
99112

100-
if not sdist_file:
113+
else:
101114
raise Exception(f"Unable to extract sdist {sdist}")
102115

103-
return sdist_file
116+
return os.path.join(utils_pypi.CACHE_THIRDPARTY_DIR, "extracted_sdists", sdist_file, sdist_file)
117+
118+
119+
def remove_extras(identifier):
120+
"""
121+
Return the identifier without extras.
122+
>>> assert remove_extras("foo[bar]") == "foo"
123+
"""
124+
name, _, _ = identifier.partition("[")
125+
return name
104126

105127

106128
class PythonInputProvider(AbstractProvider):
@@ -212,32 +234,21 @@ def get_requirements_for_package_from_pypi_simple(self, candidate):
212234
if dep.scope == "install":
213235
yield packaging.requirements.Requirement(str(dep.extracted_requirement))
214236

215-
sdist_file = get_sdist_file(
237+
sdist_file = fetch_and_extract_sdist(
216238
repos=self.repos, candidate=candidate, python_version=python_version
217239
)
218240

219241
if sdist_file:
220242
setup_py_path = os.path.join(
221-
utils_pypi.CACHE_THIRDPARTY_DIR,
222-
"extracted_sdists",
223-
sdist_file,
224243
sdist_file,
225244
"setup.py",
226245
)
227246
setup_cfg_path = os.path.join(
228-
utils_pypi.CACHE_THIRDPARTY_DIR,
229-
"extracted_sdists",
230-
sdist_file,
231247
sdist_file,
232248
"setup.cfg",
233249
)
234-
pkg_info_path = os.path.join(
235-
utils_pypi.CACHE_THIRDPARTY_DIR, "extracted_sdists", sdist_file
236-
)
250+
pkg_info_path = os.path.dirname(sdist_file)
237251
requirement_path = os.path.join(
238-
utils_pypi.CACHE_THIRDPARTY_DIR,
239-
"extracted_sdists",
240-
sdist_file,
241252
sdist_file,
242253
"requirements.txt",
243254
)
@@ -254,30 +265,31 @@ def get_requirements_for_package_from_pypi_simple(self, candidate):
254265
continue
255266

256267
deps = list(handler.parse(path))
257-
assert len(deps) == 1, handler
258-
if not deps:
259-
continue
268+
assert len(deps) == 1
269+
260270
dependencies = deps[0].dependencies
261271
for dep in dependencies:
262272
if not dep.purl:
263273
continue
264274

275+
if dep.scope != "install":
276+
continue
277+
265278
dep_purl = PackageURL.from_string(dep.purl)
266-
if not (
267-
dep.scope == "install"
268-
and (
269-
not (dep.is_resolved)
270-
or (dep.is_resolved and dep_purl.name not in self.resolved_requirements)
271-
)
272-
):
279+
280+
if self.is_dep_resolved_and_in_resolved_requirements(dep, dep_purl):
273281
continue
282+
274283
if dep.is_resolved:
275-
self.resolved_requirements = (*self.resolved_requirements, dep_purl)
284+
self.resolved_requirements.append(dep_purl)
276285
# skip the requirement starting with -- like
277286
# --editable, --requirement
278287
if not dep.extracted_requirement.startswith("--"):
279288
yield packaging.requirements.Requirement(str(dep.extracted_requirement))
280289

290+
def is_dep_resolved_and_in_resolved_requirements(self, dep, dep_purl):
291+
return dep.is_resolved and dep_purl.name in self.resolved_requirements
292+
281293
def get_requirements_for_package_from_pypi_json_api(self, purl):
282294
"""
283295
Return requirements for a package from the PyPI.org JSON API
@@ -308,7 +320,7 @@ def _iter_matches(self, identifier, requirements, incompatibilities):
308320
"""
309321
Yield candidates for the given identifier, requirements and incompatibilities
310322
"""
311-
name, _, _ = identifier.partition("[")
323+
name = remove_extras(identifier)
312324
bad_versions = {c.version for c in incompatibilities[identifier]}
313325
extras = {e for r in requirements[identifier] for e in r.extras}
314326
if not self.repos:
@@ -460,7 +472,7 @@ def get_resolved_dependencies(
460472
]
461473
resolver = Resolver(
462474
provider=PythonInputProvider(
463-
environment=environment, repos=repos, resolved_requirements=tuple(resolved_requirements)
475+
environment=environment, repos=repos, resolved_requirements=resolved_requirements
464476
),
465477
reporter=BaseReporter(),
466478
)

src/python_inspector/resolve_cli.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -168,7 +168,7 @@ def resolve_dependencies(
168168
direct_dependencies = []
169169

170170
if PYPI_SIMPLE_URL not in index_urls:
171-
index_urls = (*index_urls, PYPI_SIMPLE_URL)
171+
index_urls = tuple([PYPI_SIMPLE_URL]) + tuple(index_urls)
172172

173173
for req_file in requirement_files:
174174
deps = dependencies.get_dependencies_from_requirements(requirements_file=req_file)

0 commit comments

Comments
 (0)