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

kivy / python-for-android / 22259302242

21 Feb 2026 03:24PM UTC coverage: 63.887% (+4.7%) from 59.214%
22259302242

Pull #3198

github

web-flow
Merge 758a52847 into 1fc026943
Pull Request #3198: Bump SDL3 (`3.4.2`) and SDL3_image (`3.4.0`) to the latest stable releases.

1823 of 3111 branches covered (58.6%)

Branch coverage included in aggregate %.

3 of 4 new or added lines in 3 files covered. (75.0%)

788 existing lines in 24 files now uncovered.

5287 of 8018 relevant lines covered (65.94%)

5.26 hits per line

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

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

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

22
import packaging.version
8✔
23

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

31

32
url_opener = urllib.request.build_opener()
8✔
33
url_orig_headers = url_opener.addheaders
8✔
34
urllib.request.install_opener(url_opener)
8✔
35

36

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

45
        return super().__new__(cls, name, bases, dct)
8✔
46

47

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

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

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

63
    _download_headers = None
8✔
64
    '''Add additional headers used when downloading the package, typically
6✔
65
    for authorization purposes.
66

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

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

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

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

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

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

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

100
    depends = []
8✔
101
    '''A list containing the names of any recipes that this recipe depends on.
6✔
102
    '''
103

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

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

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

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

124
    archs = ['armeabi']  # Not currently implemented properly
8✔
125

126
    built_libraries = {}
8✔
127
    """Each recipe that builds a system library (e.g.:libffi, openssl, etc...)
6✔
128
    should contain a dict holding the relevant information of the library. The
129
    keys should be the generated libraries and the values the relative path of
130
    the library inside his build folder. This dict will be used to perform
131
    different operations:
132

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

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

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

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

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

151
    stl_lib_name = 'c++_shared'
8✔
152
    '''
6✔
153
    The default STL shared lib to use: `c++_shared`.
154

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

159
    min_ndk_api_support = 20
8✔
160
    '''
6✔
161
    Minimum ndk api recipe will support.
162
    '''
163

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

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

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

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

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

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

205
        return environ.get(key, self._download_headers)
8✔
206

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

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

216
        if cwd:
8!
UNCOV
217
            target = join(cwd, target)
×
218

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

231
            if exists(target):
8!
UNCOV
232
                unlink(target)
×
233

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

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

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

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

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

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

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

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

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

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

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

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

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

368
        return join(self.get_build_container_dir(arch), self.name)
8✔
369

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

381
    # Public Recipe API to be subclassed if needed
382

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

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

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

415
        ensure_dir(join(self.ctx.packages_path, self.name))
8✔
416

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

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

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

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

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

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

UNCOV
463
        build_dir = self.get_build_container_dir(arch)
×
464

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

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

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

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

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

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

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

539
        for proxy_key in ['HTTP_PROXY', 'http_proxy', 'HTTPS_PROXY', 'https_proxy']:
8✔
540
            if proxy_key in environ:
8!
UNCOV
541
                env[proxy_key] = environ[proxy_key]
×
542

543
        return env
8✔
544

545
    def prebuild_arch(self, arch):
8✔
546
        '''Run any pre-build tasks for the Recipe. By default, this checks if
547
        any prebuild_archname methods exist for the archname of the current
548
        architecture, and runs them if so.'''
549
        prebuild = "prebuild_{}".format(arch.arch.replace('-', '_'))
8✔
550
        if hasattr(self, prebuild):
8!
UNCOV
551
            getattr(self, prebuild)()
×
552
        else:
553
            info('{} has no {}, skipping'.format(self.name, prebuild))
8✔
554

555
    def is_patched(self, arch):
8✔
UNCOV
556
        build_dir = self.get_build_dir(arch.arch)
×
UNCOV
557
        return exists(join(build_dir, '.patched'))
×
558

559
    def apply_patches(self, arch, build_dir=None):
8✔
560
        '''Apply any patches for the Recipe.
561

562
        .. versionchanged:: 0.6.0
563
            Add ability to apply patches from any dir via kwarg `build_dir`'''
564
        if self.patches:
×
UNCOV
565
            info_main('Applying patches for {}[{}]'
×
566
                      .format(self.name, arch.arch))
567

568
            if self.is_patched(arch):
×
569
                info_main('{} already patched, skipping'.format(self.name))
×
570
                return
×
571

UNCOV
572
            build_dir = build_dir if build_dir else self.get_build_dir(arch.arch)
×
573
            for patch in self.patches:
×
UNCOV
574
                if isinstance(patch, (tuple, list)):
×
UNCOV
575
                    patch, patch_check = patch
×
UNCOV
576
                    if not patch_check(arch=arch, recipe=self):
×
577
                        continue
×
578

UNCOV
579
                self.apply_patch(
×
580
                        patch.format(version=self.version, arch=arch.arch),
581
                        arch.arch, build_dir=build_dir)
582

UNCOV
583
            touch(join(build_dir, '.patched'))
×
584

585
    def should_build(self, arch):
8✔
586
        '''Should perform any necessary test and return True only if it needs
587
        building again. Per default we implement a library test, in case that
588
        we detect so.
589
        '''
590
        if self.built_libraries:
8!
591
            return not all(
8✔
592
                exists(lib) for lib in self.get_libraries(arch.arch)
593
            )
594
        return True
×
595

596
    def build_arch(self, arch):
8✔
597
        '''Run any build tasks for the Recipe. By default, this checks if
598
        any build_archname methods exist for the archname of the current
599
        architecture, and runs them if so.'''
UNCOV
600
        build = "build_{}".format(arch.arch)
×
UNCOV
601
        if hasattr(self, build):
×
UNCOV
602
            getattr(self, build)()
×
603

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

617
    def postbuild_arch(self, arch):
8✔
618
        '''Run any post-build tasks for the Recipe. By default, this checks if
619
        any postbuild_archname methods exist for the archname of the
620
        current architecture, and runs them if so.
621
        '''
622
        postbuild = "postbuild_{}".format(arch.arch)
8✔
623
        if hasattr(self, postbuild):
8!
UNCOV
624
            getattr(self, postbuild)()
×
625

626
        if self.need_stl_shared:
8!
627
            self.install_stl_lib(arch)
8✔
628

629
    def prepare_build_dir(self, arch):
8✔
630
        '''Copies the recipe data into a build dir for the given arch. By
631
        default, this unpacks a downloaded recipe. You should override
632
        it (or use a Recipe subclass with different behaviour) if you
633
        want to do something else.
634
        '''
UNCOV
635
        self.unpack(arch)
