view install.py @ 501:7a69433f0b1c

Ignore more stuff in mercurial.
author Ludovic Chabant <ludovic@chabant.com>
date Mon, 20 Apr 2020 16:21:47 -0700
parents 91652f4b9752
children 5bbc05a69f4c
line wrap: on
line source

import os
import os.path
import sys
import stat
import shutil
import argparse
import functools
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')])


@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'))
    ])

    if cfg.has_section('vimbundles'):
        bundle_dir = _p('vim', 'bundle')
        os.makedirs(bundle_dir, exist_ok=True)
        existing_plugins = set(os.listdir(bundle_dir))

        for name, url in cfg.items('vimbundles'):
            path = os.path.join(bundle_dir, name)
            if url.startswith('[git]'):
                clone_git(url[len('[git]'):], path, force=force)
            else:
                clone_hg(url, path, force=force)

            existing_plugins.discard(name)

        for name in existing_plugins:
            print("Removing plugin %s" % name)
            path = os.path.join(bundle_dir, name)
            rmtree(path)


@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/allpaths/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():
    # On Windows, u-ctags has a bug where it outputs double-backslashes.
    if is_windows:
        writelines('~/ctags.d/global.ctags', [
            '--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


def clone_git(url, path, force=False):
    if _is_non_empty_dir_with(path, '.git'):
        if not force:
            print("git pull origin master %s" % path)
            subprocess.check_call(['git', 'pull', 'origin', 'master'],
                                  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[len('[git]'):], full_path, force=force)
        else:
            clone_hg(url, full_path, force=force)


@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'))

    # 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 = os.path.join(dotfiles_dir, '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)
            print("ERROR: %s" % ex)
            if isinstance(ex, FatalInstallerError):
                print("Aborting all remaining installs because '%s' failed!" % name)
                break
            else:
                print("Skipping install of '%s'." % name)
    if failed_installs:
        for name in failed_installs:
            print("ERROR: failed to install '%s'." % name)


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


if __name__ == '__main__':
    main()