]>
Commit | Line | Data |
---|---|---|
ee8b6808 | 1 | #!/usr/bin/env python3 |
1008ac44 DT |
2 | """ |
3 | ||
4 | Utility for building Buildroot packages for existing PyPI packages | |
5 | ||
6 | Any package built by scanpypi should be manually checked for | |
7 | errors. | |
8 | """ | |
9 | from __future__ import print_function | |
3a0c20c5 | 10 | from __future__ import absolute_import |
1008ac44 DT |
11 | import argparse |
12 | import json | |
1008ac44 DT |
13 | import sys |
14 | import os | |
15 | import shutil | |
1008ac44 DT |
16 | import tarfile |
17 | import zipfile | |
18 | import errno | |
19 | import hashlib | |
20 | import re | |
21 | import textwrap | |
22 | import tempfile | |
23 | import imp | |
24 | from functools import wraps | |
e273c36a YY |
25 | import six.moves.urllib.request |
26 | import six.moves.urllib.error | |
27 | import six.moves.urllib.parse | |
3a0c20c5 YY |
28 | from six.moves import map |
29 | from six.moves import zip | |
30 | from six.moves import input | |
31 | if six.PY2: | |
32 | import StringIO | |
33 | else: | |
34 | import io | |
1008ac44 | 35 | |
13d94669 YY |
36 | BUF_SIZE = 65536 |
37 | ||
d2ac1ec6 YY |
38 | try: |
39 | import spdx_lookup as liclookup | |
40 | except ImportError: | |
41 | # spdx_lookup is not installed | |
42 | print('spdx_lookup module is not installed. This can lead to an ' | |
43 | 'inaccurate licence detection. Please install it via\n' | |
44 | 'pip install spdx_lookup') | |
45 | liclookup = None | |
46 | ||
2455e5a0 | 47 | |
1008ac44 DT |
48 | def setup_decorator(func, method): |
49 | """ | |
50 | Decorator for distutils.core.setup and setuptools.setup. | |
51 | Puts the arguments with which setup is called as a dict | |
52 | Add key 'method' which should be either 'setuptools' or 'distutils'. | |
53 | ||
54 | Keyword arguments: | |
55 | func -- either setuptools.setup or distutils.core.setup | |
56 | method -- either 'setuptools' or 'distutils' | |
57 | """ | |
58 | ||
59 | @wraps(func) | |
60 | def closure(*args, **kwargs): | |
61 | # Any python packages calls its setup function to be installed. | |
62 | # Argument 'name' of this setup function is the package's name | |
63 | BuildrootPackage.setup_args[kwargs['name']] = kwargs | |
64 | BuildrootPackage.setup_args[kwargs['name']]['method'] = method | |
65 | return closure | |
66 | ||
67 | # monkey patch | |
2455e5a0 | 68 | import setuptools # noqa E402 |
1008ac44 | 69 | setuptools.setup = setup_decorator(setuptools.setup, 'setuptools') |
2455e5a0 | 70 | import distutils # noqa E402 |
1008ac44 DT |
71 | distutils.core.setup = setup_decorator(setuptools.setup, 'distutils') |
72 | ||
2455e5a0 | 73 | |
1008ac44 DT |
74 | def find_file_upper_case(filenames, path='./'): |
75 | """ | |
76 | List generator: | |
77 | Recursively find files that matches one of the specified filenames. | |
78 | Returns a relative path starting with path argument. | |
79 | ||
80 | Keyword arguments: | |
81 | filenames -- List of filenames to be found | |
82 | path -- Path to the directory to search | |
83 | """ | |
84 | for root, dirs, files in os.walk(path): | |
85 | for file in files: | |
86 | if file.upper() in filenames: | |
87 | yield (os.path.join(root, file)) | |
88 | ||
89 | ||
90 | def pkg_buildroot_name(pkg_name): | |
91 | """ | |
92 | Returns the Buildroot package name for the PyPI package pkg_name. | |
93 | Remove all non alphanumeric characters except - | |
94 | Also lowers the name and adds 'python-' suffix | |
95 | ||
96 | Keyword arguments: | |
97 | pkg_name -- String to rename | |
98 | """ | |
f9150a6a | 99 | name = re.sub(r'[^\w-]', '', pkg_name.lower()) |
f13b843e | 100 | name = name.replace('_', '-') |
1008ac44 | 101 | prefix = 'python-' |
f9150a6a | 102 | pattern = re.compile(r'^(?!' + prefix + ')(.+?)$') |
1008ac44 DT |
103 | name = pattern.sub(r'python-\1', name) |
104 | return name | |
105 | ||
2455e5a0 | 106 | |
1008ac44 DT |
107 | class DownloadFailed(Exception): |
108 | pass | |
109 | ||
2455e5a0 | 110 | |
1008ac44 DT |
111 | class BuildrootPackage(): |
112 | """This class's methods are not meant to be used individually please | |
113 | use them in the correct order: | |
114 | ||
115 | __init__ | |
116 | ||
117 | download_package | |
118 | ||
119 | extract_package | |
120 | ||
121 | load_module | |
122 | ||
123 | get_requirements | |
124 | ||
125 | create_package_mk | |
126 | ||
127 | create_hash_file | |
128 | ||
129 | create_config_in | |
130 | ||
131 | """ | |
132 | setup_args = {} | |
133 | ||
134 | def __init__(self, real_name, pkg_folder): | |
135 | self.real_name = real_name | |
136 | self.buildroot_name = pkg_buildroot_name(self.real_name) | |
137 | self.pkg_dir = os.path.join(pkg_folder, self.buildroot_name) | |
138 | self.mk_name = self.buildroot_name.upper().replace('-', '_') | |
139 | self.as_string = None | |
140 | self.md5_sum = None | |
141 | self.metadata = None | |
142 | self.metadata_name = None | |
143 | self.metadata_url = None | |
144 | self.pkg_req = None | |
145 | self.setup_metadata = None | |
146 | self.tmp_extract = None | |
147 | self.used_url = None | |
148 | self.filename = None | |
149 | self.url = None | |
150 | self.version = None | |
13d94669 | 151 | self.license_files = [] |
1008ac44 DT |
152 | |
153 | def fetch_package_info(self): | |
154 | """ | |
155 | Fetch a package's metadata from the python package index | |
156 | """ | |
6766ff9d | 157 | self.metadata_url = 'https://pypi.org/pypi/{pkg}/json'.format( |
1008ac44 DT |
158 | pkg=self.real_name) |
159 | try: | |
3a0c20c5 YY |
160 | pkg_json = six.moves.urllib.request.urlopen(self.metadata_url).read().decode() |
161 | except six.moves.urllib.error.HTTPError as error: | |
1008ac44 DT |
162 | print('ERROR:', error.getcode(), error.msg, file=sys.stderr) |
163 | print('ERROR: Could not find package {pkg}.\n' | |
164 | 'Check syntax inside the python package index:\n' | |
165 | 'https://pypi.python.org/pypi/ ' | |
166 | .format(pkg=self.real_name)) | |
167 | raise | |
3a0c20c5 | 168 | except six.moves.urllib.error.URLError: |
1008ac44 DT |
169 | print('ERROR: Could not find package {pkg}.\n' |
170 | 'Check syntax inside the python package index:\n' | |
171 | 'https://pypi.python.org/pypi/ ' | |
172 | .format(pkg=self.real_name)) | |
173 | raise | |
174 | self.metadata = json.loads(pkg_json) | |
175 | self.version = self.metadata['info']['version'] | |
176 | self.metadata_name = self.metadata['info']['name'] | |
177 | ||
178 | def download_package(self): | |
179 | """ | |
180 | Download a package using metadata from pypi | |
181 | """ | |
fb775f4c | 182 | download = None |
1008ac44 DT |
183 | try: |
184 | self.metadata['urls'][0]['filename'] | |
185 | except IndexError: | |
186 | print( | |
187 | 'Non-conventional package, ', | |
188 | 'please check carefully after creation') | |
189 | self.metadata['urls'] = [{ | |
190 | 'packagetype': 'sdist', | |
191 | 'url': self.metadata['info']['download_url'], | |
6766ff9d | 192 | 'digests': None}] |
1008ac44 DT |
193 | # In this case, we can't get the name of the downloaded file |
194 | # from the pypi api, so we need to find it, this should work | |
cfafcfa9 | 195 | urlpath = six.moves.urllib.parse.urlparse( |
1008ac44 DT |
196 | self.metadata['info']['download_url']).path |
197 | # urlparse().path give something like | |
198 | # /path/to/file-version.tar.gz | |
199 | # We use basename to remove /path/to | |
200 | self.metadata['urls'][0]['filename'] = os.path.basename(urlpath) | |
201 | for download_url in self.metadata['urls']: | |
202 | if 'bdist' in download_url['packagetype']: | |
203 | continue | |
204 | try: | |
205 | print('Downloading package {pkg} from {url}...'.format( | |
fb775f4c | 206 | pkg=self.real_name, url=download_url['url'])) |
3a0c20c5 YY |
207 | download = six.moves.urllib.request.urlopen(download_url['url']) |
208 | except six.moves.urllib.error.HTTPError as http_error: | |
1008ac44 DT |
209 | download = http_error |
210 | else: | |
211 | self.used_url = download_url | |
212 | self.as_string = download.read() | |
6766ff9d | 213 | if not download_url['digests']['md5']: |
1008ac44 DT |
214 | break |
215 | self.md5_sum = hashlib.md5(self.as_string).hexdigest() | |
6766ff9d | 216 | if self.md5_sum == download_url['digests']['md5']: |
1008ac44 | 217 | break |
fb775f4c YY |
218 | |
219 | if download is None: | |
220 | raise DownloadFailed('Failed to download package {pkg}: ' | |
221 | 'No source archive available' | |
1008ac44 | 222 | .format(pkg=self.real_name)) |
fb775f4c YY |
223 | elif download.__class__ == six.moves.urllib.error.HTTPError: |
224 | raise download | |
225 | ||
1008ac44 DT |
226 | self.filename = self.used_url['filename'] |
227 | self.url = self.used_url['url'] | |
228 | ||
a83e30ad PK |
229 | def check_archive(self, members): |
230 | """ | |
231 | Check archive content before extracting | |
232 | ||
233 | Keyword arguments: | |
234 | members -- list of archive members | |
235 | """ | |
236 | # Protect against https://github.com/snyk/zip-slip-vulnerability | |
237 | # Older python versions do not validate that the extracted files are | |
238 | # inside the target directory. Detect and error out on evil paths | |
239 | evil = [e for e in members if os.path.relpath(e).startswith(('/', '..'))] | |
240 | if evil: | |
241 | print('ERROR: Refusing to extract {} with suspicious members {}'.format( | |
242 | self.filename, evil)) | |
243 | sys.exit(1) | |
244 | ||
1008ac44 DT |
245 | def extract_package(self, tmp_path): |
246 | """ | |
247 | Extract the package contents into a directrory | |
248 | ||
249 | Keyword arguments: | |
250 | tmp_path -- directory where you want the package to be extracted | |
251 | """ | |
3a0c20c5 YY |
252 | if six.PY2: |
253 | as_file = StringIO.StringIO(self.as_string) | |
254 | else: | |
255 | as_file = io.BytesIO(self.as_string) | |
1008ac44 DT |
256 | if self.filename[-3:] == 'zip': |
257 | with zipfile.ZipFile(as_file) as as_zipfile: | |
258 | tmp_pkg = os.path.join(tmp_path, self.buildroot_name) | |
259 | try: | |
260 | os.makedirs(tmp_pkg) | |
261 | except OSError as exception: | |
262 | if exception.errno != errno.EEXIST: | |
fd29797f YY |
263 | print("ERROR: ", exception.strerror, file=sys.stderr) |
264 | return | |
265 | print('WARNING:', exception.strerror, file=sys.stderr) | |
1008ac44 DT |
266 | print('Removing {pkg}...'.format(pkg=tmp_pkg)) |
267 | shutil.rmtree(tmp_pkg) | |
268 | os.makedirs(tmp_pkg) | |
a83e30ad | 269 | self.check_archive(as_zipfile.namelist()) |
1008ac44 | 270 | as_zipfile.extractall(tmp_pkg) |
fd29797f | 271 | pkg_filename = self.filename.split(".zip")[0] |
1008ac44 DT |
272 | else: |
273 | with tarfile.open(fileobj=as_file) as as_tarfile: | |
274 | tmp_pkg = os.path.join(tmp_path, self.buildroot_name) | |
275 | try: | |
276 | os.makedirs(tmp_pkg) | |
277 | except OSError as exception: | |
278 | if exception.errno != errno.EEXIST: | |
fd29797f YY |
279 | print("ERROR: ", exception.strerror, file=sys.stderr) |
280 | return | |
281 | print('WARNING:', exception.strerror, file=sys.stderr) | |
1008ac44 DT |
282 | print('Removing {pkg}...'.format(pkg=tmp_pkg)) |
283 | shutil.rmtree(tmp_pkg) | |
284 | os.makedirs(tmp_pkg) | |
a83e30ad | 285 | self.check_archive(as_tarfile.getnames()) |
1008ac44 | 286 | as_tarfile.extractall(tmp_pkg) |
fd29797f | 287 | pkg_filename = self.filename.split(".tar")[0] |
1008ac44 | 288 | |
fd29797f | 289 | tmp_extract = '{folder}/{name}' |
1008ac44 DT |
290 | self.tmp_extract = tmp_extract.format( |
291 | folder=tmp_pkg, | |
fd29797f | 292 | name=pkg_filename) |
1008ac44 DT |
293 | |
294 | def load_setup(self): | |
295 | """ | |
296 | Loads the corresponding setup and store its metadata | |
297 | """ | |
298 | current_dir = os.getcwd() | |
299 | os.chdir(self.tmp_extract) | |
f982f704 | 300 | sys.path.insert(0, self.tmp_extract) |
1008ac44 | 301 | s_file, s_path, s_desc = imp.find_module('setup', [self.tmp_extract]) |
f982f704 | 302 | imp.load_module('__main__', s_file, s_path, s_desc) |
f13b843e JH |
303 | if self.metadata_name in self.setup_args: |
304 | pass | |
305 | elif self.metadata_name.replace('_', '-') in self.setup_args: | |
306 | self.metadata_name = self.metadata_name.replace('_', '-') | |
307 | elif self.metadata_name.replace('-', '_') in self.setup_args: | |
308 | self.metadata_name = self.metadata_name.replace('-', '_') | |
1008ac44 DT |
309 | try: |
310 | self.setup_metadata = self.setup_args[self.metadata_name] | |
311 | except KeyError: | |
e43c0509 TDS |
312 | # This means setup was not called |
313 | print('ERROR: Could not determine package metadata for {pkg}.\n' | |
314 | .format(pkg=self.real_name)) | |
315 | raise | |
1008ac44 DT |
316 | os.chdir(current_dir) |
317 | sys.path.remove(self.tmp_extract) | |
318 | ||
319 | def get_requirements(self, pkg_folder): | |
320 | """ | |
321 | Retrieve dependencies from the metadata found in the setup.py script of | |
322 | a pypi package. | |
323 | ||
324 | Keyword Arguments: | |
325 | pkg_folder -- location of the already created packages | |
326 | """ | |
327 | if 'install_requires' not in self.setup_metadata: | |
328 | self.pkg_req = None | |
329 | return set() | |
330 | self.pkg_req = self.setup_metadata['install_requires'] | |
f9150a6a | 331 | self.pkg_req = [re.sub(r'([-.\w]+).*', r'\1', req) |
1008ac44 | 332 | for req in self.pkg_req] |
d2e29fcc YY |
333 | |
334 | # get rid of commented lines and also strip the package strings | |
09ec6d7f YY |
335 | self.pkg_req = [item.strip() for item in self.pkg_req |
336 | if len(item) > 0 and item[0] != '#'] | |
d2e29fcc | 337 | |
1008ac44 | 338 | req_not_found = self.pkg_req |
3a0c20c5 YY |
339 | self.pkg_req = list(map(pkg_buildroot_name, self.pkg_req)) |
340 | pkg_tuples = list(zip(req_not_found, self.pkg_req)) | |
1008ac44 DT |
341 | # pkg_tuples is a list of tuples that looks like |
342 | # ('werkzeug','python-werkzeug') because I need both when checking if | |
343 | # dependencies already exist or are already in the download list | |
344 | req_not_found = set( | |
345 | pkg[0] for pkg in pkg_tuples | |
346 | if not os.path.isdir(pkg[1]) | |
347 | ) | |
348 | return req_not_found | |
349 | ||
350 | def __create_mk_header(self): | |
351 | """ | |
352 | Create the header of the <package_name>.mk file | |
353 | """ | |
354 | header = ['#' * 80 + '\n'] | |
355 | header.append('#\n') | |
356 | header.append('# {name}\n'.format(name=self.buildroot_name)) | |
357 | header.append('#\n') | |
358 | header.append('#' * 80 + '\n') | |
359 | header.append('\n') | |
360 | return header | |
361 | ||
362 | def __create_mk_download_info(self): | |
363 | """ | |
364 | Create the lines refering to the download information of the | |
365 | <package_name>.mk file | |
366 | """ | |
367 | lines = [] | |
368 | version_line = '{name}_VERSION = {version}\n'.format( | |
369 | name=self.mk_name, | |
370 | version=self.version) | |
371 | lines.append(version_line) | |
372 | ||
2bcc4edc AK |
373 | if self.buildroot_name != self.real_name: |
374 | targz = self.filename.replace( | |
375 | self.version, | |
376 | '$({name}_VERSION)'.format(name=self.mk_name)) | |
377 | targz_line = '{name}_SOURCE = {filename}\n'.format( | |
378 | name=self.mk_name, | |
379 | filename=targz) | |
380 | lines.append(targz_line) | |
1008ac44 DT |
381 | |
382 | if self.filename not in self.url: | |
383 | # Sometimes the filename is in the url, sometimes it's not | |
384 | site_url = self.url | |
385 | else: | |
386 | site_url = self.url[:self.url.find(self.filename)] | |
387 | site_line = '{name}_SITE = {url}'.format(name=self.mk_name, | |
388 | url=site_url) | |
389 | site_line = site_line.rstrip('/') + '\n' | |
390 | lines.append(site_line) | |
391 | return lines | |
392 | ||
393 | def __create_mk_setup(self): | |
394 | """ | |
395 | Create the line refering to the setup method of the package of the | |
396 | <package_name>.mk file | |
397 | ||
398 | There are two things you can use to make an installer | |
399 | for a python package: distutils or setuptools | |
400 | distutils comes with python but does not support dependencies. | |
401 | distutils is mostly still there for backward support. | |
402 | setuptools is what smart people use, | |
403 | but it is not shipped with python :( | |
404 | """ | |
405 | lines = [] | |
406 | setup_type_line = '{name}_SETUP_TYPE = {method}\n'.format( | |
407 | name=self.mk_name, | |
408 | method=self.setup_metadata['method']) | |
409 | lines.append(setup_type_line) | |
410 | return lines | |
411 | ||
d2ac1ec6 | 412 | def __get_license_names(self, license_files): |
1008ac44 | 413 | """ |
d2ac1ec6 YY |
414 | Try to determine the related license name. |
415 | ||
d05e41eb | 416 | There are two possibilities. Either the script tries to |
d2ac1ec6 YY |
417 | get license name from package's metadata or, if spdx_lookup |
418 | package is available, the script compares license files with | |
419 | SPDX database. | |
420 | """ | |
421 | license_line = '' | |
422 | if liclookup is None: | |
423 | license_dict = { | |
424 | 'Apache Software License': 'Apache-2.0', | |
d05e41eb | 425 | 'BSD License': 'FIXME: please specify the exact BSD version', |
d2ac1ec6 YY |
426 | 'European Union Public Licence 1.0': 'EUPL-1.0', |
427 | 'European Union Public Licence 1.1': 'EUPL-1.1', | |
428 | "GNU General Public License": "GPL", | |
429 | "GNU General Public License v2": "GPL-2.0", | |
430 | "GNU General Public License v2 or later": "GPL-2.0+", | |
431 | "GNU General Public License v3": "GPL-3.0", | |
432 | "GNU General Public License v3 or later": "GPL-3.0+", | |
433 | "GNU Lesser General Public License v2": "LGPL-2.1", | |
434 | "GNU Lesser General Public License v2 or later": "LGPL-2.1+", | |
435 | "GNU Lesser General Public License v3": "LGPL-3.0", | |
436 | "GNU Lesser General Public License v3 or later": "LGPL-3.0+", | |
437 | "GNU Library or Lesser General Public License": "LGPL-2.0", | |
438 | "ISC License": "ISC", | |
439 | "MIT License": "MIT", | |
440 | "Mozilla Public License 1.0": "MPL-1.0", | |
441 | "Mozilla Public License 1.1": "MPL-1.1", | |
442 | "Mozilla Public License 2.0": "MPL-2.0", | |
443 | "Zope Public License": "ZPL" | |
444 | } | |
f9150a6a | 445 | regexp = re.compile(r'^License :* *.* *:+ (.*)( \(.*\))?$') |
d2ac1ec6 YY |
446 | classifiers_licenses = [regexp.sub(r"\1", lic) |
447 | for lic in self.metadata['info']['classifiers'] | |
448 | if regexp.match(lic)] | |
3a0c20c5 | 449 | licenses = [license_dict[x] if x in license_dict else x for x in classifiers_licenses] |
d2ac1ec6 YY |
450 | if not len(licenses): |
451 | print('WARNING: License has been set to "{license}". It is most' | |
452 | ' likely wrong, please change it if need be'.format( | |
453 | license=', '.join(licenses))) | |
454 | licenses = [self.metadata['info']['license']] | |
143f1358 | 455 | licenses = set(licenses) |
d2ac1ec6 YY |
456 | license_line = '{name}_LICENSE = {license}\n'.format( |
457 | name=self.mk_name, | |
458 | license=', '.join(licenses)) | |
459 | else: | |
460 | license_names = [] | |
461 | for license_file in license_files: | |
462 | with open(license_file) as lic_file: | |
463 | match = liclookup.match(lic_file.read()) | |
c46f72b6 | 464 | if match is not None and match.confidence >= 90.0: |
d2ac1ec6 | 465 | license_names.append(match.license.id) |
0101ac62 YY |
466 | else: |
467 | license_names.append("FIXME: license id couldn't be detected") | |
143f1358 | 468 | license_names = set(license_names) |
1008ac44 | 469 | |
d2ac1ec6 YY |
470 | if len(license_names) > 0: |
471 | license_line = ('{name}_LICENSE =' | |
472 | ' {names}\n'.format( | |
473 | name=self.mk_name, | |
474 | names=', '.join(license_names))) | |
1008ac44 | 475 | |
d2ac1ec6 | 476 | return license_line |
1008ac44 | 477 | |
d2ac1ec6 YY |
478 | def __create_mk_license(self): |
479 | """ | |
480 | Create the lines referring to the package's license informations of the | |
481 | <package_name>.mk file | |
1008ac44 | 482 | |
d2ac1ec6 YY |
483 | The license's files are found by searching the package (case insensitive) |
484 | for files named license, license.txt etc. If more than one license file | |
485 | is found, the user is asked to select which ones he wants to use. | |
1008ac44 | 486 | """ |
1008ac44 | 487 | lines = [] |
1008ac44 | 488 | |
60aa8969 FF |
489 | filenames = ['LICENCE', 'LICENSE', 'LICENSE.MD', 'LICENSE.RST', |
490 | 'LICENSE.TXT', 'COPYING', 'COPYING.TXT'] | |
13d94669 | 491 | self.license_files = list(find_file_upper_case(filenames, self.tmp_extract)) |
d2ac1ec6 | 492 | |
13d94669 | 493 | lines.append(self.__get_license_names(self.license_files)) |
d2ac1ec6 | 494 | |
1008ac44 | 495 | license_files = [license.replace(self.tmp_extract, '')[1:] |
13d94669 | 496 | for license in self.license_files] |
1008ac44 DT |
497 | if len(license_files) > 0: |
498 | if len(license_files) > 1: | |
499 | print('More than one file found for license:', | |
500 | ', '.join(license_files)) | |
501 | license_files = [filename | |
502 | for index, filename in enumerate(license_files)] | |
503 | license_file_line = ('{name}_LICENSE_FILES =' | |
504 | ' {files}\n'.format( | |
505 | name=self.mk_name, | |
506 | files=' '.join(license_files))) | |
507 | lines.append(license_file_line) | |
508 | else: | |
509 | print('WARNING: No license file found,' | |
510 | ' please specify it manually afterwards') | |
511 | license_file_line = '# No license file found\n' | |
512 | ||
513 | return lines | |
514 | ||
515 | def __create_mk_requirements(self): | |
516 | """ | |
517 | Create the lines referring to the dependencies of the of the | |
518 | <package_name>.mk file | |
519 | ||
520 | Keyword Arguments: | |
521 | pkg_name -- name of the package | |
522 | pkg_req -- dependencies of the package | |
523 | """ | |
524 | lines = [] | |
525 | dependencies_line = ('{name}_DEPENDENCIES =' | |
526 | ' {reqs}\n'.format( | |
527 | name=self.mk_name, | |
528 | reqs=' '.join(self.pkg_req))) | |
529 | lines.append(dependencies_line) | |
530 | return lines | |
531 | ||
532 | def create_package_mk(self): | |
533 | """ | |
534 | Create the lines corresponding to the <package_name>.mk file | |
535 | """ | |
536 | pkg_mk = '{name}.mk'.format(name=self.buildroot_name) | |
537 | path_to_mk = os.path.join(self.pkg_dir, pkg_mk) | |
538 | print('Creating {file}...'.format(file=path_to_mk)) | |
539 | lines = self.__create_mk_header() | |
540 | lines += self.__create_mk_download_info() | |
541 | lines += self.__create_mk_setup() | |
542 | lines += self.__create_mk_license() | |
1008ac44 DT |
543 | |
544 | lines.append('\n') | |
545 | lines.append('$(eval $(python-package))') | |
546 | lines.append('\n') | |
547 | with open(path_to_mk, 'w') as mk_file: | |
548 | mk_file.writelines(lines) | |
549 | ||
550 | def create_hash_file(self): | |
551 | """ | |
552 | Create the lines corresponding to the <package_name>.hash files | |
553 | """ | |
554 | pkg_hash = '{name}.hash'.format(name=self.buildroot_name) | |
555 | path_to_hash = os.path.join(self.pkg_dir, pkg_hash) | |
556 | print('Creating {filename}...'.format(filename=path_to_hash)) | |
557 | lines = [] | |
6766ff9d YY |
558 | if self.used_url['digests']['md5'] and self.used_url['digests']['sha256']: |
559 | hash_header = '# md5, sha256 from {url}\n'.format( | |
13d94669 | 560 | url=self.metadata_url) |
6766ff9d | 561 | lines.append(hash_header) |
9fbbf4fd | 562 | hash_line = '{method} {digest} {filename}\n'.format( |
1008ac44 | 563 | method='md5', |
6766ff9d YY |
564 | digest=self.used_url['digests']['md5'], |
565 | filename=self.filename) | |
566 | lines.append(hash_line) | |
9fbbf4fd | 567 | hash_line = '{method} {digest} {filename}\n'.format( |
6766ff9d YY |
568 | method='sha256', |
569 | digest=self.used_url['digests']['sha256'], | |
1008ac44 DT |
570 | filename=self.filename) |
571 | lines.append(hash_line) | |
1008ac44 | 572 | |
6766ff9d YY |
573 | if self.license_files: |
574 | lines.append('# Locally computed sha256 checksums\n') | |
13d94669 YY |
575 | for license_file in self.license_files: |
576 | sha256 = hashlib.sha256() | |
577 | with open(license_file, 'rb') as lic_f: | |
578 | while True: | |
579 | data = lic_f.read(BUF_SIZE) | |
580 | if not data: | |
581 | break | |
582 | sha256.update(data) | |
9fbbf4fd | 583 | hash_line = '{method} {digest} {filename}\n'.format( |
13d94669 YY |
584 | method='sha256', |
585 | digest=sha256.hexdigest(), | |
7cfceeb9 | 586 | filename=license_file.replace(self.tmp_extract, '')[1:]) |
13d94669 YY |
587 | lines.append(hash_line) |
588 | ||
1008ac44 DT |
589 | with open(path_to_hash, 'w') as hash_file: |
590 | hash_file.writelines(lines) | |
591 | ||
592 | def create_config_in(self): | |
593 | """ | |
594 | Creates the Config.in file of a package | |
595 | """ | |
596 | path_to_config = os.path.join(self.pkg_dir, 'Config.in') | |
597 | print('Creating {file}...'.format(file=path_to_config)) | |
598 | lines = [] | |
599 | config_line = 'config BR2_PACKAGE_{name}\n'.format( | |
600 | name=self.mk_name) | |
601 | lines.append(config_line) | |
602 | ||
603 | bool_line = '\tbool "{name}"\n'.format(name=self.buildroot_name) | |
604 | lines.append(bool_line) | |
605 | if self.pkg_req: | |
2d28d020 | 606 | self.pkg_req.sort() |
1008ac44 | 607 | for dep in self.pkg_req: |
8a64ade2 | 608 | dep_line = '\tselect BR2_PACKAGE_{req} # runtime\n'.format( |
1008ac44 DT |
609 | req=dep.upper().replace('-', '_')) |
610 | lines.append(dep_line) | |
611 | ||
612 | lines.append('\thelp\n') | |
613 | ||
3ff07284 | 614 | help_lines = textwrap.wrap(self.metadata['info']['summary'], 62, |
1008ac44 DT |
615 | initial_indent='\t ', |
616 | subsequent_indent='\t ') | |
f49295cc YY |
617 | |
618 | # make sure a help text is terminated with a full stop | |
619 | if help_lines[-1][-1] != '.': | |
620 | help_lines[-1] += '.' | |
621 | ||
1008ac44 DT |
622 | # \t + two spaces is 3 char long |
623 | help_lines.append('') | |
624 | help_lines.append('\t ' + self.metadata['info']['home_page']) | |
3a0c20c5 | 625 | help_lines = [x + '\n' for x in help_lines] |
1008ac44 DT |
626 | lines += help_lines |
627 | ||
628 | with open(path_to_config, 'w') as config_file: | |
629 | config_file.writelines(lines) | |
630 | ||
631 | ||
632 | def main(): | |
633 | # Building the parser | |
634 | parser = argparse.ArgumentParser( | |
635 | description="Creates buildroot packages from the metadata of " | |
636 | "an existing PyPI packages and include it " | |
637 | "in menuconfig") | |
638 | parser.add_argument("packages", | |
639 | help="list of packages to be created", | |
640 | nargs='+') | |
641 | parser.add_argument("-o", "--output", | |
642 | help=""" | |
643 | Output directory for packages. | |
644 | Default is ./package | |
645 | """, | |
646 | default='./package') | |
647 | ||
648 | args = parser.parse_args() | |
649 | packages = list(set(args.packages)) | |
650 | ||
651 | # tmp_path is where we'll extract the files later | |
652 | tmp_prefix = 'scanpypi-' | |
653 | pkg_folder = args.output | |
654 | tmp_path = tempfile.mkdtemp(prefix=tmp_prefix) | |
655 | try: | |
656 | for real_pkg_name in packages: | |
657 | package = BuildrootPackage(real_pkg_name, pkg_folder) | |
658 | print('buildroot package name for {}:'.format(package.real_name), | |
659 | package.buildroot_name) | |
660 | # First we download the package | |
661 | # Most of the info we need can only be found inside the package | |
662 | print('Package:', package.buildroot_name) | |
663 | print('Fetching package', package.real_name) | |
664 | try: | |
665 | package.fetch_package_info() | |
3a0c20c5 | 666 | except (six.moves.urllib.error.URLError, six.moves.urllib.error.HTTPError): |
1008ac44 DT |
667 | continue |
668 | if package.metadata_name.lower() == 'setuptools': | |
669 | # setuptools imports itself, that does not work very well | |
670 | # with the monkey path at the begining | |
671 | print('Error: setuptools cannot be built using scanPyPI') | |
672 | continue | |
673 | ||
674 | try: | |
675 | package.download_package() | |
3a0c20c5 | 676 | except six.moves.urllib.error.HTTPError as error: |
1008ac44 DT |
677 | print('Error: {code} {reason}'.format(code=error.code, |
678 | reason=error.reason)) | |
679 | print('Error downloading package :', package.buildroot_name) | |
680 | print() | |
681 | continue | |
682 | ||
683 | # extract the tarball | |
684 | try: | |
685 | package.extract_package(tmp_path) | |
686 | except (tarfile.ReadError, zipfile.BadZipfile): | |
687 | print('Error extracting package {}'.format(package.real_name)) | |
688 | print() | |
689 | continue | |
690 | ||
691 | # Loading the package install info from the package | |
692 | try: | |
693 | package.load_setup() | |
694 | except ImportError as err: | |
695 | if 'buildutils' in err.message: | |
696 | print('This package needs buildutils') | |
697 | else: | |
698 | raise | |
699 | continue | |
e43c0509 | 700 | except (AttributeError, KeyError) as error: |
5d2c69da AR |
701 | print('Error: Could not install package {pkg}: {error}'.format( |
702 | pkg=package.real_name, error=error)) | |
1008ac44 DT |
703 | continue |
704 | ||
705 | # Package requirement are an argument of the setup function | |
706 | req_not_found = package.get_requirements(pkg_folder) | |
707 | req_not_found = req_not_found.difference(packages) | |
708 | ||
709 | packages += req_not_found | |
710 | if req_not_found: | |
711 | print('Added packages \'{pkgs}\' as dependencies of {pkg}' | |
712 | .format(pkgs=", ".join(req_not_found), | |
713 | pkg=package.buildroot_name)) | |
714 | print('Checking if package {name} already exists...'.format( | |
715 | name=package.pkg_dir)) | |
716 | try: | |
717 | os.makedirs(package.pkg_dir) | |
718 | except OSError as exception: | |
719 | if exception.errno != errno.EEXIST: | |
720 | print("ERROR: ", exception.message, file=sys.stderr) | |
721 | continue | |
722 | print('Error: Package {name} already exists' | |
723 | .format(name=package.pkg_dir)) | |
3a0c20c5 | 724 | del_pkg = input( |
1008ac44 DT |
725 | 'Do you want to delete existing package ? [y/N]') |
726 | if del_pkg.lower() == 'y': | |
727 | shutil.rmtree(package.pkg_dir) | |
728 | os.makedirs(package.pkg_dir) | |
729 | else: | |
730 | continue | |
731 | package.create_package_mk() | |
732 | ||
733 | package.create_hash_file() | |
734 | ||
735 | package.create_config_in() | |
f64701b0 MW |
736 | print("NOTE: Remember to also make an update to the DEVELOPERS file") |
737 | print(" and include an entry for the pkg in packages/Config.in") | |
1008ac44 DT |
738 | print() |
739 | # printing an empty line for visual confort | |
740 | finally: | |
741 | shutil.rmtree(tmp_path) | |
742 | ||
2455e5a0 | 743 | |
1008ac44 DT |
744 | if __name__ == "__main__": |
745 | main() |