diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 5e346af..19643d2 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -14,13 +14,19 @@ test_package: deploy_package: stage: deploy + variables: + TWINE_USERNAME: $PYPI_USERNAME + TWINE_PASSWORD: $PYPI_PASSWORD script: + - pip install twine wheel + - rm -rf dist - export VERSION=$(python setup.py --version) - - python setup.py sdist --formats=gztar upload -r gog-pypi + - python setup.py sdist --formats=gztar bdist_wheel + - twine upload dist/* - curl -X POST --silent --show-error --fail "https://gitlab.gog.com/api/v4/projects/${CI_PROJECT_ID}/repository/tags?tag_name=${VERSION}&ref=${CI_COMMIT_REF_NAME}&private_token=${PACKAGE_DEPLOYER_API_TOKEN}" when: manual only: - master except: - - tags \ No newline at end of file + - tags diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..388c5ad --- /dev/null +++ b/.travis.yml @@ -0,0 +1,8 @@ +dist: xenial # required for Python >= 3.7 +language: python +python: + - "3.7" +install: + - pip install -r requirements.txt +script: + - pytest diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..21a86b9 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2019 GOG sp. z o.o. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/PLATFORM_IDs.md b/PLATFORM_IDs.md index af73bc7..60e8737 100644 --- a/PLATFORM_IDs.md +++ b/PLATFORM_IDs.md @@ -79,3 +79,4 @@ Platform ID list for GOG Galaxy 2.0 Integrations | psvita | PlayStation Vita | | nds | Nintendo DS | | 3ds | Nintendo 3DS | +| pathofexile | Path of Exile | diff --git a/README.md b/README.md index 4330b1f..60896ba 100644 --- a/README.md +++ b/README.md @@ -1,35 +1,35 @@ # GOG Galaxy Integrations Python API -This Python library allows to easily build community integrations for various gaming platforms with GOG Galaxy 2.0. +This Python library allows developers to easily build community integrations for various gaming platforms with GOG Galaxy 2.0. - refer to our documentation ## Features -Each integration in GOG Galaxy 2.0 comes as a separate Python script, and is launched as a separate process, that which needs to communicate with main instance of GOG Galaxy 2.0. +Each integration in GOG Galaxy 2.0 comes as a separate Python script and is launched as a separate process that needs to communicate with the main instance of GOG Galaxy 2.0. The provided features are: -- multistep authorisation using a browser built into GOG Galaxy 2.0 +- multistep authorization using a browser built into GOG Galaxy 2.0 - support for GOG Galaxy 2.0 features: - - importing owned and detecting installed games - - installing and launching games - - importing achievements and game time - - importing friends lists and statuses - - importing friends recomendations list - - receiving and sending chat messages + - importing owned and detecting installed games + - installing and launching games + - importing achievements and game time + - importing friends lists and statuses + - importing friends recommendations list + - receiving and sending chat messages - cache storage ## Platform Id's Each integration can implement only one platform. Each integration must declare which platform it's integrating. -[List of possible Platofrm IDs](PLATFORM_IDs.md) +[List of possible Platform IDs](PLATFORM_IDs.md) ## Basic usage -Eeach integration should inherit from the :class:`~galaxy.api.plugin.Plugin` class. Supported methods like :meth:`~galaxy.api.plugin.Plugin.get_owned_games` should be overwritten - they are called from the GOG Galaxy client in the appropriate times. -Each of those method can raise exceptions inherited from the :exc:`~galaxy.api.jsonrpc.ApplicationError`. +Each integration should inherit from the :class:`~galaxy.api.plugin.Plugin` class. Supported methods like :meth:`~galaxy.api.plugin.Plugin.get_owned_games` should be overwritten - they are called from the GOG Galaxy client at the appropriate times. +Each of those methods can raise exceptions inherited from the :exc:`~galaxy.api.jsonrpc.ApplicationError`. Communication between an integration and the client is also possible with the use of notifications, for example: :meth:`~galaxy.api.plugin.Plugin.update_local_game_status`. ```python @@ -61,11 +61,13 @@ if __name__ == "__main__": ## Deployment -The client has a built-in Python 3.7 interpreter, so the integrations are delivered as python modules. -In order to be found by GOG Galaxy 2.0 an integration folder should be placed in [lookup directory](#deploy-location). Beside all the python files, the integration folder has to contain [manifest.json](#deploy-manifest) and all third-party dependencies. See an [examplary structure](#deploy-structure-example). +The client has a built-in Python 3.7 interpreter, so integrations are delivered as Python modules. +In order to be found by GOG Galaxy 2.0 an integration folder should be placed in [lookup directory](#deploy-location). Beside all the Python files, the integration folder must contain [manifest.json](#deploy-manifest) and all third-party dependencies. See an [exemplary structure](#deploy-structure-example). ### Lookup directory + + - Windows: `%localappdata%\GOG.com\Galaxy\plugins\installed` @@ -75,8 +77,9 @@ In order to be found by GOG Galaxy 2.0 an integration folder should be placed in `~/Library/Application Support/GOG.com/Galaxy/plugins/installed` ### Manifest - -Obligatory JSON file to be placed in a integration folder. + + +Obligatory JSON file to be placed in an integration folder. ```json { @@ -91,6 +94,7 @@ Obligatory JSON file to be placed in a integration folder. "script": "plugin.py" } ``` + | property | description | |---------------|---| | `guid` | | @@ -99,13 +103,15 @@ Obligatory JSON file to be placed in a integration folder. | `script` | path of the entry point module, relative to the integration folder | ### Dependencies -All third-party packages (packages not included in Python 3.7 standard library) should be deployed along with plugin files. Use the folowing command structure: + +All third-party packages (packages not included in the Python 3.7 standard library) should be deployed along with plugin files. Use the following command structure: ```pip install DEP --target DIR --implementation cp --python-version 37``` -For example plugin that uses *requests* has structure as follows: +For example, a plugin that uses *requests* could have the following structure: + ```bash installed └── my_integration diff --git a/docs/source/conf.py b/docs/source/conf.py index 096dbb5..299cc18 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -47,7 +47,7 @@ templates_path = ['_templates'] # List of patterns, relative to source directory, that match files and # directories to ignore when looking for source files. # This pattern also affects html_static_path and html_extra_path. -exclude_patterns = [] +exclude_patterns = [] # type: ignore # -- Options for HTML output ------------------------------------------------- diff --git a/jenkins/release.groovy b/jenkins/release.groovy deleted file mode 100644 index d1a8542..0000000 --- a/jenkins/release.groovy +++ /dev/null @@ -1,14 +0,0 @@ -stage('Upload to github') -{ - node('ActiveClientMacosxBuilder') { - deleteDir() - checkout scm - withPythonEnv('/usr/local/bin/python3.7') { - withCredentials([string(credentialsId: 'github_goggalaxy', variable: 'GITHUB_TOKEN')]) { - sh 'pip install -r jenkins/requirements.txt' - def version = sh(returnStdout: true, script: 'python setup.py --version').trim() - sh "python jenkins/release.py $version" - } - } - } -} diff --git a/jenkins/release.py b/jenkins/release.py deleted file mode 100644 index 12ef332..0000000 --- a/jenkins/release.py +++ /dev/null @@ -1,26 +0,0 @@ -import os -import sys -from galaxy.github.exporter import transfer_repo - -GITHUB_USERNAME = "goggalaxy" -GITHUB_EMAIL = "galaxy-sdk@gog.com" -GITHUB_TOKEN = os.environ["GITHUB_TOKEN"] -GITHUB_REPO_NAME = "galaxy-integrations-python-api" -SOURCE_BRANCH = os.environ["GIT_REFSPEC"] - -GITLAB_USERNAME = "galaxy-client" -GITLAB_REPO_NAME = "galaxy-plugin-api" - -def version_provider(_): - return sys.argv[1] - -gh_version = transfer_repo( - version_provider=version_provider, - source_repo_spec="git@gitlab.gog.com:{}/{}.git".format(GITLAB_USERNAME, GITLAB_REPO_NAME), - source_include_elements=["src", "docs", "tests", "requirements.txt", ".readthedocs.yml" ".gitignore", "*.md", "pytest.ini", "setup.py"], - source_branch=SOURCE_BRANCH, - dest_repo_spec="https://{}:{}@github.com/{}/{}.git".format(GITHUB_USERNAME, GITHUB_TOKEN, "gogcom", GITHUB_REPO_NAME), - dest_branch="master", - dest_user_email=GITHUB_EMAIL, - dest_user_name="GOG Galaxy SDK Team" -) \ No newline at end of file diff --git a/jenkins/requirements.txt b/jenkins/requirements.txt deleted file mode 100644 index d76c88e..0000000 --- a/jenkins/requirements.txt +++ /dev/null @@ -1 +0,0 @@ -git+ssh://git@gitlab.gog.com/galaxy-client/github-exporter.git@v0.1 \ No newline at end of file diff --git a/mypy.ini b/mypy.ini new file mode 100644 index 0000000..976ba02 --- /dev/null +++ b/mypy.ini @@ -0,0 +1,2 @@ +[mypy] +ignore_missing_imports = True diff --git a/pytest.ini b/pytest.ini index 3d6dc59..7bd7c2a 100644 --- a/pytest.ini +++ b/pytest.ini @@ -1,2 +1,2 @@ [pytest] -addopts = --flakes \ No newline at end of file +addopts = --flakes --mypy diff --git a/requirements.txt b/requirements.txt index 1e99e14..35da1fa 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,7 +2,9 @@ pytest==4.2.0 pytest-asyncio==0.10.0 pytest-mock==1.10.3 +pytest-mypy==0.3.2 pytest-flakes==4.0.0 # because of pip bug https://github.com/pypa/pip/issues/4780 aiohttp==3.5.4 -certifi==2019.3.9 \ No newline at end of file +certifi==2019.3.9 +psutil==5.6.3; sys_platform == 'darwin' diff --git a/setup.py b/setup.py index 6162140..5996bfd 100644 --- a/setup.py +++ b/setup.py @@ -2,7 +2,7 @@ from setuptools import setup, find_packages setup( name="galaxy.plugin.api", - version="0.36", + version="0.47", description="GOG Galaxy Integrations Python API", author='Galaxy team', author_email='galaxy@gog.com', diff --git a/src/galaxy/__init__.py b/src/galaxy/__init__.py index 69e3be5..97b69ed 100644 --- a/src/galaxy/__init__.py +++ b/src/galaxy/__init__.py @@ -1 +1 @@ -__path__ = __import__('pkgutil').extend_path(__path__, __name__) +__path__: str = __import__('pkgutil').extend_path(__path__, __name__) diff --git a/src/galaxy/api/consts.py b/src/galaxy/api/consts.py index fd5204e..2b08f36 100644 --- a/src/galaxy/api/consts.py +++ b/src/galaxy/api/consts.py @@ -80,6 +80,7 @@ class Platform(Enum): PlayStationVita = "psvita" NintendoDs = "nds" Nintendo3Ds = "3ds" + PathOfExile = "pathofexile" class Feature(Enum): """Possible features that can be implemented by an integration. @@ -97,6 +98,8 @@ class Feature(Enum): ImportUsers = "ImportUsers" VerifyGame = "VerifyGame" ImportFriends = "ImportFriends" + ShutdownPlatformClient = "ShutdownPlatformClient" + LaunchPlatformClient = "LaunchPlatformClient" class LicenseType(Enum): @@ -115,11 +118,3 @@ class LocalGameState(Flag): None_ = 0 Installed = 1 Running = 2 - - -class PresenceState(Enum): - """"Possible states that a user can be in.""" - Unknown = "Unknown" - Online = "online" - Offline = "offline" - Away = "away" diff --git a/src/galaxy/api/errors.py b/src/galaxy/api/errors.py index 6564b48..f53479f 100644 --- a/src/galaxy/api/errors.py +++ b/src/galaxy/api/errors.py @@ -1,6 +1,6 @@ from galaxy.api.jsonrpc import ApplicationError, UnknownError -UnknownError = UnknownError +assert UnknownError class AuthenticationRequired(ApplicationError): def __init__(self, data=None): diff --git a/src/galaxy/api/jsonrpc.py b/src/galaxy/api/jsonrpc.py index 31a80dd..87bff71 100644 --- a/src/galaxy/api/jsonrpc.py +++ b/src/galaxy/api/jsonrpc.py @@ -5,6 +5,8 @@ import logging import inspect import json +from galaxy.reader import StreamLineReader + class JsonRpcError(Exception): def __init__(self, code, message, data=None): self.code = code @@ -67,7 +69,7 @@ def anonymise_sensitive_params(params, sensitive_params): class Server(): def __init__(self, reader, writer, encoder=json.JSONEncoder()): self._active = True - self._reader = reader + self._reader = StreamLineReader(reader) self._writer = writer self._encoder = encoder self._methods = {} @@ -114,6 +116,7 @@ class Server(): data = data.strip() logging.debug("Received %d bytes of data", len(data)) self._handle_input(data) + await asyncio.sleep(0) # To not starve task queue def stop(self): self._active = False diff --git a/src/galaxy/api/plugin.py b/src/galaxy/api/plugin.py index b876c57..2b67fea 100644 --- a/src/galaxy/api/plugin.py +++ b/src/galaxy/api/plugin.py @@ -1,19 +1,18 @@ import asyncio +import dataclasses import json import logging import logging.handlers -import dataclasses -from enum import Enum -from collections import OrderedDict import sys +from collections import OrderedDict +from enum import Enum +from itertools import count +from typing import Any, Dict, List, Optional, Set, Union -from typing import List, Dict - -from galaxy.api.types import Achievement, Game, LocalGame, FriendInfo, GameTime, UserInfo, Room - -from galaxy.api.jsonrpc import Server, NotificationClient, ApplicationError from galaxy.api.consts import Feature -from galaxy.api.errors import UnknownError, ImportInProgress +from galaxy.api.errors import ImportInProgress, UnknownError +from galaxy.api.jsonrpc import ApplicationError, NotificationClient, Server +from galaxy.api.types import Achievement, Authentication, FriendInfo, Game, GameTime, LocalGame, NextStep class JSONEncoder(json.JSONEncoder): @@ -22,6 +21,7 @@ class JSONEncoder(json.JSONEncoder): # filter None values def dict_factory(elements): return {k: v for k, v in elements if v is not None} + return dataclasses.asdict(o, dict_factory=dict_factory) if isinstance(o, Enum): return o.value @@ -30,13 +30,15 @@ class JSONEncoder(json.JSONEncoder): class Plugin: """Use and override methods of this class to create a new platform integration.""" + def __init__(self, platform, version, reader, writer, handshake_token): logging.info("Creating plugin for platform %s, version %s", platform.value, version) self._platform = platform self._version = version - self._feature_methods = OrderedDict() + self._features: Set[Feature] = set() self._active = True + self._pass_control_task = None self._reader, self._writer = reader, writer self._handshake_token = handshake_token @@ -47,6 +49,7 @@ class Plugin: def eof_handler(): self._shutdown() + self._server.register_eof(eof_handler) self._achievements_import_in_progress = False @@ -54,6 +57,9 @@ class Plugin: self._persistent_cache = dict() + self._tasks = OrderedDict() + self._task_counter = count() + # internal self._register_method("shutdown", self._shutdown, internal=True) self._register_method("get_capabilities", self._get_capabilities, internal=True) @@ -79,92 +85,40 @@ class Plugin: self._register_method( "import_owned_games", self.get_owned_games, - result_name="owned_games", - feature=Feature.ImportOwnedGames - ) - self._register_method( - "import_unlocked_achievements", - self.get_unlocked_achievements, - result_name="unlocked_achievements", - feature=Feature.ImportAchievements - ) - self._register_method( - "start_achievements_import", - self.start_achievements_import, - ) - self._register_method( - "import_local_games", - self.get_local_games, - result_name="local_games", - feature=Feature.ImportInstalledGames - ) - self._register_notification("launch_game", self.launch_game, feature=Feature.LaunchGame) - self._register_notification("install_game", self.install_game, feature=Feature.InstallGame) - self._register_notification( - "uninstall_game", - self.uninstall_game, - feature=Feature.UninstallGame - ) - self._register_method( - "import_friends", - self.get_friends, - result_name="friend_info_list", - feature=Feature.ImportFriends - ) - self._register_method( - "import_user_infos", - self.get_users, - result_name="user_info_list", - feature=Feature.ImportUsers - ) - self._register_method( - "send_message", - self.send_message, - feature=Feature.Chat - ) - self._register_method( - "mark_as_read", - self.mark_as_read, - feature=Feature.Chat - ) - self._register_method( - "import_rooms", - self.get_rooms, - result_name="rooms", - feature=Feature.Chat - ) - self._register_method( - "import_room_history_from_message", - self.get_room_history_from_message, - result_name="messages", - feature=Feature.Chat - ) - self._register_method( - "import_room_history_from_timestamp", - self.get_room_history_from_timestamp, - result_name="messages", - feature=Feature.Chat - ) - self._register_method( - "import_game_times", - self.get_game_times, - result_name="game_times", - feature=Feature.ImportGameTime - ) - self._register_method( - "start_game_times_import", - self.start_game_times_import, + result_name="owned_games" ) + self._detect_feature(Feature.ImportOwnedGames, ["get_owned_games"]) + + self._register_method("start_achievements_import", self._start_achievements_import) + self._detect_feature(Feature.ImportAchievements, ["get_unlocked_achievements"]) + + self._register_method("import_local_games", self.get_local_games, result_name="local_games") + self._detect_feature(Feature.ImportInstalledGames, ["get_local_games"]) + + self._register_notification("launch_game", self.launch_game) + self._detect_feature(Feature.LaunchGame, ["launch_game"]) + + self._register_notification("install_game", self.install_game) + self._detect_feature(Feature.InstallGame, ["install_game"]) + + self._register_notification("uninstall_game", self.uninstall_game) + self._detect_feature(Feature.UninstallGame, ["uninstall_game"]) + + self._register_notification("shutdown_platform_client", self.shutdown_platform_client) + self._detect_feature(Feature.ShutdownPlatformClient, ["shutdown_platform_client"]) + + self._register_notification("launch_platform_client", self.launch_platform_client) + self._detect_feature(Feature.LaunchPlatformClient, ["launch_platform_client"]) + + self._register_method("import_friends", self.get_friends, result_name="friend_info_list") + self._detect_feature(Feature.ImportFriends, ["get_friends"]) + + self._register_method("start_game_times_import", self._start_game_times_import) + self._detect_feature(Feature.ImportGameTime, ["get_game_time"]) @property - def features(self): - features = [] - if self.__class__ != Plugin: - for feature, handlers in self._feature_methods.items(): - if self._implements(handlers): - features.append(feature) - - return features + def features(self) -> List[Feature]: + return list(self._features) @property def persistent_cache(self) -> Dict: @@ -172,13 +126,17 @@ class Plugin: """ return self._persistent_cache - def _implements(self, handlers): - for handler in handlers: - if handler.__name__ not in self.__class__.__dict__: + def _implements(self, methods: List[str]) -> bool: + for method in methods: + if method not in self.__class__.__dict__: return False return True - def _register_method(self, name, handler, result_name=None, internal=False, sensitive_params=False, feature=None): + def _detect_feature(self, feature: Feature, methods: List[str]): + if self._implements(methods): + self._features.add(feature) + + def _register_method(self, name, handler, result_name=None, internal=False, sensitive_params=False): if internal: def method(*args, **kwargs): result = handler(*args, **kwargs) @@ -187,6 +145,7 @@ class Plugin: result_name: result } return result + self._server.register_method(name, method, True, sensitive_params) else: async def method(*args, **kwargs): @@ -196,34 +155,52 @@ class Plugin: result_name: result } return result + self._server.register_method(name, method, False, sensitive_params) - if feature is not None: - self._feature_methods.setdefault(feature, []).append(handler) - - def _register_notification(self, name, handler, internal=False, sensitive_params=False, feature=None): + def _register_notification(self, name, handler, internal=False, sensitive_params=False): self._server.register_notification(name, handler, internal, sensitive_params) - if feature is not None: - self._feature_methods.setdefault(feature, []).append(handler) - async def run(self): """Plugin's main coroutine.""" - async def pass_control(): - while self._active: - try: - self.tick() - except Exception: - logging.exception("Unexpected exception raised in plugin tick") - await asyncio.sleep(1) + await self._server.run() + if self._pass_control_task is not None: + await self._pass_control_task - await asyncio.gather(pass_control(), self._server.run()) + def create_task(self, coro, description): + """Wrapper around asyncio.create_task - takes care of canceling tasks on shutdown""" + + async def task_wrapper(task_id): + try: + return await coro + except asyncio.CancelledError: + logging.debug("Canceled task %d (%s)", task_id, description) + except Exception: + logging.exception("Exception raised in task %d (%s)", task_id, description) + finally: + del self._tasks[task_id] + + task_id = next(self._task_counter) + logging.debug("Creating task %d (%s)", task_id, description) + task = asyncio.create_task(task_wrapper(task_id)) + self._tasks[task_id] = task + return task + + async def _pass_control(self): + while self._active: + try: + self.tick() + except Exception: + logging.exception("Unexpected exception raised in plugin tick") + await asyncio.sleep(1) def _shutdown(self): logging.info("Shutting down") self._server.stop() self._active = False self.shutdown() + for task in self._tasks.values(): + task.cancel() def _get_capabilities(self): return { @@ -234,14 +211,18 @@ class Plugin: def _initialize_cache(self, data: Dict): self._persistent_cache = data - self.handshake_complete() + try: + self.handshake_complete() + except Exception: + logging.exception("Unhandled exception during `handshake_complete` step") + self._pass_control_task = asyncio.create_task(self._pass_control()) @staticmethod def _ping(): pass # notifications - def store_credentials(self, credentials: dict): + def store_credentials(self, credentials: Dict[str, Any]) -> None: """Notify the client to store authentication credentials. Credentials are passed on the next authenticate call. @@ -263,9 +244,10 @@ class Plugin: return Authentication(user_data['userId'], user_data['username']) """ + self.persistent_cache['credentials'] = credentials self._notification_client.notify("store_credentials", credentials, sensitive_params=True) - def add_game(self, game: Game): + def add_game(self, game: Game) -> None: """Notify the client to add game to the list of owned games of the currently authenticated user. @@ -287,11 +269,11 @@ class Plugin: params = {"owned_game": game} self._notification_client.notify("owned_game_added", params) - def remove_game(self, game_id: str): + def remove_game(self, game_id: str) -> None: """Notify the client to remove game from the list of owned games of the currently authenticated user. - :param game_id: game id of the game to remove from the list of owned games + :param game_id: the id of the game to remove from the list of owned games Example use case of remove_game: @@ -309,7 +291,7 @@ class Plugin: params = {"game_id": game_id} self._notification_client.notify("owned_game_removed", params) - def update_game(self, game: Game): + def update_game(self, game: Game) -> None: """Notify the client to update the status of a game owned by the currently authenticated user. @@ -318,10 +300,10 @@ class Plugin: params = {"owned_game": game} self._notification_client.notify("owned_game_updated", params) - def unlock_achievement(self, game_id: str, achievement: Achievement): + def unlock_achievement(self, game_id: str, achievement: Achievement) -> None: """Notify the client to unlock an achievement for a specific game. - :param game_id: game_id of the game for which to unlock an achievement. + :param game_id: the id of the game for which to unlock an achievement. :param achievement: achievement to unlock. """ params = { @@ -330,26 +312,14 @@ class Plugin: } self._notification_client.notify("achievement_unlocked", params) - def game_achievements_import_success(self, game_id: str, achievements): - """Notify the client that import of achievements for a given game has succeeded. - This method is called by import_games_achievements. - - :param game_id: id of the game for which the achievements were imported - :param achievements: list of imported achievements - """ + def _game_achievements_import_success(self, game_id: str, achievements: List[Achievement]) -> None: params = { "game_id": game_id, "unlocked_achievements": achievements } self._notification_client.notify("game_achievements_import_success", params) - def game_achievements_import_failure(self, game_id: str, error: ApplicationError): - """Notify the client that import of achievements for a given game has failed. - This method is called by import_games_achievements. - - :param game_id: id of the game for which the achievements import failed - :param error: error which prevented the achievements import - """ + def _game_achievements_import_failure(self, game_id: str, error: ApplicationError) -> None: params = { "game_id": game_id, "error": { @@ -359,12 +329,10 @@ class Plugin: } self._notification_client.notify("game_achievements_import_failure", params) - def achievements_import_finished(self): - """Notify the client that importing achievements has finished. - This method is called by import_games_achievements_task""" + def _achievements_import_finished(self) -> None: self._notification_client.notify("achievements_import_finished", None) - def update_local_game_status(self, local_game: LocalGame): + def update_local_game_status(self, local_game: LocalGame) -> None: """Notify the client to update the status of a local game. :param local_game: the LocalGame to update @@ -381,7 +349,7 @@ class Plugin: continue self.update_local_game_status(LocalGame(game.id, game.status)) self._cached_games_statuses[game.id] = game.status - asyncio.sleep(5) # interval + await asyncio.sleep(5) # interval def tick(self): if self._check_statuses_task is None or self._check_statuses_task.done(): @@ -390,7 +358,7 @@ class Plugin: params = {"local_game": local_game} self._notification_client.notify("local_game_status_changed", params) - def add_friend(self, user: FriendInfo): + def add_friend(self, user: FriendInfo) -> None: """Notify the client to add a user to friends list of the currently authenticated user. :param user: FriendInfo of a user that the client will add to friends list @@ -398,7 +366,7 @@ class Plugin: params = {"friend_info": user} self._notification_client.notify("friend_added", params) - def remove_friend(self, user_id: str): + def remove_friend(self, user_id: str) -> None: """Notify the client to remove a user from friends list of the currently authenticated user. :param user_id: id of the user to remove from friends list @@ -406,22 +374,7 @@ class Plugin: params = {"user_id": user_id} self._notification_client.notify("friend_removed", params) - def update_room(self, room_id: str, unread_message_count=None, new_messages=None): - """WIP, Notify the client to update the information regarding - a chat room that the currently authenticated user is in. - - :param room_id: id of the room to update - :param unread_message_count: information about the new unread message count in the room - :param new_messages: list of new messages that the user received - """ - params = {"room_id": room_id} - if unread_message_count is not None: - params["unread_message_count"] = unread_message_count - if new_messages is not None: - params["messages"] = new_messages - self._notification_client.notify("chat_room_updated", params) - - def update_game_time(self, game_time: GameTime): + def update_game_time(self, game_time: GameTime) -> None: """Notify the client to update game time for a game. :param game_time: game time to update @@ -429,22 +382,11 @@ class Plugin: params = {"game_time": game_time} self._notification_client.notify("game_time_updated", params) - def game_time_import_success(self, game_time: GameTime): - """Notify the client that import of a given game_time has succeeded. - This method is called by import_game_times. - - :param game_time: game_time which was imported - """ + def _game_time_import_success(self, game_time: GameTime) -> None: params = {"game_time": game_time} self._notification_client.notify("game_time_import_success", params) - def game_time_import_failure(self, game_id: str, error: ApplicationError): - """Notify the client that import of a game time for a given game has failed. - This method is called by import_game_times. - - :param game_id: id of the game for which the game time could not be imported - :param error: error which prevented the game time import - """ + def _game_time_import_failure(self, game_id: str, error: ApplicationError) -> None: params = { "game_id": game_id, "error": { @@ -454,19 +396,16 @@ class Plugin: } self._notification_client.notify("game_time_import_failure", params) - def game_times_import_finished(self): - """Notify the client that importing game times has finished. - This method is called by :meth:`~.import_game_times_task`. - """ + def _game_times_import_finished(self) -> None: self._notification_client.notify("game_times_import_finished", None) - def lost_authentication(self): + def lost_authentication(self) -> None: """Notify the client that integration has lost authentication for the current user and is unable to perform actions which would require it. """ self._notification_client.notify("authentication_lost", None) - def push_cache(self): + def push_cache(self) -> None: """Push local copy of the persistent cache to the GOG Galaxy Client replacing existing one. """ self._notification_client.notify( @@ -476,14 +415,14 @@ class Plugin: ) # handlers - def handshake_complete(self): + def handshake_complete(self) -> None: """This method is called right after the handshake with the GOG Galaxy Client is complete and before any other operations are called by the GOG Galaxy Client. Persistent cache is available when this method is called. Override it if you need to do additional plugin initializations. This method is called internally.""" - def tick(self): + def tick(self) -> None: """This method is called periodically. Override it to implement periodical non-blocking tasks. This method is called internally. @@ -503,13 +442,13 @@ class Plugin: """ - def shutdown(self): + def shutdown(self) -> None: """This method is called on integration shutdown. Override it to implement tear down. This method is called by the GOG Galaxy Client.""" # methods - async def authenticate(self, stored_credentials: dict = None): + async def authenticate(self, stored_credentials: Optional[Dict] = None) -> Union[NextStep, Authentication]: """Override this method to handle user authentication. This method should either return :class:`~galaxy.api.types.Authentication` if the authentication is finished or :class:`~galaxy.api.types.NextStep` if it requires going to another url. @@ -537,7 +476,8 @@ class Plugin: """ raise NotImplementedError() - async def pass_login_credentials(self, step: str, credentials: Dict[str, str], cookies: List[Dict[str, str]]): + async def pass_login_credentials(self, step: str, credentials: Dict[str, str], cookies: List[Dict[str, str]]) \ + -> Union[NextStep, Authentication]: """This method is called if we return galaxy.api.types.NextStep from authenticate or from pass_login_credentials. This method's parameters provide the data extracted from the web page navigation that previous NextStep finished on. This method should either return galaxy.api.types.Authentication if the authentication is finished @@ -585,50 +525,59 @@ class Plugin: """ raise NotImplementedError() - async def get_unlocked_achievements(self, game_id: str) -> List[Achievement]: - """ - .. deprecated:: 0.33 - Use :meth:`~.import_games_achievements`. - """ - raise NotImplementedError() - - async def start_achievements_import(self, game_ids: List[str]): - """Starts the task of importing achievements. - This method is called by the GOG Galaxy Client. - - :param game_ids: ids of the games for which the achievements are imported - """ + async def _start_achievements_import(self, game_ids: List[str]) -> None: if self._achievements_import_in_progress: raise ImportInProgress() - async def import_games_achievements_task(game_ids): - try: - await self.import_games_achievements(game_ids) - finally: - self.achievements_import_finished() - self._achievements_import_in_progress = False + context = await self.prepare_achievements_context(game_ids) - asyncio.create_task(import_games_achievements_task(game_ids)) + async def import_game_achievements(game_id, context_): + try: + achievements = await self.get_unlocked_achievements(game_id, context_) + self._game_achievements_import_success(game_id, achievements) + except ApplicationError as error: + self._game_achievements_import_failure(game_id, error) + except Exception: + logging.exception("Unexpected exception raised in import_game_achievements") + self._game_achievements_import_failure(game_id, UnknownError()) + + async def import_games_achievements(game_ids_, context_): + try: + imports = [import_game_achievements(game_id, context_) for game_id in game_ids_] + await asyncio.gather(*imports) + finally: + self._achievements_import_finished() + self._achievements_import_in_progress = False + self.achievements_import_complete() + + self.create_task(import_games_achievements(game_ids, context), "Games unlocked achievements import") self._achievements_import_in_progress = True - async def import_games_achievements(self, game_ids: List[str]): - """ - Override this method to return the unlocked achievements - of the user that is currently logged in to the plugin. - Call game_achievements_import_success/game_achievements_import_failure for each game_id on the list. - This method is called by the GOG Galaxy Client. + async def prepare_achievements_context(self, game_ids: List[str]) -> Any: + """Override this method to prepare context for get_unlocked_achievements. + This allows for optimizations like batch requests to platform API. + Default implementation returns None. - :param game_ids: ids of the games for which to import unlocked achievements + :param game_ids: the ids of the games for which achievements are imported + :return: context """ - async def import_game_achievements(game_id): - try: - achievements = await self.get_unlocked_achievements(game_id) - self.game_achievements_import_success(game_id, achievements) - except Exception as error: - self.game_achievements_import_failure(game_id, error) + return None - imports = [import_game_achievements(game_id) for game_id in game_ids] - await asyncio.gather(*imports) + async def get_unlocked_achievements(self, game_id: str, context: Any) -> List[Achievement]: + """Override this method to return list of unlocked achievements + for the game identified by the provided game_id. + This method is called by import task initialized by GOG Galaxy Client. + + :param game_id: the id of the game for which the achievements are returned + :param context: the value returned from :meth:`prepare_achievements_context` + :return: list of Achievement objects + """ + raise NotImplementedError() + + def achievements_import_complete(self): + """Override this method to handle operations after achievements import is finished + (like updating cache). + """ async def get_local_games(self) -> List[LocalGame]: """Override this method to return the list of @@ -652,12 +601,12 @@ class Plugin: """ raise NotImplementedError() - async def launch_game(self, game_id: str): + async def launch_game(self, game_id: str) -> None: """Override this method to launch the game identified by the provided game_id. This method is called by the GOG Galaxy Client. - :param str game_id: id of the game to launch + :param str game_id: the id of the game to launch Example of possible override of the method: @@ -670,12 +619,12 @@ class Plugin: """ raise NotImplementedError() - async def install_game(self, game_id: str): + async def install_game(self, game_id: str) -> None: """Override this method to install the game identified by the provided game_id. This method is called by the GOG Galaxy Client. - :param str game_id: id of the game to install + :param str game_id: the id of the game to install Example of possible override of the method: @@ -688,12 +637,12 @@ class Plugin: """ raise NotImplementedError() - async def uninstall_game(self, game_id: str): + async def uninstall_game(self, game_id: str) -> None: """Override this method to uninstall the game identified by the provided game_id. This method is called by the GOG Galaxy Client. - :param str game_id: id of the game to uninstall + :param str game_id: the id of the game to uninstall Example of possible override of the method: @@ -706,6 +655,16 @@ class Plugin: """ raise NotImplementedError() + async def shutdown_platform_client(self) -> None: + """Override this method to gracefully terminate platform client. + This method is called by the GOG Galaxy Client.""" + raise NotImplementedError() + + async def launch_platform_client(self) -> None: + """Override this method to launch platform client. + This method is called by the GOG Galaxy Client.""" + raise NotImplementedError() + async def get_friends(self) -> List[FriendInfo]: """Override this method to return the friends list of the currently authenticated user. @@ -726,105 +685,59 @@ class Plugin: """ raise NotImplementedError() - async def get_users(self, user_id_list: List[str]) -> List[UserInfo]: - """WIP, Override this method to return the list of users matching the provided ids. - This method is called by the GOG Galaxy Client. - - :param user_id_list: list of user ids - """ - raise NotImplementedError() - - async def send_message(self, room_id: str, message_text: str): - """WIP, Override this method to send message to a chat room. - This method is called by the GOG Galaxy Client. - - :param room_id: id of the room to which the message should be sent - :param message_text: text which should be sent in the message - """ - raise NotImplementedError() - - async def mark_as_read(self, room_id: str, last_message_id: str): - """WIP, Override this method to mark messages in a chat room as read up to the id provided in the parameter. - This method is called by the GOG Galaxy Client. - - :param room_id: id of the room - :param last_message_id: id of the last message; room is marked as read only if this id matches - the last message id known to the client - """ - raise NotImplementedError() - - async def get_rooms(self) -> List[Room]: - """WIP, Override this method to return the chat rooms in which the user is currently in. - This method is called by the GOG Galaxy Client - """ - raise NotImplementedError() - - async def get_room_history_from_message(self, room_id: str, message_id: str): - """WIP, Override this method to return the chat room history since the message provided in parameter. - This method is called by the GOG Galaxy Client. - - :param room_id: id of the room - :param message_id: id of the message since which the history should be retrieved - """ - raise NotImplementedError() - - async def get_room_history_from_timestamp(self, room_id: str, from_timestamp: int): - """WIP, Override this method to return the chat room history since the timestamp provided in parameter. - This method is called by the GOG Galaxy Client. - - :param room_id: id of the room - :param from_timestamp: timestamp since which the history should be retrieved - """ - raise NotImplementedError() - - async def get_game_times(self) -> List[GameTime]: - """ - .. deprecated:: 0.33 - Use :meth:`~.import_game_times`. - """ - raise NotImplementedError() - - async def start_game_times_import(self, game_ids: List[str]): - """Starts the task of importing game times - This method is called by the GOG Galaxy Client. - - :param game_ids: ids of the games for which the game time is imported - """ + async def _start_game_times_import(self, game_ids: List[str]) -> None: if self._game_times_import_in_progress: raise ImportInProgress() - async def import_game_times_task(game_ids): - try: - await self.import_game_times(game_ids) - finally: - self.game_times_import_finished() - self._game_times_import_in_progress = False + context = await self.prepare_game_times_context(game_ids) - asyncio.create_task(import_game_times_task(game_ids)) + async def import_game_time(game_id, context_): + try: + game_time = await self.get_game_time(game_id, context_) + self._game_time_import_success(game_time) + except ApplicationError as error: + self._game_time_import_failure(game_id, error) + except Exception: + logging.exception("Unexpected exception raised in import_game_time") + self._game_time_import_failure(game_id, UnknownError()) + + async def import_game_times(game_ids_, context_): + try: + imports = [import_game_time(game_id, context_) for game_id in game_ids_] + await asyncio.gather(*imports) + finally: + self._game_times_import_finished() + self._game_times_import_in_progress = False + self.game_times_import_complete() + + self.create_task(import_game_times(game_ids, context), "Game times import") self._game_times_import_in_progress = True - async def import_game_times(self, game_ids: List[str]): - """ - Override this method to return game times for - games owned by the currently authenticated user. - Call game_time_import_success/game_time_import_failure for each game_id on the list. - This method is called by GOG Galaxy Client. + async def prepare_game_times_context(self, game_ids: List[str]) -> Any: + """Override this method to prepare context for get_game_time. + This allows for optimizations like batch requests to platform API. + Default implementation returns None. - :param game_ids: ids of the games for which the game time is imported + :param game_ids: the ids of the games for which game time are imported + :return: context + """ + return None + + async def get_game_time(self, game_id: str, context: Any) -> GameTime: + """Override this method to return the game time for the game + identified by the provided game_id. + This method is called by import task initialized by GOG Galaxy Client. + + :param game_id: the id of the game for which the game time is returned + :param context: the value returned from :meth:`prepare_game_times_context` + :return: GameTime object + """ + raise NotImplementedError() + + def game_times_import_complete(self) -> None: + """Override this method to handle operations after game times import is finished + (like updating cache). """ - try: - game_times = await self.get_game_times() - game_ids_set = set(game_ids) - for game_time in game_times: - if game_time.game_id not in game_ids_set: - continue - self.game_time_import_success(game_time) - game_ids_set.discard(game_time.game_id) - for game_id in game_ids_set: - self.game_time_import_failure(game_id, UnknownError()) - except Exception as error: - for game_id in game_ids: - self.game_time_import_failure(game_id, error) def create_and_run_plugin(plugin_class, argv): @@ -872,6 +785,9 @@ def create_and_run_plugin(plugin_class, argv): await plugin.run() try: + if sys.platform == "win32": + asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy()) + asyncio.run(coroutine()) except Exception: logging.exception("Error while running plugin") diff --git a/src/galaxy/api/types.py b/src/galaxy/api/types.py index fb3b908..37d55a3 100644 --- a/src/galaxy/api/types.py +++ b/src/galaxy/api/types.py @@ -1,7 +1,7 @@ from dataclasses import dataclass from typing import List, Dict, Optional -from galaxy.api.consts import LicenseType, LocalGameState, PresenceState +from galaxy.api.consts import LicenseType, LocalGameState @dataclass class Authentication(): @@ -61,7 +61,6 @@ class NextStep(): :param auth_params: configuration options: {"window_title": :class:`str`, "window_width": :class:`str`, "window_height": :class:`int`, "start_uri": :class:`int`, "end_uri_regex": :class:`str`} :param cookies: browser initial set of cookies :param js: a map of the url regex patterns into the list of *js* scripts that should be executed on every document at given step of internal browser authentication. - """ next_step: str auth_params: Dict[str, str] @@ -130,34 +129,6 @@ class LocalGame(): game_id: str local_game_state: LocalGameState -@dataclass -class Presence(): - """Information about a presence of a user. - - :param presence_state: the state in which the user's presence is - :param game_id: id of the game which the user is currently playing - :param presence_status: optional attached string with the detailed description of the user's presence - """ - presence_state: PresenceState - game_id: Optional[str] = None - presence_status: Optional[str] = None - -@dataclass -class UserInfo(): - """Detailed information about a user. - - :param user_id: of the user - :param is_friend: whether the user is a friend of the currently authenticated user - :param user_name: of the user - :param avatar_url: to the avatar of the user - :param presence: about the users presence - """ - user_id: str - is_friend: bool - user_name: str - avatar_url: str - presence: Presence - @dataclass class FriendInfo(): """Information about a friend of the currently authenticated user. @@ -168,32 +139,6 @@ class FriendInfo(): user_id: str user_name: str -@dataclass -class Room(): - """WIP, Chatroom. - - :param room_id: id of the room - :param unread_message_count: number of unread messages in the room - :param last_message_id: id of the last message in the room - """ - room_id: str - unread_message_count: int - last_message_id: str - -@dataclass -class Message(): - """WIP, A chatroom message. - - :param message_id: id of the message - :param sender_id: id of the sender of the message - :param sent_time: time at which the message was sent - :param message_text: text attached to the message - """ - message_id: str - sender_id: str - sent_time: int - message_text: str - @dataclass class GameTime(): """Game time of a game, defines the total time spent in the game @@ -204,5 +149,5 @@ class GameTime(): :param last_time_played: last time the game was played (**unix timestamp**) """ game_id: str - time_played: int - last_played_time: int + time_played: Optional[int] + last_played_time: Optional[int] diff --git a/src/galaxy/http.py b/src/galaxy/http.py index 667f55a..1e6db79 100644 --- a/src/galaxy/http.py +++ b/src/galaxy/http.py @@ -1,5 +1,6 @@ import asyncio import ssl +from contextlib import contextmanager from http import HTTPStatus import aiohttp @@ -12,44 +13,69 @@ from galaxy.api.errors import ( ) +DEFAULT_LIMIT = 20 +DEFAULT_TIMEOUT = 60 # seconds + + class HttpClient: - def __init__(self, limit=20, timeout=aiohttp.ClientTimeout(total=60), cookie_jar=None): - ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) - ssl_context.load_verify_locations(certifi.where()) - connector = aiohttp.TCPConnector(limit=limit, ssl=ssl_context) - self._session = aiohttp.ClientSession(connector=connector, timeout=timeout, cookie_jar=cookie_jar) + """Deprecated""" + def __init__(self, limit=DEFAULT_LIMIT, timeout=aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT), cookie_jar=None): + connector = create_tcp_connector(limit=limit) + self._session = create_client_session(connector=connector, timeout=timeout, cookie_jar=cookie_jar) async def close(self): await self._session.close() async def request(self, method, url, *args, **kwargs): - try: - response = await self._session.request(method, url, *args, **kwargs) - except asyncio.TimeoutError: - raise BackendTimeout() - except aiohttp.ServerDisconnectedError: - raise BackendNotAvailable() - except aiohttp.ClientConnectionError: - raise NetworkError() - except aiohttp.ContentTypeError: - raise UnknownBackendResponse() - except aiohttp.ClientError: - logging.exception( - "Caught exception while running {} request for {}".format(method, url)) - raise UnknownError() - if response.status == HTTPStatus.UNAUTHORIZED: - raise AuthenticationRequired() - if response.status == HTTPStatus.FORBIDDEN: - raise AccessDenied() - if response.status == HTTPStatus.SERVICE_UNAVAILABLE: - raise BackendNotAvailable() - if response.status == HTTPStatus.TOO_MANY_REQUESTS: - raise TooManyRequests() - if response.status >= 500: - raise BackendError() - if response.status >= 400: - logging.warning( - "Got status {} while running {} request for {}".format(response.status, method, url)) - raise UnknownError() + with handle_exception(): + return await self._session.request(method, url, *args, **kwargs) + + +def create_tcp_connector(*args, **kwargs): + ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) + ssl_context.load_verify_locations(certifi.where()) + kwargs.setdefault("ssl", ssl_context) + kwargs.setdefault("limit", DEFAULT_LIMIT) + return aiohttp.TCPConnector(*args, **kwargs) + + +def create_client_session(*args, **kwargs): + kwargs.setdefault("connector", create_tcp_connector()) + kwargs.setdefault("timeout", aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT)) + kwargs.setdefault("raise_for_status", True) + return aiohttp.ClientSession(*args, **kwargs) + + +@contextmanager +def handle_exception(): + try: + yield + except asyncio.TimeoutError: + raise BackendTimeout() + except aiohttp.ServerDisconnectedError: + raise BackendNotAvailable() + except aiohttp.ClientConnectionError: + raise NetworkError() + except aiohttp.ContentTypeError: + raise UnknownBackendResponse() + except aiohttp.ClientResponseError as error: + if error.status == HTTPStatus.UNAUTHORIZED: + raise AuthenticationRequired() + if error.status == HTTPStatus.FORBIDDEN: + raise AccessDenied() + if error.status == HTTPStatus.SERVICE_UNAVAILABLE: + raise BackendNotAvailable() + if error.status == HTTPStatus.TOO_MANY_REQUESTS: + raise TooManyRequests() + if error.status >= 500: + raise BackendError() + if error.status >= 400: + logging.warning( + "Got status %d while performing %s request for %s", + error.status, error.request_info.method, str(error.request_info.url) + ) + raise UnknownError() + except aiohttp.ClientError: + logging.exception("Caught exception while performing request") + raise UnknownError() - return response diff --git a/src/galaxy/proc_tools.py b/src/galaxy/proc_tools.py new file mode 100644 index 0000000..b0de0bc --- /dev/null +++ b/src/galaxy/proc_tools.py @@ -0,0 +1,88 @@ +import sys +from dataclasses import dataclass +from typing import Iterable, NewType, Optional, List, cast + + + +ProcessId = NewType("ProcessId", int) + + +@dataclass +class ProcessInfo: + pid: ProcessId + binary_path: Optional[str] + + +if sys.platform == "win32": + from ctypes import byref, sizeof, windll, create_unicode_buffer, FormatError, WinError + from ctypes.wintypes import DWORD + + + def pids() -> Iterable[ProcessId]: + _PROC_ID_T = DWORD + list_size = 4096 + + def try_get_pids(list_size: int) -> List[ProcessId]: + result_size = DWORD() + proc_id_list = (_PROC_ID_T * list_size)() + + if not windll.psapi.EnumProcesses(byref(proc_id_list), sizeof(proc_id_list), byref(result_size)): + raise WinError(descr="Failed to get process ID list: %s" % FormatError()) # type: ignore + + return cast(List[ProcessId], proc_id_list[:int(result_size.value / sizeof(_PROC_ID_T()))]) + + while True: + proc_ids = try_get_pids(list_size) + if len(proc_ids) < list_size: + return proc_ids + + list_size *= 2 + + + def get_process_info(pid: ProcessId) -> Optional[ProcessInfo]: + _PROC_QUERY_LIMITED_INFORMATION = 0x1000 + + process_info = ProcessInfo(pid=pid, binary_path=None) + + h_process = windll.kernel32.OpenProcess(_PROC_QUERY_LIMITED_INFORMATION, False, pid) + if not h_process: + return process_info + + try: + def get_exe_path() -> Optional[str]: + _MAX_PATH = 260 + _WIN32_PATH_FORMAT = 0x0000 + + exe_path_buffer = create_unicode_buffer(_MAX_PATH) + exe_path_len = DWORD(len(exe_path_buffer)) + + return cast(str, exe_path_buffer[:exe_path_len.value]) if windll.kernel32.QueryFullProcessImageNameW( + h_process, _WIN32_PATH_FORMAT, exe_path_buffer, byref(exe_path_len) + ) else None + + process_info.binary_path = get_exe_path() + finally: + windll.kernel32.CloseHandle(h_process) + return process_info +else: + import psutil + + + def pids() -> Iterable[ProcessId]: + for pid in psutil.pids(): + yield pid + + + def get_process_info(pid: ProcessId) -> Optional[ProcessInfo]: + process_info = ProcessInfo(pid=pid, binary_path=None) + try: + process_info.binary_path = psutil.Process(pid=pid).as_dict(attrs=["exe"])["exe"] + except psutil.NoSuchProcess: + pass + finally: + return process_info + + +def process_iter() -> Iterable[Optional[ProcessInfo]]: + for pid in pids(): + yield get_process_info(pid) diff --git a/src/galaxy/reader.py b/src/galaxy/reader.py new file mode 100644 index 0000000..551f803 --- /dev/null +++ b/src/galaxy/reader.py @@ -0,0 +1,28 @@ +from asyncio import StreamReader + + +class StreamLineReader: + """Handles StreamReader readline without buffer limit""" + def __init__(self, reader: StreamReader): + self._reader = reader + self._buffer = bytes() + self._processed_buffer_it = 0 + + async def readline(self): + while True: + # check if there is no unprocessed data in the buffer + if not self._buffer or self._processed_buffer_it != 0: + chunk = await self._reader.read(1024) + if not chunk: + return bytes() # EOF + self._buffer += chunk + + it = self._buffer.find(b"\n", self._processed_buffer_it) + if it < 0: + self._processed_buffer_it = len(self._buffer) + continue + + line = self._buffer[:it] + self._buffer = self._buffer[it+1:] + self._processed_buffer_it = 0 + return line diff --git a/src/galaxy/tools.py b/src/galaxy/tools.py index 3996d25..8cb5540 100644 --- a/src/galaxy/tools.py +++ b/src/galaxy/tools.py @@ -3,6 +3,7 @@ import os import zipfile from glob import glob + def zip_folder(folder): files = glob(os.path.join(folder, "**"), recursive=True) files = [file.replace(folder + os.sep, "") for file in files] @@ -14,6 +15,7 @@ def zip_folder(folder): zipf.write(os.path.join(folder, file), arcname=file) return zip_buffer + def zip_folder_to_file(folder, filename): zip_content = zip_folder(folder).getbuffer() with open(filename, "wb") as archive: diff --git a/src/galaxy/unittest/mock.py b/src/galaxy/unittest/mock.py index 264c3fa..d5fc0af 100644 --- a/src/galaxy/unittest/mock.py +++ b/src/galaxy/unittest/mock.py @@ -1,12 +1,31 @@ -from asyncio import coroutine +import asyncio from unittest.mock import MagicMock + class AsyncMock(MagicMock): + """ + ..deprecated:: 0.45 + Use: :class:`MagicMock` with meth:`~.async_return_value`. + """ async def __call__(self, *args, **kwargs): return super(AsyncMock, self).__call__(*args, **kwargs) + def coroutine_mock(): + """ + ..deprecated:: 0.45 + Use: :class:`MagicMock` with meth:`~.async_return_value`. + """ coro = MagicMock(name="CoroutineResult") - corofunc = MagicMock(name="CoroutineFunction", side_effect=coroutine(coro)) + corofunc = MagicMock(name="CoroutineFunction", side_effect=asyncio.coroutine(coro)) corofunc.coro = coro - return corofunc \ No newline at end of file + return corofunc + +async def skip_loop(iterations=1): + for _ in range(iterations): + await asyncio.sleep(0) + + +async def async_return_value(return_value, loop_iterations_delay=0): + await skip_loop(loop_iterations_delay) + return return_value diff --git a/tests/__init__.py b/tests/__init__.py index e69de29..140adbd 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -0,0 +1,19 @@ +import json + + +def create_message(request): + return json.dumps(request).encode() + b"\n" + + +def get_messages(write_mock): + messages = [] + print("call_args_list", write_mock.call_args_list) + for call_args in write_mock.call_args_list: + print("call_args", call_args) + data = call_args[0][0] + print("data", data) + for line in data.splitlines(): + message = json.loads(line) + messages.append(message) + return messages + diff --git a/tests/conftest.py b/tests/conftest.py index d373c32..23bdadc 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -6,24 +6,23 @@ import pytest from galaxy.api.plugin import Plugin from galaxy.api.consts import Platform -from galaxy.unittest.mock import AsyncMock, coroutine_mock @pytest.fixture() def reader(): stream = MagicMock(name="stream_reader") - stream.readline = AsyncMock() + stream.read = MagicMock() yield stream @pytest.fixture() -def writer(): +async def writer(): stream = MagicMock(name="stream_writer") stream.write = MagicMock() - stream.drain = AsyncMock() + stream.drain = MagicMock() yield stream @pytest.fixture() -def readline(reader): - yield reader.readline +def read(reader): + yield reader.read @pytest.fixture() def write(writer): @@ -32,37 +31,33 @@ def write(writer): @pytest.fixture() def plugin(reader, writer): """Return plugin instance with all feature methods mocked""" - async_methods = ( + methods = ( "handshake_complete", "authenticate", "get_owned_games", + "prepare_achievements_context", "get_unlocked_achievements", + "achievements_import_complete", "get_local_games", "launch_game", + "launch_platform_client", "install_game", "uninstall_game", "get_friends", - "get_users", - "send_message", - "mark_as_read", - "get_rooms", - "get_room_history_from_message", - "get_room_history_from_timestamp", - "get_game_times" - ) - - methods = ( + "get_game_time", + "prepare_game_times_context", + "game_times_import_complete", + "shutdown_platform_client", "shutdown", "tick" ) with ExitStack() as stack: - for method in async_methods: - stack.enter_context(patch.object(Plugin, method, new_callable=coroutine_mock)) for method in methods: stack.enter_context(patch.object(Plugin, method)) yield Plugin(Platform.Generic, "0.1", reader, writer, "token") + @pytest.fixture(autouse=True) def my_caplog(caplog): caplog.set_level(logging.DEBUG) diff --git a/tests/test_achievements.py b/tests/test_achievements.py index 84421bd..1d7abda 100644 --- a/tests/test_achievements.py +++ b/tests/test_achievements.py @@ -1,94 +1,205 @@ -import asyncio import json -from unittest.mock import call import pytest from pytest import raises from galaxy.api.types import Achievement -from galaxy.api.errors import UnknownError, ImportInProgress, BackendError +from galaxy.api.errors import BackendError +from galaxy.unittest.mock import async_return_value + +from tests import create_message, get_messages + def test_initialization_no_unlock_time(): with raises(Exception): Achievement(achievement_id="lvl30", achievement_name="Got level 30") + def test_initialization_no_id_nor_name(): with raises(AssertionError): Achievement(unlock_time=1234567890) -def test_success(plugin, readline, write): + +@pytest.mark.asyncio +async def test_get_unlocked_achievements_success(plugin, read, write): + plugin.prepare_achievements_context.return_value = async_return_value(5) request = { "jsonrpc": "2.0", "id": "3", - "method": "import_unlocked_achievements", + "method": "start_achievements_import", "params": { - "game_id": "14" + "game_ids": ["14"] } } - readline.side_effect = [json.dumps(request), ""] - plugin.get_unlocked_achievements.coro.return_value = [ + read.side_effect = [async_return_value(create_message(request)), async_return_value(b"", 10)] + plugin.get_unlocked_achievements.return_value = async_return_value([ Achievement(achievement_id="lvl10", unlock_time=1548421241), Achievement(achievement_name="Got level 20", unlock_time=1548422395), Achievement(achievement_id="lvl30", achievement_name="Got level 30", unlock_time=1548495633) - ] - asyncio.run(plugin.run()) - plugin.get_unlocked_achievements.assert_called_with(game_id="14") - response = json.loads(write.call_args[0][0]) + ]) + await plugin.run() + plugin.prepare_achievements_context.assert_called_with(["14"]) + plugin.get_unlocked_achievements.assert_called_with("14", 5) + plugin.achievements_import_complete.asert_called_with() - assert response == { - "jsonrpc": "2.0", - "id": "3", - "result": { - "unlocked_achievements": [ - { - "achievement_id": "lvl10", - "unlock_time": 1548421241 - }, - { - "achievement_name": "Got level 20", - "unlock_time": 1548422395 - }, - { - "achievement_id": "lvl30", - "achievement_name": "Got level 30", - "unlock_time": 1548495633 - } - ] + assert get_messages(write) == [ + { + "jsonrpc": "2.0", + "id": "3", + "result": None + }, + { + "jsonrpc": "2.0", + "method": "game_achievements_import_success", + "params": { + "game_id": "14", + "unlocked_achievements": [ + { + "achievement_id": "lvl10", + "unlock_time": 1548421241 + }, + { + "achievement_name": "Got level 20", + "unlock_time": 1548422395 + }, + { + "achievement_id": "lvl30", + "achievement_name": "Got level 30", + "unlock_time": 1548495633 + } + ] + } + }, + { + "jsonrpc": "2.0", + "method": "achievements_import_finished", + "params": None } - } + ] -def test_failure(plugin, readline, write): + +@pytest.mark.asyncio +@pytest.mark.parametrize("exception,code,message", [ + (BackendError, 4, "Backend error"), + (KeyError, 0, "Unknown error") +]) +async def test_get_unlocked_achievements_error(exception, code, message, plugin, read, write): + plugin.prepare_achievements_context.return_value = async_return_value(None) request = { "jsonrpc": "2.0", "id": "3", - "method": "import_unlocked_achievements", + "method": "start_achievements_import", "params": { - "game_id": "14" + "game_ids": ["14"] } } - readline.side_effect = [json.dumps(request), ""] - plugin.get_unlocked_achievements.coro.side_effect = UnknownError() - asyncio.run(plugin.run()) + read.side_effect = [async_return_value(create_message(request)), async_return_value(b"", 10)] + plugin.get_unlocked_achievements.side_effect = exception + await plugin.run() plugin.get_unlocked_achievements.assert_called() - response = json.loads(write.call_args[0][0]) + plugin.achievements_import_complete.asert_called_with() - assert response == { + assert get_messages(write) == [ + { + "jsonrpc": "2.0", + "id": "3", + "result": None + }, + { + "jsonrpc": "2.0", + "method": "game_achievements_import_failure", + "params": { + "game_id": "14", + "error": { + "code": code, + "message": message + } + } + }, + { + "jsonrpc": "2.0", + "method": "achievements_import_finished", + "params": None + } + ] + +@pytest.mark.asyncio +async def test_prepare_get_unlocked_achievements_context_error(plugin, read, write): + plugin.prepare_achievements_context.side_effect = BackendError() + request = { "jsonrpc": "2.0", "id": "3", - "error": { - "code": 0, - "message": "Unknown error" + "method": "start_achievements_import", + "params": { + "game_ids": ["14"] } } + read.side_effect = [async_return_value(create_message(request)), async_return_value(b"")] + await plugin.run() -def test_unlock_achievement(plugin, write): + assert get_messages(write) == [ + { + "jsonrpc": "2.0", + "id": "3", + "error": { + "code": 4, + "message": "Backend error" + } + } + ] + + +@pytest.mark.asyncio +async def test_import_in_progress(plugin, read, write): + plugin.prepare_achievements_context.return_value = async_return_value(None) + requests = [ + { + "jsonrpc": "2.0", + "id": "3", + "method": "start_achievements_import", + "params": { + "game_ids": ["14"] + } + }, + { + "jsonrpc": "2.0", + "id": "4", + "method": "start_achievements_import", + "params": { + "game_ids": ["15"] + } + } + ] + read.side_effect = [ + async_return_value(create_message(requests[0])), + async_return_value(create_message(requests[1])), + async_return_value(b"") + ] + + await plugin.run() + + assert get_messages(write) == [ + { + "jsonrpc": "2.0", + "id": "3", + "result": None + }, + { + "jsonrpc": "2.0", + "id": "4", + "error": { + "code": 600, + "message": "Import already in progress" + } + } + ] + + +@pytest.mark.asyncio +async def test_unlock_achievement(plugin, write): achievement = Achievement(achievement_id="lvl20", unlock_time=1548422395) - - async def couritine(): - plugin.unlock_achievement("14", achievement) - - asyncio.run(couritine()) + plugin.unlock_achievement("14", achievement) response = json.loads(write.call_args[0][0]) assert response == { @@ -102,92 +213,3 @@ def test_unlock_achievement(plugin, write): } } } - -@pytest.mark.asyncio -async def test_game_achievements_import_success(plugin, write): - achievements = [ - Achievement(achievement_id="lvl10", unlock_time=1548421241), - Achievement(achievement_name="Got level 20", unlock_time=1548422395) - ] - plugin.game_achievements_import_success("134", achievements) - response = json.loads(write.call_args[0][0]) - - assert response == { - "jsonrpc": "2.0", - "method": "game_achievements_import_success", - "params": { - "game_id": "134", - "unlocked_achievements": [ - { - "achievement_id": "lvl10", - "unlock_time": 1548421241 - }, - { - "achievement_name": "Got level 20", - "unlock_time": 1548422395 - } - ] - } - } - -@pytest.mark.asyncio -async def test_game_achievements_import_failure(plugin, write): - plugin.game_achievements_import_failure("134", ImportInProgress()) - response = json.loads(write.call_args[0][0]) - - assert response == { - "jsonrpc": "2.0", - "method": "game_achievements_import_failure", - "params": { - "game_id": "134", - "error": { - "code": 600, - "message": "Import already in progress" - } - } - } - -@pytest.mark.asyncio -async def test_achievements_import_finished(plugin, write): - plugin.achievements_import_finished() - response = json.loads(write.call_args[0][0]) - - assert response == { - "jsonrpc": "2.0", - "method": "achievements_import_finished", - "params": None - } - -@pytest.mark.asyncio -async def test_start_achievements_import(plugin, write, mocker): - game_achievements_import_success = mocker.patch.object(plugin, "game_achievements_import_success") - game_achievements_import_failure = mocker.patch.object(plugin, "game_achievements_import_failure") - achievements_import_finished = mocker.patch.object(plugin, "achievements_import_finished") - - game_ids = ["1", "5", "9"] - error = BackendError() - achievements = [ - Achievement(achievement_id="lvl10", unlock_time=1548421241), - Achievement(achievement_name="Got level 20", unlock_time=1548422395) - ] - plugin.get_unlocked_achievements.coro.side_effect = [ - achievements, - [], - error - ] - await plugin.start_achievements_import(game_ids) - - with pytest.raises(ImportInProgress): - await plugin.start_achievements_import(["4", "8"]) - - # wait until all tasks are finished - for _ in range(4): - await asyncio.sleep(0) - - plugin.get_unlocked_achievements.coro.assert_has_calls([call("1"), call("5"), call("9")]) - game_achievements_import_success.assert_has_calls([ - call("1", achievements), - call("5", []) - ]) - game_achievements_import_failure.assert_called_once_with("9", error) - achievements_import_finished.assert_called_once_with() diff --git a/tests/test_authenticate.py b/tests/test_authenticate.py index 6cb96a6..e43d59c 100644 --- a/tests/test_authenticate.py +++ b/tests/test_authenticate.py @@ -1,6 +1,3 @@ -import asyncio -import json - import pytest from galaxy.api.types import Authentication @@ -8,29 +5,36 @@ from galaxy.api.errors import ( UnknownError, InvalidCredentials, NetworkError, LoggedInElsewhere, ProtocolError, BackendNotAvailable, BackendTimeout, BackendError, TemporaryBlocked, Banned, AccessDenied ) +from galaxy.unittest.mock import async_return_value -def test_success(plugin, readline, write): +from tests import create_message, get_messages + + +@pytest.mark.asyncio +async def test_success(plugin, read, write): request = { "jsonrpc": "2.0", "id": "3", "method": "init_authentication" } - - readline.side_effect = [json.dumps(request), ""] - plugin.authenticate.coro.return_value = Authentication("132", "Zenek") - asyncio.run(plugin.run()) + read.side_effect = [async_return_value(create_message(request)), async_return_value(b"")] + plugin.authenticate.return_value = async_return_value(Authentication("132", "Zenek")) + await plugin.run() plugin.authenticate.assert_called_with() - response = json.loads(write.call_args[0][0]) - assert response == { - "jsonrpc": "2.0", - "id": "3", - "result": { - "user_id": "132", - "user_name": "Zenek" + assert get_messages(write) == [ + { + "jsonrpc": "2.0", + "id": "3", + "result": { + "user_id": "132", + "user_name": "Zenek" + } } - } + ] + +@pytest.mark.asyncio @pytest.mark.parametrize("error,code,message", [ pytest.param(UnknownError, 0, "Unknown error", id="unknown_error"), pytest.param(BackendNotAvailable, 2, "Backend not available", id="backend_not_available"), @@ -44,29 +48,32 @@ def test_success(plugin, readline, write): pytest.param(Banned, 105, "Banned", id="banned"), pytest.param(AccessDenied, 106, "Access denied", id="access_denied"), ]) -def test_failure(plugin, readline, write, error, code, message): +async def test_failure(plugin, read, write, error, code, message): request = { "jsonrpc": "2.0", "id": "3", "method": "init_authentication" } - readline.side_effect = [json.dumps(request), ""] - plugin.authenticate.coro.side_effect = error() - asyncio.run(plugin.run()) + read.side_effect = [async_return_value(create_message(request)), async_return_value(b"")] + plugin.authenticate.side_effect = error() + await plugin.run() plugin.authenticate.assert_called_with() - response = json.loads(write.call_args[0][0]) - assert response == { - "jsonrpc": "2.0", - "id": "3", - "error": { - "code": code, - "message": message + assert get_messages(write) == [ + { + "jsonrpc": "2.0", + "id": "3", + "error": { + "code": code, + "message": message + } } - } + ] -def test_stored_credentials(plugin, readline, write): + +@pytest.mark.asyncio +async def test_stored_credentials(plugin, read, write): request = { "jsonrpc": "2.0", "id": "3", @@ -77,39 +84,37 @@ def test_stored_credentials(plugin, readline, write): } } } - readline.side_effect = [json.dumps(request), ""] - plugin.authenticate.coro.return_value = Authentication("132", "Zenek") - asyncio.run(plugin.run()) + read.side_effect = [async_return_value(create_message(request)), async_return_value(b"")] + plugin.authenticate.return_value = async_return_value(Authentication("132", "Zenek")) + await plugin.run() plugin.authenticate.assert_called_with(stored_credentials={"token": "ABC"}) write.assert_called() -def test_store_credentials(plugin, write): + +@pytest.mark.asyncio +async def test_store_credentials(plugin, write): credentials = { "token": "ABC" } + plugin.store_credentials(credentials) - async def couritine(): - plugin.store_credentials(credentials) + assert get_messages(write) == [ + { + "jsonrpc": "2.0", + "method": "store_credentials", + "params": credentials + } + ] - asyncio.run(couritine()) - response = json.loads(write.call_args[0][0]) - assert response == { - "jsonrpc": "2.0", - "method": "store_credentials", - "params": credentials - } +@pytest.mark.asyncio +async def test_lost_authentication(plugin, write): + plugin.lost_authentication() -def test_lost_authentication(plugin, readline, write): - - async def couritine(): - plugin.lost_authentication() - - asyncio.run(couritine()) - response = json.loads(write.call_args[0][0]) - - assert response == { - "jsonrpc": "2.0", - "method": "authentication_lost", - "params": None - } + assert get_messages(write) == [ + { + "jsonrpc": "2.0", + "method": "authentication_lost", + "params": None + } + ] diff --git a/tests/test_chat.py b/tests/test_chat.py deleted file mode 100644 index 97dad89..0000000 --- a/tests/test_chat.py +++ /dev/null @@ -1,354 +0,0 @@ -import asyncio -import json - -import pytest - -from galaxy.api.types import Room, Message -from galaxy.api.errors import ( - UnknownError, AuthenticationRequired, BackendNotAvailable, BackendTimeout, BackendError, - TooManyMessagesSent, IncoherentLastMessage, MessageNotFound -) - -def test_send_message_success(plugin, readline, write): - request = { - "jsonrpc": "2.0", - "id": "3", - "method": "send_message", - "params": { - "room_id": "14", - "message": "Hello!" - } - } - - readline.side_effect = [json.dumps(request), ""] - plugin.send_message.coro.return_value = None - asyncio.run(plugin.run()) - plugin.send_message.assert_called_with(room_id="14", message="Hello!") - response = json.loads(write.call_args[0][0]) - - assert response == { - "jsonrpc": "2.0", - "id": "3", - "result": None - } - -@pytest.mark.parametrize("error,code,message", [ - pytest.param(UnknownError, 0, "Unknown error", id="unknown_error"), - pytest.param(AuthenticationRequired, 1, "Authentication required", id="not_authenticated"), - pytest.param(BackendNotAvailable, 2, "Backend not available", id="backend_not_available"), - pytest.param(BackendTimeout, 3, "Backend timed out", id="backend_timeout"), - pytest.param(BackendError, 4, "Backend error", id="backend_error"), - pytest.param(TooManyMessagesSent, 300, "Too many messages sent", id="too_many_messages") -]) -def test_send_message_failure(plugin, readline, write, error, code, message): - request = { - "jsonrpc": "2.0", - "id": "6", - "method": "send_message", - "params": { - "room_id": "15", - "message": "Bye" - } - } - - readline.side_effect = [json.dumps(request), ""] - plugin.send_message.coro.side_effect = error() - asyncio.run(plugin.run()) - plugin.send_message.assert_called_with(room_id="15", message="Bye") - response = json.loads(write.call_args[0][0]) - - assert response == { - "jsonrpc": "2.0", - "id": "6", - "error": { - "code": code, - "message": message - } - } - -def test_mark_as_read_success(plugin, readline, write): - request = { - "jsonrpc": "2.0", - "id": "7", - "method": "mark_as_read", - "params": { - "room_id": "14", - "last_message_id": "67" - } - } - - readline.side_effect = [json.dumps(request), ""] - plugin.mark_as_read.coro.return_value = None - asyncio.run(plugin.run()) - plugin.mark_as_read.assert_called_with(room_id="14", last_message_id="67") - response = json.loads(write.call_args[0][0]) - - assert response == { - "jsonrpc": "2.0", - "id": "7", - "result": None - } - -@pytest.mark.parametrize("error,code,message", [ - pytest.param(UnknownError, 0, "Unknown error", id="unknown_error"), - pytest.param(AuthenticationRequired, 1, "Authentication required", id="not_authenticated"), - pytest.param(BackendNotAvailable, 2, "Backend not available", id="backend_not_available"), - pytest.param(BackendTimeout, 3, "Backend timed out", id="backend_timeout"), - pytest.param(BackendError, 4, "Backend error", id="backend_error"), - pytest.param( - IncoherentLastMessage, - 400, - "Different last message id on backend", - id="incoherent_last_message" - ) -]) -def test_mark_as_read_failure(plugin, readline, write, error, code, message): - request = { - "jsonrpc": "2.0", - "id": "4", - "method": "mark_as_read", - "params": { - "room_id": "18", - "last_message_id": "7" - } - } - - readline.side_effect = [json.dumps(request), ""] - plugin.mark_as_read.coro.side_effect = error() - asyncio.run(plugin.run()) - plugin.mark_as_read.assert_called_with(room_id="18", last_message_id="7") - response = json.loads(write.call_args[0][0]) - - assert response == { - "jsonrpc": "2.0", - "id": "4", - "error": { - "code": code, - "message": message - } - } - -def test_get_rooms_success(plugin, readline, write): - request = { - "jsonrpc": "2.0", - "id": "2", - "method": "import_rooms" - } - - readline.side_effect = [json.dumps(request), ""] - plugin.get_rooms.coro.return_value = [ - Room("13", 0, None), - Room("15", 34, "8") - ] - asyncio.run(plugin.run()) - plugin.get_rooms.assert_called_with() - response = json.loads(write.call_args[0][0]) - - assert response == { - "jsonrpc": "2.0", - "id": "2", - "result": { - "rooms": [ - { - "room_id": "13", - "unread_message_count": 0, - }, - { - "room_id": "15", - "unread_message_count": 34, - "last_message_id": "8" - } - ] - } - } - -def test_get_rooms_failure(plugin, readline, write): - request = { - "jsonrpc": "2.0", - "id": "9", - "method": "import_rooms" - } - - readline.side_effect = [json.dumps(request), ""] - plugin.get_rooms.coro.side_effect = UnknownError() - asyncio.run(plugin.run()) - plugin.get_rooms.assert_called_with() - response = json.loads(write.call_args[0][0]) - - assert response == { - "jsonrpc": "2.0", - "id": "9", - "error": { - "code": 0, - "message": "Unknown error" - } - } - -def test_get_room_history_from_message_success(plugin, readline, write): - request = { - "jsonrpc": "2.0", - "id": "2", - "method": "import_room_history_from_message", - "params": { - "room_id": "34", - "message_id": "66" - } - } - - readline.side_effect = [json.dumps(request), ""] - plugin.get_room_history_from_message.coro.return_value = [ - Message("13", "149", 1549454837, "Hello"), - Message("14", "812", 1549454899, "Hi") - ] - asyncio.run(plugin.run()) - plugin.get_room_history_from_message.assert_called_with(room_id="34", message_id="66") - response = json.loads(write.call_args[0][0]) - - assert response == { - "jsonrpc": "2.0", - "id": "2", - "result": { - "messages": [ - { - "message_id": "13", - "sender_id": "149", - "sent_time": 1549454837, - "message_text": "Hello" - }, - { - "message_id": "14", - "sender_id": "812", - "sent_time": 1549454899, - "message_text": "Hi" - } - ] - } - } - -@pytest.mark.parametrize("error,code,message", [ - pytest.param(UnknownError, 0, "Unknown error", id="unknown_error"), - pytest.param(AuthenticationRequired, 1, "Authentication required", id="not_authenticated"), - pytest.param(BackendNotAvailable, 2, "Backend not available", id="backend_not_available"), - pytest.param(BackendTimeout, 3, "Backend timed out", id="backend_timeout"), - pytest.param(BackendError, 4, "Backend error", id="backend_error"), - pytest.param(MessageNotFound, 500, "Message not found", id="message_not_found") -]) -def test_get_room_history_from_message_failure(plugin, readline, write, error, code, message): - request = { - "jsonrpc": "2.0", - "id": "7", - "method": "import_room_history_from_message", - "params": { - "room_id": "33", - "message_id": "88" - } - } - - readline.side_effect = [json.dumps(request), ""] - plugin.get_room_history_from_message.coro.side_effect = error() - asyncio.run(plugin.run()) - plugin.get_room_history_from_message.assert_called_with(room_id="33", message_id="88") - response = json.loads(write.call_args[0][0]) - - assert response == { - "jsonrpc": "2.0", - "id": "7", - "error": { - "code": code, - "message": message - } - } - -def test_get_room_history_from_timestamp_success(plugin, readline, write): - request = { - "jsonrpc": "2.0", - "id": "7", - "method": "import_room_history_from_timestamp", - "params": { - "room_id": "12", - "from_timestamp": 1549454835 - } - } - - readline.side_effect = [json.dumps(request), ""] - plugin.get_room_history_from_timestamp.coro.return_value = [ - Message("12", "155", 1549454836, "Bye") - ] - asyncio.run(plugin.run()) - plugin.get_room_history_from_timestamp.assert_called_with( - room_id="12", - from_timestamp=1549454835 - ) - response = json.loads(write.call_args[0][0]) - - assert response == { - "jsonrpc": "2.0", - "id": "7", - "result": { - "messages": [ - { - "message_id": "12", - "sender_id": "155", - "sent_time": 1549454836, - "message_text": "Bye" - } - ] - } - } - -def test_get_room_history_from_timestamp_failure(plugin, readline, write): - request = { - "jsonrpc": "2.0", - "id": "3", - "method": "import_room_history_from_timestamp", - "params": { - "room_id": "10", - "from_timestamp": 1549454800 - } - } - - readline.side_effect = [json.dumps(request), ""] - plugin.get_room_history_from_timestamp.coro.side_effect = UnknownError() - asyncio.run(plugin.run()) - plugin.get_room_history_from_timestamp.assert_called_with( - room_id="10", - from_timestamp=1549454800 - ) - response = json.loads(write.call_args[0][0]) - - assert response == { - "jsonrpc": "2.0", - "id": "3", - "error": { - "code": 0, - "message": "Unknown error" - } - } - -def test_update_room(plugin, write): - messages = [ - Message("10", "898", 1549454832, "Hi") - ] - - async def couritine(): - plugin.update_room("14", 15, messages) - - asyncio.run(couritine()) - response = json.loads(write.call_args[0][0]) - - assert response == { - "jsonrpc": "2.0", - "method": "chat_room_updated", - "params": { - "room_id": "14", - "unread_message_count": 15, - "messages": [ - { - "message_id": "10", - "sender_id": "898", - "sent_time": 1549454832, - "message_text": "Hi" - } - ] - } - } diff --git a/tests/test_chunk_messages.py b/tests/test_chunk_messages.py new file mode 100644 index 0000000..340c880 --- /dev/null +++ b/tests/test_chunk_messages.py @@ -0,0 +1,63 @@ +import json + +import pytest + +from galaxy.unittest.mock import async_return_value + + +@pytest.mark.asyncio +async def test_chunked_messages(plugin, read): + request = { + "jsonrpc": "2.0", + "method": "install_game", + "params": { + "game_id": "3" + } + } + + message = json.dumps(request).encode() + b"\n" + read.side_effect = [async_return_value(message[:5]), async_return_value(message[5:]), async_return_value(b"")] + await plugin.run() + plugin.install_game.assert_called_with(game_id="3") + + +@pytest.mark.asyncio +async def test_joined_messages(plugin, read): + requests = [ + { + "jsonrpc": "2.0", + "method": "install_game", + "params": { + "game_id": "3" + } + }, + { + "jsonrpc": "2.0", + "method": "launch_game", + "params": { + "game_id": "3" + } + } + ] + data = b"".join([json.dumps(request).encode() + b"\n" for request in requests]) + + read.side_effect = [async_return_value(data), async_return_value(b"")] + await plugin.run() + plugin.install_game.assert_called_with(game_id="3") + plugin.launch_game.assert_called_with(game_id="3") + + +@pytest.mark.asyncio +async def test_not_finished(plugin, read): + request = { + "jsonrpc": "2.0", + "method": "install_game", + "params": { + "game_id": "3" + } + } + + message = json.dumps(request).encode() # no new line + read.side_effect = [async_return_value(message), async_return_value(b"")] + await plugin.run() + plugin.install_game.assert_not_called() diff --git a/tests/test_features.py b/tests/test_features.py index 7f11c17..68a6cd0 100644 --- a/tests/test_features.py +++ b/tests/test_features.py @@ -1,45 +1,50 @@ +from galaxy.api.consts import Feature, Platform from galaxy.api.plugin import Plugin -from galaxy.api.consts import Platform, Feature + def test_base_class(): plugin = Plugin(Platform.Generic, "0.1", None, None, None) - assert plugin.features == [] + assert set(plugin.features) == { + Feature.ImportInstalledGames, + Feature.ImportOwnedGames, + Feature.LaunchGame, + Feature.InstallGame, + Feature.UninstallGame, + Feature.ImportAchievements, + Feature.ImportGameTime, + Feature.ImportFriends, + Feature.ShutdownPlatformClient, + Feature.LaunchPlatformClient + } + def test_no_overloads(): - class PluginImpl(Plugin): #pylint: disable=abstract-method + class PluginImpl(Plugin): # pylint: disable=abstract-method pass plugin = PluginImpl(Platform.Generic, "0.1", None, None, None) assert plugin.features == [] + def test_one_method_feature(): - class PluginImpl(Plugin): #pylint: disable=abstract-method + class PluginImpl(Plugin): # pylint: disable=abstract-method async def get_owned_games(self): pass plugin = PluginImpl(Platform.Generic, "0.1", None, None, None) assert plugin.features == [Feature.ImportOwnedGames] -def test_multiple_methods_feature_all(): - class PluginImpl(Plugin): #pylint: disable=abstract-method - async def send_message(self, room_id, message): + +def test_multi_features(): + class PluginImpl(Plugin): # pylint: disable=abstract-method + async def get_owned_games(self): pass - async def mark_as_read(self, room_id, last_message_id): + + async def get_unlocked_achievements(self, game_id, context): pass - async def get_rooms(self): - pass - async def get_room_history_from_message(self, room_id, message_id): - pass - async def get_room_history_from_timestamp(self, room_id, timestamp): + + async def get_game_time(self, game_id, context): pass plugin = PluginImpl(Platform.Generic, "0.1", None, None, None) - assert plugin.features == [Feature.Chat] - -def test_multiple_methods_feature_not_all(): - class PluginImpl(Plugin): #pylint: disable=abstract-method - async def send_message(self, room_id, message): - pass - - plugin = PluginImpl(Platform.Generic, "0.1", None, None, None) - assert plugin.features == [] + assert set(plugin.features) == {Feature.ImportAchievements, Feature.ImportOwnedGames, Feature.ImportGameTime} diff --git a/tests/test_friends.py b/tests/test_friends.py index 52cdd9b..2917fea 100644 --- a/tests/test_friends.py +++ b/tests/test_friends.py @@ -1,90 +1,94 @@ -import asyncio -import json - from galaxy.api.types import FriendInfo from galaxy.api.errors import UnknownError +from galaxy.unittest.mock import async_return_value + +import pytest + +from tests import create_message, get_messages -def test_get_friends_success(plugin, readline, write): +@pytest.mark.asyncio +async def test_get_friends_success(plugin, read, write): request = { "jsonrpc": "2.0", "id": "3", "method": "import_friends" } - readline.side_effect = [json.dumps(request), ""] - plugin.get_friends.coro.return_value = [ + read.side_effect = [async_return_value(create_message(request)), async_return_value(b"")] + plugin.get_friends.return_value = async_return_value([ FriendInfo("3", "Jan"), FriendInfo("5", "Ola") - ] - asyncio.run(plugin.run()) + ]) + await plugin.run() plugin.get_friends.assert_called_with() - response = json.loads(write.call_args[0][0]) - assert response == { - "jsonrpc": "2.0", - "id": "3", - "result": { - "friend_info_list": [ - {"user_id": "3", "user_name": "Jan"}, - {"user_id": "5", "user_name": "Ola"} - ] + assert get_messages(write) == [ + { + "jsonrpc": "2.0", + "id": "3", + "result": { + "friend_info_list": [ + {"user_id": "3", "user_name": "Jan"}, + {"user_id": "5", "user_name": "Ola"} + ] + } } - } + ] -def test_get_friends_failure(plugin, readline, write): +@pytest.mark.asyncio +async def test_get_friends_failure(plugin, read, write): request = { "jsonrpc": "2.0", "id": "3", "method": "import_friends" } - readline.side_effect = [json.dumps(request), ""] - plugin.get_friends.coro.side_effect = UnknownError() - asyncio.run(plugin.run()) + read.side_effect = [async_return_value(create_message(request)), async_return_value(b"")] + plugin.get_friends.side_effect = UnknownError() + await plugin.run() plugin.get_friends.assert_called_with() - response = json.loads(write.call_args[0][0]) - assert response == { - "jsonrpc": "2.0", - "id": "3", - "error": { - "code": 0, - "message": "Unknown error", + assert get_messages(write) == [ + { + "jsonrpc": "2.0", + "id": "3", + "error": { + "code": 0, + "message": "Unknown error", + } } - } + ] -def test_add_friend(plugin, write): +@pytest.mark.asyncio +async def test_add_friend(plugin, write): friend = FriendInfo("7", "Kuba") - async def couritine(): - plugin.add_friend(friend) + plugin.add_friend(friend) - asyncio.run(couritine()) - response = json.loads(write.call_args[0][0]) - - assert response == { - "jsonrpc": "2.0", - "method": "friend_added", - "params": { - "friend_info": {"user_id": "7", "user_name": "Kuba"} + assert get_messages(write) == [ + { + "jsonrpc": "2.0", + "method": "friend_added", + "params": { + "friend_info": {"user_id": "7", "user_name": "Kuba"} + } } - } + ] -def test_remove_friend(plugin, write): - async def couritine(): - plugin.remove_friend("5") +@pytest.mark.asyncio +async def test_remove_friend(plugin, write): + plugin.remove_friend("5") - asyncio.run(couritine()) - response = json.loads(write.call_args[0][0]) - - assert response == { - "jsonrpc": "2.0", - "method": "friend_removed", - "params": { - "user_id": "5" + assert get_messages(write) == [ + { + "jsonrpc": "2.0", + "method": "friend_removed", + "params": { + "user_id": "5" + } } - } + ] diff --git a/tests/test_game_times.py b/tests/test_game_times.py index 9ad7220..877bcc1 100644 --- a/tests/test_game_times.py +++ b/tests/test_game_times.py @@ -1,175 +1,216 @@ -import asyncio -import json from unittest.mock import call import pytest from galaxy.api.types import GameTime -from galaxy.api.errors import UnknownError, ImportInProgress, BackendError +from galaxy.api.errors import BackendError +from galaxy.unittest.mock import async_return_value -def test_success(plugin, readline, write): +from tests import create_message, get_messages + + +@pytest.mark.asyncio +async def test_get_game_time_success(plugin, read, write): + plugin.prepare_game_times_context.return_value = async_return_value("abc") request = { "jsonrpc": "2.0", "id": "3", - "method": "import_game_times" + "method": "start_game_times_import", + "params": { + "game_ids": ["3", "5", "7"] + } } - - readline.side_effect = [json.dumps(request), ""] - plugin.get_game_times.coro.return_value = [ - GameTime("3", 60, 1549550504), - GameTime("5", 10, 1549550502) + read.side_effect = [async_return_value(create_message(request)), async_return_value(b"", 10)] + plugin.get_game_time.side_effect = [ + async_return_value(GameTime("3", 60, 1549550504)), + async_return_value(GameTime("5", 10, None)), + async_return_value(GameTime("7", None, 1549550502)), ] - asyncio.run(plugin.run()) - plugin.get_game_times.assert_called_with() - response = json.loads(write.call_args[0][0]) + await plugin.run() + plugin.get_game_time.assert_has_calls([ + call("3", "abc"), + call("5", "abc"), + call("7", "abc"), + ]) + plugin.game_times_import_complete.assert_called_once_with() - assert response == { - "jsonrpc": "2.0", - "id": "3", - "result": { - "game_times": [ - { + assert get_messages(write) == [ + { + "jsonrpc": "2.0", + "id": "3", + "result": None + }, + { + "jsonrpc": "2.0", + "method": "game_time_import_success", + "params": { + "game_time": { "game_id": "3", - "time_played": 60, - "last_played_time": 1549550504 - }, - { + "last_played_time": 1549550504, + "time_played": 60 + } + } + }, + { + "jsonrpc": "2.0", + "method": "game_time_import_success", + "params": { + "game_time": { "game_id": "5", - "time_played": 10, + "time_played": 10 + } + } + }, + { + "jsonrpc": "2.0", + "method": "game_time_import_success", + "params": { + "game_time": { + "game_id": "7", "last_played_time": 1549550502 } - ] + } + }, + { + "jsonrpc": "2.0", + "method": "game_times_import_finished", + "params": None } - } + ] -def test_failure(plugin, readline, write): + +@pytest.mark.asyncio +@pytest.mark.parametrize("exception,code,message", [ + (BackendError, 4, "Backend error"), + (KeyError, 0, "Unknown error") +]) +async def test_get_game_time_error(exception, code, message, plugin, read, write): + plugin.prepare_game_times_context.return_value = async_return_value(None) request = { "jsonrpc": "2.0", "id": "3", - "method": "import_game_times" + "method": "start_game_times_import", + "params": { + "game_ids": ["6"] + } } + read.side_effect = [async_return_value(create_message(request)), async_return_value(b"", 10)] + plugin.get_game_time.side_effect = exception + await plugin.run() + plugin.get_game_time.assert_called() + plugin.game_times_import_complete.assert_called_once_with() - readline.side_effect = [json.dumps(request), ""] - plugin.get_game_times.coro.side_effect = UnknownError() - asyncio.run(plugin.run()) - plugin.get_game_times.assert_called_with() - response = json.loads(write.call_args[0][0]) + assert get_messages(write) == [ + { + "jsonrpc": "2.0", + "id": "3", + "result": None + }, + { + "jsonrpc": "2.0", + "method": "game_time_import_failure", + "params": { + "game_id": "6", + "error": { + "code": code, + "message": message + } + } + }, + { + "jsonrpc": "2.0", + "method": "game_times_import_finished", + "params": None + } + ] - assert response == { + +@pytest.mark.asyncio +async def test_prepare_get_game_time_context_error(plugin, read, write): + plugin.prepare_game_times_context.side_effect = BackendError() + request = { "jsonrpc": "2.0", "id": "3", - "error": { - "code": 0, - "message": "Unknown error", + "method": "start_game_times_import", + "params": { + "game_ids": ["6"] } } + read.side_effect = [async_return_value(create_message(request)), async_return_value(b"")] + await plugin.run() -def test_update_game(plugin, write): - game_time = GameTime("3", 60, 1549550504) - - async def couritine(): - plugin.update_game_time(game_time) - - asyncio.run(couritine()) - response = json.loads(write.call_args[0][0]) - - assert response == { - "jsonrpc": "2.0", - "method": "game_time_updated", - "params": { - "game_time": { - "game_id": "3", - "time_played": 60, - "last_played_time": 1549550504 + assert get_messages(write) == [ + { + "jsonrpc": "2.0", + "id": "3", + "error": { + "code": 4, + "message": "Backend error" } } - } + ] + @pytest.mark.asyncio -async def test_game_time_import_success(plugin, write): - plugin.game_time_import_success(GameTime("3", 60, 1549550504)) - response = json.loads(write.call_args[0][0]) - - assert response == { - "jsonrpc": "2.0", - "method": "game_time_import_success", - "params": { - "game_time": { - "game_id": "3", - "time_played": 60, - "last_played_time": 1549550504 +async def test_import_in_progress(plugin, read, write): + plugin.prepare_game_times_context.return_value = async_return_value(None) + requests = [ + { + "jsonrpc": "2.0", + "id": "3", + "method": "start_game_times_import", + "params": { + "game_ids": ["6"] + } + }, + { + "jsonrpc": "2.0", + "id": "4", + "method": "start_game_times_import", + "params": { + "game_ids": ["7"] } } - } + ] + read.side_effect = [ + async_return_value(create_message(requests[0])), + async_return_value(create_message(requests[1])), + async_return_value(b"") + ] -@pytest.mark.asyncio -async def test_game_time_import_failure(plugin, write): - plugin.game_time_import_failure("134", ImportInProgress()) - response = json.loads(write.call_args[0][0]) + await plugin.run() - assert response == { - "jsonrpc": "2.0", - "method": "game_time_import_failure", - "params": { - "game_id": "134", + assert get_messages(write) == [ + { + "jsonrpc": "2.0", + "id": "3", + "result": None + }, + { + "jsonrpc": "2.0", + "id": "4", "error": { "code": 600, "message": "Import already in progress" } } - } - -@pytest.mark.asyncio -async def test_game_times_import_finished(plugin, write): - plugin.game_times_import_finished() - response = json.loads(write.call_args[0][0]) - - assert response == { - "jsonrpc": "2.0", - "method": "game_times_import_finished", - "params": None - } - -@pytest.mark.asyncio -async def test_start_game_times_import(plugin, write, mocker): - game_time_import_success = mocker.patch.object(plugin, "game_time_import_success") - game_time_import_failure = mocker.patch.object(plugin, "game_time_import_failure") - game_times_import_finished = mocker.patch.object(plugin, "game_times_import_finished") - - game_ids = ["1", "5"] - game_time = GameTime("1", 10, 1549550502) - plugin.get_game_times.coro.return_value = [ - game_time ] - await plugin.start_game_times_import(game_ids) - with pytest.raises(ImportInProgress): - await plugin.start_game_times_import(["4", "8"]) - - # wait until all tasks are finished - for _ in range(4): - await asyncio.sleep(0) - - plugin.get_game_times.coro.assert_called_once_with() - game_time_import_success.assert_called_once_with(game_time) - game_time_import_failure.assert_called_once_with("5", UnknownError()) - game_times_import_finished.assert_called_once_with() @pytest.mark.asyncio -async def test_start_game_times_import_failure(plugin, write, mocker): - game_time_import_failure = mocker.patch.object(plugin, "game_time_import_failure") - game_times_import_finished = mocker.patch.object(plugin, "game_times_import_finished") +async def test_update_game(plugin, write): + game_time = GameTime("3", 60, 1549550504) + plugin.update_game_time(game_time) - game_ids = ["1", "5"] - error = BackendError() - plugin.get_game_times.coro.side_effect = error - - await plugin.start_game_times_import(game_ids) - - # wait until all tasks are finished - for _ in range(4): - await asyncio.sleep(0) - - plugin.get_game_times.coro.assert_called_once_with() - - assert game_time_import_failure.mock_calls == [call("1", error), call("5", error)] - game_times_import_finished.assert_called_once_with() + assert get_messages(write) == [ + { + "jsonrpc": "2.0", + "method": "game_time_updated", + "params": { + "game_time": { + "game_id": "3", + "time_played": 60, + "last_played_time": 1549550504 + } + } + } + ] diff --git a/tests/test_http.py b/tests/test_http.py new file mode 100644 index 0000000..09aa136 --- /dev/null +++ b/tests/test_http.py @@ -0,0 +1,39 @@ +import asyncio +from http import HTTPStatus + +import aiohttp +import pytest +from multidict import CIMultiDict, CIMultiDictProxy +from yarl import URL + +from galaxy.api.errors import ( + AccessDenied, AuthenticationRequired, BackendTimeout, BackendNotAvailable, BackendError, NetworkError, + TooManyRequests, UnknownBackendResponse, UnknownError +) +from galaxy.http import handle_exception + +request_info = aiohttp.RequestInfo(URL("http://o.pl"), "GET", CIMultiDictProxy(CIMultiDict())) + +@pytest.mark.parametrize( + "aiohttp_exception,expected_exception_type", + [ + (asyncio.TimeoutError(), BackendTimeout), + (aiohttp.ServerDisconnectedError(), BackendNotAvailable), + (aiohttp.ClientConnectionError(), NetworkError), + (aiohttp.ContentTypeError(request_info, ()), UnknownBackendResponse), + (aiohttp.ClientResponseError(request_info, (), status=HTTPStatus.UNAUTHORIZED), AuthenticationRequired), + (aiohttp.ClientResponseError(request_info, (), status=HTTPStatus.FORBIDDEN), AccessDenied), + (aiohttp.ClientResponseError(request_info, (), status=HTTPStatus.SERVICE_UNAVAILABLE), BackendNotAvailable), + (aiohttp.ClientResponseError(request_info, (), status=HTTPStatus.TOO_MANY_REQUESTS), TooManyRequests), + (aiohttp.ClientResponseError(request_info, (), status=HTTPStatus.INTERNAL_SERVER_ERROR), BackendError), + (aiohttp.ClientResponseError(request_info, (), status=HTTPStatus.NOT_IMPLEMENTED), BackendError), + (aiohttp.ClientResponseError(request_info, (), status=HTTPStatus.BAD_REQUEST), UnknownError), + (aiohttp.ClientResponseError(request_info, (), status=HTTPStatus.NOT_FOUND), UnknownError), + (aiohttp.ClientError(), UnknownError) + ] +) +def test_handle_exception(aiohttp_exception, expected_exception_type): + with pytest.raises(expected_exception_type): + with handle_exception(): + raise aiohttp_exception + diff --git a/tests/test_install_game.py b/tests/test_install_game.py index ca9c4d0..fb739f0 100644 --- a/tests/test_install_game.py +++ b/tests/test_install_game.py @@ -1,7 +1,12 @@ -import asyncio -import json +import pytest -def test_success(plugin, readline): +from galaxy.unittest.mock import async_return_value + +from tests import create_message + + +@pytest.mark.asyncio +async def test_success(plugin, read): request = { "jsonrpc": "2.0", "method": "install_game", @@ -10,7 +15,6 @@ def test_success(plugin, readline): } } - readline.side_effect = [json.dumps(request), ""] - plugin.get_owned_games.return_value = None - asyncio.run(plugin.run()) + read.side_effect = [async_return_value(create_message(request)), async_return_value(b"")] + await plugin.run() plugin.install_game.assert_called_with(game_id="3") diff --git a/tests/test_internal.py b/tests/test_internal.py index 44f8ee7..bfd42a0 100644 --- a/tests/test_internal.py +++ b/tests/test_internal.py @@ -1,10 +1,14 @@ -import asyncio -import json +import pytest from galaxy.api.plugin import Plugin from galaxy.api.consts import Platform +from galaxy.unittest.mock import async_return_value -def test_get_capabilites(reader, writer, readline, write): +from tests import create_message, get_messages + + +@pytest.mark.asyncio +async def test_get_capabilities(reader, writer, read, write): class PluginImpl(Plugin): #pylint: disable=abstract-method async def get_owned_games(self): pass @@ -16,53 +20,75 @@ def test_get_capabilites(reader, writer, readline, write): } token = "token" plugin = PluginImpl(Platform.Generic, "0.1", reader, writer, token) - readline.side_effect = [json.dumps(request), ""] - asyncio.run(plugin.run()) - response = json.loads(write.call_args[0][0]) - assert response == { - "jsonrpc": "2.0", - "id": "3", - "result": { - "platform_name": "generic", - "features": [ - "ImportOwnedGames" - ], - "token": token + read.side_effect = [async_return_value(create_message(request)), async_return_value(b"")] + await plugin.run() + assert get_messages(write) == [ + { + "jsonrpc": "2.0", + "id": "3", + "result": { + "platform_name": "generic", + "features": [ + "ImportOwnedGames" + ], + "token": token + } } - } + ] -def test_shutdown(plugin, readline, write): + +@pytest.mark.asyncio +async def test_shutdown(plugin, read, write): request = { "jsonrpc": "2.0", "id": "5", "method": "shutdown" } - readline.side_effect = [json.dumps(request)] - asyncio.run(plugin.run()) + read.side_effect = [async_return_value(create_message(request))] + await plugin.run() plugin.shutdown.assert_called_with() - response = json.loads(write.call_args[0][0]) - assert response == { - "jsonrpc": "2.0", - "id": "5", - "result": None - } + assert get_messages(write) == [ + { + "jsonrpc": "2.0", + "id": "5", + "result": None + } + ] -def test_ping(plugin, readline, write): + +@pytest.mark.asyncio +async def test_ping(plugin, read, write): request = { "jsonrpc": "2.0", "id": "7", "method": "ping" } - readline.side_effect = [json.dumps(request), ""] - asyncio.run(plugin.run()) - response = json.loads(write.call_args[0][0]) - assert response == { - "jsonrpc": "2.0", - "id": "7", - "result": None - } + read.side_effect = [async_return_value(create_message(request)), async_return_value(b"")] + await plugin.run() + assert get_messages(write) == [ + { + "jsonrpc": "2.0", + "id": "7", + "result": None + } + ] -def test_tick(plugin, readline): - readline.side_effect = [""] - asyncio.run(plugin.run()) + +@pytest.mark.asyncio +async def test_tick_before_handshake(plugin, read): + read.side_effect = [async_return_value(b"")] + await plugin.run() + plugin.tick.assert_not_called() + + +@pytest.mark.asyncio +async def test_tick_after_handshake(plugin, read): + request = { + "jsonrpc": "2.0", + "id": "6", + "method": "initialize_cache", + "params": {"data": {}} + } + read.side_effect = [async_return_value(create_message(request)), async_return_value(b"")] + await plugin.run() plugin.tick.assert_called_with() diff --git a/tests/test_launch_game.py b/tests/test_launch_game.py index fa654e9..4ab22c6 100644 --- a/tests/test_launch_game.py +++ b/tests/test_launch_game.py @@ -1,7 +1,12 @@ -import asyncio -import json +import pytest -def test_success(plugin, readline): +from galaxy.unittest.mock import async_return_value + +from tests import create_message + + +@pytest.mark.asyncio +async def test_success(plugin, read): request = { "jsonrpc": "2.0", "method": "launch_game", @@ -10,7 +15,6 @@ def test_success(plugin, readline): } } - readline.side_effect = [json.dumps(request), ""] - plugin.get_owned_games.return_value = None - asyncio.run(plugin.run()) + read.side_effect = [async_return_value(create_message(request)), async_return_value(b"")] + await plugin.run() plugin.launch_game.assert_called_with(game_id="3") diff --git a/tests/test_launch_platform_client.py b/tests/test_launch_platform_client.py new file mode 100644 index 0000000..3536047 --- /dev/null +++ b/tests/test_launch_platform_client.py @@ -0,0 +1,17 @@ +import pytest + +from galaxy.unittest.mock import async_return_value + +from tests import create_message + +@pytest.mark.asyncio +async def test_success(plugin, read): + request = { + "jsonrpc": "2.0", + "method": "launch_platform_client" + } + + read.side_effect = [async_return_value(create_message(request)), async_return_value(b"")] + plugin.launch_platform_client.return_value = async_return_value(None) + await plugin.run() + plugin.launch_platform_client.assert_called_with() diff --git a/tests/test_local_games.py b/tests/test_local_games.py index 445e699..74c0636 100644 --- a/tests/test_local_games.py +++ b/tests/test_local_games.py @@ -1,51 +1,55 @@ -import asyncio -import json - import pytest from galaxy.api.types import LocalGame from galaxy.api.consts import LocalGameState from galaxy.api.errors import UnknownError, FailedParsingManifest +from galaxy.unittest.mock import async_return_value -def test_success(plugin, readline, write): +from tests import create_message, get_messages + + +@pytest.mark.asyncio +async def test_success(plugin, read, write): request = { "jsonrpc": "2.0", "id": "3", "method": "import_local_games" } + read.side_effect = [async_return_value(create_message(request)), async_return_value(b"")] - readline.side_effect = [json.dumps(request), ""] - - plugin.get_local_games.coro.return_value = [ + plugin.get_local_games.return_value = async_return_value([ LocalGame("1", LocalGameState.Running), LocalGame("2", LocalGameState.Installed), LocalGame("3", LocalGameState.Installed | LocalGameState.Running) - ] - asyncio.run(plugin.run()) + ]) + await plugin.run() plugin.get_local_games.assert_called_with() - response = json.loads(write.call_args[0][0]) - assert response == { - "jsonrpc": "2.0", - "id": "3", - "result": { - "local_games" : [ - { - "game_id": "1", - "local_game_state": LocalGameState.Running.value - }, - { - "game_id": "2", - "local_game_state": LocalGameState.Installed.value - }, - { - "game_id": "3", - "local_game_state": (LocalGameState.Installed | LocalGameState.Running).value - } - ] + assert get_messages(write) == [ + { + "jsonrpc": "2.0", + "id": "3", + "result": { + "local_games" : [ + { + "game_id": "1", + "local_game_state": LocalGameState.Running.value + }, + { + "game_id": "2", + "local_game_state": LocalGameState.Installed.value + }, + { + "game_id": "3", + "local_game_state": (LocalGameState.Installed | LocalGameState.Running).value + } + ] + } } - } + ] + +@pytest.mark.asyncio @pytest.mark.parametrize( "error,code,message", [ @@ -53,44 +57,42 @@ def test_success(plugin, readline, write): pytest.param(FailedParsingManifest, 200, "Failed parsing manifest", id="failed_parsing") ], ) -def test_failure(plugin, readline, write, error, code, message): +async def test_failure(plugin, read, write, error, code, message): request = { "jsonrpc": "2.0", "id": "3", "method": "import_local_games" } - - readline.side_effect = [json.dumps(request), ""] - plugin.get_local_games.coro.side_effect = error() - asyncio.run(plugin.run()) + read.side_effect = [async_return_value(create_message(request)), async_return_value(b"")] + plugin.get_local_games.side_effect = error() + await plugin.run() plugin.get_local_games.assert_called_with() - response = json.loads(write.call_args[0][0]) - assert response == { - "jsonrpc": "2.0", - "id": "3", - "error": { - "code": code, - "message": message - } - } - -def test_local_game_state_update(plugin, write): - game = LocalGame("1", LocalGameState.Running) - - async def couritine(): - plugin.update_local_game_status(game) - - asyncio.run(couritine()) - response = json.loads(write.call_args[0][0]) - - assert response == { - "jsonrpc": "2.0", - "method": "local_game_status_changed", - "params": { - "local_game": { - "game_id": "1", - "local_game_state": LocalGameState.Running.value + assert get_messages(write) == [ + { + "jsonrpc": "2.0", + "id": "3", + "error": { + "code": code, + "message": message } } - } + ] + +@pytest.mark.asyncio +async def test_local_game_state_update(plugin, write): + game = LocalGame("1", LocalGameState.Running) + plugin.update_local_game_status(game) + + assert get_messages(write) == [ + { + "jsonrpc": "2.0", + "method": "local_game_status_changed", + "params": { + "local_game": { + "game_id": "1", + "local_game_state": LocalGameState.Running.value + } + } + } + ] diff --git a/tests/test_owned_games.py b/tests/test_owned_games.py index 1202c9e..64ece04 100644 --- a/tests/test_owned_games.py +++ b/tests/test_owned_games.py @@ -1,19 +1,23 @@ -import asyncio -import json +import pytest from galaxy.api.types import Game, Dlc, LicenseInfo from galaxy.api.consts import LicenseType from galaxy.api.errors import UnknownError +from galaxy.unittest.mock import async_return_value -def test_success(plugin, readline, write): +from tests import create_message, get_messages + + +@pytest.mark.asyncio +async def test_success(plugin, read, write): request = { "jsonrpc": "2.0", "id": "3", "method": "import_owned_games" } + read.side_effect = [async_return_value(create_message(request)), async_return_value(b"")] - readline.side_effect = [json.dumps(request), ""] - plugin.get_owned_games.coro.return_value = [ + plugin.get_owned_games.return_value = async_return_value([ Game("3", "Doom", None, LicenseInfo(LicenseType.SinglePurchase, None)), Game( "5", @@ -23,129 +27,126 @@ def test_success(plugin, readline, write): Dlc("8", "Temerian Armor Set", LicenseInfo(LicenseType.FreeToPlay, None)), ], LicenseInfo(LicenseType.SinglePurchase, None)) - ] - asyncio.run(plugin.run()) + ]) + await plugin.run() plugin.get_owned_games.assert_called_with() - response = json.loads(write.call_args[0][0]) - - assert response == { - "jsonrpc": "2.0", - "id": "3", - "result": { - "owned_games": [ - { - "game_id": "3", - "game_title": "Doom", - "license_info": { - "license_type": "SinglePurchase" - } - }, - { - "game_id": "5", - "game_title": "Witcher 3", - "dlcs": [ - { - "dlc_id": "7", - "dlc_title": "Hearts of Stone", - "license_info": { - "license_type": "SinglePurchase" - } - }, - { - "dlc_id": "8", - "dlc_title": "Temerian Armor Set", - "license_info": { - "license_type": "FreeToPlay" - } + assert get_messages(write) == [ + { + "jsonrpc": "2.0", + "id": "3", + "result": { + "owned_games": [ + { + "game_id": "3", + "game_title": "Doom", + "license_info": { + "license_type": "SinglePurchase" + } + }, + { + "game_id": "5", + "game_title": "Witcher 3", + "dlcs": [ + { + "dlc_id": "7", + "dlc_title": "Hearts of Stone", + "license_info": { + "license_type": "SinglePurchase" + } + }, + { + "dlc_id": "8", + "dlc_title": "Temerian Armor Set", + "license_info": { + "license_type": "FreeToPlay" + } + } + ], + "license_info": { + "license_type": "SinglePurchase" } - ], - "license_info": { - "license_type": "SinglePurchase" } - } - ] + ] + } } - } + ] -def test_failure(plugin, readline, write): + +@pytest.mark.asyncio +async def test_failure(plugin, read, write): request = { "jsonrpc": "2.0", "id": "3", "method": "import_owned_games" } - readline.side_effect = [json.dumps(request), ""] - plugin.get_owned_games.coro.side_effect = UnknownError() - asyncio.run(plugin.run()) + read.side_effect = [async_return_value(create_message(request)), async_return_value(b"")] + plugin.get_owned_games.side_effect = UnknownError() + await plugin.run() plugin.get_owned_games.assert_called_with() - response = json.loads(write.call_args[0][0]) - - assert response == { - "jsonrpc": "2.0", - "id": "3", - "error": { - "code": 0, - "message": "Unknown error" + assert get_messages(write) == [ + { + "jsonrpc": "2.0", + "id": "3", + "error": { + "code": 0, + "message": "Unknown error" + } } - } + ] -def test_add_game(plugin, write): + +@pytest.mark.asyncio +async def test_add_game(plugin, write): game = Game("3", "Doom", None, LicenseInfo(LicenseType.SinglePurchase, None)) - - async def couritine(): - plugin.add_game(game) - - asyncio.run(couritine()) - response = json.loads(write.call_args[0][0]) - - assert response == { - "jsonrpc": "2.0", - "method": "owned_game_added", - "params": { - "owned_game": { - "game_id": "3", - "game_title": "Doom", - "license_info": { - "license_type": "SinglePurchase" + plugin.add_game(game) + assert get_messages(write) == [ + { + "jsonrpc": "2.0", + "method": "owned_game_added", + "params": { + "owned_game": { + "game_id": "3", + "game_title": "Doom", + "license_info": { + "license_type": "SinglePurchase" + } } } } - } + ] -def test_remove_game(plugin, write): - async def couritine(): - plugin.remove_game("5") - asyncio.run(couritine()) - response = json.loads(write.call_args[0][0]) - - assert response == { - "jsonrpc": "2.0", - "method": "owned_game_removed", - "params": { - "game_id": "5" +@pytest.mark.asyncio +async def test_remove_game(plugin, write): + plugin.remove_game("5") + assert get_messages(write) == [ + { + "jsonrpc": "2.0", + "method": "owned_game_removed", + "params": { + "game_id": "5" + } } - } + ] -def test_update_game(plugin, write): + +@pytest.mark.asyncio +async def test_update_game(plugin, write): game = Game("3", "Doom", None, LicenseInfo(LicenseType.SinglePurchase, None)) - - async def couritine(): - plugin.update_game(game) - - asyncio.run(couritine()) - response = json.loads(write.call_args[0][0]) - - assert response == { - "jsonrpc": "2.0", - "method": "owned_game_updated", - "params": { - "owned_game": { - "game_id": "3", - "game_title": "Doom", - "license_info": { - "license_type": "SinglePurchase" + plugin.update_game(game) + assert get_messages(write) == [ + { + "jsonrpc": "2.0", + "method": "owned_game_updated", + "params": { + "owned_game": { + "game_id": "3", + "game_title": "Doom", + "license_info": { + "license_type": "SinglePurchase" + } } } } - } + ] diff --git a/tests/test_persistent_cache.py b/tests/test_persistent_cache.py index 056ddb5..9aaa0f7 100644 --- a/tests/test_persistent_cache.py +++ b/tests/test_persistent_cache.py @@ -1,23 +1,28 @@ -import asyncio -import json - import pytest +from galaxy.unittest.mock import async_return_value + +from tests import create_message, get_messages + def assert_rpc_response(write, response_id, result=None): - assert json.loads(write.call_args[0][0]) == { - "jsonrpc": "2.0", - "id": str(response_id), - "result": result - } + assert get_messages(write) == [ + { + "jsonrpc": "2.0", + "id": str(response_id), + "result": result + } + ] def assert_rpc_request(write, method, params=None): - assert json.loads(write.call_args[0][0]) == { - "jsonrpc": "2.0", - "method": method, - "params": {"data": params} - } + assert get_messages(write) == [ + { + "jsonrpc": "2.0", + "method": method, + "params": {"data": params} + } + ] @pytest.fixture @@ -28,7 +33,8 @@ def cache_data(): } -def test_initialize_cache(plugin, readline, write, cache_data): +@pytest.mark.asyncio +async def test_initialize_cache(plugin, read, write, cache_data): request_id = 3 request = { "jsonrpc": "2.0", @@ -36,36 +42,32 @@ def test_initialize_cache(plugin, readline, write, cache_data): "method": "initialize_cache", "params": {"data": cache_data} } - readline.side_effect = [json.dumps(request)] + read.side_effect = [async_return_value(create_message(request)), async_return_value(b"")] assert {} == plugin.persistent_cache - asyncio.run(plugin.run()) + await plugin.run() plugin.handshake_complete.assert_called_once_with() assert cache_data == plugin.persistent_cache assert_rpc_response(write, response_id=request_id) -def test_set_cache(plugin, write, cache_data): - async def runner(): - assert {} == plugin.persistent_cache +@pytest.mark.asyncio +async def test_set_cache(plugin, write, cache_data): + assert {} == plugin.persistent_cache - plugin.persistent_cache.update(cache_data) - plugin.push_cache() + plugin.persistent_cache.update(cache_data) + plugin.push_cache() - assert_rpc_request(write, "push_cache", cache_data) - assert cache_data == plugin.persistent_cache - - asyncio.run(runner()) + assert_rpc_request(write, "push_cache", cache_data) + assert cache_data == plugin.persistent_cache -def test_clear_cache(plugin, write, cache_data): - async def runner(): - plugin._persistent_cache = cache_data +@pytest.mark.asyncio +async def test_clear_cache(plugin, write, cache_data): + plugin._persistent_cache = cache_data - plugin.persistent_cache.clear() - plugin.push_cache() + plugin.persistent_cache.clear() + plugin.push_cache() - assert_rpc_request(write, "push_cache", {}) - assert {} == plugin.persistent_cache - - asyncio.run(runner()) + assert_rpc_request(write, "push_cache", {}) + assert {} == plugin.persistent_cache diff --git a/tests/test_shutdown_platform_client.py b/tests/test_shutdown_platform_client.py new file mode 100644 index 0000000..c2dc3f1 --- /dev/null +++ b/tests/test_shutdown_platform_client.py @@ -0,0 +1,17 @@ +import pytest + +from galaxy.unittest.mock import async_return_value + +from tests import create_message + +@pytest.mark.asyncio +async def test_success(plugin, read): + request = { + "jsonrpc": "2.0", + "method": "shutdown_platform_client" + } + + read.side_effect = [async_return_value(create_message(request)), async_return_value(b"")] + plugin.shutdown_platform_client.return_value = async_return_value(None) + await plugin.run() + plugin.shutdown_platform_client.assert_called_with() diff --git a/tests/test_stream_line_reader.py b/tests/test_stream_line_reader.py new file mode 100644 index 0000000..64226f5 --- /dev/null +++ b/tests/test_stream_line_reader.py @@ -0,0 +1,46 @@ +import pytest + +from galaxy.reader import StreamLineReader +from galaxy.unittest.mock import async_return_value + + +@pytest.fixture() +def stream_line_reader(reader): + return StreamLineReader(reader) + + +@pytest.mark.asyncio +async def test_message(stream_line_reader, read): + read.return_value = async_return_value(b"a\n") + assert await stream_line_reader.readline() == b"a" + read.assert_called_once() + + +@pytest.mark.asyncio +async def test_separate_messages(stream_line_reader, read): + read.side_effect = [async_return_value(b"a\n"), async_return_value(b"b\n")] + assert await stream_line_reader.readline() == b"a" + assert await stream_line_reader.readline() == b"b" + assert read.call_count == 2 + + +@pytest.mark.asyncio +async def test_connected_messages(stream_line_reader, read): + read.return_value = async_return_value(b"a\nb\n") + assert await stream_line_reader.readline() == b"a" + assert await stream_line_reader.readline() == b"b" + read.assert_called_once() + + +@pytest.mark.asyncio +async def test_cut_message(stream_line_reader, read): + read.side_effect = [async_return_value(b"a"), async_return_value(b"b\n")] + assert await stream_line_reader.readline() == b"ab" + assert read.call_count == 2 + + +@pytest.mark.asyncio +async def test_half_message(stream_line_reader, read): + read.side_effect = [async_return_value(b"a"), async_return_value(b"")] + assert await stream_line_reader.readline() == b"" + assert read.call_count == 2 diff --git a/tests/test_uninstall_game.py b/tests/test_uninstall_game.py index 2e7c4ef..1ec79a7 100644 --- a/tests/test_uninstall_game.py +++ b/tests/test_uninstall_game.py @@ -1,7 +1,11 @@ -import asyncio -import json +import pytest -def test_success(plugin, readline): +from galaxy.unittest.mock import async_return_value + +from tests import create_message + +@pytest.mark.asyncio +async def test_success(plugin, read): request = { "jsonrpc": "2.0", "method": "uninstall_game", @@ -9,8 +13,7 @@ def test_success(plugin, readline): "game_id": "3" } } - - readline.side_effect = [json.dumps(request), ""] + read.side_effect = [async_return_value(create_message(request)), async_return_value(b"")] plugin.get_owned_games.return_value = None - asyncio.run(plugin.run()) + await plugin.run() plugin.uninstall_game.assert_called_with(game_id="3") diff --git a/tests/test_users.py b/tests/test_users.py deleted file mode 100644 index 24c9dbb..0000000 --- a/tests/test_users.py +++ /dev/null @@ -1,69 +0,0 @@ -import asyncio -import json - -from galaxy.api.types import UserInfo, Presence -from galaxy.api.errors import UnknownError -from galaxy.api.consts import PresenceState - - -def test_get_users_success(plugin, readline, write): - request = { - "jsonrpc": "2.0", - "id": "8", - "method": "import_user_infos", - "params": { - "user_id_list": ["13"] - } - } - - readline.side_effect = [json.dumps(request), ""] - plugin.get_users.coro.return_value = [ - UserInfo("5", False, "Ula", "http://avatar.png", Presence(PresenceState.Offline)) - ] - asyncio.run(plugin.run()) - plugin.get_users.assert_called_with(user_id_list=["13"]) - response = json.loads(write.call_args[0][0]) - - assert response == { - "jsonrpc": "2.0", - "id": "8", - "result": { - "user_info_list": [ - { - "user_id": "5", - "is_friend": False, - "user_name": "Ula", - "avatar_url": "http://avatar.png", - "presence": { - "presence_state": "offline" - } - } - ] - } - } - - -def test_get_users_failure(plugin, readline, write): - request = { - "jsonrpc": "2.0", - "id": "12", - "method": "import_user_infos", - "params": { - "user_id_list": ["10", "11", "12"] - } - } - - readline.side_effect = [json.dumps(request), ""] - plugin.get_users.coro.side_effect = UnknownError() - asyncio.run(plugin.run()) - plugin.get_users.assert_called_with(user_id_list=["10", "11", "12"]) - response = json.loads(write.call_args[0][0]) - - assert response == { - "jsonrpc": "2.0", - "id": "12", - "error": { - "code": 0, - "message": "Unknown error" - } - }