Compare commits

..

44 Commits
0.43 ... 0.55

Author SHA1 Message Date
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
Romuald Juchnowicz-Bierbasz
cec36695b6 Increment version 2019-08-13 16:08:59 +02:00
Romuald Juchnowicz-Bierbasz
4cc8be8f5d SDK-2997: Add launch_platform_client method 2019-08-13 15:23:20 +02:00
Vadim Suharnikov
f5eb32aa19 Fix a comment typo 2019-08-04 01:06:22 +02:00
Romuald Bierbasz
a76345ff6b Docs fixes 2019-08-02 17:23:56 +02:00
Romuald Bierbasz
c3bbeee54d Turn on type checking 2019-08-02 15:15:29 +02:00
Romuald Juchnowicz-Bierbasz
13a3f7577b Increment version 2019-08-02 15:13:11 +02:00
Romuald Bierbasz
f5b9adfbd5 Add achievements_import_complete and game_times_import_complete 2019-08-02 15:11:05 +02:00
Romuald Juchnowicz-Bierbasz
e33dd09a8d Increment version 2019-08-02 12:57:13 +02:00
Aleksej Pawlowskij
f4ea2af924 Make 'handshake_complete' safe 2019-08-02 12:29:48 +02:00
Romuald Bierbasz
3bcc674518 Use MagicMocks with async_return_value and pytest.mark.asyncio 2019-08-02 12:28:13 +02:00
Romuald Juchnowicz-Bierbasz
b14595bef5 Fix types 2019-08-02 12:20:27 +02:00
Romuald Bierbasz
a9acb7a0db SDK-2931: Refactor import methods 2019-08-02 10:10:58 +02:00
Mesco
d95aacb9d8 Fix typo in docstring 2019-08-01 09:53:50 +02:00
Aleksej Pawlowskij
49ae2beab9 SDK-2983: Split entry methods from feature detection 2019-07-29 12:59:45 +02:00
Aleksej Pawlowskij
c9e190772c Increment version 2019-07-26 16:28:47 +02:00
Aleksej Pawlowskij
789415d31b SDK-2974: add process info tools 2019-07-26 13:41:43 +02:00
Piotr Marzec
8d210e7f3e platform name fixing 2019-06-19 20:43:25 +02:00
45 changed files with 2550 additions and 1073 deletions

1
.gitignore vendored
View File

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

View File

@@ -14,9 +14,15 @@ test_package:
deploy_package: deploy_package:
stage: deploy stage: deploy
variables:
TWINE_USERNAME: $PYPI_USERNAME
TWINE_PASSWORD: $PYPI_PASSWORD
script: script:
- pip install twine wheel
- rm -rf dist
- export VERSION=$(python setup.py --version) - 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 - 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}" "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 when: manual

View File

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

View File

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

View File

@@ -32,12 +32,13 @@ release = _version
# ones. # ones.
extensions = [ extensions = [
'sphinx.ext.autodoc', 'sphinx.ext.autodoc',
'sphinxcontrib.asyncio',
'sphinx_autodoc_typehints', 'sphinx_autodoc_typehints',
'm2r' # mdinclude directive for makrdown files 'm2r' # mdinclude directive for makrdown files
] ]
autodoc_member_order = 'bysource' autodoc_member_order = 'bysource'
autodoc_inherit_docstrings = False autodoc_inherit_docstrings = False
autodoc_mock_imports = ["galaxy.http"] autodoc_mock_imports = ["aiohttp"]
set_type_checking_flag = True set_type_checking_flag = True
@@ -47,7 +48,7 @@ templates_path = ['_templates']
# List of patterns, relative to source directory, that match files and # List of patterns, relative to source directory, that match files and
# directories to ignore when looking for source files. # directories to ignore when looking for source files.
# This pattern also affects html_static_path and html_extra_path. # This pattern also affects html_static_path and html_extra_path.
exclude_patterns = [] exclude_patterns = [] # type: ignore
# -- Options for HTML output ------------------------------------------------- # -- Options for HTML output -------------------------------------------------

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: :includehidden:
Overview <overview> Overview <overview>
API <galaxy.api> galaxy.api
galaxy.http
Platform ID's <platforms> Platform ID's <platforms>
Index Index

2
mypy.ini Normal file
View File

@@ -0,0 +1,2 @@
[mypy]
ignore_missing_imports = True

View File

@@ -1,2 +1,2 @@
[pytest] [pytest]
addopts = --flakes addopts = --flakes --mypy

View File

@@ -2,7 +2,9 @@
pytest==4.2.0 pytest==4.2.0
pytest-asyncio==0.10.0 pytest-asyncio==0.10.0
pytest-mock==1.10.3 pytest-mock==1.10.3
pytest-mypy==0.4.1
pytest-flakes==4.0.0 pytest-flakes==4.0.0
# because of pip bug https://github.com/pypa/pip/issues/4780 # because of pip bug https://github.com/pypa/pip/issues/4780
aiohttp==3.5.4 aiohttp==3.5.4
certifi==2019.3.9 certifi==2019.3.9
psutil==5.6.3; sys_platform == 'darwin'

View File

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

View File

@@ -1 +1 @@
__path__ = __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" NintendoDs = "nds"
Nintendo3Ds = "3ds" Nintendo3Ds = "3ds"
PathOfExile = "pathofexile" 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): class Feature(Enum):
"""Possible features that can be implemented by an integration. """Possible features that can be implemented by an integration.
@@ -99,6 +110,10 @@ class Feature(Enum):
VerifyGame = "VerifyGame" VerifyGame = "VerifyGame"
ImportFriends = "ImportFriends" ImportFriends = "ImportFriends"
ShutdownPlatformClient = "ShutdownPlatformClient" ShutdownPlatformClient = "ShutdownPlatformClient"
LaunchPlatformClient = "LaunchPlatformClient"
ImportGameLibrarySettings = "ImportGameLibrarySettings"
ImportOSCompatibility = "ImportOSCompatibility"
ImportUserPresence = "ImportUserPresence"
class LicenseType(Enum): class LicenseType(Enum):
@@ -117,3 +132,20 @@ class LocalGameState(Flag):
None_ = 0 None_ = 0
Installed = 1 Installed = 1
Running = 2 Running = 2
class OSCompatibility(Flag):
"""Possible game OS compatibility.
Use "bitwise or" to express multiple OSs compatibility, e.g. ``os=OSCompatibility.Windows|OSCompatibility.MacOS``
"""
Windows = 0b001
MacOS = 0b010
Linux = 0b100
class PresenceState(Enum):
""""Possible states of a user."""
Unknown = "unknown"
Online = "online"
Offline = "offline"
Away = "away"

View File

@@ -1,6 +1,6 @@
from galaxy.api.jsonrpc import ApplicationError, UnknownError from galaxy.api.jsonrpc import ApplicationError, UnknownError
UnknownError = UnknownError assert UnknownError
class AuthenticationRequired(ApplicationError): class AuthenticationRequired(ApplicationError):
def __init__(self, data=None): def __init__(self, data=None):

View File

@@ -6,6 +6,7 @@ import inspect
import json import json
from galaxy.reader import StreamLineReader from galaxy.reader import StreamLineReader
from galaxy.task_manager import TaskManager
class JsonRpcError(Exception): class JsonRpcError(Exception):
def __init__(self, code, message, data=None): def __init__(self, code, message, data=None):
@@ -17,6 +18,17 @@ class JsonRpcError(Exception):
def __eq__(self, other): def __eq__(self, other):
return self.code == other.code and self.message == other.message and self.data == other.data 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["error"]["data"] = self.data
return obj
class ParseError(JsonRpcError): class ParseError(JsonRpcError):
def __init__(self): def __init__(self):
super().__init__(-32700, "Parse error") super().__init__(-32700, "Parse error")
@@ -52,7 +64,8 @@ class UnknownError(ApplicationError):
super().__init__(0, "Unknown error", data) super().__init__(0, "Unknown error", data)
Request = namedtuple("Request", ["method", "params", "id"], defaults=[{}, None]) Request = namedtuple("Request", ["method", "params", "id"], defaults=[{}, None])
Method = namedtuple("Method", ["callback", "signature", "internal", "sensitive_params"]) Method = namedtuple("Method", ["callback", "signature", "immediate", "sensitive_params"])
def anonymise_sensitive_params(params, sensitive_params): def anonymise_sensitive_params(params, sensitive_params):
anomized_data = "****" anomized_data = "****"
@@ -74,9 +87,10 @@ class Server():
self._encoder = encoder self._encoder = encoder
self._methods = {} self._methods = {}
self._notifications = {} self._notifications = {}
self._eof_listeners = [] self._task_manager = TaskManager("jsonrpc server")
self._write_lock = asyncio.Lock()
def register_method(self, name, callback, internal, sensitive_params=False): def register_method(self, name, callback, immediate, sensitive_params=False):
""" """
Register method Register method
@@ -86,9 +100,9 @@ class Server():
:param sensitive_params: list of parameters that are anonymized before logging; \ :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 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 Register notification
@@ -98,10 +112,7 @@ class Server():
:param sensitive_params: list of parameters that are anonymized before logging; \ :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 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 run(self): async def run(self):
while self._active: while self._active:
@@ -118,14 +129,17 @@ class Server():
self._handle_input(data) self._handle_input(data)
await asyncio.sleep(0) # To not starve task queue await asyncio.sleep(0) # To not starve task queue
def stop(self): def close(self):
self._active = False if self._active:
logging.info("Closing JSON-RPC server - not more messages will be read")
self._active = False
async def wait_closed(self):
await self._task_manager.wait()
def _eof(self): def _eof(self):
logging.info("Received EOF") logging.info("Received EOF")
self.stop() self.close()
for listener in self._eof_listeners:
listener()
def _handle_input(self, data): def _handle_input(self, data):
try: try:
@@ -145,7 +159,7 @@ class Server():
logging.error("Received unknown notification: %s", request.method) logging.error("Received unknown notification: %s", request.method)
return return
callback, signature, internal, sensitive_params = method callback, signature, immediate, sensitive_params = method
self._log_request(request, sensitive_params) self._log_request(request, sensitive_params)
try: try:
@@ -153,12 +167,11 @@ class Server():
except TypeError: except TypeError:
self._send_error(request.id, InvalidParams()) self._send_error(request.id, InvalidParams())
if internal: if immediate:
# internal requests are handled immediately
callback(*bound_args.args, **bound_args.kwargs) callback(*bound_args.args, **bound_args.kwargs)
else: else:
try: 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: except Exception:
logging.exception("Unexpected exception raised in notification handler") logging.exception("Unexpected exception raised in notification handler")
@@ -169,7 +182,7 @@ class Server():
self._send_error(request.id, MethodNotFound()) self._send_error(request.id, MethodNotFound())
return return
callback, signature, internal, sensitive_params = method callback, signature, immediate, sensitive_params = method
self._log_request(request, sensitive_params) self._log_request(request, sensitive_params)
try: try:
@@ -177,8 +190,7 @@ class Server():
except TypeError: except TypeError:
self._send_error(request.id, InvalidParams()) self._send_error(request.id, InvalidParams())
if internal: if immediate:
# internal requests are handled immediately
response = callback(*bound_args.args, **bound_args.kwargs) response = callback(*bound_args.args, **bound_args.kwargs)
self._send_response(request.id, response) self._send_response(request.id, response)
else: else:
@@ -190,11 +202,13 @@ class Server():
self._send_error(request.id, MethodNotFound()) self._send_error(request.id, MethodNotFound())
except JsonRpcError as error: except JsonRpcError as error:
self._send_error(request.id, error) self._send_error(request.id, error)
except asyncio.CancelledError:
self._send_error(request.id, Aborted())
except Exception as e: #pylint: disable=broad-except except Exception as e: #pylint: disable=broad-except
logging.exception("Unexpected exception raised in plugin handler") logging.exception("Unexpected exception raised in plugin handler")
self._send_error(request.id, UnknownError(str(e))) self._send_error(request.id, UnknownError(str(e)))
asyncio.create_task(handle()) self._task_manager.create_task(handle(), request.method)
@staticmethod @staticmethod
def _parse_request(data): def _parse_request(data):
@@ -210,12 +224,16 @@ class Server():
raise InvalidRequest() raise InvalidRequest()
def _send(self, data): def _send(self, data):
async def send_task(data_):
async with self._write_lock:
self._writer.write(data_)
await self._writer.drain()
try: try:
line = self._encoder.encode(data) line = self._encoder.encode(data)
logging.debug("Sending data: %s", line) logging.debug("Sending data: %s", line)
data = (line + "\n").encode("utf-8") data = (line + "\n").encode("utf-8")
self._writer.write(data) self._task_manager.create_task(send_task(data), "send")
asyncio.create_task(self._writer.drain())
except TypeError as error: except TypeError as error:
logging.error(str(error)) logging.error(str(error))
@@ -231,15 +249,9 @@ class Server():
response = { response = {
"jsonrpc": "2.0", "jsonrpc": "2.0",
"id": request_id, "id": request_id,
"error": { "error": error.json()
"code": error.code,
"message": error.message
}
} }
if error.data is not None:
response["error"]["data"] = error.data
self._send(response) self._send(response)
@staticmethod @staticmethod
@@ -255,6 +267,8 @@ class NotificationClient():
self._writer = writer self._writer = writer
self._encoder = encoder self._encoder = encoder
self._methods = {} self._methods = {}
self._task_manager = TaskManager("notification client")
self._write_lock = asyncio.Lock()
def notify(self, method, params, sensitive_params=False): def notify(self, method, params, sensitive_params=False):
""" """
@@ -273,13 +287,21 @@ class NotificationClient():
self._log(method, params, sensitive_params) self._log(method, params, sensitive_params)
self._send(notification) self._send(notification)
async def close(self):
self._task_manager.cancel()
await self._task_manager.wait()
def _send(self, data): def _send(self, data):
async def send_task(data_):
async with self._write_lock:
self._writer.write(data_)
await self._writer.drain()
try: try:
line = self._encoder.encode(data) line = self._encoder.encode(data)
data = (line + "\n").encode("utf-8") data = (line + "\n").encode("utf-8")
logging.debug("Sending %d byte of data", len(data)) logging.debug("Sending %d byte of data", len(data))
self._writer.write(data) self._task_manager.create_task(send_task(data), "send")
asyncio.create_task(self._writer.drain())
except TypeError as error: except TypeError as error:
logging.error("Failed to parse outgoing message: %s", str(error)) logging.error("Failed to parse outgoing message: %s", str(error))

View File

