py2http 0.1.64__tar.gz → 0.1.66__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.64 → py2http-0.1.66}/PKG-INFO +1 -1
- {py2http-0.1.64 → py2http-0.1.66}/py2http/service.py +329 -31
- {py2http-0.1.64 → py2http-0.1.66}/py2http/tests/test_attr_dispatch.py +7 -0
- py2http-0.1.66/py2http/tests/test_obj_ownership.py +253 -0
- {py2http-0.1.64 → py2http-0.1.66}/py2http.egg-info/PKG-INFO +1 -1
- {py2http-0.1.64 → py2http-0.1.66}/py2http.egg-info/SOURCES.txt +1 -0
- {py2http-0.1.64 → py2http-0.1.66}/setup.cfg +1 -1
- {py2http-0.1.64 → py2http-0.1.66}/LICENSE +0 -0
- {py2http-0.1.64 → py2http-0.1.66}/README.md +0 -0
- {py2http-0.1.64 → py2http-0.1.66}/py2http/__init__.py +0 -0
- {py2http-0.1.64 → py2http-0.1.66}/py2http/bottle_plugins.py +0 -0
- {py2http-0.1.64 → py2http-0.1.66}/py2http/config.py +0 -0
- {py2http-0.1.64 → py2http-0.1.66}/py2http/constants.py +0 -0
- {py2http-0.1.64 → py2http-0.1.66}/py2http/decorators.py +0 -0
- {py2http-0.1.64 → py2http-0.1.66}/py2http/default_configs.py +0 -0
- {py2http-0.1.64 → py2http-0.1.66}/py2http/diagnosis.py +0 -0
- {py2http-0.1.64 → py2http-0.1.66}/py2http/examples/__init__.py +0 -0
- {py2http-0.1.64 → py2http-0.1.66}/py2http/examples/custom_input_mapper.py +0 -0
- {py2http-0.1.64 → py2http-0.1.66}/py2http/examples/custom_input_mappers.py +0 -0
- {py2http-0.1.64 → py2http-0.1.66}/py2http/examples/example_service.py +0 -0
- {py2http-0.1.64 → py2http-0.1.66}/py2http/examples/example_service_bottle.py +0 -0
- {py2http-0.1.64 → py2http-0.1.66}/py2http/examples/example_service_flask.py +0 -0
- {py2http-0.1.64 → py2http-0.1.66}/py2http/examples/object_dispatch.py +0 -0
- {py2http-0.1.64 → py2http-0.1.66}/py2http/middleware.py +0 -0
- {py2http-0.1.64 → py2http-0.1.66}/py2http/openapi_utils.py +0 -0
- {py2http-0.1.64 → py2http-0.1.66}/py2http/route_utils.py +0 -0
- {py2http-0.1.64 → py2http-0.1.66}/py2http/schema_tools.py +0 -0
- {py2http-0.1.64 → py2http-0.1.66}/py2http/tests/__init__.py +0 -0
- {py2http-0.1.64 → py2http-0.1.66}/py2http/tests/example_service_test.py +0 -0
- {py2http-0.1.64 → py2http-0.1.66}/py2http/tests/objects_for_testing.py +0 -0
- {py2http-0.1.64 → py2http-0.1.66}/py2http/tests/simple_run_process_test.py +0 -0
- {py2http-0.1.64 → py2http-0.1.66}/py2http/tests/test_binary_req.py +0 -0
- {py2http-0.1.64 → py2http-0.1.66}/py2http/tests/test_decorators.py +0 -0
- {py2http-0.1.64 → py2http-0.1.66}/py2http/tests/test_flask.py +0 -0
- {py2http-0.1.64 → py2http-0.1.66}/py2http/tests/test_jwt_middleware.py +0 -0
- {py2http-0.1.64 → py2http-0.1.66}/py2http/tests/test_not_set_defaults.py +0 -0
- {py2http-0.1.64 → py2http-0.1.66}/py2http/tests/test_p2h2p.py +0 -0
- {py2http-0.1.64 → py2http-0.1.66}/py2http/tests/utils_for_testing.py +0 -0
- {py2http-0.1.64 → py2http-0.1.66}/py2http/types.py +0 -0
- {py2http-0.1.64 → py2http-0.1.66}/py2http/util.py +0 -0
- {py2http-0.1.64 → py2http-0.1.66}/py2http.egg-info/dependency_links.txt +0 -0
- {py2http-0.1.64 → py2http-0.1.66}/py2http.egg-info/not-zip-safe +0 -0
- {py2http-0.1.64 → py2http-0.1.66}/py2http.egg-info/requires.txt +0 -0
- {py2http-0.1.64 → py2http-0.1.66}/py2http.egg-info/top_level.txt +0 -0
- {py2http-0.1.64 → py2http-0.1.66}/setup.py +0 -0
|
@@ -16,7 +16,8 @@ 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
|
|
19
|
+
from collections import OrderedDict, deque
|
|
20
|
+
from contextvars import ContextVar
|
|
20
21
|
from collections.abc import MutableMapping
|
|
21
22
|
from types import ModuleType
|
|
22
23
|
from uuid import uuid4
|
|
@@ -30,6 +31,8 @@ from typing import Any, Callable, Dict, Iterable, Optional, TypedDict, Union
|
|
|
30
31
|
from types import FunctionType
|
|
31
32
|
import logging
|
|
32
33
|
import os
|
|
34
|
+
import sys
|
|
35
|
+
import threading
|
|
33
36
|
from bottle import Bottle, run as run_bottle
|
|
34
37
|
import traceback
|
|
35
38
|
from swagger_ui import api_doc
|
|
@@ -56,16 +59,29 @@ from py2http.schema_tools import (
|
|
|
56
59
|
from py2http.util import TypeAsserter
|
|
57
60
|
from py2http.constants import JSON_CONTENT_TYPE
|
|
58
61
|
|
|
59
|
-
#
|
|
60
|
-
#
|
|
61
|
-
#
|
|
62
|
-
#
|
|
62
|
+
# Class endpoints (``handler['endpoint']`` is a class) keep the objects clients create.
|
|
63
|
+
# Who may use an object is the handler's ``obj_owner`` (see ``ObjOwner``); how many
|
|
64
|
+
# objects an owner may keep is ``obj_quota``; how many bytes each object, each owner and
|
|
65
|
+
# the whole endpoint may hold are ``max_obj_size``, ``max_owner_bytes`` and
|
|
66
|
+
# ``max_total_bytes`` (see ``_class_endpoint_dispatcher``).
|
|
67
|
+
#
|
|
68
|
+
# Maximum number of objects a class endpoint keeps alive in ``shared_objects`` mode; the
|
|
69
|
+
# least recently used one is evicted beyond that. Pass ``obj_store=...`` to change it.
|
|
63
70
|
DFLT_OBJ_STORE_MAX_SIZE = 1000
|
|
71
|
+
DFLT_OBJ_QUOTA = 100 # objects per owner
|
|
72
|
+
DFLT_MAX_OWNERS = 1000 # owners holding objects at once (least recently active evicted)
|
|
73
|
+
DFLT_MAX_OBJ_SIZE = 1_000_000 # bytes per object, as measured by ``obj_sizeof``
|
|
74
|
+
DFLT_MAX_OWNER_BYTES = 20_000_000 # bytes per owner (all their objects)
|
|
75
|
+
DFLT_MAX_TOTAL_BYTES = 256_000_000 # bytes per endpoint (every object of every owner)
|
|
76
|
+
|
|
77
|
+
#: The request being handled (set around every handler call), so per-request hooks such
|
|
78
|
+
#: as ``obj_owner`` can read it without the dispatched function taking it.
|
|
79
|
+
current_request: ContextVar = ContextVar("py2http_current_request", default=None)
|
|
64
80
|
|
|
65
81
|
|
|
66
82
|
class LRUObjStore(MutableMapping):
|
|
67
83
|
"""A mapping that keeps at most ``max_size`` items, evicting the least recently
|
|
68
|
-
used one when a new key would exceed it.
|
|
84
|
+
used one when a new key would exceed it (``on_evict(k, v)`` is told).
|
|
69
85
|
|
|
70
86
|
Used as the default store of objects created through a class endpoint, so that
|
|
71
87
|
clients cannot grow server memory without bound by creating objects.
|
|
@@ -78,10 +94,16 @@ class LRUObjStore(MutableMapping):
|
|
|
78
94
|
['a', 'c']
|
|
79
95
|
"""
|
|
80
96
|
|
|
81
|
-
def __init__(
|
|
97
|
+
def __init__(
|
|
98
|
+
self,
|
|
99
|
+
*,
|
|
100
|
+
max_size: int = DFLT_OBJ_STORE_MAX_SIZE,
|
|
101
|
+
on_evict: Optional[Callable[[Any, Any], None]] = None,
|
|
102
|
+
):
|
|
82
103
|
if max_size < 1:
|
|
83
104
|
raise ValueError(f"max_size must be at least 1, got {max_size}")
|
|
84
105
|
self.max_size = max_size
|
|
106
|
+
self.on_evict = on_evict
|
|
85
107
|
self._data = OrderedDict()
|
|
86
108
|
|
|
87
109
|
def __getitem__(self, k):
|
|
@@ -93,7 +115,9 @@ class LRUObjStore(MutableMapping):
|
|
|
93
115
|
self._data[k] = v
|
|
94
116
|
self._data.move_to_end(k)
|
|
95
117
|
while len(self._data) > self.max_size:
|
|
96
|
-
self._data.popitem(last=False)
|
|
118
|
+
evicted_k, evicted_v = self._data.popitem(last=False)
|
|
119
|
+
if self.on_evict is not None:
|
|
120
|
+
self.on_evict(evicted_k, evicted_v)
|
|
97
121
|
|
|
98
122
|
def __delitem__(self, k):
|
|
99
123
|
del self._data[k]
|
|
@@ -105,6 +129,142 @@ class LRUObjStore(MutableMapping):
|
|
|
105
129
|
return len(self._data)
|
|
106
130
|
|
|
107
131
|
|
|
132
|
+
class _SharedObjects:
|
|
133
|
+
"""``obj_owner=shared_objects``: objects belong to no one. Any client holding an
|
|
134
|
+
object id may use it, and every client's objects share one LRU store, so a client
|
|
135
|
+
creating objects evicts everyone else's. Only for trusted clients."""
|
|
136
|
+
|
|
137
|
+
def __repr__(self):
|
|
138
|
+
return "shared_objects"
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
shared_objects = _SharedObjects()
|
|
142
|
+
|
|
143
|
+
#: ``obj_owner`` hook: ``(request) -> owner``, a hashable that identifies an
|
|
144
|
+
#: *authenticated* client (e.g. a user id from a verified token). ``None`` and ``""``
|
|
145
|
+
#: mean "not identified" and are refused. A client-chosen value (a session cookie the
|
|
146
|
+
#: client mints, an IP) lets one client create owners until others are evicted.
|
|
147
|
+
ObjOwner = Callable[[Any], Optional[Any]]
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
class OwnedObjStore:
|
|
151
|
+
"""Objects kept per owner, with object and byte budgets. Thread-safe.
|
|
152
|
+
|
|
153
|
+
- ``quota`` objects per owner: an owner's own least recently used object is evicted
|
|
154
|
+
beyond it, never someone else's;
|
|
155
|
+
- ``max_owners`` owners at once: the least recently active owner's objects go;
|
|
156
|
+
- byte accounting: :meth:`record` the size of an object; :meth:`owner_bytes` and
|
|
157
|
+
:attr:`total_bytes` sum what is currently stored.
|
|
158
|
+
|
|
159
|
+
>>> s = OwnedObjStore(quota=2, max_owners=2)
|
|
160
|
+
>>> s.put('ann', 'a1', 1); s.put('ann', 'a2', 2); s.put('bob', 'b1', 3)
|
|
161
|
+
>>> s.put('ann', 'a3', 4) # ann's quota: her least recently used object goes
|
|
162
|
+
>>> s.get('ann', 'a1') is None, s.get('ann', 'a3'), s.get('bob', 'b1')
|
|
163
|
+
(True, 4, 3)
|
|
164
|
+
>>> s.get('bob', 'a3') is None # another owner's id is simply not found
|
|
165
|
+
True
|
|
166
|
+
>>> s.record('ann', 'a3', 100); s.record('bob', 'b1', 5)
|
|
167
|
+
>>> s.owner_bytes('ann'), s.total_bytes
|
|
168
|
+
(100, 105)
|
|
169
|
+
"""
|
|
170
|
+
|
|
171
|
+
def __init__(
|
|
172
|
+
self, *, quota: int = DFLT_OBJ_QUOTA, max_owners: int = DFLT_MAX_OWNERS
|
|
173
|
+
):
|
|
174
|
+
if max_owners < 1:
|
|
175
|
+
raise ValueError(f"max_owners must be at least 1, got {max_owners}")
|
|
176
|
+
LRUObjStore(max_size=quota) # validates quota now, not on first use
|
|
177
|
+
self._quota = quota
|
|
178
|
+
self._lock = threading.RLock()
|
|
179
|
+
self._sizes: dict = {} # (owner, obj_id) -> bytes last recorded
|
|
180
|
+
self.total_bytes = 0
|
|
181
|
+
self._owners = LRUObjStore(max_size=max_owners, on_evict=self._forget_owner)
|
|
182
|
+
|
|
183
|
+
def _forget(self, owner, obj_id):
|
|
184
|
+
self.total_bytes -= self._sizes.pop((owner, obj_id), 0)
|
|
185
|
+
|
|
186
|
+
def _forget_owner(self, owner, objects):
|
|
187
|
+
for obj_id in list(objects):
|
|
188
|
+
self._forget(owner, obj_id)
|
|
189
|
+
|
|
190
|
+
def _objects_of(self, owner, *, create: bool):
|
|
191
|
+
objects = self._owners.get(owner)
|
|
192
|
+
if objects is None and create:
|
|
193
|
+
objects = LRUObjStore(
|
|
194
|
+
max_size=self._quota,
|
|
195
|
+
on_evict=lambda obj_id, _obj: self._forget(owner, obj_id),
|
|
196
|
+
)
|
|
197
|
+
self._owners[owner] = objects
|
|
198
|
+
return objects
|
|
199
|
+
|
|
200
|
+
def put(self, owner, obj_id: str, obj) -> None:
|
|
201
|
+
with self._lock:
|
|
202
|
+
self._objects_of(owner, create=True)[obj_id] = obj
|
|
203
|
+
|
|
204
|
+
def get(self, owner, obj_id: str):
|
|
205
|
+
with self._lock:
|
|
206
|
+
objects = self._objects_of(owner, create=False)
|
|
207
|
+
return None if objects is None else objects.get(obj_id)
|
|
208
|
+
|
|
209
|
+
def drop(self, owner, obj_id: str) -> None:
|
|
210
|
+
with self._lock:
|
|
211
|
+
objects = self._objects_of(owner, create=False)
|
|
212
|
+
if objects is not None:
|
|
213
|
+
objects.pop(obj_id, None)
|
|
214
|
+
self._forget(owner, obj_id)
|
|
215
|
+
|
|
216
|
+
def record(self, owner, obj_id: str, size: int) -> None:
|
|
217
|
+
with self._lock:
|
|
218
|
+
if self.get(owner, obj_id) is None:
|
|
219
|
+
return # evicted meanwhile
|
|
220
|
+
self.total_bytes += size - self._sizes.get((owner, obj_id), 0)
|
|
221
|
+
self._sizes[(owner, obj_id)] = size
|
|
222
|
+
|
|
223
|
+
def owner_bytes(self, owner) -> int:
|
|
224
|
+
with self._lock:
|
|
225
|
+
objects = self._owners._data.get(owner) or {}
|
|
226
|
+
return sum(self._sizes.get((owner, obj_id), 0) for obj_id in objects)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
_SHARED_SIZE_LEAVES = (type, ModuleType, FunctionType)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def bounded_deep_sizeof(obj, limit: Optional[int] = None) -> int:
|
|
233
|
+
"""Approximate memory held by ``obj`` and everything it references (containers,
|
|
234
|
+
instance ``__dict__`` and ``__slots__``), counting each object once. Stops as soon
|
|
235
|
+
as the total exceeds ``limit``, so measuring costs at most about ``limit`` bytes'
|
|
236
|
+
worth of objects. Classes, modules and functions are shared, so not counted.
|
|
237
|
+
|
|
238
|
+
>>> small, big = bounded_deep_sizeof([1, 2]), bounded_deep_sizeof(list(range(1000)))
|
|
239
|
+
>>> small < big
|
|
240
|
+
True
|
|
241
|
+
>>> bounded_deep_sizeof(list(range(10**6)), limit=1000) > 1000
|
|
242
|
+
True
|
|
243
|
+
"""
|
|
244
|
+
seen, total, todo = set(), 0, deque([obj])
|
|
245
|
+
while todo:
|
|
246
|
+
o = todo.pop()
|
|
247
|
+
if id(o) in seen or isinstance(o, _SHARED_SIZE_LEAVES):
|
|
248
|
+
continue
|
|
249
|
+
seen.add(id(o))
|
|
250
|
+
total += sys.getsizeof(o, 0)
|
|
251
|
+
if limit is not None and total > limit:
|
|
252
|
+
break
|
|
253
|
+
if isinstance(o, dict):
|
|
254
|
+
todo.extend(o.keys())
|
|
255
|
+
todo.extend(o.values())
|
|
256
|
+
elif isinstance(o, (list, tuple, set, frozenset, deque)):
|
|
257
|
+
todo.extend(o)
|
|
258
|
+
if hasattr(o, "__dict__") and isinstance(vars(o), dict):
|
|
259
|
+
todo.append(vars(o))
|
|
260
|
+
for klass in type(o).__mro__:
|
|
261
|
+
slots = getattr(klass, "__slots__", ())
|
|
262
|
+
for slot in (slots,) if isinstance(slots, str) else slots:
|
|
263
|
+
if slot not in ("__dict__", "__weakref__") and hasattr(o, slot):
|
|
264
|
+
todo.append(getattr(o, slot))
|
|
265
|
+
return total
|
|
266
|
+
|
|
267
|
+
|
|
108
268
|
def public_attr(obj, attr_name: str) -> bool:
|
|
109
269
|
"""The attribute policy ``attr_names='*'`` stands for: only public methods.
|
|
110
270
|
|
|
@@ -304,13 +464,18 @@ def mk_route(func, **configs):
|
|
|
304
464
|
|
|
305
465
|
@handle_error
|
|
306
466
|
def sync_handle_request(req):
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
467
|
+
token = current_request.set(req)
|
|
468
|
+
try:
|
|
469
|
+
inputs = input_mapper(req)
|
|
470
|
+
input_args, input_kwargs = get_input_args_and_kwargs(inputs)
|
|
471
|
+
raw_result = func(*input_args, **input_kwargs)
|
|
472
|
+
return output_mapper(raw_result, **inputs)
|
|
473
|
+
finally:
|
|
474
|
+
current_request.reset(token)
|
|
311
475
|
|
|
312
476
|
@handle_error
|
|
313
477
|
async def aiohttp_handle_request(req):
|
|
478
|
+
current_request.set(req) # each aiohttp request runs in its own task context
|
|
314
479
|
inputs = input_mapper(req)
|
|
315
480
|
if isawaitable(inputs): # Pattern: pass-on async property
|
|
316
481
|
inputs = await inputs
|
|
@@ -761,25 +926,7 @@ def _get_func_to_dispatch_handler(handler):
|
|
|
761
926
|
# func = endpoint
|
|
762
927
|
is_dispatchable = _attr_name_policy(attr_names)
|
|
763
928
|
if inspect.isclass(endpoint):
|
|
764
|
-
|
|
765
|
-
obj_store = handler.get("obj_store")
|
|
766
|
-
if obj_store is None:
|
|
767
|
-
obj_store = LRUObjStore()
|
|
768
|
-
|
|
769
|
-
def func(_obj_id=None, _attr_name=None, **kwargs):
|
|
770
|
-
if _obj_id is None:
|
|
771
|
-
if _attr_name is not None:
|
|
772
|
-
raise InputError("_attr_name must be None when _obj_id is None")
|
|
773
|
-
new_obj = cls(**kwargs)
|
|
774
|
-
new_id = str(uuid4())
|
|
775
|
-
obj_store[new_id] = new_obj
|
|
776
|
-
return new_id
|
|
777
|
-
obj = obj_store.get(_obj_id) if isinstance(_obj_id, str) else None
|
|
778
|
-
if obj is None:
|
|
779
|
-
raise InputError(f"No object found with id {_obj_id}")
|
|
780
|
-
if _attr_name is None:
|
|
781
|
-
raise InputError("_attr_name must be provided when _obj_id is not None")
|
|
782
|
-
return _get_attr_value(obj, _attr_name, is_dispatchable, **kwargs)
|
|
929
|
+
func = _class_endpoint_dispatcher(endpoint, handler, name, is_dispatchable)
|
|
783
930
|
|
|
784
931
|
else:
|
|
785
932
|
|
|
@@ -790,6 +937,157 @@ def _get_func_to_dispatch_handler(handler):
|
|
|
790
937
|
return func
|
|
791
938
|
|
|
792
939
|
|
|
940
|
+
class _MappingObjStore:
|
|
941
|
+
"""``OwnedObjStore``'s interface over a user-given ``obj_store`` mapping
|
|
942
|
+
(``shared_objects`` mode): one owner, no byte accounting, a lock around access."""
|
|
943
|
+
|
|
944
|
+
total_bytes = 0
|
|
945
|
+
|
|
946
|
+
def __init__(self, mapping: MutableMapping):
|
|
947
|
+
self._mapping, self._lock = mapping, threading.RLock()
|
|
948
|
+
|
|
949
|
+
def put(self, owner, obj_id, obj):
|
|
950
|
+
with self._lock:
|
|
951
|
+
self._mapping[obj_id] = obj
|
|
952
|
+
|
|
953
|
+
def get(self, owner, obj_id):
|
|
954
|
+
with self._lock:
|
|
955
|
+
return self._mapping.get(obj_id)
|
|
956
|
+
|
|
957
|
+
def drop(self, owner, obj_id):
|
|
958
|
+
with self._lock:
|
|
959
|
+
self._mapping.pop(obj_id, None)
|
|
960
|
+
|
|
961
|
+
def record(self, owner, obj_id, size):
|
|
962
|
+
pass
|
|
963
|
+
|
|
964
|
+
def owner_bytes(self, owner):
|
|
965
|
+
return 0
|
|
966
|
+
|
|
967
|
+
|
|
968
|
+
_SHARED_OWNER = "<shared>"
|
|
969
|
+
|
|
970
|
+
|
|
971
|
+
def _class_endpoint_dispatcher(cls, handler, name, is_dispatchable):
|
|
972
|
+
"""The function behind a class endpoint: called without ``_obj_id`` it creates an
|
|
973
|
+
object and returns its id; with one, it dispatches ``_attr_name`` on that object.
|
|
974
|
+
|
|
975
|
+
Handler options:
|
|
976
|
+
|
|
977
|
+
- ``obj_owner`` (required): ``(request) -> owner`` (see ``ObjOwner``), binding each
|
|
978
|
+
object to the client that created it (another owner's id is simply not found), or
|
|
979
|
+
``shared_objects`` to let anyone holding an id use it. A class endpoint without
|
|
980
|
+
one is refused at build time.
|
|
981
|
+
- ``obj_quota``: objects kept per owner (``DFLT_OBJ_QUOTA``); an owner's least
|
|
982
|
+
recently used object is evicted beyond it. ``max_owners`` bounds the owners.
|
|
983
|
+
- ``obj_store``: ``shared_objects`` mode only, an id -> object mapping replacing the
|
|
984
|
+
default store (``DFLT_OBJ_STORE_MAX_SIZE`` objects); byte budgets other than
|
|
985
|
+
``max_obj_size`` are then not tracked.
|
|
986
|
+
- ``max_obj_size`` (``DFLT_MAX_OBJ_SIZE``), ``max_owner_bytes``
|
|
987
|
+
(``DFLT_MAX_OWNER_BYTES``) and ``max_total_bytes`` (``DFLT_MAX_TOTAL_BYTES``):
|
|
988
|
+
bytes one object, all of one owner's objects, and all objects of the endpoint may
|
|
989
|
+
hold. Sizes come from ``obj_sizeof`` (default ``bounded_deep_sizeof``, which
|
|
990
|
+
stops at ``max_obj_size``, so measuring costs at most about that much), measured
|
|
991
|
+
after creation and after every dispatched call, failed ones included. The object
|
|
992
|
+
that takes a budget over it is dropped. ``None`` disables a budget. An object
|
|
993
|
+
referencing large shared data (a loaded model) counts that data: raise
|
|
994
|
+
``max_obj_size`` or pass an ``obj_sizeof`` that skips it. A single call can still
|
|
995
|
+
allocate before it is measured.
|
|
996
|
+
"""
|
|
997
|
+
owner_of = handler.get("obj_owner")
|
|
998
|
+
if owner_of is None:
|
|
999
|
+
raise ValueError(
|
|
1000
|
+
f"Class endpoint {name!r} needs an obj_owner: a callable (request) -> owner "
|
|
1001
|
+
"(e.g. the authenticated user id), so a client can only use the objects it "
|
|
1002
|
+
"created. Pass obj_owner=shared_objects (from py2http.service) to let any "
|
|
1003
|
+
"client holding an object id use the object, as before."
|
|
1004
|
+
)
|
|
1005
|
+
max_obj_size = handler.get("max_obj_size", DFLT_MAX_OBJ_SIZE)
|
|
1006
|
+
max_owner_bytes = handler.get("max_owner_bytes", DFLT_MAX_OWNER_BYTES)
|
|
1007
|
+
max_total_bytes = handler.get("max_total_bytes", DFLT_MAX_TOTAL_BYTES)
|
|
1008
|
+
sizeof = handler.get("obj_sizeof", bounded_deep_sizeof)
|
|
1009
|
+
|
|
1010
|
+
if owner_of is shared_objects:
|
|
1011
|
+
custom = handler.get("obj_store")
|
|
1012
|
+
store = (
|
|
1013
|
+
OwnedObjStore(quota=DFLT_OBJ_STORE_MAX_SIZE, max_owners=1)
|
|
1014
|
+
if custom is None
|
|
1015
|
+
else _MappingObjStore(custom)
|
|
1016
|
+
)
|
|
1017
|
+
|
|
1018
|
+
def owner():
|
|
1019
|
+
return _SHARED_OWNER
|
|
1020
|
+
|
|
1021
|
+
else:
|
|
1022
|
+
if not callable(owner_of):
|
|
1023
|
+
raise TypeError(
|
|
1024
|
+
f"obj_owner must be callable or shared_objects: {owner_of!r}"
|
|
1025
|
+
)
|
|
1026
|
+
if handler.get("obj_store") is not None:
|
|
1027
|
+
raise ValueError(
|
|
1028
|
+
"obj_store applies to obj_owner=shared_objects only; with an obj_owner "
|
|
1029
|
+
"use obj_quota and max_owners."
|
|
1030
|
+
)
|
|
1031
|
+
store = OwnedObjStore(
|
|
1032
|
+
quota=handler.get("obj_quota", DFLT_OBJ_QUOTA),
|
|
1033
|
+
max_owners=handler.get("max_owners", DFLT_MAX_OWNERS),
|
|
1034
|
+
)
|
|
1035
|
+
|
|
1036
|
+
def owner():
|
|
1037
|
+
who = owner_of(current_request.get())
|
|
1038
|
+
if who is None or who == "":
|
|
1039
|
+
raise AuthorizationError(f"{name}: no owner for this request")
|
|
1040
|
+
try:
|
|
1041
|
+
hash(who)
|
|
1042
|
+
except TypeError:
|
|
1043
|
+
raise TypeError(
|
|
1044
|
+
f"obj_owner of {name!r} must return a hashable, got {type(who)}"
|
|
1045
|
+
) from None
|
|
1046
|
+
return who
|
|
1047
|
+
|
|
1048
|
+
def over_budget(who, obj_id, obj):
|
|
1049
|
+
"""Why the object must go, or None. Records its size when it may stay."""
|
|
1050
|
+
limit = max_obj_size
|
|
1051
|
+
size = sizeof(obj, limit) if limit is not None else sizeof(obj, None)
|
|
1052
|
+
if limit is not None and size > limit:
|
|
1053
|
+
return f"grew past the size limit ({limit} bytes)"
|
|
1054
|
+
store.record(who, obj_id, size)
|
|
1055
|
+
if max_owner_bytes is not None and store.owner_bytes(who) > max_owner_bytes:
|
|
1056
|
+
return f"takes its owner past {max_owner_bytes} bytes"
|
|
1057
|
+
if max_total_bytes is not None and store.total_bytes > max_total_bytes:
|
|
1058
|
+
return f"takes the endpoint past {max_total_bytes} bytes"
|
|
1059
|
+
return None
|
|
1060
|
+
|
|
1061
|
+
def check_size(who, obj_id, obj):
|
|
1062
|
+
no_budget = max_obj_size is max_owner_bytes is max_total_bytes is None
|
|
1063
|
+
reason = None if no_budget else over_budget(who, obj_id, obj)
|
|
1064
|
+
if reason is not None:
|
|
1065
|
+
store.drop(who, obj_id)
|
|
1066
|
+
raise InputError(f"Object {obj_id} {reason} and was dropped")
|
|
1067
|
+
|
|
1068
|
+
def func(_obj_id=None, _attr_name=None, **kwargs):
|
|
1069
|
+
who = owner()
|
|
1070
|
+
if _obj_id is None:
|
|
1071
|
+
if _attr_name is not None:
|
|
1072
|
+
raise InputError("_attr_name must be None when _obj_id is None")
|
|
1073
|
+
new_obj = cls(**kwargs)
|
|
1074
|
+
new_id = str(uuid4())
|
|
1075
|
+
store.put(who, new_id, new_obj)
|
|
1076
|
+
check_size(who, new_id, new_obj)
|
|
1077
|
+
return new_id
|
|
1078
|
+
obj = store.get(who, _obj_id) if isinstance(_obj_id, str) else None
|
|
1079
|
+
if obj is None:
|
|
1080
|
+
raise InputError(f"No object found with id {_obj_id}")
|
|
1081
|
+
if _attr_name is None:
|
|
1082
|
+
raise InputError("_attr_name must be provided when _obj_id is not None")
|
|
1083
|
+
try:
|
|
1084
|
+
return _get_attr_value(obj, _attr_name, is_dispatchable, **kwargs)
|
|
1085
|
+
finally: # a call that grows the object and then raises is measured too
|
|
1086
|
+
check_size(who, _obj_id, obj)
|
|
1087
|
+
|
|
1088
|
+
return func
|
|
1089
|
+
|
|
1090
|
+
|
|
793
1091
|
def _get_attr_value(obj, attr_name, is_dispatchable, /, **kwargs):
|
|
794
1092
|
if not is_dispatchable(obj, attr_name):
|
|
795
1093
|
raise InputError(
|
|
@@ -3,8 +3,11 @@
|
|
|
3
3
|
import pytest
|
|
4
4
|
from i2.errors import InputError
|
|
5
5
|
|
|
6
|
+
import inspect
|
|
7
|
+
|
|
6
8
|
from py2http.service import (
|
|
7
9
|
LRUObjStore,
|
|
10
|
+
shared_objects,
|
|
8
11
|
_get_func_to_dispatch_handler,
|
|
9
12
|
public_attr,
|
|
10
13
|
unsafe_any_attr,
|
|
@@ -25,6 +28,10 @@ class Counter:
|
|
|
25
28
|
|
|
26
29
|
|
|
27
30
|
def _dispatcher(endpoint, attr_names, **handler_extras):
|
|
31
|
+
if inspect.isclass(
|
|
32
|
+
endpoint
|
|
33
|
+
): # class endpoints must say who owns their objects (#22)
|
|
34
|
+
handler_extras.setdefault("obj_owner", shared_objects)
|
|
28
35
|
return _get_func_to_dispatch_handler(
|
|
29
36
|
dict(endpoint=endpoint, name="ep", attr_names=attr_names, **handler_extras)
|
|
30
37
|
)
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
"""Class-endpoint objects belong to their creator and are bounded in size (#22)."""
|
|
2
|
+
|
|
3
|
+
import io
|
|
4
|
+
import json
|
|
5
|
+
from wsgiref.util import setup_testing_defaults
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
from i2.errors import AuthorizationError, InputError
|
|
9
|
+
|
|
10
|
+
from py2http import mk_app
|
|
11
|
+
from py2http.service import (
|
|
12
|
+
OwnedObjStore,
|
|
13
|
+
_get_func_to_dispatch_handler,
|
|
14
|
+
bounded_deep_sizeof,
|
|
15
|
+
current_request,
|
|
16
|
+
shared_objects,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Bag:
|
|
21
|
+
def __init__(self):
|
|
22
|
+
self.items = []
|
|
23
|
+
|
|
24
|
+
def add(self, item=None):
|
|
25
|
+
self.items.append(item)
|
|
26
|
+
return len(self.items)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _owned(**extras):
|
|
30
|
+
extras.setdefault("obj_owner", lambda req: req) # the "request" is the owner here
|
|
31
|
+
return _get_func_to_dispatch_handler(
|
|
32
|
+
dict(endpoint=Bag, name="bag", attr_names="*", **extras)
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _as(owner, func, **kwargs):
|
|
37
|
+
token = current_request.set(owner)
|
|
38
|
+
try:
|
|
39
|
+
return func(**kwargs)
|
|
40
|
+
finally:
|
|
41
|
+
current_request.reset(token)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def test_class_endpoint_without_owner_is_refused_at_build():
|
|
45
|
+
with pytest.raises(ValueError, match="obj_owner"):
|
|
46
|
+
_get_func_to_dispatch_handler(dict(endpoint=Bag, name="bag", attr_names="*"))
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def test_other_owners_objects_are_not_found():
|
|
50
|
+
func = _owned()
|
|
51
|
+
ann_bag = _as("ann", func)
|
|
52
|
+
assert _as("ann", func, _obj_id=ann_bag, _attr_name="add", item=1) == 1
|
|
53
|
+
with pytest.raises(InputError, match="No object found"):
|
|
54
|
+
_as("bob", func, _obj_id=ann_bag, _attr_name="add", item=2)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def test_unidentified_request_is_refused():
|
|
58
|
+
func = _owned()
|
|
59
|
+
with pytest.raises(AuthorizationError):
|
|
60
|
+
_as(None, func)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def test_quota_evicts_only_the_owners_own_objects():
|
|
64
|
+
func = _owned(obj_quota=2)
|
|
65
|
+
bob_bag = _as("bob", func)
|
|
66
|
+
ann_bags = [_as("ann", func) for _ in range(3)]
|
|
67
|
+
with pytest.raises(InputError, match="No object found"):
|
|
68
|
+
_as("ann", func, _obj_id=ann_bags[0], _attr_name="add")
|
|
69
|
+
assert _as("ann", func, _obj_id=ann_bags[2], _attr_name="add") == 1
|
|
70
|
+
assert _as("bob", func, _obj_id=bob_bag, _attr_name="add") == 1
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def test_obj_store_is_for_shared_mode_only():
|
|
74
|
+
with pytest.raises(ValueError, match="shared_objects"):
|
|
75
|
+
_owned(obj_store={})
|
|
76
|
+
with pytest.raises(TypeError, match="callable"):
|
|
77
|
+
_owned(obj_owner="ann")
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def test_shared_objects_opt_in_restores_capability_ids():
|
|
81
|
+
func = _owned(obj_owner=shared_objects)
|
|
82
|
+
bag = _as("ann", func)
|
|
83
|
+
assert _as("bob", func, _obj_id=bag, _attr_name="add") == 1
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def test_object_growing_past_the_size_limit_is_dropped():
|
|
87
|
+
func = _owned(max_obj_size=5_000)
|
|
88
|
+
bag = _as("ann", func)
|
|
89
|
+
_as("ann", func, _obj_id=bag, _attr_name="add", item="x")
|
|
90
|
+
with pytest.raises(InputError, match="size limit"):
|
|
91
|
+
_as("ann", func, _obj_id=bag, _attr_name="add", item="y" * 10_000)
|
|
92
|
+
with pytest.raises(InputError, match="No object found"):
|
|
93
|
+
_as("ann", func, _obj_id=bag, _attr_name="add")
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def test_size_check_can_be_disabled_or_replaced():
|
|
97
|
+
func = _owned(max_obj_size=None)
|
|
98
|
+
bag = _as("ann", func)
|
|
99
|
+
_as("ann", func, _obj_id=bag, _attr_name="add", item="y" * 100_000)
|
|
100
|
+
func = _owned(max_obj_size=1, obj_sizeof=lambda obj, limit: 0)
|
|
101
|
+
_as("ann", func, _obj_id=_as("ann", func), _attr_name="add", item="y")
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def test_bounded_deep_sizeof_sees_slots_and_stops_early():
|
|
105
|
+
class Slotted:
|
|
106
|
+
__slots__ = ("payload",)
|
|
107
|
+
|
|
108
|
+
def __init__(self, payload):
|
|
109
|
+
self.payload = payload
|
|
110
|
+
|
|
111
|
+
assert bounded_deep_sizeof(Slotted("y" * 10_000)) > 10_000
|
|
112
|
+
assert bounded_deep_sizeof(Bag()) < bounded_deep_sizeof(_full_bag())
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _full_bag():
|
|
116
|
+
b = Bag()
|
|
117
|
+
b.items.extend(range(1000))
|
|
118
|
+
return b
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def test_owned_store_rejects_bad_bounds():
|
|
122
|
+
with pytest.raises(ValueError):
|
|
123
|
+
OwnedObjStore(quota=0)
|
|
124
|
+
with pytest.raises(ValueError):
|
|
125
|
+
OwnedObjStore(max_owners=0)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
# ------------------------------------------------- end to end, through a real request
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _post(app, path, payload, user):
|
|
132
|
+
body = json.dumps(payload).encode()
|
|
133
|
+
environ = {}
|
|
134
|
+
setup_testing_defaults(environ)
|
|
135
|
+
environ.update(
|
|
136
|
+
REQUEST_METHOD="POST",
|
|
137
|
+
PATH_INFO=path,
|
|
138
|
+
CONTENT_TYPE="application/json",
|
|
139
|
+
CONTENT_LENGTH=str(len(body)),
|
|
140
|
+
HTTP_X_USER=user,
|
|
141
|
+
)
|
|
142
|
+
environ["wsgi.input"] = io.BytesIO(body)
|
|
143
|
+
statuses = []
|
|
144
|
+
out = b"".join(app(environ, lambda status, headers, *a: statuses.append(status)))
|
|
145
|
+
return statuses[0], json.loads(out)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def test_owner_hook_reads_the_real_request():
|
|
149
|
+
app = mk_app(
|
|
150
|
+
[
|
|
151
|
+
dict(
|
|
152
|
+
endpoint=Bag,
|
|
153
|
+
name="bag",
|
|
154
|
+
attr_names="*",
|
|
155
|
+
obj_owner=lambda req: req.headers.get("X-User") or None,
|
|
156
|
+
)
|
|
157
|
+
]
|
|
158
|
+
)
|
|
159
|
+
status, bag = _post(app, "/bag", {}, "ann")
|
|
160
|
+
assert status.startswith("200")
|
|
161
|
+
status, n = _post(app, "/bag", {"_obj_id": bag, "_attr_name": "add"}, "ann")
|
|
162
|
+
assert status.startswith("200") and n == 1
|
|
163
|
+
status, _ = _post(app, "/bag", {"_obj_id": bag, "_attr_name": "add"}, "bob")
|
|
164
|
+
assert not status.startswith("2")
|
|
165
|
+
status, _ = _post(app, "/bag", {}, "")
|
|
166
|
+
assert not status.startswith("2")
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
# ------------------------------------------------- adversarial-review findings (#29)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
class Validating(Bag):
|
|
173
|
+
def extend(self, items=()):
|
|
174
|
+
for x in items:
|
|
175
|
+
if x is None:
|
|
176
|
+
raise ValueError("bad item")
|
|
177
|
+
self.items.append(x)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _validating(**extras):
|
|
181
|
+
extras.setdefault("obj_owner", lambda req: req)
|
|
182
|
+
return _get_func_to_dispatch_handler(
|
|
183
|
+
dict(endpoint=Validating, name="v", attr_names="*", **extras)
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def test_a_call_that_grows_the_object_then_raises_is_still_measured():
|
|
188
|
+
func = _validating(max_obj_size=10_000)
|
|
189
|
+
bag = _as("ann", func)
|
|
190
|
+
with pytest.raises(InputError, match="size limit"):
|
|
191
|
+
_as("ann", func, _obj_id=bag, _attr_name="extend", items=["y" * 20_000, None])
|
|
192
|
+
with pytest.raises(InputError, match="No object found"):
|
|
193
|
+
_as("ann", func, _obj_id=bag, _attr_name="add")
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def test_owner_byte_budget():
|
|
197
|
+
func = _owned(max_obj_size=50_000, max_owner_bytes=100_000)
|
|
198
|
+
bags = [_as("ann", func) for _ in range(3)]
|
|
199
|
+
for bag in bags[:2]:
|
|
200
|
+
_as("ann", func, _obj_id=bag, _attr_name="add", item="y" * 40_000)
|
|
201
|
+
with pytest.raises(InputError, match="its owner past"):
|
|
202
|
+
_as("ann", func, _obj_id=bags[2], _attr_name="add", item="y" * 40_000)
|
|
203
|
+
bob = _as("bob", func) # another owner is unaffected
|
|
204
|
+
assert _as("bob", func, _obj_id=bob, _attr_name="add", item="y" * 40_000) == 1
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def test_endpoint_byte_budget():
|
|
208
|
+
func = _owned(max_obj_size=50_000, max_total_bytes=100_000)
|
|
209
|
+
for who in ("ann", "bob"):
|
|
210
|
+
_as(who, func, _obj_id=_as(who, func), _attr_name="add", item="y" * 40_000)
|
|
211
|
+
cat = _as("cat", func)
|
|
212
|
+
with pytest.raises(InputError, match="the endpoint past"):
|
|
213
|
+
_as("cat", func, _obj_id=cat, _attr_name="add", item="y" * 40_000)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def test_evicted_objects_release_their_bytes():
|
|
217
|
+
store = OwnedObjStore(quota=1, max_owners=1)
|
|
218
|
+
store.put("ann", "a1", 1)
|
|
219
|
+
store.record("ann", "a1", 100)
|
|
220
|
+
store.put("ann", "a2", 2) # quota evicts a1
|
|
221
|
+
assert store.total_bytes == 0
|
|
222
|
+
store.record("ann", "a2", 7)
|
|
223
|
+
store.put("bob", "b1", 3) # max_owners evicts ann
|
|
224
|
+
assert store.total_bytes == 0 and store.owner_bytes("ann") == 0
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
@pytest.mark.parametrize("who", ["", None])
|
|
228
|
+
def test_empty_owner_is_refused(who):
|
|
229
|
+
with pytest.raises(AuthorizationError):
|
|
230
|
+
_as(who, _owned())
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def test_unhashable_owner_names_the_hook():
|
|
234
|
+
with pytest.raises(TypeError, match="hashable"):
|
|
235
|
+
_as({"user": 1}, _owned())
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def test_store_is_thread_safe():
|
|
239
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
240
|
+
|
|
241
|
+
store = OwnedObjStore(quota=2, max_owners=4)
|
|
242
|
+
|
|
243
|
+
def churn(i):
|
|
244
|
+
for j in range(300):
|
|
245
|
+
owner = (i + j) % 9
|
|
246
|
+
store.put(owner, f"{i}-{j}", j)
|
|
247
|
+
store.record(owner, f"{i}-{j}", 1)
|
|
248
|
+
store.get(owner, f"{i}-{j}")
|
|
249
|
+
store.owner_bytes(owner)
|
|
250
|
+
|
|
251
|
+
with ThreadPoolExecutor(8) as pool:
|
|
252
|
+
list(pool.map(churn, range(8)))
|
|
253
|
+
assert store.total_bytes == sum(store.owner_bytes(o) for o in range(9))
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|