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.
Files changed (35) hide show
  1. {http2py-0.1.34 → http2py-0.1.35}/PKG-INFO +1 -2
  2. {http2py-0.1.34 → http2py-0.1.35}/http2py/client.py +20 -1
  3. http2py-0.1.35/http2py/decorators.py +142 -0
  4. {http2py-0.1.34 → http2py-0.1.35}/http2py/py2request.py +52 -7
  5. {http2py-0.1.34 → http2py-0.1.35}/pyproject.toml +1 -7
  6. http2py-0.1.35/tests/test_binary_payloads.py +189 -0
  7. http2py-0.1.34/http2py/decorators.py +0 -59
  8. http2py-0.1.34/tests/test_dependency_workarounds.py +0 -85
  9. {http2py-0.1.34 → http2py-0.1.35}/.gitattributes +0 -0
  10. {http2py-0.1.34 → http2py-0.1.35}/.github/workflows/ci.yml +0 -0
  11. {http2py-0.1.34 → http2py-0.1.35}/.gitignore +0 -0
  12. {http2py-0.1.34 → http2py-0.1.35}/LICENSE +0 -0
  13. {http2py-0.1.34 → http2py-0.1.35}/README.md +0 -0
  14. {http2py-0.1.34 → http2py-0.1.35}/conftest.py +0 -0
  15. {http2py-0.1.34 → http2py-0.1.35}/http2py/__init__.py +0 -0
  16. {http2py-0.1.34 → http2py-0.1.35}/http2py/api_pkg_maker.py +0 -0
  17. {http2py-0.1.34 → http2py-0.1.35}/http2py/authentication.py +0 -0
  18. {http2py-0.1.34 → http2py-0.1.35}/http2py/cli_maker.py +0 -0
  19. {http2py-0.1.34 → http2py-0.1.35}/http2py/constants.py +0 -0
  20. {http2py-0.1.34 → http2py-0.1.35}/http2py/default_configs.py +0 -0
  21. {http2py-0.1.34 → http2py-0.1.35}/http2py/example_cli.py +0 -0
  22. {http2py-0.1.34 → http2py-0.1.35}/http2py/global_state.py +0 -0
  23. {http2py-0.1.34 → http2py-0.1.35}/http2py/http2py_cls.py +0 -0
  24. {http2py-0.1.34 → http2py-0.1.35}/http2py/resources/module_maker/func_tpl.txt +0 -0
  25. {http2py-0.1.34 → http2py-0.1.35}/http2py/testing_utils.py +0 -0
  26. {http2py-0.1.34 → http2py-0.1.35}/http2py/tests/__init__.py +0 -0
  27. {http2py-0.1.34 → http2py-0.1.35}/http2py/tests/api_pkg_maker_test.py +0 -0
  28. {http2py-0.1.34 → http2py-0.1.35}/http2py/tests/conftest.py +0 -0
  29. {http2py-0.1.34 → http2py-0.1.35}/http2py/tests/test_requirements.txt +0 -0
  30. {http2py-0.1.34 → http2py-0.1.35}/http2py/util.py +0 -0
  31. {http2py-0.1.34 → http2py-0.1.35}/tests/cli_goldens/mk_cli.json +0 -0
  32. {http2py-0.1.34 → http2py-0.1.35}/tests/spec_fixture.py +0 -0
  33. {http2py-0.1.34 → http2py-0.1.35}/tests/test_ci_collection_contract.py +0 -0
  34. {http2py-0.1.34 → http2py-0.1.35}/tests/test_cli_surface.py +0 -0
  35. {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.34
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, dispatch=self.handle_request, verify_cert=self.verify_cert
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
- default_binary_output_trans,
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, *, function_kind="method", dispatch=request, verify_cert=True
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 = default_binary_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"] = pickle.dumps(kwargs)
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
- if self._dflt_method_func_from_method_spec == mk_request_function: # TODO: WTF!
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(method_spec, function_kind="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(uri, patched_spec, method=method)
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.34"
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