@@ -1,21 +1,19 @@
import asyncio import asyncio
import dataclasses
import json import json
import logging import logging
import logging.handlers import logging.handlers
import dataclasses
from enum import Enum
from collections import OrderedDict
from itertools import count
import sys import sys
from enum import Enum
from typing import Any, Dict, List, Optional, Set, Union
from typing import Any, List, Dict, Optional, Union from galaxy.api.consts import Feature, OSCompatibility
from galaxy.api.errors import ImportInProgress, UnknownError
from galaxy.api.types import Achievement, Game, LocalGame, FriendInfo, GameTime from galaxy.api.jsonrpc import ApplicationError, NotificationClient, Server
from galaxy.api.types import (
from galaxy.api.jsonrpc import Server, NotificationClient, ApplicationError Achievement, Authentication, FriendInfo, Game, GameLibrarySettings, GameTime, LocalGame, NextStep, UserPresence
from galaxy.api.consts import Feature )
from galaxy.api.errors import UnknownError, ImportInProgress from galaxy.task_manager import TaskManager
from galaxy.api.types import Authentication, NextStep
class JSONEncoder(json.JSONEncoder): class JSONEncoder(json.JSONEncoder):
@@ -24,6 +22,7 @@ class JSONEncoder(json.JSONEncoder):
# filter None values # filter None values
def dict_factory(elements): def dict_factory(elements):
return {k: v for k, v in elements if v is not None} return {k: v for k, v in elements if v is not None}
return dataclasses.asdict(o, dict_factory=dict_factory) return dataclasses.asdict(o, dict_factory=dict_factory)
if isinstance(o, Enum): if isinstance(o, Enum):
return o.value return o.value
@@ -32,14 +31,14 @@ class JSONEncoder(json.JSONEncoder):
class Plugin: class Plugin:
"""Use and override methods of this class to create a new platform integration.""" """Use and override methods of this class to create a new platform integration."""
def __init__(self, platform, version, reader, writer, handshake_token): def __init__(self, platform, version, reader, writer, handshake_token):
logging.info("Creating plugin for platform %s, version %s", platform.value, version) logging.info("Creating plugin for platform %s, version %s", platform.value, version)
self._platform = platform self._platform = platform
self._version = version self._version = version
self._feature_methods = OrderedDict() self._features: Set[Feature] = set()
self._active = True self._active = True
self._pass_control_task = None
self._reader, self._writer = reader, writer self._reader, self._writer = reader, writer
self._handshake_token = handshake_token self._handshake_token = handshake_token
@@ -48,28 +47,28 @@ class Plugin:
self._server = Server(self._reader, self._writer, encoder) self._server = Server(self._reader, self._writer, encoder)
self._notification_client = NotificationClient(self._writer, encoder) self._notification_client = NotificationClient(self._writer, encoder)
def eof_handler():
self._shutdown()
self._server.register_eof(eof_handler)
self._achievements_import_in_progress = False self._achievements_import_in_progress = False
self._game_times_import_in_progress = False self._game_times_import_in_progress = False
self._game_library_settings_import_in_progress = False
self._os_compatibility_import_in_progress = False
self._user_presence_import_in_progress = False
self._persistent_cache = dict() self._persistent_cache = dict()
self._tasks = OrderedDict() self._internal_task_manager = TaskManager("plugin internal")
self._task_counter = count() self._external_task_manager = TaskManager("plugin external")
# internal # internal
self._register_method("shutdown", self._shutdown, internal=True) 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( self._register_method(
"initialize_cache", "initialize_cache",
self._initialize_cache, self._initialize_cache,
internal=True, internal=True,
immediate=True,
sensitive_params="data" sensitive_params="data"
) )
self._register_method("ping", self._ping, internal=True) self._register_method("ping", self._ping, internal=True, immediate=True)
# implemented by developer # implemented by developer
self._register_method( self._register_method(
@@ -85,128 +84,135 @@ class Plugin:
self._register_method( self._register_method(
"import_owned_games", "import_owned_games",
self.get_owned_games, self.get_owned_games,
result_name="owned_games", result_name="owned_games"
feature=Feature.ImportOwnedGames
)
self._register_method(
"import_unlocked_achievements",
self.get_unlocked_achievements,
result_name="unlocked_achievements",
feature=Feature.ImportAchievements
)
self._register_method(
"start_achievements_import",
self.start_achievements_import,
)
self._register_method(
"import_local_games",
self.get_local_games,
result_name="local_games",
feature=Feature.ImportInstalledGames
)
self._register_notification("launch_game", self.launch_game, feature=Feature.LaunchGame)
self._register_notification("install_game", self.install_game, feature=Feature.InstallGame)
self._register_notification(
"uninstall_game",
self.uninstall_game,
feature=Feature.UninstallGame
)
self._register_notification(
"shutdown_platform_client",
self.shutdown_platform_client,
feature=Feature.ShutdownPlatformClient
)
self._register_method(
"import_friends",
self.get_friends,
result_name="friend_info_list",
feature=Feature.ImportFriends
)
self._register_method(
"import_game_times",
self.get_game_times,
result_name="game_times",
feature=Feature.ImportGameTime
)
self._register_method(
"start_game_times_import",
self.start_game_times_import,
) )
self._detect_feature(Feature.ImportOwnedGames, ["get_owned_games"])
self._register_method("start_achievements_import", self._start_achievements_import)
self._detect_feature(Feature.ImportAchievements, ["get_unlocked_achievements"])
self._register_method("import_local_games", self.get_local_games, result_name="local_games")
self._detect_feature(Feature.ImportInstalledGames, ["get_local_games"])
self._register_notification("launch_game", self.launch_game)
self._detect_feature(Feature.LaunchGame, ["launch_game"])
self._register_notification("install_game", self.install_game)
self._detect_feature(Feature.InstallGame, ["install_game"])
self._register_notification("uninstall_game", self.uninstall_game)
self._detect_feature(Feature.UninstallGame, ["uninstall_game"])
self._register_notification("shutdown_platform_client", self.shutdown_platform_client)
self._detect_feature(Feature.ShutdownPlatformClient, ["shutdown_platform_client"])
self._register_notification("launch_platform_client", self.launch_platform_client)
self._detect_feature(Feature.LaunchPlatformClient, ["launch_platform_client"])
self._register_method("import_friends", self.get_friends, result_name="friend_info_list")
self._detect_feature(Feature.ImportFriends, ["get_friends"])
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 @property
def features(self): def features(self) -> List[Feature]:
features = [] return list(self._features)
if self.__class__ != Plugin:
for feature, handlers in self._feature_methods.items():
if self._implements(handlers):
features.append(feature)
return features
@property @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. """The cache is only available after the :meth:`~.handshake_complete()` is called.
""" """
return self._persistent_cache return self._persistent_cache
def _implements(self, handlers): def _implements(self, methods: List[str]) -> bool:
for handler in handlers: for method in methods:
if handler.__name__ not in self.__class__.__dict__: if method not in self.__class__.__dict__:
return False return False
return True return True
def _register_method(self, name, handler, result_name=None, internal=False, sensitive_params=False, feature=None): def _detect_feature(self, feature: Feature, methods: List[str]):
if internal: if self._implements(methods):
self._features.add(feature)
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): def method(*args, **kwargs):
result = handler(*args, **kwargs) result = handler(*args, **kwargs)
if result_name: return wrap_result(result)
result = {
result_name: result
}
return result
self._server.register_method(name, method, True, sensitive_params) self._server.register_method(name, method, True, sensitive_params)
else: else:
async def method(*args, **kwargs): async def method(*args, **kwargs):
result = await handler(*args, **kwargs) if not internal:
if result_name: handler_ = self._wrap_external_method(handler, name)
result = { else:
result_name: result handler_ = handler
} result = await handler_(*args, **kwargs)
return result return wrap_result(result)
self._server.register_method(name, method, False, sensitive_params) self._server.register_method(name, method, False, sensitive_params)
if feature is not None: def _register_notification(self, name, handler, internal=False, immediate=False, sensitive_params=False):
self._feature_methods.setdefault(feature, []).append(handler) if not internal and not immediate:
handler = self._wrap_external_method(handler, name)
self._server.register_notification(name, handler, immediate, sensitive_params)
def _register_notification(self, name, handler, internal=False, sensitive_params=False, feature=None): def _wrap_external_method(self, handler, name: str):
self._server.register_notification(name, handler, internal, sensitive_params) async def wrapper(*args, **kwargs):
return await self._external_task_manager.create_task(handler(*args, **kwargs), name, False)
if feature is not None: return wrapper
self._feature_methods.setdefault(feature, []).append(handler)
async def run(self): async def run(self):
"""Plugin's main coroutine.""" """Plugin's main coroutine."""
await self._server.run() await self._server.run()
if self._pass_control_task is not None: logging.debug("Plugin run loop finished")
await self._pass_control_task
def close(self) -> None:
if not self._active:
return
logging.info("Closing plugin")
self._server.close()
self._external_task_manager.cancel()
self._internal_task_manager.create_task(self.shutdown(), "shutdown")
self._active = False
async def wait_closed(self) -> None:
logging.debug("Waiting for plugin to close")
await self._external_task_manager.wait()
await self._internal_task_manager.wait()
await self._server.wait_closed()
await self._notification_client.close()
logging.debug("Plugin closed")
def create_task(self, coro, description): def create_task(self, coro, description):
"""Wrapper around asyncio.create_task - takes care of canceling tasks on shutdown""" """Wrapper around asyncio.create_task - takes care of canceling tasks on shutdown"""
async def task_wrapper(task_id): return self._external_task_manager.create_task(coro, description)
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
async def _pass_control(self): async def _pass_control(self):
while self._active: while self._active:
@@ -216,13 +222,11 @@ class Plugin:
logging.exception("Unexpected exception raised in plugin tick") logging.exception("Unexpected exception raised in plugin tick")
await asyncio.sleep(1) await asyncio.sleep(1)
def _shutdown(self): async def _shutdown(self):
logging.info("Shutting down") logging.info("Shutting down")
self._server.stop() self.close()
self._active = False await self._external_task_manager.wait()
self.shutdown() await self._internal_task_manager.wait()
for task in self._tasks.values():
task.cancel()
def _get_capabilities(self): def _get_capabilities(self):
return { return {
@@ -233,8 +237,11 @@ class Plugin:
def _initialize_cache(self, data: Dict): def _initialize_cache(self, data: Dict):
self._persistent_cache = data self._persistent_cache = data
self.handshake_complete() try:
self._pass_control_task = asyncio.create_task(self._pass_control()) self.handshake_complete()
except Exception:
logging.exception("Unhandled exception during `handshake_complete` step")
self._internal_task_manager.create_task(self._pass_control(), "tick")
@staticmethod @staticmethod
def _ping(): def _ping():
@@ -262,8 +269,10 @@ class Plugin:
self.store_credentials(user_data['credentials']) self.store_credentials(user_data['credentials'])
return Authentication(user_data['userId'], user_data['username']) return Authentication(user_data['userId'], user_data['username'])
""" """
self.persistent_cache['credentials'] = credentials # temporary solution for persistent_cache vs credentials issue
self.persistent_cache["credentials"] = credentials # type: ignore
self._notification_client.notify("store_credentials", credentials, sensitive_params=True) self._notification_client.notify("store_credentials", credentials, sensitive_params=True)
def add_game(self, game: Game) -> None: def add_game(self, game: Game) -> None:
@@ -292,7 +301,7 @@ class Plugin:
"""Notify the client to remove game from the list of owned games """Notify the client to remove game from the list of owned games
of the currently authenticated user. of the currently authenticated user.
:param game_id: game id of the game to remove from the list of owned games :param game_id: the id of the game to remove from the list of owned games
Example use case of remove_game: Example use case of remove_game:
@@ -322,7 +331,7 @@ class Plugin:
def unlock_achievement(self, game_id: str, achievement: Achievement) -> None: def unlock_achievement(self, game_id: str, achievement: Achievement) -> None:
"""Notify the client to unlock an achievement for a specific game. """Notify the client to unlock an achievement for a specific game.
:param game_id: game_id of the game for which to unlock an achievement. :param game_id: the id of the game for which to unlock an achievement.
:param achievement: achievement to unlock. :param achievement: achievement to unlock.
""" """
params = { params = {
@@ -331,38 +340,21 @@ class Plugin:
} }
self._notification_client.notify("achievement_unlocked", params) self._notification_client.notify("achievement_unlocked", params)
def game_achievements_import_success(self, game_id: str, achievements: List[Achievement]) -> None: def _game_achievements_import_success(self, game_id: str, achievements: List[Achievement]) -> None:
"""Notify the client that import of achievements for a given game has succeeded.
This method is called by import_games_achievements.
:param game_id: id of the game for which the achievements were imported
:param achievements: list of imported achievements
"""
params = { params = {
"game_id": game_id, "game_id": game_id,
"unlocked_achievements": achievements "unlocked_achievements": achievements
} }
self._notification_client.notify("game_achievements_import_success", params) self._notification_client.notify("game_achievements_import_success", params)
def game_achievements_import_failure(self, game_id: str, error: ApplicationError) -> None: def _game_achievements_import_failure(self, game_id: str, error: ApplicationError) -> None:
"""Notify the client that import of achievements for a given game has failed.
This method is called by import_games_achievements.
:param game_id: id of the game for which the achievements import failed
:param error: error which prevented the achievements import
"""
params = { params = {
"game_id": game_id, "game_id": game_id,
"error": { "error": error.json()
"code": error.code,
"message": error.message
}
} }
self._notification_client.notify("game_achievements_import_failure", params) self._notification_client.notify("game_achievements_import_failure", params)
def achievements_import_finished(self) -> None: def _achievements_import_finished(self) -> None:
"""Notify the client that importing achievements has finished.
This method is called by import_games_achievements_task"""
self._notification_client.notify("achievements_import_finished", None) self._notification_client.notify("achievements_import_finished", None)
def update_local_game_status(self, local_game: LocalGame) -> None: def update_local_game_status(self, local_game: LocalGame) -> None:
@@ -382,7 +374,7 @@ class Plugin:
continue continue
self.update_local_game_status(LocalGame(game.id, game.status)) self.update_local_game_status(LocalGame(game.id, game.status))
self._cached_games_statuses[game.id] = game.status self._cached_games_statuses[game.id] = game.status
asyncio.sleep(5) # interval await asyncio.sleep(5) # interval
def tick(self): def tick(self):
if self._check_statuses_task is None or self._check_statuses_task.done(): if self._check_statuses_task is None or self._check_statuses_task.done():
@@ -415,37 +407,76 @@ class Plugin:
params = {"game_time": game_time} params = {"game_time": game_time}
self._notification_client.notify("game_time_updated", params) self._notification_client.notify("game_time_updated", params)
def game_time_import_success(self, game_time: GameTime) -> None: def _game_time_import_success(self, game_time: GameTime) -> None:
"""Notify the client that import of a given game_time has succeeded.
This method is called by import_game_times.
:param game_time: game_time which was imported
"""
params = {"game_time": game_time} params = {"game_time": game_time}
self._notification_client.notify("game_time_import_success", params) self._notification_client.notify("game_time_import_success", params)
def game_time_import_failure(self, game_id: str, error: ApplicationError) -> None: def _game_time_import_failure(self, game_id: str, error: ApplicationError) -> None:
"""Notify the client that import of a game time for a given game has failed.
This method is called by import_game_times.
:param game_id: id of the game for which the game time could not be imported
:param error: error which prevented the game time import
"""
params = { params = {
"game_id": game_id, "game_id": game_id,
"error": { "error": error.json()
"code": error.code,
"message": error.message
}
} }
self._notification_client.notify("game_time_import_failure", params) self._notification_client.notify("game_time_import_failure", params)
def game_times_import_finished(self) -> None: def _game_times_import_finished(self) -> None:
"""Notify the client that importing game times has finished.
This method is called by :meth:`~.import_game_times_task`.
"""
self._notification_client.notify("game_times_import_finished", None) self._notification_client.notify("game_times_import_finished", None)
def _game_library_settings_import_success(self, game_library_settings: GameLibrarySettings) -> None:
params = {"game_library_settings": game_library_settings}
self._notification_client.notify("game_library_settings_import_success", params)
def _game_library_settings_import_failure(self, game_id: str, error: ApplicationError) -> None:
params = {
"game_id": game_id,
"error": error.json()
}
self._notification_client.notify("game_library_settings_import_failure", params)
def _game_library_settings_import_finished(self) -> None:
self._notification_client.notify("game_library_settings_import_finished", None)
def _os_compatibility_import_success(self, game_id: str, os_compatibility: Optional[OSCompatibility]) -> None:
self._notification_client.notify(
"os_compatibility_import_success",
{
"game_id": game_id,
"os_compatibility": os_compatibility
}
)
def _os_compatibility_import_failure(self, game_id: str, error: ApplicationError) -> None:
self._notification_client.notify(
"os_compatibility_import_failure",
{
"game_id": game_id,
"error": error.json()
}
)
def _os_compatibility_import_finished(self) -> None:
self._notification_client.notify("os_compatibility_import_finished", None)
def _user_presence_import_success(self, user_id: str, user_presence: UserPresence) -> None:
self._notification_client.notify(
"user_presence_import_success",
{
"user_id": user_id,
"presence": user_presence
}
)
def _user_presence_import_failure(self, user_id: str, error: ApplicationError) -> None:
self._notification_client.notify(
"user_presence_import_failure",
{
"user_id": user_id,
"error": error.json()
}
)
def _user_presence_import_finished(self) -> None:
self._notification_client.notify("user_presence_import_finished", None)
def lost_authentication(self) -> None: def lost_authentication(self) -> None:
"""Notify the client that integration has lost authentication for the """Notify the client that integration has lost authentication for the
current user and is unable to perform actions which would require it. current user and is unable to perform actions which would require it.
@@ -489,7 +520,7 @@ class Plugin:
""" """
def shutdown(self) -> None: async def shutdown(self) -> None:
"""This method is called on integration shutdown. """This method is called on integration shutdown.
Override it to implement tear down. Override it to implement tear down.
This method is called by the GOG Galaxy Client.""" This method is called by the GOG Galaxy Client."""
@@ -524,7 +555,7 @@ class Plugin:
raise NotImplementedError() raise NotImplementedError()
async def pass_login_credentials(self, step: str, credentials: Dict[str, str], cookies: List[Dict[str, str]]) \ async def pass_login_credentials(self, step: str, credentials: Dict[str, str], cookies: List[Dict[str, str]]) \
-> Union[NextStep, Authentication]: -> 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 galaxy.api.types.NextStep from authenticate or from pass_login_credentials.
This method's parameters provide the data extracted from the web page navigation that previous NextStep finished on. 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 This method should either return galaxy.api.types.Authentication if the authentication is finished
@@ -572,50 +603,63 @@ class Plugin:
""" """
raise NotImplementedError() raise NotImplementedError()
async def get_unlocked_achievements(self, game_id: str) -> List[Achievement]: async def _start_achievements_import(self, game_ids: List[str]) -> None:
"""
.. deprecated:: 0.33
Use :meth:`~.import_games_achievements`.
"""
raise NotImplementedError()
async def start_achievements_import(self, game_ids: List[str]) -> None:
"""Starts the task of importing achievements.
This method is called by the GOG Galaxy Client.
:param game_ids: ids of the games for which the achievements are imported
"""
if self._achievements_import_in_progress: if self._achievements_import_in_progress:
raise ImportInProgress() raise ImportInProgress()
async def import_games_achievements_task(game_ids): context = await self.prepare_achievements_context(game_ids)
try:
await self.import_games_achievements(game_ids)
finally:
self.achievements_import_finished()
self._achievements_import_in_progress = False
asyncio.create_task(import_games_achievements_task(game_ids)) async def import_game_achievements(game_id, context_):
try:
achievements = await self.get_unlocked_achievements(game_id, context_)
self._game_achievements_import_success(game_id, achievements)
except ApplicationError as error:
self._game_achievements_import_failure(game_id, error)
except Exception:
logging.exception("Unexpected exception raised in import_game_achievements")
self._game_achievements_import_failure(game_id, UnknownError())
async def import_games_achievements(game_ids_, context_):
try:
imports = [import_game_achievements(game_id, context_) for game_id in game_ids_]
await asyncio.gather(*imports)
finally:
self._achievements_import_finished()
self._achievements_import_in_progress = False
self.achievements_import_complete()
self._external_task_manager.create_task(
import_games_achievements(game_ids, context),
"unlocked achievements import",
handle_exceptions=False
)
self._achievements_import_in_progress = True self._achievements_import_in_progress = True
async def import_games_achievements(self, game_ids: List[str]) -> None: async def prepare_achievements_context(self, game_ids: List[str]) -> Any:
""" """Override this method to prepare context for get_unlocked_achievements.
Override this method to return the unlocked achievements This allows for optimizations like batch requests to platform API.
of the user that is currently logged in to the plugin. Default implementation returns None.
Call game_achievements_import_success/game_achievements_import_failure for each game_id on the list.
This method is called by the GOG Galaxy Client.
:param game_ids: ids of the games for which to import unlocked achievements :param game_ids: the ids of the games for which achievements are imported
:return: context
""" """
async def import_game_achievements(game_id): return None
try:
achievements = await self.get_unlocked_achievements(game_id)
self.game_achievements_import_success(game_id, achievements)
except Exception as error:
self.game_achievements_import_failure(game_id, error)
imports = [import_game_achievements(game_id) for game_id in game_ids] async def get_unlocked_achievements(self, game_id: str, context: Any) -> List[Achievement]:
await asyncio.gather(*imports) """Override this method to return list of unlocked achievements
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 achievements are returned
:param context: the value returned from :meth:`prepare_achievements_context`
:return: list of Achievement objects
"""
raise NotImplementedError()
def achievements_import_complete(self):
"""Override this method to handle operations after achievements import is finished
(like updating cache).
"""
async def get_local_games(self) -> List[LocalGame]: async def get_local_games(self) -> List[LocalGame]:
"""Override this method to return the list of """Override this method to return the list of
@@ -644,7 +688,7 @@ class Plugin:
identified by the provided game_id. identified by the provided game_id.
This method is called by the GOG Galaxy Client. This method is called by the GOG Galaxy Client.
:param str game_id: id of the game to launch :param str game_id: the id of the game to launch
Example of possible override of the method: Example of possible override of the method:
@@ -662,7 +706,7 @@ class Plugin:
identified by the provided game_id. identified by the provided game_id.
This method is called by the GOG Galaxy Client. This method is called by the GOG Galaxy Client.
:param str game_id: id of the game to install :param str game_id: the id of the game to install
Example of possible override of the method: Example of possible override of the method:
@@ -680,7 +724,7 @@ class Plugin:
identified by the provided game_id. identified by the provided game_id.
This method is called by the GOG Galaxy Client. This method is called by the GOG Galaxy Client.
:param str game_id: id of the game to uninstall :param str game_id: the id of the game to uninstall
Example of possible override of the method: Example of possible override of the method:
@@ -698,6 +742,11 @@ class Plugin:
This method is called by the GOG Galaxy Client.""" This method is called by the GOG Galaxy Client."""
raise NotImplementedError() raise NotImplementedError()
async def launch_platform_client(self) -> None:
"""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[FriendInfo]:
"""Override this method to return the friends list """Override this method to return the friends list
of the currently authenticated user. of the currently authenticated user.
@@ -718,54 +767,233 @@ class Plugin:
""" """
raise NotImplementedError() raise NotImplementedError()
async def get_game_times(self) -> List[GameTime]: async def _start_game_times_import(self, game_ids: List[str]) -> None:
"""
.. deprecated:: 0.33
Use :meth:`~.import_game_times`.
"""
raise NotImplementedError()
async def start_game_times_import(self, game_ids: List[str]) -> None:
"""Starts the task of importing game times
This method is called by the GOG Galaxy Client.
:param game_ids: ids of the games for which the game time is imported
"""
if self._game_times_import_in_progress: if self._game_times_import_in_progress:
raise ImportInProgress() raise ImportInProgress()
async def import_game_times_task(game_ids): context = await self.prepare_game_times_context(game_ids)
try:
await self.import_game_times(game_ids)
finally:
self.game_times_import_finished()
self._game_times_import_in_progress = False
asyncio.create_task(import_game_times_task(game_ids)) async def import_game_time(game_id, context_):
try:
game_time = await self.get_game_time(game_id, context_)
self._game_time_import_success(game_time)
except ApplicationError as error:
self._game_time_import_failure(game_id, error)
except Exception:
logging.exception("Unexpected exception raised in import_game_time")
self._game_time_import_failure(game_id, UnknownError())
async def import_game_times(game_ids_, context_):
try:
imports = [import_game_time(game_id, context_) for game_id in game_ids_]
await asyncio.gather(*imports)
finally:
self._game_times_import_finished()
self._game_times_import_in_progress = False
self.game_times_import_complete()
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 self._game_times_import_in_progress = True
async def import_game_times(self, game_ids: List[str]) -> None: async def prepare_game_times_context(self, game_ids: List[str]) -> Any:
""" """Override this method to prepare context for get_game_time.
Override this method to return game times for This allows for optimizations like batch requests to platform API.
games owned by the currently authenticated user. Default implementation returns None.
Call game_time_import_success/game_time_import_failure for each game_id on the list.
This method is called by GOG Galaxy Client.
:param game_ids: ids of the games for which the game time is imported :param game_ids: the ids of the games for which game time are imported
:return: context
""" """
try: return None
game_times = await self.get_game_times()
game_ids_set = set(game_ids) async def get_game_time(self, game_id: str, context: Any) -> GameTime:
for game_time in game_times: """Override this method to return the game time for the game
if game_time.game_id not in game_ids_set: identified by the provided game_id.
continue This method is called by import task initialized by GOG Galaxy Client.
self.game_time_import_success(game_time)
game_ids_set.discard(game_time.game_id) :param game_id: the id of the game for which the game time is returned
for game_id in game_ids_set: :param context: the value returned from :meth:`prepare_game_times_context`
self.game_time_import_failure(game_id, UnknownError()) :return: GameTime object
except Exception as error: """
for game_id in game_ids: raise NotImplementedError()
self.game_time_import_failure(game_id, error)
def game_times_import_complete(self) -> None:
"""Override this method to handle operations after game times import is finished
(like updating cache).
"""
async def _start_game_library_settings_import(self, game_ids: List[str]) -> None:
if self._game_library_settings_import_in_progress:
raise ImportInProgress()
context = await self.prepare_game_library_settings_context(game_ids)
async def import_game_library_settings(game_id, context_):
try:
game_library_settings = await self.get_game_library_settings(game_id, context_)
self._game_library_settings_import_success(game_library_settings)
except ApplicationError as error:
self._game_library_settings_import_failure(game_id, error)
except Exception:
logging.exception("Unexpected exception raised in import_game_library_settings")
self._game_library_settings_import_failure(game_id, UnknownError())
async def import_game_library_settings_set(game_ids_, context_):
try:
imports = [import_game_library_settings(game_id, context_) for game_id in game_ids_]
await asyncio.gather(*imports)
finally:
self._game_library_settings_import_finished()
self._game_library_settings_import_in_progress = False
self.game_library_settings_import_complete()
self._external_task_manager.create_task(
import_game_library_settings_set(game_ids, context),
"game library settings import",
handle_exceptions=False
)
self._game_library_settings_import_in_progress = True
async def prepare_game_library_settings_context(self, game_ids: List[str]) -> Any:
"""Override this method to prepare context for get_game_library_settings.
This allows for optimizations like batch requests to platform API.
Default implementation returns None.
:param game_ids: the ids of the games for which game library settings are imported
:return: context
"""
return None
async def get_game_library_settings(self, game_id: str, context: Any) -> GameLibrarySettings:
"""Override this method to return the game library settings for the game
identified by the provided game_id.
This method is called by import task initialized by GOG Galaxy Client.
:param game_id: the id of the game for which the game library settings are imported
:param context: the value returned from :meth:`prepare_game_library_settings_context`
:return: GameLibrarySettings object
"""
raise NotImplementedError()
def game_library_settings_import_complete(self) -> None:
"""Override this method to handle operations after game library settings import is finished
(like updating cache).
"""
async def _start_os_compatibility_import(self, game_ids: List[str]) -> None:
if self._os_compatibility_import_in_progress:
raise ImportInProgress()
context = await self.prepare_os_compatibility_context(game_ids)
async def import_os_compatibility(game_id, context_):
try:
os_compatibility = await self.get_os_compatibility(game_id, context_)
self._os_compatibility_import_success(game_id, os_compatibility)
except ApplicationError as error:
self._os_compatibility_import_failure(game_id, error)
except Exception:
logging.exception("Unexpected exception raised in import_os_compatibility")
self._os_compatibility_import_failure(game_id, UnknownError())
async def import_os_compatibility_set(game_ids_, context_):
try:
await asyncio.gather(*[
import_os_compatibility(game_id, context_) for game_id in game_ids_
])
finally:
self._os_compatibility_import_finished()
self._os_compatibility_import_in_progress = False
self.os_compatibility_import_complete()
self._external_task_manager.create_task(
import_os_compatibility_set(game_ids, context),
"game OS compatibility import",
handle_exceptions=False
)
self._os_compatibility_import_in_progress = True
async def prepare_os_compatibility_context(self, game_ids: List[str]) -> Any:
"""Override this method to prepare context for get_os_compatibility.
This allows for optimizations like batch requests to platform API.
Default implementation returns None.
:param game_ids: the ids of the games for which game os compatibility is imported
:return: context
"""
return None
async def get_os_compatibility(self, game_id: str, context: Any) -> Optional[OSCompatibility]:
"""Override this method to return the OS compatibility for the game with the provided game_id.
This method is called by import task initialized by GOG Galaxy Client.
:param game_id: the id of the game for which the game os compatibility is imported
:param context: the value returned from :meth:`prepare_os_compatibility_context`
:return: OSCompatibility flags indicating compatible OSs, or None if compatibility is not know
"""
raise NotImplementedError()
def os_compatibility_import_complete(self) -> None:
"""Override this method to handle operations after OS compatibility import is finished (like updating cache)."""
async def _start_user_presence_import(self, user_ids: List[str]) -> None:
if self._user_presence_import_in_progress:
raise ImportInProgress()
context = await self.prepare_user_presence_context(user_ids)
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:
logging.exception("Unexpected exception raised in import_user_presence")
self._user_presence_import_failure(user_id, UnknownError())
async def import_user_presence_set(user_ids_, context_) -> None:
try:
await asyncio.gather(*[
import_user_presence(user_id, context_)
for user_id in user_ids_
])
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_ids, context),
"user presence import",
handle_exceptions=False
)
self._user_presence_import_in_progress = True
async def prepare_user_presence_context(self, user_ids: 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_ids: 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): def create_and_run_plugin(plugin_class, argv):
@@ -809,8 +1037,8 @@ def create_and_run_plugin(plugin_class, argv):
reader, writer = await asyncio.open_connection("127.0.0.1", port) reader, writer = await asyncio.open_connection("127.0.0.1", port)
extra_info = writer.get_extra_info("sockname") extra_info = writer.get_extra_info("sockname")
logging.info("Using local address: %s:%u", *extra_info) logging.info("Using local address: %s:%u", *extra_info)
plugin = plugin_class(reader, writer, token) async with plugin_class(reader, writer, token) as plugin:
await plugin.run() await plugin.run()
try: try:
if sys.platform == "win32": if sys.platform == "win32":

