py2http 0.1.66__tar.gz → 0.1.67__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.
Files changed (46) hide show
  1. {py2http-0.1.66 → py2http-0.1.67}/PKG-INFO +1 -1
  2. {py2http-0.1.66 → py2http-0.1.67}/py2http/default_configs.py +41 -23
  3. {py2http-0.1.66 → py2http-0.1.67}/py2http/examples/object_dispatch.py +5 -0
  4. {py2http-0.1.66 → py2http-0.1.67}/py2http/service.py +197 -30
  5. {py2http-0.1.66 → py2http-0.1.67}/py2http/tests/test_attr_dispatch.py +1 -3
  6. py2http-0.1.67/py2http/tests/test_instance_endpoints.py +240 -0
  7. {py2http-0.1.66 → py2http-0.1.67}/py2http.egg-info/PKG-INFO +1 -1
  8. {py2http-0.1.66 → py2http-0.1.67}/py2http.egg-info/SOURCES.txt +1 -0
  9. {py2http-0.1.66 → py2http-0.1.67}/setup.cfg +1 -1
  10. {py2http-0.1.66 → py2http-0.1.67}/LICENSE +0 -0
  11. {py2http-0.1.66 → py2http-0.1.67}/README.md +0 -0
  12. {py2http-0.1.66 → py2http-0.1.67}/py2http/__init__.py +0 -0
  13. {py2http-0.1.66 → py2http-0.1.67}/py2http/bottle_plugins.py +0 -0
  14. {py2http-0.1.66 → py2http-0.1.67}/py2http/config.py +0 -0
  15. {py2http-0.1.66 → py2http-0.1.67}/py2http/constants.py +0 -0
  16. {py2http-0.1.66 → py2http-0.1.67}/py2http/decorators.py +0 -0
  17. {py2http-0.1.66 → py2http-0.1.67}/py2http/diagnosis.py +0 -0
  18. {py2http-0.1.66 → py2http-0.1.67}/py2http/examples/__init__.py +0 -0
  19. {py2http-0.1.66 → py2http-0.1.67}/py2http/examples/custom_input_mapper.py +0 -0
  20. {py2http-0.1.66 → py2http-0.1.67}/py2http/examples/custom_input_mappers.py +0 -0
  21. {py2http-0.1.66 → py2http-0.1.67}/py2http/examples/example_service.py +0 -0
  22. {py2http-0.1.66 → py2http-0.1.67}/py2http/examples/example_service_bottle.py +0 -0
  23. {py2http-0.1.66 → py2http-0.1.67}/py2http/examples/example_service_flask.py +0 -0
  24. {py2http-0.1.66 → py2http-0.1.67}/py2http/middleware.py +0 -0
  25. {py2http-0.1.66 → py2http-0.1.67}/py2http/openapi_utils.py +0 -0
  26. {py2http-0.1.66 → py2http-0.1.67}/py2http/route_utils.py +0 -0
  27. {py2http-0.1.66 → py2http-0.1.67}/py2http/schema_tools.py +0 -0
  28. {py2http-0.1.66 → py2http-0.1.67}/py2http/tests/__init__.py +0 -0
  29. {py2http-0.1.66 → py2http-0.1.67}/py2http/tests/example_service_test.py +0 -0
  30. {py2http-0.1.66 → py2http-0.1.67}/py2http/tests/objects_for_testing.py +0 -0
  31. {py2http-0.1.66 → py2http-0.1.67}/py2http/tests/simple_run_process_test.py +0 -0
  32. {py2http-0.1.66 → py2http-0.1.67}/py2http/tests/test_binary_req.py +0 -0
  33. {py2http-0.1.66 → py2http-0.1.67}/py2http/tests/test_decorators.py +0 -0
  34. {py2http-0.1.66 → py2http-0.1.67}/py2http/tests/test_flask.py +0 -0
  35. {py2http-0.1.66 → py2http-0.1.67}/py2http/tests/test_jwt_middleware.py +0 -0
  36. {py2http-0.1.66 → py2http-0.1.67}/py2http/tests/test_not_set_defaults.py +0 -0
  37. {py2http-0.1.66 → py2http-0.1.67}/py2http/tests/test_obj_ownership.py +0 -0
  38. {py2http-0.1.66 → py2http-0.1.67}/py2http/tests/test_p2h2p.py +0 -0
  39. {py2http-0.1.66 → py2http-0.1.67}/py2http/tests/utils_for_testing.py +0 -0
  40. {py2http-0.1.66 → py2http-0.1.67}/py2http/types.py +0 -0
  41. {py2http-0.1.66 → py2http-0.1.67}/py2http/util.py +0 -0
  42. {py2http-0.1.66 → py2http-0.1.67}/py2http.egg-info/dependency_links.txt +0 -0
  43. {py2http-0.1.66 → py2http-0.1.67}/py2http.egg-info/not-zip-safe +0 -0
  44. {py2http-0.1.66 → py2http-0.1.67}/py2http.egg-info/requires.txt +0 -0
  45. {py2http-0.1.66 → py2http-0.1.67}/py2http.egg-info/top_level.txt +0 -0
  46. {py2http-0.1.66 → py2http-0.1.67}/setup.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: py2http
