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

kivy / python-for-android / 24930372022

25 Apr 2026 11:58AM UTC coverage: 62.932% (-0.5%) from 63.382%
24930372022

push

github

web-flow
fix PYTHONPATH hacks (#3301)

* fix PYTHONPATH hacks

* fix android recipe

* fix numpy and matplotlib build

* no shebang patching required now

* fix kivy master build

* fix numpy build

* more wrappers for meson recipe

* flake8 fix

* add back pandas host preq

* fix bug in logger

* add back cython for pandas

1832 of 3175 branches covered (57.7%)

Branch coverage included in aggregate %.

61 of 150 new or added lines in 8 files covered. (40.67%)

9 existing lines in 2 files now uncovered.

5329 of 8204 relevant lines covered (64.96%)

5.18 hits per line

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

44.0
/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 subprocess
8✔
9
import shutil
8✔
10
import fnmatch
8✔
11
import zipfile
8✔
12
import urllib.request
8✔
13
from urllib.request import urlretrieve
8✔
14
from os import listdir, unlink, environ, curdir, walk, chmod
8✔
15
from sys import stdout
8✔
16
from packaging.version import Version
8✔
17
from multiprocessing import cpu_count
8✔
18
import time
8✔
19
try:
8✔
20
    from urlparse import urlparse
8✔
21
except ImportError:
8✔
22
    from urllib.parse import urlparse
8✔
23

24
import packaging.version
8✔
25

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

33

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

38

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

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

49

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

383
    # Public Recipe API to be subclassed if needed
384

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

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

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

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

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

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

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

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

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

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

465
        build_dir = self.get_build_container_dir(arch)
×
466

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

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

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

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

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

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

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

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

545
        return env
8✔
546

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

760

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

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

774

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

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

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

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

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

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

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

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

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

812

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

816
    generated_libraries = []
8✔
817

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

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

825
        return False
×
826

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

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

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

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

849

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

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

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

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

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

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

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

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

891
    _host_recipe = None
8✔
892

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

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

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

922
    @property
8✔
923
    def real_hostpython_location(self):
8✔
924
        host_name = 'host{}'.format(self.ctx.python_recipe.name)
×
925
        if host_name == 'hostpython3':
×
926
            return self._host_recipe.python_exe
×
927
        else:
928
            return 'python{}'.format(self.ctx.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✔
UNCOV
965
        if not isdir(path):
×
UNCOV
966
            warning(f"Shebang patch skipped: '{path}' does not exist.")
×
UNCOV
967
            return
×
968
        # set correct shebang
969
        for file in listdir(path):
×
970
            _file = join(path, file)
×
NEW
971
            if isfile(_file):
×
NEW
972
                self.patch_shebang(_file, original_bin)
×
973

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

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

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

995
        return env
8✔
996

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

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

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

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

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

1031
    def get_hostrecipe_env(self, arch=None):
8✔
1032
        env = environ.copy()
8✔
1033
        env['PYTHONPATH'] = ''
8✔
1034
        env['HOME'] = '/tmp'
8✔
1035
        return env
8✔
1036

1037
    @property
8✔
1038
    def hostpython_site_dir(self):
8✔
1039
        return join(dirname(self.real_hostpython_location), 'Lib', 'site-packages')
×
1040

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

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

1053
    def install_hostpython_prerequisites(self, packages=None, force_upgrade=True):
8✔
1054
        if not packages:
×
1055
            packages = self.hostpython_prerequisites
×
1056

1057
        if len(packages) == 0:
×
1058
            return
×
1059

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

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

1077

1078
class CompiledComponentsPythonRecipe(PythonRecipe):
8✔
1079
    pre_build_ext = False
8✔
1080

1081
    build_cmd = 'build_ext'
8✔
1082

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

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

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

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

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

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

1119

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

1125

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

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

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

1143
        env = self.get_recipe_env(arch)
×
1144

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

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

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

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

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

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

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

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

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

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

1233
        return env
×
1234

1235

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

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

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

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

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

1272
        return opts
×
1273

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

1285
    def check_prebuilt(self, arch, msg=""):
8✔
1286
        if self.ctx.skip_prebuilt:
×
1287
            return False
×
1288

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

1294
        return False
×
1295

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

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

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

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

1327
    def get_wheel_platform_tag(self, arch):
8✔
1328
        return PyProjectRecipe.get_wheel_platform_tags(arch, self.ctx)[0]
8✔
1329

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

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

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

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

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

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

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

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

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

1407

1408
class MesonRecipe(PyProjectRecipe):
8✔
1409
    '''Recipe for projects which uses meson as build system'''
1410

1411
    meson_version = "1.4.0"
8✔
1412
    pybind_version = "3.3.0"
8✔
1413

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

1418
    def sanitize_flags(self, *flag_strings):
8✔
1419
        return " ".join(flag_strings).strip().split(" ")
×
1420

1421
    def get_wrapper_dir(self, arch):
8✔
NEW
1422
        return join(self.get_build_dir(arch.arch), "p4a_wrappers")
×
1423

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

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

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

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

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

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

NEW
1468
        return self.write_wrapper(arch, "python", file_data)
×
1469

1470
    def get_config_wrappers(self, arch, w_type: str):
8✔
NEW
1471
        wrapper_name = ""
×
NEW
1472
        version = ""
×
NEW
1473
        include_path = ""
×
1474

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

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

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

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

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

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

1567
    def ensure_args(self, *args):
8✔
1568
        for arg in args:
×
1569
            if arg not in self.extra_build_args:
×
1570
                self.extra_build_args.append(arg)
×
1571

1572
    def build_arch(self, arch):
8✔
1573
        cross_file = join("/tmp", "android.meson.cross")
×
1574
        info("Writing cross file at: {}".format(cross_file))
×
1575
        # write cross config file
1576
        with open(cross_file, "w") as file:
×
1577
            file.write(self.write_build_options(arch))
×
1578
            file.close()
×
1579
        # set cross file
1580
        self.ensure_args('-Csetup-args=--cross-file', '-Csetup-args={}'.format(cross_file))
×
1581
        # ensure ninja and meson
1582
        for dep in [
×
1583
            "ninja",
1584
            "meson=={}".format(self.meson_version),
1585
        ]:
1586
            if dep not in self.hostpython_prerequisites:
×
1587
                self.hostpython_prerequisites.append(dep)
×
1588

1589
        if not self.skip_python:
×
1590
            super().build_arch(arch)
×
1591
        else:
NEW
1592
            self.install_hostpython_prerequisites(
×
1593
                packages=["build[virtualenv]", "pip", "setuptools", "patchelf"] + self.hostpython_prerequisites
1594
            )
1595

1596

1597
class RustCompiledComponentsRecipe(PyProjectRecipe):
8✔
1598
    # Rust toolchain codes
1599
    # https://doc.rust-lang.org/nightly/rustc/platform-support.html
1600
    RUST_ARCH_CODES = {
8✔
1601
        "arm64-v8a": "aarch64-linux-android",
1602
        "armeabi-v7a": "armv7-linux-androideabi",
1603
        "x86_64": "x86_64-linux-android",
1604
        "x86": "i686-linux-android",
1605
    }
1606

1607
    def get_recipe_env(self, arch, **kwargs):
8✔
UNCOV
1608
        env = super().get_recipe_env(arch, **kwargs)
×
1609

1610
        # Set rust build target
1611
        build_target = self.RUST_ARCH_CODES[arch.arch]
×
1612
        cargo_linker_name = "CARGO_TARGET_{}_LINKER".format(
×
1613
            build_target.upper().replace("-", "_")
1614
        )
1615
        env["CARGO_BUILD_TARGET"] = build_target
×
1616
        env[cargo_linker_name] = join(
×
1617
            self.ctx.ndk.llvm_prebuilt_dir,
1618
            "bin",
1619
            "{}{}-clang".format(
1620
                # NDK's Clang format
1621
                build_target.replace("7", "7a")
1622
                if build_target.startswith("armv7")
1623
                else build_target,
1624
                self.ctx.ndk_api,
1625
            ),
1626
        )
1627
        realpython_dir = self.ctx.python_recipe.get_build_dir(arch.arch)
×
1628

1629
        env["RUSTFLAGS"] = "-Clink-args=-L{} -L{}".format(
×
1630
            self.ctx.get_libs_dir(arch.arch), join(realpython_dir, "android-build")
1631
        )
1632

1633
        env["PYO3_CROSS_LIB_DIR"] = realpath(glob.glob(join(
×
1634
            realpython_dir, "android-build", "build",
1635
            "lib.*{}/".format(self.python_major_minor_version),
1636
        ))[0])
1637

1638
        info_main("Ensuring rust build toolchain")
×
1639
        shprint(sh.rustup, "target", "add", build_target)
×
1640

1641
        # Add host python to PATH
1642
        env["PATH"] = ("{hostpython_dir}:{old_path}").format(
×
1643
            hostpython_dir=Recipe.get_recipe(
1644
                "hostpython3", self.ctx
1645
            ).local_bin,
1646
            old_path=env["PATH"],
1647
        )
1648
        return env
×
1649

1650
    def check_host_deps(self):
8✔
1651
        if not hasattr(sh, "rustup"):
×
1652
            error(
×
1653
                "`rustup` was not found on host system."
1654
                "Please install it using :"
1655
                "\n`curl https://sh.rustup.rs -sSf | sh`\n"
1656
            )
1657
            exit(1)
×
1658

1659
    def build_arch(self, arch):
8✔
1660
        self.check_host_deps()
×
1661
        super().build_arch(arch)
×
1662

1663

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

1668
    def __init__(self, *args, **kwargs):
8✔
1669
        self._ctx = None
8✔
1670
        super().__init__(*args, **kwargs)
8✔
1671

1672
    def prebuild_arch(self, arch):
8✔
1673
        super().prebuild_arch(arch)
×
1674
        self.ctx.python_recipe = self
×
1675

1676
    def include_root(self, arch):
8✔
1677
        '''The root directory from which to include headers.'''
1678
        raise NotImplementedError('Not implemented in TargetPythonRecipe')
1679

1680
    def link_root(self):
8✔
1681
        raise NotImplementedError('Not implemented in TargetPythonRecipe')
1682

1683
    @property
8✔
1684
    def major_minor_version_string(self):
8✔
1685
        parsed_version = packaging.version.parse(self.version)
8✔
1686
        return f"{parsed_version.major}.{parsed_version.minor}"
8✔
1687

1688
    def create_python_bundle(self, dirn, arch):
8✔
1689
        """
1690
        Create a packaged python bundle in the target directory, by
1691
        copying all the modules and standard library to the right
1692
        place.
1693
        """
1694
        raise NotImplementedError('{} does not implement create_python_bundle'.format(self))
1695

1696
    def reduce_object_file_names(self, dirn):
8✔
1697
        """Recursively renames all files named XXX.cpython-...-linux-gnu.so"
1698
        to "XXX.so", i.e. removing the erroneous architecture name
1699
        coming from the local system.
1700
        """
1701
        py_so_files = shprint(sh.find, dirn, '-iname', '*.so')
×
1702
        filens = py_so_files.stdout.decode('utf-8').split('\n')[:-1]
×
1703
        for filen in filens:
×
1704
            file_dirname, file_basename = split(filen)
×
1705
            parts = file_basename.split('.')
×
1706
            if len(parts) <= 2:
×
1707
                continue
×
1708
            # PySide6 libraries end with .abi3.so
1709
            if parts[1] == "abi3":
×
1710
                continue
×
1711
            move(filen, join(file_dirname, parts[0] + '.so'))
×
1712

1713

1714
def algsum(alg, filen):
8✔
1715
    '''Calculate the digest of a file.
1716
    '''
1717
    with open(filen, 'rb') as fileh:
×
1718
        digest = getattr(hashlib, alg)(fileh.read())
×
1719

1720
    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