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

kivy / python-for-android / 23490436859

24 Mar 2026 12:53PM UTC coverage: 63.565% (-0.1%) from 63.661%
23490436859

Pull #3296

github

web-flow
Merge 6c05f134c into a4a8b2814
Pull Request #3296: Implement wheel platform tag renaming method

1808 of 3114 branches covered (58.06%)

Branch coverage included in aggregate %.

2 of 18 new or added lines in 1 file covered. (11.11%)

1 existing line in 1 file now uncovered.

5282 of 8040 relevant lines covered (65.7%)

5.24 hits per line

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

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

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

23
import packaging.version
8✔
24

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

32

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

37

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

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

48

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

382
    # Public Recipe API to be subclassed if needed
383

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

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

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

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

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

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

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

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

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

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

464
        build_dir = self.get_build_container_dir(arch)
×
465

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

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

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

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

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

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

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

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

544
        return env
8✔
545

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

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

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

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

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

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

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

584
            touch(join(build_dir, '.patched'))
×
585

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

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

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

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

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

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

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

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

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

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

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

663
        for directory in dirs:
×
664
            rmdir(directory)
×
665

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

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

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

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

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

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

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

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

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

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

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

759

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

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

773

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

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

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

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

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

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

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

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

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

811

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

815
    generated_libraries = []
8✔
816

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

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

824
        return False
×
825

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

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

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

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

848

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

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

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

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

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

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

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

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

890
    _host_recipe = None
8✔
891

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

998
        return env
8✔
999

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

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

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

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

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

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

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

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

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

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

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

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

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

1086

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

1090
    build_cmd = 'build_ext'
8✔
1091

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

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

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

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

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

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

1128

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

1134

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

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

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

1152
        env = self.get_recipe_env(arch)
×
1153

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

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

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

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

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

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

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

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

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

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

1242
        return env
×
1243

1244

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

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

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

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

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

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

1282
    def _retag_wheel_platform(self, path, new_platform_tag, remove_old=True):
8✔
1283
        """
1284
        Rename a wheel by replacing only its platform tag.
1285

1286
        Wheel filename format:
1287
          {dist}-{version}(-{build})?-{python tag}-{abi tag}-{platform tag}.whl
1288

1289
        We split from the right so project names/build tags containing '-' still work.
1290
        """
NEW
1291
        p = Path(path)
×
NEW
1292
        if p.suffix != ".whl":
×
NEW
1293
            raise ValueError(f"Not a wheel: {path}")
×
1294

NEW
1295
        parts = p.stem.rsplit("-", 3)
×
NEW
1296
        if len(parts) != 4:
×
NEW
1297
            raise ValueError(f"Unexpected wheel filename format: {p.name}")
×
1298

NEW
1299
        left, py_tag, abi_tag, _old_platform_tag = parts
×
NEW
1300
        new_name = f"{left}-{py_tag}-{abi_tag}-{new_platform_tag}.whl"
×
NEW
1301
        new_path = p.with_name(new_name)
×
1302

NEW
1303
        if new_path != p:
×
NEW
1304
            p.rename(new_path)
×
NEW
1305
            if not remove_old and new_path != p:
×
1306
                # rename already removed old path, so if you truly want both,
1307
                # copy instead of rename
NEW
1308
                pass
×
1309

NEW
1310
        return str(new_path)
×
1311

1312
    def install_wheel(self, arch, built_wheels):
8✔
1313
        with patch_wheel_setuptools_logging():
×
1314
            from wheel.wheelfile import WheelFile
×
1315
        _wheel = built_wheels[0]
×
1316
        # Fix wheel platform tag
NEW
1317
        selected_wheel = self._retag_wheel_platform(
×
1318
            _wheel,
1319
            self.get_wheel_platform_tag(arch),
1320
            remove_old=True,
1321
        )
1322

UNCOV
1323
        _dev_wheel_dir = environ.get("P4A_WHEEL_DIR", False)
×
1324
        if _dev_wheel_dir:
×
1325
            ensure_dir(_dev_wheel_dir)
