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

kivy / python-for-android / 10347694551

12 Aug 2024 07:28AM UTC coverage: 59.07% (-0.07%) from 59.14%
10347694551

push

github

AndreMiras
:arrow_up: Bump to sh 2

1048 of 2361 branches covered (44.39%)

Branch coverage included in aggregate %.

0 of 4 new or added lines in 2 files covered. (0.0%)

6 existing lines in 3 files now uncovered.

4862 of 7644 relevant lines covered (63.61%)

2.53 hits per line

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

45.33
/pythonforandroid/recipe.py
1
from os.path import basename, dirname, exists, isdir, isfile, join, realpath, split
4✔
2
import glob
4✔
3

4
import hashlib
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
from wheel.wheelfile import WheelFile
4✔
16
from wheel.cli.tags import tags as wheel_tags
4✔
17
import time
4✔
18
try:
4✔
19
    from urlparse import urlparse
4✔
20
except ImportError:
4✔
21
    from urllib.parse import urlparse
4✔
22

23
import packaging.version
4✔
24

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

32

33
url_opener = urllib.request.build_opener()
4✔
34
url_orig_headers = url_opener.addheaders
4✔
35
urllib.request.install_opener(url_opener)
4✔
36

37

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

46
        return super().__new__(cls, name, bases, dct)
4✔
47

48

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

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

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

64
    _version = None
4✔
65
    '''A string giving the version of the software the recipe describes,
2✔
66
    e.g. ``2.0.3`` or ``master``.'''
67

68
    md5sum = None
4✔
69
    '''The md5sum of the source from the :attr:`url`. Non-essential, but
2✔
70
    you should try to include this, it is used to check that the download
71
    finished correctly.
72
    '''
73

74
    sha512sum = None
4✔
75
    '''The sha512sum of the source from the :attr:`url`. Non-essential, but
2✔
76
    you should try to include this, it is used to check that the download
77
    finished correctly.
78
    '''
79

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

86
    depends = []
4✔
87
    '''A list containing the names of any recipes that this recipe depends on.
2✔
88
    '''
89

90
    conflicts = []
4✔
91
    '''A list containing the names of any recipes that are known to be
2✔
92
    incompatible with this one.'''
93

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

98
    patches = []
4✔
99
    '''A list of patches to apply to the source. Values can be either a string
2✔
100
    referring to the patch file relative to the recipe dir, or a tuple of the
101
    string patch file and a callable, which will receive the kwargs `arch` and
102
    `recipe`, which should return True if the patch should be applied.'''
103

104
    python_depends = []
4✔
105
    '''A list of pure-Python packages that this package requires. These
2✔
106
    packages will NOT be available at build time, but will be added to the
107
    list of pure-Python packages to install via pip. If you need these packages
108
    at build time, you must create a recipe.'''
109

110
    archs = ['armeabi']  # Not currently implemented properly
4✔
111

112
    built_libraries = {}
4✔
113
    """Each recipe that builds a system library (e.g.:libffi, openssl, etc...)
2✔
114
    should contain a dict holding the relevant information of the library. The
115
    keys should be the generated libraries and the values the relative path of
116
    the library inside his build folder. This dict will be used to perform
117
    different operations:
118
        - copy the library into the right location, depending on if it's shared
119
          or static)
120
        - check if we have to rebuild the library
121

122
    Here an example of how it would look like for `libffi` recipe:
123

124
        - `built_libraries = {'libffi.so': '.libs'}`
125

126
    .. note:: in case that the built library resides in recipe's build
127
              directory, you can set the following values for the relative
128
              path: `'.', None or ''`
129
    """
130

131
    need_stl_shared = False
4✔
132
    '''Some libraries or python packages may need the c++_shared in APK.
2✔
133
    We can automatically do this for any recipe if we set this property to
134
    `True`'''
135

136
    stl_lib_name = 'c++_shared'
4✔
137
    '''
2✔
138
    The default STL shared lib to use: `c++_shared`.
139

140
    .. note:: Android NDK version > 17 only supports 'c++_shared', because
141
        starting from NDK r18 the `gnustl_shared` lib has been deprecated.
142
    '''
143

144
    def get_stl_library(self, arch):
4✔
145
        return join(
4✔
146
            arch.ndk_lib_dir,
147
            'lib{name}.so'.format(name=self.stl_lib_name),
148
        )
149

150
    def install_stl_lib(self, arch):
4✔
151
        if not self.ctx.has_lib(
4!
152
            arch.arch, 'lib{name}.so'.format(name=self.stl_lib_name)
153
        ):
154
            self.install_libs(arch, self.get_stl_library(arch))
4✔
155

156
    @property
4✔
157
    def version(self):
4✔
158
        key = 'VERSION_' + self.name
4✔
159
        return environ.get(key, self._version)
4✔
160

161
    @property
4✔
162
    def url(self):
4✔
163
        key = 'URL_' + self.name
4✔
164
        return environ.get(key, self._url)
4✔
165

166
    @property
4✔
167
    def versioned_url(self):
4✔
168
        '''A property returning the url of the recipe with ``{version}``
169
        replaced by the :attr:`url`. If accessing the url, you should use this
170
        property, *not* access the url directly.'''
171
        if self.url is None:
4!
172
            return None
×
173
        return self.url.format(version=self.version)
4✔
174

175
    def download_file(self, url, target, cwd=None):
4✔
176
        """
177
        (internal) Download an ``url`` to a ``target``.
178
        """
179
        if not url:
4!
180
            return
×
181

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

184
        if cwd:
4!
185
            target = join(cwd, target)
×
186

187
        parsed_url = urlparse(url)
4✔
188
        if parsed_url.scheme in ('http', 'https'):
4!
189
            def report_hook(index, blksize, size):
4✔
190
                if size <= 0:
×
191
                    progression = '{0} bytes'.format(index * blksize)
×
192
                else:
193
                    progression = '{0:.2f}%'.format(
×
194
                        index * blksize * 100. / float(size))
195
                if "CI" not in environ:
×
196
                    stdout.write('- Download {}\r'.format(progression))
×
197
                    stdout.flush()
×
198

199
            if exists(target):
4!
200
                unlink(target)
×
201

202
            # Download item with multiple attempts (for bad connections):
203
            attempts = 0
4✔
204
            seconds = 1
4✔
205
            while True:
2✔
206
                try:
4✔
207
                    # jqueryui.com returns a 403 w/ the default user agent
208
                    # Mozilla/5.0 doesnt handle redirection for liblzma
209
                    url_opener.addheaders = [('User-agent', 'Wget/1.0')]
4✔
210
                    urlretrieve(url, target, report_hook)
4✔
211
                except OSError as e:
4✔
212
                    attempts += 1
4✔
213
                    if attempts >= 5:
4✔
214
                        raise
4✔
215
                    stdout.write('Download failed: {}; retrying in {} second(s)...'.format(e, seconds))
4✔
216
                    time.sleep(seconds)
4✔
217
                    seconds *= 2
4✔
218
                    continue
4✔
219
                finally:
220
                    url_opener.addheaders = url_orig_headers
4✔
221
                break
3✔
222
            return target
4✔
223
        elif parsed_url.scheme in ('git', 'git+file', 'git+ssh', 'git+http', 'git+https'):
×
224
            if not isdir(target):
×
225
                if url.startswith('git+'):
×
226
                    url = url[4:]
×
227
                # if 'version' is specified, do a shallow clone
228
                if self.version:
×
229
                    ensure_dir(target)
×
230
                    with current_directory(target):
×
231
                        shprint(sh.git, 'init')
×
232
                        shprint(sh.git, 'remote', 'add', 'origin', url)
×
233
                else:
234
                    shprint(sh.git, 'clone', '--recursive', url, target)
×
235
            with current_directory(target):
×
236
                if self.version:
×
237
                    shprint(sh.git, 'fetch', '--tags', '--depth', '1')
×
238
                    shprint(sh.git, 'checkout', self.version)
