Compare commits

..

29 Commits
0.39 ... 0.41

Author SHA1 Message Date
Romuald Juchnowicz-Bierbasz
630d878a3c Use constants 2019-07-12 11:46:48 +02:00
Romuald Juchnowicz-Bierbasz
cc63c24bde Increment version 2019-07-12 11:46:48 +02:00
Romuald Juchnowicz-Bierbasz
0d0f657240 SDK-2930: Refactor http module 2019-07-12 11:46:48 +02:00
Steven M. Vascellaro
33c630225d README.md cleanup (#17) 2019-07-12 10:39:37 +02:00
apaulouski
f4bd18a8ab Merge pull request #2 from rogersachan/rogersachan-patch-1
Fix spelling errors in the README
2019-07-10 12:12:07 +02:00
Roger
fa4541434f Merge branch 'master' into rogersachan-patch-1 2019-07-09 13:30:35 -04:00
rbierbasz-gog
c083a3089a Create .travis.yml 2019-07-08 15:43:12 +02:00
Romuald Juchnowicz-Bierbasz
f6b5a12b24 SDK-2932: Remove github deployment (use mirroring) 2019-07-03 13:42:06 +02:00
Romuald Juchnowicz-Bierbasz
8a67747df5 Merge remote-tracking branch 'github/master'
* github/master:
  version 0.38
  version 0.35.2
  version 0.35.1
  version 0.34
  version 0.33.1
  version 0.33
  version 0.32.1
  version 0.32.0
  version 0.31.3
  version 0.31.2
  version 0.31.1
  Initial commit
2019-07-03 13:35:34 +02:00
Romuald Juchnowicz-Bierbasz
2db9d0f383 Increment version 2019-07-01 14:35:05 +02:00
Mieszko Banczerowski
9d93762867 Workaround for removing creds on push_cache 2019-07-01 14:32:23 +02:00
Romuald Juchnowicz-Bierbasz
c364b716f4 Increment version 2019-07-01 13:16:08 +02:00
Romuald Juchnowicz-Bierbasz
48e1782484 SDK-2893: Optional game time and last played 2019-07-01 13:14:07 +02:00
Romuald Juchnowicz-Bierbasz
ff30675a25 Do not invoke tick before handshake 2019-07-01 12:26:05 +02:00
Aliaksei Paulouski
7b3965ff4b Add poe platform 2019-06-28 15:09:46 +02:00
Piotr Marzec
2ebdfabd9b Path of Exile added 2019-06-28 14:49:56 +02:00
GOG Galaxy SDK Team
f1fd00fcd3 version 0.38 2019-06-26 12:46:23 +02:00
Roger
1edf4ff5ba Fix spelling errors 2019-06-19 15:39:22 -04:00
GOG Galaxy SDK Team
9d5d48032e version 0.35.2 2019-06-17 18:11:24 +02:00
GOG Galaxy SDK Team
179fd147c1 version 0.35.1 2019-06-17 17:41:44 +02:00
GOG Galaxy SDK Team
7789927ed9 version 0.34 2019-06-14 16:54:11 +02:00
GOG Galaxy SDK Team
e2f26271cb version 0.33.1 2019-06-14 14:49:25 +02:00
GOG Galaxy SDK Team
3bd0b71ab3 version 0.33 2019-06-13 12:30:12 +02:00
GOG Galaxy SDK Team
192d655d51 version 0.32.1 2019-06-10 19:04:08 +02:00
GOG Galaxy SDK Team
6c6dc42cd6 version 0.32.0 2019-06-07 15:08:47 +02:00
GOG Galaxy SDK Team
f97b6c8971 version 0.31.3 2019-05-31 12:09:18 +02:00
GOG Galaxy SDK Team
0af7387342 version 0.31.2 2019-05-31 11:53:15 +02:00
GOG Galaxy SDK Team
60fab25a55 version 0.31.1 2019-05-29 13:09:13 +02:00
GOG Galaxy SDK Team
6f717a1e31 Initial commit 2019-05-29 13:08:20 +02:00
15 changed files with 166 additions and 108 deletions

8
.travis.yml Normal file
View File

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

View File

@@ -79,4 +79,4 @@ Platform ID list for GOG Galaxy 2.0 Integrations
| psvita | Playstation Vita |
| nds | Nintendo DS |
| 3ds | Nintendo 3DS |
| pathofexile | Path of Exile |

View File

@@ -1,35 +1,35 @@
# GOG Galaxy Integrations Python API
This Python library allows to easily build community integrations for various gaming platforms with GOG Galaxy 2.0.
This Python library allows developers to easily build community integrations for various gaming platforms with GOG Galaxy 2.0.
- refer to our <a href='https://galaxy-integrations-python-api.readthedocs.io'>documentation</a>
## Features
Each integration in GOG Galaxy 2.0 comes as a separate Python script, and is launched as a separate process, that which needs to communicate with main instance of GOG Galaxy 2.0.
Each integration in GOG Galaxy 2.0 comes as a separate Python script and is launched as a separate process that needs to communicate with the main instance of GOG Galaxy 2.0.
The provided features are:
- multistep authorisation using a browser built into GOG Galaxy 2.0
- multistep authorization using a browser built into GOG Galaxy 2.0
- support for GOG Galaxy 2.0 features:
- importing owned and detecting installed games
- installing and launching games
- importing achievements and game time
- importing friends lists and statuses
- importing friends recomendations list
- receiving and sending chat messages
- importing owned and detecting installed games
- installing and launching games
- importing achievements and game time
- importing friends lists and statuses
- importing friends recommendations list
- receiving and sending chat messages
- cache storage
## Platform Id's
Each integration can implement only one platform. Each integration must declare which platform it's integrating.
[List of possible Platofrm IDs](PLATFORM_IDs.md)
[List of possible Platform IDs](PLATFORM_IDs.md)
## Basic usage
Eeach integration should inherit from the :class:`~galaxy.api.plugin.Plugin` class. Supported methods like :meth:`~galaxy.api.plugin.Plugin.get_owned_games` should be overwritten - they are called from the GOG Galaxy client in the appropriate times.
Each of those method can raise exceptions inherited from the :exc:`~galaxy.api.jsonrpc.ApplicationError`.
Each integration should inherit from the :class:`~galaxy.api.plugin.Plugin` class. Supported methods like :meth:`~galaxy.api.plugin.Plugin.get_owned_games` should be overwritten - they are called from the GOG Galaxy client at the appropriate times.
Each of those methods can raise exceptions inherited from the :exc:`~galaxy.api.jsonrpc.ApplicationError`.
Communication between an integration and the client is also possible with the use of notifications, for example: :meth:`~galaxy.api.plugin.Plugin.update_local_game_status`.
```python
@@ -61,11 +61,13 @@ if __name__ == "__main__":
## Deployment
The client has a built-in Python 3.7 interpreter, so the integrations are delivered as python modules.
In order to be found by GOG Galaxy 2.0 an integration folder should be placed in [lookup directory](#deploy-location). Beside all the python files, the integration folder has to contain [manifest.json](#deploy-manifest) and all third-party dependencies. See an [examplary structure](#deploy-structure-example).
The client has a built-in Python 3.7 interpreter, so integrations are delivered as Python modules.
In order to be found by GOG Galaxy 2.0 an integration folder should be placed in [lookup directory](#deploy-location). Beside all the Python files, the integration folder must contain [manifest.json](#deploy-manifest) and all third-party dependencies. See an [exemplary structure](#deploy-structure-example).
### Lookup directory
<a name="deploy-location"></a>
- Windows:
`%localappdata%\GOG.com\Galaxy\plugins\installed`
@@ -75,8 +77,9 @@ 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`
### Manifest
<a name="deploy-manifest"></a>
Obligatory JSON file to be placed in a integration folder.
<a name="deploy-manifest"></a>
Obligatory JSON file to be placed in an integration folder.
```json
{
@@ -91,6 +94,7 @@ Obligatory JSON file to be placed in a integration folder.
"script": "plugin.py"
}
```
| property | description |
|---------------|---|
| `guid` | |
@@ -99,13 +103,15 @@ Obligatory JSON file to be placed in a integration folder.
| `script` | path of the entry point module, relative to the integration folder |
### Dependencies
All third-party packages (packages not included in Python 3.7 standard library) should be deployed along with plugin files. Use the folowing command structure:
All third-party packages (packages not included in the Python 3.7 standard library) should be deployed along with plugin files. Use the following command structure:
```pip install DEP --target DIR --implementation cp --python-version 37```
For example plugin that uses *requests* has structure as follows:
For example, a plugin that uses *requests* could have the following structure:
<a name="deploy-structure-example"></a>
```bash
installed
└── my_integration

View File

@@ -1,14 +0,0 @@
stage('Upload to github')
{
node('ActiveClientMacosxBuilder') {
deleteDir()
checkout scm
withPythonEnv('/usr/local/bin/python3.7') {
withCredentials([string(credentialsId: 'github_goggalaxy', variable: 'GITHUB_TOKEN')]) {
sh 'pip install -r jenkins/requirements.txt'
def version = sh(returnStdout: true, script: 'python setup.py --version').trim()
sh "python jenkins/release.py $version"
}
}
}
}

View File

@@ -1,26 +0,0 @@
import os
import sys
from galaxy.github.exporter import transfer_repo
GITHUB_USERNAME = "goggalaxy"
GITHUB_EMAIL = "galaxy-sdk@gog.com"
GITHUB_TOKEN = os.environ["GITHUB_TOKEN"]
GITHUB_REPO_NAME = "galaxy-integrations-python-api"
SOURCE_BRANCH = os.environ["GIT_REFSPEC"]
GITLAB_USERNAME = "galaxy-client"
GITLAB_REPO_NAME = "galaxy-plugin-api"
def version_provider(_):
return sys.argv[1]
gh_version = transfer_repo(
version_provider=version_provider,
source_repo_spec="git@gitlab.gog.com:{}/{}.git".format(GITLAB_USERNAME, GITLAB_REPO_NAME),
source_include_elements=["src", "docs", "tests", "requirements.txt", ".readthedocs.yml" ".gitignore", "*.md", "pytest.ini", "setup.py"],
source_branch=SOURCE_BRANCH,
dest_repo_spec="https://{}:{}@github.com/{}/{}.git".format(GITHUB_USERNAME, GITHUB_TOKEN, "gogcom", GITHUB_REPO_NAME),
dest_branch="master",
dest_user_email=GITHUB_EMAIL,
dest_user_name="GOG Galaxy SDK Team"
)

View File

@@ -1 +0,0 @@
git+ssh://git@gitlab.gog.com/galaxy-client/github-exporter.git@v0.1

View File

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

View File

@@ -80,6 +80,7 @@ class Platform(Enum):
PlayStationVita = "psvita"
NintendoDs = "nds"
Nintendo3Ds = "3ds"
PathOfExile = "pathofexile"
class Feature(Enum):
"""Possible features that can be implemented by an integration.

View File

@@ -116,6 +116,7 @@ class Server():
data = data.strip()
logging.debug("Received %d bytes of data", len(data))
self._handle_input(data)
await asyncio.sleep(0) # To not starve task queue
def stop(self):
self._active = False

View File

@@ -38,6 +38,7 @@ class Plugin:
self._feature_methods = OrderedDict()
self._active = True
self._pass_control_task = None
self._reader, self._writer = reader, writer
self._handshake_token = handshake_token
@@ -210,15 +211,17 @@ class Plugin:
async def run(self):
"""Plugin's main coroutine."""
async def pass_control():
while self._active:
try:
self.tick()
except Exception:
logging.exception("Unexpected exception raised in plugin tick")
await asyncio.sleep(1)
await self._server.run()
if self._pass_control_task is not None:
await self._pass_control_task
await asyncio.gather(pass_control(), self._server.run())
async def _pass_control(self):
while self._active:
try:
self.tick()
except Exception:
logging.exception("Unexpected exception raised in plugin tick")
await asyncio.sleep(1)
def _shutdown(self):
logging.info("Shutting down")
@@ -236,6 +239,7 @@ class Plugin:
def _initialize_cache(self, data: Dict):
self._persistent_cache = data
self.handshake_complete()
self._pass_control_task = asyncio.create_task(self._pass_control())
@staticmethod
def _ping():
@@ -264,6 +268,7 @@ class Plugin:
return Authentication(user_data['userId'], user_data['username'])
"""
self.persistent_cache['credentials'] = credentials
self._notification_client.notify("store_credentials", credentials, sensitive_params=True)
def add_game(self, game: Game) -> None:

View File

@@ -204,5 +204,5 @@ class GameTime():
:param last_time_played: last time the game was played (**unix timestamp**)
"""
game_id: str
time_played: int
last_played_time: int
time_played: Optional[int]
last_played_time: Optional[int]

View File

@@ -1,5 +1,6 @@
import asyncio
import ssl
from contextlib import contextmanager
from http import HTTPStatus
import aiohttp
@@ -12,44 +13,69 @@ from galaxy.api.errors import (
)
DEFAULT_LIMIT = 20
DEFAULT_TIMEOUT = 60 # seconds
class HttpClient:
def __init__(self, limit=20, timeout=aiohttp.ClientTimeout(total=60), cookie_jar=None):
ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
ssl_context.load_verify_locations(certifi.where())
connector = aiohttp.TCPConnector(limit=limit, ssl=ssl_context)
self._session = aiohttp.ClientSession(connector=connector, timeout=timeout, cookie_jar=cookie_jar)
"""Deprecated"""
def __init__(self, limit=DEFAULT_LIMIT, timeout=aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT), cookie_jar=None):
connector = create_tcp_connector(limit=limit)
self._session = create_client_session(connector=connector, timeout=timeout, cookie_jar=cookie_jar)
async def close(self):
await self._session.close()
async def request(self, method, url, *args, **kwargs):
try:
response = await self._session.request(method, url, *args, **kwargs)
except asyncio.TimeoutError:
raise BackendTimeout()
except aiohttp.ServerDisconnectedError:
raise BackendNotAvailable()
except aiohttp.ClientConnectionError:
raise NetworkError()
except aiohttp.ContentTypeError:
raise UnknownBackendResponse()
except aiohttp.ClientError:
logging.exception(
"Caught exception while running {} request for {}".format(method, url))
raise UnknownError()
if response.status == HTTPStatus.UNAUTHORIZED:
raise AuthenticationRequired()
if response.status == HTTPStatus.FORBIDDEN:
raise AccessDenied()
if response.status == HTTPStatus.SERVICE_UNAVAILABLE:
raise BackendNotAvailable()
if response.status == HTTPStatus.TOO_MANY_REQUESTS:
raise TooManyRequests()
if response.status >= 500:
raise BackendError()
if response.status >= 400:
logging.warning(
"Got status {} while running {} request for {}".format(response.status, method, url))
raise UnknownError()
with handle_exception():
return await self._session.request(method, url, *args, **kwargs)
def create_tcp_connector(*args, **kwargs):
ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
ssl_context.load_verify_locations(certifi.where())
kwargs.setdefault("ssl", ssl_context)
kwargs.setdefault("limit", DEFAULT_LIMIT)
return aiohttp.TCPConnector(*args, **kwargs)
def create_client_session(*args, **kwargs):
kwargs.setdefault("connector", create_tcp_connector())
kwargs.setdefault("timeout", aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT))
kwargs.setdefault("raise_for_status", True)
return aiohttp.ClientSession(*args, **kwargs)
@contextmanager
def handle_exception():
try:
yield
except asyncio.TimeoutError:
raise BackendTimeout()
except aiohttp.ServerDisconnectedError:
raise BackendNotAvailable()
except aiohttp.ClientConnectionError:
raise NetworkError()
except aiohttp.ContentTypeError:
raise UnknownBackendResponse()
except aiohttp.ClientResponseError as error:
if error.status == HTTPStatus.UNAUTHORIZED:
raise AuthenticationRequired()
if error.status == HTTPStatus.FORBIDDEN:
raise AccessDenied()
if error.status == HTTPStatus.SERVICE_UNAVAILABLE:
raise BackendNotAvailable()
if error.status == HTTPStatus.TOO_MANY_REQUESTS:
raise TooManyRequests()
if error.status >= 500:
raise BackendError()
if error.status >= 400:
logging.warning(
"Got status %d while performing %s request for %s",
error.status, error.request_info.method, str(error.request_info.url)
)
raise UnknownError()
except aiohttp.ClientError:
logging.exception("Caught exception while performing request")
raise UnknownError()
return response

View File

@@ -16,7 +16,8 @@ def test_success(plugin, read, write):
read.side_effect = [json.dumps(request).encode() + b"\n", b""]
plugin.get_game_times.coro.return_value = [
GameTime("3", 60, 1549550504),
GameTime("5", 10, 1549550502)
GameTime("5", 10, None),
GameTime("7", None, 1549550502),
]
asyncio.run(plugin.run())
plugin.get_game_times.assert_called_with()
@@ -35,7 +36,10 @@ def test_success(plugin, read, write):
{
"game_id": "5",
"time_played": 10,
"last_played_time": 1549550502
},
{
"game_id": "7",
"last_played_time": 1549550502
}
]
}

37
tests/test_http.py Normal file
View File

@@ -0,0 +1,37 @@
import asyncio
from http import HTTPStatus
import aiohttp
import pytest
from galaxy.api.errors import (
AccessDenied, AuthenticationRequired, BackendTimeout, BackendNotAvailable, BackendError, NetworkError,
TooManyRequests, UnknownBackendResponse, UnknownError
)
from galaxy.http import handle_exception
request_info = aiohttp.RequestInfo("http://o.pl", "GET", {})
@pytest.mark.parametrize(
"aiohttp_exception,expected_exception_type",
[
(asyncio.TimeoutError(), BackendTimeout),
(aiohttp.ServerDisconnectedError(), BackendNotAvailable),
(aiohttp.ClientConnectionError(), NetworkError),
(aiohttp.ContentTypeError(request_info, []), UnknownBackendResponse),
(aiohttp.ClientResponseError(request_info, [], status=HTTPStatus.UNAUTHORIZED), AuthenticationRequired),
(aiohttp.ClientResponseError(request_info, [], status=HTTPStatus.FORBIDDEN), AccessDenied),
(aiohttp.ClientResponseError(request_info, [], status=HTTPStatus.SERVICE_UNAVAILABLE), BackendNotAvailable),
(aiohttp.ClientResponseError(request_info, [], status=HTTPStatus.TOO_MANY_REQUESTS), TooManyRequests),
(aiohttp.ClientResponseError(request_info, [], status=HTTPStatus.INTERNAL_SERVER_ERROR), BackendError),
(aiohttp.ClientResponseError(request_info, [], status=HTTPStatus.NOT_IMPLEMENTED), BackendError),
(aiohttp.ClientResponseError(request_info, [], status=HTTPStatus.BAD_REQUEST), UnknownError),
(aiohttp.ClientResponseError(request_info, [], status=HTTPStatus.NOT_FOUND), UnknownError),
(aiohttp.ClientError(), UnknownError)
]
)
def test_handle_exception(aiohttp_exception, expected_exception_type):
with pytest.raises(expected_exception_type):
with handle_exception():
raise aiohttp_exception

View File

@@ -62,7 +62,18 @@ def test_ping(plugin, read, write):
"result": None
}
def test_tick(plugin, read):
def test_tick_before_handshake(plugin, read):
read.side_effect = [b""]
asyncio.run(plugin.run())
plugin.tick.assert_not_called()
def test_tick_after_handshake(plugin, read):
request = {
"jsonrpc": "2.0",
"id": "6",
"method": "initialize_cache",
"params": {"data": {}}
}
read.side_effect = [json.dumps(request).encode() + b"\n", b""]
asyncio.run(plugin.run())
plugin.tick.assert_called_with()