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

kivy / python-for-android / 18239320693

04 Oct 2025 04:03AM UTC coverage: 59.085% (-0.04%) from 59.122%
18239320693

Pull #3180

github

web-flow
Merge 79d1051c1 into 5aa97321e
Pull Request #3180: `python`: add `3.13` support

1068 of 2409 branches covered (44.33%)

Branch coverage included in aggregate %.

90 of 128 new or added lines in 6 files covered. (70.31%)

10 existing lines in 3 files now uncovered.

4987 of 7839 relevant lines covered (63.62%)

2.53 hits per line

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

44.59
/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
from multiprocessing import cpu_count
4✔
16
import time
4✔
17
try:
4✔
18
    from urlparse import urlparse
4✔
19
except ImportError:
4✔
20
    from urllib.parse import urlparse
4✔
21

22
import packaging.version
4✔
23

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

31

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

36

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

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

47

48
class Recipe(metaclass=RecipeMeta):
4✔
49
    _url = None
4✔
50
    '''The address from which the recipe may be downloaded. This is not
2✔
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
4✔
64
    '''Add additional headers used when downloading the package, typically
2✔
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
4✔
79
    '''A string giving the version of the software the recipe describes,
2✔
80
    e.g. ``2.0.3`` or ``master``.'''
81

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

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

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

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

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

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

112
    patches = []
4✔
113
    '''A list of patches to apply to the source. Values can be either a string
2✔
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 = []
4✔
119
    '''A list of pure-Python packages that this package requires. These
2✔
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
4✔
125

126
    built_libraries = {}
