• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

kivy / python-for-android / 11454422838

22 Oct 2024 06:20AM UTC coverage: 59.167% (-0.03%) from 59.195%
11454422838

push

github

web-flow
Merge pull request #3074 from autosportlabs/brent/private_github_repos

Add ability to use private github repos for recipes

1050 of 2363 branches covered (44.44%)

Branch coverage included in aggregate %.

12 of 15 new or added lines in 1 file covered. (80.0%)

4 existing lines in 1 file now uncovered.

4888 of 7673 relevant lines covered (63.7%)

2.54 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

45.63
/pythonforandroid/recipe.py
1
from os.path import basename, dirname, exists, isdir, isfile, join, realpath, split
4✔
2
import glob
4✔
3
import hashlib
4✔
4
import json
4✔
5
from re import match
4✔
6

7
import sh
4✔
8
import shutil
4✔
9
import fnmatch
4✔
10
import zipfile
4✔
11
import urllib.request
4✔
12
from urllib.request import urlretrieve
4✔
13
from os import listdir, unlink, environ, curdir, walk
4✔
14
from sys import stdout
4✔
15
import time
4✔
16
try:
4✔
17
    from urlparse import urlparse
4✔
18
except ImportError:
4✔
19
    from urllib.parse import urlparse
4✔
20

21
import packaging.version
4✔
22

23
from pythonforandroid.logger import (
4✔
24
    logger, info, warning, debug, shprint, info_main, error)
25
from pythonforandroid.util import (
4✔
26
    current_directory, ensure_dir, BuildInterruptingException, rmdir, move,
27
    touch, patch_wheel_setuptools_logging)
28
from pythonforandroid.util import load_source as import_recipe
4✔
29

30

31
url_opener = urllib.request.build_opener()
4✔
32
url_orig_headers = url_opener.addheaders
4✔
33
urllib.request.install_opener(url_opener)
4✔
34

35

36
class RecipeMeta(type):
4✔
37
    def __new__(cls, name, bases, dct):
4✔
38
        if name != 'Recipe':
4✔
39
            if 'url' in dct:
4✔
40
                dct['_url'] = dct.pop('url')
4✔
41
            if 'version' in dct:
4✔
42
                dct['_version'] = dct.pop('version')
4✔
43

44
        return super().__new__(cls, name, bases, dct)
4✔
45

46

47
class Recipe(metaclass=RecipeMeta):
4✔
48
    _url = None
4✔
49
    '''The address from which the recipe may be downloaded. This is not
2✔
50
    essential, it may be omitted if the source is available some other
51
    way, such as via the :class:`IncludedFilesBehaviour` mixin.
52

53
    If the url includes the version, you may (and probably should)
54
    replace this with ``{version}``, which will automatically be
55
    replaced by the :attr:`version` string during download.
56

57
    .. note:: Methods marked (internal) are used internally and you
58
              probably don't need to call them, but they are available
59
              if you want.
60
    '''
61

62
    _download_headers = None
4✔
63
    '''Add additional headers used when downloading the package, typically
2✔
64
    for authorization purposes.
65

66
    Specified as an array of tuples:
67
    [("header1", "foo"), ("header2", "bar")]
68

69
    When specifying as an environment variable (DOWNLOAD_HEADER_my-package-name), use a JSON formatted fragement:
70
    [["header1","foo"],["header2", "bar"]]
71

72
    For example, when downloading from a private
73
    github repository, you can specify the following:
74
    [('Authorization', 'token <your personal access token>'), ('Accept', 'application/vnd.github+json')]
75
    '''
76

77
    _version = None
4✔
78
    '''A string giving the version of the software the recipe describes,
2✔
79
    e.g. ``2.0.3`` or ``master``.'''
80

81
    md5sum = None
4✔
82
    '''The md5sum of the source from the :attr:`url`. Non-essential, but
2✔
83
    you should try to include this, it is used to check that the download
84
    finished correctly.
85
    '''
86

87
    sha512sum = None
4✔
88
    '''The sha512sum of the source from the :attr:`url`. Non-essential, but
2✔
89
    you should try to include this, it is used to check that the download
90
    finished correctly.
91
    '''
92

93
    blake2bsum = None
4✔
94
    '''The blake2bsum of the source from the :attr:`url`. Non-essential, but
2✔
95
    you should try to include this, it is used to check that the download
96
    finished correctly.
97
    '''
98

99
    depends = []
4✔
100
    '''A list containing the names of any recipes that this recipe depends on.
2✔
101
    '''
102

103
    conflicts = []
4✔
104
    '''A list containing the names of any recipes that are known to be
2✔
105
    incompatible with this one.'''
106

107
    opt_depends = []
4✔
108
    '''A list of optional dependencies, that must be built before this
2✔
109
    recipe if they are built at all, but whose presence is not essential.'''
110

111
    patches = []
4✔
112
    '''A list of patches to apply to the source. Values can be either a string
2✔
113
    referring to the patch file relative to the recipe dir, or a tuple of the
114
    string patch file and a callable, which will receive the kwargs `arch` and
115
    `recipe`, which should return True if the patch should be applied.'''
116

117
    python_depends = []
4✔
118
    '''A list of pure-Python packages that this package requires. These
2✔
119
    packages will NOT be available at build time, but will be added to the
120
    list of pure-Python packages to install via pip. If you need these packages
121
    at build time, you must create a recipe.'''
122

123
    archs = ['armeabi']  # Not currently implemented properly
4✔
124

125
    built_libraries = {}
4✔
126
    """Each recipe that builds a system library (e.g.:libffi, openssl, etc...)
2✔
127
    should contain a dict holding the relevant information of the library. The
128
    keys should be the generated libraries and the values the relative path of
129
    the library inside his build folder. This dict will be used to perform
130
    different operations:
131
        - copy the library into the right location, depending on if it's shared
132
          or static)
133
        - check if we have to rebuild the library
134

135
    Here an example of how it would look like for `libffi` recipe:
136

137
        - `built_libraries = {'libffi.so': '.libs'}`
138

139
    .. note:: in case that the built library resides in recipe's build
140
              directory, you can set the following values for the relative
141
              path: `'.', None or ''`
142
    """
143

144
    need_stl_shared = False
4✔
145
    '''Some libraries or python packages may need the c++_shared in APK.
2✔
146
    We can automatically do this for any recipe if we set this property to
147
    `True`'''
148

149
    stl_lib_name = 'c++_shared'
4✔
150
    '''
2✔
151
    The default STL shared lib to use: `c++_shared`.
152

153
    .. note:: Android NDK version > 17 only supports 'c++_shared', because
154
        starting from NDK r18 the `gnustl_shared` lib has been deprecated.
155
    '''
156

157
    def get_stl_library(self, arch):
4✔
158
        return join(
4✔
159
            arch.ndk_lib_dir,
160
            'lib{name}.so'.format(name=self.stl_lib_name),
161
        )
162

163
    def install_stl_lib(self, arch):
4✔
164
        if not self.ctx.has_lib(
4!
165
            arch.arch, 'lib{name}.so'.format(name=self.stl_lib_name)
166
        ):
167
            self.install_libs(arch, self.get_stl_library(arch))
4✔
168

169
    @property
4✔
170
    def version(self):
4✔
171
        key = 'VERSION_' + self.name
4✔
172
        return environ.get(key, self._version)
4✔
173

174
    @property
4✔
175
    def url(self):
4✔
176
        key = 'URL_' + self.name
4✔
177
        return environ.get(key, self._url)
4✔
178

179
    @property
4✔
180
    def versioned_url(self):
4✔
181
        '''A property returning the url of the recipe with ``{version}``
182
        replaced by the :attr:`url`. If accessing the url, you should use this
183
        property, *not* access the url directly.'''
184
        if self.url is None:
4!
185
            return None
×
186
        return self.url.format(version=self.version)
4✔
187

188
    @property
4✔
189
    def download_headers(self):
4✔
190
        key = "DOWNLOAD_HEADERS_" + self.name
4✔
191
        env_headers = environ.get(key)
4✔
192
        if env_headers:
4✔
193
            try:
4✔
194
                return [tuple(h) for h in json.loads(env_headers)]
4✔
NEW
195
            except Exception as ex:
×
NEW
196
                raise ValueError(f'Invalid Download headers for {key} - must be JSON formatted as [["header1","foo"],["header2","bar"]]: {ex}')
×
197

198
        return environ.get(key, self._download_headers)
4✔
199