×
239
                branch = sh.git('branch', '--show-current')
×
240
                if branch:
×
241
                    shprint(sh.git, 'pull')
×
242
                    shprint(sh.git, 'pull', '--recurse-submodules')
×
243
                shprint(sh.git, 'submodule', 'update', '--recursive', '--init', '--depth', '1')
×
244
            return target
×
245

246
    def apply_patch(self, filename, arch, build_dir=None):
4✔
247
        """
248
        Apply a patch from the current recipe directory into the current
249
        build directory.
250

251
        .. versionchanged:: 0.6.0
252
            Add ability to apply patch from any dir via kwarg `build_dir`'''
253
        """
254
        info("Applying patch {}".format(filename))
4✔
255
        build_dir = build_dir if build_dir else self.get_build_dir(arch)
4✔
256
        filename = join(self.get_recipe_dir(), filename)
4✔
257
        shprint(sh.patch, "-t", "-d", build_dir, "-p1",
4✔
258
                "-i", filename, _tail=10)
259

260
    def copy_file(self, filename, dest):
4✔
261
        info("Copy {} to {}".format(filename, dest))
×
262
        filename = join(self.get_recipe_dir(), filename)
×
263
        dest = join(self.build_dir, dest)
×
264
        shutil.copy(filename, dest)
×
265

266
    def append_file(self, filename, dest):
4✔
267
        info("Append {} to {}".format(filename, dest))
×
268
        filename = join(self.get_recipe_dir(), filename)
×
269
        dest = join(self.build_dir, dest)
×
270
        with open(filename, "rb") as fd:
×
271
            data = fd.read()
×
272
        with open(dest, "ab") as fd:
×
273
            fd.write(data)
×
274

275
    @property
4✔
276
    def name(self):
4✔
277
        '''The name of the recipe, the same as the folder containing it.'''
278
        modname = self.__class__.__module__
4✔
279
        return modname.split(".", 2)[-1]
4✔
280

281
    @property
4✔
282
    def filtered_archs(self):
4✔
283
        '''Return archs of self.ctx that are valid build archs
284
        for the Recipe.'''
285
        result = []
×
286
        for arch in self.ctx.archs:
×
287
            if not self.archs or (arch.arch in self.archs):
×
288
                result.append(arch)
×
289
        return result
×
290

291
    def check_recipe_choices(self):
4✔
292
        '''Checks what recipes are being built to see which of the alternative
293
        and optional dependencies are being used,
294
        and returns a list of these.'''
295
        recipes = []
4✔
296
        built_recipes = self.ctx.recipe_build_order
4✔
297
        for recipe in self.depends:
4✔
298
            if isinstance(recipe, (tuple, list)):
4!
299
                for alternative in recipe:
×
300
                    if alternative in built_recipes:
×
301
                        recipes.append(alternative)
×
302
                        break
×
303
        for recipe in self.opt_depends:
4✔
304
            if recipe in built_recipes:
4!
305
                recipes.append(recipe)
×
306
        return sorted(recipes)
4✔
307

308
    def get_opt_depends_in_list(self, recipes):
4✔
309
        '''Given a list of recipe names, returns those that are also in
310
        self.opt_depends.
311
        '''
312
        return [recipe for recipe in recipes if recipe in self.opt_depends]
4✔
313

314
    def get_build_container_dir(self, arch):
4✔
315
        '''Given the arch name, returns the directory where it will be
316
        built.
317

318
        This returns a different directory depending on what
319
        alternative or optional dependencies are being built.
320
        '''
321
        dir_name = self.get_dir_name()
4✔
322
        return join(self.ctx.build_dir, 'other_builds',
4✔
323
                    dir_name, '{}__ndk_target_{}'.format(arch, self.ctx.ndk_api))
324

325
    def get_dir_name(self):
4✔
326
        choices = self.check_recipe_choices()
4✔
327
        dir_name = '-'.join([self.name] + choices)
4✔
328
        return dir_name
4✔
329

330
    def get_build_dir(self, arch):
4✔
331
        '''Given the arch name, returns the directory where the
332
        downloaded/copied package will be built.'''
333

334
        return join(self.get_build_container_dir(arch), self.name)
4✔
335

336
    def get_recipe_dir(self):
4✔
337
        """
338
        Returns the local recipe directory or defaults to the core recipe
339
        directory.
340
        """
341
        if self.ctx.local_recipes is not None:
4!
342
            local_recipe_dir = join(self.ctx.local_recipes, self.name)
×
343
            if exists(local_recipe_dir):
×
344
                return local_recipe_dir
×
345
        return join(self.ctx.root_dir, 'recipes', self.name)
4✔
346

347
    # Public Recipe API to be subclassed if needed
348

349
    def download_if_necessary(self):
4✔
350
        info_main('Downloading {}'.format(self.name))
4✔
351
        user_dir = environ.get('P4A_{}_DIR'.format(self.name.lower()))
4✔
352
        if user_dir is not None:
4✔
353
            info('P4A_{}_DIR is set, skipping download for {}'.format(
4✔
354
                self.name, self.name))
355
            return
4✔
356
        self.download()
4✔
357

358
    def download(self):
4✔
359
        if self.url is None:
4✔
360
            info('Skipping {} download as no URL is set'.format(self.name))
4✔
361
            return
4✔
362

363
        url = self.versioned_url
4✔
364
        expected_digests = {}
4✔
365
        for alg in set(hashlib.algorithms_guaranteed) | set(('md5', 'sha512', 'blake2b')):
4✔
366
            expected_digest = getattr(self, alg + 'sum') if hasattr(self, alg + 'sum') else None
4✔
367
            ma = match(u'^(.+)#' + alg + u'=([0-9a-f]{32,})$', url)
4✔
368
            if ma:                # fragmented URL?
4!
369
                if expected_digest:
×
370
                    raise ValueError(
×
371
                        ('Received {}sum from both the {} recipe '
372
                         'and its url').format(alg, self.name))
373
                url = ma.group(1)
×
374
                expected_digest = ma.group(2)
×
375
            if expected_digest:
4!
376
                expected_digests[alg] = expected_digest
×
377

378
        ensure_dir(join(self.ctx.packages_path, self.name))
4✔
379

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

383
            do_download = True
4✔
384
            marker_filename = '.mark-{}'.format(filename)
4✔
385
            if exists(filename) and isfile(filename):
4!
386
                if not exists(marker_filename):
×
387
                    shprint(sh.rm, filename)
×
388
                else:
389
                    for alg, expected_digest in expected_digests.items():
×
390
                        current_digest = algsum(alg, filename)
×
391
                        if current_digest != expected_digest:
×
392
                            debug('* Generated {}sum: {}'.format(alg,
×
393
                                                                 current_digest))
394
                            debug('* Expected {}sum: {}'.format(alg,
×
395
                                                                expected_digest))
396
                            raise ValueError(
×
397
                                ('Generated {0}sum does not match expected {0}sum '
398
                                 'for {1} recipe').format(alg, self.name))
399
                    do_download = False
×
400

401
            # If we got this far, we will download
402
            if do_download:
4!
403
                debug('Downloading {} from {}'.format(self.name, url))
4✔
404

405
                shprint(sh.rm, '-f', marker_filename)
4✔
406
                self.download_file(self.versioned_url, filename)
4✔
407
                touch(marker_filename)
4✔
408

409
                if exists(filename) and isfile(filename):
4!
410
                    for alg, expected_digest in expected_digests.items():
×
411
                        current_digest = algsum(alg, filename)
×
412
                        if current_digest != expected_digest:
×
413
                            debug('* Generated {}sum: {}'.format(alg,
×
414
                                                                 current_digest))
415
                            debug('* Expected {}sum: {}'.format(alg,
×
416
                                                                expected_digest))
417
                            raise ValueError(
×
418
                                ('Generated {0}sum does not match expected {0}sum '
419
                                 'for {1} recipe').format(alg, self.name))
420
            else:
421
                info('{} download already cached, skipping'.format(self.name))
