From 8da8c7acb7160c118494dfc01eb59841bd4d94db Mon Sep 17 00:00:00 2001 From: MosesofEgypt Date: Sun, 11 Jun 2017 18:35:09 -0400 Subject: [PATCH 01/16] Added launcher logs to ignore Fixed typos in readme, config, and download Renamed img\app.ico to img\256x256.ico to simplify code in main Simplified and sped up inject_variables in common.py Added __all__ to config to clarify module exports Fixed missing import of requests library in download Made the download increment function a private class method so it can be easily overloaded and doesn't have to be remade each time download_file is called. Added assertion to sha256_hash to make sure a block_size > 0 is given Sped up list_files and made it handle receiving directory strings that already have a path separator on the end. --- .gitignore | 3 +++ Launcher/config.json | 1 + README.md | 2 +- img/{app.ico => 256x256.ico} | Bin src/common.py | 13 ++++++++----- src/config.py | 13 +++++++++++-- src/download.py | 21 +++++++++++++-------- src/main.py | 7 +++---- src/util.py | 11 +++++++---- 9 files changed, 47 insertions(+), 24 deletions(-) create mode 100644 Launcher/config.json rename img/{app.ico => 256x256.ico} (100%) diff --git a/.gitignore b/.gitignore index 76e7f89..97dd493 100644 --- a/.gitignore +++ b/.gitignore @@ -89,3 +89,6 @@ ENV/ # Idea/PyCharm project .idea *.iml + +# Launcher logs +launcher_log.* \ No newline at end of file diff --git a/Launcher/config.json b/Launcher/config.json new file mode 100644 index 0000000..cb12bbf --- /dev/null +++ b/Launcher/config.json @@ -0,0 +1 @@ +{"branches": {"develop": "Development"}, "config_endpoint": "https://patch.houraiteahouse.net/{project}/launcher/config.json", "launcher_endpoint": "https://patch.houraiteahouse.net/{project}/launcher/{platform}/{executable}", "project": "Fantasy Crescendo", "index_endpoint": "https://patch.houraiteahouse.net/{project}/{branch}/{platform}/index.json", "launch_flags": {}, "news_rss_feed": "https://www.reddit.com/r/touhou.rss", "game_binary": {"Linux": "fc.x86_64", "Windows": "fc.exe"}, "logo": "img/logo.png"} \ No newline at end of file diff --git a/README.md b/README.md index d546121..9dc5be8 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ you'll need to configure it, and build your own copy. **NOTE:** For the patching functionality to work, the install directory for both the patcher and the game (it must be the same directory) must be writable -without evevated permissions. The game will launch properly, but will not patch +without elevated permissions. The game will launch properly, but will not patch over time. This includes Window's "Program Files" folder. For such situations, we suggest installing under "C:\Games" or something similar. diff --git a/img/app.ico b/img/256x256.ico similarity index 100% rename from img/app.ico rename to img/256x256.ico diff --git a/src/common.py b/src/common.py index 17a7394..daa38d9 100644 --- a/src/common.py +++ b/src/common.py @@ -24,12 +24,15 @@ def sanitize_url(url): def inject_variables(path_format, vars_obj=GLOBAL_CONTEXT): matches = vars_regex.findall(path_format) path = path_format + vars_is_dict = isinstance(vars_obj, dict) for match in matches: - target = '{%s}' % match - if isinstance(vars_obj, dict) and match in vars_obj: - path = path.replace(target, str(vars_obj[match])) + if vars_is_dict: + replacement = vars_obj.get(match) else: replacement = getattr(vars_obj, match, None) - if replacement is not None: - path = path.replace(target, str(replacement)) + + if replacement is None: + continue + + path = path.replace('{%s}' % match, str(replacement)) return path diff --git a/src/config.py b/src/config.py index 5cec31a..2237452 100644 --- a/src/config.py +++ b/src/config.py @@ -12,18 +12,27 @@ from util import namedtuple_from_mapping from collections import OrderedDict +# there are a lot of module-level declarations below, so to make it +# clear what objects are available we will specify with __all__ +__all__ = ( + "TRANSLATION_DIRNAME", "TRANSLATION_DIR", "TRANSLATIONS", + "CONFIG_DIRNAME", "CONFIG_DIR", "CONFIG_FILE", + "BASE_DIR", "RESOURCE_DIR", "GLOBAL_CONTEXT" + ) + + if 'win' in platform.platform().lower(): try: import gettext_windows except: - loggin.warning('Cannot import gettext_windows') + logging.warning('Cannot import gettext_windows') CONFIG_DIRNAME = 'Launcher' TRANSLATION_DIRNAME = 'i18n' CONFIG_FILE = 'config.json' # Get the base directory the executable is found in -# When running from a python interpretter, it will use the current working +# When running from a python interpreter, it will use the current working # directory. # sys.frozen is an attribute injected by pyinstaller at runtime if getattr(sys, 'frozen', False): diff --git a/src/download.py b/src/download.py index f734b5a..8e3723c 100644 --- a/src/download.py +++ b/src/download.py @@ -1,6 +1,7 @@ import asyncio import hashlib import logging +import requests import time from util import CHUNK_SIZE @@ -12,23 +13,26 @@ def download_file(url, filehash=None): logging.info('Downloading %s from %s...' % (path, url)) hasher = hashlib.sha256() + if session: + response = session.get(url, stream=True) + else: + response = requests.get(url, stream=True) + with open(path, 'wb+') as downloaded_file: - if session: - response = session.get(url, stream=True) - else: - response = requests.get(url, stream=True) logging.info('Response: %s (%s)' % (response.status_code, url)) for block in response.iter_content(CHUNK_SIZE): logging.info('Downloaded chunk of (size: %s, %s)' % (len(block), path)) if block_fun is not None: + # give callback function the block block_fun(block) downloaded_file.write(block) hasher.update(block) + download_hash = hasher.hexdigest() if filehash is not None and download_hash != filehash: - logging.error('File downoad hash mismatch: (%s) \n' + logging.error('File download hash mismatch: (%s) \n' ' Expected: %s \n' ' Actual: %s' % (path, filehash, download_hash)) logging.info('Done downloading: %s' % path) @@ -44,13 +48,14 @@ def __init__(self, path, url, download_size): self.downloaded_bytes = 0 def download_file(self, session=None): - def inc_fun(block): - self.downloaded_bytes += len(block) return download_file(self.url, self.file_path, - block_fun=inc_fun, + block_fun=self._inc_download, session=session) + def _inc_download(self, block): + self.downloaded_bytes += len(block) + class DownloadTracker(object): diff --git a/src/main.py b/src/main.py index dcc3211..d5b365f 100644 --- a/src/main.py +++ b/src/main.py @@ -5,13 +5,12 @@ from ui import MainWindow from common import app, loop +# load all the icons from the img folder into a QIcon object app_icon = QtGui.QIcon() -for size in [16, 24, 32, 48]: +for size in (16, 32, 48, 64, 256): app_icon.addFile( - os.path.join(RESOURCE_DIR, 'img/%sx%s.ico' % (size, size)), + os.path.join(RESOURCE_DIR, 'img', '%sx%s.ico' % (size, size)), QtCore.QSize(size, size)) -app_icon.addFile( - os.path.join(RESOURCE_DIR, 'img/app.ico'), QtCore.QSize(256, 256)) app.setWindowIcon(app_icon) main_window = MainWindow(CONFIG) diff --git a/src/util.py b/src/util.py index 9153f7c..5a46b19 100644 --- a/src/util.py +++ b/src/util.py @@ -3,11 +3,12 @@ import hashlib import os -CHUNK_SIZE = 1024 * 1024 +CHUNK_SIZE = 1024**2 def sha256_hash(filepath, block_size=CHUNK_SIZE): hasher = hashlib.sha256() + assert block_size > 0, ("hash block size must be greater than zero.") with open(filepath, 'rb') as hash_file: for block in iter(lambda: hash_file.read(block_size), b''): hasher.update(block) @@ -16,12 +17,14 @@ def sha256_hash(filepath, block_size=CHUNK_SIZE): def list_files(directory): - replacement = directory + os.path.sep + sep = os.path.sep + # using os.path.join to prevent an additional + # os.path.sep if directory already ends with it + root = os.path.join(directory, '') for directory, _, files in os.walk(directory): for file in files: full_path = os.path.join(directory, file) - relative_path = full_path.replace(replacement, - '').replace(os.path.sep, '/') + relative_path = os.path.relpath(full_path, root).replace(sep, '/') yield full_path, relative_path From 19ff98a75d9c18e15eb010d89a74df6bd6ba7de2 Mon Sep 17 00:00:00 2001 From: MosesofEgypt Date: Mon, 12 Jun 2017 00:01:43 -0400 Subject: [PATCH 02/16] Changed install/setup.iss to use renamed 256x256.ico Included CONFIG in src/config.py __all__ Wrote unit tests for util and common --- install/setup.iss | 2 +- src/config.py | 2 +- test/test_common.py | 39 ++++++++++++++++++++++++++++++++++++++ test/test_util.py | 46 +++++++++++++++++++++++++++++++++++++++++++-- 4 files changed, 85 insertions(+), 4 deletions(-) create mode 100644 test/test_common.py diff --git a/install/setup.iss b/install/setup.iss index 56a4c8a..f328cfd 100644 --- a/install/setup.iss +++ b/install/setup.iss @@ -27,7 +27,7 @@ AppSupportURL=http://houraiteahouse.net AppUpdatesURL=http://houraiteahouse.net DefaultDirName=C:\Games\{#APP_NAME} DefaultGroupName={#APP_NAME} -SetupIconFile=..\img\app.ico +SetupIconFile=..\img\256x256.ico AllowNoIcons=yes ;AppReadmeFile={#README} LicenseFile={#LICENSE} diff --git a/src/config.py b/src/config.py index 2237452..e76b460 100644 --- a/src/config.py +++ b/src/config.py @@ -16,7 +16,7 @@ # clear what objects are available we will specify with __all__ __all__ = ( "TRANSLATION_DIRNAME", "TRANSLATION_DIR", "TRANSLATIONS", - "CONFIG_DIRNAME", "CONFIG_DIR", "CONFIG_FILE", + "CONFIG", "CONFIG_DIRNAME", "CONFIG_DIR", "CONFIG_FILE", "BASE_DIR", "RESOURCE_DIR", "GLOBAL_CONTEXT" ) diff --git a/test/test_common.py b/test/test_common.py new file mode 100644 index 0000000..7791a41 --- /dev/null +++ b/test/test_common.py @@ -0,0 +1,39 @@ +import os +import platform +import sys +from unittest import TestCase, main +from common import sanitize_url, inject_variables, GLOBAL_CONTEXT + + +launcher_endpoint = "https://patch.houraiteahouse.net/{project}/launcher\ +/{platform}/{executable}" + + +class CommonTest(TestCase): + + def test_sanitize_url(self): + self.assertEqual( + sanitize_url("https://this is a test url.com"), + "https://this-is-a-test-url.com") + + def test_inject_variables_using_custom_context(self, custom_context=True): + if custom_context: + context = dict( + platform=platform.system(), + executable=os.path.basename(sys.executable) + ) + endpoint = inject_variables(launcher_endpoint, context) + else: + endpoint = inject_variables(launcher_endpoint) + + self.assertEqual( + endpoint, + "https://patch.houraiteahouse.net/{project}/launcher/%s/%s" % + (platform.system(),os.path.basename(sys.executable) )) + + def test_inject_variables_using_global_context(self): + self.test_inject_variables_using_custom_context(False) + + +if __name__ == "__main__": + main() diff --git a/test/test_util.py b/test/test_util.py index 867796c..ce34a9f 100644 --- a/test/test_util.py +++ b/test/test_util.py @@ -1,12 +1,54 @@ +import os from unittest import TestCase, main -from util import namedtuple_from_mapping +from util import list_files, namedtuple_from_mapping, ProtectedDict,\ + sha256_hash, tupperware class UtilTest(TestCase): - def test_namedtuple_from_mapping_can_succeed(self): + def test_sha256_hash_empty_file(self): + # need to figure out how to mock a file object + pass + + def test_sha256_hash_4kb_of_0xff(self): + # need to figure out how to mock a file object pass + def test_list_files_in_test_directory(self): + splitext = os.path.splitext + this_dir = os.path.dirname(__file__) + filenames = set() + for fullpath, relpath in list_files(this_dir): + relpath = relpath.replace('/', os.path.sep) + self.assertEqual(os.path.join(this_dir, relpath), fullpath) + filenames.add(splitext(os.path.basename(relpath))[0]) + + module_name = splitext(os.path.basename(__file__))[0] + + self.assertTrue(filenames) + self.assertIn(module_name, filenames) + + def test_namedtuple_from_mapping_can_succeed(self): + test_tupp = namedtuple_from_mapping({'attr': 'qwer'}) + self.assertEqual(test_tupp.attr, 'qwer') + + def test_tupperware_is_formatted_properly(self): + test_dict = dict( + list=[0, '1'], + dict={ + 'zxcv': ProtectedDict( + {1234: '1234', '': 'test'} + ) + }, + actual_dict=ProtectedDict({1: 2, 3: 4}) + ) + tupp = tupperware(test_dict) + self.assertIsNot(tupp, test_dict) + self.assertEqual(tupp.list, [0, '1']) + self.assertIsInstance(tupp.dict.zxcv, ProtectedDict) + self.assertEqual(tupp.dict.zxcv, {1234: '1234', '': 'test'}) + self.assertEqual(tupp.actual_dict, {1: 2, 3: 4}) + if __name__ == "__main__": main() From bba2543803250ba6d3af8b3ae2a99f00384535db Mon Sep 17 00:00:00 2001 From: MosesofEgypt Date: Mon, 12 Jun 2017 00:38:08 -0400 Subject: [PATCH 03/16] Added launcher and Launcher directories to gitignore Fixed incorrect icon file name in specs files --- .gitignore | 4 +++- specs/launcher.spec | 2 +- specs/windows_launcher.spec | 2 +- specs/windows_launcher_dir.spec | 2 +- src/common.py | 2 -- src/config.py | 2 -- 6 files changed, 6 insertions(+), 8 deletions(-) diff --git a/.gitignore b/.gitignore index 97dd493..744e49e 100644 --- a/.gitignore +++ b/.gitignore @@ -90,5 +90,7 @@ ENV/ .idea *.iml -# Launcher logs +# Launcher stuff +Launcher/ +launcher/ launcher_log.* \ No newline at end of file diff --git a/specs/launcher.spec b/specs/launcher.spec index e6492e3..c60e0c3 100644 --- a/specs/launcher.spec +++ b/specs/launcher.spec @@ -34,4 +34,4 @@ exe = EXE(pyz, strip=False, upx=True, console=False, - icon="img/app.ico") + icon="img/256x256.ico") diff --git a/specs/windows_launcher.spec b/specs/windows_launcher.spec index c7ca7c8..0c539e1 100644 --- a/specs/windows_launcher.spec +++ b/specs/windows_launcher.spec @@ -36,4 +36,4 @@ exe = EXE(pyz, strip=False, upx=True, console=False, - icon="img/app.ico") + icon="img/256x256.ico") diff --git a/specs/windows_launcher_dir.spec b/specs/windows_launcher_dir.spec index bd5340a..96a78d5 100644 --- a/specs/windows_launcher_dir.spec +++ b/specs/windows_launcher_dir.spec @@ -34,7 +34,7 @@ exe = EXE(pyz, strip=False, upx=True, console=False, - icon="img/app.ico") + icon="img/256x256.ico") coll = COLLECT(exe, a.binaries, a.zipfiles, diff --git a/src/common.py b/src/common.py index daa38d9..7ff85cc 100644 --- a/src/common.py +++ b/src/common.py @@ -30,9 +30,7 @@ def inject_variables(path_format, vars_obj=GLOBAL_CONTEXT): replacement = vars_obj.get(match) else: replacement = getattr(vars_obj, match, None) - if replacement is None: continue - path = path.replace('{%s}' % match, str(replacement)) return path diff --git a/src/config.py b/src/config.py index e76b460..ad47a63 100644 --- a/src/config.py +++ b/src/config.py @@ -12,8 +12,6 @@ from util import namedtuple_from_mapping from collections import OrderedDict -# there are a lot of module-level declarations below, so to make it -# clear what objects are available we will specify with __all__ __all__ = ( "TRANSLATION_DIRNAME", "TRANSLATION_DIR", "TRANSLATIONS", "CONFIG", "CONFIG_DIRNAME", "CONFIG_DIR", "CONFIG_FILE", From 9d075300d9a089db3532117f1060454a8282b096 Mon Sep 17 00:00:00 2001 From: MosesofEgypt Date: Mon, 12 Jun 2017 02:54:28 -0400 Subject: [PATCH 04/16] Implemented config test, though it cant be run until a bug is fixed that is specified in the test Implemented sha256_hash test for util Implemented testing inject_variables using a tupperware Started on test for download module --- test/test_common.py | 27 +++++++++++++++++++-------- test/test_config.py | 35 +++++++++++++++++++++++++++++++++++ test/test_download.py | 17 +++++++++++++++++ test/test_util.py | 21 ++++++++++++++++----- 4 files changed, 87 insertions(+), 13 deletions(-) create mode 100644 test/test_config.py create mode 100644 test/test_download.py diff --git a/test/test_common.py b/test/test_common.py index 7791a41..04f9a74 100644 --- a/test/test_common.py +++ b/test/test_common.py @@ -3,6 +3,7 @@ import sys from unittest import TestCase, main from common import sanitize_url, inject_variables, GLOBAL_CONTEXT +from util import tupperware launcher_endpoint = "https://patch.houraiteahouse.net/{project}/launcher\ @@ -16,12 +17,8 @@ def test_sanitize_url(self): sanitize_url("https://this is a test url.com"), "https://this-is-a-test-url.com") - def test_inject_variables_using_custom_context(self, custom_context=True): - if custom_context: - context = dict( - platform=platform.system(), - executable=os.path.basename(sys.executable) - ) + def _inject_variables_using_custom_context(self, context=None): + if context: endpoint = inject_variables(launcher_endpoint, context) else: endpoint = inject_variables(launcher_endpoint) @@ -29,10 +26,24 @@ def test_inject_variables_using_custom_context(self, custom_context=True): self.assertEqual( endpoint, "https://patch.houraiteahouse.net/{project}/launcher/%s/%s" % - (platform.system(),os.path.basename(sys.executable) )) + (platform.system(), os.path.basename(sys.executable))) + + def test_inject_variables_using_custom_context_dict(self): + context = dict( + platform=platform.system(), + executable=os.path.basename(sys.executable) + ) + self._inject_variables_using_custom_context(context) + + def test_inject_variables_using_custom_context_object(self): + context = tupperware(dict( + platform=platform.system(), + executable=os.path.basename(sys.executable) + )) + self._inject_variables_using_custom_context(context) def test_inject_variables_using_global_context(self): - self.test_inject_variables_using_custom_context(False) + self._inject_variables_using_custom_context() if __name__ == "__main__": diff --git a/test/test_config.py b/test/test_config.py new file mode 100644 index 0000000..36776a3 --- /dev/null +++ b/test/test_config.py @@ -0,0 +1,35 @@ +#import config +from unittest import TestCase, main + +# cannot run this test until a bug is fixed with the config module. +# it is exceptioning when config tries to use the logging module. + +class DownloadTest(TestCase): + def test_config_contains_proper_attributes(self): + # remove return when bug is fixed and decomment the config import above + return + + assert hasattr(config, "TRANSLATION_DIRNAME") + assert hasattr(config, "TRANSLATION_DIR") + assert hasattr(config, "TRANSLATIONS") + assert hasattr(config, "CONFIG") + assert hasattr(config, "CONFIG_DIRNAME") + assert hasattr(config, "CONFIG_DIR") + assert hasattr(config, "CONFIG_FILE") + assert hasattr(config, "BASE_DIR") + assert hasattr(config, "RESOURCE_DIR") + assert hasattr(config, "GLOBAL_CONTEXT") + + CONFIG = config.CONFIG + + assert hasattr(CONFIG, "branches") + assert hasattr(CONFIG, "config_endpoint") + assert hasattr(CONFIG, "launcher_endpoint") + assert hasattr(CONFIG, "index_endpoint") + assert hasattr(CONFIG, "launch_flags") + assert hasattr(CONFIG, "news_rss_feed") + assert hasattr(CONFIG, "game_binary") + assert hasattr(CONFIG, "logo") + +if __name__ == "__main__": + main() diff --git a/test/test_download.py b/test/test_download.py new file mode 100644 index 0000000..f99e43e --- /dev/null +++ b/test/test_download.py @@ -0,0 +1,17 @@ +import requests +from unittest import TestCase, main +from download import download_file, Download, DownloadTracker + + +class DownloadTest(TestCase): + def setUp(self): + # replace functions in the requests library with mocks + pass + + def tearDown(self): + # replace the mocks in the requests library with the real ones + pass + + +if __name__ == "__main__": + main() diff --git a/test/test_util.py b/test/test_util.py index ce34a9f..e65533f 100644 --- a/test/test_util.py +++ b/test/test_util.py @@ -1,5 +1,5 @@ import os -from unittest import TestCase, main +from unittest import TestCase, main, mock from util import list_files, namedtuple_from_mapping, ProtectedDict,\ sha256_hash, tupperware @@ -7,12 +7,23 @@ class UtilTest(TestCase): def test_sha256_hash_empty_file(self): - # need to figure out how to mock a file object - pass + with mock.patch('util.open', mock.mock_open(read_data=b'')) as m: + result_hash = sha256_hash('mockfile_empty') + + m.assert_called_once_with('mockfile_empty', 'rb') + self.assertEqual( + result_hash, + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855") def test_sha256_hash_4kb_of_0xff(self): - # need to figure out how to mock a file object - pass + with mock.patch('util.open', + mock.mock_open(read_data=b'\xFF'*4*(1024**2))) as m: + result_hash = sha256_hash('mockfile_4kb_0xff') + + m.assert_called_once_with('mockfile_4kb_0xff', 'rb') + self.assertEqual( + result_hash, + "cd3517473707d59c3d915b52a3e16213cadce80d9ffb2b4371958fb7acb51a08") def test_list_files_in_test_directory(self): splitext = os.path.splitext From 21b890a4211760a2e891072faf2cfc4a11a8ee6d Mon Sep 17 00:00:00 2001 From: MosesofEgypt Date: Mon, 12 Jun 2017 03:06:39 -0400 Subject: [PATCH 05/16] pep8 --- test/test_config.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/test/test_config.py b/test/test_config.py index 36776a3..6502c96 100644 --- a/test/test_config.py +++ b/test/test_config.py @@ -1,9 +1,10 @@ -#import config +# import config from unittest import TestCase, main # cannot run this test until a bug is fixed with the config module. # it is exceptioning when config tries to use the logging module. + class DownloadTest(TestCase): def test_config_contains_proper_attributes(self): # remove return when bug is fixed and decomment the config import above From 975ed87b6f6f119bf52466d51c6c32b0877c260e Mon Sep 17 00:00:00 2001 From: MosesofEgypt Date: Mon, 12 Jun 2017 20:51:43 -0400 Subject: [PATCH 06/16] Moved attempt to import gettext_windows to where it is used and added logic to prevent accessing it if the import failed Tweaked Download.download_file to allow a comparison hash to be provided to the download_file function Implemented test cases for testing download_file function, Download class, and began implementing for test cases for the DownloadTracker class --- src/config.py | 15 ++-- src/download.py | 5 +- test/test_download.py | 155 ++++++++++++++++++++++++++++++++++++++++-- 3 files changed, 160 insertions(+), 15 deletions(-) diff --git a/src/config.py b/src/config.py index ad47a63..b208d92 100644 --- a/src/config.py +++ b/src/config.py @@ -18,13 +18,6 @@ "BASE_DIR", "RESOURCE_DIR", "GLOBAL_CONTEXT" ) - -if 'win' in platform.platform().lower(): - try: - import gettext_windows - except: - logging.warning('Cannot import gettext_windows') - CONFIG_DIRNAME = 'Launcher' TRANSLATION_DIRNAME = 'i18n' CONFIG_FILE = 'config.json' @@ -58,9 +51,17 @@ RESOURCE_DIR = os.getcwd() logging.info('Resource Directory: %s' % RESOURCE_DIR) +gettext_windows = None if 'win' in platform.platform().lower(): logging.info('Setting Windows enviorment variables for translation...') + try: + import gettext_windows + except: + logging.warning('Cannot import gettext_windows') + +if gettext_windows is not None: gettext_windows.setup_env() + TRANSLATION_DIR = os.path.join(RESOURCE_DIR, TRANSLATION_DIRNAME) TRANSLATIONS = gettext.translation( 'hourai-launcher', TRANSLATION_DIR, fallback=True) diff --git a/src/download.py b/src/download.py index 8e3723c..4da2c37 100644 --- a/src/download.py +++ b/src/download.py @@ -47,11 +47,12 @@ def __init__(self, path, url, download_size): self.total_size = download_size self.downloaded_bytes = 0 - def download_file(self, session=None): + def download_file(self, session=None, filehash=None): return download_file(self.url, self.file_path, block_fun=self._inc_download, - session=session) + session=session, + filehash=filehash) def _inc_download(self, block): self.downloaded_bytes += len(block) diff --git a/test/test_download.py b/test/test_download.py index f99e43e..2da3c33 100644 --- a/test/test_download.py +++ b/test/test_download.py @@ -1,16 +1,159 @@ import requests -from unittest import TestCase, main +from unittest import TestCase, main, mock from download import download_file, Download, DownloadTracker -class DownloadTest(TestCase): +class ResponseMock(object): + data = b'' + status_code = requests.codes['ok'] + + def __init__(self, data): + self.data = data + + def iter_content(self, chunk_size): + assert chunk_size > 0 + for i in range(0, len(self.data), chunk_size): + yield self.data[i: i + chunk_size] + + +class SessionMock(object): + responses = None + data = None + + def __init__(self, data=b''): + self.responses = {} + self.data = data + + def get(self, url, **kwargs): + responses = self.responses[url] = self.responses.get(url, []) + response = ResponseMock(self.data) + responses.append(response) + return response + + +class ExecutorMock(object): + pass + + +class ProgressBarMock(object): + minimum = 0 + maximum = 0 + value = 0 + + def setMinimum(self, val): + self.minimum = val + + def setMaximum(self, val): + self.maximum = val + + def setValue(self, val): + self.value = val + + +class DownloadFileTest(TestCase): + session_mock = None + downloaded_bytes = 0 + def setUp(self): - # replace functions in the requests library with mocks - pass + self.session_mock = SessionMock() + requests.real_get = requests.get + requests.get = self.session_mock.get def tearDown(self): - # replace the mocks in the requests library with the real ones - pass + requests.get = requests.real_get + del requests.real_get + del self.session_mock + if hasattr(self, "downloaded_bytes"): + self.downloaded_bytes = 0 + + def _download_inc(self, block): + self.downloaded_bytes += len(block) + + def _call_download(self, test_path, test_url, + test_data=b'', test_hash=None, session=None): + with mock.patch('download.open', mock.mock_open()) as m: + download_file( + test_url, test_path, self._download_inc, session, test_hash) + + session = self.session_mock + responses = session.responses + self.assertIn(test_url, responses) + self.assertEqual(1, len(responses[test_url])) + self.assertEqual(len(session.data), self.downloaded_bytes) + m.assert_called_once_with(test_path, 'wb+') + + def test_download_file_4kb_of_0xff(self): + test_path = "4kb_0xff_0.bin" + test_url = "http://test-url.com/%s" % test_path + test_data = b'\xff'*4*(1024**2) + test_hash = ( + "cd3517473707d59c3d915b52a3e16213cadce80d9ffb2b4371958fb7acb51a08" + ) + self._call_download(test_path, test_url, test_data, test_hash, + self.session_mock) + + def test_download_file_empty_file(self): + test_path = "empty_0.bin" + test_url = "http://test-url.com/%s" % test_path + test_hash = ( + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + ) + self._call_download(test_path, test_url, b'', test_hash, + self.session_mock) + + def test_download_file_bad_hash(self): + test_path = "empty_1.bin" + test_url = "http://test-url.com/%s" % test_path + test_hash = "badhash" + self._call_download(test_path, test_url, b'', test_hash, + self.session_mock) + + def test_download_file_no_session_provided(self): + test_path = "4kb_0xff_1.bin" + test_url = "http://test-url.com/%s" % test_path + test_data = b'\xff'*4*(1024**2) + self._call_download(test_path, test_url, test_data) + + +class DownloadTest(TestCase): + session_mock = None + + setUp = DownloadFileTest.setUp + + tearDown = DownloadFileTest.tearDown + + def _call_download(self, test_path, test_url, + test_data=b'', test_hash=None, session=None): + downloader = Download(test_path, test_url, len(test_data)) + + with mock.patch('download.open', mock.mock_open()) as m: + downloader.download_file(session) + + session = self.session_mock + responses = session.responses + self.assertIn(test_url, responses) + self.assertEqual(1, len(responses[test_url])) + self.assertEqual(len(session.data), downloader.downloaded_bytes) + m.assert_called_once_with(test_path, 'wb+') + + test_download_4kb_of_0xff = DownloadFileTest.\ + test_download_file_4kb_of_0xff + + test_download_empty_file = DownloadFileTest.\ + test_download_file_empty_file + + test_download_bad_hash = DownloadFileTest.\ + test_download_file_bad_hash + + test_download_no_session_provided = DownloadFileTest.\ + test_download_file_no_session_provided + + +class DownloadTrackerTest(TestCase): + + setUp = DownloadFileTest.setUp + + tearDown = DownloadFileTest.tearDown if __name__ == "__main__": From a62fe3709a0af822d03a5a78fb1b67603a746ee0 Mon Sep 17 00:00:00 2001 From: MosesofEgypt Date: Mon, 12 Jun 2017 23:02:10 -0400 Subject: [PATCH 07/16] Accidentally broke news feed. Will fix later Started refactoring config module into parameterized functions that inject constants to the module at a global level Refactored main into an initialize function Tweaked ui.py to access config.py attributes rather than importing and using them directly --- src/config.py | 213 +++++++++++++++++++++++++++++--------------------- src/main.py | 53 +++++++------ src/ui.py | 16 ++-- 3 files changed, 165 insertions(+), 117 deletions(-) diff --git a/src/config.py b/src/config.py index b208d92..5d45266 100644 --- a/src/config.py +++ b/src/config.py @@ -14,96 +14,135 @@ __all__ = ( "TRANSLATION_DIRNAME", "TRANSLATION_DIR", "TRANSLATIONS", - "CONFIG", "CONFIG_DIRNAME", "CONFIG_DIR", "CONFIG_FILE", + "CONFIG", "CONFIG_DIRNAME", "CONFIG_DIR", "CONFIG_NAME", "BASE_DIR", "RESOURCE_DIR", "GLOBAL_CONTEXT" ) + CONFIG_DIRNAME = 'Launcher' +CONFIG_NAME = 'config.json' TRANSLATION_DIRNAME = 'i18n' -CONFIG_FILE = 'config.json' - -# Get the base directory the executable is found in -# When running from a python interpreter, it will use the current working -# directory. -# sys.frozen is an attribute injected by pyinstaller at runtime -if getattr(sys, 'frozen', False): - BASE_DIR = os.path.dirname(os.path.abspath(sys.executable)) -else: - BASE_DIR = os.getcwd() -log_handler = RotatingFileHandler(os.path.join(BASE_DIR, 'launcher_log.txt'), - backupCount=5) -log_handler.doRollover() -root_logger = logging.getLogger() -root_logger.addHandler(log_handler) -root_logger.setLevel(logging.INFO) -logging.info('Base Directory: %s' % BASE_DIR) -requests_log = logging.getLogger("requests.packages.urllib3") -requests_log.setLevel(logging.DEBUG) - -CONFIG_DIR = os.path.join(BASE_DIR, CONFIG_DIRNAME) -if not os.path.exists(CONFIG_DIR): - os.makedirs(CONFIG_DIR) -logging.info('Config Directory: %s' % CONFIG_DIR) - -if getattr(sys, '_MEIPASS', False): - RESOURCE_DIR = os.path.abspath(sys._MEIPASS) -else: - RESOURCE_DIR = os.getcwd() -logging.info('Resource Directory: %s' % RESOURCE_DIR) - -gettext_windows = None -if 'win' in platform.platform().lower(): - logging.info('Setting Windows enviorment variables for translation...') - try: - import gettext_windows - except: - logging.warning('Cannot import gettext_windows') - -if gettext_windows is not None: - gettext_windows.setup_env() - -TRANSLATION_DIR = os.path.join(RESOURCE_DIR, TRANSLATION_DIRNAME) -TRANSLATIONS = gettext.translation( - 'hourai-launcher', TRANSLATION_DIR, fallback=True) -TRANSLATIONS.install() -logging.info('Translation Directory: %s' % TRANSLATION_DIR) - -# Load Config -config_path = os.path.join(CONFIG_DIR, CONFIG_FILE) -resource_config = os.path.join(RESOURCE_DIR, CONFIG_FILE) -if not os.path.exists(config_path) and os.path.exists(resource_config): - shutil.copyfile(resource_config, config_path) - -logging.info('Loading local config from %s...' % config_path) -with open(config_path, 'r+') as config_file: - # Using OrderedDict to preserve JSON ordering of dictionaries - config_json = json.load(config_file, object_pairs_hook=OrderedDict) - - old_url = None - GLOBAL_CONTEXT['project'] = sanitize_url(config_json['project']) - if 'config_endpoint' in config_json: - url = inject_variables(config_json['config_endpoint']) - while old_url != url: - logging.info('Loading remote config from %s' % url) - try: - response = requests.get(url, timeout=5) - response.raise_for_status() - config_json = response.json() - logging.info('Fetched new config from %s.' % url) - config_file.seek(0) - config_file.truncate() - json.dump(config_json, config_file) - logging.info('Saved new config to disk: %s' % config_path) - except HTTPError as http_error: - logging.error(http_error) - break - except Timeout as timeout: - logging.error(timeout) - break - old_url = url - GLOBAL_CONTEXT['project'] = sanitize_url(config_json['project']) +_LOGGER_SETUP = False + + +def get_config(): + g = globals() + if g.get('CONFIG') is None: + reload_config() + + return CONFIG + + +def install_translations(): + g = globals() + gettext_windows = None + if 'win' in platform.platform().lower(): + logging.info( + 'Setting Windows environment variables for translation...') + try: + import gettext_windows + except: + logging.warning('Cannot import gettext_windows') + + if gettext_windows is not None: + gettext_windows.setup_env() + + g['TRANSLATIONS'] = gettext.translation( + 'hourai-launcher', TRANSLATION_DIR, fallback=True) + TRANSLATIONS.install() + + +def reload_config(): + g = globals() + set_directories() + install_translations() + + # Load Config + config_path = os.path.join(CONFIG_DIR, CONFIG_NAME) + resource_config = os.path.join(RESOURCE_DIR, CONFIG_NAME) + if not os.path.exists(config_path) and os.path.exists(resource_config): + shutil.copyfile(resource_config, config_path) + + logging.info('Loading local config from %s...' % config_path) + with open(config_path, 'r+') as config_file: + # Using OrderedDict to preserve JSON ordering of dictionaries + config_json = json.load(config_file, object_pairs_hook=OrderedDict) + + old_url = None + GLOBAL_CONTEXT['project'] = sanitize_url(config_json['project']) + if 'config_endpoint' in config_json: url = inject_variables(config_json['config_endpoint']) - if 'config_endpoint' not in config_json: - break - CONFIG = namedtuple_from_mapping(config_json) - GLOBAL_CONTEXT['project'] = sanitize_url(config_json['project']) + while old_url != url: + logging.info('Loading remote config from %s' % url) + try: + response = requests.get(url, timeout=5) + response.raise_for_status() + config_json = response.json() + logging.info('Fetched new config from %s.' % url) + config_file.seek(0) + config_file.truncate() + json.dump(config_json, config_file) + logging.info('Saved new config to disk: %s' % config_path) + except HTTPError as http_error: + logging.error(http_error) + break + except Timeout as timeout: + logging.error(timeout) + break + old_url = url + GLOBAL_CONTEXT['project'] = sanitize_url(config_json['project']) + url = inject_variables(config_json['config_endpoint']) + if 'config_endpoint' not in config_json: + break + g['CONFIG'] = namedtuple_from_mapping(config_json) + GLOBAL_CONTEXT['project'] = sanitize_url(config_json['project']) + + return g['CONFIG'] + + +def setup_logger(log_dir, backup_count=5): + g = globals() + log_handler = RotatingFileHandler( + os.path.join(log_dir, 'launcher_log.txt'), + backupCount=backup_count) + log_handler.doRollover() + root_logger = logging.getLogger() + root_logger.addHandler(log_handler) + root_logger.setLevel(logging.INFO) + requests_log = logging.getLogger("requests.packages.urllib3") + requests_log.setLevel(logging.DEBUG) + g['_LOGGER_SETUP'] = True + + +def set_directories(): + g = globals() + # Get the base directory the executable is found in + # When running from a python interpreter, it will use the current working + # directory. + # sys.frozen is an attribute injected by pyinstaller at runtime + if getattr(sys, 'frozen', False): + BASE_DIR = os.path.dirname(os.path.abspath(sys.executable)) + else: + BASE_DIR = os.getcwd() + + if not g.get('_LOGGER_SETUP'): + setup_logger(log_dir=BASE_DIR, backup_count=5) + logging.info('Base Directory: %s' % BASE_DIR) + + if getattr(sys, '_MEIPASS', False): + RESOURCE_DIR = os.path.abspath(sys._MEIPASS) + else: + RESOURCE_DIR = os.getcwd() + logging.info('Resource Directory: %s' % RESOURCE_DIR) + + CONFIG_DIR = os.path.join(BASE_DIR, CONFIG_DIRNAME) + if not os.path.exists(CONFIG_DIR): + os.makedirs(CONFIG_DIR) + logging.info('Config Directory: %s' % CONFIG_DIR) + + TRANSLATION_DIR = os.path.join(RESOURCE_DIR, TRANSLATION_DIRNAME) + logging.info('Translation Directory: %s' % TRANSLATION_DIR) + + # inject the directories into the module globals + g.update(BASE_DIR=BASE_DIR, RESOURCE_DIR=RESOURCE_DIR, + CONFIG_DIR=CONFIG_DIR, TRANSLATION_DIR=TRANSLATION_DIR) diff --git a/src/main.py b/src/main.py index d5b365f..34693ce 100644 --- a/src/main.py +++ b/src/main.py @@ -1,30 +1,39 @@ import logging import os +import config from PyQt5 import QtGui, QtCore -from config import CONFIG, RESOURCE_DIR from ui import MainWindow from common import app, loop -# load all the icons from the img folder into a QIcon object -app_icon = QtGui.QIcon() -for size in (16, 32, 48, 64, 256): - app_icon.addFile( - os.path.join(RESOURCE_DIR, 'img', '%sx%s.ico' % (size, size)), - QtCore.QSize(size, size)) -app.setWindowIcon(app_icon) -main_window = MainWindow(CONFIG) +def initialize(show_main_window=False): + g = globals() -if __name__ == '__main__': - main_window.show() - try: - loop.run_until_complete(main_window.main_loop()) - loop.run_forever() - except RuntimeError as e: - logging.exception(e) - except Exception as e: - print('Hello') - logging.exception(e) - raise - finally: - loop.close() + config.set_directories() + config.install_translations() + + # load all the icons from the img folder into a QIcon object + g['app_icon'] = app_icon = QtGui.QIcon() + for size in (16, 32, 48, 64, 256): + app_icon.addFile( + os.path.join( + config.RESOURCE_DIR, 'img', '%sx%s.ico' % (size, size)), + QtCore.QSize(size, size)) + app.setWindowIcon(app_icon) + g['main_window'] = main_window = MainWindow(config.get_config()) + + if show_main_window: + main_window.show() + try: + loop.run_until_complete(main_window.main_loop()) + loop.run_forever() + except RuntimeError as e: + logging.exception(e) + except Exception as e: + print('Hello') + logging.exception(e) + raise + finally: + loop.close() + +initialize(__name__ == '__main__') diff --git a/src/ui.py b/src/ui.py index 43d27b8..a9ce03a 100644 --- a/src/ui.py +++ b/src/ui.py @@ -1,4 +1,5 @@ import asyncio +import config import logging import os import platform @@ -12,7 +13,6 @@ from babel.dates import format_date from datetime import datetime from time import mktime -from config import BASE_DIR, RESOURCE_DIR from enum import Enum from common import inject_variables, loop, sanitize_url, GLOBAL_CONTEXT from util import sha256_hash, list_files @@ -31,13 +31,13 @@ class Branch(object): - def __init__(self, name, source_branch, config): + def __init__(self, name, source_branch, cfg): self.name = name self.source_branch = source_branch - self.directory = os.path.join(BASE_DIR, name) + self.directory = os.path.join(config.BASE_DIR, name) self.is_indexed = False self.last_fetched = None - self.config = config + self.config = cfg self.files = {} def index_directory(self): @@ -142,12 +142,12 @@ class ClientState(Enum): class MainWindow(QWidget): - def __init__(self, config): + def __init__(self, cfg): super().__init__() - self.config = config + self.config = cfg branches = self.config.branches self.branches = { - name: Branch(name, branch, config) + name: Branch(name, branch, cfg) for branch, name in branches.items() } self.branch_lookup = {v: k for k, v in self.config.branches.items()} @@ -307,7 +307,7 @@ def init_ui(self): self.launch_game_btn.clicked.connect(self.launch_game) - logo = QPixmap(os.path.join(RESOURCE_DIR, self.config.logo)) + logo = QPixmap(os.path.join(config.RESOURCE_DIR, self.config.logo)) logo = logo.scaledToWidth(WIDTH) logo_label = QLabel() logo_label.setPixmap(logo) From ca4cf81ddfd34ca74a8233a1b133f6e4dfd2ea0f Mon Sep 17 00:00:00 2001 From: MosesofEgypt Date: Mon, 12 Jun 2017 23:40:47 -0400 Subject: [PATCH 08/16] news still broken. fixes to prevent accidentally calling setup_directories and install_translations more than once --- src/config.py | 23 +++++++++++++++++------ src/main.py | 2 +- 2 files changed, 18 insertions(+), 7 deletions(-) diff --git a/src/config.py b/src/config.py index 5d45266..52ac35a 100644 --- a/src/config.py +++ b/src/config.py @@ -22,12 +22,13 @@ CONFIG_DIRNAME = 'Launcher' CONFIG_NAME = 'config.json' TRANSLATION_DIRNAME = 'i18n' +_TRANSLATIONS_INSTALLED = False +_DIRECTORIES_SETUP = False _LOGGER_SETUP = False def get_config(): - g = globals() - if g.get('CONFIG') is None: + if globals().get('CONFIG') is None: reload_config() return CONFIG @@ -35,6 +36,9 @@ def get_config(): def install_translations(): g = globals() + if g.get('_TRANSLATIONS_INSTALLED'): + return + gettext_windows = None if 'win' in platform.platform().lower(): logging.info( @@ -50,11 +54,12 @@ def install_translations(): g['TRANSLATIONS'] = gettext.translation( 'hourai-launcher', TRANSLATION_DIR, fallback=True) TRANSLATIONS.install() + g['_TRANSLATIONS_INSTALLED'] = True def reload_config(): g = globals() - set_directories() + setup_directories() install_translations() # Load Config @@ -102,6 +107,9 @@ def reload_config(): def setup_logger(log_dir, backup_count=5): g = globals() + if g.get('_LOGGER_SETUP'): + return + log_handler = RotatingFileHandler( os.path.join(log_dir, 'launcher_log.txt'), backupCount=backup_count) @@ -114,8 +122,10 @@ def setup_logger(log_dir, backup_count=5): g['_LOGGER_SETUP'] = True -def set_directories(): +def setup_directories(): g = globals() + if g.get('_DIRECTORIES_SETUP'): + return # Get the base directory the executable is found in # When running from a python interpreter, it will use the current working # directory. @@ -125,8 +135,7 @@ def set_directories(): else: BASE_DIR = os.getcwd() - if not g.get('_LOGGER_SETUP'): - setup_logger(log_dir=BASE_DIR, backup_count=5) + setup_logger(log_dir=BASE_DIR, backup_count=5) logging.info('Base Directory: %s' % BASE_DIR) if getattr(sys, '_MEIPASS', False): @@ -146,3 +155,5 @@ def set_directories(): # inject the directories into the module globals g.update(BASE_DIR=BASE_DIR, RESOURCE_DIR=RESOURCE_DIR, CONFIG_DIR=CONFIG_DIR, TRANSLATION_DIR=TRANSLATION_DIR) + + g['_DIRECTORIES_SETUP'] = True diff --git a/src/main.py b/src/main.py index 34693ce..a754f26 100644 --- a/src/main.py +++ b/src/main.py @@ -9,7 +9,7 @@ def initialize(show_main_window=False): g = globals() - config.set_directories() + config.setup_directories() config.install_translations() # load all the icons from the img folder into a QIcon object From b19981538af7e5a5e482f2266791b5be86bfa6f6 Mon Sep 17 00:00:00 2001 From: James Liu Date: Tue, 13 Jun 2017 00:04:53 -0700 Subject: [PATCH 09/16] Only deploy on master --- scripts/deploy_win.bat | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/scripts/deploy_win.bat b/scripts/deploy_win.bat index cb5eb5e..6ecac12 100755 --- a/scripts/deploy_win.bat +++ b/scripts/deploy_win.bat @@ -1,8 +1,9 @@ +if %APPVEYOR_REPO_BRANCH% == "master" ( + for /r %%i in (dist/*) do echo %%i -for /r %%i in (dist/*) do echo %%i - -for %%f in (dist/*) do ( -curl.exe -i -X POST "%DEPLOY_UPLOAD_URL%/%APPVEYOR_REPO_BRANCH%/Windows?token=%TOKEN%" ^ - -F "file=@dist/%%f" ^ - --keepalive-time 2 + for %%f in (dist/*) do ( + curl.exe -i -X POST "%DEPLOY_UPLOAD_URL%/%APPVEYOR_REPO_BRANCH%/Windows?token=%TOKEN%" ^ + -F "file=@dist/%%f" ^ + --keepalive-time 2 + ) ) From 3050c35e7999275ba289cb87ab2e3bdb84ebcc1c Mon Sep 17 00:00:00 2001 From: James Liu Date: Tue, 13 Jun 2017 00:14:25 -0700 Subject: [PATCH 10/16] Debug Appveyor deployments --- scripts/deploy_win.bat | 2 ++ 1 file changed, 2 insertions(+) diff --git a/scripts/deploy_win.bat b/scripts/deploy_win.bat index 6ecac12..d42b6c5 100755 --- a/scripts/deploy_win.bat +++ b/scripts/deploy_win.bat @@ -1,3 +1,5 @@ +echo "Branch: %APPVEYOR_REPO_BRANCH%" +echo "Pull Request: %APPVEYOR_PULL_REQUEST_NUMBER%" if %APPVEYOR_REPO_BRANCH% == "master" ( for /r %%i in (dist/*) do echo %%i From 52de2ff06bee36ae9543fcad04c2ce8491f41511 Mon Sep 17 00:00:00 2001 From: MosesofEgypt Date: Tue, 13 Jun 2017 04:14:42 -0400 Subject: [PATCH 11/16] Removed Launcher folder from repo Added cover folder to gitignore Added some todo markers Started on test_ui Implemented most of test_main Fixed bugs with _call_download in test_download where it did not actually set the data of the session to what was provided Implemented most tests in test_config and left others as stubs Removed get_config from config Added logic around all calls to logger in config to ensure they aren't called if the logger isnt setup, otherwise everything will be printed out to the console instead of the file --- .gitignore | 5 +- Launcher/config.json | 1 - src/config.py | 83 ++++++++++++++-------- src/main.py | 8 +-- test/test_config.py | 161 ++++++++++++++++++++++++++++++++++++------ test/test_download.py | 18 ++++- test/test_main.py | 84 ++++++++++++++++++++++ test/test_ui.py | 17 +++++ 8 files changed, 319 insertions(+), 58 deletions(-) delete mode 100644 Launcher/config.json create mode 100644 test/test_main.py create mode 100644 test/test_ui.py diff --git a/.gitignore b/.gitignore index 744e49e..2f2c720 100644 --- a/.gitignore +++ b/.gitignore @@ -93,4 +93,7 @@ ENV/ # Launcher stuff Launcher/ launcher/ -launcher_log.* \ No newline at end of file +launcher_log.* + +# Coverage tests +cover/ \ No newline at end of file diff --git a/Launcher/config.json b/Launcher/config.json deleted file mode 100644 index cb12bbf..0000000 --- a/Launcher/config.json +++ /dev/null @@ -1 +0,0 @@ -{"branches": {"develop": "Development"}, "config_endpoint": "https://patch.houraiteahouse.net/{project}/launcher/config.json", "launcher_endpoint": "https://patch.houraiteahouse.net/{project}/launcher/{platform}/{executable}", "project": "Fantasy Crescendo", "index_endpoint": "https://patch.houraiteahouse.net/{project}/{branch}/{platform}/index.json", "launch_flags": {}, "news_rss_feed": "https://www.reddit.com/r/touhou.rss", "game_binary": {"Linux": "fc.x86_64", "Windows": "fc.exe"}, "logo": "img/logo.png"} \ No newline at end of file diff --git a/src/config.py b/src/config.py index 52ac35a..e79d784 100644 --- a/src/config.py +++ b/src/config.py @@ -7,15 +7,15 @@ import logging import platform from logging.handlers import RotatingFileHandler -from requests.exceptions import HTTPError +from requests.exceptions import HTTPError, Timeout from common import inject_variables, GLOBAL_CONTEXT, sanitize_url from util import namedtuple_from_mapping from collections import OrderedDict __all__ = ( - "TRANSLATION_DIRNAME", "TRANSLATION_DIR", "TRANSLATIONS", - "CONFIG", "CONFIG_DIRNAME", "CONFIG_DIR", "CONFIG_NAME", - "BASE_DIR", "RESOURCE_DIR", "GLOBAL_CONTEXT" + "TRANSLATION_DIR", "TRANSLATION_DIRNAME", "TRANSLATIONS", + "CONFIG_DIR", "CONFIG_DIRNAME", "CONFIG_NAME", "CONFIG", + "BASE_DIR", "RESOURCE_DIR", "GLOBAL_CONTEXT", "ROOT_LOGGER" ) @@ -27,13 +27,6 @@ _LOGGER_SETUP = False -def get_config(): - if globals().get('CONFIG') is None: - reload_config() - - return CONFIG - - def install_translations(): g = globals() if g.get('_TRANSLATIONS_INSTALLED'): @@ -41,12 +34,14 @@ def install_translations(): gettext_windows = None if 'win' in platform.platform().lower(): - logging.info( - 'Setting Windows environment variables for translation...') + if _LOGGER_SETUP: + logging.info( + 'Setting Windows environment variables for translation...') try: import gettext_windows except: - logging.warning('Cannot import gettext_windows') + if _LOGGER_SETUP: + logging.warning('Cannot import gettext_windows') if gettext_windows is not None: gettext_windows.setup_env() @@ -57,8 +52,15 @@ def install_translations(): g['_TRANSLATIONS_INSTALLED'] = True +def load_config(): + if globals().get('CONFIG') is None: + reload_config() + return CONFIG + + def reload_config(): g = globals() + setup_logger(backup_count=5) setup_directories() install_translations() @@ -68,7 +70,8 @@ def reload_config(): if not os.path.exists(config_path) and os.path.exists(resource_config): shutil.copyfile(resource_config, config_path) - logging.info('Loading local config from %s...' % config_path) + if _LOGGER_SETUP: + logging.info('Loading local config from %s' % config_path) with open(config_path, 'r+') as config_file: # Using OrderedDict to preserve JSON ordering of dictionaries config_json = json.load(config_file, object_pairs_hook=OrderedDict) @@ -78,24 +81,31 @@ def reload_config(): if 'config_endpoint' in config_json: url = inject_variables(config_json['config_endpoint']) while old_url != url: - logging.info('Loading remote config from %s' % url) + if _LOGGER_SETUP: + logging.info('Loading remote config from %s' % url) try: response = requests.get(url, timeout=5) response.raise_for_status() config_json = response.json() - logging.info('Fetched new config from %s.' % url) + if _LOGGER_SETUP: + logging.info('Fetched new config from %s.' % url) config_file.seek(0) config_file.truncate() json.dump(config_json, config_file) - logging.info('Saved new config to disk: %s' % config_path) + if _LOGGER_SETUP: + logging.info( + 'Saved new config to disk: %s' % config_path) except HTTPError as http_error: - logging.error(http_error) + if _LOGGER_SETUP: + logging.error(http_error) break except Timeout as timeout: - logging.error(timeout) + if _LOGGER_SETUP: + logging.error(timeout) break old_url = url - GLOBAL_CONTEXT['project'] = sanitize_url(config_json['project']) + GLOBAL_CONTEXT['project'] = sanitize_url( + config_json['project']) url = inject_variables(config_json['config_endpoint']) if 'config_endpoint' not in config_json: break @@ -105,16 +115,26 @@ def reload_config(): return g['CONFIG'] -def setup_logger(log_dir, backup_count=5): +def setup_logger(backup_count=5): g = globals() + if g.get('_LOGGER_SETUP'): return + if g.get('BASE_DIR') is not None: + log_dir = BASE_DIR + elif getattr(sys, 'frozen', False): + log_dir = os.path.dirname(os.path.abspath(sys.executable)) + else: + log_dir = os.getcwd() + log_handler = RotatingFileHandler( os.path.join(log_dir, 'launcher_log.txt'), backupCount=backup_count) log_handler.doRollover() - root_logger = logging.getLogger() + root_logger = g['ROOT_LOGGER'] = logging.getLogger() + # adding the log_handler to the root_logger is causing any unit tests + # of this module to spit loads of logging errors. tests pass though... root_logger.addHandler(log_handler) root_logger.setLevel(logging.INFO) requests_log = logging.getLogger("requests.packages.urllib3") @@ -134,26 +154,31 @@ def setup_directories(): BASE_DIR = os.path.dirname(os.path.abspath(sys.executable)) else: BASE_DIR = os.getcwd() - - setup_logger(log_dir=BASE_DIR, backup_count=5) - logging.info('Base Directory: %s' % BASE_DIR) + if _LOGGER_SETUP: + logging.info('Base Directory: %s' % BASE_DIR) if getattr(sys, '_MEIPASS', False): RESOURCE_DIR = os.path.abspath(sys._MEIPASS) else: RESOURCE_DIR = os.getcwd() - logging.info('Resource Directory: %s' % RESOURCE_DIR) + if _LOGGER_SETUP: + logging.info('Resource Directory: %s' % RESOURCE_DIR) CONFIG_DIR = os.path.join(BASE_DIR, CONFIG_DIRNAME) if not os.path.exists(CONFIG_DIR): os.makedirs(CONFIG_DIR) - logging.info('Config Directory: %s' % CONFIG_DIR) + if _LOGGER_SETUP: + logging.info('Config Directory: %s' % CONFIG_DIR) TRANSLATION_DIR = os.path.join(RESOURCE_DIR, TRANSLATION_DIRNAME) - logging.info('Translation Directory: %s' % TRANSLATION_DIR) + if _LOGGER_SETUP: + logging.info('Translation Directory: %s' % TRANSLATION_DIR) # inject the directories into the module globals g.update(BASE_DIR=BASE_DIR, RESOURCE_DIR=RESOURCE_DIR, CONFIG_DIR=CONFIG_DIR, TRANSLATION_DIR=TRANSLATION_DIR) g['_DIRECTORIES_SETUP'] = True + +setup_directories() +install_translations() diff --git a/src/main.py b/src/main.py index a754f26..a36bc64 100644 --- a/src/main.py +++ b/src/main.py @@ -1,6 +1,6 @@ +import config import logging import os -import config from PyQt5 import QtGui, QtCore from ui import MainWindow from common import app, loop @@ -8,7 +8,6 @@ def initialize(show_main_window=False): g = globals() - config.setup_directories() config.install_translations() @@ -20,7 +19,7 @@ def initialize(show_main_window=False): config.RESOURCE_DIR, 'img', '%sx%s.ico' % (size, size)), QtCore.QSize(size, size)) app.setWindowIcon(app_icon) - g['main_window'] = main_window = MainWindow(config.get_config()) + g['main_window'] = main_window = MainWindow(config.load_config()) if show_main_window: main_window.show() @@ -36,4 +35,5 @@ def initialize(show_main_window=False): finally: loop.close() -initialize(__name__ == '__main__') +if __name__ == '__main__': + initialize(True) diff --git a/test/test_config.py b/test/test_config.py index 6502c96..326441e 100644 --- a/test/test_config.py +++ b/test/test_config.py @@ -1,36 +1,157 @@ -# import config -from unittest import TestCase, main +import config +import requests +import os +import sys +from logging.handlers import RotatingFileHandler +from unittest import TestCase, main, mock +from test_download import SessionMock -# cannot run this test until a bug is fixed with the config module. -# it is exceptioning when config tries to use the logging module. +config_test_data = """ +{ +"project": "Fantasy Crescendo", +"logo": "img/logo.png", +"config_endpoint": "https://patch.houraiteahouse.net/{project}/launcher/\ +config.json", +"launcher_endpoint": "https://patch.houraiteahouse.net/{project}/launcher/\ +{platform}/{executable}", +"index_endpoint": "https://patch.houraiteahouse.net/{project}/{branch}/\ +{platform}/index.json", +"news_rss_feed": "https://www.reddit.com/r/touhou.rss", +"game_binary": { + "Windows": "fc.exe", + "Linux": "fc.x86_64" +}, +"launch_flags":{ +}, +"branches" : { + "develop": "Development" + } +} +""" -class DownloadTest(TestCase): +class RotatingFileHandlerMock(RotatingFileHandler): + + def doRollover(self): + pass + + def rotate(self, source, dest): + pass + + def close(self): + pass + + def _open(self): + pass + + def emit(self, record): + pass + + +class ConfigTest(TestCase): + session_mock = None + + def setUp(self): + config.RotatingFileHandler = RotatingFileHandlerMock + + self.session_mock = SessionMock() + self.session_mock.data = config_test_data + requests.real_get = requests.get + requests.get = self.session_mock.get + + def tearDown(self): + config._LOGGER_SETUP = False + config._DIRECTORIES_SETUP = False + config._TRANSLATIONS_INSTALLED = False + config.CONFIG = None + config.TRANSLATION_DIR = None + config.TRANSLATIONS = None + config.CONFIG_DIR = None + config.BASE_DIR = None + config.RESOURCE_DIR = None + + config.RotatingFileHandler = RotatingFileHandler + requests.get = requests.real_get + del requests.real_get + del self.session_mock + + def test_directories_are_properly_setup(self): + config.setup_directories() + + if getattr(sys, 'frozen', False): + BASE_DIR = os.path.dirname(os.path.abspath(sys.executable)) + else: + BASE_DIR = os.getcwd() + + if getattr(sys, '_MEIPASS', False): + RESOURCE_DIR = os.path.abspath(sys._MEIPASS) + else: + RESOURCE_DIR = os.getcwd() + + CONFIG_DIR = os.path.join(BASE_DIR, config.CONFIG_DIRNAME) + TRANSLATION_DIR = os.path.join( + RESOURCE_DIR, config.TRANSLATION_DIRNAME) + + self.assertEqual(config.BASE_DIR, BASE_DIR) + self.assertEqual(config.RESOURCE_DIR, RESOURCE_DIR) + self.assertEqual(config.CONFIG_DIR, CONFIG_DIR) + self.assertEqual(config.TRANSLATION_DIR, TRANSLATION_DIR) + + self.assertTrue(config._DIRECTORIES_SETUP) + + def test_loggers_are_properly_setup(self): + # TODO: + # until the bug with the logger can be fixed, the logger wont be setup. + # also need to determine how to detect if it has been set up + pass + + def test_translations_are_properly_installed(self): + # TODO: + # need to figure out how to determine if they are installed + config.install_translations() + + self.assertTrue(config._TRANSLATIONS_INSTALLED) + + def test_config_can_load_config(self): + with mock.patch('config.open', mock.mock_open( + read_data=config_test_data)) as m: + config.load_config() + + session = self.session_mock + responses = session.responses + self.assertIn( + "https://patch.houraiteahouse.net/fantasy-crescendo/" + "launcher/config.json", responses) + + m.assert_called_once_with( + os.path.join(config.CONFIG_DIR, config.CONFIG_NAME), 'r+') + def test_config_contains_proper_attributes(self): - # remove return when bug is fixed and decomment the config import above - return + with mock.patch('config.open', mock.mock_open( + read_data=config_test_data)) as m: + cfg = config.load_config() - assert hasattr(config, "TRANSLATION_DIRNAME") assert hasattr(config, "TRANSLATION_DIR") + assert hasattr(config, "TRANSLATION_DIRNAME") assert hasattr(config, "TRANSLATIONS") - assert hasattr(config, "CONFIG") - assert hasattr(config, "CONFIG_DIRNAME") assert hasattr(config, "CONFIG_DIR") - assert hasattr(config, "CONFIG_FILE") + assert hasattr(config, "CONFIG_DIRNAME") + assert hasattr(config, "CONFIG_NAME") + assert hasattr(config, "CONFIG") assert hasattr(config, "BASE_DIR") assert hasattr(config, "RESOURCE_DIR") assert hasattr(config, "GLOBAL_CONTEXT") - CONFIG = config.CONFIG + assert hasattr(cfg, "branches") + assert hasattr(cfg, "config_endpoint") + assert hasattr(cfg, "launcher_endpoint") + assert hasattr(cfg, "index_endpoint") + assert hasattr(cfg, "launch_flags") + assert hasattr(cfg, "news_rss_feed") + assert hasattr(cfg, "game_binary") + assert hasattr(cfg, "logo") + assert hasattr(cfg, "project") - assert hasattr(CONFIG, "branches") - assert hasattr(CONFIG, "config_endpoint") - assert hasattr(CONFIG, "launcher_endpoint") - assert hasattr(CONFIG, "index_endpoint") - assert hasattr(CONFIG, "launch_flags") - assert hasattr(CONFIG, "news_rss_feed") - assert hasattr(CONFIG, "game_binary") - assert hasattr(CONFIG, "logo") if __name__ == "__main__": main() diff --git a/test/test_download.py b/test/test_download.py index 2da3c33..edb4523 100644 --- a/test/test_download.py +++ b/test/test_download.py @@ -15,6 +15,12 @@ def iter_content(self, chunk_size): for i in range(0, len(self.data), chunk_size): yield self.data[i: i + chunk_size] + def raise_for_status(self): + pass + + def json(self): + return eval(self.data) + class SessionMock(object): responses = None @@ -71,15 +77,16 @@ def _download_inc(self, block): def _call_download(self, test_path, test_url, test_data=b'', test_hash=None, session=None): + session = self.session_mock + session.data = test_data with mock.patch('download.open', mock.mock_open()) as m: download_file( test_url, test_path, self._download_inc, session, test_hash) - session = self.session_mock responses = session.responses self.assertIn(test_url, responses) self.assertEqual(1, len(responses[test_url])) - self.assertEqual(len(session.data), self.downloaded_bytes) + self.assertEqual(len(test_data), self.downloaded_bytes) m.assert_called_once_with(test_path, 'wb+') def test_download_file_4kb_of_0xff(self): @@ -125,6 +132,8 @@ class DownloadTest(TestCase): def _call_download(self, test_path, test_url, test_data=b'', test_hash=None, session=None): downloader = Download(test_path, test_url, len(test_data)) + session = self.session_mock + session.data = test_data with mock.patch('download.open', mock.mock_open()) as m: downloader.download_file(session) @@ -133,7 +142,7 @@ def _call_download(self, test_path, test_url, responses = session.responses self.assertIn(test_url, responses) self.assertEqual(1, len(responses[test_url])) - self.assertEqual(len(session.data), downloader.downloaded_bytes) + self.assertEqual(len(test_data), downloader.downloaded_bytes) m.assert_called_once_with(test_path, 'wb+') test_download_4kb_of_0xff = DownloadFileTest.\ @@ -155,6 +164,9 @@ class DownloadTrackerTest(TestCase): tearDown = DownloadFileTest.tearDown + # TODO: + # write tests for this class + if __name__ == "__main__": main() diff --git a/test/test_main.py b/test/test_main.py new file mode 100644 index 0000000..371f12c --- /dev/null +++ b/test/test_main.py @@ -0,0 +1,84 @@ +import config +import main +from test_config import RotatingFileHandler, RotatingFileHandlerMock +from unittest import TestCase, mock, main as unittest_main + + +class AppMock(object): + + def setWindowIcon(self, app_icon): + pass + + +class LoopMock(object): + exception_type = None + + def run_until_complete(self, main_window): + pass + + def run_forever(self): + if self.exception_type is not None: + raise self.exception_type() + + def close(self): + pass + + +class MainWindowMock(object): + + def __init__(self, config): + pass + + def main_loop(self): + pass + + def show(self): + pass + + +class MainTest(TestCase): + + def setUp(self): + config.RotatingFileHandler = RotatingFileHandlerMock + main.real_app = main.app + main.real_loop = main.loop + main.real_MainWindow = main.MainWindow + main.app = AppMock() + main.loop = LoopMock() + main.MainWindow = MainWindowMock + + def tearDown(self): + config.RotatingFileHandler = RotatingFileHandler + main.app = main.real_app + main.loop = main.real_loop + main.MainWindow = main.real_MainWindow + del main.real_app + del main.real_loop + del main.real_MainWindow + + def test_main_can_initialize(self): + main.initialize() + assert hasattr(main, "app_icon") + assert hasattr(main, "main_window") + + # TODO: + # check that the icon isn't empty somehow + + def test_main_can_show_main_window(self): + main.initialize(True) + + def test_main_can_catch_runtime_error(self): + main.loop.exception_type = RuntimeError + main.initialize(True) + + def test_main_will_raise_general_exception(self): + main.loop.exception_type = Exception + try: + main.initialize(True) + except Exception as e: + if type(e) is not main.loop.exception_type: + raise + + +if __name__ == "__main__": + unittest_main() diff --git a/test/test_ui.py b/test/test_ui.py new file mode 100644 index 0000000..cd90311 --- /dev/null +++ b/test/test_ui.py @@ -0,0 +1,17 @@ +import config +import ui +from test_config import RotatingFileHandler, RotatingFileHandlerMock +from unittest import TestCase, mock, main + + +class UiTest(TestCase): + + def setUp(self): + config.RotatingFileHandler = RotatingFileHandlerMock + + def tearDown(self): + config.RotatingFileHandler = RotatingFileHandler + + +if __name__ == "__main__": + main() From 75a40d34583b7731171e0f807af5169e79e278ec Mon Sep 17 00:00:00 2001 From: james7132 Date: Tue, 13 Jun 2017 20:18:05 +0000 Subject: [PATCH 12/16] Try turning off view for Linux builds --- .travis.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.travis.yml b/.travis.yml index 5c9dbe0..ad6cfb9 100644 --- a/.travis.yml +++ b/.travis.yml @@ -8,6 +8,9 @@ matrix: cache: pip dist: trusty sudo: false + env: + # Required to disable QXcbConnection errors/warnings + - QT_QPA_PLATFORM=offscreen # Use generic language for osx - os: osx language: generic From 30e2cf37e47153c66080c751026d330068e8ae79 Mon Sep 17 00:00:00 2001 From: MosesofEgypt Date: Tue, 13 Jun 2017 19:04:18 -0400 Subject: [PATCH 13/16] Refactored icon setting code out of main and into common Created get_app and get_loop functions for common to allow getting the app and/or loop, while creating them if they dont already exist Implemented new tests in test_common for the new functions Added placeholder call to setup_logger in test_config since it no longer spits loads of logging errors if called --- src/common.py | 49 ++++++++++++++++++++++++-- src/main.py | 49 +++++++++----------------- src/ui.py | 18 +++++----- test/test_common.py | 50 ++++++++++++++++++++++++++- test/test_config.py | 2 +- test/test_main.py | 84 --------------------------------------------- 6 files changed, 122 insertions(+), 130 deletions(-) delete mode 100644 test/test_main.py diff --git a/src/common.py b/src/common.py index 7ff85cc..dd3ca46 100644 --- a/src/common.py +++ b/src/common.py @@ -4,11 +4,11 @@ import platform import re from quamash import QEventLoop +from PyQt5 import QtGui, QtCore from PyQt5.QtWidgets import QApplication -app = QApplication(sys.argv) -loop = QEventLoop(app) -asyncio.set_event_loop(loop) +ICON_SIZES = (16, 32, 48, 64, 256) + GLOBAL_CONTEXT = { 'platform': platform.system(), 'executable': os.path.basename(sys.executable) @@ -17,6 +17,49 @@ vars_regex = re.compile('{(.*?)}') +def get_app(): + g = globals() + if g.get('app') is None: + g['app'] = QApplication(sys.argv) + + return app + + +def get_loop(): + g = globals() + if g.get('loop') is None: + if g.get('app') is None: + raise NameError("cannot create loop without an app to bind it to.") + new_loop = QEventLoop(app) + asyncio.set_event_loop(new_loop) + g['loop'] = new_loop + + return loop + + +def set_app_icon(): + # config relies on common, and this function in common relies on config. + # gotta break the import loop somewhere, so this seems like a good place. + # might be more preferrable to make a dummy config attribute and have the + # launcher's __init__.py set up a circular link between both modules. + # that might not work well when running tests though... + import config + g = globals() + + if g.get('app') is None: + raise NameError("'app' is not defined. cannot set its icon.") + # load all the icons from the img folder into a QIcon object + app_icon = QtGui.QIcon() + for size in ICON_SIZES: + app_icon.addFile( + os.path.join( + config.RESOURCE_DIR, 'img', '%sx%s.ico' % (size, size)), + QtCore.QSize(size, size)) + + g['app_icon'] = app_icon + app.setWindowIcon(app_icon) + + def sanitize_url(url): return url.lower().replace(' ', '-') diff --git a/src/main.py b/src/main.py index a36bc64..9539572 100644 --- a/src/main.py +++ b/src/main.py @@ -1,39 +1,24 @@ import config import logging import os -from PyQt5 import QtGui, QtCore from ui import MainWindow -from common import app, loop +from common import get_app, get_loop, set_app_icon - -def initialize(show_main_window=False): - g = globals() - config.setup_directories() - config.install_translations() - - # load all the icons from the img folder into a QIcon object - g['app_icon'] = app_icon = QtGui.QIcon() - for size in (16, 32, 48, 64, 256): - app_icon.addFile( - os.path.join( - config.RESOURCE_DIR, 'img', '%sx%s.ico' % (size, size)), - QtCore.QSize(size, size)) - app.setWindowIcon(app_icon) - g['main_window'] = main_window = MainWindow(config.load_config()) - - if show_main_window: - main_window.show() - try: - loop.run_until_complete(main_window.main_loop()) - loop.run_forever() - except RuntimeError as e: - logging.exception(e) - except Exception as e: - print('Hello') - logging.exception(e) - raise - finally: - loop.close() +app = get_app() +loop = get_loop() +set_app_icon() +main_window = MainWindow(config.load_config()) if __name__ == '__main__': - initialize(True) + main_window.show() + try: + loop.run_until_complete(main_window.main_loop()) + loop.run_forever() + except RuntimeError as e: + logging.exception(e) + except Exception as e: + print('Hello') + logging.exception(e) + raise + finally: + loop.close() diff --git a/src/ui.py b/src/ui.py index a9ce03a..98fe662 100644 --- a/src/ui.py +++ b/src/ui.py @@ -14,7 +14,7 @@ from datetime import datetime from time import mktime from enum import Enum -from common import inject_variables, loop, sanitize_url, GLOBAL_CONTEXT +from common import inject_variables, get_loop, sanitize_url, GLOBAL_CONTEXT from util import sha256_hash, list_files from download import DownloadTracker from quamash import QThreadExecutor @@ -97,7 +97,7 @@ def _preclean_branch_directory(self, download_tracker): shutil.rmtree(path) def fetch_remote_index(self, context, download_tracker): - asyncio.set_event_loop(loop) + asyncio.set_event_loop(get_loop()) branch_context = dict(context) branch_context["branch"] = self.source_branch url = inject_variables(self.config.index_endpoint, branch_context) @@ -230,9 +230,9 @@ async def launcher_update_check(self): hash_url = url + '.hash' logging.info('Fetching remote hash from: %s' % hash_url) # TODO(james7132): Do proper error checking - response = await loop.run_in_executor(self.executor, - requests.get, - hash_url) + response = await get_loop().run_in_executor(self.executor, + requests.get, + hash_url) remote_launcher_hash = response.text logging.info('Remote launcher hash: %s' % remote_launcher_hash) if remote_launcher_hash == launcher_hash: @@ -267,7 +267,7 @@ async def game_status_check(self): self.launch_game_btn.setEnabled(False) start = time.time() await asyncio.gather(*[ - loop.run_in_executor(self.executor, branch.index_directory) + get_loop().run_in_executor(self.executor, branch.index_directory) for branch in self.branches.values()]) logging.info('Local installation check completed.') logging.info('Game status check took %s seconds.' % (time.time() - @@ -278,9 +278,9 @@ async def game_update_check(self): self.launch_game_btn.hide() self.progress_bar.show() logging.info('Checking for remote game updates...') - downloads = [loop.run_in_executor(self.executor, - branch.fetch_remote_index, - self.context, self.download_tracker) + downloads = [get_loop().run_in_executor( + self.executor, branch.fetch_remote_index, + self.context, self.download_tracker) for branch in self.branches.values()] await asyncio.gather(*downloads) logging.info('Remote game update check completed.') diff --git a/test/test_common.py b/test/test_common.py index 04f9a74..ba97855 100644 --- a/test/test_common.py +++ b/test/test_common.py @@ -1,8 +1,13 @@ +import common import os import platform import sys from unittest import TestCase, main -from common import sanitize_url, inject_variables, GLOBAL_CONTEXT +from unittest.case import _UnexpectedSuccess +from common import get_app, get_loop, set_app_icon, ICON_SIZES, sanitize_url,\ + inject_variables, GLOBAL_CONTEXT +from PyQt5.QtWidgets import QApplication +from quamash import QEventLoop from util import tupperware @@ -12,6 +17,49 @@ class CommonTest(TestCase): + def test_get_loop_fails_without_app(self): + common.app = None + common.loop = None + try: + loop = get_loop() + raise _UnexpectedSuccess + except NameError: + pass + + def test_can_get_app(self): + app = get_app() + self.assertTrue(app) + + # make sure if it is called again, the loop is the same object + self.assertIs(get_app(), app) + + def test_can_get_loop(self): + loop = get_loop() + self.assertTrue(loop) + + # make sure if it is called again, the loop is the same object + self.assertIs(get_loop(), loop) + + def test_cannot_set_icon_without_app(self): + common.app = None + try: + set_app_icon() + raise _UnexpectedSuccess + except NameError: + pass + + def test_app_icon_has_all_sizes(self): + common.app = QApplication(sys.argv) + common.loop = QEventLoop(common.app) + set_app_icon() + + qicon_sizes = common.app_icon.availableSizes() + self.assertEqual(len(ICON_SIZES), len(qicon_sizes)) + + for q_size in qicon_sizes: + self.assertTrue(q_size.height() == q_size.width()) + self.assertIn(q_size.height(), ICON_SIZES) + def test_sanitize_url(self): self.assertEqual( sanitize_url("https://this is a test url.com"), diff --git a/test/test_config.py b/test/test_config.py index 326441e..a2cd04c 100644 --- a/test/test_config.py +++ b/test/test_config.py @@ -103,7 +103,7 @@ def test_loggers_are_properly_setup(self): # TODO: # until the bug with the logger can be fixed, the logger wont be setup. # also need to determine how to detect if it has been set up - pass + config.setup_logger() def test_translations_are_properly_installed(self): # TODO: diff --git a/test/test_main.py b/test/test_main.py deleted file mode 100644 index 371f12c..0000000 --- a/test/test_main.py +++ /dev/null @@ -1,84 +0,0 @@ -import config -import main -from test_config import RotatingFileHandler, RotatingFileHandlerMock -from unittest import TestCase, mock, main as unittest_main - - -class AppMock(object): - - def setWindowIcon(self, app_icon): - pass - - -class LoopMock(object): - exception_type = None - - def run_until_complete(self, main_window): - pass - - def run_forever(self): - if self.exception_type is not None: - raise self.exception_type() - - def close(self): - pass - - -class MainWindowMock(object): - - def __init__(self, config): - pass - - def main_loop(self): - pass - - def show(self): - pass - - -class MainTest(TestCase): - - def setUp(self): - config.RotatingFileHandler = RotatingFileHandlerMock - main.real_app = main.app - main.real_loop = main.loop - main.real_MainWindow = main.MainWindow - main.app = AppMock() - main.loop = LoopMock() - main.MainWindow = MainWindowMock - - def tearDown(self): - config.RotatingFileHandler = RotatingFileHandler - main.app = main.real_app - main.loop = main.real_loop - main.MainWindow = main.real_MainWindow - del main.real_app - del main.real_loop - del main.real_MainWindow - - def test_main_can_initialize(self): - main.initialize() - assert hasattr(main, "app_icon") - assert hasattr(main, "main_window") - - # TODO: - # check that the icon isn't empty somehow - - def test_main_can_show_main_window(self): - main.initialize(True) - - def test_main_can_catch_runtime_error(self): - main.loop.exception_type = RuntimeError - main.initialize(True) - - def test_main_will_raise_general_exception(self): - main.loop.exception_type = Exception - try: - main.initialize(True) - except Exception as e: - if type(e) is not main.loop.exception_type: - raise - - -if __name__ == "__main__": - unittest_main() From d57465e972165a87d2d7bfaa19f5dbd38fe26326 Mon Sep 17 00:00:00 2001 From: MosesofEgypt Date: Wed, 14 Jun 2017 03:05:29 -0400 Subject: [PATCH 14/16] Implemented some tests for DownloadTracker Fixed bugs in test_download tests Fixed logic bug in config Fixed bugs in test_common --- src/config.py | 5 ++-- test/test_common.py | 2 ++ test/test_config.py | 6 ++++ test/test_download.py | 65 +++++++++++++++++++++++++++++++++++-------- 4 files changed, 64 insertions(+), 14 deletions(-) diff --git a/src/config.py b/src/config.py index e79d784..bf86d3d 100644 --- a/src/config.py +++ b/src/config.py @@ -106,9 +106,8 @@ def reload_config(): old_url = url GLOBAL_CONTEXT['project'] = sanitize_url( config_json['project']) - url = inject_variables(config_json['config_endpoint']) - if 'config_endpoint' not in config_json: - break + if 'config_endpoint' in config_json: + url = inject_variables(config_json['config_endpoint']) g['CONFIG'] = namedtuple_from_mapping(config_json) GLOBAL_CONTEXT['project'] = sanitize_url(config_json['project']) diff --git a/test/test_common.py b/test/test_common.py index ba97855..5879bc2 100644 --- a/test/test_common.py +++ b/test/test_common.py @@ -27,6 +27,7 @@ def test_get_loop_fails_without_app(self): pass def test_can_get_app(self): + common.app = None app = get_app() self.assertTrue(app) @@ -34,6 +35,7 @@ def test_can_get_app(self): self.assertIs(get_app(), app) def test_can_get_loop(self): + common.loop = None loop = get_loop() self.assertTrue(loop) diff --git a/test/test_config.py b/test/test_config.py index a2cd04c..8e93553 100644 --- a/test/test_config.py +++ b/test/test_config.py @@ -105,6 +105,12 @@ def test_loggers_are_properly_setup(self): # also need to determine how to detect if it has been set up config.setup_logger() + def test_loggers_are_not_setup_twice(self): + config._LOGGER_SETUP = True + config.setup_logger() + # TODO: + # need to determine how to detect if it has been set up + def test_translations_are_properly_installed(self): # TODO: # need to figure out how to determine if they are installed diff --git a/test/test_download.py b/test/test_download.py index edb4523..76f6cd3 100644 --- a/test/test_download.py +++ b/test/test_download.py @@ -77,13 +77,12 @@ def _download_inc(self, block): def _call_download(self, test_path, test_url, test_data=b'', test_hash=None, session=None): - session = self.session_mock - session.data = test_data + self.session_mock.data = test_data with mock.patch('download.open', mock.mock_open()) as m: download_file( test_url, test_path, self._download_inc, session, test_hash) - responses = session.responses + responses = self.session_mock.responses self.assertIn(test_url, responses) self.assertEqual(1, len(responses[test_url])) self.assertEqual(len(test_data), self.downloaded_bytes) @@ -132,14 +131,12 @@ class DownloadTest(TestCase): def _call_download(self, test_path, test_url, test_data=b'', test_hash=None, session=None): downloader = Download(test_path, test_url, len(test_data)) - session = self.session_mock - session.data = test_data + self.session_mock.data = test_data with mock.patch('download.open', mock.mock_open()) as m: downloader.download_file(session) - session = self.session_mock - responses = session.responses + responses = self.session_mock.responses self.assertIn(test_url, responses) self.assertEqual(1, len(responses[test_url])) self.assertEqual(len(test_data), downloader.downloaded_bytes) @@ -159,14 +156,60 @@ def _call_download(self, test_path, test_url, class DownloadTrackerTest(TestCase): + download_tracker = None + progress_bar = ProgressBarMock() + executor = ExecutorMock() - setUp = DownloadFileTest.setUp + def setUp(self): + self.session_mock = SessionMock() + requests.real_get = requests.get + requests.get = self.session_mock.get + self.download_tracker = DownloadTracker( + self.progress_bar, self.executor) - tearDown = DownloadFileTest.tearDown + def tearDown(self): + requests.get = requests.real_get + del requests.real_get + del self.session_mock - # TODO: - # write tests for this class + def test_download_tracker_can_update(self): + self.download_tracker.update() + + def test_download_tracker_can_add_downloads(self): + for path, url, size in [("path1", "url1", 1337), + ("path2", "url2", 1412)]: + self.download_tracker.add_download(path, url, size) + d = self.download_tracker.downloads[-1] + self.assertEqual(d.url, url) + self.assertEqual(d.file_path, path) + self.assertEqual(d.total_size, size) + + def test_download_tracker_can_clear_downloads(self): + downloads = self.download_tracker.downloads + download_futures = self.download_tracker.download_futures + for i in range(0, 4096, 1024): + self.download_tracker.add_download("url%s" % i, "path%s" % i, i) + download_futures.append(None) + + self.assertEqual(len(downloads), 4) + self.assertEqual(len(download_futures), 4) + self.download_tracker.clear() + self.assertEqual(len(downloads), 0) + self.assertEqual(len(download_futures), 0) + + def test_download_tracker_updates_properly(self): + test_download_1 = Download('', '', 2048) + test_download_2 = Download('', '', 4096) + test_download_1.downloaded_bytes = 1337 + test_download_2.downloaded_bytes = 1412 + self.download_tracker.downloads = (test_download_1, test_download_2) + self.download_tracker.update() + + self.assertEqual(self.progress_bar.maximum, 2048+4096) + self.assertEqual(self.progress_bar.value, 1337+1412) + # TODO: + # write unittests for _execute_requests, run, and run_async if __name__ == "__main__": main() From 4bda5f6dfe8d3451c1e107ddf5679fb3c70d1fa8 Mon Sep 17 00:00:00 2001 From: MosesofEgypt Date: Wed, 14 Jun 2017 03:06:34 -0400 Subject: [PATCH 15/16] pep8 --- test/test_download.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/test_download.py b/test/test_download.py index 76f6cd3..be831cb 100644 --- a/test/test_download.py +++ b/test/test_download.py @@ -209,7 +209,7 @@ def test_download_tracker_updates_properly(self): self.assertEqual(self.progress_bar.value, 1337+1412) # TODO: - # write unittests for _execute_requests, run, and run_async + # write unittests for _execute_requests, run, and run_async if __name__ == "__main__": main() From 60b90c6d235b710b676aabb311d489fb612f5bac Mon Sep 17 00:00:00 2001 From: MosesofEgypt Date: Thu, 15 Jun 2017 03:28:39 -0400 Subject: [PATCH 16/16] Implemented 48% test coverage for ui Added comments to clarify chmod calls Refactoring code to be smaller --- src/ui.py | 15 ++-- test/test_ui.py | 216 +++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 219 insertions(+), 12 deletions(-) diff --git a/src/ui.py b/src/ui.py index 98fe662..23e15b2 100644 --- a/src/ui.py +++ b/src/ui.py @@ -50,6 +50,7 @@ def index_directory(self): def launch_game(self, game_binary, command_args): binary_path = os.path.join(self.directory, game_binary) + # set to mask for owner permissions and read by group os.chmod(binary_path, 0o740) args = [binary_path] + command_args logging.info('Command: %s' % ' '.join(args)) @@ -65,20 +66,18 @@ def _diff_files(self, for filename, filedata in remote_index['files'].items(): filehash = filedata['sha256'] filesize = filedata['size'] - context['filename'] = filename - context['filehash'] = filehash + context.update(filename=filename, filehash=filehash) url = inject_variables(url_format, context) file_path = os.path.join(self.directory, filename) - download = False + download = True if filename not in self.files: - download = True logging.info('Missing file: %s (%s)' % (filehash, filename)) elif self.files[filename] != filehash: - download = True logging.info('Hash mismatch: %s (%s vs %s)' % (filename, filehash, self.files[filename])) else: + download = False logging.info('Matched File: %s (%s)' % (filehash, filename)) if download: download_tracker.add_download(file_path, url, filesize) @@ -257,6 +256,7 @@ async def launcher_update_check(self): os.rename(sys.executable, old_file) logging.info('Renaming old launcher to: %s' % old_file) os.rename(temp_file, sys.executable) + # set to mask for owner permissions and read/execute by group os.chmod(sys.executable, 0o750) subprocess.Popen([sys.executable]) sys.exit(0) @@ -334,8 +334,7 @@ def launch_game(self): self.launch_game_btn.setEnabled(False) system = platform.system() binary = self.config.game_binary[system] + args = [] if system in self.config.launch_flags: - args = self.config.launch_flags[system] - else: - args = [] + args.extend(self.config.launch_flags[system]) self.branches[self.branch].launch_game(binary, args) diff --git a/test/test_ui.py b/test/test_ui.py index cd90311..023b730 100644 --- a/test/test_ui.py +++ b/test/test_ui.py @@ -1,16 +1,224 @@ +import asyncio import config +import common import ui -from test_config import RotatingFileHandler, RotatingFileHandlerMock +import os +import sys +import subprocess +import platform +from PyQt5 import QtWidgets from unittest import TestCase, mock, main +from util import namedtuple_from_mapping + +config.setup_directories() +testing_config = namedtuple_from_mapping( + dict( + project="Fantasy Crescendo", + logo="img/logo.png", + config_endpoint=("https://patch.houraiteahouse.net/fantasy-crescendo/" + "launcher/config.json"), + launcher_endpoint=( + "https://patch.houraiteahouse.net/fantasy-crescendo/launcher/" + "{platform}/{executable}"), + index_endpoint=("https://patch.houraiteahouse.net/fantasy-crescendo/" + "{branch}/{platform}/index.json"), + news_rss_feed="https://www.reddit.com/r/touhou.rss", + game_binary=dict(Windows="fc.exe", Linux="fc.x86_64"), + launch_flags=dict(Windows=["-bill", "-gates"], + Linux=["-linus", "-torvalds"]), + branches=dict(develop="Development"), + ) + ) + +testing_index_files = dict( + test1=dict( + sha256="one hash", + size=0xdeadbeef + ), + test2=dict( + sha256="two hash", + size=0xbadf00d + ), + test3=dict( + sha256="red AND blue hash", + size=0xc001d00d + ) + ) + +testing_index = namedtuple_from_mapping(dict( + files=testing_index_files, + base_url="https://patch.houraiteahouse.net", + project="fantasy-crescendo", + branch="develop", + platform="Windows", + url_format="{base_url}/{project}/{branch}/{platform}/{filename}_{filehash}" + )) + + +class BranchTest(TestCase): + branch = None + + def setUp(self): + self.branch = ui.Branch("Development", "develop", testing_config) + + def test_branch_can_create_branch(self): + branch = self.branch + self.assertEqual(branch.name, "Development") + self.assertEqual(branch.source_branch, "develop") + self.assertEqual(branch.directory, + os.path.join(config.BASE_DIR, "Development")) + self.assertFalse(branch.is_indexed) + self.assertIs(branch.last_fetched, None) + self.assertIs(branch.config, testing_config) + self.assertIsInstance(branch.files, dict) + self.assertEqual(branch.files, {}) + + def test_branch_can_index_directory(self): + branch = self.branch + test_paths = (("full_1", "rel_1"), ("full_2", "rel_2")) + with mock.patch('os.path.exists', lambda dir: True) as m1,\ + mock.patch('ui.list_files', lambda dir: test_paths) as m2,\ + mock.patch('ui.sha256_hash', lambda p: "HASH%s" % p) as m3: + branch.index_directory() + + files = branch.files + self.assertIn("rel_1", files) + self.assertIn("rel_2", files) + + def test_branch_cannot_index_nonexistant_directory(self): + branch = self.branch + old_files = branch.files + test_paths = (("full_1", "rel_1"), ("full_2", "rel_2")) + with mock.patch('os.path.exists', lambda dir: False) as m1,\ + mock.patch('ui.list_files', lambda dir: test_paths) as m2: + branch.index_directory() + + self.assertTrue(branch.is_indexed) + self.assertIs(old_files, branch.files) + + def test_branch_can_launch_game(self): + branch = self.branch + mock_data = dict( + sys_exit_called=False, + os_chmod_args=('', 0), + subprocess_Popen_args=()) + + game_binary = "fc.exe" + binary_path = os.path.join(branch.directory, game_binary) + command_args = ["-have", "-some", "-arguments"] + + def sys_exit_mock(): + mock_data['sys_exit_called'] = True + + def os_chmod_mock(binary_path, new_mode): + mock_data['os_chmod_args'] = (binary_path, new_mode) + + def subprocess_Popen_mock(args): + mock_data['subprocess_Popen_args'] = args + + with mock.patch('subprocess.Popen', subprocess_Popen_mock) as m1,\ + mock.patch('os.chmod', os_chmod_mock) as m2,\ + mock.patch('sys.exit', sys_exit_mock) as m3: + branch.launch_game(game_binary, command_args) + + self.assertTrue(mock_data['sys_exit_called']) + self.assertEqual(mock_data['os_chmod_args'][0], binary_path) + self.assertEqual(mock_data['os_chmod_args'][1], 0o740) + self.assertEqual(mock_data['subprocess_Popen_args'][0], binary_path) + self.assertEqual(mock_data['subprocess_Popen_args'][1:], command_args) class UiTest(TestCase): def setUp(self): - config.RotatingFileHandler = RotatingFileHandlerMock + common.get_app() + common.get_loop() + + def test_main_window_can_create_main_window(self): + with mock.patch('ui.MainWindow.init_ui', lambda self: None) as m: + main_window = ui.MainWindow(testing_config) + + self.assertTrue(hasattr(main_window, "branches")) + self.assertTrue(hasattr(main_window, "branch_lookup")) + self.assertTrue(hasattr(main_window, "context")) + self.assertTrue(hasattr(main_window, "client_state")) + self.assertTrue(hasattr(main_window, "config")) + self.assertIn("Development", main_window.branches) + + def test_main_window_can_initialize_ui(self): + main_window = ui.MainWindow(testing_config) + self.assertTrue(hasattr(main_window, "launch_game_btn")) + self.assertTrue(hasattr(main_window, "branch_box")) + self.assertTrue(hasattr(main_window, "news_view")) + self.assertTrue(hasattr(main_window, "progress_bar")) + self.assertTrue(hasattr(main_window, "download_tracker")) + self.assertTrue(hasattr(main_window, "download_tracker")) + + # TODO: determine if the widgets are set up properly? + + def test_main_window_can_launch_game(self): + launch_args = [] + system = platform.system() + args = testing_config.launch_flags[system] + main_window = ui.MainWindow(testing_config) + + def branch_launch_game_mock(self, binary, args): + launch_args.extend([self.name, binary, args]) + + with mock.patch('ui.Branch.launch_game', branch_launch_game_mock) as m: + main_window.launch_game() + + self.assertEqual(len(launch_args), 3) + self.assertEqual(launch_args[0], "Development") + self.assertEqual(launch_args[1], testing_config.game_binary[system]) + self.assertEqual(launch_args[2], args) + + def test_main_window_can_change_branch_name(self): + main_window = ui.MainWindow(testing_config) + main_window.branch = None + self.assertIs(main_window.branch, None) + main_window.on_branch_change("Development") + self.assertEqual(main_window.branch, "develop") + + def test_main_window_can_build_path(self): + main_window = ui.MainWindow(testing_config) + test_path = ( + "https://patch.houraiteahouse.net/fantasy-crescendo/launcher/" + + "%s/%s" % (platform.system(), os.path.basename(sys.executable))) + built_path = main_window.build_path(testing_config.launcher_endpoint) + self.assertEqual(built_path, test_path) + + def test_main_window_ready_swaps_progress_bar_and_launch_game_button(self): + main_window = ui.MainWindow(testing_config) + mock_data = dict( + launch_game_button_enabled=False, + launch_game_button_shown=False, + progress_bar_shown=True, + ) + + def launch_game_button_enable_mock(self, new_state): + mock_data['launch_game_button_enabled'] = bool(new_state) + + def launch_game_button_show_mock(self): + mock_data['launch_game_button_shown'] = True + + def progress_bar_hide_mock(self): + mock_data['progress_bar_shown'] = False + + # TODO: This part needs to be tested using async. need to figure + # out how to use asyncio in order to finish this test + return + with mock.patch('PyQt5.QtWidgets.QPushButton.setEnabled', + launch_game_button_enable_mock) as m1,\ + mock.patch('PyQt5.QtWidgets.QPushButton.show', + launch_game_button_show_mock) as m2,\ + mock.patch('PyQt5.QtWidgets.QProgressBar.hide', + progress_bar_hide_mock) as m3: + main_window.ready() - def tearDown(self): - config.RotatingFileHandler = RotatingFileHandler + self.assertTrue(mock_data['launch_game_button_enabled']) + self.assertTrue(mock_data['launch_game_button_shown']) + self.assertFalse(mock_data['progress_bar_shown']) if __name__ == "__main__":