Skip to content

Commit f8a9538

Browse files
authored
Support conan in packagedcode (#3650)
- Parse and collect version and download_url for conan package - Override the assembly to enhance the package data from conanfile.py - Test conan data file handlers Signed-off-by: Keshav Priyadarshi <[email protected]>
1 parent f70bbb7 commit f8a9538

28 files changed

+7816
-0
lines changed

CHANGELOG.rst

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,13 @@ Changes in Output Data Structure:
103103
- Upgraded spdx-tools dependency to v0.8.
104104
See https://github.com/nexB/scancode-toolkit/issues/3455
105105

106+
Support for Conan package parser:
107+
108+
- We now support the parsing of Conan manifest files, such as
109+
`conanfile.py`, as described here https://docs.conan.io/2.0/reference/conanfile.html.
110+
We also support source extraction from `conandata.yml`, as described here
111+
https://docs.conan.io/2/tutorial/creating_packages/handle_sources_in_packages.html#using-the-conandata-yml-file.
112+
106113

107114
v32.0.8 - 2023-10-11
108115
------------------------

docs/source/reference/available_package_parsers.rst

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,18 @@ parsers in scancode-toolkit during documentation builds.
177177
- ``php_composer_lock``
178178
- PHP
179179
- https://getcomposer.org/doc/01-basic-usage.md#commit-your-composer-lock-file-to-version-control
180+
* - conan external source
181+
- ``*/conandata.yml``
182+
- ``conan``
183+
- ``conan_conandata_yml``
184+
- C++
185+
- https://docs.conan.io/2/tutorial/creating_packages/handle_sources_in_packages.html#using-the-conandata-yml-file
186+
* - conan recipe
187+
- ``*/conanfile.py``
188+
- ``conan``
189+
- ``conan_conanfile_py``
190+
- C++
191+
- https://docs.conan.io/2.0/reference/conanfile.html
180192
* - Conda meta.yml manifest
181193
- ``*/meta.yaml``
182194
- ``conda``

src/packagedcode/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
from packagedcode import debian_copyright
2222
from packagedcode import distro
2323
from packagedcode import conda
24+
from packagedcode import conan
2425
from packagedcode import cocoapods
2526
from packagedcode import cran
2627
from packagedcode import freebsd
@@ -77,6 +78,9 @@
7778
conda.CondaYamlHandler,
7879
conda.CondaMetaYamlHandler,
7980

81+
conan.ConanFileHandler,
82+
conan.ConanDataHandler,
83+
8084
cran.CranDescriptionFileHandler,
8185

8286
debian_copyright.DebianCopyrightFileInPackageHandler,

src/packagedcode/conan.py

Lines changed: 297 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,297 @@
1+
# Copyright (c) nexB Inc. and others. All rights reserved.
2+
# ScanCode is a trademark of nexB Inc.
3+
# SPDX-License-Identifier: Apache-2.0
4+
# See http://www.apache.org/licenses/LICENSE-2.0 for the license text.
5+
# See https://github.com/nexB/scancode-toolkit for support or download.
6+
# See https://aboutcode.org for more information about nexB OSS projects.
7+
#
8+
9+
import ast
10+
import io
11+
import logging
12+
import os
13+
14+
import saneyaml
15+
from packageurl import PackageURL
16+
17+
from packagedcode import models
18+
19+
"""
20+
Handle conanfile recipes for conan packages
21+
https://docs.conan.io/2/reference/conanfile.html
22+
"""
23+
24+
25+
SCANCODE_DEBUG_PACKAGE = os.environ.get("SCANCODE_DEBUG_PACKAGE", False)
26+
27+
TRACE = SCANCODE_DEBUG_PACKAGE
28+
29+
30+
def logger_debug(*args):
31+
pass
32+
33+
34+
logger = logging.getLogger(__name__)
35+
36+
if TRACE:
37+
import sys
38+
39+
logging.basicConfig(stream=sys.stdout)
40+
logger.setLevel(logging.DEBUG)
41+
42+
def logger_debug(*args):
43+
return logger.debug(" ".join(isinstance(a, str) and a or repr(a) for a in args))
44+
45+
46+
class ConanFileParser(ast.NodeVisitor):
47+
def __init__(self):
48+
self.name = None
49+
self.version = None
50+
self.description = None
51+
self.author = None
52+
self.homepage_url = None
53+
self.vcs_url = None
54+
self.license = []
55+
self.keywords = []
56+
self.requires = []
57+
58+
def to_dict(self):
59+
return {
60+
"name": self.name,
61+
"version": self.version,
62+
"description": self.description,
63+
"author": self.author,
64+
"homepage_url": self.homepage_url,
65+
"vcs_url": self.vcs_url,
66+
"license": self.license,
67+
"keywords": self.keywords,
68+
"requires": self.requires,
69+
}
70+
71+
def visit_Assign(self, node):
72+
if not node.targets or not isinstance(node.targets[0], ast.Name):
73+
return
74+
if not node.value or not (
75+
isinstance(node.value, ast.Constant) or isinstance(node.value, ast.Tuple)
76+
):
77+
return
78+
79+
attribute_mapping = {
80+
"name": "name",
81+
"version": "version",
82+
"description": "description",
83+
"author": "author",
84+
"homepage": "homepage_url",
85+
"url": "vcs_url",
86+
"license": "license",
87+
"topics": "keywords",
88+
"requires": "requires",
89+
}
90+
variable_name = node.targets[0].id
91+
values = node.value
92+
93+
if variable_name in attribute_mapping:
94+
attribute_name = attribute_mapping[variable_name]
95+
if variable_name in ("topics", "requires", "license"):
96+
current_list = getattr(self, attribute_name)
97+
if isinstance(values, ast.Tuple):
98+
current_list.extend(
99+
[el.value for el in values.elts if isinstance(el, ast.Constant)]
100+
)
101+
elif isinstance(values, ast.Constant):
102+
current_list.append(values.value)
103+
setattr(self, attribute_name, current_list)
104+
else:
105+
setattr(self, attribute_name, values.value)
106+
107+
def visit_Call(self, node):
108+
if not isinstance(node.func, ast.Attribute) or not isinstance(
109+
node.func.value, ast.Name
110+
):
111+
return
112+
if node.func.value.id == "self" and node.func.attr == "requires":
113+
if node.args and isinstance(node.args[0], ast.Constant):
114+
self.requires.append(node.args[0].value)
115+
116+
117+
class ConanFileHandler(models.DatafileHandler):
118+
datasource_id = "conan_conanfile_py"
119+
path_patterns = ("*/conanfile.py",)
120+
default_package_type = "conan"
121+
default_primary_language = "C++"
122+
description = "conan recipe"
123+
documentation_url = "https://docs.conan.io/2.0/reference/conanfile.html"
124+
125+
@classmethod
126+
def _parse(cls, conan_recipe):
127+
try:
128+
tree = ast.parse(conan_recipe)
129+
recipe_class_def = next(
130+
(
131+
node
132+
for node in tree.body
133+
if isinstance(node, ast.ClassDef)
134+
and node.bases
135+
and isinstance(node.bases[0], ast.Name)
136+
and node.bases[0].id == "ConanFile"
137+
),
138+
None,
139+
)
140+
141+
parser = ConanFileParser()
142+
parser.visit(recipe_class_def)
143+
except SyntaxError as e:
144+
if TRACE:
145+
logger_debug(f"Syntax error in conan recipe: {e}")
146+
return
147+
148+
if TRACE:
149+
logger_debug(f"ConanFileHandler: parse: package: {parser.to_dict()}")
150+
151+
dependencies = get_dependencies(parser.requires)
152+
153+
return models.PackageData(
154+
datasource_id=cls.datasource_id,
155+
type=cls.default_package_type,
156+
primary_language=cls.default_primary_language,
157+
namespace=None,
158+
name=parser.name,
159+
version=parser.version,
160+
description=parser.description,
161+
homepage_url=parser.homepage_url,
162+
keywords=parser.keywords,
163+
extracted_license_statement=parser.license,
164+
dependencies=dependencies,
165+
)
166+
167+
@classmethod
168+
def parse(cls, location):
169+
with io.open(location, encoding="utf-8") as loc:
170+
conan_recipe = loc.read()
171+
172+
yield cls._parse(conan_recipe)
173+
174+
175+
class ConanDataHandler(models.DatafileHandler):
176+
datasource_id = "conan_conandata_yml"
177+
path_patterns = ("*/conandata.yml",)
178+
default_package_type = "conan"
179+
default_primary_language = "C++"
180+
description = "conan external source"
181+
documentation_url = (
182+
"https://docs.conan.io/2/tutorial/creating_packages/"
183+
"handle_sources_in_packages.html#using-the-conandata-yml-file"
184+
)
185+
186+
@classmethod
187+
def parse(cls, location):
188+
with io.open(location, encoding="utf-8") as loc:
189+
conan_data = loc.read()
190+
191+
conan_data = saneyaml.load(conan_data)
192+
sources = conan_data.get("sources", {})
193+
194+
for version, source in sources.items():
195+
sha256 = source.get("sha256", None)
196+
source_urls = source.get("url")
197+
if not source_urls:
198+
continue
199+
200+
url = None
201+
if isinstance(source_urls, str):
202+
url = source_urls
203+
elif isinstance(source_urls, list):
204+
url = source_urls[0]
205+
206+
yield models.PackageData(
207+
datasource_id=cls.datasource_id,
208+
type=cls.default_package_type,
209+
primary_language=cls.default_primary_language,
210+
namespace=None,
211+
version=version,
212+
download_url=url,
213+
sha256=sha256,
214+
)
215+
216+
@classmethod
217+
def assemble(
218+
cls, package_data, resource, codebase, package_adder=models.add_to_package
219+
):
220+
"""
221+
`conandata.yml` only contains the `version` and `download_url` use the conanfile.py
222+
to enhance the package metadata.
223+
"""
224+
siblings = resource.siblings(codebase)
225+
conanfile_package_resource = [r for r in siblings if r.name == "conanfile.py"]
226+
package_data_dict = package_data.to_dict()
227+
228+
if conanfile_package_resource:
229+
conanfile_package_resource = conanfile_package_resource[0]
230+
231+
conanfile_package_data = conanfile_package_resource.package_data
232+
if conanfile_package_data:
233+
conanfile_package_data = conanfile_package_data[0]
234+
235+
package_data_dict["name"] = conanfile_package_data.get("name")
236+
package_data_dict["description"] = conanfile_package_data.get(
237+
"description"
238+
)
239+
package_data_dict["homepage_url"] = conanfile_package_data.get(
240+
"homepage_url"
241+
)
242+
package_data_dict["keywords"] = conanfile_package_data.get("keywords")
243+
package_data_dict[
244+
"extracted_license_statement"
245+
] = conanfile_package_data.get("extracted_license_statement")
246+
247+
datafile_path = resource.path
248+
pkg_data = models.PackageData.from_dict(package_data_dict)
249+
250+
if pkg_data.purl:
251+
package = models.Package.from_package_data(
252+
package_data=pkg_data,
253+
datafile_path=datafile_path,
254+
)
255+
package.datafile_paths.append(conanfile_package_resource.path)
256+
package.datasource_ids.append(ConanFileHandler.datasource_id)
257+
258+
package.populate_license_fields()
259+
yield package
260+
261+
cls.assign_package_to_resources(
262+
package=package,
263+
resource=resource,
264+
codebase=codebase,
265+
package_adder=package_adder,
266+
)
267+
yield resource
268+
269+
270+
def is_constraint_resolved(constraint):
271+
"""
272+
Checks if a constraint is resolved and it specifies an exact version.
273+
"""
274+
range_characters = {">", "<", "[", "]", ">=", "<="}
275+
return not any(char in range_characters for char in constraint)
276+
277+
278+
def get_dependencies(requires):
279+
dependent_packages = []
280+
for req in requires:
281+
name, constraint = req.split("/", 1)
282+
is_resolved = is_constraint_resolved(constraint)
283+
version = None
284+
if is_resolved:
285+
version = constraint
286+
purl = PackageURL(type="conan", name=name, version=version)
287+
dependent_packages.append(
288+
models.DependentPackage(
289+
purl=purl.to_string(),
290+
scope="install",
291+
is_runtime=True,
292+
is_optional=False,
293+
is_resolved=is_resolved,
294+
extracted_requirement=constraint,
295+
)
296+
)
297+
return dependent_packages

0 commit comments

Comments
 (0)