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

kivy / python-for-android / 16465999162

23 Jul 2025 08:49AM UTC coverage: 59.159% (-0.01%) from 59.171%
16465999162

Pull #3174

github

web-flow
Merge fb16b08a7 into a8f2ca1c5
Pull Request #3174: `recipes`: new pycairo recipe

1054 of 2381 branches covered (44.27%)

Branch coverage included in aggregate %.

29 of 48 new or added lines in 3 files covered. (60.42%)

32 existing lines in 1 file now uncovered.

4985 of 7827 relevant lines covered (63.69%)

2.54 hits per line

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

45.56
/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

132
        - copy the library into the right location, depending on if it's shared
133
          or static)
134
        - check if we have to rebuild the library
135

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

375
    # Public Recipe API to be subclassed if needed
376

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

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

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

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

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

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

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

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

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

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

454
        build_dir = self.get_build_container_dir(arch)
×
455

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

571
    def should_build(self, arch):
4✔
572
        '''Should perform any necessary test and return True only if it needs
573
        building again. Per default we implement a library test, in case that
574
        we detect so.
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
    skip_python = False
4✔
1294

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

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

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

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

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

1379

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

1390
    call_hostpython_via_targetpython = False
4✔
1391

1392
    def get_recipe_env(self, arch, **kwargs):
4✔
UNCOV
1393
        env = super().get_recipe_env(arch, **kwargs)
×
1394

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

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

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

UNCOV
1423
        info_main("Ensuring rust build toolchain")
×
UNCOV
1424
        shprint(sh.rustup, "target", "add", build_target)
×
1425

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

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

1444
    def build_arch(self, arch):
4✔
1445
        self.check_host_deps()
×
UNCOV
1446
        super().build_arch(arch)
×
1447

1448

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

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

1457
    def prebuild_arch(self, arch):
4✔
UNCOV
1458
        super().prebuild_arch(arch)
×
UNCOV
1459
        self.ctx.python_recipe = self
×
1460

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

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

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

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

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

1498

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

1505
    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

© 2026 Coveralls, Inc