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

kivy / python-for-android / 26682386815

30 May 2026 11:14AM UTC coverage: 62.67% (-1.2%) from 63.887%
26682386815

Pull #3278

github

web-flow
Merge 77aee3d95 into 74b559a3c
Pull Request #3278: Handling system bars and Edge-to-Edge enforcement (android 15+)

1832 of 3194 branches covered (57.36%)

Branch coverage included in aggregate %.

5407 of 8357 relevant lines covered (64.7%)

3.88 hits per line

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

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

7
import sh
6✔
8
import subprocess
6✔
9
import shutil
6✔
10
import fnmatch
6✔
11
import zipfile
6✔
12
import urllib.request
6✔
13
from urllib.request import urlretrieve
6✔
14
from os import listdir, unlink, environ, curdir, walk, chmod
6✔
15
from sys import stdout
6✔
16
from packaging.version import Version
6✔
17
from multiprocessing import cpu_count
6✔
18
import time
6✔
19
from urllib.parse import urlparse
6✔
20

21
import packaging.version
6✔
22

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

30

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

35

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

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

46

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

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

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

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

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

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

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

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

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

150
    stl_lib_name = 'c++_shared'
6✔
151
    '''
6✔
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
6✔
159
    '''
6✔
160
    Minimum ndk api recipe will support.
161
    '''
162

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

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

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

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

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

194
    @property
6✔
195
    def download_headers(self):
6✔
196
        key = "DOWNLOAD_HEADERS_" + self.name
6✔
197
        env_headers = environ.get(key)
6✔
198
        if env_headers:
6✔
199
            try:
6✔
200
                return [tuple(h) for h in json.loads(env_headers)]
6✔
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)
6✔
205

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

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

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

218
        parsed_url = urlparse(url)
6✔
219
        if parsed_url.scheme in ('http', 'https'):
6!
220
            def report_hook(index, blksize, size):
6✔
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):
6!
231
                unlink(target)
×
232

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

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

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

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

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

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

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

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

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

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

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

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

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

367
        return join(self.get_build_container_dir(arch), self.name)
6✔
368

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

380
    # Public Recipe API to be subclassed if needed
381

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

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

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

414
        ensure_dir(join(self.ctx.packages_path, self.name))
6✔
415

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

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

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

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

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

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

462
        build_dir = self.get_build_container_dir(arch)
×
463

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

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

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

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

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

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

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

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

542
        return env
6✔
543

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

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

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

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

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

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

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

582
            touch(join(build_dir, '.patched'))
×
583

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

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

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

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

625
        if self.need_stl_shared:
6!
626
            self.install_stl_lib(arch)
6✔
627

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

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

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

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

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

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

661
        for directory in dirs:
×
662
            rmdir(directory)
×
663

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

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

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

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

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

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

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

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

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

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

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

757

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

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

771

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

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

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

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

786
    def get_build_container_dir(self, arch):
6✔
787
        return self.get_jni_dir()
×
788

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

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

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

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

809

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

813
    generated_libraries = []
6✔
814

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

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

822
        return False
×
823

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

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

830
    def build_arch(self, arch, *extra_args):
6✔
831
        super().build_arch(arch)
×
832

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

846

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

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

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

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

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

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

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

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

888
    _host_recipe = None
6✔
889

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

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

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

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

927
    @property
6✔
928
    def hostpython_location(self):
6✔
929
        if not self.call_hostpython_via_targetpython:
×
930
            return self.real_hostpython_location
×
931
        return self.ctx.hostpython
×
932

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

941
    def patch_shebang(self, _file, original_bin):
6✔
942
        _file_des = open(_file, "r")
×
943

944
        try:
×
945
            data = _file_des.readlines()
×
946
        except UnicodeDecodeError:
×
947
            return
×
948

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

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

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

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

975
        env = super().get_recipe_env(arch, with_flags_in_cc)
6✔
976
        # Set the LANG, this isn't usually important but is a better default
977
        # as it occasionally matters how Python e.g. reads files
978
        env['LANG'] = "en_GB.UTF-8"
6✔
979
        env["PATH"] = self._host_recipe.local_bin + ":" + self._host_recipe.site_bin + ":" + env["PATH"]
6✔
980
        host_env = self.get_hostrecipe_env(arch)
6✔
981
        env['PYTHONPATH'] = host_env["PYTHONPATH"]
6✔
982

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

992
        return env
6✔
993

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

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

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

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

1020
        hpenv = env.copy()
×
1021
        with current_directory(self.get_build_dir(arch.arch)):
×
1022
            shprint(self._host_recipe.pip, 'install', '.',
×
1023
                    '--compile', '--target',
1024
                    self.ctx.get_python_install_dir(arch.arch),
1025
                    _env=hpenv, *self.setup_extra_args
1026
            )
1027

1028
    def get_hostrecipe_env(self, arch=None):
6✔
1029
        env = environ.copy()
6✔
1030
        env['PYTHONPATH'] = ''
6✔
1031
        env['HOME'] = '/tmp'
6✔
1032
        return env
6✔
1033

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

1038
    def install_hostpython_package(self, arch):
6✔
1039
        env = self.get_hostrecipe_env(arch)
×
1040
        shprint(self._host_recipe.pip, 'install', '.',
×
1041
                '--compile',
1042
                '--root={}'.format(self._host_recipe.site_root),
1043
                _env=env, *self.setup_extra_args)
1044

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

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

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

1057
        pip_options = [
×
1058
            "install",
1059
            *packages,
1060
            "-q",
1061
        ]
1062
        if force_upgrade:
×
1063
            pip_options.append("--upgrade")
×
1064
        pip_env = self.get_hostrecipe_env()
×
1065
        shprint(self._host_recipe.pip, *pip_options, _env=pip_env)
×
1066

1067
    def restore_hostpython_prerequisites(self, packages):
6✔
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):
6✔
1076
    pre_build_ext = False
6✔
1077

1078
    build_cmd = 'build_ext'
6✔
1079

1080
    def build_arch(self, arch):
6✔
1081
        '''Build any cython components, then install the Python module by
