view install.py @ 514:fc35cae2fb52

Support other branches than master for git repos
author Ludovic Chabant <ludovic@chabant.com>
date Wed, 04 May 2022 15:03:48 -0700
parents b8eeae888aab
children 6d5e2a583502
line wrap: on
line source

import os
import os.path
import re
import sys
import stat
import shutil
import argparse
import functools
import traceback
import subprocess
import configparser


# Utility stuff.

dotfiles_dir = os.path.abspath(os.path.dirname(__file__))

is_nix = True
is_mac = False
is_windows = False
if sys.platform == "win32":
    is_nix = False
    is_windows = True
if sys.platform == 'darwin':
    is_mac = True


def _is_executable(fpath):
    return os.path.isfile(fpath) and os.access(fpath, os.X_OK)


def which(exename):
    for path in os.environ.get("PATH", "").split(os.pathsep):
        exepath = os.path.join(path, exename)
        if _is_executable(exepath):
            return exepath
    return None


def _p(*paths, force_unix=False):
    res = os.path.join(dotfiles_dir, *paths)
    if force_unix:
        res = res.replace('\\', '/')
    else:
        res = res.replace('/', os.sep)
    return res


def nixslash(path):
    return path.replace('\\', '/')


def ensure_dir(path):
    full_path = os.path.abspath(os.path.expanduser(path))
    if not os.path.isdir(full_path):
        os.makedirs(full_path, mode=0o700)


def mklink(orig_rel_path, link_path, mode=None):
    orig_full_path = os.path.join(dotfiles_dir, orig_rel_path)
    link_full_path = os.path.abspath(os.path.expanduser(link_path))
    if os.path.islink(link_full_path):
        print("Unlinking %s" % link_full_path)
        os.unlink(link_full_path)
    elif os.path.exists(link_full_path):
        print("Removing %s" % link_full_path)
        os.remove(link_full_path)

    print("%s -> %s" % (link_full_path, orig_full_path))
    if not is_windows or os.path.isfile(orig_full_path):
        os.symlink(orig_full_path, link_full_path)
    else:
        subprocess.check_call(['mklink', '/J', link_full_path, orig_full_path], shell=True)
    if mode is not None:
        os.chmod(link_full_path, mode)


def writelines(path, lines):
    full_path = os.path.abspath(os.path.expanduser(path))
    print("%d lines to %s" % (len(lines), full_path))
    with open(full_path, 'w') as fp:
        for l in lines:
            fp.write(l)
            fp.write('\n')


def _on_rmtree_err(func, name, excinfo):
    os.chmod(name, stat.S_IWUSR | stat.S_IWGRP)
    os.remove(name)


def rmtree(dirpath):
    shutil.rmtree(dirpath, onerror=_on_rmtree_err)


# Installer method decorators.

def only_on_nix(f):
    @functools.wraps(f)
    def decorator(*args, **kwargs):
        if is_nix:
            return f(*args, **kwargs)
    return decorator


def only_on_mac(f):
    @functools.wraps(f)
    def decorator(*args, **kwargs):
        if is_mac:
            return f(*args, **kwargs)
    return decorator


def only_on_win(f):
    @functools.wraps(f)
    def decorator(*args, **kwargs):
        if is_windows:
            return f(*args, **kwargs)
    return decorator


def supports_forcing(f):
    f.__dotfiles_supports_forcing__ = True
    return f


def needs_config(f):
    f.__dotfiles_needs_config__ = True
    return f


def run_priority(prio):
    def wrapper(f):
        f.__dotfiles_priority__ = prio
        return f
    return wrapper


# Installer methods.

@only_on_nix
def install_bash():
    mklink('bashrc/bashrc', '~/.bashrc')
    mklink('bashrc/bash_profile', '~/.bash_profile')


@only_on_nix
def install_fish():
    ensure_dir('~/.config/fish')
    writelines('~/.config/fish/config.fish',
               ['source %s' % _p('fish', 'config.fish')])


def _install_vim_bundle(cfg, cfg_section_name, bundle_dir, force=False):
    if not cfg.has_section(cfg_section_name):
        return

    os.makedirs(bundle_dir, exist_ok=True)

    # Keep track of lowercase directory names because the cfg parser stores
    # config section names in lowercase.
    existing_plugins = dict([(d.lower(), d) for d in os.listdir(bundle_dir)])

    for name, url in cfg.items(cfg_section_name):
        path = os.path.join(bundle_dir, name)
        if url.startswith('[local]'):
            pass
        elif url.startswith('[git'):
            clone_git(url, path, force=force)
        else:
            clone_hg(url, path, force=force)
        print()

        existing_plugins.pop(name, None)

    for k, name in existing_plugins.items():
        print("Removing plugin %s" % name)
        ok = input("OK? [Y/n]")
        if ok.lower() == "y":
            path = os.path.join(bundle_dir, name)
            rmtree(path)


