Compare commits

...

6 Commits
0.52 ... 0.54

Author SHA1 Message Date
Romuald Juchnowicz-Bierbasz
c874bc1d6e Increment version 2019-10-21 14:54:23 +02:00
Romuald Bierbasz
2dc56571d6 Revert "Add more logs"
This reverts commit 21ab8bf33d3c2714b8c7af2da8036fe8baae20ea.
2019-10-21 14:50:33 +02:00
Romuald Bierbasz
eb216a50a8 Fix mypy ignores 2019-10-21 14:20:21 +02:00
Rafal Makagon
c9b1c8fcae Increment version 2019-10-15 12:58:48 +02:00
Aleksej Pawlowskij
a19a6cf11f Add Rockstar platform 2019-10-07 14:12:44 +02:00
Aleksej Pawlowskij
98cff9cfb8 SDK-3069: add OS compatibility import 2019-10-02 15:41:16 +02:00
10 changed files with 327 additions and 23 deletions

View File

@@ -2,7 +2,7 @@ from setuptools import setup, find_packages
setup( setup(
name="galaxy.plugin.api", name="galaxy.plugin.api",
version="0.52", version="0.54",
description="GOG Galaxy Integrations Python API", description="GOG Galaxy Integrations Python API",
author='Galaxy team', author='Galaxy team',
author_email='galaxy@gog.com', author_email='galaxy@gog.com',

View File

@@ -90,6 +90,7 @@ class Platform(Enum):
Playfire = "playfire" Playfire = "playfire"
Oculus = "oculus" Oculus = "oculus"
Test = "test" Test = "test"
Rockstar = "rockstar"
class Feature(Enum): class Feature(Enum):
@@ -111,6 +112,7 @@ class Feature(Enum):
ShutdownPlatformClient = "ShutdownPlatformClient" ShutdownPlatformClient = "ShutdownPlatformClient"
LaunchPlatformClient = "LaunchPlatformClient" LaunchPlatformClient = "LaunchPlatformClient"
ImportGameLibrarySettings = "ImportGameLibrarySettings" ImportGameLibrarySettings = "ImportGameLibrarySettings"
ImportOSCompatibility = "ImportOSCompatibility"
class LicenseType(Enum): class LicenseType(Enum):
@@ -129,3 +131,12 @@ class LocalGameState(Flag):
None_ = 0 None_ = 0
Installed = 1 Installed = 1
Running = 2 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

View File

@@ -129,8 +129,9 @@ class Server():
await asyncio.sleep(0) # To not starve task queue await asyncio.sleep(0) # To not starve task queue
def close(self): def close(self):
logging.info("Closing JSON-RPC server - not more messages will be read") if self._active:
self._active = False logging.info("Closing JSON-RPC server - not more messages will be read")
self._active = False
async def wait_closed(self): async def wait_closed(self):
await self._task_manager.wait() await self._task_manager.wait()
@@ -281,6 +282,7 @@ class NotificationClient():
self._send(notification) self._send(notification)
async def close(self): async def close(self):
self._task_manager.cancel()
await self._task_manager.wait() await self._task_manager.wait()
def _send(self, data): def _send(self, data):

View File

@@ -7,12 +7,13 @@ import sys
from enum import Enum from enum import Enum
from typing import Any, Dict, List, Optional, Set, Union 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.errors import ImportInProgress, UnknownError
from galaxy.api.jsonrpc import ApplicationError, NotificationClient, Server from galaxy.api.jsonrpc import ApplicationError, NotificationClient, Server
from galaxy.api.types import Achievement, Authentication, FriendInfo, Game, GameTime, LocalGame, NextStep, GameLibrarySettings from galaxy.api.types import Achievement, Authentication, FriendInfo, Game, GameTime, LocalGame, NextStep, GameLibrarySettings
from galaxy.task_manager import TaskManager from galaxy.task_manager import TaskManager
class JSONEncoder(json.JSONEncoder): 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): if dataclasses.is_dataclass(o):
@@ -47,6 +48,7 @@ class Plugin:
self._achievements_import_in_progress = False self._achievements_import_in_progress = False
self._game_times_import_in_progress = False self._game_times_import_in_progress = False
self._game_library_settings_import_in_progress = False self._game_library_settings_import_in_progress = False
self._os_compatibility_import_in_progress = False
self._persistent_cache = dict() 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._register_method("start_game_library_settings_import", self._start_game_library_settings_import)
self._detect_feature(Feature.ImportGameLibrarySettings, ["get_game_library_settings"]) 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): async def __aenter__(self):
return self return self
@@ -173,11 +178,13 @@ class Plugin:
def _wrap_external_method(self, handler, name: str): def _wrap_external_method(self, handler, name: str):
async def wrapper(*args, **kwargs): async def wrapper(*args, **kwargs):
return await self._external_task_manager.create_task(handler(*args, **kwargs), name, False) return await self._external_task_manager.create_task(handler(*args, **kwargs), name, False)
return wrapper return wrapper
async def run(self): async def run(self):
"""Plugin's main coroutine.""" """Plugin's main coroutine."""
await self._server.run() await self._server.run()
logging.debug("Plugin run loop finished")
def close(self) -> None: def close(self) -> None:
if not self._active: if not self._active:
@@ -190,10 +197,12 @@ class Plugin:
self._active = False self._active = False
async def wait_closed(self) -> None: async def wait_closed(self) -> None:
logging.debug("Waiting for plugin to close")
await self._external_task_manager.wait() await self._external_task_manager.wait()
await self._internal_task_manager.wait() await self._internal_task_manager.wait()
await self._server.wait_closed() await self._server.wait_closed()
await self._notification_client.close() await self._notification_client.close()
logging.debug("Plugin closed")
def create_task(self, coro, description): def create_task(self, coro, description):
"""Wrapper around asyncio.create_task - takes care of canceling tasks on shutdown""" """Wrapper around asyncio.create_task - takes care of canceling tasks on shutdown"""
@@ -420,6 +429,27 @@ class Plugin:
def _game_library_settings_import_finished(self) -> None: def _game_library_settings_import_finished(self) -> None:
self._notification_client.notify("game_library_settings_import_finished", 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: def lost_authentication(self) -> None:
"""Notify the client that integration has lost authentication for the """Notify the client that integration has lost authentication for the
current user and is unable to perform actions which would require it. current user and is unable to perform actions which would require it.
@@ -805,7 +835,7 @@ class Plugin:
This allows for optimizations like batch requests to platform API. This allows for optimizations like batch requests to platform API.
Default implementation returns None. 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: context
""" """
return None return None
@@ -815,17 +845,74 @@ class Plugin:
identified by the provided game_id. identified by the provided game_id.
This method is called by import task initialized by GOG Galaxy Client. 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` :param context: the value returned from :meth:`prepare_game_library_settings_context`
:return: GameLibrarySettings object :return: GameLibrarySettings object
""" """
raise NotImplementedError() raise NotImplementedError()
def game_library_settings_import_complete(self) -> None: 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). (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): def create_and_run_plugin(plugin_class, argv):
"""Call this method as an entry point for the implemented integration. """Call this method as an entry point for the implemented integration.

View File

@@ -1,10 +1,11 @@
from dataclasses import dataclass from dataclasses import dataclass
from typing import List, Dict, Optional from typing import Dict, List, Optional
from galaxy.api.consts import LicenseType, LocalGameState from galaxy.api.consts import LicenseType, LocalGameState
@dataclass @dataclass
class Authentication(): class Authentication:
"""Return this from :meth:`.authenticate` or :meth:`.pass_login_credentials` """Return this from :meth:`.authenticate` or :meth:`.pass_login_credentials`
to inform the client that authentication has successfully finished. to inform the client that authentication has successfully finished.
@@ -14,8 +15,9 @@ class Authentication():
user_id: str user_id: str
user_name: str user_name: str
@dataclass @dataclass
class Cookie(): class Cookie:
"""Cookie """Cookie
:param name: name of the cookie :param name: name of the cookie
@@ -28,8 +30,9 @@ class Cookie():
domain: Optional[str] = None domain: Optional[str] = None
path: Optional[str] = None path: Optional[str] = None
@dataclass @dataclass
class NextStep(): class NextStep:
"""Return this from :meth:`.authenticate` or :meth:`.pass_login_credentials` to open client built-in browser with given url. """Return this from :meth:`.authenticate` or :meth:`.pass_login_credentials` to open client built-in browser with given url.
For example: For example:
@@ -67,8 +70,9 @@ class NextStep():
cookies: Optional[List[Cookie]] = None cookies: Optional[List[Cookie]] = None
js: Optional[Dict[str, List[str]]] = None js: Optional[Dict[str, List[str]]] = None
@dataclass @dataclass
class LicenseInfo(): class LicenseInfo:
"""Information about the license of related product. """Information about the license of related product.
:param license_type: type of license :param license_type: type of license
@@ -77,8 +81,9 @@ class LicenseInfo():
license_type: LicenseType license_type: LicenseType
owner: Optional[str] = None owner: Optional[str] = None
@dataclass @dataclass
class Dlc(): class Dlc:
"""Downloadable content object. """Downloadable content object.
:param dlc_id: id of the dlc :param dlc_id: id of the dlc
@@ -89,8 +94,9 @@ class Dlc():
dlc_title: str dlc_title: str
license_info: LicenseInfo license_info: LicenseInfo
@dataclass @dataclass
class Game(): class Game:
"""Game object. """Game object.
:param game_id: unique identifier of the game, this will be passed as parameter for methods such as launch_game :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]] dlcs: Optional[List[Dlc]]
license_info: LicenseInfo license_info: LicenseInfo
@dataclass @dataclass
class Achievement(): class Achievement:
"""Achievement, has to be initialized with either id or name. """Achievement, has to be initialized with either id or name.
:param unlock_time: unlock time of the achievement :param unlock_time: unlock time of the achievement
@@ -119,8 +126,9 @@ class Achievement():
assert self.achievement_id or self.achievement_name, \ assert self.achievement_id or self.achievement_name, \
"One of achievement_id or achievement_name is required" "One of achievement_id or achievement_name is required"
@dataclass @dataclass
class LocalGame(): class LocalGame:
"""Game locally present on the authenticated user's computer. """Game locally present on the authenticated user's computer.
:param game_id: id of the game :param game_id: id of the game
@@ -129,8 +137,9 @@ class LocalGame():
game_id: str game_id: str
local_game_state: LocalGameState local_game_state: LocalGameState
@dataclass @dataclass
class FriendInfo(): class FriendInfo:
"""Information about a friend of the currently authenticated user. """Information about a friend of the currently authenticated user.
:param user_id: id of the user :param user_id: id of the user
@@ -139,8 +148,9 @@ class FriendInfo():
user_id: str user_id: str
user_name: str user_name: str
@dataclass @dataclass
class GameTime(): class GameTime:
"""Game time of a game, defines the total time spent in the game """Game time of a game, defines the total time spent in the game
and the last time the game was played. and the last time the game was played.
@@ -152,8 +162,9 @@ class GameTime():
time_played: Optional[int] time_played: Optional[int]
last_played_time: Optional[int] last_played_time: Optional[int]
@dataclass @dataclass
class GameLibrarySettings(): class GameLibrarySettings:
"""Library settings of a game, defines assigned tags and visibility flag. """Library settings of a game, defines assigned tags and visibility flag.
:param game_id: id of the related game :param game_id: id of the related game

View File

@@ -78,7 +78,8 @@ def create_tcp_connector(*args, **kwargs) -> aiohttp.TCPConnector:
ssl_context.load_verify_locations(certifi.where()) ssl_context.load_verify_locations(certifi.where())
kwargs.setdefault("ssl", ssl_context) kwargs.setdefault("ssl", ssl_context)
kwargs.setdefault("limit", DEFAULT_LIMIT) kwargs.setdefault("limit", DEFAULT_LIMIT)
return aiohttp.TCPConnector(*args, **kwargs) # type: ignore due to https://github.com/python/mypy/issues/4001 # due to https://github.com/python/mypy/issues/4001
return aiohttp.TCPConnector(*args, **kwargs) # type: ignore
def create_client_session(*args, **kwargs) -> aiohttp.ClientSession: def create_client_session(*args, **kwargs) -> aiohttp.ClientSession:
@@ -103,7 +104,8 @@ def create_client_session(*args, **kwargs) -> aiohttp.ClientSession:
kwargs.setdefault("connector", create_tcp_connector()) kwargs.setdefault("connector", create_tcp_connector())
kwargs.setdefault("timeout", aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT)) kwargs.setdefault("timeout", aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT))
kwargs.setdefault("raise_for_status", True) kwargs.setdefault("raise_for_status", True)
return aiohttp.ClientSession(*args, **kwargs) # type: ignore due to https://github.com/python/mypy/issues/4001 # due to https://github.com/python/mypy/issues/4001
return aiohttp.ClientSession(*args, **kwargs) # type: ignore
@contextmanager @contextmanager

View File

@@ -53,6 +53,9 @@ async def plugin(reader, writer):
"get_game_library_settings", "get_game_library_settings",
"prepare_game_library_settings_context", "prepare_game_library_settings_context",
"game_library_settings_import_complete", "game_library_settings_import_complete",
"get_os_compatibility",
"prepare_os_compatibility_context",
"os_compatibility_import_complete",
) )
with ExitStack() as stack: with ExitStack() as stack:

View File

@@ -15,7 +15,8 @@ def test_base_class():
Feature.ImportFriends, Feature.ImportFriends,
Feature.ShutdownPlatformClient, Feature.ShutdownPlatformClient,
Feature.LaunchPlatformClient, Feature.LaunchPlatformClient,
Feature.ImportGameLibrarySettings Feature.ImportGameLibrarySettings,
Feature.ImportOSCompatibility
} }

View File

@@ -9,7 +9,7 @@ from tests import create_message, get_messages
@pytest.mark.asyncio @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") plugin.prepare_game_library_settings_context.return_value = async_return_value("abc")
request = { request = {
"jsonrpc": "2.0", "jsonrpc": "2.0",

View File

@@ -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