From c015f7bce800567df499a26aa254fd498634c837 Mon Sep 17 00:00:00 2001 From: Kurt McKee Date: Thu, 14 Apr 2022 08:17:18 -0500 Subject: [PATCH 01/23] Add a test framework for all supported Python versions --- .gitignore | 3 + setup.py | 6 + tests/conftest.py | 291 +++++++++++++++++++++++++++++++++++++++++++++ tests/test_noop.py | 25 ++++ tox.ini | 16 +++ 5 files changed, 341 insertions(+) create mode 100644 tests/conftest.py create mode 100644 tests/test_noop.py create mode 100644 tox.ini diff --git a/.gitignore b/.gitignore index bf45d22..9a522db 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,7 @@ *.egg-info *.pyc +.idea/ +.tox/ +.venv/ build/ dist/ diff --git a/setup.py b/setup.py index a12bf98..8b3e592 100644 --- a/setup.py +++ b/setup.py @@ -58,6 +58,12 @@ setup( install_requires=[ "PyYAML>=5.3,<6", ], + 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. diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..74a01a8 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,291 @@ +import json +import os +import shutil +import sys +from shutil import rmtree +import tempfile + +import pytest +import yaml + +import dotbot.cli + +try: + import builtins + import unittest.mock as mock +except ImportError: + # Python 2.7 compatibility + builtins = None + import __builtin__ + import mock # noqa: module not found + + +# Python 2.7 compatibility: +# On Linux, Python 2.7's tempfile.TemporaryFile() requires unlink access. +# This list is updated by a tempfile._mkstemp_inner() wrapper, +# and its contents are checked by wrapped functions. +allowed_tempfile_internal_unlink_calls = [] + + +def wrap_function(function, function_path, arg_index, kwarg_key, root): + def wrapper(*args, **kwargs): + if kwarg_key in kwargs: + value = kwargs[kwarg_key] + else: + value = args[arg_index] + + # Python 2.7 compatibility: + # Allow tempfile.TemporaryFile's internal unlink calls to work. + if value in allowed_tempfile_internal_unlink_calls: + return function(*args, **kwargs) + + msg = "The '{0}' argument to {1}() must be an absolute path" + msg = msg.format(kwarg_key, function_path) + assert value == os.path.abspath(value), msg + + msg = "The '{0}' argument to {1}() must be rooted in {2}" + msg = msg.format(kwarg_key, function_path, root) + assert value[:len(str(root))] == str(root), msg + + return function(*args, **kwargs) + + return wrapper + + +def wrap_open(root): + try: + wrapped = getattr(builtins, "open") + except AttributeError: + # Python 2.7 compatibility + wrapped = getattr(__builtin__, "open") + + def wrapper(*args, **kwargs): + if "file" in kwargs: + value = kwargs["file"] + else: + value = args[0] + + mode = "r" + if "mode" in kwargs: + mode = kwargs["mode"] + elif len(args) >= 2: + mode = args[1] + + msg = "The 'file' argument to open() must be an absolute path" + if value != os.devnull and "w" in mode: + assert value == os.path.abspath(value), msg + + msg = "The 'file' argument to open() must be rooted in {0}" + msg = msg.format(root) + if value != os.devnull and "w" in mode: + assert value[:len(str(root))] == str(root), msg + + return wrapped(*args, **kwargs) + + return wrapper + + +def rmtree_error_handler(_, path, __): + # Handle read-only files and directories. + os.chmod(path, 0o777) + if os.path.isdir(path): + rmtree(path) + else: + os.unlink(path) + + +@pytest.fixture(autouse=True) +def root(): + """Create a temporary directory for the duration of each test.""" + + # Reset allowed_tempfile_internal_unlink_calls. + global allowed_tempfile_internal_unlink_calls + allowed_tempfile_internal_unlink_calls = [] + + # Dotbot changes the current working directory, + # so this must be reset at the end of each test. + current_working_directory = os.getcwd() + + # Create an isolated temporary directory from which to operate. + current_root = tempfile.mkdtemp() + + functions_to_wrap = [ + (os, "chflags", 0, "path"), + (os, "chmod", 0, "path"), + (os, "chown", 0, "path"), + (os, "copy_file_range", 1, "dst"), + (os, "lchflags", 0, "path"), + (os, "lchmod", 0, "path"), + (os, "link", 1, "dst"), + (os, "makedirs", 0, "name"), + (os, "mkdir", 0, "path"), + (os, "mkfifo", 0, "path"), + (os, "mknod", 0, "path"), + (os, "remove", 0, "path"), + (os, "removedirs", 0, "name"), + (os, "removexattr", 0, "path"), + (os, "rename", 0, "src"), # Check both + (os, "rename", 1, "dst"), + (os, "renames", 0, "old"), # Check both + (os, "renames", 1, "new"), + (os, "replace", 0, "src"), # Check both + (os, "replace", 1, "dst"), + (os, "rmdir", 0, "path"), + (os, "setxattr", 0, "path"), + (os, "splice", 1, "dst"), + (os, "symlink", 1, "dst"), + (os, "truncate", 0, "path"), + (os, "unlink", 0, "path"), + (os, "utime", 0, "path"), + (shutil, "chown", 0, "path"), + (shutil, "copy", 1, "dst"), + (shutil, "copy2", 1, "dst"), + (shutil, "copyfile", 1, "dst"), + (shutil, "copymode", 1, "dst"), + (shutil, "copystat", 1, "dst"), + (shutil, "copytree", 1, "dst"), + (shutil, "make_archive", 0, "base_name"), + (shutil, "move", 0, "src"), # Check both + (shutil, "move", 1, "dst"), + (shutil, "rmtree", 0, "path"), + (shutil, "unpack_archive", 1, "extract_dir"), + ] + + patches = [] + for module, function_name, arg_index, kwarg_key in functions_to_wrap: + # Skip anything that doesn't exist in this version of Python. + if not hasattr(module, function_name): + continue + + # These values must be passed to a separate function + # to ensure the variable closures work correctly. + function_path = "{0}.{1}".format(module.__name__, function_name) + function = getattr(module, function_name) + wrapped = wrap_function( + function, function_path, arg_index, kwarg_key, current_root + ) + patches.append(mock.patch(function_path, wrapped)) + + # open() must be separately wrapped. + if builtins is not None: + function_path = "builtins.open" + else: + # Python 2.7 compatibility + function_path = "__builtin__.open" + wrapped = wrap_open(current_root) + patches.append(mock.patch(function_path, wrapped)) + + # Block all access to bad functions. + if hasattr(os, "chroot"): + patches.append(mock.patch("os.chroot", lambda *_, **__: None)) + + # Patch tempfile._mkstemp_inner() so tempfile.TemporaryFile() + # can unlink files immediately. + mkstemp_inner = tempfile._mkstemp_inner + + def wrap_mkstemp_inner(*args, **kwargs): + (fd, name) = mkstemp_inner(*args, **kwargs) + allowed_tempfile_internal_unlink_calls.append(name) + return fd, name + + patches.append(mock.patch("tempfile._mkstemp_inner", wrap_mkstemp_inner)) + + [patch.start() for patch in patches] + try: + yield current_root + finally: + [patch.stop() for patch in patches] + os.chdir(current_working_directory) + rmtree(current_root, onerror=rmtree_error_handler) + + +@pytest.fixture +def home(monkeypatch, root): + """Create a home directory for the duration of the test. + + On *nix, the environment variable "HOME" will be mocked. + On Windows, the environment variable "USERPROFILE" will be mocked. + """ + + home = os.path.abspath(os.path.join(root, "home/user")) + os.makedirs(home) + if sys.platform[:5] == "win32": + monkeypatch.setenv("USERPROFILE", home) + else: + monkeypatch.setenv("HOME", home) + yield home + + +class Dotfiles(object): + """Create and manage a dotfiles directory for a test.""" + + def __init__(self, root): + self.root = root + self.config = None + self.config_filename = None + self.directory = os.path.join(root, "dotfiles") + os.mkdir(self.directory) + + def makedirs(self, path): + os.makedirs(os.path.abspath(os.path.join(self.directory, path))) + + def write(self, path, content=""): + path = os.path.abspath(os.path.join(self.directory, path)) + if not os.path.exists(os.path.dirname(path)): + os.makedirs(os.path.dirname(path)) + with open(path, "w") as file: + file.write(content) + + def write_config(self, config, serializer="yaml", path=None): + """Write a dotbot config and return the filename.""" + + assert serializer in {"json", "yaml"}, "Only json and yaml are supported" + if serializer == "yaml": + serialize = yaml.dump + else: # serializer == "json" + serialize = json.dumps + + if path: + msg = "The config file path must be an absolute path" + assert path == os.path.abspath(path), msg + + msg = "The config file path must be rooted in {0}" + msg = msg.format(root) + assert path[:len(str(root))] == str(root), msg + + self.config_filename = path + else: + self.config_filename = os.path.join(self.directory, "install.conf.yaml") + self.config = config + + with open(self.config_filename, "w") as file: + file.write(serialize(config)) + return self.config_filename + + +@pytest.fixture +def dotfiles(root): + """Create a dotfiles directory.""" + + yield Dotfiles(root) + + +@pytest.fixture +def run_dotbot(dotfiles): + """Run dotbot. + + When calling `runner()`, only CLI arguments need to be specified. + + If the keyword-only argument *custom* is True + then the CLI arguments will not be modified, + and the caller will be responsible for all CLI arguments. + """ + + def runner(*argv, **kwargs): + argv = ["dotbot"] + list(argv) + if kwargs.get("custom", False) is not True: + argv.extend(["-c", dotfiles.config_filename]) + with mock.patch("sys.argv", argv): + dotbot.cli.main() + + yield runner diff --git a/tests/test_noop.py b/tests/test_noop.py new file mode 100644 index 0000000..5949ff5 --- /dev/null +++ b/tests/test_noop.py @@ -0,0 +1,25 @@ +import os + +import pytest + + +def test_success(root): + path = os.path.join(root, "abc.txt") + with open(path, "wt") as f: + f.write("hello") + with open(path, "rt") as f: + assert f.read() == "hello" + + +def test_failure(): + with pytest.raises(AssertionError): + open("abc.txt", "w") + + with pytest.raises(AssertionError): + open(file="abc.txt", mode="w") + + with pytest.raises(AssertionError): + os.mkdir("a") + + with pytest.raises(AssertionError): + os.mkdir(path="a") diff --git a/tox.ini b/tox.ini new file mode 100644 index 0000000..acabf72 --- /dev/null +++ b/tox.ini @@ -0,0 +1,16 @@ +[tox] +envlist = + py{27, 35, 36, 37, 38, 39, 310} + pypy{2, 3} +skip_missing_interpreters = true + + +[testenv] +deps = + pytest + pytest-randomly + pyyaml + mock; python_version == "2.7" + +commands = + pytest tests/ From 4469b857aaf4389324e56f07caf9aa138a4610d1 Mon Sep 17 00:00:00 2001 From: Kurt McKee Date: Mon, 25 Apr 2022 08:27:39 -0500 Subject: [PATCH 02/23] Migrate link-* tests to Python --- tests/test_link.py | 922 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 922 insertions(+) create mode 100644 tests/test_link.py diff --git a/tests/test_link.py b/tests/test_link.py new file mode 100644 index 0000000..e620b6a --- /dev/null +++ b/tests/test_link.py @@ -0,0 +1,922 @@ +import os +import sys + +import pytest + + +# Python 2.7 on Windows does not have an `os.symlink()` function. +# PyPy on Windows raises NotImplementedError when `os.symlink()` is called. +# Older Python 3 versions on Windows require admin rights to create symlinks. +# +# In addition, functions like `os.path.realpath()` on Windows Pythons < 3.8 +# do not resolve symlinks and directory junctions correctly, +# and `shutil.rmtree()` will fail to delete directory junctions. +# +# For these reasons, if the tests are running on Windows with Python < 3.8 +# or with PyPy, the entire link test suite must be skipped. +# +if ( + sys.platform[:5] == "win32" + and (sys.version_info < (3, 8) or "pypy" in sys.version.lower()) +): + reason = "It is impossible to perform link tests on this platform" + pytestmark = pytest.mark.skip(reason=reason) + + +def test_link_canonicalization(home, dotfiles, run_dotbot): + """Verify links to symlinked destinations are canonical. + + "Canonical", here, means that dotbot does not create symlinks + that point to intermediary symlinks. + """ + + dotfiles.write("f", "apple") + dotfiles.write_config([{"link": {"~/.f": {"path": "f"}}}]) + + # Point to the config file in a symlinked dotfiles directory. + dotfiles_symlink = os.path.join(home, "dotfiles-symlink") + os.symlink(dotfiles.directory, dotfiles_symlink) + config_file = os.path.join(dotfiles_symlink, os.path.basename(dotfiles.config_filename)) + run_dotbot("-c", config_file, custom=True) + + expected = os.path.join(dotfiles.directory, "f") + actual = os.readlink(os.path.abspath(os.path.expanduser("~/.f"))) + if sys.platform[:5] == "win32" and actual.startswith("\\\\?\\"): + actual = actual[4:] + assert expected == actual + + +@pytest.mark.parametrize("dst", ("~/.f", "~/f")) +@pytest.mark.parametrize("include_force", (True, False)) +def test_link_default_source(root, home, dst, include_force, dotfiles, run_dotbot): + """Verify that default sources are calculated correctly. + + This test includes verifying files with and without leading periods, + as well as verifying handling of None dict values. + """ + + dotfiles.write("f", "apple") + config = [ + { + "link": { + dst: {"force": False} if include_force else None, + } + } + ] + dotfiles.write_config(config) + run_dotbot() + + with open(os.path.abspath(os.path.expanduser(dst)), "r") as file: + assert file.read() == "apple" + + +def test_link_environment_user_expansion_target(home, dotfiles, run_dotbot): + """Verify link expands user in target.""" + + src = "~/f" + target = "~/g" + with open(os.path.abspath(os.path.expanduser(src)), "w") as file: + file.write("apple") + dotfiles.write_config([{"link": {target: src}}]) + run_dotbot() + + with open(os.path.abspath(os.path.expanduser(target)), "r") as file: + assert file.read() == "apple" + + +def test_link_environment_variable_expansion_source(monkeypatch, root, home, dotfiles, run_dotbot): + """Verify link expands environment variables in source.""" + + monkeypatch.setenv("APPLE", "h") + target = "~/.i" + src = "$APPLE" + dotfiles.write("h", "grape") + dotfiles.write_config([{"link": {target: src}}]) + run_dotbot() + + with open(os.path.abspath(os.path.expanduser(target)), "r") as file: + assert file.read() == "grape" + + +def test_link_environment_variable_expansion_source_extended(monkeypatch, root, home, dotfiles, run_dotbot): + """Verify link expands environment variables in extended config syntax.""" + + monkeypatch.setenv("APPLE", "h") + target = "~/.i" + src = "$APPLE" + dotfiles.write("h", "grape") + dotfiles.write_config([{"link": {target: {"path": src, "relink": True}}}]) + run_dotbot() + + with open(os.path.abspath(os.path.expanduser(target)), "r") as file: + assert file.read() == "grape" + + +def test_link_environment_variable_expansion_target(monkeypatch, root, home, dotfiles, run_dotbot): + """Verify link expands environment variables in target. + + If the variable doesn't exist, the "variable" must not be replaced. + """ + + monkeypatch.setenv("ORANGE", ".config") + monkeypatch.setenv("BANANA", "g") + monkeypatch.delenv("PEAR", raising=False) + + dotfiles.write("f", "apple") + dotfiles.write("h", "grape") + + config = [ + { + "link": { + "~/${ORANGE}/$BANANA": { + "path": "f", + "create": True, + }, + "~/$PEAR": "h", + } + } + ] + dotfiles.write_config(config) + run_dotbot() + + with open(os.path.join(home, ".config", "g"), "r") as file: + assert file.read() == "apple" + with open(os.path.join(home, "$PEAR"), "r") as file: + assert file.read() == "grape" + + +def test_link_environment_variable_unset(monkeypatch, root, home, dotfiles, run_dotbot): + """Verify link leaves unset environment variables.""" + + monkeypatch.delenv("ORANGE", raising=False) + dotfiles.write("$ORANGE", "apple") + dotfiles.write_config([{"link": {"~/f": "$ORANGE"}}]) + run_dotbot() + + with open(os.path.join(home, "f"), "r") as file: + assert file.read() == "apple" + + +def test_link_force_leaves_when_nonexistent(root, home, dotfiles, run_dotbot): + """Verify force doesn't erase sources when targets are nonexistent.""" + + os.mkdir(os.path.join(home, "dir")) + open(os.path.join(home, "file"), "a").close() + config = [ + { + "link": { + "~/dir": {"path": "dir", "force": True}, + "~/file": {"path": "file", "force": True}, + } + } + ] + dotfiles.write_config(config) + with pytest.raises(SystemExit): + run_dotbot() + + assert os.path.isdir(os.path.join(home, "dir")) + assert os.path.isfile(os.path.join(home, "file")) + + +def test_link_force_overwrite_symlink(home, dotfiles, run_dotbot): + """Verify force overwrites a symlinked directory.""" + + os.mkdir(os.path.join(home, "dir")) + dotfiles.write("dir/f") + os.symlink(home, os.path.join(home, ".dir")) + + config = [{"link": {"~/.dir": {"path": "dir", "force": True}}}] + dotfiles.write_config(config) + run_dotbot() + + assert os.path.isfile(os.path.join(home, ".dir", "f")) + + +def test_link_glob_1(home, dotfiles, run_dotbot): + """Verify globbing works.""" + + dotfiles.write("bin/a", "apple") + dotfiles.write("bin/b", "banana") + dotfiles.write("bin/c", "cherry") + dotfiles.write_config([ + {"defaults": {"link": {"glob": True, "create": True}}}, + {"link": {"~/bin": "bin/*"}}, + ]) + run_dotbot() + + with open(os.path.join(home, "bin", "a")) as file: + assert file.read() == "apple" + with open(os.path.join(home, "bin", "b")) as file: + assert file.read() == "banana" + with open(os.path.join(home, "bin", "c")) as file: + assert file.read() == "cherry" + + +def test_link_glob_2(home, dotfiles, run_dotbot): + """Verify globbing works with a trailing slash in the source.""" + + dotfiles.write("bin/a", "apple") + dotfiles.write("bin/b", "banana") + dotfiles.write("bin/c", "cherry") + dotfiles.write_config([ + {"defaults": {"link": {"glob": True, "create": True}}}, + {"link": {"~/bin/": "bin/*"}}, + ]) + run_dotbot() + + with open(os.path.join(home, "bin", "a")) as file: + assert file.read() == "apple" + with open(os.path.join(home, "bin", "b")) as file: + assert file.read() == "banana" + with open(os.path.join(home, "bin", "c")) as file: + assert file.read() == "cherry" + + +def test_link_glob_3(home, dotfiles, run_dotbot): + """Verify globbing works with hidden ("period-prefixed") files.""" + + dotfiles.write("bin/.a", "dot-apple") + dotfiles.write("bin/.b", "dot-banana") + dotfiles.write("bin/.c", "dot-cherry") + dotfiles.write_config([ + {"defaults": {"link": {"glob": True, "create": True}}}, + {"link": {"~/bin/": "bin/.*"}}, + ]) + run_dotbot() + + with open(os.path.join(home, "bin", ".a")) as file: + assert file.read() == "dot-apple" + with open(os.path.join(home, "bin", ".b")) as file: + assert file.read() == "dot-banana" + with open(os.path.join(home, "bin", ".c")) as file: + assert file.read() == "dot-cherry" + + +def test_link_glob_4(home, dotfiles, run_dotbot): + """Verify globbing works at the root of the home and dotfiles directories.""" + + dotfiles.write(".a", "dot-apple") + dotfiles.write(".b", "dot-banana") + dotfiles.write(".c", "dot-cherry") + dotfiles.write_config([{ + "link": { + "~": { + "path": ".*", + "glob": True, + }, + }, + }]) + run_dotbot() + + with open(os.path.join(home, ".a")) as file: + assert file.read() == "dot-apple" + with open(os.path.join(home, ".b")) as file: + assert file.read() == "dot-banana" + with open(os.path.join(home, ".c")) as file: + assert file.read() == "dot-cherry" + + +@pytest.mark.parametrize("path", ("foo", "foo/")) +def test_link_glob_ambiguous_failure(path, home, dotfiles, run_dotbot): + """Verify ambiguous link globbing fails.""" + + dotfiles.makedirs("foo") + dotfiles.write_config([{ + "link": { + "~/foo/": { + "path": path, + "glob": True, + } + } + }]) + with pytest.raises(SystemExit): + run_dotbot() + assert not os.path.exists(os.path.join(home, "foo")) + + +def test_link_glob_ambiguous_success(home, dotfiles, run_dotbot): + """Verify the case where ambiguous link globbing succeeds.""" + + dotfiles.makedirs("foo") + dotfiles.write_config([{ + "link": { + "~/foo": { + "path": "foo", + "glob": True, + } + } + }]) + run_dotbot() + assert os.path.exists(os.path.join(home, "foo")) + + +def test_link_glob_exclude_1(home, dotfiles, run_dotbot): + """Verify link globbing with an explicit exclusion.""" + + dotfiles.write("config/foo/a", "apple") + dotfiles.write("config/bar/b", "banana") + dotfiles.write("config/bar/c", "cherry") + dotfiles.write("config/baz/d", "donut") + dotfiles.write_config([ + { + "defaults": { + "link": { + "glob": True, + "create": True, + }, + }, + }, + { + "link": { + "~/.config/": { + "path": "config/*", + "exclude": ["config/baz"], + }, + }, + }, + ]) + run_dotbot() + + assert not os.path.exists(os.path.join(home, ".config", "baz")) + + assert not os.path.islink(os.path.join(home, ".config")) + assert os.path.islink(os.path.join(home, ".config", "foo")) + assert os.path.islink(os.path.join(home, ".config", "bar")) + with open(os.path.join(home, ".config", "foo", "a")) as file: + assert file.read() == "apple" + with open(os.path.join(home, ".config", "bar", "b")) as file: + assert file.read() == "banana" + with open(os.path.join(home, ".config", "bar", "c")) as file: + assert file.read() == "cherry" + + +def test_link_glob_exclude_2(home, dotfiles, run_dotbot): + """Verify deep link globbing with a globbed exclusion.""" + + dotfiles.write("config/foo/a", "apple") + dotfiles.write("config/bar/b", "banana") + dotfiles.write("config/bar/c", "cherry") + dotfiles.write("config/baz/d", "donut") + dotfiles.write("config/baz/buzz/e", "egg") + dotfiles.write_config([ + { + "defaults": { + "link": { + "glob": True, + "create": True, + }, + }, + }, + { + "link": { + "~/.config/": { + "path": "config/*/*", + "exclude": ["config/baz/*"], + }, + }, + }, + ]) + run_dotbot() + + assert not os.path.exists(os.path.join(home, ".config", "baz")) + + assert not os.path.islink(os.path.join(home, ".config")) + assert not os.path.islink(os.path.join(home, ".config", "foo")) + assert not os.path.islink(os.path.join(home, ".config", "bar")) + assert os.path.islink(os.path.join(home, ".config", "foo", "a")) + with open(os.path.join(home, ".config", "foo", "a")) as file: + assert file.read() == "apple" + with open(os.path.join(home, ".config", "bar", "b")) as file: + assert file.read() == "banana" + with open(os.path.join(home, ".config", "bar", "c")) as file: + assert file.read() == "cherry" + + +def test_link_glob_exclude_3(home, dotfiles, run_dotbot): + """Verify deep link globbing with an explicit exclusion.""" + + dotfiles.write("config/foo/a", "apple") + dotfiles.write("config/bar/b", "banana") + dotfiles.write("config/bar/c", "cherry") + dotfiles.write("config/baz/d", "donut") + dotfiles.write("config/baz/buzz/e", "egg") + dotfiles.write("config/baz/bizz/g", "grape") + dotfiles.write_config([ + { + "defaults": { + "link": { + "glob": True, + "create": True, + }, + }, + }, + { + "link": { + "~/.config/": { + "path": "config/*/*", + "exclude": ["config/baz/buzz"], + }, + }, + }, + ]) + run_dotbot() + + assert not os.path.exists(os.path.join(home, ".config", "baz", "buzz")) + + assert not os.path.islink(os.path.join(home, ".config")) + assert not os.path.islink(os.path.join(home, ".config", "foo")) + assert not os.path.islink(os.path.join(home, ".config", "bar")) + assert not os.path.islink(os.path.join(home, ".config", "baz")) + assert os.path.islink(os.path.join(home, ".config", "baz", "bizz")) + assert os.path.islink(os.path.join(home, ".config", "foo", "a")) + with open(os.path.join(home, ".config", "foo", "a")) as file: + assert file.read() == "apple" + with open(os.path.join(home, ".config", "bar", "b")) as file: + assert file.read() == "banana" + with open(os.path.join(home, ".config", "bar", "c")) as file: + assert file.read() == "cherry" + with open(os.path.join(home, ".config", "baz", "d")) as file: + assert file.read() == "donut" + with open(os.path.join(home, ".config", "baz", "bizz", "g")) as file: + assert file.read() == "grape" + + +def test_link_glob_exclude_4(home, dotfiles, run_dotbot): + """Verify deep link globbing with multiple globbed exclusions.""" + + dotfiles.write("config/foo/a", "apple") + dotfiles.write("config/bar/b", "banana") + dotfiles.write("config/bar/c", "cherry") + dotfiles.write("config/baz/d", "donut") + dotfiles.write("config/baz/buzz/e", "egg") + dotfiles.write("config/baz/bizz/g", "grape") + dotfiles.write("config/fiz/f", "fig") + dotfiles.write_config([ + { + "defaults": { + "link": { + "glob": True, + "create": True, + }, + }, + }, + { + "link": { + "~/.config/": { + "path": "config/*/*", + "exclude": ["config/baz/*", "config/fiz/*"], + }, + }, + }, + ]) + run_dotbot() + + assert not os.path.exists(os.path.join(home, ".config", "baz")) + assert not os.path.exists(os.path.join(home, ".config", "fiz")) + + assert not os.path.islink(os.path.join(home, ".config")) + assert not os.path.islink(os.path.join(home, ".config", "foo")) + assert not os.path.islink(os.path.join(home, ".config", "bar")) + assert os.path.islink(os.path.join(home, ".config", "foo", "a")) + with open(os.path.join(home, ".config", "foo", "a")) as file: + assert file.read() == "apple" + with open(os.path.join(home, ".config", "bar", "b")) as file: + assert file.read() == "banana" + with open(os.path.join(home, ".config", "bar", "c")) as file: + assert file.read() == "cherry" + + +def test_link_glob_multi_star(home, dotfiles, run_dotbot): + """Verify link globbing with deep-nested stars.""" + + dotfiles.write("config/foo/a", "apple") + dotfiles.write("config/bar/b", "banana") + dotfiles.write("config/bar/c", "cherry") + dotfiles.write_config([ + {"defaults": {"link": {"glob": True, "create": True}}}, + {"link": {"~/.config/": "config/*/*"}}, + ]) + run_dotbot() + + assert not os.path.islink(os.path.join(home, ".config")) + assert not os.path.islink(os.path.join(home, ".config", "foo")) + assert not os.path.islink(os.path.join(home, ".config", "bar")) + assert os.path.islink(os.path.join(home, ".config", "foo", "a")) + with open(os.path.join(home, ".config", "foo", "a")) as file: + assert file.read() == "apple" + with open(os.path.join(home, ".config", "bar", "b")) as file: + assert file.read() == "banana" + with open(os.path.join(home, ".config", "bar", "c")) as file: + assert file.read() == "cherry" + + +@pytest.mark.parametrize( + "pattern, expect_file", + ( + ("conf/*", lambda fruit: fruit), + ("conf/.*", lambda fruit: "." + fruit), + ("conf/[bc]*", lambda fruit: fruit if fruit[0] in "bc" else None), + ("conf/*e", lambda fruit: fruit if fruit[-1] == "e" else None), + ("conf/??r*", lambda fruit: fruit if fruit[2] == "r" else None), + ) +) +def test_link_glob_patterns(pattern, expect_file, home, dotfiles, run_dotbot): + """Verify link glob pattern matching.""" + + fruits = ["apple", "apricot", "banana", "cherry", "currant", "cantalope"] + [dotfiles.write("conf/" + fruit, fruit) for fruit in fruits] + [dotfiles.write("conf/." + fruit, "dot-" + fruit) for fruit in fruits] + dotfiles.write_config([ + {"defaults": {"link": {"glob": True, "create": True}}}, + {"link": {"~/globtest": pattern}}, + ]) + run_dotbot() + + for fruit in fruits: + if expect_file(fruit) is None: + assert not os.path.exists(os.path.join(home, "globtest", fruit)) + assert not os.path.exists(os.path.join(home, "globtest", "." + fruit)) + elif "." in expect_file(fruit): + assert not os.path.islink(os.path.join(home, "globtest", fruit)) + assert os.path.islink(os.path.join(home, "globtest", "." + fruit)) + else: # "." not in expect_file(fruit) + assert os.path.islink(os.path.join(home, "globtest", fruit)) + assert not os.path.islink(os.path.join(home, "globtest", "." + fruit)) + + +@pytest.mark.skipif( + "sys.version_info < (3, 5)", + reason="Python 3.5 required for ** globbing", +) +def test_link_glob_recursive(home, dotfiles, run_dotbot): + """Verify recursive link globbing and exclusions.""" + + dotfiles.write("config/foo/bar/a", "apple") + dotfiles.write("config/foo/bar/b", "banana") + dotfiles.write("config/foo/bar/c", "cherry") + dotfiles.write_config([ + {"defaults": {"link": {"glob": True, "create": True}}}, + {"link": {"~/.config/": {"path": "config/**", "exclude": ["config/**/b"]}}}, + ]) + run_dotbot() + + assert not os.path.islink(os.path.join(home, ".config")) + assert not os.path.islink(os.path.join(home, ".config", "foo")) + assert not os.path.islink(os.path.join(home, ".config", "foo", "bar")) + assert os.path.islink(os.path.join(home, ".config", "foo", "bar", "a")) + assert not os.path.exists(os.path.join(home, ".config", "foo", "bar", "b")) + assert os.path.islink(os.path.join(home, ".config", "foo", "bar", "c")) + with open(os.path.join(home, ".config", "foo", "bar", "a")) as file: + assert file.read() == "apple" + with open(os.path.join(home, ".config", "foo", "bar", "c")) as file: + assert file.read() == "cherry" + + +@pytest.mark.skipif( + "sys.platform[:5] == 'win32'", + reason="These if commands won't run on Windows", +) +def test_link_if(home, dotfiles, run_dotbot): + """Verify 'if' directives are checked when linking.""" + + os.mkdir(os.path.join(home, "d")) + dotfiles.write("f", "apple") + dotfiles.write_config([{ + "link": { + "~/.f": {"path": "f", "if": "true"}, + "~/.g": {"path": "f", "if": "false"}, + "~/.h": {"path": "f", "if": "[ -d ~/d ]"}, + "~/.i": {"path": "f", "if": "badcommand"}, + }, + }]) + run_dotbot() + + assert not os.path.exists(os.path.join(home, ".g")) + assert not os.path.exists(os.path.join(home, ".i")) + with open(os.path.join(home, ".f")) as file: + assert file.read() == "apple" + with open(os.path.join(home, ".h")) as file: + assert file.read() == "apple" + + +@pytest.mark.skipif( + "sys.platform[:5] == 'win32'", + reason="These if commands won't run on Windows.", +) +def test_link_if_defaults(home, dotfiles, run_dotbot): + """Verify 'if' directive defaults are checked when linking.""" + + os.mkdir(os.path.join(home, "d")) + dotfiles.write("f", "apple") + dotfiles.write_config([ + { + "defaults": { + "link": { + "if": "false", + }, + }, + }, + { + "link": { + "~/.j": {"path": "f", "if": "true"}, + "~/.k": {"path": "f"}, # default is false + }, + }, + ]) + run_dotbot() + + assert not os.path.exists(os.path.join(home, ".k")) + with open(os.path.join(home, ".j")) as file: + assert file.read() == "apple" + + +@pytest.mark.skipif( + "sys.platform[:5] != 'win32'", + reason="These if commands only run on Windows.", +) +def test_link_if_windows(home, dotfiles, run_dotbot): + """Verify 'if' directives are checked when linking (Windows only).""" + + os.mkdir(os.path.join(home, "d")) + dotfiles.write("f", "apple") + dotfiles.write_config([{ + "link": { + "~/.f": {"path": "f", "if": 'cmd /c "exit 0"'}, + "~/.g": {"path": "f", "if": 'cmd /c "exit 1"'}, + "~/.h": {"path": "f", "if": 'cmd /c "dir %USERPROFILE%\\d'}, + "~/.i": {"path": "f", "if": 'cmd /c "badcommand"'}, + }, + }]) + run_dotbot() + + assert not os.path.exists(os.path.join(home, ".g")) + assert not os.path.exists(os.path.join(home, ".i")) + with open(os.path.join(home, ".f")) as file: + assert file.read() == "apple" + with open(os.path.join(home, ".h")) as file: + assert file.read() == "apple" + + +@pytest.mark.skipif( + "sys.platform[:5] != 'win32'", + reason="These if commands only run on Windows", +) +def test_link_if_defaults_windows(home, dotfiles, run_dotbot): + """Verify 'if' directive defaults are checked when linking (Windows only).""" + + os.mkdir(os.path.join(home, "d")) + dotfiles.write("f", "apple") + dotfiles.write_config([ + { + "defaults": { + "link": { + "if": 'cmd /c "exit 1"', + }, + }, + }, + { + "link": { + "~/.j": {"path": "f", "if": 'cmd /c "exit 0"'}, + "~/.k": {"path": "f"}, # default is false + }, + }, + ]) + run_dotbot() + + assert not os.path.exists(os.path.join(home, ".k")) + with open(os.path.join(home, ".j")) as file: + assert file.read() == "apple" + + +@pytest.mark.parametrize("ignore_missing", (True, False)) +def test_link_ignore_missing(ignore_missing, home, dotfiles, run_dotbot): + """Verify link 'ignore_missing' is respected when the target is missing.""" + + dotfiles.write_config([{ + "link": { + "~/missing_link": { + "path": "missing", + "ignore-missing": ignore_missing, + }, + }, + }]) + + if ignore_missing: + run_dotbot() + assert os.path.islink(os.path.join(home, "missing_link")) + else: + with pytest.raises(SystemExit): + run_dotbot() + + +def test_link_leaves_file(home, dotfiles, run_dotbot): + """Verify relink does not overwrite file.""" + + dotfiles.write("f", "apple") + with open(os.path.join(home, ".f"), "w") as file: + file.write("grape") + dotfiles.write_config([{"link": {"~/.f": "f"}}]) + with pytest.raises(SystemExit): + run_dotbot() + + with open(os.path.join(home, ".f"), "r") as file: + assert file.read() == "grape" + + +@pytest.mark.parametrize("key", ("canonicalize-path", "canonicalize")) +def test_link_no_canonicalize(key, home, dotfiles, run_dotbot): + """Verify link canonicalization can be disabled.""" + + dotfiles.write("f", "apple") + dotfiles.write_config([ + {"defaults": {"link": {key: False}}}, + {"link": {"~/.f": {"path": "f"}}} + ]) + try: + os.symlink( + dotfiles.directory, + os.path.join(home, "dotfiles-symlink"), + target_is_directory=True, + ) + except TypeError: + # Python 2 compatibility: + # target_is_directory is only consistently available after Python 3.3. + os.symlink( + dotfiles.directory, + os.path.join(home, "dotfiles-symlink"), + ) + run_dotbot( + "-c", + os.path.join(home, "dotfiles-symlink", os.path.basename(dotfiles.config_filename)), + custom=True, + ) + assert "dotfiles-symlink" in os.readlink(os.path.join(home, ".f")) + + +def test_link_prefix(home, dotfiles, run_dotbot): + """Verify link prefixes are prepended.""" + + dotfiles.write("conf/a", "apple") + dotfiles.write("conf/b", "banana") + dotfiles.write("conf/c", "cherry") + dotfiles.write_config([{ + "link": { + "~/": { + "glob": True, + "path": "conf/*", + "prefix": ".", + }, + }, + }]) + run_dotbot() + with open(os.path.join(home, ".a")) as file: + assert file.read() == "apple" + with open(os.path.join(home, ".b")) as file: + assert file.read() == "banana" + with open(os.path.join(home, ".c")) as file: + assert file.read() == "cherry" + + +def test_link_relative(home, dotfiles, run_dotbot): + """Test relative linking works.""" + + dotfiles.write("f", "apple") + dotfiles.write("d/e", "grape") + dotfiles.write_config([{ + "link": { + "~/.f": { + "path": "f", + }, + "~/.frel": { + "path": "f", + "relative": True, + }, + "~/nested/.frel": { + "path": "f", + "relative": True, + "create": True, + }, + "~/.d": { + "path": "d", + "relative": True, + }, + }, + }]) + run_dotbot() + + f = os.readlink(os.path.join(home, ".f")) + if sys.platform[:5] == "win32" and f.startswith("\\\\?\\"): + f = f[4:] + assert f == os.path.join(dotfiles.directory, "f") + + frel = os.readlink(os.path.join(home, ".frel")) + if sys.platform[:5] == "win32" and frel.startswith("\\\\?\\"): + frel = frel[4:] + assert frel == os.path.normpath("../../dotfiles/f") + + nested_frel = os.readlink(os.path.join(home, "nested", ".frel")) + if sys.platform[:5] == "win32" and nested_frel.startswith("\\\\?\\"): + nested_frel = nested_frel[4:] + assert nested_frel == os.path.normpath("../../../dotfiles/f") + + d = os.readlink(os.path.join(home, ".d")) + if sys.platform[:5] == "win32" and d.startswith("\\\\?\\"): + d = d[4:] + assert d == os.path.normpath("../../dotfiles/d") + + with open(os.path.join(home, ".f")) as file: + assert file.read() == "apple" + with open(os.path.join(home, ".frel")) as file: + assert file.read() == "apple" + with open(os.path.join(home, "nested", ".frel")) as file: + assert file.read() == "apple" + with open(os.path.join(home, ".d", "e")) as file: + assert file.read() == "grape" + + +def test_link_relink_leaves_file(home, dotfiles, run_dotbot): + """Verify relink does not overwrite file.""" + + dotfiles.write("f", "apple") + with open(os.path.join(home, ".f"), "w") as file: + file.write("grape") + dotfiles.write_config([{"link": {"~/.f": {"path": "f", "relink": True}}}]) + with pytest.raises(SystemExit): + run_dotbot() + with open(os.path.join(home, ".f"), "r") as file: + assert file.read() == "grape" + + +def test_link_relink_overwrite_symlink(home, dotfiles, run_dotbot): + """Verify relink overwrites symlinks.""" + + dotfiles.write("f", "apple") + with open(os.path.join(home, "f"), "w") as file: + file.write("grape") + os.symlink(os.path.join(home, "f"), os.path.join(home, ".f")) + dotfiles.write_config([{"link": {"~/.f": {"path": "f", "relink": True}}}]) + run_dotbot() + with open(os.path.join(home, ".f"), "r") as file: + assert file.read() == "apple" + + +def test_link_relink_relative_leaves_file(home, dotfiles, run_dotbot): + """Verify relink relative does not incorrectly relink file.""" + + dotfiles.write("f", "apple") + with open(os.path.join(home, ".f"), "w") as file: + file.write("grape") + config = [{ + "link": { + "~/.folder/f": { + "path": "f", + "create": True, + "relative": True, + }, + }, + }] + dotfiles.write_config(config) + run_dotbot() + + mtime = os.stat(os.path.join(home, ".folder", "f")).st_mtime + + config[0]["link"]["~/.folder/f"]["relink"] = True + dotfiles.write_config(config) + run_dotbot() + + new_mtime = os.stat(os.path.join(home, ".folder", "f")).st_mtime + assert mtime == new_mtime + + +def test_link_defaults_1(home, dotfiles, run_dotbot): + """Verify that link doesn't overwrite non-dotfiles links by default.""" + + with open(os.path.join(home, "f"), "w") as file: + file.write("grape") + os.symlink(os.path.join(home, "f"), os.path.join(home, ".f")) + dotfiles.write("f", "apple") + dotfiles.write_config([{ + "link": {"~/.f": "f"}, + }]) + with pytest.raises(SystemExit): + run_dotbot() + + with open(os.path.join(home, ".f"), "r") as file: + assert file.read() == "grape" + + +def test_link_defaults_2(home, dotfiles, run_dotbot): + """Verify that explicit link defaults override the implicit default.""" + + with open(os.path.join(home, "f"), "w") as file: + file.write("grape") + os.symlink(os.path.join(home, "f"), os.path.join(home, ".f")) + dotfiles.write("f", "apple") + dotfiles.write_config([ + {"defaults": {"link": {"relink": True}}}, + {"link": {"~/.f": "f"}}, + ]) + run_dotbot() + + with open(os.path.join(home, ".f"), "r") as file: + assert file.read() == "apple" From 78bec43e332046fa073595069d752b1e54930564 Mon Sep 17 00:00:00 2001 From: Kurt McKee Date: Mon, 25 Apr 2022 09:02:10 -0500 Subject: [PATCH 03/23] Resolve Windows-specific link issues --- dotbot/plugins/link.py | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/dotbot/plugins/link.py b/dotbot/plugins/link.py index 39893ec..c938080 100644 --- a/dotbot/plugins/link.py +++ b/dotbot/plugins/link.py @@ -4,7 +4,6 @@ import glob import shutil import dotbot import dotbot.util -import subprocess class Link(dotbot.Plugin): @@ -58,7 +57,7 @@ class Link(dotbot.Plugin): if test is not None and not self._test_success(test): self._log.lowinfo("Skipping %s" % destination) continue - path = os.path.expandvars(os.path.expanduser(path)) + path = os.path.normpath(os.path.expandvars(os.path.expanduser(path))) if use_glob: glob_results = self._create_glob_results(path, exclude_paths) if len(glob_results) == 0: @@ -166,6 +165,8 @@ class Link(dotbot.Plugin): return [] # call glob.glob; only python >= 3.5 supports recursive globs found = glob.glob(path) if (sys.version_info < (3, 5)) else glob.glob(path, recursive=True) + # normalize paths to ensure cross-platform compatibility + found = [os.path.normpath(p) for p in found] # if using recursive glob (`**`), filter results to return only files: if "**" in path and not path.endswith(str(os.sep)): self._log.debug("Excluding directories from recursive glob: " + str(path)) @@ -197,7 +198,10 @@ class Link(dotbot.Plugin): Returns the destination of the symbolic link. """ path = os.path.expanduser(path) - return os.readlink(path) + path = os.readlink(path) + if sys.platform[:5] == "win32" and path.startswith("\\\\?\\"): + path = path[4:] + return path def _exists(self, path): """ @@ -223,7 +227,7 @@ class Link(dotbot.Plugin): def _delete(self, source, path, relative, canonical_path, force): success = True source = os.path.join(self._context.base_directory(canonical_path=canonical_path), source) - fullpath = os.path.expanduser(path) + fullpath = os.path.abspath(os.path.expanduser(path)) if relative: source = self._relative_path(source, fullpath) if (self._is_link(path) and self._link_destination(path) != source) or ( @@ -264,9 +268,10 @@ class Link(dotbot.Plugin): Returns true if successfully linked files. """ success = False - destination = os.path.expanduser(link_name) + destination = os.path.abspath(os.path.expanduser(link_name)) base_directory = self._context.base_directory(canonical_path=canonical_path) absolute_source = os.path.join(base_directory, source) + link_name = os.path.normpath(link_name) if relative: source = self._relative_path(absolute_source, destination) else: From b59b3af4487eda48ed708a2605ba28802ad6f61f Mon Sep 17 00:00:00 2001 From: Kurt McKee Date: Mon, 25 Apr 2022 09:02:42 -0500 Subject: [PATCH 04/23] Migrate create-* tests to Python --- tests/test_create.py | 58 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 tests/test_create.py diff --git a/tests/test_create.py b/tests/test_create.py new file mode 100644 index 0000000..7ea1ab6 --- /dev/null +++ b/tests/test_create.py @@ -0,0 +1,58 @@ +import os +import stat + +import pytest + + +@pytest.mark.parametrize("directory", ("~/a", "~/b/c")) +def test_directory_creation(home, directory, dotfiles, run_dotbot): + """Test creating directories, including nested directories.""" + + dotfiles.write_config([{"create": [directory]}]) + run_dotbot() + + expanded_directory = os.path.abspath(os.path.expanduser(directory)) + assert os.path.isdir(expanded_directory) + assert os.stat(expanded_directory).st_mode & 0o777 == 0o777 + + +def test_default_mode(home, dotfiles, run_dotbot): + """Test creating a directory with an explicit default mode. + + Note: `os.chmod()` on Windows only supports changing write permissions. + Therefore, this test is restricted to testing read-only access. + """ + + read_only = 0o777 - stat.S_IWUSR - stat.S_IWGRP - stat.S_IWOTH + config = [ + {"defaults": {"create": {"mode": read_only}}}, + {"create": ["~/a"]} + ] + dotfiles.write_config(config) + run_dotbot() + + directory = os.path.abspath(os.path.expanduser("~/a")) + assert os.stat(directory).st_mode & stat.S_IWUSR == 0 + assert os.stat(directory).st_mode & stat.S_IWGRP == 0 + assert os.stat(directory).st_mode & stat.S_IWOTH == 0 + + +def test_default_mode_override(home, dotfiles, run_dotbot): + """Test creating a directory that overrides an explicit default mode. + + Note: `os.chmod()` on Windows only supports changing write permissions. + Therefore, this test is restricted to testing read-only access. + """ + + read_only = 0o777 - stat.S_IWUSR - stat.S_IWGRP - stat.S_IWOTH + config = [ + {"defaults": {"create": {"mode": read_only}}}, + {"create": {"~/a": {"mode": 0o777}}}, + ] + dotfiles.write_config(config) + run_dotbot() + + directory = os.path.abspath(os.path.expanduser("~/a")) + assert os.stat(directory).st_mode & stat.S_IWUSR == stat.S_IWUSR + assert os.stat(directory).st_mode & stat.S_IWGRP == stat.S_IWGRP + assert os.stat(directory).st_mode & stat.S_IWOTH == stat.S_IWOTH From 5d11c7954da81cba321c8716e4b0b76bcb11bf73 Mon Sep 17 00:00:00 2001 From: Kurt McKee Date: Mon, 25 Apr 2022 09:02:58 -0500 Subject: [PATCH 05/23] Resolve Windows-specific create issues --- dotbot/plugins/create.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/dotbot/plugins/create.py b/dotbot/plugins/create.py index 6fd660f..85557a6 100644 --- a/dotbot/plugins/create.py +++ b/dotbot/plugins/create.py @@ -21,7 +21,7 @@ class Create(dotbot.Plugin): success = True defaults = self._context.defaults().get("create", {}) for key in paths: - path = os.path.expandvars(os.path.expanduser(key)) + path = os.path.abspath(os.path.expandvars(os.path.expanduser(key))) mode = defaults.get("mode", 0o777) # same as the default for os.makedirs if isinstance(paths, dict): options = paths[key] @@ -48,6 +48,9 @@ class Create(dotbot.Plugin): try: self._log.lowinfo("Creating path %s" % path) os.makedirs(path, mode) + # On Windows, the *mode* argument to `os.makedirs()` is ignored. + # The mode must be set explicitly in a follow-up call. + os.chmod(path, mode) except OSError: self._log.warning("Failed to create path %s" % path) success = False From 5b7db08e8a1ef5d3f4703eb3e0503d0f42c3e2c1 Mon Sep 17 00:00:00 2001 From: Kurt McKee Date: Tue, 26 Apr 2022 07:03:44 -0500 Subject: [PATCH 06/23] Migrate clean-* tests to Python --- tests/test_clean.py | 149 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 149 insertions(+) create mode 100644 tests/test_clean.py diff --git a/tests/test_clean.py b/tests/test_clean.py new file mode 100644 index 0000000..e67d4de --- /dev/null +++ b/tests/test_clean.py @@ -0,0 +1,149 @@ +import os +import sys + +import pytest + + +# Python 2.7 on Windows does not have an `os.symlink()` function. +# PyPy on Windows raises NotImplementedError when `os.symlink()` is called. +# Older Python 3 versions on Windows require admin rights to create symlinks. +# +# In addition, functions like `os.path.realpath()` on Windows Pythons < 3.8 +# do not resolve symlinks and directory junctions correctly, +# and `shutil.rmtree()` will fail to delete directory junctions. +# +# For these reasons, if the tests are running on Windows with Python < 3.8 +# or with PyPy, the entire link test suite must be skipped. +# +if ( + sys.platform[:5] == "win32" + and (sys.version_info < (3, 8) or "pypy" in sys.version.lower()) +): + reason = "It is impossible to perform link tests on this platform" + pytestmark = pytest.mark.skip(reason=reason) + + +def test_clean_default(root, home, dotfiles, run_dotbot): + """Verify clean uses default unless overridden.""" + + os.symlink(os.path.join(root, "nowhere"), os.path.join(home, ".g")) + dotfiles.write_config([{ + "clean": { + "~/nonexistent": {"force": True}, + "~/": None, + }, + }]) + run_dotbot() + + assert not os.path.isdir(os.path.join(home, "nonexistent")) + assert os.path.islink(os.path.join(home, ".g")) + + +def test_clean_environment_variable_expansion(home, dotfiles, run_dotbot): + """Verify clean expands environment variables.""" + + os.symlink(os.path.join(dotfiles.directory, "f"), os.path.join(home, ".f")) + variable = "$HOME" + if sys.platform[:5] == "win32": + variable = "$USERPROFILE" + dotfiles.write_config([{"clean": [variable]}]) + run_dotbot() + + assert not os.path.islink(os.path.join(home, ".f")) + + +def test_clean_missing(home, dotfiles, run_dotbot): + """Verify clean deletes links to missing files.""" + + dotfiles.write("f") + os.symlink(os.path.join(dotfiles.directory, "f"), os.path.join(home, ".f")) + os.symlink(os.path.join(dotfiles.directory, "g"), os.path.join(home, ".g")) + dotfiles.write_config([{"clean": ["~"]}]) + run_dotbot() + + assert os.path.islink(os.path.join(home, ".f")) + assert not os.path.islink(os.path.join(home, ".g")) + + +def test_clean_nonexistent(home, dotfiles, run_dotbot): + """Verify clean ignores nonexistent directories.""" + + dotfiles.write_config([{"clean": ["~", "~/fake"]}]) + run_dotbot() # Nonexistent directories should not raise exceptions. + + assert not os.path.isdir(os.path.join(home, "fake")) + + +def test_clean_outside_force(root, home, dotfiles, run_dotbot): + """Verify clean forced to remove files linking outside dotfiles directory.""" + + os.symlink(os.path.join(root, "nowhere"), os.path.join(home, ".g")) + dotfiles.write_config([{"clean": {"~/": {"force": True}}}]) + run_dotbot() + + assert not os.path.islink(os.path.join(home, ".g")) + + +def test_clean_outside(root, home, dotfiles, run_dotbot): + """Verify clean ignores files linking outside dotfiles directory.""" + + os.symlink(os.path.join(dotfiles.directory, "f"), os.path.join(home, ".f")) + os.symlink(os.path.join(home, "g"), os.path.join(home, ".g")) + dotfiles.write_config([{"clean": ["~"]}]) + run_dotbot() + + assert not os.path.islink(os.path.join(home, ".f")) + assert os.path.islink(os.path.join(home, ".g")) + + +def test_clean_recursive_1(root, home, dotfiles, run_dotbot): + """Verify clean respects when the recursive directive is off (default).""" + + os.makedirs(os.path.join(home, "a", "b")) + os.symlink(os.path.join(root, "nowhere"), os.path.join(home, "c")) + os.symlink(os.path.join(root, "nowhere"), os.path.join(home, "a", "d")) + os.symlink(os.path.join(root, "nowhere"), os.path.join(home, "a", "b", "e")) + dotfiles.write_config([{"clean": {"~": {"force": True}}}]) + run_dotbot() + + assert not os.path.islink(os.path.join(home, "c")) + assert os.path.islink(os.path.join(home, "a", "d")) + assert os.path.islink(os.path.join(home, "a", "b", "e")) + + +def test_clean_recursive_2(root, home, dotfiles, run_dotbot): + """Verify clean respects when the recursive directive is on.""" + + os.makedirs(os.path.join(home, "a", "b")) + os.symlink(os.path.join(root, "nowhere"), os.path.join(home, "c")) + os.symlink(os.path.join(root, "nowhere"), os.path.join(home, "a", "d")) + os.symlink(os.path.join(root, "nowhere"), os.path.join(home, "a", "b", "e")) + dotfiles.write_config([{"clean": {"~": {"force": True, "recursive": True}}}]) + run_dotbot() + + assert not os.path.islink(os.path.join(home, "c")) + assert not os.path.islink(os.path.join(home, "a", "d")) + assert not os.path.islink(os.path.join(home, "a", "b", "e")) + + +def test_clean_defaults_1(root, home, dotfiles, run_dotbot): + """Verify that clean doesn't erase non-dotfiles links by default.""" + + os.symlink(os.path.join(root, "nowhere"), os.path.join(home, ".g")) + dotfiles.write_config([{"clean": ["~"]}]) + run_dotbot() + + assert os.path.islink(os.path.join(home, ".g")) + + +def test_clean_defaults_2(root, home, dotfiles, run_dotbot): + """Verify that explicit clean defaults override the implicit default.""" + + os.symlink(os.path.join(root, "nowhere"), os.path.join(home, ".g")) + dotfiles.write_config([ + {"defaults": {"clean": {"force": True}}}, + {"clean": ["~"]}, + ]) + run_dotbot() + + assert not os.path.islink(os.path.join(home, ".g")) From a2846d0a6148837789b4cc75c71be4d1cba11c35 Mon Sep 17 00:00:00 2001 From: Kurt McKee Date: Tue, 26 Apr 2022 07:04:07 -0500 Subject: [PATCH 07/23] Resolve Windows-specific clean issues --- dotbot/plugins/clean.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/dotbot/plugins/clean.py b/dotbot/plugins/clean.py index ff42c09..e2671ad 100644 --- a/dotbot/plugins/clean.py +++ b/dotbot/plugins/clean.py @@ -1,4 +1,6 @@ import os +import sys + import dotbot @@ -42,7 +44,7 @@ class Clean(dotbot.Plugin): self._log.debug("Ignoring nonexistent directory %s" % target) return True for item in os.listdir(os.path.expandvars(os.path.expanduser(target))): - path = os.path.join(os.path.expandvars(os.path.expanduser(target)), item) + path = os.path.abspath(os.path.join(os.path.expandvars(os.path.expanduser(target)), item)) if recursive and os.path.isdir(path): # isdir implies not islink -- we don't want to descend into # symlinked directories. okay to do a recursive call here @@ -50,6 +52,8 @@ class Clean(dotbot.Plugin): self._clean(path, force, recursive) if not os.path.exists(path) and os.path.islink(path): points_at = os.path.join(os.path.dirname(path), os.readlink(path)) + if sys.platform[:5] == "win32" and points_at.startswith("\\\\?\\"): + points_at = points_at[4:] if self._in_directory(path, self._context.base_directory()) or force: self._log.lowinfo("Removing invalid link %s -> %s" % (path, points_at)) os.remove(path) From b8dfbae730ca723006ee8a0b7b8549e232e6fefa Mon Sep 17 00:00:00 2001 From: Kurt McKee Date: Tue, 26 Apr 2022 21:02:40 -0500 Subject: [PATCH 08/23] Migrate config-* tests to Python --- tests/test_config.py | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 tests/test_config.py diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..d6338d4 --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,36 @@ +import json +import os + + +def test_config_blank(dotfiles, run_dotbot): + """Verify blank configs work.""" + + dotfiles.write_config([]) + run_dotbot() + + +def test_config_empty(dotfiles, run_dotbot): + """Verify empty configs work.""" + + dotfiles.write("config.yaml", "") + run_dotbot("-c", os.path.join(dotfiles.directory, "config.yaml"), custom=True) + + +def test_json(home, dotfiles, run_dotbot): + """Verify JSON configs work.""" + + document = json.dumps([{"create": ["~/d"]}]) + dotfiles.write("config.json", document) + run_dotbot("-c", os.path.join(dotfiles.directory, "config.json"), custom=True) + + assert os.path.isdir(os.path.join(home, "d")) + + +def test_json_tabs(home, dotfiles, run_dotbot): + """Verify JSON configs with tabs work.""" + + document = """[\n\t{\n\t\t"create": ["~/d"]\n\t}\n]""" + dotfiles.write("config.json", document) + run_dotbot("-c", os.path.join(dotfiles.directory, "config.json"), custom=True) + + assert os.path.isdir(os.path.join(home, "d")) From 68246ba33ebcd2f5accafcf7360b7b9be0730ee4 Mon Sep 17 00:00:00 2001 From: Kurt McKee Date: Fri, 29 Apr 2022 06:13:21 -0500 Subject: [PATCH 09/23] Migrate shell-* tests to Python --- tests/test_shell.py | 217 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 217 insertions(+) create mode 100644 tests/test_shell.py diff --git a/tests/test_shell.py b/tests/test_shell.py new file mode 100644 index 0000000..8296ab3 --- /dev/null +++ b/tests/test_shell.py @@ -0,0 +1,217 @@ +def test_shell_allow_stdout(capfd, dotfiles, run_dotbot): + """Verify shell command STDOUT works.""" + + dotfiles.write_config([{ + "shell": [{ + "command": "echo apple", + "stdout": True, + }], + }]) + run_dotbot() + + output = capfd.readouterr() + assert any([line.startswith("apple") for line in output.out.splitlines()]), output + + +def test_shell_cli_verbosity_overrides_1(capfd, dotfiles, run_dotbot): + """Verify that '-vv' overrides the implicit default stdout=False.""" + + dotfiles.write_config([{"shell": [{"command": "echo apple"}]}]) + run_dotbot("-vv") + + lines = capfd.readouterr().out.splitlines() + assert any(line.startswith("apple") for line in lines) + + +def test_shell_cli_verbosity_overrides_2(capfd, dotfiles, run_dotbot): + """Verify that '-vv' overrides an explicit stdout=False.""" + + dotfiles.write_config([{"shell": [{"command": "echo apple", "stdout": False}]}]) + run_dotbot("-vv") + + lines = capfd.readouterr().out.splitlines() + assert any(line.startswith("apple") for line in lines) + + +def test_shell_cli_verbosity_overrides_3(capfd, dotfiles, run_dotbot): + """Verify that '-vv' overrides an explicit defaults:shell:stdout=False.""" + + dotfiles.write_config([ + {"defaults": {"shell": {"stdout": False}}}, + {"shell": [{"command": "echo apple"}]}, + ]) + run_dotbot("-vv") + + stdout = capfd.readouterr().out.splitlines() + assert any(line.startswith("apple") for line in stdout) + + +def test_shell_cli_verbosity_stderr(capfd, dotfiles, run_dotbot): + """Verify that commands can output to STDERR.""" + + dotfiles.write_config([{"shell": [{"command": "echo apple >&2"}]}]) + run_dotbot("-vv") + + stderr = capfd.readouterr().err.splitlines() + assert any(line.startswith("apple") for line in stderr) + + +def test_shell_cli_verbosity_stderr_with_explicit_stdout_off(capfd, dotfiles, run_dotbot): + """Verify that commands can output to STDERR with STDOUT explicitly off.""" + + dotfiles.write_config([{ + "shell": [{ + "command": "echo apple >&2", + "stdout": False, + }], + }]) + run_dotbot("-vv") + + stderr = capfd.readouterr().err.splitlines() + assert any(line.startswith("apple") for line in stderr) + + +def test_shell_cli_verbosity_stderr_with_defaults_stdout_off(capfd, dotfiles, run_dotbot): + """Verify that commands can output to STDERR with defaults:shell:stdout=False.""" + + dotfiles.write_config([ + { + "defaults": { + "shell": { + "stdout": False, + }, + }, + }, + { + "shell": [ + {"command": "echo apple >&2"}, + ], + }, + ]) + run_dotbot("-vv") + + stderr = capfd.readouterr().err.splitlines() + assert any(line.startswith("apple") for line in stderr) + + +def test_shell_single_v_verbosity_stdout(capfd, dotfiles, run_dotbot): + """Verify that a single '-v' verbosity doesn't override stdout=False.""" + + dotfiles.write_config([{"shell": [{"command": "echo apple"}]}]) + run_dotbot("-v") + + stdout = capfd.readouterr().out.splitlines() + assert not any(line.startswith("apple") for line in stdout) + + +def test_shell_single_v_verbosity_stderr(capfd, dotfiles, run_dotbot): + """Verify that a single '-v' verbosity doesn't override stderr=False.""" + + dotfiles.write_config([{"shell": [{"command": "echo apple >&2"}]}]) + run_dotbot("-v") + + stderr = capfd.readouterr().err.splitlines() + assert not any(line.startswith("apple") for line in stderr) + + +def test_shell_compact_stdout_1(capfd, dotfiles, run_dotbot): + """Verify that shell command stdout works in compact form.""" + + dotfiles.write_config([ + {"defaults": {"shell": {"stdout": True}}}, + {"shell": ["echo apple"]}, + ]) + run_dotbot() + + stdout = capfd.readouterr().out.splitlines() + assert any(line.startswith("apple") for line in stdout) + + +def test_shell_compact_stdout_2(capfd, dotfiles, run_dotbot): + """Verify that shell command stdout works in compact form.""" + + dotfiles.write_config([ + {"defaults": {"shell": {"stdout": True}}}, + {"shell": [["echo apple", "echoing message"]]}, + ]) + run_dotbot() + + stdout = capfd.readouterr().out.splitlines() + assert any(line.startswith("apple") for line in stdout) + assert any(line.startswith("echoing message") for line in stdout) + + +def test_shell_stdout_disabled_by_default(capfd, dotfiles, run_dotbot): + """Verify that the shell command disables stdout by default.""" + + dotfiles.write_config([{ + "shell": ["echo banana"], + }]) + run_dotbot() + + stdout = capfd.readouterr().out.splitlines() + assert not any(line.startswith("banana") for line in stdout) + + +def test_shell_can_override_defaults(capfd, dotfiles, run_dotbot): + """Verify that the shell command can override defaults.""" + + dotfiles.write_config([ + {"defaults": {"shell": {"stdout": True}}}, + {"shell": [{"command": "echo apple", "stdout": False}]}, + ]) + run_dotbot() + + stdout = capfd.readouterr().out.splitlines() + assert not any(line.startswith("apple") for line in stdout) + + +def test_shell_quiet_default(capfd, dotfiles, run_dotbot): + """Verify that quiet is off by default.""" + + dotfiles.write_config([{ + "shell": [{ + "command": "echo banana", + "description": "echoing a thing...", + }], + }]) + run_dotbot() + + stdout = capfd.readouterr().out.splitlines() + assert not any(line.startswith("banana") for line in stdout) + assert any("echo banana" in line for line in stdout) + assert any(line.startswith("echoing a thing...") for line in stdout) + + +def test_shell_quiet_enabled_with_description(capfd, dotfiles, run_dotbot): + """Verify that only the description is shown when quiet is enabled.""" + + dotfiles.write_config([{ + "shell": [{ + "command": "echo banana", + "description": "echoing a thing...", + "quiet": True, + }], + }]) + run_dotbot() + + stdout = capfd.readouterr().out.splitlines() + assert not any(line.startswith("banana") for line in stdout) + assert not any("echo banana" in line for line in stdout) + assert any(line.startswith("echoing a thing...") for line in stdout) + + +def test_shell_quiet_enabled_without_description(capfd, dotfiles, run_dotbot): + """Verify the command is shown when quiet is enabled with no description.""" + + dotfiles.write_config([{ + "shell": [{ + "command": "echo banana", + "quiet": True, + }], + }]) + run_dotbot() + + stdout = capfd.readouterr().out.splitlines() + assert not any(line.startswith("banana") for line in stdout) + assert any(line.startswith("echo banana") for line in stdout) From a8dd89f48fe6dcd3edf07cba1c156a75d44b68ec Mon Sep 17 00:00:00 2001 From: Kurt McKee Date: Fri, 29 Apr 2022 08:26:55 -0500 Subject: [PATCH 10/23] Migrate CLI argument tests to Python --- tests/dotbot_plugin_directory.py | 24 +++++ tests/dotbot_plugin_file.py | 27 ++++++ tests/test_cli.py | 150 +++++++++++++++++++++++++++++++ 3 files changed, 201 insertions(+) create mode 100644 tests/dotbot_plugin_directory.py create mode 100644 tests/dotbot_plugin_file.py create mode 100644 tests/test_cli.py diff --git a/tests/dotbot_plugin_directory.py b/tests/dotbot_plugin_directory.py new file mode 100644 index 0000000..5ab828a --- /dev/null +++ b/tests/dotbot_plugin_directory.py @@ -0,0 +1,24 @@ +"""Test that a plugin can be loaded by directory. + +This file is copied to a location with the name "directory.py", +and is then loaded from within the `test_cli.py` code. +""" + +import dotbot +import os.path + + +class Directory(dotbot.Plugin): + def can_handle(self, directive): + return directive == "plugin_directory" + + def handle(self, directive, data): + self._log.debug("Attempting to get options from Context") + options = self._context.options() + if len(options.plugin_dirs) != 1: + self._log.debug("Context.options.plugins length is %i, expected 1" % len(options.plugins)) + return False + + with open(os.path.abspath(os.path.expanduser("~/flag")), "w") as file: + file.write("directory plugin loading works") + return True diff --git a/tests/dotbot_plugin_file.py b/tests/dotbot_plugin_file.py new file mode 100644 index 0000000..23b6a36 --- /dev/null +++ b/tests/dotbot_plugin_file.py @@ -0,0 +1,27 @@ +"""Test that a plugin can be loaded by filename. + +This file is copied to a location with the name "file.py", +and is then loaded from within the `test_cli.py` code. +""" + +import dotbot +import os.path + + +class File(dotbot.Plugin): + def can_handle(self, directive): + return directive == "plugin_file" + + def handle(self, directive, data): + self._log.debug("Attempting to get options from Context") + options = self._context.options() + if len(options.plugins) != 1: + self._log.debug("Context.options.plugins length is %i, expected 1" % len(options.plugins)) + return False + if not options.plugins[0].endswith("file.py"): + self._log.debug("Context.options.plugins[0] is %s, expected end with file.py" % options.plugins[0]) + return False + + with open(os.path.abspath(os.path.expanduser("~/flag")), "w") as file: + file.write("file plugin loading works") + return True diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..8ce690a --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,150 @@ +import os +import shutil + +import pytest + + +def test_except_create(capfd, home, dotfiles, run_dotbot): + """Verify that `--except` works as intended.""" + + dotfiles.write_config([ + {"create": ["~/a"]}, + {"shell": [ + {"command": "echo success", "stdout": True}, + ]}, + ]) + run_dotbot("--except", "create") + + assert not os.path.exists(os.path.join(home, "a")) + stdout = capfd.readouterr().out.splitlines() + assert any(line.startswith("success") for line in stdout) + + +def test_except_shell(capfd, home, dotfiles, run_dotbot): + """Verify that `--except` works as intended.""" + + dotfiles.write_config([ + {"create": ["~/a"]}, + {"shell": [ + {"command": "echo failure", "stdout": True}, + ]}, + ]) + run_dotbot("--except", "shell") + + assert os.path.exists(os.path.join(home, "a")) + stdout = capfd.readouterr().out.splitlines() + assert not any(line.startswith("failure") for line in stdout) + + +def test_except_multiples(capfd, home, dotfiles, run_dotbot): + """Verify that `--except` works with multiple exceptions.""" + + dotfiles.write_config([ + {"create": ["~/a"]}, + {"shell": [ + {"command": "echo failure", "stdout": True}, + ]}, + ]) + run_dotbot("--except", "create", "shell") + + assert not os.path.exists(os.path.join(home, "a")) + stdout = capfd.readouterr().out.splitlines() + assert not any(line.startswith("failure") for line in stdout) + + +def test_exit_on_failure(capfd, home, dotfiles, run_dotbot): + """Verify that processing can halt immediately on failures.""" + + dotfiles.write_config([ + {"create": ["~/a"]}, + {"shell": ["this_is_not_a_command"]}, + {"create": ["~/b"]}, + ]) + with pytest.raises(SystemExit): + run_dotbot("-x") + + assert os.path.isdir(os.path.join(home, "a")) + assert not os.path.isdir(os.path.join(home, "b")) + + +def test_only(capfd, home, dotfiles, run_dotbot): + """Verify that `--only` works as intended.""" + + dotfiles.write_config([ + {"create": ["~/a"]}, + {"shell": [{"command": "echo success", "stdout": True}]}, + ]) + run_dotbot("--only", "shell") + + assert not os.path.exists(os.path.join(home, "a")) + stdout = capfd.readouterr().out.splitlines() + assert any(line.startswith("success") for line in stdout) + + +def test_only_with_defaults(capfd, home, dotfiles, run_dotbot): + """Verify that `--only` does not suppress defaults.""" + + dotfiles.write_config([ + {"defaults": {"shell": {"stdout": True}}}, + {"create": ["~/a"]}, + {"shell": [{"command": "echo success"}]}, + ]) + run_dotbot("--only", "shell") + + assert not os.path.exists(os.path.join(home, "a")) + stdout = capfd.readouterr().out.splitlines() + assert any(line.startswith("success") for line in stdout) + + +def test_only_with_multiples(capfd, home, dotfiles, run_dotbot): + """Verify that `--only` works as intended.""" + + dotfiles.write_config([ + {"create": ["~/a"]}, + {"shell": [{"command": "echo success", "stdout": True}]}, + {"link": ["~/.f"]} + ]) + run_dotbot("--only", "create", "shell") + + assert os.path.isdir(os.path.join(home, "a")) + stdout = capfd.readouterr().out.splitlines() + assert any(line.startswith("success") for line in stdout) + assert not os.path.exists(os.path.join(home, ".f")) + + +def test_plugin_loading_file(home, dotfiles, run_dotbot): + """Verify that plugins can be loaded by file.""" + + plugin_file = os.path.join(os.path.dirname(os.path.abspath(__file__)), "dotbot_plugin_file.py") + shutil.copy(plugin_file, os.path.join(dotfiles.directory, "file.py")) + dotfiles.write_config([{"plugin_file": "~"}]) + run_dotbot("--plugin", os.path.join(dotfiles.directory, "file.py")) + + with open(os.path.join(home, "flag"), "r") as file: + assert file.read() == "file plugin loading works" + + +def test_plugin_loading_directory(home, dotfiles, run_dotbot): + """Verify that plugins can be loaded from a directory.""" + + dotfiles.makedirs("plugins") + plugin_file = os.path.join(os.path.dirname(os.path.abspath(__file__)), "dotbot_plugin_directory.py") + shutil.copy(plugin_file, os.path.join(dotfiles.directory, "plugins", "directory.py")) + dotfiles.write_config([{"plugin_directory": "~"}]) + run_dotbot("--plugin-dir", os.path.join(dotfiles.directory, "plugins")) + + with open(os.path.join(home, "flag"), "r") as file: + assert file.read() == "directory plugin loading works" + + +def test_disable_builtin_plugins(home, dotfiles, run_dotbot): + """Verify that builtin plugins can be disabled.""" + + dotfiles.write("f", "apple") + dotfiles.write_config([{"link": {"~/.f": "f"}}]) + + # The link directive will be unhandled so dotbot will raise SystemExit. + with pytest.raises(SystemExit): + run_dotbot("--disable-built-in-plugins") + + assert not os.path.exists(os.path.join(home, ".f")) From b5499c7dc5b300462f3ce1c2a3d9b7a76233b39b Mon Sep 17 00:00:00 2001 From: Kurt McKee Date: Sat, 30 Apr 2022 20:19:22 -0500 Subject: [PATCH 11/23] Separate module importing from plugin identification This change allows the test framework to reliably specify which plugins to load and use within the same process. Previously, plugins were loaded by importing files and then accessing the Plugin class' list of subclasses. Now, it's possible to run dotbot multiple times without plugins accruing across runs with different configurations and CLI arguments. In addition, this fixes some circular imports that were previously avoided because plugins were imported in a function. --- dotbot/cli.py | 9 ++++++--- dotbot/dispatcher.py | 8 +++----- dotbot/plugins/clean.py | 4 ++-- dotbot/plugins/create.py | 5 +++-- dotbot/plugins/link.py | 9 +++++---- dotbot/plugins/shell.py | 10 ++++------ dotbot/util/module.py | 18 +++++++++++++++--- 7 files changed, 38 insertions(+), 25 deletions(-) diff --git a/dotbot/cli.py b/dotbot/cli.py index 28485d1..b230b6d 100644 --- a/dotbot/cli.py +++ b/dotbot/cli.py @@ -1,4 +1,4 @@ -import os, glob +import glob import sys from argparse import ArgumentParser, RawTextHelpFormatter @@ -6,6 +6,7 @@ from .config import ConfigReader, ReadingError from .dispatcher import Dispatcher, DispatchError from .messenger import Messenger from .messenger import Level +from .plugins import Clean, Create, Link, Shell from .util import module import dotbot @@ -118,9 +119,10 @@ def main(): else: log.use_color(sys.stdout.isatty()) + plugins = [] plugin_directories = list(options.plugin_dirs) if not options.disable_built_in_plugins: - from .plugins import Clean, Create, Link, Shell + plugins.extend([Clean, Create, Link, Shell]) plugin_paths = [] for directory in plugin_directories: for plugin_path in glob.glob(os.path.join(directory, "*.py")): @@ -129,7 +131,7 @@ def main(): plugin_paths.append(plugin_path) for plugin_path in plugin_paths: abspath = os.path.abspath(plugin_path) - module.load(abspath) + plugins.extend(module.load(abspath)) if not options.config_file: log.error("No configuration file specified") exit(1) @@ -151,6 +153,7 @@ def main(): skip=options.skip, exit_on_failure=options.exit_on_failure, options=options, + plugins=plugins, ) success = dispatcher.dispatch(tasks) if success: diff --git a/dotbot/dispatcher.py b/dotbot/dispatcher.py index 18f0b0a..630c895 100644 --- a/dotbot/dispatcher.py +++ b/dotbot/dispatcher.py @@ -7,11 +7,12 @@ from .context import Context class Dispatcher(object): def __init__( - self, base_directory, only=None, skip=None, exit_on_failure=False, options=Namespace() + self, base_directory, only=None, skip=None, exit_on_failure=False, options=Namespace(), plugins=None, ): self._log = Messenger() self._setup_context(base_directory, options) - self._load_plugins() + plugins = plugins or [] + self._plugins = [plugin(self._context) for plugin in plugins] self._only = only self._skip = skip self._exit = exit_on_failure @@ -65,9 +66,6 @@ class Dispatcher(object): return False return success - def _load_plugins(self): - self._plugins = [plugin(self._context) for plugin in Plugin.__subclasses__()] - class DispatchError(Exception): pass diff --git a/dotbot/plugins/clean.py b/dotbot/plugins/clean.py index e2671ad..1d17b72 100644 --- a/dotbot/plugins/clean.py +++ b/dotbot/plugins/clean.py @@ -1,10 +1,10 @@ import os import sys -import dotbot +from ..plugin import Plugin -class Clean(dotbot.Plugin): +class Clean(Plugin): """ Cleans broken symbolic links. """ diff --git a/dotbot/plugins/create.py b/dotbot/plugins/create.py index 85557a6..c593d52 100644 --- a/dotbot/plugins/create.py +++ b/dotbot/plugins/create.py @@ -1,8 +1,9 @@ import os -import dotbot + +from ..plugin import Plugin -class Create(dotbot.Plugin): +class Create(Plugin): """ Create empty paths. """ diff --git a/dotbot/plugins/link.py b/dotbot/plugins/link.py index c938080..3e8c91e 100644 --- a/dotbot/plugins/link.py +++ b/dotbot/plugins/link.py @@ -2,11 +2,12 @@ import os import sys import glob import shutil -import dotbot -import dotbot.util + +from ..plugin import Plugin +from ..util import shell_command -class Link(dotbot.Plugin): +class Link(Plugin): """ Symbolically links dotfiles. """ @@ -139,7 +140,7 @@ class Link(dotbot.Plugin): return success def _test_success(self, command): - ret = dotbot.util.shell_command(command, cwd=self._context.base_directory()) + ret = shell_command(command, cwd=self._context.base_directory()) if ret != 0: self._log.debug("Test '%s' returned false" % command) return ret == 0 diff --git a/dotbot/plugins/shell.py b/dotbot/plugins/shell.py index bbdcb6d..2ee0d68 100644 --- a/dotbot/plugins/shell.py +++ b/dotbot/plugins/shell.py @@ -1,10 +1,8 @@ -import os -import subprocess -import dotbot -import dotbot.util +from ..plugin import Plugin +from ..util import shell_command -class Shell(dotbot.Plugin): +class Shell(Plugin): """ Run arbitrary shell commands. """ @@ -50,7 +48,7 @@ class Shell(dotbot.Plugin): self._log.lowinfo("%s [%s]" % (msg, cmd)) stdout = options.get("stdout", stdout) stderr = options.get("stderr", stderr) - ret = dotbot.util.shell_command( + ret = shell_command( cmd, cwd=self._context.base_directory(), enable_stdin=stdin, diff --git a/dotbot/util/module.py b/dotbot/util/module.py index ded485a..183cac2 100644 --- a/dotbot/util/module.py +++ b/dotbot/util/module.py @@ -1,4 +1,7 @@ -import sys, os.path +import os +import sys + +from dotbot.plugin import Plugin # We keep references to loaded modules so they don't get garbage collected. loaded_modules = [] @@ -7,8 +10,17 @@ loaded_modules = [] def load(path): basename = os.path.basename(path) module_name, extension = os.path.splitext(basename) - plugin = load_module(module_name, path) - loaded_modules.append(plugin) + loaded_module = load_module(module_name, path) + plugins = [] + for name in dir(loaded_module): + possible_plugin = getattr(loaded_module, name) + try: + if issubclass(possible_plugin, Plugin) and possible_plugin is not Plugin: + plugins.append(possible_plugin) + except TypeError: + pass + loaded_modules.append(loaded_module) + return plugins if sys.version_info >= (3, 5): From 566ba0b853e1674b692c4c517ecd8133ac206674 Mon Sep 17 00:00:00 2001 From: Kurt McKee Date: Sat, 30 Apr 2022 20:42:36 -0500 Subject: [PATCH 12/23] Add black as tox environment, and run it --- dotbot/dispatcher.py | 8 +- dotbot/plugins/clean.py | 4 +- tests/conftest.py | 10 +- tests/dotbot_plugin_directory.py | 4 +- tests/dotbot_plugin_file.py | 8 +- tests/test_clean.py | 31 +- tests/test_cli.py | 98 ++++--- tests/test_create.py | 5 +- tests/test_link.py | 476 +++++++++++++++++-------------- tests/test_shell.py | 168 +++++++---- tox.ini | 11 + 11 files changed, 486 insertions(+), 337 deletions(-) diff --git a/dotbot/dispatcher.py b/dotbot/dispatcher.py index 630c895..ad82bd9 100644 --- a/dotbot/dispatcher.py +++ b/dotbot/dispatcher.py @@ -7,7 +7,13 @@ from .context import Context class Dispatcher(object): def __init__( - self, base_directory, only=None, skip=None, exit_on_failure=False, options=Namespace(), plugins=None, + self, + base_directory, + only=None, + skip=None, + exit_on_failure=False, + options=Namespace(), + plugins=None, ): self._log = Messenger() self._setup_context(base_directory, options) diff --git a/dotbot/plugins/clean.py b/dotbot/plugins/clean.py index 1d17b72..70d3522 100644 --- a/dotbot/plugins/clean.py +++ b/dotbot/plugins/clean.py @@ -44,7 +44,9 @@ class Clean(Plugin): self._log.debug("Ignoring nonexistent directory %s" % target) return True for item in os.listdir(os.path.expandvars(os.path.expanduser(target))): - path = os.path.abspath(os.path.join(os.path.expandvars(os.path.expanduser(target)), item)) + path = os.path.abspath( + os.path.join(os.path.expandvars(os.path.expanduser(target)), item) + ) if recursive and os.path.isdir(path): # isdir implies not islink -- we don't want to descend into # symlinked directories. okay to do a recursive call here diff --git a/tests/conftest.py b/tests/conftest.py index 74a01a8..3d9da2e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -45,7 +45,7 @@ def wrap_function(function, function_path, arg_index, kwarg_key, root): msg = "The '{0}' argument to {1}() must be rooted in {2}" msg = msg.format(kwarg_key, function_path, root) - assert value[:len(str(root))] == str(root), msg + assert value[: len(str(root))] == str(root), msg return function(*args, **kwargs) @@ -78,7 +78,7 @@ def wrap_open(root): msg = "The 'file' argument to open() must be rooted in {0}" msg = msg.format(root) if value != os.devnull and "w" in mode: - assert value[:len(str(root))] == str(root), msg + assert value[: len(str(root))] == str(root), msg return wrapped(*args, **kwargs) @@ -161,9 +161,7 @@ def root(): # to ensure the variable closures work correctly. function_path = "{0}.{1}".format(module.__name__, 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)) # open() must be separately wrapped. @@ -251,7 +249,7 @@ class Dotfiles(object): msg = "The config file path must be rooted in {0}" msg = msg.format(root) - assert path[:len(str(root))] == str(root), msg + assert path[: len(str(root))] == str(root), msg self.config_filename = path else: diff --git a/tests/dotbot_plugin_directory.py b/tests/dotbot_plugin_directory.py index 5ab828a..517b5c8 100644 --- a/tests/dotbot_plugin_directory.py +++ b/tests/dotbot_plugin_directory.py @@ -16,7 +16,9 @@ class Directory(dotbot.Plugin): self._log.debug("Attempting to get options from Context") options = self._context.options() if len(options.plugin_dirs) != 1: - self._log.debug("Context.options.plugins length is %i, expected 1" % len(options.plugins)) + self._log.debug( + "Context.options.plugins length is %i, expected 1" % len(options.plugins) + ) return False with open(os.path.abspath(os.path.expanduser("~/flag")), "w") as file: diff --git a/tests/dotbot_plugin_file.py b/tests/dotbot_plugin_file.py index 23b6a36..832d585 100644 --- a/tests/dotbot_plugin_file.py +++ b/tests/dotbot_plugin_file.py @@ -16,10 +16,14 @@ class File(dotbot.Plugin): self._log.debug("Attempting to get options from Context") options = self._context.options() if len(options.plugins) != 1: - self._log.debug("Context.options.plugins length is %i, expected 1" % len(options.plugins)) + self._log.debug( + "Context.options.plugins length is %i, expected 1" % len(options.plugins) + ) return False if not options.plugins[0].endswith("file.py"): - self._log.debug("Context.options.plugins[0] is %s, expected end with file.py" % options.plugins[0]) + self._log.debug( + "Context.options.plugins[0] is %s, expected end with file.py" % options.plugins[0] + ) return False with open(os.path.abspath(os.path.expanduser("~/flag")), "w") as file: diff --git a/tests/test_clean.py b/tests/test_clean.py index e67d4de..63bcd75 100644 --- a/tests/test_clean.py +++ b/tests/test_clean.py @@ -15,10 +15,7 @@ import pytest # For these reasons, if the tests are running on Windows with Python < 3.8 # or with PyPy, the entire link test suite must be skipped. # -if ( - sys.platform[:5] == "win32" - and (sys.version_info < (3, 8) or "pypy" in sys.version.lower()) -): +if sys.platform[:5] == "win32" and (sys.version_info < (3, 8) or "pypy" in sys.version.lower()): reason = "It is impossible to perform link tests on this platform" pytestmark = pytest.mark.skip(reason=reason) @@ -27,12 +24,16 @@ def test_clean_default(root, home, dotfiles, run_dotbot): """Verify clean uses default unless overridden.""" os.symlink(os.path.join(root, "nowhere"), os.path.join(home, ".g")) - dotfiles.write_config([{ - "clean": { - "~/nonexistent": {"force": True}, - "~/": None, - }, - }]) + dotfiles.write_config( + [ + { + "clean": { + "~/nonexistent": {"force": True}, + "~/": None, + }, + } + ] + ) run_dotbot() assert not os.path.isdir(os.path.join(home, "nonexistent")) @@ -140,10 +141,12 @@ def test_clean_defaults_2(root, home, dotfiles, run_dotbot): """Verify that explicit clean defaults override the implicit default.""" os.symlink(os.path.join(root, "nowhere"), os.path.join(home, ".g")) - dotfiles.write_config([ - {"defaults": {"clean": {"force": True}}}, - {"clean": ["~"]}, - ]) + dotfiles.write_config( + [ + {"defaults": {"clean": {"force": True}}}, + {"clean": ["~"]}, + ] + ) run_dotbot() assert not os.path.islink(os.path.join(home, ".g")) diff --git a/tests/test_cli.py b/tests/test_cli.py index 8ce690a..5868e6f 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -7,12 +7,16 @@ import pytest def test_except_create(capfd, home, dotfiles, run_dotbot): """Verify that `--except` works as intended.""" - dotfiles.write_config([ - {"create": ["~/a"]}, - {"shell": [ - {"command": "echo success", "stdout": True}, - ]}, - ]) + dotfiles.write_config( + [ + {"create": ["~/a"]}, + { + "shell": [ + {"command": "echo success", "stdout": True}, + ] + }, + ] + ) run_dotbot("--except", "create") assert not os.path.exists(os.path.join(home, "a")) @@ -23,12 +27,16 @@ def test_except_create(capfd, home, dotfiles, run_dotbot): def test_except_shell(capfd, home, dotfiles, run_dotbot): """Verify that `--except` works as intended.""" - dotfiles.write_config([ - {"create": ["~/a"]}, - {"shell": [ - {"command": "echo failure", "stdout": True}, - ]}, - ]) + dotfiles.write_config( + [ + {"create": ["~/a"]}, + { + "shell": [ + {"command": "echo failure", "stdout": True}, + ] + }, + ] + ) run_dotbot("--except", "shell") assert os.path.exists(os.path.join(home, "a")) @@ -39,12 +47,16 @@ def test_except_shell(capfd, home, dotfiles, run_dotbot): def test_except_multiples(capfd, home, dotfiles, run_dotbot): """Verify that `--except` works with multiple exceptions.""" - dotfiles.write_config([ - {"create": ["~/a"]}, - {"shell": [ - {"command": "echo failure", "stdout": True}, - ]}, - ]) + dotfiles.write_config( + [ + {"create": ["~/a"]}, + { + "shell": [ + {"command": "echo failure", "stdout": True}, + ] + }, + ] + ) run_dotbot("--except", "create", "shell") assert not os.path.exists(os.path.join(home, "a")) @@ -55,11 +67,13 @@ def test_except_multiples(capfd, home, dotfiles, run_dotbot): def test_exit_on_failure(capfd, home, dotfiles, run_dotbot): """Verify that processing can halt immediately on failures.""" - dotfiles.write_config([ - {"create": ["~/a"]}, - {"shell": ["this_is_not_a_command"]}, - {"create": ["~/b"]}, - ]) + dotfiles.write_config( + [ + {"create": ["~/a"]}, + {"shell": ["this_is_not_a_command"]}, + {"create": ["~/b"]}, + ] + ) with pytest.raises(SystemExit): run_dotbot("-x") @@ -70,10 +84,12 @@ def test_exit_on_failure(capfd, home, dotfiles, run_dotbot): def test_only(capfd, home, dotfiles, run_dotbot): """Verify that `--only` works as intended.""" - dotfiles.write_config([ - {"create": ["~/a"]}, - {"shell": [{"command": "echo success", "stdout": True}]}, - ]) + dotfiles.write_config( + [ + {"create": ["~/a"]}, + {"shell": [{"command": "echo success", "stdout": True}]}, + ] + ) run_dotbot("--only", "shell") assert not os.path.exists(os.path.join(home, "a")) @@ -84,11 +100,13 @@ def test_only(capfd, home, dotfiles, run_dotbot): def test_only_with_defaults(capfd, home, dotfiles, run_dotbot): """Verify that `--only` does not suppress defaults.""" - dotfiles.write_config([ - {"defaults": {"shell": {"stdout": True}}}, - {"create": ["~/a"]}, - {"shell": [{"command": "echo success"}]}, - ]) + dotfiles.write_config( + [ + {"defaults": {"shell": {"stdout": True}}}, + {"create": ["~/a"]}, + {"shell": [{"command": "echo success"}]}, + ] + ) run_dotbot("--only", "shell") assert not os.path.exists(os.path.join(home, "a")) @@ -99,11 +117,13 @@ def test_only_with_defaults(capfd, home, dotfiles, run_dotbot): def test_only_with_multiples(capfd, home, dotfiles, run_dotbot): """Verify that `--only` works as intended.""" - dotfiles.write_config([ - {"create": ["~/a"]}, - {"shell": [{"command": "echo success", "stdout": True}]}, - {"link": ["~/.f"]} - ]) + dotfiles.write_config( + [ + {"create": ["~/a"]}, + {"shell": [{"command": "echo success", "stdout": True}]}, + {"link": ["~/.f"]}, + ] + ) run_dotbot("--only", "create", "shell") assert os.path.isdir(os.path.join(home, "a")) @@ -128,7 +148,9 @@ def test_plugin_loading_directory(home, dotfiles, run_dotbot): """Verify that plugins can be loaded from a directory.""" dotfiles.makedirs("plugins") - plugin_file = os.path.join(os.path.dirname(os.path.abspath(__file__)), "dotbot_plugin_directory.py") + plugin_file = os.path.join( + os.path.dirname(os.path.abspath(__file__)), "dotbot_plugin_directory.py" + ) shutil.copy(plugin_file, os.path.join(dotfiles.directory, "plugins", "directory.py")) dotfiles.write_config([{"plugin_directory": "~"}]) run_dotbot("--plugin-dir", os.path.join(dotfiles.directory, "plugins")) diff --git a/tests/test_create.py b/tests/test_create.py index 7ea1ab6..7b2c6d5 100644 --- a/tests/test_create.py +++ b/tests/test_create.py @@ -24,10 +24,7 @@ def test_default_mode(home, dotfiles, run_dotbot): """ 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) run_dotbot() diff --git a/tests/test_link.py b/tests/test_link.py index e620b6a..37ddded 100644 --- a/tests/test_link.py +++ b/tests/test_link.py @@ -15,10 +15,7 @@ import pytest # For these reasons, if the tests are running on Windows with Python < 3.8 # or with PyPy, the entire link test suite must be skipped. # -if ( - sys.platform[:5] == "win32" - and (sys.version_info < (3, 8) or "pypy" in sys.version.lower()) -): +if sys.platform[:5] == "win32" and (sys.version_info < (3, 8) or "pypy" in sys.version.lower()): reason = "It is impossible to perform link tests on this platform" pytestmark = pytest.mark.skip(reason=reason) @@ -98,7 +95,9 @@ def test_link_environment_variable_expansion_source(monkeypatch, root, home, dot assert file.read() == "grape" -def test_link_environment_variable_expansion_source_extended(monkeypatch, root, home, dotfiles, run_dotbot): +def test_link_environment_variable_expansion_source_extended( + monkeypatch, root, home, dotfiles, run_dotbot +): """Verify link expands environment variables in extended config syntax.""" monkeypatch.setenv("APPLE", "h") @@ -198,10 +197,12 @@ def test_link_glob_1(home, dotfiles, run_dotbot): dotfiles.write("bin/a", "apple") dotfiles.write("bin/b", "banana") dotfiles.write("bin/c", "cherry") - dotfiles.write_config([ - {"defaults": {"link": {"glob": True, "create": True}}}, - {"link": {"~/bin": "bin/*"}}, - ]) + dotfiles.write_config( + [ + {"defaults": {"link": {"glob": True, "create": True}}}, + {"link": {"~/bin": "bin/*"}}, + ] + ) run_dotbot() with open(os.path.join(home, "bin", "a")) as file: @@ -218,10 +219,12 @@ def test_link_glob_2(home, dotfiles, run_dotbot): dotfiles.write("bin/a", "apple") dotfiles.write("bin/b", "banana") dotfiles.write("bin/c", "cherry") - dotfiles.write_config([ - {"defaults": {"link": {"glob": True, "create": True}}}, - {"link": {"~/bin/": "bin/*"}}, - ]) + dotfiles.write_config( + [ + {"defaults": {"link": {"glob": True, "create": True}}}, + {"link": {"~/bin/": "bin/*"}}, + ] + ) run_dotbot() with open(os.path.join(home, "bin", "a")) as file: @@ -238,10 +241,12 @@ def test_link_glob_3(home, dotfiles, run_dotbot): dotfiles.write("bin/.a", "dot-apple") dotfiles.write("bin/.b", "dot-banana") dotfiles.write("bin/.c", "dot-cherry") - dotfiles.write_config([ - {"defaults": {"link": {"glob": True, "create": True}}}, - {"link": {"~/bin/": "bin/.*"}}, - ]) + dotfiles.write_config( + [ + {"defaults": {"link": {"glob": True, "create": True}}}, + {"link": {"~/bin/": "bin/.*"}}, + ] + ) run_dotbot() with open(os.path.join(home, "bin", ".a")) as file: @@ -258,14 +263,18 @@ def test_link_glob_4(home, dotfiles, run_dotbot): dotfiles.write(".a", "dot-apple") dotfiles.write(".b", "dot-banana") dotfiles.write(".c", "dot-cherry") - dotfiles.write_config([{ - "link": { - "~": { - "path": ".*", - "glob": True, - }, - }, - }]) + dotfiles.write_config( + [ + { + "link": { + "~": { + "path": ".*", + "glob": True, + }, + }, + } + ] + ) run_dotbot() with open(os.path.join(home, ".a")) as file: @@ -281,14 +290,18 @@ def test_link_glob_ambiguous_failure(path, home, dotfiles, run_dotbot): """Verify ambiguous link globbing fails.""" dotfiles.makedirs("foo") - dotfiles.write_config([{ - "link": { - "~/foo/": { - "path": path, - "glob": True, + dotfiles.write_config( + [ + { + "link": { + "~/foo/": { + "path": path, + "glob": True, + } + } } - } - }]) + ] + ) with pytest.raises(SystemExit): run_dotbot() assert not os.path.exists(os.path.join(home, "foo")) @@ -298,14 +311,18 @@ def test_link_glob_ambiguous_success(home, dotfiles, run_dotbot): """Verify the case where ambiguous link globbing succeeds.""" dotfiles.makedirs("foo") - dotfiles.write_config([{ - "link": { - "~/foo": { - "path": "foo", - "glob": True, + dotfiles.write_config( + [ + { + "link": { + "~/foo": { + "path": "foo", + "glob": True, + } + } } - } - }]) + ] + ) run_dotbot() assert os.path.exists(os.path.join(home, "foo")) @@ -317,24 +334,26 @@ def test_link_glob_exclude_1(home, dotfiles, run_dotbot): dotfiles.write("config/bar/b", "banana") dotfiles.write("config/bar/c", "cherry") dotfiles.write("config/baz/d", "donut") - dotfiles.write_config([ - { - "defaults": { + dotfiles.write_config( + [ + { + "defaults": { + "link": { + "glob": True, + "create": True, + }, + }, + }, + { "link": { - "glob": True, - "create": True, + "~/.config/": { + "path": "config/*", + "exclude": ["config/baz"], + }, }, }, - }, - { - "link": { - "~/.config/": { - "path": "config/*", - "exclude": ["config/baz"], - }, - }, - }, - ]) + ] + ) run_dotbot() assert not os.path.exists(os.path.join(home, ".config", "baz")) @@ -358,24 +377,26 @@ def test_link_glob_exclude_2(home, dotfiles, run_dotbot): dotfiles.write("config/bar/c", "cherry") dotfiles.write("config/baz/d", "donut") dotfiles.write("config/baz/buzz/e", "egg") - dotfiles.write_config([ - { - "defaults": { + dotfiles.write_config( + [ + { + "defaults": { + "link": { + "glob": True, + "create": True, + }, + }, + }, + { "link": { - "glob": True, - "create": True, + "~/.config/": { + "path": "config/*/*", + "exclude": ["config/baz/*"], + }, }, }, - }, - { - "link": { - "~/.config/": { - "path": "config/*/*", - "exclude": ["config/baz/*"], - }, - }, - }, - ]) + ] + ) run_dotbot() assert not os.path.exists(os.path.join(home, ".config", "baz")) @@ -401,24 +422,26 @@ def test_link_glob_exclude_3(home, dotfiles, run_dotbot): dotfiles.write("config/baz/d", "donut") dotfiles.write("config/baz/buzz/e", "egg") dotfiles.write("config/baz/bizz/g", "grape") - dotfiles.write_config([ - { - "defaults": { + dotfiles.write_config( + [ + { + "defaults": { + "link": { + "glob": True, + "create": True, + }, + }, + }, + { "link": { - "glob": True, - "create": True, + "~/.config/": { + "path": "config/*/*", + "exclude": ["config/baz/buzz"], + }, }, }, - }, - { - "link": { - "~/.config/": { - "path": "config/*/*", - "exclude": ["config/baz/buzz"], - }, - }, - }, - ]) + ] + ) run_dotbot() assert not os.path.exists(os.path.join(home, ".config", "baz", "buzz")) @@ -451,24 +474,26 @@ def test_link_glob_exclude_4(home, dotfiles, run_dotbot): dotfiles.write("config/baz/buzz/e", "egg") dotfiles.write("config/baz/bizz/g", "grape") dotfiles.write("config/fiz/f", "fig") - dotfiles.write_config([ - { - "defaults": { + dotfiles.write_config( + [ + { + "defaults": { + "link": { + "glob": True, + "create": True, + }, + }, + }, + { "link": { - "glob": True, - "create": True, + "~/.config/": { + "path": "config/*/*", + "exclude": ["config/baz/*", "config/fiz/*"], + }, }, }, - }, - { - "link": { - "~/.config/": { - "path": "config/*/*", - "exclude": ["config/baz/*", "config/fiz/*"], - }, - }, - }, - ]) + ] + ) run_dotbot() assert not os.path.exists(os.path.join(home, ".config", "baz")) @@ -492,10 +517,12 @@ def test_link_glob_multi_star(home, dotfiles, run_dotbot): dotfiles.write("config/foo/a", "apple") dotfiles.write("config/bar/b", "banana") dotfiles.write("config/bar/c", "cherry") - dotfiles.write_config([ - {"defaults": {"link": {"glob": True, "create": True}}}, - {"link": {"~/.config/": "config/*/*"}}, - ]) + dotfiles.write_config( + [ + {"defaults": {"link": {"glob": True, "create": True}}}, + {"link": {"~/.config/": "config/*/*"}}, + ] + ) run_dotbot() assert not os.path.islink(os.path.join(home, ".config")) @@ -518,7 +545,7 @@ def test_link_glob_multi_star(home, dotfiles, run_dotbot): ("conf/[bc]*", lambda fruit: fruit if fruit[0] in "bc" else None), ("conf/*e", lambda fruit: fruit if fruit[-1] == "e" else None), ("conf/??r*", lambda fruit: fruit if fruit[2] == "r" else None), - ) + ), ) def test_link_glob_patterns(pattern, expect_file, home, dotfiles, run_dotbot): """Verify link glob pattern matching.""" @@ -526,10 +553,12 @@ def test_link_glob_patterns(pattern, expect_file, home, dotfiles, run_dotbot): fruits = ["apple", "apricot", "banana", "cherry", "currant", "cantalope"] [dotfiles.write("conf/" + fruit, fruit) for fruit in fruits] [dotfiles.write("conf/." + fruit, "dot-" + fruit) for fruit in fruits] - dotfiles.write_config([ - {"defaults": {"link": {"glob": True, "create": True}}}, - {"link": {"~/globtest": pattern}}, - ]) + dotfiles.write_config( + [ + {"defaults": {"link": {"glob": True, "create": True}}}, + {"link": {"~/globtest": pattern}}, + ] + ) run_dotbot() for fruit in fruits: @@ -554,10 +583,12 @@ def test_link_glob_recursive(home, dotfiles, run_dotbot): dotfiles.write("config/foo/bar/a", "apple") dotfiles.write("config/foo/bar/b", "banana") dotfiles.write("config/foo/bar/c", "cherry") - dotfiles.write_config([ - {"defaults": {"link": {"glob": True, "create": True}}}, - {"link": {"~/.config/": {"path": "config/**", "exclude": ["config/**/b"]}}}, - ]) + dotfiles.write_config( + [ + {"defaults": {"link": {"glob": True, "create": True}}}, + {"link": {"~/.config/": {"path": "config/**", "exclude": ["config/**/b"]}}}, + ] + ) run_dotbot() assert not os.path.islink(os.path.join(home, ".config")) @@ -581,14 +612,18 @@ def test_link_if(home, dotfiles, run_dotbot): os.mkdir(os.path.join(home, "d")) dotfiles.write("f", "apple") - dotfiles.write_config([{ - "link": { - "~/.f": {"path": "f", "if": "true"}, - "~/.g": {"path": "f", "if": "false"}, - "~/.h": {"path": "f", "if": "[ -d ~/d ]"}, - "~/.i": {"path": "f", "if": "badcommand"}, - }, - }]) + dotfiles.write_config( + [ + { + "link": { + "~/.f": {"path": "f", "if": "true"}, + "~/.g": {"path": "f", "if": "false"}, + "~/.h": {"path": "f", "if": "[ -d ~/d ]"}, + "~/.i": {"path": "f", "if": "badcommand"}, + }, + } + ] + ) run_dotbot() assert not os.path.exists(os.path.join(home, ".g")) @@ -608,21 +643,23 @@ def test_link_if_defaults(home, dotfiles, run_dotbot): os.mkdir(os.path.join(home, "d")) dotfiles.write("f", "apple") - dotfiles.write_config([ - { - "defaults": { - "link": { - "if": "false", + dotfiles.write_config( + [ + { + "defaults": { + "link": { + "if": "false", + }, }, }, - }, - { - "link": { - "~/.j": {"path": "f", "if": "true"}, - "~/.k": {"path": "f"}, # default is false + { + "link": { + "~/.j": {"path": "f", "if": "true"}, + "~/.k": {"path": "f"}, # default is false + }, }, - }, - ]) + ] + ) run_dotbot() assert not os.path.exists(os.path.join(home, ".k")) @@ -639,14 +676,18 @@ def test_link_if_windows(home, dotfiles, run_dotbot): os.mkdir(os.path.join(home, "d")) dotfiles.write("f", "apple") - dotfiles.write_config([{ - "link": { - "~/.f": {"path": "f", "if": 'cmd /c "exit 0"'}, - "~/.g": {"path": "f", "if": 'cmd /c "exit 1"'}, - "~/.h": {"path": "f", "if": 'cmd /c "dir %USERPROFILE%\\d'}, - "~/.i": {"path": "f", "if": 'cmd /c "badcommand"'}, - }, - }]) + dotfiles.write_config( + [ + { + "link": { + "~/.f": {"path": "f", "if": 'cmd /c "exit 0"'}, + "~/.g": {"path": "f", "if": 'cmd /c "exit 1"'}, + "~/.h": {"path": "f", "if": 'cmd /c "dir %USERPROFILE%\\d'}, + "~/.i": {"path": "f", "if": 'cmd /c "badcommand"'}, + }, + } + ] + ) run_dotbot() assert not os.path.exists(os.path.join(home, ".g")) @@ -666,21 +707,23 @@ def test_link_if_defaults_windows(home, dotfiles, run_dotbot): os.mkdir(os.path.join(home, "d")) dotfiles.write("f", "apple") - dotfiles.write_config([ - { - "defaults": { - "link": { - "if": 'cmd /c "exit 1"', + dotfiles.write_config( + [ + { + "defaults": { + "link": { + "if": 'cmd /c "exit 1"', + }, }, }, - }, - { - "link": { - "~/.j": {"path": "f", "if": 'cmd /c "exit 0"'}, - "~/.k": {"path": "f"}, # default is false + { + "link": { + "~/.j": {"path": "f", "if": 'cmd /c "exit 0"'}, + "~/.k": {"path": "f"}, # default is false + }, }, - }, - ]) + ] + ) run_dotbot() assert not os.path.exists(os.path.join(home, ".k")) @@ -692,14 +735,18 @@ def test_link_if_defaults_windows(home, dotfiles, run_dotbot): def test_link_ignore_missing(ignore_missing, home, dotfiles, run_dotbot): """Verify link 'ignore_missing' is respected when the target is missing.""" - dotfiles.write_config([{ - "link": { - "~/missing_link": { - "path": "missing", - "ignore-missing": ignore_missing, - }, - }, - }]) + dotfiles.write_config( + [ + { + "link": { + "~/missing_link": { + "path": "missing", + "ignore-missing": ignore_missing, + }, + }, + } + ] + ) if ignore_missing: run_dotbot() @@ -728,10 +775,7 @@ def test_link_no_canonicalize(key, home, dotfiles, run_dotbot): """Verify link canonicalization can be disabled.""" dotfiles.write("f", "apple") - dotfiles.write_config([ - {"defaults": {"link": {key: False}}}, - {"link": {"~/.f": {"path": "f"}}} - ]) + dotfiles.write_config([{"defaults": {"link": {key: False}}}, {"link": {"~/.f": {"path": "f"}}}]) try: os.symlink( dotfiles.directory, @@ -759,15 +803,19 @@ def test_link_prefix(home, dotfiles, run_dotbot): dotfiles.write("conf/a", "apple") dotfiles.write("conf/b", "banana") dotfiles.write("conf/c", "cherry") - dotfiles.write_config([{ - "link": { - "~/": { - "glob": True, - "path": "conf/*", - "prefix": ".", - }, - }, - }]) + dotfiles.write_config( + [ + { + "link": { + "~/": { + "glob": True, + "path": "conf/*", + "prefix": ".", + }, + }, + } + ] + ) run_dotbot() with open(os.path.join(home, ".a")) as file: assert file.read() == "apple" @@ -782,26 +830,30 @@ def test_link_relative(home, dotfiles, run_dotbot): dotfiles.write("f", "apple") dotfiles.write("d/e", "grape") - dotfiles.write_config([{ - "link": { - "~/.f": { - "path": "f", - }, - "~/.frel": { - "path": "f", - "relative": True, - }, - "~/nested/.frel": { - "path": "f", - "relative": True, - "create": True, - }, - "~/.d": { - "path": "d", - "relative": True, - }, - }, - }]) + dotfiles.write_config( + [ + { + "link": { + "~/.f": { + "path": "f", + }, + "~/.frel": { + "path": "f", + "relative": True, + }, + "~/nested/.frel": { + "path": "f", + "relative": True, + "create": True, + }, + "~/.d": { + "path": "d", + "relative": True, + }, + }, + } + ] + ) run_dotbot() f = os.readlink(os.path.join(home, ".f")) @@ -866,15 +918,17 @@ def test_link_relink_relative_leaves_file(home, dotfiles, run_dotbot): dotfiles.write("f", "apple") with open(os.path.join(home, ".f"), "w") as file: file.write("grape") - config = [{ - "link": { - "~/.folder/f": { - "path": "f", - "create": True, - "relative": True, + config = [ + { + "link": { + "~/.folder/f": { + "path": "f", + "create": True, + "relative": True, + }, }, - }, - }] + } + ] dotfiles.write_config(config) run_dotbot() @@ -895,9 +949,13 @@ def test_link_defaults_1(home, dotfiles, run_dotbot): file.write("grape") os.symlink(os.path.join(home, "f"), os.path.join(home, ".f")) dotfiles.write("f", "apple") - dotfiles.write_config([{ - "link": {"~/.f": "f"}, - }]) + dotfiles.write_config( + [ + { + "link": {"~/.f": "f"}, + } + ] + ) with pytest.raises(SystemExit): run_dotbot() @@ -912,10 +970,12 @@ def test_link_defaults_2(home, dotfiles, run_dotbot): file.write("grape") os.symlink(os.path.join(home, "f"), os.path.join(home, ".f")) dotfiles.write("f", "apple") - dotfiles.write_config([ - {"defaults": {"link": {"relink": True}}}, - {"link": {"~/.f": "f"}}, - ]) + dotfiles.write_config( + [ + {"defaults": {"link": {"relink": True}}}, + {"link": {"~/.f": "f"}}, + ] + ) run_dotbot() with open(os.path.join(home, ".f"), "r") as file: diff --git a/tests/test_shell.py b/tests/test_shell.py index 8296ab3..9ae7a64 100644 --- a/tests/test_shell.py +++ b/tests/test_shell.py @@ -1,12 +1,18 @@ def test_shell_allow_stdout(capfd, dotfiles, run_dotbot): """Verify shell command STDOUT works.""" - dotfiles.write_config([{ - "shell": [{ - "command": "echo apple", - "stdout": True, - }], - }]) + dotfiles.write_config( + [ + { + "shell": [ + { + "command": "echo apple", + "stdout": True, + } + ], + } + ] + ) run_dotbot() output = capfd.readouterr() @@ -36,10 +42,12 @@ def test_shell_cli_verbosity_overrides_2(capfd, dotfiles, run_dotbot): def test_shell_cli_verbosity_overrides_3(capfd, dotfiles, run_dotbot): """Verify that '-vv' overrides an explicit defaults:shell:stdout=False.""" - dotfiles.write_config([ - {"defaults": {"shell": {"stdout": False}}}, - {"shell": [{"command": "echo apple"}]}, - ]) + dotfiles.write_config( + [ + {"defaults": {"shell": {"stdout": False}}}, + {"shell": [{"command": "echo apple"}]}, + ] + ) run_dotbot("-vv") stdout = capfd.readouterr().out.splitlines() @@ -59,12 +67,18 @@ def test_shell_cli_verbosity_stderr(capfd, dotfiles, run_dotbot): def test_shell_cli_verbosity_stderr_with_explicit_stdout_off(capfd, dotfiles, run_dotbot): """Verify that commands can output to STDERR with STDOUT explicitly off.""" - dotfiles.write_config([{ - "shell": [{ - "command": "echo apple >&2", - "stdout": False, - }], - }]) + dotfiles.write_config( + [ + { + "shell": [ + { + "command": "echo apple >&2", + "stdout": False, + } + ], + } + ] + ) run_dotbot("-vv") stderr = capfd.readouterr().err.splitlines() @@ -74,20 +88,22 @@ def test_shell_cli_verbosity_stderr_with_explicit_stdout_off(capfd, dotfiles, ru def test_shell_cli_verbosity_stderr_with_defaults_stdout_off(capfd, dotfiles, run_dotbot): """Verify that commands can output to STDERR with defaults:shell:stdout=False.""" - dotfiles.write_config([ - { - "defaults": { - "shell": { - "stdout": False, + dotfiles.write_config( + [ + { + "defaults": { + "shell": { + "stdout": False, + }, }, }, - }, - { - "shell": [ - {"command": "echo apple >&2"}, - ], - }, - ]) + { + "shell": [ + {"command": "echo apple >&2"}, + ], + }, + ] + ) run_dotbot("-vv") stderr = capfd.readouterr().err.splitlines() @@ -117,10 +133,12 @@ def test_shell_single_v_verbosity_stderr(capfd, dotfiles, run_dotbot): def test_shell_compact_stdout_1(capfd, dotfiles, run_dotbot): """Verify that shell command stdout works in compact form.""" - dotfiles.write_config([ - {"defaults": {"shell": {"stdout": True}}}, - {"shell": ["echo apple"]}, - ]) + dotfiles.write_config( + [ + {"defaults": {"shell": {"stdout": True}}}, + {"shell": ["echo apple"]}, + ] + ) run_dotbot() stdout = capfd.readouterr().out.splitlines() @@ -130,10 +148,12 @@ def test_shell_compact_stdout_1(capfd, dotfiles, run_dotbot): def test_shell_compact_stdout_2(capfd, dotfiles, run_dotbot): """Verify that shell command stdout works in compact form.""" - dotfiles.write_config([ - {"defaults": {"shell": {"stdout": True}}}, - {"shell": [["echo apple", "echoing message"]]}, - ]) + dotfiles.write_config( + [ + {"defaults": {"shell": {"stdout": True}}}, + {"shell": [["echo apple", "echoing message"]]}, + ] + ) run_dotbot() stdout = capfd.readouterr().out.splitlines() @@ -144,9 +164,13 @@ def test_shell_compact_stdout_2(capfd, dotfiles, run_dotbot): def test_shell_stdout_disabled_by_default(capfd, dotfiles, run_dotbot): """Verify that the shell command disables stdout by default.""" - dotfiles.write_config([{ - "shell": ["echo banana"], - }]) + dotfiles.write_config( + [ + { + "shell": ["echo banana"], + } + ] + ) run_dotbot() stdout = capfd.readouterr().out.splitlines() @@ -156,10 +180,12 @@ def test_shell_stdout_disabled_by_default(capfd, dotfiles, run_dotbot): def test_shell_can_override_defaults(capfd, dotfiles, run_dotbot): """Verify that the shell command can override defaults.""" - dotfiles.write_config([ - {"defaults": {"shell": {"stdout": True}}}, - {"shell": [{"command": "echo apple", "stdout": False}]}, - ]) + dotfiles.write_config( + [ + {"defaults": {"shell": {"stdout": True}}}, + {"shell": [{"command": "echo apple", "stdout": False}]}, + ] + ) run_dotbot() stdout = capfd.readouterr().out.splitlines() @@ -169,12 +195,18 @@ def test_shell_can_override_defaults(capfd, dotfiles, run_dotbot): def test_shell_quiet_default(capfd, dotfiles, run_dotbot): """Verify that quiet is off by default.""" - dotfiles.write_config([{ - "shell": [{ - "command": "echo banana", - "description": "echoing a thing...", - }], - }]) + dotfiles.write_config( + [ + { + "shell": [ + { + "command": "echo banana", + "description": "echoing a thing...", + } + ], + } + ] + ) run_dotbot() stdout = capfd.readouterr().out.splitlines() @@ -186,13 +218,19 @@ def test_shell_quiet_default(capfd, dotfiles, run_dotbot): def test_shell_quiet_enabled_with_description(capfd, dotfiles, run_dotbot): """Verify that only the description is shown when quiet is enabled.""" - dotfiles.write_config([{ - "shell": [{ - "command": "echo banana", - "description": "echoing a thing...", - "quiet": True, - }], - }]) + dotfiles.write_config( + [ + { + "shell": [ + { + "command": "echo banana", + "description": "echoing a thing...", + "quiet": True, + } + ], + } + ] + ) run_dotbot() stdout = capfd.readouterr().out.splitlines() @@ -204,12 +242,18 @@ def test_shell_quiet_enabled_with_description(capfd, dotfiles, run_dotbot): def test_shell_quiet_enabled_without_description(capfd, dotfiles, run_dotbot): """Verify the command is shown when quiet is enabled with no description.""" - dotfiles.write_config([{ - "shell": [{ - "command": "echo banana", - "quiet": True, - }], - }]) + dotfiles.write_config( + [ + { + "shell": [ + { + "command": "echo banana", + "quiet": True, + } + ], + } + ] + ) run_dotbot() stdout = capfd.readouterr().out.splitlines() diff --git a/tox.ini b/tox.ini index acabf72..3ccd81a 100644 --- a/tox.ini +++ b/tox.ini @@ -2,6 +2,7 @@ envlist = py{27, 35, 36, 37, 38, 39, 310} pypy{2, 3} + py310-black skip_missing_interpreters = true @@ -14,3 +15,13 @@ deps = commands = pytest tests/ + + +[testenv:py310-black] +skip_install = true +skip_build = true +deps = + black + +commands = + black --check dotbot/ tests/ From ea98e5eafc0a7594770d7b9f54f5ac943a82d795 Mon Sep 17 00:00:00 2001 From: Kurt McKee Date: Sat, 30 Apr 2022 20:46:09 -0500 Subject: [PATCH 13/23] Add isort as a tox environment, and run it --- dotbot/cli.py | 21 ++++++++++----------- dotbot/config.py | 4 +++- dotbot/dispatcher.py | 5 +++-- dotbot/messenger/__init__.py | 2 +- dotbot/messenger/messenger.py | 2 +- dotbot/plugin.py | 2 +- dotbot/plugins/link.py | 4 ++-- dotbot/util/common.py | 2 +- tests/conftest.py | 2 +- tests/dotbot_plugin_directory.py | 3 ++- tests/dotbot_plugin_file.py | 3 ++- tests/test_clean.py | 1 - tests/test_link.py | 1 - tox.ini | 11 +++++++++++ 14 files changed, 38 insertions(+), 25 deletions(-) diff --git a/dotbot/cli.py b/dotbot/cli.py index b230b6d..d119902 100644 --- a/dotbot/cli.py +++ b/dotbot/cli.py @@ -1,17 +1,16 @@ import glob -import sys - -from argparse import ArgumentParser, RawTextHelpFormatter -from .config import ConfigReader, ReadingError -from .dispatcher import Dispatcher, DispatchError -from .messenger import Messenger -from .messenger import Level -from .plugins import Clean, Create, Link, Shell -from .util import module - -import dotbot import os import subprocess +import sys +from argparse import ArgumentParser, RawTextHelpFormatter + +import dotbot + +from .config import ConfigReader, ReadingError +from .dispatcher import Dispatcher, DispatchError +from .messenger import Level, Messenger +from .plugins import Clean, Create, Link, Shell +from .util import module def add_options(parser): diff --git a/dotbot/config.py b/dotbot/config.py index 9012f17..b076fd6 100644 --- a/dotbot/config.py +++ b/dotbot/config.py @@ -1,6 +1,8 @@ -import yaml import json import os.path + +import yaml + from .util import string diff --git a/dotbot/dispatcher.py b/dotbot/dispatcher.py index ad82bd9..76994c3 100644 --- a/dotbot/dispatcher.py +++ b/dotbot/dispatcher.py @@ -1,8 +1,9 @@ import os from argparse import Namespace -from .plugin import Plugin -from .messenger import Messenger + from .context import Context +from .messenger import Messenger +from .plugin import Plugin class Dispatcher(object): diff --git a/dotbot/messenger/__init__.py b/dotbot/messenger/__init__.py index 38fc6bc..394ebb3 100644 --- a/dotbot/messenger/__init__.py +++ b/dotbot/messenger/__init__.py @@ -1,2 +1,2 @@ -from .messenger import Messenger from .level import Level +from .messenger import Messenger diff --git a/dotbot/messenger/messenger.py b/dotbot/messenger/messenger.py index 2d2eead..ddd8e39 100644 --- a/dotbot/messenger/messenger.py +++ b/dotbot/messenger/messenger.py @@ -1,5 +1,5 @@ -from ..util.singleton import Singleton from ..util.compat import with_metaclass +from ..util.singleton import Singleton from .color import Color from .level import Level diff --git a/dotbot/plugin.py b/dotbot/plugin.py index ef835ab..fd1fe3d 100644 --- a/dotbot/plugin.py +++ b/dotbot/plugin.py @@ -1,5 +1,5 @@ -from .messenger import Messenger from .context import Context +from .messenger import Messenger class Plugin(object): diff --git a/dotbot/plugins/link.py b/dotbot/plugins/link.py index 3e8c91e..d0f8634 100644 --- a/dotbot/plugins/link.py +++ b/dotbot/plugins/link.py @@ -1,7 +1,7 @@ -import os -import sys import glob +import os import shutil +import sys from ..plugin import Plugin from ..util import shell_command diff --git a/dotbot/util/common.py b/dotbot/util/common.py index 62614a1..10dbb48 100644 --- a/dotbot/util/common.py +++ b/dotbot/util/common.py @@ -1,6 +1,6 @@ import os -import subprocess import platform +import subprocess def shell_command(command, cwd=None, enable_stdin=False, enable_stdout=False, enable_stderr=False): diff --git a/tests/conftest.py b/tests/conftest.py index 3d9da2e..94862de 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -2,8 +2,8 @@ import json import os import shutil import sys -from shutil import rmtree import tempfile +from shutil import rmtree import pytest import yaml diff --git a/tests/dotbot_plugin_directory.py b/tests/dotbot_plugin_directory.py index 517b5c8..fe60d7d 100644 --- a/tests/dotbot_plugin_directory.py +++ b/tests/dotbot_plugin_directory.py @@ -4,9 +4,10 @@ This file is copied to a location with the name "directory.py", and is then loaded from within the `test_cli.py` code. """ -import dotbot import os.path +import dotbot + class Directory(dotbot.Plugin): def can_handle(self, directive): diff --git a/tests/dotbot_plugin_file.py b/tests/dotbot_plugin_file.py index 832d585..1dde95d 100644 --- a/tests/dotbot_plugin_file.py +++ b/tests/dotbot_plugin_file.py @@ -4,9 +4,10 @@ This file is copied to a location with the name "file.py", and is then loaded from within the `test_cli.py` code. """ -import dotbot import os.path +import dotbot + class File(dotbot.Plugin): def can_handle(self, directive): diff --git a/tests/test_clean.py b/tests/test_clean.py index 63bcd75..8c6f901 100644 --- a/tests/test_clean.py +++ b/tests/test_clean.py @@ -3,7 +3,6 @@ import sys import pytest - # Python 2.7 on Windows does not have an `os.symlink()` function. # PyPy on Windows raises NotImplementedError when `os.symlink()` is called. # Older Python 3 versions on Windows require admin rights to create symlinks. diff --git a/tests/test_link.py b/tests/test_link.py index 37ddded..47d1051 100644 --- a/tests/test_link.py +++ b/tests/test_link.py @@ -3,7 +3,6 @@ import sys import pytest - # Python 2.7 on Windows does not have an `os.symlink()` function. # PyPy on Windows raises NotImplementedError when `os.symlink()` is called. # Older Python 3 versions on Windows require admin rights to create symlinks. diff --git a/tox.ini b/tox.ini index 3ccd81a..f401e3e 100644 --- a/tox.ini +++ b/tox.ini @@ -3,6 +3,7 @@ envlist = py{27, 35, 36, 37, 38, 39, 310} pypy{2, 3} py310-black + py310-isort skip_missing_interpreters = true @@ -25,3 +26,13 @@ deps = commands = black --check dotbot/ tests/ + + +[testenv:py310-isort] +skip_install = true +skip_build = true +deps = + isort + +commands = + isort --check dotbot/ tests/ From 1ff796a9dc5636241773e8ea2c2929ae479c4d17 Mon Sep 17 00:00:00 2001 From: Kurt McKee Date: Thu, 5 May 2022 07:51:05 -0500 Subject: [PATCH 14/23] Enforce platform-specific CPython version requirements for Windows in tox This also changes the black and isort tests to use CPython 3.9 because Cygwin currently doesn't have CPython 3.10 available. --- tests/test_clean.py | 15 --------------- tests/test_link.py | 15 --------------- tox.ini | 19 +++++++++++++------ 3 files changed, 13 insertions(+), 36 deletions(-) diff --git a/tests/test_clean.py b/tests/test_clean.py index 8c6f901..0b85fce 100644 --- a/tests/test_clean.py +++ b/tests/test_clean.py @@ -3,21 +3,6 @@ import sys import pytest -# Python 2.7 on Windows does not have an `os.symlink()` function. -# PyPy on Windows raises NotImplementedError when `os.symlink()` is called. -# Older Python 3 versions on Windows require admin rights to create symlinks. -# -# In addition, functions like `os.path.realpath()` on Windows Pythons < 3.8 -# do not resolve symlinks and directory junctions correctly, -# and `shutil.rmtree()` will fail to delete directory junctions. -# -# For these reasons, if the tests are running on Windows with Python < 3.8 -# or with PyPy, the entire link test suite must be skipped. -# -if sys.platform[:5] == "win32" and (sys.version_info < (3, 8) or "pypy" in sys.version.lower()): - reason = "It is impossible to perform link tests on this platform" - pytestmark = pytest.mark.skip(reason=reason) - def test_clean_default(root, home, dotfiles, run_dotbot): """Verify clean uses default unless overridden.""" diff --git a/tests/test_link.py b/tests/test_link.py index 47d1051..dd6d085 100644 --- a/tests/test_link.py +++ b/tests/test_link.py @@ -3,21 +3,6 @@ import sys import pytest -# Python 2.7 on Windows does not have an `os.symlink()` function. -# PyPy on Windows raises NotImplementedError when `os.symlink()` is called. -# Older Python 3 versions on Windows require admin rights to create symlinks. -# -# In addition, functions like `os.path.realpath()` on Windows Pythons < 3.8 -# do not resolve symlinks and directory junctions correctly, -# and `shutil.rmtree()` will fail to delete directory junctions. -# -# For these reasons, if the tests are running on Windows with Python < 3.8 -# or with PyPy, the entire link test suite must be skipped. -# -if sys.platform[:5] == "win32" and (sys.version_info < (3, 8) or "pypy" in sys.version.lower()): - reason = "It is impossible to perform link tests on this platform" - pytestmark = pytest.mark.skip(reason=reason) - def test_link_canonicalization(home, dotfiles, run_dotbot): """Verify links to symlinked destinations are canonical. diff --git a/tox.ini b/tox.ini index f401e3e..752a2bd 100644 --- a/tox.ini +++ b/tox.ini @@ -1,13 +1,20 @@ [tox] +; On Windows, only CPython >= 3.8 is supported. +; All older versions, and PyPy, lack full symlink support. envlist = - py{27, 35, 36, 37, 38, 39, 310} - pypy{2, 3} - py310-black - py310-isort + py{38, 39, 310}-all_platforms + py{27, 35, 36, 37}-most_platforms + pypy{2, 3}-most_platforms + py39-black + py39-isort skip_missing_interpreters = true [testenv] +platform = + all_platforms: cygwin|darwin|linux|win32 + most_platforms: cygwin|darwin|linux + deps = pytest pytest-randomly @@ -18,7 +25,7 @@ commands = pytest tests/ -[testenv:py310-black] +[testenv:py39-black] skip_install = true skip_build = true deps = @@ -28,7 +35,7 @@ commands = black --check dotbot/ tests/ -[testenv:py310-isort] +[testenv:py39-isort] skip_install = true skip_build = true deps = From 74aca02157ff4eb8bcb233d59337ae452cb0b6d1 Mon Sep 17 00:00:00 2001 From: Kurt McKee Date: Fri, 6 May 2022 16:32:29 -0500 Subject: [PATCH 15/23] Migrate the shim test to Python --- tests/test_shim.py | 62 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 tests/test_shim.py diff --git a/tests/test_shim.py b/tests/test_shim.py new file mode 100644 index 0000000..5d5fb28 --- /dev/null +++ b/tests/test_shim.py @@ -0,0 +1,62 @@ +import os +import shutil +import subprocess +import sys + +import pytest + + +def which(name): + """Find an executable. + + Python 2.7 doesn't have shutil.which(). + shutil.which() is used, if possible, to handle Windows' case-insensitivity. + """ + + if hasattr(shutil, "which"): + return shutil.which(name) + + for path in os.environ["PATH"].split(os.pathsep): + if os.path.isfile(os.path.join(path, name)): + return os.path.join(path, name) + + +def test_shim(root, home, dotfiles, run_dotbot): + """Verify install shim works.""" + + # Skip the test if git is unavailable. + git = which("git") + if git is None: + pytest.skip("git is unavailable") + + if sys.platform[:5] == "win32": + install = os.path.join( + dotfiles.directory, "dotbot", "tools", "git-submodule", "install.ps1" + ) + shim = os.path.join(dotfiles.directory, "install.ps1") + else: + install = os.path.join(dotfiles.directory, "dotbot", "tools", "git-submodule", "install") + shim = os.path.join(dotfiles.directory, "install") + + # Set up the test environment. + git_directory = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + os.chdir(dotfiles.directory) + subprocess.check_call([git, "init"]) + subprocess.check_call([git, "submodule", "add", git_directory, "dotbot"]) + shutil.copy(install, shim) + dotfiles.write("foo", "pear") + dotfiles.write_config([{"link": {"~/.foo": "foo"}}]) + + # Run the shim script. + env = dict(os.environ) + if sys.platform[:5] == "win32": + args = [which("powershell"), "-ExecutionPolicy", "RemoteSigned", shim] + env["USERPROFILE"] = home + else: + args = [shim] + env["HOME"] = home + subprocess.check_call(args, env=env, cwd=dotfiles.directory) + + assert os.path.islink(os.path.join(home, ".foo")) + with open(os.path.join(home, ".foo"), "r") as file: + assert file.read() == "pear" From 30f310e9359069aaaaa0d4d940fd29861a266890 Mon Sep 17 00:00:00 2001 From: Kurt McKee Date: Fri, 6 May 2022 16:33:12 -0500 Subject: [PATCH 16/23] Remove Python 2 references in the Powershell shim CPython >= 3.8 is required for proper Windows support. --- tools/git-submodule/install.ps1 | 2 +- tools/hg-subrepo/install.ps1 | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tools/git-submodule/install.ps1 b/tools/git-submodule/install.ps1 index a5940cf..5d8d9c5 100644 --- a/tools/git-submodule/install.ps1 +++ b/tools/git-submodule/install.ps1 @@ -10,7 +10,7 @@ Set-Location $BASEDIR git -C $DOTBOT_DIR submodule sync --quiet --recursive git submodule update --init --recursive $DOTBOT_DIR -foreach ($PYTHON in ('python', 'python3', 'python2')) { +foreach ($PYTHON in ('python', 'python3')) { # Python redirects to Microsoft Store in Windows 10 when not installed if (& { $ErrorActionPreference = "SilentlyContinue" ![string]::IsNullOrEmpty((&$PYTHON -V)) diff --git a/tools/hg-subrepo/install.ps1 b/tools/hg-subrepo/install.ps1 index 39078bf..3b9439a 100644 --- a/tools/hg-subrepo/install.ps1 +++ b/tools/hg-subrepo/install.ps1 @@ -9,7 +9,7 @@ $BASEDIR = $PSScriptRoot Set-Location $BASEDIR Set-Location $DOTBOT_DIR && git submodule update --init --recursive -foreach ($PYTHON in ('python', 'python3', 'python2')) { +foreach ($PYTHON in ('python', 'python3')) { # Python redirects to Microsoft Store in Windows 10 when not installed if (& { $ErrorActionPreference = "SilentlyContinue" ![string]::IsNullOrEmpty((&$PYTHON -V)) From 5c0ddc6fc16fb390600997ea0555398c56389ebb Mon Sep 17 00:00:00 2001 From: Kurt McKee Date: Fri, 6 May 2022 16:35:41 -0500 Subject: [PATCH 17/23] Migrate the bin/dotbot script test to Python --- tests/test_bin_dotbot.py | 55 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 tests/test_bin_dotbot.py diff --git a/tests/test_bin_dotbot.py b/tests/test_bin_dotbot.py new file mode 100644 index 0000000..aac6f14 --- /dev/null +++ b/tests/test_bin_dotbot.py @@ -0,0 +1,55 @@ +import os +import subprocess + +import pytest + + +def which(name): + """Find an executable. + + Python 2.7 doesn't have shutil.which(). + """ + + for path in os.environ["PATH"].split(os.pathsep): + if os.path.isfile(os.path.join(path, name)): + return os.path.join(path, name) + + +@pytest.mark.skipif( + "sys.platform[:5] == 'win32'", + reason="The hybrid sh/Python dotbot script doesn't run on Windows platforms", +) +@pytest.mark.parametrize("python_name", (None, "python", "python2", "python3")) +def test_find_python_executable(python_name, home, dotfiles): + """Verify that the sh/Python hybrid dotbot executable can find Python.""" + + dotfiles.write_config([]) + dotbot_executable = os.path.join( + os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "bin", "dotbot" + ) + + # Create a link to sh. + tmp_bin = os.path.join(home, "tmp_bin") + os.makedirs(tmp_bin) + sh_path = which("sh") + os.symlink(sh_path, os.path.join(tmp_bin, "sh")) + + if python_name: + with open(os.path.join(tmp_bin, python_name), "w") as file: + file.write("#!" + tmp_bin + "/sh\n") + file.write("exit 0\n") + os.chmod(os.path.join(tmp_bin, python_name), 0o777) + env = dict(os.environ) + env["PATH"] = tmp_bin + + if python_name: + subprocess.check_call( + [dotbot_executable, "-c", dotfiles.config_filename], + env=env, + ) + else: + with pytest.raises(subprocess.CalledProcessError): + subprocess.check_call( + [dotbot_executable, "-c", dotfiles.config_filename], + env=env, + ) From 57a27a770c825a2f62bff1cf48987c72dadf2624 Mon Sep 17 00:00:00 2001 From: Kurt McKee Date: Thu, 12 May 2022 15:41:38 -0500 Subject: [PATCH 18/23] Add code coverage reports --- .gitignore | 2 ++ tox.ini | 37 ++++++++++++++++++++++++++++++++++++- 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 9a522db..993327a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,9 @@ *.egg-info *.pyc +.coverage* .idea/ .tox/ .venv/ build/ dist/ +htmlcov/ diff --git a/tox.ini b/tox.ini index 752a2bd..b70643d 100644 --- a/tox.ini +++ b/tox.ini @@ -2,7 +2,9 @@ ; On Windows, only CPython >= 3.8 is supported. ; All older versions, and PyPy, lack full symlink support. envlist = + coverage_erase py{38, 39, 310}-all_platforms + coverage_report py{27, 35, 36, 37}-most_platforms pypy{2, 3}-most_platforms py39-black @@ -20,9 +22,30 @@ deps = pytest-randomly pyyaml mock; python_version == "2.7" + all_platforms: coverage commands = - pytest tests/ + all_platforms: coverage run -m pytest + most_platforms: 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 [testenv:py39-black] @@ -43,3 +66,15 @@ deps = commands = isort --check dotbot/ tests/ + + +[coverage:run] +branch = true +parallel = true +source = + dotbot/ + tests/ + + +[coverage:html] +directory = htmlcov From ee3646bba3903f169092bef9407cb5ca53d3709d Mon Sep 17 00:00:00 2001 From: Kurt McKee Date: Thu, 12 May 2022 16:30:44 -0500 Subject: [PATCH 19/23] Update CI to use tox tests --- .github/workflows/build.yml | 31 +++++++++++++++++++++---------- tox.ini | 18 +++++++++++++++++- 2 files changed, 38 insertions(+), 11 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index a77ff1e..e3154c0 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -6,22 +6,33 @@ on: - cron: '0 8 * * 6' jobs: test: - runs-on: ubuntu-latest + env: + PIP_DISABLE_PIP_VERSION_CHECK: 1 strategy: + fail-fast: false matrix: + os: ["ubuntu-latest", "macos-latest"] python: ["2.7", "pypy2", "3.5", "3.6", "3.7", "3.8", "3.9", "3.10", "pypy3"] - name: "Test: Python ${{ matrix.python }}" + include: + - os: "windows-latest" + python: "3.8" + - os: "windows-latest" + python: "3.9" + - os: "windows-latest" + python: "3.10" + runs-on: ${{ matrix.os }} + name: "Python ${{ matrix.python }} on ${{ matrix.os }}" steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 with: submodules: recursive - uses: actions/setup-python@v2 with: python-version: ${{ matrix.python }} - - run: ./test/test - fmt: - name: Format - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v2 - - uses: psf/black@stable + - name: "Install dependencies" + run: | + python -m pip install --disable-pip-version-check --upgrade pip setuptools + python -m pip install --disable-pip-version-check tox tox-gh-actions + - name: "Run tests" + run: | + python -m tox diff --git a/tox.ini b/tox.ini index b70643d..f08f09c 100644 --- a/tox.ini +++ b/tox.ini @@ -25,7 +25,7 @@ deps = all_platforms: coverage commands = - all_platforms: coverage run -m pytest + all_platforms: coverage run -m pytest tests/ most_platforms: pytest tests/ @@ -78,3 +78,19 @@ source = [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, py39-black, py39-isort + 3.10: py310-all_platforms + + ; Run on most platforms (Linux and Mac) + pypy-2: pypy2-most_platforms + pypy-3: pypy3-most_platforms + 2.7: py27-most_platforms + 3.5: py35-most_platforms + 3.6: py36-most_platforms + 3.7: py37-most_platforms From 59b1b85d07012dcfa07d82b10d06bd5180c3df2e Mon Sep 17 00:00:00 2001 From: Kurt McKee Date: Fri, 13 May 2022 10:43:29 -0500 Subject: [PATCH 20/23] Account for MacOS and Windows temp directory issues --- tests/conftest.py | 44 +++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 43 insertions(+), 1 deletion(-) diff --git a/tests/conftest.py b/tests/conftest.py index 94862de..2e9fb4d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,3 +1,4 @@ +import ctypes import json import os import shutil @@ -20,6 +21,20 @@ except ImportError: import mock # noqa: module not found +def get_long_path(path): + """Get the long path for a given path.""" + + # Do nothing for non-Windows platforms. + if sys.platform[:5] != "win32": + return path + + buffer_size = 1000 + buffer = ctypes.create_unicode_buffer(buffer_size) + get_long_path_name = ctypes.windll.kernel32.GetLongPathNameW + get_long_path_name(path, buffer, buffer_size) + return buffer.value + + # Python 2.7 compatibility: # On Linux, Python 2.7's tempfile.TemporaryFile() requires unlink access. # This list is updated by a tempfile._mkstemp_inner() wrapper, @@ -94,8 +109,35 @@ def rmtree_error_handler(_, path, __): os.unlink(path) +@pytest.fixture(autouse=True, scope="session") +def standardize_tmp(): + r"""Standardize the temporary directory path. + + On MacOS, `/var` is a symlink to `/private/var`. + This creates issues with link canonicalization and relative link tests, + so this fixture rewrites environment variables and Python variables + to ensure the tests work the same as on Linux and Windows. + + On Windows in GitHub CI, the temporary directory may be a short path. + For example, `C:\Users\RUNNER~1\...` instead of `C:\Users\runneradmin\...`. + This causes string-based path comparisons to fail. + """ + + tmp = tempfile.gettempdir() + # MacOS: `/var` is a symlink. + tmp = os.path.abspath(os.path.realpath(tmp)) + # Windows: The temporary directory may be a short path. + if sys.platform[:5] == "win32": + tmp = get_long_path(tmp) + os.environ["TMP"] = tmp + os.environ["TEMP"] = tmp + os.environ["TMPDIR"] = tmp + tempfile.tempdir = tmp + yield + + @pytest.fixture(autouse=True) -def root(): +def root(standardize_tmp): """Create a temporary directory for the duration of each test.""" # Reset allowed_tempfile_internal_unlink_calls. From d055802a667308366c8332eb5c5ce59535b5a432 Mon Sep 17 00:00:00 2001 From: Kurt McKee Date: Mon, 16 May 2022 08:53:24 -0500 Subject: [PATCH 21/23] Fix pypy3 CI issue on MacOS --- .github/workflows/build.yml | 8 ++++---- tox.ini | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index e3154c0..4d1c7e4 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -12,7 +12,7 @@ jobs: fail-fast: false matrix: os: ["ubuntu-latest", "macos-latest"] - python: ["2.7", "pypy2", "3.5", "3.6", "3.7", "3.8", "3.9", "3.10", "pypy3"] + python: ["2.7", "pypy-2.7", "3.5", "3.6", "3.7", "3.8", "3.9", "3.10", "pypy-3.9"] include: - os: "windows-latest" python: "3.8" @@ -26,13 +26,13 @@ jobs: - uses: actions/checkout@v3 with: submodules: recursive - - uses: actions/setup-python@v2 + - uses: actions/setup-python@v3 with: python-version: ${{ matrix.python }} - name: "Install dependencies" run: | - python -m pip install --disable-pip-version-check --upgrade pip setuptools - python -m pip install --disable-pip-version-check tox tox-gh-actions + python -m pip install --upgrade pip setuptools + python -m pip install tox tox-gh-actions - name: "Run tests" run: | python -m tox diff --git a/tox.ini b/tox.ini index f08f09c..6bf285a 100644 --- a/tox.ini +++ b/tox.ini @@ -88,8 +88,8 @@ python = 3.10: py310-all_platforms ; Run on most platforms (Linux and Mac) - pypy-2: pypy2-most_platforms - pypy-3: pypy3-most_platforms + pypy-2.7: pypy2-most_platforms + pypy-3.9: pypy3-most_platforms 2.7: py27-most_platforms 3.5: py35-most_platforms 3.6: py36-most_platforms From 7a586aa4c5c4be28b0077277f1e4e1ff466009cd Mon Sep 17 00:00:00 2001 From: Kurt McKee Date: Wed, 18 May 2022 07:01:06 -0500 Subject: [PATCH 22/23] Remove the Vagrant-based tests --- test/.editorconfig | 5 - test/.gitignore | 2 - test/README.md | 86 ------------ test/Vagrantfile | 28 ---- test/driver-lib.bash | 99 -------------- test/test | 53 -------- test/test-lib.bash | 76 ----------- test/tests/clean-default.bash | 19 --- .../clean-environment-variable-expansion.bash | 16 --- test/tests/clean-missing.bash | 19 --- test/tests/clean-nonexistent.bash | 8 -- test/tests/clean-outside-force.bash | 18 --- test/tests/clean-outside.bash | 18 --- test/tests/clean-recursive.bash | 34 ----- test/tests/config-blank.bash | 8 -- test/tests/config-empty.bash | 7 - test/tests/config-json-tabs.bash | 20 --- test/tests/config-json.bash | 20 --- test/tests/create-mode.bash | 26 ---- test/tests/create.bash | 23 ---- test/tests/defaults.bash | 59 --------- test/tests/except-multi.bash | 21 --- test/tests/except.bash | 32 ----- test/tests/exit-on-failure.bash | 32 ----- test/tests/find-python-executable.bash | 60 --------- test/tests/link-canonicalize.bash | 20 --- test/tests/link-default-source.bash | 26 ---- ...ink-environment-user-expansion-target.bash | 17 --- ...nt-variable-expansion-source-extended.bash | 20 --- ...environment-variable-expansion-source.bash | 18 --- ...environment-variable-expansion-target.bash | 25 ---- .../link-environment-variable-unset.bash | 18 --- .../link-force-leaves-when-nonexistent.bash | 24 ---- test/tests/link-force-overwrite-symlink.bash | 21 --- test/tests/link-glob-ambiguous.bash | 45 ------- test/tests/link-glob-exclude.bash | 123 ------------------ test/tests/link-glob-multi-star.bash | 31 ----- test/tests/link-glob-patterns.bash | 106 --------------- test/tests/link-glob-recursive.bash | 46 ------- test/tests/link-glob.bash | 93 ------------- test/tests/link-if.bash | 51 -------- test/tests/link-ignore-missing.bash | 23 ---- test/tests/link-leaves-file.bash | 18 --- test/tests/link-no-canonicalize.bash | 40 ------ test/tests/link-prefix.bash | 23 ---- test/tests/link-relative.bash | 36 ----- test/tests/link-relink-leaves-file.bash | 20 --- test/tests/link-relink-overwrite-symlink.bash | 21 --- .../link-relink-relative-leaves-file.bash | 32 ----- test/tests/only-defaults.bash | 22 ---- test/tests/only-multi.bash | 20 --- test/tests/only.bash | 32 ----- test/tests/plugin-dir.bash | 29 ----- test/tests/plugin-disable-builtin.bash | 17 --- test/tests/plugin.bash | 64 --------- test/tests/shell-allow-stdout.bash | 11 -- test/tests/shell-cli-override-config.bash | 79 ----------- test/tests/shell-compact-stdout.bash | 22 ---- test/tests/shell-disables-stdout.bash | 9 -- test/tests/shell-override-default.bash | 14 -- test/tests/shell-quiet.bash | 30 ----- test/tests/shim.bash | 22 ---- 62 files changed, 2057 deletions(-) delete mode 100644 test/.editorconfig delete mode 100644 test/.gitignore delete mode 100644 test/README.md delete mode 100644 test/Vagrantfile delete mode 100644 test/driver-lib.bash delete mode 100755 test/test delete mode 100644 test/test-lib.bash delete mode 100644 test/tests/clean-default.bash delete mode 100644 test/tests/clean-environment-variable-expansion.bash delete mode 100644 test/tests/clean-missing.bash delete mode 100644 test/tests/clean-nonexistent.bash delete mode 100644 test/tests/clean-outside-force.bash delete mode 100644 test/tests/clean-outside.bash delete mode 100644 test/tests/clean-recursive.bash delete mode 100644 test/tests/config-blank.bash delete mode 100644 test/tests/config-empty.bash delete mode 100644 test/tests/config-json-tabs.bash delete mode 100644 test/tests/config-json.bash delete mode 100644 test/tests/create-mode.bash delete mode 100644 test/tests/create.bash delete mode 100644 test/tests/defaults.bash delete mode 100644 test/tests/except-multi.bash delete mode 100644 test/tests/except.bash delete mode 100644 test/tests/exit-on-failure.bash delete mode 100644 test/tests/find-python-executable.bash delete mode 100644 test/tests/link-canonicalize.bash delete mode 100644 test/tests/link-default-source.bash delete mode 100644 test/tests/link-environment-user-expansion-target.bash delete mode 100644 test/tests/link-environment-variable-expansion-source-extended.bash delete mode 100644 test/tests/link-environment-variable-expansion-source.bash delete mode 100644 test/tests/link-environment-variable-expansion-target.bash delete mode 100644 test/tests/link-environment-variable-unset.bash delete mode 100644 test/tests/link-force-leaves-when-nonexistent.bash delete mode 100644 test/tests/link-force-overwrite-symlink.bash delete mode 100644 test/tests/link-glob-ambiguous.bash delete mode 100644 test/tests/link-glob-exclude.bash delete mode 100644 test/tests/link-glob-multi-star.bash delete mode 100644 test/tests/link-glob-patterns.bash delete mode 100644 test/tests/link-glob-recursive.bash delete mode 100644 test/tests/link-glob.bash delete mode 100644 test/tests/link-if.bash delete mode 100644 test/tests/link-ignore-missing.bash delete mode 100644 test/tests/link-leaves-file.bash delete mode 100644 test/tests/link-no-canonicalize.bash delete mode 100644 test/tests/link-prefix.bash delete mode 100644 test/tests/link-relative.bash delete mode 100644 test/tests/link-relink-leaves-file.bash delete mode 100644 test/tests/link-relink-overwrite-symlink.bash delete mode 100644 test/tests/link-relink-relative-leaves-file.bash delete mode 100644 test/tests/only-defaults.bash delete mode 100644 test/tests/only-multi.bash delete mode 100644 test/tests/only.bash delete mode 100644 test/tests/plugin-dir.bash delete mode 100644 test/tests/plugin-disable-builtin.bash delete mode 100644 test/tests/plugin.bash delete mode 100644 test/tests/shell-allow-stdout.bash delete mode 100644 test/tests/shell-cli-override-config.bash delete mode 100644 test/tests/shell-compact-stdout.bash delete mode 100644 test/tests/shell-disables-stdout.bash delete mode 100644 test/tests/shell-override-default.bash delete mode 100644 test/tests/shell-quiet.bash delete mode 100644 test/tests/shim.bash diff --git a/test/.editorconfig b/test/.editorconfig deleted file mode 100644 index b490228..0000000 --- a/test/.editorconfig +++ /dev/null @@ -1,5 +0,0 @@ -[Vagrantfile] -indent_size = 2 - -[{test,test_travis}] -indent_size = 4 diff --git a/test/.gitignore b/test/.gitignore deleted file mode 100644 index 73ab2cf..0000000 --- a/test/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -.vagrant/ -*.log diff --git a/test/README.md b/test/README.md deleted file mode 100644 index 459981e..0000000 --- a/test/README.md +++ /dev/null @@ -1,86 +0,0 @@ -Testing -======= - -Dotbot testing code uses [Vagrant] to run all tests inside a virtual machine to -have tests be completely isolated from the host machine. Specifically, you -will need both: - - - [VirtualBox] - - [Vagrant] - -Install Dotbot dependencies ---------------------------- - -Ensure you have updated the `dotbot` submodule dependencies, on the host machine: - -```bash -git submodule sync --quiet --recursive -git submodule update --init --recursive -``` -Install Vagrant ---------------- - -### Debian-based distributions - -```bash -sudo apt install vagrant virtualbox -``` - -### macOS - -You can download those directly from the above URLs, or via some MacOS package managers. -e.g. using [HomeBrew](https://brew.sh/): - -```bash -brew cask install virtualbox -brew cask install vagrant -# optional, adding menu-bar support: -brew cask install vagrant-manager -``` - -Running the Tests ------------------ - -Before running the tests, you must start and `ssh` into the VM: - -```bash -vagrant up -vagrant ssh -``` - -All remaining commands are run inside the VM. - -First, you must install a version of Python to test against, using: - - pyenv install -s {version} - -You can choose any version you like, e.g. `3.8.1`. It isn't particularly -important to test against all supported versions of Python in the VM, because -they will be tested by CI. Once you've installed a specific version of Python, -activate it with: - - pyenv global {version} - -The VM mounts your host's Dotbot directory in `/dotbot` as read-only, allowing -you to make edits on your host machine. Run the entire test suite by: - -```bash -cd /dotbot/test -./test -``` - -Selected tests can be run by passing paths to the tests as arguments, e.g.: - -```bash -./test tests/create.bash tests/defaults.bash -``` - -To debug tests, you can run the test driver with the `--debug` (or `-d` short -form) flag, e.g. `./test --debug tests/link-if.bash`. This will enable printing -stdout/stderr. - -When finished with testing, it is good to shut down the virtual machine by -running `vagrant halt`. - -[VirtualBox]: https://www.virtualbox.org/ -[Vagrant]: https://www.vagrantup.com/ diff --git a/test/Vagrantfile b/test/Vagrantfile deleted file mode 100644 index ad79ef1..0000000 --- a/test/Vagrantfile +++ /dev/null @@ -1,28 +0,0 @@ -Vagrant.configure(2) do |config| - config.vm.box = 'ubuntu/jammy64' - - config.vm.synced_folder "..", "/dotbot", mount_options: ["ro"] - - # disable default synced folder - config.vm.synced_folder ".", "/vagrant", disabled: true - - # install packages - config.vm.provision "shell", inline: <<-EOS - apt-get -y update - apt-get install -y git make build-essential libssl-dev zlib1g-dev \ - libbz2-dev libreadline-dev libsqlite3-dev wget curl llvm \ - libncurses5-dev - EOS - - # install pyenv - config.vm.provision "shell", privileged: false, inline: <<-EOS - rm -rf ~/.pyenv - git clone https://github.com/pyenv/pyenv.git ~/.pyenv - cat <<-'PYENV' > ~/.bashrc - export PYENV_ROOT="$HOME/.pyenv" - export PATH="$PYENV_ROOT/bin:$PATH" - eval "$(pyenv init --path)" - eval "$(pyenv init -)" -PYENV - EOS -end diff --git a/test/driver-lib.bash b/test/driver-lib.bash deleted file mode 100644 index 09ad303..0000000 --- a/test/driver-lib.bash +++ /dev/null @@ -1,99 +0,0 @@ -red() { - if [ -t 1 ]; then - printf "\033[31m%s\033[0m\n" "$*" - else - printf "%s\n" "$*" - fi -} - -green() { - if [ -t 1 ]; then - printf "\033[32m%s\033[0m\n" "$*" - else - printf "%s\n" "$*" - fi -} - -yellow() { - if [ -t 1 ]; then - printf "\033[33m%s\033[0m\n" "$*" - else - printf "%s\n" "$*" - fi -} - - -check_env() { - if [[ "$(whoami)" != "vagrant" && "${CI}" != true ]]; then - die "tests must be run inside Vagrant or CI" - fi -} - -cleanup() { - rm -rf ~/fakehome - mkdir -p ~/fakehome -} - -initialize() { - echo "initializing." - tests_run=0 - tests_passed=0 - tests_failed=0 - tests_skipped=0 - tests_total="${1}" - local plural="" && [ "${tests_total}" -gt 1 ] && plural="s" - printf -- "running %d test%s...\n\n" "${tests_total}" "${plural}" -} - -pass() { - tests_passed=$((tests_passed + 1)) - green "-> ok." - echo -} - -fail() { - tests_failed=$((tests_failed + 1)) - red "-> fail!" - echo -} - -skip() { - tests_skipped=$((tests_skipped + 1)) - yellow "-> skipped." - echo -} - -run_test() { - tests_run=$((tests_run + 1)) - printf '[%d/%d] (%s)\n' "${tests_run}" "${tests_total}" "${1}" - cleanup - if (cd "${BASEDIR}/test/tests" && HOME=~/fakehome DEBUG=${2} DOTBOT_TEST=true bash "${1}"); then - pass - elif [ $? -eq 42 ]; then - skip - else - fail - fi -} - -report() { - printf -- "test report\n" - printf -- "-----------\n" - printf -- "- %3d run\n" ${tests_run} - printf -- "- %3d passed\n" ${tests_passed} - printf -- "- %3d skipped\n" ${tests_skipped} - printf -- "- %3d failed\n" ${tests_failed} - if [ ${tests_failed} -gt 0 ]; then - red "==> FAIL! " - return 1 - else - green "==> PASS. " - return 0 - fi -} - -die() { - >&2 echo $@ - >&2 echo "terminating..." - exit 1 -} diff --git a/test/test b/test/test deleted file mode 100755 index c52932b..0000000 --- a/test/test +++ /dev/null @@ -1,53 +0,0 @@ -#!/usr/bin/env bash -set -e - -export BASEDIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -cd "${BASEDIR}/test" -. "./driver-lib.bash" - -date_stamp="$(date --rfc-3339=ns)" -start="$(date +%s)" - -check_env - -# parse flags; must come before positional arguments -POSITIONAL=() -DEBUG=false -while [[ $# -gt 0 ]]; do - case $1 in - -d|--debug) - DEBUG=true - shift - ;; - *) - POSITIONAL+=("$1") - shift - ;; - esac -done -set -- "${POSITIONAL[@]}" # restore positional arguments - -declare -a tests=() - -if [ $# -eq 0 ]; then - while read file; do - tests+=("${file}") - done < <(find tests -type f -name '*.bash' | sort) -else - tests=("$@") -fi - -initialize "${#tests[@]}" - -for file in "${tests[@]}"; do - run_test "$(basename "${file}")" "${DEBUG}" -done - -if report; then - ret=0 -else - ret=1 -fi - -echo "(tests run in $(($(date +%s) - start)) seconds)" -exit ${ret} diff --git a/test/test-lib.bash b/test/test-lib.bash deleted file mode 100644 index d1028cf..0000000 --- a/test/test-lib.bash +++ /dev/null @@ -1,76 +0,0 @@ -DOTBOT_EXEC="${BASEDIR}/bin/dotbot" -DOTFILES="${HOME}/dotfiles" -INSTALL_CONF='install.conf.yaml' -INSTALL_CONF_JSON='install.conf.json' - -test_run_() { - if ! ${DEBUG}; then - (eval "$*") >/dev/null 2>&1 - else - (eval "$*") - fi -} - -test_expect_success() { - local tag=${1} && shift - if ! test_run_ "$@"; then - >&2 echo "- ${tag} failed." - exit 1 - fi -} - -test_expect_failure() { - local tag=${1} && shift - if test_run_ "$@"; then - >&2 echo "- ${tag} failed." - exit 1 - fi -} - -skip_tests() { - # exit with special exit code picked up by driver-lib.bash - exit 42 -} - -check_env() { - if [ "${DOTBOT_TEST}" != "true" ]; then - >&2 echo "test must be run by test driver" - exit 1 - fi -} - -# run comparison check on python version; args: -# $1 - comparison operator (e.g. '>=') -# $2 - version number, to be passed to python (e.g. '3', '3.5', '3.6.4') -# status code will reflect if comparison is true/false -# e.g. `check_python_version '>=' 3.5` -check_python_version() { - check="$1" - version="$(echo "$2" | tr . , )" - # this call to just `python` will work in the Vagrant-based testing VM - # because `pyenv` will always create a link to the "right" version. - python -c "import sys; exit( not (sys.version_info ${check} (${version})) )" -} - -initialize() { - check_env - echo "${test_description}" - mkdir -p "${DOTFILES}" - cd -} - -run_dotbot() { - ( - cat > "${DOTFILES}/${INSTALL_CONF}" - ${DOTBOT_EXEC} -c "${DOTFILES}/${INSTALL_CONF}" "${@}" - ) -} - -run_dotbot_json() { - ( - cat > "${DOTFILES}/${INSTALL_CONF_JSON}" - ${DOTBOT_EXEC} -c "${DOTFILES}/${INSTALL_CONF_JSON}" "${@}" - ) -} - -initialize diff --git a/test/tests/clean-default.bash b/test/tests/clean-default.bash deleted file mode 100644 index 8bb405d..0000000 --- a/test/tests/clean-default.bash +++ /dev/null @@ -1,19 +0,0 @@ -test_description='clean uses default unless overridden' -. '../test-lib.bash' - -test_expect_success 'setup' ' -ln -s /nowhere ~/.g -' - -test_expect_success 'run' ' -run_dotbot < ${DOTFILES}/h -' - -test_expect_success 'run' ' -run_dotbot_json < ${DOTFILES}/h -' - -test_expect_success 'run' ' -run_dotbot_json < ${DOTFILES}/f && -echo "grape" > ~/f && -ln -s ~/f ~/.f && -ln -s /nowhere ~/.g -' - -test_expect_failure 'run-fail' ' -run_dotbot < ~/x -- link: - ~/y: y -EOF -' - -test_expect_success 'test' ' -[ "$(readlink ~/bad | cut -d/ -f5-)" = "dotfiles/nonexistent" ] && - ! test -f ~/x && test -f ~/y -' diff --git a/test/tests/except.bash b/test/tests/except.bash deleted file mode 100644 index 2973ad9..0000000 --- a/test/tests/except.bash +++ /dev/null @@ -1,32 +0,0 @@ -test_description='--except' -. '../test-lib.bash' - -test_expect_success 'setup' ' -echo "apple" > ${DOTFILES}/x -' - -test_expect_success 'run' ' -run_dotbot --except link < ~/y -- link: - ~/x: x -EOF -' - -test_expect_success 'test' ' -grep "pear" ~/y && ! test -f ~/x -' - -test_expect_success 'run 2' ' -run_dotbot --except shell < ~/z -- link: - ~/x: x -EOF -' - -test_expect_success 'test' ' -grep "apple" ~/x && ! test -f ~/z -' diff --git a/test/tests/exit-on-failure.bash b/test/tests/exit-on-failure.bash deleted file mode 100644 index aead77b..0000000 --- a/test/tests/exit-on-failure.bash +++ /dev/null @@ -1,32 +0,0 @@ -test_description='test exit on failure' -. '../test-lib.bash' - -test_expect_success 'setup' ' -echo "apple" > ${DOTFILES}/f1 && -echo "orange" > ${DOTFILES}/f2 && -echo "pineapple" > ${DOTFILES}/f3 -' - -test_expect_failure 'run_case1' ' -run_dotbot -x <> ~/tmp_bin/python < ${DOTFILES}/f && -ln -s dotfiles dotfiles-symlink -' - -test_expect_success 'run' ' -cat > "${DOTFILES}/${INSTALL_CONF}" < ${DOTFILES}/f && -echo "grape" > ${DOTFILES}/fd -' - -test_expect_success 'run' ' -run_dotbot < ~/f -' - -test_expect_success 'run' ' -run_dotbot < ${DOTFILES}/h -' - -test_expect_success 'run' ' -export APPLE="h" && -run_dotbot < ${DOTFILES}/h -' - -test_expect_success 'run' ' -export APPLE="h" && -run_dotbot < ${DOTFILES}/f && -echo "grape" > ${DOTFILES}/h -' - -test_expect_success 'run' ' -export ORANGE=".config" && -export BANANA="g" && -unset PEAR && -run_dotbot < ${DOTFILES}/\$ORANGE -' - -test_expect_success 'run' ' -unset ORANGE && -run_dotbot < ${DOTFILES}/config/foo/a && -echo "banana" > ${DOTFILES}/config/bar/b && -echo "cherry" > ${DOTFILES}/config/bar/c && -echo "donut" > ${DOTFILES}/config/baz/d -' - -test_expect_success 'run 1' ' -run_dotbot -v < ${DOTFILES}/config/baz/buzz/e -' - -test_expect_success 'run 2' ' -run_dotbot -v < ${DOTFILES}/config/baz/bizz/g -' - -test_expect_success 'run 3' ' -run_dotbot -v < ${DOTFILES}/config/fiz/f -' - -test_expect_success 'run 4' ' -run_dotbot -v < ${DOTFILES}/config/foo/a && -echo "banana" > ${DOTFILES}/config/bar/b && -echo "cherry" > ${DOTFILES}/config/bar/c -' - -test_expect_success 'run' ' -run_dotbot -v < ${DOTFILES}/conf/${fruit} - echo "dot-${fruit}" > ${DOTFILES}/conf/.${fruit} -done -' - -test_expect_success 'glob patterns: "conf/*"' ' -run_dotbot -v <=" 3.5 \ - || test_expect_failure 'expect-fail' ' -run_dotbot -v <=" 3.5 \ - || skip_tests - -test_expect_success 'setup' ' -mkdir -p ${DOTFILES}/config/foo/bar && -echo "apple" > ${DOTFILES}/config/foo/bar/a && -echo "banana" > ${DOTFILES}/config/foo/bar/b && -echo "cherry" > ${DOTFILES}/config/foo/bar/c -' - -test_expect_success 'run' ' -run_dotbot -v < ${DOTFILES}/bin/a && -echo "banana" > ${DOTFILES}/bin/b && -echo "cherry" > ${DOTFILES}/bin/c -' - -test_expect_success 'run 1' ' -run_dotbot -v < ${DOTFILES}/bin/.a && -echo "dot_banana" > ${DOTFILES}/bin/.b && -echo "dot_cherry" > ${DOTFILES}/bin/.c -' - -test_expect_success 'run 3' ' -run_dotbot -v < ${DOTFILES}/.a && -echo "dot_banana" > ${DOTFILES}/.b && -echo "dot_cherry" > ${DOTFILES}/.c -' - -test_expect_success 'run 4' ' -run_dotbot -v < ${DOTFILES}/f -' - -test_expect_success 'run' ' -run_dotbot < ${DOTFILES}/f && -echo "grape" > ~/.f -' - -test_expect_failure 'run' ' -run_dotbot < ${DOTFILES}/f && -echo "grape" > ${DOTFILES}/g && -ln -s dotfiles dotfiles-symlink -' - -test_expect_success 'run' ' -cat > "${DOTFILES}/${INSTALL_CONF}" < "${DOTFILES}/${INSTALL_CONF}" < ${DOTFILES}/conf/a && -echo "banana" > ${DOTFILES}/conf/b && -echo "cherry" > ${DOTFILES}/conf/c -' - -test_expect_success 'test glob w/ prefix' ' -run_dotbot -v < ${DOTFILES}/f && -mkdir ${DOTFILES}/d && -echo "grape" > ${DOTFILES}/d/e -' - -test_expect_success 'run' ' -run_dotbot < ${DOTFILES}/f && -echo "grape" > ~/.f -' - -test_expect_failure 'run' ' -run_dotbot < ${DOTFILES}/f && -echo "grape" > ~/f && -ln -s ~/f ~/.f -' - -test_expect_success 'run' ' -run_dotbot < ${DOTFILES}/f && -echo "grape" > ~/.f -' - -test_expect_success 'run' ' -run_dotbot < ${DOTFILES}/x -' - -test_expect_success 'run' ' -run_dotbot --only link < ~/z -- link: - ~/d/x: x -EOF -' - -test_expect_success 'test' ' -grep "apple" ~/d/x && ! test -f ~/z -' diff --git a/test/tests/only-multi.bash b/test/tests/only-multi.bash deleted file mode 100644 index e8d8362..0000000 --- a/test/tests/only-multi.bash +++ /dev/null @@ -1,20 +0,0 @@ -test_description='--only with multiple arguments' -. '../test-lib.bash' - -test_expect_success 'setup' ' -ln -s ${DOTFILES}/nonexistent ~/bad && touch ${DOTFILES}/y -' - -test_expect_success 'run' ' -run_dotbot --only clean shell < ~/x -- link: - ~/y: y -EOF -' - -test_expect_success 'test' ' -! test -f ~/bad && grep "x" ~/x && ! test -f ~/y -' diff --git a/test/tests/only.bash b/test/tests/only.bash deleted file mode 100644 index 1f74441..0000000 --- a/test/tests/only.bash +++ /dev/null @@ -1,32 +0,0 @@ -test_description='--only' -. '../test-lib.bash' - -test_expect_success 'setup' ' -echo "apple" > ${DOTFILES}/x -' - -test_expect_success 'run' ' -run_dotbot --only shell < ~/y -- link: - ~/x: x -EOF -' - -test_expect_success 'test' ' -grep "pear" ~/y && ! test -f ~/x -' - -test_expect_success 'run 2' ' -run_dotbot --only link < ~/z -- link: - ~/x: x -EOF -' - -test_expect_success 'test' ' -grep "apple" ~/x && ! test -f ~/z -' diff --git a/test/tests/plugin-dir.bash b/test/tests/plugin-dir.bash deleted file mode 100644 index f3a5e94..0000000 --- a/test/tests/plugin-dir.bash +++ /dev/null @@ -1,29 +0,0 @@ -test_description='directory-based plugin loading works' -. '../test-lib.bash' - -test_expect_success 'setup' ' -mkdir ${DOTFILES}/plugins -cat > ${DOTFILES}/plugins/test.py < ${DOTFILES}/f -' - -test_expect_failure 'run' ' -run_dotbot --disable-built-in-plugins < ${DOTFILES}/test.py < ${DOTFILES}/test.py <&2 -EOF -' - -test_expect_success 'run 5' ' -(run_dotbot -vv 2>&1 | (grep "^apple")) <&2 -EOF -' - -test_expect_success 'run 6' ' -(run_dotbot -vv 2>&1 | (grep "^apple")) <&2 - stdout: false -EOF -' - -test_expect_success 'run 7' ' -(run_dotbot -vv 2>&1 | (grep "^apple")) <&2 -EOF -' - -# Make sure that we must use verbose level 2 -# This preserves backwards compatability -test_expect_failure 'run 8' ' -(run_dotbot -v | (grep "^apple")) <&2 -EOF -' diff --git a/test/tests/shell-compact-stdout.bash b/test/tests/shell-compact-stdout.bash deleted file mode 100644 index dc55d52..0000000 --- a/test/tests/shell-compact-stdout.bash +++ /dev/null @@ -1,22 +0,0 @@ -test_description='shell command stdout works in compact form' -. '../test-lib.bash' - -test_expect_success 'run' ' -(run_dotbot | grep "^apple") < ${DOTFILES}/foo -' - -test_expect_success 'run' ' -cat > ${DOTFILES}/install.conf.yaml < Date: Wed, 18 May 2022 07:01:39 -0500 Subject: [PATCH 23/23] Document how to run the unit tests locally When verifying the steps on Windows, the `.eggs` directory suddenly appeared. This is now ignored. --- .gitignore | 1 + CONTRIBUTING.md | 25 +++++++++++++++++++++++++ 2 files changed, 26 insertions(+) diff --git a/.gitignore b/.gitignore index 993327a..c904d91 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ *.egg-info *.pyc .coverage* +.eggs/ .idea/ .tox/ .venv/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a56d1c4..53c18ec 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -50,6 +50,31 @@ used in the rest of the project. The version history should be clean, and commit messages should be descriptive and [properly formatted][commit-messages]. +When preparing a patch, it's recommended that you add unit tests +that demonstrate the bug is fixed (or that the feature works). +You can run the tests on your local machine by installing the `dev` extras. +The steps below do this using a virtual environment: + +```shell +# Create a local virtual environment +$ python -m venv .venv + +# Activate the virtual environment +# Cygwin, Linux, and MacOS: +$ . .venv/bin/activate +# Windows Powershell: +$ & .venv\Scripts\Activate.ps1 + +# Update pip and setuptools +(.venv) $ python -m pip install -U pip setuptools + +# Install dotbot and its development dependencies +(.venv) $ python -m pip install -e .[dev] + +# Run the unit tests +(.venv) $ tox +``` + --- If you have any questions about anything, feel free to [ask][email]!