3
- Version: 0.1.66
3
+ Version: 0.1.67
4
4
  Summary: Declarative HTTP service entry point.
5
5
  Home-page: https://github.com/i2mint/py2http
6
6
  License: MIT
@@ -23,6 +23,7 @@ from i2.errors import (
23
23
  InputError,
24
24
  NotFoundError,
25
25
  DuplicateRecordError,
26
+ OverwritesNotAllowed,
26
27
  )
27
28
 
28
29
  from py2http.decorators import handle_json_req, send_json_resp, JsonRespEncoder
@@ -59,34 +60,51 @@ def _raise_http_client_error(error, message, reason=None):
59
60
  )
60
61
 
61
62
 
63
+ # HTTP status of each client error, most specific class first. ``ForbiddenError`` and
64
+ # ``OverwritesNotAllowed`` mean the client may not do this (403); any other
65
+ # ``AuthorizationError`` means the request did not say who it is (401).
66
+ _CLIENT_ERROR_STATUSES = (
67
+ ((ForbiddenError, OverwritesNotAllowed), 403),
68
+ (AuthorizationError, 401),
69
+ ((InputError, DuplicateRecordError), 400),
70
+ (NotFoundError, 404),
71
+ )
72
+ _AIOHTTP_ERRORS = {
73
+ 400: web.HTTPBadRequest,
74
+ 401: web.HTTPUnauthorized,
75
+ 403: web.HTTPForbidden,
76
+ 404: web.HTTPNotFound,
77
+ }
78
+
79
+
80
+ def error_status(error: Exception) -> int:
81
+ """The HTTP status for an error a handler raised (500 when it is not a client's).
82
+
83
+ >>> error_status(AuthorizationError()), error_status(ForbiddenError())
84
+ (401, 403)
85
+ >>> error_status(InputError()), error_status(KeyError())
86
+ (400, 500)
87
+ """
88
+ for error_types, status in _CLIENT_ERROR_STATUSES:
89
+ if isinstance(error, error_types):
90
+ return status
91
+ return 500
92
+
93
+
62
94
  def aiohttp_error_handler(error: Exception):
63
- message = str(error)
64
- if isinstance(error, (AuthorizationError, InputError, DuplicateRecordError)):
65
- _raise_http_client_error(
66
- web.HTTPBadRequest, message, reason=type(error).__name__
67
- )
68
- elif isinstance(error, ForbiddenError):
69
- _raise_http_client_error(web.HTTPForbidden, message)
70
- elif isinstance(error, NotFoundError):
71
- _raise_http_client_error(web.HTTPNotFound, message)
72
- else:
73
- message = "Internal server error"
74
- _raise_http_client_error(web.HTTPInternalServerError, message)
95
+ status = error_status(error)
96
+ if status == 500:
97
+ _raise_http_client_error(web.HTTPInternalServerError, "Internal server error")
98
+ reason = type(error).__name__ if status == 400 else None
99
+ _raise_http_client_error(_AIOHTTP_ERRORS[status], str(error), reason=reason)
75
100
 
76
101
 
77
102
  def bottle_error_handler(error: Exception):
78
103
  message = str(error)
79
- if isinstance(error, (AuthorizationError, InputError, DuplicateRecordError)):
80
- response.status = f"400 {type(error).__name__}"
81
- # response.reason = type(error).__name__
82
- elif isinstance(error, ForbiddenError):
83
- response.status = 403
84
- elif isinstance(error, NotFoundError):
85
- response.status = 404
86
- else:
87
- response.status = 500
88
- if os.getenv("OPAQUE_ERRORS", None):
89
- message = "Internal server error"
104
+ status = error_status(error)
105
+ response.status = f"400 {type(error).__name__}" if status == 400 else status
106
+ if status == 500 and os.getenv("OPAQUE_ERRORS", None):
107
+ message = "Internal server error"
90
108
  return {"error": message}