×
636

637
    def clean_build(self, arch=None):
8✔
638
        '''Deletes all the build information of the recipe.
639

640
        If arch is not None, only this arch dir is deleted. Otherwise
641
        (the default) all builds for all archs are deleted.
642

643
        By default, this just deletes the main build dir. If the
644
        recipe has e.g. object files biglinked, or .so files stored
645
        elsewhere, you should override this method.
646

647
        This method is intended for testing purposes, it may have
648
        strange results. Rebuild everything if this seems to happen.
649

650
        '''
651
        if arch is None:
×
652
            base_dir = join(self.ctx.build_dir, 'other_builds', self.name)
×
653
        else:
UNCOV
654
            base_dir = self.get_build_container_dir(arch)
×
UNCOV
655
        dirs = glob.glob(base_dir + '-*')
×
656
        if exists(base_dir):
×
657
            dirs.append(base_dir)
×
UNCOV
658
        if not dirs:
×
UNCOV
659
            warning('Attempted to clean build for {} but found no existing '
×
660
                    'build dirs'.format(self.name))
661

UNCOV
662
        for directory in dirs:
×
UNCOV
663
            rmdir(directory)
×
664

665
        # Delete any Python distributions to ensure the recipe build
666
        # doesn't persist in site-packages
667
        rmdir(self.ctx.python_installs_dir)
×
668

669
    def install_libs(self, arch, *libs):
8✔
670
        libs_dir = self.ctx.get_libs_dir(arch.arch)
8✔
671
        if not libs:
8!
672
            warning('install_libs called with no libraries to install!')
×
UNCOV
673
            return
×
674
        args = libs + (libs_dir,)
8✔
675
        shprint(sh.cp, *args)
8✔
676

677
    def has_libs(self, arch, *libs):
8✔
UNCOV
678
        return all(map(lambda lib: self.ctx.has_lib(arch.arch, lib), libs))
×
679

680
    def get_libraries(self, arch_name, in_context=False):
8✔
681
        """Return the full path of the library depending on the architecture.
682
        Per default, the build library path it will be returned, unless
683
        `get_libraries` has been called with kwarg `in_context` set to
684
        True.
685

686
        .. note:: this method should be used for library recipes only
687
        """
688
        recipe_libs = set()
8✔
689
        if not self.built_libraries:
8!
UNCOV
690
            return recipe_libs
×
691
        for lib, rel_path in self.built_libraries.items():
8✔
692
            if not in_context:
8!
693
                abs_path = join(self.get_build_dir(arch_name), rel_path, lib)
8✔
694
                if rel_path in {".", "", None}:
8✔
695
                    abs_path = join(self.get_build_dir(arch_name), lib)
8✔
696
            else:
UNCOV
697
                abs_path = join(self.ctx.get_libs_dir(arch_name), lib)
×
698
            recipe_libs.add(abs_path)
8✔
699
        return recipe_libs
8✔
700

701
    @classmethod
8✔
702
    def recipe_dirs(cls, ctx):
8✔
703
        recipe_dirs = []
8✔
704
        if ctx.local_recipes is not None:
8✔
705
            recipe_dirs.append(realpath(ctx.local_recipes))
8✔
706
        if ctx.storage_dir:
8✔
707
            recipe_dirs.append(join(ctx.storage_dir, 'recipes'))
8✔
708
        recipe_dirs.append(join(ctx.root_dir, "recipes"))
8✔
709
        return recipe_dirs
8✔
710

711
    @classmethod
8✔
712
    def list_recipes(cls, ctx):
8✔
713
        forbidden_dirs = ('__pycache__', )
8✔
714
        for recipes_dir in cls.recipe_dirs(ctx):
8✔
715
            if recipes_dir and exists(recipes_dir):
8✔
716
                for name in listdir(recipes_dir):
8✔
717
                    if name in forbidden_dirs:
8✔
718
                        continue
8✔
719
                    fn = join(recipes_dir, name)
8✔
720
                    if isdir(fn):
8✔
721
                        yield name
8✔
722

723
    @classmethod
8✔
724
    def get_recipe(cls, name, ctx):
8✔
725
        '''Returns the Recipe with the given name, if it exists.'''
726
        name = name.lower()
8✔
727
        if not hasattr(cls, "recipes"):
8✔
728
            cls.recipes = {}
8✔
729
        if name in cls.recipes:
8✔
730
            return cls.recipes[name]
8✔
731

732
        recipe_file = None
8✔
733
        for recipes_dir in cls.recipe_dirs(ctx):
8✔
734
            if not exists(recipes_dir):
8✔
735
                continue
8✔
736
            # Find matching folder (may differ in case):
737
            for subfolder in listdir(recipes_dir):
8✔
738
                if subfolder.lower() == name:
8✔
739
                    recipe_file = join(recipes_dir, subfolder, '__init__.py')
8✔
740
                    if exists(recipe_file):
8!
741
                        name = subfolder  # adapt to actual spelling
8✔
742
                        break
8✔
UNCOV
743
                    recipe_file = None
×
744
            if recipe_file is not None:
8✔
745
                break
8✔
746

747
        else:
748
            raise ValueError('Recipe does not exist: {}'.format(name))
8✔
749

750
        mod = import_recipe('pythonforandroid.recipes.{}'.format(name), recipe_file)
8✔
751
        if len(logger.handlers) > 1:
8!
UNCOV
752
            logger.removeHandler(logger.handlers[1])
×
753
        recipe = mod.recipe
8✔
754
        recipe.ctx = ctx
8✔
755
        cls.recipes[name.lower()] = recipe
8✔
756
        return recipe
8✔
757

758

759
class IncludedFilesBehaviour(object):
8✔
760
    '''Recipe mixin class that will automatically unpack files included in
761
    the recipe directory.'''
762
    src_filename = None
8✔
763

764
    def prepare_build_dir(self, arch):
8✔
UNCOV
765
        if self.src_filename is None:
×
UNCOV
766
            raise BuildInterruptingException(
×
767
                'IncludedFilesBehaviour failed: no src_filename specified')
UNCOV
768
        rmdir(self.get_build_dir(arch))
×
UNCOV
769
        shprint(sh.cp, '-a', join(self.get_recipe_dir(), self.src_filename),
×
770
                self.get_build_dir(arch))
771

772

