Skip to content

Commit db8742c

Browse files
committed
Add dependency resolution
Signed-off-by: Tushar Goel <tushar.goel.dav@gmail.com>
1 parent 1fc0a12 commit db8742c

8 files changed

Lines changed: 475 additions & 7 deletions

File tree

src/_packagedcode/models.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -356,6 +356,12 @@ class DependentPackage(ModelMixin):
356356
"lockfiles for Composer or Cargo contain extra dependency data.",
357357
)
358358

359+
# dependencies = List(
360+
# item_type="DependentPackage",
361+
# label="dependencies",
362+
# help="A list of DependentPackage for this package.",
363+
# )
364+
359365

360366
@attr.attributes(slots=True)
361367
class Dependency(DependentPackage):

src/_packagedcode/pypi.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -528,7 +528,7 @@ def get_requirements_txt_dependencies(location, include_nested=False):
528528
is_runtime=is_runtime,
529529
is_optional=is_optional,
530530
is_resolved=req.is_pinned or False,
531-
extracted_requirement=requirement,
531+
extracted_requirement=str(req),
532532
)
533533
)
534534

src/python_inspector/resolution.py

Lines changed: 310 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,310 @@
1+
#
2+
# Copyright (c) nexB Inc. and others. All rights reserved.
3+
# ScanCode is a trademark of nexB Inc.
4+
# SPDX-License-Identifier: Apache-2.0
5+
# See http://www.apache.org/licenses/LICENSE-2.0 for the license text.
6+
# See https://github.com/nexB/scancode-toolkit for support or download.
7+
# See https://aboutcode.org for more information about nexB OSS projects.
8+
#
9+
10+
import collections
11+
import operator
12+
import os
13+
from typing import List
14+
15+
import packaging.markers
16+
import packaging.requirements
17+
import packaging.specifiers
18+
import packaging.utils
19+
import packaging.version
20+
import requests
21+
from packageurl import PackageURL
22+
from packaging.requirements import Requirement
23+
from resolvelib import AbstractProvider
24+
from resolvelib import Resolver
25+
from resolvelib.reporters import BaseReporter
26+
27+
from _packagedcode.pypi import PypiWheelHandler
28+
from python_inspector.utils_pypi import CACHE_THIRDPARTY_DIR
29+
from python_inspector.utils_pypi import PYPI_PUBLIC_REPO
30+
from python_inspector.utils_pypi import PYPI_SIMPLE_URL
31+
from python_inspector.utils_pypi import Environment
32+
from python_inspector.utils_pypi import PypiSimpleRepository
33+
from python_inspector.utils_pypi import download_wheel
34+
35+
Candidate = collections.namedtuple("Candidate", "name version extras")
36+
37+
38+
def get_response(url):
39+
resp = requests.get(url)
40+
if resp.status_code == 200:
41+
return resp.json()
42+
return None
43+
44+
45+
def is_valid_version(parsed_version, requirements, identifier, bad_versions):
46+
if (
47+
any(parsed_version not in r.specifier for r in requirements[identifier])
48+
or parsed_version in bad_versions
49+
):
50+
return False
51+
return True
52+
53+
54+
class PythonInputProvider(AbstractProvider):
55+
def __init__(self, environment, repos):
56+
self.environment = environment
57+
self.repos = repos
58+
self.versions_by_package = {}
59+
self.dependencies_by_purl = {}
60+
61+
def identify(self, requirement_or_candidate):
62+
name = packaging.utils.canonicalize_name(requirement_or_candidate.name)
63+
if requirement_or_candidate.extras:
64+
extras_str = ",".join(sorted(requirement_or_candidate.extras))
65+
return "{}[{}]".format(name, extras_str)
66+
return name
67+
68+
def get_preference(
69+
self,
70+
identifier,
71+
resolutions,
72+
candidates,
73+
information,
74+
backtrack_causes,
75+
):
76+
transitive = all(p is not None for _, p in information[identifier])
77+
return (transitive, identifier)
78+
79+
def get_versions_for_package(self, name, repo=None):
80+
"""
81+
Return a list of versions for a package.
82+
"""
83+
versions = []
84+
if repo:
85+
for version, package in repo._get_package_versions_map(name).items():
86+
wheels = package.get_supported_wheels(environment=self.environment)
87+
if list(wheels):
88+
versions.append(version)
89+
else:
90+
if name not in self.versions_by_package:
91+
api_url = f"https://pypi.org/pypi/{name}/json"
92+
resp = get_response(api_url)
93+
if not resp:
94+
self.versions_by_package[name] = []
95+
releases = resp.get("releases") or {}
96+
self.versions_by_package[name] = releases.keys() or []
97+
versions = self.versions_by_package[name]
98+
return versions
99+
100+
def get_requirements_for_package(self, purl, candidate):
101+
"""
102+
Generate requirements for a package.
103+
"""
104+
if self.repos:
105+
wheels = download_wheel(
106+
name=candidate.name,
107+
version=str(candidate.version),
108+
environment=self.environment,
109+
repos=self.repos,
110+
)
111+
for wheel in wheels:
112+
deps = list(PypiWheelHandler.parse(os.path.join(CACHE_THIRDPARTY_DIR, wheel)))
113+
assert len(deps) == 1
114+
deps = deps[0].dependencies
115+
for dep in deps:
116+
if dep.scope == "install":
117+
yield packaging.requirements.Requirement(str(dep.extracted_requirement))
118+
else:
119+
if str(purl) not in self.dependencies_by_purl:
120+
api_url = f"https://pypi.org/pypi/{purl.name}/{purl.version}/json"
121+
resp = get_response(api_url)
122+
if not resp:
123+
self.dependencies_by_purl[str(purl)] = []
124+
info = resp.get("info") or {}
125+
requires_dist = info.get("requires_dist") or []
126+
self.dependencies_by_purl[str(purl)] = requires_dist
127+
for dependency in self.dependencies_by_purl[str(purl)]:
128+
yield packaging.requirements.Requirement(dependency)
129+
130+
def get_candidates(self, all_versions, requirements, identifier, bad_versions, name, extras):
131+
"""
132+
Generate candidates for the given identifier.
133+
"""
134+
for version in all_versions:
135+
parsed_version = packaging.version.parse(version)
136+
if not is_valid_version(parsed_version, requirements, identifier, bad_versions):
137+
continue
138+
yield Candidate(name=name, version=parsed_version, extras=extras)
139+
140+
def _iter_matches(self, identifier, requirements, incompatibilities):
141+
"""
142+
Return a list of candidates for the given identifier.
143+
"""
144+
name, _, _ = identifier.partition("[")
145+
bad_versions = {c.version for c in incompatibilities[identifier]}
146+
extras = {e for r in requirements[identifier] for e in r.extras}
147+
if not self.repos:
148+
all_versions = self.get_versions_for_package(name)
149+
yield from self.get_candidates(
150+
all_versions, requirements, identifier, bad_versions, name, extras
151+
)
152+
else:
153+
for repo in self.repos:
154+
all_versions = self.get_versions_for_package(name, repo)
155+
yield from self.get_candidates(
156+
all_versions, requirements, identifier, bad_versions, name, extras
157+
)
158+
159+
def find_matches(self, identifier, requirements, incompatibilities):
160+
"""
161+
Return a sorted list of candidates for the given identifier.
162+
"""
163+
candidates = sorted(
164+
self._iter_matches(identifier, requirements, incompatibilities),
165+
key=operator.attrgetter("version"),
166+
reverse=True,
167+
)
168+
return candidates
169+
170+
def is_satisfied_by(self, requirement, candidate):
171+
"""
172+
Return True if the given requirement is satisfied by the given candidate.
173+
"""
174+
return candidate.version in requirement.specifier
175+
176+
def _iter_dependencies(self, candidate):
177+
"""
178+
Yield dependencies for the given candidate.
179+
"""
180+
name = packaging.utils.canonicalize_name(candidate.name)
181+
if candidate.extras:
182+
r = f"{name}=={candidate.version}"
183+
yield packaging.requirements.Requirement(r)
184+
purl = PackageURL(
185+
type="pypi",
186+
name=name,
187+
version=str(candidate.version),
188+
)
189+
for r in self.get_requirements_for_package(purl, candidate):
190+
if r.marker is None:
191+
yield r
192+
else:
193+
if r.marker.evaluate({"extra": ""}):
194+
yield r
195+
196+
def get_dependencies(self, candidate):
197+
"""
198+
Return a list of dependencies for the given candidate.
199+
"""
200+
return list(self._iter_dependencies(candidate))
201+
202+
203+
def get_all_srcs(mapping, graph):
204+
"""
205+
Return a list of all sources in the graph.
206+
"""
207+
for name in mapping.keys():
208+
if list(graph.iter_parents(name)) == [None]:
209+
yield name
210+
211+
212+
def dfs(mapping, graph, src):
213+
"""
214+
Return a recursive mapping of dependencies.
215+
"""
216+
children = list(graph.iter_children(src))
217+
src_purl = PackageURL(
218+
type="pypi",
219+
name=src,
220+
version=str(mapping[src].version),
221+
)
222+
if not children:
223+
return dict(package=str(src_purl), dependencies=[])
224+
225+
return dict(
226+
package=str(src_purl),
227+
dependencies=sorted([dfs(mapping, graph, c) for c in children], key=lambda d: d["package"]),
228+
)
229+
230+
231+
def _format_resolution(result):
232+
"""
233+
Return a formatted resolution.
234+
"""
235+
mapping = result.mapping
236+
graph = result.graph
237+
as_list = [
238+
str(
239+
PackageURL(
240+
type="pypi",
241+
name=name,
242+
version=str(candidate.version),
243+
)
244+
)
245+
for name, candidate in mapping.items()
246+
]
247+
248+
as_parent_children = []
249+
parents = mapping.keys()
250+
for parent in parents:
251+
parent_purl = PackageURL(
252+
type="pypi",
253+
name=parent,
254+
version=str(mapping[parent].version),
255+
)
256+
dependencies = []
257+
for dependency in graph.iter_children(parent):
258+
dep_purl = PackageURL(
259+
type="pypi",
260+
name=dependency,
261+
version=str(mapping[dependency].version),
262+
)
263+
dependencies.append(str(dep_purl))
264+
dependencies.sort()
265+
parent_children = dict(package=str(parent_purl), dependencies=dependencies)
266+
as_parent_children.append(parent_children)
267+
268+
srcs = list(get_all_srcs(mapping=mapping, graph=graph))
269+
dependencies = []
270+
for src in srcs:
271+
dependencies.append(dfs(mapping=mapping, graph=graph, src=src))
272+
273+
as_list.sort()
274+
as_parent_children.sort(key=lambda d: d["package"])
275+
dependencies.sort(key=lambda d: d["package"])
276+
as_tree = dict(dependencies=dependencies)
277+
return as_list, as_parent_children, as_tree
278+
279+
280+
def pypi_simple_repo_in_repos(repos: PypiSimpleRepository):
281+
"""
282+
Return True if simple pypi index_url is present in any of the repos
283+
"""
284+
for repo in repos:
285+
if repo.index_url == PYPI_SIMPLE_URL:
286+
return True
287+
return False
288+
289+
290+
def resolution(
291+
requirements: List[Requirement],
292+
environment: Environment,
293+
repos: List[PypiSimpleRepository] = [],
294+
return_as_parent_children: bool = True,
295+
return_as_tree: bool = False,
296+
return_as_list: bool = False,
297+
):
298+
"""
299+
Return a resolution for the given requirements.
300+
"""
301+
if repos and not pypi_simple_repo_in_repos(repos):
302+
repos.append(PYPI_PUBLIC_REPO)
303+
resolver = Resolver(PythonInputProvider(environment, repos), BaseReporter())
304+
as_list, as_parent_children, as_tree = _format_resolution(resolver.resolve(requirements))
305+
if return_as_parent_children:
306+
return as_parent_children
307+
if return_as_tree:
308+
return as_tree
309+
if return_as_list:
310+
return as_list
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
about_resource: resolution.py
2+
package_url: pkg:github.com/sarugaku/resolvelib/@a5ae68140afac49dd1a1a8e87eff9550db4a586b#tests/functional/python/test_resolvers_python.py
3+
type: github
4+
namespace: sarugaku
5+
name: resolvelib
6+
version: a5ae68140afac49dd1a1a8e87eff9550db4a586b
7+
subpath: tests/functional/python/test_resolvers_python.py
8+
9+
download_url: https://github.com/sarugaku/resolvelib/blob/a5ae68140afac49dd1a1a8e87eff9550db4a586b/tests/functional/python/test_resolvers_python.py
10+
copyright: Copyright (c) 2018, Tzu-ping Chung <uranusjr@gmail.com>
11+
license_expression: isc
12+
notes: The PythonInputProvider is copied and heavily modified from
13+
resolvelib tests/functional/python/test_resolvers_python.py
14+

0 commit comments

Comments
 (0)