200
    def download_file(self, url, target, cwd=None):
4✔
201
        """
202
        (internal) Download an ``url`` to a ``target``.
203
        """
204
        if not url:
4!
205
            return
×
206

207
        info('Downloading {} from {}'.format(self.name, url))
4✔
208

209
        if cwd:
4!
210
            target = join(cwd, target)
×
211

212
        parsed_url = urlparse(url)
4✔
213
        if parsed_url.scheme in ('http', 'https'):
4!
214
            def report_hook(index, blksize, size):
4✔
215
                if size <= 0:
×
216
                    progression = '{0} bytes'.format(index * blksize)
×
217
                else:
218
                    progression = '{0:.2f}%'.format(
×
219
                        index * blksize * 100. / float(size))
220
                if "CI" not in environ:
×
221
                    stdout.write('- Download {}\r'.format(progression))
×
222
                    stdout.flush()
×
223

224
            if exists(target):
4!
225
                unlink(target)
×
226

227
            # Download item with multiple attempts (for bad connections):
228
            attempts = 0
4✔
229
            seconds = 1
4✔
230
            while True:
2✔
231
                try:
4✔
232
                    # jqueryui.com returns a 403 w/ the default user agent
233
                    # Mozilla/5.0 does not handle redirection for liblzma
234
                    url_opener.addheaders = [('User-agent', 'Wget/1.0')]
4✔
235
                    if self.download_headers:
4!
NEW
236
                        url_opener.addheaders += self.download_headers
×
237
                    urlretrieve(url, target, report_hook)
4✔
238
                except OSError as e:
4✔
239
                    attempts += 1
4✔
240
                    if attempts >= 5:
4✔
241
                        raise
4✔
242
                    stdout.write('Download failed: {}; retrying in {} second(s)...'.format(e, seconds))
4✔
243
                    time.sleep(seconds)
4✔
244
                    seconds *= 2
4✔
245
                    continue
4✔
246
                finally:
247
                    url_opener.addheaders = url_orig_headers
4✔
248
                break
4✔
249
            return target
4✔
250
        elif parsed_url.scheme in ('git', 'git+file', 'git+ssh', 'git+http', 'git+https'):
×
251
            if not isdir(target):
×
252
                if url.startswith('git+'):
×
253
                    url = url[4:]
×
254
                # if 'version' is specified, do a shallow clone
255
                if self.version:
×
256
                    ensure_dir(target)
×
257
                    with current_directory(target):
×
258
                        shprint(sh.git, 'init')
×
259
                        shprint(sh.git, 'remote', 'add', 'origin', url)
×
260
                else:
261
                    shprint(sh.git, 'clone', '--recursive', url, target)
×
262
            with current_directory(target):
×
263
                if self.version:
×
264
                    shprint(sh.git, 'fetch', '--tags', '--depth', '1')
×
265
                    shprint(sh.git, 'checkout', self.version)
×
266
                branch = sh.git('branch', '--show-current')
×
267
                if branch:
×
268
                    shprint(sh.git, 'pull')
×
269
                    shprint(sh.git, 'pull', '--recurse-submodules')
×
270
                shprint(sh.git, 'submodule', 'update', '--recursive', '--init', '--depth', '1')
×
271
            return target
×
272

273
    def apply_patch(self, filename, arch, build_dir=None):
4✔
274
        """
275
        Apply a patch from the current recipe directory into the current
276
        build directory.
277

278
        .. versionchanged:: 0.6.0
279
            Add ability to apply patch from any dir via kwarg `build_dir`'''
280
        """
281
        info("Applying patch {}".format(filename))
4✔
282
        build_dir = build_dir if build_dir else self.get_build_dir(arch)
4✔
283
        filename = join(self.get_recipe_dir(), filename)
4✔
284
        shprint(sh.patch, "-t", "-d", build_dir, "-p1",
4✔
285
                "-i", filename, _tail=10)
286

287
    def copy_file(self, filename, dest):
4✔
288
        info("Copy {} to {}".format(filename, dest))
×
289
        filename = join(self.get_recipe_dir(), filename)
×
290
        dest = join(self.build_dir, dest)
×
291
        shutil.copy(filename, dest)
×
292

293
    def append_file(self, filename, dest):
4✔
294
        info("Append {} to {}".format(filename, dest))
×
295
        filename = join(self.get_recipe_dir(), filename)
×
296
        dest = join(self.build_dir, dest)
×
297
        with open(filename, "rb") as fd:
×
298
            data = fd.read()
×
299
        with open(dest, "ab") as fd:
×
300
            fd.write(data)
×
301

302
    @property
4✔
303
    def name(self):
4✔
304
        '''The name of the recipe, the same as the folder containing it.'''
305
        modname = self.__class__.__module__
4✔
306
        return modname.split(".", 2)[-1]
4✔
307

308
    @property
4✔
309
    def filtered_archs(self):
4✔
310
        '''Return archs of self.ctx that are valid build archs
311
        for the Recipe.'''
312
        result = []
×
313
        for arch in self.ctx.archs:
×
314
            if not self.archs or (arch.arch in self.archs):
×
315
                result.append(arch)
×
316
        return result
×
317

318
    def check_recipe_choices(self):
4✔
319
        '''Checks what recipes are being built to see which of the alternative
320
        and optional dependencies are being used,
321
        and returns a list of these.'''
322
        recipes = []
4✔
323
        built_recipes = self.ctx.recipe_build_order
4✔
324
        for recipe in self.depends:
4✔
325
            if isinstance(recipe, (tuple, list)):
4!
326
                for alternative in recipe:
×
327
                    if alternative in built_recipes:
×
328
                        recipes.append(alternative)
×
329
                        break
×
330
        for recipe in self.opt_depends:
4✔
331
            if recipe in built_recipes:
4!
332
                recipes.append(recipe)
×
333
        return sorted(recipes)
4✔
334

335
    def get_opt_depends_in_list(self, recipes):
4✔
336
        '''Given a list of recipe names, returns those that are also in
337
        self.opt_depends.
338
        '''
339
        return [recipe for recipe in recipes if recipe in self.opt_depends]
4✔
340

341
    def get_build_container_dir(self, arch):
4✔
342
        '''Given the arch name, returns the directory where it will be
343
        built.
344

345
        This returns a different directory depending on what
346
        alternative or optional dependencies are being built.
347
        '''
348
        dir_name = self.get_dir_name()
4✔
349
        return join(self.ctx.build_dir, 'other_builds',
4✔
350
                    dir_name, '{}__ndk_target_{}'.format(arch, self.ctx.ndk_api))
351

352
    def get_dir_name(self):
4✔
353
        choices = self.check_recipe_choices()
4✔
354
        dir_name = '-'.join([self.name] + choices)
4✔
355
        return dir_name
4✔
356

357
    def get_build_dir(self, arch):
4✔
358
        '''Given the arch name, returns the directory where the
359
        downloaded/copied package will be built.'''
360

361
        return join(self.get_build_container_dir(arch), self.name)
4✔
362

363
    def get_recipe_dir(self):
4✔
364
        """
365
        Returns the local recipe directory or defaults to the core recipe
366
        directory.
367
        """
368
        if self.ctx.local_recipes is not None:
4!
369
            local_recipe_dir = join(self.ctx.local_recipes, self.name)
×
370
            if exists(local_recipe_dir):
×
371
                return local_recipe_dir
×
372
        return join(self.ctx.root_dir, 'recipes', self.name)
4✔
373

374
    # Public Recipe API to be subclassed if needed
375

376
    def download_if_necessary(self):
4✔
377
        info_main('Downloading {}'.format(self.name))
4✔
378
        user_dir = environ.get('P4A_{}_DIR'.format(self.name.lower()))
4✔
379
        if user_dir is not None:
4✔
380
            info('P4A_{}_DIR is set, skipping download for {}'.format(
4✔
381
                self.name, self.name))
382
            return
4✔
383
        self.download()
4✔
384

385
    def download(self):
4✔
386
        if self.url is None:
4✔
387
            info('Skipping {} download as no URL is set'.format(self.name))
4✔
388
            return
4✔
389

390
        url = self.versioned_url
4✔
391
        expected_digests = {}
4✔
392
        for alg in set(hashlib.algorithms_guaranteed) | set(('md5', 'sha512', 'blake2b')):
4✔
393
            expected_digest = getattr(self, alg + 'sum') if hasattr(self, alg + 'sum') else None
4✔
394
            ma = match(u'^(.+)#' + alg + u'=([0-9a-f]{32,})$', url)
