Add a test framework for all supported Python versions
This commit is contained in:
parent
d2f76a2593
commit
c015f7bce8
5 changed files with 341 additions and 0 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
@ -1,4 +1,7 @@
|
||||||
*.egg-info
|
*.egg-info
|
||||||
*.pyc
|
*.pyc
|
||||||
|
.idea/
|
||||||
|
.tox/
|
||||||
|
.venv/
|
||||||
build/
|
build/
|
||||||
dist/
|
dist/
|
||||||
|
|
6
setup.py
6
setup.py
|
@ -58,6 +58,12 @@ setup(
|
||||||
install_requires=[
|
install_requires=[
|
||||||
"PyYAML>=5.3,<6",
|
"PyYAML>=5.3,<6",
|
||||||
],
|
],
|
||||||
|
extras_require={
|
||||||
|
"dev": {
|
||||||
|
"pytest",
|
||||||
|
"tox",
|
||||||
|
}
|
||||||
|
},
|
||||||
# To provide executable scripts, use entry points in preference to the
|
# To provide executable scripts, use entry points in preference to the
|
||||||
# "scripts" keyword. Entry points provide cross-platform support and allow
|
# "scripts" keyword. Entry points provide cross-platform support and allow
|
||||||
# pip to create the appropriate form of executable for the target platform.
|
# pip to create the appropriate form of executable for the target platform.
|
||||||
|
|
291
tests/conftest.py
Normal file
291
tests/conftest.py
Normal file
|
@ -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
|
25
tests/test_noop.py
Normal file
25
tests/test_noop.py
Normal file
|
@ -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")
|
16
tox.ini
Normal file
16
tox.ini
Normal file
|
@ -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/
|
Loading…
Reference in a new issue