Skip to content

Commit 02c1d9d

Browse files
authored
Add type checking to our XMLRPC API (pypi#4575)
1 parent 44ba0ef commit 02c1d9d

File tree

5 files changed

+51
-27
lines changed

5 files changed

+51
-27
lines changed

requirements/main.in

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ sqlalchemy-citext
4949
stdlib-list
5050
structlog
5151
transaction
52+
typeguard
5253
whitenoise
5354
WTForms>=2.0.0
5455
zope.sqlalchemy

requirements/main.txt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -522,6 +522,9 @@ transaction==2.2.1 \
522522
translationstring==1.3 \
523523
--hash=sha256:4ee44cfa58c52ade8910ea0ebc3d2d84bdcad9fa0422405b1801ec9b9a65b72d \
524524
--hash=sha256:e26c7bf383413234ed442e0980a2ebe192b95e3745288a8fd2805156d27515b4
525+
typeguard==2.2.2 \
526+
--hash=sha256:13ecd6ca9cd4a8b7965138c5782e3ab3711096f573c91b3ce120ac4764a6a233 \
527+
--hash=sha256:b8ddc6e2e60bd64b7003f9a685a09ba387b74adf2f6bea7534a76d61892f573e
525528
urllib3==1.22 \
526529
--hash=sha256:06330f386d6e4b195fbfc736b297f58c5a892e4440e54d294d7004e3a9bbea1b \
527530
--hash=sha256:cc44da8e1145637334317feebd728bd869a35285b93cbb4cca2577da7e62db4f

tests/functional/legacy_api/test_xmlrpc.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,15 @@ def test_xmlrpc_succeeds(app_config, webtest, metrics):
3333

3434
def test_invalid_arguments(app_config, webtest):
3535
with pytest.raises(
36-
xmlrpc.client.Fault, match="server error; invalid method params"
36+
xmlrpc.client.Fault,
37+
match="client error; missing a required argument: 'package_name'",
3738
):
3839
webtest.xmlrpc("/pypi", "package_releases")
40+
41+
42+
def test_arguments_with_wrong_type(app_config, webtest):
43+
with pytest.raises(
44+
xmlrpc.client.Fault,
45+
match='client error; type of argument "serial" must be int; got str instead',
46+
):
47+
webtest.xmlrpc("/pypi", "changelog_since_serial", "wrong!")

tests/unit/legacy/api/xmlrpc/test_xmlrpc.py

Lines changed: 0 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -40,16 +40,6 @@ def test_fails_with_invalid_operator(self, pyramid_request, metrics):
4040
)
4141
assert metrics.histogram.calls == []
4242

43-
def test_fails_if_spec_not_mapping(self, pyramid_request, metrics):
44-
with pytest.raises(xmlrpc.XMLRPCWrappedError) as exc:
45-
xmlrpc.search(pyramid_request, "a string")
46-
47-
assert (
48-
exc.value.faultString
49-
== "TypeError: Invalid spec, must be a mapping/dictionary."
50-
)
51-
assert metrics.histogram.calls == []
52-
5343
def test_default_search_operator(self, pyramid_request, metrics):
5444
class FakeQuery:
5545
def __init__(self, type, must):

warehouse/legacy/api/xmlrpc/views.py

Lines changed: 37 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -10,19 +10,23 @@
1010
# See the License for the specific language governing permissions and
1111
# limitations under the License.
1212

13-
import collections.abc
1413
import datetime
1514
import functools
1615
import xmlrpc.client
1716
import xmlrpc.server
1817

18+
from typing import List, Mapping, Union
19+
1920
import elasticsearch
21+
import typeguard
2022

2123
from elasticsearch_dsl import Q
2224
from packaging.utils import canonicalize_name
2325
from pyramid.view import view_config
26+
from pyramid_rpc.mapper import MapplyViewMapper
2427
from pyramid_rpc.xmlrpc import (
2528
XmlRpcError,
29+
XmlRpcInvalidMethodParams,
2630
exception_view as _exception_view,
2731
xmlrpc_method as _xmlrpc_method,
2832
)
@@ -72,6 +76,7 @@ def xmlrpc_method(**kwargs):
7276
require_csrf=False,
7377
require_methods=["POST"],
7478
decorator=(submit_xmlrpc_metrics(method=kwargs["method"]),),
79+
mapper=TypedMapplyViewMapper,
7580
)
7681

7782
def decorator(f):
@@ -106,6 +111,15 @@ class XMLRPCServiceUnavailable(XmlRpcError):
106111
faultString = "server error; service unavailable"
107112

108113