View File

@@ -1,10 +1,11 @@
from dataclasses import dataclass from dataclasses import dataclass
from typing import List, Dict, Optional from typing import Dict, List, Optional
from galaxy.api.consts import LicenseType, LocalGameState, PresenceState
from galaxy.api.consts import LicenseType, LocalGameState
@dataclass @dataclass
class Authentication(): class Authentication:
"""Return this from :meth:`.authenticate` or :meth:`.pass_login_credentials` """Return this from :meth:`.authenticate` or :meth:`.pass_login_credentials`
to inform the client that authentication has successfully finished. to inform the client that authentication has successfully finished.
@@ -14,8 +15,9 @@ class Authentication():
user_id: str user_id: str
user_name: str user_name: str
@dataclass @dataclass
class Cookie(): class Cookie:
"""Cookie """Cookie
:param name: name of the cookie :param name: name of the cookie
@@ -28,8 +30,9 @@ class Cookie():
domain: Optional[str] = None domain: Optional[str] = None
path: Optional[str] = None path: Optional[str] = None
@dataclass @dataclass
class NextStep(): class NextStep:
"""Return this from :meth:`.authenticate` or :meth:`.pass_login_credentials` to open client built-in browser with given url. """Return this from :meth:`.authenticate` or :meth:`.pass_login_credentials` to open client built-in browser with given url.
For example: For example:
@@ -61,15 +64,15 @@ class NextStep():
: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 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 next_step: str
auth_params: Dict[str, str] auth_params: Dict[str, str]
cookies: Optional[List[Cookie]] = None cookies: Optional[List[Cookie]] = None
js: Optional[Dict[str, List[str]]] = None js: Optional[Dict[str, List[str]]] = None
@dataclass @dataclass
class LicenseInfo(): class LicenseInfo:
"""Information about the license of related product. """Information about the license of related product.
:param license_type: type of license :param license_type: type of license
@@ -78,8 +81,9 @@ class LicenseInfo():
license_type: LicenseType license_type: LicenseType
owner: Optional[str] = None owner: Optional[str] = None
@dataclass @dataclass
class Dlc(): class Dlc:
"""Downloadable content object. """Downloadable content object.
:param dlc_id: id of the dlc :param dlc_id: id of the dlc
@@ -90,8 +94,9 @@ class Dlc():
dlc_title: str dlc_title: str
license_info: LicenseInfo license_info: LicenseInfo
@dataclass @dataclass
class Game(): class Game:
"""Game object. """Game object.
:param game_id: unique identifier of the game, this will be passed as parameter for methods such as launch_game :param game_id: unique identifier of the game, this will be passed as parameter for methods such as launch_game
@@ -104,8 +109,9 @@ class Game():
dlcs: Optional[List[Dlc]] dlcs: Optional[List[Dlc]]
license_info: LicenseInfo license_info: LicenseInfo
@dataclass @dataclass
class Achievement(): class Achievement:
"""Achievement, has to be initialized with either id or name. """Achievement, has to be initialized with either id or name.
:param unlock_time: unlock time of the achievement :param unlock_time: unlock time of the achievement
@@ -120,8 +126,9 @@ class Achievement():
assert self.achievement_id or self.achievement_name, \ assert self.achievement_id or self.achievement_name, \
"One of achievement_id or achievement_name is required" "One of achievement_id or achievement_name is required"
@dataclass @dataclass
class LocalGame(): class LocalGame:
"""Game locally present on the authenticated user's computer. """Game locally present on the authenticated user's computer.
:param game_id: id of the game :param game_id: id of the game
@@ -130,8 +137,9 @@ class LocalGame():
game_id: str game_id: str
local_game_state: LocalGameState local_game_state: LocalGameState
@dataclass @dataclass
class FriendInfo(): class FriendInfo:
"""Information about a friend of the currently authenticated user. """Information about a friend of the currently authenticated user.
:param user_id: id of the user :param user_id: id of the user
@@ -140,8 +148,9 @@ class FriendInfo():
user_id: str user_id: str
user_name: str user_name: str
@dataclass @dataclass
class GameTime(): class GameTime:
"""Game time of a game, defines the total time spent in the game """Game time of a game, defines the total time spent in the game
and the last time the game was played. and the last time the game was played.
@@ -152,3 +161,31 @@ class GameTime():
game_id: str game_id: str
time_played: Optional[int] time_played: Optional[int]
last_played_time: Optional[int] last_played_time: Optional[int]
@dataclass
class GameLibrarySettings:
"""Library settings of a game, defines assigned tags and visibility flag.
:param game_id: id of the related game
:param tags: collection of tags assigned to the game
:param hidden: indicates if the game should be hidden in GOG Galaxy application
"""
game_id: str
tags: Optional[List[str]]
hidden: Optional[bool]
@dataclass
class UserPresence:
"""Presence information of a user.
: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 presence_status: detailed user's presence description
"""
presence_state: PresenceState
game_id: Optional[str] = None
game_title: Optional[str] = None
presence_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 asyncio
import ssl import ssl
from contextlib import contextmanager from contextlib import contextmanager
@@ -13,17 +44,23 @@ from galaxy.api.errors import (
) )
#: Default limit of the simultaneous connections for ssl connector.
DEFAULT_LIMIT = 20 DEFAULT_LIMIT = 20
DEFAULT_TIMEOUT = 60 # seconds #: Default timeout in seconds used for client session.
DEFAULT_TIMEOUT = 60
class HttpClient: 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): def __init__(self, limit=DEFAULT_LIMIT, timeout=aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT), cookie_jar=None):
connector = create_tcp_connector(limit=limit) connector = create_tcp_connector(limit=limit)
self._session = create_client_session(connector=connector, timeout=timeout, cookie_jar=cookie_jar) self._session = create_client_session(connector=connector, timeout=timeout, cookie_jar=cookie_jar)
async def close(self): async def close(self):
"""Closes connection. Should be called in :meth:`~galaxy.api.plugin.Plugin.shutdown`"""
await self._session.close() await self._session.close()
async def request(self, method, url, *args, **kwargs): async def request(self, method, url, *args, **kwargs):
@@ -31,23 +68,52 @@ class HttpClient:
return await self._session.request(method, url, *args, **kwargs) 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 = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
ssl_context.load_verify_locations(certifi.where()) ssl_context.load_verify_locations(certifi.where())
kwargs.setdefault("ssl", ssl_context) kwargs.setdefault("ssl", ssl_context)
kwargs.setdefault("limit", DEFAULT_LIMIT) kwargs.setdefault("limit", DEFAULT_LIMIT)
return aiohttp.TCPConnector(*args, **kwargs) # 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("connector", create_tcp_connector())
kwargs.setdefault("timeout", aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT)) kwargs.setdefault("timeout", aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT))
kwargs.setdefault("raise_for_status", True) kwargs.setdefault("raise_for_status", True)
return aiohttp.ClientSession(*args, **kwargs) # due to https://github.com/python/mypy/issues/4001
return aiohttp.ClientSession(*args, **kwargs) # type: ignore
@contextmanager @contextmanager
def handle_exception(): def handle_exception():
"""
Context manager translating network related exceptions
to custom :mod:`~galaxy.api.errors`.
"""
try: try:
yield yield
except asyncio.TimeoutError: except asyncio.TimeoutError:
@@ -78,4 +144,3 @@ def handle_exception():
except aiohttp.ClientError: except aiohttp.ClientError:
logging.exception("Caught exception while performing request") logging.exception("Caught exception while performing request")
raise UnknownError() raise UnknownError()

