Compare commits

..

37 Commits
0.61 ... 0.66.0

Author SHA1 Message Date
Mieszko Banczerowski
0cf447bdcf Increment vesion 2020-06-24 16:04:20 +02:00
Mieszko Banczerowski
259702e0de GPI-1396 Add issue templates and external links 2020-06-23 11:18:08 +02:00
mbanczerowski
b96c55397e Fix typo in PLATFORM_IDs.md (#161) 2020-06-23 10:56:16 +02:00
Mieszko Banczerowski
f82cab2770 GPI-1399: Update get_local_size docs 2020-06-22 11:12:12 +02:00
Robert Korulczyk
1e7c284035 Fix typo 2020-06-19 22:19:45 +02:00
Mieszko Banczerowski
0c49ee315e Updates missing ids to PLATFORM_ID.md 2020-06-18 17:31:16 +02:00
Mieszko Banczerowski
aaeca6b47e Increment version 2020-05-15 12:17:28 +02:00
Mieszko Banczerowski
fe8f7e929a Bump psutil >5.6.6 due to CVE-2019-18874 2020-05-15 11:52:49 +02:00
Mieszko Banczerowski
49da4d4d37 GPI-1341 Fix logging error on _handle_response 2020-05-11 16:05:54 +02:00
Mieszko Banczerowski
9745dcd8ef GPI-1237 Docs clarification about platforms 2020-04-27 12:14:23 +02:00
Mateusz Silaczewski
ad758b0da9 subscription settings 2020-03-23 10:15:24 +01:00
unknown
9062944d4f adhere to comments 2020-02-18 15:09:19 +01:00
unknown
2251747281 adhere to comments 2020-02-18 15:03:58 +01:00
unknown
0245e47a74 cleanup docs, up version 2020-02-11 09:53:38 +01:00
unknown
0c51ff2cc9 adhere to comments, move importers to seperate module 2020-02-10 11:37:01 +01:00
unknown
cd452b881d include subscription_name in partial finished notification 2020-02-10 10:05:58 +01:00
unknown
19c9f14ca9 separate sub importer, notify partial finished per subscription 2020-02-10 09:26:48 +01:00
unknown
f5683d222a adhere to comments 2020-02-07 09:20:33 +01:00
unknown
44ea89ef63 adhere to comments 2020-02-07 09:17:26 +01:00
unknown
325cf66c7d cleanup 2020-02-06 13:52:07 +01:00
unknown
cd8aecac8f use python from galaxy 2020-02-06 13:49:23 +01:00
unknown
3aa37907fc use python from galaxy 2020-02-06 13:49:02 +01:00
unknown
01e844009b use python from galaxy 2020-02-06 13:45:24 +01:00
unknown
4a7febfa37 check remote tests fix 2020-02-06 12:20:30 +01:00
unknown
f9eb9ab6cb dont change line in unittest/mock.py 2020-02-06 11:49:30 +01:00
unknown
134fbe2752 adjust tests, allow for None yield 2020-02-06 11:47:34 +01:00
unknown
bd8e6703e0 async yield 2020-02-06 11:05:47 +01:00
unknown
74e3825f10 prepare interfaces for subscriptions 2020-02-05 16:28:15 +01:00
Mieszko Banczerowski
62206318bd GPI-1122 get_local_size docs clarification about unknown size and 0 value use-cases 2020-02-03 11:23:01 +01:00
Mieszko Banczerowski
083b9f869f GPI-1050 More detailed logging in http module 2020-01-28 15:03:21 +01:00
Mieszko Banczerowski
617dbdfee7 GPI-1109 Implement get_game_size 2020-01-28 10:35:54 +01:00
Denis LE
65f4334c03 Fix typo in galaxy.http doc 2019-12-25 12:42:56 +01:00
Aleksej Pawlowskij
26102dd832 Increment version 2019-12-17 15:56:37 +01:00
Aleksej Pawlowskij
cdcebda529 SDK-3136: Relax install requirements 2019-12-17 15:43:47 +01:00
Romuald Bierbasz
a83f348d7d Increment version 2019-12-10 16:02:40 +01:00
Romuald Bierbasz
1c196d60d5 SDK-3199: Log response json 2019-12-10 16:00:46 +01:00
Aleksej Pawlowskij
deb125ec48 Add missing psutil setup requirement 2019-12-05 16:22:26 +01:00
20 changed files with 951 additions and 115 deletions

11
.github/ISSUE_TEMPLATE/config.yml vendored Normal file
View File

@@ -0,0 +1,11 @@
blank_issues_enabled: true
contact_links:
- name: GOG GALAXY 2.0 issue
url: https://mantis2.gog.com/
about: Report issues related to GOG GALAXY 2.0, official integrations or the whole ecosystem
- name: Platform ID request
url: https://github.com/gogcom/galaxy-integrations-python-api/issues/160
about: Report missing platform id
- name: Community integrations
url: https://github.com/Mixaill/awesome-gog-galaxy
about: Find integrations and their maintainers, request new integrations or report issues related to unofficial integrations.

View File

@@ -0,0 +1,14 @@
---
name: API issue
about: Report a bug or problem with current API architecture
---
**Problem**
<!-- Describe the problem you faced. -->
**Solution**
<!-- Describe the solution you'd like. -->
**Alternatives**
<!-- Optionally describe possible alternatives or current workarounds if any. -->

1
.gitignore vendored
View File

@@ -8,3 +8,4 @@ Pipfile
.idea
docs/source/_build
.mypy_cache
.pytest_cache

View File

@@ -1,4 +1,4 @@
image: registry-gitlab.gog.com/galaxy-client/gitlab-ci-tools:latest
image: registry-gitlab.gog.com/docker/python:3.7.3
stages:
- test

View File

@@ -4,10 +4,10 @@ Platform ID list for GOG Galaxy 2.0 Integrations
| ID | Name |
| --- | --- |
| test | Testing purposes |
| steam | Steam |
| psn | PlayStation Network |
| xboxone | Xbox Live |
| generic | Manually added games |
| origin | Origin |
| uplay | Uplay |
| battlenet | Battle.net |
@@ -80,3 +80,12 @@ Platform ID list for GOG Galaxy 2.0 Integrations
| nds | Nintendo DS |
| 3ds | Nintendo 3DS |
| pathofexile | Path of Exile |
| twitch | Twitch |
| minecraft | Minecraft |
| gamesessions | GameSessions |
| nuuvem | Nuuvem |
| fxstore | FX Store |
| indiegala | IndieGala |
| playfire | Playfire |
| oculus | Oculus |
| rockstar | Rockstar |

View File

@@ -36,20 +36,31 @@ Communication between an integration and the client is also possible with the us
import sys
from galaxy.api.plugin import Plugin, create_and_run_plugin
from galaxy.api.consts import Platform
from galaxy.api.types import Authentication, Game, LicenseInfo, LicenseType
class PluginExample(Plugin):
def __init__(self, reader, writer, token):
super().__init__(
Platform.Generic, # Choose platform from available list
"0.1", # Version
Platform.Test, # choose platform from available list
"0.1", # version
reader,
writer,
token
)
# implement methods
# required
async def authenticate(self, stored_credentials=None):
pass
return Authentication('test_user_id', 'Test User Name')
# required
async def get_owned_games(self):
return [
Game('test', 'The Test', None, LicenseInfo(LicenseType.SinglePurchase))
]
def main():
create_and_run_plugin(PluginExample, sys.argv)
@@ -76,6 +87,20 @@ In order to be found by GOG Galaxy 2.0 an integration folder should be placed in
`~/Library/Application Support/GOG.com/Galaxy/plugins/installed`
### Logging
<a href='https://docs.python.org/3.7/howto/logging.html'>Root logger</a> is already setup by GOG Galaxy to store rotated log files in:
- Windows:
`%programdata%\GOG.com\Galaxy\logs`
- macOS:
`/Users/Shared/GOG.com/Galaxy/Logs`
Plugin logs are kept in `plugin-<platform>-<guid>.log`.
When debugging, inspecting the other side of communication in the `GalaxyClient.log` can be helpful as well.
### Manifest
<a name="deploy-manifest"></a>
@@ -84,8 +109,8 @@ Obligatory JSON file to be placed in an integration folder.
```json
{
"name": "Example plugin",
"platform": "generic",
"guid": "UNIQUE-GUID",
"platform": "test",
"guid": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
"version": "0.1",
"description": "Example plugin",
"author": "Name",
@@ -97,9 +122,8 @@ Obligatory JSON file to be placed in an integration folder.
| property | description |
|---------------|---|
| `guid` | |
| `description` | |
| `url` | |
| `guid` | custom Globally Unique Identifier |
| `version` | the same string as `version` in `Plugin` constructor |
| `script` | path of the entry point module, relative to the integration folder |
### Dependencies

View File

@@ -7,4 +7,4 @@ pytest-flakes==4.0.0
# because of pip bug https://github.com/pypa/pip/issues/4780
aiohttp==3.5.4
certifi==2019.3.9
psutil==5.6.3; sys_platform == 'darwin'
psutil==5.6.6; sys_platform == 'darwin'

View File

@@ -2,14 +2,15 @@ from setuptools import setup, find_packages
setup(
name="galaxy.plugin.api",
version="0.61",
version="0.66.0",
description="GOG Galaxy Integrations Python API",
author='Galaxy team',
author_email='galaxy@gog.com',
packages=find_packages("src"),
package_dir={'': 'src'},
install_requires=[
"aiohttp==3.5.4",
"certifi==2019.3.9"
"aiohttp>=3.5.4",
"certifi>=2019.3.9",
"psutil>=5.6.6; sys_platform == 'darwin'"
]
)

View File

@@ -114,6 +114,9 @@ class Feature(Enum):
ImportGameLibrarySettings = "ImportGameLibrarySettings"
ImportOSCompatibility = "ImportOSCompatibility"
ImportUserPresence = "ImportUserPresence"
ImportLocalSize = "ImportLocalSize"
ImportSubscriptions = "ImportSubscriptions"
ImportSubscriptionGames = "ImportSubscriptionGames"
class LicenseType(Enum):
@@ -149,3 +152,13 @@ class PresenceState(Enum):
Online = "online"
Offline = "offline"
Away = "away"
class SubscriptionDiscovery(Flag):
"""Possible capabilities which inform what methods of subscriptions ownership detection are supported.
:param AUTOMATIC: integration can retrieve the proper status of subscription ownership.
:param USER_ENABLED: integration can handle override of ~class::`Subscription.owned` value to True
"""
AUTOMATIC = 1
USER_ENABLED = 2

View File

@@ -20,7 +20,7 @@ class BackendError(ApplicationError):
class UnknownBackendResponse(ApplicationError):
def __init__(self, data=None):
super().__init__(4, "Backend responded in uknown way", data)
super().__init__(4, "Backend responded in unknown way", data)
class TooManyRequests(ApplicationError):
def __init__(self, data=None):

View File

@@ -0,0 +1,89 @@
import asyncio
import logging
from galaxy.api.jsonrpc import ApplicationError
from galaxy.api.errors import ImportInProgress, UnknownError
logger = logging.getLogger(__name__)
class Importer:
def __init__(
self,
task_manger,
name,
get,
prepare_context,
notification_success,
notification_failure,
notification_finished,
complete,
):
self._task_manager = task_manger
self._name = name
self._get = get
self._prepare_context = prepare_context
self._notification_success = notification_success
self._notification_failure = notification_failure
self._notification_finished = notification_finished
self._complete = complete
self._import_in_progress = False
async def _import_element(self, id_, context_):
try:
element = await self._get(id_, context_)
self._notification_success(id_, element)
except ApplicationError as error:
self._notification_failure(id_, error)
except asyncio.CancelledError:
pass
except Exception:
logger.exception("Unexpected exception raised in %s importer", self._name)
self._notification_failure(id_, UnknownError())
async def _import_elements(self, ids_, context_):
try:
imports = [self._import_element(id_, context_) for id_ in ids_]
await asyncio.gather(*imports)
self._notification_finished()
self._complete()
except asyncio.CancelledError:
logger.debug("Importing %s cancelled", self._name)
finally:
self._import_in_progress = False
async def start(self, ids):
if self._import_in_progress:
raise ImportInProgress()
self._import_in_progress = True
try:
context = await self._prepare_context(ids)
self._task_manager.create_task(
self._import_elements(ids, context),
"{} import".format(self._name),
handle_exceptions=False
)
except:
self._import_in_progress = False
raise
class CollectionImporter(Importer):
def __init__(self, notification_partially_finished, *args):
super().__init__(*args)
self._notification_partially_finished = notification_partially_finished
async def _import_element(self, id_, context_):
try:
async for element in self._get(id_, context_):
self._notification_success(id_, element)
except ApplicationError as error:
self._notification_failure(id_, error)
except asyncio.CancelledError:
pass
except Exception:
logger.exception("Unexpected exception raised in %s importer", self._name)
self._notification_failure(id_, UnknownError())
finally:
self._notification_partially_finished(id_)

View File

@@ -299,11 +299,14 @@ class Connection():
except TypeError:
raise InvalidRequest()
def _send(self, data):
def _send(self, data, sensitive=True):
try:
line = self._encoder.encode(data)
data = (line + "\n").encode("utf-8")
logger.debug("Sending %d byte of data", len(data))
if sensitive:
logger.debug("Sending %d bytes of data", len(data))
else:
logging.debug("Sending data: %s", line)
self._writer.write(data)
except TypeError as error:
logger.error(str(error))
@@ -314,7 +317,7 @@ class Connection():
"id": request_id,
"result": result
}
self._send(response)
self._send(response, sensitive=False)
def _send_error(self, request_id, error):
response = {
@@ -323,7 +326,7 @@ class Connection():
"error": error.json()
}
self._send(response)
self._send(response, sensitive=False)
def _send_request(self, request_id, method, params):
request = {
@@ -332,7 +335,7 @@ class Connection():
"id": request_id,
"params": params
}
self._send(request)
self._send(request, sensitive=True)
def _send_notification(self, method, params):
notification = {
@@ -340,7 +343,7 @@ class Connection():
"method": method,
"params": params
}
self._send(notification)
self._send(notification, sensitive=True)
@staticmethod
def _log_request(request, sensitive_params):
@@ -357,7 +360,8 @@ class Connection():
@staticmethod
def _log_error(response, error, sensitive_params):
data = anonymise_sensitive_params(error.data, sensitive_params)
params = error.data if error.data is not None else {}
data = anonymise_sensitive_params(params, sensitive_params)
logger.info("Handling error: id=%s, code=%s, description=%s, data=%s",
response.id, error.code, error.message, data
)

View File

@@ -4,15 +4,16 @@ import json
import logging
import sys
from enum import Enum
from typing import Any, Dict, List, Optional, Set, Union
from typing import Any, Dict, List, Optional, Set, Union, AsyncGenerator
from galaxy.api.consts import Feature, OSCompatibility
from galaxy.api.errors import ImportInProgress, UnknownError
from galaxy.api.jsonrpc import ApplicationError, Connection
from galaxy.api.types import (
Achievement, Authentication, Game, GameLibrarySettings, GameTime, LocalGame, NextStep, UserInfo, UserPresence
Achievement, Authentication, Game, GameLibrarySettings, GameTime, LocalGame, NextStep, UserInfo, UserPresence,
Subscription, SubscriptionGame
)
from galaxy.task_manager import TaskManager
from galaxy.api.importer import Importer, CollectionImporter
logger = logging.getLogger(__name__)
@@ -31,69 +32,6 @@ class JSONEncoder(json.JSONEncoder):
return super().default(o)
class Importer:
def __init__(
self,
task_manger,
name,
get,
prepare_context,
notification_success,
notification_failure,
notification_finished,
complete
):
self._task_manager = task_manger
self._name = name
self._get = get
self._prepare_context = prepare_context
self._notification_success = notification_success
self._notification_failure = notification_failure
self._notification_finished = notification_finished
self._complete = complete
self._import_in_progress = False
async def start(self, ids):
if self._import_in_progress:
raise ImportInProgress()
async def import_element(id_, context_):
try:
element = await self._get(id_, context_)
self._notification_success(id_, element)
except ApplicationError as error:
self._notification_failure(id_, error)
except asyncio.CancelledError:
pass
except Exception:
logger.exception("Unexpected exception raised in %s importer", self._name)
self._notification_failure(id_, UnknownError())
async def import_elements(ids_, context_):
try:
imports = [import_element(id_, context_) for id_ in ids_]
await asyncio.gather(*imports)
self._notification_finished()
self._complete()
except asyncio.CancelledError:
logger.debug("Importing %s cancelled", self._name)
finally:
self._import_in_progress = False
self._import_in_progress = True
try:
context = await self._prepare_context(ids)
self._task_manager.create_task(
import_elements(ids, context),
"{} import".format(self._name),
handle_exceptions=False
)
except:
self._import_in_progress = False
raise
class Plugin:
"""Use and override methods of this class to create a new platform integration."""
@@ -166,6 +104,27 @@ class Plugin:
self._user_presence_import_finished,
self.user_presence_import_complete
)
self._local_size_importer = Importer(
self._external_task_manager,
"local size",
self.get_local_size,
self.prepare_local_size_context,
self._local_size_import_success,
self._local_size_import_failure,
self._local_size_import_finished,
self.local_size_import_complete
)
self._subscription_games_importer = CollectionImporter(
self._subscriptions_games_partial_import_finished,
self._external_task_manager,
"subscription games",
self.get_subscription_games,
self.prepare_subscription_games_context,
self._subscription_games_import_success,
self._subscription_games_import_failure,
self._subscription_games_import_finished,
self.subscription_games_import_complete
)
# internal
self._register_method("shutdown", self._shutdown, internal=True)
@@ -233,6 +192,15 @@ class Plugin:
self._register_method("start_user_presence_import", self._start_user_presence_import)
self._detect_feature(Feature.ImportUserPresence, ["get_user_presence"])
self._register_method("start_local_size_import", self._start_local_size_import)
self._detect_feature(Feature.ImportLocalSize, ["get_local_size"])
self._register_method("import_subscriptions", self.get_subscriptions, result_name="subscriptions")
self._detect_feature(Feature.ImportSubscriptions, ["get_subscriptions"])
self._register_method("start_subscription_games_import", self._start_subscription_games_import)
self._detect_feature(Feature.ImportSubscriptionGames, ["get_subscription_games"])
async def __aenter__(self):
return self
@@ -260,7 +228,8 @@ class Plugin:
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 _register_method(self, name, handler, result_name=None, internal=False, immediate=False,
sensitive_params=False):
def wrap_result(result):
if result_name:
result = {
@@ -613,6 +582,57 @@ class Plugin:
def _user_presence_import_finished(self) -> None:
self._connection.send_notification("user_presence_import_finished", None)
def _local_size_import_success(self, game_id: str, size: Optional[int]) -> None:
self._connection.send_notification(
"local_size_import_success",
{
"game_id": game_id,
"local_size": size
}
)
def _local_size_import_failure(self, game_id: str, error: ApplicationError) -> None:
self._connection.send_notification(
"local_size_import_failure",
{
"game_id": game_id,
"error": error.json()
}
)
def _local_size_import_finished(self) -> None:
self._connection.send_notification("local_size_import_finished", None)
def _subscription_games_import_success(self, subscription_name: str,
subscription_games: Optional[List[SubscriptionGame]]) -> None:
self._connection.send_notification(
"subscription_games_import_success",
{
"subscription_name": subscription_name,
"subscription_games": subscription_games
}
)
def _subscription_games_import_failure(self, subscription_name: str, error: ApplicationError) -> None:
self._connection.send_notification(
"subscription_games_import_failure",
{
"subscription_name": subscription_name,
"error": error.json()
}
)
def _subscriptions_games_partial_import_finished(self, subscription_name: str) -> None:
self._connection.send_notification(
"subscription_games_partial_import_finished",
{
"subscription_name": subscription_name
}
)
def _subscription_games_import_finished(self) -> None:
self._connection.send_notification("subscription_games_import_finished", None)
def lost_authentication(self) -> None:
"""Notify the client that integration has lost authentication for the
current user and is unable to perform actions which would require it.
@@ -672,7 +692,7 @@ class Plugin:
This method is called by the GOG Galaxy Client.
:param stored_credentials: If the client received any credentials to store locally
in the previous session they will be passed here as a parameter.
in the previous session they will be passed here as a parameter.
Example of possible override of the method:
@@ -694,7 +714,7 @@ class Plugin:
raise NotImplementedError()
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 :class:`~galaxy.api.types.NextStep` from :meth:`.authenticate`
or :meth:`.pass_login_credentials`.
This method's parameters provide the data extracted from the web page navigation that previous NextStep finished on.
@@ -966,7 +986,7 @@ class Plugin:
await self._user_presence_importer.start(user_id_list)
async def prepare_user_presence_context(self, user_id_list: List[str]) -> Any:
"""Override this method to prepare context for get_user_presence.
"""Override this method to prepare context for :meth:`get_user_presence`.
This allows for optimizations like batch requests to platform API.
Default implementation returns None.
@@ -988,6 +1008,81 @@ class Plugin:
def user_presence_import_complete(self) -> None:
"""Override this method to handle operations after presence import is finished (like updating cache)."""
async def _start_local_size_import(self, game_ids: List[str]) -> None:
await self._local_size_importer.start(game_ids)
async def prepare_local_size_context(self, game_ids: List[str]) -> Any:
"""Override this method to prepare context for :meth:`get_local_size`
Default implementation returns None.
:param game_ids: the ids of the games for which information about size is imported
:return: context
"""
return None
async def get_local_size(self, game_id: str, context: Any) -> Optional[int]:
"""Override this method to return installed game size.
.. note::
It is preferable to avoid iterating over local game files when overriding this method.
If possible, please use a more efficient way of game size retrieval.
:param game_id: the id of the installed game
:param context: the value returned from :meth:`prepare_local_size_context`
:return: the size of the game on a user-owned storage device (in bytes) or `None` if the size cannot be determined
"""
raise NotImplementedError()
def local_size_import_complete(self) -> None:
"""Override this method to handle operations after local game size import is finished (like updating cache)."""
async def get_subscriptions(self) -> List[Subscription]:
"""Override this method to return a list of
Subscriptions available on platform.
This method is called by the GOG Galaxy Client.
"""
raise NotImplementedError()
async def _start_subscription_games_import(self, subscription_names: List[str]) -> None:
await self._subscription_games_importer.start(subscription_names)
async def prepare_subscription_games_context(self, subscription_names: List[str]) -> Any:
"""Override this method to prepare context for :meth:`get_subscription_games`
Default implementation returns None.
:param subscription_names: the names of the subscriptions' for which subscriptions games are imported
:return: context
"""
return None
async def get_subscription_games(self, subscription_name: str, context: Any) -> AsyncGenerator[
List[SubscriptionGame], None]:
"""Override this method to provide SubscriptionGames for a given subscription.
This method should `yield` a list of SubscriptionGames -> yield [sub_games]
This method will only be used if :meth:`get_subscriptions` has been implemented.
:param context: the value returned from :meth:`prepare_subscription_games_context`
:return a generator object that yields SubscriptionGames
.. code-block:: python
:linenos:
async def get_subscription_games(subscription_name: str, context: Any):
while True:
games_page = await self._get_subscriptions_from_backend(subscription_name, i)
if not games_pages:
yield None
yield [SubGame(game['game_id'], game['game_title']) for game in games_page]
"""
raise NotImplementedError()
def subscription_games_import_complete(self) -> None:
"""Override this method to handle operations after
subscription games import is finished (like updating cache).
"""
def create_and_run_plugin(plugin_class, argv):
"""Call this method as an entry point for the implemented integration.
@@ -1037,7 +1132,6 @@ def create_and_run_plugin(plugin_class, argv):
writer.close()
await writer.wait_closed()
try:
if sys.platform == "win32":
asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy())

View File

@@ -1,7 +1,7 @@
from dataclasses import dataclass
from typing import Dict, List, Optional
from galaxy.api.consts import LicenseType, LocalGameState, PresenceState
from galaxy.api.consts import LicenseType, LocalGameState, PresenceState, SubscriptionDiscovery
@dataclass
@@ -62,10 +62,10 @@ class NextStep:
return NextStep("web_session", PARAMS, cookies=COOKIES, js=JS)
:param auth_params: configuration options: {"window_title": :class:`str`, "window_width": :class:`str`,
"window_height": :class:`int`, "start_uri": :class:`int`, "end_uri_regex": :class:`str`}
"window_height": :class:`int`, "start_uri": :class:`int`, "end_uri_regex": :class:`str`}
:param cookies: browser initial set of cookies
:param js: a map of the url regex patterns into the list of *js* scripts that should be executed
on every document at given step of internal browser authentication.
on every document at given step of internal browser authentication.
"""
next_step: str
auth_params: Dict[str, str]
@@ -216,3 +216,42 @@ class UserPresence:
game_title: Optional[str] = None
in_game_status: Optional[str] = None
full_status: Optional[str] = None
@dataclass
class Subscription:
"""Information about a subscription.
:param subscription_name: name of the subscription, will also be used as its identifier.
:param owned: whether the subscription is owned or not, None if unknown.
:param end_time: unix timestamp of when the subscription ends, None if unknown.
:param subscription_discovery: combination of settings that can be manually
chosen by user to determine subscription handling behaviour. For example, if the integration cannot retrieve games
for subscription when user doesn't own it, then USER_ENABLED should not be used.
If the integration cannot determine subscription ownership for a user then AUTOMATIC should not be used.
"""
subscription_name: str
owned: Optional[bool] = None
end_time: Optional[int] = None
subscription_discovery: SubscriptionDiscovery = SubscriptionDiscovery.AUTOMATIC | \
SubscriptionDiscovery.USER_ENABLED
def __post_init__(self):
assert self.subscription_discovery in [SubscriptionDiscovery.AUTOMATIC, SubscriptionDiscovery.USER_ENABLED,
SubscriptionDiscovery.AUTOMATIC | SubscriptionDiscovery.USER_ENABLED]
@dataclass
class SubscriptionGame:
"""Information about a game from a subscription.
:param game_title: title of the game
:param game_id: id of the game
:param start_time: unix timestamp of when the game has been added to subscription
:param end_time: unix timestamp of when the game will be removed from subscription.
"""
game_title: str
game_id: str
start_time: Optional[int] = None
end_time: Optional[int] = None

View File

@@ -1,12 +1,11 @@
"""
This module standarize http traffic and the error handling for further communication with the GOG Galaxy 2.0.
This module standardizes 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:
Exemplary simple web service could looks like:
.. code-block:: python
import logging
from galaxy.http import create_client_session, handle_exception
class BackendClient:
@@ -72,7 +71,7 @@ class HttpClient:
def create_tcp_connector(*args, **kwargs) -> aiohttp.TCPConnector:
"""
Creates TCP connector with resonable defaults.
Creates TCP connector with reasonable defaults.
For details about available parameters refer to
`aiohttp.TCPConnector <https://docs.aiohttp.org/en/stable/client_reference.html#tcpconnector>`_
"""
@@ -86,11 +85,11 @@ def create_tcp_connector(*args, **kwargs) -> aiohttp.TCPConnector:
def create_client_session(*args, **kwargs) -> aiohttp.ClientSession:
"""
Creates client session with resonable defaults.
Creates client session with reasonable defaults.
For details about available parameters refer to
`aiohttp.ClientSession <https://docs.aiohttp.org/en/stable/client_reference.html>`_
Examplary customization:
Exemplary customization:
.. code-block:: python
@@ -124,25 +123,25 @@ def handle_exception():
raise BackendNotAvailable()
except aiohttp.ClientConnectionError:
raise NetworkError()
except aiohttp.ContentTypeError:
raise UnknownBackendResponse()
except aiohttp.ContentTypeError as error:
raise UnknownBackendResponse(error.message)
except aiohttp.ClientResponseError as error:
if error.status == HTTPStatus.UNAUTHORIZED:
raise AuthenticationRequired()
raise AuthenticationRequired(error.message)
if error.status == HTTPStatus.FORBIDDEN:
raise AccessDenied()
raise AccessDenied(error.message)
if error.status == HTTPStatus.SERVICE_UNAVAILABLE:
raise BackendNotAvailable()
raise BackendNotAvailable(error.message)
if error.status == HTTPStatus.TOO_MANY_REQUESTS:
raise TooManyRequests()
raise TooManyRequests(error.message)
if error.status >= 500:
raise BackendError()
raise BackendError(error.message)
if error.status >= 400:
logger.warning(
"Got status %d while performing %s request for %s",
error.status, error.request_info.method, str(error.request_info.url)
)
raise UnknownError()
except aiohttp.ClientError:
raise UnknownError(error.message)
except aiohttp.ClientError as e:
logger.exception("Caught exception while performing request")
raise UnknownError()
raise UnknownError(repr(e))

View File

@@ -64,6 +64,13 @@ async def plugin(reader, writer):
"get_user_presence",
"prepare_user_presence_context",
"user_presence_import_complete",
"get_local_size",
"prepare_local_size_context",
"local_size_import_complete",
"get_subscriptions",
"get_subscription_games",
"prepare_subscription_games_context",
"subscription_games_import_complete"
)
with ExitStack() as stack:

View File

@@ -17,7 +17,10 @@ def test_base_class():
Feature.LaunchPlatformClient,
Feature.ImportGameLibrarySettings,
Feature.ImportOSCompatibility,
Feature.ImportUserPresence
Feature.ImportUserPresence,
Feature.ImportLocalSize,
Feature.ImportSubscriptions,
Feature.ImportSubscriptionGames
}

188
tests/test_local_size.py Normal file
View File

@@ -0,0 +1,188 @@
from unittest.mock import call
import pytest
from galaxy.api.errors import FailedParsingManifest
from galaxy.unittest.mock import async_return_value
from tests import create_message, get_messages
@pytest.mark.asyncio
async def test_get_local_size_success(plugin, read, write):
context = {'abc': 'def'}
plugin.prepare_local_size_context.return_value = async_return_value(context)
request = {
"jsonrpc": "2.0",
"id": "11",
"method": "start_local_size_import",
"params": {"game_ids": ["777", "13", "42"]}
}
read.side_effect = [async_return_value(create_message(request)), async_return_value(b"", 10)]
plugin.get_local_size.side_effect = [
async_return_value(100000000000),
async_return_value(None),
async_return_value(3333333)
]
await plugin.run()
plugin.get_local_size.assert_has_calls([
call("777", context),
call("13", context),
call("42", context)
])
plugin.local_size_import_complete.assert_called_once_with()
assert get_messages(write) == [
{
"jsonrpc": "2.0",
"id": "11",
"result": None
},
{
"jsonrpc": "2.0",
"method": "local_size_import_success",
"params": {
"game_id": "777",
"local_size": 100000000000
}
},
{
"jsonrpc": "2.0",
"method": "local_size_import_success",
"params": {
"game_id": "13",
"local_size": None
}
},
{
"jsonrpc": "2.0",
"method": "local_size_import_success",
"params": {
"game_id": "42",
"local_size": 3333333
}
},
{
"jsonrpc": "2.0",
"method": "local_size_import_finished",
"params": None
}
]
@pytest.mark.asyncio
@pytest.mark.parametrize("exception,code,message", [
(FailedParsingManifest, 200, "Failed parsing manifest"),
(KeyError, 0, "Unknown error")
])
async def test_get_local_size_error(exception, code, message, plugin, read, write):
game_id = "6"
request_id = "55"
plugin.prepare_local_size_context.return_value = async_return_value(None)
request = {
"jsonrpc": "2.0",
"id": request_id,
"method": "start_local_size_import",
"params": {"game_ids": [game_id]}
}
read.side_effect = [async_return_value(create_message(request)), async_return_value(b"", 10)]
plugin.get_local_size.side_effect = exception
await plugin.run()
plugin.get_local_size.assert_called()
plugin.local_size_import_complete.assert_called_once_with()
assert get_messages(write) == [
{
"jsonrpc": "2.0",
"id": request_id,
"result": None
},
{
"jsonrpc": "2.0",
"method": "local_size_import_failure",
"params": {
"game_id": game_id,
"error": {
"code": code,
"message": message
}
}
},
{
"jsonrpc": "2.0",
"method": "local_size_import_finished",
"params": None
}
]
@pytest.mark.asyncio
async def test_prepare_get_local_size_context_error(plugin, read, write):
request_id = "31415"
error_details = "Unexpected syntax"
error_message, error_code = FailedParsingManifest().message, FailedParsingManifest().code
plugin.prepare_local_size_context.side_effect = FailedParsingManifest(error_details)
request = {
"jsonrpc": "2.0",
"id": request_id,
"method": "start_local_size_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": error_code,
"message": error_message,
"data": error_details
}
}
]
@pytest.mark.asyncio
async def test_import_already_in_progress_error(plugin, read, write):
plugin.prepare_local_size_context.return_value = async_return_value(None)
requests = [
{
"jsonrpc": "2.0",
"id": "3",
"method": "start_local_size_import",
"params": {
"game_ids": ["42"]
}
},
{
"jsonrpc": "2.0",
"id": "4",
"method": "start_local_size_import",
"params": {
"game_ids": ["13"]
}
}
]
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

