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

kivy / python-for-android / 14561414518

20 Apr 2025 04:39PM UTC coverage: 58.861% (-0.3%) from 59.204%
14561414518

Pull #3136

github

web-flow
Merge bce0f1307 into f491c6eb2
Pull Request #3136: scipy: update to v1.15.2

1059 of 2398 branches covered (44.16%)

Branch coverage included in aggregate %.

72 of 221 new or added lines in 9 files covered. (32.58%)

4 existing lines in 3 files now uncovered.

4979 of 7860 relevant lines covered (63.35%)

2.52 hits per line

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

45.71
/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``. In case of git repository version is branch or a tagged release e.g. ``v12.3`` or ``develop``.'''
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

NEW
261
                shprint(
×
262
                    sh.git,
263
                    'clone',
264
                    '--branch',
265
                    self.version,
266
                    '--depth',
267
                    '1',
268
                    '--recurse-submodules',
269
                    '--shallow-submodules',
270
                    url,
271
                    target,
272
                )
273

UNCOV
274
            return target
×
275

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

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

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

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

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

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

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

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

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

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

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

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

364
        return join(self.get_build_container_dir(arch), self.name)
4✔
365

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

377
    # Public Recipe API to be subclassed if needed
378

379
    def download_if_necessary(self):
4✔
380
        if self.ctx.ndk_api < self.min_ndk_api_support:
4!
NEW
381
            error(f"In order to build '{self.name}', you must set minimum ndk api (minapi) to `{self.min_ndk_api_support}`.\n")
×
NEW
382
            exit(1)
×
383

384
        info_main('Downloading {}'.format(self.name))
4✔
385

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

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

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

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

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

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

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

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

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

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

461
        build_dir = self.get_build_container_dir(arch)
×
462

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

751

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

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

765

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

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

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

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

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

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

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

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

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

803

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

807
    generated_libraries = []
4✔
808

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

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

816
        return False
×
817

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

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

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

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

838

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

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

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

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

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

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

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

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

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

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

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

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

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

929
    def patch_shebang(self, _file, original_bin):
4✔
NEW
930
        _file_des = open(_file, "r")
×
931

NEW
932
        try:
×
NEW
933
            data = _file_des.readlines()
×
NEW
934
        except UnicodeDecodeError:
×
NEW
935
            return
×
936

NEW
937
        if "#!" in (line := data[0]):
×
NEW
938
            if line.split("#!")[-1].strip() == original_bin:
×
NEW
939
                return
×
940

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

949
    def patch_shebangs(self, path, original_bin):
4✔
950
        # set correct shebang
NEW
951
        for file in listdir(path):
×
NEW
952
            _file = join(path, file)
×
NEW
953
            self.patch_shebang(_file, original_bin)
×
954

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

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

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

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

995
    def build_arch(self, arch):
4✔
996
        '''Install the Python module by calling setup.py install with