88
src/galaxy/proc_tools.py Normal file
View File

@@ -0,0 +1,88 @@
import sys
from dataclasses import dataclass
from typing import Iterable, NewType, Optional, List, cast
ProcessId = NewType("ProcessId", int)
@dataclass
class ProcessInfo:
pid: ProcessId
binary_path: Optional[str]
if sys.platform == "win32":
from ctypes import byref, sizeof, windll, create_unicode_buffer, FormatError, WinError
from ctypes.wintypes import DWORD
def pids() -> Iterable[ProcessId]:
_PROC_ID_T = DWORD
list_size = 4096
def try_get_pids(list_size: int) -> List[ProcessId]:
result_size = DWORD()
proc_id_list = (_PROC_ID_T * list_size)()
if not windll.psapi.EnumProcesses(byref(proc_id_list), sizeof(proc_id_list), byref(result_size)):
raise WinError(descr="Failed to get process ID list: %s" % FormatError()) # type: ignore
return cast(List[ProcessId], proc_id_list[:int(result_size.value / sizeof(_PROC_ID_T()))])
while True:
proc_ids = try_get_pids(list_size)
if len(proc_ids) < list_size:
return proc_ids
list_size *= 2
def get_process_info(pid: ProcessId) -> Optional[ProcessInfo]:
_PROC_QUERY_LIMITED_INFORMATION = 0x1000
process_info = ProcessInfo(pid=pid, binary_path=None)
h_process = windll.kernel32.OpenProcess(_PROC_QUERY_LIMITED_INFORMATION, False, pid)
if not h_process:
return process_info
try:
def get_exe_path() -> Optional[str]:
_MAX_PATH = 260
_WIN32_PATH_FORMAT = 0x0000
exe_path_buffer = create_unicode_buffer(_MAX_PATH)
exe_path_len = DWORD(len(exe_path_buffer))
return cast(str, exe_path_buffer[:exe_path_len.value]) if windll.kernel32.QueryFullProcessImageNameW(
h_process, _WIN32_PATH_FORMAT, exe_path_buffer, byref(exe_path_len)
) else None
process_info.binary_path = get_exe_path()
finally:
windll.kernel32.CloseHandle(h_process)
return process_info
else:
import psutil
def pids() -> Iterable[ProcessId]:
for pid in psutil.pids():
yield pid
def get_process_info(pid: ProcessId) -> Optional[ProcessInfo]:
process_info = ProcessInfo(pid=pid, binary_path=None)
try:
process_info.binary_path = psutil.Process(pid=pid).as_dict(attrs=["exe"])["exe"]
except psutil.NoSuchProcess:
pass
finally:
return process_info
def process_iter() -> Iterable[Optional[ProcessInfo]]:
for pid in pids():
yield get_process_info(pid)

View File

@@ -0,0 +1,98 @@
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 None:
return False
self._set_key_update_notification()
return True
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,49 @@
import asyncio
import logging
from collections import OrderedDict
from itertools import count
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
logging.debug("Task manager %s: finished task %d (%s)", self._name, task_id, description)
return result
except asyncio.CancelledError:
if handle_exceptions:
logging.debug("Task manager %s: canceled task %d (%s)", self._name, task_id, description)
else:
raise
except Exception:
if handle_exceptions:
logging.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)
logging.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

@@ -3,6 +3,7 @@ import os
import zipfile import zipfile
from glob import glob from glob import glob
def zip_folder(folder): def zip_folder(folder):
files = glob(os.path.join(folder, "**"), recursive=True) files = glob(os.path.join(folder, "**"), recursive=True)
files = [file.replace(folder + os.sep, "") for file in files] files = [file.replace(folder + os.sep, "") for file in files]
@@ -14,6 +15,7 @@ def zip_folder(folder):
zipf.write(os.path.join(folder, file), arcname=file) zipf.write(os.path.join(folder, file), arcname=file)
return zip_buffer return zip_buffer
def zip_folder_to_file(folder, filename): def zip_folder_to_file(folder, filename):
zip_content = zip_folder(folder).getbuffer() zip_content = zip_folder(folder).getbuffer()
with open(filename, "wb") as archive: with open(filename, "wb") as archive:

View File

@@ -1,12 +1,31 @@
from asyncio import coroutine import asyncio
from unittest.mock import MagicMock from unittest.mock import MagicMock
class AsyncMock(MagicMock): class AsyncMock(MagicMock):
"""
.. deprecated:: 0.45
Use: :class:`MagicMock` with meth:`~.async_return_value`.
"""
async def __call__(self, *args, **kwargs): async def __call__(self, *args, **kwargs):
return super(AsyncMock, self).__call__(*args, **kwargs) return super(AsyncMock, self).__call__(*args, **kwargs)
def coroutine_mock(): def coroutine_mock():
"""
.. deprecated:: 0.45
Use: :class:`MagicMock` with meth:`~.async_return_value`.
"""
coro = MagicMock(name="CoroutineResult") coro = MagicMock(name="CoroutineResult")
corofunc = MagicMock(name="CoroutineFunction", side_effect=coroutine(coro)) corofunc = MagicMock(name="CoroutineFunction", side_effect=asyncio.coroutine(coro))
corofunc.coro = coro corofunc.coro = coro
return corofunc 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)
return return_value

View File

@@ -0,0 +1,16 @@
import json
def create_message(request):
return json.dumps(request).encode() + b"\n"
def get_messages(write_mock):
messages = []
for call_args in write_mock.call_args_list:
data = call_args[0][0]
for line in data.splitlines():
message = json.loads(line)
messages.append(message)
return messages

View File

