dotfm

My dotfile manager
git clone git://src.gearsix.net/dotfm
Log | Files | Refs | Atom | README | LICENSE

dotfm.py (13831B)


      1 #!/usr/bin/env python3
      2 
      3 #=========================
      4 # dotfm - dotfile manager
      5 #=========================
      6 # authors: gearsix
      7 # created: 2020-01-15
      8 # updated: 2021-09-06
      9 
     10 #---------
     11 # IMPORTS
     12 #---------
     13 # std
     14 import sys
     15 import os
     16 import csv
     17 import logging
     18 import argparse
     19 
     20 #---------
     21 # GLOBALS
     22 #---------
     23 NAME = os.path.basename(__file__)
     24 HOME = os.getenv('HOME')
     25 ARGS = []
     26 EDITOR = os.getenv('EDITOR') or 'nano'
     27 VERSION = 'v2.2.1'
     28 INSTALLED = []
     29 INSTALLED_FILE = ''
     30 if sys.platform == 'linux' or sys.platform == 'linux2':
     31 	INSTALLED_FILE = '{}/.local/share/dotfm/installed.csv'.format(HOME)
     32 elif sys.platform == 'darwin':
     33 	INSTALLED_FILE = '{}/Library/Application Support/dotfm/installed.csv'.format(HOME)
     34 elif sys.platform == 'win32' or sys.platform == 'cygwin' or sys.platform == 'msys':
     35 	INSTALLED_FILE = '{}/Local/dotfm/installed.csv'.format(os.getenv('APPDATA'))
     36 else:
     37 	print('warning: unsupported system, things might break')
     38 KNOWN = [ # dotfiles that dotfm knows by default
     39     # install location, aliases...
     40     [INSTALLED_FILE, 'dotfm'],
     41     ['{}/.bashrc'.format(HOME), '.bashrc', 'bashrc'],
     42     ['{}/.bash_profile'.format(HOME), '.bash_profile', 'bash_profile'],
     43     ['{}/.profile'.format(HOME), '.profile', 'profile'],
     44     ['{}/.zshrc'.format(HOME), '.zshrc', 'zshrc'],
     45     ['{}/.zprofile'.format(HOME), '.zprofile', 'zprofile'],
     46     ['{}/.zshenv'.format(HOME), '.zshenv', 'zshenv'],
     47     ['{}/.ssh/config'.format(HOME), 'ssh_config'],
     48     ['{}/.vimrc'.format(HOME), '.vimrc', 'vimrc'],
     49     ['{}/.config/nvim/init.vim'.format(HOME), 'init.vim', 'nvimrc'],
     50     ['{}/.gitconfig'.format(HOME), '.gitconfig', 'gitconfig'],
     51     ['{}/.gitmessage'.format(HOME), '.gitmessage', 'gitmessage'],
     52     ['{}/.gitignore'.format(HOME), '.gitignore', 'gitignore'],
     53     ['{}/.gemrc'.format(HOME), '.gemrc', 'gemrc'],
     54     ['{}/.tmux.conf'.format(HOME), '.tmux.conf', 'tmux.conf'],
     55     ['{}/.config/user-dirs.dirs'.format(HOME), 'user-dirs.dirs', 'xdg-user-dirs'],
     56     ['{}/.xinitrc'.format(HOME), '.xinitrc', 'xinitrc'],
     57     ['{}/.config/rc.conf'.format(HOME), 'rc.conf', 'ranger.conf', 'ranger.cfg'],
     58     ['{}/.config/neofetch/config'.format(HOME), 'config', 'neofetch.conf', 'neofetch.cfg'],
     59     ['{}/.config/sway/config'.format(HOME), 'config', 'sway.cfg', 'sway.conf'],
     60     ['{}/.config/awesome/rc.lua'.format(HOME), 'rc.lua', 'awesomerc'],
     61     ['{}/.config/i3/config'.format(HOME), 'config', 'i3.conf', 'i3.cfg', 'i3'],
     62     ['{}/.emacs'.format(HOME), '.emacs', 'emacs'],
     63     ['{}/.sfeed/sfeedrc'.format(HOME), '.sfeedrc', 'sfeedrc'],
     64     ['{}/.config/txtnish/config'.format(HOME), 'txtnish', 'txtnish_config'],
     65 ]
     66 
     67 #-----------
     68 # FUNCTIONS
     69 #-----------
     70 # utilities
     71 def ask(message):
     72     return input('dotfm | {} '.format(message))
     73 
     74 def log(message):
     75     print('dotfm | {}'.format(message))
     76     
     77 def debug(message):
     78     if ARGS.debug == True:
     79         log(message)
     80         
     81 def info(message):
     82     if ARGS.quiet == False:
     83         log(message)
     84 
     85 def warn(message):
     86     ask('{}, press key to continue'.format(message))
     87 
     88 # main
     89 def parseargs():
     90     valid_commands = ['install', 'in', 'update', 'up', 'link', 'ln', 'remove', 'rm', 'edit', 'ed', 'list', 'ls']
     91     parser = argparse.ArgumentParser(description='a simple tool to help you manage your dotfile symlinks.')
     92     # OPTIONS
     93     parser.add_argument('-s', '--skip', action='store_true',
     94             help='skip any user prompts and use default values where possible')
     95     parser.add_argument('-d', '--debug', action='store_true',
     96             help='display debug logs')
     97     parser.add_argument('-v', '--version', action='version',
     98             version='%(prog)s {}'.format(VERSION))
     99     parser.add_argument('-q', '--quiet', action='store_true',
    100             help='mute dotfm info logs')
    101     # POSITIONAL
    102     parser.add_argument('cmd', metavar='COMMAND', choices=valid_commands,
    103             help='the dotfm COMMAND to execute: {}'.format(valid_commands))
    104     parser.add_argument('dotfile', metavar='DOTFILE', nargs=argparse.REMAINDER,
    105             help='the target dotfile to execute COMMAND on')
    106     return parser.parse_args()
    107 
    108 def writeinstalled():
    109     with open(INSTALLED_FILE, "w") as dotfm_csv_file:
    110         dotfm_csv_writer = csv.writer(dotfm_csv_file, lineterminator='\n')
    111         for dfl in INSTALLED:
    112             dotfm_csv_writer.writerow(dfl)
    113         dotfm_csv_file.close()
    114 
    115 def isdotfile(dotfile_list, query):
    116     query = os.path.basename(query)
    117     debug('checking for {}'.format(query))
    118     found = -1
    119     for d, dfl in enumerate(dotfile_list):
    120         if query == os.path.basename(dfl[0]) or query in dfl:
    121             found = d
    122         if found != -1:
    123             debug('dotfile {} matches known dotfile alias for {}'.format(query, dfl[0]))
    124             break
    125     return found
    126 
    127 def clearduplicates(dotfile_list, id_index=0):
    128     for i, d in enumerate(dotfile_list):
    129         if len(d) == 0:
    130             continue
    131         for j, dd in enumerate(dotfile_list):
    132             if len(dd) == 0:
    133                 continue
    134             if j > i and dd[id_index] == d[id_index]:
    135                 dotfile_list.remove(d)
    136                 break
    137 
    138 # main/init
    139 def init():
    140     debug('init...')
    141     if not os.path.exists(INSTALLED_FILE):
    142         debug('{} not found'.format(INSTALLED_FILE))
    143         init_createcsv(INSTALLED_FILE)
    144     init_loadcsv(INSTALLED_FILE)
    145     clearduplicates(INSTALLED)
    146     debug('loaded dotfile list: {}'.format(INSTALLED))
    147 
    148 def init_createcsv(default_location):
    149     location = default_location
    150     if ARGS.skip == False:
    151         info('default dotfm csv file location: "{}"'.format(default_location))
    152         location = ask('dotfm csv file location (enter for default)? ')
    153         if len(location) == 0:
    154             location = default_location
    155         if os.path.exists(location):
    156             debug('{} already exists'.format(location))
    157             on = ask('[o]verwrite or [u]se {}? '.format(location))
    158             if len(on) > 0:
    159                 if on[0] == 'o': # create file at location & write KNOWN[0] to it
    160                     warn('overwriting {}, all existing data in this file will be lost'.format(location))
    161                     os.makedirs(os.path.dirname(location), exist_ok=True)
    162                     dotfm_csv = open(location, "w")
    163                     for i, dfl in enumerate(KNOWN[0]):
    164                         dotfm_csv.write(dfl if i == 0 else ',{}'.format(dfl))
    165                     dotfm_csv.write('\n')
    166                     dotfm_csv.close()
    167                 elif on[0] == 'u':
    168                     debug('using pre-existing csv {}'.format(location))
    169                     sys.exit()
    170 
    171     # create default_location symlink
    172     if os.path.abspath(location) != os.path.abspath(default_location):
    173         debug('creating dotfm csv file symlink')
    174         os.makedirs(os.path.dirname(default_location), exist_ok=True)
    175         os.system('ln -isv', os.path.abspath(location), default_location)
    176     else:
    177         os.makedirs(os.path.dirname(location), exist_ok=True)
    178         f = open(location, "w")
    179         f.close()
    180 
    181 def init_loadcsv(location):
    182     dotfm_csv = open(location, "r")
    183     dotfm_csv_reader = csv.reader(dotfm_csv)
    184     for dfl in dotfm_csv_reader:
    185         INSTALLED.append(dfl)
    186     dotfm_csv.close()
    187 
    188 # main/install
    189 def install(dotfile):
    190     info('installing {}...'.format(dotfile))
    191     known = isdotfile(KNOWN, dotfile)
    192     location = install_getlocation(known)
    193     aliases = install_getaliases(known)
    194     if not os.path.exists(os.path.dirname(location)):
    195         os.makedirs(os.path.dirname(location), exist_ok=True)
    196     if dotfile != location:
    197         if os.path.lexists(location):
    198             install_oca(dotfile, location)
    199         os.system('ln -vs {} {}'.format(dotfile, location))
    200     debug('appending to {} installed...'.format(location))
    201     aliases.insert(0, location)
    202     INSTALLED.append(aliases)
    203     clearduplicates(INSTALLED)
    204     info('success - you might need to re-open the terminal to see changes take effect')
    205 
    206 def install_getlocation(known_index, msg='install location?'):
    207     default = ''
    208     if known_index != -1:
    209         default = KNOWN[known_index][0]
    210         info('default install location is "{}"'.format(default))
    211         msg = 'install location (enter for default):'.format(default)
    212     if len(default) > 0 and ARGS.skip == True:
    213         return default
    214     location = ''
    215     while location == '':
    216         location = ask(msg)
    217         if len(location) == 0 and len(default) > 0:
    218             return default
    219         elif location.find('~') != -1:
    220             return location.replace('~', HOME)
    221         else:
    222             debug('invalid location "{}"'.format(location))
    223             location = ''
    224 
    225 def install_getaliases(known_index):
    226     default = ''
    227     if known_index != -1:
    228         default = KNOWN[known_index][1:]
    229         info('default aliases are "{}"'.format(' '.join(default)))
    230     if len(default) > 0 and ARGS.skip == True:
    231         return default
    232     aliases = ''
    233     while aliases == '':
    234         aliases = ask('dotfile aliases (enter for default): '.format(
    235             ('defaults', default) if len(default) > 0 else ''))
    236         if len(aliases) > 0:
    237             return aliases.split(' ')
    238         elif len(default) > 0:
    239             return default
    240 
    241 def install_oca(dotfile, location):
    242     oca = ''
    243     while oca == '':
    244         oca = ask('{} already exists, [o]verwrite/[c]ompare/[a]bort? '.format(location))
    245         if len(oca) > 0:
    246             if oca[0] == 'o': # overwrite
    247                 os.remove(location)
    248             elif oca[0] == 'c': # compare
    249                 debug('comparing {} to {}'.format(dotfile, location))
    250                 os.system('diff -bys {} {}'.format(dotfile, location))
    251                 oca = ''
    252             elif oca[0] == 'a': # abort
    253                 debug('aborting install')
    254                 sys.exit()
    255             else:
    256                 oca = ''
    257     return oca
    258 
    259 # main/update
    260 def update(alias, location):
    261     debug('updating {} -> {}'.format(alias, location))
    262     known = isdotfile(INSTALLED, alias)
    263     if known != -1:
    264         os.system('ln -isv {} {}'.format(location, INSTALLED[known][0]))
    265     else:
    266         warn('{} is unrecognised, installing'.format(dotfile))
    267         install(location)
    268 
    269 # main/link
    270 def link(dotfile):
    271     dotfm_dir='~/.dotfiles/'
    272 
    273     if 'DFMDIR' in os.environ:
    274         dotfm_dir = os.environ('DFMDIR')
    275     else:
    276         log('default dotfm dir: "{}"'.format(dotfm_dir))
    277         d = ask('link to (enter for default)? '.format(dotfm_dir))
    278         if os.path.exists(d):
    279             dotfm_dir=d
    280 
    281     dotfm_dir = dotfm_dir.replace('~', HOME)
    282     os.makedirs(dotfm_dir, exist_ok=True)
    283 
    284     target=os.path.join(dotfm_dir, os.path.basename(dotfile))
    285 
    286     debug('linking {} -> {}'.format(dotfile, target))
    287     if not os.path.exists(dotfile):
    288         answer = ask('"{}" does not exist, create [y/n]?'.format(dotfile))
    289         debug(answer)
    290         if answer[0] == 'y':
    291             f = open(dotfile, 'w')
    292             f.close()
    293         else:
    294             return
    295     if os.path.exists(target):
    296         answer = install_oca(dotfile, target)
    297     os.link(dotfile, target)
    298 
    299 # main/remove
    300 def remove(dotfile):
    301     debug('removing {}'.format(dotfile))
    302 
    303     index = isdotfile(INSTALLED, dotfile)
    304     if index == -1:
    305         warn('could not find dotfile "{}"'.format(dotfile))
    306         return
    307     dotfile = os.path.abspath(INSTALLED[index][0])
    308     confirm = ''
    309     while confirm == '':
    310         confirm = ask('remove "{}", are you sure [y/n]?'.format(dotfile))
    311     try:
    312         os.remove(dotfile)
    313     except OSError as err:
    314         warn('cannot remove "{}"...\n{}'.format(dotfile, err))
    315     del INSTALLED[index]
    316     writeinstalled()
    317 
    318 # main/edit
    319 def edit(dotfile):
    320     debug('editing {}'.format(dotfile))
    321     index = isdotfile(INSTALLED, dotfile)
    322     if index == -1:
    323         if edit_promptinstall(dotfile) == 'y':
    324             index = isdotfile(INSTALLED, dotfile)
    325         else:
    326             return
    327     target = INSTALLED[index][0]
    328     os.system('{} {}'.format(EDITOR, target))
    329 
    330 def edit_promptinstall(dotfile):
    331     yn = '-'
    332     while yn[0] != 'y' and yn[0] != 'n':
    333         yn = ask('could not find installed dotfile matching "{}", install [y/n]? '.format(dotfile))
    334         if len(yn) == 0:
    335             yn = '-'
    336         if yn[0] == 'y':
    337             install(install_getlocation(-1, msg='input source path:'))
    338     return yn[0]
    339 
    340 # main/list
    341 def list(dotfiles):
    342     debug('listing dotfiles: {}'.format(dotfiles))
    343     if len(dotfiles) == 0:
    344         os.system('cat "{}" | sed "s/,/\t/g"'.format(INSTALLED_FILE))
    345     else:
    346         data = ''
    347         for d in dotfiles:
    348             for i in INSTALLED:
    349                 if d in i:
    350                     data += '{},'.format(i[0])
    351                     for alias in i[1:]:
    352                         data += ',{}'.format(alias)
    353                     data += '\n'
    354         os.system('printf "LOCATION,ALIASES...\n{}" | column -t -s ,'.format(data))
    355 
    356 #------
    357 # MAIN
    358 #------
    359 if __name__ == '__main__':
    360     ARGS = parseargs()
    361     if ARGS.debug == True:
    362         debug('printing debug logs')
    363         debug('args = {}'.format(ARGS))
    364     if ARGS.quiet == True:
    365         debug('muting info logs')
    366 
    367     init()
    368     if ARGS.cmd == 'install' or ARGS.cmd == 'in':
    369         for d in ARGS.dotfile:
    370             install(os.path.abspath(d))
    371     elif ARGS.cmd == 'update' or ARGS.cmd == 'up':
    372         if len(ARGS.dotfile) < 2:
    373             debug('invalid number of arguments')
    374             info('usage: "dotfm update DOTFILE LOCATION"')
    375             sys.exit()
    376         update(ARGS.dotfile[0], ARGS.dotfile[1])
    377     elif ARGS.cmd == 'link' or ARGS.cmd == 'ln':
    378         for d in ARGS.dotfile:
    379             link(d)
    380     elif ARGS.cmd == 'remove' or ARGS.cmd == 'rm':
    381         for d in ARGS.dotfile:
    382             remove(d)
    383     elif ARGS.cmd == 'edit' or ARGS.cmd == 'ed':
    384         for d in ARGS.dotfile:
    385             edit(d)
    386     elif ARGS.cmd == 'list' or ARGS.cmd == 'ls':
    387         list(ARGS.dotfile)
    388     writeinstalled()
    389