py2http 0.1.69__tar.gz → 0.1.70__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of py2http might be problematic. Click here for more details.
- {py2http-0.1.69 → py2http-0.1.70}/PKG-INFO +1 -1
- {py2http-0.1.69 → py2http-0.1.70}/py2http/bottle_plugins.py +4 -3
- {py2http-0.1.69 → py2http-0.1.70}/py2http/constants.py +8 -0
- {py2http-0.1.69 → py2http-0.1.70}/py2http/default_configs.py +44 -3
- {py2http-0.1.69 → py2http-0.1.70}/py2http/middleware.py +6 -4
- {py2http-0.1.69 → py2http-0.1.70}/py2http/obj_dispatch.py +129 -21
- py2http-0.1.70/py2http/tests/test_obj_measuring.py +263 -0
- {py2http-0.1.69 → py2http-0.1.70}/py2http/tests/utils_for_testing.py +17 -6
- {py2http-0.1.69 → py2http-0.1.70}/py2http.egg-info/PKG-INFO +1 -1
- {py2http-0.1.69 → py2http-0.1.70}/py2http.egg-info/SOURCES.txt +1 -0
- {py2http-0.1.69 → py2http-0.1.70}/setup.cfg +1 -1
- {py2http-0.1.69 → py2http-0.1.70}/LICENSE +0 -0
- {py2http-0.1.69 → py2http-0.1.70}/README.md +0 -0
- {py2http-0.1.69 → py2http-0.1.70}/py2http/__init__.py +0 -0
- {py2http-0.1.69 → py2http-0.1.70}/py2http/auth.py +0 -0
- {py2http-0.1.69 → py2http-0.1.70}/py2http/config.py +0 -0
- {py2http-0.1.69 → py2http-0.1.70}/py2http/decorators.py +0 -0
- {py2http-0.1.69 → py2http-0.1.70}/py2http/diagnosis.py +0 -0
- {py2http-0.1.69 → py2http-0.1.70}/py2http/examples/__init__.py +0 -0
- {py2http-0.1.69 → py2http-0.1.70}/py2http/examples/custom_input_mapper.py +0 -0
- {py2http-0.1.69 → py2http-0.1.70}/py2http/examples/custom_input_mappers.py +0 -0
- {py2http-0.1.69 → py2http-0.1.70}/py2http/examples/example_service.py +0 -0
- {py2http-0.1.69 → py2http-0.1.70}/py2http/examples/example_service_bottle.py +0 -0
- {py2http-0.1.69 → py2http-0.1.70}/py2http/examples/example_service_flask.py +0 -0
- {py2http-0.1.69 → py2http-0.1.70}/py2http/examples/object_dispatch.py +0 -0
- {py2http-0.1.69 → py2http-0.1.70}/py2http/openapi_utils.py +0 -0
- {py2http-0.1.69 → py2http-0.1.70}/py2http/route_utils.py +0 -0
- {py2http-0.1.69 → py2http-0.1.70}/py2http/schema_tools.py +0 -0
- {py2http-0.1.69 → py2http-0.1.70}/py2http/service.py +0 -0
- {py2http-0.1.69 → py2http-0.1.70}/py2http/sizeof.py +0 -0
- {py2http-0.1.69 → py2http-0.1.70}/py2http/tests/__init__.py +0 -0
- {py2http-0.1.69 → py2http-0.1.70}/py2http/tests/example_service_test.py +0 -0
- {py2http-0.1.69 → py2http-0.1.70}/py2http/tests/objects_for_testing.py +0 -0
- {py2http-0.1.69 → py2http-0.1.70}/py2http/tests/simple_run_process_test.py +0 -0
- {py2http-0.1.69 → py2http-0.1.70}/py2http/tests/test_attr_dispatch.py +0 -0
- {py2http-0.1.69 → py2http-0.1.70}/py2http/tests/test_auth_plugins.py +0 -0
- {py2http-0.1.69 → py2http-0.1.70}/py2http/tests/test_binary_req.py +0 -0
- {py2http-0.1.69 → py2http-0.1.70}/py2http/tests/test_decorators.py +0 -0
- {py2http-0.1.69 → py2http-0.1.70}/py2http/tests/test_error_statuses.py +0 -0
- {py2http-0.1.69 → py2http-0.1.70}/py2http/tests/test_flask.py +0 -0
- {py2http-0.1.69 → py2http-0.1.70}/py2http/tests/test_instance_endpoints.py +0 -0
- {py2http-0.1.69 → py2http-0.1.70}/py2http/tests/test_jwt_middleware.py +0 -0
- {py2http-0.1.69 → py2http-0.1.70}/py2http/tests/test_not_set_defaults.py +0 -0
- {py2http-0.1.69 → py2http-0.1.70}/py2http/tests/test_obj_ownership.py +0 -0
- {py2http-0.1.69 → py2http-0.1.70}/py2http/tests/test_p2h2p.py +0 -0
- {py2http-0.1.69 → py2http-0.1.70}/py2http/tests/test_positional_only.py +0 -0
- {py2http-0.1.69 → py2http-0.1.70}/py2http/types.py +0 -0
- {py2http-0.1.69 → py2http-0.1.70}/py2http/util.py +0 -0
- {py2http-0.1.69 → py2http-0.1.70}/py2http.egg-info/dependency_links.txt +0 -0
- {py2http-0.1.69 → py2http-0.1.70}/py2http.egg-info/not-zip-safe +0 -0
- {py2http-0.1.69 → py2http-0.1.70}/py2http.egg-info/requires.txt +0 -0
- {py2http-0.1.69 → py2http-0.1.70}/py2http.egg-info/top_level.txt +0 -0
- {py2http-0.1.69 → py2http-0.1.70}/setup.py +0 -0
|
@@ -17,7 +17,7 @@ from py2http.auth import (
|
|
|
17
17
|
key_matches,
|
|
18
18
|
required_key,
|
|
19
19
|
)
|
|
20
|
-
from py2http.constants import JSON_CONTENT_TYPE
|
|
20
|
+
from py2http.constants import API_KEY_CHALLENGE, BEARER_CHALLENGE, JSON_CONTENT_TYPE
|
|
21
21
|
|
|
22
22
|
OPTIONS = "OPTIONS"
|
|
23
23
|
|
|
@@ -31,8 +31,9 @@ def _authorization_header() -> str:
|
|
|
31
31
|
return ""
|
|
32
32
|
|
|
33
33
|
|
|
34
|
-
def _unauthorized(message):
|
|
34
|
+
def _unauthorized(message, challenge=BEARER_CHALLENGE):
|
|
35
35
|
response.status = 401
|
|
36
|
+
response.set_header("WWW-Authenticate", challenge)
|
|
36
37
|
response.content_type = JSON_CONTENT_TYPE
|
|
37
38
|
return json.dumps({"error": message})
|
|
38
39
|
|
|
@@ -126,7 +127,7 @@ class ApiKeyAuthPlugin:
|
|
|
126
127
|
def wrapped_handler(*args, **kwargs):
|
|
127
128
|
if key_matches(_authorization_header(), self._api_key):
|
|
128
129
|
return handler(*args, **kwargs)
|
|
129
|
-
return _unauthorized("invalid API key")
|
|
130
|
+
return _unauthorized("invalid API key", API_KEY_CHALLENGE)
|
|
130
131
|
|
|
131
132
|
return wrapped_handler
|
|
132
133
|
|
|
@@ -5,3 +5,11 @@ BINARY_CONTENT_TYPE = "application/octet-stream"
|
|
|
5
5
|
FORM_CONTENT_TYPE = "multipart/form-data"
|
|
6
6
|
RAW_CONTENT_TYPE = "text/plain"
|
|
7
7
|
HTML_CONTENT_TYPE = "text/html"
|
|
8
|
+
|
|
9
|
+
# ``WWW-Authenticate`` challenges sent with every 401 (RFC 7235 requires one): a JWT is
|
|
10
|
+
# sent as ``Authorization: Bearer <token>``; an API key as the bare header value.
|
|
11
|
+
BEARER_CHALLENGE = "Bearer"
|
|
12
|
+
API_KEY_CHALLENGE = "ApiKey"
|
|
13
|
+
#: Environment variable naming the challenge for a 401 a dispatched function raised
|
|
14
|
+
#: (``AuthorizationError``); the deployment's auth decides it. Default: Bearer.
|
|
15
|
+
WWW_AUTHENTICATE_ENV_VAR = "PY2HTTP_WWW_AUTHENTICATE"
|
|
@@ -15,6 +15,7 @@ up an HTTP service in Python using the py2http library."""
|
|
|
15
15
|
from aiohttp import web
|
|
16
16
|
from bottle import response
|
|
17
17
|
import json
|
|
18
|
+
import logging
|
|
18
19
|
import os
|
|
19
20
|
|
|
20
21
|
from i2.errors import (
|
|
@@ -28,7 +29,13 @@ from i2.errors import (
|
|
|
28
29
|
|
|
29
30
|
from py2http.decorators import handle_json_req, send_json_resp, JsonRespEncoder
|
|
30
31
|
from py2http.config import AIOHTTP, BOTTLE, FLASK
|
|
31
|
-
from py2http.constants import
|
|
32
|
+
from py2http.constants import (
|
|
33
|
+
BEARER_CHALLENGE,
|
|
34
|
+
JSON_CONTENT_TYPE,
|
|
35
|
+
WWW_AUTHENTICATE_ENV_VAR,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
logger = logging.getLogger(__name__)
|
|
32
39
|
|
|
33
40
|
DFLT_CONTENT_TYPE = JSON_CONTENT_TYPE
|
|
34
41
|
|
|
@@ -52,14 +59,41 @@ def bottle_output_mapper(output, **inputs):
|
|
|
52
59
|
return json.dumps(output, cls=JsonRespEncoder)
|
|
53
60
|
|
|
54
61
|
|
|
55
|
-
def _raise_http_client_error(error, message, reason=None):
|
|
62
|
+
def _raise_http_client_error(error, message, reason=None, headers=None):
|
|
56
63
|
raise error(
|
|
57
64
|
text=json.dumps({"error": message}),
|
|
58
65
|
content_type=JSON_CONTENT_TYPE,
|
|
59
66
|
reason=reason,
|
|
67
|
+
headers=headers,
|
|
60
68
|
)
|
|
61
69
|
|
|
62
70
|
|
|
71
|
+
def www_authenticate_challenge() -> str:
|
|
72
|
+
"""The ``WWW-Authenticate`` value of a 401 a dispatched function raised: the
|
|
73
|
+
``PY2HTTP_WWW_AUTHENTICATE`` environment variable, else ``Bearer`` (also when the
|
|
74
|
+
variable holds a line break, which would make every 401 fail to send).
|
|
75
|
+
|
|
76
|
+
>>> os.environ.pop(WWW_AUTHENTICATE_ENV_VAR, None) and None
|
|
77
|
+
>>> www_authenticate_challenge()
|
|
78
|
+
'Bearer'
|
|
79
|
+
"""
|
|
80
|
+
challenge = os.getenv(WWW_AUTHENTICATE_ENV_VAR)
|
|
81
|
+
if not challenge:
|
|
82
|
+
return BEARER_CHALLENGE
|
|
83
|
+
if any(c in challenge for c in "\r\n\0"):
|
|
84
|
+
logger.warning(
|
|
85
|
+
"%s holds a control character; sending %r instead",
|
|
86
|
+
WWW_AUTHENTICATE_ENV_VAR,
|
|
87
|
+
BEARER_CHALLENGE,
|
|
88
|
+
)
|
|
89
|
+
return BEARER_CHALLENGE
|
|
90
|
+
return challenge
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _status_headers(status: int) -> dict:
|
|
94
|
+
return {"WWW-Authenticate": www_authenticate_challenge()} if status == 401 else {}
|
|
95
|
+
|
|
96
|
+
|
|
63
97
|
# HTTP status of each client error, most specific class first. ``ForbiddenError`` and
|
|
64
98
|
# ``OverwritesNotAllowed`` mean the client may not do this (403); any other
|
|
65
99
|
# ``AuthorizationError`` means the request did not say who it is (401).
|
|
@@ -96,13 +130,20 @@ def aiohttp_error_handler(error: Exception):
|
|
|
96
130
|
if status == 500:
|
|
97
131
|
_raise_http_client_error(web.HTTPInternalServerError, "Internal server error")
|
|
98
132
|
reason = type(error).__name__ if status == 400 else None
|
|
99
|
-
_raise_http_client_error(
|
|
133
|
+
_raise_http_client_error(
|
|
134
|
+
_AIOHTTP_ERRORS[status],
|
|
135
|
+
str(error),
|
|
136
|
+
reason=reason,
|
|
137
|
+
headers=_status_headers(status) or None,
|
|
138
|
+
)
|
|
100
139
|
|
|
101
140
|
|
|
102
141
|
def bottle_error_handler(error: Exception):
|
|
103
142
|
message = str(error)
|
|
104
143
|
status = error_status(error)
|
|
105
144
|
response.status = f"400 {type(error).__name__}" if status == 400 else status
|
|
145
|
+
for header, value in _status_headers(status).items():
|
|
146
|
+
response.set_header(header, value)
|
|
106
147
|
if status == 500 and os.getenv("OPAQUE_ERRORS", None):
|
|
107
148
|
message = "Internal server error"
|
|
108
149
|
return {"error": message}
|
|
@@ -19,16 +19,18 @@ from py2http.auth import (
|
|
|
19
19
|
key_matches,
|
|
20
20
|
required_key,
|
|
21
21
|
)
|
|
22
|
-
from py2http.constants import JSON_CONTENT_TYPE
|
|
22
|
+
from py2http.constants import API_KEY_CHALLENGE, BEARER_CHALLENGE, JSON_CONTENT_TYPE
|
|
23
23
|
|
|
24
24
|
DFLT_PUBLIC_ROUTES = ("ping", "openapi")
|
|
25
25
|
|
|
26
26
|
|
|
27
|
-
def _unauthorized(message):
|
|
27
|
+
def _unauthorized(message, challenge=BEARER_CHALLENGE):
|
|
28
28
|
from aiohttp import web
|
|
29
29
|
|
|
30
30
|
return web.HTTPUnauthorized(
|
|
31
|
-
text=json.dumps({"error": message}),
|
|
31
|
+
text=json.dumps({"error": message}),
|
|
32
|
+
content_type=JSON_CONTENT_TYPE,
|
|
33
|
+
headers={"WWW-Authenticate": challenge},
|
|
32
34
|
)
|
|
33
35
|
|
|
34
36
|
|
|
@@ -119,6 +121,6 @@ def mk_superadmin_middleware(secret):
|
|
|
119
121
|
async def middleware(req, handler):
|
|
120
122
|
if key_matches(req.headers.get("Authorization"), secret):
|
|
121
123
|
return await handler(req)
|
|
122
|
-
raise _unauthorized("invalid API key")
|
|
124
|
+
raise _unauthorized("invalid API key", API_KEY_CHALLENGE)
|
|
123
125
|
|
|
124
126
|
return middleware
|
|
@@ -3,13 +3,20 @@
|
|
|
3
3
|
A handler whose ``endpoint`` is not a function names the attributes clients may call
|
|
4
4
|
with ``attr_names`` (see ``public_attr`` for ``'*'``):
|
|
5
5
|
|
|
6
|
-
- a **module**: its functions are called by name
|
|
6
|
+
- a **module**: its functions are called by name. They are function endpoints like
|
|
7
|
+
any other: no object is kept, so there is no size bound, and a function that mutates
|
|
8
|
+
module globals does so for every client, exactly as a plain function endpoint (or a
|
|
9
|
+
bound method passed into an app spec) would. Authenticate with the auth middleware
|
|
10
|
+
(``mk_jwt_middleware``, ``JWTPlugin``), or pass an optional ``obj_owner`` to refuse
|
|
11
|
+
requests it does not identify;
|
|
7
12
|
- an **instance** (one object every client shares): ``_instance_endpoint_dispatcher``;
|
|
8
13
|
- a **class** (each client creates and uses its own objects):
|
|
9
14
|
``_class_endpoint_dispatcher``.
|
|
10
15
|
|
|
11
16
|
Instances and classes need an ``obj_owner``, a ``(request) -> owner`` hook reading
|
|
12
|
-
``current_request``, and are bounded in size (``py2http.sizeof``)
|
|
17
|
+
``current_request``, and are bounded in size (``py2http.sizeof``), measured after each
|
|
18
|
+
call except those to ``obj_readers``. A call returning a generator, coroutine or async
|
|
19
|
+
generator is measured when it is returned and again when that result finishes.
|
|
13
20
|
"""
|
|
14
21
|
|
|
15
22
|
import inspect
|
|
@@ -323,8 +330,14 @@ def _get_func_to_dispatch_handler(handler):
|
|
|
323
330
|
func = _class_endpoint_dispatcher(endpoint, handler, name, is_dispatchable)
|
|
324
331
|
|
|
325
332
|
elif isinstance(endpoint, ModuleType): # a module's functions: no shared object
|
|
333
|
+
owner_of = handler.get("obj_owner")
|
|
334
|
+
if owner_of is None or owner_of is shared_objects:
|
|
335
|
+
owner = lambda: None # noqa: E731
|
|
336
|
+
else:
|
|
337
|
+
owner = _request_owner(owner_of, name)
|
|
326
338
|
|
|
327
339
|
def func(_attr_name=None, **kwargs):
|
|
340
|
+
owner() # an optional gate: refuses requests the hook does not identify
|
|
328
341
|
return _get_attr_value(endpoint, _attr_name, is_dispatchable, **kwargs)
|
|
329
342
|
|
|
330
343
|
else:
|
|
@@ -410,9 +423,14 @@ def _instance_endpoint_dispatcher(obj, handler, name, is_dispatchable):
|
|
|
410
423
|
for a large object. Not measured: data the object keeps outside the process
|
|
411
424
|
(files, a database), and state held in its class or in module globals.
|
|
412
425
|
Measuring walks the object (up to ``max_obj_size`` bytes of it) after every
|
|
413
|
-
call
|
|
414
|
-
returning a generator
|
|
415
|
-
|
|
426
|
+
call except those to ``obj_readers``, so a large limit costs time on each such
|
|
427
|
+
request. A method returning a generator, coroutine or async generator is
|
|
428
|
+
measured when it returns and again when that result finishes.
|
|
429
|
+
- ``obj_readers``: names of dispatched attributes that never add to the object
|
|
430
|
+
(``read``, ``keys``, ``get``...; not ``__getitem__`` of a ``defaultdict``, which
|
|
431
|
+
inserts): calls to them are not measured, which saves the walk on every read. Empty by default; the bound holds only if every
|
|
432
|
+
name listed really never adds (what one does add is caught by the next measured
|
|
433
|
+
call).
|
|
416
434
|
- ``obj_shrinkers``: names of dispatched attributes that only ever remove
|
|
417
435
|
(``pop``, ``clear``, ``__delitem__`` if ``attr_names`` lists it...), still
|
|
418
436
|
callable while the object is over its limit so a client can bring it back under.
|
|
@@ -443,16 +461,8 @@ def _instance_endpoint_dispatcher(obj, handler, name, is_dispatchable):
|
|
|
443
461
|
f"{max_obj_size} bytes (as measured by obj_sizeof): raise max_obj_size, pass "
|
|
444
462
|
"an obj_sizeof that skips what clients cannot grow, or max_obj_size=None."
|
|
445
463
|
)
|
|
446
|
-
shrinkers = handler
|
|
447
|
-
|
|
448
|
-
raise TypeError(f"obj_shrinkers must be a collection of names: {shrinkers!r}")
|
|
449
|
-
shrinkers = frozenset(shrinkers)
|
|
450
|
-
for attr_name in shrinkers:
|
|
451
|
-
if not is_dispatchable(obj, attr_name):
|
|
452
|
-
raise ValueError(
|
|
453
|
-
f"obj_shrinkers of {name!r}: {attr_name!r} is not dispatched "
|
|
454
|
-
"(see attr_names)"
|
|
455
|
-
)
|
|
464
|
+
shrinkers = _names_option(handler, "obj_shrinkers", obj, name, is_dispatchable)
|
|
465
|
+
readers = _names_option(handler, "obj_readers", obj, name, is_dispatchable)
|
|
456
466
|
over_limit = threading.Event() # set while the object is known to be too big
|
|
457
467
|
measuring = threading.Lock() # so a stale measure cannot clear a newer one's flag
|
|
458
468
|
|
|
@@ -478,13 +488,20 @@ def _instance_endpoint_dispatcher(obj, handler, name, is_dispatchable):
|
|
|
478
488
|
shrinking = isinstance(_attr_name, str) and _attr_name in shrinkers
|
|
479
489
|
if over_limit.is_set() and measure() and not shrinking:
|
|
480
490
|
refuse()
|
|
481
|
-
|
|
491
|
+
|
|
492
|
+
def call():
|
|
482
493
|
return _get_attr_value(obj, _attr_name, is_dispatchable, **kwargs)
|
|
483
|
-
|
|
494
|
+
|
|
495
|
+
if isinstance(_attr_name, str) and _attr_name in readers:
|
|
496
|
+
return call()
|
|
497
|
+
|
|
498
|
+
def settle():
|
|
484
499
|
# a shrinker that ran but left it over the limit keeps its result
|
|
485
500
|
if measure() and not shrinking:
|
|
486
501
|
refuse()
|
|
487
502
|
|
|
503
|
+
return _call_then_settle(call, settle)
|
|
504
|
+
|
|
488
505
|
return func
|
|
489
506
|
|
|
490
507
|
|
|
@@ -512,7 +529,10 @@ def _class_endpoint_dispatcher(cls, handler, name, is_dispatchable):
|
|
|
512
529
|
that takes a budget over it is dropped. ``None`` disables a budget. An object
|
|
513
530
|
referencing large shared data (a loaded model) counts that data: raise
|
|
514
531
|
``max_obj_size`` or pass an ``obj_sizeof`` that skips it. A single call can still
|
|
515
|
-
allocate before it is measured.
|
|
532
|
+
allocate before it is measured. A method returning a generator, coroutine or
|
|
533
|
+
async generator is measured when it returns and again when that result finishes.
|
|
534
|
+
- ``obj_readers``: names of methods that never add to an object; calls to them are
|
|
535
|
+
not measured (see ``_instance_endpoint_dispatcher``).
|
|
516
536
|
"""
|
|
517
537
|
owner_of = handler.get("obj_owner")
|
|
518
538
|
if owner_of is None:
|
|
@@ -523,6 +543,10 @@ def _class_endpoint_dispatcher(cls, handler, name, is_dispatchable):
|
|
|
523
543
|
"client holding an object id use the object, as before."
|
|
524
544
|
)
|
|
525
545
|
max_obj_size = handler.get("max_obj_size", DFLT_MAX_OBJ_SIZE)
|
|
546
|
+
# the class's own attributes (no object yet for is_dispatchable): catches typos
|
|
547
|
+
readers = _names_option(
|
|
548
|
+
handler, "obj_readers", cls, name, lambda c, n: hasattr(c, n)
|
|
549
|
+
)
|
|
526
550
|
max_owner_bytes = handler.get("max_owner_bytes", DFLT_MAX_OWNER_BYTES)
|
|
527
551
|
max_total_bytes = handler.get("max_total_bytes", DFLT_MAX_TOTAL_BYTES)
|
|
528
552
|
sizeof = handler.get("obj_sizeof", bounded_deep_sizeof)
|
|
@@ -584,14 +608,98 @@ def _class_endpoint_dispatcher(cls, handler, name, is_dispatchable):
|
|
|
584
608
|
raise InputError(f"No object found with id {_obj_id}")
|
|
585
609
|
if _attr_name is None:
|
|
586
610
|
raise InputError("_attr_name must be provided when _obj_id is not None")
|
|
587
|
-
|
|
611
|
+
|
|
612
|
+
def call():
|
|
588
613
|
return _get_attr_value(obj, _attr_name, is_dispatchable, **kwargs)
|
|
589
|
-
|
|
590
|
-
|
|
614
|
+
|
|
615
|
+
if isinstance(_attr_name, str) and _attr_name in readers:
|
|
616
|
+
return call()
|
|
617
|
+
return _call_then_settle(call, lambda: check_size(who, _obj_id, obj))
|
|
591
618
|
|
|
592
619
|
return func
|
|
593
620
|
|
|
594
621
|
|
|
622
|
+
def _names_option(handler, option, obj, name, is_dispatchable) -> frozenset:
|
|
623
|
+
"""A handler option naming dispatched attributes, validated."""
|
|
624
|
+
names = handler.get(option, ())
|
|
625
|
+
if isinstance(names, str):
|
|
626
|
+
raise TypeError(f"{option} must be a collection of names: {names!r}")
|
|
627
|
+
names = frozenset(names)
|
|
628
|
+
for attr_name in names:
|
|
629
|
+
if not is_dispatchable(obj, attr_name):
|
|
630
|
+
raise ValueError(
|
|
631
|
+
f"{option} of {name!r}: {attr_name!r} is not dispatched (see attr_names)"
|
|
632
|
+
)
|
|
633
|
+
return names
|
|
634
|
+
|
|
635
|
+
|
|
636
|
+
def _settled(result, settle: Callable[[], None]):
|
|
637
|
+
"""``result``, after ``settle()``; for a generator, coroutine or async generator
|
|
638
|
+
(which do more work after being returned) ``settle()`` runs again when it finishes.
|
|
639
|
+
|
|
640
|
+
Settling at return is what bounds a call whose lazy result is never consumed (a
|
|
641
|
+
generator a JSON encoder refuses, a client that disconnects): whatever the method
|
|
642
|
+
did before returning is measured either way.
|
|
643
|
+
"""
|
|
644
|
+
settle()
|
|
645
|
+
if inspect.isgenerator(result):
|
|
646
|
+
|
|
647
|
+
def settling_generator():
|
|
648
|
+
try:
|
|
649
|
+
return (yield from result)
|
|
650
|
+
finally:
|
|
651
|
+
settle()
|
|
652
|
+
|
|
653
|
+
return settling_generator()
|
|
654
|
+
if inspect.iscoroutine(result):
|
|
655
|
+
|
|
656
|
+
async def settling_coroutine():
|
|
657
|
+
try:
|
|
658
|
+
return await result
|
|
659
|
+
finally:
|
|
660
|
+
settle()
|
|
661
|
+
|
|
662
|
+
return settling_coroutine()
|
|
663
|
+
if inspect.isasyncgen(result):
|
|
664
|
+
|
|
665
|
+
async def settling_async_generator():
|
|
666
|
+
# driven by hand so asend()/athrow() reach the inner generator
|
|
667
|
+
value, error = None, None
|
|
668
|
+
try:
|
|
669
|
+
while True:
|
|
670
|
+
try:
|
|
671
|
+
if error is None:
|
|
672
|
+
item = await result.asend(value)
|
|
673
|
+
else:
|
|
674
|
+
item = await result.athrow(error)
|
|
675
|
+
except StopAsyncIteration:
|
|
676
|
+
return
|
|
677
|
+
value, error = None, None
|
|
678
|
+
try:
|
|
679
|
+
value = yield item
|
|
680
|
+
except GeneratorExit:
|
|
681
|
+
await result.aclose()
|
|
682
|
+
raise
|
|
683
|
+
except BaseException as e:
|
|
684
|
+
error = e
|
|
685
|
+
finally:
|
|
686
|
+
settle()
|
|
687
|
+
|
|
688
|
+
return settling_async_generator()
|
|
689
|
+
return result
|
|
690
|
+
|
|
691
|
+
|
|
692
|
+
def _call_then_settle(call: Callable[[], Any], settle: Callable[[], None]):
|
|
693
|
+
"""``call()``, then ``settle()`` (see ``_settled``); also after a call that raised,
|
|
694
|
+
since it may have grown the object first."""
|
|
695
|
+
try:
|
|
696
|
+
result = call()
|
|
697
|
+
except BaseException:
|
|
698
|
+
settle()
|
|
699
|
+
raise
|
|
700
|
+
return _settled(result, settle)
|
|
701
|
+
|
|
702
|
+
|
|
595
703
|
def _get_attr_value(obj, attr_name, is_dispatchable, /, **kwargs):
|
|
596
704
|
if not is_dispatchable(obj, attr_name):
|
|
597
705
|
raise InputError(
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
"""Object endpoints: ``obj_readers`` skip measuring, lazy results are measured when
|
|
2
|
+
they finish, module endpoints take an optional owner gate, and every 401 names a
|
|
3
|
+
challenge (#32)."""
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
from aiohttp import web
|
|
9
|
+
from i2.errors import AuthorizationError, InputError
|
|
10
|
+
|
|
11
|
+
from py2http import mk_app
|
|
12
|
+
from py2http.bottle_plugins import ApiKeyAuthPlugin, JWTPlugin
|
|
13
|
+
from py2http.default_configs import aiohttp_error_handler
|
|
14
|
+
from py2http.service import _get_func_to_dispatch_handler
|
|
15
|
+
from py2http.tests.utils_for_testing import as_owner, call_wsgi
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class Store(dict):
|
|
19
|
+
def write(self, key, value):
|
|
20
|
+
self[key] = value
|
|
21
|
+
|
|
22
|
+
def read(self, key):
|
|
23
|
+
return self[key]
|
|
24
|
+
|
|
25
|
+
def fill_lazily(self, n):
|
|
26
|
+
for i in range(n):
|
|
27
|
+
self[i] = "y" * 1_000
|
|
28
|
+
yield i
|
|
29
|
+
|
|
30
|
+
async def fill_async(self, n):
|
|
31
|
+
for i in range(n):
|
|
32
|
+
self[i] = "y" * 1_000
|
|
33
|
+
return n
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class _Counting:
|
|
37
|
+
def __init__(self):
|
|
38
|
+
self.calls = 0
|
|
39
|
+
|
|
40
|
+
def __call__(self, obj, limit):
|
|
41
|
+
self.calls += 1
|
|
42
|
+
return len(obj)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _instance(store, **extras):
|
|
46
|
+
extras.setdefault("obj_owner", lambda req: req)
|
|
47
|
+
return _get_func_to_dispatch_handler(
|
|
48
|
+
dict(endpoint=store, name="store", attr_names="*", **extras)
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def test_obj_readers_are_not_measured():
|
|
53
|
+
sizeof = _Counting()
|
|
54
|
+
func = _instance(Store(), max_obj_size=10, obj_sizeof=sizeof, obj_readers={"read"})
|
|
55
|
+
built = sizeof.calls
|
|
56
|
+
as_owner("ann", func, _attr_name="write", key="a", value=1)
|
|
57
|
+
assert sizeof.calls == built + 1
|
|
58
|
+
for _ in range(5):
|
|
59
|
+
assert as_owner("ann", func, _attr_name="read", key="a") == 1
|
|
60
|
+
assert sizeof.calls == built + 1
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def test_class_endpoint_readers_are_not_measured():
|
|
64
|
+
sizeof = _Counting()
|
|
65
|
+
func = _get_func_to_dispatch_handler(
|
|
66
|
+
dict(
|
|
67
|
+
endpoint=Store,
|
|
68
|
+
name="bag",
|
|
69
|
+
attr_names="*",
|
|
70
|
+
obj_owner=lambda req: req,
|
|
71
|
+
obj_sizeof=sizeof,
|
|
72
|
+
obj_readers=["read"],
|
|
73
|
+
)
|
|
74
|
+
)
|
|
75
|
+
obj_id = as_owner("ann", func)
|
|
76
|
+
as_owner("ann", func, _obj_id=obj_id, _attr_name="write", key="a", value=1)
|
|
77
|
+
measured = sizeof.calls
|
|
78
|
+
as_owner("ann", func, _obj_id=obj_id, _attr_name="read", key="a")
|
|
79
|
+
assert sizeof.calls == measured
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@pytest.mark.parametrize(
|
|
83
|
+
"readers, error", [("read", TypeError), (["nope"], ValueError)]
|
|
84
|
+
)
|
|
85
|
+
def test_obj_readers_are_validated(readers, error):
|
|
86
|
+
with pytest.raises(error, match="obj_readers"):
|
|
87
|
+
_instance(Store(), obj_readers=readers)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def test_a_generator_is_measured_when_it_finishes():
|
|
91
|
+
store = Store()
|
|
92
|
+
func = _instance(store, max_obj_size=5, obj_sizeof=lambda obj, limit: len(obj))
|
|
93
|
+
gen = as_owner("ann", func, _attr_name="fill_lazily", n=10)
|
|
94
|
+
assert store == {} # nothing ran yet: measuring now would see nothing
|
|
95
|
+
with pytest.raises(InputError, match="size limit"):
|
|
96
|
+
list(gen)
|
|
97
|
+
with pytest.raises(InputError, match="size limit"): # and the next call is refused
|
|
98
|
+
as_owner("ann", func, _attr_name="write", key="x", value=1)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def test_a_coroutine_is_measured_when_it_finishes():
|
|
102
|
+
store = Store()
|
|
103
|
+
func = _instance(store, max_obj_size=5, obj_sizeof=lambda obj, limit: len(obj))
|
|
104
|
+
coro = as_owner("ann", func, _attr_name="fill_async", n=10)
|
|
105
|
+
with pytest.raises(InputError, match="size limit"):
|
|
106
|
+
asyncio.run(coro)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def test_class_endpoint_drops_an_object_a_generator_grew():
|
|
110
|
+
func = _get_func_to_dispatch_handler(
|
|
111
|
+
dict(
|
|
112
|
+
endpoint=Store,
|
|
113
|
+
name="bag",
|
|
114
|
+
attr_names="*",
|
|
115
|
+
obj_owner=lambda req: req,
|
|
116
|
+
max_obj_size=5,
|
|
117
|
+
obj_sizeof=lambda obj, limit: len(obj),
|
|
118
|
+
)
|
|
119
|
+
)
|
|
120
|
+
obj_id = as_owner("ann", func)
|
|
121
|
+
gen = as_owner("ann", func, _obj_id=obj_id, _attr_name="fill_lazily", n=10)
|
|
122
|
+
with pytest.raises(InputError, match="dropped"):
|
|
123
|
+
list(gen)
|
|
124
|
+
with pytest.raises(InputError, match="No object"):
|
|
125
|
+
as_owner("ann", func, _obj_id=obj_id, _attr_name="read", key=0)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def test_module_endpoint_takes_an_optional_owner_gate():
|
|
129
|
+
import os.path as module
|
|
130
|
+
|
|
131
|
+
gated = _get_func_to_dispatch_handler(
|
|
132
|
+
dict(endpoint=module, name="m", attr_names=["basename"], obj_owner=lambda r: r)
|
|
133
|
+
)
|
|
134
|
+
assert as_owner("ann", gated, _attr_name="basename", p="/a/b") == "b"
|
|
135
|
+
with pytest.raises(AuthorizationError):
|
|
136
|
+
as_owner(None, gated, _attr_name="basename", p="/a/b")
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
# ---------------------------------------------------------------------------
|
|
140
|
+
# 401 responses name a challenge (RFC 7235)
|
|
141
|
+
# ---------------------------------------------------------------------------
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _unidentified():
|
|
145
|
+
raise AuthorizationError("who are you")
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def test_a_raised_authorization_error_names_a_challenge(monkeypatch, capsys):
|
|
149
|
+
app = mk_app([_unidentified])
|
|
150
|
+
status, _, headers = call_wsgi(app, "/_unidentified", {}, with_headers=True)
|
|
151
|
+
assert status.startswith("401") and headers["www-authenticate"] == "Bearer"
|
|
152
|
+
monkeypatch.setenv("PY2HTTP_WWW_AUTHENTICATE", 'Basic realm="api"')
|
|
153
|
+
_, _, headers = call_wsgi(app, "/_unidentified", {}, with_headers=True)
|
|
154
|
+
assert headers["www-authenticate"] == 'Basic realm="api"'
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def test_aiohttp_401_names_a_challenge():
|
|
158
|
+
with pytest.raises(web.HTTPUnauthorized) as caught:
|
|
159
|
+
aiohttp_error_handler(AuthorizationError("who"))
|
|
160
|
+
assert caught.value.headers["WWW-Authenticate"] == "Bearer"
|
|
161
|
+
with pytest.raises(web.HTTPBadRequest) as caught:
|
|
162
|
+
aiohttp_error_handler(InputError("bad"))
|
|
163
|
+
assert "WWW-Authenticate" not in caught.value.headers
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def whoami():
|
|
167
|
+
return "me"
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
@pytest.mark.parametrize(
|
|
171
|
+
"plugin, challenge",
|
|
172
|
+
[
|
|
173
|
+
(JWTPlugin("s3cret-for-tests-only-0123456789abcdef"), "Bearer"),
|
|
174
|
+
(ApiKeyAuthPlugin("api-key-for-tests-only"), "ApiKey"),
|
|
175
|
+
],
|
|
176
|
+
ids=["jwt", "api-key"],
|
|
177
|
+
)
|
|
178
|
+
def test_auth_plugins_name_their_challenge(plugin, challenge):
|
|
179
|
+
app = mk_app([whoami], plugins=[plugin])
|
|
180
|
+
status, _, headers = call_wsgi(app, "/whoami", {}, with_headers=True)
|
|
181
|
+
assert status.startswith("401") and headers["www-authenticate"] == challenge
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def test_aiohttp_middlewares_name_their_challenge():
|
|
185
|
+
from py2http.middleware import _unauthorized
|
|
186
|
+
|
|
187
|
+
assert _unauthorized("x").headers["WWW-Authenticate"] == "Bearer"
|
|
188
|
+
assert _unauthorized("x", "ApiKey").headers["WWW-Authenticate"] == "ApiKey"
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
# ---------------------------------------------------------------------------
|
|
192
|
+
# Adversarial-review regressions
|
|
193
|
+
# ---------------------------------------------------------------------------
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
class Eager(dict):
|
|
197
|
+
def add(self, key):
|
|
198
|
+
self[key] = "x" * 1_000
|
|
199
|
+
return (k for k in [key]) # grows first, then returns a lazy result
|
|
200
|
+
|
|
201
|
+
async def echo(self):
|
|
202
|
+
got = None
|
|
203
|
+
while True:
|
|
204
|
+
got = yield got
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def test_growth_before_returning_a_lazy_result_is_measured_at_return():
|
|
208
|
+
store = Eager()
|
|
209
|
+
func = _instance(store, max_obj_size=3, obj_sizeof=lambda obj, limit: len(obj))
|
|
210
|
+
for i in range(3):
|
|
211
|
+
as_owner("ann", func, _attr_name="add", key=i) # never consumed
|
|
212
|
+
with pytest.raises(InputError, match="size limit"):
|
|
213
|
+
as_owner("ann", func, _attr_name="add", key=3)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def test_class_endpoint_drops_an_object_grown_before_a_lazy_return():
|
|
217
|
+
func = _get_func_to_dispatch_handler(
|
|
218
|
+
dict(
|
|
219
|
+
endpoint=Eager,
|
|
220
|
+
name="bag",
|
|
221
|
+
attr_names="*",
|
|
222
|
+
obj_owner=lambda req: req,
|
|
223
|
+
max_obj_size=1,
|
|
224
|
+
obj_sizeof=lambda obj, limit: len(obj),
|
|
225
|
+
)
|
|
226
|
+
)
|
|
227
|
+
obj_id = as_owner("ann", func)
|
|
228
|
+
as_owner("ann", func, _obj_id=obj_id, _attr_name="add", key=0)
|
|
229
|
+
with pytest.raises(InputError, match="dropped"):
|
|
230
|
+
as_owner("ann", func, _obj_id=obj_id, _attr_name="add", key=1)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def test_a_wrapped_async_generator_forwards_asend():
|
|
234
|
+
func = _instance(Eager(), max_obj_size=None)
|
|
235
|
+
|
|
236
|
+
async def drive():
|
|
237
|
+
gen = as_owner("ann", func, _attr_name="echo")
|
|
238
|
+
await gen.asend(None)
|
|
239
|
+
got = await gen.asend(42)
|
|
240
|
+
await gen.aclose()
|
|
241
|
+
return got
|
|
242
|
+
|
|
243
|
+
assert asyncio.run(drive()) == 42
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def test_class_endpoint_readers_are_validated():
|
|
247
|
+
with pytest.raises(ValueError, match="obj_readers"):
|
|
248
|
+
_get_func_to_dispatch_handler(
|
|
249
|
+
dict(
|
|
250
|
+
endpoint=Store,
|
|
251
|
+
name="bag",
|
|
252
|
+
attr_names="*",
|
|
253
|
+
obj_owner=lambda req: req,
|
|
254
|
+
obj_readers=["raed"],
|
|
255
|
+
)
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def test_a_challenge_with_a_line_break_falls_back_to_bearer(monkeypatch):
|
|
260
|
+
from py2http.default_configs import www_authenticate_challenge
|
|
261
|
+
|
|
262
|
+
monkeypatch.setenv("PY2HTTP_WWW_AUTHENTICATE", "Basic\r\nSet-Cookie: x=1")
|
|
263
|
+
assert www_authenticate_challenge() == "Bearer"
|
|
@@ -32,9 +32,12 @@ def run_server(launcher, wait_before_entering=0, verbose=False, **kwargs):
|
|
|
32
32
|
clog(f"... server terminated")
|
|
33
33
|
|
|
34
34
|
|
|
35
|
-
def call_wsgi(
|
|
35
|
+
def call_wsgi(
|
|
36
|
+
app, path, payload=None, *, method="POST", headers=(), with_headers=False
|
|
37
|
+
):
|
|
36
38
|
"""Send one request to the WSGI ``app`` in-process; return ``(status, body)``, the
|
|
37
|
-
body decoded from JSON when it is JSON
|
|
39
|
+
body decoded from JSON when it is JSON (``(status, body, response_headers)`` with
|
|
40
|
+
``with_headers``, header names lower-cased).
|
|
38
41
|
|
|
39
42
|
``headers`` maps header names (``"Authorization"``) to values.
|
|
40
43
|
"""
|
|
@@ -49,12 +52,20 @@ def call_wsgi(app, path, payload=None, *, method="POST", headers=()):
|
|
|
49
52
|
for name, value in dict(headers).items():
|
|
50
53
|
environ["HTTP_" + name.upper().replace("-", "_")] = value
|
|
51
54
|
setup_testing_defaults(environ)
|
|
52
|
-
statuses = []
|
|
53
|
-
|
|
55
|
+
statuses, response_headers = [], {}
|
|
56
|
+
|
|
57
|
+
def start_response(status, headers, *a):
|
|
58
|
+
statuses.append(status)
|
|
59
|
+
response_headers.update((k.lower(), v) for k, v in headers) # case-insensitive
|
|
60
|
+
|
|
61
|
+
out = b"".join(app(environ, start_response))
|
|
54
62
|
try:
|
|
55
|
-
|
|
63
|
+
body = json.loads(out)
|
|
56
64
|
except ValueError:
|
|
57
|
-
|
|
65
|
+
body = out.decode()
|
|
66
|
+
if with_headers:
|
|
67
|
+
return statuses[0], body, response_headers
|
|
68
|
+
return statuses[0], body
|
|
58
69
|
|
|
59
70
|
|
|
60
71
|
def as_owner(owner, func, **kwargs):
|
|
@@ -45,6 +45,7 @@ py2http/tests/test_flask.py
|
|
|
45
45
|
py2http/tests/test_instance_endpoints.py
|
|
46
46
|
py2http/tests/test_jwt_middleware.py
|
|
47
47
|
py2http/tests/test_not_set_defaults.py
|
|
48
|
+
py2http/tests/test_obj_measuring.py
|
|
48
49
|
py2http/tests/test_obj_ownership.py
|
|
49
50
|
py2http/tests/test_p2h2p.py
|
|
50
51
|
py2http/tests/test_positional_only.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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|