114+
class XMLRPCInvalidParamTypes(XmlRpcInvalidMethodParams):
115+
def __init__(self, exc):
116+
self.exc = exc
117+
118+
@property
119+
def faultString(self): # noqa
120+
return f"client error; {self.exc}"
121+
122+
109123
class XMLRPCWrappedError(xmlrpc.server.Fault):
110124
def __init__(self, exc):
111125
self.faultCode = -32500
@@ -116,18 +130,25 @@ def faultString(self): # noqa
116130
return "{exc.__class__.__name__}: {exc}".format(exc=self.wrapped_exception)
117131

118132

133+
class TypedMapplyViewMapper(MapplyViewMapper):
134+
def mapply(self, fn, args, kwargs):
135+
try:
136+
memo = typeguard._CallMemo(fn, args=args, kwargs=kwargs)
137+
typeguard.check_argument_types(memo)
138+
except TypeError as exc:
139+
print(exc)
140+
raise XMLRPCInvalidParamTypes(exc)
141+
142+
return super().mapply(fn, args, kwargs)
143+
144+
119145
@view_config(route_name="pypi", context=Exception, renderer="xmlrpc")
120146
def exception_view(exc, request):
121147
return _exception_view(exc, request)
122148

123149

124150
@xmlrpc_method(method="search")
125-
def search(request, spec, operator="and"):
126-
if not isinstance(spec, collections.abc.Mapping):
127-
raise XMLRPCWrappedError(
128-
TypeError("Invalid spec, must be a mapping/dictionary.")
129-
)
130-
151+
def search(request, spec: Mapping[str, Union[str, List[str]]], operator: str = "and"):
131152
if operator not in {"and", "or"}:
132153
raise XMLRPCWrappedError(
133154
ValueError("Invalid operator, must be one of 'and' or 'or'.")
@@ -220,7 +241,7 @@ def list_packages_with_serial(request):
220241

221242

222243
@xmlrpc_method(method="package_hosting_mode")
223-
def package_hosting_mode(request, package_name):
244+
def package_hosting_mode(request, package_name: str):
224245
try:
225246
project = (
226247
request.db.query(Project)
@@ -234,7 +255,7 @@ def package_hosting_mode(request, package_name):
234255

235256

236257
@xmlrpc_method(method="user_packages")
237-
def user_packages(request, username):
258+
def user_packages(request, username: str):
238259
roles = (
239260
request.db.query(Role)
240261
.join(User, Project)
@@ -253,7 +274,7 @@ def top_packages(request, num=None):
253274

254275

255276
@xmlrpc_cache_by_project(method="package_releases")
256-
def package_releases(request, package_name, show_hidden=False):
277+
def package_releases(request, package_name: str, show_hidden: bool = False):
257278
try:
258279
project = (
259280
request.db.query(Project)
@@ -293,7 +314,7 @@ def package_data(request, package_name, version):
293314

294315

295316
@xmlrpc_cache_by_project(method="release_data")
296-
def release_data(request, package_name, version):
317+
def release_data(request, package_name: str, version: str):
297318
try:
298319
release = (
299320
request.db.query(Release)
@@ -367,7 +388,7 @@ def package_urls(request, package_name, version):
367388

368389

369390
@xmlrpc_cache_by_project(method="release_urls")
370-
def release_urls(request, package_name, version):
391+
def release_urls(request, package_name: str, version: str):
371392
files = (
372393
request.db.query(File)
373394
.join(Release, Project)
@@ -401,7 +422,7 @@ def release_urls(request, package_name, version):
401422

402423

403424
@xmlrpc_cache_by_project(method="package_roles")
404-
def package_roles(request, package_name):
425+
def package_roles(request, package_name: str):
405426
roles = (
406427
request.db.query(Role)
407428
.join(User, Project)
@@ -418,7 +439,7 @@ def changelog_last_serial(request):
418439

419440

420441
@xmlrpc_method(method="changelog_since_serial")
421-
def changelog_since_serial(request, serial):
442+
def changelog_since_serial(request, serial: int):
422443
entries = (
423444
request.db.query(JournalEntry)
424445
.filter(JournalEntry.id > serial)
@@ -439,7 +460,7 @@ def changelog_since_serial(request, serial):
439460

440461

441462
@xmlrpc_method(method="changelog")
442-
def changelog(request, since, with_ids=False):
463+
def changelog(request, since: int, with_ids: bool = False):
443464
since = datetime.datetime.utcfromtimestamp(since)
444465
entries = (
445466
request.db.query(JournalEntry)
@@ -466,7 +487,7 @@ def changelog(request, since, with_ids=False):
466487

467488

468489
@xmlrpc_method(method="browse")
469-
def browse(request, classifiers):
490+
def browse(request, classifiers: List[str]):
470491
classifiers_q = (
471492
request.db.query(Classifier)
472493
.filter(Classifier.classifier.in_(classifiers))

0 commit comments

Comments
 (0)