diff options
author | Julien Stephan <jstephan@baylibre.com> | 2023-10-25 17:46:57 +0200 |
---|---|---|
committer | Richard Purdie <richard.purdie@linuxfoundation.org> | 2023-10-27 08:28:38 +0100 |
commit | e64e92f2de4b182d087d7d88326aa7d06f59776e (patch) | |
tree | 89dcdf4bfa4d362ed7981612af45f4c872c79293 /scripts/lib/recipetool/create_buildsys_python.py | |
parent | be129bd0bc5ed4e637dbb313c8cf96bad660438c (diff) | |
download | poky-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/recipetool/create_buildsys_python.py')
-rw-r--r-- | scripts/lib/recipetool/create_buildsys_python.py | 720 |
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 | |||
304 | class 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 | ||
637 | def gather_setup_info(fileobj): | 659 | def 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 | ||
749 | def register_recipe_handlers(handlers): | 771 | def 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)) |