×
422

423
    def unpack(self, arch):
4✔
424
        info_main('Unpacking {} for {}'.format(self.name, arch))
×
425

426
        build_dir = self.get_build_container_dir(arch)
×
427

428
        user_dir = environ.get('P4A_{}_DIR'.format(self.name.lower()))
×
429
        if user_dir is not None:
×
430
            info('P4A_{}_DIR exists, symlinking instead'.format(
×
431
                self.name.lower()))
432
            if exists(self.get_build_dir(arch)):
×
433
                return
×
434
            rmdir(build_dir)
×
435
            ensure_dir(build_dir)
×
436
            shprint(sh.cp, '-a', user_dir, self.get_build_dir(arch))
×
437
            return
×
438

439
        if self.url is None:
×
440
            info('Skipping {} unpack as no URL is set'.format(self.name))
×
441
            return
×
442

443
        filename = shprint(
×
444
            sh.basename, self.versioned_url).stdout[:-1].decode('utf-8')
445
        ma = match(u'^(.+)#[a-z0-9_]{3,}=([0-9a-f]{32,})$', filename)
×
446
        if ma:                  # fragmented URL?
×
447
            filename = ma.group(1)
×
448

449
        with current_directory(build_dir):
×
450
            directory_name = self.get_build_dir(arch)
×
451

452
            if not exists(directory_name) or not isdir(directory_name):
×
453
                extraction_filename = join(
×
454
                    self.ctx.packages_path, self.name, filename)
455
                if isfile(extraction_filename):
×
456
                    if extraction_filename.endswith(('.zip', '.whl')):
×
457
                        try:
×
458
                            sh.unzip(extraction_filename)
×
459
                        except (sh.ErrorReturnCode_1, sh.ErrorReturnCode_2):
×
460
                            # return code 1 means unzipping had
461
                            # warnings but did complete,
462
                            # apparently happens sometimes with
463
                            # github zips
464
                            pass
×
465
                        fileh = zipfile.ZipFile(extraction_filename, 'r')
×
466
                        root_directory = fileh.filelist[0].filename.split('/')[0]
×
467
                        if root_directory != basename(directory_name):
×
468
                            move(root_directory, directory_name)
×
469
                    elif extraction_filename.endswith(
×
470
                            ('.tar.gz', '.tgz', '.tar.bz2', '.tbz2', '.tar.xz', '.txz')):
471
                        sh.tar('xf', extraction_filename)
×
NEW
472
                        root_directory = sh.tar('tf', extraction_filename).split('\n')[0].split('/')[0]
×
UNCOV
473
                        if root_directory != basename(directory_name):
×
474
                            move(root_directory, directory_name)
×
475
                    else:
476
                        raise Exception(
×
477
                            'Could not extract {} download, it must be .zip, '
478
                            '.tar.gz or .tar.bz2 or .tar.xz'.format(extraction_filename))
479
                elif isdir(extraction_filename):
×
480
                    ensure_dir(directory_name)
×
481
                    for entry in listdir(extraction_filename):
×
482
                        # Previously we filtered out the .git folder, but during the build process for some recipes
483
                        # (e.g. when version is parsed by `setuptools_scm`) that may be needed.
484
                        shprint(sh.cp, '-Rv',
×
485
                                join(extraction_filename, entry),
486
                                directory_name)
487
                else:
488
                    raise Exception(
×
489
                        'Given path is neither a file nor a directory: {}'
490
                        .format(extraction_filename))
491

492
            else:
493
                info('{} is already unpacked, skipping'.format(self.name))
×
494

495
    def get_recipe_env(self, arch=None, with_flags_in_cc=True):
4✔
496
        """Return the env specialized for the recipe
497
        """
498
        if arch is None:
4!
499
            arch = self.filtered_archs[0]
×
500
        env = arch.get_env(with_flags_in_cc=with_flags_in_cc)
4✔
501
        return env
4✔
502

503
    def prebuild_arch(self, arch):
4✔
504
        '''Run any pre-build tasks for the Recipe. By default, this checks if
505
        any prebuild_archname methods exist for the archname of the current
506
        architecture, and runs them if so.'''
507
        prebuild = "prebuild_{}".format(arch.arch.replace('-', '_'))
4✔
508
        if hasattr(self, prebuild):
4!
509
            getattr(self, prebuild)()
×
510
        else:
511
            info('{} has no {}, skipping'.format(self.name, prebuild))
4✔
512

513
    def is_patched(self, arch):
4✔
514
        build_dir = self.get_build_dir(arch.arch)
4✔
515
        return exists(join(build_dir, '.patched'))
4✔
516

517
    def apply_patches(self, arch, build_dir=None):
4✔
518
        '''Apply any patches for the Recipe.
519

520
        .. versionchanged:: 0.6.0
521
            Add ability to apply patches from any dir via kwarg `build_dir`'''
522
        if self.patches:
×
523
            info_main('Applying patches for {}[{}]'
×
524
                      .format(self.name, arch.arch))
525

526
            if self.is_patched(arch):
×
527
                info_main('{} already patched, skipping'.format(self.name))
×
528
                return
×
529

530
            build_dir = build_dir if build_dir else self.get_build_dir(arch.arch)
×
531
            for patch in self.patches:
×
532
                if isinstance(patch, (tuple, list)):
×
533
                    patch, patch_check = patch
×
534
                    if not patch_check(arch=arch, recipe=self):
×
535
                        continue
×
536

537
                self.apply_patch(
×
538
                        patch.format(version=self.version, arch=arch.arch),
539
                        arch.arch, build_dir=build_dir)
540

541
            touch(join(build_dir, '.patched'))
×
542

543
    def should_build(self, arch):
4✔
544
        '''Should perform any necessary test and return True only if it needs
545
        building again. Per default we implement a library test, in case that
546
        we detect so.
547

548
        '''
549
        if self.built_libraries:
4!
550
            return not all(
4✔
551
                exists(lib) for lib in self.get_libraries(arch.arch)
552
            )
553
        return True
×
554

555
    def build_arch(self, arch):
4✔
556
        '''Run any build tasks for the Recipe. By default, this checks if
557
        any build_archname methods exist for the archname of the current
558
        architecture, and runs them if so.'''
559
        build = "build_{}".format(arch.arch)
×
560
        if hasattr(self, build):
×
561
            getattr(self, build)()
×
562

563
    def install_libraries(self, arch):
4✔
564
        '''This method is always called after `build_arch`. In case that we
565
        detect a library recipe, defined by the class attribute
566
        `built_libraries`, we will copy all defined libraries into the
567
         right location.
568
        '''
569
        if not self.built_libraries:
4!
570
            return
×
571
        shared_libs = [
4✔
572
            lib for lib in self.get_libraries(arch) if lib.endswith(".so")
573
        ]
574
        self.install_libs(arch, *shared_libs)
4✔
575

576
    def postbuild_arch(self, arch):
4✔
577
        '''Run any post-build tasks for the Recipe. By default, this checks if
578
        any postbuild_archname methods exist for the archname of the
579
        current architecture, and runs them if so.
580
        '''
581
        postbuild = "postbuild_{}".format(arch.arch)
4✔
582
        if hasattr(self, postbuild):
4!
583
            getattr(self, postbuild)()
×
584

585
        if self.need_stl_shared:
4!
586
            self.install_stl_lib(arch)
4✔
587

588
    def prepare_build_dir(self, arch):
4✔
589
        '''Copies the recipe data into a build dir for the given arch. By
590
        default, this unpacks a downloaded recipe. You should override
591
        it (or use a Recipe subclass with different behaviour) if you
592
        want to do something else.
593
        '''
594
        self.unpack(arch)
×
595

596
    def clean_build(self, arch=None):
4✔
597
        '''Deletes all the build information of the recipe.
598

599
        If arch is not None, only this arch dir is deleted. Otherwise
600
        (the default) all builds for all archs are deleted.
601

602
        By default, this just deletes the main build dir. If the
603
        recipe has e.g. object files biglinked, or .so files stored
604
        elsewhere, you should override this method.
605

606
        This method is intended for testing purposes, it may have
607
        strange results. Rebuild everything if this seems to happen.
608

609
        '''