@needs_config
@supports_forcing
def install_vim(cfg, force=False):
    vimrc_path = '~/.vimrc'
    if is_windows:
        vimrc_path = '~/_vimrc'
    writelines(vimrc_path, [
        'set runtimepath+=%s' % nixslash(_p('vim')),
        'source %s' % nixslash(_p('vim', 'vimrc'))
    ])

    # Create a gvimrc file mostly to fix a bug with Scoop, which
    # installs a _gvimrc in the base runtime path, and it can mess
    # up colors and settings.
    gvimrc_path = '~/.gvimrc'
    if is_windows:
        gvimrc_path = '~/_gvimrc'
    writelines(gvimrc_path, [
        'source %s' % nixslash(_p('vim', 'gvimrc'))
    ])

    _install_vim_bundle(cfg, 'vimbundles', _p('vim', 'bundle'), force)
    _install_vim_bundle(cfg, 'vimbundles:local', _p('vim', 'local'), force)


@run_priority(2)   # Needs to run before `fish`.
def install_mercurial():
    hgrc_path = '~/.hgrc'
    if is_windows:
        hgrc_path = '~/mercurial.ini'
    writelines(hgrc_path, [
        '%%include %s' % _p('hgrc/hgrc'),
        '[ui]',
        'ignore = %s' % _p('hgrc/hgignore'),
        '[subrepos]',
        'git:allowed = true',
        '[extensions]',
        'hggit = %s' % _p('lib/hg/hg-git/hggit/'),
        'onsub = %s' % _p('lib/hg/onsub/onsub.py'),
        'allpaths = %s' % _p('lib/hg/mercurial-all_paths/mercurial_all_paths.py'),
        'prompt = %s' % _p('lib/hg/hg-prompt/prompt.py'),
        'evolve = %s' % _p('lib/hg/evolve/hgext3rd/evolve'),
        '[alias]',
        ('dlog = log --pager=yes --style=%s' %
         _p('lib/hg/mercurial-cli-templates/map-cmdline.dlog',
            force_unix=True)),
        ('slog = log --pager=yes --style=%s' %
         _p('lib/hg/mercurial-cli-templates/map-cmdline.slog',
            force_unix=True)),
        ('nlog = log --pager=yes --style=%s' %
         _p('lib/hg/mercurial-cli-templates/map-cmdline.nlog',
            force_unix=True)),
        ('sglog = glog --pager=yes --style=%s' %
         _p('lib/hg/mercurial-cli-templates/map-cmdline.sglog',
            force_unix=True)),
        ('nglog = glog --pager=yes --style=%s' %
         _p('lib/hg/mercurial-cli-templates/map-cmdline.nlog',
            force_unix=True)),
        ('blog = glog --page=yes --style=%s' %
         _p('hgrc/logstyles')),
        ('wip = glog --pager=yes --style=%s --rev wip' %
         _p('hgrc/wip.style', force_unix=True))
    ])
    if is_nix:
        print("Building fast-hg-prompt...")
        compile_ok = True
        try:
            subprocess.check_call(['make'], cwd=_p('lib/hg/fast-hg-prompt'))
        except subprocess.CalledProcessError:
            compile_ok = False

        for n in ['bookmark', 'remote', 'status']:
            link_path = os.path.expanduser('~/.local/bin/fast-hg-%s' % n)
            if compile_ok:
                mklink('lib/hg/fast-hg-prompt/fast-hg-%s' % n, link_path,
                       mode=(stat.S_IRUSR | stat.S_IWUSR | stat.S_IXUSR))
            elif os.path.islink(link_path):
                os.unlink(link_path)
            elif os.path.exists(link_path):
                os.remove(link_path)


def install_git():
    writelines('~/.gitconfig', [
        '[include]',
        '  path = %s' % _p('git/gitconfig', force_unix=True),
        '[core]',
        '  excludesfile = %s' % _p('git/gitignore', force_unix=True)
    ])
    if is_windows:
        subprocess.check_call(
            ['setx', 'GIT_SSH',
             '%USERPROFILE%\\Dropbox\\Utilities\\plink.exe'],
            shell=True)


def install_universal_ctags():
    ensure_dir('~/.ctags.d')
    writelines('~/.ctags.d/global.ctags', [
        '--exclude=node_modules',
        # On Windows, u-ctags has a bug where it outputs double-backslashes.
        '--output-format=e-ctags'
    ])