91
109
 
92
110
 
@@ -11,6 +11,8 @@ and its ecosystem (mongodol, s3dol, etc.).
11
11
 
12
12
  from typing import MutableMapping
13
13
 
14
+ from py2http.service import shared_objects
15
+
14
16
 
15
17
  class BackendStore(MutableMapping):
16
18
  """MutableMapping wrapper that assumes that values are given as strings,
@@ -47,6 +49,9 @@ handlers = [
47
49
  endpoint=backend_store,
48
50
  name="backend_store",
49
51
  attr_names=["__iter__", "__getitem__", "__setitem__"],
52
+ # One store every client shares and may write to: fine for a demo. A real
53
+ # service passes obj_owner=<(request) -> authenticated user id> instead.
54
+ obj_owner=shared_objects,
50
55
  ),
51
56
  ]
52
57
 
@@ -28,7 +28,7 @@ from functools import partial, wraps
28
28
  from inspect import isawaitable
29
29
  import json
30
30
  from typing import Any, Callable, Dict, Iterable, Optional, TypedDict, Union
31
- from types import FunctionType
31
+ from types import BuiltinMethodType, FunctionType, MethodType, MethodWrapperType
32
32
  import logging
33
33
  import os
34
34
  import sys
@@ -59,6 +59,10 @@ from py2http.schema_tools import (
59
59
  from py2http.util import TypeAsserter
60
60
  from py2http.constants import JSON_CONTENT_TYPE
61
61
 
62
+ # Instance endpoints (``handler['endpoint']`` is an object) are one object every client
63
+ # shares; they too need an ``obj_owner`` and are bounded by ``max_obj_size`` (see
64
+ # ``_instance_endpoint_dispatcher``).
65
+ #
62
66
  # Class endpoints (``handler['endpoint']`` is a class) keep the objects clients create.
63
67
  # Who may use an object is the handler's ``obj_owner`` (see ``ObjOwner``); how many
64
68
  # objects an owner may keep is ``obj_quota``; how many bytes each object, each owner and
@@ -226,14 +230,19 @@ class OwnedObjStore:
226
230
  return sum(self._sizes.get((owner, obj_id), 0) for obj_id in objects)
227
231
 
228
232
 
229
- _SHARED_SIZE_LEAVES = (type, ModuleType, FunctionType)
233
+ _SHARED_SIZE_LEAVES = (type, ModuleType)
234
+ _BOUND_METHODS = (MethodType, BuiltinMethodType, MethodWrapperType)
230
235
 
231
236
 
232
237
  def bounded_deep_sizeof(obj, limit: Optional[int] = None) -> int:
233
238
  """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.
239
+ instance ``__dict__`` and ``__slots__``, what a bound method, a ``partial`` or a
240
+ function's closure, attributes and defaults hold), counting each object once. Stops
241
+ as soon as the total exceeds ``limit``, so measuring costs at most about ``limit``
242
+ bytes' worth of objects. Classes, modules and the code of functions are shared, so
243
+ not counted, and neither is what classes and modules hold (class attributes, module
244
+ globals), nor caches kept in C (``lru_cache``). Holding a bound method of another
245
+ object counts that whole object, as holding the object itself would.
237
246
 
238
247
  >>> small, big = bounded_deep_sizeof([1, 2]), bounded_deep_sizeof(list(range(1000)))
239
248
  >>> small < big
@@ -247,6 +256,14 @@ def bounded_deep_sizeof(obj, limit: Optional[int] = None) -> int:
247
256
  if id(o) in seen or isinstance(o, _SHARED_SIZE_LEAVES):
248
257
  continue
249
258
  seen.add(id(o))
259
+ try:
260
+ held = _held_by_callable(o)
261
+ except Exception: # an exotic object (a mock, a proxy): measure it plainly
262
+ held = None
263
+ if isinstance(o, FunctionType): # the code is shared; only what it holds counts
264
+ todo.extend(held or ())
265
+ continue
266
+ todo.extend(held or ())
250
267
  total += sys.getsizeof(o, 0)
251
268
  if limit is not None and total > limit:
252
269
  break
@@ -255,16 +272,49 @@ def bounded_deep_sizeof(obj, limit: Optional[int] = None) -> int:
255
272
  todo.extend(o.values())
256
273
  elif isinstance(o, (list, tuple, set, frozenset, deque)):
257
274
  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))
275
+ try: # a __getattr__ may raise anything
276
+ todo.extend(_instance_state(o))
277
+ except Exception:
278
+ pass
265
279
  return total
266
280
 
267
281
 
282
+ def _instance_state(o):
283
+ """An object's ``__dict__`` and the values of its ``__slots__``."""
284
+ if hasattr(o, "__dict__") and isinstance(vars(o), dict):
285
+ yield vars(o)
286
+ for klass in type(o).__mro__:
287
+ slots = getattr(klass, "__slots__", ())
288
+ for slot in (slots,) if isinstance(slots, str) else slots:
289
+ if slot not in ("__dict__", "__weakref__") and hasattr(o, slot):
290
+ yield getattr(o, slot)
291
+
292
+
293
+ def _held_by_callable(o) -> Optional[list]:
294
+ """What a function (closure, attributes, defaults), bound method, static or class
295
+ method, or ``partial`` holds; ``None`` for anything else."""
296
+ if isinstance(o, FunctionType):
297
+ cells = [c.cell_contents for c in o.__closure__ or () if _cell_is_set(c)]
298
+ return [*cells, o.__dict__, o.__defaults__, o.__kwdefaults__]
299
+ if isinstance(o, MethodType):
300
+ return [o.__self__, o.__func__]
301
+ if isinstance(o, _BOUND_METHODS): # a builtin's: what it is bound to
302
+ return [o.__self__]
303
+ if isinstance(o, (staticmethod, classmethod)):
304
+ return [o.__func__]
305
+ if isinstance(o, partial):
306
+ return [o.func, o.args, o.keywords]
307
+ return None
308
+
309
+
310
+ def _cell_is_set(cell) -> bool:
311
+ try:
312
+ cell.cell_contents
313
+ except ValueError: # an empty cell (a free variable not yet assigned)
314
+ return False
315
+ return True
316
+
317
+
268
318
  def public_attr(obj, attr_name: str) -> bool:
269
319
  """The attribute policy ``attr_names='*'`` stands for: only public methods.
270
320
 
@@ -475,7 +525,13 @@ def mk_route(func, **configs):
475
525
 
476
526
  @handle_error
477
527
  async def aiohttp_handle_request(req):
478
- current_request.set(req) # each aiohttp request runs in its own task context
528
+ token = current_request.set(req)
529
+ try:
530
+ return await _aiohttp_handle(req)
531
+ finally:
532
+ current_request.reset(token)
533
+
534
+ async def _aiohttp_handle(req):
479
535
  inputs = input_mapper(req)
480
536
  if isawaitable(inputs): # Pattern: pass-on async property
481
537
  inputs = await inputs
@@ -928,11 +984,14 @@ def _get_func_to_dispatch_handler(handler):
928
984
  if inspect.isclass(endpoint):
929
985
  func = _class_endpoint_dispatcher(endpoint, handler, name, is_dispatchable)
930
986
 
931
- else:
987
+ elif isinstance(endpoint, ModuleType): # a module's functions: no shared object
932
988
 
933
989
  def func(_attr_name=None, **kwargs):
934
990
  return _get_attr_value(endpoint, _attr_name, is_dispatchable, **kwargs)
935
991
 
992
+ else:
993
+ func = _instance_endpoint_dispatcher(endpoint, handler, name, is_dispatchable)
994
+
936
995
  func.__name__ = name
937
996
  return func
938
997
 
@@ -968,6 +1027,129 @@ class _MappingObjStore:
968
1027
  _SHARED_OWNER = "<shared>"
969
1028
 
970
1029
 
1030
+ def _request_owner(owner_of: ObjOwner, name: str) -> Callable[[], Any]:
1031
+ """A zero-argument function giving the owner of the request being handled, as
1032
+ ``owner_of(current_request)`` says, refusing requests it does not identify."""
1033
+ if not callable(owner_of):
1034
+ raise TypeError(f"obj_owner must be callable or shared_objects: {owner_of!r}")
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
+ return owner
1049
+
1050
+
1051
+ def _instance_endpoint_dispatcher(obj, handler, name, is_dispatchable):
1052
+ """The function behind an instance endpoint (``handler['endpoint']`` is an object,
1053
+ not a class or module): it dispatches ``_attr_name`` on that one object, which every
1054
+ client allowed in shares. A method that mutates it (``__setitem__``, ``write``...)
1055
+ lets each of them change what the others see.
1056
+
1057
+ Handler options:
1058
+
1059
+ - ``obj_owner`` (required): ``(request) -> owner`` (see ``ObjOwner``); a request it
1060
+ does not identify is refused, the others share the object. Or ``shared_objects``
1061
+ to let any client use it. An instance endpoint without one is refused at build
1062
+ time.
1063
+ - ``max_obj_size`` (``DFLT_MAX_OBJ_SIZE``) and ``obj_sizeof``
1064
+ (``bounded_deep_sizeof``): the bytes the object may hold, measured when the
1065
+ service is built (an object already over it is refused then) and after every
1066
+ dispatched call, failed ones included. The call that takes it over the limit has
1067
+ already run: its result is withheld, the object is kept, and every later call is
1068
+ refused until it is back under, except calls to ``obj_shrinkers``. So memory is
1069
+ bounded by the limit plus what the calls running at that moment allocate.
1070
+ ``None`` disables the check. Raise ``max_obj_size``, or pass an ``obj_sizeof``
1071
+ that skips what is not request-driven (a loaded model, a database connection),
1072
+ for a large object. Not measured: data the object keeps outside the process
1073
+ (files, a database), and state held in its class or in module globals.
1074
+ Measuring walks the object (up to ``max_obj_size`` bytes of it) after every
1075
+ call, reads included, so a large limit costs time on every request. A method
1076
+ returning a generator or coroutine does its work after the measure: what it
1077
+ adds is caught by the next call.
1078
+ - ``obj_shrinkers``: names of dispatched attributes that only ever remove
1079
+ (``pop``, ``clear``, ``__delitem__`` if ``attr_names`` lists it...), still
1080
+ callable while the object is over its limit so a client can bring it back under.
1081
+ Empty by default: py2http cannot tell which methods shrink an object, and the
1082
+ bound holds only if every name listed really only removes (one that also adds is
1083
+ never refused).
1084
+ """
1085
+ owner_of = handler.get("obj_owner")
1086
+ if owner_of is None:
1087
+ raise ValueError(
1088
+ f"Instance endpoint {name!r} is one object shared by every client: give it "
1089
+ "an obj_owner, a callable (request) -> owner (e.g. the authenticated user "
1090
+ "id) that refuses requests it does not identify, or obj_owner=shared_objects "
1091
+ "(from py2http.service) to let any client use it, as before."
1092
+ )
1093
+ owner = (
1094
+ (lambda: None) if owner_of is shared_objects else _request_owner(owner_of, name)
1095
+ )
1096
+ max_obj_size = handler.get("max_obj_size", DFLT_MAX_OBJ_SIZE)
1097
+ sizeof = handler.get("obj_sizeof", bounded_deep_sizeof)
1098
+
1099
+ def is_too_big():
1100
+ return max_obj_size is not None and sizeof(obj, max_obj_size) > max_obj_size
1101
+
1102
+ if is_too_big():
1103
+ raise ValueError(
1104
+ f"Instance endpoint {name!r} already holds more than max_obj_size="
1105
+ f"{max_obj_size} bytes (as measured by obj_sizeof): raise max_obj_size, pass "
1106
+ "an obj_sizeof that skips what clients cannot grow, or max_obj_size=None."
1107
+ )
1108
+ shrinkers = handler.get("obj_shrinkers", ())
1109
+ if isinstance(shrinkers, str):
1110
+ raise TypeError(f"obj_shrinkers must be a collection of names: {shrinkers!r}")
1111
+ shrinkers = frozenset(shrinkers)
1112
+ for attr_name in shrinkers:
1113
+ if not is_dispatchable(obj, attr_name):
1114
+ raise ValueError(
1115
+ f"obj_shrinkers of {name!r}: {attr_name!r} is not dispatched "
1116
+ "(see attr_names)"
1117
+ )
1118
+ over_limit = threading.Event() # set while the object is known to be too big
1119
+ measuring = threading.Lock() # so a stale measure cannot clear a newer one's flag
1120
+
1121
+ def measure() -> bool:
1122
+ """Whether the object is over its limit, recorded in ``over_limit``."""
1123
+ with measuring:
1124
+ too_big = is_too_big()
1125
+ if too_big:
1126
+ over_limit.set()
1127
+ else:
1128
+ over_limit.clear()
1129
+ return too_big
1130
+
1131
+ def refuse():
1132
+ allowed = f" (except {', '.join(sorted(shrinkers))})" if shrinkers else ""
1133
+ raise InputError(
1134
+ f"{name} is over its size limit ({max_obj_size} bytes): calls{allowed} "
1135
+ "are refused until it is back under"
1136
+ )
1137
+
1138
+ def func(_attr_name=None, **kwargs):
1139
+ owner()
1140
+ shrinking = isinstance(_attr_name, str) and _attr_name in shrinkers
1141
+ if over_limit.is_set() and measure() and not shrinking:
1142
+ refuse()
1143
+ try:
1144
+ return _get_attr_value(obj, _attr_name, is_dispatchable, **kwargs)
1145
+ finally: # a call that grows the object and then raises is measured too
1146
+ # a shrinker that ran but left it over the limit keeps its result
1147
+ if measure() and not shrinking:
1148
+ refuse()
1149
+
1150
+ return func
1151
+
1152
+
971
1153
  def _class_endpoint_dispatcher(cls, handler, name, is_dispatchable):
972
1154
  """The function behind a class endpoint: called without ``_obj_id`` it creates an
973
1155
  object and returns its id; with one, it dispatches ``_attr_name`` on that object.
@@ -1019,10 +1201,7 @@ def _class_endpoint_dispatcher(cls, handler, name, is_dispatchable):
1019
1201
  return _SHARED_OWNER
1020
1202
 
1021
1203
  else:
1022
- if not callable(owner_of):
1023
- raise TypeError(
1024
- f"obj_owner must be callable or shared_objects: {owner_of!r}"
1025
- )
1204
+ owner = _request_owner(owner_of, name)
1026
1205
  if handler.get("obj_store") is not None:
1027
1206
  raise ValueError(
1028
1207
  "obj_store applies to obj_owner=shared_objects only; with an obj_owner "
@@ -1033,18 +1212,6 @@ def _class_endpoint_dispatcher(cls, handler, name, is_dispatchable):
1033
1212
  max_owners=handler.get("max_owners", DFLT_MAX_OWNERS),
1034
1213
  )
1035
1214
 
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
1215
  def over_budget(who, obj_id, obj):
1049
1216
  """Why the object must go, or None. Records its size when it may stay."""
1050
1217
  limit = max_obj_size
@@ -28,9 +28,7 @@ class Counter:
28
28
 
29
29
 
30
30
  def _dispatcher(endpoint, attr_names, **handler_extras):
31
- if inspect.isclass(
32
- endpoint
33
- ): # class endpoints must say who owns their objects (#22)
31
+ if not inspect.ismodule(endpoint): # objects must say who may use them (#22, #30)
34
32
  handler_extras.setdefault("obj_owner", shared_objects)