610
        if arch is None:
×
611
            base_dir = join(self.ctx.build_dir, 'other_builds', self.name)
×
612
        else:
613
            base_dir = self.get_build_container_dir(arch)
×
614
        dirs = glob.glob(base_dir + '-*')
×
615
        if exists(base_dir):
×
616
            dirs.append(base_dir)
×
617
        if not dirs:
×
618
            warning('Attempted to clean build for {} but found no existing '
×
619
                    'build dirs'.format(self.name))
620

621
        for directory in dirs:
×
622
            rmdir(directory)
×
623

624
        # Delete any Python distributions to ensure the recipe build
625
        # doesn't persist in site-packages
626
        rmdir(self.ctx.python_installs_dir)
×
627

628
    def install_libs(self, arch, *libs):
4✔
629
        libs_dir = self.ctx.get_libs_dir(arch.arch)
4✔
630
        if not libs:
4!
631
            warning('install_libs called with no libraries to install!')
×
632
            return
×
633
        args = libs + (libs_dir,)
4✔
634
        shprint(sh.cp, *args)
4✔
635

636
    def has_libs(self, arch, *libs):
4✔
637
        return all(map(lambda lib: self.ctx.has_lib(arch.arch, lib), libs))
×
638

639
    def get_libraries(self, arch_name, in_context=False):
4✔
640
        """Return the full path of the library depending on the architecture.
641
        Per default, the build library path it will be returned, unless
642
        `get_libraries` has been called with kwarg `in_context` set to
643
        True.
644

645
        .. note:: this method should be used for library recipes only
646
        """
647
        recipe_libs = set()
4✔
648
        if not self.built_libraries:
4!
649
            return recipe_libs
×
650
        for lib, rel_path in self.built_libraries.items():
4✔
651
            if not in_context:
4!
652
                abs_path = join(self.get_build_dir(arch_name), rel_path, lib)
4✔
653
                if rel_path in {".", "", None}:
4✔
654
                    abs_path = join(self.get_build_dir(arch_name), lib)
4✔
655
            else:
656
                abs_path = join(self.ctx.get_libs_dir(arch_name), lib)
×
657
            recipe_libs.add(abs_path)
4✔
658
        return recipe_libs
4✔
659

660
    @classmethod
4✔
661
    def recipe_dirs(cls, ctx):
4✔
662
        recipe_dirs = []
4✔
663
        if ctx.local_recipes is not None:
4✔
664
            recipe_dirs.append(realpath(ctx.local_recipes))
4✔
665
        if ctx.storage_dir:
4✔
666
            recipe_dirs.append(join(ctx.storage_dir, 'recipes'))
4✔
667
        recipe_dirs.append(join(ctx.root_dir, "recipes"))
4✔
668
        return recipe_dirs
4✔
669

670
    @classmethod
4✔
671
    def list_recipes(cls, ctx):
4✔
672
        forbidden_dirs = ('__pycache__', )
4✔
673
        for recipes_dir in cls.recipe_dirs(ctx):
4✔
674
            if recipes_dir and exists(recipes_dir):
4✔
675
                for name in listdir(recipes_dir):
4✔
676
                    if name in forbidden_dirs:
4✔
677
                        continue
4✔
678
                    fn = join(recipes_dir, name)
4✔
679
                    if isdir(fn):
4✔
680
                        yield name
4✔
681

682
    @classmethod
4✔
683
    def get_recipe(cls, name, ctx):
4✔
684
        '''Returns the Recipe with the given name, if it exists.'''
685
        name = name.lower()
4✔
686
        if not hasattr(cls, "recipes"):
4✔
687
            cls.recipes = {}
4✔
688
        if name in cls.recipes:
4✔
689
            return cls.recipes[name]
4✔
690

691
        recipe_file = None
4✔
692
        for recipes_dir in cls.recipe_dirs(ctx):
4✔
693
            if not exists(recipes_dir):
4✔
694
                continue
4✔
695
            # Find matching folder (may differ in case):
696
            for subfolder in listdir(recipes_dir):
4✔
697
                if subfolder.lower() == name:
4✔
698
                    recipe_file = join(recipes_dir, subfolder, '__init__.py')
4✔
699
                    if exists(recipe_file):
4!
700
                        name = subfolder  # adapt to actual spelling
4✔
701
                        break
4✔
702
                    recipe_file = None
×
703
            if recipe_file is not None:
4✔
704
                break
4✔
705

706
        else:
707
            raise ValueError('Recipe does not exist: {}'.format(name))
4✔
708

709
        mod = import_recipe('pythonforandroid.recipes.{}'.format(name), recipe_file)
4✔
710
        if len(logger.handlers) > 1:
4!
711
            logger.removeHandler(logger.handlers[1])
×
712
        recipe = mod.recipe
4✔
713
        recipe.ctx = ctx
4✔
714
        cls.recipes[name.lower()] = recipe
4✔
715
        return recipe
4✔
716

717

718
class IncludedFilesBehaviour(object):
4✔
719
    '''Recipe mixin class that will automatically unpack files included in
720
    the recipe directory.'''
721
    src_filename = None
4✔
722

723
    def prepare_build_dir(self, arch):
4✔
724
        if self.src_filename is None:
×
725
            raise BuildInterruptingException(
×
726
                'IncludedFilesBehaviour failed: no src_filename specified')
727
        rmdir(self.get_build_dir(arch))
×
728
        shprint(sh.cp, '-a', join(self.get_recipe_dir(), self.src_filename),
×
729
                self.get_build_dir(arch))
730

731

732
class BootstrapNDKRecipe(Recipe):
4✔
733
    '''A recipe class for recipes built in an Android project jni dir with
734
    an Android.mk. These are not cached separatly, but built in the
735
    bootstrap's own building directory.
736

737
    To build an NDK project which is not part of the bootstrap, see
738
    :class:`~pythonforandroid.recipe.NDKRecipe`.
739

740
    To link with python, call the method :meth:`get_recipe_env`
741
    with the kwarg *with_python=True*.
742
    '''
743

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

746
    def get_build_container_dir(self, arch):
4✔
747
        return self.get_jni_dir()
×
748

749
    def get_build_dir(self, arch):
4✔
750
        if self.dir_name is None:
×
751
            raise ValueError('{} recipe doesn\'t define a dir_name, but '
×
752
                             'this is necessary'.format(self.name))
753
        return join(self.get_build_container_dir(arch), self.dir_name)
×
754

755
    def get_jni_dir(self):
4✔
756
        return join(self.ctx.bootstrap.build_dir, 'jni')
4✔
757

758
    def get_recipe_env(self, arch=None, with_flags_in_cc=True, with_python=False):
4✔
759
        env = super().get_recipe_env(arch, with_flags_in_cc)
×
760
        if not with_python:
×
761
            return env
×
762

763
        env['PYTHON_INCLUDE_ROOT'] = self.ctx.python_recipe.include_root(arch.arch)
×
764
        env['PYTHON_LINK_ROOT'] = self.ctx.python_recipe.link_root(arch.arch)
×
765
        env['EXTRA_LDLIBS'] = ' -lpython{}'.format(
×
766
            self.ctx.python_recipe.link_version)
767
        return env
×
768

769

770
class NDKRecipe(Recipe):
4✔
771
    '''A recipe class for any NDK project not included in the bootstrap.'''
772

773
    generated_libraries = []
4✔
774

775
    def should_build(self, arch):
4✔
776
        lib_dir = self.get_lib_dir(arch)
×
777

778
        for lib in self.generated_libraries:
×
779
            if not exists(join(lib_dir, lib)):
×
780
                return True
×
781

782
        return False
×
783

784
    def get_lib_dir(self, arch):
4✔
785
        return join(self.get_build_dir(arch.arch), 'obj', 'local', arch.arch)