1082
        calling pip 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):
6✔
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):
6✔
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):
6✔
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):
6✔
1118
    """ Extensions that require the cxx-stl """
1119
    call_hostpython_via_targetpython = False
6✔
1120
    need_stl_shared = True
6✔
1121

1122

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

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

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

1141
        env = self.get_recipe_env(arch)
×
1142

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

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

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

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

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

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

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

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

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

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

1231
        return env
×
1232

1233

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

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

1241
    def get_pip_name(self):
6✔
1242
        name_str = self.name
×
1243
        if self.name not in self.ctx.use_prebuilt_version_for and self.version is not None:
×
1244
            # Like: v2.3.0 -> 2.3.0
1245
            cleaned_version = self.version.lstrip("v")
×
1246
            name_str += f"=={cleaned_version}"
×
1247
        return name_str
×
1248

1249
    def get_pip_install_args(self, arch):
6✔
1250
        python_recipe = Recipe.get_recipe("python3", self.ctx)
×
1251
        opts = [
×
1252
            "install",
1253
            self.get_pip_name(),
1254
            "--ignore-installed",
1255
            "--disable-pip-version-check",
1256
            "--python-version",
1257
            python_recipe.version,
1258
            "--only-binary=:all:",
1259
            "--no-deps",
1260
        ]
1261
        # add platform tags
1262
        tags = PyProjectRecipe.get_wheel_platform_tags(arch.arch, self.ctx)
×
1263
        for tag in tags:
×
1264
            opts.append(f"--platform={tag}")
×
1265

1266
        # add extra index urls
1267
        for index in self.ctx.extra_index_urls:
×
1268
            opts.extend(["--extra-index-url", index])
×
1269

1270
        return opts
×
1271

1272
    def lookup_prebuilt(self, arch):
6✔
1273
        pip_options = self.get_pip_install_args(arch)
×
1274
        # do not install
1275
        pip_options.extend(["--dry-run", "-q"])
×
1276
        pip_env = self.get_hostrecipe_env()
×
1277
        try:
×
1278
            shprint(self._host_recipe.pip, *pip_options, _env=pip_env, silent=True)
×
1279
        except Exception:
×
1280
            return False
×
1281
        return True
×
1282

1283
    def check_prebuilt(self, arch, msg=""):
6✔
1284
        if self.ctx.skip_prebuilt:
×
1285
            return False
×
1286

