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]
|
[*.py]
|
||||||
indent_size = 4
|
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:
|
matrix:
|
||||||
os: ["ubuntu-20.04", "macos-latest"]
|
os: ["ubuntu-20.04", "macos-latest"]
|
||||||
python:
|
python:
|
||||||
- "3.6"
|
|
||||||
- "3.7"
|
- "3.7"
|
||||||
- "3.8"
|
- "3.8"
|
||||||
- "3.9"
|
- "3.9"
|
||||||
|
@ -37,8 +36,6 @@ jobs:
|
||||||
- os: "windows-latest"
|
- os: "windows-latest"
|
||||||
python: "3.13"
|
python: "3.13"
|
||||||
exclude:
|
exclude:
|
||||||
- os: "macos-latest"
|
|
||||||
python: "3.6"
|
|
||||||
- os: "macos-latest"
|
- os: "macos-latest"
|
||||||
python: "3.7"
|
python: "3.7"
|
||||||
runs-on: ${{ matrix.os }}
|
runs-on: ${{ matrix.os }}
|
||||||
|
@ -50,23 +47,30 @@ jobs:
|
||||||
- uses: actions/setup-python@v5
|
- uses: actions/setup-python@v5
|
||||||
with:
|
with:
|
||||||
python-version: ${{ matrix.python }}
|
python-version: ${{ matrix.python }}
|
||||||
allow-prereleases: true
|
- uses: pypa/hatch@install
|
||||||
- name: "Install dependencies"
|
- run: hatch test -v --cover --include python=$(echo ${{ matrix.python }} | tr -d '-') tests
|
||||||
run: |
|
- run: hatch run coverage:xml
|
||||||
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: codecov/codecov-action@v5
|
- uses: codecov/codecov-action@v5
|
||||||
with:
|
with:
|
||||||
fail_ci_if_error: true
|
fail_ci_if_error: true
|
||||||
token: ${{ secrets.CODECOV_TOKEN }}
|
token: ${{ secrets.CODECOV_TOKEN }}
|
||||||
fmt:
|
typecheck:
|
||||||
name: Format
|
name: Type check
|
||||||
runs-on: ubuntu-22.04
|
runs-on: ubuntu-24.04
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- uses: psf/black@stable
|
- uses: actions/setup-python@v5
|
||||||
- uses: isort/isort-action@v1
|
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
|
*.pyc
|
||||||
.coverage*
|
|
||||||
.eggs/
|
|
||||||
.idea/
|
|
||||||
.tox/
|
|
||||||
.venv/
|
|
||||||
build/
|
|
||||||
coverage.xml
|
|
||||||
dist/
|
dist/
|
||||||
htmlcov/
|
.coverage*
|
||||||
|
|
|
@ -1,6 +1,9 @@
|
||||||
Note: this changelog only lists feature additions, not bugfixes. For details on
|
Note: this changelog only lists feature additions, not bugfixes. For details on
|
||||||
those, see the Git history.
|
those, see the Git history.
|
||||||
|
|
||||||
|
- v1.21
|
||||||
|
- Drop support for Python 3.6: the minimum version supported is now Python
|
||||||
|
3.7
|
||||||
- v1.20
|
- v1.20
|
||||||
- Drop support for Python 2 and old versions of Python 3: the minimum
|
- Drop support for Python 2 and old versions of Python 3: the minimum
|
||||||
version supported is now Python 3.6
|
version supported is now Python 3.6
|
||||||
|
|
|
@ -1,5 +1,4 @@
|
||||||
Contributing
|
# Contributing
|
||||||
============
|
|
||||||
|
|
||||||
All kinds of contributions to Dotbot are greatly appreciated. For someone
|
All kinds of contributions to Dotbot are greatly appreciated. For someone
|
||||||
unfamiliar with the code base, the most efficient way to contribute is usually
|
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
|
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].
|
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
|
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.
|
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
|
This will help avoid wasted efforts and ensure that your work is incorporated
|
||||||
into the code base.
|
into the code base.
|
||||||
|
|
||||||
Bug Reports
|
## Bug Reports
|
||||||
-----------
|
|
||||||
|
|
||||||
Did something go wrong with Dotbot? Sorry about that! Bug reports are greatly
|
Did something go wrong with Dotbot? Sorry about that! Bug reports are greatly
|
||||||
appreciated!
|
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
|
steps to reproduce the bug. The more details you can include, the easier it is
|
||||||
to find and fix the bug.
|
to find and fix the bug.
|
||||||
|
|
||||||
Patches
|
## Patches
|
||||||
-------
|
|
||||||
|
|
||||||
Want to hack on Dotbot? Awesome!
|
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
|
commit messages should be descriptive and [properly
|
||||||
formatted][commit-messages].
|
formatted][commit-messages].
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
|
||||||
When preparing a patch, it's recommended that you add unit tests
|
When preparing a patch, it's recommended that you add unit tests
|
||||||
that demonstrate the bug is fixed (or that the feature works).
|
that demonstrate the bug is fixed (or that the feature works). You
|
||||||
You can run the tests on your local machine by installing the `dev` extras.
|
can run tests on your local machine using [Hatch][hatch]:
|
||||||
The steps below do this using a virtual environment:
|
|
||||||
|
|
||||||
```shell
|
```bash
|
||||||
# Create a local virtual environment
|
hatch test
|
||||||
$ python -m venv .venv
|
|
||||||
|
|
||||||
# Activate the virtual environment
|
|
||||||
# Cygwin, Linux, and MacOS:
|
|
||||||
$ . .venv/bin/activate
|
|
||||||
# Windows Powershell:
|
|
||||||
$ & .venv\Scripts\Activate.ps1
|
|
||||||
|
|
||||||
# Update pip and setuptools
|
|
||||||
(.venv) $ python -m pip install -U pip setuptools
|
|
||||||
|
|
||||||
# Install dotbot and its development dependencies
|
|
||||||
(.venv) $ python -m pip install -e .[dev]
|
|
||||||
|
|
||||||
# Run the unit tests
|
|
||||||
(.venv) $ tox
|
|
||||||
```
|
```
|
||||||
|
|
||||||
If you prefer to run the tests in an isolated container using Docker, you can
|
If you prefer to run the tests in an isolated container using Docker, you can
|
||||||
do so with the following:
|
do so with the following:
|
||||||
|
|
||||||
```
|
```bash
|
||||||
docker run -it --rm -v "${PWD}:/dotbot" -w /dotbot python:3.10-alpine /bin/sh
|
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
|
After spawning the container, install Hatch with `pip install hatch`, and then
|
||||||
virtualenv, ..., run the tests).
|
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
|
[fork]: https://github.com/anishathalye/dotbot/fork
|
||||||
[email]: mailto:me@anishathalye.com
|
[email]: mailto:me@anishathalye.com
|
||||||
[commit-messages]: http://tbaggery.com/2008/04/19/a-note-about-git-commit-messages.html
|
[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
|
Dotbot makes installing your dotfiles as easy as `git clone $url && cd dotfiles
|
||||||
&& ./install`, even on a freshly installed system!
|
&& ./install`, even on a freshly installed system!
|
||||||
|
|
30
bin/dotbot
30
bin/dotbot
|
@ -15,34 +15,32 @@ exit 1
|
||||||
|
|
||||||
# python code
|
# python code
|
||||||
|
|
||||||
import sys, os
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
# this file is syntactically valid Python 2; bail out if the interpreter is Python 2
|
# this file is syntactically valid Python 2; bail out if the interpreter is Python 2
|
||||||
if sys.version_info[0] < 3:
|
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')
|
print('error: this version of Dotbot is not compatible with Python 2:\nhttps://github.com/anishathalye/dotbot/wiki/Troubleshooting#python-2')
|
||||||
exit(1)
|
sys.exit(1)
|
||||||
if sys.version_info < (3, 6):
|
if sys.version_info < (3, 7):
|
||||||
print('error: this version of Dotbot requires Python 3.6+')
|
print('error: this version of Dotbot requires Python 3.7+')
|
||||||
exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
PROJECT_ROOT_DIRECTORY = os.path.dirname(
|
project_root_directory = os.path.dirname(
|
||||||
os.path.dirname(os.path.realpath(__file__)))
|
os.path.dirname(os.path.realpath(__file__)))
|
||||||
|
|
||||||
def inject(lib_path):
|
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)
|
sys.path.insert(0, path)
|
||||||
|
|
||||||
inject('pyyaml/lib')
|
inject('pyyaml/lib')
|
||||||
|
|
||||||
if os.path.exists(os.path.join(PROJECT_ROOT_DIRECTORY, 'dotbot')):
|
if os.path.exists(os.path.join(project_root_directory, 'src', 'dotbot')):
|
||||||
if PROJECT_ROOT_DIRECTORY not in sys.path:
|
src_directory = os.path.join(project_root_directory, 'src')
|
||||||
sys.path.insert(0, PROJECT_ROOT_DIRECTORY)
|
if src_directory not in sys.path:
|
||||||
os.putenv('PYTHONPATH', PROJECT_ROOT_DIRECTORY)
|
sys.path.insert(0, src_directory)
|
||||||
|
os.putenv('PYTHONPATH', src_directory)
|
||||||
|
|
||||||
import dotbot
|
import dotbot
|
||||||
|
|
||||||
def main():
|
dotbot.cli.main()
|
||||||
dotbot.cli.main()
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
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]
|
[build-system]
|
||||||
line-length = 100
|
requires = ["hatchling"]
|
||||||
exclude = '''
|
build-backend = "hatchling.build"
|
||||||
/(
|
|
||||||
\.git
|
|
||||||
| \.github
|
|
||||||
| .*\.egg-info
|
|
||||||
| build
|
|
||||||
| dist
|
|
||||||
| lib
|
|
||||||
)/
|
|
||||||
'''
|
|
||||||
|
|
||||||
[tool.pytest.ini_options]
|
[project]
|
||||||
filterwarnings = [
|
name = "dotbot"
|
||||||
"error",
|
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 subprocess
|
||||||
import sys
|
import sys
|
||||||
from argparse import ArgumentParser, RawTextHelpFormatter
|
from argparse import ArgumentParser, RawTextHelpFormatter
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
import dotbot
|
import dotbot
|
||||||
|
from dotbot.config import ConfigReader, ReadingError
|
||||||
from .config import ConfigReader, ReadingError
|
from dotbot.dispatcher import Dispatcher, DispatchError, _all_plugins
|
||||||
from .dispatcher import Dispatcher, DispatchError, _all_plugins
|
from dotbot.messenger import Level, Messenger
|
||||||
from .messenger import Level, Messenger
|
from dotbot.plugins import Clean, Create, Link, Shell
|
||||||
from .plugins import Clean, Create, Link, Shell
|
from dotbot.util import module
|
||||||
from .util import module
|
|
||||||
|
|
||||||
|
|
||||||
def add_options(parser):
|
def add_options(parser: ArgumentParser) -> None:
|
||||||
parser.add_argument(
|
parser.add_argument("-Q", "--super-quiet", action="store_true", help="suppress almost all output")
|
||||||
"-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("-q", "--quiet", action="store_true", help="suppress most output")
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"-v",
|
"-v",
|
||||||
"--verbose",
|
"--verbose",
|
||||||
action="count",
|
action="count",
|
||||||
default=0,
|
default=0,
|
||||||
help="enable verbose output\n"
|
help="enable verbose output\n" "-v: typical verbose\n" "-vv: also, set shell commands stderr/stdout to true",
|
||||||
"-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("-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(
|
parser.add_argument(
|
||||||
"-p",
|
"-p",
|
||||||
"--plugin",
|
"--plugin",
|
||||||
|
@ -42,9 +34,7 @@ def add_options(parser):
|
||||||
help="load PLUGIN as a plugin",
|
help="load PLUGIN as a plugin",
|
||||||
metavar="PLUGIN",
|
metavar="PLUGIN",
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument("--disable-built-in-plugins", action="store_true", help="disable built-in plugins")
|
||||||
"--disable-built-in-plugins", action="store_true", help="disable built-in plugins"
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--plugin-dir",
|
"--plugin-dir",
|
||||||
action="append",
|
action="append",
|
||||||
|
@ -53,21 +43,11 @@ def add_options(parser):
|
||||||
metavar="PLUGIN_DIR",
|
metavar="PLUGIN_DIR",
|
||||||
help="load all plugins in PLUGIN_DIR",
|
help="load all plugins in PLUGIN_DIR",
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument("--only", nargs="+", help="only run specified directives", metavar="DIRECTIVE")
|
||||||
"--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(
|
parser.add_argument("--no-color", dest="no_color", action="store_true", help="disable color output")
|
||||||
"--except", nargs="+", dest="skip", help="skip specified directives", metavar="DIRECTIVE"
|
parser.add_argument("--version", action="store_true", help="show program's version number and exit")
|
||||||
)
|
|
||||||
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(
|
parser.add_argument(
|
||||||
"-x",
|
"-x",
|
||||||
"--exit-on-failure",
|
"--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)
|
reader = ConfigReader(config_file)
|
||||||
return reader.get_config()
|
return reader.get_config()
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main() -> None:
|
||||||
log = Messenger()
|
log = Messenger()
|
||||||
try:
|
try:
|
||||||
parser = ArgumentParser(formatter_class=RawTextHelpFormatter)
|
parser = ArgumentParser(formatter_class=RawTextHelpFormatter)
|
||||||
|
@ -92,15 +72,15 @@ def main():
|
||||||
try:
|
try:
|
||||||
with open(os.devnull) as devnull:
|
with open(os.devnull) as devnull:
|
||||||
git_hash = subprocess.check_output(
|
git_hash = subprocess.check_output(
|
||||||
["git", "rev-parse", "HEAD"],
|
["git", "rev-parse", "HEAD"], # noqa: S607
|
||||||
cwd=os.path.dirname(os.path.abspath(__file__)),
|
cwd=os.path.dirname(os.path.abspath(__file__)),
|
||||||
stderr=devnull,
|
stderr=devnull,
|
||||||
).decode("ascii")
|
).decode("ascii")
|
||||||
hash_msg = " (git %s)" % git_hash[:10]
|
hash_msg = f" (git {git_hash[:10]})"
|
||||||
except (OSError, subprocess.CalledProcessError):
|
except (OSError, subprocess.CalledProcessError):
|
||||||
hash_msg = ""
|
hash_msg = ""
|
||||||
print("Dotbot version %s%s" % (dotbot.__version__, hash_msg))
|
print(f"Dotbot version {dotbot.__version__}{hash_msg}") # noqa: T201
|
||||||
exit(0)
|
sys.exit(0)
|
||||||
if options.super_quiet:
|
if options.super_quiet:
|
||||||
log.set_level(Level.WARNING)
|
log.set_level(Level.WARNING)
|
||||||
if options.quiet:
|
if options.quiet:
|
||||||
|
@ -110,7 +90,7 @@ def main():
|
||||||
|
|
||||||
if options.force_color and options.no_color:
|
if options.force_color and options.no_color:
|
||||||
log.error("`--force-color` and `--no-color` cannot both be provided")
|
log.error("`--force-color` and `--no-color` cannot both be provided")
|
||||||
exit(1)
|
sys.exit(1)
|
||||||
elif options.force_color:
|
elif options.force_color:
|
||||||
log.use_color(True)
|
log.use_color(True)
|
||||||
elif options.no_color:
|
elif options.no_color:
|
||||||
|
@ -124,10 +104,8 @@ def main():
|
||||||
plugins.extend([Clean, Create, Link, Shell])
|
plugins.extend([Clean, Create, Link, Shell])
|
||||||
plugin_paths = []
|
plugin_paths = []
|
||||||
for directory in plugin_directories:
|
for directory in plugin_directories:
|
||||||
for plugin_path in glob.glob(os.path.join(directory, "*.py")):
|
plugin_paths.extend(glob.glob(os.path.join(directory, "*.py")))
|
||||||
plugin_paths.append(plugin_path)
|
plugin_paths.extend(options.plugins)
|
||||||
for plugin_path in options.plugins:
|
|
||||||
plugin_paths.append(plugin_path)
|
|
||||||
for plugin_path in plugin_paths:
|
for plugin_path in plugin_paths:
|
||||||
abspath = os.path.abspath(plugin_path)
|
abspath = os.path.abspath(plugin_path)
|
||||||
plugins.extend(module.load(abspath))
|
plugins.extend(module.load(abspath))
|
||||||
|
@ -135,16 +113,17 @@ def main():
|
||||||
# can happen if, for example, a third-party plugin loads a
|
# can happen if, for example, a third-party plugin loads a
|
||||||
# built-in plugin, which will cause it to appear in the list
|
# built-in plugin, which will cause it to appear in the list
|
||||||
# returned by module.load above
|
# returned by module.load above
|
||||||
plugins = set(plugins)
|
plugins = list(set(plugins))
|
||||||
if not options.config_file:
|
if not options.config_file:
|
||||||
log.error("No configuration file specified")
|
log.error("No configuration file specified")
|
||||||
exit(1)
|
sys.exit(1)
|
||||||
tasks = read_config(options.config_file)
|
tasks = read_config(options.config_file)
|
||||||
if tasks is None:
|
if tasks is None:
|
||||||
log.warning("Configuration file is empty, no work to do")
|
log.warning("Configuration file is empty, no work to do")
|
||||||
tasks = []
|
tasks = []
|
||||||
if not isinstance(tasks, list):
|
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:
|
if options.base_directory:
|
||||||
base_directory = os.path.abspath(options.base_directory)
|
base_directory = os.path.abspath(options.base_directory)
|
||||||
else:
|
else:
|
||||||
|
@ -164,10 +143,11 @@ def main():
|
||||||
if success:
|
if success:
|
||||||
log.info("\n==> All tasks executed successfully")
|
log.info("\n==> All tasks executed successfully")
|
||||||
else:
|
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:
|
except (ReadingError, DispatchError) as e:
|
||||||
log.error("%s" % e)
|
log.error(str(e)) # noqa: TRY400
|
||||||
exit(1)
|
sys.exit(1)
|
||||||
except KeyboardInterrupt:
|
except KeyboardInterrupt:
|
||||||
log.error("\n==> Operation aborted")
|
log.error("\n==> Operation aborted") # noqa: TRY400
|
||||||
exit(1)
|
sys.exit(1)
|
|
@ -1,29 +1,27 @@
|
||||||
import json
|
import json
|
||||||
import os.path
|
import os.path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
import yaml
|
import yaml
|
||||||
|
|
||||||
from .util import string
|
from dotbot.util import string
|
||||||
|
|
||||||
|
|
||||||
class ConfigReader:
|
class ConfigReader:
|
||||||
def __init__(self, config_file_path):
|
def __init__(self, config_file_path: str):
|
||||||
self._config = self._read(config_file_path)
|
self._config = self._read(config_file_path)
|
||||||
|
|
||||||
def _read(self, config_file_path):
|
def _read(self, config_file_path: str) -> Any:
|
||||||
try:
|
try:
|
||||||
_, ext = os.path.splitext(config_file_path)
|
_, ext = os.path.splitext(config_file_path)
|
||||||
with open(config_file_path) as fin:
|
with open(config_file_path) as fin:
|
||||||
if ext == ".json":
|
return json.load(fin) if ext == ".json" else yaml.safe_load(fin)
|
||||||
data = json.load(fin)
|
|
||||||
else:
|
|
||||||
data = yaml.safe_load(fin)
|
|
||||||
return data
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
msg = string.indent_lines(str(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
|
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
|
import os
|
||||||
from argparse import Namespace
|
from argparse import Namespace
|
||||||
|
from typing import Any, Dict, List, Optional, Type
|
||||||
|
|
||||||
from .context import Context
|
from dotbot.context import Context
|
||||||
from .messenger import Messenger
|
from dotbot.messenger import Messenger
|
||||||
from .plugin import Plugin
|
from dotbot.plugin import Plugin
|
||||||
|
|
||||||
# Before b5499c7dc5b300462f3ce1c2a3d9b7a76233b39b, Dispatcher auto-loaded all
|
# Before b5499c7dc5b300462f3ce1c2a3d9b7a76233b39b, Dispatcher auto-loaded all
|
||||||
# plugins, but after that change, plugins are passed in explicitly (and loaded
|
# 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
|
# so this is a workaround for implementing similar functionality: when
|
||||||
# Dispatcher is constructed without an explicit list of plugins, _all_plugins is
|
# Dispatcher is constructed without an explicit list of plugins, _all_plugins is
|
||||||
# used instead.
|
# used instead.
|
||||||
_all_plugins = [] # filled in by cli.py
|
_all_plugins: List[Type[Plugin]] = [] # filled in by cli.py
|
||||||
|
|
||||||
|
|
||||||
class Dispatcher:
|
class Dispatcher:
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
base_directory,
|
base_directory: str,
|
||||||
only=None,
|
only: Optional[List[str]] = None,
|
||||||
skip=None,
|
skip: Optional[List[str]] = None,
|
||||||
exit_on_failure=False,
|
exit_on_failure: bool = False, # noqa: FBT001, FBT002 part of established public API
|
||||||
options=Namespace(),
|
options: Optional[Namespace] = None,
|
||||||
plugins=None,
|
plugins: Optional[List[Type[Plugin]]] = None,
|
||||||
):
|
):
|
||||||
# if the caller wants no plugins, the caller needs to explicitly pass in
|
# if the caller wants no plugins, the caller needs to explicitly pass in
|
||||||
# plugins=[]
|
# plugins=[]
|
||||||
|
@ -35,13 +36,16 @@ class Dispatcher:
|
||||||
self._skip = skip
|
self._skip = skip
|
||||||
self._exit = exit_on_failure
|
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))
|
path = os.path.abspath(os.path.expanduser(base_directory))
|
||||||
if not os.path.exists(path):
|
if not os.path.exists(path):
|
||||||
raise DispatchError("Nonexistent base directory")
|
msg = "Nonexistent base directory"
|
||||||
|
raise DispatchError(msg)
|
||||||
self._context = Context(path, options, plugins)
|
self._context = Context(path, options, plugins)
|
||||||
|
|
||||||
def dispatch(self, tasks):
|
def dispatch(self, tasks: List[Dict[str, Any]]) -> bool:
|
||||||
success = True
|
success = True
|
||||||
for task in tasks:
|
for task in tasks:
|
||||||
for action in task:
|
for action in task:
|
||||||
|
@ -51,7 +55,7 @@ class Dispatcher:
|
||||||
or self._skip is not None
|
or self._skip is not None
|
||||||
and action in self._skip
|
and action in self._skip
|
||||||
) and action != "defaults":
|
) and action != "defaults":
|
||||||
self._log.info("Skipping action %s" % action)
|
self._log.info(f"Skipping action {action}")
|
||||||
continue
|
continue
|
||||||
handled = False
|
handled = False
|
||||||
if action == "defaults":
|
if action == "defaults":
|
||||||
|
@ -64,21 +68,19 @@ class Dispatcher:
|
||||||
local_success = plugin.handle(action, task[action])
|
local_success = plugin.handle(action, task[action])
|
||||||
if not local_success and self._exit:
|
if not local_success and self._exit:
|
||||||
# The action has failed exit
|
# The action has failed exit
|
||||||
self._log.error("Action %s failed" % action)
|
self._log.error(f"Action {action} failed")
|
||||||
return False
|
return False
|
||||||
success &= local_success
|
success &= local_success
|
||||||
handled = True
|
handled = True
|
||||||
except Exception as err:
|
except Exception as err: # noqa: BLE001
|
||||||
self._log.error(
|
self._log.error(f"An error was encountered while executing action {action}")
|
||||||
"An error was encountered while executing action %s" % action
|
self._log.debug(str(err))
|
||||||
)
|
|
||||||
self._log.debug(err)
|
|
||||||
if self._exit:
|
if self._exit:
|
||||||
# There was an execption exit
|
# There was an execption exit
|
||||||
return False
|
return False
|
||||||
if not handled:
|
if not handled:
|
||||||
success = False
|
success = False
|
||||||
self._log.error("Action %s not handled" % action)
|
self._log.error(f"Action {action} not handled")
|
||||||
if self._exit:
|
if self._exit:
|
||||||
# Invalid action exit
|
# Invalid action exit
|
||||||
return False
|
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 typing import Any
|
||||||
from .messenger import Messenger
|
|
||||||
|
from dotbot.context import Context
|
||||||
|
from dotbot.messenger import Messenger
|
||||||
|
|
||||||
|
|
||||||
class Plugin:
|
class Plugin:
|
||||||
|
@ -7,17 +9,17 @@ class Plugin:
|
||||||
Abstract base class for commands that process directives.
|
Abstract base class for commands that process directives.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, context):
|
def __init__(self, context: Context):
|
||||||
self._context = context
|
self._context = context
|
||||||
self._log = Messenger()
|
self._log = Messenger()
|
||||||
|
|
||||||
def can_handle(self, directive):
|
def can_handle(self, directive: str) -> bool:
|
||||||
"""
|
"""
|
||||||
Returns true if the Plugin can handle the directive.
|
Returns true if the Plugin can handle the directive.
|
||||||
"""
|
"""
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
def handle(self, directive, data):
|
def handle(self, directive: str, data: Any) -> bool:
|
||||||
"""
|
"""
|
||||||
Executes the directive.
|
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 os
|
||||||
import sys
|
import sys
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from ..plugin import Plugin
|
from dotbot.plugin import Plugin
|
||||||
|
|
||||||
|
|
||||||
class Clean(Plugin):
|
class Clean(Plugin):
|
||||||
|
@ -11,15 +12,16 @@ class Clean(Plugin):
|
||||||
|
|
||||||
_directive = "clean"
|
_directive = "clean"
|
||||||
|
|
||||||
def can_handle(self, directive):
|
def can_handle(self, directive: str) -> bool:
|
||||||
return directive == self._directive
|
return directive == self._directive
|
||||||
|
|
||||||
def handle(self, directive, data):
|
def handle(self, directive: str, data: Any) -> bool:
|
||||||
if directive != self._directive:
|
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)
|
return self._process_clean(data)
|
||||||
|
|
||||||
def _process_clean(self, targets):
|
def _process_clean(self, targets: Any) -> bool:
|
||||||
success = True
|
success = True
|
||||||
defaults = self._context.defaults().get(self._directive, {})
|
defaults = self._context.defaults().get(self._directive, {})
|
||||||
for target in targets:
|
for target in targets:
|
||||||
|
@ -28,42 +30,40 @@ class Clean(Plugin):
|
||||||
if isinstance(targets, dict) and isinstance(targets[target], dict):
|
if isinstance(targets, dict) and isinstance(targets[target], dict):
|
||||||
force = targets[target].get("force", force)
|
force = targets[target].get("force", force)
|
||||||
recursive = targets[target].get("recursive", recursive)
|
recursive = targets[target].get("recursive", recursive)
|
||||||
success &= self._clean(target, force, recursive)
|
success &= self._clean(target, force=force, recursive=recursive)
|
||||||
if success:
|
if success:
|
||||||
self._log.info("All targets have been cleaned")
|
self._log.info("All targets have been cleaned")
|
||||||
else:
|
else:
|
||||||
self._log.error("Some targets were not successfully cleaned")
|
self._log.error("Some targets were not successfully cleaned")
|
||||||
return success
|
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
|
Cleans all the broken symbolic links in target if they point to
|
||||||
a subdirectory of the base directory or if forced to clean.
|
a subdirectory of the base directory or if forced to clean.
|
||||||
"""
|
"""
|
||||||
if not os.path.isdir(os.path.expandvars(os.path.expanduser(target))):
|
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
|
return True
|
||||||
for item in os.listdir(os.path.expandvars(os.path.expanduser(target))):
|
for item in os.listdir(os.path.expandvars(os.path.expanduser(target))):
|
||||||
path = os.path.abspath(
|
path = os.path.abspath(os.path.join(os.path.expandvars(os.path.expanduser(target)), item))
|
||||||
os.path.join(os.path.expandvars(os.path.expanduser(target)), item)
|
|
||||||
)
|
|
||||||
if recursive and os.path.isdir(path):
|
if recursive and os.path.isdir(path):
|
||||||
# isdir implies not islink -- we don't want to descend into
|
# isdir implies not islink -- we don't want to descend into
|
||||||
# symlinked directories. okay to do a recursive call here
|
# symlinked directories. okay to do a recursive call here
|
||||||
# because depth should be fairly limited
|
# 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):
|
if not os.path.exists(path) and os.path.islink(path):
|
||||||
points_at = os.path.join(os.path.dirname(path), os.readlink(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:]
|
points_at = points_at[4:]
|
||||||
if self._in_directory(path, self._context.base_directory()) or force:
|
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)
|
os.remove(path)
|
||||||
else:
|
else:
|
||||||
self._log.lowinfo("Link %s -> %s not removed." % (path, points_at))
|
self._log.lowinfo(f"Link {path} -> {points_at} not removed.")
|
||||||
return True
|
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.
|
Returns true if the path is in the directory.
|
||||||
"""
|
"""
|
|
@ -1,6 +1,7 @@
|
||||||
import os
|
import os
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from ..plugin import Plugin
|
from dotbot.plugin import Plugin
|
||||||
|
|
||||||
|
|
||||||
class Create(Plugin):
|
class Create(Plugin):
|
||||||
|
@ -10,15 +11,16 @@ class Create(Plugin):
|
||||||
|
|
||||||
_directive = "create"
|
_directive = "create"
|
||||||
|
|
||||||
def can_handle(self, directive):
|
def can_handle(self, directive: str) -> bool:
|
||||||
return directive == self._directive
|
return directive == self._directive
|
||||||
|
|
||||||
def handle(self, directive, data):
|
def handle(self, directive: str, data: Any) -> bool:
|
||||||
if directive != self._directive:
|
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)
|
return self._process_paths(data)
|
||||||
|
|
||||||
def _process_paths(self, paths):
|
def _process_paths(self, paths: Any) -> bool:
|
||||||
success = True
|
success = True
|
||||||
defaults = self._context.defaults().get("create", {})
|
defaults = self._context.defaults().get("create", {})
|
||||||
for key in paths:
|
for key in paths:
|
||||||
|
@ -35,26 +37,26 @@ class Create(Plugin):
|
||||||
self._log.error("Some paths were not successfully set up")
|
self._log.error("Some paths were not successfully set up")
|
||||||
return success
|
return success
|
||||||
|
|
||||||
def _exists(self, path):
|
def _exists(self, path: str) -> bool:
|
||||||
"""
|
"""
|
||||||
Returns true if the path exists.
|
Returns true if the path exists.
|
||||||
"""
|
"""
|
||||||
path = os.path.expanduser(path)
|
path = os.path.expanduser(path)
|
||||||
return os.path.exists(path)
|
return os.path.exists(path)
|
||||||
|
|
||||||
def _create(self, path, mode):
|
def _create(self, path: str, mode: int) -> bool:
|
||||||
success = True
|
success = True
|
||||||
if not self._exists(path):
|
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:
|
try:
|
||||||
self._log.lowinfo("Creating path %s" % path)
|
self._log.lowinfo(f"Creating path {path}")
|
||||||
os.makedirs(path, mode)
|
os.makedirs(path, mode)
|
||||||
# On Windows, the *mode* argument to `os.makedirs()` is ignored.
|
# On Windows, the *mode* argument to `os.makedirs()` is ignored.
|
||||||
# The mode must be set explicitly in a follow-up call.
|
# The mode must be set explicitly in a follow-up call.
|
||||||
os.chmod(path, mode)
|
os.chmod(path, mode)
|
||||||
except OSError:
|
except OSError:
|
||||||
self._log.warning("Failed to create path %s" % path)
|
self._log.warning(f"Failed to create path {path}")
|
||||||
success = False
|
success = False
|
||||||
else:
|
else:
|
||||||
self._log.lowinfo("Path exists %s" % path)
|
self._log.lowinfo(f"Path exists {path}")
|
||||||
return success
|
return success
|
|
@ -2,9 +2,10 @@ import glob
|
||||||
import os
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
import sys
|
import sys
|
||||||
|
from typing import Any, List, Optional
|
||||||
|
|
||||||
from ..plugin import Plugin
|
from dotbot.plugin import Plugin
|
||||||
from ..util import shell_command
|
from dotbot.util import shell_command
|
||||||
|
|
||||||
|
|
||||||
class Link(Plugin):
|
class Link(Plugin):
|
||||||
|
@ -14,19 +15,20 @@ class Link(Plugin):
|
||||||
|
|
||||||
_directive = "link"
|
_directive = "link"
|
||||||
|
|
||||||
def can_handle(self, directive):
|
def can_handle(self, directive: str) -> bool:
|
||||||
return directive == self._directive
|
return directive == self._directive
|
||||||
|
|
||||||
def handle(self, directive, data):
|
def handle(self, directive: str, data: Any) -> bool:
|
||||||
if directive != self._directive:
|
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)
|
return self._process_links(data)
|
||||||
|
|
||||||
def _process_links(self, links):
|
def _process_links(self, links: Any) -> bool:
|
||||||
success = True
|
success = True
|
||||||
defaults = self._context.defaults().get("link", {})
|
defaults = self._context.defaults().get("link", {})
|
||||||
for destination, source in links.items():
|
for destination, source in links.items():
|
||||||
destination = os.path.expandvars(destination)
|
destination = os.path.expandvars(destination) # noqa: PLW2901
|
||||||
relative = defaults.get("relative", False)
|
relative = defaults.get("relative", False)
|
||||||
# support old "canonicalize-path" key for compatibility
|
# support old "canonicalize-path" key for compatibility
|
||||||
canonical_path = defaults.get("canonicalize", defaults.get("canonicalize-path", True))
|
canonical_path = defaults.get("canonicalize", defaults.get("canonicalize-path", True))
|
||||||
|
@ -42,9 +44,7 @@ class Link(Plugin):
|
||||||
# extended config
|
# extended config
|
||||||
test = source.get("if", test)
|
test = source.get("if", test)
|
||||||
relative = source.get("relative", relative)
|
relative = source.get("relative", relative)
|
||||||
canonical_path = source.get(
|
canonical_path = source.get("canonicalize", source.get("canonicalize-path", canonical_path))
|
||||||
"canonicalize", source.get("canonicalize-path", canonical_path)
|
|
||||||
)
|
|
||||||
force = source.get("force", force)
|
force = source.get("force", force)
|
||||||
relink = source.get("relink", relink)
|
relink = source.get("relink", relink)
|
||||||
create = source.get("create", create)
|
create = source.get("create", create)
|
||||||
|
@ -56,20 +56,16 @@ class Link(Plugin):
|
||||||
else:
|
else:
|
||||||
path = self._default_source(destination, source)
|
path = self._default_source(destination, source)
|
||||||
if test is not None and not self._test_success(test):
|
if test is not None and not self._test_success(test):
|
||||||
self._log.lowinfo("Skipping %s" % destination)
|
self._log.lowinfo(f"Skipping {destination}")
|
||||||
continue
|
continue
|
||||||
path = os.path.normpath(os.path.expandvars(os.path.expanduser(path)))
|
path = os.path.normpath(os.path.expandvars(os.path.expanduser(path)))
|
||||||
if use_glob and self._has_glob_chars(path):
|
if use_glob and self._has_glob_chars(path):
|
||||||
glob_results = self._create_glob_results(path, exclude_paths)
|
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:
|
for glob_full_item in glob_results:
|
||||||
# Find common dirname between pattern and the item:
|
# Find common dirname between pattern and the item:
|
||||||
glob_dirname = os.path.dirname(os.path.commonprefix([path, glob_full_item]))
|
glob_dirname = os.path.dirname(os.path.commonprefix([path, glob_full_item]))
|
||||||
glob_item = (
|
glob_item = glob_full_item if len(glob_dirname) == 0 else glob_full_item[len(glob_dirname) + 1 :]
|
||||||
glob_full_item
|
|
||||||
if len(glob_dirname) == 0
|
|
||||||
else glob_full_item[len(glob_dirname) + 1 :]
|
|
||||||
)
|
|
||||||
# Add prefix to basepath, if provided
|
# Add prefix to basepath, if provided
|
||||||
if base_prefix:
|
if base_prefix:
|
||||||
glob_item = base_prefix + glob_item
|
glob_item = base_prefix + glob_item
|
||||||
|
@ -81,59 +77,59 @@ class Link(Plugin):
|
||||||
success &= self._delete(
|
success &= self._delete(
|
||||||
glob_full_item,
|
glob_full_item,
|
||||||
glob_link_destination,
|
glob_link_destination,
|
||||||
relative,
|
relative=relative,
|
||||||
canonical_path,
|
canonical_path=canonical_path,
|
||||||
force,
|
force=force,
|
||||||
)
|
)
|
||||||
success &= self._link(
|
success &= self._link(
|
||||||
glob_full_item,
|
glob_full_item,
|
||||||
glob_link_destination,
|
glob_link_destination,
|
||||||
relative,
|
relative=relative,
|
||||||
canonical_path,
|
canonical_path=canonical_path,
|
||||||
ignore_missing,
|
ignore_missing=ignore_missing,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
if create:
|
if create:
|
||||||
success &= self._create(destination)
|
success &= self._create(destination)
|
||||||
if not ignore_missing and not self._exists(
|
if not ignore_missing and not self._exists(os.path.join(self._context.base_directory(), path)):
|
||||||
os.path.join(self._context.base_directory(), path)
|
|
||||||
):
|
|
||||||
# we seemingly check this twice (here and in _link) because
|
# we seemingly check this twice (here and in _link) because
|
||||||
# if the file doesn't exist and force is True, we don't
|
# if the file doesn't exist and force is True, we don't
|
||||||
# want to remove the original (this is tested by
|
# want to remove the original (this is tested by
|
||||||
# link-force-leaves-when-nonexistent.bash)
|
# link-force-leaves-when-nonexistent.bash)
|
||||||
success = False
|
success = False
|
||||||
self._log.warning("Nonexistent source %s -> %s" % (destination, path))
|
self._log.warning(f"Nonexistent source {destination} -> {path}")
|
||||||
continue
|
continue
|
||||||
if force or relink:
|
if force or relink:
|
||||||
success &= self._delete(path, destination, relative, canonical_path, force)
|
success &= self._delete(
|
||||||
success &= self._link(path, destination, relative, canonical_path, ignore_missing)
|
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:
|
if success:
|
||||||
self._log.info("All links have been set up")
|
self._log.info("All links have been set up")
|
||||||
else:
|
else:
|
||||||
self._log.error("Some links were not successfully set up")
|
self._log.error("Some links were not successfully set up")
|
||||||
return success
|
return success
|
||||||
|
|
||||||
def _test_success(self, command):
|
def _test_success(self, command: str) -> bool:
|
||||||
ret = shell_command(command, cwd=self._context.base_directory())
|
ret = shell_command(command, cwd=self._context.base_directory())
|
||||||
if ret != 0:
|
if ret != 0:
|
||||||
self._log.debug("Test '%s' returned false" % command)
|
self._log.debug(f"Test '{command}' returned false")
|
||||||
return ret == 0
|
return ret == 0
|
||||||
|
|
||||||
def _default_source(self, destination, source):
|
def _default_source(self, destination: str, source: Optional[str]) -> str:
|
||||||
if source is None:
|
if source is None:
|
||||||
basename = os.path.basename(destination)
|
basename = os.path.basename(destination)
|
||||||
if basename.startswith("."):
|
if basename.startswith("."):
|
||||||
return basename[1:]
|
return basename[1:]
|
||||||
else:
|
return basename
|
||||||
return basename
|
return source
|
||||||
else:
|
|
||||||
return source
|
|
||||||
|
|
||||||
def _has_glob_chars(self, path):
|
def _has_glob_chars(self, path: str) -> bool:
|
||||||
return any(i in path for i in "?*[")
|
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.
|
Wrap `glob.glob` in a python agnostic way, catching errors in usage.
|
||||||
"""
|
"""
|
||||||
|
@ -147,7 +143,7 @@ class Link(Plugin):
|
||||||
# return matched results
|
# return matched results
|
||||||
return found
|
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))
|
self._log.debug("Globbing with pattern: " + str(path))
|
||||||
include = self._glob(path)
|
include = self._glob(path)
|
||||||
self._log.debug("Glob found : " + str(include))
|
self._log.debug("Glob found : " + str(include))
|
||||||
|
@ -160,44 +156,44 @@ class Link(Plugin):
|
||||||
ret = set(include) - set(exclude)
|
ret = set(include) - set(exclude)
|
||||||
return list(ret)
|
return list(ret)
|
||||||
|
|
||||||
def _is_link(self, path):
|
def _is_link(self, path: str) -> bool:
|
||||||
"""
|
"""
|
||||||
Returns true if the path is a symbolic link.
|
Returns true if the path is a symbolic link.
|
||||||
"""
|
"""
|
||||||
return os.path.islink(os.path.expanduser(path))
|
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.
|
Returns the destination of the symbolic link.
|
||||||
"""
|
"""
|
||||||
path = os.path.expanduser(path)
|
path = os.path.expanduser(path)
|
||||||
path = os.readlink(path)
|
path = os.readlink(path)
|
||||||
if sys.platform[:5] == "win32" and path.startswith("\\\\?\\"):
|
if sys.platform == "win32" and path.startswith("\\\\?\\"):
|
||||||
path = path[4:]
|
path = path[4:]
|
||||||
return path
|
return path
|
||||||
|
|
||||||
def _exists(self, path):
|
def _exists(self, path: str) -> bool:
|
||||||
"""
|
"""
|
||||||
Returns true if the path exists.
|
Returns true if the path exists.
|
||||||
"""
|
"""
|
||||||
path = os.path.expanduser(path)
|
path = os.path.expanduser(path)
|
||||||
return os.path.exists(path)
|
return os.path.exists(path)
|
||||||
|
|
||||||
def _create(self, path):
|
def _create(self, path: str) -> bool:
|
||||||
success = True
|
success = True
|
||||||
parent = os.path.abspath(os.path.join(os.path.expanduser(path), os.pardir))
|
parent = os.path.abspath(os.path.join(os.path.expanduser(path), os.pardir))
|
||||||
if not self._exists(parent):
|
if not self._exists(parent):
|
||||||
self._log.debug("Try to create parent: " + str(parent))
|
self._log.debug(f"Try to create parent: {parent}")
|
||||||
try:
|
try:
|
||||||
os.makedirs(parent)
|
os.makedirs(parent)
|
||||||
except OSError:
|
except OSError:
|
||||||
self._log.warning("Failed to create directory %s" % parent)
|
self._log.warning(f"Failed to create directory {parent}")
|
||||||
success = False
|
success = False
|
||||||
else:
|
else:
|
||||||
self._log.lowinfo("Creating directory %s" % parent)
|
self._log.lowinfo(f"Creating directory {parent}")
|
||||||
return success
|
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
|
success = True
|
||||||
source = os.path.join(self._context.base_directory(canonical_path=canonical_path), source)
|
source = os.path.join(self._context.base_directory(canonical_path=canonical_path), source)
|
||||||
fullpath = os.path.abspath(os.path.expanduser(path))
|
fullpath = os.path.abspath(os.path.expanduser(path))
|
||||||
|
@ -219,14 +215,14 @@ class Link(Plugin):
|
||||||
os.remove(fullpath)
|
os.remove(fullpath)
|
||||||
removed = True
|
removed = True
|
||||||
except OSError:
|
except OSError:
|
||||||
self._log.warning("Failed to remove %s" % path)
|
self._log.warning(f"Failed to remove {path}")
|
||||||
success = False
|
success = False
|
||||||
else:
|
else:
|
||||||
if removed:
|
if removed:
|
||||||
self._log.lowinfo("Removing %s" % path)
|
self._log.lowinfo(f"Removing {path}")
|
||||||
return success
|
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
|
Returns the relative path to get to the source file from the
|
||||||
destination file.
|
destination file.
|
||||||
|
@ -234,7 +230,7 @@ class Link(Plugin):
|
||||||
destination_dir = os.path.dirname(destination)
|
destination_dir = os.path.dirname(destination)
|
||||||
return os.path.relpath(source, destination_dir)
|
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.
|
Links link_name to source.
|
||||||
|
|
||||||
|
@ -245,18 +241,9 @@ class Link(Plugin):
|
||||||
base_directory = self._context.base_directory(canonical_path=canonical_path)
|
base_directory = self._context.base_directory(canonical_path=canonical_path)
|
||||||
absolute_source = os.path.join(base_directory, source)
|
absolute_source = os.path.join(base_directory, source)
|
||||||
link_name = os.path.normpath(link_name)
|
link_name = os.path.normpath(link_name)
|
||||||
if relative:
|
source = self._relative_path(absolute_source, destination) if relative else absolute_source
|
||||||
source = self._relative_path(absolute_source, destination)
|
if not self._exists(link_name) and self._is_link(link_name) and self._link_destination(link_name) != source:
|
||||||
else:
|
self._log.warning(f"Invalid link {link_name} -> {self._link_destination(link_name)}")
|
||||||
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))
|
|
||||||
)
|
|
||||||
# we need to use absolute_source below because our cwd is the dotfiles
|
# 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
|
# directory, and if source is relative, it will be relative to the
|
||||||
# destination directory
|
# destination directory
|
||||||
|
@ -264,23 +251,21 @@ class Link(Plugin):
|
||||||
try:
|
try:
|
||||||
os.symlink(source, destination)
|
os.symlink(source, destination)
|
||||||
except OSError:
|
except OSError:
|
||||||
self._log.warning("Linking failed %s -> %s" % (link_name, source))
|
self._log.warning(f"Linking failed {link_name} -> {source}")
|
||||||
else:
|
else:
|
||||||
self._log.lowinfo("Creating link %s -> %s" % (link_name, source))
|
self._log.lowinfo(f"Creating link {link_name} -> {source}")
|
||||||
success = True
|
success = True
|
||||||
elif self._exists(link_name) and not self._is_link(link_name):
|
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:
|
elif self._is_link(link_name) and self._link_destination(link_name) != source:
|
||||||
self._log.warning(
|
self._log.warning(f"Incorrect link {link_name} -> {self._link_destination(link_name)}")
|
||||||
"Incorrect link %s -> %s" % (link_name, self._link_destination(link_name))
|
|
||||||
)
|
|
||||||
# again, we use absolute_source to check for existence
|
# again, we use absolute_source to check for existence
|
||||||
elif not self._exists(absolute_source):
|
elif not self._exists(absolute_source):
|
||||||
if self._is_link(link_name):
|
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:
|
else:
|
||||||
self._log.warning("Nonexistent source for %s : %s" % (link_name, source))
|
self._log.warning(f"Nonexistent source for {link_name} : {source}")
|
||||||
else:
|
else:
|
||||||
self._log.lowinfo("Link exists %s -> %s" % (link_name, source))
|
self._log.lowinfo(f"Link exists {link_name} -> {source}")
|
||||||
success = True
|
success = True
|
||||||
return success
|
return success
|
|
@ -1,5 +1,7 @@
|
||||||
from ..plugin import Plugin
|
from typing import Any, Dict
|
||||||
from ..util import shell_command
|
|
||||||
|
from dotbot.plugin import Plugin
|
||||||
|
from dotbot.util import shell_command
|
||||||
|
|
||||||
|
|
||||||
class Shell(Plugin):
|
class Shell(Plugin):
|
||||||
|
@ -10,15 +12,16 @@ class Shell(Plugin):
|
||||||
_directive = "shell"
|
_directive = "shell"
|
||||||
_has_shown_override_message = False
|
_has_shown_override_message = False
|
||||||
|
|
||||||
def can_handle(self, directive):
|
def can_handle(self, directive: str) -> bool:
|
||||||
return directive == self._directive
|
return directive == self._directive
|
||||||
|
|
||||||
def handle(self, directive, data):
|
def handle(self, directive: str, data: Any) -> bool:
|
||||||
if directive != self._directive:
|
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)
|
return self._process_commands(data)
|
||||||
|
|
||||||
def _process_commands(self, data):
|
def _process_commands(self, data: Any) -> bool:
|
||||||
success = True
|
success = True
|
||||||
defaults = self._context.defaults().get("shell", {})
|
defaults = self._context.defaults().get("shell", {})
|
||||||
options = self._get_option_overrides()
|
options = self._get_option_overrides()
|
||||||
|
@ -42,11 +45,11 @@ class Shell(Plugin):
|
||||||
msg = None
|
msg = None
|
||||||
if quiet:
|
if quiet:
|
||||||
if msg is not None:
|
if msg is not None:
|
||||||
self._log.lowinfo("%s" % msg)
|
self._log.lowinfo(str(msg))
|
||||||
elif msg is None:
|
elif msg is None:
|
||||||
self._log.lowinfo(cmd)
|
self._log.lowinfo(cmd)
|
||||||
else:
|
else:
|
||||||
self._log.lowinfo("%s [%s]" % (msg, cmd))
|
self._log.lowinfo(f"{msg} [{cmd}]")
|
||||||
stdout = options.get("stdout", stdout)
|
stdout = options.get("stdout", stdout)
|
||||||
stderr = options.get("stderr", stderr)
|
stderr = options.get("stderr", stderr)
|
||||||
ret = shell_command(
|
ret = shell_command(
|
||||||
|
@ -58,14 +61,14 @@ class Shell(Plugin):
|
||||||
)
|
)
|
||||||
if ret != 0:
|
if ret != 0:
|
||||||
success = False
|
success = False
|
||||||
self._log.warning("Command [%s] failed" % cmd)
|
self._log.warning(f"Command [{cmd}] failed")
|
||||||
if success:
|
if success:
|
||||||
self._log.info("All commands have been executed")
|
self._log.info("All commands have been executed")
|
||||||
else:
|
else:
|
||||||
self._log.error("Some commands were not successfully executed")
|
self._log.error("Some commands were not successfully executed")
|
||||||
return success
|
return success
|
||||||
|
|
||||||
def _get_option_overrides(self):
|
def _get_option_overrides(self) -> Dict[str, bool]:
|
||||||
ret = {}
|
ret = {}
|
||||||
options = self._context.options()
|
options = self._context.options()
|
||||||
if options.verbose > 1:
|
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 os
|
||||||
import platform
|
import platform
|
||||||
import subprocess
|
import subprocess
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
def shell_command(command, cwd=None, enable_stdin=False, enable_stdout=False, enable_stderr=False):
|
def shell_command(
|
||||||
with open(os.devnull, "w") as devnull_w, open(os.devnull, "r") as devnull_r:
|
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
|
stdin = None if enable_stdin else devnull_r
|
||||||
stdout = None if enable_stdout else devnull_w
|
stdout = None if enable_stdout else devnull_w
|
||||||
stderr = None if enable_stderr 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
|
executable = None
|
||||||
return subprocess.call(
|
return subprocess.call(
|
||||||
command,
|
command,
|
||||||
shell=True,
|
shell=True, # noqa: S602
|
||||||
executable=executable,
|
executable=executable,
|
||||||
stdin=stdin,
|
stdin=stdin,
|
||||||
stdout=stdout,
|
stdout=stdout,
|
|
@ -1,5 +1,7 @@
|
||||||
|
import importlib.util
|
||||||
import os
|
import os
|
||||||
import sys
|
from types import ModuleType
|
||||||
|
from typing import List, Type
|
||||||
|
|
||||||
from dotbot.plugin import Plugin
|
from dotbot.plugin import Plugin
|
||||||
|
|
||||||
|
@ -7,7 +9,7 @@ from dotbot.plugin import Plugin
|
||||||
loaded_modules = []
|
loaded_modules = []
|
||||||
|
|
||||||
|
|
||||||
def load(path):
|
def load(path: str) -> List[Type[Plugin]]:
|
||||||
basename = os.path.basename(path)
|
basename = os.path.basename(path)
|
||||||
module_name, extension = os.path.splitext(basename)
|
module_name, extension = os.path.splitext(basename)
|
||||||
loaded_module = load_module(module_name, path)
|
loaded_module = load_module(module_name, path)
|
||||||
|
@ -23,11 +25,11 @@ def load(path):
|
||||||
return plugins
|
return plugins
|
||||||
|
|
||||||
|
|
||||||
import importlib.util
|
def load_module(module_name: str, path: str) -> ModuleType:
|
||||||
|
|
||||||
|
|
||||||
def load_module(module_name, path):
|
|
||||||
spec = importlib.util.spec_from_file_location(module_name, path)
|
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)
|
module = importlib.util.module_from_spec(spec)
|
||||||
spec.loader.exec_module(module)
|
spec.loader.exec_module(module)
|
||||||
return module
|
return module
|
|
@ -1,9 +1,12 @@
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
class Singleton(type):
|
class Singleton(type):
|
||||||
def __call__(cls, *args, **kwargs):
|
def __call__(cls, *args: Any, **kwargs: Any) -> Any:
|
||||||
if not hasattr(cls, "_singleton_instance"):
|
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
|
return cls._singleton_instance
|
||||||
|
|
||||||
def reset_instance(cls):
|
def reset_instance(cls) -> None:
|
||||||
if hasattr(cls, "_singleton_instance"):
|
if hasattr(cls, "_singleton_instance"):
|
||||||
del 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 shutil
|
||||||
import sys
|
import sys
|
||||||
import tempfile
|
import tempfile
|
||||||
import unittest.mock as mock
|
|
||||||
from shutil import rmtree
|
from shutil import rmtree
|
||||||
|
from typing import Any, Callable, Generator, List, Optional
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
import yaml
|
import yaml
|
||||||
|
@ -14,11 +15,11 @@ import yaml
|
||||||
import dotbot.cli
|
import dotbot.cli
|
||||||
|
|
||||||
|
|
||||||
def get_long_path(path):
|
def get_long_path(path: str) -> str:
|
||||||
"""Get the long path for a given path."""
|
"""Get the long path for a given path."""
|
||||||
|
|
||||||
# Do nothing for non-Windows platforms.
|
# Do nothing for non-Windows platforms.
|
||||||
if sys.platform[:5] != "win32":
|
if sys.platform != "win32":
|
||||||
return path
|
return path
|
||||||
|
|
||||||
buffer_size = 1000
|
buffer_size = 1000
|
||||||
|
@ -31,15 +32,14 @@ def get_long_path(path):
|
||||||
# On Linux, tempfile.TemporaryFile() requires unlink access.
|
# On Linux, tempfile.TemporaryFile() requires unlink access.
|
||||||
# This list is updated by a tempfile._mkstemp_inner() wrapper,
|
# This list is updated by a tempfile._mkstemp_inner() wrapper,
|
||||||
# and its contents are checked by wrapped functions.
|
# 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 wrap_function(
|
||||||
def wrapper(*args, **kwargs):
|
function: Callable[..., Any], function_path: str, arg_index: int, kwarg_key: str, root: str
|
||||||
if kwarg_key in kwargs:
|
) -> Callable[..., Any]:
|
||||||
value = kwargs[kwarg_key]
|
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
||||||
else:
|
value = kwargs[kwarg_key] if kwarg_key in kwargs else args[arg_index]
|
||||||
value = args[arg_index]
|
|
||||||
|
|
||||||
# Allow tempfile.TemporaryFile's internal unlink calls to work.
|
# Allow tempfile.TemporaryFile's internal unlink calls to work.
|
||||||
if value in allowed_tempfile_internal_unlink_calls:
|
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
|
return wrapper
|
||||||
|
|
||||||
|
|
||||||
def wrap_open(root):
|
def wrap_open(root: str) -> Callable[..., Any]:
|
||||||
wrapped = getattr(builtins, "open")
|
wrapped = builtins.open
|
||||||
|
|
||||||
def wrapper(*args, **kwargs):
|
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
||||||
if "file" in kwargs:
|
value = kwargs["file"] if "file" in kwargs else args[0]
|
||||||
value = kwargs["file"]
|
|
||||||
else:
|
|
||||||
value = args[0]
|
|
||||||
|
|
||||||
mode = "r"
|
mode = "r"
|
||||||
if "mode" in kwargs:
|
if "mode" in kwargs:
|
||||||
|
@ -87,7 +84,7 @@ def wrap_open(root):
|
||||||
return wrapper
|
return wrapper
|
||||||
|
|
||||||
|
|
||||||
def rmtree_error_handler(_, path, __):
|
def rmtree_error_handler(_function: Any, path: str, _excinfo: Any) -> None:
|
||||||
# Handle read-only files and directories.
|
# Handle read-only files and directories.
|
||||||
os.chmod(path, 0o777)
|
os.chmod(path, 0o777)
|
||||||
if os.path.isdir(path):
|
if os.path.isdir(path):
|
||||||
|
@ -97,7 +94,7 @@ def rmtree_error_handler(_, path, __):
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True, scope="session")
|
@pytest.fixture(autouse=True, scope="session")
|
||||||
def standardize_tmp():
|
def standardize_tmp() -> None:
|
||||||
r"""Standardize the temporary directory path.
|
r"""Standardize the temporary directory path.
|
||||||
|
|
||||||
On MacOS, `/var` is a symlink to `/private/var`.
|
On MacOS, `/var` is a symlink to `/private/var`.
|
||||||
|
@ -114,21 +111,21 @@ def standardize_tmp():
|
||||||
# MacOS: `/var` is a symlink.
|
# MacOS: `/var` is a symlink.
|
||||||
tmp = os.path.abspath(os.path.realpath(tmp))
|
tmp = os.path.abspath(os.path.realpath(tmp))
|
||||||
# Windows: The temporary directory may be a short path.
|
# Windows: The temporary directory may be a short path.
|
||||||
if sys.platform[:5] == "win32":
|
if sys.platform == "win32":
|
||||||
tmp = get_long_path(tmp)
|
tmp = get_long_path(tmp)
|
||||||
os.environ["TMP"] = tmp
|
os.environ["TMP"] = tmp
|
||||||
os.environ["TEMP"] = tmp
|
os.environ["TEMP"] = tmp
|
||||||
os.environ["TMPDIR"] = tmp
|
os.environ["TMPDIR"] = tmp
|
||||||
tempfile.tempdir = tmp
|
tempfile.tempdir = tmp
|
||||||
yield
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
@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."""
|
"""Create a temporary directory for the duration of each test."""
|
||||||
|
|
||||||
# Reset allowed_tempfile_internal_unlink_calls.
|
# 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 = []
|
allowed_tempfile_internal_unlink_calls = []
|
||||||
|
|
||||||
# Dotbot changes the current working directory,
|
# Dotbot changes the current working directory,
|
||||||
|
@ -180,7 +177,7 @@ def root(standardize_tmp):
|
||||||
(shutil, "unpack_archive", 1, "extract_dir"),
|
(shutil, "unpack_archive", 1, "extract_dir"),
|
||||||
]
|
]
|
||||||
|
|
||||||
patches = []
|
patches: List[Any] = []
|
||||||
for module, function_name, arg_index, kwarg_key in functions_to_wrap:
|
for module, function_name, arg_index, kwarg_key in functions_to_wrap:
|
||||||
# Skip anything that doesn't exist in this version of Python.
|
# Skip anything that doesn't exist in this version of Python.
|
||||||
if not hasattr(module, function_name):
|
if not hasattr(module, function_name):
|
||||||
|
@ -188,7 +185,7 @@ def root(standardize_tmp):
|
||||||
|
|
||||||
# These values must be passed to a separate function
|
# These values must be passed to a separate function
|
||||||
# to ensure the variable closures work correctly.
|
# 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)
|
function = getattr(module, function_name)
|
||||||
wrapped = wrap_function(function, function_path, arg_index, kwarg_key, current_root)
|
wrapped = wrap_function(function, function_path, arg_index, kwarg_key, current_root)
|
||||||
patches.append(mock.patch(function_path, wrapped))
|
patches.append(mock.patch(function_path, wrapped))
|
||||||
|
@ -200,13 +197,13 @@ def root(standardize_tmp):
|
||||||
|
|
||||||
# Block all access to bad functions.
|
# Block all access to bad functions.
|
||||||
if hasattr(os, "chroot"):
|
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()
|
# Patch tempfile._mkstemp_inner() so tempfile.TemporaryFile()
|
||||||
# can unlink files immediately.
|
# 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)
|
(fd, name) = mkstemp_inner(*args, **kwargs)
|
||||||
allowed_tempfile_internal_unlink_calls.append(name)
|
allowed_tempfile_internal_unlink_calls.append(name)
|
||||||
return fd, name
|
return fd, name
|
||||||
|
@ -219,7 +216,8 @@ def root(standardize_tmp):
|
||||||
finally:
|
finally:
|
||||||
# Patches must be stopped in reverse order because some patches are nested.
|
# Patches must be stopped in reverse order because some patches are nested.
|
||||||
# Stopping in the reverse order restores the original function.
|
# 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)
|
os.chdir(current_working_directory)
|
||||||
if sys.version_info >= (3, 12):
|
if sys.version_info >= (3, 12):
|
||||||
rmtree(current_root, onexc=rmtree_error_handler)
|
rmtree(current_root, onexc=rmtree_error_handler)
|
||||||
|
@ -228,7 +226,7 @@ def root(standardize_tmp):
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def home(monkeypatch, root):
|
def home(monkeypatch: pytest.MonkeyPatch, root: str) -> str:
|
||||||
"""Create a home directory for the duration of the test.
|
"""Create a home directory for the duration of the test.
|
||||||
|
|
||||||
On *nix, the environment variable "HOME" will be mocked.
|
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"))
|
home = os.path.abspath(os.path.join(root, "home/user"))
|
||||||
os.makedirs(home)
|
os.makedirs(home)
|
||||||
if sys.platform[:5] == "win32":
|
if sys.platform == "win32":
|
||||||
monkeypatch.setenv("USERPROFILE", home)
|
monkeypatch.setenv("USERPROFILE", home)
|
||||||
else:
|
else:
|
||||||
monkeypatch.setenv("HOME", home)
|
monkeypatch.setenv("HOME", home)
|
||||||
yield home
|
return home
|
||||||
|
|
||||||
|
|
||||||
class Dotfiles:
|
class Dotfiles:
|
||||||
"""Create and manage a dotfiles directory for a test."""
|
"""Create and manage a dotfiles directory for a test."""
|
||||||
|
|
||||||
def __init__(self, root):
|
def __init__(self, root: str):
|
||||||
self.root = root
|
self.root = root
|
||||||
self.config = None
|
self.config = None
|
||||||
self.config_filename = None
|
self._config_filename: Optional[str] = None
|
||||||
self.directory = os.path.join(root, "dotfiles")
|
self.directory = os.path.join(root, "dotfiles")
|
||||||
os.mkdir(self.directory)
|
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)))
|
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))
|
path = os.path.abspath(os.path.join(self.directory, path))
|
||||||
if not os.path.exists(os.path.dirname(path)):
|
if not os.path.exists(os.path.dirname(path)):
|
||||||
os.makedirs(os.path.dirname(path))
|
os.makedirs(os.path.dirname(path))
|
||||||
with open(path, "w") as file:
|
with open(path, "w") as file:
|
||||||
file.write(content)
|
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."""
|
"""Write a dotbot config and return the filename."""
|
||||||
|
|
||||||
assert serializer in {"json", "yaml"}, "Only json and yaml are supported"
|
assert serializer in {"json", "yaml"}, "Only json and yaml are supported"
|
||||||
if serializer == "yaml":
|
if serializer == "yaml":
|
||||||
serialize = yaml.dump
|
serialize: Callable[[Any], str] = yaml.dump
|
||||||
else: # serializer == "json"
|
else: # serializer == "json"
|
||||||
serialize = json.dumps
|
serialize = json.dumps
|
||||||
|
|
||||||
if path:
|
if path is not None:
|
||||||
msg = "The config file path must be an absolute path"
|
msg = "The config file path must be an absolute path"
|
||||||
assert path == os.path.abspath(path), msg
|
assert path == os.path.abspath(path), msg
|
||||||
|
|
||||||
|
@ -281,25 +279,30 @@ class Dotfiles:
|
||||||
msg = msg.format(root)
|
msg = msg.format(root)
|
||||||
assert path[: len(str(root))] == str(root), msg
|
assert path[: len(str(root))] == str(root), msg
|
||||||
|
|
||||||
self.config_filename = path
|
self._config_filename = path
|
||||||
else:
|
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
|
self.config = config
|
||||||
|
|
||||||
with open(self.config_filename, "w") as file:
|
with open(self._config_filename, "w") as file:
|
||||||
file.write(serialize(config))
|
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
|
@pytest.fixture
|
||||||
def dotfiles(root):
|
def dotfiles(root: str) -> Dotfiles:
|
||||||
"""Create a dotfiles directory."""
|
"""Create a dotfiles directory."""
|
||||||
|
|
||||||
yield Dotfiles(root)
|
return Dotfiles(root)
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def run_dotbot(dotfiles):
|
def run_dotbot(dotfiles: Dotfiles) -> Callable[..., None]:
|
||||||
"""Run dotbot.
|
"""Run dotbot.
|
||||||
|
|
||||||
When calling `runner()`, only CLI arguments need to be specified.
|
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.
|
and the caller will be responsible for all CLI arguments.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def runner(*argv, **kwargs):
|
def runner(*argv: Any, **kwargs: Any) -> None:
|
||||||
argv = ["dotbot"] + list(argv)
|
argv = ("dotbot", *argv)
|
||||||
if kwargs.get("custom", False) is not True:
|
if kwargs.get("custom", False) is not True:
|
||||||
argv.extend(["-c", dotfiles.config_filename])
|
argv = (*argv, "-c", dotfiles.config_filename)
|
||||||
with mock.patch("sys.argv", argv):
|
with mock.patch("sys.argv", list(argv)):
|
||||||
dotbot.cli.main()
|
dotbot.cli.main()
|
||||||
|
|
||||||
yield runner
|
return runner
|
||||||
|
|
|
@ -1,15 +1,20 @@
|
||||||
# https://github.com/anishathalye/dotbot/issues/339
|
# https://github.com/anishathalye/dotbot/issues/339
|
||||||
# plugins should be able to instantiate a Dispatcher with all the plugins
|
# plugins should be able to instantiate a Dispatcher with all the plugins
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
import dotbot
|
import dotbot
|
||||||
from dotbot.dispatcher import Dispatcher
|
from dotbot.dispatcher import Dispatcher
|
||||||
|
|
||||||
|
|
||||||
class Dispatch(dotbot.Plugin):
|
class Dispatch(dotbot.Plugin):
|
||||||
def can_handle(self, directive):
|
def can_handle(self, directive: str) -> bool:
|
||||||
return directive == "dispatch"
|
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(
|
dispatcher = Dispatcher(
|
||||||
base_directory=self._context.base_directory(),
|
base_directory=self._context.base_directory(),
|
||||||
options=self._context.options(),
|
options=self._context.options(),
|
||||||
|
|
|
@ -5,21 +5,23 @@ and is then loaded from within the `test_cli.py` code.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os.path
|
import os.path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
import dotbot
|
import dotbot
|
||||||
|
|
||||||
|
|
||||||
class Directory(dotbot.Plugin):
|
class Directory(dotbot.Plugin):
|
||||||
def can_handle(self, directive):
|
def can_handle(self, directive: str) -> bool:
|
||||||
return directive == "plugin_directory"
|
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")
|
self._log.debug("Attempting to get options from Context")
|
||||||
options = self._context.options()
|
options = self._context.options()
|
||||||
if len(options.plugin_dirs) != 1:
|
if len(options.plugin_dirs) != 1:
|
||||||
self._log.debug(
|
self._log.debug("Context.options.plugins length is %i, expected 1" % len(options.plugins))
|
||||||
"Context.options.plugins length is %i, expected 1" % len(options.plugins)
|
|
||||||
)
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
with open(os.path.abspath(os.path.expanduser("~/flag")), "w") as file:
|
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,
|
# if plugins instantiate a Dispatcher without explicitly passing in plugins,
|
||||||
# the Dispatcher should have access to all plugins (matching context.plugins())
|
# the Dispatcher should have access to all plugins (matching context.plugins())
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
import dotbot
|
import dotbot
|
||||||
from dotbot.dispatcher import Dispatcher
|
from dotbot.dispatcher import Dispatcher
|
||||||
|
|
||||||
|
|
||||||
class Dispatch(dotbot.Plugin):
|
class Dispatch(dotbot.Plugin):
|
||||||
def can_handle(self, directive):
|
def can_handle(self, directive: str) -> bool:
|
||||||
return directive == "dispatch"
|
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(
|
dispatcher = Dispatcher(
|
||||||
base_directory=self._context.base_directory(),
|
base_directory=self._context.base_directory(),
|
||||||
options=self._context.options(),
|
options=self._context.options(),
|
||||||
|
|
|
@ -5,26 +5,26 @@ and is then loaded from within the `test_cli.py` code.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os.path
|
import os.path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
import dotbot
|
import dotbot
|
||||||
|
|
||||||
|
|
||||||
class File(dotbot.Plugin):
|
class File(dotbot.Plugin):
|
||||||
def can_handle(self, directive):
|
def can_handle(self, directive: str) -> bool:
|
||||||
return directive == "plugin_file"
|
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")
|
self._log.debug("Attempting to get options from Context")
|
||||||
options = self._context.options()
|
options = self._context.options()
|
||||||
if len(options.plugins) != 1:
|
if len(options.plugins) != 1:
|
||||||
self._log.debug(
|
self._log.debug(f"Context.options.plugins length is {len(options.plugins)}, expected 1")
|
||||||
"Context.options.plugins length is %i, expected 1" % len(options.plugins)
|
|
||||||
)
|
|
||||||
return False
|
return False
|
||||||
if not options.plugins[0].endswith("file.py"):
|
if not options.plugins[0].endswith("file.py"):
|
||||||
self._log.debug(
|
self._log.debug(f"Context.options.plugins[0] is {options.plugins[0]}, expected end with file.py")
|
||||||
"Context.options.plugins[0] is %s, expected end with file.py" % options.plugins[0]
|
|
||||||
)
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
with open(os.path.abspath(os.path.expanduser("~/flag")), "w") as file:
|
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.plugin import Plugin
|
||||||
from dotbot.plugins import Clean, Create, Link, Shell
|
|
||||||
|
|
||||||
# https://github.com/anishathalye/dotbot/issues/357
|
# https://github.com/anishathalye/dotbot/issues/357
|
||||||
# if we import from dotbot.plugins, the built-in plugins get executed multiple times
|
# 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):
|
class NoopPlugin(Plugin):
|
||||||
_directive = "noop"
|
_directive = "noop"
|
||||||
|
|
||||||
def can_handle(self, directive):
|
def can_handle(self, directive: str) -> bool:
|
||||||
return directive == self._directive
|
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
|
return True
|
||||||
|
|
|
@ -1,30 +1,32 @@
|
||||||
import os
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
from tests.conftest import Dotfiles
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.skipif(
|
@pytest.mark.skipif(
|
||||||
"sys.platform[:5] == 'win32'",
|
"sys.platform == 'win32'",
|
||||||
reason="The hybrid sh/Python dotbot script doesn't run on Windows platforms",
|
reason="The hybrid sh/Python dotbot script doesn't run on Windows platforms",
|
||||||
)
|
)
|
||||||
@pytest.mark.parametrize("python_name", (None, "python", "python3"))
|
@pytest.mark.parametrize("python_name", [None, "python", "python3"])
|
||||||
def test_find_python_executable(python_name, home, dotfiles):
|
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."""
|
"""Verify that the sh/Python hybrid dotbot executable can find Python."""
|
||||||
|
|
||||||
dotfiles.write_config([])
|
dotfiles.write_config([])
|
||||||
dotbot_executable = os.path.join(
|
dotbot_executable = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "bin", "dotbot")
|
||||||
os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "bin", "dotbot"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Create a link to sh.
|
# Create a link to sh.
|
||||||
tmp_bin = os.path.join(home, "tmp_bin")
|
tmp_bin = os.path.join(home, "tmp_bin")
|
||||||
os.makedirs(tmp_bin)
|
os.makedirs(tmp_bin)
|
||||||
sh_path = shutil.which("sh")
|
sh_path = shutil.which("sh")
|
||||||
|
assert sh_path is not None
|
||||||
os.symlink(sh_path, os.path.join(tmp_bin, "sh"))
|
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:
|
with open(os.path.join(tmp_bin, python_name), "w") as file:
|
||||||
file.write("#!" + tmp_bin + "/sh\n")
|
file.write("#!" + tmp_bin + "/sh\n")
|
||||||
file.write("exit 0\n")
|
file.write("exit 0\n")
|
||||||
|
@ -32,7 +34,7 @@ def test_find_python_executable(python_name, home, dotfiles):
|
||||||
env = dict(os.environ)
|
env = dict(os.environ)
|
||||||
env["PATH"] = tmp_bin
|
env["PATH"] = tmp_bin
|
||||||
|
|
||||||
if python_name:
|
if python_name is not None:
|
||||||
subprocess.check_call(
|
subprocess.check_call(
|
||||||
[dotbot_executable, "-c", dotfiles.config_filename],
|
[dotbot_executable, "-c", dotfiles.config_filename],
|
||||||
env=env,
|
env=env,
|
||||||
|
|
|
@ -1,10 +1,11 @@
|
||||||
import os
|
import os
|
||||||
import sys
|
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."""
|
"""Verify clean uses default unless overridden."""
|
||||||
|
|
||||||
os.symlink(os.path.join(root, "nowhere"), os.path.join(home, ".g"))
|
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"))
|
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."""
|
"""Verify clean expands environment variables."""
|
||||||
|
|
||||||
os.symlink(os.path.join(dotfiles.directory, "f"), os.path.join(home, ".f"))
|
os.symlink(os.path.join(dotfiles.directory, "f"), os.path.join(home, ".f"))
|
||||||
variable = "$HOME"
|
variable = "$HOME"
|
||||||
if sys.platform[:5] == "win32":
|
if sys.platform == "win32":
|
||||||
variable = "$USERPROFILE"
|
variable = "$USERPROFILE"
|
||||||
dotfiles.write_config([{"clean": [variable]}])
|
dotfiles.write_config([{"clean": [variable]}])
|
||||||
run_dotbot()
|
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"))
|
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."""
|
"""Verify clean deletes links to missing files."""
|
||||||
|
|
||||||
dotfiles.write("f")
|
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"))
|
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."""
|
"""Verify clean ignores nonexistent directories."""
|
||||||
|
|
||||||
dotfiles.write_config([{"clean": ["~", "~/fake"]}])
|
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"))
|
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."""
|
"""Verify clean forced to remove files linking outside dotfiles directory."""
|
||||||
|
|
||||||
os.symlink(os.path.join(root, "nowhere"), os.path.join(home, ".g"))
|
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"))
|
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."""
|
"""Verify clean ignores files linking outside dotfiles directory."""
|
||||||
|
|
||||||
os.symlink(os.path.join(dotfiles.directory, "f"), os.path.join(home, ".f"))
|
os.symlink(os.path.join(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"))
|
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)."""
|
"""Verify clean respects when the recursive directive is off (default)."""
|
||||||
|
|
||||||
os.makedirs(os.path.join(home, "a", "b"))
|
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"))
|
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."""
|
"""Verify clean respects when the recursive directive is on."""
|
||||||
|
|
||||||
os.makedirs(os.path.join(home, "a", "b"))
|
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"))
|
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."""
|
"""Verify that clean doesn't erase non-dotfiles links by default."""
|
||||||
|
|
||||||
os.symlink(os.path.join(root, "nowhere"), os.path.join(home, ".g"))
|
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"))
|
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."""
|
"""Verify that explicit clean defaults override the implicit default."""
|
||||||
|
|
||||||
os.symlink(os.path.join(root, "nowhere"), os.path.join(home, ".g"))
|
os.symlink(os.path.join(root, "nowhere"), os.path.join(home, ".g"))
|
||||||
|
|
|
@ -1,10 +1,15 @@
|
||||||
import os
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
|
from typing import Callable
|
||||||
|
|
||||||
import pytest
|
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."""
|
"""Verify that `--except` works as intended."""
|
||||||
|
|
||||||
dotfiles.write_config(
|
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)
|
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."""
|
"""Verify that `--except` works as intended."""
|
||||||
|
|
||||||
dotfiles.write_config(
|
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)
|
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."""
|
"""Verify that `--except` works with multiple exceptions."""
|
||||||
|
|
||||||
dotfiles.write_config(
|
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)
|
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."""
|
"""Verify that processing can halt immediately on failures."""
|
||||||
|
|
||||||
dotfiles.write_config(
|
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"))
|
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."""
|
"""Verify that `--only` works as intended."""
|
||||||
|
|
||||||
dotfiles.write_config(
|
dotfiles.write_config(
|
||||||
|
@ -97,7 +108,9 @@ def test_only(capfd, home, dotfiles, run_dotbot):
|
||||||
assert any(line.startswith("success") for line in stdout)
|
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."""
|
"""Verify that `--only` does not suppress defaults."""
|
||||||
|
|
||||||
dotfiles.write_config(
|
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)
|
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."""
|
"""Verify that `--only` works as intended."""
|
||||||
|
|
||||||
dotfiles.write_config(
|
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"))
|
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."""
|
"""Verify that plugins can be loaded by file."""
|
||||||
|
|
||||||
plugin_file = os.path.join(os.path.dirname(os.path.abspath(__file__)), "dotbot_plugin_file.py")
|
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": "~"}])
|
dotfiles.write_config([{"plugin_file": "~"}])
|
||||||
run_dotbot("--plugin", os.path.join(dotfiles.directory, "file.py"))
|
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"
|
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."""
|
"""Verify that plugins can be loaded from a directory."""
|
||||||
|
|
||||||
dotfiles.makedirs("plugins")
|
dotfiles.makedirs("plugins")
|
||||||
plugin_file = os.path.join(
|
plugin_file = os.path.join(os.path.dirname(os.path.abspath(__file__)), "dotbot_plugin_directory.py")
|
||||||
os.path.dirname(os.path.abspath(__file__)), "dotbot_plugin_directory.py"
|
|
||||||
)
|
|
||||||
shutil.copy(plugin_file, os.path.join(dotfiles.directory, "plugins", "directory.py"))
|
shutil.copy(plugin_file, os.path.join(dotfiles.directory, "plugins", "directory.py"))
|
||||||
dotfiles.write_config([{"plugin_directory": "~"}])
|
dotfiles.write_config([{"plugin_directory": "~"}])
|
||||||
run_dotbot("--plugin-dir", os.path.join(dotfiles.directory, "plugins"))
|
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"
|
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
|
"""Verify that built-in plugins are only executed once, when
|
||||||
using a plugin that imports from dotbot.plugins."""
|
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}]}])
|
dotfiles.write_config([{"shell": [{"command": "echo apple", "stdout": True}]}])
|
||||||
|
|
||||||
run_dotbot("--plugin", plugin_file)
|
run_dotbot("--plugin", plugin_file)
|
||||||
|
|
||||||
assert (
|
assert len([line for line in capfd.readouterr().out.splitlines() if line.strip() == "apple"]) == 1
|
||||||
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."""
|
"""Verify that builtin plugins can be disabled."""
|
||||||
|
|
||||||
dotfiles.write("f", "apple")
|
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"))
|
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."""
|
"""Verify that the plugin context is available to plugins."""
|
||||||
|
|
||||||
plugin_file = os.path.join(
|
_ = home
|
||||||
os.path.dirname(os.path.abspath(__file__)), "dotbot_plugin_context_plugin.py"
|
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"))
|
shutil.copy(plugin_file, os.path.join(dotfiles.directory, "plugin.py"))
|
||||||
dotfiles.write_config([{"dispatch": [{"shell": [{"command": "echo apple", "stdout": True}]}]}])
|
dotfiles.write_config([{"dispatch": [{"shell": [{"command": "echo apple", "stdout": True}]}]}])
|
||||||
run_dotbot("--plugin", os.path.join(dotfiles.directory, "plugin.py"))
|
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)
|
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."""
|
"""Verify that plugins instantiating Dispatcher without plugins work."""
|
||||||
|
|
||||||
plugin_file = os.path.join(
|
_ = home
|
||||||
os.path.dirname(os.path.abspath(__file__)), "dotbot_plugin_dispatcher_no_plugins.py"
|
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"))
|
shutil.copy(plugin_file, os.path.join(dotfiles.directory, "plugin.py"))
|
||||||
dotfiles.write_config([{"dispatch": [{"shell": [{"command": "echo apple", "stdout": True}]}]}])
|
dotfiles.write_config([{"dispatch": [{"shell": [{"command": "echo apple", "stdout": True}]}]}])
|
||||||
run_dotbot("--plugin", os.path.join(dotfiles.directory, "plugin.py"))
|
run_dotbot("--plugin", os.path.join(dotfiles.directory, "plugin.py"))
|
||||||
|
|
|
@ -1,22 +1,25 @@
|
||||||
import json
|
import json
|
||||||
import os
|
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."""
|
"""Verify blank configs work."""
|
||||||
|
|
||||||
dotfiles.write_config([])
|
dotfiles.write_config([])
|
||||||
run_dotbot()
|
run_dotbot()
|
||||||
|
|
||||||
|
|
||||||
def test_config_empty(dotfiles, run_dotbot):
|
def test_config_empty(dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None:
|
||||||
"""Verify empty configs work."""
|
"""Verify empty configs work."""
|
||||||
|
|
||||||
dotfiles.write("config.yaml", "")
|
dotfiles.write("config.yaml", "")
|
||||||
run_dotbot("-c", os.path.join(dotfiles.directory, "config.yaml"), custom=True)
|
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."""
|
"""Verify JSON configs work."""
|
||||||
|
|
||||||
document = json.dumps([{"create": ["~/d"]}])
|
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"))
|
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."""
|
"""Verify JSON configs with tabs work."""
|
||||||
|
|
||||||
document = """[\n\t{\n\t\t"create": ["~/d"]\n\t}\n]"""
|
document = """[\n\t{\n\t\t"create": ["~/d"]\n\t}\n]"""
|
||||||
|
|
|
@ -1,13 +1,17 @@
|
||||||
import os
|
import os
|
||||||
import stat
|
import stat
|
||||||
|
from typing import Callable
|
||||||
|
|
||||||
import pytest
|
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."""
|
"""Test creating directories, including nested directories."""
|
||||||
|
|
||||||
|
_ = home
|
||||||
dotfiles.write_config([{"create": [directory]}])
|
dotfiles.write_config([{"create": [directory]}])
|
||||||
run_dotbot()
|
run_dotbot()
|
||||||
|
|
||||||
|
@ -16,13 +20,14 @@ def test_directory_creation(home, directory, dotfiles, run_dotbot):
|
||||||
assert os.stat(expanded_directory).st_mode & 0o777 == 0o777
|
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.
|
"""Test creating a directory with an explicit default mode.
|
||||||
|
|
||||||
Note: `os.chmod()` on Windows only supports changing write permissions.
|
Note: `os.chmod()` on Windows only supports changing write permissions.
|
||||||
Therefore, this test is restricted to testing read-only access.
|
Therefore, this test is restricted to testing read-only access.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
_ = home
|
||||||
read_only = 0o777 - stat.S_IWUSR - stat.S_IWGRP - stat.S_IWOTH
|
read_only = 0o777 - stat.S_IWUSR - stat.S_IWGRP - stat.S_IWOTH
|
||||||
config = [{"defaults": {"create": {"mode": read_only}}}, {"create": ["~/a"]}]
|
config = [{"defaults": {"create": {"mode": read_only}}}, {"create": ["~/a"]}]
|
||||||
dotfiles.write_config(config)
|
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
|
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.
|
"""Test creating a directory that overrides an explicit default mode.
|
||||||
|
|
||||||
Note: `os.chmod()` on Windows only supports changing write permissions.
|
Note: `os.chmod()` on Windows only supports changing write permissions.
|
||||||
Therefore, this test is restricted to testing read-only access.
|
Therefore, this test is restricted to testing read-only access.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
_ = home
|
||||||
read_only = 0o777 - stat.S_IWUSR - stat.S_IWGRP - stat.S_IWOTH
|
read_only = 0o777 - stat.S_IWUSR - stat.S_IWGRP - stat.S_IWOTH
|
||||||
config = [
|
config = [
|
||||||
{"defaults": {"create": {"mode": read_only}}},
|
{"defaults": {"create": {"mode": read_only}}},
|
||||||
|
|
|
@ -1,10 +1,13 @@
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
|
from typing import Callable, Optional
|
||||||
|
|
||||||
import pytest
|
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.
|
"""Verify links to symlinked destinations are canonical.
|
||||||
|
|
||||||
"Canonical", here, means that dotbot does not create symlinks
|
"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")
|
expected = os.path.join(dotfiles.directory, "f")
|
||||||
actual = os.readlink(os.path.abspath(os.path.expanduser("~/.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:]
|
actual = actual[4:]
|
||||||
assert expected == actual
|
assert expected == actual
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("dst", ("~/.f", "~/f"))
|
@pytest.mark.parametrize("dst", ["~/.f", "~/f"])
|
||||||
@pytest.mark.parametrize("include_force", (True, False))
|
@pytest.mark.parametrize("include_force", [True, False])
|
||||||
def test_link_default_source(root, home, dst, include_force, dotfiles, run_dotbot):
|
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.
|
"""Verify that default sources are calculated correctly.
|
||||||
|
|
||||||
This test includes verifying files with and without leading periods,
|
This test includes verifying files with and without leading periods,
|
||||||
as well as verifying handling of None dict values.
|
as well as verifying handling of None dict values.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
_ = home
|
||||||
dotfiles.write("f", "apple")
|
dotfiles.write("f", "apple")
|
||||||
config = [
|
config = [
|
||||||
{
|
{
|
||||||
|
@ -47,13 +57,14 @@ def test_link_default_source(root, home, dst, include_force, dotfiles, run_dotbo
|
||||||
dotfiles.write_config(config)
|
dotfiles.write_config(config)
|
||||||
run_dotbot()
|
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"
|
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."""
|
"""Verify link expands user in target."""
|
||||||
|
|
||||||
|
_ = home
|
||||||
src = "~/f"
|
src = "~/f"
|
||||||
target = "~/g"
|
target = "~/g"
|
||||||
with open(os.path.abspath(os.path.expanduser(src)), "w") as file:
|
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}}])
|
dotfiles.write_config([{"link": {target: src}}])
|
||||||
run_dotbot()
|
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"
|
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."""
|
"""Verify link expands environment variables in source."""
|
||||||
|
|
||||||
|
_ = home
|
||||||
monkeypatch.setenv("APPLE", "h")
|
monkeypatch.setenv("APPLE", "h")
|
||||||
target = "~/.i"
|
target = "~/.i"
|
||||||
src = "$APPLE"
|
src = "$APPLE"
|
||||||
|
@ -75,15 +89,16 @@ def test_link_environment_variable_expansion_source(monkeypatch, root, home, dot
|
||||||
dotfiles.write_config([{"link": {target: src}}])
|
dotfiles.write_config([{"link": {target: src}}])
|
||||||
run_dotbot()
|
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"
|
assert file.read() == "grape"
|
||||||
|
|
||||||
|
|
||||||
def test_link_environment_variable_expansion_source_extended(
|
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."""
|
"""Verify link expands environment variables in extended config syntax."""
|
||||||
|
|
||||||
|
_ = home
|
||||||
monkeypatch.setenv("APPLE", "h")
|
monkeypatch.setenv("APPLE", "h")
|
||||||
target = "~/.i"
|
target = "~/.i"
|
||||||
src = "$APPLE"
|
src = "$APPLE"
|
||||||
|
@ -91,11 +106,13 @@ def test_link_environment_variable_expansion_source_extended(
|
||||||
dotfiles.write_config([{"link": {target: {"path": src, "relink": True}}}])
|
dotfiles.write_config([{"link": {target: {"path": src, "relink": True}}}])
|
||||||
run_dotbot()
|
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"
|
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.
|
"""Verify link expands environment variables in target.
|
||||||
|
|
||||||
If the variable doesn't exist, the "variable" must not be replaced.
|
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)
|
dotfiles.write_config(config)
|
||||||
run_dotbot()
|
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"
|
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"
|
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."""
|
"""Verify link leaves unset environment variables."""
|
||||||
|
|
||||||
monkeypatch.delenv("ORANGE", raising=False)
|
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"}}])
|
dotfiles.write_config([{"link": {"~/f": "$ORANGE"}}])
|
||||||
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"
|
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."""
|
"""Verify force doesn't erase sources when targets are nonexistent."""
|
||||||
|
|
||||||
os.mkdir(os.path.join(home, "dir"))
|
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"))
|
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."""
|
"""Verify force overwrites a symlinked directory."""
|
||||||
|
|
||||||
os.mkdir(os.path.join(home, "dir"))
|
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"))
|
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."""
|
"""Verify globbing works."""
|
||||||
|
|
||||||
dotfiles.write("bin/a", "apple")
|
dotfiles.write("bin/a", "apple")
|
||||||
|
@ -197,7 +216,7 @@ def test_link_glob_1(home, dotfiles, run_dotbot):
|
||||||
assert file.read() == "cherry"
|
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."""
|
"""Verify globbing works with a trailing slash in the source."""
|
||||||
|
|
||||||
dotfiles.write("bin/a", "apple")
|
dotfiles.write("bin/a", "apple")
|
||||||
|
@ -219,7 +238,7 @@ def test_link_glob_2(home, dotfiles, run_dotbot):
|
||||||
assert file.read() == "cherry"
|
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."""
|
"""Verify globbing works with hidden ("period-prefixed") files."""
|
||||||
|
|
||||||
dotfiles.write("bin/.a", "dot-apple")
|
dotfiles.write("bin/.a", "dot-apple")
|
||||||
|
@ -241,7 +260,7 @@ def test_link_glob_3(home, dotfiles, run_dotbot):
|
||||||
assert file.read() == "dot-cherry"
|
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."""
|
"""Verify globbing works at the root of the home and dotfiles directories."""
|
||||||
|
|
||||||
dotfiles.write(".a", "dot-apple")
|
dotfiles.write(".a", "dot-apple")
|
||||||
|
@ -269,8 +288,10 @@ def test_link_glob_4(home, dotfiles, run_dotbot):
|
||||||
assert file.read() == "dot-cherry"
|
assert file.read() == "dot-cherry"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("path", ("foo", "foo/"))
|
@pytest.mark.parametrize("path", ["foo", "foo/"])
|
||||||
def test_link_glob_ignore_no_glob_chars(path, home, dotfiles, run_dotbot):
|
def test_link_glob_ignore_no_glob_chars(
|
||||||
|
path: str, home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]
|
||||||
|
) -> None:
|
||||||
"""Verify ambiguous link globbing fails."""
|
"""Verify ambiguous link globbing fails."""
|
||||||
|
|
||||||
dotfiles.makedirs("foo")
|
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"))
|
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."""
|
"""Verify link globbing with an explicit exclusion."""
|
||||||
|
|
||||||
dotfiles.write("config/foo/a", "apple")
|
dotfiles.write("config/foo/a", "apple")
|
||||||
|
@ -333,7 +354,7 @@ def test_link_glob_exclude_1(home, dotfiles, run_dotbot):
|
||||||
assert file.read() == "cherry"
|
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."""
|
"""Verify deep link globbing with a globbed exclusion."""
|
||||||
|
|
||||||
dotfiles.write("config/foo/a", "apple")
|
dotfiles.write("config/foo/a", "apple")
|
||||||
|
@ -377,7 +398,7 @@ def test_link_glob_exclude_2(home, dotfiles, run_dotbot):
|
||||||
assert file.read() == "cherry"
|
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."""
|
"""Verify deep link globbing with an explicit exclusion."""
|
||||||
|
|
||||||
dotfiles.write("config/foo/a", "apple")
|
dotfiles.write("config/foo/a", "apple")
|
||||||
|
@ -428,7 +449,7 @@ def test_link_glob_exclude_3(home, dotfiles, run_dotbot):
|
||||||
assert file.read() == "grape"
|
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."""
|
"""Verify deep link globbing with multiple globbed exclusions."""
|
||||||
|
|
||||||
dotfiles.write("config/foo/a", "apple")
|
dotfiles.write("config/foo/a", "apple")
|
||||||
|
@ -475,7 +496,7 @@ def test_link_glob_exclude_4(home, dotfiles, run_dotbot):
|
||||||
assert file.read() == "cherry"
|
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."""
|
"""Verify link globbing with deep-nested stars."""
|
||||||
|
|
||||||
dotfiles.write("config/foo/a", "apple")
|
dotfiles.write("config/foo/a", "apple")
|
||||||
|
@ -502,21 +523,28 @@ def test_link_glob_multi_star(home, dotfiles, run_dotbot):
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
"pattern, expect_file",
|
("pattern", "expect_file"),
|
||||||
(
|
[
|
||||||
("conf/*", lambda fruit: fruit),
|
("conf/*", lambda fruit: fruit),
|
||||||
("conf/.*", lambda fruit: "." + fruit),
|
("conf/.*", lambda fruit: "." + fruit),
|
||||||
("conf/[bc]*", lambda fruit: fruit if fruit[0] in "bc" else None),
|
("conf/[bc]*", lambda fruit: fruit if fruit[0] in "bc" else None),
|
||||||
("conf/*e", lambda fruit: fruit if fruit[-1] == "e" else None),
|
("conf/*e", lambda fruit: fruit if fruit[-1] == "e" else None),
|
||||||
("conf/??r*", lambda fruit: fruit if fruit[2] == "r" 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."""
|
"""Verify link glob pattern matching."""
|
||||||
|
|
||||||
fruits = ["apple", "apricot", "banana", "cherry", "currant", "cantalope"]
|
fruits = ["apple", "apricot", "banana", "cherry", "currant", "cantalope"]
|
||||||
[dotfiles.write("conf/" + fruit, fruit) for fruit in fruits]
|
for fruit in fruits:
|
||||||
[dotfiles.write("conf/." + fruit, "dot-" + fruit) for fruit in fruits]
|
dotfiles.write("conf/" + fruit, fruit)
|
||||||
|
dotfiles.write("conf/." + fruit, "dot-" + fruit)
|
||||||
dotfiles.write_config(
|
dotfiles.write_config(
|
||||||
[
|
[
|
||||||
{"defaults": {"link": {"glob": True, "create": True}}},
|
{"defaults": {"link": {"glob": True, "create": True}}},
|
||||||
|
@ -526,18 +554,19 @@ def test_link_glob_patterns(pattern, expect_file, home, dotfiles, run_dotbot):
|
||||||
run_dotbot()
|
run_dotbot()
|
||||||
|
|
||||||
for fruit in fruits:
|
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))
|
||||||
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 not os.path.islink(os.path.join(home, "globtest", fruit))
|
||||||
assert 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 os.path.islink(os.path.join(home, "globtest", fruit))
|
||||||
assert not 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."""
|
"""Verify recursive link globbing and exclusions."""
|
||||||
|
|
||||||
dotfiles.write("config/foo/bar/a", "apple")
|
dotfiles.write("config/foo/bar/a", "apple")
|
||||||
|
@ -563,9 +592,10 @@ def test_link_glob_recursive(home, dotfiles, run_dotbot):
|
||||||
assert file.read() == "cherry"
|
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."""
|
"""Verify that a glob with no match doesn't raise an error."""
|
||||||
|
|
||||||
|
_ = home
|
||||||
dotfiles.makedirs("foo")
|
dotfiles.makedirs("foo")
|
||||||
dotfiles.write_config(
|
dotfiles.write_config(
|
||||||
[
|
[
|
||||||
|
@ -576,7 +606,7 @@ def test_link_glob_no_match(home, dotfiles, run_dotbot):
|
||||||
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."""
|
"""Verify linking works even when glob matches exactly one file."""
|
||||||
# regression test for https://github.com/anishathalye/dotbot/issues/282
|
# 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(
|
@pytest.mark.skipif(
|
||||||
"sys.platform[:5] == 'win32'",
|
"sys.platform == 'win32'",
|
||||||
reason="These if commands won't run on Windows",
|
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."""
|
"""Verify 'if' directives are checked when linking."""
|
||||||
|
|
||||||
os.mkdir(os.path.join(home, "d"))
|
os.mkdir(os.path.join(home, "d"))
|
||||||
|
@ -628,10 +658,10 @@ def test_link_if(home, dotfiles, run_dotbot):
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.skipif(
|
@pytest.mark.skipif(
|
||||||
"sys.platform[:5] == 'win32'",
|
"sys.platform == 'win32'",
|
||||||
reason="These if commands won't run on Windows.",
|
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."""
|
"""Verify 'if' directive defaults are checked when linking."""
|
||||||
|
|
||||||
os.mkdir(os.path.join(home, "d"))
|
os.mkdir(os.path.join(home, "d"))
|
||||||
|
@ -661,10 +691,10 @@ def test_link_if_defaults(home, dotfiles, run_dotbot):
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.skipif(
|
@pytest.mark.skipif(
|
||||||
"sys.platform[:5] != 'win32'",
|
"sys.platform != 'win32'",
|
||||||
reason="These if commands only run on Windows.",
|
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)."""
|
"""Verify 'if' directives are checked when linking (Windows only)."""
|
||||||
|
|
||||||
os.mkdir(os.path.join(home, "d"))
|
os.mkdir(os.path.join(home, "d"))
|
||||||
|
@ -692,10 +722,10 @@ def test_link_if_windows(home, dotfiles, run_dotbot):
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.skipif(
|
@pytest.mark.skipif(
|
||||||
"sys.platform[:5] != 'win32'",
|
"sys.platform != 'win32'",
|
||||||
reason="These if commands only run on Windows",
|
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)."""
|
"""Verify 'if' directive defaults are checked when linking (Windows only)."""
|
||||||
|
|
||||||
os.mkdir(os.path.join(home, "d"))
|
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"
|
assert file.read() == "apple"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("ignore_missing", (True, False))
|
@pytest.mark.parametrize("ignore_missing", [True, False])
|
||||||
def test_link_ignore_missing(ignore_missing, home, dotfiles, run_dotbot):
|
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."""
|
"""Verify link 'ignore_missing' is respected when the target is missing."""
|
||||||
|
|
||||||
dotfiles.write_config(
|
dotfiles.write_config(
|
||||||
|
@ -749,7 +784,7 @@ def test_link_ignore_missing(ignore_missing, home, dotfiles, run_dotbot):
|
||||||
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."""
|
"""Verify relink does not overwrite file."""
|
||||||
|
|
||||||
dotfiles.write("f", "apple")
|
dotfiles.write("f", "apple")
|
||||||
|
@ -759,12 +794,12 @@ def test_link_leaves_file(home, dotfiles, run_dotbot):
|
||||||
with pytest.raises(SystemExit):
|
with pytest.raises(SystemExit):
|
||||||
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() == "grape"
|
assert file.read() == "grape"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("key", ("canonicalize-path", "canonicalize"))
|
@pytest.mark.parametrize("key", ["canonicalize-path", "canonicalize"])
|
||||||
def test_link_no_canonicalize(key, home, dotfiles, run_dotbot):
|
def test_link_no_canonicalize(key: str, home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None:
|
||||||
"""Verify link canonicalization can be disabled."""
|
"""Verify link canonicalization can be disabled."""
|
||||||
|
|
||||||
dotfiles.write("f", "apple")
|
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"))
|
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."""
|
"""Verify link prefixes are prepended."""
|
||||||
|
|
||||||
dotfiles.write("conf/a", "apple")
|
dotfiles.write("conf/a", "apple")
|
||||||
|
@ -810,7 +845,7 @@ def test_link_prefix(home, dotfiles, run_dotbot):
|
||||||
assert file.read() == "cherry"
|
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."""
|
"""Test relative linking works."""
|
||||||
|
|
||||||
dotfiles.write("f", "apple")
|
dotfiles.write("f", "apple")
|
||||||
|
@ -842,22 +877,22 @@ def test_link_relative(home, dotfiles, run_dotbot):
|
||||||
run_dotbot()
|
run_dotbot()
|
||||||
|
|
||||||
f = os.readlink(os.path.join(home, ".f"))
|
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:]
|
f = f[4:]
|
||||||
assert f == os.path.join(dotfiles.directory, "f")
|
assert f == os.path.join(dotfiles.directory, "f")
|
||||||
|
|
||||||
frel = os.readlink(os.path.join(home, ".frel"))
|
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:]
|
frel = frel[4:]
|
||||||
assert frel == os.path.normpath("../../dotfiles/f")
|
assert frel == os.path.normpath("../../dotfiles/f")
|
||||||
|
|
||||||
nested_frel = os.readlink(os.path.join(home, "nested", ".frel"))
|
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:]
|
nested_frel = nested_frel[4:]
|
||||||
assert nested_frel == os.path.normpath("../../../dotfiles/f")
|
assert nested_frel == os.path.normpath("../../../dotfiles/f")
|
||||||
|
|
||||||
d = os.readlink(os.path.join(home, ".d"))
|
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:]
|
d = d[4:]
|
||||||
assert d == os.path.normpath("../../dotfiles/d")
|
assert d == os.path.normpath("../../dotfiles/d")
|
||||||
|
|
||||||
|
@ -871,7 +906,7 @@ def test_link_relative(home, dotfiles, run_dotbot):
|
||||||
assert file.read() == "grape"
|
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."""
|
"""Verify relink does not overwrite file."""
|
||||||
|
|
||||||
dotfiles.write("f", "apple")
|
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}}}])
|
dotfiles.write_config([{"link": {"~/.f": {"path": "f", "relink": True}}}])
|
||||||
with pytest.raises(SystemExit):
|
with pytest.raises(SystemExit):
|
||||||
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() == "grape"
|
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."""
|
"""Verify relink overwrites symlinks."""
|
||||||
|
|
||||||
dotfiles.write("f", "apple")
|
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"))
|
os.symlink(os.path.join(home, "f"), os.path.join(home, ".f"))
|
||||||
dotfiles.write_config([{"link": {"~/.f": {"path": "f", "relink": True}}}])
|
dotfiles.write_config([{"link": {"~/.f": {"path": "f", "relink": True}}}])
|
||||||
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"
|
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."""
|
"""Verify relink relative does not incorrectly relink file."""
|
||||||
|
|
||||||
dotfiles.write("f", "apple")
|
dotfiles.write("f", "apple")
|
||||||
|
@ -927,7 +962,7 @@ def test_link_relink_relative_leaves_file(home, dotfiles, run_dotbot):
|
||||||
assert mtime == new_mtime
|
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."""
|
"""Verify that link doesn't overwrite non-dotfiles links by default."""
|
||||||
|
|
||||||
with open(os.path.join(home, "f"), "w") as file:
|
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):
|
with pytest.raises(SystemExit):
|
||||||
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() == "grape"
|
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."""
|
"""Verify that explicit link defaults override the implicit default."""
|
||||||
|
|
||||||
with open(os.path.join(home, "f"), "w") as file:
|
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()
|
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"
|
assert file.read() == "apple"
|
||||||
|
|
|
@ -3,20 +3,20 @@ import os
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
def test_success(root):
|
def test_success(root: str) -> None:
|
||||||
path = os.path.join(root, "abc.txt")
|
path = os.path.join(root, "abc.txt")
|
||||||
with open(path, "wt") as f:
|
with open(path, "w") as f:
|
||||||
f.write("hello")
|
f.write("hello")
|
||||||
with open(path, "rt") as f:
|
with open(path) as f:
|
||||||
assert f.read() == "hello"
|
assert f.read() == "hello"
|
||||||
|
|
||||||
|
|
||||||
def test_failure():
|
def test_failure() -> None:
|
||||||
with pytest.raises(AssertionError):
|
with pytest.raises(AssertionError):
|
||||||
open("abc.txt", "w")
|
open("abc.txt", "w") # noqa: SIM115
|
||||||
|
|
||||||
with pytest.raises(AssertionError):
|
with pytest.raises(AssertionError):
|
||||||
open(file="abc.txt", mode="w")
|
open(file="abc.txt", mode="w") # noqa: SIM115
|
||||||
|
|
||||||
with pytest.raises(AssertionError):
|
with pytest.raises(AssertionError):
|
||||||
os.mkdir("a")
|
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."""
|
"""Verify shell command STDOUT works."""
|
||||||
|
|
||||||
dotfiles.write_config(
|
dotfiles.write_config(
|
||||||
|
@ -16,10 +25,12 @@ def test_shell_allow_stdout(capfd, dotfiles, run_dotbot):
|
||||||
run_dotbot()
|
run_dotbot()
|
||||||
|
|
||||||
output = capfd.readouterr()
|
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."""
|
"""Verify that '-vv' overrides the implicit default stdout=False."""
|
||||||
|
|
||||||
dotfiles.write_config([{"shell": [{"command": "echo apple"}]}])
|
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)
|
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."""
|
"""Verify that '-vv' overrides an explicit stdout=False."""
|
||||||
|
|
||||||
dotfiles.write_config([{"shell": [{"command": "echo apple", "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)
|
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."""
|
"""Verify that '-vv' overrides an explicit defaults:shell:stdout=False."""
|
||||||
|
|
||||||
dotfiles.write_config(
|
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)
|
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."""
|
"""Verify that commands can output to STDERR."""
|
||||||
|
|
||||||
dotfiles.write_config([{"shell": [{"command": "echo apple >&2"}]}])
|
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)
|
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."""
|
"""Verify that commands can output to STDERR with STDOUT explicitly off."""
|
||||||
|
|
||||||
dotfiles.write_config(
|
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)
|
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."""
|
"""Verify that commands can output to STDERR with defaults:shell:stdout=False."""
|
||||||
|
|
||||||
dotfiles.write_config(
|
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)
|
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."""
|
"""Verify that a single '-v' verbosity doesn't override stdout=False."""
|
||||||
|
|
||||||
dotfiles.write_config([{"shell": [{"command": "echo apple"}]}])
|
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)
|
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."""
|
"""Verify that a single '-v' verbosity doesn't override stderr=False."""
|
||||||
|
|
||||||
dotfiles.write_config([{"shell": [{"command": "echo apple >&2"}]}])
|
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)
|
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."""
|
"""Verify that shell command stdout works in compact form."""
|
||||||
|
|
||||||
dotfiles.write_config(
|
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)
|
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."""
|
"""Verify that shell command stdout works in compact form."""
|
||||||
|
|
||||||
dotfiles.write_config(
|
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)
|
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."""
|
"""Verify that the shell command disables stdout by default."""
|
||||||
|
|
||||||
dotfiles.write_config(
|
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)
|
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."""
|
"""Verify that the shell command can override defaults."""
|
||||||
|
|
||||||
dotfiles.write_config(
|
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)
|
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."""
|
"""Verify that quiet is off by default."""
|
||||||
|
|
||||||
dotfiles.write_config(
|
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)
|
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."""
|
"""Verify that only the description is shown when quiet is enabled."""
|
||||||
|
|
||||||
dotfiles.write_config(
|
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)
|
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."""
|
"""Verify nothing is shown when quiet is enabled with no description."""
|
||||||
|
|
||||||
dotfiles.write_config(
|
dotfiles.write_config(
|
||||||
|
|
|
@ -5,8 +5,10 @@ import sys
|
||||||
|
|
||||||
import pytest
|
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."""
|
"""Verify install shim works."""
|
||||||
|
|
||||||
# Skip the test if git is unavailable.
|
# Skip the test if git is unavailable.
|
||||||
|
@ -14,10 +16,8 @@ def test_shim(root, home, dotfiles, run_dotbot):
|
||||||
if git is None:
|
if git is None:
|
||||||
pytest.skip("git is unavailable")
|
pytest.skip("git is unavailable")
|
||||||
|
|
||||||
if sys.platform[:5] == "win32":
|
if sys.platform == "win32":
|
||||||
install = os.path.join(
|
install = os.path.join(dotfiles.directory, "dotbot", "tools", "git-submodule", "install.ps1")
|
||||||
dotfiles.directory, "dotbot", "tools", "git-submodule", "install.ps1"
|
|
||||||
)
|
|
||||||
shim = os.path.join(dotfiles.directory, "install.ps1")
|
shim = os.path.join(dotfiles.directory, "install.ps1")
|
||||||
else:
|
else:
|
||||||
install = os.path.join(dotfiles.directory, "dotbot", "tools", "git-submodule", "install")
|
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__)))
|
git_directory = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
os.chdir(dotfiles.directory)
|
os.chdir(dotfiles.directory)
|
||||||
subprocess.check_call([git, "init"])
|
subprocess.check_call([git, "init"])
|
||||||
subprocess.check_call(
|
subprocess.check_call([git, "-c", "protocol.file.allow=always", "submodule", "add", git_directory, "dotbot"])
|
||||||
[git, "-c", "protocol.file.allow=always", "submodule", "add", git_directory, "dotbot"]
|
|
||||||
)
|
|
||||||
shutil.copy(install, shim)
|
shutil.copy(install, shim)
|
||||||
dotfiles.write("foo", "pear")
|
dotfiles.write("foo", "pear")
|
||||||
dotfiles.write_config([{"link": {"~/.foo": "foo"}}])
|
dotfiles.write_config([{"link": {"~/.foo": "foo"}}])
|
||||||
|
|
||||||
# Run the shim script.
|
# Run the shim script.
|
||||||
env = dict(os.environ)
|
env = dict(os.environ)
|
||||||
if sys.platform[:5] == "win32":
|
if sys.platform == "win32":
|
||||||
args = [shutil.which("powershell"), "-ExecutionPolicy", "RemoteSigned", shim]
|
ps = shutil.which("powershell")
|
||||||
|
assert ps is not None
|
||||||
|
args = [ps, "-ExecutionPolicy", "RemoteSigned", shim]
|
||||||
env["USERPROFILE"] = home
|
env["USERPROFILE"] = home
|
||||||
else:
|
else:
|
||||||
args = [shim]
|
args = [shim]
|
||||||
|
@ -45,5 +45,5 @@ def test_shim(root, home, dotfiles, run_dotbot):
|
||||||
subprocess.check_call(args, env=env, cwd=dotfiles.directory)
|
subprocess.check_call(args, env=env, cwd=dotfiles.directory)
|
||||||
|
|
||||||
assert os.path.islink(os.path.join(home, ".foo"))
|
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"
|
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