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

kivy / python-for-android / 14999495895

13 May 2025 02:38PM UTC coverage: 58.724% (-0.5%) from 59.204%
14999495895

Pull #3136

github

web-flow
Merge 55a9af12f into 40993e323
Pull Request #3136: scipy: update to v1.15.2

1059 of 2408 branches covered (43.98%)

Branch coverage included in aggregate %.

73 of 221 new or added lines in 9 files covered. (33.03%)

4 existing lines in 3 files now uncovered.

4979 of 7874 relevant lines covered (63.23%)

2.52 hits per line

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

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

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

21
import packaging.version
4✔
22

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

30

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

35

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

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

46

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

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

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

62
    _download_headers = None
4✔
63
    '''Add additional headers used when downloading the package, typically
2✔
64
    for authorization purposes.
65

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

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

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

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

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

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

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

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

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

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

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

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

123
    archs = ['armeabi']  # Not currently implemented properly
4✔
124

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

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

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

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

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

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

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

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

158
    min_ndk_api_support = 20
4✔
159
    '''
2✔
160
    Minimum ndk api your recipe will support
161
    '''
162

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

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

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

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

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

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

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

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

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

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

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

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

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

UNCOV
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!
NEW
385
            error(f"In order to build '{self.name}', you must set minimum ndk api (minapi) to `{self.min_ndk_api_support}`.\n")
×
NEW
386
            exit(1)
×
387

388
        info_main('Downloading {}'.format(self.name))
4✔
389

390
        user_dir = environ.get('P4A_{}_DIR'.format(self.name.lower()))
4✔
391
        if user_dir is not None:
4✔
392
            info('P4A_{}_DIR is set, skipping download for {}'.format(
4✔
393
                self.name, self.name))
394
            return
4✔
395
        self.download()
4✔
396

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

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

417
        ensure_dir(join(self.ctx.packages_path, self.name))
4✔
418

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

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

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

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

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

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

465
        build_dir = self.get_build_container_dir(arch)
×
466

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

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

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

488
        with current_directory(build_dir):
×
489
            directory_name = self.get_build_dir(arch)
×
490

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

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

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

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

552
    def is_patched(self, arch):
4✔
553
        build_dir = self.get_build_dir(arch.arch)
4✔
554
        return exists(join(build_dir, '.patched'))
4✔
555

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

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

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

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

576
                self.apply_patch(
×
577
                        patch.format(version=self.version, arch=arch.arch),
578
                        arch.arch, build_dir=build_dir)
579

580
            touch(join(build_dir, '.patched'))
×
581

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

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

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

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

623
        if self.need_stl_shared:
4!
624
            self.install_stl_lib(arch)
4✔
625

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

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

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

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

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

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

659
        for directory in dirs:
×
660
            rmdir(directory)
×
661

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

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

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

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

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

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

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

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

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

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

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

755

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

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

769

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

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

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

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

784
    def get_build_container_dir(self, arch):
4✔
785
        return self.get_jni_dir()
×
786

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

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

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

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

807

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

811
    generated_libraries = []
4✔
812

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

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

820
        return False
×
821

822
    def get_lib_dir(self, arch):
4✔
823
        return join(self.get_build_dir(arch.arch), 'obj', 'local', arch.arch)
×
824

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

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

831
        env = self.get_recipe_env(arch)
×
832
        with current_directory(self.get_build_dir(arch.arch)):
×
833
            shprint(
×
834
                sh.Command(join(self.ctx.ndk_dir, "ndk-build")),
835
                'V=1',
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
    def __init__(self, *args, **kwargs):
4✔
885
        super().__init__(*args, **kwargs)
4✔
886
        if 'python3' not in self.depends:
4✔
887
            # We ensure here that the recipe depends on python even it overrode
888
            # `depends`. We only do this if it doesn't already depend on any
889
            # python, since some recipes intentionally don't depend on/work
890
            # with all python variants
891
            depends = self.depends
4✔
892
            depends.append('python3')
4✔
893
            depends = list(set(depends))
4✔
894
            self.depends = depends
4✔
895

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

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

919
    @property
4✔
920
    def hostpython_location(self):
4✔
921
        if not self.call_hostpython_via_targetpython:
4!
922
            return self.real_hostpython_location
4✔
923
        return self.ctx.hostpython
×
924

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

933
    def patch_shebang(self, _file, original_bin):
4✔
NEW
934
        _file_des = open(_file, "r")
×
935

NEW
936
        try:
×
NEW
937
            data = _file_des.readlines()
×
NEW
938
        except UnicodeDecodeError:
×
NEW
939
            return
×
940

NEW
941
        if "#!" in (line := data[0]):
×
NEW
942
            if line.split("#!")[-1].strip() == original_bin:
×
NEW
943
                return
×
944

NEW
945
            info(f"Fixing sheband for '{_file}'")
×
NEW
946
            data.pop(0)
×
NEW
947
            data.insert(0, "#!" + original_bin + "\n")
×
NEW
948
            _file_des.close()
×
NEW
949
            _file_des = open(_file, "w")
×
NEW
950
            _file_des.write("".join(data))
×
NEW
951
            _file_des.close()
×
952

953
    def patch_shebangs(self, path, original_bin):
4✔
954
        # set correct shebang
NEW
955
        for file in listdir(path):
×
NEW
956
            _file = join(path, file)
×
NEW
957
            self.patch_shebang(_file, original_bin)
×
958

959
    def get_recipe_env(self, arch=None, with_flags_in_cc=True):
4✔
960
        env = super().get_recipe_env(arch, with_flags_in_cc)
4✔
961
        env['PYTHONNOUSERSITE'] = '1'
4✔
962
        # Set the LANG, this isn't usually important but is a better default
963
        # as it occasionally matters how Python e.g. reads files
964
        env['LANG'] = "en_GB.UTF-8"
4✔
965
        # Binaries made by packages installed by pip
966
        env["PATH"] = join(self.hostpython_site_dir, "bin") + ":" + env["PATH"]
4✔
967

968
        if not self.call_hostpython_via_targetpython:
4!
969
            env['CFLAGS'] += ' -I{}'.format(
4✔
970
                self.ctx.python_recipe.include_root(arch.arch)
971
            )
972
            env['LDFLAGS'] += ' -L{} -lpython{}'.format(
4✔
973
                self.ctx.python_recipe.link_root(arch.arch),
974
                self.ctx.python_recipe.link_version,
975
            )
976

977
            hppath = []
4✔
978
            hppath.append(join(dirname(self.hostpython_location), 'Lib'))
4✔
979
            hppath.append(join(hppath[0], 'site-packages'))
4✔
980
            builddir = join(dirname(self.hostpython_location), 'build')
4✔
981
            if exists(builddir):
4!
982
                hppath += [join(builddir, d) for d in listdir(builddir)
×
983
                           if isdir(join(builddir, d))]
984
            if len(hppath) > 0:
4!
985
                if 'PYTHONPATH' in env:
4!
986
                    env['PYTHONPATH'] = ':'.join(hppath + [env['PYTHONPATH']])
×
987
                else:
988
                    env['PYTHONPATH'] = ':'.join(hppath)
4✔
989
        return env
4✔
990

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

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

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

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

1017
        hostpython = sh.Command(self.hostpython_location)
×
1018
        hpenv = env.copy()
×
1019
        with current_directory(self.get_build_dir(arch.arch)):
×
1020
            shprint(hostpython, 'setup.py', 'install', '-O2',
×
1021
                    '--root={}'.format(self.ctx.get_python_install_dir(arch.arch)),
1022
                    '--install-lib=.',
1023
                    _env=hpenv, *self.setup_extra_args)
1024

1025
            # If asked, also install in the hostpython build dir
1026
            if self.install_in_hostpython:
×
1027
                self.install_hostpython_package(arch)
×
1028

1029
    def get_hostrecipe_env(self, arch):
4✔
1030
        env = environ.copy()
×
1031
        env['PYTHONPATH'] = self.hostpython_site_dir
×
1032
        return env
×
1033

1034
    @property
4✔
1035
    def hostpython_site_dir(self):
4✔
1036
        return join(dirname(self.real_hostpython_location), 'Lib', 'site-packages')
4✔
1037

1038
    def install_hostpython_package(self, arch):
4✔
1039
        env = self.get_hostrecipe_env(arch)
×
1040
        real_hostpython = sh.Command(self.real_hostpython_location)
×
1041
        shprint(real_hostpython, 'setup.py', 'install', '-O2',
×
1042
                '--root={}'.format(dirname(self.real_hostpython_location)),
1043
                '--install-lib=Lib/site-packages',
1044
                _env=env, *self.setup_extra_args)
1045

1046
    @property
4✔
1047
    def python_major_minor_version(self):
4✔
1048
        parsed_version = packaging.version.parse(self.ctx.python_recipe.version)
×
1049
        return f"{parsed_version.major}.{parsed_version.minor}"
×
1050

1051
    def install_hostpython_prerequisites(self, packages=None, force_upgrade=True):
4✔
1052
        if not packages:
×
1053
            packages = self.hostpython_prerequisites
×
1054

1055
        if len(packages) == 0:
×
1056
            return
×
1057

1058
        pip_options = [
×
1059
            "install",
1060
            *packages,
1061
            "--target", self.hostpython_site_dir, "--python-version",
1062
            self.ctx.python_recipe.version,
1063
            # Don't use sources, instead wheels
1064
            "--only-binary=:all:",
1065
        ]
1066
        if force_upgrade:
×
1067
            pip_options.append("--upgrade")
×
1068
        # Use system's pip
1069
        shprint(sh.pip, *pip_options)
×
1070

1071
    def restore_hostpython_prerequisites(self, packages):
4✔
1072
        _packages = []
×
1073
        for package in packages:
×
1074
            original_version = Recipe.get_recipe(package, self.ctx).version
×
1075
            _packages.append(package + "==" + original_version)
×
1076
        self.install_hostpython_prerequisites(packages=_packages)
×
1077

1078

1079
class CompiledComponentsPythonRecipe(PythonRecipe):
4✔
1080
    pre_build_ext = False
4✔
1081

1082
    build_cmd = 'build_ext'
4✔
1083

1084
    def build_arch(self, arch):
4✔
1085
        '''Build any cython components, then install the Python module by
