From 715a414abd1d2dcd9ee83cd535656b95d0245733 Mon Sep 17 00:00:00 2001 From: Renato Alves Date: Sun, 6 Jul 2014 02:03:04 +0100 Subject: [PATCH] UnitTests * Taskd and Taskw classes for testing are now available * Testing of server and client can now be performed. * The newer test wrappers will eventually replace the BaseTest class --- test/basetest/exceptions.py | 20 ++++ test/basetest/task.py | 183 ++++++++++++++++++++++++++++++++++++ test/basetest/taskd.py | 86 +++++++++++++++-- test/basetest/utils.py | 46 +++++++-- 4 files changed, 318 insertions(+), 17 deletions(-) create mode 100644 test/basetest/exceptions.py create mode 100644 test/basetest/task.py diff --git a/test/basetest/exceptions.py b/test/basetest/exceptions.py new file mode 100644 index 000000000..3bb836711 --- /dev/null +++ b/test/basetest/exceptions.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- + + +class CommandError(Exception): + def __init__(self, cmd, code, out, err, msg=None): + if msg is None: + self.msg = ("Command '{0}' finished with unexpected exit code " + "'{1}':\nStdout: '{2}'\nStderr: '{3}'") + else: + self.msg = msg + + self.cmd = cmd + self.out = out + self.err = err + self.code = code + + def __str__(self): + return self.msg.format(self.cmd, self.code, self.out, self.err) + +# vim: ai sts=4 et sw=4 diff --git a/test/basetest/task.py b/test/basetest/task.py new file mode 100644 index 000000000..1b86f8c4f --- /dev/null +++ b/test/basetest/task.py @@ -0,0 +1,183 @@ +# -*- coding: utf-8 -*- + +import os +import tempfile +import shutil +import atexit +from .utils import run_cmd_wait, run_cmd_wait_nofail +from .exceptions import CommandError + + +class Task(object): + """Manage a task warrior instance + + A temporary folder is used as data store of task warrior. + This class can be instanciated multiple times if multiple taskw clients are + needed. + + This class can be given a Taskd instance for simplified configuration. + + A taskw client should not be used after being destroyed. + """ + def __init__(self, taskw="task", taskd=None): + """Initialize a Task warrior (client) that can interact with a taskd + server. The task client runs in a temporary folder. + + :arg taskw: Task binary to use as client (defaults: task in PATH) + :arg taskd: Taskd instance for client-server configuration + """ + self.taskw = taskw + self.taskd = taskd + + # Configuration of the isolated environment + self._original_pwd = os.getcwd() + self.datadir = tempfile.mkdtemp() + self.taskrc = os.path.join(self.datadir, "test.rc") + + # Ensure any instance is properly destroyed at session end + atexit.register(lambda: self.destroy()) + + # Copy all env variables to avoid clashing subprocess environments + self.env = os.environ.copy() + + # Make sure no TASKDDATA is isolated + self.env["TASKDATA"] = self.datadir + # As well as TASKRC + self.env["TASKRC"] = self.taskrc + + # Cannot call self.config until confirmation is disabled + with open(self.taskrc, 'w') as rc: + rc.write("data.location={0}\n" + "confirmation=no".format(self.datadir)) + + # Setup configuration to talk to taskd automatically + if self.taskd is not None: + self.bind_taskd_server(self.taskd) + + def __repr__(self): + txt = super(Task, self).__repr__() + return "{0} running from {1}>".format(txt[:-1], self.datadir) + + def bind_taskd_server(self, taskd): + """Configure the present task client to talk to given taskd server + + Note that this can be performed automatically by passing taskd when + creating an instance of the current class. + """ + self.taskd = taskd + + cert = os.path.join(self.taskd.certpath, "test_client.cert.pem") + key = os.path.join(self.taskd.certpath, "test_client.key.pem") + self.config("taskd.certificate", cert) + self.config("taskd.key", key) + self.config("taskd.ca", self.taskd.ca_cert) + + address = ":".join((self.taskd.address, str(self.taskd.port))) + self.config("taskd.server", address) + + # Also configure the default user for given taskd server + self.set_taskd_user() + + def set_taskd_user(self, taskd_user=None, default=True): + """Assign a new user user to the present task client + + If default==False, a new user will be assigned instead of reusing the + default taskd user for the corresponding instance. + """ + if taskd_user is None: + if default: + user, group, org, userkey = self.taskd.default_user + else: + user, group, org, userkey = self.taskd.create_user() + else: + user, group, org, userkey = taskd_user + + self.credentials = "/".join((org, user, userkey)) + self.config("taskd.credentials", self.credentials) + + def config(self, var, value): + """Run setup `var` as `value` in taskd config + """ + # Add -- to avoid misinterpretation of - in things like UUIDs + cmd = (self.taskw, "config", "--", var, value) + return run_cmd_wait(cmd, env=self.env) + + def runSuccess(self, args=(), input=None, merge_streams=True): + """Invoke task with the given arguments + + Use runError if you want exit_code to be tested automatically and + *not* fail if program finishes abnormally. + + If you wish to pass instructions to task such as confirmations or other + input via stdin, you can do so by providing a input string. + Such as input="y\ny". + + If merge_streams=True stdout and stderr will be merged into stdout. + + Returns (exit_code, stdout, stderr) + """ + command = [self.taskw] + command.extend(args) + + return run_cmd_wait(command, input, + merge_streams=merge_streams, env=self.env) + + def runError(self, args=(), input=None, merge_streams=True): + """Same as runSuccess but Invoke task with the given arguments + + Use runSuccess if you want exit_code to be tested automatically and + *fail* if program finishes abnormally. + + If you wish to pass instructions to task such as confirmations or other + input via stdin, you can do so by providing a input string. + Such as input="y\ny". + + If merge_streams=True stdout and stderr will be merged into stdout. + + Returns (exit_code, stdout, stderr) + """ + command = [self.taskw] + command.extend(args) + + output = run_cmd_wait_nofail(command, input, + merge_streams=merge_streams, env=self.env) + + # output[0] is the exit code + if output[0] == 0: + raise CommandError(command, *output) + + return output + + def destroy(self): + """Cleanup the data folder and release server port for other instances + """ + try: + shutil.rmtree(self.datadir) + except OSError as e: + if e.errno == 2: + # Directory no longer exists + pass + else: + raise + + # Prevent future reuse of this instance + self.runSuccess = self.__destroyed + self.runError = self.__destroyed + + # self.destroy will get called when the python session closes. + # If self.destroy was already called, turn the action into a noop + self.destroy = lambda: None + + def __destroyed(self, *args, **kwargs): + raise AttributeError("Task instance has been destroyed. " + "Create a new instance if you need a new client.") + + def diag(self, out): + """Diagnostics are just lines preceded with #. + """ + print '# --- diag start ---' + for line in out.split("\n"): + print '#', line + print '# --- diag end ---' + +# vim: ai sts=4 et sw=4 diff --git a/test/basetest/taskd.py b/test/basetest/taskd.py index f833a2ddd..9b47b21fa 100644 --- a/test/basetest/taskd.py +++ b/test/basetest/taskd.py @@ -4,9 +4,11 @@ import os import tempfile import shutil import signal +import atexit from time import sleep from subprocess import Popen from .utils import find_unused_port, release_port, port_used, run_cmd_wait +from .exceptions import CommandError try: from subprocess import DEVNULL @@ -40,17 +42,21 @@ class Taskd(object): :arg address: Address to bind to """ self.taskd = taskd + self.usercount = 0 + # Will hold the taskd subprocess if it's running self.proc = None self.datadir = tempfile.mkdtemp() self.tasklog = os.path.join(self.datadir, "taskd.log") self.taskpid = os.path.join(self.datadir, "taskd.pid") - # Make sure no TASKDDATA is defined - try: - del os.environ["TASKDDATA"] - except KeyError: - pass + # Ensure any instance is properly destroyed at session end + atexit.register(lambda: self.destroy()) + + # Copy all env variables to avoid clashing subprocess environments + self.env = os.environ.copy() + # Make sure TASKDDATA points to the temporary folder + self.env["TASKDATA"] = self.datadir if certpath is None: certpath = DEFAULT_CERT_PATH @@ -69,7 +75,7 @@ class Taskd(object): # Initialize taskd cmd = (self.taskd, "init", "--data", self.datadir) - run_cmd_wait(cmd) + run_cmd_wait(cmd, env=self.env) self.config("server", "{0}:{1}".format(self.address, self.port)) self.config("log", self.tasklog) @@ -85,12 +91,71 @@ class Taskd(object): self.config("server.crl", self.server_crl) self.config("ca.cert", self.ca_cert) + self.default_user = self.create_user() + + def __repr__(self): + txt = super(Taskd, self).__repr__() + return "{0} running from {1}>".format(txt[:-1], self.datadir) + + def create_user(self, user=None, group=None, org=None): + """Create a user/group in the server and return the user + credentials to use in a taskw client. + """ + if user is None: + # Create a unique user ID + uid = self.usercount + user = "test_user_{0}".format(uid) + + # Increment the user_id + self.usercount += 1 + + if group is None: + group = "default_group" + + if org is None: + org = "default_org" + + self._add_entity("org", org, ignore_exists=True) + self._add_entity("group", org, group, ignore_exists=True) + userkey = self._add_entity("user", org, user) + + return user, group, org, userkey + + def _add_entity(self, keyword, org, value=None, ignore_exists=False): + """Add an organization, group or user to the current server + + If a user creation is requested, the user unique ID is returned + """ + cmd = (self.taskd, "add", "--data", self.datadir, keyword, org) + + if value is not None: + cmd += (value,) + + try: + code, out, err = run_cmd_wait(cmd, env=self.env) + except CommandError as e: + match = False + for line in e.out.splitlines(): + if line.endswith("already exists.") and ignore_exists: + match = True + break + + # If the error was not "Already exists" report it + if not match: + raise + + if keyword == "user": + expected = "New user key: " + for line in out.splitlines(): + if line.startswith(expected): + return line.replace(expected, '') + def config(self, var, value): """Run setup `var` as `value` in taskd config """ cmd = (self.taskd, "config", "--force", "--data", self.datadir, var, value) - run_cmd_wait(cmd) + run_cmd_wait(cmd, env=self.env) # If server is running send a SIGHUP to force config reload if self.proc is not None: @@ -122,7 +187,7 @@ class Taskd(object): """ if self.proc is None: cmd = (self.taskd, "server", "--data", self.datadir) - self.proc = Popen(cmd, stdout=DEVNULL, stdin=DEVNULL) + self.proc = Popen(cmd, stdout=DEVNULL, stdin=DEVNULL, env=self.env) else: raise OSError("Taskd server is still running or crashed") @@ -186,7 +251,10 @@ class Taskd(object): self.start = self.__destroyed self.config = self.__destroyed self.stop = self.__destroyed - self.destroy = self.__destroyed + + # self.destroy will get called when the python session closes. + # If self.destroy was already called, turn the action into a noop + self.destroy = lambda: None def __destroyed(self, *args, **kwargs): raise AttributeError("Taskd instance has been destroyed. " diff --git a/test/basetest/utils.py b/test/basetest/utils.py index 3628e268e..a24e51a04 100644 --- a/test/basetest/utils.py +++ b/test/basetest/utils.py @@ -1,19 +1,49 @@ # -*- coding: utf-8 -*- +import os import socket -from subprocess import Popen, PIPE +from subprocess import Popen, PIPE, STDOUT +from .exceptions import CommandError USED_PORTS = set() -def run_cmd_wait(cmd): +def run_cmd_wait(cmd, input=None, stdout=PIPE, stderr=PIPE, + merge_streams=False, env=os.environ): "Run a subprocess and wait for it to finish" - p = Popen(cmd, stdout=PIPE, stderr=PIPE) - out, err = p.communicate() + + if input is None: + stdin = None + else: + stdin = PIPE + + if merge_streams: + stderr = STDOUT + else: + stderr = PIPE + + p = Popen(cmd, stdin=stdin, stdout=stdout, stderr=stderr, env=env) + out, err = p.communicate(input) + + # In python3 we will be able use the following instead of the previous + # line to avoid locking if task is unexpectedly waiting for input + # try: + # out, err = p.communicate(input, timeout=15) + # except TimeoutExpired: + # p.kill() + # out, err = proc.communicate() if p.returncode != 0: - raise IOError("Failed to run '{0}', exit code was '{1}', stdout" - " '{2}' and stderr '{3}'".format(cmd, p.returncode, - out, err)) + raise CommandError(cmd, p.returncode, out, err) + + return p.returncode, out, err + + +def run_cmd_wait_nofail(*args, **kwargs): + "Same as run_cmd_wait but silence the exception if it happens" + try: + return run_cmd_wait(*args, **kwargs) + except CommandError as e: + return e.code, e.out, e.err def port_used(addr="localhost", port=None): @@ -29,7 +59,7 @@ def port_used(addr="localhost", port=None): def find_unused_port(addr="localhost", start=53589, track=True): - """Find an unused port starting at `port` + """Find an unused port starting at `start` port If track=False the returned port will not be marked as in-use and the code will rely entirely on the ability to connect to addr:port as detection