@only_on_nix
def install_tmux():
    mklink('tmux/tmux.conf', '~/.tmux.conf')


@only_on_nix
def install_weechat():
    mklink('weechat', '~/.weechat')


@only_on_nix
def install_mutt():
    if which('gpg2'):
        gpgbin = 'gpg2'
    else:
        if not which('gpg'):
            print("WARNING: no GPG tools seem to be installed!")
        gpgbin = 'gpg'
    writelines('~/.muttrc', [
        'source "%s -dq %s |"' % (gpgbin, _p('mutt/variables.gpg')),
        'source "%s"' % _p('mutt/muttrc'),
        'source "%s"' % _p('lib/mutt/mutt-colors-solarized/'
                           'mutt-colors-solarized-dark-256.muttrc')
    ])


def install_tridactyl():
    cfgname = '~/_tridactylrc' if is_windows else '~/.tridactylrc'
    writelines(cfgname, [
        'source %s' % _p('tridactyl/tridactylrc')
    ])


def install_qutebrowser():
    if is_mac:
        config_dir = '~/.qutebrowser'
    elif is_windows:
        config_dir = '%s/qutebrowser/' % os.getenv('APPDATA')
    else:
        config_dir = '~/.config/qutebrowser'

    mklink('qutebrowser', config_dir)


def _on_error_try_make_readable(func, path, exc_info):
    if not os.access(path, os.W_OK):
        os.chmod(path, stat.S_IWUSR)
        func(path)
    else:
        raise


def _is_non_empty_dir_with(path, contains=None):
    if not os.path.isdir(path):
        return False
    if isinstance(contains, str):
        contains = [contains]
    for cnt in contains:
        if not os.path.exists(os.path.join(path, cnt)):
            return False
    return True


re_git_url_prefix = re.compile(r'^\[git(\:(?P<branch>[^\]]+))?\](?P<url>.*)$')

# TODO: support for submodules
def clone_git(url, path, force=False):
    m = re_git_url_prefix.match(url)
    if not m:
        raise Exception("Not a git url: %s" % url)
    url, branch = m.group('url'), (m.group('branch') or 'master')
    
    if _is_non_empty_dir_with(path, '.git'):
        if not force:
            print("git pull origin %s %s" % (branch, path))
            subprocess.check_call(['git', 'pull', 'origin', branch],
                                  cwd=path)
            return
        else:
            print("Deleting existing: %s" % path)
            shutil.rmtree(path, onerror=_on_error_try_make_readable)

    print("git clone %s %s" % (url, path))
    ensure_dir(os.path.dirname(path))
    subprocess.check_call(['git', 'clone', url, path])


def clone_hg(url, path, force=False):
    if _is_non_empty_dir_with(path, '.hg'):
        if not force:
            print("hg pull -u %s" % path)
            subprocess.check_call(['hg', 'pull', '-u'], cwd=path)
            return
        else:
            print("Deleting existing: %s" % path)
            shutil.rmtree(path, onerror=_on_error_try_make_readable)

    print("hg clone %s %s" % (url, path))
    ensure_dir(os.path.dirname(path))
    env = dict(os.environ)
    env.update({'HGPLAIN': '1'})
    subprocess.check_call(['hg', 'clone', url, path], env=env)


@needs_config
@supports_forcing
@run_priority(100)
def install_subrepos(cfg, force=False):
    if not cfg.has_section('subrepos'):
        return

    for path, url in cfg.items('subrepos'):
        full_path = _p(path)
        if url.startswith('[git'):
            clone_git(url, full_path, force=force)
        else:
            clone_hg(url, full_path, force=force)
        print()


@only_on_mac
@run_priority(210)
def install_xcode():
    if shutil.which('xcodebuild') is None:
        print("Installing XCode")
        subprocess.check_call(['xcode-select', '--install'])
        subprocess.check_call(['sudo', 'xcodebuild', '-license', 'accept'])


@only_on_mac
@run_priority(209)
def install_homebrew():
    if shutil.which('brew') is None:
        print("Installing Homebrew and Homebrew Cask")
        subprocess.check_call([
            '/usr/bin/ruby', '-e',
            "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/master/install)"])  # NOQA
        subprocess.check_call(['brew', 'tap', 'caskroom/fonts'])


@only_on_mac
@needs_config
@supports_forcing
@run_priority(208)
def install_mactools(cfg, force=False):
    if not cfg.has_section('mactools'):
        return

    for name, _ in cfg.items('mactools'):
        args = ['brew', 'install', name]
        if force:
            args.append('--force')
        subprocess.check_call(args)