1086
        calling setup.py install with the target Python dir.
1087
        '''
1088
        Recipe.build_arch(self, arch)
×
1089
        self.install_hostpython_prerequisites()
×
1090
        self.build_compiled_components(arch)
×
1091
        self.install_python_package(arch)
×
1092

1093
    def build_compiled_components(self, arch):
4✔
1094
        info('Building compiled components in {}'.format(self.name))
×
1095

1096
        env = self.get_recipe_env(arch)
×
1097
        hostpython = sh.Command(self.hostpython_location)
×
1098
        with current_directory(self.get_build_dir(arch.arch)):
×
1099
            if self.install_in_hostpython:
×
1100
                shprint(hostpython, 'setup.py', 'clean', '--all', _env=env)
×
1101
            shprint(hostpython, 'setup.py', self.build_cmd, '-v',
×
1102
                    _env=env, *self.setup_extra_args)
1103
            build_dir = glob.glob('build/lib.*')[0]
×
1104
            shprint(sh.find, build_dir, '-name', '"*.o"', '-exec',
×
1105
                    env['STRIP'], '{}', ';', _env=env)
1106

1107
    def install_hostpython_package(self, arch):
4✔
1108
        env = self.get_hostrecipe_env(arch)
×
1109
        self.rebuild_compiled_components(arch, env)
×
1110
        super().install_hostpython_package(arch)
×
1111

1112
    def rebuild_compiled_components(self, arch, env):
4✔
1113
        info('Rebuilding compiled components in {}'.format(self.name))
×
1114

1115
        hostpython = sh.Command(self.real_hostpython_location)
×
1116
        shprint(hostpython, 'setup.py', 'clean', '--all', _env=env)
×
1117
        shprint(hostpython, 'setup.py', self.build_cmd, '-v', _env=env,
×
1118
                *self.setup_extra_args)
1119

1120

1121
class CppCompiledComponentsPythonRecipe(CompiledComponentsPythonRecipe):
4✔
1122
    """ Extensions that require the cxx-stl """
1123
    call_hostpython_via_targetpython = False
4✔
1124
    need_stl_shared = True
4✔
1125

1126

1127
class CythonRecipe(PythonRecipe):
4✔
1128
    pre_build_ext = False
4✔
1129
    cythonize = True
4✔
1130
    cython_args = []
4✔
1131
    call_hostpython_via_targetpython = False
4✔
1132

1133
    def build_arch(self, arch):
4✔
1134
        '''Build any cython components, then install the Python module by
