1
0
Fork 0
mirror of synced 2024-11-22 16:25:34 -05:00

Merge remote-tracking branch 'upstream/master' into add-plugin-config-loading

This commit is contained in:
Mike Hennessy 2023-02-24 21:49:57 -05:00
commit 442b8483db
No known key found for this signature in database
101 changed files with 2720 additions and 2405 deletions

View file

@ -15,6 +15,3 @@ indent_size = 4
[*.yml] [*.yml]
indent_size = 2 indent_size = 2
[*.md]
trim_trailing_whitespace = false

View file

@ -5,17 +5,44 @@ on:
schedule: schedule:
- cron: '0 8 * * 6' - cron: '0 8 * * 6'
jobs: jobs:
build: test:
runs-on: ubuntu-latest env:
PIP_DISABLE_PIP_VERSION_CHECK: 1
strategy: strategy:
fail-fast: false
matrix: matrix:
python: [2.7, pypy2, 3.5, 3.6, 3.7, 3.8, 3.9, pypy3] os: ["ubuntu-20.04", "macos-latest"]
name: Python ${{ matrix.python }} python: ["2.7", "pypy-2.7", "3.5", "3.6", "3.7", "3.8", "3.9", "3.10", "pypy-3.9"]
include:
- os: "windows-latest"
python: "3.8"
- os: "windows-latest"
python: "3.9"
- os: "windows-latest"
python: "3.10"
runs-on: ${{ matrix.os }}
name: "Test: Python ${{ matrix.python }} on ${{ matrix.os }}"
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v3
with: with:
submodules: recursive submodules: recursive
- uses: actions/setup-python@v2 - uses: actions/setup-python@v4
with: with:
python-version: ${{ matrix.python }} python-version: ${{ matrix.python }}
- run: ./test/test - name: "Install dependencies"
run: |
python -m pip install --upgrade pip setuptools
python -m pip install tox tox-gh-actions
- name: "Run tests"
run: |
python -m tox
python -m tox -e coverage_report
- uses: codecov/codecov-action@v3
fmt:
name: Format
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v3
- uses: psf/black@stable
- uses: isort/isort-action@v1

7
.gitignore vendored
View file

@ -1,4 +1,11 @@
*.egg-info *.egg-info
*.pyc *.pyc
.coverage*
.eggs/
.idea/
.tox/
.venv/
build/ build/
coverage.xml
dist/ dist/
htmlcov/

View file

