diff --git a/.gitignore b/.gitignore index 76e7f89..2f2c720 100644 --- a/.gitignore +++ b/.gitignore @@ -89,3 +89,11 @@ ENV/ # Idea/PyCharm project .idea *.iml + +# Launcher stuff +Launcher/ +launcher/ +launcher_log.* + +# Coverage tests +cover/ \ No newline at end of file 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 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/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/scripts/deploy_win.bat b/scripts/deploy_win.bat index cb5eb5e..d42b6c5 100755 --- a/scripts/deploy_win.bat +++ b/scripts/deploy_win.bat @@ -1,8 +1,11 @@ +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 -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 + ) ) 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 17a7394..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(' ', '-') @@ -24,12 +67,13 @@ 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..e79d784 100644 --- a/src/config.py +++ b/src/config.py @@ -7,95 +7,178 @@ 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 -if 'win' in platform.platform().lower(): - try: - import gettext_windows - except: - loggin.warning('Cannot import gettext_windows') +__all__ = ( + "TRANSLATION_DIR", "TRANSLATION_DIRNAME", "TRANSLATIONS", + "CONFIG_DIR", "CONFIG_DIRNAME", "CONFIG_NAME", "CONFIG", + "BASE_DIR", "RESOURCE_DIR", "GLOBAL_CONTEXT", "ROOT_LOGGER" + ) + 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 interpretter, 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) - -if 'win' in platform.platform().lower(): - logging.info('Setting Windows enviorment variables for translation...') - 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']) +_TRANSLATIONS_INSTALLED = False +_DIRECTORIES_SETUP = False +_LOGGER_SETUP = False + + +def install_translations(): + g = globals() + if g.get('_TRANSLATIONS_INSTALLED'): + return + + gettext_windows = None + if 'win' in platform.platform().lower(): + if _LOGGER_SETUP: + logging.info( + 'Setting Windows environment variables for translation...') + try: + import gettext_windows + except: + if _LOGGER_SETUP: + 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() + 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() + + # 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) + + 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) + + 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: + 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() + if _LOGGER_SETUP: + logging.info('Fetched new config from %s.' % url) + config_file.seek(0) + config_file.truncate() + json.dump(config_json, config_file) + if _LOGGER_SETUP: + logging.info( + 'Saved new config to disk: %s' % config_path) + except HTTPError as http_error: + if _LOGGER_SETUP: + logging.error(http_error) + break + except Timeout as timeout: + if _LOGGER_SETUP: + 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(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 = 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") + requests_log.setLevel(logging.DEBUG) + g['_LOGGER_SETUP'] = True + + +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. + # 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 _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() + 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) + if _LOGGER_SETUP: + logging.info('Config Directory: %s' % CONFIG_DIR) + + TRANSLATION_DIR = os.path.join(RESOURCE_DIR, TRANSLATION_DIRNAME) + 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/download.py b/src/download.py index f734b5a..4da2c37 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) @@ -43,13 +47,15 @@ def __init__(self, path, url, download_size): self.total_size = download_size self.downloaded_bytes = 0 - def download_file(self, session=None): - def inc_fun(block): - self.downloaded_bytes += len(block) + def download_file(self, session=None, filehash=None): return download_file(self.url, self.file_path, - block_fun=inc_fun, - session=session) + block_fun=self._inc_download, + session=session, + filehash=filehash) + + 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..9539572 100644 --- a/src/main.py +++ b/src/main.py @@ -1,20 +1,13 @@ +import config import logging import os -from PyQt5 import QtGui, QtCore -from config import CONFIG, RESOURCE_DIR from ui import MainWindow -from common import app, loop +from common import get_app, get_loop, set_app_icon -app_icon = QtGui.QIcon() -for size in [16, 24, 32, 48]: - app_icon.addFile( - 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) +app = get_app() +loop = get_loop() +set_app_icon() +main_window = MainWindow(config.load_config()) if __name__ == '__main__': main_window.show() diff --git a/src/ui.py b/src/ui.py index 43d27b8..98fe662 100644 --- a/src/ui.py +++ b/src/ui.py @@ -1,4 +1,5 @@ import asyncio +import config import logging import os import platform @@ -12,9 +13,8 @@ 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 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 @@ -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): @@ -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) @@ -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()} @@ -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.') @@ -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) 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 diff --git a/test/test_common.py b/test/test_common.py new file mode 100644 index 0000000..ba97855 --- /dev/null +++ b/test/test_common.py @@ -0,0 +1,98 @@ +import common +import os +import platform +import sys +from unittest import TestCase, main +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 + + +launcher_endpoint = "https://patch.houraiteahouse.net/{project}/launcher\ +/{platform}/{executable}" + + +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"), + "https://this-is-a-test-url.com") + + def _inject_variables_using_custom_context(self, context=None): + if context: + 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_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._inject_variables_using_custom_context() + + +if __name__ == "__main__": + main() diff --git a/test/test_config.py b/test/test_config.py new file mode 100644 index 0000000..a2cd04c --- /dev/null +++ b/test/test_config.py @@ -0,0 +1,157 @@ +import config +import requests +import os +import sys +from logging.handlers import RotatingFileHandler +from unittest import TestCase, main, mock +from test_download import SessionMock + +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 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 + config.setup_logger() + + 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): + with mock.patch('config.open', mock.mock_open( + read_data=config_test_data)) as m: + cfg = config.load_config() + + assert hasattr(config, "TRANSLATION_DIR") + assert hasattr(config, "TRANSLATION_DIRNAME") + assert hasattr(config, "TRANSLATIONS") + assert hasattr(config, "CONFIG_DIR") + 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") + + 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") + + +if __name__ == "__main__": + main() diff --git a/test/test_download.py b/test/test_download.py new file mode 100644 index 0000000..edb4523 --- /dev/null +++ b/test/test_download.py @@ -0,0 +1,172 @@ +import requests +from unittest import TestCase, main, mock +from download import download_file, Download, DownloadTracker + + +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] + + def raise_for_status(self): + pass + + def json(self): + return eval(self.data) + + +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): + self.session_mock = SessionMock() + requests.real_get = requests.get + requests.get = self.session_mock.get + + def tearDown(self): + 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): + 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) + + responses = session.responses + self.assertIn(test_url, responses) + self.assertEqual(1, len(responses[test_url])) + self.assertEqual(len(test_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)) + session = self.session_mock + session.data = 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(test_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 + + # TODO: + # write tests for this class + + +if __name__ == "__main__": + 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() diff --git a/test/test_util.py b/test/test_util.py index 867796c..e65533f 100644 --- a/test/test_util.py +++ b/test/test_util.py @@ -1,11 +1,64 @@ -from unittest import TestCase, main -from util import namedtuple_from_mapping +import os +from unittest import TestCase, main, mock +from util import list_files, namedtuple_from_mapping, ProtectedDict,\ + sha256_hash, tupperware class UtilTest(TestCase): + def test_sha256_hash_empty_file(self): + 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): + 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 + 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): - pass + 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__":