Compare commits

..

6 Commits
0.51 ... 0.53

Author SHA1 Message Date
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
Rafal Makagon
2e2aa8c4a0 Increment version 2019-10-01 11:21:32 +02:00
Rafal Makagon
f57e03db2d Add game library settings feature 2019-09-27 16:15:57 +02:00
Rafal Makagon
66085e2239 Revert "Add ignoring not having windll to mypy"
This reverts commit 55c7fcfd61e0391287e2717117da4fca03b77dec.
2019-09-27 15:37:21 +02:00
11 changed files with 605 additions and 20 deletions

View File

@@ -2,7 +2,7 @@
pytest==4.2.0
pytest-asyncio==0.10.0
pytest-mock==1.10.3
pytest-mypy==0.3.2
pytest-mypy==0.4.1
pytest-flakes==4.0.0
# because of pip bug https://github.com/pypa/pip/issues/4780
aiohttp==3.5.4

View File

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

View File

@@ -1 +1 @@
__path__: str = __import__('pkgutil').extend_path(__path__, __name__)
__path__: str = __import__('pkgutil').extend_path(__path__, __name__) # type: ignore

View File

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

@@ -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
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):
@@ -46,6 +47,8 @@ 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()
@@ -109,6 +112,12 @@ class Plugin:
self._register_method("start_game_times_import", self._start_game_times_import)
self._detect_feature(Feature.ImportGameTime, ["get_game_time"])
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
@@ -169,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):
@@ -402,6 +412,41 @@ class Plugin:
def _game_times_import_finished(self) -> None:
self._notification_client.notify("game_times_import_finished", None)
def _game_library_settings_import_success(self, game_library_settings: GameLibrarySettings) -> None:
params = {"game_library_settings": game_library_settings}
self._notification_client.notify("game_library_settings_import_success", params)
def _game_library_settings_import_failure(self, game_id: str, error: ApplicationError) -> None:
params = {
"game_id": game_id,
"error": error.json()
}
self._notification_client.notify("game_library_settings_import_failure", params)
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.
@@ -750,6 +795,120 @@ class Plugin:
(like updating cache).
"""
async def _start_game_library_settings_import(self, game_ids: List[str]) -> None:
if self._game_library_settings_import_in_progress:
raise ImportInProgress()
context = await self.prepare_game_library_settings_context(game_ids)
async def import_game_library_settings(game_id, context_):
try:
game_library_settings = await self.get_game_library_settings(game_id, context_)
self._game_library_settings_import_success(game_library_settings)
except ApplicationError as error:
self._game_library_settings_import_failure(game_id, error)
except Exception:
logging.exception("Unexpected exception raised in import_game_library_settings")
self._game_library_settings_import_failure(game_id, UnknownError())
async def import_game_library_settings_set(game_ids_, context_):
try:
imports = [import_game_library_settings(game_id, context_) for game_id in game_ids_]
await asyncio.gather(*imports)
finally:
self._game_library_settings_import_finished()
self._game_library_settings_import_in_progress = False
self.game_library_settings_import_complete()
self._external_task_manager.create_task(
import_game_library_settings_set(game_ids, context),
"game library settings import",
handle_exceptions=False
)
self._game_library_settings_import_in_progress = True
async def prepare_game_library_settings_context(self, game_ids: List[str]) -> Any:
"""Override this method to prepare context for get_game_library_settings.
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 library settings are imported
:return: context
"""
return None
async def get_game_library_settings(self, game_id: str, context: Any) -> GameLibrarySettings:
"""Override this method to return the game library settings for the game
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 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 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.
@@ -151,3 +161,16 @@ class GameTime():
game_id: str
time_played: Optional[int]
last_played_time: Optional[int]
@dataclass
class GameLibrarySettings:
"""Library settings of a game, defines assigned tags and visibility flag.
:param game_id: id of the related game
:param tags: collection of tags assigned to the game
:param hidden: indicates if the game should be hidden in GOG Galaxy application
"""
game_id: str
tags: Optional[List[str]]
hidden: Optional[bool]

View File

@@ -1,5 +1,5 @@
import platform
if platform.system().lower() == "windows":
import sys
if sys.platform == "win32":
import logging
import ctypes
from ctypes.wintypes import LONG, HKEY, LPCWSTR, DWORD, BOOL, HANDLE, LPVOID

View File

