dotfm

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

dotfm.py (raw) (13866B)


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