From 74e3825f10f9905b92ff6eab5cf76ecdb9b49c63 Mon Sep 17 00:00:00 2001 From: unknown Date: Wed, 5 Feb 2020 16:28:15 +0100 Subject: [PATCH] prepare interfaces for subscriptions --- src/galaxy/api/consts.py | 1 + src/galaxy/api/plugin.py | 86 +++++++++++- src/galaxy/api/types.py | 31 +++++ tests/conftest.py | 4 + tests/test_features.py | 3 +- tests/test_subscriptions.py | 271 ++++++++++++++++++++++++++++++++++++ 6 files changed, 394 insertions(+), 2 deletions(-) create mode 100644 tests/test_subscriptions.py diff --git a/src/galaxy/api/consts.py b/src/galaxy/api/consts.py index 76a89b1..8e224a7 100644 --- a/src/galaxy/api/consts.py +++ b/src/galaxy/api/consts.py @@ -115,6 +115,7 @@ class Feature(Enum): ImportOSCompatibility = "ImportOSCompatibility" ImportUserPresence = "ImportUserPresence" ImportLocalSize = "ImportLocalSize" + ImportSubscriptions = "ImportSubscriptions" class LicenseType(Enum): diff --git a/src/galaxy/api/plugin.py b/src/galaxy/api/plugin.py index 98f2459..5fd778e 100644 --- a/src/galaxy/api/plugin.py +++ b/src/galaxy/api/plugin.py @@ -10,7 +10,8 @@ from galaxy.api.consts import Feature, OSCompatibility from galaxy.api.errors import ImportInProgress, UnknownError from galaxy.api.jsonrpc import ApplicationError, Connection from galaxy.api.types import ( - Achievement, Authentication, Game, GameLibrarySettings, GameTime, LocalGame, NextStep, UserInfo, UserPresence + Achievement, Authentication, Game, GameLibrarySettings, GameTime, LocalGame, NextStep, UserInfo, UserPresence, + Subscription, SubscriptionGame ) from galaxy.task_manager import TaskManager @@ -176,6 +177,16 @@ class Plugin: self._local_size_import_finished, self.local_size_import_complete ) + self._subscription_games_importer = Importer( + self._external_task_manager, + "subscription games", + self.get_subscription_games, + self.prepare_subscription_games_context, + self._subscription_games_import_success, + self._subscription_games_import_failure, + self._subscription_games_import_finished, + self.subscription_games_import_complete + ) # internal self._register_method("shutdown", self._shutdown, internal=True) @@ -246,6 +257,10 @@ class Plugin: self._register_method("start_local_size_import", self._start_local_size_import) self._detect_feature(Feature.ImportLocalSize, ["get_local_size"]) + self._register_method("import_subscriptions", self.get_subscriptions, result_name="subscriptions") + self._register_method("start_subscription_games_import", self._start_subscription_games_import) + self._detect_feature(Feature.ImportSubscriptions, ["get_subscriptions", "get_subscription_games"]) + async def __aenter__(self): return self @@ -647,6 +662,27 @@ class Plugin: def _local_size_import_finished(self) -> None: self._connection.send_notification("local_size_import_finished", None) + def _subscription_games_import_success(self, subscription_name: str, subscription_games: Optional[List[SubscriptionGame]]) -> None: + self._connection.send_notification( + "subscription_games_import_success", + { + "subscription_name": subscription_name, + "subscription_games": subscription_games + } + ) + + def _subscription_games_import_failure(self, subscription_name: str, error: ApplicationError) -> None: + self._connection.send_notification( + "subscription_games_import_failure", + { + "subscription_name": subscription_name, + "error": error.json() + } + ) + + def _subscription_games_import_finished(self) -> None: + self._connection.send_notification("subscription_games_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. @@ -1051,6 +1087,54 @@ class Plugin: def local_size_import_complete(self) -> None: """Override this method to handle operations after local game size import is finished (like updating cache).""" + async def get_subscriptions(self) -> List[Subscription]: + """Override this method to return a list of available + Subscriptions available on platform. + This method is called by the GOG Galaxy Client. + + Both this method and get_subscription_games are required to be overridden + for the Subscriptions feature to be recognized + + Example of possible override of the method: + + .. code-block:: python + :linenos: + + async def get_subscriptions(self, game_id): + subs = [] + platform_subs_info = await self.retrieve_platform_subs_info() + for sub_info in platform_subs_info: + subs.append(Subscription(subscription_name=sub_info['name'], owned=sub_info['is_owned'])) + return subs + + """ + raise NotImplementedError() + + async def _start_subscription_games_import(self, subscription_names: List[str]) -> None: + await self._subscription_games_importer.start(subscription_names) + + async def prepare_subscription_games_context(self, subscription_names: List[str]) -> Any: + """Override this method to prepare context for :meth:`get_subscription_games` + Default implementation returns None. + + :param subscription_names: the names of the subscriptions for which subscriptions games are imported + :return: context + """ + return None + + async def get_subscription_games(self, subscription_name: str, context: Any) -> Optional[List[SubscriptionGame]]: + """Override this method to return list of subscription games in a given subscription. + + :param context: the value returned from :meth:`prepare_subscription_games_context` + :return: List of subscription games or `None` if list cannot be determined. + """ + raise NotImplementedError() + + def subscription_games_import_complete(self) -> None: + """Override this method to handle operations after + subscription games 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 4bd01ea..f98557d 100644 --- a/src/galaxy/api/types.py +++ b/src/galaxy/api/types.py @@ -216,3 +216,34 @@ class UserPresence: game_title: Optional[str] = None in_game_status: Optional[str] = None full_status: Optional[str] = None + +@dataclass +class Subscription: + """Information about a subscription. If a subscription is not owned no end_time should be specified. + + :param subscription_name: name of the subscription, will also be used as its identifier. + :param owned: whether the subscription is owned or not, None if unknown. + :param end_time: unix timestamp of when the subscription ends, None if unknown. + """ + subscription_name: str + owned: Optional[bool] = None + end_time: Optional[int] = None + + def __post_init__(self): + if not self.owned: + assert self.end_time is None, "Subscriptions not owned but end time specified." \ + "Specify end time for owned subscriptions only" + +@dataclass +class SubscriptionGame: + """Information about a game from a subscription. + + :param game_title: title of the game + :param game_id: id of the game + :param start_time: unix timestamp of when the game has been added to subscription + :param end_time: unix timestamp of when the game is removed from subscription. + """ + game_title: str + game_id: str + start_time: Optional[int] = None + end_time: Optional[int] = None diff --git a/tests/conftest.py b/tests/conftest.py index 26ee0c9..cb0b87d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -67,6 +67,10 @@ async def plugin(reader, writer): "get_local_size", "prepare_local_size_context", "local_size_import_complete", + "get_subscriptions", + "get_subscription_games", + "prepare_subscription_games_context", + "subscription_games_import_complete" ) with ExitStack() as stack: diff --git a/tests/test_features.py b/tests/test_features.py index 6bb4201..c06a1a7 100644 --- a/tests/test_features.py +++ b/tests/test_features.py @@ -18,7 +18,8 @@ def test_base_class(): Feature.ImportGameLibrarySettings, Feature.ImportOSCompatibility, Feature.ImportUserPresence, - Feature.ImportLocalSize + Feature.ImportLocalSize, + Feature.ImportSubscriptions } diff --git a/tests/test_subscriptions.py b/tests/test_subscriptions.py new file mode 100644 index 0000000..1083816 --- /dev/null +++ b/tests/test_subscriptions.py @@ -0,0 +1,271 @@ +import pytest + +from galaxy.api.types import Subscription, SubscriptionGame +from galaxy.api.errors import FailedParsingManifest, BackendError, UnknownError +from galaxy.unittest.mock import async_return_value + +from tests import create_message, get_messages + + +@pytest.mark.asyncio +@pytest.mark.asyncio +async def test_get_subscriptions_success(plugin, read, write): + request = { + "jsonrpc": "2.0", + "id": "3", + "method": "import_subscriptions" + } + read.side_effect = [async_return_value(create_message(request)), async_return_value(b"", 10)] + + plugin.get_subscriptions.return_value = async_return_value([ + Subscription("1"), + Subscription("2", False), + Subscription("3", True, 1580899100) + ]) + await plugin.run() + plugin.get_subscriptions.assert_called_with() + + assert get_messages(write) == [ + { + "jsonrpc": "2.0", + "id": "3", + "result": { + "subscriptions": [ + { + "subscription_name": "1" + }, + { + "subscription_name": "2", + "owned": False + }, + { + "subscription_name": "3", + "owned": True, + "end_time": 1580899100 + } + ] + } + } + ] + + +@pytest.mark.asyncio +@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") + ], +) +async def test_get_subscriptions_failure_generic(plugin, read, write, error, code, message): + request = { + "jsonrpc": "2.0", + "id": "3", + "method": "import_subscriptions" + } + read.side_effect = [async_return_value(create_message(request)), async_return_value(b"", 10)] + plugin.get_subscriptions.side_effect = error() + await plugin.run() + plugin.get_subscriptions.assert_called_with() + + assert get_messages(write) == [ + { + "jsonrpc": "2.0", + "id": "3", + "error": { + "code": code, + "message": message + } + } + ] + +@pytest.mark.asyncio +async def test_subscription_assert_failure(): + with pytest.raises(AssertionError): + Subscription("test", False, 123) + +@pytest.mark.asyncio +async def test_get_subscription_games_success(plugin, read, write): + plugin.prepare_subscription_games_context.return_value = async_return_value(5) + request = { + "jsonrpc": "2.0", + "id": "3", + "method": "start_subscription_games_import", + "params": { + "subscription_names": ["sub_a"] + } + } + read.side_effect = [async_return_value(create_message(request)), async_return_value(b"", 10)] + plugin.get_subscription_games.return_value = async_return_value([ + SubscriptionGame(game_title="game A", game_id="game_A"), + SubscriptionGame(game_title="game B", game_id="game_B", start_time=1548495632), + SubscriptionGame(game_title="game C", game_id="game_C", end_time=1548495633), + SubscriptionGame(game_title="game D", game_id="game_D", start_time=1548495632, end_time=1548495633), + ]) + await plugin.run() + plugin.prepare_subscription_games_context.assert_called_with(["sub_a"]) + plugin.get_subscription_games.assert_called_with("sub_a", 5) + plugin.subscription_games_import_complete.asert_called_with() + + assert get_messages(write) == [ + { + "jsonrpc": "2.0", + "id": "3", + "result": None + }, + { + "jsonrpc": "2.0", + "method": "subscription_games_import_success", + "params": { + "subscription_name": "sub_a", + "subscription_games": [ + { + "game_title": "game A", + "game_id": "game_A" + }, + { + "game_title": "game B", + "game_id": "game_B", + "start_time": 1548495632 + }, + { + "game_title": "game C", + "game_id": "game_C", + "end_time": 1548495633 + }, + { + "game_title": "game D", + "game_id": "game_D", + "start_time": 1548495632, + "end_time": 1548495633 + } + ] + } + }, + { + "jsonrpc": "2.0", + "method": "subscription_games_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_subscription_games_error(exception, code, message, plugin, read, write): + plugin.prepare_subscription_games_context.return_value = async_return_value(None) + request = { + "jsonrpc": "2.0", + "id": "3", + "method": "start_subscription_games_import", + "params": { + "subscription_names": ["sub_a"] + } + } + + read.side_effect = [async_return_value(create_message(request)), async_return_value(b"", 10)] + plugin.get_subscription_games.side_effect = exception + await plugin.run() + plugin.get_subscription_games.assert_called() + plugin.subscription_games_import_complete.asert_called_with() + + assert get_messages(write) == [ + { + "jsonrpc": "2.0", + "id": "3", + "result": None + }, + { + "jsonrpc": "2.0", + "method": "subscription_games_import_failure", + "params": { + "subscription_name": "sub_a", + "error": { + "code": code, + "message": message + } + } + }, + { + "jsonrpc": "2.0", + "method": "subscription_games_import_finished", + "params": None + } + ] + + +@pytest.mark.asyncio +async def test_prepare_get_subscription_games_context_error(plugin, read, write): + request_id = "31415" + error_details = "Unexpected syntax" + error_message, error_code = FailedParsingManifest().message, FailedParsingManifest().code + plugin.prepare_subscription_games_context.side_effect = FailedParsingManifest(error_details) + request = { + "jsonrpc": "2.0", + "id": request_id, + "method": "start_subscription_games_import", + "params": {"subscription_names": ["sub_a", "sub_b"]} + } + 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": error_code, + "message": error_message, + "data": error_details + } + } + ] + + +@pytest.mark.asyncio +async def test_import_already_in_progress_error(plugin, read, write): + plugin.prepare_subscription_games_context.return_value = async_return_value(None) + requests = [ + { + "jsonrpc": "2.0", + "id": "3", + "method": "start_subscription_games_import", + "params": { + "subscription_names": ["sub_a"] + } + }, + { + "jsonrpc": "2.0", + "id": "4", + "method": "start_subscription_games_import", + "params": { + "subscription_names": ["sub_a","sub_b"] + } + } + ] + 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 +