Skip to content

Commit 090db29

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

11 files changed

Lines changed: 546 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: 46 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,14 @@
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+
name: str
41+
version: str
42+
extras: str
3643

3744

3845
def get_response(url):
@@ -68,9 +75,16 @@ def get_python_version_from_env_tag(python_version: str):
6875
return python_version
6976

7077

71-
def get_sdist_file(repos, candidate, python_version):
78+
def fetch_and_extract_sdist(repos, candidate, python_version):
7279
"""
73-
Return the sdist file for a candidate.
80+
Fetch and extract the source distribution (sdist) for the ``candidate`` Candidate
81+
from the `repos` list of PyPiRepository
82+
and a required ``python_version`` Python version.
83+
Return the directory location string where the sdist has been extracted.
84+
Return None if the sdist was not fetched either
85+
because does not exist in any of the ``repos`` or it does not work with
86+
the required ``python_version``.
87+
Raise an Exception if extraction fails.
7488
"""
7589
sdist = utils_pypi.download_sdist(
7690
name=candidate.name,
@@ -82,25 +96,32 @@ def get_sdist_file(repos, candidate, python_version):
8296
if not sdist:
8397
return
8498

85-
sdist_file = None
86-
8799
if sdist.endswith(".tar.gz"):
88100
sdist_file = sdist.rstrip(".tar.gz")
89101
with tarfile.open(os.path.join(utils_pypi.CACHE_THIRDPARTY_DIR, sdist)) as file:
90102
file.extractall(
91103
os.path.join(utils_pypi.CACHE_THIRDPARTY_DIR, "extracted_sdists", sdist_file)
92104
)
93-
if sdist.endswith(".zip"):
105+
elif sdist.endswith(".zip"):
94106
sdist_file = sdist.rstrip(".zip")
95107
with ZipFile(os.path.join(utils_pypi.CACHE_THIRDPARTY_DIR, sdist)) as zip:
96108
zip.extractall(
97109
os.path.join(utils_pypi.CACHE_THIRDPARTY_DIR, "extracted_sdists", sdist_file)
98110
)
99111

100-
if not sdist_file:
112+
else:
101113
raise Exception(f"Unable to extract sdist {sdist}")
102114

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

105126

106127
class PythonInputProvider(AbstractProvider):
@@ -212,32 +233,21 @@ def get_requirements_for_package_from_pypi_simple(self, candidate):
212233
if dep.scope == "install":
213234
yield packaging.requirements.Requirement(str(dep.extracted_requirement))
214235

215-
sdist_file = get_sdist_file(
236+
sdist_file = fetch_and_extract_sdist(
216237
repos=self.repos, candidate=candidate, python_version=python_version
217238
)
218239

219240
if sdist_file:
220241
setup_py_path = os.path.join(
221-
utils_pypi.CACHE_THIRDPARTY_DIR,
222-
"extracted_sdists",
223-
sdist_file,
224242
sdist_file,
225243
"setup.py",
226244
)
227245
setup_cfg_path = os.path.join(
228-
utils_pypi.CACHE_THIRDPARTY_DIR,
229-
"extracted_sdists",
230-
sdist_file,
231246
sdist_file,
232247
"setup.cfg",
233248
)
234-
pkg_info_path = os.path.join(
235-
utils_pypi.CACHE_THIRDPARTY_DIR, "extracted_sdists", sdist_file
236-
)
249+
pkg_info_path = os.path.dirname(sdist_file)
237250
requirement_path = os.path.join(
238-
utils_pypi.CACHE_THIRDPARTY_DIR,
239-
"extracted_sdists",
240-
sdist_file,
241251
sdist_file,
242252
"requirements.txt",
243253
)
@@ -254,30 +264,31 @@ def get_requirements_for_package_from_pypi_simple(self, candidate):
254264
continue
255265

256266
deps = list(handler.parse(path))
257-
assert len(deps) == 1, handler
258-
if not deps:
259-
continue
267+
assert len(deps) == 1
268+
260269
dependencies = deps[0].dependencies
261270
for dep in dependencies:
262271
if not dep.purl:
263272
continue
264273

274+
if dep.scope != "install":
275+
continue
276+
265277
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-
):
278+
279+
if self.is_dep_resolved_and_in_resolved_requirements(dep, dep_purl):
273280
continue
281+
274282
if dep.is_resolved:
275-
self.resolved_requirements = (*self.resolved_requirements, dep_purl)
283+
self.resolved_requirements.append(dep_purl)
276284
# skip the requirement starting with -- like
277285
# --editable, --requirement
278286
if not dep.extracted_requirement.startswith("--"):
279287
yield packaging.requirements.Requirement(str(dep.extracted_requirement))
280288

289+
def is_dep_resolved_and_in_resolved_requirements(self, dep, dep_purl):
290+
return dep.is_resolved and dep_purl.name in self.resolved_requirements
291+
281292
def get_requirements_for_package_from_pypi_json_api(self, purl):
282293
"""
283294
Return requirements for a package from the PyPI.org JSON API
@@ -308,7 +319,7 @@ def _iter_matches(self, identifier, requirements, incompatibilities):
308319
"""
309320
Yield candidates for the given identifier, requirements and incompatibilities
310321
"""
311-
name, _, _ = identifier.partition("[")
322+
name = remove_extras(identifier)
312323
bad_versions = {c.version for c in incompatibilities[identifier]}
313324
extras = {e for r in requirements[identifier] for e in r.extras}
314325
if not self.repos:
@@ -460,7 +471,7 @@ def get_resolved_dependencies(
460471
]
461472
resolver = Resolver(
462473
provider=PythonInputProvider(
463-
environment=environment, repos=repos, resolved_requirements=tuple(resolved_requirements)
474+
environment=environment, repos=repos, resolved_requirements=resolved_requirements
464475
),
465476
reporter=BaseReporter(),
466477
)

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)