py2http 0.1.67__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.
Files changed (47) hide show
  1. {py2http-0.1.67 → py2http-0.1.68}/PKG-INFO +1 -1
  2. {py2http-0.1.67 → py2http-0.1.68}/py2http/service.py +5 -5
  3. py2http-0.1.68/py2http/tests/test_positional_only.py +150 -0
  4. {py2http-0.1.67 → py2http-0.1.68}/py2http/util.py +80 -1
  5. {py2http-0.1.67 → py2http-0.1.68}/py2http.egg-info/PKG-INFO +1 -1
  6. {py2http-0.1.67 → py2http-0.1.68}/py2http.egg-info/SOURCES.txt +1 -0
  7. {py2http-0.1.67 → py2http-0.1.68}/setup.cfg +1 -1
  8. {py2http-0.1.67 → py2http-0.1.68}/LICENSE +0 -0
  9. {py2http-0.1.67 → py2http-0.1.68}/README.md +0 -0
  10. {py2http-0.1.67 → py2http-0.1.68}/py2http/__init__.py +0 -0
  11. {py2http-0.1.67 → py2http-0.1.68}/py2http/bottle_plugins.py +0 -0
  12. {py2http-0.1.67 → py2http-0.1.68}/py2http/config.py +0 -0
  13. {py2http-0.1.67 → py2http-0.1.68}/py2http/constants.py +0 -0
  14. {py2http-0.1.67 → py2http-0.1.68}/py2http/decorators.py +0 -0
  15. {py2http-0.1.67 → py2http-0.1.68}/py2http/default_configs.py +0 -0
  16. {py2http-0.1.67 → py2http-0.1.68}/py2http/diagnosis.py +0 -0
  17. {py2http-0.1.67 → py2http-0.1.68}/py2http/examples/__init__.py +0 -0
  18. {py2http-0.1.67 → py2http-0.1.68}/py2http/examples/custom_input_mapper.py +0 -0
  19. {py2http-0.1.67 → py2http-0.1.68}/py2http/examples/custom_input_mappers.py +0 -0
  20. {py2http-0.1.67 → py2http-0.1.68}/py2http/examples/example_service.py +0 -0
  21. {py2http-0.1.67 → py2http-0.1.68}/py2http/examples/example_service_bottle.py +0 -0
  22. {py2http-0.1.67 → py2http-0.1.68}/py2http/examples/example_service_flask.py +0 -0
  23. {py2http-0.1.67 → py2http-0.1.68}/py2http/examples/object_dispatch.py +0 -0
  24. {py2http-0.1.67 → py2http-0.1.68}/py2http/middleware.py +0 -0
  25. {py2http-0.1.67 → py2http-0.1.68}/py2http/openapi_utils.py +0 -0
  26. {py2http-0.1.67 → py2http-0.1.68}/py2http/route_utils.py +0 -0
  27. {py2http-0.1.67 → py2http-0.1.68}/py2http/schema_tools.py +0 -0
  28. {py2http-0.1.67 → py2http-0.1.68}/py2http/tests/__init__.py +0 -0
  29. {py2http-0.1.67 → py2http-0.1.68}/py2http/tests/example_service_test.py +0 -0
  30. {py2http-0.1.67 → py2http-0.1.68}/py2http/tests/objects_for_testing.py +0 -0
  31. {py2http-0.1.67 → py2http-0.1.68}/py2http/tests/simple_run_process_test.py +0 -0
  32. {py2http-0.1.67 → py2http-0.1.68}/py2http/tests/test_attr_dispatch.py +0 -0
  33. {py2http-0.1.67 → py2http-0.1.68}/py2http/tests/test_binary_req.py +0 -0
  34. {py2http-0.1.67 → py2http-0.1.68}/py2http/tests/test_decorators.py +0 -0
  35. {py2http-0.1.67 → py2http-0.1.68}/py2http/tests/test_flask.py +0 -0
  36. {py2http-0.1.67 → py2http-0.1.68}/py2http/tests/test_instance_endpoints.py +0 -0
  37. {py2http-0.1.67 → py2http-0.1.68}/py2http/tests/test_jwt_middleware.py +0 -0
  38. {py2http-0.1.67 → py2http-0.1.68}/py2http/tests/test_not_set_defaults.py +0 -0
  39. {py2http-0.1.67 → py2http-0.1.68}/py2http/tests/test_obj_ownership.py +0 -0
  40. {py2http-0.1.67 → py2http-0.1.68}/py2http/tests/test_p2h2p.py +0 -0
  41. {py2http-0.1.67 → py2http-0.1.68}/py2http/tests/utils_for_testing.py +0 -0
  42. {py2http-0.1.67 → py2http-0.1.68}/py2http/types.py +0 -0
  43. {py2http-0.1.67 → py2http-0.1.68}/py2http.egg-info/dependency_links.txt +0 -0
  44. {py2http-0.1.67 → py2http-0.1.68}/py2http.egg-info/not-zip-safe +0 -0
  45. {py2http-0.1.67 → py2http-0.1.68}/py2http.egg-info/requires.txt +0 -0
  46. {py2http-0.1.67 → py2http-0.1.68}/py2http.egg-info/top_level.txt +0 -0
  47. {py2http-0.1.67 → py2http-0.1.68}/setup.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: py2http
