mirror of
https://github.com/gogcom/galaxy-integrations-python-api.git
synced 2025-12-31 19:08:16 -05:00
Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
52273e2f8c | ||
|
|
bda867473c | ||
|
|
6885cdc439 | ||
|
|
88e25a93be | ||
|
|
67e7a4c0b2 | ||
|
|
788d2550e6 | ||
|
|
059a1ea343 | ||
|
|
300ade5d43 | ||
|
|
43556a0470 | ||
|
|
e244d3bb44 | ||
|
|
d6e6efc633 | ||
|
|
a114c9721c | ||
|
|
6c0389834b | ||
|
|
bc7d1c2914 | ||
|
|
d69e1aaa08 | ||
|
|
c2a0534162 | ||
|
|
1614fd6eb2 | ||
|
|
48e54a8460 | ||
|
|
70a1d5cd1f | ||
|
|
853ecf1d3b |
@@ -20,5 +20,7 @@ deploy_package:
|
||||
- curl -X POST --silent --show-error --fail
|
||||
"https://gitlab.gog.com/api/v4/projects/${CI_PROJECT_ID}/repository/tags?tag_name=${VERSION}&ref=${CI_COMMIT_REF_NAME}&private_token=${PACKAGE_DEPLOYER_API_TOKEN}"
|
||||
when: manual
|
||||
only:
|
||||
- master
|
||||
except:
|
||||
- tags
|
||||
@@ -30,4 +30,9 @@ pip install -r requirements.txt
|
||||
Run tests:
|
||||
```bash
|
||||
pytest
|
||||
```
|
||||
```
|
||||
|
||||
## Changelog
|
||||
|
||||
### 0.14
|
||||
* Added required version parameter to Plugin constructor.
|
||||
@@ -12,6 +12,7 @@ class Platform(Enum):
|
||||
Battlenet = "battlenet"
|
||||
|
||||
class Feature(Enum):
|
||||
Unknown = "Unknown"
|
||||
ImportInstalledGames = "ImportInstalledGames"
|
||||
ImportOwnedGames = "ImportOwnedGames"
|
||||
LaunchGame = "LaunchGame"
|
||||
@@ -23,11 +24,19 @@ class Feature(Enum):
|
||||
ImportUsers = "ImportUsers"
|
||||
VerifyGame = "VerifyGame"
|
||||
|
||||
class LicenseType(Enum):
|
||||
Unknown = "Unknown"
|
||||
SinglePurchase = "SinglePurchase"
|
||||
FreeToPlay = "FreeToPlay"
|
||||
OtherUserLicense = "OtherUserLicense"
|
||||
|
||||
class LocalGameState(Enum):
|
||||
None_ = "None"
|
||||
Installed = "Installed"
|
||||
Running = "Running"
|
||||
|
||||
class PresenceState(Enum):
|
||||
Unknown = "Unknown"
|
||||
Online = "online"
|
||||
Offline = "offline"
|
||||
Away = "away"
|
||||
|
||||
@@ -20,6 +20,10 @@ class BackendError(ApplicationError):
|
||||
def __init__(self, data=None):
|
||||
super().__init__(4, "Backend error", data)
|
||||
|
||||
class UnknownBackendResponse(ApplicationError):
|
||||
def __init__(self, data=None):
|
||||
super().__init__(4, "Backend responded in uknown way", data)
|
||||
|
||||
class InvalidCredentials(ApplicationError):
|
||||
def __init__(self, data=None):
|
||||
super().__init__(100, "Invalid credentials", data)
|
||||
|
||||
@@ -64,10 +64,12 @@ class Server():
|
||||
|
||||
async def run(self):
|
||||
while self._active:
|
||||
data = await self._reader.readline()
|
||||
if not data:
|
||||
# on windows rederecting a pipe to stdin result on continues
|
||||
# not-blocking return of empty line on EOF
|
||||
try:
|
||||
data = await self._reader.readline()
|
||||
if not data:
|
||||
self._eof()
|
||||
continue
|
||||
except:
|
||||
self._eof()
|
||||
continue
|
||||
data = data.strip()
|
||||
@@ -102,6 +104,7 @@ class Server():
|
||||
method = self._notifications.get(request.method)
|
||||
if not method:
|
||||
logging.error("Received uknown notification: %s", request.method)
|
||||
return
|
||||
|
||||
callback, internal = method
|
||||
if internal:
|
||||
@@ -141,15 +144,15 @@ class Server():
|
||||
self._send_error(request.id, MethodNotFound())
|
||||
except JsonRpcError as error:
|
||||
self._send_error(request.id, error)
|
||||
except Exception as error: #pylint: disable=broad-except
|
||||
logging.error("Unexpected exception raised in plugin handler: %s", repr(error))
|
||||
except Exception: #pylint: disable=broad-except
|
||||
logging.exception("Unexpected exception raised in plugin handler")
|
||||
|
||||
asyncio.create_task(handle())
|
||||
|
||||
@staticmethod
|
||||
def _parse_request(data):
|
||||
try:
|
||||
jsonrpc_request = json.loads(data)
|
||||
jsonrpc_request = json.loads(data, encoding="utf-8")
|
||||
if jsonrpc_request.get("jsonrpc") != "2.0":
|
||||
raise InvalidRequest()
|
||||
del jsonrpc_request["jsonrpc"]
|
||||
@@ -163,7 +166,7 @@ class Server():
|
||||
try:
|
||||
line = self._encoder.encode(data)
|
||||
logging.debug("Sending data: %s", line)
|
||||
self._writer.write(line + "\n")
|
||||
self._writer.write((line + "\n").encode("utf-8"))
|
||||
asyncio.create_task(self._writer.drain())
|
||||
except TypeError as error:
|
||||
logging.error(str(error))
|
||||
@@ -209,7 +212,7 @@ class NotificationClient():
|
||||
try:
|
||||
line = self._encoder.encode(data)
|
||||
logging.debug("Sending data: %s", line)
|
||||
self._writer.write(line + "\n")
|
||||
self._writer.write((line + "\n").encode("utf-8"))
|
||||
asyncio.create_task(self._writer.drain())
|
||||
except TypeError as error:
|
||||
logging.error("Failed to parse outgoing message: %s", str(error))
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import logging.handlers
|
||||
import dataclasses
|
||||
from enum import Enum
|
||||
from collections import OrderedDict
|
||||
import sys
|
||||
import os
|
||||
|
||||
from galaxy.api.jsonrpc import Server, NotificationClient
|
||||
from galaxy.api.stream import stdio
|
||||
from galaxy.api.consts import Feature
|
||||
|
||||
class JSONEncoder(json.JSONEncoder):
|
||||
@@ -21,13 +23,16 @@ class JSONEncoder(json.JSONEncoder):
|
||||
return super().default(o)
|
||||
|
||||
class Plugin():
|
||||
def __init__(self, platform):
|
||||
def __init__(self, platform, version, reader, writer, handshake_token):
|
||||
logging.info("Creating plugin for platform %s, version %s", platform.value, version)
|
||||
self._platform = platform
|
||||
self._version = version
|
||||
|
||||
self._feature_methods = OrderedDict()
|
||||
self._active = True
|
||||
|
||||
self._reader, self._writer = stdio()
|
||||
self._reader, self._writer = reader, writer
|
||||
self._handshake_token = handshake_token
|
||||
|
||||
encoder = JSONEncoder()
|
||||
self._server = Server(self._reader, self._writer, encoder)
|
||||
@@ -167,7 +172,10 @@ class Plugin():
|
||||
async def pass_control():
|
||||
while self._active:
|
||||
logging.debug("Passing control to plugin")
|
||||
self.tick()
|
||||
try:
|
||||
self.tick()
|
||||
except Exception:
|
||||
logging.exception("Unexpected exception raised in plugin tick")
|
||||
await asyncio.sleep(1)
|
||||
|
||||
await asyncio.gather(pass_control(), self._server.run())
|
||||
@@ -181,7 +189,8 @@ class Plugin():
|
||||
def _get_capabilities(self):
|
||||
return {
|
||||
"platform_name": self._platform,
|
||||
"features": self.features
|
||||
"features": self.features,
|
||||
"token": self._handshake_token
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
@@ -307,3 +316,57 @@ class Plugin():
|
||||
|
||||
async def get_game_times(self):
|
||||
raise NotImplementedError()
|
||||
|
||||
def _prepare_logging(logger_file):
|
||||
root = logging.getLogger()
|
||||
root.setLevel(logging.DEBUG)
|
||||
if logger_file:
|
||||
# ensure destination folder exists
|
||||
os.makedirs(os.path.dirname(os.path.abspath(logger_file)), exist_ok=True)
|
||||
handler = logging.handlers.RotatingFileHandler(
|
||||
logger_file,
|
||||
mode="a",
|
||||
maxBytes=10000000,
|
||||
backupCount=10,
|
||||
encoding="utf-8"
|
||||
)
|
||||
else:
|
||||
handler = logging.StreamHandler()
|
||||
formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s')
|
||||
handler.setFormatter(formatter)
|
||||
root.addHandler(handler)
|
||||
|
||||
def create_and_run_plugin(plugin_class, argv):
|
||||
logger_file = argv[3] if len(argv) >= 4 else None
|
||||
_prepare_logging(logger_file)
|
||||
|
||||
if len(argv) < 3:
|
||||
logging.critical("Not enough parameters, required: token, port")
|
||||
sys.exit(1)
|
||||
|
||||
token = argv[1]
|
||||
|
||||
try:
|
||||
port = int(argv[2])
|
||||
except ValueError:
|
||||
logging.critical("Failed to parse port value: %s", argv[2])
|
||||
sys.exit(2)
|
||||
|
||||
if not (1 <= port <= 65535):
|
||||
logging.critical("Port value out of range (1, 65535)")
|
||||
sys.exit(3)
|
||||
|
||||
if not issubclass(plugin_class, Plugin):
|
||||
logging.critical("plugin_class must be subclass of Plugin")
|
||||
sys.exit(4)
|
||||
|
||||
async def coroutine():
|
||||
reader, writer = await asyncio.open_connection("127.0.0.1", port)
|
||||
plugin = plugin_class(reader, writer, token)
|
||||
await plugin.run()
|
||||
|
||||
try:
|
||||
asyncio.run(coroutine())
|
||||
except Exception:
|
||||
logging.exception("Error while running plugin")
|
||||
sys.exit(5)
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
import asyncio
|
||||
import sys
|
||||
|
||||
class StdinReader():
|
||||
def __init__(self):
|
||||
self._stdin = sys.stdin.buffer
|
||||
|
||||
async def readline(self):
|
||||
# a single call to sys.stdin.readline() is thread-safe
|
||||
loop = asyncio.get_running_loop()
|
||||
return await loop.run_in_executor(None, self._stdin.readline)
|
||||
|
||||
class StdoutWriter():
|
||||
def __init__(self):
|
||||
self._buffer = []
|
||||
self._stdout = sys.stdout.buffer
|
||||
|
||||
def write(self, data):
|
||||
self._buffer.append(data)
|
||||
|
||||
async def drain(self):
|
||||
data, self._buffer = self._buffer, []
|
||||
# a single call to sys.stdout.writelines() is thread-safe
|
||||
def write(data):
|
||||
sys.stdout.writelines(data)
|
||||
sys.stdout.flush()
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
return await loop.run_in_executor(None, write, data)
|
||||
|
||||
def stdio():
|
||||
# no support for asyncio stdio yet on Windows, see https://bugs.python.org/issue26832
|
||||
# use an executor to read from stdio and write to stdout
|
||||
# note: if nothing ever drains the writer explicitly, no flushing ever takes place!
|
||||
return StdinReader(), StdoutWriter()
|
||||
@@ -1,7 +1,7 @@
|
||||
from dataclasses import dataclass
|
||||
from typing import List
|
||||
from typing import List, Optional
|
||||
|
||||
from galaxy.api.consts import LocalGameState, PresenceState
|
||||
from galaxy.api.consts import LicenseType, LocalGameState, PresenceState
|
||||
|
||||
@dataclass
|
||||
class Authentication():
|
||||
@@ -10,8 +10,8 @@ class Authentication():
|
||||
|
||||
@dataclass
|
||||
class LicenseInfo():
|
||||
license_type: str
|
||||
owner: str = None
|
||||
license_type: LicenseType
|
||||
owner: Optional[str] = None
|
||||
|
||||
@dataclass
|
||||
class Dlc():
|
||||
@@ -28,8 +28,13 @@ class Game():
|
||||
|
||||
@dataclass
|
||||
class Achievement():
|
||||
achievement_id: str
|
||||
unlock_time: int
|
||||
achievement_id: Optional[str] = None
|
||||
achievement_name: Optional[str] = None
|
||||
|
||||
def __post_init__(self):
|
||||
assert self.achievement_id or self.achievement_name, \
|
||||
"One of achievement_id or achievement_name is required"
|
||||
|
||||
@dataclass
|
||||
class LocalGame():
|
||||
@@ -39,8 +44,8 @@ class LocalGame():
|
||||
@dataclass
|
||||
class Presence():
|
||||
presence_state: PresenceState
|
||||
game_id: str = None
|
||||
presence_status: str = None
|
||||
game_id: Optional[str] = None
|
||||
presence_status: Optional[str] = None
|
||||
|
||||
@dataclass
|
||||
class UserInfo():
|
||||
|
||||
2
setup.py
2
setup.py
@@ -2,7 +2,7 @@ from setuptools import setup, find_packages
|
||||
|
||||
setup(
|
||||
name="galaxy.plugin.api",
|
||||
version="0.3",
|
||||
version="0.14",
|
||||
description="Galaxy python plugin API",
|
||||
author='Galaxy team',
|
||||
author_email='galaxy@gog.com',
|
||||
|
||||
@@ -1,16 +1,36 @@
|
||||
from contextlib import ExitStack
|
||||
import logging
|
||||
from unittest.mock import patch
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from galaxy.api.plugin import Plugin
|
||||
from galaxy.api.stream import StdinReader, StdoutWriter
|
||||
from galaxy.api.consts import Platform
|
||||
from tests.async_mock import AsyncMock
|
||||
|
||||
@pytest.fixture()
|
||||
def plugin():
|
||||
def reader():
|
||||
stream = MagicMock(name="stream_reader")
|
||||
stream.readline = AsyncMock()
|
||||
yield stream
|
||||
|
||||
@pytest.fixture()
|
||||
def writer():
|
||||
stream = MagicMock(name="stream_writer")
|
||||
stream.write = MagicMock()
|
||||
stream.drain = AsyncMock()
|
||||
yield stream
|
||||
|
||||
@pytest.fixture()
|
||||
def readline(reader):
|
||||
yield reader.readline
|
||||
|
||||
@pytest.fixture()
|
||||
def write(writer):
|
||||
yield writer.write
|
||||
|
||||
@pytest.fixture()
|
||||
def plugin(reader, writer):
|
||||
"""Return plugin instance with all feature methods mocked"""
|
||||
async_methods = (
|
||||
"authenticate",
|
||||
@@ -40,17 +60,7 @@ def plugin():
|
||||
stack.enter_context(patch.object(Plugin, method, new_callable=AsyncMock))
|
||||
for method in methods:
|
||||
stack.enter_context(patch.object(Plugin, method))
|
||||
yield Plugin(Platform.Generic)
|
||||
|
||||
@pytest.fixture()
|
||||
def readline():
|
||||
with patch.object(StdinReader, "readline", new_callable=AsyncMock) as mock:
|
||||
yield mock
|
||||
|
||||
@pytest.fixture()
|
||||
def write():
|
||||
with patch.object(StdoutWriter, "write") as mock:
|
||||
yield mock
|
||||
yield Plugin(Platform.Generic, "0.1", reader, writer, "token")
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def my_caplog(caplog):
|
||||
|
||||
@@ -1,9 +1,18 @@
|
||||
import asyncio
|
||||
import json
|
||||
from pytest import raises
|
||||
|
||||
from galaxy.api.types import Achievement
|
||||
from galaxy.api.errors import UnknownError
|
||||
|
||||
def test_initialization_no_unlock_time():
|
||||
with raises(Exception):
|
||||
Achievement(achievement_id="lvl30", achievement_name="Got level 30")
|
||||
|
||||
def test_initialization_no_id_nor_name():
|
||||
with raises(AssertionError):
|
||||
Achievement(unlock_time=1234567890)
|
||||
|
||||
def test_success(plugin, readline, write):
|
||||
request = {
|
||||
"jsonrpc": "2.0",
|
||||
@@ -15,8 +24,9 @@ def test_success(plugin, readline, write):
|
||||
}
|
||||
readline.side_effect = [json.dumps(request), ""]
|
||||
plugin.get_unlocked_achievements.return_value = [
|
||||
Achievement("lvl10", 1548421241),
|
||||
Achievement("lvl20", 1548422395)
|
||||
Achievement(achievement_id="lvl10", unlock_time=1548421241),
|
||||
Achievement(achievement_name="Got level 20", unlock_time=1548422395),
|
||||
Achievement(achievement_id="lvl30", achievement_name="Got level 30", unlock_time=1548495633)
|
||||
]
|
||||
asyncio.run(plugin.run())
|
||||
plugin.get_unlocked_achievements.assert_called_with(game_id="14")
|
||||
@@ -32,8 +42,13 @@ def test_success(plugin, readline, write):
|
||||
"unlock_time": 1548421241
|
||||
},
|
||||
{
|
||||
"achievement_id": "lvl20",
|
||||
"achievement_name": "Got level 20",
|
||||
"unlock_time": 1548422395
|
||||
},
|
||||
{
|
||||
"achievement_id": "lvl30",
|
||||
"achievement_name": "Got level 30",
|
||||
"unlock_time": 1548495633
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -65,7 +80,7 @@ def test_failure(plugin, readline, write):
|
||||
}
|
||||
|
||||
def test_unlock_achievement(plugin, write):
|
||||
achievement = Achievement("lvl20", 1548422395)
|
||||
achievement = Achievement(achievement_id="lvl20", unlock_time=1548422395)
|
||||
|
||||
async def couritine():
|
||||
plugin.unlock_achievement("14", achievement)
|
||||
|
||||
@@ -2,14 +2,14 @@ from galaxy.api.plugin import Plugin
|
||||
from galaxy.api.consts import Platform, Feature
|
||||
|
||||
def test_base_class():
|
||||
plugin = Plugin(Platform.Generic)
|
||||
plugin = Plugin(Platform.Generic, "0.1", None, None, None)
|
||||
assert plugin.features == []
|
||||
|
||||
def test_no_overloads():
|
||||
class PluginImpl(Plugin): #pylint: disable=abstract-method
|
||||
pass
|
||||
|
||||
plugin = PluginImpl(Platform.Generic)
|
||||
plugin = PluginImpl(Platform.Generic, "0.1", None, None, None)
|
||||
assert plugin.features == []
|
||||
|
||||
def test_one_method_feature():
|
||||
@@ -17,7 +17,7 @@ def test_one_method_feature():
|
||||
async def get_owned_games(self):
|
||||
pass
|
||||
|
||||
plugin = PluginImpl(Platform.Generic)
|
||||
plugin = PluginImpl(Platform.Generic, "0.1", None, None, None)
|
||||
assert plugin.features == [Feature.ImportOwnedGames]
|
||||
|
||||
def test_multiple_methods_feature_all():
|
||||
@@ -33,7 +33,7 @@ def test_multiple_methods_feature_all():
|
||||
async def get_room_history_from_timestamp(self, room_id, timestamp):
|
||||
pass
|
||||
|
||||
plugin = PluginImpl(Platform.Generic)
|
||||
plugin = PluginImpl(Platform.Generic, "0.1", None, None, None)
|
||||
assert plugin.features == [Feature.Chat]
|
||||
|
||||
def test_multiple_methods_feature_not_all():
|
||||
@@ -41,5 +41,5 @@ def test_multiple_methods_feature_not_all():
|
||||
async def send_message(self, room_id, message):
|
||||
pass
|
||||
|
||||
plugin = PluginImpl(Platform.Generic)
|
||||
plugin = PluginImpl(Platform.Generic, "0.1", None, None, None)
|
||||
assert plugin.features == []
|
||||
|
||||
@@ -4,7 +4,7 @@ import json
|
||||
from galaxy.api.plugin import Plugin
|
||||
from galaxy.api.consts import Platform
|
||||
|
||||
def test_get_capabilites(readline, write):
|
||||
def test_get_capabilites(reader, writer, readline, write):
|
||||
class PluginImpl(Plugin): #pylint: disable=abstract-method
|
||||
async def get_owned_games(self):
|
||||
pass
|
||||
@@ -14,7 +14,8 @@ def test_get_capabilites(readline, write):
|
||||
"id": "3",
|
||||
"method": "get_capabilities"
|
||||
}
|
||||
plugin = PluginImpl(Platform.Generic)
|
||||
token = "token"
|
||||
plugin = PluginImpl(Platform.Generic, "0.1", reader, writer, token)
|
||||
readline.side_effect = [json.dumps(request), ""]
|
||||
asyncio.run(plugin.run())
|
||||
response = json.loads(write.call_args[0][0])
|
||||
@@ -25,7 +26,8 @@ def test_get_capabilites(readline, write):
|
||||
"platform_name": "generic",
|
||||
"features": [
|
||||
"ImportOwnedGames"
|
||||
]
|
||||
],
|
||||
"token": token
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import asyncio
|
||||
import json
|
||||
|
||||
from galaxy.api.types import Game, Dlc, LicenseInfo
|
||||
from galaxy.api.consts import LicenseType
|
||||
from galaxy.api.errors import UnknownError
|
||||
|
||||
def test_success(plugin, readline, write):
|
||||
@@ -13,15 +14,15 @@ def test_success(plugin, readline, write):
|
||||
|
||||
readline.side_effect = [json.dumps(request), ""]
|
||||
plugin.get_owned_games.return_value = [
|
||||
Game("3", "Doom", None, LicenseInfo("SinglePurchase", None)),
|
||||
Game("3", "Doom", None, LicenseInfo(LicenseType.SinglePurchase, None)),
|
||||
Game(
|
||||
"5",
|
||||
"Witcher 3",
|
||||
[
|
||||
Dlc("7", "Hearts of Stone", LicenseInfo("SinglePurchase", None)),
|
||||
Dlc("8", "Temerian Armor Set", LicenseInfo("FreeToPlay", None)),
|
||||
Dlc("7", "Hearts of Stone", LicenseInfo(LicenseType.SinglePurchase, None)),
|
||||
Dlc("8", "Temerian Armor Set", LicenseInfo(LicenseType.FreeToPlay, None)),
|
||||
],
|
||||
LicenseInfo("SinglePurchase", None))
|
||||
LicenseInfo(LicenseType.SinglePurchase, None))
|
||||
]
|
||||
asyncio.run(plugin.run())
|
||||
plugin.get_owned_games.assert_called_with()
|
||||
@@ -89,7 +90,7 @@ def test_failure(plugin, readline, write):
|
||||
}
|
||||
|
||||
def test_add_game(plugin, write):
|
||||
game = Game("3", "Doom", None, LicenseInfo("SinglePurchase", None))
|
||||
game = Game("3", "Doom", None, LicenseInfo(LicenseType.SinglePurchase, None))
|
||||
|
||||
async def couritine():
|
||||
plugin.add_game(game)
|
||||
@@ -127,7 +128,7 @@ def test_remove_game(plugin, write):
|
||||
}
|
||||
|
||||
def test_update_game(plugin, write):
|
||||
game = Game("3", "Doom", None, LicenseInfo("SinglePurchase", None))
|
||||
game = Game("3", "Doom", None, LicenseInfo(LicenseType.SinglePurchase, None))
|
||||
|
||||
async def couritine():
|
||||
plugin.update_game(game)
|
||||
|
||||
Reference in New Issue
Block a user