Compare commits

..

90 Commits
0.53 ... test

Author SHA1 Message Date
Mieszko Bańczerowski
ae0498caf7 Check if documentation fix works on RTD 2021-11-05 16:47:26 +01:00
Mieszko Bańczerowski
f4ded58c28 Increment version 2021-11-03 16:38:26 +01:00
Mieszko Banczerowski
d2f34349b8 Fix documentation generation by updating sphinx dependencies 2021-11-02 15:55:16 +00:00
Mieszko Bańczerowski
f062387ddb change error code fro UnknownBackendResponse 2021-11-02 16:00:51 +01:00
Pawel Sikorski
5a699100d6 Merge remote-tracking branch 'github/master' 2021-11-02 14:34:08 +01:00
Martin Grzymski
5daa386f6e PLINT-1088 Including internal_type in errors' data member 2021-10-20 07:52:40 +00:00
Albert Suralinski
761598de54 PLINT-1175 Allow specifying error message 2021-10-14 09:25:20 +00:00
Mikhail Paulyshka
ab44e137c3 Reduce error spam on plugin termination (#188) 2021-09-29 09:21:27 +02:00
Mieszko Bańczerowski
948bfcd971 Use latest 3.7 Python version 2021-09-28 17:29:54 +02:00
Mieszko Banczerowski
6196e751c6 Replace travis with Github Actions 2021-09-28 15:22:55 +00:00
Mieszko Bańczerowski
a5b2a0890e Make Travis CI use requirements-dev.txt for tests 2021-09-21 14:40:36 +02:00
Miron Moderau
46cda7d61a Make GitLab CI use requirements-dev.txt for tests 2021-09-08 16:27:20 +02:00
Miron Moderau
f0f6210c3e Split requirements-dev.txt from requirements.txt
All pytest and types requirements are moved.
Also, the pip bug comment is made more explanatory.
2021-09-08 15:33:18 +02:00
Miron Moderau
468dfcc60d Add types-certifi to Requirements.txt
Required after mypy update.
2021-09-08 12:17:14 +02:00
Miron Moderau
8f91f705ee Remove LoggedInElsewhere error type
This error is obsolete. It was never used by most plugins and Galaxy
does not handle it in any specific way.
2021-09-08 11:33:16 +02:00
Mieszko Banczerowski
46588c321e PLINT-711 Enhancement for logging plugin responses 2021-05-04 13:42:03 +02:00
Mieszko Bańczerowski
947c578121 Increment version 2021-04-20 13:26:30 +02:00
Mieszko Banczerowski
aba9b0ed6b PLINT-575 set galaxy package logging level to INFO 2021-04-20 11:45:48 +02:00
Albert Suralinski
f0d65a72ff PLINT-139 added default values for optional UserInfo dataclass parameters 2020-12-03 10:02:25 +01:00
Mieszko Bańczerowski
96cb48fcaf Increment version 2020-09-18 11:01:55 +02:00
Mieszko Banczerowski
17b0542fdf GPI-1232 Synchronous importer for local sizes 2020-09-16 13:33:17 +02:00
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
Rafal Makagon
4cc0055119 Increment version 2019-12-05 13:58:04 +01:00
Romuald Bierbasz
00164fab67 Correctly set _import_in_progress 2019-12-05 11:39:09 +01:00
Romuald Juchnowicz-Bierbasz
453cd1cc70 Do not send notificaitons when import is cancelled 2019-12-03 14:06:55 +01:00
Romuald Juchnowicz-Bierbasz
1f55253fd7 Wait until writer is closed 2019-12-03 14:04:19 +01:00
Romuald Juchnowicz-Bierbasz
7aa3b01abd Add Importer class (reuse code for importers) 2019-12-03 14:03:53 +01:00
Rafal Makagon
bd14d58bad Increment version 2019-11-28 14:37:46 +01:00
Romuald Juchnowicz-Bierbasz
274b9a2c18 Do not wait for drain 2019-11-28 13:10:58 +01:00
Rafal Makagon
75e5a66fbe Increment version 2019-11-27 13:14:11 +01:00
Mieszko Banczerowski
2a9ec3067d Fix sending Exceptions with custom data 2019-11-27 13:12:20 +01:00
Rafal Makagon
69532a5ba9 fix richpresence parameter name 2019-11-27 13:10:43 +01:00
Romuald Juchnowicz-Bierbasz
f5d47b0167 Add timeout to shutdown 2019-11-22 13:11:08 +01:00
Romuald Juchnowicz-Bierbasz
02f4faa432 Do not use root logger 2019-11-22 13:07:33 +01:00
Romuald Juchnowicz-Bierbasz
3d3922c965 Add async_raise 2019-11-20 17:57:17 +01:00
Rafal Makagon
b695cdfc78 Increment version 2019-11-20 16:23:23 +01:00
Rafal Makagon
66ab1809b8 Do not log data sent to socket 2019-11-20 15:48:00 +01:00
Rafal Makagon
8bf367d0f9 Increment vesion 2019-11-18 13:59:09 +01:00
Rafal Makagon
2cf83395fa fix parse sphinx parse error
+ other small imporvements in docs
2019-11-15 16:06:45 +01:00
Aliaksei Paulouski
4aa76b6e3d SDK-3137: friends and presence updates 2019-11-13 13:40:53 +01:00
Aleksej Pawlowskij
c03465e8f2 SDK-3145: Add optional profile and avatar url 2019-11-13 08:49:38 +01:00
mezzode
810a87718d Fix incorrect field name in GameTime docstring 2019-11-08 11:25:58 +01:00
Rafal Makagon
e32abe11b7 Increment version 2019-11-07 12:29:13 +01:00
FriendsOfGalaxy
d79f183826 Fix RegistryMonitor.is_updated method 2019-11-04 14:26:26 +01:00
Rafal Makagon
78f1d5a4cc Add refresh_credentials method to plugin 2019-10-31 15:15:14 +01:00
Rafal Makagon
9041dbd98c Increase size that is to be read at once from reader's buffer 2019-10-30 15:42:03 +01:00
Aleksej Pawlowskij
e57ecc489c SDK-3110: Deprecate FriendInfo and replace with UserInfo 2019-10-28 11:37:21 +01:00
Romuald Bierbasz
0a20629459 Use pytest 5.2.2 2019-10-28 11:35:59 +01:00
Romuald Bierbasz
1585bab203 Wait for drain before writing 2019-10-22 11:30:01 +02:00
Aleksej Pawlowskij
92caf682d8 Increment version 2019-10-21 16:13:09 +02:00
Aleksej Pawlowskij
062d6a9428 Add user presence import feature 2019-10-21 16:11:10 +02:00
Romuald Juchnowicz-Bierbasz
c874bc1d6e Increment version 2019-10-21 14:54:23 +02:00
Romuald Bierbasz
2dc56571d6 Revert "Add more logs"
This reverts commit 21ab8bf33d3c2714b8c7af2da8036fe8baae20ea.
2019-10-21 14:50:33 +02:00
Romuald Bierbasz
eb216a50a8 Fix mypy ignores 2019-10-21 14:20:21 +02:00
42 changed files with 2164 additions and 458 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. -->

20
.github/workflows/ci.yml vendored Normal file
View File

@@ -0,0 +1,20 @@
name: CI
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-20.04
steps:
- uses: actions/checkout@v2
- name: Set up Python
uses: actions/setup-python@v2
with:
python-version: 3.7
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements-dev.txt
- name: Run tests
run: |
pytest

2
.gitignore vendored
View File

@@ -7,3 +7,5 @@ docs/build/
Pipfile Pipfile
.idea .idea
docs/source/_build 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: stages:
- test - test
@@ -7,7 +7,7 @@ stages:
test_package: test_package:
stage: test stage: test
script: script:
- pip install -r requirements.txt - pip install -r requirements-dev.txt
- pytest - pytest
except: except:
- tags - tags

View File

@@ -1,8 +0,0 @@
dist: xenial # required for Python >= 3.7
language: python
python:
- "3.7"
install:
- pip install -r requirements.txt
script:
- pytest

View File

@@ -4,10 +4,10 @@ Platform ID list for GOG Galaxy 2.0 Integrations
| ID | Name | | ID | Name |
| --- | --- | | --- | --- |
| test | Testing purposes |
| steam | Steam | | steam | Steam |
| psn | PlayStation Network | | psn | PlayStation Network |
| xboxone | Xbox Live | | xboxone | Xbox Live |
| generic | Manually added games |
| origin | Origin | | origin | Origin |
| uplay | Uplay | | uplay | Uplay |
| battlenet | Battle.net | | battlenet | Battle.net |
@@ -80,3 +80,12 @@ Platform ID list for GOG Galaxy 2.0 Integrations
| nds | Nintendo DS | | nds | Nintendo DS |
| 3ds | Nintendo 3DS | | 3ds | Nintendo 3DS |
| pathofexile | Path of Exile | | 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 import sys
from galaxy.api.plugin import Plugin, create_and_run_plugin from galaxy.api.plugin import Plugin, create_and_run_plugin
from galaxy.api.consts import Platform from galaxy.api.consts import Platform
from galaxy.api.types import Authentication, Game, LicenseInfo, LicenseType
class PluginExample(Plugin): class PluginExample(Plugin):
def __init__(self, reader, writer, token): def __init__(self, reader, writer, token):
super().__init__( super().__init__(
Platform.Generic, # Choose platform from available list Platform.Test, # choose platform from available list
"0.1", # Version "0.1", # version
reader, reader,
writer, writer,
token token
) )
# implement methods # implement methods
# required
async def authenticate(self, stored_credentials=None): 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(): def main():
create_and_run_plugin(PluginExample, sys.argv) 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` `~/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 ### Manifest
<a name="deploy-manifest"></a> <a name="deploy-manifest"></a>
@@ -84,8 +109,8 @@ Obligatory JSON file to be placed in an integration folder.
```json ```json
{ {
"name": "Example plugin", "name": "Example plugin",
"platform": "generic", "platform": "test",
"guid": "UNIQUE-GUID", "guid": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
"version": "0.1", "version": "0.1",
"description": "Example plugin", "description": "Example plugin",
"author": "Name", "author": "Name",
@@ -97,9 +122,8 @@ Obligatory JSON file to be placed in an integration folder.
| property | description | | property | description |
|---------------|---| |---------------|---|
| `guid` | | | `guid` | custom Globally Unique Identifier |
| `description` | | | `version` | the same string as `version` in `Plugin` constructor |
| `url` | |
| `script` | path of the entry point module, relative to the integration folder | | `script` | path of the entry point module, relative to the integration folder |
### Dependencies ### Dependencies

View File

@@ -1,5 +1,6 @@
Sphinx==2.0.1 Sphinx==4.2.0
sphinx-rtd-theme==0.4.3 sphinx-rtd-theme==1.0.0
sphinx-autodoc-typehints==1.6.0 sphinx-autodoc-typehints==1.12.0
sphinxcontrib-asyncio==0.2.0 sphinxcontrib-asyncio==0.3.0
m2r==0.2.1 m2r2==0.3.1
typing-extensions==3.10.0.2

View File

@@ -34,7 +34,7 @@ extensions = [
'sphinx.ext.autodoc', 'sphinx.ext.autodoc',
'sphinxcontrib.asyncio', 'sphinxcontrib.asyncio',
'sphinx_autodoc_typehints', 'sphinx_autodoc_typehints',
'm2r' # mdinclude directive for makrdown files 'm2r2' # mdinclude directive for makrdown files
] ]
autodoc_member_order = 'bysource' autodoc_member_order = 'bysource'
autodoc_inherit_docstrings = False autodoc_inherit_docstrings = False

7
requirements-dev.txt Normal file
View File

@@ -0,0 +1,7 @@
-r requirements.txt
pytest==5.2.2
pytest-asyncio==0.10.0
pytest-mock==1.10.3
pytest-mypy==0.4.1
pytest-flakes==4.0.0
types-certifi==2020.4.0

View File

@@ -1,10 +1,7 @@
-e . -e .
pytest==4.2.0 # Copied from setup.py because of a pip bug
pytest-asyncio==0.10.0 # see https://github.com/pypa/pip/issues/4780
pytest-mock==1.10.3
pytest-mypy==0.4.1
pytest-flakes==4.0.0
# because of pip bug https://github.com/pypa/pip/issues/4780
aiohttp==3.5.4 aiohttp==3.5.4
certifi==2019.3.9 certifi==2019.3.9
psutil==5.6.3; sys_platform == 'darwin' psutil==5.6.6; sys_platform == 'darwin'
# End of copy from setup.py

View File

@@ -2,14 +2,15 @@ from setuptools import setup, find_packages
setup( setup(
name="galaxy.plugin.api", name="galaxy.plugin.api",
version="0.53", version="0.69",
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',
packages=find_packages("src"), packages=find_packages("src"),
package_dir={'': 'src'}, package_dir={'': 'src'},
install_requires=[ install_requires=[
"aiohttp==3.5.4", "aiohttp>=3.5.4",
"certifi==2019.3.9" "certifi>=2019.3.9",
"psutil>=5.6.6; sys_platform == 'darwin'"
] ]
) )

View File

@@ -1 +1,6 @@
__path__: str = __import__('pkgutil').extend_path(__path__, __name__) # type: ignore import logging
logging.getLogger(__name__).setLevel(logging.INFO)
__path__: str = __import__('pkgutil').extend_path(__path__, __name__) # type: ignore

View File

@@ -113,6 +113,10 @@ class Feature(Enum):
LaunchPlatformClient = "LaunchPlatformClient" LaunchPlatformClient = "LaunchPlatformClient"
ImportGameLibrarySettings = "ImportGameLibrarySettings" ImportGameLibrarySettings = "ImportGameLibrarySettings"
ImportOSCompatibility = "ImportOSCompatibility" ImportOSCompatibility = "ImportOSCompatibility"
ImportUserPresence = "ImportUserPresence"
ImportLocalSize = "ImportLocalSize"
ImportSubscriptions = "ImportSubscriptions"
ImportSubscriptionGames = "ImportSubscriptionGames"
class LicenseType(Enum): class LicenseType(Enum):
@@ -140,3 +144,21 @@ class OSCompatibility(Flag):
Windows = 0b001 Windows = 0b001
MacOS = 0b010 MacOS = 0b010
Linux = 0b100 Linux = 0b100
class PresenceState(Enum):
""""Possible states of a user."""
Unknown = "unknown"
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

@@ -2,74 +2,87 @@ from galaxy.api.jsonrpc import ApplicationError, UnknownError
assert UnknownError assert UnknownError
class AuthenticationRequired(ApplicationError): class AuthenticationRequired(ApplicationError):
def __init__(self, data=None): def __init__(self, message="Authentication required", data=None):
super().__init__(1, "Authentication required", data) super().__init__(1, message, data)
class BackendNotAvailable(ApplicationError): class BackendNotAvailable(ApplicationError):
def __init__(self, data=None): def __init__(self, message="Backend not available", data=None):
super().__init__(2, "Backend not available", data) super().__init__(2, message, data)
class BackendTimeout(ApplicationError): class BackendTimeout(ApplicationError):
def __init__(self, data=None): def __init__(self, message="Backend timed out", data=None):
super().__init__(3, "Backend timed out", data) super().__init__(3, message, data)
class BackendError(ApplicationError): class BackendError(ApplicationError):
def __init__(self, data=None): def __init__(self, message="Backend error", data=None):
super().__init__(4, "Backend error", data) super().__init__(4, message, data)
class UnknownBackendResponse(ApplicationError):
def __init__(self, data=None):
super().__init__(4, "Backend responded in uknown way", data)
class TooManyRequests(ApplicationError): class TooManyRequests(ApplicationError):
def __init__(self, data=None): def __init__(self, message="Too many requests. Try again later", data=None):
super().__init__(5, "Too many requests. Try again later", data) super().__init__(5, message, data)
class UnknownBackendResponse(ApplicationError):
def __init__(self, message="Backend responded in unknown way", data=None):
super().__init__(6, message, data)
class InvalidCredentials(ApplicationError): class InvalidCredentials(ApplicationError):
def __init__(self, data=None): def __init__(self, message="Invalid credentials", data=None):
super().__init__(100, "Invalid credentials", data) super().__init__(100, message, data)
class NetworkError(ApplicationError): class NetworkError(ApplicationError):
def __init__(self, data=None): def __init__(self, message="Network error", data=None):
super().__init__(101, "Network error", data) super().__init__(101, message, data)
class LoggedInElsewhere(ApplicationError):
def __init__(self, data=None):
super().__init__(102, "Logged in elsewhere", data)
class ProtocolError(ApplicationError): class ProtocolError(ApplicationError):
def __init__(self, data=None): def __init__(self, message="Protocol error", data=None):
super().__init__(103, "Protocol error", data) super().__init__(103, message, data)
class TemporaryBlocked(ApplicationError): class TemporaryBlocked(ApplicationError):
def __init__(self, data=None): def __init__(self, message="Temporary blocked", data=None):
super().__init__(104, "Temporary blocked", data) super().__init__(104, message, data)
class Banned(ApplicationError): class Banned(ApplicationError):
def __init__(self, data=None): def __init__(self, message="Banned", data=None):
super().__init__(105, "Banned", data) super().__init__(105, message, data)
class AccessDenied(ApplicationError): class AccessDenied(ApplicationError):
def __init__(self, data=None): def __init__(self, message="Access denied", data=None):
super().__init__(106, "Access denied", data) super().__init__(106, message, data)
class FailedParsingManifest(ApplicationError): class FailedParsingManifest(ApplicationError):
def __init__(self, data=None): def __init__(self, message="Failed parsing manifest", data=None):
super().__init__(200, "Failed parsing manifest", data) super().__init__(200, message, data)
class TooManyMessagesSent(ApplicationError): class TooManyMessagesSent(ApplicationError):
def __init__(self, data=None): def __init__(self, message="Too many messages sent", data=None):
super().__init__(300, "Too many messages sent", data) super().__init__(300, message, data)
class IncoherentLastMessage(ApplicationError): class IncoherentLastMessage(ApplicationError):
def __init__(self, data=None): def __init__(self, message="Different last message id on backend", data=None):
super().__init__(400, "Different last message id on backend", data) super().__init__(400, message, data)
class MessageNotFound(ApplicationError): class MessageNotFound(ApplicationError):
def __init__(self, data=None): def __init__(self, message="Message not found", data=None):
super().__init__(500, "Message not found", data) super().__init__(500, message, data)
class ImportInProgress(ApplicationError): class ImportInProgress(ApplicationError):
def __init__(self, data=None): def __init__(self, message="Import already in progress", data=None):
super().__init__(600, "Import already in progress", data) super().__init__(600, message, data)

102
src/galaxy/api/importer.py Normal file
View File

@@ -0,0 +1,102 @@
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_)
class SynchroneousImporter(Importer):
async def _import_elements(self, ids_, context_):
try:
for id_ in ids_:
await self._import_element(id_, context_)
self._notification_finished()
self._complete()
except asyncio.CancelledError:
logger.debug("Importing %s cancelled", self._name)
finally:
self._import_in_progress = False

View File

@@ -1,6 +1,6 @@
import asyncio import asyncio
from collections import namedtuple from collections import namedtuple
from collections.abc import Iterable from collections.abc import Iterable, Mapping
import logging import logging
import inspect import inspect
import json import json
@@ -8,11 +8,20 @@ import json
from galaxy.reader import StreamLineReader from galaxy.reader import StreamLineReader
from galaxy.task_manager import TaskManager from galaxy.task_manager import TaskManager
logger = logging.getLogger(__name__)
class JsonRpcError(Exception): class JsonRpcError(Exception):
def __init__(self, code, message, data=None): def __init__(self, code, message, data=None):
self.code = code self.code = code
self.message = message self.message = str(message)
self.data = data self.data = {}
if data is not None:
if not isinstance(data, Mapping):
raise TypeError(f"Data parameter should be a mapping, got this instead: {data}")
self.data = data
self.data.update({"internal_type": type(self).__name__})
super().__init__() super().__init__()
def __eq__(self, other): def __eq__(self, other):
@@ -21,37 +30,42 @@ class JsonRpcError(Exception):
def json(self): def json(self):
obj = { obj = {
"code": self.code, "code": self.code,
"message": self.message "message": self.message,
"data": self.data
} }
if self.data is not None:
obj["error"]["data"] = self.data
return obj return obj
class ParseError(JsonRpcError): class ParseError(JsonRpcError):
def __init__(self): def __init__(self, message="Parse error", data=None):
super().__init__(-32700, "Parse error") super().__init__(-32700, message, data)
class InvalidRequest(JsonRpcError): class InvalidRequest(JsonRpcError):
def __init__(self): def __init__(self, message="Invalid Request", data=None):
super().__init__(-32600, "Invalid Request") super().__init__(-32600, message, data)
class MethodNotFound(JsonRpcError): class MethodNotFound(JsonRpcError):
def __init__(self): def __init__(self, message="Method not found", data=None):
super().__init__(-32601, "Method not found") super().__init__(-32601, message, data)
class InvalidParams(JsonRpcError): class InvalidParams(JsonRpcError):
def __init__(self): def __init__(self, message="Invalid params", data=None):
super().__init__(-32602, "Invalid params") super().__init__(-32602, message, data)
class Timeout(JsonRpcError): class Timeout(JsonRpcError):
def __init__(self): def __init__(self, message="Method timed out", data=None):
super().__init__(-32000, "Method timed out") super().__init__(-32000, message, data)
class Aborted(JsonRpcError): class Aborted(JsonRpcError):
def __init__(self): def __init__(self, message="Method aborted", data=None):
super().__init__(-32001, "Method aborted") super().__init__(-32001, message, data)
class ApplicationError(JsonRpcError): class ApplicationError(JsonRpcError):
def __init__(self, code, message, data): def __init__(self, code, message, data):
@@ -59,11 +73,14 @@ class ApplicationError(JsonRpcError):
raise ValueError("The error code in reserved range") raise ValueError("The error code in reserved range")
super().__init__(code, message, data) super().__init__(code, message, data)
class UnknownError(ApplicationError): class UnknownError(ApplicationError):
def __init__(self, data=None): def __init__(self, message="Unknown error", data=None):
super().__init__(0, "Unknown error", data) super().__init__(0, message, data)
Request = namedtuple("Request", ["method", "params", "id"], defaults=[{}, None]) Request = namedtuple("Request", ["method", "params", "id"], defaults=[{}, None])
Response = namedtuple("Response", ["id", "result", "error"], defaults=[None, {}, {}])
Method = namedtuple("Method", ["callback", "signature", "immediate", "sensitive_params"]) Method = namedtuple("Method", ["callback", "signature", "immediate", "sensitive_params"])
@@ -79,7 +96,7 @@ def anonymise_sensitive_params(params, sensitive_params):
return params return params
class Server(): class Connection():
def __init__(self, reader, writer, encoder=json.JSONEncoder()): def __init__(self, reader, writer, encoder=json.JSONEncoder()):
self._active = True self._active = True
self._reader = StreamLineReader(reader) self._reader = StreamLineReader(reader)
@@ -88,6 +105,8 @@ class Server():
self._methods = {} self._methods = {}
self._notifications = {} self._notifications = {}
self._task_manager = TaskManager("jsonrpc server") self._task_manager = TaskManager("jsonrpc server")
self._last_request_id = 0
self._requests_futures = {}
def register_method(self, name, callback, immediate, sensitive_params=False): def register_method(self, name, callback, immediate, sensitive_params=False):
""" """
@@ -113,6 +132,47 @@ class Server():
""" """
self._notifications[name] = Method(callback, inspect.signature(callback), immediate, sensitive_params) self._notifications[name] = Method(callback, inspect.signature(callback), immediate, sensitive_params)
async def send_request(self, method, params, sensitive_params):
"""
Send request
:param method:
:param params:
:param sensitive_params: list of parameters that are anonymized before logging; \
if False - no params are considered sensitive, if True - all params are considered sensitive
"""
self._last_request_id += 1
request_id = str(self._last_request_id)
loop = asyncio.get_running_loop()
future = loop.create_future()
self._requests_futures[self._last_request_id] = (future, sensitive_params)
logger.info(
"Sending request: id=%s, method=%s, params=%s",
request_id, method, anonymise_sensitive_params(params, sensitive_params)
)
self._send_request(request_id, method, params)
return await future
def send_notification(self, method, params, sensitive_params=False):
"""
Send notification
:param method:
:param params:
:param sensitive_params: list of parameters that are anonymized before logging; \
if False - no params are considered sensitive, if True - all params are considered sensitive
"""
logger.info(
"Sending notification: method=%s, params=%s",
method, anonymise_sensitive_params(params, sensitive_params)
)
self._send_notification(method, params)
async def run(self): async def run(self):
while self._active: while self._active:
try: try:
@@ -124,37 +184,63 @@ class Server():
self._eof() self._eof()
continue continue
data = data.strip() data = data.strip()
logging.debug("Received %d bytes of data", len(data)) logger.debug("Received %d bytes of data", len(data))
self._handle_input(data) self._handle_input(data)
await asyncio.sleep(0) # To not starve task queue await asyncio.sleep(0) # To not starve task queue
def close(self): def close(self):
logging.info("Closing JSON-RPC server - not more messages will be read") if self._active:
self._active = False logger.info("Closing JSON-RPC server - not more messages will be read")
self._active = False
async def wait_closed(self): async def wait_closed(self):
await self._task_manager.wait() await self._task_manager.wait()
def _eof(self): def _eof(self):
logging.info("Received EOF") logger.info("Received EOF")
self.close() self.close()
def _handle_input(self, data): def _handle_input(self, data):
try: try:
request = self._parse_request(data) message = self._parse_message(data)
except JsonRpcError as error: except JsonRpcError as error:
self._send_error(None, error) self._send_error(None, error)
return return
if request.id is not None: if isinstance(message, Request):
self._handle_request(request) if message.id is not None:
else: self._handle_request(message)
self._handle_notification(request) else:
self._handle_notification(message)
elif isinstance(message, Response):
self._handle_response(message)
def _handle_response(self, response):
request_future = self._requests_futures.get(int(response.id))
if request_future is None:
response_type = "response" if response.result is not None else "error"
logger.warning("Received %s for unknown request: %s", response_type, response.id)
return
future, sensitive_params = request_future
if response.error:
error = JsonRpcError(
response.error.setdefault("code", 0),
response.error.setdefault("message", ""),
response.error.setdefault("data", None)
)
self._log_error(response, error, sensitive_params)
future.set_exception(error)
return
self._log_response(response, sensitive_params)
future.set_result(response.result)
def _handle_notification(self, request): def _handle_notification(self, request):
method = self._notifications.get(request.method) method = self._notifications.get(request.method)
if not method: if not method:
logging.error("Received unknown notification: %s", request.method) logger.error("Received unknown notification: %s", request.method)
return return
callback, signature, immediate, sensitive_params = method callback, signature, immediate, sensitive_params = method
@@ -171,12 +257,12 @@ class Server():
try: try:
self._task_manager.create_task(callback(*bound_args.args, **bound_args.kwargs), request.method) 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") logger.exception("Unexpected exception raised in notification handler")
def _handle_request(self, request): def _handle_request(self, request):
method = self._methods.get(request.method) method = self._methods.get(request.method)
if not method: if not method:
logging.error("Received unknown request: %s", request.method) logger.error("Received unknown request: %s", request.method)
self._send_error(request.id, MethodNotFound()) self._send_error(request.id, MethodNotFound())
return return
@@ -203,33 +289,36 @@ class Server():
except asyncio.CancelledError: except asyncio.CancelledError:
self._send_error(request.id, Aborted()) 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") logger.exception("Unexpected exception raised in plugin handler")
self._send_error(request.id, UnknownError(str(e))) self._send_error(request.id, UnknownError(str(e)))
self._task_manager.create_task(handle(), request.method) self._task_manager.create_task(handle(), request.method)
@staticmethod @staticmethod
def _parse_request(data): def _parse_message(data):
try: try:
jsonrpc_request = json.loads(data, encoding="utf-8") jsonrpc_message = json.loads(data, encoding="utf-8")
if jsonrpc_request.get("jsonrpc") != "2.0": if jsonrpc_message.get("jsonrpc") != "2.0":
raise InvalidRequest() raise InvalidRequest()
del jsonrpc_request["jsonrpc"] del jsonrpc_message["jsonrpc"]
return Request(**jsonrpc_request) if "result" in jsonrpc_message.keys() or "error" in jsonrpc_message.keys():
return Response(**jsonrpc_message)
else:
return Request(**jsonrpc_message)
except json.JSONDecodeError: except json.JSONDecodeError:
raise ParseError() raise ParseError()
except TypeError: except TypeError:
raise InvalidRequest() raise InvalidRequest()
def _send(self, data): def _send(self, data, log_level=logging.DEBUG):
try: try:
line = self._encoder.encode(data) line = self._encoder.encode(data)
logging.debug("Sending data: %s", line) logger.log(log_level, "Sending data: %s", line)
data = (line + "\n").encode("utf-8") data = (line + "\n").encode("utf-8")
self._writer.write(data) self._writer.write(data)
self._task_manager.create_task(self._writer.drain(), "drain")
except TypeError as error: except TypeError as error:
logging.error(str(error)) logger.error(str(error))
def _send_response(self, request_id, result): def _send_response(self, request_id, result):
response = { response = {
@@ -237,7 +326,7 @@ class Server():
"id": request_id, "id": request_id,
"result": result "result": result
} }
self._send(response) self._send(response, logging.INFO)
def _send_error(self, request_id, error): def _send_error(self, request_id, error):
response = { response = {
@@ -245,55 +334,42 @@ class Server():
"id": request_id, "id": request_id,
"error": error.json() "error": error.json()
} }
self._send(response, logging.ERROR)
self._send(response) def _send_request(self, request_id, method, params):
request = {
"jsonrpc": "2.0",
"method": method,
"id": request_id,
"params": params
}
self._send(request, logging.NOTSET)
@staticmethod def _send_notification(self, method, params):
def _log_request(request, sensitive_params):
params = anonymise_sensitive_params(request.params, sensitive_params)
if request.id is not None:
logging.info("Handling request: id=%s, method=%s, params=%s", request.id, request.method, params)
else:
logging.info("Handling notification: method=%s, params=%s", request.method, params)
class NotificationClient():
def __init__(self, writer, encoder=json.JSONEncoder()):
self._writer = writer
self._encoder = encoder
self._methods = {}
self._task_manager = TaskManager("notification client")
def notify(self, method, params, sensitive_params=False):
"""
Send notification
:param method:
:param params:
:param sensitive_params: list of parameters that are anonymized before logging; \
if False - no params are considered sensitive, if True - all params are considered sensitive
"""
notification = { notification = {
"jsonrpc": "2.0", "jsonrpc": "2.0",
"method": method, "method": method,
"params": params "params": params
} }
self._log(method, params, sensitive_params) self._send(notification, logging.NOTSET)
self._send(notification)
async def close(self):
await self._task_manager.wait()
def _send(self, data):
try:
line = self._encoder.encode(data)
data = (line + "\n").encode("utf-8")
logging.debug("Sending %d byte of data", len(data))
self._writer.write(data)
self._task_manager.create_task(self._writer.drain(), "drain")
except TypeError as error:
logging.error("Failed to parse outgoing message: %s", str(error))
@staticmethod @staticmethod
def _log(method, params, sensitive_params): def _log_request(request, sensitive_params):
params = anonymise_sensitive_params(params, sensitive_params) params = anonymise_sensitive_params(request.params, sensitive_params)
logging.info("Sending notification: method=%s, params=%s", method, params) if request.id is not None:
logger.info("Handling request: id=%s, method=%s, params=%s", request.id, request.method, params)
else:
logger.info("Handling notification: method=%s, params=%s", request.method, params)
@staticmethod
def _log_response(response, sensitive_params):
result = anonymise_sensitive_params(response.result, sensitive_params)
logger.info("Handling response: id=%s, result=%s", response.id, result)
@staticmethod
def _log_error(response, error, sensitive_params):
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

@@ -2,16 +2,21 @@ import asyncio
import dataclasses import dataclasses
import json import json
import logging import logging
import logging.handlers
import sys import sys
from enum import Enum 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.consts import Feature, OSCompatibility
from galaxy.api.errors import ImportInProgress, UnknownError from galaxy.api.jsonrpc import ApplicationError, Connection
from galaxy.api.jsonrpc import ApplicationError, NotificationClient, Server from galaxy.api.types import (
from galaxy.api.types import Achievement, Authentication, FriendInfo, Game, GameTime, LocalGame, NextStep, GameLibrarySettings Achievement, Authentication, Game, GameLibrarySettings, GameTime, LocalGame, NextStep, UserInfo, UserPresence,
Subscription, SubscriptionGame
)
from galaxy.task_manager import TaskManager from galaxy.task_manager import TaskManager
from galaxy.api.importer import Importer, CollectionImporter, SynchroneousImporter
logger = logging.getLogger(__name__)
class JSONEncoder(json.JSONEncoder): class JSONEncoder(json.JSONEncoder):
@@ -31,7 +36,7 @@ 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) logger.info("Creating plugin for platform %s, version %s", platform.value, version)
self._platform = platform self._platform = platform
self._version = version self._version = version
@@ -42,19 +47,85 @@ class Plugin:
self._handshake_token = handshake_token self._handshake_token = handshake_token
encoder = JSONEncoder() encoder = JSONEncoder()
self._server = Server(self._reader, self._writer, encoder) self._connection = Connection(self._reader, self._writer, encoder)
self._notification_client = NotificationClient(self._writer, encoder)
self._achievements_import_in_progress = False
self._game_times_import_in_progress = False
self._game_library_settings_import_in_progress = False
self._os_compatibility_import_in_progress = False
self._persistent_cache = dict() self._persistent_cache = dict()
self._internal_task_manager = TaskManager("plugin internal") self._internal_task_manager = TaskManager("plugin internal")
self._external_task_manager = TaskManager("plugin external") self._external_task_manager = TaskManager("plugin external")
self._achievements_importer = Importer(
self._external_task_manager,
"achievements",
self.get_unlocked_achievements,
self.prepare_achievements_context,
self._game_achievements_import_success,
self._game_achievements_import_failure,
self._achievements_import_finished,
self.achievements_import_complete
)
self._game_time_importer = Importer(
self._external_task_manager,
"game times",
self.get_game_time,
self.prepare_game_times_context,
self._game_time_import_success,
self._game_time_import_failure,
self._game_times_import_finished,
self.game_times_import_complete
)
self._game_library_settings_importer = Importer(
self._external_task_manager,
"game library settings",
self.get_game_library_settings,
self.prepare_game_library_settings_context,
self._game_library_settings_import_success,
self._game_library_settings_import_failure,
self._game_library_settings_import_finished,
self.game_library_settings_import_complete
)
self._os_compatibility_importer = Importer(
self._external_task_manager,
"os compatibility",
self.get_os_compatibility,
self.prepare_os_compatibility_context,
self._os_compatibility_import_success,
self._os_compatibility_import_failure,
self._os_compatibility_import_finished,
self.os_compatibility_import_complete
)
self._user_presence_importer = Importer(
self._external_task_manager,
"users presence",
self.get_user_presence,
self.prepare_user_presence_context,
self._user_presence_import_success,
self._user_presence_import_failure,
self._user_presence_import_finished,
self.user_presence_import_complete
)
self._local_size_importer = SynchroneousImporter(
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 # 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, immediate=True) self._register_method("get_capabilities", self._get_capabilities, internal=True, immediate=True)
@@ -118,6 +189,18 @@ class Plugin:
self._register_method("start_os_compatibility_import", self._start_os_compatibility_import) self._register_method("start_os_compatibility_import", self._start_os_compatibility_import)
self._detect_feature(Feature.ImportOSCompatibility, ["get_os_compatibility"]) 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"])
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): async def __aenter__(self):
return self return self
@@ -145,7 +228,8 @@ class Plugin:
if self._implements(methods): if self._implements(methods):
self._features.add(feature) 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): def wrap_result(result):
if result_name: if result_name:
result = { result = {
@@ -158,7 +242,7 @@ class Plugin:
result = handler(*args, **kwargs) result = handler(*args, **kwargs)
return wrap_result(result) return wrap_result(result)
self._server.register_method(name, method, True, sensitive_params) self._connection.register_method(name, method, True, sensitive_params)
else: else:
async def method(*args, **kwargs): async def method(*args, **kwargs):
if not internal: if not internal:
@@ -168,12 +252,12 @@ class Plugin:
result = await handler_(*args, **kwargs) result = await handler_(*args, **kwargs)
return wrap_result(result) return wrap_result(result)
self._server.register_method(name, method, False, sensitive_params) self._connection.register_method(name, method, False, sensitive_params)
def _register_notification(self, name, handler, internal=False, immediate=False, sensitive_params=False): def _register_notification(self, name, handler, internal=False, immediate=False, sensitive_params=False):
if not internal and not immediate: if not internal and not immediate:
handler = self._wrap_external_method(handler, name) handler = self._wrap_external_method(handler, name)
self._server.register_notification(name, handler, immediate, sensitive_params) self._connection.register_notification(name, handler, immediate, sensitive_params)
def _wrap_external_method(self, handler, name: str): def _wrap_external_method(self, handler, name: str):
async def wrapper(*args, **kwargs): async def wrapper(*args, **kwargs):
@@ -183,23 +267,32 @@ class Plugin:
async def run(self): async def run(self):
"""Plugin's main coroutine.""" """Plugin's main coroutine."""
await self._server.run() await self._connection.run()
logger.debug("Plugin run loop finished")
def close(self) -> None: def close(self) -> None:
if not self._active: if not self._active:
return return
logging.info("Closing plugin") logger.info("Closing plugin")
self._server.close() self._connection.close()
self._external_task_manager.cancel() self._external_task_manager.cancel()
self._internal_task_manager.create_task(self.shutdown(), "shutdown")
async def shutdown():
try:
await asyncio.wait_for(self.shutdown(), 30)
except asyncio.TimeoutError:
logging.warning("Plugin shutdown timed out")
self._internal_task_manager.create_task(shutdown(), "shutdown")
self._active = False self._active = False
async def wait_closed(self) -> None: async def wait_closed(self) -> None:
logger.debug("Waiting for plugin to close")
await self._external_task_manager.wait() await self._external_task_manager.wait()
await self._internal_task_manager.wait() await self._internal_task_manager.wait()
await self._server.wait_closed() await self._connection.wait_closed()
await self._notification_client.close() logger.info("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"""
@@ -210,11 +303,11 @@ class Plugin:
try: try:
self.tick() self.tick()
except Exception: except Exception:
logging.exception("Unexpected exception raised in plugin tick") logger.exception("Unexpected exception raised in plugin tick")
await asyncio.sleep(1) await asyncio.sleep(1)
async def _shutdown(self): async def _shutdown(self):
logging.info("Shutting down") logger.info("Shutting down")
self.close() self.close()
await self._external_task_manager.wait() await self._external_task_manager.wait()
await self._internal_task_manager.wait() await self._internal_task_manager.wait()
@@ -231,7 +324,7 @@ class Plugin:
try: try:
self.handshake_complete() self.handshake_complete()
except Exception: except Exception:
logging.exception("Unhandled exception during `handshake_complete` step") logger.exception("Unhandled exception during `handshake_complete` step")
self._internal_task_manager.create_task(self._pass_control(), "tick") self._internal_task_manager.create_task(self._pass_control(), "tick")
@staticmethod @staticmethod
@@ -262,9 +355,9 @@ class Plugin:
""" """
# temporary solution for persistent_cache vs credentials issue # temporary solution for persistent_cache vs credentials issue
self.persistent_cache['credentials'] = credentials # type: ignore self.persistent_cache["credentials"] = credentials # type: ignore
self._notification_client.notify("store_credentials", credentials, sensitive_params=True) self._connection.send_notification("store_credentials", credentials, sensitive_params=True)
def add_game(self, game: Game) -> None: def add_game(self, game: Game) -> None:
"""Notify the client to add game to the list of owned games """Notify the client to add game to the list of owned games
@@ -286,7 +379,7 @@ class Plugin:
""" """
params = {"owned_game": game} params = {"owned_game": game}
self._notification_client.notify("owned_game_added", params) self._connection.send_notification("owned_game_added", params)
def remove_game(self, game_id: str) -> None: def remove_game(self, game_id: str) -> None:
"""Notify the client to remove game from the list of owned games """Notify the client to remove game from the list of owned games
@@ -308,7 +401,7 @@ class Plugin:
""" """
params = {"game_id": game_id} params = {"game_id": game_id}
self._notification_client.notify("owned_game_removed", params) self._connection.send_notification("owned_game_removed", params)
def update_game(self, game: Game) -> None: def update_game(self, game: Game) -> None:
"""Notify the client to update the status of a game """Notify the client to update the status of a game
@@ -317,7 +410,7 @@ class Plugin:
:param game: Game to update :param game: Game to update
""" """
params = {"owned_game": game} params = {"owned_game": game}
self._notification_client.notify("owned_game_updated", params) self._connection.send_notification("owned_game_updated", params)
def unlock_achievement(self, game_id: str, achievement: Achievement) -> None: 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.
@@ -329,24 +422,24 @@ class Plugin:
"game_id": game_id, "game_id": game_id,
"achievement": achievement "achievement": achievement
} }
self._notification_client.notify("achievement_unlocked", params) self._connection.send_notification("achievement_unlocked", params)
def _game_achievements_import_success(self, game_id: str, achievements: List[Achievement]) -> None: def _game_achievements_import_success(self, game_id: str, achievements: List[Achievement]) -> None:
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._connection.send_notification("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:
params = { params = {
"game_id": game_id, "game_id": game_id,
"error": error.json() "error": error.json()
} }
self._notification_client.notify("game_achievements_import_failure", params) self._connection.send_notification("game_achievements_import_failure", params)
def _achievements_import_finished(self) -> None: def _achievements_import_finished(self) -> None:
self._notification_client.notify("achievements_import_finished", None) self._connection.send_notification("achievements_import_finished", None)
def update_local_game_status(self, local_game: LocalGame) -> None: def update_local_game_status(self, local_game: LocalGame) -> None:
"""Notify the client to update the status of a local game. """Notify the client to update the status of a local game.
@@ -372,15 +465,15 @@ class Plugin:
self._check_statuses_task = asyncio.create_task(self._check_statuses()) self._check_statuses_task = asyncio.create_task(self._check_statuses())
""" """
params = {"local_game": local_game} params = {"local_game": local_game}
self._notification_client.notify("local_game_status_changed", params) self._connection.send_notification("local_game_status_changed", params)
def add_friend(self, user: FriendInfo) -> None: def add_friend(self, user: UserInfo) -> None:
"""Notify the client to add a user to friends list of the currently authenticated user. """Notify the client to add a user to friends list of the currently authenticated user.
:param user: FriendInfo of a user that the client will add to friends list :param user: UserInfo of a user that the client will add to friends list
""" """
params = {"friend_info": user} params = {"friend_info": user}
self._notification_client.notify("friend_added", params) self._connection.send_notification("friend_added", params)
def remove_friend(self, user_id: str) -> None: def remove_friend(self, user_id: str) -> None:
"""Notify the client to remove a user from friends list of the currently authenticated user. """Notify the client to remove a user from friends list of the currently authenticated user.
@@ -388,7 +481,14 @@ class Plugin:
:param user_id: id of the user to remove from friends list :param user_id: id of the user to remove from friends list
""" """
params = {"user_id": user_id} params = {"user_id": user_id}
self._notification_client.notify("friend_removed", params) self._connection.send_notification("friend_removed", params)
def update_friend_info(self, user: UserInfo) -> None:
"""Notify the client about the updated friend information.
:param user: UserInfo of a friend whose info was updated
"""
self._connection.send_notification("friend_updated", params={"friend_info": user})
def update_game_time(self, game_time: GameTime) -> None: def update_game_time(self, game_time: GameTime) -> None:
"""Notify the client to update game time for a game. """Notify the client to update game time for a game.
@@ -396,38 +496,52 @@ class Plugin:
:param game_time: game time to update :param game_time: game time to update
""" """
params = {"game_time": game_time} params = {"game_time": game_time}
self._notification_client.notify("game_time_updated", params) self._connection.send_notification("game_time_updated", params)
def _game_time_import_success(self, game_time: GameTime) -> None: def update_user_presence(self, user_id: str, user_presence: UserPresence) -> None:
"""Notify the client about the updated user presence information.
:param user_id: the id of the user whose presence information is updated
:param user_presence: presence information of the specified user
"""
self._connection.send_notification(
"user_presence_updated",
{
"user_id": user_id,
"presence": user_presence
}
)
def _game_time_import_success(self, game_id: str, game_time: GameTime) -> None:
params = {"game_time": game_time} params = {"game_time": game_time}
self._notification_client.notify("game_time_import_success", params) self._connection.send_notification("game_time_import_success", params)
def _game_time_import_failure(self, game_id: str, error: ApplicationError) -> None: def _game_time_import_failure(self, game_id: str, error: ApplicationError) -> None:
params = { params = {
"game_id": game_id, "game_id": game_id,
"error": error.json() "error": error.json()
} }
self._notification_client.notify("game_time_import_failure", params) self._connection.send_notification("game_time_import_failure", params)
def _game_times_import_finished(self) -> None: def _game_times_import_finished(self) -> None:
self._notification_client.notify("game_times_import_finished", None) self._connection.send_notification("game_times_import_finished", None)
def _game_library_settings_import_success(self, game_library_settings: GameLibrarySettings) -> None: def _game_library_settings_import_success(self, game_id: str, game_library_settings: GameLibrarySettings) -> None:
params = {"game_library_settings": game_library_settings} params = {"game_library_settings": game_library_settings}
self._notification_client.notify("game_library_settings_import_success", params) self._connection.send_notification("game_library_settings_import_success", params)
def _game_library_settings_import_failure(self, game_id: str, error: ApplicationError) -> None: def _game_library_settings_import_failure(self, game_id: str, error: ApplicationError) -> None:
params = { params = {
"game_id": game_id, "game_id": game_id,
"error": error.json() "error": error.json()
} }
self._notification_client.notify("game_library_settings_import_failure", params) self._connection.send_notification("game_library_settings_import_failure", params)
def _game_library_settings_import_finished(self) -> None: def _game_library_settings_import_finished(self) -> None:
self._notification_client.notify("game_library_settings_import_finished", None) self._connection.send_notification("game_library_settings_import_finished", None)
def _os_compatibility_import_success(self, game_id: str, os_compatibility: Optional[OSCompatibility]) -> None: def _os_compatibility_import_success(self, game_id: str, os_compatibility: Optional[OSCompatibility]) -> None:
self._notification_client.notify( self._connection.send_notification(
"os_compatibility_import_success", "os_compatibility_import_success",
{ {
"game_id": game_id, "game_id": game_id,
@@ -436,7 +550,7 @@ class Plugin:
) )
def _os_compatibility_import_failure(self, game_id: str, error: ApplicationError) -> None: def _os_compatibility_import_failure(self, game_id: str, error: ApplicationError) -> None:
self._notification_client.notify( self._connection.send_notification(
"os_compatibility_import_failure", "os_compatibility_import_failure",
{ {
"game_id": game_id, "game_id": game_id,
@@ -445,23 +559,98 @@ class Plugin:
) )
def _os_compatibility_import_finished(self) -> None: def _os_compatibility_import_finished(self) -> None:
self._notification_client.notify("os_compatibility_import_finished", None) self._connection.send_notification("os_compatibility_import_finished", None)
def _user_presence_import_success(self, user_id: str, user_presence: UserPresence) -> None:
self._connection.send_notification(
"user_presence_import_success",
{
"user_id": user_id,
"presence": user_presence
}
)
def _user_presence_import_failure(self, user_id: str, error: ApplicationError) -> None:
self._connection.send_notification(
"user_presence_import_failure",
{
"user_id": user_id,
"error": error.json()
}
)
def _user_presence_import_finished(self) -> None:
self._connection.send_notification("user_presence_import_finished", None)
def _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: 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.
""" """
self._notification_client.notify("authentication_lost", None) self._connection.send_notification("authentication_lost", None)
def push_cache(self) -> None: def push_cache(self) -> None:
"""Push local copy of the persistent cache to the GOG Galaxy Client replacing existing one. """Push local copy of the persistent cache to the GOG Galaxy Client replacing existing one.
""" """
self._notification_client.notify( self._connection.send_notification(
"push_cache", "push_cache",
params={"data": self._persistent_cache}, params={"data": self._persistent_cache},
sensitive_params="data" sensitive_params="data"
) )
async def refresh_credentials(self, params: Dict[str, Any], sensitive_params) -> Dict[str, Any]:
return await self._connection.send_request("refresh_credentials", params, sensitive_params)
# handlers # handlers
def handshake_complete(self) -> None: def handshake_complete(self) -> None:
"""This method is called right after the handshake with the GOG Galaxy Client is complete and """This method is called right after the handshake with the GOG Galaxy Client is complete and
@@ -503,7 +692,7 @@ class Plugin:
This method is called by the GOG Galaxy Client. This method is called by the GOG Galaxy Client.
:param stored_credentials: If the client received any credentials to store locally :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: Example of possible override of the method:
@@ -525,11 +714,12 @@ 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 :class:`~galaxy.api.types.NextStep` from :meth:`.authenticate`
or :meth:`.pass_login_credentials`.
This method's parameters provide the data extracted from the web page navigation that previous NextStep finished on. This method'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 :class:`~galaxy.api.types.Authentication` if the authentication is finished
or galaxy.api.types.NextStep if it requires going to another cef url. or :class:`~galaxy.api.types.NextStep` if it requires going to another cef url.
This method is called by the GOG Galaxy Client. This method is called by the GOG Galaxy Client.
:param step: deprecated. :param step: deprecated.
@@ -574,36 +764,7 @@ class Plugin:
raise NotImplementedError() raise NotImplementedError()
async def _start_achievements_import(self, game_ids: List[str]) -> None: async def _start_achievements_import(self, game_ids: List[str]) -> None:
if self._achievements_import_in_progress: await self._achievements_importer.start(game_ids)
raise ImportInProgress()
context = await self.prepare_achievements_context(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
async def prepare_achievements_context(self, game_ids: List[str]) -> Any: 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 prepare context for get_unlocked_achievements.
@@ -717,7 +878,7 @@ 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 get_friends(self) -> List[FriendInfo]: async def get_friends(self) -> List[UserInfo]:
"""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.
This method is called by the GOG Galaxy Client. This method is called by the GOG Galaxy Client.
@@ -738,36 +899,7 @@ class Plugin:
raise NotImplementedError() raise NotImplementedError()
async def _start_game_times_import(self, game_ids: List[str]) -> None: async def _start_game_times_import(self, game_ids: List[str]) -> None:
if self._game_times_import_in_progress: await self._game_time_importer.start(game_ids)
raise ImportInProgress()
context = await self.prepare_game_times_context(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
async def prepare_game_times_context(self, game_ids: List[str]) -> Any: 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 prepare context for get_game_time.
@@ -796,36 +928,7 @@ class Plugin:
""" """
async def _start_game_library_settings_import(self, game_ids: List[str]) -> None: async def _start_game_library_settings_import(self, game_ids: List[str]) -> None:
if self._game_library_settings_import_in_progress: await self._game_library_settings_importer.start(game_ids)
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: async def prepare_game_library_settings_context(self, game_ids: List[str]) -> Any:
"""Override this method to prepare context for get_game_library_settings. """Override this method to prepare context for get_game_library_settings.
@@ -854,37 +957,7 @@ class Plugin:
""" """
async def _start_os_compatibility_import(self, game_ids: List[str]) -> None: async def _start_os_compatibility_import(self, game_ids: List[str]) -> None:
if self._os_compatibility_import_in_progress: await self._os_compatibility_importer.start(game_ids)
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: async def prepare_os_compatibility_context(self, game_ids: List[str]) -> Any:
"""Override this method to prepare context for get_os_compatibility. """Override this method to prepare context for get_os_compatibility.
@@ -909,6 +982,107 @@ class Plugin:
def os_compatibility_import_complete(self) -> None: def os_compatibility_import_complete(self) -> None:
"""Override this method to handle operations after OS compatibility import is finished (like updating cache).""" """Override this method to handle operations after OS compatibility import is finished (like updating cache)."""
async def _start_user_presence_import(self, user_id_list: List[str]) -> None:
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 :meth:`get_user_presence`.
This allows for optimizations like batch requests to platform API.
Default implementation returns None.
:param user_id_list: the ids of the users for whom presence information is imported
:return: context
"""
return None
async def get_user_presence(self, user_id: str, context: Any) -> UserPresence:
"""Override this method to return presence information for the user with the provided user_id.
This method is called by import task initialized by GOG Galaxy Client.
:param user_id: the id of the user for whom presence information is imported
:param context: the value returned from :meth:`prepare_user_presence_context`
:return: UserPresence presence information of the provided user
"""
raise NotImplementedError()
def user_presence_import_complete(self) -> None:
"""Override this method to handle operations after presence import is finished (like updating cache)."""
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): def create_and_run_plugin(plugin_class, argv):
"""Call this method as an entry point for the implemented integration. """Call this method as an entry point for the implemented integration.
@@ -928,7 +1102,7 @@ def create_and_run_plugin(plugin_class, argv):
main() main()
""" """
if len(argv) < 3: if len(argv) < 3:
logging.critical("Not enough parameters, required: token, port") logger.critical("Not enough parameters, required: token, port")
sys.exit(1) sys.exit(1)
token = argv[1] token = argv[1]
@@ -936,23 +1110,30 @@ def create_and_run_plugin(plugin_class, argv):
try: try:
port = int(argv[2]) port = int(argv[2])
except ValueError: except ValueError:
logging.critical("Failed to parse port value: %s", argv[2]) logger.critical("Failed to parse port value: %s", argv[2])
sys.exit(2) sys.exit(2)
if not (1 <= port <= 65535): if not (1 <= port <= 65535):
logging.critical("Port value out of range (1, 65535)") logger.critical("Port value out of range (1, 65535)")
sys.exit(3) sys.exit(3)
if not issubclass(plugin_class, Plugin): if not issubclass(plugin_class, Plugin):
logging.critical("plugin_class must be subclass of Plugin") logger.critical("plugin_class must be subclass of Plugin")
sys.exit(4) sys.exit(4)
async def coroutine(): async def coroutine():
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") try:
logging.info("Using local address: %s:%u", *extra_info) extra_info = writer.get_extra_info("sockname")
async with plugin_class(reader, writer, token) as plugin: logger.info("Using local address: %s:%u", *extra_info)
await plugin.run() async with plugin_class(reader, writer, token) as plugin:
await plugin.run()
finally:
try:
writer.close()
await writer.wait_closed()
except (ConnectionAbortedError, ConnectionResetError):
pass
try: try:
if sys.platform == "win32": if sys.platform == "win32":
@@ -960,5 +1141,5 @@ def create_and_run_plugin(plugin_class, argv):
asyncio.run(coroutine()) asyncio.run(coroutine())
except Exception: except Exception:
logging.exception("Error while running plugin") logger.exception("Error while running plugin")
sys.exit(5) sys.exit(5)

View File

@@ -1,7 +1,7 @@
from dataclasses import dataclass from dataclasses import dataclass
from typing import Dict, List, Optional from typing import Dict, List, Optional
from galaxy.api.consts import LicenseType, LocalGameState from galaxy.api.consts import LicenseType, LocalGameState, PresenceState, SubscriptionDiscovery
@dataclass @dataclass
@@ -33,7 +33,7 @@ class Cookie:
@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. R"""Return this from :meth:`.authenticate` or :meth:`.pass_login_credentials` to open client built-in browser with given url.
For example: For example:
.. code-block:: python .. code-block:: python
@@ -61,9 +61,11 @@ class NextStep:
if not stored_credentials: if not stored_credentials:
return NextStep("web_session", PARAMS, cookies=COOKIES, js=JS) return NextStep("web_session", PARAMS, cookies=COOKIES, js=JS)
:param auth_params: configuration options: {"window_title": :class:`str`, "window_width": :class:`str`, "window_height": :class:`int`, "start_uri": :class:`int`, "end_uri_regex": :class:`str`} :param auth_params: configuration options: {"window_title": :class:`str`, "window_width": :class:`str`,
"window_height": :class:`int`, "start_uri": :class:`int`, "end_uri_regex": :class:`str`}
:param cookies: browser initial set of cookies :param 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]
@@ -140,7 +142,11 @@ class LocalGame:
@dataclass @dataclass
class FriendInfo: class FriendInfo:
"""Information about a friend of the currently authenticated user. """
.. deprecated:: 0.56
Use :class:`UserInfo`.
Information about a friend of the currently authenticated user.
:param user_id: id of the user :param user_id: id of the user
:param user_name: username of the user :param user_name: username of the user
@@ -149,6 +155,21 @@ class FriendInfo:
user_name: str user_name: str
@dataclass
class UserInfo:
"""Information about a user of related user.
:param user_id: id of the user
:param user_name: username of the user
:param avatar_url: the URL of the user avatar
:param profile_url: the URL of the user profile
"""
user_id: str
user_name: str
avatar_url: Optional[str] = None
profile_url: Optional[str] = None
@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
@@ -156,7 +177,7 @@ class GameTime:
:param game_id: id of the related game :param game_id: id of the related game
:param time_played: the total time spent in the game in **minutes** :param time_played: the total time spent in the game in **minutes**
:param last_time_played: last time the game was played (**unix timestamp**) :param last_played_time: last time the game was played (**unix timestamp**)
""" """
game_id: str game_id: str
time_played: Optional[int] time_played: Optional[int]
@@ -169,8 +190,68 @@ class GameLibrarySettings:
:param game_id: id of the related game :param game_id: id of the related game
:param tags: collection of tags assigned to the game :param tags: collection of tags assigned to the game
:param hidden: indicates if the game should be hidden in GOG Galaxy application :param hidden: indicates if the game should be hidden in GOG Galaxy client
""" """
game_id: str game_id: str
tags: Optional[List[str]] tags: Optional[List[str]]
hidden: Optional[bool] hidden: Optional[bool]
@dataclass
class UserPresence:
"""Presence information of a user.
The GOG Galaxy client will prefer to generate user status basing on `game_id` (or `game_title`)
and `in_game_status` fields but if plugin is not capable of delivering it then the `full_status` will be used if
available
:param presence_state: the state of the user
:param game_id: id of the game a user is currently in
:param game_title: name of the game a user is currently in
:param in_game_status: status set by the game itself e.x. "In Main Menu"
:param full_status: full user status e.x. "Playing <title_name>: <in_game_status>"
"""
presence_state: PresenceState
game_id: Optional[str] = None
game_title: Optional[str] = None
in_game_status: Optional[str] = None
full_status: Optional[str] = None
@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. 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 .. code-block:: python
import logging
from galaxy.http import create_client_session, handle_exception from galaxy.http import create_client_session, handle_exception
class BackendClient: class BackendClient:
@@ -44,6 +43,8 @@ from galaxy.api.errors import (
) )
logger = logging.getLogger(__name__)
#: Default limit of the simultaneous connections for ssl connector. #: Default limit of the simultaneous connections for ssl connector.
DEFAULT_LIMIT = 20 DEFAULT_LIMIT = 20
#: Default timeout in seconds used for client session. #: Default timeout in seconds used for client session.
@@ -70,7 +71,7 @@ class HttpClient:
def create_tcp_connector(*args, **kwargs) -> aiohttp.TCPConnector: 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 For details about available parameters refer to
`aiohttp.TCPConnector <https://docs.aiohttp.org/en/stable/client_reference.html#tcpconnector>`_ `aiohttp.TCPConnector <https://docs.aiohttp.org/en/stable/client_reference.html#tcpconnector>`_
""" """
@@ -78,16 +79,17 @@ def create_tcp_connector(*args, **kwargs) -> aiohttp.TCPConnector:
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) # type: ignore due to https://github.com/python/mypy/issues/4001 # due to https://github.com/python/mypy/issues/4001
return aiohttp.TCPConnector(*args, **kwargs) # type: ignore
def create_client_session(*args, **kwargs) -> aiohttp.ClientSession: 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 For details about available parameters refer to
`aiohttp.ClientSession <https://docs.aiohttp.org/en/stable/client_reference.html>`_ `aiohttp.ClientSession <https://docs.aiohttp.org/en/stable/client_reference.html>`_
Examplary customization: Exemplary customization:
.. code-block:: python .. code-block:: python
@@ -103,7 +105,8 @@ def create_client_session(*args, **kwargs) -> aiohttp.ClientSession:
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) # type: ignore due to https://github.com/python/mypy/issues/4001 # due to https://github.com/python/mypy/issues/4001
return aiohttp.ClientSession(*args, **kwargs) # type: ignore
@contextmanager @contextmanager
@@ -120,25 +123,25 @@ def handle_exception():
raise BackendNotAvailable() raise BackendNotAvailable()
except aiohttp.ClientConnectionError: except aiohttp.ClientConnectionError:
raise NetworkError() raise NetworkError()
except aiohttp.ContentTypeError: except aiohttp.ContentTypeError as error:
raise UnknownBackendResponse() raise UnknownBackendResponse(error.message)
except aiohttp.ClientResponseError as error: except aiohttp.ClientResponseError as error:
if error.status == HTTPStatus.UNAUTHORIZED: if error.status == HTTPStatus.UNAUTHORIZED:
raise AuthenticationRequired() raise AuthenticationRequired(error.message)
if error.status == HTTPStatus.FORBIDDEN: if error.status == HTTPStatus.FORBIDDEN:
raise AccessDenied() raise AccessDenied(error.message)
if error.status == HTTPStatus.SERVICE_UNAVAILABLE: if error.status == HTTPStatus.SERVICE_UNAVAILABLE:
raise BackendNotAvailable() raise BackendNotAvailable(error.message)
if error.status == HTTPStatus.TOO_MANY_REQUESTS: if error.status == HTTPStatus.TOO_MANY_REQUESTS:
raise TooManyRequests() raise TooManyRequests(error.message)
if error.status >= 500: if error.status >= 500:
raise BackendError() raise BackendError(error.message)
if error.status >= 400: if error.status >= 400:
logging.warning( logger.warning(
"Got status %d while performing %s request for %s", "Got status %d while performing %s request for %s",
error.status, error.request_info.method, str(error.request_info.url) error.status, error.request_info.method, str(error.request_info.url)
) )
raise UnknownError() raise UnknownError(error.message)
except aiohttp.ClientError: except aiohttp.ClientError as e:
logging.exception("Caught exception while performing request") logger.exception("Caught exception while performing request")
raise UnknownError() raise UnknownError(repr(e))

View File

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

View File

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

View File

@@ -1,4 +1,6 @@
import sys import sys
if sys.platform == "win32": if sys.platform == "win32":
import logging import logging
import ctypes import ctypes
@@ -76,11 +78,10 @@ class RegistryMonitor:
if self._key is None: if self._key is None:
self._open_key() self._open_key()
if self._key is None: if self._key is not None:
return False self._set_key_update_notification()
self._set_key_update_notification() return False
return True
def _set_key_update_notification(self): def _set_key_update_notification(self):
filter_ = REG_NOTIFY_CHANGE_NAME | REG_NOTIFY_CHANGE_LAST_SET filter_ = REG_NOTIFY_CHANGE_NAME | REG_NOTIFY_CHANGE_LAST_SET

View File

@@ -3,6 +3,10 @@ import logging
from collections import OrderedDict from collections import OrderedDict
from itertools import count from itertools import count
logger = logging.getLogger(__name__)
class TaskManager: class TaskManager:
def __init__(self, name): def __init__(self, name):
self._name = name self._name = name
@@ -15,23 +19,23 @@ class TaskManager:
async def task_wrapper(task_id): async def task_wrapper(task_id):
try: try:
result = await coro result = await coro
logging.debug("Task manager %s: finished task %d (%s)", self._name, task_id, description) logger.debug("Task manager %s: finished task %d (%s)", self._name, task_id, description)
return result return result
except asyncio.CancelledError: except asyncio.CancelledError:
if handle_exceptions: if handle_exceptions:
logging.debug("Task manager %s: canceled task %d (%s)", self._name, task_id, description) logger.debug("Task manager %s: canceled task %d (%s)", self._name, task_id, description)
else: else:
raise raise
except Exception: except Exception:
if handle_exceptions: if handle_exceptions:
logging.exception("Task manager %s: exception raised in task %d (%s)", self._name, task_id, description) logger.exception("Task manager %s: exception raised in task %d (%s)", self._name, task_id, description)
else: else:
raise raise
finally: finally:
del self._tasks[task_id] del self._tasks[task_id]
task_id = next(self._task_counter) task_id = next(self._task_counter)
logging.debug("Task manager %s: creating task %d (%s)", self._name, task_id, description) logger.debug("Task manager %s: creating task %d (%s)", self._name, task_id, description)
task = asyncio.create_task(task_wrapper(task_id)) task = asyncio.create_task(task_wrapper(task_id))
self._tasks[task_id] = task self._tasks[task_id] = task
return task return task

View File

@@ -21,11 +21,19 @@ def coroutine_mock():
corofunc.coro = coro corofunc.coro = coro
return corofunc return corofunc
async def skip_loop(iterations=1): async def skip_loop(iterations=1):
for _ in range(iterations): for _ in range(iterations):
await asyncio.sleep(0) await asyncio.sleep(0)
async def async_return_value(return_value, loop_iterations_delay=0): async def async_return_value(return_value, loop_iterations_delay=0):
await skip_loop(loop_iterations_delay) if loop_iterations_delay > 0:
await skip_loop(loop_iterations_delay)
return return_value return return_value
async def async_raise(error, loop_iterations_delay=0):
if loop_iterations_delay > 0:
await skip_loop(loop_iterations_delay)
raise error

View File

@@ -1,33 +1,38 @@
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.api.plugin import Plugin
from galaxy.unittest.mock import async_return_value 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 = MagicMock() stream.read = MagicMock()
yield stream yield stream
@pytest.fixture() @pytest.fixture()
async def writer(): async def writer():
stream = MagicMock(name="stream_writer") stream = MagicMock(name="stream_writer")
stream.drain.side_effect = lambda: async_return_value(None) stream.drain.side_effect = lambda: async_return_value(None)
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()
async 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"""
@@ -56,6 +61,16 @@ async def plugin(reader, writer):
"get_os_compatibility", "get_os_compatibility",
"prepare_os_compatibility_context", "prepare_os_compatibility_context",
"os_compatibility_import_complete", "os_compatibility_import_complete",
"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: with ExitStack() as stack:

View File

@@ -5,7 +5,7 @@ from pytest import raises
from galaxy.api.types import Achievement from galaxy.api.types import Achievement
from galaxy.api.errors import BackendError from galaxy.api.errors import BackendError
from galaxy.unittest.mock import async_return_value from galaxy.unittest.mock import async_return_value, skip_loop
from tests import create_message, get_messages from tests import create_message, get_messages
@@ -79,11 +79,11 @@ async def test_get_unlocked_achievements_success(plugin, read, write):
@pytest.mark.asyncio @pytest.mark.asyncio
@pytest.mark.parametrize("exception,code,message", [ @pytest.mark.parametrize("exception,code,message,internal_type", [
(BackendError, 4, "Backend error"), (BackendError, 4, "Backend error", "BackendError"),
(KeyError, 0, "Unknown error") (KeyError, 0, "Unknown error", "UnknownError")
]) ])
async def test_get_unlocked_achievements_error(exception, code, message, plugin, read, write): async def test_get_unlocked_achievements_error(exception, code, message, internal_type, plugin, read, write):
plugin.prepare_achievements_context.return_value = async_return_value(None) plugin.prepare_achievements_context.return_value = async_return_value(None)
request = { request = {
"jsonrpc": "2.0", "jsonrpc": "2.0",
@@ -113,7 +113,8 @@ async def test_get_unlocked_achievements_error(exception, code, message, plugin,
"game_id": "14", "game_id": "14",
"error": { "error": {
"code": code, "code": code,
"message": message "message": message,
"data": {"internal_type" : internal_type}
} }
} }
}, },
@@ -145,7 +146,8 @@ async def test_prepare_get_unlocked_achievements_context_error(plugin, read, wri
"id": "3", "id": "3",
"error": { "error": {
"code": 4, "code": 4,
"message": "Backend error" "message": "Backend error",
"data": {"internal_type": "BackendError"}
} }
} }
] ]
@@ -192,7 +194,8 @@ async def test_import_in_progress(plugin, read, write):
"id": "4", "id": "4",
"error": { "error": {
"code": 600, "code": 600,
"message": "Import already in progress" "message": "Import already in progress",
"data": {"internal_type": "ImportInProgress"}
} }
} in messages } in messages
@@ -201,6 +204,7 @@ async def test_import_in_progress(plugin, read, write):
async def test_unlock_achievement(plugin, write): 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) plugin.unlock_achievement("14", achievement)
await skip_loop()
response = json.loads(write.call_args[0][0]) response = json.loads(write.call_args[0][0])
assert response == { assert response == {

View File

@@ -2,10 +2,18 @@ import pytest
from galaxy.api.types import Authentication from galaxy.api.types import Authentication
from galaxy.api.errors import ( from galaxy.api.errors import (
UnknownError, InvalidCredentials, NetworkError, LoggedInElsewhere, ProtocolError, UnknownError,
BackendNotAvailable, BackendTimeout, BackendError, TemporaryBlocked, Banned, AccessDenied BackendNotAvailable,
BackendTimeout,
BackendError,
InvalidCredentials,
NetworkError,
ProtocolError,
TemporaryBlocked,
Banned,
AccessDenied,
) )
from galaxy.unittest.mock import async_return_value from galaxy.unittest.mock import async_return_value, skip_loop
from tests import create_message, get_messages from tests import create_message, get_messages
@@ -35,20 +43,19 @@ async def test_success(plugin, read, write):
@pytest.mark.asyncio @pytest.mark.asyncio
@pytest.mark.parametrize("error,code,message", [ @pytest.mark.parametrize("error,code,message, internal_type", [
pytest.param(UnknownError, 0, "Unknown error", id="unknown_error"), pytest.param(UnknownError, 0, "Unknown error", "UnknownError"),
pytest.param(BackendNotAvailable, 2, "Backend not available", id="backend_not_available"), pytest.param(BackendNotAvailable, 2, "Backend not available", "BackendNotAvailable"),
pytest.param(BackendTimeout, 3, "Backend timed out", id="backend_timeout"), pytest.param(BackendTimeout, 3, "Backend timed out", "BackendTimeout"),
pytest.param(BackendError, 4, "Backend error", id="backend_error"), pytest.param(BackendError, 4, "Backend error", "BackendError"),
pytest.param(InvalidCredentials, 100, "Invalid credentials", id="invalid_credentials"), pytest.param(InvalidCredentials, 100, "Invalid credentials", "InvalidCredentials"),
pytest.param(NetworkError, 101, "Network error", id="network_error"), pytest.param(NetworkError, 101, "Network error", "NetworkError"),
pytest.param(LoggedInElsewhere, 102, "Logged in elsewhere", id="logged_elsewhere"), pytest.param(ProtocolError, 103, "Protocol error", "ProtocolError"),
pytest.param(ProtocolError, 103, "Protocol error", id="protocol_error"), pytest.param(TemporaryBlocked, 104, "Temporary blocked", "TemporaryBlocked"),
pytest.param(TemporaryBlocked, 104, "Temporary blocked", id="temporary_blocked"), pytest.param(Banned, 105, "Banned", "Banned"),
pytest.param(Banned, 105, "Banned", id="banned"), pytest.param(AccessDenied, 106, "Access denied", "AccessDenied"),
pytest.param(AccessDenied, 106, "Access denied", id="access_denied"),
]) ])
async def test_failure(plugin, read, write, error, code, message): async def test_failure(plugin, read, write, error, code, message, internal_type):
request = { request = {
"jsonrpc": "2.0", "jsonrpc": "2.0",
"id": "3", "id": "3",
@@ -66,7 +73,8 @@ async def test_failure(plugin, read, write, error, code, message):
"id": "3", "id": "3",
"error": { "error": {
"code": code, "code": code,
"message": message "message": message,
"data" : {"internal_type" : internal_type}
} }
} }
] ]
@@ -97,6 +105,7 @@ async def test_store_credentials(plugin, write):
"token": "ABC" "token": "ABC"
} }
plugin.store_credentials(credentials) plugin.store_credentials(credentials)
await skip_loop()
assert get_messages(write) == [ assert get_messages(write) == [
{ {
@@ -110,6 +119,7 @@ async def test_store_credentials(plugin, write):
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_lost_authentication(plugin, write): async def test_lost_authentication(plugin, write):
plugin.lost_authentication() plugin.lost_authentication()
await skip_loop()
assert get_messages(write) == [ assert get_messages(write) == [
{ {

147
tests/test_errors.py Normal file
View File

@@ -0,0 +1,147 @@
import pytest
import galaxy.api.errors as errors
import galaxy.api.jsonrpc as jsonrpc
@pytest.mark.parametrize("data", [
{"key1": "value", "key2": "value2"},
{},
{"key1": ["list", "of", "things"], "key2": None},
{"key1": ("tuple", Exception)},
])
def test_valid_error_data(data):
test_message = "Test error message"
test_code = 1
err_obj = jsonrpc.JsonRpcError(code=test_code, message=test_message, data=data)
data.update({"internal_type": "JsonRpcError"})
expected_json = {"code": 1, "data": data, "message": "Test error message"}
assert err_obj.json() == expected_json
def test_error_default_data():
test_message = "Test error message"
test_code = 1
err_obj = jsonrpc.JsonRpcError(code=test_code, message=test_message)
expected_json = {"code": test_code, "data": {"internal_type": "JsonRpcError"}, "message": test_message}
assert err_obj.json() == expected_json
@pytest.mark.parametrize("data", [
123,
["not", "a", "mapping"],
"nor is this"
])
def test_invalid_error_data(data):
test_message = "Test error message"
test_code = 1
with pytest.raises(TypeError):
jsonrpc.JsonRpcError(code=test_code, message=test_message, data=data)
def test_error_override_internal_type():
test_message = "Test error message"
test_code = 1
test_data = {"internal_type": "SomeUserProvidedType", "details": "some more data"}
err_obj = jsonrpc.JsonRpcError(code=test_code, message=test_message, data=test_data)
expected_json = {"code": test_code, "data": {"details": "some more data", "internal_type": "JsonRpcError"}, "message": test_message}
assert err_obj.json() == expected_json
@pytest.mark.parametrize("error, expected_error_msg", [
(errors.AuthenticationRequired, "Authentication required"),
(errors.BackendNotAvailable, "Backend not available"),
(errors.BackendTimeout, "Backend timed out"),
(errors.BackendError, "Backend error"),
(errors.UnknownBackendResponse, "Backend responded in unknown way"),
(errors.TooManyRequests, "Too many requests. Try again later"),
(errors.InvalidCredentials, "Invalid credentials"),
(errors.NetworkError, "Network error"),
(errors.ProtocolError, "Protocol error"),
(errors.TemporaryBlocked, "Temporary blocked"),
(errors.Banned, "Banned"),
(errors.AccessDenied, "Access denied"),
(errors.FailedParsingManifest, "Failed parsing manifest"),
(errors.TooManyMessagesSent, "Too many messages sent"),
(errors.IncoherentLastMessage, "Different last message id on backend"),
(errors.MessageNotFound, "Message not found"),
(errors.ImportInProgress, "Import already in progress"),
(jsonrpc.UnknownError, "Unknown error"),
(jsonrpc.ParseError, "Parse error"),
(jsonrpc.InvalidRequest, "Invalid Request"),
(jsonrpc.MethodNotFound, "Method not found"),
(jsonrpc.InvalidParams, "Invalid params"),
(jsonrpc.Timeout, "Method timed out"),
(jsonrpc.Aborted, "Method aborted"),
])
def test_error_default_message(error, expected_error_msg):
error_json = error().json()
assert error_json["message"] == expected_error_msg
@pytest.mark.parametrize("error", [
errors.AuthenticationRequired,
errors.BackendNotAvailable,
errors.BackendTimeout,
errors.BackendError,
errors.UnknownBackendResponse,
errors.TooManyRequests,
errors.InvalidCredentials,
errors.NetworkError,
errors.ProtocolError,
errors.TemporaryBlocked,
errors.Banned,
errors.AccessDenied,
errors.FailedParsingManifest,
errors.TooManyMessagesSent,
errors.IncoherentLastMessage,
errors.MessageNotFound,
errors.ImportInProgress,
jsonrpc.UnknownError,
jsonrpc.ParseError,
jsonrpc.InvalidRequest,
jsonrpc.MethodNotFound,
jsonrpc.InvalidParams,
jsonrpc.Timeout,
jsonrpc.Aborted,
])
def test_set_error_custom_message(error):
custom_message = "test message"
error_json = error(custom_message).json()
assert error_json["message"] == custom_message
@pytest.mark.parametrize("error", [
errors.AuthenticationRequired,
errors.BackendNotAvailable,
errors.BackendTimeout,
errors.BackendError,
errors.UnknownBackendResponse,
errors.TooManyRequests,
errors.InvalidCredentials,
errors.NetworkError,
errors.ProtocolError,
errors.TemporaryBlocked,
errors.Banned,
errors.AccessDenied,
errors.FailedParsingManifest,
errors.TooManyMessagesSent,
errors.IncoherentLastMessage,
errors.MessageNotFound,
errors.ImportInProgress,
jsonrpc.UnknownError,
jsonrpc.ParseError,
jsonrpc.InvalidRequest,
jsonrpc.MethodNotFound,
jsonrpc.InvalidParams,
jsonrpc.Timeout,
jsonrpc.Aborted,
])
def test_set_arbitrary_error_message(error):
arbitrary_messages = [[], {}, (), 1, None]
for msg in arbitrary_messages:
error_json = error(msg).json()
assert error_json["message"] == str(msg)

View File

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

View File

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

View File

@@ -79,11 +79,11 @@ async def test_get_library_settings_success(plugin, read, write):
@pytest.mark.asyncio @pytest.mark.asyncio
@pytest.mark.parametrize("exception,code,message", [ @pytest.mark.parametrize("exception,code,message,internal_type", [
(BackendError, 4, "Backend error"), (BackendError, 4, "Backend error", "BackendError"),
(KeyError, 0, "Unknown error") (KeyError, 0, "Unknown error", "UnknownError")
]) ])
async def test_get_game_library_settings_error(exception, code, message, plugin, read, write): async def test_get_game_library_settings_error(exception, code, message, internal_type, plugin, read, write):
plugin.prepare_game_library_settings_context.return_value = async_return_value(None) plugin.prepare_game_library_settings_context.return_value = async_return_value(None)
request = { request = {
"jsonrpc": "2.0", "jsonrpc": "2.0",
@@ -112,7 +112,8 @@ async def test_get_game_library_settings_error(exception, code, message, plugin,
"game_id": "6", "game_id": "6",
"error": { "error": {
"code": code, "code": code,
"message": message "message": message,
"data": {"internal_type": internal_type}
} }
} }
}, },
@@ -144,7 +145,8 @@ async def test_prepare_get_game_library_settings_context_error(plugin, read, wri
"id": "3", "id": "3",
"error": { "error": {
"code": 4, "code": 4,
"message": "Backend error" "message": "Backend error",
"data": {"internal_type": "BackendError"}
} }
} }
] ]
@@ -190,7 +192,8 @@ async def test_import_in_progress(plugin, read, write):
"id": "4", "id": "4",
"error": { "error": {
"code": 600, "code": 600,
"message": "Import already in progress" "message": "Import already in progress",
"data": {"internal_type": "ImportInProgress"}
} }
} in messages } in messages

View File

@@ -3,7 +3,7 @@ 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 BackendError from galaxy.api.errors import BackendError
from galaxy.unittest.mock import async_return_value from galaxy.unittest.mock import async_return_value, skip_loop
from tests import create_message, get_messages from tests import create_message, get_messages
@@ -79,11 +79,11 @@ async def test_get_game_time_success(plugin, read, write):
@pytest.mark.asyncio @pytest.mark.asyncio
@pytest.mark.parametrize("exception,code,message", [ @pytest.mark.parametrize("exception,code,message, internal_type", [
(BackendError, 4, "Backend error"), (BackendError, 4, "Backend error", "BackendError"),
(KeyError, 0, "Unknown error") (KeyError, 0, "Unknown error", "UnknownError")
]) ])
async def test_get_game_time_error(exception, code, message, plugin, read, write): async def test_get_game_time_error(exception, code, message, internal_type, plugin, read, write):
plugin.prepare_game_times_context.return_value = async_return_value(None) plugin.prepare_game_times_context.return_value = async_return_value(None)
request = { request = {
"jsonrpc": "2.0", "jsonrpc": "2.0",
@@ -112,7 +112,8 @@ async def test_get_game_time_error(exception, code, message, plugin, read, write
"game_id": "6", "game_id": "6",
"error": { "error": {
"code": code, "code": code,
"message": message "message": message,
"data" : {"internal_type" : internal_type}
} }
} }
}, },
@@ -144,7 +145,8 @@ async def test_prepare_get_game_time_context_error(plugin, read, write):
"id": "3", "id": "3",
"error": { "error": {
"code": 4, "code": 4,
"message": "Backend error" "message": "Backend error",
"data": {"internal_type": "BackendError"}
} }
} }
] ]
@@ -190,7 +192,8 @@ async def test_import_in_progress(plugin, read, write):
"id": "4", "id": "4",
"error": { "error": {
"code": 600, "code": 600,
"message": "Import already in progress" "message": "Import already in progress",
"data": {"internal_type": "ImportInProgress"}
} }
} in messages } in messages
@@ -199,6 +202,7 @@ async def test_import_in_progress(plugin, read, write):
async def test_update_game(plugin, write): async def test_update_game(plugin, write):
game_time = GameTime("3", 60, 1549550504) game_time = GameTime("3", 60, 1549550504)
plugin.update_game_time(game_time) plugin.update_game_time(game_time)
await skip_loop()
assert get_messages(write) == [ assert get_messages(write) == [
{ {

View File

@@ -3,7 +3,7 @@ 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 from galaxy.unittest.mock import async_return_value, skip_loop
from tests import create_message, get_messages from tests import create_message, get_messages
@@ -51,13 +51,13 @@ async def test_success(plugin, read, write):
@pytest.mark.asyncio @pytest.mark.asyncio
@pytest.mark.parametrize( @pytest.mark.parametrize(
"error,code,message", "error,code,message, internal_type",
[ [
pytest.param(UnknownError, 0, "Unknown error", id="unknown_error"), pytest.param(UnknownError, 0, "Unknown error", "UnknownError", id="unknown_error"),
pytest.param(FailedParsingManifest, 200, "Failed parsing manifest", id="failed_parsing") pytest.param(FailedParsingManifest, 200, "Failed parsing manifest", "FailedParsingManifest", id="failed_parsing")
], ],
) )
async def test_failure(plugin, read, write, error, code, message): async def test_failure(plugin, read, write, error, code, message, internal_type):
request = { request = {
"jsonrpc": "2.0", "jsonrpc": "2.0",
"id": "3", "id": "3",
@@ -74,7 +74,8 @@ async def test_failure(plugin, read, write, error, code, message):
"id": "3", "id": "3",
"error": { "error": {
"code": code, "code": code,
"message": message "message": message,
"data" : {"internal_type" : internal_type}
} }
} }
] ]
@@ -83,6 +84,7 @@ async def test_failure(plugin, read, write, error, code, message):
async def test_local_game_state_update(plugin, write): async def test_local_game_state_update(plugin, write):
game = LocalGame("1", LocalGameState.Running) game = LocalGame("1", LocalGameState.Running)
plugin.update_local_game_status(game) plugin.update_local_game_status(game)
await skip_loop()
assert get_messages(write) == [ assert get_messages(write) == [
{ {

197
tests/test_local_size.py Normal file
View File

@@ -0,0 +1,197 @@
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, 1),
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,internal_type", [
(FailedParsingManifest, 200, "Failed parsing manifest", "FailedParsingManifest"),
(KeyError, 0, "Unknown error", "UnknownError")
])
async def test_get_local_size_error(exception, code, message, internal_type, 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()
direct_response = {
"jsonrpc": "2.0",
"id": request_id,
"result": None
}
responses = get_messages(write)
assert direct_response in responses
responses.remove(direct_response)
assert responses == [
{
"jsonrpc": "2.0",
"method": "local_size_import_failure",
"params": {
"game_id": game_id,
"error": {
"code": code,
"message": message,
"data": {
"internal_type": internal_type
}
}
}
},
{
"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 = {"Details": "Unexpected syntax"}
error_message, error_code = FailedParsingManifest().message, FailedParsingManifest().code
plugin.prepare_local_size_context.side_effect = FailedParsingManifest(data=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": {
"internal_type": "FailedParsingManifest",
"Details": "Unexpected syntax"
}
}
}
]
@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)
plugin.get_local_size.return_value = async_return_value(100, 5)
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",
"data": {"internal_type": "ImportInProgress"}
}
} in responses