@only_on_win
@run_priority(209)
def install_scoop():
    if shutil.which('scoop') is None:
        print("Installing Scoop")
        subprocess.check_call(
            '@"%SystemRoot%\System32\WindowsPowerShell\v1.0\powershell.exe" '
            '-NoProfile -InputFormat None -ExecutionPolicy Bypass '
            '-Command "iex (new-object net.webclient).downloadstring(\'https://get.scoop.sh\')"')
        subprocess.check_call(
            ['scoop.cmd', 'bucket', 'add', 'extras'])
    else:
        print("Scoop is already installed")

@only_on_win
@needs_config
@supports_forcing
@run_priority(208)
def install_wintools(cfg, force=False):
    if not cfg.has_section('wintools'):
        return

    for name, arch in cfg.items('wintools'):
        args = ['scoop.cmd', 'install', name]
        if arch:
            args += ['-a', arch]
        subprocess.check_call(args)


# Main stuff!

class FatalInstallerError(Exception):
	pass


def main():
    print("dotfiles installer")
    print("python %s" % sys.version)
    print("on %s" % sys.platform)
    print('')

    cfg = configparser.ConfigParser()
    cfg.read([
        _p('install.cfg'),
        _p('local', 'local_install.cfg')])

    # Get all the methods in this module that are named `install_xxx`.
    mod_names = ['all']
    this_mod = sys.modules[__name__]
    for an in dir(this_mod):
        if not an.startswith('install_'):
            continue

        name = an[len('install_'):]
        mod_names.append(name)

    # See if we have any local install script.
    local_mod = None
    local_install_py = _p('local', 'local_install.py')
    if os.path.isfile(local_install_py):
        import importlib.util
        spec = importlib.util.spec_from_file_location('local_install',
                                                      local_install_py)
        local_mod = importlib.util.module_from_spec(spec)
        spec.loader.exec_module(local_mod)
        sys.modules['local_install'] = local_mod

    # Create the parser, where you can specify one or more install target.
    parser = argparse.ArgumentParser()
    parser.add_argument(
        'module', nargs='*',
        help="Which module(s) to install. Defaults to all modules.")
    parser.add_argument(
        '-l', '--list', action='store_true',
        help="List available modules to install.")
    parser.add_argument(
        '-f', '--force', action='store_true',
        help="Force installation by overwriting things.")
    args = parser.parse_args()

    # Print list and exit if needed.
    if args.list:
        print("Available modules to install:")
        for nm in mod_names:
            print(nm)
        print()
        print("Specify 'all' to install all modules.")
        return

    # Get the list of methods to run.
    funcs = []
    selected_mods = set(args.module)
    if 'all' in selected_mods:
        selected_mods = set(mod_names)
        selected_mods.remove('all')
    selected_mods.difference_update([neg[3:] for neg in args.module if neg.startswith('no-')])

    for mn in selected_mods:
        func = getattr(this_mod, 'install_%s' % mn)
        funcs.append((mn, func))
        # See if there's a local method too for this.
        if local_mod is not None:
            local_func = getattr(local_mod, 'install_%s' % mn, None)
            if local_func is not None:
                lmn = '%s (local)' % mn
                funcs.append((lmn, local_func))

    failed_installs = []
    funcs = sorted(funcs, key=_get_install_func_priority, reverse=True)
    for name, func in funcs:
        print("Installing %s" % name)

        f_args = []
        f_kwargs = {}
        if getattr(func, '__dotfiles_needs_config__', False):
            f_args.append(cfg)
        if getattr(func, '__dotfiles_supports_forcing__', False):
            f_kwargs['force'] = args.force

        try:
            func(*f_args, **f_kwargs)
        except Exception as ex:
            failed_installs.append((name, sys.exc_info()))
            print("ERROR: %s" % ex)
            traceback.print_exc()
            if isinstance(ex, FatalInstallerError):
                print("Aborting all remaining installs because '%s' failed!" % name)
                break
            else:
                print("Skipping install of '%s'." % name)

        print()

    if failed_installs:
        print()
        print("----------------------------------")
        print("ERROR: There were failed installs!")
        for name, ex_info in failed_installs:
            print("ERROR: failed to install '%s'." % name)
            print("ERROR: %s" % ex_info[1])
            traceback.print_exception(*ex_info)


def _get_install_func_priority(func_info):
    func = func_info[1]
    return getattr(func, '__dotfiles_priority__', 0)


if __name__ == '__main__':
    main()