1287
        if self.lookup_prebuilt(arch):
×
1288
            if msg != "":
×
1289
                info(f"Prebuilt pip wheel found, {msg}")
×
1290
            return True
×
1291

1292
        return False
×
1293

1294
    def get_recipe_env(self, arch, **kwargs):
6✔
1295
        env = super().get_recipe_env(arch, **kwargs)
6✔
1296
        build_dir = self.get_build_dir(arch)
6✔
1297
        ensure_dir(build_dir)
6✔
1298
        build_opts = join(build_dir, "build-opts.cfg")
6✔
1299

1300
        with open(build_opts, "w") as file:
6✔
1301
            file.write("[bdist_wheel]\nplat_name={}".format(
6✔
1302
                self.get_wheel_platform_tag(arch.arch)
1303
            ))
1304
            file.close()
6✔
1305

1306
        env["DIST_EXTRA_CONFIG"] = build_opts
6✔
1307
        python_recipe = Recipe.get_recipe("python3", self.ctx)
6✔
1308
        env["INCLUDEPY"] = python_recipe.include_root(arch.arch)
6✔
1309
        return env
6✔
1310

1311
    @staticmethod
6✔
1312
    def get_wheel_platform_tags(arch, ctx):
6✔
1313
        # https://peps.python.org/pep-0738/#packaging
1314
        # official python only supports 64 bit:
1315
        # android_21_arm64_v8a
1316
        # android_21_x86_64
1317
        _suffix = {
6✔
1318
            "arm64-v8a": ["arm64_v8a", "aarch64"],
1319
            "x86_64": ["x86_64"],
1320
            "armeabi-v7a": ["arm"],
1321
            "x86": ["i686"],
1322
        }[arch]
1323
        return [f"android_{ctx.ndk_api}_" + _ for _ in _suffix]
6✔
1324

1325
    def get_wheel_platform_tag(self, arch):
6✔
1326
        return PyProjectRecipe.get_wheel_platform_tags(arch, self.ctx)[0]
6✔
1327

1328
    def install_prebuilt_wheel(self, arch):
6✔
1329
        info("Installing prebuilt wheel")
×
1330
        destination = self.ctx.get_python_install_dir(arch.arch)
×
1331
        pip_options = self.get_pip_install_args(arch)
×
1332
        pip_options.extend(["--target", destination])
×
1333
        pip_options.append("--upgrade")
×
1334
        pip_env = self.get_hostrecipe_env()
×
1335
        try:
×
1336
            shprint(self._host_recipe.pip, *pip_options, _env=pip_env)
×
1337
        except Exception:
×
1338
            return False
×
1339
        return True
×
1340

1341
    def install_wheel(self, arch, built_wheels):
6✔
1342
        with patch_wheel_setuptools_logging():
×
1343
            from wheel.cli.tags import tags as wheel_tags
×
1344
            from wheel.wheelfile import WheelFile
×
1345
        _wheel = built_wheels[0]
×
1346
        built_wheel_dir = dirname(_wheel)
×
1347
        # Fix wheel platform tag
1348
        wheel_tag = wheel_tags(
×
1349
            _wheel,
1350
            platform_tags=self.get_wheel_platform_tag(arch.arch),
1351
            remove=True,
1352
        )
1353
        selected_wheel = join(built_wheel_dir, wheel_tag)
×
1354
        _dev_wheel_dir = environ.get("P4A_WHEEL_DIR", False)
×
1355
        if _dev_wheel_dir:
×
1356
            ensure_dir(_dev_wheel_dir)
×
1357
            shprint(sh.cp, selected_wheel, _dev_wheel_dir)
×
1358

1359
        if exists(self.ctx.save_wheel_dir):
×
1360
            shprint(sh.cp, selected_wheel, self.ctx.save_wheel_dir)
×
1361

1362
        info(f"Installing built wheel: {wheel_tag}")
×
1363
        destination = self.ctx.get_python_install_dir(arch.arch)
×
1364
        with WheelFile(selected_wheel) as wf:
×
1365
            for zinfo in wf.filelist:
×
1366
                wf.extract(zinfo, destination)
×
1367
            wf.close()
×
1368

1369
    def build_arch(self, arch):
6✔
1370
        if self.check_prebuilt(arch, "skipping build_arch"):
×
1371
            result = self.install_prebuilt_wheel(arch)
×
1372
            if result:
×
1373
                return
×
1374
            warning("Failed to install prebuilt wheel, falling back to build_arch")
×
1375

1376
        build_dir = self.get_build_dir(arch.arch)
×
1377
        if not (isfile(join(build_dir, "pyproject.toml")) or isfile(join(build_dir, "setup.py"))):
×
1378
            warning("Skipping build because it does not appear to be a Python project.")
×
1379
            return
×
1380
        self.install_hostpython_prerequisites(
×
1381
            packages=["build[virtualenv]", "pip", "setuptools", "patchelf"] + self.hostpython_prerequisites
1382
        )
1383

1384
        env = self.get_recipe_env(arch, with_flags_in_cc=True)
×
1385
        # make build dir separately
1386
        sub_build_dir = join(build_dir, "p4a_android_build")
×
1387
        ensure_dir(sub_build_dir)
×
1388

1389
        build_args = [
×
1390
            "-m",
1391
            "build",
1392
            "--wheel",
1393
            "--config-setting",
1394
            "builddir={}".format(sub_build_dir),
1395
        ] + self.extra_build_args
1396

1397
        built_wheels = []
×
1398
        with current_directory(build_dir):
×
1399
            shprint(
×
1400
                sh.Command(self.real_hostpython_location), *build_args, _env=env
1401
            )
1402
            built_wheels = [realpath(whl) for whl in glob.glob("dist/*.whl")]
×
1403
        self.install_wheel(arch, built_wheels)
×
1404

1405

1406
class MesonRecipe(PyProjectRecipe):
6✔
1407
    '''Recipe for projects which uses meson as build system'''
1408

1409
    meson_version = "1.4.0"
6✔
1410
    pybind_version = "3.3.0"
6✔
1411

1412
    skip_python = False
6✔
1413
    '''If true, skips all Python build and installation steps.
6✔
1414
    Useful for Meson projects written purely in C/C++ without Python bindings.'''
1415

1416
    def sanitize_flags(self, *flag_strings):
6✔
1417
        return " ".join(flag_strings).strip().split(" ")
×
1418

1419
    def get_wrapper_dir(self, arch):
6✔
1420
        return join(self.get_build_dir(arch.arch), "p4a_wrappers")
×
1421

1422
    def write_wrapper(self, arch, name, content):
6✔
1423
        wrapper_dir = self.get_wrapper_dir(arch)
×
1424
        ensure_dir(wrapper_dir)
×
1425
        wrapper_path = join(wrapper_dir, name)
×
1426
        with open(wrapper_path, "w") as f:
×
1427
            f.write(content)
×
1428
        chmod(wrapper_path, 0o755)
×
1429
        return wrapper_path
×
1430

1431
    def get_python_wrapper(self, arch):
6✔
1432
        """
1433
        Meson Python introspection runs on the host interpreter, but the
1434
        target Python (Android) cannot be executed on the build machine.
1435

1436
        We therefore run host Python and override sysconfig data to emulate
1437
        the target Android Python environment during Meson introspection.
1438
        """
1439
        python_recipe = Recipe.get_recipe('python3', self.ctx)
×
1440
        target_prefix = python_recipe.get_python_root(arch)
×
1441
        python_file = join(self.ctx.root_dir, 'meson_python.py')
×
1442
        _arch = {
×
1443
            "arm64-v8a": ["aarch64"],
1444
            "x86_64": ["x86_64"],
1445
            "armeabi-v7a": ["arm"],
1446
            "x86": ["i686"],
1447
        }[arch.arch][0]
1448

1449
        # Real values pulled from android
1450
        # PYTHON_MAJOR_VERSION -> 3
1451
        # PYTHON_MINOR_VERSION -> 14
1452
        # PLATFORM_TAG eg -> 'android-24-arm64_v8a'
1453
        # PYTHON_SUFFIX eg -> '.cpython-314-aarch64-linux-android.so'
1454

1455
        _p_version = Version(python_recipe.version)
×
1456
        file_data = f"#!{self.real_hostpython_location}"
×
1457
        file_data += f"\nTARGET_PYTHON_PREFIX='{target_prefix}'"
×
1458
        file_data += f"\nPYTHON_MAJOR_VERSION='{_p_version.major}'"
×
1459
        file_data += f"\nPYTHON_MINOR_VERSION='{_p_version.minor}'"
×
1460
        file_data += f"\nPLATFORM_TAG='{self.get_wheel_platform_tags(arch.arch, self.ctx)[0]}'"
×
1461
        file_data += f"\nPYTHON_SUFFIX='.cpython-{_p_version.major}{_p_version.minor}-{_arch}-linux-android.so'"
×
1462

1463
        with open(python_file, "r") as f:
×
1464
            file_data += "\n" + f.read()
×
1465

1466
        return self.write_wrapper(arch, "python", file_data)
×
1467

1468
    def get_config_wrappers(self, arch, w_type: str):
6✔
1469
        wrapper_name = ""
×
1470
        version = ""
×
1471
        include_path = ""
×
1472

1473
        if w_type == "pybind11":
×
1474
            wrapper_name = "pybind11-config"
×
1475
            include_path = join(self._host_recipe.site_dir, "pybind11/include")
×
1476

1477
            version = None
×
1478
            try:
×
1479
                command = [self._host_recipe.real_hostpython_location, "-c", "import pybind11; print(pybind11.__version__)"]
×
1480
                version = subprocess.check_output(command).decode('utf-8').strip()
×
1481
            except Exception:
×
1482
                warning("Unable to get pybind11 version")
×
1483
            if version is None:
×
1484
                version = self.pybind_version
×
1485

1486
        elif w_type == "numpy":
×
1487
            wrapper_name = "numpy-config"
×
1488
            recipe = Recipe.get_recipe("numpy", self.ctx)
×
1489
            include_path = recipe.get_include(arch)
×
1490
            version = recipe.version
×
1491
        else:
1492
            raise ValueError(f"Unknown wrapper type: {w_type}")
×
1493

1494
        content = (
×
1495
            f"#!/bin/sh\n"
1496
            f"if [ \"$1\" = \"--version\" ]; then\n"
1497
            f"    echo '{version}'\n"
1498
            f"else\n"
1499
            f"    echo '-I{include_path}'\n"
1500
            f"fi\n"
1501
        )
1502
        return self.write_wrapper(arch, wrapper_name, content)
×
1503

1504
    def get_recipe_meson_options(self, arch):
6✔
1505
        env = self.get_recipe_env(arch, with_flags_in_cc=True)
×
1506
        return {
×
1507
            "binaries": {
1508
                "pybind11-config": self.get_config_wrappers(arch, "pybind11"),
1509
                "numpy-config": self.get_config_wrappers(arch, "numpy"),
1510
                "python": self.get_python_wrapper(arch),
1511
                "c": arch.get_clang_exe(with_target=True),
1512
                "cpp": arch.get_clang_exe(with_target=True, plus_plus=True),
1513
                "ar": self.ctx.ndk.llvm_ar,
1514
                "strip": self.ctx.ndk.llvm_strip,
1515
            },
1516
            "built-in options": {
1517
                "c_args": self.sanitize_flags(env["CFLAGS"], env["CPPFLAGS"]),
1518
                "cpp_args": self.sanitize_flags(env["CXXFLAGS"], env["CPPFLAGS"]),
1519
                "c_link_args": self.sanitize_flags(env["LDFLAGS"]),
1520
                "cpp_link_args": self.sanitize_flags(env["LDFLAGS"]),
1521
                "fortran_link_args": self.sanitize_flags(env["LDFLAGS"]),
1522
            },
1523
            "properties": {
1524
                "needs_exe_wrapper": True,
1525
                "sys_root": self.ctx.ndk.sysroot
1526
            },
1527
            "host_machine": {
1528
                "cpu_family": {
1529
                    "arm64-v8a": "aarch64",
1530
                    "armeabi-v7a": "arm",
1531
                    "x86_64": "x86_64",
1532
                    "x86": "x86"
1533
                }[arch.arch],
1534
                "cpu": {
1535
                    "arm64-v8a": "aarch64",
1536
                    "armeabi-v7a": "armv7",
1537
                    "x86_64": "x86_64",
1538
                    "x86": "i686"
1539
                }[arch.arch],
1540
                "endian": "little",
1541
                "system": "android",
1542
            }
1543
        }
1544

1545
    def write_build_options(self, arch):
6✔
1546
        """Writes python dict to meson config file"""
1547
        option_data = ""
×
1548
        build_options = self.get_recipe_meson_options(arch)
×
1549
        for key in build_options.keys():
×
1550
            data_chunk = "[{}]".format(key)
×
1551
            for subkey in build_options[key].keys():
×
1552
                value = build_options[key][subkey]
×
1553
                if isinstance(value, int):
×
1554
                    value = str(value)
×
1555
                elif isinstance(value, str):
×
1556
                    value = "'{}'".format(value)
×
1557
                elif isinstance(value, bool):
×
1558
                    value = "true" if value else "false"
×
1559
                elif isinstance(value, list):
×
1560
                    value = "['" + "', '".join(value) + "']"
×
1561
                data_chunk += "\n" + subkey + " = " + value
×
1562
            option_data += data_chunk + "\n\n"
×
1563
        return option_data
×
1564

1565
    def ensure_args(self, *args):
6✔
1566
        for arg in args:
×
1567
            if arg not in self.extra_build_args:
×
1568
                self.extra_build_args.append(arg)
×
1569

1570
    def get_recipe_env_command(self, command, env):
6✔
1571
        command_path = shutil.which(command, path=env["PATH"])
6✔
1572
        if command_path is None:
6!
1573
            raise sh.CommandNotFound(command)
×
1574
        return sh.Command(command_path)
6✔
1575

1576
    def get_meson_command(self, env):
6✔
1577
        return self.get_recipe_env_command("meson", env)
6✔
1578

1579
    def get_ninja_command(self, env):
6✔
1580
        return self.get_recipe_env_command("ninja", env)
×
1581

1582
    def build_arch(self, arch):
6✔
1583
        cross_file = join("/tmp", "android.meson.cross")
×
1584
        info("Writing cross file at: {}".format(cross_file))
×
1585
        # write cross config file
1586
        with open(cross_file, "w") as file:
×
1587
            file.write(self.write_build_options(arch))
×
1588
            file.close()
×
1589
        # set cross file
1590
        self.ensure_args('-Csetup-args=--cross-file', '-Csetup-args={}'.format(cross_file))
×
1591
        # ensure ninja and meson
1592
        for dep in [
×
1593
            "ninja",
1594
            "meson=={}".format(self.meson_version),
1595
        ]:
1596
            if dep not in self.hostpython_prerequisites:
×
1597
                self.hostpython_prerequisites.append(dep)
×
1598

1599
        if not self.skip_python:
×
1600
            super().build_arch(arch)
×
1601
        else:
1602
            self.install_hostpython_prerequisites(
×
1603
                packages=["build[virtualenv]", "pip", "setuptools", "patchelf"] + self.hostpython_prerequisites
1604
            )
1605

1606

1607
class RustCompiledComponentsRecipe(PyProjectRecipe):
6✔
1608
    # Rust toolchain codes
1609
    # https://doc.rust-lang.org/nightly/rustc/platform-support.html
1610
    RUST_ARCH_CODES = {
6✔
1611
        "arm64-v8a": "aarch64-linux-android",
1612
        "armeabi-v7a": "armv7-linux-androideabi",
1613
        "x86_64": "x86_64-linux-android",
1614
        "x86": "i686-linux-android",
1615
    }
1616

1617
    def get_recipe_env(self, arch, **kwargs):
6✔
1618
        env = super().get_recipe_env(arch, **kwargs)
×
1619

1620
        # Set rust build target
1621
        build_target = self.RUST_ARCH_CODES[arch.arch]
×
1622
        cargo_linker_name = "CARGO_TARGET_{}_LINKER".format(
×
1623
            build_target.upper().replace("-", "_")
1624
        )
1625
        env["CARGO_BUILD_TARGET"] = build_target
×
1626
        env[cargo_linker_name] = join(
×
1627
            self.ctx.ndk.llvm_prebuilt_dir,
1628
            "bin",
1629
            "{}{}-clang".format(
1630
                # NDK's Clang format
1631
                build_target.replace("7", "7a")
1632
                if build_target.startswith("armv7")
1633
                else build_target,
1634
                self.ctx.ndk_api,
1635
            ),
1636
        )