×
1326
            shprint(sh.cp, selected_wheel, _dev_wheel_dir)
×
1327

NEW
1328
        info(f"Installing built wheel: {basename(selected_wheel)}")
×
1329
        destination = self.ctx.get_python_install_dir(arch.arch)
×
1330
        with WheelFile(selected_wheel) as wf:
×
1331
            for zinfo in wf.filelist:
×
1332
                wf.extract(zinfo, destination)
×
1333

1334
    def build_arch(self, arch):
8✔
1335

1336
        build_dir = self.get_build_dir(arch.arch)
×
1337
        if not (isfile(join(build_dir, "pyproject.toml")) or isfile(join(build_dir, "setup.py"))):
×
1338
            warning("Skipping build because it does not appear to be a Python project.")
×
1339
            return
×
1340

1341
        self.install_hostpython_prerequisites(
×
1342
            packages=["build[virtualenv]", "pip", "setuptools", "patchelf"] + self.hostpython_prerequisites
1343
        )
1344
        self.patch_shebangs(self._host_recipe.site_bin, self.real_hostpython_location)
×
1345

1346
        env = self.get_recipe_env(arch, with_flags_in_cc=True)
×
1347
        # make build dir separately
1348
        sub_build_dir = join(build_dir, "p4a_android_build")
×
1349
        ensure_dir(sub_build_dir)
×
1350
        # copy hostpython to built python to ensure correct selection of libs and includes
1351
        shprint(sh.cp, self.real_hostpython_location, self.ctx.python_recipe.python_exe)
×
1352

1353
        build_args = [
×
1354
            "-m",
1355
            "build",
1356
            "--wheel",
1357
            "--config-setting",
1358
            "builddir={}".format(sub_build_dir),
1359
        ] + self.extra_build_args
1360

1361
        built_wheels = []
×
1362
        with current_directory(build_dir):
×
1363
            shprint(
×
1364
                sh.Command(self.ctx.python_recipe.python_exe), *build_args, _env=env
1365
            )
1366
            built_wheels = [realpath(whl) for whl in glob.glob("dist/*.whl")]
×
1367
        self.install_wheel(arch, built_wheels)
×
1368

1369

1370
class MesonRecipe(PyProjectRecipe):
8✔
1371
    '''Recipe for projects which uses meson as build system'''
1372

1373
    meson_version = "1.4.0"
8✔
1374
    ninja_version = "1.11.1.1"
8✔
1375

1376
    skip_python = False
8✔
1377
    '''If true, skips all Python build and installation steps.
6✔
1378
    Useful for Meson projects written purely in C/C++ without Python bindings.'''
1379

1380
    def sanitize_flags(self, *flag_strings):
8✔
1381
        return " ".join(flag_strings).strip().split(" ")
×
1382

1383
    def get_recipe_meson_options(self, arch):
8✔
1384
        env = self.get_recipe_env(arch, with_flags_in_cc=True)
×
1385
        return {
×
1386
            "binaries": {
1387
                "c": arch.get_clang_exe(with_target=True),
1388
                "cpp": arch.get_clang_exe(with_target=True, plus_plus=True),
1389
                "ar": self.ctx.ndk.llvm_ar,
1390
                "strip": self.ctx.ndk.llvm_strip,
1391
            },
1392
            "built-in options": {
1393
                "c_args": self.sanitize_flags(env["CFLAGS"], env["CPPFLAGS"]),
1394
                "cpp_args": self.sanitize_flags(env["CXXFLAGS"], env["CPPFLAGS"]),
1395
                "c_link_args": self.sanitize_flags(env["LDFLAGS"]),
1396
                "cpp_link_args": self.sanitize_flags(env["LDFLAGS"]),
1397
                "fortran_link_args": self.sanitize_flags(env["LDFLAGS"]),
1398
            },
1399
            "properties": {
1400
                "needs_exe_wrapper": True,
1401
                "sys_root": self.ctx.ndk.sysroot
1402
            },
1403
            "host_machine": {
1404
                "cpu_family": {
1405
                    "arm64-v8a": "aarch64",
1406
                    "armeabi-v7a": "arm",
1407
                    "x86_64": "x86_64",
1408
                    "x86": "x86"
1409
                }[arch.arch],
1410
                "cpu": {
1411
                    "arm64-v8a": "aarch64",
1412
                    "armeabi-v7a": "armv7",
1413
                    "x86_64": "x86_64",
1414
                    "x86": "i686"
1415
                }[arch.arch],
1416
                "endian": "little",
1417
                "system": "android",
1418
            }
1419
        }