35
33
  return _get_func_to_dispatch_handler(
36
34
  dict(endpoint=endpoint, name="ep", attr_names=attr_names, **handler_extras)
@@ -0,0 +1,240 @@
1
+ """Instance endpoints (one object every client shares) need an owner hook and are
2
+ bounded in size (#30)."""
3
+
4
+ import io
5
+ import json
6
+ from wsgiref.util import setup_testing_defaults
7
+
8
+ import pytest
9
+ from i2.errors import AuthorizationError, InputError
10
+
11
+ from py2http import mk_app
12
+ from py2http.service import (
13
+ _get_func_to_dispatch_handler,
14
+ current_request,
15
+ shared_objects,
16
+ )
17
+
18
+
19
+ class Store(dict):
20
+ """A mapping clients may write to."""
21
+
22
+ def write(self, key, value):
23
+ self[key] = value
24
+ return len(self)
25
+
26
+ def read(self, key):
27
+ return self[key]
28
+
29
+ def remove(self, key):
30
+ del self[key]
31
+
32
+ def write_then_fail(self, key, value):
33
+ self[key] = value
34
+ raise ValueError("invalid")
35
+
36
+
37
+ def _instance(endpoint=None, **extras):
38
+ extras.setdefault("obj_owner", lambda req: req) # the "request" is the owner here
39
+ extras.setdefault("attr_names", "*")
40
+ endpoint = Store() if endpoint is None else endpoint
41
+ return _get_func_to_dispatch_handler(
42
+ dict(endpoint=endpoint, name="store", **extras)
43
+ )
44
+
45
+
46
+ def _as(owner, func, **kwargs):
47
+ token = current_request.set(owner)
48
+ try:
49
+ return func(**kwargs)
50
+ finally:
51
+ current_request.reset(token)
52
+
53
+
54
+ def test_instance_endpoint_without_owner_is_refused_at_build():
55
+ with pytest.raises(ValueError, match="obj_owner"):
56
+ _get_func_to_dispatch_handler(dict(endpoint=Store(), name="s", attr_names="*"))
57
+
58
+
59
+ def test_bad_owner_hook_is_refused_at_build():
60
+ with pytest.raises(TypeError, match="obj_owner"):
61
+ _instance(obj_owner="ann")
62
+
63
+
64
+ def test_module_endpoint_needs_no_owner():
65
+ import os.path as module
66
+
67
+ func = _get_func_to_dispatch_handler(
68
+ dict(endpoint=module, name="m", attr_names=["basename"])
69
+ )
70
+ assert func(_attr_name="basename", p="a/b") == "b"
71
+
72
+
73
+ @pytest.mark.parametrize("who", [None, ""])
74
+ def test_unidentified_request_is_refused(who):
75
+ store = Store()
76
+ func = _instance(store)
77
+ with pytest.raises(AuthorizationError):
78
+ _as(who, func, _attr_name="write", key="k", value=1)
79
+ assert store == {}
80
+
81
+
82
+ def test_identified_requests_share_the_object():
83
+ func = _instance()
84
+ assert _as("ann", func, _attr_name="write", key="k", value=1) == 1
85
+ assert _as("bob", func, _attr_name="read", key="k") == 1
86
+
87
+
88
+ def test_shared_objects_lets_anyone_in():
89
+ func = _instance(obj_owner=shared_objects)
90
+ assert _as(None, func, _attr_name="write", key="k", value=1) == 1
91
+
92
+
93
+ def test_object_already_too_big_is_refused_at_build():
94
+ with pytest.raises(ValueError, match="max_obj_size"):
95
+ _instance(Store(big="y" * 20_000), max_obj_size=10_000)
96
+ _instance(Store(big="y" * 20_000), max_obj_size=None) # check disabled
97
+
98
+
99
+ def test_growing_past_the_limit_refuses_until_back_under():
100
+ store = Store()
101
+ func = _instance(store, max_obj_size=10_000)
102
+ _as("ann", func, _attr_name="write", key="a", value="small")
103
+ with pytest.raises(InputError, match="size limit"):
104
+ _as("ann", func, _attr_name="write", key="big", value="y" * 20_000)
105
+ assert "big" in store # the object is kept, not dropped: it is the endpoint
106
+ with pytest.raises(
107
+ InputError, match="size limit"
108
+ ): # even a read, the object is full
109
+ _as("bob", func, _attr_name="read", key="a")
110
+ del store["big"] # shrunk outside the service
111
+ assert _as("bob", func, _attr_name="read", key="a") == "small"
112
+
113
+
114
+ def test_a_call_that_grows_the_object_then_raises_is_still_measured():
115
+ store = Store()
116
+ func = _instance(store, max_obj_size=10_000)
117
+ with pytest.raises(InputError, match="size limit"):
118
+ _as("ann", func, _attr_name="write_then_fail", key="big", value="y" * 20_000)
119
+ with pytest.raises(InputError, match="size limit"):
120
+ _as("ann", func, _attr_name="write", key="more", value="y" * 20_000)
121
+ assert "more" not in store # refused before running
122
+
123
+
124
+ def test_custom_sizeof():
125
+ func = _instance(max_obj_size=2, obj_sizeof=lambda obj, limit: len(obj))
126
+ _as("ann", func, _attr_name="write", key="a", value="y" * 20_000)
127
+ _as("ann", func, _attr_name="write", key="b", value=1)
128
+ with pytest.raises(InputError, match="size limit"):
129
+ _as("ann", func, _attr_name="write", key="c", value=1)
130
+
131
+
132
+ def _post(app, path, payload, user):
133
+ body = json.dumps(payload).encode()
134
+ environ = {
135
+ "REQUEST_METHOD": "POST",
136
+ "PATH_INFO": path,
137
+ "CONTENT_TYPE": "application/json",
138
+ "CONTENT_LENGTH": str(len(body)),
139
+ "HTTP_X_USER": user,
140
+ "wsgi.input": io.BytesIO(body),
141
+ }
142
+ setup_testing_defaults(environ)
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_http_statuses():
149
+ store = Store()
150
+ app = mk_app(
151
+ [
152
+ dict(
153
+ endpoint=store,
154
+ name="store",
155
+ attr_names=["write"],
156
+ obj_owner=lambda req: req.headers.get("X-User"),
157
+ max_obj_size=10_000,
158
+ )
159
+ ]
160
+ )
161
+ payload = {"_attr_name": "write", "key": "k", "value": 1}
162
+ status, n = _post(app, "/store", payload, "ann")
163
+ assert status.startswith("200") and n == 1
164
+ status, _ = _post(app, "/store", payload, "")
165
+ assert status.startswith("401")
166
+ payload = {"_attr_name": "write", "key": "big", "value": "y" * 20_000}
167
+ status, _ = _post(app, "/store", payload, "ann")
168
+ assert status.startswith("400")
169
+
170
+
171
+ def test_shrinkers_run_while_over_the_limit():
172
+ store = Store()
173
+ func = _instance(
174
+ store,
175
+ max_obj_size=10_000,
176
+ attr_names=["write", "read", "remove"],
177
+ obj_shrinkers=["remove"],
178
+ )
179
+ for key in ("a", "b"):
180
+ with pytest.raises(InputError) if key == "b" else _no_error():
181
+ _as("ann", func, _attr_name="write", key=key, value="y" * 6_000)
182
+ with pytest.raises(InputError, match="except remove"):
183
+ _as("ann", func, _attr_name="read", key="a")
184
+ assert _as("ann", func, _attr_name="remove", key="b") is None
185
+ assert _as("ann", func, _attr_name="read", key="a") == "y" * 6_000
186
+
187
+
188
+ def _no_error():
189
+ from contextlib import nullcontext
190
+
191
+ return nullcontext()
192
+
193
+
194
+ def test_state_behind_methods_partials_and_closures_is_measured():
195
+ from functools import partial
196
+
197
+ from py2http.service import bounded_deep_sizeof
198
+
199
+ big = [str(i) * 1000 for i in range(100)] # distinct strings, not one constant
200
+
201
+ class Holder:
202
+ pass
203
+
204
+ def closure():
205
+ return big
206
+
207
+ for held in (big.append, partial(print, big), closure):
208
+ holder = Holder()
209
+ holder.held = held
210
+ assert bounded_deep_sizeof(holder) > 100_000, held
211
+
212
+
213
+ def test_shrinkers_are_checked_at_build():
214
+ with pytest.raises(TypeError, match="collection"):
215
+ _instance(attr_names=["remove"], obj_shrinkers="remove")
216
+ with pytest.raises(ValueError, match="not dispatched"):
217
+ _instance(attr_names=["remove"], obj_shrinkers=["remvoe"])
218
+
219
+
220
+ def test_more_held_state_is_measured_and_odd_objects_do_not_break_it():
221
+ from types import MethodType
222
+ from unittest.mock import Mock
223
+
224
+ from py2http.service import bounded_deep_sizeof
225
+
226
+ big = [str(i) * 1000 for i in range(100)]
227
+
228
+ def closure(self):
229
+ return big
230
+
231
+ class Holder:
232
+ pass
233
+
234
+ holder = Holder()
235
+ holder.held = MethodType(closure, object())
236
+ assert bounded_deep_sizeof(holder) > 100_000
237
+ holder.held = staticmethod(lambda: big)
238
+ assert bounded_deep_sizeof(holder) > 100_000
239
+ holder.held = Mock(spec=type(closure))
240
+ assert bounded_deep_sizeof(holder) > 0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: py2http
3
- Version: 0.1.66
3
+ Version: 0.1.67
4
4
  Summary: Declarative HTTP service entry point.
5
5
  Home-page: https://github.com/i2mint/py2http
6
6
  License: MIT
@@ -37,6 +37,7 @@ py2http/tests/test_attr_dispatch.py
37
37
  py2http/tests/test_binary_req.py
38
38
  py2http/tests/test_decorators.py
39
39
  py2http/tests/test_flask.py
40
+ py2http/tests/test_instance_endpoints.py
40
41
  py2http/tests/test_jwt_middleware.py
41
42
  py2http/tests/test_not_set_defaults.py
42
43
  py2http/tests/test_obj_ownership.py
@@ -1,6 +1,6 @@
1
1
  [metadata]
2
2
  name = py2http
3
- version = 0.1.66
3
+ version = 0.1.67
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