Modernize project
This patch adds static typing, and it switches to the Hatch package manager and its Hatchling build backend.
This commit is contained in:
parent
256babe052
commit
abecc97bad
55 changed files with 875 additions and 824 deletions
|
@ -9,9 +9,3 @@ trim_trailing_whitespace = true
|
|||
|
||||
[*.py]
|
||||
indent_size = 4
|
||||
|
||||
[*.bash]
|
||||
indent_size = 4
|
||||
|
||||
[*.yml]
|
||||
indent_size = 2
|
||||
|
|
38
.github/workflows/ci.yml
vendored
38
.github/workflows/ci.yml
vendored
|
@ -13,7 +13,6 @@ jobs:
|
|||
matrix:
|
||||
os: ["ubuntu-20.04", "macos-latest"]
|
||||
python:
|
||||
- "3.6"
|
||||
- "3.7"
|
||||
- "3.8"
|
||||
- "3.9"
|
||||
|
@ -37,8 +36,6 @@ jobs:
|
|||
- os: "windows-latest"
|
||||
python: "3.13"
|
||||
exclude:
|
||||
- os: "macos-latest"
|
||||
python: "3.6"
|
||||
- os: "macos-latest"
|
||||
python: "3.7"
|
||||
runs-on: ${{ matrix.os }}
|
||||
|
@ -50,23 +47,30 @@ jobs:
|
|||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: ${{ matrix.python }}
|
||||
allow-prereleases: true
|
||||
- name: "Install dependencies"
|
||||
run: |
|
||||
python -m pip install --upgrade pip setuptools
|
||||
python -m pip install tox tox-gh-actions
|
||||
- name: "Run tests"
|
||||
run: |
|
||||
python -m tox
|
||||
python -m tox -e coverage_report
|
||||
- uses: pypa/hatch@install
|
||||
- run: hatch test -v --cover --include python=$(echo ${{ matrix.python }} | tr -d '-') tests
|
||||
- run: hatch run coverage:xml
|
||||
- uses: codecov/codecov-action@v5
|
||||
with:
|
||||
fail_ci_if_error: true
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
fmt:
|
||||
name: Format
|
||||
runs-on: ubuntu-22.04
|
||||
typecheck:
|
||||
name: Type check
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: psf/black@stable
|
||||
- uses: isort/isort-action@v1
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.13"
|
||||
- uses: pypa/hatch@install
|
||||
- run: hatch run types:check
|
||||
fmt:
|
||||
name: Format and lint
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.13"
|
||||
- uses: pypa/hatch@install
|
||||
- run: hatch fmt --check
|
||||
|
|
10
.gitignore
vendored
10
.gitignore
vendored
|
@ -1,11 +1,3 @@
|
|||
*.egg-info
|
||||
*.pyc
|
||||
.coverage*
|
||||
.eggs/
|
||||
.idea/
|
||||
.tox/
|
||||
.venv/
|
||||
build/
|
||||
coverage.xml
|
||||
dist/
|
||||
htmlcov/
|
||||
.coverage*
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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!
|
||||
|
|
30
bin/dotbot
30
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()
|
||||
|
|
|
@ -1,4 +0,0 @@
|
|||
from .cli import main
|
||||
from .plugin import Plugin
|
||||
|
||||
__version__ = "1.20.4"
|
|
@ -1,4 +0,0 @@
|
|||
from .cli import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
|
@ -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)
|
|
@ -1,2 +0,0 @@
|
|||
from .level import Level
|
||||
from .messenger import Messenger
|
|
@ -1,7 +0,0 @@
|
|||
class Level:
|
||||
NOTSET = 0
|
||||
DEBUG = 10
|
||||
LOWINFO = 15
|
||||
INFO = 20
|
||||
WARNING = 30
|
||||
ERROR = 40
|
|
@ -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
|
|
@ -1,4 +0,0 @@
|
|||
from .clean import Clean
|
||||
from .create import Create
|
||||
from .link import Link
|
||||
from .shell import Shell
|
|
@ -1 +0,0 @@
|
|||
from .common import shell_command
|
|
@ -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)))
|
104
pyproject.toml
104
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",
|
||||
]
|
||||
|
|
73
setup.py
73
setup.py
|
@ -1,73 +0,0 @@
|
|||
import re
|
||||
from os import path
|
||||
|
||||
from setuptools import find_packages, setup
|
||||
|
||||
here = path.dirname(__file__)
|
||||
|
||||
|
||||
with open(path.join(here, "README.md"), encoding="utf-8") as f:
|
||||
long_description = f.read()
|
||||
|
||||
|
||||
def read(*names, **kwargs):
|
||||
with open(path.join(here, *names), encoding=kwargs.get("encoding", "utf8")) as fp:
|
||||
return fp.read()
|
||||
|
||||
|
||||
def find_version(*file_paths):
|
||||
version_file = read(*file_paths)
|
||||
version_match = re.search(r"^__version__ = ['\"]([^'\"]*)['\"]", version_file, re.M)
|
||||
if version_match:
|
||||
return version_match.group(1)
|
||||
raise RuntimeError("Unable to find version string.")
|
||||
|
||||
|
||||
setup(
|
||||
name="dotbot",
|
||||
version=find_version("dotbot", "__init__.py"),
|
||||
description="A tool that bootstraps your dotfiles",
|
||||
long_description=long_description,
|
||||
long_description_content_type="text/markdown",
|
||||
url="https://github.com/anishathalye/dotbot",
|
||||
author="Anish Athalye",
|
||||
author_email="me@anishathalye.com",
|
||||
license="MIT",
|
||||
classifiers=[
|
||||
"Development Status :: 5 - Production/Stable",
|
||||
"Intended Audience :: Developers",
|
||||
"License :: OSI Approved :: MIT License",
|
||||
"Programming Language :: Python :: 3",
|
||||
"Programming Language :: Python :: 3.6",
|
||||
"Programming Language :: Python :: 3.7",
|
||||
"Programming Language :: Python :: 3.8",
|
||||
"Programming Language :: Python :: 3.9",
|
||||
"Programming Language :: Python :: 3.10",
|
||||
"Programming Language :: Python :: 3.11",
|
||||
"Programming Language :: Python :: 3.12",
|
||||
"Topic :: Utilities",
|
||||
],
|
||||
keywords="dotfiles",
|
||||
packages=find_packages(),
|
||||
setup_requires=[
|
||||
"setuptools>=38.6.0",
|
||||
"wheel>=0.31.0",
|
||||
],
|
||||
install_requires=[
|
||||
"PyYAML>=6.0.1,<7",
|
||||
],
|
||||
extras_require={
|
||||
"dev": {
|
||||
"pytest",
|
||||
"tox",
|
||||
}
|
||||
},
|
||||
# To provide executable scripts, use entry points in preference to the
|
||||
# "scripts" keyword. Entry points provide cross-platform support and allow
|
||||
# pip to create the appropriate form of executable for the target platform.
|
||||
entry_points={
|
||||
"console_scripts": [
|
||||
"dotbot=dotbot:main",
|
||||
],
|
||||
},
|
||||
)
|
6
src/dotbot/__init__.py
Normal file
6
src/dotbot/__init__.py
Normal file
|
@ -0,0 +1,6 @@
|
|||
from dotbot.cli import main
|
||||
from dotbot.plugin import Plugin
|
||||
|
||||
__version__ = "1.21.0"
|
||||
|
||||
__all__ = ["main", "Plugin"]
|
|
@ -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)
|
|
@ -1,29 +1,27 @@
|
|||
import json
|
||||
import os.path
|
||||
from typing import Any
|
||||
|
||||
import yaml
|
||||
|
||||
from .util import string
|
||||
from dotbot.util import string
|
||||
|
||||
|
||||
class ConfigReader:
|
||||
def __init__(self, config_file_path):
|
||||
def __init__(self, config_file_path: str):
|
||||
self._config = self._read(config_file_path)
|
||||
|
||||
def _read(self, config_file_path):
|
||||
def _read(self, config_file_path: str) -> Any:
|
||||
try:
|
||||
_, ext = os.path.splitext(config_file_path)
|
||||
with open(config_file_path) as fin:
|
||||
if ext == ".json":
|
||||
data = json.load(fin)
|
||||
else:
|
||||
data = yaml.safe_load(fin)
|
||||
return data
|
||||
return json.load(fin) if ext == ".json" else yaml.safe_load(fin)
|
||||
except Exception as e:
|
||||
msg = string.indent_lines(str(e))
|
||||
raise ReadingError("Could not read config file:\n%s" % msg)
|
||||
msg = f"Could not read config file:\n{msg}"
|
||||
raise ReadingError(msg) from e
|
||||
|
||||
def get_config(self):
|
||||
def get_config(self) -> Any:
|
||||
return self._config
|
||||
|
||||
|
43
src/dotbot/context.py
Normal file
43
src/dotbot/context.py
Normal file
|
@ -0,0 +1,43 @@
|
|||
import copy
|
||||
import os
|
||||
from argparse import Namespace
|
||||
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Type
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from dotbot.plugin import Plugin
|
||||
|
||||
|
||||
class Context:
|
||||
"""
|
||||
Contextual data and information for plugins.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self, base_directory: str, options: Optional[Namespace] = None, plugins: "Optional[List[Type[Plugin]]]" = None
|
||||
):
|
||||
self._base_directory = base_directory
|
||||
self._defaults: Dict[str, Any] = {}
|
||||
self._options = options if options is not None else Namespace()
|
||||
self._plugins = plugins
|
||||
|
||||
def set_base_directory(self, base_directory: str) -> None:
|
||||
self._base_directory = base_directory
|
||||
|
||||
def base_directory(self, canonical_path: bool = True) -> str: # noqa: FBT001, FBT002 # part of established public API
|
||||
base_directory = self._base_directory
|
||||
if canonical_path:
|
||||
base_directory = os.path.realpath(base_directory)
|
||||
return base_directory
|
||||
|
||||
def set_defaults(self, defaults: Dict[str, Any]) -> None:
|
||||
self._defaults = defaults
|
||||
|
||||
def defaults(self) -> Dict[str, Any]:
|
||||
return copy.deepcopy(self._defaults)
|
||||
|
||||
def options(self) -> Namespace:
|
||||
return copy.deepcopy(self._options)
|
||||
|
||||
def plugins(self) -> "Optional[List[Type[Plugin]]]":
|
||||
# shallow copy is ok here
|
||||
return copy.copy(self._plugins)
|
|
@ -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
|
4
src/dotbot/messenger/__init__.py
Normal file
4
src/dotbot/messenger/__init__.py
Normal file
|
@ -0,0 +1,4 @@
|
|||
from dotbot.messenger.level import Level
|
||||
from dotbot.messenger.messenger import Messenger
|
||||
|
||||
__all__ = ["Level", "Messenger"]
|
36
src/dotbot/messenger/level.py
Normal file
36
src/dotbot/messenger/level.py
Normal file
|
@ -0,0 +1,36 @@
|
|||
from enum import Enum
|
||||
from typing import Any
|
||||
|
||||
|
||||
class Level(Enum):
|
||||
NOTSET = 0
|
||||
DEBUG = 10
|
||||
LOWINFO = 15
|
||||
INFO = 20
|
||||
WARNING = 30
|
||||
ERROR = 40
|
||||
|
||||
def __lt__(self, other: Any) -> bool:
|
||||
if not isinstance(other, Level):
|
||||
return NotImplemented
|
||||
return self.value < other.value
|
||||
|
||||
def __le__(self, other: Any) -> bool:
|
||||
if not isinstance(other, Level):
|
||||
return NotImplemented
|
||||
return self.value <= other.value
|
||||
|
||||
def __gt__(self, other: Any) -> bool:
|
||||
if not isinstance(other, Level):
|
||||
return NotImplemented
|
||||
return self.value > other.value
|
||||
|
||||
def __ge__(self, other: Any) -> bool:
|
||||
if not isinstance(other, Level):
|
||||
return NotImplemented
|
||||
return self.value >= other.value
|
||||
|
||||
def __eq__(self, other: object) -> bool:
|
||||
if not isinstance(other, Level):
|
||||
return NotImplemented
|
||||
return self.value == other.value
|
58
src/dotbot/messenger/messenger.py
Normal file
58
src/dotbot/messenger/messenger.py
Normal file
|
@ -0,0 +1,58 @@
|
|||
from dotbot.messenger.color import Color
|
||||
from dotbot.messenger.level import Level
|
||||
from dotbot.util.singleton import Singleton
|
||||
|
||||
|
||||
class Messenger(metaclass=Singleton):
|
||||
def __init__(self, level: Level = Level.LOWINFO):
|
||||
self.set_level(level)
|
||||
self.use_color(True)
|
||||
|
||||
def set_level(self, level: Level) -> None:
|
||||
self._level = level
|
||||
|
||||
def use_color(self, yesno: bool) -> None: # noqa: FBT001
|
||||
self._use_color = yesno
|
||||
|
||||
def log(self, level: Level, message: str) -> None:
|
||||
if level >= self._level:
|
||||
print(f"{self._color(level)}{message}{self._reset()}") # noqa: T201
|
||||
|
||||
def debug(self, message: str) -> None:
|
||||
self.log(Level.DEBUG, message)
|
||||
|
||||
def lowinfo(self, message: str) -> None:
|
||||
self.log(Level.LOWINFO, message)
|
||||
|
||||
def info(self, message: str) -> None:
|
||||
self.log(Level.INFO, message)
|
||||
|
||||
def warning(self, message: str) -> None:
|
||||
self.log(Level.WARNING, message)
|
||||
|
||||
def error(self, message: str) -> None:
|
||||
self.log(Level.ERROR, message)
|
||||
|
||||
def _color(self, level: Level) -> str:
|
||||
"""
|
||||
Get a color (terminal escape sequence) according to a level.
|
||||
"""
|
||||
if not self._use_color or level < Level.DEBUG:
|
||||
return ""
|
||||
if level < Level.LOWINFO:
|
||||
return Color.YELLOW
|
||||
if level < Level.INFO:
|
||||
return Color.BLUE
|
||||
if level < Level.WARNING:
|
||||
return Color.GREEN
|
||||
if level < Level.ERROR:
|
||||
return Color.MAGENTA
|
||||
return Color.RED
|
||||
|
||||
def _reset(self) -> str:
|
||||
"""
|
||||
Get a reset color (terminal escape sequence).
|
||||
"""
|
||||
if not self._use_color:
|
||||
return ""
|
||||
return Color.RESET
|
|
@ -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.
|
||||
|
6
src/dotbot/plugins/__init__.py
Normal file
6
src/dotbot/plugins/__init__.py
Normal file
|
@ -0,0 +1,6 @@
|
|||
from dotbot.plugins.clean import Clean
|
||||
from dotbot.plugins.create import Create
|
||||
from dotbot.plugins.link import Link
|
||||
from dotbot.plugins.shell import Shell
|
||||
|
||||
__all__ = ["Clean", "Create", "Link", "Shell"]
|
|
@ -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.
|
||||
"""
|
|
@ -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
|
|
@ -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
|
|
@ -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:
|
3
src/dotbot/util/__init__.py
Normal file
3
src/dotbot/util/__init__.py
Normal file
|
@ -0,0 +1,3 @@
|
|||
from dotbot.util.common import shell_command
|
||||
|
||||
__all__ = ["shell_command"]
|
|
@ -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,
|
|
@ -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
|
|
@ -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
|
4
src/dotbot/util/string.py
Normal file
4
src/dotbot/util/string.py
Normal file
|
@ -0,0 +1,4 @@
|
|||
def indent_lines(string: str, amount: int = 2, delimiter: str = "\n") -> str:
|
||||
whitespace = " " * amount
|
||||
sep = f"{delimiter}{whitespace}"
|
||||
return f"{whitespace}{sep.join(string.split(delimiter))}"
|
0
tests/__init__.py
Normal file
0
tests/__init__.py
Normal file
|
@ -5,8 +5,9 @@ import os
|
|||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest.mock as mock
|
||||
from shutil import rmtree
|
||||
from typing import Any, Callable, Generator, List, Optional
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
|
@ -14,11 +15,11 @@ import yaml
|
|||
import dotbot.cli
|
||||
|
||||
|
||||
def get_long_path(path):
|
||||
def get_long_path(path: str) -> str:
|
||||
"""Get the long path for a given path."""
|
||||
|
||||
# Do nothing for non-Windows platforms.
|
||||
if sys.platform[:5] != "win32":
|
||||
if sys.platform != "win32":
|
||||
return path
|
||||
|
||||
buffer_size = 1000
|
||||
|
@ -31,15 +32,14 @@ def get_long_path(path):
|
|||
# On Linux, tempfile.TemporaryFile() requires unlink access.
|
||||
# This list is updated by a tempfile._mkstemp_inner() wrapper,
|
||||
# and its contents are checked by wrapped functions.
|
||||
allowed_tempfile_internal_unlink_calls = []
|
||||
allowed_tempfile_internal_unlink_calls: List[str] = []
|
||||
|
||||
|
||||
def wrap_function(function, function_path, arg_index, kwarg_key, root):
|
||||
def wrapper(*args, **kwargs):
|
||||
if kwarg_key in kwargs:
|
||||
value = kwargs[kwarg_key]
|
||||
else:
|
||||
value = args[arg_index]
|
||||
def wrap_function(
|
||||
function: Callable[..., Any], function_path: str, arg_index: int, kwarg_key: str, root: str
|
||||
) -> Callable[..., Any]:
|
||||
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
||||
value = kwargs[kwarg_key] if kwarg_key in kwargs else args[arg_index]
|
||||
|
||||
# Allow tempfile.TemporaryFile's internal unlink calls to work.
|
||||
if value in allowed_tempfile_internal_unlink_calls:
|
||||
|
@ -58,14 +58,11 @@ def wrap_function(function, function_path, arg_index, kwarg_key, root):
|
|||
return wrapper
|
||||
|
||||
|
||||
def wrap_open(root):
|
||||
wrapped = getattr(builtins, "open")
|
||||
def wrap_open(root: str) -> Callable[..., Any]:
|
||||
wrapped = builtins.open
|
||||
|
||||
def wrapper(*args, **kwargs):
|
||||
if "file" in kwargs:
|
||||
value = kwargs["file"]
|
||||
else:
|
||||
value = args[0]
|
||||
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
||||
value = kwargs["file"] if "file" in kwargs else args[0]
|
||||
|
||||
mode = "r"
|
||||
if "mode" in kwargs:
|
||||
|
@ -87,7 +84,7 @@ def wrap_open(root):
|
|||
return wrapper
|
||||
|
||||
|
||||
def rmtree_error_handler(_, path, __):
|
||||
def rmtree_error_handler(_function: Any, path: str, _excinfo: Any) -> None:
|
||||
# Handle read-only files and directories.
|
||||
os.chmod(path, 0o777)
|
||||
if os.path.isdir(path):
|
||||
|
@ -97,7 +94,7 @@ def rmtree_error_handler(_, path, __):
|
|||
|
||||
|
||||
@pytest.fixture(autouse=True, scope="session")
|
||||
def standardize_tmp():
|
||||
def standardize_tmp() -> None:
|
||||
r"""Standardize the temporary directory path.
|
||||
|
||||
On MacOS, `/var` is a symlink to `/private/var`.
|
||||
|
@ -114,21 +111,21 @@ def standardize_tmp():
|
|||
# MacOS: `/var` is a symlink.
|
||||
tmp = os.path.abspath(os.path.realpath(tmp))
|
||||
# Windows: The temporary directory may be a short path.
|
||||
if sys.platform[:5] == "win32":
|
||||
if sys.platform == "win32":
|
||||
tmp = get_long_path(tmp)
|
||||
os.environ["TMP"] = tmp
|
||||
os.environ["TEMP"] = tmp
|
||||
os.environ["TMPDIR"] = tmp
|
||||
tempfile.tempdir = tmp
|
||||
yield
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def root(standardize_tmp):
|
||||
def root(standardize_tmp: None) -> Generator[str, None, None]:
|
||||
_ = standardize_tmp
|
||||
"""Create a temporary directory for the duration of each test."""
|
||||
|
||||
# Reset allowed_tempfile_internal_unlink_calls.
|
||||
global allowed_tempfile_internal_unlink_calls
|
||||
global allowed_tempfile_internal_unlink_calls # noqa: PLW0603
|
||||
allowed_tempfile_internal_unlink_calls = []
|
||||
|
||||
# Dotbot changes the current working directory,
|
||||
|
@ -180,7 +177,7 @@ def root(standardize_tmp):
|
|||
(shutil, "unpack_archive", 1, "extract_dir"),
|
||||
]
|
||||
|
||||
patches = []
|
||||
patches: List[Any] = []
|
||||
for module, function_name, arg_index, kwarg_key in functions_to_wrap:
|
||||
# Skip anything that doesn't exist in this version of Python.
|
||||
if not hasattr(module, function_name):
|
||||
|
@ -188,7 +185,7 @@ def root(standardize_tmp):
|
|||
|
||||
# These values must be passed to a separate function
|
||||
# to ensure the variable closures work correctly.
|
||||
function_path = "{0}.{1}".format(module.__name__, function_name)
|
||||
function_path = f"{module.__name__}.{function_name}"
|
||||
function = getattr(module, function_name)
|
||||
wrapped = wrap_function(function, function_path, arg_index, kwarg_key, current_root)
|
||||
patches.append(mock.patch(function_path, wrapped))
|
||||
|
@ -200,13 +197,13 @@ def root(standardize_tmp):
|
|||
|
||||
# Block all access to bad functions.
|
||||
if hasattr(os, "chroot"):
|
||||
patches.append(mock.patch("os.chroot", lambda *_, **__: None))
|
||||
patches.append(mock.patch("os.chroot", return_value=None))
|
||||
|
||||
# Patch tempfile._mkstemp_inner() so tempfile.TemporaryFile()
|
||||
# can unlink files immediately.
|
||||
mkstemp_inner = tempfile._mkstemp_inner
|
||||
mkstemp_inner = tempfile._mkstemp_inner # type: ignore # noqa: SLF001
|
||||
|
||||
def wrap_mkstemp_inner(*args, **kwargs):
|
||||
def wrap_mkstemp_inner(*args: Any, **kwargs: Any) -> Any:
|
||||
(fd, name) = mkstemp_inner(*args, **kwargs)
|
||||
allowed_tempfile_internal_unlink_calls.append(name)
|
||||
return fd, name
|
||||
|
@ -219,7 +216,8 @@ def root(standardize_tmp):
|
|||
finally:
|
||||
# Patches must be stopped in reverse order because some patches are nested.
|
||||
# Stopping in the reverse order restores the original function.
|
||||
[patch.stop() for patch in reversed(patches)]
|
||||
for patch in reversed(patches):
|
||||
patch.stop()
|
||||
os.chdir(current_working_directory)
|
||||
if sys.version_info >= (3, 12):
|
||||
rmtree(current_root, onexc=rmtree_error_handler)
|
||||
|
@ -228,7 +226,7 @@ def root(standardize_tmp):
|
|||
|
||||
|
||||
@pytest.fixture
|
||||
def home(monkeypatch, root):
|
||||
def home(monkeypatch: pytest.MonkeyPatch, root: str) -> str:
|
||||
"""Create a home directory for the duration of the test.
|
||||
|
||||
On *nix, the environment variable "HOME" will be mocked.
|
||||
|
@ -237,43 +235,43 @@ def home(monkeypatch, root):
|
|||
|
||||
home = os.path.abspath(os.path.join(root, "home/user"))
|
||||
os.makedirs(home)
|
||||
if sys.platform[:5] == "win32":
|
||||
if sys.platform == "win32":
|
||||
monkeypatch.setenv("USERPROFILE", home)
|
||||
else:
|
||||
monkeypatch.setenv("HOME", home)
|
||||
yield home
|
||||
return home
|
||||
|
||||
|
||||
class Dotfiles:
|
||||
"""Create and manage a dotfiles directory for a test."""
|
||||
|
||||
def __init__(self, root):
|
||||
def __init__(self, root: str):
|
||||
self.root = root
|
||||
self.config = None
|
||||
self.config_filename = None
|
||||
self._config_filename: Optional[str] = None
|
||||
self.directory = os.path.join(root, "dotfiles")
|
||||
os.mkdir(self.directory)
|
||||
|
||||
def makedirs(self, path):
|
||||
def makedirs(self, path: str) -> None:
|
||||
os.makedirs(os.path.abspath(os.path.join(self.directory, path)))
|
||||
|
||||
def write(self, path, content=""):
|
||||
def write(self, path: str, content: str = "") -> None:
|
||||
path = os.path.abspath(os.path.join(self.directory, path))
|
||||
if not os.path.exists(os.path.dirname(path)):
|
||||
os.makedirs(os.path.dirname(path))
|
||||
with open(path, "w") as file:
|
||||
file.write(content)
|
||||
|
||||
def write_config(self, config, serializer="yaml", path=None):
|
||||
def write_config(self, config: Any, serializer: str = "yaml", path: Optional[str] = None) -> str:
|
||||
"""Write a dotbot config and return the filename."""
|
||||
|
||||
assert serializer in {"json", "yaml"}, "Only json and yaml are supported"
|
||||
if serializer == "yaml":
|
||||
serialize = yaml.dump
|
||||
serialize: Callable[[Any], str] = yaml.dump
|
||||
else: # serializer == "json"
|
||||
serialize = json.dumps
|
||||
|
||||
if path:
|
||||
if path is not None:
|
||||
msg = "The config file path must be an absolute path"
|
||||
assert path == os.path.abspath(path), msg
|
||||
|
||||
|
@ -281,25 +279,30 @@ class Dotfiles:
|
|||
msg = msg.format(root)
|
||||
assert path[: len(str(root))] == str(root), msg
|
||||
|
||||
self.config_filename = path
|
||||
self._config_filename = path
|
||||
else:
|
||||
self.config_filename = os.path.join(self.directory, "install.conf.yaml")
|
||||
self._config_filename = os.path.join(self.directory, "install.conf.yaml")
|
||||
self.config = config
|
||||
|
||||
with open(self.config_filename, "w") as file:
|
||||
with open(self._config_filename, "w") as file:
|
||||
file.write(serialize(config))
|
||||
return self.config_filename
|
||||
return self._config_filename
|
||||
|
||||
@property
|
||||
def config_filename(self) -> str:
|
||||
assert self._config_filename is not None
|
||||
return self._config_filename
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def dotfiles(root):
|
||||
def dotfiles(root: str) -> Dotfiles:
|
||||
"""Create a dotfiles directory."""
|
||||
|
||||
yield Dotfiles(root)
|
||||
return Dotfiles(root)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def run_dotbot(dotfiles):
|
||||
def run_dotbot(dotfiles: Dotfiles) -> Callable[..., None]:
|
||||
"""Run dotbot.
|
||||
|
||||
When calling `runner()`, only CLI arguments need to be specified.
|
||||
|
@ -309,11 +312,11 @@ def run_dotbot(dotfiles):
|
|||
and the caller will be responsible for all CLI arguments.
|
||||
"""
|
||||
|
||||
def runner(*argv, **kwargs):
|
||||
argv = ["dotbot"] + list(argv)
|
||||
def runner(*argv: Any, **kwargs: Any) -> None:
|
||||
argv = ("dotbot", *argv)
|
||||
if kwargs.get("custom", False) is not True:
|
||||
argv.extend(["-c", dotfiles.config_filename])
|
||||
with mock.patch("sys.argv", argv):
|
||||
argv = (*argv, "-c", dotfiles.config_filename)
|
||||
with mock.patch("sys.argv", list(argv)):
|
||||
dotbot.cli.main()
|
||||
|
||||
yield runner
|
||||
return runner
|
||||
|
|
|
@ -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(),
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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(),
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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"))
|
||||
|
|
|
@ -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"))
|
||||
|
|
|
@ -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]"""
|
||||
|
|
|
@ -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}}},
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -5,8 +5,10 @@ import sys
|
|||
|
||||
import pytest
|
||||
|
||||
from tests.conftest import Dotfiles
|
||||
|
||||
def test_shim(root, home, dotfiles, run_dotbot):
|
||||
|
||||
def test_shim(home: str, dotfiles: Dotfiles) -> None:
|
||||
"""Verify install shim works."""
|
||||
|
||||
# Skip the test if git is unavailable.
|
||||
|
@ -14,10 +16,8 @@ def test_shim(root, home, dotfiles, run_dotbot):
|
|||
if git is None:
|
||||
pytest.skip("git is unavailable")
|
||||
|
||||
if sys.platform[:5] == "win32":
|
||||
install = os.path.join(
|
||||
dotfiles.directory, "dotbot", "tools", "git-submodule", "install.ps1"
|
||||
)
|
||||
if sys.platform == "win32":
|
||||
install = os.path.join(dotfiles.directory, "dotbot", "tools", "git-submodule", "install.ps1")
|
||||
shim = os.path.join(dotfiles.directory, "install.ps1")
|
||||
else:
|
||||
install = os.path.join(dotfiles.directory, "dotbot", "tools", "git-submodule", "install")
|
||||
|
@ -27,17 +27,17 @@ def test_shim(root, home, dotfiles, run_dotbot):
|
|||
git_directory = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
os.chdir(dotfiles.directory)
|
||||
subprocess.check_call([git, "init"])
|
||||
subprocess.check_call(
|
||||
[git, "-c", "protocol.file.allow=always", "submodule", "add", git_directory, "dotbot"]
|
||||
)
|
||||
subprocess.check_call([git, "-c", "protocol.file.allow=always", "submodule", "add", git_directory, "dotbot"])
|
||||
shutil.copy(install, shim)
|
||||
dotfiles.write("foo", "pear")
|
||||
dotfiles.write_config([{"link": {"~/.foo": "foo"}}])
|
||||
|
||||
# Run the shim script.
|
||||
env = dict(os.environ)
|
||||
if sys.platform[:5] == "win32":
|
||||
args = [shutil.which("powershell"), "-ExecutionPolicy", "RemoteSigned", shim]
|
||||
if sys.platform == "win32":
|
||||
ps = shutil.which("powershell")
|
||||
assert ps is not None
|
||||
args = [ps, "-ExecutionPolicy", "RemoteSigned", shim]
|
||||
env["USERPROFILE"] = home
|
||||
else:
|
||||
args = [shim]
|
||||
|
@ -45,5 +45,5 @@ def test_shim(root, home, dotfiles, run_dotbot):
|
|||
subprocess.check_call(args, env=env, cwd=dotfiles.directory)
|
||||
|
||||
assert os.path.islink(os.path.join(home, ".foo"))
|
||||
with open(os.path.join(home, ".foo"), "r") as file:
|
||||
with open(os.path.join(home, ".foo")) as file:
|
||||
assert file.read() == "pear"
|
||||
|
|
78
tox.ini
78
tox.ini
|
@ -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
|
Loading…
Reference in a new issue