py2http 0.1.61__tar.gz → 0.1.63__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of py2http might be problematic. Click here for more details.
- {py2http-0.1.61 → py2http-0.1.63}/PKG-INFO +1 -1
- {py2http-0.1.61 → py2http-0.1.63}/py2http/decorators.py +62 -8
- {py2http-0.1.61 → py2http-0.1.63}/py2http/service.py +153 -8
- py2http-0.1.63/py2http/tests/test_attr_dispatch.py +284 -0
- py2http-0.1.63/py2http/tests/test_binary_req.py +114 -0
- {py2http-0.1.61 → py2http-0.1.63}/py2http.egg-info/PKG-INFO +1 -1
- {py2http-0.1.61 → py2http-0.1.63}/py2http.egg-info/SOURCES.txt +2 -0
- {py2http-0.1.61 → py2http-0.1.63}/setup.cfg +1 -1
- {py2http-0.1.61 → py2http-0.1.63}/LICENSE +0 -0
- {py2http-0.1.61 → py2http-0.1.63}/README.md +0 -0
- {py2http-0.1.61 → py2http-0.1.63}/py2http/__init__.py +0 -0
- {py2http-0.1.61 → py2http-0.1.63}/py2http/bottle_plugins.py +0 -0
- {py2http-0.1.61 → py2http-0.1.63}/py2http/config.py +0 -0
- {py2http-0.1.61 → py2http-0.1.63}/py2http/constants.py +0 -0
- {py2http-0.1.61 → py2http-0.1.63}/py2http/default_configs.py +0 -0
- {py2http-0.1.61 → py2http-0.1.63}/py2http/diagnosis.py +0 -0
- {py2http-0.1.61 → py2http-0.1.63}/py2http/examples/__init__.py +0 -0
- {py2http-0.1.61 → py2http-0.1.63}/py2http/examples/custom_input_mapper.py +0 -0
- {py2http-0.1.61 → py2http-0.1.63}/py2http/examples/custom_input_mappers.py +0 -0
- {py2http-0.1.61 → py2http-0.1.63}/py2http/examples/example_service.py +0 -0
- {py2http-0.1.61 → py2http-0.1.63}/py2http/examples/example_service_bottle.py +0 -0
- {py2http-0.1.61 → py2http-0.1.63}/py2http/examples/example_service_flask.py +0 -0
- {py2http-0.1.61 → py2http-0.1.63}/py2http/examples/object_dispatch.py +0 -0
- {py2http-0.1.61 → py2http-0.1.63}/py2http/middleware.py +0 -0
- {py2http-0.1.61 → py2http-0.1.63}/py2http/openapi_utils.py +0 -0
- {py2http-0.1.61 → py2http-0.1.63}/py2http/route_utils.py +0 -0
- {py2http-0.1.61 → py2http-0.1.63}/py2http/schema_tools.py +0 -0
- {py2http-0.1.61 → py2http-0.1.63}/py2http/tests/__init__.py +0 -0
- {py2http-0.1.61 → py2http-0.1.63}/py2http/tests/example_service_test.py +0 -0
- {py2http-0.1.61 → py2http-0.1.63}/py2http/tests/objects_for_testing.py +0 -0
- {py2http-0.1.61 → py2http-0.1.63}/py2http/tests/simple_run_process_test.py +0 -0
- {py2http-0.1.61 → py2http-0.1.63}/py2http/tests/test_decorators.py +0 -0
- {py2http-0.1.61 → py2http-0.1.63}/py2http/tests/test_flask.py +0 -0
- {py2http-0.1.61 → py2http-0.1.63}/py2http/tests/test_not_set_defaults.py +0 -0
- {py2http-0.1.61 → py2http-0.1.63}/py2http/tests/test_p2h2p.py +0 -0
- {py2http-0.1.61 → py2http-0.1.63}/py2http/tests/utils_for_testing.py +0 -0
- {py2http-0.1.61 → py2http-0.1.63}/py2http/types.py +0 -0
- {py2http-0.1.61 → py2http-0.1.63}/py2http/util.py +0 -0
- {py2http-0.1.61 → py2http-0.1.63}/py2http.egg-info/dependency_links.txt +0 -0
- {py2http-0.1.61 → py2http-0.1.63}/py2http.egg-info/not-zip-safe +0 -0
- {py2http-0.1.61 → py2http-0.1.63}/py2http.egg-info/requires.txt +0 -0
- {py2http-0.1.61 → py2http-0.1.63}/py2http.egg-info/top_level.txt +0 -0
- {py2http-0.1.61 → py2http-0.1.63}/setup.py +0 -0
|
@@ -10,7 +10,8 @@ from inspect import (
|
|
|
10
10
|
import inspect
|
|
11
11
|
import json
|
|
12
12
|
import pickle
|
|
13
|
-
|
|
13
|
+
import warnings
|
|
14
|
+
from typing import Iterable, Callable, Union, Mapping, Optional
|
|
14
15
|
from functools import lru_cache, partial, wraps, update_wrapper
|
|
15
16
|
from json import JSONEncoder, dumps
|
|
16
17
|
from aiohttp import web
|
|
@@ -1007,7 +1008,7 @@ def _validate_and_invoke_mapper(func, inputs):
|
|
|
1007
1008
|
return func(**inputs)
|
|
1008
1009
|
|
|
1009
1010
|
|
|
1010
|
-
def _handle_req(func, content_type):
|
|
1011
|
+
def _handle_req(func, content_type, *, binary_loads=None):
|
|
1011
1012
|
func.request_schema = mk_input_schema_from_func(func)
|
|
1012
1013
|
func.content_type = content_type
|
|
1013
1014
|
|
|
@@ -1017,7 +1018,7 @@ def _handle_req(func, content_type):
|
|
|
1017
1018
|
if content_type not in req.content_type:
|
|
1018
1019
|
raise RuntimeError(f"The incoming request's content is of type \
|
|
1019
1020
|
{req.content_type}, when {content_type} is expected.")
|
|
1020
|
-
inputs = _get_inputs_from_request(req, content_type)
|
|
1021
|
+
inputs = _get_inputs_from_request(req, content_type, binary_loads=binary_loads)
|
|
1021
1022
|
return _validate_and_invoke_mapper(func, inputs)
|
|
1022
1023
|
|
|
1023
1024
|
return input_mapper
|
|
@@ -1027,8 +1028,53 @@ def handle_json_req(func):
|
|
|
1027
1028
|
return _handle_req(func, JSON_CONTENT_TYPE)
|
|
1028
1029
|
|
|
1029
1030
|
|
|
1030
|
-
def
|
|
1031
|
-
|
|
1031
|
+
def unsafe_pickle_loads(data: bytes):
|
|
1032
|
+
"""Unpickle ``data``. UNSAFE on anything a client can send.
|
|
1033
|
+
|
|
1034
|
+
Unpickling runs code chosen by whoever produced the bytes, so this must only
|
|
1035
|
+
be used when every caller of the endpoint is fully trusted (for example, a
|
|
1036
|
+
service reachable only by your own processes). It exists so that opting into
|
|
1037
|
+
pickled request bodies is explicit and visible at the call site::
|
|
1038
|
+
|
|
1039
|
+
handle_binary_req(func, loads=unsafe_pickle_loads)
|
|
1040
|
+
"""
|
|
1041
|
+
return pickle.loads(data)
|
|
1042
|
+
|
|
1043
|
+
|
|
1044
|
+
def handle_binary_req(func, *, loads: Optional[Callable[[bytes], Mapping]] = None):
|
|
1045
|
+
"""Make an input mapper that decodes a binary (octet-stream) request body.
|
|
1046
|
+
|
|
1047
|
+
``loads`` turns the raw body bytes into the mapping of keyword arguments for
|
|
1048
|
+
``func``. There is deliberately no default: request bodies used to be
|
|
1049
|
+
unpickled implicitly, which lets any client run code on the server. Pass a
|
|
1050
|
+
safe decoder of your own, or ``loads=unsafe_pickle_loads`` if (and only if)
|
|
1051
|
+
every client is trusted.
|
|
1052
|
+
|
|
1053
|
+
Note that ``http2py`` clients encode binary request bodies with pickle, so
|
|
1054
|
+
they only work against endpoints that opted into ``unsafe_pickle_loads``.
|
|
1055
|
+
|
|
1056
|
+
>>> handle_binary_req(lambda x: x) # doctest: +ELLIPSIS
|
|
1057
|
+
Traceback (most recent call last):
|
|
1058
|
+
...
|
|
1059
|
+
TypeError: handle_binary_req needs an explicit loads=... (bytes -> dict of inputs). ...
|
|
1060
|
+
"""
|
|
1061
|
+
if loads is None:
|
|
1062
|
+
raise TypeError(
|
|
1063
|
+
"handle_binary_req needs an explicit loads=... (bytes -> dict of "
|
|
1064
|
+
"inputs). Request bodies are no longer unpickled by default, since "
|
|
1065
|
+
"unpickling client data lets the client run code on the server. "
|
|
1066
|
+
"Use loads=unsafe_pickle_loads only if every client is trusted."
|
|
1067
|
+
)
|
|
1068
|
+
if not callable(loads):
|
|
1069
|
+
raise TypeError(f"loads must be callable, got {loads!r}")
|
|
1070
|
+
if loads is pickle.loads:
|
|
1071
|
+
warnings.warn(
|
|
1072
|
+
"handle_binary_req(loads=pickle.loads) unpickles client data, which lets "
|
|
1073
|
+
"clients run code on the server. Use loads=unsafe_pickle_loads to make "
|
|
1074
|
+
"that choice explicit, and only if every client is trusted.",
|
|
1075
|
+
stacklevel=2,
|
|
1076
|
+
)
|
|
1077
|
+
return _handle_req(func, BINARY_CONTENT_TYPE, binary_loads=loads)
|
|
1032
1078
|
|
|
1033
1079
|
|
|
1034
1080
|
def handle_form_req(func):
|
|
@@ -1170,7 +1216,7 @@ def mk_input_mapper(input_map):
|
|
|
1170
1216
|
return decorator
|
|
1171
1217
|
|
|
1172
1218
|
|
|
1173
|
-
def _get_inputs_from_request(request, content_type):
|
|
1219
|
+
def _get_inputs_from_request(request, content_type, *, binary_loads=None):
|
|
1174
1220
|
defaults = getattr(request, "defaults", {})
|
|
1175
1221
|
if request.method == "POST":
|
|
1176
1222
|
if content_type == JSON_CONTENT_TYPE:
|
|
@@ -1179,8 +1225,16 @@ def _get_inputs_from_request(request, content_type):
|
|
|
1179
1225
|
data = request.body.read().decode("utf-8")
|
|
1180
1226
|
inputs = json.loads(data)
|
|
1181
1227
|
elif content_type == BINARY_CONTENT_TYPE:
|
|
1182
|
-
|
|
1183
|
-
|
|
1228
|
+
if binary_loads is None:
|
|
1229
|
+
raise TypeError(
|
|
1230
|
+
"No decoder given for a binary request body; see handle_binary_req."
|
|
1231
|
+
)
|
|
1232
|
+
inputs = binary_loads(request.body.read())
|
|
1233
|
+
if not isinstance(inputs, Mapping):
|
|
1234
|
+
raise TypeError(
|
|
1235
|
+
f"The binary request body decoded to a {type(inputs).__name__}, "
|
|
1236
|
+
"not a mapping of inputs."
|
|
1237
|
+
)
|
|
1184
1238
|
elif content_type == FORM_CONTENT_TYPE:
|
|
1185
1239
|
fields = json.loads(
|
|
1186
1240
|
request.files.pop("__fields").file.read().decode("utf-8")
|
|
@@ -16,6 +16,9 @@ Python functions, enabling developers to easily expose their functions as remote
|
|
|
16
16
|
with minimal setup."""
|
|
17
17
|
|
|
18
18
|
import inspect
|
|
19
|
+
from collections import OrderedDict
|
|
20
|
+
from collections.abc import MutableMapping
|
|
21
|
+
from types import ModuleType
|
|
19
22
|
from uuid import uuid4
|
|
20
23
|
from aiohttp import web
|
|
21
24
|
import asyncio
|
|
@@ -53,7 +56,146 @@ from py2http.schema_tools import (
|
|
|
53
56
|
from py2http.util import TypeAsserter
|
|
54
57
|
from py2http.constants import JSON_CONTENT_TYPE
|
|
55
58
|
|
|
56
|
-
|
|
59
|
+
# Maximum number of objects a class endpoint keeps alive; the least recently used
|
|
60
|
+
# one is evicted beyond that. Pass ``obj_store=...`` in a handler to change it.
|
|
61
|
+
# Note: this bounds the number of objects, not their size, and object ids are the
|
|
62
|
+
# only access control (any client holding an id can use the object).
|
|
63
|
+
DFLT_OBJ_STORE_MAX_SIZE = 1000
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class LRUObjStore(MutableMapping):
|
|
67
|
+
"""A mapping that keeps at most ``max_size`` items, evicting the least recently
|
|
68
|
+
used one when a new key would exceed it.
|
|
69
|
+
|
|
70
|
+
Used as the default store of objects created through a class endpoint, so that
|
|
71
|
+
clients cannot grow server memory without bound by creating objects.
|
|
72
|
+
|
|
73
|
+
>>> s = LRUObjStore(max_size=2)
|
|
74
|
+
>>> s['a'], s['b'] = 1, 2
|
|
75
|
+
>>> _ = s['a'] # 'a' is now the most recently used
|
|
76
|
+
>>> s['c'] = 3
|
|
77
|
+
>>> sorted(s)
|
|
78
|
+
['a', 'c']
|
|
79
|
+
"""
|
|
80
|
+
|
|
81
|
+
def __init__(self, *, max_size: int = DFLT_OBJ_STORE_MAX_SIZE):
|
|
82
|
+
if max_size < 1:
|
|
83
|
+
raise ValueError(f"max_size must be at least 1, got {max_size}")
|
|
84
|
+
self.max_size = max_size
|
|
85
|
+
self._data = OrderedDict()
|
|
86
|
+
|
|
87
|
+
def __getitem__(self, k):
|
|
88
|
+
v = self._data[k]
|
|
89
|
+
self._data.move_to_end(k)
|
|
90
|
+
return v
|
|
91
|
+
|
|
92
|
+
def __setitem__(self, k, v):
|
|
93
|
+
self._data[k] = v
|
|
94
|
+
self._data.move_to_end(k)
|
|
95
|
+
while len(self._data) > self.max_size:
|
|
96
|
+
self._data.popitem(last=False)
|
|
97
|
+
|
|
98
|
+
def __delitem__(self, k):
|
|
99
|
+
del self._data[k]
|
|
100
|
+
|
|
101
|
+
def __iter__(self):
|
|
102
|
+
return iter(self._data)
|
|
103
|
+
|
|
104
|
+
def __len__(self):
|
|
105
|
+
return len(self._data)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def public_attr(obj, attr_name: str) -> bool:
|
|
109
|
+
"""The attribute policy ``attr_names='*'`` stands for: only public methods.
|
|
110
|
+
|
|
111
|
+
A name is dispatchable if it does not start with ``_`` and
|
|
112
|
+
|
|
113
|
+
- for a module: it is a function that the module defines (or lists in its
|
|
114
|
+
``__all__``), never something it merely imports, nor plain data;
|
|
115
|
+
- otherwise: it is a plain Python function, ``staticmethod``,
|
|
116
|
+
``classmethod`` or ``property`` defined in the object's class (or a base
|
|
117
|
+
class), and the instance does not shadow it.
|
|
118
|
+
|
|
119
|
+
This keeps private helpers, dunders (``__dict__``, ``__init__``, ...),
|
|
120
|
+
per-instance state (``__dict__`` entries, ``__slots__``, dataclass and
|
|
121
|
+
namedtuple fields) and callables merely assigned as class attributes
|
|
122
|
+
(``opener = open``) out of reach of HTTP clients.
|
|
123
|
+
|
|
124
|
+
Note that every public method is exposed, inherited ones included (e.g. a
|
|
125
|
+
pydantic model's ``model_dump``). For anything holding secrets, or a mapping
|
|
126
|
+
whose mutators should stay private (``MutableMapping`` mixins such as
|
|
127
|
+
``clear`` and ``pop`` are plain functions), list the names explicitly instead.
|
|
128
|
+
|
|
129
|
+
>>> class A:
|
|
130
|
+
... data = 0
|
|
131
|
+
... opener = open
|
|
132
|
+
... def f(self): ...
|
|
133
|
+
... def _g(self): ...
|
|
134
|
+
... def __init__(self): self.secret = 1
|
|
135
|
+
>>> [public_attr(A(), name)
|
|
136
|
+
... for name in ('f', '_g', '__init__', '__dict__', 'secret', 'data', 'opener')]
|
|
137
|
+
[True, False, False, False, False, False, False]
|
|
138
|
+
"""
|
|
139
|
+
if not isinstance(attr_name, str) or attr_name.startswith("_"):
|
|
140
|
+
return False
|
|
141
|
+
if isinstance(obj, ModuleType):
|
|
142
|
+
return _is_public_module_function(obj, attr_name)
|
|
143
|
+
try: # (bypassing any class-level ``__dict__`` attribute)
|
|
144
|
+
instance_dict = object.__getattribute__(obj, "__dict__")
|
|
145
|
+
except AttributeError:
|
|
146
|
+
instance_dict = {}
|
|
147
|
+
if attr_name in instance_dict:
|
|
148
|
+
return False # instance state, or an instance attribute shadowing a method
|
|
149
|
+
# Look in the class's MRO dicts only (not the metaclass: ``mro``, ...), and at
|
|
150
|
+
# the raw class attribute, not what the instance would return for it
|
|
151
|
+
for klass in type(obj).__mro__:
|
|
152
|
+
if attr_name in vars(klass):
|
|
153
|
+
return _is_method_like(vars(klass)[attr_name])
|
|
154
|
+
return False
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _is_method_like(class_attr) -> bool:
|
|
158
|
+
if isinstance(class_attr, (staticmethod, classmethod)):
|
|
159
|
+
class_attr = class_attr.__func__
|
|
160
|
+
return isinstance(class_attr, (FunctionType, property))
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _is_public_module_function(module: ModuleType, attr_name: str) -> bool:
|
|
164
|
+
value = vars(module).get(attr_name)
|
|
165
|
+
if not inspect.isfunction(value):
|
|
166
|
+
return False
|
|
167
|
+
exported = getattr(module, "__all__", None)
|
|
168
|
+
if exported is not None:
|
|
169
|
+
return attr_name in exported
|
|
170
|
+
return value.__module__ == module.__name__
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def unsafe_any_attr(obj, attr_name: str) -> bool:
|
|
174
|
+
"""Dispatch ANY attribute of the object, including private ones and dunders.
|
|
175
|
+
|
|
176
|
+
This was the behaviour of ``attr_names='*'`` before ``public_attr``: it lets a
|
|
177
|
+
client read ``__dict__``, call ``__init__`` with arguments of its choosing, and so
|
|
178
|
+
on. Use ``attr_names=unsafe_any_attr`` only when every client is trusted.
|
|
179
|
+
"""
|
|
180
|
+
return isinstance(attr_name, str) and hasattr(obj, attr_name)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _attr_name_policy(attr_names) -> Callable[[Any, str], bool]:
|
|
184
|
+
"""Normalize a handler's ``attr_names`` into a ``(obj, attr_name) -> bool``
|
|
185
|
+
predicate: ``'*'`` means ``public_attr``, a callable is used as is, and an
|
|
186
|
+
iterable of names is an explicit allow-list (dunders included, if listed).
|
|
187
|
+
"""
|
|
188
|
+
if attr_names == "*":
|
|
189
|
+
return public_attr
|
|
190
|
+
if callable(attr_names):
|
|
191
|
+
return attr_names
|
|
192
|
+
if isinstance(attr_names, str):
|
|
193
|
+
allowed = {attr_names}
|
|
194
|
+
else:
|
|
195
|
+
allowed = set(attr_names)
|
|
196
|
+
return lambda obj, attr_name: (
|
|
197
|
+
isinstance(attr_name, str) and attr_name in allowed and hasattr(obj, attr_name)
|
|
198
|
+
)
|
|
57
199
|
|
|
58
200
|
|
|
59
201
|
def method_not_found(method_name):
|
|
@@ -617,8 +759,12 @@ def _get_func_to_dispatch_handler(handler):
|
|
|
617
759
|
return endpoint
|
|
618
760
|
# was return endpoint, but then the output didn't get the __name__ = name
|
|
619
761
|
# func = endpoint
|
|
762
|
+
is_dispatchable = _attr_name_policy(attr_names)
|
|
620
763
|
if inspect.isclass(endpoint):
|
|
621
764
|
cls = endpoint
|
|
765
|
+
obj_store = handler.get("obj_store")
|
|
766
|
+
if obj_store is None:
|
|
767
|
+
obj_store = LRUObjStore()
|
|
622
768
|
|
|
623
769
|
def func(_obj_id=None, _attr_name=None, **kwargs):
|
|
624
770
|
if _obj_id is None:
|
|
@@ -628,25 +774,24 @@ def _get_func_to_dispatch_handler(handler):
|
|
|
628
774
|
new_id = str(uuid4())
|
|
629
775
|
obj_store[new_id] = new_obj
|
|
630
776
|
return new_id
|
|
631
|
-
obj = obj_store.get(_obj_id)
|
|
632
|
-
if
|
|
777
|
+
obj = obj_store.get(_obj_id) if isinstance(_obj_id, str) else None
|
|
778
|
+
if obj is None:
|
|
633
779
|
raise InputError(f"No object found with id {_obj_id}")
|
|
634
780
|
if _attr_name is None:
|
|
635
781
|
raise InputError("_attr_name must be provided when _obj_id is not None")
|
|
636
|
-
return _get_attr_value(obj, _attr_name,
|
|
782
|
+
return _get_attr_value(obj, _attr_name, is_dispatchable, **kwargs)
|
|
637
783
|
|
|
638
784
|
else:
|
|
639
785
|
|
|
640
786
|
def func(_attr_name=None, **kwargs):
|
|
641
|
-
return _get_attr_value(endpoint, _attr_name,
|
|
787
|
+
return _get_attr_value(endpoint, _attr_name, is_dispatchable, **kwargs)
|
|
642
788
|
|
|
643
789
|
func.__name__ = name
|
|
644
790
|
return func
|
|
645
791
|
|
|
646
792
|
|
|
647
|
-
def _get_attr_value(obj, attr_name,
|
|
648
|
-
|
|
649
|
-
if attr_name not in valid_attr_names or not hasattr(obj, attr_name):
|
|
793
|
+
def _get_attr_value(obj, attr_name, is_dispatchable, /, **kwargs):
|
|
794
|
+
if not is_dispatchable(obj, attr_name):
|
|
650
795
|
raise InputError(
|
|
651
796
|
f"No attribute found with name {attr_name} or it is not dispatched"
|
|
652
797
|
)
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
"""Object/attribute dispatch (``attr_names``) only exposes what was meant to be exposed."""
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
from i2.errors import InputError
|
|
5
|
+
|
|
6
|
+
from py2http.service import (
|
|
7
|
+
LRUObjStore,
|
|
8
|
+
_get_func_to_dispatch_handler,
|
|
9
|
+
public_attr,
|
|
10
|
+
unsafe_any_attr,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Counter:
|
|
15
|
+
def __init__(self, start: int = 0):
|
|
16
|
+
self.count = start
|
|
17
|
+
self._secret = "s3cr3t"
|
|
18
|
+
|
|
19
|
+
def incr(self, by: int = 1):
|
|
20
|
+
self.count += by
|
|
21
|
+
return self.count
|
|
22
|
+
|
|
23
|
+
def _reset(self):
|
|
24
|
+
self.count = 0
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _dispatcher(endpoint, attr_names, **handler_extras):
|
|
28
|
+
return _get_func_to_dispatch_handler(
|
|
29
|
+
dict(endpoint=endpoint, name="ep", attr_names=attr_names, **handler_extras)
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@pytest.mark.parametrize(
|
|
34
|
+
"attr_name", ["_reset", "_secret", "__dict__", "__init__", "__class__", "count"]
|
|
35
|
+
)
|
|
36
|
+
def test_star_refuses_private_dunder_and_instance_attrs(attr_name):
|
|
37
|
+
obj = Counter(5)
|
|
38
|
+
func = _dispatcher(obj, "*")
|
|
39
|
+
with pytest.raises(InputError):
|
|
40
|
+
func(_attr_name=attr_name)
|
|
41
|
+
assert obj.count == 5
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def test_star_allows_public_methods():
|
|
45
|
+
func = _dispatcher(Counter(5), "*")
|
|
46
|
+
assert func(_attr_name="incr", by=2) == 7
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def test_star_on_class_endpoint():
|
|
50
|
+
func = _dispatcher(Counter, "*")
|
|
51
|
+
obj_id = func(start=3)
|
|
52
|
+
assert func(_obj_id=obj_id, _attr_name="incr") == 4
|
|
53
|
+
for attr_name in ("__init__", "__dict__", "_secret", "mro"):
|
|
54
|
+
with pytest.raises(InputError):
|
|
55
|
+
func(_obj_id=obj_id, _attr_name=attr_name)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def test_explicit_list_unaffected():
|
|
59
|
+
"""An explicit allow-list may name dunders on purpose (see examples/object_dispatch)."""
|
|
60
|
+
|
|
61
|
+
class Store(dict):
|
|
62
|
+
def __getitem__(self, key):
|
|
63
|
+
return super().__getitem__(key)
|
|
64
|
+
|
|
65
|
+
d = Store(a=1)
|
|
66
|
+
func = _dispatcher(d, ["__getitem__", "__len__"])
|
|
67
|
+
assert func(_attr_name="__getitem__", key="a") == 1
|
|
68
|
+
assert func(_attr_name="__len__") == 1
|
|
69
|
+
with pytest.raises(InputError):
|
|
70
|
+
func(_attr_name="clear")
|
|
71
|
+
with pytest.raises(InputError):
|
|
72
|
+
func(_attr_name="__dict__")
|
|
73
|
+
assert d == {"a": 1}
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def test_unsafe_any_attr_opt_in_restores_old_behaviour():
|
|
77
|
+
obj = Counter(5)
|
|
78
|
+
func = _dispatcher(obj, unsafe_any_attr)
|
|
79
|
+
func(_attr_name="_reset")
|
|
80
|
+
assert obj.count == 0
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def test_custom_predicate():
|
|
84
|
+
func = _dispatcher(Counter(1), lambda obj, name: name == "incr")
|
|
85
|
+
assert func(_attr_name="incr") == 2
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def test_public_attr_on_module():
|
|
89
|
+
import json
|
|
90
|
+
|
|
91
|
+
assert public_attr(json, "dumps")
|
|
92
|
+
assert not public_attr(json, "_default_encoder")
|
|
93
|
+
assert not public_attr(json, "__loader__")
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def test_obj_store_is_bounded():
|
|
97
|
+
store = LRUObjStore(max_size=2)
|
|
98
|
+
func = _dispatcher(Counter, "*", obj_store=store)
|
|
99
|
+
ids = [func(start=i) for i in range(3)]
|
|
100
|
+
assert len(store) == 2
|
|
101
|
+
with pytest.raises(InputError, match="No object found"):
|
|
102
|
+
func(_obj_id=ids[0], _attr_name="incr")
|
|
103
|
+
assert func(_obj_id=ids[2], _attr_name="incr") == 3
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def test_obj_store_default_is_bounded_lru():
|
|
107
|
+
func = _dispatcher(Counter, "*")
|
|
108
|
+
obj_id = func()
|
|
109
|
+
assert func(_obj_id=obj_id, _attr_name="incr") == 1
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def test_falsy_object_is_still_found():
|
|
113
|
+
func = _dispatcher(list, ["append", "__len__"])
|
|
114
|
+
obj_id = func() # an empty list is falsy
|
|
115
|
+
assert func(_obj_id=obj_id, _attr_name="__len__") == 0
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def test_lru_obj_store_rejects_bad_size():
|
|
119
|
+
with pytest.raises(ValueError):
|
|
120
|
+
LRUObjStore(max_size=0)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
# --- shapes found by the adversarial review: instance state must stay private ---
|
|
124
|
+
|
|
125
|
+
from dataclasses import dataclass
|
|
126
|
+
from types import ModuleType
|
|
127
|
+
from typing import NamedTuple
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
class Slotted:
|
|
131
|
+
__slots__ = ("token",)
|
|
132
|
+
|
|
133
|
+
def __init__(self):
|
|
134
|
+
self.token = "TOK"
|
|
135
|
+
|
|
136
|
+
def ping(self):
|
|
137
|
+
return "pong"
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
@dataclass
|
|
141
|
+
class DC:
|
|
142
|
+
token: str = "default"
|
|
143
|
+
|
|
144
|
+
def ping(self):
|
|
145
|
+
return "pong"
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
class NT(NamedTuple):
|
|
149
|
+
token: str
|
|
150
|
+
|
|
151
|
+
def ping(self):
|
|
152
|
+
return "pong"
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
class Odd:
|
|
156
|
+
count = 0
|
|
157
|
+
opener = open
|
|
158
|
+
Nested = dict
|
|
159
|
+
|
|
160
|
+
def __init__(self):
|
|
161
|
+
self.count = 42
|
|
162
|
+
self.incr = lambda: "shadowed"
|
|
163
|
+
|
|
164
|
+
def incr(self):
|
|
165
|
+
return "real"
|
|
166
|
+
|
|
167
|
+
@staticmethod
|
|
168
|
+
def s():
|
|
169
|
+
return "static"
|
|
170
|
+
|
|
171
|
+
@classmethod
|
|
172
|
+
def c(cls):
|
|
173
|
+
return "class"
|
|
174
|
+
|
|
175
|
+
@property
|
|
176
|
+
def p(self):
|
|
177
|
+
return "prop"
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
@pytest.mark.parametrize(
|
|
181
|
+
"obj", [Slotted(), DC(token="TOK"), NT(token="TOK")], ids=["slots", "dc", "nt"]
|
|
182
|
+
)
|
|
183
|
+
def test_star_refuses_fields(obj):
|
|
184
|
+
func = _dispatcher(obj, "*")
|
|
185
|
+
with pytest.raises(InputError):
|
|
186
|
+
func(_attr_name="token")
|
|
187
|
+
assert func(_attr_name="ping") == "pong"
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
@pytest.mark.parametrize("attr_name", ["count", "opener", "Nested", "incr"])
|
|
191
|
+
def test_star_refuses_data_shadowed_and_assigned_callables(attr_name):
|
|
192
|
+
func = _dispatcher(Odd(), "*")
|
|
193
|
+
with pytest.raises(InputError):
|
|
194
|
+
func(_attr_name=attr_name)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def test_star_allows_static_class_methods_and_properties():
|
|
198
|
+
func = _dispatcher(Odd(), "*")
|
|
199
|
+
assert [func(_attr_name=n) for n in ("s", "c", "p")] == ["static", "class", "prop"]
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def test_star_refuses_builtin_mapping_mutators():
|
|
203
|
+
class Store(dict):
|
|
204
|
+
def keys_list(self):
|
|
205
|
+
return list(self)
|
|
206
|
+
|
|
207
|
+
d = Store(a=1)
|
|
208
|
+
func = _dispatcher(d, "*")
|
|
209
|
+
with pytest.raises(InputError):
|
|
210
|
+
func(_attr_name="clear")
|
|
211
|
+
assert func(_attr_name="keys_list") == ["a"] and d == {"a": 1}
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _mk_module(name, *, all_=None):
|
|
215
|
+
mod = ModuleType(name)
|
|
216
|
+
exec(
|
|
217
|
+
"from os import listdir\nfrom json import dumps\nimport os\nAPI_KEY = 'k-123'\n"
|
|
218
|
+
"def hello():\n return 'hi'\n",
|
|
219
|
+
vars(mod),
|
|
220
|
+
)
|
|
221
|
+
if all_ is not None:
|
|
222
|
+
mod.__all__ = all_
|
|
223
|
+
return mod
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
@pytest.mark.parametrize("attr_name", ["listdir", "dumps", "os", "API_KEY"])
|
|
227
|
+
def test_star_on_module_refuses_imports_and_data(attr_name):
|
|
228
|
+
func = _dispatcher(_mk_module("some_service"), "*")
|
|
229
|
+
with pytest.raises(InputError):
|
|
230
|
+
func(_attr_name=attr_name)
|
|
231
|
+
assert func(_attr_name="hello") == "hi"
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def test_star_on_module_honours_all():
|
|
235
|
+
mod = _mk_module("some_service", all_=["dumps", "listdir"])
|
|
236
|
+
assert public_attr(mod, "dumps") # a re-export listed in __all__
|
|
237
|
+
assert not public_attr(mod, "listdir") # listed, but not a Python function
|
|
238
|
+
assert not public_attr(mod, "hello") # not listed
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
@pytest.mark.parametrize("bad", [["x"], {"a": 1}, 3, None])
|
|
242
|
+
def test_non_str_names_and_ids_are_input_errors(bad):
|
|
243
|
+
func = _dispatcher({"x": 1}, ["x", "__len__"])
|
|
244
|
+
if bad is not None:
|
|
245
|
+
with pytest.raises(InputError):
|
|
246
|
+
func(_attr_name=bad)
|
|
247
|
+
cls_func = _dispatcher(Counter, "*")
|
|
248
|
+
with pytest.raises(InputError):
|
|
249
|
+
cls_func(_obj_id=bad if bad is not None else [], _attr_name="incr")
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def test_method_params_named_like_dispatch_internals():
|
|
253
|
+
class A:
|
|
254
|
+
def f(self, obj, attr_name, is_dispatchable):
|
|
255
|
+
return [obj, attr_name, is_dispatchable]
|
|
256
|
+
|
|
257
|
+
func = _dispatcher(A(), "*")
|
|
258
|
+
assert func(_attr_name="f", obj=1, attr_name=2, is_dispatchable=3) == [1, 2, 3]
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def test_star_exposes_mutable_mapping_mixins_as_documented():
|
|
262
|
+
"""Inherited public Python methods ARE dispatched: use an explicit list for stores."""
|
|
263
|
+
from collections.abc import MutableMapping
|
|
264
|
+
|
|
265
|
+
class MM(MutableMapping):
|
|
266
|
+
def __init__(self):
|
|
267
|
+
self._d = {"a": 1}
|
|
268
|
+
|
|
269
|
+
def __getitem__(self, k):
|
|
270
|
+
return self._d[k]
|
|
271
|
+
|
|
272
|
+
def __setitem__(self, k, v):
|
|
273
|
+
self._d[k] = v
|
|
274
|
+
|
|
275
|
+
def __delitem__(self, k):
|
|
276
|
+
del self._d[k]
|
|
277
|
+
|
|
278
|
+
def __iter__(self):
|
|
279
|
+
return iter(self._d)
|
|
280
|
+
|
|
281
|
+
def __len__(self):
|
|
282
|
+
return len(self._d)
|
|
283
|
+
|
|
284
|
+
assert public_attr(MM(), "clear")
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""Binary (octet-stream) request bodies are never unpickled unless explicitly asked."""
|
|
2
|
+
|
|
3
|
+
import io
|
|
4
|
+
import json
|
|
5
|
+
import pickle
|
|
6
|
+
from wsgiref.util import setup_testing_defaults
|
|
7
|
+
|
|
8
|
+
import pytest
|
|
9
|
+
|
|
10
|
+
from py2http import mk_app
|
|
11
|
+
from py2http.constants import BINARY_CONTENT_TYPE, JSON_CONTENT_TYPE
|
|
12
|
+
from py2http.decorators import (
|
|
13
|
+
_get_inputs_from_request,
|
|
14
|
+
handle_binary_req,
|
|
15
|
+
unsafe_pickle_loads,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
_calls = []
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _record(*args):
|
|
22
|
+
_calls.append(args)
|
|
23
|
+
return {}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class _RecordsWhenUnpickled:
|
|
27
|
+
"""Unpickling this calls ``_record``: a harmless stand-in for side effects."""
|
|
28
|
+
|
|
29
|
+
def __reduce__(self):
|
|
30
|
+
return (_record, ("unpickled",))
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@pytest.fixture
|
|
34
|
+
def calls():
|
|
35
|
+
_calls.clear()
|
|
36
|
+
yield _calls
|
|
37
|
+
_calls.clear()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class _FakeRequest:
|
|
41
|
+
def __init__(self, body: bytes, *, content_type=BINARY_CONTENT_TYPE):
|
|
42
|
+
self.method = "POST"
|
|
43
|
+
self.content_type = content_type
|
|
44
|
+
self.body = io.BytesIO(body)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def add(x: int, y: int = 1):
|
|
48
|
+
return x + y
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _post(app, path, body: bytes, content_type: str):
|
|
52
|
+
"""Call a WSGI app directly; return (status, body)."""
|
|
53
|
+
environ = {}
|
|
54
|
+
setup_testing_defaults(environ)
|
|
55
|
+
environ.update(
|
|
56
|
+
REQUEST_METHOD="POST",
|
|
57
|
+
PATH_INFO=path,
|
|
58
|
+
CONTENT_TYPE=content_type,
|
|
59
|
+
CONTENT_LENGTH=str(len(body)),
|
|
60
|
+
)
|
|
61
|
+
environ["wsgi.input"] = io.BytesIO(body)
|
|
62
|
+
statuses = []
|
|
63
|
+
out = b"".join(app(environ, lambda status, headers, *a: statuses.append(status)))
|
|
64
|
+
return statuses[0], out
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def test_binary_req_requires_explicit_loads():
|
|
68
|
+
with pytest.raises(TypeError, match="explicit loads"):
|
|
69
|
+
handle_binary_req(add)
|
|
70
|
+
with pytest.raises(TypeError, match="callable"):
|
|
71
|
+
handle_binary_req(add, loads="pickle")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def test_bare_pickle_loads_warns():
|
|
75
|
+
with pytest.warns(UserWarning, match="unsafe_pickle_loads"):
|
|
76
|
+
handle_binary_req(add, loads=pickle.loads)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def test_binary_body_not_unpickled_by_default(calls):
|
|
80
|
+
payload = pickle.dumps(_RecordsWhenUnpickled())
|
|
81
|
+
with pytest.raises(TypeError, match="No decoder"):
|
|
82
|
+
_get_inputs_from_request(_FakeRequest(payload), BINARY_CONTENT_TYPE)
|
|
83
|
+
assert calls == []
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def test_binary_req_with_safe_loads(calls):
|
|
87
|
+
mapper = handle_binary_req(add, loads=json.loads)
|
|
88
|
+
assert mapper(_FakeRequest(json.dumps({"x": 2, "y": 3}).encode())) == 5
|
|
89
|
+
with pytest.raises(ValueError):
|
|
90
|
+
mapper(_FakeRequest(pickle.dumps(_RecordsWhenUnpickled())))
|
|
91
|
+
assert calls == []
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def test_binary_req_rejects_non_mapping():
|
|
95
|
+
mapper = handle_binary_req(add, loads=json.loads)
|
|
96
|
+
with pytest.raises(TypeError, match="not a mapping"):
|
|
97
|
+
mapper(_FakeRequest(b"[1, 2]"))
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def test_binary_req_explicit_unsafe_opt_in_still_works():
|
|
101
|
+
mapper = handle_binary_req(add, loads=unsafe_pickle_loads)
|
|
102
|
+
assert mapper(_FakeRequest(pickle.dumps({"x": 2}))) == 3
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def test_default_service_does_not_unpickle_octet_stream_bodies(calls):
|
|
106
|
+
app = mk_app([add])
|
|
107
|
+
status, _ = _post(
|
|
108
|
+
app, "/add", pickle.dumps(_RecordsWhenUnpickled()), BINARY_CONTENT_TYPE
|
|
109
|
+
)
|
|
110
|
+
assert not status.startswith("2")
|
|
111
|
+
assert calls == []
|
|
112
|
+
# the normal JSON path still works
|
|
113
|
+
status, body = _post(app, "/add", json.dumps({"x": 2}).encode(), JSON_CONTENT_TYPE)
|
|
114
|
+
assert status.startswith("200") and json.loads(body) == 3
|
|
@@ -33,6 +33,8 @@ py2http/tests/__init__.py
|
|
|
33
33
|
py2http/tests/example_service_test.py
|
|
34
34
|
py2http/tests/objects_for_testing.py
|
|
35
35
|
py2http/tests/simple_run_process_test.py
|
|
36
|
+
py2http/tests/test_attr_dispatch.py
|
|
37
|
+
py2http/tests/test_binary_req.py
|
|
36
38
|
py2http/tests/test_decorators.py
|
|
37
39
|
py2http/tests/test_flask.py
|
|
38
40
|
py2http/tests/test_not_set_defaults.py
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|