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/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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 f1fd00fcd3abd57ea1b5c3e099d273fbf4f90843 Mon Sep 17 00:00:00 2001 From: GOG Galaxy SDK Team Date: Wed, 26 Jun 2019 12:46:23 +0200 Subject: [PATCH 12/12] 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"])