Repository: pypi/warehouse

Issue:
XML-RPC doesn't support MultiCall
New API differ from the old API (`pypi.python.org/pypi/`):

* ~`search` method work different e.g. `search({'keywords': "orange3 add-on"})` returns empty list because of space~
* `MultiCall` is not supported. 

I know that this API is deprecated but would ask if there is an option to make functionalities work same to support old versions of the softwares.


Discussion:
Search issue is likely #3717

MultiCall must not be supported by our RPC implementation, https://github.com/Pylons/pyramid_rpc
@PrimozGodec if you point to https://legacy.pypi.org does that address your needs for the time being, and eventually do you want/expect the new API to support some of the same functionality?
FTR, something like this (untested) in warehouse/legacy/api/xmlrpc/views.py is one way to go to support this:

```
@xmlrpc_method(method='system.multicall')
def multicall(request):
    args = request.rpc_args
    responses = []
    for arg in args:
        name = arg.get('methodName')
        params = arg.get('params')
        if name == 'system.multicall':
            raise Exception
        if not name:
            raise Exception
        subreq = Request.blank('/RPC2', base_url=request.application_url)
        subreq.method = 'POST'
        subreq.body = xmlrpclib.dumps(arg)
        response = request.invoke_subrequest(subreq, use_tweens=True)
        responses.append(xmlrpclib.loads(response.body))
    return responses
```

Resolving patch:
diff --git a/warehouse/legacy/api/xmlrpc/views.py b/warehouse/legacy/api/xmlrpc/views.py
--- a/warehouse/legacy/api/xmlrpc/views.py
+++ b/warehouse/legacy/api/xmlrpc/views.py
@@ -13,10 +13,12 @@
 import collections.abc
 import datetime
 import functools
+import xmlrpc.client
 import xmlrpc.server
 
 from elasticsearch_dsl import Q
 from packaging.utils import canonicalize_name
+from pyramid.request import Request
 from pyramid.view import view_config
 from pyramid_rpc.xmlrpc import (
     exception_view as _exception_view, xmlrpc_method as _xmlrpc_method
@@ -31,6 +33,9 @@
 )
 
 
