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 | |
| 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')
| -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)) |
