Compare commits

...

61 Commits
0.5 ... 0.28

Author SHA1 Message Date
Romuald Juchnowicz-Bierbasz
701d3cf522 Increment version 2019-05-10 17:06:01 +02:00
Romuald Juchnowicz-Bierbasz
c8083b9006 Add cookie_jar param to HttpClient 2019-05-10 17:05:40 +02:00
Romuald Juchnowicz-Bierbasz
0608ade6d3 Fix name 2019-05-10 14:14:33 +02:00
Romuald Juchnowicz-Bierbasz
c349a3df8e Add timeout 2019-05-10 13:56:53 +02:00
Romuald Juchnowicz-Bierbasz
1fd959a665 Handle ServerDisconnectedError 2019-05-10 13:50:46 +02:00
Romuald Juchnowicz-Bierbasz
234a21d085 Add HttpClient 2019-05-10 13:36:32 +02:00
Romuald Juchnowicz-Bierbasz
90835ece58 Change project layout 2019-05-10 13:16:28 +02:00
Aliaksei Paulouski
9e1c8cfddd Add JS to NextStep params 2019-05-03 15:56:40 +02:00
Aliaksei Paulouski
f7f170b9ca Increment version 2019-05-03 15:56:40 +02:00
Romuald Juchnowicz-Bierbasz
8ad5ed76b7 Increment version 2019-04-30 16:59:13 +02:00
Romuald Juchnowicz-Bierbasz
7727098c6f SDK-2762: Fix error handling 2019-04-30 16:58:54 +02:00
Romuald Juchnowicz-Bierbasz
e53dc8f2c6 Merge branch 'master' into parameter-checking
* master:
  Add friends features
2019-04-30 14:50:23 +02:00
Romuald Juchnowicz-Bierbasz
527fd034bf Increment version 2019-04-29 15:45:18 +02:00
Romuald Juchnowicz-Bierbasz
6e251c6eb9 SDK-2762: Bind method params before calling 2019-04-29 15:45:03 +02:00
Romuald Juchnowicz-Bierbasz
dc9fc2cc5d SDK-2762: Standarize parameter binding 2019-04-29 14:51:12 +02:00
Aliaksei Paulouski
1fb79eb21a Add friends features 2019-04-26 11:08:49 +02:00
Romuald Juchnowicz-Bierbasz
7b9bcf86a1 Increment version 2019-04-16 14:53:57 +02:00
Romuald Juchnowicz-Bierbasz
30b3533e1d Old style namespace package 2019-04-16 14:53:28 +02:00
Romuald Juchnowicz-Bierbasz
92b1d8e4df SDK-2760: Fix paths 2019-04-16 11:02:56 +02:00
Romuald Juchnowicz-Bierbasz
4adef2dace SDK-2760: Move modules 2019-04-16 10:38:45 +02:00
Romuald Juchnowicz-Bierbasz
1430fe39d7 SDK-2760: Make galaxy namespace package 2019-04-16 10:33:05 +02:00
Romuald Juchnowicz-Bierbasz
c591efc493 Increment version 2019-04-16 10:27:07 +02:00
Romuald Juchnowicz-Bierbasz
7c4f3fba5b SDK-2760: Add mock module 2019-04-16 10:26:37 +02:00
Romuald Juchnowicz-Bierbasz
f2e2e41d04 SDK-2760: Add tools module 2019-04-16 10:24:32 +02:00
Romuald Juchnowicz-Bierbasz
25b850d8bb SDK-2760: Dlc list optional 2019-04-16 10:21:31 +02:00
Romuald Juchnowicz-Bierbasz
403736612a Increment version, add changelog 2019-04-12 13:52:47 +02:00
Romuald Juchnowicz-Bierbasz
3071c2e771 SDK-2760: Add Epic platform 2019-04-12 13:51:41 +02:00
Aliaksei Paulouski
23ef34bed5 Increment version 2019-04-06 15:02:06 +02:00
Aliaksei Paulouski
a4b08f8105 Add Cookies to NextStep 2019-04-06 15:02:01 +02:00
Romuald Bierbasz
4d62b8ccb8 SDK-2743: Remove logging setup 2019-04-05 14:24:14 +02:00
Aliaksei Paulouski
d759b4aa85 Increment version 2019-03-28 14:37:30 +01:00
Romuald Juchnowicz-Bierbasz
9b33397827 Add NextStep and pass_login_credentials 2019-03-28 10:20:16 +01:00
Romuald Juchnowicz-Bierbasz
e09e443064 Increment version 2019-03-27 15:15:25 +01:00
Romuald Juchnowicz-Bierbasz
00ed52384a Exclude tests from package 2019-03-27 15:14:29 +01:00
Aliaksei Paulouski
958d9bc0e6 Fix send_message message param name 2019-03-25 11:46:30 +01:00
Pawel Kierski
d73d048ff7 Increment version to 0.17 2019-03-12 16:10:23 +01:00
Aliaksei Paulouski
e06e40f845 Fix duplicated error code 2019-03-12 15:53:42 +01:00
Paweł Kierski
833e6999d7 Return JSON-RPC reponse on generic Exception 2019-03-12 15:38:22 +01:00
Paweł Kierski
ca778e2cdb SDK-2647 Serializing local game state as integer 2019-03-11 13:47:12 +01:00
Romuald Juchnowicz-Bierbasz
9a06428fc0 Remove log 2019-03-11 12:19:45 +01:00
Romuald Juchnowicz-Bierbasz
f9eaeaf726 Increment version, add changelog 2019-03-11 11:08:28 +01:00
Romuald Juchnowicz-Bierbasz
f09171672f SDK-2636: Do not log sensitive data 2019-03-11 11:07:31 +01:00
Romuald Juchnowicz-Bierbasz
ca8d0dfaf4 Increment version, add changelog 2019-03-08 10:19:43 +01:00
Romuald Juchnowicz-Bierbasz
73bc9aa8ec SDK-2623: Call shutdown on socket close 2019-03-08 10:16:57 +01:00
Romuald Juchnowicz-Bierbasz
52273e2f8c Increment veresion, add changelog 2019-03-07 13:19:37 +01:00
Romuald Juchnowicz-Bierbasz
bda867473c SDK-2627: Add version param to Plugin 2019-03-07 13:18:26 +01:00
Pawel Kierski
6885cdc439 Increment version 2019-03-06 14:13:45 +01:00
Pawel Kierski
88e25a93be Ensure log folder exists 2019-03-06 11:15:33 +01:00
Pawel Kierski
67e7a4c0b2 Don't create log file if not specified 2019-03-05 12:19:48 +01:00
Paweł Kierski
788d2550e6 SDK-2552 optional achievement id or name 2019-03-05 12:18:33 +01:00
Rafal Makagon
059a1ea343 update logging facility in plugin API 2019-03-05 09:36:44 +01:00
Pawel Kierski
300ade5d43 Fix handling unknown notification 2019-03-04 11:52:08 +01:00
Paweł Kierski
43556a0470 SDK-2586 Return "None" instead of "Unknown" state for local game for Origin 2019-03-01 14:10:48 +01:00
Romuald Bierbasz
e244d3bb44 SDK-2577: Add UnknownBackendResponse 2019-02-28 15:20:03 +01:00
Romuald Bierbasz
d6e6efc633 SDK-2571: Refactor logging 2019-02-28 10:31:12 +01:00
Paweł Kierski
a114c9721c Add Unknown for all enums 2019-02-22 11:26:17 +01:00
Romuald Juchnowicz-Bierbasz
6c0389834b Increment version 2019-02-21 15:29:39 +01:00
Romuald Juchnowicz-Bierbasz
bc7d1c2914 SDK-2538: Use Optional 2019-02-21 15:17:38 +01:00
Romuald Juchnowicz-Bierbasz
d69e1aaa08 SDK-2538: Add LicenseType enum 2019-02-21 15:11:49 +01:00
Romuald Juchnowicz-Bierbasz
c2a0534162 Deploy only from master 2019-02-20 16:44:53 +01:00
Paweł Kierski
1614fd6eb2 Fix end of stream detecting 2019-02-20 16:41:44 +01:00
27 changed files with 467 additions and 287 deletions

View File

@@ -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

View File

@@ -30,4 +30,16 @@ pip install -r requirements.txt
Run tests:
```bash
pytest
```
```
## Changelog
### 0.21
* Add `Epic` platform.
### 0.16
* Do not log sensitive data.
* Return `LocalGameState` as int (possible combination of flags).
### 0.15
* `shutdown()` is called on socket disconnection.
### 0.14
* Added required version parameter to Plugin constructor.

View File

@@ -1,2 +1,6 @@
-e .
pytest==4.2.0
pytest-flakes==4.0.0
# because of pip bug https://github.com/pypa/pip/issues/4780
aiohttp==3.5.4
certifi==2019.3.9

View File

@@ -2,9 +2,14 @@ from setuptools import setup, find_packages
setup(
name="galaxy.plugin.api",
version="0.5",
version="0.28",
description="Galaxy python plugin API",
author='Galaxy team',
author_email='galaxy@gog.com',
packages=find_packages()
packages=find_packages("src"),
package_dir={'': 'src'},
install_requires=[
"aiohttp==3.5.4",
"certifi==2019.3.9"
]
)

1
src/galaxy/__init__.py Normal file
View File

@@ -0,0 +1 @@
__path__ = __import__('pkgutil').extend_path(__path__, __name__)

View File

@@ -1,4 +1,4 @@
from enum import Enum
from enum import Enum, Flag
class Platform(Enum):
Unknown = "unknown"
@@ -10,8 +10,10 @@ class Platform(Enum):
Origin = "origin"
Uplay = "uplay"
Battlenet = "battlenet"
Epic = "epic"
class Feature(Enum):
Unknown = "Unknown"
ImportInstalledGames = "ImportInstalledGames"
ImportOwnedGames = "ImportOwnedGames"
LaunchGame = "LaunchGame"
@@ -22,12 +24,21 @@ class Feature(Enum):
Chat = "Chat"
ImportUsers = "ImportUsers"
VerifyGame = "VerifyGame"
ImportFriends = "ImportFriends"
class LocalGameState(Enum):
Installed = "Installed"
Running = "Running"
class LicenseType(Enum):
Unknown = "Unknown"
SinglePurchase = "SinglePurchase"
FreeToPlay = "FreeToPlay"
OtherUserLicense = "OtherUserLicense"
class LocalGameState(Flag):
None_ = 0
Installed = 1
Running = 2
class PresenceState(Enum):
Unknown = "Unknown"
Online = "online"
Offline = "offline"
Away = "away"

View File

@@ -1,8 +1,6 @@
from galaxy.api.jsonrpc import ApplicationError
from galaxy.api.jsonrpc import ApplicationError, UnknownError
class UnknownError(ApplicationError):
def __init__(self, data=None):
super().__init__(0, "Unknown error", data)
UnknownError = UnknownError
class AuthenticationRequired(ApplicationError):
def __init__(self, data=None):
@@ -20,6 +18,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)

View File

@@ -1,6 +1,8 @@
import asyncio
from collections import namedtuple
from collections.abc import Iterable
import logging
import inspect
import json
class JsonRpcError(Exception):
@@ -24,7 +26,7 @@ class MethodNotFound(JsonRpcError):
class InvalidParams(JsonRpcError):
def __init__(self):
super().__init__(-32601, "Invalid params")
super().__init__(-32602, "Invalid params")
class Timeout(JsonRpcError):
def __init__(self):
@@ -40,8 +42,26 @@ class ApplicationError(JsonRpcError):
raise ValueError("The error code in reserved range")
super().__init__(code, message, data)
class UnknownError(ApplicationError):
def __init__(self, data=None):
super().__init__(0, "Unknown error", data)
Request = namedtuple("Request", ["method", "params", "id"], defaults=[{}, None])
Method = namedtuple("Method", ["callback", "internal"])
Method = namedtuple("Method", ["callback", "signature", "internal", "sensitive_params"])
def anonymise_sensitive_params(params, sensitive_params):
anomized_data = "****"
if not sensitive_params:
return params
if isinstance(sensitive_params, Iterable):
anomized_params = params.copy()
for key in anomized_params.keys():
if key in sensitive_params:
anomized_params[key] = anomized_data
return anomized_params
return anomized_data
class Server():
def __init__(self, reader, writer, encoder=json.JSONEncoder()):
@@ -53,25 +73,43 @@ class Server():
self._notifications = {}
self._eof_listeners = []
def register_method(self, name, callback, internal):
self._methods[name] = Method(callback, internal)
def register_method(self, name, callback, internal, sensitive_params=False):
"""
Register method
:param name:
:param callback:
:param internal: if True the callback will be processed immediately (synchronously)
:param sensitive_params: list of parameters that will by anonymized before logging; if False - no params
are considered sensitive, if True - all params are considered sensitive
"""
self._methods[name] = Method(callback, inspect.signature(callback), internal, sensitive_params)
def register_notification(self, name, callback, internal):
self._notifications[name] = Method(callback, internal)
def register_notification(self, name, callback, internal, sensitive_params=False):
"""
Register notification
:param name:
:param callback:
:param internal: if True the callback will be processed immediately (synchronously)
:param sensitive_params: list of parameters that will by anonymized before logging; if False - no params
are considered sensitive, if True - all params are considered sensitive
"""
self._notifications[name] = Method(callback, inspect.signature(callback), internal, sensitive_params)
def register_eof(self, callback):
self._eof_listeners.append(callback)
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()
logging.debug("Received data: %s", data)
logging.debug("Received %d bytes of data", len(data))
self._handle_input(data)
def stop(self):
@@ -90,59 +128,65 @@ class Server():
self._send_error(None, error)
return
logging.debug("Parsed input: %s", request)
if request.id is not None:
self._handle_request(request)
else:
self._handle_notification(request)
def _handle_notification(self, request):
logging.debug("Handling notification %s", request)
method = self._notifications.get(request.method)
if not method:
logging.error("Received uknown notification: %s", request.method)
logging.error("Received unknown notification: %s", request.method)
return
callback, signature, internal, sensitive_params = method
self._log_request(request, sensitive_params)
try:
bound_args = signature.bind(**request.params)
except TypeError:
self._send_error(request.id, InvalidParams())
callback, internal = method
if internal:
# internal requests are handled immediately
callback(**request.params)
callback(*bound_args.args, **bound_args.kwargs)
else:
try:
asyncio.create_task(callback(**request.params))
except Exception as error: #pylint: disable=broad-except
logging.error(
"Unexpected exception raised in notification handler: %s",
repr(error)
)
asyncio.create_task(callback(*bound_args.args, **bound_args.kwargs))
except Exception:
logging.exception("Unexpected exception raised in notification handler")
def _handle_request(self, request):
logging.debug("Handling request %s", request)
method = self._methods.get(request.method)
if not method:
logging.error("Received uknown request: %s", request.method)
logging.error("Received unknown request: %s", request.method)
self._send_error(request.id, MethodNotFound())
return
callback, internal = method
callback, signature, internal, sensitive_params = method
self._log_request(request, sensitive_params)
try:
bound_args = signature.bind(**request.params)
except TypeError:
self._send_error(request.id, InvalidParams())
if internal:
# internal requests are handled immediately
response = callback(request.params)
response = callback(*bound_args.args, **bound_args.kwargs)
self._send_response(request.id, response)
else:
async def handle():
try:
result = await callback(request.params)
result = await callback(*bound_args.args, **bound_args.kwargs)
self._send_response(request.id, result)
except TypeError:
self._send_error(request.id, InvalidParams())
except NotImplementedError:
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 as e: #pylint: disable=broad-except
logging.exception("Unexpected exception raised in plugin handler")
self._send_error(request.id, UnknownError(str(e)))
asyncio.create_task(handle())
@@ -163,7 +207,8 @@ class Server():
try:
line = self._encoder.encode(data)
logging.debug("Sending data: %s", line)
self._writer.write((line + "\n").encode("utf-8"))
data = (line + "\n").encode("utf-8")
self._writer.write(data)
asyncio.create_task(self._writer.drain())
except TypeError as error:
logging.error(str(error))
@@ -191,25 +236,47 @@ class Server():
self._send(response)
@staticmethod
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 = {}
def notify(self, method, params):
def notify(self, method, params, sensitive_params=False):
"""
Send notification
:param method:
:param params:
:param sensitive_params: list of parameters that will by anonymized before logging; if False - no params
are considered sensitive, if True - all params are considered sensitive
"""
notification = {
"jsonrpc": "2.0",
"method": method,
"params": params
}
self._log(method, params, sensitive_params)
self._send(notification)
def _send(self, data):
try:
line = self._encoder.encode(data)
logging.debug("Sending data: %s", line)
self._writer.write((line + "\n").encode("utf-8"))
data = (line + "\n").encode("utf-8")
logging.debug("Sending %d byte of data", len(data))
self._writer.write(data)
asyncio.create_task(self._writer.drain())
except TypeError as error:
logging.error("Failed to parse outgoing message: %s", str(error))
@staticmethod
def _log(method, params, sensitive_params):
params = anonymise_sensitive_params(params, sensitive_params)
logging.info("Sending notification: method=%s, params=%s", method, params)

View File

@@ -1,9 +1,11 @@
import asyncio
import json
import logging
import logging.handlers
import dataclasses
from enum import Enum
from collections import OrderedDict
import sys
from galaxy.api.jsonrpc import Server, NotificationClient
from galaxy.api.consts import Feature
@@ -20,8 +22,10 @@ class JSONEncoder(json.JSONEncoder):
return super().default(o)
class Plugin():
def __init__(self, platform, 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)
self._platform = platform
self._version = version
self._feature_methods = OrderedDict()
self._active = True
@@ -34,7 +38,7 @@ class Plugin():
self._notification_client = NotificationClient(self._writer, encoder)
def eof_handler():
self._active = False
self._shutdown()
self._server.register_eof(eof_handler)
# internal
@@ -43,7 +47,8 @@ class Plugin():
self._register_method("ping", self._ping, internal=True)
# implemented by developer
self._register_method("init_authentication", self.authenticate)
self._register_method("init_authentication", self.authenticate, sensitive_params=["stored_credentials"])
self._register_method("pass_login_credentials", self.pass_login_credentials)
self._register_method(
"import_owned_games",
self.get_owned_games,
@@ -72,8 +77,8 @@ class Plugin():
self._register_method(
"import_friends",
self.get_friends,
result_name="user_info_list",
feature=Feature.ImportUsers
result_name="friend_info_list",
feature=Feature.ImportFriends
)
self._register_method(
"import_user_infos",
@@ -133,31 +138,31 @@ class Plugin():
return False
return True
def _register_method(self, name, handler, result_name=None, internal=False, feature=None):
def _register_method(self, name, handler, result_name=None, internal=False, sensitive_params=False, feature=None):
if internal:
def method(params):
result = handler(**params)
def method(*args, **kwargs):
result = handler(*args, **kwargs)
if result_name:
result = {
result_name: result
}
return result
self._server.register_method(name, method, True)
self._server.register_method(name, method, True, sensitive_params)
else:
async def method(params):
result = await handler(**params)
async def method(*args, **kwargs):
result = await handler(*args, **kwargs)
if result_name:
result = {
result_name: result
}
return result
self._server.register_method(name, method, False)
self._server.register_method(name, method, False, sensitive_params)
if feature is not None:
self._feature_methods.setdefault(feature, []).append(handler)
def _register_notification(self, name, handler, internal=False, feature=None):
self._server.register_notification(name, handler, internal)
def _register_notification(self, name, handler, internal=False, sensitive_params=False, feature=None):
self._server.register_notification(name, handler, internal, sensitive_params)
if feature is not None:
self._feature_methods.setdefault(feature, []).append(handler)
@@ -166,8 +171,10 @@ class Plugin():
"""Plugin main coorutine"""
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())
@@ -194,7 +201,7 @@ class Plugin():
"""Notify client to store plugin credentials.
They will be pass to next authencicate calls.
"""
self._notification_client.notify("store_credentials", credentials)
self._notification_client.notify("store_credentials", credentials, sensitive_params=True)
def add_game(self, game):
params = {"owned_game" : game}
@@ -220,17 +227,13 @@ class Plugin():
self._notification_client.notify("local_game_status_changed", params)
def add_friend(self, user):
params = {"user_info" : user}
params = {"friend_info" : user}
self._notification_client.notify("friend_added", params)
def remove_friend(self, user_id):
params = {"user_id" : user_id}
self._notification_client.notify("friend_removed", params)
def update_friend(self, user):
params = {"user_info" : user}
self._notification_client.notify("friend_updated", params)
def update_room(self, room_id, unread_message_count=None, new_messages=None):
params = {"room_id": room_id}
if unread_message_count is not None:
@@ -267,6 +270,9 @@ class Plugin():
"""
raise NotImplementedError()
async def pass_login_credentials(self, step, credentials, cookies):
raise NotImplementedError()
async def get_owned_games(self):
raise NotImplementedError()
@@ -291,7 +297,7 @@ class Plugin():
async def get_users(self, user_id_list):
raise NotImplementedError()
async def send_message(self, room_id, message):
async def send_message(self, room_id, message_text):
raise NotImplementedError()
async def mark_as_read(self, room_id, last_message_id):
@@ -309,22 +315,34 @@ class Plugin():
async def get_game_times(self):
raise NotImplementedError()
def create_and_run_plugin(plugin_class, argv):
if not issubclass(plugin_class, Plugin):
raise TypeError("plugin_class must be subclass of Plugin")
if len(argv) < 3:
raise ValueError("Not enough parameters, required: token, port")
logging.critical("Not enough parameters, required: token, port")
sys.exit(1)
token = argv[1]
try:
port = int(argv[2])
except ValueError as e:
raise ValueError("Failed to parse port value, {}".format(e))
except ValueError:
logging.critical("Failed to parse port value: %s", argv[2])
sys.exit(2)
if not (1 <= port <= 65535):
raise ValueError("Port value out of range (1, 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()
asyncio.run(coroutine())
try:
asyncio.run(coroutine())
except Exception:
logging.exception("Error while running plugin")
sys.exit(5)

View File

@@ -1,17 +1,31 @@
from dataclasses import dataclass
from typing import List
from typing import List, Dict, Optional
from galaxy.api.consts import LocalGameState, PresenceState
from galaxy.api.consts import LicenseType, LocalGameState, PresenceState
@dataclass
class Authentication():
user_id: str
user_name: str
@dataclass
class Cookie():
name: str
value: str
domain: Optional[str] = None
path: Optional[str] = None
@dataclass
class NextStep():
next_step: str
auth_params: Dict[str, str]
cookies: Optional[List[Cookie]] = None
js: Optional[Dict[str, List[str]]] = None
@dataclass
class LicenseInfo():
license_type: str
owner: str = None
license_type: LicenseType
owner: Optional[str] = None
@dataclass
class Dlc():
@@ -23,13 +37,18 @@ class Dlc():
class Game():
game_id: str
game_title: str
dlcs: List[Dlc]
dlcs: Optional[List[Dlc]]
license_info: LicenseInfo
@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 +58,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():
@@ -50,6 +69,11 @@ class UserInfo():
avatar_url: str
presence: Presence
@dataclass
class FriendInfo():
user_id: str
user_name: str
@dataclass
class Room():
room_id: str

43
src/galaxy/http.py Normal file
View File

@@ -0,0 +1,43 @@
import asyncio
import ssl
from http import HTTPStatus
import aiohttp
import certifi
from galaxy.api.errors import (
AccessDenied, AuthenticationRequired,
BackendTimeout, BackendNotAvailable, BackendError, NetworkError, UnknownError
)
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, timeout=timeout, ssl=ssl_context)
self._session = aiohttp.ClientSession(connector=connector, cookie_jar=cookie_jar)
async def close(self):
await self._session.close()
async def request(self, method, *args, **kwargs):
try:
response = await self._session.request(method, *args, **kwargs)
except asyncio.TimeoutError:
raise BackendTimeout()
except aiohttp.ClientConnectionError:
raise NetworkError()
except aiohttp.ServerDisconnectedError:
raise BackendNotAvailable()
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 >= 500:
raise BackendError()
if response.status >= 400:
raise UnknownError()
return response

20
src/galaxy/tools.py Normal file
View File

@@ -0,0 +1,20 @@
import io
import os
import zipfile
from glob import glob
def zip_folder(folder):
files = glob(os.path.join(folder, "**"), recursive=True)
files = [file.replace(folder + os.sep, "") for file in files]
files = [file for file in files if file]
zip_buffer = io.BytesIO()
with zipfile.ZipFile(zip_buffer, mode="w", compression=zipfile.ZIP_DEFLATED) as zipf:
for file in files:
zipf.write(os.path.join(folder, file), arcname=file)
return zip_buffer
def zip_folder_to_file(folder, filename):
zip_content = zip_folder(folder).getbuffer()
with open(filename, "wb") as archive:
archive.write(zip_content)

View File

@@ -0,0 +1,12 @@
from asyncio import coroutine
from unittest.mock import MagicMock
class AsyncMock(MagicMock):
async def __call__(self, *args, **kwargs):
return super(AsyncMock, self).__call__(*args, **kwargs)
def coroutine_mock():
coro = MagicMock(name="CoroutineResult")
corofunc = MagicMock(name="CoroutineFunction", side_effect=coroutine(coro))
corofunc.coro = coro
return corofunc

View File

@@ -1,6 +0,0 @@
from unittest.mock import MagicMock
class AsyncMock(MagicMock):
async def __call__(self, *args, **kwargs):
# pylint: disable=useless-super-delegation
return super(AsyncMock, self).__call__(*args, **kwargs)

View File

@@ -6,7 +6,7 @@ import pytest
from galaxy.api.plugin import Plugin
from galaxy.api.consts import Platform
from tests.async_mock import AsyncMock
from galaxy.unittest.mock import AsyncMock, coroutine_mock
@pytest.fixture()
def reader():
@@ -57,10 +57,10 @@ def plugin(reader, writer):
with ExitStack() as stack:
for method in async_methods:
stack.enter_context(patch.object(Plugin, method, new_callable=AsyncMock))
stack.enter_context(patch.object(Plugin, method, new_callable=coroutine_mock))
for method in methods:
stack.enter_context(patch.object(Plugin, method))
yield Plugin(Platform.Generic, reader, writer, "token")
yield Plugin(Platform.Generic, "0.1", reader, writer, "token")
@pytest.fixture(autouse=True)
def my_caplog(caplog):

View File

@@ -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",
@@ -14,9 +23,10 @@ def test_success(plugin, readline, write):
}
}
readline.side_effect = [json.dumps(request), ""]
plugin.get_unlocked_achievements.return_value = [
Achievement("lvl10", 1548421241),
Achievement("lvl20", 1548422395)
plugin.get_unlocked_achievements.coro.return_value = [
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
}
]
}
@@ -50,7 +65,7 @@ def test_failure(plugin, readline, write):
}
readline.side_effect = [json.dumps(request), ""]
plugin.get_unlocked_achievements.side_effect = UnknownError()
plugin.get_unlocked_achievements.coro.side_effect = UnknownError()
asyncio.run(plugin.run())
plugin.get_unlocked_achievements.assert_called()
response = json.loads(write.call_args[0][0])
@@ -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)

View File

@@ -18,7 +18,7 @@ def test_success(plugin, readline, write):
}
readline.side_effect = [json.dumps(request), ""]
plugin.authenticate.return_value = Authentication("132", "Zenek")
plugin.authenticate.coro.return_value = Authentication("132", "Zenek")
asyncio.run(plugin.run())
plugin.authenticate.assert_called_with()
response = json.loads(write.call_args[0][0])
@@ -56,7 +56,7 @@ def test_failure(plugin, readline, write, error, code, message):
}
readline.side_effect = [json.dumps(request), ""]
plugin.authenticate.side_effect = error()
plugin.authenticate.coro.side_effect = error()
asyncio.run(plugin.run())
plugin.authenticate.assert_called_with()
response = json.loads(write.call_args[0][0])
@@ -82,7 +82,7 @@ def test_stored_credentials(plugin, readline, write):
}
}
readline.side_effect = [json.dumps(request), ""]
plugin.authenticate.return_value = Authentication("132", "Zenek")
plugin.authenticate.coro.return_value = Authentication("132", "Zenek")
asyncio.run(plugin.run())
plugin.authenticate.assert_called_with(stored_credentials={"token": "ABC"})
write.assert_called()

View File

@@ -21,7 +21,7 @@ def test_send_message_success(plugin, readline, write):
}
readline.side_effect = [json.dumps(request), ""]
plugin.send_message.return_value = None
plugin.send_message.coro.return_value = None
asyncio.run(plugin.run())
plugin.send_message.assert_called_with(room_id="14", message="Hello!")
response = json.loads(write.call_args[0][0])
@@ -52,7 +52,7 @@ def test_send_message_failure(plugin, readline, write, error, code, message):
}
readline.side_effect = [json.dumps(request), ""]
plugin.send_message.side_effect = error()
plugin.send_message.coro.side_effect = error()
asyncio.run(plugin.run())
plugin.send_message.assert_called_with(room_id="15", message="Bye")
response = json.loads(write.call_args[0][0])
@@ -78,7 +78,7 @@ def test_mark_as_read_success(plugin, readline, write):
}
readline.side_effect = [json.dumps(request), ""]
plugin.mark_as_read.return_value = None
plugin.mark_as_read.coro.return_value = None
asyncio.run(plugin.run())
plugin.mark_as_read.assert_called_with(room_id="14", last_message_id="67")
response = json.loads(write.call_args[0][0])
@@ -114,7 +114,7 @@ def test_mark_as_read_failure(plugin, readline, write, error, code, message):
}
readline.side_effect = [json.dumps(request), ""]
plugin.mark_as_read.side_effect = error()
plugin.mark_as_read.coro.side_effect = error()
asyncio.run(plugin.run())
plugin.mark_as_read.assert_called_with(room_id="18", last_message_id="7")
response = json.loads(write.call_args[0][0])
@@ -136,7 +136,7 @@ def test_get_rooms_success(plugin, readline, write):
}
readline.side_effect = [json.dumps(request), ""]
plugin.get_rooms.return_value = [
plugin.get_rooms.coro.return_value = [
Room("13", 0, None),
Room("15", 34, "8")
]
@@ -170,7 +170,7 @@ def test_get_rooms_failure(plugin, readline, write):
}
readline.side_effect = [json.dumps(request), ""]
plugin.get_rooms.side_effect = UnknownError()
plugin.get_rooms.coro.side_effect = UnknownError()
asyncio.run(plugin.run())
plugin.get_rooms.assert_called_with()
response = json.loads(write.call_args[0][0])
@@ -196,7 +196,7 @@ def test_get_room_history_from_message_success(plugin, readline, write):
}
readline.side_effect = [json.dumps(request), ""]
plugin.get_room_history_from_message.return_value = [
plugin.get_room_history_from_message.coro.return_value = [
Message("13", "149", 1549454837, "Hello"),
Message("14", "812", 1549454899, "Hi")
]
@@ -245,7 +245,7 @@ def test_get_room_history_from_message_failure(plugin, readline, write, error, c
}
readline.side_effect = [json.dumps(request), ""]
plugin.get_room_history_from_message.side_effect = error()
plugin.get_room_history_from_message.coro.side_effect = error()
asyncio.run(plugin.run())
plugin.get_room_history_from_message.assert_called_with(room_id="33", message_id="88")
response = json.loads(write.call_args[0][0])
@@ -271,7 +271,7 @@ def test_get_room_history_from_timestamp_success(plugin, readline, write):
}
readline.side_effect = [json.dumps(request), ""]
plugin.get_room_history_from_timestamp.return_value = [
plugin.get_room_history_from_timestamp.coro.return_value = [
Message("12", "155", 1549454836, "Bye")
]
asyncio.run(plugin.run())
@@ -308,7 +308,7 @@ def test_get_room_history_from_timestamp_failure(plugin, readline, write):
}
readline.side_effect = [json.dumps(request), ""]
plugin.get_room_history_from_timestamp.side_effect = UnknownError()
plugin.get_room_history_from_timestamp.coro.side_effect = UnknownError()
asyncio.run(plugin.run())
plugin.get_room_history_from_timestamp.assert_called_with(
room_id="10",

View File

@@ -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, None, None, None)
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, None, None, None)
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, None, None, None)
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, None, None, None)
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, None, None, None)
plugin = PluginImpl(Platform.Generic, "0.1", None, None, None)
assert plugin.features == []

90
tests/test_friends.py Normal file
View File

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

View File

@@ -12,7 +12,7 @@ def test_success(plugin, readline, write):
}
readline.side_effect = [json.dumps(request), ""]
plugin.get_game_times.return_value = [
plugin.get_game_times.coro.return_value = [
GameTime("3", 60, 1549550504),
GameTime("5", 10, 1549550502)
]
@@ -47,7 +47,7 @@ def test_failure(plugin, readline, write):
}
readline.side_effect = [json.dumps(request), ""]
plugin.get_game_times.side_effect = UnknownError()
plugin.get_game_times.coro.side_effect = UnknownError()
asyncio.run(plugin.run())
plugin.get_game_times.assert_called_with()
response = json.loads(write.call_args[0][0])

View File

@@ -15,7 +15,7 @@ def test_get_capabilites(reader, writer, readline, write):
"method": "get_capabilities"
}
token = "token"
plugin = PluginImpl(Platform.Generic, reader, writer, 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])

View File

@@ -16,9 +16,10 @@ def test_success(plugin, readline, write):
readline.side_effect = [json.dumps(request), ""]
plugin.get_local_games.return_value = [
LocalGame("1", "Running"),
LocalGame("2", "Installed")
plugin.get_local_games.coro.return_value = [
LocalGame("1", LocalGameState.Running),
LocalGame("2", LocalGameState.Installed),
LocalGame("3", LocalGameState.Installed | LocalGameState.Running)
]
asyncio.run(plugin.run())
plugin.get_local_games.assert_called_with()
@@ -31,11 +32,15 @@ def test_success(plugin, readline, write):
"local_games" : [
{
"game_id": "1",
"local_game_state": "Running"
"local_game_state": LocalGameState.Running.value
},
{
"game_id": "2",
"local_game_state": "Installed"
"local_game_state": LocalGameState.Installed.value
},
{
"game_id": "3",
"local_game_state": (LocalGameState.Installed | LocalGameState.Running).value
}
]
}
@@ -56,7 +61,7 @@ def test_failure(plugin, readline, write, error, code, message):
}
readline.side_effect = [json.dumps(request), ""]
plugin.get_local_games.side_effect = error()
plugin.get_local_games.coro.side_effect = error()
asyncio.run(plugin.run())
plugin.get_local_games.assert_called_with()
response = json.loads(write.call_args[0][0])
@@ -85,7 +90,7 @@ def test_local_game_state_update(plugin, write):
"params": {
"local_game": {
"game_id": "1",
"local_game_state": "Running"
"local_game_state": LocalGameState.Running.value
}
}
}

View File

@@ -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):
@@ -12,16 +13,16 @@ 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)),
plugin.get_owned_games.coro.return_value = [
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()
@@ -74,7 +75,7 @@ def test_failure(plugin, readline, write):
}
readline.side_effect = [json.dumps(request), ""]
plugin.get_owned_games.side_effect = UnknownError()
plugin.get_owned_games.coro.side_effect = UnknownError()
asyncio.run(plugin.run())
plugin.get_owned_games.assert_called_with()
response = json.loads(write.call_args[0][0])
@@ -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)

View File

@@ -5,153 +5,6 @@ from galaxy.api.types import UserInfo, Presence
from galaxy.api.errors import UnknownError
from galaxy.api.consts import PresenceState
def test_get_friends_success(plugin, readline, write):
request = {
"jsonrpc": "2.0",
"id": "3",
"method": "import_friends"
}
readline.side_effect = [json.dumps(request), ""]
plugin.get_friends.return_value = [
UserInfo(
"3",
True,
"Jan",
"http://avatar1.png",
Presence(
PresenceState.Online,
"123",
"Main menu"
)
),
UserInfo(
"5",
True,
"Ola",
"http://avatar2.png",
Presence(PresenceState.Offline)
)
]
asyncio.run(plugin.run())
plugin.get_friends.assert_called_with()
response = json.loads(write.call_args[0][0])
assert response == {
"jsonrpc": "2.0",
"id": "3",
"result": {
"user_info_list": [
{
"user_id": "3",
"is_friend": True,
"user_name": "Jan",
"avatar_url": "http://avatar1.png",
"presence": {
"presence_state": "online",
"game_id": "123",
"presence_status": "Main menu"
}
},
{
"user_id": "5",
"is_friend": True,
"user_name": "Ola",
"avatar_url": "http://avatar2.png",
"presence": {
"presence_state": "offline"
}
}
]
}
}
def test_get_friends_failure(plugin, readline, write):
request = {
"jsonrpc": "2.0",
"id": "3",
"method": "import_friends"
}
readline.side_effect = [json.dumps(request), ""]
plugin.get_friends.side_effect = UnknownError()
asyncio.run(plugin.run())
plugin.get_friends.assert_called_with()
response = json.loads(write.call_args[0][0])
assert response == {
"jsonrpc": "2.0",
"id": "3",
"error": {
"code": 0,
"message": "Unknown error",
}
}
def test_add_friend(plugin, write):
friend = UserInfo("7", True, "Kuba", "http://avatar.png", Presence(PresenceState.Offline))
async def couritine():
plugin.add_friend(friend)
asyncio.run(couritine())
response = json.loads(write.call_args[0][0])
assert response == {
"jsonrpc": "2.0",
"method": "friend_added",
"params": {
"user_info": {
"user_id": "7",
"is_friend": True,
"user_name": "Kuba",
"avatar_url": "http://avatar.png",
"presence": {
"presence_state": "offline"
}
}
}
}
def test_remove_friend(plugin, write):
async def couritine():
plugin.remove_friend("5")
asyncio.run(couritine())
response = json.loads(write.call_args[0][0])
assert response == {
"jsonrpc": "2.0",
"method": "friend_removed",
"params": {
"user_id": "5"
}
}
def test_update_friend(plugin, write):
friend = UserInfo("9", True, "Anna", "http://avatar.png", Presence(PresenceState.Offline))
async def couritine():
plugin.update_friend(friend)
asyncio.run(couritine())
response = json.loads(write.call_args[0][0])
assert response == {
"jsonrpc": "2.0",
"method": "friend_updated",
"params": {
"user_info": {
"user_id": "9",
"is_friend": True,
"user_name": "Anna",
"avatar_url": "http://avatar.png",
"presence": {
"presence_state": "offline"
}
}
}
}
def test_get_users_success(plugin, readline, write):
request = {
@@ -164,7 +17,7 @@ def test_get_users_success(plugin, readline, write):
}
readline.side_effect = [json.dumps(request), ""]
plugin.get_users.return_value = [
plugin.get_users.coro.return_value = [
UserInfo("5", False, "Ula", "http://avatar.png", Presence(PresenceState.Offline))
]
asyncio.run(plugin.run())
@@ -189,6 +42,7 @@ def test_get_users_success(plugin, readline, write):
}
}
def test_get_users_failure(plugin, readline, write):
request = {
"jsonrpc": "2.0",
@@ -200,7 +54,7 @@ def test_get_users_failure(plugin, readline, write):
}
readline.side_effect = [json.dumps(request), ""]
plugin.get_users.side_effect = UnknownError()
plugin.get_users.coro.side_effect = UnknownError()
asyncio.run(plugin.run())
plugin.get_users.assert_called_with(user_id_list=["10", "11", "12"])
response = json.loads(write.call_args[0][0])