@ -67,7 +67,9 @@ touch install.conf.yaml
``` ```
If you are using PowerShell instead of a POSIX shell, you can use the provided If you are using PowerShell instead of a POSIX shell, you can use the provided
`install.ps1` script instead of `install`. `install.ps1` script instead of `install`. On Windows, Dotbot only supports
Python 3.8+, and it requires that your account is [allowed to create symbolic
links][windows-symlinks].
To get started, you just need to fill in the `install.conf.yaml` and Dotbot To get started, you just need to fill in the `install.conf.yaml` and Dotbot
will take care of the rest. To help you get started we have [an will take care of the rest. To help you get started we have [an
@ -481,7 +483,7 @@ Copyright (c) 2014-2021 Anish Athalye. Released under the MIT License. See
[init-dotfiles]: https://github.com/Vaelatern/init-dotfiles [init-dotfiles]: https://github.com/Vaelatern/init-dotfiles
[dotfiles-template]: https://github.com/anishathalye/dotfiles_template [dotfiles-template]: https://github.com/anishathalye/dotfiles_template
[inspiration]: https://github.com/anishathalye/dotbot/wiki/Users [inspiration]: https://github.com/anishathalye/dotbot/wiki/Users
[managing-dotfiles-post]: http://www.anishathalye.com/2014/08/03/managing-your-dotfiles/ [windows-symlinks]: https://learn.microsoft.com/en-us/windows/security/threat-protection/security-policy-settings/create-symbolic-links
[json2yaml]: https://www.json2yaml.com/ [json2yaml]: https://www.json2yaml.com/
[plugins]: https://github.com/anishathalye/dotbot/wiki/Plugins [plugins]: https://github.com/anishathalye/dotbot/wiki/Plugins
[wiki]: https://github.com/anishathalye/dotbot/wiki [wiki]: https://github.com/anishathalye/dotbot/wiki

View file

@ -1,4 +1,4 @@
from .cli import main from .cli import main
from .plugin import Plugin from .plugin import Plugin
__version__ = '1.19.0' __version__ = "1.19.1"

View file

@ -1,54 +1,87 @@
import os, glob import glob
import sys
from argparse import ArgumentParser, RawTextHelpFormatter
from .config import ConfigReader, ReadingError
from .dispatcher import Dispatcher, DispatchError
from .messenger import Messenger
from .messenger import Level
from .util import module
import dotbot
import os import os
import subprocess import subprocess
import sys
from argparse import ArgumentParser, RawTextHelpFormatter
import dotbot
from .config import ConfigReader, ReadingError
from .dispatcher import Dispatcher, DispatchError
from .messenger import Level, Messenger
from .plugins import Clean, Create, Link, Plugins, Shell
from .util import module
def add_options(parser): def add_options(parser):
parser.add_argument('-Q', '--super-quiet', action='store_true', parser.add_argument(
help='suppress almost all output') "-Q", "--super-quiet", action="store_true", help="suppress almost all output"
parser.add_argument('-q', '--quiet', action='store_true', )
help='suppress most output') parser.add_argument("-q", "--quiet", action="store_true", help="suppress most output")
parser.add_argument('-v', '--verbose', action='count', default=0, parser.add_argument(
help='enable verbose output\n' "-v",
'-v: typical verbose\n' "--verbose",
'-vv: also, set shell commands stderr/stdout to true') action="count",
parser.add_argument('-d', '--base-directory', default=0,
help='execute commands from within BASEDIR', help="enable verbose output\n"
metavar='BASEDIR') "-v: typical verbose\n"
parser.add_argument('-c', '--config-file', "-vv: also, set shell commands stderr/stdout to true",
help='run commands given in CONFIGFILE', metavar='CONFIGFILE') )
parser.add_argument('-p', '--plugin', action='append', dest='plugins', default=[], parser.add_argument(
help='load PLUGIN as a plugin', metavar='PLUGIN') "-d", "--base-directory", help="execute commands from within BASEDIR", metavar="BASEDIR"
parser.add_argument('--disable-built-in-plugins', )
action='store_true', help='disable built-in plugins') parser.add_argument(
parser.add_argument('--plugin-dir', action='append', dest='plugin_dirs', default=[], "-c", "--config-file", help="run commands given in CONFIGFILE", metavar="CONFIGFILE"
metavar='PLUGIN_DIR', help='load all plugins in PLUGIN_DIR') )
parser.add_argument('--only', nargs='+', parser.add_argument(
help='only run specified directives', metavar='DIRECTIVE') "-p",
parser.add_argument('--except', nargs='+', dest='skip', "--plugin",
help='skip specified directives', metavar='DIRECTIVE') action="append",
parser.add_argument('--force-color', dest='force_color', action='store_true', dest="plugins",
help='force color output') default=[],
parser.add_argument('--no-color', dest='no_color', action='store_true', help="load PLUGIN as a plugin",
help='disable color output') metavar="PLUGIN",
parser.add_argument('--version', action='store_true', )
help='show program\'s version number and exit') parser.add_argument(
parser.add_argument('-x', '--exit-on-failure', dest='exit_on_failure', action='store_true', "--disable-built-in-plugins", action="store_true", help="disable built-in plugins"
help='exit after first failed directive') )
parser.add_argument(
"--plugin-dir",
action="append",
dest="plugin_dirs",
default=[],
metavar="PLUGIN_DIR",
help="load all plugins in PLUGIN_DIR",
)
parser.add_argument(
"--only", nargs="+", help="only run specified directives", metavar="DIRECTIVE"
)
parser.add_argument(
"--except", nargs="+", dest="skip", help="skip specified directives", metavar="DIRECTIVE"
)
parser.add_argument(
"--force-color", dest="force_color", action="store_true", help="force color output"
)
parser.add_argument(
"--no-color", dest="no_color", action="store_true", help="disable color output"
)
parser.add_argument(
"--version", action="store_true", help="show program's version number and exit"
)
parser.add_argument(
"-x",
"--exit-on-failure",
dest="exit_on_failure",
action="store_true",
help="exit after first failed directive",
)
def read_config(config_file): def read_config(config_file):
reader = ConfigReader(config_file) reader = ConfigReader(config_file)
return reader.get_config() return reader.get_config()
def main(): def main():
log = Messenger() log = Messenger()
try: try:
@ -58,12 +91,15 @@ def main():
if options.version: if options.version:
try: try:
with open(os.devnull) as devnull: with open(os.devnull) as devnull:
git_hash = subprocess.check_output(['git', 'rev-parse', 'HEAD'], git_hash = subprocess.check_output(
cwd=os.path.dirname(os.path.abspath(__file__)), stderr=devnull) ["git", "rev-parse", "HEAD"],
hash_msg = ' (git %s)' % git_hash[:10] cwd=os.path.dirname(os.path.abspath(__file__)),
stderr=devnull,
)
hash_msg = " (git %s)" % git_hash[:10]
except (OSError, subprocess.CalledProcessError): except (OSError, subprocess.CalledProcessError):
hash_msg = '' hash_msg = ""
print('Dotbot version %s%s' % (dotbot.__version__, hash_msg)) print("Dotbot version %s%s" % (dotbot.__version__, hash_msg))
exit(0) exit(0)
if options.super_quiet: if options.super_quiet:
log.set_level(Level.WARNING) log.set_level(Level.WARNING)
@ -82,43 +118,50 @@ def main():
else: else:
log.use_color(sys.stdout.isatty()) log.use_color(sys.stdout.isatty())
plugins = []
plugin_directories = list(options.plugin_dirs) plugin_directories = list(options.plugin_dirs)
if not options.disable_built_in_plugins: if not options.disable_built_in_plugins:
from .plugins import Clean, Create, Link, Shell, Plugins plugins.extend([Clean, Create, Link, Plugins, Shell])
plugin_paths = [] plugin_paths = []
for directory in plugin_directories: for directory in plugin_directories:
for plugin_path in glob.glob(os.path.join(directory, '*.py')): for plugin_path in glob.glob(os.path.join(directory, "*.py")):
plugin_paths.append(plugin_path) plugin_paths.append(plugin_path)
for plugin_path in options.plugins: for plugin_path in options.plugins:
plugin_paths.append(plugin_path) plugin_paths.append(plugin_path)
for plugin_path in plugin_paths: for plugin_path in plugin_paths:
abspath = os.path.abspath(plugin_path) abspath = os.path.abspath(plugin_path)
module.load(abspath) plugins.extend(module.load(abspath))
if not options.config_file: if not options.config_file:
log.error('No configuration file specified') log.error("No configuration file specified")
exit(1) exit(1)
tasks = read_config(options.config_file) tasks = read_config(options.config_file)
if tasks is None: if tasks is None:
log.warning('Configuration file is empty, no work to do') log.warning("Configuration file is empty, no work to do")
tasks = [] tasks = []
if not isinstance(tasks, list): if not isinstance(tasks, list):
raise ReadingError('Configuration file must be a list of tasks') raise ReadingError("Configuration file must be a list of tasks")
if options.base_directory: if options.base_directory:
base_directory = os.path.abspath(options.base_directory) base_directory = os.path.abspath(options.base_directory)
else: else:
# default to directory of config file # default to directory of config file
base_directory = os.path.dirname(os.path.abspath(options.config_file)) base_directory = os.path.dirname(os.path.abspath(options.config_file))
os.chdir(base_directory) os.chdir(base_directory)
dispatcher = Dispatcher(base_directory, only=options.only, skip=options.skip, dispatcher = Dispatcher(
exit_on_failure=options.exit_on_failure, options=options) base_directory,
only=options.only,
skip=options.skip,
exit_on_failure=options.exit_on_failure,
options=options,
plugins=plugins,
)
success = dispatcher.dispatch(tasks) success = dispatcher.dispatch(tasks)
if success: if success:
log.info('\n==> All tasks executed successfully') log.info("\n==> All tasks executed successfully")
else: else:
raise DispatchError('\n==> Some tasks were not executed successfully') raise DispatchError("\n==> Some tasks were not executed successfully")
except (ReadingError, DispatchError) as e: except (ReadingError, DispatchError) as e:
log.error('%s' % e) log.error("%s" % e)
exit(1) exit(1)
except KeyboardInterrupt: except KeyboardInterrupt:
log.error('\n==> Operation aborted') log.error("\n==> Operation aborted")
exit(1) exit(1)

View file

@ -1,8 +1,11 @@
import yaml
import json import json
import os.path import os.path
import yaml
from .util import string from .util import string
class ConfigReader(object): class ConfigReader(object):
def __init__(self, config_file_path): def __init__(self, config_file_path):
self._config = self._read(config_file_path) self._config = self._read(config_file_path)
@ -11,17 +14,18 @@ class ConfigReader(object):
try: try:
_, ext = os.path.splitext(config_file_path) _, ext = os.path.splitext(config_file_path)
with open(config_file_path) as fin: with open(config_file_path) as fin:
if ext == '.json': if ext == ".json":
data = json.load(fin) data = json.load(fin)
else: else:
data = yaml.safe_load(fin) data = yaml.safe_load(fin)
return data return data
except Exception as e: except Exception as e:
msg = string.indent_lines(str(e)) msg = string.indent_lines(str(e))
raise ReadingError('Could not read config file:\n%s' % msg) raise ReadingError("Could not read config file:\n%s" % msg)
def get_config(self): def get_config(self):
return self._config return self._config
class ReadingError(Exception): class ReadingError(Exception):
pass pass

View file

@ -2,10 +2,11 @@ import copy
import os import os
from argparse import Namespace from argparse import Namespace
class Context(object): class Context(object):
''' """
Contextual data and information for plugins. Contextual data and information for plugins.
''' """
def __init__(self, base_directory, options=Namespace()): def __init__(self, base_directory, options=Namespace()):
self._base_directory = base_directory self._base_directory = base_directory

View file

@ -15,10 +15,12 @@ class Dispatcher(object):
skip=None, skip=None,
exit_on_failure=False, exit_on_failure=False,
options=Namespace(), options=Namespace(),
plugins=None,
): ):
self._log = Messenger() self._log = Messenger()
self._setup_context(base_directory, options) self._setup_context(base_directory, options)
self._load_plugins() plugins = plugins or []
self._plugins = [plugin(self._context) for plugin in plugins]
self._only = only self._only = only
self._skip = skip self._skip = skip
self._exit = exit_on_failure self._exit = exit_on_failure
@ -60,8 +62,7 @@ class Dispatcher(object):
handled = True handled = True
except Exception as err: except Exception as err:
self._log.error( self._log.error(
"An error was encountered while executing action %s" "An error was encountered while executing action %s" % action
% action
) )
self._log.debug(err) self._log.debug(err)
if self._exit: if self._exit:
@ -77,9 +78,7 @@ class Dispatcher(object):
if action == "plugins": if action == "plugins":
# Create a list of loaded plugin names # Create a list of loaded plugin names
loaded_plugins = [ loaded_plugins = [plugin.__class__.__name__ for plugin in self._plugins]
plugin.__class__.__name__ for plugin in self._plugins
]
# Load plugins that haven't been loaded yet # Load plugins that haven't been loaded yet
for plugin in Plugin.__subclasses__(): for plugin in Plugin.__subclasses__():
@ -88,9 +87,6 @@ class Dispatcher(object):
return success return success
def _load_plugins(self):
self._plugins = [plugin(self._context) for plugin in Plugin.__subclasses__()]
class DispatchError(Exception): class DispatchError(Exception):
pass pass

View file

@ -1,2 +1,2 @@
from .messenger import Messenger
from .level import Level from .level import Level
from .messenger import Messenger

View file

@ -1,8 +1,8 @@
class Color(object): class Color(object):
NONE = '' NONE = ""
RESET = '\033[0m' RESET = "\033[0m"
RED = '\033[91m' RED = "\033[91m"
GREEN = '\033[92m' GREEN = "\033[92m"
YELLOW = '\033[93m' YELLOW = "\033[93m"
BLUE = '\033[94m' BLUE = "\033[94m"
MAGENTA = '\033[95m' MAGENTA = "\033[95m"

View file

@ -1,8 +1,9 @@
from ..util.singleton import Singleton
from ..util.compat import with_metaclass from ..util.compat import with_metaclass
from ..util.singleton import Singleton
from .color import Color from .color import Color
from .level import Level from .level import Level
class Messenger(with_metaclass(Singleton, object)): class Messenger(with_metaclass(Singleton, object)):
def __init__(self, level=Level.LOWINFO): def __init__(self, level=Level.LOWINFO):
self.set_level(level) self.set_level(level)
@ -15,8 +16,8 @@ class Messenger(with_metaclass(Singleton, object)):
self._use_color = yesno self._use_color = yesno
def log(self, level, message): def log(self, level, message):
if (level >= self._level): if level >= self._level:
print('%s%s%s' % (self._color(level), message, self._reset())) print("%s%s%s" % (self._color(level), message, self._reset()))
def debug(self, message): def debug(self, message):
self.log(Level.DEBUG, message) self.log(Level.DEBUG, message)
@ -34,13 +35,13 @@ class Messenger(with_metaclass(Singleton, object)):
self.log(Level.ERROR, message) self.log(Level.ERROR, message)
def _color(self, level): def _color(self, level):
''' """
Get a color (terminal escape sequence) according to a level. Get a color (terminal escape sequence) according to a level.
''' """
if not self._use_color: if not self._use_color:
return '' return ""
elif level < Level.DEBUG: elif level < Level.DEBUG:
return '' return ""
elif Level.DEBUG <= level < Level.LOWINFO: elif Level.DEBUG <= level < Level.LOWINFO:
return Color.YELLOW return Color.YELLOW
elif Level.LOWINFO <= level < Level.INFO: elif Level.LOWINFO <= level < Level.INFO:
@ -53,10 +54,10 @@ class Messenger(with_metaclass(Singleton, object)):
return Color.RED return Color.RED
def _reset(self): def _reset(self):
''' """
Get a reset color (terminal escape sequence). Get a reset color (terminal escape sequence).
''' """
if not self._use_color: if not self._use_color:
return '' return ""
else: else:
return Color.RESET return Color.RESET

View file

@ -1,25 +1,26 @@
from .messenger import Messenger
from .context import Context from .context import Context
from .messenger import Messenger
class Plugin(object): class Plugin(object):
''' """
Abstract base class for commands that process directives. Abstract base class for commands that process directives.
''' """
def __init__(self, context): def __init__(self, context):
self._context = context self._context = context
self._log = Messenger() self._log = Messenger()
def can_handle(self, directive): def can_handle(self, directive):
''' """
Returns true if the Plugin can handle the directive. Returns true if the Plugin can handle the directive.
''' """
raise NotImplementedError raise NotImplementedError
def handle(self, directive, data): def handle(self, directive, data):
''' """
Executes the directive. Executes the directive.
Returns true if the Plugin successfully handled the directive. Returns true if the Plugin successfully handled the directive.
''' """
raise NotImplementedError raise NotImplementedError

View file

@ -1,48 +1,52 @@
import os import os
import dotbot import sys
from ..plugin import Plugin
class Clean(dotbot.Plugin): class Clean(Plugin):
''' """
Cleans broken symbolic links. Cleans broken symbolic links.
''' """
_directive = 'clean' _directive = "clean"
def can_handle(self, directive): def can_handle(self, directive):
return directive == self._directive return directive == self._directive
def handle(self, directive, data): def handle(self, directive, data):
if directive != self._directive: if directive != self._directive:
raise ValueError('Clean cannot handle directive %s' % directive) raise ValueError("Clean cannot handle directive %s" % directive)
return self._process_clean(data) return self._process_clean(data)
def _process_clean(self, targets): def _process_clean(self, targets):
success = True success = True
defaults = self._context.defaults().get(self._directive, {}) defaults = self._context.defaults().get(self._directive, {})
for target in targets: for target in targets:
force = defaults.get('force', False) force = defaults.get("force", False)
recursive = defaults.get('recursive', False) recursive = defaults.get("recursive", False)
if isinstance(targets, dict) and isinstance(targets[target], dict): if isinstance(targets, dict) and isinstance(targets[target], dict):
force = targets[target].get('force', force) force = targets[target].get("force", force)
recursive = targets[target].get('recursive', recursive) recursive = targets[target].get("recursive", recursive)
success &= self._clean(target, force, recursive) success &= self._clean(target, force, recursive)
if success: if success:
self._log.info('All targets have been cleaned') self._log.info("All targets have been cleaned")
else: else:
self._log.error('Some targets were not successfully cleaned') self._log.error("Some targets were not successfully cleaned")
return success return success
def _clean(self, target, force, recursive): def _clean(self, target, force, recursive):
''' """
Cleans all the broken symbolic links in target if they point to Cleans all the broken symbolic links in target if they point to
a subdirectory of the base directory or if forced to clean. a subdirectory of the base directory or if forced to clean.
''' """
if not os.path.isdir(os.path.expandvars(os.path.expanduser(target))): if not os.path.isdir(os.path.expandvars(os.path.expanduser(target))):
self._log.debug('Ignoring nonexistent directory %s' % target) self._log.debug("Ignoring nonexistent directory %s" % target)
return True return True
for item in os.listdir(os.path.expandvars(os.path.expanduser(target))): for item in os.listdir(os.path.expandvars(os.path.expanduser(target))):
path = os.path.join(os.path.expandvars(os.path.expanduser(target)), item) path = os.path.abspath(
os.path.join(os.path.expandvars(os.path.expanduser(target)), item)
)
if recursive and os.path.isdir(path): if recursive and os.path.isdir(path):
# isdir implies not islink -- we don't want to descend into # isdir implies not islink -- we don't want to descend into
# symlinked directories. okay to do a recursive call here # symlinked directories. okay to do a recursive call here
@ -50,17 +54,19 @@ class Clean(dotbot.Plugin):
self._clean(path, force, recursive) self._clean(path, force, recursive)
if not os.path.exists(path) and os.path.islink(path): if not os.path.exists(path) and os.path.islink(path):
points_at = os.path.join(os.path.dirname(path), os.readlink(path)) points_at = os.path.join(os.path.dirname(path), os.readlink(path))
if sys.platform[:5] == "win32" and points_at.startswith("\\\\?\\"):
points_at = points_at[4:]
if self._in_directory(path, self._context.base_directory()) or force: if self._in_directory(path, self._context.base_directory()) or force:
self._log.lowinfo('Removing invalid link %s -> %s' % (path, points_at)) self._log.lowinfo("Removing invalid link %s -> %s" % (path, points_at))
os.remove(path) os.remove(path)
else: else:
self._log.lowinfo('Link %s -> %s not removed.' % (path, points_at)) self._log.lowinfo("Link %s -> %s not removed." % (path, points_at))
return True return True
def _in_directory(self, path, directory): def _in_directory(self, path, directory):
''' """
Returns true if the path is in the directory. Returns true if the path is in the directory.
''' """
directory = os.path.join(os.path.realpath(directory), '') directory = os.path.join(os.path.realpath(directory), "")
path = os.path.realpath(path) path = os.path.realpath(path)
return os.path.commonprefix([path, directory]) == directory return os.path.commonprefix([path, directory]) == directory

View file

@ -1,56 +1,60 @@
import os import os
import dotbot
from ..plugin import Plugin
class Create(dotbot.Plugin): class Create(Plugin):
''' """
Create empty paths. Create empty paths.
''' """
_directive = 'create' _directive = "create"
def can_handle(self, directive): def can_handle(self, directive):
return directive == self._directive return directive == self._directive
def handle(self, directive, data): def handle(self, directive, data):
if directive != self._directive: if directive != self._directive:
raise ValueError('Create cannot handle directive %s' % directive) raise ValueError("Create cannot handle directive %s" % directive)
return self._process_paths(data) return self._process_paths(data)
def _process_paths(self, paths): def _process_paths(self, paths):
success = True success = True
defaults = self._context.defaults().get('create', {}) defaults = self._context.defaults().get("create", {})
for key in paths: for key in paths:
path = os.path.expandvars(os.path.expanduser(key)) path = os.path.abspath(os.path.expandvars(os.path.expanduser(key)))
mode = defaults.get('mode', 0o777) # same as the default for os.makedirs mode = defaults.get("mode", 0o777) # same as the default for os.makedirs
if isinstance(paths, dict): if isinstance(paths, dict):
options = paths[key] options = paths[key]
if options: if options:
mode = options.get('mode', mode) mode = options.get("mode", mode)
success &= self._create(path, mode) success &= self._create(path, mode)
if success: if success:
self._log.info('All paths have been set up') self._log.info("All paths have been set up")
else: else:
self._log.error('Some paths were not successfully set up') self._log.error("Some paths were not successfully set up")
return success return success
def _exists(self, path): def _exists(self, path):
''' """
Returns true if the path exists. Returns true if the path exists.
''' """
path = os.path.expanduser(path) path = os.path.expanduser(path)
return os.path.exists(path) return os.path.exists(path)
def _create(self, path, mode): def _create(self, path, mode):
success = True success = True
if not self._exists(path): if not self._exists(path):
self._log.debug('Trying to create path %s with mode %o' % (path, mode)) self._log.debug("Trying to create path %s with mode %o" % (path, mode))
try: try:
self._log.lowinfo('Creating path %s' % path) self._log.lowinfo("Creating path %s" % path)
os.makedirs(path, mode) os.makedirs(path, mode)
# On Windows, the *mode* argument to `os.makedirs()` is ignored.
# The mode must be set explicitly in a follow-up call.
os.chmod(path, mode)
except OSError: except OSError:
self._log.warning('Failed to create path %s' % path) self._log.warning("Failed to create path %s" % path)
success = False success = False
else: else:
self._log.lowinfo('Path exists %s' % path) self._log.lowinfo("Path exists %s" % path)
return success return success

View file

@ -1,88 +1,100 @@
import os
import sys
import glob import glob
import os
import shutil import shutil
import dotbot import sys
import dotbot.util
import subprocess from ..plugin import Plugin
from ..util import shell_command
class Link(dotbot.Plugin): class Link(Plugin):
''' """
Symbolically links dotfiles. Symbolically links dotfiles.
''' """
_directive = 'link' _directive = "link"
def can_handle(self, directive): def can_handle(self, directive):
return directive == self._directive return directive == self._directive
def handle(self, directive, data): def handle(self, directive, data):
if directive != self._directive: if directive != self._directive:
raise ValueError('Link cannot handle directive %s' % directive) raise ValueError("Link cannot handle directive %s" % directive)
return self._process_links(data) return self._process_links(data)
def _process_links(self, links): def _process_links(self, links):
success = True success = True
defaults = self._context.defaults().get('link', {}) defaults = self._context.defaults().get("link", {})
for destination, source in links.items(): for destination, source in links.items():
destination = os.path.expandvars(destination) destination = os.path.expandvars(destination)
relative = defaults.get('relative', False) relative = defaults.get("relative", False)
# support old "canonicalize-path" key for compatibility # support old "canonicalize-path" key for compatibility
canonical_path = defaults.get('canonicalize', defaults.get('canonicalize-path', True)) canonical_path = defaults.get("canonicalize", defaults.get("canonicalize-path", True))
force = defaults.get('force', False) force = defaults.get("force", False)
relink = defaults.get('relink', False) relink = defaults.get("relink", False)
create = defaults.get('create', False) create = defaults.get("create", False)
use_glob = defaults.get('glob', False) use_glob = defaults.get("glob", False)
base_prefix = defaults.get('prefix', '') base_prefix = defaults.get("prefix", "")
test = defaults.get('if', None) test = defaults.get("if", None)
ignore_missing = defaults.get('ignore-missing', False) ignore_missing = defaults.get("ignore-missing", False)
exclude_paths = defaults.get('exclude', []) exclude_paths = defaults.get("exclude", [])
if isinstance(source, dict): if isinstance(source, dict):
# extended config # extended config
test = source.get('if', test) test = source.get("if", test)
relative = source.get('relative', relative) relative = source.get("relative", relative)
canonical_path = source.get('canonicalize', source.get('canonicalize-path', canonical_path)) canonical_path = source.get(
force = source.get('force', force) "canonicalize", source.get("canonicalize-path", canonical_path)
relink = source.get('relink', relink) )
create = source.get('create', create) force = source.get("force", force)
use_glob = source.get('glob', use_glob) relink = source.get("relink", relink)
base_prefix = source.get('prefix', base_prefix) create = source.get("create", create)
ignore_missing = source.get('ignore-missing', ignore_missing) use_glob = source.get("glob", use_glob)
exclude_paths = source.get('exclude', exclude_paths) base_prefix = source.get("prefix", base_prefix)
path = self._default_source(destination, source.get('path')) ignore_missing = source.get("ignore-missing", ignore_missing)
exclude_paths = source.get("exclude", exclude_paths)
path = self._default_source(destination, source.get("path"))
else: else:
path = self._default_source(destination, source) path = self._default_source(destination, source)
if test is not None and not self._test_success(test): if test is not None and not self._test_success(test):
self._log.lowinfo('Skipping %s' % destination) self._log.lowinfo("Skipping %s" % destination)
continue continue
path = os.path.expandvars(os.path.expanduser(path)) path = os.path.normpath(os.path.expandvars(os.path.expanduser(path)))
if use_glob: if use_glob:
glob_results = self._create_glob_results(path, exclude_paths) glob_results = self._create_glob_results(path, exclude_paths)
if len(glob_results) == 0: if len(glob_results) == 0:
self._log.warning("Globbing couldn't find anything matching " + str(path)) self._log.warning("Globbing couldn't find anything matching " + str(path))
success = False success = False
continue continue
if len(glob_results) == 1 and destination[-1] == '/': if len(glob_results) == 1 and destination[-1] == "/":
self._log.error("Ambiguous action requested.") self._log.error("Ambiguous action requested.")
self._log.error("No wildcard in glob, directory use undefined: " + self._log.error(
destination + " -> " + str(glob_results)) "No wildcard in glob, directory use undefined: "
+ destination
+ " -> "
+ str(glob_results)
)
self._log.warning("Did you want to link the directory or into it?") self._log.warning("Did you want to link the directory or into it?")
success = False success = False
continue continue
elif len(glob_results) == 1 and destination[-1] != '/': elif len(glob_results) == 1 and destination[-1] != "/":
# perform a normal link operation # perform a normal link operation
if create: if create:
success &= self._create(destination) success &= self._create(destination)
if force or relink: if force or relink:
success &= self._delete(path, destination, relative, canonical_path, force) success &= self._delete(path, destination, relative, canonical_path, force)
success &= self._link(path, destination, relative, canonical_path, ignore_missing) success &= self._link(
path, destination, relative, canonical_path, ignore_missing
)
else: else:
self._log.lowinfo("Globs from '" + path + "': " + str(glob_results)) self._log.lowinfo("Globs from '" + path + "': " + str(glob_results))
for glob_full_item in glob_results: for glob_full_item in glob_results:
# Find common dirname between pattern and the item: # Find common dirname between pattern and the item:
glob_dirname = os.path.dirname(os.path.commonprefix([path, glob_full_item])) glob_dirname = os.path.dirname(os.path.commonprefix([path, glob_full_item]))
glob_item = (glob_full_item if len(glob_dirname) == 0 else glob_full_item[len(glob_dirname) + 1:]) glob_item = (
glob_full_item
if len(glob_dirname) == 0
else glob_full_item[len(glob_dirname) + 1 :]
)
# Add prefix to basepath, if provided # Add prefix to basepath, if provided
if base_prefix: if base_prefix:
glob_item = base_prefix + glob_item glob_item = base_prefix + glob_item
@ -91,39 +103,52 @@ class Link(dotbot.Plugin):
if create: if create:
success &= self._create(glob_link_destination) success &= self._create(glob_link_destination)
if force or relink: if force or relink:
success &= self._delete(glob_full_item, glob_link_destination, relative, canonical_path, force) success &= self._delete(
success &= self._link(glob_full_item, glob_link_destination, relative, canonical_path, ignore_missing) glob_full_item,
glob_link_destination,
relative,
canonical_path,
force,
)
success &= self._link(
glob_full_item,
glob_link_destination,
relative,
canonical_path,
ignore_missing,
)
else: else:
if create: if create:
success &= self._create(destination) success &= self._create(destination)
if not ignore_missing and not self._exists(os.path.join(self._context.base_directory(), path)): if not ignore_missing and not self._exists(
os.path.join(self._context.base_directory(), path)
):
# we seemingly check this twice (here and in _link) because # we seemingly check this twice (here and in _link) because
# if the file doesn't exist and force is True, we don't # if the file doesn't exist and force is True, we don't
# want to remove the original (this is tested by # want to remove the original (this is tested by
# link-force-leaves-when-nonexistent.bash) # link-force-leaves-when-nonexistent.bash)
success = False success = False
self._log.warning('Nonexistent source %s -> %s' % self._log.warning("Nonexistent source %s -> %s" % (destination, path))
(destination, path))
continue continue
if force or relink: if force or relink:
success &= self._delete(path, destination, relative, canonical_path, force) success &= self._delete(path, destination, relative, canonical_path, force)
success &= self._link(path, destination, relative, canonical_path, ignore_missing) success &= self._link(path, destination, relative, canonical_path, ignore_missing)
if success: if success:
self._log.info('All links have been set up') self._log.info("All links have been set up")
else: else:
self._log.error('Some links were not successfully set up') self._log.error("Some links were not successfully set up")
return success return success
def _test_success(self, command): def _test_success(self, command):
ret = dotbot.util.shell_command(command, cwd=self._context.base_directory()) ret = shell_command(command, cwd=self._context.base_directory())
if ret != 0: if ret != 0:
self._log.debug('Test \'%s\' returned false' % command) self._log.debug("Test '%s' returned false" % command)
return ret == 0 return ret == 0
def _default_source(self, destination, source): def _default_source(self, destination, source):
if source is None: if source is None:
basename = os.path.basename(destination) basename = os.path.basename(destination)
if basename.startswith('.'): if basename.startswith("."):
return basename[1:] return basename[1:]
else: else:
return basename return basename
@ -131,18 +156,20 @@ class Link(dotbot.Plugin):
return source return source
def _glob(self, path): def _glob(self, path):
''' """
Wrap `glob.glob` in a python agnostic way, catching errors in usage. Wrap `glob.glob` in a python agnostic way, catching errors in usage.
''' """
if (sys.version_info < (3, 5) and '**' in path): if sys.version_info < (3, 5) and "**" in path:
self._log.error('Link cannot handle recursive glob ("**") for Python < version 3.5: "%s"' % path) self._log.error(
'Link cannot handle recursive glob ("**") for Python < version 3.5: "%s"' % path
)
return [] return []
# call glob.glob; only python >= 3.5 supports recursive globs # call glob.glob; only python >= 3.5 supports recursive globs
found = ( glob.glob(path) found = glob.glob(path) if (sys.version_info < (3, 5)) else glob.glob(path, recursive=True)
if (sys.version_info < (3, 5)) else # normalize paths to ensure cross-platform compatibility
glob.glob(path, recursive=True) ) found = [os.path.normpath(p) for p in found]
# if using recursive glob (`**`), filter results to return only files: # if using recursive glob (`**`), filter results to return only files:
if '**' in path and not path.endswith(str(os.sep)): if "**" in path and not path.endswith(str(os.sep)):
self._log.debug("Excluding directories from recursive glob: " + str(path)) self._log.debug("Excluding directories from recursive glob: " + str(path))
found = [f for f in found if os.path.isfile(f)] found = [f for f in found if os.path.isfile(f)]
# return matched results # return matched results
@ -162,22 +189,25 @@ class Link(dotbot.Plugin):
return list(ret) return list(ret)
def _is_link(self, path): def _is_link(self, path):
''' """
Returns true if the path is a symbolic link. Returns true if the path is a symbolic link.
''' """
return os.path.islink(os.path.expanduser(path)) return os.path.islink(os.path.expanduser(path))
def _link_destination(self, path): def _link_destination(self, path):
''' """
Returns the destination of the symbolic link. Returns the destination of the symbolic link.
''' """
path = os.path.expanduser(path) path = os.path.expanduser(path)
return os.readlink(path) path = os.readlink(path)
if sys.platform[:5] == "win32" and path.startswith("\\\\?\\"):
path = path[4:]
return path
def _exists(self, path): def _exists(self, path):
''' """
Returns true if the path exists. Returns true if the path exists.
''' """
path = os.path.expanduser(path) path = os.path.expanduser(path)
return os.path.exists(path) return os.path.exists(path)
@ -189,20 +219,21 @@ class Link(dotbot.Plugin):
try: try:
os.makedirs(parent) os.makedirs(parent)
except OSError: except OSError:
self._log.warning('Failed to create directory %s' % parent) self._log.warning("Failed to create directory %s" % parent)
success = False success = False
else: else:
self._log.lowinfo('Creating directory %s' % parent) self._log.lowinfo("Creating directory %s" % parent)
return success return success
def _delete(self, source, path, relative, canonical_path, force): def _delete(self, source, path, relative, canonical_path, force):
success = True success = True
source = os.path.join(self._context.base_directory(canonical_path=canonical_path), source) source = os.path.join(self._context.base_directory(canonical_path=canonical_path), source)
fullpath = os.path.expanduser(path) fullpath = os.path.abspath(os.path.expanduser(path))
if relative: if relative:
source = self._relative_path(source, fullpath) source = self._relative_path(source, fullpath)
if ((self._is_link(path) and self._link_destination(path) != source) or if (self._is_link(path) and self._link_destination(path) != source) or (
(self._exists(path) and not self._is_link(path))): self._exists(path) and not self._is_link(path)
):
removed = False removed = False
try: try:
if os.path.islink(fullpath): if os.path.islink(fullpath):
@ -216,39 +247,44 @@ class Link(dotbot.Plugin):
os.remove(fullpath) os.remove(fullpath)
removed = True removed = True
except OSError: except OSError:
self._log.warning('Failed to remove %s' % path) self._log.warning("Failed to remove %s" % path)
success = False success = False
else: else:
if removed: if removed:
self._log.lowinfo('Removing %s' % path) self._log.lowinfo("Removing %s" % path)
return success return success
def _relative_path(self, source, destination): def _relative_path(self, source, destination):
''' """
Returns the relative path to get to the source file from the Returns the relative path to get to the source file from the
destination file. destination file.
''' """
destination_dir = os.path.dirname(destination) destination_dir = os.path.dirname(destination)
return os.path.relpath(source, destination_dir) return os.path.relpath(source, destination_dir)
def _link(self, source, link_name, relative, canonical_path, ignore_missing): def _link(self, source, link_name, relative, canonical_path, ignore_missing):
''' """
Links link_name to source. Links link_name to source.
Returns true if successfully linked files. Returns true if successfully linked files.
''' """
success = False success = False
destination = os.path.expanduser(link_name) destination = os.path.abspath(os.path.expanduser(link_name))
base_directory = self._context.base_directory(canonical_path=canonical_path) base_directory = self._context.base_directory(canonical_path=canonical_path)
absolute_source = os.path.join(base_directory, source) absolute_source = os.path.join(base_directory, source)
link_name = os.path.normpath(link_name)
if relative: if relative:
source = self._relative_path(absolute_source, destination) source = self._relative_path(absolute_source, destination)
else: else:
source = absolute_source source = absolute_source
if (not self._exists(link_name) and self._is_link(link_name) and if (
self._link_destination(link_name) != source): not self._exists(link_name)
self._log.warning('Invalid link %s -> %s' % and self._is_link(link_name)
(link_name, self._link_destination(link_name))) and self._link_destination(link_name) != source
):
self._log.warning(
"Invalid link %s -> %s" % (link_name, self._link_destination(link_name))
)
# we need to use absolute_source below because our cwd is the dotfiles # we need to use absolute_source below because our cwd is the dotfiles
# directory, and if source is relative, it will be relative to the # directory, and if source is relative, it will be relative to the
# destination directory # destination directory
@ -256,26 +292,23 @@ class Link(dotbot.Plugin):
try: try:
os.symlink(source, destination) os.symlink(source, destination)
except OSError: except OSError:
self._log.warning('Linking failed %s -> %s' % (link_name, source)) self._log.warning("Linking failed %s -> %s" % (link_name, source))
else: else:
self._log.lowinfo('Creating link %s -> %s' % (link_name, source)) self._log.lowinfo("Creating link %s -> %s" % (link_name, source))
success = True success = True
elif self._exists(link_name) and not self._is_link(link_name): elif self._exists(link_name) and not self._is_link(link_name):
self._log.warning( self._log.warning("%s already exists but is a regular file or directory" % link_name)
'%s already exists but is a regular file or directory' %
link_name)
elif self._is_link(link_name) and self._link_destination(link_name) != source: elif self._is_link(link_name) and self._link_destination(link_name) != source:
self._log.warning('Incorrect link %s -> %s' % self._log.warning(
(link_name, self._link_destination(link_name))) "Incorrect link %s -> %s" % (link_name, self._link_destination(link_name))
)
# again, we use absolute_source to check for existence # again, we use absolute_source to check for existence
elif not self._exists(absolute_source): elif not self._exists(absolute_source):
if self._is_link(link_name): if self._is_link(link_name):
self._log.warning('Nonexistent source %s -> %s' % self._log.warning("Nonexistent source %s -> %s" % (link_name, source))
(link_name, source))
else: else:
self._log.warning('Nonexistent source for %s : %s' % self._log.warning("Nonexistent source for %s : %s" % (link_name, source))
(link_name, source))
else: else:
self._log.lowinfo('Link exists %s -> %s' % (link_name, source)) self._log.lowinfo("Link exists %s -> %s" % (link_name, source))
success = True success = True
return success return success

View file

@ -1,15 +1,13 @@
import os from ..plugin import Plugin
import subprocess from ..util import shell_command
import dotbot
import dotbot.util
class Shell(dotbot.Plugin): class Shell(Plugin):
''' """
Run arbitrary shell commands. Run arbitrary shell commands.
''' """
_directive = 'shell' _directive = "shell"
_has_shown_override_message = False _has_shown_override_message = False
def can_handle(self, directive): def can_handle(self, directive):
@ -17,62 +15,62 @@ class Shell(dotbot.Plugin):
def handle(self, directive, data): def handle(self, directive, data):
if directive != self._directive: if directive != self._directive:
raise ValueError('Shell cannot handle directive %s' % raise ValueError("Shell cannot handle directive %s" % directive)
directive)
return self._process_commands(data) return self._process_commands(data)
def _process_commands(self, data): def _process_commands(self, data):
success = True success = True
defaults = self._context.defaults().get('shell', {}) defaults = self._context.defaults().get("shell", {})
options = self._get_option_overrides() options = self._get_option_overrides()
for item in data: for item in data:
stdin = defaults.get('stdin', False) stdin = defaults.get("stdin", False)
stdout = defaults.get('stdout', False) stdout = defaults.get("stdout", False)
stderr = defaults.get('stderr', False) stderr = defaults.get("stderr", False)
quiet = defaults.get('quiet', False) quiet = defaults.get("quiet", False)
if isinstance(item, dict): if isinstance(item, dict):
cmd = item['command'] cmd = item["command"]
msg = item.get('description', None) msg = item.get("description", None)
stdin = item.get('stdin', stdin) stdin = item.get("stdin", stdin)
stdout = item.get('stdout', stdout) stdout = item.get("stdout", stdout)
stderr = item.get('stderr', stderr) stderr = item.get("stderr", stderr)
quiet = item.get('quiet', quiet) quiet = item.get("quiet", quiet)
elif isinstance(item, list): elif isinstance(item, list):
cmd = item[0] cmd = item[0]
msg = item[1] if len(item) > 1 else None msg = item[1] if len(item) > 1 else None
else: else:
cmd = item cmd = item
msg = None msg = None
if msg is None: if quiet:
if msg is not None:
self._log.lowinfo("%s" % msg)
elif msg is None:
self._log.lowinfo(cmd) self._log.lowinfo(cmd)
elif quiet:
self._log.lowinfo('%s' % msg)
else: else:
self._log.lowinfo('%s [%s]' % (msg, cmd)) self._log.lowinfo("%s [%s]" % (msg, cmd))
stdout = options.get('stdout', stdout) stdout = options.get("stdout", stdout)
stderr = options.get('stderr', stderr) stderr = options.get("stderr", stderr)
ret = dotbot.util.shell_command( ret = shell_command(
cmd, cmd,
cwd=self._context.base_directory(), cwd=self._context.base_directory(),
enable_stdin=stdin, enable_stdin=stdin,
enable_stdout=stdout, enable_stdout=stdout,
enable_stderr=stderr enable_stderr=stderr,
) )
if ret != 0: if ret != 0:
success = False success = False
self._log.warning('Command [%s] failed' % cmd) self._log.warning("Command [%s] failed" % cmd)
if success: if success:
self._log.info('All commands have been executed') self._log.info("All commands have been executed")
else: else:
self._log.error('Some commands were not successfully executed') self._log.error("Some commands were not successfully executed")
return success return success
def _get_option_overrides(self): def _get_option_overrides(self):
ret = {} ret = {}
options = self._context.options() options = self._context.options()
if options.verbose > 1: if options.verbose > 1:
ret['stderr'] = True ret["stderr"] = True
ret['stdout'] = True ret["stdout"] = True
if not self._has_shown_override_message: if not self._has_shown_override_message:
self._log.debug("Shell: Found cli option to force show stderr and stdout.") self._log.debug("Shell: Found cli option to force show stderr and stdout.")
self._has_shown_override_message = True self._has_shown_override_message = True

View file

@ -1,15 +1,15 @@
import os import os
import subprocess
import platform import platform
import subprocess
def shell_command(command, cwd=None, enable_stdin=False, enable_stdout=False, enable_stderr=False): def shell_command(command, cwd=None, enable_stdin=False, enable_stdout=False, enable_stderr=False):
with open(os.devnull, 'w') as devnull_w, open(os.devnull, 'r') as devnull_r: with open(os.devnull, "w") as devnull_w, open(os.devnull, "r") as devnull_r:
stdin = None if enable_stdin else devnull_r stdin = None if enable_stdin else devnull_r
stdout = None if enable_stdout else devnull_w stdout = None if enable_stdout else devnull_w
stderr = None if enable_stderr else devnull_w stderr = None if enable_stderr else devnull_w
executable = os.environ.get('SHELL') executable = os.environ.get("SHELL")
if platform.system() == 'Windows': if platform.system() == "Windows":
# We avoid setting the executable kwarg on Windows because it does # We avoid setting the executable kwarg on Windows because it does
# not have the desired effect when combined with shell=True. It # not have the desired effect when combined with shell=True. It
# will result in the correct program being run (e.g. bash), but it # will result in the correct program being run (e.g. bash), but it
@ -30,5 +30,5 @@ def shell_command(command, cwd=None, enable_stdin=False, enable_stdout=False, en
stdin=stdin, stdin=stdin,
stdout=stdout, stdout=stdout,
stderr=stderr, stderr=stderr,
cwd=cwd cwd=cwd,
) )

View file

@ -2,4 +2,5 @@ def with_metaclass(meta, *bases):
class metaclass(meta): class metaclass(meta):
def __new__(cls, name, this_bases, d): def __new__(cls, name, this_bases, d):
return meta(name, bases, d) return meta(name, bases, d)
return type.__new__(metaclass, 'temporary_class', (), {})
return type.__new__(metaclass, "temporary_class", (), {})

View file

@ -1,13 +1,27 @@
import sys, os.path import os
import sys
from dotbot.plugin import Plugin
# We keep references to loaded modules so they don't get garbage collected. # We keep references to loaded modules so they don't get garbage collected.
loaded_modules = [] loaded_modules = []
def load(path): def load(path):
basename = os.path.basename(path) basename = os.path.basename(path)
module_name, extension = os.path.splitext(basename) module_name, extension = os.path.splitext(basename)
plugin = load_module(module_name, path) loaded_module = load_module(module_name, path)
loaded_modules.append(plugin) plugins = []
for name in dir(loaded_module):
possible_plugin = getattr(loaded_module, name)
try:
if issubclass(possible_plugin, Plugin) and possible_plugin is not Plugin:
plugins.append(possible_plugin)
except TypeError:
pass
loaded_modules.append(loaded_module)
return plugins
if sys.version_info >= (3, 5): if sys.version_info >= (3, 5):
import importlib.util import importlib.util
@ -17,11 +31,13 @@ if sys.version_info >= (3, 5):
module = importlib.util.module_from_spec(spec) module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module) spec.loader.exec_module(module)
return module return module
elif sys.version_info >= (3, 3): elif sys.version_info >= (3, 3):
from importlib.machinery import SourceFileLoader from importlib.machinery import SourceFileLoader
def load_module(module_name, path): def load_module(module_name, path):
return SourceFileLoader(module_name, path).load_module() return SourceFileLoader(module_name, path).load_module()
else: else:
import imp import imp

View file

@ -1,5 +1,6 @@
class Singleton(type): class Singleton(type):
_instances = {} _instances = {}
def __call__(cls, *args, **kwargs): def __call__(cls, *args, **kwargs):
if cls not in cls._instances: if cls not in cls._instances:
cls._instances[cls] = super(Singleton, cls).__call__(*args, **kwargs) cls._instances[cls] = super(Singleton, cls).__call__(*args, **kwargs)

View file

@ -1,4 +1,4 @@
def indent_lines(string, amount=2, delimiter='\n'): def indent_lines(string, amount=2, delimiter="\n"):
whitespace = ' ' * amount whitespace = " " * amount
sep = '%s%s' % (delimiter, whitespace) sep = "%s%s" % (delimiter, whitespace)
return '%s%s' % (whitespace, sep.join(string.split(delimiter))) return "%s%s" % (whitespace, sep.join(string.split(delimiter)))

12
pyproject.toml Normal file
View file

@ -0,0 +1,12 @@
[tool.black]
line-length = 100
exclude = '''
/(
\.git
| \.github
| .*\.egg-info
| build
| dist
| lib
)/
'''

View file

@ -1,87 +1,75 @@
from setuptools import setup, find_packages import re
from codecs import open # For a consistent encoding from codecs import open # For a consistent encoding
from os import path from os import path
import re
from setuptools import find_packages, setup
here = path.dirname(__file__) here = path.dirname(__file__)
with open(path.join(here, 'README.md'), encoding='utf-8') as f: with open(path.join(here, "README.md"), encoding="utf-8") as f:
long_description = f.read() long_description = f.read()
def read(*names, **kwargs): def read(*names, **kwargs):
with open( with open(path.join(here, *names), encoding=kwargs.get("encoding", "utf8")) as fp:
path.join(here, *names),
encoding=kwargs.get("encoding", "utf8")
) as fp:
return fp.read() return fp.read()
def find_version(*file_paths): def find_version(*file_paths):
version_file = read(*file_paths) version_file = read(*file_paths)
version_match = re.search(r"^__version__ = ['\"]([^'\"]*)['\"]", version_match = re.search(r"^__version__ = ['\"]([^'\"]*)['\"]", version_file, re.M)
version_file, re.M)
if version_match: if version_match:
return version_match.group(1) return version_match.group(1)
raise RuntimeError("Unable to find version string.") raise RuntimeError("Unable to find version string.")
setup( setup(
name='dotbot', name="dotbot",
version=find_version("dotbot", "__init__.py"),
version=find_version('dotbot', '__init__.py'), description="A tool that bootstraps your dotfiles",
description='A tool that bootstraps your dotfiles',
long_description=long_description, long_description=long_description,
long_description_content_type='text/markdown', long_description_content_type="text/markdown",
url="https://github.com/anishathalye/dotbot",
url='https://github.com/anishathalye/dotbot', author="Anish Athalye",
author_email="me@anishathalye.com",
author='Anish Athalye', license="MIT",
author_email='me@anishathalye.com',
license='MIT',
classifiers=[ classifiers=[
'Development Status :: 5 - Production/Stable', "Development Status :: 5 - Production/Stable",
"Intended Audience :: Developers",
'Intended Audience :: Developers', "License :: OSI Approved :: MIT License",
"Programming Language :: Python :: 2",
'License :: OSI Approved :: MIT License', "Programming Language :: Python :: 2.7",
"Programming Language :: Python :: 3",
'Programming Language :: Python :: 2', "Programming Language :: Python :: 3.5",
'Programming Language :: Python :: 2.7', "Programming Language :: Python :: 3.6",
'Programming Language :: Python :: 3', "Programming Language :: Python :: 3.7",
'Programming Language :: Python :: 3.2', "Programming Language :: Python :: 3.8",
'Programming Language :: Python :: 3.3', "Programming Language :: Python :: 3.9",
'Programming Language :: Python :: 3.4', "Programming Language :: Python :: 3.10",
'Programming Language :: Python :: 3.5', "Topic :: Utilities",
'Programming Language :: Python :: 3.6',
'Topic :: Utilities',
], ],
keywords="dotfiles",
keywords='dotfiles',
packages=find_packages(), packages=find_packages(),
setup_requires=[ setup_requires=[
'setuptools>=38.6.0', "setuptools>=38.6.0",
'wheel>=0.31.0', "wheel>=0.31.0",
], ],
install_requires=[ install_requires=[
'PyYAML>=5.3,<6', "PyYAML>=5.3,<6",
], ],
extras_require={
"dev": {
"pytest",
"tox",
}
},
# To provide executable scripts, use entry points in preference to the # To provide executable scripts, use entry points in preference to the
# "scripts" keyword. Entry points provide cross-platform support and allow # "scripts" keyword. Entry points provide cross-platform support and allow
# pip to create the appropriate form of executable for the target platform. # pip to create the appropriate form of executable for the target platform.
entry_points={ entry_points={
'console_scripts': [ "console_scripts": [
'dotbot=dotbot:main', "dotbot=dotbot:main",
], ],
}, },
) )

View file

@ -1,5 +0,0 @@
[Vagrantfile]
indent_size = 2
[{test,test_travis}]
indent_size = 4

2
test/.gitignore vendored
View file

@ -1,2 +0,0 @@
.vagrant/
*.log

View file

@ -1,86 +0,0 @@
Testing
=======
Dotbot testing code uses [Vagrant] to run all tests inside a virtual machine to
have tests be completely isolated from the host machine. Specifically, you
will need both:
- [VirtualBox]
- [Vagrant]
Install Dotbot dependencies
---------------------------
Ensure you have updated the `dotbot` submodule dependencies, on the host machine:
```bash
git submodule sync --quiet --recursive
git submodule update --init --recursive
```
Install Vagrant
---------------
### Debian-based distributions
```bash
sudo apt install vagrant virtualbox
```
### macOS
You can download those directly from the above URLs, or via some MacOS package managers.
e.g. using [HomeBrew](https://brew.sh/):
```bash
brew cask install virtualbox
brew cask install vagrant
# optional, adding menu-bar support:
brew cask install vagrant-manager
```
Running the Tests
-----------------
Before running the tests, you must start and `ssh` into the VM:
```bash
vagrant up
vagrant ssh
```
All remaining commands are run inside the VM.
First, you must install a version of Python to test against, using:
pyenv install -s {version}
You can choose any version you like, e.g. `3.8.1`. It isn't particularly
important to test against all supported versions of Python in the VM, because
they will be tested by CI. Once you've installed a specific version of Python,
activate it with:
pyenv global {version}
The VM mounts your host's Dotbot directory in `/dotbot` as read-only, allowing
you to make edits on your host machine. Run the entire test suite by:
```bash
cd /dotbot/test
./test
```
Selected tests can be run by passing paths to the tests as arguments, e.g.:
```bash
./test tests/create.bash tests/defaults.bash
```
To debug tests, you can run the test driver with the `--debug` (or `-d` short
form) flag, e.g. `./test --debug tests/link-if.bash`. This will enable printing
stdout/stderr.
When finished with testing, it is good to shut down the virtual machine by
running `vagrant halt`.
[VirtualBox]: https://www.virtualbox.org/
[Vagrant]: https://www.vagrantup.com/

28
test/Vagrantfile vendored
View file

@ -1,28 +0,0 @@
Vagrant.configure(2) do |config|
config.vm.box = 'ubuntu/bionic64'
config.vm.synced_folder "..", "/dotbot", mount_options: ["ro"]
# disable default synced folder
config.vm.synced_folder ".", "/vagrant", disabled: true
# install packages
config.vm.provision "shell", inline: <<-EOS
apt-get -y update
apt-get install -y git make build-essential libssl-dev zlib1g-dev \
libbz2-dev libreadline-dev libsqlite3-dev wget curl llvm \
libncurses5-dev
EOS
# install pyenv
config.vm.provision "shell", privileged: false, inline: <<-EOS
rm -rf ~/.pyenv
git clone git://github.com/yyuu/pyenv.git ~/.pyenv
cat <<-'PYENV' > ~/.bashrc
export PYENV_ROOT="$HOME/.pyenv"
export PATH="$PYENV_ROOT/bin:$PATH"
eval "$(pyenv init --path)"
eval "$(pyenv init -)"
PYENV
EOS
end

View file

@ -1,99 +0,0 @@
red() {
if [ -t 1 ]; then
printf "\033[31m%s\033[0m\n" "$*"
else
printf "%s\n" "$*"
fi
}
green() {
if [ -t 1 ]; then
printf "\033[32m%s\033[0m\n" "$*"
else
printf "%s\n" "$*"
fi
}
yellow() {
if [ -t 1 ]; then
printf "\033[33m%s\033[0m\n" "$*"
else
printf "%s\n" "$*"
fi
}
check_env() {
if [[ "$(whoami)" != "vagrant" && "${CI}" != true ]]; then
die "tests must be run inside Vagrant or CI"
fi
}
cleanup() {
rm -rf ~/fakehome
mkdir -p ~/fakehome
}
initialize() {
echo "initializing."
tests_run=0
tests_passed=0
tests_failed=0
tests_skipped=0
tests_total="${1}"
local plural="" && [ "${tests_total}" -gt 1 ] && plural="s"
printf -- "running %d test%s...\n\n" "${tests_total}" "${plural}"
}
pass() {
tests_passed=$((tests_passed + 1))
green "-> ok."
echo
}
fail() {
tests_failed=$((tests_failed + 1))
red "-> fail!"
echo
}
skip() {
tests_skipped=$((tests_skipped + 1))
yellow "-> skipped."
echo
}
run_test() {
tests_run=$((tests_run + 1))
printf '[%d/%d] (%s)\n' "${tests_run}" "${tests_total}" "${1}"
cleanup
if (cd "${BASEDIR}/test/tests" && HOME=~/fakehome DEBUG=${2} DOTBOT_TEST=true bash "${1}"); then
pass
elif [ $? -eq 42 ]; then
skip
else
fail
fi
}
report() {
printf -- "test report\n"
printf -- "-----------\n"
printf -- "- %3d run\n" ${tests_run}
printf -- "- %3d passed\n" ${tests_passed}
printf -- "- %3d skipped\n" ${tests_skipped}
printf -- "- %3d failed\n" ${tests_failed}
if [ ${tests_failed} -gt 0 ]; then
red "==> FAIL! "
return 1
else
green "==> PASS. "
return 0
fi
}
die() {
>&2 echo $@
>&2 echo "terminating..."
exit 1
}

View file

@ -1,53 +0,0 @@
#!/usr/bin/env bash
set -e
export BASEDIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "${BASEDIR}/test"
. "./driver-lib.bash"
date_stamp="$(date --rfc-3339=ns)"
start="$(date +%s)"
check_env
# parse flags; must come before positional arguments
POSITIONAL=()
DEBUG=false
while [[ $# -gt 0 ]]; do
case $1 in
-d|--debug)
DEBUG=true
shift
;;
*)
POSITIONAL+=("$1")
shift
;;
esac
done
set -- "${POSITIONAL[@]}" # restore positional arguments
declare -a tests=()
if [ $# -eq 0 ]; then
while read file; do
tests+=("${file}")
done < <(find tests -type f -name '*.bash' | sort)
else
tests=("$@")
fi
initialize "${#tests[@]}"
for file in "${tests[@]}"; do
run_test "$(basename "${file}")" "${DEBUG}"
done
if report; then
ret=0
else
ret=1
fi
echo "(tests run in $(($(date +%s) - start)) seconds)"
exit ${ret}

View file

@ -1,76 +0,0 @@
DOTBOT_EXEC="${BASEDIR}/bin/dotbot"
DOTFILES="${HOME}/dotfiles"
INSTALL_CONF='install.conf.yaml'
INSTALL_CONF_JSON='install.conf.json'
test_run_() {
if ! ${DEBUG}; then
(eval "$*") >/dev/null 2>&1
else
(eval "$*")
fi
}
test_expect_success() {
local tag=${1} && shift
if ! test_run_ "$@"; then
>&2 echo "- ${tag} failed."
exit 1
fi
}
test_expect_failure() {
local tag=${1} && shift
if test_run_ "$@"; then
>&2 echo "- ${tag} failed."
exit 1
fi
}
skip_tests() {
# exit with special exit code picked up by driver-lib.bash
exit 42
}
check_env() {
if [ "${DOTBOT_TEST}" != "true" ]; then
>&2 echo "test must be run by test driver"
exit 1
fi
}
# run comparison check on python version; args:
# $1 - comparison operator (e.g. '>=')
# $2 - version number, to be passed to python (e.g. '3', '3.5', '3.6.4')
# status code will reflect if comparison is true/false
# e.g. `check_python_version '>=' 3.5`
check_python_version() {
check="$1"
version="$(echo "$2" | tr . , )"
# this call to just `python` will work in the Vagrant-based testing VM
# because `pyenv` will always create a link to the "right" version.
python -c "import sys; exit( not (sys.version_info ${check} (${version})) )"
}
initialize() {
check_env
echo "${test_description}"
mkdir -p "${DOTFILES}"
cd
}
run_dotbot() {
(
cat > "${DOTFILES}/${INSTALL_CONF}"
${DOTBOT_EXEC} -c "${DOTFILES}/${INSTALL_CONF}" "${@}"
)
}
run_dotbot_json() {
(
cat > "${DOTFILES}/${INSTALL_CONF_JSON}"
${DOTBOT_EXEC} -c "${DOTFILES}/${INSTALL_CONF_JSON}" "${@}"
)
}
initialize

View file

@ -1,19 +0,0 @@
test_description='clean uses default unless overridden'
. '../test-lib.bash'
test_expect_success 'setup' '
ln -s /nowhere ~/.g
'
test_expect_success 'run' '
run_dotbot <<EOF
- clean:
~/nonexistent:
force: true
~/:
EOF
'
test_expect_success 'test' '
test -h ~/.g
'

View file

@ -1,16 +0,0 @@
test_description='clean expands environment variables'
. '../test-lib.bash'
test_expect_success 'setup' '
ln -s ${DOTFILES}/f ~/.f
'
test_expect_success 'run' '
run_dotbot <<EOF
- clean: ["\$HOME"]
EOF
'
test_expect_success 'test' '
! test -h ~/.f
'

View file

@ -1,19 +0,0 @@
test_description='clean deletes links to missing files'
. '../test-lib.bash'
test_expect_success 'setup' '
touch ${DOTFILES}/f &&
ln -s ${DOTFILES}/f ~/.f &&
ln -s ${DOTFILES}/g ~/.g
'
test_expect_success 'run' '
run_dotbot <<EOF
- clean: ["~"]
EOF
'
test_expect_success 'test' '
test -f ~/.f &&
! test -h ~/.g
'

View file

@ -1,8 +0,0 @@
test_description='clean ignores nonexistent directories'
. '../test-lib.bash'
test_expect_success 'run' '
run_dotbot <<EOF
- clean: ["~", "~/fake"]
EOF
'

View file

@ -1,18 +0,0 @@
test_description='clean forced to remove files linking outside dotfiles directory'
. '../test-lib.bash'
test_expect_success 'setup' '
ln -s /nowhere ~/.g
'
test_expect_success 'run' '
run_dotbot <<EOF
- clean:
~/:
force: true
EOF
'
test_expect_success 'test' '
! test -h ~/.g
'

View file

@ -1,18 +0,0 @@
test_description='clean ignores files linking outside dotfiles directory'
. '../test-lib.bash'
test_expect_success 'setup' '
ln -s ${DOTFILES}/f ~/.f &&
ln -s ~/g ~/.g
'
test_expect_success 'run' '
run_dotbot <<EOF
- clean: ["~"]
EOF
'
test_expect_success 'test' '
! test -h ~/.f &&
test -h ~/.g
'

View file

@ -1,34 +0,0 @@
test_description='clean removes recursively'
. '../test-lib.bash'
test_expect_success 'setup' '
mkdir -p ~/a/b
ln -s /nowhere ~/c
ln -s /nowhere ~/a/d
ln -s /nowhere ~/a/b/e
'
test_expect_success 'run' '
run_dotbot <<EOF
- clean:
~/:
force: true
EOF
'
test_expect_success 'test' '
! test -h ~/c && test -h ~/a/d && test -h ~/a/b/e
'
test_expect_success 'run 2' '
run_dotbot <<EOF
- clean:
~/:
force: true
recursive: true
EOF
'
test_expect_success 'test 2' '
! test -h ~/a/d && ! test -h ~/a/b/e
'

View file

@ -1,8 +0,0 @@
test_description='blank config allowed'
. '../test-lib.bash'
test_expect_success 'run' '
run_dotbot <<EOF
[]
EOF
'

View file

@ -1,7 +0,0 @@
test_description='empty config allowed'
. '../test-lib.bash'
test_expect_success 'run' '
run_dotbot <<EOF
EOF
'

View file

@ -1,20 +0,0 @@
test_description='json config with tabs allowed'
. '../test-lib.bash'
test_expect_success 'setup' '
echo "grape" > ${DOTFILES}/h
'
test_expect_success 'run' '
run_dotbot_json <<EOF
[{
"link": {
"~/.i": "h"
}
}]
EOF
'
test_expect_success 'test' '
grep "grape" ~/.i
'

View file

@ -1,20 +0,0 @@
test_description='json config allowed'
. '../test-lib.bash'
test_expect_success 'setup' '
echo "grape" > ${DOTFILES}/h
'
test_expect_success 'run' '
run_dotbot_json <<EOF
[{
"link": {
"~/.i": "h"
}
}]
EOF
'
test_expect_success 'test' '
grep "grape" ~/.i
'

View file

@ -1,26 +0,0 @@
test_description='create mode'
. '../test-lib.bash'
test_expect_success 'run' '
run_dotbot -v <<EOF
- defaults:
create:
mode: 0755
- create:
- ~/downloads
- ~/.vim/undo-history
- create:
~/.ssh:
mode: 0700
~/projects:
EOF
'
test_expect_success 'test' '
[ -d ~/downloads ] &&
[ -d ~/.vim/undo-history ] &&
[ -d ~/.ssh ] &&
[ -d ~/projects ] &&
[ "$(stat -c %a ~/.ssh)" = "700" ] &&
[ "$(stat -c %a ~/downloads)" = "755" ]
'

View file

@ -1,23 +0,0 @@
test_description='create folders'
. '../test-lib.bash'
test_expect_success 'run' '
run_dotbot <<EOF
- create:
- ~/somedir
- ~/nested/somedir
EOF
'
test_expect_success 'test' '
[ -d ~/somedir ] &&
[ -d ~/nested/somedir ]
'
test_expect_success 'run 2' '
run_dotbot <<EOF
- create:
- ~/somedir
- ~/nested/somedir
EOF
'

View file

@ -1,59 +0,0 @@
test_description='defaults setting works'
. '../test-lib.bash'
test_expect_success 'setup' '
echo "apple" > ${DOTFILES}/f &&
echo "grape" > ~/f &&
ln -s ~/f ~/.f &&
ln -s /nowhere ~/.g
'
test_expect_failure 'run-fail' '
run_dotbot <<EOF
- link:
~/.f: f
EOF
'
test_expect_failure 'test-fail' '
grep "apple" ~/.f
'
test_expect_success 'run' '
run_dotbot <<EOF
- defaults:
link:
relink: true
- link:
~/.f: f
EOF
'
test_expect_success 'test' '
grep "apple" ~/.f
'
test_expect_success 'run-fail 2' '
run_dotbot <<EOF
- clean: ["~"]
EOF
'
test_expect_failure 'test-fail 2' '
! test -h ~/.g
'
test_expect_success 'run 2' '
run_dotbot <<EOF
- defaults:
clean:
force: true
- clean: ["~"]
EOF
'
test_expect_success 'test 2' '
! test -h ~/.g
'

View file

@ -1,21 +0,0 @@
test_description='--except with multiple arguments'
. '../test-lib.bash'
test_expect_success 'setup' '
ln -s ${DOTFILES}/nonexistent ~/bad && touch ${DOTFILES}/y
'
test_expect_success 'run' '
run_dotbot --except clean shell <<EOF
- clean: ["~"]
- shell:
- echo "x" > ~/x
- link:
~/y: y
EOF
'
test_expect_success 'test' '
[ "$(readlink ~/bad | cut -d/ -f5-)" = "dotfiles/nonexistent" ] &&
! test -f ~/x && test -f ~/y
'

View file

@ -1,32 +0,0 @@
test_description='--except'
. '../test-lib.bash'
test_expect_success 'setup' '
echo "apple" > ${DOTFILES}/x
'
test_expect_success 'run' '
run_dotbot --except link <<EOF
- shell:
- echo "pear" > ~/y
- link:
~/x: x
EOF
'
test_expect_success 'test' '
grep "pear" ~/y && ! test -f ~/x
'
test_expect_success 'run 2' '
run_dotbot --except shell <<EOF
- shell:
- echo "pear" > ~/z
- link:
~/x: x
EOF
'
test_expect_success 'test' '
grep "apple" ~/x && ! test -f ~/z
'

View file

@ -1,32 +0,0 @@
test_description='test exit on failure'
. '../test-lib.bash'
test_expect_success 'setup' '
echo "apple" > ${DOTFILES}/f1 &&
echo "orange" > ${DOTFILES}/f2 &&
echo "pineapple" > ${DOTFILES}/f3
'
test_expect_failure 'run_case1' '
run_dotbot -x <<EOF
- shell:
- "this_is_not_a_command"
- link:
~/f1:
EOF
'
test_expect_failure 'run_case2' '
run_dotbot -x <<EOF
- link:
~/f2:
- shell:
- "this_is_not_a_command"
- link:
~/f3:
EOF
'
test_expect_success 'test' '
[[ ! -f ~/f1 ]] && [[ -f ~/f2 ]] && [[ ! -f ~/f3 ]]
'

View file

@ -1,60 +0,0 @@
test_description='can find python executable with different names'
. '../test-lib.bash'
# the test machine needs to have a binary named `python`
test_expect_success 'setup' '
mkdir ~/tmp_bin &&
(
IFS=:
for p in $PATH; do
if [ -d $p ]; then
find $p -maxdepth 1 -mindepth 1 -exec sh -c \
'"'"'ln -sf {} $HOME/tmp_bin/$(basename {})'"'"' \;
fi
done
) &&
rm -f ~/tmp_bin/python &&
rm -f ~/tmp_bin/python2 &&
rm -f ~/tmp_bin/python3
'
test_expect_failure 'run' '
PATH="$HOME/tmp_bin" run_dotbot <<EOF
[]
EOF
'
test_expect_success 'setup 2' '
touch ~/tmp_bin/python &&
chmod +x ~/tmp_bin/python &&
cat >> ~/tmp_bin/python <<EOF
#!$HOME/tmp_bin/bash
exec $(command -v python)
EOF
'
test_expect_success 'run 2' '
PATH="$HOME/tmp_bin" run_dotbot <<EOF
[]
EOF
'
test_expect_success 'setup 3' '
mv ~/tmp_bin/python ~/tmp_bin/python2
'
test_expect_success 'run 3' '
PATH="$HOME/tmp_bin" run_dotbot <<EOF
[]
EOF
'
test_expect_success 'setup 4' '
mv ~/tmp_bin/python2 ~/tmp_bin/python3
'
test_expect_success 'run 4' '
PATH="$HOME/tmp_bin" run_dotbot <<EOF
[]
EOF
'

View file

@ -1,20 +0,0 @@
test_description='linking canonicalizes path by default'
. '../test-lib.bash'
test_expect_success 'setup' '
echo "apple" > ${DOTFILES}/f &&
ln -s dotfiles dotfiles-symlink
'
test_expect_success 'run' '
cat > "${DOTFILES}/${INSTALL_CONF}" <<EOF
- link:
~/.f:
path: f
EOF
${DOTBOT_EXEC} -c dotfiles-symlink/${INSTALL_CONF}
'
test_expect_success 'test' '
[ "$(readlink ~/.f | cut -d/ -f5-)" = "dotfiles/f" ]
'

View file

@ -1,26 +0,0 @@
test_description='link uses destination if source is null'
. '../test-lib.bash'
test_expect_success 'setup' '
echo "apple" > ${DOTFILES}/f &&
echo "grape" > ${DOTFILES}/fd
'
test_expect_success 'run' '
run_dotbot <<EOF
- link:
~/f:
~/.f:
~/fd:
force: false
~/.fd:
force: false
EOF
'
test_expect_success 'test' '
grep "apple" ~/f &&
grep "apple" ~/.f &&
grep "grape" ~/fd &&
grep "grape" ~/.fd
'

View file

@ -1,17 +0,0 @@
test_description='link expands user in target'
. '../test-lib.bash'
test_expect_success 'setup' '
echo "apple" > ~/f
'
test_expect_success 'run' '
run_dotbot <<EOF
- link:
~/g: ~/f
EOF
'
test_expect_success 'test' '
grep "apple" ~/g
'

View file

@ -1,20 +0,0 @@
test_description='link expands environment variables in extended config syntax'
. '../test-lib.bash'
test_expect_success 'setup' '
echo "grape" > ${DOTFILES}/h
'
test_expect_success 'run' '
export APPLE="h" &&
run_dotbot <<EOF
- link:
~/.i:
path: \$APPLE
relink: true
EOF
'
test_expect_success 'test' '
grep "grape" ~/.i
'

View file

@ -1,18 +0,0 @@
test_description='link expands environment variables in source'
. '../test-lib.bash'
test_expect_success 'setup' '
echo "grape" > ${DOTFILES}/h
'
test_expect_success 'run' '
export APPLE="h" &&
run_dotbot <<EOF
- link:
~/.i: \$APPLE
EOF
'
test_expect_success 'test' '
grep "grape" ~/.i
'

View file

@ -1,25 +0,0 @@
test_description='link expands environment variables in target'
. '../test-lib.bash'
test_expect_success 'setup' '
echo "apple" > ${DOTFILES}/f &&
echo "grape" > ${DOTFILES}/h
'
test_expect_success 'run' '
export ORANGE=".config" &&
export BANANA="g" &&
unset PEAR &&
run_dotbot <<EOF
- link:
~/\${ORANGE}/\$BANANA:
path: f
create: true
~/\$PEAR: h
EOF
'
test_expect_success 'test' '
grep "apple" ~/.config/g &&
grep "grape" ~/\$PEAR
'

View file

@ -1,18 +0,0 @@
test_description='link leaves unset environment variables'
. '../test-lib.bash'
test_expect_success 'setup' '
echo "apple" > ${DOTFILES}/\$ORANGE
'
test_expect_success 'run' '
unset ORANGE &&
run_dotbot <<EOF
- link:
~/.f: \$ORANGE
EOF
'
test_expect_success 'test' '
grep "apple" ~/.f
'

View file

@ -1,24 +0,0 @@
test_description='force leaves file when target nonexistent'
. '../test-lib.bash'
test_expect_success 'setup' '
mkdir ~/dir &&
touch ~/file
'
test_expect_failure 'run' '
run_dotbot <<EOF
- link:
~/dir:
path: dir
force: true
~/file:
path: file
force: true
EOF
'
test_expect_success 'test' '
test -d ~/dir &&
test -f ~/file
'

View file

@ -1,21 +0,0 @@
test_description='force overwrites symlinked directory'
. '../test-lib.bash'
test_expect_success 'setup' '
mkdir ${DOTFILES}/dir ~/dir &&
touch ${DOTFILES}/dir/f &&
ln -s ~/ ~/.dir
'
test_expect_success 'run' '
run_dotbot <<EOF
- link:
~/.dir:
path: dir
force: true
EOF
'
test_expect_success 'test' '
test -f ~/.dir/f
'

View file

@ -1,45 +0,0 @@
test_description='link glob ambiguous'
. '../test-lib.bash'
test_expect_success 'setup' '
mkdir ${DOTFILES}/foo
'
test_expect_failure 'run 1' '
run_dotbot <<EOF
- link:
~/foo/:
path: foo
glob: true
EOF
'
test_expect_failure 'test 1' '
test -d ~/foo
'
test_expect_failure 'run 2' '
run_dotbot <<EOF
- link:
~/foo/:
path: foo/
glob: true
EOF
'
test_expect_failure 'test 2' '
test -d ~/foo
'
test_expect_success 'run 3' '
run_dotbot <<EOF
- link:
~/foo:
path: foo
glob: true
EOF
'
test_expect_success 'test 3' '
test -d ~/foo
'

View file

@ -1,123 +0,0 @@
test_description='link glob exclude'
. '../test-lib.bash'
test_expect_success 'setup 1' '
mkdir -p ${DOTFILES}/config/{foo,bar,baz} &&
echo "apple" > ${DOTFILES}/config/foo/a &&
echo "banana" > ${DOTFILES}/config/bar/b &&
echo "cherry" > ${DOTFILES}/config/bar/c &&
echo "donut" > ${DOTFILES}/config/baz/d
'
test_expect_success 'run 1' '
run_dotbot -v <<EOF
- defaults:
link:
glob: true
create: true
- link:
~/.config/:
path: config/*
exclude: [config/baz]
EOF
'
test_expect_success 'test 1' '
! readlink ~/.config/ &&
readlink ~/.config/foo &&
! readlink ~/.config/baz &&
grep "apple" ~/.config/foo/a &&
grep "banana" ~/.config/bar/b &&
grep "cherry" ~/.config/bar/c
'
test_expect_success 'setup 2' '
rm -rf ~/.config &&
mkdir ${DOTFILES}/config/baz/buzz &&
echo "egg" > ${DOTFILES}/config/baz/buzz/e
'
test_expect_success 'run 2' '
run_dotbot -v <<EOF
- defaults:
link:
glob: true
create: true
- link:
~/.config/:
path: config/*/*
exclude: [config/baz/*]
EOF
'
test_expect_success 'test 2' '
! readlink ~/.config/ &&
! readlink ~/.config/foo &&
[ ! -d ~/.config/baz ] &&
readlink ~/.config/foo/a &&
grep "apple" ~/.config/foo/a &&
grep "banana" ~/.config/bar/b &&
grep "cherry" ~/.config/bar/c
'
test_expect_success 'setup 3' '
rm -rf ~/.config &&
mkdir ${DOTFILES}/config/baz/bizz &&
echo "grape" > ${DOTFILES}/config/baz/bizz/g
'
test_expect_success 'run 3' '
run_dotbot -v <<EOF
- defaults:
link:
glob: true
create: true
- link:
~/.config/:
path: config/*/*
exclude: [config/baz/buzz]
EOF
'
test_expect_success 'test 3' '
! readlink ~/.config/ &&
! readlink ~/.config/foo &&
readlink ~/.config/foo/a &&
! readlink ~/.config/baz/buzz &&
readlink ~/.config/baz/bizz &&
grep "apple" ~/.config/foo/a &&
grep "banana" ~/.config/bar/b &&
grep "cherry" ~/.config/bar/c &&
grep "donut" ~/.config/baz/d &&
grep "grape" ~/.config/baz/bizz/g
'
test_expect_success 'setup 4' '
rm -rf ~/.config &&
mkdir ${DOTFILES}/config/fiz &&
echo "fig" > ${DOTFILES}/config/fiz/f
'
test_expect_success 'run 4' '
run_dotbot -v <<EOF
- defaults:
link:
glob: true
create: true
- link:
~/.config/:
path: config/*/*
exclude: [config/baz/*, config/fiz/*]
EOF
'
test_expect_success 'test 4' '
! readlink ~/.config/ &&
! readlink ~/.config/foo &&
[ ! -d ~/.config/baz ] &&
[ ! -d ~/.config/fiz ] &&
readlink ~/.config/foo/a &&
grep "apple" ~/.config/foo/a &&
grep "banana" ~/.config/bar/b &&
grep "cherry" ~/.config/bar/c
'

View file

@ -1,31 +0,0 @@
test_description='link glob multi star'
. '../test-lib.bash'
test_expect_success 'setup' '
mkdir ${DOTFILES}/config &&
mkdir ${DOTFILES}/config/foo &&
mkdir ${DOTFILES}/config/bar &&
echo "apple" > ${DOTFILES}/config/foo/a &&
echo "banana" > ${DOTFILES}/config/bar/b &&
echo "cherry" > ${DOTFILES}/config/bar/c
'
test_expect_success 'run' '
run_dotbot -v <<EOF
- defaults:
link:
glob: true
create: true
- link:
~/.config/: config/*/*
EOF
'
test_expect_success 'test' '
! readlink ~/.config/ &&
! readlink ~/.config/foo &&
readlink ~/.config/foo/a &&
grep "apple" ~/.config/foo/a &&
grep "banana" ~/.config/bar/b &&
grep "cherry" ~/.config/bar/c
'

View file

@ -1,106 +0,0 @@
test_description='link glob, all patterns'
. '../test-lib.bash'
allfruit=(apple apricot banana cherry currant cantalope)
test_expect_success 'glob patterns setup' '
mkdir ${DOTFILES}/conf &&
for fruit in "${allfruit[@]}"; do
echo "${fruit}" > ${DOTFILES}/conf/${fruit}
echo "dot-${fruit}" > ${DOTFILES}/conf/.${fruit}
done
'
test_expect_success 'glob patterns: "conf/*"' '
run_dotbot -v <<EOF
- defaults:
link:
glob: true
create: true
- link:
~/globtest: conf/*
EOF
for fruit in "${allfruit[@]}"; do
grep "${fruit}" ~/globtest/${fruit} &&
test \! -e ~/globtest/.${fruit}
done
'
test_expect_success 'reset' 'rm -rf ~/globtest'
test_expect_success 'glob pattern: "conf/.*"' '
run_dotbot -v <<EOF
- defaults:
link:
glob: true
create: true
- link:
~/globtest: conf/.*
EOF
for fruit in "${allfruit[@]}"; do
test \! -e ~/globtest/${fruit} &&
grep "dot-${fruit}" ~/globtest/.${fruit}
done
'
test_expect_success 'reset 2' 'rm -rf ~/globtest'
test_expect_success 'glob pattern: "conf/[bc]*"' '
run_dotbot -v <<EOF
- defaults:
link:
glob: true
create: true
- link:
~/globtest: conf/[bc]*
EOF
for fruit in "${allfruit[@]}"; do
[[ $fruit = [bc]* ]] &&
grep "${fruit}" ~/globtest/${fruit} ||
test \! -e ~/globtest/${fruit} &&
test \! -e ~/globtest/.${fruit}
done
'
test_expect_success 'reset 3' 'rm -rf ~/globtest'
test_expect_success 'glob pattern: "conf/*e"' '
run_dotbot -v <<EOF
- defaults:
link:
glob: true
create: true
- link:
~/globtest: conf/*e
EOF
for fruit in "${allfruit[@]}"; do
[[ $fruit = *e ]] &&
grep "${fruit}" ~/globtest/${fruit} ||
test \! -e ~/globtest/${fruit} &&
test \! -e ~/globtest/.${fruit}
done
'
test_expect_success 'reset 4' 'rm -rf ~/globtest'
test_expect_success 'glob pattern: "conf/??r*"' '
run_dotbot -v <<EOF
- defaults:
link:
glob: true
create: true
- link:
~/globtest: conf/??r*
EOF
for fruit in "${allfruit[@]}"; do
[[ $fruit = ??r* ]] &&
grep "${fruit}" ~/globtest/${fruit} ||
test \! -e ~/globtest/${fruit} &&
test \! -e ~/globtest/.${fruit}
done
'

View file

@ -1,46 +0,0 @@
test_description='link glob recursive'
. '../test-lib.bash'
check_python_version ">=" 3.5 \
|| test_expect_failure 'expect-fail' '
run_dotbot -v <<EOF
- link:
~/.config/:
glob: true
path: bogus/**
EOF
'
# Skip remaining tests if not supported
check_python_version ">=" 3.5 \
|| skip_tests
test_expect_success 'setup' '
mkdir -p ${DOTFILES}/config/foo/bar &&
echo "apple" > ${DOTFILES}/config/foo/bar/a &&
echo "banana" > ${DOTFILES}/config/foo/bar/b &&
echo "cherry" > ${DOTFILES}/config/foo/bar/c
'
test_expect_success 'run' '
run_dotbot -v <<EOF
- defaults:
link:
glob: true
create: true
- link:
~/.config/:
path: config/**
exclude: [config/**/b]
EOF
'
test_expect_success 'test' '
! readlink ~/.config/ &&
! readlink ~/.config/foo &&
! readlink ~/.config/foo/bar &&
readlink ~/.config/foo/bar/a &&
grep "apple" ~/.config/foo/bar/a &&
test \! -e ~/.config/foo/bar/b &&
grep "cherry" ~/.config/foo/bar/c
'

View file

@ -1,93 +0,0 @@
test_description='link glob'
. '../test-lib.bash'
test_expect_success 'setup 1' '
mkdir ${DOTFILES}/bin &&
echo "apple" > ${DOTFILES}/bin/a &&
echo "banana" > ${DOTFILES}/bin/b &&
echo "cherry" > ${DOTFILES}/bin/c
'
test_expect_success 'run 1' '
run_dotbot -v <<EOF
- defaults:
link:
glob: true
create: true
- link:
~/bin: bin/*
EOF
'
test_expect_success 'test 1' '
grep "apple" ~/bin/a &&
grep "banana" ~/bin/b &&
grep "cherry" ~/bin/c
'
test_expect_success 'setup 2' '
rm -rf ~/bin
'
test_expect_success 'run 2' '
run_dotbot -v <<EOF
- defaults:
link:
glob: true
create: true
- link:
~/bin/: bin/*
EOF
'
test_expect_success 'test 2' '
grep "apple" ~/bin/a &&
grep "banana" ~/bin/b &&
grep "cherry" ~/bin/c
'
test_expect_success 'setup 3' '
rm -rf ~/bin &&
echo "dot_apple" > ${DOTFILES}/bin/.a &&
echo "dot_banana" > ${DOTFILES}/bin/.b &&
echo "dot_cherry" > ${DOTFILES}/bin/.c
'
test_expect_success 'run 3' '
run_dotbot -v <<EOF
- defaults:
link:
glob: true
create: true
- link:
~/bin/: bin/.*
EOF
'
test_expect_success 'test 3' '
grep "dot_apple" ~/bin/.a &&
grep "dot_banana" ~/bin/.b &&
grep "dot_cherry" ~/bin/.c
'
test_expect_success 'setup 4' '
rm -rf ~/bin &&
echo "dot_apple" > ${DOTFILES}/.a &&
echo "dot_banana" > ${DOTFILES}/.b &&
echo "dot_cherry" > ${DOTFILES}/.c
'
test_expect_success 'run 4' '
run_dotbot -v <<EOF
- link:
"~":
path: .*
glob: true
EOF
'
test_expect_success 'test 4' '
grep "dot_apple" ~/.a &&
grep "dot_banana" ~/.b &&
grep "dot_cherry" ~/.c
'

View file

@ -1,51 +0,0 @@
test_description='link if'
. '../test-lib.bash'
test_expect_success 'setup' '
mkdir ~/d
echo "apple" > ${DOTFILES}/f
'
test_expect_success 'run' '
run_dotbot <<EOF
- link:
~/.f:
path: f
if: "true"
~/.g:
path: f
if: "false"
~/.h:
path: f
if: "[ -d ~/d ]"
~/.i:
path: f
if: "badcommand"
EOF
'
test_expect_success 'test' '
grep "apple" ~/.f &&
! test -f ~/.g &&
grep "apple" ~/.h &&
! test -f ~/.i
'
test_expect_success 'run 2' '
run_dotbot <<EOF
- defaults:
link:
if: "false"
- link:
~/.j:
path: f
if: "true"
~/.k:
path: f
EOF
'
test_expect_success 'test 2' '
grep "apple" ~/.j &&
! test -f ~/.k
'

View file

@ -1,23 +0,0 @@
test_description='link is created even if source is missing'
. '../test-lib.bash'
test_expect_failure 'run' '
run_dotbot <<EOF
- link:
~/missing_link:
path: missing
EOF
'
test_expect_success 'run 2' '
run_dotbot <<EOF
- link:
~/missing_link:
path: missing
ignore-missing: true
EOF
'
test_expect_success 'test' '
test -L ~/missing_link
'

View file

@ -1,18 +0,0 @@
test_description='relink does not overwrite file'
. '../test-lib.bash'
test_expect_success 'setup' '
echo "apple" > ${DOTFILES}/f &&
echo "grape" > ~/.f
'
test_expect_failure 'run' '
run_dotbot <<EOF
- link:
~/.f: f
EOF
'
test_expect_success 'test' '
grep "grape" ~/.f
'

View file

@ -1,40 +0,0 @@
test_description='linking path canonicalization can be disabled'
. '../test-lib.bash'
test_expect_success 'setup' '
echo "apple" > ${DOTFILES}/f &&
echo "grape" > ${DOTFILES}/g &&
ln -s dotfiles dotfiles-symlink
'
test_expect_success 'run' '
cat > "${DOTFILES}/${INSTALL_CONF}" <<EOF
- defaults:
link:
canonicalize-path: false
- link:
~/.f:
path: f
EOF
${DOTBOT_EXEC} -c ./dotfiles-symlink/${INSTALL_CONF}
'
test_expect_success 'test' '
[ "$(readlink ~/.f | cut -d/ -f5-)" = "dotfiles-symlink/f" ]
'
test_expect_success 'run 2' '
cat > "${DOTFILES}/${INSTALL_CONF}" <<EOF
- defaults:
link:
canonicalize: false
- link:
~/.g:
path: g
EOF
${DOTBOT_EXEC} -c ./dotfiles-symlink/${INSTALL_CONF}
'
test_expect_success 'test' '
[ "$(readlink ~/.g | cut -d/ -f5-)" = "dotfiles-symlink/g" ]
'

View file

@ -1,23 +0,0 @@
test_description='link prefix'
. '../test-lib.bash'
test_expect_success 'setup' '
mkdir ${DOTFILES}/conf &&
echo "apple" > ${DOTFILES}/conf/a &&
echo "banana" > ${DOTFILES}/conf/b &&
echo "cherry" > ${DOTFILES}/conf/c
'
test_expect_success 'test glob w/ prefix' '
run_dotbot -v <<EOF
- link:
~/:
glob: true
path: conf/*
prefix: '.'
EOF
grep "apple" ~/.a &&
grep "banana" ~/.b &&
grep "cherry" ~/.c
'

View file

@ -1,36 +0,0 @@
test_description='relative linking works'
. '../test-lib.bash'
test_expect_success 'setup' '
echo "apple" > ${DOTFILES}/f &&
mkdir ${DOTFILES}/d &&
echo "grape" > ${DOTFILES}/d/e
'
test_expect_success 'run' '
run_dotbot <<EOF
- link:
~/.f:
path: f
~/.frel:
path: f
relative: true
~/nested/.frel:
path: f
create: true
relative: true
~/.d:
path: d
relative: true
EOF
'
test_expect_success 'test' '
grep "apple" ~/.f &&
grep "apple" ~/.frel &&
[[ "$(readlink ~/.f)" == "$(readlink -f dotfiles/f)" ]] &&
[[ "$(readlink ~/.frel)" == "dotfiles/f" ]] &&
[[ "$(readlink ~/nested/.frel)" == "../dotfiles/f" ]] &&
grep "grape" ~/.d/e &&
[[ "$(readlink ~/.d)" == "dotfiles/d" ]]
'

View file

@ -1,20 +0,0 @@
test_description='relink does not overwrite file'
. '../test-lib.bash'
test_expect_success 'setup' '
echo "apple" > ${DOTFILES}/f &&
echo "grape" > ~/.f
'
test_expect_failure 'run' '
run_dotbot <<EOF
- link:
~/.f:
path: f
relink: true
EOF
'
test_expect_success 'test' '
grep "grape" ~/.f
'

View file

@ -1,21 +0,0 @@
test_description='relink overwrites symlink'
. '../test-lib.bash'
test_expect_success 'setup' '
echo "apple" > ${DOTFILES}/f &&
echo "grape" > ~/f &&
ln -s ~/f ~/.f
'
test_expect_success 'run' '
run_dotbot <<EOF
- link:
~/.f:
path: f
relink: true
EOF
'
test_expect_success 'test' '
grep "apple" ~/.f
'

View file

@ -1,32 +0,0 @@
test_description='relink relative does not incorrectly relink file'
. '../test-lib.bash'
test_expect_success 'setup' '
echo "apple" > ${DOTFILES}/f &&
echo "grape" > ~/.f
'
test_expect_success 'run' '
run_dotbot <<EOF
- link:
~/.folder/f:
path: f
create: true
relative: true
EOF
'
# these are done in a single block because they run in a subshell, and it
# wouldn't be possible to access `$mtime` outside of the subshell
test_expect_success 'test' '
mtime=$(stat ~/.folder/f | grep Modify)
run_dotbot <<EOF
- link:
~/.folder/f:
path: f
create: true
relative: true
relink: true
EOF
[[ "$mtime" == "$(stat ~/.folder/f | grep Modify)" ]]
'

View file

@ -1,22 +0,0 @@
test_description='--only does not skip defaults'
. '../test-lib.bash'
test_expect_success 'setup' '
echo "apple" > ${DOTFILES}/x
'
test_expect_success 'run' '
run_dotbot --only link <<EOF
- defaults:
link:
create: true
- shell:
- echo "pear" > ~/z
- link:
~/d/x: x
EOF
'
test_expect_success 'test' '
grep "apple" ~/d/x && ! test -f ~/z
'

View file

@ -1,20 +0,0 @@
test_description='--only with multiple arguments'
. '../test-lib.bash'
test_expect_success 'setup' '
ln -s ${DOTFILES}/nonexistent ~/bad && touch ${DOTFILES}/y
'
test_expect_success 'run' '
run_dotbot --only clean shell <<EOF
- clean: ["~"]
- shell:
- echo "x" > ~/x
- link:
~/y: y
EOF
'
test_expect_success 'test' '
! test -f ~/bad && grep "x" ~/x && ! test -f ~/y
'

View file

@ -1,32 +0,0 @@
test_description='--only'
. '../test-lib.bash'
test_expect_success 'setup' '
echo "apple" > ${DOTFILES}/x
'
test_expect_success 'run' '
run_dotbot --only shell <<EOF
- shell:
- echo "pear" > ~/y
- link:
~/x: x
EOF
'
test_expect_success 'test' '
grep "pear" ~/y && ! test -f ~/x
'
test_expect_success 'run 2' '
run_dotbot --only link <<EOF
- shell:
- echo "pear" > ~/z
- link:
~/x: x
EOF
'
test_expect_success 'test' '
grep "apple" ~/x && ! test -f ~/z
'

View file

@ -1,29 +0,0 @@
test_description='directory-based plugin loading works'
. '../test-lib.bash'
test_expect_success 'setup' '
mkdir ${DOTFILES}/plugins
cat > ${DOTFILES}/plugins/test.py <<EOF
import dotbot
import os.path
class Test(dotbot.Plugin):
def can_handle(self, directive):
return directive == "test"
def handle(self, directive, data):
with open(os.path.expanduser("~/flag"), "w") as f:
f.write("it works")
return True
EOF
'
test_expect_success 'run' '
run_dotbot --plugin-dir ${DOTFILES}/plugins <<EOF
- test: ~
EOF
'
test_expect_success 'test' '
grep "it works" ~/flag
'

View file

@ -1,17 +0,0 @@
test_description='can disable built-in plugins'
. '../test-lib.bash'
test_expect_success 'setup' '
echo "apple" > ${DOTFILES}/f
'
test_expect_failure 'run' '
run_dotbot --disable-built-in-plugins <<EOF
- link:
~/.f: f
EOF
'
test_expect_failure 'test' '
test -f ~/.f
'

View file

@ -1,64 +0,0 @@
test_description='plugin loading works'
. '../test-lib.bash'
test_expect_success 'setup 1' '
cat > ${DOTFILES}/test.py <<EOF
import dotbot
import os.path
class Test(dotbot.Plugin):
def can_handle(self, directive):
return directive == "test"
def handle(self, directive, data):
with open(os.path.expanduser("~/flag"), "w") as f:
f.write("it works")
return True
EOF
'
test_expect_success 'run 1' '
run_dotbot --plugin ${DOTFILES}/test.py <<EOF
- test: ~
EOF
'
test_expect_success 'test 1' '
grep "it works" ~/flag
'
test_expect_success 'setup 2' '
rm ${DOTFILES}/test.py;
cat > ${DOTFILES}/test.py <<EOF
import dotbot
import os.path
class Test(dotbot.Plugin):
def can_handle(self, directive):
return directive == "test"
def handle(self, directive, data):
self._log.debug("Attempting to get options from Context")
options = self._context.options()
if len(options.plugins) != 1:
self._log.debug("Context.options.plugins length is %i, expected 1" % len(options.plugins))
return False
if not options.plugins[0].endswith("test.py"):
self._log.debug("Context.options.plugins[0] is %s, expected end with test.py" % options.plugins[0])
return False
with open(os.path.expanduser("~/flag"), "w") as f:
f.write("it works")
return True
EOF
'
test_expect_success 'run 2' '
run_dotbot --plugin ${DOTFILES}/test.py <<EOF
- test: ~
EOF
'
test_expect_success 'test 2' '
grep "it works" ~/flag
'

View file

@ -1,11 +0,0 @@
test_description='shell command stdout works'
. '../test-lib.bash'
test_expect_success 'run' '
(run_dotbot | grep "^apple") <<EOF
- shell:
-
command: echo apple
stdout: true
EOF
'

View file

@ -1,79 +0,0 @@
test_description='cli options can override config file'
. '../test-lib.bash'
test_expect_success 'run 1' '
(run_dotbot -vv | (grep "^apple")) <<EOF
- shell:
-
command: echo apple
EOF
'
test_expect_success 'run 2' '
(run_dotbot -vv | (grep "^apple")) <<EOF
- shell:
-
command: echo apple
stdout: false
EOF
'
test_expect_success 'run 3' '
(run_dotbot -vv | (grep "^apple")) <<EOF
- defaults:
shell:
stdout: false
- shell:
- command: echo apple
EOF
'
# Control to make sure stderr redirection is working as expected
test_expect_failure 'run 4' '
(run_dotbot -vv | (grep "^apple")) <<EOF
- shell:
- command: echo apple >&2
EOF
'
test_expect_success 'run 5' '
(run_dotbot -vv 2>&1 | (grep "^apple")) <<EOF
- shell:
- command: echo apple >&2
EOF
'
test_expect_success 'run 6' '
(run_dotbot -vv 2>&1 | (grep "^apple")) <<EOF
- shell:
-
command: echo apple >&2
stdout: false
EOF
'
test_expect_success 'run 7' '
(run_dotbot -vv 2>&1 | (grep "^apple")) <<EOF
- defaults:
shell:
stdout: false
- shell:
- command: echo apple >&2
EOF
'
# Make sure that we must use verbose level 2
# This preserves backwards compatability
test_expect_failure 'run 8' '
(run_dotbot -v | (grep "^apple")) <<EOF
- shell:
- command: echo apple
EOF
'
test_expect_failure 'run 9' '
(run_dotbot -v | (grep "^apple")) <<EOF
- shell:
- command: echo apple >&2
EOF
'

View file

@ -1,22 +0,0 @@
test_description='shell command stdout works in compact form'
. '../test-lib.bash'
test_expect_success 'run' '
(run_dotbot | grep "^apple") <<EOF
- defaults:
shell:
stdout: true
- shell:
- echo apple
EOF
'
test_expect_success 'run 2' '
(run_dotbot | grep "^apple") <<EOF
- defaults:
shell:
stdout: true
- shell:
- [echo apple, "echoing message"]
EOF
'

View file

@ -1,9 +0,0 @@
test_description='shell command stdout disabled by default'
. '../test-lib.bash'
test_expect_success 'run' '
(run_dotbot | (! grep "^banana")) <<EOF
- shell:
- echo banana
EOF
'

View file

@ -1,14 +0,0 @@
test_description='shell command can override default'
. '../test-lib.bash'
test_expect_success 'run' '
(run_dotbot | (! grep "^apple")) <<EOF
- defaults:
shell:
stdout: true
- shell:
-
command: echo apple
stdout: false
EOF
'

View file

@ -1,30 +0,0 @@
test_description='shell command can be suppressed in output'
. '../test-lib.bash'
# when not quiet, expect to see command that was run
test_expect_success 'run' '
(run_dotbot | grep "echo banana") <<EOF
- shell:
- command: echo banana
description: echoing a thing...
EOF
'
# when quiet, expect command to be suppressed
test_expect_success 'run 2' '
(run_dotbot | (! grep "echo banana")) <<EOF
- shell:
- command: echo banana
description: echoing a thing...
quiet: true
EOF
'
# when no description, expect to see command
test_expect_success 'run 3' '
(run_dotbot | grep "echo banana") <<EOF
- shell:
- command: echo banana
quiet: true
EOF
'

View file

@ -1,22 +0,0 @@
test_description='install shim works'
. '../test-lib.bash'
test_expect_success 'setup' '
cd ${DOTFILES}
git init
git submodule add ${BASEDIR} dotbot
cp ./dotbot/tools/git-submodule/install .
echo "pear" > ${DOTFILES}/foo
'
test_expect_success 'run' '
cat > ${DOTFILES}/install.conf.yaml <<EOF
- link:
~/.foo: foo
EOF
${DOTFILES}/install
'
test_expect_success 'test' '
grep "pear" ~/.foo
'

331
tests/conftest.py Normal file
View file

@ -0,0 +1,331 @@
import ctypes
import json
import os
import shutil
import sys
import tempfile
from shutil import rmtree
import pytest
import yaml
import dotbot.cli
try:
import builtins
import unittest.mock as mock
except ImportError:
# Python 2.7 compatibility
builtins = None
import __builtin__
import mock # noqa: module not found
def get_long_path(path):
"""Get the long path for a given path."""
# Do nothing for non-Windows platforms.
if sys.platform[:5] != "win32":
return path
buffer_size = 1000
buffer = ctypes.create_unicode_buffer(buffer_size)
get_long_path_name = ctypes.windll.kernel32.GetLongPathNameW
get_long_path_name(path, buffer, buffer_size)
return buffer.value
# Python 2.7 compatibility:
# On Linux, Python 2.7's tempfile.TemporaryFile() requires unlink access.
# This list is updated by a tempfile._mkstemp_inner() wrapper,
# and its contents are checked by wrapped functions.
allowed_tempfile_internal_unlink_calls = []
def wrap_function(function, function_path, arg_index, kwarg_key, root):
def wrapper(*args, **kwargs):
if kwarg_key in kwargs:
value = kwargs[kwarg_key]
else:
value = args[arg_index]
# Python 2.7 compatibility:
# Allow tempfile.TemporaryFile's internal unlink calls to work.
if value in allowed_tempfile_internal_unlink_calls:
return function(*args, **kwargs)
msg = "The '{0}' argument to {1}() must be an absolute path"
msg = msg.format(kwarg_key, function_path)
assert value == os.path.abspath(value), msg
msg = "The '{0}' argument to {1}() must be rooted in {2}"
msg = msg.format(kwarg_key, function_path, root)
assert value[: len(str(root))] == str(root), msg
return function(*args, **kwargs)
return wrapper
def wrap_open(root):
try:
wrapped = getattr(builtins, "open")
except AttributeError:
# Python 2.7 compatibility
wrapped = getattr(__builtin__, "open")
def wrapper(*args, **kwargs):
if "file" in kwargs:
value = kwargs["file"]
else:
value = args[0]
mode = "r"
if "mode" in kwargs:
mode = kwargs["mode"]
elif len(args) >= 2:
mode = args[1]
msg = "The 'file' argument to open() must be an absolute path"
if value != os.devnull and "w" in mode:
assert value == os.path.abspath(value), msg
msg = "The 'file' argument to open() must be rooted in {0}"
msg = msg.format(root)
if value != os.devnull and "w" in mode:
assert value[: len(str(root))] == str(root), msg
return wrapped(*args, **kwargs)
return wrapper
def rmtree_error_handler(_, path, __):
# Handle read-only files and directories.
os.chmod(path, 0o777)
if os.path.isdir(path):
rmtree(path)
else:
os.unlink(path)
@pytest.fixture(autouse=True, scope="session")
def standardize_tmp():
r"""Standardize the temporary directory path.
On MacOS, `/var` is a symlink to `/private/var`.
This creates issues with link canonicalization and relative link tests,
so this fixture rewrites environment variables and Python variables
to ensure the tests work the same as on Linux and Windows.
On Windows in GitHub CI, the temporary directory may be a short path.
For example, `C:\Users\RUNNER~1\...` instead of `C:\Users\runneradmin\...`.
This causes string-based path comparisons to fail.
"""
tmp = tempfile.gettempdir()
# MacOS: `/var` is a symlink.
tmp = os.path.abspath(os.path.realpath(tmp))
# Windows: The temporary directory may be a short path.
if sys.platform[:5] == "win32":
tmp = get_long_path(tmp)
os.environ["TMP"] = tmp
os.environ["TEMP"] = tmp
os.environ["TMPDIR"] = tmp
tempfile.tempdir = tmp
yield
@pytest.fixture(autouse=True)
def root(standardize_tmp):
"""Create a temporary directory for the duration of each test."""
# Reset allowed_tempfile_internal_unlink_calls.
global allowed_tempfile_internal_unlink_calls
allowed_tempfile_internal_unlink_calls = []
# Dotbot changes the current working directory,
# so this must be reset at the end of each test.
current_working_directory = os.getcwd()
# Create an isolated temporary directory from which to operate.
current_root = tempfile.mkdtemp()
functions_to_wrap = [
(os, "chflags", 0, "path"),
(os, "chmod", 0, "path"),
(os, "chown", 0, "path"),
(os, "copy_file_range", 1, "dst"),
(os, "lchflags", 0, "path"),
(os, "lchmod", 0, "path"),
(os, "link", 1, "dst"),
(os, "makedirs", 0, "name"),
(os, "mkdir", 0, "path"),
(os, "mkfifo", 0, "path"),
(os, "mknod", 0, "path"),
(os, "remove", 0, "path"),
(os, "removedirs", 0, "name"),
(os, "removexattr", 0, "path"),
(os, "rename", 0, "src"), # Check both
(os, "rename", 1, "dst"),
(os, "renames", 0, "old"), # Check both
(os, "renames", 1, "new"),
(os, "replace", 0, "src"), # Check both
(os, "replace", 1, "dst"),
(os, "rmdir", 0, "path"),
(os, "setxattr", 0, "path"),
(os, "splice", 1, "dst"),
(os, "symlink", 1, "dst"),
(os, "truncate", 0, "path"),
(os, "unlink", 0, "path"),
(os, "utime", 0, "path"),
(shutil, "chown", 0, "path"),
(shutil, "copy", 1, "dst"),
(shutil, "copy2", 1, "dst"),
(shutil, "copyfile", 1, "dst"),
(shutil, "copymode", 1, "dst"),
(shutil, "copystat", 1, "dst"),
(shutil, "copytree", 1, "dst"),
(shutil, "make_archive", 0, "base_name"),
(shutil, "move", 0, "src"), # Check both
(shutil, "move", 1, "dst"),
(shutil, "rmtree", 0, "path"),
(shutil, "unpack_archive", 1, "extract_dir"),
]
patches = []
for module, function_name, arg_index, kwarg_key in functions_to_wrap:
# Skip anything that doesn't exist in this version of Python.
if not hasattr(module, function_name):
continue
# These values must be passed to a separate function
# to ensure the variable closures work correctly.
function_path = "{0}.{1}".format(module.__name__, function_name)
function = getattr(module, function_name)
wrapped = wrap_function(function, function_path, arg_index, kwarg_key, current_root)
patches.append(mock.patch(function_path, wrapped))
# open() must be separately wrapped.
if builtins is not None:
function_path = "builtins.open"
else:
# Python 2.7 compatibility
function_path = "__builtin__.open"
wrapped = wrap_open(current_root)
patches.append(mock.patch(function_path, wrapped))
# Block all access to bad functions.
if hasattr(os, "chroot"):
patches.append(mock.patch("os.chroot", lambda *_, **__: None))
# Patch tempfile._mkstemp_inner() so tempfile.TemporaryFile()
# can unlink files immediately.
mkstemp_inner = tempfile._mkstemp_inner
def wrap_mkstemp_inner(*args, **kwargs):
(fd, name) = mkstemp_inner(*args, **kwargs)
allowed_tempfile_internal_unlink_calls.append(name)
return fd, name
patches.append(mock.patch("tempfile._mkstemp_inner", wrap_mkstemp_inner))
[patch.start() for patch in patches]
try:
yield current_root
finally:
[patch.stop() for patch in patches]
os.chdir(current_working_directory)
rmtree(current_root, onerror=rmtree_error_handler)
@pytest.fixture
def home(monkeypatch, root):
"""Create a home directory for the duration of the test.
On *nix, the environment variable "HOME" will be mocked.
On Windows, the environment variable "USERPROFILE" will be mocked.
"""
home = os.path.abspath(os.path.join(root, "home/user"))
os.makedirs(home)
if sys.platform[:5] == "win32":
monkeypatch.setenv("USERPROFILE", home)
else:
monkeypatch.setenv("HOME", home)
yield home
class Dotfiles(object):
"""Create and manage a dotfiles directory for a test."""
def __init__(self, root):
self.root = root
self.config = None
self.config_filename = None
self.directory = os.path.join(root, "dotfiles")
os.mkdir(self.directory)
def makedirs(self, path):
os.makedirs(os.path.abspath(os.path.join(self.directory, path)))
def write(self, path, content=""):
path = os.path.abspath(os.path.join(self.directory, path))
if not os.path.exists(os.path.dirname(path)):
os.makedirs(os.path.dirname(path))
with open(path, "w") as file:
file.write(content)
def write_config(self, config, serializer="yaml", path=None):
"""Write a dotbot config and return the filename."""
assert serializer in {"json", "yaml"}, "Only json and yaml are supported"
if serializer == "yaml":
serialize = yaml.dump
else: # serializer == "json"
serialize = json.dumps
if path:
msg = "The config file path must be an absolute path"
assert path == os.path.abspath(path), msg
msg = "The config file path must be rooted in {0}"
msg = msg.format(root)
assert path[: len(str(root))] == str(root), msg
self.config_filename = path
else:
self.config_filename = os.path.join(self.directory, "install.conf.yaml")
self.config = config
with open(self.config_filename, "w") as file:
file.write(serialize(config))
return self.config_filename
@pytest.fixture
def dotfiles(root):
"""Create a dotfiles directory."""
yield Dotfiles(root)
@pytest.fixture
def run_dotbot(dotfiles):
"""Run dotbot.
When calling `runner()`, only CLI arguments need to be specified.
If the keyword-only argument *custom* is True
then the CLI arguments will not be modified,
and the caller will be responsible for all CLI arguments.
"""
def runner(*argv, **kwargs):
argv = ["dotbot"] + list(argv)
if kwargs.get("custom", False) is not True:
argv.extend(["-c", dotfiles.config_filename])
with mock.patch("sys.argv", argv):
dotbot.cli.main()
yield runner

View file

@ -0,0 +1,27 @@
"""Test that a plugin can be loaded by directory.
This file is copied to a location with the name "directory.py",
and is then loaded from within the `test_cli.py` code.
"""
import os.path
import dotbot
class Directory(dotbot.Plugin):
def can_handle(self, directive):
return directive == "plugin_directory"
def handle(self, directive, data):
self._log.debug("Attempting to get options from Context")
options = self._context.options()
if len(options.plugin_dirs) != 1:
self._log.debug(
"Context.options.plugins length is %i, expected 1" % len(options.plugins)
)
return False
with open(os.path.abspath(os.path.expanduser("~/flag")), "w") as file:
file.write("directory plugin loading works")
return True

View file

@ -0,0 +1,32 @@
"""Test that a plugin can be loaded by filename.
This file is copied to a location with the name "file.py",
and is then loaded from within the `test_cli.py` code.
"""
import os.path
import dotbot
class File(dotbot.Plugin):
def can_handle(self, directive):
return directive == "plugin_file"
def handle(self, directive, data):
self._log.debug("Attempting to get options from Context")
options = self._context.options()
if len(options.plugins) != 1:
self._log.debug(
"Context.options.plugins length is %i, expected 1" % len(options.plugins)
)
return False
if not options.plugins[0].endswith("file.py"):
self._log.debug(
"Context.options.plugins[0] is %s, expected end with file.py" % options.plugins[0]
)
return False
with open(os.path.abspath(os.path.expanduser("~/flag")), "w") as file:
file.write("file plugin loading works")
return True

55
tests/test_bin_dotbot.py Normal file
View file

@ -0,0 +1,55 @@
import os
import subprocess
import pytest
def which(name):
"""Find an executable.
Python 2.7 doesn't have shutil.which().
"""
for path in os.environ["PATH"].split(os.pathsep):
if os.path.isfile(os.path.join(path, name)):
return os.path.join(path, name)
@pytest.mark.skipif(
"sys.platform[:5] == 'win32'",
reason="The hybrid sh/Python dotbot script doesn't run on Windows platforms",
)
@pytest.mark.parametrize("python_name", (None, "python", "python2", "python3"))
def test_find_python_executable(python_name, home, dotfiles):
"""Verify that the sh/Python hybrid dotbot executable can find Python."""
dotfiles.write_config([])
dotbot_executable = os.path.join(
os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "bin", "dotbot"
)
# Create a link to sh.
tmp_bin = os.path.join(home, "tmp_bin")
os.makedirs(tmp_bin)
sh_path = which("sh")
os.symlink(sh_path, os.path.join(tmp_bin, "sh"))
if python_name:
with open(os.path.join(tmp_bin, python_name), "w") as file:
file.write("#!" + tmp_bin + "/sh\n")
file.write("exit 0\n")
os.chmod(os.path.join(tmp_bin, python_name), 0o777)
env = dict(os.environ)
env["PATH"] = tmp_bin
if python_name:
subprocess.check_call(
[dotbot_executable, "-c", dotfiles.config_filename],
env=env,
)
else:
with pytest.raises(subprocess.CalledProcessError):
subprocess.check_call(
[dotbot_executable, "-c", dotfiles.config_filename],
env=env,
)

136
tests/test_clean.py Normal file
View file

@ -0,0 +1,136 @@
import os
import sys
import pytest
def test_clean_default(root, home, dotfiles, run_dotbot):
"""Verify clean uses default unless overridden."""
os.symlink(os.path.join(root, "nowhere"), os.path.join(home, ".g"))
dotfiles.write_config(
[
{
"clean": {
"~/nonexistent": {"force": True},
"~/": None,
},
}
]
)
run_dotbot()
assert not os.path.isdir(os.path.join(home, "nonexistent"))
assert os.path.islink(os.path.join(home, ".g"))
def test_clean_environment_variable_expansion(home, dotfiles, run_dotbot):
"""Verify clean expands environment variables."""
os.symlink(os.path.join(dotfiles.directory, "f"), os.path.join(home, ".f"))
variable = "$HOME"
if sys.platform[:5] == "win32":
variable = "$USERPROFILE"
dotfiles.write_config([{"clean": [variable]}])
run_dotbot()
assert not os.path.islink(os.path.join(home, ".f"))
def test_clean_missing(home, dotfiles, run_dotbot):
"""Verify clean deletes links to missing files."""
dotfiles.write("f")
os.symlink(os.path.join(dotfiles.directory, "f"), os.path.join(home, ".f"))
os.symlink(os.path.join(dotfiles.directory, "g"), os.path.join(home, ".g"))
dotfiles.write_config([{"clean": ["~"]}])
run_dotbot()
assert os.path.islink(os.path.join(home, ".f"))
assert not os.path.islink(os.path.join(home, ".g"))
def test_clean_nonexistent(home, dotfiles, run_dotbot):
"""Verify clean ignores nonexistent directories."""
dotfiles.write_config([{"clean": ["~", "~/fake"]}])
run_dotbot() # Nonexistent directories should not raise exceptions.
assert not os.path.isdir(os.path.join(home, "fake"))
def test_clean_outside_force(root, home, dotfiles, run_dotbot):
"""Verify clean forced to remove files linking outside dotfiles directory."""
os.symlink(os.path.join(root, "nowhere"), os.path.join(home, ".g"))
dotfiles.write_config([{"clean": {"~/": {"force": True}}}])
run_dotbot()
assert not os.path.islink(os.path.join(home, ".g"))
def test_clean_outside(root, home, dotfiles, run_dotbot):
"""Verify clean ignores files linking outside dotfiles directory."""
os.symlink(os.path.join(dotfiles.directory, "f"), os.path.join(home, ".f"))
os.symlink(os.path.join(home, "g"), os.path.join(home, ".g"))
dotfiles.write_config([{"clean": ["~"]}])
run_dotbot()
assert not os.path.islink(os.path.join(home, ".f"))
assert os.path.islink(os.path.join(home, ".g"))
def test_clean_recursive_1(root, home, dotfiles, run_dotbot):
"""Verify clean respects when the recursive directive is off (default)."""
os.makedirs(os.path.join(home, "a", "b"))
os.symlink(os.path.join(root, "nowhere"), os.path.join(home, "c"))
os.symlink(os.path.join(root, "nowhere"), os.path.join(home, "a", "d"))
os.symlink(os.path.join(root, "nowhere"), os.path.join(home, "a", "b", "e"))
dotfiles.write_config([{"clean": {"~": {"force": True}}}])
run_dotbot()
assert not os.path.islink(os.path.join(home, "c"))
assert os.path.islink(os.path.join(home, "a", "d"))
assert os.path.islink(os.path.join(home, "a", "b", "e"))
def test_clean_recursive_2(root, home, dotfiles, run_dotbot):
"""Verify clean respects when the recursive directive is on."""
os.makedirs(os.path.join(home, "a", "b"))
os.symlink(os.path.join(root, "nowhere"), os.path.join(home, "c"))
os.symlink(os.path.join(root, "nowhere"), os.path.join(home, "a", "d"))
os.symlink(os.path.join(root, "nowhere"), os.path.join(home, "a", "b", "e"))
dotfiles.write_config([{"clean": {"~": {"force": True, "recursive": True}}}])
run_dotbot()
assert not os.path.islink(os.path.join(home, "c"))
assert not os.path.islink(os.path.join(home, "a", "d"))
assert not os.path.islink(os.path.join(home, "a", "b", "e"))
def test_clean_defaults_1(root, home, dotfiles, run_dotbot):
"""Verify that clean doesn't erase non-dotfiles links by default."""
os.symlink(os.path.join(root, "nowhere"), os.path.join(home, ".g"))
dotfiles.write_config([{"clean": ["~"]}])
run_dotbot()
assert os.path.islink(os.path.join(home, ".g"))
def test_clean_defaults_2(root, home, dotfiles, run_dotbot):
"""Verify that explicit clean defaults override the implicit default."""
os.symlink(os.path.join(root, "nowhere"), os.path.join(home, ".g"))
dotfiles.write_config(
[
{"defaults": {"clean": {"force": True}}},
{"clean": ["~"]},
]
)
run_dotbot()
assert not os.path.islink(os.path.join(home, ".g"))

172
tests/test_cli.py Normal file
View file

@ -0,0 +1,172 @@
import os
import shutil
import pytest
def test_except_create(capfd, home, dotfiles, run_dotbot):
"""Verify that `--except` works as intended."""
dotfiles.write_config(
[
{"create": ["~/a"]},
{
"shell": [
{"command": "echo success", "stdout": True},
]
},
]
)
run_dotbot("--except", "create")
assert not os.path.exists(os.path.join(home, "a"))
stdout = capfd.readouterr().out.splitlines()
assert any(line.startswith("success") for line in stdout)
def test_except_shell(capfd, home, dotfiles, run_dotbot):
"""Verify that `--except` works as intended."""
dotfiles.write_config(
[
{"create": ["~/a"]},
{
"shell": [
{"command": "echo failure", "stdout": True},
]
},
]
)
run_dotbot("--except", "shell")
assert os.path.exists(os.path.join(home, "a"))
stdout = capfd.readouterr().out.splitlines()
assert not any(line.startswith("failure") for line in stdout)
def test_except_multiples(capfd, home, dotfiles, run_dotbot):
"""Verify that `--except` works with multiple exceptions."""
dotfiles.write_config(
[
{"create": ["~/a"]},
{
"shell": [
{"command": "echo failure", "stdout": True},
]
},
]
)
run_dotbot("--except", "create", "shell")
assert not os.path.exists(os.path.join(home, "a"))
stdout = capfd.readouterr().out.splitlines()
assert not any(line.startswith("failure") for line in stdout)
def test_exit_on_failure(capfd, home, dotfiles, run_dotbot):
"""Verify that processing can halt immediately on failures."""
dotfiles.write_config(
[
{"create": ["~/a"]},
{"shell": ["this_is_not_a_command"]},
{"create": ["~/b"]},
]
)
with pytest.raises(SystemExit):
run_dotbot("-x")
assert os.path.isdir(os.path.join(home, "a"))
assert not os.path.isdir(os.path.join(home, "b"))
def test_only(capfd, home, dotfiles, run_dotbot):
"""Verify that `--only` works as intended."""
dotfiles.write_config(
[
{"create": ["~/a"]},
{"shell": [{"command": "echo success", "stdout": True}]},
]
)
run_dotbot("--only", "shell")
assert not os.path.exists(os.path.join(home, "a"))
stdout = capfd.readouterr().out.splitlines()
assert any(line.startswith("success") for line in stdout)
def test_only_with_defaults(capfd, home, dotfiles, run_dotbot):
"""Verify that `--only` does not suppress defaults."""
dotfiles.write_config(
[
{"defaults": {"shell": {"stdout": True}}},
{"create": ["~/a"]},
{"shell": [{"command": "echo success"}]},
]
)
run_dotbot("--only", "shell")
assert not os.path.exists(os.path.join(home, "a"))
stdout = capfd.readouterr().out.splitlines()
assert any(line.startswith("success") for line in stdout)
def test_only_with_multiples(capfd, home, dotfiles, run_dotbot):
"""Verify that `--only` works as intended."""
dotfiles.write_config(
[
{"create": ["~/a"]},
{"shell": [{"command": "echo success", "stdout": True}]},
{"link": ["~/.f"]},
]
)
run_dotbot("--only", "create", "shell")
assert os.path.isdir(os.path.join(home, "a"))
stdout = capfd.readouterr().out.splitlines()
assert any(line.startswith("success") for line in stdout)
assert not os.path.exists(os.path.join(home, ".f"))
def test_plugin_loading_file(home, dotfiles, run_dotbot):
"""Verify that plugins can be loaded by file."""
plugin_file = os.path.join(os.path.dirname(os.path.abspath(__file__)), "dotbot_plugin_file.py")
shutil.copy(plugin_file, os.path.join(dotfiles.directory, "file.py"))
dotfiles.write_config([{"plugin_file": "~"}])
run_dotbot("--plugin", os.path.join(dotfiles.directory, "file.py"))
with open(os.path.join(home, "flag"), "r") as file:
assert file.read() == "file plugin loading works"
def test_plugin_loading_directory(home, dotfiles, run_dotbot):
"""Verify that plugins can be loaded from a directory."""
dotfiles.makedirs("plugins")
plugin_file = os.path.join(
os.path.dirname(os.path.abspath(__file__)), "dotbot_plugin_directory.py"
)
shutil.copy(plugin_file, os.path.join(dotfiles.directory, "plugins", "directory.py"))
dotfiles.write_config([{"plugin_directory": "~"}])
run_dotbot("--plugin-dir", os.path.join(dotfiles.directory, "plugins"))
with open(os.path.join(home, "flag"), "r") as file:
assert file.read() == "directory plugin loading works"
def test_disable_builtin_plugins(home, dotfiles, run_dotbot):
"""Verify that builtin plugins can be disabled."""
dotfiles.write("f", "apple")
dotfiles.write_config([{"link": {"~/.f": "f"}}])
# The link directive will be unhandled so dotbot will raise SystemExit.
with pytest.raises(SystemExit):
run_dotbot("--disable-built-in-plugins")
assert not os.path.exists(os.path.join(home, ".f"))

36
tests/test_config.py Normal file
View file

@ -0,0 +1,36 @@
import json
import os
def test_config_blank(dotfiles, run_dotbot):
"""Verify blank configs work."""
dotfiles.write_config([])
run_dotbot()
def test_config_empty(dotfiles, run_dotbot):
"""Verify empty configs work."""
dotfiles.write("config.yaml", "")
run_dotbot("-c", os.path.join(dotfiles.directory, "config.yaml"), custom=True)
def test_json(home, dotfiles, run_dotbot):
"""Verify JSON configs work."""
document = json.dumps([{"create": ["~/d"]}])
dotfiles.write("config.json", document)
run_dotbot("-c", os.path.join(dotfiles.directory, "config.json"), custom=True)
assert os.path.isdir(os.path.join(home, "d"))
def test_json_tabs(home, dotfiles, run_dotbot):
"""Verify JSON configs with tabs work."""
document = """[\n\t{\n\t\t"create": ["~/d"]\n\t}\n]"""
dotfiles.write("config.json", document)
run_dotbot("-c", os.path.join(dotfiles.directory, "config.json"), custom=True)
assert os.path.isdir(os.path.join(home, "d"))

55
tests/test_create.py Normal file
View file

@ -0,0 +1,55 @@
import os
import stat
import pytest
@pytest.mark.parametrize("directory", ("~/a", "~/b/c"))
def test_directory_creation(home, directory, dotfiles, run_dotbot):
"""Test creating directories, including nested directories."""
dotfiles.write_config([{"create": [directory]}])
run_dotbot()
expanded_directory = os.path.abspath(os.path.expanduser(directory))
assert os.path.isdir(expanded_directory)
assert os.stat(expanded_directory).st_mode & 0o777 == 0o777
def test_default_mode(home, dotfiles, run_dotbot):
"""Test creating a directory with an explicit default mode.
Note: `os.chmod()` on Windows only supports changing write permissions.
Therefore, this test is restricted to testing read-only access.
"""
read_only = 0o777 - stat.S_IWUSR - stat.S_IWGRP - stat.S_IWOTH
config = [{"defaults": {"create": {"mode": read_only}}}, {"create": ["~/a"]}]
dotfiles.write_config(config)
run_dotbot()
directory = os.path.abspath(os.path.expanduser("~/a"))
assert os.stat(directory).st_mode & stat.S_IWUSR == 0
assert os.stat(directory).st_mode & stat.S_IWGRP == 0
assert os.stat(directory).st_mode & stat.S_IWOTH == 0
def test_default_mode_override(home, dotfiles, run_dotbot):
"""Test creating a directory that overrides an explicit default mode.
Note: `os.chmod()` on Windows only supports changing write permissions.
Therefore, this test is restricted to testing read-only access.
"""
read_only = 0o777 - stat.S_IWUSR - stat.S_IWGRP - stat.S_IWOTH
config = [
{"defaults": {"create": {"mode": read_only}}},
{"create": {"~/a": {"mode": 0o777}}},
]
dotfiles.write_config(config)
run_dotbot()
directory = os.path.abspath(os.path.expanduser("~/a"))
assert os.stat(directory).st_mode & stat.S_IWUSR == stat.S_IWUSR
assert os.stat(directory).st_mode & stat.S_IWGRP == stat.S_IWGRP
assert os.stat(directory).st_mode & stat.S_IWOTH == stat.S_IWOTH

966
tests/test_link.py Normal file
View file

@ -0,0 +1,966 @@
import os
import sys
import pytest
def test_link_canonicalization(home, dotfiles, run_dotbot):
"""Verify links to symlinked destinations are canonical.
"Canonical", here, means that dotbot does not create symlinks
that point to intermediary symlinks.
"""
dotfiles.write("f", "apple")
dotfiles.write_config([{"link": {"~/.f": {"path": "f"}}}])
# Point to the config file in a symlinked dotfiles directory.
dotfiles_symlink = os.path.join(home, "dotfiles-symlink")
os.symlink(dotfiles.directory, dotfiles_symlink)
config_file = os.path.join(dotfiles_symlink, os.path.basename(dotfiles.config_filename))
run_dotbot("-c", config_file, custom=True)
expected = os.path.join(dotfiles.directory, "f")
actual = os.readlink(os.path.abspath(os.path.expanduser("~/.f")))
if sys.platform[:5] == "win32" and actual.startswith("\\\\?\\"):
actual = actual[4:]
assert expected == actual
@pytest.mark.parametrize("dst", ("~/.f", "~/f"))
@pytest.mark.parametrize("include_force", (True, False))
def test_link_default_source(root, home, dst, include_force, dotfiles, run_dotbot):
"""Verify that default sources are calculated correctly.
This test includes verifying files with and without leading periods,
as well as verifying handling of None dict values.
"""
dotfiles.write("f", "apple")
config = [
{
"link": {
dst: {"force": False} if include_force else None,
}
}
]
dotfiles.write_config(config)
run_dotbot()
with open(os.path.abspath(os.path.expanduser(dst)), "r") as file:
assert file.read() == "apple"
def test_link_environment_user_expansion_target(home, dotfiles, run_dotbot):
"""Verify link expands user in target."""
src = "~/f"
target = "~/g"
with open(os.path.abspath(os.path.expanduser(src)), "w") as file:
file.write("apple")
dotfiles.write_config([{"link": {target: src}}])
run_dotbot()
with open(os.path.abspath(os.path.expanduser(target)), "r") as file:
assert file.read() == "apple"
def test_link_environment_variable_expansion_source(monkeypatch, root, home, dotfiles, run_dotbot):
"""Verify link expands environment variables in source."""
monkeypatch.setenv("APPLE", "h")
target = "~/.i"
src = "$APPLE"
dotfiles.write("h", "grape")
dotfiles.write_config([{"link": {target: src}}])
run_dotbot()
with open(os.path.abspath(os.path.expanduser(target)), "r") as file:
assert file.read() == "grape"
def test_link_environment_variable_expansion_source_extended(
monkeypatch, root, home, dotfiles, run_dotbot
):
"""Verify link expands environment variables in extended config syntax."""
monkeypatch.setenv("APPLE", "h")
target = "~/.i"
src = "$APPLE"
dotfiles.write("h", "grape")
dotfiles.write_config([{"link": {target: {"path": src, "relink": True}}}])
run_dotbot()
with open(os.path.abspath(os.path.expanduser(target)), "r") as file:
assert file.read() == "grape"
def test_link_environment_variable_expansion_target(monkeypatch, root, home, dotfiles, run_dotbot):
"""Verify link expands environment variables in target.
If the variable doesn't exist, the "variable" must not be replaced.
"""
monkeypatch.setenv("ORANGE", ".config")
monkeypatch.setenv("BANANA", "g")
monkeypatch.delenv("PEAR", raising=False)
dotfiles.write("f", "apple")
dotfiles.write("h", "grape")
config = [
{
"link": {
"~/${ORANGE}/$BANANA": {
"path": "f",
"create": True,
},
"~/$PEAR": "h",
}
}
]
dotfiles.write_config(config)
run_dotbot()
with open(os.path.join(home, ".config", "g"), "r") as file:
assert file.read() == "apple"
with open(os.path.join(home, "$PEAR"), "r") as file:
assert file.read() == "grape"
def test_link_environment_variable_unset(monkeypatch, root, home, dotfiles, run_dotbot):
"""Verify link leaves unset environment variables."""
monkeypatch.delenv("ORANGE", raising=False)
dotfiles.write("$ORANGE", "apple")
dotfiles.write_config([{"link": {"~/f": "$ORANGE"}}])
run_dotbot()
with open(os.path.join(home, "f"), "r") as file:
assert file.read() == "apple"
def test_link_force_leaves_when_nonexistent(root, home, dotfiles, run_dotbot):
"""Verify force doesn't erase sources when targets are nonexistent."""
os.mkdir(os.path.join(home, "dir"))
open(os.path.join(home, "file"), "a").close()
config = [
{
"link": {
"~/dir": {"path": "dir", "force": True},
"~/file": {"path": "file", "force": True},
}
}
]
dotfiles.write_config(config)
with pytest.raises(SystemExit):
run_dotbot()
assert os.path.isdir(os.path.join(home, "dir"))
assert os.path.isfile(os.path.join(home, "file"))
def test_link_force_overwrite_symlink(home, dotfiles, run_dotbot):
"""Verify force overwrites a symlinked directory."""
os.mkdir(os.path.join(home, "dir"))
dotfiles.write("dir/f")
os.symlink(home, os.path.join(home, ".dir"))
config = [{"link": {"~/.dir": {"path": "dir", "force": True}}}]
dotfiles.write_config(config)
run_dotbot()
assert os.path.isfile(os.path.join(home, ".dir", "f"))
def test_link_glob_1(home, dotfiles, run_dotbot):
"""Verify globbing works."""
dotfiles.write("bin/a", "apple")
dotfiles.write("bin/b", "banana")
dotfiles.write("bin/c", "cherry")
dotfiles.write_config(
[
{"defaults": {"link": {"glob": True, "create": True}}},
{"link": {"~/bin": "bin/*"}},
]
)
run_dotbot()
with open(os.path.join(home, "bin", "a")) as file:
assert file.read() == "apple"
with open(os.path.join(home, "bin", "b")) as file:
assert file.read() == "banana"
with open(os.path.join(home, "bin", "c")) as file:
assert file.read() == "cherry"
def test_link_glob_2(home, dotfiles, run_dotbot):
"""Verify globbing works with a trailing slash in the source."""
dotfiles.write("bin/a", "apple")
dotfiles.write("bin/b", "banana")
dotfiles.write("bin/c", "cherry")
dotfiles.write_config(
[
{"defaults": {"link": {"glob": True, "create": True}}},
{"link": {"~/bin/": "bin/*"}},
]
)
run_dotbot()
with open(os.path.join(home, "bin", "a")) as file:
assert file.read() == "apple"
with open(os.path.join(home, "bin", "b")) as file:
assert file.read() == "banana"
with open(os.path.join(home, "bin", "c")) as file:
assert file.read() == "cherry"
def test_link_glob_3(home, dotfiles, run_dotbot):
"""Verify globbing works with hidden ("period-prefixed") files."""
dotfiles.write("bin/.a", "dot-apple")
dotfiles.write("bin/.b", "dot-banana")
dotfiles.write("bin/.c", "dot-cherry")
dotfiles.write_config(
[
{"defaults": {"link": {"glob": True, "create": True}}},
{"link": {"~/bin/": "bin/.*"}},
]
)
run_dotbot()
with open(os.path.join(home, "bin", ".a")) as file:
assert file.read() == "dot-apple"
with open(os.path.join(home, "bin", ".b")) as file:
assert file.read() == "dot-banana"
with open(os.path.join(home, "bin", ".c")) as file:
assert file.read() == "dot-cherry"
def test_link_glob_4(home, dotfiles, run_dotbot):
"""Verify globbing works at the root of the home and dotfiles directories."""
dotfiles.write(".a", "dot-apple")
dotfiles.write(".b", "dot-banana")
dotfiles.write(".c", "dot-cherry")
dotfiles.write_config(
[
{
"link": {
"~": {
"path": ".*",
"glob": True,
},
},
}
]
)
run_dotbot()
with open(os.path.join(home, ".a")) as file:
assert file.read() == "dot-apple"
with open(os.path.join(home, ".b")) as file:
assert file.read() == "dot-banana"
with open(os.path.join(home, ".c")) as file:
assert file.read() == "dot-cherry"
@pytest.mark.parametrize("path", ("foo", "foo/"))
def test_link_glob_ambiguous_failure(path, home, dotfiles, run_dotbot):
"""Verify ambiguous link globbing fails."""
dotfiles.makedirs("foo")
dotfiles.write_config(
[
{
"link": {
"~/foo/": {
"path": path,
"glob": True,
}
}
}
]
)
with pytest.raises(SystemExit):
run_dotbot()
assert not os.path.exists(os.path.join(home, "foo"))
def test_link_glob_ambiguous_success(home, dotfiles, run_dotbot):
"""Verify the case where ambiguous link globbing succeeds."""
dotfiles.makedirs("foo")
dotfiles.write_config(
[
{
"link": {
"~/foo": {
"path": "foo",
"glob": True,
}
}
}
]
)
run_dotbot()
assert os.path.exists(os.path.join(home, "foo"))
def test_link_glob_exclude_1(home, dotfiles, run_dotbot):
"""Verify link globbing with an explicit exclusion."""
dotfiles.write("config/foo/a", "apple")
dotfiles.write("config/bar/b", "banana")
dotfiles.write("config/bar/c", "cherry")
dotfiles.write("config/baz/d", "donut")
dotfiles.write_config(
[
{
"defaults": {
"link": {
"glob": True,
"create": True,
},
},
},
{
"link": {
"~/.config/": {
"path": "config/*",
"exclude": ["config/baz"],
},
},
},
]
)
run_dotbot()
assert not os.path.exists(os.path.join(home, ".config", "baz"))
assert not os.path.islink(os.path.join(home, ".config"))
assert os.path.islink(os.path.join(home, ".config", "foo"))
assert os.path.islink(os.path.join(home, ".config", "bar"))
with open(os.path.join(home, ".config", "foo", "a")) as file:
assert file.read() == "apple"
with open(os.path.join(home, ".config", "bar", "b")) as file:
assert file.read() == "banana"
with open(os.path.join(home, ".config", "bar", "c")) as file:
assert file.read() == "cherry"
def test_link_glob_exclude_2(home, dotfiles, run_dotbot):
"""Verify deep link globbing with a globbed exclusion."""
dotfiles.write("config/foo/a", "apple")
dotfiles.write("config/bar/b", "banana")
dotfiles.write("config/bar/c", "cherry")
dotfiles.write("config/baz/d", "donut")
dotfiles.write("config/baz/buzz/e", "egg")
dotfiles.write_config(
[
{
"defaults": {
"link": {
"glob": True,
"create": True,
},
},
},
{
"link": {
"~/.config/": {
"path": "config/*/*",
"exclude": ["config/baz/*"],
},
},
},
]
)
run_dotbot()
assert not os.path.exists(os.path.join(home, ".config", "baz"))
assert not os.path.islink(os.path.join(home, ".config"))
assert not os.path.islink(os.path.join(home, ".config", "foo"))
assert not os.path.islink(os.path.join(home, ".config", "bar"))
assert os.path.islink(os.path.join(home, ".config", "foo", "a"))
with open(os.path.join(home, ".config", "foo", "a")) as file:
assert file.read() == "apple"
with open(os.path.join(home, ".config", "bar", "b")) as file:
assert file.read() == "banana"
with open(os.path.join(home, ".config", "bar", "c")) as file:
assert file.read() == "cherry"
def test_link_glob_exclude_3(home, dotfiles, run_dotbot):
"""Verify deep link globbing with an explicit exclusion."""
dotfiles.write("config/foo/a", "apple")
dotfiles.write("config/bar/b", "banana")
dotfiles.write("config/bar/c", "cherry")
dotfiles.write("config/baz/d", "donut")
dotfiles.write("config/baz/buzz/e", "egg")
dotfiles.write("config/baz/bizz/g", "grape")
dotfiles.write_config(
[
{
"defaults": {
"link": {
"glob": True,
"create": True,
},
},
},
{
"link": {
"~/.config/": {
"path": "config/*/*",
"exclude": ["config/baz/buzz"],
},
},
},
]
)
run_dotbot()
assert not os.path.exists(os.path.join(home, ".config", "baz", "buzz"))
assert not os.path.islink(os.path.join(home, ".config"))
assert not os.path.islink(os.path.join(home, ".config", "foo"))
assert not os.path.islink(os.path.join(home, ".config", "bar"))
assert not os.path.islink(os.path.join(home, ".config", "baz"))
assert os.path.islink(os.path.join(home, ".config", "baz", "bizz"))
assert os.path.islink(os.path.join(home, ".config", "foo", "a"))
with open(os.path.join(home, ".config", "foo", "a")) as file:
assert file.read() == "apple"
with open(os.path.join(home, ".config", "bar", "b")) as file:
assert file.read() == "banana"
with open(os.path.join(home, ".config", "bar", "c")) as file:
assert file.read() == "cherry"
with open(os.path.join(home, ".config", "baz", "d")) as file:
assert file.read() == "donut"
with open(os.path.join(home, ".config", "baz", "bizz", "g")) as file:
assert file.read() == "grape"
def test_link_glob_exclude_4(home, dotfiles, run_dotbot):
"""Verify deep link globbing with multiple globbed exclusions."""
dotfiles.write("config/foo/a", "apple")
dotfiles.write("config/bar/b", "banana")
dotfiles.write("config/bar/c", "cherry")
dotfiles.write("config/baz/d", "donut")
dotfiles.write("config/baz/buzz/e", "egg")
dotfiles.write("config/baz/bizz/g", "grape")
dotfiles.write("config/fiz/f", "fig")
dotfiles.write_config(
[
{
"defaults": {
"link": {
"glob": True,
"create": True,
},
},
},
{
"link": {
"~/.config/": {
"path": "config/*/*",
"exclude": ["config/baz/*", "config/fiz/*"],
},
},
},
]
)
run_dotbot()
assert not os.path.exists(os.path.join(home, ".config", "baz"))
assert not os.path.exists(os.path.join(home, ".config", "fiz"))
assert not os.path.islink(os.path.join(home, ".config"))
assert not os.path.islink(os.path.join(home, ".config", "foo"))
assert not os.path.islink(os.path.join(home, ".config", "bar"))
assert os.path.islink(os.path.join(home, ".config", "foo", "a"))
with open(os.path.join(home, ".config", "foo", "a")) as file:
assert file.read() == "apple"
with open(os.path.join(home, ".config", "bar", "b")) as file:
assert file.read() == "banana"
with open(os.path.join(home, ".config", "bar", "c")) as file:
assert file.read() == "cherry"
def test_link_glob_multi_star(home, dotfiles, run_dotbot):
"""Verify link globbing with deep-nested stars."""
dotfiles.write("config/foo/a", "apple")
dotfiles.write("config/bar/b", "banana")
dotfiles.write("config/bar/c", "cherry")
dotfiles.write_config(
[
{"defaults": {"link": {"glob": True, "create": True}}},
{"link": {"~/.config/": "config/*/*"}},
]
)
run_dotbot()
assert not os.path.islink(os.path.join(home, ".config"))
assert not os.path.islink(os.path.join(home, ".config", "foo"))
assert not os.path.islink(os.path.join(home, ".config", "bar"))
assert os.path.islink(os.path.join(home, ".config", "foo", "a"))
with open(os.path.join(home, ".config", "foo", "a")) as file:
assert file.read() == "apple"
with open(os.path.join(home, ".config", "bar", "b")) as file:
assert file.read() == "banana"
with open(os.path.join(home, ".config", "bar", "c")) as file:
assert file.read() == "cherry"
@pytest.mark.parametrize(
"pattern, expect_file",
(
("conf/*", lambda fruit: fruit),
("conf/.*", lambda fruit: "." + fruit),
("conf/[bc]*", lambda fruit: fruit if fruit[0] in "bc" else None),
("conf/*e", lambda fruit: fruit if fruit[-1] == "e" else None),
("conf/??r*", lambda fruit: fruit if fruit[2] == "r" else None),
),
)
def test_link_glob_patterns(pattern, expect_file, home, dotfiles, run_dotbot):
"""Verify link glob pattern matching."""
fruits = ["apple", "apricot", "banana", "cherry", "currant", "cantalope"]
[dotfiles.write("conf/" + fruit, fruit) for fruit in fruits]
[dotfiles.write("conf/." + fruit, "dot-" + fruit) for fruit in fruits]
dotfiles.write_config(
[
{"defaults": {"link": {"glob": True, "create": True}}},
{"link": {"~/globtest": pattern}},
]
)
run_dotbot()
for fruit in fruits:
if expect_file(fruit) is None:
assert not os.path.exists(os.path.join(home, "globtest", fruit))
assert not os.path.exists(os.path.join(home, "globtest", "." + fruit))
elif "." in expect_file(fruit):
assert not os.path.islink(os.path.join(home, "globtest", fruit))
assert os.path.islink(os.path.join(home, "globtest", "." + fruit))
else: # "." not in expect_file(fruit)
assert os.path.islink(os.path.join(home, "globtest", fruit))
assert not os.path.islink(os.path.join(home, "globtest", "." + fruit))
@pytest.mark.skipif(
"sys.version_info < (3, 5)",
reason="Python 3.5 required for ** globbing",
)
def test_link_glob_recursive(home, dotfiles, run_dotbot):
"""Verify recursive link globbing and exclusions."""
dotfiles.write("config/foo/bar/a", "apple")
dotfiles.write("config/foo/bar/b", "banana")
dotfiles.write("config/foo/bar/c", "cherry")
dotfiles.write_config(
[
{"defaults": {"link": {"glob": True, "create": True}}},
{"link": {"~/.config/": {"path": "config/**", "exclude": ["config/**/b"]}}},
]
)
run_dotbot()
assert not os.path.islink(os.path.join(home, ".config"))
assert not os.path.islink(os.path.join(home, ".config", "foo"))
assert not os.path.islink(os.path.join(home, ".config", "foo", "bar"))
assert os.path.islink(os.path.join(home, ".config", "foo", "bar", "a"))
assert not os.path.exists(os.path.join(home, ".config", "foo", "bar", "b"))
assert os.path.islink(os.path.join(home, ".config", "foo", "bar", "c"))
with open(os.path.join(home, ".config", "foo", "bar", "a")) as file:
assert file.read() == "apple"
with open(os.path.join(home, ".config", "foo", "bar", "c")) as file:
assert file.read() == "cherry"
@pytest.mark.skipif(
"sys.platform[:5] == 'win32'",
reason="These if commands won't run on Windows",
)
def test_link_if(home, dotfiles, run_dotbot):
"""Verify 'if' directives are checked when linking."""
os.mkdir(os.path.join(home, "d"))
dotfiles.write("f", "apple")
dotfiles.write_config(
[
{
"link": {
"~/.f": {"path": "f", "if": "true"},
"~/.g": {"path": "f", "if": "false"},
"~/.h": {"path": "f", "if": "[ -d ~/d ]"},
"~/.i": {"path": "f", "if": "badcommand"},
},
}
]
)
run_dotbot()
assert not os.path.exists(os.path.join(home, ".g"))
assert not os.path.exists(os.path.join(home, ".i"))
with open(os.path.join(home, ".f")) as file:
assert file.read() == "apple"
with open(os.path.join(home, ".h")) as file:
assert file.read() == "apple"
@pytest.mark.skipif(
"sys.platform[:5] == 'win32'",
reason="These if commands won't run on Windows.",
)
def test_link_if_defaults(home, dotfiles, run_dotbot):
"""Verify 'if' directive defaults are checked when linking."""
os.mkdir(os.path.join(home, "d"))
dotfiles.write("f", "apple")
dotfiles.write_config(
[
{
"defaults": {
"link": {
"if": "false",
},
},
},
{
"link": {
"~/.j": {"path": "f", "if": "true"},
"~/.k": {"path": "f"}, # default is false
},
},
]
)
run_dotbot()
assert not os.path.exists(os.path.join(home, ".k"))
with open(os.path.join(home, ".j")) as file:
assert file.read() == "apple"
@pytest.mark.skipif(
"sys.platform[:5] != 'win32'",
reason="These if commands only run on Windows.",
)
def test_link_if_windows(home, dotfiles, run_dotbot):
"""Verify 'if' directives are checked when linking (Windows only)."""
os.mkdir(os.path.join(home, "d"))
dotfiles.write("f", "apple")
dotfiles.write_config(
[
{
"link": {
"~/.f": {"path": "f", "if": 'cmd /c "exit 0"'},
"~/.g": {"path": "f", "if": 'cmd /c "exit 1"'},
"~/.h": {"path": "f", "if": 'cmd /c "dir %USERPROFILE%\\d'},
"~/.i": {"path": "f", "if": 'cmd /c "badcommand"'},
},
}
]
)
run_dotbot()
assert not os.path.exists(os.path.join(home, ".g"))
assert not os.path.exists(os.path.join(home, ".i"))
with open(os.path.join(home, ".f")) as file:
assert file.read() == "apple"
with open(os.path.join(home, ".h")) as file:
assert file.read() == "apple"
@pytest.mark.skipif(
"sys.platform[:5] != 'win32'",
reason="These if commands only run on Windows",
)
def test_link_if_defaults_windows(home, dotfiles, run_dotbot):
"""Verify 'if' directive defaults are checked when linking (Windows only)."""
os.mkdir(os.path.join(home, "d"))
dotfiles.write("f", "apple")
dotfiles.write_config(
[
{
"defaults": {
"link": {
"if": 'cmd /c "exit 1"',
},
},
},
{
"link": {
"~/.j": {"path": "f", "if": 'cmd /c "exit 0"'},
"~/.k": {"path": "f"}, # default is false
},
},
]
)
run_dotbot()
assert not os.path.exists(os.path.join(home, ".k"))
with open(os.path.join(home, ".j")) as file:
assert file.read() == "apple"
@pytest.mark.parametrize("ignore_missing", (True, False))
def test_link_ignore_missing(ignore_missing, home, dotfiles, run_dotbot):
"""Verify link 'ignore_missing' is respected when the target is missing."""
dotfiles.write_config(
[
{
"link": {
"~/missing_link": {
"path": "missing",
"ignore-missing": ignore_missing,
},
},
}
]
)
if ignore_missing:
run_dotbot()
assert os.path.islink(os.path.join(home, "missing_link"))
else:
with pytest.raises(SystemExit):
run_dotbot()
def test_link_leaves_file(home, dotfiles, run_dotbot):
"""Verify relink does not overwrite file."""
dotfiles.write("f", "apple")
with open(os.path.join(home, ".f"), "w") as file:
file.write("grape")
dotfiles.write_config([{"link": {"~/.f": "f"}}])
with pytest.raises(SystemExit):
run_dotbot()
with open(os.path.join(home, ".f"), "r") as file:
assert file.read() == "grape"
@pytest.mark.parametrize("key", ("canonicalize-path", "canonicalize"))
def test_link_no_canonicalize(key, home, dotfiles, run_dotbot):
"""Verify link canonicalization can be disabled."""
dotfiles.write("f", "apple")
dotfiles.write_config([{"defaults": {"link": {key: False}}}, {"link": {"~/.f": {"path": "f"}}}])
try:
os.symlink(
dotfiles.directory,
os.path.join(home, "dotfiles-symlink"),
target_is_directory=True,
)
except TypeError:
# Python 2 compatibility:
# target_is_directory is only consistently available after Python 3.3.
os.symlink(
dotfiles.directory,
os.path.join(home, "dotfiles-symlink"),
)
run_dotbot(
"-c",
os.path.join(home, "dotfiles-symlink", os.path.basename(dotfiles.config_filename)),
custom=True,
)
assert "dotfiles-symlink" in os.readlink(os.path.join(home, ".f"))
def test_link_prefix(home, dotfiles, run_dotbot):
"""Verify link prefixes are prepended."""
dotfiles.write("conf/a", "apple")
dotfiles.write("conf/b", "banana")
dotfiles.write("conf/c", "cherry")
dotfiles.write_config(
[
{
"link": {
"~/": {
"glob": True,
"path": "conf/*",
"prefix": ".",
},
},
}
]
)
run_dotbot()
with open(os.path.join(home, ".a")) as file:
assert file.read() == "apple"
with open(os.path.join(home, ".b")) as file:
assert file.read() == "banana"
with open(os.path.join(home, ".c")) as file:
assert file.read() == "cherry"
def test_link_relative(home, dotfiles, run_dotbot):
"""Test relative linking works."""
dotfiles.write("f", "apple")
dotfiles.write("d/e", "grape")
dotfiles.write_config(
[
{
"link": {
"~/.f": {
"path": "f",
},
"~/.frel": {
"path": "f",
"relative": True,
},
"~/nested/.frel": {
"path": "f",
"relative": True,
"create": True,
},
"~/.d": {
"path": "d",
"relative": True,
},
},
}
]
)
run_dotbot()
f = os.readlink(os.path.join(home, ".f"))
if sys.platform[:5] == "win32" and f.startswith("\\\\?\\"):
f = f[4:]
assert f == os.path.join(dotfiles.directory, "f")
frel = os.readlink(os.path.join(home, ".frel"))
if sys.platform[:5] == "win32" and frel.startswith("\\\\?\\"):
frel = frel[4:]
assert frel == os.path.normpath("../../dotfiles/f")
nested_frel = os.readlink(os.path.join(home, "nested", ".frel"))
if sys.platform[:5] == "win32" and nested_frel.startswith("\\\\?\\"):
nested_frel = nested_frel[4:]
assert nested_frel == os.path.normpath("../../../dotfiles/f")
d = os.readlink(os.path.join(home, ".d"))
if sys.platform[:5] == "win32" and d.startswith("\\\\?\\"):
d = d[4:]
assert d == os.path.normpath("../../dotfiles/d")
with open(os.path.join(home, ".f")) as file:
assert file.read() == "apple"
with open(os.path.join(home, ".frel")) as file:
assert file.read() == "apple"
with open(os.path.join(home, "nested", ".frel")) as file:
assert file.read() == "apple"
with open(os.path.join(home, ".d", "e")) as file:
assert file.read() == "grape"
def test_link_relink_leaves_file(home, dotfiles, run_dotbot):
"""Verify relink does not overwrite file."""
dotfiles.write("f", "apple")
with open(os.path.join(home, ".f"), "w") as file:
file.write("grape")
dotfiles.write_config([{"link": {"~/.f": {"path": "f", "relink": True}}}])
with pytest.raises(SystemExit):
run_dotbot()
with open(os.path.join(home, ".f"), "r") as file:
assert file.read() == "grape"
def test_link_relink_overwrite_symlink(home, dotfiles, run_dotbot):
"""Verify relink overwrites symlinks."""
dotfiles.write("f", "apple")
with open(os.path.join(home, "f"), "w") as file:
file.write("grape")
os.symlink(os.path.join(home, "f"), os.path.join(home, ".f"))
dotfiles.write_config([{"link": {"~/.f": {"path": "f", "relink": True}}}])
run_dotbot()
with open(os.path.join(home, ".f"), "r") as file:
assert file.read() == "apple"
def test_link_relink_relative_leaves_file(home, dotfiles, run_dotbot):
"""Verify relink relative does not incorrectly relink file."""
dotfiles.write("f", "apple")
with open(os.path.join(home, ".f"), "w") as file:
file.write("grape")
config = [
{
"link": {
"~/.folder/f": {
"path": "f",
"create": True,
"relative": True,
},
},
}
]
dotfiles.write_config(config)
run_dotbot()
mtime = os.stat(os.path.join(home, ".folder", "f")).st_mtime
config[0]["link"]["~/.folder/f"]["relink"] = True
dotfiles.write_config(config)
run_dotbot()
new_mtime = os.stat(os.path.join(home, ".folder", "f")).st_mtime
assert mtime == new_mtime
def test_link_defaults_1(home, dotfiles, run_dotbot):
"""Verify that link doesn't overwrite non-dotfiles links by default."""
with open(os.path.join(home, "f"), "w") as file:
file.write("grape")
os.symlink(os.path.join(home, "f"), os.path.join(home, ".f"))
dotfiles.write("f", "apple")
dotfiles.write_config(
[
{
"link": {"~/.f": "f"},
}
]
)
with pytest.raises(SystemExit):
run_dotbot()
with open(os.path.join(home, ".f"), "r") as file:
assert file.read() == "grape"
def test_link_defaults_2(home, dotfiles, run_dotbot):
"""Verify that explicit link defaults override the implicit default."""
with open(os.path.join(home, "f"), "w") as file:
file.write("grape")
os.symlink(os.path.join(home, "f"), os.path.join(home, ".f"))
dotfiles.write("f", "apple")
dotfiles.write_config(
[
{"defaults": {"link": {"relink": True}}},
{"link": {"~/.f": "f"}},
]
)
run_dotbot()
with open(os.path.join(home, ".f"), "r") as file:
assert file.read() == "apple"

25
tests/test_noop.py Normal file
View file

@ -0,0 +1,25 @@
import os
import pytest
def test_success(root):
path = os.path.join(root, "abc.txt")
with open(path, "wt") as f:
f.write("hello")
with open(path, "rt") as f:
assert f.read() == "hello"
def test_failure():
with pytest.raises(AssertionError):
open("abc.txt", "w")
with pytest.raises(AssertionError):
open(file="abc.txt", mode="w")
with pytest.raises(AssertionError):
os.mkdir("a")
with pytest.raises(AssertionError):
os.mkdir(path="a")

261
tests/test_shell.py Normal file
View file

@ -0,0 +1,261 @@
def test_shell_allow_stdout(capfd, dotfiles, run_dotbot):
"""Verify shell command STDOUT works."""
dotfiles.write_config(
[
{
"shell": [
{
"command": "echo apple",
"stdout": True,
}
],
}
]
)
run_dotbot()
output = capfd.readouterr()
assert any([line.startswith("apple") for line in output.out.splitlines()]), output
def test_shell_cli_verbosity_overrides_1(capfd, dotfiles, run_dotbot):
"""Verify that '-vv' overrides the implicit default stdout=False."""
dotfiles.write_config([{"shell": [{"command": "echo apple"}]}])
run_dotbot("-vv")
lines = capfd.readouterr().out.splitlines()
assert any(line.startswith("apple") for line in lines)
def test_shell_cli_verbosity_overrides_2(capfd, dotfiles, run_dotbot):
"""Verify that '-vv' overrides an explicit stdout=False."""
dotfiles.write_config([{"shell": [{"command": "echo apple", "stdout": False}]}])
run_dotbot("-vv")
lines = capfd.readouterr().out.splitlines()
assert any(line.startswith("apple") for line in lines)
def test_shell_cli_verbosity_overrides_3(capfd, dotfiles, run_dotbot):
"""Verify that '-vv' overrides an explicit defaults:shell:stdout=False."""
dotfiles.write_config(
[
{"defaults": {"shell": {"stdout": False}}},
{"shell": [{"command": "echo apple"}]},
]
)
run_dotbot("-vv")
stdout = capfd.readouterr().out.splitlines()
assert any(line.startswith("apple") for line in stdout)
def test_shell_cli_verbosity_stderr(capfd, dotfiles, run_dotbot):
"""Verify that commands can output to STDERR."""
dotfiles.write_config([{"shell": [{"command": "echo apple >&2"}]}])
run_dotbot("-vv")
stderr = capfd.readouterr().err.splitlines()
assert any(line.startswith("apple") for line in stderr)
def test_shell_cli_verbosity_stderr_with_explicit_stdout_off(capfd, dotfiles, run_dotbot):
"""Verify that commands can output to STDERR with STDOUT explicitly off."""
dotfiles.write_config(
[
{
"shell": [
{
"command": "echo apple >&2",
"stdout": False,
}
],
}
]
)
run_dotbot("-vv")
stderr = capfd.readouterr().err.splitlines()
assert any(line.startswith("apple") for line in stderr)
def test_shell_cli_verbosity_stderr_with_defaults_stdout_off(capfd, dotfiles, run_dotbot):
"""Verify that commands can output to STDERR with defaults:shell:stdout=False."""
dotfiles.write_config(
[
{
"defaults": {
"shell": {
"stdout": False,
},
},
},
{
"shell": [
{"command": "echo apple >&2"},
],
},
]
)
run_dotbot("-vv")
stderr = capfd.readouterr().err.splitlines()
assert any(line.startswith("apple") for line in stderr)
def test_shell_single_v_verbosity_stdout(capfd, dotfiles, run_dotbot):
"""Verify that a single '-v' verbosity doesn't override stdout=False."""
dotfiles.write_config([{"shell": [{"command": "echo apple"}]}])
run_dotbot("-v")
stdout = capfd.readouterr().out.splitlines()
assert not any(line.startswith("apple") for line in stdout)
def test_shell_single_v_verbosity_stderr(capfd, dotfiles, run_dotbot):
"""Verify that a single '-v' verbosity doesn't override stderr=False."""
dotfiles.write_config([{"shell": [{"command": "echo apple >&2"}]}])
run_dotbot("-v")
stderr = capfd.readouterr().err.splitlines()
assert not any(line.startswith("apple") for line in stderr)
def test_shell_compact_stdout_1(capfd, dotfiles, run_dotbot):
"""Verify that shell command stdout works in compact form."""
dotfiles.write_config(
[
{"defaults": {"shell": {"stdout": True}}},
{"shell": ["echo apple"]},
]
)
run_dotbot()
stdout = capfd.readouterr().out.splitlines()
assert any(line.startswith("apple") for line in stdout)
def test_shell_compact_stdout_2(capfd, dotfiles, run_dotbot):
"""Verify that shell command stdout works in compact form."""
dotfiles.write_config(
[
{"defaults": {"shell": {"stdout": True}}},
{"shell": [["echo apple", "echoing message"]]},
]
)
run_dotbot()
stdout = capfd.readouterr().out.splitlines()
assert any(line.startswith("apple") for line in stdout)
assert any(line.startswith("echoing message") for line in stdout)
def test_shell_stdout_disabled_by_default(capfd, dotfiles, run_dotbot):
"""Verify that the shell command disables stdout by default."""
dotfiles.write_config(
[
{
"shell": ["echo banana"],
}
]
)
run_dotbot()
stdout = capfd.readouterr().out.splitlines()
assert not any(line.startswith("banana") for line in stdout)
def test_shell_can_override_defaults(capfd, dotfiles, run_dotbot):
"""Verify that the shell command can override defaults."""
dotfiles.write_config(
[
{"defaults": {"shell": {"stdout": True}}},
{"shell": [{"command": "echo apple", "stdout": False}]},
]
)
run_dotbot()
stdout = capfd.readouterr().out.splitlines()
assert not any(line.startswith("apple") for line in stdout)
def test_shell_quiet_default(capfd, dotfiles, run_dotbot):
"""Verify that quiet is off by default."""
dotfiles.write_config(
[
{
"shell": [
{
"command": "echo banana",
"description": "echoing a thing...",
}
],
}
]
)
run_dotbot()
stdout = capfd.readouterr().out.splitlines()
assert not any(line.startswith("banana") for line in stdout)
assert any("echo banana" in line for line in stdout)
assert any(line.startswith("echoing a thing...") for line in stdout)
def test_shell_quiet_enabled_with_description(capfd, dotfiles, run_dotbot):
"""Verify that only the description is shown when quiet is enabled."""
dotfiles.write_config(
[
{
"shell": [
{
"command": "echo banana",
"description": "echoing a thing...",
"quiet": True,
}
],
}
]
)
run_dotbot()
stdout = capfd.readouterr().out.splitlines()
assert not any(line.startswith("banana") for line in stdout)
assert not any("echo banana" in line for line in stdout)
assert any(line.startswith("echoing a thing...") for line in stdout)
def test_shell_quiet_enabled_without_description(capfd, dotfiles, run_dotbot):
"""Verify nothing is shown when quiet is enabled with no description."""
dotfiles.write_config(
[
{
"shell": [
{
"command": "echo banana",
"quiet": True,
}
],
}
]
)
run_dotbot()
stdout = capfd.readouterr().out.splitlines()
assert not any(line.startswith("banana") for line in stdout)
assert not any(line.startswith("echo banana") for line in stdout)

64
tests/test_shim.py Normal file
View file

@ -0,0 +1,64 @@
import os
import shutil
import subprocess
import sys
import pytest
def which(name):
"""Find an executable.
Python 2.7 doesn't have shutil.which().
shutil.which() is used, if possible, to handle Windows' case-insensitivity.
"""
if hasattr(shutil, "which"):
return shutil.which(name)
for path in os.environ["PATH"].split(os.pathsep):
if os.path.isfile(os.path.join(path, name)):
return os.path.join(path, name)
def test_shim(root, home, dotfiles, run_dotbot):
"""Verify install shim works."""
# Skip the test if git is unavailable.
git = which("git")
if git is None:
pytest.skip("git is unavailable")
if sys.platform[:5] == "win32":
install = os.path.join(
dotfiles.directory, "dotbot", "tools", "git-submodule", "install.ps1"
)
shim = os.path.join(dotfiles.directory, "install.ps1")
else:
install = os.path.join(dotfiles.directory, "dotbot", "tools", "git-submodule", "install")
shim = os.path.join(dotfiles.directory, "install")
# Set up the test environment.
git_directory = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
os.chdir(dotfiles.directory)
subprocess.check_call([git, "init"])
subprocess.check_call(
[git, "-c", "protocol.file.allow=always", "submodule", "add", git_directory, "dotbot"]
)
shutil.copy(install, shim)
dotfiles.write("foo", "pear")
dotfiles.write_config([{"link": {"~/.foo": "foo"}}])
# Run the shim script.
env = dict(os.environ)
if sys.platform[:5] == "win32":
args = [which("powershell"), "-ExecutionPolicy", "RemoteSigned", shim]
env["USERPROFILE"] = home
else:
args = [shim]
env["HOME"] = home
subprocess.check_call(args, env=env, cwd=dotfiles.directory)
assert os.path.islink(os.path.join(home, ".foo"))
with open(os.path.join(home, ".foo"), "r") as file:
assert file.read() == "pear"

View file

@ -10,7 +10,7 @@ Set-Location $BASEDIR
git -C $DOTBOT_DIR submodule sync --quiet --recursive git -C $DOTBOT_DIR submodule sync --quiet --recursive
git submodule update --init --recursive $DOTBOT_DIR git submodule update --init --recursive $DOTBOT_DIR
foreach ($PYTHON in ('python', 'python3', 'python2')) { foreach ($PYTHON in ('python', 'python3')) {
# Python redirects to Microsoft Store in Windows 10 when not installed # Python redirects to Microsoft Store in Windows 10 when not installed
if (& { $ErrorActionPreference = "SilentlyContinue" if (& { $ErrorActionPreference = "SilentlyContinue"
![string]::IsNullOrEmpty((&$PYTHON -V)) ![string]::IsNullOrEmpty((&$PYTHON -V))

View file

@ -9,7 +9,7 @@ $BASEDIR = $PSScriptRoot
Set-Location $BASEDIR Set-Location $BASEDIR
Set-Location $DOTBOT_DIR && git submodule update --init --recursive Set-Location $DOTBOT_DIR && git submodule update --init --recursive
foreach ($PYTHON in ('python', 'python3', 'python2')) { foreach ($PYTHON in ('python', 'python3')) {
# Python redirects to Microsoft Store in Windows 10 when not installed # Python redirects to Microsoft Store in Windows 10 when not installed
if (& { $ErrorActionPreference = "SilentlyContinue" if (& { $ErrorActionPreference = "SilentlyContinue"
![string]::IsNullOrEmpty((&$PYTHON -V)) ![string]::IsNullOrEmpty((&$PYTHON -V))

Some files were not shown because too many files have changed in this diff Show more