From 6f717a1e3111a7597c7a8f70c41bc464d5211d4c Mon Sep 17 00:00:00 2001 From: GOG Galaxy SDK Team Date: Wed, 29 May 2019 13:08:20 +0200 Subject: [PATCH 01/58] Initial commit --- README.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 README.md diff --git a/README.md b/README.md new file mode 100644 index 0000000..38c95c8 --- /dev/null +++ b/README.md @@ -0,0 +1 @@ +# galaxy-integrations-python-api \ No newline at end of file From 60fab25a55b147015954ec2463b15e2864585e1c Mon Sep 17 00:00:00 2001 From: GOG Galaxy SDK Team Date: Wed, 29 May 2019 13:09:13 +0200 Subject: [PATCH 02/58] version 0.31.1 --- .gitignore | 2 + README.md | 64 ++++- pytest.ini | 2 + requirements.txt | 8 + src/galaxy/__init__.py | 1 + src/galaxy/api/__init__.py | 0 src/galaxy/api/consts.py | 44 +++ src/galaxy/api/errors.py | 83 ++++++ src/galaxy/api/jsonrpc.py | 285 ++++++++++++++++++++ src/galaxy/api/plugin.py | 462 ++++++++++++++++++++++++++++++++ src/galaxy/api/types.py | 94 +++++++ src/galaxy/http.py | 47 ++++ src/galaxy/tools.py | 20 ++ src/galaxy/unittest/__init__.py | 0 src/galaxy/unittest/mock.py | 12 + tests/__init__.py | 0 tests/conftest.py | 67 +++++ tests/test_achievements.py | 193 +++++++++++++ tests/test_authenticate.py | 119 ++++++++ tests/test_chat.py | 354 ++++++++++++++++++++++++ tests/test_features.py | 45 ++++ tests/test_friends.py | 90 +++++++ tests/test_game_times.py | 175 ++++++++++++ tests/test_install_game.py | 16 ++ tests/test_internal.py | 68 +++++ tests/test_launch_game.py | 16 ++ tests/test_local_games.py | 96 +++++++ tests/test_owned_games.py | 151 +++++++++++ tests/test_uninstall_game.py | 16 ++ tests/test_users.py | 69 +++++ 30 files changed, 2598 insertions(+), 1 deletion(-) create mode 100644 .gitignore create mode 100644 pytest.ini create mode 100644 requirements.txt create mode 100644 src/galaxy/__init__.py create mode 100644 src/galaxy/api/__init__.py create mode 100644 src/galaxy/api/consts.py create mode 100644 src/galaxy/api/errors.py create mode 100644 src/galaxy/api/jsonrpc.py create mode 100644 src/galaxy/api/plugin.py create mode 100644 src/galaxy/api/types.py create mode 100644 src/galaxy/http.py create mode 100644 src/galaxy/tools.py create mode 100644 src/galaxy/unittest/__init__.py create mode 100644 src/galaxy/unittest/mock.py create mode 100644 tests/__init__.py create mode 100644 tests/conftest.py create mode 100644 tests/test_achievements.py create mode 100644 tests/test_authenticate.py create mode 100644 tests/test_chat.py create mode 100644 tests/test_features.py create mode 100644 tests/test_friends.py create mode 100644 tests/test_game_times.py create mode 100644 tests/test_install_game.py create mode 100644 tests/test_internal.py create mode 100644 tests/test_launch_game.py create mode 100644 tests/test_local_games.py create mode 100644 tests/test_owned_games.py create mode 100644 tests/test_uninstall_game.py create mode 100644 tests/test_users.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a87a247 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +# pytest +__pycache__/ diff --git a/README.md b/README.md index 38c95c8..09aad74 100644 --- a/README.md +++ b/README.md @@ -1 +1,63 @@ -# galaxy-integrations-python-api \ No newline at end of file +# GOG Galaxy - Community Integration - Python API + +This document is still work in progress. + +## Basic Usage + +Basic implementation: + +```python +import sys +from galaxy.api.plugin import Plugin, create_and_run_plugin +from galaxy.api.consts import Platform + +class PluginExample(Plugin): + def __init__(self, reader, writer, token): + super().__init__( + Platform.Generic, # Choose platform from available list + "0.1", # Version + reader, + writer, + token + ) + + # implement methods + async def authenticate(self, stored_credentials=None): + pass + +def main(): + create_and_run_plugin(PluginExample, sys.argv) + +# run plugin event loop +if __name__ == "__main__": + main() +``` + +Plugin should be deployed with manifest: +```json +{ + "name": "Example plugin", + "platform": "generic", + "guid": "UNIQUE-GUID", + "version": "0.1", + "description": "Example plugin", + "author": "Name", + "email": "author@email.com", + "url": "https://github.com/user/galaxy-plugin-example", + "script": "plugin.py" +} +``` + +## Development + +Install required packages: +```bash +pip install -r requirements.txt +``` + +Run tests: +```bash +pytest +``` +## Methods Documentation +TODO diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..3d6dc59 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,2 @@ +[pytest] +addopts = --flakes \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..528cc4c --- /dev/null +++ b/requirements.txt @@ -0,0 +1,8 @@ +-e . +pytest==4.2.0 +pytest-asyncio==0.10.0 +pytest-mock==1.10.3 +pytest-flakes==4.0.0 +# because of pip bug https://github.com/pypa/pip/issues/4780 +aiohttp==3.5.4 +certifi==2019.3.9 diff --git a/src/galaxy/__init__.py b/src/galaxy/__init__.py new file mode 100644 index 0000000..69e3be5 --- /dev/null +++ b/src/galaxy/__init__.py @@ -0,0 +1 @@ +__path__ = __import__('pkgutil').extend_path(__path__, __name__) diff --git a/src/galaxy/api/__init__.py b/src/galaxy/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/galaxy/api/consts.py b/src/galaxy/api/consts.py new file mode 100644 index 0000000..a4ad274 --- /dev/null +++ b/src/galaxy/api/consts.py @@ -0,0 +1,44 @@ +from enum import Enum, Flag + +class Platform(Enum): + Unknown = "unknown" + Gog = "gog" + Steam = "steam" + Psn = "psn" + XBoxOne = "xboxone" + Generic = "generic" + Origin = "origin" + Uplay = "uplay" + Battlenet = "battlenet" + Epic = "epic" + +class Feature(Enum): + Unknown = "Unknown" + ImportInstalledGames = "ImportInstalledGames" + ImportOwnedGames = "ImportOwnedGames" + LaunchGame = "LaunchGame" + InstallGame = "InstallGame" + UninstallGame = "UninstallGame" + ImportAchievements = "ImportAchievements" + ImportGameTime = "ImportGameTime" + Chat = "Chat" + ImportUsers = "ImportUsers" + VerifyGame = "VerifyGame" + ImportFriends = "ImportFriends" + +class LicenseType(Enum): + Unknown = "Unknown" + SinglePurchase = "SinglePurchase" + FreeToPlay = "FreeToPlay" + OtherUserLicense = "OtherUserLicense" + +class LocalGameState(Flag): + None_ = 0 + Installed = 1 + Running = 2 + +class PresenceState(Enum): + Unknown = "Unknown" + Online = "online" + Offline = "offline" + Away = "away" diff --git a/src/galaxy/api/errors.py b/src/galaxy/api/errors.py new file mode 100644 index 0000000..189db00 --- /dev/null +++ b/src/galaxy/api/errors.py @@ -0,0 +1,83 @@ +from galaxy.api.jsonrpc import ApplicationError, UnknownError + +UnknownError = UnknownError + +class AuthenticationRequired(ApplicationError): + def __init__(self, data=None): + super().__init__(1, "Authentication required", data) + +class BackendNotAvailable(ApplicationError): + def __init__(self, data=None): + super().__init__(2, "Backend not available", data) + +class BackendTimeout(ApplicationError): + def __init__(self, data=None): + super().__init__(3, "Backend timed out", data) + +class BackendError(ApplicationError): + def __init__(self, data=None): + super().__init__(4, "Backend error", data) + +class UnknownBackendResponse(ApplicationError): + def __init__(self, data=None): + super().__init__(4, "Backend responded in uknown way", data) + +class InvalidCredentials(ApplicationError): + def __init__(self, data=None): + super().__init__(100, "Invalid credentials", data) + +class NetworkError(ApplicationError): + def __init__(self, data=None): + super().__init__(101, "Network error", data) + +class LoggedInElsewhere(ApplicationError): + def __init__(self, data=None): + super().__init__(102, "Logged in elsewhere", data) + +class ProtocolError(ApplicationError): + def __init__(self, data=None): + super().__init__(103, "Protocol error", data) + +class TemporaryBlocked(ApplicationError): + def __init__(self, data=None): + super().__init__(104, "Temporary blocked", data) + +class Banned(ApplicationError): + def __init__(self, data=None): + super().__init__(105, "Banned", data) + +class AccessDenied(ApplicationError): + def __init__(self, data=None): + super().__init__(106, "Access denied", data) + +class ParentalControlBlock(ApplicationError): + def __init__(self, data=None): + super().__init__(107, "Parental control block", data) + +class DeviceBlocked(ApplicationError): + def __init__(self, data=None): + super().__init__(108, "Device blocked", data) + +class RegionBlocked(ApplicationError): + def __init__(self, data=None): + super().__init__(109, "Region blocked", data) + +class FailedParsingManifest(ApplicationError): + def __init__(self, data=None): + super().__init__(200, "Failed parsing manifest", data) + +class TooManyMessagesSent(ApplicationError): + def __init__(self, data=None): + super().__init__(300, "Too many messages sent", data) + +class IncoherentLastMessage(ApplicationError): + def __init__(self, data=None): + super().__init__(400, "Different last message id on backend", data) + +class MessageNotFound(ApplicationError): + def __init__(self, data=None): + super().__init__(500, "Message not found", data) + +class ImportInProgress(ApplicationError): + def __init__(self, data=None): + super().__init__(600, "Import already in progress", data) \ No newline at end of file diff --git a/src/galaxy/api/jsonrpc.py b/src/galaxy/api/jsonrpc.py new file mode 100644 index 0000000..07290d7 --- /dev/null +++ b/src/galaxy/api/jsonrpc.py @@ -0,0 +1,285 @@ +import asyncio +from collections import namedtuple +from collections.abc import Iterable +import logging +import inspect +import json + +class JsonRpcError(Exception): + def __init__(self, code, message, data=None): + self.code = code + self.message = message + self.data = data + super().__init__() + + def __eq__(self, other): + return self.code == other.code and self.message == other.message and self.data == other.data + +class ParseError(JsonRpcError): + def __init__(self): + super().__init__(-32700, "Parse error") + +class InvalidRequest(JsonRpcError): + def __init__(self): + super().__init__(-32600, "Invalid Request") + +class MethodNotFound(JsonRpcError): + def __init__(self): + super().__init__(-32601, "Method not found") + +class InvalidParams(JsonRpcError): + def __init__(self): + super().__init__(-32602, "Invalid params") + +class Timeout(JsonRpcError): + def __init__(self): + super().__init__(-32000, "Method timed out") + +class Aborted(JsonRpcError): + def __init__(self): + super().__init__(-32001, "Method aborted") + +class ApplicationError(JsonRpcError): + def __init__(self, code, message, data): + if code >= -32768 and code <= -32000: + raise ValueError("The error code in reserved range") + super().__init__(code, message, data) + +class UnknownError(ApplicationError): + def __init__(self, data=None): + super().__init__(0, "Unknown error", data) + +Request = namedtuple("Request", ["method", "params", "id"], defaults=[{}, None]) +Method = namedtuple("Method", ["callback", "signature", "internal", "sensitive_params"]) + +def anonymise_sensitive_params(params, sensitive_params): + anomized_data = "****" + if not sensitive_params: + return params + + if isinstance(sensitive_params, Iterable): + anomized_params = params.copy() + for key in anomized_params.keys(): + if key in sensitive_params: + anomized_params[key] = anomized_data + return anomized_params + + return anomized_data + +class Server(): + def __init__(self, reader, writer, encoder=json.JSONEncoder()): + self._active = True + self._reader = reader + self._writer = writer + self._encoder = encoder + self._methods = {} + self._notifications = {} + self._eof_listeners = [] + + def register_method(self, name, callback, internal, sensitive_params=False): + """ + Register method + :param name: + :param callback: + :param internal: if True the callback will be processed immediately (synchronously) + :param sensitive_params: list of parameters that will by anonymized before logging; if False - no params + are considered sensitive, if True - all params are considered sensitive + """ + self._methods[name] = Method(callback, inspect.signature(callback), internal, sensitive_params) + + def register_notification(self, name, callback, internal, sensitive_params=False): + """ + Register notification + :param name: + :param callback: + :param internal: if True the callback will be processed immediately (synchronously) + :param sensitive_params: list of parameters that will by anonymized before logging; if False - no params + are considered sensitive, if True - all params are considered sensitive + """ + self._notifications[name] = Method(callback, inspect.signature(callback), internal, sensitive_params) + + def register_eof(self, callback): + self._eof_listeners.append(callback) + + async def run(self): + while self._active: + try: + data = await self._reader.readline() + if not data: + self._eof() + continue + except: + self._eof() + continue + data = data.strip() + logging.debug("Received %d bytes of data", len(data)) + self._handle_input(data) + + def stop(self): + self._active = False + + def _eof(self): + logging.info("Received EOF") + self.stop() + for listener in self._eof_listeners: + listener() + + def _handle_input(self, data): + try: + request = self._parse_request(data) + except JsonRpcError as error: + self._send_error(None, error) + return + + if request.id is not None: + self._handle_request(request) + else: + self._handle_notification(request) + + def _handle_notification(self, request): + method = self._notifications.get(request.method) + if not method: + logging.error("Received unknown notification: %s", request.method) + return + + callback, signature, internal, sensitive_params = method + self._log_request(request, sensitive_params) + + try: + bound_args = signature.bind(**request.params) + except TypeError: + self._send_error(request.id, InvalidParams()) + + if internal: + # internal requests are handled immediately + callback(*bound_args.args, **bound_args.kwargs) + else: + try: + asyncio.create_task(callback(*bound_args.args, **bound_args.kwargs)) + except Exception: + logging.exception("Unexpected exception raised in notification handler") + + def _handle_request(self, request): + method = self._methods.get(request.method) + if not method: + logging.error("Received unknown request: %s", request.method) + self._send_error(request.id, MethodNotFound()) + return + + callback, signature, internal, sensitive_params = method + self._log_request(request, sensitive_params) + + try: + bound_args = signature.bind(**request.params) + except TypeError: + self._send_error(request.id, InvalidParams()) + + if internal: + # internal requests are handled immediately + response = callback(*bound_args.args, **bound_args.kwargs) + self._send_response(request.id, response) + else: + async def handle(): + try: + result = await callback(*bound_args.args, **bound_args.kwargs) + self._send_response(request.id, result) + except NotImplementedError: + self._send_error(request.id, MethodNotFound()) + except JsonRpcError as error: + self._send_error(request.id, error) + except Exception as e: #pylint: disable=broad-except + logging.exception("Unexpected exception raised in plugin handler") + self._send_error(request.id, UnknownError(str(e))) + + asyncio.create_task(handle()) + + @staticmethod + def _parse_request(data): + try: + jsonrpc_request = json.loads(data, encoding="utf-8") + if jsonrpc_request.get("jsonrpc") != "2.0": + raise InvalidRequest() + del jsonrpc_request["jsonrpc"] + return Request(**jsonrpc_request) + except json.JSONDecodeError: + raise ParseError() + except TypeError: + raise InvalidRequest() + + def _send(self, data): + try: + line = self._encoder.encode(data) + logging.debug("Sending data: %s", line) + data = (line + "\n").encode("utf-8") + self._writer.write(data) + asyncio.create_task(self._writer.drain()) + except TypeError as error: + logging.error(str(error)) + + def _send_response(self, request_id, result): + response = { + "jsonrpc": "2.0", + "id": request_id, + "result": result + } + self._send(response) + + def _send_error(self, request_id, error): + response = { + "jsonrpc": "2.0", + "id": request_id, + "error": { + "code": error.code, + "message": error.message + } + } + + if error.data is not None: + response["error"]["data"] = error.data + + self._send(response) + + @staticmethod + def _log_request(request, sensitive_params): + params = anonymise_sensitive_params(request.params, sensitive_params) + if request.id is not None: + logging.info("Handling request: id=%s, method=%s, params=%s", request.id, request.method, params) + else: + logging.info("Handling notification: method=%s, params=%s", request.method, params) + +class NotificationClient(): + def __init__(self, writer, encoder=json.JSONEncoder()): + self._writer = writer + self._encoder = encoder + self._methods = {} + + def notify(self, method, params, sensitive_params=False): + """ + Send notification + :param method: + :param params: + :param sensitive_params: list of parameters that will by anonymized before logging; if False - no params + are considered sensitive, if True - all params are considered sensitive + """ + notification = { + "jsonrpc": "2.0", + "method": method, + "params": params + } + self._log(method, params, sensitive_params) + self._send(notification) + + def _send(self, data): + try: + line = self._encoder.encode(data) + data = (line + "\n").encode("utf-8") + logging.debug("Sending %d byte of data", len(data)) + self._writer.write(data) + asyncio.create_task(self._writer.drain()) + except TypeError as error: + logging.error("Failed to parse outgoing message: %s", str(error)) + + @staticmethod + def _log(method, params, sensitive_params): + params = anonymise_sensitive_params(params, sensitive_params) + logging.info("Sending notification: method=%s, params=%s", method, params) diff --git a/src/galaxy/api/plugin.py b/src/galaxy/api/plugin.py new file mode 100644 index 0000000..db8321a --- /dev/null +++ b/src/galaxy/api/plugin.py @@ -0,0 +1,462 @@ +import asyncio +import json +import logging +import logging.handlers +import dataclasses +from enum import Enum +from collections import OrderedDict +import sys + +from galaxy.api.jsonrpc import Server, NotificationClient +from galaxy.api.consts import Feature +from galaxy.api.errors import UnknownError, ImportInProgress + +class JSONEncoder(json.JSONEncoder): + def default(self, o): # pylint: disable=method-hidden + if dataclasses.is_dataclass(o): + # 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 + return super().default(o) + +class Plugin(): + 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._active = True + + self._reader, self._writer = reader, writer + self._handshake_token = handshake_token + + encoder = JSONEncoder() + self._server = Server(self._reader, self._writer, encoder) + self._notification_client = NotificationClient(self._writer, encoder) + + def eof_handler(): + self._shutdown() + self._server.register_eof(eof_handler) + + self._achievements_import_in_progress = False + self._game_times_import_in_progress = False + + # internal + self._register_method("shutdown", self._shutdown, internal=True) + self._register_method("get_capabilities", self._get_capabilities, internal=True) + self._register_method("ping", self._ping, internal=True) + + # implemented by developer + self._register_method( + "init_authentication", + self.authenticate, + sensitive_params=["stored_credentials"] + ) + self._register_method( + "pass_login_credentials", + self.pass_login_credentials, + sensitive_params=["cookies", "credentials"] + ) + 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, + ) + + @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 _implements(self, handlers): + for handler in handlers: + if handler.__name__ 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): + if internal: + def method(*args, **kwargs): + result = handler(*args, **kwargs) + if result_name: + result = { + result_name: result + } + return result + self._server.register_method(name, method, True, sensitive_params) + else: + async def method(*args, **kwargs): + result = await handler(*args, **kwargs) + if result_name: + result = { + 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): + 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 main coorutine""" + 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 asyncio.gather(pass_control(), self._server.run()) + + def _shutdown(self): + logging.info("Shuting down") + self._server.stop() + self._active = False + self.shutdown() + + def _get_capabilities(self): + return { + "platform_name": self._platform, + "features": self.features, + "token": self._handshake_token + } + + @staticmethod + def _ping(): + pass + + # notifications + def store_credentials(self, credentials): + """Notify client to store plugin credentials. + They will be pass to next authencicate calls. + """ + self._notification_client.notify("store_credentials", credentials, sensitive_params=True) + + def add_game(self, game): + params = {"owned_game" : game} + self._notification_client.notify("owned_game_added", params) + + def remove_game(self, game_id): + params = {"game_id" : game_id} + self._notification_client.notify("owned_game_removed", params) + + def update_game(self, game): + params = {"owned_game" : game} + self._notification_client.notify("owned_game_updated", params) + + def unlock_achievement(self, game_id, achievement): + params = { + "game_id": game_id, + "achievement": achievement + } + self._notification_client.notify("achievement_unlocked", params) + + def game_achievements_import_success(self, game_id, achievements): + 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, error): + params = { + "game_id": game_id, + "error": { + "code": error.code, + "message": error.message + } + } + self._notification_client.notify("game_achievements_import_failure", params) + + def achievements_import_finished(self): + self._notification_client.notify("achievements_import_finished", None) + + def update_local_game_status(self, local_game): + params = {"local_game" : local_game} + self._notification_client.notify("local_game_status_changed", params) + + def add_friend(self, user): + params = {"friend_info" : user} + self._notification_client.notify("friend_added", params) + + def remove_friend(self, user_id): + params = {"user_id" : user_id} + self._notification_client.notify("friend_removed", params) + + def update_room(self, room_id, unread_message_count=None, new_messages=None): + 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): + params = {"game_time" : game_time} + self._notification_client.notify("game_time_updated", params) + + def game_time_import_success(self, game_time): + params = {"game_time" : game_time} + self._notification_client.notify("game_time_import_success", params) + + def game_time_import_failure(self, game_id, error): + params = { + "game_id": game_id, + "error": { + "code": error.code, + "message": error.message + } + } + self._notification_client.notify("game_time_import_failure", params) + + def game_times_import_finished(self): + self._notification_client.notify("game_times_import_finished", None) + + def lost_authentication(self): + self._notification_client.notify("authentication_lost", None) + + # handlers + def tick(self): + """This method is called periodicaly. + Override it to implement periodical tasks like refreshing cache. + This method should not be blocking - any longer actions should be + handled by asycio tasks. + """ + + def shutdown(self): + """This method is called on plugin shutdown. + Override it to implement tear down. + """ + + # methods + async def authenticate(self, stored_credentials=None): + """Overide this method to handle plugin authentication. + The method should return galaxy.api.types.Authentication + or raise galaxy.api.types.LoginError on authentication failure. + """ + raise NotImplementedError() + + async def pass_login_credentials(self, step, credentials, cookies): + raise NotImplementedError() + + async def get_owned_games(self): + raise NotImplementedError() + + async def get_unlocked_achievements(self, game_id): + raise NotImplementedError() + + async def start_achievements_import(self, game_ids): + 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 + + asyncio.create_task(import_games_achievements_task(game_ids)) + self._achievements_import_in_progress = True + + async def import_games_achievements(self, game_ids): + """Call game_achievements_import_success/game_achievements_import_failure for each game_id on the list""" + 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) + + imports = [import_game_achievements(game_id) for game_id in game_ids] + await asyncio.gather(*imports) + + async def get_local_games(self): + raise NotImplementedError() + + async def launch_game(self, game_id): + raise NotImplementedError() + + async def install_game(self, game_id): + raise NotImplementedError() + + async def uninstall_game(self, game_id): + raise NotImplementedError() + + async def get_friends(self): + raise NotImplementedError() + + async def get_users(self, user_id_list): + raise NotImplementedError() + + async def send_message(self, room_id, message_text): + raise NotImplementedError() + + async def mark_as_read(self, room_id, last_message_id): + raise NotImplementedError() + + async def get_rooms(self): + raise NotImplementedError() + + async def get_room_history_from_message(self, room_id, message_id): + raise NotImplementedError() + + async def get_room_history_from_timestamp(self, room_id, from_timestamp): + raise NotImplementedError() + + async def get_game_times(self): + raise NotImplementedError() + + async def start_game_times_import(self, game_ids): + 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 + + asyncio.create_task(import_game_times_task(game_ids)) + self._game_times_import_in_progress = True + + async def import_game_times(self, game_ids): + """Call game_time_import_success/game_time_import_failure for each game_id on the list""" + 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): + if len(argv) < 3: + logging.critical("Not enough parameters, required: token, port") + sys.exit(1) + + token = argv[1] + + try: + port = int(argv[2]) + except ValueError: + logging.critical("Failed to parse port value: %s", argv[2]) + sys.exit(2) + + if not (1 <= port <= 65535): + logging.critical("Port value out of range (1, 65535)") + sys.exit(3) + + if not issubclass(plugin_class, Plugin): + logging.critical("plugin_class must be subclass of Plugin") + sys.exit(4) + + async def coroutine(): + reader, writer = await asyncio.open_connection("127.0.0.1", port) + extra_info = writer.get_extra_info('sockname') + logging.info("Using local address: %s:%u", *extra_info) + plugin = plugin_class(reader, writer, token) + await plugin.run() + + try: + asyncio.run(coroutine()) + except Exception: + logging.exception("Error while running plugin") + sys.exit(5) diff --git a/src/galaxy/api/types.py b/src/galaxy/api/types.py new file mode 100644 index 0000000..746f3a3 --- /dev/null +++ b/src/galaxy/api/types.py @@ -0,0 +1,94 @@ +from dataclasses import dataclass +from typing import List, Dict, Optional + +from galaxy.api.consts import LicenseType, LocalGameState, PresenceState + +@dataclass +class Authentication(): + user_id: str + user_name: str + +@dataclass +class Cookie(): + name: str + value: str + domain: Optional[str] = None + path: Optional[str] = None + +@dataclass +class NextStep(): + next_step: str + auth_params: Dict[str, str] + cookies: Optional[List[Cookie]] = None + js: Optional[Dict[str, List[str]]] = None + +@dataclass +class LicenseInfo(): + license_type: LicenseType + owner: Optional[str] = None + +@dataclass +class Dlc(): + dlc_id: str + dlc_title: str + license_info: LicenseInfo + +@dataclass +class Game(): + game_id: str + game_title: str + dlcs: Optional[List[Dlc]] + license_info: LicenseInfo + +@dataclass +class Achievement(): + unlock_time: int + achievement_id: Optional[str] = None + achievement_name: Optional[str] = None + + def __post_init__(self): + assert self.achievement_id or self.achievement_name, \ + "One of achievement_id or achievement_name is required" + +@dataclass +class LocalGame(): + game_id: str + local_game_state: LocalGameState + +@dataclass +class Presence(): + presence_state: PresenceState + game_id: Optional[str] = None + presence_status: Optional[str] = None + +@dataclass +class UserInfo(): + user_id: str + is_friend: bool + user_name: str + avatar_url: str + presence: Presence + +@dataclass +class FriendInfo(): + user_id: str + user_name: str + +@dataclass +class Room(): + room_id: str + unread_message_count: int + last_message_id: str + +@dataclass +class Message(): + message_id: str + sender_id: str + sent_time: int + message_text: str + +@dataclass +class GameTime(): + game_id: str + time_played: int + last_played_time: int diff --git a/src/galaxy/http.py b/src/galaxy/http.py new file mode 100644 index 0000000..5b494ca --- /dev/null +++ b/src/galaxy/http.py @@ -0,0 +1,47 @@ +import asyncio +import ssl +from http import HTTPStatus + +import aiohttp +import certifi + +from galaxy.api.errors import ( + AccessDenied, AuthenticationRequired, + BackendTimeout, BackendNotAvailable, BackendError, NetworkError, UnknownBackendResponse, UnknownError +) + +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) + + async def close(self): + await self._session.close() + + async def request(self, method, *args, **kwargs): + try: + response = await self._session.request(method, *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: + 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 >= 500: + raise BackendError() + if response.status >= 400: + raise UnknownError() + + return response diff --git a/src/galaxy/tools.py b/src/galaxy/tools.py new file mode 100644 index 0000000..3996d25 --- /dev/null +++ b/src/galaxy/tools.py @@ -0,0 +1,20 @@ +import io +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] + files = [file for file in files if file] + + zip_buffer = io.BytesIO() + with zipfile.ZipFile(zip_buffer, mode="w", compression=zipfile.ZIP_DEFLATED) as zipf: + for file in files: + 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: + archive.write(zip_content) diff --git a/src/galaxy/unittest/__init__.py b/src/galaxy/unittest/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/galaxy/unittest/mock.py b/src/galaxy/unittest/mock.py new file mode 100644 index 0000000..264c3fa --- /dev/null +++ b/src/galaxy/unittest/mock.py @@ -0,0 +1,12 @@ +from asyncio import coroutine +from unittest.mock import MagicMock + +class AsyncMock(MagicMock): + async def __call__(self, *args, **kwargs): + return super(AsyncMock, self).__call__(*args, **kwargs) + +def coroutine_mock(): + coro = MagicMock(name="CoroutineResult") + corofunc = MagicMock(name="CoroutineFunction", side_effect=coroutine(coro)) + corofunc.coro = coro + return corofunc \ No newline at end of file diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..fed2e87 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,67 @@ +from contextlib import ExitStack +import logging +from unittest.mock import patch, MagicMock + +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() + yield stream + +@pytest.fixture() +def writer(): + stream = MagicMock(name="stream_writer") + stream.write = MagicMock() + stream.drain = AsyncMock() + yield stream + +@pytest.fixture() +def readline(reader): + yield reader.readline + +@pytest.fixture() +def write(writer): + yield writer.write + +@pytest.fixture() +def plugin(reader, writer): + """Return plugin instance with all feature methods mocked""" + async_methods = ( + "authenticate", + "get_owned_games", + "get_unlocked_achievements", + "get_local_games", + "launch_game", + "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 = ( + "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 new file mode 100644 index 0000000..84421bd --- /dev/null +++ b/tests/test_achievements.py @@ -0,0 +1,193 @@ +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 + +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): + request = { + "jsonrpc": "2.0", + "id": "3", + "method": "import_unlocked_achievements", + "params": { + "game_id": "14" + } + } + readline.side_effect = [json.dumps(request), ""] + plugin.get_unlocked_achievements.coro.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]) + + 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 + } + ] + } + } + +def test_failure(plugin, readline, write): + request = { + "jsonrpc": "2.0", + "id": "3", + "method": "import_unlocked_achievements", + "params": { + "game_id": "14" + } + } + + readline.side_effect = [json.dumps(request), ""] + plugin.get_unlocked_achievements.coro.side_effect = UnknownError() + asyncio.run(plugin.run()) + plugin.get_unlocked_achievements.assert_called() + response = json.loads(write.call_args[0][0]) + + assert response == { + "jsonrpc": "2.0", + "id": "3", + "error": { + "code": 0, + "message": "Unknown error" + } + } + +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()) + response = json.loads(write.call_args[0][0]) + + assert response == { + "jsonrpc": "2.0", + "method": "achievement_unlocked", + "params": { + "game_id": "14", + "achievement": { + "achievement_id": "lvl20", + "unlock_time": 1548422395 + } + } + } + +@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 new file mode 100644 index 0000000..4c25ba5 --- /dev/null +++ b/tests/test_authenticate.py @@ -0,0 +1,119 @@ +import asyncio +import json + +import pytest + +from galaxy.api.types import Authentication +from galaxy.api.errors import ( + UnknownError, InvalidCredentials, NetworkError, LoggedInElsewhere, ProtocolError, + BackendNotAvailable, BackendTimeout, BackendError, TemporaryBlocked, Banned, AccessDenied, + ParentalControlBlock, DeviceBlocked, RegionBlocked +) + +def test_success(plugin, readline, 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()) + 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" + } + } + +@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"), + pytest.param(BackendTimeout, 3, "Backend timed out", id="backend_timeout"), + pytest.param(BackendError, 4, "Backend error", id="backend_error"), + pytest.param(InvalidCredentials, 100, "Invalid credentials", id="invalid_credentials"), + pytest.param(NetworkError, 101, "Network error", id="network_error"), + pytest.param(LoggedInElsewhere, 102, "Logged in elsewhere", id="logged_elsewhere"), + pytest.param(ProtocolError, 103, "Protocol error", id="protocol_error"), + pytest.param(TemporaryBlocked, 104, "Temporary blocked", id="temporary_blocked"), + pytest.param(Banned, 105, "Banned", id="banned"), + pytest.param(AccessDenied, 106, "Access denied", id="access_denied"), + pytest.param(ParentalControlBlock, 107, "Parental control block", id="parental_control_clock"), + pytest.param(DeviceBlocked, 108, "Device blocked", id="device_blocked"), + pytest.param(RegionBlocked, 109, "Region blocked", id="region_blocked") +]) +def test_failure(plugin, readline, 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()) + 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 + } + } + +def test_stored_credentials(plugin, readline, write): + request = { + "jsonrpc": "2.0", + "id": "3", + "method": "init_authentication", + "params": { + "stored_credentials": { + "token": "ABC" + } + } + } + readline.side_effect = [json.dumps(request), ""] + plugin.authenticate.coro.return_value = Authentication("132", "Zenek") + asyncio.run(plugin.run()) + plugin.authenticate.assert_called_with(stored_credentials={"token": "ABC"}) + write.assert_called() + +def test_store_credentials(plugin, write): + credentials = { + "token": "ABC" + } + + async def couritine(): + plugin.store_credentials(credentials) + + asyncio.run(couritine()) + response = json.loads(write.call_args[0][0]) + + assert response == { + "jsonrpc": "2.0", + "method": "store_credentials", + "params": credentials + } + +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 + } diff --git a/tests/test_chat.py b/tests/test_chat.py new file mode 100644 index 0000000..97dad89 --- /dev/null +++ b/tests/test_chat.py @@ -0,0 +1,354 @@ +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_features.py b/tests/test_features.py new file mode 100644 index 0000000..7f11c17 --- /dev/null +++ b/tests/test_features.py @@ -0,0 +1,45 @@ +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 == [] + +def test_no_overloads(): + 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 + 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): + pass + async def mark_as_read(self, room_id, last_message_id): + 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): + 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 == [] diff --git a/tests/test_friends.py b/tests/test_friends.py new file mode 100644 index 0000000..52cdd9b --- /dev/null +++ b/tests/test_friends.py @@ -0,0 +1,90 @@ +import asyncio +import json + +from galaxy.api.types import FriendInfo +from galaxy.api.errors import UnknownError + + +def test_get_friends_success(plugin, readline, write): + request = { + "jsonrpc": "2.0", + "id": "3", + "method": "import_friends" + } + + readline.side_effect = [json.dumps(request), ""] + plugin.get_friends.coro.return_value = [ + FriendInfo("3", "Jan"), + FriendInfo("5", "Ola") + ] + asyncio.run(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"} + ] + } + } + + +def test_get_friends_failure(plugin, readline, 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()) + 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", + } + } + + +def test_add_friend(plugin, write): + friend = FriendInfo("7", "Kuba") + + async def couritine(): + 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"} + } + } + + +def test_remove_friend(plugin, write): + async def couritine(): + 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" + } + } diff --git a/tests/test_game_times.py b/tests/test_game_times.py new file mode 100644 index 0000000..9ad7220 --- /dev/null +++ b/tests/test_game_times.py @@ -0,0 +1,175 @@ +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 + +def test_success(plugin, readline, write): + request = { + "jsonrpc": "2.0", + "id": "3", + "method": "import_game_times" + } + + readline.side_effect = [json.dumps(request), ""] + plugin.get_game_times.coro.return_value = [ + GameTime("3", 60, 1549550504), + GameTime("5", 10, 1549550502) + ] + asyncio.run(plugin.run()) + plugin.get_game_times.assert_called_with() + response = json.loads(write.call_args[0][0]) + + assert response == { + "jsonrpc": "2.0", + "id": "3", + "result": { + "game_times": [ + { + "game_id": "3", + "time_played": 60, + "last_played_time": 1549550504 + }, + { + "game_id": "5", + "time_played": 10, + "last_played_time": 1549550502 + } + ] + } + } + +def test_failure(plugin, readline, write): + request = { + "jsonrpc": "2.0", + "id": "3", + "method": "import_game_times" + } + + 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 response == { + "jsonrpc": "2.0", + "id": "3", + "error": { + "code": 0, + "message": "Unknown error", + } + } + +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 + } + } + } + +@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 + } + } + } + +@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]) + + assert response == { + "jsonrpc": "2.0", + "method": "game_time_import_failure", + "params": { + "game_id": "134", + "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") + + 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() diff --git a/tests/test_install_game.py b/tests/test_install_game.py new file mode 100644 index 0000000..ca9c4d0 --- /dev/null +++ b/tests/test_install_game.py @@ -0,0 +1,16 @@ +import asyncio +import json + +def test_success(plugin, readline): + request = { + "jsonrpc": "2.0", + "method": "install_game", + "params": { + "game_id": "3" + } + } + + readline.side_effect = [json.dumps(request), ""] + plugin.get_owned_games.return_value = None + asyncio.run(plugin.run()) + plugin.install_game.assert_called_with(game_id="3") diff --git a/tests/test_internal.py b/tests/test_internal.py new file mode 100644 index 0000000..44f8ee7 --- /dev/null +++ b/tests/test_internal.py @@ -0,0 +1,68 @@ +import asyncio +import json + +from galaxy.api.plugin import Plugin +from galaxy.api.consts import Platform + +def test_get_capabilites(reader, writer, readline, write): + class PluginImpl(Plugin): #pylint: disable=abstract-method + async def get_owned_games(self): + pass + + request = { + "jsonrpc": "2.0", + "id": "3", + "method": "get_capabilities" + } + 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 + } + } + +def test_shutdown(plugin, readline, write): + request = { + "jsonrpc": "2.0", + "id": "5", + "method": "shutdown" + } + readline.side_effect = [json.dumps(request)] + asyncio.run(plugin.run()) + plugin.shutdown.assert_called_with() + response = json.loads(write.call_args[0][0]) + assert response == { + "jsonrpc": "2.0", + "id": "5", + "result": None + } + +def test_ping(plugin, readline, 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 + } + +def test_tick(plugin, readline): + readline.side_effect = [""] + asyncio.run(plugin.run()) + plugin.tick.assert_called_with() diff --git a/tests/test_launch_game.py b/tests/test_launch_game.py new file mode 100644 index 0000000..fa654e9 --- /dev/null +++ b/tests/test_launch_game.py @@ -0,0 +1,16 @@ +import asyncio +import json + +def test_success(plugin, readline): + request = { + "jsonrpc": "2.0", + "method": "launch_game", + "params": { + "game_id": "3" + } + } + + readline.side_effect = [json.dumps(request), ""] + plugin.get_owned_games.return_value = None + asyncio.run(plugin.run()) + plugin.launch_game.assert_called_with(game_id="3") diff --git a/tests/test_local_games.py b/tests/test_local_games.py new file mode 100644 index 0000000..445e699 --- /dev/null +++ b/tests/test_local_games.py @@ -0,0 +1,96 @@ +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 + +def test_success(plugin, readline, write): + request = { + "jsonrpc": "2.0", + "id": "3", + "method": "import_local_games" + } + + readline.side_effect = [json.dumps(request), ""] + + plugin.get_local_games.coro.return_value = [ + LocalGame("1", LocalGameState.Running), + LocalGame("2", LocalGameState.Installed), + LocalGame("3", LocalGameState.Installed | LocalGameState.Running) + ] + asyncio.run(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 + } + ] + } + } + +@pytest.mark.parametrize( + "error,code,message", + [ + pytest.param(UnknownError, 0, "Unknown error", id="unknown_error"), + pytest.param(FailedParsingManifest, 200, "Failed parsing manifest", id="failed_parsing") + ], +) +def test_failure(plugin, readline, 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()) + 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 + } + } + } diff --git a/tests/test_owned_games.py b/tests/test_owned_games.py new file mode 100644 index 0000000..1202c9e --- /dev/null +++ b/tests/test_owned_games.py @@ -0,0 +1,151 @@ +import asyncio +import json + +from galaxy.api.types import Game, Dlc, LicenseInfo +from galaxy.api.consts import LicenseType +from galaxy.api.errors import UnknownError + +def test_success(plugin, readline, write): + request = { + "jsonrpc": "2.0", + "id": "3", + "method": "import_owned_games" + } + + readline.side_effect = [json.dumps(request), ""] + plugin.get_owned_games.coro.return_value = [ + Game("3", "Doom", None, LicenseInfo(LicenseType.SinglePurchase, None)), + Game( + "5", + "Witcher 3", + [ + Dlc("7", "Hearts of Stone", LicenseInfo(LicenseType.SinglePurchase, None)), + Dlc("8", "Temerian Armor Set", LicenseInfo(LicenseType.FreeToPlay, None)), + ], + LicenseInfo(LicenseType.SinglePurchase, None)) + ] + asyncio.run(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" + } + } + ], + "license_info": { + "license_type": "SinglePurchase" + } + } + ] + } + } + +def test_failure(plugin, readline, 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()) + 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" + } + } + +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" + } + } + } + } + +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" + } + } + +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" + } + } + } + } diff --git a/tests/test_uninstall_game.py b/tests/test_uninstall_game.py new file mode 100644 index 0000000..2e7c4ef --- /dev/null +++ b/tests/test_uninstall_game.py @@ -0,0 +1,16 @@ +import asyncio +import json + +def test_success(plugin, readline): + request = { + "jsonrpc": "2.0", + "method": "uninstall_game", + "params": { + "game_id": "3" + } + } + + readline.side_effect = [json.dumps(request), ""] + plugin.get_owned_games.return_value = None + asyncio.run(plugin.run()) + plugin.uninstall_game.assert_called_with(game_id="3") diff --git a/tests/test_users.py b/tests/test_users.py new file mode 100644 index 0000000..24c9dbb --- /dev/null +++ b/tests/test_users.py @@ -0,0 +1,69 @@ +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" + } + } From 0af7387342ef21c13b3d5d286d84cd6eee00d1af Mon Sep 17 00:00:00 2001 From: GOG Galaxy SDK Team Date: Fri, 31 May 2019 11:53:15 +0200 Subject: [PATCH 03/58] version 0.31.2 --- README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.md b/README.md index 09aad74..f452516 100644 --- a/README.md +++ b/README.md @@ -61,3 +61,7 @@ pytest ``` ## Methods Documentation TODO + +## Legal Notice + +By integrating or attempting to integrate any applications or content with or into GOG Galaxy® 2.0. you represent that such application or content is your original creation (other than any software made available by GOG) and/or that you have all necessary rights to grant such applicable rights to the relevant community integration to GOG and to GOG Galaxy 2.0 end users for the purpose of use of such community integration and that such community integration comply with any third party license and other requirements including compliance with applicable laws. From f97b6c8971fdaef7dfc1139848c18bc12a98cfaa Mon Sep 17 00:00:00 2001 From: GOG Galaxy SDK Team Date: Fri, 31 May 2019 12:09:18 +0200 Subject: [PATCH 04/58] version 0.31.3 --- setup.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 setup.py diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..962a712 --- /dev/null +++ b/setup.py @@ -0,0 +1,15 @@ +from setuptools import setup, find_packages + +setup( + name="galaxy.plugin.api", + version="0.31.3", + description="Galaxy python plugin API", + author='Galaxy team', + author_email='galaxy@gog.com', + packages=find_packages("src"), + package_dir={'': 'src'}, + install_requires=[ + "aiohttp==3.5.4", + "certifi==2019.3.9" + ] +) From 6c6dc42cd6a4858c6293b2b04b94568989d126c9 Mon Sep 17 00:00:00 2001 From: GOG Galaxy SDK Team Date: Fri, 7 Jun 2019 15:08:47 +0200 Subject: [PATCH 05/58] version 0.32.0 --- README.md | 45 ++-- docs/make.py | 13 + docs/requirements.txt | 4 + docs/source/conf.py | 74 ++++++ docs/source/galaxy.api.rst | 40 +++ docs/source/index.rst | 14 ++ docs/source/overview.rst | 1 + requirements.txt | 2 +- setup.py | 4 +- src/.readthedocs.yml | 12 + src/galaxy/api/consts.py | 15 ++ src/galaxy/api/jsonrpc.py | 17 +- src/galaxy/api/plugin.py | 481 +++++++++++++++++++++++++++++++++---- src/galaxy/api/types.py | 114 +++++++++ 14 files changed, 755 insertions(+), 81 deletions(-) create mode 100644 docs/make.py create mode 100644 docs/requirements.txt create mode 100644 docs/source/conf.py create mode 100644 docs/source/galaxy.api.rst create mode 100644 docs/source/index.rst create mode 100644 docs/source/overview.rst create mode 100644 src/.readthedocs.yml diff --git a/README.md b/README.md index f452516..fbbb420 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,26 @@ # GOG Galaxy - Community Integration - Python API -This document is still work in progress. +This Python library allows to easily build community integrations for various gaming platforms with GOG Galaxy 2.0. -## Basic Usage +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 main instance of GOG Galaxy 2.0. -Basic implementation: +The provided features are: + +- multistep authorisation 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 +- cache storage + +## 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`. +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 import sys @@ -33,7 +49,11 @@ if __name__ == "__main__": main() ``` -Plugin should be deployed with manifest: +## Deployment + +The client has a built-in Python 3.7 interpreter, so the integrations are delivered as `.py` files. +The additional `manifest.json` file is required: + ```json { "name": "Example plugin", @@ -47,21 +67,6 @@ Plugin should be deployed with manifest: "script": "plugin.py" } ``` - -## Development - -Install required packages: -```bash -pip install -r requirements.txt -``` - -Run tests: -```bash -pytest -``` -## Methods Documentation -TODO - ## Legal Notice -By integrating or attempting to integrate any applications or content with or into GOG Galaxy® 2.0. you represent that such application or content is your original creation (other than any software made available by GOG) and/or that you have all necessary rights to grant such applicable rights to the relevant community integration to GOG and to GOG Galaxy 2.0 end users for the purpose of use of such community integration and that such community integration comply with any third party license and other requirements including compliance with applicable laws. +By integrating or attempting to integrate any applications or content with or into GOG Galaxy 2.0. you represent that such application or content is your original creation (other than any software made available by GOG) and/or that you have all necessary rights to grant such applicable rights to the relevant community integration to GOG and to GOG Galaxy 2.0 end users for the purpose of use of such community integration and that such community integration comply with any third party license and other requirements including compliance with applicable laws. diff --git a/docs/make.py b/docs/make.py new file mode 100644 index 0000000..cb41897 --- /dev/null +++ b/docs/make.py @@ -0,0 +1,13 @@ +"""Builds documentation locally. Use for preview only""" + +import subprocess +import webbrowser + + +source = "docs/source" +build = "docs/build" +master_doc = 'index' + +subprocess.run(['sphinx-build', '-M', 'clean', source, build]) +subprocess.run(['sphinx-build', '-M', 'html', source, build]) +webbrowser.open(f'{build}/html/{master_doc}') \ No newline at end of file diff --git a/docs/requirements.txt b/docs/requirements.txt new file mode 100644 index 0000000..98fe76e --- /dev/null +++ b/docs/requirements.txt @@ -0,0 +1,4 @@ +Sphinx==2.0.1 +sphinx-rtd-theme==0.4.3 +sphinx-autodoc-typehints==1.6.0 +m2r==0.2.1 \ No newline at end of file diff --git a/docs/source/conf.py b/docs/source/conf.py new file mode 100644 index 0000000..096dbb5 --- /dev/null +++ b/docs/source/conf.py @@ -0,0 +1,74 @@ +# Configuration file for the Sphinx documentation builder. +# Documentation: +# http://www.sphinx-doc.org/en/master/config + +import os +import sys +import subprocess + +# -- Path setup -------------------------------------------------------------- +_ROOT = os.path.join('..', '..') + +sys.path.append(os.path.abspath(os.path.join(_ROOT, 'src'))) + +# -- Project information ----------------------------------------------------- + +project = 'GOG Galaxy Integrations API' +copyright = '2019, GOG.com' + +_author, _version = subprocess.check_output( + ['python', os.path.join(_ROOT, 'setup.py'), '--author', '--version'], + universal_newlines=True).strip().split('\n') + +author = _author +version = _version +release = _version + + +# -- General configuration --------------------------------------------------- + +# Add any Sphinx extension module names here, as strings. They can be +# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom +# ones. +extensions = [ + 'sphinx.ext.autodoc', + 'sphinx_autodoc_typehints', + 'm2r' # mdinclude directive for makrdown files +] +autodoc_member_order = 'bysource' +autodoc_inherit_docstrings = False +autodoc_mock_imports = ["galaxy.http"] + +set_type_checking_flag = True + +# Add any paths that contain templates here, relative to this directory. +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 = [] + + +# -- Options for HTML output ------------------------------------------------- + +# The theme to use for HTML and HTML Help pages. See the documentation for +# a list of builtin themes. +# +html_theme = "sphinx_rtd_theme" +html_theme_options = { + # 'canonical_url': '', # main page to be serach in google with trailing slash + 'display_version': True, + 'style_external_links': True, + # Toc options + 'collapse_navigation': False, + 'sticky_navigation': True, + 'navigation_depth': 4, +} + +# Add any paths that contain custom static files (such as style sheets) here, +# relative to this directory. They are copied after the builtin static files, +# so a file named "default.css" will overwrite the builtin "default.css". +html_static_path = ['_static'] + +master_doc = 'index' diff --git a/docs/source/galaxy.api.rst b/docs/source/galaxy.api.rst new file mode 100644 index 0000000..7a45d37 --- /dev/null +++ b/docs/source/galaxy.api.rst @@ -0,0 +1,40 @@ +galaxy.api +================= + +plugin +------------------------ + +.. automodule:: galaxy.api.plugin + :members: + :undoc-members: + :exclude-members: JSONEncoder, features, achievements_import_finished, game_times_import_finished, start_achievements_import, start_game_times_import, get_game_times, get_unlocked_achievements + +types +----------------------- + +.. automodule:: galaxy.api.types + :members: + :undoc-members: + +consts +------------------------ + +.. automodule:: galaxy.api.consts + :members: + :undoc-members: + :show-inheritance: + :exclude-members: Feature + +errors +------------------------ + +.. autoexception:: galaxy.api.jsonrpc.ApplicationError + :show-inheritance: + +.. autoexception:: galaxy.api.jsonrpc.UnknownError + :show-inheritance: + +.. automodule:: galaxy.api.errors + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/index.rst b/docs/source/index.rst new file mode 100644 index 0000000..05f97f7 --- /dev/null +++ b/docs/source/index.rst @@ -0,0 +1,14 @@ +GOG Galaxy Integrations Python API +================================================= + +.. toctree:: + :maxdepth: 2 + :includehidden: + + Overview + API + +Index +------------------- + +* :ref:`genindex` diff --git a/docs/source/overview.rst b/docs/source/overview.rst new file mode 100644 index 0000000..3bd447c --- /dev/null +++ b/docs/source/overview.rst @@ -0,0 +1 @@ +.. mdinclude:: ../../README.md diff --git a/requirements.txt b/requirements.txt index 528cc4c..1e99e14 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,4 +5,4 @@ pytest-mock==1.10.3 pytest-flakes==4.0.0 # because of pip bug https://github.com/pypa/pip/issues/4780 aiohttp==3.5.4 -certifi==2019.3.9 +certifi==2019.3.9 \ No newline at end of file diff --git a/setup.py b/setup.py index 962a712..513ede7 100644 --- a/setup.py +++ b/setup.py @@ -2,8 +2,8 @@ from setuptools import setup, find_packages setup( name="galaxy.plugin.api", - version="0.31.3", - description="Galaxy python plugin API", + version="0.32.0", + description="GOG Galaxy Integrations Python API", author='Galaxy team', author_email='galaxy@gog.com', packages=find_packages("src"), diff --git a/src/.readthedocs.yml b/src/.readthedocs.yml new file mode 100644 index 0000000..051ee73 --- /dev/null +++ b/src/.readthedocs.yml @@ -0,0 +1,12 @@ +version: 2 + +sphinx: + configuration: docs/source/conf.py + +formats: all + +python: + version: 3.7 + install: + - requirements: requirements.txt + - requirements: docs/requirements.txt diff --git a/src/galaxy/api/consts.py b/src/galaxy/api/consts.py index a4ad274..e938977 100644 --- a/src/galaxy/api/consts.py +++ b/src/galaxy/api/consts.py @@ -1,6 +1,8 @@ from enum import Enum, Flag + class Platform(Enum): + """Supported gaming platforms""" Unknown = "unknown" Gog = "gog" Steam = "steam" @@ -12,7 +14,11 @@ class Platform(Enum): Battlenet = "battlenet" Epic = "epic" + class Feature(Enum): + """Possible features that can be implemented by an integration. + It does not have to support all or any specific features from the list. + """ Unknown = "Unknown" ImportInstalledGames = "ImportInstalledGames" ImportOwnedGames = "ImportOwnedGames" @@ -26,18 +32,27 @@ class Feature(Enum): VerifyGame = "VerifyGame" ImportFriends = "ImportFriends" + class LicenseType(Enum): + """Possible game license types, understandable for the GOG Galaxy client.""" Unknown = "Unknown" SinglePurchase = "SinglePurchase" FreeToPlay = "FreeToPlay" OtherUserLicense = "OtherUserLicense" + class LocalGameState(Flag): + """Possible states that a local game can be in. + For example a game which is both installed and currently running should have its state set as a "bitwise or" of Running and Installed flags: + ``local_game_state=`` + """ None_ = 0 Installed = 1 Running = 2 + class PresenceState(Enum): + """"Possible states that a user can be in.""" Unknown = "Unknown" Online = "online" Offline = "offline" diff --git a/src/galaxy/api/jsonrpc.py b/src/galaxy/api/jsonrpc.py index 07290d7..45f1b0d 100644 --- a/src/galaxy/api/jsonrpc.py +++ b/src/galaxy/api/jsonrpc.py @@ -79,22 +79,24 @@ class Server(): def register_method(self, name, callback, internal, sensitive_params=False): """ Register method + :param name: :param callback: :param internal: if True the callback will be processed immediately (synchronously) - :param sensitive_params: list of parameters that will by anonymized before logging; if False - no params - are considered sensitive, if True - all params are considered sensitive + :param sensitive_params: list of parameters that are anonymized before logging; \ + if False - no params are considered sensitive, if True - all params are considered sensitive """ self._methods[name] = Method(callback, inspect.signature(callback), internal, sensitive_params) def register_notification(self, name, callback, internal, sensitive_params=False): """ Register notification + :param name: :param callback: :param internal: if True the callback will be processed immediately (synchronously) - :param sensitive_params: list of parameters that will by anonymized before logging; if False - no params - are considered sensitive, if True - all params are considered sensitive + :param sensitive_params: list of parameters that are anonymized before logging; \ + if False - no params are considered sensitive, if True - all params are considered sensitive """ self._notifications[name] = Method(callback, inspect.signature(callback), internal, sensitive_params) @@ -187,7 +189,7 @@ class Server(): self._send_error(request.id, MethodNotFound()) except JsonRpcError as error: self._send_error(request.id, error) - except Exception as e: #pylint: disable=broad-except + except Exception as e: #pylint: disable=broad-except logging.exception("Unexpected exception raised in plugin handler") self._send_error(request.id, UnknownError(str(e))) @@ -256,10 +258,11 @@ class NotificationClient(): def notify(self, method, params, sensitive_params=False): """ Send notification + :param method: :param params: - :param sensitive_params: list of parameters that will by anonymized before logging; if False - no params - are considered sensitive, if True - all params are considered sensitive + :param sensitive_params: list of parameters that are anonymized before logging; \ + if False - no params are considered sensitive, if True - all params are considered sensitive """ notification = { "jsonrpc": "2.0", diff --git a/src/galaxy/api/plugin.py b/src/galaxy/api/plugin.py index db8321a..9b0dc71 100644 --- a/src/galaxy/api/plugin.py +++ b/src/galaxy/api/plugin.py @@ -7,7 +7,11 @@ from enum import Enum from collections import OrderedDict import sys -from galaxy.api.jsonrpc import Server, NotificationClient +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 @@ -23,6 +27,7 @@ class JSONEncoder(json.JSONEncoder): return super().default(o) 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 @@ -187,7 +192,7 @@ class Plugin(): self._feature_methods.setdefault(feature, []).append(handler) async def run(self): - """Plugin main coorutine""" + """Plugin main coroutine.""" async def pass_control(): while self._active: try: @@ -199,7 +204,7 @@ class Plugin(): await asyncio.gather(pass_control(), self._server.run()) def _shutdown(self): - logging.info("Shuting down") + logging.info("Shutting down") self._server.stop() self._active = False self.shutdown() @@ -216,39 +221,115 @@ class Plugin(): pass # notifications - def store_credentials(self, credentials): - """Notify client to store plugin credentials. - They will be pass to next authencicate calls. - """ + def store_credentials(self, credentials: dict): + """Notify the client to store authentication credentials. + Credentials are passed on the next authenticate call. + + :param credentials: credentials that client will store; they are stored locally on a user pc + + Example use case of store_credentials: + + .. code-block:: python + :linenos: + + async def pass_login_credentials(self, step, credentials, cookies): + if self.got_everything(credentials,cookies): + user_data = await self.parse_credentials(credentials,cookies) + else: + next_params = self.get_next_params(credentials,cookies) + next_cookies = self.get_next_cookies(credentials,cookies) + return NextStep("web_session", next_params, cookies=next_cookies) + self.store_credentials(user_data['credentials']) + return Authentication(user_data['userId'], user_data['username']) + + """ self._notification_client.notify("store_credentials", credentials, sensitive_params=True) - def add_game(self, game): + def add_game(self, game: Game): + """Notify the client to add game to the list of owned games + of the currently authenticated user. + + :param game: Game to add to the list of owned games + + Example use case of add_game: + + .. code-block:: python + :linenos: + + async def check_for_new_games(self): + games = await self.get_owned_games() + for game in games: + if game not in self.owned_games_cache: + self.owned_games_cache.append(game) + self.add_game(game) + + """ params = {"owned_game" : game} self._notification_client.notify("owned_game_added", params) - def remove_game(self, game_id): + def remove_game(self, game_id: str): + """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 + + Example use case of remove_game: + + .. code-block:: python + :linenos: + + async def check_for_removed_games(self): + games = await self.get_owned_games() + for game in self.owned_games_cache: + if game not in games: + self.owned_games_cache.remove(game) + self.remove_game(game.game_id) + + """ params = {"game_id" : game_id} self._notification_client.notify("owned_game_removed", params) - def update_game(self, game): + def update_game(self, game: Game): + """Notify the client to update the status of a game + owned by the currently authenticated user. + + :param game: Game to update + """ params = {"owned_game" : game} self._notification_client.notify("owned_game_updated", params) - def unlock_achievement(self, game_id, achievement): + def unlock_achievement(self, game_id: str, achievement: Achievement): + """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 achievement: achievement to unlock. + """ params = { "game_id": game_id, "achievement": achievement } self._notification_client.notify("achievement_unlocked", params) - def game_achievements_import_success(self, game_id, achievements): + 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 + """ 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, error): + 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 + """ params = { "game_id": game_id, "error": { @@ -259,21 +340,60 @@ 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""" self._notification_client.notify("achievements_import_finished", None) - def update_local_game_status(self, local_game): + def update_local_game_status(self, local_game: LocalGame): + """Notify the client to update the status of a local game. + + :param local_game: the LocalGame to update + + Example use case triggered by the :meth:`.tick` method: + + .. code-block:: python + :linenos: + :emphasize-lines: 5 + + async def _check_statuses(self): + for game in await self._get_local_games(): + if game.status == self._cached_game_statuses.get(game.id): + continue + self.update_local_game_status(LocalGame(game.id, game.status)) + self._cached_games_statuses[game.id] = game.status + asyncio.sleep(5) # interval + + def tick(self): + if self._check_statuses_task is None or self._check_statuses_task.done(): + self._check_statuses_task = asyncio.create_task(self._check_statuses()) + """ params = {"local_game" : local_game} self._notification_client.notify("local_game_status_changed", params) - def add_friend(self, user): + def add_friend(self, user: FriendInfo): + """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 + """ params = {"friend_info" : user} self._notification_client.notify("friend_added", params) - def remove_friend(self, user_id): + def remove_friend(self, user_id: str): + """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 + """ params = {"user_id" : user_id} self._notification_client.notify("friend_removed", params) - def update_room(self, room_id, unread_message_count=None, new_messages=None): + 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 @@ -281,15 +401,30 @@ class Plugin(): params["messages"] = new_messages self._notification_client.notify("chat_room_updated", params) - def update_game_time(self, game_time): + def update_game_time(self, game_time: GameTime): + """Notify the client to update game time for a game. + + :param game_time: game time to update + """ params = {"game_time" : game_time} self._notification_client.notify("game_time_updated", params) - def game_time_import_success(self, game_time): + 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 + """ params = {"game_time" : game_time} self._notification_client.notify("game_time_import_success", params) - def game_time_import_failure(self, game_id, error): + 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 + """ params = { "game_id": game_id, "error": { @@ -300,42 +435,133 @@ 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`. + """ self._notification_client.notify("game_times_import_finished", None) def lost_authentication(self): + """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) # handlers def tick(self): - """This method is called periodicaly. - Override it to implement periodical tasks like refreshing cache. - This method should not be blocking - any longer actions should be - handled by asycio tasks. + """This method is called periodically. + Override it to implement periodical non-blocking tasks. + This method is called internally. + + Example of possible override of the method: + + .. code-block:: python + :linenos: + + def tick(self): + if not self.checking_for_new_games: + asyncio.create_task(self.check_for_new_games()) + if not self.checking_for_removed_games: + asyncio.create_task(self.check_for_removed_games()) + if not self.updating_game_statuses: + asyncio.create_task(self.update_game_statuses()) + """ def shutdown(self): - """This method is called on plugin shutdown. + """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=None): - """Overide this method to handle plugin authentication. - The method should return galaxy.api.types.Authentication - or raise galaxy.api.types.LoginError on authentication failure. + async def authenticate(self, stored_credentials:dict=None): + """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. + This method is called by the GOG Galaxy client. + + :param stored_credentials: If the client received any credentials to store locally + in the previous session they will be passed here as a parameter. + + + Example of possible override of the method: + + .. code-block:: python + :linenos: + + async def authenticate(self, stored_credentials=None): + if not stored_credentials: + return NextStep("web_session", PARAMS, cookies=COOKIES) + else: + try: + user_data = self._authenticate(stored_credentials) + except AccessDenied: + raise InvalidCredentials() + return Authentication(user_data['userId'], user_data['username']) + """ raise NotImplementedError() - async def pass_login_credentials(self, step, credentials, cookies): + async def pass_login_credentials(self, step: str, credentials: Dict[str, str], cookies: List[Dict[str, str]]): + """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 + or galaxy.api.types.NextStep if it requires going to another cef url. + This method is called by the GOG Galaxy client. + + :param step: deprecated. + :param credentials: end_uri previous NextStep finished on. + :param cookies: cookies extracted from the end_uri site. + + Example of possible override of the method: + + .. code-block:: python + :linenos: + + async def pass_login_credentials(self, step, credentials, cookies): + if self.got_everything(credentials,cookies): + user_data = await self.parse_credentials(credentials,cookies) + else: + next_params = self.get_next_params(credentials,cookies) + next_cookies = self.get_next_cookies(credentials,cookies) + return NextStep("web_session", next_params, cookies=next_cookies) + self.store_credentials(user_data['credentials']) + return Authentication(user_data['userId'], user_data['username']) + + """ raise NotImplementedError() - async def get_owned_games(self): + async def get_owned_games(self) -> List[Game]: + """Override this method to return owned games for currenly logged in user. + This method is called by the GOG Galaxy client. + + Example of possible override of the method: + + .. code-block:: python + :linenos: + + async def get_owned_games(self): + if not self.authenticated(): + raise AuthenticationRequired() + + games = self.retrieve_owned_games() + return games + + """ raise NotImplementedError() - async def get_unlocked_achievements(self, game_id): + 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): + 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 + """ if self._achievements_import_in_progress: raise ImportInProgress() @@ -349,8 +575,15 @@ class Plugin(): asyncio.create_task(import_games_achievements_task(game_ids)) self._achievements_import_in_progress = True - async def import_games_achievements(self, game_ids): - """Call game_achievements_import_success/game_achievements_import_failure for each game_id on the list""" + 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. + + :param game_id: ids of the games for which to import unlocked achievements + """ async def import_game_achievements(game_id): try: achievements = await self.get_unlocked_achievements(game_id) @@ -361,43 +594,165 @@ class Plugin(): imports = [import_game_achievements(game_id) for game_id in game_ids] await asyncio.gather(*imports) - async def get_local_games(self): + async def get_local_games(self) -> List[LocalGame]: + """Override this method to return the list of + games present locally on the users pc. + This method is called by the GOG Galaxy client. + + Example of possible override of the method: + + .. code-block:: python + :linenos: + + async def get_local_games(self): + local_games = [] + for game in self.games_present_on_user_pc: + local_game = LocalGame() + local_game.game_id = game.id + local_game.local_game_state = game.get_installation_status() + local_games.append(local_game) + return local_games + + """ raise NotImplementedError() - async def launch_game(self, game_id): + async def launch_game(self, game_id: str): + """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 + + Example of possible override of the method: + + .. code-block:: python + :linenos: + + async def launch_game(self, game_id): + await self.open_uri(f"start client://launchgame/{game_id}") + + """ raise NotImplementedError() - async def install_game(self, game_id): + async def install_game(self, game_id: str): + """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 + + Example of possible override of the method: + + .. code-block:: python + :linenos: + + async def install_game(self, game_id): + await self.open_uri(f"start client://installgame/{game_id}") + + """ raise NotImplementedError() - async def uninstall_game(self, game_id): + async def uninstall_game(self, game_id: str): + """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 + + Example of possible override of the method: + + .. code-block:: python + :linenos: + + async def uninstall_game(self, game_id): + await self.open_uri(f"start client://uninstallgame/{game_id}") + + """ raise NotImplementedError() - async def get_friends(self): + async def get_friends(self) -> List[FriendInfo]: + """Override this method to return the friends list + of the currently authenticated user. + This method is called by the GOG Galaxy client. + + Example of possible override of the method: + + .. code-block:: python + :linenos: + + async def get_friends(self): + if not self._http_client.is_authenticated(): + raise AuthenticationRequired() + + friends = self.retrieve_friends() + return friends + + """ raise NotImplementedError() - async def get_users(self, user_id_list): + 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, message_text): + 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, last_message_id): + 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): + 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, message_id): + 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, from_timestamp): + 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): + 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): + 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 + """ if self._game_times_import_in_progress: raise ImportInProgress() @@ -411,8 +766,15 @@ class Plugin(): asyncio.create_task(import_game_times_task(game_ids)) self._game_times_import_in_progress = True - async def import_game_times(self, game_ids): - """Call game_time_import_success/game_time_import_failure for each game_id on the list""" + 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. + + :param game_ids: ids of the games for which the game time is imported + """ try: game_times = await self.get_game_times() game_ids_set = set(game_ids) @@ -427,7 +789,24 @@ class Plugin(): for game_id in game_ids: self.game_time_import_failure(game_id, error) + def create_and_run_plugin(plugin_class, argv): + """Call this method as an entry point for the implemented integration. + + :param plugin_class: your plugin class. + :param argv: command line arguments with which the script was started. + + Example of possible use of the method: + + .. code-block:: python + :linenos: + + def main(): + create_and_run_plugin(PlatformPlugin, sys.argv) + + if __name__ == "__main__": + main() + """ if len(argv) < 3: logging.critical("Not enough parameters, required: token, port") sys.exit(1) diff --git a/src/galaxy/api/types.py b/src/galaxy/api/types.py index 746f3a3..fb3b908 100644 --- a/src/galaxy/api/types.py +++ b/src/galaxy/api/types.py @@ -5,11 +5,24 @@ from galaxy.api.consts import LicenseType, LocalGameState, PresenceState @dataclass class Authentication(): + """Return this from :meth:`.authenticate` or :meth:`.pass_login_credentials` + to inform the client that authentication has successfully finished. + + :param user_id: id of the authenticated user + :param user_name: username of the authenticated user + """ user_id: str user_name: str @dataclass class Cookie(): + """Cookie + + :param name: name of the cookie + :param value: value of the cookie + :param domain: optional domain of the cookie + :param path: optional path of the cookie + """ name: str value: str domain: Optional[str] = None @@ -17,6 +30,39 @@ class Cookie(): @dataclass class NextStep(): + """Return this from :meth:`.authenticate` or :meth:`.pass_login_credentials` to open client built-in browser with given url. + For example: + + .. code-block:: python + :linenos: + + PARAMS = { + "window_title": "Login to platform", + "window_width": 800, + "window_height": 600, + "start_uri": URL, + "end_uri_regex": r"^https://platform_website\.com/.*" + } + + JS = {r"^https://platform_website\.com/.*": [ + r''' + location.reload(); + ''' + ]} + + COOKIES = [Cookie("Cookie1", "ok", ".platform.com"), + Cookie("Cookie2", "ok", ".platform.com") + ] + + async def authenticate(self, stored_credentials=None): + if not stored_credentials: + return NextStep("web_session", PARAMS, cookies=COOKIES, js=JS) + + :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] cookies: Optional[List[Cookie]] = None @@ -24,17 +70,35 @@ class NextStep(): @dataclass class LicenseInfo(): + """Information about the license of related product. + + :param license_type: type of license + :param owner: optional owner of the related product, defaults to currently authenticated user + """ license_type: LicenseType owner: Optional[str] = None @dataclass class Dlc(): + """Downloadable content object. + + :param dlc_id: id of the dlc + :param dlc_title: title of the dlc + :param license_info: information about the license attached to the dlc + """ dlc_id: str dlc_title: str license_info: LicenseInfo @dataclass class Game(): + """Game object. + + :param game_id: unique identifier of the game, this will be passed as parameter for methods such as launch_game + :param game_title: title of the game + :param dlcs: list of dlcs available for the game + :param license_info: information about the license attached to the game + """ game_id: str game_title: str dlcs: Optional[List[Dlc]] @@ -42,6 +106,12 @@ class Game(): @dataclass class Achievement(): + """Achievement, has to be initialized with either id or name. + + :param unlock_time: unlock time of the achievement + :param achievement_id: optional id of the achievement + :param achievement_name: optional name of the achievement + """ unlock_time: int achievement_id: Optional[str] = None achievement_name: Optional[str] = None @@ -52,17 +122,36 @@ class Achievement(): @dataclass class LocalGame(): + """Game locally present on the authenticated user's computer. + + :param game_id: id of the game + :param local_game_state: state of the game + """ 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 @@ -71,17 +160,35 @@ class UserInfo(): @dataclass class FriendInfo(): + """Information about a friend of the currently authenticated user. + + :param user_id: id of the user + :param user_name: username of the user + """ 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 @@ -89,6 +196,13 @@ class Message(): @dataclass class GameTime(): + """Game time of a game, defines the total time spent in the game + and the last time the game was played. + + :param game_id: id of the related game + :param time_played: the total time spent in the game in **minutes** + :param last_time_played: last time the game was played (**unix timestamp**) + """ game_id: str time_played: int last_played_time: int From 192d655d5133c1c03da087735c26fe03e690d8a3 Mon Sep 17 00:00:00 2001 From: GOG Galaxy SDK Team Date: Mon, 10 Jun 2019 19:04:08 +0200 Subject: [PATCH 06/58] version 0.32.1 --- README.md | 10 +++++++--- docs/make.py | 15 +++++++++------ docs/source/overview.rst | 6 ++++++ setup.py | 2 +- 4 files changed, 23 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index fbbb420..326c283 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,12 @@ -# GOG Galaxy - Community Integration - Python API +# GOG Galaxy Integrations Python API This Python library allows to easily build community integrations for various gaming platforms with 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 main instance of 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. The provided features are: @@ -69,4 +73,4 @@ The additional `manifest.json` file is required: ``` ## Legal Notice -By integrating or attempting to integrate any applications or content with or into GOG Galaxy 2.0. you represent that such application or content is your original creation (other than any software made available by GOG) and/or that you have all necessary rights to grant such applicable rights to the relevant community integration to GOG and to GOG Galaxy 2.0 end users for the purpose of use of such community integration and that such community integration comply with any third party license and other requirements including compliance with applicable laws. +By integrating or attempting to integrate any applications or content with or into GOG Galaxy 2.0 you represent that such application or content is your original creation (other than any software made available by GOG) and/or that you have all necessary rights to grant such applicable rights to the relevant community integration to GOG and to GOG Galaxy 2.0 end users for the purpose of use of such community integration and that such community integration comply with any third party license and other requirements including compliance with applicable laws. diff --git a/docs/make.py b/docs/make.py index cb41897..42f6a02 100644 --- a/docs/make.py +++ b/docs/make.py @@ -1,13 +1,16 @@ """Builds documentation locally. Use for preview only""" +import pathlib import subprocess import webbrowser -source = "docs/source" -build = "docs/build" -master_doc = 'index' +source = pathlib.Path("docs", "source") +build = pathlib.Path("docs", "build") +master_doc = 'index.html' -subprocess.run(['sphinx-build', '-M', 'clean', source, build]) -subprocess.run(['sphinx-build', '-M', 'html', source, build]) -webbrowser.open(f'{build}/html/{master_doc}') \ No newline at end of file +subprocess.run(['sphinx-build', '-M', 'clean', str(source), str(build)]) +subprocess.run(['sphinx-build', '-M', 'html', str(source), str(build)]) + +master_path = build / 'html' / master_doc +webbrowser.open(f'file://{master_path.resolve()}') \ No newline at end of file diff --git a/docs/source/overview.rst b/docs/source/overview.rst index 3bd447c..cb8accb 100644 --- a/docs/source/overview.rst +++ b/docs/source/overview.rst @@ -1 +1,7 @@ .. mdinclude:: ../../README.md + :end-line: 4 + +.. excluding self-pointing documentation link + +.. mdinclude:: ../../README.md + :start-line: 6 diff --git a/setup.py b/setup.py index 513ede7..850b589 100644 --- a/setup.py +++ b/setup.py @@ -2,7 +2,7 @@ from setuptools import setup, find_packages setup( name="galaxy.plugin.api", - version="0.32.0", + version="0.32.1", description="GOG Galaxy Integrations Python API", author='Galaxy team', author_email='galaxy@gog.com', From 3bd0b71ab3a87dbe4437a2d327794a5cdd1bfb7d Mon Sep 17 00:00:00 2001 From: GOG Galaxy SDK Team Date: Thu, 13 Jun 2019 12:30:12 +0200 Subject: [PATCH 07/58] version 0.33 --- setup.py | 2 +- src/galaxy/api/errors.py | 18 ++----- src/galaxy/api/jsonrpc.py | 14 +++-- src/galaxy/api/plugin.py | 99 ++++++++++++++++++++++------------ src/galaxy/http.py | 7 ++- tests/conftest.py | 1 + tests/test_authenticate.py | 6 +-- tests/test_persistent_cache.py | 71 ++++++++++++++++++++++++ 8 files changed, 155 insertions(+), 63 deletions(-) create mode 100644 tests/test_persistent_cache.py diff --git a/setup.py b/setup.py index 850b589..1a58faa 100644 --- a/setup.py +++ b/setup.py @@ -2,7 +2,7 @@ from setuptools import setup, find_packages setup( name="galaxy.plugin.api", - version="0.32.1", + version="0.33", description="GOG Galaxy Integrations Python API", author='Galaxy team', author_email='galaxy@gog.com', diff --git a/src/galaxy/api/errors.py b/src/galaxy/api/errors.py index 189db00..6564b48 100644 --- a/src/galaxy/api/errors.py +++ b/src/galaxy/api/errors.py @@ -22,6 +22,10 @@ class UnknownBackendResponse(ApplicationError): def __init__(self, data=None): super().__init__(4, "Backend responded in uknown way", data) +class TooManyRequests(ApplicationError): + def __init__(self, data=None): + super().__init__(5, "Too many requests. Try again later", data) + class InvalidCredentials(ApplicationError): def __init__(self, data=None): super().__init__(100, "Invalid credentials", data) @@ -50,18 +54,6 @@ class AccessDenied(ApplicationError): def __init__(self, data=None): super().__init__(106, "Access denied", data) -class ParentalControlBlock(ApplicationError): - def __init__(self, data=None): - super().__init__(107, "Parental control block", data) - -class DeviceBlocked(ApplicationError): - def __init__(self, data=None): - super().__init__(108, "Device blocked", data) - -class RegionBlocked(ApplicationError): - def __init__(self, data=None): - super().__init__(109, "Region blocked", data) - class FailedParsingManifest(ApplicationError): def __init__(self, data=None): super().__init__(200, "Failed parsing manifest", data) @@ -80,4 +72,4 @@ class MessageNotFound(ApplicationError): class ImportInProgress(ApplicationError): def __init__(self, data=None): - super().__init__(600, "Import already in progress", data) \ No newline at end of file + super().__init__(600, "Import already in progress", data) diff --git a/src/galaxy/api/jsonrpc.py b/src/galaxy/api/jsonrpc.py index 45f1b0d..31a80dd 100644 --- a/src/galaxy/api/jsonrpc.py +++ b/src/galaxy/api/jsonrpc.py @@ -54,17 +54,15 @@ Method = namedtuple("Method", ["callback", "signature", "internal", "sensitive_p def anonymise_sensitive_params(params, sensitive_params): anomized_data = "****" - if not sensitive_params: - return params + + if isinstance(sensitive_params, bool): + if sensitive_params: + return {k:anomized_data for k,v in params.items()} if isinstance(sensitive_params, Iterable): - anomized_params = params.copy() - for key in anomized_params.keys(): - if key in sensitive_params: - anomized_params[key] = anomized_data - return anomized_params + return {k: anomized_data if k in sensitive_params else v for k, v in params.items()} - return anomized_data + return params class Server(): def __init__(self, reader, writer, encoder=json.JSONEncoder()): diff --git a/src/galaxy/api/plugin.py b/src/galaxy/api/plugin.py index 9b0dc71..0b7f2a2 100644 --- a/src/galaxy/api/plugin.py +++ b/src/galaxy/api/plugin.py @@ -15,8 +15,9 @@ from galaxy.api.jsonrpc import Server, NotificationClient, ApplicationError from galaxy.api.consts import Feature from galaxy.api.errors import UnknownError, ImportInProgress + class JSONEncoder(json.JSONEncoder): - def default(self, o): # pylint: disable=method-hidden + def default(self, o): # pylint: disable=method-hidden if dataclasses.is_dataclass(o): # filter None values def dict_factory(elements): @@ -26,7 +27,8 @@ class JSONEncoder(json.JSONEncoder): return o.value return super().default(o) -class Plugin(): + +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) @@ -50,9 +52,12 @@ class Plugin(): self._achievements_import_in_progress = False self._game_times_import_in_progress = False + self._persistent_cache = dict() + # internal self._register_method("shutdown", self._shutdown, internal=True) self._register_method("get_capabilities", self._get_capabilities, internal=True) + self._register_method("initialize_cache", self._initialize_cache, internal=True) self._register_method("ping", self._ping, internal=True) # implemented by developer @@ -156,6 +161,12 @@ class Plugin(): return features + @property + def persistent_cache(self) -> Dict: + """The cache is only available after the :meth:`~.handshake_complete()` is called. + """ + return self._persistent_cache + def _implements(self, handlers): for handler in handlers: if handler.__name__ not in self.__class__.__dict__: @@ -192,7 +203,7 @@ class Plugin(): self._feature_methods.setdefault(feature, []).append(handler) async def run(self): - """Plugin main coroutine.""" + """Plugin's main coroutine.""" async def pass_control(): while self._active: try: @@ -216,6 +227,10 @@ class Plugin(): "token": self._handshake_token } + def _initialize_cache(self, data: Dict): + self._persistent_cache = data + self.handshake_complete() + @staticmethod def _ping(): pass @@ -264,7 +279,7 @@ class Plugin(): self.add_game(game) """ - params = {"owned_game" : game} + params = {"owned_game": game} self._notification_client.notify("owned_game_added", params) def remove_game(self, game_id: str): @@ -286,7 +301,7 @@ class Plugin(): self.remove_game(game.game_id) """ - params = {"game_id" : game_id} + params = {"game_id": game_id} self._notification_client.notify("owned_game_removed", params) def update_game(self, game: Game): @@ -295,7 +310,7 @@ class Plugin(): :param game: Game to update """ - params = {"owned_game" : game} + params = {"owned_game": game} self._notification_client.notify("owned_game_updated", params) def unlock_achievement(self, game_id: str, achievement: Achievement): @@ -367,7 +382,7 @@ class Plugin(): if self._check_statuses_task is None or self._check_statuses_task.done(): self._check_statuses_task = asyncio.create_task(self._check_statuses()) """ - params = {"local_game" : local_game} + params = {"local_game": local_game} self._notification_client.notify("local_game_status_changed", params) def add_friend(self, user: FriendInfo): @@ -375,7 +390,7 @@ class Plugin(): :param user: FriendInfo of a user that the client will add to friends list """ - params = {"friend_info" : user} + params = {"friend_info": user} self._notification_client.notify("friend_added", params) def remove_friend(self, user_id: str): @@ -383,7 +398,7 @@ class Plugin(): :param user_id: id of the user to remove from friends list """ - params = {"user_id" : user_id} + 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): @@ -406,7 +421,7 @@ class Plugin(): :param game_time: game time to update """ - params = {"game_time" : game_time} + params = {"game_time": game_time} self._notification_client.notify("game_time_updated", params) def game_time_import_success(self, game_time: GameTime): @@ -415,7 +430,7 @@ class Plugin(): :param game_time: game_time which was imported """ - params = {"game_time" : game_time} + 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): @@ -446,7 +461,22 @@ class Plugin(): """ self._notification_client.notify("authentication_lost", None) + def push_cache(self): + """Push local copy of the persistent cache to the GOG Galaxy Client replacing existing one. + """ + self._notification_client.notify( + "push_cache", + params={"data": self._persistent_cache} + ) + # handlers + def handshake_complete(self): + """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): """This method is called periodically. Override it to implement periodical non-blocking tasks. @@ -470,14 +500,14 @@ class Plugin(): def shutdown(self): """This method is called on integration shutdown. Override it to implement tear down. - This method is called by the GOG Galaxy client.""" + This method is called by the GOG Galaxy Client.""" # methods - async def authenticate(self, stored_credentials:dict=None): + async def authenticate(self, stored_credentials: dict = None): """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. - This method is called by the GOG Galaxy client. + This method is called by the GOG Galaxy Client. :param stored_credentials: If the client received any credentials to store locally in the previous session they will be passed here as a parameter. @@ -506,7 +536,7 @@ class Plugin(): 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 or galaxy.api.types.NextStep if it requires going to another cef url. - This method is called by the GOG Galaxy client. + This method is called by the GOG Galaxy Client. :param step: deprecated. :param credentials: end_uri previous NextStep finished on. @@ -531,8 +561,8 @@ class Plugin(): raise NotImplementedError() async def get_owned_games(self) -> List[Game]: - """Override this method to return owned games for currenly logged in user. - This method is called by the GOG Galaxy client. + """Override this method to return owned games for currently logged in user. + This method is called by the GOG Galaxy Client. Example of possible override of the method: @@ -558,7 +588,7 @@ class Plugin(): 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. + This method is called by the GOG Galaxy Client. :param game_ids: ids of the games for which the achievements are imported """ @@ -580,9 +610,9 @@ class Plugin(): 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. + This method is called by the GOG Galaxy Client. - :param game_id: ids of the games for which to import unlocked achievements + :param game_ids: ids of the games for which to import unlocked achievements """ async def import_game_achievements(game_id): try: @@ -597,7 +627,7 @@ class Plugin(): async def get_local_games(self) -> List[LocalGame]: """Override this method to return the list of games present locally on the users pc. - This method is called by the GOG Galaxy client. + This method is called by the GOG Galaxy Client. Example of possible override of the method: @@ -619,7 +649,7 @@ class Plugin(): async def launch_game(self, game_id: str): """Override this method to launch the game identified by the provided game_id. - This method is called by the GOG Galaxy client. + This method is called by the GOG Galaxy Client. :param str game_id: id of the game to launch @@ -637,7 +667,7 @@ class Plugin(): async def install_game(self, game_id: str): """Override this method to install the game identified by the provided game_id. - This method is called by the GOG Galaxy client. + This method is called by the GOG Galaxy Client. :param str game_id: id of the game to install @@ -655,7 +685,7 @@ class Plugin(): async def uninstall_game(self, game_id: str): """Override this method to uninstall the game identified by the provided game_id. - This method is called by the GOG Galaxy client. + This method is called by the GOG Galaxy Client. :param str game_id: id of the game to uninstall @@ -673,7 +703,7 @@ class Plugin(): async def get_friends(self) -> List[FriendInfo]: """Override this method to return the friends list of the currently authenticated user. - This method is called by the GOG Galaxy client. + This method is called by the GOG Galaxy Client. Example of possible override of the method: @@ -692,7 +722,7 @@ class Plugin(): 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. + This method is called by the GOG Galaxy Client. :param user_id_list: list of user ids """ @@ -700,7 +730,7 @@ class Plugin(): 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. + 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 @@ -709,22 +739,23 @@ class Plugin(): 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. + 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 + :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 + 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. + 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 @@ -733,7 +764,7 @@ class Plugin(): 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. + 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 @@ -749,7 +780,7 @@ class Plugin(): 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. + This method is called by the GOG Galaxy Client. :param game_ids: ids of the games for which the game time is imported """ @@ -829,7 +860,7 @@ def create_and_run_plugin(plugin_class, argv): async def coroutine(): reader, writer = await asyncio.open_connection("127.0.0.1", port) - extra_info = writer.get_extra_info('sockname') + extra_info = writer.get_extra_info("sockname") logging.info("Using local address: %s:%u", *extra_info) plugin = plugin_class(reader, writer, token) await plugin.run() diff --git a/src/galaxy/http.py b/src/galaxy/http.py index 5b494ca..0e7b8b2 100644 --- a/src/galaxy/http.py +++ b/src/galaxy/http.py @@ -6,10 +6,11 @@ import aiohttp import certifi from galaxy.api.errors import ( - AccessDenied, AuthenticationRequired, - BackendTimeout, BackendNotAvailable, BackendError, NetworkError, UnknownBackendResponse, UnknownError + AccessDenied, AuthenticationRequired, BackendTimeout, BackendNotAvailable, BackendError, NetworkError, + TooManyRequests, UnknownBackendResponse, UnknownError ) + class HttpClient: def __init__(self, limit=20, timeout=aiohttp.ClientTimeout(total=60), cookie_jar=None): ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) @@ -39,6 +40,8 @@ class HttpClient: 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: diff --git a/tests/conftest.py b/tests/conftest.py index fed2e87..d373c32 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -33,6 +33,7 @@ def write(writer): def plugin(reader, writer): """Return plugin instance with all feature methods mocked""" async_methods = ( + "handshake_complete", "authenticate", "get_owned_games", "get_unlocked_achievements", diff --git a/tests/test_authenticate.py b/tests/test_authenticate.py index 4c25ba5..6cb96a6 100644 --- a/tests/test_authenticate.py +++ b/tests/test_authenticate.py @@ -6,8 +6,7 @@ import pytest from galaxy.api.types import Authentication from galaxy.api.errors import ( UnknownError, InvalidCredentials, NetworkError, LoggedInElsewhere, ProtocolError, - BackendNotAvailable, BackendTimeout, BackendError, TemporaryBlocked, Banned, AccessDenied, - ParentalControlBlock, DeviceBlocked, RegionBlocked + BackendNotAvailable, BackendTimeout, BackendError, TemporaryBlocked, Banned, AccessDenied ) def test_success(plugin, readline, write): @@ -44,9 +43,6 @@ def test_success(plugin, readline, write): pytest.param(TemporaryBlocked, 104, "Temporary blocked", id="temporary_blocked"), pytest.param(Banned, 105, "Banned", id="banned"), pytest.param(AccessDenied, 106, "Access denied", id="access_denied"), - pytest.param(ParentalControlBlock, 107, "Parental control block", id="parental_control_clock"), - pytest.param(DeviceBlocked, 108, "Device blocked", id="device_blocked"), - pytest.param(RegionBlocked, 109, "Region blocked", id="region_blocked") ]) def test_failure(plugin, readline, write, error, code, message): request = { diff --git a/tests/test_persistent_cache.py b/tests/test_persistent_cache.py new file mode 100644 index 0000000..056ddb5 --- /dev/null +++ b/tests/test_persistent_cache.py @@ -0,0 +1,71 @@ +import asyncio +import json + +import pytest + + +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 + } + + +def assert_rpc_request(write, method, params=None): + assert json.loads(write.call_args[0][0]) == { + "jsonrpc": "2.0", + "method": method, + "params": {"data": params} + } + + +@pytest.fixture +def cache_data(): + return { + "persistent key": "persistent value", + "persistent object": {"answer to everything": 42} + } + + +def test_initialize_cache(plugin, readline, write, cache_data): + request_id = 3 + request = { + "jsonrpc": "2.0", + "id": str(request_id), + "method": "initialize_cache", + "params": {"data": cache_data} + } + readline.side_effect = [json.dumps(request)] + + assert {} == plugin.persistent_cache + asyncio.run(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 + + 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()) + + +def test_clear_cache(plugin, write, cache_data): + async def runner(): + plugin._persistent_cache = cache_data + + plugin.persistent_cache.clear() + plugin.push_cache() + + assert_rpc_request(write, "push_cache", {}) + assert {} == plugin.persistent_cache + + asyncio.run(runner()) From e2f26271cb263f41073c56e1a1cb197b10ef2a00 Mon Sep 17 00:00:00 2001 From: GOG Galaxy SDK Team Date: Fri, 14 Jun 2019 14:49:25 +0200 Subject: [PATCH 08/58] version 0.33.1 --- README.md | 42 ++++++++++++++++++++++++++++++++++++++++-- setup.py | 2 +- 2 files changed, 41 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 326c283..fcd0775 100644 --- a/README.md +++ b/README.md @@ -55,8 +55,20 @@ if __name__ == "__main__": ## Deployment -The client has a built-in Python 3.7 interpreter, so the integrations are delivered as `.py` files. -The additional `manifest.json` file is required: +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). + +### Lookup directory: +- Windows: + + `%localappdata%\GOG.com\Galaxy\plugins\installed` + +- macOS: + + `~/Library/Application Support/GOG.com/Galaxy/plugins/installed` + +### Manifest +Obligatory JSON file to be placed in a integration folder. ```json { @@ -71,6 +83,32 @@ The additional `manifest.json` file is required: "script": "plugin.py" } ``` +| property | description | +|---------------|---| +| `guid` | | +| `description` | | +| `url` | | +| `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: + +```pip install DEP --target DIR --implementation cp --python-version 37``` + +For example plugin that uses *requests* has structure as follows: + + +```bash +installed +└── my_integration +    ├── galaxy +    │   └── api +    ├── requests +    │   └── ... +    ├── plugin.py + └── manifest.json +``` + ## Legal Notice By integrating or attempting to integrate any applications or content with or into GOG Galaxy 2.0 you represent that such application or content is your original creation (other than any software made available by GOG) and/or that you have all necessary rights to grant such applicable rights to the relevant community integration to GOG and to GOG Galaxy 2.0 end users for the purpose of use of such community integration and that such community integration comply with any third party license and other requirements including compliance with applicable laws. diff --git a/setup.py b/setup.py index 1a58faa..02ae4a5 100644 --- a/setup.py +++ b/setup.py @@ -2,7 +2,7 @@ from setuptools import setup, find_packages setup( name="galaxy.plugin.api", - version="0.33", + version="0.33.1", description="GOG Galaxy Integrations Python API", author='Galaxy team', author_email='galaxy@gog.com', From 7789927ed9b409f7211e3fb18cae90cdffefe46f Mon Sep 17 00:00:00 2001 From: GOG Galaxy SDK Team Date: Fri, 14 Jun 2019 16:54:11 +0200 Subject: [PATCH 09/58] version 0.34 --- setup.py | 2 +- src/galaxy/http.py | 9 +++++++-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/setup.py b/setup.py index 02ae4a5..f75584e 100644 --- a/setup.py +++ b/setup.py @@ -2,7 +2,7 @@ from setuptools import setup, find_packages setup( name="galaxy.plugin.api", - version="0.33.1", + version="0.34", description="GOG Galaxy Integrations Python API", author='Galaxy team', author_email='galaxy@gog.com', diff --git a/src/galaxy/http.py b/src/galaxy/http.py index 0e7b8b2..667f55a 100644 --- a/src/galaxy/http.py +++ b/src/galaxy/http.py @@ -4,6 +4,7 @@ from http import HTTPStatus import aiohttp import certifi +import logging from galaxy.api.errors import ( AccessDenied, AuthenticationRequired, BackendTimeout, BackendNotAvailable, BackendError, NetworkError, @@ -21,9 +22,9 @@ class HttpClient: async def close(self): await self._session.close() - async def request(self, method, *args, **kwargs): + async def request(self, method, url, *args, **kwargs): try: - response = await self._session.request(method, *args, **kwargs) + response = await self._session.request(method, url, *args, **kwargs) except asyncio.TimeoutError: raise BackendTimeout() except aiohttp.ServerDisconnectedError: @@ -33,6 +34,8 @@ class HttpClient: 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() @@ -45,6 +48,8 @@ class HttpClient: 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() return response From 179fd147c12e41245166a1bb30c573e620cd6827 Mon Sep 17 00:00:00 2001 From: GOG Galaxy SDK Team Date: Mon, 17 Jun 2019 17:41:44 +0200 Subject: [PATCH 10/58] version 0.35.1 --- PLATFORM_IDs.md | 82 ++++++++++++++++++++++++++++++++++++++++ README.md | 12 +++++- docs/source/index.rst | 1 + docs/source/overview.rst | 8 ++++ setup.py | 2 +- src/galaxy/api/plugin.py | 10 ++++- 6 files changed, 110 insertions(+), 5 deletions(-) create mode 100644 PLATFORM_IDs.md diff --git a/PLATFORM_IDs.md b/PLATFORM_IDs.md new file mode 100644 index 0000000..c58dbc4 --- /dev/null +++ b/PLATFORM_IDs.md @@ -0,0 +1,82 @@ +### PLATFORM ID LIST + +Platform ID list for GOG Galaxy 2.0 Integrations + +| ID | Name | +| --- | --- | +| steam | Steam | +| psn | PlayStation Network | +| xboxone | Xbox Live | +| generic | Manually added games | +| origin | Origin | +| uplay | Uplay | +| battlenet | Battle.net | +| epic | Epic Games Store | +| bethesda | Bethesda.net | +| paradox | Paradox Plaza | +| humble | Humble Bundle | +| kartridge | Kartridge | +| itch | Itch.io | +| nswitch | Nintendo Switch | +| nwiiu | Nintendo Wii U | +| nwii | Nintendo Wii | +| ncube | Nintendo Game Cube | +| riot | Riot | +| wargaming | Wargaming | +| ngameboy | Nintendo Game Boy | +| atari | Atari | +| amiga | Amiga | +| snes | SNES | +| beamdog | Beamdog | +| d2d | Direct2Drive | +| discord | Discord | +| dotemu | DotEmu | +| gamehouse | GameHouse | +| gmg | Green Man Gaming | +| weplay | WePlay | +| zx | Zx Spectrum PC | +| vision | ColecoVision | +| nes | NES | +| sms | Sega Master System | +| c64 | Commodore 64 | +| pce | PC Engine | +| segag | Sega Genesis | +| neo | NeoGeo | +| sega32 | Sega 32X | +| segacd | Sega CD | +| 3do | 3DO Interactive | +| saturn | SegaSaturn | +| psx | Sony PlayStation | +| ps2 | Sony PlayStation 2 | +| n64 | Nintendo64 | +| jaguar | Atari Jaguar | +| dc | Sega Dreamcast | +| xboxog | Original Xbox games | +| amazon | Amazon | +| gg | GamersGate | +| egg | Newegg | +| bb | BestBuy | +| gameuk | Game UK | +| fanatical | Fanatical store | +| playasia | PlayAsia | +| stadia | Google Stadia | +| arc | ARC | +| eso | ESO | +| glyph | Trion World | +| aionl | Aion: Legions of War | +| aion | Aion | +| blade | Blade and Soul | +| gw | Guild Wars | +| gw2 | Guild Wars 2 | +| lin2 | Lineage 2 | +| ffxi | Final Fantasy XI | +| ffxiv | Final Fantasy XIV | +| totalwar | TotalWar | +| winstore | Windows Store | +| elites | Elite Dangerous | +| star | Star Citizen | +| psp | Playstation Portable | +| psvita | Playstation Vita | +| nds | Nintendo DS | +| 3ds | Nintendo 3DS | + diff --git a/README.md b/README.md index fcd0775..4330b1f 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,12 @@ The provided features are: - 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) + ## 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. @@ -58,7 +64,8 @@ if __name__ == "__main__": 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). -### Lookup directory: +### Lookup directory + - Windows: `%localappdata%\GOG.com\Galaxy\plugins\installed` @@ -67,7 +74,8 @@ 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 +### Manifest + Obligatory JSON file to be placed in a integration folder. ```json diff --git a/docs/source/index.rst b/docs/source/index.rst index 05f97f7..4455599 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -7,6 +7,7 @@ GOG Galaxy Integrations Python API Overview API + Platform ID's Index ------------------- diff --git a/docs/source/overview.rst b/docs/source/overview.rst index cb8accb..ea506a8 100644 --- a/docs/source/overview.rst +++ b/docs/source/overview.rst @@ -5,3 +5,11 @@ .. mdinclude:: ../../README.md :start-line: 6 + :end-line: 26 + +.. excluding Platforms Id's link + +:ref:`platforms-link`. + +.. mdinclude:: ../../README.md + :start-line: 28 diff --git a/setup.py b/setup.py index f75584e..6416391 100644 --- a/setup.py +++ b/setup.py @@ -2,7 +2,7 @@ from setuptools import setup, find_packages setup( name="galaxy.plugin.api", - version="0.34", + version="0.35.1", description="GOG Galaxy Integrations Python API", author='Galaxy team', author_email='galaxy@gog.com', diff --git a/src/galaxy/api/plugin.py b/src/galaxy/api/plugin.py index 0b7f2a2..b876c57 100644 --- a/src/galaxy/api/plugin.py +++ b/src/galaxy/api/plugin.py @@ -57,7 +57,12 @@ class Plugin: # internal self._register_method("shutdown", self._shutdown, internal=True) self._register_method("get_capabilities", self._get_capabilities, internal=True) - self._register_method("initialize_cache", self._initialize_cache, internal=True) + self._register_method( + "initialize_cache", + self._initialize_cache, + internal=True, + sensitive_params="data" + ) self._register_method("ping", self._ping, internal=True) # implemented by developer @@ -466,7 +471,8 @@ class Plugin: """ self._notification_client.notify( "push_cache", - params={"data": self._persistent_cache} + params={"data": self._persistent_cache}, + sensitive_params="data" ) # handlers From 9d5d48032ef0c0fb3bb8a349d526dfed962e1fc9 Mon Sep 17 00:00:00 2001 From: GOG Galaxy SDK Team Date: Mon, 17 Jun 2019 18:11:24 +0200 Subject: [PATCH 11/58] version 0.35.2 --- docs/source/overview.rst | 2 +- docs/source/platforms.rst | 2 ++ setup.py | 2 +- 3 files changed, 4 insertions(+), 2 deletions(-) create mode 100644 docs/source/platforms.rst diff --git a/docs/source/overview.rst b/docs/source/overview.rst index ea506a8..400f745 100644 --- a/docs/source/overview.rst +++ b/docs/source/overview.rst @@ -9,7 +9,7 @@ .. excluding Platforms Id's link -:ref:`platforms-link`. +:ref:`platforms-link` .. mdinclude:: ../../README.md :start-line: 28 diff --git a/docs/source/platforms.rst b/docs/source/platforms.rst new file mode 100644 index 0000000..2b79f6f --- /dev/null +++ b/docs/source/platforms.rst @@ -0,0 +1,2 @@ +.. _platforms-link: +.. mdinclude:: ../../PLATFORM_IDs.md \ No newline at end of file diff --git a/setup.py b/setup.py index 6416391..43f5cf6 100644 --- a/setup.py +++ b/setup.py @@ -2,7 +2,7 @@ from setuptools import setup, find_packages setup( name="galaxy.plugin.api", - version="0.35.1", + version="0.35.2", description="GOG Galaxy Integrations Python API", author='Galaxy team', author_email='galaxy@gog.com', From 1edf4ff5badcb8a49d78a6b7d733eae9fd92f669 Mon Sep 17 00:00:00 2001 From: Roger Date: Wed, 19 Jun 2019 15:39:22 -0400 Subject: [PATCH 12/58] Fix spelling errors --- README.md | 30 ++++++++++++++++++------------ 1 file changed, 18 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 4330b1f..d5ae966 100644 --- a/README.md +++ b/README.md @@ -10,25 +10,25 @@ Each integration in GOG Galaxy 2.0 comes as a separate Python script, and is lau 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 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`. 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`. @@ -62,10 +62,12 @@ 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). +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 [exemplary structure](#deploy-structure-example). ### Lookup directory + + - Windows: `%localappdata%\GOG.com\Galaxy\plugins\installed` @@ -75,7 +77,8 @@ 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. ```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 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: + ```bash installed └── my_integration From be03c83d4541286e1077c7248be51ebabef94f3c Mon Sep 17 00:00:00 2001 From: Romuald Juchnowicz-Bierbasz Date: Fri, 14 Jun 2019 18:11:26 +0200 Subject: [PATCH 13/58] SDK-2873: Add typing to API --- src/galaxy/api/plugin.py | 77 ++++++++++++++++++++++------------------ 1 file changed, 42 insertions(+), 35 deletions(-) diff --git a/src/galaxy/api/plugin.py b/src/galaxy/api/plugin.py index b876c57..22eb9d4 100644 --- a/src/galaxy/api/plugin.py +++ b/src/galaxy/api/plugin.py @@ -7,13 +7,14 @@ from enum import Enum from collections import OrderedDict import sys -from typing import List, Dict +from typing import Any, List, Dict, Optional, Union 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.types import Authentication, NextStep, Message class JSONEncoder(json.JSONEncoder): @@ -241,7 +242,7 @@ class Plugin: 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. @@ -265,7 +266,7 @@ class Plugin: """ 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,7 +288,7 @@ 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. @@ -309,7 +310,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,7 +319,7 @@ 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. @@ -330,7 +331,7 @@ class Plugin: } self._notification_client.notify("achievement_unlocked", params) - def game_achievements_import_success(self, game_id: str, achievements): + def game_achievements_import_success(self, game_id: str, achievements: List[Achievement]) -> None: """Notify the client that import of achievements for a given game has succeeded. This method is called by import_games_achievements. @@ -343,7 +344,7 @@ class Plugin: } self._notification_client.notify("game_achievements_import_success", params) - def game_achievements_import_failure(self, game_id: str, error: ApplicationError): + def game_achievements_import_failure(self, game_id: str, error: ApplicationError) -> None: """Notify the client that import of achievements for a given game has failed. This method is called by import_games_achievements. @@ -359,12 +360,12 @@ class Plugin: } self._notification_client.notify("game_achievements_import_failure", params) - def achievements_import_finished(self): + def achievements_import_finished(self) -> None: """Notify the client that importing achievements has finished. This method is called by import_games_achievements_task""" 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 @@ -390,7 +391,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 +399,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,7 +407,12 @@ 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): + def update_room( + self, + room_id: str, + unread_message_count: Optional[int]=None, + new_messages: Optional[List[Message]]=None + ) -> None: """WIP, Notify the client to update the information regarding a chat room that the currently authenticated user is in. @@ -421,7 +427,7 @@ class Plugin: 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,7 +435,7 @@ class Plugin: params = {"game_time": game_time} self._notification_client.notify("game_time_updated", params) - def game_time_import_success(self, game_time: GameTime): + def game_time_import_success(self, game_time: GameTime) -> None: """Notify the client that import of a given game_time has succeeded. This method is called by import_game_times. @@ -438,7 +444,7 @@ class Plugin: 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): + def game_time_import_failure(self, game_id: str, error: ApplicationError) -> None: """Notify the client that import of a game time for a given game has failed. This method is called by import_game_times. @@ -454,19 +460,19 @@ class Plugin: } self._notification_client.notify("game_time_import_failure", params) - def game_times_import_finished(self): + def game_times_import_finished(self) -> None: """Notify the client that importing game times has finished. This method is called by :meth:`~.import_game_times_task`. """ 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 +482,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 +509,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 +543,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 @@ -592,7 +599,7 @@ class Plugin: """ raise NotImplementedError() - async def start_achievements_import(self, game_ids: List[str]): + async def start_achievements_import(self, game_ids: List[str]) -> None: """Starts the task of importing achievements. This method is called by the GOG Galaxy Client. @@ -611,7 +618,7 @@ class Plugin: asyncio.create_task(import_games_achievements_task(game_ids)) self._achievements_import_in_progress = True - async def import_games_achievements(self, game_ids: List[str]): + async def import_games_achievements(self, game_ids: List[str]) -> None: """ Override this method to return the unlocked achievements of the user that is currently logged in to the plugin. @@ -652,7 +659,7 @@ 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. @@ -670,7 +677,7 @@ 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. @@ -688,7 +695,7 @@ 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. @@ -734,7 +741,7 @@ class Plugin: """ raise NotImplementedError() - async def send_message(self, room_id: str, message_text: str): + async def send_message(self, room_id: str, message_text: str) -> None: """WIP, Override this method to send message to a chat room. This method is called by the GOG Galaxy Client. @@ -743,7 +750,7 @@ class Plugin: """ raise NotImplementedError() - async def mark_as_read(self, room_id: str, last_message_id: str): + async def mark_as_read(self, room_id: str, last_message_id: str) -> None: """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. @@ -759,7 +766,7 @@ class Plugin: """ raise NotImplementedError() - async def get_room_history_from_message(self, room_id: str, message_id: str): + async def get_room_history_from_message(self, room_id: str, message_id: str) -> List[Message]: """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. @@ -768,7 +775,7 @@ class Plugin: """ raise NotImplementedError() - async def get_room_history_from_timestamp(self, room_id: str, from_timestamp: int): + async def get_room_history_from_timestamp(self, room_id: str, from_timestamp: int) -> List[Message]: """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. @@ -784,7 +791,7 @@ class Plugin: """ raise NotImplementedError() - async def start_game_times_import(self, game_ids: List[str]): + async def start_game_times_import(self, game_ids: List[str]) -> None: """Starts the task of importing game times This method is called by the GOG Galaxy Client. @@ -803,7 +810,7 @@ class Plugin: asyncio.create_task(import_game_times_task(game_ids)) self._game_times_import_in_progress = True - async def import_game_times(self, game_ids: List[str]): + async def import_game_times(self, game_ids: List[str]) -> None: """ Override this method to return game times for games owned by the currently authenticated user. From 58b17d94fa2db8435f9cd482b47652a75490487b Mon Sep 17 00:00:00 2001 From: Romuald Juchnowicz-Bierbasz Date: Tue, 25 Jun 2019 17:53:16 +0200 Subject: [PATCH 14/58] SDK-2880: Read in chunks --- src/galaxy/api/jsonrpc.py | 18 ++++++++++++++- tests/conftest.py | 6 ++--- tests/test_achievements.py | 8 +++---- tests/test_authenticate.py | 14 ++++++------ tests/test_chat.py | 40 +++++++++++++++++----------------- tests/test_chunk_messages.py | 17 +++++++++++++++ tests/test_friends.py | 8 +++---- tests/test_game_times.py | 8 +++---- tests/test_install_game.py | 4 ++-- tests/test_internal.py | 16 +++++++------- tests/test_launch_game.py | 4 ++-- tests/test_local_games.py | 8 +++---- tests/test_owned_games.py | 8 +++---- tests/test_persistent_cache.py | 4 ++-- tests/test_uninstall_game.py | 4 ++-- tests/test_users.py | 8 +++---- 16 files changed, 104 insertions(+), 71 deletions(-) create mode 100644 tests/test_chunk_messages.py diff --git a/src/galaxy/api/jsonrpc.py b/src/galaxy/api/jsonrpc.py index 31a80dd..62a36b4 100644 --- a/src/galaxy/api/jsonrpc.py +++ b/src/galaxy/api/jsonrpc.py @@ -73,6 +73,7 @@ class Server(): self._methods = {} self._notifications = {} self._eof_listeners = [] + self._input_buffer = bytes() def register_method(self, name, callback, internal, sensitive_params=False): """ @@ -104,7 +105,7 @@ class Server(): async def run(self): while self._active: try: - data = await self._reader.readline() + data = await self._readline() if not data: self._eof() continue @@ -115,6 +116,21 @@ class Server(): logging.debug("Received %d bytes of data", len(data)) self._handle_input(data) + async def _readline(self): + """Like StreamReader.readline but without limit""" + while True: + chunk = await self._reader.read(1024) + if not chunk: + return chunk + previous_size = len(self._input_buffer) + self._input_buffer += chunk + it = self._input_buffer.find(b"\n", previous_size) + if it < 0: + continue + line = self._input_buffer[:it] + self._input_buffer = self._input_buffer[it+1:] + return line + def stop(self): self._active = False diff --git a/tests/conftest.py b/tests/conftest.py index d373c32..d94a9f3 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -11,7 +11,7 @@ from galaxy.unittest.mock import AsyncMock, coroutine_mock @pytest.fixture() def reader(): stream = MagicMock(name="stream_reader") - stream.readline = AsyncMock() + stream.read = AsyncMock() yield stream @pytest.fixture() @@ -22,8 +22,8 @@ def writer(): yield stream @pytest.fixture() -def readline(reader): - yield reader.readline +def read(reader): + yield reader.read @pytest.fixture() def write(writer): diff --git a/tests/test_achievements.py b/tests/test_achievements.py index 84421bd..9a6ec30 100644 --- a/tests/test_achievements.py +++ b/tests/test_achievements.py @@ -16,7 +16,7 @@ def test_initialization_no_id_nor_name(): with raises(AssertionError): Achievement(unlock_time=1234567890) -def test_success(plugin, readline, write): +def test_success(plugin, read, write): request = { "jsonrpc": "2.0", "id": "3", @@ -25,7 +25,7 @@ def test_success(plugin, readline, write): "game_id": "14" } } - readline.side_effect = [json.dumps(request), ""] + read.side_effect = [json.dumps(request).encode() + b"\n", b""] plugin.get_unlocked_achievements.coro.return_value = [ Achievement(achievement_id="lvl10", unlock_time=1548421241), Achievement(achievement_name="Got level 20", unlock_time=1548422395), @@ -57,7 +57,7 @@ def test_success(plugin, readline, write): } } -def test_failure(plugin, readline, write): +def test_failure(plugin, read, write): request = { "jsonrpc": "2.0", "id": "3", @@ -67,7 +67,7 @@ def test_failure(plugin, readline, write): } } - readline.side_effect = [json.dumps(request), ""] + read.side_effect = [json.dumps(request).encode() + b"\n", b""] plugin.get_unlocked_achievements.coro.side_effect = UnknownError() asyncio.run(plugin.run()) plugin.get_unlocked_achievements.assert_called() diff --git a/tests/test_authenticate.py b/tests/test_authenticate.py index 6cb96a6..1d84c60 100644 --- a/tests/test_authenticate.py +++ b/tests/test_authenticate.py @@ -9,14 +9,14 @@ from galaxy.api.errors import ( BackendNotAvailable, BackendTimeout, BackendError, TemporaryBlocked, Banned, AccessDenied ) -def test_success(plugin, readline, write): +def test_success(plugin, read, write): request = { "jsonrpc": "2.0", "id": "3", "method": "init_authentication" } - readline.side_effect = [json.dumps(request), ""] + read.side_effect = [json.dumps(request).encode() + b"\n", b""] plugin.authenticate.coro.return_value = Authentication("132", "Zenek") asyncio.run(plugin.run()) plugin.authenticate.assert_called_with() @@ -44,14 +44,14 @@ 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): +def test_failure(plugin, read, write, error, code, message): request = { "jsonrpc": "2.0", "id": "3", "method": "init_authentication" } - readline.side_effect = [json.dumps(request), ""] + read.side_effect = [json.dumps(request).encode() + b"\n", b""] plugin.authenticate.coro.side_effect = error() asyncio.run(plugin.run()) plugin.authenticate.assert_called_with() @@ -66,7 +66,7 @@ def test_failure(plugin, readline, write, error, code, message): } } -def test_stored_credentials(plugin, readline, write): +def test_stored_credentials(plugin, read, write): request = { "jsonrpc": "2.0", "id": "3", @@ -77,7 +77,7 @@ def test_stored_credentials(plugin, readline, write): } } } - readline.side_effect = [json.dumps(request), ""] + read.side_effect = [json.dumps(request).encode() + b"\n", b""] plugin.authenticate.coro.return_value = Authentication("132", "Zenek") asyncio.run(plugin.run()) plugin.authenticate.assert_called_with(stored_credentials={"token": "ABC"}) @@ -100,7 +100,7 @@ def test_store_credentials(plugin, write): "params": credentials } -def test_lost_authentication(plugin, readline, write): +def test_lost_authentication(plugin, write): async def couritine(): plugin.lost_authentication() diff --git a/tests/test_chat.py b/tests/test_chat.py index 97dad89..db840b4 100644 --- a/tests/test_chat.py +++ b/tests/test_chat.py @@ -9,7 +9,7 @@ from galaxy.api.errors import ( TooManyMessagesSent, IncoherentLastMessage, MessageNotFound ) -def test_send_message_success(plugin, readline, write): +def test_send_message_success(plugin, read, write): request = { "jsonrpc": "2.0", "id": "3", @@ -20,7 +20,7 @@ def test_send_message_success(plugin, readline, write): } } - readline.side_effect = [json.dumps(request), ""] + read.side_effect = [json.dumps(request).encode() + b"\n", b""] plugin.send_message.coro.return_value = None asyncio.run(plugin.run()) plugin.send_message.assert_called_with(room_id="14", message="Hello!") @@ -40,7 +40,7 @@ def test_send_message_success(plugin, readline, write): 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): +def test_send_message_failure(plugin, read, write, error, code, message): request = { "jsonrpc": "2.0", "id": "6", @@ -51,7 +51,7 @@ def test_send_message_failure(plugin, readline, write, error, code, message): } } - readline.side_effect = [json.dumps(request), ""] + read.side_effect = [json.dumps(request).encode() + b"\n", b""] plugin.send_message.coro.side_effect = error() asyncio.run(plugin.run()) plugin.send_message.assert_called_with(room_id="15", message="Bye") @@ -66,7 +66,7 @@ def test_send_message_failure(plugin, readline, write, error, code, message): } } -def test_mark_as_read_success(plugin, readline, write): +def test_mark_as_read_success(plugin, read, write): request = { "jsonrpc": "2.0", "id": "7", @@ -77,7 +77,7 @@ def test_mark_as_read_success(plugin, readline, write): } } - readline.side_effect = [json.dumps(request), ""] + read.side_effect = [json.dumps(request).encode() + b"\n", b""] 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") @@ -102,7 +102,7 @@ def test_mark_as_read_success(plugin, readline, write): id="incoherent_last_message" ) ]) -def test_mark_as_read_failure(plugin, readline, write, error, code, message): +def test_mark_as_read_failure(plugin, read, write, error, code, message): request = { "jsonrpc": "2.0", "id": "4", @@ -113,7 +113,7 @@ def test_mark_as_read_failure(plugin, readline, write, error, code, message): } } - readline.side_effect = [json.dumps(request), ""] + read.side_effect = [json.dumps(request).encode() + b"\n", b""] 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") @@ -128,14 +128,14 @@ def test_mark_as_read_failure(plugin, readline, write, error, code, message): } } -def test_get_rooms_success(plugin, readline, write): +def test_get_rooms_success(plugin, read, write): request = { "jsonrpc": "2.0", "id": "2", "method": "import_rooms" } - readline.side_effect = [json.dumps(request), ""] + read.side_effect = [json.dumps(request).encode() + b"\n", b""] plugin.get_rooms.coro.return_value = [ Room("13", 0, None), Room("15", 34, "8") @@ -162,14 +162,14 @@ def test_get_rooms_success(plugin, readline, write): } } -def test_get_rooms_failure(plugin, readline, write): +def test_get_rooms_failure(plugin, read, write): request = { "jsonrpc": "2.0", "id": "9", "method": "import_rooms" } - readline.side_effect = [json.dumps(request), ""] + read.side_effect = [json.dumps(request).encode() + b"\n", b""] plugin.get_rooms.coro.side_effect = UnknownError() asyncio.run(plugin.run()) plugin.get_rooms.assert_called_with() @@ -184,7 +184,7 @@ def test_get_rooms_failure(plugin, readline, write): } } -def test_get_room_history_from_message_success(plugin, readline, write): +def test_get_room_history_from_message_success(plugin, read, write): request = { "jsonrpc": "2.0", "id": "2", @@ -195,7 +195,7 @@ def test_get_room_history_from_message_success(plugin, readline, write): } } - readline.side_effect = [json.dumps(request), ""] + read.side_effect = [json.dumps(request).encode() + b"\n", b""] plugin.get_room_history_from_message.coro.return_value = [ Message("13", "149", 1549454837, "Hello"), Message("14", "812", 1549454899, "Hi") @@ -233,7 +233,7 @@ def test_get_room_history_from_message_success(plugin, readline, write): 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): +def test_get_room_history_from_message_failure(plugin, read, write, error, code, message): request = { "jsonrpc": "2.0", "id": "7", @@ -244,7 +244,7 @@ def test_get_room_history_from_message_failure(plugin, readline, write, error, c } } - readline.side_effect = [json.dumps(request), ""] + read.side_effect = [json.dumps(request).encode() + b"\n", b""] 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") @@ -259,7 +259,7 @@ def test_get_room_history_from_message_failure(plugin, readline, write, error, c } } -def test_get_room_history_from_timestamp_success(plugin, readline, write): +def test_get_room_history_from_timestamp_success(plugin, read, write): request = { "jsonrpc": "2.0", "id": "7", @@ -270,7 +270,7 @@ def test_get_room_history_from_timestamp_success(plugin, readline, write): } } - readline.side_effect = [json.dumps(request), ""] + read.side_effect = [json.dumps(request).encode() + b"\n", b""] plugin.get_room_history_from_timestamp.coro.return_value = [ Message("12", "155", 1549454836, "Bye") ] @@ -296,7 +296,7 @@ def test_get_room_history_from_timestamp_success(plugin, readline, write): } } -def test_get_room_history_from_timestamp_failure(plugin, readline, write): +def test_get_room_history_from_timestamp_failure(plugin, read, write): request = { "jsonrpc": "2.0", "id": "3", @@ -307,7 +307,7 @@ def test_get_room_history_from_timestamp_failure(plugin, readline, write): } } - readline.side_effect = [json.dumps(request), ""] + read.side_effect = [json.dumps(request).encode() + b"\n", b""] plugin.get_room_history_from_timestamp.coro.side_effect = UnknownError() asyncio.run(plugin.run()) plugin.get_room_history_from_timestamp.assert_called_with( diff --git a/tests/test_chunk_messages.py b/tests/test_chunk_messages.py new file mode 100644 index 0000000..3e09adb --- /dev/null +++ b/tests/test_chunk_messages.py @@ -0,0 +1,17 @@ +import asyncio +import json + +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 = [message[:5], message[5:], b""] + plugin.get_owned_games.return_value = None + asyncio.run(plugin.run()) + plugin.install_game.assert_called_with(game_id="3") diff --git a/tests/test_friends.py b/tests/test_friends.py index 52cdd9b..030f029 100644 --- a/tests/test_friends.py +++ b/tests/test_friends.py @@ -5,14 +5,14 @@ from galaxy.api.types import FriendInfo from galaxy.api.errors import UnknownError -def test_get_friends_success(plugin, readline, write): +def test_get_friends_success(plugin, read, write): request = { "jsonrpc": "2.0", "id": "3", "method": "import_friends" } - readline.side_effect = [json.dumps(request), ""] + read.side_effect = [json.dumps(request).encode() + b"\n", b""] plugin.get_friends.coro.return_value = [ FriendInfo("3", "Jan"), FriendInfo("5", "Ola") @@ -33,14 +33,14 @@ def test_get_friends_success(plugin, readline, write): } -def test_get_friends_failure(plugin, readline, write): +def test_get_friends_failure(plugin, read, write): request = { "jsonrpc": "2.0", "id": "3", "method": "import_friends" } - readline.side_effect = [json.dumps(request), ""] + read.side_effect = [json.dumps(request).encode() + b"\n", b""] plugin.get_friends.coro.side_effect = UnknownError() asyncio.run(plugin.run()) plugin.get_friends.assert_called_with() diff --git a/tests/test_game_times.py b/tests/test_game_times.py index 9ad7220..8f5d5ae 100644 --- a/tests/test_game_times.py +++ b/tests/test_game_times.py @@ -6,14 +6,14 @@ import pytest from galaxy.api.types import GameTime from galaxy.api.errors import UnknownError, ImportInProgress, BackendError -def test_success(plugin, readline, write): +def test_success(plugin, read, write): request = { "jsonrpc": "2.0", "id": "3", "method": "import_game_times" } - readline.side_effect = [json.dumps(request), ""] + read.side_effect = [json.dumps(request).encode() + b"\n", b""] plugin.get_game_times.coro.return_value = [ GameTime("3", 60, 1549550504), GameTime("5", 10, 1549550502) @@ -41,14 +41,14 @@ def test_success(plugin, readline, write): } } -def test_failure(plugin, readline, write): +def test_failure(plugin, read, write): request = { "jsonrpc": "2.0", "id": "3", "method": "import_game_times" } - readline.side_effect = [json.dumps(request), ""] + read.side_effect = [json.dumps(request).encode() + b"\n", b""] plugin.get_game_times.coro.side_effect = UnknownError() asyncio.run(plugin.run()) plugin.get_game_times.assert_called_with() diff --git a/tests/test_install_game.py b/tests/test_install_game.py index ca9c4d0..744bb1a 100644 --- a/tests/test_install_game.py +++ b/tests/test_install_game.py @@ -1,7 +1,7 @@ import asyncio import json -def test_success(plugin, readline): +def test_success(plugin, read): request = { "jsonrpc": "2.0", "method": "install_game", @@ -10,7 +10,7 @@ def test_success(plugin, readline): } } - readline.side_effect = [json.dumps(request), ""] + read.side_effect = [json.dumps(request).encode() + b"\n", b""] plugin.get_owned_games.return_value = None asyncio.run(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..d381f4f 100644 --- a/tests/test_internal.py +++ b/tests/test_internal.py @@ -4,7 +4,7 @@ import json from galaxy.api.plugin import Plugin from galaxy.api.consts import Platform -def test_get_capabilites(reader, writer, readline, write): +def test_get_capabilites(reader, writer, read, write): class PluginImpl(Plugin): #pylint: disable=abstract-method async def get_owned_games(self): pass @@ -16,7 +16,7 @@ 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), ""] + read.side_effect = [json.dumps(request).encode() + b"\n", b""] asyncio.run(plugin.run()) response = json.loads(write.call_args[0][0]) assert response == { @@ -31,13 +31,13 @@ def test_get_capabilites(reader, writer, readline, write): } } -def test_shutdown(plugin, readline, write): +def test_shutdown(plugin, read, write): request = { "jsonrpc": "2.0", "id": "5", "method": "shutdown" } - readline.side_effect = [json.dumps(request)] + read.side_effect = [json.dumps(request).encode() + b"\n", b""] asyncio.run(plugin.run()) plugin.shutdown.assert_called_with() response = json.loads(write.call_args[0][0]) @@ -47,13 +47,13 @@ def test_shutdown(plugin, readline, write): "result": None } -def test_ping(plugin, readline, write): +def test_ping(plugin, read, write): request = { "jsonrpc": "2.0", "id": "7", "method": "ping" } - readline.side_effect = [json.dumps(request), ""] + read.side_effect = [json.dumps(request).encode() + b"\n", b""] asyncio.run(plugin.run()) response = json.loads(write.call_args[0][0]) assert response == { @@ -62,7 +62,7 @@ def test_ping(plugin, readline, write): "result": None } -def test_tick(plugin, readline): - readline.side_effect = [""] +def test_tick(plugin, read): + read.side_effect = [b""] asyncio.run(plugin.run()) plugin.tick.assert_called_with() diff --git a/tests/test_launch_game.py b/tests/test_launch_game.py index fa654e9..551f7cf 100644 --- a/tests/test_launch_game.py +++ b/tests/test_launch_game.py @@ -1,7 +1,7 @@ import asyncio import json -def test_success(plugin, readline): +def test_success(plugin, read): request = { "jsonrpc": "2.0", "method": "launch_game", @@ -10,7 +10,7 @@ def test_success(plugin, readline): } } - readline.side_effect = [json.dumps(request), ""] + read.side_effect = [json.dumps(request).encode() + b"\n", b""] plugin.get_owned_games.return_value = None asyncio.run(plugin.run()) plugin.launch_game.assert_called_with(game_id="3") diff --git a/tests/test_local_games.py b/tests/test_local_games.py index 445e699..b53056b 100644 --- a/tests/test_local_games.py +++ b/tests/test_local_games.py @@ -7,14 +7,14 @@ from galaxy.api.types import LocalGame from galaxy.api.consts import LocalGameState from galaxy.api.errors import UnknownError, FailedParsingManifest -def test_success(plugin, readline, write): +def test_success(plugin, read, write): request = { "jsonrpc": "2.0", "id": "3", "method": "import_local_games" } - readline.side_effect = [json.dumps(request), ""] + read.side_effect = [json.dumps(request).encode() + b"\n", b""] plugin.get_local_games.coro.return_value = [ LocalGame("1", LocalGameState.Running), @@ -53,14 +53,14 @@ 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): +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), ""] + read.side_effect = [json.dumps(request).encode() + b"\n", b""] plugin.get_local_games.coro.side_effect = error() asyncio.run(plugin.run()) plugin.get_local_games.assert_called_with() diff --git a/tests/test_owned_games.py b/tests/test_owned_games.py index 1202c9e..f455914 100644 --- a/tests/test_owned_games.py +++ b/tests/test_owned_games.py @@ -5,14 +5,14 @@ from galaxy.api.types import Game, Dlc, LicenseInfo from galaxy.api.consts import LicenseType from galaxy.api.errors import UnknownError -def test_success(plugin, readline, write): +def test_success(plugin, read, write): request = { "jsonrpc": "2.0", "id": "3", "method": "import_owned_games" } - readline.side_effect = [json.dumps(request), ""] + read.side_effect = [json.dumps(request).encode() + b"\n", b""] plugin.get_owned_games.coro.return_value = [ Game("3", "Doom", None, LicenseInfo(LicenseType.SinglePurchase, None)), Game( @@ -67,14 +67,14 @@ def test_success(plugin, readline, write): } } -def test_failure(plugin, readline, write): +def test_failure(plugin, read, write): request = { "jsonrpc": "2.0", "id": "3", "method": "import_owned_games" } - readline.side_effect = [json.dumps(request), ""] + read.side_effect = [json.dumps(request).encode() + b"\n", b""] plugin.get_owned_games.coro.side_effect = UnknownError() asyncio.run(plugin.run()) plugin.get_owned_games.assert_called_with() diff --git a/tests/test_persistent_cache.py b/tests/test_persistent_cache.py index 056ddb5..d2d9d8c 100644 --- a/tests/test_persistent_cache.py +++ b/tests/test_persistent_cache.py @@ -28,7 +28,7 @@ def cache_data(): } -def test_initialize_cache(plugin, readline, write, cache_data): +def test_initialize_cache(plugin, read, write, cache_data): request_id = 3 request = { "jsonrpc": "2.0", @@ -36,7 +36,7 @@ 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 = [json.dumps(request).encode() + b"\n"] assert {} == plugin.persistent_cache asyncio.run(plugin.run()) diff --git a/tests/test_uninstall_game.py b/tests/test_uninstall_game.py index 2e7c4ef..40a316b 100644 --- a/tests/test_uninstall_game.py +++ b/tests/test_uninstall_game.py @@ -1,7 +1,7 @@ import asyncio import json -def test_success(plugin, readline): +def test_success(plugin, read): request = { "jsonrpc": "2.0", "method": "uninstall_game", @@ -10,7 +10,7 @@ def test_success(plugin, readline): } } - readline.side_effect = [json.dumps(request), ""] + read.side_effect = [json.dumps(request).encode() + b"\n", b""] plugin.get_owned_games.return_value = None asyncio.run(plugin.run()) plugin.uninstall_game.assert_called_with(game_id="3") diff --git a/tests/test_users.py b/tests/test_users.py index 24c9dbb..47837ef 100644 --- a/tests/test_users.py +++ b/tests/test_users.py @@ -6,7 +6,7 @@ from galaxy.api.errors import UnknownError from galaxy.api.consts import PresenceState -def test_get_users_success(plugin, readline, write): +def test_get_users_success(plugin, read, write): request = { "jsonrpc": "2.0", "id": "8", @@ -16,7 +16,7 @@ def test_get_users_success(plugin, readline, write): } } - readline.side_effect = [json.dumps(request), ""] + read.side_effect = [json.dumps(request).encode() + b"\n", b""] plugin.get_users.coro.return_value = [ UserInfo("5", False, "Ula", "http://avatar.png", Presence(PresenceState.Offline)) ] @@ -43,7 +43,7 @@ def test_get_users_success(plugin, readline, write): } -def test_get_users_failure(plugin, readline, write): +def test_get_users_failure(plugin, read, write): request = { "jsonrpc": "2.0", "id": "12", @@ -53,7 +53,7 @@ def test_get_users_failure(plugin, readline, write): } } - readline.side_effect = [json.dumps(request), ""] + read.side_effect = [json.dumps(request).encode() + b"\n", b""] plugin.get_users.coro.side_effect = UnknownError() asyncio.run(plugin.run()) plugin.get_users.assert_called_with(user_id_list=["10", "11", "12"]) From 05042fe430262cd8322a96fe60d5e2cc5cbe074a Mon Sep 17 00:00:00 2001 From: Romuald Juchnowicz-Bierbasz Date: Tue, 25 Jun 2019 17:53:35 +0200 Subject: [PATCH 15/58] Increment version --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 6162140..cf48618 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.37", description="GOG Galaxy Integrations Python API", author='Galaxy team', author_email='galaxy@gog.com', From 207b1e1313021cd8575c23d397bf962c0a04dfff Mon Sep 17 00:00:00 2001 From: Romuald Juchnowicz-Bierbasz Date: Wed, 26 Jun 2019 12:02:25 +0200 Subject: [PATCH 16/58] SDK-2880: Fix readline --- src/galaxy/api/jsonrpc.py | 13 +++++++----- tests/test_chunk_messages.py | 39 +++++++++++++++++++++++++++++++++++- 2 files changed, 46 insertions(+), 6 deletions(-) diff --git a/src/galaxy/api/jsonrpc.py b/src/galaxy/api/jsonrpc.py index 62a36b4..e491c3f 100644 --- a/src/galaxy/api/jsonrpc.py +++ b/src/galaxy/api/jsonrpc.py @@ -74,6 +74,7 @@ class Server(): self._notifications = {} self._eof_listeners = [] self._input_buffer = bytes() + self._input_buffer_it = 0 def register_method(self, name, callback, internal, sensitive_params=False): """ @@ -120,15 +121,17 @@ class Server(): """Like StreamReader.readline but without limit""" while True: chunk = await self._reader.read(1024) - if not chunk: - return chunk - previous_size = len(self._input_buffer) self._input_buffer += chunk - it = self._input_buffer.find(b"\n", previous_size) + it = self._input_buffer.find(b"\n", self._input_buffer_it) if it < 0: - continue + if not chunk: + return bytes() # EOF + else: + self._input_buffer_it = len(self._input_buffer) + continue line = self._input_buffer[:it] self._input_buffer = self._input_buffer[it+1:] + self._input_buffer_it = 0 return line def stop(self): diff --git a/tests/test_chunk_messages.py b/tests/test_chunk_messages.py index 3e09adb..68a4b52 100644 --- a/tests/test_chunk_messages.py +++ b/tests/test_chunk_messages.py @@ -12,6 +12,43 @@ def test_chunked_messages(plugin, read): message = json.dumps(request).encode() + b"\n" read.side_effect = [message[:5], message[5:], b""] - plugin.get_owned_games.return_value = None asyncio.run(plugin.run()) plugin.install_game.assert_called_with(game_id="3") + +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 = [data, b""] + asyncio.run(plugin.run()) + plugin.install_game.assert_called_with(game_id="3") + plugin.launch_game.assert_called_with(game_id="3") + +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 = [message, b""] + asyncio.run(plugin.run()) + plugin.install_game.assert_not_called() From 692bdbf370b6285c992c4f561f00106c6bfd6614 Mon Sep 17 00:00:00 2001 From: Romuald Juchnowicz-Bierbasz Date: Wed, 26 Jun 2019 12:07:28 +0200 Subject: [PATCH 17/58] Increment version --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index cf48618..555c8ea 100644 --- a/setup.py +++ b/setup.py @@ -2,7 +2,7 @@ from setuptools import setup, find_packages setup( name="galaxy.plugin.api", - version="0.37", + version="0.38", description="GOG Galaxy Integrations Python API", author='Galaxy team', author_email='galaxy@gog.com', From f1fd00fcd3abd57ea1b5c3e099d273fbf4f90843 Mon Sep 17 00:00:00 2001 From: GOG Galaxy SDK Team Date: Wed, 26 Jun 2019 12:46:23 +0200 Subject: [PATCH 18/58] version 0.38 --- setup.py | 2 +- src/galaxy/api/consts.py | 68 +++++++++++++++++++++++++++++- src/galaxy/api/jsonrpc.py | 21 +++++++++- src/galaxy/api/plugin.py | 77 ++++++++++++++++++---------------- tests/conftest.py | 6 +-- tests/test_achievements.py | 8 ++-- tests/test_authenticate.py | 14 +++---- tests/test_chat.py | 40 +++++++++--------- tests/test_chunk_messages.py | 54 ++++++++++++++++++++++++ tests/test_friends.py | 8 ++-- tests/test_game_times.py | 8 ++-- tests/test_install_game.py | 4 +- tests/test_internal.py | 16 +++---- tests/test_launch_game.py | 4 +- tests/test_local_games.py | 8 ++-- tests/test_owned_games.py | 8 ++-- tests/test_persistent_cache.py | 4 +- tests/test_uninstall_game.py | 4 +- tests/test_users.py | 8 ++-- 19 files changed, 254 insertions(+), 108 deletions(-) create mode 100644 tests/test_chunk_messages.py diff --git a/setup.py b/setup.py index 43f5cf6..555c8ea 100644 --- a/setup.py +++ b/setup.py @@ -2,7 +2,7 @@ from setuptools import setup, find_packages setup( name="galaxy.plugin.api", - version="0.35.2", + version="0.38", description="GOG Galaxy Integrations Python API", author='Galaxy team', author_email='galaxy@gog.com', diff --git a/src/galaxy/api/consts.py b/src/galaxy/api/consts.py index e938977..fd5204e 100644 --- a/src/galaxy/api/consts.py +++ b/src/galaxy/api/consts.py @@ -13,7 +13,73 @@ class Platform(Enum): Uplay = "uplay" Battlenet = "battlenet" Epic = "epic" - + Bethesda = "bethesda" + ParadoxPlaza = "paradox" + HumbleBundle = "humble" + Kartridge = "kartridge" + ItchIo = "itch" + NintendoSwitch = "nswitch" + NintendoWiiU = "nwiiu" + NintendoWii = "nwii" + NintendoGameCube = "ncube" + RiotGames = "riot" + Wargaming = "wargaming" + NintendoGameBoy = "ngameboy" + Atari = "atari" + Amiga = "amiga" + SuperNintendoEntertainmentSystem = "snes" + Beamdog = "beamdog" + Direct2Drive = "d2d" + Discord = "discord" + DotEmu = "dotemu" + GameHouse = "gamehouse" + GreenManGaming = "gmg" + WePlay = "weplay" + ZxSpectrum = "zx" + ColecoVision = "vision" + NintendoEntertainmentSystem = "nes" + SegaMasterSystem = "sms" + Commodore64 = "c64" + PcEngine = "pce" + SegaGenesis = "segag" + NeoGeo = "neo" + Sega32X = "sega32" + SegaCd = "segacd" + _3Do = "3do" + SegaSaturn = "saturn" + PlayStation = "psx" + PlayStation2 = "ps2" + Nintendo64 = "n64" + AtariJaguar = "jaguar" + SegaDreamcast = "dc" + Xbox = "xboxog" + Amazon = "amazon" + GamersGate = "gg" + Newegg = "egg" + BestBuy = "bb" + GameUk = "gameuk" + Fanatical = "fanatical" + PlayAsia = "playasia" + Stadia = "stadia" + Arc = "arc" + ElderScrollsOnline = "eso" + Glyph = "glyph" + AionLegionsOfWar = "aionl" + Aion = "aion" + BladeAndSoul = "blade" + GuildWars = "gw" + GuildWars2 = "gw2" + Lineage2 = "lin2" + FinalFantasy11 = "ffxi" + FinalFantasy14 = "ffxiv" + TotalWar = "totalwar" + WindowsStore = "winstore" + EliteDangerous = "elites" + StarCitizen = "star" + PlayStationPortable = "psp" + PlayStationVita = "psvita" + NintendoDs = "nds" + Nintendo3Ds = "3ds" class Feature(Enum): """Possible features that can be implemented by an integration. diff --git a/src/galaxy/api/jsonrpc.py b/src/galaxy/api/jsonrpc.py index 31a80dd..e491c3f 100644 --- a/src/galaxy/api/jsonrpc.py +++ b/src/galaxy/api/jsonrpc.py @@ -73,6 +73,8 @@ class Server(): self._methods = {} self._notifications = {} self._eof_listeners = [] + self._input_buffer = bytes() + self._input_buffer_it = 0 def register_method(self, name, callback, internal, sensitive_params=False): """ @@ -104,7 +106,7 @@ class Server(): async def run(self): while self._active: try: - data = await self._reader.readline() + data = await self._readline() if not data: self._eof() continue @@ -115,6 +117,23 @@ class Server(): logging.debug("Received %d bytes of data", len(data)) self._handle_input(data) + async def _readline(self): + """Like StreamReader.readline but without limit""" + while True: + chunk = await self._reader.read(1024) + self._input_buffer += chunk + it = self._input_buffer.find(b"\n", self._input_buffer_it) + if it < 0: + if not chunk: + return bytes() # EOF + else: + self._input_buffer_it = len(self._input_buffer) + continue + line = self._input_buffer[:it] + self._input_buffer = self._input_buffer[it+1:] + self._input_buffer_it = 0 + return line + def stop(self): self._active = False diff --git a/src/galaxy/api/plugin.py b/src/galaxy/api/plugin.py index b876c57..22eb9d4 100644 --- a/src/galaxy/api/plugin.py +++ b/src/galaxy/api/plugin.py @@ -7,13 +7,14 @@ from enum import Enum from collections import OrderedDict import sys -from typing import List, Dict +from typing import Any, List, Dict, Optional, Union 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.types import Authentication, NextStep, Message class JSONEncoder(json.JSONEncoder): @@ -241,7 +242,7 @@ class Plugin: 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. @@ -265,7 +266,7 @@ class Plugin: """ 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,7 +288,7 @@ 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. @@ -309,7 +310,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,7 +319,7 @@ 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. @@ -330,7 +331,7 @@ class Plugin: } self._notification_client.notify("achievement_unlocked", params) - def game_achievements_import_success(self, game_id: str, achievements): + def game_achievements_import_success(self, game_id: str, achievements: List[Achievement]) -> None: """Notify the client that import of achievements for a given game has succeeded. This method is called by import_games_achievements. @@ -343,7 +344,7 @@ class Plugin: } self._notification_client.notify("game_achievements_import_success", params) - def game_achievements_import_failure(self, game_id: str, error: ApplicationError): + def game_achievements_import_failure(self, game_id: str, error: ApplicationError) -> None: """Notify the client that import of achievements for a given game has failed. This method is called by import_games_achievements. @@ -359,12 +360,12 @@ class Plugin: } self._notification_client.notify("game_achievements_import_failure", params) - def achievements_import_finished(self): + def achievements_import_finished(self) -> None: """Notify the client that importing achievements has finished. This method is called by import_games_achievements_task""" 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 @@ -390,7 +391,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 +399,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,7 +407,12 @@ 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): + def update_room( + self, + room_id: str, + unread_message_count: Optional[int]=None, + new_messages: Optional[List[Message]]=None + ) -> None: """WIP, Notify the client to update the information regarding a chat room that the currently authenticated user is in. @@ -421,7 +427,7 @@ class Plugin: 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,7 +435,7 @@ class Plugin: params = {"game_time": game_time} self._notification_client.notify("game_time_updated", params) - def game_time_import_success(self, game_time: GameTime): + def game_time_import_success(self, game_time: GameTime) -> None: """Notify the client that import of a given game_time has succeeded. This method is called by import_game_times. @@ -438,7 +444,7 @@ class Plugin: 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): + def game_time_import_failure(self, game_id: str, error: ApplicationError) -> None: """Notify the client that import of a game time for a given game has failed. This method is called by import_game_times. @@ -454,19 +460,19 @@ class Plugin: } self._notification_client.notify("game_time_import_failure", params) - def game_times_import_finished(self): + def game_times_import_finished(self) -> None: """Notify the client that importing game times has finished. This method is called by :meth:`~.import_game_times_task`. """ 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 +482,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 +509,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 +543,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 @@ -592,7 +599,7 @@ class Plugin: """ raise NotImplementedError() - async def start_achievements_import(self, game_ids: List[str]): + async def start_achievements_import(self, game_ids: List[str]) -> None: """Starts the task of importing achievements. This method is called by the GOG Galaxy Client. @@ -611,7 +618,7 @@ class Plugin: asyncio.create_task(import_games_achievements_task(game_ids)) self._achievements_import_in_progress = True - async def import_games_achievements(self, game_ids: List[str]): + async def import_games_achievements(self, game_ids: List[str]) -> None: """ Override this method to return the unlocked achievements of the user that is currently logged in to the plugin. @@ -652,7 +659,7 @@ 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. @@ -670,7 +677,7 @@ 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. @@ -688,7 +695,7 @@ 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. @@ -734,7 +741,7 @@ class Plugin: """ raise NotImplementedError() - async def send_message(self, room_id: str, message_text: str): + async def send_message(self, room_id: str, message_text: str) -> None: """WIP, Override this method to send message to a chat room. This method is called by the GOG Galaxy Client. @@ -743,7 +750,7 @@ class Plugin: """ raise NotImplementedError() - async def mark_as_read(self, room_id: str, last_message_id: str): + async def mark_as_read(self, room_id: str, last_message_id: str) -> None: """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. @@ -759,7 +766,7 @@ class Plugin: """ raise NotImplementedError() - async def get_room_history_from_message(self, room_id: str, message_id: str): + async def get_room_history_from_message(self, room_id: str, message_id: str) -> List[Message]: """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. @@ -768,7 +775,7 @@ class Plugin: """ raise NotImplementedError() - async def get_room_history_from_timestamp(self, room_id: str, from_timestamp: int): + async def get_room_history_from_timestamp(self, room_id: str, from_timestamp: int) -> List[Message]: """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. @@ -784,7 +791,7 @@ class Plugin: """ raise NotImplementedError() - async def start_game_times_import(self, game_ids: List[str]): + async def start_game_times_import(self, game_ids: List[str]) -> None: """Starts the task of importing game times This method is called by the GOG Galaxy Client. @@ -803,7 +810,7 @@ class Plugin: asyncio.create_task(import_game_times_task(game_ids)) self._game_times_import_in_progress = True - async def import_game_times(self, game_ids: List[str]): + async def import_game_times(self, game_ids: List[str]) -> None: """ Override this method to return game times for games owned by the currently authenticated user. diff --git a/tests/conftest.py b/tests/conftest.py index d373c32..d94a9f3 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -11,7 +11,7 @@ from galaxy.unittest.mock import AsyncMock, coroutine_mock @pytest.fixture() def reader(): stream = MagicMock(name="stream_reader") - stream.readline = AsyncMock() + stream.read = AsyncMock() yield stream @pytest.fixture() @@ -22,8 +22,8 @@ def writer(): yield stream @pytest.fixture() -def readline(reader): - yield reader.readline +def read(reader): + yield reader.read @pytest.fixture() def write(writer): diff --git a/tests/test_achievements.py b/tests/test_achievements.py index 84421bd..9a6ec30 100644 --- a/tests/test_achievements.py +++ b/tests/test_achievements.py @@ -16,7 +16,7 @@ def test_initialization_no_id_nor_name(): with raises(AssertionError): Achievement(unlock_time=1234567890) -def test_success(plugin, readline, write): +def test_success(plugin, read, write): request = { "jsonrpc": "2.0", "id": "3", @@ -25,7 +25,7 @@ def test_success(plugin, readline, write): "game_id": "14" } } - readline.side_effect = [json.dumps(request), ""] + read.side_effect = [json.dumps(request).encode() + b"\n", b""] plugin.get_unlocked_achievements.coro.return_value = [ Achievement(achievement_id="lvl10", unlock_time=1548421241), Achievement(achievement_name="Got level 20", unlock_time=1548422395), @@ -57,7 +57,7 @@ def test_success(plugin, readline, write): } } -def test_failure(plugin, readline, write): +def test_failure(plugin, read, write): request = { "jsonrpc": "2.0", "id": "3", @@ -67,7 +67,7 @@ def test_failure(plugin, readline, write): } } - readline.side_effect = [json.dumps(request), ""] + read.side_effect = [json.dumps(request).encode() + b"\n", b""] plugin.get_unlocked_achievements.coro.side_effect = UnknownError() asyncio.run(plugin.run()) plugin.get_unlocked_achievements.assert_called() diff --git a/tests/test_authenticate.py b/tests/test_authenticate.py index 6cb96a6..1d84c60 100644 --- a/tests/test_authenticate.py +++ b/tests/test_authenticate.py @@ -9,14 +9,14 @@ from galaxy.api.errors import ( BackendNotAvailable, BackendTimeout, BackendError, TemporaryBlocked, Banned, AccessDenied ) -def test_success(plugin, readline, write): +def test_success(plugin, read, write): request = { "jsonrpc": "2.0", "id": "3", "method": "init_authentication" } - readline.side_effect = [json.dumps(request), ""] + read.side_effect = [json.dumps(request).encode() + b"\n", b""] plugin.authenticate.coro.return_value = Authentication("132", "Zenek") asyncio.run(plugin.run()) plugin.authenticate.assert_called_with() @@ -44,14 +44,14 @@ 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): +def test_failure(plugin, read, write, error, code, message): request = { "jsonrpc": "2.0", "id": "3", "method": "init_authentication" } - readline.side_effect = [json.dumps(request), ""] + read.side_effect = [json.dumps(request).encode() + b"\n", b""] plugin.authenticate.coro.side_effect = error() asyncio.run(plugin.run()) plugin.authenticate.assert_called_with() @@ -66,7 +66,7 @@ def test_failure(plugin, readline, write, error, code, message): } } -def test_stored_credentials(plugin, readline, write): +def test_stored_credentials(plugin, read, write): request = { "jsonrpc": "2.0", "id": "3", @@ -77,7 +77,7 @@ def test_stored_credentials(plugin, readline, write): } } } - readline.side_effect = [json.dumps(request), ""] + read.side_effect = [json.dumps(request).encode() + b"\n", b""] plugin.authenticate.coro.return_value = Authentication("132", "Zenek") asyncio.run(plugin.run()) plugin.authenticate.assert_called_with(stored_credentials={"token": "ABC"}) @@ -100,7 +100,7 @@ def test_store_credentials(plugin, write): "params": credentials } -def test_lost_authentication(plugin, readline, write): +def test_lost_authentication(plugin, write): async def couritine(): plugin.lost_authentication() diff --git a/tests/test_chat.py b/tests/test_chat.py index 97dad89..db840b4 100644 --- a/tests/test_chat.py +++ b/tests/test_chat.py @@ -9,7 +9,7 @@ from galaxy.api.errors import ( TooManyMessagesSent, IncoherentLastMessage, MessageNotFound ) -def test_send_message_success(plugin, readline, write): +def test_send_message_success(plugin, read, write): request = { "jsonrpc": "2.0", "id": "3", @@ -20,7 +20,7 @@ def test_send_message_success(plugin, readline, write): } } - readline.side_effect = [json.dumps(request), ""] + read.side_effect = [json.dumps(request).encode() + b"\n", b""] plugin.send_message.coro.return_value = None asyncio.run(plugin.run()) plugin.send_message.assert_called_with(room_id="14", message="Hello!") @@ -40,7 +40,7 @@ def test_send_message_success(plugin, readline, write): 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): +def test_send_message_failure(plugin, read, write, error, code, message): request = { "jsonrpc": "2.0", "id": "6", @@ -51,7 +51,7 @@ def test_send_message_failure(plugin, readline, write, error, code, message): } } - readline.side_effect = [json.dumps(request), ""] + read.side_effect = [json.dumps(request).encode() + b"\n", b""] plugin.send_message.coro.side_effect = error() asyncio.run(plugin.run()) plugin.send_message.assert_called_with(room_id="15", message="Bye") @@ -66,7 +66,7 @@ def test_send_message_failure(plugin, readline, write, error, code, message): } } -def test_mark_as_read_success(plugin, readline, write): +def test_mark_as_read_success(plugin, read, write): request = { "jsonrpc": "2.0", "id": "7", @@ -77,7 +77,7 @@ def test_mark_as_read_success(plugin, readline, write): } } - readline.side_effect = [json.dumps(request), ""] + read.side_effect = [json.dumps(request).encode() + b"\n", b""] 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") @@ -102,7 +102,7 @@ def test_mark_as_read_success(plugin, readline, write): id="incoherent_last_message" ) ]) -def test_mark_as_read_failure(plugin, readline, write, error, code, message): +def test_mark_as_read_failure(plugin, read, write, error, code, message): request = { "jsonrpc": "2.0", "id": "4", @@ -113,7 +113,7 @@ def test_mark_as_read_failure(plugin, readline, write, error, code, message): } } - readline.side_effect = [json.dumps(request), ""] + read.side_effect = [json.dumps(request).encode() + b"\n", b""] 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") @@ -128,14 +128,14 @@ def test_mark_as_read_failure(plugin, readline, write, error, code, message): } } -def test_get_rooms_success(plugin, readline, write): +def test_get_rooms_success(plugin, read, write): request = { "jsonrpc": "2.0", "id": "2", "method": "import_rooms" } - readline.side_effect = [json.dumps(request), ""] + read.side_effect = [json.dumps(request).encode() + b"\n", b""] plugin.get_rooms.coro.return_value = [ Room("13", 0, None), Room("15", 34, "8") @@ -162,14 +162,14 @@ def test_get_rooms_success(plugin, readline, write): } } -def test_get_rooms_failure(plugin, readline, write): +def test_get_rooms_failure(plugin, read, write): request = { "jsonrpc": "2.0", "id": "9", "method": "import_rooms" } - readline.side_effect = [json.dumps(request), ""] + read.side_effect = [json.dumps(request).encode() + b"\n", b""] plugin.get_rooms.coro.side_effect = UnknownError() asyncio.run(plugin.run()) plugin.get_rooms.assert_called_with() @@ -184,7 +184,7 @@ def test_get_rooms_failure(plugin, readline, write): } } -def test_get_room_history_from_message_success(plugin, readline, write): +def test_get_room_history_from_message_success(plugin, read, write): request = { "jsonrpc": "2.0", "id": "2", @@ -195,7 +195,7 @@ def test_get_room_history_from_message_success(plugin, readline, write): } } - readline.side_effect = [json.dumps(request), ""] + read.side_effect = [json.dumps(request).encode() + b"\n", b""] plugin.get_room_history_from_message.coro.return_value = [ Message("13", "149", 1549454837, "Hello"), Message("14", "812", 1549454899, "Hi") @@ -233,7 +233,7 @@ def test_get_room_history_from_message_success(plugin, readline, write): 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): +def test_get_room_history_from_message_failure(plugin, read, write, error, code, message): request = { "jsonrpc": "2.0", "id": "7", @@ -244,7 +244,7 @@ def test_get_room_history_from_message_failure(plugin, readline, write, error, c } } - readline.side_effect = [json.dumps(request), ""] + read.side_effect = [json.dumps(request).encode() + b"\n", b""] 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") @@ -259,7 +259,7 @@ def test_get_room_history_from_message_failure(plugin, readline, write, error, c } } -def test_get_room_history_from_timestamp_success(plugin, readline, write): +def test_get_room_history_from_timestamp_success(plugin, read, write): request = { "jsonrpc": "2.0", "id": "7", @@ -270,7 +270,7 @@ def test_get_room_history_from_timestamp_success(plugin, readline, write): } } - readline.side_effect = [json.dumps(request), ""] + read.side_effect = [json.dumps(request).encode() + b"\n", b""] plugin.get_room_history_from_timestamp.coro.return_value = [ Message("12", "155", 1549454836, "Bye") ] @@ -296,7 +296,7 @@ def test_get_room_history_from_timestamp_success(plugin, readline, write): } } -def test_get_room_history_from_timestamp_failure(plugin, readline, write): +def test_get_room_history_from_timestamp_failure(plugin, read, write): request = { "jsonrpc": "2.0", "id": "3", @@ -307,7 +307,7 @@ def test_get_room_history_from_timestamp_failure(plugin, readline, write): } } - readline.side_effect = [json.dumps(request), ""] + read.side_effect = [json.dumps(request).encode() + b"\n", b""] plugin.get_room_history_from_timestamp.coro.side_effect = UnknownError() asyncio.run(plugin.run()) plugin.get_room_history_from_timestamp.assert_called_with( diff --git a/tests/test_chunk_messages.py b/tests/test_chunk_messages.py new file mode 100644 index 0000000..68a4b52 --- /dev/null +++ b/tests/test_chunk_messages.py @@ -0,0 +1,54 @@ +import asyncio +import json + +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 = [message[:5], message[5:], b""] + asyncio.run(plugin.run()) + plugin.install_game.assert_called_with(game_id="3") + +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 = [data, b""] + asyncio.run(plugin.run()) + plugin.install_game.assert_called_with(game_id="3") + plugin.launch_game.assert_called_with(game_id="3") + +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 = [message, b""] + asyncio.run(plugin.run()) + plugin.install_game.assert_not_called() diff --git a/tests/test_friends.py b/tests/test_friends.py index 52cdd9b..030f029 100644 --- a/tests/test_friends.py +++ b/tests/test_friends.py @@ -5,14 +5,14 @@ from galaxy.api.types import FriendInfo from galaxy.api.errors import UnknownError -def test_get_friends_success(plugin, readline, write): +def test_get_friends_success(plugin, read, write): request = { "jsonrpc": "2.0", "id": "3", "method": "import_friends" } - readline.side_effect = [json.dumps(request), ""] + read.side_effect = [json.dumps(request).encode() + b"\n", b""] plugin.get_friends.coro.return_value = [ FriendInfo("3", "Jan"), FriendInfo("5", "Ola") @@ -33,14 +33,14 @@ def test_get_friends_success(plugin, readline, write): } -def test_get_friends_failure(plugin, readline, write): +def test_get_friends_failure(plugin, read, write): request = { "jsonrpc": "2.0", "id": "3", "method": "import_friends" } - readline.side_effect = [json.dumps(request), ""] + read.side_effect = [json.dumps(request).encode() + b"\n", b""] plugin.get_friends.coro.side_effect = UnknownError() asyncio.run(plugin.run()) plugin.get_friends.assert_called_with() diff --git a/tests/test_game_times.py b/tests/test_game_times.py index 9ad7220..8f5d5ae 100644 --- a/tests/test_game_times.py +++ b/tests/test_game_times.py @@ -6,14 +6,14 @@ import pytest from galaxy.api.types import GameTime from galaxy.api.errors import UnknownError, ImportInProgress, BackendError -def test_success(plugin, readline, write): +def test_success(plugin, read, write): request = { "jsonrpc": "2.0", "id": "3", "method": "import_game_times" } - readline.side_effect = [json.dumps(request), ""] + read.side_effect = [json.dumps(request).encode() + b"\n", b""] plugin.get_game_times.coro.return_value = [ GameTime("3", 60, 1549550504), GameTime("5", 10, 1549550502) @@ -41,14 +41,14 @@ def test_success(plugin, readline, write): } } -def test_failure(plugin, readline, write): +def test_failure(plugin, read, write): request = { "jsonrpc": "2.0", "id": "3", "method": "import_game_times" } - readline.side_effect = [json.dumps(request), ""] + read.side_effect = [json.dumps(request).encode() + b"\n", b""] plugin.get_game_times.coro.side_effect = UnknownError() asyncio.run(plugin.run()) plugin.get_game_times.assert_called_with() diff --git a/tests/test_install_game.py b/tests/test_install_game.py index ca9c4d0..744bb1a 100644 --- a/tests/test_install_game.py +++ b/tests/test_install_game.py @@ -1,7 +1,7 @@ import asyncio import json -def test_success(plugin, readline): +def test_success(plugin, read): request = { "jsonrpc": "2.0", "method": "install_game", @@ -10,7 +10,7 @@ def test_success(plugin, readline): } } - readline.side_effect = [json.dumps(request), ""] + read.side_effect = [json.dumps(request).encode() + b"\n", b""] plugin.get_owned_games.return_value = None asyncio.run(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..d381f4f 100644 --- a/tests/test_internal.py +++ b/tests/test_internal.py @@ -4,7 +4,7 @@ import json from galaxy.api.plugin import Plugin from galaxy.api.consts import Platform -def test_get_capabilites(reader, writer, readline, write): +def test_get_capabilites(reader, writer, read, write): class PluginImpl(Plugin): #pylint: disable=abstract-method async def get_owned_games(self): pass @@ -16,7 +16,7 @@ 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), ""] + read.side_effect = [json.dumps(request).encode() + b"\n", b""] asyncio.run(plugin.run()) response = json.loads(write.call_args[0][0]) assert response == { @@ -31,13 +31,13 @@ def test_get_capabilites(reader, writer, readline, write): } } -def test_shutdown(plugin, readline, write): +def test_shutdown(plugin, read, write): request = { "jsonrpc": "2.0", "id": "5", "method": "shutdown" } - readline.side_effect = [json.dumps(request)] + read.side_effect = [json.dumps(request).encode() + b"\n", b""] asyncio.run(plugin.run()) plugin.shutdown.assert_called_with() response = json.loads(write.call_args[0][0]) @@ -47,13 +47,13 @@ def test_shutdown(plugin, readline, write): "result": None } -def test_ping(plugin, readline, write): +def test_ping(plugin, read, write): request = { "jsonrpc": "2.0", "id": "7", "method": "ping" } - readline.side_effect = [json.dumps(request), ""] + read.side_effect = [json.dumps(request).encode() + b"\n", b""] asyncio.run(plugin.run()) response = json.loads(write.call_args[0][0]) assert response == { @@ -62,7 +62,7 @@ def test_ping(plugin, readline, write): "result": None } -def test_tick(plugin, readline): - readline.side_effect = [""] +def test_tick(plugin, read): + read.side_effect = [b""] asyncio.run(plugin.run()) plugin.tick.assert_called_with() diff --git a/tests/test_launch_game.py b/tests/test_launch_game.py index fa654e9..551f7cf 100644 --- a/tests/test_launch_game.py +++ b/tests/test_launch_game.py @@ -1,7 +1,7 @@ import asyncio import json -def test_success(plugin, readline): +def test_success(plugin, read): request = { "jsonrpc": "2.0", "method": "launch_game", @@ -10,7 +10,7 @@ def test_success(plugin, readline): } } - readline.side_effect = [json.dumps(request), ""] + read.side_effect = [json.dumps(request).encode() + b"\n", b""] plugin.get_owned_games.return_value = None asyncio.run(plugin.run()) plugin.launch_game.assert_called_with(game_id="3") diff --git a/tests/test_local_games.py b/tests/test_local_games.py index 445e699..b53056b 100644 --- a/tests/test_local_games.py +++ b/tests/test_local_games.py @@ -7,14 +7,14 @@ from galaxy.api.types import LocalGame from galaxy.api.consts import LocalGameState from galaxy.api.errors import UnknownError, FailedParsingManifest -def test_success(plugin, readline, write): +def test_success(plugin, read, write): request = { "jsonrpc": "2.0", "id": "3", "method": "import_local_games" } - readline.side_effect = [json.dumps(request), ""] + read.side_effect = [json.dumps(request).encode() + b"\n", b""] plugin.get_local_games.coro.return_value = [ LocalGame("1", LocalGameState.Running), @@ -53,14 +53,14 @@ 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): +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), ""] + read.side_effect = [json.dumps(request).encode() + b"\n", b""] plugin.get_local_games.coro.side_effect = error() asyncio.run(plugin.run()) plugin.get_local_games.assert_called_with() diff --git a/tests/test_owned_games.py b/tests/test_owned_games.py index 1202c9e..f455914 100644 --- a/tests/test_owned_games.py +++ b/tests/test_owned_games.py @@ -5,14 +5,14 @@ from galaxy.api.types import Game, Dlc, LicenseInfo from galaxy.api.consts import LicenseType from galaxy.api.errors import UnknownError -def test_success(plugin, readline, write): +def test_success(plugin, read, write): request = { "jsonrpc": "2.0", "id": "3", "method": "import_owned_games" } - readline.side_effect = [json.dumps(request), ""] + read.side_effect = [json.dumps(request).encode() + b"\n", b""] plugin.get_owned_games.coro.return_value = [ Game("3", "Doom", None, LicenseInfo(LicenseType.SinglePurchase, None)), Game( @@ -67,14 +67,14 @@ def test_success(plugin, readline, write): } } -def test_failure(plugin, readline, write): +def test_failure(plugin, read, write): request = { "jsonrpc": "2.0", "id": "3", "method": "import_owned_games" } - readline.side_effect = [json.dumps(request), ""] + read.side_effect = [json.dumps(request).encode() + b"\n", b""] plugin.get_owned_games.coro.side_effect = UnknownError() asyncio.run(plugin.run()) plugin.get_owned_games.assert_called_with() diff --git a/tests/test_persistent_cache.py b/tests/test_persistent_cache.py index 056ddb5..d2d9d8c 100644 --- a/tests/test_persistent_cache.py +++ b/tests/test_persistent_cache.py @@ -28,7 +28,7 @@ def cache_data(): } -def test_initialize_cache(plugin, readline, write, cache_data): +def test_initialize_cache(plugin, read, write, cache_data): request_id = 3 request = { "jsonrpc": "2.0", @@ -36,7 +36,7 @@ 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 = [json.dumps(request).encode() + b"\n"] assert {} == plugin.persistent_cache asyncio.run(plugin.run()) diff --git a/tests/test_uninstall_game.py b/tests/test_uninstall_game.py index 2e7c4ef..40a316b 100644 --- a/tests/test_uninstall_game.py +++ b/tests/test_uninstall_game.py @@ -1,7 +1,7 @@ import asyncio import json -def test_success(plugin, readline): +def test_success(plugin, read): request = { "jsonrpc": "2.0", "method": "uninstall_game", @@ -10,7 +10,7 @@ def test_success(plugin, readline): } } - readline.side_effect = [json.dumps(request), ""] + read.side_effect = [json.dumps(request).encode() + b"\n", b""] plugin.get_owned_games.return_value = None asyncio.run(plugin.run()) plugin.uninstall_game.assert_called_with(game_id="3") diff --git a/tests/test_users.py b/tests/test_users.py index 24c9dbb..47837ef 100644 --- a/tests/test_users.py +++ b/tests/test_users.py @@ -6,7 +6,7 @@ from galaxy.api.errors import UnknownError from galaxy.api.consts import PresenceState -def test_get_users_success(plugin, readline, write): +def test_get_users_success(plugin, read, write): request = { "jsonrpc": "2.0", "id": "8", @@ -16,7 +16,7 @@ def test_get_users_success(plugin, readline, write): } } - readline.side_effect = [json.dumps(request), ""] + read.side_effect = [json.dumps(request).encode() + b"\n", b""] plugin.get_users.coro.return_value = [ UserInfo("5", False, "Ula", "http://avatar.png", Presence(PresenceState.Offline)) ] @@ -43,7 +43,7 @@ def test_get_users_success(plugin, readline, write): } -def test_get_users_failure(plugin, readline, write): +def test_get_users_failure(plugin, read, write): request = { "jsonrpc": "2.0", "id": "12", @@ -53,7 +53,7 @@ def test_get_users_failure(plugin, readline, write): } } - readline.side_effect = [json.dumps(request), ""] + read.side_effect = [json.dumps(request).encode() + b"\n", b""] plugin.get_users.coro.side_effect = UnknownError() asyncio.run(plugin.run()) plugin.get_users.assert_called_with(user_id_list=["10", "11", "12"]) From 77d742ce18c3fb22f09627c5006a722eda93a38a Mon Sep 17 00:00:00 2001 From: Romuald Juchnowicz-Bierbasz Date: Fri, 28 Jun 2019 11:58:32 +0200 Subject: [PATCH 19/58] SDK-2910: Fix readline --- src/galaxy/api/jsonrpc.py | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/src/galaxy/api/jsonrpc.py b/src/galaxy/api/jsonrpc.py index e491c3f..eb40ab3 100644 --- a/src/galaxy/api/jsonrpc.py +++ b/src/galaxy/api/jsonrpc.py @@ -74,7 +74,7 @@ class Server(): self._notifications = {} self._eof_listeners = [] self._input_buffer = bytes() - self._input_buffer_it = 0 + self._processed_input_buffer_it = 0 def register_method(self, name, callback, internal, sensitive_params=False): """ @@ -120,18 +120,21 @@ class Server(): async def _readline(self): """Like StreamReader.readline but without limit""" while True: - chunk = await self._reader.read(1024) - self._input_buffer += chunk - it = self._input_buffer.find(b"\n", self._input_buffer_it) - if it < 0: + # check if there is no unprocessed data in the buffer + if not self._input_buffer or self._processed_input_buffer_it != 0: + chunk = await self._reader.read(1024) if not chunk: return bytes() # EOF - else: - self._input_buffer_it = len(self._input_buffer) - continue + self._input_buffer += chunk + + it = self._input_buffer.find(b"\n", self._processed_input_buffer_it) + if it < 0: + self._processed_input_buffer_it = len(self._input_buffer) + continue + line = self._input_buffer[:it] self._input_buffer = self._input_buffer[it+1:] - self._input_buffer_it = 0 + self._processed_input_buffer_it = 0 return line def stop(self): From 67e8681de60c350ab63408a34c5570144fd01a9b Mon Sep 17 00:00:00 2001 From: Romuald Juchnowicz-Bierbasz Date: Fri, 28 Jun 2019 11:59:01 +0200 Subject: [PATCH 20/58] Increment version --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 555c8ea..73b51c8 100644 --- a/setup.py +++ b/setup.py @@ -2,7 +2,7 @@ from setuptools import setup, find_packages setup( name="galaxy.plugin.api", - version="0.38", + version="0.39", description="GOG Galaxy Integrations Python API", author='Galaxy team', author_email='galaxy@gog.com', From 4e1ea8056d66bb58ac5ca0e83cc6de7ce30aaf36 Mon Sep 17 00:00:00 2001 From: Romuald Juchnowicz-Bierbasz Date: Fri, 28 Jun 2019 14:00:44 +0200 Subject: [PATCH 21/58] Add StreamLineReader with unit tests --- src/galaxy/api/jsonrpc.py | 28 +++-------------- src/galaxy/reader.py | 28 +++++++++++++++++ tests/test_stream_line_reader.py | 52 ++++++++++++++++++++++++++++++++ 3 files changed, 84 insertions(+), 24 deletions(-) create mode 100644 src/galaxy/reader.py create mode 100644 tests/test_stream_line_reader.py diff --git a/src/galaxy/api/jsonrpc.py b/src/galaxy/api/jsonrpc.py index eb40ab3..37d0e17 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,14 +69,12 @@ 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 = {} self._notifications = {} self._eof_listeners = [] - self._input_buffer = bytes() - self._processed_input_buffer_it = 0 def register_method(self, name, callback, internal, sensitive_params=False): """ @@ -106,7 +106,7 @@ class Server(): async def run(self): while self._active: try: - data = await self._readline() + data = await self._reader.readline() if not data: self._eof() continue @@ -117,26 +117,6 @@ class Server(): logging.debug("Received %d bytes of data", len(data)) self._handle_input(data) - async def _readline(self): - """Like StreamReader.readline but without limit""" - while True: - # check if there is no unprocessed data in the buffer - if not self._input_buffer or self._processed_input_buffer_it != 0: - chunk = await self._reader.read(1024) - if not chunk: - return bytes() # EOF - self._input_buffer += chunk - - it = self._input_buffer.find(b"\n", self._processed_input_buffer_it) - if it < 0: - self._processed_input_buffer_it = len(self._input_buffer) - continue - - line = self._input_buffer[:it] - self._input_buffer = self._input_buffer[it+1:] - self._processed_input_buffer_it = 0 - return line - def stop(self): self._active = False 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/tests/test_stream_line_reader.py b/tests/test_stream_line_reader.py new file mode 100644 index 0000000..2f81e6c --- /dev/null +++ b/tests/test_stream_line_reader.py @@ -0,0 +1,52 @@ +from unittest.mock import MagicMock + +import pytest + +from galaxy.reader import StreamLineReader +from galaxy.unittest.mock import AsyncMock + +@pytest.fixture() +def stream_reader(): + reader = MagicMock() + reader.read = AsyncMock() + return reader + +@pytest.fixture() +def read(stream_reader): + return stream_reader.read + +@pytest.fixture() +def reader(stream_reader): + return StreamLineReader(stream_reader) + +@pytest.mark.asyncio +async def test_message(reader, read): + read.return_value = b"a\n" + assert await reader.readline() == b"a" + read.assert_called_once() + +@pytest.mark.asyncio +async def test_separate_messages(reader, read): + read.side_effect = [b"a\n", b"b\n"] + assert await reader.readline() == b"a" + assert await reader.readline() == b"b" + assert read.call_count == 2 + +@pytest.mark.asyncio +async def test_connected_messages(reader, read): + read.return_value = b"a\nb\n" + assert await reader.readline() == b"a" + assert await reader.readline() == b"b" + read.assert_called_once() + +@pytest.mark.asyncio +async def test_cut_message(reader, read): + read.side_effect = [b"a", b"b\n"] + assert await reader.readline() == b"ab" + assert read.call_count == 2 + +@pytest.mark.asyncio +async def test_half_message(reader, read): + read.side_effect = [b"a", b""] + assert await reader.readline() == b"" + assert read.call_count == 2 From 2ebdfabd9b92eca29e2caf9b2adc7e7b0d3c9a36 Mon Sep 17 00:00:00 2001 From: Piotr Marzec Date: Fri, 28 Jun 2019 14:49:56 +0200 Subject: [PATCH 22/58] Path of Exile added --- PLATFORM_IDs.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/PLATFORM_IDs.md b/PLATFORM_IDs.md index c58dbc4..d931b1d 100644 --- a/PLATFORM_IDs.md +++ b/PLATFORM_IDs.md @@ -79,4 +79,4 @@ Platform ID list for GOG Galaxy 2.0 Integrations | psvita | Playstation Vita | | nds | Nintendo DS | | 3ds | Nintendo 3DS | - +| pathofexile | Path of Exile | From 7b3965ff4b2df55e90f6b98997d8d383ce57507e Mon Sep 17 00:00:00 2001 From: Aliaksei Paulouski Date: Fri, 28 Jun 2019 15:09:46 +0200 Subject: [PATCH 23/58] Add poe platform --- src/galaxy/api/consts.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/galaxy/api/consts.py b/src/galaxy/api/consts.py index fd5204e..d006714 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. From ff30675a256294d55dd39bd5b38163ecb3138d7b Mon Sep 17 00:00:00 2001 From: Romuald Juchnowicz-Bierbasz Date: Thu, 27 Jun 2019 21:18:16 +0200 Subject: [PATCH 24/58] Do not invoke tick before handshake --- src/galaxy/api/jsonrpc.py | 1 + src/galaxy/api/plugin.py | 20 ++++++++++++-------- tests/test_internal.py | 13 ++++++++++++- 3 files changed, 25 insertions(+), 9 deletions(-) diff --git a/src/galaxy/api/jsonrpc.py b/src/galaxy/api/jsonrpc.py index 37d0e17..87bff71 100644 --- a/src/galaxy/api/jsonrpc.py +++ b/src/galaxy/api/jsonrpc.py @@ -116,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 22eb9d4..43eb95f 100644 --- a/src/galaxy/api/plugin.py +++ b/src/galaxy/api/plugin.py @@ -38,6 +38,7 @@ class Plugin: self._feature_methods = OrderedDict() self._active = True + self._pass_control_task = None self._reader, self._writer = reader, writer self._handshake_token = handshake_token @@ -210,15 +211,17 @@ class Plugin: 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()) + 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") @@ -236,6 +239,7 @@ class Plugin: def _initialize_cache(self, data: Dict): self._persistent_cache = data self.handshake_complete() + self._pass_control_task = asyncio.create_task(self._pass_control()) @staticmethod def _ping(): diff --git a/tests/test_internal.py b/tests/test_internal.py index d381f4f..ec3dd77 100644 --- a/tests/test_internal.py +++ b/tests/test_internal.py @@ -62,7 +62,18 @@ def test_ping(plugin, read, write): "result": None } -def test_tick(plugin, read): +def test_tick_before_handshake(plugin, read): read.side_effect = [b""] asyncio.run(plugin.run()) + plugin.tick.assert_not_called() + +def test_tick_after_handshake(plugin, read): + request = { + "jsonrpc": "2.0", + "id": "6", + "method": "initialize_cache", + "params": {"data": {}} + } + read.side_effect = [json.dumps(request).encode() + b"\n", b""] + asyncio.run(plugin.run()) plugin.tick.assert_called_with() From 48e17824842dcd2468214763c83c75f6e0d2101a Mon Sep 17 00:00:00 2001 From: Romuald Juchnowicz-Bierbasz Date: Thu, 27 Jun 2019 18:35:56 +0200 Subject: [PATCH 25/58] SDK-2893: Optional game time and last played --- src/galaxy/api/types.py | 4 ++-- tests/test_game_times.py | 8 ++++++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/src/galaxy/api/types.py b/src/galaxy/api/types.py index fb3b908..21466ac 100644 --- a/src/galaxy/api/types.py +++ b/src/galaxy/api/types.py @@ -204,5 +204,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/tests/test_game_times.py b/tests/test_game_times.py index 8f5d5ae..a9f11f7 100644 --- a/tests/test_game_times.py +++ b/tests/test_game_times.py @@ -16,7 +16,8 @@ def test_success(plugin, read, write): read.side_effect = [json.dumps(request).encode() + b"\n", b""] plugin.get_game_times.coro.return_value = [ GameTime("3", 60, 1549550504), - GameTime("5", 10, 1549550502) + GameTime("5", 10, None), + GameTime("7", None, 1549550502), ] asyncio.run(plugin.run()) plugin.get_game_times.assert_called_with() @@ -35,7 +36,10 @@ def test_success(plugin, read, write): { "game_id": "5", "time_played": 10, - "last_played_time": 1549550502 + }, + { + "game_id": "7", + "last_played_time": 1549550502 } ] } From c364b716f4301a32a208a7576105610e75f9e4cb Mon Sep 17 00:00:00 2001 From: Romuald Juchnowicz-Bierbasz Date: Mon, 1 Jul 2019 13:16:08 +0200 Subject: [PATCH 26/58] Increment version --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 73b51c8..6b82ec5 100644 --- a/setup.py +++ b/setup.py @@ -2,7 +2,7 @@ from setuptools import setup, find_packages setup( name="galaxy.plugin.api", - version="0.39", + version="0.40", description="GOG Galaxy Integrations Python API", author='Galaxy team', author_email='galaxy@gog.com', From 9d9376286748b2d79c5bc0602a803114f223cbf6 Mon Sep 17 00:00:00 2001 From: Mieszko Banczerowski Date: Mon, 1 Jul 2019 14:32:23 +0200 Subject: [PATCH 27/58] Workaround for removing creds on push_cache --- src/galaxy/api/plugin.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/galaxy/api/plugin.py b/src/galaxy/api/plugin.py index 43eb95f..bfa1d75 100644 --- a/src/galaxy/api/plugin.py +++ b/src/galaxy/api/plugin.py @@ -268,6 +268,7 @@ 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) -> None: From 2db9d0f38392208ff9e908e15273341b8e9e32cc Mon Sep 17 00:00:00 2001 From: Romuald Juchnowicz-Bierbasz Date: Mon, 1 Jul 2019 14:35:05 +0200 Subject: [PATCH 28/58] Increment version --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 6b82ec5..2a3bc00 100644 --- a/setup.py +++ b/setup.py @@ -2,7 +2,7 @@ from setuptools import setup, find_packages setup( name="galaxy.plugin.api", - version="0.40", + version="0.40.1", description="GOG Galaxy Integrations Python API", author='Galaxy team', author_email='galaxy@gog.com', From f6b5a12b24bc6bc07b8219a30e1facf151dd8fb5 Mon Sep 17 00:00:00 2001 From: Romuald Juchnowicz-Bierbasz Date: Wed, 3 Jul 2019 13:42:06 +0200 Subject: [PATCH 29/58] SDK-2932: Remove github deployment (use mirroring) --- jenkins/release.groovy | 14 -------------- jenkins/release.py | 26 -------------------------- jenkins/requirements.txt | 1 - 3 files changed, 41 deletions(-) delete mode 100644 jenkins/release.groovy delete mode 100644 jenkins/release.py delete mode 100644 jenkins/requirements.txt 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 From c083a3089a6738f5fed01bb843961e523fc21132 Mon Sep 17 00:00:00 2001 From: rbierbasz-gog <52658196+rbierbasz-gog@users.noreply.github.com> Date: Mon, 8 Jul 2019 15:40:25 +0200 Subject: [PATCH 30/58] Create .travis.yml --- .travis.yml | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 .travis.yml 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 From 33c630225d45c21490a36aa11a5ca7959b9f7c1e Mon Sep 17 00:00:00 2001 From: "Steven M. Vascellaro" Date: Fri, 12 Jul 2019 04:39:37 -0400 Subject: [PATCH 31/58] README.md cleanup (#17) --- README.md | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index d5ae966..60896ba 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,12 @@ # 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: @@ -28,8 +28,8 @@ Each integration can implement only one platform. Each integration must declare ## Basic usage -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 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,8 +61,8 @@ 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 [exemplary 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 @@ -79,7 +79,7 @@ In order to be found by GOG Galaxy 2.0 an integration folder should be placed in ### Manifest -Obligatory JSON file to be placed in a integration folder. +Obligatory JSON file to be placed in an integration folder. ```json { @@ -104,11 +104,11 @@ Obligatory JSON file to be placed in a 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 following 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: From 0d0f657240f154310960c6cd260b5545b8b3bdb4 Mon Sep 17 00:00:00 2001 From: Romuald Juchnowicz-Bierbasz Date: Fri, 5 Jul 2019 18:43:28 +0200 Subject: [PATCH 32/58] SDK-2930: Refactor http module --- src/galaxy/http.py | 88 +++++++++++++++++++++++++++++----------------- tests/test_http.py | 37 +++++++++++++++++++ 2 files changed, 92 insertions(+), 33 deletions(-) create mode 100644 tests/test_http.py diff --git a/src/galaxy/http.py b/src/galaxy/http.py index 667f55a..65cfd22 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 @@ -13,43 +14,64 @@ from galaxy.api.errors import ( class HttpClient: + """Deprecated""" 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) + 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", 20) + return aiohttp.TCPConnector(*args, **kwargs) + + +def create_client_session(*args, **kwargs): + kwargs.setdefault("connector", create_tcp_connector()) + kwargs.setdefault("timeout", aiohttp.ClientTimeout(total=60)) + 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/tests/test_http.py b/tests/test_http.py new file mode 100644 index 0000000..b9f2a3e --- /dev/null +++ b/tests/test_http.py @@ -0,0 +1,37 @@ +import asyncio +from http import HTTPStatus + +import aiohttp +import pytest + +from galaxy.api.errors import ( + AccessDenied, AuthenticationRequired, BackendTimeout, BackendNotAvailable, BackendError, NetworkError, + TooManyRequests, UnknownBackendResponse, UnknownError +) +from galaxy.http import handle_exception + +request_info = aiohttp.RequestInfo("http://o.pl", "GET", {}) + +@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 + From cc63c24bdedf83abcd7504b9a97e9fb69e69b46f Mon Sep 17 00:00:00 2001 From: Romuald Juchnowicz-Bierbasz Date: Fri, 5 Jul 2019 18:44:56 +0200 Subject: [PATCH 33/58] Increment version --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 2a3bc00..f3ee311 100644 --- a/setup.py +++ b/setup.py @@ -2,7 +2,7 @@ from setuptools import setup, find_packages setup( name="galaxy.plugin.api", - version="0.40.1", + version="0.41", description="GOG Galaxy Integrations Python API", author='Galaxy team', author_email='galaxy@gog.com', From 630d878a3c7c065bd592d52a404851df716f6800 Mon Sep 17 00:00:00 2001 From: Romuald Juchnowicz-Bierbasz Date: Fri, 12 Jul 2019 11:46:24 +0200 Subject: [PATCH 34/58] Use constants --- src/galaxy/http.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/galaxy/http.py b/src/galaxy/http.py index 65cfd22..1e6db79 100644 --- a/src/galaxy/http.py +++ b/src/galaxy/http.py @@ -13,9 +13,13 @@ from galaxy.api.errors import ( ) +DEFAULT_LIMIT = 20 +DEFAULT_TIMEOUT = 60 # seconds + + class HttpClient: """Deprecated""" - def __init__(self, limit=20, timeout=aiohttp.ClientTimeout(total=60), cookie_jar=None): + 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) @@ -31,13 +35,13 @@ 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", 20) + 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=60)) + kwargs.setdefault("timeout", aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT)) kwargs.setdefault("raise_for_status", True) return aiohttp.ClientSession(*args, **kwargs) From ce193f39bc8662c6c9c3fba9c6f40f95d7280919 Mon Sep 17 00:00:00 2001 From: Romuald Bierbasz Date: Wed, 17 Jul 2019 15:27:27 +0200 Subject: [PATCH 35/58] SDK-2933: Add shutdown_client notification --- src/galaxy/api/consts.py | 1 + src/galaxy/api/plugin.py | 10 ++++++++++ tests/conftest.py | 3 ++- tests/test_shutdown_platform_client.py | 15 +++++++++++++++ 4 files changed, 28 insertions(+), 1 deletion(-) create mode 100644 tests/test_shutdown_platform_client.py diff --git a/src/galaxy/api/consts.py b/src/galaxy/api/consts.py index d006714..437f293 100644 --- a/src/galaxy/api/consts.py +++ b/src/galaxy/api/consts.py @@ -98,6 +98,7 @@ class Feature(Enum): ImportUsers = "ImportUsers" VerifyGame = "VerifyGame" ImportFriends = "ImportFriends" + ShutdownPlatformClient = "ShutdownPlatformClient" class LicenseType(Enum): diff --git a/src/galaxy/api/plugin.py b/src/galaxy/api/plugin.py index bfa1d75..34898dc 100644 --- a/src/galaxy/api/plugin.py +++ b/src/galaxy/api/plugin.py @@ -107,6 +107,11 @@ class Plugin: self.uninstall_game, feature=Feature.UninstallGame ) + self._register_notification( + "shutdown_platform_client", + self.shutdown_platform_client, + feature=Feature.ShutdownPlatformClient + ) self._register_method( "import_friends", self.get_friends, @@ -718,6 +723,11 @@ 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 get_friends(self) -> List[FriendInfo]: """Override this method to return the friends list of the currently authenticated user. diff --git a/tests/conftest.py b/tests/conftest.py index d94a9f3..0f2fe22 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -48,7 +48,8 @@ def plugin(reader, writer): "get_rooms", "get_room_history_from_message", "get_room_history_from_timestamp", - "get_game_times" + "get_game_times", + "shutdown_platform_client" ) methods = ( diff --git a/tests/test_shutdown_platform_client.py b/tests/test_shutdown_platform_client.py new file mode 100644 index 0000000..e6e8eeb --- /dev/null +++ b/tests/test_shutdown_platform_client.py @@ -0,0 +1,15 @@ +import json + +import pytest + +@pytest.mark.asyncio +async def test_success(plugin, read): + request = { + "jsonrpc": "2.0", + "method": "shutdown_platform_client" + } + + read.side_effect = [json.dumps(request).encode() + b"\n", b""] + plugin.shutdown_platform_client.return_value = None + await plugin.run() + plugin.shutdown_platform_client.assert_called_with() From 10ecef791f0b95854a94e5af7fe28aded3636ce9 Mon Sep 17 00:00:00 2001 From: Romuald Juchnowicz-Bierbasz Date: Wed, 17 Jul 2019 15:35:38 +0200 Subject: [PATCH 36/58] Increment version --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index f3ee311..12d8e61 100644 --- a/setup.py +++ b/setup.py @@ -2,7 +2,7 @@ from setuptools import setup, find_packages setup( name="galaxy.plugin.api", - version="0.41", + version="0.42", description="GOG Galaxy Integrations Python API", author='Galaxy team', author_email='galaxy@gog.com', From 49eb10ac8a1fd93fcbf91ef34791795d29cb2766 Mon Sep 17 00:00:00 2001 From: "Steven M. Vascellaro" Date: Thu, 18 Jul 2019 05:26:47 -0400 Subject: [PATCH 37/58] Add MIT LICENSE (#19) Based on license from 'galaxy-csharp-demo-game' https://github.com/gogcom/galaxy-csharp-demo-game/blob/da1b7f14536f5afc0373c9e000c543a69694ca50/LICENSE.md --- LICENSE | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 LICENSE 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. From 53b30627196c0f9bf81b5165be27a8f1adcfc983 Mon Sep 17 00:00:00 2001 From: Romuald Juchnowicz-Bierbasz Date: Tue, 23 Jul 2019 17:08:48 +0200 Subject: [PATCH 38/58] SDK-2951: Use WindowsProactorEventLoopPolicy on Windows --- src/galaxy/api/plugin.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/galaxy/api/plugin.py b/src/galaxy/api/plugin.py index 34898dc..96bdd7c 100644 --- a/src/galaxy/api/plugin.py +++ b/src/galaxy/api/plugin.py @@ -894,6 +894,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") From bfb63a42bd37bdfb7e9227b745dc3c6616d62588 Mon Sep 17 00:00:00 2001 From: Romuald Juchnowicz-Bierbasz Date: Tue, 23 Jul 2019 17:14:09 +0200 Subject: [PATCH 39/58] SDK-2951: Add create_task method --- src/galaxy/api/plugin.py | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/src/galaxy/api/plugin.py b/src/galaxy/api/plugin.py index 96bdd7c..31e4da2 100644 --- a/src/galaxy/api/plugin.py +++ b/src/galaxy/api/plugin.py @@ -5,6 +5,7 @@ import logging.handlers import dataclasses from enum import Enum from collections import OrderedDict +from itertools import count import sys from typing import Any, List, Dict, Optional, Union @@ -56,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) @@ -220,6 +224,24 @@ class Plugin: if self._pass_control_task is not None: await self._pass_control_task + 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: @@ -233,6 +255,8 @@ class Plugin: self._server.stop() self._active = False self.shutdown() + for task in self._tasks.values(): + task.cancel() def _get_capabilities(self): return { From 223adf6a384c438552be697467c9495dc591c448 Mon Sep 17 00:00:00 2001 From: Rafal Makagon Date: Wed, 24 Jul 2019 10:34:22 +0200 Subject: [PATCH 40/58] Remove chat and users --- setup.py | 2 +- src/galaxy/api/consts.py | 8 - src/galaxy/api/plugin.py | 109 +----------- src/galaxy/api/types.py | 56 +------ tests/conftest.py | 6 - tests/test_chat.py | 354 --------------------------------------- tests/test_features.py | 26 +-- tests/test_users.py | 69 -------- 8 files changed, 5 insertions(+), 625 deletions(-) delete mode 100644 tests/test_chat.py delete mode 100644 tests/test_users.py diff --git a/setup.py b/setup.py index 12d8e61..9fc0cb2 100644 --- a/setup.py +++ b/setup.py @@ -2,7 +2,7 @@ from setuptools import setup, find_packages setup( name="galaxy.plugin.api", - version="0.42", + version="0.43", description="GOG Galaxy Integrations Python API", author='Galaxy team', author_email='galaxy@gog.com', diff --git a/src/galaxy/api/consts.py b/src/galaxy/api/consts.py index 437f293..19beba4 100644 --- a/src/galaxy/api/consts.py +++ b/src/galaxy/api/consts.py @@ -117,11 +117,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/plugin.py b/src/galaxy/api/plugin.py index 31e4da2..dbe746b 100644 --- a/src/galaxy/api/plugin.py +++ b/src/galaxy/api/plugin.py @@ -10,12 +10,12 @@ import sys from typing import Any, List, Dict, Optional, Union -from galaxy.api.types import Achievement, Game, LocalGame, FriendInfo, GameTime, UserInfo, Room +from galaxy.api.types import Achievement, Game, LocalGame, FriendInfo, GameTime from galaxy.api.jsonrpc import Server, NotificationClient, ApplicationError from galaxy.api.consts import Feature from galaxy.api.errors import UnknownError, ImportInProgress -from galaxy.api.types import Authentication, NextStep, Message +from galaxy.api.types import Authentication, NextStep class JSONEncoder(json.JSONEncoder): @@ -122,40 +122,6 @@ class Plugin: 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, @@ -441,26 +407,6 @@ class Plugin: params = {"user_id": user_id} self._notification_client.notify("friend_removed", params) - def update_room( - self, - room_id: str, - unread_message_count: Optional[int]=None, - new_messages: Optional[List[Message]]=None - ) -> 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) -> None: """Notify the client to update game time for a game. @@ -772,57 +718,6 @@ 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) -> None: - """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) -> None: - """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) -> List[Message]: - """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) -> List[Message]: - """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 diff --git a/src/galaxy/api/types.py b/src/galaxy/api/types.py index 21466ac..ace0814 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(): @@ -130,34 +130,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 +140,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 diff --git a/tests/conftest.py b/tests/conftest.py index 0f2fe22..4c74e42 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -42,12 +42,6 @@ def plugin(reader, writer): "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", "shutdown_platform_client" ) diff --git a/tests/test_chat.py b/tests/test_chat.py deleted file mode 100644 index db840b4..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, read, write): - request = { - "jsonrpc": "2.0", - "id": "3", - "method": "send_message", - "params": { - "room_id": "14", - "message": "Hello!" - } - } - - read.side_effect = [json.dumps(request).encode() + b"\n", b""] - 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, read, write, error, code, message): - request = { - "jsonrpc": "2.0", - "id": "6", - "method": "send_message", - "params": { - "room_id": "15", - "message": "Bye" - } - } - - read.side_effect = [json.dumps(request).encode() + b"\n", b""] - 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, read, write): - request = { - "jsonrpc": "2.0", - "id": "7", - "method": "mark_as_read", - "params": { - "room_id": "14", - "last_message_id": "67" - } - } - - read.side_effect = [json.dumps(request).encode() + b"\n", b""] - 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, read, write, error, code, message): - request = { - "jsonrpc": "2.0", - "id": "4", - "method": "mark_as_read", - "params": { - "room_id": "18", - "last_message_id": "7" - } - } - - read.side_effect = [json.dumps(request).encode() + b"\n", b""] - 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, read, write): - request = { - "jsonrpc": "2.0", - "id": "2", - "method": "import_rooms" - } - - read.side_effect = [json.dumps(request).encode() + b"\n", b""] - 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, read, write): - request = { - "jsonrpc": "2.0", - "id": "9", - "method": "import_rooms" - } - - read.side_effect = [json.dumps(request).encode() + b"\n", b""] - 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, read, write): - request = { - "jsonrpc": "2.0", - "id": "2", - "method": "import_room_history_from_message", - "params": { - "room_id": "34", - "message_id": "66" - } - } - - read.side_effect = [json.dumps(request).encode() + b"\n", b""] - 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, read, write, error, code, message): - request = { - "jsonrpc": "2.0", - "id": "7", - "method": "import_room_history_from_message", - "params": { - "room_id": "33", - "message_id": "88" - } - } - - read.side_effect = [json.dumps(request).encode() + b"\n", b""] - 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, read, write): - request = { - "jsonrpc": "2.0", - "id": "7", - "method": "import_room_history_from_timestamp", - "params": { - "room_id": "12", - "from_timestamp": 1549454835 - } - } - - read.side_effect = [json.dumps(request).encode() + b"\n", b""] - 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, read, write): - request = { - "jsonrpc": "2.0", - "id": "3", - "method": "import_room_history_from_timestamp", - "params": { - "room_id": "10", - "from_timestamp": 1549454800 - } - } - - read.side_effect = [json.dumps(request).encode() + b"\n", b""] - 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_features.py b/tests/test_features.py index 7f11c17..dce5fb0 100644 --- a/tests/test_features.py +++ b/tests/test_features.py @@ -18,28 +18,4 @@ def test_one_method_feature(): 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): - pass - async def mark_as_read(self, room_id, last_message_id): - 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): - 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 plugin.features == [Feature.ImportOwnedGames] \ No newline at end of file diff --git a/tests/test_users.py b/tests/test_users.py deleted file mode 100644 index 47837ef..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, read, write): - request = { - "jsonrpc": "2.0", - "id": "8", - "method": "import_user_infos", - "params": { - "user_id_list": ["13"] - } - } - - read.side_effect = [json.dumps(request).encode() + b"\n", b""] - 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, read, write): - request = { - "jsonrpc": "2.0", - "id": "12", - "method": "import_user_infos", - "params": { - "user_id_list": ["10", "11", "12"] - } - } - - read.side_effect = [json.dumps(request).encode() + b"\n", b""] - 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" - } - } From 789415d31b6ab12f2ea7a4b8925a080d55b9a4ef Mon Sep 17 00:00:00 2001 From: Aleksej Pawlowskij Date: Wed, 24 Jul 2019 14:07:09 +0200 Subject: [PATCH 41/58] SDK-2974: add process info tools --- requirements.txt | 3 +- src/galaxy/api/types.py | 1 - src/galaxy/proc_tools.py | 91 ++++++++++++++++++++++++++++++++++++++++ src/galaxy/tools.py | 2 + 4 files changed, 95 insertions(+), 2 deletions(-) create mode 100644 src/galaxy/proc_tools.py diff --git a/requirements.txt b/requirements.txt index 1e99e14..f2e2e90 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,4 +5,5 @@ pytest-mock==1.10.3 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/src/galaxy/api/types.py b/src/galaxy/api/types.py index ace0814..37d55a3 100644 --- a/src/galaxy/api/types.py +++ b/src/galaxy/api/types.py @@ -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] diff --git a/src/galaxy/proc_tools.py b/src/galaxy/proc_tools.py new file mode 100644 index 0000000..bba61d3 --- /dev/null +++ b/src/galaxy/proc_tools.py @@ -0,0 +1,91 @@ +import platform +from dataclasses import dataclass +from typing import Iterable, NewType, Optional, Set + + +def is_windows(): + return platform.system() == "Windows" + + +ProcessId = NewType("ProcessId", int) + + +@dataclass +class ProcessInfo: + pid: ProcessId + binary_path: Optional[str] + + +if is_windows(): + 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) -> Set[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()) + + return 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 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[ProcessInfo]: + for pid in pids(): + yield get_process_info(pid) 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: From c9e190772c7bc1da531658065ea3c1700f7256cc Mon Sep 17 00:00:00 2001 From: Aleksej Pawlowskij Date: Fri, 26 Jul 2019 16:28:47 +0200 Subject: [PATCH 42/58] Increment version --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 9fc0cb2..9095da3 100644 --- a/setup.py +++ b/setup.py @@ -2,7 +2,7 @@ from setuptools import setup, find_packages setup( name="galaxy.plugin.api", - version="0.43", + version="0.44", description="GOG Galaxy Integrations Python API", author='Galaxy team', author_email='galaxy@gog.com', From 49ae2beab9cc48e55d7a5e90e39d43789fb823db Mon Sep 17 00:00:00 2001 From: Aleksej Pawlowskij Date: Mon, 29 Jul 2019 11:24:08 +0200 Subject: [PATCH 43/58] SDK-2983: Split entry methods from feature detection --- src/galaxy/api/plugin.py | 134 ++++++++++++++++++--------------------- tests/conftest.py | 1 + tests/test_features.py | 38 +++++++++-- 3 files changed, 94 insertions(+), 79 deletions(-) diff --git a/src/galaxy/api/plugin.py b/src/galaxy/api/plugin.py index dbe746b..23d3f33 100644 --- a/src/galaxy/api/plugin.py +++ b/src/galaxy/api/plugin.py @@ -1,21 +1,18 @@ import asyncio +import dataclasses import json import logging import logging.handlers -import dataclasses -from enum import Enum -from collections import OrderedDict -from itertools import count 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 Any, List, Dict, Optional, Union - -from galaxy.api.types import Achievement, Game, LocalGame, FriendInfo, GameTime - -from galaxy.api.jsonrpc import Server, NotificationClient, ApplicationError from galaxy.api.consts import Feature -from galaxy.api.errors import UnknownError, ImportInProgress -from galaxy.api.types import Authentication, NextStep +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): @@ -24,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 @@ -32,12 +30,13 @@ 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 @@ -50,6 +49,7 @@ class Plugin: def eof_handler(): self._shutdown() + self._server.register_eof(eof_handler) self._achievements_import_in_progress = False @@ -85,63 +85,47 @@ class Plugin: self._register_method( "import_owned_games", self.get_owned_games, - result_name="owned_games", - feature=Feature.ImportOwnedGames + result_name="owned_games" ) + self._detect_feature(Feature.ImportOwnedGames, ["get_owned_games"]) + 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_notification( - "shutdown_platform_client", - self.shutdown_platform_client, - feature=Feature.ShutdownPlatformClient - ) - self._register_method( - "import_friends", - self.get_friends, - result_name="friend_info_list", - feature=Feature.ImportFriends - ) - 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="unlocked_achievements" ) + self._detect_feature(Feature.ImportAchievements, ["get_unlocked_achievements"]) + + self._register_method("start_achievements_import", self.start_achievements_import) + self._detect_feature(Feature.ImportAchievements, ["import_games_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_method("import_friends", self.get_friends, result_name="friend_info_list") + self._detect_feature(Feature.ImportFriends, ["get_friends"]) + + self._register_method("import_game_times", self.get_game_times, result_name="game_times") + self._detect_feature(Feature.ImportGameTime, ["get_game_times"]) + + self._register_method("start_game_times_import", self.start_game_times_import) + self._detect_feature(Feature.ImportGameTime, ["import_game_times"]) @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: @@ -149,13 +133,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) @@ -164,6 +152,7 @@ class Plugin: result_name: result } return result + self._server.register_method(name, method, True, sensitive_params) else: async def method(*args, **kwargs): @@ -173,17 +162,12 @@ 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.""" await self._server.run() @@ -192,6 +176,7 @@ class Plugin: 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 @@ -524,7 +509,7 @@ class Plugin: raise NotImplementedError() async def pass_login_credentials(self, step: str, credentials: Dict[str, str], cookies: List[Dict[str, str]]) \ - -> Union[NextStep, Authentication]: + -> 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 @@ -607,6 +592,7 @@ class Plugin: :param game_ids: ids of the games for which to import unlocked achievements """ + async def import_game_achievements(game_id): try: achievements = await self.get_unlocked_achievements(game_id) diff --git a/tests/conftest.py b/tests/conftest.py index 4c74e42..9cd4b9a 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -58,6 +58,7 @@ def plugin(reader, writer): 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_features.py b/tests/test_features.py index dce5fb0..1b518db 100644 --- a/tests/test_features.py +++ b/tests/test_features.py @@ -1,21 +1,49 @@ +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 + } + 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] \ No newline at end of file + assert plugin.features == [Feature.ImportOwnedGames] + + +def test_multi_features(): + class PluginImpl(Plugin): # pylint: disable=abstract-method + async def get_owned_games(self): + pass + + async def import_games_achievements(self, game_ids) -> None: + pass + + async def start_game_times_import(self, game_ids) -> None: + pass + + plugin = PluginImpl(Platform.Generic, "0.1", None, None, None) + assert set(plugin.features) == {Feature.ImportAchievements, Feature.ImportOwnedGames} From d95aacb9d8c9bdaa73ecb317edc1e73eb41334ca Mon Sep 17 00:00:00 2001 From: Mesco Date: Wed, 31 Jul 2019 17:52:43 +0200 Subject: [PATCH 44/58] Fix typo in docstring --- src/galaxy/api/plugin.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/galaxy/api/plugin.py b/src/galaxy/api/plugin.py index 23d3f33..1af8442 100644 --- a/src/galaxy/api/plugin.py +++ b/src/galaxy/api/plugin.py @@ -367,7 +367,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(): From a9acb7a0dbaa49cba09e2347548df37f62beab00 Mon Sep 17 00:00:00 2001 From: Romuald Bierbasz Date: Fri, 2 Aug 2019 10:10:58 +0200 Subject: [PATCH 45/58] SDK-2931: Refactor import methods --- src/galaxy/api/plugin.py | 190 +++++++++------------- src/galaxy/unittest/mock.py | 18 ++- tests/__init__.py | 18 +++ tests/conftest.py | 11 +- tests/test_achievements.py | 299 ++++++++++++++++++---------------- tests/test_features.py | 6 +- tests/test_game_times.py | 312 ++++++++++++++++++++---------------- 7 files changed, 458 insertions(+), 396 deletions(-) diff --git a/src/galaxy/api/plugin.py b/src/galaxy/api/plugin.py index 1af8442..c77002f 100644 --- a/src/galaxy/api/plugin.py +++ b/src/galaxy/api/plugin.py @@ -89,16 +89,9 @@ class Plugin: ) self._detect_feature(Feature.ImportOwnedGames, ["get_owned_games"]) - self._register_method( - "import_unlocked_achievements", - self.get_unlocked_achievements, - result_name="unlocked_achievements" - ) + self._register_method("start_achievements_import", self._start_achievements_import) self._detect_feature(Feature.ImportAchievements, ["get_unlocked_achievements"]) - self._register_method("start_achievements_import", self.start_achievements_import) - self._detect_feature(Feature.ImportAchievements, ["import_games_achievements"]) - self._register_method("import_local_games", self.get_local_games, result_name="local_games") self._detect_feature(Feature.ImportInstalledGames, ["get_local_games"]) @@ -117,11 +110,8 @@ class Plugin: self._register_method("import_friends", self.get_friends, result_name="friend_info_list") self._detect_feature(Feature.ImportFriends, ["get_friends"]) - self._register_method("import_game_times", self.get_game_times, result_name="game_times") - self._detect_feature(Feature.ImportGameTime, ["get_game_times"]) - - self._register_method("start_game_times_import", self.start_game_times_import) - self._detect_feature(Feature.ImportGameTime, ["import_game_times"]) + self._register_method("start_game_times_import", self._start_game_times_import) + self._detect_feature(Feature.ImportGameTime, ["get_game_time"]) @property def features(self) -> List[Feature]: @@ -316,26 +306,14 @@ class Plugin: } self._notification_client.notify("achievement_unlocked", params) - def game_achievements_import_success(self, game_id: str, achievements: List[Achievement]) -> None: - """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) -> None: - """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": { @@ -345,9 +323,7 @@ class Plugin: } self._notification_client.notify("game_achievements_import_failure", params) - def achievements_import_finished(self) -> None: - """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) -> None: @@ -400,22 +376,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) -> None: - """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) -> None: - """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": { @@ -425,10 +390,7 @@ class Plugin: } self._notification_client.notify("game_time_import_failure", params) - def game_times_import_finished(self) -> None: - """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) -> None: @@ -557,51 +519,51 @@ 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]) -> None: - """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): + context = await self.prepare_achievements_context(game_ids) + + async def import_game_achievements(game_id, context_): try: - await self.import_games_achievements(game_ids) + 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_finished() self._achievements_import_in_progress = False - asyncio.create_task(import_games_achievements_task(game_ids)) + 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]) -> None: + async def prepare_achievements_context(self, game_ids: List[str]) -> None: + """Override this method to prepare context for get_unlocked_achievements. + + This allows for optimizations like batch requests to platform API. + Default implementation returns None. """ - 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. + return None - :param game_ids: ids of the games for which to import unlocked achievements + 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: + :param context: Value return from :meth:`prepare_achievements_context` + :return: """ - - 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) - - imports = [import_game_achievements(game_id) for game_id in game_ids] - await asyncio.gather(*imports) + raise NotImplementedError() async def get_local_games(self) -> List[LocalGame]: """Override this method to return the list of @@ -704,54 +666,50 @@ class Plugin: """ 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]) -> None: - """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): + context = await self.prepare_game_times_context(game_ids) + + async def import_game_time(game_id, context_): try: - await self.import_game_times(game_ids) + 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_finished() self._game_times_import_in_progress = False - asyncio.create_task(import_game_times_task(game_ids)) + 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]) -> None: + async def prepare_game_times_context(self, game_ids: List[str]) -> None: + """Override this method to prepare context for get_game_time. + This allows for optimizations like batch requests to platform API. + Default implementation returns None. """ - 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. + return None - :param game_ids: ids of the games for which the game time is imported + 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: + :param context: Value return from :meth:`prepare_game_times_context` + :return: """ - 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) + raise NotImplementedError() def create_and_run_plugin(plugin_class, argv): diff --git a/src/galaxy/unittest/mock.py b/src/galaxy/unittest/mock.py index 264c3fa..a3051cc 100644 --- a/src/galaxy/unittest/mock.py +++ b/src/galaxy/unittest/mock.py @@ -1,12 +1,24 @@ -from asyncio import coroutine +import asyncio from unittest.mock import MagicMock + class AsyncMock(MagicMock): async def __call__(self, *args, **kwargs): return super(AsyncMock, self).__call__(*args, **kwargs) + def coroutine_mock(): 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..2174b66 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -0,0 +1,18 @@ +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 9cd4b9a..b819f8e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -6,7 +6,7 @@ import pytest from galaxy.api.plugin import Plugin from galaxy.api.consts import Platform -from galaxy.unittest.mock import AsyncMock, coroutine_mock +from galaxy.unittest.mock import AsyncMock, coroutine_mock, skip_loop @pytest.fixture() def reader(): @@ -15,11 +15,12 @@ def reader(): yield stream @pytest.fixture() -def writer(): +async def writer(): stream = MagicMock(name="stream_writer") stream.write = MagicMock() - stream.drain = AsyncMock() + stream.drain = AsyncMock(return_value=None) yield stream + await skip_loop(1) # drain @pytest.fixture() def read(reader): @@ -36,13 +37,15 @@ def plugin(reader, writer): "handshake_complete", "authenticate", "get_owned_games", + "prepare_achievements_context", "get_unlocked_achievements", "get_local_games", "launch_game", "install_game", "uninstall_game", "get_friends", - "get_game_times", + "get_game_time", + "prepare_game_times_context", "shutdown_platform_client" ) diff --git a/tests/test_achievements.py b/tests/test_achievements.py index 9a6ec30..311554d 100644 --- a/tests/test_achievements.py +++ b/tests/test_achievements.py @@ -1,94 +1,210 @@ -import asyncio import json -from unittest.mock import call +from unittest.mock import MagicMock 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, read, write): + +# TODO replace AsyncMocks with MagicMocks in conftest and use async_return_value +@pytest.fixture() +def reader(): + stream = MagicMock(name="stream_reader") + stream.read = MagicMock() + yield stream + + +@pytest.mark.asyncio +async def test_get_unlocked_achievements_success(plugin, read, write): + plugin.prepare_achievements_context.coro.return_value = 5 request = { "jsonrpc": "2.0", "id": "3", - "method": "import_unlocked_achievements", + "method": "start_achievements_import", "params": { - "game_id": "14" + "game_ids": ["14"] } } - read.side_effect = [json.dumps(request).encode() + b"\n", b""] + read.side_effect = [async_return_value(create_message(request)), async_return_value(b"", 10)] plugin.get_unlocked_achievements.coro.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) - 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, read, 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): request = { "jsonrpc": "2.0", "id": "3", - "method": "import_unlocked_achievements", + "method": "start_achievements_import", "params": { - "game_id": "14" + "game_ids": ["14"] } } - read.side_effect = [json.dumps(request).encode() + b"\n", b""] - 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.coro.side_effect = exception + await plugin.run() plugin.get_unlocked_achievements.assert_called() - response = json.loads(write.call_args[0][0]) - 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.coro.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): + 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 +218,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_features.py b/tests/test_features.py index 1b518db..47e561d 100644 --- a/tests/test_features.py +++ b/tests/test_features.py @@ -39,11 +39,11 @@ def test_multi_features(): async def get_owned_games(self): pass - async def import_games_achievements(self, game_ids) -> None: + async def get_unlocked_achievements(self, game_id, context): pass - async def start_game_times_import(self, game_ids) -> None: + async def get_game_time(self, game_id, context): pass plugin = PluginImpl(Platform.Generic, "0.1", None, None, None) - assert set(plugin.features) == {Feature.ImportAchievements, Feature.ImportOwnedGames} + assert set(plugin.features) == {Feature.ImportAchievements, Feature.ImportOwnedGames, Feature.ImportGameTime} diff --git a/tests/test_game_times.py b/tests/test_game_times.py index a9f11f7..f17c614 100644 --- a/tests/test_game_times.py +++ b/tests/test_game_times.py @@ -1,71 +1,205 @@ import asyncio import json -from unittest.mock import call +from unittest.mock import MagicMock, 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, read, write): +from tests import create_message, get_messages + +# TODO replace AsyncMocks with MagicMocks in conftest and use async_return_value +@pytest.fixture() +def reader(): + stream = MagicMock(name="stream_reader") + stream.read = MagicMock() + yield stream + + +@pytest.mark.asyncio +async def test_get_game_time_success(plugin, read, write): + plugin.prepare_game_times_context.coro.return_value = "abc" request = { "jsonrpc": "2.0", "id": "3", - "method": "import_game_times" + "method": "start_game_times_import", + "params": { + "game_ids": ["3", "5", "7"] + } } - - read.side_effect = [json.dumps(request).encode() + b"\n", b""] - plugin.get_game_times.coro.return_value = [ + read.side_effect = [async_return_value(create_message(request)), async_return_value(b"", 10)] + plugin.get_game_time.coro.side_effect = [ GameTime("3", 60, 1549550504), GameTime("5", 10, None), 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"), + ]) - 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 - }, - { - "game_id": "5", - "time_played": 10, - }, - { - "game_id": "7", - "last_played_time": 1549550502 + "last_played_time": 1549550504, + "time_played": 60 } - ] + } + }, + { + "jsonrpc": "2.0", + "method": "game_time_import_success", + "params": { + "game_time": { + "game_id": "5", + "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, read, 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): request = { "jsonrpc": "2.0", "id": "3", - "method": "import_game_times" - } - - read.side_effect = [json.dumps(request).encode() + b"\n", b""] - 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 response == { - "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"", 10)] + plugin.get_game_time.coro.side_effect = exception + await plugin.run() + plugin.get_game_time.assert_called() + + 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 + } + ] + + +@pytest.mark.asyncio +async def test_prepare_get_game_time_context_error(plugin, read, write): + plugin.prepare_game_times_context.coro.side_effect = BackendError() + request = { + "jsonrpc": "2.0", + "id": "3", + "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() + + 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): + 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"") + ] + + 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" + } + } + ] + def test_update_game(plugin, write): game_time = GameTime("3", 60, 1549550504) @@ -87,93 +221,3 @@ def test_update_game(plugin, write): } } } - -@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 - } - } - } - -@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]) - - assert response == { - "jsonrpc": "2.0", - "method": "game_time_import_failure", - "params": { - "game_id": "134", - "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") - - 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() From b14595bef5fea5f9e3d9cf6883861c5321759400 Mon Sep 17 00:00:00 2001 From: Romuald Juchnowicz-Bierbasz Date: Fri, 2 Aug 2019 12:20:27 +0200 Subject: [PATCH 46/58] Fix types --- src/galaxy/api/plugin.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/galaxy/api/plugin.py b/src/galaxy/api/plugin.py index c77002f..39152e6 100644 --- a/src/galaxy/api/plugin.py +++ b/src/galaxy/api/plugin.py @@ -546,7 +546,7 @@ class Plugin: self.create_task(import_games_achievements(game_ids, context), "Games unlocked achievements import") self._achievements_import_in_progress = True - async def prepare_achievements_context(self, game_ids: List[str]) -> None: + 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. @@ -693,7 +693,7 @@ class Plugin: self.create_task(import_game_times(game_ids, context), "Game times import") self._game_times_import_in_progress = True - async def prepare_game_times_context(self, game_ids: List[str]) -> None: + 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. From 3bcc674518d70493495cf963ac2a6691a306b643 Mon Sep 17 00:00:00 2001 From: Romuald Bierbasz Date: Fri, 2 Aug 2019 12:28:13 +0200 Subject: [PATCH 47/58] Use MagicMocks with async_return_value and pytest.mark.asyncio --- src/galaxy/unittest/mock.py | 9 +- tests/__init__.py | 1 + tests/conftest.py | 15 +- tests/test_achievements.py | 21 +-- tests/test_authenticate.py | 115 +++++++------- tests/test_chunk_messages.py | 29 ++-- tests/test_friends.py | 112 ++++++------- tests/test_game_times.py | 57 +++---- tests/test_install_game.py | 16 +- tests/test_internal.py | 95 ++++++----- tests/test_launch_game.py | 16 +- tests/test_local_games.py | 124 ++++++++------- tests/test_owned_games.py | 211 +++++++++++++------------ tests/test_persistent_cache.py | 70 ++++---- tests/test_shutdown_platform_client.py | 10 +- tests/test_stream_line_reader.py | 64 ++++---- tests/test_uninstall_game.py | 15 +- 17 files changed, 505 insertions(+), 475 deletions(-) diff --git a/src/galaxy/unittest/mock.py b/src/galaxy/unittest/mock.py index a3051cc..d5fc0af 100644 --- a/src/galaxy/unittest/mock.py +++ b/src/galaxy/unittest/mock.py @@ -3,17 +3,24 @@ 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=asyncio.coroutine(coro)) corofunc.coro = coro return corofunc - async def skip_loop(iterations=1): for _ in range(iterations): await asyncio.sleep(0) diff --git a/tests/__init__.py b/tests/__init__.py index 2174b66..140adbd 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -16,3 +16,4 @@ def get_messages(write_mock): message = json.loads(line) messages.append(message) return messages + diff --git a/tests/conftest.py b/tests/conftest.py index b819f8e..23283a9 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -6,21 +6,19 @@ import pytest from galaxy.api.plugin import Plugin from galaxy.api.consts import Platform -from galaxy.unittest.mock import AsyncMock, coroutine_mock, skip_loop @pytest.fixture() def reader(): stream = MagicMock(name="stream_reader") - stream.read = AsyncMock() + stream.read = MagicMock() yield stream @pytest.fixture() async def writer(): stream = MagicMock(name="stream_writer") stream.write = MagicMock() - stream.drain = AsyncMock(return_value=None) + stream.drain = MagicMock() yield stream - await skip_loop(1) # drain @pytest.fixture() def read(reader): @@ -33,7 +31,7 @@ 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", @@ -46,17 +44,12 @@ def plugin(reader, writer): "get_friends", "get_game_time", "prepare_game_times_context", - "shutdown_platform_client" - ) - - methods = ( + "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") diff --git a/tests/test_achievements.py b/tests/test_achievements.py index 311554d..6bf05ee 100644 --- a/tests/test_achievements.py +++ b/tests/test_achievements.py @@ -1,5 +1,4 @@ import json -from unittest.mock import MagicMock import pytest from pytest import raises @@ -21,17 +20,9 @@ def test_initialization_no_id_nor_name(): Achievement(unlock_time=1234567890) -# TODO replace AsyncMocks with MagicMocks in conftest and use async_return_value -@pytest.fixture() -def reader(): - stream = MagicMock(name="stream_reader") - stream.read = MagicMock() - yield stream - - @pytest.mark.asyncio async def test_get_unlocked_achievements_success(plugin, read, write): - plugin.prepare_achievements_context.coro.return_value = 5 + plugin.prepare_achievements_context.return_value = async_return_value(5) request = { "jsonrpc": "2.0", "id": "3", @@ -41,11 +32,11 @@ async def test_get_unlocked_achievements_success(plugin, read, write): } } read.side_effect = [async_return_value(create_message(request)), async_return_value(b"", 10)] - plugin.get_unlocked_achievements.coro.return_value = [ + 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) - ] + ]) await plugin.run() plugin.prepare_achievements_context.assert_called_with(["14"]) plugin.get_unlocked_achievements.assert_called_with("14", 5) @@ -92,6 +83,7 @@ async def test_get_unlocked_achievements_success(plugin, read, write): (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", @@ -102,7 +94,7 @@ async def test_get_unlocked_achievements_error(exception, code, message, plugin, } read.side_effect = [async_return_value(create_message(request)), async_return_value(b"", 10)] - plugin.get_unlocked_achievements.coro.side_effect = exception + plugin.get_unlocked_achievements.side_effect = exception await plugin.run() plugin.get_unlocked_achievements.assert_called() @@ -132,7 +124,7 @@ async def test_get_unlocked_achievements_error(exception, code, message, plugin, @pytest.mark.asyncio async def test_prepare_get_unlocked_achievements_context_error(plugin, read, write): - plugin.prepare_achievements_context.coro.side_effect = BackendError() + plugin.prepare_achievements_context.side_effect = BackendError() request = { "jsonrpc": "2.0", "id": "3", @@ -158,6 +150,7 @@ async def test_prepare_get_unlocked_achievements_context_error(plugin, read, wri @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", diff --git a/tests/test_authenticate.py b/tests/test_authenticate.py index 1d84c60..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, read, 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" } - - read.side_effect = [json.dumps(request).encode() + b"\n", b""] - 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, read, write): pytest.param(Banned, 105, "Banned", id="banned"), pytest.param(AccessDenied, 106, "Access denied", id="access_denied"), ]) -def test_failure(plugin, read, write, error, code, message): +async def test_failure(plugin, read, write, error, code, message): request = { "jsonrpc": "2.0", "id": "3", "method": "init_authentication" } - read.side_effect = [json.dumps(request).encode() + b"\n", b""] - 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, read, 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, read, write): } } } - read.side_effect = [json.dumps(request).encode() + b"\n", b""] - 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, 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_chunk_messages.py b/tests/test_chunk_messages.py index 68a4b52..340c880 100644 --- a/tests/test_chunk_messages.py +++ b/tests/test_chunk_messages.py @@ -1,7 +1,12 @@ -import asyncio import json -def test_chunked_messages(plugin, read): +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", @@ -11,11 +16,13 @@ def test_chunked_messages(plugin, read): } message = json.dumps(request).encode() + b"\n" - read.side_effect = [message[:5], message[5:], b""] - asyncio.run(plugin.run()) + 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") -def test_joined_messages(plugin, read): + +@pytest.mark.asyncio +async def test_joined_messages(plugin, read): requests = [ { "jsonrpc": "2.0", @@ -34,12 +41,14 @@ def test_joined_messages(plugin, read): ] data = b"".join([json.dumps(request).encode() + b"\n" for request in requests]) - read.side_effect = [data, b""] - asyncio.run(plugin.run()) + 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") -def test_not_finished(plugin, read): + +@pytest.mark.asyncio +async def test_not_finished(plugin, read): request = { "jsonrpc": "2.0", "method": "install_game", @@ -49,6 +58,6 @@ def test_not_finished(plugin, read): } message = json.dumps(request).encode() # no new line - read.side_effect = [message, b""] - asyncio.run(plugin.run()) + 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_friends.py b/tests/test_friends.py index 030f029..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, read, write): +@pytest.mark.asyncio +async def test_get_friends_success(plugin, read, write): request = { "jsonrpc": "2.0", "id": "3", "method": "import_friends" } - read.side_effect = [json.dumps(request).encode() + b"\n", b""] - 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, read, write): +@pytest.mark.asyncio +async def test_get_friends_failure(plugin, read, write): request = { "jsonrpc": "2.0", "id": "3", "method": "import_friends" } - read.side_effect = [json.dumps(request).encode() + b"\n", b""] - 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 f17c614..334d01a 100644 --- a/tests/test_game_times.py +++ b/tests/test_game_times.py @@ -1,6 +1,4 @@ -import asyncio -import json -from unittest.mock import MagicMock, call +from unittest.mock import call import pytest from galaxy.api.types import GameTime @@ -9,17 +7,10 @@ from galaxy.unittest.mock import async_return_value from tests import create_message, get_messages -# TODO replace AsyncMocks with MagicMocks in conftest and use async_return_value -@pytest.fixture() -def reader(): - stream = MagicMock(name="stream_reader") - stream.read = MagicMock() - yield stream - @pytest.mark.asyncio async def test_get_game_time_success(plugin, read, write): - plugin.prepare_game_times_context.coro.return_value = "abc" + plugin.prepare_game_times_context.return_value = async_return_value("abc") request = { "jsonrpc": "2.0", "id": "3", @@ -29,10 +20,10 @@ async def test_get_game_time_success(plugin, read, write): } } read.side_effect = [async_return_value(create_message(request)), async_return_value(b"", 10)] - plugin.get_game_time.coro.side_effect = [ - GameTime("3", 60, 1549550504), - GameTime("5", 10, None), - GameTime("7", None, 1549550502), + 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)), ] await plugin.run() plugin.get_game_time.assert_has_calls([ @@ -92,6 +83,7 @@ async def test_get_game_time_success(plugin, read, write): (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", @@ -101,7 +93,7 @@ async def test_get_game_time_error(exception, code, message, plugin, read, write } } read.side_effect = [async_return_value(create_message(request)), async_return_value(b"", 10)] - plugin.get_game_time.coro.side_effect = exception + plugin.get_game_time.side_effect = exception await plugin.run() plugin.get_game_time.assert_called() @@ -132,7 +124,7 @@ async def test_get_game_time_error(exception, code, message, plugin, read, write @pytest.mark.asyncio async def test_prepare_get_game_time_context_error(plugin, read, write): - plugin.prepare_game_times_context.coro.side_effect = BackendError() + plugin.prepare_game_times_context.side_effect = BackendError() request = { "jsonrpc": "2.0", "id": "3", @@ -158,6 +150,7 @@ async def test_prepare_get_game_time_context_error(plugin, read, write): @pytest.mark.asyncio async def test_import_in_progress(plugin, read, write): + plugin.prepare_game_times_context.return_value = async_return_value(None) requests = [ { "jsonrpc": "2.0", @@ -201,23 +194,21 @@ async def test_import_in_progress(plugin, read, write): ] -def test_update_game(plugin, write): +@pytest.mark.asyncio +async def test_update_game(plugin, write): game_time = GameTime("3", 60, 1549550504) + plugin.update_game_time(game_time) - 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", + "method": "game_time_updated", + "params": { + "game_time": { + "game_id": "3", + "time_played": 60, + "last_played_time": 1549550504 + } } } - } + ] diff --git a/tests/test_install_game.py b/tests/test_install_game.py index 744bb1a..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, read): +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, read): } } - read.side_effect = [json.dumps(request).encode() + b"\n", b""] - 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 ec3dd77..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, read, 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,64 +20,75 @@ def test_get_capabilites(reader, writer, read, write): } token = "token" plugin = PluginImpl(Platform.Generic, "0.1", reader, writer, token) - read.side_effect = [json.dumps(request).encode() + b"\n", b""] - 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, read, write): + +@pytest.mark.asyncio +async def test_shutdown(plugin, read, write): request = { "jsonrpc": "2.0", "id": "5", "method": "shutdown" } - read.side_effect = [json.dumps(request).encode() + b"\n", b""] - 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, read, write): + +@pytest.mark.asyncio +async def test_ping(plugin, read, write): request = { "jsonrpc": "2.0", "id": "7", "method": "ping" } - read.side_effect = [json.dumps(request).encode() + b"\n", b""] - 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_before_handshake(plugin, read): - read.side_effect = [b""] - 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() -def test_tick_after_handshake(plugin, read): + +@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 = [json.dumps(request).encode() + b"\n", b""] - asyncio.run(plugin.run()) + 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 551f7cf..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, read): +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, read): } } - read.side_effect = [json.dumps(request).encode() + b"\n", b""] - 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_local_games.py b/tests/test_local_games.py index b53056b..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, read, 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"")] - read.side_effect = [json.dumps(request).encode() + b"\n", b""] - - 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, read, write): pytest.param(FailedParsingManifest, 200, "Failed parsing manifest", id="failed_parsing") ], ) -def test_failure(plugin, read, write, error, code, message): +async def test_failure(plugin, read, write, error, code, message): request = { "jsonrpc": "2.0", "id": "3", "method": "import_local_games" } - - read.side_effect = [json.dumps(request).encode() + b"\n", b""] - 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 f455914..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, read, 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"")] - read.side_effect = [json.dumps(request).encode() + b"\n", b""] - 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, read, 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, read, write): + +@pytest.mark.asyncio +async def test_failure(plugin, read, write): request = { "jsonrpc": "2.0", "id": "3", "method": "import_owned_games" } - read.side_effect = [json.dumps(request).encode() + b"\n", b""] - 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 d2d9d8c..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, read, 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, read, write, cache_data): "method": "initialize_cache", "params": {"data": cache_data} } - read.side_effect = [json.dumps(request).encode() + b"\n"] + 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 index e6e8eeb..c2dc3f1 100644 --- a/tests/test_shutdown_platform_client.py +++ b/tests/test_shutdown_platform_client.py @@ -1,7 +1,9 @@ -import json - 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 = { @@ -9,7 +11,7 @@ async def test_success(plugin, read): "method": "shutdown_platform_client" } - read.side_effect = [json.dumps(request).encode() + b"\n", b""] - plugin.shutdown_platform_client.return_value = None + 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 index 2f81e6c..64226f5 100644 --- a/tests/test_stream_line_reader.py +++ b/tests/test_stream_line_reader.py @@ -1,52 +1,46 @@ -from unittest.mock import MagicMock - import pytest from galaxy.reader import StreamLineReader -from galaxy.unittest.mock import AsyncMock +from galaxy.unittest.mock import async_return_value + @pytest.fixture() -def stream_reader(): - reader = MagicMock() - reader.read = AsyncMock() - return reader +def stream_line_reader(reader): + return StreamLineReader(reader) -@pytest.fixture() -def read(stream_reader): - return stream_reader.read - -@pytest.fixture() -def reader(stream_reader): - return StreamLineReader(stream_reader) @pytest.mark.asyncio -async def test_message(reader, read): - read.return_value = b"a\n" - assert await reader.readline() == b"a" +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(reader, read): - read.side_effect = [b"a\n", b"b\n"] - assert await reader.readline() == b"a" - assert await reader.readline() == b"b" - assert read.call_count == 2 @pytest.mark.asyncio -async def test_connected_messages(reader, read): - read.return_value = b"a\nb\n" - assert await reader.readline() == b"a" - assert await reader.readline() == b"b" +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(reader, read): - read.side_effect = [b"a", b"b\n"] - assert await reader.readline() == b"ab" - assert read.call_count == 2 @pytest.mark.asyncio -async def test_half_message(reader, read): - read.side_effect = [b"a", b""] - assert await reader.readline() == b"" +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 40a316b..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, read): +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, read): "game_id": "3" } } - - read.side_effect = [json.dumps(request).encode() + b"\n", b""] + 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") From f4ea2af924b1379adf391c321bf2c7cf97d31999 Mon Sep 17 00:00:00 2001 From: Aleksej Pawlowskij Date: Fri, 2 Aug 2019 12:26:44 +0200 Subject: [PATCH 48/58] Make 'handshake_complete' safe --- src/galaxy/api/plugin.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/galaxy/api/plugin.py b/src/galaxy/api/plugin.py index 39152e6..f8cdc61 100644 --- a/src/galaxy/api/plugin.py +++ b/src/galaxy/api/plugin.py @@ -208,7 +208,10 @@ 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 From e33dd09a8df329076124d2d0dc262fba830186d1 Mon Sep 17 00:00:00 2001 From: Romuald Juchnowicz-Bierbasz Date: Fri, 2 Aug 2019 12:57:13 +0200 Subject: [PATCH 49/58] Increment version --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 9095da3..1ecbc99 100644 --- a/setup.py +++ b/setup.py @@ -2,7 +2,7 @@ from setuptools import setup, find_packages setup( name="galaxy.plugin.api", - version="0.44", + version="0.45", description="GOG Galaxy Integrations Python API", author='Galaxy team', author_email='galaxy@gog.com', From f5b9adfbd59573abf580d1d396dee9db1cd160ad Mon Sep 17 00:00:00 2001 From: Romuald Bierbasz Date: Fri, 2 Aug 2019 15:11:05 +0200 Subject: [PATCH 50/58] Add achievements_import_complete and game_times_import_complete --- src/galaxy/api/plugin.py | 12 ++++++++++++ tests/conftest.py | 2 ++ tests/test_achievements.py | 2 ++ tests/test_game_times.py | 2 ++ 4 files changed, 18 insertions(+) diff --git a/src/galaxy/api/plugin.py b/src/galaxy/api/plugin.py index f8cdc61..a23130a 100644 --- a/src/galaxy/api/plugin.py +++ b/src/galaxy/api/plugin.py @@ -545,6 +545,7 @@ class Plugin: 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 @@ -568,6 +569,11 @@ class Plugin: """ 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 games present locally on the users pc. @@ -692,6 +698,7 @@ class Plugin: 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 @@ -714,6 +721,11 @@ class Plugin: """ raise NotImplementedError() + def game_times_import_complete(self): + """Override this method to handle operations after game times import is finished + (like updating cache). + """ + def create_and_run_plugin(plugin_class, argv): """Call this method as an entry point for the implemented integration. diff --git a/tests/conftest.py b/tests/conftest.py index 23283a9..9fa89ab 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -37,6 +37,7 @@ def plugin(reader, writer): "get_owned_games", "prepare_achievements_context", "get_unlocked_achievements", + "achievements_import_complete", "get_local_games", "launch_game", "install_game", @@ -44,6 +45,7 @@ def plugin(reader, writer): "get_friends", "get_game_time", "prepare_game_times_context", + "game_times_import_complete", "shutdown_platform_client", "shutdown", "tick" diff --git a/tests/test_achievements.py b/tests/test_achievements.py index 6bf05ee..1d7abda 100644 --- a/tests/test_achievements.py +++ b/tests/test_achievements.py @@ -40,6 +40,7 @@ async def test_get_unlocked_achievements_success(plugin, read, write): 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 get_messages(write) == [ { @@ -97,6 +98,7 @@ async def test_get_unlocked_achievements_error(exception, code, message, plugin, plugin.get_unlocked_achievements.side_effect = exception await plugin.run() plugin.get_unlocked_achievements.assert_called() + plugin.achievements_import_complete.asert_called_with() assert get_messages(write) == [ { diff --git a/tests/test_game_times.py b/tests/test_game_times.py index 334d01a..877bcc1 100644 --- a/tests/test_game_times.py +++ b/tests/test_game_times.py @@ -31,6 +31,7 @@ async def test_get_game_time_success(plugin, read, write): call("5", "abc"), call("7", "abc"), ]) + plugin.game_times_import_complete.assert_called_once_with() assert get_messages(write) == [ { @@ -96,6 +97,7 @@ async def test_get_game_time_error(exception, code, message, plugin, read, write 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() assert get_messages(write) == [ { From 13a3f7577b180baf72e65757961a14e701687381 Mon Sep 17 00:00:00 2001 From: Romuald Juchnowicz-Bierbasz Date: Fri, 2 Aug 2019 15:13:11 +0200 Subject: [PATCH 51/58] Increment version --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 1ecbc99..1ff0665 100644 --- a/setup.py +++ b/setup.py @@ -2,7 +2,7 @@ from setuptools import setup, find_packages setup( name="galaxy.plugin.api", - version="0.45", + version="0.46", description="GOG Galaxy Integrations Python API", author='Galaxy team', author_email='galaxy@gog.com', From c3bbeee54da8cd334c87afeee74e1fff9a8c140e Mon Sep 17 00:00:00 2001 From: Romuald Bierbasz Date: Fri, 2 Aug 2019 15:15:29 +0200 Subject: [PATCH 52/58] Turn on type checking --- docs/source/conf.py | 2 +- mypy.ini | 2 ++ pytest.ini | 2 +- requirements.txt | 1 + src/galaxy/__init__.py | 2 +- src/galaxy/api/errors.py | 2 +- src/galaxy/proc_tools.py | 19 ++++++++----------- tests/test_http.py | 22 ++++++++++++---------- 8 files changed, 27 insertions(+), 25 deletions(-) create mode 100644 mypy.ini 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/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 f2e2e90..35da1fa 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,6 +2,7 @@ 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 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/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/proc_tools.py b/src/galaxy/proc_tools.py index bba61d3..b0de0bc 100644 --- a/src/galaxy/proc_tools.py +++ b/src/galaxy/proc_tools.py @@ -1,11 +1,8 @@ -import platform +import sys from dataclasses import dataclass -from typing import Iterable, NewType, Optional, Set +from typing import Iterable, NewType, Optional, List, cast -def is_windows(): - return platform.system() == "Windows" - ProcessId = NewType("ProcessId", int) @@ -16,7 +13,7 @@ class ProcessInfo: binary_path: Optional[str] -if is_windows(): +if sys.platform == "win32": from ctypes import byref, sizeof, windll, create_unicode_buffer, FormatError, WinError from ctypes.wintypes import DWORD @@ -25,14 +22,14 @@ if is_windows(): _PROC_ID_T = DWORD list_size = 4096 - def try_get_pids(list_size: int) -> Set[ProcessId]: + 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()) + raise WinError(descr="Failed to get process ID list: %s" % FormatError()) # type: ignore - return proc_id_list[:int(result_size.value / sizeof(_PROC_ID_T()))] + return cast(List[ProcessId], proc_id_list[:int(result_size.value / sizeof(_PROC_ID_T()))]) while True: proc_ids = try_get_pids(list_size) @@ -59,7 +56,7 @@ if is_windows(): exe_path_buffer = create_unicode_buffer(_MAX_PATH) exe_path_len = DWORD(len(exe_path_buffer)) - return exe_path_buffer[:exe_path_len.value] if windll.kernel32.QueryFullProcessImageNameW( + 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 @@ -86,6 +83,6 @@ else: return process_info -def process_iter() -> Iterable[ProcessInfo]: +def process_iter() -> Iterable[Optional[ProcessInfo]]: for pid in pids(): yield get_process_info(pid) diff --git a/tests/test_http.py b/tests/test_http.py index b9f2a3e..09aa136 100644 --- a/tests/test_http.py +++ b/tests/test_http.py @@ -3,6 +3,8 @@ 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, @@ -10,7 +12,7 @@ from galaxy.api.errors import ( ) from galaxy.http import handle_exception -request_info = aiohttp.RequestInfo("http://o.pl", "GET", {}) +request_info = aiohttp.RequestInfo(URL("http://o.pl"), "GET", CIMultiDictProxy(CIMultiDict())) @pytest.mark.parametrize( "aiohttp_exception,expected_exception_type", @@ -18,15 +20,15 @@ request_info = aiohttp.RequestInfo("http://o.pl", "GET", {}) (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.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) ] ) From a76345ff6bb1bcc11b12dafd41ac0eef9f76bff1 Mon Sep 17 00:00:00 2001 From: Romuald Bierbasz Date: Fri, 2 Aug 2019 17:23:56 +0200 Subject: [PATCH 53/58] Docs fixes --- src/galaxy/api/plugin.py | 31 ++++++++++++++++++------------- 1 file changed, 18 insertions(+), 13 deletions(-) diff --git a/src/galaxy/api/plugin.py b/src/galaxy/api/plugin.py index a23130a..c5bf87c 100644 --- a/src/galaxy/api/plugin.py +++ b/src/galaxy/api/plugin.py @@ -270,7 +270,7 @@ class Plugin: """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: @@ -300,7 +300,7 @@ class Plugin: 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 of the game for which to unlock an achievement. :param achievement: achievement to unlock. """ params = { @@ -552,9 +552,11 @@ class Plugin: 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: the ids of the games for which achievements are imported + :return: context """ return None @@ -563,9 +565,9 @@ class Plugin: for the game identified by the provided game_id. This method is called by import task initialized by GOG Galaxy Client. - :param game_id: - :param context: Value return from :meth:`prepare_achievements_context` - :return: + :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() @@ -601,7 +603,7 @@ class Plugin: 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: @@ -619,7 +621,7 @@ class Plugin: 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: @@ -637,7 +639,7 @@ class Plugin: 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: @@ -707,6 +709,9 @@ class Plugin: """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: the ids of the games for which game time are imported + :return: context """ return None @@ -715,13 +720,13 @@ class Plugin: identified by the provided game_id. This method is called by import task initialized by GOG Galaxy Client. - :param game_id: - :param context: Value return from :meth:`prepare_game_times_context` - :return: + :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): + def game_times_import_complete(self) -> None: """Override this method to handle operations after game times import is finished (like updating cache). """ From f5eb32aa19c7b366e3d0791e5c14f09550f88f7b Mon Sep 17 00:00:00 2001 From: Vadim Suharnikov Date: Sat, 3 Aug 2019 13:05:25 +0300 Subject: [PATCH 54/58] Fix a comment typo --- src/galaxy/api/plugin.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/galaxy/api/plugin.py b/src/galaxy/api/plugin.py index c5bf87c..415a448 100644 --- a/src/galaxy/api/plugin.py +++ b/src/galaxy/api/plugin.py @@ -300,7 +300,7 @@ class Plugin: def unlock_achievement(self, game_id: str, achievement: Achievement) -> None: """Notify the client to unlock an achievement for a specific game. - :param game_id: the 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 = { From 4cc8be8f5d53c35c7e77badb0b7ac8994623269a Mon Sep 17 00:00:00 2001 From: Romuald Juchnowicz-Bierbasz Date: Tue, 13 Aug 2019 15:23:20 +0200 Subject: [PATCH 55/58] SDK-2997: Add launch_platform_client method --- src/galaxy/api/consts.py | 1 + src/galaxy/api/plugin.py | 8 ++++++++ tests/conftest.py | 1 + tests/test_features.py | 3 ++- tests/test_launch_platform_client.py | 17 +++++++++++++++++ 5 files changed, 29 insertions(+), 1 deletion(-) create mode 100644 tests/test_launch_platform_client.py diff --git a/src/galaxy/api/consts.py b/src/galaxy/api/consts.py index 19beba4..2b08f36 100644 --- a/src/galaxy/api/consts.py +++ b/src/galaxy/api/consts.py @@ -99,6 +99,7 @@ class Feature(Enum): VerifyGame = "VerifyGame" ImportFriends = "ImportFriends" ShutdownPlatformClient = "ShutdownPlatformClient" + LaunchPlatformClient = "LaunchPlatformClient" class LicenseType(Enum): diff --git a/src/galaxy/api/plugin.py b/src/galaxy/api/plugin.py index 415a448..2b67fea 100644 --- a/src/galaxy/api/plugin.py +++ b/src/galaxy/api/plugin.py @@ -107,6 +107,9 @@ class Plugin: 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"]) @@ -657,6 +660,11 @@ class Plugin: 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. diff --git a/tests/conftest.py b/tests/conftest.py index 9fa89ab..23bdadc 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -40,6 +40,7 @@ def plugin(reader, writer): "achievements_import_complete", "get_local_games", "launch_game", + "launch_platform_client", "install_game", "uninstall_game", "get_friends", diff --git a/tests/test_features.py b/tests/test_features.py index 47e561d..68a6cd0 100644 --- a/tests/test_features.py +++ b/tests/test_features.py @@ -13,7 +13,8 @@ def test_base_class(): Feature.ImportAchievements, Feature.ImportGameTime, Feature.ImportFriends, - Feature.ShutdownPlatformClient + Feature.ShutdownPlatformClient, + Feature.LaunchPlatformClient } 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() From cec36695b6f9e6072fe6ad54e007b8c7014389dd Mon Sep 17 00:00:00 2001 From: Romuald Juchnowicz-Bierbasz Date: Tue, 13 Aug 2019 16:08:59 +0200 Subject: [PATCH 56/58] Increment version --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 1ff0665..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.46", + version="0.47", description="GOG Galaxy Integrations Python API", author='Galaxy team', author_email='galaxy@gog.com', From 161122b94d4805ffbb175c85462676fecfd9fc4d Mon Sep 17 00:00:00 2001 From: Romuald Juchnowicz-Bierbasz Date: Wed, 14 Aug 2019 13:48:35 +0200 Subject: [PATCH 57/58] SDK-2988: Upload to official pypi server --- .gitlab-ci.yml | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 5e346af..c9c4183 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -14,9 +14,15 @@ test_package: deploy_package: stage: deploy + variables: + TWINE_USERNAME: $PYPI_USERNAME + TWINE_PASSWORD: $PYPI_PASSWORD script: + - pip install twine + - 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 From d4cd1cedfdd4f693cf67071c9652caa0e0b90317 Mon Sep 17 00:00:00 2001 From: Romuald Juchnowicz-Bierbasz Date: Wed, 14 Aug 2019 16:07:01 +0200 Subject: [PATCH 58/58] Install wheel in deploy --- .gitlab-ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index c9c4183..19643d2 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -18,7 +18,7 @@ deploy_package: TWINE_USERNAME: $PYPI_USERNAME TWINE_PASSWORD: $PYPI_PASSWORD script: - - pip install twine + - pip install twine wheel - rm -rf dist - export VERSION=$(python setup.py --version) - python setup.py sdist --formats=gztar bdist_wheel @@ -29,4 +29,4 @@ deploy_package: only: - master except: - - tags \ No newline at end of file + - tags