+_MAX_MULTICALLS = 20
+
+
 def xmlrpc_method(**kwargs):
     """
     Support multiple endpoints serving the same views by chaining calls to
@@ -441,3 +446,49 @@ def browse(request, classifiers):
     )
 
     return [(r.name, r.version) for r in releases]
+
+
+def measure_response_content_length(metric_name):
+
+    def _callback(request, response):
+        request.registry.datadog.histogram(
+            metric_name, response.content_length
+        )
+
+    return _callback
+
+
+@xmlrpc_method(method='system.multicall')
+def multicall(request, args):
+    if any(arg.get('methodName') == 'system.multicall' for arg in args):
+        raise XMLRPCWrappedError(
+            ValueError('Cannot use system.multicall inside a multicall')
+        )
+
+    if not all(arg.get('methodName') for arg in args):
+        raise XMLRPCWrappedError(ValueError('Method name not provided'))
+
+    if len(args) > _MAX_MULTICALLS:
+        raise XMLRPCWrappedError(
+            ValueError(f'Multicall limit is {_MAX_MULTICALLS} calls')
+        )
+
+    responses = []
+    for arg in args:
+        name = arg.get('methodName')
+        subreq = Request.blank('/RPC2', headers={'Content-Type': 'text/xml'})
+        subreq.method = 'POST'
+        subreq.body = xmlrpc.client.dumps(
+            tuple(arg.get('params')),
+            methodname=name,
+        ).encode()
+        response = request.invoke_subrequest(subreq, use_tweens=True)
+        responses.append(xmlrpc.client.loads(response.body))
+
+    request.add_response_callback(
+        measure_response_content_length(
+            'warehouse.xmlrpc.system.multicall.content_length'
+        )
+    )
+
+    return responses


Test patch:
diff --git a/tests/unit/legacy/api/xmlrpc/test_xmlrpc.py b/tests/unit/legacy/api/xmlrpc/test_xmlrpc.py
--- a/tests/unit/legacy/api/xmlrpc/test_xmlrpc.py
+++ b/tests/unit/legacy/api/xmlrpc/test_xmlrpc.py
@@ -792,3 +792,112 @@ def test_browse(db_request):
             "Programming Language :: Python",
         ],
     )) == {(expected_release.name, expected_release.version)}
+
+
+class TestMulticall:
+
+    def test_multicall(self, monkeypatch):
+        dumped = pretend.stub(encode=lambda: None)
+        dumps = pretend.call_recorder(lambda *a, **kw: dumped)
+        monkeypatch.setattr(xmlrpc.xmlrpc.client, 'dumps', dumps)
+
+        loaded = pretend.stub()
+        loads = pretend.call_recorder(lambda *a, **kw: loaded)
+        monkeypatch.setattr(xmlrpc.xmlrpc.client, 'loads', loads)
+
+        subreq = pretend.stub()
+        blank = pretend.call_recorder(lambda *a, **kw: subreq)
+        monkeypatch.setattr(xmlrpc.Request, 'blank', blank)
+
+        body = pretend.stub()
+        response = pretend.stub(body=body)
+
+        request = pretend.stub(
+            invoke_subrequest=pretend.call_recorder(lambda *a, **kw: response),
+            add_response_callback=pretend.call_recorder(
+                lambda *a, **kw: response),
+        )
+
+        callback = pretend.stub()
+        monkeypatch.setattr(
+            xmlrpc,
+            'measure_response_content_length',
+            pretend.call_recorder(lambda metric_name: callback)
+        )
+
+        args = [
+            {'methodName': 'search', 'params': [{'name': 'foo'}]},
+            {'methodName': 'browse', 'params': [{'classifiers': ['bar']}]},
+        ]
+
+        responses = xmlrpc.multicall(request, args)
+
+        assert responses == [loaded, loaded]
+        assert blank.calls == [
+            pretend.call('/RPC2', headers={'Content-Type': 'text/xml'}),
+            pretend.call('/RPC2', headers={'Content-Type': 'text/xml'}),
+        ]
+        assert request.invoke_subrequest.calls == [
+            pretend.call(subreq, use_tweens=True),
+            pretend.call(subreq, use_tweens=True),
+        ]
+        assert request.add_response_callback.calls == [
+            pretend.call(callback),
+        ]
+        assert dumps.calls == [
+            pretend.call(({'name': 'foo'},), methodname='search'),
+            pretend.call(({'classifiers': ['bar']},), methodname='browse'),
+        ]
+        assert loads.calls == [pretend.call(body), pretend.call(body)]
+
+    def test_recursive_multicall(self):
+        request = pretend.stub()
+        args = [
+            {'methodName': 'system.multicall', 'params': []},
+        ]
+        with pytest.raises(xmlrpc.XMLRPCWrappedError) as exc:
+            xmlrpc.multicall(request, args)
+
+        assert exc.value.faultString == (
+            'ValueError: Cannot use system.multicall inside a multicall'
+        )
+
+    def test_missing_multicall_method(self):
+        request = pretend.stub()
+        args = [{}]
+        with pytest.raises(xmlrpc.XMLRPCWrappedError) as exc:
+            xmlrpc.multicall(request, args)
+
+        assert exc.value.faultString == (
+            'ValueError: Method name not provided'
+        )
+
+    def test_too_many_multicalls_method(self):
+        request = pretend.stub()
+        args = [{'methodName': 'nah'}] * 21
+
+        with pytest.raises(xmlrpc.XMLRPCWrappedError) as exc:
+            xmlrpc.multicall(request, args)
+
+        assert exc.value.faultString == (
+            'ValueError: Multicall limit is 20 calls'
+        )
+
+    def test_measure_response_content_length(self):
+        metric_name = 'some_metric_name'
+        callback = xmlrpc.measure_response_content_length(metric_name)
+
+        request = pretend.stub(
+            registry=pretend.stub(
+                datadog=pretend.stub(
+                    histogram=pretend.call_recorder(lambda *a: None)
+                )
+            )
+        )
+        response = pretend.stub(content_length=pretend.stub())
+
+        callback(request, response)
+
+        assert request.registry.datadog.histogram.calls == [
+            pretend.call(metric_name, response.content_length),
+        ]