@@ -1,62 +1,79 @@
from contextlib import ExitStack
import logging import logging
from unittest.mock import patch, MagicMock from contextlib import ExitStack
from unittest.mock import MagicMock, patch
import pytest import pytest
from galaxy.api.plugin import Plugin
from galaxy.api.consts import Platform from galaxy.api.consts import Platform
from galaxy.unittest.mock import AsyncMock, coroutine_mock from galaxy.api.plugin import Plugin
from galaxy.unittest.mock import async_return_value
@pytest.fixture() @pytest.fixture()
def reader(): def reader():
stream = MagicMock(name="stream_reader") stream = MagicMock(name="stream_reader")
stream.read = AsyncMock() stream.read = MagicMock()
yield stream yield stream
@pytest.fixture() @pytest.fixture()
def writer(): async def writer():
stream = MagicMock(name="stream_writer") stream = MagicMock(name="stream_writer")
stream.write = MagicMock() stream.drain.side_effect = lambda: async_return_value(None)
stream.drain = AsyncMock()
yield stream yield stream
@pytest.fixture() @pytest.fixture()
def read(reader): def read(reader):
yield reader.read yield reader.read
@pytest.fixture() @pytest.fixture()
def write(writer): def write(writer):
yield writer.write yield writer.write
@pytest.fixture() @pytest.fixture()
def plugin(reader, writer): async def plugin(reader, writer):
"""Return plugin instance with all feature methods mocked""" """Return plugin instance with all feature methods mocked"""
async_methods = ( methods = (
"handshake_complete", "handshake_complete",
"authenticate", "authenticate",
"get_owned_games", "get_owned_games",
"prepare_achievements_context",
"get_unlocked_achievements", "get_unlocked_achievements",
"achievements_import_complete",
"get_local_games", "get_local_games",
"launch_game", "launch_game",
"launch_platform_client",
"install_game", "install_game",
"uninstall_game", "uninstall_game",
"get_friends", "get_friends",
"get_game_times", "get_game_time",
"shutdown_platform_client" "prepare_game_times_context",
) "game_times_import_complete",
"shutdown_platform_client",
methods = (
"shutdown", "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: with ExitStack() as stack:
for method in async_methods:
stack.enter_context(patch.object(Plugin, method, new_callable=coroutine_mock))
for method in methods: for method in methods:
stack.enter_context(patch.object(Plugin, method)) 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) @pytest.fixture(autouse=True)
def my_caplog(caplog): def my_caplog(caplog):

View File

@@ -1,94 +1,207 @@
import asyncio
import json import json
from unittest.mock import call
import pytest import pytest
from pytest import raises from pytest import raises
from galaxy.api.types import Achievement from galaxy.api.types import Achievement
from galaxy.api.errors import UnknownError, ImportInProgress, BackendError from galaxy.api.errors import BackendError
from galaxy.unittest.mock import async_return_value, skip_loop
from tests import create_message, get_messages
def test_initialization_no_unlock_time(): def test_initialization_no_unlock_time():
with raises(Exception): with raises(Exception):
Achievement(achievement_id="lvl30", achievement_name="Got level 30") Achievement(achievement_id="lvl30", achievement_name="Got level 30")
def test_initialization_no_id_nor_name(): def test_initialization_no_id_nor_name():
with raises(AssertionError): with raises(AssertionError):
Achievement(unlock_time=1234567890) Achievement(unlock_time=1234567890)
def test_success(plugin, read, write):
@pytest.mark.asyncio
async def test_get_unlocked_achievements_success(plugin, read, write):
plugin.prepare_achievements_context.return_value = async_return_value(5)
request = { request = {
"jsonrpc": "2.0", "jsonrpc": "2.0",
"id": "3", "id": "3",
"method": "import_unlocked_achievements", "method": "start_achievements_import",
"params": { "params": {
"game_id": "14" "game_ids": ["14"]
} }
} }
read.side_effect = [json.dumps(request).encode() + b"\n", b""] read.side_effect = [async_return_value(create_message(request)), async_return_value(b"", 10)]
plugin.get_unlocked_achievements.coro.return_value = [ plugin.get_unlocked_achievements.return_value = async_return_value([
Achievement(achievement_id="lvl10", unlock_time=1548421241), Achievement(achievement_id="lvl10", unlock_time=1548421241),
Achievement(achievement_name="Got level 20", unlock_time=1548422395), Achievement(achievement_name="Got level 20", unlock_time=1548422395),
Achievement(achievement_id="lvl30", achievement_name="Got level 30", unlock_time=1548495633) Achievement(achievement_id="lvl30", achievement_name="Got level 30", unlock_time=1548495633)
] ])
asyncio.run(plugin.run()) await plugin.run()
plugin.get_unlocked_achievements.assert_called_with(game_id="14") plugin.prepare_achievements_context.assert_called_with(["14"])
response = json.loads(write.call_args[0][0]) plugin.get_unlocked_achievements.assert_called_with("14", 5)
plugin.achievements_import_complete.asert_called_with()
assert response == { assert get_messages(write) == [
"jsonrpc": "2.0", {
"id": "3", "jsonrpc": "2.0",
"result": { "id": "3",
"unlocked_achievements": [ "result": None
{ },
"achievement_id": "lvl10", {
"unlock_time": 1548421241 "jsonrpc": "2.0",
}, "method": "game_achievements_import_success",
{ "params": {
"achievement_name": "Got level 20", "game_id": "14",
"unlock_time": 1548422395 "unlocked_achievements": [
}, {
{ "achievement_id": "lvl10",
"achievement_id": "lvl30", "unlock_time": 1548421241
"achievement_name": "Got level 30", },
"unlock_time": 1548495633 {
} "achievement_name": "Got level 20",
] "unlock_time": 1548422395
},
{
"achievement_id": "lvl30",
"achievement_name": "Got level 30",
"unlock_time": 1548495633
}
]
}
},
{
"jsonrpc": "2.0",
"method": "achievements_import_finished",
"params": None
} }
} ]
def test_failure(plugin, read, write):
@pytest.mark.asyncio
@pytest.mark.parametrize("exception,code,message", [
(BackendError, 4, "Backend error"),
(KeyError, 0, "Unknown error")
])
async def test_get_unlocked_achievements_error(exception, code, message, plugin, read, write):
plugin.prepare_achievements_context.return_value = async_return_value(None)
request = { request = {
"jsonrpc": "2.0", "jsonrpc": "2.0",
"id": "3", "id": "3",
"method": "import_unlocked_achievements", "method": "start_achievements_import",
"params": { "params": {
"game_id": "14" "game_ids": ["14"]
} }
} }
read.side_effect = [json.dumps(request).encode() + b"\n", b""] read.side_effect = [async_return_value(create_message(request)), async_return_value(b"", 10)]
plugin.get_unlocked_achievements.coro.side_effect = UnknownError() plugin.get_unlocked_achievements.side_effect = exception
asyncio.run(plugin.run()) await plugin.run()
plugin.get_unlocked_achievements.assert_called() plugin.get_unlocked_achievements.assert_called()
response = json.loads(write.call_args[0][0]) plugin.achievements_import_complete.asert_called_with()
assert response == { assert get_messages(write) == [
{
"jsonrpc": "2.0",
"id": "3",
"result": None
},
{
"jsonrpc": "2.0",
"method": "game_achievements_import_failure",
"params": {
"game_id": "14",
"error": {
"code": code,
"message": message
}
}
},
{
"jsonrpc": "2.0",
"method": "achievements_import_finished",
"params": None
}
]
@pytest.mark.asyncio
async def test_prepare_get_unlocked_achievements_context_error(plugin, read, write):
plugin.prepare_achievements_context.side_effect = BackendError()
request = {
"jsonrpc": "2.0", "jsonrpc": "2.0",
"id": "3", "id": "3",
"error": { "method": "start_achievements_import",
"code": 0, "params": {
"message": "Unknown error" "game_ids": ["14"]
} }
} }
read.side_effect = [async_return_value(create_message(request)), async_return_value(b"", 10)]
def test_unlock_achievement(plugin, write): 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_achievements_context.return_value = async_return_value(None)
plugin.get_unlocked_achievements.return_value = async_return_value([])
requests = [
{
"jsonrpc": "2.0",
"id": "3",
"method": "start_achievements_import",
"params": {
"game_ids": ["14"]
}
},
{
"jsonrpc": "2.0",
"id": "4",
"method": "start_achievements_import",
"params": {
"game_ids": ["15"]
}
}
]
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
@pytest.mark.asyncio
async def test_unlock_achievement(plugin, write):
achievement = Achievement(achievement_id="lvl20", unlock_time=1548422395) achievement = Achievement(achievement_id="lvl20", unlock_time=1548422395)
plugin.unlock_achievement("14", achievement)
async def couritine(): await skip_loop()
plugin.unlock_achievement("14", achievement)
asyncio.run(couritine())
response = json.loads(write.call_args[0][0]) response = json.loads(write.call_args[0][0])
assert response == { assert response == {
@@ -102,92 +215,3 @@ def test_unlock_achievement(plugin, write):
} }
} }
} }
@pytest.mark.asyncio
async def test_game_achievements_import_success(plugin, write):
achievements = [
Achievement(achievement_id="lvl10", unlock_time=1548421241),
Achievement(achievement_name="Got level 20", unlock_time=1548422395)
]
plugin.game_achievements_import_success("134", achievements)
response = json.loads(write.call_args[0][0])
assert response == {
"jsonrpc": "2.0",
"method": "game_achievements_import_success",
"params": {
"game_id": "134",
"unlocked_achievements": [
{
"achievement_id": "lvl10",
"unlock_time": 1548421241
},
{
"achievement_name": "Got level 20",
"unlock_time": 1548422395
}
]
}
}
@pytest.mark.asyncio
async def test_game_achievements_import_failure(plugin, write):
plugin.game_achievements_import_failure("134", ImportInProgress())
response = json.loads(write.call_args[0][0])
assert response == {
"jsonrpc": "2.0",
"method": "game_achievements_import_failure",
"params": {
"game_id": "134",
"error": {
"code": 600,
"message": "Import already in progress"
}
}
}
@pytest.mark.asyncio
async def test_achievements_import_finished(plugin, write):
plugin.achievements_import_finished()
response = json.loads(write.call_args[0][0])
assert response == {
"jsonrpc": "2.0",
"method": "achievements_import_finished",
"params": None
}
@pytest.mark.asyncio
async def test_start_achievements_import(plugin, write, mocker):
game_achievements_import_success = mocker.patch.object(plugin, "game_achievements_import_success")
game_achievements_import_failure = mocker.patch.object(plugin, "game_achievements_import_failure")
achievements_import_finished = mocker.patch.object(plugin, "achievements_import_finished")
game_ids = ["1", "5", "9"]
error = BackendError()
achievements = [
Achievement(achievement_id="lvl10", unlock_time=1548421241),
Achievement(achievement_name="Got level 20", unlock_time=1548422395)
]
plugin.get_unlocked_achievements.coro.side_effect = [
achievements,
[],
error
]
await plugin.start_achievements_import(game_ids)
with pytest.raises(ImportInProgress):
await plugin.start_achievements_import(["4", "8"])
# wait until all tasks are finished
for _ in range(4):
await asyncio.sleep(0)
plugin.get_unlocked_achievements.coro.assert_has_calls([call("1"), call("5"), call("9")])
game_achievements_import_success.assert_has_calls([
call("1", achievements),
call("5", [])
])
game_achievements_import_failure.assert_called_once_with("9", error)
achievements_import_finished.assert_called_once_with()

View File

@@ -1,6 +1,3 @@
import asyncio
import json
import pytest import pytest
from galaxy.api.types import Authentication from galaxy.api.types import Authentication
@@ -8,29 +5,36 @@ from galaxy.api.errors import (
UnknownError, InvalidCredentials, NetworkError, LoggedInElsewhere, ProtocolError, UnknownError, InvalidCredentials, NetworkError, LoggedInElsewhere, ProtocolError,
BackendNotAvailable, BackendTimeout, BackendError, TemporaryBlocked, Banned, AccessDenied BackendNotAvailable, BackendTimeout, BackendError, TemporaryBlocked, Banned, AccessDenied
) )
from galaxy.unittest.mock import async_return_value, skip_loop
def test_success(plugin, read, write): from tests import create_message, get_messages
@pytest.mark.asyncio
async def test_success(plugin, read, write):
request = { request = {
"jsonrpc": "2.0", "jsonrpc": "2.0",
"id": "3", "id": "3",
"method": "init_authentication" "method": "init_authentication"
} }
read.side_effect = [async_return_value(create_message(request)), async_return_value(b"", 10)]
read.side_effect = [json.dumps(request).encode() + b"\n", b""] plugin.authenticate.return_value = async_return_value(Authentication("132", "Zenek"))
plugin.authenticate.coro.return_value = Authentication("132", "Zenek") await plugin.run()
asyncio.run(plugin.run())
plugin.authenticate.assert_called_with() plugin.authenticate.assert_called_with()
response = json.loads(write.call_args[0][0])
assert response == { assert get_messages(write) == [
"jsonrpc": "2.0", {
"id": "3", "jsonrpc": "2.0",
"result": { "id": "3",
"user_id": "132", "result": {
"user_name": "Zenek" "user_id": "132",
"user_name": "Zenek"
}
} }
} ]
@pytest.mark.asyncio
@pytest.mark.parametrize("error,code,message", [ @pytest.mark.parametrize("error,code,message", [
pytest.param(UnknownError, 0, "Unknown error", id="unknown_error"), pytest.param(UnknownError, 0, "Unknown error", id="unknown_error"),
pytest.param(BackendNotAvailable, 2, "Backend not available", id="backend_not_available"), pytest.param(BackendNotAvailable, 2, "Backend not available", id="backend_not_available"),
@@ -44,29 +48,32 @@ def test_success(plugin, read, write):
pytest.param(Banned, 105, "Banned", id="banned"), pytest.param(Banned, 105, "Banned", id="banned"),
pytest.param(AccessDenied, 106, "Access denied", id="access_denied"), pytest.param(AccessDenied, 106, "Access denied", id="access_denied"),
]) ])
def test_failure(plugin, read, write, error, code, message): async def test_failure(plugin, read, write, error, code, message):
request = { request = {
"jsonrpc": "2.0", "jsonrpc": "2.0",
"id": "3", "id": "3",
"method": "init_authentication" "method": "init_authentication"
} }
read.side_effect = [json.dumps(request).encode() + b"\n", b""] read.side_effect = [async_return_value(create_message(request)), async_return_value(b"", 10)]
plugin.authenticate.coro.side_effect = error() plugin.authenticate.side_effect = error()
asyncio.run(plugin.run()) await plugin.run()
plugin.authenticate.assert_called_with() plugin.authenticate.assert_called_with()
response = json.loads(write.call_args[0][0])
assert response == { assert get_messages(write) == [
"jsonrpc": "2.0", {
"id": "3", "jsonrpc": "2.0",
"error": { "id": "3",
"code": code, "error": {
"message": message "code": code,
"message": message
}
} }
} ]
def test_stored_credentials(plugin, read, write):
@pytest.mark.asyncio
async def test_stored_credentials(plugin, read, write):
request = { request = {
"jsonrpc": "2.0", "jsonrpc": "2.0",
"id": "3", "id": "3",
@@ -77,39 +84,39 @@ def test_stored_credentials(plugin, read, write):
} }
} }
} }
read.side_effect = [json.dumps(request).encode() + b"\n", b""] read.side_effect = [async_return_value(create_message(request)), async_return_value(b"", 10)]
plugin.authenticate.coro.return_value = Authentication("132", "Zenek") plugin.authenticate.return_value = async_return_value(Authentication("132", "Zenek"))
asyncio.run(plugin.run()) await plugin.run()
plugin.authenticate.assert_called_with(stored_credentials={"token": "ABC"}) plugin.authenticate.assert_called_with(stored_credentials={"token": "ABC"})
write.assert_called() write.assert_called()
def test_store_credentials(plugin, write):
@pytest.mark.asyncio
async def test_store_credentials(plugin, write):
credentials = { credentials = {
"token": "ABC" "token": "ABC"
} }
plugin.store_credentials(credentials)
await skip_loop()
async def couritine(): assert get_messages(write) == [
plugin.store_credentials(credentials) {
"jsonrpc": "2.0",
"method": "store_credentials",
"params": credentials
}
]
asyncio.run(couritine())
response = json.loads(write.call_args[0][0])
assert response == { @pytest.mark.asyncio
"jsonrpc": "2.0", async def test_lost_authentication(plugin, write):
"method": "store_credentials", plugin.lost_authentication()
"params": credentials await skip_loop()
}
def test_lost_authentication(plugin, write): assert get_messages(write) == [
{
async def couritine(): "jsonrpc": "2.0",
plugin.lost_authentication() "method": "authentication_lost",
"params": None
asyncio.run(couritine()) }
response = json.loads(write.call_args[0][0]) ]
assert response == {
"jsonrpc": "2.0",
"method": "authentication_lost",
"params": None
}

View File