4✔
395
            if ma:                # fragmented URL?
4!
396
                if expected_digest:
×
397
                    raise ValueError(
×
398
                        ('Received {}sum from both the {} recipe '
399
                         'and its url').format(alg, self.name))
400
                url = ma.group(1)
×
401
                expected_digest = ma.group(2)
×
402
            if expected_digest:
4!
403
                expected_digests[alg] = expected_digest
×
404

405
        ensure_dir(join(self.ctx.packages_path, self.name))
4✔
406

407
        with current_directory(join(self.ctx.packages_path, self.name)):
4✔
408
            filename = shprint(sh.basename, url).stdout[:-1].decode('utf-8')
4✔
409

410
            do_download = True
4✔
411
            marker_filename = '.mark-{}'.format(filename)
4✔
412
            if exists(filename) and isfile(filename):
4!
413
                if not exists(marker_filename):
×
414
                    shprint(sh.rm, filename)
×
415
                else:
416
                    for alg, expected_digest in expected_digests.items():
×
417
                        current_digest = algsum(alg, filename)
×
418
                        if current_digest != expected_digest:
×
419
                            debug('* Generated {}sum: {}'.format(alg,
×
420
                                                                 current_digest))
421
                            debug('* Expected {}sum: {}'.format(alg,
×
422
                                                                expected_digest))
423
                            raise ValueError(
×
424
                                ('Generated {0}sum does not match expected {0}sum '
425
                                 'for {1} recipe').format(alg, self.name))
426
                    do_download = False
×
427

428
            # If we got this far, we will download
429
            if do_download:
4!
430
                debug('Downloading {} from {}'.format(self.name, url))
4✔
431

432
                shprint(sh.rm, '-f', marker_filename)
4✔
433
                self.download_file(self.versioned_url, filename)
4✔
434
                touch(marker_filename)
4✔
435

436
                if exists(filename) and isfile(filename):
4!
437
                    for alg, expected_digest in expected_digests.items():
×
438
                        current_digest = algsum(alg, filename)
×
439
                        if current_digest != expected_digest:
×
440
                            debug('* Generated {}sum: {}'.format(alg,
×
441
                                                                 current_digest))
442
                            debug('* Expected {}sum: {}'.format(alg,
×
443
                                                                expected_digest))
444
                            raise ValueError(
×
445
                                ('Generated {0}sum does not match expected {0}sum '
446
                                 'for {1} recipe').format(alg, self.name))
447
            else:
448
                info('{} download already cached, skipping'.format(self.name))
×
449

450
    def unpack(self, arch):
4✔
451
        info_main('Unpacking {} for {}'.format(self.name, arch))
×
452

453
        build_dir = self.get_build_container_dir(arch)
×
454

455
        user_dir = environ.get('P4A_{}_DIR'.format(self.name.lower()))
×
456
        if user_dir is not None:
×
457
            info('P4A_{}_DIR exists, symlinking instead'.format(
×
458
                self.name.lower()))
459
            if exists(self.get_build_dir(arch)):
×
460
                return
×
461
            rmdir(build_dir)
×
462
            ensure_dir(build_dir)
×
463
            shprint(sh.cp, '-a', user_dir, self.get_build_dir(arch))
×
464
            return
×
465

466
        if self.url is None:
×
467
            info('Skipping {} unpack as no URL is set'.format(self.name))
×
468
            return
×
469

470
        filename = shprint(
×
471
            sh.basename, self.versioned_url).stdout[:-1].decode('utf-8')
472
        ma = match(u'^(.+)#[a-z0-9_]{3,}=([0-9a-f]{32,})$', filename)
×
473
        if ma:                  # fragmented URL?
×
474
            filename = ma.group(1)
×
475

476
        with current_directory(build_dir):
×
477
            directory_name = self.get_build_dir(arch)
×
478

479
            if not exists(directory_name) or not isdir(directory_name):
×
480
                extraction_filename = join(
×
481
                    self.ctx.packages_path, self.name, filename)
482
                if isfile(extraction_filename):
×
483
                    if extraction_filename.endswith(('.zip', '.whl')):
×
484
                        try:
×
485
                            sh.unzip(extraction_filename)
×
486
                        except (sh.ErrorReturnCode_1, sh.ErrorReturnCode_2):
×
487
                            # return code 1 means unzipping had
488
                            # warnings but did complete,
489
                            # apparently happens sometimes with
490
                            # github zips
491
                            pass
×
492
                        fileh = zipfile.ZipFile(extraction_filename, 'r')
×
493
                        root_directory = fileh.filelist[0].filename.split('/')[0]
×
494
                        if root_directory != basename(directory_name):
×
495
                            move(root_directory, directory_name)
×
496
                    elif extraction_filename.endswith(
×
497
                            ('.tar.gz', '.tgz', '.tar.bz2', '.tbz2', '.tar.xz', '.txz')):
498
                        sh.tar('xf', extraction_filename)
×
499
                        root_directory = sh.tar('tf', extraction_filename).split('\n')[0].split('/')[0]
×
500
                        if root_directory != basename(directory_name):
×
501
                            move(root_directory, directory_name)
×
502
                    else:
503
                        raise Exception(
×
504
                            'Could not extract {} download, it must be .zip, '
505
                            '.tar.gz or .tar.bz2 or .tar.xz'.format(extraction_filename))
506
                elif isdir(extraction_filename):
×
507
                    ensure_dir(directory_name)
×
508
                    for entry in listdir(extraction_filename):
×
509
                        # Previously we filtered out the .git folder, but during the build process for some recipes
510
                        # (e.g. when version is parsed by `setuptools_scm`) that may be needed.
511
                        shprint(sh.cp, '-Rv',
×
512
                                join(extraction_filename, entry),
513
                                directory_name)
514
                else:
515
                    raise Exception(
×
516
                        'Given path is neither a file nor a directory: {}'
517
                        .format(extraction_filename))
518

519
            else:
520
                info('{} is already unpacked, skipping'.format(self.name))
×
521

522
    def get_recipe_env(self, arch=None, with_flags_in_cc=True):
4✔
523
        """Return the env specialized for the recipe
524
        """
525
        if arch is None:
4!
526
            arch = self.filtered_archs[0]
×
527
        env = arch.get_env(with_flags_in_cc=with_flags_in_cc)
4✔
528
        return env
4✔
529

530
    def prebuild_arch(self, arch):
4✔
531
        '''Run any pre-build tasks for the Recipe. By default, this checks if
532
        any prebuild_archname methods exist for the archname of the current
533
        architecture, and runs them if so.'''
534
        prebuild = "prebuild_{}".format(arch.arch.replace('-', '_'))
4✔
535
        if hasattr(self, prebuild):
4!
536
            getattr(self, prebuild)()
×
537
        else:
538
            info('{} has no {}, skipping'.format(self.name, prebuild))
4✔
539

540
    def is_patched(self, arch):
4✔
541
        build_dir = self.get_build_dir(arch.arch)
4✔
542
        return exists(join(build_dir, '.patched'))
4✔
543

544
    def apply_patches(self, arch, build_dir=None):
4✔
545
        '''Apply any patches for the Recipe.
546

547
        .. versionchanged:: 0.6.0
548
            Add ability to apply patches from any dir via kwarg `build_dir`'''
549
        if self.patches:
×
550
            info_main('Applying patches for {}[{}]'
×
551
                      .format(self.name, arch.arch))
552

553
            if self.is_patched(arch):
×
554
                info_main('{} already patched, skipping'.format(self.name))
×
555
                return
×
556

557
            build_dir = build_dir if build_dir else self.get_build_dir(arch.arch)
×
558
            for patch in self.patches:
×
559
                if isinstance(patch, (tuple, list)):
×
560
                    patch, patch_check = patch
×
561
                    if not patch_check(arch=arch, recipe=self):
×
562
                        continue
×
563

564
                self.apply_patch(
×
565
                        patch.format(version=self.version, arch=arch.arch),
566
                        arch.arch, build_dir=build_dir)
567

568
            touch(join(build_dir, '.patched'))
×
569

570
    def should_build(self, arch):
4✔
571
        '''Should perform any necessary test and return True only if it needs
572
        building again. Per default we implement a library test, in case that
573
        we detect so.
574

575
        '''
576
        if self.built_libraries:
4!
577
            return not all(
4✔
578
                exists(lib) for lib in self.get_libraries(arch.arch)
579
            )
580
        return True
×
581