×
786

787
    def get_jni_dir(self, arch):
4✔
788
        return join(self.get_build_dir(arch.arch), 'jni')
×
789

790
    def build_arch(self, arch, *extra_args):
4✔
791
        super().build_arch(arch)
×
792

793
        env = self.get_recipe_env(arch)
×
794
        with current_directory(self.get_build_dir(arch.arch)):
×
795
            shprint(
×
796
                sh.Command(join(self.ctx.ndk_dir, "ndk-build")),
797
                'V=1',
798
                'NDK_DEBUG=' + ("1" if self.ctx.build_as_debuggable else "0"),
799
                'APP_PLATFORM=android-' + str(self.ctx.ndk_api),
800
                'APP_ABI=' + arch.arch,
801
                *extra_args, _env=env
802
            )
803

804

805
class PythonRecipe(Recipe):
4✔
806
    site_packages_name = None
4✔
807
    '''The name of the module's folder when installed in the Python
2✔
808
    site-packages (e.g. for pyjnius it is 'jnius')'''
809

810
    call_hostpython_via_targetpython = True
4✔
811
    '''If True, tries to install the module using the hostpython binary
2✔
812
    copied to the target (normally arm) python build dir. However, this
813
    will fail if the module tries to import e.g. _io.so. Set this to False
814
    to call hostpython from its own build dir, installing the module in
815
    the right place via arguments to setup.py. However, this may not set
816
    the environment correctly and so False is not the default.'''
817

818
    install_in_hostpython = False
4✔
819
    '''If True, additionally installs the module in the hostpython build
2✔
820
    dir. This will make it available to other recipes if
821
    call_hostpython_via_targetpython is False.
822
    '''
823

824
    install_in_targetpython = True
4✔
825
    '''If True, installs the module in the targetpython installation dir.
2✔
826
    This is almost always what you want to do.'''
827

828
    setup_extra_args = []
4✔
829
    '''List of extra arguments to pass to setup.py'''
2✔
830

831
    depends = ['python3']
4✔
832
    '''
2✔
833
    .. note:: it's important to keep this depends as a class attribute outside
834
              `__init__` because sometimes we only initialize the class, so the
835
              `__init__` call won't be called and the deps would be missing
836
              (which breaks the dependency graph computation)
837

838
    .. warning:: don't forget to call `super().__init__()` in any recipe's
839
                 `__init__`, or otherwise it may not be ensured that it depends
840
                 on python2 or python3 which can break the dependency graph
841
    '''
842

843
    hostpython_prerequisites = []
4✔
844
    '''List of hostpython packages required to build a recipe'''
2✔
845

846
    def __init__(self, *args, **kwargs):
4✔
847
        super().__init__(*args, **kwargs)
4✔
848
        if 'python3' not in self.depends:
4✔
849
            # We ensure here that the recipe depends on python even it overrode
850
            # `depends`. We only do this if it doesn't already depend on any
851
            # python, since some recipes intentionally don't depend on/work
852
            # with all python variants
853
            depends = self.depends
4✔
854
            depends.append('python3')
4✔
855
            depends = list(set(depends))
4✔
856
            self.depends = depends
4✔
857

858
    def clean_build(self, arch=None):
4✔
859
        super().clean_build(arch=arch)
×
860
        name = self.folder_name
×
861
        python_install_dirs = glob.glob(join(self.ctx.python_installs_dir, '*'))
×
862
        for python_install in python_install_dirs:
×
863
            site_packages_dir = glob.glob(join(python_install, 'lib', 'python*',
×
864
                                               'site-packages'))
865
            if site_packages_dir:
×
866
                build_dir = join(site_packages_dir[0], name)
×
867
                if exists(build_dir):
×
868
                    info('Deleted {}'.format(build_dir))
×
869
                    rmdir(build_dir)
×
870

871
    @property
4✔
872
    def real_hostpython_location(self):
4✔
873
        host_name = 'host{}'.format(self.ctx.python_recipe.name)
4✔
874
        if host_name == 'hostpython3':
4!
875
            python_recipe = Recipe.get_recipe(host_name, self.ctx)
4✔
876
            return python_recipe.python_exe
4✔
877
        else:
878
            python_recipe = self.ctx.python_recipe
×
879
            return 'python{}'.format(python_recipe.version)
×
880

881
    @property
4✔
882
    def hostpython_location(self):
4✔
883
        if not self.call_hostpython_via_targetpython:
4!
884
            return self.real_hostpython_location
4✔
885
        return self.ctx.hostpython
×
886

887
    @property
4✔
888
    def folder_name(self):
4✔
889
        '''The name of the build folders containing this recipe.'''
890
        name = self.site_packages_name
×
891
        if name is None:
×
892
            name = self.name
×
893
        return name
×
894

895
    def get_recipe_env(self, arch=None, with_flags_in_cc=True):
4✔
896
        env = super().get_recipe_env(arch, with_flags_in_cc)
4✔
897
        env['PYTHONNOUSERSITE'] = '1'
4✔
898
        # Set the LANG, this isn't usually important but is a better default
899
        # as it occasionally matters how Python e.g. reads files
900
        env['LANG'] = "en_GB.UTF-8"
4✔
901
        # Binaries made by packages installed by pip
902
        env["PATH"] = join(self.hostpython_site_dir, "bin") + ":" + env["PATH"]
4✔
903

904
        if not self.call_hostpython_via_targetpython:
4!
905
            env['CFLAGS'] += ' -I{}'.format(
4✔
906
                self.ctx.python_recipe.include_root(arch.arch)
907
            )
908
            env['LDFLAGS'] += ' -L{} -lpython{}'.format(
4✔
909
                self.ctx.python_recipe.link_root(arch.arch),
910
                self.ctx.python_recipe.link_version,
911
            )
912

913
            hppath = []
4✔
914
            hppath.append(join(dirname(self.hostpython_location), 'Lib'))
4✔
915
            hppath.append(join(hppath[0], 'site-packages'))
4✔
916
            builddir = join(dirname(self.hostpython_location), 'build')
4✔
917
            if exists(builddir):
4!
918
                hppath += [join(builddir, d) for d in listdir(builddir)
×
919
                           if isdir(join(builddir, d))]
920
            if len(hppath) > 0:
4!
921
                if 'PYTHONPATH' in env:
4!
922
                    env['PYTHONPATH'] = ':'.join(hppath + [env['PYTHONPATH']])
×
923
                else:
924
                    env['PYTHONPATH'] = ':'.join(hppath)
4✔
925
        return env
4✔
926

927
    def should_build(self, arch):
4✔
928
        name = self.folder_name
×
929
        if self.ctx.has_package(name, arch):
×
930
            info('Python package already exists in site-packages')
×
931
            return False
×
932
        info('{} apparently isn\'t already in site-packages'.format(name))
×
933
        return True
×
934

935
    def build_arch(self, arch):
4✔
936
        '''Install the Python module by calling setup.py install with
937
        the target Python dir.'''
938
        self.install_hostpython_prerequisites()
×
939
        super().build_arch(arch)
×
940
        self.install_python_package(arch)
×
941

942
    def install_python_package(self, arch, name=None, env=None, is_dir=True):
4✔
943
        '''Automate the installation of a Python package (or a cython
944
        package where the cython components are pre-built).'''
945
        # arch = self.filtered_archs[0]  # old kivy-ios way
946
        if name is None:
×
947
            name = self.name
×
948
        if env is None:
×
949
            env = self.get_recipe_env(arch)
×
950

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

953
        hostpython = sh.Command(self.hostpython_location)
×
954
        hpenv = env.copy()
×
955
        with current_directory(self.get_build_dir(arch.arch)):
×
956
            shprint(hostpython, 'setup.py', 'install', '-O2',
×
957
                    '--root={}'.format(self.ctx.get_python_install_dir(arch.arch)),
958
                    '--install-lib=.',
959
                    _env=hpenv, *self.setup_extra_args)