773
class BootstrapNDKRecipe(Recipe):
8✔
774
    '''A recipe class for recipes built in an Android project jni dir with
775
    an Android.mk. These are not cached separately, but built in the
776
    bootstrap's own building directory.
777

778
    To build an NDK project which is not part of the bootstrap, see
779
    :class:`~pythonforandroid.recipe.NDKRecipe`.
780

781
    To link with python, call the method :meth:`get_recipe_env`
782
    with the kwarg *with_python=True*.
783
    '''
784

785
    dir_name = None  # The name of the recipe build folder in the jni dir
8✔
786

787
    def get_build_container_dir(self, arch):
8✔
788
        return self.get_jni_dir()
×
789

790
    def get_build_dir(self, arch):
8✔
UNCOV
791
        if self.dir_name is None:
×
UNCOV
792
            raise ValueError('{} recipe doesn\'t define a dir_name, but '
×
793
                             'this is necessary'.format(self.name))
794
        return join(self.get_build_container_dir(arch), self.dir_name)
×
795

796
    def get_jni_dir(self):
8✔
797
        return join(self.ctx.bootstrap.build_dir, 'jni')
8✔
798

799
    def get_recipe_env(self, arch=None, with_flags_in_cc=True, with_python=False):
8✔
800
        env = super().get_recipe_env(arch, with_flags_in_cc)
×
UNCOV
801
        if not with_python:
×
802
            return env
×
803

UNCOV
804
        env['PYTHON_INCLUDE_ROOT'] = self.ctx.python_recipe.include_root(arch.arch)
×
UNCOV
805
        env['PYTHON_LINK_ROOT'] = self.ctx.python_recipe.link_root(arch.arch)
×
UNCOV
806
        env['EXTRA_LDLIBS'] = ' -lpython{}'.format(
×
807
            self.ctx.python_recipe.link_version)
UNCOV
808
        return env
×
809

810

811
class NDKRecipe(Recipe):
8✔
812
    '''A recipe class for any NDK project not included in the bootstrap.'''
813

814
    generated_libraries = []
8✔
815

816
    def should_build(self, arch):
8✔
817
        lib_dir = self.get_lib_dir(arch)
×
818

UNCOV
819
        for lib in self.generated_libraries:
×
820
            if not exists(join(lib_dir, lib)):
×
UNCOV
821
                return True
×
822

823
        return False
×
824

825
    def get_lib_dir(self, arch):
8✔
826
        return join(self.get_build_dir(arch.arch), 'obj', 'local', arch.arch)
×
827

828
    def get_jni_dir(self, arch):
8✔
829
        return join(self.get_build_dir(arch.arch), 'jni')
×
830

831
    def build_arch(self, arch, *extra_args):
8✔
UNCOV
832
        super().build_arch(arch)
×
833

UNCOV
834
        env = self.get_recipe_env(arch)
×
UNCOV
835
        with current_directory(self.get_build_dir(arch.arch)):
×
UNCOV
836
            shprint(
×
837
                sh.Command(join(self.ctx.ndk_dir, "ndk-build")),
838
                'V=1',
839
                "-j",
840
                str(cpu_count()),
841
                'NDK_DEBUG=' + ("1" if self.ctx.build_as_debuggable else "0"),
842
                'APP_PLATFORM=android-' + str(self.ctx.ndk_api),
843
                'APP_ABI=' + arch.arch,
844
                *extra_args, _env=env
845
            )
846

847

848
class PythonRecipe(Recipe):
8✔
849
    site_packages_name = None
8✔
850
    '''The name of the module's folder when installed in the Python
6✔
851
    site-packages (e.g. for pyjnius it is 'jnius')'''
852

853
    call_hostpython_via_targetpython = True
8✔
854
    '''If True, tries to install the module using the hostpython binary
6✔
855
    copied to the target (normally arm) python build dir. However, this
856
    will fail if the module tries to import e.g. _io.so. Set this to False
857
    to call hostpython from its own build dir, installing the module in
858
    the right place via arguments to setup.py. However, this may not set
859
    the environment correctly and so False is not the default.'''
860

861
    install_in_hostpython = False
8✔
862
    '''If True, additionally installs the module in the hostpython build
6✔
863
    dir. This will make it available to other recipes if
864
    call_hostpython_via_targetpython is False.
865
    '''
866

867
    install_in_targetpython = True
8✔
868
    '''If True, installs the module in the targetpython installation dir.
6✔
869
    This is almost always what you want to do.'''
870

871
    setup_extra_args = []
8✔
872
    '''List of extra arguments to pass to setup.py'''
6✔
873

874
    depends = ['python3']
8✔
875
    '''
6✔
876
    .. note:: it's important to keep this depends as a class attribute outside
877
              `__init__` because sometimes we only initialize the class, so the
878
              `__init__` call won't be called and the deps would be missing
879
              (which breaks the dependency graph computation)
880

881
    .. warning:: don't forget to call `super().__init__()` in any recipe's
882
                 `__init__`, or otherwise it may not be ensured that it depends
883
                 on python2 or python3 which can break the dependency graph
884
    '''
885

886
    hostpython_prerequisites = ['setuptools']
8✔
887
    '''List of hostpython packages required to build a recipe'''
6✔
888

889
    _host_recipe = None
8✔
890

891
    def __init__(self, *args, **kwargs):
8✔
892
        super().__init__(*args, **kwargs)
8✔
893
        if 'python3' not in self.depends:
8✔
894
            # We ensure here that the recipe depends on python even it overrode
895
            # `depends`. We only do this if it doesn't already depend on any
896
            # python, since some recipes intentionally don't depend on/work
897
            # with all python variants
898
            depends = self.depends
8✔
899
            depends.append('python3')
8✔
900
            depends = list(set(depends))
8✔
901
            self.depends = depends
8✔
902

903
    def prebuild_arch(self, arch):
8✔
904
        self._host_recipe = Recipe.get_recipe("hostpython3", self.ctx)
8✔
905
        return super().prebuild_arch(arch)
8✔
906

907
    def clean_build(self, arch=None):
8✔
UNCOV
908
        super().clean_build(arch=arch)
×
UNCOV
909
        name = self.folder_name
×
UNCOV
910
        python_install_dirs = glob.glob(join(self.ctx.python_installs_dir, '*'))
×
UNCOV
911
        for python_install in python_install_dirs:
×
UNCOV
912
            site_packages_dir = glob.glob(join(python_install, 'lib', 'python*',
×
913
                                               'site-packages'))
914
            if site_packages_dir:
×
UNCOV
915
                build_dir = join(site_packages_dir[0], name)
×
UNCOV
916
                if exists(build_dir):
×
UNCOV
917
                    info('Deleted {}'.format(build_dir))
×
UNCOV
918
                    rmdir(build_dir)
×
919

