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(
name="galaxy.plugin.api",
version="0.52",
version="0.54",
description="GOG Galaxy Integrations Python API",
author='Galaxy team',
author_email='galaxy@gog.com',

View File

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

View File

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

View File

@@ -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,11 +178,13 @@ 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):
"""Plugin's main coroutine."""
await self._server.run()
logging.debug("Plugin run loop finished")
def close(self) -> None:
if not self._active:
@@ -190,10 +197,12 @@ class Plugin:
self._active = False
async def wait_closed(self) -> None:
logging.debug("Waiting for plugin to close")
await self._external_task_manager.wait()
await self._internal_task_manager.wait()
await self._server.wait_closed()
await self._notification_client.close()
logging.debug("Plugin closed")
def create_task(self, coro, description):
"""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:
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 +835,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 +845,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.

View File

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

View File

@@ -78,7 +78,8 @@ def create_tcp_connector(*args, **kwargs) -> aiohttp.TCPConnector:
ssl_context.load_verify_locations(certifi.where())
kwargs.setdefault("ssl", ssl_context)
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:
@@ -103,7 +104,8 @@ def create_client_session(*args, **kwargs) -> aiohttp.ClientSession:
kwargs.setdefault("connector", create_tcp_connector())
kwargs.setdefault("timeout", aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT))
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

View File

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

View File

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

View File

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

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