582
    def build_arch(self, arch):
4✔
583
        '''Run any build tasks for the Recipe. By default, this checks if
584
        any build_archname methods exist for the archname of the current
585
        architecture, and runs them if so.'''
586
        build = "build_{}".format(arch.arch)
×
587
        if hasattr(self, build):
×
588
            getattr(self, build)()
×
589

590
    def install_libraries(self, arch):
4✔
591
        '''This method is always called after `build_arch`. In case that we
592
        detect a library recipe, defined by the class attribute
593
        `built_libraries`, we will copy all defined libraries into the
594
         right location.
595
        '''
596
        if not self.built_libraries:
4!
597
            return
×
598
        shared_libs = [
4✔
599
            lib for lib in self.get_libraries(arch) if lib.endswith(".so")
600
        ]
601
        self.install_libs(arch, *shared_libs)
4✔
602

603
    def postbuild_arch(self, arch):
4✔
604
        '''Run any post-build tasks for the Recipe. By default, this checks if
605
        any postbuild_archname methods exist for the archname of the
606
        current architecture, and runs them if so.
607
        '''
608
        postbuild = "postbuild_{}".format(arch.arch)
4✔
609
        if hasattr(self, postbuild):
4!
610
            getattr(self, postbuild)()
×
611

612
        if self.need_stl_shared:
4!
613
            self.install_stl_lib(arch)
4✔
614

615
    def prepare_build_dir(self, arch):
4✔
616
        '''Copies the recipe data into a build dir for the given arch. By
617
        default, this unpacks a downloaded recipe. You should override
618
        it (or use a Recipe subclass with different behaviour) if you
619
        want to do something else.
620
        '''
621
        self.unpack(arch)
×
622

623
    def clean_build(self, arch=None):
4✔
624
        '''Deletes all the build information of the recipe.
625

626
        If arch is not None, only this arch dir is deleted. Otherwise
627
        (the default) all builds for all archs are deleted.
628

629
        By default, this just deletes the main build dir. If the
630
        recipe has e.g. object files biglinked, or .so files stored
631
        elsewhere, you should override this method.
632

633
        This method is intended for testing purposes, it may have
634
        strange results. Rebuild everything if this seems to happen.
635

636
        '''
637
        if arch is None:
×
638
            base_dir = join(self.ctx.build_dir, 'other_builds', self.name)
×
639
        else:
640
            base_dir = self.get_build_container_dir(arch)
×
641
        dirs = glob.glob(base_dir + '-*')
×
642
        if exists(base_dir):
×
643
            dirs.append(base_dir)
×
644
        if not dirs:
×
645
            warning('Attempted to clean build for {} but found no existing '
×
646
                    'build dirs'.format(self.name))
647

648
        for directory in dirs:
×
649
            rmdir(directory)
×
650

651
        # Delete any Python distributions to ensure the recipe build
652
        # doesn't persist in site-packages
653
        rmdir(self.ctx.python_installs_dir)
×
654

655
    def install_libs(self, arch, *libs):
4✔
656
        libs_dir = self.ctx.get_libs_dir(arch.arch)
4✔
657
        if not libs:
4!
658
            warning('install_libs called with no libraries to install!')
×
659
            return
×
660
        args = libs + (libs_dir,)
4✔
661
        shprint(sh.cp, *args)
4✔
662

663
    def has_libs(self, arch, *libs):
4✔
664
        return all(map(lambda lib: self.ctx.has_lib(arch.arch, lib), libs))
×
665

666
    def get_libraries(self, arch_name, in_context=False):
4✔
667
        """Return the full path of the library depending on the architecture.
668
        Per default, the build library path it will be returned, unless
669
        `get_libraries` has been called with kwarg `in_context` set to
670
        True.
671

672
        .. note:: this method should be used for library recipes only
673
        """
674
        recipe_libs = set()
4✔
675
        if not self.built_libraries:
4!
676
            return recipe_libs
×
677
        for lib, rel_path in self.built_libraries.items():
4✔
678
            if not in_context:
4!
679
                abs_path = join(self.get_build_dir(arch_name), rel_path, lib)
4✔
680
                if rel_path in {".", "", None}:
4✔
681
                    abs_path = join(self.get_build_dir(arch_name), lib)
4✔
682
            else:
683
                abs_path = join(self.ctx.get_libs_dir(arch_name), lib)
×
684
            recipe_libs.add(abs_path)
4✔
685
        return recipe_libs
4✔
686

687
    @classmethod
4✔
688
    def recipe_dirs(cls, ctx):
4✔
689
        recipe_dirs = []
4✔
690
        if ctx.local_recipes is not None:
4✔
691
            recipe_dirs.append(realpath(ctx.local_recipes))
4✔
692
        if ctx.storage_dir:
4✔
693
            recipe_dirs.append(join(ctx.storage_dir, 'recipes'))
4✔
694
        recipe_dirs.append(join(ctx.root_dir, "recipes"))
4✔
695
        return recipe_dirs
4✔
696

697
    @classmethod
4✔
698
    def list_recipes(cls, ctx):
4✔
699
        forbidden_dirs = ('__pycache__', )
4✔
700
        for recipes_dir in cls.recipe_dirs(ctx):
4✔
701
            if recipes_dir and exists(recipes_dir):
4✔
702
                for name in listdir(recipes_dir):
4✔
703
                    if name in forbidden_dirs:
4✔
704
                        continue
4✔
705
                    fn = join(recipes_dir, name)
4✔
706
                    if isdir(fn):
4✔
707
                        yield name
4✔
708

709
    @classmethod
4✔
710
    def get_recipe(cls, name, ctx):
4✔
711
        '''Returns the Recipe with the given name, if it exists.'''
712
        name = name.lower()
4✔
713
        if not hasattr(cls, "recipes"):
4✔
714
            cls.recipes = {}
4✔
715
        if name in cls.recipes:
4✔
716
            return cls.recipes[name]
4✔
717

718
        recipe_file = None
4✔
719
        for recipes_dir in cls.recipe_dirs(ctx):
4✔
720
            if not exists(recipes_dir):
4✔
721
                continue
4✔
722
            # Find matching folder (may differ in case):
723
            for subfolder in listdir(recipes_dir):
4✔
724
                if subfolder.lower() == name:
4✔
725
                    recipe_file = join(recipes_dir, subfolder, '__init__.py')
4✔
726
                    if exists(recipe_file):
4!
727
                        name = subfolder  # adapt to actual spelling
4✔
728
                        break
4✔
729
                    recipe_file = None
×
730
            if recipe_file is not None:
4✔
731
                break
4✔
732

733
        else:
734
            raise ValueError('Recipe does not exist: {}'.format(name))
4✔
735

736
        mod = import_recipe('pythonforandroid.recipes.{}'.format(name), recipe_file)
4✔
737
        if len(logger.handlers) > 1:
4!
738
            logger.removeHandler(logger.handlers[1])
×
739
        recipe = mod.recipe
4✔
740
        recipe.ctx = ctx
4✔
741
        cls.recipes[name.lower()] = recipe
4✔
742
        return recipe
4✔
743

744

745
class IncludedFilesBehaviour(object):
4✔
746
    '''Recipe mixin class that will automatically unpack files included in
747
    the recipe directory.'''
748
    src_filename = None
4✔
749

750
    def prepare_build_dir(self, arch):
4✔
751
        if self.src_filename is None:
×
752
            raise BuildInterruptingException(
×
753
                'IncludedFilesBehaviour failed: no src_filename specified')
754
        rmdir(self.get_build_dir(arch))
×
755
        shprint(sh.cp, '-a', join(self.get_recipe_dir(), self.src_filename),
×
756
                self.get_build_dir(arch))
757

758

759
class BootstrapNDKRecipe(Recipe):
4✔
760
    '''A recipe class for recipes built in an Android project jni dir with
761
    an Android.mk. These are not cached separately, but built in the
762
    bootstrap's own building directory.
763

764
    To build an NDK project which is not part of the bootstrap, see
765
    :class:`~pythonforandroid.recipe.NDKRecipe`.
766

767
    To link with python, call the method :meth:`get_recipe_env`
768
    with the kwarg *with_python=True*.
769
    '''
770

771
    dir_name = None  # The name of the recipe build folder in the jni dir
4✔
772

773
    def get_build_container_dir(self, arch):
4✔
774
        return self.get_jni_dir()
×
775

776
    def get_build_dir(self, arch):
4✔
777
        if self.dir_name is None:
×
778
            raise ValueError('{} recipe doesn\'t define a dir_name, but '
×
779
                             'this is necessary'.format(self.name))
780
        return join(self.get_build_container_dir(arch), self.dir_name)
×
781

782
    def get_jni_dir(self):
4✔
783
        return join(self.ctx.bootstrap.build_dir, 'jni')
4✔
784

785
    def get_recipe_env(self, arch=None, with_flags_in_cc=True, with_python=False):
4✔
786
        env = super().get_recipe_env(arch, with_flags_in_cc)
×
787
        if not with_python:
×
788
            return env
×
789

790
        env['PYTHON_INCLUDE_ROOT'] = self.ctx.python_recipe.include_root(arch.arch)
×
791
        env['PYTHON_LINK_ROOT'] = self.ctx.python_recipe.link_root(arch.arch)
×
792
        env['EXTRA_LDLIBS'] = ' -lpython{}'.format(
×
793
            self.ctx.python_recipe.link_version)
794
        return env
×
795

796

797
class NDKRecipe(Recipe):
4✔
798
    '''A recipe class for any NDK project not included in the bootstrap.'''
799

800
    generated_libraries = []
4✔
801

802
    def should_build(self, arch):
4✔
803
        lib_dir = self.get_lib_dir(arch)
×
804

805
        for lib in self.generated_libraries:
×
806
            if not exists(join(lib_dir, lib)):
×
807
                return True
×
808

809
        return False
×
810

811
    def get_lib_dir(self, arch):
4✔
812
        return join(self.get_build_dir(arch.arch), 'obj', 'local', arch.arch)
×
813

814
    def get_jni_dir(self, arch):
4✔
815
        return join(self.get_build_dir(arch.arch), 'jni')
×
816

817
    def build_arch(self, arch, *extra_args):
4✔
818
        super().build_arch(arch)
×
819

820
        env = self.get_recipe_env(arch)
×
821
        with current_directory(self.get_build_dir(arch.arch)):
×
822
            shprint(
×
823
                sh.Command(join(self.ctx.ndk_dir, "ndk-build")),
824
                'V=1',
825
                'NDK_DEBUG=' + ("1" if self.ctx.build_as_debuggable else "0"),
826
                'APP_PLATFORM=android-' + str(self.ctx.ndk_api),
827
                'APP_ABI=' + arch.arch,
828
                *extra_args, _env=env
829
            )
830

831

832
class PythonRecipe(Recipe):
4✔
833
    site_packages_name = None
4✔
834
    '''The name of the module's folder when installed in the Python
2✔
835
    site-packages (e.g. for pyjnius it is 'jnius')'''
836

837
    call_hostpython_via_targetpython = True
4✔
838
    '''If True, tries to install the module using the hostpython binary
2✔
839
    copied to the target (normally arm) python build dir. However, this
840
    will fail if the module tries to import e.g. _io.so. Set this to False
841
    to call hostpython from its own build dir, installing the module in
842
    the right place via arguments to setup.py. However, this may not set
843
    the environment correctly and so False is not the default.'''
844

845
    install_in_hostpython = False
4✔
846
    '''If True, additionally installs the module in the hostpython build
2✔
847
    dir. This will make it available to other recipes if
848
    call_hostpython_via_targetpython is False.
849
    '''
850

851
    install_in_targetpython = True
4✔
852
    '''If True, installs the module in the targetpython installation dir.
2✔
853
    This is almost always what you want to do.'''
854

855
    setup_extra_args = []
4✔
856
    '''List of extra arguments to pass to setup.py'''
2✔
857

858
    depends = ['python3']
4✔
859
    '''
2✔
860
    .. note:: it's important to keep this depends as a class attribute outside
861
              `__init__` because sometimes we only initialize the class, so the
862
              `__init__` call won't be called and the deps would be missing
863
              (which breaks the dependency graph computation)
864

865
    .. warning:: don't forget to call `super().__init__()` in any recipe's
866
                 `__init__`, or otherwise it may not be ensured that it depends
867
                 on python2 or python3 which can break the dependency graph
868
    '''
869

870
    hostpython_prerequisites = []
4✔
871
    '''List of hostpython packages required to build a recipe'''
2✔
872

873
    def __init__(self, *args, **kwargs):
4✔
874
        super().__init__(*args, **kwargs)
4✔
875
        if 'python3' not in self.depends:
4✔
876
            # We ensure here that the recipe depends on python even it overrode
877
            # `depends`. We only do this if it doesn't already depend on any
878
            # python, since some recipes intentionally don't depend on/work
879
            # with all python variants
880
            depends = self.depends
4✔
881
            depends.append('python3')
4✔
882
            depends = list(set(depends))
4✔
883
            self.depends = depends
4✔
884

885
    def clean_build(self, arch=None):
4✔
886
        super().clean_build(arch=arch)
×
887
        name = self.folder_name
×
888
        python_install_dirs = glob.glob(join(self.ctx.python_installs_dir, '*'))
×
889
        for python_install in python_install_dirs:
×
890
            site_packages_dir = glob.glob(join(python_install, 'lib', 'python*',
×
891
                                               'site-packages'))
892
            if site_packages_dir:
×
893
                build_dir = join(site_packages_dir[0], name)
×
894
                if exists(build_dir):
×
895
                    info('Deleted {}'.format(build_dir))
×
896
                    rmdir(build_dir)
×
897

898
    @property
4✔
899
    def real_hostpython_location(self):
4✔
900
        host_name = 'host{}'.format(self.ctx.python_recipe.name)
4✔
901
        if host_name == 'hostpython3':
4!
902
            python_recipe = Recipe.get_recipe(host_name, self.ctx)
4✔
903
            return python_recipe.python_exe
4✔
904
        else:
905
            python_recipe = self.ctx.python_recipe
×
906
            return 'python{}'.format(python_recipe.version)
×
907

908
    @property
4✔
909
    def hostpython_location(self):
4✔
910
        if not self.call_hostpython_via_targetpython:
4!
911
            return self.real_hostpython_location
4✔
912
        return self.ctx.hostpython
×
913

914
    @property
4✔
915
    def folder_name(self):
4✔
916
        '''The name of the build folders containing this recipe.'''
917
        name = self.site_packages_name
×
918
        if name is None:
×
919
            name = self.name
×
920
        return name
×
921

922
    def get_recipe_env(self, arch=None, with_flags_in_cc=True):
4✔
923
        env = super().get_recipe_env(arch, with_flags_in_cc)
4✔
924
        env['PYTHONNOUSERSITE'] = '1'
4✔
925
        # Set the LANG, this isn't usually important but is a better default
926
        # as it occasionally matters how Python e.g. reads files
927
        env['LANG'] = "en_GB.UTF-8"
4✔
928
        # Binaries made by packages installed by pip
929
        env["PATH"] = join(self.hostpython_site_dir, "bin") + ":" + env["PATH"]
4✔
930

931
        if not self.call_hostpython_via_targetpython:
4!
932
            env['CFLAGS'] += ' -I{}'.format(
4✔
933
                self.ctx.python_recipe.include_root(arch.arch)
934
            )
935
            env['LDFLAGS'] += ' -L{} -lpython{}'.format(
4✔
936
                self.ctx.python_recipe.link_root(arch.arch),
937
                self.ctx.python_recipe.link_version,
938
            )
939

940
            hppath = []
4✔
941
            hppath.append(join(dirname(self.hostpython_location), 'Lib'))
4✔
942
            hppath.append(join(hppath[0], 'site-packages'))
4✔
943
            builddir = join(dirname(self.hostpython_location), 'build')
4✔
944
            if exists(builddir):
4!
945
                hppath += [join(builddir, d) for d in listdir(builddir)
×
946
                           if isdir(join(builddir, d))]
947
            if len(hppath) > 0:
4!
948
                if 'PYTHONPATH' in env:
4!
949
                    env['PYTHONPATH'] = ':'.join(hppath + [env['PYTHONPATH']])
×
950
                else:
951
                    env['PYTHONPATH'] = ':'.join(hppath)
4✔
952
        return env
4✔
953

954
    def should_build(self, arch):
4✔
955
        name = self.folder_name
×
956
        if self.ctx.has_package(name, arch):
×
957
            info('Python package already exists in site-packages')
×
958
            return False
×
959
        info('{} apparently isn\'t already in site-packages'.format(name))
×
960
        return True
×
961

962
    def build_arch(self, arch):
4✔
963
        '''Install the Python module by calling setup.py install with
964
        the target Python dir.'''