3
- Version: 0.1.67
3
+ Version: 0.1.68
4
4
  Summary: Declarative HTTP service entry point.
5
5
  Home-page: https://github.com/i2mint/py2http
6
6
  License: MIT
@@ -56,7 +56,7 @@ 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
62
  # Instance endpoints (``handler['endpoint']`` is an object) are one object every client
@@ -518,7 +518,7 @@ def mk_route(func, **configs):
518
518
  try:
519
519
  inputs = input_mapper(req)
520
520
  input_args, input_kwargs = get_input_args_and_kwargs(inputs)
521
- raw_result = func(*input_args, **input_kwargs)
521
+ raw_result = call_with_inputs(func, input_args, input_kwargs)
522
522
  return output_mapper(raw_result, **inputs)
523
523
  finally:
524
524
  current_request.reset(token)
@@ -536,7 +536,7 @@ def mk_route(func, **configs):
536
536
  if isawaitable(inputs): # Pattern: pass-on async property
537
537
  inputs = await inputs
538
538
  input_args, input_kwargs = get_input_args_and_kwargs(inputs)
539
- raw_result = func(*input_args, **input_kwargs)
539
+ raw_result = call_with_inputs(func, input_args, input_kwargs)
540
540
  if isawaitable(raw_result): # Pattern: pass-on async property
541
541
  raw_result = await raw_result
542
542
  final_result = output_mapper(raw_result, **inputs)
@@ -1237,7 +1237,7 @@ def _class_endpoint_dispatcher(cls, handler, name, is_dispatchable):
1237
1237
  if _obj_id is None:
1238
1238
  if _attr_name is not None:
1239
1239
  raise InputError("_attr_name must be None when _obj_id is None")
1240
- new_obj = cls(**kwargs)
1240
+ new_obj = call_with_inputs(cls, kwargs=kwargs)
1241
1241
  new_id = str(uuid4())
1242
1242
  store.put(who, new_id, new_obj)
1243
1243
  check_size(who, new_id, new_obj)
@@ -1262,5 +1262,5 @@ def _get_attr_value(obj, attr_name, is_dispatchable, /, **kwargs):
1262
1262
  )
1263
1263
  attr = getattr(obj, attr_name)
1264
1264
  if callable(attr):
1265
- return attr(**kwargs)
1265
+ return call_with_inputs(attr, kwargs=kwargs)
1266
1266
  return attr
@@ -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 inspect import Parameter, signature
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)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: py2http
3
- Version: 0.1.67
3
+ Version: 0.1.68
4
4
  Summary: Declarative HTTP service entry point.
5
5
  Home-page: https://github.com/i2mint/py2http
6
6
  License: MIT
@@ -42,4 +42,5 @@ py2http/tests/test_jwt_middleware.py
42
42
  py2http/tests/test_not_set_defaults.py
43
43
  py2http/tests/test_obj_ownership.py
44
44
  py2http/tests/test_p2h2p.py
45
+ py2http/tests/test_positional_only.py
45
46
  py2http/tests/utils_for_testing.py
@@ -1,6 +1,6 @@
1
1
  [metadata]
2
2
  name = py2http
3
- version = 0.1.67
3
+ version = 0.1.68
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