1420

1421
    def write_build_options(self, arch):
8✔
1422
        """Writes python dict to meson config file"""
1423
        option_data = ""
×
1424
        build_options = self.get_recipe_meson_options(arch)
×
1425
        for key in build_options.keys():
×
1426
            data_chunk = "[{}]".format(key)
×
1427
            for subkey in build_options[key].keys():
×
1428
                value = build_options[key][subkey]
×
1429
                if isinstance(value, int):
×
1430
                    value = str(value)
×
1431
                elif isinstance(value, str):
×
1432
                    value = "'{}'".format(value)
×
1433
                elif isinstance(value, bool):
×
1434
                    value = "true" if value else "false"
×
1435
                elif isinstance(value, list):
×
1436
                    value = "['" + "', '".join(value) + "']"
×
1437
                data_chunk += "\n" + subkey + " = " + value
×
1438
            option_data += data_chunk + "\n\n"
×
1439
        return option_data
×
1440

1441
    def ensure_args(self, *args):
8✔
1442
        for arg in args:
×
1443
            if arg not in self.extra_build_args:
×
1444
                self.extra_build_args.append(arg)
×
1445

1446
    def build_arch(self, arch):
8✔
1447
        cross_file = join("/tmp", "android.meson.cross")
×
1448
        info("Writing cross file at: {}".format(cross_file))
×
1449
        # write cross config file
1450
        with open(cross_file, "w") as file:
×
1451
            file.write(self.write_build_options(arch))
×
1452
            file.close()
×
1453
        # set cross file
1454
        self.ensure_args('-Csetup-args=--cross-file', '-Csetup-args={}'.format(cross_file))
×
1455
        # ensure ninja and meson
1456
        for dep in [
×
1457
            "ninja=={}".format(self.ninja_version),
1458
            "meson=={}".format(self.meson_version),
1459
        ]:
1460
            if dep not in self.hostpython_prerequisites:
×
1461
                self.hostpython_prerequisites.append(dep)
×
1462
        if not self.skip_python:
×
1463
            super().build_arch(arch)
×
1464

1465

1466
class RustCompiledComponentsRecipe(PyProjectRecipe):
8✔
1467
    # Rust toolchain codes
1468
    # https://doc.rust-lang.org/nightly/rustc/platform-support.html
1469
    RUST_ARCH_CODES = {
8✔
1470
        "arm64-v8a": "aarch64-linux-android",
1471
        "armeabi-v7a": "armv7-linux-androideabi",
1472
        "x86_64": "x86_64-linux-android",
1473
        "x86": "i686-linux-android",
1474
    }
1475

1476
    call_hostpython_via_targetpython = False
8✔
1477

1478
    def get_recipe_env(self, arch, **kwargs):
8✔
1479
        env = super().get_recipe_env(arch, **kwargs)
×
1480

1481
        # Set rust build target
1482
        build_target = self.RUST_ARCH_CODES[arch.arch]
×
1483
        cargo_linker_name = "CARGO_TARGET_{}_LINKER".format(
×
1484
            build_target.upper().replace("-", "_")
1485
        )
1486
        env["CARGO_BUILD_TARGET"] = build_target
×
1487
        env[cargo_linker_name] = join(
×
1488
            self.ctx.ndk.llvm_prebuilt_dir,
1489
            "bin",
1490
            "{}{}-clang".format(
1491
                # NDK's Clang format
1492
                build_target.replace("7", "7a")
1493
                if build_target.startswith("armv7")
1494
                else build_target,
1495
                self.ctx.ndk_api,
1496
            ),
1497
        )
