Index: /trunk/tools/backups/atlassian_backups.config
===================================================================
--- /trunk/tools/backups/atlassian_backups.config	(revision 40907)
+++ /trunk/tools/backups/atlassian_backups.config	(revision 40907)
@@ -0,0 +1,10 @@
+[atlassian_backups]
+host_backup_path = /export/ippops4.0/atlassian_backups
+backup_1_path = /data/ippops3.0/atlassian_backups
+backup_2_path = /data/ippops5.0/atlassian_backups
+jira_backup_path = /var/atlassian/application-data/jira/export
+conf_backup_path = /var/atlassian/application-data/confluence/backups
+jira_attachments_path = /var/atlassian/application-data/jira/data
+conf_attachments_path = /var/atlassian/application-data/confluence/attachments
+mysqldump_password = not_a_real_password
+verbose = False
Index: /trunk/tools/backups/backup_atlassian_applications.py
===================================================================
--- /trunk/tools/backups/backup_atlassian_applications.py	(revision 40907)
+++ /trunk/tools/backups/backup_atlassian_applications.py	(revision 40907)
@@ -0,0 +1,315 @@
+import argparse
+import configparser
+import datetime
+import glob
+import os
+import os.path as path
+import shutil
+import subprocess
+import sys
+
+import backups.utils.subprocess_utils as sub_utils
+from backups.utils.errors import ConfigError, SubprocessError
+
+DEFAULT_CONFIG_FILE = path.join(path.dirname(__file__), 'atlassian_backups.config')
+
+
+def _move_dir_contents_to_dir(source_dirs: [str], destination_dirs: [str], verbose: bool=False):
+    """ Moves all files from inside source paths to the destination dits.
+It's important to note that it moves index to index
+i.e. source[0] -> dest[0]; source[1] -> dest[1]
+
+This is actually a pretty generic function, should be moved into a utils class.
+"""
+
+    if source_dirs is None or destination_dirs is None:
+        print("Error: You need to supply a source and destination")
+        return 2
+    if type(source_dirs) != list or type(destination_dirs) != list:
+        print("Error: source_dirs and destination_dirs must be type: list")
+        print("     type {0} and {1} given".format(type(source_dirs), type(destination_dirs)))
+        return 2
+    if len(source_dirs) != len(destination_dirs):
+        print("Error: source and target paths are not equal; aborting file movement")
+        return 2
+
+    for i in range(0, len(source_dirs)):
+        # Identify files in path, move each one:
+        wildcard_search = "{0}/*".format(source_dirs[i])
+        filepaths = glob.glob(wildcard_search)
+        if len(filepaths) == 0:
+            print("Warning: directory is empty: ", source_dirs[i])
+            continue
+        try:
+            for fp in filepaths:
+                if verbose:
+                    print("Moving: {0} -> {1}".format(fp, destination_dirs[i]))
+                command_and_args = ["mv", fp, destination_dirs[i]]
+                # shutil.move(fp, destination_dirs[i])
+                sub_utils.simple_unix_wrapper(command_and_args)
+        except IOError as e:
+            print("IOError copying: ", e.filename, e.filename2)
+            return 2
+
+    return 0
+
+
+class AtlassianBackups():
+
+    def __init__(self, config_file=None):
+        self.config_file = DEFAULT_CONFIG_FILE if config_file is None else config_file
+        self.load_config(self.config_file)
+
+    def load_config(self, config_class_or_filepath):
+        """Accepts a filepath to a config file, or """
+
+        config = configparser.ConfigParser()
+        if config_class_or_filepath is None:
+            raise ConfigError(config_class_or_filepath, "Neither config settings or filepath of config given")
+        elif isinstance(config_class_or_filepath, configparser.ConfigParser):
+            config = config_class_or_filepath
+        elif path.exists(config_class_or_filepath):
+            config.read(config_class_or_filepath)
+
+        def _get_str_key_from_section(config, section, key):
+            if config.has_option(section, key):
+                return config[section][key]
+            raise ConfigError(config, "ConfigError: KeyError: no key found for {0}".format(key))
+
+        section = 'atlassian_backups'
+        self.host_backup_path      = _get_str_key_from_section(config, section, 'host_backup_path')
+        self.backup_1_path         = _get_str_key_from_section(config, section, 'backup_1_path')
+        self.backup_2_path         = _get_str_key_from_section(config, section, 'backup_2_path')
+        self.jira_backup_path      = _get_str_key_from_section(config, section, 'jira_backup_path')
+        self.conf_backup_path      = _get_str_key_from_section(config, section, 'conf_backup_path')
+        self.jira_attachments_path = _get_str_key_from_section(config, section, 'jira_attachments_path')
+        self.conf_attachments_path = _get_str_key_from_section(config, section, 'conf_attachments_path')
+        self.verbose               = config.getboolean('atlassian_backups', 'verbose')
+
+        self.mysqldump_password    = _get_str_key_from_section(config, section, 'mysqldump_password')
+
+    def target_backup_paths_ok(self) -> bool:
+        copy_paths = [ self.host_backup_path
+                     , self.backup_1_path
+                     , self.backup_2_path
+                     ]
+        for cp in copy_paths:
+            if not path.exists(cp):
+                return False
+            self._make_destination_subdirs_if_needed(cp)
+
+        return True
+
+    def _make_destination_subdirs_if_needed(self, copy_path):
+        subdirs = [ path.join(copy_path, "latest")
+                  , path.join(copy_path, "old")
+                  , path.join(copy_path, "tmp")
+                  ]
+        for sd in subdirs:
+            if not path.exists(sd):
+                if self.verbose:
+                    print("creating dir: ", sd)
+                os.makedirs(sd)
+
+    def target_paths(self, subdir):
+        return [ path.join(self.host_backup_path, subdir)
+               , path.join(self.backup_1_path, subdir)
+               , path.join(self.backup_2_path, subdir)
+               ]
+
+    def perform_atlassian_backups(self):
+
+        # Check all paths are ok first:
+        if not self.target_backup_paths_ok():
+            return 1
+
+        latest_paths = self.target_paths('latest')
+        tmp_paths = self.target_paths('tmp')
+        old_paths = self.target_paths('old')
+
+        # Move latest to tmp (and delete if move ok)
+        _move_dir_contents_to_dir(latest_paths, tmp_paths, self.verbose)
+
+        # copy jira
+        jira_return = self._copy_jira_backup(self.verbose)
+
+        # copy confluence
+        conf_return = self._copy_confluence_backup(self.verbose)
+
+        # tarfiles:
+        tar_return = self._tar_attachments()
+
+        # mysqldump
+        mysqldump_return = self.make_mysql_dump(self.mysqldump_password)
+
+        # If successful: move tmp to old
+        if jira_return == 0 and conf_return == 0 and tar_return == 0 and mysqldump_return == 0:
+            _move_dir_contents_to_dir(tmp_paths, old_paths, self.verbose)
+        else:
+            # If any failed: delete latest, move tmp back
+            _move_dir_contents_to_dir(tmp_paths, latest_paths, self.verbose)
+            return 5
+
+        # TODO:clean-up old files (to save space)
+
+        return 0
+
+    def get_jira_backup_date_format(self, custom_date=None):
+        # Format is currently: 2019-Aug-13--0325.zip
+        jira_format = "%Y-%b-%d"
+        if custom_date is not None:
+            return custom_date.strftime(jira_format)
+        return datetime.date.today().strftime(jira_format)
+
+    def _copy_jira_backup(self, verbose=False):
+        if not self.target_backup_paths_ok():
+            return 2
+
+        jira_wildcard_search = "{0}/{1}*".format(self.jira_backup_path, self.get_jira_backup_date_format())
+        jira_xml_filepaths = glob.glob(jira_wildcard_search)
+        if len(jira_xml_filepaths) == 0:
+            print("Copy Failure: No jira backups found at: ", jira_wildcard_search)
+            return 2
+        target_paths = self.target_paths('latest')
+        for jfile in jira_xml_filepaths:
+            for tpath in target_paths:
+                if not path.exists(tpath):
+                    print("Copy Failure: target path does not exist:", tpath)
+                    return 2
+                try:
+                    if verbose:
+                        print("Copying: {0} to {1}".format(jfile, tpath))
+                    shutil.copy2(jfile, tpath)
+                    sub_utils.chmod_wrapper(['774', os.path.join(tpath, os.path.basename(jfile))])
+                except IOError as e:
+                    print("Error: copying jira backup.\n  copying: {0}\n  to: {1}".format(e.filename, e.filename2))
+                    print(e)
+                    return 2
+        return 0
+
+    def get_confluence_backup_date_format(self, custom_date=None):
+        # Format is: confluence-xml-backup-2019_08_14.zip
+        conf_format = "%Y_%m_%d"
+        if custom_date is not None:
+            return custom_date.strftime(conf_format)
+        return datetime.date.today().strftime(conf_format)
+
+    def _copy_confluence_backup(self, verbose=False):
+        if not self.target_backup_paths_ok():
+            print("Copy Failure: target path failure")
+            return 2
+
+        conf_wildcard_search = "{0}/*{1}*.zip".format(self.conf_backup_path, self.get_confluence_backup_date_format())
+        conf_xml_filepaths = glob.glob(conf_wildcard_search)
+        if len(conf_xml_filepaths) == 0:
+            print("Copy Failure: No confluence backups found at: ", conf_wildcard_search)
+            return 2
+        target_paths = self.target_paths('latest')
+        for cfile in conf_xml_filepaths:
+            for tpath in target_paths:
+                try:
+                    sub_utils.cp_wrapper([cfile, tpath])
+                    sub_utils.chmod_wrapper(['774', os.path.join(tpath, os.path.basename(cfile))])
+                except Exception as e:
+                    print(e)
+                    return 2
+        return 0
+
+    def _tar_attachments(self):
+        # tar jira
+        latest_paths = self.target_paths("latest")
+        if not self.target_backup_paths_ok():
+            return 1
+
+        jira_tar_name = datetime.date.today().strftime("%Y_%m_%d") + "_jira_attachments.tar"
+        jira_tar_filepath = path.join(latest_paths[0], jira_tar_name)
+        jira_tar_args = ["-C", "/", "-cf", jira_tar_filepath, self.jira_attachments_path.strip('/')]
+        sub_utils.tar_wrapper(jira_tar_args)
+        sub_utils.chmod_wrapper(['774', jira_tar_filepath])
+        # copy to backups
+        sub_utils.cp_wrapper([ jira_tar_filepath, latest_paths[1]])
+        sub_utils.cp_wrapper([ jira_tar_filepath, latest_paths[2]])
+
+        # tar confluence
+        confluence_tar_name = datetime.date.today().strftime("%Y_%m_%d") + "_confluence_attachments.tar"
+        confluence_tar_filepath = path.join(latest_paths[0], confluence_tar_name)
+        confluence_tar_args = ["-C", "/", "-cf", confluence_tar_filepath, self.conf_attachments_path.strip('/')]
+        sub_utils.tar_wrapper(confluence_tar_args)
+        sub_utils.chmod_wrapper(['774', confluence_tar_filepath])
+        # copy to backups
+        sub_utils.cp_wrapper([ confluence_tar_filepath, latest_paths[1]])
+        sub_utils.cp_wrapper([ confluence_tar_filepath, latest_paths[2]])
+        return 0
+
+    def make_mysql_dump(self, dump_password, dump_name=None):
+        # do this as the dumper user
+
+        latest_paths = self.target_paths("latest")
+        if not self.target_backup_paths_ok():
+            return 1
+
+        # make sure target file does not exist (or it will fail)
+        if dump_name is None:
+            date = datetime.date.today().strftime("%Y_%m_%d")
+            dump_filepath = path.join(latest_paths[0], '{0}_mysql_dump.sql'.format(date))
+        else:
+            dump_filepath = path.join(latest_paths[0], dump_name)
+        if path.exists(dump_filepath):
+            print("Warning: SQL dump already exists: ", dump_filepath)
+            return 2
+
+        # Setup the commands:
+        mysqldump_args = [ 'mysqldump', '-u', 'dumper', '-p{0}'.format(dump_password)
+                         , '--databases', 'jiradb', 'confluencedb'
+                         ]
+        with open(dump_filepath, "w+") as df:
+            mysqldump_process = subprocess.Popen(mysqldump_args, stdout=df, stderr=subprocess.PIPE)
+            dump_result = mysqldump_process.communicate()
+
+        # Get the piped std err. See it contains the word error
+        stdo, stde = dump_result
+        if stde is not None:
+            decoded_stderr = stde.decode()
+            if decoded_stderr.casefold().find('error'.casefold()) > -1:
+                raise SubprocessError()
+
+        # move the dump file to the correct locations
+        if not path.exists(dump_filepath):
+            return 2
+        cp_1 = sub_utils.cp_wrapper([dump_filepath, latest_paths[1]])
+        cp_2 = sub_utils.cp_wrapper([dump_filepath, latest_paths[2]])
+        if cp_1 != 0:
+            return cp_1
+        if cp_2 != 0:
+            return cp_2
+        return 0
+
+    def tidy_backup_folders(self):
+        return 0
+
+    def run(self):
+        return self.perform_atlassian_backups()
+
+
+def main():
+    args = parse_args(sys.argv[1:])
+    args_dict = vars(args)
+    ab = AtlassianBackups(**args_dict)
+    return ab.run()
+
+
+def parse_args(args):
+
+    parser = argparse.ArgumentParser(
+        description='Performs backup of atlassian applications\nYou should run this on the machine hosting the atlassian applications')
+
+    parser.add_argument('--config_file',
+        nargs='?',
+        help='contains config for all paths and mysqldump details',
+        default=DEFAULT_CONFIG_FILE)
+
+    return parser.parse_args()
+
+
+if __name__ == "__main__":
+    main()
Index: /trunk/tools/backups/backup_atlassian_applications_test.py
===================================================================
--- /trunk/tools/backups/backup_atlassian_applications_test.py	(revision 40907)
+++ /trunk/tools/backups/backup_atlassian_applications_test.py	(revision 40907)
@@ -0,0 +1,438 @@
+import backups.backup_atlassian_applications as baa
+import configparser
+import datetime
+import os
+import pytest
+
+from pathlib import Path
+
+import backups.full_atlassian_backup_test as fab_test
+import backups.utils.subprocess_utils as sub_utils
+
+from backups.backup_atlassian_applications import AtlassianBackups
+
+TEST_DEFAULT_CONFIG_FILE = os.path.join(os.path.dirname(__file__), './testing/test.config')
+
+
+def make_config(tmpdir) -> configparser.ConfigParser:
+    config = configparser.ConfigParser()
+    config['atlassian_backups'] = \
+    {
+        'host_backup_path'      : os.path.join(tmpdir, 'host_backup_path'),
+        'backup_1_path'         : os.path.join(tmpdir, 'backup_1_path'),
+        'backup_2_path'         : os.path.join(tmpdir, 'backup_2_path'),
+        'jira_backup_path'      : os.path.join(tmpdir, 'jira_backup_path'),
+        'conf_backup_path'      : os.path.join(tmpdir, 'conf_backup_path'),
+        'jira_attachments_path' : os.path.join(tmpdir, 'jira_attachments_path'),
+        'conf_attachments_path' : os.path.join(tmpdir, 'conf_attachments_path'),
+        'mysqldump_password'    : 'not_a_real_password'
+    }
+
+
+class TestArguments(object):
+
+    def test_load_from_config_file(self):
+
+        ab = AtlassianBackups(config_file=TEST_DEFAULT_CONFIG_FILE)
+
+        assert ab.host_backup_path      == "/export/ippops4.0/atlassian_backups"
+        assert ab.backup_1_path         == "/data/ippops3.0/atlassian_backups"
+        assert ab.backup_2_path         == "/data/ippops5.0/atlassian_backups"
+        assert ab.jira_backup_path      == "/var/atlassian/application-data/jira/export"
+        assert ab.conf_backup_path      == "/var/atlassian/application-data/confluence/backups"
+        assert ab.jira_attachments_path == "/var/atlassian/application-data/jira/data"
+        assert ab.conf_attachments_path == "/var/atlassian/application-data/confluence/attachments"
+        assert ab.mysqldump_password    == "not_a_real_password"
+        assert ab.verbose is True
+
+    def test_default_config_loads_with_no_args(self):
+        default_config = baa.DEFAULT_CONFIG_FILE
+        assert os.path.exists(default_config)
+        ab = AtlassianBackups()
+        assert ab.host_backup_path      == "/export/ippops4.0/atlassian_backups"
+        assert ab.backup_1_path         == "/data/ippops3.0/atlassian_backups"
+        assert ab.backup_2_path         == "/data/ippops5.0/atlassian_backups"
+        assert ab.jira_backup_path      == "/var/atlassian/application-data/jira/export"
+        assert ab.conf_backup_path      == "/var/atlassian/application-data/confluence/backups"
+        assert ab.jira_attachments_path == "/var/atlassian/application-data/jira/data"
+        assert ab.conf_attachments_path == "/var/atlassian/application-data/confluence/attachments"
+        assert ab.mysqldump_password    == "not_a_real_password"
+
+    def test_default_arguments(self):
+
+        ab = AtlassianBackups(config_file=TEST_DEFAULT_CONFIG_FILE)
+        assert ab.host_backup_path      == "/export/ippops4.0/atlassian_backups"
+        assert ab.backup_1_path         == "/data/ippops3.0/atlassian_backups"
+        assert ab.backup_2_path         == "/data/ippops5.0/atlassian_backups"
+        assert ab.jira_backup_path      == "/var/atlassian/application-data/jira/export"
+        assert ab.conf_backup_path      == "/var/atlassian/application-data/confluence/backups"
+        assert ab.jira_attachments_path == "/var/atlassian/application-data/jira/data"
+        assert ab.conf_attachments_path == "/var/atlassian/application-data/confluence/attachments"
+        assert ab.mysqldump_password    == "not_a_real_password"
+
+    def test_providing_config_directly(self):
+        config = configparser.ConfigParser()
+        config.add_section("atlassian_backups")
+        config.set('atlassian_backups', 'host_backup_path',      'custom_config1')
+        config.set('atlassian_backups', 'backup_1_path',         'custom_config2')
+        config.set('atlassian_backups', 'backup_2_path',         'custom_config3')
+        config.set('atlassian_backups', 'jira_backup_path',      'custom_config4')
+        config.set('atlassian_backups', 'conf_backup_path',      'custom_config5')
+        config.set('atlassian_backups', 'jira_attachments_path', 'custom_config6')
+        config.set('atlassian_backups', 'conf_attachments_path', 'custom_config7')
+        config.set('atlassian_backups', 'mysqldump_password',    'custom_config8')
+        config.set('atlassian_backups', 'verbose', 'False')
+
+        ab = AtlassianBackups(config_file=config)
+
+        assert ab.host_backup_path      == 'custom_config1'
+        assert ab.backup_1_path         == 'custom_config2'
+        assert ab.backup_2_path         == 'custom_config3'
+        assert ab.jira_backup_path      == 'custom_config4'
+        assert ab.conf_backup_path      == 'custom_config5'
+        assert ab.jira_attachments_path == 'custom_config6'
+        assert ab.conf_attachments_path == 'custom_config7'
+        assert ab.mysqldump_password    == 'custom_config8'
+        assert ab.verbose is False
+
+    def test_nonexistant_config_file_raises_error(self):
+        with pytest.raises(baa.ConfigError):
+            AtlassianBackups("not a real file path")
+
+    def test_default_config_loads_from_main_call(self):
+        try:
+            baa.main()
+        except Exception:
+            pytest.fail("An error was raised when it should not")
+
+
+class TestFilepathVerifications(object):
+
+    def test_default_target_paths_do_not_exists_in_testing(self):
+        ab = AtlassianBackups(config_file=TEST_DEFAULT_CONFIG_FILE)
+        assert ab.target_backup_paths_ok() is False
+
+    def test_an_incorrect_path_then_correct_path(self, tmpdir):
+        ab = AtlassianBackups(config_file=TEST_DEFAULT_CONFIG_FILE)
+
+        assert os.path.exists(ab.jira_backup_path) is False
+        ab.jira_backup_path = tmpdir
+        assert os.path.exists(ab.jira_backup_path) is True
+
+    def test_each_path_individually(self, tmpdir):
+        ab = AtlassianBackups(config_file=TEST_DEFAULT_CONFIG_FILE)
+
+        assert os.path.exists(ab.host_backup_path) is False
+        ab.host_backup_path = tmpdir
+        assert os.path.exists(ab.host_backup_path) is True
+        assert ab.target_backup_paths_ok() is False
+
+        assert os.path.exists(ab.backup_1_path) is False
+        ab.backup_1_path = tmpdir
+        assert os.path.exists(ab.backup_1_path) is True
+        assert ab.target_backup_paths_ok() is False
+
+        assert os.path.exists(ab.backup_2_path) is False
+        ab.backup_2_path = tmpdir
+        assert os.path.exists(ab.backup_2_path) is True
+        # Once all backup paths are set, they are ok
+        assert ab.target_backup_paths_ok() is True
+
+        assert os.path.exists(ab.jira_backup_path) is False
+        ab.jira_backup_path = tmpdir
+        assert os.path.exists(ab.jira_backup_path) is True
+
+        assert os.path.exists(ab.conf_backup_path) is False
+        ab.conf_backup_path = tmpdir
+        assert os.path.exists(ab.conf_backup_path) is True
+
+        assert os.path.exists(ab.jira_attachments_path) is False
+        ab.jira_attachments_path = tmpdir
+        assert os.path.exists(ab.jira_attachments_path) is True
+
+        assert os.path.exists(ab.conf_attachments_path) is False
+        ab.conf_attachments_path = tmpdir
+        assert os.path.exists(ab.conf_attachments_path) is True
+
+    def test_backup_subdirs_are_created_if_missing(self, tmpdir):
+        ab = AtlassianBackups(config_file=TEST_DEFAULT_CONFIG_FILE)
+        ab.jira_path = tmpdir
+        ab.conf_path = tmpdir
+
+        backup_paths = [ os.path.join(tmpdir, 'host')
+                       , os.path.join(tmpdir, 'backup_1')
+                       , os.path.join(tmpdir, 'backup_2')
+                       ]
+
+        for bp in backup_paths:
+
+            # make the parent dir
+            os.makedirs(bp)
+            ab.bp = bp
+            assert os.path.exists(bp)
+
+            # Expected subdirs
+            expected_latest = os.path.join(bp, 'latest')
+            expected_tmp = os.path.join(bp, 'tmp')
+            expected_old = os.path.join(bp, 'old')
+            assert not os.path.exists(expected_latest)
+            assert not os.path.exists(expected_tmp)
+            assert not os.path.exists(expected_old)
+
+            # all_paths_ok will call subdir creation
+            ab._make_destination_subdirs_if_needed(bp)
+            assert os.path.exists(expected_latest)
+            assert os.path.exists(expected_tmp)
+            assert os.path.exists(expected_old)
+
+
+class TestMovingBackups(object):
+
+    def test_target_paths_with_defaults_function(self):
+        ab = AtlassianBackups(config_file=TEST_DEFAULT_CONFIG_FILE)
+
+        default = ab.target_paths('default_paths')
+
+        assert default[0] == "/export/ippops4.0/atlassian_backups/default_paths"
+        assert default[1] == "/data/ippops3.0/atlassian_backups/default_paths"
+        assert default[2] == "/data/ippops5.0/atlassian_backups/default_paths"
+
+    def test_target_paths_function_after_changing_paths(self):
+        ab = AtlassianBackups(config_file=TEST_DEFAULT_CONFIG_FILE)
+
+        ab.host_backup_path = "/different_path/for_host"
+        ab.backup_1_path = "/bk/one"
+        ab.backup_2_path = "/bk/two"
+
+        another_subdir = ab.target_paths('a_different_subdir')
+
+        assert another_subdir[0] == "/different_path/for_host/a_different_subdir"
+        assert another_subdir[1] == "/bk/one/a_different_subdir"
+        assert another_subdir[2] == "/bk/two/a_different_subdir"
+
+    def test_move_aborts_if_source_and_destination_path_lists_are_different_lengths(self):
+        source_paths = [ "/Just/one/path" ]
+        destination_paths = [ "/Two", "/paths"]
+
+        return_is_non_zero = baa._move_dir_contents_to_dir(source_paths, destination_paths)
+        assert return_is_non_zero != 0
+
+    def test_moving_old_backups(self, tmpdir):
+        # Need to set up X source_dirs, and X target_subdirs
+        tmpdir_host = os.path.join(tmpdir, "host")
+        tmpdir_bak1 = os.path.join(tmpdir, "bak1")
+        tmpdir_bak2 = os.path.join(tmpdir, "bak2")
+        os.makedirs(tmpdir_host)
+        os.makedirs(tmpdir_bak1)
+        os.makedirs(tmpdir_bak2)
+
+        # Setup some subdirectories
+        source_paths = [ os.path.join(tmpdir_host, 'source')
+                       , os.path.join(tmpdir_bak1, 'source')
+                       , os.path.join(tmpdir_bak2, 'source')
+                       ]
+        destination_paths = [ os.path.join(tmpdir_host, 'destination')
+                            , os.path.join(tmpdir_bak1, 'destination')
+                            , os.path.join(tmpdir_bak2, 'destination')
+                            ]
+        for i in range(0, len(source_paths)):
+            os.makedirs(source_paths[i])
+            os.makedirs(destination_paths[i])
+
+        # Put some files in the source directories
+        source_host_file1 = os.path.join(source_paths[0], "host_file1.zip")
+        source_bak1_file1 = os.path.join(source_paths[1], "bak1_file1.txt")
+        source_bak1_file2 = os.path.join(source_paths[1], "bak1_file2.txt")
+        source_bak1_file3 = os.path.join(source_paths[1], "bak1_file3.txt")
+        source_bak2_file1 = os.path.join(source_paths[2], "bak2_file1.lol")
+        source_bak2_file2 = os.path.join(source_paths[2], "bak2_file2.lol")
+        Path(source_host_file1).touch()
+        Path(source_bak1_file1).touch()
+        Path(source_bak1_file2).touch()
+        Path(source_bak1_file3).touch()
+        Path(source_bak2_file1).touch()
+        Path(source_bak2_file2).touch()
+        assert os.path.exists(source_host_file1)
+        assert os.path.exists(source_bak1_file1)
+        assert os.path.exists(source_bak1_file2)
+        assert os.path.exists(source_bak1_file3)
+        assert os.path.exists(source_bak2_file1)
+        assert os.path.exists(source_bak2_file2)
+
+        # Assert files do NOT exist in target dirs yet
+        destination_host_file1 = os.path.join(destination_paths[0], "host_file1.zip")
+        destination_bak1_file1 = os.path.join(destination_paths[1], "bak1_file1.txt")
+        destination_bak1_file2 = os.path.join(destination_paths[1], "bak1_file2.txt")
+        destination_bak1_file3 = os.path.join(destination_paths[1], "bak1_file3.txt")
+        destination_bak2_file1 = os.path.join(destination_paths[2], "bak2_file1.lol")
+        destination_bak2_file2 = os.path.join(destination_paths[2], "bak2_file2.lol")
+        assert not os.path.exists(destination_host_file1)
+        assert not os.path.exists(destination_bak1_file1)
+        assert not os.path.exists(destination_bak1_file2)
+        assert not os.path.exists(destination_bak1_file3)
+        assert not os.path.exists(destination_bak2_file1)
+        assert not os.path.exists(destination_bak2_file2)
+
+        # run the move
+        return_result = baa._move_dir_contents_to_dir(source_paths, destination_paths)
+        assert return_result == 0
+
+        # check that source files are no longer there, but the target ones do exist
+        assert not os.path.exists(source_host_file1)
+        assert not os.path.exists(source_bak1_file1)
+        assert not os.path.exists(source_bak1_file2)
+        assert not os.path.exists(source_bak1_file3)
+        assert not os.path.exists(source_bak2_file1)
+        assert not os.path.exists(source_bak2_file2)
+        assert os.path.exists(destination_host_file1)
+        assert os.path.exists(destination_bak1_file1)
+        assert os.path.exists(destination_bak1_file2)
+        assert os.path.exists(destination_bak1_file3)
+        assert os.path.exists(destination_bak2_file1)
+        assert os.path.exists(destination_bak2_file2)
+
+
+class TestCopyingBackups(object):
+
+    def test_get_jira_backup_date_format(self):
+        ab = AtlassianBackups(config_file=TEST_DEFAULT_CONFIG_FILE)
+        today = datetime.date.today()
+        expected_format = today.strftime("%Y-%b-%d")
+        assert ab.get_jira_backup_date_format() == expected_format
+
+    def test_copying_fails_if_paths_not_present(self):
+        ab = AtlassianBackups(config_file=TEST_DEFAULT_CONFIG_FILE)
+        return_value = ab._copy_jira_backup()
+        assert return_value != 0
+
+        return_value = ab._copy_confluence_backup()
+        assert return_value != 0
+
+    def test_jira_copy(self, tmpdir):
+        ab = AtlassianBackups(config_file=TEST_DEFAULT_CONFIG_FILE)
+        ab = fab_test.create_all_paths_and_folders(ab, tmpdir)
+
+        return_value = ab._copy_jira_backup()
+        assert return_value == 0
+
+    def test_get_confluence_backup_date_format(self):
+        ab = AtlassianBackups(config_file=TEST_DEFAULT_CONFIG_FILE)
+        today = datetime.date.today()
+        expected_format = today.strftime("%Y_%m_%d")
+        assert ab.get_confluence_backup_date_format() == expected_format
+
+    def test_confluence_copy(self, tmpdir):
+        ab = AtlassianBackups(config_file=TEST_DEFAULT_CONFIG_FILE)
+        ab = fab_test.create_all_paths_and_folders(ab, tmpdir)
+
+        return_value = ab._copy_confluence_backup()
+        assert return_value == 0
+
+    def test_full_copy(self, tmpdir):
+        ab = AtlassianBackups(config_file=TEST_DEFAULT_CONFIG_FILE)
+        ab = fab_test.create_all_paths_and_folders(ab, tmpdir)
+
+        result = ab.perform_atlassian_backups()
+
+        assert result == 0
+
+    def test_atlsssian_copying_fails_when_paths_do_not_exist(self, tmpdir):
+        ab = AtlassianBackups(config_file=TEST_DEFAULT_CONFIG_FILE)
+        fake_path = "/not/a/real/path"
+        ab.jira_backup_path = fake_path
+        ab.conf_backup_path = fake_path
+        ab.host_backup_path = fake_path
+        ab.backup_1_path = fake_path
+        ab.backup_2_path = fake_path
+        assert not os.path.exists(ab.jira_backup_path)
+        assert not os.path.exists(ab.conf_backup_path)
+        assert not os.path.exists(ab.host_backup_path)
+        assert not os.path.exists(ab.backup_1_path)
+        assert not os.path.exists(ab.backup_2_path)
+
+        result = ab.perform_atlassian_backups()
+        assert result == 1
+
+
+class TestTaringOfAttachments(object):
+
+    def test_attachments_tar(self, tmpdir):
+
+        ab = AtlassianBackups(config_file=TEST_DEFAULT_CONFIG_FILE)
+        ab = fab_test.create_all_paths_and_folders(ab, tmpdir)
+
+        today = datetime.date.today().strftime("%Y_%m_%d")
+        jira_tar_name = today + "_jira_attachments.tar"
+        conf_tar_name = today + "_confluence_attachments.tar"
+        expected_jira_host_tar = os.path.join(ab.host_backup_path, "latest", jira_tar_name)
+        expected_conf_host_tar = os.path.join(ab.host_backup_path, "latest", conf_tar_name)
+        expected_jira_bak1_tar = os.path.join(ab.backup_1_path, "latest", jira_tar_name)
+        expected_conf_bak1_tar = os.path.join(ab.backup_1_path, "latest", conf_tar_name)
+        expected_jira_bak2_tar = os.path.join(ab.backup_2_path, "latest", jira_tar_name)
+        expected_conf_bak2_tar = os.path.join(ab.backup_2_path, "latest", conf_tar_name)
+        assert not os.path.exists(expected_jira_host_tar)
+        assert not os.path.exists(expected_conf_host_tar)
+        assert not os.path.exists(expected_jira_bak1_tar)
+        assert not os.path.exists(expected_conf_bak1_tar)
+        assert not os.path.exists(expected_jira_bak2_tar)
+        assert not os.path.exists(expected_conf_bak2_tar)
+
+        tar_result = ab._tar_attachments()
+        assert tar_result == 0
+
+        assert os.path.exists(expected_jira_host_tar)
+        assert os.path.exists(expected_conf_host_tar)
+        assert os.path.exists(expected_jira_bak1_tar)
+        assert os.path.exists(expected_conf_bak1_tar)
+        assert os.path.exists(expected_jira_bak2_tar)
+        assert os.path.exists(expected_conf_bak2_tar)
+
+
+@pytest.mark.skip(reason="Requires a fake database to be setup")
+class TestMysqlBackup(object):
+    """ The mySQL dump tests require the machine on which they are
+running to have a mysql database running with a user called
+'dumper' and a password set to 'not_a_real_password'.
+
+In production, a proper password should be set on the server and in the config
+file.
+
+To change the password:
+UPDATE mysql.user SET authentication_string = PASSWORD('NEW_USER_PASSWORD')WHERE User = 'user-name' AND Host = 'localhost';FLUSH PRIVILEGES;
+"""
+
+    dumper_password = "not_a_real_password"
+
+    def test_mysql_dump_is_ok(self, tmpdir):
+        ab = AtlassianBackups(config_file=TEST_DEFAULT_CONFIG_FILE)
+        ab = fab_test.create_all_paths_and_folders(ab, tmpdir)
+
+        date = datetime.date.today().strftime("%Y_%m_%d")
+        expected_dump = os.path.join(ab.host_backup_path, "latest", '{0}_mysql_dump.sql'.format(date))
+        print(expected_dump)
+        assert not os.path.exists(expected_dump)
+
+        mysqldump_result = ab.make_mysql_dump(self.dumper_password)
+        assert mysqldump_result == 0
+
+        assert os.path.exists(expected_dump)
+
+    def test_mysql_dump_is_ok_to_custom_file(self, tmpdir):
+        ab = AtlassianBackups(config_file=TEST_DEFAULT_CONFIG_FILE)
+        ab = fab_test.create_all_paths_and_folders(ab, tmpdir)
+
+        expected_dump = os.path.join(ab.host_backup_path, "latest", "humptydumpy.sql")
+        print(expected_dump)
+        assert not os.path.exists(expected_dump)
+
+        mysqldump_result = ab.make_mysql_dump(self.dumper_password, "humptydumpy.sql")
+        assert mysqldump_result == 0
+
+        assert os.path.exists(expected_dump)
+
+    def test_mysql_dump_fails_with_incorrect_password(self, tmpdir):
+        ab = AtlassianBackups(config_file=TEST_DEFAULT_CONFIG_FILE)
+        ab = fab_test.create_all_paths_and_folders(ab, tmpdir)
+
+        with pytest.raises(sub_utils.SubprocessError):
+            ab.make_mysql_dump("Not the correct password")
Index: /trunk/tools/backups/fixture_test.py
===================================================================
--- /trunk/tools/backups/fixture_test.py	(revision 40907)
+++ /trunk/tools/backups/fixture_test.py	(revision 40907)
@@ -0,0 +1,37 @@
+import pytest
+
+
+@pytest.fixture
+def mr_fixture():
+
+    ab = {}
+
+    def _add_to_dict():
+        return ab.update({"hiya": 12})
+
+    def _add_more_to_dict():
+        return ab.update({"bonjour": 13})
+
+    def _full_creation():
+        _add_to_dict()
+        _add_more_to_dict()
+        print(ab)
+        return ab
+
+    yield _full_creation()
+
+    def _full_removal():
+        ab = {}
+        print(ab)
+
+    _full_removal()
+
+
+class TestFixtures(object):
+
+    def test_loading_fixture(self, mr_fixture):
+        ab = mr_fixture
+        print(mr_fixture)
+        type(mr_fixture)
+        assert ab["hiya"] == 12
+        assert ab["bonjour"] == 13
Index: /trunk/tools/backups/full_atlassian_backup_test.py
===================================================================
--- /trunk/tools/backups/full_atlassian_backup_test.py	(revision 40907)
+++ /trunk/tools/backups/full_atlassian_backup_test.py	(revision 40907)
@@ -0,0 +1,146 @@
+import datetime
+import os
+import backup_atlassian_applications as baa
+
+from pathlib import Path
+
+from backup_atlassian_applications import AtlassianBackups
+
+def create_all_paths_and_folders(ab_class: AtlassianBackups, tmpdir) -> AtlassianBackups:
+    """This creates all paths and files needed for thorough testing of the
+atlassian backup class. The class returned will have all the paths set too.
+Assertions are made that the files exist and they are set on the class object.
+
+    The directory strucutre is as follows:
+
+    tmpdir/host_backup/latest/2019-09-01_jira_prev_latest_file.zip
+                             /2019_09_01_conf_prev_latest_file.zip
+          /backup_1/old/2019-08-13_jira_old_file.zip
+                       /2019_08_13_conf_old_file.zip
+          /backup_2/latest/2019-09-01_jira_prev_latest_file.zip
+                          /2019_09_01_conf_prev_latest_file.zip
+                   /old/2019-08-13_jira_old_file.zip
+                       /2019_08_13_conf_old_file.zip
+                   /tmp
+
+    # Directories where the original files come from (date will be todays)
+    tmpdir/jira_home/zip_in_here/2019-11-23_jira_test_file.zip
+                    /attachments/attachment_1.zip
+                    /attachments/attachment_2.txt
+    tmpdir/conf_home/zip_in_here/2019_11_23_conf_test_file.zip
+                    /attachments/attachment_1.zip
+                    /attachments/attachment_2.txt
+
+"""
+
+    # Create all main paths required
+    host_backup_path = os.path.join(tmpdir, "host_backup")
+    backup_1_path = os.path.join(tmpdir, "backup_1")
+    backup_2_path = os.path.join(tmpdir, "backup_2")
+    jira_backup_path = os.path.join(tmpdir, "jira_home", "zip_in_here")
+    conf_backup_path = os.path.join(tmpdir, "conf_home", "zip_in_here")
+    jira_attachments_path = os.path.join(tmpdir, "jira_home", "attachments")
+    conf_attachments_path = os.path.join(tmpdir, "conf_home", "attachments")
+    os.makedirs(host_backup_path)
+    os.makedirs(backup_1_path)
+    os.makedirs(backup_2_path)
+    os.makedirs(jira_backup_path)
+    os.makedirs(conf_backup_path)
+    os.makedirs(jira_attachments_path)
+    os.makedirs(conf_attachments_path)
+    assert os.path.exists(host_backup_path)
+    assert os.path.exists(backup_1_path)
+    assert os.path.exists(backup_2_path)
+    assert os.path.exists(jira_backup_path)
+    assert os.path.exists(conf_backup_path)
+    assert os.path.exists(jira_attachments_path)
+    assert os.path.exists(conf_attachments_path)
+
+    # And the some of the subdirs (only one of the backups has all of them)
+    os.makedirs(os.path.join(host_backup_path, "latest"))
+    os.makedirs(os.path.join(backup_1_path, "old"))
+    os.makedirs(os.path.join(backup_2_path, "old"))
+    os.makedirs(os.path.join(backup_2_path, "tmp"))
+    os.makedirs(os.path.join(backup_2_path, "latest"))
+    assert os.path.exists(os.path.join(host_backup_path, "latest"))
+    assert os.path.exists(os.path.join(backup_1_path, "old"))
+    assert os.path.exists(os.path.join(backup_2_path, "old"))
+    assert os.path.exists(os.path.join(backup_2_path, "tmp"))
+    assert os.path.exists(os.path.join(backup_2_path, "latest"))
+
+    # set them on the class
+    ab_class.host_backup_path = host_backup_path
+    ab_class.backup_1_path = backup_1_path
+    ab_class.backup_2_path = backup_2_path
+    ab_class.jira_backup_path = jira_backup_path
+    ab_class.conf_backup_path = conf_backup_path
+    ab_class.jira_attachments_path = jira_attachments_path
+    ab_class.conf_attachments_path = conf_attachments_path
+
+    # Create backup files
+    jira_backup_name = ab_class.get_jira_backup_date_format() + "_jira_test_file.zip"
+    jira_backup_filepath = os.path.join(jira_backup_path, jira_backup_name)
+    Path(jira_backup_filepath).touch()
+    assert os.path.exists(jira_backup_filepath)
+
+    conf_backup_name = ab_class.get_confluence_backup_date_format() + "_conf_test_file.zip"
+    conf_backup_filepath = os.path.join(conf_backup_path, conf_backup_name)
+    Path(conf_backup_filepath).touch()
+    assert os.path.exists(conf_backup_filepath)
+
+    # Create some files that are attchments
+    jira_attachment_1_name = "attachment_1.zip"
+    jira_attachment_2_name = "attachment_2.txt"
+    jira_attachment_1_filepath = os.path.join(jira_attachments_path, jira_attachment_1_name)
+    jira_attachment_2_filepath = os.path.join(jira_attachments_path, jira_attachment_2_name)
+    Path(jira_attachment_1_filepath).touch()
+    Path(jira_attachment_2_filepath).touch()
+    assert os.path.exists(jira_attachment_1_filepath)
+    assert os.path.exists(jira_attachment_2_filepath)
+
+    conf_attachment_1_name = "attachment_1.zip"
+    conf_attachment_2_name = "attachment_2.txt"
+    conf_attachment_1_filepath = os.path.join(conf_attachments_path, conf_attachment_1_name)
+    conf_attachment_2_filepath = os.path.join(conf_attachments_path, conf_attachment_2_name)
+    Path(conf_attachment_1_filepath).touch()
+    Path(conf_attachment_2_filepath).touch()
+    assert os.path.exists(conf_attachment_1_filepath)
+    assert os.path.exists(conf_attachment_2_filepath)
+
+    # Have some old and latest versions in the backup dirs:
+    old_date = datetime.date(2019, 8, 13)
+    latest_date = datetime.date(2019, 9, 1)
+    jira_old_backup_name    = ab_class.get_jira_backup_date_format(old_date)          + "_jira_old_file.zip"
+    jira_latest_backup_name = ab_class.get_jira_backup_date_format(latest_date)       + "_jira_prev_latest_file.zip"
+    conf_old_backup_name    = ab_class.get_confluence_backup_date_format(old_date)    + "_conf_old_file.zip"
+    conf_latest_backup_name = ab_class.get_confluence_backup_date_format(latest_date) + "_conf_prev_latest_file.zip"
+
+    def assert_not_create_assert_exists(root_path, subdir, filename):
+        filepath = os.path.join(root_path, subdir, filename)
+        assert not os.path.exists(filepath)
+        Path(filepath).touch()
+        assert os.path.exists(filepath)
+
+    assert_not_create_assert_exists(host_backup_path, "latest", jira_latest_backup_name)
+    assert_not_create_assert_exists(host_backup_path, "latest", conf_latest_backup_name)
+    assert_not_create_assert_exists(backup_1_path,    "old",    jira_old_backup_name)
+    assert_not_create_assert_exists(backup_1_path,    "old",    conf_old_backup_name)
+    assert_not_create_assert_exists(backup_2_path,    "old",    jira_old_backup_name)
+    assert_not_create_assert_exists(backup_2_path,    "old",    conf_old_backup_name)
+    assert_not_create_assert_exists(backup_2_path,    "latest", jira_latest_backup_name)
+    assert_not_create_assert_exists(backup_2_path,    "latest", conf_latest_backup_name)
+
+    return ab_class
+
+
+class TestEntireProcess(object):
+
+    def test_entire_process(self, tmpdir):
+        test_config_file = os.path.join(os.path.dirname(__file__), 'testing', 'test.config')
+
+        ab = AtlassianBackups(config_file=test_config_file)
+        ab = create_all_paths_and_folders(ab, tmpdir)
+
+        result = ab.perform_atlassian_backups()
+
+        assert result == 0
Index: /trunk/tools/backups/testing/test.config
===================================================================
--- /trunk/tools/backups/testing/test.config	(revision 40907)
+++ /trunk/tools/backups/testing/test.config	(revision 40907)
@@ -0,0 +1,10 @@
+[atlassian_backups]
+host_backup_path = /export/ippops4.0/atlassian_backups
+backup_1_path = /data/ippops3.0/atlassian_backups
+backup_2_path = /data/ippops5.0/atlassian_backups
+jira_backup_path = /var/atlassian/application-data/jira/export
+conf_backup_path = /var/atlassian/application-data/confluence/backups
+jira_attachments_path = /var/atlassian/application-data/jira/data
+conf_attachments_path = /var/atlassian/application-data/confluence/attachments
+mysqldump_password = not_a_real_password
+verbose = True
Index: /trunk/tools/backups/utils/database/database_tool.py
===================================================================
--- /trunk/tools/backups/utils/database/database_tool.py	(revision 40907)
+++ /trunk/tools/backups/utils/database/database_tool.py	(revision 40907)
@@ -0,0 +1,37 @@
+import mysql.connector
+from mysql.connector import Error
+
+
+# To install this, you'll need:
+# pip install mysql-connector-python
+
+def connection():
+
+    try:
+        connection_config_dict = {
+            'user': 'pynative',
+            'password': 'pynative@123',
+            'host': '127.0.0.1',
+            'database': 'Electronics',
+            'raise_on_warnings': True,
+            'use_pure': False,
+            'autocommit': True,
+            'pool_size': 5
+        }
+        connection = mysql.connector.connect(**connection_config_dict)
+
+        if connection.is_connected():
+            db_Info = connection.get_server_info()
+            print("Connected to MySQL Server version ", db_Info)
+            cursor = connection.cursor()
+            cursor.execute("select database();")
+            record = cursor.fetchone()
+            print("Your connected to database: ", record)
+
+    except Error as e:
+        print("Error while connecting to MySQL", e)
+    finally:
+        if (connection.is_connected()):
+            cursor.close()
+            connection.close()
+            print("MySQL connection is closed")
Index: /trunk/tools/backups/utils/database/database_tool_test.py
===================================================================
--- /trunk/tools/backups/utils/database/database_tool_test.py	(revision 40907)
+++ /trunk/tools/backups/utils/database/database_tool_test.py	(revision 40907)
@@ -0,0 +1,9 @@
+import backups.utils.database.database_tool as dbt
+
+
+class TestMySQLConnections(object):
+
+    def test_connection(self):
+        # dbt.connection()
+
+        assert True is True
Index: /trunk/tools/backups/utils/errors.py
===================================================================
--- /trunk/tools/backups/utils/errors.py	(revision 40907)
+++ /trunk/tools/backups/utils/errors.py	(revision 40907)
@@ -0,0 +1,16 @@
+
+
+class ConfigError(Exception):
+    """Error in loading the config file"""
+
+    def __init__(self, config_item, message):
+        self.config_item = config_item
+        self.message = message
+
+
+class ValidationError(Exception):
+    """Validation errors in utils functions. Raised when arguments are not valid"""
+
+
+class SubprocessError(Exception):
+    """Errors in Popen stderr subprocess in subprocess_utils"""
Index: /trunk/tools/backups/utils/generic_utils.py
===================================================================
--- /trunk/tools/backups/utils/generic_utils.py	(revision 40907)
+++ /trunk/tools/backups/utils/generic_utils.py	(revision 40907)
@@ -0,0 +1,67 @@
+import os.path as path
+import shutil
+
+from distutils.dir_util import copy_tree
+from backups.utils.errors import ValidationError
+
+
+def copy_to_dir(source_file: str, destination_path: str, verbose: bool=False):
+    """ Copies a single file into the destination directory. If a directory is
+    given as a source then all the contents of the directory will be copied (the
+    directory itself will not be).
+"""
+
+    if source_file is None or destination_path is None:
+        raise ValidationError("Error: source for copy cannot be None type")
+    if destination_path is None:
+        raise ValidationError("Error: copy destination cannot be None type")
+    if not path.exists(source_file):
+        raise ValidationError(f"Error: Copy Failure: source does not exist: {source_file}")
+    if not path.exists(destination_path) or not path.isdir(destination_path):
+        print("Error: Copy Failure: destination path does not exist: ", destination_path)
+        raise ValidationError(f"Error: Copy Failure: destination path does not exist: {destination_path}")
+
+    try:
+        if verbose:
+            print(f"Copying: {source_file} to {destination_path}")
+        if path.isdir(source_file):
+            copy_tree(source_file, destination_path)
+        else:
+            shutil.copy2(source_file, destination_path)
+    except IOError as e:
+        msg = f"Error: copy failure.\n  copying: {source_file}\n  to: {destination_path}"
+        raise IOError(msg) from e
+    return 0
+
+
+def copy_multiple_items_to_multiple_dirs(source_paths: [str], destination_paths: [str], verbose: bool=False):
+    """ Copies the sources to the destination paths.
+
+This is a generic function that just wraps shutil.copy2"""
+
+    if source_paths is None:
+        raise ValidationError("Error: source for copy cannot be None type")
+    if destination_paths is None:
+        raise ValidationError("Error: copy destination cannot be None type")
+
+    if type(source_paths) is not list or len(source_paths) == 0:
+        raise ValidationError("Error: sources must be given as a list with at least one entry")
+    if type(destination_paths) is not list or len(destination_paths) == 0:
+        raise ValidationError("Error: destinations must be given as a list with at least one entry")
+
+    for dpath in destination_paths:
+        if not path.exists(dpath):
+            if verbose:
+                print("Copy Warning: target path does not exist (skipping): ", dpath)
+            continue
+        if not path.isdir(dpath):
+            if verbose:
+                print("Copy Warning: target path is not a directory (skipping): ", )
+            continue
+
+        for spath in source_paths:
+            result = copy_to_dir(spath, dpath, verbose)
+            if result != 0:
+                return result
+
+    return 0
Index: /trunk/tools/backups/utils/generic_utils_test.py
===================================================================
--- /trunk/tools/backups/utils/generic_utils_test.py	(revision 40907)
+++ /trunk/tools/backups/utils/generic_utils_test.py	(revision 40907)
@@ -0,0 +1,224 @@
+import backups.utils.generic_utils as utils
+import os
+import pytest
+
+from backups.utils.errors import ValidationError
+from pathlib import Path
+
+
+def make_a_real_file(file_path, file_name):
+    real_file = os.path.join(file_path, file_name)
+    Path(real_file).touch()
+    assert os.path.exists(real_file)
+    assert os.path.isfile(real_file)
+    return real_file
+
+
+class TestCopyingToDir(object):
+
+    def test_copy_fails_with_none_args(self, tmpdir):
+        with pytest.raises(ValidationError):
+            utils.copy_to_dir(None, tmpdir)
+
+        with pytest.raises(ValidationError):
+            real_file = make_a_real_file(tmpdir, "file.txt")
+            utils.copy_to_dir(real_file, None)
+
+    def test_copy_fails_if_source_does_not_exist(self, tmpdir):
+        with pytest.raises(ValidationError):
+            utils.copy_to_dir("not a real file", tmpdir)
+
+    def test_copy_fails_if_destination_is_not_real(self, tmpdir):
+        with pytest.raises(ValidationError):
+            real_file = make_a_real_file(tmpdir, "real_file.txt")
+            utils.copy_to_dir(real_file, "not a real directory")
+
+    def test_copy_fails_if_destination_is_not_a_directory(self, tmpdir):
+
+        real_file = make_a_real_file(tmpdir, "not_a_directory.txt")
+        not_a_directory = real_file
+        assert os.path.exists(not_a_directory)
+        assert not os.path.isdir(not_a_directory)
+
+        with pytest.raises(ValidationError):
+            utils.copy_to_dir(real_file, not_a_directory)
+
+    def test_copy_to_dir(self, tmpdir):
+
+        # Setup source file and target dir
+        destination_dir = os.path.join(tmpdir, "destination")
+        os.makedirs(destination_dir)
+        assert os.path.exists(destination_dir)
+        file_name = "fun_file.txt"
+        file_to_copy = os.path.join(tmpdir, file_name)
+        Path(file_to_copy).touch()
+        assert os.path.exists(file_to_copy)
+
+        # copied file does not exist yet
+        expected_copy = os.path.join(tmpdir, "destination", file_name)
+        assert not os.path.exists(expected_copy)
+
+        # run copy function and confirm copy exists (and original)
+        result = utils.copy_to_dir(file_to_copy, destination_dir)
+        assert result == 0
+        assert os.path.exists(file_to_copy)
+        assert os.path.exists(expected_copy)
+
+    def test_copying_directories_and_subdirectories(self, tmpdir):
+
+        subdir = os.path.join(tmpdir, "subdir")
+        subsubdir = os.path.join(subdir, "subsubdir")
+        os.makedirs(subsubdir)
+        filename_a = "file_in_subdir.txt"
+        filename_b = "file_in_subsubdir.txt"
+        filepath_a = make_a_real_file(subdir, filename_a)
+        filepath_b = make_a_real_file(subsubdir, filename_b)
+
+        print(filepath_a)
+        print(filepath_b)
+
+        destination_dir = os.path.join(tmpdir, "destination")
+        os.makedirs(destination_dir)
+
+        expected_filepath_a = os.path.join(destination_dir, "subdir", filename_a)
+        expected_filepath_b = os.path.join(destination_dir, "subdir", "subsubdir", filename_b)
+        print(expected_filepath_a)
+        print(expected_filepath_b)
+        print(os.path.exists(expected_filepath_a))
+        if os.path.exists(expected_filepath_a):
+            print("apple")
+        else:
+            print("banana")
+        print(os.path.exists(expected_filepath_b))
+
+        os.path.commonpath
+
+        assert not os.path.exists(expected_filepath_a)
+        assert not os.path.exists(expected_filepath_b)
+
+        # Provide only the original tmpdir to the copying function
+        utils.copy_to_dir(subdir, destination_dir, True)
+
+        # assert that the subdirs and files all exist
+        assert not os.path.exists(expected_filepath_a)
+        assert not os.path.exists(expected_filepath_b)
+
+
+class TestMultiCopy(object):
+
+    def test_copy_fails_if_args_are_none(self, tmpdir):
+
+        with pytest.raises(ValidationError):
+            utils.copy_multiple_items_to_multiple_dirs(None, None)
+
+        with pytest.raises(ValidationError):
+            utils.copy_multiple_items_to_multiple_dirs(tmpdir, None)
+
+        with pytest.raises(ValidationError):
+            utils.copy_multiple_items_to_multiple_dirs(None, tmpdir)
+
+    def test_copy_requires_args_to_be_in_list_format(self, tmpdir):
+
+        not_a_list = "not a list"
+        with pytest.raises(ValidationError):
+            utils.copy_multiple_items_to_multiple_dirs(not_a_list, tmpdir)
+
+        tmpfile = make_a_real_file(tmpdir, "a_file.txt")
+        with pytest.raises(ValidationError):
+            utils.copy_multiple_items_to_multiple_dirs(tmpfile, not_a_list)
+
+    def test_copy_is_fine_with_single_items(self, tmpdir):
+
+        tmpfile = make_a_real_file(tmpdir, "single_file.txt")
+        destination = os.path.join(tmpdir, "destination")
+        os.makedirs(destination)
+        expected_file = os.path.join(tmpdir, "destination", "single_file.txt")
+        assert not os.path.exists(expected_file)
+
+        result = utils.copy_multiple_items_to_multiple_dirs([tmpfile], [destination])
+        assert result == 0
+        assert os.path.exists(expected_file)
+
+    def test_copy_multiple_items_to_multiple_dirs(self, tmpdir):
+
+        file_a = make_a_real_file(tmpdir, "single_file.txt")
+        file_b = make_a_real_file(tmpdir, "single_file.txt")
+        file_c = make_a_real_file(tmpdir, "single_file.txt")
+        source_files = [ file_a
+                       , file_b
+                       , file_c
+                       ]
+
+        destination_1 = os.path.join(tmpdir, "dest_1")
+        destination_2 = os.path.join(tmpdir, "dest_2")
+        destination_3 = os.path.join(tmpdir, "dest_3")
+        dest_paths = [ destination_1
+                     , destination_2
+                     , destination_3
+                     ]
+        os.makedirs(destination_1)
+        os.makedirs(destination_2)
+        os.makedirs(destination_3)
+
+        expected_files = []
+        for dp in dest_paths:
+            for sf in source_files:
+                expected_files.append( os.path.join(dp, os.path.basename(sf)))
+                assert not os.path.exists(expected_files[-1])
+        assert len(expected_files) == 9
+
+        result = utils.copy_multiple_items_to_multiple_dirs(source_files, dest_paths)
+        assert result == 0
+        for ef in expected_files:
+            assert os.path.exists(ef)
+
+    def test_copying_is_recursive(self, tmpdir):
+
+        # make some subdirs, with sub-subdirs, and files within them
+        subdir_a = os.path.join(tmpdir, "subdir_a")
+        subsubdir_aa = os.path.join(subdir_a, "subsubdir_aa")
+        subsubdir_ab = os.path.join(subdir_a, "subsubdir_ab")
+        subdir_b = os.path.join(tmpdir, "subdir_b")
+        subsubdir_ba = os.path.join(subdir_b, "subsubdir_ba")
+        subsubdir_bb = os.path.join(subdir_b, "subsubdir_bb")
+        os.makedirs(subsubdir_aa)
+        os.makedirs(subsubdir_ab)
+        os.makedirs(subsubdir_ba)
+        os.makedirs(subsubdir_bb)
+        make_a_real_file(subdir_a, "file_a.txt")
+        make_a_real_file(subsubdir_aa, "file_aa.txt")
+        make_a_real_file(subsubdir_ab, "file_ab.txt")
+        make_a_real_file(subdir_b, "file_b.txt")
+        make_a_real_file(subsubdir_ba, "file_ba.txt")
+        make_a_real_file(subsubdir_bb, "file_bb.txt")
+
+        destination_dir = os.path.join(tmpdir, "destination")
+        os.makedirs(destination_dir)
+
+        expected_filepath_a = os.path.join(destination_dir, "subdir_a", "file_a.txt")
+        expected_filepath_aa = os.path.join(destination_dir, "subdir_a", "subsubdir_aa", "file_aa.txt")
+        expected_filepath_ab = os.path.join(destination_dir, "subdir_a", "subsubdir_ab", "file_ab.txt")
+        expected_filepath_b = os.path.join(destination_dir, "subdir_b", "file_b.txt")
+        expected_filepath_ba = os.path.join(destination_dir, "subdir_b", "subsubdir_ba", "file_ba.txt")
+        expected_filepath_bb = os.path.join(destination_dir, "subdir_b", "subsubdir_bb", "file_bb.txt")
+        assert not os.path.exists(expected_filepath_a)
+        assert not os.path.exists(expected_filepath_b)
+        assert not os.path.exists(expected_filepath_a)
+        assert not os.path.exists(expected_filepath_aa)
+        assert not os.path.exists(expected_filepath_ab)
+        assert not os.path.exists(expected_filepath_b)
+        assert not os.path.exists(expected_filepath_ba)
+        assert not os.path.exists(expected_filepath_bb)
+
+        # Provide only the original tmpdir to the copying function
+        utils.copy_multiple_items_to_multiple_dirs([tmpdir], [destination_dir], True)
+
+        # assert that the subdirs and files all exist
+        assert os.path.exists(expected_filepath_a)
+        assert os.path.exists(expected_filepath_b)
+        assert os.path.exists(expected_filepath_a)
+        assert os.path.exists(expected_filepath_aa)
+        assert os.path.exists(expected_filepath_ab)
+        assert os.path.exists(expected_filepath_b)
+        assert os.path.exists(expected_filepath_ba)
+        assert os.path.exists(expected_filepath_bb)
Index: /trunk/tools/backups/utils/python/generic_utils.py
===================================================================
--- /trunk/tools/backups/utils/python/generic_utils.py	(revision 40907)
+++ /trunk/tools/backups/utils/python/generic_utils.py	(revision 40907)
@@ -0,0 +1,70 @@
+import os.path as path
+import shutil
+
+from distutils.dir_util import copy_tree
+
+
+class ValidationError(Exception):
+    """validation has failed in the generic utils module"""
+
+
+def copy_to_dir(source_file: str, destination_path: str, verbose: bool=False):
+    """ Copies a single file into the destination directory. If a directory is
+    given as a source then all the contents of the directory will be copied (the
+    directory itself will not be).
+"""
+
+    if source_file is None or destination_path is None:
+        raise ValidationError("Error: source for copy cannot be None type")
+    if destination_path is None:
+        raise ValidationError("Error: copy destination cannot be None type")
+    if not path.exists(source_file):
+        raise ValidationError(f"Error: Copy Failure: source does not exist: {source_file}")
+    if not path.exists(destination_path) or not path.isdir(destination_path):
+        print("Error: Copy Failure: destination path does not exist: ", destination_path)
+        raise ValidationError(f"Error: Copy Failure: destination path does not exist: {destination_path}")
+
+    try:
+        if verbose:
+            print(f"Copying: {source_file} to {destination_path}")
+        if path.isdir(source_file):
+            copy_tree(source_file, destination_path)
+        else:
+            shutil.copy2(source_file, destination_path)
+    except IOError as e:
+        msg = f"Error: copy failure.\n  copying: {source_file}\n  to: {destination_path}"
+        raise IOError(msg) from e
+    return 0
+
+
+def copy_multiple_items_to_multiple_dirs(source_paths: [str], destination_paths: [str], verbose: bool=False):
+    """ Copies the sources to the destination paths.
+
+This is a generic function that just wraps shutil.copy2"""
+
+    if source_paths is None:
+        raise ValidationError("Error: source for copy cannot be None type")
+    if destination_paths is None:
+        raise ValidationError("Error: copy destination cannot be None type")
+
+    if type(source_paths) is not list or len(source_paths) == 0:
+        raise ValidationError("Error: sources must be given as a list with at least one entry")
+    if type(destination_paths) is not list or len(destination_paths) == 0:
+        raise ValidationError("Error: destinations must be given as a list with at least one entry")
+
+    for dpath in destination_paths:
+        if not path.exists(dpath):
+            if verbose:
+                print("Copy Warning: target path does not exist (skipping): ", dpath)
+            continue
+        if not path.isdir(dpath):
+            if verbose:
+                print("Copy Warning: target path is not a directory (skipping): ", )
+            continue
+
+        for spath in source_paths:
+            result = copy_to_dir(spath, dpath, verbose)
+            if result != 0:
+                return result
+
+    return 0
Index: /trunk/tools/backups/utils/python/generic_utils_test.py
===================================================================
--- /trunk/tools/backups/utils/python/generic_utils_test.py	(revision 40907)
+++ /trunk/tools/backups/utils/python/generic_utils_test.py	(revision 40907)
@@ -0,0 +1,224 @@
+import generic_utils as utils
+import os
+import pytest
+import subprocess
+
+from pathlib import Path
+
+
+def make_a_real_file(file_path, file_name):
+    real_file = os.path.join(file_path, file_name)
+    Path(real_file).touch()
+    assert os.path.exists(real_file)
+    assert os.path.isfile(real_file)
+    return real_file
+
+
+class TestCopyingToDir(object):
+
+    def test_copy_fails_with_none_args(self, tmpdir):
+        with pytest.raises(utils.ValidationError):
+            utils.copy_to_dir(None, tmpdir)
+
+        with pytest.raises(utils.ValidationError):
+            real_file = make_a_real_file(tmpdir, "file.txt")
+            utils.copy_to_dir(real_file, None)
+
+    def test_copy_fails_if_source_does_not_exist(self, tmpdir):
+        with pytest.raises(utils.ValidationError):
+            utils.copy_to_dir("not a real file", tmpdir)
+
+    def test_copy_fails_if_destination_is_not_real(self, tmpdir):
+        with pytest.raises(utils.ValidationError):
+            real_file = make_a_real_file(tmpdir, "real_file.txt")
+            utils.copy_to_dir(real_file, "not a real directory")
+
+    def test_copy_fails_if_destination_is_not_a_directory(self, tmpdir):
+
+        real_file = make_a_real_file(tmpdir, "not_a_directory.txt")
+        not_a_directory = real_file
+        assert os.path.exists(not_a_directory)
+        assert not os.path.isdir(not_a_directory)
+
+        with pytest.raises(utils.ValidationError):
+            utils.copy_to_dir(real_file, not_a_directory)
+
+    def test_copy_to_dir(self, tmpdir):
+
+        # Setup source file and target dir
+        destination_dir = os.path.join(tmpdir, "destination")
+        os.makedirs(destination_dir)
+        assert os.path.exists(destination_dir)
+        file_name = "fun_file.txt"
+        file_to_copy = os.path.join(tmpdir, file_name)
+        Path(file_to_copy).touch()
+        assert os.path.exists(file_to_copy)
+
+        # copied file does not exist yet
+        expected_copy = os.path.join(tmpdir, "destination", file_name)
+        assert not os.path.exists(expected_copy)
+
+        # run copy function and confirm copy exists (and original)
+        result = utils.copy_to_dir(file_to_copy, destination_dir)
+        assert result == 0
+        assert os.path.exists(file_to_copy)
+        assert os.path.exists(expected_copy)
+
+    def test_copying_directories_and_subdirectories(self, tmpdir):
+
+        subdir = os.path.join(tmpdir, "subdir")
+        subsubdir = os.path.join(subdir, "subsubdir")
+        os.makedirs(subsubdir)
+        filename_a = "file_in_subdir.txt"
+        filename_b = "file_in_subsubdir.txt"
+        filepath_a = make_a_real_file(subdir, filename_a)
+        filepath_b = make_a_real_file(subsubdir, filename_b)
+
+        print(filepath_a)
+        print(filepath_b)
+
+        destination_dir = os.path.join(tmpdir, "destination")
+        os.makedirs(destination_dir)
+
+        expected_filepath_a = os.path.join(destination_dir, "subdir", filename_a)
+        expected_filepath_b = os.path.join(destination_dir, "subdir", "subsubdir", filename_b)
+        print(expected_filepath_a)
+        print(expected_filepath_b)
+        print(os.path.exists(expected_filepath_a))
+        if os.path.exists(expected_filepath_a):
+            print("apple")
+        else:
+            print("banana")
+        print(os.path.exists(expected_filepath_b))
+
+        os.path.commonpath
+
+        assert not os.path.exists(expected_filepath_a)
+        assert not os.path.exists(expected_filepath_b)
+
+        # Provide only the original tmpdir to the copying function
+        utils.copy_to_dir(subdir, destination_dir, True)
+
+        # assert that the subdirs and files all exist
+        assert not os.path.exists(expected_filepath_a)
+        assert not os.path.exists(expected_filepath_b)
+
+
+class TestMultiCopy(object):
+
+    def test_copy_fails_if_args_are_none(self, tmpdir):
+
+        with pytest.raises(utils.ValidationError):
+            utils.copy_multiple_items_to_multiple_dirs(None, None)
+
+        with pytest.raises(utils.ValidationError):
+            utils.copy_multiple_items_to_multiple_dirs(tmpdir, None)
+
+        with pytest.raises(utils.ValidationError):
+            utils.copy_multiple_items_to_multiple_dirs(None, tmpdir)
+
+    def test_copy_requires_args_to_be_in_list_format(self, tmpdir):
+
+        not_a_list = "not a list"
+        with pytest.raises(utils.ValidationError):
+            utils.copy_multiple_items_to_multiple_dirs(not_a_list, tmpdir)
+
+        tmpfile = make_a_real_file(tmpdir, "a_file.txt")
+        with pytest.raises(utils.ValidationError):
+            utils.copy_multiple_items_to_multiple_dirs(tmpfile, not_a_list)
+
+    def test_copy_is_fine_with_single_items(self, tmpdir):
+
+        tmpfile = make_a_real_file(tmpdir, "single_file.txt")
+        destination = os.path.join(tmpdir, "destination")
+        os.makedirs(destination)
+        expected_file = os.path.join(tmpdir, "destination", "single_file.txt")
+        assert not os.path.exists(expected_file)
+
+        result = utils.copy_multiple_items_to_multiple_dirs([tmpfile], [destination])
+        assert result == 0
+        assert os.path.exists(expected_file)
+
+    def test_copy_multiple_items_to_multiple_dirs(self, tmpdir):
+
+        file_a = make_a_real_file(tmpdir, "single_file.txt")
+        file_b = make_a_real_file(tmpdir, "single_file.txt")
+        file_c = make_a_real_file(tmpdir, "single_file.txt")
+        source_files = [ file_a
+                       , file_b
+                       , file_c
+                       ]
+
+        destination_1 = os.path.join(tmpdir, "dest_1")
+        destination_2 = os.path.join(tmpdir, "dest_2")
+        destination_3 = os.path.join(tmpdir, "dest_3")
+        dest_paths = [ destination_1
+                     , destination_2
+                     , destination_3
+                     ]
+        os.makedirs(destination_1)
+        os.makedirs(destination_2)
+        os.makedirs(destination_3)
+
+        expected_files = []
+        for dp in dest_paths:
+            for sf in source_files:
+                expected_files.append( os.path.join(dp, os.path.basename(sf)))
+                assert not os.path.exists(expected_files[-1])
+        assert len(expected_files) == 9
+
+        result = utils.copy_multiple_items_to_multiple_dirs(source_files, dest_paths)
+        assert result == 0
+        for ef in expected_files:
+            assert os.path.exists(ef)
+
+    def test_copying_is_recursive(self, tmpdir):
+
+        # make some subdirs, with sub-subdirs, and files within them
+        subdir_a = os.path.join(tmpdir, "subdir_a")
+        subsubdir_aa = os.path.join(subdir_a, "subsubdir_aa")
+        subsubdir_ab = os.path.join(subdir_a, "subsubdir_ab")
+        subdir_b = os.path.join(tmpdir, "subdir_b")
+        subsubdir_ba = os.path.join(subdir_b, "subsubdir_ba")
+        subsubdir_bb = os.path.join(subdir_b, "subsubdir_bb")
+        os.makedirs(subsubdir_aa)
+        os.makedirs(subsubdir_ab)
+        os.makedirs(subsubdir_ba)
+        os.makedirs(subsubdir_bb)
+        make_a_real_file(subdir_a, "file_a.txt")
+        make_a_real_file(subsubdir_aa, "file_aa.txt")
+        make_a_real_file(subsubdir_ab, "file_ab.txt")
+        make_a_real_file(subdir_b, "file_b.txt")
+        make_a_real_file(subsubdir_ba, "file_ba.txt")
+        make_a_real_file(subsubdir_bb, "file_bb.txt")
+
+        destination_dir = os.path.join(tmpdir, "destination")
+        os.makedirs(destination_dir)
+
+        expected_filepath_a = os.path.join(destination_dir, "subdir_a", "file_a.txt")
+        expected_filepath_aa = os.path.join(destination_dir, "subdir_a", "subsubdir_aa", "file_aa.txt")
+        expected_filepath_ab = os.path.join(destination_dir, "subdir_a", "subsubdir_ab", "file_ab.txt")
+        expected_filepath_b = os.path.join(destination_dir, "subdir_b", "file_b.txt")
+        expected_filepath_ba = os.path.join(destination_dir, "subdir_b", "subsubdir_ba", "file_ba.txt")
+        expected_filepath_bb = os.path.join(destination_dir, "subdir_b", "subsubdir_bb", "file_bb.txt")
+        assert not os.path.exists(expected_filepath_a)
+        assert not os.path.exists(expected_filepath_b)
+        assert not os.path.exists(expected_filepath_a)
+        assert not os.path.exists(expected_filepath_aa)
+        assert not os.path.exists(expected_filepath_ab)
+        assert not os.path.exists(expected_filepath_b)
+        assert not os.path.exists(expected_filepath_ba)
+        assert not os.path.exists(expected_filepath_bb)
+
+        # Provide only the original tmpdir to the copying function
+        utils.copy_multiple_items_to_multiple_dirs([tmpdir], [destination_dir], True)
+
+        # assert that the subdirs and files all exist
+        assert os.path.exists(expected_filepath_a)
+        assert os.path.exists(expected_filepath_b)
+        assert os.path.exists(expected_filepath_a)
+        assert os.path.exists(expected_filepath_aa)
+        assert os.path.exists(expected_filepath_ab)
+        assert os.path.exists(expected_filepath_b)
+        assert os.path.exists(expected_filepath_ba)
+        assert os.path.exists(expected_filepath_bb)
Index: /trunk/tools/backups/utils/python/subprocess_utils.py
===================================================================
--- /trunk/tools/backups/utils/python/subprocess_utils.py	(revision 40907)
+++ /trunk/tools/backups/utils/python/subprocess_utils.py	(revision 40907)
@@ -0,0 +1,53 @@
+import subprocess
+
+# Plans are to flesh this out a bit more to make some smarter copying etc
+from generic_utils import ValidationError
+
+
+class SubprocessError(Exception):
+    """Errors in Popen stderr subprocess"""
+
+
+def _args_check(args, function_name):
+    if args is None:
+        raise ValidationError(f"Error: no arguments provided in {function_name}")
+    if type(args) != list or len(args) == 0:
+        raise ValidationError(f"Error: no arguments provided in {function_name}")
+    return 0
+
+
+def cp_wrapper(args: [str]):
+    if args is None:
+        raise ValidationError(f"Error: no arguments provided to {cp_wrapper.__name__}")
+    command_and_args = ["cp"] + args
+    return simple_unix_wrapper(command_and_args)
+
+
+def tar_wrapper(args: [str]):
+    if args is None:
+        raise ValidationError(f"Error: no arguments provided to {tar_wrapper.__name__}")
+    command_and_args = ["tar"] + args
+    return simple_unix_wrapper(command_and_args)
+
+
+def chmod_wrapper(args: [str]):
+    if args is None:
+        raise ValidationError(f"Error: no arguments provided to {chmod_wrapper.__name__}")
+    command_and_args = ["chmod"] + args
+    return simple_unix_wrapper(command_and_args)
+
+
+def simple_unix_wrapper(command_and_args: [str]):
+    """ This should provide the program name and all arguments in a list.
+
+Only a single command should be called.
+    """
+    _args_check(command_and_args, simple_unix_wrapper.__name__)
+
+    try:
+        subprocess.check_call(command_and_args)
+    except (subprocess.CalledProcessError) as cpe:
+        raise SubprocessError(f"{type(cpe)} in python simple_unix_wrapper") from cpe
+    except (ValidationError) as ve:
+        raise ValidationError(f"{type(ve)} in python simple_unix_wrapper") from ve
+    return 0
Index: /trunk/tools/backups/utils/python/subprocess_utils_test.py
===================================================================
--- /trunk/tools/backups/utils/python/subprocess_utils_test.py	(revision 40907)
+++ /trunk/tools/backups/utils/python/subprocess_utils_test.py	(revision 40907)
@@ -0,0 +1,165 @@
+import subprocess_utils as utils
+import os
+import pytest
+import stat
+
+from pathlib import Path
+from generic_utils import ValidationError
+
+
+def make_a_real_file(file_path, file_name):
+    real_file = os.path.join(file_path, file_name)
+    Path(real_file).touch()
+    assert os.path.exists(real_file)
+    assert os.path.isfile(real_file)
+    return real_file
+
+
+class TestCopyWrapper(object):
+
+    def test_copy_raises_validation_error_with_no_args(self):
+        with pytest.raises(ValidationError):
+            utils.cp_wrapper(None)
+
+    def test_copy_fails_with_invalid_args(self, tmpdir):
+        with pytest.raises(utils.SubprocessError):
+            utils.cp_wrapper(["not a real file", "not a valid copy_location"])
+
+    def test_copy_fails_if_source_does_not_exist(self, tmpdir):
+        with pytest.raises(utils.SubprocessError):
+            utils.cp_wrapper(["not a real file", tmpdir])
+
+    def test_simple_copy(self, tmpdir):
+        real_file = make_a_real_file(tmpdir, "real_file.txt")
+        expected_filepath = os.path.join(tmpdir, "copied_file.txt")
+        assert not os.path.isfile(expected_filepath)
+
+        result = utils.cp_wrapper(["-v", real_file, expected_filepath])
+        assert os.path.isfile(expected_filepath)
+        assert result == 0
+
+    def test_simple_copy_to_directory(self, tmpdir):
+
+        # Setup source file and target dir
+        filename = "real_file.txt"
+        real_file = make_a_real_file(tmpdir, filename)
+
+        destination_dir = os.path.join(tmpdir, "destination")
+        os.makedirs(destination_dir)
+        assert os.path.exists(destination_dir)
+
+        expected_filepath = os.path.join(destination_dir, filename)
+        assert not os.path.exists(expected_filepath)
+
+        # run copy function and confirm copy exists (and original)
+        result = utils.cp_wrapper([real_file, destination_dir])
+        assert result == 0
+        assert os.path.isfile(real_file)
+        assert os.path.exists(expected_filepath)
+
+    def test_copying_directories_and_subdirectories(self, tmpdir):
+
+        subdir = os.path.join(tmpdir, "subdir")
+        subsubdir = os.path.join(subdir, "subsubdir")
+        os.makedirs(subsubdir)
+        filename_a = "file_in_subdir.txt"
+        filename_b = "file_in_subsubdir.txt"
+        make_a_real_file(subdir, filename_a)
+        make_a_real_file(subsubdir, filename_b)
+
+        destination_dir = os.path.join(tmpdir, "destination")
+        os.makedirs(destination_dir)
+        expected_filepath_a = os.path.join(destination_dir, "subdir", filename_a)
+        expected_filepath_b = os.path.join(destination_dir, "subdir", "subsubdir", filename_b)
+        assert not os.path.exists(expected_filepath_a)
+        assert not os.path.exists(expected_filepath_b)
+
+        # fails without -r arg:
+        with pytest.raises(utils.SubprocessError):
+            utils.cp_wrapper([subdir, destination_dir])
+        assert not os.path.exists(expected_filepath_a)
+        assert not os.path.exists(expected_filepath_b)
+
+        utils.cp_wrapper(["-r", subdir, destination_dir])
+        assert os.path.exists(expected_filepath_a)
+        assert os.path.exists(expected_filepath_b)
+
+
+class TestTarWrapper(object):
+
+    def test_tar_raise_validation_error_with_none_args(self):
+        with pytest.raises(ValidationError):
+            utils.tar_wrapper(None)
+
+    def test_tar_returns_safely_with_too_few_args(self):
+        with pytest.raises(utils.SubprocessError):
+            utils.tar_wrapper(["only one arg"])
+
+    def test_tar_return_error_if_invalid_args_given(self, tmpdir):
+        tar_filename = os.path.join(tmpdir, "tar_file.tar")
+        fake_file = os.path.join(tmpdir, "fake_file.txt")
+        assert not os.path.exists(fake_file)
+        with pytest.raises(utils.SubprocessError):
+            utils.tar_wrapper(["-cf", tar_filename, fake_file])
+
+    def test_simple_tar_of_files(self, tmpdir):
+        file_a = make_a_real_file(tmpdir, "file_a.txt")
+        file_b = make_a_real_file(tmpdir, "file_b.txt")
+
+        expected_file = os.path.join(tmpdir, "output.tar")
+        assert not os.path.exists(expected_file)
+
+        args = ["-cf", expected_file, file_a, file_b]
+        utils.tar_wrapper(args)
+        assert os.path.exists(expected_file)
+
+
+class TestChmodWrapper(object):
+
+    def test_chmod_gives_validation_error_when_no_args(self):
+        with pytest.raises(utils.ValidationError):
+            utils.chmod_wrapper(None)
+
+    def test_chmod_wrapper_raises_error_with_invalid_args(self):
+        with pytest.raises(utils.SubprocessError):
+            utils.chmod_wrapper(["only one arg"])
+
+    def test_chmod_fails_if_no_permissions_given(self, tmpdir):
+        real_file = make_a_real_file(tmpdir, 'mr_file')
+
+        with pytest.raises(utils.SubprocessError):
+            utils.chmod_wrapper([real_file])
+
+        with pytest.raises(utils.SubprocessError):
+            utils.chmod_wrapper(["dumb_arg", real_file])
+
+    def test_can_chmod_file(self, tmpdir):
+        real_file = make_a_real_file(tmpdir, 'mr_file')
+
+        # check initial permissions
+        org_stats = os.stat(real_file)
+        # Default should be: rw-rw-r--
+        assert bool(org_stats.st_mode & stat.S_IRUSR) is True
+        assert bool(org_stats.st_mode & stat.S_IWUSR) is True
+        assert bool(org_stats.st_mode & stat.S_IXUSR) is False
+        assert bool(org_stats.st_mode & stat.S_IRGRP) is True
+        assert bool(org_stats.st_mode & stat.S_IWGRP) is True
+        assert bool(org_stats.st_mode & stat.S_IXGRP) is False
+        assert bool(org_stats.st_mode & stat.S_IROTH) is True
+        assert bool(org_stats.st_mode & stat.S_IWOTH) is False
+        assert bool(org_stats.st_mode & stat.S_IXOTH) is False
+
+        # change them
+        utils.chmod_wrapper(["777", real_file])
+
+        # check they changed
+        new_stats = os.stat(real_file)
+        assert bool(new_stats.st_mode & stat.S_IRUSR) is True
+        assert bool(new_stats.st_mode & stat.S_IWUSR) is True
+        assert bool(new_stats.st_mode & stat.S_IXUSR) is True
+        assert bool(new_stats.st_mode & stat.S_IRGRP) is True
+        assert bool(new_stats.st_mode & stat.S_IWGRP) is True
+        assert bool(new_stats.st_mode & stat.S_IXGRP) is True
+        assert bool(new_stats.st_mode & stat.S_IROTH) is True
+        assert bool(new_stats.st_mode & stat.S_IWOTH) is True
+        assert bool(new_stats.st_mode & stat.S_IXOTH) is True
Index: /trunk/tools/backups/utils/subprocess_utils.py
===================================================================
--- /trunk/tools/backups/utils/subprocess_utils.py	(revision 40907)
+++ /trunk/tools/backups/utils/subprocess_utils.py	(revision 40907)
@@ -0,0 +1,49 @@
+import subprocess
+
+# Plans are to flesh this out a bit more to make some smarter copying etc
+from backups.utils.errors import ValidationError, SubprocessError
+
+
+def _args_check(args, function_name):
+    if args is None:
+        raise ValidationError(f"Error: no arguments provided in {function_name}")
+    if type(args) != list or len(args) == 0:
+        raise ValidationError(f"Error: no arguments provided in {function_name}")
+    return 0
+
+
+def cp_wrapper(args: [str]):
+    if args is None:
+        raise ValidationError(f"Error: no arguments provided to {cp_wrapper.__name__}")
+    command_and_args = ["cp"] + args
+    return simple_unix_wrapper(command_and_args)
+
+
+def tar_wrapper(args: [str]):
+    if args is None:
+        raise ValidationError(f"Error: no arguments provided to {tar_wrapper.__name__}")
+    command_and_args = ["tar"] + args
+    return simple_unix_wrapper(command_and_args)
+
+
+def chmod_wrapper(args: [str]):
+    if args is None:
+        raise ValidationError(f"Error: no arguments provided to {chmod_wrapper.__name__}")
+    command_and_args = ["chmod"] + args
+    return simple_unix_wrapper(command_and_args)
+
+
+def simple_unix_wrapper(command_and_args: [str]):
+    """ This should provide the program name and all arguments in a list.
+
+Only a single command should be called.
+    """
+    _args_check(command_and_args, simple_unix_wrapper.__name__)
+
+    try:
+        subprocess.check_call(command_and_args)
+    except (subprocess.CalledProcessError) as cpe:
+        raise SubprocessError(f"{type(cpe)} in python simple_unix_wrapper") from cpe
+    except (ValidationError) as ve:
+        raise ValidationError(f"{type(ve)} in python simple_unix_wrapper") from ve
+    return 0
Index: /trunk/tools/backups/utils/subprocess_utils_test.py
===================================================================
--- /trunk/tools/backups/utils/subprocess_utils_test.py	(revision 40907)
+++ /trunk/tools/backups/utils/subprocess_utils_test.py	(revision 40907)
@@ -0,0 +1,165 @@
+import backups.utils.subprocess_utils as utils
+import os
+import pytest
+import stat
+
+from pathlib import Path
+from backups.utils.errors import SubprocessError, ValidationError
+
+
+def make_a_real_file(file_path, file_name):
+    real_file = os.path.join(file_path, file_name)
+    Path(real_file).touch()
+    assert os.path.exists(real_file)
+    assert os.path.isfile(real_file)
+    return real_file
+
+
+class TestCopyWrapper(object):
+
+    def test_copy_raises_validation_error_with_no_args(self):
+        with pytest.raises(ValidationError):
+            utils.cp_wrapper(None)
+
+    def test_copy_fails_with_invalid_args(self, tmpdir):
+        with pytest.raises(SubprocessError):
+            utils.cp_wrapper(["not a real file", "not a valid copy_location"])
+
+    def test_copy_fails_if_source_does_not_exist(self, tmpdir):
+        with pytest.raises(SubprocessError):
+            utils.cp_wrapper(["not a real file", tmpdir])
+
+    def test_simple_copy(self, tmpdir):
+        real_file = make_a_real_file(tmpdir, "real_file.txt")
+        expected_filepath = os.path.join(tmpdir, "copied_file.txt")
+        assert not os.path.isfile(expected_filepath)
+
+        result = utils.cp_wrapper(["-v", real_file, expected_filepath])
+        assert os.path.isfile(expected_filepath)
+        assert result == 0
+
+    def test_simple_copy_to_directory(self, tmpdir):
+
+        # Setup source file and target dir
+        filename = "real_file.txt"
+        real_file = make_a_real_file(tmpdir, filename)
+
+        destination_dir = os.path.join(tmpdir, "destination")
+        os.makedirs(destination_dir)
+        assert os.path.exists(destination_dir)
+
+        expected_filepath = os.path.join(destination_dir, filename)
+        assert not os.path.exists(expected_filepath)
+
+        # run copy function and confirm copy exists (and original)
+        result = utils.cp_wrapper([real_file, destination_dir])
+        assert result == 0
+        assert os.path.isfile(real_file)
+        assert os.path.exists(expected_filepath)
+
+    def test_copying_directories_and_subdirectories(self, tmpdir):
+
+        subdir = os.path.join(tmpdir, "subdir")
+        subsubdir = os.path.join(subdir, "subsubdir")
+        os.makedirs(subsubdir)
+        filename_a = "file_in_subdir.txt"
+        filename_b = "file_in_subsubdir.txt"
+        make_a_real_file(subdir, filename_a)
+        make_a_real_file(subsubdir, filename_b)
+
+        destination_dir = os.path.join(tmpdir, "destination")
+        os.makedirs(destination_dir)
+        expected_filepath_a = os.path.join(destination_dir, "subdir", filename_a)
+        expected_filepath_b = os.path.join(destination_dir, "subdir", "subsubdir", filename_b)
+        assert not os.path.exists(expected_filepath_a)
+        assert not os.path.exists(expected_filepath_b)
+
+        # fails without -r arg:
+        with pytest.raises(SubprocessError):
+            utils.cp_wrapper([subdir, destination_dir])
+        assert not os.path.exists(expected_filepath_a)
+        assert not os.path.exists(expected_filepath_b)
+
+        utils.cp_wrapper(["-r", subdir, destination_dir])
+        assert os.path.exists(expected_filepath_a)
+        assert os.path.exists(expected_filepath_b)
+
+
+class TestTarWrapper(object):
+
+    def test_tar_raise_validation_error_with_none_args(self):
+        with pytest.raises(ValidationError):
+            utils.tar_wrapper(None)
+
+    def test_tar_returns_safely_with_too_few_args(self):
+        with pytest.raises(SubprocessError):
+            utils.tar_wrapper(["only one arg"])
+
+    def test_tar_return_error_if_invalid_args_given(self, tmpdir):
+        tar_filename = os.path.join(tmpdir, "tar_file.tar")
+        fake_file = os.path.join(tmpdir, "fake_file.txt")
+        assert not os.path.exists(fake_file)
+        with pytest.raises(SubprocessError):
+            utils.tar_wrapper(["-cf", tar_filename, fake_file])
+
+    def test_simple_tar_of_files(self, tmpdir):
+        file_a = make_a_real_file(tmpdir, "file_a.txt")
+        file_b = make_a_real_file(tmpdir, "file_b.txt")
+
+        expected_file = os.path.join(tmpdir, "output.tar")
+        assert not os.path.exists(expected_file)
+
+        args = ["-cf", expected_file, file_a, file_b]
+        utils.tar_wrapper(args)
+        assert os.path.exists(expected_file)
+
+
+class TestChmodWrapper(object):
+
+    def test_chmod_gives_validation_error_when_no_args(self):
+        with pytest.raises(utils.ValidationError):
+            utils.chmod_wrapper(None)
+
+    def test_chmod_wrapper_raises_error_with_invalid_args(self):
+        with pytest.raises(SubprocessError):
+            utils.chmod_wrapper(["only one arg"])
+
+    def test_chmod_fails_if_no_permissions_given(self, tmpdir):
+        real_file = make_a_real_file(tmpdir, 'mr_file')
+
+        with pytest.raises(SubprocessError):
+            utils.chmod_wrapper([real_file])
+
+        with pytest.raises(SubprocessError):
+            utils.chmod_wrapper(["dumb_arg", real_file])
+
+    def test_can_chmod_file(self, tmpdir):
+        real_file = make_a_real_file(tmpdir, 'mr_file')
+
+        # check initial permissions
+        org_stats = os.stat(real_file)
+        # Default should be: rw-rw-r--
+        assert bool(org_stats.st_mode & stat.S_IRUSR) is True
+        assert bool(org_stats.st_mode & stat.S_IWUSR) is True
+        assert bool(org_stats.st_mode & stat.S_IXUSR) is False
+        assert bool(org_stats.st_mode & stat.S_IRGRP) is True
+        assert bool(org_stats.st_mode & stat.S_IWGRP) is True
+        assert bool(org_stats.st_mode & stat.S_IXGRP) is False
+        assert bool(org_stats.st_mode & stat.S_IROTH) is True
+        assert bool(org_stats.st_mode & stat.S_IWOTH) is False
+        assert bool(org_stats.st_mode & stat.S_IXOTH) is False
+
+        # change them
+        utils.chmod_wrapper(["777", real_file])
+
+        # check they changed
+        new_stats = os.stat(real_file)
+        assert bool(new_stats.st_mode & stat.S_IRUSR) is True
+        assert bool(new_stats.st_mode & stat.S_IWUSR) is True
+        assert bool(new_stats.st_mode & stat.S_IXUSR) is True
+        assert bool(new_stats.st_mode & stat.S_IRGRP) is True
+        assert bool(new_stats.st_mode & stat.S_IWGRP) is True
+        assert bool(new_stats.st_mode & stat.S_IXGRP) is True
+        assert bool(new_stats.st_mode & stat.S_IROTH) is True
+        assert bool(new_stats.st_mode & stat.S_IWOTH) is True
+        assert bool(new_stats.st_mode & stat.S_IXOTH) is True
