http2py 0.1.34__tar.gz → 0.1.35__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.
- {http2py-0.1.34 → http2py-0.1.35}/PKG-INFO +1 -2
- {http2py-0.1.34 → http2py-0.1.35}/http2py/client.py +20 -1
- http2py-0.1.35/http2py/decorators.py +142 -0
- {http2py-0.1.34 → http2py-0.1.35}/http2py/py2request.py +52 -7
- {http2py-0.1.34 → http2py-0.1.35}/pyproject.toml +1 -7
- http2py-0.1.35/tests/test_binary_payloads.py +189 -0
- http2py-0.1.34/http2py/decorators.py +0 -59
- http2py-0.1.34/tests/test_dependency_workarounds.py +0 -85
- {http2py-0.1.34 → http2py-0.1.35}/.gitattributes +0 -0
- {http2py-0.1.34 → http2py-0.1.35}/.github/workflows/ci.yml +0 -0
- {http2py-0.1.34 → http2py-0.1.35}/.gitignore +0 -0
- {http2py-0.1.34 → http2py-0.1.35}/LICENSE +0 -0
- {http2py-0.1.34 → http2py-0.1.35}/README.md +0 -0
- {http2py-0.1.34 → http2py-0.1.35}/conftest.py +0 -0
- {http2py-0.1.34 → http2py-0.1.35}/http2py/__init__.py +0 -0
- {http2py-0.1.34 → http2py-0.1.35}/http2py/api_pkg_maker.py +0 -0
- {http2py-0.1.34 → http2py-0.1.35}/http2py/authentication.py +0 -0
- {http2py-0.1.34 → http2py-0.1.35}/http2py/cli_maker.py +0 -0
- {http2py-0.1.34 → http2py-0.1.35}/http2py/constants.py +0 -0
- {http2py-0.1.34 → http2py-0.1.35}/http2py/default_configs.py +0 -0
- {http2py-0.1.34 → http2py-0.1.35}/http2py/example_cli.py +0 -0
- {http2py-0.1.34 → http2py-0.1.35}/http2py/global_state.py +0 -0
- {http2py-0.1.34 → http2py-0.1.35}/http2py/http2py_cls.py +0 -0
- {http2py-0.1.34 → http2py-0.1.35}/http2py/resources/module_maker/func_tpl.txt +0 -0
- {http2py-0.1.34 → http2py-0.1.35}/http2py/testing_utils.py +0 -0
- {http2py-0.1.34 → http2py-0.1.35}/http2py/tests/__init__.py +0 -0
- {http2py-0.1.34 → http2py-0.1.35}/http2py/tests/api_pkg_maker_test.py +0 -0
- {http2py-0.1.34 → http2py-0.1.35}/http2py/tests/conftest.py +0 -0
- {http2py-0.1.34 → http2py-0.1.35}/http2py/tests/test_requirements.txt +0 -0
- {http2py-0.1.34 → http2py-0.1.35}/http2py/util.py +0 -0
- {http2py-0.1.34 → http2py-0.1.35}/tests/cli_goldens/mk_cli.json +0 -0
- {http2py-0.1.34 → http2py-0.1.35}/tests/spec_fixture.py +0 -0
- {http2py-0.1.34 → http2py-0.1.35}/tests/test_ci_collection_contract.py +0 -0
- {http2py-0.1.34 → http2py-0.1.35}/tests/test_cli_surface.py +0 -0
- {http2py-0.1.34 → http2py-0.1.35}/tests/test_smoke.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: http2py
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.35
|
|
4
4
|
Summary: Tools to create python binders to http web services.
|
|
5
5
|
Project-URL: Homepage, https://github.com/i2mint/http2py
|
|
6
6
|
Project-URL: Repository, https://github.com/i2mint/http2py
|
|
@@ -17,7 +17,6 @@ Classifier: Topic :: Internet :: WWW/HTTP
|
|
|
17
17
|
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
18
18
|
Requires-Python: >=3.10
|
|
19
19
|
Requires-Dist: cw<0.2,>=0.1.1
|
|
20
|
-
Requires-Dist: dill
|
|
21
20
|
Requires-Dist: glom
|
|
22
21
|
Requires-Dist: i2
|
|
23
22
|
Requires-Dist: importlib-resources
|
|
@@ -10,9 +10,11 @@ from i2.errors import AuthorizationError
|
|
|
10
10
|
|
|
11
11
|
from http2py.authentication import mk_auth, DFLT_CONFIG_FILENAME
|
|
12
12
|
from http2py.py2request import (
|
|
13
|
+
DFLT_BINARY_DUMPS,
|
|
13
14
|
mk_method_spec_from_openapi_method_spec,
|
|
14
15
|
mk_request_function,
|
|
15
16
|
)
|
|
17
|
+
from http2py.decorators import check_loads
|
|
16
18
|
from http2py.global_state import get_global_state
|
|
17
19
|
|
|
18
20
|
|
|
@@ -37,6 +39,9 @@ class HttpClient:
|
|
|
37
39
|
session_state=None,
|
|
38
40
|
base_url=None,
|
|
39
41
|
verify_cert=True,
|
|
42
|
+
*,
|
|
43
|
+
binary_loads=None,
|
|
44
|
+
binary_dumps=DFLT_BINARY_DUMPS,
|
|
40
45
|
**auth_kwargs,
|
|
41
46
|
):
|
|
42
47
|
"""
|
|
@@ -47,6 +52,13 @@ class HttpClient:
|
|
|
47
52
|
:param session: A session for HTTP requests
|
|
48
53
|
:param base_url: A base url to enforce if you don't want to take the base url from the OpenAPI spec
|
|
49
54
|
:param verify_cert: If True, it verifies the SSL certificate validity for each https request.
|
|
55
|
+
:param binary_loads: How to decode binary (octet-stream) responses. By
|
|
56
|
+
default they are NOT unpickled (that would let the server run code on
|
|
57
|
+
this client) and raise ``BinaryResponseNotDecoded``; pass ``bytes``,
|
|
58
|
+
a decoder of your own, or ``http2py.decorators.unsafe_pickle_loads``
|
|
59
|
+
if the server is fully trusted.
|
|
60
|
+
:param binary_dumps: How to encode binary request bodies (default
|
|
61
|
+
``pickle.dumps``).
|
|
50
62
|
|
|
51
63
|
:Keyword Arguments:
|
|
52
64
|
* *api_key*
|
|
@@ -56,6 +68,9 @@ class HttpClient:
|
|
|
56
68
|
|
|
57
69
|
"""
|
|
58
70
|
self.verify_cert = verify_cert
|
|
71
|
+
check_loads(binary_loads)
|
|
72
|
+
self.binary_loads = binary_loads
|
|
73
|
+
self.binary_dumps = binary_dumps
|
|
59
74
|
if url and not openapi_spec:
|
|
60
75
|
openapi_spec = get(url, verify=self.verify_cert).json()
|
|
61
76
|
self.openapi_spec = openapi_spec
|
|
@@ -125,7 +140,11 @@ class HttpClient:
|
|
|
125
140
|
content_type=content_type,
|
|
126
141
|
)
|
|
127
142
|
func = mk_request_function(
|
|
128
|
-
method_spec,
|
|
143
|
+
method_spec,
|
|
144
|
+
dispatch=self.handle_request,
|
|
145
|
+
verify_cert=self.verify_cert,
|
|
146
|
+
binary_loads=self.binary_loads,
|
|
147
|
+
binary_dumps=self.binary_dumps,
|
|
129
148
|
)
|
|
130
149
|
func.method_spec = method_spec
|
|
131
150
|
func.content_type = content_type
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""Response handling: map http status codes to exceptions, decode the payload.
|
|
2
|
+
|
|
3
|
+
``handle_json_resp`` / ``handle_binary_resp`` / ``handle_raw_resp`` wrap an
|
|
4
|
+
output transformation so it only ever sees a successful response of the
|
|
5
|
+
expected content type.
|
|
6
|
+
|
|
7
|
+
Binary (``application/octet-stream``) responses are never unpickled unless asked:
|
|
8
|
+
unpickling runs code chosen by whoever produced the bytes, so a spoofed or
|
|
9
|
+
compromised server could run code on the client. Pass ``loads=`` to
|
|
10
|
+
``handle_binary_resp`` (or ``binary_loads=`` to ``mk_request_function`` /
|
|
11
|
+
``HttpClient``): ``bytes`` for the raw content, a decoder of your own, or
|
|
12
|
+
``unsafe_pickle_loads`` if (and only if) the server is fully trusted.
|
|
13
|
+
|
|
14
|
+
>>> class Resp:
|
|
15
|
+
... status_code, content = 200, b'raw bytes'
|
|
16
|
+
>>> handle_binary_resp(lambda x: x, loads=bytes)(Resp())
|
|
17
|
+
b'raw bytes'
|
|
18
|
+
>>> handle_binary_resp(lambda x: x)(Resp()) # doctest: +ELLIPSIS
|
|
19
|
+
Traceback (most recent call last):
|
|
20
|
+
...
|
|
21
|
+
http2py.decorators.BinaryResponseNotDecoded: Got a binary response but no loads=... to decode it. ...
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
import os
|
|
25
|
+
import pickle
|
|
26
|
+
import sys
|
|
27
|
+
import warnings
|
|
28
|
+
from collections.abc import Callable
|
|
29
|
+
from functools import partial
|
|
30
|
+
from typing import Any, Optional
|
|
31
|
+
|
|
32
|
+
from i2.errors import (
|
|
33
|
+
AuthorizationError,
|
|
34
|
+
ForbiddenError,
|
|
35
|
+
InputError,
|
|
36
|
+
NotFoundError,
|
|
37
|
+
DuplicateRecordError,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
from http2py.constants import BINARY_CONTENT_TYPE, JSON_CONTENT_TYPE, RAW_CONTENT_TYPE
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _handle_error(resp):
|
|
44
|
+
if resp.status_code == 400:
|
|
45
|
+
if resp.reason == "AuthorizationError":
|
|
46
|
+
raise AuthorizationError(resp.text)
|
|
47
|
+
if resp.reason == "InputError":
|
|
48
|
+
raise InputError(resp.text)
|
|
49
|
+
if resp.reason == "DuplicateRecordError":
|
|
50
|
+
raise DuplicateRecordError(resp.text)
|
|
51
|
+
if resp.status_code == 403:
|
|
52
|
+
raise ForbiddenError(resp.text)
|
|
53
|
+
if resp.status_code == 404:
|
|
54
|
+
raise NotFoundError(resp.text)
|
|
55
|
+
raise RuntimeError(resp.text)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class BinaryResponseNotDecoded(ValueError):
|
|
59
|
+
"""A binary response arrived, but no ``loads`` was given to decode it."""
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def unsafe_pickle_loads(data: bytes) -> Any:
|
|
63
|
+
"""Unpickle ``data``. UNSAFE on anything an untrusted server can send.
|
|
64
|
+
|
|
65
|
+
Unpickling runs code chosen by whoever produced the bytes, so use this only
|
|
66
|
+
when the server (and the network path to it) is fully trusted. It exists so
|
|
67
|
+
that opting into pickled responses is explicit and visible at the call site::
|
|
68
|
+
|
|
69
|
+
HttpClient(spec, binary_loads=unsafe_pickle_loads)
|
|
70
|
+
"""
|
|
71
|
+
return pickle.loads(data)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
_PACKAGE_DIR = os.path.dirname(os.path.abspath(__file__)) + os.sep
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _stacklevel_outside_package() -> int:
|
|
78
|
+
"""The ``stacklevel`` (for a ``warn`` called by our caller) of the first frame
|
|
79
|
+
outside http2py, so a warning points at the user's line, however deep in
|
|
80
|
+
http2py it was raised; and repeated checks of one call collapse into one."""
|
|
81
|
+
frame, level = sys._getframe(1), 1
|
|
82
|
+
while frame is not None and frame.f_code.co_filename.startswith(_PACKAGE_DIR):
|
|
83
|
+
frame, level = frame.f_back, level + 1
|
|
84
|
+
return level
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def check_loads(loads) -> None:
|
|
88
|
+
"""Validate a ``loads=`` / ``binary_loads=`` argument: ``None`` or a callable.
|
|
89
|
+
|
|
90
|
+
``pickle.loads`` itself gets a warning suggesting ``unsafe_pickle_loads``.
|
|
91
|
+
That is advice, not a guard: any other callable (a ``partial`` of
|
|
92
|
+
``pickle.loads``, ``dill.loads``, ...) is trusted as given.
|
|
93
|
+
"""
|
|
94
|
+
if loads is None:
|
|
95
|
+
return
|
|
96
|
+
if not callable(loads):
|
|
97
|
+
raise TypeError(f"loads must be callable, got {loads!r}")
|
|
98
|
+
if loads is pickle.loads:
|
|
99
|
+
warnings.warn(
|
|
100
|
+
"loads=pickle.loads unpickles server data, which lets a spoofed or "
|
|
101
|
+
"compromised server run code on this client. Use "
|
|
102
|
+
"loads=unsafe_pickle_loads to make that choice explicit, and only if "
|
|
103
|
+
"the server is fully trusted.",
|
|
104
|
+
stacklevel=_stacklevel_outside_package(),
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _handle_resp(func, content_type, *, loads: Optional[Callable[[bytes], Any]] = None):
|
|
109
|
+
check_loads(loads)
|
|
110
|
+
|
|
111
|
+
def output_trans(resp):
|
|
112
|
+
if resp.status_code == 200:
|
|
113
|
+
if content_type == JSON_CONTENT_TYPE:
|
|
114
|
+
output = resp.json()
|
|
115
|
+
elif content_type == BINARY_CONTENT_TYPE:
|
|
116
|
+
if loads is None:
|
|
117
|
+
raise BinaryResponseNotDecoded(
|
|
118
|
+
"Got a binary response but no loads=... to decode it. "
|
|
119
|
+
"Binary responses are no longer unpickled by default, "
|
|
120
|
+
"since unpickling lets the server run code on this "
|
|
121
|
+
"client. Pass binary_loads=bytes for the raw content, a "
|
|
122
|
+
"decoder of your own, or binary_loads=unsafe_pickle_loads "
|
|
123
|
+
"(from http2py.decorators) if the server is fully trusted."
|
|
124
|
+
)
|
|
125
|
+
output = loads(resp.content)
|
|
126
|
+
elif content_type == RAW_CONTENT_TYPE:
|
|
127
|
+
output = resp.text
|
|
128
|
+
else:
|
|
129
|
+
raise NotImplementedError(
|
|
130
|
+
f"Response of type {content_type} is not supported yet."
|
|
131
|
+
)
|
|
132
|
+
return func(output)
|
|
133
|
+
else:
|
|
134
|
+
_handle_error(resp)
|
|
135
|
+
|
|
136
|
+
output_trans.content_type = content_type
|
|
137
|
+
return output_trans
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
handle_json_resp = partial(_handle_resp, content_type=JSON_CONTENT_TYPE)
|
|
141
|
+
handle_binary_resp = partial(_handle_resp, content_type=BINARY_CONTENT_TYPE)
|
|
142
|
+
handle_raw_resp = partial(_handle_resp, content_type=RAW_CONTENT_TYPE)
|
|
@@ -54,12 +54,19 @@ from http2py.constants import (
|
|
|
54
54
|
)
|
|
55
55
|
|
|
56
56
|
from http2py.util import I2mintModuleNotFoundErrorNiceMessage, is_jsonable
|
|
57
|
+
from http2py.decorators import check_loads, handle_binary_resp
|
|
57
58
|
from http2py.default_configs import (
|
|
58
59
|
default_json_output_trans,
|
|
59
60
|
default_text_output_trans,
|
|
60
|
-
|
|
61
|
+
_default_output_trans,
|
|
61
62
|
)
|
|
62
63
|
|
|
64
|
+
# Binary request bodies are pickled by default. That is safe for this client (it
|
|
65
|
+
# only serializes its own arguments), but a server must opt into unpickling them
|
|
66
|
+
# (py2http's ``handle_binary_req(..., loads=unsafe_pickle_loads)``), so pass
|
|
67
|
+
# ``binary_dumps=`` to match whatever decoder the server uses.
|
|
68
|
+
DFLT_BINARY_DUMPS = pickle.dumps
|
|
69
|
+
|
|
63
70
|
# TODO: Do we really need this anymore?
|
|
64
71
|
with I2mintModuleNotFoundErrorNiceMessage():
|
|
65
72
|
from i2.util import inject_method, imdict
|
|
@@ -130,7 +137,13 @@ def mk_param_spec_from_arg_schema(arg, required=False):
|
|
|
130
137
|
# TODO: Deprecated output_trans in favor of egress, for consistency with other i2imint?
|
|
131
138
|
# TODO: Use method_spec object that has more schema, signature, and validation.
|
|
132
139
|
def mk_request_function(
|
|
133
|
-
method_spec,
|
|
140
|
+
method_spec,
|
|
141
|
+
*,
|
|
142
|
+
function_kind="method",
|
|
143
|
+
dispatch=request,
|
|
144
|
+
verify_cert=True,
|
|
145
|
+
binary_loads=None,
|
|
146
|
+
binary_dumps=DFLT_BINARY_DUMPS,
|
|
134
147
|
):
|
|
135
148
|
"""
|
|
136
149
|
Makes function that will make http requests for you, on your own terms.
|
|
@@ -165,11 +178,19 @@ def mk_request_function(
|
|
|
165
178
|
- 'wraps': A function whose signature we should use as the output's function's signature
|
|
166
179
|
|
|
167
180
|
:param method_spec: Specification of how to convert arguments of the function that is being made to an http request.
|
|
181
|
+
:param binary_loads: How to decode a binary (octet-stream) response, when
|
|
182
|
+
``method_spec`` has no ``output_trans``. ``None`` (the default) raises
|
|
183
|
+
``BinaryResponseNotDecoded`` on such a response rather than unpickling
|
|
184
|
+
it; pass ``bytes`` for the raw content, a decoder of your own, or
|
|
185
|
+
``http2py.decorators.unsafe_pickle_loads`` if the server is fully trusted.
|
|
186
|
+
:param binary_dumps: How to encode the arguments of a binary request body
|
|
187
|
+
(default ``pickle.dumps``; see ``DFLT_BINARY_DUMPS``).
|
|
168
188
|
:return: A function.
|
|
169
189
|
Note: I say "function", but the function is meant to be a method, so the function has a self as first argument.
|
|
170
190
|
That argument is ignored.
|
|
171
191
|
|
|
172
192
|
"""
|
|
193
|
+
check_loads(binary_loads)
|
|
173
194
|
if isinstance(method_spec, str):
|
|
174
195
|
# If method_spec is a string, assume it's a url_template with a get method
|
|
175
196
|
# like 'http://myapi.com/search?q={search_term}'
|
|
@@ -202,7 +223,7 @@ def mk_request_function(
|
|
|
202
223
|
elif response_type == JSON_CONTENT_TYPE:
|
|
203
224
|
output_trans = default_json_output_trans
|
|
204
225
|
else:
|
|
205
|
-
output_trans =
|
|
226
|
+
output_trans = handle_binary_resp(_default_output_trans, loads=binary_loads)
|
|
206
227
|
|
|
207
228
|
wraps_func = method_spec.pop("wraps", None)
|
|
208
229
|
|
|
@@ -261,7 +282,7 @@ def mk_request_function(
|
|
|
261
282
|
)
|
|
262
283
|
_request_kwargs["data"] = json.dumps(json_kwargs)
|
|
263
284
|
elif content_type == BINARY_CONTENT_TYPE:
|
|
264
|
-
_request_kwargs["data"] =
|
|
285
|
+
_request_kwargs["data"] = binary_dumps(kwargs)
|
|
265
286
|
elif content_type == FORM_CONTENT_TYPE:
|
|
266
287
|
fields = json.dumps(json_kwargs).encode("utf-8")
|
|
267
288
|
binaries = {k: (f"{k}.bin", v) for k, v in binary_kwargs.items()}
|
|
@@ -396,7 +417,15 @@ class Py2Request:
|
|
|
396
417
|
self._inject_method(method_name, method_spec, method_func_from_method_spec)
|
|
397
418
|
|
|
398
419
|
def _process_method_specs(self):
|
|
399
|
-
|
|
420
|
+
# (also when it is a functools.partial of it, e.g. with binary_loads=...)
|
|
421
|
+
if (
|
|
422
|
+
getattr(
|
|
423
|
+
self._dflt_method_func_from_method_spec,
|
|
424
|
+
"func",
|
|
425
|
+
self._dflt_method_func_from_method_spec,
|
|
426
|
+
)
|
|
427
|
+
is mk_request_function
|
|
428
|
+
): # TODO: WTF!
|
|
400
429
|
|
|
401
430
|
def gen():
|
|
402
431
|
for method_name, method_spec in self._method_specs.items():
|
|
@@ -702,6 +731,8 @@ def mk_request_func_from_openapi_spec(
|
|
|
702
731
|
content_type=JSON_CONTENT_TYPE,
|
|
703
732
|
input_trans=None,
|
|
704
733
|
output_trans=None,
|
|
734
|
+
binary_loads=None,
|
|
735
|
+
binary_dumps=DFLT_BINARY_DUMPS,
|
|
705
736
|
):
|
|
706
737
|
base_url = openapi_spec["servers"][0]["url"]
|
|
707
738
|
if base_url.endswith("/"):
|
|
@@ -719,7 +750,12 @@ def mk_request_func_from_openapi_spec(
|
|
|
719
750
|
output_trans=output_trans,
|
|
720
751
|
)
|
|
721
752
|
|
|
722
|
-
func = mk_request_function(
|
|
753
|
+
func = mk_request_function(
|
|
754
|
+
method_spec,
|
|
755
|
+
function_kind="function",
|
|
756
|
+
binary_loads=binary_loads,
|
|
757
|
+
binary_dumps=binary_dumps,
|
|
758
|
+
)
|
|
723
759
|
openapi_props = glom(
|
|
724
760
|
spec, f"requestBody.content.{content_type}.schema.properties", default={}
|
|
725
761
|
)
|
|
@@ -780,10 +816,13 @@ def openapi_to_py(
|
|
|
780
816
|
path_to_func_name: Callable[[str, str], str] = default_path_to_func_name,
|
|
781
817
|
servers: list | None = None,
|
|
782
818
|
default_server_url: str = "http://localhost:8000",
|
|
819
|
+
binary_loads=None,
|
|
820
|
+
binary_dumps=DFLT_BINARY_DUMPS,
|
|
783
821
|
) -> dict[str, Callable]:
|
|
784
822
|
"""
|
|
785
823
|
Given an OpenAPI spec dict, returns a dict mapping function names to request functions.
|
|
786
824
|
The `servers` argument overrides or provides a default for the OpenAPI servers field.
|
|
825
|
+
`binary_loads` / `binary_dumps` are passed to `mk_request_function`.
|
|
787
826
|
"""
|
|
788
827
|
# Patch the spec with servers if needed
|
|
789
828
|
patched_spec = dict(openapi_spec)
|
|
@@ -799,7 +838,13 @@ def openapi_to_py(
|
|
|
799
838
|
result = {}
|
|
800
839
|
for method, uri in paths:
|
|
801
840
|
func_name = path_to_func_name(method, uri)
|
|
802
|
-
func = mk_request_func_from_openapi_spec(
|
|
841
|
+
func = mk_request_func_from_openapi_spec(
|
|
842
|
+
uri,
|
|
843
|
+
patched_spec,
|
|
844
|
+
method=method,
|
|
845
|
+
binary_loads=binary_loads,
|
|
846
|
+
binary_dumps=binary_dumps,
|
|
847
|
+
)
|
|
803
848
|
result[func_name] = func
|
|
804
849
|
return result
|
|
805
850
|
|
|
@@ -6,7 +6,7 @@ build-backend = "hatchling.build"
|
|
|
6
6
|
|
|
7
7
|
[project]
|
|
8
8
|
name = "http2py"
|
|
9
|
-
version = "0.1.
|
|
9
|
+
version = "0.1.35"
|
|
10
10
|
description = "Tools to create python binders to http web services."
|
|
11
11
|
readme = "README.md"
|
|
12
12
|
requires-python = ">=3.10"
|
|
@@ -37,12 +37,6 @@ dependencies = [
|
|
|
37
37
|
"cw>=0.1.1,<0.2",
|
|
38
38
|
"PyYAML",
|
|
39
39
|
"importlib_resources",
|
|
40
|
-
# TEMPORARY, not imported by http2py: ju/oas.py has an unconditional (and
|
|
41
|
-
# unused) `import dill` while not declaring dill, so a cold `pip install
|
|
42
|
-
# http2py` dies on `import ju`. Tracked upstream as i2mint/ju#6. Remove this
|
|
43
|
-
# line once a fixed ju is released -- tests/test_dependency_workarounds.py
|
|
44
|
-
# fails as soon as that happens, so it will not be forgotten.
|
|
45
|
-
"dill",
|
|
46
40
|
]
|
|
47
41
|
|
|
48
42
|
[project.urls]
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
"""Binary (octet-stream) responses are never unpickled unless explicitly asked."""
|
|
2
|
+
|
|
3
|
+
import pickle
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
from http2py import HttpClient, mk_request_function
|
|
8
|
+
from http2py.constants import BINARY_CONTENT_TYPE, JSON_CONTENT_TYPE
|
|
9
|
+
from http2py.decorators import (
|
|
10
|
+
BinaryResponseNotDecoded,
|
|
11
|
+
handle_binary_resp,
|
|
12
|
+
unsafe_pickle_loads,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
_calls = []
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _record(*args):
|
|
19
|
+
_calls.append(args)
|
|
20
|
+
return "ran"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class _RunsCodeWhenUnpickled:
|
|
24
|
+
"""Unpickling this calls ``_record``: a harmless stand-in for a payload."""
|
|
25
|
+
|
|
26
|
+
def __reduce__(self):
|
|
27
|
+
return (_record, ("unpickled",))
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
EVIL = pickle.dumps(_RunsCodeWhenUnpickled())
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class _Resp:
|
|
34
|
+
status_code = 200
|
|
35
|
+
text = ""
|
|
36
|
+
|
|
37
|
+
def __init__(self, content):
|
|
38
|
+
self.content = content
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _binary_spec(**extra):
|
|
42
|
+
return dict(
|
|
43
|
+
method="post",
|
|
44
|
+
url="http://test.invalid/f",
|
|
45
|
+
content_type=BINARY_CONTENT_TYPE,
|
|
46
|
+
response_type=BINARY_CONTENT_TYPE,
|
|
47
|
+
body_arg_names=["x"],
|
|
48
|
+
**extra,
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _fake_dispatch(content, sent):
|
|
53
|
+
def dispatch(method, url, **request_kwargs):
|
|
54
|
+
sent.append(request_kwargs)
|
|
55
|
+
return _Resp(content)
|
|
56
|
+
|
|
57
|
+
return dispatch
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def test_binary_response_is_not_unpickled_by_default():
|
|
61
|
+
_calls.clear()
|
|
62
|
+
f = mk_request_function(
|
|
63
|
+
_binary_spec(), function_kind="function", dispatch=_fake_dispatch(EVIL, [])
|
|
64
|
+
)
|
|
65
|
+
with pytest.raises(BinaryResponseNotDecoded, match="unsafe_pickle_loads"):
|
|
66
|
+
f(x=1)
|
|
67
|
+
assert _calls == []
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def test_binary_loads_choices():
|
|
71
|
+
_calls.clear()
|
|
72
|
+
raw = mk_request_function(
|
|
73
|
+
_binary_spec(),
|
|
74
|
+
function_kind="function",
|
|
75
|
+
dispatch=_fake_dispatch(EVIL, []),
|
|
76
|
+
binary_loads=bytes,
|
|
77
|
+
)
|
|
78
|
+
assert raw(x=1) == EVIL and _calls == []
|
|
79
|
+
|
|
80
|
+
trusted = mk_request_function(
|
|
81
|
+
_binary_spec(),
|
|
82
|
+
function_kind="function",
|
|
83
|
+
dispatch=_fake_dispatch(pickle.dumps({"a": 1}), []),
|
|
84
|
+
binary_loads=unsafe_pickle_loads,
|
|
85
|
+
)
|
|
86
|
+
assert trusted(x=1) == {"a": 1}
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def test_bare_pickle_loads_warns_and_non_callable_is_rejected():
|
|
90
|
+
with pytest.warns(UserWarning, match="unsafe_pickle_loads"):
|
|
91
|
+
handle_binary_resp(lambda x: x, loads=pickle.loads)
|
|
92
|
+
with pytest.raises(TypeError):
|
|
93
|
+
handle_binary_resp(lambda x: x, loads="pickle")
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def test_binary_dumps_encodes_request_body():
|
|
97
|
+
sent = []
|
|
98
|
+
f = mk_request_function(
|
|
99
|
+
_binary_spec(),
|
|
100
|
+
function_kind="function",
|
|
101
|
+
dispatch=_fake_dispatch(b"ok", sent),
|
|
102
|
+
binary_loads=bytes,
|
|
103
|
+
binary_dumps=lambda kwargs: repr(sorted(kwargs.items())).encode(),
|
|
104
|
+
)
|
|
105
|
+
f(x=1)
|
|
106
|
+
assert sent[-1]["data"] == b"[('x', 1)]"
|
|
107
|
+
|
|
108
|
+
sent.clear()
|
|
109
|
+
mk_request_function(
|
|
110
|
+
_binary_spec(),
|
|
111
|
+
function_kind="function",
|
|
112
|
+
dispatch=_fake_dispatch(b"ok", sent),
|
|
113
|
+
binary_loads=bytes,
|
|
114
|
+
)(x=1)
|
|
115
|
+
assert pickle.loads(sent[-1]["data"]) == {"x": 1} # default stays pickle
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def test_http_client_passes_binary_loads_through():
|
|
119
|
+
spec = {
|
|
120
|
+
"openapi": "3.0.3",
|
|
121
|
+
"info": {"title": "t", "version": "1"},
|
|
122
|
+
"servers": [{"url": "http://test.invalid"}],
|
|
123
|
+
"paths": {
|
|
124
|
+
"/f": {
|
|
125
|
+
"post": {
|
|
126
|
+
"x-method_name": "f",
|
|
127
|
+
"requestBody": {
|
|
128
|
+
"content": {
|
|
129
|
+
JSON_CONTENT_TYPE: {
|
|
130
|
+
"schema": {
|
|
131
|
+
"type": "object",
|
|
132
|
+
"properties": {"x": {"type": "integer"}},
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
},
|
|
137
|
+
"responses": {"200": {"content": {BINARY_CONTENT_TYPE: {}}}},
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
},
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
def client(**kwargs):
|
|
144
|
+
c = HttpClient(
|
|
145
|
+
spec, session_state={"session": object(), "refresh_inputs": {}}, **kwargs
|
|
146
|
+
)
|
|
147
|
+
c.handle_request = _fake_dispatch(EVIL, [])
|
|
148
|
+
c.register_method("http://test.invalid/f", "post", spec["paths"]["/f"]["post"])
|
|
149
|
+
return c
|
|
150
|
+
|
|
151
|
+
_calls.clear()
|
|
152
|
+
with pytest.raises(BinaryResponseNotDecoded):
|
|
153
|
+
client().f(x=1)
|
|
154
|
+
assert client(binary_loads=bytes).f(x=1) == EVIL
|
|
155
|
+
assert _calls == []
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def test_pickle_loads_warning_points_at_the_callers_line():
|
|
159
|
+
import warnings
|
|
160
|
+
|
|
161
|
+
with warnings.catch_warnings(record=True) as caught:
|
|
162
|
+
warnings.simplefilter("always")
|
|
163
|
+
mk_request_function(
|
|
164
|
+
_binary_spec(), function_kind="function", binary_loads=pickle.loads
|
|
165
|
+
)
|
|
166
|
+
assert caught and all(w.filename == __file__ for w in caught)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def test_bad_binary_loads_is_rejected_even_without_binary_endpoints():
|
|
170
|
+
with pytest.raises(TypeError):
|
|
171
|
+
mk_request_function({"url": "http://test.invalid"}, binary_loads="x")
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def test_py2request_accepts_a_partial_of_mk_request_function():
|
|
175
|
+
from functools import partial
|
|
176
|
+
|
|
177
|
+
from http2py.py2request import Py2Request
|
|
178
|
+
|
|
179
|
+
sent = []
|
|
180
|
+
p = Py2Request(
|
|
181
|
+
{"get": "http://test.invalid/{x}"},
|
|
182
|
+
method_func_from_method_spec=partial(
|
|
183
|
+
mk_request_function,
|
|
184
|
+
dispatch=_fake_dispatch(b"raw", sent),
|
|
185
|
+
binary_loads=bytes,
|
|
186
|
+
),
|
|
187
|
+
)
|
|
188
|
+
p.get(x=1)
|
|
189
|
+
assert sent
|
|
@@ -1,59 +0,0 @@
|
|
|
1
|
-
"""Response handling: map http status codes to exceptions, decode the payload.
|
|
2
|
-
|
|
3
|
-
``handle_json_resp`` / ``handle_binary_resp`` / ``handle_raw_resp`` wrap an
|
|
4
|
-
output transformation so it only ever sees a successful response of the
|
|
5
|
-
expected content type.
|
|
6
|
-
"""
|
|
7
|
-
|
|
8
|
-
from functools import partial
|
|
9
|
-
from i2.errors import (
|
|
10
|
-
AuthorizationError,
|
|
11
|
-
ForbiddenError,
|
|
12
|
-
InputError,
|
|
13
|
-
NotFoundError,
|
|
14
|
-
DuplicateRecordError,
|
|
15
|
-
)
|
|
16
|
-
import pickle
|
|
17
|
-
|
|
18
|
-
from http2py.constants import BINARY_CONTENT_TYPE, JSON_CONTENT_TYPE, RAW_CONTENT_TYPE
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
def _handle_error(resp):
|
|
22
|
-
if resp.status_code == 400:
|
|
23
|
-
if resp.reason == "AuthorizationError":
|
|
24
|
-
raise AuthorizationError(resp.text)
|
|
25
|
-
if resp.reason == "InputError":
|
|
26
|
-
raise InputError(resp.text)
|
|
27
|
-
if resp.reason == "DuplicateRecordError":
|
|
28
|
-
raise DuplicateRecordError(resp.text)
|
|
29
|
-
if resp.status_code == 403:
|
|
30
|
-
raise ForbiddenError(resp.text)
|
|
31
|
-
if resp.status_code == 404:
|
|
32
|
-
raise NotFoundError(resp.text)
|
|
33
|
-
raise RuntimeError(resp.text)
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
def _handle_resp(func, content_type):
|
|
37
|
-
def output_trans(resp):
|
|
38
|
-
if resp.status_code == 200:
|
|
39
|
-
if content_type == JSON_CONTENT_TYPE:
|
|
40
|
-
output = resp.json()
|
|
41
|
-
elif content_type == BINARY_CONTENT_TYPE:
|
|
42
|
-
output = pickle.loads(resp.content)
|
|
43
|
-
elif content_type == RAW_CONTENT_TYPE:
|
|
44
|
-
output = resp.text
|
|
45
|
-
else:
|
|
46
|
-
raise NotImplementedError(
|
|
47
|
-
f"Response of type {content_type} is not supported yet."
|
|
48
|
-
)
|
|
49
|
-
return func(output)
|
|
50
|
-
else:
|
|
51
|
-
_handle_error(resp)
|
|
52
|
-
|
|
53
|
-
output_trans.content_type = content_type
|
|
54
|
-
return output_trans
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
handle_json_resp = partial(_handle_resp, content_type=JSON_CONTENT_TYPE)
|
|
58
|
-
handle_binary_resp = partial(_handle_resp, content_type=BINARY_CONTENT_TYPE)
|
|
59
|
-
handle_raw_resp = partial(_handle_resp, content_type=RAW_CONTENT_TYPE)
|
|
@@ -1,85 +0,0 @@
|
|
|
1
|
-
"""Tripwires for dependencies http2py declares but does not itself use.
|
|
2
|
-
|
|
3
|
-
Right now there is exactly one: ``dill``. ``ju/oas.py`` imports it at module
|
|
4
|
-
level while ``ju`` does not declare it, and ``ju/__init__.py`` imports ``ju.oas``
|
|
5
|
-
eagerly -- so a cold ``pip install http2py`` dies on ``import ju`` with
|
|
6
|
-
``ModuleNotFoundError: No module named 'dill'``. Declaring ``dill`` here is a
|
|
7
|
-
workaround, tracked upstream as i2mint/ju#6.
|
|
8
|
-
|
|
9
|
-
A workaround with no expiry becomes permanent, so the test below fails the
|
|
10
|
-
moment upstream is fixed (either the import goes away or ``dill`` becomes a
|
|
11
|
-
declared requirement of ``ju``). When it fails, drop ``dill`` from
|
|
12
|
-
``[project].dependencies`` and delete this module.
|
|
13
|
-
|
|
14
|
-
The check is deliberately static -- it reads ``ju``'s source and metadata rather
|
|
15
|
-
than re-importing ``ju`` with ``dill`` hidden, so it cannot leave a half-imported
|
|
16
|
-
package behind for the rest of the session.
|
|
17
|
-
"""
|
|
18
|
-
|
|
19
|
-
import ast
|
|
20
|
-
import re
|
|
21
|
-
import sys
|
|
22
|
-
from importlib.metadata import PackageNotFoundError, requires
|
|
23
|
-
from pathlib import Path
|
|
24
|
-
|
|
25
|
-
import pytest
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
def _ju_oas_source():
|
|
29
|
-
import ju.oas
|
|
30
|
-
|
|
31
|
-
return Path(ju.oas.__file__).read_text()
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
def _module_level_imports(source):
|
|
35
|
-
"""Top-level (non-nested) imported top-level module names."""
|
|
36
|
-
names = set()
|
|
37
|
-
for node in ast.parse(source).body:
|
|
38
|
-
if isinstance(node, ast.Import):
|
|
39
|
-
names.update(alias.name.split(".")[0] for alias in node.names)
|
|
40
|
-
elif isinstance(node, ast.ImportFrom) and node.level == 0 and node.module:
|
|
41
|
-
names.add(node.module.split(".")[0])
|
|
42
|
-
return names
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
def _declared_requirement_names(distribution):
|
|
46
|
-
try:
|
|
47
|
-
reqs = requires(distribution) or []
|
|
48
|
-
except PackageNotFoundError: # pragma: no cover - ju is a hard dependency
|
|
49
|
-
pytest.skip(f"{distribution} is not installed as a distribution")
|
|
50
|
-
return {re.split(r"[\s\[<>=!;(]", req, maxsplit=1)[0].lower() for req in reqs}
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
def test_dill_workaround_is_still_needed():
|
|
54
|
-
"""Fails once i2mint/ju#6 is released -- then remove the `dill` dependency."""
|
|
55
|
-
imports_dill = "dill" in _module_level_imports(_ju_oas_source())
|
|
56
|
-
ju_declares_dill = "dill" in _declared_requirement_names("ju")
|
|
57
|
-
|
|
58
|
-
assert imports_dill and not ju_declares_dill, (
|
|
59
|
-
"ju no longer needs http2py to carry `dill` for it "
|
|
60
|
-
f"(module-level import: {imports_dill}, declared by ju: {ju_declares_dill}). "
|
|
61
|
-
"Remove `dill` from [project].dependencies in pyproject.toml and delete "
|
|
62
|
-
"this module. See i2mint/ju#6."
|
|
63
|
-
)
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
@pytest.mark.skipif(sys.version_info < (3, 11), reason="tomllib needs Python 3.11+")
|
|
67
|
-
def test_dill_is_declared_while_the_workaround_stands():
|
|
68
|
-
"""The workaround only works if the dependency is actually declared.
|
|
69
|
-
|
|
70
|
-
Read from pyproject.toml rather than installed metadata: an editable install
|
|
71
|
-
freezes its .dist-info at install time, so the metadata of a dev checkout
|
|
72
|
-
lags the file that CI actually builds from.
|
|
73
|
-
"""
|
|
74
|
-
import tomllib
|
|
75
|
-
|
|
76
|
-
repo_root = Path(__file__).resolve().parent.parent
|
|
77
|
-
pyproject = tomllib.loads((repo_root / "pyproject.toml").read_text())
|
|
78
|
-
declared = {
|
|
79
|
-
re.split(r"[\s\[<>=!;(]", dep, maxsplit=1)[0].lower()
|
|
80
|
-
for dep in pyproject["project"]["dependencies"]
|
|
81
|
-
}
|
|
82
|
-
assert "dill" in declared, (
|
|
83
|
-
"http2py must declare `dill` while i2mint/ju#6 is open, otherwise a cold "
|
|
84
|
-
"`pip install http2py` fails at `import ju`."
|
|
85
|
-
)
|
|
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
|