960

961
            # If asked, also install in the hostpython build dir
962
            if self.install_in_hostpython:
×
963
                self.install_hostpython_package(arch)
×
964

965
    def get_hostrecipe_env(self, arch):
4✔
966
        env = environ.copy()
×
967
        env['PYTHONPATH'] = self.hostpython_site_dir
×
968
        return env
×
969

970
    @property
4✔
971
    def hostpython_site_dir(self):
4✔
972
        return join(dirname(self.real_hostpython_location), 'Lib', 'site-packages')
4✔
973

974
    def install_hostpython_package(self, arch):
4✔
975
        env = self.get_hostrecipe_env(arch)
×
976
        real_hostpython = sh.Command(self.real_hostpython_location)
×
977
        shprint(real_hostpython, 'setup.py', 'install', '-O2',
×
978
                '--root={}'.format(dirname(self.real_hostpython_location)),
979
                '--install-lib=Lib/site-packages',
980
                _env=env, *self.setup_extra_args)
981

982
    @property
4✔
983
    def python_major_minor_version(self):
4✔
984
        parsed_version = packaging.version.parse(self.ctx.python_recipe.version)
×
985
        return f"{parsed_version.major}.{parsed_version.minor}"
×
986

987
    def install_hostpython_prerequisites(self, packages=None, force_upgrade=True):
4✔
988
        if not packages:
×
989
            packages = self.hostpython_prerequisites
×
990

991
        if len(packages) == 0:
×
992
            return
×
993

994
        pip_options = [
×
995
            "install",
996
            *packages,
997
            "--target", self.hostpython_site_dir, "--python-version",
998
            self.ctx.python_recipe.version,
999
            # Don't use sources, instead wheels
1000
            "--only-binary=:all:",
1001
        ]
1002
        if force_upgrade:
×
1003
            pip_options.append("--upgrade")
×
1004
        # Use system's pip
1005
        shprint(sh.pip, *pip_options)
×
1006

1007
    def restore_hostpython_prerequisites(self, packages):
4✔
1008
        _packages = []
×
1009
        for package in packages:
×
1010
            original_version = Recipe.get_recipe(package, self.ctx).version
×
1011
            _packages.append(package + "==" + original_version)
×
1012
        self.install_hostpython_prerequisites(packages=_packages)
×
1013

1014

1015
class CompiledComponentsPythonRecipe(PythonRecipe):
4✔
1016
    pre_build_ext = False
4✔
1017

1018
    build_cmd = 'build_ext'
4✔
1019

1020
    def build_arch(self, arch):
4✔
1021
        '''Build any cython components, then install the Python module by
1022
        calling setup.py install with the target Python dir.
1023
        '''
1024
        Recipe.build_arch(self, arch)
×
1025
        self.install_hostpython_prerequisites()
×
1026
        self.build_compiled_components(arch)
×
1027
        self.install_python_package(arch)
×
1028

1029
    def build_compiled_components(self, arch):
4✔
1030
        info('Building compiled components in {}'.format(self.name))
×
1031

1032
        env = self.get_recipe_env(arch)
×
1033
        hostpython = sh.Command(self.hostpython_location)
×
1034
        with current_directory(self.get_build_dir(arch.arch)):
×
1035
            if self.install_in_hostpython:
×
1036
                shprint(hostpython, 'setup.py', 'clean', '--all', _env=env)
×
1037
            shprint(hostpython, 'setup.py', self.build_cmd, '-v',
×
1038
                    _env=env, *self.setup_extra_args)
1039
            build_dir = glob.glob('build/lib.*')[0]
×
1040
            shprint(sh.find, build_dir, '-name', '"*.o"', '-exec',
×
1041
                    env['STRIP'], '{}', ';', _env=env)
1042

1043
    def install_hostpython_package(self, arch):
4✔
1044
        env = self.get_hostrecipe_env(arch)
×
1045
        self.rebuild_compiled_components(arch, env)
×
1046
        super().install_hostpython_package(arch)
×
1047

1048
    def rebuild_compiled_components(self, arch, env):
4✔
1049
        info('Rebuilding compiled components in {}'.format(self.name))
×
1050

1051
        hostpython = sh.Command(self.real_hostpython_location)
×
1052
        shprint(hostpython, 'setup.py', 'clean', '--all', _env=env)
×
1053
        shprint(hostpython, 'setup.py', self.build_cmd, '-v', _env=env,
×
1054
                *self.setup_extra_args)
1055

1056

1057
class CppCompiledComponentsPythonRecipe(CompiledComponentsPythonRecipe):
4✔
1058
    """ Extensions that require the cxx-stl """
1059
    call_hostpython_via_targetpython = False
4✔
1060
    need_stl_shared = True
4✔
1061

1062

1063
class CythonRecipe(PythonRecipe):
4✔
1064
    pre_build_ext = False
4✔
1065
    cythonize = True
4✔
1066
    cython_args = []
4✔
1067
    call_hostpython_via_targetpython = False
4✔
1068

1069
    def build_arch(self, arch):
4✔
1070
        '''Build any cython components, then install the Python module by
1071
        calling setup.py install with the target Python dir.
1072
        '''
1073
        Recipe.build_arch(self, arch)
×
1074
        self.build_cython_components(arch)
×
1075
        self.install_python_package(arch)
×
1076

1077
    def build_cython_components(self, arch):
4✔
1078
        info('Cythonizing anything necessary in {}'.format(self.name))
×
1079

1080
        env = self.get_recipe_env(arch)
×
1081

1082
        with current_directory(self.get_build_dir(arch.arch)):
×
1083
            hostpython = sh.Command(self.ctx.hostpython)
×
1084
            shprint(hostpython, '-c', 'import sys; print(sys.path)', _env=env)
×
1085
            debug('cwd is {}'.format(realpath(curdir)))
×
1086
            info('Trying first build of {} to get cython files: this is '
×
1087
                 'expected to fail'.format(self.name))
1088

1089
            manually_cythonise = False
×
1090
            try:
×
1091
                shprint(hostpython, 'setup.py', 'build_ext', '-v', _env=env,
×
1092
                        *self.setup_extra_args)
1093
            except sh.ErrorReturnCode_1:
×
1094
                print()
×
1095
                info('{} first build failed (as expected)'.format(self.name))
×
1096
                manually_cythonise = True
×
1097

1098
            if manually_cythonise:
×
1099
                self.cythonize_build(env=env)
×
1100
                shprint(hostpython, 'setup.py', 'build_ext', '-v', _env=env,
×
1101
                        _tail=20, _critical=True, *self.setup_extra_args)
1102
            else:
1103
                info('First build appeared to complete correctly, skipping manual'
×
1104
                     'cythonising.')
1105

1106
            if not self.ctx.with_debug_symbols:
×
1107
                self.strip_object_files(arch, env)
×
1108

1109
    def strip_object_files(self, arch, env, build_dir=None):
4✔
1110
        if build_dir is None:
×
1111
            build_dir = self.get_build_dir(arch.arch)
×
1112
        with current_directory(build_dir):
×
1113
            info('Stripping object files')
×
1114
            shprint(sh.find, '.', '-iname', '*.so', '-exec',
×
1115
                    '/usr/bin/echo', '{}', ';', _env=env)
1116
            shprint(sh.find, '.', '-iname', '*.so', '-exec',
×
1117
                    env['STRIP'].split(' ')[0], '--strip-unneeded',
1118
                    # '/usr/bin/strip', '--strip-unneeded',
1119
                    '{}', ';', _env=env)
1120

1121
    def cythonize_file(self, env, build_dir, filename):
4✔
1122
        short_filename = filename
×
1123
        if filename.startswith(build_dir):
×
1124
            short_filename = filename[len(build_dir) + 1:]
×
1125
        info(u"Cythonize {}".format(short_filename))
×
1126
        cyenv = env.copy()
×
1127
        if 'CYTHONPATH' in cyenv:
×
1128
            cyenv['PYTHONPATH'] = cyenv['CYTHONPATH']
×
1129
        elif 'PYTHONPATH' in cyenv:
×
1130
            del cyenv['PYTHONPATH']
×
1131
        if 'PYTHONNOUSERSITE' in cyenv:
×
1132
            cyenv.pop('PYTHONNOUSERSITE')
×
1133
        python_command = sh.Command("python{}".format(
×
1134
            self.ctx.python_recipe.major_minor_version_string.split(".")[0]
1135
        ))
1136
        shprint(python_command, "-c"
×
1137
                "import sys; from Cython.Compiler.Main import setuptools_main; sys.exit(setuptools_main());",
1138
                filename, *self.cython_args, _env=cyenv)
1139

1140
    def cythonize_build(self, env, build_dir="."):
4✔
1141
        if not self.cythonize:
×
1142
            info('Running cython cancelled per recipe setting')
×
1143
            return
×
1144
        info('Running cython where appropriate')
×
1145
        for root, dirnames, filenames in walk("."):
×
1146
            for filename in fnmatch.filter(filenames, "*.pyx"):
×
1147
                self.cythonize_file(env, build_dir, join(root, filename))
×
1148

1149
    def get_recipe_env(self, arch, with_flags_in_cc=True):
4✔
1150
        env = super().get_recipe_env(arch, with_flags_in_cc)
×
1151
        env['LDFLAGS'] = env['LDFLAGS'] + ' -L{} '.format(
×
1152
            self.ctx.get_libs_dir(arch.arch) +
1153
            ' -L{} '.format(self.ctx.libs_dir) +
1154
            ' -L{}'.format(join(self.ctx.bootstrap.build_dir, 'obj', 'local',
1155
                                arch.arch)))
1156

1157
        env['LDSHARED'] = env['CC'] + ' -shared'
×
1158
        # shprint(sh.whereis, env['LDSHARED'], _env=env)
1159
        env['LIBLINK'] = 'NOTNONE'
×
1160
        if self.ctx.copy_libs:
×
1161
            env['COPYLIBS'] = '1'
×
1162

1163
        # Every recipe uses its own liblink path, object files are
1164
        # collected and biglinked later
1165
        liblink_path = join(self.get_build_container_dir(arch.arch),
×
1166
                            'objects_{}'.format(self.name))
1167
        env['LIBLINK_PATH'] = liblink_path
×
1168
        ensure_dir(liblink_path)
×
1169

1170
        return env
×
1171

1172

1173
class PyProjectRecipe(PythonRecipe):
4✔
1174
    '''Recipe for projects which containes `pyproject.toml`'''
1175

1176
    # Extra args to pass to `python -m build ...`
1177
    extra_build_args = []
4✔
1178
    call_hostpython_via_targetpython = False
4✔
1179

1180
    def get_recipe_env(self, arch, **kwargs):
4✔
1181
        # Custom hostpython
1182
        self.ctx.python_recipe.python_exe = join(
4✔
1183
            self.ctx.python_recipe.get_build_dir(arch), "android-build", "python3")
1184
        env = super().get_recipe_env(arch, **kwargs)
4✔
1185
        build_dir = self.get_build_dir(arch)
4✔
1186
        ensure_dir(build_dir)
4✔
1187
        build_opts = join(build_dir, "build-opts.cfg")
4✔
1188

1189
        with open(build_opts, "w") as file:
4✔
1190
            file.write("[bdist_wheel]\nplat-name={}".format(
4✔
1191
                self.get_wheel_platform_tag(arch)
1192
            ))
1193
            file.close()
4✔
1194

1195
        env["DIST_EXTRA_CONFIG"] = build_opts
4✔
1196
        return env
4✔
1197

1198
    def get_wheel_platform_tag(self, arch):
4✔
1199
        return "android_" + {
4✔
1200
            "armeabi-v7a": "arm",
1201
            "arm64-v8a": "aarch64",
1202
            "x86_64": "x86_64",
1203
            "x86": "i686",
1204
        }[arch.arch]
1205

1206
    def install_wheel(self, arch, built_wheels):
4✔
1207
        _wheel = built_wheels[0]
×
1208
        built_wheel_dir = dirname(_wheel)
×
1209
        # Fix wheel platform tag
1210
        wheel_tag = wheel_tags(
×
1211
            _wheel,
1212
            platform_tags=self.get_wheel_platform_tag(arch),
1213
            remove=True,
1214
        )
1215
        selected_wheel = join(built_wheel_dir, wheel_tag)
×
1216

1217
        _dev_wheel_dir = environ.get("P4A_WHEEL_DIR", False)
×
1218
        if _dev_wheel_dir:
×
1219
            ensure_dir(_dev_wheel_dir)
×
1220
            shprint(sh.cp, selected_wheel, _dev_wheel_dir)
×
1221

1222
        info(f"Installing built wheel: {wheel_tag}")
×
1223
        destination = self.ctx.get_python_install_dir(arch.arch)
×
1224
        with WheelFile(selected_wheel) as wf:
×
1225
            for zinfo in wf.filelist:
×
1226
                wf.extract(zinfo, destination)
×
1227
            wf.close()
×
1228

1229
    def build_arch(self, arch):
4✔
1230
        self.install_hostpython_prerequisites(
×
1231
            packages=["build[virtualenv]", "pip"] + self.hostpython_prerequisites
1232
        )
1233
        build_dir = self.get_build_dir(arch.arch)
×
1234
        env = self.get_recipe_env(arch, with_flags_in_cc=True)
×
1235
        # make build dir separatly
1236
        sub_build_dir = join(build_dir, "p4a_android_build")
×
1237
        ensure_dir(sub_build_dir)
×
1238
        # copy hostpython to built python to ensure correct selection of libs and includes
1239
        shprint(sh.cp, self.real_hostpython_location, self.ctx.python_recipe.python_exe)
×
1240

1241
        build_args = [
×
1242
            "-m",
1243
            "build",
1244
            "--wheel",
1245
            "--config-setting",
1246
            "builddir={}".format(sub_build_dir),
1247
        ] + self.extra_build_args
1248

1249
        built_wheels = []
×
1250
        with current_directory(build_dir):
×
1251
            shprint(
×
1252
                sh.Command(self.ctx.python_recipe.python_exe), *build_args, _env=env
1253
            )
1254
            built_wheels = [realpath(whl) for whl in glob.glob("dist/*.whl")]
×
1255
        self.install_wheel(arch, built_wheels)
×
1256

1257

1258
class MesonRecipe(PyProjectRecipe):
4✔
1259
    '''Recipe for projects which uses meson as build system'''
1260

1261
    meson_version = "1.4.0"
4✔
1262
    ninja_version = "1.11.1.1"
4✔
1263

1264
    def sanitize_flags(self, *flag_strings):
4✔
1265
        return " ".join(flag_strings).strip().split(" ")
×
1266

1267
    def get_recipe_meson_options(self, arch):
4✔
1268
        env = self.get_recipe_env(arch, with_flags_in_cc=True)
×
1269
        return {
×
1270
            "binaries": {
1271
                "c": arch.get_clang_exe(with_target=True),
1272
                "cpp": arch.get_clang_exe(with_target=True, plus_plus=True),
1273
                "ar": self.ctx.ndk.llvm_ar,
1274
                "strip": self.ctx.ndk.llvm_strip,
1275
            },
1276
            "built-in options": {
1277
                "c_args": self.sanitize_flags(env["CFLAGS"], env["CPPFLAGS"]),
1278
                "cpp_args": self.sanitize_flags(env["CXXFLAGS"], env["CPPFLAGS"]),
1279
                "c_link_args": self.sanitize_flags(env["LDFLAGS"]),
1280
                "cpp_link_args": self.sanitize_flags(env["LDFLAGS"]),
1281
            },
1282
            "properties": {
1283
                "needs_exe_wrapper": True,
1284
                "sys_root": self.ctx.ndk.sysroot
1285
            },
1286
            "host_machine": {
1287
                "cpu_family": {
1288
                    "arm64-v8a": "aarch64",
1289
                    "armeabi-v7a": "arm",
1290
                    "x86_64": "x86_64",
1291
                    "x86": "x86"
1292
                }[arch.arch],
1293
                "cpu": {
1294
                    "arm64-v8a": "aarch64",
1295
                    "armeabi-v7a": "armv7",
1296
                    "x86_64": "x86_64",
1297
                    "x86": "i686"
1298
                }[arch.arch],
1299
                "endian": "little",
1300
                "system": "android",
1301
            }
1302
        }