@@ -1,7 +1,12 @@
import asyncio
import json import json
def test_chunked_messages(plugin, read): import pytest
from galaxy.unittest.mock import async_return_value
@pytest.mark.asyncio
async def test_chunked_messages(plugin, read):
request = { request = {
"jsonrpc": "2.0", "jsonrpc": "2.0",
"method": "install_game", "method": "install_game",
@@ -11,11 +16,13 @@ def test_chunked_messages(plugin, read):
} }
message = json.dumps(request).encode() + b"\n" message = json.dumps(request).encode() + b"\n"
read.side_effect = [message[:5], message[5:], b""] read.side_effect = [async_return_value(message[:5]), async_return_value(message[5:]), async_return_value(b"")]
asyncio.run(plugin.run()) await plugin.run()
plugin.install_game.assert_called_with(game_id="3") plugin.install_game.assert_called_with(game_id="3")
def test_joined_messages(plugin, read):
@pytest.mark.asyncio
async def test_joined_messages(plugin, read):
requests = [ requests = [
{ {
"jsonrpc": "2.0", "jsonrpc": "2.0",
@@ -34,12 +41,14 @@ def test_joined_messages(plugin, read):
] ]
data = b"".join([json.dumps(request).encode() + b"\n" for request in requests]) data = b"".join([json.dumps(request).encode() + b"\n" for request in requests])
read.side_effect = [data, b""] read.side_effect = [async_return_value(data), async_return_value(b"")]
asyncio.run(plugin.run()) await plugin.run()
plugin.install_game.assert_called_with(game_id="3") plugin.install_game.assert_called_with(game_id="3")
plugin.launch_game.assert_called_with(game_id="3") plugin.launch_game.assert_called_with(game_id="3")
def test_not_finished(plugin, read):
@pytest.mark.asyncio
async def test_not_finished(plugin, read):
request = { request = {
"jsonrpc": "2.0", "jsonrpc": "2.0",
"method": "install_game", "method": "install_game",
@@ -49,6 +58,6 @@ def test_not_finished(plugin, read):
} }
message = json.dumps(request).encode() # no new line message = json.dumps(request).encode() # no new line
read.side_effect = [message, b""] read.side_effect = [async_return_value(message), async_return_value(b"")]
asyncio.run(plugin.run()) await plugin.run()
plugin.install_game.assert_not_called() plugin.install_game.assert_not_called()

View File

@@ -1,21 +1,53 @@
from galaxy.api.consts import Feature, Platform
from galaxy.api.plugin import Plugin from galaxy.api.plugin import Plugin
from galaxy.api.consts import Platform, Feature
def test_base_class(): def test_base_class():
plugin = Plugin(Platform.Generic, "0.1", None, None, None) plugin = Plugin(Platform.Generic, "0.1", None, None, None)
assert plugin.features == [] assert set(plugin.features) == {
Feature.ImportInstalledGames,
Feature.ImportOwnedGames,
Feature.LaunchGame,
Feature.InstallGame,
Feature.UninstallGame,
Feature.ImportAchievements,
Feature.ImportGameTime,
Feature.ImportFriends,
Feature.ShutdownPlatformClient,
Feature.LaunchPlatformClient,
Feature.ImportGameLibrarySettings,
Feature.ImportOSCompatibility,
Feature.ImportUserPresence
}
def test_no_overloads(): def test_no_overloads():
class PluginImpl(Plugin): #pylint: disable=abstract-method class PluginImpl(Plugin): # pylint: disable=abstract-method
pass pass
plugin = PluginImpl(Platform.Generic, "0.1", None, None, None) plugin = PluginImpl(Platform.Generic, "0.1", None, None, None)
assert plugin.features == [] assert plugin.features == []
def test_one_method_feature(): def test_one_method_feature():
class PluginImpl(Plugin): #pylint: disable=abstract-method class PluginImpl(Plugin): # pylint: disable=abstract-method
async def get_owned_games(self): async def get_owned_games(self):
pass pass
plugin = PluginImpl(Platform.Generic, "0.1", None, None, None) plugin = PluginImpl(Platform.Generic, "0.1", None, None, None)
assert plugin.features == [Feature.ImportOwnedGames] assert plugin.features == [Feature.ImportOwnedGames]
def test_multi_features():
class PluginImpl(Plugin): # pylint: disable=abstract-method
async def get_owned_games(self):
pass
async def get_unlocked_achievements(self, game_id, context):
pass
async def get_game_time(self, game_id, context):
pass
plugin = PluginImpl(Platform.Generic, "0.1", None, None, None)
assert set(plugin.features) == {Feature.ImportAchievements, Feature.ImportOwnedGames, Feature.ImportGameTime}

View File

@@ -1,90 +1,96 @@
import asyncio
import json
from galaxy.api.types import FriendInfo from galaxy.api.types import FriendInfo
from galaxy.api.errors import UnknownError from galaxy.api.errors import UnknownError
from galaxy.unittest.mock import async_return_value, skip_loop
import pytest
from tests import create_message, get_messages
def test_get_friends_success(plugin, read, write): @pytest.mark.asyncio
async def test_get_friends_success(plugin, read, write):
request = { request = {
"jsonrpc": "2.0", "jsonrpc": "2.0",
"id": "3", "id": "3",
"method": "import_friends" "method": "import_friends"
} }
read.side_effect = [json.dumps(request).encode() + b"\n", b""] read.side_effect = [async_return_value(create_message(request)), async_return_value(b"", 10)]
plugin.get_friends.coro.return_value = [ plugin.get_friends.return_value = async_return_value([
FriendInfo("3", "Jan"), FriendInfo("3", "Jan"),
FriendInfo("5", "Ola") FriendInfo("5", "Ola")
] ])
asyncio.run(plugin.run()) await plugin.run()
plugin.get_friends.assert_called_with() plugin.get_friends.assert_called_with()
response = json.loads(write.call_args[0][0])
assert response == { assert get_messages(write) == [
"jsonrpc": "2.0", {
"id": "3", "jsonrpc": "2.0",
"result": { "id": "3",
"friend_info_list": [ "result": {
{"user_id": "3", "user_name": "Jan"}, "friend_info_list": [
{"user_id": "5", "user_name": "Ola"} {"user_id": "3", "user_name": "Jan"},
] {"user_id": "5", "user_name": "Ola"}
]
}
} }
} ]
def test_get_friends_failure(plugin, read, write): @pytest.mark.asyncio
async def test_get_friends_failure(plugin, read, write):
request = { request = {
"jsonrpc": "2.0", "jsonrpc": "2.0",
"id": "3", "id": "3",
"method": "import_friends" "method": "import_friends"
} }
read.side_effect = [json.dumps(request).encode() + b"\n", b""] read.side_effect = [async_return_value(create_message(request)), async_return_value(b"", 10)]
plugin.get_friends.coro.side_effect = UnknownError() plugin.get_friends.side_effect = UnknownError()
asyncio.run(plugin.run()) await plugin.run()
plugin.get_friends.assert_called_with() plugin.get_friends.assert_called_with()
response = json.loads(write.call_args[0][0])
assert response == { assert get_messages(write) == [
"jsonrpc": "2.0", {
"id": "3", "jsonrpc": "2.0",
"error": { "id": "3",
"code": 0, "error": {
"message": "Unknown error", "code": 0,
"message": "Unknown error",
}
} }
} ]
def test_add_friend(plugin, write): @pytest.mark.asyncio
async def test_add_friend(plugin, write):
friend = FriendInfo("7", "Kuba") friend = FriendInfo("7", "Kuba")
async def couritine(): plugin.add_friend(friend)
plugin.add_friend(friend) await skip_loop()
asyncio.run(couritine()) assert get_messages(write) == [
response = json.loads(write.call_args[0][0]) {
"jsonrpc": "2.0",
assert response == { "method": "friend_added",
"jsonrpc": "2.0", "params": {
"method": "friend_added", "friend_info": {"user_id": "7", "user_name": "Kuba"}
"params": { }
"friend_info": {"user_id": "7", "user_name": "Kuba"}
} }
} ]
def test_remove_friend(plugin, write): @pytest.mark.asyncio
async def couritine(): async def test_remove_friend(plugin, write):
plugin.remove_friend("5") plugin.remove_friend("5")
await skip_loop()
asyncio.run(couritine()) assert get_messages(write) == [
response = json.loads(write.call_args[0][0]) {
"jsonrpc": "2.0",
assert response == { "method": "friend_removed",
"jsonrpc": "2.0", "params": {
"method": "friend_removed", "user_id": "5"
"params": { }
"user_id": "5"
} }
} ]

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

@@ -1,179 +1,216 @@
import asyncio
import json
from unittest.mock import call from unittest.mock import call
import pytest import pytest
from galaxy.api.types import GameTime from galaxy.api.types import GameTime
from galaxy.api.errors import UnknownError, ImportInProgress, BackendError from galaxy.api.errors import BackendError
from galaxy.unittest.mock import async_return_value, skip_loop
def test_success(plugin, read, write): from tests import create_message, get_messages
@pytest.mark.asyncio
async def test_get_game_time_success(plugin, read, write):
plugin.prepare_game_times_context.return_value = async_return_value("abc")
request = { request = {
"jsonrpc": "2.0", "jsonrpc": "2.0",
"id": "3", "id": "3",
"method": "import_game_times" "method": "start_game_times_import",
"params": {
"game_ids": ["3", "5", "7"]
}
} }
read.side_effect = [async_return_value(create_message(request)), async_return_value(b"", 10)]
read.side_effect = [json.dumps(request).encode() + b"\n", b""] plugin.get_game_time.side_effect = [
plugin.get_game_times.coro.return_value = [ async_return_value(GameTime("3", 60, 1549550504)),
GameTime("3", 60, 1549550504), async_return_value(GameTime("5", 10, None)),
GameTime("5", 10, None), async_return_value(GameTime("7", None, 1549550502)),
GameTime("7", None, 1549550502),
] ]
asyncio.run(plugin.run()) await plugin.run()
plugin.get_game_times.assert_called_with() plugin.get_game_time.assert_has_calls([
response = json.loads(write.call_args[0][0]) call("3", "abc"),
call("5", "abc"),
call("7", "abc"),
])
plugin.game_times_import_complete.assert_called_once_with()
assert response == { assert get_messages(write) == [
{
"jsonrpc": "2.0",
"id": "3",
"result": None
},
{
"jsonrpc": "2.0",
"method": "game_time_import_success",
"params": {
"game_time": {
"game_id": "3",
"last_played_time": 1549550504,
"time_played": 60
}
}
},
{
"jsonrpc": "2.0",
"method": "game_time_import_success",
"params": {
"game_time": {
"game_id": "5",
"time_played": 10
}
}
},
{
"jsonrpc": "2.0",
"method": "game_time_import_success",
"params": {
"game_time": {
"game_id": "7",
"last_played_time": 1549550502
}
}
},
{
"jsonrpc": "2.0",
"method": "game_times_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_time_error(exception, code, message, plugin, read, write):
plugin.prepare_game_times_context.return_value = async_return_value(None)
request = {
"jsonrpc": "2.0", "jsonrpc": "2.0",
"id": "3", "id": "3",
"result": { "method": "start_game_times_import",
"game_times": [ "params": {
{ "game_ids": ["6"]
}
}
read.side_effect = [async_return_value(create_message(request)), async_return_value(b"", 10)]
plugin.get_game_time.side_effect = exception
await plugin.run()
plugin.get_game_time.assert_called()
plugin.game_times_import_complete.assert_called_once_with()
assert get_messages(write) == [
{
"jsonrpc": "2.0",
"id": "3",
"result": None
},
{
"jsonrpc": "2.0",
"method": "game_time_import_failure",
"params": {
"game_id": "6",
"error": {
"code": code,
"message": message
}
}
},
{
"jsonrpc": "2.0",
"method": "game_times_import_finished",
"params": None
}
]
@pytest.mark.asyncio
async def test_prepare_get_game_time_context_error(plugin, read, write):
plugin.prepare_game_times_context.side_effect = BackendError()
request = {
"jsonrpc": "2.0",
"id": "3",
"method": "start_game_times_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_times_context.return_value = async_return_value(None)
requests = [
{
"jsonrpc": "2.0",
"id": "3",
"method": "start_game_times_import",
"params": {
"game_ids": ["6"]
}
},
{
"jsonrpc": "2.0",
"id": "4",
"method": "start_game_times_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
@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) == [
{
"jsonrpc": "2.0",
"method": "game_time_updated",
"params": {
"game_time": {
"game_id": "3", "game_id": "3",
"time_played": 60, "time_played": 60,
"last_played_time": 1549550504 "last_played_time": 1549550504
},
{
"game_id": "5",
"time_played": 10,
},
{
"game_id": "7",
"last_played_time": 1549550502
} }
]
}
}
def test_failure(plugin, read, write):
request = {
"jsonrpc": "2.0",
"id": "3",
"method": "import_game_times"
}
read.side_effect = [json.dumps(request).encode() + b"\n", b""]
plugin.get_game_times.coro.side_effect = UnknownError()
asyncio.run(plugin.run())
plugin.get_game_times.assert_called_with()
response = json.loads(write.call_args[0][0])
assert response == {
"jsonrpc": "2.0",
"id": "3",
"error": {
"code": 0,
"message": "Unknown error",
}
}
def test_update_game(plugin, write):
game_time = GameTime("3", 60, 1549550504)
async def couritine():
plugin.update_game_time(game_time)
asyncio.run(couritine())
response = json.loads(write.call_args[0][0])
assert response == {
"jsonrpc": "2.0",
"method": "game_time_updated",
"params": {
"game_time": {
"game_id": "3",
"time_played": 60,
"last_played_time": 1549550504
} }
} }
}
@pytest.mark.asyncio
async def test_game_time_import_success(plugin, write):
plugin.game_time_import_success(GameTime("3", 60, 1549550504))
response = json.loads(write.call_args[0][0])
assert response == {
"jsonrpc": "2.0",
"method": "game_time_import_success",
"params": {
"game_time": {
"game_id": "3",
"time_played": 60,
"last_played_time": 1549550504
}
}
}
@pytest.mark.asyncio
async def test_game_time_import_failure(plugin, write):
plugin.game_time_import_failure("134", ImportInProgress())
response = json.loads(write.call_args[0][0])
assert response == {
"jsonrpc": "2.0",
"method": "game_time_import_failure",
"params": {
"game_id": "134",
"error": {
"code": 600,
"message": "Import already in progress"
}
}
}
@pytest.mark.asyncio
async def test_game_times_import_finished(plugin, write):
plugin.game_times_import_finished()
response = json.loads(write.call_args[0][0])
assert response == {
"jsonrpc": "2.0",
"method": "game_times_import_finished",
"params": None
}
@pytest.mark.asyncio
async def test_start_game_times_import(plugin, write, mocker):
game_time_import_success = mocker.patch.object(plugin, "game_time_import_success")
game_time_import_failure = mocker.patch.object(plugin, "game_time_import_failure")
game_times_import_finished = mocker.patch.object(plugin, "game_times_import_finished")
game_ids = ["1", "5"]
game_time = GameTime("1", 10, 1549550502)
plugin.get_game_times.coro.return_value = [
game_time
] ]
await plugin.start_game_times_import(game_ids)
with pytest.raises(ImportInProgress):
await plugin.start_game_times_import(["4", "8"])
# wait until all tasks are finished
for _ in range(4):
await asyncio.sleep(0)
plugin.get_game_times.coro.assert_called_once_with()
game_time_import_success.assert_called_once_with(game_time)
game_time_import_failure.assert_called_once_with("5", UnknownError())
game_times_import_finished.assert_called_once_with()
@pytest.mark.asyncio
async def test_start_game_times_import_failure(plugin, write, mocker):
game_time_import_failure = mocker.patch.object(plugin, "game_time_import_failure")
game_times_import_finished = mocker.patch.object(plugin, "game_times_import_finished")
game_ids = ["1", "5"]
error = BackendError()
plugin.get_game_times.coro.side_effect = error
await plugin.start_game_times_import(game_ids)
# wait until all tasks are finished
for _ in range(4):
await asyncio.sleep(0)
plugin.get_game_times.coro.assert_called_once_with()
assert game_time_import_failure.mock_calls == [call("1", error), call("5", error)]
game_times_import_finished.assert_called_once_with()

View File

@@ -3,6 +3,8 @@ from http import HTTPStatus
import aiohttp import aiohttp
import pytest import pytest
from multidict import CIMultiDict, CIMultiDictProxy
from yarl import URL
from galaxy.api.errors import ( from galaxy.api.errors import (
AccessDenied, AuthenticationRequired, BackendTimeout, BackendNotAvailable, BackendError, NetworkError, AccessDenied, AuthenticationRequired, BackendTimeout, BackendNotAvailable, BackendError, NetworkError,
@@ -10,7 +12,7 @@ from galaxy.api.errors import (
) )
from galaxy.http import handle_exception from galaxy.http import handle_exception
request_info = aiohttp.RequestInfo("http://o.pl", "GET", {}) request_info = aiohttp.RequestInfo(URL("http://o.pl"), "GET", CIMultiDictProxy(CIMultiDict()))
@pytest.mark.parametrize( @pytest.mark.parametrize(
"aiohttp_exception,expected_exception_type", "aiohttp_exception,expected_exception_type",
@@ -18,15 +20,15 @@ request_info = aiohttp.RequestInfo("http://o.pl", "GET", {})
(asyncio.TimeoutError(), BackendTimeout), (asyncio.TimeoutError(), BackendTimeout),
(aiohttp.ServerDisconnectedError(), BackendNotAvailable), (aiohttp.ServerDisconnectedError(), BackendNotAvailable),
(aiohttp.ClientConnectionError(), NetworkError), (aiohttp.ClientConnectionError(), NetworkError),
(aiohttp.ContentTypeError(request_info, []), UnknownBackendResponse), (aiohttp.ContentTypeError(request_info, ()), UnknownBackendResponse),
(aiohttp.ClientResponseError(request_info, [], status=HTTPStatus.UNAUTHORIZED), AuthenticationRequired), (aiohttp.ClientResponseError(request_info, (), status=HTTPStatus.UNAUTHORIZED), AuthenticationRequired),
(aiohttp.ClientResponseError(request_info, [], status=HTTPStatus.FORBIDDEN), AccessDenied), (aiohttp.ClientResponseError(request_info, (), status=HTTPStatus.FORBIDDEN), AccessDenied),
(aiohttp.ClientResponseError(request_info, [], status=HTTPStatus.SERVICE_UNAVAILABLE), BackendNotAvailable), (aiohttp.ClientResponseError(request_info, (), status=HTTPStatus.SERVICE_UNAVAILABLE), BackendNotAvailable),
(aiohttp.ClientResponseError(request_info, [], status=HTTPStatus.TOO_MANY_REQUESTS), TooManyRequests), (aiohttp.ClientResponseError(request_info, (), status=HTTPStatus.TOO_MANY_REQUESTS), TooManyRequests),
(aiohttp.ClientResponseError(request_info, [], status=HTTPStatus.INTERNAL_SERVER_ERROR), BackendError), (aiohttp.ClientResponseError(request_info, (), status=HTTPStatus.INTERNAL_SERVER_ERROR), BackendError),
(aiohttp.ClientResponseError(request_info, [], status=HTTPStatus.NOT_IMPLEMENTED), BackendError), (aiohttp.ClientResponseError(request_info, (), status=HTTPStatus.NOT_IMPLEMENTED), BackendError),
(aiohttp.ClientResponseError(request_info, [], status=HTTPStatus.BAD_REQUEST), UnknownError), (aiohttp.ClientResponseError(request_info, (), status=HTTPStatus.BAD_REQUEST), UnknownError),
(aiohttp.ClientResponseError(request_info, [], status=HTTPStatus.NOT_FOUND), UnknownError), (aiohttp.ClientResponseError(request_info, (), status=HTTPStatus.NOT_FOUND), UnknownError),
(aiohttp.ClientError(), UnknownError) (aiohttp.ClientError(), UnknownError)
] ]
) )

View File

@@ -1,7 +1,12 @@
import asyncio import pytest
import json
def test_success(plugin, read): from galaxy.unittest.mock import async_return_value
from tests import create_message
@pytest.mark.asyncio
async def test_success(plugin, read):
request = { request = {
"jsonrpc": "2.0", "jsonrpc": "2.0",
"method": "install_game", "method": "install_game",
@@ -10,7 +15,6 @@ def test_success(plugin, read):
} }
} }
read.side_effect = [json.dumps(request).encode() + b"\n", b""] read.side_effect = [async_return_value(create_message(request)), async_return_value(b"")]
plugin.get_owned_games.return_value = None await plugin.run()
asyncio.run(plugin.run())
plugin.install_game.assert_called_with(game_id="3") plugin.install_game.assert_called_with(game_id="3")

View File

@@ -1,10 +1,14 @@
import asyncio import pytest
import json
from galaxy.api.plugin import Plugin from galaxy.api.plugin import Plugin
from galaxy.api.consts import Platform from galaxy.api.consts import Platform
from galaxy.unittest.mock import async_return_value
def test_get_capabilites(reader, writer, read, write): from tests import create_message, get_messages
@pytest.mark.asyncio
async def test_get_capabilities(reader, writer, read, write):
class PluginImpl(Plugin): #pylint: disable=abstract-method class PluginImpl(Plugin): #pylint: disable=abstract-method
async def get_owned_games(self): async def get_owned_games(self):
pass pass
@@ -16,64 +20,76 @@ def test_get_capabilites(reader, writer, read, write):
} }
token = "token" token = "token"
plugin = PluginImpl(Platform.Generic, "0.1", reader, writer, token) plugin = PluginImpl(Platform.Generic, "0.1", reader, writer, token)
read.side_effect = [json.dumps(request).encode() + b"\n", b""] read.side_effect = [async_return_value(create_message(request)), async_return_value(b"")]
asyncio.run(plugin.run()) await plugin.run()
response = json.loads(write.call_args[0][0]) assert get_messages(write) == [
assert response == { {
"jsonrpc": "2.0", "jsonrpc": "2.0",
"id": "3", "id": "3",
"result": { "result": {
"platform_name": "generic", "platform_name": "generic",
"features": [ "features": [
"ImportOwnedGames" "ImportOwnedGames"
], ],
"token": token "token": token
}
} }
} ]
def test_shutdown(plugin, read, write):
@pytest.mark.asyncio
async def test_shutdown(plugin, read, write):
request = { request = {
"jsonrpc": "2.0", "jsonrpc": "2.0",
"id": "5", "id": "5",
"method": "shutdown" "method": "shutdown"
} }
read.side_effect = [json.dumps(request).encode() + b"\n", b""] read.side_effect = [async_return_value(create_message(request))]
asyncio.run(plugin.run()) await plugin.run()
await plugin.wait_closed()
plugin.shutdown.assert_called_with() plugin.shutdown.assert_called_with()
response = json.loads(write.call_args[0][0]) assert get_messages(write) == [
assert response == { {
"jsonrpc": "2.0", "jsonrpc": "2.0",
"id": "5", "id": "5",
"result": None "result": None
} }
]
def test_ping(plugin, read, write):
@pytest.mark.asyncio
async def test_ping(plugin, read, write):
request = { request = {
"jsonrpc": "2.0", "jsonrpc": "2.0",
"id": "7", "id": "7",
"method": "ping" "method": "ping"
} }
read.side_effect = [json.dumps(request).encode() + b"\n", b""] read.side_effect = [async_return_value(create_message(request)), async_return_value(b"")]
asyncio.run(plugin.run()) await plugin.run()
response = json.loads(write.call_args[0][0]) assert get_messages(write) == [
assert response == { {
"jsonrpc": "2.0", "jsonrpc": "2.0",
"id": "7", "id": "7",
"result": None "result": None
} }
]
def test_tick_before_handshake(plugin, read):
read.side_effect = [b""] @pytest.mark.asyncio
asyncio.run(plugin.run()) async def test_tick_before_handshake(plugin, read):
read.side_effect = [async_return_value(b"")]
await plugin.run()
plugin.tick.assert_not_called() plugin.tick.assert_not_called()
def test_tick_after_handshake(plugin, read):
@pytest.mark.asyncio
async def test_tick_after_handshake(plugin, read):
request = { request = {
"jsonrpc": "2.0", "jsonrpc": "2.0",
"id": "6", "id": "6",
"method": "initialize_cache", "method": "initialize_cache",
"params": {"data": {}} "params": {"data": {}}
} }
read.side_effect = [json.dumps(request).encode() + b"\n", b""] read.side_effect = [async_return_value(create_message(request)), async_return_value(b"")]
asyncio.run(plugin.run()) await plugin.run()
plugin.tick.assert_called_with() plugin.tick.assert_called_with()

View File

@@ -1,7 +1,12 @@
import asyncio import pytest
import json
def test_success(plugin, read): from galaxy.unittest.mock import async_return_value
from tests import create_message
@pytest.mark.asyncio
async def test_success(plugin, read):
request = { request = {
"jsonrpc": "2.0", "jsonrpc": "2.0",
"method": "launch_game", "method": "launch_game",
@@ -10,7 +15,6 @@ def test_success(plugin, read):
} }
} }
read.side_effect = [json.dumps(request).encode() + b"\n", b""] read.side_effect = [async_return_value(create_message(request)), async_return_value(b"")]
plugin.get_owned_games.return_value = None await plugin.run()
asyncio.run(plugin.run())
plugin.launch_game.assert_called_with(game_id="3") plugin.launch_game.assert_called_with(game_id="3")