965
        self.install_hostpython_prerequisites()
×
966
        super().build_arch(arch)
×
967
        self.install_python_package(arch)
×
968

969
    def install_python_package(self, arch, name=None, env=None, is_dir=True):
4✔
970
        '''Automate the installation of a Python package (or a cython
971
        package where the cython components are pre-built).'''
972
        # arch = self.filtered_archs[0]  # old kivy-ios way
973
        if name is None:
×
974
            name = self.name
×
975
        if env is None:
×
976
            env = self.get_recipe_env(arch)
×
977

978
        info('Installing {} into site-packages'.format(self.name))
×
979

980
        hostpython = sh.Command(self.hostpython_location)
×
981
        hpenv = env.copy()
×
982
        with current_directory(self.get_build_dir(arch.arch)):
×
983
            shprint(hostpython, 'setup.py', 'install', '-O2',
×
984
                    '--root={}'.format(self.ctx.get_python_install_dir(arch.arch)),
985
                    '--install-lib=.',
986
                    _env=hpenv, *self.setup_extra_args)
987

988
            # If asked, also install in the hostpython build dir
989
            if self.install_in_hostpython:
×
990
                self.install_hostpython_package(arch)
×
991

992
    def get_hostrecipe_env(self, arch):
4✔
993
        env = environ.copy()
×
994
        env['PYTHONPATH'] = self.hostpython_site_dir
×
995
        return env
×
996

997
    @property
4✔
998
    def hostpython_site_dir(self):
4✔
999
        return join(dirname(self.real_hostpython_location), 'Lib', 'site-packages')
4✔
1000

1001
    def install_hostpython_package(self, arch):
4✔
1002
        env = self.get_hostrecipe_env(arch)
×
1003
        real_hostpython = sh.Command(self.real_hostpython_location)
×
1004
        shprint(real_hostpython, 'setup.py', 'install', '-O2',
×
1005
                '--root={}'.format(dirname(self.real_hostpython_location)),
1006
                '--install-lib=Lib/site-packages',
1007
                _env=env, *self.setup_extra_args)
1008

1009
    @property
4✔
1010
    def python_major_minor_version(self):
4✔
1011
        parsed_version = packaging.version.parse(self.ctx.python_recipe.version)
×
1012
        return f"{parsed_version.major}.{parsed_version.minor}"
×
1013

1014
    def install_hostpython_prerequisites(self, packages=None, force_upgrade=True):
4✔
1015
        if not packages:
×
1016
            packages = self.hostpython_prerequisites
×
1017

1018
        if len(packages) == 0:
×
1019
            return
×
1020

1021
        pip_options = [
×
1022
            "install",
1023
            *packages,
1024
            "--target", self.hostpython_site_dir, "--python-version",
1025
            self.ctx.python_recipe.version,
1026
            # Don't use sources, instead wheels
1027
            "--only-binary=:all:",
1028
        ]
1029
        if force_upgrade:
×
1030
            pip_options.append("--upgrade")
×
1031
        # Use system's pip
1032
        shprint(sh.pip, *pip_options)
×
1033

1034
    def restore_hostpython_prerequisites(self, packages):
4✔
1035
        _packages = []
×
1036
        for package in packages:
×
1037
            original_version = Recipe.get_recipe(package, self.ctx).version
×
1038
            _packages.append(package + "==" + original_version)
×
1039
        self.install_hostpython_prerequisites(packages=_packages)
×
1040

1041

1042
class CompiledComponentsPythonRecipe(PythonRecipe):
4✔
1043
    pre_build_ext = False
4✔
1044

1045
    build_cmd = 'build_ext'
4✔
1046

1047
    def build_arch(self, arch):
4✔
1048
        '''Build any cython components, then install the Python module by
1049
        calling setup.py install with the target Python dir.
1050
        '''
1051
        Recipe.build_arch(self, arch)
×
1052
        self.install_hostpython_prerequisites()
×
1053
        self.build_compiled_components(arch)
×
1054
        self.install_python_package(arch)
×
1055

1056
    def build_compiled_components(self, arch):
4✔
1057
        info('Building compiled components in {}'.format(self.name))
×
1058

1059
        env = self.get_recipe_env(arch)
×
1060
        hostpython = sh.Command(self.hostpython_location)
×
1061
        with current_directory(self.get_build_dir(arch.arch)):
×
1062
            if self.install_in_hostpython:
×
1063
                shprint(hostpython, 'setup.py', 'clean', '--all', _env=env)
×
1064
            shprint(hostpython, 'setup.py', self.build_cmd, '-v',
×
1065
                    _env=env, *self.setup_extra_args)
1066
            build_dir = glob.glob('build/lib.*')[0]
×
1067
            shprint(sh.find, build_dir, '-name', '"*.o"', '-exec',
×
1068
                    env['STRIP'], '{}', ';', _env=env)
1069

1070
    def install_hostpython_package(self, arch):
4✔
1071
        env = self.get_hostrecipe_env(arch)
×
1072
        self.rebuild_compiled_components(arch, env)
×
1073
        super().install_hostpython_package(arch)
×
1074

1075
    def rebuild_compiled_components(self, arch, env):
4✔
1076
        info('Rebuilding compiled components in {}'.format(self.name))
×
1077

1078
        hostpython = sh.Command(self.real_hostpython_location)
×
1079
        shprint(hostpython, 'setup.py', 'clean', '--all', _env=env)
×
1080
        shprint(hostpython, 'setup.py', self.build_cmd, '-v', _env=env,
×
1081
                *self.setup_extra_args)
1082

1083

1084
class CppCompiledComponentsPythonRecipe(CompiledComponentsPythonRecipe):
4✔
1085
    """ Extensions that require the cxx-stl """
1086
    call_hostpython_via_targetpython = False
4✔
1087
    need_stl_shared = True
4✔
1088

1089

1090
class CythonRecipe(PythonRecipe):
4✔
1091
    pre_build_ext = False
4✔
1092
    cythonize = True
4✔
1093
    cython_args = []
4✔
1094
    call_hostpython_via_targetpython = False
4✔
1095

1096
    def build_arch(self, arch):
4✔
1097
        '''Build any cython components, then install the Python module by
1098
        calling setup.py install with the target Python dir.
1099
        '''
1100
        Recipe.build_arch(self, arch)
×
1101
        self.build_cython_components(arch)
×
1102
        self.install_python_package(arch)
×
1103

1104
    def build_cython_components(self, arch):
4✔
1105
        info('Cythonizing anything necessary in {}'.format(self.name))
×
1106

1107
        env = self.get_recipe_env(arch)
×
1108

1109
        with current_directory(self.get_build_dir(arch.arch)):
×
1110
            hostpython = sh.Command(self.ctx.hostpython)
×
1111
            shprint(hostpython, '-c', 'import sys; print(sys.path)', _env=env)
×
1112
            debug('cwd is {}'.format(realpath(curdir)))
×
1113
            info('Trying first build of {} to get cython files: this is '
×
1114
                 'expected to fail'.format(self.name))
1115

1116
            manually_cythonise = False
×
1117
            try:
×
1118
                shprint(hostpython, 'setup.py', 'build_ext', '-v', _env=env,
×
1119
                        *self.setup_extra_args)
1120
            except sh.ErrorReturnCode_1:
×
1121
                print()
×
1122
                info('{} first build failed (as expected)'.format(self.name))
×
1123
                manually_cythonise = True
×
1124

1125
            if manually_cythonise:
×
1126
                self.cythonize_build(env=env)
×
1127
                shprint(hostpython, 'setup.py', 'build_ext', '-v', _env=env,
×
1128
                        _tail=20, _critical=True, *self.setup_extra_args)
1129
            else:
1130
                info('First build appeared to complete correctly, skipping manual'
×
1131
                     'cythonising.')
1132

1133
            if not self.ctx.with_debug_symbols:
×
1134
                self.strip_object_files(arch, env)
×
1135

1136
    def strip_object_files(self, arch, env, build_dir=None):
4✔
1137
        if build_dir is None:
×
1138
            build_dir = self.get_build_dir(arch.arch)
×
1139
        with current_directory(build_dir):
×
1140
            info('Stripping object files')
×
1141
            shprint(sh.find, '.', '-iname', '*.so', '-exec',
×
1142
                    '/usr/bin/echo', '{}', ';', _env=env)