1637
        realpython_dir = self.ctx.python_recipe.get_build_dir(arch.arch)
×
1638

1639
        env["RUSTFLAGS"] = "-Clink-args=-L{} -L{}".format(
×
1640
            self.ctx.get_libs_dir(arch.arch), join(realpython_dir, "android-build")
1641
        )
1642
        env["RUSTFLAGS"] += f" -Clink-arg=-lpython{self.ctx.python_recipe.link_version}"
×
1643

1644
        env["PYO3_CROSS_LIB_DIR"] = realpath(glob.glob(join(
×
1645
            realpython_dir, "android-build", "build",
1646
            "lib.*{}/".format(self.python_major_minor_version),
1647
        ))[0])
1648

1649
        info_main("Ensuring rust build toolchain")
×
1650
        shprint(sh.rustup, "target", "add", build_target)
×
1651

1652
        # Add host python to PATH
1653
        env["PATH"] = ("{hostpython_dir}:{old_path}").format(
×
1654
            hostpython_dir=Recipe.get_recipe(
1655
                "hostpython3", self.ctx
1656
            ).local_bin,
1657
            old_path=env["PATH"],
1658
        )
1659
        return env
×
1660

1661
    def check_host_deps(self):
6✔
1662
        if not hasattr(sh, "rustup"):
×
1663
            error(
×
1664
                "`rustup` was not found on host system."
1665
                "Please install it using :"
1666
                "\n`curl https://sh.rustup.rs -sSf | sh`\n"
1667
            )
1668
            exit(1)
×
1669

1670
    def build_arch(self, arch):
6✔
1671
        self.check_host_deps()
×
1672
        super().build_arch(arch)
×
1673

1674

1675
class TargetPythonRecipe(Recipe):
6✔
1676
    '''Class for target python recipes. Sets ctx.python_recipe to point to
1677
    itself, so as to know later what kind of Python was built or used.'''
1678

1679
    def __init__(self, *args, **kwargs):
6✔
1680
        self._ctx = None
6✔
1681
        super().__init__(*args, **kwargs)
6✔
1682

1683
    def prebuild_arch(self, arch):
6✔
1684
        super().prebuild_arch(arch)
×
1685
        self.ctx.python_recipe = self
×
1686

1687
    def include_root(self, arch):
6✔
1688
        '''The root directory from which to include headers.'''
1689
        raise NotImplementedError('Not implemented in TargetPythonRecipe')
1690

1691
    def link_root(self):
6✔
1692
        raise NotImplementedError('Not implemented in TargetPythonRecipe')
1693

1694
    @property
6✔
1695
    def major_minor_version_string(self):
6✔
1696
        parsed_version = packaging.version.parse(self.version)
6✔
1697
        return f"{parsed_version.major}.{parsed_version.minor}"
6✔
1698

1699
    def create_python_bundle(self, dirn, arch):
6✔
1700
        """
1701
        Create a packaged python bundle in the target directory, by
1702
        copying all the modules and standard library to the right
1703
        place.
1704
        """
1705
        raise NotImplementedError('{} does not implement create_python_bundle'.format(self))
1706

1707
    def reduce_object_file_names(self, dirn):
6✔
1708
        """Recursively renames all files named XXX.cpython-...-linux-gnu.so"
1709
        to "XXX.so", i.e. removing the erroneous architecture name
1710
        coming from the local system.
1711
        """
1712
        py_so_files = shprint(sh.find, dirn, '-iname', '*.so')
×
1713
        filens = py_so_files.stdout.decode('utf-8').split('\n')[:-1]
×
1714
        for filen in filens:
×
1715
            file_dirname, file_basename = split(filen)
×
1716
            parts = file_basename.split('.')
×
1717
            if len(parts) <= 2:
×
1718
                continue
×
1719
            # PySide6 libraries end with .abi3.so
1720
            if parts[1] == "abi3":
×
1721
                continue
×
1722
            move(filen, join(file_dirname, parts[0] + '.so'))
×
1723

1724

1725
def algsum(alg, filen):
6✔
1726
    '''Calculate the digest of a file.
1727
    '''
1728
    with open(filen, 'rb') as fileh:
×
1729
        digest = getattr(hashlib, alg)(fileh.read())
×
1730

1731
    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