Repository: anishathalye/dotbot Branch: master Commit: a7fe585f0876 Files: 59 Total size: 208.6 KB Directory structure: gitextract_is1qblwr/ ├── .ci/ │ └── check-changelog.py ├── .editorconfig ├── .github/ │ ├── dependabot.yaml │ └── workflows/ │ ├── ci.yml │ └── release.yml ├── .gitignore ├── .gitmodules ├── CHANGELOG.md ├── CONTRIBUTING.md ├── DEVELOPMENT.md ├── LICENSE.md ├── README.md ├── bin/ │ └── dotbot ├── codecov.yml ├── pyproject.toml ├── src/ │ └── dotbot/ │ ├── __about__.py │ ├── __init__.py │ ├── cli.py │ ├── config.py │ ├── context.py │ ├── dispatcher.py │ ├── messenger/ │ │ ├── __init__.py │ │ ├── color.py │ │ ├── level.py │ │ └── messenger.py │ ├── plugin.py │ ├── plugins/ │ │ ├── __init__.py │ │ ├── clean.py │ │ ├── create.py │ │ ├── link.py │ │ └── shell.py │ └── util/ │ ├── __init__.py │ ├── common.py │ ├── module.py │ ├── singleton.py │ └── string.py ├── tests/ │ ├── __init__.py │ ├── conftest.py │ ├── dotbot_plugin_context_plugin.py │ ├── dotbot_plugin_counter.py │ ├── dotbot_plugin_directory.py │ ├── dotbot_plugin_dispatcher_no_plugins.py │ ├── dotbot_plugin_dry_run.py │ ├── dotbot_plugin_file.py │ ├── dotbot_plugin_issue_357.py │ ├── test_bin_dotbot.py │ ├── test_clean.py │ ├── test_cli.py │ ├── test_config.py │ ├── test_create.py │ ├── test_link.py │ ├── test_noop.py │ ├── test_plugins.py │ ├── test_shell.py │ └── test_shim.py └── tools/ ├── git-submodule/ │ ├── install │ └── install.ps1 └── hg-subrepo/ ├── install └── install.ps1 ================================================ FILE CONTENTS ================================================ ================================================ FILE: .ci/check-changelog.py ================================================ # noqa: INP001 import argparse import re import subprocess import sys from typing import Set, Tuple def main() -> None: parser = argparse.ArgumentParser() parser.add_argument("--changelog", type=str, help="Path to the changelog file", required=True) args = parser.parse_args() git_versions = get_git_minor_versions() changelog_versions = get_changelog_versions(args.changelog) missing_versions = git_versions - changelog_versions if missing_versions: print("The following versions are missing from the changelog:") # noqa: T201 for version in sorted(missing_versions, key=version_to_tuple): print(f"- v{version}") # noqa: T201 sys.exit(1) changelog_extras = changelog_versions - git_versions if changelog_extras: print("The following versions are in the changelog but not in git tags:") # noqa: T201 for version in sorted(changelog_extras, key=version_to_tuple): print(f"- v{version}") # noqa: T201 sys.exit(1) print("All versions are present in the changelog.") # noqa: T201 def get_git_minor_versions() -> Set[str]: """ Get all major/minor versions (but not patch versions) from git tags. """ tags = subprocess.check_output(["git", "tag", "--list"]).decode().splitlines() # noqa: S607 major_minor_re = re.compile(r"^v(\d+\.\d+)\.\d+$") versions: Set[str] = set() for tag in tags: m = major_minor_re.match(tag) if m: versions.add(m.group(1)) return versions def get_changelog_versions(changelog_path: str) -> Set[str]: """ Get all versions listed in the changelog. """ with open(changelog_path) as f: changelog = f.read() version_re = re.compile(r"^- v(\d+\.\d+)$", re.MULTILINE) return {m.group(1) for m in version_re.finditer(changelog)} def version_to_tuple(version: str) -> Tuple[int, ...]: """ Convert a version string without the "v" prefix to a tuple. """ return tuple(int(i) for i in version.split(".")) if __name__ == "__main__": main() ================================================ FILE: .editorconfig ================================================ root = true [*] charset = utf-8 end_of_line = lf insert_final_newline = true indent_style = space trim_trailing_whitespace = true [*.py] indent_size = 4 ================================================ FILE: .github/dependabot.yaml ================================================ version: 2 updates: - package-ecosystem: "github-actions" directory: "/" schedule: interval: "monthly" groups: github-actions: patterns: - "*" ================================================ FILE: .github/workflows/ci.yml ================================================ name: CI on: push: pull_request: schedule: - cron: "0 8 * * 6" jobs: test: env: PIP_DISABLE_PIP_VERSION_CHECK: 1 strategy: fail-fast: false matrix: os: ["ubuntu-22.04", "macos-latest", "windows-latest"] python: - "3.7" - "3.8" - "3.9" - "3.10" - "3.11" - "3.12" - "3.13" - "3.14" - "pypy-3.10" include: - python: "3.7" hatch_version: "1.16.4" exclude: - os: "macos-latest" python: "3.7" - os: "windows-latest" python: "3.7" - os: "windows-latest" python: "pypy-3.10" runs-on: ${{ matrix.os }} name: "Test: Python ${{ matrix.python }} on ${{ matrix.os }}" steps: - uses: actions/checkout@v6 with: submodules: recursive - uses: pypa/hatch@install with: version: ${{ matrix.hatch_version || 'latest' }} - run: hatch test -v --cover --include python=$(echo ${{ matrix.python }} | tr -d '-') tests - run: hatch run coverage:xml - uses: codecov/codecov-action@v5 with: fail_ci_if_error: true token: ${{ secrets.CODECOV_TOKEN }} typecheck: name: Type check runs-on: ubuntu-24.04 steps: - uses: actions/checkout@v6 - uses: pypa/hatch@install - run: hatch run types:check fmt: name: Format and lint runs-on: ubuntu-24.04 steps: - uses: actions/checkout@v6 - uses: pypa/hatch@install - run: hatch fmt --check changelog: name: Check changelog runs-on: ubuntu-24.04 steps: - uses: actions/checkout@v6 with: fetch-depth: 0 fetch-tags: true - uses: pypa/hatch@install - run: hatch run python .ci/check-changelog.py --changelog CHANGELOG.md ================================================ FILE: .github/workflows/release.yml ================================================ name: Release on: push: tags: - "v[0-9]+.[0-9]+.[0-9]+" jobs: test: env: PIP_DISABLE_PIP_VERSION_CHECK: 1 strategy: fail-fast: false matrix: include: - os: ubuntu-latest platform: linux arch: x64 - os: ubuntu-24.04-arm platform: linux arch: arm64 - os: macos-13 platform: macos arch: x64 - os: macos-latest platform: macos arch: arm64 - os: windows-latest platform: windows arch: x64 - os: windows-11-arm platform: windows arch: arm64 runs-on: ${{ matrix.os }} name: "Test: ${{ matrix.platform }}-${{ matrix.arch }}" steps: - uses: actions/checkout@v6 with: submodules: recursive - uses: pypa/hatch@install - run: hatch test build-binaries: needs: test strategy: matrix: include: - os: ubuntu-latest platform: linux arch: x64 - os: ubuntu-24.04-arm platform: linux arch: arm64 - os: macos-13 platform: macos arch: x64 - os: macos-latest platform: macos arch: arm64 - os: windows-latest platform: windows arch: x64 - os: windows-11-arm platform: windows arch: arm64 runs-on: ${{ matrix.os }} name: Build binary for ${{ matrix.platform }}-${{ matrix.arch }} steps: - uses: actions/checkout@v6 with: submodules: recursive - uses: actions/setup-python@v6 with: python-version: "3.11" - name: Install dependencies run: | python -m pip install --upgrade pip pip install pyinstaller pip install . - name: Build binary run: | pyinstaller --onefile --name dotbot src/dotbot/cli.py - name: Package binary shell: bash run: | if [[ "${{ matrix.platform }}" == "windows" ]]; then cd dist && powershell Compress-Archive -Path dotbot.exe -DestinationPath dotbot-${{ matrix.platform }}-${{ matrix.arch }}.zip else cd dist && tar -czf dotbot-${{ matrix.platform }}-${{ matrix.arch }}.tar.gz dotbot fi - name: Upload binary uses: actions/upload-artifact@v5 with: name: dotbot-${{ matrix.platform }}-${{ matrix.arch }} path: dist/dotbot-${{ matrix.platform }}-${{ matrix.arch }}.* github-release: needs: build-binaries runs-on: ubuntu-latest name: Create GitHub Release permissions: contents: write steps: - uses: actions/checkout@v6 - name: Download all artifacts uses: actions/download-artifact@v6 with: path: artifacts - name: Create Release uses: softprops/action-gh-release@v2 with: files: artifacts/*/dotbot-* generate_release_notes: true ================================================ FILE: .gitignore ================================================ *.pyc dist/ .coverage* ================================================ FILE: .gitmodules ================================================ [submodule "lib/pyyaml"] path = lib/pyyaml url = https://github.com/yaml/pyyaml ignore = dirty ================================================ FILE: CHANGELOG.md ================================================ Note: this changelog only lists feature additions, not bugfixes. For details on those, see the Git history. - v1.24 - Add `backup:` option for `link` - v1.23 - Switch default output to only show actions taken - Add support for `--dry-run` - Add ability to specify plugins in config file - v1.22 - Add support for creating hardlinks in `link` - Add ability to pass multiple config files - v1.21 - Drop support for Python 3.6: the minimum version supported is now Python 3.7 - v1.20 - Drop support for Python 2 and old versions of Python 3: the minimum version supported is now Python 3.6 - v1.19 - Add `mode:` option for `create` - Add `exclude:` option for `link` - v1.18 - Add `--only` and `--except` flags - Add support to run with `python -m dotbot` - Add `--force-color` option - v1.17 - Add `canonicalize-path:` option for `link` - v1.16 - Add `create` plugin - v1.15 - Add `quiet:` option for `shell` - v1.14 - Add `if:` option for `link` - v1.13 - Add `--no-color` flag - v1.12 - Add globbing support to `link` - v1.11 - Add force option to `clean` to remove all broken symlinks - v1.10 - Update `link` to support shorthand syntax for links - v1.9 - Add support for default options for commands - v1.8 - Update `link` to be able to create relative links - v1.7 - Add support for plugins - v1.6 - Update `link` to expand environment variables in paths - v1.5 - Update `link` to be able to automatically overwrite broken symlinks - v1.4 - Update `shell` to allow for selectively enabling/disabling stdin, stdout, and stderr - v1.3 - Add support for YAML format configs - v1.2 - Update `link` to be able to force create links (deleting things that were previously there) - Update `link` to be able to create parent directories - v1.1 - Update `clean` to remove old broken symlinks - v1.0 - Initial commit ================================================ FILE: CONTRIBUTING.md ================================================ # Contributing All kinds of contributions to Dotbot are greatly appreciated. For someone unfamiliar with the code base, the most efficient way to contribute is usually to submit a [feature request](#feature-requests) or [bug report](#bug-reports). If you want to dive into the source code, you can submit a [patch](#patches) as well, either working on your own ideas or [existing issues][issues]. ## Feature Requests Do you have an idea for an awesome new feature for Dotbot? Please [submit a feature request][issue]. It's great to hear about new ideas. If you are inclined to do so, you're welcome to [fork][fork] Dotbot, work on implementing the feature yourself, and submit a patch. In this case, it's *highly recommended* that you first [open an issue][issue] describing your enhancement to get early feedback on the new feature that you are implementing. This will help avoid wasted efforts and ensure that your work is incorporated into the code base. ## Bug Reports Did something go wrong with Dotbot? Sorry about that! Bug reports are greatly appreciated! When you [submit a bug report][issue], please include relevant information such as Dotbot version, operating system, configuration file, error messages, and steps to reproduce the bug. The more details you can include, the easier it is to find and fix the bug. ## Patches Want to hack on Dotbot? Awesome! If there are [open issues][issues], you're more than welcome to work on those - this is probably the best way to contribute to Dotbot. If you have your own ideas, that's great too! In that case, before working on substantial changes to the code base, it is *highly recommended* that you first [open an issue][issue] describing what you intend to work on. **Patches are generally submitted as pull requests.** Patches are also [accepted over email][email]. Any changes to the code base should follow the style and coding conventions used in the rest of the project. The version history should be clean, and commit messages should be descriptive and [properly formatted][commit-messages]. It's recommended that you add unit tests to demonstrate that the bug is fixed (or that the feature works). See the [Dotbot development guide][development] to learn how to run the tests, type checking, and more. --- If you have any questions about anything, feel free to [ask][email]! [issue]: https://github.com/anishathalye/dotbot/issues/new [issues]: https://github.com/anishathalye/dotbot/issues [fork]: https://github.com/anishathalye/dotbot/fork [email]: mailto:me@anishathalye.com [commit-messages]: http://tbaggery.com/2008/04/19/a-note-about-git-commit-messages.html [development]: DEVELOPMENT.md ================================================ FILE: DEVELOPMENT.md ================================================ # Development Dotbot uses the [Hatch] project manager ([installation instructions][hatch-install]). Hatch automatically manages dependencies and runs testing, type checking, and other operations in isolated [environments][hatch-environments]. [Hatch]: https://hatch.pypa.io/ [hatch-install]: https://hatch.pypa.io/latest/install/ [hatch-environments]: https://hatch.pypa.io/latest/environment/ ## Testing You can run the tests on your local machine with: ```bash hatch test ``` The [`test` command][hatch-test] supports options such as `-c` for measuring test coverage, `-a` for testing with a matrix of Python versions, and appending an argument like `tests/test_shell.py::test_shell_can_override_defaults` for running a single test. [hatch-test]: https://hatch.pypa.io/latest/tutorials/testing/overview/ ### Isolation Dotbot executes shell commands and interacts with the filesystem, and the tests exercise this functionality. The tests try to [insulate][dotbot-conftest] themselves from the machine, but if you prefer to run tests in an isolated container using Docker, you can do so with the following: ```bash docker run -it --rm -v "${PWD}:/dotbot" -w /dotbot python:3.13-bookworm /bin/bash ``` After spawning the container, install Hatch with `pip install hatch`, and then run the tests as described above. [dotbot-conftest]: tests/conftest.py ## Type checking You can run the [mypy static type checker][mypy] with: ```bash hatch run types:check ``` [mypy]: https://mypy-lang.org/ ## Formatting and linting You can run the [Ruff][ruff] formatter and linter with: ```bash hatch fmt ``` This will automatically make [safe fixes][fix-safety] to your code. If you want to only check your files without making modifications, run `hatch fmt --check`. [ruff]: https://github.com/astral-sh/ruff [fix-safety]: https://docs.astral.sh/ruff/linter/#fix-safety ## Packaging You can use [`hatch build`][hatch-build] to create build artifacts, a [source distribution ("sdist")][sdist] and a [built distribution ("wheel")][bdist]. You can use [`hatch publish`][hatch-publish] to publish build artifacts to [PyPI][pypi]. [hatch-build]: https://hatch.pypa.io/latest/build/ [sdist]: https://packaging.python.org/en/latest/glossary/#term-Source-Distribution-or-sdist [bdist]: https://packaging.python.org/en/latest/glossary/#term-Built-Distribution [hatch-publish]: https://hatch.pypa.io/latest/publish/ [pypi]: https://pypi.org/ ## Continuous integration Testing, type checking, and formatting/linting is [checked in CI][ci]. [ci]: .github/workflows/ci.yml ================================================ FILE: LICENSE.md ================================================ The MIT License (MIT) ===================== **Copyright (c) Anish Athalye (me@anishathalye.com)** Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================ # Dotbot [![Build Status](https://github.com/anishathalye/dotbot/actions/workflows/ci.yml/badge.svg)](https://github.com/anishathalye/dotbot/actions/workflows/ci.yml) [![Coverage](https://codecov.io/gh/anishathalye/dotbot/branch/master/graph/badge.svg)](https://app.codecov.io/gh/anishathalye/dotbot) [![PyPI](https://img.shields.io/pypi/v/dotbot.svg)](https://pypi.org/pypi/dotbot/) [![PyPI - Python version](https://img.shields.io/pypi/pyversions/dotbot.svg)](https://pypi.org/pypi/dotbot/) Dotbot makes installing your dotfiles as easy as `git clone $url && cd dotfiles && ./install`, even on a freshly installed system! - [Rationale](#rationale) - [Getting Started](#getting-started) - [Installing Your Dotfiles](#installing-your-dotfiles) - [Configuration](#configuration) - [Directives](#directives) ([Link](#link), [Create](#create), [Shell](#shell), [Clean](#clean), [Defaults](#defaults)) - [Plugins](#plugins) - [Command-line Arguments](#command-line-arguments) - [Wiki][wiki] --- ## Rationale Dotbot is a tool that bootstraps your dotfiles (it's a [Dot]files [bo]o[t]strapper, get it?). It does *less* than you think, because version control systems do more than you think. Dotbot is designed to be lightweight and self-contained, with no external dependencies and no installation required. Dotbot can also be a drop-in replacement for any other tool you were using to manage your dotfiles, and Dotbot is VCS-agnostic — it doesn't make any attempt to manage your dotfiles. Dotbot has many [plugins] that extend its functionality, such as: - Secrets management: [dotbot-age](https://github.com/fcatuhe/dotbot-age), [dotbot-gitcrypt](https://gitlab.com/gnfzdz/dotbot-gitcrypt), … - Package management: [dotbot-brew](https://github.com/d12frosted/dotbot-brew), [dotbot-apt](https://github.com/bryant1410/dotbot-apt), [dotbot-yum](https://gitlab.com/flyingchipmunk/dotbot-yum), … - OS and application configuration: [crontab-dotbot](https://github.com/fundor333/crontab-dotbot), [dotbot-firefox](https://github.com/kurtmckee/dotbot-firefox), … - … and [more][plugins]! See [this blog post](https://www.anishathalye.com/2014/08/03/managing-your-dotfiles/) or more resources on the [tutorials page](https://github.com/anishathalye/dotbot/wiki/Tutorials) for more detailed explanations of how to organize your dotfiles. ## Getting started ### Starting fresh? Great! You can automate the creation of your dotfiles by using the user-contributed [init-dotfiles][init-dotfiles] script. If you'd rather use a template repository, check out [dotfiles_template][dotfiles-template]. Or, if you're just looking for [some inspiration][inspiration], we've got you covered. ### Integrate with existing dotfiles The following will help you get set up using Dotbot in just a few steps. You can create an empty configuration file with: ```bash touch install.conf.yaml ``` If you're using **Git**, you can add Dotbot as a submodule: ```bash cd ~/.dotfiles # replace with the path to your dotfiles git init # initialize repository if needed git submodule add https://github.com/anishathalye/dotbot git config -f .gitmodules submodule.dotbot.ignore dirty # ignore dirty commits in the submodule cp dotbot/tools/git-submodule/install . ``` If you're using **Mercurial**, you can add Dotbot as a subrepo: ```bash cd ~/.dotfiles # replace with the path to your dotfiles hg init # initialize repository if needed echo "dotbot = [git]https://github.com/anishathalye/dotbot" > .hgsub hg add .hgsub git clone https://github.com/anishathalye/dotbot cp dotbot/tools/hg-subrepo/install . ``` If you are using PowerShell instead of a POSIX shell, you can use the provided `install.ps1` script instead of `install`. On Windows, Dotbot only supports Python 3.8+, and it requires that your account is [allowed to create symbolic links][windows-symlinks]. To get started, you just need to fill in the `install.conf.yaml` and Dotbot will take care of the rest. To help you get started we have [an example](#full-example) config file as well as [configuration documentation](#configuration) for the accepted parameters. Note: The `install` script is merely a shim that checks out the appropriate version of Dotbot and calls the full Dotbot installer. By default, the script assumes that the configuration is located in `install.conf.yaml` the Dotbot submodule is located in `dotbot`. You can change either of these parameters by editing the variables in the `install` script appropriately. Setting up Dotbot as a submodule or subrepo locks it on the current version. You can upgrade Dotbot at any point. If using a submodule, run `git submodule update --remote dotbot`, substituting `dotbot` with the path to the Dotbot submodule; be sure to commit your changes before running `./install`, otherwise the old version of Dotbot will be checked out by the install script. If using a subrepo, run `git fetch && git checkout origin/master` in the Dotbot directory. #### Installation as a command-line program If you prefer, instead of bundling Dotbot as a submodule with your dotfiles, you can install Dotbot from [PyPI] as a standalone command-line program. Use the tool of your choice, such as `pip` or [`uv`][uv]: ```bash uv tool install dotbot ``` Some systems include Dotbot in their native package manager, such as [Homebrew][homebrew-dotbot] and [Arch Linux][arch-dotbot], so for example, you can also install it with `brew install dotbot`. With Dotbot installed as a command-line program on your system, you can invoke Dotbot with `dotbot -c `. ### Full example Here's an example of a complete configuration. The conventional name for the configuration file is `install.conf.yaml`. ```yaml - defaults: link: relink: true - clean: ['~'] - link: ~/.tmux.conf: tmux.conf ~/.vim: vim ~/.vimrc: vimrc - create: - ~/downloads - ~/.vim/undo-history - shell: - [git submodule update --init --recursive, Installing submodules] ``` The configuration file is typically written in YAML, but it can also be written in JSON (which is a [subset of YAML][json2yaml]). JSON configuration files are conventionally named `install.conf.json`. ## Installing Your Dotfiles To install your dotfiles on a new machine or after updates: ```bash git clone ~/.dotfiles cd ~/.dotfiles ./install ``` To update an existing installation: ```bash cd ~/.dotfiles git pull ./install ``` ## Configuration Dotbot uses YAML or JSON-formatted configuration files to let you specify how to set up your dotfiles. Currently, Dotbot knows how to [link](#link) files and folders, [create](#create) folders, execute [shell](#shell) commands, and [clean](#clean) directories of broken symbolic links. Dotbot also supports user [plugins](#plugins) for custom commands. **Ideally, bootstrap configurations should be idempotent. That is, the installer should be able to be run multiple times without causing any problems.** This makes a lot of things easier to do (in particular, syncing updates between machines becomes really easy). Dotbot configuration files are arrays of tasks, where each task is a dictionary that contains a command name mapping to data for that command. Tasks are run in the order in which they are specified. Commands within a task do not have a defined ordering. When writing nested constructs, keep in mind that YAML is whitespace-sensitive. Following the formatting used in the examples is a good idea. If a YAML configuration file is not behaving as you expect, try inspecting the [equivalent JSON][json2yaml] and check that it is correct. ## Directives Most Dotbot commands support both a simplified and extended syntax, and they can also be configured via setting [defaults](#defaults). ### Link Link commands create symbolic links at specified locations that point to files in your dotfiles repository. This allows you to keep your configuration files in version control while having them appear where applications expect to find them. Symlinks are created by default, but hardlinks are also supported. If desired, items can be specified to be forcibly linked, overwriting existing files if necessary. Environment variables in paths are automatically expanded. #### Format Link commands are specified as a dictionary mapping link names to targets. The link name (key) is where the symbolic link will be created, and the target (value) is the file in your dotfiles directory that the link will point to. Targets are specified relative to the base directory (that is specified when running the installer). If linking directories, *do not* include a trailing slash. Link commands support an optional extended configuration. In this type of configuration, instead of specifying targets directly, link names are mapped to extended configuration dictionaries. | Parameter | Explanation | | --- | --- | | `path` | The target for the link (file in dotfiles directory), the same as in the shortcut syntax (default: null, automatic (see below)) | | `type` | The type of link to create. If specified, must be either `symlink` or `hardlink`. (default: `symlink`) | | `create` | When true, create parent directories to the link as needed. (default: false) | | `relink` | Removes the old link if it's a symlink (default: false) | | `force` | Force removes the old link, file or folder, and forces a new link (default: false) | | `backup` | Backup existing files/directories if they exist, creating a backup with suffix `.dotbot-backup.{timestamp}` (default: false) | | `relative` | When creating a symlink, use a relative path to the target. (default: false, absolute links) | | `canonicalize` | Resolve any symbolic links encountered in the target to symlink to the canonical path (default: true, real paths) | | `if` | Execute this in your `$SHELL` and only link if it is successful. | | `ignore-missing` | Do not fail if the target is missing and create the link anyway (default: false) | | `glob` | Treat `path` as a glob pattern, expanding patterns referenced below, linking all *files* matched. (default: false) | | `exclude` | Array of glob patterns to remove from glob matches. Uses same syntax as `path`. Ignored if `glob` is `false`. (default: empty, keep all matches) | | `prefix` | Prepend prefix prefix to basename of each file when linked, when `glob` is `true`. (default: '') | When `glob: true`, Dotbot uses [glob.glob](https://docs.python.org/3/library/glob.html#glob.glob) to resolve glob paths, expanding Unix shell-style wildcards, which are **not** the same as regular expressions; Only the following are expanded: | Pattern | Meaning | |:---------|:-----------------------------------| | `*` | matches anything | | `**` | matches any **file**, recursively | | `?` | matches any single character | | `[seq]` | matches any character in `seq` | | `[!seq]` | matches any character not in `seq` | However, due to the design of `glob.glob`, using a glob pattern such as `config/*`, will **not** match items that begin with `.`. To specifically capture items that being with `.`, you will need to include the `.` in the pattern, like this: `config/.*`. When using glob with the `exclude:` option, the paths in the exclude paths should be relative to the base directory, same as the glob pattern itself. For example, if a glob pattern `vim/*` matches directories `vim/autoload`, `vim/ftdetect`, `vim/ftplugin`, and `vim/spell`, and you want to ignore the spell directory, then you should use `exclude: ["vim/spell"]` (not just `"spell"`). #### Example ```yaml - link: ~/.config/terminator: create: true path: config/terminator ~/.vim: vim ~/.vimrc: relink: true path: vimrc ~/.zshrc: force: true path: zshrc ~/.hammerspoon: if: '[ `uname` = Darwin ]' path: hammerspoon ~/.config/: glob: true path: dotconf/config/** ~/: glob: true path: dotconf/* prefix: '.' ``` If the target location is omitted or set to `null`, Dotbot will use the basename of the link name, with a leading `.` stripped if present. This makes the following two config files equivalent. Explicit targets: ```yaml - link: ~/bin/ack: ack ~/.vim: vim ~/.vimrc: relink: true path: vimrc ~/.zshrc: force: true path: zshrc ~/.config/: glob: true path: config/* relink: true exclude: [ config/Code ] ~/.config/Code/User/: create: true glob: true path: config/Code/User/* relink: true ``` Implicit targets: ```yaml - link: ~/bin/ack: ~/.vim: ~/.vimrc: relink: true ~/.zshrc: force: true ~/.config/: glob: true path: config/* relink: true exclude: [ config/Code ] ~/.config/Code/User/: create: true glob: true path: config/Code/User/* relink: true ``` ### Create Create commands specify empty directories to be created. This can be useful for scaffolding out folders or parent folder structure required for various apps, plugins, shell commands, etc. #### Format Create commands are specified as an array of directories to be created. If you want to use the optional extended configuration, create commands are specified as dictionaries. For convenience, it's permissible to leave the options blank (null) in the dictionary syntax. | Parameter | Explanation | | --- | --- | | `mode` | The file mode to use for creating the leaf directory (default: 0777) | The `mode` parameter is treated in the same way as in Python's [os.mkdir](https://docs.python.org/3/library/os.html#mkdir-modebits). Its behavior is platform-dependent. On Unix systems, the current umask value is first masked out. #### Example ```yaml - create: - ~/downloads - ~/.vim/undo-history - create: ~/.ssh: mode: 0700 ~/projects: ``` ### Shell Shell commands specify shell commands to be run. Shell commands are run in the base directory (that is specified when running the installer). #### Format Shell commands can be specified in several different ways. The simplest way is just to specify a command as a string containing the command to be run. Another way is to specify a two element array where the first element is the shell command and the second is an optional human-readable description. Shell commands support an extended syntax as well, which provides more fine-grained control. | Parameter | Explanation | | --- | --- | | `command` | The command to be run | | `description` | A human-readable message describing the command (default: null) | | `quiet` | Show only the description but not the command in log output (default: false) | | `stdin` | Allow a command to read from standard input (default: false) | | `stdout` | Show a command's output from stdout (default: false) | | `stderr` | Show a command's error output from stderr (default: false) | Note that `quiet` controls whether the command (a string) is printed in log output, it does not control whether the output from running the command is printed (that is controlled by `stdout` / `stderr`). When a command's `stdin` / `stdout` / `stderr` is not enabled (which is the default), it's connected to `/dev/null`, disabling input and hiding output. #### Example ```yaml - shell: - chsh -s $(which zsh) - [chsh -s $(which zsh), Making zsh the default shell] - command: read var && echo Your variable is $var stdin: true stdout: true description: Reading and printing variable quiet: true - command: read fail stderr: true ``` ### Clean Clean commands specify directories that should be checked for dead symbolic links. These dead links are removed automatically. Only dead links that point to somewhere within the dotfiles directory are removed unless the `force` option is set to `true`. #### Format Clean commands are specified as an array of directories to be cleaned. Clean commands also support an extended configuration syntax. | Parameter | Explanation | | --- | --- | | `force` | Remove dead links even if they don't point to a file inside the dotfiles directory (default: false) | | `recursive` | Traverse the directory recursively looking for dead links (default: false) | Note: using the `recursive` option for `~` is not recommended because it will be slow. #### Example ```yaml - clean: ['~'] - clean: ~/: force: true ~/.config: recursive: true ``` ### Defaults Default options for plugins can be specified so that options don't have to be repeated many times. This can be very useful to use with the link command, for example. Defaults apply to all commands that come after setting the defaults. Defaults can be set multiple times; each change replaces the defaults with a new set of options. #### Format Defaults are specified as a dictionary mapping action names to settings, which are dictionaries from option names to values. #### Example ```yaml - defaults: link: create: true relink: true ``` ### Plugins Dotbot also supports custom directives implemented by plugins. Plugins are implemented as subclasses of `dotbot.Plugin`, so they must implement `can_handle()` and `handle()`. The `can_handle()` method should return `True` if the plugin can handle an action with the given name. The `handle()` method should do something and return whether or not it completed successfully. Plugins should declare support for dry-run with `supports_dry_run = True`, and implement this support by logging what the plugin _would_ do (without doing it) when `Context.dry_run()` is set. Plugins that don't explicitly declare support for dry-run will be skipped when Dotbot is run with `--dry-run`. All built-in Dotbot directives are written as plugins that are loaded by default, so those can be used as a reference when writing custom plugins. See [here][plugins] for a current list of third-party plugins. #### Loading plugins via configuration You can specify plugins in your configuration file as an array of files or directories (containing plugins) to load: ```yaml - plugins: - dotbot-plugins/dotbot-brew/ - dotbot-plugins/custom_plugin.py ``` Paths specified in the config file are interpreted relative to the _base directory_. #### Loading plugins via command line Plugins can also be loaded using the `--plugin` option. You can use this argument multiple times: ```bash dotbot --plugin dotbot-plugins/dotbot-brew/ --plugin dotbot-plugins/custom_plugin.py ... ``` Paths specified this way are interpreted relative to the _working directory_ where `dotbot` is invoked. It is recommended that these options are added directly to your `install` script for consistency across installations. ## Command-line arguments Dotbot takes a number of command-line arguments; you can run Dotbot with `--help`, for example, by running `./install --help`, to see the full list of options. Here, we highlight a couple that are particularly interesting. ### `--dry-run` You can call `./install --dry-run`, and Dotbot will explain what it _would_ do, without actually making any changes. This can be helpful for safely testing your configuration. Plugins that don't support dry-run will be skipped. ### `--only` You can call `./install --only [list of directives]`, such as `./install --only link`, and Dotbot will only run those sections of the config file. ### `--except` You can call `./install --except [list of directives]`, such as `./install --except shell`, and Dotbot will run all the sections of the config file except the ones listed. ## Wiki Check out the [Dotbot wiki][wiki] for more information, tips and tricks, user-contributed plugins, and more. ## Contributing Do you have a feature request, bug report, or patch? Great! See [CONTRIBUTING.md][contributing] for information on what you can do about that. ## License Copyright (c) Anish Athalye. Released under the MIT License. See [LICENSE.md][license] for details. [PyPI]: https://pypi.org/project/dotbot/ [uv]: https://github.com/astral-sh/uv [homebrew-dotbot]: https://formulae.brew.sh/formula/dotbot [arch-dotbot]: https://aur.archlinux.org/packages/dotbot [init-dotfiles]: https://github.com/Vaelatern/init-dotfiles [dotfiles-template]: https://github.com/anishathalye/dotfiles_template [inspiration]: https://github.com/anishathalye/dotbot/wiki/Users [windows-symlinks]: https://learn.microsoft.com/en-us/windows/security/threat-protection/security-policy-settings/create-symbolic-links [json2yaml]: https://www.json2yaml.com/ [plugins]: https://github.com/anishathalye/dotbot/wiki/Plugins [wiki]: https://github.com/anishathalye/dotbot/wiki [contributing]: CONTRIBUTING.md [license]: LICENSE.md ================================================ FILE: bin/dotbot ================================================ #!/usr/bin/env sh # This is a valid shell script and also a valid Python script. When this file # is executed as a shell script, it finds a python binary and executes this # file as a Python script, passing along all of the command line arguments. # When this file is executed as a Python script, it loads and runs Dotbot. This # is useful because we don't know the name of the python binary. ''':' # begin python string; this line is interpreted by the shell as `:` command -v python3 >/dev/null 2>&1 && exec python3 "$0" "$@" command -v python >/dev/null 2>&1 && exec python "$0" "$@" >&2 echo "error: cannot find python" exit 1 ''' # python code import os import sys # this file is syntactically valid Python 2; bail out if the interpreter is Python 2 if sys.version_info[0] < 3: print('error: this version of Dotbot is not compatible with Python 2:\nhttps://github.com/anishathalye/dotbot/wiki/Troubleshooting#python-2') sys.exit(1) if sys.version_info < (3, 7): print('error: this version of Dotbot requires Python 3.7+') sys.exit(1) project_root_directory = os.path.dirname( os.path.dirname(os.path.realpath(__file__))) def inject(lib_path): path = os.path.join(project_root_directory, 'lib', lib_path) sys.path.insert(0, path) inject('pyyaml/lib') if os.path.exists(os.path.join(project_root_directory, 'src', 'dotbot')): src_directory = os.path.join(project_root_directory, 'src') if src_directory not in sys.path: sys.path.insert(0, src_directory) os.putenv('PYTHONPATH', src_directory) import dotbot dotbot.cli.main() ================================================ FILE: codecov.yml ================================================ coverage: status: project: off patch: off ================================================ FILE: pyproject.toml ================================================ [build-system] requires = ["hatchling"] build-backend = "hatchling.build" [project] name = "dotbot" authors = [ { name = "Anish Athalye", email = "me@anishathalye.com" }, ] description = "A tool that bootstraps your dotfiles" readme = "README.md" requires-python = ">=3.7" classifiers = [ "Development Status :: 5 - Production/Stable", "Intended Audience :: Developers", "License :: OSI Approved :: MIT License", "Programming Language :: Python :: 3", "Topic :: Utilities", ] keywords = ["dotfiles"] dynamic = ["version"] dependencies = [ "PyYAML>=6.0.1,<7", ] [project.scripts] dotbot = "dotbot.cli:main" [project.urls] homepage = "https://github.com/anishathalye/dotbot" repository = "https://github.com/anishathalye/dotbot.git" issues = "https://github.com/anishathalye/dotbot/issues" [tool.hatch.version] path = "src/dotbot/__about__.py" [tool.hatch.build.targets.sdist] exclude = [ "lib/", ] [tool.hatch.envs.default] installer = "uv" [[tool.hatch.envs.hatch-test.matrix]] python = ["3.7", "3.8", "3.9", "3.10", "3.11", "3.12", "3.13", "3.14", "pypy3.9", "pypy3.10"] # the default configuration for the hatch-test environment # (https://hatch.pypa.io/latest/config/internal/testing/#dependencies) uses a # version of coverage[toml] that is incompatible with Python 3.7 to 3.9, so we override # the test dependencies for those Python versions here [tool.hatch.envs.hatch-test.overrides] name."^py(py)?3\\.(7|8|9)$".set-dependencies = [ "coverage-enable-subprocess", "coverage[toml]", "pytest", "pytest-mock", "pytest-randomly", "pytest-rerunfailures", "pytest-xdist[psutil]", ] [tool.coverage.run] omit = [ "*/tests/*", "*/dotfiles/*" # the tests create some .py files in a "dotfiles" directory ] [tool.hatch.envs.types] extra-dependencies = [ "mypy>=1.0.0", "pytest", "types-PyYAML>=6.0.1,<7", ] [tool.hatch.envs.types.scripts] check = "mypy {args:src tests .ci}" [tool.hatch.envs.coverage] detached = true installer = "uv" dependencies = [ "coverage[toml]", ] [tool.hatch.envs.coverage.scripts] html = "coverage html" xml = "coverage xml" [tool.mypy] strict = true [tool.ruff] extend-exclude = [ "lib/*.py" ] lint.ignore = [ "FA100", ] ================================================ FILE: src/dotbot/__about__.py ================================================ __version__ = "1.24.1" ================================================ FILE: src/dotbot/__init__.py ================================================ from dotbot.__about__ import __version__ from dotbot.cli import main from dotbot.plugin import Plugin __all__ = ["Plugin", "__version__", "main"] ================================================ FILE: src/dotbot/cli.py ================================================ import os import subprocess import sys from argparse import SUPPRESS, ArgumentParser, RawTextHelpFormatter from typing import Any, List import dotbot from dotbot.config import ConfigReader, ReadingError from dotbot.dispatcher import Dispatcher, DispatchError from dotbot.messenger import Level, Messenger from dotbot.plugins import Clean, Create, Link, Shell from dotbot.util import module def add_options(parser: ArgumentParser) -> None: parser.add_argument("-Q", "--super-quiet", action="store_true", help=SUPPRESS) # deprecated parser.add_argument("-q", "--quiet", action="store_true", help="suppress most output") parser.add_argument( "-v", "--verbose", action="count", default=0, help="enable verbose output\n" "-v: show informational messages\n" "-vv: also, set shell commands stderr/stdout to true", ) parser.add_argument("-d", "--base-directory", help="execute commands from within BASE_DIR", metavar="BASE_DIR") parser.add_argument( "-c", "--config-file", help="run commands given in CONFIG_FILE", metavar="CONFIG_FILE", nargs="+" ) parser.add_argument( "-p", "--plugin", action="append", dest="plugins", default=[], help="load PLUGIN as a plugin", metavar="PLUGIN", ) parser.add_argument("--disable-built-in-plugins", action="store_true", help="disable built-in plugins") parser.add_argument( "--plugin-dir", action="append", dest="plugin_dirs", default=[], metavar="PLUGIN_DIR", help=SUPPRESS, # deprecated ) parser.add_argument("--only", nargs="+", help="only run specified directives", metavar="DIRECTIVE") parser.add_argument("--except", nargs="+", dest="skip", help="skip specified directives", metavar="DIRECTIVE") parser.add_argument("-n", "--dry-run", action="store_true", help="print what would be done, without doing it") parser.add_argument("--force-color", dest="force_color", action="store_true", help="force color output") parser.add_argument("--no-color", dest="no_color", action="store_true", help="disable color output") parser.add_argument("--version", action="store_true", help="show program's version number and exit") parser.add_argument( "-x", "--exit-on-failure", dest="exit_on_failure", action="store_true", help="exit after first failed directive", ) def read_config(config_files: List[str]) -> Any: reader = ConfigReader(config_files) return reader.get_config() def main() -> None: log = Messenger() try: parser = ArgumentParser(formatter_class=RawTextHelpFormatter) add_options(parser) options = parser.parse_args() if options.version: try: with open(os.devnull) as devnull: git_hash = subprocess.check_output( ["git", "rev-parse", "HEAD"], # noqa: S607 cwd=os.path.dirname(os.path.abspath(__file__)), stderr=devnull, ).decode("ascii") hash_msg = f" (git {git_hash[:10]})" except (OSError, subprocess.CalledProcessError): hash_msg = "" print(f"Dotbot version {dotbot.__version__}{hash_msg}") # noqa: T201 sys.exit(0) if options.super_quiet or options.quiet: log.set_level(Level.WARNING) if options.verbose > 0: log.set_level(Level.INFO if options.verbose == 1 else Level.DEBUG) if options.force_color and options.no_color: log.error("`--force-color` and `--no-color` cannot both be provided") sys.exit(1) elif options.force_color: log.use_color(True) elif options.no_color: log.use_color(False) else: log.use_color(sys.stdout.isatty()) plugins = [] if not options.disable_built_in_plugins: plugins.extend([Clean, Create, Link, Shell]) module.load_plugins(options.plugin_dirs, plugins) # note, plugin_dirs is deprecated module.load_plugins(options.plugins, plugins) if not options.config_file: log.error("No configuration file specified") sys.exit(1) tasks = read_config(options.config_file) if not tasks: log.warning("No tasks given in configuration, no work to do") if options.base_directory: base_directory = os.path.abspath(options.base_directory) else: # default to directory of first config file base_directory = os.path.dirname(os.path.abspath(options.config_file[0])) os.chdir(base_directory) dotbot.dispatcher._all_plugins = plugins # for backwards compatibility, see dispatcher.py # noqa: SLF001 dispatcher = Dispatcher( base_directory, only=options.only, skip=options.skip, exit_on_failure=options.exit_on_failure, options=options, plugins=plugins, ) success = dispatcher.dispatch(tasks) if success: log.info("All tasks executed successfully") else: msg = "Some tasks were not executed successfully" raise DispatchError(msg) # noqa: TRY301 except (ReadingError, DispatchError) as e: log.error(str(e)) # noqa: TRY400 sys.exit(1) except KeyboardInterrupt: log.error("Operation aborted") # noqa: TRY400 sys.exit(1) if __name__ == "__main__": main() ================================================ FILE: src/dotbot/config.py ================================================ import json import os.path from typing import Any, List import yaml from dotbot.util import string class ConfigReader: _config: List[Any] def __init__(self, config_file_paths: List[str]): self._config = [] for path in config_file_paths: config = self._read(path) if config is None: continue if not isinstance(config, list): msg = "Configuration file must be a list of tasks" raise ReadingError(msg) self._config.extend(config) def _read(self, config_file_path: str) -> Any: try: _, ext = os.path.splitext(config_file_path) with open(config_file_path, encoding="utf-8") as fin: return json.load(fin) if ext == ".json" else yaml.safe_load(fin) except Exception as e: msg = string.indent_lines(str(e)) msg = f"Could not read config file:\n{msg}" raise ReadingError(msg) from e def get_config(self) -> Any: return self._config class ReadingError(Exception): pass ================================================ FILE: src/dotbot/context.py ================================================ import copy import os from argparse import Namespace from typing import TYPE_CHECKING, Any, Dict, List, Optional, Type if TYPE_CHECKING: from dotbot.plugin import Plugin class Context: """ Contextual data and information for plugins. """ def __init__( self, base_directory: str, options: Optional[Namespace] = None, plugins: "Optional[List[Type[Plugin]]]" = None ): self._base_directory = base_directory self._defaults: Dict[str, Any] = {} self._options = options if options is not None else Namespace() self._plugins = plugins def set_base_directory(self, base_directory: str) -> None: self._base_directory = base_directory def base_directory(self, canonical_path: bool = True) -> str: # noqa: FBT001, FBT002 # part of established public API base_directory = self._base_directory if canonical_path: base_directory = os.path.realpath(base_directory) return base_directory def set_defaults(self, defaults: Dict[str, Any]) -> None: self._defaults = defaults def defaults(self) -> Dict[str, Any]: return copy.deepcopy(self._defaults) def options(self) -> Namespace: return copy.deepcopy(self._options) def plugins(self) -> "Optional[List[Type[Plugin]]]": # shallow copy is ok here return copy.copy(self._plugins) def dry_run(self) -> bool: return bool(self._options.dry_run) ================================================ FILE: src/dotbot/dispatcher.py ================================================ import os from argparse import Namespace from typing import Any, Dict, List, Optional, Type from dotbot.context import Context from dotbot.messenger import Messenger from dotbot.plugin import Plugin from dotbot.util.module import load_plugins # Before b5499c7dc5b300462f3ce1c2a3d9b7a76233b39b, Dispatcher auto-loaded all # plugins, but after that change, plugins are passed in explicitly (and loaded # in cli.py). There are some plugins that rely on the old Dispatcher behavior, # so this is a workaround for implementing similar functionality: when # Dispatcher is constructed without an explicit list of plugins, _all_plugins is # used instead. _all_plugins: List[Type[Plugin]] = [] # filled in by cli.py class Dispatcher: def __init__( self, base_directory: str, only: Optional[List[str]] = None, skip: Optional[List[str]] = None, exit_on_failure: bool = False, # noqa: FBT001, FBT002 part of established public API options: Optional[Namespace] = None, plugins: Optional[List[Type[Plugin]]] = None, ): # if the caller wants no plugins, the caller needs to explicitly pass in # plugins=[] self._log = Messenger() self._setup_context(base_directory, options, plugins) if plugins is None: plugins = _all_plugins self._plugins = [plugin(self._context) for plugin in plugins] self._only = only self._skip = skip self._exit = exit_on_failure self._dry_run: bool = options is not None and bool(options.dry_run) def _setup_context( self, base_directory: str, options: Optional[Namespace], plugins: Optional[List[Type[Plugin]]] ) -> None: path = os.path.abspath(os.path.expanduser(base_directory)) if not os.path.exists(path): msg = "Nonexistent base directory" raise DispatchError(msg) self._context = Context(path, options, plugins) def dispatch(self, tasks: List[Dict[str, Any]]) -> bool: success = True for task in tasks: for action in task: if ( (self._only is not None and action not in self._only) or (self._skip is not None and action in self._skip) ) and action != "defaults": self._log.info(f"Skipping action {action}") continue handled = False if action == "defaults": self._context.set_defaults(task[action]) # replace, not update handled = True # keep going, let other plugins handle this if they want if action == "plugins": for plugin_path in task[action]: try: # load the new plugins and add them to the list of plugins # this mutates self._context._plugins; we don't add a setter method # to Context because we don't want plugins to call it new_plugins = load_plugins([plugin_path], self._context._plugins) # noqa: SLF001 for plugin_class in new_plugins: self._plugins.append(plugin_class(self._context)) except Exception as err: # noqa: BLE001 self._log.warning(f"Failed to load plugin '{plugin_path}'") self._log.debug(str(err)) success = False if not success: self._log.error("Some plugins could not be loaded") if self._exit: self._log.error("Action plugins failed") return False handled = True # keep going, let other plugins handle this if they want for plugin in self._plugins: if plugin.can_handle(action): if self._dry_run and not plugin.supports_dry_run: self._log.action(f"Skipping dry-run-unaware plugin {plugin.__class__.__name__}") handled = True continue try: local_success = plugin.handle(action, task[action]) if not local_success and self._exit: # The action has failed, exit self._log.error(f"Action {action} failed") return False success &= local_success handled = True except Exception as err: # noqa: BLE001 self._log.error(f"An error was encountered while executing action {action}") self._log.debug(str(err)) if self._exit: # There was an exception, exit return False if not handled: success = False self._log.error(f"Action {action} not handled") if self._exit: # Invalid action exit return False return success class DispatchError(Exception): pass ================================================ FILE: src/dotbot/messenger/__init__.py ================================================ from dotbot.messenger.level import Level from dotbot.messenger.messenger import Messenger __all__ = ["Level", "Messenger"] ================================================ FILE: src/dotbot/messenger/color.py ================================================ class Color: NONE = "" RESET = "\033[0m" RED = "\033[91m" GREEN = "\033[92m" YELLOW = "\033[93m" BLUE = "\033[94m" MAGENTA = "\033[95m" ================================================ FILE: src/dotbot/messenger/level.py ================================================ from enum import Enum from typing import Any class Level(Enum): NOTSET = 0 DEBUG = 10 INFO = 15 LOWINFO = 15 # Deprecated: use INFO instead # noqa: PIE796 ACTION = 20 WARNING = 30 ERROR = 40 def __lt__(self, other: Any) -> bool: if not isinstance(other, Level): return NotImplemented return self.value < other.value def __le__(self, other: Any) -> bool: if not isinstance(other, Level): return NotImplemented return self.value <= other.value def __gt__(self, other: Any) -> bool: if not isinstance(other, Level): return NotImplemented return self.value > other.value def __ge__(self, other: Any) -> bool: if not isinstance(other, Level): return NotImplemented return self.value >= other.value def __eq__(self, other: object) -> bool: if not isinstance(other, Level): return NotImplemented return self.value == other.value def __hash__(self) -> int: return hash(self.value) ================================================ FILE: src/dotbot/messenger/messenger.py ================================================ from dotbot.messenger.color import Color from dotbot.messenger.level import Level from dotbot.util.singleton import Singleton class Messenger(metaclass=Singleton): def __init__(self, level: Level = Level.ACTION): self.set_level(level) self.use_color(True) def set_level(self, level: Level) -> None: self._level = level def use_color(self, yesno: bool) -> None: # noqa: FBT001 self._use_color = yesno def log(self, level: Level, message: str) -> None: if level >= self._level: print(f"{self._color(level)}{message}{self._reset()}") # noqa: T201 def debug(self, message: str) -> None: self.log(Level.DEBUG, message) def action(self, message: str) -> None: self.log(Level.ACTION, message) def info(self, message: str) -> None: self.log(Level.INFO, message) def lowinfo(self, message: str) -> None: """Deprecated: use info() or action() instead.""" self.info(message) def warning(self, message: str) -> None: self.log(Level.WARNING, message) def error(self, message: str) -> None: self.log(Level.ERROR, message) def _color(self, level: Level) -> str: """ Get a color (terminal escape sequence) according to a level. """ if not self._use_color or level < Level.DEBUG: return "" if level < Level.INFO: return Color.YELLOW if level < Level.ACTION: return Color.BLUE if level < Level.WARNING: return Color.GREEN if level < Level.ERROR: return Color.MAGENTA return Color.RED def _reset(self) -> str: """ Get a reset color (terminal escape sequence). """ if not self._use_color: return "" return Color.RESET ================================================ FILE: src/dotbot/plugin.py ================================================ from typing import Any from dotbot.context import Context from dotbot.messenger import Messenger class Plugin: """ Abstract base class for commands that process directives. """ _context: Context _log: Messenger supports_dry_run: bool = False # plugins must explicitly declare support for dry-run mode def __init__(self, context: Context): self._context = context self._log = Messenger() def can_handle(self, directive: str) -> bool: """ Returns true if the Plugin can handle the directive. """ raise NotImplementedError def handle(self, directive: str, data: Any) -> bool: """ Executes the directive. Returns true if the Plugin successfully handled the directive. """ raise NotImplementedError ================================================ FILE: src/dotbot/plugins/__init__.py ================================================ from dotbot.plugins.clean import Clean from dotbot.plugins.create import Create from dotbot.plugins.link import Link from dotbot.plugins.shell import Shell __all__ = ["Clean", "Create", "Link", "Shell"] ================================================ FILE: src/dotbot/plugins/clean.py ================================================ import os import sys from typing import Any from dotbot.plugin import Plugin from dotbot.util.common import normslash class Clean(Plugin): """ Cleans broken symbolic links. """ supports_dry_run = True _directive = "clean" def can_handle(self, directive: str) -> bool: return directive == self._directive def handle(self, directive: str, data: Any) -> bool: if directive != self._directive: msg = f"Clean cannot handle directive {directive}" raise ValueError(msg) return self._process_clean(data) def _process_clean(self, targets: Any) -> bool: success = True defaults = self._context.defaults().get(self._directive, {}) for target in targets: force = defaults.get("force", False) recursive = defaults.get("recursive", False) if isinstance(targets, dict) and isinstance(targets[target], dict): force = targets[target].get("force", force) recursive = targets[target].get("recursive", recursive) success &= self._clean(normslash(target), force=force, recursive=recursive) if success: self._log.info("All targets have been cleaned") else: self._log.error("Some targets were not successfully cleaned") return success def _clean(self, target: str, *, force: bool, recursive: bool) -> bool: """ Cleans all the broken symbolic links in target if they point to a subdirectory of the base directory or if forced to clean. """ if not os.path.isdir(os.path.expandvars(os.path.expanduser(target))): self._log.debug(f"Ignoring nonexistent directory {target}") return True for item in os.listdir(os.path.expandvars(os.path.expanduser(target))): 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 # because depth should be fairly limited self._clean(path, force=force, recursive=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 == "win32" and points_at.startswith("\\\\?\\"): points_at = points_at[4:] if self._in_directory(path, self._context.base_directory()) or force: if self._context.dry_run(): self._log.action(f"Would remove invalid link {path} -> {points_at}") else: self._log.action(f"Removing invalid link {path} -> {points_at}") os.remove(path) else: self._log.info(f"Link {path} -> {points_at} not removed.") return True def _in_directory(self, path: str, directory: str) -> bool: """ Returns true if the path is in the directory. """ directory = os.path.join(os.path.realpath(directory), "") path = os.path.realpath(path) return os.path.commonprefix([path, directory]) == directory ================================================ FILE: src/dotbot/plugins/create.py ================================================ import os from typing import Any from dotbot.plugin import Plugin from dotbot.util.common import normslash class Create(Plugin): """ Create empty paths. """ supports_dry_run = True _directive = "create" def can_handle(self, directive: str) -> bool: return directive == self._directive def handle(self, directive: str, data: Any) -> bool: if directive != self._directive: msg = f"Create cannot handle directive {directive}" raise ValueError(msg) return self._process_paths(data) def _process_paths(self, paths: Any) -> bool: success = True defaults = self._context.defaults().get("create", {}) for key in paths: path = os.path.abspath(os.path.expandvars(os.path.expanduser(normslash(key)))) mode = defaults.get("mode", 0o777) # same as the default for os.makedirs if isinstance(paths, dict): options = paths[key] if options: mode = options.get("mode", mode) success &= self._create(path, mode) if success: self._log.info("All paths have been set up") else: self._log.error("Some paths were not successfully set up") return success def _exists(self, path: str) -> bool: """ Returns true if the path exists. """ path = os.path.expanduser(path) return os.path.exists(path) def _create(self, path: str, mode: int) -> bool: success = True if not self._exists(path): self._log.debug(f"Trying to create path {path} with mode {mode}") try: if self._context.dry_run(): self._log.action(f"Would create path {path}") return True self._log.action(f"Creating path {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(f"Failed to create path {path}") success = False else: self._log.info(f"Path exists {path}") return success ================================================ FILE: src/dotbot/plugins/link.py ================================================ import glob import os import shutil import sys from datetime import datetime, timezone from typing import Any, List, Optional, Tuple from dotbot.plugin import Plugin from dotbot.util import shell_command from dotbot.util.common import normslash class Link(Plugin): """ Symbolically links dotfiles. """ supports_dry_run = True _directive = "link" def can_handle(self, directive: str) -> bool: return directive == self._directive def handle(self, directive: str, data: Any) -> bool: if directive != self._directive: msg = f"Link cannot handle directive {directive}" raise ValueError(msg) return self._process_links(data) def _process_links(self, links: Any) -> bool: success = True defaults = self._context.defaults().get("link", {}) # Validate the default link type before looping. link_type = defaults.get("type", "symlink") if link_type not in {"symlink", "hardlink"}: self._log.warning(f"The default link type is not recognized: '{link_type}'") return False for link_name, target in links.items(): link_name = os.path.expandvars(normslash(link_name)) # noqa: PLW2901 relative = defaults.get("relative", False) # support old "canonicalize-path" key for compatibility canonical_path = defaults.get("canonicalize", defaults.get("canonicalize-path", True)) link_type = defaults.get("type", "symlink") force = defaults.get("force", False) relink = defaults.get("relink", False) create = defaults.get("create", False) use_glob = defaults.get("glob", False) backup = defaults.get("backup", False) base_prefix = defaults.get("prefix", "") test = defaults.get("if", None) ignore_missing = defaults.get("ignore-missing", False) exclude_paths = defaults.get("exclude", []) if isinstance(target, dict): # extended config test = target.get("if", test) relative = target.get("relative", relative) canonical_path = target.get("canonicalize", target.get("canonicalize-path", canonical_path)) link_type = target.get("type", link_type) if link_type not in {"symlink", "hardlink"}: msg = f"The link type is not recognized: '{link_type}'" self._log.warning(msg) success = False continue force = target.get("force", force) relink = target.get("relink", relink) create = target.get("create", create) use_glob = target.get("glob", use_glob) backup = target.get("backup", backup) base_prefix = target.get("prefix", base_prefix) ignore_missing = target.get("ignore-missing", ignore_missing) exclude_paths = target.get("exclude", exclude_paths) path = self._default_target(link_name, target.get("path")) else: path = self._default_target(link_name, target) path = normslash(path) if test is not None and not self._test_success(test): self._log.info(f"Skipping {link_name}") continue path = os.path.normpath(os.path.expandvars(os.path.expanduser(path))) if use_glob and self._has_glob_chars(path): glob_results = self._create_glob_results(path, exclude_paths) self._log.debug(f"Globs from '{path}': {glob_results}") for glob_full_item in glob_results: # Find common dirname between pattern and the item: glob_dirname = os.path.dirname(os.path.commonprefix([path, glob_full_item])) glob_item = glob_full_item if len(glob_dirname) == 0 else glob_full_item[len(glob_dirname) + 1 :] # Add prefix to basepath, if provided if base_prefix: glob_item = base_prefix + glob_item # where is it going glob_link_name = os.path.join(link_name, glob_item) if create: success &= self._create(glob_link_name) did_backup = False did_delete = False if backup: did_backup, backup_success = self._backup(glob_link_name) success &= backup_success # we only need to consider force/relink if we didn't do a backup if (force or relink) and not (did_backup and backup_success): did_delete, delete_success = self._delete( glob_full_item, glob_link_name, relative=relative, canonical_path=canonical_path, force=force, ) success &= delete_success success &= self._link( glob_full_item, glob_link_name, relative=relative, canonical_path=canonical_path, ignore_missing=ignore_missing, link_type=link_type, assume_gone=(did_backup or did_delete), ) else: if create: success &= self._create(link_name) if not ignore_missing and not self._exists(os.path.join(self._context.base_directory(), path)): # we seemingly check this twice (here and in _link) because # if the file doesn't exist and force is True, we don't # want to remove the original (this is tested by test_link_force_leaves_when_nonexistent) success = False self._log.warning(f"Nonexistent target {link_name} -> {path}") continue did_backup = False did_delete = False if backup: did_backup, backup_success = self._backup(link_name) success &= backup_success # we only need to consider force/relink if we didn't do a backup if (force or relink) and not (did_backup and backup_success): did_delete, delete_success = self._delete( path, link_name, relative=relative, canonical_path=canonical_path, force=force ) success &= delete_success success &= self._link( path, link_name, relative=relative, canonical_path=canonical_path, ignore_missing=ignore_missing, link_type=link_type, assume_gone=(did_backup or did_delete), ) if success: self._log.info("All links have been set up") else: self._log.error("Some links were not successfully set up") return success def _test_success(self, command: str) -> bool: ret = shell_command(command, cwd=self._context.base_directory()) if ret != 0: self._log.debug(f"Test '{command}' returned false") return ret == 0 def _default_target(self, link_name: str, target: Optional[str]) -> str: if target is None: basename = os.path.basename(link_name) if basename.startswith("."): return basename[1:] return basename return target def _has_glob_chars(self, path: str) -> bool: return any(i in path for i in "?*[") def _glob(self, path: str) -> List[str]: """ Wrap `glob.glob` in a python agnostic way, catching errors in usage. """ found = 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)) found = [f for f in found if os.path.isfile(f)] # return matched results return found def _create_glob_results(self, path: str, exclude_paths: List[str]) -> List[str]: self._log.debug("Globbing with pattern: " + str(path)) include = self._glob(path) self._log.debug("Glob found : " + str(include)) # filter out any paths matching the exclude globs: exclude = [] for expat in exclude_paths: self._log.debug("Excluding globs with pattern: " + str(expat)) exclude.extend(self._glob(expat)) self._log.debug("Excluded globs from '" + path + "': " + str(exclude)) ret = set(include) - set(exclude) return list(ret) def _is_link(self, path: str) -> bool: """ Returns true if the path is a symbolic link. """ return os.path.islink(os.path.expanduser(path)) def _link_target(self, path: str) -> str: """ Returns the target of the symbolic link. """ path = os.path.expanduser(path) path = os.readlink(path) if sys.platform == "win32" and path.startswith("\\\\?\\"): path = path[4:] return path def _exists(self, path: str) -> bool: """ Returns true if the path exists. """ path = os.path.expanduser(path) return os.path.exists(path) def _lexists(self, path: str) -> bool: """ Returns true if the path exists (including broken symlinks). """ path = os.path.expanduser(path) return os.path.lexists(path) def _create(self, path: str) -> bool: success = True parent = os.path.abspath(os.path.join(os.path.expanduser(path), os.pardir)) if not self._exists(parent): self._log.debug(f"Try to create parent: {parent}") if self._context.dry_run(): self._log.action(f"Would create directory {parent}") return True try: os.makedirs(parent) except OSError as e: self._log.warning(f"Failed to create directory {parent}") self._log.debug(f"OSError: {e!s}") success = False else: self._log.action(f"Creating directory {parent}") return success def _backup(self, path: str) -> Tuple[bool, bool]: if self._exists(path) and not self._is_link(path): timestamp = datetime.now(timezone.utc).astimezone().strftime("%Y%m%d-%H%M%S") backup_name = f"{path}.dotbot-backup.{timestamp}" self._log.debug(f"Try to backup file {path} to {backup_name}") if self._context.dry_run(): self._log.action(f"Would backup {path} to {backup_name}") return True, True try: os.rename(os.path.abspath(os.path.expanduser(path)), os.path.abspath(os.path.expanduser(backup_name))) except OSError as e: self._log.warning(f"Failed to backup file {path} to {backup_name}") self._log.debug(f"OSError: {e!s}") return False, False else: self._log.action(f"Backed up file {path} to {backup_name}") return True, True return False, True def _delete( self, target: str, path: str, *, relative: bool, canonical_path: bool, force: bool ) -> Tuple[bool, bool]: success = True removed = False target = os.path.join(self._context.base_directory(canonical_path=canonical_path), target) fullpath = os.path.abspath(os.path.expanduser(path)) if self._exists(path) and not self._is_link(path) and os.path.realpath(fullpath) == target: # Special case: The path is not a symlink but resolves to the target anyway. # Deleting the path would actually delete the target. # This may happen if a parent directory is a symlink. self._log.warning(f"{path} appears to be the same file as {target}.") return False, False if relative: target = self._relative_path(target, fullpath) if (self._is_link(path) and self._link_target(path) != target) or ( self._lexists(path) and not self._is_link(path) ): if self._context.dry_run(): self._log.action(f"Would remove {path}") removed = True else: try: if os.path.islink(fullpath): os.unlink(fullpath) removed = True elif force: if os.path.isdir(fullpath): shutil.rmtree(fullpath) removed = True else: os.remove(fullpath) removed = True except OSError as e: self._log.warning(f"Failed to remove {path}") self._log.debug(f"OSError: {e!s}") success = False else: if removed: self._log.action(f"Removing {path}") return removed, success def _relative_path(self, target: str, link_name: str) -> str: """ Returns the relative path to get to the target file from the link location. """ link_dir = os.path.dirname(link_name) return os.path.relpath(target, link_dir) def _link( self, target: str, link_name: str, *, relative: bool, canonical_path: bool, ignore_missing: bool, link_type: str, assume_gone: bool, ) -> bool: """ Links link_name to target. The caller must ensure that the target exists. Returns true if successfully linked files. """ link_path = os.path.abspath(os.path.expanduser(link_name)) base_directory = self._context.base_directory(canonical_path=canonical_path) absolute_target = os.path.join(base_directory, target) link_name = os.path.normpath(link_name) target_path = self._relative_path(absolute_target, link_path) if relative else absolute_target # we need to use absolute_target below because our cwd is the dotfiles # directory, and if target_path is relative, it will be relative to the # link directory if ((not self._lexists(link_name)) or (self._context.dry_run() and assume_gone)) and ( ignore_missing or self._exists(absolute_target) ): if self._context.dry_run(): self._log.action(f"Would create {link_type} {link_name} -> {target_path}") return True try: if link_type == "symlink": os.symlink(target_path, link_path) else: # link_type == "hardlink" os.link(absolute_target, link_path) except OSError as e: self._log.warning(f"Linking failed {link_name} -> {target_path}") self._log.debug(f"OSError: {e!s}") return False else: self._log.action(f"Creating {link_type} {link_name} -> {target_path}") return True # Failure case: The link name exists and is a symlink if self._is_link(link_name): if link_type == "symlink": if self._link_target(link_name) == target_path: # Idempotent case: The configured symlink already exists self._log.info(f"Link exists {link_name} -> {target_path}") return True # The existing symlink isn't pointing at the target. # Distinguish between an incorrect symlink and a broken ("invalid") symlink. terminology = "Incorrect" if self._exists(link_name) else "Invalid" self._log.warning(f"{terminology} link {link_name} -> {self._link_target(link_name)}") return False self._log.warning(f"{link_name} already exists but is a symbolic link, not a hard link") return False # Failure case: The link name exists if link_type == "hardlink" and os.stat(link_path).st_ino == os.stat(absolute_target).st_ino: # Idempotent case: The configured hardlink already exists self._log.info(f"Link exists {link_name} -> {target_path}") return True self._log.warning(f"{link_name} already exists but is a regular file or directory") return False ================================================ FILE: src/dotbot/plugins/shell.py ================================================ from typing import Any, Dict from dotbot.plugin import Plugin from dotbot.util.common import shell_command class Shell(Plugin): """ Run arbitrary shell commands. """ supports_dry_run = True _directive = "shell" _has_shown_override_message = False def can_handle(self, directive: str) -> bool: return directive == self._directive def handle(self, directive: str, data: Any) -> bool: if directive != self._directive: msg = f"Shell cannot handle directive {directive}" raise ValueError(msg) return self._process_commands(data) def _process_commands(self, data: Any) -> bool: success = True defaults = self._context.defaults().get("shell", {}) options = self._get_option_overrides() for item in data: stdin = defaults.get("stdin", False) stdout = defaults.get("stdout", False) stderr = defaults.get("stderr", False) quiet = defaults.get("quiet", False) if isinstance(item, dict): cmd = item["command"] msg = item.get("description", None) stdin = item.get("stdin", stdin) stdout = item.get("stdout", stdout) stderr = item.get("stderr", stderr) quiet = item.get("quiet", quiet) elif isinstance(item, list): cmd = item[0] msg = item[1] if len(item) > 1 else None else: cmd = item msg = None prefix = "Would run command " if self._context.dry_run() else "" if quiet: if msg is not None: self._log.info(f"{prefix}{msg}") # if quiet and no msg, show nothing elif msg is None: self._log.action(f"{prefix}{cmd}") else: self._log.action(f"{prefix}{msg} [{cmd}]") if self._context.dry_run(): continue stdout = options.get("stdout", stdout) stderr = options.get("stderr", stderr) ret = shell_command( cmd, cwd=self._context.base_directory(), enable_stdin=stdin, enable_stdout=stdout, enable_stderr=stderr, ) if ret != 0: success = False self._log.warning(f"Command [{cmd}] failed") if success: self._log.info("All commands have been executed") else: self._log.error("Some commands were not successfully executed") return success def _get_option_overrides(self) -> Dict[str, bool]: ret = {} options = self._context.options() if options.verbose > 1: ret["stderr"] = True ret["stdout"] = True if not self._has_shown_override_message: self._log.debug("Shell: Found cli option to force show stderr and stdout.") self._has_shown_override_message = True return ret ================================================ FILE: src/dotbot/util/__init__.py ================================================ from dotbot.util.common import shell_command __all__ = ["shell_command"] ================================================ FILE: src/dotbot/util/common.py ================================================ import os import platform import subprocess import sys from typing import Optional def shell_command( command: str, cwd: Optional[str] = None, *, enable_stdin: bool = False, enable_stdout: bool = False, enable_stderr: bool = False, ) -> int: with open(os.devnull, "w") as devnull_w, open(os.devnull) as devnull_r: stdin = None if enable_stdin else devnull_r stdout = None if enable_stdout else devnull_w stderr = None if enable_stderr else devnull_w executable = os.environ.get("SHELL") if platform.system() == "Windows": # We avoid setting the executable kwarg on Windows because it does # not have the desired effect when combined with shell=True. It # will result in the correct program being run (e.g. bash), but it # will be invoked with a '/c' argument instead of a '-c' argument, # which it won't understand. # # See https://github.com/anishathalye/dotbot/issues/219 and # https://bugs.python.org/issue40467. # # This means that complex commands that require Bash's parsing # won't work; a workaround for this is to write the command as # `bash -c "..."`. executable = None return subprocess.call( # noqa: S602 command, shell=True, executable=executable, stdin=stdin, stdout=stdout, stderr=stderr, cwd=cwd, ) def normslash(path: str) -> str: if sys.platform == "win32": # this is how normcase in cpython/Lib/ntpath.py does it; we don't use normcase # because we don't want to make all characters lowercase return path.replace("/", "\\") return path ================================================ FILE: src/dotbot/util/module.py ================================================ import glob import importlib.util import os from types import ModuleType from typing import List, Optional, Type from dotbot.plugin import Plugin # We keep references to loaded modules so they don't get garbage collected. loaded_modules = [] def load(path: str) -> List[Type[Plugin]]: basename = os.path.basename(path) module_name, _ = os.path.splitext(basename) 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 def load_module(module_name: str, path: str) -> ModuleType: spec = importlib.util.spec_from_file_location(module_name, path) if not spec or not spec.loader: msg = f"Unable to load module {module_name} from {path}" raise ImportError(msg) module = importlib.util.module_from_spec(spec) spec.loader.exec_module(module) return module def load_plugins(paths: List[str], plugins: Optional[List[Type[Plugin]]] = None) -> List[Type[Plugin]]: """ Load plugins from the given paths and add them to the given list of plugins. Args: paths: List of file paths to load plugins from. Each path can be either a file or a directory. plugins: List of existing plugins to add to. Returns the newly-loaded plugins. """ if plugins is None: plugins = [] new_plugins = [] plugin_paths = [] for path in paths: if os.path.isdir(path): plugin_paths.extend(glob.glob(os.path.join(path, "*.py"))) else: plugin_paths.append(path) for plugin_path in plugin_paths: abspath = os.path.abspath(plugin_path) for plugin in load(abspath): # ensure plugins are unique to avoid duplicate execution, which # can happen if, for example, a third-party plugin loads a # built-in plugin, which will cause it to appear in the list # returned by load() above plugin_already_loaded = any( existing_plugin.__module__ == plugin.__module__ and existing_plugin.__name__ == plugin.__name__ for existing_plugin in plugins ) if not plugin_already_loaded: plugins.append(plugin) new_plugins.append(plugin) return new_plugins ================================================ FILE: src/dotbot/util/singleton.py ================================================ from typing import Any class Singleton(type): def __call__(cls, *args: Any, **kwargs: Any) -> Any: if not hasattr(cls, "_singleton_instance"): cls._singleton_instance = super().__call__(*args, **kwargs) return cls._singleton_instance def reset_instance(cls) -> None: if hasattr(cls, "_singleton_instance"): del cls._singleton_instance ================================================ FILE: src/dotbot/util/string.py ================================================ def indent_lines(string: str, amount: int = 2, delimiter: str = "\n") -> str: whitespace = " " * amount sep = f"{delimiter}{whitespace}" return f"{whitespace}{sep.join(string.split(delimiter))}" ================================================ FILE: tests/__init__.py ================================================ ================================================ FILE: tests/conftest.py ================================================ import builtins import ctypes import json import os import shutil import sys import tempfile from shutil import rmtree from typing import Any, Callable, Generator, List, Optional from unittest import mock import pytest import yaml import dotbot.cli def get_long_path(path: str) -> str: """Get the long path for a given path.""" # Do nothing for non-Windows platforms. if sys.platform != "win32": return path buffer_size = 1000 buffer = ctypes.create_unicode_buffer(buffer_size) get_long_path_name = ctypes.windll.kernel32.GetLongPathNameW get_long_path_name(path, buffer, buffer_size) return buffer.value # On Linux, tempfile.TemporaryFile() requires unlink access. # This list is updated by a tempfile._mkstemp_inner() wrapper, # and its contents are checked by wrapped functions. allowed_tempfile_internal_unlink_calls: List[str] = [] def get_path_from_fd(fd: int) -> Optional[str]: """Get the filesystem path for a file descriptor. Returns None if the path cannot be determined (e.g., on Windows or if fd is invalid). """ try: if sys.platform == "linux": return os.readlink(f"/proc/self/fd/{fd}") if sys.platform == "darwin": import fcntl # noqa: PLC0415 f_getpath = getattr(fcntl, "F_GETPATH", 50) # Python 3.9+ exposes fcntl.F_GETPATH path_buf = b"\0" * 1024 result = fcntl.fcntl(fd, f_getpath, path_buf) return result.rstrip(b"\0").decode("utf-8") except (OSError, ValueError): return None else: # Windows doesn't have an easy way to get the path from an fd return None def wrap_function( function: Callable[..., Any], function_path: str, arg_index: int, kwarg_key: str, root: str ) -> Callable[..., Any]: is_unlink = function == os.unlink def wrapper(*args: Any, **kwargs: Any) -> Any: value = kwargs[kwarg_key] if kwarg_key in kwargs else args[arg_index] # Allow tempfile.TemporaryFile's internal unlink calls to work. if value in allowed_tempfile_internal_unlink_calls: return function(*args, **kwargs) # For unlink(), allow relative paths when dir_fd is provided (used by shutil.rmtree) if is_unlink and "dir_fd" in kwargs and kwargs["dir_fd"] is not None: dir_fd = kwargs["dir_fd"] dir_path = get_path_from_fd(dir_fd) if sys.platform in {"linux", "darwin"}: msg = f"Failed to resolve dir_fd to path for {function_path}()" assert dir_path is not None, msg msg = f"The dir_fd argument to {function_path}() must point to a directory rooted in {root}" assert dir_path[: len(str(root))] == str(root), msg # On Windows, we can't easily validate dir_fd, but it's reasonably safe because the dir_fd typically comes # from opening a path we already validated (e.g., from shutil.rmtree), and this is test infrastructure with # trusted (non-adversarial) code. return function(*args, **kwargs) msg = f"The '{kwarg_key}' argument to {function_path}() must be an absolute path" assert value == os.path.abspath(value), msg msg = f"The '{kwarg_key}' argument to {function_path}() must be rooted in {root}" assert value[: len(str(root))] == str(root), msg return function(*args, **kwargs) return wrapper def wrap_open(root: str) -> Callable[..., Any]: wrapped = builtins.open def wrapper(*args: Any, **kwargs: Any) -> Any: value = kwargs["file"] if "file" in kwargs else args[0] mode = "r" if "mode" in kwargs: mode = kwargs["mode"] elif len(args) >= 2: mode = args[1] msg = "The 'file' argument to open() must be an absolute path" if value != os.devnull and "w" in mode: assert value == os.path.abspath(value), msg msg = f"The 'file' argument to open() must be rooted in {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(_function: Any, path: str, _excinfo: Any) -> None: # 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() -> None: 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 == "win32": tmp = get_long_path(tmp) os.environ["TMP"] = tmp os.environ["TEMP"] = tmp os.environ["TMPDIR"] = tmp tempfile.tempdir = tmp @pytest.fixture(autouse=True) def root(standardize_tmp: None) -> Generator[str, None, None]: _ = standardize_tmp """Create a temporary directory for the duration of each test.""" # Reset allowed_tempfile_internal_unlink_calls. global allowed_tempfile_internal_unlink_calls # noqa: PLW0603 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, "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: List[Any] = [] 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 = f"{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. function_path = "builtins.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", return_value=None)) # Patch tempfile._mkstemp_inner() so tempfile.TemporaryFile() # can unlink files immediately. mkstemp_inner = tempfile._mkstemp_inner # type: ignore # noqa: SLF001 def wrap_mkstemp_inner(*args: Any, **kwargs: Any) -> Any: (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: # Patches must be stopped in reverse order because some patches are nested. # Stopping in the reverse order restores the original function. for patch in reversed(patches): patch.stop() os.chdir(current_working_directory) if sys.version_info >= (3, 12): rmtree(current_root, onexc=rmtree_error_handler) else: rmtree(current_root, onerror=rmtree_error_handler) @pytest.fixture def home(monkeypatch: pytest.MonkeyPatch, root: str) -> str: """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 == "win32": monkeypatch.setenv("USERPROFILE", home) else: monkeypatch.setenv("HOME", home) return home class Dotfiles: """Create and manage a dotfiles directory for a test.""" def __init__(self, root: str): self.root = root self.config = None self._config_filename: Optional[str] = None self.directory = os.path.join(root, "dotfiles") os.mkdir(self.directory) def makedirs(self, path: str) -> None: os.makedirs(os.path.abspath(os.path.join(self.directory, path))) def write(self, path: str, content: str = "") -> None: 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: Any, serializer: str = "yaml", path: Optional[str] = None) -> str: """Write a dotbot config and return the filename.""" assert serializer in {"json", "yaml"}, "Only json and yaml are supported" if serializer == "yaml": serialize: Callable[[Any], str] = yaml.dump else: # serializer == "json" serialize = json.dumps if path is not None: msg = "The config file path must be an absolute path" assert path == os.path.abspath(path), msg msg = f"The config file path must be rooted in {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 @property def config_filename(self) -> str: assert self._config_filename is not None return self._config_filename @pytest.fixture def dotfiles(root: str) -> Dotfiles: """Create a dotfiles directory.""" return Dotfiles(root) @pytest.fixture def run_dotbot(dotfiles: Dotfiles) -> Callable[..., None]: """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: Any, **kwargs: Any) -> None: argv = ("dotbot", *argv) if kwargs.get("custom", False) is not True: argv = (*argv, "-c", dotfiles.config_filename) with mock.patch("sys.argv", list(argv)): dotbot.cli.main() return runner ================================================ FILE: tests/dotbot_plugin_context_plugin.py ================================================ # https://github.com/anishathalye/dotbot/issues/339 # plugins should be able to instantiate a Dispatcher with all the plugins from typing import Any import dotbot from dotbot.dispatcher import Dispatcher class Dispatch(dotbot.Plugin): def can_handle(self, directive: str) -> bool: return directive == "dispatch" def handle(self, directive: str, data: Any) -> bool: if directive != "dispatch": msg = f"Dispatch cannot handle directive {directive}" raise ValueError(msg) dispatcher = Dispatcher( base_directory=self._context.base_directory(), options=self._context.options(), plugins=self._context.plugins(), ) return dispatcher.dispatch(data) ================================================ FILE: tests/dotbot_plugin_counter.py ================================================ """Test plugin that counts how many times it's executed. This file is used to test that duplicate plugin references don't cause the plugin to be loaded/executed multiple times. """ import os.path from typing import Any import dotbot class Counter(dotbot.Plugin): def can_handle(self, directive: str) -> bool: return directive == "counter" def handle(self, directive: str, _data: Any) -> bool: if directive != "counter": msg = f"Counter cannot handle directive {directive}" raise ValueError(msg) counter_file = os.path.abspath(os.path.expanduser("~/counter")) if os.path.exists(counter_file): with open(counter_file) as f: count = int(f.read().strip()) else: count = 0 count += 1 with open(counter_file, "w") as f: f.write(str(count)) return True ================================================ FILE: tests/dotbot_plugin_directory.py ================================================ """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 from typing import Any import dotbot class Directory(dotbot.Plugin): def can_handle(self, directive: str) -> bool: return directive == "plugin_directory" def handle(self, directive: str, data: Any) -> bool: if directive != "plugin_directory": msg = f"Directory cannot handle directive {directive}" raise ValueError(msg) if data != "no-check-context": self._log.debug("Attempting to get options from Context") options = self._context.options() if len(options.plugin_dirs) != 1: self._log.debug(f"Context.options.plugin_dirs length is {len(options.plugin_dirs)}, expected 1") return False with open(os.path.abspath(os.path.expanduser("~/flag-directory")), "w") as file: file.write("directory plugin loading works") return True ================================================ FILE: tests/dotbot_plugin_dispatcher_no_plugins.py ================================================ # https://github.com/anishathalye/dotbot/issues/339, https://github.com/anishathalye/dotbot/pull/332 # if plugins instantiate a Dispatcher without explicitly passing in plugins, # the Dispatcher should have access to all plugins (matching context.plugins()) from typing import Any import dotbot from dotbot.dispatcher import Dispatcher class Dispatch(dotbot.Plugin): def can_handle(self, directive: str) -> bool: return directive == "dispatch" def handle(self, directive: str, data: Any) -> bool: if directive != "dispatch": msg = f"Dispatch cannot handle directive {directive}" raise ValueError(msg) dispatcher = Dispatcher( base_directory=self._context.base_directory(), options=self._context.options(), ) return dispatcher.dispatch(data) ================================================ FILE: tests/dotbot_plugin_dry_run.py ================================================ import os from typing import Any import dotbot class DryRun(dotbot.Plugin): """A plugin that is aware of dry-run mode.""" _directive = "dry_run" supports_dry_run = True def can_handle(self, directive: str) -> bool: return directive == self._directive def handle(self, _directive: str, _data: Any) -> bool: if self._context.dry_run(): self._log.action("Would execute dry run") else: with open(os.path.abspath(os.path.expanduser("~/flag-dry-run")), "w") as file: file.write("Dry run executed") return True ================================================ FILE: tests/dotbot_plugin_file.py ================================================ """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 from typing import Any import dotbot class File(dotbot.Plugin): def can_handle(self, directive: str) -> bool: return directive == "plugin_file" def handle(self, directive: str, data: Any) -> bool: if directive != "plugin_file": msg = f"File cannot handle directive {directive}" raise ValueError(msg) if data != "no-check-context": self._log.debug("Attempting to get options from Context") options = self._context.options() if len(options.plugins) != 1: self._log.debug(f"Context.options.plugins length is {len(options.plugins)}, expected 1") return False if not options.plugins[0].endswith("file.py"): self._log.debug(f"Context.options.plugins[0] is {options.plugins[0]}, expected end with file.py") return False with open(os.path.abspath(os.path.expanduser("~/flag-file")), "w") as file: file.write("file plugin loading works") return True ================================================ FILE: tests/dotbot_plugin_issue_357.py ================================================ from typing import Any from dotbot.plugin import Plugin from dotbot.plugins import Clean, Create, Link, Shell # https://github.com/anishathalye/dotbot/issues/357 # if we import from dotbot.plugins, the built-in plugins get executed multiple times _: Any = Clean _ = Create _ = Link _ = Shell class NoopPlugin(Plugin): _directive = "noop" def can_handle(self, directive: str) -> bool: return directive == self._directive def handle(self, directive: str, _data: Any) -> bool: if directive != self._directive: msg = f"NoopPlugin cannot handle directive {directive}" raise ValueError(msg) return True ================================================ FILE: tests/test_bin_dotbot.py ================================================ import os import shutil import subprocess from typing import Optional import pytest from tests.conftest import Dotfiles @pytest.mark.skipif( "sys.platform == 'win32'", reason="The hybrid sh/Python dotbot script doesn't run on Windows platforms", ) @pytest.mark.parametrize("python_name", [None, "python", "python3"]) def test_find_python_executable(python_name: Optional[str], home: str, dotfiles: Dotfiles) -> None: """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 = shutil.which("sh") assert sh_path is not None os.symlink(sh_path, os.path.join(tmp_bin, "sh")) if python_name is not None: 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 is not None: 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, ) ================================================ FILE: tests/test_clean.py ================================================ import os import sys from typing import Callable import pytest from tests.conftest import Dotfiles def test_clean_default(root: str, home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None: """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: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None: """Verify clean expands environment variables.""" os.symlink(os.path.join(dotfiles.directory, "f"), os.path.join(home, ".f")) variable = "$HOME" if sys.platform == "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: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None: """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: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None: """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: str, home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None: """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(home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None: """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: str, home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None: """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: str, home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None: """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: str, home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None: """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: str, home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None: """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")) def test_clean_dry_run( capfd: pytest.CaptureFixture[str], root: str, home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None] ) -> None: """Verify that the clean plugin does not delete files during a dry run.""" os.symlink(os.path.join(root, "nowhere"), os.path.join(home, ".g")) dotfiles.write_config([{"clean": {"~/": {"force": True}}}]) run_dotbot("-n") assert os.path.islink(os.path.join(home, ".g")) lines = capfd.readouterr().out.splitlines() assert any( f"Would remove invalid link {os.path.join(home, '.g')} -> {os.path.join(root, 'nowhere')}" in line for line in lines ) def test_clean_dry_run_recursive( capfd: pytest.CaptureFixture[str], root: str, home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None] ) -> None: """Verify that the clean plugin does not delete files during a recursive dry run.""" 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("-n") assert 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")) lines = capfd.readouterr().out.splitlines() assert any( f"Would remove invalid link {os.path.join(home, 'c')} -> {os.path.join(root, 'nowhere')}" in line for line in lines ) assert any( f"Would remove invalid link {os.path.join(home, 'a', 'd')} -> {os.path.join(root, 'nowhere')}" in line for line in lines ) assert any( f"Would remove invalid link {os.path.join(home, 'a', 'b', 'e')} -> {os.path.join(root, 'nowhere')}" in line for line in lines ) ================================================ FILE: tests/test_cli.py ================================================ import os import shutil from typing import Callable import pytest from tests.conftest import Dotfiles def test_except_create( capfd: pytest.CaptureFixture[str], home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None] ) -> None: """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: pytest.CaptureFixture[str], home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None] ) -> None: """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: pytest.CaptureFixture[str], home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None] ) -> None: """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(home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None: """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: pytest.CaptureFixture[str], home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None] ) -> None: """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: pytest.CaptureFixture[str], home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None] ) -> None: """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: pytest.CaptureFixture[str], home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None] ) -> None: """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: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None: """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-file")) as file: assert file.read() == "file plugin loading works" def test_plugin_loading_directory(home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None: """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-directory")) as file: assert file.read() == "directory plugin loading works" def test_issue_357( capfd: pytest.CaptureFixture[str], home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None] ) -> None: """Verify that built-in plugins are only executed once, when using a plugin that imports from dotbot.plugins.""" _ = home plugin_file = os.path.join(os.path.dirname(os.path.abspath(__file__)), "dotbot_plugin_issue_357.py") dotfiles.write_config([{"shell": [{"command": "echo apple", "stdout": True}]}]) run_dotbot("--plugin", plugin_file) assert len([line for line in capfd.readouterr().out.splitlines() if line.strip() == "apple"]) == 1 def test_disable_builtin_plugins(home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None: """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")) def test_plugin_context_plugin( capfd: pytest.CaptureFixture[str], home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None] ) -> None: """Verify that the plugin context is available to plugins.""" _ = home plugin_file = os.path.join(os.path.dirname(os.path.abspath(__file__)), "dotbot_plugin_context_plugin.py") shutil.copy(plugin_file, os.path.join(dotfiles.directory, "plugin.py")) dotfiles.write_config([{"dispatch": [{"shell": [{"command": "echo apple", "stdout": True}]}]}]) run_dotbot("--plugin", os.path.join(dotfiles.directory, "plugin.py")) stdout = capfd.readouterr().out.splitlines() assert any(line.startswith("apple") for line in stdout) def test_plugin_dispatcher_no_plugins( capfd: pytest.CaptureFixture[str], home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None] ) -> None: """Verify that plugins instantiating Dispatcher without plugins work.""" _ = home plugin_file = os.path.join(os.path.dirname(os.path.abspath(__file__)), "dotbot_plugin_dispatcher_no_plugins.py") shutil.copy(plugin_file, os.path.join(dotfiles.directory, "plugin.py")) dotfiles.write_config([{"dispatch": [{"shell": [{"command": "echo apple", "stdout": True}]}]}]) run_dotbot("--plugin", os.path.join(dotfiles.directory, "plugin.py")) stdout = capfd.readouterr().out.splitlines() assert any(line.startswith("apple") for line in stdout) def test_dry_run_unaware_plugin( capfd: pytest.CaptureFixture[str], home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None] ) -> None: """Verify that plugins not aware of dry-run mode do not execute actions during a dry run.""" 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"), "--dry-run") assert not os.path.exists(os.path.join(home, "flag-file")) stdout = capfd.readouterr().out.splitlines() assert any(line.strip() == "Skipping dry-run-unaware plugin File" for line in stdout) def test_dry_run_aware_plugin( capfd: pytest.CaptureFixture[str], home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None] ) -> None: """Verify that plugins that are aware of dry-run mode do execute during a dry run.""" plugin_file = os.path.join(os.path.dirname(os.path.abspath(__file__)), "dotbot_plugin_dry_run.py") shutil.copy(plugin_file, os.path.join(dotfiles.directory, "dry_run.py")) dotfiles.write_config([{"dry_run": "~"}]) run_dotbot("--plugin", os.path.join(dotfiles.directory, "dry_run.py"), "--dry-run") assert not os.path.exists(os.path.join(home, "flag-dry-run")) stdout = capfd.readouterr().out.splitlines() assert any(line.startswith("Would execute dry run") for line in stdout) def test_dry_run_aware_plugin_no_dry_run(home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None: """Verify that plugins that are aware of dry-run mode do execute without dry run.""" plugin_file = os.path.join(os.path.dirname(os.path.abspath(__file__)), "dotbot_plugin_dry_run.py") shutil.copy(plugin_file, os.path.join(dotfiles.directory, "dry_run.py")) dotfiles.write_config([{"dry_run": "~"}]) run_dotbot("--plugin", os.path.join(dotfiles.directory, "dry_run.py")) with open(os.path.join(home, "flag-dry-run")) as file: assert file.read() == "Dry run executed" ================================================ FILE: tests/test_config.py ================================================ import json import os from typing import Callable from tests.conftest import Dotfiles def test_config_blank(dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None: """Verify blank configs work.""" dotfiles.write_config([]) run_dotbot() def test_config_empty(dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None: """Verify empty configs work.""" dotfiles.write("config.yaml", "") run_dotbot("-c", os.path.join(dotfiles.directory, "config.yaml"), custom=True) def test_json(home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None: """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: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None: """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")) def test_multiple_config(home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None: """Verify that passing multiple configs works.""" dotfiles.write("config1.json", json.dumps([{"create": ["~/d1"]}])) dotfiles.write("config2.json", json.dumps([{"create": ["~/d2"]}])) run_dotbot( "-c", os.path.join(dotfiles.directory, "config1.json"), os.path.join(dotfiles.directory, "config2.json"), custom=True, ) assert os.path.isdir(os.path.join(home, "d1")) assert os.path.isdir(os.path.join(home, "d2")) ================================================ FILE: tests/test_create.py ================================================ import os import stat from typing import Callable import pytest from tests.conftest import Dotfiles @pytest.mark.parametrize("directory", ["~/a", "~/b/c"]) def test_directory_creation(home: str, directory: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None: """Test creating directories, including nested directories.""" _ = home 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: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None: """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. """ _ = home 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: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None: """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. """ _ = home 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 def test_create_dry_run( capfd: pytest.CaptureFixture[str], home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None] ) -> None: """Verify that the create plugin does not create directories during a dry run.""" os.makedirs(os.path.join(home, "existing")) dotfiles.write_config([{"create": ["~/a", "~/existing"]}]) run_dotbot("-n", "-v") directory = os.path.abspath(os.path.expanduser("~/a")) assert not os.path.exists(directory) lines = capfd.readouterr().out.splitlines() assert any(line.strip() == f"Would create path {os.path.join(home, 'a')}" for line in lines) assert any(f"Path exists {os.path.join(home, 'existing')}" == line.strip() for line in lines) ================================================ FILE: tests/test_link.py ================================================ import os import pathlib import stat import sys from datetime import datetime, timedelta, timezone from typing import Any, Callable, Dict, List, Optional from unittest.mock import patch import pytest from dotbot.plugins.link import Link from tests.conftest import Dotfiles def test_link_canonicalization(home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None: """Verify links to symlinked targets 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 == "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_target( dst: str, include_force: bool, # noqa: FBT001 home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None], ) -> None: """Verify that default targets are calculated correctly. This test includes verifying files with and without leading periods, as well as verifying handling of None dict values. """ _ = home 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))) as file: assert file.read() == "apple" def test_link_environment_user_expansion_link_name( home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None] ) -> None: """Verify link expands user in link name.""" _ = home target = "~/f" link_name = "~/g" with open(os.path.abspath(os.path.expanduser(target)), "w") as file: file.write("apple") dotfiles.write_config([{"link": {link_name: target}}]) run_dotbot() with open(os.path.abspath(os.path.expanduser(link_name))) as file: assert file.read() == "apple" def test_link_environment_variable_expansion_target( monkeypatch: pytest.MonkeyPatch, home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None] ) -> None: """Verify link expands environment variables in target.""" _ = home monkeypatch.setenv("APPLE", "h") link_name = "~/.i" target = "$APPLE" dotfiles.write("h", "grape") dotfiles.write_config([{"link": {link_name: target}}]) run_dotbot() with open(os.path.abspath(os.path.expanduser(link_name))) as file: assert file.read() == "grape" def test_link_environment_variable_expansion_target_extended( monkeypatch: pytest.MonkeyPatch, home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None] ) -> None: """Verify link expands environment variables in extended config syntax.""" _ = home monkeypatch.setenv("APPLE", "h") link_name = "~/.i" target = "$APPLE" dotfiles.write("h", "grape") dotfiles.write_config([{"link": {link_name: {"path": target, "relink": True}}}]) run_dotbot() with open(os.path.abspath(os.path.expanduser(link_name))) as file: assert file.read() == "grape" def test_link_environment_variable_expansion_link_name( monkeypatch: pytest.MonkeyPatch, home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None] ) -> None: """Verify link expands environment variables in link name. 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")) as file: assert file.read() == "apple" with open(os.path.join(home, "$PEAR")) as file: assert file.read() == "grape" def test_link_environment_variable_unset( monkeypatch: pytest.MonkeyPatch, home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None] ) -> None: """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")) as file: assert file.read() == "apple" def test_link_force_leaves_when_nonexistent(home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None: """Verify force doesn't erase existing files when targets are nonexistent.""" os.mkdir(os.path.join(home, "dir")) open(os.path.join(home, "file"), "w").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: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None: """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_force_directory(home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None: """Verify force deletes directories.""" os.mkdir(os.path.join(home, ".dir")) with open(os.path.join(home, ".dir", "f"), "w") as f: f.write("apple") dotfiles.write("dir/f", "banana") config = [{"link": {"~/.dir": {"path": "dir", "force": True}}}] dotfiles.write_config(config) run_dotbot() with open(os.path.join(home, ".dir", "f")) as file: assert file.read() == "banana" def test_link_backup_directory(home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None: """Verify that a backup directory is created if destination directory exists.""" os.mkdir(os.path.join(home, ".dir")) with open(os.path.join(home, ".dir", "f"), "w") as f: f.write("apple") dotfiles.write("dir/f", "banana") config = [{"link": {"~/.dir": {"path": "dir", "backup": True}}}] dotfiles.write_config(config) run_dotbot() backup_dirs = [d for d in os.listdir(home) if d.startswith(".dir.dotbot-backup")] assert len(backup_dirs) == 1 with open(os.path.join(home, backup_dirs[0], "f")) as file: assert file.read() == "apple" with open(os.path.join(home, ".dir", "f")) as file: assert file.read() == "banana" def test_link_backup_file(home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None: """Verify that a backup file is created if destination file exists.""" with open(os.path.join(home, ".file"), "w") as f: f.write("apple") dotfiles.write("file", "banana") config = [{"link": {"~/.file": {"path": "file", "backup": True}}}] dotfiles.write_config(config) run_dotbot() backup_files = [f for f in os.listdir(home) if f.startswith(".file.dotbot-backup")] assert len(backup_files) == 1 with open(os.path.join(home, backup_files[0])) as file: assert file.read() == "apple" with open(os.path.join(home, ".file")) as file: assert file.read() == "banana" def test_link_backup_not_created_if_link(home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None: """Verify that a backup file isn't created if destination is a symlink.""" open(os.path.join(home, "file"), "w").close() dotfiles.write("file") os.symlink(os.path.join(home, "file"), os.path.join(home, ".file")) config = [{"link": {"~/.file": {"path": "file", "backup": True}}}] dotfiles.write_config(config) with pytest.raises(SystemExit): run_dotbot() assert not os.path.exists(os.path.join(home, ".file.dotbot-backup")) def test_link_backup_created_if_force(home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None: """Verify that backups are created when the force option is used.""" with open(os.path.join(home, ".file"), "w") as f: f.write("apple") dotfiles.write("file", "banana") config = [{"link": {"~/.file": {"path": "file", "backup": True, "force": True}}}] dotfiles.write_config(config) run_dotbot() backup_files = [f for f in os.listdir(home) if f.startswith(".file.dotbot-backup")] assert len(backup_files) == 1 with open(os.path.join(home, backup_files[0])) as file: assert file.read() == "apple" with open(os.path.join(home, ".file")) as file: assert file.read() == "banana" def test_link_backup_error_if_dest_already_exists( home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None] ) -> None: """Verify an error is thrown if the backup already exists.""" os.mkdir(os.path.join(home, ".dir")) # create fake backup directories; this test is technically timing dependent but it should work out in practice now = datetime.now(timezone.utc).astimezone() for delta in range(10): timestamp = (now + timedelta(seconds=delta)).strftime("%Y%m%d-%H%M%S") os.mkdir(os.path.join(home, f".dir.dotbot-backup.{timestamp}")) with open(os.path.join(home, f".dir.dotbot-backup.{timestamp}", "f"), "w") as file: file.write("apple") dotfiles.write("dir") config = [{"link": {"~/.dir": {"path": "dir", "backup": True}}}] dotfiles.write_config(config) with pytest.raises(SystemExit): run_dotbot() for delta in range(10): timestamp = (now + timedelta(seconds=delta)).strftime("%Y%m%d-%H%M%S") with open(os.path.join(home, f".dir.dotbot-backup.{timestamp}", "f")) as file: assert file.read() == "apple" def test_link_backup_glob(home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None: """Verify that backup works with globbing.""" dotfiles.write("bin/a", "apple") dotfiles.write("bin/b", "banana") dotfiles.write("bin/c", "cherry") os.mkdir(os.path.join(home, "bin")) with open(os.path.join(home, "bin", "a"), "w") as f: f.write("apricot") with open(os.path.join(home, "bin", "b"), "w") as f: f.write("blueberry") with open(os.path.join(home, "bin", "c"), "w") as f: f.write("cranberry") config = [ { "defaults": {"link": {"glob": True, "create": True, "backup": True}}, }, { "link": {"~/bin": "bin/*"}, }, ] dotfiles.write_config(config) run_dotbot() backup_a = [f for f in os.listdir(os.path.join(home, "bin")) if f.startswith("a.dotbot-backup")] backup_b = [f for f in os.listdir(os.path.join(home, "bin")) if f.startswith("b.dotbot-backup")] backup_c = [f for f in os.listdir(os.path.join(home, "bin")) if f.startswith("c.dotbot-backup")] assert len(backup_a) == 1 assert len(backup_b) == 1 assert len(backup_c) == 1 with open(os.path.join(home, "bin", backup_a[0])) as file: assert file.read() == "apricot" with open(os.path.join(home, "bin", backup_b[0])) as file: assert file.read() == "blueberry" with open(os.path.join(home, "bin", backup_c[0])) as file: assert file.read() == "cranberry" 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_backup_dry_run(home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None: """Verify that a backup file is not created if running in dry-run mode.""" with open(os.path.join(home, ".file"), "w") as f: f.write("apple") dotfiles.write("file", "banana") config = [{"link": {"~/.file": {"path": "file", "backup": True}}}] dotfiles.write_config(config) run_dotbot("-n") backup_files = [f for f in os.listdir(home) if f.startswith(".file.dotbot-backup")] assert len(backup_files) == 0 @pytest.mark.parametrize("option", ["relink", "force"]) def test_link_backup_relink_force_with_existing_incorrect_symlink( option: str, home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None], ) -> None: """Verify that backup + relink/force replaces an incorrect symlink. When an incorrect symlink exists at the destination, backup should not prevent the symlink from being deleted and recreated correctly. """ dotfiles.write("f", "apple") # Create an incorrect symlink at the destination. wrong_target = os.path.join(home, "wrong_target") open(wrong_target, "w").close() os.symlink(wrong_target, os.path.join(home, ".f")) config = [{"link": {"~/.f": {"path": "f", "backup": True, option: True}}}] dotfiles.write_config(config) run_dotbot() # The symlink should now point to the correct target. with open(os.path.join(home, ".f")) as file: assert file.read() == "apple" # Symlinks are not backed up (backup only applies to real files). backup_files = [f for f in os.listdir(home) if f.startswith(".f.dotbot-backup")] assert len(backup_files) == 0 def test_link_backup_relink_real_file_skips_delete( home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None], ) -> None: """Verify that _delete is not called when backup already moved a real file. When backup successfully renames a real file and relink is set, there's no need to call _delete: the file is already gone. """ dotfiles.write("f", "apple") with open(os.path.join(home, ".f"), "w") as f: f.write("banana") config = [{"link": {"~/.f": {"path": "f", "backup": True, "relink": True}}}] dotfiles.write_config(config) original_delete = Link._delete # noqa: SLF001 delete_was_called = False def tracking_delete(self: Any, *args: Any, **kwargs: Any) -> Any: nonlocal delete_was_called delete_was_called = True return original_delete(self, *args, **kwargs) with patch.object(Link, "_delete", tracking_delete): run_dotbot() assert not delete_was_called with open(os.path.join(home, ".f")) as file: assert file.read() == "apple" backup_files = [f for f in os.listdir(home) if f.startswith(".f.dotbot-backup")] assert len(backup_files) == 1 with open(os.path.join(home, backup_files[0])) as file: assert file.read() == "banana" def test_link_backup_relink_with_existing_incorrect_symlink_glob( home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None], ) -> None: """Verify that backup + relink replaces incorrect symlinks with globbing.""" dotfiles.write("bin/a", "apple") dotfiles.write("bin/b", "banana") os.makedirs(os.path.join(home, "bin")) # Create incorrect symlinks at the destinations. wrong_target = os.path.join(home, "wrong_target") open(wrong_target, "w").close() os.symlink(wrong_target, os.path.join(home, "bin", "a")) os.symlink(wrong_target, os.path.join(home, "bin", "b")) config = [ {"defaults": {"link": {"glob": True, "create": True, "backup": True, "relink": True}}}, {"link": {"~/bin": "bin/*"}}, ] dotfiles.write_config(config) 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" def test_link_glob_1(home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None: """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: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None: """Verify globbing works with a trailing slash in the target.""" 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: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None: """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: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None: """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" def test_link_glob_force(home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None: """Verify that glob/force work together.""" os.mkdir(os.path.join(home, "bin")) with open(os.path.join(home, "bin", "a"), "w") as f: f.write("apricot") with open(os.path.join(home, "bin", "b"), "w") as f: f.write("blueberry") with open(os.path.join(home, "bin", "c"), "w") as f: f.write("cranberry") dotfiles.write("bin/a", "apple") dotfiles.write("bin/b", "banana") dotfiles.write("bin/c", "cherry") dotfiles.write_config( [ {"defaults": {"link": {"glob": True, "create": True, "force": 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" @pytest.mark.parametrize("path", ["foo", "foo/"]) def test_link_glob_ignore_no_glob_chars( path: str, home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None] ) -> None: """Verify ambiguous link globbing fails.""" dotfiles.makedirs("foo") dotfiles.write_config( [ { "link": { "~/foo/": { "path": path, "glob": True, } } } ] ) run_dotbot() assert os.path.islink(os.path.join(home, "foo")) assert os.path.exists(os.path.join(home, "foo")) def test_link_glob_exclude_1(home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None: """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: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None: """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: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None: """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: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None: """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: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None: """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: str, expect_file: Callable[[str], Optional[str]], home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None], ) -> None: """Verify link glob pattern matching.""" fruits = ["apple", "apricot", "banana", "cherry", "currant", "cantalope"] for fruit in fruits: dotfiles.write("conf/" + fruit, fruit) dotfiles.write("conf/." + fruit, "dot-" + fruit) dotfiles.write_config( [ {"defaults": {"link": {"glob": True, "create": True}}}, {"link": {"~/globtest": pattern}}, ] ) run_dotbot() for fruit in fruits: expected = expect_file(fruit) if expected 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 expected: assert not os.path.islink(os.path.join(home, "globtest", fruit)) assert os.path.islink(os.path.join(home, "globtest", "." + fruit)) else: # "." not in expected assert os.path.islink(os.path.join(home, "globtest", fruit)) assert not os.path.islink(os.path.join(home, "globtest", "." + fruit)) def test_link_glob_recursive(home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None: """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" def test_link_glob_no_match(home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None: """Verify that a glob with no match doesn't raise an error.""" _ = home dotfiles.makedirs("foo") dotfiles.write_config( [ {"defaults": {"link": {"glob": True, "create": True}}}, {"link": {"~/.config/foo": "foo/*"}}, ] ) run_dotbot() def test_link_glob_single_match(home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None: """Verify linking works even when glob matches exactly one file.""" # regression test for https://github.com/anishathalye/dotbot/issues/282 dotfiles.write("foo/a", "apple") dotfiles.write_config( [ {"defaults": {"link": {"glob": True, "create": True}}}, {"link": {"~/.config/foo": "foo/*"}}, ] ) run_dotbot() assert not os.path.islink(os.path.join(home, ".config")) assert not os.path.islink(os.path.join(home, ".config", "foo")) 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" @pytest.mark.skipif( "sys.platform == 'win32'", reason="These if commands won't run on Windows", ) def test_link_if(home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None: """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 == 'win32'", reason="These if commands won't run on Windows", ) def test_link_if_defaults(home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None: """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 != 'win32'", reason="These if commands only run on Windows", ) def test_link_if_windows(home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None: """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 != 'win32'", reason="These if commands only run on Windows.", ) def test_link_if_defaults_windows(home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None: """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: bool, # noqa: FBT001 home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None], ) -> None: """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: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None: """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")) as file: assert file.read() == "grape" @pytest.mark.parametrize("key", ["canonicalize-path", "canonicalize"]) def test_link_no_canonicalize(key: str, home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None: """Verify link canonicalization can be disabled.""" dotfiles.write("f", "apple") dotfiles.write_config([{"defaults": {"link": {key: False}}}, {"link": {"~/.f": {"path": "f"}}}]) os.symlink( dotfiles.directory, os.path.join(home, "dotfiles-symlink"), target_is_directory=True, ) 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: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None: """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: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None: """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 == "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 == "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 == "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 == "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: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None: """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")) as file: assert file.read() == "grape" def test_link_relink_overwrite_symlink(home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None: """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")) as file: assert file.read() == "apple" def test_link_relink_relative_leaves_file(home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None: """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_target_is_not_overwritten_by_symlink_trickery( capsys: pytest.CaptureFixture[str], home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None] ) -> None: dotfiles_path = pathlib.Path(dotfiles.directory) home_path = pathlib.Path(home) # Setup: # * A symlink exists from `~/.ssh` to `ssh` in the dotfiles directory. # * Dotbot is configured to force-recreate a symlink between two paths # when, in reality, it's actually the same file when resolved. ssh_config = (dotfiles_path / "ssh/config").absolute() os.mkdir(str(ssh_config.parent)) ssh_config.write_text("preserve me!") os.symlink(str(ssh_config.parent), str(home_path / ".ssh")) dotfiles.write_config( [ { "defaults": { "link": { "relink": True, "create": True, "force": True, }, } }, { "link": { # When symlinks are resolved, these are actually the same file. "~/.ssh/config": "ssh/config", }, }, ] ) # Execute dotbot. with pytest.raises(SystemExit): run_dotbot() stdout, _ = capsys.readouterr() assert "appears to be the same file" in stdout # Verify that the file was not overwritten. assert ssh_config.read_text() == "preserve me!" def test_link_defaults_1(home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None: """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")) as file: assert file.read() == "grape" def test_link_defaults_2(home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None: """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")) as file: assert file.read() == "apple" @pytest.mark.parametrize( "config", [ pytest.param([{"link": {"~/.f": "f"}}], id="unspecified"), pytest.param( [{"link": {"~/.f": {"path": "f", "type": "symlink"}}}], id="specified", ), pytest.param( [ {"defaults": {"link": {"type": "symlink"}}}, {"link": {"~/.f": "f"}}, ], id="symlink set for all links by default", ), ], ) def test_link_type_symlink( config: List[Dict[str, Any]], home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None] ) -> None: """Verify that symlinks are created by default, and when specified.""" dotfiles.write("f", "apple") dotfiles.write_config(config) run_dotbot() assert os.path.islink(os.path.join(home, ".f")) @pytest.mark.parametrize( "config", [ pytest.param( [{"link": {"~/.f": {"path": "f", "type": "hardlink"}}}], id="specified", ), pytest.param( [ {"defaults": {"link": {"type": "hardlink"}}}, {"link": {"~/.f": "f"}}, ], id="hardlink set for all links by default", ), ], ) def test_link_type_hardlink( config: List[Dict[str, Any]], home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None] ) -> None: """Verify that hardlinks are created when specified.""" dotfiles.write("f", "apple") assert os.stat(os.path.join(dotfiles.directory, "f")).st_nlink == 1 dotfiles.write_config(config) run_dotbot() assert not os.path.islink(os.path.join(home, ".f")) assert os.stat(os.path.join(dotfiles.directory, "f")).st_nlink == 2 assert os.stat(os.path.join(home, ".f")).st_nlink == 2 @pytest.mark.parametrize( "config", [ pytest.param( [{"defaults": {"link": {"type": "default-bogus"}}, "link": {}}], id="default link type not recognized", ), pytest.param( [{"link": {"~/.f": {"type": "specified-bogus"}}}], id="specified link type not recognized", ), ], ) def test_unknown_link_type( capsys: pytest.CaptureFixture[str], config: List[Dict[str, Any]], dotfiles: Dotfiles, run_dotbot: Callable[..., None], ) -> None: """Verify that unknown link types are rejected.""" dotfiles.write_config(config) with pytest.raises(SystemExit): run_dotbot() stdout, _ = capsys.readouterr() assert "link type is not recognized" in stdout def test_symlink_exists_when_hardlink_requested( capsys: pytest.CaptureFixture[str], home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None], ) -> None: """Confirm messaging when a symlink exists but a hardlink is requested.""" # Setup: Create a symlink to the hardlink target. dotfiles.write("target", "potato") os.symlink( os.path.join(dotfiles.directory, "target"), os.path.join(home, "hardlink"), ) # Act dotfiles.write_config([{"link": {"~/hardlink": {"path": "target", "type": "hardlink"}}}]) with pytest.raises(SystemExit): run_dotbot() # Verify stdout, _ = capsys.readouterr() assert "already exists but is a symbolic link, not a hard link" in stdout def test_hardlink_already_exists( capsys: pytest.CaptureFixture[str], home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None], ) -> None: """Confirm messaging when the hardlink already exists.""" dotfiles.write("target", "potato") os.link( os.path.join(dotfiles.directory, "target"), os.path.join(home, "hardlink"), ) # Act dotfiles.write_config([{"link": {"~/hardlink": {"path": "target", "type": "hardlink"}}}]) run_dotbot("-v") # Verify stdout, _ = capsys.readouterr() assert "Link exists" in stdout def test_broken_symlink_shows_invalid_link_message( capsys: pytest.CaptureFixture[str], home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None], ) -> None: """Verify that broken symlinks show 'Invalid link' message instead of 'Linking failed'.""" broken_target = os.path.join(home, "nonexistent") link_name = os.path.join(home, ".f") os.symlink(broken_target, link_name) dotfiles.write("f", "apple") dotfiles.write_config([{"link": {"~/.f": "f"}}]) with pytest.raises(SystemExit): run_dotbot() stdout, _ = capsys.readouterr() assert "Invalid link" in stdout assert "Linking failed" not in stdout def test_link_dry_run( capfd: pytest.CaptureFixture[str], home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None] ) -> None: """Verify that the link plugin does not create links during a dry run.""" dotfiles.write("f", "apple") dotfiles.write("g", "pear") dotfiles.write("h", "banana") os.symlink(os.path.join(dotfiles.directory, "g"), os.path.join(home, ".g")) dotfiles.write_config( [{"link": {"~/.f": "f", "~/.g": {"path": None, "relink": True}, "~/.h": {"path": "h", "type": "hardlink"}}}] ) run_dotbot("-n", "-v") assert not os.path.exists(os.path.join(home, ".f")) lines = capfd.readouterr().out.splitlines() assert any( f"Link exists {os.path.join('~', '.g')} -> {os.path.join(dotfiles.directory, 'g')}" == line.strip() for line in lines ) assert any( f"Would create symlink {os.path.join('~', '.f')} -> {os.path.join(dotfiles.directory, 'f')}" == line.strip() for line in lines ) assert any( f"Would create hardlink {os.path.join('~', '.h')} -> {os.path.join(dotfiles.directory, 'h')}" == line.strip() for line in lines ) def test_link_dry_run_if( capfd: pytest.CaptureFixture[str], home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None] ) -> None: """Verify that the link plugin does run condition checks during a dry run.""" dotfiles.write("f", "apple") dotfiles.write("g", "pear") dotfiles.write_config( [ { "link": { "~/.f": { "path": "f", "if": "touch " + os.path.join(home, "side_effect"), }, "~/.g": { "path": "g", "if": "false", }, } } ] ) run_dotbot("-n") assert os.path.exists(os.path.join(home, "side_effect")) assert not os.path.exists(os.path.join(home, ".f")) lines = capfd.readouterr().out.splitlines() assert any( f"Would create symlink {os.path.join('~', '.f')} -> {os.path.join(dotfiles.directory, 'f')}" == line.strip() for line in lines ) assert not any("Would create symlink {os.path.join('~', '.g')}" in line for line in lines) def test_link_dry_run_create( capfd: pytest.CaptureFixture[str], home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None] ) -> None: """Verify that the link plugin does not create parent directories during a dry run.""" dotfiles.write("f", "apple") dotfiles.write_config( [ { "link": { "~/.config/.f": { "path": "f", "create": True, } } } ] ) run_dotbot("-n") assert not os.path.exists(os.path.join(home, ".config")) assert not os.path.exists(os.path.join(home, ".config", ".f")) lines = capfd.readouterr().out.splitlines() assert any(line.strip() == f"Would create directory {os.path.join(home, '.config')}" for line in lines) assert any( f"Would create symlink {os.path.join('~', '.config', '.f')} -> {os.path.join(dotfiles.directory, 'f')}" == line.strip() for line in lines ) def test_link_dry_run_relink( capfd: pytest.CaptureFixture[str], home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None] ) -> None: """Verify that the link plugin does not relink existing links during a dry run.""" dotfiles.write("f", "apple") dotfiles.write("g", "pear") dotfiles.write_config( [ { "link": { "~/.f": { "path": "f", "relink": True, } } } ] ) os.symlink(os.path.join(dotfiles.directory, "g"), os.path.join(home, ".f")) run_dotbot("-n") with open(os.path.join(home, ".f")) as file: assert file.read() == "pear" lines = capfd.readouterr().out.splitlines() assert any(line.strip() == f"Would remove {os.path.join('~', '.f')}" for line in lines) assert any( f"Would create symlink {os.path.join('~', '.f')} -> {os.path.join(dotfiles.directory, 'f')}" == line.strip() for line in lines ) def test_link_dry_run_overwrite( capfd: pytest.CaptureFixture[str], home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None] ) -> None: """Verify that the link plugin does not delete existing files during a dry run.""" dotfiles.write("f", "apple") dotfiles.write_config( [ { "link": { "~/.f": { "path": "f", "force": True, } } } ] ) with open(os.path.join(home, ".f"), "w") as file: file.write("pear") run_dotbot("-n") with open(os.path.join(home, ".f")) as file: assert file.read() == "pear" lines = capfd.readouterr().out.splitlines() assert any(line.strip() == f"Would remove {os.path.join('~', '.f')}" for line in lines) assert any( f"Would create symlink {os.path.join('~', '.f')} -> {os.path.join(dotfiles.directory, 'f')}" == line.strip() for line in lines ) @pytest.mark.skipif( "sys.platform == 'win32'", reason="Permissions work differently on Windows", ) @pytest.mark.skipif( "hasattr(os, 'getuid') and os.getuid() == 0", reason="Root bypasses permission checks", ) def test_link_error_creating_link( capsys: pytest.CaptureFixture[str], home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None] ) -> None: """Verify that link reports link creation errors.""" os.makedirs(os.path.join(home, "subdir")) dotfiles.write("f", "apple") dotfiles.write_config([{"link": {"~/subdir/.f": "f"}}]) # Remove all write permissions from subdir. old_permissions = stat.S_IMODE(os.stat(os.path.join(home, "subdir")).st_mode) os.chmod( os.path.join(home, "subdir"), stat.S_IRUSR | stat.S_IXUSR | stat.S_IRGRP | stat.S_IXGRP | stat.S_IROTH | stat.S_IXOTH, ) with pytest.raises(SystemExit): run_dotbot() # Restore permissions to allow test cleanup. os.chmod(os.path.join(home, "subdir"), old_permissions) stdout, _ = capsys.readouterr() assert "Linking failed" in stdout @pytest.mark.skipif( "sys.platform == 'win32'", reason="Permissions work differently on Windows", ) @pytest.mark.skipif( "hasattr(os, 'getuid') and os.getuid() == 0", reason="Root bypasses permission checks", ) def test_link_error_creating_directory( capsys: pytest.CaptureFixture[str], home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None] ) -> None: """Verify that link reports directory creation errors.""" os.makedirs(os.path.join(home, "subdir")) dotfiles.write("f", "apple") dotfiles.write_config([{"link": {"~/subdir/subsubdir/.f": {"path": "f", "create": True}}}]) # Remove all write permissions from subdir. old_permissions = stat.S_IMODE(os.stat(os.path.join(home, "subdir")).st_mode) os.chmod( os.path.join(home, "subdir"), stat.S_IRUSR | stat.S_IXUSR | stat.S_IRGRP | stat.S_IXGRP | stat.S_IROTH | stat.S_IXOTH, ) with pytest.raises(SystemExit): run_dotbot() # Restore permissions to allow test cleanup. os.chmod(os.path.join(home, "subdir"), old_permissions) stdout, _ = capsys.readouterr() assert "Failed to create directory" in stdout @pytest.mark.skipif( "sys.platform == 'win32'", reason="Permissions work differently on Windows", ) @pytest.mark.skipif( "hasattr(os, 'getuid') and os.getuid() == 0", reason="Root bypasses permission checks", ) def test_link_error_delete( capsys: pytest.CaptureFixture[str], home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None] ) -> None: """Verify that link reports deletion errors.""" os.makedirs(os.path.join(home, "subdir")) with open(os.path.join(home, "subdir", ".f"), "w") as file: file.write("grape") dotfiles.write("f", "apple") dotfiles.write_config([{"link": {"~/subdir/.f": {"path": "f", "force": True}}}]) # Remove all write permissions from subdir. old_permissions = stat.S_IMODE(os.stat(os.path.join(home, "subdir")).st_mode) os.chmod( os.path.join(home, "subdir"), stat.S_IRUSR | stat.S_IXUSR | stat.S_IRGRP | stat.S_IXGRP | stat.S_IROTH | stat.S_IXOTH, ) with pytest.raises(SystemExit): run_dotbot() # Restore permissions to allow test cleanup. os.chmod(os.path.join(home, "subdir"), old_permissions) stdout, _ = capsys.readouterr() assert "Failed to remove" in stdout ================================================ FILE: tests/test_noop.py ================================================ import os import pytest def test_success(root: str) -> None: path = os.path.join(root, "abc.txt") with open(path, "w") as f: f.write("hello") with open(path) as f: assert f.read() == "hello" def test_failure() -> None: with pytest.raises(AssertionError): open("abc.txt", "w") # noqa: SIM115 with pytest.raises(AssertionError): open(file="abc.txt", mode="w") # noqa: SIM115 with pytest.raises(AssertionError): os.mkdir("a") with pytest.raises(AssertionError): os.mkdir(path="a") ================================================ FILE: tests/test_plugins.py ================================================ import os import shutil from typing import Callable import pytest from tests.conftest import Dotfiles def test_plugin_file(home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None: """Verify that a plugin file can be loaded in the config.""" 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( [ {"plugins": ["file.py"]}, {"plugin_file": "no-check-context"}, ] ) run_dotbot() with open(os.path.join(home, "flag-file")) as file: assert file.read() == "file plugin loading works" def test_plugin_absolute_path(home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None: """Verify that a plugin can be loaded via absolute path.""" 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( [ {"plugins": [os.path.join(os.path.abspath(dotfiles.directory), "file.py")]}, {"plugin_file": "no-check-context"}, ] ) run_dotbot() with open(os.path.join(home, "flag-file")) as file: assert file.read() == "file plugin loading works" def test_plugin_directory(home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None: """Verify that a plugin directory can be loaded in the config.""" plugin_file = os.path.join(os.path.dirname(os.path.abspath(__file__)), "dotbot_plugin_directory.py") os.makedirs(os.path.join(dotfiles.directory, "plugins")) shutil.copy(plugin_file, os.path.join(dotfiles.directory, "plugins", "directory.py")) dotfiles.write_config( [ {"plugins": ["plugins"]}, {"plugin_directory": "no-check-context"}, ] ) run_dotbot() with open(os.path.join(home, "flag-directory")) as file: assert file.read() == "directory plugin loading works" def test_plugin_multiple(home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None: """Verify that multiple plugins can be loaded at once in the config.""" plugin_file1 = os.path.join(os.path.dirname(os.path.abspath(__file__)), "dotbot_plugin_file.py") plugin_file2 = os.path.join(os.path.dirname(os.path.abspath(__file__)), "dotbot_plugin_directory.py") shutil.copy(plugin_file1, os.path.join(dotfiles.directory, "file.py")) os.makedirs(os.path.join(dotfiles.directory, "plugins")) shutil.copy(plugin_file2, os.path.join(dotfiles.directory, "plugins", "directory.py")) dotfiles.write_config( [ {"plugins": ["file.py", "plugins"]}, {"plugin_file": "no-check-context"}, {"plugin_directory": "no-check-context"}, ] ) run_dotbot() with open(os.path.join(home, "flag-file")) as file: assert file.read() == "file plugin loading works" with open(os.path.join(home, "flag-directory")) as file: assert file.read() == "directory plugin loading works" def test_plugin_command_line_and_config(home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None: """Verify that plugins can be simultaneously loaded via command-line arguments and config.""" plugin_file1 = os.path.join(os.path.dirname(os.path.abspath(__file__)), "dotbot_plugin_file.py") plugin_file2 = os.path.join(os.path.dirname(os.path.abspath(__file__)), "dotbot_plugin_directory.py") shutil.copy(plugin_file1, os.path.join(dotfiles.directory, "file.py")) os.makedirs(os.path.join(dotfiles.directory, "plugins")) shutil.copy(plugin_file2, os.path.join(dotfiles.directory, "plugins", "directory.py")) dotfiles.write_config( [ {"plugins": ["file.py"]}, {"plugin_file": "no-check-context"}, {"plugin_directory": "no-check-context"}, ] ) run_dotbot("--plugin-dir", os.path.join(dotfiles.directory, "plugins")) with open(os.path.join(home, "flag-file")) as file: assert file.read() == "file plugin loading works" with open(os.path.join(home, "flag-directory")) as file: assert file.read() == "directory plugin loading works" def test_plugin_nonexistent( capfd: pytest.CaptureFixture[str], dotfiles: Dotfiles, run_dotbot: Callable[..., None] ) -> None: """Verify that trying to load a non-existent plugin emits a warning and error.""" dotfiles.write_config( [ {"plugins": ["nonexistent.py"]}, {"plugin_file": "no-check-context"}, ] ) with pytest.raises(SystemExit) as excinfo: run_dotbot() assert excinfo.value.code == 1 stdout = capfd.readouterr().out.splitlines() assert any("Failed to load plugin 'nonexistent.py'" in line for line in stdout) assert any("Some plugins could not be loaded" in line for line in stdout) def test_plugin_empty_list(home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None: """Verify that an empty plugin list doesn't cause errors.""" dotfiles.write_config( [ {"plugins": []}, {"link": {"~/test": "test"}}, ] ) dotfiles.write("test", "content") run_dotbot() with open(os.path.join(home, "test")) as file: assert file.read() == "content" def test_plugin_multiple_directives(home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None: """Verify that multiple plugin directives in the same config work correctly.""" plugin_file1 = os.path.join(os.path.dirname(os.path.abspath(__file__)), "dotbot_plugin_file.py") plugin_file2 = os.path.join(os.path.dirname(os.path.abspath(__file__)), "dotbot_plugin_directory.py") shutil.copy(plugin_file1, os.path.join(dotfiles.directory, "file.py")) os.makedirs(os.path.join(dotfiles.directory, "plugins")) shutil.copy(plugin_file2, os.path.join(dotfiles.directory, "plugins", "directory.py")) dotfiles.write_config( [ {"plugins": ["file.py"]}, {"plugins": ["plugins"]}, {"plugin_file": "no-check-context"}, {"plugin_directory": "no-check-context"}, ] ) run_dotbot() with open(os.path.join(home, "flag-file")) as file: assert file.read() == "file plugin loading works" with open(os.path.join(home, "flag-directory")) as file: assert file.read() == "directory plugin loading works" def test_plugin_duplicate_loading(home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None: """Verify that duplicate plugin references don't load/execute the plugin multiple times.""" plugin_file = os.path.join(os.path.dirname(os.path.abspath(__file__)), "dotbot_plugin_counter.py") shutil.copy(plugin_file, os.path.join(dotfiles.directory, "counter.py")) dotfiles.write_config( [ {"plugins": ["counter.py", "counter.py"]}, {"counter": {}}, ] ) run_dotbot() with open(os.path.join(home, "counter")) as file: assert file.read() == "1" def test_plugin_subdirectory(home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None: """Verify that a plugin file in a subdirectory can be loaded.""" plugin_file = os.path.join(os.path.dirname(os.path.abspath(__file__)), "dotbot_plugin_file.py") os.makedirs(os.path.join(dotfiles.directory, "plugins", "subdir")) shutil.copy(plugin_file, os.path.join(dotfiles.directory, "plugins", "subdir", "file.py")) dotfiles.write_config( [ {"plugins": ["plugins/subdir/file.py"]}, {"plugin_file": "no-check-context"}, ] ) run_dotbot() with open(os.path.join(home, "flag-file")) as file: assert file.read() == "file plugin loading works" ================================================ FILE: tests/test_shell.py ================================================ from typing import Callable import pytest from tests.conftest import Dotfiles def test_shell_allow_stdout( capfd: pytest.CaptureFixture[str], dotfiles: Dotfiles, run_dotbot: Callable[..., None] ) -> None: """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: pytest.CaptureFixture[str], dotfiles: Dotfiles, run_dotbot: Callable[..., None] ) -> None: """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: pytest.CaptureFixture[str], dotfiles: Dotfiles, run_dotbot: Callable[..., None] ) -> None: """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: pytest.CaptureFixture[str], dotfiles: Dotfiles, run_dotbot: Callable[..., None] ) -> None: """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: pytest.CaptureFixture[str], dotfiles: Dotfiles, run_dotbot: Callable[..., None] ) -> None: """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: pytest.CaptureFixture[str], dotfiles: Dotfiles, run_dotbot: Callable[..., None] ) -> None: """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: pytest.CaptureFixture[str], dotfiles: Dotfiles, run_dotbot: Callable[..., None] ) -> None: """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: pytest.CaptureFixture[str], dotfiles: Dotfiles, run_dotbot: Callable[..., None] ) -> None: """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: pytest.CaptureFixture[str], dotfiles: Dotfiles, run_dotbot: Callable[..., None] ) -> None: """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: pytest.CaptureFixture[str], dotfiles: Dotfiles, run_dotbot: Callable[..., None] ) -> None: """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: pytest.CaptureFixture[str], dotfiles: Dotfiles, run_dotbot: Callable[..., None] ) -> None: """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: pytest.CaptureFixture[str], dotfiles: Dotfiles, run_dotbot: Callable[..., None] ) -> None: """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: pytest.CaptureFixture[str], dotfiles: Dotfiles, run_dotbot: Callable[..., None] ) -> None: """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: pytest.CaptureFixture[str], dotfiles: Dotfiles, run_dotbot: Callable[..., None] ) -> None: """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: pytest.CaptureFixture[str], dotfiles: Dotfiles, run_dotbot: Callable[..., None] ) -> None: """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: pytest.CaptureFixture[str], dotfiles: Dotfiles, run_dotbot: Callable[..., None] ) -> None: """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) def test_shell_dry_run(capfd: pytest.CaptureFixture[str], dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None: """Verify that the shell plugin does not execute commands during a dry run.""" dotfiles.write_config( [ { "shell": [ { "command": "exit 1", } ], } ] ) run_dotbot("--dry-run") lines = capfd.readouterr().out.splitlines() assert any(line.strip() == "Would run command exit 1" for line in lines) ================================================ FILE: tests/test_shim.py ================================================ import os import shutil import subprocess import sys import pytest from tests.conftest import Dotfiles def test_shim(home: str, dotfiles: Dotfiles) -> None: """Verify install shim works.""" # Skip the test if git is unavailable. git = shutil.which("git") if git is None: pytest.skip("git is unavailable") if sys.platform == "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 == "win32": ps = shutil.which("powershell") assert ps is not None args = [ps, "-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")) as file: assert file.read() == "pear" ================================================ FILE: tools/git-submodule/install ================================================ #!/usr/bin/env bash set -e CONFIG="install.conf.yaml" DOTBOT_DIR="dotbot" DOTBOT_BIN="bin/dotbot" BASEDIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" cd "${BASEDIR}" git -C "${DOTBOT_DIR}" submodule sync --quiet --recursive git submodule update --init --recursive "${DOTBOT_DIR}" "${BASEDIR}/${DOTBOT_DIR}/${DOTBOT_BIN}" -d "${BASEDIR}" -c "${CONFIG}" "${@}" ================================================ FILE: tools/git-submodule/install.ps1 ================================================ $ErrorActionPreference = "Stop" $CONFIG = "install.conf.yaml" $DOTBOT_DIR = "dotbot" $DOTBOT_BIN = "bin/dotbot" $BASEDIR = $PSScriptRoot Set-Location $BASEDIR git -C $DOTBOT_DIR submodule sync --quiet --recursive git submodule update --init --recursive $DOTBOT_DIR foreach ($PYTHON in ('python', 'python3')) { # Python redirects to Microsoft Store in Windows 10 when not installed if (& { $ErrorActionPreference = "SilentlyContinue" ![string]::IsNullOrEmpty((&$PYTHON -V)) $ErrorActionPreference = "Stop" }) { &$PYTHON $(Join-Path $BASEDIR -ChildPath $DOTBOT_DIR | Join-Path -ChildPath $DOTBOT_BIN) -d $BASEDIR -c $CONFIG $Args return } } Write-Error "Error: Cannot find Python." ================================================ FILE: tools/hg-subrepo/install ================================================ #!/usr/bin/env bash set -e CONFIG="install.conf.yaml" DOTBOT_DIR="dotbot" DOTBOT_BIN="bin/dotbot" BASEDIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" cd "${BASEDIR}" (cd "${DOTBOT_DIR}" && git submodule update --init --recursive) "${BASEDIR}/${DOTBOT_DIR}/${DOTBOT_BIN}" -d "${BASEDIR}" -c "${CONFIG}" "${@}" ================================================ FILE: tools/hg-subrepo/install.ps1 ================================================ $ErrorActionPreference = "Stop" $CONFIG = "install.conf.yaml" $DOTBOT_DIR = "dotbot" $DOTBOT_BIN = "bin/dotbot" $BASEDIR = $PSScriptRoot Set-Location $BASEDIR Set-Location $DOTBOT_DIR && git submodule update --init --recursive foreach ($PYTHON in ('python', 'python3')) { # Python redirects to Microsoft Store in Windows 10 when not installed if (& { $ErrorActionPreference = "SilentlyContinue" ![string]::IsNullOrEmpty((&$PYTHON -V)) $ErrorActionPreference = "Stop" }) { &$PYTHON $(Join-Path $BASEDIR -ChildPath $DOTBOT_DIR | Join-Path -ChildPath $DOTBOT_BIN) -d $BASEDIR -c $CONFIG $Args return } } Write-Error "Error: Cannot find Python."