View File

@@ -71,11 +71,11 @@ async def test_get_os_compatibility_success(plugin, read, write):
@pytest.mark.asyncio @pytest.mark.asyncio
@pytest.mark.parametrize("exception,code,message", [ @pytest.mark.parametrize("exception,code,message,internal_type", [
(BackendError, 4, "Backend error"), (BackendError, 4, "Backend error", "BackendError"),
(KeyError, 0, "Unknown error") (KeyError, 0, "Unknown error", "UnknownError")
]) ])
async def test_get_os_compatibility_error(exception, code, message, plugin, read, write): async def test_get_os_compatibility_error(exception, code, message, internal_type, plugin, read, write):
game_id = "6" game_id = "6"
request_id = "55" request_id = "55"
plugin.prepare_os_compatibility_context.return_value = async_return_value(None) plugin.prepare_os_compatibility_context.return_value = async_return_value(None)
@@ -104,7 +104,8 @@ async def test_get_os_compatibility_error(exception, code, message, plugin, read
"game_id": game_id, "game_id": game_id,
"error": { "error": {
"code": code, "code": code,
"message": message "message": message,
"data": {"internal_type": internal_type}
} }
} }
}, },
@@ -135,7 +136,8 @@ async def test_prepare_get_os_compatibility_context_error(plugin, read, write):
"id": request_id, "id": request_id,
"error": { "error": {
"code": 4, "code": 4,
"message": "Backend error" "message": "Backend error",
"data": {"internal_type": "BackendError"}
} }
} }
] ]
@@ -181,7 +183,8 @@ async def test_import_already_in_progress_error(plugin, read, write):
"id": "4", "id": "4",
"error": { "error": {
"code": 600, "code": 600,
"message": "Import already in progress" "message": "Import already in progress",
"data": {"internal_type": "ImportInProgress"}
} }
} in responses } in responses

View File

@@ -3,7 +3,7 @@ import pytest
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 from galaxy.unittest.mock import async_return_value, skip_loop
from tests import create_message, get_messages from tests import create_message, get_messages
@@ -90,7 +90,8 @@ async def test_failure(plugin, read, write):
"id": "3", "id": "3",
"error": { "error": {
"code": 0, "code": 0,
"message": "Unknown error" "message": "Unknown error",
"data": {"internal_type": "UnknownError"}
} }
} }
] ]
@@ -100,6 +101,7 @@ async def test_failure(plugin, read, write):
async def test_add_game(plugin, write): 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) plugin.add_game(game)
await skip_loop()
assert get_messages(write) == [ assert get_messages(write) == [
{ {
"jsonrpc": "2.0", "jsonrpc": "2.0",
@@ -120,6 +122,7 @@ async def test_add_game(plugin, write):
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_remove_game(plugin, write): async def test_remove_game(plugin, write):
plugin.remove_game("5") plugin.remove_game("5")
await skip_loop()
assert get_messages(write) == [ assert get_messages(write) == [
{ {
"jsonrpc": "2.0", "jsonrpc": "2.0",
@@ -135,6 +138,7 @@ async def test_remove_game(plugin, write):
async def test_update_game(plugin, write): 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) plugin.update_game(game)
await skip_loop()
assert get_messages(write) == [ assert get_messages(write) == [
{ {
"jsonrpc": "2.0", "jsonrpc": "2.0",

View File

@@ -1,6 +1,6 @@
import pytest import pytest
from galaxy.unittest.mock import async_return_value from galaxy.unittest.mock import async_return_value, skip_loop
from tests import create_message, get_messages from tests import create_message, get_messages
@@ -57,6 +57,7 @@ async def test_set_cache(plugin, write, cache_data):
plugin.persistent_cache.update(cache_data) plugin.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
@@ -68,6 +69,7 @@ async def test_clear_cache(plugin, write, 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

View File

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

345
tests/test_subscriptions.py Normal file
View File

@@ -0,0 +1,345 @@
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,internal_type",
[
pytest.param(UnknownError, 0, "Unknown error", "UnknownError", id="unknown_error"),
pytest.param(FailedParsingManifest, 200, "Failed parsing manifest", "FailedParsingManifest", id="failed_parsing")
],
)
async def test_get_subscriptions_failure_generic(plugin, read, write, error, code, message, internal_type):
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,
"data": {"internal_type": internal_type},
"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,internal_type", [
(BackendError, 4, "Backend error", "BackendError"),
(KeyError, 0, "Unknown error", "UnknownError")
])
async def test_get_subscription_games_error(exception, code, message, internal_type, 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,
"data": {"internal_type": internal_type}
}
}
},
{
'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 = {"Details": "Unexpected backend error"}
error_message, error_code = BackendError().message, BackendError().code
plugin.prepare_subscription_games_context.side_effect = BackendError(data=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": {
"internal_type": "BackendError",
"Details": "Unexpected backend error"
}
}
}
]
@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",
"data": {"internal_type": "ImportInProgress"}
}
} in responses

283
tests/test_user_presence.py Normal file
View File

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