py2http 0.1.69__tar.gz → 0.1.71__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 (53) hide show
  1. {py2http-0.1.69 → py2http-0.1.71}/PKG-INFO +1 -1
  2. {py2http-0.1.69 → py2http-0.1.71}/py2http/bottle_plugins.py +4 -3
  3. {py2http-0.1.69 → py2http-0.1.71}/py2http/constants.py +8 -0
  4. {py2http-0.1.69 → py2http-0.1.71}/py2http/default_configs.py +52 -4
  5. {py2http-0.1.69 → py2http-0.1.71}/py2http/middleware.py +6 -4
  6. {py2http-0.1.69 → py2http-0.1.71}/py2http/obj_dispatch.py +129 -21
  7. {py2http-0.1.69 → py2http-0.1.71}/py2http/tests/test_error_statuses.py +38 -0
  8. py2http-0.1.71/py2http/tests/test_obj_measuring.py +263 -0
  9. {py2http-0.1.69 → py2http-0.1.71}/py2http/tests/utils_for_testing.py +17 -6
  10. {py2http-0.1.69 → py2http-0.1.71}/py2http.egg-info/PKG-INFO +1 -1
  11. {py2http-0.1.69 → py2http-0.1.71}/py2http.egg-info/SOURCES.txt +1 -0
  12. {py2http-0.1.69 → py2http-0.1.71}/setup.cfg +1 -1
  13. {py2http-0.1.69 → py2http-0.1.71}/LICENSE +0 -0
  14. {py2http-0.1.69 → py2http-0.1.71}/README.md +0 -0
  15. {py2http-0.1.69 → py2http-0.1.71}/py2http/__init__.py +0 -0
  16. {py2http-0.1.69 → py2http-0.1.71}/py2http/auth.py +0 -0
  17. {py2http-0.1.69 → py2http-0.1.71}/py2http/config.py +0 -0
  18. {py2http-0.1.69 → py2http-0.1.71}/py2http/decorators.py +0 -0
  19. {py2http-0.1.69 → py2http-0.1.71}/py2http/diagnosis.py +0 -0
  20. {py2http-0.1.69 → py2http-0.1.71}/py2http/examples/__init__.py +0 -0
  21. {py2http-0.1.69 → py2http-0.1.71}/py2http/examples/custom_input_mapper.py +0 -0
  22. {py2http-0.1.69 → py2http-0.1.71}/py2http/examples/custom_input_mappers.py +0 -0
  23. {py2http-0.1.69 → py2http-0.1.71}/py2http/examples/example_service.py +0 -0
  24. {py2http-0.1.69 → py2http-0.1.71}/py2http/examples/example_service_bottle.py +0 -0
  25. {py2http-0.1.69 → py2http-0.1.71}/py2http/examples/example_service_flask.py +0 -0
  26. {py2http-0.1.69 → py2http-0.1.71}/py2http/examples/object_dispatch.py +0 -0
  27. {py2http-0.1.69 → py2http-0.1.71}/py2http/openapi_utils.py +0 -0
  28. {py2http-0.1.69 → py2http-0.1.71}/py2http/route_utils.py +0 -0
  29. {py2http-0.1.69 → py2http-0.1.71}/py2http/schema_tools.py +0 -0
  30. {py2http-0.1.69 → py2http-0.1.71}/py2http/service.py +0 -0
  31. {py2http-0.1.69 → py2http-0.1.71}/py2http/sizeof.py +0 -0
  32. {py2http-0.1.69 → py2http-0.1.71}/py2http/tests/__init__.py +0 -0
  33. {py2http-0.1.69 → py2http-0.1.71}/py2http/tests/example_service_test.py +0 -0
  34. {py2http-0.1.69 → py2http-0.1.71}/py2http/tests/objects_for_testing.py +0 -0
  35. {py2http-0.1.69 → py2http-0.1.71}/py2http/tests/simple_run_process_test.py +0 -0
  36. {py2http-0.1.69 → py2http-0.1.71}/py2http/tests/test_attr_dispatch.py +0 -0
  37. {py2http-0.1.69 → py2http-0.1.71}/py2http/tests/test_auth_plugins.py +0 -0
  38. {py2http-0.1.69 → py2http-0.1.71}/py2http/tests/test_binary_req.py +0 -0
  39. {py2http-0.1.69 → py2http-0.1.71}/py2http/tests/test_decorators.py +0 -0
  40. {py2http-0.1.69 → py2http-0.1.71}/py2http/tests/test_flask.py +0 -0
  41. {py2http-0.1.69 → py2http-0.1.71}/py2http/tests/test_instance_endpoints.py +0 -0
  42. {py2http-0.1.69 → py2http-0.1.71}/py2http/tests/test_jwt_middleware.py +0 -0
  43. {py2http-0.1.69 → py2http-0.1.71}/py2http/tests/test_not_set_defaults.py +0 -0
  44. {py2http-0.1.69 → py2http-0.1.71}/py2http/tests/test_obj_ownership.py +0 -0
  45. {py2http-0.1.69 → py2http-0.1.71}/py2http/tests/test_p2h2p.py +0 -0
  46. {py2http-0.1.69 → py2http-0.1.71}/py2http/tests/test_positional_only.py +0 -0
  47. {py2http-0.1.69 → py2http-0.1.71}/py2http/types.py +0 -0
  48. {py2http-0.1.69 → py2http-0.1.71}/py2http/util.py +0 -0
  49. {py2http-0.1.69 → py2http-0.1.71}/py2http.egg-info/dependency_links.txt +0 -0
  50. {py2http-0.1.69 → py2http-0.1.71}/py2http.egg-info/not-zip-safe +0 -0
  51. {py2http-0.1.69 → py2http-0.1.71}/py2http.egg-info/requires.txt +0 -0
  52. {py2http-0.1.69 → py2http-0.1.71}/py2http.egg-info/top_level.txt +0 -0
  53. {py2http-0.1.69 → py2http-0.1.71}/setup.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: py2http
