From 98cff9cfb8e6aad5434bc6232738715b9943a2ac Mon Sep 17 00:00:00 2001 From: Aleksej Pawlowskij Date: Wed, 2 Oct 2019 15:41:16 +0200 Subject: [PATCH] SDK-3069: add OS compatibility import --- src/galaxy/api/consts.py | 10 ++ src/galaxy/api/plugin.py | 92 +++++++++++++- src/galaxy/api/types.py | 35 ++++-- tests/conftest.py | 3 + tests/test_features.py | 3 +- tests/test_game_library_settings.py | 2 +- tests/test_os_compatibility.py | 187 ++++++++++++++++++++++++++++ 7 files changed, 314 insertions(+), 18 deletions(-) create mode 100644 tests/test_os_compatibility.py diff --git a/src/galaxy/api/consts.py b/src/galaxy/api/consts.py index 71e9c68..d490b44 100644 --- a/src/galaxy/api/consts.py +++ b/src/galaxy/api/consts.py @@ -111,6 +111,7 @@ class Feature(Enum): ShutdownPlatformClient = "ShutdownPlatformClient" LaunchPlatformClient = "LaunchPlatformClient" ImportGameLibrarySettings = "ImportGameLibrarySettings" + ImportOSCompatibility = "ImportOSCompatibility" class LicenseType(Enum): @@ -129,3 +130,12 @@ class LocalGameState(Flag): None_ = 0 Installed = 1 Running = 2 + + +class OSCompatibility(Flag): + """Possible game OS compatibility. + Use "bitwise or" to express multiple OSs compatibility, e.g. ``os=OSCompatibility.Windows|OSCompatibility.MacOS`` + """ + Windows = 0b001 + MacOS = 0b010 + Linux = 0b100 diff --git a/src/galaxy/api/plugin.py b/src/galaxy/api/plugin.py index 82b1c7d..ab2461a 100644 --- a/src/galaxy/api/plugin.py +++ b/src/galaxy/api/plugin.py @@ -7,12 +7,13 @@ import sys from enum import Enum from typing import Any, Dict, List, Optional, Set, Union -from galaxy.api.consts import Feature +from galaxy.api.consts import Feature, OSCompatibility from galaxy.api.errors import ImportInProgress, UnknownError from galaxy.api.jsonrpc import ApplicationError, NotificationClient, Server from galaxy.api.types import Achievement, Authentication, FriendInfo, Game, GameTime, LocalGame, NextStep, GameLibrarySettings from galaxy.task_manager import TaskManager + class JSONEncoder(json.JSONEncoder): def default(self, o): # pylint: disable=method-hidden if dataclasses.is_dataclass(o): @@ -47,6 +48,7 @@ class Plugin: self._achievements_import_in_progress = False self._game_times_import_in_progress = False self._game_library_settings_import_in_progress = False + self._os_compatibility_import_in_progress = False self._persistent_cache = dict() @@ -113,6 +115,9 @@ class Plugin: self._register_method("start_game_library_settings_import", self._start_game_library_settings_import) self._detect_feature(Feature.ImportGameLibrarySettings, ["get_game_library_settings"]) + self._register_method("start_os_compatibility_import", self._start_os_compatibility_import) + self._detect_feature(Feature.ImportOSCompatibility, ["get_os_compatibility"]) + async def __aenter__(self): return self @@ -173,6 +178,7 @@ class Plugin: def _wrap_external_method(self, handler, name: str): async def wrapper(*args, **kwargs): return await self._external_task_manager.create_task(handler(*args, **kwargs), name, False) + return wrapper async def run(self): @@ -420,6 +426,27 @@ class Plugin: def _game_library_settings_import_finished(self) -> None: self._notification_client.notify("game_library_settings_import_finished", None) + def _os_compatibility_import_success(self, game_id: str, os_compatibility: Optional[OSCompatibility]) -> None: + self._notification_client.notify( + "os_compatibility_import_success", + { + "game_id": game_id, + "os_compatibility": os_compatibility + } + ) + + def _os_compatibility_import_failure(self, game_id: str, error: ApplicationError) -> None: + self._notification_client.notify( + "os_compatibility_import_failure", + { + "game_id": game_id, + "error": error.json() + } + ) + + def _os_compatibility_import_finished(self) -> None: + self._notification_client.notify("os_compatibility_import_finished", None) + 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. @@ -805,7 +832,7 @@ class Plugin: This allows for optimizations like batch requests to platform API. Default implementation returns None. - :param game_ids: the ids of the games for which game time are imported + :param game_ids: the ids of the games for which game library settings are imported :return: context """ return None @@ -815,17 +842,74 @@ class Plugin: identified by the provided game_id. This method is called by import task initialized by GOG Galaxy Client. - :param game_id: the id of the game for which the game time is returned + :param game_id: the id of the game for which the game library settings are imported :param context: the value returned from :meth:`prepare_game_library_settings_context` :return: GameLibrarySettings object """ raise NotImplementedError() def game_library_settings_import_complete(self) -> None: - """Override this method to handle operations after game times import is finished + """Override this method to handle operations after game library settings import is finished (like updating cache). """ + async def _start_os_compatibility_import(self, game_ids: List[str]) -> None: + if self._os_compatibility_import_in_progress: + raise ImportInProgress() + + context = await self.prepare_os_compatibility_context(game_ids) + + async def import_os_compatibility(game_id, context_): + try: + os_compatibility = await self.get_os_compatibility(game_id, context_) + self._os_compatibility_import_success(game_id, os_compatibility) + except ApplicationError as error: + self._os_compatibility_import_failure(game_id, error) + except Exception: + logging.exception("Unexpected exception raised in import_os_compatibility") + self._os_compatibility_import_failure(game_id, UnknownError()) + + async def import_os_compatibility_set(game_ids_, context_): + try: + await asyncio.gather(*[ + import_os_compatibility(game_id, context_) for game_id in game_ids_ + ]) + finally: + self._os_compatibility_import_finished() + self._os_compatibility_import_in_progress = False + self.os_compatibility_import_complete() + + self._external_task_manager.create_task( + import_os_compatibility_set(game_ids, context), + "game OS compatibility import", + handle_exceptions=False + ) + self._os_compatibility_import_in_progress = True + + async def prepare_os_compatibility_context(self, game_ids: List[str]) -> Any: + """Override this method to prepare context for get_os_compatibility. + This allows for optimizations like batch requests to platform API. + Default implementation returns None. + + :param game_ids: the ids of the games for which game os compatibility is imported + :return: context + """ + return None + + async def get_os_compatibility(self, game_id: str, context: Any) -> Optional[OSCompatibility]: + """Override this method to return the OS compatibility for the game with the provided game_id. + This method is called by import task initialized by GOG Galaxy Client. + + :param game_id: the id of the game for which the game os compatibility is imported + :param context: the value returned from :meth:`prepare_os_compatibility_context` + :return: OSCompatibility flags indicating compatible OSs, or None if compatibility is not know + """ + raise NotImplementedError() + + def os_compatibility_import_complete(self) -> None: + """Override this method to handle operations after OS compatibility import is finished (like updating cache).""" + + def create_and_run_plugin(plugin_class, argv): """Call this method as an entry point for the implemented integration. diff --git a/src/galaxy/api/types.py b/src/galaxy/api/types.py index 9fee40b..aeefe0b 100644 --- a/src/galaxy/api/types.py +++ b/src/galaxy/api/types.py @@ -1,10 +1,11 @@ from dataclasses import dataclass -from typing import List, Dict, Optional +from typing import Dict, List, Optional from galaxy.api.consts import LicenseType, LocalGameState + @dataclass -class Authentication(): +class Authentication: """Return this from :meth:`.authenticate` or :meth:`.pass_login_credentials` to inform the client that authentication has successfully finished. @@ -14,8 +15,9 @@ class Authentication(): user_id: str user_name: str + @dataclass -class Cookie(): +class Cookie: """Cookie :param name: name of the cookie @@ -28,8 +30,9 @@ class Cookie(): domain: Optional[str] = None path: Optional[str] = None + @dataclass -class NextStep(): +class NextStep: """Return this from :meth:`.authenticate` or :meth:`.pass_login_credentials` to open client built-in browser with given url. For example: @@ -67,8 +70,9 @@ class NextStep(): cookies: Optional[List[Cookie]] = None js: Optional[Dict[str, List[str]]] = None + @dataclass -class LicenseInfo(): +class LicenseInfo: """Information about the license of related product. :param license_type: type of license @@ -77,8 +81,9 @@ class LicenseInfo(): license_type: LicenseType owner: Optional[str] = None + @dataclass -class Dlc(): +class Dlc: """Downloadable content object. :param dlc_id: id of the dlc @@ -89,8 +94,9 @@ class Dlc(): dlc_title: str license_info: LicenseInfo + @dataclass -class Game(): +class Game: """Game object. :param game_id: unique identifier of the game, this will be passed as parameter for methods such as launch_game @@ -103,8 +109,9 @@ class Game(): dlcs: Optional[List[Dlc]] license_info: LicenseInfo + @dataclass -class Achievement(): +class Achievement: """Achievement, has to be initialized with either id or name. :param unlock_time: unlock time of the achievement @@ -119,8 +126,9 @@ class Achievement(): assert self.achievement_id or self.achievement_name, \ "One of achievement_id or achievement_name is required" + @dataclass -class LocalGame(): +class LocalGame: """Game locally present on the authenticated user's computer. :param game_id: id of the game @@ -129,8 +137,9 @@ class LocalGame(): game_id: str local_game_state: LocalGameState + @dataclass -class FriendInfo(): +class FriendInfo: """Information about a friend of the currently authenticated user. :param user_id: id of the user @@ -139,8 +148,9 @@ class FriendInfo(): user_id: str user_name: str + @dataclass -class GameTime(): +class GameTime: """Game time of a game, defines the total time spent in the game and the last time the game was played. @@ -152,8 +162,9 @@ class GameTime(): time_played: Optional[int] last_played_time: Optional[int] + @dataclass -class GameLibrarySettings(): +class GameLibrarySettings: """Library settings of a game, defines assigned tags and visibility flag. :param game_id: id of the related game diff --git a/tests/conftest.py b/tests/conftest.py index 623281a..97b0ffe 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -53,6 +53,9 @@ async def plugin(reader, writer): "get_game_library_settings", "prepare_game_library_settings_context", "game_library_settings_import_complete", + "get_os_compatibility", + "prepare_os_compatibility_context", + "os_compatibility_import_complete", ) with ExitStack() as stack: diff --git a/tests/test_features.py b/tests/test_features.py index 27cb402..3d669ab 100644 --- a/tests/test_features.py +++ b/tests/test_features.py @@ -15,7 +15,8 @@ def test_base_class(): Feature.ImportFriends, Feature.ShutdownPlatformClient, Feature.LaunchPlatformClient, - Feature.ImportGameLibrarySettings + Feature.ImportGameLibrarySettings, + Feature.ImportOSCompatibility } diff --git a/tests/test_game_library_settings.py b/tests/test_game_library_settings.py index e335256..f27d5cf 100644 --- a/tests/test_game_library_settings.py +++ b/tests/test_game_library_settings.py @@ -9,7 +9,7 @@ from tests import create_message, get_messages @pytest.mark.asyncio -async def test_get_game_time_success(plugin, read, write): +async def test_get_library_settings_success(plugin, read, write): plugin.prepare_game_library_settings_context.return_value = async_return_value("abc") request = { "jsonrpc": "2.0", diff --git a/tests/test_os_compatibility.py b/tests/test_os_compatibility.py new file mode 100644 index 0000000..b712065 --- /dev/null +++ b/tests/test_os_compatibility.py @@ -0,0 +1,187 @@ +from unittest.mock import call + +import pytest +from galaxy.api.consts import OSCompatibility +from galaxy.api.errors import BackendError +from galaxy.unittest.mock import async_return_value + +from tests import create_message, get_messages + + +@pytest.mark.asyncio +async def test_get_os_compatibility_success(plugin, read, write): + context = "abc" + plugin.prepare_os_compatibility_context.return_value = async_return_value(context) + request = { + "jsonrpc": "2.0", + "id": "11", + "method": "start_os_compatibility_import", + "params": {"game_ids": ["666", "13", "42"]} + } + read.side_effect = [async_return_value(create_message(request)), async_return_value(b"", 10)] + plugin.get_os_compatibility.side_effect = [ + async_return_value(OSCompatibility.Linux), + async_return_value(None), + async_return_value(OSCompatibility.Windows | OSCompatibility.MacOS), + ] + await plugin.run() + plugin.get_os_compatibility.assert_has_calls([ + call("666", context), + call("13", context), + call("42", context), + ]) + plugin.os_compatibility_import_complete.assert_called_once_with() + + assert get_messages(write) == [ + { + "jsonrpc": "2.0", + "id": "11", + "result": None + }, + { + "jsonrpc": "2.0", + "method": "os_compatibility_import_success", + "params": { + "game_id": "666", + "os_compatibility": OSCompatibility.Linux.value + } + }, + { + "jsonrpc": "2.0", + "method": "os_compatibility_import_success", + "params": { + "game_id": "13", + "os_compatibility": None + } + }, + { + "jsonrpc": "2.0", + "method": "os_compatibility_import_success", + "params": { + "game_id": "42", + "os_compatibility": (OSCompatibility.Windows | OSCompatibility.MacOS).value + } + }, + { + "jsonrpc": "2.0", + "method": "os_compatibility_import_finished", + "params": None + } + ] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("exception,code,message", [ + (BackendError, 4, "Backend error"), + (KeyError, 0, "Unknown error") +]) +async def test_get_os_compatibility_error(exception, code, message, plugin, read, write): + game_id = "6" + request_id = "55" + plugin.prepare_os_compatibility_context.return_value = async_return_value(None) + request = { + "jsonrpc": "2.0", + "id": request_id, + "method": "start_os_compatibility_import", + "params": {"game_ids": [game_id]} + } + read.side_effect = [async_return_value(create_message(request)), async_return_value(b"", 10)] + plugin.get_os_compatibility.side_effect = exception + await plugin.run() + plugin.get_os_compatibility.assert_called() + plugin.os_compatibility_import_complete.assert_called_once_with() + + assert get_messages(write) == [ + { + "jsonrpc": "2.0", + "id": request_id, + "result": None + }, + { + "jsonrpc": "2.0", + "method": "os_compatibility_import_failure", + "params": { + "game_id": game_id, + "error": { + "code": code, + "message": message + } + } + }, + { + "jsonrpc": "2.0", + "method": "os_compatibility_import_finished", + "params": None + } + ] + + +@pytest.mark.asyncio +async def test_prepare_get_os_compatibility_context_error(plugin, read, write): + request_id = "31415" + plugin.prepare_os_compatibility_context.side_effect = BackendError() + request = { + "jsonrpc": "2.0", + "id": request_id, + "method": "start_os_compatibility_import", + "params": {"game_ids": ["6"]} + } + read.side_effect = [async_return_value(create_message(request)), async_return_value(b"", 10)] + await plugin.run() + + assert get_messages(write) == [ + { + "jsonrpc": "2.0", + "id": request_id, + "error": { + "code": 4, + "message": "Backend error" + } + } + ] + + +@pytest.mark.asyncio +async def test_import_already_in_progress_error(plugin, read, write): + plugin.prepare_os_compatibility_context.return_value = async_return_value(None) + requests = [ + { + "jsonrpc": "2.0", + "id": "3", + "method": "start_os_compatibility_import", + "params": { + "game_ids": ["42"] + } + }, + { + "jsonrpc": "2.0", + "id": "4", + "method": "start_os_compatibility_import", + "params": { + "game_ids": ["666"] + } + } + ] + read.side_effect = [ + async_return_value(create_message(requests[0])), + async_return_value(create_message(requests[1])), + async_return_value(b"", 10) + ] + + await plugin.run() + + responses = get_messages(write) + assert { + "jsonrpc": "2.0", + "id": "3", + "result": None + } in responses + assert { + "jsonrpc": "2.0", + "id": "4", + "error": { + "code": 600, + "message": "Import already in progress" + } + } in responses +