1# Copyright (c) 2018-2019, Neil Booth
2#
3# All rights reserved.
4#
5# The MIT License (MIT)
6#
7# Permission is hereby granted, free of charge, to any person obtaining
8# a copy of this software and associated documentation files (the
9# "Software"), to deal in the Software without restriction, including
10# without limitation the rights to use, copy, modify, merge, publish,
11# distribute, sublicense, and/or sell copies of the Software, and to
12# permit persons to whom the Software is furnished to do so, subject to
13# the following conditions:
14#
15# The above copyright notice and this permission notice shall be
16# included in all copies or substantial portions of the Software.
17#
18# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
19# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
20# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
21# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
22# LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
23# OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
24# WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
25
26'''Classes for JSONRPC versions 1.0 and 2.0, and a loose interpretation.'''
27
28__all__ = ('JSONRPC', 'JSONRPCv1', 'JSONRPCv2', 'JSONRPCLoose',
29           'JSONRPCAutoDetect', 'Request', 'Notification', 'Batch',
30           'RPCError', 'ProtocolError', 'JSONRPCConnection', 'handler_invocation')
31
32import itertools
33import json
34from functools import partial
35from numbers import Number
36
37from asyncio import get_event_loop
38from aiorpcx.util import signature_info
39
40
41class SingleRequest:
42    __slots__ = ('method', 'args')
43
44    def __init__(self, method, args):
45        if not isinstance(method, str):
46            raise ProtocolError(JSONRPC.METHOD_NOT_FOUND,
47                                'method must be a string')
48        if not isinstance(args, (list, tuple, dict)):
49            raise ProtocolError.invalid_args('request arguments must be a '
50                                             'list or a dictionary')
51        self.args = args
52        self.method = method
53
54    def __repr__(self):
55        return f'{self.__class__.__name__}({self.method!r}, {self.args!r})'
56
57    def __eq__(self, other):
58        return (isinstance(other, self.__class__) and
59                self.method == other.method and self.args == other.args)
60
61
62class Request(SingleRequest):
63    def send_result(self, _response):
64        return None
65
66
67class Notification(SingleRequest):
68    pass
69
70
71class Batch:
72    __slots__ = ('items', )
73
74    def __init__(self, items):
75        if not isinstance(items, (list, tuple)):
76            raise ProtocolError.invalid_request('items must be a list')
77        if not items:
78            raise ProtocolError.empty_batch()
79        if not (all(isinstance(item, SingleRequest) for item in items) or
80                all(isinstance(item, Response) for item in items)):
81            raise ProtocolError.invalid_request('batch must be homogeneous')
82        self.items = items
83
84    def __len__(self):
85        return len(self.items)
86
87    def __getitem__(self, item):
88        return self.items[item]
89
90    def __iter__(self):
91        return iter(self.items)
92
93    def __repr__(self):
94        return f'Batch({len(self.items)} items)'
95
96
97class Response:
98    __slots__ = ('result', )
99
100    def __init__(self, result):
101        # Type checking happens when converting to a message
102        self.result = result
103
104
105class CodeMessageError(Exception):
106    '''Invoke as CodeMessageError(code, message)'''
107
108    @property
109    def code(self):
110        return self.args[0]
111
112    @property
113    def message(self):
114        return self.args[1]
115
116    def __eq__(self, other):
117        return (isinstance(other, self.__class__) and
118                self.code == other.code and self.message == other.message)
119
120    def __hash__(self):
121        # overridden to make the exception hashable
122        # see https://bugs.python.org/issue28603
123        return hash((self.code, self.message))
124
125    @classmethod
126    def invalid_args(cls, message):
127        return cls(JSONRPC.INVALID_ARGS, message)
128
129    @classmethod
130    def invalid_request(cls, message):
131        return cls(JSONRPC.INVALID_REQUEST, message)
132
133    @classmethod
134    def empty_batch(cls):
135        return cls.invalid_request('batch is empty')
136
137
138class RPCError(CodeMessageError):
139
140    def __init__(self, code, message, *, cost=0.0):
141        super().__init__(code, message)
142        self.cost = cost
143
144
145class ProtocolError(CodeMessageError):
146
147    def __init__(self, code, message):
148        super().__init__(code, message)
149        # If not None send this unframed message over the network
150        self.error_message = None
151        # If the error was in a JSON response message; its message ID.
152        # Since None can be a response message ID, "id" means the
153        # error was not sent in a JSON response
154        self.response_msg_id = id
155
156
157class JSONRPC:
158    '''Abstract base class that interprets and constructs JSON RPC messages.'''
159
160    # Error codes.  See http://www.jsonrpc.org/specification
161    PARSE_ERROR = -32700
162    INVALID_REQUEST = -32600
163    METHOD_NOT_FOUND = -32601
164    INVALID_ARGS = -32602
165    INTERNAL_ERROR = -32603
166    # Codes specific to this library
167    ERROR_CODE_UNAVAILABLE = -100
168    EXCESSIVE_RESOURCE_USAGE = -101
169    SERVER_BUSY = -102
170
171    # Can be overridden by derived classes
172    allow_batches = True
173
174    @classmethod
175    def _message_id(cls, message, require_id):
176        '''Validate the message is a dictionary and return its ID.
177
178        Raise an error if the message is invalid or the ID is of an
179        invalid type.  If it has no ID, raise an error if require_id
180        is True, otherwise return None.
181        '''
182        raise NotImplementedError
183
184    @classmethod
185    def _validate_message(cls, message):
186        '''Validate other parts of the message other than those
187        done in _message_id.'''
188
189    @classmethod
190    def _request_args(cls, request):
191        '''Validate the existence and type of the arguments passed
192        in the request dictionary.'''
193        raise NotImplementedError
194
195    @classmethod
196    def _process_request(cls, payload):
197        request_id = None
198        try:
199            request_id = cls._message_id(payload, False)
200            cls._validate_message(payload)
201            method = payload.get('method')
202            if request_id is None:
203                item = Notification(method, cls._request_args(payload))
204            else:
205                item = Request(method, cls._request_args(payload))
206            return item, request_id
207        except ProtocolError as error:
208            code, message = error.code, error.message
209        raise cls._error(code, message, True, request_id)
210
211    @classmethod
212    def _process_response(cls, payload):
213        request_id = None
214        try:
215            request_id = cls._message_id(payload, True)
216            cls._validate_message(payload)
217            return Response(cls.response_value(payload)), request_id
218        except ProtocolError as error:
219            code, message = error.code, error.message
220        raise cls._error(code, message, False, request_id)
221
222    @classmethod
223    def _message_to_payload(cls, message):
224        '''Returns a Python object or a ProtocolError.'''
225        try:
226            return json.loads(message.decode())
227        except UnicodeDecodeError:
228            message = 'messages must be encoded in UTF-8'
229        except json.JSONDecodeError:
230            message = 'invalid JSON'
231        raise cls._error(cls.PARSE_ERROR, message, True, None)
232
233    @classmethod
234    def _error(cls, code, message, send, msg_id):
235        error = ProtocolError(code, message)
236        if send:
237            error.error_message = cls.response_message(error, msg_id)
238        else:
239            error.response_msg_id = msg_id
240        return error
241
242    #
243    # External API
244    #
245
246    @classmethod
247    def message_to_item(cls, message):
248        '''Translate an unframed received message and return an
249        (item, request_id) pair.
250
251        The item can be a Request, Notification, Response or a list.
252
253        A JSON RPC error response is returned as an RPCError inside a
254        Response object.
255
256        If a Batch is returned, request_id is an iterable of request
257        ids, one per batch member.
258
259        If the message violates the protocol in some way a
260        ProtocolError is returned, except if the message was
261        determined to be a response, in which case the ProtocolError
262        is placed inside a Response object.  This is so that client
263        code can mark a request as having been responded to even if
264        the response was bad.
265
266        raises: ProtocolError
267        '''
268        payload = cls._message_to_payload(message)
269        if isinstance(payload, dict):
270            if 'method' in payload:
271                return cls._process_request(payload)
272            else:
273                return cls._process_response(payload)
274        elif isinstance(payload, list) and cls.allow_batches:
275            if not payload:
276                raise cls._error(JSONRPC.INVALID_REQUEST, 'batch is empty',
277                                 True, None)
278            return payload, None
279        raise cls._error(cls.INVALID_REQUEST,
280                         'request object must be a dictionary', True, None)
281
282    # Message formation
283    @classmethod
284    def request_message(cls, item, request_id):
285        '''Convert an RPCRequest item to a message.'''
286        assert isinstance(item, Request)
287        return cls.encode_payload(cls.request_payload(item, request_id))
288
289    @classmethod
290    def notification_message(cls, item):
291        '''Convert an RPCRequest item to a message.'''
292        assert isinstance(item, Notification)
293        return cls.encode_payload(cls.request_payload(item, None))
294
295    @classmethod
296    def response_message(cls, result, request_id):
297        '''Convert a response result (or RPCError) to a message.'''
298        if isinstance(result, CodeMessageError):
299            payload = cls.error_payload(result, request_id)
300        else:
301            payload = cls.response_payload(result, request_id)
302        return cls.encode_payload(payload)
303
304    @classmethod
305    def batch_message(cls, batch, request_ids):
306        '''Convert a request Batch to a message.'''
307        assert isinstance(batch, Batch)
308        if not cls.allow_batches:
309            raise ProtocolError.invalid_request(
310                'protocol does not permit batches')
311        id_iter = iter(request_ids)
312        rm = cls.request_message
313        nm = cls.notification_message
314        parts = (rm(request, next(id_iter)) if isinstance(request, Request)
315                 else nm(request) for request in batch)
316        return cls.batch_message_from_parts(parts)
317
318    @classmethod
319    def batch_message_from_parts(cls, messages):
320        '''Convert messages, one per batch item, into a batch message.  At
321        least one message must be passed.
322        '''
323        # Comma-separate the messages and wrap the lot in square brackets
324        middle = b', '.join(messages)
325        if not middle:
326            raise ProtocolError.empty_batch()
327        return b''.join([b'[', middle, b']'])
328
329    @classmethod
330    def encode_payload(cls, payload):
331        '''Encode a Python object as JSON and convert it to bytes.'''
332        try:
333            return json.dumps(payload).encode()
334        except TypeError:
335            msg = f'JSON payload encoding error: {payload}'
336            raise ProtocolError(cls.INTERNAL_ERROR, msg) from None
337
338
339class JSONRPCv1(JSONRPC):
340    '''JSON RPC version 1.0.'''
341
342    allow_batches = False
343
344    @classmethod
345    def _message_id(cls, message, require_id):
346        # JSONv1 requires an ID always, but without constraint on its type
347        # No need to test for a dictionary here as we don't handle batches.
348        if 'id' not in message:
349            raise ProtocolError.invalid_request('request has no "id"')
350        return message['id']
351
352    @classmethod
353    def _request_args(cls, request):
354        args = request.get('params')
355        if not isinstance(args, list):
356            raise ProtocolError.invalid_args(
357                f'invalid request arguments: {args}')
358        return args
359
360    @classmethod
361    def _best_effort_error(cls, error):
362        # Do our best to interpret the error
363        code = cls.ERROR_CODE_UNAVAILABLE
364        message = 'no error message provided'
365        if isinstance(error, str):
366            message = error
367        elif isinstance(error, int):
368            code = error
369        elif isinstance(error, dict):
370            if isinstance(error.get('message'), str):
371                message = error['message']
372            if isinstance(error.get('code'), int):
373                code = error['code']
374
375        return RPCError(code, message)
376
377    @classmethod
378    def response_value(cls, payload):
379        if 'result' not in payload or 'error' not in payload:
380            raise ProtocolError.invalid_request(
381                'response must contain both "result" and "error"')
382
383        result = payload['result']
384        error = payload['error']
385        if error is None:
386            return result   # It seems None can be a valid result
387        if result is not None:
388            raise ProtocolError.invalid_request(
389                'response has a "result" and an "error"')
390
391        return cls._best_effort_error(error)
392
393    @classmethod
394    def request_payload(cls, request, request_id):
395        '''JSON v1 request (or notification) payload.'''
396        if isinstance(request.args, dict):
397            raise ProtocolError.invalid_args(
398                'JSONRPCv1 does not support named arguments')
399        return {
400            'method': request.method,
401            'params': request.args,
402            'id': request_id
403        }
404
405    @classmethod
406    def response_payload(cls, result, request_id):
407        '''JSON v1 response payload.'''
408        return {
409            'result': result,
410            'error': None,
411            'id': request_id
412        }
413
414    @classmethod
415    def error_payload(cls, error, request_id):
416        return {
417            'result': None,
418            'error': {'code': error.code, 'message': error.message},
419            'id': request_id
420        }
421
422
423class JSONRPCv2(JSONRPC):
424    '''JSON RPC version 2.0.'''
425
426    @classmethod
427    def _message_id(cls, message, require_id):
428        if not isinstance(message, dict):
429            raise ProtocolError.invalid_request(
430                'request object must be a dictionary')
431        if 'id' in message:
432            request_id = message['id']
433            if not isinstance(request_id, (Number, str, type(None))):
434                raise ProtocolError.invalid_request(
435                    f'invalid "id": {request_id}')
436            return request_id
437        else:
438            if require_id:
439                raise ProtocolError.invalid_request('request has no "id"')
440            return None
441
442    @classmethod
443    def _validate_message(cls, message):
444        if message.get('jsonrpc') != '2.0':
445            raise ProtocolError.invalid_request('"jsonrpc" is not "2.0"')
446
447    @classmethod
448    def _request_args(cls, request):
449        args = request.get('params', [])
450        if not isinstance(args, (dict, list)):
451            raise ProtocolError.invalid_args(
452                f'invalid request arguments: {args}')
453        return args
454
455    @classmethod
456    def response_value(cls, payload):
457        if 'result' in payload:
458            if 'error' in payload:
459                raise ProtocolError.invalid_request(
460                    'response contains both "result" and "error"')
461            return payload['result']
462
463        if 'error' not in payload:
464            raise ProtocolError.invalid_request(
465                'response contains neither "result" nor "error"')
466
467        # Return an RPCError object
468        error = payload['error']
469        if isinstance(error, dict):
470            code = error.get('code')
471            message = error.get('message')
472            if isinstance(code, int) and isinstance(message, str):
473                return RPCError(code, message)
474
475        raise ProtocolError.invalid_request(
476            f'ill-formed response error object: {error}')
477
478    @classmethod
479    def request_payload(cls, request, request_id):
480        '''JSON v2 request (or notification) payload.'''
481        payload = {
482            'jsonrpc': '2.0',
483            'method': request.method,
484        }
485        # A notification?
486        if request_id is not None:
487            payload['id'] = request_id
488        # Preserve empty dicts as missing params is read as an array
489        if request.args or request.args == {}:
490            payload['params'] = request.args
491        return payload
492
493    @classmethod
494    def response_payload(cls, result, request_id):
495        '''JSON v2 response payload.'''
496        return {
497            'jsonrpc': '2.0',
498            'result': result,
499            'id': request_id
500        }
501
502    @classmethod
503    def error_payload(cls, error, request_id):
504        return {
505            'jsonrpc': '2.0',
506            'error': {'code': error.code, 'message': error.message},
507            'id': request_id
508        }
509
510
511class JSONRPCLoose(JSONRPC):
512    '''A relaxed versin of JSON RPC.'''
513
514    # Don't be so loose we accept any old message ID
515    _message_id = JSONRPCv2._message_id
516    _validate_message = JSONRPC._validate_message
517    _request_args = JSONRPCv2._request_args
518    # Outoing messages are JSONRPCv2 so we give the other side the
519    # best chance to assume / detect JSONRPCv2 as default protocol.
520    error_payload = JSONRPCv2.error_payload
521    request_payload = JSONRPCv2.request_payload
522    response_payload = JSONRPCv2.response_payload
523
524    @classmethod
525    def response_value(cls, payload):
526        # Return result, unless it is None and there is an error
527        if payload.get('error') is not None:
528            if payload.get('result') is not None:
529                raise ProtocolError.invalid_request(
530                    'response contains both "result" and "error"')
531            return JSONRPCv1._best_effort_error(payload['error'])
532
533        if 'result' not in payload:
534            raise ProtocolError.invalid_request(
535                'response contains neither "result" nor "error"')
536
537        # Can be None
538        return payload['result']
539
540
541class JSONRPCAutoDetect(JSONRPCv2):
542
543    @classmethod
544    def detect_protocol(cls, message):
545        '''Attempt to detect the protocol from the message.'''
546        main = cls._message_to_payload(message)
547
548        def protocol_for_payload(payload):
549            if not isinstance(payload, dict):
550                return JSONRPCLoose   # Will error
551            # Obey an explicit "jsonrpc"
552            version = payload.get('jsonrpc')
553            if version == '2.0':
554                return JSONRPCv2
555            if version == '1.0':
556                return JSONRPCv1
557
558            # Now to decide between JSONRPCLoose and JSONRPCv1 if possible
559            if 'result' in payload and 'error' in payload:
560                return JSONRPCv1
561            return JSONRPCLoose
562
563        if isinstance(main, list):
564            parts = set(protocol_for_payload(payload) for payload in main)
565            # If all same protocol, return it
566            if len(parts) == 1:
567                return parts.pop()
568            # If strict protocol detected, return it, preferring JSONRPCv2.
569            # This means a batch of JSONRPCv1 will fail
570            for protocol in (JSONRPCv2, JSONRPCv1):
571                if protocol in parts:
572                    return protocol
573            # Will error if no parts
574            return JSONRPCLoose
575
576        return protocol_for_payload(main)
577
578
579class JSONRPCConnection:
580    '''Maintains state of a JSON RPC connection, in particular
581    encapsulating the handling of request IDs.
582
583    protocol - the JSON RPC protocol to follow
584    max_response_size - responses over this size send an error response
585        instead.
586    '''
587
588    def __init__(self, protocol):
589        self._protocol = protocol
590        self._id_counter = itertools.count()
591        # Sent Requests and Batches that have not received a response.
592        # The key is its request ID; for a batch it is sorted tuple
593        # of request IDs
594        self._requests = {}
595        self._create_future = get_event_loop().create_future
596        # A public attribute intended to be settable dynamically
597        self.max_response_size = 0
598
599    def _oversized_response_message(self, request_id):
600        text = f'response too large (over {self.max_response_size:,d} bytes'
601        error = RPCError.invalid_request(text)
602        return self._protocol.response_message(error, request_id)
603
604    def _receive_response(self, result, request_id):
605        if request_id not in self._requests:
606            if request_id is None and isinstance(result, RPCError):
607                message = f'diagnostic error received: {result}'
608            else:
609                message = f'response to unsent request (ID: {request_id})'
610            raise ProtocolError.invalid_request(message) from None
611        _request, future = self._requests.pop(request_id)
612        if not future.done():
613            if isinstance(result, Exception):
614                future.set_exception(result)
615            else:
616                future.set_result(result)
617        return []
618
619    def _receive_request_batch(self, payloads):
620        def item_send_result(request_id, result):
621            nonlocal size
622            part = protocol.response_message(result, request_id)
623            size += len(part) + 2
624            if size > self.max_response_size > 0:
625                part = self._oversized_response_message(request_id)
626            parts.append(part)
627            if len(parts) == count:
628                return protocol.batch_message_from_parts(parts)
629            return None
630
631        parts = []
632        items = []
633        size = 0
634        count = 0
635        protocol = self._protocol
636        for payload in payloads:
637            try:
638                item, request_id = protocol._process_request(payload)
639                items.append(item)
640                if isinstance(item, Request):
641                    count += 1
642                    item.send_result = partial(item_send_result, request_id)
643            except ProtocolError as error:
644                count += 1
645                parts.append(error.error_message)
646
647        if not items and parts:
648            error = ProtocolError(0, "")
649            error.error_message = protocol.batch_message_from_parts(parts)
650            raise error
651        return items
652
653    def _receive_response_batch(self, payloads):
654        request_ids = []
655        results = []
656        for payload in payloads:
657            # Let ProtocolError exceptions through
658            item, request_id = self._protocol._process_response(payload)
659            request_ids.append(request_id)
660            results.append(item.result)
661
662        ordered = sorted(zip(request_ids, results), key=lambda t: t[0])
663        ordered_ids, ordered_results = zip(*ordered)
664        if ordered_ids not in self._requests:
665            raise ProtocolError.invalid_request('response to unsent batch')
666        _request_batch, future = self._requests.pop(ordered_ids)
667        if not future.done():
668            future.set_result(ordered_results)
669        return []
670
671    def _send_result(self, request_id, result):
672        message = self._protocol.response_message(result, request_id)
673        if len(message) > self.max_response_size > 0:
674            message = self._oversized_response_message(request_id)
675        return message
676
677    def _future(self, request, request_id):
678        future = self._create_future()
679        self._requests[request_id] = (request, future)
680        return future
681
682    #
683    # External API
684    #
685    def send_request(self, request):
686        '''Send a Request.  Return a (message, event) pair.
687
688        The message is an unframed message to send over the network.
689        Wait on the event for the response; which will be in the
690        "result" attribute.
691
692        Raises: ProtocolError if the request violates the protocol
693        in some way..
694        '''
695        request_id = next(self._id_counter)
696        message = self._protocol.request_message(request, request_id)
697        return message, self._future(request, request_id)
698
699    def send_notification(self, notification):
700        return self._protocol.notification_message(notification)
701
702    def send_batch(self, batch):
703        ids = tuple(next(self._id_counter)
704                    for request in batch if isinstance(request, Request))
705        message = self._protocol.batch_message(batch, ids)
706        event = self._future(batch, ids) if ids else None
707        return message, event
708
709    def receive_message(self, message):
710        '''Call with an unframed message received from the network.
711
712        Raises: ProtocolError if the message violates the protocol in
713        some way.  However, if it happened in a response that can be
714        paired with a request, the ProtocolError is instead set in the
715        result attribute of the send_request() that caused the error.
716        '''
717        if self._protocol is JSONRPCAutoDetect:
718            self._protocol = JSONRPCAutoDetect.detect_protocol(message)
719
720        try:
721            item, request_id = self._protocol.message_to_item(message)
722        except ProtocolError as e:
723            if e.response_msg_id is not id:
724                return self._receive_response(e, e.response_msg_id)
725            raise
726
727        if isinstance(item, Request):
728            item.send_result = partial(self._send_result, request_id)
729            return [item]
730        if isinstance(item, Notification):
731            return [item]
732        if isinstance(item, Response):
733            return self._receive_response(item.result, request_id)
734
735        assert isinstance(item, list)
736        if all(isinstance(payload, dict) and ('result' in payload or 'error' in payload)
737               for payload in item):
738            return self._receive_response_batch(item)
739        else:
740            return self._receive_request_batch(item)
741
742    def cancel_pending_requests(self):
743        '''Cancel all pending requests.'''
744        for _request, future in self._requests.values():
745            if not future.done():
746                future.cancel()
747        self._requests.clear()
748
749    def pending_requests(self):
750        '''All sent requests that have not received a response.'''
751        return [request for request, event in self._requests.values()]
752
753
754def handler_invocation(handler, request):
755    method, args = request.method, request.args
756    if handler is None:
757        raise RPCError(JSONRPC.METHOD_NOT_FOUND,
758                       f'unknown method "{method}"')
759
760    # We must test for too few and too many arguments.  How
761    # depends on whether the arguments were passed as a list or as
762    # a dictionary.
763    info = signature_info(handler)
764    if isinstance(args, (tuple, list)):
765        if len(args) < info.min_args:
766            s = '' if len(args) == 1 else 's'
767            raise RPCError.invalid_args(
768                f'{len(args)} argument{s} passed to method '
769                f'"{method}" but it requires {info.min_args}')
770        if info.max_args is not None and len(args) > info.max_args:
771            s = '' if len(args) == 1 else 's'
772            raise RPCError.invalid_args(
773                f'{len(args)} argument{s} passed to method '
774                f'{method} taking at most {info.max_args}')
775        return partial(handler, *args)
776
777    # Arguments passed by name
778    if info.other_names is None:
779        raise RPCError.invalid_args(f'method "{method}" cannot '
780                                    f'be called with named arguments')
781
782    missing = set(info.required_names).difference(args)
783    if missing:
784        s = '' if len(missing) == 1 else 's'
785        missing = ', '.join(sorted(f'"{name}"' for name in missing))
786        raise RPCError.invalid_args(f'method "{method}" requires '
787                                    f'parameter{s} {missing}')
788
789    if info.other_names is not any:
790        excess = set(args).difference(info.required_names)
791        excess = excess.difference(info.other_names)
792        if excess:
793            s = '' if len(excess) == 1 else 's'
794            excess = ', '.join(sorted(f'"{name}"' for name in excess))
795            raise RPCError.invalid_args(f'method "{method}" does not '
796                                        f'take parameter{s} {excess}')
797    return partial(handler, **args)
798