3
- Version: 0.1.69
3
+ Version: 0.1.71
4
4
  Summary: Declarative HTTP service entry point.
5
5
  Home-page: https://github.com/i2mint/py2http
6
6
  License: MIT
@@ -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 JSON_CONTENT_TYPE
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,20 +130,34 @@ 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(_AIOHTTP_ERRORS[status], str(error), reason=reason)
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}
109
150
 
110
151
 
111
152
  def flask_error_handler(error: Exception):
112
- raise NotImplementedError()
153
+ """The ``(body, status, headers)`` a Flask view returns for an error, mapped like
154
+ the bottle handler (``OPAQUE_ERRORS`` hides the text of a 500)."""
155
+ message = str(error)
156
+ status = error_status(error)
157
+ if status == 500 and os.getenv("OPAQUE_ERRORS", None):
158
+ message = "Internal server error"
159
+ status_line = f"400 {type(error).__name__}" if status == 400 else status
160
+ return {"error": message}, status_line, _status_headers(status)
113
161
 
114
162
 
115
163
  def default_error_handler(error: Exception):
@@ -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}), content_type=JSON_CONTENT_TYPE
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, reads included, so a large limit costs time on every request. A method
414
- returning a generator or coroutine does its work after the measure: what it
415
- adds is caught by the next call.
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.get("obj_shrinkers", ())
447
- if isinstance(shrinkers, str):
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
- try:
491
+
492
+ def call():
482
493
  return _get_attr_value(obj, _attr_name, is_dispatchable, **kwargs)
483
- finally: # a call that grows the object and then raises is measured too
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
- try:
611
+
612
+ def call():
588
613
  return _get_attr_value(obj, _attr_name, is_dispatchable, **kwargs)
589
- finally: # a call that grows the object and then raises is measured too
590
- check_size(who, _obj_id, obj)
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(
@@ -63,3 +63,41 @@ def test_aiohttp_error_handler(error, status):
63
63
  assert caught.value.status == status
64
64
  if status == 500:
65
65
  assert "internal detail" not in caught.value.text
66
+
67
+
68
+ @pytest.mark.parametrize("error, status", CASES)
69
+ def test_flask_error_handler(error, status, monkeypatch):
70
+ flask = pytest.importorskip("flask")
71
+ monkeypatch.delenv("PY2HTTP_WWW_AUTHENTICATE", raising=False)
72
+ monkeypatch.setenv("PY2HTTP_FRAMEWORK", "flask")
73
+ from py2http.default_configs import default_error_handler
74
+
75
+ app = flask.Flask(__name__)
76
+
77
+ @app.route("/fail")
78
+ def fail():
79
+ return default_error_handler(error)
80
+
81
+ response = app.test_client().get("/fail")
82
+ assert response.status_code == status
83
+ assert set(response.get_json()) == {"error"}
84
+ if status == 400: # as the bottle handler: the status line names the error
85
+ assert response.status == f"400 {type(error).__name__}"
86
+ if status == 401:
87
+ assert response.headers["WWW-Authenticate"] == "Bearer"
88
+ else:
89
+ assert "WWW-Authenticate" not in response.headers
90
+ if status == 500:
91
+ assert "internal detail" in response.get_json()["error"]
92
+
93
+
94
+ def test_flask_opaque_errors_hides_500_text(monkeypatch):
95
+ flask = pytest.importorskip("flask")
96
+ monkeypatch.setenv("OPAQUE_ERRORS", "1")
97
+ from py2http.default_configs import flask_error_handler
98
+
99
+ app = flask.Flask(__name__)
100
+ app.add_url_rule(
101
+ "/f", "f", lambda: flask_error_handler(KeyError("internal detail"))
102
+ )
103
+ assert app.test_client().get("/f").get_json() == {"error": "Internal server error"}
@@ -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(app, path, payload=None, *, method="POST", headers=()):
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
- out = b"".join(app(environ, lambda status, headers, *a: statuses.append(status)))
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
- return statuses[0], json.loads(out)
63
+ body = json.loads(out)
56
64
  except ValueError:
57
- return statuses[0], out.decode()
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):
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: py2http
3
- Version: 0.1.69
3
+ Version: 0.1.71
4
4
  Summary: Declarative HTTP service entry point.
5
5
  Home-page: https://github.com/i2mint/py2http
6
6
  License: MIT
@@ -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
@@ -1,6 +1,6 @@
1
1
  [metadata]
2
2
  name = py2http
3
- version = 0.1.69
3
+ version = 0.1.71
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
File without changes