920
    @property
8✔
921
    def real_hostpython_location(self):
8✔
UNCOV
922
        host_name = 'host{}'.format(self.ctx.python_recipe.name)
×
UNCOV
923
        if host_name == 'hostpython3':
×
UNCOV
924
            return self._host_recipe.python_exe
×
925
        else:
926
            python_recipe = self.ctx.python_recipe
×
927
            return 'python{}'.format(python_recipe.version)
×
928

929
    @property
8✔
930
    def hostpython_location(self):
8✔
UNCOV
931
        if not self.call_hostpython_via_targetpython:
×
UNCOV
932
            return self.real_hostpython_location
×
UNCOV
933
        return self.ctx.hostpython
×
934

935
    @property
8✔
936
    def folder_name(self):
8✔
937
        '''The name of the build folders containing this recipe.'''
UNCOV
938
        name = self.site_packages_name
×
UNCOV
939
        if name is None:
×
UNCOV
940
            name = self.name
×
UNCOV
941
        return name
×
942

943
    def patch_shebang(self, _file, original_bin):
8✔
UNCOV
944
        _file_des = open(_file, "r")
×
945

UNCOV
946
        try:
×
UNCOV
947
            data = _file_des.readlines()
×
UNCOV
948
        except UnicodeDecodeError:
×
UNCOV
949
            return
×
950

UNCOV
951
        if "#!" in (line := data[0]):
×
UNCOV
952
            if line.split("#!")[-1].strip() == original_bin:
×
953
                return
×
954

UNCOV
955
            info(f"Fixing shebang for '{_file}'")
×
UNCOV
956
            data.pop(0)
×
957
            data.insert(0, "#!" + original_bin + "\n")
×
UNCOV
958
            _file_des.close()
×
UNCOV
959
            _file_des = open(_file, "w")
×
UNCOV
960
            _file_des.write("".join(data))
×
UNCOV
961
            _file_des.close()
×
962

963
    def patch_shebangs(self, path, original_bin):
8✔
964
        if not isdir(path):
8!
965
            warning(f"Shebang patch skipped: '{path}' does not exist.")
8✔
966
            return
8✔
967
        # set correct shebang
968
        for file in listdir(path):
×
UNCOV
969
            _file = join(path, file)
×
UNCOV
970
            self.patch_shebang(_file, original_bin)
×
971

972
    def get_recipe_env(self, arch=None, with_flags_in_cc=True):
8✔
973
        if self._host_recipe is None:
8!
974
            self._host_recipe = Recipe.get_recipe("hostpython3", self.ctx)
8✔
975

976
        env = super().get_recipe_env(arch, with_flags_in_cc)
8✔
977
        # Set the LANG, this isn't usually important but is a better default
978
        # as it occasionally matters how Python e.g. reads files
979
        env['LANG'] = "en_GB.UTF-8"
8✔
980

981
        # Binaries made by packages installed by pip
982
        self.patch_shebangs(self._host_recipe.local_bin, self._host_recipe.python_exe)
8✔
983
        env["PATH"] = self._host_recipe.local_bin + ":" + self._host_recipe.site_bin + ":" + env["PATH"]
8✔
984

985
        host_env = self.get_hostrecipe_env(arch)
8✔
986
        env['PYTHONPATH'] = host_env["PYTHONPATH"]
8✔
987

988
        if not self.call_hostpython_via_targetpython:
8!
989
            env['CFLAGS'] += ' -I{}'.format(
8✔
990
                self.ctx.python_recipe.include_root(arch.arch)
991
            )
992
            env['LDFLAGS'] += ' -L{} -lpython{}'.format(
8✔
993
                self.ctx.python_recipe.link_root(arch.arch),
994
                self.ctx.python_recipe.link_version,
995
            )
996

997
        return env
8✔
998

999
    def should_build(self, arch):
8✔
UNCOV
1000
        name = self.folder_name
×
1001
        if self.ctx.has_package(name, arch):
×
1002
            info('Python package already exists in site-packages')
×
1003
            return False
×
UNCOV
1004
        info('{} apparently isn\'t already in site-packages'.format(name))
×
UNCOV
1005
        return True
×
1006

1007
    def build_arch(self, arch):
8✔
1008
        '''Install the Python module by calling setup.py install with
1009
        the target Python dir.'''
1010
        self.install_hostpython_prerequisites()
×
1011
        super().build_arch(arch)
×
1012
        self.install_python_package(arch)
×
1013

1014
    def install_python_package(self, arch, name=None, env=None, is_dir=True):
8✔
1015
        '''Automate the installation of a Python package (or a cython
1016
        package where the cython components are pre-built).'''
1017
        # arch = self.filtered_archs[0]  # old kivy-ios way
UNCOV
1018
        if name is None:
×
1019
            name = self.name
×
1020
        if env is None:
×
UNCOV
1021
            env = self.get_recipe_env(arch)
×
1022

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

UNCOV
1025
        hpenv = env.copy()
×
1026
        with current_directory(self.get_build_dir(arch.arch)):
×
1027
            shprint(self._host_recipe.pip, 'install', '.',
×
1028
                    '--compile', '--target',
1029
                    self.ctx.get_python_install_dir(arch.arch),
1030
                    _env=hpenv, *self.setup_extra_args
1031
            )
1032

1033
    def get_hostrecipe_env(self, arch=None):
8✔
1034
        env = environ.copy()
8✔
1035
        _python_path = self._host_recipe.get_path_to_python()
8✔
1036
        libdir = glob.glob(join(_python_path, "build", "lib*"))
8✔
1037
        env['PYTHONPATH'] = self._host_recipe.site_dir + ":" + join(
8✔
1038
            _python_path, "Modules") + ":" + (libdir[0] if libdir else "")
1039
        return env
8✔
1040

1041
    @property
8✔
1042
    def hostpython_site_dir(self):
8✔
1043
        return join(dirname(self.real_hostpython_location), 'Lib', 'site-packages')
×
1044

1045
    def install_hostpython_package(self, arch):
8✔
1046
        env = self.get_hostrecipe_env(arch)
×
1047
        shprint(self._host_recipe.pip, 'install', '.',
×
1048
                '--compile',
1049
                '--root={}'.format(self._host_recipe.site_root),
1050
                _env=env, *self.setup_extra_args)
1051

1052
    @property
8✔
1053
    def python_major_minor_version(self):
8✔
UNCOV
1054
        parsed_version = packaging.version.parse(self.ctx.python_recipe.version)
×
UNCOV
1055
        return f"{parsed_version.major}.{parsed_version.minor}"