View File

@@ -0,0 +1,17 @@
import pytest
from galaxy.unittest.mock import async_return_value
from tests import create_message
@pytest.mark.asyncio
async def test_success(plugin, read):
request = {
"jsonrpc": "2.0",
"method": "launch_platform_client"
}
read.side_effect = [async_return_value(create_message(request)), async_return_value(b"")]
plugin.launch_platform_client.return_value = async_return_value(None)
await plugin.run()
plugin.launch_platform_client.assert_called_with()

View File

@@ -1,51 +1,55 @@
import asyncio
import json
import pytest import pytest
from galaxy.api.types import LocalGame from galaxy.api.types import LocalGame
from galaxy.api.consts import LocalGameState from galaxy.api.consts import LocalGameState
from galaxy.api.errors import UnknownError, FailedParsingManifest from galaxy.api.errors import UnknownError, FailedParsingManifest
from galaxy.unittest.mock import async_return_value, skip_loop
def test_success(plugin, read, write): from tests import create_message, get_messages
@pytest.mark.asyncio
async def test_success(plugin, read, write):
request = { request = {
"jsonrpc": "2.0", "jsonrpc": "2.0",
"id": "3", "id": "3",
"method": "import_local_games" "method": "import_local_games"
} }
read.side_effect = [async_return_value(create_message(request)), async_return_value(b"", 10)]
read.side_effect = [json.dumps(request).encode() + b"\n", b""] plugin.get_local_games.return_value = async_return_value([
plugin.get_local_games.coro.return_value = [
LocalGame("1", LocalGameState.Running), LocalGame("1", LocalGameState.Running),
LocalGame("2", LocalGameState.Installed), LocalGame("2", LocalGameState.Installed),
LocalGame("3", LocalGameState.Installed | LocalGameState.Running) LocalGame("3", LocalGameState.Installed | LocalGameState.Running)
] ])
asyncio.run(plugin.run()) await plugin.run()
plugin.get_local_games.assert_called_with() plugin.get_local_games.assert_called_with()
response = json.loads(write.call_args[0][0]) assert get_messages(write) == [
assert response == { {
"jsonrpc": "2.0", "jsonrpc": "2.0",
"id": "3", "id": "3",
"result": { "result": {
"local_games" : [ "local_games" : [
{ {
"game_id": "1", "game_id": "1",
"local_game_state": LocalGameState.Running.value "local_game_state": LocalGameState.Running.value
}, },
{ {
"game_id": "2", "game_id": "2",
"local_game_state": LocalGameState.Installed.value "local_game_state": LocalGameState.Installed.value
}, },
{ {
"game_id": "3", "game_id": "3",
"local_game_state": (LocalGameState.Installed | LocalGameState.Running).value "local_game_state": (LocalGameState.Installed | LocalGameState.Running).value
} }
] ]
}
} }
} ]
@pytest.mark.asyncio
@pytest.mark.parametrize( @pytest.mark.parametrize(
"error,code,message", "error,code,message",
[ [
@@ -53,44 +57,43 @@ def test_success(plugin, read, write):
pytest.param(FailedParsingManifest, 200, "Failed parsing manifest", id="failed_parsing") pytest.param(FailedParsingManifest, 200, "Failed parsing manifest", id="failed_parsing")
], ],
) )
def test_failure(plugin, read, write, error, code, message): async def test_failure(plugin, read, write, error, code, message):
request = { request = {
"jsonrpc": "2.0", "jsonrpc": "2.0",
"id": "3", "id": "3",
"method": "import_local_games" "method": "import_local_games"
} }
read.side_effect = [async_return_value(create_message(request)), async_return_value(b"", 10)]
read.side_effect = [json.dumps(request).encode() + b"\n", b""] plugin.get_local_games.side_effect = error()
plugin.get_local_games.coro.side_effect = error() await plugin.run()
asyncio.run(plugin.run())
plugin.get_local_games.assert_called_with() plugin.get_local_games.assert_called_with()
response = json.loads(write.call_args[0][0])
assert response == { assert get_messages(write) == [
"jsonrpc": "2.0", {
"id": "3", "jsonrpc": "2.0",
"error": { "id": "3",
"code": code, "error": {
"message": message "code": code,
} "message": message
}
def test_local_game_state_update(plugin, write):
game = LocalGame("1", LocalGameState.Running)
async def couritine():
plugin.update_local_game_status(game)
asyncio.run(couritine())
response = json.loads(write.call_args[0][0])
assert response == {
"jsonrpc": "2.0",
"method": "local_game_status_changed",
"params": {
"local_game": {
"game_id": "1",
"local_game_state": LocalGameState.Running.value
} }
} }
} ]
@pytest.mark.asyncio
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) == [
{
"jsonrpc": "2.0",
"method": "local_game_status_changed",
"params": {
"local_game": {
"game_id": "1",
"local_game_state": LocalGameState.Running.value
}
}
}
]

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

