Compare commits

..

47 Commits
0.47 ... 0.59

Author SHA1 Message Date
Rafal Makagon
75e5a66fbe Increment version 2019-11-27 13:14:11 +01:00
Mieszko Banczerowski
2a9ec3067d Fix sending Exceptions with custom data 2019-11-27 13:12:20 +01:00
Rafal Makagon
69532a5ba9 fix richpresence parameter name 2019-11-27 13:10:43 +01:00
Romuald Juchnowicz-Bierbasz
f5d47b0167 Add timeout to shutdown 2019-11-22 13:11:08 +01:00
Romuald Juchnowicz-Bierbasz
02f4faa432 Do not use root logger 2019-11-22 13:07:33 +01:00
Romuald Juchnowicz-Bierbasz
3d3922c965 Add async_raise 2019-11-20 17:57:17 +01:00
Rafal Makagon
b695cdfc78 Increment version 2019-11-20 16:23:23 +01:00
Rafal Makagon
66ab1809b8 Do not log data sent to socket 2019-11-20 15:48:00 +01:00
Rafal Makagon
8bf367d0f9 Increment vesion 2019-11-18 13:59:09 +01:00
Rafal Makagon
2cf83395fa fix parse sphinx parse error
+ other small imporvements in docs
2019-11-15 16:06:45 +01:00
Aliaksei Paulouski
4aa76b6e3d SDK-3137: friends and presence updates 2019-11-13 13:40:53 +01:00
Aleksej Pawlowskij
c03465e8f2 SDK-3145: Add optional profile and avatar url 2019-11-13 08:49:38 +01:00
mezzode
810a87718d Fix incorrect field name in GameTime docstring 2019-11-08 11:25:58 +01:00
Rafal Makagon
e32abe11b7 Increment version 2019-11-07 12:29:13 +01:00
FriendsOfGalaxy
d79f183826 Fix RegistryMonitor.is_updated method 2019-11-04 14:26:26 +01:00
Rafal Makagon
78f1d5a4cc Add refresh_credentials method to plugin 2019-10-31 15:15:14 +01:00
Rafal Makagon
9041dbd98c Increase size that is to be read at once from reader's buffer 2019-10-30 15:42:03 +01:00
Aleksej Pawlowskij
e57ecc489c SDK-3110: Deprecate FriendInfo and replace with UserInfo 2019-10-28 11:37:21 +01:00
Romuald Bierbasz
0a20629459 Use pytest 5.2.2 2019-10-28 11:35:59 +01:00
Romuald Bierbasz
1585bab203 Wait for drain before writing 2019-10-22 11:30:01 +02:00
Aleksej Pawlowskij
92caf682d8 Increment version 2019-10-21 16:13:09 +02:00
Aleksej Pawlowskij
062d6a9428 Add user presence import feature 2019-10-21 16:11:10 +02:00
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
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
Romuald Bierbasz
4d3c9b78c4 Add RegistryMonitor 2019-09-25 11:46:18 +02:00
Romuald Juchnowicz-Bierbasz
392e4c5f68 Print error data in import notifications 2019-09-19 14:47:50 +02:00
Romuald Juchnowicz-Bierbasz
4d6d3b8eb2 Increment version 2019-09-18 12:16:34 +02:00
Romuald Juchnowicz-Bierbasz
d5610221a9 Do not wait for external tasks in run 2019-09-18 12:15:18 +02:00
Aleksej Pawlowskij
aa7b398d3b Increment version 2019-09-05 09:14:46 +02:00
Aleksej Pawlowskij
8d6ec500f9 SDK-3041: Add new platforms 2019-09-05 09:14:37 +02:00
Romuald Juchnowicz-Bierbasz
bab0be9994 Increment version 2019-09-02 17:31:47 +02:00
Romuald Juchnowicz-Bierbasz
0294e2a1f1 SDK-3023: Introduce task managers 2019-09-02 17:28:13 +02:00
Mieszko Banczerowski
0ab00e4119 Fix typing in persistent cache 2019-08-23 17:48:42 +02:00
Mieszko Banczerowski
b20fce057b GPI-661: Add docs to http module 2019-08-23 17:46:08 +02:00
Romuald Juchnowicz-Bierbasz
dec59f47dd Add more detials to launch_platform_client method 2019-08-22 09:42:10 +02:00
Mieszko Banczerowski
ca85b2428b GPI-517: Mark coroutines in docs 2019-08-20 14:35:54 +02:00
Pawel Czoppa
d8a00d58a6 Merge branch 'master' of https://gitlab.gog.com/galaxy-client/galaxy-plugin-api into platform_id_update
# Conflicts:
#	PLATFORM_IDs.md
2019-08-20 14:22:36 +02:00
Romuald Juchnowicz-Bierbasz
d4cd1cedfd Install wheel in deploy 2019-08-14 16:07:01 +02:00
Romuald Juchnowicz-Bierbasz
161122b94d SDK-2988: Upload to official pypi server 2019-08-14 13:48:35 +02:00
Piotr Marzec
8d210e7f3e platform name fixing 2019-06-19 20:43:25 +02:00
35 changed files with 1808 additions and 311 deletions

1
.gitignore vendored
View File

@@ -7,3 +7,4 @@ docs/build/
Pipfile
.idea
docs/source/_build
.mypy_cache

View File

@@ -14,13 +14,19 @@ test_package:
deploy_package:
stage: deploy
variables:
TWINE_USERNAME: $PYPI_USERNAME
TWINE_PASSWORD: $PYPI_PASSWORD
script:
- pip install twine wheel
- rm -rf dist
- export VERSION=$(python setup.py --version)
- python setup.py sdist --formats=gztar upload -r gog-pypi
- python setup.py sdist --formats=gztar bdist_wheel
- twine upload dist/*
- curl -X POST --silent --show-error --fail
"https://gitlab.gog.com/api/v4/projects/${CI_PROJECT_ID}/repository/tags?tag_name=${VERSION}&ref=${CI_COMMIT_REF_NAME}&private_token=${PACKAGE_DEPLOYER_API_TOKEN}"
when: manual
only:
- master
except:
- tags
- tags

View File

@@ -20,7 +20,7 @@ Platform ID list for GOG Galaxy 2.0 Integrations
| nswitch | Nintendo Switch |
| nwiiu | Nintendo Wii U |
| nwii | Nintendo Wii |
| ncube | Nintendo Game Cube |
| ncube | Nintendo GameCube |
| riot | Riot |
| wargaming | Wargaming |
| ngameboy | Nintendo Game Boy |
@@ -58,25 +58,25 @@ Platform ID list for GOG Galaxy 2.0 Integrations
| bb | BestBuy |
| gameuk | Game UK |
| fanatical | Fanatical store |
| playasia | PlayAsia |
| playasia | Play-Asia |
| stadia | Google Stadia |
| arc | ARC |
| eso | ESO |
| glyph | Trion World |
| aionl | Aion: Legions of War |
| aion | Aion |
| blade | Blade and Soul |
| blade | Blade & Soul |
| gw | Guild Wars |
| gw2 | Guild Wars 2 |
| lin2 | Lineage 2 |
| ffxi | Final Fantasy XI |
| ffxiv | Final Fantasy XIV |
| totalwar | TotalWar |
| ffxiv | Final Fantasy XIV |
| totalwar | Total War |
| winstore | Windows Store |
| elites | Elite Dangerous |
| star | Star Citizen |
| psp | Playstation Portable |
| psvita | Playstation Vita |
| psp | PlayStation Portable |
| psvita | PlayStation Vita |
| nds | Nintendo DS |
| 3ds | Nintendo 3DS |
| pathofexile | Path of Exile |

View File

@@ -1,4 +1,5 @@
Sphinx==2.0.1
sphinx-rtd-theme==0.4.3
sphinx-autodoc-typehints==1.6.0
sphinxcontrib-asyncio==0.2.0
m2r==0.2.1

View File

@@ -32,12 +32,13 @@ release = _version
# ones.
extensions = [
'sphinx.ext.autodoc',
'sphinxcontrib.asyncio',
'sphinx_autodoc_typehints',
'm2r' # mdinclude directive for makrdown files
]
autodoc_member_order = 'bysource'
autodoc_inherit_docstrings = False
autodoc_mock_imports = ["galaxy.http"]
autodoc_mock_imports = ["aiohttp"]
set_type_checking_flag = True

View File

@@ -0,0 +1,8 @@
galaxy.http
=================
.. automodule:: galaxy.http
:members:
:special-members: __init__
:undoc-members:
:show-inheritance:

View File

@@ -6,7 +6,8 @@ GOG Galaxy Integrations Python API
:includehidden:
Overview <overview>
API <galaxy.api>
galaxy.api
galaxy.http
Platform ID's <platforms>
Index

View File

@@ -1,8 +1,8 @@
-e .
pytest==4.2.0
pytest==5.2.2
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.47",
version="0.59",
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

@@ -81,6 +81,17 @@ class Platform(Enum):
NintendoDs = "nds"
Nintendo3Ds = "3ds"
PathOfExile = "pathofexile"
Twitch = "twitch"
Minecraft = "minecraft"
GameSessions = "gamesessions"
Nuuvem = "nuuvem"
FXStore = "fxstore"
IndieGala = "indiegala"
Playfire = "playfire"
Oculus = "oculus"
Test = "test"
Rockstar = "rockstar"
class Feature(Enum):
"""Possible features that can be implemented by an integration.
@@ -100,6 +111,9 @@ class Feature(Enum):
ImportFriends = "ImportFriends"
ShutdownPlatformClient = "ShutdownPlatformClient"
LaunchPlatformClient = "LaunchPlatformClient"
ImportGameLibrarySettings = "ImportGameLibrarySettings"
ImportOSCompatibility = "ImportOSCompatibility"
ImportUserPresence = "ImportUserPresence"
class LicenseType(Enum):
@@ -118,3 +132,20 @@ 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
class PresenceState(Enum):
""""Possible states of a user."""
Unknown = "unknown"
Online = "online"
Offline = "offline"
Away = "away"

View File

@@ -6,6 +6,11 @@ import inspect
import json
from galaxy.reader import StreamLineReader
from galaxy.task_manager import TaskManager
logger = logging.getLogger(__name__)
class JsonRpcError(Exception):
def __init__(self, code, message, data=None):
@@ -17,6 +22,17 @@ class JsonRpcError(Exception):
def __eq__(self, other):
return self.code == other.code and self.message == other.message and self.data == other.data
def json(self):
obj = {
"code": self.code,
"message": self.message
}
if self.data is not None:
obj["data"] = self.data
return obj
class ParseError(JsonRpcError):
def __init__(self):
super().__init__(-32700, "Parse error")
@@ -52,7 +68,9 @@ class UnknownError(ApplicationError):
super().__init__(0, "Unknown error", data)
Request = namedtuple("Request", ["method", "params", "id"], defaults=[{}, None])
Method = namedtuple("Method", ["callback", "signature", "internal", "sensitive_params"])
Response = namedtuple("Response", ["id", "result", "error"], defaults=[None, {}, {}])
Method = namedtuple("Method", ["callback", "signature", "immediate", "sensitive_params"])
def anonymise_sensitive_params(params, sensitive_params):
anomized_data = "****"
@@ -66,7 +84,7 @@ def anonymise_sensitive_params(params, sensitive_params):
return params
class Server():
class Connection():
def __init__(self, reader, writer, encoder=json.JSONEncoder()):
self._active = True
self._reader = StreamLineReader(reader)
@@ -74,9 +92,12 @@ class Server():
self._encoder = encoder
self._methods = {}
self._notifications = {}
self._eof_listeners = []
self._task_manager = TaskManager("jsonrpc server")
self._write_lock = asyncio.Lock()
self._last_request_id = 0
self._requests_futures = {}
def register_method(self, name, callback, internal, sensitive_params=False):
def register_method(self, name, callback, immediate, sensitive_params=False):
"""
Register method
@@ -86,9 +107,9 @@ class Server():
:param sensitive_params: list of parameters that are anonymized before logging; \
if False - no params are considered sensitive, if True - all params are considered sensitive
"""
self._methods[name] = Method(callback, inspect.signature(callback), internal, sensitive_params)
self._methods[name] = Method(callback, inspect.signature(callback), immediate, sensitive_params)
def register_notification(self, name, callback, internal, sensitive_params=False):
def register_notification(self, name, callback, immediate, sensitive_params=False):
"""
Register notification
@@ -98,10 +119,48 @@ class Server():
:param sensitive_params: list of parameters that are anonymized before logging; \
if False - no params are considered sensitive, if True - all params are considered sensitive
"""
self._notifications[name] = Method(callback, inspect.signature(callback), internal, sensitive_params)
self._notifications[name] = Method(callback, inspect.signature(callback), immediate, sensitive_params)
def register_eof(self, callback):
self._eof_listeners.append(callback)
async def send_request(self, method, params, sensitive_params):
"""
Send request
:param method:
:param params:
:param sensitive_params: list of parameters that are anonymized before logging; \
if False - no params are considered sensitive, if True - all params are considered sensitive
"""
self._last_request_id += 1
request_id = str(self._last_request_id)
loop = asyncio.get_running_loop()
future = loop.create_future()
self._requests_futures[self._last_request_id] = (future, sensitive_params)
logger.info(
"Sending request: id=%s, method=%s, params=%s",
request_id, method, anonymise_sensitive_params(params, sensitive_params)
)
self._send_request(request_id, method, params)
return await future
def send_notification(self, method, params, sensitive_params=False):
"""
Send notification
:param method:
:param params:
:param sensitive_params: list of parameters that are anonymized before logging; \
if False - no params are considered sensitive, if True - all params are considered sensitive
"""
logger.info(
"Sending notification: method=%s, params=%s",
method, anonymise_sensitive_params(params, sensitive_params)
)
self._send_notification(method, params)
async def run(self):
while self._active:
@@ -114,38 +173,66 @@ class Server():
self._eof()
continue
data = data.strip()
logging.debug("Received %d bytes of data", len(data))
logger.debug("Received %d bytes of data", len(data))
self._handle_input(data)
await asyncio.sleep(0) # To not starve task queue
def stop(self):
self._active = False
def close(self):
if self._active:
logger.info("Closing JSON-RPC server - not more messages will be read")
self._active = False
async def wait_closed(self):
await self._task_manager.wait()
def _eof(self):
logging.info("Received EOF")
self.stop()
for listener in self._eof_listeners:
listener()
logger.info("Received EOF")
self.close()
def _handle_input(self, data):
try:
request = self._parse_request(data)
message = self._parse_message(data)
except JsonRpcError as error:
self._send_error(None, error)
return
if request.id is not None:
self._handle_request(request)
else:
self._handle_notification(request)
if isinstance(message, Request):
if message.id is not None:
self._handle_request(message)
else:
self._handle_notification(message)
elif isinstance(message, Response):
self._handle_response(message)
def _handle_response(self, response):
request_future = self._requests_futures.get(int(response.id))
if request_future is None:
response_type = "response" if response.result is not None else "error"
logger.warning("Received %s for unknown request: %s", response_type, response.id)
return
future, sensitive_params = request_future
if response.error:
error = JsonRpcError(
response.error.setdefault("code", 0),
response.error.setdefault("message", ""),
response.error.setdefault("data", None)
)
self._log_error(response, error, sensitive_params)
future.set_exception(error)
return
self._log_response(response, sensitive_params)
future.set_result(response.result)
def _handle_notification(self, request):
method = self._notifications.get(request.method)
if not method:
logging.error("Received unknown notification: %s", request.method)
logger.error("Received unknown notification: %s", request.method)
return
callback, signature, internal, sensitive_params = method
callback, signature, immediate, sensitive_params = method
self._log_request(request, sensitive_params)
try:
@@ -153,23 +240,22 @@ class Server():
except TypeError:
self._send_error(request.id, InvalidParams())
if internal:
# internal requests are handled immediately
if immediate:
callback(*bound_args.args, **bound_args.kwargs)
else:
try:
asyncio.create_task(callback(*bound_args.args, **bound_args.kwargs))
self._task_manager.create_task(callback(*bound_args.args, **bound_args.kwargs), request.method)
except Exception:
logging.exception("Unexpected exception raised in notification handler")
logger.exception("Unexpected exception raised in notification handler")
def _handle_request(self, request):
method = self._methods.get(request.method)
if not method:
logging.error("Received unknown request: %s", request.method)
logger.error("Received unknown request: %s", request.method)
self._send_error(request.id, MethodNotFound())
return
callback, signature, internal, sensitive_params = method
callback, signature, immediate, sensitive_params = method
self._log_request(request, sensitive_params)
try:
@@ -177,8 +263,7 @@ class Server():
except TypeError:
self._send_error(request.id, InvalidParams())
if internal:
# internal requests are handled immediately
if immediate:
response = callback(*bound_args.args, **bound_args.kwargs)
self._send_response(request.id, response)
else:
@@ -190,34 +275,44 @@ class Server():
self._send_error(request.id, MethodNotFound())
except JsonRpcError as error:
self._send_error(request.id, error)
except asyncio.CancelledError:
self._send_error(request.id, Aborted())
except Exception as e: #pylint: disable=broad-except
logging.exception("Unexpected exception raised in plugin handler")
logger.exception("Unexpected exception raised in plugin handler")
self._send_error(request.id, UnknownError(str(e)))
asyncio.create_task(handle())
self._task_manager.create_task(handle(), request.method)
@staticmethod
def _parse_request(data):
def _parse_message(data):
try:
jsonrpc_request = json.loads(data, encoding="utf-8")
if jsonrpc_request.get("jsonrpc") != "2.0":
jsonrpc_message = json.loads(data, encoding="utf-8")
if jsonrpc_message.get("jsonrpc") != "2.0":
raise InvalidRequest()
del jsonrpc_request["jsonrpc"]
return Request(**jsonrpc_request)
del jsonrpc_message["jsonrpc"]
if "result" in jsonrpc_message.keys() or "error" in jsonrpc_message.keys():
return Response(**jsonrpc_message)
else:
return Request(**jsonrpc_message)
except json.JSONDecodeError:
raise ParseError()
except TypeError:
raise InvalidRequest()
def _send(self, data):
async def send_task(data_):
async with self._write_lock:
self._writer.write(data_)
await self._writer.drain()
try:
line = self._encoder.encode(data)
logging.debug("Sending data: %s", line)
data = (line + "\n").encode("utf-8")
self._writer.write(data)
asyncio.create_task(self._writer.drain())
logger.debug("Sending %d byte of data", len(data))
self._task_manager.create_task(send_task(data), "send")
except TypeError as error:
logging.error(str(error))
logger.error(str(error))
def _send_response(self, request_id, result):
response = {
@@ -231,59 +326,44 @@ class Server():
response = {
"jsonrpc": "2.0",
"id": request_id,
"error": {
"code": error.code,
"message": error.message
}
"error": error.json()
}
if error.data is not None:
response["error"]["data"] = error.data
self._send(response)
@staticmethod
def _log_request(request, sensitive_params):
params = anonymise_sensitive_params(request.params, sensitive_params)
if request.id is not None:
logging.info("Handling request: id=%s, method=%s, params=%s", request.id, request.method, params)
else:
logging.info("Handling notification: method=%s, params=%s", request.method, params)
def _send_request(self, request_id, method, params):
request = {
"jsonrpc": "2.0",
"method": method,
"id": request_id,
"params": params
}
self._send(request)
class NotificationClient():
def __init__(self, writer, encoder=json.JSONEncoder()):
self._writer = writer
self._encoder = encoder
self._methods = {}
def notify(self, method, params, sensitive_params=False):
"""
Send notification
:param method:
:param params:
:param sensitive_params: list of parameters that are anonymized before logging; \
if False - no params are considered sensitive, if True - all params are considered sensitive
"""
def _send_notification(self, method, params):
notification = {
"jsonrpc": "2.0",
"method": method,
"params": params
}
self._log(method, params, sensitive_params)
self._send(notification)
def _send(self, data):
try:
line = self._encoder.encode(data)
data = (line + "\n").encode("utf-8")
logging.debug("Sending %d byte of data", len(data))
self._writer.write(data)
asyncio.create_task(self._writer.drain())
except TypeError as error:
logging.error("Failed to parse outgoing message: %s", str(error))
@staticmethod
def _log_request(request, sensitive_params):
params = anonymise_sensitive_params(request.params, sensitive_params)
if request.id is not None:
logger.info("Handling request: id=%s, method=%s, params=%s", request.id, request.method, params)
else:
logger.info("Handling notification: method=%s, params=%s", request.method, params)
@staticmethod
def _log(method, params, sensitive_params):
params = anonymise_sensitive_params(params, sensitive_params)
logging.info("Sending notification: method=%s, params=%s", method, params)
def _log_response(response, sensitive_params):
result = anonymise_sensitive_params(response.result, sensitive_params)
logger.info("Handling response: id=%s, result=%s", response.id, result)
@staticmethod
def _log_error(response, error, sensitive_params):
data = anonymise_sensitive_params(error.data, sensitive_params)
logger.info("Handling error: id=%s, code=%s, description=%s, data=%s",
response.id, error.code, error.message, data
)

View File

@@ -2,17 +2,20 @@ import asyncio
import dataclasses
import json
import logging
import logging.handlers
import sys
from collections import OrderedDict
from enum import Enum
from itertools import count
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.jsonrpc import ApplicationError, Connection
from galaxy.api.types import (
Achievement, Authentication, Game, GameLibrarySettings, GameTime, LocalGame, NextStep, UserInfo, UserPresence
)
from galaxy.task_manager import TaskManager
logger = logging.getLogger(__name__)
class JSONEncoder(json.JSONEncoder):
@@ -32,44 +35,41 @@ class Plugin:
"""Use and override methods of this class to create a new platform integration."""
def __init__(self, platform, version, reader, writer, handshake_token):
logging.info("Creating plugin for platform %s, version %s", platform.value, version)
logger.info("Creating plugin for platform %s, version %s", platform.value, version)
self._platform = platform
self._version = version
self._features: Set[Feature] = set()
self._active = True
self._pass_control_task = None
self._reader, self._writer = reader, writer
self._handshake_token = handshake_token
encoder = JSONEncoder()
self._server = Server(self._reader, self._writer, encoder)
self._notification_client = NotificationClient(self._writer, encoder)
def eof_handler():
self._shutdown()
self._server.register_eof(eof_handler)
self._connection = Connection(self._reader, self._writer, encoder)
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._user_presence_import_in_progress = False
self._persistent_cache = dict()
self._tasks = OrderedDict()
self._task_counter = count()
self._internal_task_manager = TaskManager("plugin internal")
self._external_task_manager = TaskManager("plugin external")
# internal
self._register_method("shutdown", self._shutdown, internal=True)
self._register_method("get_capabilities", self._get_capabilities, internal=True)
self._register_method("get_capabilities", self._get_capabilities, internal=True, immediate=True)
self._register_method(
"initialize_cache",
self._initialize_cache,
internal=True,
immediate=True,
sensitive_params="data"
)
self._register_method("ping", self._ping, internal=True)
self._register_method("ping", self._ping, internal=True, immediate=True)
# implemented by developer
self._register_method(
@@ -116,12 +116,28 @@ 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"])
self._register_method("start_user_presence_import", self._start_user_presence_import)
self._detect_feature(Feature.ImportUserPresence, ["get_user_presence"])
async def __aenter__(self):
return self
async def __aexit__(self, exc_type, exc, tb):
self.close()
await self.wait_closed()
@property
def features(self) -> List[Feature]:
return list(self._features)
@property
def persistent_cache(self) -> Dict:
def persistent_cache(self) -> Dict[str, str]:
"""The cache is only available after the :meth:`~.handshake_complete()` is called.
"""
return self._persistent_cache
@@ -136,71 +152,88 @@ class Plugin:
if self._implements(methods):
self._features.add(feature)
def _register_method(self, name, handler, result_name=None, internal=False, sensitive_params=False):
if internal:
def _register_method(self, name, handler, result_name=None, internal=False, immediate=False, sensitive_params=False):
def wrap_result(result):
if result_name:
result = {
result_name: result
}
return result
if immediate:
def method(*args, **kwargs):
result = handler(*args, **kwargs)
if result_name:
result = {
result_name: result
}
return result
return wrap_result(result)
self._server.register_method(name, method, True, sensitive_params)
self._connection.register_method(name, method, True, sensitive_params)
else:
async def method(*args, **kwargs):
result = await handler(*args, **kwargs)
if result_name:
result = {
result_name: result
}
return result
if not internal:
handler_ = self._wrap_external_method(handler, name)
else:
handler_ = handler
result = await handler_(*args, **kwargs)
return wrap_result(result)
self._server.register_method(name, method, False, sensitive_params)
self._connection.register_method(name, method, False, sensitive_params)
def _register_notification(self, name, handler, internal=False, sensitive_params=False):
self._server.register_notification(name, handler, internal, sensitive_params)
def _register_notification(self, name, handler, internal=False, immediate=False, sensitive_params=False):
if not internal and not immediate:
handler = self._wrap_external_method(handler, name)
self._connection.register_notification(name, handler, immediate, sensitive_params)
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()
if self._pass_control_task is not None:
await self._pass_control_task
await self._connection.run()
logger.debug("Plugin run loop finished")
def close(self) -> None:
if not self._active:
return
logger.info("Closing plugin")
self._connection.close()
self._external_task_manager.cancel()
async def shutdown():
try:
await asyncio.wait_for(self.shutdown(), 30)
except asyncio.TimeoutError:
logging.warning("Plugin shutdown timed out")
self._internal_task_manager.create_task(shutdown(), "shutdown")
self._active = False
async def wait_closed(self) -> None:
logger.debug("Waiting for plugin to close")
await self._external_task_manager.wait()
await self._internal_task_manager.wait()
await self._connection.wait_closed()
logger.debug("Plugin closed")
def create_task(self, coro, description):
"""Wrapper around asyncio.create_task - takes care of canceling tasks on shutdown"""
async def task_wrapper(task_id):
try:
return await coro
except asyncio.CancelledError:
logging.debug("Canceled task %d (%s)", task_id, description)
except Exception:
logging.exception("Exception raised in task %d (%s)", task_id, description)
finally:
del self._tasks[task_id]
task_id = next(self._task_counter)
logging.debug("Creating task %d (%s)", task_id, description)
task = asyncio.create_task(task_wrapper(task_id))
self._tasks[task_id] = task
return task
return self._external_task_manager.create_task(coro, description)
async def _pass_control(self):
while self._active:
try:
self.tick()
except Exception:
logging.exception("Unexpected exception raised in plugin tick")
logger.exception("Unexpected exception raised in plugin tick")
await asyncio.sleep(1)
def _shutdown(self):
logging.info("Shutting down")
self._server.stop()
self._active = False
self.shutdown()
for task in self._tasks.values():
task.cancel()
async def _shutdown(self):
logger.info("Shutting down")
self.close()
await self._external_task_manager.wait()
await self._internal_task_manager.wait()
def _get_capabilities(self):
return {
@@ -214,8 +247,8 @@ class Plugin:
try:
self.handshake_complete()
except Exception:
logging.exception("Unhandled exception during `handshake_complete` step")
self._pass_control_task = asyncio.create_task(self._pass_control())
logger.exception("Unhandled exception during `handshake_complete` step")
self._internal_task_manager.create_task(self._pass_control(), "tick")
@staticmethod
def _ping():
@@ -243,9 +276,11 @@ class Plugin:
self.store_credentials(user_data['credentials'])
return Authentication(user_data['userId'], user_data['username'])
"""
self.persistent_cache['credentials'] = credentials
self._notification_client.notify("store_credentials", credentials, sensitive_params=True)
"""
# temporary solution for persistent_cache vs credentials issue
self.persistent_cache["credentials"] = credentials # type: ignore
self._connection.send_notification("store_credentials", credentials, sensitive_params=True)
def add_game(self, game: Game) -> None:
"""Notify the client to add game to the list of owned games
@@ -267,7 +302,7 @@ class Plugin:
"""
params = {"owned_game": game}
self._notification_client.notify("owned_game_added", params)
self._connection.send_notification("owned_game_added", params)
def remove_game(self, game_id: str) -> None:
"""Notify the client to remove game from the list of owned games
@@ -289,7 +324,7 @@ class Plugin:
"""
params = {"game_id": game_id}
self._notification_client.notify("owned_game_removed", params)
self._connection.send_notification("owned_game_removed", params)
def update_game(self, game: Game) -> None:
"""Notify the client to update the status of a game
@@ -298,7 +333,7 @@ class Plugin:
:param game: Game to update
"""
params = {"owned_game": game}
self._notification_client.notify("owned_game_updated", params)
self._connection.send_notification("owned_game_updated", params)
def unlock_achievement(self, game_id: str, achievement: Achievement) -> None:
"""Notify the client to unlock an achievement for a specific game.
@@ -310,27 +345,24 @@ class Plugin:
"game_id": game_id,
"achievement": achievement
}
self._notification_client.notify("achievement_unlocked", params)
self._connection.send_notification("achievement_unlocked", params)
def _game_achievements_import_success(self, game_id: str, achievements: List[Achievement]) -> None:
params = {
"game_id": game_id,
"unlocked_achievements": achievements
}
self._notification_client.notify("game_achievements_import_success", params)
self._connection.send_notification("game_achievements_import_success", params)
def _game_achievements_import_failure(self, game_id: str, error: ApplicationError) -> None:
params = {
"game_id": game_id,
"error": {
"code": error.code,
"message": error.message
}
"error": error.json()
}
self._notification_client.notify("game_achievements_import_failure", params)
self._connection.send_notification("game_achievements_import_failure", params)
def _achievements_import_finished(self) -> None:
self._notification_client.notify("achievements_import_finished", None)
self._connection.send_notification("achievements_import_finished", None)
def update_local_game_status(self, local_game: LocalGame) -> None:
"""Notify the client to update the status of a local game.
@@ -356,15 +388,15 @@ class Plugin:
self._check_statuses_task = asyncio.create_task(self._check_statuses())
"""
params = {"local_game": local_game}
self._notification_client.notify("local_game_status_changed", params)
self._connection.send_notification("local_game_status_changed", params)
def add_friend(self, user: FriendInfo) -> None:
def add_friend(self, user: UserInfo) -> None:
"""Notify the client to add a user to friends list of the currently authenticated user.
:param user: FriendInfo of a user that the client will add to friends list
:param user: UserInfo of a user that the client will add to friends list
"""
params = {"friend_info": user}
self._notification_client.notify("friend_added", params)
self._connection.send_notification("friend_added", params)
def remove_friend(self, user_id: str) -> None:
"""Notify the client to remove a user from friends list of the currently authenticated user.
@@ -372,7 +404,14 @@ class Plugin:
:param user_id: id of the user to remove from friends list
"""
params = {"user_id": user_id}
self._notification_client.notify("friend_removed", params)
self._connection.send_notification("friend_removed", params)
def update_friend_info(self, user: UserInfo) -> None:
"""Notify the client about the updated friend information.
:param user: UserInfo of a friend whose info was updated
"""
self._connection.send_notification("friend_updated", params={"friend_info": user})
def update_game_time(self, game_time: GameTime) -> None:
"""Notify the client to update game time for a game.
@@ -380,40 +419,110 @@ class Plugin:
:param game_time: game time to update
"""
params = {"game_time": game_time}
self._notification_client.notify("game_time_updated", params)
self._connection.send_notification("game_time_updated", params)
def update_user_presence(self, user_id: str, user_presence: UserPresence) -> None:
"""Notify the client about the updated user presence information.
:param user_id: the id of the user whose presence information is updated
:param user_presence: presence information of the specified user
"""
self._connection.send_notification(
"user_presence_updated",
{
"user_id": user_id,
"presence": user_presence
}
)
def _game_time_import_success(self, game_time: GameTime) -> None:
params = {"game_time": game_time}
self._notification_client.notify("game_time_import_success", params)
self._connection.send_notification("game_time_import_success", params)
def _game_time_import_failure(self, game_id: str, error: ApplicationError) -> None:
params = {
"game_id": game_id,
"error": {
"code": error.code,
"message": error.message
}
"error": error.json()
}
self._notification_client.notify("game_time_import_failure", params)
self._connection.send_notification("game_time_import_failure", params)
def _game_times_import_finished(self) -> None:
self._notification_client.notify("game_times_import_finished", None)
self._connection.send_notification("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._connection.send_notification("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._connection.send_notification("game_library_settings_import_failure", params)
def _game_library_settings_import_finished(self) -> None:
self._connection.send_notification("game_library_settings_import_finished", None)
def _os_compatibility_import_success(self, game_id: str, os_compatibility: Optional[OSCompatibility]) -> None:
self._connection.send_notification(
"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._connection.send_notification(
"os_compatibility_import_failure",
{
"game_id": game_id,
"error": error.json()
}
)
def _os_compatibility_import_finished(self) -> None:
self._connection.send_notification("os_compatibility_import_finished", None)
def _user_presence_import_success(self, user_id: str, user_presence: UserPresence) -> None:
self._connection.send_notification(
"user_presence_import_success",
{
"user_id": user_id,
"presence": user_presence
}
)
def _user_presence_import_failure(self, user_id: str, error: ApplicationError) -> None:
self._connection.send_notification(
"user_presence_import_failure",
{
"user_id": user_id,
"error": error.json()
}
)
def _user_presence_import_finished(self) -> None:
self._connection.send_notification("user_presence_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.
"""
self._notification_client.notify("authentication_lost", None)
self._connection.send_notification("authentication_lost", None)
def push_cache(self) -> None:
"""Push local copy of the persistent cache to the GOG Galaxy Client replacing existing one.
"""
self._notification_client.notify(
self._connection.send_notification(
"push_cache",
params={"data": self._persistent_cache},
sensitive_params="data"
)
async def refresh_credentials(self, params: Dict[str, Any], sensitive_params) -> Dict[str, Any]:
return await self._connection.send_request("refresh_credentials", params, sensitive_params)
# handlers
def handshake_complete(self) -> None:
"""This method is called right after the handshake with the GOG Galaxy Client is complete and
@@ -442,7 +551,7 @@ class Plugin:
"""
def shutdown(self) -> None:
async def shutdown(self) -> None:
"""This method is called on integration shutdown.
Override it to implement tear down.
This method is called by the GOG Galaxy Client."""
@@ -478,10 +587,11 @@ class Plugin:
async def pass_login_credentials(self, step: str, credentials: Dict[str, str], cookies: List[Dict[str, str]]) \
-> Union[NextStep, Authentication]:
"""This method is called if we return galaxy.api.types.NextStep from authenticate or from pass_login_credentials.
"""This method is called if we return :class:`~galaxy.api.types.NextStep` from :meth:`.authenticate`
or :meth:`.pass_login_credentials`.
This method's parameters provide the data extracted from the web page navigation that previous NextStep finished on.
This method should either return galaxy.api.types.Authentication if the authentication is finished
or galaxy.api.types.NextStep if it requires going to another cef url.
This method should either return :class:`~galaxy.api.types.Authentication` if the authentication is finished
or :class:`~galaxy.api.types.NextStep` if it requires going to another cef url.
This method is called by the GOG Galaxy Client.
:param step: deprecated.
@@ -538,7 +648,7 @@ class Plugin:
except ApplicationError as error:
self._game_achievements_import_failure(game_id, error)
except Exception:
logging.exception("Unexpected exception raised in import_game_achievements")
logger.exception("Unexpected exception raised in import_game_achievements")
self._game_achievements_import_failure(game_id, UnknownError())
async def import_games_achievements(game_ids_, context_):
@@ -550,7 +660,11 @@ class Plugin:
self._achievements_import_in_progress = False
self.achievements_import_complete()
self.create_task(import_games_achievements(game_ids, context), "Games unlocked achievements import")
self._external_task_manager.create_task(
import_games_achievements(game_ids, context),
"unlocked achievements import",
handle_exceptions=False
)
self._achievements_import_in_progress = True
async def prepare_achievements_context(self, game_ids: List[str]) -> Any:
@@ -661,11 +775,11 @@ class Plugin:
raise NotImplementedError()
async def launch_platform_client(self) -> None:
"""Override this method to launch platform client.
"""Override this method to launch platform client. Preferably minimized to tray.
This method is called by the GOG Galaxy Client."""
raise NotImplementedError()
async def get_friends(self) -> List[FriendInfo]:
async def get_friends(self) -> List[UserInfo]:
"""Override this method to return the friends list
of the currently authenticated user.
This method is called by the GOG Galaxy Client.
@@ -698,7 +812,7 @@ class Plugin:
except ApplicationError as error:
self._game_time_import_failure(game_id, error)
except Exception:
logging.exception("Unexpected exception raised in import_game_time")
logger.exception("Unexpected exception raised in import_game_time")
self._game_time_import_failure(game_id, UnknownError())
async def import_game_times(game_ids_, context_):
@@ -710,7 +824,11 @@ class Plugin:
self._game_times_import_in_progress = False
self.game_times_import_complete()
self.create_task(import_game_times(game_ids, context), "Game times import")
self._external_task_manager.create_task(
import_game_times(game_ids, context),
"game times import",
handle_exceptions=False
)
self._game_times_import_in_progress = True
async def prepare_game_times_context(self, game_ids: List[str]) -> Any:
@@ -739,6 +857,176 @@ 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:
logger.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:
logger.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)."""
async def _start_user_presence_import(self, user_id_list: List[str]) -> None:
if self._user_presence_import_in_progress:
raise ImportInProgress()
context = await self.prepare_user_presence_context(user_id_list)
async def import_user_presence(user_id, context_) -> None:
try:
self._user_presence_import_success(user_id, await self.get_user_presence(user_id, context_))
except ApplicationError as error:
self._user_presence_import_failure(user_id, error)
except Exception:
logger.exception("Unexpected exception raised in import_user_presence")
self._user_presence_import_failure(user_id, UnknownError())
async def import_user_presence_set(user_id_list_, context_) -> None:
try:
await asyncio.gather(*[
import_user_presence(user_id, context_)
for user_id in user_id_list_
])
finally:
self._user_presence_import_finished()
self._user_presence_import_in_progress = False
self.user_presence_import_complete()
self._external_task_manager.create_task(
import_user_presence_set(user_id_list, context),
"user presence import",
handle_exceptions=False
)
self._user_presence_import_in_progress = True
async def prepare_user_presence_context(self, user_id_list: List[str]) -> Any:
"""Override this method to prepare context for get_user_presence.
This allows for optimizations like batch requests to platform API.
Default implementation returns None.
:param user_id_list: the ids of the users for whom presence information is imported
:return: context
"""
return None
async def get_user_presence(self, user_id: str, context: Any) -> UserPresence:
"""Override this method to return presence information for the user with the provided user_id.
This method is called by import task initialized by GOG Galaxy Client.
:param user_id: the id of the user for whom presence information is imported
:param context: the value returned from :meth:`prepare_user_presence_context`
:return: UserPresence presence information of the provided user
"""
raise NotImplementedError()
def user_presence_import_complete(self) -> None:
"""Override this method to handle operations after presence 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.
@@ -758,7 +1046,7 @@ def create_and_run_plugin(plugin_class, argv):
main()
"""
if len(argv) < 3:
logging.critical("Not enough parameters, required: token, port")
logger.critical("Not enough parameters, required: token, port")
sys.exit(1)
token = argv[1]
@@ -766,23 +1054,23 @@ def create_and_run_plugin(plugin_class, argv):
try:
port = int(argv[2])
except ValueError:
logging.critical("Failed to parse port value: %s", argv[2])
logger.critical("Failed to parse port value: %s", argv[2])
sys.exit(2)
if not (1 <= port <= 65535):
logging.critical("Port value out of range (1, 65535)")
logger.critical("Port value out of range (1, 65535)")
sys.exit(3)
if not issubclass(plugin_class, Plugin):
logging.critical("plugin_class must be subclass of Plugin")
logger.critical("plugin_class must be subclass of Plugin")
sys.exit(4)
async def coroutine():
reader, writer = await asyncio.open_connection("127.0.0.1", port)
extra_info = writer.get_extra_info("sockname")
logging.info("Using local address: %s:%u", *extra_info)
plugin = plugin_class(reader, writer, token)
await plugin.run()
logger.info("Using local address: %s:%u", *extra_info)
async with plugin_class(reader, writer, token) as plugin:
await plugin.run()
try:
if sys.platform == "win32":
@@ -790,5 +1078,5 @@ def create_and_run_plugin(plugin_class, argv):
asyncio.run(coroutine())
except Exception:
logging.exception("Error while running plugin")
logger.exception("Error while running plugin")
sys.exit(5)

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, PresenceState
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:
@@ -58,17 +61,20 @@ class NextStep():
if not stored_credentials:
return NextStep("web_session", PARAMS, cookies=COOKIES, js=JS)
:param auth_params: configuration options: {"window_title": :class:`str`, "window_width": :class:`str`, "window_height": :class:`int`, "start_uri": :class:`int`, "end_uri_regex": :class:`str`}
:param auth_params: configuration options: {"window_title": :class:`str`, "window_width": :class:`str`,
"window_height": :class:`int`, "start_uri": :class:`int`, "end_uri_regex": :class:`str`}
:param cookies: browser initial set of cookies
:param js: a map of the url regex patterns into the list of *js* scripts that should be executed on every document at given step of internal browser authentication.
:param js: a map of the url regex patterns into the list of *js* scripts that should be executed
on every document at given step of internal browser authentication.
"""
next_step: str
auth_params: Dict[str, str]
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 +83,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 +96,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 +111,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 +128,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,9 +139,14 @@ class LocalGame():
game_id: str
local_game_state: LocalGameState
@dataclass
class FriendInfo():
"""Information about a friend of the currently authenticated user.
class FriendInfo:
"""
.. deprecated:: 0.56
Use :class:`UserInfo`.
Information about a friend of the currently authenticated user.
:param user_id: id of the user
:param user_name: username of the user
@@ -139,15 +154,65 @@ class FriendInfo():
user_id: str
user_name: str
@dataclass
class GameTime():
class UserInfo:
"""Information about a user of related user.
:param user_id: id of the user
:param user_name: username of the user
:param avatar_url: the URL of the user avatar
:param profile_url: the URL of the user profile
"""
user_id: str
user_name: str
avatar_url: Optional[str]
profile_url: Optional[str]
@dataclass
class GameTime:
"""Game time of a game, defines the total time spent in the game
and the last time the game was played.
:param game_id: id of the related game
:param time_played: the total time spent in the game in **minutes**
:param last_time_played: last time the game was played (**unix timestamp**)
:param last_played_time: last time the game was played (**unix timestamp**)
"""
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 client
"""
game_id: str
tags: Optional[List[str]]
hidden: Optional[bool]
@dataclass
class UserPresence:
"""Presence information of a user.
The GOG Galaxy client will prefer to generate user status basing on `game_id` (or `game_title`)
and `in_game_status` fields but if plugin is not capable of delivering it then the `full_status` will be used if
available
:param presence_state: the state of the user
:param game_id: id of the game a user is currently in
:param game_title: name of the game a user is currently in
:param in_game_status: status set by the game itself e.x. "In Main Menu"
:param full_status: full user status e.x. "Playing <title_name>: <in_game_status>"
"""
presence_state: PresenceState
game_id: Optional[str] = None
game_title: Optional[str] = None
in_game_status: Optional[str] = None
full_status: Optional[str] = None

View File

@@ -1,3 +1,34 @@
"""
This module standarize http traffic and the error handling for further communication with the GOG Galaxy 2.0.
It is recommended to use provided convenient methods for HTTP requests, especially when dealing with authorized sessions.
Examplary simple web service could looks like:
.. code-block:: python
import logging
from galaxy.http import create_client_session, handle_exception
class BackendClient:
AUTH_URL = 'my-integration.com/auth'
HEADERS = {
"My-Custom-Header": "true",
}
def __init__(self):
self._session = create_client_session(headers=self.HEADERS)
async def authenticate(self):
await self._session.request('POST', self.AUTH_URL)
async def close(self):
# to be called on plugin shutdown
await self._session.close()
async def _authorized_request(self, method, url, *args, **kwargs):
with handle_exceptions():
return await self._session.request(method, url, *args, **kwargs)
"""
import asyncio
import ssl
from contextlib import contextmanager
@@ -13,17 +44,25 @@ from galaxy.api.errors import (
)
logger = logging.getLogger(__name__)
#: Default limit of the simultaneous connections for ssl connector.
DEFAULT_LIMIT = 20
DEFAULT_TIMEOUT = 60 # seconds
#: Default timeout in seconds used for client session.
DEFAULT_TIMEOUT = 60
class HttpClient:
"""Deprecated"""
"""
.. deprecated:: 0.41
Use http module functions instead
"""
def __init__(self, limit=DEFAULT_LIMIT, timeout=aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT), cookie_jar=None):
connector = create_tcp_connector(limit=limit)
self._session = create_client_session(connector=connector, timeout=timeout, cookie_jar=cookie_jar)
async def close(self):
"""Closes connection. Should be called in :meth:`~galaxy.api.plugin.Plugin.shutdown`"""
await self._session.close()
async def request(self, method, url, *args, **kwargs):
@@ -31,23 +70,52 @@ class HttpClient:
return await self._session.request(method, url, *args, **kwargs)
def create_tcp_connector(*args, **kwargs):
def create_tcp_connector(*args, **kwargs) -> aiohttp.TCPConnector:
"""
Creates TCP connector with resonable defaults.
For details about available parameters refer to
`aiohttp.TCPConnector <https://docs.aiohttp.org/en/stable/client_reference.html#tcpconnector>`_
"""
ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
ssl_context.load_verify_locations(certifi.where())
kwargs.setdefault("ssl", ssl_context)
kwargs.setdefault("limit", DEFAULT_LIMIT)
return aiohttp.TCPConnector(*args, **kwargs)
# due to https://github.com/python/mypy/issues/4001
return aiohttp.TCPConnector(*args, **kwargs) # type: ignore
def create_client_session(*args, **kwargs):
def create_client_session(*args, **kwargs) -> aiohttp.ClientSession:
"""
Creates client session with resonable defaults.
For details about available parameters refer to
`aiohttp.ClientSession <https://docs.aiohttp.org/en/stable/client_reference.html>`_
Examplary customization:
.. code-block:: python
from galaxy.http import create_client_session, create_tcp_connector
session = create_client_session(
headers={
"Keep-Alive": "true"
},
connector=create_tcp_connector(limit=40),
timeout=100)
"""
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)
# due to https://github.com/python/mypy/issues/4001
return aiohttp.ClientSession(*args, **kwargs) # type: ignore
@contextmanager
def handle_exception():
"""
Context manager translating network related exceptions
to custom :mod:`~galaxy.api.errors`.
"""
try:
yield
except asyncio.TimeoutError:
@@ -70,12 +138,11 @@ def handle_exception():
if error.status >= 500:
raise BackendError()
if error.status >= 400:
logging.warning(
logger.warning(
"Got status %d while performing %s request for %s",
error.status, error.request_info.method, str(error.request_info.url)
)
raise UnknownError()
except aiohttp.ClientError:
logging.exception("Caught exception while performing request")
logger.exception("Caught exception while performing request")
raise UnknownError()

View File

@@ -3,7 +3,6 @@ from dataclasses import dataclass
from typing import Iterable, NewType, Optional, List, cast
ProcessId = NewType("ProcessId", int)

View File

@@ -12,7 +12,7 @@ class StreamLineReader:
while True:
# check if there is no unprocessed data in the buffer
if not self._buffer or self._processed_buffer_it != 0:
chunk = await self._reader.read(1024)
chunk = await self._reader.read(1024*1024)
if not chunk:
return bytes() # EOF
self._buffer += chunk

View File

@@ -0,0 +1,99 @@
import sys
if sys.platform == "win32":
import logging
import ctypes
from ctypes.wintypes import LONG, HKEY, LPCWSTR, DWORD, BOOL, HANDLE, LPVOID
LPSECURITY_ATTRIBUTES = LPVOID
RegOpenKeyEx = ctypes.windll.advapi32.RegOpenKeyExW
RegOpenKeyEx.restype = LONG
RegOpenKeyEx.argtypes = [HKEY, LPCWSTR, DWORD, DWORD, ctypes.POINTER(HKEY)]
RegCloseKey = ctypes.windll.advapi32.RegCloseKey
RegCloseKey.restype = LONG
RegCloseKey.argtypes = [HKEY]
RegNotifyChangeKeyValue = ctypes.windll.advapi32.RegNotifyChangeKeyValue
RegNotifyChangeKeyValue.restype = LONG
RegNotifyChangeKeyValue.argtypes = [HKEY, BOOL, DWORD, HANDLE, BOOL]
CloseHandle = ctypes.windll.kernel32.CloseHandle
CloseHandle.restype = BOOL
CloseHandle.argtypes = [HANDLE]
CreateEvent = ctypes.windll.kernel32.CreateEventW
CreateEvent.restype = BOOL
CreateEvent.argtypes = [LPSECURITY_ATTRIBUTES, BOOL, BOOL, LPCWSTR]
WaitForSingleObject = ctypes.windll.kernel32.WaitForSingleObject
WaitForSingleObject.restype = DWORD
WaitForSingleObject.argtypes = [HANDLE, DWORD]
ERROR_SUCCESS = 0x00000000
KEY_READ = 0x00020019
KEY_QUERY_VALUE = 0x00000001
REG_NOTIFY_CHANGE_NAME = 0x00000001
REG_NOTIFY_CHANGE_LAST_SET = 0x00000004
WAIT_OBJECT_0 = 0x00000000
WAIT_TIMEOUT = 0x00000102
class RegistryMonitor:
def __init__(self, root, subkey):
self._root = root
self._subkey = subkey
self._event = CreateEvent(None, False, False, None)
self._key = None
self._open_key()
if self._key:
self._set_key_update_notification()
def close(self):
CloseHandle(self._event)
if self._key:
RegCloseKey(self._key)
self._key = None
def is_updated(self):
wait_result = WaitForSingleObject(self._event, 0)
# previously watched
if wait_result == WAIT_OBJECT_0:
self._set_key_update_notification()
return True
# no changes or no key before
if wait_result != WAIT_TIMEOUT:
# unexpected error
logging.warning("Unexpected WaitForSingleObject result %s", wait_result)
return False
if self._key is None:
self._open_key()
if self._key is not None:
self._set_key_update_notification()
return False
def _set_key_update_notification(self):
filter_ = REG_NOTIFY_CHANGE_NAME | REG_NOTIFY_CHANGE_LAST_SET
status = RegNotifyChangeKeyValue(self._key, True, filter_, self._event, True)
if status != ERROR_SUCCESS:
# key was deleted
RegCloseKey(self._key)
self._key = None
def _open_key(self):
access = KEY_QUERY_VALUE | KEY_READ
self._key = HKEY()
rc = RegOpenKeyEx(self._root, self._subkey, 0, access, ctypes.byref(self._key))
if rc != ERROR_SUCCESS:
self._key = None

View File

@@ -0,0 +1,53 @@
import asyncio
import logging
from collections import OrderedDict
from itertools import count
logger = logging.getLogger(__name__)
class TaskManager:
def __init__(self, name):
self._name = name
self._tasks = OrderedDict()
self._task_counter = count()
def create_task(self, coro, description, handle_exceptions=True):
"""Wrapper around asyncio.create_task - takes care of canceling tasks on shutdown"""
async def task_wrapper(task_id):
try:
result = await coro
logger.debug("Task manager %s: finished task %d (%s)", self._name, task_id, description)
return result
except asyncio.CancelledError:
if handle_exceptions:
logger.debug("Task manager %s: canceled task %d (%s)", self._name, task_id, description)
else:
raise
except Exception:
if handle_exceptions:
logger.exception("Task manager %s: exception raised in task %d (%s)", self._name, task_id, description)
else:
raise
finally:
del self._tasks[task_id]
task_id = next(self._task_counter)
logger.debug("Task manager %s: creating task %d (%s)", self._name, task_id, description)
task = asyncio.create_task(task_wrapper(task_id))
self._tasks[task_id] = task
return task
def cancel(self):
for task in self._tasks.values():
task.cancel()
async def wait(self):
# Tasks can spawn other tasks
while True:
tasks = self._tasks.values()
if not tasks:
return
await asyncio.gather(*tasks, return_exceptions=True)

View File

@@ -4,8 +4,8 @@ from unittest.mock import MagicMock
class AsyncMock(MagicMock):
"""
..deprecated:: 0.45
Use: :class:`MagicMock` with meth:`~.async_return_value`.
.. deprecated:: 0.45
Use: :class:`MagicMock` with meth:`~.async_return_value`.
"""
async def __call__(self, *args, **kwargs):
return super(AsyncMock, self).__call__(*args, **kwargs)
@@ -13,19 +13,27 @@ class AsyncMock(MagicMock):
def coroutine_mock():
"""
..deprecated:: 0.45
Use: :class:`MagicMock` with meth:`~.async_return_value`.
.. deprecated:: 0.45
Use: :class:`MagicMock` with meth:`~.async_return_value`.
"""
coro = MagicMock(name="CoroutineResult")
corofunc = MagicMock(name="CoroutineFunction", side_effect=asyncio.coroutine(coro))
corofunc.coro = coro
return corofunc
async def skip_loop(iterations=1):
for _ in range(iterations):
await asyncio.sleep(0)
async def async_return_value(return_value, loop_iterations_delay=0):
await skip_loop(loop_iterations_delay)
if loop_iterations_delay > 0:
await skip_loop(loop_iterations_delay)
return return_value
async def async_raise(error, loop_iterations_delay=0):
if loop_iterations_delay > 0:
await skip_loop(loop_iterations_delay)
raise error

View File

@@ -7,11 +7,8 @@ def create_message(request):
def get_messages(write_mock):
messages = []
print("call_args_list", write_mock.call_args_list)
for call_args in write_mock.call_args_list:
print("call_args", call_args)
data = call_args[0][0]
print("data", data)
for line in data.splitlines():
message = json.loads(line)
messages.append(message)

View File

@@ -1,11 +1,13 @@
from contextlib import ExitStack
import logging
from unittest.mock import patch, MagicMock
from contextlib import ExitStack
from unittest.mock import MagicMock, patch
import pytest
from galaxy.api.plugin import Plugin
from galaxy.api.consts import Platform
from galaxy.api.plugin import Plugin
from galaxy.unittest.mock import async_return_value
@pytest.fixture()
def reader():
@@ -13,23 +15,26 @@ def reader():
stream.read = MagicMock()
yield stream
@pytest.fixture()
async def writer():
stream = MagicMock(name="stream_writer")
stream.write = MagicMock()
stream.drain = MagicMock()
stream.drain.side_effect = lambda: async_return_value(None)
yield stream
@pytest.fixture()
def read(reader):
yield reader.read
@pytest.fixture()
def write(writer):
yield writer.write
@pytest.fixture()
def plugin(reader, writer):
async def plugin(reader, writer):
"""Return plugin instance with all feature methods mocked"""
methods = (
"handshake_complete",
@@ -49,13 +54,25 @@ 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",
"get_user_presence",
"prepare_user_presence_context",
"user_presence_import_complete",
)
with ExitStack() as stack:
for method in methods:
stack.enter_context(patch.object(Plugin, method))
yield Plugin(Platform.Generic, "0.1", reader, writer, "token")
async with Plugin(Platform.Generic, "0.1", reader, writer, "token") as plugin:
plugin.shutdown.return_value = async_return_value(None)
yield plugin
@pytest.fixture(autouse=True)

View File

@@ -5,7 +5,7 @@ from pytest import raises
from galaxy.api.types import Achievement
from galaxy.api.errors import BackendError
from galaxy.unittest.mock import async_return_value
from galaxy.unittest.mock import async_return_value, skip_loop
from tests import create_message, get_messages
@@ -135,7 +135,8 @@ async def test_prepare_get_unlocked_achievements_context_error(plugin, read, wri
"game_ids": ["14"]
}
}
read.side_effect = [async_return_value(create_message(request)), async_return_value(b"")]
read.side_effect = [async_return_value(create_message(request)), async_return_value(b"", 10)]
await plugin.run()
assert get_messages(write) == [
@@ -153,6 +154,7 @@ async def test_prepare_get_unlocked_achievements_context_error(plugin, read, wri
@pytest.mark.asyncio
async def test_import_in_progress(plugin, read, write):
plugin.prepare_achievements_context.return_value = async_return_value(None)
plugin.get_unlocked_achievements.return_value = async_return_value([])
requests = [
{
"jsonrpc": "2.0",
@@ -174,32 +176,32 @@ async def test_import_in_progress(plugin, read, write):
read.side_effect = [
async_return_value(create_message(requests[0])),
async_return_value(create_message(requests[1])),
async_return_value(b"")
async_return_value(b"", 10)
]
await plugin.run()
assert get_messages(write) == [
{
"jsonrpc": "2.0",
"id": "3",
"result": None
},
{
"jsonrpc": "2.0",
"id": "4",
"error": {
"code": 600,
"message": "Import already in progress"
}
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
@pytest.mark.asyncio
async def test_unlock_achievement(plugin, write):
achievement = Achievement(achievement_id="lvl20", unlock_time=1548422395)
plugin.unlock_achievement("14", achievement)
await skip_loop()
response = json.loads(write.call_args[0][0])
assert response == {

View File

@@ -5,7 +5,7 @@ from galaxy.api.errors import (
UnknownError, InvalidCredentials, NetworkError, LoggedInElsewhere, ProtocolError,
BackendNotAvailable, BackendTimeout, BackendError, TemporaryBlocked, Banned, AccessDenied
)
from galaxy.unittest.mock import async_return_value
from galaxy.unittest.mock import async_return_value, skip_loop
from tests import create_message, get_messages
@@ -17,7 +17,7 @@ async def test_success(plugin, read, write):
"id": "3",
"method": "init_authentication"
}
read.side_effect = [async_return_value(create_message(request)), async_return_value(b"")]
read.side_effect = [async_return_value(create_message(request)), async_return_value(b"", 10)]
plugin.authenticate.return_value = async_return_value(Authentication("132", "Zenek"))
await plugin.run()
plugin.authenticate.assert_called_with()
@@ -55,7 +55,7 @@ async def test_failure(plugin, read, write, error, code, message):
"method": "init_authentication"
}
read.side_effect = [async_return_value(create_message(request)), async_return_value(b"")]
read.side_effect = [async_return_value(create_message(request)), async_return_value(b"", 10)]
plugin.authenticate.side_effect = error()
await plugin.run()
plugin.authenticate.assert_called_with()
@@ -84,7 +84,7 @@ async def test_stored_credentials(plugin, read, write):
}
}
}
read.side_effect = [async_return_value(create_message(request)), async_return_value(b"")]
read.side_effect = [async_return_value(create_message(request)), async_return_value(b"", 10)]
plugin.authenticate.return_value = async_return_value(Authentication("132", "Zenek"))
await plugin.run()
plugin.authenticate.assert_called_with(stored_credentials={"token": "ABC"})
@@ -97,6 +97,7 @@ async def test_store_credentials(plugin, write):
"token": "ABC"
}
plugin.store_credentials(credentials)
await skip_loop()
assert get_messages(write) == [
{
@@ -110,6 +111,7 @@ async def test_store_credentials(plugin, write):
@pytest.mark.asyncio
async def test_lost_authentication(plugin, write):
plugin.lost_authentication()
await skip_loop()
assert get_messages(write) == [
{

View File

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

View File

@@ -1,6 +1,6 @@
from galaxy.api.types import FriendInfo
from galaxy.api.types import UserInfo
from galaxy.api.errors import UnknownError
from galaxy.unittest.mock import async_return_value
from galaxy.unittest.mock import async_return_value, skip_loop
import pytest
@@ -15,10 +15,10 @@ async def test_get_friends_success(plugin, read, write):
"method": "import_friends"
}
read.side_effect = [async_return_value(create_message(request)), async_return_value(b"")]
read.side_effect = [async_return_value(create_message(request)), async_return_value(b"", 10)]
plugin.get_friends.return_value = async_return_value([
FriendInfo("3", "Jan"),
FriendInfo("5", "Ola")
UserInfo("3", "Jan", "https://avatar.url/u3", None),
UserInfo("5", "Ola", None, "https://profile.url/u5")
])
await plugin.run()
plugin.get_friends.assert_called_with()
@@ -29,8 +29,8 @@ async def test_get_friends_success(plugin, read, write):
"id": "3",
"result": {
"friend_info_list": [
{"user_id": "3", "user_name": "Jan"},
{"user_id": "5", "user_name": "Ola"}
{"user_id": "3", "user_name": "Jan", "avatar_url": "https://avatar.url/u3"},
{"user_id": "5", "user_name": "Ola", "profile_url": "https://profile.url/u5"}
]
}
}
@@ -45,7 +45,7 @@ async def test_get_friends_failure(plugin, read, write):
"method": "import_friends"
}
read.side_effect = [async_return_value(create_message(request)), async_return_value(b"")]
read.side_effect = [async_return_value(create_message(request)), async_return_value(b"", 10)]
plugin.get_friends.side_effect = UnknownError()
await plugin.run()
plugin.get_friends.assert_called_with()
@@ -64,16 +64,22 @@ async def test_get_friends_failure(plugin, read, write):
@pytest.mark.asyncio
async def test_add_friend(plugin, write):
friend = FriendInfo("7", "Kuba")
friend = UserInfo("7", "Kuba", avatar_url="https://avatar.url/kuba.jpg", profile_url="https://profile.url/kuba")
plugin.add_friend(friend)
await skip_loop()
assert get_messages(write) == [
{
"jsonrpc": "2.0",
"method": "friend_added",
"params": {
"friend_info": {"user_id": "7", "user_name": "Kuba"}
"friend_info": {
"user_id": "7",
"user_name": "Kuba",
"avatar_url": "https://avatar.url/kuba.jpg",
"profile_url": "https://profile.url/kuba"
}
}
}
]
@@ -82,6 +88,7 @@ async def test_add_friend(plugin, write):
@pytest.mark.asyncio
async def test_remove_friend(plugin, write):
plugin.remove_friend("5")
await skip_loop()
assert get_messages(write) == [
{
@@ -92,3 +99,26 @@ async def test_remove_friend(plugin, write):
}
}
]
@pytest.mark.asyncio
async def test_update_friend_info(plugin, write):
plugin.update_friend_info(
UserInfo("7", "Jakub", avatar_url="https://new-avatar.url/kuba2.jpg", profile_url="https://profile.url/kuba")
)
await skip_loop()
assert get_messages(write) == [
{
"jsonrpc": "2.0",
"method": "friend_updated",
"params": {
"friend_info": {
"user_id": "7",
"user_name": "Jakub",
"avatar_url": "https://new-avatar.url/kuba2.jpg",
"profile_url": "https://profile.url/kuba"
}
}
}
]

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

@@ -3,7 +3,7 @@ from unittest.mock import call
import pytest
from galaxy.api.types import GameTime
from galaxy.api.errors import BackendError
from galaxy.unittest.mock import async_return_value
from galaxy.unittest.mock import async_return_value, skip_loop
from tests import create_message, get_messages
@@ -135,7 +135,7 @@ async def test_prepare_get_game_time_context_error(plugin, read, write):
"game_ids": ["6"]
}
}
read.side_effect = [async_return_value(create_message(request)), async_return_value(b"")]
read.side_effect = [async_return_value(create_message(request)), async_return_value(b"", 10)]
await plugin.run()
assert get_messages(write) == [
@@ -174,32 +174,32 @@ async def test_import_in_progress(plugin, read, write):
read.side_effect = [
async_return_value(create_message(requests[0])),
async_return_value(create_message(requests[1])),
async_return_value(b"")
async_return_value(b"", 10)
]
await plugin.run()
assert get_messages(write) == [
{
"jsonrpc": "2.0",
"id": "3",
"result": None
},
{
"jsonrpc": "2.0",
"id": "4",
"error": {
"code": 600,
"message": "Import already in progress"
}
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
@pytest.mark.asyncio
async def test_update_game(plugin, write):
game_time = GameTime("3", 60, 1549550504)
plugin.update_game_time(game_time)
await skip_loop()
assert get_messages(write) == [
{

View File

@@ -46,6 +46,7 @@ async def test_shutdown(plugin, read, write):
}
read.side_effect = [async_return_value(create_message(request))]
await plugin.run()
await plugin.wait_closed()
plugin.shutdown.assert_called_with()
assert get_messages(write) == [
{

View File

@@ -3,7 +3,7 @@ import pytest
from galaxy.api.types import LocalGame
from galaxy.api.consts import LocalGameState
from galaxy.api.errors import UnknownError, FailedParsingManifest
from galaxy.unittest.mock import async_return_value
from galaxy.unittest.mock import async_return_value, skip_loop
from tests import create_message, get_messages
@@ -15,7 +15,7 @@ async def test_success(plugin, read, write):
"id": "3",
"method": "import_local_games"
}
read.side_effect = [async_return_value(create_message(request)), async_return_value(b"")]
read.side_effect = [async_return_value(create_message(request)), async_return_value(b"", 10)]
plugin.get_local_games.return_value = async_return_value([
LocalGame("1", LocalGameState.Running),
@@ -63,7 +63,7 @@ async def test_failure(plugin, read, write, error, code, message):
"id": "3",
"method": "import_local_games"
}
read.side_effect = [async_return_value(create_message(request)), async_return_value(b"")]
read.side_effect = [async_return_value(create_message(request)), async_return_value(b"", 10)]
plugin.get_local_games.side_effect = error()
await plugin.run()
plugin.get_local_games.assert_called_with()
@@ -83,6 +83,7 @@ async def test_failure(plugin, read, write, error, code, message):
async def test_local_game_state_update(plugin, write):
game = LocalGame("1", LocalGameState.Running)
plugin.update_local_game_status(game)
await skip_loop()
assert get_messages(write) == [
{

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

View File

@@ -3,7 +3,7 @@ import pytest
from galaxy.api.types import Game, Dlc, LicenseInfo
from galaxy.api.consts import LicenseType
from galaxy.api.errors import UnknownError
from galaxy.unittest.mock import async_return_value
from galaxy.unittest.mock import async_return_value, skip_loop
from tests import create_message, get_messages
@@ -15,7 +15,7 @@ async def test_success(plugin, read, write):
"id": "3",
"method": "import_owned_games"
}
read.side_effect = [async_return_value(create_message(request)), async_return_value(b"")]
read.side_effect = [async_return_value(create_message(request)), async_return_value(b"", 10)]
plugin.get_owned_games.return_value = async_return_value([
Game("3", "Doom", None, LicenseInfo(LicenseType.SinglePurchase, None)),
@@ -80,7 +80,7 @@ async def test_failure(plugin, read, write):
"method": "import_owned_games"
}
read.side_effect = [async_return_value(create_message(request)), async_return_value(b"")]
read.side_effect = [async_return_value(create_message(request)), async_return_value(b"", 10)]
plugin.get_owned_games.side_effect = UnknownError()
await plugin.run()
plugin.get_owned_games.assert_called_with()
@@ -100,6 +100,7 @@ async def test_failure(plugin, read, write):
async def test_add_game(plugin, write):
game = Game("3", "Doom", None, LicenseInfo(LicenseType.SinglePurchase, None))
plugin.add_game(game)
await skip_loop()
assert get_messages(write) == [
{
"jsonrpc": "2.0",
@@ -120,6 +121,7 @@ async def test_add_game(plugin, write):
@pytest.mark.asyncio
async def test_remove_game(plugin, write):
plugin.remove_game("5")
await skip_loop()
assert get_messages(write) == [
{
"jsonrpc": "2.0",
@@ -135,6 +137,7 @@ async def test_remove_game(plugin, write):
async def test_update_game(plugin, write):
game = Game("3", "Doom", None, LicenseInfo(LicenseType.SinglePurchase, None))
plugin.update_game(game)
await skip_loop()
assert get_messages(write) == [
{
"jsonrpc": "2.0",

View File

@@ -1,6 +1,6 @@
import pytest
from galaxy.unittest.mock import async_return_value
from galaxy.unittest.mock import async_return_value, skip_loop
from tests import create_message, get_messages
@@ -57,6 +57,7 @@ async def test_set_cache(plugin, write, cache_data):
plugin.persistent_cache.update(cache_data)
plugin.push_cache()
await skip_loop()
assert_rpc_request(write, "push_cache", cache_data)
assert cache_data == plugin.persistent_cache
@@ -68,6 +69,7 @@ async def test_clear_cache(plugin, write, cache_data):
plugin.persistent_cache.clear()
plugin.push_cache()
await skip_loop()
assert_rpc_request(write, "push_cache", {})
assert {} == plugin.persistent_cache

View File

@@ -0,0 +1,72 @@
import pytest
import asyncio
from galaxy.unittest.mock import async_return_value
from tests import create_message, get_messages
from galaxy.api.errors import (
BackendNotAvailable, BackendTimeout, BackendError, InvalidCredentials, NetworkError, AccessDenied
)
from galaxy.api.jsonrpc import JsonRpcError
@pytest.mark.asyncio
async def test_refresh_credentials_success(plugin, read, write):
run_task = asyncio.create_task(plugin.run())
refreshed_credentials = {
"access_token": "new_access_token"
}
response = {
"jsonrpc": "2.0",
"id": "1",
"result": refreshed_credentials
}
# 2 loop iterations delay is to force sending response after request has been sent
read.side_effect = [async_return_value(create_message(response), loop_iterations_delay=2)]
result = await plugin.refresh_credentials({}, False)
assert get_messages(write) == [
{
"jsonrpc": "2.0",
"method": "refresh_credentials",
"params": {
},
"id": "1"
}
]
assert result == refreshed_credentials
await run_task
@pytest.mark.asyncio
@pytest.mark.parametrize("exception", [
BackendNotAvailable, BackendTimeout, BackendError, InvalidCredentials, NetworkError, AccessDenied
])
async def test_refresh_credentials_failure(exception, plugin, read, write):
run_task = asyncio.create_task(plugin.run())
error = exception()
response = {
"jsonrpc": "2.0",
"id": "1",
"error": error.json()
}
# 2 loop iterations delay is to force sending response after request has been sent
read.side_effect = [async_return_value(create_message(response), loop_iterations_delay=2)]
with pytest.raises(JsonRpcError) as e:
await plugin.refresh_credentials({}, False)
assert error == e.value
assert get_messages(write) == [
{
"jsonrpc": "2.0",
"method": "refresh_credentials",
"params": {
},
"id": "1"
}
]
await run_task

276
tests/test_user_presence.py Normal file
View File

@@ -0,0 +1,276 @@
from unittest.mock import call
import pytest
from galaxy.api.consts import PresenceState
from galaxy.api.errors import BackendError
from galaxy.api.types import UserPresence
from galaxy.unittest.mock import async_return_value, skip_loop
from tests import create_message, get_messages
@pytest.mark.asyncio
async def test_get_user_presence_success(plugin, read, write):
context = "abc"
user_id_list = ["666", "13", "42", "69", "22"]
plugin.prepare_user_presence_context.return_value = async_return_value(context)
request = {
"jsonrpc": "2.0",
"id": "11",
"method": "start_user_presence_import",
"params": {"user_id_list": user_id_list}
}
read.side_effect = [async_return_value(create_message(request)), async_return_value(b"", 10)]
plugin.get_user_presence.side_effect = [
async_return_value(UserPresence(
PresenceState.Unknown,
"game-id1",
None,
"unknown state",
None
)),
async_return_value(UserPresence(
PresenceState.Offline,
None,
None,
"Going to grandma's house",
None
)),
async_return_value(UserPresence(
PresenceState.Online,
"game-id3",
"game-title3",
"Pew pew",
None
)),
async_return_value(UserPresence(
PresenceState.Away,
None,
"game-title4",
"AFKKTHXBY",
None
)),
async_return_value(UserPresence(
PresenceState.Away,
None,
"game-title5",
None,
"Playing game-title5: In Menu"
)),
]
await plugin.run()
plugin.get_user_presence.assert_has_calls([
call(user_id, context) for user_id in user_id_list
])
plugin.user_presence_import_complete.assert_called_once_with()
assert get_messages(write) == [
{
"jsonrpc": "2.0",
"id": "11",
"result": None
},
{
"jsonrpc": "2.0",
"method": "user_presence_import_success",
"params": {
"user_id": "666",
"presence": {
"presence_state": PresenceState.Unknown.value,
"game_id": "game-id1",
"in_game_status": "unknown state"
}
}
},
{
"jsonrpc": "2.0",
"method": "user_presence_import_success",
"params": {
"user_id": "13",
"presence": {
"presence_state": PresenceState.Offline.value,
"in_game_status": "Going to grandma's house"
}
}
},
{
"jsonrpc": "2.0",
"method": "user_presence_import_success",
"params": {
"user_id": "42",
"presence": {
"presence_state": PresenceState.Online.value,
"game_id": "game-id3",
"game_title": "game-title3",
"in_game_status": "Pew pew"
}
}
},
{
"jsonrpc": "2.0",
"method": "user_presence_import_success",
"params": {
"user_id": "69",
"presence": {
"presence_state": PresenceState.Away.value,
"game_title": "game-title4",
"in_game_status": "AFKKTHXBY"
}
}
},
{
"jsonrpc": "2.0",
"method": "user_presence_import_success",
"params": {
"user_id": "22",
"presence": {
"presence_state": PresenceState.Away.value,
"game_title": "game-title5",
"full_status": "Playing game-title5: In Menu"
}
}
},
{
"jsonrpc": "2.0",
"method": "user_presence_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_user_presence_error(exception, code, message, plugin, read, write):
user_id = "69"
request_id = "55"
plugin.prepare_user_presence_context.return_value = async_return_value(None)
request = {
"jsonrpc": "2.0",
"id": request_id,
"method": "start_user_presence_import",
"params": {"user_id_list": [user_id]}
}
read.side_effect = [async_return_value(create_message(request)), async_return_value(b"", 10)]
plugin.get_user_presence.side_effect = exception
await plugin.run()
plugin.get_user_presence.assert_called()
plugin.user_presence_import_complete.assert_called_once_with()
assert get_messages(write) == [
{
"jsonrpc": "2.0",
"id": request_id,
"result": None
},
{
"jsonrpc": "2.0",
"method": "user_presence_import_failure",
"params": {
"user_id": user_id,
"error": {
"code": code,
"message": message
}
}
},
{
"jsonrpc": "2.0",
"method": "user_presence_import_finished",
"params": None
}
]
@pytest.mark.asyncio
async def test_prepare_get_user_presence_context_error(plugin, read, write):
request_id = "31415"
plugin.prepare_user_presence_context.side_effect = BackendError()
request = {
"jsonrpc": "2.0",
"id": request_id,
"method": "start_user_presence_import",
"params": {"user_id_list": ["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_user_presence_context.return_value = async_return_value(None)
requests = [
{
"jsonrpc": "2.0",
"id": "3",
"method": "start_user_presence_import",
"params": {
"user_id_list": ["42"]
}
},
{
"jsonrpc": "2.0",
"id": "4",
"method": "start_user_presence_import",
"params": {
"user_id_list": ["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
@pytest.mark.asyncio
async def test_update_user_presence(plugin, write):
plugin.update_user_presence("42", UserPresence(PresenceState.Online, "game-id", "game-title", "Pew pew"))
await skip_loop()
assert get_messages(write) == [
{
"jsonrpc": "2.0",
"method": "user_presence_updated",
"params": {
"user_id": "42",
"presence": {
"presence_state": PresenceState.Online.value,
"game_id": "game-id",
"game_title": "game-title",
"in_game_status": "Pew pew"
}
}
}
]