×
1056

1057
    def install_hostpython_prerequisites(self, packages=None, force_upgrade=True):
8✔
UNCOV
1058
        if not packages:
×
1059
            packages = self.hostpython_prerequisites
×
1060

1061
        if len(packages) == 0:
×
1062
            return
×
1063

UNCOV
1064
        pip_options = [
×
1065
            "install",
1066
            *packages,
1067
            "--target", self._host_recipe.site_dir, "--python-version",
1068
            self.ctx.python_recipe.version,
1069
            # Don't use sources, instead wheels
1070
            "--only-binary=:all:",
1071
        ]
1072
        if force_upgrade:
×
UNCOV
1073
            pip_options.append("--upgrade")
×
1074
        # Use system's pip
1075
        pip_env = self.get_hostrecipe_env()
×
UNCOV
1076
        shprint(self._host_recipe.pip, *pip_options, _env=pip_env)
×
1077

1078
    def restore_hostpython_prerequisites(self, packages):
8✔
1079
        _packages = []
×
1080
        for package in packages:
×
1081
            original_version = Recipe.get_recipe(package, self.ctx).version
×
UNCOV
1082
            _packages.append(package + "==" + original_version)
×
UNCOV
1083
        self.install_hostpython_prerequisites(packages=_packages)
×
1084

1085

1086
class CompiledComponentsPythonRecipe(PythonRecipe):
8✔
1087
    pre_build_ext = False
8✔
1088

1089
    build_cmd = 'build_ext'
8✔
1090

1091
    def build_arch(self, arch):
8✔
1092
        '''Build any cython components, then install the Python module by
1093
        calling pip install with the target Python dir.
1094
        '''
UNCOV
1095
        Recipe.build_arch(self, arch)
×
UNCOV
1096
        self.install_hostpython_prerequisites()
×
UNCOV
1097
        self.build_compiled_components(arch)
×
UNCOV
1098
        self.install_python_package(arch)
×
1099

1100
    def build_compiled_components(self, arch):
8✔
UNCOV
1101
        info('Building compiled components in {}'.format(self.name))
×
1102

UNCOV
1103
        env = self.get_recipe_env(arch)
×
UNCOV
1104
        hostpython = sh.Command(self.hostpython_location)
×
UNCOV
1105
        with current_directory(self.get_build_dir(arch.arch)):
×
UNCOV
1106
            if self.install_in_hostpython:
×
UNCOV
1107
                shprint(hostpython, 'setup.py', 'clean', '--all', _env=env)
×
1108
            shprint(hostpython, 'setup.py', self.build_cmd, '-v',
×
1109
                    _env=env, *self.setup_extra_args)
1110
            build_dir = glob.glob('build/lib.*')[0]
×
UNCOV
1111
            shprint(sh.find, build_dir, '-name', '"*.o"', '-exec',
×
1112
                    env['STRIP'], '{}', ';', _env=env)
1113

1114
    def install_hostpython_package(self, arch):
8✔
1115
        env = self.get_hostrecipe_env(arch)
×
UNCOV
1116
        self.rebuild_compiled_components(arch, env)
×
1117
        super().install_hostpython_package(arch)
×
1118

1119
    def rebuild_compiled_components(self, arch, env):
8✔
1120
        info('Rebuilding compiled components in {}'.format(self.name))
×
1121

UNCOV
1122
        hostpython = sh.Command(self.real_hostpython_location)
×
UNCOV
1123
        shprint(hostpython, 'setup.py', 'clean', '--all', _env=env)
×
1124
        shprint(hostpython, 'setup.py', self.build_cmd, '-v', _env=env,
×
1125
                *self.setup_extra_args)
1126

1127

1128
class CppCompiledComponentsPythonRecipe(CompiledComponentsPythonRecipe):
8✔
1129
    """ Extensions that require the cxx-stl """
1130
    call_hostpython_via_targetpython = False
8✔
1131
    need_stl_shared = True
8✔
1132

1133

1134
class CythonRecipe(PythonRecipe):
8✔
1135
    pre_build_ext = False
8✔
1136
    cythonize = True
8✔
1137
    cython_args = []
8✔
1138
    call_hostpython_via_targetpython = False
8✔
1139

1140
    def build_arch(self, arch):
8✔
1141
        '''Build any cython components, then install the Python module by
1142
        calling pip install with the target Python dir.
1143
        '''
UNCOV
1144
        Recipe.build_arch(self, arch)
×
1145
        self.build_cython_components(arch)
×
1146
        self.install_python_package(arch)
×
1147

1148
    def build_cython_components(self, arch):
8✔
1149
        info('Cythonizing anything necessary in {}'.format(self.name))
×
1150

1151
        env = self.get_recipe_env(arch)
×
1152

UNCOV
1153
        with current_directory(self.get_build_dir(arch.arch)):
×
UNCOV
1154
            hostpython = sh.Command(self.ctx.hostpython)
×
UNCOV
1155
            shprint(hostpython, '-c', 'import sys; print(sys.path)', _env=env)
×
UNCOV
1156
            debug('cwd is {}'.format(realpath(curdir)))
×
1157
            info('Trying first build of {} to get cython files: this is '
×
1158
                 'expected to fail'.format(self.name))
1159

1160
            manually_cythonise = False
×
1161
            try:
×
1162
                shprint(hostpython, 'setup.py', 'build_ext', '-v', _env=env,
×
1163
                        *self.setup_extra_args)
1164
            except sh.ErrorReturnCode_1:
×
1165
                print()
×
1166
                info('{} first build failed (as expected)'.format(self.name))
×
1167
                manually_cythonise = True
×
1168

UNCOV
1169
            if manually_cythonise:
×
UNCOV
1170
                self.cythonize_build(env=env)
×
1171
                shprint(hostpython, 'setup.py', 'build_ext', '-v', _env=env,
×
1172
                        _tail=20, _critical=True, *self.setup_extra_args)
1173
            else:
UNCOV
1174
                info('First build appeared to complete correctly, skipping manual'
×
1175
                     'cythonising.')
1176

1177
            if not self.ctx.with_debug_symbols:
×
1178
                self.strip_object_files(arch, env)
×
1179

1180
    def strip_object_files(self, arch, env, build_dir=None):
8✔
1181
        if build_dir is None:
×
1182
            build_dir = self.get_build_dir(arch.arch)
×
UNCOV
1183
        with current_directory(build_dir):
×
UNCOV
1184
            info('Stripping object files')
×
1185
            shprint(sh.find, '.', '-iname', '*.so', '-exec',
×
1186
                    '/usr/bin/echo', '{}', ';', _env=env)
