1239 lines
43 KiB
Python
1239 lines
43 KiB
Python
'''
|
|
Buildozer
|
|
=========
|
|
|
|
Generic Python packager for Android / iOS. Desktop later.
|
|
|
|
'''
|
|
|
|
__version__ = '1.4.0'
|
|
|
|
import os
|
|
import re
|
|
import sys
|
|
import select
|
|
import codecs
|
|
import textwrap
|
|
import warnings
|
|
from buildozer.jsonstore import JsonStore
|
|
from sys import stdout, stderr, exit
|
|
from re import search
|
|
from os.path import join, exists, dirname, realpath, splitext, expanduser
|
|
from subprocess import Popen, PIPE, TimeoutExpired
|
|
from os import environ, unlink, walk, sep, listdir, makedirs
|
|
from copy import copy
|
|
from shutil import copyfile, rmtree, copytree, move
|
|
from fnmatch import fnmatch
|
|
|
|
from pprint import pformat
|
|
|
|
from urllib.request import FancyURLopener
|
|
from configparser import ConfigParser
|
|
try:
|
|
import fcntl
|
|
except ImportError:
|
|
# on windows, no fcntl
|
|
fcntl = None
|
|
try:
|
|
# if installed, it can give color to windows as well
|
|
import colorama
|
|
colorama.init()
|
|
|
|
RESET_SEQ = colorama.Fore.RESET + colorama.Style.RESET_ALL
|
|
COLOR_SEQ = lambda x: x # noqa: E731
|
|
BOLD_SEQ = ''
|
|
if sys.platform == 'win32':
|
|
BLACK = colorama.Fore.BLACK + colorama.Style.DIM
|
|
else:
|
|
BLACK = colorama.Fore.BLACK + colorama.Style.BRIGHT
|
|
RED = colorama.Fore.RED
|
|
BLUE = colorama.Fore.CYAN
|
|
USE_COLOR = 'NO_COLOR' not in environ
|
|
|
|
except ImportError:
|
|
if sys.platform != 'win32':
|
|
RESET_SEQ = "\033[0m"
|
|
COLOR_SEQ = lambda x: "\033[1;{}m".format(30 + x) # noqa: E731
|
|
BOLD_SEQ = "\033[1m"
|
|
BLACK = 0
|
|
RED = 1
|
|
BLUE = 4
|
|
USE_COLOR = 'NO_COLOR' not in environ
|
|
else:
|
|
RESET_SEQ = ''
|
|
COLOR_SEQ = ''
|
|
BOLD_SEQ = ''
|
|
RED = BLUE = BLACK = 0
|
|
USE_COLOR = False
|
|
|
|
# error, info, debug
|
|
LOG_LEVELS_C = (RED, BLUE, BLACK)
|
|
LOG_LEVELS_T = 'EID'
|
|
SIMPLE_HTTP_SERVER_PORT = 8000
|
|
|
|
|
|
class ChromeDownloader(FancyURLopener):
|
|
version = (
|
|
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 '
|
|
'(KHTML, like Gecko) Chrome/28.0.1500.71 Safari/537.36')
|
|
|
|
|
|
urlretrieve = ChromeDownloader().retrieve
|
|
|
|
|
|
class BuildozerException(Exception):
|
|
'''
|
|
Exception raised for general situations buildozer cannot process.
|
|
'''
|
|
pass
|
|
|
|
|
|
class BuildozerCommandException(BuildozerException):
|
|
'''
|
|
Exception raised when an external command failed.
|
|
|
|
See: `Buildozer.cmd()`.
|
|
'''
|
|
pass
|
|
|
|
|
|
class Buildozer:
|
|
|
|
ERROR = 0
|
|
INFO = 1
|
|
DEBUG = 2
|
|
|
|
standard_cmds = ('distclean', 'update', 'debug', 'release',
|
|
'deploy', 'run', 'serve')
|
|
|
|
def __init__(self, filename='buildozer.spec', target=None):
|
|
self.log_level = 2
|
|
self.environ = {}
|
|
self.specfilename = filename
|
|
self.state = None
|
|
self.build_id = None
|
|
self.config_profile = ''
|
|
self.config = ConfigParser(allow_no_value=True)
|
|
self.config.optionxform = lambda value: value
|
|
self.config.getlist = self._get_config_list
|
|
self.config.getlistvalues = self._get_config_list_values
|
|
self.config.getdefault = self._get_config_default
|
|
self.config.getbooldefault = self._get_config_bool
|
|
self.config.getrawdefault = self._get_config_raw_default
|
|
|
|
if exists(filename):
|
|
self.config.read(filename, "utf-8")
|
|
self.check_configuration_tokens()
|
|
|
|
# Check all section/tokens for env vars, and replace the
|
|
# config value if a suitable env var exists.
|
|
set_config_from_envs(self.config)
|
|
|
|
try:
|
|
self.log_level = int(self.config.getdefault(
|
|
'buildozer', 'log_level', '2'))
|
|
except Exception:
|
|
pass
|
|
|
|
self.user_bin_dir = self.config.getdefault('buildozer', 'bin_dir', None)
|
|
if self.user_bin_dir:
|
|
self.user_bin_dir = realpath(join(self.root_dir, self.user_bin_dir))
|
|
|
|
self.targetname = None
|
|
self.target = None
|
|
if target:
|
|
self.set_target(target)
|
|
|
|
def set_target(self, target):
|
|
'''Set the target to use (one of buildozer.targets, such as "android")
|
|
'''
|
|
self.targetname = target
|
|
m = __import__('buildozer.targets.{0}'.format(target),
|
|
fromlist=['buildozer'])
|
|
self.target = m.get_target(self)
|
|
self.check_build_layout()
|
|
self.check_configuration_tokens()
|
|
|
|
def prepare_for_build(self):
|
|
'''Prepare the build.
|
|
'''
|
|
assert(self.target is not None)
|
|
if hasattr(self.target, '_build_prepared'):
|
|
return
|
|
|
|
self.info('Preparing build')
|
|
|
|
self.info('Check requirements for {0}'.format(self.targetname))
|
|
self.target.check_requirements()
|
|
|
|
self.info('Install platform')
|
|
self.target.install_platform()
|
|
|
|
self.info('Check application requirements')
|
|
self.check_application_requirements()
|
|
|
|
self.check_garden_requirements()
|
|
|
|
self.info('Compile platform')
|
|
self.target.compile_platform()
|
|
|
|
# flag to prevent multiple build
|
|
self.target._build_prepared = True
|
|
|
|
def build(self):
|
|
'''Do the build.
|
|
|
|
The target can set build_mode to 'release' or 'debug' before calling
|
|
this method.
|
|
|
|
(:meth:`prepare_for_build` must have been call before.)
|
|
'''
|
|
assert(self.target is not None)
|
|
assert(hasattr(self.target, '_build_prepared'))
|
|
|
|
if hasattr(self.target, '_build_done'):
|
|
return
|
|
|
|
# increment the build number
|
|
self.build_id = int(self.state.get('cache.build_id', '0')) + 1
|
|
self.state['cache.build_id'] = str(self.build_id)
|
|
|
|
self.info('Build the application #{}'.format(self.build_id))
|
|
self.build_application()
|
|
|
|
self.info('Package the application')
|
|
self.target.build_package()
|
|
|
|
# flag to prevent multiple build
|
|
self.target._build_done = True
|
|
|
|
#
|
|
# Log functions
|
|
#
|
|
|
|
def log(self, level, msg):
|
|
if level > self.log_level:
|
|
return
|
|
if USE_COLOR:
|
|
color = COLOR_SEQ(LOG_LEVELS_C[level])
|
|
print(''.join((RESET_SEQ, color, '# ', msg, RESET_SEQ)))
|
|
else:
|
|
print('{} {}'.format(LOG_LEVELS_T[level], msg))
|
|
|
|
def debug(self, msg):
|
|
self.log(self.DEBUG, msg)
|
|
|
|
def log_env(self, level, env):
|
|
"""dump env into debug logger in readable format"""
|
|
self.log(level, "ENVIRONMENT:")
|
|
for k, v in env.items():
|
|
self.log(level, " {} = {}".format(k, pformat(v)))
|
|
|
|
def info(self, msg):
|
|
self.log(self.INFO, msg)
|
|
|
|
def error(self, msg):
|
|
self.log(self.ERROR, msg)
|
|
|
|
#
|
|
# Internal check methods
|
|
#
|
|
|
|
def checkbin(self, msg, fn):
|
|
self.debug('Search for {0}'.format(msg))
|
|
if exists(fn):
|
|
return realpath(fn)
|
|
for dn in environ['PATH'].split(':'):
|
|
rfn = realpath(join(dn, fn))
|
|
if exists(rfn):
|
|
self.debug(' -> found at {0}'.format(rfn))
|
|
return rfn
|
|
self.error('{} not found, please install it.'.format(msg))
|
|
exit(1)
|
|
|
|
def cmd(self, command, **kwargs):
|
|
# prepare the environ, based on the system + our own env
|
|
env = environ.copy()
|
|
env.update(self.environ)
|
|
|
|
# prepare the process
|
|
kwargs.setdefault('env', env)
|
|
kwargs.setdefault('stdout', PIPE)
|
|
kwargs.setdefault('stderr', PIPE)
|
|
kwargs.setdefault('close_fds', True)
|
|
kwargs.setdefault('shell', True)
|
|
kwargs.setdefault('show_output', self.log_level > 1)
|
|
|
|
show_output = kwargs.pop('show_output')
|
|
get_stdout = kwargs.pop('get_stdout', False)
|
|
get_stderr = kwargs.pop('get_stderr', False)
|
|
break_on_error = kwargs.pop('break_on_error', True)
|
|
sensible = kwargs.pop('sensible', False)
|
|
run_condition = kwargs.pop('run_condition', None)
|
|
quiet = kwargs.pop('quiet', False)
|
|
|
|
if not quiet:
|
|
if not sensible:
|
|
self.debug('Run {0!r}'.format(command))
|
|
else:
|
|
if isinstance(command, (list, tuple)):
|
|
self.debug('Run {0!r} ...'.format(command[0]))
|
|
else:
|
|
self.debug('Run {0!r} ...'.format(command.split()[0]))
|
|
self.debug('Cwd {}'.format(kwargs.get('cwd')))
|
|
|
|
# open the process
|
|
if sys.platform == 'win32':
|
|
kwargs.pop('close_fds', None)
|
|
process = Popen(command, **kwargs)
|
|
|
|
# prepare fds
|
|
fd_stdout = process.stdout.fileno()
|
|
fd_stderr = process.stderr.fileno()
|
|
if fcntl:
|
|
fcntl.fcntl(
|
|
fd_stdout, fcntl.F_SETFL,
|
|
fcntl.fcntl(fd_stdout, fcntl.F_GETFL) | os.O_NONBLOCK)
|
|
fcntl.fcntl(
|
|
fd_stderr, fcntl.F_SETFL,
|
|
fcntl.fcntl(fd_stderr, fcntl.F_GETFL) | os.O_NONBLOCK)
|
|
|
|
ret_stdout = [] if get_stdout else None
|
|
ret_stderr = [] if get_stderr else None
|
|
while not run_condition or run_condition():
|
|
try:
|
|
readx = select.select([fd_stdout, fd_stderr], [], [], 1)[0]
|
|
except select.error:
|
|
break
|
|
if fd_stdout in readx:
|
|
chunk = process.stdout.read()
|
|
if not chunk:
|
|
break
|
|
if get_stdout:
|
|
ret_stdout.append(chunk)
|
|
if show_output:
|
|
stdout.write(chunk.decode('utf-8', 'replace'))
|
|
if fd_stderr in readx:
|
|
chunk = process.stderr.read()
|
|
if not chunk:
|
|
break
|
|
if get_stderr:
|
|
ret_stderr.append(chunk)
|
|
if show_output:
|
|
stderr.write(chunk.decode('utf-8', 'replace'))
|
|
|
|
stdout.flush()
|
|
stderr.flush()
|
|
|
|
try:
|
|
process.communicate(
|
|
timeout=(1 if run_condition and not run_condition() else None)
|
|
)
|
|
except TimeoutExpired:
|
|
pass
|
|
|
|
if process.returncode != 0 and break_on_error:
|
|
self.error('Command failed: {0}'.format(command))
|
|
self.log_env(self.ERROR, kwargs['env'])
|
|
self.error('')
|
|
self.error('Buildozer failed to execute the last command')
|
|
if self.log_level <= self.INFO:
|
|
self.error('If the error is not obvious, please raise the log_level to 2')
|
|
self.error('and retry the latest command.')
|
|
else:
|
|
self.error('The error might be hidden in the log above this error')
|
|
self.error('Please read the full log, and search for it before')
|
|
self.error('raising an issue with buildozer itself.')
|
|
self.error('In case of a bug report, please add a full log with log_level = 2')
|
|
raise BuildozerCommandException()
|
|
|
|
if ret_stdout:
|
|
ret_stdout = b''.join(ret_stdout)
|
|
if ret_stderr:
|
|
ret_stderr = b''.join(ret_stderr)
|
|
|
|
return (ret_stdout.decode('utf-8', 'ignore') if ret_stdout else None,
|
|
ret_stderr.decode('utf-8') if ret_stderr else None,
|
|
process.returncode)
|
|
|
|
def cmd_expect(self, command, **kwargs):
|
|
from pexpect import spawnu
|
|
|
|
# prepare the environ, based on the system + our own env
|
|
env = environ.copy()
|
|
env.update(self.environ)
|
|
|
|
# prepare the process
|
|
kwargs.setdefault('env', env)
|
|
kwargs.setdefault('show_output', self.log_level > 1)
|
|
sensible = kwargs.pop('sensible', False)
|
|
show_output = kwargs.pop('show_output')
|
|
|
|
if show_output:
|
|
kwargs['logfile'] = codecs.getwriter('utf8')(stdout.buffer)
|
|
|
|
if not sensible:
|
|
self.debug('Run (expect) {0!r}'.format(command))
|
|
else:
|
|
self.debug('Run (expect) {0!r} ...'.format(command.split()[0]))
|
|
|
|
self.debug('Cwd {}'.format(kwargs.get('cwd')))
|
|
return spawnu(command, **kwargs)
|
|
|
|
def check_configuration_tokens(self):
|
|
'''Ensure the spec file is 'correct'.
|
|
'''
|
|
self.info('Check configuration tokens')
|
|
self.migrate_configuration_tokens()
|
|
get = self.config.getdefault
|
|
errors = []
|
|
adderror = errors.append
|
|
if not get('app', 'title', ''):
|
|
adderror('[app] "title" is missing')
|
|
if not get('app', 'source.dir', ''):
|
|
adderror('[app] "source.dir" is missing')
|
|
|
|
package_name = get('app', 'package.name', '')
|
|
if not package_name:
|
|
adderror('[app] "package.name" is missing')
|
|
elif package_name[0] in map(str, range(10)):
|
|
adderror('[app] "package.name" may not start with a number.')
|
|
|
|
version = get('app', 'version', '')
|
|
version_regex = get('app', 'version.regex', '')
|
|
if not version and not version_regex:
|
|
adderror('[app] One of "version" or "version.regex" must be set')
|
|
if version and version_regex:
|
|
adderror('[app] Conflict between "version" and "version.regex"'
|
|
', only one can be used.')
|
|
if version_regex and not get('app', 'version.filename', ''):
|
|
adderror('[app] "version.filename" is missing'
|
|
', required by "version.regex"')
|
|
|
|
orientation = get('app', 'orientation', 'landscape')
|
|
if orientation not in ('landscape', 'portrait', 'all', 'sensorLandscape'):
|
|
adderror('[app] "orientation" have an invalid value')
|
|
|
|
if errors:
|
|
self.error('{0} error(s) found in the buildozer.spec'.format(
|
|
len(errors)))
|
|
for error in errors:
|
|
print(error)
|
|
exit(1)
|
|
|
|
def migrate_configuration_tokens(self):
|
|
config = self.config
|
|
if config.has_section("app"):
|
|
migration = (
|
|
("android.p4a_dir", "p4a.source_dir"),
|
|
("android.p4a_whitelist", "android.whitelist"),
|
|
("android.bootstrap", "p4a.bootstrap"),
|
|
("android.branch", "p4a.branch"),
|
|
("android.p4a_whitelist_src", "android.whitelist_src"),
|
|
("android.p4a_blacklist_src", "android.blacklist_src")
|
|
)
|
|
for entry_old, entry_new in migration:
|
|
if not config.has_option("app", entry_old):
|
|
continue
|
|
value = config.get("app", entry_old)
|
|
config.set("app", entry_new, value)
|
|
config.remove_option("app", entry_old)
|
|
self.error("In section [app]: {} is deprecated, rename to {}!".format(
|
|
entry_old, entry_new))
|
|
|
|
def check_build_layout(self):
|
|
'''Ensure the build (local and global) directory layout and files are
|
|
ready.
|
|
'''
|
|
self.info('Ensure build layout')
|
|
|
|
if not exists(self.specfilename):
|
|
print('No {0} found in the current directory. Abandon.'.format(
|
|
self.specfilename))
|
|
exit(1)
|
|
|
|
# create global dir
|
|
self.mkdir(self.global_buildozer_dir)
|
|
self.mkdir(self.global_cache_dir)
|
|
|
|
# create local .buildozer/ dir
|
|
self.mkdir(self.buildozer_dir)
|
|
# create local bin/ dir
|
|
self.mkdir(self.bin_dir)
|
|
|
|
self.mkdir(self.applibs_dir)
|
|
self.state = JsonStore(join(self.buildozer_dir, 'state.db'))
|
|
|
|
target = self.targetname
|
|
if target:
|
|
self.mkdir(join(self.global_platform_dir, target, 'platform'))
|
|
self.mkdir(join(self.buildozer_dir, target, 'platform'))
|
|
self.mkdir(join(self.buildozer_dir, target, 'app'))
|
|
|
|
def check_application_requirements(self):
|
|
'''Ensure the application requirements are all available and ready to be
|
|
packaged as well.
|
|
'''
|
|
requirements = self.config.getlist('app', 'requirements', '')
|
|
target_available_packages = self.target.get_available_packages()
|
|
if target_available_packages is True:
|
|
# target handles all packages!
|
|
return
|
|
|
|
# remove all the requirements that the target can compile
|
|
onlyname = lambda x: x.split('==')[0] # noqa: E731
|
|
requirements = [x for x in requirements if onlyname(x) not in
|
|
target_available_packages]
|
|
|
|
if requirements and hasattr(sys, 'real_prefix'):
|
|
e = self.error
|
|
e('virtualenv is needed to install pure-Python modules, but')
|
|
e('virtualenv does not support nesting, and you are running')
|
|
e('buildozer in one. Please run buildozer outside of a')
|
|
e('virtualenv instead.')
|
|
exit(1)
|
|
|
|
# did we already installed the libs ?
|
|
if (
|
|
exists(self.applibs_dir) and
|
|
self.state.get('cache.applibs', '') == requirements
|
|
):
|
|
self.debug('Application requirements already installed, pass')
|
|
return
|
|
|
|
# recreate applibs
|
|
self.rmdir(self.applibs_dir)
|
|
self.mkdir(self.applibs_dir)
|
|
|
|
# ok now check the availability of all requirements
|
|
for requirement in requirements:
|
|
self._install_application_requirement(requirement)
|
|
|
|
# everything goes as expected, save this state!
|
|
self.state['cache.applibs'] = requirements
|
|
|
|
def _install_application_requirement(self, module):
|
|
self._ensure_virtualenv()
|
|
self.debug('Install requirement {} in virtualenv'.format(module))
|
|
self.cmd('pip install --target={} {}'.format(self.applibs_dir, module),
|
|
env=self.env_venv,
|
|
cwd=self.buildozer_dir)
|
|
|
|
def check_garden_requirements(self):
|
|
garden_requirements = self.config.getlist('app',
|
|
'garden_requirements', '')
|
|
if garden_requirements:
|
|
warnings.warn("`garden_requirements` settings is deprecated, use `requirements` instead", DeprecationWarning)
|
|
|
|
def _ensure_virtualenv(self):
|
|
if hasattr(self, 'venv'):
|
|
return
|
|
self.venv = join(self.buildozer_dir, 'venv')
|
|
if not self.file_exists(self.venv):
|
|
self.cmd('python3 -m venv ./venv',
|
|
cwd=self.buildozer_dir)
|
|
|
|
# read virtualenv output and parse it
|
|
output = self.cmd('bash -c "source venv/bin/activate && env"',
|
|
get_stdout=True,
|
|
cwd=self.buildozer_dir)
|
|
self.env_venv = copy(self.environ)
|
|
for line in output[0].splitlines():
|
|
args = line.split('=', 1)
|
|
if len(args) != 2:
|
|
continue
|
|
key, value = args
|
|
if key in ('VIRTUAL_ENV', 'PATH'):
|
|
self.env_venv[key] = value
|
|
if 'PYTHONHOME' in self.env_venv:
|
|
del self.env_venv['PYTHONHOME']
|
|
|
|
# ensure any sort of compilation will fail
|
|
self.env_venv['CC'] = '/bin/false'
|
|
self.env_venv['CXX'] = '/bin/false'
|
|
|
|
def mkdir(self, dn):
|
|
if exists(dn):
|
|
return
|
|
self.debug('Create directory {0}'.format(dn))
|
|
makedirs(dn)
|
|
|
|
def rmdir(self, dn):
|
|
if not exists(dn):
|
|
return
|
|
self.debug('Remove directory and subdirectory {}'.format(dn))
|
|
rmtree(dn)
|
|
|
|
def file_matches(self, patterns):
|
|
from glob import glob
|
|
result = []
|
|
for pattern in patterns:
|
|
matches = glob(expanduser(pattern.strip()))
|
|
result.extend(matches)
|
|
return result
|
|
|
|
def file_exists(self, *args):
|
|
return exists(join(*args))
|
|
|
|
def file_rename(self, source, target, cwd=None):
|
|
if cwd:
|
|
source = join(cwd, source)
|
|
target = join(cwd, target)
|
|
self.debug('Rename {0} to {1}'.format(source, target))
|
|
if not os.path.isdir(os.path.dirname(target)):
|
|
self.error(('Rename {0} to {1} fails because {2} is not a '
|
|
'directory').format(source, target, target))
|
|
move(source, target)
|
|
|
|
def file_copy(self, source, target, cwd=None):
|
|
if cwd:
|
|
source = join(cwd, source)
|
|
target = join(cwd, target)
|
|
self.debug('Copy {0} to {1}'.format(source, target))
|
|
copyfile(source, target)
|
|
|
|
def file_extract(self, archive, cwd=None):
|
|
if archive.endswith('.tgz') or archive.endswith('.tar.gz'):
|
|
self.cmd('tar xzf {0}'.format(archive), cwd=cwd)
|
|
return
|
|
|
|
if archive.endswith('.tbz2') or archive.endswith('.tar.bz2'):
|
|
# XXX same as before
|
|
self.cmd('tar xjf {0}'.format(archive), cwd=cwd)
|
|
return
|
|
|
|
if archive.endswith('.bin'):
|
|
# To process the bin files for linux and darwin systems
|
|
self.cmd('chmod a+x {0}'.format(archive), cwd=cwd)
|
|
self.cmd('./{0}'.format(archive), cwd=cwd)
|
|
return
|
|
|
|
if archive.endswith('.zip'):
|
|
self.cmd('unzip -q {}'.format(join(cwd, archive)), cwd=cwd)
|
|
return
|
|
|
|
raise Exception('Unhandled extraction for type {0}'.format(archive))
|
|
|
|
def file_copytree(self, src, dest):
|
|
print('copy {} to {}'.format(src, dest))
|
|
if os.path.isdir(src):
|
|
if not os.path.isdir(dest):
|
|
os.makedirs(dest)
|
|
files = os.listdir(src)
|
|
for f in files:
|
|
self.file_copytree(
|
|
os.path.join(src, f),
|
|
os.path.join(dest, f))
|
|
else:
|
|
copyfile(src, dest)
|
|
|
|
def clean_platform(self):
|
|
self.info('Clean the platform build directory')
|
|
if not exists(self.platform_dir):
|
|
return
|
|
rmtree(self.platform_dir)
|
|
|
|
def download(self, url, filename, cwd=None):
|
|
def report_hook(index, blksize, size):
|
|
if size <= 0:
|
|
progression = '{0} bytes'.format(index * blksize)
|
|
else:
|
|
progression = '{0:.2f}%'.format(
|
|
index * blksize * 100. / float(size))
|
|
if "CI" not in environ:
|
|
stdout.write('- Download {}\r'.format(progression))
|
|
stdout.flush()
|
|
|
|
url = url + filename
|
|
if cwd:
|
|
filename = join(cwd, filename)
|
|
if self.file_exists(filename):
|
|
unlink(filename)
|
|
|
|
self.debug('Downloading {0}'.format(url))
|
|
urlretrieve(url, filename, report_hook)
|
|
return filename
|
|
|
|
def get_version(self):
|
|
c = self.config
|
|
has_version = c.has_option('app', 'version')
|
|
has_regex = c.has_option('app', 'version.regex')
|
|
has_filename = c.has_option('app', 'version.filename')
|
|
|
|
# version number specified
|
|
if has_version:
|
|
if has_regex or has_filename:
|
|
raise Exception(
|
|
'version.regex and version.filename conflict with version')
|
|
return c.get('app', 'version')
|
|
|
|
# search by regex
|
|
if has_regex or has_filename:
|
|
if has_regex and not has_filename:
|
|
raise Exception('version.filename is missing')
|
|
if has_filename and not has_regex:
|
|
raise Exception('version.regex is missing')
|
|
|
|
fn = c.get('app', 'version.filename')
|
|
with open(fn) as fd:
|
|
data = fd.read()
|
|
regex = c.get('app', 'version.regex')
|
|
match = search(regex, data)
|
|
if not match:
|
|
raise Exception(
|
|
'Unable to find capture version in {0}\n'
|
|
' (looking for `{1}`)'.format(fn, regex))
|
|
version = match.groups()[0]
|
|
self.debug('Captured version: {0}'.format(version))
|
|
return version
|
|
|
|
raise Exception('Missing version or version.regex + version.filename')
|
|
|
|
def build_application(self):
|
|
self._copy_application_sources()
|
|
self._copy_application_libs()
|
|
self._add_sitecustomize()
|
|
|
|
def _copy_application_sources(self):
|
|
# XXX clean the inclusion/exclusion algo.
|
|
source_dir = realpath(expanduser(self.config.getdefault('app', 'source.dir', '.')))
|
|
include_exts = self.config.getlist('app', 'source.include_exts', '')
|
|
exclude_exts = self.config.getlist('app', 'source.exclude_exts', '')
|
|
exclude_dirs = self.config.getlist('app', 'source.exclude_dirs', '')
|
|
exclude_patterns = self.config.getlist('app', 'source.exclude_patterns', '')
|
|
include_patterns = self.config.getlist('app',
|
|
'source.include_patterns',
|
|
'')
|
|
app_dir = self.app_dir
|
|
|
|
include_exts = [ext.lower() for ext in include_exts]
|
|
exclude_exts = [ext.lower() for ext in exclude_exts]
|
|
exclude_dirs = [dir.lower() for dir in exclude_dirs]
|
|
exclude_patterns = [pat.lower() for pat in exclude_patterns]
|
|
include_patterns = [pat.lower() for pat in include_patterns]
|
|
|
|
self.debug('Copy application source from {}'.format(source_dir))
|
|
|
|
rmtree(self.app_dir)
|
|
|
|
for root, dirs, files in walk(source_dir, followlinks=True):
|
|
# avoid hidden directory
|
|
if True in [x.startswith('.') for x in root.split(sep)]:
|
|
continue
|
|
|
|
# need to have sort-of normalization. Let's say you want to exclude
|
|
# image directory but not images, the filtered_root must have a / at
|
|
# the end, same for the exclude_dir. And then we can safely compare
|
|
filtered_root = root[len(source_dir) + 1:].lower()
|
|
if filtered_root:
|
|
filtered_root += '/'
|
|
|
|
# manual exclude_dirs approach
|
|
is_excluded = False
|
|
for exclude_dir in exclude_dirs:
|
|
if exclude_dir[-1] != '/':
|
|
exclude_dir += '/'
|
|
if filtered_root.startswith(exclude_dir):
|
|
is_excluded = True
|
|
break
|
|
|
|
# pattern matching
|
|
if not is_excluded:
|
|
# match pattern if not ruled out by exclude_dirs
|
|
for pattern in exclude_patterns:
|
|
if fnmatch(filtered_root, pattern):
|
|
is_excluded = True
|
|
break
|
|
for pattern in include_patterns:
|
|
if fnmatch(filtered_root, pattern):
|
|
is_excluded = False
|
|
break
|
|
|
|
if is_excluded:
|
|
continue
|
|
|
|
for fn in files:
|
|
# avoid hidden files
|
|
if fn.startswith('.'):
|
|
continue
|
|
|
|
# pattern matching
|
|
is_excluded = False
|
|
dfn = fn.lower()
|
|
if filtered_root:
|
|
dfn = join(filtered_root, fn)
|
|
for pattern in exclude_patterns:
|
|
if fnmatch(dfn, pattern):
|
|
is_excluded = True
|
|
break
|
|
for pattern in include_patterns:
|
|
if fnmatch(dfn, pattern):
|
|
is_excluded = False
|
|
break
|
|
if is_excluded:
|
|
continue
|
|
|
|
# filter based on the extension
|
|
# TODO more filters
|
|
basename, ext = splitext(fn)
|
|
if ext:
|
|
ext = ext[1:].lower()
|
|
if include_exts and ext not in include_exts:
|
|
continue
|
|
if exclude_exts and ext in exclude_exts:
|
|
continue
|
|
|
|
sfn = join(root, fn)
|
|
rfn = realpath(join(app_dir, root[len(source_dir) + 1:], fn))
|
|
|
|
# ensure the directory exists
|
|
dfn = dirname(rfn)
|
|
self.mkdir(dfn)
|
|
|
|
# copy!
|
|
self.debug('Copy {0}'.format(sfn))
|
|
copyfile(sfn, rfn)
|
|
|
|
def _copy_application_libs(self):
|
|
# copy also the libs
|
|
copytree(self.applibs_dir, join(self.app_dir, '_applibs'))
|
|
|
|
def _add_sitecustomize(self):
|
|
copyfile(join(dirname(__file__), 'sitecustomize.py'),
|
|
join(self.app_dir, 'sitecustomize.py'))
|
|
|
|
main_py = join(self.app_dir, 'service', 'main.py')
|
|
if not self.file_exists(main_py):
|
|
return
|
|
|
|
header = (b'import sys, os; '
|
|
b'sys.path = [os.path.join(os.getcwd(),'
|
|
b'"..", "_applibs")] + sys.path\n')
|
|
with open(main_py, 'rb') as fd:
|
|
data = fd.read()
|
|
data = header + data
|
|
with open(main_py, 'wb') as fd:
|
|
fd.write(data)
|
|
self.info('Patched service/main.py to include applibs')
|
|
|
|
def namify(self, name):
|
|
'''Return a "valid" name from a name with lot of invalid chars
|
|
(allowed characters: a-z, A-Z, 0-9, -, _)
|
|
'''
|
|
return re.sub(r'[^a-zA-Z0-9_\-]', '_', name)
|
|
|
|
@property
|
|
def root_dir(self):
|
|
return realpath(expanduser(dirname(self.specfilename)))
|
|
|
|
@property
|
|
def user_build_dir(self):
|
|
"""The user-provided build dir, if any."""
|
|
# Check for a user-provided build dir
|
|
# Check the (deprecated) builddir token, for backwards compatibility
|
|
build_dir = self.config.getdefault('buildozer', 'builddir', None)
|
|
if build_dir is not None:
|
|
# for backwards compatibility, append .buildozer to builddir
|
|
build_dir = join(build_dir, '.buildozer')
|
|
build_dir = self.config.getdefault('buildozer', 'build_dir', build_dir)
|
|
|
|
if build_dir is not None:
|
|
build_dir = realpath(join(self.root_dir, expanduser(build_dir)))
|
|
|
|
return build_dir
|
|
|
|
@property
|
|
def buildozer_dir(self):
|
|
'''The directory in which to run the app build.'''
|
|
if self.user_build_dir is not None:
|
|
return self.user_build_dir
|
|
return join(self.root_dir, '.buildozer')
|
|
|
|
@property
|
|
def bin_dir(self):
|
|
if self.user_bin_dir:
|
|
return self.user_bin_dir
|
|
return join(self.root_dir, 'bin')
|
|
|
|
@property
|
|
def platform_dir(self):
|
|
return join(self.buildozer_dir, self.targetname, 'platform')
|
|
|
|
@property
|
|
def app_dir(self):
|
|
return join(self.buildozer_dir, self.targetname, 'app')
|
|
|
|
@property
|
|
def applibs_dir(self):
|
|
return join(self.buildozer_dir, 'applibs')
|
|
|
|
@property
|
|
def global_buildozer_dir(self):
|
|
return join(expanduser('~'), '.buildozer')
|
|
|
|
@property
|
|
def global_platform_dir(self):
|
|
return join(self.global_buildozer_dir, self.targetname, 'platform')
|
|
|
|
@property
|
|
def global_packages_dir(self):
|
|
return join(self.global_buildozer_dir, self.targetname, 'packages')
|
|
|
|
@property
|
|
def global_cache_dir(self):
|
|
return join(self.global_buildozer_dir, 'cache')
|
|
|
|
@property
|
|
def package_full_name(self):
|
|
package_name = self.config.getdefault('app', 'package.name', '')
|
|
package_domain = self.config.getdefault('app', 'package.domain', '')
|
|
if package_domain == '':
|
|
return package_name
|
|
return '{}.{}'.format(package_domain, package_name)
|
|
|
|
#
|
|
# command line invocation
|
|
#
|
|
|
|
def targets(self):
|
|
for fn in listdir(join(dirname(__file__), 'targets')):
|
|
if fn.startswith('.') or fn.startswith('__'):
|
|
continue
|
|
if not fn.endswith('.py'):
|
|
continue
|
|
target = fn[:-3]
|
|
try:
|
|
m = __import__('buildozer.targets.{0}'.format(target),
|
|
fromlist=['buildozer'])
|
|
yield target, m
|
|
except NotImplementedError:
|
|
pass
|
|
except:
|
|
raise
|
|
pass
|
|
|
|
def usage(self):
|
|
print('Usage:')
|
|
print(' buildozer [--profile <name>] [--verbose] [target] <command>...')
|
|
print(' buildozer --version')
|
|
print('')
|
|
print('Available targets:')
|
|
targets = list(self.targets())
|
|
for target, m in targets:
|
|
try:
|
|
doc = m.__doc__.strip().splitlines()[0].strip()
|
|
except Exception:
|
|
doc = '<no description>'
|
|
print(' {0:<18} {1}'.format(target, doc))
|
|
|
|
print('')
|
|
print('Global commands (without target):')
|
|
cmds = [x for x in dir(self) if x.startswith('cmd_')]
|
|
for cmd in cmds:
|
|
name = cmd[4:]
|
|
meth = getattr(self, cmd)
|
|
|
|
if not meth.__doc__:
|
|
continue
|
|
doc = list(meth.__doc__.strip().splitlines())[0].strip()
|
|
print(' {0:<18} {1}'.format(name, doc))
|
|
|
|
print('')
|
|
print('Target commands:')
|
|
print(' clean Clean the target environment')
|
|
print(' update Update the target dependencies')
|
|
print(' debug Build the application in debug mode')
|
|
print(' release Build the application in release mode')
|
|
print(' deploy Deploy the application on the device')
|
|
print(' run Run the application on the device')
|
|
print(' serve Serve the bin directory via SimpleHTTPServer')
|
|
|
|
for target, m in targets:
|
|
mt = m.get_target(self)
|
|
commands = mt.get_custom_commands()
|
|
if not commands:
|
|
continue
|
|
print('')
|
|
print('Target "{0}" commands:'.format(target))
|
|
for command, doc in commands:
|
|
if not doc:
|
|
continue
|
|
doc = textwrap.fill(textwrap.dedent(doc).strip(), 59,
|
|
subsequent_indent=' ' * 21)
|
|
print(' {0:<18} {1}'.format(command, doc))
|
|
|
|
print('')
|
|
|
|
def run_default(self):
|
|
self.check_build_layout()
|
|
if 'buildozer:defaultcommand' not in self.state:
|
|
print('No default command set.')
|
|
print('Use "buildozer setdefault <command args...>"')
|
|
print('Use "buildozer help" for a list of all commands"')
|
|
exit(1)
|
|
cmd = self.state['buildozer:defaultcommand']
|
|
self.run_command(cmd)
|
|
|
|
def run_command(self, args):
|
|
while args:
|
|
if not args[0].startswith('-'):
|
|
break
|
|
arg = args.pop(0)
|
|
|
|
if arg in ('-v', '--verbose'):
|
|
self.log_level = 2
|
|
|
|
elif arg in ('-h', '--help'):
|
|
self.usage()
|
|
exit(0)
|
|
|
|
elif arg in ('-p', '--profile'):
|
|
self.config_profile = args.pop(0)
|
|
|
|
elif arg == '--version':
|
|
print('Buildozer {0}'.format(__version__))
|
|
exit(0)
|
|
|
|
self._merge_config_profile()
|
|
|
|
self.check_root()
|
|
|
|
if not args:
|
|
self.run_default()
|
|
return
|
|
|
|
command, args = args[0], args[1:]
|
|
cmd = 'cmd_{0}'.format(command)
|
|
|
|
# internal commands ?
|
|
if hasattr(self, cmd):
|
|
getattr(self, cmd)(*args)
|
|
return
|
|
|
|
# maybe it's a target?
|
|
targets = [x[0] for x in self.targets()]
|
|
if command not in targets:
|
|
print('Unknown command/target {}'.format(command))
|
|
exit(1)
|
|
|
|
self.set_target(command)
|
|
self.target.run_commands(args)
|
|
|
|
def check_root(self):
|
|
'''If effective user id is 0, display a warning and require
|
|
user input to continue (or to cancel)'''
|
|
|
|
warn_on_root = self.config.getdefault('buildozer', 'warn_on_root', '1')
|
|
try:
|
|
euid = os.geteuid() == 0
|
|
except AttributeError:
|
|
if sys.platform == 'win32':
|
|
import ctypes
|
|
euid = ctypes.windll.shell32.IsUserAnAdmin() != 0
|
|
if warn_on_root == '1' and euid:
|
|
print('\033[91m\033[1mBuildozer is running as root!\033[0m')
|
|
print('\033[91mThis is \033[1mnot\033[0m \033[91mrecommended, and may lead to problems later.\033[0m')
|
|
cont = None
|
|
while cont not in ('y', 'n'):
|
|
cont = input('Are you sure you want to continue [y/n]? ')
|
|
|
|
if cont == 'n':
|
|
sys.exit()
|
|
|
|
def cmd_init(self, *args):
|
|
'''Create a initial buildozer.spec in the current directory
|
|
'''
|
|
if exists('buildozer.spec'):
|
|
print('ERROR: You already have a buildozer.spec file.')
|
|
exit(1)
|
|
copyfile(join(dirname(__file__), 'default.spec'), 'buildozer.spec')
|
|
print('File buildozer.spec created, ready to customize!')
|
|
|
|
def cmd_distclean(self, *args):
|
|
'''Clean the whole Buildozer environment.
|
|
'''
|
|
print("Warning: Your ndk, sdk and all other cached packages will be"
|
|
" removed. Continue? (y/n)")
|
|
if sys.stdin.readline().lower()[0] == 'y':
|
|
self.info('Clean the global build directory')
|
|
if not exists(self.global_buildozer_dir):
|
|
return
|
|
rmtree(self.global_buildozer_dir)
|
|
|
|
def cmd_appclean(self, *args):
|
|
'''Clean the .buildozer folder in the app directory.
|
|
|
|
This command specifically refuses to delete files in a
|
|
user-specified build directory, to avoid accidentally deleting
|
|
more than the user intends.
|
|
'''
|
|
if self.user_build_dir is not None:
|
|
self.error(
|
|
('Failed: build_dir is specified as {} in the buildozer config. `appclean` will '
|
|
'not attempt to delete files in a user-specified build directory.').format(self.user_build_dir))
|
|
elif exists(self.buildozer_dir):
|
|
self.info('Deleting {}'.format(self.buildozer_dir))
|
|
rmtree(self.buildozer_dir)
|
|
else:
|
|
self.error('{} already deleted, skipping.'.format(self.buildozer_dir))
|
|
|
|
def cmd_help(self, *args):
|
|
'''Show the Buildozer help.
|
|
'''
|
|
self.usage()
|
|
|
|
def cmd_setdefault(self, *args):
|
|
'''Set the default command to run when no arguments are given
|
|
'''
|
|
self.check_build_layout()
|
|
self.state['buildozer:defaultcommand'] = args
|
|
|
|
def cmd_version(self, *args):
|
|
'''Show the Buildozer version
|
|
'''
|
|
print('Buildozer {0}'.format(__version__))
|
|
|
|
def cmd_serve(self, *args):
|
|
'''Serve the bin directory via SimpleHTTPServer
|
|
'''
|
|
try:
|
|
from http.server import SimpleHTTPRequestHandler
|
|
from socketserver import TCPServer
|
|
except ImportError:
|
|
from SimpleHTTPServer import SimpleHTTPRequestHandler
|
|
from SocketServer import TCPServer
|
|
|
|
os.chdir(self.bin_dir)
|
|
handler = SimpleHTTPRequestHandler
|
|
httpd = TCPServer(("", SIMPLE_HTTP_SERVER_PORT), handler)
|
|
print("Serving via HTTP at port {}".format(SIMPLE_HTTP_SERVER_PORT))
|
|
print("Press Ctrl+c to quit serving.")
|
|
httpd.serve_forever()
|
|
|
|
#
|
|
# Private
|
|
#
|
|
|
|
def _merge_config_profile(self):
|
|
profile = self.config_profile
|
|
if not profile:
|
|
return
|
|
for section in self.config.sections():
|
|
|
|
# extract the profile part from the section name
|
|
# example: [app@default,hd]
|
|
parts = section.split('@', 1)
|
|
if len(parts) < 2:
|
|
continue
|
|
|
|
# create a list that contain all the profiles of the current section
|
|
# ['default', 'hd']
|
|
section_base, section_profiles = parts
|
|
section_profiles = section_profiles.split(',')
|
|
if profile not in section_profiles:
|
|
continue
|
|
|
|
# the current profile is one available in the section
|
|
# merge with the general section, or make it one.
|
|
if not self.config.has_section(section_base):
|
|
self.config.add_section(section_base)
|
|
for name, value in self.config.items(section):
|
|
print('merged ({}, {}) into {} (profile is {})'.format(name,
|
|
value, section_base, profile))
|
|
self.config.set(section_base, name, value)
|
|
|
|
def _get_config_list_values(self, *args, **kwargs):
|
|
kwargs['with_values'] = True
|
|
return self._get_config_list(*args, **kwargs)
|
|
|
|
def _get_config_list(self, section, token, default=None, with_values=False):
|
|
# monkey-patch method for ConfigParser
|
|
# get a key as a list of string, separated from the comma
|
|
|
|
# check if an env var exists that should replace the file config
|
|
set_config_token_from_env(section, token, self.config)
|
|
|
|
# if a section:token is defined, let's use the content as a list.
|
|
l_section = '{}:{}'.format(section, token)
|
|
if self.config.has_section(l_section):
|
|
values = self.config.options(l_section)
|
|
if with_values:
|
|
return ['{}={}'.format(key, self.config.get(l_section, key)) for
|
|
key in values]
|
|
else:
|
|
return [x.strip() for x in values]
|
|
|
|
values = self.config.getdefault(section, token, '')
|
|
if not values:
|
|
return default
|
|
values = values.split(',')
|
|
if not values:
|
|
return default
|
|
return [x.strip() for x in values]
|
|
|
|
def _get_config_default(self, section, token, default=None):
|
|
# monkey-patch method for ConfigParser
|
|
# get an appropriate env var if it exists, else
|
|
# get a key in a section, or the default
|
|
|
|
# check if an env var exists that should replace the file config
|
|
set_config_token_from_env(section, token, self.config)
|
|
|
|
if not self.config.has_section(section):
|
|
return default
|
|
if not self.config.has_option(section, token):
|
|
return default
|
|
return self.config.get(section, token)
|
|
|
|
def _get_config_bool(self, section, token, default=False):
|
|
# monkey-patch method for ConfigParser
|
|
# get a key in a section, or the default
|
|
|
|
# check if an env var exists that should replace the file config
|
|
set_config_token_from_env(section, token, self.config)
|
|
|
|
if not self.config.has_section(section):
|
|
return default
|
|
if not self.config.has_option(section, token):
|
|
return default
|
|
return self.config.getboolean(section, token)
|
|
|
|
def _get_config_raw_default(self, section, token, default=None, section_sep="=", split_char=" "):
|
|
l_section = '{}:{}'.format(section, token)
|
|
if self.config.has_section(l_section):
|
|
return [section_sep.join(item) for item in self.config.items(l_section)]
|
|
if not self.config.has_option(section, token):
|
|
return default.split(split_char)
|
|
return self.config.get(section, token).split(split_char)
|
|
|
|
|
|
def set_config_from_envs(config):
|
|
'''Takes a ConfigParser, and checks every section/token for an
|
|
environment variable of the form SECTION_TOKEN, with any dots
|
|
replaced by underscores. If the variable exists, sets the config
|
|
variable to the env value.
|
|
'''
|
|
for section in config.sections():
|
|
for token in config.options(section):
|
|
set_config_token_from_env(section, token, config)
|
|
|
|
|
|
def set_config_token_from_env(section, token, config):
|
|
'''Given a config section and token, checks for an appropriate
|
|
environment variable. If the variable exists, sets the config entry to
|
|
its value.
|
|
|
|
The environment variable checked is of the form SECTION_TOKEN, all
|
|
upper case, with any dots replaced by underscores.
|
|
|
|
Returns True if the environment variable exists and was used, or
|
|
False otherwise.
|
|
|
|
'''
|
|
env_var_name = ''.join([section.upper(), '_',
|
|
token.upper().replace('.', '_')])
|
|
env_var = os.environ.get(env_var_name)
|
|
if env_var is None:
|
|
return False
|
|
config.set(section, token, env_var)
|
|
return True
|