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

kivy / python-for-android / 16505756202

24 Jul 2025 07:13PM UTC coverage: 59.227% (+0.06%) from 59.171%
16505756202

push

github

web-flow
Update: `numpy`, `pandas`, `sdl2` to newer versions which support `ndk28c` (#3164)

1058 of 2381 branches covered (44.44%)

Branch coverage included in aggregate %.

22 of 24 new or added lines in 7 files covered. (91.67%)

1 existing line in 1 file now uncovered.

4963 of 7785 relevant lines covered (63.75%)

2.54 hits per line

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

45.7
/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
    min_ndk_api_support = 20
4✔
159
    '''
2✔
160
    Minimum ndk api recipe will support.
161
    '''
162

163
    def get_stl_library(self, arch):
4✔
164
        return join(
4✔
165
            arch.ndk_lib_dir,
166
            'lib{name}.so'.format(name=self.stl_lib_name),
167
        )
168

169
    def install_stl_lib(self, arch):
4✔
170
        if not self.ctx.has_lib(
4!
171
            arch.arch, 'lib{name}.so'.format(name=self.stl_lib_name)
172
        ):
173
            self.install_libs(arch, self.get_stl_library(arch))
4✔
174

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

180
    @property
4✔
181
    def url(self):
4✔
182
        key = 'URL_' + self.name
4✔
183
        return environ.get(key, self._url)
4✔
184

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

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

204
        return environ.get(key, self._download_headers)
4✔
205

206
    def download_file(self, url, target, cwd=None):
4✔
207
        """
208
        (internal) Download an ``url`` to a ``target``.
209
        """
210
        if not url:
4!
211
            return
×
212

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

215
        if cwd:
4!
216
            target = join(cwd, target)
×
217

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

230
            if exists(target):
4!
231
                unlink(target)
×
232

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

279
    def apply_patch(self, filename, arch, build_dir=None):
4✔
280
        """
281
        Apply a patch from the current recipe directory into the current
282
        build directory.
283

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

293
    def copy_file(self, filename, dest):
4✔
294
        info("Copy {} to {}".format(filename, dest))
×
295
        filename = join(self.get_recipe_dir(), filename)
×
296
        dest = join(self.build_dir, dest)
×
297
        shutil.copy(filename, dest)
×
298

299
    def append_file(self, filename, dest):
4✔
300
        info("Append {} to {}".format(filename, dest))
×
301
        filename = join(self.get_recipe_dir(), filename)
×
302
        dest = join(self.build_dir, dest)
×
303
        with open(filename, "rb") as fd:
×
304
            data = fd.read()
×
305
        with open(dest, "ab") as fd:
×
306
            fd.write(data)
×
307

308
    @property
4✔
309
    def name(self):
4✔
310
        '''The name of the recipe, the same as the folder containing it.'''
311
        modname = self.__class__.__module__
4✔
312
        return modname.split(".", 2)[-1]
4✔
313

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

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

341
    def get_opt_depends_in_list(self, recipes):
4✔
342
        '''Given a list of recipe names, returns those that are also in
343
        self.opt_depends.
344
        '''
345
        return [recipe for recipe in recipes if recipe in self.opt_depends]
4✔
346

347
    def get_build_container_dir(self, arch):
4✔
348
        '''Given the arch name, returns the directory where it will be
349
        built.
350

351
        This returns a different directory depending on what
352
        alternative or optional dependencies are being built.
353
        '''
354
        dir_name = self.get_dir_name()
4✔
355
        return join(self.ctx.build_dir, 'other_builds',
4✔
356
                    dir_name, '{}__ndk_target_{}'.format(arch, self.ctx.ndk_api))
357

358
    def get_dir_name(self):
4✔
359
        choices = self.check_recipe_choices()
4✔
360
        dir_name = '-'.join([self.name] + choices)
4✔
361
        return dir_name
4✔
362

363
    def get_build_dir(self, arch):
4✔
364
        '''Given the arch name, returns the directory where the
365
        downloaded/copied package will be built.'''
366

367
        return join(self.get_build_container_dir(arch), self.name)
4✔
368

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

380
    # Public Recipe API to be subclassed if needed
381

382
    def download_if_necessary(self):
4✔
383
        if self.ctx.ndk_api < self.min_ndk_api_support:
4!
NEW
384
            error(f"In order to build '{self.name}', you must set minimum ndk api (minapi) to `{self.min_ndk_api_support}`.\n")
×
NEW
385
            exit(1)
×
386
        info_main('Downloading {}'.format(self.name))
4✔
387
        user_dir = environ.get('P4A_{}_DIR'.format(self.name.lower()))
4✔
388
        if user_dir is not None:
4✔
389
            info('P4A_{}_DIR is set, skipping download for {}'.format(
4✔
390
                self.name, self.name))
391
            return
4✔
392
        self.download()
4✔
393

394
    def download(self):
4✔
395
        if self.url is None:
4✔
396
            info('Skipping {} download as no URL is set'.format(self.name))
4✔
397
            return
4✔
398

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

414
        ensure_dir(join(self.ctx.packages_path, self.name))
4✔
415

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

419
            do_download = True
4✔
420
            marker_filename = '.mark-{}'.format(filename)
4✔
421
            if exists(filename) and isfile(filename):
4!
422
                if not exists(marker_filename):
×
423
                    shprint(sh.rm, filename)
×
424
                else:
425
                    for alg, expected_digest in expected_digests.items():
×
426
                        current_digest = algsum(alg, filename)
×
427
                        if current_digest != expected_digest:
×
428
                            debug('* Generated {}sum: {}'.format(alg,
×
429
                                                                 current_digest))
430
                            debug('* Expected {}sum: {}'.format(alg,
×
431
                                                                expected_digest))
432
                            raise ValueError(
×
433
                                ('Generated {0}sum does not match expected {0}sum '
434
                                 'for {1} recipe').format(alg, self.name))
435
                    do_download = False
×
436

437
            # If we got this far, we will download
438
            if do_download:
4!
439
                debug('Downloading {} from {}'.format(self.name, url))
4✔
440

441
                shprint(sh.rm, '-f', marker_filename)
4✔
442
                self.download_file(self.versioned_url, filename)
4✔
443
                touch(marker_filename)
4✔
444

445
                if exists(filename) and isfile(filename):
4!
446
                    for alg, expected_digest in expected_digests.items():
×
447
                        current_digest = algsum(alg, filename)
×
448
                        if current_digest != expected_digest:
×
449
                            debug('* Generated {}sum: {}'.format(alg,
×
450
                                                                 current_digest))
451
                            debug('* Expected {}sum: {}'.format(alg,
×
452
                                                                expected_digest))
453
                            raise ValueError(
×
454
                                ('Generated {0}sum does not match expected {0}sum '
455
                                 'for {1} recipe').format(alg, self.name))
456
            else:
457
                info('{} download already cached, skipping'.format(self.name))
×
458

459
    def unpack(self, arch):
4✔
460
        info_main('Unpacking {} for {}'.format(self.name, arch))
×
461

462
        build_dir = self.get_build_container_dir(arch)
×
463

464
        user_dir = environ.get('P4A_{}_DIR'.format(self.name.lower()))
×
465
        if user_dir is not None:
×
466
            info('P4A_{}_DIR exists, symlinking instead'.format(
×
467
                self.name.lower()))
468
            if exists(self.get_build_dir(arch)):
×
469
                return
×
470
            rmdir(build_dir)
×
471
            ensure_dir(build_dir)
×
472
            shprint(sh.cp, '-a', user_dir, self.get_build_dir(arch))
×
473
            return
×
474

475
        if self.url is None:
×
476
            info('Skipping {} unpack as no URL is set'.format(self.name))
×
477
            return
×
478

479
        filename = shprint(
×
480
            sh.basename, self.versioned_url).stdout[:-1].decode('utf-8')
481
        ma = match(u'^(.+)#[a-z0-9_]{3,}=([0-9a-f]{32,})$', filename)
×
482
        if ma:                  # fragmented URL?
×
483
            filename = ma.group(1)
×
484

485
        with current_directory(build_dir):
×
486
            directory_name = self.get_build_dir(arch)
×
487

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

528
            else:
529
                info('{} is already unpacked, skipping'.format(self.name))
×
530

531
    def get_recipe_env(self, arch=None, with_flags_in_cc=True):
4✔
532
        """Return the env specialized for the recipe
533
        """
534
        if arch is None:
4!
535
            arch = self.filtered_archs[0]
×
536
        env = arch.get_env(with_flags_in_cc=with_flags_in_cc)
4✔
537
        return env
4✔
538

539
    def prebuild_arch(self, arch):
4✔
540
        '''Run any pre-build tasks for the Recipe. By default, this checks if
541
        any prebuild_archname methods exist for the archname of the current
542
        architecture, and runs them if so.'''
543
        prebuild = "prebuild_{}".format(arch.arch.replace('-', '_'))
4✔
544
        if hasattr(self, prebuild):
4!
545
            getattr(self, prebuild)()
×
546
        else:
547
            info('{} has no {}, skipping'.format(self.name, prebuild))
4✔
548

549
    def is_patched(self, arch):
4✔
550
        build_dir = self.get_build_dir(arch.arch)
4✔
551
        return exists(join(build_dir, '.patched'))
4✔
552

553
    def apply_patches(self, arch, build_dir=None):
4✔
554
        '''Apply any patches for the Recipe.
555

556
        .. versionchanged:: 0.6.0
557
            Add ability to apply patches from any dir via kwarg `build_dir`'''
558
        if self.patches:
×
559
            info_main('Applying patches for {}[{}]'
×
560
                      .format(self.name, arch.arch))
561

562
            if self.is_patched(arch):
×
563
                info_main('{} already patched, skipping'.format(self.name))
×
564
                return
×
565

566
            build_dir = build_dir if build_dir else self.get_build_dir(arch.arch)
×
567
            for patch in self.patches:
×
568
                if isinstance(patch, (tuple, list)):
×
569
                    patch, patch_check = patch
×
570
                    if not patch_check(arch=arch, recipe=self):
×
571
                        continue
×
572

573
                self.apply_patch(
×
574
                        patch.format(version=self.version, arch=arch.arch),
575
                        arch.arch, build_dir=build_dir)
576

577
            touch(join(build_dir, '.patched'))
×
578

579
    def should_build(self, arch):
4✔
580
        '''Should perform any necessary test and return True only if it needs
581
        building again. Per default we implement a library test, in case that
582
        we detect so.
583
        '''
584
        if self.built_libraries:
4!
585
            return not all(
4✔
586
                exists(lib) for lib in self.get_libraries(arch.arch)
587
            )
588
        return True
×
589

590
    def build_arch(self, arch):
4✔
591
        '''Run any build tasks for the Recipe. By default, this checks if
592
        any build_archname methods exist for the archname of the current
593
        architecture, and runs them if so.'''
594
        build = "build_{}".format(arch.arch)
×
595
        if hasattr(self, build):
×
596
            getattr(self, build)()
×
597

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

611
    def postbuild_arch(self, arch):
4✔
612
        '''Run any post-build tasks for the Recipe. By default, this checks if
613
        any postbuild_archname methods exist for the archname of the
614
        current architecture, and runs them if so.
615
        '''
616
        postbuild = "postbuild_{}".format(arch.arch)
4✔
617
        if hasattr(self, postbuild):
4!
618
            getattr(self, postbuild)()
×
619

620
        if self.need_stl_shared:
4!
621
            self.install_stl_lib(arch)
4✔
622

623
    def prepare_build_dir(self, arch):
4✔
624
        '''Copies the recipe data into a build dir for the given arch. By
625
        default, this unpacks a downloaded recipe. You should override
626
        it (or use a Recipe subclass with different behaviour) if you
627
        want to do something else.
628
        '''
629
        self.unpack(arch)
×
630

631
    def clean_build(self, arch=None):
4✔
632
        '''Deletes all the build information of the recipe.
633

634
        If arch is not None, only this arch dir is deleted. Otherwise
635
        (the default) all builds for all archs are deleted.
636

637
        By default, this just deletes the main build dir. If the
638
        recipe has e.g. object files biglinked, or .so files stored
639
        elsewhere, you should override this method.
640

641
        This method is intended for testing purposes, it may have
642
        strange results. Rebuild everything if this seems to happen.
643

644
        '''
645
        if arch is None:
×
646
            base_dir = join(self.ctx.build_dir, 'other_builds', self.name)
×
647
        else:
648
            base_dir = self.get_build_container_dir(arch)
×
649
        dirs = glob.glob(base_dir + '-*')
×
650
        if exists(base_dir):
×
651
            dirs.append(base_dir)
×
652
        if not dirs:
×
653
            warning('Attempted to clean build for {} but found no existing '
×
654
                    'build dirs'.format(self.name))
655

656
        for directory in dirs:
×
657
            rmdir(directory)
×
658

659
        # Delete any Python distributions to ensure the recipe build
660
        # doesn't persist in site-packages
661
        rmdir(self.ctx.python_installs_dir)
×
662

663
    def install_libs(self, arch, *libs):
4✔
664
        libs_dir = self.ctx.get_libs_dir(arch.arch)
4✔
665
        if not libs:
4!
666
            warning('install_libs called with no libraries to install!')
×
667
            return
×
668
        args = libs + (libs_dir,)
4✔
669
        shprint(sh.cp, *args)
4✔
670

671
    def has_libs(self, arch, *libs):
4✔
672
        return all(map(lambda lib: self.ctx.has_lib(arch.arch, lib), libs))
×
673

674
    def get_libraries(self, arch_name, in_context=False):
4✔
675
        """Return the full path of the library depending on the architecture.
676
        Per default, the build library path it will be returned, unless
677
        `get_libraries` has been called with kwarg `in_context` set to
678
        True.
679

680
        .. note:: this method should be used for library recipes only
681
        """
682
        recipe_libs = set()
4✔
683
        if not self.built_libraries:
4!
684
            return recipe_libs
×
685
        for lib, rel_path in self.built_libraries.items():
4✔
686
            if not in_context:
4!
687
                abs_path = join(self.get_build_dir(arch_name), rel_path, lib)
4✔
688
                if rel_path in {".", "", None}:
4✔
689
                    abs_path = join(self.get_build_dir(arch_name), lib)
4✔
690
            else:
691
                abs_path = join(self.ctx.get_libs_dir(arch_name), lib)
×
692
            recipe_libs.add(abs_path)
4✔
693
        return recipe_libs
4✔
694

695
    @classmethod
4✔
696
    def recipe_dirs(cls, ctx):
4✔
697
        recipe_dirs = []
4✔
698
        if ctx.local_recipes is not None:
4✔
699
            recipe_dirs.append(realpath(ctx.local_recipes))
4✔
700
        if ctx.storage_dir:
4✔
701
            recipe_dirs.append(join(ctx.storage_dir, 'recipes'))
4✔
702
        recipe_dirs.append(join(ctx.root_dir, "recipes"))
4✔
703
        return recipe_dirs
4✔
704

705
    @classmethod
4✔
706
    def list_recipes(cls, ctx):
4✔
707
        forbidden_dirs = ('__pycache__', )
4✔
708
        for recipes_dir in cls.recipe_dirs(ctx):
4✔
709
            if recipes_dir and exists(recipes_dir):
4✔
710
                for name in listdir(recipes_dir):
4✔
711
                    if name in forbidden_dirs:
4✔
712
                        continue
4✔
713
                    fn = join(recipes_dir, name)
4✔
714
                    if isdir(fn):
4✔
715
                        yield name
4✔
716

717
    @classmethod
4✔
718
    def get_recipe(cls, name, ctx):
4✔
719
        '''Returns the Recipe with the given name, if it exists.'''
720
        name = name.lower()
4✔
721
        if not hasattr(cls, "recipes"):
4✔
722
            cls.recipes = {}
4✔
723
        if name in cls.recipes:
4✔
724
            return cls.recipes[name]
4✔
725

726
        recipe_file = None
4✔
727
        for recipes_dir in cls.recipe_dirs(ctx):
4✔
728
            if not exists(recipes_dir):
4✔
729
                continue
4✔
730
            # Find matching folder (may differ in case):
731
            for subfolder in listdir(recipes_dir):
4✔
732
                if subfolder.lower() == name:
4✔
733
                    recipe_file = join(recipes_dir, subfolder, '__init__.py')
4✔
734
                    if exists(recipe_file):
4!
735
                        name = subfolder  # adapt to actual spelling
4✔
736
                        break
4✔
737
                    recipe_file = None
×
738
            if recipe_file is not None:
4✔
739
                break
4✔
740

741
        else:
742
            raise ValueError('Recipe does not exist: {}'.format(name))
4✔
743

744
        mod = import_recipe('pythonforandroid.recipes.{}'.format(name), recipe_file)
4✔
745
        if len(logger.handlers) > 1:
4!
746
            logger.removeHandler(logger.handlers[1])
×
747
        recipe = mod.recipe
4✔
748
        recipe.ctx = ctx
4✔
749
        cls.recipes[name.lower()] = recipe
4✔
750
        return recipe
4✔
751

752

753
class IncludedFilesBehaviour(object):
4✔
754
    '''Recipe mixin class that will automatically unpack files included in
755
    the recipe directory.'''
756
    src_filename = None
4✔
757

758
    def prepare_build_dir(self, arch):
4✔
759
        if self.src_filename is None:
×
760
            raise BuildInterruptingException(
×
761
                'IncludedFilesBehaviour failed: no src_filename specified')
762
        rmdir(self.get_build_dir(arch))
×
763
        shprint(sh.cp, '-a', join(self.get_recipe_dir(), self.src_filename),
×
764
                self.get_build_dir(arch))
765

766

767
class BootstrapNDKRecipe(Recipe):
4✔
768
    '''A recipe class for recipes built in an Android project jni dir with
769
    an Android.mk. These are not cached separately, but built in the
770
    bootstrap's own building directory.
771

772
    To build an NDK project which is not part of the bootstrap, see
773
    :class:`~pythonforandroid.recipe.NDKRecipe`.
774

775
    To link with python, call the method :meth:`get_recipe_env`
776
    with the kwarg *with_python=True*.
777
    '''
778

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

781
    def get_build_container_dir(self, arch):
4✔
782
        return self.get_jni_dir()
×
783

784
    def get_build_dir(self, arch):
4✔
785
        if self.dir_name is None:
×
786
            raise ValueError('{} recipe doesn\'t define a dir_name, but '
×
787
                             'this is necessary'.format(self.name))
788
        return join(self.get_build_container_dir(arch), self.dir_name)
×
789

790
    def get_jni_dir(self):
4✔
791
        return join(self.ctx.bootstrap.build_dir, 'jni')
4✔
792

793
    def get_recipe_env(self, arch=None, with_flags_in_cc=True, with_python=False):
4✔
794
        env = super().get_recipe_env(arch, with_flags_in_cc)
×
795
        if not with_python:
×
796
            return env
×
797

798
        env['PYTHON_INCLUDE_ROOT'] = self.ctx.python_recipe.include_root(arch.arch)
×
799
        env['PYTHON_LINK_ROOT'] = self.ctx.python_recipe.link_root(arch.arch)
×
800
        env['EXTRA_LDLIBS'] = ' -lpython{}'.format(
×
801
            self.ctx.python_recipe.link_version)
802
        return env
×
803

804

805
class NDKRecipe(Recipe):
4✔
806
    '''A recipe class for any NDK project not included in the bootstrap.'''
807

808
    generated_libraries = []
4✔
809

810
    def should_build(self, arch):
4✔
811
        lib_dir = self.get_lib_dir(arch)
×
812

813
        for lib in self.generated_libraries:
×
814
            if not exists(join(lib_dir, lib)):
×
815
                return True
×
816

817
        return False
×
818

819
    def get_lib_dir(self, arch):
4✔
820
        return join(self.get_build_dir(arch.arch), 'obj', 'local', arch.arch)
×
821

822
    def get_jni_dir(self, arch):
4✔
823
        return join(self.get_build_dir(arch.arch), 'jni')
×
824

825
    def build_arch(self, arch, *extra_args):
4✔
826
        super().build_arch(arch)
×
827

828
        env = self.get_recipe_env(arch)
×
829
        with current_directory(self.get_build_dir(arch.arch)):
×
830
            shprint(
×
831
                sh.Command(join(self.ctx.ndk_dir, "ndk-build")),
832
                'V=1',
833
                'NDK_DEBUG=' + ("1" if self.ctx.build_as_debuggable else "0"),
834
                'APP_PLATFORM=android-' + str(self.ctx.ndk_api),
835
                'APP_ABI=' + arch.arch,
836
                *extra_args, _env=env
837
            )
838

839

840
class PythonRecipe(Recipe):
4✔
841
    site_packages_name = None
4✔
842
    '''The name of the module's folder when installed in the Python
2✔
843
    site-packages (e.g. for pyjnius it is 'jnius')'''
844

845
    call_hostpython_via_targetpython = True
4✔
846
    '''If True, tries to install the module using the hostpython binary
2✔
847
    copied to the target (normally arm) python build dir. However, this
848
    will fail if the module tries to import e.g. _io.so. Set this to False
849
    to call hostpython from its own build dir, installing the module in
850
    the right place via arguments to setup.py. However, this may not set
851
    the environment correctly and so False is not the default.'''
852

853
    install_in_hostpython = False
4✔
854
    '''If True, additionally installs the module in the hostpython build
2✔
855
    dir. This will make it available to other recipes if
856
    call_hostpython_via_targetpython is False.
857
    '''
858

859
    install_in_targetpython = True
4✔
860
    '''If True, installs the module in the targetpython installation dir.
2✔
861
    This is almost always what you want to do.'''
862

863
    setup_extra_args = []
4✔
864
    '''List of extra arguments to pass to setup.py'''
2✔
865

866
    depends = ['python3']
4✔
867
    '''
2✔
868
    .. note:: it's important to keep this depends as a class attribute outside
869
              `__init__` because sometimes we only initialize the class, so the
870
              `__init__` call won't be called and the deps would be missing
871
              (which breaks the dependency graph computation)
872

873
    .. warning:: don't forget to call `super().__init__()` in any recipe's
874
                 `__init__`, or otherwise it may not be ensured that it depends
875
                 on python2 or python3 which can break the dependency graph
876
    '''
877

878
    hostpython_prerequisites = []
4✔
879
    '''List of hostpython packages required to build a recipe'''
2✔
880

881
    def __init__(self, *args, **kwargs):
4✔
882
        super().__init__(*args, **kwargs)
4✔
883
        if 'python3' not in self.depends:
4✔
884
            # We ensure here that the recipe depends on python even it overrode
885
            # `depends`. We only do this if it doesn't already depend on any
886
            # python, since some recipes intentionally don't depend on/work
887
            # with all python variants
888
            depends = self.depends
4✔
889
            depends.append('python3')
4✔
890
            depends = list(set(depends))
4✔
891
            self.depends = depends
4✔
892

893
    def clean_build(self, arch=None):
4✔
894
        super().clean_build(arch=arch)
×
895
        name = self.folder_name
×
896
        python_install_dirs = glob.glob(join(self.ctx.python_installs_dir, '*'))
×
897
        for python_install in python_install_dirs:
×
898
            site_packages_dir = glob.glob(join(python_install, 'lib', 'python*',
×
899
                                               'site-packages'))
900
            if site_packages_dir:
×
901
                build_dir = join(site_packages_dir[0], name)
×
902
                if exists(build_dir):
×
903
                    info('Deleted {}'.format(build_dir))
×
904
                    rmdir(build_dir)
×
905

906
    @property
4✔
907
    def real_hostpython_location(self):
4✔
908
        host_name = 'host{}'.format(self.ctx.python_recipe.name)
4✔
909
        if host_name == 'hostpython3':
4!
910
            python_recipe = Recipe.get_recipe(host_name, self.ctx)
4✔
911
            return python_recipe.python_exe
4✔
912
        else:
913
            python_recipe = self.ctx.python_recipe
×
914
            return 'python{}'.format(python_recipe.version)
×
915

916
    @property
4✔
917
    def hostpython_location(self):
4✔
918
        if not self.call_hostpython_via_targetpython:
4!
919
            return self.real_hostpython_location
4✔
920
        return self.ctx.hostpython
×
921

922
    @property
4✔
923
    def folder_name(self):
4✔
924
        '''The name of the build folders containing this recipe.'''
925
        name = self.site_packages_name
×
926
        if name is None:
×
927
            name = self.name
×
928
        return name
×
929

930
    def get_recipe_env(self, arch=None, with_flags_in_cc=True):
4✔
931
        env = super().get_recipe_env(arch, with_flags_in_cc)
4✔
932
        env['PYTHONNOUSERSITE'] = '1'
4✔
933
        # Set the LANG, this isn't usually important but is a better default
934
        # as it occasionally matters how Python e.g. reads files
935
        env['LANG'] = "en_GB.UTF-8"
4✔
936
        # Binaries made by packages installed by pip
937
        env["PATH"] = join(self.hostpython_site_dir, "bin") + ":" + env["PATH"]
4✔
938

939
        if not self.call_hostpython_via_targetpython:
4!
940
            env['CFLAGS'] += ' -I{}'.format(
4✔
941
                self.ctx.python_recipe.include_root(arch.arch)
942
            )
943
            env['LDFLAGS'] += ' -L{} -lpython{}'.format(
4✔
944
                self.ctx.python_recipe.link_root(arch.arch),
945
                self.ctx.python_recipe.link_version,
946
            )
947

948
            hppath = []
4✔
949
            hppath.append(join(dirname(self.hostpython_location), 'Lib'))
4✔
950
            hppath.append(join(hppath[0], 'site-packages'))
4✔
951
            builddir = join(dirname(self.hostpython_location), 'build')
4✔
952
            if exists(builddir):
4!
953
                hppath += [join(builddir, d) for d in listdir(builddir)
×
954
                           if isdir(join(builddir, d))]
955
            if len(hppath) > 0:
4!
956
                if 'PYTHONPATH' in env:
4!
957
                    env['PYTHONPATH'] = ':'.join(hppath + [env['PYTHONPATH']])
×
958
                else:
959
                    env['PYTHONPATH'] = ':'.join(hppath)
4✔
960
        return env
4✔
961

962
    def should_build(self, arch):
4✔
963
        name = self.folder_name
×
964
        if self.ctx.has_package(name, arch):
×
965
            info('Python package already exists in site-packages')
×
966
            return False
×
967
        info('{} apparently isn\'t already in site-packages'.format(name))
×
968
        return True
×
969

970
    def build_arch(self, arch):
4✔
971
        '''Install the Python module by calling setup.py install with
972
        the target Python dir.'''
973
        self.install_hostpython_prerequisites()
×
974
        super().build_arch(arch)
×
975
        self.install_python_package(arch)
×
976

977
    def install_python_package(self, arch, name=None, env=None, is_dir=True):
4✔
978
        '''Automate the installation of a Python package (or a cython
979
        package where the cython components are pre-built).'''
980
        # arch = self.filtered_archs[0]  # old kivy-ios way
981
        if name is None:
×
982
            name = self.name
×
983
        if env is None:
×
984
            env = self.get_recipe_env(arch)
×
985

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

988
        hostpython = sh.Command(self.hostpython_location)
×
989
        hpenv = env.copy()
×
990
        with current_directory(self.get_build_dir(arch.arch)):
×
991
            shprint(hostpython, 'setup.py', 'install', '-O2',
×
992
                    '--root={}'.format(self.ctx.get_python_install_dir(arch.arch)),
993
                    '--install-lib=.',
994
                    _env=hpenv, *self.setup_extra_args)
995

996
            # If asked, also install in the hostpython build dir
997
            if self.install_in_hostpython:
×
998
                self.install_hostpython_package(arch)
×
999

1000
    def get_hostrecipe_env(self, arch):
4✔
1001
        env = environ.copy()
×
1002
        env['PYTHONPATH'] = self.hostpython_site_dir
×
1003
        return env
×
1004

1005
    @property
4✔
1006
    def hostpython_site_dir(self):
4✔
1007
        return join(dirname(self.real_hostpython_location), 'Lib', 'site-packages')
4✔
1008

1009
    def install_hostpython_package(self, arch):
4✔
1010
        env = self.get_hostrecipe_env(arch)
×
1011
        real_hostpython = sh.Command(self.real_hostpython_location)
×
1012
        shprint(real_hostpython, 'setup.py', 'install', '-O2',
×
1013
                '--root={}'.format(dirname(self.real_hostpython_location)),
1014
                '--install-lib=Lib/site-packages',
1015
                _env=env, *self.setup_extra_args)
1016

1017
    @property
4✔
1018
    def python_major_minor_version(self):
4✔
1019
        parsed_version = packaging.version.parse(self.ctx.python_recipe.version)
×
1020
        return f"{parsed_version.major}.{parsed_version.minor}"
×
1021

1022
    def install_hostpython_prerequisites(self, packages=None, force_upgrade=True):
4✔
1023
        if not packages:
×
1024
            packages = self.hostpython_prerequisites
×
1025

1026
        if len(packages) == 0:
×
1027
            return
×
1028

1029
        pip_options = [
×
1030
            "install",
1031
            *packages,
1032
            "--target", self.hostpython_site_dir, "--python-version",
1033
            self.ctx.python_recipe.version,
1034
            # Don't use sources, instead wheels
1035
            "--only-binary=:all:",
1036
        ]
1037
        if force_upgrade:
×
1038
            pip_options.append("--upgrade")
×
1039
        # Use system's pip
1040
        shprint(sh.pip, *pip_options)
×
1041

1042
    def restore_hostpython_prerequisites(self, packages):
4✔
1043
        _packages = []
×
1044
        for package in packages:
×
1045
            original_version = Recipe.get_recipe(package, self.ctx).version
×
1046
            _packages.append(package + "==" + original_version)
×
1047
        self.install_hostpython_prerequisites(packages=_packages)
×
1048

1049

1050
class CompiledComponentsPythonRecipe(PythonRecipe):
4✔
1051
    pre_build_ext = False
4✔
1052

1053
    build_cmd = 'build_ext'
4✔
1054

1055
    def build_arch(self, arch):
4✔
1056
        '''Build any cython components, then install the Python module by
1057
        calling setup.py install with the target Python dir.
1058
        '''
1059
        Recipe.build_arch(self, arch)
×
1060
        self.install_hostpython_prerequisites()
×
1061
        self.build_compiled_components(arch)
×
1062
        self.install_python_package(arch)
×
1063

1064
    def build_compiled_components(self, arch):
4✔
1065
        info('Building compiled components in {}'.format(self.name))
×
1066

1067
        env = self.get_recipe_env(arch)
×
1068
        hostpython = sh.Command(self.hostpython_location)
×
1069
        with current_directory(self.get_build_dir(arch.arch)):
×
1070
            if self.install_in_hostpython:
×
1071
                shprint(hostpython, 'setup.py', 'clean', '--all', _env=env)
×
1072
            shprint(hostpython, 'setup.py', self.build_cmd, '-v',
×
1073
                    _env=env, *self.setup_extra_args)
1074
            build_dir = glob.glob('build/lib.*')[0]
×
1075
            shprint(sh.find, build_dir, '-name', '"*.o"', '-exec',
×
1076
                    env['STRIP'], '{}', ';', _env=env)
1077

1078
    def install_hostpython_package(self, arch):
4✔
1079
        env = self.get_hostrecipe_env(arch)
×
1080
        self.rebuild_compiled_components(arch, env)
×
1081
        super().install_hostpython_package(arch)
×
1082

1083
    def rebuild_compiled_components(self, arch, env):
4✔
1084
        info('Rebuilding compiled components in {}'.format(self.name))
×
1085

1086
        hostpython = sh.Command(self.real_hostpython_location)
×
1087
        shprint(hostpython, 'setup.py', 'clean', '--all', _env=env)
×
1088
        shprint(hostpython, 'setup.py', self.build_cmd, '-v', _env=env,
×
1089
                *self.setup_extra_args)
1090

1091

1092
class CppCompiledComponentsPythonRecipe(CompiledComponentsPythonRecipe):
4✔
1093
    """ Extensions that require the cxx-stl """
1094
    call_hostpython_via_targetpython = False
4✔
1095
    need_stl_shared = True
4✔
1096

1097

1098
class CythonRecipe(PythonRecipe):
4✔
1099
    pre_build_ext = False
4✔
1100
    cythonize = True
4✔
1101
    cython_args = []
4✔
1102
    call_hostpython_via_targetpython = False
4✔
1103

1104
    def build_arch(self, arch):
4✔
1105
        '''Build any cython components, then install the Python module by
1106
        calling setup.py install with the target Python dir.
1107
        '''
1108
        Recipe.build_arch(self, arch)
×
1109
        self.build_cython_components(arch)
×
1110
        self.install_python_package(arch)
×
1111

1112
    def build_cython_components(self, arch):
4✔
1113
        info('Cythonizing anything necessary in {}'.format(self.name))
×
1114

1115
        env = self.get_recipe_env(arch)
×
1116

1117
        with current_directory(self.get_build_dir(arch.arch)):
×
1118
            hostpython = sh.Command(self.ctx.hostpython)
×
1119
            shprint(hostpython, '-c', 'import sys; print(sys.path)', _env=env)
×
1120
            debug('cwd is {}'.format(realpath(curdir)))
×
1121
            info('Trying first build of {} to get cython files: this is '
×
1122
                 'expected to fail'.format(self.name))
1123

1124
            manually_cythonise = False
×
1125
            try:
×
1126
                shprint(hostpython, 'setup.py', 'build_ext', '-v', _env=env,
×
1127
                        *self.setup_extra_args)
1128
            except sh.ErrorReturnCode_1:
×
1129
                print()
×
1130
                info('{} first build failed (as expected)'.format(self.name))
×
1131
                manually_cythonise = True
×
1132

1133
            if manually_cythonise:
×
1134
                self.cythonize_build(env=env)
×
1135
                shprint(hostpython, 'setup.py', 'build_ext', '-v', _env=env,
×
1136
                        _tail=20, _critical=True, *self.setup_extra_args)
1137
            else:
1138
                info('First build appeared to complete correctly, skipping manual'
×
1139
                     'cythonising.')
1140

1141
            if not self.ctx.with_debug_symbols:
×
1142
                self.strip_object_files(arch, env)
×
1143

1144
    def strip_object_files(self, arch, env, build_dir=None):
4✔
1145
        if build_dir is None:
×
1146
            build_dir = self.get_build_dir(arch.arch)
×
1147
        with current_directory(build_dir):
×
1148
            info('Stripping object files')
×
1149
            shprint(sh.find, '.', '-iname', '*.so', '-exec',
×
1150
                    '/usr/bin/echo', '{}', ';', _env=env)
1151
            shprint(sh.find, '.', '-iname', '*.so', '-exec',
×
1152
                    env['STRIP'].split(' ')[0], '--strip-unneeded',
1153
                    # '/usr/bin/strip', '--strip-unneeded',
1154
                    '{}', ';', _env=env)
1155

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

1175
    def cythonize_build(self, env, build_dir="."):
4✔
1176
        if not self.cythonize:
×
1177
            info('Running cython cancelled per recipe setting')
×
1178
            return
×
1179
        info('Running cython where appropriate')
×
1180
        for root, dirnames, filenames in walk("."):
×
1181
            for filename in fnmatch.filter(filenames, "*.pyx"):
×
1182
                self.cythonize_file(env, build_dir, join(root, filename))
×
1183

1184
    def get_recipe_env(self, arch, with_flags_in_cc=True):
4✔
1185
        env = super().get_recipe_env(arch, with_flags_in_cc)
×
1186
        env['LDFLAGS'] = env['LDFLAGS'] + ' -L{} '.format(
×
1187
            self.ctx.get_libs_dir(arch.arch) +
1188
            ' -L{} '.format(self.ctx.libs_dir) +
1189
            ' -L{}'.format(join(self.ctx.bootstrap.build_dir, 'obj', 'local',
1190
                                arch.arch)))
1191

1192
        env['LDSHARED'] = env['CC'] + ' -shared'
×
1193
        # shprint(sh.whereis, env['LDSHARED'], _env=env)
1194
        env['LIBLINK'] = 'NOTNONE'
×
1195
        if self.ctx.copy_libs:
×
1196
            env['COPYLIBS'] = '1'
×
1197

1198
        # Every recipe uses its own liblink path, object files are
1199
        # collected and biglinked later
1200
        liblink_path = join(self.get_build_container_dir(arch.arch),
×
1201
                            'objects_{}'.format(self.name))
1202
        env['LIBLINK_PATH'] = liblink_path
×
1203
        ensure_dir(liblink_path)
×
1204

1205
        return env
×
1206

1207

1208
class PyProjectRecipe(PythonRecipe):
4✔
1209
    """Recipe for projects which contain `pyproject.toml`"""
1210

1211
    # Extra args to pass to `python -m build ...`
1212
    extra_build_args = []
4✔
1213
    call_hostpython_via_targetpython = False
4✔
1214

1215
    def get_recipe_env(self, arch, **kwargs):
4✔
1216
        # Custom hostpython
1217
        self.ctx.python_recipe.python_exe = join(
4✔
1218
            self.ctx.python_recipe.get_build_dir(arch), "android-build", "python3")
1219
        env = super().get_recipe_env(arch, **kwargs)
4✔
1220
        build_dir = self.get_build_dir(arch)
4✔
1221
        ensure_dir(build_dir)
4✔
1222
        build_opts = join(build_dir, "build-opts.cfg")
4✔
1223

1224
        with open(build_opts, "w") as file:
4✔
1225
            file.write("[bdist_wheel]\nplat-name={}".format(
4✔
1226
                self.get_wheel_platform_tag(arch)
1227
            ))
1228
            file.close()
4✔
1229

1230
        env["DIST_EXTRA_CONFIG"] = build_opts
4✔
1231
        return env
4✔
1232

1233
    def get_wheel_platform_tag(self, arch):
4✔
1234
        return "android_" + {
4✔
1235
            "armeabi-v7a": "arm",
1236
            "arm64-v8a": "aarch64",
1237
            "x86_64": "x86_64",
1238
            "x86": "i686",
1239
        }[arch.arch]
1240

1241
    def install_wheel(self, arch, built_wheels):
4✔
1242
        with patch_wheel_setuptools_logging():
×
1243
            from wheel.cli.tags import tags as wheel_tags
×
1244
            from wheel.wheelfile import WheelFile
×
1245
        _wheel = built_wheels[0]
×
1246
        built_wheel_dir = dirname(_wheel)
×
1247
        # Fix wheel platform tag
1248
        wheel_tag = wheel_tags(
×
1249
            _wheel,
1250
            platform_tags=self.get_wheel_platform_tag(arch),
1251
            remove=True,
1252
        )
1253
        selected_wheel = join(built_wheel_dir, wheel_tag)
×
1254

1255
        _dev_wheel_dir = environ.get("P4A_WHEEL_DIR", False)
×
1256
        if _dev_wheel_dir:
×
1257
            ensure_dir(_dev_wheel_dir)
×
1258
            shprint(sh.cp, selected_wheel, _dev_wheel_dir)
×
1259

1260
        info(f"Installing built wheel: {wheel_tag}")
×
1261
        destination = self.ctx.get_python_install_dir(arch.arch)
×
1262
        with WheelFile(selected_wheel) as wf:
×
1263
            for zinfo in wf.filelist:
×
1264
                wf.extract(zinfo, destination)
×
1265
            wf.close()
×
1266

1267
    def build_arch(self, arch):
4✔
1268
        self.install_hostpython_prerequisites(
×
1269
            packages=["build[virtualenv]", "pip"] + self.hostpython_prerequisites
1270
        )
1271
        build_dir = self.get_build_dir(arch.arch)
×
1272
        env = self.get_recipe_env(arch, with_flags_in_cc=True)
×
1273
        # make build dir separately
1274
        sub_build_dir = join(build_dir, "p4a_android_build")
×
1275
        ensure_dir(sub_build_dir)
×
1276
        # copy hostpython to built python to ensure correct selection of libs and includes
1277
        shprint(sh.cp, self.real_hostpython_location, self.ctx.python_recipe.python_exe)
×
1278

1279
        build_args = [
×
1280
            "-m",
1281
            "build",
1282
            "--wheel",
1283
            "--config-setting",
1284
            "builddir={}".format(sub_build_dir),
1285
        ] + self.extra_build_args
1286

1287
        built_wheels = []
×
1288
        with current_directory(build_dir):
×
1289
            shprint(
×
1290
                sh.Command(self.ctx.python_recipe.python_exe), *build_args, _env=env
1291
            )
1292
            built_wheels = [realpath(whl) for whl in glob.glob("dist/*.whl")]
×
1293
        self.install_wheel(arch, built_wheels)
×
1294

1295

1296
class MesonRecipe(PyProjectRecipe):
4✔
1297
    '''Recipe for projects which uses meson as build system'''
1298

1299
    meson_version = "1.4.0"
4✔
1300
    ninja_version = "1.11.1.1"
4✔
1301

1302
    def sanitize_flags(self, *flag_strings):
4✔
1303
        return " ".join(flag_strings).strip().split(" ")
×
1304

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

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

1362
    def ensure_args(self, *args):
4✔
1363
        for arg in args:
×
1364
            if arg not in self.extra_build_args:
×
1365
                self.extra_build_args.append(arg)
×
1366

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

1385

1386
class RustCompiledComponentsRecipe(PyProjectRecipe):
4✔
1387
    # Rust toolchain codes
1388
    # https://doc.rust-lang.org/nightly/rustc/platform-support.html
1389
    RUST_ARCH_CODES = {
4✔
1390
        "arm64-v8a": "aarch64-linux-android",
1391
        "armeabi-v7a": "armv7-linux-androideabi",
1392
        "x86_64": "x86_64-linux-android",
1393
        "x86": "i686-linux-android",
1394
    }
1395

1396
    call_hostpython_via_targetpython = False
4✔
1397

1398
    def get_recipe_env(self, arch, **kwargs):
4✔
1399
        env = super().get_recipe_env(arch, **kwargs)
×
1400

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

1420
        env["RUSTFLAGS"] = "-Clink-args=-L{} -L{}".format(
×
1421
            self.ctx.get_libs_dir(arch.arch), join(realpython_dir, "android-build")
1422
        )
1423

1424
        env["PYO3_CROSS_LIB_DIR"] = realpath(glob.glob(join(
×
1425
            realpython_dir, "android-build", "build",
1426
            "lib.linux-*-{}/".format(self.python_major_minor_version),
1427
        ))[0])
1428

1429
        info_main("Ensuring rust build toolchain")
×
1430
        shprint(sh.rustup, "target", "add", build_target)
×
1431

1432
        # Add host python to PATH
1433
        env["PATH"] = ("{hostpython_dir}:{old_path}").format(
×
1434
            hostpython_dir=Recipe.get_recipe(
1435
                "hostpython3", self.ctx
1436
            ).get_path_to_python(),
1437
            old_path=env["PATH"],
1438
        )
1439
        return env
×
1440

1441
    def check_host_deps(self):
4✔
1442
        if not hasattr(sh, "rustup"):
×
1443
            error(
×
1444
                "`rustup` was not found on host system."
1445
                "Please install it using :"
1446
                "\n`curl https://sh.rustup.rs -sSf | sh`\n"
1447
            )
1448
            exit(1)
×
1449

1450
    def build_arch(self, arch):
4✔
1451
        self.check_host_deps()
×
1452
        super().build_arch(arch)
×
1453

1454

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

1459
    def __init__(self, *args, **kwargs):
4✔
1460
        self._ctx = None
4✔
1461
        super().__init__(*args, **kwargs)
4✔
1462

1463
    def prebuild_arch(self, arch):
4✔
1464
        super().prebuild_arch(arch)
×
1465
        self.ctx.python_recipe = self
×
1466

1467
    def include_root(self, arch):
4✔
1468
        '''The root directory from which to include headers.'''
1469
        raise NotImplementedError('Not implemented in TargetPythonRecipe')
1470

1471
    def link_root(self):
4✔
1472
        raise NotImplementedError('Not implemented in TargetPythonRecipe')
1473

1474
    @property
4✔
1475
    def major_minor_version_string(self):
4✔
1476
        parsed_version = packaging.version.parse(self.version)
4✔
1477
        return f"{parsed_version.major}.{parsed_version.minor}"
4✔
1478

1479
    def create_python_bundle(self, dirn, arch):
4✔
1480
        """
1481
        Create a packaged python bundle in the target directory, by
1482
        copying all the modules and standard library to the right
1483
        place.
1484
        """
1485
        raise NotImplementedError('{} does not implement create_python_bundle'.format(self))
1486

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

1504

1505
def algsum(alg, filen):
4✔
1506
    '''Calculate the digest of a file.
1507
    '''
1508
    with open(filen, 'rb') as fileh:
×
1509
        digest = getattr(hashlib, alg)(fileh.read())
×
1510

1511
    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