summaryrefslogtreecommitdiffstats
path: root/scripts/lib
diff options
context:
space:
mode:
authorJulien Stephan <jstephan@baylibre.com>2023-10-25 17:46:57 +0200
committerRichard Purdie <richard.purdie@linuxfoundation.org>2023-10-27 08:28:38 +0100
commite64e92f2de4b182d087d7d88326aa7d06f59776e (patch)
tree89dcdf4bfa4d362ed7981612af45f4c872c79293 /scripts/lib
parentbe129bd0bc5ed4e637dbb313c8cf96bad660438c (diff)
downloadpoky-e64e92f2de4b182d087d7d88326aa7d06f59776e.tar.gz
recipetool/create_buildsys_python: refactor code for futur PEP517 addition
In order to prepare the support for pyproject.toml (PEP517 [1]) enabled projects, refactor the code and move setup.py specific code into a specific class in order to allow sharing the PythonRecipeHandler class No functionnal changes expected [1]: https://peps.python.org/pep-0517/#source-tree (From OE-Core rev: 2281e93347da4129062cfb40710df03c87c63168) Signed-off-by: Julien Stephan <jstephan@baylibre.com> Signed-off-by: Luca Ceresoli <luca.ceresoli@bootlin.com> Signed-off-by: Richard Purdie <richard.purdie@linuxfoundation.org>
Diffstat (limited to 'scripts/lib')
-rw-r--r--scripts/lib/recipetool/create_buildsys_python.py720
1 files changed, 371 insertions, 349 deletions
diff --git a/scripts/lib/recipetool/create_buildsys_python.py b/scripts/lib/recipetool/create_buildsys_python.py
index 502e1dfbc3..69f6f5ca51 100644
--- a/scripts/lib/recipetool/create_buildsys_python.py
+++ b/scripts/lib/recipetool/create_buildsys_python.py
@@ -37,63 +37,8 @@ class PythonRecipeHandler(RecipeHandler):
37 assume_provided = ['builtins', 'os.path'] 37 assume_provided = ['builtins', 'os.path']
38 # Assumes that the host python3 builtin_module_names is sane for target too 38 # Assumes that the host python3 builtin_module_names is sane for target too
39 assume_provided = assume_provided + list(sys.builtin_module_names) 39 assume_provided = assume_provided + list(sys.builtin_module_names)
40 excluded_fields = []
40 41
41 bbvar_map = {
42 'Name': 'PN',
43 'Version': 'PV',
44 'Home-page': 'HOMEPAGE',
45 'Summary': 'SUMMARY',
46 'Description': 'DESCRIPTION',
47 'License': 'LICENSE',
48 'Requires': 'RDEPENDS:${PN}',
49 'Provides': 'RPROVIDES:${PN}',
50 'Obsoletes': 'RREPLACES:${PN}',
51 }
52 # PN/PV are already set by recipetool core & desc can be extremely long
53 excluded_fields = [
54 'Description',
55 ]
56 setup_parse_map = {
57 'Url': 'Home-page',
58 'Classifiers': 'Classifier',
59 'Description': 'Summary',
60 }
61 setuparg_map = {
62 'Home-page': 'url',
63 'Classifier': 'classifiers',
64 'Summary': 'description',
65 'Description': 'long-description',
66 }
67 # Values which are lists, used by the setup.py argument based metadata
68 # extraction method, to determine how to process the setup.py output.
69 setuparg_list_fields = [
70 'Classifier',
71 'Requires',
72 'Provides',
73 'Obsoletes',
74 'Platform',
75 'Supported-Platform',
76 ]
77 setuparg_multi_line_values = ['Description']
78 replacements = [
79 ('License', r' +$', ''),
80 ('License', r'^ +', ''),
81 ('License', r' ', '-'),
82 ('License', r'^GNU-', ''),
83 ('License', r'-[Ll]icen[cs]e(,?-[Vv]ersion)?', ''),
84 ('License', r'^UNKNOWN$', ''),
85
86 # Remove currently unhandled version numbers from these variables
87 ('Requires', r' *\([^)]*\)', ''),
88 ('Provides', r' *\([^)]*\)', ''),
89 ('Obsoletes', r' *\([^)]*\)', ''),
90 ('Install-requires', r'^([^><= ]+).*', r'\1'),
91 ('Extras-require', r'^([^><= ]+).*', r'\1'),
92 ('Tests-require', r'^([^><= ]+).*', r'\1'),
93
94 # Remove unhandled dependency on particular features (e.g. foo[PDF])
95 ('Install-requires', r'\[[^\]]+\]$', ''),
96 ]
97 42
98 classifier_license_map = { 43 classifier_license_map = {
99 'License :: OSI Approved :: Academic Free License (AFL)': 'AFL', 44 'License :: OSI Approved :: Academic Free License (AFL)': 'AFL',
@@ -166,122 +111,34 @@ class PythonRecipeHandler(RecipeHandler):
166 def __init__(self): 111 def __init__(self):
167 pass 112 pass
168 113
169 def process(self, srctree, classes, lines_before, lines_after, handled, extravalues): 114 def handle_classifier_license(self, classifiers, existing_licenses=""):
170 if 'buildsystem' in handled: 115
171 return False 116 licenses = []
172 117 for classifier in classifiers:
173 # Check for non-zero size setup.py files 118 if classifier in self.classifier_license_map:
174 setupfiles = RecipeHandler.checkfiles(srctree, ['setup.py']) 119 license = self.classifier_license_map[classifier]
175 for fn in setupfiles: 120 if license == 'Apache' and 'Apache-2.0' in existing_licenses:
176 if os.path.getsize(fn): 121 license = 'Apache-2.0'
177 break 122 elif license == 'GPL':
178 else: 123 if 'GPL-2.0' in existing_licenses or 'GPLv2' in existing_licenses:
179 return False 124 license = 'GPL-2.0'
180 125 elif 'GPL-3.0' in existing_licenses or 'GPLv3' in existing_licenses:
181 # setup.py is always parsed to get at certain required information, such as 126 license = 'GPL-3.0'
182 # distutils vs setuptools 127 elif license == 'LGPL':
183 # 128 if 'LGPL-2.1' in existing_licenses or 'LGPLv2.1' in existing_licenses:
184 # If egg info is available, we use it for both its PKG-INFO metadata 129 license = 'LGPL-2.1'
185 # and for its requires.txt for install_requires. 130 elif 'LGPL-2.0' in existing_licenses or 'LGPLv2' in existing_licenses:
186 # If PKG-INFO is available but no egg info is, we use that for metadata in preference to 131 license = 'LGPL-2.0'
187 # the parsed setup.py, but use the install_requires info from the 132 elif 'LGPL-3.0' in existing_licenses or 'LGPLv3' in existing_licenses:
188 # parsed setup.py. 133 license = 'LGPL-3.0'
189 134 licenses.append(license)
190 setupscript = os.path.join(srctree, 'setup.py') 135
191 try: 136 if licenses:
192 setup_info, uses_setuptools, setup_non_literals, extensions = self.parse_setup_py(setupscript) 137 return ' & '.join(licenses)
193 except Exception: 138
194 logger.exception("Failed to parse setup.py") 139 return None
195 setup_info, uses_setuptools, setup_non_literals, extensions = {}, True, [], [] 140
196 141 def map_info_to_bbvar(self, info, extravalues):
197 egginfo = glob.glob(os.path.join(srctree, '*.egg-info'))
198 if egginfo:
199 info = self.get_pkginfo(os.path.join(egginfo[0], 'PKG-INFO'))
200 requires_txt = os.path.join(egginfo[0], 'requires.txt')
201 if os.path.exists(requires_txt):
202 with codecs.open(requires_txt) as f:
203 inst_req = []
204 extras_req = collections.defaultdict(list)
205 current_feature = None
206 for line in f.readlines():
207 line = line.rstrip()
208 if not line:
209 continue
210
211 if line.startswith('['):
212 # PACKAGECONFIG must not contain expressions or whitespace
213 line = line.replace(" ", "")
214 line = line.replace(':', "")
215 line = line.replace('.', "-dot-")
216 line = line.replace('"', "")
217 line = line.replace('<', "-smaller-")
218 line = line.replace('>', "-bigger-")
219 line = line.replace('_', "-")
220 line = line.replace('(', "")
221 line = line.replace(')', "")
222 line = line.replace('!', "-not-")
223 line = line.replace('=', "-equals-")
224 current_feature = line[1:-1]
225 elif current_feature:
226 extras_req[current_feature].append(line)
227 else:
228 inst_req.append(line)
229 info['Install-requires'] = inst_req
230 info['Extras-require'] = extras_req
231 elif RecipeHandler.checkfiles(srctree, ['PKG-INFO']):
232 info = self.get_pkginfo(os.path.join(srctree, 'PKG-INFO'))
233
234 if setup_info:
235 if 'Install-requires' in setup_info:
236 info['Install-requires'] = setup_info['Install-requires']
237 if 'Extras-require' in setup_info:
238 info['Extras-require'] = setup_info['Extras-require']
239 else:
240 if setup_info:
241 info = setup_info
242 else:
243 info = self.get_setup_args_info(setupscript)
244
245 # Grab the license value before applying replacements
246 license_str = info.get('License', '').strip()
247
248 self.apply_info_replacements(info)
249
250 if uses_setuptools:
251 classes.append('setuptools3')
252 else:
253 classes.append('distutils3')
254
255 if license_str:
256 for i, line in enumerate(lines_before):
257 if line.startswith('##LICENSE_PLACEHOLDER##'):
258 lines_before.insert(i, '# NOTE: License in setup.py/PKGINFO is: %s' % license_str)
259 break
260
261 if 'Classifier' in info:
262 existing_licenses = info.get('License', '')
263 licenses = []
264 for classifier in info['Classifier']:
265 if classifier in self.classifier_license_map:
266 license = self.classifier_license_map[classifier]
267 if license == 'Apache' and 'Apache-2.0' in existing_licenses:
268 license = 'Apache-2.0'
269 elif license == 'GPL':
270 if 'GPL-2.0' in existing_licenses or 'GPLv2' in existing_licenses:
271 license = 'GPL-2.0'
272 elif 'GPL-3.0' in existing_licenses or 'GPLv3' in existing_licenses:
273 license = 'GPL-3.0'
274 elif license == 'LGPL':
275 if 'LGPL-2.1' in existing_licenses or 'LGPLv2.1' in existing_licenses:
276 license = 'LGPL-2.1'
277 elif 'LGPL-2.0' in existing_licenses or 'LGPLv2' in existing_licenses:
278 license = 'LGPL-2.0'
279 elif 'LGPL-3.0' in existing_licenses or 'LGPLv3' in existing_licenses:
280 license = 'LGPL-3.0'
281 licenses.append(license)
282
283 if licenses:
284 info['License'] = ' & '.join(licenses)
285 142
286 # Map PKG-INFO & setup.py fields to bitbake variables 143 # Map PKG-INFO & setup.py fields to bitbake variables
287 for field, values in info.items(): 144 for field, values in info.items():
@@ -305,71 +162,206 @@ class PythonRecipeHandler(RecipeHandler):
305 if bbvar not in extravalues and value: 162 if bbvar not in extravalues and value:
306 extravalues[bbvar] = value 163 extravalues[bbvar] = value
307 164
308 mapped_deps, unmapped_deps = self.scan_setup_python_deps(srctree, setup_info, setup_non_literals) 165 def apply_info_replacements(self, info):
166 if not self.replacements:
167 return
309 168
310 extras_req = set() 169 for variable, search, replace in self.replacements:
311 if 'Extras-require' in info: 170 if variable not in info:
312 extras_req = info['Extras-require'] 171 continue
313 if extras_req:
314 lines_after.append('# The following configs & dependencies are from setuptools extras_require.')
315 lines_after.append('# These dependencies are optional, hence can be controlled via PACKAGECONFIG.')
316 lines_after.append('# The upstream names may not correspond exactly to bitbake package names.')
317 lines_after.append('# The configs are might not correct, since PACKAGECONFIG does not support expressions as may used in requires.txt - they are just replaced by text.')
318 lines_after.append('#')
319 lines_after.append('# Uncomment this line to enable all the optional features.')
320 lines_after.append('#PACKAGECONFIG ?= "{}"'.format(' '.join(k.lower() for k in extras_req)))
321 for feature, feature_reqs in extras_req.items():
322 unmapped_deps.difference_update(feature_reqs)
323 172
324 feature_req_deps = ('python3-' + r.replace('.', '-').lower() for r in sorted(feature_reqs)) 173 def replace_value(search, replace, value):
325 lines_after.append('PACKAGECONFIG[{}] = ",,,{}"'.format(feature.lower(), ' '.join(feature_req_deps))) 174 if replace is None:
175 if re.search(search, value):
176 return None
177 else:
178 new_value = re.sub(search, replace, value)
179 if value != new_value:
180 return new_value
181 return value
326 182
327 inst_reqs = set() 183 value = info[variable]
328 if 'Install-requires' in info: 184 if isinstance(value, str):
329 if extras_req: 185 new_value = replace_value(search, replace, value)
330 lines_after.append('') 186 if new_value is None:
331 inst_reqs = info['Install-requires'] 187 del info[variable]
332 if inst_reqs: 188 elif new_value != value:
333 unmapped_deps.difference_update(inst_reqs) 189 info[variable] = new_value
190 elif hasattr(value, 'items'):
191 for dkey, dvalue in list(value.items()):
192 new_list = []
193 for pos, a_value in enumerate(dvalue):
194 new_value = replace_value(search, replace, a_value)
195 if new_value is not None and new_value != value:
196 new_list.append(new_value)
334 197
335 inst_req_deps = ('python3-' + r.replace('.', '-').lower() for r in sorted(inst_reqs)) 198 if value != new_list:
336 lines_after.append('# WARNING: the following rdepends are from setuptools install_requires. These') 199 value[dkey] = new_list
337 lines_after.append('# upstream names may not correspond exactly to bitbake package names.') 200 else:
338 lines_after.append('RDEPENDS:${{PN}} += "{}"'.format(' '.join(inst_req_deps))) 201 new_list = []
202 for pos, a_value in enumerate(value):
203 new_value = replace_value(search, replace, a_value)
204 if new_value is not None and new_value != value:
205 new_list.append(new_value)
339 206
340 if mapped_deps: 207 if value != new_list:
341 name = info.get('Name') 208 info[variable] = new_list
342 if name and name[0] in mapped_deps:
343 # Attempt to avoid self-reference
344 mapped_deps.remove(name[0])
345 mapped_deps -= set(self.excluded_pkgdeps)
346 if inst_reqs or extras_req:
347 lines_after.append('')
348 lines_after.append('# WARNING: the following rdepends are determined through basic analysis of the')
349 lines_after.append('# python sources, and might not be 100% accurate.')
350 lines_after.append('RDEPENDS:${{PN}} += "{}"'.format(' '.join(sorted(mapped_deps))))
351 209
352 unmapped_deps -= set(extensions)
353 unmapped_deps -= set(self.assume_provided)
354 if unmapped_deps:
355 if mapped_deps:
356 lines_after.append('')
357 lines_after.append('# WARNING: We were unable to map the following python package/module')
358 lines_after.append('# dependencies to the bitbake packages which include them:')
359 lines_after.extend('# {}'.format(d) for d in sorted(unmapped_deps))
360 210
361 handled.append('buildsystem') 211 def scan_python_dependencies(self, paths):
212 deps = set()
213 try:
214 dep_output = self.run_command(['pythondeps', '-d'] + paths)
215 except (OSError, subprocess.CalledProcessError):
216 pass
217 else:
218 for line in dep_output.splitlines():
219 line = line.rstrip()
220 dep, filename = line.split('\t', 1)
221 if filename.endswith('/setup.py'):
222 continue
223 deps.add(dep)
362 224
363 def get_pkginfo(self, pkginfo_fn): 225 try:
364 msg = email.message_from_file(open(pkginfo_fn, 'r')) 226 provides_output = self.run_command(['pythondeps', '-p'] + paths)
365 msginfo = {} 227 except (OSError, subprocess.CalledProcessError):
366 for field in msg.keys(): 228 pass
367 values = msg.get_all(field) 229 else:
368 if len(values) == 1: 230 provides_lines = (l.rstrip() for l in provides_output.splitlines())
369 msginfo[field] = values[0] 231 provides = set(l for l in provides_lines if l and l != 'setup')
370 else: 232 deps -= provides
371 msginfo[field] = values 233
372 return msginfo 234 return deps
235
236 def parse_pkgdata_for_python_packages(self):
237 pkgdata_dir = tinfoil.config_data.getVar('PKGDATA_DIR')
238
239 ldata = tinfoil.config_data.createCopy()
240 bb.parse.handle('classes-recipe/python3-dir.bbclass', ldata, True)
241 python_sitedir = ldata.getVar('PYTHON_SITEPACKAGES_DIR')
242
243 dynload_dir = os.path.join(os.path.dirname(python_sitedir), 'lib-dynload')
244 python_dirs = [python_sitedir + os.sep,
245 os.path.join(os.path.dirname(python_sitedir), 'dist-packages') + os.sep,
246 os.path.dirname(python_sitedir) + os.sep]
247 packages = {}
248 for pkgdatafile in glob.glob('{}/runtime/*'.format(pkgdata_dir)):
249 files_info = None
250 with open(pkgdatafile, 'r') as f:
251 for line in f.readlines():
252 field, value = line.split(': ', 1)
253 if field.startswith('FILES_INFO'):
254 files_info = ast.literal_eval(value)
255 break
256 else:
257 continue
258
259 for fn in files_info:
260 for suffix in importlib.machinery.all_suffixes():
261 if fn.endswith(suffix):
262 break
263 else:
264 continue
265
266 if fn.startswith(dynload_dir + os.sep):
267 if '/.debug/' in fn:
268 continue
269 base = os.path.basename(fn)
270 provided = base.split('.', 1)[0]
271 packages[provided] = os.path.basename(pkgdatafile)
272 continue
273
274 for python_dir in python_dirs:
275 if fn.startswith(python_dir):
276 relpath = fn[len(python_dir):]
277 relstart, _, relremaining = relpath.partition(os.sep)
278 if relstart.endswith('.egg'):
279 relpath = relremaining
280 base, _ = os.path.splitext(relpath)
281
282 if '/.debug/' in base:
283 continue
284 if os.path.basename(base) == '__init__':
285 base = os.path.dirname(base)
286 base = base.replace(os.sep + os.sep, os.sep)
287 provided = base.replace(os.sep, '.')
288 packages[provided] = os.path.basename(pkgdatafile)
289 return packages
290
291 @classmethod
292 def run_command(cls, cmd, **popenargs):
293 if 'stderr' not in popenargs:
294 popenargs['stderr'] = subprocess.STDOUT
295 try:
296 return subprocess.check_output(cmd, **popenargs).decode('utf-8')
297 except OSError as exc:
298 logger.error('Unable to run `{}`: {}', ' '.join(cmd), exc)
299 raise
300 except subprocess.CalledProcessError as exc:
301 logger.error('Unable to run `{}`: {}', ' '.join(cmd), exc.output)
302 raise
303
304class PythonSetupPyRecipeHandler(PythonRecipeHandler):
305 bbvar_map = {
306 'Name': 'PN',
307 'Version': 'PV',
308 'Home-page': 'HOMEPAGE',
309 'Summary': 'SUMMARY',
310 'Description': 'DESCRIPTION',
311 'License': 'LICENSE',
312 'Requires': 'RDEPENDS:${PN}',
313 'Provides': 'RPROVIDES:${PN}',
314 'Obsoletes': 'RREPLACES:${PN}',
315 }
316 # PN/PV are already set by recipetool core & desc can be extremely long
317 excluded_fields = [
318 'Description',
319 ]
320 setup_parse_map = {
321 'Url': 'Home-page',
322 'Classifiers': 'Classifier',
323 'Description': 'Summary',
324 }
325 setuparg_map = {
326 'Home-page': 'url',
327 'Classifier': 'classifiers',
328 'Summary': 'description',
329 'Description': 'long-description',
330 }
331 # Values which are lists, used by the setup.py argument based metadata
332 # extraction method, to determine how to process the setup.py output.
333 setuparg_list_fields = [
334 'Classifier',
335 'Requires',
336 'Provides',
337 'Obsoletes',
338 'Platform',
339 'Supported-Platform',
340 ]
341 setuparg_multi_line_values = ['Description']
342
343 replacements = [
344 ('License', r' +$', ''),
345 ('License', r'^ +', ''),
346 ('License', r' ', '-'),
347 ('License', r'^GNU-', ''),
348 ('License', r'-[Ll]icen[cs]e(,?-[Vv]ersion)?', ''),
349 ('License', r'^UNKNOWN$', ''),
350
351 # Remove currently unhandled version numbers from these variables
352 ('Requires', r' *\([^)]*\)', ''),
353 ('Provides', r' *\([^)]*\)', ''),
354 ('Obsoletes', r' *\([^)]*\)', ''),
355 ('Install-requires', r'^([^><= ]+).*', r'\1'),
356 ('Extras-require', r'^([^><= ]+).*', r'\1'),
357 ('Tests-require', r'^([^><= ]+).*', r'\1'),
358
359 # Remove unhandled dependency on particular features (e.g. foo[PDF])
360 ('Install-requires', r'\[[^\]]+\]$', ''),
361 ]
362
363 def __init__(self):
364 pass
373 365
374 def parse_setup_py(self, setupscript='./setup.py'): 366 def parse_setup_py(self, setupscript='./setup.py'):
375 with codecs.open(setupscript) as f: 367 with codecs.open(setupscript) as f:
@@ -445,47 +437,16 @@ class PythonRecipeHandler(RecipeHandler):
445 info[fields[lineno]] = line 437 info[fields[lineno]] = line
446 return info 438 return info
447 439
448 def apply_info_replacements(self, info): 440 def get_pkginfo(self, pkginfo_fn):
449 for variable, search, replace in self.replacements: 441 msg = email.message_from_file(open(pkginfo_fn, 'r'))
450 if variable not in info: 442 msginfo = {}
451 continue 443 for field in msg.keys():
452 444 values = msg.get_all(field)
453 def replace_value(search, replace, value): 445 if len(values) == 1:
454 if replace is None: 446 msginfo[field] = values[0]
455 if re.search(search, value):
456 return None
457 else:
458 new_value = re.sub(search, replace, value)
459 if value != new_value:
460 return new_value
461 return value
462
463 value = info[variable]
464 if isinstance(value, str):
465 new_value = replace_value(search, replace, value)
466 if new_value is None:
467 del info[variable]
468 elif new_value != value:
469 info[variable] = new_value
470 elif hasattr(value, 'items'):
471 for dkey, dvalue in list(value.items()):
472 new_list = []
473 for pos, a_value in enumerate(dvalue):
474 new_value = replace_value(search, replace, a_value)
475 if new_value is not None and new_value != value:
476 new_list.append(new_value)
477
478 if value != new_list:
479 value[dkey] = new_list
480 else: 447 else:
481 new_list = [] 448 msginfo[field] = values
482 for pos, a_value in enumerate(value): 449 return msginfo
483 new_value = replace_value(search, replace, a_value)
484 if new_value is not None and new_value != value:
485 new_list.append(new_value)
486
487 if value != new_list:
488 info[variable] = new_list
489 450
490 def scan_setup_python_deps(self, srctree, setup_info, setup_non_literals): 451 def scan_setup_python_deps(self, srctree, setup_info, setup_non_literals):
491 if 'Package-dir' in setup_info: 452 if 'Package-dir' in setup_info:
@@ -540,99 +501,160 @@ class PythonRecipeHandler(RecipeHandler):
540 unmapped_deps.add(dep) 501 unmapped_deps.add(dep)
541 return mapped_deps, unmapped_deps 502 return mapped_deps, unmapped_deps
542 503
543 def scan_python_dependencies(self, paths): 504 def process(self, srctree, classes, lines_before, lines_after, handled, extravalues):
544 deps = set() 505
545 try: 506 if 'buildsystem' in handled:
546 dep_output = self.run_command(['pythondeps', '-d'] + paths) 507 return False
547 except (OSError, subprocess.CalledProcessError): 508
548 pass 509 # Check for non-zero size setup.py files
510 setupfiles = RecipeHandler.checkfiles(srctree, ['setup.py'])
511 for fn in setupfiles:
512 if os.path.getsize(fn):
513 break
549 else: 514 else:
550 for line in dep_output.splitlines(): 515 return False
551 line = line.rstrip()
552 dep, filename = line.split('\t', 1)
553 if filename.endswith('/setup.py'):
554 continue
555 deps.add(dep)
556 516
517 # setup.py is always parsed to get at certain required information, such as
518 # distutils vs setuptools
519 #
520 # If egg info is available, we use it for both its PKG-INFO metadata
521 # and for its requires.txt for install_requires.
522 # If PKG-INFO is available but no egg info is, we use that for metadata in preference to
523 # the parsed setup.py, but use the install_requires info from the
524 # parsed setup.py.
525
526 setupscript = os.path.join(srctree, 'setup.py')
557 try: 527 try:
558 provides_output = self.run_command(['pythondeps', '-p'] + paths) 528 setup_info, uses_setuptools, setup_non_literals, extensions = self.parse_setup_py(setupscript)
559 except (OSError, subprocess.CalledProcessError): 529 except Exception:
560 pass 530 logger.exception("Failed to parse setup.py")
531 setup_info, uses_setuptools, setup_non_literals, extensions = {}, True, [], []
532
533 egginfo = glob.glob(os.path.join(srctree, '*.egg-info'))
534 if egginfo:
535 info = self.get_pkginfo(os.path.join(egginfo[0], 'PKG-INFO'))
536 requires_txt = os.path.join(egginfo[0], 'requires.txt')
537 if os.path.exists(requires_txt):
538 with codecs.open(requires_txt) as f:
539 inst_req = []
540 extras_req = collections.defaultdict(list)
541 current_feature = None
542 for line in f.readlines():
543 line = line.rstrip()
544 if not line:
545 continue
546
547 if line.startswith('['):
548 # PACKAGECONFIG must not contain expressions or whitespace
549 line = line.replace(" ", "")
550 line = line.replace(':', "")
551 line = line.replace('.', "-dot-")
552 line = line.replace('"', "")
553 line = line.replace('<', "-smaller-")
554 line = line.replace('>', "-bigger-")
555 line = line.replace('_', "-")
556 line = line.replace('(', "")
557 line = line.replace(')', "")
558 line = line.replace('!', "-not-")
559 line = line.replace('=', "-equals-")
560 current_feature = line[1:-1]
561 elif current_feature:
562 extras_req[current_feature].append(line)
563 else:
564 inst_req.append(line)
565 info['Install-requires'] = inst_req
566 info['Extras-require'] = extras_req
567 elif RecipeHandler.checkfiles(srctree, ['PKG-INFO']):
568 info = self.get_pkginfo(os.path.join(srctree, 'PKG-INFO'))
569
570 if setup_info:
571 if 'Install-requires' in setup_info:
572 info['Install-requires'] = setup_info['Install-requires']
573 if 'Extras-require' in setup_info:
574 info['Extras-require'] = setup_info['Extras-require']
561 else: 575 else:
562 provides_lines = (l.rstrip() for l in provides_output.splitlines()) 576 if setup_info:
563 provides = set(l for l in provides_lines if l and l != 'setup') 577 info = setup_info
564 deps -= provides 578 else:
579 info = self.get_setup_args_info(setupscript)
565 580
566 return deps 581 # Grab the license value before applying replacements
582 license_str = info.get('License', '').strip()
567 583
568 def parse_pkgdata_for_python_packages(self): 584 self.apply_info_replacements(info)
569 pkgdata_dir = tinfoil.config_data.getVar('PKGDATA_DIR')
570 585
571 ldata = tinfoil.config_data.createCopy() 586 if uses_setuptools:
572 bb.parse.handle('classes-recipe/python3-dir.bbclass', ldata, True) 587 classes.append('setuptools3')
573 python_sitedir = ldata.getVar('PYTHON_SITEPACKAGES_DIR') 588 else:
589 classes.append('distutils3')
574 590
575 dynload_dir = os.path.join(os.path.dirname(python_sitedir), 'lib-dynload') 591 if license_str:
576 python_dirs = [python_sitedir + os.sep, 592 for i, line in enumerate(lines_before):
577 os.path.join(os.path.dirname(python_sitedir), 'dist-packages') + os.sep, 593 if line.startswith('##LICENSE_PLACEHOLDER##'):
578 os.path.dirname(python_sitedir) + os.sep] 594 lines_before.insert(i, '# NOTE: License in setup.py/PKGINFO is: %s' % license_str)
579 packages = {} 595 break
580 for pkgdatafile in glob.glob('{}/runtime/*'.format(pkgdata_dir)):
581 files_info = None
582 with open(pkgdatafile, 'r') as f:
583 for line in f.readlines():
584 field, value = line.split(': ', 1)
585 if field.startswith('FILES_INFO'):
586 files_info = ast.literal_eval(value)
587 break
588 else:
589 continue
590 596
591 for fn in files_info: 597 if 'Classifier' in info:
592 for suffix in importlib.machinery.all_suffixes(): 598 license = self.handle_classifier_license(info['Classifier'], info.get('License', ''))
593 if fn.endswith(suffix): 599 if license:
594 break 600 info['License'] = license
595 else:
596 continue
597 601
598 if fn.startswith(dynload_dir + os.sep): 602 self.map_info_to_bbvar(info, extravalues)
599 if '/.debug/' in fn:
600 continue
601 base = os.path.basename(fn)
602 provided = base.split('.', 1)[0]
603 packages[provided] = os.path.basename(pkgdatafile)
604 continue
605 603
606 for python_dir in python_dirs: 604 mapped_deps, unmapped_deps = self.scan_setup_python_deps(srctree, setup_info, setup_non_literals)
607 if fn.startswith(python_dir):
608 relpath = fn[len(python_dir):]
609 relstart, _, relremaining = relpath.partition(os.sep)
610 if relstart.endswith('.egg'):
611 relpath = relremaining
612 base, _ = os.path.splitext(relpath)
613 605
614 if '/.debug/' in base: 606 extras_req = set()
615 continue 607 if 'Extras-require' in info:
616 if os.path.basename(base) == '__init__': 608 extras_req = info['Extras-require']
617 base = os.path.dirname(base) 609 if extras_req:
618 base = base.replace(os.sep + os.sep, os.sep) 610 lines_after.append('# The following configs & dependencies are from setuptools extras_require.')
619 provided = base.replace(os.sep, '.') 611 lines_after.append('# These dependencies are optional, hence can be controlled via PACKAGECONFIG.')
620 packages[provided] = os.path.basename(pkgdatafile) 612 lines_after.append('# The upstream names may not correspond exactly to bitbake package names.')
621 return packages 613 lines_after.append('# The configs are might not correct, since PACKAGECONFIG does not support expressions as may used in requires.txt - they are just replaced by text.')
614 lines_after.append('#')
615 lines_after.append('# Uncomment this line to enable all the optional features.')
616 lines_after.append('#PACKAGECONFIG ?= "{}"'.format(' '.join(k.lower() for k in extras_req)))
617 for feature, feature_reqs in extras_req.items():
618 unmapped_deps.difference_update(feature_reqs)
622 619
623 @classmethod 620 feature_req_deps = ('python3-' + r.replace('.', '-').lower() for r in sorted(feature_reqs))
624 def run_command(cls, cmd, **popenargs): 621 lines_after.append('PACKAGECONFIG[{}] = ",,,{}"'.format(feature.lower(), ' '.join(feature_req_deps)))
625 if 'stderr' not in popenargs:
626 popenargs['stderr'] = subprocess.STDOUT
627 try:
628 return subprocess.check_output(cmd, **popenargs).decode('utf-8')
629 except OSError as exc:
630 logger.error('Unable to run `{}`: {}', ' '.join(cmd), exc)
631 raise
632 except subprocess.CalledProcessError as exc:
633 logger.error('Unable to run `{}`: {}', ' '.join(cmd), exc.output)
634 raise
635 622
623 inst_reqs = set()
624 if 'Install-requires' in info:
625 if extras_req:
626 lines_after.append('')
627 inst_reqs = info['Install-requires']
628 if inst_reqs:
629 unmapped_deps.difference_update(inst_reqs)
630
631 inst_req_deps = ('python3-' + r.replace('.', '-').lower() for r in sorted(inst_reqs))
632 lines_after.append('# WARNING: the following rdepends are from setuptools install_requires. These')
633 lines_after.append('# upstream names may not correspond exactly to bitbake package names.')
634 lines_after.append('RDEPENDS:${{PN}} += "{}"'.format(' '.join(inst_req_deps)))
635
636 if mapped_deps:
637 name = info.get('Name')
638 if name and name[0] in mapped_deps:
639 # Attempt to avoid self-reference
640 mapped_deps.remove(name[0])
641 mapped_deps -= set(self.excluded_pkgdeps)
642 if inst_reqs or extras_req:
643 lines_after.append('')
644 lines_after.append('# WARNING: the following rdepends are determined through basic analysis of the')
645 lines_after.append('# python sources, and might not be 100% accurate.')
646 lines_after.append('RDEPENDS:${{PN}} += "{}"'.format(' '.join(sorted(mapped_deps))))
647
648 unmapped_deps -= set(extensions)
649 unmapped_deps -= set(self.assume_provided)
650 if unmapped_deps:
651 if mapped_deps:
652 lines_after.append('')
653 lines_after.append('# WARNING: We were unable to map the following python package/module')
654 lines_after.append('# dependencies to the bitbake packages which include them:')
655 lines_after.extend('# {}'.format(d) for d in sorted(unmapped_deps))
656
657 handled.append('buildsystem')
636 658
637def gather_setup_info(fileobj): 659def gather_setup_info(fileobj):
638 parsed = ast.parse(fileobj.read(), fileobj.name) 660 parsed = ast.parse(fileobj.read(), fileobj.name)
@@ -748,4 +770,4 @@ def has_non_literals(value):
748 770
749def register_recipe_handlers(handlers): 771def register_recipe_handlers(handlers):
750 # We need to make sure this is ahead of the makefile fallback handler 772 # We need to make sure this is ahead of the makefile fallback handler
751 handlers.append((PythonRecipeHandler(), 70)) 773 handlers.append((PythonSetupPyRecipeHandler(), 70))