From a8a511e169295c72583e85b59119cda79736f3c5 Mon Sep 17 00:00:00 2001 From: Clint Modien Date: Wed, 15 Nov 2017 19:31:09 -0600 Subject: [PATCH] added unit tests --- .gitignore | 7 ++- .pylintrc | 2 +- .vscode/settings.json | 11 +++-- .vscode/tasks.json | 18 ++++++++ Makefile | 22 +++++++++ README.md | 19 +++++++- backup_preferences.py | 8 +--- backup_system_preferences.py | 21 +++++---- config.py | 17 ++++++- macprefs | 29 ++++++------ pytest.ini | 8 ++++ requirements.txt | 8 ++++ restore_preferences.py | 22 ++++++--- restore_system_preferences.py | 16 ++++--- test_backup_preferences.py | 39 ++++++++++++++++ test_backup_system_preferences.py | 16 +++++++ test_config.py | 18 ++++++++ test_macprefs.py | 73 ++++++++++++++++++++++++++++++ test_restore_preferences.py | 39 ++++++++++++++++ test_restore_system_preferences.py | 35 ++++++++++++++ test_utils.py | 36 +++++++++++++++ utils.py | 2 +- 22 files changed, 413 insertions(+), 53 deletions(-) create mode 100644 .vscode/tasks.json create mode 100644 Makefile create mode 100644 pytest.ini create mode 100644 requirements.txt create mode 100644 test_backup_preferences.py create mode 100644 test_backup_system_preferences.py create mode 100644 test_config.py create mode 100644 test_macprefs.py create mode 100644 test_restore_preferences.py create mode 100644 test_restore_system_preferences.py create mode 100644 test_utils.py diff --git a/.gitignore b/.gitignore index 7e99e36..37693a2 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,6 @@ -*.pyc \ No newline at end of file +*.pyc +.cache +.tmontmp +.testmondata +macprefsc +.coverage \ No newline at end of file diff --git a/.pylintrc b/.pylintrc index 917bf1d..01b2e27 100644 --- a/.pylintrc +++ b/.pylintrc @@ -50,7 +50,7 @@ confidence= # --enable=similarities". If you want to run only the classes checker, but have # no Warning level messages displayed, use"--disable=all --enable=classes # --disable=W" -disable=print-statement,parameter-unpacking,unpacking-in-except,old-raise-syntax,backtick,long-suffix,old-ne-operator,old-octal-literal,import-star-module-level,raw-checker-failed,bad-inline-option,locally-disabled,locally-enabled,file-ignored,suppressed-message,useless-suppression,deprecated-pragma,apply-builtin,basestring-builtin,buffer-builtin,cmp-builtin,coerce-builtin,execfile-builtin,file-builtin,long-builtin,raw_input-builtin,reduce-builtin,standarderror-builtin,unicode-builtin,xrange-builtin,coerce-method,delslice-method,getslice-method,setslice-method,no-absolute-import,old-division,dict-iter-method,dict-view-method,next-method-called,metaclass-assignment,indexing-exception,raising-string,reload-builtin,oct-method,hex-method,nonzero-method,cmp-method,input-builtin,round-builtin,intern-builtin,unichr-builtin,map-builtin-not-iterating,zip-builtin-not-iterating,range-builtin-not-iterating,filter-builtin-not-iterating,using-cmp-argument,eq-without-hash,div-method,idiv-method,rdiv-method,exception-message-attribute,invalid-str-codec,sys-max-int,bad-python3-import,deprecated-string-function,deprecated-str-translate-call,C0111 +disable=print-statement,parameter-unpacking,unpacking-in-except,old-raise-syntax,backtick,long-suffix,old-ne-operator,old-octal-literal,import-star-module-level,raw-checker-failed,bad-inline-option,locally-disabled,locally-enabled,file-ignored,suppressed-message,useless-suppression,deprecated-pragma,apply-builtin,basestring-builtin,buffer-builtin,cmp-builtin,coerce-builtin,execfile-builtin,file-builtin,long-builtin,raw_input-builtin,reduce-builtin,standarderror-builtin,unicode-builtin,xrange-builtin,coerce-method,delslice-method,getslice-method,setslice-method,no-absolute-import,old-division,dict-iter-method,dict-view-method,next-method-called,metaclass-assignment,indexing-exception,raising-string,reload-builtin,oct-method,hex-method,nonzero-method,cmp-method,input-builtin,round-builtin,intern-builtin,unichr-builtin,map-builtin-not-iterating,zip-builtin-not-iterating,range-builtin-not-iterating,filter-builtin-not-iterating,using-cmp-argument,eq-without-hash,div-method,idiv-method,rdiv-method,exception-message-attribute,invalid-str-codec,sys-max-int,bad-python3-import,deprecated-string-function,deprecated-str-translate-call,C0111,C0304,C0103 # Enable the message, report, category or checker with the given id(s). You can # either give multiple identifier separated by comma (,) or put this option diff --git a/.vscode/settings.json b/.vscode/settings.json index 1b33878..5030dfa 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,9 +1,12 @@ { - "cSpell.words": [ +"cSpell.words": [ + "macprefs", + "plist", + "pytest", "symlinks" - ], +], "python.unitTest.pyTestArgs": [ "." ], - "python.unitTest.pyTestEnabled": true -} \ No newline at end of file + "python.unitTest.pyTestEnabled": false +} diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 0000000..9dcb093 --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,18 @@ +{ + // See https://go.microsoft.com/fwlink/?LinkId=733558 + // for the documentation about the tasks.json format + "version": "2.0.0", + "tasks": [ + { + "label": "Run All Tests with watch", + "type": "shell", + "command": "ptw --onfail 'say failed' -- --testmon ", + "isBackground": true, + "presentation": { + "echo":false, + "reveal": "never" + }, + "problemMatcher": [] + } + ] +} \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..c7ccc43 --- /dev/null +++ b/Makefile @@ -0,0 +1,22 @@ +clean: + find . -name '*.pyc' -delete + +setup: + pip install -r requirements.txt + +test: + pytest --cov=. + +lint: + pylint *.py + +publish: + + +help: + @echo "COMMANDS:" + @echo " clean Remove all generated files." + @echo " setup Setup development environment." + @echo " test Run tests." + @echo " lint Run analysis tools." + @echo " publish Tag and push to github and update the brew formula with the new url and sha256 and push to github \ No newline at end of file diff --git a/README.md b/README.md index 26fbb40..89fd497 100644 --- a/README.md +++ b/README.md @@ -104,4 +104,21 @@ sudo macprefs restore ## Problems -- If you find a problem or a have a question feel free to file a bug here and/or send a pull request and I'll be happy to look at it and/or merge it. \ No newline at end of file +- If you find a problem or a have a question feel free to file a bug here and/or send a pull request and I'll be happy to look at it and/or merge it. + +## Contributing + +### Getting started + +- Fork and clone then cd to this git repo +- Run `pip install -r requirements.txt` + +### Running the tests + +- Run `make test lint` (make sure you've done the [Getting Started](#getting-started)) + +### Getting your changes merged + +- Make your changes and push them to github +- Make sure your changes have tests and pass linting +- Open a pull request diff --git a/backup_preferences.py b/backup_preferences.py index 7a2b7f8..8f373a4 100644 --- a/backup_preferences.py +++ b/backup_preferences.py @@ -1,5 +1,4 @@ -import os -from config import BACKUP_DIR +import config from utils import execute_shell @@ -8,11 +7,8 @@ def backup(): domains = domains.split("\n")[0].split(", ") domains = ["NSGlobalDomain"] + domains - if not os.path.exists(BACKUP_DIR): - os.makedirs(BACKUP_DIR) - for domain in domains: - filepath = BACKUP_DIR + domain + ".plist" + filepath = config.get_backup_file_path(domain) print "Backing up: " + domain + " to " + filepath execute_shell(["defaults", "export", domain, filepath]) diff --git a/backup_system_preferences.py b/backup_system_preferences.py index ae87689..901c76c 100644 --- a/backup_system_preferences.py +++ b/backup_system_preferences.py @@ -1,19 +1,24 @@ -import os +from os import path from utils import execute_shell -from config import BACKUP_DIR +from config import get_backup_dir def backup(): - power_management_backup_path = os.path.join( - BACKUP_DIR, "System", "com.apple.PowerManagement.plist") - power_management_domain = "/Library/Preferences/com.apple.PowerManagement" + power_management_domain = get_domain() + power_management_backup_path = path.join( + get_backup_dir(), "System", "com.apple.PowerManagement.plist") # On older versions of Mac OS X PowerManagement lived under SystemConfiguration - if not os.path.exists(power_management_domain + ".plist"): - power_management_domain = "/Library/Preferences/SystemConfiguration/com.apple.PowerManagement" print "Backing up: " + power_management_domain + " to " + power_management_backup_path # sudo is not required to back up but it is to restore execute_shell(["defaults", "export", power_management_domain, - power_management_backup_path], False) + power_management_backup_path], False) + + +def get_domain(): + power_management_domain = "/Library/Preferences/com.apple.PowerManagement" + if not path.exists(power_management_domain + ".plist"): + power_management_domain = "/Library/Preferences/SystemConfiguration/com.apple.PowerManagement" + return power_management_domain if __name__ == '__main__': diff --git a/config.py b/config.py index 26d7066..ce0a4f4 100644 --- a/config.py +++ b/config.py @@ -1,5 +1,18 @@ import os from os import path -BACKUP_DIR = os.environ['MACPREFS_BACKUP_DIR'] if 'MACPREFS_BACKUP_DIR' in os.environ else path.join( - path.expanduser("~"), "Dropbox", "MacPrefsBackup") + +def get_backup_dir(): + backup_dir = "" + if 'MACPREFS_BACKUP_DIR' in os.environ: + backup_dir = os.environ['MACPREFS_BACKUP_DIR'] + else: + backup_dir = path.join(path.expanduser( + "~"), "Dropbox", "MacPrefsBackup") + if not os.path.exists(backup_dir): + os.makedirs(backup_dir) + return backup_dir + + +def get_backup_file_path(domain): + return path.join(get_backup_dir(), domain + ".plist") diff --git a/macprefs b/macprefs index 1219f7a..7ca448d 100755 --- a/macprefs +++ b/macprefs @@ -8,7 +8,6 @@ import backup_system_preferences import restore_preferences import restore_system_preferences - def backup(): backup_system_preferences.backup() backup_preferences.backup() @@ -18,20 +17,20 @@ def restore(): restore_system_preferences.restore() restore_preferences.restore() - if __name__ == '__main__': - PARSER = argparse.ArgumentParser( + backup_dir = config.get_backup_dir() + parser = argparse.ArgumentParser( prog="macprefs", description='Backup and restore mac system preferences.') - SUBPARSERS = PARSER.add_subparsers(title="commands", metavar='') - BACKUP_PARSER = SUBPARSERS.add_parser( - 'backup', help="Backup mac preferences to " + config.BACKUP_DIR) - BACKUP_PARSER.set_defaults(func=backup) - RESTORE_PARSER = SUBPARSERS.add_parser( - 'restore', help="Restore mac preferences from " + config.BACKUP_DIR) - RESTORE_PARSER.set_defaults(func=restore) + subparsers = parser.add_subparsers(title="commands", metavar='') + backup_parser = subparsers.add_parser( + 'backup', help="Backup mac preferences to " + backup_dir) + backup_parser.set_defaults(func=backup) + restore_parser = subparsers.add_parser( + 'restore', help="Restore mac preferences from " + backup_dir) + restore_parser.set_defaults(func=restore) if len(sys.argv) == 1: - PARSER.print_help() - sys.exit(1) - ARGS = PARSER.parse_args() - if ARGS.func is not None: - ARGS.func() + parser.print_help() + sys.exit(0) + args = parser.parse_args() + if args.func is not None: + args.func() diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..10f8991 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,8 @@ +# pytest.ini + +[pytest] +addopts = --maxfail=2 -s --tb=native -v -m "not integration" +markers = integration: integration tests + +[pytest-watch] +nobeep = True \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..5195cb0 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,8 @@ +# Needed to run the tests +# Run "pip install -r requirements.txt" then pytest +mock +pylint +pytest +pytest-cov +pytest-testmon +pytest-watch \ No newline at end of file diff --git a/restore_preferences.py b/restore_preferences.py index 9bf311e..719fefe 100644 --- a/restore_preferences.py +++ b/restore_preferences.py @@ -1,16 +1,24 @@ import os -from config import BACKUP_DIR +from config import get_backup_dir from utils import execute_shell - def restore(): - for filename in sorted(os.listdir(BACKUP_DIR)): - if not ".plist" in filename: - continue - domain = filename.replace(".plist", "") + backup_dir = get_backup_dir() + domains = get_domains() + for domain in domains: + filename = domain + ".plist" print "Importing: " + domain execute_shell(["defaults", "import", domain, - os.path.join(BACKUP_DIR, filename)]) + os.path.join(backup_dir, filename)]) + + +def get_domains(): + domains = [] + backup_dir = get_backup_dir() + for filename in sorted(os.listdir(backup_dir)): + if ".plist" in filename: + domains.append(filename.replace(".plist", "")) + return domains if __name__ == '__main__': diff --git a/restore_system_preferences.py b/restore_system_preferences.py index 8ce98e9..88595c7 100644 --- a/restore_system_preferences.py +++ b/restore_system_preferences.py @@ -1,19 +1,21 @@ import os import sys -from config import BACKUP_DIR +from config import get_backup_dir from utils import execute_shell +power_management_restore_path = os.path.join( + get_backup_dir(), "System", "com.apple.PowerManagement.plist") + +power_management_domain = "/Library/Preferences/com.apple.PowerManagement" + def restore(): if os.getuid() != 0: - print "sudo is required to restore preferences: (e.g. sudo " + sys.argv[0] + " restore)" - sys.exit() - power_management_restore_path = os.path.join( - BACKUP_DIR, "System", "com.apple.PowerManagement.plist") - power_management_domain = "/Library/Preferences/com.apple.PowerManagement" + print "Error: sudo is required to restore preferences: (e.g. sudo " + sys.argv[0] + " restore)" + sys.exit(1) print "Restoring: " + power_management_domain + " from " + power_management_restore_path execute_shell(["defaults", "import", power_management_domain, - power_management_restore_path], False) + power_management_restore_path]) if __name__ == '__main__': diff --git a/test_backup_preferences.py b/test_backup_preferences.py new file mode 100644 index 0000000..7d962ee --- /dev/null +++ b/test_backup_preferences.py @@ -0,0 +1,39 @@ +from os import path +from mock import patch + +import backup_preferences +from config import get_backup_dir + + +@patch("backup_preferences.execute_shell") +def test_backup(execute_shell_mock): + execute_shell_mock.side_effect = execute_shell_func + backup_preferences.backup() + + +def execute_shell_func(*args): + command = args[0] + if command[1] == "domains": + return domains_func(command) + if command[1] == "export": + return exports_func(command) + + +def domains_func(command): + assert isinstance(command, list) + assert len(command) == 2 + assert command[0] == "defaults" + assert command[1] == "domains" + return ", ".join(["asdf.com"]) + + +def exports_func(command): + assert isinstance(command, list) + assert len(command) == 4 + assert command[0] == "defaults" + assert command[1] == "export" + assert "NSGlobalDomain" in command[2] or "asdf.com" in command[2] + backup_dir = get_backup_dir() + nsgd_file = path.join(backup_dir, "/NSGlobalDomain.plist") + asdf_file = path.join(backup_dir, "asdf.com.plist") + assert nsgd_file in command[3] or asdf_file in command[3] diff --git a/test_backup_system_preferences.py b/test_backup_system_preferences.py new file mode 100644 index 0000000..8156ecc --- /dev/null +++ b/test_backup_system_preferences.py @@ -0,0 +1,16 @@ +import backup_system_preferences +from mock import patch + + +@patch("backup_system_preferences.execute_shell") +def test_backup(execute_shell_mock): + execute_shell_mock.side_effect = execute_shell_func + backup_system_preferences.backup() + + +def execute_shell_func(*args): + arg_length = len(args) + assert arg_length > 0 + command = args[0] + assert command[0] == "defaults" + assert command[1] == "export" diff --git a/test_config.py b/test_config.py new file mode 100644 index 0000000..8f8519f --- /dev/null +++ b/test_config.py @@ -0,0 +1,18 @@ +import os +from os import path +import config + + +def test_get_backup_dir(): + backup_dir = config.get_backup_dir() + assert backup_dir is not None + +def test_get_backup_dir_works_with_environ(): + os.environ['MACPREFS_BACKUP_DIR'] = "asdf" + backup_dir = config.get_backup_dir() + assert "asdf" in backup_dir + + +def test_get_backup_file_path(): + assert config.get_backup_file_path("asdf.com") == path.join( + config.get_backup_dir(), "asdf.com.plist") diff --git a/test_macprefs.py b/test_macprefs.py new file mode 100644 index 0000000..b33b86b --- /dev/null +++ b/test_macprefs.py @@ -0,0 +1,73 @@ +from StringIO import StringIO +import imp +import sys +from mock import patch +import pytest + + +# load as module should work +macprefs = imp.load_source('macprefs', 'macprefs') + + +@patch('sys.stdout', new_callable=StringIO) +def test_invoke_help(mock_stdout): + try: + sys.argv = ['macprefs', '-h'] + # invoke as script + imp.load_source('__main__', 'macprefs') + except SystemExit as e: + assert_correct_std_out(e, mock_stdout) + + +@patch('sys.stdout', new_callable=StringIO) +def test_invoke_no_args(mock_stdout): + try: + sys.argv = ['macprefs'] + # invoke as script + imp.load_source('__main__', 'macprefs') + except SystemExit as e: + assert_correct_std_out(e, mock_stdout) + + +def assert_correct_std_out(e, mock_stdout): + assert e.code == 0 + assert 'usage: macprefs' in mock_stdout.getvalue() + assert 'Backup mac preferences' in mock_stdout.getvalue() + assert 'Restore mac preferences' in mock_stdout.getvalue() + assert 'show this help message and exit' in mock_stdout.getvalue() + + +@patch('backup_system_preferences.backup') +@patch('backup_preferences.backup') +def test_backup(backup_system_preferences_mock, backup_preferences_mock): + macprefs.backup() + backup_system_preferences_mock.assert_called_once() + backup_preferences_mock.assert_called_once() + + +@patch('restore_system_preferences.restore') +@patch('restore_preferences.restore') +def test_restore(restore_system_preferences_mock, restore_preferences_mock): + macprefs.restore() + restore_system_preferences_mock.assert_called_once() + restore_preferences_mock.assert_called_once() + + +@pytest.mark.integration +def test_backup_intergration(): + try: + sys.argv = ['macprefs', 'backup'] + # invoke as script + imp.load_source('__main__', 'macprefs') + except SystemExit as e: + assert e.code == 0 + + +@pytest.mark.integration +def test_restore_intergration(): + try: + sys.argv = ['macprefs', 'restore'] + # invoke as script + imp.load_source('__main__', 'macprefs') + except SystemExit as e: + assert e.code == 0 diff --git a/test_restore_preferences.py b/test_restore_preferences.py new file mode 100644 index 0000000..e43e82e --- /dev/null +++ b/test_restore_preferences.py @@ -0,0 +1,39 @@ +from os import path +from mock import patch + +import config +import restore_preferences + + +@patch("restore_preferences.get_domains") +@patch("restore_preferences.execute_shell") +def test_restore(execute_shell_mock, get_domains_mock): + execute_shell_mock.side_effect = execute_shell_func + get_domains_mock.side_effect = get_domains + restore_preferences.restore() + + +@patch("os.listdir") +def test_get_domains(listdir_mock): + listdir_mock.side_effect = listdir_func + result = restore_preferences.get_domains() + assert result[0] == "asdf.com" + +#pylint: disable=unused-argument +def listdir_func(directory): + return ['asdf.com.plist'] + + +def execute_shell_func(*args): + command = args[0] + backup_dir = config.get_backup_dir() + assert isinstance(command, list) + assert len(command) == 4 + assert command[0] == "defaults" + assert command[1] == "import" + assert command[2] == "asdf.com" + assert command[3] == path.join(backup_dir, "asdf.com.plist") + + +def get_domains(): + return ["asdf.com"] diff --git a/test_restore_system_preferences.py b/test_restore_system_preferences.py new file mode 100644 index 0000000..98964f3 --- /dev/null +++ b/test_restore_system_preferences.py @@ -0,0 +1,35 @@ +from StringIO import StringIO +import restore_system_preferences +from mock import patch + +@patch("restore_system_preferences.execute_shell") +@patch("os.getuid") +def test_restore_works_if_sudo(get_uid_mock, execute_shell_mock): + execute_shell_mock.side_effect = execute_shell_func + get_uid_mock.side_effect = get_uid_func + restore_system_preferences.power_management_restore_path = "pmrp" + restore_system_preferences.power_management_domain = "pmd" + restore_system_preferences.restore() + +@patch('sys.stdout', new_callable=StringIO) +def test_restore_exits_if_not_sudo(mock_stdout): + try: + restore_system_preferences.restore() + except SystemExit as e: + assert_correct_std_out(e, mock_stdout) + +def execute_shell_func(*args): + arg_length = len(args) + assert arg_length > 0 + command = args[0] + assert command[0] == "defaults" + assert command[1] == "import" + assert command[2] == "pmd" + assert command[3] == "pmrp" + +def get_uid_func(): + return 0 + +def assert_correct_std_out(e, mock_stdout): + assert e.code == 1 + assert 'sudo is required' in mock_stdout.getvalue() \ No newline at end of file diff --git a/test_utils.py b/test_utils.py new file mode 100644 index 0000000..18c7e48 --- /dev/null +++ b/test_utils.py @@ -0,0 +1,36 @@ +from subprocess import CalledProcessError +import utils +from mock import patch + + +@patch('utils.check_output') +def test_execute_shell(check_output_mock): + check_output_mock.side_effect = check_output_func + utils.execute_shell(['wtf']) + +# pylint: disable=unused-argument + + +def check_output_func(command, shell, cwd, stderr): + length = len(command) + assert length > 0 + assert command[0] == 'wtf' + return "" + + +@patch('utils.check_output') +def test_execute_shell_handles_errors(check_output_mock): + check_output_mock.side_effect = check_output_error_func + try: + utils.execute_shell(['expecting error']) + except CalledProcessError: + pass + + +def check_output_error_func(command, shell, cwd, stderr): + raise CalledProcessError(0, command) + + +@patch('utils.check_output') +def test_execute_shell_handles_verbose(check_output_mock): + utils.execute_shell(['wtf'], False, ".", False, True) diff --git a/utils.py b/utils.py index 3204226..a10e6c8 100644 --- a/utils.py +++ b/utils.py @@ -6,7 +6,7 @@ def execute_shell(command, is_shell=False, cwd=".", suppress_errors=False, verbo if verbose: print "\n--- executing shell command ----\n" print "setting working dir to: " + cwd - print "command: " + command + print "command: " + str(command) try: output = check_output(command, shell=is_shell, cwd=cwd, stderr=STDOUT).strip()