diff --git a/config.ini b/config.ini index fecf2b8..c5a0943 100644 --- a/config.ini +++ b/config.ini @@ -1,9 +1,12 @@ +# items in the [home] section are copied into the home directory with the same +# relative location as they appear in the dotfiles repo [home] -dirs= +files= + # sync vim plugin directories but not all of .vim .vim/ftplugin .vim/pack -files= + # individual files to sync .bash_profile .bashrc .screenrc @@ -11,3 +14,13 @@ files= .vimrc .config/htop/htoprc .config/lazydocker/config.yml + +# items in map.posix define a place where config files will be placed on posix +# systems relative to the user's home directory +[map.posix] +nvim/init.vim=.config/nvim/init.vim + +# items in the map.windows section defines a place where config files will be +# placed on windows, relative to the user's home directory +[map.windows] +nvim/init.vim=AppData/Local/nvim/init.vim diff --git a/install b/install index afbf5ed..bcd9f28 100755 --- a/install +++ b/install @@ -2,5 +2,6 @@ """ installs preferences """ -from installer import install -install() +from installer import Installer +installer = Installer() +installer.run() diff --git a/installer/__init__.py b/installer/__init__.py index ab12045..ff4e228 100644 --- a/installer/__init__.py +++ b/installer/__init__.py @@ -14,13 +14,7 @@ from functools import cached_property from installer import host from installer.options import Options from installer import log - -def install(): - """ - runs our install process based on cli arguments - """ - installer = Installer() - installer.run() +from installer import targets class Installer: """ @@ -28,86 +22,75 @@ class Installer: """ def __init__(self): self.options = Options.from_cli_args() - self.config_path = "include.json" def run(self): """ runs the install process """ - if home_files := self.config.get('home_files'): - for name in home_files: - self.install_home_file(name) + if host.is_windows and not host.is_admin: + print("You are not admin: admin is required on Windows") + os.exit(1) + + print("linking in home files") + home = self.options.config['home'] + home_files = filter(None, home['files'].splitlines()) + for fname in home_files: + print(f"\n{fname}") + path = pathlib.Path(fname) + self.map_file(path, path) + + if host.is_linux: + self.map_section('map.posix') + self.map_section('map.windows') - def install_home_file(self, name): + if host.is_windows: + self.map_section('map.windows') + + @cached_property + def targets(self): """ - installs a given file + defines all of the places where preferences files will be installed """ - log.debug("install: %s", name) + if host.is_linux: + return [targets.Linux(), targets.WSLHost()] + + if host.is_windows: + return [targets.Windows()] + + return [] + + def map_file(self, source_path, target_path): + if not source_path.is_absolute(): + source_path = host.dotfiles_root / source_path - source_path = self.prefs_dir / name - log.debug(" source: %s", source_path) + print(f"source path: {source_path}") + print(f"source drive: {source_path.drive}") if not source_path.exists(): - log.warning("home file source path %s does not exist", source_path) + print('skip: no such file') return - if not source_path.is_file(): - log.warning("home file source path %s is not a file", source_path) - return + for target in self.targets: + target.map_file(source_path, target_path) - if host.is_linux: - target_path = pathlib.Path.home() / name - log.debug(" target: %s", target_path) - if target_path.exists(): - log.debug(" target path exists, will remove") - target_path.unlink() - log.debug(" link: %s -> %s", target_path, source_path) - target_path.symlink_to(source_path) - - if host.is_wsl: - target_path = self.windows_home_dir / pathlib.PureWindowsPath(name) - log.debug(" target: %s", target_path) - if target_path.exists(): - log.debug(" target path exists, will remove") - target_path.unlink() - log.debug(" copy: %s -> %s", source_path, target_path) - shutil.copy(source_path, target_path) + @property + def config_path(self): + # pylint: disable=missing-function-docstring + return self.options.config @cached_property def config(self): """ - loads the json configuration + the contents of our configuration file """ with open(self.config_path, 'r', encoding='utf-8') as config_fp: log.debug("loading config from path %s", self.config_path) return json.load(config_fp) - @property - def prefs_dir(self): - """ - directory containing our preferences repo - """ - here = pathlib.Path(os.path.realpath(__file__)) - return here.parent.parent - - @cached_property - def windows_home_dir(self): - """ - Finds the home directory of the user's Windows home directory and - returns it as a Posix path representing its mount point from the - perspective of WSL. - """ - if not host.is_wsl: - raise Exception("cannot get windows home dir from anything other than wsl") - res = subprocess.run(['wslvar', 'USERPROFILE'], check=False, - capture_output=True) - winpath = res.stdout.decode('utf-8').strip() - res = subprocess.run(['wslpath', winpath], check=False, - capture_output=True) - return pathlib.Path(res.stdout.decode('utf-8').strip()) - - def setup_file(self, fname): - """ - sets up an individual file - """ - source = self.prefs_dir / fname - print(source) + def map_section(self, section_name): + section = self.options.config[section_name] + for source_name in section: + target_name = section[source_name] + source_path = pathlib.Path(source_name) + target_path = pathlib.Path(target_name) + print(f"Map {source_path} to {target_path}") + self.map_file(source_path, target_path) diff --git a/installer/host.py b/installer/host.py index 5db43e6..b381dfc 100644 --- a/installer/host.py +++ b/installer/host.py @@ -3,8 +3,11 @@ host module represents our host: the machine on which the installer script is running. On WSL, that means we're on Linux """ -import sys +import ctypes +import os import platform +import sys +import pathlib from functools import cached_property class _Host: @@ -26,4 +29,30 @@ class _Host: """ return platform.system() == 'Linux' + @cached_property + def is_windows(self): + """ + true if we're on Windows (and running Python from Windows) + """ + return platform.system() == 'Windows' + + @cached_property + def is_admin(self): + """ + tells us whether the running user has admin powers or not + """ + try: + return os.getuid() == 0 + except AttributeError: + return ctypes.windll.shell32.IsUserAnAdmin() != 0 + + @property + def dotfiles_root(self): + """ + directory containing our preferences repo + """ + here = pathlib.Path(os.path.realpath(__file__)) + return here.parent.parent + + sys.modules[__name__] = _Host() diff --git a/installer/options.py b/installer/options.py index 8ab1a19..9b07a94 100644 --- a/installer/options.py +++ b/installer/options.py @@ -3,8 +3,10 @@ cli options """ import argparse +import configparser +import pathlib -from installer import log +from installer import log, host class Options: """ @@ -24,10 +26,7 @@ class Options: """) parser.add_argument('-v', '--verbose', action='store_true') parser.add_argument('-q', '--quiet', action='store_true') - parser.add_argument('-c', '--config', default='config.ini', - help="path to config file", - metavar='config.ini', - type=argparse.FileType('r', encoding='utf-8')) + parser.add_argument('-c', '--config', help="path to config file") options = cls() parser.parse_args(namespace=options) @@ -64,4 +63,12 @@ class Options: @config.setter def config(self, val): - self._config = val + if val is None: + path = host.dotfiles_root / "config.ini" + else: + path = pathlib.Path(val) + + with open(path, 'r', encoding='utf-8') as config_file: + parser = configparser.ConfigParser() + parser.read_file(config_file) + self._config = parser diff --git a/installer/targets.py b/installer/targets.py index a5e6da1..0e3bd81 100644 --- a/installer/targets.py +++ b/installer/targets.py @@ -1,3 +1,148 @@ """ defines target classes: the places where things should be written """ + +import pathlib +import subprocess +import shutil +import os +from functools import cached_property + +from installer import host + +class Target: + """ + base class of all target platforms + """ + def target_path(self, relpath): + """ + computes the path of a file in the home directory + """ + return self.target_root / relpath + + @cached_property + def target_root(self): + """ + locates the home directory + """ + return pathlib.Path.home() + + def map_file(self, source_path, target_path): + """ + maps a file from a source path to some target path. The source path is + expected to be an absolute path, while the target_path is a relative + path, relative to the target's root. + """ + if not target_path.is_absolute(): + target_path = self.target_root / target_path + + print(f"target path: {target_path}") + print(f"target drive: {target_path.drive}") + if not target_path.parent.exists(): + print("creating missing parent directories for target") + parent_dir = target_path.parent + parent_dir.mkdir(parents=True) + + print(f"checking if target path {target_path.parts} exists") + exists = os.path.exists(str(target_path)) + print(f"exists: {exists}") + is_link = os.path.islink(str(target_path)) + print(f"is link: {is_link}") + if target_path.exists(): + print("target path exists") + if target_path.is_symlink(): + print("target path is symlink") + if target_path.resolve() == source_path: + print("symlink is up to date") + return + print("removing out of date symlink") + target_path.unlink() + elif target_path.is_file(): + print("removing existing regular file") + target_path.unlink() + elif target_path.is_dir(): + print("skip: target path is existing directory") + return + else: + print("skip: target path already exists") + return + else: + print("target path does not exist") + if target_path.is_symlink(): + # ok this deserves some explaining: if a symlink points to + # itself, then pathlib considers it to not exist. It can't be + # resolved because it's an infinite loop, but a non-existent + # file that is a symlink is a circular reference in a symlink. + print("removing broken symlink") + target_path.unlink() + print("creating symlink") + target_path.symlink_to(source_path) + +class Linux(Target): + """ + defines a local Linux target: the local machine when the script is run on + Linux + """ + +class Windows(Target): + """ + defines a local Windows target: the local machine when the script is run on + Windows + """ + +class WSLHost(Target): + """ + defines the Windows machine on which the WSL instance is hosted + """ + @cached_property + def target_root(self): + if not host.is_wsl: + raise Exception("cannot get windows home dir from anything other than wsl") + res = subprocess.run(['wslvar', 'USERPROFILE'], check=False, + capture_output=True) + winpath = res.stdout.decode('utf-8').strip() + res = subprocess.run(['wslpath', winpath], check=False, + capture_output=True) + return pathlib.Path(res.stdout.decode('utf-8').strip()) + + def map_file(self, source_path, target_path): + """ + maps a file from a source path to some target path. The source path is + expected to be an absolute path, while the target_path is a relative + path, relative to the target's root. + """ + if source_path.is_file(): + clone = shutil.copy + elif source_path.is_dir(): + clone = shutil.copytree + else: + print(f"source path {source_path} is not a file or directory") + return + + if not target_path.is_absolute(): + target_path = self.target_root / target_path + + print(f"target path: {target_path}") + print(f"target drive: {target_path.drive}") + if not target_path.parent.exists(): + print("creating missing parent directories for target") + parent_dir = target_path.parent + parent_dir.mkdir(parents=True) + + print(f"checking if target path {target_path.parts} exists") + exists = os.path.exists(str(target_path)) + print(f"exists: {exists}") + is_link = os.path.islink(str(target_path)) + print(f"is link: {is_link}") + if target_path.exists(): + print("target path exists") + if target_path.is_file(): + print("removing existing regular file") + target_path.unlink() + else: + print("skip: target path exists and is not a regular file") + return + else: + print("target path does not exist") + print("copying file to target") + clone(source_path, target_path)