diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 7ee2182..4a58333 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -6,11 +6,22 @@ on: - cron: '0 8 * * 6' jobs: test: - runs-on: ubuntu-20.04 + env: + PIP_DISABLE_PIP_VERSION_CHECK: 1 strategy: + fail-fast: false matrix: - python: ["2.7", "pypy2.7", "3.5", "3.6", "3.7", "3.8", "3.9", "3.10", "pypy3.9"] - name: "Test: Python ${{ matrix.python }}" + os: ["ubuntu-20.04", "macos-latest"] + 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: - uses: actions/checkout@v3 with: @@ -18,7 +29,14 @@ jobs: - uses: actions/setup-python@v4 with: 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 + fmt: name: Format runs-on: ubuntu-22.04 diff --git a/.gitignore b/.gitignore index bf45d22..c904d91 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,10 @@ *.egg-info *.pyc +.coverage* +.eggs/ +.idea/ +.tox/ +.venv/ build/ dist/ +htmlcov/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a56d1c4..53c18ec 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -50,6 +50,31 @@ used in the rest of the project. The version history should be clean, and commit messages should be descriptive and [properly formatted][commit-messages]. +When preparing a patch, it's recommended that you add unit tests +that demonstrate the bug is fixed (or that the feature works). +You can run the tests on your local machine by installing the `dev` extras. +The steps below do this using a virtual environment: + +```shell +# Create a local virtual environment +$ python -m venv .venv + +# Activate the virtual environment +# Cygwin, Linux, and MacOS: +$ . .venv/bin/activate +# Windows Powershell: +$ & .venv\Scripts\Activate.ps1 + +# Update pip and setuptools +(.venv) $ python -m pip install -U pip setuptools + +# Install dotbot and its development dependencies +(.venv) $ python -m pip install -e .[dev] + +# Run the unit tests +(.venv) $ tox +``` + --- If you have any questions about anything, feel free to [ask][email]! diff --git a/dotbot/cli.py b/dotbot/cli.py index 28485d1..d119902 100644 --- a/dotbot/cli.py +++ b/dotbot/cli.py @@ -1,16 +1,16 @@ -import os, 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 glob import os 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, Shell +from .util import module def add_options(parser): @@ -118,9 +118,10 @@ def main(): else: log.use_color(sys.stdout.isatty()) + plugins = [] plugin_directories = list(options.plugin_dirs) if not options.disable_built_in_plugins: - from .plugins import Clean, Create, Link, Shell + plugins.extend([Clean, Create, Link, Shell]) plugin_paths = [] for directory in plugin_directories: for plugin_path in glob.glob(os.path.join(directory, "*.py")): @@ -129,7 +130,7 @@ def main(): plugin_paths.append(plugin_path) for plugin_path in plugin_paths: abspath = os.path.abspath(plugin_path) - module.load(abspath) + plugins.extend(module.load(abspath)) if not options.config_file: log.error("No configuration file specified") exit(1) @@ -151,6 +152,7 @@ def main(): skip=options.skip, exit_on_failure=options.exit_on_failure, options=options, + plugins=plugins, ) success = dispatcher.dispatch(tasks) if success: diff --git a/dotbot/config.py b/dotbot/config.py index 9012f17..b076fd6 100644 --- a/dotbot/config.py +++ b/dotbot/config.py @@ -1,6 +1,8 @@ -import yaml import json import os.path + +import yaml + from .util import string diff --git a/dotbot/dispatcher.py b/dotbot/dispatcher.py index 18f0b0a..76994c3 100644 --- a/dotbot/dispatcher.py +++ b/dotbot/dispatcher.py @@ -1,17 +1,25 @@ import os from argparse import Namespace -from .plugin import Plugin -from .messenger import Messenger + from .context import Context +from .messenger import Messenger +from .plugin import Plugin class Dispatcher(object): def __init__( - self, base_directory, only=None, skip=None, exit_on_failure=False, options=Namespace() + self, + base_directory, + only=None, + skip=None, + exit_on_failure=False, + options=Namespace(), + plugins=None, ): self._log = Messenger() 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._skip = skip self._exit = exit_on_failure @@ -65,9 +73,6 @@ class Dispatcher(object): return False return success - def _load_plugins(self): - self._plugins = [plugin(self._context) for plugin in Plugin.__subclasses__()] - class DispatchError(Exception): pass diff --git a/dotbot/messenger/__init__.py b/dotbot/messenger/__init__.py index 38fc6bc..394ebb3 100644 --- a/dotbot/messenger/__init__.py +++ b/dotbot/messenger/__init__.py @@ -1,2 +1,2 @@ -from .messenger import Messenger from .level import Level +from .messenger import Messenger diff --git a/dotbot/messenger/messenger.py b/dotbot/messenger/messenger.py index 2d2eead..ddd8e39 100644 --- a/dotbot/messenger/messenger.py +++ b/dotbot/messenger/messenger.py @@ -1,5 +1,5 @@ -from ..util.singleton import Singleton from ..util.compat import with_metaclass +from ..util.singleton import Singleton from .color import Color from .level import Level diff --git a/dotbot/plugin.py b/dotbot/plugin.py index ef835ab..fd1fe3d 100644 --- a/dotbot/plugin.py +++ b/dotbot/plugin.py @@ -1,5 +1,5 @@ -from .messenger import Messenger from .context import Context +from .messenger import Messenger class Plugin(object): diff --git a/dotbot/plugins/clean.py b/dotbot/plugins/clean.py index ff42c09..70d3522 100644 --- a/dotbot/plugins/clean.py +++ b/dotbot/plugins/clean.py @@ -1,8 +1,10 @@ import os -import dotbot +import sys + +from ..plugin import Plugin -class Clean(dotbot.Plugin): +class Clean(Plugin): """ Cleans broken symbolic links. """ @@ -42,7 +44,9 @@ class Clean(dotbot.Plugin): self._log.debug("Ignoring nonexistent directory %s" % target) return True 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): # isdir implies not islink -- we don't want to descend into # symlinked directories. okay to do a recursive call here @@ -50,6 +54,8 @@ class Clean(dotbot.Plugin): self._clean(path, force, recursive) if not os.path.exists(path) and os.path.islink(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: self._log.lowinfo("Removing invalid link %s -> %s" % (path, points_at)) os.remove(path) diff --git a/dotbot/plugins/create.py b/dotbot/plugins/create.py index 6fd660f..c593d52 100644 --- a/dotbot/plugins/create.py +++ b/dotbot/plugins/create.py @@ -1,8 +1,9 @@ import os -import dotbot + +from ..plugin import Plugin -class Create(dotbot.Plugin): +class Create(Plugin): """ Create empty paths. """ @@ -21,7 +22,7 @@ class Create(dotbot.Plugin): success = True defaults = self._context.defaults().get("create", {}) 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 if isinstance(paths, dict): options = paths[key] @@ -48,6 +49,9 @@ class Create(dotbot.Plugin): try: self._log.lowinfo("Creating path %s" % path) 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: self._log.warning("Failed to create path %s" % path) success = False diff --git a/dotbot/plugins/link.py b/dotbot/plugins/link.py index 39893ec..d0f8634 100644 --- a/dotbot/plugins/link.py +++ b/dotbot/plugins/link.py @@ -1,13 +1,13 @@ -import os -import sys import glob +import os import shutil -import dotbot -import dotbot.util -import subprocess +import sys + +from ..plugin import Plugin +from ..util import shell_command -class Link(dotbot.Plugin): +class Link(Plugin): """ Symbolically links dotfiles. """ @@ -58,7 +58,7 @@ class Link(dotbot.Plugin): if test is not None and not self._test_success(test): self._log.lowinfo("Skipping %s" % destination) continue - path = os.path.expandvars(os.path.expanduser(path)) + path = os.path.normpath(os.path.expandvars(os.path.expanduser(path))) if use_glob: glob_results = self._create_glob_results(path, exclude_paths) if len(glob_results) == 0: @@ -140,7 +140,7 @@ class Link(dotbot.Plugin): return success 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: self._log.debug("Test '%s' returned false" % command) return ret == 0 @@ -166,6 +166,8 @@ class Link(dotbot.Plugin): return [] # call glob.glob; only python >= 3.5 supports recursive globs found = glob.glob(path) if (sys.version_info < (3, 5)) else glob.glob(path, recursive=True) + # normalize paths to ensure cross-platform compatibility + found = [os.path.normpath(p) for p in found] # if using recursive glob (`**`), filter results to return only files: if "**" in path and not path.endswith(str(os.sep)): self._log.debug("Excluding directories from recursive glob: " + str(path)) @@ -197,7 +199,10 @@ class Link(dotbot.Plugin): Returns the destination of the symbolic link. """ 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): """ @@ -223,7 +228,7 @@ class Link(dotbot.Plugin): def _delete(self, source, path, relative, canonical_path, force): success = True 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: source = self._relative_path(source, fullpath) if (self._is_link(path) and self._link_destination(path) != source) or ( @@ -264,9 +269,10 @@ class Link(dotbot.Plugin): Returns true if successfully linked files. """ 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) absolute_source = os.path.join(base_directory, source) + link_name = os.path.normpath(link_name) if relative: source = self._relative_path(absolute_source, destination) else: diff --git a/dotbot/plugins/shell.py b/dotbot/plugins/shell.py index 7092158..4c8781e 100644 --- a/dotbot/plugins/shell.py +++ b/dotbot/plugins/shell.py @@ -1,10 +1,8 @@ -import os -import subprocess -import dotbot -import dotbot.util +from ..plugin import Plugin +from ..util import shell_command -class Shell(dotbot.Plugin): +class Shell(Plugin): """ Run arbitrary shell commands. """ @@ -51,7 +49,7 @@ class Shell(dotbot.Plugin): self._log.lowinfo("%s [%s]" % (msg, cmd)) stdout = options.get("stdout", stdout) stderr = options.get("stderr", stderr) - ret = dotbot.util.shell_command( + ret = shell_command( cmd, cwd=self._context.base_directory(), enable_stdin=stdin, diff --git a/dotbot/util/common.py b/dotbot/util/common.py index 62614a1..10dbb48 100644 --- a/dotbot/util/common.py +++ b/dotbot/util/common.py @@ -1,6 +1,6 @@ import os -import subprocess import platform +import subprocess def shell_command(command, cwd=None, enable_stdin=False, enable_stdout=False, enable_stderr=False): diff --git a/dotbot/util/module.py b/dotbot/util/module.py index ded485a..183cac2 100644 --- a/dotbot/util/module.py +++ b/dotbot/util/module.py @@ -1,4 +1,7 @@ -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. loaded_modules = [] @@ -7,8 +10,17 @@ loaded_modules = [] def load(path): basename = os.path.basename(path) module_name, extension = os.path.splitext(basename) - plugin = load_module(module_name, path) - loaded_modules.append(plugin) + loaded_module = load_module(module_name, path) + 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): diff --git a/setup.py b/setup.py index a12bf98..8b3e592 100644 --- a/setup.py +++ b/setup.py @@ -58,6 +58,12 @@ setup( install_requires=[ "PyYAML>=5.3,<6", ], + extras_require={ + "dev": { + "pytest", + "tox", + } + }, # To provide executable scripts, use entry points in preference to the # "scripts" keyword. Entry points provide cross-platform support and allow # pip to create the appropriate form of executable for the target platform. diff --git a/test/.editorconfig b/test/.editorconfig deleted file mode 100644 index b490228..0000000 --- a/test/.editorconfig +++ /dev/null @@ -1,5 +0,0 @@ -[Vagrantfile] -indent_size = 2 - -[{test,test_travis}] -indent_size = 4 diff --git a/test/.gitignore b/test/.gitignore deleted file mode 100644 index 73ab2cf..0000000 --- a/test/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -.vagrant/ -*.log diff --git a/test/README.md b/test/README.md deleted file mode 100644 index 459981e..0000000 --- a/test/README.md +++ /dev/null @@ -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/ diff --git a/test/Vagrantfile b/test/Vagrantfile deleted file mode 100644 index ad79ef1..0000000 --- a/test/Vagrantfile +++ /dev/null @@ -1,28 +0,0 @@ -Vagrant.configure(2) do |config| - config.vm.box = 'ubuntu/jammy64' - - 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 https://github.com/pyenv/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 diff --git a/test/driver-lib.bash b/test/driver-lib.bash deleted file mode 100644 index 09ad303..0000000 --- a/test/driver-lib.bash +++ /dev/null @@ -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 -} diff --git a/test/test b/test/test deleted file mode 100755 index c52932b..0000000 --- a/test/test +++ /dev/null @@ -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} diff --git a/test/test-lib.bash b/test/test-lib.bash deleted file mode 100644 index d1028cf..0000000 --- a/test/test-lib.bash +++ /dev/null @@ -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 diff --git a/test/tests/clean-default.bash b/test/tests/clean-default.bash deleted file mode 100644 index 8bb405d..0000000 --- a/test/tests/clean-default.bash +++ /dev/null @@ -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 < ${DOTFILES}/h -' - -test_expect_success 'run' ' -run_dotbot_json < ${DOTFILES}/h -' - -test_expect_success 'run' ' -run_dotbot_json < ${DOTFILES}/f && -echo "grape" > ~/f && -ln -s ~/f ~/.f && -ln -s /nowhere ~/.g -' - -test_expect_failure 'run-fail' ' -run_dotbot < ~/x -- link: - ~/y: y -EOF -' - -test_expect_success 'test' ' -[ "$(readlink ~/bad | cut -d/ -f5-)" = "dotfiles/nonexistent" ] && - ! test -f ~/x && test -f ~/y -' diff --git a/test/tests/except.bash b/test/tests/except.bash deleted file mode 100644 index 2973ad9..0000000 --- a/test/tests/except.bash +++ /dev/null @@ -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 < ~/y -- link: - ~/x: x -EOF -' - -test_expect_success 'test' ' -grep "pear" ~/y && ! test -f ~/x -' - -test_expect_success 'run 2' ' -run_dotbot --except shell < ~/z -- link: - ~/x: x -EOF -' - -test_expect_success 'test' ' -grep "apple" ~/x && ! test -f ~/z -' diff --git a/test/tests/exit-on-failure.bash b/test/tests/exit-on-failure.bash deleted file mode 100644 index aead77b..0000000 --- a/test/tests/exit-on-failure.bash +++ /dev/null @@ -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 <> ~/tmp_bin/python < ${DOTFILES}/f && -ln -s dotfiles dotfiles-symlink -' - -test_expect_success 'run' ' -cat > "${DOTFILES}/${INSTALL_CONF}" < ${DOTFILES}/f && -echo "grape" > ${DOTFILES}/fd -' - -test_expect_success 'run' ' -run_dotbot < ~/f -' - -test_expect_success 'run' ' -run_dotbot < ${DOTFILES}/h -' - -test_expect_success 'run' ' -export APPLE="h" && -run_dotbot < ${DOTFILES}/h -' - -test_expect_success 'run' ' -export APPLE="h" && -run_dotbot < ${DOTFILES}/f && -echo "grape" > ${DOTFILES}/h -' - -test_expect_success 'run' ' -export ORANGE=".config" && -export BANANA="g" && -unset PEAR && -run_dotbot < ${DOTFILES}/\$ORANGE -' - -test_expect_success 'run' ' -unset ORANGE && -run_dotbot < ${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 < ${DOTFILES}/config/baz/buzz/e -' - -test_expect_success 'run 2' ' -run_dotbot -v < ${DOTFILES}/config/baz/bizz/g -' - -test_expect_success 'run 3' ' -run_dotbot -v < ${DOTFILES}/config/fiz/f -' - -test_expect_success 'run 4' ' -run_dotbot -v < ${DOTFILES}/config/foo/a && -echo "banana" > ${DOTFILES}/config/bar/b && -echo "cherry" > ${DOTFILES}/config/bar/c -' - -test_expect_success 'run' ' -run_dotbot -v < ${DOTFILES}/conf/${fruit} - echo "dot-${fruit}" > ${DOTFILES}/conf/.${fruit} -done -' - -test_expect_success 'glob patterns: "conf/*"' ' -run_dotbot -v <=" 3.5 \ - || test_expect_failure 'expect-fail' ' -run_dotbot -v <=" 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 < ${DOTFILES}/bin/a && -echo "banana" > ${DOTFILES}/bin/b && -echo "cherry" > ${DOTFILES}/bin/c -' - -test_expect_success 'run 1' ' -run_dotbot -v < ${DOTFILES}/bin/.a && -echo "dot_banana" > ${DOTFILES}/bin/.b && -echo "dot_cherry" > ${DOTFILES}/bin/.c -' - -test_expect_success 'run 3' ' -run_dotbot -v < ${DOTFILES}/.a && -echo "dot_banana" > ${DOTFILES}/.b && -echo "dot_cherry" > ${DOTFILES}/.c -' - -test_expect_success 'run 4' ' -run_dotbot -v < ${DOTFILES}/f -' - -test_expect_success 'run' ' -run_dotbot < ${DOTFILES}/f && -echo "grape" > ~/.f -' - -test_expect_failure 'run' ' -run_dotbot < ${DOTFILES}/f && -echo "grape" > ${DOTFILES}/g && -ln -s dotfiles dotfiles-symlink -' - -test_expect_success 'run' ' -cat > "${DOTFILES}/${INSTALL_CONF}" < "${DOTFILES}/${INSTALL_CONF}" < ${DOTFILES}/conf/a && -echo "banana" > ${DOTFILES}/conf/b && -echo "cherry" > ${DOTFILES}/conf/c -' - -test_expect_success 'test glob w/ prefix' ' -run_dotbot -v < ${DOTFILES}/f && -mkdir ${DOTFILES}/d && -echo "grape" > ${DOTFILES}/d/e -' - -test_expect_success 'run' ' -run_dotbot < ${DOTFILES}/f && -echo "grape" > ~/.f -' - -test_expect_failure 'run' ' -run_dotbot < ${DOTFILES}/f && -echo "grape" > ~/f && -ln -s ~/f ~/.f -' - -test_expect_success 'run' ' -run_dotbot < ${DOTFILES}/f && -echo "grape" > ~/.f -' - -test_expect_success 'run' ' -run_dotbot < ${DOTFILES}/x -' - -test_expect_success 'run' ' -run_dotbot --only link < ~/z -- link: - ~/d/x: x -EOF -' - -test_expect_success 'test' ' -grep "apple" ~/d/x && ! test -f ~/z -' diff --git a/test/tests/only-multi.bash b/test/tests/only-multi.bash deleted file mode 100644 index e8d8362..0000000 --- a/test/tests/only-multi.bash +++ /dev/null @@ -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 < ~/x -- link: - ~/y: y -EOF -' - -test_expect_success 'test' ' -! test -f ~/bad && grep "x" ~/x && ! test -f ~/y -' diff --git a/test/tests/only.bash b/test/tests/only.bash deleted file mode 100644 index 1f74441..0000000 --- a/test/tests/only.bash +++ /dev/null @@ -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 < ~/y -- link: - ~/x: x -EOF -' - -test_expect_success 'test' ' -grep "pear" ~/y && ! test -f ~/x -' - -test_expect_success 'run 2' ' -run_dotbot --only link < ~/z -- link: - ~/x: x -EOF -' - -test_expect_success 'test' ' -grep "apple" ~/x && ! test -f ~/z -' diff --git a/test/tests/plugin-dir.bash b/test/tests/plugin-dir.bash deleted file mode 100644 index f3a5e94..0000000 --- a/test/tests/plugin-dir.bash +++ /dev/null @@ -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 < ${DOTFILES}/f -' - -test_expect_failure 'run' ' -run_dotbot --disable-built-in-plugins < ${DOTFILES}/test.py < ${DOTFILES}/test.py <&2 -EOF -' - -test_expect_success 'run 5' ' -(run_dotbot -vv 2>&1 | (grep "^apple")) <&2 -EOF -' - -test_expect_success 'run 6' ' -(run_dotbot -vv 2>&1 | (grep "^apple")) <&2 - stdout: false -EOF -' - -test_expect_success 'run 7' ' -(run_dotbot -vv 2>&1 | (grep "^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")) <&2 -EOF -' diff --git a/test/tests/shell-compact-stdout.bash b/test/tests/shell-compact-stdout.bash deleted file mode 100644 index dc55d52..0000000 --- a/test/tests/shell-compact-stdout.bash +++ /dev/null @@ -1,22 +0,0 @@ -test_description='shell command stdout works in compact form' -. '../test-lib.bash' - -test_expect_success 'run' ' -(run_dotbot | grep "^apple") < ${DOTFILES}/foo -' - -test_expect_success 'run' ' -cat > ${DOTFILES}/install.conf.yaml <= 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 diff --git a/tests/dotbot_plugin_directory.py b/tests/dotbot_plugin_directory.py new file mode 100644 index 0000000..fe60d7d --- /dev/null +++ b/tests/dotbot_plugin_directory.py @@ -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 diff --git a/tests/dotbot_plugin_file.py b/tests/dotbot_plugin_file.py new file mode 100644 index 0000000..1dde95d --- /dev/null +++ b/tests/dotbot_plugin_file.py @@ -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 diff --git a/tests/test_bin_dotbot.py b/tests/test_bin_dotbot.py new file mode 100644 index 0000000..aac6f14 --- /dev/null +++ b/tests/test_bin_dotbot.py @@ -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, + ) diff --git a/tests/test_clean.py b/tests/test_clean.py new file mode 100644 index 0000000..0b85fce --- /dev/null +++ b/tests/test_clean.py @@ -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")) diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..5868e6f --- /dev/null +++ b/tests/test_cli.py @@ -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")) diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..d6338d4 --- /dev/null +++ b/tests/test_config.py @@ -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")) diff --git a/tests/test_create.py b/tests/test_create.py new file mode 100644 index 0000000..7b2c6d5 --- /dev/null +++ b/tests/test_create.py @@ -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 diff --git a/tests/test_link.py b/tests/test_link.py new file mode 100644 index 0000000..dd6d085 --- /dev/null +++ b/tests/test_link.py @@ -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" diff --git a/tests/test_noop.py b/tests/test_noop.py new file mode 100644 index 0000000..5949ff5 --- /dev/null +++ b/tests/test_noop.py @@ -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") diff --git a/tests/test_shell.py b/tests/test_shell.py new file mode 100644 index 0000000..1744163 --- /dev/null +++ b/tests/test_shell.py @@ -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) diff --git a/tests/test_shim.py b/tests/test_shim.py new file mode 100644 index 0000000..4a137ca --- /dev/null +++ b/tests/test_shim.py @@ -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" diff --git a/tools/git-submodule/install.ps1 b/tools/git-submodule/install.ps1 index a5940cf..5d8d9c5 100644 --- a/tools/git-submodule/install.ps1 +++ b/tools/git-submodule/install.ps1 @@ -10,7 +10,7 @@ Set-Location $BASEDIR git -C $DOTBOT_DIR submodule sync --quiet --recursive 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 if (& { $ErrorActionPreference = "SilentlyContinue" ![string]::IsNullOrEmpty((&$PYTHON -V)) diff --git a/tools/hg-subrepo/install.ps1 b/tools/hg-subrepo/install.ps1 index 39078bf..3b9439a 100644 --- a/tools/hg-subrepo/install.ps1 +++ b/tools/hg-subrepo/install.ps1 @@ -9,7 +9,7 @@ $BASEDIR = $PSScriptRoot Set-Location $BASEDIR 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 if (& { $ErrorActionPreference = "SilentlyContinue" ![string]::IsNullOrEmpty((&$PYTHON -V)) diff --git a/tox.ini b/tox.ini new file mode 100644 index 0000000..6bf285a --- /dev/null +++ b/tox.ini @@ -0,0 +1,96 @@ +[tox] +; On Windows, only CPython >= 3.8 is supported. +; All older versions, and PyPy, lack full symlink support. +envlist = + coverage_erase + py{38, 39, 310}-all_platforms + coverage_report + py{27, 35, 36, 37}-most_platforms + pypy{2, 3}-most_platforms + py39-black + py39-isort +skip_missing_interpreters = true + + +[testenv] +platform = + all_platforms: cygwin|darwin|linux|win32 + most_platforms: cygwin|darwin|linux + +deps = + pytest + pytest-randomly + pyyaml + mock; python_version == "2.7" + all_platforms: coverage + +commands = + all_platforms: coverage run -m pytest tests/ + most_platforms: pytest tests/ + + +[testenv:coverage_erase] +skipsdist = true +skip_install = true +deps = coverage +commands = coverage erase + + +[testenv:coverage_report] +skipsdist = true +skip_install = true +deps = coverage +commands_pre = + coverage combine + +commands = + coverage report + coverage html + + +[testenv:py39-black] +skip_install = true +skip_build = true +deps = + black + +commands = + black --check dotbot/ tests/ + + +[testenv:py39-isort] +skip_install = true +skip_build = true +deps = + isort + +commands = + isort --check dotbot/ tests/ + + +[coverage:run] +branch = true +parallel = true +source = + dotbot/ + tests/ + + +[coverage:html] +directory = htmlcov + + +[gh-actions] +python = + ; Run on all platforms (Linux, Mac, and Windows) + 3.8: py38-all_platforms + 3.9: py39-all_platforms, py39-black, py39-isort + 3.10: py310-all_platforms + + ; Run on most platforms (Linux and Mac) + pypy-2.7: pypy2-most_platforms + pypy-3.9: pypy3-most_platforms + 2.7: py27-most_platforms + 3.5: py35-most_platforms + 3.6: py36-most_platforms + 3.7: py37-most_platforms