1143
            shprint(sh.find, '.', '-iname', '*.so', '-exec',
×
1144
                    env['STRIP'].split(' ')[0], '--strip-unneeded',
1145
                    # '/usr/bin/strip', '--strip-unneeded',
1146
                    '{}', ';', _env=env)
1147

1148
    def cythonize_file(self, env, build_dir, filename):
4✔
1149
        short_filename = filename
×
1150
        if filename.startswith(build_dir):
×
1151
            short_filename = filename[len(build_dir) + 1:]
×
1152
        info(u"Cythonize {}".format(short_filename))
×
1153
        cyenv = env.copy()
×
1154
        if 'CYTHONPATH' in cyenv:
×
1155
            cyenv['PYTHONPATH'] = cyenv['CYTHONPATH']
×
1156
        elif 'PYTHONPATH' in cyenv:
×
1157
            del cyenv['PYTHONPATH']
×
1158
        if 'PYTHONNOUSERSITE' in cyenv:
×
1159
            cyenv.pop('PYTHONNOUSERSITE')
×
1160
        python_command = sh.Command("python{}".format(
×
1161
            self.ctx.python_recipe.major_minor_version_string.split(".")[0]
1162
        ))
1163
        shprint(python_command, "-c"
×
1164
                "import sys; from Cython.Compiler.Main import setuptools_main; sys.exit(setuptools_main());",
1165
                filename, *self.cython_args, _env=cyenv)
1166

1167
    def cythonize_build(self, env, build_dir="."):
4✔
1168
        if not self.cythonize:
×
1169
            info('Running cython cancelled per recipe setting')
×
1170
            return
×
1171
        info('Running cython where appropriate')
×
1172
        for root, dirnames, filenames in walk("."):
×
1173
            for filename in fnmatch.filter(filenames, "*.pyx"):
×
1174
                self.cythonize_file(env, build_dir, join(root, filename))
×
1175

1176
    def get_recipe_env(self, arch, with_flags_in_cc=True):
4✔
1177
        env = super().get_recipe_env(arch, with_flags_in_cc)
×
1178
        env['LDFLAGS'] = env['LDFLAGS'] + ' -L{} '.format(
×
1179
            self.ctx.get_libs_dir(arch.arch) +
1180
            ' -L{} '.format(self.ctx.libs_dir) +
1181
            ' -L{}'.format(join(self.ctx.bootstrap.build_dir, 'obj', 'local',
1182
                                arch.arch)))
1183

1184
        env['LDSHARED'] = env['CC'] + ' -shared'
×
1185
        # shprint(sh.whereis, env['LDSHARED'], _env=env)
1186
        env['LIBLINK'] = 'NOTNONE'
×
1187
        if self.ctx.copy_libs:
×
1188
            env['COPYLIBS'] = '1'
×
1189

1190
        # Every recipe uses its own liblink path, object files are
1191
        # collected and biglinked later
1192
        liblink_path = join(self.get_build_container_dir(arch.arch),
×
1193
                            'objects_{}'.format(self.name))
1194
        env['LIBLINK_PATH'] = liblink_path
×
1195
        ensure_dir(liblink_path)
×
1196

1197
        return env
×
1198

1199

1200
class PyProjectRecipe(PythonRecipe):
4✔
1201
    """Recipe for projects which contain `pyproject.toml`"""
1202

1203
    # Extra args to pass to `python -m build ...`
1204
    extra_build_args = []
4✔
1205
    call_hostpython_via_targetpython = False
4✔
1206

1207
    def get_recipe_env(self, arch, **kwargs):
4✔
1208
        # Custom hostpython
1209
        self.ctx.python_recipe.python_exe = join(
4✔
1210
            self.ctx.python_recipe.get_build_dir(arch), "android-build", "python3")
1211
        env = super().get_recipe_env(arch, **kwargs)
4✔
1212
        build_dir = self.get_build_dir(arch)
4✔
1213
        ensure_dir(build_dir)
4✔
1214
        build_opts = join(build_dir, "build-opts.cfg")
4✔
1215

1216
        with open(build_opts, "w") as file:
4✔
1217
            file.write("[bdist_wheel]\nplat-name={}".format(
4✔
1218
                self.get_wheel_platform_tag(arch)
1219
            ))
1220
            file.close()
4✔
1221

1222
        env["DIST_EXTRA_CONFIG"] = build_opts
4✔
1223
        return env
4✔
1224

1225
    def get_wheel_platform_tag(self, arch):
4✔
1226
        return "android_" + {
4✔
1227
            "armeabi-v7a": "arm",
1228
            "arm64-v8a": "aarch64",
1229
            "x86_64": "x86_64",
1230
            "x86": "i686",
1231
        }[arch.arch]
1232

1233
    def install_wheel(self, arch, built_wheels):
4✔
1234
        with patch_wheel_setuptools_logging():
×
1235
            from wheel.cli.tags import tags as wheel_tags
×
1236
            from wheel.wheelfile import WheelFile
×
1237
        _wheel = built_wheels[0]
×
1238
        built_wheel_dir = dirname(_wheel)
×
1239
        # Fix wheel platform tag
1240
        wheel_tag = wheel_tags(
×
1241
            _wheel,
1242
            platform_tags=self.get_wheel_platform_tag(arch),
1243
            remove=True,
1244
        )
1245
        selected_wheel = join(built_wheel_dir, wheel_tag)
×
1246

1247
        _dev_wheel_dir = environ.get("P4A_WHEEL_DIR", False)
×
1248
        if _dev_wheel_dir:
×
1249
            ensure_dir(_dev_wheel_dir)
×
1250
            shprint(sh.cp, selected_wheel, _dev_wheel_dir)
×
1251

1252
        info(f"Installing built wheel: {wheel_tag}")
×
1253
        destination = self.ctx.get_python_install_dir(arch.arch)
×
1254
        with WheelFile(selected_wheel) as wf:
×
1255
            for zinfo in wf.filelist:
×
1256
                wf.extract(zinfo, destination)
×
1257
            wf.close()
×
1258

1259
    def build_arch(self, arch):
4✔
1260
        self.install_hostpython_prerequisites(
×
1261
            packages=["build[virtualenv]", "pip"] + self.hostpython_prerequisites
1262
        )
1263
        build_dir = self.get_build_dir(arch.arch)
×
1264
        env = self.get_recipe_env(arch, with_flags_in_cc=True)
×
1265
        # make build dir separately
1266
        sub_build_dir = join(build_dir, "p4a_android_build")
×
1267
        ensure_dir(sub_build_dir)
×
1268
        # copy hostpython to built python to ensure correct selection of libs and includes
1269
        shprint(sh.cp, self.real_hostpython_location, self.ctx.python_recipe.python_exe)
×
1270

1271
        build_args = [
×
1272
            "-m",
1273
            "build",
1274
            "--wheel",
1275
            "--config-setting",
1276
            "builddir={}".format(sub_build_dir),
1277
        ] + self.extra_build_args
1278

1279
        built_wheels = []
×
1280
        with current_directory(build_dir):
×
1281
            shprint(
×
1282
                sh.Command(self.ctx.python_recipe.python_exe), *build_args, _env=env
1283
            )
1284
            built_wheels = [realpath(whl) for whl in glob.glob("dist/*.whl")]
×
1285
        self.install_wheel(arch, built_wheels)
×
1286

1287

1288
class MesonRecipe(PyProjectRecipe):
4✔
1289
    '''Recipe for projects which uses meson as build system'''
1290

1291
    meson_version = "1.4.0"
4✔
1292
    ninja_version = "1.11.1.1"
4✔
1293

1294
    def sanitize_flags(self, *flag_strings):
4✔
1295
        return " ".join(flag_strings).strip().split(" ")
×
1296

1297
    def get_recipe_meson_options(self, arch):
4✔
1298
        env = self.get_recipe_env(arch, with_flags_in_cc=True)