1303

1304
    def write_build_options(self, arch):
4✔
1305
        """Writes python dict to meson config file"""
1306
        option_data = ""
×
1307
        build_options = self.get_recipe_meson_options(arch)
×
1308
        for key in build_options.keys():
×
1309
            data_chunk = "[{}]".format(key)
×
1310
            for subkey in build_options[key].keys():
×
1311
                value = build_options[key][subkey]
×
1312
                if isinstance(value, int):
×
1313
                    value = str(value)
×
1314
                elif isinstance(value, str):
×
1315
                    value = "'{}'".format(value)
×
1316
                elif isinstance(value, bool):
×
1317
                    value = "true" if value else "false"
×
1318
                elif isinstance(value, list):
×
1319
                    value = "['" + "', '".join(value) + "']"
×
1320
                data_chunk += "\n" + subkey + " = " + value
×
1321
            option_data += data_chunk + "\n\n"
×
1322
        return option_data
×
1323

1324
    def ensure_args(self, *args):
4✔
1325
        for arg in args:
×
1326
            if arg not in self.extra_build_args:
×
1327
                self.extra_build_args.append(arg)
×
1328

1329
    def build_arch(self, arch):
4✔
1330
        cross_file = join("/tmp", "android.meson.cross")
×
1331
        info("Writing cross file at: {}".format(cross_file))
×
1332
        # write cross config file
1333
        with open(cross_file, "w") as file:
×
1334
            file.write(self.write_build_options(arch))
×
1335
            file.close()
×
1336
        # set cross file
1337
        self.ensure_args('-Csetup-args=--cross-file', '-Csetup-args={}'.format(cross_file))
×
1338
        # ensure ninja and meson
1339
        for dep in [
×
1340
            "ninja=={}".format(self.ninja_version),
1341
            "meson=={}".format(self.meson_version),
1342
        ]:
1343
            if dep not in self.hostpython_prerequisites:
×
1344
                self.hostpython_prerequisites.append(dep)
×
1345
        super().build_arch(arch)
×
1346

1347

1348
class RustCompiledComponentsRecipe(PyProjectRecipe):
4✔
1349
    # Rust toolchain codes
1350
    # https://doc.rust-lang.org/nightly/rustc/platform-support.html
1351
    RUST_ARCH_CODES = {
4✔
1352
        "arm64-v8a": "aarch64-linux-android",
1353
        "armeabi-v7a": "armv7-linux-androideabi",
1354
        "x86_64": "x86_64-linux-android",
1355
        "x86": "i686-linux-android",
1356
    }
1357

1358
    call_hostpython_via_targetpython = False
4✔
1359

1360
    def get_recipe_env(self, arch, **kwargs):
4✔
1361
        env = super().get_recipe_env(arch, **kwargs)
×
1362

1363
        # Set rust build target
1364
        build_target = self.RUST_ARCH_CODES[arch.arch]
×
1365
        cargo_linker_name = "CARGO_TARGET_{}_LINKER".format(
×
1366
            build_target.upper().replace("-", "_")
1367
        )
1368
        env["CARGO_BUILD_TARGET"] = build_target
×
1369
        env[cargo_linker_name] = join(
×
1370
            self.ctx.ndk.llvm_prebuilt_dir,
1371
            "bin",
1372
            "{}{}-clang".format(
1373
                # NDK's Clang format
1374
                build_target.replace("7", "7a")
1375
                if build_target.startswith("armv7")
1376
                else build_target,
1377
                self.ctx.ndk_api,
1378
            ),
1379
        )
1380
        realpython_dir = self.ctx.python_recipe.get_build_dir(arch.arch)
×
1381

1382
        env["RUSTFLAGS"] = "-Clink-args=-L{} -L{}".format(
×
1383
            self.ctx.get_libs_dir(arch.arch), join(realpython_dir, "android-build")
1384
        )
1385

1386
        env["PYO3_CROSS_LIB_DIR"] = realpath(glob.glob(join(
×
1387
            realpython_dir, "android-build", "build",
1388
            "lib.linux-*-{}/".format(self.python_major_minor_version),
1389
        ))[0])
1390

1391
        info_main("Ensuring rust build toolchain")
×
1392
        shprint(sh.rustup, "target", "add", build_target)
×
1393

1394
        # Add host python to PATH
1395
        env["PATH"] = ("{hostpython_dir}:{old_path}").format(
×
1396
            hostpython_dir=Recipe.get_recipe(
1397
                "hostpython3", self.ctx
1398
            ).get_path_to_python(),
1399
            old_path=env["PATH"],
1400
        )
1401
        return env
×
1402

1403
    def check_host_deps(self):
4✔
1404
        if not hasattr(sh, "rustup"):
×
1405
            error(
×
1406
                "`rustup` was not found on host system."
1407
                "Please install it using :"
1408
                "\n`curl https://sh.rustup.rs -sSf | sh`\n"
1409
            )
1410
            exit(1)
×
1411

1412
    def build_arch(self, arch):
4✔
1413
        self.check_host_deps()
×
1414
        super().build_arch(arch)
×
1415

1416

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

1421
    def __init__(self, *args, **kwargs):
4✔
1422
        self._ctx = None
4✔
1423
        super().__init__(*args, **kwargs)
4✔
1424

1425
    def prebuild_arch(self, arch):
4✔
1426
        super().prebuild_arch(arch)
×
1427
        self.ctx.python_recipe = self
×
1428

1429
    def include_root(self, arch):
4✔
1430
        '''The root directory from which to include headers.'''
1431
        raise NotImplementedError('Not implemented in TargetPythonRecipe')
1432

1433
    def link_root(self):
4✔
1434
        raise NotImplementedError('Not implemented in TargetPythonRecipe')
1435

1436
    @property
4✔
1437
    def major_minor_version_string(self):
4✔
1438
        parsed_version = packaging.version.parse(self.version)
4✔
1439
        return f"{parsed_version.major}.{parsed_version.minor}"
4✔
1440

1441
    def create_python_bundle(self, dirn, arch):
4✔
1442
        """
1443
        Create a packaged python bundle in the target directory, by
1444
        copying all the modules and standard library to the right
1445
        place.
1446
        """
1447
        raise NotImplementedError('{} does not implement create_python_bundle'.format(self))
1448

1449
    def reduce_object_file_names(self, dirn):
4✔
1450
        """Recursively renames all files named XXX.cpython-...-linux-gnu.so"
1451
        to "XXX.so", i.e. removing the erroneous architecture name
1452
        coming from the local system.
1453
        """
1454
        py_so_files = shprint(sh.find, dirn, '-iname', '*.so')
4✔
1455
        filens = py_so_files.stdout.decode('utf-8').split('\n')[:-1]
4✔
1456
        for filen in filens:
4!
1457
            file_dirname, file_basename = split(filen)
×
1458
            parts = file_basename.split('.')
×
1459
            if len(parts) <= 2:
×
1460
                continue
×
1461
            # PySide6 libraries end with .abi3.so
1462
            if parts[1] == "abi3":
×
1463
                continue
×
1464
            move(filen, join(file_dirname, parts[0] + '.so'))
×
1465

1466

1467
def algsum(alg, filen):
4✔
1468
    '''Calculate the digest of a file.
1469
    '''
1470
    with open(filen, 'rb') as fileh:
×
1471
        digest = getattr(hashlib, alg)(fileh.read())
×
1472

1473
    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