1
0
Fork 0
mirror of synced 2025-01-02 19:28:59 -05:00

Modernize project

This patch adds static typing, and it switches to the Hatch package
manager and its Hatchling build backend.
This commit is contained in:
Anish Athalye 2024-12-27 22:01:05 -08:00
parent 256babe052
commit abecc97bad
55 changed files with 875 additions and 824 deletions

View file

@ -9,9 +9,3 @@ trim_trailing_whitespace = true
[*.py]
indent_size = 4
[*.bash]
indent_size = 4
[*.yml]
indent_size = 2

View file

@ -13,7 +13,6 @@ jobs:
matrix:
os: ["ubuntu-20.04", "macos-latest"]
python:
- "3.6"
- "3.7"
- "3.8"
- "3.9"
@ -37,8 +36,6 @@ jobs:
- os: "windows-latest"
python: "3.13"
exclude:
- os: "macos-latest"
python: "3.6"
- os: "macos-latest"
python: "3.7"
runs-on: ${{ matrix.os }}
@ -50,23 +47,30 @@ jobs:
- uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python }}
allow-prereleases: true
- name: "Install dependencies"
run: |
python -m pip install --upgrade pip setuptools
python -m pip install tox tox-gh-actions
- name: "Run tests"
run: |
python -m tox
python -m tox -e coverage_report
- uses: pypa/hatch@install
- 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 }}
fmt:
name: Format
runs-on: ubuntu-22.04
typecheck:
name: Type check
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- uses: psf/black@stable
- uses: isort/isort-action@v1
- uses: actions/setup-python@v5
with:
python-version: "3.13"
- uses: pypa/hatch@install
- run: hatch run types:check
fmt:
name: Format and lint
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.13"
- uses: pypa/hatch@install
- run: hatch fmt --check

10
.gitignore vendored
View file

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

View file

@ -1,6 +1,9 @@
Note: this changelog only lists feature additions, not bugfixes. For details on
those, see the Git history.
- 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

View file

@ -1,5 +1,4 @@
Contributing
============
# 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
@ -7,8 +6,7 @@ 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
----------------
## 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.
@ -20,8 +18,7 @@ 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
-----------
## Bug Reports
Did something go wrong with Dotbot? Sorry about that! Bug reports are greatly
appreciated!
@ -31,8 +28,7 @@ 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
-------
## Patches
Want to hack on Dotbot? Awesome!
@ -50,40 +46,41 @@ used in the rest of the project. The version history should be clean, and
commit messages should be descriptive and [properly
formatted][commit-messages].
### Testing
When preparing a patch, it's recommended that you add unit tests
that demonstrate the bug is fixed (or that the feature works).
You can run the tests on your local machine by installing the `dev` extras.
The steps below do this using a virtual environment:
that demonstrate the bug is fixed (or that the feature works). You
can run tests on your local machine using [Hatch][hatch]:
```shell
# Create a local virtual environment
$ python -m venv .venv
# Activate the virtual environment
# Cygwin, Linux, and MacOS:
$ . .venv/bin/activate
# Windows Powershell:
$ & .venv\Scripts\Activate.ps1
# Update pip and setuptools
(.venv) $ python -m pip install -U pip setuptools
# Install dotbot and its development dependencies
(.venv) $ python -m pip install -e .[dev]
# Run the unit tests
(.venv) $ tox
```bash
hatch test
```
If you prefer to run the tests in an isolated container using Docker, you can
do so with the following:
```
docker run -it --rm -v "${PWD}:/dotbot" -w /dotbot python:3.10-alpine /bin/sh
```bash
docker run -it --rm -v "${PWD}:/dotbot" -w /dotbot python:3.13-bookworm /bin/bash
```
After spawning the container, follow the same instructions as above (create a
virtualenv, ..., run the tests).
After spawning the container, install Hatch with `pip install hatch`, and then
run the tests.
### Type checking
You can run type checking with:
```bash
hatch run types:check
```
### Formatting and linting
You can run the [Ruff][ruff] formatter and linter with:
```bash
hatch fmt
```
---
@ -94,3 +91,5 @@ If you have any questions about anything, feel free to [ask][email]!
[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
[hatch]: https://hatch.pypa.io/
[ruff]: https://github.com/astral-sh/ruff

View file

@ -1,4 +1,4 @@
# Dotbot [![Build Status](https://github.com/anishathalye/dotbot/workflows/CI/badge.svg)](https://github.com/anishathalye/dotbot/actions?query=workflow%3ACI) [![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/) [![Python 3.6+](https://img.shields.io/badge/python-3.6%2B-blue)](https://pypi.org/pypi/dotbot/)
# Dotbot [![Build Status](https://github.com/anishathalye/dotbot/workflows/CI/badge.svg)](https://github.com/anishathalye/dotbot/actions?query=workflow%3ACI) [![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/) [![Python 3.7+](https://img.shields.io/badge/python-3.7%2B-blue)](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!

View file

@ -15,34 +15,32 @@ exit 1
# python code
import sys, os
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')
exit(1)
if sys.version_info < (3, 6):
print('error: this version of Dotbot requires Python 3.6+')
exit(1)
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(
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)
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, 'dotbot')):
if PROJECT_ROOT_DIRECTORY not in sys.path:
sys.path.insert(0, PROJECT_ROOT_DIRECTORY)
os.putenv('PYTHONPATH', PROJECT_ROOT_DIRECTORY)
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
def main():
dotbot.cli.main()
if __name__ == '__main__':
main()
dotbot.cli.main()

View file

@ -1,4 +0,0 @@
from .cli import main
from .plugin import Plugin
__version__ = "1.20.4"

View file

@ -1,4 +0,0 @@
from .cli import main
if __name__ == "__main__":
main()

View file

@ -1,37 +0,0 @@
import copy
import os
from argparse import Namespace
class Context:
"""
Contextual data and information for plugins.
"""
def __init__(self, base_directory, options=Namespace(), plugins=None):
self._base_directory = base_directory
self._defaults = {}
self._options = options
self._plugins = plugins
def set_base_directory(self, base_directory):
self._base_directory = base_directory
def base_directory(self, canonical_path=True):
base_directory = self._base_directory
if canonical_path:
base_directory = os.path.realpath(base_directory)
return base_directory
def set_defaults(self, defaults):
self._defaults = defaults
def defaults(self):
return copy.deepcopy(self._defaults)
def options(self):
return copy.deepcopy(self._options)
def plugins(self):
# shallow copy is ok here
return copy.copy(self._plugins)

View file

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

View file

@ -1,7 +0,0 @@
class Level:
NOTSET = 0
DEBUG = 10
LOWINFO = 15
INFO = 20
WARNING = 30
ERROR = 40

View file

@ -1,62 +0,0 @@
from ..util.singleton import Singleton
from .color import Color
from .level import Level
class Messenger(metaclass=Singleton):
def __init__(self, level=Level.LOWINFO):
self.set_level(level)
self.use_color(True)
def set_level(self, level):
self._level = level
def use_color(self, yesno):
self._use_color = yesno
def log(self, level, message):
if level >= self._level:
print("%s%s%s" % (self._color(level), message, self._reset()))
def debug(self, message):
self.log(Level.DEBUG, message)
def lowinfo(self, message):
self.log(Level.LOWINFO, message)
def info(self, message):
self.log(Level.INFO, message)
def warning(self, message):
self.log(Level.WARNING, message)
def error(self, message):
self.log(Level.ERROR, message)
def _color(self, level):
"""
Get a color (terminal escape sequence) according to a level.
"""
if not self._use_color:
return ""
elif level < Level.DEBUG:
return ""
elif Level.DEBUG <= level < Level.LOWINFO:
return Color.YELLOW
elif Level.LOWINFO <= level < Level.INFO:
return Color.BLUE
elif Level.INFO <= level < Level.WARNING:
return Color.GREEN
elif Level.WARNING <= level < Level.ERROR:
return Color.MAGENTA
elif Level.ERROR <= level:
return Color.RED
def _reset(self):
"""
Get a reset color (terminal escape sequence).
"""
if not self._use_color:
return ""
else:
return Color.RESET

View file

@ -1,4 +0,0 @@
from .clean import Clean
from .create import Create
from .link import Link
from .shell import Shell

View file

@ -1 +0,0 @@
from .common import shell_command

View file

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

View file

@ -1,17 +1,91 @@
[tool.black]
line-length = 100
exclude = '''
/(
\.git
| \.github
| .*\.egg-info
| build
| dist
| lib
)/
'''
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.pytest.ini_options]
filterwarnings = [
"error",
[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/__init__.py"
[tool.hatch.build.targets.sdist]
exclude = [
"lib/",
]
[[tool.hatch.envs.hatch-test.matrix]]
python = ["3.7", "3.8", "3.9", "3.10", "3.11", "3.12", "3.13", "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, so we override
# the test dependencies for Python 3.7 here
[tool.hatch.envs.hatch-test.overrides]
name."^py3\\.7$".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",
]
[tool.hatch.envs.types.scripts]
check = "mypy --strict --install-types --non-interactive {args:src tests}"
[tool.hatch.envs.coverage]
detached = true
dependencies = [
"coverage",
]
[tool.hatch.envs.coverage.scripts]
html = "coverage html"
xml = "coverage xml"
[tool.ruff]
extend-exclude = [
"lib/*.py"
]
lint.ignore = [
"FA100",
]

View file

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

6
src/dotbot/__init__.py Normal file
View file

@ -0,0 +1,6 @@
from dotbot.cli import main
from dotbot.plugin import Plugin
__version__ = "1.21.0"
__all__ = ["main", "Plugin"]

View file

@ -3,36 +3,28 @@ import os
import subprocess
import sys
from argparse import ArgumentParser, RawTextHelpFormatter
from typing import Any
import dotbot
from .config import ConfigReader, ReadingError
from .dispatcher import Dispatcher, DispatchError, _all_plugins
from .messenger import Level, Messenger
from .plugins import Clean, Create, Link, Shell
from .util import module
from dotbot.config import ConfigReader, ReadingError
from dotbot.dispatcher import Dispatcher, DispatchError, _all_plugins
from dotbot.messenger import Level, Messenger
from dotbot.plugins import Clean, Create, Link, Shell
from dotbot.util import module
def add_options(parser):
parser.add_argument(
"-Q", "--super-quiet", action="store_true", help="suppress almost all output"
)
def add_options(parser: ArgumentParser) -> None:
parser.add_argument("-Q", "--super-quiet", action="store_true", help="suppress almost all output")
parser.add_argument("-q", "--quiet", action="store_true", help="suppress most output")
parser.add_argument(
"-v",
"--verbose",
action="count",
default=0,
help="enable verbose output\n"
"-v: typical verbose\n"
"-vv: also, set shell commands stderr/stdout to true",
)
parser.add_argument(
"-d", "--base-directory", help="execute commands from within BASEDIR", metavar="BASEDIR"
)
parser.add_argument(
"-c", "--config-file", help="run commands given in CONFIGFILE", metavar="CONFIGFILE"
help="enable verbose output\n" "-v: typical verbose\n" "-vv: also, set shell commands stderr/stdout to true",
)
parser.add_argument("-d", "--base-directory", help="execute commands from within BASEDIR", metavar="BASEDIR")
parser.add_argument("-c", "--config-file", help="run commands given in CONFIGFILE", metavar="CONFIGFILE")
parser.add_argument(
"-p",
"--plugin",
@ -42,9 +34,7 @@ def add_options(parser):
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("--disable-built-in-plugins", action="store_true", help="disable built-in plugins")
parser.add_argument(
"--plugin-dir",
action="append",
@ -53,21 +43,11 @@ def add_options(parser):
metavar="PLUGIN_DIR",
help="load all plugins in PLUGIN_DIR",
)
parser.add_argument(
"--only", nargs="+", help="only run specified directives", metavar="DIRECTIVE"
)
parser.add_argument(
"--except", nargs="+", dest="skip", help="skip specified directives", metavar="DIRECTIVE"
)
parser.add_argument(
"--force-color", dest="force_color", action="store_true", help="force color output"
)
parser.add_argument(
"--no-color", dest="no_color", action="store_true", help="disable color output"
)
parser.add_argument(
"--version", action="store_true", help="show program's version number and exit"
)
parser.add_argument("--only", nargs="+", help="only run specified directives", metavar="DIRECTIVE")
parser.add_argument("--except", nargs="+", dest="skip", help="skip specified directives", metavar="DIRECTIVE")
parser.add_argument("--force-color", dest="force_color", action="store_true", help="force color output")
parser.add_argument("--no-color", dest="no_color", action="store_true", help="disable color output")
parser.add_argument("--version", action="store_true", help="show program's version number and exit")
parser.add_argument(
"-x",
"--exit-on-failure",
@ -77,12 +57,12 @@ def add_options(parser):
)
def read_config(config_file):
def read_config(config_file: str) -> Any:
reader = ConfigReader(config_file)
return reader.get_config()
def main():
def main() -> None:
log = Messenger()
try:
parser = ArgumentParser(formatter_class=RawTextHelpFormatter)
@ -92,15 +72,15 @@ def main():
try:
with open(os.devnull) as devnull:
git_hash = subprocess.check_output(
["git", "rev-parse", "HEAD"],
["git", "rev-parse", "HEAD"], # noqa: S607
cwd=os.path.dirname(os.path.abspath(__file__)),
stderr=devnull,
).decode("ascii")
hash_msg = " (git %s)" % git_hash[:10]
hash_msg = f" (git {git_hash[:10]})"
except (OSError, subprocess.CalledProcessError):
hash_msg = ""
print("Dotbot version %s%s" % (dotbot.__version__, hash_msg))
exit(0)
print(f"Dotbot version {dotbot.__version__}{hash_msg}") # noqa: T201
sys.exit(0)
if options.super_quiet:
log.set_level(Level.WARNING)
if options.quiet:
@ -110,7 +90,7 @@ def main():
if options.force_color and options.no_color:
log.error("`--force-color` and `--no-color` cannot both be provided")
exit(1)
sys.exit(1)
elif options.force_color:
log.use_color(True)
elif options.no_color:
@ -124,10 +104,8 @@ def main():
plugins.extend([Clean, Create, Link, Shell])
plugin_paths = []
for directory in plugin_directories:
for plugin_path in glob.glob(os.path.join(directory, "*.py")):
plugin_paths.append(plugin_path)
for plugin_path in options.plugins:
plugin_paths.append(plugin_path)
plugin_paths.extend(glob.glob(os.path.join(directory, "*.py")))
plugin_paths.extend(options.plugins)
for plugin_path in plugin_paths:
abspath = os.path.abspath(plugin_path)
plugins.extend(module.load(abspath))
@ -135,16 +113,17 @@ def main():
# 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 module.load above
plugins = set(plugins)
plugins = list(set(plugins))
if not options.config_file:
log.error("No configuration file specified")
exit(1)
sys.exit(1)
tasks = read_config(options.config_file)
if tasks is None:
log.warning("Configuration file is empty, no work to do")
tasks = []
if not isinstance(tasks, list):
raise ReadingError("Configuration file must be a list of tasks")
msg = "Configuration file must be a list of tasks"
raise ReadingError(msg) # noqa: TRY301
if options.base_directory:
base_directory = os.path.abspath(options.base_directory)
else:
@ -164,10 +143,11 @@ def main():
if success:
log.info("\n==> All tasks executed successfully")
else:
raise DispatchError("\n==> Some tasks were not executed successfully")
msg = "\n==> Some tasks were not executed successfully"
raise DispatchError(msg) # noqa: TRY301
except (ReadingError, DispatchError) as e:
log.error("%s" % e)
exit(1)
log.error(str(e)) # noqa: TRY400
sys.exit(1)
except KeyboardInterrupt:
log.error("\n==> Operation aborted")
exit(1)
log.error("\n==> Operation aborted") # noqa: TRY400
sys.exit(1)

View file

@ -1,29 +1,27 @@
import json
import os.path
from typing import Any
import yaml
from .util import string
from dotbot.util import string
class ConfigReader:
def __init__(self, config_file_path):
def __init__(self, config_file_path: str):
self._config = self._read(config_file_path)
def _read(self, config_file_path):
def _read(self, config_file_path: str) -> Any:
try:
_, ext = os.path.splitext(config_file_path)
with open(config_file_path) as fin:
if ext == ".json":
data = json.load(fin)
else:
data = yaml.safe_load(fin)
return data
return json.load(fin) if ext == ".json" else yaml.safe_load(fin)
except Exception as e:
msg = string.indent_lines(str(e))
raise ReadingError("Could not read config file:\n%s" % msg)
msg = f"Could not read config file:\n{msg}"
raise ReadingError(msg) from e
def get_config(self):
def get_config(self) -> Any:
return self._config

43
src/dotbot/context.py Normal file
View file

@ -0,0 +1,43 @@
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)

View file

@ -1,9 +1,10 @@
import os
from argparse import Namespace
from typing import Any, Dict, List, Optional, Type
from .context import Context
from .messenger import Messenger
from .plugin import Plugin
from dotbot.context import Context
from dotbot.messenger import Messenger
from dotbot.plugin import Plugin
# Before b5499c7dc5b300462f3ce1c2a3d9b7a76233b39b, Dispatcher auto-loaded all
# plugins, but after that change, plugins are passed in explicitly (and loaded
@ -11,18 +12,18 @@ from .plugin import Plugin
# 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 = [] # filled in by cli.py
_all_plugins: List[Type[Plugin]] = [] # filled in by cli.py
class Dispatcher:
def __init__(
self,
base_directory,
only=None,
skip=None,
exit_on_failure=False,
options=Namespace(),
plugins=None,
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=[]
@ -35,13 +36,16 @@ class Dispatcher:
self._skip = skip
self._exit = exit_on_failure
def _setup_context(self, base_directory, options, plugins):
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):
raise DispatchError("Nonexistent base directory")
msg = "Nonexistent base directory"
raise DispatchError(msg)
self._context = Context(path, options, plugins)
def dispatch(self, tasks):
def dispatch(self, tasks: List[Dict[str, Any]]) -> bool:
success = True
for task in tasks:
for action in task:
@ -51,7 +55,7 @@ class Dispatcher:
or self._skip is not None
and action in self._skip
) and action != "defaults":
self._log.info("Skipping action %s" % action)
self._log.info(f"Skipping action {action}")
continue
handled = False
if action == "defaults":
@ -64,21 +68,19 @@ class Dispatcher:
local_success = plugin.handle(action, task[action])
if not local_success and self._exit:
# The action has failed exit
self._log.error("Action %s failed" % action)
self._log.error(f"Action {action} failed")
return False
success &= local_success
handled = True
except Exception as err:
self._log.error(
"An error was encountered while executing action %s" % action
)
self._log.debug(err)
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 execption exit
return False
if not handled:
success = False
self._log.error("Action %s not handled" % action)
self._log.error(f"Action {action} not handled")
if self._exit:
# Invalid action exit
return False

View file

@ -0,0 +1,4 @@
from dotbot.messenger.level import Level
from dotbot.messenger.messenger import Messenger
__all__ = ["Level", "Messenger"]

View file

@ -0,0 +1,36 @@
from enum import Enum
from typing import Any
class Level(Enum):
NOTSET = 0
DEBUG = 10
LOWINFO = 15
INFO = 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

View file

@ -0,0 +1,58 @@
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.LOWINFO):
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 lowinfo(self, message: str) -> None:
self.log(Level.LOWINFO, message)
def info(self, message: str) -> None:
self.log(Level.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.LOWINFO:
return Color.YELLOW
if level < Level.INFO:
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

View file

@ -1,5 +1,7 @@
from .context import Context
from .messenger import Messenger
from typing import Any
from dotbot.context import Context
from dotbot.messenger import Messenger
class Plugin:
@ -7,17 +9,17 @@ class Plugin:
Abstract base class for commands that process directives.
"""
def __init__(self, context):
def __init__(self, context: Context):
self._context = context
self._log = Messenger()
def can_handle(self, directive):
def can_handle(self, directive: str) -> bool:
"""
Returns true if the Plugin can handle the directive.
"""
raise NotImplementedError
def handle(self, directive, data):
def handle(self, directive: str, data: Any) -> bool:
"""
Executes the directive.

View file

@ -0,0 +1,6 @@
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"]

View file

@ -1,7 +1,8 @@
import os
import sys
from typing import Any
from ..plugin import Plugin
from dotbot.plugin import Plugin
class Clean(Plugin):
@ -11,15 +12,16 @@ class Clean(Plugin):
_directive = "clean"
def can_handle(self, directive):
def can_handle(self, directive: str) -> bool:
return directive == self._directive
def handle(self, directive, data):
def handle(self, directive: str, data: Any) -> bool:
if directive != self._directive:
raise ValueError("Clean cannot handle directive %s" % directive)
msg = f"Clean cannot handle directive {directive}"
raise ValueError(msg)
return self._process_clean(data)
def _process_clean(self, targets):
def _process_clean(self, targets: Any) -> bool:
success = True
defaults = self._context.defaults().get(self._directive, {})
for target in targets:
@ -28,42 +30,40 @@ class Clean(Plugin):
if isinstance(targets, dict) and isinstance(targets[target], dict):
force = targets[target].get("force", force)
recursive = targets[target].get("recursive", recursive)
success &= self._clean(target, force, recursive)
success &= self._clean(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, force, recursive):
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("Ignoring nonexistent directory %s" % 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)
)
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, recursive)
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[:5] == "win32" and points_at.startswith("\\\\?\\"):
if sys.platform == "win32" and points_at.startswith("\\\\?\\"):
points_at = points_at[4:]
if self._in_directory(path, self._context.base_directory()) or force:
self._log.lowinfo("Removing invalid link %s -> %s" % (path, points_at))
self._log.lowinfo(f"Removing invalid link {path} -> {points_at}")
os.remove(path)
else:
self._log.lowinfo("Link %s -> %s not removed." % (path, points_at))
self._log.lowinfo(f"Link {path} -> {points_at} not removed.")
return True
def _in_directory(self, path, directory):
def _in_directory(self, path: str, directory: str) -> bool:
"""
Returns true if the path is in the directory.
"""

View file

@ -1,6 +1,7 @@
import os
from typing import Any
from ..plugin import Plugin
from dotbot.plugin import Plugin
class Create(Plugin):
@ -10,15 +11,16 @@ class Create(Plugin):
_directive = "create"
def can_handle(self, directive):
def can_handle(self, directive: str) -> bool:
return directive == self._directive
def handle(self, directive, data):
def handle(self, directive: str, data: Any) -> bool:
if directive != self._directive:
raise ValueError("Create cannot handle directive %s" % directive)
msg = f"Create cannot handle directive {directive}"
raise ValueError(msg)
return self._process_paths(data)
def _process_paths(self, paths):
def _process_paths(self, paths: Any) -> bool:
success = True
defaults = self._context.defaults().get("create", {})
for key in paths:
@ -35,26 +37,26 @@ class Create(Plugin):
self._log.error("Some paths were not successfully set up")
return success
def _exists(self, path):
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, mode):
def _create(self, path: str, mode: int) -> bool:
success = True
if not self._exists(path):
self._log.debug("Trying to create path %s with mode %o" % (path, mode))
self._log.debug(f"Trying to create path {path} with mode {mode}")
try:
self._log.lowinfo("Creating path %s" % path)
self._log.lowinfo(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("Failed to create path %s" % path)
self._log.warning(f"Failed to create path {path}")
success = False
else:
self._log.lowinfo("Path exists %s" % path)
self._log.lowinfo(f"Path exists {path}")
return success

View file

@ -2,9 +2,10 @@ import glob
import os
import shutil
import sys
from typing import Any, List, Optional
from ..plugin import Plugin
from ..util import shell_command
from dotbot.plugin import Plugin
from dotbot.util import shell_command
class Link(Plugin):
@ -14,19 +15,20 @@ class Link(Plugin):
_directive = "link"
def can_handle(self, directive):
def can_handle(self, directive: str) -> bool:
return directive == self._directive
def handle(self, directive, data):
def handle(self, directive: str, data: Any) -> bool:
if directive != self._directive:
raise ValueError("Link cannot handle directive %s" % directive)
msg = f"Link cannot handle directive {directive}"
raise ValueError(msg)
return self._process_links(data)
def _process_links(self, links):
def _process_links(self, links: Any) -> bool:
success = True
defaults = self._context.defaults().get("link", {})
for destination, source in links.items():
destination = os.path.expandvars(destination)
destination = os.path.expandvars(destination) # noqa: PLW2901
relative = defaults.get("relative", False)
# support old "canonicalize-path" key for compatibility
canonical_path = defaults.get("canonicalize", defaults.get("canonicalize-path", True))
@ -42,9 +44,7 @@ class Link(Plugin):
# extended config
test = source.get("if", test)
relative = source.get("relative", relative)
canonical_path = source.get(
"canonicalize", source.get("canonicalize-path", canonical_path)
)
canonical_path = source.get("canonicalize", source.get("canonicalize-path", canonical_path))
force = source.get("force", force)
relink = source.get("relink", relink)
create = source.get("create", create)
@ -56,20 +56,16 @@ class Link(Plugin):
else:
path = self._default_source(destination, source)
if test is not None and not self._test_success(test):
self._log.lowinfo("Skipping %s" % destination)
self._log.lowinfo(f"Skipping {destination}")
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.lowinfo("Globs from '" + path + "': " + str(glob_results))
self._log.lowinfo(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 :]
)
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
@ -81,59 +77,59 @@ class Link(Plugin):
success &= self._delete(
glob_full_item,
glob_link_destination,
relative,
canonical_path,
force,
relative=relative,
canonical_path=canonical_path,
force=force,
)
success &= self._link(
glob_full_item,
glob_link_destination,
relative,
canonical_path,
ignore_missing,
relative=relative,
canonical_path=canonical_path,
ignore_missing=ignore_missing,
)
else:
if create:
success &= self._create(destination)
if not ignore_missing and not self._exists(
os.path.join(self._context.base_directory(), path)
):
if not ignore_missing and not self._exists(os.path.join(self._context.base_directory(), path)):
# we seemingly check this twice (here and in _link) because
# if the file doesn't exist and force is True, we don't
# want to remove the original (this is tested by
# link-force-leaves-when-nonexistent.bash)
success = False
self._log.warning("Nonexistent source %s -> %s" % (destination, path))
self._log.warning(f"Nonexistent source {destination} -> {path}")
continue
if force or relink:
success &= self._delete(path, destination, relative, canonical_path, force)
success &= self._link(path, destination, relative, canonical_path, ignore_missing)
success &= self._delete(
path, destination, relative=relative, canonical_path=canonical_path, force=force
)
success &= self._link(
path, destination, relative=relative, canonical_path=canonical_path, ignore_missing=ignore_missing
)
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):
def _test_success(self, command: str) -> bool:
ret = shell_command(command, cwd=self._context.base_directory())
if ret != 0:
self._log.debug("Test '%s' returned false" % command)
self._log.debug(f"Test '{command}' returned false")
return ret == 0
def _default_source(self, destination, source):
def _default_source(self, destination: str, source: Optional[str]) -> str:
if source is None:
basename = os.path.basename(destination)
if basename.startswith("."):
return basename[1:]
else:
return basename
else:
return source
return basename
return source
def _has_glob_chars(self, path):
def _has_glob_chars(self, path: str) -> bool:
return any(i in path for i in "?*[")
def _glob(self, path):
def _glob(self, path: str) -> List[str]:
"""
Wrap `glob.glob` in a python agnostic way, catching errors in usage.
"""
@ -147,7 +143,7 @@ class Link(Plugin):
# return matched results
return found
def _create_glob_results(self, path, exclude_paths):
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))
@ -160,44 +156,44 @@ class Link(Plugin):
ret = set(include) - set(exclude)
return list(ret)
def _is_link(self, path):
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_destination(self, path):
def _link_destination(self, path: str) -> str:
"""
Returns the destination of the symbolic link.
"""
path = os.path.expanduser(path)
path = os.readlink(path)
if sys.platform[:5] == "win32" and path.startswith("\\\\?\\"):
if sys.platform == "win32" and path.startswith("\\\\?\\"):
path = path[4:]
return path
def _exists(self, path):
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):
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("Try to create parent: " + str(parent))
self._log.debug(f"Try to create parent: {parent}")
try:
os.makedirs(parent)
except OSError:
self._log.warning("Failed to create directory %s" % parent)
self._log.warning(f"Failed to create directory {parent}")
success = False
else:
self._log.lowinfo("Creating directory %s" % parent)
self._log.lowinfo(f"Creating directory {parent}")
return success
def _delete(self, source, path, relative, canonical_path, force):
def _delete(self, source: str, path: str, *, relative: bool, canonical_path: bool, force: bool) -> bool:
success = True
source = os.path.join(self._context.base_directory(canonical_path=canonical_path), source)
fullpath = os.path.abspath(os.path.expanduser(path))
@ -219,14 +215,14 @@ class Link(Plugin):
os.remove(fullpath)
removed = True
except OSError:
self._log.warning("Failed to remove %s" % path)
self._log.warning(f"Failed to remove {path}")
success = False
else:
if removed:
self._log.lowinfo("Removing %s" % path)
self._log.lowinfo(f"Removing {path}")
return success
def _relative_path(self, source, destination):
def _relative_path(self, source: str, destination: str) -> str:
"""
Returns the relative path to get to the source file from the
destination file.
@ -234,7 +230,7 @@ class Link(Plugin):
destination_dir = os.path.dirname(destination)
return os.path.relpath(source, destination_dir)
def _link(self, source, link_name, relative, canonical_path, ignore_missing):
def _link(self, source: str, link_name: str, *, relative: bool, canonical_path: bool, ignore_missing: bool) -> bool:
"""
Links link_name to source.
@ -245,18 +241,9 @@ class Link(Plugin):
base_directory = self._context.base_directory(canonical_path=canonical_path)
absolute_source = os.path.join(base_directory, source)
link_name = os.path.normpath(link_name)
if relative:
source = self._relative_path(absolute_source, destination)
else:
source = absolute_source
if (
not self._exists(link_name)
and self._is_link(link_name)
and self._link_destination(link_name) != source
):
self._log.warning(
"Invalid link %s -> %s" % (link_name, self._link_destination(link_name))
)
source = self._relative_path(absolute_source, destination) if relative else absolute_source
if not self._exists(link_name) and self._is_link(link_name) and self._link_destination(link_name) != source:
self._log.warning(f"Invalid link {link_name} -> {self._link_destination(link_name)}")
# we need to use absolute_source below because our cwd is the dotfiles
# directory, and if source is relative, it will be relative to the
# destination directory
@ -264,23 +251,21 @@ class Link(Plugin):
try:
os.symlink(source, destination)
except OSError:
self._log.warning("Linking failed %s -> %s" % (link_name, source))
self._log.warning(f"Linking failed {link_name} -> {source}")
else:
self._log.lowinfo("Creating link %s -> %s" % (link_name, source))
self._log.lowinfo(f"Creating link {link_name} -> {source}")
success = True
elif self._exists(link_name) and not self._is_link(link_name):
self._log.warning("%s already exists but is a regular file or directory" % link_name)
self._log.warning(f"{link_name} already exists but is a regular file or directory")
elif self._is_link(link_name) and self._link_destination(link_name) != source:
self._log.warning(
"Incorrect link %s -> %s" % (link_name, self._link_destination(link_name))
)
self._log.warning(f"Incorrect link {link_name} -> {self._link_destination(link_name)}")
# again, we use absolute_source to check for existence
elif not self._exists(absolute_source):
if self._is_link(link_name):
self._log.warning("Nonexistent source %s -> %s" % (link_name, source))
self._log.warning(f"Nonexistent source {link_name} -> {source}")
else:
self._log.warning("Nonexistent source for %s : %s" % (link_name, source))
self._log.warning(f"Nonexistent source for {link_name} : {source}")
else:
self._log.lowinfo("Link exists %s -> %s" % (link_name, source))
self._log.lowinfo(f"Link exists {link_name} -> {source}")
success = True
return success

View file

@ -1,5 +1,7 @@
from ..plugin import Plugin
from ..util import shell_command
from typing import Any, Dict
from dotbot.plugin import Plugin
from dotbot.util import shell_command
class Shell(Plugin):
@ -10,15 +12,16 @@ class Shell(Plugin):
_directive = "shell"
_has_shown_override_message = False
def can_handle(self, directive):
def can_handle(self, directive: str) -> bool:
return directive == self._directive
def handle(self, directive, data):
def handle(self, directive: str, data: Any) -> bool:
if directive != self._directive:
raise ValueError("Shell cannot handle directive %s" % directive)
msg = f"Shell cannot handle directive {directive}"
raise ValueError(msg)
return self._process_commands(data)
def _process_commands(self, data):
def _process_commands(self, data: Any) -> bool:
success = True
defaults = self._context.defaults().get("shell", {})
options = self._get_option_overrides()
@ -42,11 +45,11 @@ class Shell(Plugin):
msg = None
if quiet:
if msg is not None:
self._log.lowinfo("%s" % msg)
self._log.lowinfo(str(msg))
elif msg is None:
self._log.lowinfo(cmd)
else:
self._log.lowinfo("%s [%s]" % (msg, cmd))
self._log.lowinfo(f"{msg} [{cmd}]")
stdout = options.get("stdout", stdout)
stderr = options.get("stderr", stderr)
ret = shell_command(
@ -58,14 +61,14 @@ class Shell(Plugin):
)
if ret != 0:
success = False
self._log.warning("Command [%s] failed" % cmd)
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):
def _get_option_overrides(self) -> Dict[str, bool]:
ret = {}
options = self._context.options()
if options.verbose > 1:

View file

@ -0,0 +1,3 @@
from dotbot.util.common import shell_command
__all__ = ["shell_command"]

View file

@ -1,10 +1,18 @@
import os
import platform
import subprocess
from typing import Optional
def shell_command(command, cwd=None, enable_stdin=False, enable_stdout=False, enable_stderr=False):
with open(os.devnull, "w") as devnull_w, open(os.devnull, "r") as devnull_r:
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
@ -25,7 +33,7 @@ def shell_command(command, cwd=None, enable_stdin=False, enable_stdout=False, en
executable = None
return subprocess.call(
command,
shell=True,
shell=True, # noqa: S602
executable=executable,
stdin=stdin,
stdout=stdout,

View file

@ -1,5 +1,7 @@
import importlib.util
import os
import sys
from types import ModuleType
from typing import List, Type
from dotbot.plugin import Plugin
@ -7,7 +9,7 @@ from dotbot.plugin import Plugin
loaded_modules = []
def load(path):
def load(path: str) -> List[Type[Plugin]]:
basename = os.path.basename(path)
module_name, extension = os.path.splitext(basename)
loaded_module = load_module(module_name, path)
@ -23,11 +25,11 @@ def load(path):
return plugins
import importlib.util
def load_module(module_name, path):
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

View file

@ -1,9 +1,12 @@
from typing import Any
class Singleton(type):
def __call__(cls, *args, **kwargs):
def __call__(cls, *args: Any, **kwargs: Any) -> Any:
if not hasattr(cls, "_singleton_instance"):
cls._singleton_instance = super(Singleton, cls).__call__(*args, **kwargs)
cls._singleton_instance = super().__call__(*args, **kwargs)
return cls._singleton_instance
def reset_instance(cls):
def reset_instance(cls) -> None:
if hasattr(cls, "_singleton_instance"):
del cls._singleton_instance

View file

@ -0,0 +1,4 @@
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))}"

0
tests/__init__.py Normal file
View file

View file

@ -5,8 +5,9 @@ import os
import shutil
import sys
import tempfile
import unittest.mock as mock
from shutil import rmtree
from typing import Any, Callable, Generator, List, Optional
from unittest import mock
import pytest
import yaml
@ -14,11 +15,11 @@ import yaml
import dotbot.cli
def get_long_path(path):
def get_long_path(path: str) -> str:
"""Get the long path for a given path."""
# Do nothing for non-Windows platforms.
if sys.platform[:5] != "win32":
if sys.platform != "win32":
return path
buffer_size = 1000
@ -31,15 +32,14 @@ def get_long_path(path):
# 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 = []
allowed_tempfile_internal_unlink_calls: List[str] = []
def wrap_function(function, function_path, arg_index, kwarg_key, root):
def wrapper(*args, **kwargs):
if kwarg_key in kwargs:
value = kwargs[kwarg_key]
else:
value = args[arg_index]
def wrap_function(
function: Callable[..., Any], function_path: str, arg_index: int, kwarg_key: str, root: str
) -> Callable[..., Any]:
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:
@ -58,14 +58,11 @@ def wrap_function(function, function_path, arg_index, kwarg_key, root):
return wrapper
def wrap_open(root):
wrapped = getattr(builtins, "open")
def wrap_open(root: str) -> Callable[..., Any]:
wrapped = builtins.open
def wrapper(*args, **kwargs):
if "file" in kwargs:
value = kwargs["file"]
else:
value = args[0]
def wrapper(*args: Any, **kwargs: Any) -> Any:
value = kwargs["file"] if "file" in kwargs else args[0]
mode = "r"
if "mode" in kwargs:
@ -87,7 +84,7 @@ def wrap_open(root):
return wrapper
def rmtree_error_handler(_, path, __):
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):
@ -97,7 +94,7 @@ def rmtree_error_handler(_, path, __):
@pytest.fixture(autouse=True, scope="session")
def standardize_tmp():
def standardize_tmp() -> None:
r"""Standardize the temporary directory path.
On MacOS, `/var` is a symlink to `/private/var`.
@ -114,21 +111,21 @@ def standardize_tmp():
# MacOS: `/var` is a symlink.
tmp = os.path.abspath(os.path.realpath(tmp))
# Windows: The temporary directory may be a short path.
if sys.platform[:5] == "win32":
if sys.platform == "win32":
tmp = get_long_path(tmp)
os.environ["TMP"] = tmp
os.environ["TEMP"] = tmp
os.environ["TMPDIR"] = tmp
tempfile.tempdir = tmp
yield
@pytest.fixture(autouse=True)
def root(standardize_tmp):
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
global allowed_tempfile_internal_unlink_calls # noqa: PLW0603
allowed_tempfile_internal_unlink_calls = []
# Dotbot changes the current working directory,
@ -180,7 +177,7 @@ def root(standardize_tmp):
(shutil, "unpack_archive", 1, "extract_dir"),
]
patches = []
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):
@ -188,7 +185,7 @@ def root(standardize_tmp):
# These values must be passed to a separate function
# to ensure the variable closures work correctly.
function_path = "{0}.{1}".format(module.__name__, function_name)
function_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))
@ -200,13 +197,13 @@ def root(standardize_tmp):
# Block all access to bad functions.
if hasattr(os, "chroot"):
patches.append(mock.patch("os.chroot", lambda *_, **__: None))
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
mkstemp_inner = tempfile._mkstemp_inner # type: ignore # noqa: SLF001
def wrap_mkstemp_inner(*args, **kwargs):
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
@ -219,7 +216,8 @@ def root(standardize_tmp):
finally:
# Patches must be stopped in reverse order because some patches are nested.
# Stopping in the reverse order restores the original function.
[patch.stop() for patch in reversed(patches)]
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)
@ -228,7 +226,7 @@ def root(standardize_tmp):
@pytest.fixture
def home(monkeypatch, root):
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.
@ -237,43 +235,43 @@ def home(monkeypatch, root):
home = os.path.abspath(os.path.join(root, "home/user"))
os.makedirs(home)
if sys.platform[:5] == "win32":
if sys.platform == "win32":
monkeypatch.setenv("USERPROFILE", home)
else:
monkeypatch.setenv("HOME", home)
yield home
return home
class Dotfiles:
"""Create and manage a dotfiles directory for a test."""
def __init__(self, root):
def __init__(self, root: str):
self.root = root
self.config = None
self.config_filename = None
self._config_filename: Optional[str] = None
self.directory = os.path.join(root, "dotfiles")
os.mkdir(self.directory)
def makedirs(self, path):
def makedirs(self, path: str) -> None:
os.makedirs(os.path.abspath(os.path.join(self.directory, path)))
def write(self, path, content=""):
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, serializer="yaml", path=None):
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 = yaml.dump
serialize: Callable[[Any], str] = yaml.dump
else: # serializer == "json"
serialize = json.dumps
if path:
if path is not None:
msg = "The config file path must be an absolute path"
assert path == os.path.abspath(path), msg
@ -281,25 +279,30 @@ class Dotfiles:
msg = msg.format(root)
assert path[: len(str(root))] == str(root), msg
self.config_filename = path
self._config_filename = path
else:
self.config_filename = os.path.join(self.directory, "install.conf.yaml")
self._config_filename = os.path.join(self.directory, "install.conf.yaml")
self.config = config
with open(self.config_filename, "w") as file:
with open(self._config_filename, "w") as file:
file.write(serialize(config))
return self.config_filename
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):
def dotfiles(root: str) -> Dotfiles:
"""Create a dotfiles directory."""
yield Dotfiles(root)
return Dotfiles(root)
@pytest.fixture
def run_dotbot(dotfiles):
def run_dotbot(dotfiles: Dotfiles) -> Callable[..., None]:
"""Run dotbot.
When calling `runner()`, only CLI arguments need to be specified.
@ -309,11 +312,11 @@ def run_dotbot(dotfiles):
and the caller will be responsible for all CLI arguments.
"""
def runner(*argv, **kwargs):
argv = ["dotbot"] + list(argv)
def runner(*argv: Any, **kwargs: Any) -> None:
argv = ("dotbot", *argv)
if kwargs.get("custom", False) is not True:
argv.extend(["-c", dotfiles.config_filename])
with mock.patch("sys.argv", argv):
argv = (*argv, "-c", dotfiles.config_filename)
with mock.patch("sys.argv", list(argv)):
dotbot.cli.main()
yield runner
return runner

View file

@ -1,15 +1,20 @@
# 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):
def can_handle(self, directive: str) -> bool:
return directive == "dispatch"
def handle(self, directive, data):
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(),

View file

@ -5,21 +5,23 @@ 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):
def can_handle(self, directive: str) -> bool:
return directive == "plugin_directory"
def handle(self, directive, data):
def handle(self, directive: str, _data: Any) -> bool:
if directive != "plugin_directory":
msg = f"Directory cannot handle directive {directive}"
raise ValueError(msg)
self._log.debug("Attempting to get options from Context")
options = self._context.options()
if len(options.plugin_dirs) != 1:
self._log.debug(
"Context.options.plugins length is %i, expected 1" % len(options.plugins)
)
self._log.debug("Context.options.plugins length is %i, expected 1" % len(options.plugins))
return False
with open(os.path.abspath(os.path.expanduser("~/flag")), "w") as file:

View file

@ -2,15 +2,20 @@
# 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):
def can_handle(self, directive: str) -> bool:
return directive == "dispatch"
def handle(self, directive, data):
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(),

View file

@ -5,26 +5,26 @@ 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):
def can_handle(self, directive: str) -> bool:
return directive == "plugin_file"
def handle(self, directive, data):
def handle(self, directive: str, _data: Any) -> bool:
if directive != "plugin_file":
msg = f"File cannot handle directive {directive}"
raise ValueError(msg)
self._log.debug("Attempting to get options from Context")
options = self._context.options()
if len(options.plugins) != 1:
self._log.debug(
"Context.options.plugins length is %i, expected 1" % len(options.plugins)
)
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(
"Context.options.plugins[0] is %s, expected end with file.py" % options.plugins[0]
)
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")), "w") as file:

View file

@ -1,7 +1,6 @@
import os
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
@ -10,8 +9,11 @@ from dotbot.plugins import Clean, Create, Link, Shell
class NoopPlugin(Plugin):
_directive = "noop"
def can_handle(self, directive):
def can_handle(self, directive: str) -> bool:
return directive == self._directive
def handle(self, directive, data):
def handle(self, directive: str, _data: Any) -> bool:
if directive != self._directive:
msg = f"NoopPlugin cannot handle directive {directive}"
raise ValueError(msg)
return True

View file

@ -1,30 +1,32 @@
import os
import shutil
import subprocess
from typing import Optional
import pytest
from tests.conftest import Dotfiles
@pytest.mark.skipif(
"sys.platform[:5] == 'win32'",
"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, home, dotfiles):
@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"
)
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:
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")
@ -32,7 +34,7 @@ def test_find_python_executable(python_name, home, dotfiles):
env = dict(os.environ)
env["PATH"] = tmp_bin
if python_name:
if python_name is not None:
subprocess.check_call(
[dotbot_executable, "-c", dotfiles.config_filename],
env=env,

View file

@ -1,10 +1,11 @@
import os
import sys
from typing import Callable
import pytest
from tests.conftest import Dotfiles
def test_clean_default(root, home, dotfiles, run_dotbot):
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"))
@ -24,12 +25,12 @@ def test_clean_default(root, home, dotfiles, run_dotbot):
assert os.path.islink(os.path.join(home, ".g"))
def test_clean_environment_variable_expansion(home, dotfiles, run_dotbot):
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[:5] == "win32":
if sys.platform == "win32":
variable = "$USERPROFILE"
dotfiles.write_config([{"clean": [variable]}])
run_dotbot()
@ -37,7 +38,7 @@ def test_clean_environment_variable_expansion(home, dotfiles, run_dotbot):
assert not os.path.islink(os.path.join(home, ".f"))
def test_clean_missing(home, dotfiles, run_dotbot):
def test_clean_missing(home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None:
"""Verify clean deletes links to missing files."""
dotfiles.write("f")
@ -50,7 +51,7 @@ def test_clean_missing(home, dotfiles, run_dotbot):
assert not os.path.islink(os.path.join(home, ".g"))
def test_clean_nonexistent(home, dotfiles, run_dotbot):
def test_clean_nonexistent(home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None:
"""Verify clean ignores nonexistent directories."""
dotfiles.write_config([{"clean": ["~", "~/fake"]}])
@ -59,7 +60,7 @@ def test_clean_nonexistent(home, dotfiles, run_dotbot):
assert not os.path.isdir(os.path.join(home, "fake"))
def test_clean_outside_force(root, home, dotfiles, run_dotbot):
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"))
@ -69,7 +70,7 @@ def test_clean_outside_force(root, home, dotfiles, run_dotbot):
assert not os.path.islink(os.path.join(home, ".g"))
def test_clean_outside(root, home, dotfiles, run_dotbot):
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"))
@ -81,7 +82,7 @@ def test_clean_outside(root, home, dotfiles, run_dotbot):
assert os.path.islink(os.path.join(home, ".g"))
def test_clean_recursive_1(root, home, dotfiles, run_dotbot):
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"))
@ -96,7 +97,7 @@ def test_clean_recursive_1(root, home, dotfiles, run_dotbot):
assert os.path.islink(os.path.join(home, "a", "b", "e"))
def test_clean_recursive_2(root, home, dotfiles, run_dotbot):
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"))
@ -111,7 +112,7 @@ def test_clean_recursive_2(root, home, dotfiles, run_dotbot):
assert not os.path.islink(os.path.join(home, "a", "b", "e"))
def test_clean_defaults_1(root, home, dotfiles, run_dotbot):
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"))
@ -121,7 +122,7 @@ def test_clean_defaults_1(root, home, dotfiles, run_dotbot):
assert os.path.islink(os.path.join(home, ".g"))
def test_clean_defaults_2(root, home, dotfiles, run_dotbot):
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"))

View file

@ -1,10 +1,15 @@
import os
import shutil
from typing import Callable
import pytest
from tests.conftest import Dotfiles
def test_except_create(capfd, home, dotfiles, run_dotbot):
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(
@ -24,7 +29,9 @@ def test_except_create(capfd, home, dotfiles, run_dotbot):
assert any(line.startswith("success") for line in stdout)
def test_except_shell(capfd, home, dotfiles, run_dotbot):
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(
@ -44,7 +51,9 @@ def test_except_shell(capfd, home, dotfiles, run_dotbot):
assert not any(line.startswith("failure") for line in stdout)
def test_except_multiples(capfd, home, dotfiles, run_dotbot):
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(
@ -64,7 +73,7 @@ def test_except_multiples(capfd, home, dotfiles, run_dotbot):
assert not any(line.startswith("failure") for line in stdout)
def test_exit_on_failure(capfd, home, dotfiles, run_dotbot):
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(
@ -81,7 +90,9 @@ def test_exit_on_failure(capfd, home, dotfiles, run_dotbot):
assert not os.path.isdir(os.path.join(home, "b"))
def test_only(capfd, home, dotfiles, run_dotbot):
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(
@ -97,7 +108,9 @@ def test_only(capfd, home, dotfiles, run_dotbot):
assert any(line.startswith("success") for line in stdout)
def test_only_with_defaults(capfd, home, dotfiles, run_dotbot):
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(
@ -114,7 +127,9 @@ def test_only_with_defaults(capfd, home, dotfiles, run_dotbot):
assert any(line.startswith("success") for line in stdout)
def test_only_with_multiples(capfd, home, dotfiles, run_dotbot):
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(
@ -132,7 +147,7 @@ def test_only_with_multiples(capfd, home, dotfiles, run_dotbot):
assert not os.path.exists(os.path.join(home, ".f"))
def test_plugin_loading_file(home, dotfiles, run_dotbot):
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")
@ -140,41 +155,39 @@ def test_plugin_loading_file(home, dotfiles, run_dotbot):
dotfiles.write_config([{"plugin_file": "~"}])
run_dotbot("--plugin", os.path.join(dotfiles.directory, "file.py"))
with open(os.path.join(home, "flag"), "r") as file:
with open(os.path.join(home, "flag")) as file:
assert file.read() == "file plugin loading works"
def test_plugin_loading_directory(home, dotfiles, run_dotbot):
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"
)
plugin_file = os.path.join(os.path.dirname(os.path.abspath(__file__)), "dotbot_plugin_directory.py")
shutil.copy(plugin_file, os.path.join(dotfiles.directory, "plugins", "directory.py"))
dotfiles.write_config([{"plugin_directory": "~"}])
run_dotbot("--plugin-dir", os.path.join(dotfiles.directory, "plugins"))
with open(os.path.join(home, "flag"), "r") as file:
with open(os.path.join(home, "flag")) as file:
assert file.read() == "directory plugin loading works"
def test_issue_357(capfd, home, dotfiles, run_dotbot):
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."""
plugin_file = os.path.join(
os.path.dirname(os.path.abspath(__file__)), "dotbot_plugin_issue_357.py"
)
_ = 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
)
assert len([line for line in capfd.readouterr().out.splitlines() if line.strip() == "apple"]) == 1
def test_disable_builtin_plugins(home, dotfiles, run_dotbot):
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")
@ -187,12 +200,13 @@ def test_disable_builtin_plugins(home, dotfiles, run_dotbot):
assert not os.path.exists(os.path.join(home, ".f"))
def test_plugin_context_plugin(capfd, home, dotfiles, run_dotbot):
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."""
plugin_file = os.path.join(
os.path.dirname(os.path.abspath(__file__)), "dotbot_plugin_context_plugin.py"
)
_ = 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"))
@ -201,12 +215,13 @@ def test_plugin_context_plugin(capfd, home, dotfiles, run_dotbot):
assert any(line.startswith("apple") for line in stdout)
def test_plugin_dispatcher_no_plugins(capfd, home, dotfiles, run_dotbot):
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."""
plugin_file = os.path.join(
os.path.dirname(os.path.abspath(__file__)), "dotbot_plugin_dispatcher_no_plugins.py"
)
_ = 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"))

View file

@ -1,22 +1,25 @@
import json
import os
from typing import Callable
from tests.conftest import Dotfiles
def test_config_blank(dotfiles, run_dotbot):
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, 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, dotfiles, run_dotbot):
def test_json(home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None:
"""Verify JSON configs work."""
document = json.dumps([{"create": ["~/d"]}])
@ -26,7 +29,7 @@ def test_json(home, dotfiles, run_dotbot):
assert os.path.isdir(os.path.join(home, "d"))
def test_json_tabs(home, dotfiles, run_dotbot):
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]"""

View file

@ -1,13 +1,17 @@
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, directory, dotfiles, run_dotbot):
@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()
@ -16,13 +20,14 @@ def test_directory_creation(home, directory, dotfiles, run_dotbot):
assert os.stat(expanded_directory).st_mode & 0o777 == 0o777
def test_default_mode(home, dotfiles, run_dotbot):
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)
@ -34,13 +39,14 @@ def test_default_mode(home, dotfiles, run_dotbot):
assert os.stat(directory).st_mode & stat.S_IWOTH == 0
def test_default_mode_override(home, dotfiles, run_dotbot):
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}}},

View file

@ -1,10 +1,13 @@
import os
import sys
from typing import Callable, Optional
import pytest
from tests.conftest import Dotfiles
def test_link_canonicalization(home, dotfiles, run_dotbot):
def test_link_canonicalization(home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None:
"""Verify links to symlinked destinations are canonical.
"Canonical", here, means that dotbot does not create symlinks
@ -22,20 +25,27 @@ def test_link_canonicalization(home, dotfiles, run_dotbot):
expected = os.path.join(dotfiles.directory, "f")
actual = os.readlink(os.path.abspath(os.path.expanduser("~/.f")))
if sys.platform[:5] == "win32" and actual.startswith("\\\\?\\"):
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_source(root, home, dst, include_force, dotfiles, run_dotbot):
@pytest.mark.parametrize("dst", ["~/.f", "~/f"])
@pytest.mark.parametrize("include_force", [True, False])
def test_link_default_source(
dst: str,
include_force: bool, # noqa: FBT001
home: str,
dotfiles: Dotfiles,
run_dotbot: Callable[..., None],
) -> None:
"""Verify that default sources are calculated correctly.
This test includes verifying files with and without leading periods,
as well as verifying handling of None dict values.
"""
_ = home
dotfiles.write("f", "apple")
config = [
{
@ -47,13 +57,14 @@ def test_link_default_source(root, home, dst, include_force, dotfiles, run_dotbo
dotfiles.write_config(config)
run_dotbot()
with open(os.path.abspath(os.path.expanduser(dst)), "r") as file:
with open(os.path.abspath(os.path.expanduser(dst))) as file:
assert file.read() == "apple"
def test_link_environment_user_expansion_target(home, dotfiles, run_dotbot):
def test_link_environment_user_expansion_target(home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None:
"""Verify link expands user in target."""
_ = home
src = "~/f"
target = "~/g"
with open(os.path.abspath(os.path.expanduser(src)), "w") as file:
@ -61,13 +72,16 @@ def test_link_environment_user_expansion_target(home, dotfiles, run_dotbot):
dotfiles.write_config([{"link": {target: src}}])
run_dotbot()
with open(os.path.abspath(os.path.expanduser(target)), "r") as file:
with open(os.path.abspath(os.path.expanduser(target))) as file:
assert file.read() == "apple"
def test_link_environment_variable_expansion_source(monkeypatch, root, home, dotfiles, run_dotbot):
def test_link_environment_variable_expansion_source(
monkeypatch: pytest.MonkeyPatch, home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]
) -> None:
"""Verify link expands environment variables in source."""
_ = home
monkeypatch.setenv("APPLE", "h")
target = "~/.i"
src = "$APPLE"
@ -75,15 +89,16 @@ def test_link_environment_variable_expansion_source(monkeypatch, root, home, dot
dotfiles.write_config([{"link": {target: src}}])
run_dotbot()
with open(os.path.abspath(os.path.expanduser(target)), "r") as file:
with open(os.path.abspath(os.path.expanduser(target))) as file:
assert file.read() == "grape"
def test_link_environment_variable_expansion_source_extended(
monkeypatch, root, home, dotfiles, run_dotbot
):
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")
target = "~/.i"
src = "$APPLE"
@ -91,11 +106,13 @@ def test_link_environment_variable_expansion_source_extended(
dotfiles.write_config([{"link": {target: {"path": src, "relink": True}}}])
run_dotbot()
with open(os.path.abspath(os.path.expanduser(target)), "r") as file:
with open(os.path.abspath(os.path.expanduser(target))) as file:
assert file.read() == "grape"
def test_link_environment_variable_expansion_target(monkeypatch, root, home, dotfiles, run_dotbot):
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.
If the variable doesn't exist, the "variable" must not be replaced.
@ -122,13 +139,15 @@ def test_link_environment_variable_expansion_target(monkeypatch, root, home, dot
dotfiles.write_config(config)
run_dotbot()
with open(os.path.join(home, ".config", "g"), "r") as file:
with open(os.path.join(home, ".config", "g")) as file:
assert file.read() == "apple"
with open(os.path.join(home, "$PEAR"), "r") as file:
with open(os.path.join(home, "$PEAR")) as file:
assert file.read() == "grape"
def test_link_environment_variable_unset(monkeypatch, root, home, dotfiles, run_dotbot):
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)
@ -136,11 +155,11 @@ def test_link_environment_variable_unset(monkeypatch, root, home, dotfiles, run_
dotfiles.write_config([{"link": {"~/f": "$ORANGE"}}])
run_dotbot()
with open(os.path.join(home, "f"), "r") as file:
with open(os.path.join(home, "f")) as file:
assert file.read() == "apple"
def test_link_force_leaves_when_nonexistent(root, home, dotfiles, run_dotbot):
def test_link_force_leaves_when_nonexistent(home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None:
"""Verify force doesn't erase sources when targets are nonexistent."""
os.mkdir(os.path.join(home, "dir"))
@ -161,7 +180,7 @@ def test_link_force_leaves_when_nonexistent(root, home, dotfiles, run_dotbot):
assert os.path.isfile(os.path.join(home, "file"))
def test_link_force_overwrite_symlink(home, dotfiles, run_dotbot):
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"))
@ -175,7 +194,7 @@ def test_link_force_overwrite_symlink(home, dotfiles, run_dotbot):
assert os.path.isfile(os.path.join(home, ".dir", "f"))
def test_link_glob_1(home, dotfiles, run_dotbot):
def test_link_glob_1(home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None:
"""Verify globbing works."""
dotfiles.write("bin/a", "apple")
@ -197,7 +216,7 @@ def test_link_glob_1(home, dotfiles, run_dotbot):
assert file.read() == "cherry"
def test_link_glob_2(home, dotfiles, run_dotbot):
def test_link_glob_2(home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None:
"""Verify globbing works with a trailing slash in the source."""
dotfiles.write("bin/a", "apple")
@ -219,7 +238,7 @@ def test_link_glob_2(home, dotfiles, run_dotbot):
assert file.read() == "cherry"
def test_link_glob_3(home, dotfiles, run_dotbot):
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")
@ -241,7 +260,7 @@ def test_link_glob_3(home, dotfiles, run_dotbot):
assert file.read() == "dot-cherry"
def test_link_glob_4(home, dotfiles, run_dotbot):
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")
@ -269,8 +288,10 @@ def test_link_glob_4(home, dotfiles, run_dotbot):
assert file.read() == "dot-cherry"
@pytest.mark.parametrize("path", ("foo", "foo/"))
def test_link_glob_ignore_no_glob_chars(path, home, dotfiles, run_dotbot):
@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")
@ -291,7 +312,7 @@ def test_link_glob_ignore_no_glob_chars(path, home, dotfiles, run_dotbot):
assert os.path.exists(os.path.join(home, "foo"))
def test_link_glob_exclude_1(home, dotfiles, run_dotbot):
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")
@ -333,7 +354,7 @@ def test_link_glob_exclude_1(home, dotfiles, run_dotbot):
assert file.read() == "cherry"
def test_link_glob_exclude_2(home, dotfiles, run_dotbot):
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")
@ -377,7 +398,7 @@ def test_link_glob_exclude_2(home, dotfiles, run_dotbot):
assert file.read() == "cherry"
def test_link_glob_exclude_3(home, dotfiles, run_dotbot):
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")
@ -428,7 +449,7 @@ def test_link_glob_exclude_3(home, dotfiles, run_dotbot):
assert file.read() == "grape"
def test_link_glob_exclude_4(home, dotfiles, run_dotbot):
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")
@ -475,7 +496,7 @@ def test_link_glob_exclude_4(home, dotfiles, run_dotbot):
assert file.read() == "cherry"
def test_link_glob_multi_star(home, dotfiles, run_dotbot):
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")
@ -502,21 +523,28 @@ def test_link_glob_multi_star(home, dotfiles, run_dotbot):
@pytest.mark.parametrize(
"pattern, expect_file",
(
("pattern", "expect_file"),
[
("conf/*", lambda fruit: fruit),
("conf/.*", lambda fruit: "." + fruit),
("conf/[bc]*", lambda fruit: fruit if fruit[0] in "bc" else None),
("conf/*e", lambda fruit: fruit if fruit[-1] == "e" else None),
("conf/??r*", lambda fruit: fruit if fruit[2] == "r" else None),
),
],
)
def test_link_glob_patterns(pattern, expect_file, home, dotfiles, run_dotbot):
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"]
[dotfiles.write("conf/" + fruit, fruit) for fruit in fruits]
[dotfiles.write("conf/." + fruit, "dot-" + fruit) for fruit in fruits]
for fruit in fruits:
dotfiles.write("conf/" + fruit, fruit)
dotfiles.write("conf/." + fruit, "dot-" + fruit)
dotfiles.write_config(
[
{"defaults": {"link": {"glob": True, "create": True}}},
@ -526,18 +554,19 @@ def test_link_glob_patterns(pattern, expect_file, home, dotfiles, run_dotbot):
run_dotbot()
for fruit in fruits:
if expect_file(fruit) is None:
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 expect_file(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 expect_file(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, dotfiles, run_dotbot):
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")
@ -563,9 +592,10 @@ def test_link_glob_recursive(home, dotfiles, run_dotbot):
assert file.read() == "cherry"
def test_link_glob_no_match(home, dotfiles, run_dotbot):
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(
[
@ -576,7 +606,7 @@ def test_link_glob_no_match(home, dotfiles, run_dotbot):
run_dotbot()
def test_link_glob_single_match(home, dotfiles, 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
@ -597,10 +627,10 @@ def test_link_glob_single_match(home, dotfiles, run_dotbot):
@pytest.mark.skipif(
"sys.platform[:5] == 'win32'",
"sys.platform == 'win32'",
reason="These if commands won't run on Windows",
)
def test_link_if(home, dotfiles, run_dotbot):
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"))
@ -628,10 +658,10 @@ def test_link_if(home, dotfiles, run_dotbot):
@pytest.mark.skipif(
"sys.platform[:5] == 'win32'",
"sys.platform == 'win32'",
reason="These if commands won't run on Windows.",
)
def test_link_if_defaults(home, dotfiles, run_dotbot):
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"))
@ -661,10 +691,10 @@ def test_link_if_defaults(home, dotfiles, run_dotbot):
@pytest.mark.skipif(
"sys.platform[:5] != 'win32'",
"sys.platform != 'win32'",
reason="These if commands only run on Windows.",
)
def test_link_if_windows(home, dotfiles, run_dotbot):
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"))
@ -692,10 +722,10 @@ def test_link_if_windows(home, dotfiles, run_dotbot):
@pytest.mark.skipif(
"sys.platform[:5] != 'win32'",
reason="These if commands only run on Windows",
"sys.platform != 'win32'",
reason="These if commands only run on Windows.",
)
def test_link_if_defaults_windows(home, dotfiles, run_dotbot):
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"))
@ -724,8 +754,13 @@ def test_link_if_defaults_windows(home, dotfiles, run_dotbot):
assert file.read() == "apple"
@pytest.mark.parametrize("ignore_missing", (True, False))
def test_link_ignore_missing(ignore_missing, home, dotfiles, run_dotbot):
@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(
@ -749,7 +784,7 @@ def test_link_ignore_missing(ignore_missing, home, dotfiles, run_dotbot):
run_dotbot()
def test_link_leaves_file(home, dotfiles, 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")
@ -759,12 +794,12 @@ def test_link_leaves_file(home, dotfiles, run_dotbot):
with pytest.raises(SystemExit):
run_dotbot()
with open(os.path.join(home, ".f"), "r") as file:
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, home, dotfiles, run_dotbot):
@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")
@ -782,7 +817,7 @@ def test_link_no_canonicalize(key, home, dotfiles, run_dotbot):
assert "dotfiles-symlink" in os.readlink(os.path.join(home, ".f"))
def test_link_prefix(home, dotfiles, run_dotbot):
def test_link_prefix(home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None:
"""Verify link prefixes are prepended."""
dotfiles.write("conf/a", "apple")
@ -810,7 +845,7 @@ def test_link_prefix(home, dotfiles, run_dotbot):
assert file.read() == "cherry"
def test_link_relative(home, dotfiles, run_dotbot):
def test_link_relative(home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None:
"""Test relative linking works."""
dotfiles.write("f", "apple")
@ -842,22 +877,22 @@ def test_link_relative(home, dotfiles, run_dotbot):
run_dotbot()
f = os.readlink(os.path.join(home, ".f"))
if sys.platform[:5] == "win32" and f.startswith("\\\\?\\"):
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[:5] == "win32" and frel.startswith("\\\\?\\"):
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[:5] == "win32" and nested_frel.startswith("\\\\?\\"):
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[:5] == "win32" and d.startswith("\\\\?\\"):
if sys.platform == "win32" and d.startswith("\\\\?\\"):
d = d[4:]
assert d == os.path.normpath("../../dotfiles/d")
@ -871,7 +906,7 @@ def test_link_relative(home, dotfiles, run_dotbot):
assert file.read() == "grape"
def test_link_relink_leaves_file(home, dotfiles, run_dotbot):
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")
@ -880,11 +915,11 @@ def test_link_relink_leaves_file(home, dotfiles, run_dotbot):
dotfiles.write_config([{"link": {"~/.f": {"path": "f", "relink": True}}}])
with pytest.raises(SystemExit):
run_dotbot()
with open(os.path.join(home, ".f"), "r") as file:
with open(os.path.join(home, ".f")) as file:
assert file.read() == "grape"
def test_link_relink_overwrite_symlink(home, dotfiles, run_dotbot):
def test_link_relink_overwrite_symlink(home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None:
"""Verify relink overwrites symlinks."""
dotfiles.write("f", "apple")
@ -893,11 +928,11 @@ def test_link_relink_overwrite_symlink(home, dotfiles, run_dotbot):
os.symlink(os.path.join(home, "f"), os.path.join(home, ".f"))
dotfiles.write_config([{"link": {"~/.f": {"path": "f", "relink": True}}}])
run_dotbot()
with open(os.path.join(home, ".f"), "r") as file:
with open(os.path.join(home, ".f")) as file:
assert file.read() == "apple"
def test_link_relink_relative_leaves_file(home, dotfiles, run_dotbot):
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")
@ -927,7 +962,7 @@ def test_link_relink_relative_leaves_file(home, dotfiles, run_dotbot):
assert mtime == new_mtime
def test_link_defaults_1(home, dotfiles, run_dotbot):
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:
@ -944,11 +979,11 @@ def test_link_defaults_1(home, dotfiles, run_dotbot):
with pytest.raises(SystemExit):
run_dotbot()
with open(os.path.join(home, ".f"), "r") as file:
with open(os.path.join(home, ".f")) as file:
assert file.read() == "grape"
def test_link_defaults_2(home, dotfiles, run_dotbot):
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:
@ -963,5 +998,5 @@ def test_link_defaults_2(home, dotfiles, run_dotbot):
)
run_dotbot()
with open(os.path.join(home, ".f"), "r") as file:
with open(os.path.join(home, ".f")) as file:
assert file.read() == "apple"

View file

@ -3,20 +3,20 @@ import os
import pytest
def test_success(root):
def test_success(root: str) -> None:
path = os.path.join(root, "abc.txt")
with open(path, "wt") as f:
with open(path, "w") as f:
f.write("hello")
with open(path, "rt") as f:
with open(path) as f:
assert f.read() == "hello"
def test_failure():
def test_failure() -> None:
with pytest.raises(AssertionError):
open("abc.txt", "w")
open("abc.txt", "w") # noqa: SIM115
with pytest.raises(AssertionError):
open(file="abc.txt", mode="w")
open(file="abc.txt", mode="w") # noqa: SIM115
with pytest.raises(AssertionError):
os.mkdir("a")

View file

@ -1,4 +1,13 @@
def test_shell_allow_stdout(capfd, dotfiles, run_dotbot):
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(
@ -16,10 +25,12 @@ def test_shell_allow_stdout(capfd, dotfiles, run_dotbot):
run_dotbot()
output = capfd.readouterr()
assert any([line.startswith("apple") for line in output.out.splitlines()]), output
assert any(line.startswith("apple") for line in output.out.splitlines()), output
def test_shell_cli_verbosity_overrides_1(capfd, dotfiles, run_dotbot):
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"}]}])
@ -29,7 +40,9 @@ def test_shell_cli_verbosity_overrides_1(capfd, dotfiles, run_dotbot):
assert any(line.startswith("apple") for line in lines)
def test_shell_cli_verbosity_overrides_2(capfd, dotfiles, run_dotbot):
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}]}])
@ -39,7 +52,9 @@ def test_shell_cli_verbosity_overrides_2(capfd, dotfiles, run_dotbot):
assert any(line.startswith("apple") for line in lines)
def test_shell_cli_verbosity_overrides_3(capfd, dotfiles, run_dotbot):
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(
@ -54,7 +69,9 @@ def test_shell_cli_verbosity_overrides_3(capfd, dotfiles, run_dotbot):
assert any(line.startswith("apple") for line in stdout)
def test_shell_cli_verbosity_stderr(capfd, dotfiles, run_dotbot):
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"}]}])
@ -64,7 +81,9 @@ def test_shell_cli_verbosity_stderr(capfd, dotfiles, run_dotbot):
assert any(line.startswith("apple") for line in stderr)
def test_shell_cli_verbosity_stderr_with_explicit_stdout_off(capfd, dotfiles, run_dotbot):
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(
@ -85,7 +104,9 @@ def test_shell_cli_verbosity_stderr_with_explicit_stdout_off(capfd, dotfiles, ru
assert any(line.startswith("apple") for line in stderr)
def test_shell_cli_verbosity_stderr_with_defaults_stdout_off(capfd, dotfiles, run_dotbot):
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(
@ -110,7 +131,9 @@ def test_shell_cli_verbosity_stderr_with_defaults_stdout_off(capfd, dotfiles, ru
assert any(line.startswith("apple") for line in stderr)
def test_shell_single_v_verbosity_stdout(capfd, dotfiles, run_dotbot):
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"}]}])
@ -120,7 +143,9 @@ def test_shell_single_v_verbosity_stdout(capfd, dotfiles, run_dotbot):
assert not any(line.startswith("apple") for line in stdout)
def test_shell_single_v_verbosity_stderr(capfd, dotfiles, run_dotbot):
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"}]}])
@ -130,7 +155,9 @@ def test_shell_single_v_verbosity_stderr(capfd, dotfiles, run_dotbot):
assert not any(line.startswith("apple") for line in stderr)
def test_shell_compact_stdout_1(capfd, dotfiles, run_dotbot):
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(
@ -145,7 +172,9 @@ def test_shell_compact_stdout_1(capfd, dotfiles, run_dotbot):
assert any(line.startswith("apple") for line in stdout)
def test_shell_compact_stdout_2(capfd, dotfiles, run_dotbot):
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(
@ -161,7 +190,9 @@ def test_shell_compact_stdout_2(capfd, dotfiles, run_dotbot):
assert any(line.startswith("echoing message") for line in stdout)
def test_shell_stdout_disabled_by_default(capfd, dotfiles, run_dotbot):
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(
@ -177,7 +208,9 @@ def test_shell_stdout_disabled_by_default(capfd, dotfiles, run_dotbot):
assert not any(line.startswith("banana") for line in stdout)
def test_shell_can_override_defaults(capfd, dotfiles, run_dotbot):
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(
@ -192,7 +225,9 @@ def test_shell_can_override_defaults(capfd, dotfiles, run_dotbot):
assert not any(line.startswith("apple") for line in stdout)
def test_shell_quiet_default(capfd, dotfiles, run_dotbot):
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(
@ -215,7 +250,9 @@ def test_shell_quiet_default(capfd, dotfiles, run_dotbot):
assert any(line.startswith("echoing a thing...") for line in stdout)
def test_shell_quiet_enabled_with_description(capfd, dotfiles, run_dotbot):
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(
@ -239,7 +276,9 @@ def test_shell_quiet_enabled_with_description(capfd, dotfiles, run_dotbot):
assert any(line.startswith("echoing a thing...") for line in stdout)
def test_shell_quiet_enabled_without_description(capfd, dotfiles, run_dotbot):
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(

View file

@ -5,8 +5,10 @@ import sys
import pytest
from tests.conftest import Dotfiles
def test_shim(root, home, dotfiles, run_dotbot):
def test_shim(home: str, dotfiles: Dotfiles) -> None:
"""Verify install shim works."""
# Skip the test if git is unavailable.
@ -14,10 +16,8 @@ def test_shim(root, home, dotfiles, run_dotbot):
if git is None:
pytest.skip("git is unavailable")
if sys.platform[:5] == "win32":
install = os.path.join(
dotfiles.directory, "dotbot", "tools", "git-submodule", "install.ps1"
)
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")
@ -27,17 +27,17 @@ def test_shim(root, home, dotfiles, run_dotbot):
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"]
)
subprocess.check_call([git, "-c", "protocol.file.allow=always", "submodule", "add", git_directory, "dotbot"])
shutil.copy(install, shim)
dotfiles.write("foo", "pear")
dotfiles.write_config([{"link": {"~/.foo": "foo"}}])
# Run the shim script.
env = dict(os.environ)
if sys.platform[:5] == "win32":
args = [shutil.which("powershell"), "-ExecutionPolicy", "RemoteSigned", shim]
if sys.platform == "win32":
ps = shutil.which("powershell")
assert ps is not None
args = [ps, "-ExecutionPolicy", "RemoteSigned", shim]
env["USERPROFILE"] = home
else:
args = [shim]
@ -45,5 +45,5 @@ def test_shim(root, home, dotfiles, run_dotbot):
subprocess.check_call(args, env=env, cwd=dotfiles.directory)
assert os.path.islink(os.path.join(home, ".foo"))
with open(os.path.join(home, ".foo"), "r") as file:
with open(os.path.join(home, ".foo")) as file:
assert file.read() == "pear"

78
tox.ini
View file

@ -1,78 +0,0 @@
[tox]
; On Windows, only CPython >= 3.8 is supported.
; All older versions, and PyPy, lack full symlink support.
envlist =
coverage_erase
py{38, 39, 310, 311, 312, 313}-all_platforms
py{36, 37}-most_platforms
pypy{39, 310}-most_platforms
coverage_report
skip_missing_interpreters = true
[testenv]
platform =
all_platforms: cygwin|darwin|linux|win32
most_platforms: cygwin|darwin|linux
deps =
coverage
pytest
pytest-randomly
pyyaml
commands =
coverage run -m pytest tests/
[testenv:coverage_erase]
skipsdist = true
skip_install = true
deps = coverage
commands = coverage erase
[testenv:coverage_report]
skipsdist = true
skip_install = true
deps = coverage
commands_pre =
coverage combine
commands =
coverage report
coverage html
coverage xml
[coverage:run]
branch = true
parallel = true
source =
dotbot/
tests/
[coverage:html]
directory = htmlcov
[gh-actions]
python =
; Run on all platforms (Linux, Mac, and Windows)
3.8: py38-all_platforms
3.9: py39-all_platforms
3.10: py310-all_platforms
3.11: py311-all_platforms
3.12: py312-all_platforms
3.13: py313-all_platforms
; Run on most platforms (Linux and Mac)
pypy-3.9: pypy39-most_platforms
pypy-3.10: pypy310-most_platforms
3.6: py36-most_platforms
3.7: py37-most_platforms
; Disable problem matcher because it causes issues when running in a container;
; see https://github.com/ymyzk/tox-gh-actions/issues/126
problem_matcher = False