UNCOV
1187
            shprint(sh.find, '.', '-iname', '*.so', '-exec',
×
1188
                    env['STRIP'].split(' ')[0], '--strip-unneeded',
1189
                    # '/usr/bin/strip', '--strip-unneeded',
1190
                    '{}', ';', _env=env)
1191

1192
    def cythonize_file(self, env, build_dir, filename):
8✔
UNCOV
1193
        short_filename = filename
×
1194
        if filename.startswith(build_dir):
×
1195
            short_filename = filename[len(build_dir) + 1:]
×
1196
        info(u"Cythonize {}".format(short_filename))
×
UNCOV
1197
        cyenv = env.copy()
×
UNCOV
1198
        if 'CYTHONPATH' in cyenv:
×
UNCOV
1199
            cyenv['PYTHONPATH'] = cyenv['CYTHONPATH']
×
1200
        elif 'PYTHONPATH' in cyenv:
×
UNCOV
1201
            del cyenv['PYTHONPATH']
×
1202
        if 'PYTHONNOUSERSITE' in cyenv:
×
1203
            cyenv.pop('PYTHONNOUSERSITE')
×
UNCOV
1204
        python_command = sh.Command("python{}".format(
×
1205
            self.ctx.python_recipe.major_minor_version_string.split(".")[0]
1206
        ))
UNCOV
1207
        shprint(python_command, "-c"
×
1208
                "import sys; from Cython.Compiler.Main import setuptools_main; sys.exit(setuptools_main());",
1209
                filename, *self.cython_args, _env=cyenv)
1210

1211
    def cythonize_build(self, env, build_dir="."):
8✔
UNCOV
1212
        if not self.cythonize:
×
UNCOV
1213
            info('Running cython cancelled per recipe setting')
×
UNCOV
1214
            return
×
UNCOV
1215
        info('Running cython where appropriate')
×
UNCOV
1216
        for root, dirnames, filenames in walk("."):
×
UNCOV
1217
            for filename in fnmatch.filter(filenames, "*.pyx"):
×
UNCOV
1218
                self.cythonize_file(env, build_dir, join(root, filename))
×
1219

1220
    def get_recipe_env(self, arch, with_flags_in_cc=True):
8✔
UNCOV
1221
        env = super().get_recipe_env(arch, with_flags_in_cc)
×
UNCOV
1222
        env['LDFLAGS'] = env['LDFLAGS'] + ' -L{} '.format(
×
1223
            self.ctx.get_libs_dir(arch.arch) +
1224
            ' -L{} '.format(self.ctx.libs_dir) +
1225
            ' -L{}'.format(join(self.ctx.bootstrap.build_dir, 'obj', 'local',
1226
                                arch.arch)))
1227

UNCOV
1228
        env['LDSHARED'] = env['CC'] + ' -shared'
×
1229
        # shprint(sh.whereis, env['LDSHARED'], _env=env)
UNCOV
1230
        env['LIBLINK'] = 'NOTNONE'
×
UNCOV
1231
        if self.ctx.copy_libs:
×
UNCOV
1232
            env['COPYLIBS'] = '1'
×
1233

1234
        # Every recipe uses its own liblink path, object files are
1235
        # collected and biglinked later
UNCOV
1236
        liblink_path = join(self.get_build_container_dir(arch.arch),
×
1237
                            'objects_{}'.format(self.name))
UNCOV
1238
        env['LIBLINK_PATH'] = liblink_path
×
UNCOV
1239
        ensure_dir(liblink_path)
×
1240

UNCOV
1241
        return env
×
1242

1243

1244
class PyProjectRecipe(PythonRecipe):
8✔
1245
    """Recipe for projects which contain `pyproject.toml`"""
1246

1247
    # Extra args to pass to `python -m build ...`
1248
    extra_build_args = []
8✔
1249
    call_hostpython_via_targetpython = False
8✔
1250

1251
    def get_recipe_env(self, arch, **kwargs):
8✔
1252
        # Custom hostpython
1253
        self.ctx.python_recipe.python_exe = join(
8✔
1254
            self.ctx.python_recipe.get_build_dir(arch), "android-build", "python3")
1255
        env = super().get_recipe_env(arch, **kwargs)
8✔
1256
        build_dir = self.get_build_dir(arch)
8✔
1257
        ensure_dir(build_dir)
8✔
1258
        build_opts = join(build_dir, "build-opts.cfg")
8✔
1259

1260
        with open(build_opts, "w") as file:
8✔
1261
            file.write("[bdist_wheel]\nplat_name={}".format(
8✔
1262
                self.get_wheel_platform_tag(arch)
1263
            ))
1264
            file.close()
8✔
1265

1266
        env["DIST_EXTRA_CONFIG"] = build_opts
8✔
1267
        return env
8✔
1268

1269
    def get_wheel_platform_tag(self, arch):
8✔
1270
        # https://peps.python.org/pep-0738/#packaging
1271
        # official python only supports 64 bit:
1272
        # android_21_arm64_v8a
1273
        # android_21_x86_64
1274
        return f"android_{self.ctx.ndk_api}_" + {
8✔
1275
            "arm64-v8a": "arm64_v8a",
1276
            "x86_64": "x86_64",
1277
            "armeabi-v7a": "arm",
1278
            "x86": "i686",
1279
        }[arch.arch]
1280

1281
    def install_wheel(self, arch, built_wheels):
8✔
UNCOV
1282
        with patch_wheel_setuptools_logging():
×
UNCOV
1283
            from wheel.cli.tags import tags as wheel_tags
×
UNCOV
1284
            from wheel.wheelfile import WheelFile
×
UNCOV
1285
        _wheel = built_wheels[0]
×
UNCOV
1286
        built_wheel_dir = dirname(_wheel)
×
1287
        # Fix wheel platform tag
1288
        wheel_tag = wheel_tags(
×
1289
            _wheel,
1290
            platform_tags=self.get_wheel_platform_tag(arch),
1291
            remove=True,
1292
        )
1293
        selected_wheel = join(built_wheel_dir, wheel_tag)
×
1294

UNCOV
1295
        _dev_wheel_dir = environ.get("P4A_WHEEL_DIR", False)
×
UNCOV
1296
        if _dev_wheel_dir:
×
UNCOV
1297
            ensure_dir(_dev_wheel_dir)
×
UNCOV
1298
            shprint(sh.cp, selected_wheel, _dev_wheel_dir)
×
1299

UNCOV
1300
        info(f"Installing built wheel: {wheel_tag}")
