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.

Files changed (45) hide show
  1. {py2http-0.1.64 → py2http-0.1.66}/PKG-INFO +1 -1
  2. {py2http-0.1.64 → py2http-0.1.66}/py2http/service.py +329 -31
  3. {py2http-0.1.64 → py2http-0.1.66}/py2http/tests/test_attr_dispatch.py +7 -0
  4. py2http-0.1.66/py2http/tests/test_obj_ownership.py +253 -0
  5. {py2http-0.1.64 → py2http-0.1.66}/py2http.egg-info/PKG-INFO +1 -1
  6. {py2http-0.1.64 → py2http-0.1.66}/py2http.egg-info/SOURCES.txt +1 -0
  7. {py2http-0.1.64 → py2http-0.1.66}/setup.cfg +1 -1
  8. {py2http-0.1.64 → py2http-0.1.66}/LICENSE +0 -0
  9. {py2http-0.1.64 → py2http-0.1.66}/README.md +0 -0
  10. {py2http-0.1.64 → py2http-0.1.66}/py2http/__init__.py +0 -0
  11. {py2http-0.1.64 → py2http-0.1.66}/py2http/bottle_plugins.py +0 -0
  12. {py2http-0.1.64 → py2http-0.1.66}/py2http/config.py +0 -0
  13. {py2http-0.1.64 → py2http-0.1.66}/py2http/constants.py +0 -0
  14. {py2http-0.1.64 → py2http-0.1.66}/py2http/decorators.py +0 -0
  15. {py2http-0.1.64 → py2http-0.1.66}/py2http/default_configs.py +0 -0
  16. {py2http-0.1.64 → py2http-0.1.66}/py2http/diagnosis.py +0 -0
  17. {py2http-0.1.64 → py2http-0.1.66}/py2http/examples/__init__.py +0 -0
  18. {py2http-0.1.64 → py2http-0.1.66}/py2http/examples/custom_input_mapper.py +0 -0
  19. {py2http-0.1.64 → py2http-0.1.66}/py2http/examples/custom_input_mappers.py +0 -0
  20. {py2http-0.1.64 → py2http-0.1.66}/py2http/examples/example_service.py +0 -0
  21. {py2http-0.1.64 → py2http-0.1.66}/py2http/examples/example_service_bottle.py +0 -0
  22. {py2http-0.1.64 → py2http-0.1.66}/py2http/examples/example_service_flask.py +0 -0
  23. {py2http-0.1.64 → py2http-0.1.66}/py2http/examples/object_dispatch.py +0 -0
  24. {py2http-0.1.64 → py2http-0.1.66}/py2http/middleware.py +0 -0
  25. {py2http-0.1.64 → py2http-0.1.66}/py2http/openapi_utils.py +0 -0
  26. {py2http-0.1.64 → py2http-0.1.66}/py2http/route_utils.py +0 -0
  27. {py2http-0.1.64 → py2http-0.1.66}/py2http/schema_tools.py +0 -0
  28. {py2http-0.1.64 → py2http-0.1.66}/py2http/tests/__init__.py +0 -0
  29. {py2http-0.1.64 → py2http-0.1.66}/py2http/tests/example_service_test.py +0 -0
  30. {py2http-0.1.64 → py2http-0.1.66}/py2http/tests/objects_for_testing.py +0 -0
  31. {py2http-0.1.64 → py2http-0.1.66}/py2http/tests/simple_run_process_test.py +0 -0
  32. {py2http-0.1.64 → py2http-0.1.66}/py2http/tests/test_binary_req.py +0 -0
  33. {py2http-0.1.64 → py2http-0.1.66}/py2http/tests/test_decorators.py +0 -0
  34. {py2http-0.1.64 → py2http-0.1.66}/py2http/tests/test_flask.py +0 -0
  35. {py2http-0.1.64 → py2http-0.1.66}/py2http/tests/test_jwt_middleware.py +0 -0
  36. {py2http-0.1.64 → py2http-0.1.66}/py2http/tests/test_not_set_defaults.py +0 -0
  37. {py2http-0.1.64 → py2http-0.1.66}/py2http/tests/test_p2h2p.py +0 -0
  38. {py2http-0.1.64 → py2http-0.1.66}/py2http/tests/utils_for_testing.py +0 -0
  39. {py2http-0.1.64 → py2http-0.1.66}/py2http/types.py +0 -0
  40. {py2http-0.1.64 → py2http-0.1.66}/py2http/util.py +0 -0
  41. {py2http-0.1.64 → py2http-0.1.66}/py2http.egg-info/dependency_links.txt +0 -0
  42. {py2http-0.1.64 → py2http-0.1.66}/py2http.egg-info/not-zip-safe +0 -0
  43. {py2http-0.1.64 → py2http-0.1.66}/py2http.egg-info/requires.txt +0 -0
  44. {py2http-0.1.64 → py2http-0.1.66}/py2http.egg-info/top_level.txt +0 -0
  45. {py2http-0.1.64 → py2http-0.1.66}/setup.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: py2http
3
- Version: 0.1.64
3
+ Version: 0.1.66
4
4
  Summary: Declarative HTTP service entry point.
5
5
  Home-page: https://github.com/i2mint/py2http
6
6
  License: MIT
@@ -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
- # 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).
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__(self, *, max_size: int = DFLT_OBJ_STORE_MAX_SIZE):
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
- inputs = input_mapper(req)
308
- input_args, input_kwargs = get_input_args_and_kwargs(inputs)
309
- raw_result = func(*input_args, **input_kwargs)
310
- return output_mapper(raw_result, **inputs)
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
- cls = endpoint
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))
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: py2http
3
- Version: 0.1.64
3
+ Version: 0.1.66
4
4
  Summary: Declarative HTTP service entry point.
5
5
  Home-page: https://github.com/i2mint/py2http
6
6
  License: MIT
@@ -39,5 +39,6 @@ py2http/tests/test_decorators.py
39
39
  py2http/tests/test_flask.py
40
40
  py2http/tests/test_jwt_middleware.py
41
41
  py2http/tests/test_not_set_defaults.py
42
+ py2http/tests/test_obj_ownership.py
42
43
  py2http/tests/test_p2h2p.py
43
44
  py2http/tests/utils_for_testing.py
@@ -1,6 +1,6 @@
1
1
  [metadata]
2
2
  name = py2http
3
- version = 0.1.64
3
+ version = 0.1.66
4
4
  url = https://github.com/i2mint/py2http
5
5
  root_url = https://github.com/i2mint
6
6
  platforms = any
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