@@ -1,19 +1,23 @@
import asyncio import pytest
import json
from galaxy.api.types import Game, Dlc, LicenseInfo from galaxy.api.types import Game, Dlc, LicenseInfo
from galaxy.api.consts import LicenseType from galaxy.api.consts import LicenseType
from galaxy.api.errors import UnknownError from galaxy.api.errors import UnknownError
from galaxy.unittest.mock import async_return_value, skip_loop
def test_success(plugin, read, write): from tests import create_message, get_messages
@pytest.mark.asyncio
async def test_success(plugin, read, write):
request = { request = {
"jsonrpc": "2.0", "jsonrpc": "2.0",
"id": "3", "id": "3",
"method": "import_owned_games" "method": "import_owned_games"
} }
read.side_effect = [async_return_value(create_message(request)), async_return_value(b"", 10)]
read.side_effect = [json.dumps(request).encode() + b"\n", b""] plugin.get_owned_games.return_value = async_return_value([
plugin.get_owned_games.coro.return_value = [
Game("3", "Doom", None, LicenseInfo(LicenseType.SinglePurchase, None)), Game("3", "Doom", None, LicenseInfo(LicenseType.SinglePurchase, None)),
Game( Game(
"5", "5",
@@ -23,129 +27,129 @@ def test_success(plugin, read, write):
Dlc("8", "Temerian Armor Set", LicenseInfo(LicenseType.FreeToPlay, None)), Dlc("8", "Temerian Armor Set", LicenseInfo(LicenseType.FreeToPlay, None)),
], ],
LicenseInfo(LicenseType.SinglePurchase, None)) LicenseInfo(LicenseType.SinglePurchase, None))
] ])
asyncio.run(plugin.run()) await plugin.run()
plugin.get_owned_games.assert_called_with() plugin.get_owned_games.assert_called_with()
response = json.loads(write.call_args[0][0]) assert get_messages(write) == [
{
assert response == { "jsonrpc": "2.0",
"jsonrpc": "2.0", "id": "3",
"id": "3", "result": {
"result": { "owned_games": [
"owned_games": [ {
{ "game_id": "3",
"game_id": "3", "game_title": "Doom",
"game_title": "Doom", "license_info": {
"license_info": { "license_type": "SinglePurchase"
"license_type": "SinglePurchase" }
} },
}, {
{ "game_id": "5",
"game_id": "5", "game_title": "Witcher 3",
"game_title": "Witcher 3", "dlcs": [
"dlcs": [ {
{ "dlc_id": "7",
"dlc_id": "7", "dlc_title": "Hearts of Stone",
"dlc_title": "Hearts of Stone", "license_info": {
"license_info": { "license_type": "SinglePurchase"
"license_type": "SinglePurchase" }
} },
}, {
{ "dlc_id": "8",
"dlc_id": "8", "dlc_title": "Temerian Armor Set",
"dlc_title": "Temerian Armor Set", "license_info": {
"license_info": { "license_type": "FreeToPlay"
"license_type": "FreeToPlay" }
} }
],
"license_info": {
"license_type": "SinglePurchase"
} }
],
"license_info": {
"license_type": "SinglePurchase"
} }
} ]
] }
} }
} ]
def test_failure(plugin, read, write):
@pytest.mark.asyncio
async def test_failure(plugin, read, write):
request = { request = {
"jsonrpc": "2.0", "jsonrpc": "2.0",
"id": "3", "id": "3",
"method": "import_owned_games" "method": "import_owned_games"
} }
read.side_effect = [json.dumps(request).encode() + b"\n", b""] read.side_effect = [async_return_value(create_message(request)), async_return_value(b"", 10)]
plugin.get_owned_games.coro.side_effect = UnknownError() plugin.get_owned_games.side_effect = UnknownError()
asyncio.run(plugin.run()) await plugin.run()
plugin.get_owned_games.assert_called_with() plugin.get_owned_games.assert_called_with()
response = json.loads(write.call_args[0][0]) assert get_messages(write) == [
{
assert response == { "jsonrpc": "2.0",
"jsonrpc": "2.0", "id": "3",
"id": "3", "error": {
"error": { "code": 0,
"code": 0, "message": "Unknown error"
"message": "Unknown error" }
} }
} ]
def test_add_game(plugin, write):
@pytest.mark.asyncio
async def test_add_game(plugin, write):
game = Game("3", "Doom", None, LicenseInfo(LicenseType.SinglePurchase, None)) game = Game("3", "Doom", None, LicenseInfo(LicenseType.SinglePurchase, None))
plugin.add_game(game)
async def couritine(): await skip_loop()
plugin.add_game(game) assert get_messages(write) == [
{
asyncio.run(couritine()) "jsonrpc": "2.0",
response = json.loads(write.call_args[0][0]) "method": "owned_game_added",
"params": {
assert response == { "owned_game": {
"jsonrpc": "2.0", "game_id": "3",
"method": "owned_game_added", "game_title": "Doom",
"params": { "license_info": {
"owned_game": { "license_type": "SinglePurchase"
"game_id": "3", }
"game_title": "Doom",
"license_info": {
"license_type": "SinglePurchase"
} }
} }
} }
} ]
def test_remove_game(plugin, write):
async def couritine():
plugin.remove_game("5")
asyncio.run(couritine()) @pytest.mark.asyncio
response = json.loads(write.call_args[0][0]) async def test_remove_game(plugin, write):
plugin.remove_game("5")
assert response == { await skip_loop()
"jsonrpc": "2.0", assert get_messages(write) == [
"method": "owned_game_removed", {
"params": { "jsonrpc": "2.0",
"game_id": "5" "method": "owned_game_removed",
"params": {
"game_id": "5"
}
} }
} ]
def test_update_game(plugin, write):
@pytest.mark.asyncio
async def test_update_game(plugin, write):
game = Game("3", "Doom", None, LicenseInfo(LicenseType.SinglePurchase, None)) game = Game("3", "Doom", None, LicenseInfo(LicenseType.SinglePurchase, None))
plugin.update_game(game)
async def couritine(): await skip_loop()
plugin.update_game(game) assert get_messages(write) == [
{
asyncio.run(couritine()) "jsonrpc": "2.0",
response = json.loads(write.call_args[0][0]) "method": "owned_game_updated",
"params": {
assert response == { "owned_game": {
"jsonrpc": "2.0", "game_id": "3",
"method": "owned_game_updated", "game_title": "Doom",
"params": { "license_info": {
"owned_game": { "license_type": "SinglePurchase"
"game_id": "3", }
"game_title": "Doom",
"license_info": {
"license_type": "SinglePurchase"
} }
} }
} }
} ]

View File

@@ -1,23 +1,28 @@
import asyncio
import json
import pytest import pytest
from galaxy.unittest.mock import async_return_value, skip_loop
from tests import create_message, get_messages
def assert_rpc_response(write, response_id, result=None): def assert_rpc_response(write, response_id, result=None):
assert json.loads(write.call_args[0][0]) == { assert get_messages(write) == [
"jsonrpc": "2.0", {
"id": str(response_id), "jsonrpc": "2.0",
"result": result "id": str(response_id),
} "result": result
}
]
def assert_rpc_request(write, method, params=None): def assert_rpc_request(write, method, params=None):
assert json.loads(write.call_args[0][0]) == { assert get_messages(write) == [
"jsonrpc": "2.0", {
"method": method, "jsonrpc": "2.0",
"params": {"data": params} "method": method,
} "params": {"data": params}
}
]
@pytest.fixture @pytest.fixture
@@ -28,7 +33,8 @@ def cache_data():
} }
def test_initialize_cache(plugin, read, write, cache_data): @pytest.mark.asyncio
async def test_initialize_cache(plugin, read, write, cache_data):
request_id = 3 request_id = 3
request = { request = {
"jsonrpc": "2.0", "jsonrpc": "2.0",
@@ -36,36 +42,34 @@ def test_initialize_cache(plugin, read, write, cache_data):
"method": "initialize_cache", "method": "initialize_cache",
"params": {"data": cache_data} "params": {"data": cache_data}
} }
read.side_effect = [json.dumps(request).encode() + b"\n"] read.side_effect = [async_return_value(create_message(request)), async_return_value(b"")]
assert {} == plugin.persistent_cache assert {} == plugin.persistent_cache
asyncio.run(plugin.run()) await plugin.run()
plugin.handshake_complete.assert_called_once_with() plugin.handshake_complete.assert_called_once_with()
assert cache_data == plugin.persistent_cache assert cache_data == plugin.persistent_cache
assert_rpc_response(write, response_id=request_id) assert_rpc_response(write, response_id=request_id)
def test_set_cache(plugin, write, cache_data): @pytest.mark.asyncio
async def runner(): async def test_set_cache(plugin, write, cache_data):
assert {} == plugin.persistent_cache assert {} == plugin.persistent_cache
plugin.persistent_cache.update(cache_data) plugin.persistent_cache.update(cache_data)
plugin.push_cache() plugin.push_cache()
await skip_loop()
assert_rpc_request(write, "push_cache", cache_data) assert_rpc_request(write, "push_cache", cache_data)
assert cache_data == plugin.persistent_cache assert cache_data == plugin.persistent_cache
asyncio.run(runner())
def test_clear_cache(plugin, write, cache_data): @pytest.mark.asyncio
async def runner(): async def test_clear_cache(plugin, write, cache_data):
plugin._persistent_cache = cache_data plugin._persistent_cache = cache_data
plugin.persistent_cache.clear() plugin.persistent_cache.clear()
plugin.push_cache() plugin.push_cache()
await skip_loop()
assert_rpc_request(write, "push_cache", {}) assert_rpc_request(write, "push_cache", {})
assert {} == plugin.persistent_cache assert {} == plugin.persistent_cache
asyncio.run(runner())

View File

@@ -1,7 +1,9 @@
import json
import pytest import pytest
from galaxy.unittest.mock import async_return_value
from tests import create_message
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_success(plugin, read): async def test_success(plugin, read):
request = { request = {
@@ -9,7 +11,7 @@ async def test_success(plugin, read):
"method": "shutdown_platform_client" "method": "shutdown_platform_client"
} }
read.side_effect = [json.dumps(request).encode() + b"\n", b""] read.side_effect = [async_return_value(create_message(request)), async_return_value(b"")]
plugin.shutdown_platform_client.return_value = None plugin.shutdown_platform_client.return_value = async_return_value(None)
await plugin.run() await plugin.run()
plugin.shutdown_platform_client.assert_called_with() plugin.shutdown_platform_client.assert_called_with()

View File

@@ -1,52 +1,46 @@
from unittest.mock import MagicMock
import pytest import pytest
from galaxy.reader import StreamLineReader from galaxy.reader import StreamLineReader
from galaxy.unittest.mock import AsyncMock from galaxy.unittest.mock import async_return_value
@pytest.fixture() @pytest.fixture()
def stream_reader(): def stream_line_reader(reader):
reader = MagicMock() return StreamLineReader(reader)
reader.read = AsyncMock()
return reader
@pytest.fixture()
def read(stream_reader):
return stream_reader.read
@pytest.fixture()
def reader(stream_reader):
return StreamLineReader(stream_reader)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_message(reader, read): async def test_message(stream_line_reader, read):
read.return_value = b"a\n" read.return_value = async_return_value(b"a\n")
assert await reader.readline() == b"a" assert await stream_line_reader.readline() == b"a"
read.assert_called_once() read.assert_called_once()
@pytest.mark.asyncio
async def test_separate_messages(reader, read):
read.side_effect = [b"a\n", b"b\n"]
assert await reader.readline() == b"a"
assert await reader.readline() == b"b"
assert read.call_count == 2
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_connected_messages(reader, read): async def test_separate_messages(stream_line_reader, read):
read.return_value = b"a\nb\n" read.side_effect = [async_return_value(b"a\n"), async_return_value(b"b\n")]
assert await reader.readline() == b"a" assert await stream_line_reader.readline() == b"a"
assert await reader.readline() == b"b" assert await stream_line_reader.readline() == b"b"
assert read.call_count == 2
@pytest.mark.asyncio
async def test_connected_messages(stream_line_reader, read):
read.return_value = async_return_value(b"a\nb\n")
assert await stream_line_reader.readline() == b"a"
assert await stream_line_reader.readline() == b"b"
read.assert_called_once() read.assert_called_once()
@pytest.mark.asyncio
async def test_cut_message(reader, read):
read.side_effect = [b"a", b"b\n"]
assert await reader.readline() == b"ab"
assert read.call_count == 2
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_half_message(reader, read): async def test_cut_message(stream_line_reader, read):
read.side_effect = [b"a", b""] read.side_effect = [async_return_value(b"a"), async_return_value(b"b\n")]
assert await reader.readline() == b"" assert await stream_line_reader.readline() == b"ab"
assert read.call_count == 2
@pytest.mark.asyncio
async def test_half_message(stream_line_reader, read):
read.side_effect = [async_return_value(b"a"), async_return_value(b"")]
assert await stream_line_reader.readline() == b""
assert read.call_count == 2 assert read.call_count == 2

View File

@@ -1,7 +1,11 @@
import asyncio import pytest
import json
def test_success(plugin, read): from galaxy.unittest.mock import async_return_value
from tests import create_message
@pytest.mark.asyncio
async def test_success(plugin, read):
request = { request = {
"jsonrpc": "2.0", "jsonrpc": "2.0",
"method": "uninstall_game", "method": "uninstall_game",
@@ -9,8 +13,7 @@ def test_success(plugin, read):
"game_id": "3" "game_id": "3"
} }
} }
read.side_effect = [async_return_value(create_message(request)), async_return_value(b"")]
read.side_effect = [json.dumps(request).encode() + b"\n", b""]
plugin.get_owned_games.return_value = None plugin.get_owned_games.return_value = None
asyncio.run(plugin.run()) await plugin.run()
plugin.uninstall_game.assert_called_with(game_id="3") plugin.uninstall_game.assert_called_with(game_id="3")

231
tests/test_user_presence.py Normal file
View File

@@ -0,0 +1,231 @@
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
from tests import create_message, get_messages
@pytest.mark.asyncio
async def test_get_user_presence_success(plugin, read, write):
context = "abc"
user_ids = ["666", "13", "42", "69"]
plugin.prepare_user_presence_context.return_value = async_return_value(context)
request = {
"jsonrpc": "2.0",
"id": "11",
"method": "start_user_presence_import",
"params": {"user_ids": user_ids}
}
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"
)),
async_return_value(UserPresence(
PresenceState.Offline,
None,
None,
"Going to grandma's house"
)),
async_return_value(UserPresence(
PresenceState.Online,
"game-id3",
"game-title3",
"Pew pew"
)),
async_return_value(UserPresence(
PresenceState.Away,
None,
"game-title4",
"AFKKTHXBY"
)),
]
await plugin.run()
plugin.get_user_presence.assert_has_calls([
call(user_id, context) for user_id in user_ids
])
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",
"presence_status": "unknown state"
}
}
},
{
"jsonrpc": "2.0",
"method": "user_presence_import_success",
"params": {
"user_id": "13",
"presence": {
"presence_state": PresenceState.Offline.value,
"presence_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",
"presence_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",
"presence_status": "AFKKTHXBY"
}
}
},
{
"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_ids": [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_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_user_presence_context.return_value = async_return_value(None)
requests = [
{
"jsonrpc": "2.0",
"id": "3",
"method": "start_user_presence_import",
"params": {
"user_ids": ["42"]
}
},
{
"jsonrpc": "2.0",
"id": "4",
"method": "start_user_presence_import",
"params": {
"user_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