py2http 0.1.66__tar.gz → 0.1.68__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.
- {py2http-0.1.66 → py2http-0.1.68}/PKG-INFO +1 -1
- {py2http-0.1.66 → py2http-0.1.68}/py2http/default_configs.py +41 -23
- {py2http-0.1.66 → py2http-0.1.68}/py2http/examples/object_dispatch.py +5 -0
- {py2http-0.1.66 → py2http-0.1.68}/py2http/service.py +202 -35
- {py2http-0.1.66 → py2http-0.1.68}/py2http/tests/test_attr_dispatch.py +1 -3
- py2http-0.1.68/py2http/tests/test_instance_endpoints.py +240 -0
- py2http-0.1.68/py2http/tests/test_positional_only.py +150 -0
- {py2http-0.1.66 → py2http-0.1.68}/py2http/util.py +80 -1
- {py2http-0.1.66 → py2http-0.1.68}/py2http.egg-info/PKG-INFO +1 -1
- {py2http-0.1.66 → py2http-0.1.68}/py2http.egg-info/SOURCES.txt +2 -0
- {py2http-0.1.66 → py2http-0.1.68}/setup.cfg +1 -1
- {py2http-0.1.66 → py2http-0.1.68}/LICENSE +0 -0
- {py2http-0.1.66 → py2http-0.1.68}/README.md +0 -0
- {py2http-0.1.66 → py2http-0.1.68}/py2http/__init__.py +0 -0
- {py2http-0.1.66 → py2http-0.1.68}/py2http/bottle_plugins.py +0 -0
- {py2http-0.1.66 → py2http-0.1.68}/py2http/config.py +0 -0
- {py2http-0.1.66 → py2http-0.1.68}/py2http/constants.py +0 -0
- {py2http-0.1.66 → py2http-0.1.68}/py2http/decorators.py +0 -0
- {py2http-0.1.66 → py2http-0.1.68}/py2http/diagnosis.py +0 -0
- {py2http-0.1.66 → py2http-0.1.68}/py2http/examples/__init__.py +0 -0
- {py2http-0.1.66 → py2http-0.1.68}/py2http/examples/custom_input_mapper.py +0 -0
- {py2http-0.1.66 → py2http-0.1.68}/py2http/examples/custom_input_mappers.py +0 -0
- {py2http-0.1.66 → py2http-0.1.68}/py2http/examples/example_service.py +0 -0
- {py2http-0.1.66 → py2http-0.1.68}/py2http/examples/example_service_bottle.py +0 -0
- {py2http-0.1.66 → py2http-0.1.68}/py2http/examples/example_service_flask.py +0 -0
- {py2http-0.1.66 → py2http-0.1.68}/py2http/middleware.py +0 -0
- {py2http-0.1.66 → py2http-0.1.68}/py2http/openapi_utils.py +0 -0
- {py2http-0.1.66 → py2http-0.1.68}/py2http/route_utils.py +0 -0
- {py2http-0.1.66 → py2http-0.1.68}/py2http/schema_tools.py +0 -0
- {py2http-0.1.66 → py2http-0.1.68}/py2http/tests/__init__.py +0 -0
- {py2http-0.1.66 → py2http-0.1.68}/py2http/tests/example_service_test.py +0 -0
- {py2http-0.1.66 → py2http-0.1.68}/py2http/tests/objects_for_testing.py +0 -0
- {py2http-0.1.66 → py2http-0.1.68}/py2http/tests/simple_run_process_test.py +0 -0
- {py2http-0.1.66 → py2http-0.1.68}/py2http/tests/test_binary_req.py +0 -0
- {py2http-0.1.66 → py2http-0.1.68}/py2http/tests/test_decorators.py +0 -0
- {py2http-0.1.66 → py2http-0.1.68}/py2http/tests/test_flask.py +0 -0
- {py2http-0.1.66 → py2http-0.1.68}/py2http/tests/test_jwt_middleware.py +0 -0
- {py2http-0.1.66 → py2http-0.1.68}/py2http/tests/test_not_set_defaults.py +0 -0
- {py2http-0.1.66 → py2http-0.1.68}/py2http/tests/test_obj_ownership.py +0 -0
- {py2http-0.1.66 → py2http-0.1.68}/py2http/tests/test_p2h2p.py +0 -0
- {py2http-0.1.66 → py2http-0.1.68}/py2http/tests/utils_for_testing.py +0 -0
- {py2http-0.1.66 → py2http-0.1.68}/py2http/types.py +0 -0
- {py2http-0.1.66 → py2http-0.1.68}/py2http.egg-info/dependency_links.txt +0 -0
- {py2http-0.1.66 → py2http-0.1.68}/py2http.egg-info/not-zip-safe +0 -0
- {py2http-0.1.66 → py2http-0.1.68}/py2http.egg-info/requires.txt +0 -0
- {py2http-0.1.66 → py2http-0.1.68}/py2http.egg-info/top_level.txt +0 -0
- {py2http-0.1.66 → py2http-0.1.68}/setup.py +0 -0
|
@@ -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
|
-
|
|
64
|
-
if
|
|
65
|
-
_raise_http_client_error(
|
|
66
|
-
|
|
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
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
|
@@ -56,9 +56,13 @@ from py2http.schema_tools import (
|
|
|
56
56
|
mk_input_schema_from_func,
|
|
57
57
|
mk_output_schema_from_func,
|
|
58
58
|
)
|
|
59
|
-
from py2http.util import TypeAsserter
|
|
59
|
+
from py2http.util import TypeAsserter, call_with_inputs
|
|
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
|
|
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__
|
|
235
|
-
|
|
236
|
-
|
|
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
|
-
|
|
259
|
-
todo.
|
|
260
|
-
|
|
261
|
-
|
|
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
|
|
|
@@ -468,19 +518,25 @@ def mk_route(func, **configs):
|
|
|
468
518
|
try:
|
|
469
519
|
inputs = input_mapper(req)
|
|
470
520
|
input_args, input_kwargs = get_input_args_and_kwargs(inputs)
|
|
471
|
-
raw_result = func
|
|
521
|
+
raw_result = call_with_inputs(func, input_args, input_kwargs)
|
|
472
522
|
return output_mapper(raw_result, **inputs)
|
|
473
523
|
finally:
|
|
474
524
|
current_request.reset(token)
|
|
475
525
|
|
|
476
526
|
@handle_error
|
|
477
527
|
async def aiohttp_handle_request(req):
|
|
478
|
-
current_request.set(req)
|
|
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
|
|
482
538
|
input_args, input_kwargs = get_input_args_and_kwargs(inputs)
|
|
483
|
-
raw_result = func
|
|
539
|
+
raw_result = call_with_inputs(func, input_args, input_kwargs)
|
|
484
540
|
if isawaitable(raw_result): # Pattern: pass-on async property
|
|
485
541
|
raw_result = await raw_result
|
|
486
542
|
final_result = output_mapper(raw_result, **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
|
-
|
|
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
|
-
|
|
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
|
|
@@ -1070,7 +1237,7 @@ def _class_endpoint_dispatcher(cls, handler, name, is_dispatchable):
|
|
|
1070
1237
|
if _obj_id is None:
|
|
1071
1238
|
if _attr_name is not None:
|
|
1072
1239
|
raise InputError("_attr_name must be None when _obj_id is None")
|
|
1073
|
-
new_obj = cls
|
|
1240
|
+
new_obj = call_with_inputs(cls, kwargs=kwargs)
|
|
1074
1241
|
new_id = str(uuid4())
|
|
1075
1242
|
store.put(who, new_id, new_obj)
|
|
1076
1243
|
check_size(who, new_id, new_obj)
|
|
@@ -1095,5 +1262,5 @@ def _get_attr_value(obj, attr_name, is_dispatchable, /, **kwargs):
|
|
|
1095
1262
|
)
|
|
1096
1263
|
attr = getattr(obj, attr_name)
|
|
1097
1264
|
if callable(attr):
|
|
1098
|
-
return attr
|
|
1265
|
+
return call_with_inputs(attr, kwargs=kwargs)
|
|
1099
1266
|
return attr
|
|
@@ -28,9 +28,7 @@ class Counter:
|
|
|
28
28
|
|
|
29
29
|
|
|
30
30
|
def _dispatcher(endpoint, attr_names, **handler_extras):
|
|
31
|
-
if inspect.
|
|
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
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"""Positional-only parameters (``dict.__getitem__``, ``def f(x, /)``) can be called over
|
|
2
|
+
JSON, which supplies arguments by name (i2mint/py2http#13, #14)."""
|
|
3
|
+
|
|
4
|
+
import io
|
|
5
|
+
import json
|
|
6
|
+
from wsgiref.util import setup_testing_defaults
|
|
7
|
+
|
|
8
|
+
import pytest
|
|
9
|
+
from i2.errors import InputError
|
|
10
|
+
|
|
11
|
+
from py2http import mk_app
|
|
12
|
+
from py2http.util import call_with_inputs
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def test_call_with_inputs_passes_positional_only_by_name():
|
|
16
|
+
assert call_with_inputs(lambda x, /, y: (x, y), kwargs={"y": 2, "x": 1}) == (1, 2)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def test_call_with_inputs_leaves_ordinary_calls_alone():
|
|
20
|
+
assert call_with_inputs(lambda x, y=0: (x, y), (1,), {"y": 2}) == (1, 2)
|
|
21
|
+
assert call_with_inputs(lambda *a, **k: (a, k), (1,), {"z": 2}) == ((1,), {"z": 2})
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def test_explicit_positional_args_come_first():
|
|
25
|
+
assert call_with_inputs(lambda x, y, /: (x, y), (1,), {"y": 2}) == (1, 2)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def test_optional_positional_only():
|
|
29
|
+
def f(a, b=5, c=6, /):
|
|
30
|
+
return a, b, c
|
|
31
|
+
|
|
32
|
+
assert call_with_inputs(f, kwargs={"a": 1}) == (1, 5, 6)
|
|
33
|
+
assert call_with_inputs(f, kwargs={"a": 1, "c": 9}) == (1, 5, 9)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_missing_required_positional_only_is_an_input_error():
|
|
37
|
+
with pytest.raises(InputError, match="'y'"):
|
|
38
|
+
call_with_inputs(lambda x, y, /: None, kwargs={"x": 1})
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def test_callable_without_a_signature_gets_kwargs_as_before(monkeypatch):
|
|
42
|
+
import py2http.util
|
|
43
|
+
|
|
44
|
+
def no_signature(func):
|
|
45
|
+
raise ValueError("no signature found")
|
|
46
|
+
|
|
47
|
+
monkeypatch.setattr(py2http.util, "signature", no_signature)
|
|
48
|
+
assert call_with_inputs(lambda **kw: kw, kwargs={"x": 1}) == {"x": 1}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def test_container_methods_without_a_readable_signature(monkeypatch):
|
|
52
|
+
"""Python 3.10 can't read ``dict.__getitem__``'s or ``dict.pop``'s signature."""
|
|
53
|
+
import py2http.util
|
|
54
|
+
|
|
55
|
+
def no_signature(func):
|
|
56
|
+
raise ValueError("no signature found")
|
|
57
|
+
|
|
58
|
+
monkeypatch.setattr(py2http.util, "signature", no_signature)
|
|
59
|
+
d = {"a": 1, "b": 2}
|
|
60
|
+
assert call_with_inputs(d.__getitem__, kwargs={"key": "a"}) == 1
|
|
61
|
+
assert call_with_inputs([5, 6].__getitem__, kwargs={"index": 1}) == 6
|
|
62
|
+
assert call_with_inputs(d.pop, kwargs={"key": "a"}) == 1
|
|
63
|
+
assert call_with_inputs(d.pop, kwargs={"key": "zz", "default": 0}) == 0
|
|
64
|
+
assert d == {"b": 2}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _post(app, path, payload):
|
|
68
|
+
body = json.dumps(payload).encode()
|
|
69
|
+
environ = {
|
|
70
|
+
"REQUEST_METHOD": "POST",
|
|
71
|
+
"PATH_INFO": path,
|
|
72
|
+
"CONTENT_TYPE": "application/json",
|
|
73
|
+
"CONTENT_LENGTH": str(len(body)),
|
|
74
|
+
"wsgi.input": io.BytesIO(body),
|
|
75
|
+
}
|
|
76
|
+
setup_testing_defaults(environ)
|
|
77
|
+
statuses = []
|
|
78
|
+
out = b"".join(app(environ, lambda status, headers, *a: statuses.append(status)))
|
|
79
|
+
return statuses[0], json.loads(out)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def test_dict_dunders_over_http():
|
|
83
|
+
store = {"some_key": "some_value"}
|
|
84
|
+
app = mk_app(
|
|
85
|
+
[
|
|
86
|
+
dict(
|
|
87
|
+
endpoint=store,
|
|
88
|
+
name="backend_store",
|
|
89
|
+
attr_names=["__getitem__", "__setitem__"],
|
|
90
|
+
obj_owner=lambda req: "anyone",
|
|
91
|
+
)
|
|
92
|
+
]
|
|
93
|
+
)
|
|
94
|
+
status, value = _post(
|
|
95
|
+
app, "/backend_store", {"_attr_name": "__getitem__", "key": "some_key"}
|
|
96
|
+
)
|
|
97
|
+
assert status.startswith("200") and value == "some_value"
|
|
98
|
+
payload = {"_attr_name": "__setitem__", "key": "k", "value": 1}
|
|
99
|
+
assert _post(app, "/backend_store", payload)[0].startswith("200")
|
|
100
|
+
assert store["k"] == 1
|
|
101
|
+
status, _ = _post(app, "/backend_store", {"_attr_name": "__getitem__"})
|
|
102
|
+
assert status.startswith("400") # missing 'key': a client error
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def test_plain_function_with_positional_only_over_http():
|
|
106
|
+
def add(x, y, /):
|
|
107
|
+
return x + y
|
|
108
|
+
|
|
109
|
+
app = mk_app([add])
|
|
110
|
+
assert _post(app, "/add", {"x": 1, "y": 2}) == ("200 OK", 3)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def test_optional_positional_only_stays_a_keyword_when_there_is_var_keyword():
|
|
114
|
+
# Counter.update(iterable=None, /, **kwds): ``iterable`` is a keyword here
|
|
115
|
+
from collections import Counter, UserDict
|
|
116
|
+
|
|
117
|
+
assert call_with_inputs(lambda a=0, /, **kw: (a, kw), kwargs={"a": 1}) == (
|
|
118
|
+
0,
|
|
119
|
+
{"a": 1},
|
|
120
|
+
)
|
|
121
|
+
c = Counter()
|
|
122
|
+
call_with_inputs(c.update, kwargs={"iterable": 3})
|
|
123
|
+
assert c == Counter(iterable=3)
|
|
124
|
+
d = UserDict()
|
|
125
|
+
call_with_inputs(d.update, kwargs={"other": 1})
|
|
126
|
+
assert dict(d) == {"other": 1}
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def test_required_positional_only_is_bound_even_with_var_keyword():
|
|
130
|
+
assert call_with_inputs(lambda a, /, **kw: (a, kw), kwargs={"a": 1, "b": 2}) == (
|
|
131
|
+
1,
|
|
132
|
+
{"b": 2},
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def test_class_endpoint_with_positional_only_constructor():
|
|
137
|
+
from py2http.service import _get_func_to_dispatch_handler, shared_objects
|
|
138
|
+
|
|
139
|
+
class Box:
|
|
140
|
+
def __init__(self, content, /):
|
|
141
|
+
self.content = content
|
|
142
|
+
|
|
143
|
+
def get(self):
|
|
144
|
+
return self.content
|
|
145
|
+
|
|
146
|
+
func = _get_func_to_dispatch_handler(
|
|
147
|
+
dict(endpoint=Box, name="box", attr_names=["get"], obj_owner=shared_objects)
|
|
148
|
+
)
|
|
149
|
+
obj_id = func(content=7)
|
|
150
|
+
assert func(_obj_id=obj_id, _attr_name="get") == 7
|
|
@@ -12,7 +12,8 @@ Overall, this module provides a collection of useful tools for handling lazy pro
|
|
|
12
12
|
"""
|
|
13
13
|
|
|
14
14
|
from typing import Optional, Callable, Union, Iterable, Any
|
|
15
|
-
from
|
|
15
|
+
from collections.abc import MutableMapping, Sequence
|
|
16
|
+
from inspect import Parameter, Signature, signature
|
|
16
17
|
from multiprocessing.context import Process
|
|
17
18
|
from multiprocessing import Queue, active_children
|
|
18
19
|
from time import sleep, time
|
|
@@ -22,6 +23,7 @@ from contextlib import contextmanager
|
|
|
22
23
|
from strand import run_process
|
|
23
24
|
|
|
24
25
|
from glom import Spec # NOTE: Third-party
|
|
26
|
+
from i2.errors import InputError
|
|
25
27
|
|
|
26
28
|
|
|
27
29
|
class lazyprop:
|
|
@@ -488,3 +490,80 @@ def obj_to_path(obj):
|
|
|
488
490
|
return obj.__globals__[root_name], tuple(attr_path)
|
|
489
491
|
else:
|
|
490
492
|
return obj
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
_MISSING = object()
|
|
496
|
+
_REQUIRED = object()
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
def _positional_only_sig(*names: str, optional: Iterable[str] = ()) -> Signature:
|
|
500
|
+
return Signature(
|
|
501
|
+
Parameter(
|
|
502
|
+
name,
|
|
503
|
+
Parameter.POSITIONAL_ONLY,
|
|
504
|
+
default=None if name in optional else Parameter.empty,
|
|
505
|
+
)
|
|
506
|
+
for name in names
|
|
507
|
+
)
|
|
508
|
+
|
|
509
|
+
|
|
510
|
+
def _signature_or_none(func: Callable) -> Optional[Signature]:
|
|
511
|
+
"""The signature of ``func``; for the few built-in container methods the Python
|
|
512
|
+
versions we support (3.10) can't read one, the one they have in later versions."""
|
|
513
|
+
try:
|
|
514
|
+
return signature(func)
|
|
515
|
+
except (ValueError, TypeError):
|
|
516
|
+
pass
|
|
517
|
+
owner = getattr(func, "__self__", None)
|
|
518
|
+
if getattr(func, "__name__", None) == "__getitem__":
|
|
519
|
+
return _positional_only_sig("index" if isinstance(owner, Sequence) else "key")
|
|
520
|
+
if getattr(func, "__name__", None) == "pop" and isinstance(owner, MutableMapping):
|
|
521
|
+
return _positional_only_sig("key", "default", optional=["default"])
|
|
522
|
+
return None
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
def call_with_inputs(func: Callable, args: Iterable = (), kwargs: dict = None):
|
|
526
|
+
"""Call ``func(*args, **kwargs)``, passing the entries of ``kwargs`` that name
|
|
527
|
+
positional-only parameters of ``func`` positionally, which JSON (a mapping) can
|
|
528
|
+
only supply by name (i2mint/py2http#14).
|
|
529
|
+
|
|
530
|
+
>>> call_with_inputs({"a": 1}.__getitem__, kwargs={"key": "a"})
|
|
531
|
+
1
|
|
532
|
+
>>> d = {}
|
|
533
|
+
>>> call_with_inputs(d.__setitem__, kwargs={"key": "k", "value": 2}), d
|
|
534
|
+
(None, {'k': 2})
|
|
535
|
+
>>> call_with_inputs(lambda x, /, y=0: (x, y), kwargs={"x": 1, "y": 2})
|
|
536
|
+
(1, 2)
|
|
537
|
+
|
|
538
|
+
A positional-only argument that is neither required to be given nor given takes its
|
|
539
|
+
default; a missing required one is an ``InputError`` (a client error), not a
|
|
540
|
+
``TypeError``. A callable whose signature can't be read gets ``**kwargs`` as is.
|
|
541
|
+
|
|
542
|
+
Python lets a function with ``**kwargs`` receive a keyword that is also the name of
|
|
543
|
+
a positional-only parameter (``Counter.update(iterable=3)``): such an optional
|
|
544
|
+
parameter is left in ``kwargs``; only required ones are bound by name.
|
|
545
|
+
"""
|
|
546
|
+
args, kwargs = list(args), dict(kwargs or {})
|
|
547
|
+
sig = _signature_or_none(func)
|
|
548
|
+
if sig is None:
|
|
549
|
+
return func(*args, **kwargs)
|
|
550
|
+
params = list(sig.parameters.values())
|
|
551
|
+
takes_any_keyword = any(p.kind is Parameter.VAR_KEYWORD for p in params)
|
|
552
|
+
values = []
|
|
553
|
+
for param in params[len(args) :]:
|
|
554
|
+
if param.kind is not Parameter.POSITIONAL_ONLY:
|
|
555
|
+
break
|
|
556
|
+
required = param.default is param.empty
|
|
557
|
+
if param.name in kwargs and (required or not takes_any_keyword):
|
|
558
|
+
values.append(kwargs.pop(param.name))
|
|
559
|
+
else:
|
|
560
|
+
values.append(_REQUIRED if required else _MISSING)
|
|
561
|
+
while values and values[-1] is _MISSING: # trailing optionals: leave to the callee
|
|
562
|
+
values.pop()
|
|
563
|
+
for i, value in enumerate(values):
|
|
564
|
+
param = params[len(args) + i]
|
|
565
|
+
if value is _REQUIRED:
|
|
566
|
+
raise InputError(f"Missing argument: {param.name!r}")
|
|
567
|
+
if value is _MISSING: # an optional one before a given one: its default
|
|
568
|
+
values[i] = param.default
|
|
569
|
+
return func(*args, *values, **kwargs)
|
|
@@ -37,8 +37,10 @@ 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
|
|
43
44
|
py2http/tests/test_p2h2p.py
|
|
45
|
+
py2http/tests/test_positional_only.py
|
|
44
46
|
py2http/tests/utils_for_testing.py
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|