From c015f7bce800567df499a26aa254fd498634c837 Mon Sep 17 00:00:00 2001 From: Kurt McKee Date: Thu, 14 Apr 2022 08:17:18 -0500 Subject: [PATCH] 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/