@@ -4,7 +4,7 @@ import asyncio
from galaxy.unittest.mock import async_return_value
from tests import create_message, get_messages
from galaxy.api.errors import (
BackendNotAvailable, BackendTimeout, BackendError, InvalidCredentials, NetworkError, AccessDenied
BackendNotAvailable, BackendTimeout, BackendError, InvalidCredentials, NetworkError, AccessDenied, UnknownError
)
from galaxy.api.jsonrpc import JsonRpcError
@pytest.mark.asyncio
@@ -40,7 +40,7 @@ async def test_refresh_credentials_success(plugin, read, write):
@pytest.mark.asyncio
@pytest.mark.parametrize("exception", [
BackendNotAvailable, BackendTimeout, BackendError, InvalidCredentials, NetworkError, AccessDenied
BackendNotAvailable, BackendTimeout, BackendError, InvalidCredentials, NetworkError, AccessDenied, UnknownError
])
async def test_refresh_credentials_failure(exception, plugin, read, write):

340
tests/test_subscriptions.py Normal file
View File

@@ -0,0 +1,340 @@
import pytest
from galaxy.api.types import Subscription, SubscriptionGame
from galaxy.api.consts import SubscriptionDiscovery
from galaxy.api.errors import FailedParsingManifest, BackendError, UnknownError
from galaxy.unittest.mock import async_return_value
from tests import create_message, get_messages
@pytest.mark.asyncio
async def test_get_subscriptions_success(plugin, read, write):
request = {
"jsonrpc": "2.0",
"id": "3",
"method": "import_subscriptions"
}
read.side_effect = [async_return_value(create_message(request)), async_return_value(b"", 10)]
plugin.get_subscriptions.return_value = async_return_value([
Subscription("1"),
Subscription("2", False, subscription_discovery=SubscriptionDiscovery.AUTOMATIC),
Subscription("3", True, 1580899100, SubscriptionDiscovery.USER_ENABLED)
])
await plugin.run()
plugin.get_subscriptions.assert_called_with()
assert get_messages(write) == [
{
"jsonrpc": "2.0",
"id": "3",
"result": {
"subscriptions": [
{
"subscription_name": "1",
'subscription_discovery': 3
},
{
"subscription_name": "2",
"owned": False,
'subscription_discovery': 1
},
{
"subscription_name": "3",
"owned": True,
"end_time": 1580899100,
'subscription_discovery': 2
}
]
}
}
]
@pytest.mark.asyncio
@pytest.mark.parametrize(
"error,code,message",
[
pytest.param(UnknownError, 0, "Unknown error", id="unknown_error"),
pytest.param(FailedParsingManifest, 200, "Failed parsing manifest", id="failed_parsing")
],
)
async def test_get_subscriptions_failure_generic(plugin, read, write, error, code, message):
request = {
"jsonrpc": "2.0",
"id": "3",
"method": "import_subscriptions"
}
read.side_effect = [async_return_value(create_message(request)), async_return_value(b"", 10)]
plugin.get_subscriptions.side_effect = error()
await plugin.run()
plugin.get_subscriptions.assert_called_with()
assert get_messages(write) == [
{
"jsonrpc": "2.0",
"id": "3",
"error": {
"code": code,
"message": message
}
}
]
@pytest.mark.asyncio
async def test_get_subscription_games_success(plugin, read, write):
plugin.prepare_subscription_games_context.return_value = async_return_value(5)
request = {
"jsonrpc": "2.0",
"id": "3",
"method": "start_subscription_games_import",
"params": {
"subscription_names": ["sub_a"]
}
}
read.side_effect = [async_return_value(create_message(request)), async_return_value(b"", 10)]
async def sub_games():
games = [
SubscriptionGame(game_title="game A", game_id="game_A"),
SubscriptionGame(game_title="game B", game_id="game_B", start_time=1548495632),
SubscriptionGame(game_title="game C", game_id="game_C", end_time=1548495633),
SubscriptionGame(game_title="game D", game_id="game_D", start_time=1548495632, end_time=1548495633),
]
yield [game for game in games]
plugin.get_subscription_games.return_value = sub_games()
await plugin.run()
plugin.prepare_subscription_games_context.assert_called_with(["sub_a"])
plugin.get_subscription_games.assert_called_with("sub_a", 5)
plugin.subscription_games_import_complete.asert_called_with()
assert get_messages(write) == [
{
"jsonrpc": "2.0",
"id": "3",
"result": None
},
{
"jsonrpc": "2.0",
"method": "subscription_games_import_success",
"params": {
"subscription_name": "sub_a",
"subscription_games": [
{
"game_title": "game A",
"game_id": "game_A"
},
{
"game_title": "game B",
"game_id": "game_B",
"start_time": 1548495632
},
{
"game_title": "game C",
"game_id": "game_C",
"end_time": 1548495633
},
{
"game_title": "game D",
"game_id": "game_D",
"start_time": 1548495632,
"end_time": 1548495633
}
]
}
},
{
'jsonrpc': '2.0',
'method':
'subscription_games_partial_import_finished',
'params': {
"subscription_name": "sub_a"
}
},
{
"jsonrpc": "2.0",
"method": "subscription_games_import_finished",
"params": None
}
]
@pytest.mark.asyncio
async def test_get_subscription_games_success_empty(plugin, read, write):
plugin.prepare_subscription_games_context.return_value = async_return_value(5)
request = {
"jsonrpc": "2.0",
"id": "3",
"method": "start_subscription_games_import",
"params": {
"subscription_names": ["sub_a"]
}
}
read.side_effect = [async_return_value(create_message(request)), async_return_value(b"", 10)]
async def sub_games():
yield None
plugin.get_subscription_games.return_value = sub_games()
await plugin.run()
plugin.prepare_subscription_games_context.assert_called_with(["sub_a"])
plugin.get_subscription_games.assert_called_with("sub_a", 5)
plugin.subscription_games_import_complete.asert_called_with()
assert get_messages(write) == [
{
"jsonrpc": "2.0",
"id": "3",
"result": None
},
{
"jsonrpc": "2.0",
"method": "subscription_games_import_success",
"params": {
"subscription_name": "sub_a",
"subscription_games": None
}
},
{
'jsonrpc': '2.0',
'method':
'subscription_games_partial_import_finished',
'params': {
"subscription_name": "sub_a"
}
},
{
"jsonrpc": "2.0",
"method": "subscription_games_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_subscription_games_error(exception, code, message, plugin, read, write):
plugin.prepare_subscription_games_context.return_value = async_return_value(None)
request = {
"jsonrpc": "2.0",
"id": "3",
"method": "start_subscription_games_import",
"params": {
"subscription_names": ["sub_a"]
}
}
read.side_effect = [async_return_value(create_message(request)), async_return_value(b"", 10)]
plugin.get_subscription_games.side_effect = exception
await plugin.run()
plugin.get_subscription_games.assert_called()
plugin.subscription_games_import_complete.asert_called_with()
assert get_messages(write) == [
{
"jsonrpc": "2.0",
"id": "3",
"result": None
},
{
"jsonrpc": "2.0",
"method": "subscription_games_import_failure",
"params": {
"subscription_name": "sub_a",
"error": {
"code": code,
"message": message
}
}
},
{
'jsonrpc': '2.0',
'method':
'subscription_games_partial_import_finished',
'params': {
"subscription_name": "sub_a"
}
},
{
"jsonrpc": "2.0",
"method": "subscription_games_import_finished",
"params": None
}
]
@pytest.mark.asyncio
async def test_prepare_get_subscription_games_context_error(plugin, read, write):
request_id = "31415"
error_details = "Unexpected backend error"
error_message, error_code = BackendError().message, BackendError().code
plugin.prepare_subscription_games_context.side_effect = BackendError(error_details)
request = {
"jsonrpc": "2.0",
"id": request_id,
"method": "start_subscription_games_import",
"params": {"subscription_names": ["sub_a", "sub_b"]}
}
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": error_code,
"message": error_message,
"data": error_details
}
}
]
@pytest.mark.asyncio
async def test_import_already_in_progress_error(plugin, read, write):
plugin.prepare_subscription_games_context.return_value = async_return_value(None)
requests = [
{
"jsonrpc": "2.0",
"id": "3",
"method": "start_subscription_games_import",
"params": {
"subscription_names": ["sub_a"]
}
},
{
"jsonrpc": "2.0",
"id": "4",
"method": "start_subscription_games_import",
"params": {
"subscription_names": ["sub_a","sub_b"]
}
}
]
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