http2py 0.1.37__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.37 → http2py-0.1.39}/PKG-INFO +1 -1
  2. {http2py-0.1.37 → http2py-0.1.39}/http2py/api_pkg_maker.py +5 -1
  3. {http2py-0.1.37 → http2py-0.1.39}/http2py/client.py +7 -2
  4. {http2py-0.1.37 → http2py-0.1.39}/http2py/py2request.py +72 -2
  5. {http2py-0.1.37 → http2py-0.1.39}/pyproject.toml +1 -1
  6. http2py-0.1.39/tests/test_response_decoding.py +166 -0
  7. {http2py-0.1.37 → http2py-0.1.39}/tests/test_route_namespace.py +70 -0
  8. {http2py-0.1.37 → http2py-0.1.39}/.gitattributes +0 -0
  9. {http2py-0.1.37 → http2py-0.1.39}/.github/workflows/ci.yml +0 -0
  10. {http2py-0.1.37 → http2py-0.1.39}/.gitignore +0 -0
  11. {http2py-0.1.37 → http2py-0.1.39}/LICENSE +0 -0
  12. {http2py-0.1.37 → http2py-0.1.39}/README.md +0 -0
  13. {http2py-0.1.37 → http2py-0.1.39}/conftest.py +0 -0
  14. {http2py-0.1.37 → http2py-0.1.39}/http2py/__init__.py +0 -0
  15. {http2py-0.1.37 → http2py-0.1.39}/http2py/authentication.py +0 -0
  16. {http2py-0.1.37 → http2py-0.1.39}/http2py/cli_maker.py +0 -0
  17. {http2py-0.1.37 → http2py-0.1.39}/http2py/constants.py +0 -0
  18. {http2py-0.1.37 → http2py-0.1.39}/http2py/decorators.py +0 -0
  19. {http2py-0.1.37 → http2py-0.1.39}/http2py/default_configs.py +0 -0
  20. {http2py-0.1.37 → http2py-0.1.39}/http2py/example_cli.py +0 -0
  21. {http2py-0.1.37 → http2py-0.1.39}/http2py/global_state.py +0 -0
  22. {http2py-0.1.37 → http2py-0.1.39}/http2py/http2py_cls.py +0 -0
  23. {http2py-0.1.37 → http2py-0.1.39}/http2py/resources/module_maker/func_tpl.txt +0 -0
  24. {http2py-0.1.37 → http2py-0.1.39}/http2py/testing_utils.py +0 -0
  25. {http2py-0.1.37 → http2py-0.1.39}/http2py/tests/__init__.py +0 -0
  26. {http2py-0.1.37 → http2py-0.1.39}/http2py/tests/api_pkg_maker_test.py +0 -0
  27. {http2py-0.1.37 → http2py-0.1.39}/http2py/tests/conftest.py +0 -0
  28. {http2py-0.1.37 → http2py-0.1.39}/http2py/tests/test_requirements.txt +0 -0
  29. {http2py-0.1.37 → http2py-0.1.39}/http2py/util.py +0 -0
  30. {http2py-0.1.37 → http2py-0.1.39}/tests/cli_goldens/mk_cli.json +0 -0
  31. {http2py-0.1.37 → http2py-0.1.39}/tests/spec_fixture.py +0 -0
  32. {http2py-0.1.37 → http2py-0.1.39}/tests/test_binary_payloads.py +0 -0
  33. {http2py-0.1.37 → http2py-0.1.39}/tests/test_ci_collection_contract.py +0 -0
  34. {http2py-0.1.37 → http2py-0.1.39}/tests/test_cli_surface.py +0 -0
  35. {http2py-0.1.37 → http2py-0.1.39}/tests/test_error_statuses.py +0 -0
  36. {http2py-0.1.37 → http2py-0.1.39}/tests/test_generated_clients.py +0 -0
  37. {http2py-0.1.37 → 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.37
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
@@ -84,6 +84,9 @@ def {func_name}(*args, **kwargs):
84
84
  return api.routes[{func_name!r}](*args, **kwargs)
85
85
  """
86
86
 
87
+ # The first http2py release with ``HttpClient.routes``, which generated packages call.
88
+ MIN_HTTP2PY_VERSION = "0.1.37"
89
+
87
90
  SETUP_PY = """from setuptools import setup
88
91
 
89
92
  setup()
@@ -103,7 +106,7 @@ include_package_data = True
103
106
  zip_safe = False
104
107
  install_requires =
105
108
  i2
106
- http2py
109
+ http2py>={min_http2py}
107
110
 
108
111
  [options.package_data]
109
112
  * = {spec_filename}
@@ -215,6 +218,7 @@ def api_pkg_files(
215
218
  author=_cfg_value(pkg_author or "API Package Maker"),
216
219
  license=_cfg_value(pkg_license or "Apache"),
217
220
  spec_filename=SPEC_FILENAME,
221
+ min_http2py=MIN_HTTP2PY_VERSION,
218
222
  )
219
223
  funcs_code = FUNCS_FILE_TPL.format(
220
224
  spec_filename=SPEC_FILENAME,
@@ -115,7 +115,9 @@ class HttpClient:
115
115
  for pathname, path_spec in openapi_spec["paths"].items():
116
116
  url_template = self.base_url + pathname
117
117
  for http_method, openapi_method_spec in path_spec.items():
118
- self.register_method(url_template, http_method, openapi_method_spec)
118
+ self.register_method(
119
+ url_template, http_method, openapi_method_spec, pathname=pathname
120
+ )
119
121
 
120
122
  def init_security(self, openapi_spec, **auth_kwargs):
121
123
  security = openapi_spec["security"]
@@ -143,7 +145,9 @@ class HttpClient:
143
145
  if jwt:
144
146
  self.set_header({"Authorization": f"Bearer {jwt}"})
145
147
 
146
- def register_method(self, url_template, http_method, openapi_method_spec):
148
+ def register_method(
149
+ self, url_template, http_method, openapi_method_spec, *, pathname=None
150
+ ):
147
151
  content_type = None
148
152
  if "requestBody" in openapi_method_spec:
149
153
  content_type = next(
@@ -154,6 +158,7 @@ class HttpClient:
154
158
  method=http_method,
155
159
  url_template=url_template,
156
160
  content_type=content_type,
161
+ pathname=pathname,
157
162
  )
158
163
  func = mk_request_function(
159
164
  method_spec,
@@ -33,11 +33,13 @@ There must be a better way...
33
33
  from functools import wraps
34
34
  from inspect import signature, Parameter
35
35
  import json
36
+ import keyword
36
37
  import pickle
37
38
  from glom import glom
38
39
  from requests import request
39
40
  import string
40
41
  import urllib
42
+ from urllib.parse import urlparse
41
43
 
42
44
  # import io
43
45
  import re
@@ -198,6 +200,11 @@ def mk_request_function(
198
200
 
199
201
  original_spec = method_spec
200
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
+ )
201
208
  method_spec["input_trans"] = method_spec.get("input_trans", None) or {}
202
209
 
203
210
  request_kwargs = method_spec.get("request_kwargs", {}).copy()
@@ -283,7 +290,7 @@ def mk_request_function(
283
290
  _request_kwargs["data"] = json.dumps(json_kwargs)
284
291
  elif content_type == BINARY_CONTENT_TYPE:
285
292
  _request_kwargs["data"] = binary_dumps(kwargs)
286
- elif content_type == FORM_CONTENT_TYPE:
293
+ if content_type == FORM_CONTENT_TYPE: # (it used to be sent with GET only)
287
294
  fields = json.dumps(json_kwargs).encode("utf-8")
288
295
  binaries = {k: (f"{k}.bin", v) for k, v in binary_kwargs.items()}
289
296
  files_data = dict(binaries, __fields=fields)
@@ -500,6 +507,44 @@ def _difference_conserving_order(a, b):
500
507
  return [x for x in a if x not in b]
501
508
 
502
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
+
503
548
  def extract_format_names(string_template):
504
549
  """
505
550
  Extracts the names of the fields in a string template, returning a list of
@@ -607,6 +652,30 @@ def raw_response_on_error(func):
607
652
  return _func
608
653
 
609
654
 
655
+ def _identifier(text):
656
+ """``text`` as a public Python identifier: runs of other characters (and of ``_``) become ``_``,
657
+ a leading digit gets an ``op_`` prefix and a keyword a trailing ``_``."""
658
+ name = re.sub(r"[\W_]+", "_", text, flags=re.ASCII).strip("_")
659
+ if name[:1].isdigit():
660
+ name = f"op_{name}"
661
+ return f"{name}_" if keyword.iskeyword(name) else name
662
+
663
+
664
+ def _route_name(openapi_method_spec, method, pathname, url_template):
665
+ """The name of the function for a route: its ``x-method_name``, else its
666
+ ``operationId``, else the http method and the path (``get /users/{id}`` gives
667
+ ``get_users_id``), so that routes without ``x-method_name`` do not all share a name.
668
+ ``pathname`` is the path as the spec gives it, so that a server's base url does not
669
+ change the names; it defaults to the path of ``url_template``.
670
+ """
671
+ if openapi_method_spec.get("x-method_name"):
672
+ return openapi_method_spec["x-method_name"]
673
+ name = _identifier(openapi_method_spec.get("operationId") or "")
674
+ if pathname is None:
675
+ pathname = urlparse(url_template).path
676
+ return name or default_path_to_func_name(method, pathname)
677
+
678
+
610
679
  def mk_method_spec_from_openapi_method_spec(
611
680
  openapi_method_spec,
612
681
  method="post",
@@ -614,6 +683,7 @@ def mk_method_spec_from_openapi_method_spec(
614
683
  content_type=JSON_CONTENT_TYPE,
615
684
  input_trans=None,
616
685
  output_trans=None,
686
+ pathname=None,
617
687
  ):
618
688
  path_arg_names = []
619
689
  query_arg_names = []
@@ -671,7 +741,7 @@ def mk_method_spec_from_openapi_method_spec(
671
741
  path_arg_names=path_arg_names,
672
742
  query_arg_names=query_arg_names,
673
743
  body_arg_names=body_arg_names,
674
- method_name=openapi_method_spec.get("x-method_name", ""),
744
+ method_name=_route_name(openapi_method_spec, method, pathname, url_template),
675
745
  docstring=openapi_method_spec.get("description", ""),
676
746
  content_type=content_type,
677
747
  response_type=next(iter(glom(openapi_method_spec, f"responses.200.content"))),
@@ -6,7 +6,7 @@ build-backend = "hatchling.build"
6
6
 
7
7
  [project]
8
8
  name = "http2py"
9
- version = "0.1.37"
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
 
@@ -104,3 +105,72 @@ def test_subclass_skipping_init_can_register_routes():
104
105
  spec = SPEC["paths"]["/plain"]["get"]
105
106
  bare.register_method("https://example.com/plain", "get", spec)
106
107
  assert bare.routes["plain"] is bare.plain
108
+
109
+
110
+ def _spec_without_names():
111
+ """A spec whose routes have no ``x-method_name`` (i2mint/http2py#26)."""
112
+ spec = copy.deepcopy(SPEC)
113
+ for path_item in spec["paths"].values():
114
+ for op in path_item.values():
115
+ op.pop("x-method_name", None)
116
+ return spec
117
+
118
+
119
+ def test_routes_without_x_method_name_get_distinct_names_from_method_and_path():
120
+ import warnings
121
+
122
+ with warnings.catch_warnings():
123
+ warnings.simplefilter("error")
124
+ client = _client(_spec_without_names())
125
+ assert len(client.routes) == len(SPEC["paths"]) == 3
126
+ assert "get_plain" in client.routes
127
+ for name, route in client.routes.items():
128
+ assert route.__name__ == name != "request_func"
129
+
130
+
131
+ def test_operation_id_names_a_route_without_x_method_name():
132
+ spec = _spec_without_names()
133
+ spec["paths"]["/plain"]["get"]["operationId"] = "list-plain.things"
134
+ assert "list_plain_things" in _client(spec).routes
135
+
136
+
137
+ def test_x_method_name_wins_over_operation_id():
138
+ spec = _spec_naming_plain("mine")
139
+ spec["paths"]["/plain"]["get"]["operationId"] = "theirs"
140
+ assert "mine" in _client(spec).routes
141
+
142
+
143
+ def test_api_pkg_for_a_spec_without_x_method_names():
144
+ files = api_pkg_files(_spec_without_names(), pkg_name="genpkg_d")
145
+ assert "def get_plain(" in files["genpkg_d/funcs.py"]
146
+
147
+
148
+ def test_generated_package_pins_the_http2py_that_has_client_routes():
149
+ from http2py.api_pkg_maker import MIN_HTTP2PY_VERSION
150
+
151
+ files = api_pkg_files(SPEC, pkg_name="genpkg_e")
152
+ assert f"http2py>={MIN_HTTP2PY_VERSION}" in files["setup.cfg"]
153
+
154
+
155
+ def test_derived_names_do_not_depend_on_the_server_url():
156
+ spec = _spec_without_names()
157
+ spec["servers"] = [{"url": "https://example.com/v1/api"}]
158
+ assert set(_client(spec).routes) == set(_client(_spec_without_names()).routes)
159
+
160
+
161
+ @pytest.mark.parametrize("operation_id", ["-", "___", "日本"])
162
+ def test_unusable_operation_id_falls_back_to_method_and_path(operation_id):
163
+ spec = _spec_without_names()
164
+ spec["paths"]["/plain"]["get"]["operationId"] = operation_id
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