http2py 0.1.38__tar.gz → 0.1.39__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 (37) hide show
  1. {http2py-0.1.38 → http2py-0.1.39}/PKG-INFO +1 -1
  2. {http2py-0.1.38 → http2py-0.1.39}/http2py/py2request.py +44 -1
  3. {http2py-0.1.38 → http2py-0.1.39}/pyproject.toml +1 -1
  4. http2py-0.1.39/tests/test_response_decoding.py +166 -0
  5. {http2py-0.1.38 → http2py-0.1.39}/tests/test_route_namespace.py +12 -0
  6. {http2py-0.1.38 → http2py-0.1.39}/.gitattributes +0 -0
  7. {http2py-0.1.38 → http2py-0.1.39}/.github/workflows/ci.yml +0 -0
  8. {http2py-0.1.38 → http2py-0.1.39}/.gitignore +0 -0
  9. {http2py-0.1.38 → http2py-0.1.39}/LICENSE +0 -0
  10. {http2py-0.1.38 → http2py-0.1.39}/README.md +0 -0
  11. {http2py-0.1.38 → http2py-0.1.39}/conftest.py +0 -0
  12. {http2py-0.1.38 → http2py-0.1.39}/http2py/__init__.py +0 -0
  13. {http2py-0.1.38 → http2py-0.1.39}/http2py/api_pkg_maker.py +0 -0
  14. {http2py-0.1.38 → http2py-0.1.39}/http2py/authentication.py +0 -0
  15. {http2py-0.1.38 → http2py-0.1.39}/http2py/cli_maker.py +0 -0
  16. {http2py-0.1.38 → http2py-0.1.39}/http2py/client.py +0 -0
  17. {http2py-0.1.38 → http2py-0.1.39}/http2py/constants.py +0 -0
  18. {http2py-0.1.38 → http2py-0.1.39}/http2py/decorators.py +0 -0
  19. {http2py-0.1.38 → http2py-0.1.39}/http2py/default_configs.py +0 -0
  20. {http2py-0.1.38 → http2py-0.1.39}/http2py/example_cli.py +0 -0
  21. {http2py-0.1.38 → http2py-0.1.39}/http2py/global_state.py +0 -0
  22. {http2py-0.1.38 → http2py-0.1.39}/http2py/http2py_cls.py +0 -0
  23. {http2py-0.1.38 → http2py-0.1.39}/http2py/resources/module_maker/func_tpl.txt +0 -0
  24. {http2py-0.1.38 → http2py-0.1.39}/http2py/testing_utils.py +0 -0
  25. {http2py-0.1.38 → http2py-0.1.39}/http2py/tests/__init__.py +0 -0
  26. {http2py-0.1.38 → http2py-0.1.39}/http2py/tests/api_pkg_maker_test.py +0 -0
  27. {http2py-0.1.38 → http2py-0.1.39}/http2py/tests/conftest.py +0 -0
  28. {http2py-0.1.38 → http2py-0.1.39}/http2py/tests/test_requirements.txt +0 -0
  29. {http2py-0.1.38 → http2py-0.1.39}/http2py/util.py +0 -0
  30. {http2py-0.1.38 → http2py-0.1.39}/tests/cli_goldens/mk_cli.json +0 -0
  31. {http2py-0.1.38 → http2py-0.1.39}/tests/spec_fixture.py +0 -0
  32. {http2py-0.1.38 → http2py-0.1.39}/tests/test_binary_payloads.py +0 -0
  33. {http2py-0.1.38 → http2py-0.1.39}/tests/test_ci_collection_contract.py +0 -0
  34. {http2py-0.1.38 → http2py-0.1.39}/tests/test_cli_surface.py +0 -0
  35. {http2py-0.1.38 → http2py-0.1.39}/tests/test_error_statuses.py +0 -0
  36. {http2py-0.1.38 → http2py-0.1.39}/tests/test_generated_clients.py +0 -0
  37. {http2py-0.1.38 → http2py-0.1.39}/tests/test_smoke.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: http2py
3
- Version: 0.1.38
3
+ Version: 0.1.39
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
@@ -200,6 +200,11 @@ def mk_request_function(
200
200
 
201
201
  original_spec = method_spec
202
202
  method_spec = method_spec.copy() # make a copy
203
+ if "url_template" in method_spec:
204
+ check_url_template(
205
+ method_spec["url_template"],
206
+ query_arg_names=_ensure_list(method_spec.get("query_arg_names", [])),
207
+ )
203
208
  method_spec["input_trans"] = method_spec.get("input_trans", None) or {}
204
209
 
205
210
  request_kwargs = method_spec.get("request_kwargs", {}).copy()
@@ -285,7 +290,7 @@ def mk_request_function(
285
290
  _request_kwargs["data"] = json.dumps(json_kwargs)
286
291
  elif content_type == BINARY_CONTENT_TYPE:
287
292
  _request_kwargs["data"] = binary_dumps(kwargs)
288
- elif content_type == FORM_CONTENT_TYPE:
293
+ if content_type == FORM_CONTENT_TYPE: # (it used to be sent with GET only)
289
294
  fields = json.dumps(json_kwargs).encode("utf-8")
290
295
  binaries = {k: (f"{k}.bin", v) for k, v in binary_kwargs.items()}
291
296
  files_data = dict(binaries, __fields=fields)
@@ -502,6 +507,44 @@ def _difference_conserving_order(a, b):
502
507
  return [x for x in a if x not in b]
503
508
 
504
509
 
510
+ def _reads_into_arguments(field_name: str) -> bool:
511
+ """Whether a ``str.format`` field looks up an attribute or item of an argument;
512
+ any other name (``user-id``, ``0``...) only selects an argument."""
513
+ return "." in field_name or "[" in field_name
514
+
515
+
516
+ def check_url_template(url_template: str, *, query_arg_names=()) -> str:
517
+ """Return ``url_template`` if no placeholder in it, nor any of the
518
+ ``query_arg_names`` (appended as ``?name={name}`` when calling), reads into an
519
+ argument.
520
+
521
+ A template is filled with ``str.format(**arguments)``, and it may come from a spec
522
+ fetched from a server. ``{a.__class__}`` or ``{a[0]}`` would read attributes or
523
+ items of the caller's arguments into the URL, so they are refused. Other names,
524
+ such as OpenAPI's hyphenated ``{user-id}``, are fine.
525
+
526
+ >>> check_url_template('http://h/u/{user-id}?q={q}')
527
+ 'http://h/u/{user-id}?q={q}'
528
+ >>> check_url_template('http://h/{a.__init__.__globals__}')
529
+ Traceback (most recent call last):
530
+ ...
531
+ ValueError: URL template placeholder '{a.__init__.__globals__}' reads into an argument
532
+ """
533
+ fields = [
534
+ (field_name, format_spec)
535
+ for _, field_name, format_spec, _ in str_formatter.parse(url_template)
536
+ if field_name is not None
537
+ ]
538
+ fields += [(name, "") for name in query_arg_names]
539
+ for field_name, format_spec in fields:
540
+ if _reads_into_arguments(field_name) or "{" in (format_spec or ""):
541
+ raise ValueError(
542
+ f"URL template placeholder {'{' + field_name + '}'!r} reads into an "
543
+ "argument"
544
+ )
545
+ return url_template
546
+
547
+
505
548
  def extract_format_names(string_template):
506
549
  """
507
550
  Extracts the names of the fields in a string template, returning a list of
@@ -6,7 +6,7 @@ build-backend = "hatchling.build"
6
6
 
7
7
  [project]
8
8
  name = "http2py"
9
- version = "0.1.38"
9
+ version = "0.1.39"
10
10
  description = "Tools to create python binders to http web services."
11
11
  readme = "README.md"
12
12
  requires-python = ">=3.10"
@@ -0,0 +1,166 @@
1
+ """How responses are decoded (``http2py.decorators``) and how a client's requests are
2
+ built from a spec it may have fetched from a server (``mk_request_function``)."""
3
+
4
+ import pickle
5
+ import warnings
6
+
7
+ import pytest
8
+
9
+ from http2py.decorators import (
10
+ BinaryResponseNotDecoded,
11
+ check_loads,
12
+ handle_binary_resp,
13
+ unsafe_pickle_loads,
14
+ )
15
+ from http2py.py2request import mk_request_function
16
+
17
+
18
+ class Resp:
19
+ def __init__(self, content=b"", *, status_code=200, text="", reason="OK"):
20
+ self.content, self.status_code, self.text, self.reason = (
21
+ content,
22
+ status_code,
23
+ text,
24
+ reason,
25
+ )
26
+
27
+
28
+ def _identity(x):
29
+ return x
30
+
31
+
32
+ def test_binary_response_needs_an_explicit_decoder():
33
+ with pytest.raises(BinaryResponseNotDecoded):
34
+ handle_binary_resp(_identity)(Resp(pickle.dumps(1)))
35
+ assert handle_binary_resp(_identity, loads=bytes)(Resp(b"raw")) == b"raw"
36
+ trusted = handle_binary_resp(_identity, loads=unsafe_pickle_loads)
37
+ assert trusted(Resp(pickle.dumps({"a": 1}))) == {"a": 1}
38
+
39
+
40
+ def test_check_loads():
41
+ check_loads(None)
42
+ check_loads(bytes)
43
+ with pytest.raises(TypeError):
44
+ check_loads("pickle.loads")
45
+ with pytest.warns(UserWarning, match="unsafe_pickle_loads"):
46
+ check_loads(pickle.loads)
47
+ with warnings.catch_warnings():
48
+ warnings.simplefilter("error")
49
+ check_loads(unsafe_pickle_loads) # the explicit opt-in does not warn
50
+
51
+
52
+ def test_non_200_binary_response_raises_the_mapped_error():
53
+ from i2.errors import NotFoundError
54
+
55
+ with pytest.raises(NotFoundError):
56
+ handle_binary_resp(_identity, loads=bytes)(Resp(status_code=404, text="no"))
57
+
58
+
59
+ def _captured_request(method_spec, **call_kwargs):
60
+ """The (method, url, request kwargs) a request function would dispatch."""
61
+ calls = []
62
+
63
+ def dispatch(method, url, **kwargs):
64
+ calls.append((method, url, kwargs))
65
+ return Resp(b"{}")
66
+
67
+ func = mk_request_function(method_spec, dispatch=dispatch)
68
+ func(None, **call_kwargs)
69
+ return calls[0]
70
+
71
+
72
+ def test_binary_request_bodies_use_binary_dumps():
73
+ spec = {
74
+ "method": "POST",
75
+ "url_template": "http://h/f",
76
+ "body_arg_names": ["x"],
77
+ "content_type": "application/octet-stream",
78
+ "output_trans": _identity,
79
+ }
80
+ _, _, kwargs = _captured_request(spec, x=1)
81
+ assert pickle.loads(kwargs["data"]) == {"x": 1}
82
+ calls = []
83
+ func = mk_request_function(
84
+ spec,
85
+ dispatch=lambda m, u, **kw: calls.append(kw) or Resp(),
86
+ binary_dumps=lambda d: b"custom",
87
+ )
88
+ func(None, x=1)
89
+ assert calls[0]["data"] == b"custom"
90
+
91
+
92
+ def test_path_args_are_url_encoded():
93
+ spec = {
94
+ "method": "GET",
95
+ "url_template": "http://h/u/{uid}",
96
+ "path_arg_names": ["uid"],
97
+ "output_trans": _identity,
98
+ }
99
+ _, url, _ = _captured_request(spec, uid="a/b c")
100
+ assert url == "http://h/u/a%2Fb%20c"
101
+
102
+
103
+ @pytest.mark.parametrize(
104
+ "template",
105
+ [
106
+ "http://h/{x.__class__}",
107
+ "http://h/{x[0]}",
108
+ "http://h/{x:{x.__class__}}",
109
+ "http://h/{x!r:{y}}",
110
+ ],
111
+ )
112
+ def test_url_templates_may_only_name_arguments(template):
113
+ """A spec served by a remote must not read attributes of the caller's
114
+ arguments into the URL."""
115
+ spec = {"method": "GET", "url_template": template, "path_arg_names": ["x"]}
116
+ with pytest.raises(ValueError, match="reads into an argument"):
117
+ mk_request_function(spec)
118
+
119
+
120
+ def test_query_arg_names_may_only_name_arguments():
121
+ spec = {
122
+ "method": "GET",
123
+ "url_template": "http://h/x",
124
+ "query_arg_names": ["obj", "obj.token"],
125
+ }
126
+ with pytest.raises(ValueError, match="reads into an argument"):
127
+ mk_request_function(spec)
128
+
129
+
130
+ def test_hyphenated_placeholders_still_work():
131
+ spec = {"method": "GET", "url_template": "http://h/u/{user-id}"}
132
+ calls = []
133
+ func = mk_request_function(
134
+ spec, dispatch=lambda m, u, **kw: calls.append(u) or Resp(b"")
135
+ )
136
+ func(None, **{"user-id": "42"})
137
+ assert calls == ["http://h/u/42"]
138
+
139
+
140
+ def test_spec_with_attribute_placeholder_is_refused_by_the_client():
141
+ import copy
142
+ import sys
143
+ from pathlib import Path
144
+
145
+ sys.path.insert(0, str(Path(__file__).parent))
146
+ from spec_fixture import SPEC
147
+
148
+ from http2py import HttpClient
149
+
150
+ spec = copy.deepcopy(SPEC)
151
+ spec["paths"]["/x/{uid.__class__.__mro__}"] = spec["paths"].pop("/u/{uid}/p/{pid}")
152
+ with pytest.raises(ValueError, match="reads into an argument"):
153
+ HttpClient(spec, session_state={"session": object(), "refresh_inputs": {}})
154
+
155
+
156
+ @pytest.mark.parametrize("method", ["GET", "POST"])
157
+ def test_form_requests_send_their_fields_whatever_the_method(method):
158
+ spec = {
159
+ "method": method,
160
+ "url_template": "http://h/f",
161
+ "body_arg_names": ["x", "blob"],
162
+ "content_type": "multipart/form-data",
163
+ "output_trans": _identity,
164
+ }
165
+ _, _, kwargs = _captured_request(spec, x=1, blob=b"\x00")
166
+ assert set(kwargs["files"]) == {"blob", "__fields"}
@@ -83,6 +83,7 @@ def test_cli_calls_the_route_not_the_client_attribute(monkeypatch):
83
83
  spec = _spec_naming_plain("ensure_login")
84
84
  with pytest.warns(UserWarning):
85
85
  route = _client(spec).routes["ensure_login"]
86
+
86
87
  class Sent(Exception):
87
88
  pass
88
89
 
@@ -162,3 +163,14 @@ def test_unusable_operation_id_falls_back_to_method_and_path(operation_id):
162
163
  spec = _spec_without_names()
163
164
  spec["paths"]["/plain"]["get"]["operationId"] = operation_id
164
165
  assert "get_plain" in _client(spec).routes
166
+
167
+
168
+ def test_a_second_route_of_the_same_name_replaces_the_first_with_a_warning():
169
+ spec = copy.deepcopy(SPEC)
170
+ other = copy.deepcopy(spec["paths"]["/plain"])
171
+ spec["paths"]["/plain2"] = other
172
+ with pytest.warns(UserWarning, match="Two routes are named"):
173
+ client = _client(spec)
174
+ name = other["get"]["x-method_name"]
175
+ assert client.routes[name].method_spec["url_template"].endswith("/plain2")
176
+ assert getattr(client, name) is client.routes[name]
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