Skip to content

Commit d93daaa

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

8 files changed

Lines changed: 525 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
@@ -812,7 +812,7 @@ def get_requires_dependencies(requires, default_scope="install"):
812812
is_runtime=True,
813813
is_optional=False,
814814
is_resolved=is_resolved,
815-
extracted_requirement=requirement,
815+
extracted_requirement=str(req),
816816
)
817817
)
818818

src/python_inspector/resolution.py

Lines changed: 307 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,307 @@
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+
"""
40+
Return a response for the given url.
41+
"""
42+
resp = requests.get(url)
43+
if resp.status_code == 200:
44+
return resp.json()
45+
return None
46+
47+
48+
def is_valid_version(parsed_version, requirements, identifier, bad_versions):
49+
"""
50+
Return True if the parsed_version is valid for the given identifier.
51+
"""
52+
if (
53+
any(parsed_version not in r.specifier for r in requirements[identifier])
54+
or parsed_version in bad_versions
55+
):
56+
return False
57+
return True
58+
59+
60+
class PythonInputProvider(AbstractProvider):
61+
def __init__(self, environment=None, repos=[]):
62+
self.environment = environment
63+
self.repos = repos
64+
self.versions_by_package = {}
65+
self.dependencies_by_purl = {}
66+
67+
def identify(self, requirement_or_candidate):
68+
name = packaging.utils.canonicalize_name(requirement_or_candidate.name)
69+
if requirement_or_candidate.extras:
70+
extras_str = ",".join(sorted(requirement_or_candidate.extras))
71+
return "{}[{}]".format(name, extras_str)
72+
return name
73+
74+
def get_preference(
75+
self,
76+
identifier,
77+
resolutions,
78+
candidates,
79+
information,
80+
backtrack_causes,
81+
):
82+
transitive = all(p is not None for _, p in information[identifier])
83+
return (transitive, identifier)
84+
85+
def get_versions_for_package(self, name, repo=None):
86+
"""
87+
Return a list of versions for a package.
88+
"""
89+
versions = []
90+
if repo and self.environment:
91+
for version, package in repo._get_package_versions_map(name).items():
92+
wheels = package.get_supported_wheels(environment=self.environment)
93+
if list(wheels):
94+
versions.append(version)
95+
else:
96+
if name not in self.versions_by_package:
97+
api_url = f"https://pypi.org/pypi/{name}/json"
98+
resp = get_response(api_url)
99+
if not resp:
100+
self.versions_by_package[name] = []
101+
releases = resp.get("releases") or {}
102+
self.versions_by_package[name] = releases.keys() or []
103+
versions = self.versions_by_package[name]
104+
return versions
105+
106+
def get_requirements_for_package(self, purl, candidate):
107+
"""
108+
Yield requirements for a package.
109+
"""
110+
if self.repos and self.environment:
111+
wheels = download_wheel(
112+
name=candidate.name,
113+
version=str(candidate.version),
114+
environment=self.environment,
115+
repos=self.repos,
116+
)
117+
for wheel in wheels:
118+
deps = list(PypiWheelHandler.parse(os.path.join(CACHE_THIRDPARTY_DIR, wheel)))
119+
assert len(deps) == 1
120+
deps = deps[0].dependencies
121+
for dep in deps:
122+
if dep.scope == "install":
123+
yield packaging.requirements.Requirement(str(dep.extracted_requirement))
124+
else:
125+
if str(purl) not in self.dependencies_by_purl:
126+
api_url = f"https://pypi.org/pypi/{purl.name}/{purl.version}/json"
127+
resp = get_response(api_url)
128+
if not resp:
129+
self.dependencies_by_purl[str(purl)] = []
130+
info = resp.get("info") or {}
131+
requires_dist = info.get("requires_dist") or []
132+
self.dependencies_by_purl[str(purl)] = requires_dist
133+
for dependency in self.dependencies_by_purl[str(purl)]:
134+
yield packaging.requirements.Requirement(dependency)
135+
136+
def get_candidates(self, all_versions, requirements, identifier, bad_versions, name, extras):
137+
"""
138+
Generate candidates for the given identifier.
139+
"""
140+
for version in all_versions:
141+
parsed_version = packaging.version.parse(version)
142+
if not is_valid_version(parsed_version, requirements, identifier, bad_versions):
143+
continue
144+
yield Candidate(name=name, version=parsed_version, extras=extras)
145+
146+
def _iter_matches(self, identifier, requirements, incompatibilities):
147+
"""
148+
Return a list of candidates for the given identifier.
149+
"""
150+
name, _, _ = identifier.partition("[")
151+
bad_versions = {c.version for c in incompatibilities[identifier]}
152+
extras = {e for r in requirements[identifier] for e in r.extras}
153+
if not self.repos:
154+
all_versions = self.get_versions_for_package(name)
155+
yield from self.get_candidates(
156+
all_versions, requirements, identifier, bad_versions, name, extras
157+
)
158+
else:
159+
for repo in self.repos:
160+
all_versions = self.get_versions_for_package(name, repo)
161+
yield from self.get_candidates(
162+
all_versions, requirements, identifier, bad_versions, name, extras
163+
)
164+
165+
def find_matches(self, identifier, requirements, incompatibilities):
166+
candidates = sorted(
167+
self._iter_matches(identifier, requirements, incompatibilities),
168+
key=operator.attrgetter("version"),
169+
reverse=True,
170+
)
171+
return candidates
172+
173+
def is_satisfied_by(self, requirement, candidate):
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+
return list(self._iter_dependencies(candidate))
198+
199+
200+
def get_all_srcs(mapping, graph):
201+
"""
202+
Return a list of all sources in the graph.
203+
"""
204+
for name in mapping.keys():
205+
if list(graph.iter_parents(name)) == [None]:
206+
yield name
207+
208+
209+
def dfs(mapping, graph, src):
210+
"""
211+
Return a recursive mapping of dependencies.
212+
"""
213+
children = list(graph.iter_children(src))
214+
src_purl = PackageURL(
215+
type="pypi",
216+
name=src,
217+
version=str(mapping[src].version),
218+
)
219+
if not children:
220+
return dict(package=str(src_purl), dependencies=[])
221+
222+
return dict(
223+
package=str(src_purl),
224+
dependencies=sorted([dfs(mapping, graph, c) for c in children], key=lambda d: d["package"]),
225+
)
226+
227+
228+
def format_resolution(result):
229+
"""
230+
Return a formatted resolution.
231+
"""
232+
mapping = result.mapping
233+
graph = result.graph
234+
as_list = [
235+
str(
236+
PackageURL(
237+
type="pypi",
238+
name=name,
239+
version=str(candidate.version),
240+
)
241+
)
242+
for name, candidate in mapping.items()
243+
]
244+
245+
as_parent_children = []
246+
parents = mapping.keys()
247+
for parent in parents:
248+
parent_purl = PackageURL(
249+
type="pypi",
250+
name=parent,
251+
version=str(mapping[parent].version),
252+
)
253+
dependencies = []
254+
for dependency in graph.iter_children(parent):
255+
dep_purl = PackageURL(
256+
type="pypi",
257+
name=dependency,
258+
version=str(mapping[dependency].version),
259+
)
260+
dependencies.append(str(dep_purl))
261+
dependencies.sort()
262+
parent_children = dict(package=str(parent_purl), dependencies=dependencies)
263+
as_parent_children.append(parent_children)
264+
265+
srcs = list(get_all_srcs(mapping=mapping, graph=graph))
266+
dependencies = []
267+
for src in srcs:
268+
dependencies.append(dfs(mapping=mapping, graph=graph, src=src))
269+
270+
as_list.sort()
271+
as_parent_children.sort(key=lambda d: d["package"])
272+
dependencies.sort(key=lambda d: d["package"])
273+
as_tree = dict(dependencies=dependencies)
274+
return as_list, as_parent_children, as_tree
275+
276+
277+
def pypi_simple_repo_in_repos(repos: PypiSimpleRepository):
278+
"""
279+
Return True if simple pypi index_url is present in any of the repos
280+
"""
281+
for repo in repos:
282+
if repo.index_url == PYPI_SIMPLE_URL:
283+
return True
284+
return False
285+
286+
287+
def resolution(
288+
requirements: List[Requirement],
289+
environment: Environment = None,
290+
repos: List[PypiSimpleRepository] = [],
291+
return_as_parent_children: bool = True,
292+
return_as_tree: bool = False,
293+
return_as_list: bool = False,
294+
):
295+
"""
296+
Return a resolution for the given requirements.
297+
"""
298+
if repos and not pypi_simple_repo_in_repos(repos):
299+
repos.append(PYPI_PUBLIC_REPO)
300+
resolver = Resolver(PythonInputProvider(environment, repos), BaseReporter())
301+
as_list, as_parent_children, as_tree = format_resolution(resolver.resolve(requirements))
302+
if return_as_parent_children:
303+
return as_parent_children
304+
if return_as_tree:
305+
return as_tree
306+
if return_as_list:
307+
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)