×
UNCOV
1301
        destination = self.ctx.get_python_install_dir(arch.arch)
×
UNCOV
1302
        with WheelFile(selected_wheel) as wf:
×
1303
            for zinfo in wf.filelist:
×
UNCOV
1304
                wf.extract(zinfo, destination)
×
UNCOV
1305
            wf.close()
×
1306

1307
    def build_arch(self, arch):
8✔
1308

UNCOV
1309
        build_dir = self.get_build_dir(arch.arch)
×
UNCOV
1310
        if not (isfile(join(build_dir, "pyproject.toml")) or isfile(join(build_dir, "setup.py"))):
×
UNCOV
1311
            warning("Skipping build because it does not appear to be a Python project.")
×
UNCOV
1312
            return
×
1313

UNCOV
1314
        self.install_hostpython_prerequisites(
×
1315
            packages=["build[virtualenv]", "pip", "setuptools", "patchelf"] + self.hostpython_prerequisites
1316
        )
UNCOV
1317
        self.patch_shebangs(self._host_recipe.site_bin, self.real_hostpython_location)
×
1318

UNCOV
1319
        env = self.get_recipe_env(arch, with_flags_in_cc=True)
×
1320
        # make build dir separately
UNCOV
1321
        sub_build_dir = join(build_dir, "p4a_android_build")
×
UNCOV
1322
        ensure_dir(sub_build_dir)
×
1323
        # copy hostpython to built python to ensure correct selection of libs and includes
UNCOV
1324
        shprint(sh.cp, self.real_hostpython_location, self.ctx.python_recipe.python_exe)
×
1325

UNCOV
1326
        build_args = [
×
1327
            "-m",
1328
            "build",
1329
            "--wheel",
1330
            "--config-setting",
1331
            "builddir={}".format(sub_build_dir),
1332
        ] + self.extra_build_args
1333

UNCOV
1334
        built_wheels = []
×
UNCOV
1335
        with current_directory(build_dir):
×
UNCOV
1336
            shprint(
×
1337
                sh.Command(self.ctx.python_recipe.python_exe), *build_args, _env=env
1338
            )
UNCOV
1339
            built_wheels = [realpath(whl) for whl in glob.glob("dist/*.whl")]
×
UNCOV
1340
        self.install_wheel(arch, built_wheels)
×
1341

1342

1343
class MesonRecipe(PyProjectRecipe):
8✔
1344
    '''Recipe for projects which uses meson as build system'''
1345

1346
    meson_version = "1.4.0"
8✔
1347
    ninja_version = "1.11.1.1"
8✔
1348

1349
    skip_python = False
8✔
1350
    '''If true, skips all Python build and installation steps.
6✔
1351
    Useful for Meson projects written purely in C/C++ without Python bindings.'''
1352

1353
    def sanitize_flags(self, *flag_strings):
8✔
1354
        return " ".join(flag_strings).strip().split(" ")
×
1355

1356
    def get_recipe_meson_options(self, arch):
8✔
1357
        env = self.get_recipe_env(arch, with_flags_in_cc=True)
×
1358
        return {
×
1359
            "binaries": {
1360
                "c": arch.get_clang_exe(with_target=True),
1361
                "cpp": arch.get_clang_exe(with_target=True, plus_plus=True),
1362
                "ar": self.ctx.ndk.llvm_ar,
1363
                "strip": self.ctx.ndk.llvm_strip,
1364
            },
1365
            "built-in options": {
1366
                "c_args": self.sanitize_flags(env["CFLAGS"], env["CPPFLAGS"]),
1367
                "cpp_args": self.sanitize_flags(env["CXXFLAGS"], env["CPPFLAGS"]),
1368
                "c_link_args": self.sanitize_flags(env["LDFLAGS"]),
1369
                "cpp_link_args": self.sanitize_flags(env["LDFLAGS"]),
1370
                "fortran_link_args": self.sanitize_flags(env["LDFLAGS"]),
1371
            },
1372
            "properties": {
1373
                "needs_exe_wrapper": True,
1374
                "sys_root": self.ctx.ndk.sysroot
1375
            },
1376
            "host_machine": {
1377
                "cpu_family": {
1378
                    "arm64-v8a": "aarch64",
1379
                    "armeabi-v7a": "arm",
1380
                    "x86_64": "x86_64",
1381
                    "x86": "x86"
1382
                }[arch.arch],
1383
                "cpu": {
1384
                    "arm64-v8a": "aarch64",
1385
                    "armeabi-v7a": "armv7",
1386
                    "x86_64": "x86_64",
1387
                    "x86": "i686"
1388
                }[arch.arch],
1389
                "endian": "little",
1390
                "system": "android",
1391
            }
1392
        }
1393

1394
    def write_build_options(self, arch):
8✔
1395
        """Writes python dict to meson config file"""
UNCOV
1396
        option_data = ""
×
UNCOV
1397
        build_options = self.get_recipe_meson_options(arch)
×
UNCOV
1398
        for key in build_options.keys():
×
1399
            data_chunk = "[{}]".format(key)
×
UNCOV
1400
            for subkey in build_options[key].keys():
×
UNCOV
1401
                value = build_options[key][subkey]
×
1402
                if isinstance(value, int):
×
1403
                    value = str(value)
×
UNCOV
1404
                elif isinstance(value, str):
×
UNCOV
1405
                    value = "'{}'".format(value)
×
1406
                elif isinstance(value, bool):
×
1407
                    value = "true" if value else "false"
×
UNCOV
1408
                elif isinstance(value, list):
×
UNCOV
1409
                    value = "['" + "', '".join(value) + "']"
×
UNCOV
1410
                data_chunk += "\n" + subkey + " = " + value
×
UNCOV
1411
            option_data += data_chunk + "\n\n"
×
UNCOV
1412
        return option_data
×
1413

1414
    def ensure_args(self, *args):
8✔
UNCOV
1415
        for arg in args:
×
UNCOV
1416
            if arg not in self.extra_build_args:
×
UNCOV
1417
                self.extra_build_args.append(arg)
×
1418

1419
    def build_arch(self, arch):
8✔
1420
        cross_file = join("/tmp", "android.meson.cross")
×
UNCOV
1421
        info("Writing cross file at: {}".format(cross_file))
×
1422
        # write cross config file
UNCOV
1423
        with open(cross_file, "w") as file:
×
1424
            file.write(self.write_build_options(arch))
×
UNCOV
1425
            file.close()
×
1426
        # set cross file