1135
        calling setup.py install with the target Python dir.
1136
        '''
1137
        Recipe.build_arch(self, arch)
×
1138
        self.build_cython_components(arch)
×
1139
        self.install_python_package(arch)
×
1140

1141
    def build_cython_components(self, arch):
4✔
1142
        info('Cythonizing anything necessary in {}'.format(self.name))
×
1143

1144
        env = self.get_recipe_env(arch)
×
1145

1146
        with current_directory(self.get_build_dir(arch.arch)):
×
1147
            hostpython = sh.Command(self.ctx.hostpython)
×
1148
            shprint(hostpython, '-c', 'import sys; print(sys.path)', _env=env)
×
1149
            debug('cwd is {}'.format(realpath(curdir)))
×
1150
            info('Trying first build of {} to get cython files: this is '
×
1151
                 'expected to fail'.format(self.name))
1152

1153
            manually_cythonise = False
×
1154
            try:
×
1155
                shprint(hostpython, 'setup.py', 'build_ext', '-v', _env=env,
×
1156
                        *self.setup_extra_args)
1157
            except sh.ErrorReturnCode_1:
×
1158
                print()
×
1159
                info('{} first build failed (as expected)'.format(self.name))
×
1160
                manually_cythonise = True
×
1161

1162
            if manually_cythonise:
×
1163
                self.cythonize_build(env=env)
×
1164
                shprint(hostpython, 'setup.py', 'build_ext', '-v', _env=env,
×
1165
                        _tail=20, _critical=True, *self.setup_extra_args)
1166
            else:
1167
                info('First build appeared to complete correctly, skipping manual'
×
1168
                     'cythonising.')
1169

1170
            if not self.ctx.with_debug_symbols:
×
1171
                self.strip_object_files(arch, env)
×
1172

1173
    def strip_object_files(self, arch, env, build_dir=None):
4✔
1174
        if build_dir is None:
×
1175
            build_dir = self.get_build_dir(arch.arch)
×
1176
        with current_directory(build_dir):
×
1177
            info('Stripping object files')
×
1178
            shprint(sh.find, '.', '-iname', '*.so', '-exec',
×
1179
                    '/usr/bin/echo', '{}', ';', _env=env)
1180
            shprint(sh.find, '.', '-iname', '*.so', '-exec',
×
1181
                    env['STRIP'].split(' ')[0], '--strip-unneeded',
1182
                    # '/usr/bin/strip', '--strip-unneeded',
1183
                    '{}', ';', _env=env)
1184

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

1204
    def cythonize_build(self, env, build_dir="."):
4✔
1205
        if not self.cythonize:
×
1206
            info('Running cython cancelled per recipe setting')
×
1207
            return
×
1208
        info('Running cython where appropriate')
×
1209
        for root, dirnames, filenames in walk("."):
×
1210
            for filename in fnmatch.filter(filenames, "*.pyx"):
×
1211
                self.cythonize_file(env, build_dir, join(root, filename))
×
1212

1213
    def get_recipe_env(self, arch, with_flags_in_cc=True):
4✔
1214
        env = super().get_recipe_env(arch, with_flags_in_cc)
×
1215
        env['LDFLAGS'] = env['LDFLAGS'] + ' -L{} '.format(
×
1216
            self.ctx.get_libs_dir(arch.arch) +
1217
            ' -L{} '.format(self.ctx.libs_dir) +
1218
            ' -L{}'.format(join(self.ctx.bootstrap.build_dir, 'obj', 'local',
1219
                                arch.arch)))
1220

1221
        env['LDSHARED'] = env['CC'] + ' -shared'
×
1222
        # shprint(sh.whereis, env['LDSHARED'], _env=env)
1223
        env['LIBLINK'] = 'NOTNONE'
×
1224
        if self.ctx.copy_libs:
×
1225
            env['COPYLIBS'] = '1'
×
1226

1227
        # Every recipe uses its own liblink path, object files are
1228
        # collected and biglinked later
1229
        liblink_path = join(self.get_build_container_dir(arch.arch),
×
1230
                            'objects_{}'.format(self.name))
1231
        env['LIBLINK_PATH'] = liblink_path
×
1232
        ensure_dir(liblink_path)
×
1233

1234
        return env
×
1235

1236

1237
class PyProjectRecipe(PythonRecipe):
4✔
1238
    """Recipe for projects which contain `pyproject.toml`"""
1239

1240
    # Extra args to pass to `python -m build ...`
1241
    extra_build_args = []
4✔
1242
    call_hostpython_via_targetpython = False
4✔
1243

1244
    def get_recipe_env(self, arch, **kwargs):
4✔
1245
        # Custom hostpython
1246
        self.ctx.python_recipe.python_exe = join(
4✔
1247
            self.ctx.python_recipe.get_build_dir(arch), "android-build", "python3")
1248
        env = super().get_recipe_env(arch, **kwargs)
4✔
1249
        build_dir = self.get_build_dir(arch)
4✔
1250
        ensure_dir(build_dir)
4✔
1251
        build_opts = join(build_dir, "build-opts.cfg")
4✔
1252

1253
        with open(build_opts, "w") as file:
4✔
1254
            file.write("[bdist_wheel]\nplat-name={}".format(
4✔
1255
                self.get_wheel_platform_tag(arch)
1256
            ))
1257
            file.close()
4✔
1258

1259
        env["DIST_EXTRA_CONFIG"] = build_opts
4✔
1260
        return env
4✔
1261

1262
    def get_wheel_platform_tag(self, arch):
4✔
1263
        return "android_" + {
4✔
1264
            "armeabi-v7a": "arm",
1265
            "arm64-v8a": "aarch64",
1266
            "x86_64": "x86_64",
1267
            "x86": "i686",
1268
        }[arch.arch]
1269

1270
    def install_wheel(self, arch, built_wheels):
4✔
1271
        with patch_wheel_setuptools_logging():
×
1272
            from wheel.cli.tags import tags as wheel_tags
×
1273
            from wheel.wheelfile import WheelFile
×
1274
        _wheel = built_wheels[0]
×
1275
        built_wheel_dir = dirname(_wheel)
×
1276
        # Fix wheel platform tag
1277
        wheel_tag = wheel_tags(
×
1278
            _wheel,
1279
            platform_tags=self.get_wheel_platform_tag(arch),
1280
            remove=True,
1281
        )
1282
        selected_wheel = join(built_wheel_dir, wheel_tag)
×
1283

1284
        _dev_wheel_dir = environ.get("P4A_WHEEL_DIR", False)
×
1285
        if _dev_wheel_dir:
×
1286
            ensure_dir(_dev_wheel_dir)
×
1287
            shprint(sh.cp, selected_wheel, _dev_wheel_dir)
×
1288

1289
        info(f"Installing built wheel: {wheel_tag}")
×
1290
        destination = self.ctx.get_python_install_dir(arch.arch)
×
1291
        with WheelFile(selected_wheel) as wf:
×
1292
            for zinfo in wf.filelist:
×
1293
                wf.extract(zinfo, destination)
×
1294
            wf.close()
×
1295

1296
    def build_arch(self, arch):
4✔
1297
        self.install_hostpython_prerequisites(
×
1298
            packages=["build[virtualenv]", "pip"] + self.hostpython_prerequisites
1299
        )
NEW
1300
        python_bin_dir = join(self.hostpython_site_dir, "bin")
×
NEW
1301
        self.patch_shebangs(python_bin_dir, self.real_hostpython_location)
×
1302

1303
        build_dir = self.get_build_dir(arch.arch)
×
1304
        env = self.get_recipe_env(arch, with_flags_in_cc=True)
×
1305
        # make build dir separately
1306
        sub_build_dir = join(build_dir, "p4a_android_build")
×
1307
        ensure_dir(sub_build_dir)
×
1308
        # copy hostpython to built python to ensure correct selection of libs and includes
1309
        shprint(sh.cp, self.real_hostpython_location, self.ctx.python_recipe.python_exe)
×
1310

1311
        build_args = [
×
1312
            "-m",
1313
            "build",
1314
            "--wheel",
1315
            "--config-setting",
1316
            "builddir={}".format(sub_build_dir),
1317
        ] + self.extra_build_args
1318

1319
        built_wheels = []
×
1320
        with current_directory(build_dir):
×
1321
            shprint(
×
1322
                sh.Command(self.ctx.python_recipe.python_exe), *build_args, _env=env
1323
            )
1324
            built_wheels = [realpath(whl) for whl in glob.glob("dist/*.whl")]
×
1325
        self.install_wheel(arch, built_wheels)
×
1326

1327

1328
class MesonRecipe(PyProjectRecipe):
4✔
1329
    '''Recipe for projects which uses meson as build system'''
1330

1331
    meson_version = "1.4.0"
4✔
1332
    ninja_version = "1.11.1.1"
4✔
1333

1334
    def sanitize_flags(self, *flag_strings):
4✔
1335
        return " ".join(flag_strings).strip().split(" ")
×
1336

1337
    def get_recipe_meson_options(self, arch):
4✔
1338
        env = self.get_recipe_env(arch, with_flags_in_cc=True)
×
1339
        return {
×
1340
            "binaries": {
1341
                "c": arch.get_clang_exe(with_target=True),
1342
                "cpp": arch.get_clang_exe(with_target=True, plus_plus=True),
1343
                "ar": self.ctx.ndk.llvm_ar,
1344
                "strip": self.ctx.ndk.llvm_strip,
1345
            },
1346
            "built-in options": {
1347
                "c_args": self.sanitize_flags(env["CFLAGS"], env["CPPFLAGS"]),
1348
                "cpp_args": self.sanitize_flags(env["CXXFLAGS"], env["CPPFLAGS"]),
1349
                "c_link_args": self.sanitize_flags(env["LDFLAGS"]),
1350
                "cpp_link_args": self.sanitize_flags(env["LDFLAGS"]),
1351
                "fortran_link_args": self.sanitize_flags(env["LDFLAGS"]),
1352
            },
1353
            "properties": {
1354
                "needs_exe_wrapper": True,
1355
                "sys_root": self.ctx.ndk.sysroot
1356
            },
1357
            "host_machine": {
1358
                "cpu_family": {
1359
                    "arm64-v8a": "aarch64",
1360
                    "armeabi-v7a": "arm",
1361
                    "x86_64": "x86_64",
1362
                    "x86": "x86"
1363
                }[arch.arch],
1364
                "cpu": {
1365
                    "arm64-v8a": "aarch64",
1366
                    "armeabi-v7a": "armv7",
1367
                    "x86_64": "x86_64",
1368
                    "x86": "i686"
1369
                }[arch.arch],
1370
                "endian": "little",
1371
                "system": "android",
1372
            }
1373
        }
1374

1375
    def write_build_options(self, arch):
4✔
1376
        """Writes python dict to meson config file"""
1377
        option_data = ""
×
1378
        build_options = self.get_recipe_meson_options(arch)
×
1379
        for key in build_options.keys():
×
1380
            data_chunk = "[{}]".format(key)
×
1381
            for subkey in build_options[key].keys():
×
1382
                value = build_options[key][subkey]
×
1383
                if isinstance(value, int):
×
1384
                    value = str(value)
×
1385
                elif isinstance(value, str):
×
1386
                    value = "'{}'".format(value)
×
1387
                elif isinstance(value, bool):
×
1388
                    value = "true" if value else "false"
×
1389
                elif isinstance(value, list):
×
1390
                    value = "['" + "', '".join(value) + "']"
×
1391
                data_chunk += "\n" + subkey + " = " + value
×
1392
            option_data += data_chunk + "\n\n"
×
1393
        return option_data
×
1394

1395
    def ensure_args(self, *args):
4✔
1396
        for arg in args:
×
1397
            if arg not in self.extra_build_args:
×
1398
                self.extra_build_args.append(arg)
×
1399

1400
    def build_arch(self, arch):
4✔
1401
        cross_file = join("/tmp", "android.meson.cross")
×
1402
        info("Writing cross file at: {}".format(cross_file))
×
1403
        # write cross config file
1404
        with open(cross_file, "w") as file:
×
1405
            file.write(self.write_build_options(arch))
×
1406
            file.close()
×
1407
        # set cross file
1408
        self.ensure_args('-Csetup-args=--cross-file', '-Csetup-args={}'.format(cross_file))
×
1409
        # ensure ninja and meson
1410
        for dep in [
×
1411
            "ninja=={}".format(self.ninja_version),
1412
            "meson=={}".format(self.meson_version),
1413
        ]:
1414
            if dep not in self.hostpython_prerequisites:
×
1415
                self.hostpython_prerequisites.append(dep)
×
1416
        super().build_arch(arch)
×
1417

1418

1419
class RustCompiledComponentsRecipe(PyProjectRecipe):
4✔
1420
    # Rust toolchain codes
1421
    # https://doc.rust-lang.org/nightly/rustc/platform-support.html
1422
    RUST_ARCH_CODES = {
4✔
1423
        "arm64-v8a": "aarch64-linux-android",
1424
        "armeabi-v7a": "armv7-linux-androideabi",
1425
        "x86_64": "x86_64-linux-android",
1426
        "x86": "i686-linux-android",
1427
    }
1428

1429
    call_hostpython_via_targetpython = False
4✔
1430

1431
    def get_recipe_env(self, arch, **kwargs):
4✔
1432
        env = super().get_recipe_env(arch, **kwargs)
×
1433

1434
        # Set rust build target
1435
        build_target = self.RUST_ARCH_CODES[arch.arch]
×
1436
        cargo_linker_name = "CARGO_TARGET_{}_LINKER".format(
×
1437
            build_target.upper().replace("-", "_")
1438
        )
1439
        env["CARGO_BUILD_TARGET"] = build_target
×
1440
        env[cargo_linker_name] = join(
×
1441
            self.ctx.ndk.llvm_prebuilt_dir,
1442
            "bin",
1443
            "{}{}-clang".format(
1444
                # NDK's Clang format
1445
                build_target.replace("7", "7a")
1446
                if build_target.startswith("armv7")
1447
                else build_target,
1448
                self.ctx.ndk_api,
1449
            ),
1450
        )
1451
        realpython_dir = self.ctx.python_recipe.get_build_dir(arch.arch)
×
1452

1453
        env["RUSTFLAGS"] = "-Clink-args=-L{} -L{}".format(
×
1454
            self.ctx.get_libs_dir(arch.arch), join(realpython_dir, "android-build")
1455
        )
1456

1457
        env["PYO3_CROSS_LIB_DIR"] = realpath(glob.glob(join(
×
1458
            realpython_dir, "android-build", "build",
1459
            "lib.linux-*-{}/".format(self.python_major_minor_version),
1460
        ))[0])
1461

1462
        info_main("Ensuring rust build toolchain")
×
1463
        shprint(sh.rustup, "target", "add", build_target)
×
1464

1465
        # Add host python to PATH
1466
        env["PATH"] = ("{hostpython_dir}:{old_path}").format(
×
1467
            hostpython_dir=Recipe.get_recipe(
1468
                "hostpython3", self.ctx
1469
            ).get_path_to_python(),
1470
            old_path=env["PATH"],
1471
        )
1472
        return env
×
1473

1474
    def check_host_deps(self):
4✔
1475
        if not hasattr(sh, "rustup"):
×
1476
            error(
×
1477
                "`rustup` was not found on host system."
1478
                "Please install it using :"
1479
                "\n`curl https://sh.rustup.rs -sSf | sh`\n"
1480
            )
1481
            exit(1)
×
1482

1483
    def build_arch(self, arch):
4✔
1484
        self.check_host_deps()
×
1485
        super().build_arch(arch)
×
1486

1487

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

1492
    def __init__(self, *args, **kwargs):
4✔
1493
        self._ctx = None
4✔
1494
        super().__init__(*args, **kwargs)
4✔
1495

1496
    def prebuild_arch(self, arch):
4✔
1497
        super().prebuild_arch(arch)
×
1498
        self.ctx.python_recipe = self
×
1499

1500
    def include_root(self, arch):
4✔
1501
        '''The root directory from which to include headers.'''
1502
        raise NotImplementedError('Not implemented in TargetPythonRecipe')
1503

1504
    def link_root(self):
4✔
1505
        raise NotImplementedError('Not implemented in TargetPythonRecipe')
1506

1507
    @property
4✔
1508
    def major_minor_version_string(self):
4✔
1509
        parsed_version = packaging.version.parse(self.version)
4✔
1510
        return f"{parsed_version.major}.{parsed_version.minor}"
4✔
1511

1512
    def create_python_bundle(self, dirn, arch):
4✔
1513
        """
1514
        Create a packaged python bundle in the target directory, by
1515
        copying all the modules and standard library to the right
1516
        place.
1517
        """
1518
        raise NotImplementedError('{} does not implement create_python_bundle'.format(self))
1519

1520
    def reduce_object_file_names(self, dirn):
4✔
1521
        """Recursively renames all files named XXX.cpython-...-linux-gnu.so"
1522
        to "XXX.so", i.e. removing the erroneous architecture name
1523
        coming from the local system.
1524
        """
1525
        py_so_files = shprint(sh.find, dirn, '-iname', '*.so')
4✔
1526
        filens = py_so_files.stdout.decode('utf-8').split('\n')[:-1]
4✔
1527
        for filen in filens:
4!
1528
            file_dirname, file_basename = split(filen)
×
1529
            parts = file_basename.split('.')
×
1530
            if len(parts) <= 2:
×
1531
                continue
×
1532
            # PySide6 libraries end with .abi3.so
1533
            if parts[1] == "abi3":
×
1534
                continue
×
1535
            move(filen, join(file_dirname, parts[0] + '.so'))
×
1536

1537

1538
def algsum(alg, filen):
4✔
1539
    '''Calculate the digest of a file.
1540
    '''
1541
    with open(filen, 'rb') as fileh:
×
1542
        digest = getattr(hashlib, alg)(fileh.read())
×
1543

1544
    return digest.hexdigest()
×
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc