diff --git a/.editorconfig b/.editorconfig index 624bc01..0a0b417 100644 --- a/.editorconfig +++ b/.editorconfig @@ -9,9 +9,3 @@ trim_trailing_whitespace = true [*.py] indent_size = 4 - -[*.bash] -indent_size = 4 - -[*.yml] -indent_size = 2 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e551f16..4df41e2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/.gitignore b/.gitignore index 67d54b2..4a69670 100644 --- a/.gitignore +++ b/.gitignore @@ -1,11 +1,3 @@ -*.egg-info *.pyc -.coverage* -.eggs/ -.idea/ -.tox/ -.venv/ -build/ -coverage.xml dist/ -htmlcov/ +.coverage* diff --git a/CHANGELOG.md b/CHANGELOG.md index 62bd51d..487c101 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f4a3840..0854adb 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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 diff --git a/README.md b/README.md index 3c2aa17..f8108a6 100644 --- a/README.md +++ b/README.md @@ -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! diff --git a/bin/dotbot b/bin/dotbot index 5042b4b..db651a1 100755 --- a/bin/dotbot +++ b/bin/dotbot @@ -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() diff --git a/dotbot/__init__.py b/dotbot/__init__.py deleted file mode 100644 index 3c4cd39..0000000 --- a/dotbot/__init__.py +++ /dev/null @@ -1,4 +0,0 @@ -from .cli import main -from .plugin import Plugin - -__version__ = "1.20.4" diff --git a/dotbot/__main__.py b/dotbot/__main__.py deleted file mode 100644 index 9ae637f..0000000 --- a/dotbot/__main__.py +++ /dev/null @@ -1,4 +0,0 @@ -from .cli import main - -if __name__ == "__main__": - main() diff --git a/dotbot/context.py b/dotbot/context.py deleted file mode 100644 index 31e93c9..0000000 --- a/dotbot/context.py +++ /dev/null @@ -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) diff --git a/dotbot/messenger/__init__.py b/dotbot/messenger/__init__.py deleted file mode 100644 index 394ebb3..0000000 --- a/dotbot/messenger/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -from .level import Level -from .messenger import Messenger diff --git a/dotbot/messenger/level.py b/dotbot/messenger/level.py deleted file mode 100644 index b5ca195..0000000 --- a/dotbot/messenger/level.py +++ /dev/null @@ -1,7 +0,0 @@ -class Level: - NOTSET = 0 - DEBUG = 10 - LOWINFO = 15 - INFO = 20 - WARNING = 30 - ERROR = 40 diff --git a/dotbot/messenger/messenger.py b/dotbot/messenger/messenger.py deleted file mode 100644 index 364b585..0000000 --- a/dotbot/messenger/messenger.py +++ /dev/null @@ -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 diff --git a/dotbot/plugins/__init__.py b/dotbot/plugins/__init__.py deleted file mode 100644 index f75bef5..0000000 --- a/dotbot/plugins/__init__.py +++ /dev/null @@ -1,4 +0,0 @@ -from .clean import Clean -from .create import Create -from .link import Link -from .shell import Shell diff --git a/dotbot/util/__init__.py b/dotbot/util/__init__.py deleted file mode 100644 index 0c5a8f5..0000000 --- a/dotbot/util/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from .common import shell_command diff --git a/dotbot/util/string.py b/dotbot/util/string.py deleted file mode 100644 index feca74e..0000000 --- a/dotbot/util/string.py +++ /dev/null @@ -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))) diff --git a/pyproject.toml b/pyproject.toml index cd19754..004c839 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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", ] diff --git a/setup.py b/setup.py deleted file mode 100644 index 06bc7bf..0000000 --- a/setup.py +++ /dev/null @@ -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", - ], - }, -) diff --git a/src/dotbot/__init__.py b/src/dotbot/__init__.py new file mode 100644 index 0000000..c167e65 --- /dev/null +++ b/src/dotbot/__init__.py @@ -0,0 +1,6 @@ +from dotbot.cli import main +from dotbot.plugin import Plugin + +__version__ = "1.21.0" + +__all__ = ["main", "Plugin"] diff --git a/dotbot/cli.py b/src/dotbot/cli.py similarity index 62% rename from dotbot/cli.py rename to src/dotbot/cli.py index 84a1377..a4607db 100644 --- a/dotbot/cli.py +++ b/src/dotbot/cli.py @@ -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) diff --git a/dotbot/config.py b/src/dotbot/config.py similarity index 50% rename from dotbot/config.py rename to src/dotbot/config.py index 4a60bb9..b51c9fe 100644 --- a/dotbot/config.py +++ b/src/dotbot/config.py @@ -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 diff --git a/src/dotbot/context.py b/src/dotbot/context.py new file mode 100644 index 0000000..e90016d --- /dev/null +++ b/src/dotbot/context.py @@ -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) diff --git a/dotbot/dispatcher.py b/src/dotbot/dispatcher.py similarity index 67% rename from dotbot/dispatcher.py rename to src/dotbot/dispatcher.py index 5ed7ad5..6baec1e 100644 --- a/dotbot/dispatcher.py +++ b/src/dotbot/dispatcher.py @@ -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 diff --git a/src/dotbot/messenger/__init__.py b/src/dotbot/messenger/__init__.py new file mode 100644 index 0000000..ac2022c --- /dev/null +++ b/src/dotbot/messenger/__init__.py @@ -0,0 +1,4 @@ +from dotbot.messenger.level import Level +from dotbot.messenger.messenger import Messenger + +__all__ = ["Level", "Messenger"] diff --git a/dotbot/messenger/color.py b/src/dotbot/messenger/color.py similarity index 100% rename from dotbot/messenger/color.py rename to src/dotbot/messenger/color.py diff --git a/src/dotbot/messenger/level.py b/src/dotbot/messenger/level.py new file mode 100644 index 0000000..288343d --- /dev/null +++ b/src/dotbot/messenger/level.py @@ -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 diff --git a/src/dotbot/messenger/messenger.py b/src/dotbot/messenger/messenger.py new file mode 100644 index 0000000..84de608 --- /dev/null +++ b/src/dotbot/messenger/messenger.py @@ -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 diff --git a/dotbot/plugin.py b/src/dotbot/plugin.py similarity index 64% rename from dotbot/plugin.py rename to src/dotbot/plugin.py index 84913a9..5ae0205 100644 --- a/dotbot/plugin.py +++ b/src/dotbot/plugin.py @@ -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. diff --git a/src/dotbot/plugins/__init__.py b/src/dotbot/plugins/__init__.py new file mode 100644 index 0000000..d2dfa0b --- /dev/null +++ b/src/dotbot/plugins/__init__.py @@ -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"] diff --git a/dotbot/plugins/clean.py b/src/dotbot/plugins/clean.py similarity index 67% rename from dotbot/plugins/clean.py rename to src/dotbot/plugins/clean.py index 70d3522..d65039b 100644 --- a/dotbot/plugins/clean.py +++ b/src/dotbot/plugins/clean.py @@ -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. """ diff --git a/dotbot/plugins/create.py b/src/dotbot/plugins/create.py similarity index 68% rename from dotbot/plugins/create.py rename to src/dotbot/plugins/create.py index c593d52..48118be 100644 --- a/dotbot/plugins/create.py +++ b/src/dotbot/plugins/create.py @@ -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 diff --git a/dotbot/plugins/link.py b/src/dotbot/plugins/link.py similarity index 69% rename from dotbot/plugins/link.py rename to src/dotbot/plugins/link.py index 3a50a21..e02335e 100644 --- a/dotbot/plugins/link.py +++ b/src/dotbot/plugins/link.py @@ -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 diff --git a/dotbot/plugins/shell.py b/src/dotbot/plugins/shell.py similarity index 79% rename from dotbot/plugins/shell.py rename to src/dotbot/plugins/shell.py index 4c8781e..51edc23 100644 --- a/dotbot/plugins/shell.py +++ b/src/dotbot/plugins/shell.py @@ -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: diff --git a/src/dotbot/util/__init__.py b/src/dotbot/util/__init__.py new file mode 100644 index 0000000..6f28612 --- /dev/null +++ b/src/dotbot/util/__init__.py @@ -0,0 +1,3 @@ +from dotbot.util.common import shell_command + +__all__ = ["shell_command"] diff --git a/dotbot/util/common.py b/src/dotbot/util/common.py similarity index 78% rename from dotbot/util/common.py rename to src/dotbot/util/common.py index 10dbb48..8125dd3 100644 --- a/dotbot/util/common.py +++ b/src/dotbot/util/common.py @@ -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, diff --git a/dotbot/util/module.py b/src/dotbot/util/module.py similarity index 74% rename from dotbot/util/module.py rename to src/dotbot/util/module.py index 6c2a6e3..202066a 100644 --- a/dotbot/util/module.py +++ b/src/dotbot/util/module.py @@ -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 diff --git a/dotbot/util/singleton.py b/src/dotbot/util/singleton.py similarity index 51% rename from dotbot/util/singleton.py rename to src/dotbot/util/singleton.py index 5891d8b..29a9088 100644 --- a/dotbot/util/singleton.py +++ b/src/dotbot/util/singleton.py @@ -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 diff --git a/src/dotbot/util/string.py b/src/dotbot/util/string.py new file mode 100644 index 0000000..a721a0d --- /dev/null +++ b/src/dotbot/util/string.py @@ -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))}" diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py index 040dea9..9bb6d8a 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -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 diff --git a/tests/dotbot_plugin_context_plugin.py b/tests/dotbot_plugin_context_plugin.py index f1615e9..d42ca87 100644 --- a/tests/dotbot_plugin_context_plugin.py +++ b/tests/dotbot_plugin_context_plugin.py @@ -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(), diff --git a/tests/dotbot_plugin_directory.py b/tests/dotbot_plugin_directory.py index fe60d7d..76d1356 100644 --- a/tests/dotbot_plugin_directory.py +++ b/tests/dotbot_plugin_directory.py @@ -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: diff --git a/tests/dotbot_plugin_dispatcher_no_plugins.py b/tests/dotbot_plugin_dispatcher_no_plugins.py index 734169a..2910712 100644 --- a/tests/dotbot_plugin_dispatcher_no_plugins.py +++ b/tests/dotbot_plugin_dispatcher_no_plugins.py @@ -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(), diff --git a/tests/dotbot_plugin_file.py b/tests/dotbot_plugin_file.py index 1dde95d..1b7887b 100644 --- a/tests/dotbot_plugin_file.py +++ b/tests/dotbot_plugin_file.py @@ -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: diff --git a/tests/dotbot_plugin_issue_357.py b/tests/dotbot_plugin_issue_357.py index 36609de..10574b6 100644 --- a/tests/dotbot_plugin_issue_357.py +++ b/tests/dotbot_plugin_issue_357.py @@ -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 diff --git a/tests/test_bin_dotbot.py b/tests/test_bin_dotbot.py index 4914c9a..d0ea8da 100644 --- a/tests/test_bin_dotbot.py +++ b/tests/test_bin_dotbot.py @@ -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, diff --git a/tests/test_clean.py b/tests/test_clean.py index 0b85fce..3e24c0c 100644 --- a/tests/test_clean.py +++ b/tests/test_clean.py @@ -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")) diff --git a/tests/test_cli.py b/tests/test_cli.py index 136533b..9bd4781 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -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")) diff --git a/tests/test_config.py b/tests/test_config.py index d6338d4..89c4b65 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -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]""" diff --git a/tests/test_create.py b/tests/test_create.py index 7b2c6d5..1136b2e 100644 --- a/tests/test_create.py +++ b/tests/test_create.py @@ -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}}}, diff --git a/tests/test_link.py b/tests/test_link.py index b769c92..b3e8966 100644 --- a/tests/test_link.py +++ b/tests/test_link.py @@ -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" diff --git a/tests/test_noop.py b/tests/test_noop.py index 5949ff5..29e44c5 100644 --- a/tests/test_noop.py +++ b/tests/test_noop.py @@ -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") diff --git a/tests/test_shell.py b/tests/test_shell.py index 1744163..5e5e477 100644 --- a/tests/test_shell.py +++ b/tests/test_shell.py @@ -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( diff --git a/tests/test_shim.py b/tests/test_shim.py index 6923968..5275599 100644 --- a/tests/test_shim.py +++ b/tests/test_shim.py @@ -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" diff --git a/tox.ini b/tox.ini deleted file mode 100644 index 6dd50f2..0000000 --- a/tox.ini +++ /dev/null @@ -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