@@ -49,7 +49,13 @@ async def plugin(reader, writer):
"game_times_import_complete",
"shutdown_platform_client",
"shutdown",
"tick"
"tick",
"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

@@ -14,7 +14,9 @@ def test_base_class():
Feature.ImportGameTime,
Feature.ImportFriends,
Feature.ShutdownPlatformClient,
Feature.LaunchPlatformClient
Feature.LaunchPlatformClient,
Feature.ImportGameLibrarySettings,
Feature.ImportOSCompatibility
}

View File

@@ -0,0 +1,196 @@
from unittest.mock import call
import pytest
from galaxy.api.types import GameLibrarySettings
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_library_settings_success(plugin, read, write):
plugin.prepare_game_library_settings_context.return_value = async_return_value("abc")
request = {
"jsonrpc": "2.0",
"id": "3",
"method": "start_game_library_settings_import",
"params": {
"game_ids": ["3", "5", "7"]
}
}
read.side_effect = [async_return_value(create_message(request)), async_return_value(b"", 10)]
plugin.get_game_library_settings.side_effect = [
async_return_value(GameLibrarySettings("3", None, True)),
async_return_value(GameLibrarySettings("5", [], False)),
async_return_value(GameLibrarySettings("7", ["tag1", "tag2", "tag3"], None)),
]
await plugin.run()
plugin.get_game_library_settings.assert_has_calls([
call("3", "abc"),
call("5", "abc"),
call("7", "abc"),
])
plugin.game_library_settings_import_complete.assert_called_once_with()
assert get_messages(write) == [
{
"jsonrpc": "2.0",
"id": "3",
"result": None
},
{
"jsonrpc": "2.0",
"method": "game_library_settings_import_success",
"params": {
"game_library_settings": {
"game_id": "3",
"hidden": True
}
}
},
{
"jsonrpc": "2.0",
"method": "game_library_settings_import_success",
"params": {
"game_library_settings": {
"game_id": "5",
"tags": [],
"hidden": False
}
}
},
{
"jsonrpc": "2.0",
"method": "game_library_settings_import_success",
"params": {
"game_library_settings": {
"game_id": "7",
"tags": ["tag1", "tag2", "tag3"]
}
}
},
{
"jsonrpc": "2.0",
"method": "game_library_settings_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_game_library_settings_error(exception, code, message, plugin, read, write):
plugin.prepare_game_library_settings_context.return_value = async_return_value(None)
request = {
"jsonrpc": "2.0",
"id": "3",
"method": "start_game_library_settings_import",
"params": {
"game_ids": ["6"]
}
}
read.side_effect = [async_return_value(create_message(request)), async_return_value(b"", 10)]
plugin.get_game_library_settings.side_effect = exception
await plugin.run()
plugin.get_game_library_settings.assert_called()
plugin.game_library_settings_import_complete.assert_called_once_with()
assert get_messages(write) == [
{
"jsonrpc": "2.0",
"id": "3",
"result": None
},
{
"jsonrpc": "2.0",
"method": "game_library_settings_import_failure",
"params": {
"game_id": "6",
"error": {
"code": code,
"message": message
}
}
},
{
"jsonrpc": "2.0",
"method": "game_library_settings_import_finished",
"params": None
}
]
@pytest.mark.asyncio
async def test_prepare_get_game_library_settings_context_error(plugin, read, write):
plugin.prepare_game_library_settings_context.side_effect = BackendError()
request = {
"jsonrpc": "2.0",
"id": "3",
"method": "start_game_library_settings_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": "3",
"error": {
"code": 4,
"message": "Backend error"
}
}
]
@pytest.mark.asyncio
async def test_import_in_progress(plugin, read, write):
plugin.prepare_game_library_settings_context.return_value = async_return_value(None)
requests = [
{
"jsonrpc": "2.0",
"id": "3",
"method": "start_game_library_settings_import",
"params": {
"game_ids": ["6"]
}
},
{
"jsonrpc": "2.0",
"id": "4",
"method": "start_game_library_settings_import",
"params": {
"game_ids": ["7"]
}
}
]
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()
messages = get_messages(write)
assert {
"jsonrpc": "2.0",
"id": "3",
"result": None
} in messages
assert {
"jsonrpc": "2.0",
"id": "4",
"error": {
"code": 600,
"message": "Import already in progress"
}
} in messages

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