UNCOV
1427
        self.ensure_args('-Csetup-args=--cross-file', '-Csetup-args={}'.format(cross_file))
×
1428
        # ensure ninja and meson
1429
        for dep in [
×
1430
            "ninja=={}".format(self.ninja_version),
1431
            "meson=={}".format(self.meson_version),
1432
        ]:
1433
            if dep not in self.hostpython_prerequisites:
×
UNCOV
1434
                self.hostpython_prerequisites.append(dep)
×
UNCOV
1435
        if not self.skip_python:
×
UNCOV
1436
            super().build_arch(arch)
×
1437

1438

1439
class RustCompiledComponentsRecipe(PyProjectRecipe):
8✔
1440
    # Rust toolchain codes
1441
    # https://doc.rust-lang.org/nightly/rustc/platform-support.html
1442
    RUST_ARCH_CODES = {
8✔
1443
        "arm64-v8a": "aarch64-linux-android",
1444
        "armeabi-v7a": "armv7-linux-androideabi",
1445
        "x86_64": "x86_64-linux-android",
1446
        "x86": "i686-linux-android",
1447
    }
1448

1449
    call_hostpython_via_targetpython = False
8✔
1450

1451
    def get_recipe_env(self, arch, **kwargs):
8✔
1452
        env = super().get_recipe_env(arch, **kwargs)
×
1453

1454
        # Set rust build target
UNCOV
1455
        build_target = self.RUST_ARCH_CODES[arch.arch]
×
UNCOV
1456
        cargo_linker_name = "CARGO_TARGET_{}_LINKER".format(
×
1457
            build_target.upper().replace("-", "_")
1458
        )
UNCOV
1459
        env["CARGO_BUILD_TARGET"] = build_target
×
UNCOV
1460
        env[cargo_linker_name] = join(
×
1461
            self.ctx.ndk.llvm_prebuilt_dir,
1462
            "bin",
1463
            "{}{}-clang".format(
1464
                # NDK's Clang format
1465
                build_target.replace("7", "7a")
1466
                if build_target.startswith("armv7")
1467
                else build_target,
1468
                self.ctx.ndk_api,
1469
            ),
1470
        )
UNCOV
1471
        realpython_dir = self.ctx.python_recipe.get_build_dir(arch.arch)
×
1472

UNCOV
1473
        env["RUSTFLAGS"] = "-Clink-args=-L{} -L{}".format(
×
1474
            self.ctx.get_libs_dir(arch.arch), join(realpython_dir, "android-build")
1475
        )
1476

UNCOV
1477
        env["PYO3_CROSS_LIB_DIR"] = realpath(glob.glob(join(
×
1478
            realpython_dir, "android-build", "build",
1479
            "lib.*{}/".format(self.python_major_minor_version),
1480
        ))[0])
1481

UNCOV
1482
        info_main("Ensuring rust build toolchain")
×
UNCOV
1483
        shprint(sh.rustup, "target", "add", build_target)
×
1484

1485
        # Add host python to PATH
UNCOV
1486
        env["PATH"] = ("{hostpython_dir}:{old_path}").format(
×
1487
            hostpython_dir=Recipe.get_recipe(
1488
                "hostpython3", self.ctx
1489
            ).get_path_to_python(),
1490
            old_path=env["PATH"],
1491
        )
UNCOV
1492
        return env
×
1493

1494
    def check_host_deps(self):
8✔
1495
        if not hasattr(sh, "rustup"):
×
1496
            error(
×
1497
                "`rustup` was not found on host system."
1498
                "Please install it using :"
1499
                "\n`curl https://sh.rustup.rs -sSf | sh`\n"
1500
            )
1501
            exit(1)
×
1502

1503
    def build_arch(self, arch):
8✔
UNCOV
1504
        self.check_host_deps()
×
UNCOV
1505
        super().build_arch(arch)
×
1506

1507

1508
class TargetPythonRecipe(Recipe):
8✔
1509
    '''Class for target python recipes. Sets ctx.python_recipe to point to
1510
    itself, so as to know later what kind of Python was built or used.'''
1511

1512
    def __init__(self, *args, **kwargs):
8✔
1513
        self._ctx = None
8✔
1514
        super().__init__(*args, **kwargs)
8✔
1515

1516
    def prebuild_arch(self, arch):
8✔
UNCOV
1517
        super().prebuild_arch(arch)
×
UNCOV
1518
        self.ctx.python_recipe = self
×
1519

1520
    def include_root(self, arch):
8✔
1521
        '''The root directory from which to include headers.'''
1522
        raise NotImplementedError('Not implemented in TargetPythonRecipe')
1523

1524
    def link_root(self):
8✔
1525
        raise NotImplementedError('Not implemented in TargetPythonRecipe')
1526

1527
    @property
8✔
1528
    def major_minor_version_string(self):
8✔
1529
        parsed_version = packaging.version.parse(self.version)
8✔
1530
        return f"{parsed_version.major}.{parsed_version.minor}"
8✔
1531

1532
    def create_python_bundle(self, dirn, arch):
8✔
1533
        """
1534
        Create a packaged python bundle in the target directory, by
1535
        copying all the modules and standard library to the right
1536
        place.
1537
        """
1538
        raise NotImplementedError('{} does not implement create_python_bundle'.format(self))
1539

1540
    def reduce_object_file_names(self, dirn):
8✔
1541
        """Recursively renames all files named XXX.cpython-...-linux-gnu.so"
1542
        to "XXX.so", i.e. removing the erroneous architecture name
1543
        coming from the local system.
1544
        """
UNCOV
1545
        py_so_files = shprint(sh.find, dirn, '-iname', '*.so')
×
UNCOV
1546
        filens = py_so_files.stdout.decode('utf-8').split('\n')[:-1]
×
UNCOV
1547
        for filen in filens:
×
UNCOV
1548
            file_dirname, file_basename = split(filen)
×
UNCOV
1549
            parts = file_basename.split('.')
×
UNCOV
1550
            if len(parts) <= 2:
×
UNCOV
1551
                continue
×
1552
            # PySide6 libraries end with .abi3.so
UNCOV
1553
            if parts[1] == "abi3":
×
UNCOV
1554
                continue
×
UNCOV
1555
            move(filen, join(file_dirname, parts[0] + '.so'))
×
1556

1557

1558
def algsum(alg, filen):
8✔
1559
    '''Calculate the digest of a file.
1560
    '''
UNCOV
1561
    with open(filen, 'rb') as fileh:
×
UNCOV
1562
        digest = getattr(hashlib, alg)(fileh.read())
×
1563

UNCOV
1564
    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