1498
        realpython_dir = self.ctx.python_recipe.get_build_dir(arch.arch)
×
1499

1500
        env["RUSTFLAGS"] = "-Clink-args=-L{} -L{}".format(
×
1501
            self.ctx.get_libs_dir(arch.arch), join(realpython_dir, "android-build")
1502
        )
1503

1504
        env["PYO3_CROSS_LIB_DIR"] = realpath(glob.glob(join(
×
1505
            realpython_dir, "android-build", "build",
1506
            "lib.*{}/".format(self.python_major_minor_version),
1507
        ))[0])
1508

1509
        info_main("Ensuring rust build toolchain")
×
1510
        shprint(sh.rustup, "target", "add", build_target)
×
1511

1512
        # Add host python to PATH
1513
        env["PATH"] = ("{hostpython_dir}:{old_path}").format(
×
1514
            hostpython_dir=Recipe.get_recipe(
1515
                "hostpython3", self.ctx
1516
            ).get_path_to_python(),
1517
            old_path=env["PATH"],
1518
        )
1519
        return env
×
1520

1521
    def check_host_deps(self):
8✔
1522
        if not hasattr(sh, "rustup"):
×
1523
            error(
×
1524
                "`rustup` was not found on host system."
1525
                "Please install it using :"
1526
                "\n`curl https://sh.rustup.rs -sSf | sh`\n"
1527
            )
1528
            exit(1)
×
1529

1530
    def build_arch(self, arch):
8✔
1531
        self.check_host_deps()
×
1532
        super().build_arch(arch)
×
1533

1534

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

1539
    def __init__(self, *args, **kwargs):
8✔
1540
        self._ctx = None
8✔
1541
        super().__init__(*args, **kwargs)
8✔
1542

1543
    def prebuild_arch(self, arch):
8✔
1544
        super().prebuild_arch(arch)
×
1545
        self.ctx.python_recipe = self
×
1546

1547
    def include_root(self, arch):
8✔
1548
        '''The root directory from which to include headers.'''
1549
        raise NotImplementedError('Not implemented in TargetPythonRecipe')
1550

1551
    def link_root(self):
8✔
1552
        raise NotImplementedError('Not implemented in TargetPythonRecipe')
1553

1554
    @property
8✔
1555
    def major_minor_version_string(self):
8✔
1556
        parsed_version = packaging.version.parse(self.version)
8✔
1557
        return f"{parsed_version.major}.{parsed_version.minor}"
8✔
1558

1559
    def create_python_bundle(self, dirn, arch):
8✔
1560
        """
1561
        Create a packaged python bundle in the target directory, by
1562
        copying all the modules and standard library to the right
1563
        place.
1564
        """
1565
        raise NotImplementedError('{} does not implement create_python_bundle'.format(self))
1566

1567
    def reduce_object_file_names(self, dirn):
8✔
1568
        """Recursively renames all files named XXX.cpython-...-linux-gnu.so"
1569
        to "XXX.so", i.e. removing the erroneous architecture name
1570
        coming from the local system.
1571
        """
1572
        py_so_files = shprint(sh.find, dirn, '-iname', '*.so')
×
1573
        filens = py_so_files.stdout.decode('utf-8').split('\n')[:-1]
×
1574
        for filen in filens:
×
1575
            file_dirname, file_basename = split(filen)
×
1576
            parts = file_basename.split('.')
×
1577
            if len(parts) <= 2:
×
1578
                continue
×
1579
            # PySide6 libraries end with .abi3.so
1580
            if parts[1] == "abi3":
×
1581
                continue
×
1582
            move(filen, join(file_dirname, parts[0] + '.so'))
×
1583

1584

1585
def algsum(alg, filen):
8✔
1586
    '''Calculate the digest of a file.
1587
    '''
1588
    with open(filen, 'rb') as fileh:
×
1589
        digest = getattr(hashlib, alg)(fileh.read())
×
1590

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

© 2026 Coveralls, Inc