×
1299
        return {
×
1300
            "binaries": {
1301
                "c": arch.get_clang_exe(with_target=True),
1302
                "cpp": arch.get_clang_exe(with_target=True, plus_plus=True),
1303
                "ar": self.ctx.ndk.llvm_ar,
1304
                "strip": self.ctx.ndk.llvm_strip,
1305
            },
1306
            "built-in options": {
1307
                "c_args": self.sanitize_flags(env["CFLAGS"], env["CPPFLAGS"]),
1308
                "cpp_args": self.sanitize_flags(env["CXXFLAGS"], env["CPPFLAGS"]),
1309
                "c_link_args": self.sanitize_flags(env["LDFLAGS"]),
1310
                "cpp_link_args": self.sanitize_flags(env["LDFLAGS"]),
1311
            },
1312
            "properties": {
1313
                "needs_exe_wrapper": True,
1314
                "sys_root": self.ctx.ndk.sysroot
1315
            },
1316
            "host_machine": {
1317
                "cpu_family": {
1318
                    "arm64-v8a": "aarch64",
1319
                    "armeabi-v7a": "arm",
1320
                    "x86_64": "x86_64",
1321
                    "x86": "x86"
1322
                }[arch.arch],
1323
                "cpu": {
1324
                    "arm64-v8a": "aarch64",
1325
                    "armeabi-v7a": "armv7",
1326
                    "x86_64": "x86_64",
1327
                    "x86": "i686"
1328
                }[arch.arch],
1329
                "endian": "little",
1330
                "system": "android",
1331
            }
1332
        }
1333

1334
    def write_build_options(self, arch):
4✔
1335
        """Writes python dict to meson config file"""
1336
        option_data = ""
×
1337
        build_options = self.get_recipe_meson_options(arch)
×
1338
        for key in build_options.keys():
×
1339
            data_chunk = "[{}]".format(key)
×
1340
            for subkey in build_options[key].keys():
×
1341
                value = build_options[key][subkey]
×
1342
                if isinstance(value, int):
×
1343
                    value = str(value)
×
1344
                elif isinstance(value, str):
×
1345
                    value = "'{}'".format(value)
×
1346
                elif isinstance(value, bool):
×
1347
                    value = "true" if value else "false"
×
1348
                elif isinstance(value, list):
×
1349
                    value = "['" + "', '".join(value) + "']"
×
1350
                data_chunk += "\n" + subkey + " = " + value
×
1351
            option_data += data_chunk + "\n\n"
×
1352
        return option_data
×
1353

1354
    def ensure_args(self, *args):
4✔
1355
        for arg in args:
×
1356
            if arg not in self.extra_build_args:
×
1357
                self.extra_build_args.append(arg)
×
1358

1359
    def build_arch(self, arch):
4✔
1360
        cross_file = join("/tmp", "android.meson.cross")
×
1361
        info("Writing cross file at: {}".format(cross_file))
×
1362
        # write cross config file
1363
        with open(cross_file, "w") as file:
×
1364
            file.write(self.write_build_options(arch))
×
1365
            file.close()
×
1366
        # set cross file
1367
        self.ensure_args('-Csetup-args=--cross-file', '-Csetup-args={}'.format(cross_file))
×
1368
        # ensure ninja and meson
1369
        for dep in [
×
1370
            "ninja=={}".format(self.ninja_version),
1371
            "meson=={}".format(self.meson_version),
1372
        ]:
1373
            if dep not in self.hostpython_prerequisites:
×
1374
                self.hostpython_prerequisites.append(dep)
×
1375
        super().build_arch(arch)
×
1376

1377

1378
class RustCompiledComponentsRecipe(PyProjectRecipe):
4✔
1379
    # Rust toolchain codes
1380
    # https://doc.rust-lang.org/nightly/rustc/platform-support.html
1381
    RUST_ARCH_CODES = {
4✔
1382
        "arm64-v8a": "aarch64-linux-android",
1383
        "armeabi-v7a": "armv7-linux-androideabi",
1384
        "x86_64": "x86_64-linux-android",
1385
        "x86": "i686-linux-android",
1386
    }
1387

1388
    call_hostpython_via_targetpython = False
4✔
1389

1390
    def get_recipe_env(self, arch, **kwargs):
4✔
1391
        env = super().get_recipe_env(arch, **kwargs)
×
1392

1393
        # Set rust build target
1394
        build_target = self.RUST_ARCH_CODES[arch.arch]
×
1395
        cargo_linker_name = "CARGO_TARGET_{}_LINKER".format(
×
1396
            build_target.upper().replace("-", "_")
1397
        )
1398
        env["CARGO_BUILD_TARGET"] = build_target
×
1399
        env[cargo_linker_name] = join(
×
1400
            self.ctx.ndk.llvm_prebuilt_dir,
1401
            "bin",
1402
            "{}{}-clang".format(
1403
                # NDK's Clang format
1404
                build_target.replace("7", "7a")
1405
                if build_target.startswith("armv7")
1406
                else build_target,
1407
                self.ctx.ndk_api,
1408
            ),
1409
        )
1410
        realpython_dir = self.ctx.python_recipe.get_build_dir(arch.arch)
×
1411

1412
        env["RUSTFLAGS"] = "-Clink-args=-L{} -L{}".format(
×
1413
            self.ctx.get_libs_dir(arch.arch), join(realpython_dir, "android-build")
1414
        )
1415

1416
        env["PYO3_CROSS_LIB_DIR"] = realpath(glob.glob(join(
×
1417
            realpython_dir, "android-build", "build",
1418
            "lib.linux-*-{}/".format(self.python_major_minor_version),
1419
        ))[0])
1420

1421
        info_main("Ensuring rust build toolchain")
×
1422
        shprint(sh.rustup, "target", "add", build_target)
×
1423

1424
        # Add host python to PATH
1425
        env["PATH"] = ("{hostpython_dir}:{old_path}").format(
×
1426
            hostpython_dir=Recipe.get_recipe(
1427
                "hostpython3", self.ctx
1428
            ).get_path_to_python(),
1429
            old_path=env["PATH"],
1430
        )
1431
        return env
×
1432

1433
    def check_host_deps(self):
4✔
1434
        if not hasattr(sh, "rustup"):
×
1435
            error(
×
1436
                "`rustup` was not found on host system."
1437
                "Please install it using :"
1438
                "\n`curl https://sh.rustup.rs -sSf | sh`\n"
1439
            )
1440
            exit(1)
×
1441

1442
    def build_arch(self, arch):
4✔
1443
        self.check_host_deps()
×
1444
        super().build_arch(arch)
×
1445

1446

1447
class TargetPythonRecipe(Recipe):
4✔
1448
    '''Class for target python recipes. Sets ctx.python_recipe to point to
1449
    itself, so as to know later what kind of Python was built or used.'''
1450

1451
    def __init__(self, *args, **kwargs):
4✔
1452
        self._ctx = None
4✔
1453
        super().__init__(*args, **kwargs)
4✔
1454

1455
    def prebuild_arch(self, arch):
4✔
1456
        super().prebuild_arch(arch)
×
1457
        self.ctx.python_recipe = self
×
1458

1459
    def include_root(self, arch):
4✔
1460
        '''The root directory from which to include headers.'''
1461
        raise NotImplementedError('Not implemented in TargetPythonRecipe')
1462

1463
    def link_root(self):
4✔
1464
        raise NotImplementedError('Not implemented in TargetPythonRecipe')
1465

1466
    @property
4✔
1467
    def major_minor_version_string(self):
4✔
1468
        parsed_version = packaging.version.parse(self.version)
4✔
1469
        return f"{parsed_version.major}.{parsed_version.minor}"
4✔
1470

1471
    def create_python_bundle(self, dirn, arch):
4✔
1472
        """
1473
        Create a packaged python bundle in the target directory, by
1474
        copying all the modules and standard library to the right
1475
        place.
1476
        """
1477
        raise NotImplementedError('{} does not implement create_python_bundle'.format(self))
1478

1479
    def reduce_object_file_names(self, dirn):
4✔
1480
        """Recursively renames all files named XXX.cpython-...-linux-gnu.so"
1481
        to "XXX.so", i.e. removing the erroneous architecture name
1482
        coming from the local system.
1483
        """
1484
        py_so_files = shprint(sh.find, dirn, '-iname', '*.so')
4✔
1485
        filens = py_so_files.stdout.decode('utf-8').split('\n')[:-1]
4✔
1486
        for filen in filens:
4!
1487
            file_dirname, file_basename = split(filen)
×
1488
            parts = file_basename.split('.')
×
1489
            if len(parts) <= 2:
×
1490
                continue
×
1491
            # PySide6 libraries end with .abi3.so
1492
            if parts[1] == "abi3":
×
1493
                continue
×
1494
            move(filen, join(file_dirname, parts[0] + '.so'))
×
1495

1496

1497
def algsum(alg, filen):
4✔
1498
    '''Calculate the digest of a file.
1499
    '''
1500
    with open(filen, 'rb') as fileh:
×
1501
        digest = getattr(hashlib, alg)(fileh.read())
×
1502

1503
    return digest.hexdigest()
×
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc