From 73a427f6aa116100bfdff8c7777c0232012cafeb Mon Sep 17 00:00:00 2001 From: Will Date: Tue, 22 May 2018 20:29:26 -0400 Subject: [PATCH] [V3 RPC] Initial RPC library switch (#1634) * Initial RPC library switch * Use weak refs to the methods so cog unload works * Add docs * Black fixes * Add jsonrpcserver to Pipfile.lock --- Pipfile.lock | 34 ++++++-- docs/framework_rpc.rst | 33 +++++++- redbot/__main__.py | 2 +- redbot/core/bot.py | 24 +----- redbot/core/core_commands.py | 4 - redbot/core/events.py | 3 +- redbot/core/rpc.py | 158 +++++++++++++++++++++++------------ requirements.txt | 2 +- 8 files changed, 170 insertions(+), 90 deletions(-) diff --git a/Pipfile.lock b/Pipfile.lock index 79ed0e8421d..4a710cc19f9 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -32,13 +32,6 @@ ], "version": "==2.2.5" }, - "aiohttp-json-rpc": { - "hashes": [ - "sha256:9ec69ea70ce49c4af445f0ac56ac728708ccfad8b214272d2cc7e75bc0b31327", - "sha256:e2b8b49779d5d9b811f3a94e98092b1fa14af6d9adbf71c3afa6b20c641fa5d5" - ], - "version": "==0.8.7" - }, "appdirs": { "hashes": [ "sha256:9e5896d1372858f8dd3344faf4e5014d21849c756c8d5701f78f8a103b372d92", @@ -83,6 +76,13 @@ "editable": true, "path": "." }, + "funcsigs": { + "hashes": [ + "sha256:330cc27ccbf7f1e992e69fef78261dc7c6569012cf397db8d3de0234e6c937ca", + "sha256:a7bb0f2cf3a3fd1ab2732cb49eba4252c2af4240442415b4abce3b87022a8f50" + ], + "version": "==1.0.2" + }, "idna": { "hashes": [ "sha256:2c6a5de3089009e3da7c5dde64a141dbc8551d5b7f6cf4ed7c2568d0cc520a8f", @@ -90,6 +90,19 @@ ], "version": "==2.6" }, + "jsonrpcserver": { + "hashes": [ + "sha256:ab8013cdee3f65d59c5d3f84c75be76a3492caa0b33ecaa3f0f69906cf3d9e92" + ], + "version": "==3.5.4" + }, + "jsonschema": { + "hashes": [ + "sha256:000e68abd33c972a5248544925a0cae7d1125f9bf6c58280d37546b946769a08", + "sha256:6ff5f3180870836cae40f06fa10419f557208175f13ad7bc26caa77beb1f6e02" + ], + "version": "==2.6.0" + }, "multidict": { "hashes": [ "sha256:1a1d76374a1e7fe93acef96b354a03c1d7f83e7512e225a527d283da0d7ba5e0", @@ -140,6 +153,13 @@ ], "version": "==1.1.1" }, + "six": { + "hashes": [ + "sha256:70e8a77beed4562e7f14fe23a786b54f6296e34344c23bc42f07b15018ff98e9", + "sha256:832dc0e10feb1aa2c68dcc57dbb658f1c7e65b9b61af69048abc87a2db00a0eb" + ], + "version": "==1.11.0" + }, "websockets": { "hashes": [ "sha256:09dfec40e9b73e8808c39ecdbc1733e33915a2b26b90c54566afc0af546a9ec3", diff --git a/docs/framework_rpc.rst b/docs/framework_rpc.rst index e27f9d02a4e..c0190f78cbd 100644 --- a/docs/framework_rpc.rst +++ b/docs/framework_rpc.rst @@ -4,5 +4,36 @@ RPC === -.. automodule:: redbot.core.rpc +.. currentmodule:: redbot.core.rpc + +V3 comes default with an internal RPC server that may be used to remotely control the bot in various ways. +Cogs must register functions to be exposed to RPC clients. +Each of those functions must only take JSON serializable parameters and must return JSON serializable objects. + +To begin, register all methods using individual calls to the :func:`Methods.add` method. + +******** +Examples +******** + +Coming soon to a docs page near you! + +************* +API Reference +************* + +.. py:attribute:: redbot.core.rpc.methods + + An instance of the :class:`Methods` class. + All attempts to register new RPC methods **MUST** use this object. + You should never create a new instance of the :class:`Methods` class! + +RPC +^^^ +.. autoclass:: redbot.core.rpc.RPC + :members: + +Methods +^^^^^^^ +.. autoclass:: redbot.core.rpc.Methods :members: diff --git a/redbot/__main__.py b/redbot/__main__.py index 04e373cbd40..6ad23a8b0ff 100644 --- a/redbot/__main__.py +++ b/redbot/__main__.py @@ -160,7 +160,7 @@ def main(): sentry_log.critical("Fatal Exception", exc_info=e) loop.run_until_complete(red.logout()) finally: - rpc.clean_up() + red.rpc.close() if cleanup_tasks: pending = asyncio.Task.all_tasks(loop=red.loop) gathered = asyncio.gather(*pending, loop=red.loop, return_exceptions=True) diff --git a/redbot/core/bot.py b/redbot/core/bot.py index ccdc4877a14..165210b9003 100644 --- a/redbot/core/bot.py +++ b/redbot/core/bot.py @@ -21,22 +21,9 @@ from . import Config, i18n, commands, rpc from .help_formatter import Help, help as help_ from .sentry import SentryManager -from .utils import TYPE_CHECKING -if TYPE_CHECKING: - from aiohttp_json_rpc import JsonRpc -# noinspection PyUnresolvedReferences -class RpcMethodMixin: - - async def rpc__cogs(self, request): - return list(self.cogs.keys()) - - async def rpc__extensions(self, request): - return list(self.extensions.keys()) - - -class RedBase(BotBase, RpcMethodMixin): +class RedBase(BotBase): """Mixin for the main bot class. This exists because `Red` inherits from `discord.AutoShardedClient`, which @@ -104,10 +91,11 @@ async def prefix_manager(bot, message): self.cog_mgr = CogManager(paths=(str(self.main_dir / "cogs"),)) - self.register_rpc_methods() - super().__init__(formatter=Help(), **kwargs) + if self.rpc_enabled: + self.rpc = rpc.RPC(self) + self.remove_command("help") self.add_command(help_) @@ -275,10 +263,6 @@ def unload_extension(self, name): if pkg_name.startswith("redbot.cogs"): del sys.modules["redbot.cogs"].__dict__[name] - def register_rpc_methods(self): - rpc.add_method("bot", self.rpc__cogs) - rpc.add_method("bot", self.rpc__extensions) - class Red(RedBase, discord.AutoShardedClient): """ diff --git a/redbot/core/core_commands.py b/redbot/core/core_commands.py index 3fe9ed3b948..83d4a357f32 100644 --- a/redbot/core/core_commands.py +++ b/redbot/core/core_commands.py @@ -50,10 +50,6 @@ class Core: def __init__(self, bot): self.bot = bot # type: Red - rpc.add_method("core", self.rpc_load) - rpc.add_method("core", self.rpc_unload) - rpc.add_method("core", self.rpc_reload) - @commands.command(hidden=True) async def ping(self, ctx): """Pong.""" diff --git a/redbot/core/events.py b/redbot/core/events.py index b8356459dee..2d18a3c8eae 100644 --- a/redbot/core/events.py +++ b/redbot/core/events.py @@ -16,7 +16,6 @@ from . import __version__ from .data_manager import storage_type from .utils.chat_formatting import inline, bordered -from .rpc import initialize from colorama import Fore, Style, init log = logging.getLogger("red") @@ -173,7 +172,7 @@ async def on_ready(): print("\nInvite URL: {}\n".format(invite_url)) if bot.rpc_enabled: - await initialize(bot) + await bot.rpc.initialize() @bot.event async def on_error(event_method, *args, **kwargs): diff --git a/redbot/core/rpc.py b/redbot/core/rpc.py index 5828c5e3e9b..5edced84745 100644 --- a/redbot/core/rpc.py +++ b/redbot/core/rpc.py @@ -1,85 +1,135 @@ -import asyncio +import weakref -from aiohttp.web import Application -from aiohttp_json_rpc import JsonRpc +from aiohttp import web +import jsonrpcserver.aio +import inspect import logging -from .utils import TYPE_CHECKING, NewType - -if TYPE_CHECKING: - from .bot import Red +__all__ = ["methods", "RPC", "Methods"] log = logging.getLogger("red.rpc") -JsonSerializable = NewType("JsonSerializable", dict) -_rpc = JsonRpc(logger=log) -_rpc_server = None # type: asyncio.AbstractServer +class Methods(jsonrpcserver.aio.AsyncMethods): + """ + Container class for all registered RPC methods, please use the existing `methods` + attribute rather than creating a new instance of this class. + .. warning:: -async def initialize(bot: "Red"): - global _rpc_server + **NEVER** create a new instance of this class! + """ - app = Application(loop=bot.loop) - app.router.add_route("*", "/rpc", _rpc) + def __init__(self): + super().__init__() - handler = app.make_handler() + self._items = weakref.WeakValueDictionary() - _rpc_server = await bot.loop.create_server(handler, "127.0.0.1", 6133) + def add(self, method, name: str = None): + """ + Registers a method to the internal RPC server making it available for + RPC users to call. - log.debug("Created RPC _rpc_server listener.") + .. important:: + Any method added here must take ONLY JSON serializable parameters and + MUST return a JSON serializable object. -def add_topic(topic_name: str): - """ - Adds a topic for clients to listen to. + Parameters + ---------- + method : function + A reference to the function to register. - Parameters - ---------- - topic_name - """ - _rpc.add_topics(topic_name) + name : str + Name of the function as seen by the RPC clients. + """ + if not inspect.iscoroutinefunction(method): + raise TypeError("Method must be a coroutine.") + if name is None: + name = method.__qualname__ -def notify(topic_name: str, data: JsonSerializable): - """ - Publishes a notification for the given topic name to all listening clients. + self._items[str(name)] = method - data MUST be json serializable. + def remove(self, *, name: str = None, method=None): + """ + Unregisters an RPC method. Either a name or reference to the method must + be provided and name will take priority. - note:: + Parameters + ---------- + name : str + method : function + """ + if name and name in self._items: + del self._items[name] - This method will fail silently. + elif method and method in self._items.values(): + to_remove = [] + for name, val in self._items.items(): + if method == val: + to_remove.append(name) - Parameters - ---------- - topic_name - data - """ - _rpc.notify(topic_name, data) + for name in to_remove: + del self._items[name] + def all_methods(self): + """ + Lists all available method names. -def add_method(prefix, method): - """ - Makes a method available to RPC clients. The name given to clients will be as - follows:: + Returns + ------- + list of str + """ + return self._items.keys() - "{}__{}".format(prefix, method.__name__) - note:: +methods = Methods() - This method will fail silently. - Parameters - ---------- - prefix - method - MUST BE A COROUTINE OR OBJECT. - """ - _rpc.add_methods(("", method), prefix=prefix) +class BaseRPCMethodMixin: + + def __init__(self): + methods.add(self.all_methods, name="all_methods") + + async def all_methods(self): + return list(methods.all_methods()) -def clean_up(): - if _rpc_server is not None: - _rpc_server.close() +class RPC(BaseRPCMethodMixin): + """ + RPC server manager. + """ + + def __init__(self, bot): + self.app = web.Application(loop=bot.loop) + self.app.router.add_post("/rpc", self.handle) + + self.app_handler = self.app.make_handler() + + self.server = None + + super().__init__() + + async def initialize(self): + """ + Finalizes the initialization of the RPC server and allows it to begin + accepting queries. + """ + self.server = await self.app.loop.create_server(self.app_handler, "127.0.0.1", 6133) + log.debug("Created RPC server listener.") + + def close(self): + """ + Closes the RPC server. + """ + self.server.close() + + async def handle(self, request): + request = await request.text() + response = await methods.dispatch(request) + if response.is_notification: + return web.Response() + else: + return web.json_response(response, status=response.http_status) diff --git a/requirements.txt b/requirements.txt index dffc488ada6..de1c3cb7458 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,7 +3,7 @@ aiohttp>=2.0.0,<2.3.0 appdirs==1.4.3 raven==6.5.0 colorama==0.3.9 -aiohttp-json-rpc==0.8.7 +jsonrpcserver pyyaml==3.12 Red-Trivia>=1.1.1 async-timeout<3.0.0