4✔
127
    """Each recipe that builds a system library (e.g.:libffi, openssl, etc...)
2✔
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
4✔
147
    '''Some libraries or python packages may need the c++_shared in APK.
2✔
148
    We can automatically do this for any recipe if we set this property to
149
    `True`'''
150

151
    stl_lib_name = 'c++_shared'
4✔
152
    '''
2✔
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
4✔
160
    '''
2✔
161
    Minimum ndk api recipe will support.
162
    '''
163

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

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

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

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

186
    @property
4✔
187
    def versioned_url(self):
4✔
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:
4!
192
            return None
×
193
        return self.url.format(version=self.version)
4✔
194

195
    @property
4✔
196
    def download_headers(self):
4✔
197
        key = "DOWNLOAD_HEADERS_" + self.name
4✔
198
        env_headers = environ.get(key)
4✔
199
        if env_headers:
4✔
200
            try:
4✔
201
                return [tuple(h) for h in json.loads(env_headers)]
4✔
202
            except Exception as ex:
×
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)
4✔
206

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

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

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

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

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

234
            # Download item with multiple attempts (for bad connections):
235
            attempts = 0
4✔
236
            seconds = 1
4✔
237
            while True:
2✔
238
                try:
4✔
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')]
4✔
242
                    if self.download_headers:
4!
243
                        url_opener.addheaders += self.download_headers
×
244
                    urlretrieve(url, target, report_hook)
4✔
245
                except OSError as e:
4✔
246
                    attempts += 1
4✔
247
                    if attempts >= 5:
4✔
248
                        raise
4✔
249
                    stdout.write('Download failed: {}; retrying in {} second(s)...'.format(e, seconds))
4✔
250
                    time.sleep(seconds)
4✔
251
                    seconds *= 2
4✔
252
                    continue
4✔
253
                finally:
254
                    url_opener.addheaders = url_orig_headers
4✔
255
                break
4✔
256
            return target
4✔
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+'):
×
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')
×
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')
×
278
            return target
×
279

280
    def apply_patch(self, filename, arch, build_dir=None):
4✔
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))
4✔
289
        build_dir = build_dir if build_dir else self.get_build_dir(arch)
4✔
290
        filename = join(self.get_recipe_dir(), filename)
4✔
291
        shprint(sh.patch, "-t", "-d", build_dir, "-p1",
4✔
292
                "-i", filename, _tail=10)
293

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

300
    def append_file(self, filename, dest):
4✔
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:
×
307
            fd.write(data)
×
308

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

315
    @property
4✔
316
    def filtered_archs(self):
4✔
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)
×
323
        return result
×
324

325
    def check_recipe_choices(self):
4✔
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 = []
4✔
330
        built_recipes = self.ctx.recipe_build_order
4✔
331
        for recipe in self.depends:
4✔
332
            if isinstance(recipe, (tuple, list)):
4!
333
                for alternative in recipe:
×
334
                    if alternative in built_recipes:
×
335
                        recipes.append(alternative)
×
336
                        break
×
337
        for recipe in self.opt_depends:
4✔
338
            if recipe in built_recipes:
4!
339
                recipes.append(recipe)
×
340
        return sorted(recipes)
4✔
341

342
    def get_opt_depends_in_list(self, recipes):
4✔
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]
4✔
347

348
    def get_build_container_dir(self, arch):
4✔
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()
4✔
356
        return join(self.ctx.build_dir, 'other_builds',
4✔
357
                    dir_name, '{}__ndk_target_{}'.format(arch, self.ctx.ndk_api))
358

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

364
    def get_build_dir(self, arch):
4✔
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)
4✔
369

370
    def get_recipe_dir(self):
4✔
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:
4!
376
            local_recipe_dir = join(self.ctx.local_recipes, self.name)
×
377
            if exists(local_recipe_dir):
×
378
                return local_recipe_dir
×
379
        return join(self.ctx.root_dir, 'recipes', self.name)
4✔
380

381
    # Public Recipe API to be subclassed if needed
382

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

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

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

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

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

420
            do_download = True
4✔
421
            marker_filename = '.mark-{}'.format(filename)
4✔
422
            if exists(filename) and isfile(filename):
4!
423
                if not exists(marker_filename):
×
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:
×
429
                            debug('* Generated {}sum: {}'.format(alg,
×
430
                                                                 current_digest))
431
                            debug('* Expected {}sum: {}'.format(alg,
×
432
                                                                expected_digest))
433
                            raise ValueError(
×
434
                                ('Generated {0}sum does not match expected {0}sum '
435
                                 'for {1} recipe').format(alg, self.name))
436
                    do_download = False
×
437

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

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

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

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

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:
×
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))
×
474
            return
×
475

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

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?
×
484
            filename = ma.group(1)
×
485

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

489
            if not exists(directory_name) or not isdir(directory_name):
×
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)
×
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)
×
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):
×
511
                            move(root_directory, directory_name)
×
512
                    else:
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)
×
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.
NEW
521
                        shprint(sh.cp, '-R',
×
522
                                join(extraction_filename, entry),
523
                                directory_name)
524
                else:
525
                    raise Exception(
×
526
                        'Given path is neither a file nor a directory: {}'
527
                        .format(extraction_filename))
528

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

753

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

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

767

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

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

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

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

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

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

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

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

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

805

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

809
    generated_libraries = []
4✔
810

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

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

818
        return False
×
819

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

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

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

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

842

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

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

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

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

866
    setup_extra_args = []
4✔
867
    '''List of extra arguments to pass to setup.py'''
2✔
868

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

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

881
    hostpython_prerequisites = []
4✔
882
    '''List of hostpython packages required to build a recipe'''
2✔
883

884
    _host_recipe = None
4✔
885

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

898
    def prebuild_arch(self, arch):
4✔
899
        self._host_recipe = Recipe.get_recipe("hostpython3", self.ctx)
4✔
900
        return super().prebuild_arch(arch)
4✔
901

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

915
    @property
4✔
916
    def real_hostpython_location(self):
4✔
917
        host_name = 'host{}'.format(self.ctx.python_recipe.name)
4✔
918
        if host_name == 'hostpython3':
4!
919
            return self._host_recipe.python_exe
4✔
920
        else:
921
            python_recipe = self.ctx.python_recipe
×
922
            return 'python{}'.format(python_recipe.version)
×
923

924
    @property
4✔
925
    def hostpython_location(self):
4✔
UNCOV
926
        if not self.call_hostpython_via_targetpython:
×
UNCOV
927
            return self.real_hostpython_location
×
928
        return self.ctx.hostpython
×
929

930
    @property
4✔
931
    def folder_name(self):
4✔
932
        '''The name of the build folders containing this recipe.'''
933
        name = self.site_packages_name
×
934
        if name is None:
×
935
            name = self.name
×
936
        return name
×
937

938
    def patch_shebang(self, _file, original_bin):
4✔
NEW
939
        _file_des = open(_file, "r")
×
940

NEW
941
        try:
×
NEW
942
            data = _file_des.readlines()
×
NEW
943
        except UnicodeDecodeError:
×
NEW
944
            return
×
945

NEW
946
        if "#!" in (line := data[0]):
×
NEW
947
            if line.split("#!")[-1].strip() == original_bin:
×
NEW
948
                return
×
949

NEW
950
            info(f"Fixing shebang for '{_file}'")
×
NEW
951
            data.pop(0)
×
NEW
952
            data.insert(0, "#!" + original_bin + "\n")
×
NEW
953
            _file_des.close()
×
NEW
954
            _file_des = open(_file, "w")
×
NEW
955
            _file_des.write("".join(data))
×
NEW
956
            _file_des.close()
×
957

958
    def patch_shebangs(self, path, original_bin):
4✔
959
        if not isdir(path):
4!
960
            warning(f"Shebang patch skipped: '{path}' does not exist.")
4✔
961
            return
4✔
962
        # set correct shebang
NEW
963
        for file in listdir(path):
×
NEW
964
            _file = join(path, file)
×
NEW
965
            self.patch_shebang(_file, original_bin)
×
966

967
    def get_recipe_env(self, arch=None, with_flags_in_cc=True):
4✔
968
        if self._host_recipe is None:
4!
969
            self._host_recipe = Recipe.get_recipe("hostpython3", self.ctx)
4✔
970

971
        env = super().get_recipe_env(arch, with_flags_in_cc)
4✔
972
        # Set the LANG, this isn't usually important but is a better default
973
        # as it occasionally matters how Python e.g. reads files
974
        env['LANG'] = "en_GB.UTF-8"
4✔
975

976
        # Binaries made by packages installed by pip
977
        self.patch_shebangs(self._host_recipe.local_bin, self.real_hostpython_location)
4✔
978
        env["PATH"] = self._host_recipe.local_bin + ":" + self._host_recipe.site_bin + ":" + env["PATH"]
4✔
979

980
        host_env = self.get_hostrecipe_env()
4✔
981
        env['PYTHONPATH'] = host_env["PYTHONPATH"]
4✔
982

983
        if not self.call_hostpython_via_targetpython:
4!
984
            env['CFLAGS'] += ' -I{}'.format(
4✔
985
                self.ctx.python_recipe.include_root(arch.arch)
986
            )
987
            env['LDFLAGS'] += ' -L{} -lpython{}'.format(
4✔
988
                self.ctx.python_recipe.link_root(arch.arch),
989
                self.ctx.python_recipe.link_version,
990
            )
991

992
        return env
4✔
993

994
    def should_build(self, arch):
4✔
995
        name = self.folder_name
×
996
        if self.ctx.has_package(name, arch):
×
997
            info('Python package already exists in site-packages')
×
998
            return False
×
999
        info('{} apparently isn\'t already in site-packages'.format(name))
×
1000
        return True
×
1001

1002
    def build_arch(self, arch):
4✔
1003
        '''Install the Python module by calling setup.py install with
1004
        the target Python dir.'''
1005
        self.install_hostpython_prerequisites()
×
1006
        super().build_arch(arch)
×
1007
        self.install_python_package(arch)
×
1008

1009
    def install_python_package(self, arch, name=None, env=None, is_dir=True):
4✔
1010
        '''Automate the installation of a Python package (or a cython
1011
        package where the cython components are pre-built).'''
1012
        # arch = self.filtered_archs[0]  # old kivy-ios way
1013
        if name is None:
×
1014
            name = self.name
×
1015
        if env is None:
×
1016
            env = self.get_recipe_env(arch)
×
1017

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

1020
        hostpython = sh.Command(self.hostpython_location)
×
1021
        hpenv = env.copy()
×
1022
        with current_directory(self.get_build_dir(arch.arch)):
×
1023

NEW
1024
            if isfile("setup.py"):
×
NEW
1025
                shprint(hostpython, 'setup.py', 'install', '-O2',
×
1026
                        '--root={}'.format(self.ctx.get_python_install_dir(arch.arch)),
1027
                        '--install-lib=.',
1028
                        _env=hpenv, *self.setup_extra_args)
1029

1030
                # If asked, also install in the hostpython build dir
NEW
1031
                if self.install_in_hostpython:
×
NEW
1032
                    self.install_hostpython_package(arch)
×
1033
            else:
NEW
1034
                warning("`PythonRecipe.install_python_package` called without `setup.py` file!")
×
1035

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

1044
    @property
4✔
1045
    def hostpython_site_dir(self):
4✔
UNCOV
1046
        return join(dirname(self.real_hostpython_location), 'Lib', 'site-packages')
×
1047

1048
    def install_hostpython_package(self, arch):
4✔
1049
        env = self.get_hostrecipe_env(arch)
×
1050
        real_hostpython = sh.Command(self.real_hostpython_location)
×
1051
        shprint(real_hostpython, 'setup.py', 'install', '-O2',
×
1052
                '--install-lib=Lib/site-packages',
1053
                '--root={}'.format(self._host_recipe.site_root),
1054
                _env=env, *self.setup_extra_args)
1055

1056
    @property
4✔
1057
    def python_major_minor_version(self):
4✔
1058
        parsed_version = packaging.version.parse(self.ctx.python_recipe.version)
×
1059
        return f"{parsed_version.major}.{parsed_version.minor}"
×
1060

1061
    def install_hostpython_prerequisites(self, packages=None, force_upgrade=True):
4✔
1062
        if not packages:
×
1063
            packages = self.hostpython_prerequisites
×
1064

1065
        if len(packages) == 0:
×
1066
            return
×
1067

1068
        pip_options = [
×
1069
            "install",
1070
            *packages,
1071
            "--target", self._host_recipe.site_dir, "--python-version",
1072
            self.ctx.python_recipe.version,
1073
            # Don't use sources, instead wheels
1074
            "--only-binary=:all:",
1075
        ]
1076
        if force_upgrade:
×
1077
            pip_options.append("--upgrade")
×
1078
        # Use system's pip
NEW
1079
        pip_env = self.get_hostrecipe_env()
×
NEW
1080
        pip_env["HOME"] = "/tmp"
×
NEW
1081
        shprint(sh.Command(self.real_hostpython_location), "-m", "pip", *pip_options, _env=pip_env)
×
1082

1083
    def restore_hostpython_prerequisites(self, packages):
4✔
1084
        _packages = []
×
1085
        for package in packages:
×
1086
            original_version = Recipe.get_recipe(package, self.ctx).version
×
1087
            _packages.append(package + "==" + original_version)
×
1088
        self.install_hostpython_prerequisites(packages=_packages)
×
1089

1090

1091
class CompiledComponentsPythonRecipe(PythonRecipe):
4✔
1092
    pre_build_ext = False
4✔
1093

1094
    build_cmd = 'build_ext'
4✔
1095

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

1105
    def build_compiled_components(self, arch):
4✔
1106
        info('Building compiled components in {}'.format(self.name))
×
1107

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

1119
    def install_hostpython_package(self, arch):
4✔
1120
        env = self.get_hostrecipe_env(arch)
×
1121
        self.rebuild_compiled_components(arch, env)
×
1122
        super().install_hostpython_package(arch)
×
1123

1124
    def rebuild_compiled_components(self, arch, env):
4✔
1125
        info('Rebuilding compiled components in {}'.format(self.name))
×
1126

1127
        hostpython = sh.Command(self.real_hostpython_location)
×
1128
        shprint(hostpython, 'setup.py', 'clean', '--all', _env=env)
×
1129
        shprint(hostpython, 'setup.py', self.build_cmd, '-v', _env=env,
×
1130
                *self.setup_extra_args)
1131

1132

1133
class CppCompiledComponentsPythonRecipe(CompiledComponentsPythonRecipe):
4✔
1134
    """ Extensions that require the cxx-stl """
1135
    call_hostpython_via_targetpython = False
4✔
1136
    need_stl_shared = True
4✔
1137

1138

1139
class CythonRecipe(PythonRecipe):
4✔
1140
    pre_build_ext = False
4✔
1141
    cythonize = True
4✔
1142
    cython_args = []
4✔
1143
    call_hostpython_via_targetpython = False
4✔
1144

1145
    def build_arch(self, arch):
4✔
1146
        '''Build any cython components, then install the Python module by
1147
        calling setup.py install with the target Python dir.
1148
        '''
1149
        Recipe.build_arch(self, arch)
×
1150
        self.build_cython_components(arch)
×
1151
        self.install_python_package(arch)
×
1152

1153
    def build_cython_components(self, arch):
4✔
1154
        info('Cythonizing anything necessary in {}'.format(self.name))
×
1155

1156
        env = self.get_recipe_env(arch)
×
1157

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

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

1174
            if manually_cythonise:
×
1175
                self.cythonize_build(env=env)
×
1176
                shprint(hostpython, 'setup.py', 'build_ext', '-v', _env=env,
×
1177
                        _tail=20, _critical=True, *self.setup_extra_args)
1178
            else:
1179
                info('First build appeared to complete correctly, skipping manual'
×
1180
                     'cythonising.')
1181

1182
            if not self.ctx.with_debug_symbols:
×
1183
                self.strip_object_files(arch, env)
×
1184

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

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

1216
    def cythonize_build(self, env, build_dir="."):
4✔
1217
        if not self.cythonize:
×
1218
            info('Running cython cancelled per recipe setting')
×
1219
            return
×
1220
        info('Running cython where appropriate')
×
1221
        for root, dirnames, filenames in walk("."):
×
1222
            for filename in fnmatch.filter(filenames, "*.pyx"):
×
1223
                self.cythonize_file(env, build_dir, join(root, filename))
×
1224

1225
    def get_recipe_env(self, arch, with_flags_in_cc=True):
4✔
1226
        env = super().get_recipe_env(arch, with_flags_in_cc)
×
1227
        env['LDFLAGS'] = env['LDFLAGS'] + ' -L{} '.format(
×
1228
            self.ctx.get_libs_dir(arch.arch) +
1229
            ' -L{} '.format(self.ctx.libs_dir) +
1230
            ' -L{}'.format(join(self.ctx.bootstrap.build_dir, 'obj', 'local',
1231
                                arch.arch)))
1232

1233
        env['LDSHARED'] = env['CC'] + ' -shared'
×
1234
        # shprint(sh.whereis, env['LDSHARED'], _env=env)
1235
        env['LIBLINK'] = 'NOTNONE'
×
1236
        if self.ctx.copy_libs:
×
1237
            env['COPYLIBS'] = '1'
×
1238

1239
        # Every recipe uses its own liblink path, object files are
1240
        # collected and biglinked later
1241
        liblink_path = join(self.get_build_container_dir(arch.arch),
×
1242
                            'objects_{}'.format(self.name))
1243
        env['LIBLINK_PATH'] = liblink_path
×
1244
        ensure_dir(liblink_path)
×
1245

1246
        return env
×
1247

1248

1249
class PyProjectRecipe(PythonRecipe):
4✔
1250
    """Recipe for projects which contain `pyproject.toml`"""
1251

1252
    # Extra args to pass to `python -m build ...`
1253
    extra_build_args = []
4✔
1254
    call_hostpython_via_targetpython = False
4✔
1255

1256
    def get_recipe_env(self, arch, **kwargs):
4✔
1257
        # Custom hostpython
1258
        self.ctx.python_recipe.python_exe = join(
4✔
1259
            self.ctx.python_recipe.get_build_dir(arch), "android-build", "python3")
1260
        env = super().get_recipe_env(arch, **kwargs)
4✔
1261
        build_dir = self.get_build_dir(arch)
4✔
1262
        ensure_dir(build_dir)
4✔
1263
        build_opts = join(build_dir, "build-opts.cfg")
4✔
1264

1265
        with open(build_opts, "w") as file:
4✔
1266
            file.write("[bdist_wheel]\nplat_name={}".format(
4✔
1267
                self.get_wheel_platform_tag(arch)
1268
            ))
1269
            file.close()
4✔
1270

1271
        env["DIST_EXTRA_CONFIG"] = build_opts
4✔
1272
        return env
4✔
1273

1274
    def get_wheel_platform_tag(self, arch):
4✔
1275
        return f"android_{self.ctx.ndk_api}_" + {
4✔
1276
            "armeabi-v7a": "arm",
1277
            "arm64-v8a": "aarch64",
1278
            "x86_64": "x86_64",
1279
            "x86": "i686",
1280
        }[arch.arch]
1281

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

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

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

1308
    def build_arch(self, arch):
4✔
1309

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

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

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

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

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

1343

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

1347
    meson_version = "1.4.0"
4✔
1348
    ninja_version = "1.11.1.1"
4✔
1349

1350
    def sanitize_flags(self, *flag_strings):
4✔
1351
        return " ".join(flag_strings).strip().split(" ")
×
1352

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

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

1410
    def ensure_args(self, *args):
4✔
1411
        for arg in args:
×
1412
            if arg not in self.extra_build_args:
×
1413
                self.extra_build_args.append(arg)
×
1414

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

1433

1434
class RustCompiledComponentsRecipe(PyProjectRecipe):
4✔
1435
    # Rust toolchain codes
1436
    # https://doc.rust-lang.org/nightly/rustc/platform-support.html
1437
    RUST_ARCH_CODES = {
4✔
1438
        "arm64-v8a": "aarch64-linux-android",
1439
        "armeabi-v7a": "armv7-linux-androideabi",
1440
        "x86_64": "x86_64-linux-android",
1441
        "x86": "i686-linux-android",
1442
    }
1443

1444
    call_hostpython_via_targetpython = False
4✔
1445

1446
    def get_recipe_env(self, arch, **kwargs):
4✔
1447
        env = super().get_recipe_env(arch, **kwargs)
×
1448

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

1468
        env["RUSTFLAGS"] = "-Clink-args=-L{} -L{}".format(
×
1469
            self.ctx.get_libs_dir(arch.arch), join(realpython_dir, "android-build")
1470
        )
1471

1472
        env["PYO3_CROSS_LIB_DIR"] = realpath(glob.glob(join(
×
1473
            realpython_dir, "android-build", "build",
1474
            "lib.*{}/".format(self.python_major_minor_version),
1475
        ))[0])
1476

1477
        info_main("Ensuring rust build toolchain")
×
1478
        shprint(sh.rustup, "target", "add", build_target)
×
1479

1480
        # Add host python to PATH
1481
        env["PATH"] = ("{hostpython_dir}:{old_path}").format(
×
1482
            hostpython_dir=Recipe.get_recipe(
1483
                "hostpython3", self.ctx
1484
            ).get_path_to_python(),
1485
            old_path=env["PATH"],
1486
        )
1487
        return env
×
1488

1489
    def check_host_deps(self):
4✔
1490
        if not hasattr(sh, "rustup"):
×
1491
            error(
×
1492
                "`rustup` was not found on host system."
1493
                "Please install it using :"
1494
                "\n`curl https://sh.rustup.rs -sSf | sh`\n"
1495
            )
1496
            exit(1)
×
1497

1498
    def build_arch(self, arch):
4✔
1499
        self.check_host_deps()
×
1500
        super().build_arch(arch)
×
1501

1502

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

1507
    def __init__(self, *args, **kwargs):
4✔
1508
        self._ctx = None
4✔
1509
        super().__init__(*args, **kwargs)
4✔
1510

1511
    def prebuild_arch(self, arch):
4✔
1512
        super().prebuild_arch(arch)
×
1513
        self.ctx.python_recipe = self
×
1514

1515
    def include_root(self, arch):
4✔
1516
        '''The root directory from which to include headers.'''
1517
        raise NotImplementedError('Not implemented in TargetPythonRecipe')
1518

1519
    def link_root(self):
4✔
1520
        raise NotImplementedError('Not implemented in TargetPythonRecipe')
1521

1522
    @property
4✔
1523
    def major_minor_version_string(self):
4✔
1524
        parsed_version = packaging.version.parse(self.version)
4✔
1525
        return f"{parsed_version.major}.{parsed_version.minor}"
4✔
1526

1527
    def create_python_bundle(self, dirn, arch):
4✔
1528
        """
1529
        Create a packaged python bundle in the target directory, by
1530
        copying all the modules and standard library to the right
1531
        place.
1532
        """
1533
        raise NotImplementedError('{} does not implement create_python_bundle'.format(self))
1534

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

1552

1553
def algsum(alg, filen):
4✔
1554
    '''Calculate the digest of a file.
1555
    '''
1556
    with open(filen, 'rb') as fileh:
×
1557
        digest = getattr(hashlib, alg)(fileh.read())
×
1558

1559
    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