diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 1ecd818b2caa5ac57cedb8d102abd18d5709ec68..5bbc8a4042f064ff021711925c56c4a266d663a5 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -14,7 +14,7 @@ stages: .build_template: &build_job stage: build before_script: - - python3 ./ci/bootstrap.py build + - python3 ./bob/devtools/bootstrap.py build script: - ./ci/build.sh cache: &build_caches @@ -74,7 +74,7 @@ build_macosx_36: .deploy_template: &deploy_job stage: deploy before_script: - - python3 ./ci/bootstrap.py local myenv + - python3 ./bob/devtools/bootstrap.py local myenv script: - source ${CONDA_ROOT}/etc/profile.d/conda.sh - conda activate myenv @@ -115,7 +115,7 @@ pypi: except: - branches before_script: - - python3 ./ci/bootstrap.py local myenv + - python3 ./bob/devtools/bootstrap.py local myenv script: - source ${CONDA_ROOT}/etc/profile.d/conda.sh - conda activate myenv diff --git a/bob/devtools/bootstrap.py b/bob/devtools/bootstrap.py index 5ea04ae97cd686f3034421d670f031b5ab8e97da..930ffafe3e1ef571c884f4b08b40af2c8afedb62 100644 --- a/bob/devtools/bootstrap.py +++ b/bob/devtools/bootstrap.py @@ -1,128 +1,389 @@ -#!/usr/bin/env python -# vim: set fileencoding=utf-8 : +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +'''Bootstraps a new miniconda installation and prepares it for development + +This command uses a bare-minimum python3 installation (with SSL support) to +bootstrap a new miniconda installation preset for the defined activity. It is +primarily intended for CI operation and prefixes build and deployment steps. + +Usage: python3 %s <cmd> build|local|beta|stable [<name>] + +Arguments: + + <cmd> How to prepare the current environment. Use: + + build to build bob.devtools + local to bootstrap deploy|pypi stages for bob.devtools builds + beta to bootstrap CI environment for beta builds + stable to bootstrap CI environment for stable builds + test to locally test this bootstrap script + + <name> (optional) if command is one of ``local|beta|stable`` provide the + name of env for bob.devtools installation') +''' + + +BASE_CONDARC = '''\ +default_channels: + - https://repo.anaconda.com/pkgs/main + - https://repo.anaconda.com/pkgs/free + - https://repo.anaconda.com/pkgs/r + - https://repo.anaconda.com/pkgs/pro +add_pip_as_python_dependency: false #!final +changeps1: false #!final +always_yes: true #!final +quiet: true #!final +show_channel_urls: true #!final +anaconda_upload: false #!final +ssl_verify: false #!final +''' -'''Methods to bootstrap working environments based on conda-packages''' import os -import json +import sys +import glob +import time import shutil +import platform import subprocess import logging -logger = logging.getLogger(__name__) +logger = logging.getLogger('bootstrap') + -import yaml +_INTERVALS = ( + ('weeks', 604800), # 60 * 60 * 24 * 7 + ('days', 86400), # 60 * 60 * 24 + ('hours', 3600), # 60 * 60 + ('minutes', 60), + ('seconds', 1), + ) +def human_time(seconds, granularity=2): + '''Returns a human readable time string like "1 day, 2 hours"''' -def make_conda_config(config, python, append_file, condarc): + result = [] - from conda_build.api import get_or_merge_config - from conda_build.conda_interface import url_path + for name, count in _INTERVALS: + value = seconds // count + if value: + seconds -= value * count + if value == 1: + name = name.rstrip('s') + result.append("{} {}".format(int(value), name)) + else: + # Add a blank if we're in the middle of other values + if len(result) > 0: + result.append(None) - with open(condarc, 'rb') as f: - condarc_options = yaml.load(f) + if not result: + if seconds < 1.0: + return '%.2f seconds' % seconds + else: + if seconds == 1: + return '1 second' + else: + return '%d seconds' % seconds - retval = get_or_merge_config(None, variant_config_files=config, - python=python, append_sections_file=append_file, **condarc_options) + return ', '.join([x for x in result[:granularity] if x is not None]) - retval.channel_urls = [] - for url in condarc_options['channels']: - # allow people to specify relative or absolute paths to local channels - # These channels still must follow conda rules - they must have the - # appropriate platform-specific subdir (e.g. win-64) - if os.path.isdir(url): - if not os.path.isabs(url): - url = os.path.normpath(os.path.abspath(os.path.join(os.getcwd(), url))) - url = url_path(url) - retval.channel_urls.append(url) +def run_cmdline(cmd, env=None): + '''Runs a command on a environment, logs output and reports status - return retval + Parameters: -def get_rendered_metadata(recipe_dir, config): - '''Renders the recipe and returns the interpreted YAML file''' + cmd (list): The command to run, with parameters separated on a list - from conda_build.api import render - return render(recipe_dir, config=config) + env (dict, Optional): Environment to use for running the program on. If not + set, use :py:obj:`os.environ`. + ''' -def get_parsed_recipe(metadata): - '''Renders the recipe and returns the interpreted YAML file''' + if env is None: env = os.environ - from conda_build.api import output_yaml - output = output_yaml(metadata[0][0]) - return yaml.load(output) + logger.info('(system) %s' % ' '.join(cmd)) + start = time.time() + out = b'' -def remove_pins(deps): - return [l.split()[0] for l in deps] + p = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, + env=env) + for line in iter(p.stdout.readline, b''): + sys.stdout.write(line.decode(sys.stdout.encoding)) + sys.stdout.flush() -def parse_dependencies(recipe_dir, config): + if p.wait() != 0: + raise RuntimeError("command `%s' exited with error state (%d)" % \ + (' '.join(cmd), p.returncode)) - metadata = get_rendered_metadata(recipe_dir, config) - recipe = get_parsed_recipe(metadata) - return remove_pins(recipe['requirements'].get('build', [])) + \ - remove_pins(recipe['requirements'].get('host', [])) + \ - recipe['requirements'].get('run', []) + \ - recipe.get('test', {}).get('requires', []) + \ - ['bob.buildout', 'mr.developer', 'ipdb'] - # by last, packages required for local dev + total = time.time() - start + logger.info('command took %s' % human_time(total)) -def get_env_directory(conda, name): - cmd = [conda, 'env', 'list', '--json'] - output = subprocess.check_output(cmd) - data = json.loads(output) - retval = [k for k in data.get('envs', []) if k.endswith(os.sep + name)] - if retval: - return retval[0] - return None +def touch(path): + '''Python-implementation of the "touch" command-line application''' -def conda_create(conda, name, overwrite, condarc, packages, dry_run, use_local): + with open(path, 'a'): + os.utime(path, None) - specs = [] - for k in packages: - k = ' '.join(k.split()[:2]) # remove eventual build string - if any(elem in k for elem in '><|'): - specs.append(k.replace(' ', '')) - else: - specs.append(k.replace(' ', '=')) - - # if the current environment exists, delete it first - envdir = get_env_directory(conda, name) - if envdir is not None: - if overwrite: - cmd = [conda, 'env', 'remove', '--yes', '--name', name] - logger.debug('$ ' + ' '.join(cmd)) - if not dry_run: - status = subprocess.call(cmd) - if status != 0: - return status - else: - raise RuntimeError('environment `%s\' exists in `%s\' - use ' - '--overwrite to overwrite' % (name, envdir)) - - cmd = [conda, 'create', '--yes', '--name', name] - if dry_run: - cmd.append('--dry-run') - if use_local: - cmd.append('--use-local') - cmd.extend(sorted(specs)) - logger.debug('$ ' + ' '.join(cmd)) - status = subprocess.call(cmd) - if status != 0: - return status - - # copy the used condarc file to the just created environment - if not dry_run: - # get envdir again - it may just be created! - envdir = get_env_directory(conda, name) - destrc = os.path.join(envdir, '.condarc') - logger.debug('$ cp %s -> %s' % (condarc, destrc)) - shutil.copy2(condarc, destrc) - - return status + +def merge_conda_cache(cache, prefix): + '''Merges conda pkg caches and conda-bld folders''' + + pkgs_dir = os.path.join(prefix, 'pkgs') + pkgs_urls_txt = os.path.join(pkgs_dir, 'urls.txt') + if not os.path.exists(pkgs_dir): + logger.info('mkdir -p %s', pkgs_dir) + os.makedirs(pkgs_dir) + logger.info('touch %s', pkgs_urls_txt) + touch(pkgs_urls_txt) + + # move packages on cache/pkgs to pkgs_dir + cached_pkgs_dir = os.path.join(cache, 'pkgs') + cached_packages = glob.glob(os.path.join(cached_pkgs_dir, '*.tar.bz2')) + cached_packages = [k for k in cached_packages if not \ + k.startswith(os.environ['CI_PROJECT_NAME'] + '-')] + logger.info('Merging %d cached conda packages', len(cached_packages)) + for k in cached_packages: + dst = os.path.join(pkgs_dir, os.path.basename(k)) + logger.debug('(move) %s -> %s', k, dst) + os.rename(k, dst) + + # merge urls.txt files + logger.info('Merging urls.txt files from cache...') + urls = [] + cached_pkgs_urls_txt = os.path.join(cached_pkgs_dir, 'urls.txt') + with open(pkgs_urls_txt, 'rb') as f1, \ + open(cached_pkgs_urls_txt, 'rb') as f2: + data = set(f1.readlines() + f2.readlines()) + data = sorted(list(data)) + with open(pkgs_urls_txt, 'wb') as f: + f.writelines(data) + + pkgs_urls = os.path.join(pkgs_dir, 'urls') + touch(pkgs_urls) + + # move conda-bld build results + cached_conda_bld = os.path.join(cache, 'conda-bld') + if os.path.exists(cached_conda_bld): + dst = os.path.join(prefix, 'conda-bld') + logger.info('(move) %s -> %s', cached_conda_bld, dst) + os.rename(cached_conda_bld, dst) + + +def get_miniconda_sh(): + '''Retrieves the miniconda3 installer for the current system''' + + import http.client + + server = 'repo.continuum.io' #https + path = '/miniconda/Miniconda3-latest-%s-x86_64.sh' + if platform.system() == 'Darwin': + path = path % 'MacOSX' + else: + path = path % 'Linux' + + logger.info('Connecting to https://%s...', server) + conn = http.client.HTTPSConnection(server) + conn.request("GET", path) + r1 = conn.getresponse() + + assert r1.status == 200, 'Request for https://%s%s - returned status %d ' \ + '(%s)' % (server, path, r1.status, r1.reason) + + dst = 'miniconda.sh' + logger.info('(download) https://%s%s -> %s...', server, path, dst) + with open(dst, 'wb') as f: + f.write(r1.read()) + + +def install_miniconda(prefix): + '''Creates a new miniconda installation''' + + logger.info("Installing miniconda in %s...", prefix) + + if not os.path.exists('miniconda.sh'): #re-downloads installer + get_miniconda_sh() + else: + logger.info("Re-using cached miniconda3 installer") + + cached = None + if os.path.exists(prefix): #this is the previous cache, move it + cached = prefix + '.cached' + if os.path.exists(cached): + logger.info('(rmtree) %s', cached) + shutil.rmtree(cached) + logger.info('(move) %s -> %s', prefix, cached) + os.rename(prefix, cached) + + run_cmdline(['bash', 'miniconda.sh', '-b', '-p', prefix]) + if cached is not None: + merge_conda_cache(cached, prefix) + shutil.rmtree(cached) + + +def get_channels(public, stable): + '''Returns the relevant conda channels to consider if building project + + The subset of channels to be returned depends on the visibility and stability + of the package being built. Here are the rules: + + * public and stable: only returns the public stable channel(s) + * public and not stable: returns both public stable and beta channels + * not public and stable: returns both public and private stable channels + * not public and not stable: returns all channels + + Beta channels have priority over stable channels, if returned. Private + channels have priority over public channles, if turned. + + + Args: + + public: Boolean indicating if we're supposed to include only public + channels + stable: Boolean indicating if we're supposed to include only stable + channels + + + Returns: a list of channels that need to be considered. + + ''' + + server = "http://www.idiap.ch" + channels = [] + + if not public: + if not stable: #allowed private channels + channels += [server + '/private/conda/label/beta'] #allowed betas + channels += [server + '/private/conda'] + if not stable: + channels += [server + '/public/conda/label/beta'] #allowed betas + channels += [server + '/public/conda'] + + return channels + + +def add_channels_condarc(channels, condarc): + '''Appends passed channel list to condarc file, print contents''' + + with open(condarc, 'at') as f: + f.write('channels:\n') + for k in channels: + f.write(' - %s\n' % k) + + with open(condarc, 'rt') as f: + logger.info('Contents of $CONDARC:\n%s', f.read()) + + +def setup_logger(): + '''Sets-up the logging for this command at level ``INFO``''' + + warn_err = logging.StreamHandler(sys.stderr) + warn_err.setLevel(logging.WARNING) + logger.addHandler(warn_err) + + # debug and info messages are written to sys.stdout + + class _InfoFilter: + def filter(self, record): + return record.levelno <= logging.INFO + + debug_info = logging.StreamHandler(sys.stdout) + debug_info.setLevel(logging.DEBUG) + debug_info.addFilter(_InfoFilter()) + logger.addHandler(debug_info) + + formatter = logging.Formatter('%(levelname)s@%(asctime)s: %(message)s') + + for handler in logger.handlers: + handler.setFormatter(formatter) + + logger.setLevel(logging.INFO) + + +if __name__ == '__main__': + + if len(sys.argv) == 1: + print(__doc__ % sys.argv[0]) + sys.exit(1) + + setup_logger() + + if sys.argv[1] == 'test': + # sets up local variables for testing + os.environ['CI_PROJECT_DIR'] = os.path.realpath(os.curdir) + os.environ['CI_PROJECT_NAME'] = 'bob.devtools' + os.environ['CONDA_ROOT'] = os.path.join(os.environ['CI_PROJECT_DIR'], + 'miniconda') + + prefix = os.environ['CONDA_ROOT'] + logger.info('os.environ["%s"] = %s', 'CONDA_ROOT', prefix) + + workdir = os.environ['CI_PROJECT_DIR'] + logger.info('os.environ["%s"] = %s', 'CI_PROJECT_DIR', workdir) + + install_miniconda(prefix) + conda_bin = os.path.join(prefix, 'bin', 'conda') + + # creates the condarc file + condarc = os.path.join(prefix, 'condarc') + logger.info('(create) %s', condarc) + with open(condarc, 'wt') as f: + f.write(BASE_CONDARC) + os.environ['CONDARC'] = condarc + logger.info('os.environ["%s"] = %s', 'CONDARC', condarc) + + conda_version = '4' + conda_build_version = '3' + + if sys.argv[1] in ('build', 'test'): + + # simple - just use the defaults channels when self building + add_channels_condarc(['defaults'], condarc) + run_cmdline([conda_bin, 'install', '-n', 'base', + 'python', + 'conda=%s' % conda_version, + 'conda-build=%s' % conda_build_version, + ]) + + elif sys.argv[1] == 'local': + + # index the locally built packages + run_cmdline([conda_bin, 'install', '-n', 'base', + 'python', + 'conda=%s' % conda_version, + 'conda-build=%s' % conda_build_version, + ]) + conda_bld_path = os.path.join(prefix, 'conda-bld') + run_cmdline([conda_bin, 'index', conda_bld_path]) + # add the locally build directory before defaults, boot from there + channels = get_channels(public=True, stable=True) + add_channels_condarc(channels + [conda_bld_path, 'defaults'], condarc) + run_cmdline([conda_bin, 'create', '-n', sys.argv[2], 'bob.devtools']) + + elif sys.argv[1] in ('beta', 'stable'): + + # installs from channel + channels = get_channels( + public=os.environ['CI_PROJECT_VISIBILITY'] == 'public', + stable=os.environ.get('CI_COMMIT_TAG') is not None) + add_channels_condarc(channels + ['defaults'], condarc) + run_cmdline([conda_bin, 'create', '-n', sys.argv[2], 'bob.devtools']) + + else: + + logger.error("Bootstrap with 'build', or 'local|beta|stable <name>'") + logger.error("The value '%s' is not currently supported", sys.argv[1]) + sys.exit(1) + + # clean up + run_cmdline([conda_bin, 'clean', '--lock']) + + # print conda information for debugging purposes + run_cmdline([conda_bin, 'info']) diff --git a/ci/nextbuild.py b/bob/devtools/build.py similarity index 100% rename from ci/nextbuild.py rename to bob/devtools/build.py diff --git a/bob/devtools/create.py b/bob/devtools/create.py new file mode 100644 index 0000000000000000000000000000000000000000..ed3077ebb3c9e564a0b57911f48d118593d45f09 --- /dev/null +++ b/bob/devtools/create.py @@ -0,0 +1,128 @@ +#!/usr/bin/env python +# vim: set fileencoding=utf-8 : + +'''Methods to create working environments based on conda-packages''' + +import os +import json +import shutil +import subprocess + +import logging +logger = logging.getLogger(__name__) + +import yaml + + +def make_conda_config(config, python, append_file, condarc): + + from conda_build.api import get_or_merge_config + from conda_build.conda_interface import url_path + + with open(condarc, 'rb') as f: + condarc_options = yaml.load(f) + + retval = get_or_merge_config(None, variant_config_files=config, + python=python, append_sections_file=append_file, **condarc_options) + + retval.channel_urls = [] + + for url in condarc_options['channels']: + # allow people to specify relative or absolute paths to local channels + # These channels still must follow conda rules - they must have the + # appropriate platform-specific subdir (e.g. win-64) + if os.path.isdir(url): + if not os.path.isabs(url): + url = os.path.normpath(os.path.abspath(os.path.join(os.getcwd(), url))) + url = url_path(url) + retval.channel_urls.append(url) + + return retval + + +def get_rendered_metadata(recipe_dir, config): + '''Renders the recipe and returns the interpreted YAML file''' + + from conda_build.api import render + return render(recipe_dir, config=config) + + +def get_parsed_recipe(metadata): + '''Renders the recipe and returns the interpreted YAML file''' + + from conda_build.api import output_yaml + output = output_yaml(metadata[0][0]) + return yaml.load(output) + + +def remove_pins(deps): + return [l.split()[0] for l in deps] + + +def parse_dependencies(recipe_dir, config): + + metadata = get_rendered_metadata(recipe_dir, config) + recipe = get_parsed_recipe(metadata) + return remove_pins(recipe['requirements'].get('build', [])) + \ + remove_pins(recipe['requirements'].get('host', [])) + \ + recipe['requirements'].get('run', []) + \ + recipe.get('test', {}).get('requires', []) + \ + ['bob.buildout', 'mr.developer', 'ipdb'] + # by last, packages required for local dev + + +def get_env_directory(conda, name): + + cmd = [conda, 'env', 'list', '--json'] + output = subprocess.check_output(cmd) + data = json.loads(output) + retval = [k for k in data.get('envs', []) if k.endswith(os.sep + name)] + if retval: + return retval[0] + return None + + +def conda_create(conda, name, overwrite, condarc, packages, dry_run, use_local): + + specs = [] + for k in packages: + k = ' '.join(k.split()[:2]) # remove eventual build string + if any(elem in k for elem in '><|'): + specs.append(k.replace(' ', '')) + else: + specs.append(k.replace(' ', '=')) + + # if the current environment exists, delete it first + envdir = get_env_directory(conda, name) + if envdir is not None: + if overwrite: + cmd = [conda, 'env', 'remove', '--yes', '--name', name] + logger.debug('$ ' + ' '.join(cmd)) + if not dry_run: + status = subprocess.call(cmd) + if status != 0: + return status + else: + raise RuntimeError('environment `%s\' exists in `%s\' - use ' + '--overwrite to overwrite' % (name, envdir)) + + cmd = [conda, 'create', '--yes', '--name', name] + if dry_run: + cmd.append('--dry-run') + if use_local: + cmd.append('--use-local') + cmd.extend(sorted(specs)) + logger.debug('$ ' + ' '.join(cmd)) + status = subprocess.call(cmd) + if status != 0: + return status + + # copy the used condarc file to the just created environment + if not dry_run: + # get envdir again - it may just be created! + envdir = get_env_directory(conda, name) + destrc = os.path.join(envdir, '.condarc') + logger.debug('$ cp %s -> %s' % (condarc, destrc)) + shutil.copy2(condarc, destrc) + + return status diff --git a/bob/devtools/scripts/build.py b/bob/devtools/scripts/build.py index 53c6e35c69122417b234cc290c5df34835ec3d23..8aabd9157b5d0c2b4c31f80b083ecd032f1a2fc1 100644 --- a/bob/devtools/scripts/build.py +++ b/bob/devtools/scripts/build.py @@ -12,7 +12,8 @@ import click from . import bdt from ..log import verbosity_option from ..conda import next_build_number, osname -from ..bootstrap import get_rendered_metadata, get_parsed_recipe +from ..create import get_rendered_metadata, get_parsed_recipe, \ + make_conda_config from ..constants import CONDARC, CONDA_BUILD_CONFIG, CONDA_RECIPE_APPEND, \ SERVER, MATPLOTLIB_RCDIR, set_environment @@ -85,7 +86,6 @@ def build(recipe_dir, python, condarc, config, channel, no_test, append_file, logger.debug("CONDARC=%s", condarc) - from ..bootstrap import make_conda_config conda_config = make_conda_config(config, python, append_file, condarc) set_environment('LANG', 'en_US.UTF-8', os.environ) diff --git a/bob/devtools/scripts/bootstrap.py b/bob/devtools/scripts/create.py similarity index 93% rename from bob/devtools/scripts/bootstrap.py rename to bob/devtools/scripts/create.py index 07d9f14477e21639170cfb0b04ae2ff6b8718f88..e09bca5355f46d910a87e16b74539fc1905786c8 100644 --- a/bob/devtools/scripts/bootstrap.py +++ b/bob/devtools/scripts/create.py @@ -12,7 +12,7 @@ import yaml from . import bdt from ..log import verbosity_option -from ..bootstrap import parse_dependencies, conda_create, make_conda_config +from ..create import parse_dependencies, conda_create, make_conda_config from ..constants import CONDARC, CONDA_BUILD_CONFIG, CONDA_RECIPE_APPEND, \ SERVER @@ -24,7 +24,7 @@ Examples: \b $ cd bob.package.foo - $ bdt bootstrap -vv myenv + $ bdt create -vv myenv The above command assumes the directory `conda' exists on the current directory and that it contains a file called `meta.yaml' containing the recipe for the package you want to create a development environment for. @@ -33,12 +33,12 @@ Examples: 2. By default, we use the native python version of your conda installation as the default python version to use for the newly created environment. You may select a different one with `--python=X.Y': - $ bdt bootstrap -vv --python=3.6 myenv + $ bdt create -vv --python=3.6 myenv 3. By default, we use our own condarc and `conda_build_config.yaml` files that are used in creating packages for our CI/CD system. If you wish to use your own, specify them on the command line: - $ bdt bootstrap -vv --python=3.6 --config=config.yaml --condarc=~/.condarc myenv + $ bdt create -vv --python=3.6 --config=config.yaml --condarc=~/.condarc myenv Notice the condarc file **must** end in `condarc', or conda will complain. @@ -49,7 +49,7 @@ Examples: shell will be printed: - $ bdt bootstrap -vvv --dry-run myenv + $ bdt create -vvv --dry-run myenv ''') @click.argument('name') @click.argument('recipe-dir', required=False, type=click.Path(file_okay=False, @@ -80,7 +80,7 @@ Examples: help='Allow the use of local channels for package retrieval') @verbosity_option() @bdt.raise_on_error -def bootstrap(name, recipe_dir, python, overwrite, condarc, config, +def create(name, recipe_dir, python, overwrite, condarc, config, append_file, docserver, dry_run, use_local): """Creates a development environment for a recipe diff --git a/ci/bootstrap.py b/ci/bootstrap.py deleted file mode 100755 index 930ffafe3e1ef571c884f4b08b40af2c8afedb62..0000000000000000000000000000000000000000 --- a/ci/bootstrap.py +++ /dev/null @@ -1,389 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -'''Bootstraps a new miniconda installation and prepares it for development - -This command uses a bare-minimum python3 installation (with SSL support) to -bootstrap a new miniconda installation preset for the defined activity. It is -primarily intended for CI operation and prefixes build and deployment steps. - -Usage: python3 %s <cmd> build|local|beta|stable [<name>] - -Arguments: - - <cmd> How to prepare the current environment. Use: - - build to build bob.devtools - local to bootstrap deploy|pypi stages for bob.devtools builds - beta to bootstrap CI environment for beta builds - stable to bootstrap CI environment for stable builds - test to locally test this bootstrap script - - <name> (optional) if command is one of ``local|beta|stable`` provide the - name of env for bob.devtools installation') -''' - - -BASE_CONDARC = '''\ -default_channels: - - https://repo.anaconda.com/pkgs/main - - https://repo.anaconda.com/pkgs/free - - https://repo.anaconda.com/pkgs/r - - https://repo.anaconda.com/pkgs/pro -add_pip_as_python_dependency: false #!final -changeps1: false #!final -always_yes: true #!final -quiet: true #!final -show_channel_urls: true #!final -anaconda_upload: false #!final -ssl_verify: false #!final -''' - - -import os -import sys -import glob -import time -import shutil -import platform -import subprocess - -import logging -logger = logging.getLogger('bootstrap') - - -_INTERVALS = ( - ('weeks', 604800), # 60 * 60 * 24 * 7 - ('days', 86400), # 60 * 60 * 24 - ('hours', 3600), # 60 * 60 - ('minutes', 60), - ('seconds', 1), - ) - -def human_time(seconds, granularity=2): - '''Returns a human readable time string like "1 day, 2 hours"''' - - result = [] - - for name, count in _INTERVALS: - value = seconds // count - if value: - seconds -= value * count - if value == 1: - name = name.rstrip('s') - result.append("{} {}".format(int(value), name)) - else: - # Add a blank if we're in the middle of other values - if len(result) > 0: - result.append(None) - - if not result: - if seconds < 1.0: - return '%.2f seconds' % seconds - else: - if seconds == 1: - return '1 second' - else: - return '%d seconds' % seconds - - return ', '.join([x for x in result[:granularity] if x is not None]) - - -def run_cmdline(cmd, env=None): - '''Runs a command on a environment, logs output and reports status - - - Parameters: - - cmd (list): The command to run, with parameters separated on a list - - env (dict, Optional): Environment to use for running the program on. If not - set, use :py:obj:`os.environ`. - - ''' - - if env is None: env = os.environ - - logger.info('(system) %s' % ' '.join(cmd)) - - start = time.time() - out = b'' - - p = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, - env=env) - - for line in iter(p.stdout.readline, b''): - sys.stdout.write(line.decode(sys.stdout.encoding)) - sys.stdout.flush() - - if p.wait() != 0: - raise RuntimeError("command `%s' exited with error state (%d)" % \ - (' '.join(cmd), p.returncode)) - - total = time.time() - start - - logger.info('command took %s' % human_time(total)) - - - -def touch(path): - '''Python-implementation of the "touch" command-line application''' - - with open(path, 'a'): - os.utime(path, None) - - -def merge_conda_cache(cache, prefix): - '''Merges conda pkg caches and conda-bld folders''' - - pkgs_dir = os.path.join(prefix, 'pkgs') - pkgs_urls_txt = os.path.join(pkgs_dir, 'urls.txt') - if not os.path.exists(pkgs_dir): - logger.info('mkdir -p %s', pkgs_dir) - os.makedirs(pkgs_dir) - logger.info('touch %s', pkgs_urls_txt) - touch(pkgs_urls_txt) - - # move packages on cache/pkgs to pkgs_dir - cached_pkgs_dir = os.path.join(cache, 'pkgs') - cached_packages = glob.glob(os.path.join(cached_pkgs_dir, '*.tar.bz2')) - cached_packages = [k for k in cached_packages if not \ - k.startswith(os.environ['CI_PROJECT_NAME'] + '-')] - logger.info('Merging %d cached conda packages', len(cached_packages)) - for k in cached_packages: - dst = os.path.join(pkgs_dir, os.path.basename(k)) - logger.debug('(move) %s -> %s', k, dst) - os.rename(k, dst) - - # merge urls.txt files - logger.info('Merging urls.txt files from cache...') - urls = [] - cached_pkgs_urls_txt = os.path.join(cached_pkgs_dir, 'urls.txt') - with open(pkgs_urls_txt, 'rb') as f1, \ - open(cached_pkgs_urls_txt, 'rb') as f2: - data = set(f1.readlines() + f2.readlines()) - data = sorted(list(data)) - with open(pkgs_urls_txt, 'wb') as f: - f.writelines(data) - - pkgs_urls = os.path.join(pkgs_dir, 'urls') - touch(pkgs_urls) - - # move conda-bld build results - cached_conda_bld = os.path.join(cache, 'conda-bld') - if os.path.exists(cached_conda_bld): - dst = os.path.join(prefix, 'conda-bld') - logger.info('(move) %s -> %s', cached_conda_bld, dst) - os.rename(cached_conda_bld, dst) - - -def get_miniconda_sh(): - '''Retrieves the miniconda3 installer for the current system''' - - import http.client - - server = 'repo.continuum.io' #https - path = '/miniconda/Miniconda3-latest-%s-x86_64.sh' - if platform.system() == 'Darwin': - path = path % 'MacOSX' - else: - path = path % 'Linux' - - logger.info('Connecting to https://%s...', server) - conn = http.client.HTTPSConnection(server) - conn.request("GET", path) - r1 = conn.getresponse() - - assert r1.status == 200, 'Request for https://%s%s - returned status %d ' \ - '(%s)' % (server, path, r1.status, r1.reason) - - dst = 'miniconda.sh' - logger.info('(download) https://%s%s -> %s...', server, path, dst) - with open(dst, 'wb') as f: - f.write(r1.read()) - - -def install_miniconda(prefix): - '''Creates a new miniconda installation''' - - logger.info("Installing miniconda in %s...", prefix) - - if not os.path.exists('miniconda.sh'): #re-downloads installer - get_miniconda_sh() - else: - logger.info("Re-using cached miniconda3 installer") - - cached = None - if os.path.exists(prefix): #this is the previous cache, move it - cached = prefix + '.cached' - if os.path.exists(cached): - logger.info('(rmtree) %s', cached) - shutil.rmtree(cached) - logger.info('(move) %s -> %s', prefix, cached) - os.rename(prefix, cached) - - run_cmdline(['bash', 'miniconda.sh', '-b', '-p', prefix]) - if cached is not None: - merge_conda_cache(cached, prefix) - shutil.rmtree(cached) - - -def get_channels(public, stable): - '''Returns the relevant conda channels to consider if building project - - The subset of channels to be returned depends on the visibility and stability - of the package being built. Here are the rules: - - * public and stable: only returns the public stable channel(s) - * public and not stable: returns both public stable and beta channels - * not public and stable: returns both public and private stable channels - * not public and not stable: returns all channels - - Beta channels have priority over stable channels, if returned. Private - channels have priority over public channles, if turned. - - - Args: - - public: Boolean indicating if we're supposed to include only public - channels - stable: Boolean indicating if we're supposed to include only stable - channels - - - Returns: a list of channels that need to be considered. - - ''' - - server = "http://www.idiap.ch" - channels = [] - - if not public: - if not stable: #allowed private channels - channels += [server + '/private/conda/label/beta'] #allowed betas - channels += [server + '/private/conda'] - if not stable: - channels += [server + '/public/conda/label/beta'] #allowed betas - channels += [server + '/public/conda'] - - return channels - - -def add_channels_condarc(channels, condarc): - '''Appends passed channel list to condarc file, print contents''' - - with open(condarc, 'at') as f: - f.write('channels:\n') - for k in channels: - f.write(' - %s\n' % k) - - with open(condarc, 'rt') as f: - logger.info('Contents of $CONDARC:\n%s', f.read()) - - -def setup_logger(): - '''Sets-up the logging for this command at level ``INFO``''' - - warn_err = logging.StreamHandler(sys.stderr) - warn_err.setLevel(logging.WARNING) - logger.addHandler(warn_err) - - # debug and info messages are written to sys.stdout - - class _InfoFilter: - def filter(self, record): - return record.levelno <= logging.INFO - - debug_info = logging.StreamHandler(sys.stdout) - debug_info.setLevel(logging.DEBUG) - debug_info.addFilter(_InfoFilter()) - logger.addHandler(debug_info) - - formatter = logging.Formatter('%(levelname)s@%(asctime)s: %(message)s') - - for handler in logger.handlers: - handler.setFormatter(formatter) - - logger.setLevel(logging.INFO) - - -if __name__ == '__main__': - - if len(sys.argv) == 1: - print(__doc__ % sys.argv[0]) - sys.exit(1) - - setup_logger() - - if sys.argv[1] == 'test': - # sets up local variables for testing - os.environ['CI_PROJECT_DIR'] = os.path.realpath(os.curdir) - os.environ['CI_PROJECT_NAME'] = 'bob.devtools' - os.environ['CONDA_ROOT'] = os.path.join(os.environ['CI_PROJECT_DIR'], - 'miniconda') - - prefix = os.environ['CONDA_ROOT'] - logger.info('os.environ["%s"] = %s', 'CONDA_ROOT', prefix) - - workdir = os.environ['CI_PROJECT_DIR'] - logger.info('os.environ["%s"] = %s', 'CI_PROJECT_DIR', workdir) - - install_miniconda(prefix) - conda_bin = os.path.join(prefix, 'bin', 'conda') - - # creates the condarc file - condarc = os.path.join(prefix, 'condarc') - logger.info('(create) %s', condarc) - with open(condarc, 'wt') as f: - f.write(BASE_CONDARC) - os.environ['CONDARC'] = condarc - logger.info('os.environ["%s"] = %s', 'CONDARC', condarc) - - conda_version = '4' - conda_build_version = '3' - - if sys.argv[1] in ('build', 'test'): - - # simple - just use the defaults channels when self building - add_channels_condarc(['defaults'], condarc) - run_cmdline([conda_bin, 'install', '-n', 'base', - 'python', - 'conda=%s' % conda_version, - 'conda-build=%s' % conda_build_version, - ]) - - elif sys.argv[1] == 'local': - - # index the locally built packages - run_cmdline([conda_bin, 'install', '-n', 'base', - 'python', - 'conda=%s' % conda_version, - 'conda-build=%s' % conda_build_version, - ]) - conda_bld_path = os.path.join(prefix, 'conda-bld') - run_cmdline([conda_bin, 'index', conda_bld_path]) - # add the locally build directory before defaults, boot from there - channels = get_channels(public=True, stable=True) - add_channels_condarc(channels + [conda_bld_path, 'defaults'], condarc) - run_cmdline([conda_bin, 'create', '-n', sys.argv[2], 'bob.devtools']) - - elif sys.argv[1] in ('beta', 'stable'): - - # installs from channel - channels = get_channels( - public=os.environ['CI_PROJECT_VISIBILITY'] == 'public', - stable=os.environ.get('CI_COMMIT_TAG') is not None) - add_channels_condarc(channels + ['defaults'], condarc) - run_cmdline([conda_bin, 'create', '-n', sys.argv[2], 'bob.devtools']) - - else: - - logger.error("Bootstrap with 'build', or 'local|beta|stable <name>'") - logger.error("The value '%s' is not currently supported", sys.argv[1]) - sys.exit(1) - - # clean up - run_cmdline([conda_bin, 'clean', '--lock']) - - # print conda information for debugging purposes - run_cmdline([conda_bin, 'info']) diff --git a/ci/bootstrap.sh b/ci/bootstrap.sh deleted file mode 100755 index 3805cfce4a52431ed300f4457a435ca209cb9346..0000000000000000000000000000000000000000 --- a/ci/bootstrap.sh +++ /dev/null @@ -1,161 +0,0 @@ -#!/usr/bin/env bash - -# Bootstraps a new conda installation and prepares base environment -# if "local" is passed as parameter, then self installs an already built -# version of bob.devtools available on your conda-bld directory. If you pass -# "beta", then it bootstraps from the package installed on our conda beta -# channel. If you pass "stable", then it bootstraps installing the package -# available on the stable channel. -# -# If bootstrapping anything else than "build", then provide a second argument -# with the name of the environment that one wants to create with an -# installation of bob.devtools. - -# datetime prefix for logging -log_datetime() { - echo "($(date +%T.%3N))" -} - -# Functions for coloring echo commands -log_info() { - echo -e "$(log_datetime) \033[1;34m${@}\033[0m" -} - -log_error() { - echo -e "$(log_datetime) \033[1;31mError: ${@}\033[0m" >&2 -} - -# Function for running command and echoing results -run_cmd() { - log_info "$ ${@}" - ${@} - local status=$? - if [ ${status} != 0 ]; then - log_error "Command Failed \"${@}\"" - exit ${status} - fi -} - -# Checks just if the variable is defined and has non-zero length -check_defined() { - if [ -z "${!1+abc}" ]; then - log_error "Variable ${1} is undefined - aborting..."; - exit 1 - elif [ -z "${!1}" ]; then - log_error "Variable ${1} is zero-length - aborting..."; - exit 1 - fi - log_info "${1}=${!1}" -} - -# merges conda cache folders -# $1: Path to the main cache to keep. The directory must exist. -# $2: Path to the extra cache to be merged into main cache -merge_conda_cache() { - if [ -e ${1} ]; then - _cached_urlstxt="${2}/pkgs/urls.txt" - _urlstxt="${1}/pkgs/urls.txt" - if [ -e ${2}/pkgs ]; then - log_info "Merging urls.txt and packages with cached files..." - mv ${2}/pkgs/*.tar.bz2 ${1}/pkgs - run_cmd rm -f ${1}/pkgs/${CI_PROJECT_NAME}-*-*_*.tar.bz2 - cat ${_urlstxt} ${_cached_urlstxt} | sort | uniq > ${_urlstxt} - else - run_cmd mkdir -p ${1}/pkgs - run_cmd touch ${1}/pkgs/urls.txt - fi - run_cmd touch ${1}/pkgs/urls - if [ -d ${2}/conda-bld ]; then - log_info "Moving conda-bld packages (artifacts)..." - run_cmd mv ${2}/conda-bld ${1} - fi - fi -} - -# installs a miniconda installation. -# $1: Path to where to install miniconda. -install_miniconda() { - log_info "Installing miniconda in ${1} ..." - - # checks if miniconda.sh exists - if [ ! -e miniconda.sh ]; then - log_info "Downloading latest miniconda3 installer..." - # downloads the latest conda installation script - if [ "$(uname -s)" == "Linux" ]; then - _os="Linux" - else - _os="MacOSX" - fi - obj=https://repo.continuum.io/miniconda/Miniconda3-latest-${_os}-x86_64.sh - run_cmd curl --silent --output miniconda.sh ${obj} - else - log_info "Re-using cached miniconda3 installer..." - ls -l miniconda.sh - fi - - # move cache to a different folder if it exists - if [ -e ${1} ]; then - run_cmd mv ${1} ${1}.cached - fi - - # install miniconda - run_cmd bash miniconda.sh -b -p ${1} - - # Put back cache and merge urls.txt - merge_conda_cache ${1} ${1}.cached - # remove the backup cache folder - rm -rf ${1}.cached - - # List currently available packages on cache - # run_cmd ls -l ${1}/pkgs/ - # run_cmd cat ${1}/pkgs/urls.txt - - hash -r -} - - -check_defined CONDA_ROOT -check_defined CI_PROJECT_DIR - -export CONDARC="${CONDA_ROOT}/condarc" -check_defined CONDARC - -# checks if a conda installation exists. Otherwise, installs one -if [ ! -e ${CONDA_ROOT}/bin/conda ]; then - install_miniconda ${CONDA_ROOT} -fi - -run_cmd cp -fv ${CI_PROJECT_DIR}/bob/devtools/data/base-condarc ${CONDARC} -echo "Contents of \`${CONDARC}':" -cat ${CONDARC} - -# setup conda-channels -CONDA_CHANNEL_ROOT="http://www.idiap.ch/public/conda" -check_defined CONDA_CHANNEL_ROOT -CONDA_CLI_CHANNELS="-c ${CONDA_CHANNEL_ROOT} -c defaults" - -# creates a base installation depending on the purpose -if [ "${1}" == "build" ]; then - run_cmd ${CONDA_ROOT}/bin/conda install -n base python conda=4 conda-build=3 -elif [ "${1}" == "local" ]; then - # updates the base installation, installs conda-build - run_cmd ls -l ${CONDA_ROOT}/conda-bld - run_cmd ${CONDA_ROOT}/bin/conda install -n base python conda=4 conda-build=3 - CONDA_CLI_CHANNELS="-c ${CONDA_ROOT}/conda-bld ${CONDA_CLI_CHANNELS}" - run_cmd ${CONDA_ROOT}/bin/conda index ${CONDA_ROOT}/conda-bld - run_cmd ls -l ${CONDA_ROOT}/conda-bld - run_cmd ls -l ${CONDA_ROOT}/conda-bld/noarch/ - run_cmd ${CONDA_ROOT}/bin/conda create -n "${2}" --override-channels ${CONDA_CLI_CHANNELS} bob.devtools -elif [ "${1}" == "beta" ] || [ "${1}" == "stable" ]; then - run_cmd ${CONDA_ROOT}/bin/conda create -n "${2}" --override-channels ${CONDA_CLI_CHANNELS} bob.devtools -else - log_error "Bootstrap with 'build', or 'local|beta|stable <name>'" - log_error "The value '${1}' is not currently supported" - exit 1 -fi - -# cleans up -run_cmd ${CONDA_ROOT}/bin/conda clean --lock - -# print conda information for debugging purposes -run_cmd ${CONDA_ROOT}/bin/conda info diff --git a/conda/meta.yaml b/conda/meta.yaml index 23e147e3aeebac36bf64a5e4e2ddb58526940d0b..c0d16c240c054436698701ed9d461ebfaf95539e 100644 --- a/conda/meta.yaml +++ b/conda/meta.yaml @@ -61,7 +61,7 @@ test: - bdt release --help - bdt visibility --help - bdt dumpsphinx --help - - bdt bootstrap --help + - bdt create --help - bdt build --help - bdt getpath --help - bdt caupdate --help diff --git a/doc/api.rst b/doc/api.rst index b3762d86a4338517137e4b419fc0b330911a850e..cf688d1784c666b1cb1ae01abe3538ed213081fb 100644 --- a/doc/api.rst +++ b/doc/api.rst @@ -12,7 +12,9 @@ bob.devtools.constants bob.devtools.release bob.devtools.changelog + bob.devtools.create bob.devtools.bootstrap + bob.devtools.build bob.devtools.webdav3.client @@ -31,8 +33,12 @@ Detailed Information .. automodule:: bob.devtools.changelog +.. automodule:: bob.devtools.create + .. automodule:: bob.devtools.bootstrap +.. automodule:: bob.devtools.build + WebDAV Python Client -------------------- diff --git a/setup.py b/setup.py index 04415d1829e9d28c429350eae84c6db9a4de2379..c9f5cf3ce9caf702a8542865a124696487657ea9 100644 --- a/setup.py +++ b/setup.py @@ -48,7 +48,7 @@ setup( 'lasttag = bob.devtools.scripts.lasttag:lasttag', 'visibility = bob.devtools.scripts.visibility:visibility', 'dumpsphinx = bob.devtools.scripts.dumpsphinx:dumpsphinx', - 'bootstrap = bob.devtools.scripts.bootstrap:bootstrap', + 'create = bob.devtools.scripts.create:create', 'build = bob.devtools.scripts.build:build', 'getpath = bob.devtools.scripts.getpath:getpath', 'caupdate = bob.devtools.scripts.caupdate:caupdate',