997
        the target Python dir.'''
998
        self.install_hostpython_prerequisites()
×
999
        super().build_arch(arch)
×
1000
        self.install_python_package(arch)
×
1001

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

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

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

1021
            # If asked, also install in the hostpython build dir
1022
            if self.install_in_hostpython:
×
1023
                self.install_hostpython_package(arch)
×
1024

1025
    def get_hostrecipe_env(self, arch):
4✔
1026
        env = environ.copy()
×
1027
        env['PYTHONPATH'] = self.hostpython_site_dir
×
1028
        return env
×
1029

1030
    @property
4✔
1031
    def hostpython_site_dir(self):
4✔
1032
        return join(dirname(self.real_hostpython_location), 'Lib', 'site-packages')
4✔
1033

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

1042
    @property
4✔
1043
    def python_major_minor_version(self):
4✔
1044
        parsed_version = packaging.version.parse(self.ctx.python_recipe.version)
×
1045
        return f"{parsed_version.major}.{parsed_version.minor}"
×
1046

1047
    def install_hostpython_prerequisites(self, packages=None, force_upgrade=True):
4✔
1048
        if not packages:
×
1049
            packages = self.hostpython_prerequisites
×
1050

1051
        if len(packages) == 0:
×
1052
            return
×
1053

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

1067
    def restore_hostpython_prerequisites(self, packages):
4✔
1068
        _packages = []
×
1069
        for package in packages:
×
1070
            original_version = Recipe.get_recipe(package, self.ctx).version
×
1071
            _packages.append(package + "==" + original_version)
×
1072
        self.install_hostpython_prerequisites(packages=_packages)
×
1073

1074

1075
class CompiledComponentsPythonRecipe(PythonRecipe):
4✔
1076
    pre_build_ext = False
4✔
1077

1078
    build_cmd = 'build_ext'
4✔
1079

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

1089
    def build_compiled_components(self, arch):
4✔
1090
        info('Building compiled components in {}'.format(self.name))
×
1091

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

1103
    def install_hostpython_package(self, arch):
4✔
1104
        env = self.get_hostrecipe_env(arch)
×
1105
        self.rebuild_compiled_components(arch, env)
×
1106
        super().install_hostpython_package(arch)
×
1107

1108
    def rebuild_compiled_components(self, arch, env):
4✔
1109
        info('Rebuilding compiled components in {}'.format(self.name))
×
1110

1111
        hostpython = sh.Command(self.real_hostpython_location)
×
1112
        shprint(hostpython, 'setup.py', 'clean', '--all', _env=env)
×
1113
        shprint(hostpython, 'setup.py', self.build_cmd, '-v', _env=env,
×
1114
                *self.setup_extra_args)
1115

1116

1117
class CppCompiledComponentsPythonRecipe(CompiledComponentsPythonRecipe):
4✔
1118
    """ Extensions that require the cxx-stl """
1119
    call_hostpython_via_targetpython = False
4✔
1120
    need_stl_shared = True
4✔
1121

1122

1123
class CythonRecipe(PythonRecipe):
4✔
1124
    pre_build_ext = False
4✔
1125
    cythonize = True
4✔
1126
    cython_args = []
4✔
1127
    call_hostpython_via_targetpython = False
4✔
1128

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

1137
    def build_cython_components(self, arch):
4✔
1138
        info('Cythonizing anything necessary in {}'.format(self.name))
×
1139

1140
        env = self.get_recipe_env(arch)
×
1141

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

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

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

1166
            if not self.ctx.with_debug_symbols:
×
1167
                self.strip_object_files(arch, env)
×
1168

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

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

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

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

1217
        env['LDSHARED'] = env['CC'] + ' -shared'
×
1218
        # shprint(sh.whereis, env['LDSHARED'], _env=env)
1219
        env['LIBLINK'] = 'NOTNONE'
×
1220
        if self.ctx.copy_libs:
×
1221
            env['COPYLIBS'] = '1'
×
1222

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

1230
        return env
×
1231

1232

1233
class PyProjectRecipe(PythonRecipe):
4✔
1234
    """Recipe for projects which contain `pyproject.toml`"""
1235

1236
    # Extra args to pass to `python -m build ...`
1237
    extra_build_args = []
4✔
1238
    call_hostpython_via_targetpython = False
4✔
1239

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

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

1255
        env["DIST_EXTRA_CONFIG"] = build_opts
4✔
1256
        return env
4✔
1257

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

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

1280
        _dev_wheel_dir = environ.get("P4A_WHEEL_DIR", False)
×
1281
        if _dev_wheel_dir:
×
1282
            ensure_dir(_dev_wheel_dir)
×
1283
            shprint(sh.cp, selected_wheel, _dev_wheel_dir)
×
1284

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

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

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

1307
        build_args = [
×
1308
            "-m",
1309
            "build",
1310
            "--wheel",
1311
            "--config-setting",
1312
            "builddir={}".format(sub_build_dir),
1313
        ] + self.extra_build_args
1314

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

1323

1324
class MesonRecipe(PyProjectRecipe):
4✔
1325
    '''Recipe for projects which uses meson as build system'''
1326

1327
    meson_version = "1.4.0"
4✔
1328
    ninja_version = "1.11.1.1"
4✔
1329

1330
    def sanitize_flags(self, *flag_strings):
4✔
1331
        return " ".join(flag_strings).strip().split(" ")
×
1332

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

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

1391
    def ensure_args(self, *args):
4✔
1392
        for arg in args:
×
1393
            if arg not in self.extra_build_args:
×
1394
                self.extra_build_args.append(arg)
×
1395

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

1414

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

1425
    call_hostpython_via_targetpython = False
4✔
1426

1427
    def get_recipe_env(self, arch, **kwargs):
4✔
1428
        env = super().get_recipe_env(arch, **kwargs)
×
1429

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

1449
        env["RUSTFLAGS"] = "-Clink-args=-L{} -L{}".format(
×
1450
            self.ctx.get_libs_dir(arch.arch), join(realpython_dir, "android-build")
1451
        )
1452

1453
        env["PYO3_CROSS_LIB_DIR"] = realpath(glob.glob(join(
×
1454
            realpython_dir, "android-build", "build",
1455
            "lib.linux-*-{}/".format(self.python_major_minor_version),
1456
        ))[0])
1457

1458
        info_main("Ensuring rust build toolchain")
×
1459
        shprint(sh.rustup, "target", "add", build_target)
×
1460

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

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

1479
    def build_arch(self, arch):
4✔
1480
        self.check_host_deps()
×
1481
        super().build_arch(arch)
×
1482

1483

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

1488
    def __init__(self, *args, **kwargs):
4✔
1489
        self._ctx = None
4✔
1490
        super().__init__(*args, **kwargs)
4✔
1491

1492
    def prebuild_arch(self, arch):
4✔
1493
        super().prebuild_arch(arch)
×
1494
        self.ctx.python_recipe = self
×
1495

1496
    def include_root(self, arch):
4✔
1497
        '''The root directory from which to include headers.'''
1498
        raise NotImplementedError('Not implemented in TargetPythonRecipe')
1499

1500
    def link_root(self):
4✔
1501
        raise NotImplementedError('Not implemented in TargetPythonRecipe')
1502

1503
    @property
4✔
1504
    def major_minor_version_string(self):
4✔
1505
        parsed_version = packaging.version.parse(self.version)
4✔
1506
        return f"{parsed_version.major}.{parsed_version.minor}"
4✔
1507

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

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

1533

1534
def algsum(alg, filen):
4✔
1535
    '''Calculate the digest of a file.
1536
    '''
1537
    with open(filen, 'rb') as fileh:
×
1538
        digest = getattr(hashlib, alg)(fileh.read())
×
1539

1540
    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